我在之前用 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 add(v3 a, v3 b) {
    return (v3) { .x = a.x + b.x, .y = a.y + b.y, .z = a.z + b.z };
}
v3 mul(v3 a, float k) {
    return (v3) { .x = a.x * k, .y = a.y * k, .z = a.z * k };
}
v3 a2v(float *a) {
    return (v3) { .x = a[0], .y = a[1], .z = a[2] };
}
EMSCRIPTEN_KEEPALIVE
unsigned 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
        ) {
    v3 ul = a2v(upperLeft);
    v3 bv = a2v(baseVec);
    v3 nv = a2v(normalVec);
    v3 vt = a2v(vertical);
    unsigned char *dst = malloc(4 * dstWidth * dstHeight);
    for (int x = 0; x < dstWidth; x++) {
        v3 top = add(ul, add(mul(bv, cpX[x]), mul(nv, cpY[x])));
        for (int y = 0; y < dstHeight; y++) {
            v3 c = add(top, mul(vt, (float)y / dstHeight));
            float sx = c.x / (1 + c.z / d); 
            float sy = c.y / (1 + c.z / d); 
            sx = (sx + 0.5f) * srcWidth;
            sy = sy * srcWidth + 0.5f * srcHeight;
            int di = (y * dstWidth + x) * 4;
            int si = ((int)round(sy) * srcWidth + (int)round(sx)) * 4;
            dst[di] = src[si];
            dst[di + 1] = src[si + 1];
            dst[di + 2] = src[si + 2];
            dst[di + 3] = src[si + 3];
        }
    }
    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);
  Module.HEAPU8.set(new Uint8Array(typedArray.buffer), offset);
  return offset;
}

Module["dewarp"] = function(src, srcWidth, srcHeight, dstWidth, dstHeight, cpX, cpY, upperLeft, baseVec, normalVec, vertical, d) {
  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);
  ret.set(new Uint8ClampedArray(Module.HEAPU8.buffer, pDst, 4 * dstWidth * dstHeight));
  Module._free(pSrc); Module._free(pDst);
  Module._free(pCpX); Module._free(pCpY);
  Module._free(pUL); Module._free(pBV);
  Module._free(pNV); Module._free(pVertical);
  return 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 导出,因为这有助于减小接口文件的大小,但是如果要用 ccallcwrap 之类的 API 的话就要在这里指明。

(自己一开始 -s 开头的选项一个都不知道要加,然后疯狂出问题,就暴毙了)

编译完之后会生成两个文件,一个是接口的 js 文件,一个是 wasm 文件,里面存放的就是字节码了。

Javascript 端的磨合

Emscripten 上的教程在这里说得非常轻巧:只要 <script/> 引入,然后接口 js 会自动加载 wasm 文件,然后调用方法就行了。这样做是没错,但是忽略了一点:大家现在都在用 webpack 之类的打包器,这类打包器面对 WebAssembly 需要特殊的配置才能确保其顺利运行。

我之前一直避免自己动 webpack 配置这种东西,因为我觉得 Vue CLI 帮我安顿得挺好的自己一搞搞不好怎么挂的都不知道。刚开始配置我这个萌新就真马上跌进坑里了:网上教程里面写的都是 webpack.config.js,然后我一直试都没有效果,纳闷了很久,才发觉因为我用的是 Vue CLI,所以配置都在 vue.config.js 里面:

module.exports = {
  ...
  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;
  }
}); // 创建对象之后自动加载
wasmModule.onRuntimeInitialized = () => {
  // 文件加载完毕
};

在这么一番操作之后,我才终于可以在原来的地方写上

wasmModule.dewarp(...) // 调用 dewarp_post.js 里面的 helper 函数,间接调用 C 函数

整个过程挺折腾的,但事实也确实证明这么折腾一番是值得的,性能提升三十倍,非常愉悦。

结论

总体下来我觉得 WebAssembly 现在技术上已经趋于成熟了,像我第一次积累了经验之后以后再用 WebAssembly 不踩那些坑的话体验是相当不错的。当然对于 Emscripten 来说在调用时的转化上目前还不是那么智能,还有很多要改进的地方(这也可能和 JavaScript 太放飞自我的设计有关?),使用 Rust 的开发我还没有试过,看官方文档上 Game of Life 的 demo 觉得非常有意思(我觉得 Rust 真的是绝对的后起之秀,各种方面的)。

更有意思的是,这还不是优化的终点。我听说 WebAssembly 还有一个 SIMD 的提案,这似乎又可以让性能提升若干个台阶(当然我不知道目前的 JIT VM 能不能根据字节码进行自动向量化)。还真的就是啥 native 上的技术都往 web 端搬,浏览器也是啥活现在都要揽 =_=,估计过几年就真成一个小的操作系统了(ChromeOS: Hold my chrome :)