WebAssembly在QQ邮箱中的一次实践

2018 年 12 月 19 日 IMWeb前端社区

本文由 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下的工作机制

查阅了相关的资料,决定按照官方推荐用emscripten来编译wasm。在这以前,对llvm-emscripten的机制也做了一些了解和尝试,整理了从源码到wasm应用在浏览器端的流程,如下图。

核心内容有三部分:LLVM,emscripten,js/wasm/浏览器通信

  • LLVM

LLVM本质上是一系列帮助开发编译器、解释器的SDK集合,按照传统编译器三段式的结构来说,更接近于优化层(Optimizer)和编译后端(Backend),而不是一个完整的编译器。

LLVM在优化层,对中间代码IR来执行代码的优化,它独特的地方在于IR的可读性很高。体验了一下,比如这样一个C文件test.c:

  
  
    
  1. int main()

  2. {

  3.  int first = 3;

  4.  int sum = first + 4;

  5.  return 0;

  6. }

用Clang生成IR 文件test.ll

  
  
    
  1. clang -S -emit-llvm test.c

然后打开生成的test.ll,找到了main函数倒数第三行,看起来有点像机器码。这里已经比较易懂了:将%4寄存器的值和立即数4相加,写入%5寄存器。

  
  
    
  1. define i32 @main() #0 {

  2.  %1 = alloca i32, align 4

  3.  %2 = alloca i32, align 4

  4.  %3 = alloca i32, align 4

  5.  store i32 0, i32* %1, align 4

  6.  store i32 3, i32* %2, align 4

  7.  %4 = load i32, i32* %2, align 4

  8.  %5 = add nsw i32 %4, 4

  9.  store i32 %5, i32* %3, align 4

  10.  ret i32 0

  11. }

除了代码性能优化,作为SDK集合的LLVM还提供了一些工具,用来支持代码复用、多语言、JIT,文档也比较友善,相比GCC感觉这里是加分项。

然后是编译前端,在现在版本的LLVM中,使用Clang(LLVM Native)来完成编译工作。如果想要用Clang不支持的语言来作为源码,比如Java,猜测也是可以的,因为我在LLVM的下载页看到3.0之前的版本可以用GCC编译,不过这一点这次还没有去验证。

  • emscripten

生成LLVM IR后,LLVM的任务就完成了。emscripten的编译平台fastcomp负责将LLVM IR转化为特定的机器码或者其他目标语言(包括wasm)。在这里,emscripten其实扮演了编译器后端的角色(LLVM Backend)。

在安装emscripten的时候,有一步是安装它的编译工具链binaryen,有趣的是,它也是基于LLVM的,比如它的一种编译wasm的方式:

  
  
    
  1. C/C++ Source -> WebAssembly LLVM backend -> s2wasm -> WebAssembly

刚刚说到,LLVM IR本身可读性较高,文档支持友善,代码复用容易,这都有助于开发者将LLVM IR中间代码封装为自己平台的中间代码,继而解释为汇编或者其他语言。其他编译工具(比如fastcomp,binaryen)广泛适用。(感觉又要加分了)

  • js/wasm/浏览器的通信

emscripten可以用emcc编出wasm文件、和wasm通信用的胶水js以及asm.js

(asm.js是js的子集,可以理解为wasm的前身也可以当作wasm的退化方案,数据都是强类型,用于优化js速度的,这次用不到,编译时可以用--separate-asm剥离开)。

js/wasm/浏览器的调用关系,可以用这张图来表示:

  1. 浏览器要能支持wasm格式。浏览器能识别wasm时,wasm会比js以更高效的速度执行,因为它比js更直接的映射为机器码,这是由它所处在IR和机器码之间决定的。关于wasm格式的支持,已经形成了标准 ,并且Chrome,Safari,Firefox,Edge四家的新版本都积极跟进,是个好消息。

