前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >花椒前端用WebAssembly提升前端应用解压缩性能的尝试

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

作者头像
ConardLi
发布2020-02-20 12:12:42
2.6K0
发布2020-02-20 12:12:42
举报
文章被收录于专栏:code秘密花园code秘密花园
一、背景

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版本较为接近了。

参考资料或网站
  1. WebAssembly https://webassembly.org/
  2. Emscripten https://emscripten.org/
  3. Zip https://github.com/kuba--/zip
本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-02-16,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 code秘密花园 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、背景
  • 二、创建WebAssembly(Wasm)
  • 三、使用Web Worker加载WebAssembly
  • 四、测试与结论
  • 参考资料或网站
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档