WebAssembly 初探——图片灰度处理

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