花椒前端用WebAssembly提升前端应用解压缩性能的尝试

奇技指南

WebAssembly提升前端应用解压缩性能的尝试

背景

3D形象展示项目的图片及模型等资源以压缩包的形式提供,需要下载并解压后再用Three.js加载并展示出来,其中的解压缩环节使用的是GitHub上获得5.6k Star的JS开源组件库JSZip。经过不断的优化,解压缩的性能已经有了较大提升,从几百毫秒降低到一百多甚至几十毫秒。
压缩和解压缩属于CPU密集型计算任务,相对于JavaScript这样的解释型语言来说,C作为编译型语言更加适合,于是有了尝试把C解压缩程序编译为WebAssembly替换JSZip解压缩环节的想法,看看性能是否还会有进一步的提升。

创建WebAssembly(Wasm)

Emscripten是一套用于把C/C++代码编译为Wasm的工具集合,通过这套工具集可以把C/C++代码编译为Wasm字节码加载进浏览器、转换为机器码运行,保证了相对较高的计算性能,并且可以与JavaScript互相调用和传递数据。
本着不轻易制造轮子的原则,开源的C压缩/解压缩程序库Zip正适合我们的需要,它是从MiniZ项目中剥离出来的,简单易用、功能强大,我们的场景会使用到它unzip部分的功能。
Zip库的主要源文件只有三个,分别是miniz.h、zip.h、zip.c,我们需要编写代码调用Zip提供的相关API来实现解压缩功能,代码很简单,只有短短数行

#include 

#include 

#include 

#include "zip/src/zip.h"


EMSCRIPTEN_KEEPALIVE int load_zip_data(void (*callback)(void *buf, int, const char*, int, int)) { struct zip_t *zip = zip_open("archive.zip", 0, 'r'); int i, n = zip_total_entries(zip); void *buf = NULL; size_t bufSize; for (i = 0; i < n; i++) { zip_entry_openbyindex(zip, i); { const char *name = zip_entry_name(zip); zip_entry_open(zip, name); { zip_entry_read(zip, &buf, &bufSize); } callback(buf, bufSize, name, i, n); } zip_entry_close(zip); free(buf); } zip_close(zip); return n; }

EMSCRIPTEN_KEEPALIVE是emscripten.h中定义的一个宏,用于防止C/C++编译器把没有被调用的函数或代码段删除,即DCE(Dead Code Elimination)。
从导出C函数的角度来说,它与在命令行里指定 -s EXPORTED_FUNCTIONS=”[‘_load_zip_data’]”具有相同的作用。

load_zip_data函数的调用参数是一个函数指针(Function Pointer),用于回调JavaScript方法,传回压缩包中的文件数据、文件名、文件索引index和压缩包中全部的文件数。
如果一个函数指针指向的函数需要在多个地方调用的话,也可以用typedef定义一个类型以方便复用,比如:

typedef void(*callback)(void *buf, int size, const char* name, int i, int n);

现在我们可以用emsdk提供的命令把上面的代码与Zip的源文件编译生成Wasm了,命令如下:

emcc c/unzip.c c/zip/src/zip.c \

       -o unzip/unzip.js \

       -O3 \

       -s WASM=1 \

       -s FORCE_FILESYSTEM=1 \

       -s EXTRA_EXPORTED_RUNTIME_METHODS="['cwrap', 'addFunction', 'UTF8ToString', 'FS']" \

       -s RESERVED_FUNCTION_POINTERS=1 \

       -s MODULARIZE=1 \

       -s ENVIRONMENT='worker' \

       -s ASSERTIONS=1 \

       -s EXPORT_ES6=1

上面的命令会在unzip目录下生成一个unzip.wasm和对应的胶水JS代码unzip.js,unzip.wasm支持操作一个虚拟的文件系统,支持ES6语法,预留一个存放函数指针的单元,支持在Web Worker内使用。编译出来的Wasm大小在65k,加载耗时在几十毫秒左右。

使用Web Worker加载WebAssembly

JavaScript运行时只有一个主线程(UI线程),而Wasm的加载、编译、实例化、下载压缩包、解压文件这些工作如果都放在主线程执行会严重影响页面性能,所以可以把这些都放进Web Worker中以单独的线程去执行,减轻主线程的压力。
使用Web Worker的好处显而易见,但同时也会有更高的初始启动成本和更多的内存占用,所以Web Worker的数量不宜过多,而且最好用于长生命周期功能的使用。
在我们的使用场景里,主线程会首先初始化一些Three.js的组件,比如Scene、Camera、Renderer等,之后才可以加载模型和素材资源,而压缩包的解压必须要在Wasm加载和初始化之后才能进行,解压出资源后才能提供给Three.js去处理,由此可见,主线程和Worker线程之间的交互时序非常重要。具体交互时序如下图所示:


Worker中下载、编译、实例化Wasm代码如下:

import getModule from '../unzip/unzip';