2. 胶水js一方面向业务暴露接口,另一方面要向wasm(二进制)传递数据;

  • 首先它需要发起一个请求,向服务器下载wasm文件,由文件内容生成一个wasm实例(wasm-instantiate),因为这个实例是连接js和wasm的一个基本要求(即依赖,当然我们也可以额外添加其他依赖)。当所有的依赖都准备完毕时,emscripten会执行run函数,寻找并执行我们在源代码中的main函数。

  
  
    
  1. //简化了部分内容

  2. function run(args) {

  3.  if (runDependencies > 0) return;

  4.  function doRun() {

  5.    if (Module['_main'] && shouldRunNow) Module['callMain'](args);

  6.  }

  7.  doRun();

  8. }

main函数执行后,源码中声明的函数就进入了Runtime(浏览器端可调用的native code),并在浏览器端声明一个叫做Module的对象,通过它完成通信:

  • 胶水js已经暴露出了wasm的接口,然后来看下调用方如何将数据传输给wasm

emscripten在胶水函数内部模拟了内存结构,最大16MB,单次操作的内存修改默认不能超过5MB,类型是js中的typedarray。typedarray对同一个arraybuffer的不同表现实现了不同结构,这里也一样。具体使用哪种要看实际需要,如果源码内的变量都是定长,比如4字节,那可以用HEAP32,会更接近native的表现。因为这里我们要操作文件内容,不定长,就选HEAP8了(8bit)。

  
  
    
  1. function updateGlobalBufferViews() {

  2.  Module['HEAP8'] = HEAP8 = new Int8Array(buffer);

  3.  Module['HEAP16'] = HEAP16 = new Int16Array(buffer);

  4.  Module['HEAP32'] = HEAP32 = new Int32Array(buffer);

  5.  Module['HEAPU8'] = HEAPU8 = new Uint8Array(buffer);

  6.  Module['HEAPU16'] = HEAPU16 = new Uint16Array(buffer);

  7.  Module['HEAPU32'] = HEAPU32 = new Uint32Array(buffer);

  8.  Module['HEAPF32'] = HEAPF32 = new Float32Array(buffer);

  9.  Module['HEAPF64'] = HEAPF64 = new Float64Array(buffer);

  10. }

业务js可以调用cwrap(ccall等等)将数据以typedarray传递给emscripten,收到数据后,后者向Runtime申请指定大小的内存,返回内存的起始地址(ret),从这个地址开始,向Runtime写入数据。这个地址最终会作为参数传递给源码中的函数。

  
  
    
  1. 'arrayToC' : function(arr) {

  2.    var ret = Runtime.stackAlloc(arr.length);

  3.    writeArrayToMemory(arr, ret);

  4.    return ret;

  5. }

WebAssembly性能验证

wasm编译出来速度如何?准备了个demo做下简单验证。

准备好要编译的C代码,提供几个我们需要的基本函数,声明如下(具体实现是计算buffer的哈希,这里就未列出了):

  
  
    
  1. void sha1_init();

  2. void sha1_update(const char *data, int len);

  3. char* sha1_final();

  4. void md5_init();

  5. void md5_update(const char *data, int len);

  6. char* md5_final();

在两个update函数中,参数data的指针就是上面说到的,指向了Runtime.stackAlloc分配的内存起始地址,len是该段内存的长度,该段内存的内容就是我们输入的文件/文件分片的buffer。

用emcc编出需要的wasm,从胶水js暴露的接口拿到wasm版本的哈希函数,同业内速度最快的JS哈希库Rusha.js和Yamd5.js比较下速度,比较方式大致如下,读取一个530k的文件:

  
  
    
  1. const md5_init = Module.cwrap('md5_init');

  2. const md5_update = Module.cwrap('md5_update', null, ['array', 'number']);

  3. const md5_final = Module.cwrap('md5_final','string');

  4. const sha1_init = Module.cwrap('sha1_init');

  5. const sha1_update = Module.cwrap('sha1_update', null, ['array', 'number']);

  6. const sha1_final = Module.cwrap('sha1_final','string');


  7. const yamd5 = new YaMD5;

  8. const rusha = new Rusha;


  9. md5_init();

  10. sha1_init();

  11. yamd5.start();

  12. rusha.reset();


  13. $("#filetest").on('change', e => {

  14.        let fr = new FileReader();

  15.    fr.readAsArrayBuffer(e.target.files[0]);


  16.    fr.onload = (event) => {

  17.        let ia8 = new Int8Array(event.target.result);

  18.        let uia8 = new Uint8Array(event.target.result)


  19.        md5_update(ia8);

  20.        md5_final();


  21.        sha1_update(ia8);

  22.        sha1_final();


  23.        yamd5.appendByteArray(uia8);

  24.        yamd5.end();


  25.        rusha.append(uia8);

  26.        rusha.end();

  27.    }


  28. })

