我在美团多个项目应用WebAssembly技术后的工程思考

3,799 阅读13分钟

随着移动互联网的到来,蓬勃发展的Web应用,加快了很多Web相关技术及规范的诞生和迭代。其中,WebAssembly 从2015年诞生,经过多年的发展,在需要高性能的web应用场景下获得了广泛的应用。

我最开始使用WebAssembly技术,是在2019年的一个智能钢琴陪练项目,我们需要在浏览器执行琴音识别的深度学习算法,解决云端推理响应时间过长的问题。

过去一年,作为视觉智能部的前端团队,我们有大量的场景需要使用WebAssembly技术,比如Web推理的引擎及图像数据处理、音视频编解码及剪辑、特效处理等。

这些实际的WebAssembly相关的项目实践,我们也遇到了很多问题,也沉淀了一些解决方案,在此回顾总结一下,分享给大家。

什么是WebAssembly?

每一项新技术的诞生,一般都是为了解决现有技术存在的问题。可以看到,现代Web应用越来越大,功能更加复杂,JS语言本身的缺陷不仅使得大型web应用的代码难以维护,更制约了Web应用的功能实现和用户体验。

07e1271909531e708e40e937227e644474582.png

与其他解释型编程语言类似,JavaScript的速度很慢。原因可以从JavaScript 代码在 V8 引擎中的执行过程找到:

  • JavaScript 被V8引擎通过 Parser(解析器)转化成 AST(抽象语法树);
  • Interpreter(解释器)根据 AST 生成引擎能够执行的 Bytecode(字节码);
  • Compiler(编译器)将 Bytecode 逐行翻译成 Machine Code(机器码)并进行 JIT 优化(Just-In-Time编译)。

可以看到,不像 C++等强类型语言,JS这种弱类型的语言,需要在运行中动态编译,如果一个 JS变量的类型经常变化,比如从Number变成Array,相关的优化引擎就很难处理。

除了性能问题,还有代码维护的问题:使用 JS 等动态编程语言,虽然可以快速构建项目,但当项目变大时,代码管理就会变得一团糟,而静态类型可以更轻松地处理复杂系统,它可以帮助在编译时更快地捕获类型不匹配,并使其更易于优化。

为了解决JS 弱类型带来的问题,出现了TypeScript 和 Asm.js。其中,TypeScript 主要在JavaScript之上,添加了静态类型定义,目前已经获得了广泛的应用。

而2012年,Mozillia的工程师提供了作为WebAssembly前身的Asm.js和Emscripten,使得C/C++等语言编写的高效程序,转译为JavaScript并在浏览器运行成为可能。

虽然 Asm.js 在性能上比原始 JavaScript 有所改进,但它本质上仍然是 JavaScript 。因此有必要发明一种二进制格式,这就是 WebAssembly 的起源。

2015年提出的WebAssembly 技术,使得浏览器可以直接执行,C/C++/Rust 等语言通过Emscripten等工具编译后的字节码,跳过了 Parser 和 Interpreter这两步,极大地提高了代码在浏览器中的运行速度: 17d76c096150c4f39efbd026714385d1119529.png

同 asm.js 不同的是,WebAssembly 是一份字节码标准,以字节码的形式依赖虚拟机在浏览器中运行,WebAssembly的官网是这样定义的:

WebAssembly (abbreviated Wasm) is a binary instruction format for a stack-based virtual machine. Wasm is designed as a portable compilation target for programming languages, enabling deployment on the web for client and server applications.

中文的大概意思是:WebAssembly(缩写为Wasm)是一种用于基于堆栈的虚拟机的二进制指令格式。Wasm 被设计为编程语言的可移植编译目标,支持在 Web 上部署客户端和服务器应用程序。

得益于WebAssembly技术卓越性能,业界迅速成立了各种相关的组织,周边工具链也在不断完善,特别是2018年,W3C的WebAssembly工作组发布了第一个工作草案,包含了核心标准JavaScript API以及Web API

2019年12月5日W3C宣布WebAssembly核心规范成为正式标准,同时各大主流浏览器,都一致表示支持这一新的技术,使得WebAssembly技术在web应用中获得了广泛的使用。

使用 WebAssembly 的场景

应用 Wasm 的常见方式有:通过 Web 浏览器提供的 JavaScript API 与 Web API ,来在 Web 应用中调用从 Wasm 模块中导出的函数;或者通过 WASI 抽象系统调用接口,以便在 out-of-web 应用中使用 Wasm。

这种使用方式与 Web 端大同小异,不过区别是可以借助底层运行时的能力,使得我们构建出的 Wasm 应用可以在 Web 浏览器外的 Native 环境中与操作系统打交道,并同样享受着 Wasm 本身所带来的安全、高效及可移植性。