let wasmResolve; let wasmReady = new Promise((resolve) => { wasmResolve = resolve; });
const Module = getModule({ onRuntimeInitialized() { onWasmLoaded(); }, instantiateWasm(importObject, successCallback) { self.fetch('unzip.wasm', { mode: 'cors', }).then((response) => { if (response.ok) { return response.arrayBuffer(); } throw Error(response.status); }).then((wasmBinary) => { WebAssembly.instantiate(new Uint8Array(wasmBinary), importObject) .then((output) => { wasmResolve(output.instance); successCallback(output.instance); }) .catch((e) => { console.warn(`[js] wasm instantiation failed! ${e}`); }); }); return {}; }, print(text) { console.log(text); }, printErr(err) { console.error(err); }, });

当Wasm实例化完成之后,会调用onWasmLoaded方法,在这个方法里我们可以定义两个用于JavaScript调用Wasm内的C函数的方法和一个给Wasm回调传回解压后数据的回调函数指针,postMessage用于通知主线程Wasm已经初始化完毕:

function onWasmLoaded() {

    self._loadZipEntryData = Module.cwrap('load_zip_data', 'number', ['number']);

    self._addZipEntryDataPtr = Module.addFunction(addZipEntryData.bind(this));

    postMessage({

        type: 'inited'

    });

}

cwrap是Emscripten提供的用于封装C函数给JavaScript调用的工具函数,类似功能的还有一个ccall,在用法上有一些不同。cwrap的三个参数分别是C函数名、返回值类型、调用参数类型数组,ccall的参数除了这三个之外还多一个实际参数的数组。cwrap很像是封装一个柯里化函数供JS调用,而ccall则是带实参的直接调用。

addFunction是另一个由Emscripten提供的工具函数,用于向Emscripten运行时的函数指针数组动态添加函数指针,与之对应的是移除函数指针的工具函数removeFunction,要使用这一组工具函数,需要在编译参数中指明:

-s EXTRA_EXPORTED_RUNTIME_METHODS="['addFunction','removeFunction']"

_loadZipEntryData 和 _addZipEntryDataPtr定义好之后,让我们来看看怎么使用它们。

Emscripten通过FS库提供对一个虚拟文件系统的读写操作,在我们的场景中,Fetch到的压缩包数据会被写入到这个虚拟文件系统中,并被命名为archive.zip,然后调用Wasm中的load_zip_data函数进行解压缩处理:

fetch(url).then((res) => {

    res.arrayBuffer().then((buffer) => {

        loadZipEntryData(buffer);

    });

});


...
function loadZipEntryData(zipBuffer) { Module.FS.writeFile('archive.zip', new Uint8Array(zipBuffer)); self._loadZipEntryData(self._addZipEntryDataPtr); }

上面最后这一行就是调用Wasm中的load_zip_data函数,传入的参数是JavaScript里面用于接收解压出的文件数据的回调函数指针。
load_zip_data函数会遍历压缩包中的每一个文件,并调用回调函数传回每个文件数据在虚拟文件系统内的起始地址、数据大小、文件名、在压缩包中的索引i和压缩包中的全部文件数n,其中后两个参数用于判断当前压缩包是否已经全部解压完毕。

callback(buf, bufSize, name, i, n);

在JavaScript里面接收到文件数据后,根据业务需要做下一步处理,如过滤掉不需要的文件,并在一个压缩包解压完全部有效文件后通过postMessage把文件集合发送给主线程:

let obj = {};

function addZipEntryData(buff, size, namePtr, i, n) {

    const outArray = Module.HEAPU8.subarray(buff, buff + size);

    const fileName = Module.UTF8ToString(namePtr);

    if(fileName.indexOf('__') === -1) {

        const blob = new Blob([outArray]);

        obj[fileName] = URL.createObjectURL(blob);

    }

    if(i === (n -1)) {

        const o = {};

        Object.assign(o, obj);

        postMessage({

            url: zipUrl,

            files: o,

        });

        obj = {};

    }

}

测试与结论

现在让我们来看一下Wasm版的解压有没有一些性能提升。
测试方法是通过页面加载3次资源并渲染,资源共有10个压缩包,大小从几百k到2M+不等,整个流程包括下载、解压、加载三个部分,重点关注解压部分,对比JSZip和Wasm两个版本的处理耗时数据如下(测试使用Chrome浏览器):

从数据对比可以看到,JSZip版的解压在一开始时由于还没有JIT编译器对关键代码段进行优化,所以性能与Wasm版本有较大差距。
Wasm作为字节码加载到浏览器之后,只需要再转换一次到机器码,即可开始稳定工作,不需要经过浏览器引擎优化器的优化,所以从一开始的解压性能就比较平稳,不会有大的波动。
随着JIT编译器优化的启动,JSZip版本解压部分的代码由于会频繁执行,所以会被JIT编译器优化,标记为warm/hot/very hot,进而转换为机器码运行,性能得到了大幅提升,与Wasm版本较为接近了。

参考资料或网站

  • WebAssembly https://webassembly.org/
  • Emscripten https://emscripten.org/
  • Zip https://github.com/kuba–/zip

扫码关注我们

360技术公众号
技术干货|一手资讯|精彩活动