四个计算操作结束时分别计算打点,比较下耗时:

可以看到相比纯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

  • sharedArrayBuffer (仍然要使用postMessage,并且大多数浏览器未实现)

  • IndexedDB (大数据下的一次读写数据库要几十毫秒,速度在这个场景不适合)

看起来还是要用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在工作。

  
  
    
  1. Promise.all([

  2.    _aoBlock._hashHandle("", "file_sha1", parseSha1Msg, "file_sha1", true),

  3.    _aoBlock._hashHandle("", "file_md5", parseHashMsg, "file_md5", true)

  4. ]).then(function(){

  5.    //do next iteration

  6. });

更改后只有在发起任务的时候会传递一次文件内容,结构如下:

再跑一次看看效果:

单次扫描中,两处消耗较大:

  1. 第一次传递buffer耗时6ms,预期之内。

  2. promise.all发起并行任务时还有一次3ms偶然的耗时。不开worker的情况下代码都跑在主线程,所以web上的promise.all其实不是真正并行,只能算是异步,它的表现我们也比较难控制。

除此之外,基本主要流程的都可以在1ms以内完成了。

线上用户的表现

文件较大的时候,整个扫描过程耗时更贴近计算哈希的耗时,所以上线后统计用户的表现时,按照500MB区分了文件大小。500MB以上的附件扫描速度,如图:

  • 28号以后的为线上用户环境的数据,横向看wasm速度约是h5版本的2.3倍以上

  • 这里恰好统计到了27号测试环境的速度,纵向对比可以看到,用户设备的性能也决定了最终的速度

1.9G文件的表现

最后还是用本文开始时的那个1.9G的附件看下现在的表现吧,上图:

扫描1.9G文件耗时约12.1秒,扫描速度可以到160M/s。速度达到了原有速度(75M/s)的2.1倍左右,与现网用户的表现基本一致。

一些体会

  1. 一个放心敢用的Javascript API,一般要经过ECMA-262发布标准,浏览器厂商跟进。这个流程不能算快(1997年到现在ECMA-262一共也只发布了7个版本)。但是WebAssembly不一样,它已经是一个标准并被浏览器支持了,想新增特性,只要源码编的出来,js和wasm能在可忍受的耗时内完成通信,那就立刻可以得到。这点还是挺方便的。

  2. WebAssembly更适合完成CPU密集的操作,不适合重逻辑的情况,因为这会增加额外的调用消耗。

  3. 在计算速度上,WebAssembly相比Javascript是有优势的。文中提到512KB大小的分片在H5方案下有最优表现,对于wasm来说其实计算512KB和计算4MB的文件速度是接近的,整个系统可以通过提高分片大小来压榨wasm的速度。然而在加载速度上,WebAssembly的额外一次请求其实相比现在Javascript成熟的加载方案并没有什么优势。所以是否用WebAssembly还是要看具体情况。

  4. 期待WebAssembly有更多的应用场景。


关注我们

IMWeb 团队隶属腾讯公司,是国内最专业的前端团队之一。

我们专注前端领域多年,负责过 QQ 资料、QQ 注册、QQ 群等亿级业务。目前聚焦于在线教育领域,精心打磨 腾讯课堂 及 企鹅辅导 两大产品。

社区官网

http://imweb.io/

加入我们

https://hr.tencent.com/position_detail.php?id=45616


