Table of Contents
WebAssembly 的概念出现很久了,但一直没有普及,一方面是需求不多,另一方面是使用不便。这导致我在尝试 WebAssembly 进行图片处理的时候比较坎坷。
引入 wasm 文件
将其他语言编译成 wasm 模块可以使用 docker 处理。比如我使用的 c 语言,就是通过官方的 docker 编译的。这在另一篇文章里有介绍:《使用 docker 编译 wasm》。
编译完成之后的文件有 js 格式和 wasm 格式的。很多文章介绍的都是如何引入 js 格式的文件。但我需要的是直接引入 wasm 格式。我需要可控,并且会引入多个 wasm 文件。而引入 js 胶水文件会占用全局的 Module 对象。这里需要注意的是,emscripten 在生成胶水 js 文件的时候,会实现很多 c 语言的 api 供开发者调用,比如内存的分配和释放。如果直接引入 wasm 文件而又需要使用 malloc 和 free 这样的原生 api,就只能自己实现了。
为了让 wasm 更好地发挥作用,我倾向于在 web worker 里引入。这样不会阻塞主线程。相关的引入步骤在《使用 docker 编译 wasm》这篇文章里已经介绍了。这里不再赘述。
内存管理
图片数据会在主线程转换成 TypedArray,然后传给 worker,worker 再传给 wasm。这里的问题是,我们拿不到 wasm 处理后的数据,因为直接传值不是引用类型,wasm 参数和返回值只能是数值类型。并且内存复制和数据校验很慢。我一开始的做法是利用 emscripten 编译器自动分配给 wasm 的 16M 内存进行数据传递。
/** * 图片灰度--使用 c 模块 * @param {Uint8ClampedArray} buffer */ function cGrayscale(buffer) { let arr = new Uint8ClampedArray(actions[`c/image.memory`].buffer) arr.set(buffer, 0) let grayscale = actions[`c/image.grayscale`] grayscale(arr, buffer.length) buffer.set(arr.subarray(0, buffer.length)) return buffer }
如上代码,actions 是我存放 wasm 导出属性的对象。这里我将图片数据存到 wasm 模块内存里,然后把内存引用传给 wasm 相关方法(对于这里的传值,我估计不是引用,而是偏移。),最后把处理后的值返回给主线程。
// image.c void EMSCRIPTEN_KEEPALIVE grayscale (uint8_t *buffer, int len) { uint8_t avg = 0; for (int i = 0; i < len; i += 4) { avg = (buffer[i] + buffer[i + 1] + buffer[i + 2]) / 3; buffer[i] = buffer[i + 1] = buffer[i + 2] = avg; } return; }
虽然 c 代码读取到了值,也给出了正确的结果,但是非常耗时,差不多是 js 的一百倍——八九百毫秒。我将 console 方法注入,打印时间发现 c 计算数据的时间很短,只有几毫秒。由此可知,虽然使用了双方共用的内存块,但 wasm 依然要进行数据校验,这很耗时。
// 注入(import)的方法要在 env 里定义。 // worker.js /** * wasm 部分配置 */ const wasmConfig = { 'c/image': { env: { consoleLog: (num) => console.log(num), consoleTime: () => console.log(Date.now()), } } } /** * 加载 wasm * @param {string} module 模块名称 * @param {ArrayBuffer} mod wasm字节码 * @param {object} importObject 导入对象,可选 **/ async function wasm(module, mod, importObject = {}) { return new Promise((resolve, reject) => { const obj = Object.assign({}, wasmConfig[module] || {}, importObject) console.log(obj) WebAssembly.instantiate(mod, obj) // 第二个参数 .then((instance) => { Object.keys(instance.exports).map(key => { actions[`${module}.${key}`] = instance.exports[key] }) if (obj.env) { actions[`${module}.env`] = obj.env } resolve(true) }) .catch(e => { resolve(e) }) }) }
// image.c void EMSCRIPTEN_KEEPALIVE consoleLog (char num); void EMSCRIPTEN_KEEPALIVE consoleTime ();
只声明,不赋值,gcc 在编译的时候会警告,为了忽略警告和可能的报错,需要加上编译参数:-s ERROR_ON_UNDEFINED_SYMBOLS=0。
既然 js 侧分配内存不可行,就只能在 c 那一边尝试了。在上一节写过,不使用胶水 js 就只能自己实现相关 api。我这里最起码需要实现 malloc 和 free 两个函数给 js 调用。
#include <stdint.h> #include <emscripten/emscripten.h> #include <stdlib.h> uint8_t* mem; void* EMSCRIPTEN_KEEPALIVE wasm_malloc(size_t size) { mem = (uint8_t*)malloc(size); return mem; } void EMSCRIPTEN_KEEPALIVE wasm_free(void) { free(mem); }
图片数据在 js 侧是 Uint8ClampedArray,在 c 侧对应的是 char 或者 unit8_t(这个实际上就是 char 类型)。
wasm_malloc 根据指定字节大小分配内存,并返回开始地址偏移。wasm_free 释放内存。为了防止 16M 内存不够,编译的时候需要指定 -s ALLOW_MEMORY_GROWTH=1 参数,允许内存增长。这样编译没有问题了,但导入的时候会报错,因为没有实现 emscripten_notify_memory_growth 这样的回调。
/** * wasm 部分配置 */ const wasmConfig = { 'c/image': { env: { consoleLog: (num) => console.log(num), consoleTime: () => console.log(Date.now()), emscripten_notify_memory_growth: (size) => { console.log('notify: ', size) } } } }
我这里的代码只是为了不报错的占位,因为我测试的图片不大,16M 完全足够了。如果真的不够,应该是需要用 Memory.prototype.grow()
扩大内存的。至于 emscripten_notify_memory_growth 的参数是什么我暂时也不确定,因为没有触发。
因为内存是调用的 c 函数分配的,内存引用在 c 模块里也是存在的,所以,js 调用的时候就不需要传递内存偏移了。
// worker.js /** * 图片灰度--使用 c 模块以及内存分配 * @param {Uint8ClampedArray} buffer */ function cGrayscale(buffer) { let ptr = actions[`c/image.wasm_malloc`](buffer.length) console.log(ptr) let arr = new Uint8ClampedArray(actions[`c/image.memory`].buffer, ptr, ptr + buffer.length) arr.set(buffer, 0) let grayscale = actions[`c/image.grayscaleInner`] grayscale(buffer.length) buffer.set(arr.subarray(0, buffer.length)) actions[`c/image.wasm_free`](ptr) return buffer }
// image.c void EMSCRIPTEN_KEEPALIVE grayscaleInner (int len) { uint8_t avg = 0; for (int i = 0; i < len; i += 4) { avg = (mem[i] + mem[i + 1] + mem[i + 2]) / 3; mem[i] = mem[i + 1] = mem[i + 2] = avg; } return; }
这次 c 侧的处理速度终于理想了。
结果
截图是三种处理方式的结果,三个数字分别是十次处理的平均值、最小值、最大值。
第一行的“js.grayscale”是 js 的实现。
// worker.js /** * 图片灰度 * @param {Uint8ClampedArray} buffer */ function grayscale(buffer) { let avg = 0 for (let i = 0, len = buffer.length; i < len; i += 4) { avg = (buffer[i] + buffer[i + 1] + buffer[i + 2]) / 3 buffer[i] = buffer[i + 1] = buffer[i + 2] = avg } return buffer }
第二行的“partCGrayscel”是指 rgb 取平均值的时候调用 c。即每个像素调用一次。
// worker.js /** * 图片灰度--部分使用 c 模块 * @param {Uint8ClampedArray} buffer */ function partCGrayscale(buffer) { let avg = 0 let threeAvg = actions[`c/math.threeAvg`] for (let i = 0, len = buffer.length; i < len; i += 4) { avg = threeAvg(buffer[i], buffer[i + 1], buffer[i + 2]) buffer[i] = buffer[i + 1] = buffer[i + 2] = avg } return buffer }
第三、四行就是直接调用 c 的内存偏移和处理结果。
从结果来看,js 反而是最快的???这很可能是因为数据传输的问题。使用 wasm 时需要将数据拷贝到共享内存,c 处理完成后需要将共享内存里的数据再拷贝出来。
最后,贴一下编译命令:
docker run --rm -v $(pwd):/src -u $(id -u):$(id -g) emscripten/emsdk emcc --no-entry -s ERROR_ON_UNDEFINED_SYMBOLS=0 -s ALLOW_MEMORY_GROWTH=1 -O3 ./image.c -o ./image.wasm