在 WebAssembly 中使用 SIMD (一)
2025年09月08日
WebAssembly 的 SIMD 概况
WebAssembly 的 SIMD 和 CPU 的 SIMD 是一个意思,都是指 Single Instruction Multiple Data (单指令多数据) 。SIMD 指令通过同时对多个数据执行相同的操作来实现并行数据处理,进而获得矢量运算能力,计算密集型应用,例如音视频处理、编解码器、图像处理,都采用 SIMD 提升性能。SIMD 的实现依赖于 CPU ,不同的硬件条件支持的 SIMD 能力不同,所以 SIMD 指令集很大,并且在不同架构之间有所不同,当然 WebAssembly SIMD 指令集也包含其中。另一方面, WebAssembly 作为一个通用型平台,其支持的 SIMD 指令集相对比较保守,目前仅限于固定长度 16 字节(128 位)的指令集。
目前主流的大部分虚拟机都支持 SIMD :
- Chrome ≥ 91 (2021 年 5 月)
- Firefox ≥ 89 (2021 年 6 月)
- Safari ≥ 16.4 (2023 年 3 月)
- Node.js ≥ 16.4 (2021 年 6 月)
使用之前先看看大部分用户使用的客户端是否支持,然后考虑在项目中增加测试代码渐进增强。渐进增强的含义是,相同功能的 wasm 模块分别用非 SIMD 和 SIMD 指令编写,嗅探宿主对 SIMD 的支持情况,如果不支持则使用非 SIMD 模块,如果支持则使用 SIMD 模块。嗅探可以使用 wasm-feature-detect 库。这个库专门用于测试宿主对 wasm 特性支持程度,除了 SIMD 以外,这个库还可以检查诸如 64 位内存、多线程等新特性和实验特性,并且支持摇树(Tree-shakable),对 web 应用友好。
// loadWasmModule.js
import { simd } from 'wasm-feature-detect';
export default function(url, simdUrl) {
return simd().then(isSupported => {
return isSupported ? () => import(simdUrl) : () => import(url);
});
}
SIMD 指令集
SIMD 指令和单字节指令类似,也是算术运算、读取写入、逻辑运算这几类。使用时需要严格按照栈式指令操作,SIMD 指令汇总:
指令格式 | 功能描述 | 示例 |
---|---|---|
读取和存储 | ||
v128.load offset=<n> align=<m> | 从内存加载 128 位向量 | (v128.load offset=0 align=16 (i32.const 0)) |
v128.load8_splat | 加载 8 位整数并复制 16 次填充向量 | (v128.load8_splat (i32.const 42)) |
v128.load16_splat | 加载 16 位整数并复制 8 次填充向量 | (v128.load16_splat (i32.const 1024)) |
v128.load32_splat | 加载 32 位整数并复制 4 次填充向量 | (v128.load32_splat (i32.const 0x12345678)) |
v128.load64_splat | 加载 64 位整数并复制 2 次填充向量 | (v128.load64_splat (i32.const 0)) |
v128.store offset=<n> align=<m> | 存储 128 位向量到内存 | (v128.store offset=16 align=16 (i32.const 32) (local.get $vec)) |
创建常量 | ||
v128.const <type> <values> | 创建常量向量 | (v128.const i32x4 0 1 2 3) |
v128.const <type> <values> | 创建浮点常量向量 | (v128.const f32x4 1.0 2.0 3.0 4.0) |
整数算术运算 | ||
i8x16.add(a, b) | 8 位整数加法(16 通道) | (i8x16.add (local.get $a) (local.get $b)) |
i16x8.sub(a, b) | 16 位整数减法(8 通道) | (i16x8.sub (local.get $a) (local.get $b)) |
i32x4.mul(a, b) | 32 位整数乘法(4 通道) | (i32x4.mul (local.get $a) (local.get $b)) |
i64x2.add(a, b) | 64 位整数加法(2 通道) | (i64x2.add (local.get $a) (local.get $b)) |
i8x16.add_saturate_s(a, b) | 8 位有符号饱和加法 | (i8x16.add_saturate_s (local.get $a) (local.get $b)) |
i16x8.sub_saturate_u(a, b) | 16 位无符号饱和减法 | (i16x8.sub_saturate_u (local.get $a) (local.get $b)) |
整数比较运算 | ||
i8x16.eq(a, b) | 8 位整数相等比较(返回掩码) | (i8x16.eq (local.get $a) (local.get $b)) |
i32x4.lt_s(a, b) | 32 位有符号整数小于比较 | (i32x4.lt_s (local.get $a) (local.get $b)) |
i16x8.gt_u(a, b) | 16 位无符号整数大于比较 | (i16x8.gt_u (local.get $a) (local.get $b)) |
浮点运算 | ||
f32x4.add(a, b) | 32 位浮点加法(4 通道) | (f32x4.add (local.get $a) (local.get $b)) |
f64x2.mul(a, b) | 64 位浮点乘法(2 通道) | (f64x2.mul (local.get $a) (local.get $b)) |
f32x4.min(a, b) | 32 位浮点最小值(4 通道) | (f32x4.min (local.get $a) (local.get $b)) |
f64x2.sqrt(a) | 64 位浮点平方根(2 通道) | (f64x2.sqrt (local.get $a)) |
位运算 | ||
v128.and(a, b) | 按位与 | (v128.and (local.get $a) (local.get $b)) |
v128.or(a, b) | 按位或 | (v128.or (local.get $a) (local.get $b)) |
v128.xor(a, b) | 按位异或 | (v128.xor (local.get $a) (local.get $b)) |
v128.bitselect(a, b, mask) | 根据掩码选择位 | (v128.bitselect (local.get $a) (local.get $b) (local.get $mask)) |
位移 | ||
i32x4.shl(a, imm) | 32 位整数左移(立即数) | (i32x4.shl (local.get $a) (i32.const 2)) |
i64x2.shr_u(a, imm) | 64 位无符号整数右移(立即数) | (i64x2.shr_u (local.get $a) (i32.const 3)) |
i16x8.shl(a, imm) | 16 位整数左移(立即数) | (i16x8.shl (local.get $a) (i32.const 4)) |
通道操作 | ||
i8x16.extract_lane_s(idx, a) | 提取 8 位有符号整数通道 | (i8x16.extract_lane_s 3 (local.get $a)) |
f64x2.replace_lane(idx, a, value) | 替换 64 位浮点通道 | (f64x2.replace_lane 1 (local.get $a) (f64.const 3.14)) |
i8x16.swizzle(a, s) | 根据索引向量重排通道 | (i8x16.swizzle (local.get $a) (local.get $indices)) |
i8x16.shuffle(mask, a, b) | 根据掩码混洗两个向量的通道 | (i8x16.shuffle 0 1 2 3 12 13 14 15 8 9 10 11 4 5 6 7 (local.get $a) (local.get $b)) |
类型转换 | ||
i32x4.trunc_sat_f32x4_s(a) | 32 位浮点转 32 位有符号整数(饱和截断) | (i32x4.trunc_sat_f32x4_s (local.get $a)) |
f64x2.convert_i32x4_s(a) | 32 位有符号整数转 64 位浮点 | (f64x2.convert_i32x4_s (local.get $a)) |
i16x8.extend_low_i8x16_s(a) | 将低 8 个 8 位有符号整数扩展为 16 位 | (i16x8.extend_low_i8x16_s (local.get $a)) |
其他 | ||
v128.any_true(a) | 检查向量中是否有任意通道非零 | (v128.any_true (local.get $a)) |
i8x16.all_true(a) | 检查所有 8 位通道是否全为非零 | (i8x16.all_true (local.get $a)) |
f32x4.ceil(a) | 32 位浮点向上取整 | (f32x4.ceil (local.get $a)) |
f64x2.floor(a) | 64 位浮点向下取整 | (f64x2.floor (local.get $a)) |
指令集使用 deepseek 协助汇总,没有严格校对,如有错误请指出。
使用 SIMD 指令
举个例子,如果想要对一张图片进行反色处理,如果不使用 SIMD 指令集, wat 实现如下:
(module
(import "env" "log" (func $log (param i32)))
;; 导入内存
(import "env" "memory" (memory 100))
;; 反色函数:原地转换 RGB 通道,跳过Alpha通道
(func $invert (param $start i32) (param $length i32)
(local $end i32) ;; 结束地址
(local $i i32) ;; 当前字节索引
;; 计算结束地址 = start + length * 4
local.get $start
(i32.mul (local.get $length) (i32.const 4))
i32.add
local.set $end
;; 初始化循环变量 i = start
local.get $start
local.set $i
(block $exit
;; 主循环(每次处理4个字节:R,G,B,A)
(loop $loop
;; 检查是否到达结束地址
local.get $i
local.get $end
i32.ge_u
br_if $exit
;; 处理R通道(偏移0)
local.get $i
i32.const 255
local.get $i
i32.load8_u ;; 加载原始R值
i32.sub ;; 计算255 - R
i32.store8 ;; 存储反色后的R值
;; 处理G通道(偏移1)
local.get $i
i32.const 1
i32.add
i32.const 255
local.get $i
i32.const 1
i32.add
i32.load8_u ;; 加载原始G值
i32.sub ;; 计算255 - G
i32.store8 ;; 存储反色后的G值
;; 处理B通道(偏移2)
local.get $i
i32.const 2
i32.add
i32.const 255
local.get $i
i32.const 2
i32.add
i32.load8_u ;; 加载原始B值
i32.sub ;; 计算255 - B
i32.store8 ;; 存储反色后的B值
;; 跳过Alpha通道(偏移3),无需修改
;; 移动到下一个像素(i += 4)
local.get $i
i32.const 4
i32.add
local.set $i
br $loop
)
)
)
;; 导出函数
(export "invert" (func $invert))
)
使用 SIMD 指令,每一步对 1 个像素点 1 个通道的操作会增强为对 4 个像素点 4 个通道的操作:
(module
(import "env" "log" (func $log (param i32)))
(import "env" "memory" (memory 100))
(func $invert (param $start i32) (param $length i32)
(local $end i32) ;; 结束地址
(local $i i32) ;; 当前地址
(local $chunk v128) ;; 当前处理的16字节
(local $mask v128) ;; alpha 通道掩码
(local $full255 v128) ;; 全 255 掩码
;; end = start + length * 4
local.get $start
local.get $length
i32.const 4
i32.mul
;; 数据长度可能不是 4 的倍数,这里 +3 确保数据对齐
i32.add
i32.const 3
i32.add
local.set $end
;; i = start
local.get $start
local.set $i
;; 常量向量:全 255
v128.const i8x16 255 255 255 255 255 255 255 255
255 255 255 255 255 255 255 255
local.set $full255
;; 掩码:只保留 alpha 通道(第 3,7,11,15 个字节)
v128.const i8x16 0 0 0 255 0 0 0 255
0 0 0 255 0 0 0 255
local.set $mask
(block $exit
(loop $loop
;; if (i >= end) break
local.get $i
local.get $end
i32.ge_u
br_if $exit
;; load 16 bytes (4 pixels)
local.get $i
v128.load
local.set $chunk
;; tmp = 255 - chunk
local.get $full255
local.get $chunk
i8x16.sub
local.set $chunk
;; 用 bitselect 保留 alpha 通道:
local.get $i
v128.load
local.get $chunk
local.get $mask
v128.bitselect
local.set $chunk
;; store back
local.get $i
local.get $chunk
v128.store
;; i += 16
local.get $i
i32.const 16
i32.add
local.set $i
br $loop
)
)
)
(export "invert" (func $invert))
)
注意看第 18 到第 20 行,WebAssembly SIMD 指令一次处理 16 字节数据,对应 rgba 4 个通道的图片 4 个像素,每张图片的像素点数量有可能不是 4 的倍数,所以这里加上一个大于 3 的数字即可确保所有数据都可以被处理。但是也要注意,WebAssembly 没有内存守护,这么处理会污染内存,导致其他数据错误,此例功能单一且没有其他数据,这样操作性能最好。
最后看性能对比:
上图最左边是素材原图,中间是没有使用 SIMD 指令的处理结果和用时,右边是使用 SIMD 指令的处理结果和用时。素材原图的尺寸为 928*927 ,除了中间的圆形图案以外,其余都是透明像素。可以看到,使用 SIMD 指令的方案性能要比不使用的快 6 倍左右。实际上,素材越大,效果越明显,不过笔者发现在处理更小的图片的场景中,也有显著的提升,比如经典的 lenna 图:
预告
下一篇将讨论,C 程序如何在 WebAssembly 中使用 SIMD 。
最后更新时间: 2025年09月08日