扫码关注 IMWeb前端社区 公众号,获取最新前端好文

微博、掘金、Github、知乎可搜索 IMWebIMWeb团队 关注我们。


👇点击阅读原文获取更多参考资料

登录查看更多
13

相关内容

FPGA加速系统开发工具设计:综述与实践
专知会员服务
63+阅读 · 2020年6月24日
【实用书】Python爬虫Web抓取数据,第二版,306页pdf
专知会员服务
115+阅读 · 2020年5月10日
Python分布式计算,171页pdf,Distributed Computing with Python
专知会员服务
105+阅读 · 2020年5月3日
【新书】Java企业微服务,Enterprise Java Microservices,272页pdf
【电子书】C++ Primer Plus 第6版,附PDF
专知会员服务
83+阅读 · 2019年11月25日
【电子书】Flutter实战305页PDF免费下载
专知会员服务
20+阅读 · 2019年11月7日
滴滴离线索引快速构建FastIndex架构实践
InfoQ
21+阅读 · 2020年3月19日
微信小程序支持webP的WebAssembly方案
前端之巅
19+阅读 · 2019年8月14日
最佳实践:阿里巴巴数据中台
AliData
26+阅读 · 2019年7月26日
硬核实践经验 - 企鹅辅导 RN 迁移及优化总结
IMWeb前端社区
5+阅读 · 2019年5月6日
2018年7月份GitHub开源项目排行榜
算法与数据结构
15+阅读 · 2018年8月3日
为什么你应该学 Python ?
计算机与网络安全
4+阅读 · 2018年3月24日
一篇文章读懂阿里企业级数据库最佳实践
阿里巴巴数据库技术
5+阅读 · 2017年12月20日
开源巨献:阿里巴巴最热门29款开源项目
算法与数据结构
5+阅读 · 2017年7月14日
Deep Co-Training for Semi-Supervised Image Segmentation
A Sketch-Based System for Semantic Parsing
Arxiv
4+阅读 · 2019年9月12日
Revisiting CycleGAN for semi-supervised segmentation
Arxiv
3+阅读 · 2019年8月30日
Exploring Visual Relationship for Image Captioning
Arxiv
14+阅读 · 2018年9月19日
Arxiv
4+阅读 · 2018年4月29日
Arxiv
3+阅读 · 2018年3月29日
VIP会员
相关VIP内容
FPGA加速系统开发工具设计:综述与实践
专知会员服务
63+阅读 · 2020年6月24日
【实用书】Python爬虫Web抓取数据,第二版,306页pdf
专知会员服务
115+阅读 · 2020年5月10日
Python分布式计算,171页pdf,Distributed Computing with Python
专知会员服务
105+阅读 · 2020年5月3日
【新书】Java企业微服务,Enterprise Java Microservices,272页pdf
【电子书】C++ Primer Plus 第6版,附PDF
专知会员服务
83+阅读 · 2019年11月25日
【电子书】Flutter实战305页PDF免费下载
专知会员服务
20+阅读 · 2019年11月7日
相关资讯
滴滴离线索引快速构建FastIndex架构实践
InfoQ
21+阅读 · 2020年3月19日
微信小程序支持webP的WebAssembly方案
前端之巅
19+阅读 · 2019年8月14日
最佳实践:阿里巴巴数据中台
AliData
26+阅读 · 2019年7月26日
硬核实践经验 - 企鹅辅导 RN 迁移及优化总结
IMWeb前端社区
5+阅读 · 2019年5月6日
2018年7月份GitHub开源项目排行榜
算法与数据结构
15+阅读 · 2018年8月3日
为什么你应该学 Python ?
计算机与网络安全
4+阅读 · 2018年3月24日
一篇文章读懂阿里企业级数据库最佳实践
阿里巴巴数据库技术
5+阅读 · 2017年12月20日
开源巨献:阿里巴巴最热门29款开源项目
算法与数据结构
5+阅读 · 2017年7月14日
Top
微信扫码咨询专知VIP会员