奇技指南

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 <stdio.h>

#include <stdlib.h>

#include <emscripten.h>

#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技术公众号

技术干货|一手资讯|精彩活动

相关文章