我在之前用 JavaScript 实现了 PageDewarp 的核心算法。但是众所周知,JavaScript 作为一门解释性语言并不适合运算密集型的任务,导致算法运行一次要 30 秒钟…… 这个效率虽然不能算特别差,我还是不满意的。所以又花了两天学习了 WebAssembly 把瓶颈算法用 C 重写了,现在在手机端一次也只需要 1 秒左右,效果自然是极好的。但是 WebAssembly 毕竟还是新科技,和现有的一些框架融合的确实不算好,在应用的过程中踩了非常多的坑,尤其是对于我这种 1 个月前还是 Web 萌新的人更是新坑老坑一起踩到怀疑人生(悲)。
WebAssembly & Emscripten
什么是 WebAssembly?
WebAssembly 是一种通过将静态的系统编程语言(比如说 C,C++,Rust)AOT 编译成一种基于栈虚拟机架构的字节码,然后在浏览器端再次实时 JIT 执行,来逼近这些语言 native performance 的技术。
(吐槽:我最初看到 WebAssembly 的时候有一种莫名的,很强烈的 Java Applet 的既视感。)
和 CLR 类似,WebAssembly 只是字节码的标准,至于将特定语言编译成字节码的实现则是那些语言自己的事情。在目前的生态下,C 和 C++ 有名为 Emscripten 的基于 LLVM 的编译器,Rust 则有它们自己的一套 toolchain,两者都处于勉强凑活的水平。
就我个人而言我其实觉得 Rust 在这方面做得最好,官方维护,官方文档。wasm_bindgen 也给自定义数据结构类型提供了很好的支持,非常适合大项目开发。但是我只是实现一个小算法,Rust 的那一套太重了,有一种 boilerplate 会比真正代码多的预感,所以我最终选择了 C(至于为什么不是 C++,那是因为 name mangling 已经破坏了我对它 FFI 能力产生了非常深的心理阴影)。
前面说了,C 对接 WebAssembly 的工具链是 Emscripten。现在 Emscripten 的生态已经相对比较成熟了,跟着官方的 Getting Started 走基本上就没有问题。写 C 的时候就按照正常的写法(我感觉 Emscripten 对于 libc 里面的大多数常用函数都做了适配)写就可以了。算法的代码很短:
#include <math.h>
#include <stdlib.h>
#include <emscripten.h>
typedef struct v3 {
float x, y, z;
} v3;
(v3 a, v3 b) {
v3 addreturn (v3) { .x = a.x + b.x, .y = a.y + b.y, .z = a.z + b.z };
}
(v3 a, float k) {
v3 mulreturn (v3) { .x = a.x * k, .y = a.y * k, .z = a.z * k };
}
(float *a) {
v3 a2vreturn (v3) { .x = a[0], .y = a[1], .z = a[2] };
}
EMSCRIPTEN_KEEPALIVEunsigned char *dewarp(
unsigned char *src, int srcWidth, int srcHeight,
int dstWidth, int dstHeight,
float *cpX, float *cpY,
float *upperLeft, float *baseVec, float *normalVec,
float *vertical, float d
) {
= a2v(upperLeft);
v3 ul = a2v(baseVec);
v3 bv = a2v(normalVec);
v3 nv = a2v(vertical);
v3 vt unsigned char *dst = malloc(4 * dstWidth * dstHeight);
for (int x = 0; x < dstWidth; x++) {
= add(ul, add(mul(bv, cpX[x]), mul(nv, cpY[x])));
v3 top for (int y = 0; y < dstHeight; y++) {
= add(top, mul(vt, (float)y / dstHeight));
v3 c float sx = c.x / (1 + c.z / d);
float sy = c.y / (1 + c.z / d);
= (sx + 0.5f) * srcWidth;
sx = sy * srcWidth + 0.5f * srcHeight;
sy int di = (y * dstWidth + x) * 4;
int si = ((int)round(sy) * srcWidth + (int)round(sx)) * 4;
[di] = src[si];
dst[di + 1] = src[si + 1];
dst[di + 2] = src[si + 2];
dst[di + 3] = src[si + 3];
dst}
}
return dst;
}
注意函数前面加的 EMSCRIPTEN_KEEPALIVE
宏,这个宏一方面导出了函数,另一方面防止 LLVM 过于智能把这段函数当成 dead
code 消除掉或者内联掉。如果不用这个宏,就需要在编译选项里加上 -s EXPORTED_FUNCTIONS="['_dewarp']"
。注意 Emscripten 在导出函数名称的时候都在前面加上了_
,直接引用的时候不能漏掉。
另外一个要注意的点是程序内部是无法直接访问 JavaScript 里面的变量的,我用的是 Vue,那就更不用说了。所有要用到的 context 都需要在参数里面传进来。而目前来说,Emscripten 对于自定义结构体之类的支持很少,传参基本上只支持 primitive types 和指针。除此以外,在指针方面吧,Emscripten 并没有自动帮我们做好数组的转换,这一部分需要自行编写,一维的还容易一点,高维的写起来就更难受了。可以参考这个 repo。
function copyToHeap(typedArray) {
let size = typedArray.length * typedArray.BYTES_PER_ELEMENT;
let offset = Module._malloc(size);
.HEAPU8.set(new Uint8Array(typedArray.buffer), offset);
Modulereturn offset;
}
"dewarp"] = function(src, srcWidth, srcHeight, dstWidth, dstHeight, cpX, cpY, upperLeft, baseVec, normalVec, vertical, d) {
Module[let pSrc = copyToHeap(src);
let pCpX = copyToHeap(cpX), pCpY = copyToHeap(cpY);
let pUL = copyToHeap(upperLeft), pBV = copyToHeap(baseVec);
let pNV = copyToHeap(normalVec), pVertical = copyToHeap(vertical);
let pDst = Module._dewarp(pSrc, srcWidth, srcHeight, dstWidth, dstHeight, pCpX, pCpY, pUL, pBV, pNV, pVertical, d);
let ret = new Uint8ClampedArray(4 * dstWidth * dstHeight);
.set(new Uint8ClampedArray(Module.HEAPU8.buffer, pDst, 4 * dstWidth * dstHeight));
ret._free(pSrc); Module._free(pDst);
Module._free(pCpX); Module._free(pCpY);
Module._free(pUL); Module._free(pBV);
Module._free(pNV); Module._free(pVertical);
Modulereturn ret;
; }
WebAssembly 内部是简单的线性内存空间,可以使用 Module.HEAPU8
来获取一个 U8
的 View。调用的时候直接 Module._dewarp
,依然注意要在 C 函数名前面加_
。官方还提供了了 ccall
的调用方式,会自动对字符串做转码,在这里并不需要。在调用时指针一律当做整数处理。在返回时直接把返回数组在内存中的数据复制一份就行了。之后记得全部 free
掉就行了。
编译的指令是:
emcc -O3 dewarp.c -o dewarp.js --post-js dewarp_post.js \
-s ENVIRONMENT="web" \
-s MODULARIZE=1 \
-s ALLOW_MEMORY_GROWTH=1
选项的意义如下:
-O3
:优化级别,和一般的 C 编译器类似。-O3
是最激进的优化之一。除了在字节码上优化还会 minify 生成的接口 js。-o dewarp.js
:生成的接口文件名。--post-js dewarp_post.js
:把dewarp_post.js
(也就是上面copyToHeap
所在的文件)附到生成的dewarp.js
后。类似地还有--pre-js
。-s ENVIRONMENT="web"
:默认 Emscripten 在编译接口文件的时候会同时加入 node 和浏览器环境下不同的加载方案。我们这边只需要浏览器环境,加上这个选项有助于减少生成的接口 js 大小。何况在使用 webpack 的情况下不开这个 webpack 会傻乎乎地把fs
加到 dependencies 里面然后报错,这个时候要不这里加选项要不 webpack 那里额外配置二选一。-s MODULARIZE=1
:在写 Emscripten 的 js 端的文件的时候Module
是一个关键的变量。在默认情况下 Emscripten 把它定义为全局变量。这会造成命名污染。如果加上这个选项,编译出来的代码就相当于是在一个大的function
里面。这个函数接受一个预先加了点东西的Module
对象,加上自己的私货(还有我们在dewarp_post.js
里面定义的 helper 函数)之后返回完成的Module
。确保不会产生命名污染的问题。同时 Emscripten 会自动把这个大函数设成module.exports
。-s ALLOW_MEMORY_GROWTH=1
:默认的内存空间是固定的,这个选项允许内存空间的扩张。图像处理算法占的内存比较大,不加这个选项会爆内存。
另外还有两个常用的选项:
-s EXPORTED_FUNCTIONS="['_foo', ...]"
:指定要导出的函数,这里的名称要加下划线。等号右边是 js 列表的写法。如果在 C 源码要导出的函数前加上EMSCRIPTEN_KEEPALIVE
的话这里就不需要再指明了。-s EXTRA_EXPORTED_RUNTIME_METHODS="['ccall', ...]"
:默认 Emscripten 不会把很多 API 导出,因为这有助于减小接口文件的大小,但是如果要用ccall
和cwrap
之类的 API 的话就要在这里指明。
(自己一开始 -s
开头的选项一个都不知道要加,然后疯狂出问题,就暴毙了)
编译完之后会生成两个文件,一个是接口的 js 文件,一个是 wasm 文件,里面存放的就是字节码了。
Javascript 端的磨合
Emscripten 上的教程在这里说得非常轻巧:只要 <script/>
引入,然后接口 js 会自动加载 wasm 文件,然后调用方法就行了。这样做是没错,但是忽略了一点:大家现在都在用 webpack 之类的打包器,这类打包器面对 WebAssembly 需要特殊的配置才能确保其顺利运行。
我之前一直避免自己动 webpack 配置这种东西,因为我觉得 Vue
CLI 帮我安顿得挺好的自己一搞搞不好怎么挂的都不知道。刚开始配置我这个萌新就真马上跌进坑里了:网上教程里面写的都是 webpack.config.js
,然后我一直试都没有效果,纳闷了很久,才发觉因为我用的是 Vue
CLI,所以配置都在 vue.config.js
里面:
.exports = {
module...
configureWebpack: {
// 原来 webpack.config.js 的内容
}; }
这种坑有经验的开发者都不会进去吧?还是我太菜了
同时注意如果开启 ESLint 的话,注意一定要在.eslintignore
文件里面加上接口 js,一方面自动生成的 js 不需要 lint,另一方面由于上下文不足 lint 下来会有很多误报。
在 webpack 的配置上,对于两种文件分别采取如下策略:
- wasm 文件就是普通文件,应该使用
file-loader
。 - js 文件由于采用
-s MODULARIZE=1
编译开关,会把一个函数挂载到module.exports
上,这个时候就要用exports-loader
加载。
我这个萌新一开始对于 loader 的概念很困惑。因为我一开始觉得 webpack 一个打包的东西有哪里需要 load 呢?在那里用这些 loader 呢?后来意识到 loader 机制的意思是把
import foo from "bar"
转变成
var foo = some_loader("bar");
这件事情其实是挺有意思的。静态语言写多了就觉得模块导入语句应该有固定的语义才对,结果到 JavaScript 这里发现 import
其实纯粹是类似语法糖的元素,语义不固定 =_=。
倒腾一圈下来我项目当中的 webpack 配置如下:
{module: {
rules: [
{test: /dewarp\.js$/,
loader: "exports-loader"
,
}
{test: /dewarp\.wasm$/,
type: "javascript/auto",
loader: "file-loader",
}
],
} }
还没完,在 javascript 代码里面这样写:
// 指定用 exports-loader,所以 wasmInterface 现在就是 Emscripten 导出的那个返回 Module 的大函数
import wasmInterface from "./wasm/dewarp.js"
// 指定用 file-loader,所以 wasmBytecode 现在就是指向 wasm 文件的路径
import wasmBytecode from "./wasm/dewarp.wasm"
let wasmModule = wasmInterface({
// Emscripten 导出的函数可以接受一个已经塞了东西的对象作为 Module 的基础
// 可以在里面定义 Emscripten 文档里面写的一些特殊函数,比如说这里的 locateFile
// 如果定义了,在加载字节码的时候就会调用这个函数获取 path 的真实有效值,因为 webpack
// 调整了文件位置关系,而 Emscripten 生成接口脚本时写入的是生成时的文件位置,因此我们
// 就必须在这个函数里告诉它真实的文件位置,不然会 404 翻车
locateFile(path) {
return path.endsWith(".wasm") ? wasmBytecode : path;
}; // 创建对象之后自动加载
}).onRuntimeInitialized = () => {
wasmModule// 文件加载完毕
; }
在这么一番操作之后,我才终于可以在原来的地方写上
.dewarp(...) // 调用 dewarp_post.js 里面的 helper 函数,间接调用 C 函数 wasmModule
整个过程挺折腾的,但事实也确实证明这么折腾一番是值得的,性能提升三十倍,非常愉悦。
结论
总体下来我觉得 WebAssembly 现在技术上已经趋于成熟了,像我第一次积累了经验之后以后再用 WebAssembly 不踩那些坑的话体验是相当不错的。当然对于 Emscripten 来说在调用时的转化上目前还不是那么智能,还有很多要改进的地方(这也可能和 JavaScript 太放飞自我的设计有关?),使用 Rust 的开发我还没有试过,看官方文档上 Game of Life 的 demo 觉得非常有意思(我觉得 Rust 真的是绝对的后起之秀,各种方面的)。
更有意思的是,这还不是优化的终点。我听说 WebAssembly 还有一个 SIMD 的提案,这似乎又可以让性能提升若干个台阶(当然我不知道目前的 JIT VM 能不能根据字节码进行自动向量化)。还真的就是啥 native 上的技术都往 web 端搬,浏览器也是啥活现在都要揽 =_=,估计过几年就真成一个小的操作系统了(ChromeOS: Hold my chrome :)