本文由 IMWeb 社区 imweb.io 授权转载自腾讯内部 KM 论坛,原作者:pepegao。点击阅读原文查看 IMWeb 社区更多精彩文章。
WebAssembly是一种预期可以与Javascript协同工作的二进制文件格式(.wasm),通过C/C++(或其他语言)的源代码可以编译出这种格式,在现代浏览器端直接运行。
在web端提起二进制,相信第一反应就是:执行速度快了。Javascript经过现代浏览器复杂的JIT优化,执行速度有了很大改善,但是还是无法与native的速度相比较。而且很多时候JIT能否生效取决于开发者的代码是如何写的,这里就有一些V8拒绝执行JIT的例子(https://github.com/petkaantonov/bluebird/wiki/Optimization-killers)。
在QQ企业邮箱中,有这样一个功能:上传附件。为了判断附件是不是已经上传过,上传前要对文件执行一次扫描。企业邮箱中扫描和上传附件,使用的是H5 FTN上传组件。后者由纯JS实现,扫描文件的速度可以达到40+M/s,相比上一个版本的Flash+H5的组件,速度已经提高了一倍以上。以下图为例,生产中测试,一个1.9G的附件,大约需要20-40秒(视机器情况)。对于一个命中秒传逻辑的附件(只需要一次轻量ajax请求就可以完成上传),扫描的时间就有些长了。所以想在这里看看WebAssembly有没有更好的表现。
查阅了相关的资料,决定按照官方推荐用emscripten来编译wasm。在这以前,对llvm-emscripten的机制也做了一些了解和尝试,整理了从源码到wasm应用在浏览器端的流程,如下图。
核心内容有三部分:LLVM,emscripten,js/wasm/浏览器通信。
LLVM本质上是一系列帮助开发编译器、解释器的SDK集合,按照传统编译器三段式的结构来说,更接近于优化层(Optimizer)和编译后端(Backend),而不是一个完整的编译器。
LLVM在优化层,对中间代码IR来执行代码的优化,它独特的地方在于IR的可读性很高。体验了一下,比如这样一个C文件test.c:
int main(){ int first = 3; int sum = first + 4; return 0;}
用Clang生成IR 文件test.ll
clang -S -emit-llvm test.c
然后打开生成的test.ll,找到了main函数倒数第三行,看起来有点像机器码。这里已经比较易懂了:将%4寄存器的值和立即数4相加,写入%5寄存器。
define i32 @main() #0 { %1 = alloca i32, align 4 %2 = alloca i32, align 4 %3 = alloca i32, align 4 store i32 0, i32* %1, align 4 store i32 3, i32* %2, align 4 %4 = load i32, i32* %2, align 4 %5 = add nsw i32 %4, 4 store i32 %5, i32* %3, align 4 ret i32 0}
除了代码性能优化,作为SDK集合的LLVM还提供了一些工具,用来支持代码复用、多语言、JIT,文档也比较友善,相比GCC感觉这里是加分项。
然后是编译前端,在现在版本的LLVM中,使用Clang(LLVM Native)来完成编译工作。如果想要用Clang不支持的语言来作为源码,比如Java,猜测也是可以的,因为我在LLVM的下载页看到3.0之前的版本可以用GCC编译,不过这一点这次还没有去验证。
生成LLVM IR后,LLVM的任务就完成了。emscripten的编译平台fastcomp负责将LLVM IR转化为特定的机器码或者其他目标语言(包括wasm)。在这里,emscripten其实扮演了编译器后端的角色(LLVM Backend)。
在安装emscripten的时候,有一步是安装它的编译工具链binaryen,有趣的是,它也是基于LLVM的,比如它的一种编译wasm的方式:
C/C++ Source -> WebAssembly LLVM backend -> s2wasm -> WebAssembly
刚刚说到,LLVM IR本身可读性较高,文档支持友善,代码复用容易,这都有助于开发者将LLVM IR中间代码封装为自己平台的中间代码,继而解释为汇编或者其他语言。其他编译工具(比如fastcomp,binaryen)广泛适用。(感觉又要加分了)
emscripten可以用emcc编出wasm文件、和wasm通信用的胶水js以及asm.js
(asm.js是js的子集,可以理解为wasm的前身也可以当作wasm的退化方案,数据都是强类型,用于优化js速度的,这次用不到,编译时可以用--separate-asm剥离开)。
js/wasm/浏览器的调用关系,可以用这张图来表示:
2. 胶水js一方面向业务暴露接口,另一方面要向wasm(二进制)传递数据;
//简化了部分内容function run(args) { if (runDependencies > 0) return; function doRun() { if (Module['_main'] && shouldRunNow) Module['callMain'](args); } doRun();}
main函数执行后,源码中声明的函数就进入了Runtime(浏览器端可调用的native code),并在浏览器端声明一个叫做Module的对象,通过它完成通信:
emscripten在胶水函数内部模拟了内存结构,最大16MB,单次操作的内存修改默认不能超过5MB,类型是js中的typedarray。typedarray对同一个arraybuffer的不同表现实现了不同结构,这里也一样。具体使用哪种要看实际需要,如果源码内的变量都是定长,比如4字节,那可以用HEAP32,会更接近native的表现。因为这里我们要操作文件内容,不定长,就选HEAP8了(8bit)。
function updateGlobalBufferViews() { Module['HEAP8'] = HEAP8 = new Int8Array(buffer); Module['HEAP16'] = HEAP16 = new Int16Array(buffer); Module['HEAP32'] = HEAP32 = new Int32Array(buffer); Module['HEAPU8'] = HEAPU8 = new Uint8Array(buffer); Module['HEAPU16'] = HEAPU16 = new Uint16Array(buffer); Module['HEAPU32'] = HEAPU32 = new Uint32Array(buffer); Module['HEAPF32'] = HEAPF32 = new Float32Array(buffer); Module['HEAPF64'] = HEAPF64 = new Float64Array(buffer);}
业务js可以调用cwrap(ccall等等)将数据以typedarray传递给emscripten,收到数据后,后者向Runtime申请指定大小的内存,返回内存的起始地址(ret),从这个地址开始,向Runtime写入数据。这个地址最终会作为参数传递给源码中的函数。
'arrayToC' : function(arr) { var ret = Runtime.stackAlloc(arr.length); writeArrayToMemory(arr, ret); return ret;}
wasm编译出来速度如何?准备了个demo做下简单验证。
准备好要编译的C代码,提供几个我们需要的基本函数,声明如下(具体实现是计算buffer的哈希,这里就未列出了):
void sha1_init();void sha1_update(const char *data, int len);char* sha1_final();void md5_init();void md5_update(const char *data, int len);char* md5_final();
在两个update函数中,参数data的指针就是上面说到的,指向了Runtime.stackAlloc分配的内存起始地址,len是该段内存的长度,该段内存的内容就是我们输入的文件/文件分片的buffer。
用emcc编出需要的wasm,从胶水js暴露的接口拿到wasm版本的哈希函数,同业内速度最快的JS哈希库Rusha.js和Yamd5.js比较下速度,比较方式大致如下,读取一个530k的文件:
const md5_init = Module.cwrap('md5_init');const md5_update = Module.cwrap('md5_update', null, ['array', 'number']);const md5_final = Module.cwrap('md5_final','string');const sha1_init = Module.cwrap('sha1_init');const sha1_update = Module.cwrap('sha1_update', null, ['array', 'number']);const sha1_final = Module.cwrap('sha1_final','string');
const yamd5 = new YaMD5;const rusha = new Rusha;
md5_init();sha1_init();yamd5.start();rusha.reset();
$("#filetest").on('change', e => { let fr = new FileReader(); fr.readAsArrayBuffer(e.target.files[0]);
fr.onload = (event) => { let ia8 = new Int8Array(event.target.result); let uia8 = new Uint8Array(event.target.result)
md5_update(ia8); md5_final();
sha1_update(ia8); sha1_final();
yamd5.appendByteArray(uia8); yamd5.end();
rusha.append(uia8); rusha.end(); }
})
四个计算操作结束时分别计算打点,比较下耗时:
可以看到相比纯JS的方案,wasm二进制方案还是有5-8倍的提升空间,根据这个预期就开始着手修改线上代码了(这里并没有用worker,并且是在特定机器上测试的,耗时仅供做横向参考,具体表现要看代码结构、机器的性能等等)。
因为已经达到了毫秒级的计算,为了不卡死主线程的浏览器UI,H5 FTN上传组件 用worker新开了线程来执行计算操作。这里要开在计算前预先申请n个(这里以2个为例)worker,并放入一个WorkerQueue的队列。主线程和worker线程通过postMessage通信。
要上传的文件经过择优后按照每分片512KB的大小切片,对于每一个分片(chunk),并行发起sha1和md5的任务,生成task后,task会被推送到worker队列,后者每接到一个任务,就取出空闲的worker,将其标记未不可用,通过postMessage向worker传递该切片的文件内容。worker自身接收到切片后,调用哈希函数进行update,并将更新后的状态再通过postMessage发送回给主线程,这时该worker会在WorkerQueue中重新标记为可用状态。当sha1的worker和md5的worker均完成后,这个分片的周期结束。执行下一个分片的计算,重复这个过程,直到所有分片都经过计算后,再发起一次获取哈希的周期,拿到md5和sha1的最终值,扫描结束。
以上就是原有组件扫描附件时的逻辑。这次改动代码时,最初只是简单将worker中计算哈希的部分替换为wasm的实现,就迫不亟待的看结果了,看到结果后发现并没有达到demo中预期的效果,反而有时还会更慢些。在关键步骤打了下log看下耗时发现,时间主要消耗在主线程和worker线程通过postMessage传递文件内容的步骤(图中的红色的流程)。当这里的耗时高的话,会稀释掉扫描掉wasm本身的速度提升,特别是文件较小的时候。
比如这里打出了一次扫描耗时的时间点:
按上文说的,以40M/s的扫描速度、每分片512KB来计算,每个分片的扫描周期时间不能超过12ms,不然就无法达到优化的目的了。图里可以看到,6ms是buffer传递给worker的耗时,40ms是把代表系统update进度的buffer返回给主线程的耗时,虽然不每次都是这样的情况,但是已经超过了12ms的标准,当逻辑操作耗时像这样增大时,扫描操作本身的速度也就不那么重要了。
如果postMessage只是用来收发指令,不传递buffer,这个问题就不存在了。但是不传递buffer就有一个疑问,不同worker之前如何共享当前的扫描进度?因为WorkerQueue的worker是FIFO的,每次切片计算完,并不能保证回到栈顶的是执行md5的worker还是sha1的worker。如果上一次执行md5的worker这次分配到sha1任务,它自身的堆栈一定不持有当前系统的sha1的buffer(因为sha1刚刚被另一个worker操作过),让它来计算sha1,这个结果就不对了。
回过头来调研了下处理不同worker间或worker与主线程间通信可能的几种办法:
看起来还是要用postMessage,既然耗时不能避免,那尽量减少传递buffer的次数也是好的。最后决定改下WorkerQueue:队列中的worker不再等价。系统申请worker时,worker将会被打上md5或者sha1的标记,前者只执行md5任务,后者只执行sha1任务 。WokerQueue在收到系统的任务申请的时候,根据任务类型分配类型相同的worker。
这样,在一个扫描周期中,只有主线程将新文件切片的buffer传递给worker的时候才需要传递一次buffer,任务执行完毕时就不再需要返回了。因为从开始到现在,update了多少buffer,每个worker自己都很清楚(buffer维持在自己作用域下Module对象里),并且也不需要了解另一个buffer状况如何。当然这要求系统内处理哈希的worker有且只能有两个,worker多于两个,还是有需要共享的问题。
限制为两个worker,会比4个,n个慢吗?按照目前的代码结构来看,不会。因为每一次扫描的请求中,执行任务快的worker一定要等待慢的worker执行完,系统才会去WorkerQueue申请新的worker,就是说同一时刻只能有两个worker在工作。
Promise.all([ _aoBlock._hashHandle("", "file_sha1", parseSha1Msg, "file_sha1", true), _aoBlock._hashHandle("", "file_md5", parseHashMsg, "file_md5", true)]).then(function(){ //do next iteration});
更改后只有在发起任务的时候会传递一次文件内容,结构如下:
再跑一次看看效果:
单次扫描中,两处消耗较大:
除此之外,基本主要流程的都可以在1ms以内完成了。
文件较大的时候,整个扫描过程耗时更贴近计算哈希的耗时,所以上线后统计用户的表现时,按照500MB区分了文件大小。500MB以上的附件扫描速度,如图:
最后还是用本文开始时的那个1.9G的附件看下现在的表现吧,上图:
扫描1.9G文件耗时约12.1秒,扫描速度可以到160M/s。速度达到了原有速度(75M/s)的2.1倍左右,与现网用户的表现基本一致。
IMWeb 团队隶属腾讯公司,是国内最专业的前端团队之一。
我们专注前端领域多年,负责过 QQ 资料、QQ 注册、QQ 群等亿级业务。目前聚焦于在线教育领域,精心打磨 腾讯课堂 及 企鹅辅导 两大产品。
社区官网:
http://imweb.io/
加入我们:
https://hr.tencent.com/position_detail.php?id=45616
扫码关注 IMWeb前端社区 公众号,获取最新前端好文
微博、掘金、Github、知乎可搜索 IMWeb 或 IMWeb团队 关注我们。
👇点击阅读原文获取更多参考资料