目前,WebAssembly 通常用于计算密集型Web应用,使用 WebAssembly 最多的是将 C/C++ 模块编译成 Wasm 文件的方式,它允许浏览器运行过去由于性能问题而无法使用的软件和游戏,其应用场景主要有:

  • Web 游戏:WebAssembly第一个演示是运行 Unity 的“愤怒的机器人”, 可以在 WebAssembly Games 上玩更多使用 WebAssembly 技术的游戏;
  • AI 推理:主流的web推理引擎都提供了WebAssembly 计算方案,比如在 tensorflow.js 中加入 wasm 后端支持后,模型的性能提升了 10 倍左右;
  • 多媒体资源处理:bilibili 上传视频的推荐封面,就是通过编译基于C++实现的ffmpeg库,字节、腾讯和B站等公司都提供了使用WebAssembly技术的web剪辑软件;
  • 一些重量级应用:基于卫星图像呈现地球 3D 表示的软件Google Earth、计算机辅助设计和绘图软件应用程序AutoCAD、基于浏览器的协作式 UI 设计工具Figma

另外,WASI(WebAssembly System Interface)的出现,也使得 WebAssembly 可以应用在非浏览器的环境中,这给了 Serverless 更大规模的落地有了更多的想象空间。

如何使用WebAssembly技术?

为了理解 WebAssembly 是如何在 Web 运行的,需要了解以下关键概念:

  • Module:通过浏览器编译成为可执行机器码的 WebAssembly 二进制文件,Module 是无状态的,类似 Blob,能够在 Window 和 Worker 之间通过 postMessage 共享,一个 Module 声明了类似 ES2015 模块类似的 import 和 export;
  • Memory:一个可调整大小的 ArrayBuffer,其中包含由 WebAssembly 的低层次内存访问指令读取和写入的线性字节数组;
  • Table:一个可调整大小的类型化引用数组(如函数),然而处于安全和可移植性的原因,不能作为原始字节存储在内存中;
  • Instance:一个包含它在运行时用到的所有状态,包含 Memory、Table、以及一系列导入值的 Module,一个 Instance 类似一个 ES2015 的模块,它被加载到具有特定导入集的特定全局变量中。 757282080a28c430663b73fa824c0acc176253.png JavaScript 和 WebAssembly 可以互操作,一份 WebAssembly 代码被称为一个模块,其在web应用的执行过程为:
  • 使用其他语言(C/C++, Rust, Go等)编写程序,并通过各自的工具链编译为 WebAssembly 文件(.wasm格式);
  • 通过 fetch、XMLHttpRequest 等获取 .wasm 文件,得到一串 ArrayBuffer;
  • 将 ArrayBuffer 编译为浏览器可执行的模块,并实例化;
  • 调用从 Wasm 模块内导出的方法,完成所需操作。

生成 Wasm 文件,主要有以下方法:

  • 在汇编层面直接编写和生成 WebAssembly 代码;
  • 使用 AssemblyScript 将 TypeScript 的变体编译为 WebAssembly;
  • 使用 Emscripten 工具编译 C/C++ 的代码;
  • 编写一个 Rust 程序并将 WebAssembly 作为其输出。

在 Web 推理方向的实践

近年来,以深度学习为代表的新一代人工智能技术得到了快速发展和广泛应用,模型训练和模型推断基本都在云侧完成。但随着移动设备算力的提升、模型压缩技术的成熟,在终端设备(Native / web/IoT)进行模型推理成为可能,端智能应运而生。

背景

移动端的推理引擎有:google在2017年推出了TF-Lite,腾讯在2017年推出了ncnn,Apple在2017也推出了CoreML,阿里在2018年推出了MNN,华为2019年推出了MindSpsore-Lite。

web端的推理引擎有:国外有google开源的tensorflowjs和由微软主导并开源的onnxjs(后迁移到onnxruntime,取名为onnxruntime-web)等,国内有百度开源的Paddle.js和阿里闭源的MNN.js等。

美团内部基于阿里开源的MNN定制开发了MTNN,在美团各业务场景进行了落地,包括搜广推、视觉等,硬件支持手机和各种AIoT设备,应用场景和支持的设备比业界很多端智能引擎都多,转换后的模型格式更小。

但MTNN很多功能的开发受限于MNN框架的设计和迭代规划,同时MNN在Web侧的推理引擎MNN.js没有开源,MTNN 还需提供在Web环境下的推理引擎MTNN.js。

结合调研和现有团队的人力,MTNN.js借鉴tensorflow.js、Paddle.js、onnxruntime-web等开源的前端推理引擎,复用了MTNN的模型转换和优化等工具链,使用Emscripten将MTNN中C++推理功能编译成Wasm文件格式,快速实现了MTNN模型文件在Web环境中的推理。

wasm编译

在实现MTNN.js的WebAssenbly 计算方案的实践中,我们不仅需要把MTNN中C++推理相关的代码编译成Wasm文件,还需要把一些常用的推理前后的数据处理工具C++库或源码编译成Wasm文件,比如OpenCV。

为了适配多种浏览器版本和多个系统平台,我们提供了带后缀的多个编译产物,JavaScript 可根据检测到的平台特性选择加载合适的wasm版本,目前输出的版本包括如下几个:

  • Minimal default 版本:最小功能版本,不具备向量并行(SIMD)和多线程并行能力,兼容性最好,性能最差。
  • SIMD 向量并行版本:SIMDSingle Instruction Multiple Data的缩写,中文术语为“单指令多数据流”,常用于视频、音频、图像、加密、动画、游戏、AI等需要处理大量数据的应用场景,可以极大地提高向量类型的数据处理性能,主流的CPU都有SIMD指令,比如x86的SSE、ARM的Neon。
  • 多线程版本:WebAssembly对多线程支持,带来了共享内存、原子操作和 wait/notify 操作符,可惜这个功能与 SharedArrayBuffer 有一些关联,因此我们要等到 Chrome 以外的浏览器解决相关漏洞,才能看到这一功能得到广泛支持。
  • 多线程向量并行版本。

每个版本输出会有多个文件,主要包括:

  • *.js 胶水代码,由Emscripten编译产生,上层JavaScript可直接调用或者重新封装
  • *.wasm, 是二进制库文件,由Emscripten编译产生,需要部署到最终的生产环境
  • *.worker.js, 只有启用了多线程的版本才会产生,启用多线程之后由Emscripten编译产生,需要部署到最终的生产环境

开启多线程需要在请求头进行如下配置:

// chrome92以后要启动多线程,须设置此头部信息
headers: {
  'Cross-Origin-Opener-Policy': 'same-origin', 
  'Cross-Origin-Embedder-Policy': 'require-corp',
},

数据流

不管是通过执行MTNN的Wasm文件进行推理计算,还是通过执行OpenCV的Wasm文件进行推理前后的数据梳理,都需要从JS把数据传递给Wasm,执行后还要从Wasm中把数据读取回JS。

这里分享一下我们执行推理时数据传递是怎么处理的:

  • 数据从JS传给Wasm:需要先通过_malloc分配内存,然后通过HEAPF32设置,最后通过MTNN的Wasm文件提供_setTensorData 赋值给指定的地址,供推理计算的函数调用,赋值后还要及时释放该内存空间。
...部分代码
const dataOffset = wasm._malloc(data.byteLength);
try {
  wasm.HEAPF32.set(data, dataOffset / 4);
// 最后一个参数为数据排布方式 dimType:0(NHWC), 1(NCHW), 2 (NC4HW4)
  wasm._setTensorData(inputTensor, dataOffset, dimType);
} finally {
  wasm._free(dataOffset);
}
...
  • 从Wasm读取数据到JS:也是需要先通过_malloc分配内存,然后通过MTNN的Wasm文件提供_getTensorData获取数据的其实地址,基于数据的类型遍历获取数据,读完后也要释放内存。
const outputData = wasm._malloc(size);
wasm._getTensorData(outputTensor, outputData);
let startAddr = outputData / 4;
const data = [];
for (let i = 0; i < size / 4; i++) {
  data.push(wasm.HEAPF32[startAddr]);
  startAddr++;
}
wasm._free(outputData);

另外,读取数据也可以通过Wasm提供的 HEAP32.subarray 方法,不用通过for循环依次读取。

遇到的疑难问题

在应用WebAssembly技术的过程中,我们遇到最棘手的问题是,多线程报错的问题。通过分析,是因为打包后,会重命名woker.js 中的变量或函数名,导致调用时找不到。

我们的解决思路是把worker.js 文件以字符串的方式,写入到js胶水文件,然后通过Blob对象传给给线程,因为worker js文件比较小,这样既可以减少请求,还可以解决变量或函数名被打包工具更改的问题。

同时,为了解决开发或业务接入时需要安装插件处理wasm文件,还要改一些配置,我通过window.eval函数把js胶水文件注入到window对象中,这样就可以把js胶水文件和wasm文件都放在了云端,既提升了开发效率,又提升了用户的接入体验。

WebAssembly 项目的工程思考

不同于普通的静态资源文件,wasm文件由于比较新,不论是本地开发,还是打包部署,都会有很多问题需要解决。

但这些问题,如果能制定一套工程规范加以约束和说明,就可以避免或更快解决。经过多个项目的实践,我觉得可以包括以下几个方面:

  • 基于 WebAssembly 应用的整体研发流程;
  • 如何开发包含 wasm 文件的sdk项目;
  • wasm 文件的编译手册;
  • 开发调试 wasm 文件的步骤;
  • 线上 wasm 文件的管理;
  • wasm 在各端及各浏览器如何兼容;
  • wasm 的加载和执行优化。

总结

当前,WebAssembly的技术标准已经比较完善,各主流浏览器的新版本,均做了较好的支持。关于兼容性概览,大家可以通过 Can I use 网站在线查看。

我们可以看到,字节、美团、阿里等国内外大厂,近两年均有大量的项目应用了WebAssembly技术,未来还会有大量的桌面端应用Web化,比如我们正在做的特效工具,抖音和快手使用的是桌面端技术,而我们使用的是Web端技术,希望借助WebAssembly技术,实现快速迭代和弯道超车。

在ChatGPT出来后,大量传统前端工作将被AI替换的背景下,我觉得,WebAssembly 应用开发是前端开发转型的好方向之一。