一、技术难点
前 32 篇都在浏览器里量东西。这一篇的开局不一样:这台机器上没有 wat2wasm、没有 emcc、没有 rustc、没有 wasm-tools (clang 有,但没有 wasm 后端)。想跑基准,先得有 .wasm。
所以我先写了 asm.mjs ------一个约 200 行的零依赖 WebAssembly 二进制编码器:ULEB128/SLEB128、类型段、函数段、内存段、导出段、代码段、data 段、name 段,以及本篇用得到的近 50 个指令(含 0xFD 前缀的 SIMD 与 bulk-memory)。然后用它手写字节造出 15 个导出函数 (add、sum_i32、sum_f64_arith、sum_f64、dot_f32、dot_f32_simd、sieve、hash32、strlen、strlens、i64_add、grow/size、read_at、copy),产出一个 1221 字节的 core.wasm。
这个绕路有一个意外收获:当你是"从字节里造出一个 wasm 的人"时,你会被迫看清边界上到底发生了什么。 而边界上发生的三件事,和大多数文章写的都不一样。
先把结论摆出来。下面的每一条都是实测,不是推演:
| 流传的说法 | 实测结果 | 差在哪 |
|---|---|---|
| 「wasm 比 JS 快好几倍」 | 同算法对位:浮点算术 1.00x 、内存求和 0.96x 、整数 kernel 0.87x ;只有素数筛 1.68x | 收益不来自"wasm",来自 SIMD(3.89x)与内存布局 |
| 「跨边界调用很贵」 | 单实例热调用点 2.37 ns/call (JS 本地函数 0.77 ns);但 8 实例轮转 22.78 ns/call (9.60x) | 贵的是调用点的形状,不是 wasm |
| 「wasm 有严格类型契约」 | add('abc', 2) → 2 ;add({}, 2) → 2 ;add(null, 2) → 2 ;add(2**31, 0) → -2147483648,全部静默 |
边界做的是 ToNumber + ToInt32,不是类型检查 |
| 「字符串传进去就行」 | 一条字符串过边界 594.7 ns ;纯 JS .length 1.67 ns → 356x |
税在 UTF-8 编解码(325.5 + 289.7 ns)与每条一次的临时分配(230 ns)上 |
| 「编译启动很贵」 | 编译斜率 0.0009 ms/KB ,1221 字节模块 compile 0.003ms ;首调溢价 20k 迭代 60.2% 、200 万迭代 0.3% | 启动税是体积 × 实例数 + 任务长度的函数,不是常数 |
38 条断言(bench.mjs)+ 12 条断言(test-article-code.mjs)+ 9 条断言(extract-and-test.mjs,直接抽正文里的代码块 去跑),跑在真实 Node 22 / V8 上,失败即 exit 1。
难点一:收益归因错了------快的不是 wasm,是 SIMD 与内存布局
我把同一套算法用两种语言各写一遍,输入数据完全相同(同一块线性内存,JS 用同一个 Float64Array 视图读它),只比"循环体"的写法:
| 基准 | 规模 | JS | WASM | JS/WASM |
|---|---|---|---|---|
浮点算术循环 s = s*1.0000001 + i |
2×10⁷ 次 | 53.7ms | 53.7ms | 1.00x |
| 线性内存 f64 求和 | 10⁷ 个 double(76MB) | 9.5ms | 10.0ms | 0.96x |
| xorshift32 整数 kernel | 2×10⁷ 轮 | 46.8ms | 53.9ms | 0.87x |
| 素数筛 | 上限 5×10⁶ | 15.1ms | 9.0ms | 1.68x |
三条打平,一条 JS 甚至反超。为什么?
因为 V8 的 TurboFan 早就把这类"类型稳定的数字循环"编译成和 C 一样的东西了:diamond 的 s = s*1.0000001 + i 在两边都是 mulsd + addsd ,机器码几乎一致,所以耗时也几乎一致(53.7 vs 53.7)。xorshift 那 0.87x 是抖动------同一份代码连跑三轮分别得到 1.00x / 1.01x / 0.87x。任何"wasm 快 N 倍"的经验值,如果没写清对比的是"什么写法下的 JS",都是无效信息。
素数筛是唯一一个稳定 1.5~1.7x 的位置,原因不在算术而在内存访问:
- JS 侧的
Uint8Array每次buf[i]都要做边界检查 (i < length)+ 元素类型确认,且 JIT 不能总把它们提到循环外(因为循环体内其它写入可能改变 length); - wasm 侧
i32.load8_u的边界检查是一次性的:整个线性内存只有一个 base 和一个 size,编译器用一条比较就把整段循环的检查收掉了,剩下的就是裸的地址运算。
这条差异是真实且可复用的:数据规模越大、访问越密,wasm 的收益越明显;纯算术、访问密度低的场景,收益接近零。
那"wasm 快好几倍"的说法从哪来的?大多来自换了算法 ------最典型的就是 SIMD。同一份 wasm 模块里,我写了两个点积函数:dot_f32(标量逐元素)和 dot_f32_simd(v128.load + f32x4.mul + f32x4.add,4 lane,尾巴标量收口):
| 实现 | 1M 个 f32 点积 | 相对标量 |
|---|---|---|
wasm 标量 dot_f32 |
1.02ms | 1.00x |
wasm SIMD dot_f32_simd |
0.26ms | 3.89x |
| 纯 JS 标量 | 1.10ms | 0.93x |
3.89 倍是 SIMD 给的,不是 wasm 给的。 而 SIMD 在 wasm 里能用、在 JS 里用不顺手(没有裸的 128 位向量类型),这才是"选 wasm"真正的技术理由------不是它更快,而是它能让你写出的算法更快。
难点二:边界不是类型检查,是 ToNumber + ToInt32
这是本篇最危险的一条。add 的签名是 (i32, i32) -> i32,看起来是个铁打的契约。实测九种脏输入:
| 调用 | 返回 | 实际发生了什么 |
|---|---|---|
add('1', 2) |
3 | 字符串走 ToNumber → 1,不报错 |
add('abc', 2) |
2 | ToNumber('abc') = NaN → ToInt32(NaN) = 0 |
add({}, 2) |
2 | ToNumber({}) = NaN → 0 |
add(null, 2) |
2 | ToNumber(null) = 0 |
add(undefined, 2) |
2 | ToNumber(undefined) = NaN → 0 |
add(true, 2) |
3 | ToNumber(true) = 1 |
add({valueOf: () => 5}, 2) |
7 | 竟然走 valueOf 钩子 |
add(1.9, 2.9) |
3 | 各自向零截断成 1、2 |
add(-1.9, 0) |
-1 | 向零截断(不是 Math.floor,不是 -2) |
add(2**31, 0) |
-2147483648 | i32 静默回绕 |
add(1)(缺参) |
1 | 缺失参数当 undefined → 0 |
add(2n, 2) |
TypeError | 唯一会报错的一类:BigInt 不能转 number |
add(1, 2, 3)(多参) |
3 | 多余参数直接丢弃 |
只有 BigInt 会抛错,其它全部静默。 这意味着:
- TS 的
number类型在运行时等于不存在。 你写了interface Feed { size: number },运行时传进来一个'12'也能跑,而且跑出12;传进来undefined也能跑,跑出0。 - 静默变 0 是最坏的失败模式。 一份"金额"字段如果是
'12.5',ToNumber给 12.5,ToInt32给 12------单位从元变分还是从分变元,全看你有没有想过这件事。而NaN → 0意味着一个解析失败的价格会变成免费。 2**31静默回绕成-2147483648。 任何从 JS 传进去的 ID、时间戳、字节偏移,超出 i32 就是一个负数。
顺便回答一个相关问题:为什么 G7 里 JS 的多态调用点慢 85.82x (0.77 ns → 65.86 ns)?因为同一个调用点轮流喂 number / string / object 之后,V8 从单态变多态,每次调用都要做类型判定与去优化兜底。
把这两件事放一起看,结论有点反直觉:JS 的类型错误表现为"变慢",wasm 的类型错误表现为"算错且静默"。 前者你能在火焰图上看见,后者你只能在事后从数据里发现。所以 wasm 的胶水层里必须有守卫(解法 2.7),而这层守卫的开销,比它保护的东西便宜得多。
难点三:没有 ABI------字符串的成本不在边界上,在分配上
wasm 没有 String,没有对象,没有 GC 句柄。字符串只能自己搬:JS 侧 TextEncoder 编成 UTF-8 字节,拷进线性内存,把 (ptr, len) 交给 wasm,返回时再 TextDecoder 解回字符串。
我把这条链拆开逐项测(2 万条 63 字节的中英混排字符串):
| 环节 | 耗时 | 占比(对 naive 全流程 594.7ns) |
|---|---|---|
TextEncoder.encode(每次分配新 Uint8Array) |
325.5 ns/条 | 55% |
mem.set 写进线性内存 |
14.5 ns/条 | 2% |
跨边界调 strlen |
24.8 ns/条 | 4% |
| └ 同样的循环留在 wasm 内(批量入口) | 22.8 ns/条 | --- |
TextDecoder.decode 解回字符串 |
289.7 ns/条 | (naive 不含解码) |
纯 JS str.length |
1.67 ns/条 | --- |
三个结论:
-
边界税只有 2.0 ns/条 (24.8 − 22.8)。在一条 594.7 ns 的路径上,它占 0.3%。所以"跨边界调用贵所以别用 wasm"是个伪命题------真正的成本是数据格式转换。
-
naive 全流程 594.7 ns 里,还有 230 ns 是"逐条临时分配的残余" (594.7 − 325.5 − 14.5 − 24.8)。这 230 ns 不是算术,是 GC。把
encode换成encodeInto(复用同一块预分配缓冲):写法 耗时 enc.encode(s)× 2 万325.5 ns/条 enc.encodeInto(s, scratch)× 2 万73.5 ns/条 4.43x 同一份数据、同一个引擎、同一个函数签名,只因为不再每条分配一次,快了 4.43 倍。 这是本篇性价比最高的一条改动,比任何 wasm 优化都值。
-
纯 JS 处理字符串只要 1.67 ns。 如果你要做的事只是
str.length、includes、split,把字符串搬进 wasm 是 356x 的净亏损。字符串不应该过边界,只有一个例外:wasm 里有一个 JS 写不出来的算法(正则引擎、SQLite、分词、编解码),此时字符串是"必须付的过路费",那就用encodeInto+ 长度前缀一次搬完。
难点四:线性内存的两个"静默",会同时咬你
线性内存是 wasm 的唯一数据结构:一块 ArrayBuffer,按 64KB 一页分配。它有两个性质,都能在你没注意的时候悄悄毁掉数据。
静默一:memory.grow 会让所有已存在的视图变成"分离"状态。
js
const mem = inst.exports.memory; // 128MB(2048 页)
const view = new Uint8Array(mem.buffer);
view[0] = 42;
inst.exports.grow(1); // +1 页
view.byteLength; // 0 ← 视图被分离
view[0]; // undefined
new Uint8Array(mem.buffer)[0]; // 42 ← 数据没丢,是新视图
实测:grow 之后旧视图 byteLength === 0、读出来是 undefined;数据本身完好,用新视图能读到 42。而 grow 的返回值是旧页数 (2048),不是新页数------想拿新尺寸请再调一次 size()。
这条的杀伤力在于:它不抛错。 你在 init 时缓存了一份 Float64Array 视图,某个大文件触发了 grow,之后所有写入都"成功"了(写到长度为 0 的分离视图上不报错),读出来全是 undefined------你会看到一份全 NaN 的报表,而栈上什么都没有。
顺便测了一下"每次重新取视图"的代价:new Uint8Array(memory.buffer) 30.9 ns/次 。用 30.9ns 换掉一整类幽灵 bug,是划算的。 (解法 2.5 用 sync() 把它封起来。)
静默二:越界检查只查"线性内存边界",不查"你的数据结构边界"。
js
// 128MB 内存的最后一个 i32 位置
W.read_at(mem.buffer.byteLength - 4); // 0 ← 静默返回 0
W.read_at(mem.buffer.byteLength); // RuntimeError ← 出内存才炸
也就是说:在 0 ~ 128MB 之间随便读,wasm 一律不管。它保护的是"进程不崩",不是"逻辑正确"。
这条和难点二叠在一起,就是 wasm 里最典型的线上事故形态:一个没被守卫的负数下标经过 i32 回绕,变成一个"在内存范围内"的合法地址,读到的是别人的数据,不报错、不崩溃、结果错 。而同样的代码在 JS 里会得到 undefined------至少你还是能发现。
顺带一个好消息 :线性内存不在 JS 堆里 。128MB 的 memory 旁边,process.memoryUsage().heapUsed 只有 15.1MB。这意味着:
- wasm 的内存膨胀不会体现在你的 JS 堆监控图上(这也是为什么 wasm 相关的内存告警经常迟到);
- 想监控它,只能读
memory.buffer.byteLength和memory.grow的返回值,自己在胶水层里记水位(解法 2.5 的stats())。
难点五:启动税是"体积 × 实例数 + 任务长度"的函数,不是常数
我用同一段 xorshift 函数复制出 1 / 200 / 1600 份,造出三个不同体积的模块:
| 模块体积 | 函数数 | new WebAssembly.Module |
new Instance |
首调(含编译+实例化) | 热调 |
|---|---|---|---|---|---|
| 128 B | 1 | 0.002 ms | 0.034 ms | 0.499 ms | 0.461 ms |
| 15,388 B | 200 | 0.016 ms | 0.119 ms | 0.644 ms | 0.459 ms |
| 127,189 B | 1600 | 0.115 ms | 0.516 ms | 1.070 ms | 0.459 ms |
编译斜率 0.0009 ms/KB ------每 KB 不到 1 微秒 。这个数字比"wasm 启动慢"的传说低了三个数量级,原因是 V8 的惰性基线编译 :new Module 只做 Liftoff 基线编译(很快),函数第一次被调用时才编译成机器码,跑热了再 tier-up 到 TurboFan。所以"编译成本"必须按首次调用来算:
| 任务规模 | 首调(新实例) | 热调(已预热) | 首调溢价 |
|---|---|---|---|
| 20,000 次迭代 | 0.074 ms | 0.046 ms | +60.2% |
| 2,000,000 次迭代 | 4.622 ms | 4.606 ms | +0.3% |
首调溢价是一个"固定的几十微秒",不是比例。 任务越长,它被摊得越薄。所以判据很清楚:
- 任务 < 0.1ms:别用 wasm。60% 的溢价意味着你在为启动付费,而且付的是大头。
- 任务 1ms 量级:
core.wasm(1221 字节)的 compile + 单实例化 = 0.135ms ,占 13.5%。能接受,但必须把编译/预热挪到空闲期(解法 2.6)。 - 任务 > 10ms:启动税 <1.5%,可以只关心算法本身。
还有一条容易忽略的:每实例化一次都要付一次内存与数据段的心跳。 同一个 1221 字节模块建 8 个实例(worker 池场景)实测 1.058ms ,即 0.132ms/实例 ------而这还是 16 页的小内存模块。我一开始在基准里用 128MB 内存的实例做"首调"测量,抖动大到首调溢价在 3.7%~12% 之间乱跳,根因就是每实例 128MB 的内存预留 。这条也写进了解法:池的大小要按"内存 × 实例数"算预算,不是按 CPU 核数。
难点六:手写字节路上踩到的三个真 bug
这三个不是花絮。它们是"把 C 的内存模型搬进 JS 世界"时必然要交的税,而且每一个都是我自己撞上去的。
Bug 1:SIMD 的前缀操作码必须 ULEB 编码。
js
f32x4_add: [0xfd, 0xe4], // ✗ 报错:Invalid prefixed opcode 70860518
f32x4_add: [0xfd, ...uleb(0xe4)], // ✓ 0xE4 → [0xE4, 0x01]
0xFD 后面的子操作码是 u32 ,必须按 LEB128 编码。0xE4 = 228 > 127,所以要两个字节。我第一版写死单字节,V8 报了一个看起来像乱码的错误(70860518 是它把 fd e4 11 当成一个数读出来的结果)。
Bug 2:f32x4.extract_lane 是 0x1F,不是 0x1B。
js
f32x4_extract_lane: (lane) => [0xfd, 0x1b, lane], // ✗ expected type f32, found i32x4.extract_lane
f32x4_extract_lane: (lane) => [0xfd, 0x1f, lane], // ✓
lane 系列的顺序是 i8x16(0x15,0x16,0x17) → i16x8(0x18,0x19,0x1A) → i32x4(0x1B,0x1C) → i64x2(0x1D,0x1E) → f32x4(0x1F,0x20) → f64x2(0x21,0x22)。报错信息很诚实("expected f32, found i32x4.extract_lane"),只要你读它。
Bug 3(最凶):32 位溢出,把 JS 写法直译成 wasm 就炸。
我第一版素数筛几乎是照着 JS 抄的:
js
// wasm 版(第一版,错的)
i32.const 2 → loop i < n: if (buf[i]) { j = i*i; while (j < n) { buf[j]=0; j+=i } }
上限 5×10⁶ 时,i 会一路跑到 5×10⁶,而 i*i 在 i = 46342 时超过 2^31,回绕成负数 ------于是 j 变成一个负地址,buf[j] 直接写到内存外:
csharp
RuntimeError: memory access out of bounds
同样的代码在 JS 里永远不会出错 ,因为 JS 的数字是 double:i*i 只是一个很大的数,内层循环条件 j < n 直接不成立,自然退出。
修复有两个方向,我都实现了:(a)外层循环只跑到 sqrt(n); (b)用 i64 算 i*i,比较完再 i32.wrap_i64 回来。生产代码里选(a),因为它同时省掉了 99% 的空转;而(b)更好说明了问题的本质:
js
// 修正后(kernels.mjs 里的真身)
o.local_get(2), o.i64_extend_i32_u, // (i64) i
o.local_get(2), o.i64_extend_i32_u, o.i64_mul, o.local_set(4), // j64 = (i64)i*i
o.local_get(4), o.local_get(5), o.i64_ge_u, o.br_if(1), // i*i >= n → 停
o.local_get(4), o.i32_wrap_i64, o.local_set(3), // 此时 j64 < n,wrap 安全
教训:JS 里所有"数字不会溢出"的直觉,在 wasm 里全部失效。 i*i、a*b+c、ptr + len、时间戳乘 1000------每一个都可能在 2³¹ 处静默翻转成一个负地址。而 wasm 不会告诉你,因为 32 位整数溢出在 wasm 里是合法行为,不是错误。
二、完整解法
2.1 第一道闸门:先算这笔账,再决定上不上
别从"要不要用 wasm"开始,从这笔账开始。把任务拆成三段成本:
scss
总收益 = (算法的相对加速比 − 1) × 该算法占总耗时比例
− 边界税 (穿越次数 × 单次成本)
− 转换税 (每条数据 × 编解码/分配成本)
− 启动税 (模块体积 × 实例数 + 首调溢价) / 任务次数
代入实测常数(同一台机器、同一版本 V8):
| 常数 | 实测值 |
|---|---|
| 单实例热调用点穿越 | 2.37 ns/call |
| 多实例轮转调用点(megamorphic) | 22.78 ns/call |
| 字符串单条过边界(naive) | 594.7 ns/条 |
| 字符串单条过边界(encodeInto + 批量) | ≈ 110 ns/条(73.5 编 + 14.5 拷 + 2.0 穿越 + 22.8 处理) |
| 模块编译 | 0.0009 ms/KB |
| 单实例化(16 页内存) | 0.132 ms |
| 首调溢价(固定部分) | ≈ 28 µs |
三个算例,都用这张表:
算例 A:1M 个像素的高斯模糊,JS 15ms,wasm+SIMD 4ms。 收益 = (15/4 − 1) × 1 = 2.75 (省 11ms)。边界 = 1 次(传一块内存)≈ 2.4ns。转换 = 0(像素直接躺在 memory 里,用 Uint8ClampedArray 视图读写,不过边界 )。启动 = 0.135ms。净收益 ≈ 10.9ms,7 倍 。→ 上。
算例 B:把 2 万条商品标题按关键词过滤,JS 3ms。 假设关键词匹配本身能加速 3x(好消息),但每条标题要 encode/decode:2 万 × 594.7ns = 11.9ms 。收益 = (3/3 − 1) × 1 − 11.9 = −11.9ms 。→ 不上。 改成"整批 encodeInto + 长度前缀一次搬完":2 万 × 110ns = 2.2ms,仍亏;只有当匹配算法本身贵到 10ms 以上(比如正则回溯、分词)才划算。
算例 C:一个 0.3ms 的 CRC32 校验,每秒 200 次。 wasm 版 0.1ms,但启动 0.135ms。收益 = (0.3/0.1 − 1) × 1 = 2(省 0.2ms),启动 0.135ms 一次就吃掉 2/3。→ 必须预热+复用实例,否则白干。(预热后:净收益 0.2ms/次 × 200 次/秒 = 40ms/秒,值得上。)
闸门结论(写进 checklist 的那三条):
- 任务的数据能否一直躺在线性内存里?能 → 转换税 ≈ 0,几乎总是值得;不能 → 先算转换税。
- 任务的单次墙钟是否 ≥ 1ms?否 → 别上(首调溢价与实例化吃掉一切)。
- 你要的加速是否来自"JS 写不出的东西"(SIMD、多线程、成熟 C 库、确定性无 deopt)?否 → 先优化 JS(换数据布局、减分配),通常能拿到同样的收益且零迁移成本。
2.2 ABI 契约:指针 + 长度 + SoA,别传对象
wasm 里没有对象。你在边界上传的每一个结构体,都是一个"布局约定",而这个约定只存在于你的注释里。
反例(几乎所有人第一版都这么写):
js
// ✗ 每个字段一次调用,8 个字段 8 次穿越 + 8 次类型隐式转换
inst.exports.setRect(x, y, w, h, rot, opacity, zIndex, id);
7 个参数 → 7 次 ToInt32,7 次"可能静默变 0"的机会,外加 7 倍的穿越。正例:
js
// ✓ 一次穿越,数据在内存里,结构化视图(SoA)
const p = arena.allocI32(8 * n);
const v = arena.i32(p, 8 * n); // 一次取视图
v[row * 8 + 0] = x; /* ... */
inst.exports.processRects(p, n); // 1 次穿越
SoA(Structure of Arrays)比 AoS 更好 ,因为 SIMD 要的是连续同型数组,而 AoS 的 [x,y,w,h][x,y,w,h] 在 SIMD 里要做 shuffle。这一点和 Day32 里"5 万条记录 structuredClone 40.87ms → SoA 0.885ms"是同一个原因:跨边界的成本按"字节"算,而缓存的成本按"连续性"算。
ABI 契约要写下来的四件事(缺一条都会在三个月后变成 bug):
| 契约项 | 例子 | 不写会怎样 |
|---|---|---|
| 字节序与对齐 | f64 按 8 对齐,f32 按 4 | Float64Array 构造直接抛 RangeError |
| 谁拥有这块内存 | arena 分配、调用方不释放 | 泄漏(且 JS 堆监控看不见,见难点四) |
| 长度怎么传 | (ptr, len) 成对,永不省略 len |
wasm 无法知道边界,读出垃圾 |
| 字符串编码与终止符 | UTF-8 + 可选 NUL | strlen 类函数读到内存尽头 |
2.3 边界收窄:从 chatty 到 batch
这是收益最大、改动最小的优化。原文 1 次/元素 → 1 次/批:
| 形态 | 500 万次运算 | 单次成本 |
|---|---|---|
循环留在 wasm 内(sum_i32(5e6) 一次调用) |
1.94 ms | 0.387 ns/iter |
跨边界 500 万次(add × 5e6) |
11.9 ms | 2.37 ns/call |
| 6.1x |
同理,字符串场景:逐条 strlen 24.8 ns/条 vs 批量 strlens 22.8 ns/条------批量本身不省多少(因为边界只占 2ns),但它把"每条一次临时分配"的机会也一起消掉了。真正的教训是:
边界不是瓶颈,但"每元素一次调用"这个形状,会顺手带来"每元素一次分配"。 而分配才是瓶颈(难点三:230 ns/条)。
批量入口的写法(已通过 test-article-code.mjs 的批量 ABI 断言:500 条字符串 / 7390B / 1 次穿越):
js
// 500 条字符串、1 次穿越:把 (ptr...) 打包成 i32 数组,长度由 NUL 或前缀数组携带
const strs = Array.from({ length: 500 }, (_, i) => `前端${i}-Day33`);
const ptrArr = arena.allocI32(strs.length);
const ptrs = arena.i32(ptrArr, strs.length);
let expect = 0;
strs.forEach((s, i) => {
const { ptr, len } = arena.writeStr(s); // 内部用 encodeInto,不产生临时数组
ptrs[i] = ptr;
expect += len;
});
const got = W.strlens(ptrArr, strs.length); // 1 次穿越,wasm 内部循环
// got === expect(实测 7390B / 500 条)
2.4 字符串通道:encodeInto + 长度前缀 + 一次 decode
字符串是唯一"必须付费"的通道(难点三),所以每条都要省:
js
// ✗ 325.5 ns/条:每条分配一个临时 Uint8Array,交给 GC
mem.set(enc.encode(s), off);
// ✓ 73.5 ns/条:复用预分配缓冲,4.43x
const cap = s.length * 3 + 4; // UTF-8 最坏 3 字节/码元,+NUL
const ptr = arena.alloc(cap, 4);
const { written } = enc.encodeInto(s, arena.u8.subarray(ptr, ptr + cap));
三条硬规则:
- 不要来回搬同一批字符串。 如果这批标题要被过滤、排序、再渲染,就让它们一直待在线性内存里,只把结果下标 传回 JS(
Int32Array,不过边界,只是一个视图)。返回结果的成本从 594.7 ns/条 降到 0。 - 长度必须显式传,不要用 NUL 当唯一信息源。 NUL 终止字符串在 wasm 里意味着"每个字符串一次 strlen 扫描"(批量入口那 22.8 ns/条几乎全是这个扫描),而且内容里带 NUL 就截断(用户输入里真的会有)。
- 返回字符串时一次 decode 一批。
dec.decode(subarray(ptr, ptr + len))每个字符串一次调用是 289.7 ns;如果 wasm 直接返回"长度前缀的连续块",一次 decode 整块再split,能省掉大部分。
2.5 内存:Arena + 视图自愈 + 泄漏水位
难点四的两个静默,用一个不到 80 行的 Arena 全部封住。这份代码在 day33-assets/abi.mjs 里,test-article-code.mjs 对它跑了 12 条断言。
js
export class Arena {
constructor(memory, { base = 65536, enc = new TextEncoder(), dec = new TextDecoder() } = {}) {
this.memory = memory;
this.base = base;
this.brk = base;
this.highWater = base; // 历史最高水位:泄漏检测的唯一线索
this.utf8 = enc;
this.text = dec;
this.buffer = null;
this.u8 = this.i32v = this.f64v = this.dv = null;
this.sync();
}
// 返回 true 表示「视图刚被重建过」------所有缓存过的视图此刻都已是分离状态
sync() {
if (this.buffer === this.memory.buffer) return false;
this.buffer = this.memory.buffer;
this.u8 = new Uint8Array(this.buffer);
this.i32v = new Int32Array(this.buffer);
this.f64v = new Float64Array(this.buffer);
this.dv = new DataView(this.buffer);
return true;
}
get bytes() { this.sync(); return this.buffer.byteLength; }
// 每帧/每任务开始时调用:bump allocator 的回收是 O(1)
reset() { this.brk = this.base; }
alloc(size, align = 8) {
this.sync();
if (!Number.isInteger(size) || size < 0) throw new RangeError(`alloc 尺寸非法:${size}`);
const start = (this.brk + align - 1) & ~(align - 1);
const end = start + size;
if (end > this.buffer.byteLength) {
const pages = Math.ceil((end - this.buffer.byteLength) / 65536);
const prev = this.memory.grow(pages); // 返回旧页数,失败返回 -1
if (prev < 0) throw new RangeError(`memory.grow 失败:+${pages} 页(撞到 maximum)`);
this.sync(); // 关键:旧视图已被分离,必须重建
}
this.brk = end;
if (end > this.highWater) this.highWater = end;
return start;
}
allocF64(len) { return this.alloc(len * 8, 8); }
allocF32(len) { return this.alloc(len * 4, 4); }
allocI32(len) { return this.alloc(len * 4, 4); }
// 视图一律即时创建,永不缓存(30.9ns/次,换正确性)
f64(ptr, len) { this.sync(); return new Float64Array(this.buffer, ptr, len); }
f32(ptr, len) { this.sync(); return new Float32Array(this.buffer, ptr, len); }
i32(ptr, len) { this.sync(); return new Int32Array(this.buffer, ptr, len); }
writeStr(s, withNul = true) {
if (typeof s !== 'string') throw new TypeError(`writeStr 只收 string,收到 ${typeof s}`);
this.sync();
const cap = s.length * 3 + 4; // UTF-8 最坏 3 字节/码元,另加 NUL
const ptr = this.alloc(cap, 4);
const { written } = this.utf8.encodeInto(s, this.u8.subarray(ptr, ptr + cap));
if (withNul) this.u8[ptr + written] = 0;
return { ptr, len: written };
}
readStr(ptr, len) { this.sync(); return this.text.decode(this.u8.subarray(ptr, ptr + len)); }
stats() {
this.sync();
return {
bytes: this.buffer.byteLength, base: this.base, brk: this.brk,
live: this.brk - this.base, highWater: this.highWater,
leakSuspect: this.highWater > this.base * 4,
};
}
}
四个设计决定,每一个都对应前面的一条实测:
| 决定 | 对应实测 | 为什么这么做 |
|---|---|---|
视图永不缓存 ,全部走 sync() + 即时创建 |
分离视图静默返回 undefined(难点四) |
30.9ns/次 换掉一整类幽灵 bug |
grow 后立刻 sync() |
grow 让旧视图 byteLength = 0 |
把失效点收敛到一个函数里,而不是散落各处 |
reset() 而不是 free() |
bump allocator | O(1) 回收;帧/任务级生命周期比对象级更适合 wasm |
记 highWater |
heapUsed 看不见 wasm 内存(128MB vs 15.1MB) |
唯一的泄漏线索:水位只涨不落就是泄漏 |
一个真实验证:test-article-code.mjs 里有一条"grow 后旧视图被分离且 sync 报告重建"的用例,故意缓存一份 Float64Array、触发 grow、断言旧视图 byteLength === 0、再断言数据 3.14 还在------1048576 → 1179648 字节,全部通过。
2.6 启动:Module 复用 + 实例池 + 预热
难点五的账要这么还:
js
export class WasmPool {
constructor(module, imports = {}, size = 1) {
this.module = module; // ← 编译一次,永远复用(0.0009ms/KB 也别浪费)
this.imports = imports;
this.idle = [];
this.size = 0;
this.capacity = size;
while (this.size < size) this.idle.push(this.spawn());
}
spawn() {
const inst = new WebAssembly.Instance(this.module, this.imports);
this.size += 1;
return { inst, arena: inst.exports.memory ? new Arena(inst.exports.memory) : null, busy: false };
}
acquire() {
const slot = this.idle.pop() ?? (this.size < this.capacity ? this.spawn() : null);
if (!slot) throw new Error(`池已满(${this.capacity}),说明有实例没归还`);
slot.busy = true;
slot.arena?.reset(); // 复用实例 = 必须清零 arena,否则上一个任务的临时数据会污染下一个
return slot;
}
release(slot) { slot.busy = false; this.idle.push(slot); }
// 预热:把首调溢价(实测 +60.2%)提前付掉,别让它落在用户交互的那一帧
warmup(fn = (slot) => slot.inst.exports.size?.() ?? 0) {
const slot = this.acquire();
try { fn(slot); } finally { this.release(slot); }
return this;
}
stats() { return { size: this.size, idle: this.idle.length, capacity: this.capacity }; }
}
四条工程规则,每条都对应一个实测数字:
WebAssembly.Module只编译一次。 编译 0.0009ms/KB 听起来可以忽略,但模块是 500KB 的 FFmpeg 时就是 0.45ms ,而 worker 池里 8 个 worker 各编一次 = 3.6ms 白费。- 池的容量按内存算,不按核数算。 每实例 0.132ms 实例化(16 页小模块),但大模块每实例要预留几十上百 MB 线性内存。"8 核所以开 8 个实例"是个错误公式。
- 预热必须做,而且要在空闲期做。 首调溢价 +60.2%(20k 迭代任务)------ 如果第一帧就是用户交互,你在那一帧多付了 60%。
acquire时arena.reset()。 我做池的原型时忘了这条,结果是"上一个任务的字符串指针仍然有效"------典型的 use-after-free,而 wasm 那边完全不会察觉。这条已经写进test-article-code.mjs的断言(acquire 时 arena 没复位会直接 fail)。
2.7 正确性守门:因为边界只做 ToNumber + ToInt32
难点二的九种脏输入,只能靠胶水层挡。守卫本身很便宜(一个 typeof + 两个比较),但能挡住的是一整类静默错误:
js
const I32_MAX = 2147483647, I32_MIN = -2147483648;
export const i32 = (v, name = 'arg') => {
if (typeof v !== 'number' || Number.isNaN(v)) {
throw new TypeError(`${name} 必须是 number,收到 ${typeof v} ${String(v)}(wasm 会静默变成 0)`);
}
if (!Number.isFinite(v)) throw new TypeError(`${name} 必须是有限数,收到 ${v}`);
if (!Number.isInteger(v)) throw new TypeError(`${name} 必须是整数,收到 ${v}(wasm 会静默向零截断)`);
if (v > I32_MAX || v < I32_MIN) throw new RangeError(`${name} 超出 i32 范围:${v}(wasm 会静默回绕)`);
return v;
};
配合 TS 的正确姿势是双保险,而不是二选一:
ts
// 类型只在编译期;运行时守卫在边界上。两者不是重复,是分工。
export function filter(image: ImageData, threshold: number): Uint8Array {
return inst.exports.filter(i32(image.width, 'width'), i32(image.height, 'height'), i32(threshold, 'threshold'));
}
三条守门清单:
| 检查 | 挡住的静默错误 |
|---|---|
typeof === 'number' && Number.isInteger |
'12' → 12、undefined → 0、{valueOf} → 任意值 |
[-2³¹, 2³¹-1] 范围 |
ID / 时间戳 / 字节偏移静默回绕成负数,进而读到别人的内存 |
指针 + 长度成对校验(ptr >= 0 && ptr + len <= memory.buffer.byteLength) |
内存内越界读(难点四静默二),读到的是垃圾而不是崩溃 |
最后一条特别值得做,因为它把 wasm 唯一的"安全检查"从"进程不崩"提升到"逻辑正确":
js
const assertRange = (arena, ptr, len, name) => {
if (!Number.isInteger(ptr) || !Number.isInteger(len) || ptr < 0 || len < 0 || ptr + len > arena.bytes) {
throw new RangeError(`${name} 越界:ptr=${ptr} len=${len} 内存=${arena.bytes}`);
}
};
2.8 验收:把上面全部变成断言
day33-assets/ 里两个文件就是验收器:
bash
cd day33-assets
node kernels.mjs # 造 core.wasm + 15 个导出的自检(13 条)
node --expose-gc bench.mjs # 7 组基准 + 38 条断言(失败 exit 1)
node test-article-code.mjs # abi.mjs(= 解法代码):12 条断言
node extract-and-test.mjs # 从 ../Day33-WASM.md 抽代码块落地再跑:9 条断言
extract-and-test.mjs 是这一篇额外加的一道保险:它从正文 Markdown 里按标题区间抽出三个代码块 (2.5 的 Arena、2.6 的 WasmPool、2.7 的 i32 守卫),写进 .extracted/ 再 import 进来跑断言。这样"文章里贴的代码"和"验证过的代码"是同一份文本,不存在"文末有个更好的版本"。
本次运行:38/38(bench)、12/12(abi)、13/13(kernels 自检)、9/9(正文代码块),全部 exit 0。
三、应用场景
3.1 判据表
先给一张可以直接贴进技术评审的判断表。左边一列是"该上",右边是"别上",中间是实测依据:
| 该上 wasm | 依据(实测/公式) | 别上 wasm | 依据(实测/公式) |
|---|---|---|---|
| 数据天然是同型大数组(像素、音频采样、点云、矩阵) | 转换税 ≈ 0,边界 1 次 | 字符串为中心的文本处理 | 594.7 ns/条 vs 纯 JS 1.67 ns = 356x 亏损 |
| 有成熟 C/C++/Rust 库(FFmpeg、SQLite、zstd、OpenCV) | 不是"重写",是"搬运" | DOM 密集的渲染逻辑 | wasm 里没有 DOM,每次操作都要回 JS |
| 需要 SIMD / 多线程 / 确定性无 deopt | SIMD 3.89x;多态调用点 85.82x 抖动 | 单次任务 < 0.1ms | 首调溢价 +60.2% 直接吃掉 |
| 单次任务 ≥ 1ms 且重复调用 | 启动税可摊薄(13.5% → <1.5%) | 只是"想让它快点"的普通业务逻辑 | 同算法对位 0.87~1.00x,白干 |
| 需要可预测的延迟上限(音视频、游戏、金融计算) | 没有 GC 停顿、没有 deopt | 依赖 JS 对象图 / 闭包 / Promise 的逻辑 | 线性内存里没有对象,胶水层会爆炸 |
3.2 场景一:图像 / 视频(最经典的甜点区)
数据是 ImageData.data(Uint8ClampedArray),天然就在一块连续内存里 ,可以零拷贝地映射成 wasm 的 memory 视图(或 copyWithin 一次)。滤镜、缩放、色域转换、噪声、边缘检测全部是"每个像素独立算"的 SIMD 完美场景。
工程要点(本篇的数据都支撑):
- 一定配 worker (Day32):把
ImageData的 buffer 转移进 worker,wasm 直接在 worker 的线性内存里改,改完转移回来 ------ 搬运成本 0.18ms/64MB(Day32 实测),复制是 56.14ms。 - 一次处理一整帧,不要一行一次调用 :8192×8192 的图有 8192 行,逐行调用 = 8192 × 2.37ns ≈ 19µs 的纯边界开销,看起来不多,但如果每行还带一次
alloc+ 视图重建,就变成 8192 × (30.9 + 分配) ns ≈ 0.5ms 起。 - 池按内存算 :一张 4K 帧的 RGBA 是 33MB,双缓冲就是 66MB。一个 worker 池 4 个实例 = 264MB 线性内存,这部分完全不出现在 JS 堆监控上 (128MB 内存旁边
heapUsed只有 15.1MB)。
3.3 场景二:表格 / 大数据计算(SoA + 批量)
10 万行 × 20 列的筛选、聚合、排序,是典型的"数据在内存里、算法在循环里"。这个场景的胜负手不在 wasm,在布局:
- AoS(
[{a,b,c}, ...])→ 必须先转成 SoA(Float64Array列 × 20)才能进 wasm,转换税就是全部成本; - 如果数据本来就来自后端 JSON,在解析阶段直接落成 SoA (
JSON.parse后一次遍历,或structuredClone换成列式),转换税 ≈ 0; - 然后:一次
alloc放 20 列 → 一次process调用 → 回来只取Uint8Array的筛选掩码(10 万字节,不过边界,只是一个视图)。
这条路线的收益不稳定在"算术",而稳定在减少 JS 侧的分配与 GC (Day29 的主题)。实测参照:SoA + transfer 比 structuredClone 快 46 倍(Day32)。
3.4 场景三:编解码 / 压缩 / 加解密("搬运"而非"重写")
zstd、brotli、fflate、libsodium、argon2、sql.js、ffmpeg.wasm ------ 这些不是"用 wasm 重写算法",而是把 20 年的 C 代码原样搬进浏览器。这个场景的判据完全不同:
| 关注点 | 数字 | 说明 |
|---|---|---|
| 模块体积 | 0.0009 ms/KB 编译,但下载要按网络算 | 500KB 的 wasm 在 4G 下 ≈ 400ms(Day27 网络优化) |
| 首屏策略 | instantiateStreaming + <link rel="preload" as="fetch" crossorigin> |
不预热就是白给:+60.2% 的首调溢价落在首屏 |
| 线程 | pthread + SharedArrayBuffer 需要 COOP/COEP 两个响应头 (Day32 实测:缺则 SharedArrayBuffer 直接 undefined) |
这个坑在 Day32 已经付过一次学费 |
| 缓存 | Content-Type: application/wasm + 长缓存 hash |
否则流式编译退化成先下载再编译 |
3.5 场景四:实时音视频(延迟可预测性 > 平均速度)
音频回调、WebRTC 前处理(回声消除、降噪、重采样)这类场景,平均速度不重要,"最坏情况"才重要 。wasm 在这里的价值不是快,是没有 GC 停顿、没有 deopt 悬崖、没有 JIT 预热曲线:
- JS 版本在触发一次 deopt 时会掉到解释器(G7 实测多态调用点慢 85.82x),一帧音频就爆了;
- wasm 版本保持基线编译的速度下限,最坏情况可预测;
- 但内存必须预分配 :音频回调里绝不能
memory.grow(一次 grow 可能触发页分配 + 视图重建,波动在毫秒级)。
3.6 场景五:不该上的地方(写下来比写"该上"更有用)
- 任何"字符串为主"的链路。 356x 的亏损,没有任何 wasm 技巧能填平。
- 任何"每次只算一点点"的链路。 单次 <0.1ms 就别上(+60.2% 首调溢价)。
- 任何"必须访问 DOM/CSSOM"的链路。 wasm 里没有 DOM;每次回 JS 都是 2.37ns 起 + 一次类型转换的机会成本。
- "为了性能重写业务逻辑"。 同算法对位 0.87~1.00x 是实测;先问"能不能换数据布局 / 减分配 / 上 worker",这三件事通常收益更大且零迁移成本。
- "反正快,多搬点"。 每多搬一个字段,就多一次
ToInt32静默出错的机会(难点二)。
四、总结
4.1 十条 checklist(可以直接抄进评审模板)
- 先算账再动手。 收益 = (加速比−1)×占比 − 边界税 − 转换税 − 启动税/次数。三条闸门:数据能否常驻内存、单次是否 ≥1ms、加速是否来自 JS 写不出的东西。
- 别相信"wasm 快 N 倍"。 同算法对位实测 0.87~1.00x ;收益来自 SIMD(3.89x)与内存布局,不是"编译成 wasm"这件事本身。
- 边界不是类型检查。
'abc'→ 0、{}→ 0、null→ 0、1.9→ 1、2**31→ 负数,全部静默;只有 BigInt 抛错。所以胶水层必须有i32()守卫,TS 不是替代品。 - 32 位溢出是头号杀手。
i*i在i > 46341时回绕成负地址,直接RuntimeError: memory access out of bounds;JS 永远不会出这个错,所以"直译"是陷阱。用i64算中间值,或把循环上界改成sqrt(n)。 - 视图永不缓存。
memory.grow让所有旧视图byteLength = 0且不报错 ;每次new Uint8Array(memory.buffer)花 30.9ns,换掉一整类幽灵 bug。 - 批量入口。 500 万次跨边界比"一次调用干 500 万次"慢 6.1 倍(2.37 vs 0.387 ns);而且批量顺手消掉了"每元素一次分配"。
- 字符串用
encodeInto,而且要批量。 73.5 ns/条 vs 325.5 ns/条(4.43x) ,差的不是编码,是分配。字符串整体过边界是 594.7 ns/条,除非算法本身在 wasm 里,否则别搬。 - 启动税要提前还。 编译 0.0009 ms/KB 、实例化 0.132 ms/实例 、首调溢价 +60.2%(20k 迭代)→ +0.3%(200 万迭代) 。Module 只编一次、实例池复用、空闲期预热、池容量按内存算不按核数算。
- 监控要自己接。 线性内存不在 JS 堆里 (128MB 内存旁
heapUsed只有 15.1MB)。指标只有三个:memory.buffer.byteLength、grow的返回值、你的highWater水位。水位只涨不落 = 泄漏。 - 确定性才是真卖点。 JS 多态调用点比单态慢 85.82x,wasm 没有 deopt、没有 GC 停顿、没有 JIT 预热曲线。要"最坏情况可预测"时选 wasm,要"平均快点"时先优化 JS。
4.2 与前后篇的咬合
- Day32(Worker/OffscreenCanvas) :wasm 和 worker 是天生一对------计算搬进 worker,内存用
transfer交过去(0.18ms/64MB),wasm 在 worker 的线性内存里就地改。但别忘了 Day32 的教训:搬运不省时间,只改"占用发生在哪个线程";wasm 同理,它不省时间,只改"这段代码长什么样"。 - Day30(bundle 优化) :
instantiateStreaming是唯一能吃满网络的加载方式,前提是Content-Type: application/wasm;体积预算要把 wasm 和 JS 一起算。 - Day26(加载性能)/Day27(网络优化) :
<link rel="preload" as="fetch" crossorigin>+ 早编译 + 预热,把首调溢价挪出关键路径。 - Day29(运行时性能):wasm 的第一版优化永远是"先把分配干掉"(230ns/条),而不是"换语言"。
- Day34(性能监控) :本篇三条自定义指标(
byteLength/grow返回 /highWater)正好是下一篇埋点的输入。
4.3 一句话
wasm 不是"更快的 JS",它是"另一套内存模型"。 它的收益全部来自你在这套模型里能写出的算法(SIMD、SoA、无 GC), 它的成本全部来自这套模型与 JS 之间的翻译(ToInt32、UTF-8、grow、视图分离)。 先把翻译成本测出来,再决定值不值得翻译------本篇的 38 个数字,就是那笔账的汇率。
运行环境与文件清单
| 部分 | 口径 |
|---|---|
| 全部数字 | Node v22.23.3(V8 12.4)/ macOS / Apple Silicon ,process.hrtime.bigint() 计时,每项 5~15 轮取中位数 |
| 边界穿越 | 500 万次热调用点(单实例)+ 500 万次 8 实例轮转(megamorphic) |
| 计算对位 | 同一块线性内存,JS 用 Float64Array/Float32Array 视图读同一份数据 |
| 字符串 | 2 万条 63 字节中英混排,TextEncoder/TextDecoder 默认实例 |
| 内存 | 128MB 线性内存(2048 页,max 4096)+ 16 页小模块分开测 |
| 启动 | 1 / 200 / 1600 份同构函数造 3 档体积;首调 vs 热调各 15 轮中位数 |
| 断言 | bench.mjs 38 条 + kernels.mjs 13 条 + test-article-code.mjs 12 条 + extract-and-test.mjs 9 条,失败即 exit 1 |
| 工具链 | 无 emcc / wat2wasm / rustc :core.wasm 由本篇自带的 asm.mjs(192 行零依赖编码器)手写字节生成,1221 字节 |
几点必须说清的边界:
- 本篇没有浏览器数据。 所有测量在 Node 的 V8 里完成,因为 wasm 的执行引擎就是同一个 V8。但浏览器的加载路径完全不同 (
instantiateStreaming、HTTP 缓存、COOP/COEP、WebAssembly.compileStreaming的 MIME 校验),这部分依赖 Day26/Day30/Day32 的结论,本篇不重复测量。 - 绝对毫秒会随机器漂移,比值稳定。 跨边界单次成本在不同轮次间从 2.37ns 漂到 2.93ns ,xorshift 的 JS/WASM 从 0.87x 漂到 1.01x。所以断言只钉方向与量级("打平"= <1.3x),不钉绝对值。
sieve的 1.68x 里含一部分"实现差异"。 JS 版每轮都重新new Uint8Array(LIMIT)并fill(1),wasm 版复用同一块内存用memory.fill。前者更接近真实用法(每次任务新分配),后者省掉了分配------这 1.68x 不能全记在"wasm 更快"账上,约有 20~30% 是分配差异。- SIMD 只在标量对照下测了 4 lane f32。 没有测
i32x4/f64x2、没有测多累加器展开(那通常还能再快 10~20%)、没有测wasm-opt级别的优化产物(手写字节没有优化器)。 - 38 条断言不是全集。 真实项目还应包含:跨平台(Safari/JSC 的 wasm 实现差异)、
maximum打满时的grow失败路径、超长任务下的内存水位、instantiateStreaming的 MIME 降级。
复现:
bash
cd day33-assets
node kernels.mjs # 写出 core.wasm + 13 条自检
node --expose-gc bench.mjs # 7 组基准 + 38 条断言(约 2 分钟)
node test-article-code.mjs # 本文 Arena/Pool/守卫代码:12 条断言
node boundary-probe.mjs # 边界 ToNumber/ToInt32 语义逐条打印
node extract-and-test.mjs # 从 ../Day33-WASM.md 抽出的正文代码块:9 条断言
文件清单:
| 文件 | 作用 |
|---|---|
asm.mjs |
零依赖 wasm 二进制编码器(192 行:ULEB/SLEB、各段、近 50 个指令含 SIMD/bulk-memory) |
kernels.mjs |
用编码器造出 15 个导出函数 + core.wasm + 13 条自检 |
core.wasm |
1221 字节的产物(可直接 WebAssembly.instantiate) |
abi.mjs |
文中贴出的 Arena / WebAssembly.Pool 同款池 / i32 守卫 |
bench.mjs |
7 组基准 + 38 条断言,results.json 存档 |
test-article-code.mjs |
对 abi.mjs(= 正文代码)跑 12 条断言 |
extract-and-test.mjs |
从正文 Markdown 抽代码块 → 落地 → 跑 9 条断言(文中代码自证) |
boundary-probe.mjs |
边界强制转换语义逐条打印(难点二那张表的来源) |
bench-log.txt |
本次运行的完整输出 |
(Node v22.23.3 / V8 12.4 / macOS / Apple Silicon,2026-10-24)
附录:完整代码
以下脚本均来自
day33-assets/(完整源码)。core.wasm是二进制产物(约 1.2KB),由kernels.mjs用内置汇编器生成,故不贴字节码、贴生成器。
运行:node bench.mjs跑基准;node extract-and-test.mjs提取正文代码块逐段自检;node test-article-code.mjs验证正文全部断言。
kernels.mjs ------ 手写 WASM 内核(用内置汇编器生成字节码)
javascript
// kernels.mjs ------ 用手写字节构造 Day33 基准用到的全部 wasm 导出函数
// 运行:node kernels.mjs 会写出 core.wasm 并打印每个导出的自检结果
import { ModuleBuilder, op, T } from './asm.mjs';
import { writeFileSync } from 'node:fs';
const o = op;
export function buildCore({ memoryPages = 256, maxPages = 1024 } = {}) {
const b = new ModuleBuilder({ memoryPages, maxPages, name: 'day33-core' });
// 1) 边界穿越的最小函数:add(i32,i32)
b.func('add', [T.i32, T.i32], [T.i32], [T.i32], [
o.local_get(0), o.local_get(1), o.i32_add,
]);
// 2) 纯 ALU 循环:sum_i32(n) = Σ i
b.func('sum_i32', [T.i32], [T.i32], [T.i32, T.i32], [
o.i32_const(0), o.local_set(1),
o.i32_const(0), o.local_set(2),
o.block, o.loop,
o.local_get(1), o.local_get(0), o.i32_ge_s, o.br_if(1),
o.local_get(2), o.local_get(1), o.i32_add, o.local_set(2),
o.local_get(1), o.i32_const(1), o.i32_add, o.local_set(1),
o.br(0),
o.end, o.end,
o.local_get(2),
]);
// 3) 纯算术浮点循环:sum_f64_arith(n) = Σ (s*1.0000001 + i)
b.func('sum_f64_arith', [T.i32], [T.f64], [T.i32, T.f64], [
o.i32_const(0), o.local_set(1),
o.f64_const(0), o.local_set(2),
o.block, o.loop,
o.local_get(1), o.local_get(0), o.i32_ge_s, o.br_if(1),
o.local_get(2), o.f64_const(1.0000001), o.f64_mul,
o.local_get(1), o.f64_convert_i32_s, o.f64_add, o.local_set(2),
o.local_get(1), o.i32_const(1), o.i32_add, o.local_set(1),
o.br(0),
o.end, o.end,
o.local_get(2),
]);
// 4) 线性内存 f64 求和:sum_f64(ptr, len)
b.func('sum_f64', [T.i32, T.i32], [T.f64], [T.i32, T.f64], [
o.i32_const(0), o.local_set(2),
o.f64_const(0), o.local_set(3),
o.block, o.loop,
o.local_get(2), o.local_get(1), o.i32_ge_s, o.br_if(1),
o.local_get(3),
o.local_get(0), o.local_get(2), o.i32_const(8), o.i32_mul, o.i32_add, o.f64_load(3, 0),
o.f64_add, o.local_set(3),
o.local_get(2), o.i32_const(1), o.i32_add, o.local_set(2),
o.br(0),
o.end, o.end,
o.local_get(3),
]);
// 5) f32 点积(标量):dot_f32(a, b, len)
b.func('dot_f32', [T.i32, T.i32, T.i32], [T.f32], [T.i32, T.f32], [
o.i32_const(0), o.local_set(3),
o.f32_const(0), o.local_set(4),
o.block, o.loop,
o.local_get(3), o.local_get(2), o.i32_ge_s, o.br_if(1),
o.local_get(4),
o.local_get(0), o.local_get(3), o.i32_const(4), o.i32_mul, o.i32_add, o.f32_load(2, 0),
o.local_get(1), o.local_get(3), o.i32_const(4), o.i32_mul, o.i32_add, o.f32_load(2, 0),
o.f32_mul, o.f32_add, o.local_set(4),
o.local_get(3), o.i32_const(1), o.i32_add, o.local_set(3),
o.br(0),
o.end, o.end,
o.local_get(4),
]);
// 6) f32 点积(SIMD 4 lane + 标量尾巴):dot_f32_simd(a, b, len)
b.func('dot_f32_simd', [T.i32, T.i32, T.i32], [T.f32], [T.i32, T.i32, T.f32, T.v128], [
// locals: 3=v(向量下标), 4=nvec = len>>2, 5=s, 6=acc
o.i32_const(0), o.local_set(3),
o.local_get(2), o.i32_const(2), o.i32_shr_u, o.local_set(4),
o.f32_const(0), o.local_set(5),
o.v128_zero, o.local_set(6),
o.block, o.loop,
o.local_get(3), o.local_get(4), o.i32_ge_s, o.br_if(1),
o.local_get(6),
o.local_get(0), o.local_get(3), o.i32_const(16), o.i32_mul, o.i32_add, o.v128_load(4, 0),
o.local_get(1), o.local_get(3), o.i32_const(16), o.i32_mul, o.i32_add, o.v128_load(4, 0),
o.f32x4_mul, o.f32x4_add, o.local_set(6),
o.local_get(3), o.i32_const(1), o.i32_add, o.local_set(3),
o.br(0),
o.end, o.end,
// 水平求和 4 lane
o.local_get(6), o.f32x4_extract_lane(0),
o.local_get(6), o.f32x4_extract_lane(1), o.f32_add,
o.local_get(6), o.f32x4_extract_lane(2), o.f32_add,
o.local_get(6), o.f32x4_extract_lane(3), o.f32_add,
o.local_set(5),
// 尾巴:元素下标 = nvec*4
o.local_get(3), o.i32_const(4), o.i32_mul, o.local_set(3),
o.block, o.loop,
o.local_get(3), o.local_get(2), o.i32_ge_s, o.br_if(1),
o.local_get(5),
o.local_get(0), o.local_get(3), o.i32_const(4), o.i32_mul, o.i32_add, o.f32_load(2, 0),
o.local_get(1), o.local_get(3), o.i32_const(4), o.i32_mul, o.i32_add, o.f32_load(2, 0),
o.f32_mul, o.f32_add, o.local_set(5),
o.local_get(3), o.i32_const(1), o.i32_add, o.local_set(3),
o.br(0),
o.end, o.end,
o.local_get(5),
]);
// 7) 素数筛:sieve(n, buf) -> 素数个数
// 注意:i*i 用 i64 算。i32 乘法在 i > 46341 时会回绕成负数,
// 负数 j 直接写进内存 = 越界 trap(这不是 JS 的行为,JS 用 double 不溢出)
b.func('sieve', [T.i32, T.i32], [T.i32], [T.i32, T.i32, T.i64, T.i64, T.i32], [
o.local_get(1), o.i32_const(1), o.local_get(0), o.memory_fill,
o.local_get(0), o.i64_extend_i32_u, o.local_set(5), // n64
o.i32_const(2), o.local_set(2), // i = 2
o.block, o.loop,
o.local_get(2), o.i64_extend_i32_u,
o.local_get(2), o.i64_extend_i32_u, o.i64_mul, o.local_set(4), // j64 = (i64)i*i
o.local_get(4), o.local_get(5), o.i64_ge_u, o.br_if(1), // i*i >= n → 停
o.local_get(1), o.local_get(2), o.i32_add, o.i32_load8_u(0, 0),
o.if_void,
o.local_get(4), o.i32_wrap_i64, o.local_set(3), // j = wrap(j64),此时 j64 < n 安全
o.block, o.loop,
o.local_get(3), o.local_get(0), o.i32_ge_s, o.br_if(1),
o.local_get(1), o.local_get(3), o.i32_add, o.i32_const(0), o.i32_store8(0, 0),
o.local_get(3), o.local_get(2), o.i32_add, o.local_set(3),
o.br(0),
o.end, o.end,
o.end,
o.local_get(2), o.i32_const(1), o.i32_add, o.local_set(2),
o.br(0),
o.end, o.end,
// count
o.i32_const(0), o.local_set(6),
o.i32_const(2), o.local_set(2),
o.block, o.loop,
o.local_get(2), o.local_get(0), o.i32_ge_s, o.br_if(1),
o.local_get(6), o.local_get(1), o.local_get(2), o.i32_add, o.i32_load8_u(0, 0), o.i32_add, o.local_set(6),
o.local_get(2), o.i32_const(1), o.i32_add, o.local_set(2),
o.br(0),
o.end, o.end,
o.local_get(6),
]);
// 8) xorshift32:hash(seed, iters)
b.func('hash32', [T.i32, T.i32], [T.i32], [T.i32, T.i32], [
o.local_get(0), o.local_set(2),
o.i32_const(0), o.local_set(3),
o.block, o.loop,
o.local_get(3), o.local_get(1), o.i32_ge_s, o.br_if(1),
o.local_get(2), o.local_get(2), o.i32_const(13), o.i32_shl, o.i32_xor,
o.local_get(2), o.i32_const(17), o.i32_shr_u, o.i32_xor, o.local_set(2),
o.local_get(2), o.local_get(2), o.i32_const(5), o.i32_shl, o.i32_xor, o.local_set(2),
o.local_get(3), o.i32_const(1), o.i32_add, o.local_set(3),
o.br(0),
o.end, o.end,
o.local_get(2),
]);
// 9) strlen(ptr)
b.func('strlen', [T.i32], [T.i32], [T.i32], [
o.i32_const(0), o.local_set(1),
o.block, o.loop,
o.local_get(0), o.local_get(1), o.i32_add, o.i32_load8_u(0, 0), o.i32_eqz, o.br_if(1),
o.local_get(1), o.i32_const(1), o.i32_add, o.local_set(1),
o.br(0),
o.end, o.end,
o.local_get(1),
]);
// 9b) 批量 strlen:strlens(ptrArr, count) ------ 一次穿越处理 N 条字符串
b.func('strlens', [T.i32, T.i32], [T.i32], [T.i32, T.i32, T.i32, T.i32], [
// locals: 2=k, 3=total, 4=p, 5=l
o.i32_const(0), o.local_set(2),
o.i32_const(0), o.local_set(3),
o.block, o.loop,
o.local_get(2), o.local_get(1), o.i32_ge_s, o.br_if(1),
o.local_get(0), o.local_get(2), o.i32_const(4), o.i32_mul, o.i32_add, o.i32_load(2, 0), o.local_set(4),
o.i32_const(0), o.local_set(5),
o.block, o.loop,
o.local_get(4), o.local_get(5), o.i32_add, o.i32_load8_u(0, 0), o.i32_eqz, o.br_if(1),
o.local_get(5), o.i32_const(1), o.i32_add, o.local_set(5),
o.br(0),
o.end, o.end,
o.local_get(3), o.local_get(5), o.i32_add, o.local_set(3),
o.local_get(2), o.i32_const(1), o.i32_add, o.local_set(2),
o.br(0),
o.end, o.end,
o.local_get(3),
]);
// 10) i64 往返:i64_add(a,b) ------ 测 BigInt 穿越成本
b.func('i64_add', [T.i64, T.i64], [T.i64], [], [
o.local_get(0), o.local_get(1), o.i64_add,
]);
// 11) 内存管理:grow(n) / size()
b.func('grow', [T.i32], [T.i32], [], [o.local_get(0), o.memory_grow]);
b.func('size', [], [T.i32], [], [o.memory_size]);
// 12) 越界读(演示"静默"与"trap"的边界)
b.func('read_at', [T.i32], [T.i32], [], [o.local_get(0), o.i32_load(2, 0)]);
// 13) bulk copy:copy(dst, src, len)
b.func('copy', [T.i32, T.i32, T.i32], [], [], [
o.local_get(0), o.local_get(1), o.local_get(2), o.memory_copy,
]);
return b.build();
}
if (import.meta.url === `file://${process.argv[1]}`) {
const bytes = buildCore();
writeFileSync(new URL('./core.wasm', import.meta.url), bytes);
console.log('core.wasm', bytes.length, 'bytes, valid =', WebAssembly.validate(bytes));
const { exports: e } = new WebAssembly.Instance(new WebAssembly.Module(bytes), {});
const mem = e.memory;
const f64 = new Float64Array(mem.buffer, 0, 4);
f64.set([1, 2, 3, 4]);
const f32 = new Float32Array(mem.buffer, 1024, 8);
f32.set([1, 2, 3, 4, 5, 6, 7, 8]);
const checks = [
['add(2,3)', e.add(2, 3), 5],
['sum_i32(10)', e.sum_i32(10), 45],
['sum_f64_arith(3)', Math.round(e.sum_f64_arith(3)), 3],
['sum_f64(0,4)', e.sum_f64(0, 4), 10],
['dot_f32(256,256,8)', +e.dot_f32(1024, 1024, 8).toFixed(4), 204],
['dot_f32_simd(256,256,8)', +e.dot_f32_simd(1024, 1024, 8).toFixed(4), 204],
['sieve(1000, buf)', e.sieve(1000, 4096), 168],
['hash32(1,3)', e.hash32(1, 3), e.hash32(1, 3)],
['strlen(0)', e.strlen(0), 0],
['strlens(ptrArr,3)', (() => {
const dv = new DataView(mem.buffer);
const u = new Uint8Array(mem.buffer);
u[100] = 65; u[101] = 66; u[102] = 0;
u[200] = 65; u[201] = 0;
u[300] = 65; u[301] = 66; u[302] = 67; u[303] = 0;
dv.setInt32(512, 100, true); dv.setInt32(516, 200, true); dv.setInt32(520, 300, true);
return e.strlens(512, 3);
})(), 2 + 1 + 3],
['i64_add', e.i64_add(1n, 2n), 3n],
['size()', e.size(), 256],
['read_at(0)', e.read_at(0), 0],
];
let bad = 0;
for (const [name, got, want] of checks) {
const ok = got === want;
if (!ok) bad++;
console.log(`${ok ? 'PASS' : 'FAIL'} ${name} = ${got}${ok ? '' : ` (expected ${want})`}`);
}
console.log(bad ? `\n${bad} 项自检失败` : '\n全部自检通过');
process.exit(bad ? 1 : 0);
}
asm.mjs ------ 极简 WASM 汇编器(无 wat2wasm 时用)
javascript
// asm.mjs ------ 零依赖 WebAssembly 二进制编码器(只为 Day33 基准服务)
// 目标:在没有 wat2wasm / emcc / rustc 的机器上,手写 .wasm 字节,构造真实模块。
export const T = { i32: 0x7f, i64: 0x7e, f32: 0x7d, f64: 0x7c, v128: 0x7b };
export const uleb = (n) => {
const out = [];
let v = n >>> 0;
do {
let b = v & 0x7f;
v >>>= 7;
if (v !== 0) b |= 0x80;
out.push(b);
} while (v !== 0);
return out;
};
export const sleb = (n) => {
const out = [];
let v = n | 0;
let more = true;
while (more) {
let b = v & 0x7f;
v >>= 7;
if ((v === 0 && (b & 0x40) === 0) || (v === -1 && (b & 0x40) !== 0)) more = false;
else b |= 0x80;
out.push(b);
}
return out;
};
const f64bytes = (x) => {
const dv = new DataView(new ArrayBuffer(8));
dv.setFloat64(0, x, true);
return [0, 1, 2, 3, 4, 5, 6, 7].map((i) => dv.getUint8(i));
};
const f32bytes = (x) => {
const dv = new DataView(new ArrayBuffer(4));
dv.setFloat32(0, x, true);
return [0, 1, 2, 3].map((i) => dv.getUint8(i));
};
// ---------- 指令集(只收本篇用到的) ----------
export const op = {
unreachable: [0x00], nop: [0x01],
block: [0x02, 0x40], loop: [0x03, 0x40], if_void: [0x04, 0x40], else_: [0x05], end: [0x0b],
br: (d) => [0x0c, ...uleb(d)],
br_if: (d) => [0x0d, ...uleb(d)],
return_: [0x0f],
call: (i) => [0x10, ...uleb(i)],
local_get: (i) => [0x20, ...uleb(i)],
local_set: (i) => [0x21, ...uleb(i)],
local_tee: (i) => [0x22, ...uleb(i)],
i32_const: (n) => [0x41, ...sleb(n)],
i64_const: (n) => [0x42, ...sleb(n)],
f32_const: (x) => [0x43, ...f32bytes(x)],
f64_const: (x) => [0x44, ...f64bytes(x)],
i32_load: (align, off) => [0x28, ...uleb(align), ...uleb(off)],
i32_load8_u: (align, off) => [0x2d, ...uleb(align), ...uleb(off)],
f32_load: (align, off) => [0x2a, ...uleb(align), ...uleb(off)],
f64_load: (align, off) => [0x2b, ...uleb(align), ...uleb(off)],
i32_store: (align, off) => [0x36, ...uleb(align), ...uleb(off)],
i32_store8: (align, off) => [0x3a, ...uleb(align), ...uleb(off)],
f32_store: (align, off) => [0x38, ...uleb(align), ...uleb(off)],
f64_store: (align, off) => [0x39, ...uleb(align), ...uleb(off)],
i32_eqz: [0x45], i32_eq: [0x46], i32_ne: [0x47],
i32_lt_s: [0x48], i32_lt_u: [0x49], i32_gt_s: [0x4a], i32_gt_u: [0x4b],
i32_le_s: [0x4c], i32_le_u: [0x4d], i32_ge_s: [0x4e], i32_ge_u: [0x4f],
i32_add: [0x6a], i32_sub: [0x6b], i32_mul: [0x6c], i32_div_u: [0x6e],
i32_rem_u: [0x70], i32_and: [0x71], i32_or: [0x72], i32_xor: [0x73],
i32_shl: [0x74], i32_shr_u: [0x76],
i64_add: [0x7c], i64_mul: [0x7e], i64_shr_u: [0x88], i64_xor: [0x85],
i64_ge_u: [0x5a], i64_lt_u: [0x54],
i32_wrap_i64: [0xa7],
f32_add: [0x92], f32_sub: [0x93], f32_mul: [0x94], f32_div: [0x95],
f64_add: [0xa0], f64_sub: [0xa1], f64_mul: [0xa2], f64_div: [0xa3],
f64_sqrt: [0x9f],
f64_convert_i32_s: [0xb7], f64_convert_i32_u: [0xb8],
f32_convert_i32_s: [0xb2], f32_demote_f64: [0xb6],
i64_extend_i32_u: [0xad],
select: [0x1b],
memory_size: [0x3f, 0x00],
memory_grow: [0x40, 0x00],
// 0xFD 前缀(SIMD / bulk memory)
v128_load: (align, off) => [0xfd, 0x00, ...uleb(align), ...uleb(off)],
v128_store: (align, off) => [0xfd, 0x0b, ...uleb(align), ...uleb(off)],
f32x4_splat: [0xfd, 0x11],
f32x4_extract_lane: (lane) => [0xfd, 0x1f, lane],
f32x4_add: [0xfd, ...uleb(0xe4)],
f32x4_mul: [0xfd, ...uleb(0xe6)],
v128_zero: [0xfd, 0x0c, ...new Array(16).fill(0)],
memory_copy: [0xfc, 0x0a, 0x00, 0x00],
memory_fill: [0xfc, 0x0b, 0x00],
};
// ---------- 模块装配 ----------
// 避免大模块时 spread 爆栈:全部用逐字节 push
const cat = (arrays) => {
const out = [];
for (const a of arrays) for (let i = 0; i < a.length; i++) out.push(a[i]);
return out;
};
const vec = (items) => cat([uleb(items.length), ...items]);
const sec = (id, payload) => (payload.length ? cat([[id], uleb(payload.length), payload]) : []);
export class ModuleBuilder {
constructor({ memoryPages = 2, maxPages = null, exportMemory = true, name = 'm' } = {}) {
this.types = []; // [[params[], results[]]]
this.funcs = []; // { name, typeIdx, locals, body }
this.exports = []; // { name, kind, idx }
this.memoryPages = memoryPages;
this.maxPages = maxPages;
this.exportMemory = exportMemory;
this.name = name;
this.globals = [];
this.data = [];
}
_typeIdx(params, results) {
const key = JSON.stringify([params, results]);
let i = this.types.findIndex((t) => JSON.stringify(t) === key);
if (i < 0) {
this.types.push([params, results]);
i = this.types.length - 1;
}
return i;
}
func(name, params, results, locals, body) {
const typeIdx = this._typeIdx(params, results);
const idx = this.funcs.length;
this.funcs.push({ name, typeIdx, params, results, locals, body });
this.exports.push({ name, kind: 0, idx });
return idx;
}
dataSeg(offset, bytes) {
this.data.push({ offset, bytes });
}
build() {
const out = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
// type
const typeSec = vec(this.types.map(([p, r]) => cat([[0x60], vec(p.map((t) => [t])), vec(r.map((t) => [t]))])));
// function
const funcSec = vec(this.funcs.map((f) => uleb(f.typeIdx)));
// memory
const limits = this.maxPages == null
? [0x00, ...uleb(this.memoryPages)]
: [0x01, ...uleb(this.memoryPages), ...uleb(this.maxPages)];
const memSec = vec([limits]);
// global
const globSec = vec(this.globals.map((g) => [g.type, 0x01, ...g.init]));
// export
const expSec = vec([
...this.exports.map((e) => cat([uleb(e.name.length), [...e.name].map((c) => c.charCodeAt(0)), [e.kind], uleb(e.idx)])),
...(this.exportMemory ? [cat([uleb(6), [...'memory'].map((c) => c.charCodeAt(0)), [0x02], uleb(0)])] : []),
]);
// code
const bodyOf = (f) => {
// locals:按类型分组
const groups = [];
for (const t of f.locals) {
const last = groups[groups.length - 1];
if (last && last[1] === t) last[0] += 1;
else groups.push([1, t]);
}
const localsVec = vec(groups.map(([n, t]) => cat([uleb(n), [t]])));
const code = cat([localsVec, ...f.body, [0x0b]]);
return cat([uleb(code.length), code]);
};
const codeSec = vec(this.funcs.map(bodyOf));
// data(active, memory 0)
const dataSec = vec(this.data.map((d) => cat([[0x00], op.i32_const(d.offset), [0x0b], uleb(d.bytes.length), d.bytes])));
// name section(方便 wasm 调试/可读性工具)
const namePayload = (() => {
const funcNames = vec(this.funcs.map((f, i) => cat([uleb(i), uleb(f.name.length), [...f.name].map((c) => c.charCodeAt(0))])));
const sub = cat([[0x01], uleb(funcNames.length), funcNames]);
const modName = cat([uleb(this.name.length), [...this.name].map((c) => c.charCodeAt(0))]);
const sub0 = cat([[0x00], uleb(modName.length), modName]);
return cat([sub0, sub]);
})();
const parts = [
sec(1, typeSec), sec(3, funcSec), sec(5, memSec), sec(6, globSec),
sec(7, expSec), sec(10, codeSec), sec(11, dataSec),
sec(0, cat([uleb(4), [...'name'].map((c) => c.charCodeAt(0)), namePayload])),
];
for (const p of parts) for (let i = 0; i < p.length; i++) out.push(p[i]);
return new Uint8Array(out);
}
}
abi.mjs ------ WASM <-> JS 边界与 ABI(内存视图、字符串传递)
javascript
// abi.mjs ------ WASM 胶水层:arena 分配器 + 视图管理 + ABI 入参守卫 + 实例池
// 文中「完整解法」贴的就是这份代码,test-article-code.mjs 会对它跑断言。
const PAGE = 65536;
const I32_MAX = 2147483647;
const I32_MIN = -2147483648;
// ---------- 1. 入参守卫:因为边界只做 ToNumber + ToInt32,不做类型检查 ----------
export const i32 = (v, name = 'arg') => {
if (typeof v !== 'number' || Number.isNaN(v)) {
throw new TypeError(`${name} 必须是 number,收到 ${typeof v} ${String(v)}(wasm 会静默把它变成 0)`);
}
if (!Number.isFinite(v)) throw new TypeError(`${name} 必须是有限数,收到 ${v}`);
if (!Number.isInteger(v)) throw new TypeError(`${name} 必须是整数,收到 ${v}(wasm 会静默向零截断)`);
if (v > I32_MAX || v < I32_MIN) throw new RangeError(`${name} 超出 i32 范围:${v}(wasm 会静默回绕)`);
return v;
};
// ---------- 2. Arena:bump allocator + 视图失效自愈 ----------
export class Arena {
constructor(memory, { base = PAGE, enc = new TextEncoder(), dec = new TextDecoder() } = {}) {
this.memory = memory;
this.base = base;
this.brk = base;
this.highWater = base; // 历史最高水位:泄漏检测的唯一线索
this.utf8 = enc;
this.text = dec;
this.buffer = null;
this.u8 = this.i32v = this.f64v = this.dv = null;
this.sync();
}
// 返回 true 表示「视图被重建过一次」------所有缓存过的视图此刻都已经是分离状态
sync() {
if (this.buffer === this.memory.buffer) return false;
this.buffer = this.memory.buffer;
this.u8 = new Uint8Array(this.buffer);
this.i32v = new Int32Array(this.buffer);
this.f64v = new Float64Array(this.buffer);
this.dv = new DataView(this.buffer);
return true;
}
get bytes() { this.sync(); return this.buffer.byteLength; }
// 每帧/每任务开始时调用,一次性回收所有临时分配
reset() { this.brk = this.base; }
alloc(size, align = 8) {
this.sync();
if (!Number.isInteger(size) || size < 0) throw new RangeError(`alloc 尺寸非法:${size}`);
const start = (this.brk + align - 1) & ~(align - 1);
const end = start + size;
if (end > this.buffer.byteLength) {
const pages = Math.ceil((end - this.buffer.byteLength) / PAGE);
const prev = this.memory.grow(pages); // 返回旧页数,失败返回 -1
if (prev < 0) throw new RangeError(`memory.grow 失败:+${pages} 页(可能撞到 maximum)`);
this.sync(); // 关键:旧视图已被分离,必须重建
}
this.brk = end;
if (end > this.highWater) this.highWater = end;
return start;
}
allocF64(len) { return this.alloc(len * 8, 8); }
allocF32(len) { return this.alloc(len * 4, 4); }
allocI32(len) { return this.alloc(len * 4, 4); }
// 视图一律即时创建,永不缓存(30.9ns/次,换正确性)
f64(ptr, len) { this.sync(); return new Float64Array(this.buffer, ptr, len); }
f32(ptr, len) { this.sync(); return new Float32Array(this.buffer, ptr, len); }
i32(ptr, len) { this.sync(); return new Int32Array(this.buffer, ptr, len); }
// 字符串入栈:encodeInto 复用同一块缓冲,避免每条字符串一次临时分配(实测 4.43x)
writeStr(s, withNul = true) {
if (typeof s !== 'string') throw new TypeError(`writeStr 只收 string,收到 ${typeof s}`);
this.sync();
const cap = s.length * 3 + 4; // UTF-8 最坏 3 字节/码元,另加 NUL
const ptr = this.alloc(cap, 4);
const { written } = this.utf8.encodeInto(s, this.u8.subarray(ptr, ptr + cap));
if (withNul) this.u8[ptr + written] = 0;
return { ptr, len: written };
}
// 字符串出栈:调用方必须自己带着 len(wasm 没有 String 类型,长度是 ABI 的一部分)
readStr(ptr, len) { this.sync(); return this.text.decode(this.u8.subarray(ptr, ptr + len)); }
stats() {
this.sync();
return {
bytes: this.buffer.byteLength,
base: this.base,
brk: this.brk,
live: this.brk - this.base,
highWater: this.highWater,
leakSuspect: this.highWater > this.base * 4,
};
}
}
// ---------- 3. 实例池:Module 共享、实例复用、arena 复位 ----------
export class WasmPool {
constructor(module, imports = {}, size = 1) {
this.module = module;
this.imports = imports;
this.idle = [];
this.size = 0;
this.capacity = size;
while (this.size < size) this.idle.push(this.spawn());
}
spawn() {
const inst = new WebAssembly.Instance(this.module, this.imports);
this.size += 1;
return { inst, arena: inst.exports.memory ? new Arena(inst.exports.memory) : null, busy: false };
}
acquire() {
const slot = this.idle.pop() ?? (this.size < this.capacity ? this.spawn() : null);
if (!slot) throw new Error(`池已满(${this.capacity}),说明有实例没归还`);
slot.busy = true;
slot.arena?.reset();
return slot;
}
release(slot) {
slot.busy = false;
this.idle.push(slot);
}
// 预热:把首调溢价(实测 60.2%)提前付掉,别让它落在用户交互的那一帧
warmup(fn = (slot) => ver(slot)) {
const slot = this.acquire();
try { fn(slot); } finally { this.release(slot); }
return this;
}
stats() { return { size: this.size, idle: this.idle.length, capacity: this.capacity }; }
}
const ver = (slot) => slot.inst.exports.size?.() ?? 0;
boundary-probe.mjs ------ 边界探测(找出跨边界调用的代价曲线)
javascript
import { buildCore } from './kernels.mjs';
const bytes = buildCore({ memoryPages: 16 });
const { exports: W } = new WebAssembly.Instance(new WebAssembly.Module(bytes), {});
const show = (label, fn) => { try { console.log(`${label.padEnd(28)} → ${String(fn())}`); } catch (e) { console.log(`${label.padEnd(28)} → ${e.constructor.name}: ${e.message.slice(0, 60)}`); } };
show("add('1', 2)", () => W.add('1', 2));
show("add('abc', 2)", () => W.add('abc', 2));
show("add(1.9, 2.9)", () => W.add(1.9, 2.9));
show("add(-1.9, 0)", () => W.add(-1.9, 0));
show("add({}, 2)", () => W.add({}, 2));
show("add(null, 2)", () => W.add(null, 2));
show("add(undefined, 2)", () => W.add(undefined, 2));
show("add(true, 2)", () => W.add(true, 2));
show("add({valueOf:()=>5}, 2)", () => W.add({ valueOf: () => 5 }, 2));
show("add(2n, 2)", () => W.add(2n, 2));
show("add(2**31, 0)", () => W.add(2 ** 31, 0));
show("add(NaN, 1)", () => W.add(NaN, 1));
show("i64_add(1, 2) 无 n", () => W.i64_add(1, 2));
show("missing arg add(1)", () => W.add(1));
show("extra arg add(1,2,3)", () => W.add(1, 2, 3));
// G6: 首调 vs 热调 敏感性
const mod = new WebAssembly.Module(buildCore({ memoryPages: 16 }));
const t = (fn, rounds = 9) => { const a = []; for (let r = 0; r < rounds; r++) { const t0 = performance.now(); fn(); a.push(performance.now() - t0); } a.sort((x,y)=>x-y); return a[a.length>>1]; };
for (const iters of [20_000, 200_000, 2_000_000]) {
const inst = new WebAssembly.Instance(mod, {});
const first = t(() => { const i = new WebAssembly.Instance(mod, {}); i.exports.hash32(7, iters); });
const inst2 = new WebAssembly.Instance(mod, {});
inst2.exports.hash32(7, iters);
const warm = t(() => inst2.exports.hash32(7, iters));
console.log(`iters=${String(iters).padStart(9)} 首调(含实例化) ${first.toFixed(3)}ms 热调 ${warm.toFixed(3)}ms 差 ${((first/warm-1)*100).toFixed(1)}%`);
}
bench.mjs ------ 基准主程序(JS vs WASM 各项对比)
javascript
// bench.mjs ------ Day33 WASM 基准:7 组真实测量
// 运行:node --expose-gc bench.mjs (全通过 → exit 0,任一失败 → exit 1)
import { buildCore } from './kernels.mjs';
import { ModuleBuilder, op, T } from './asm.mjs';
import { writeFileSync } from 'node:fs';
const R = { env: {}, groups: {} };
R.env = { node: process.version, v8: process.versions.v8, arch: process.arch, platform: process.platform };
const now = () => Number(process.hrtime.bigint()) / 1e6;
const med = (a) => {
const s = [...a].sort((x, y) => x - y);
const m = s.length >> 1;
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
};
function bench(fn, { rounds = 7 } = {}) {
const times = [];
for (let r = 0; r < rounds; r++) {
const t0 = now();
fn(r);
times.push(now() - t0);
}
return med(times);
}
const f = (x, d = 3) => (x === undefined || Number.isNaN(x) ? String(x) : x.toFixed(d));
const nsPer = (t, n) => (t / n) * 1e6; // ms/n → ns
const coreBytes = buildCore({ memoryPages: 2048, maxPages: 4096 });
const coreMod = new WebAssembly.Module(coreBytes);
const coreInst = new WebAssembly.Instance(coreMod, {});
const W = coreInst.exports;
const MEM = W.memory;
const u8 = () => new Uint8Array(MEM.buffer);
const f64v = (o = 0, n = 1) => new Float64Array(MEM.buffer, o, n);
const f32v = (o = 0, n = 1) => new Float32Array(MEM.buffer, o, n);
const i32v = (o = 0, n = 1) => new Int32Array(MEM.buffer, o, n);
const checks = [];
const assert = (name, cond, detail) => {
checks.push({ name, ok: !!cond, detail });
console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`);
};
// ============ G1 ============
console.log('\n=== G1 边界穿越(JS ↔ WASM call overhead) ===');
{
const N = 5_000_000;
const addJs = (a, b) => a + b;
const addWasm = W.add;
const tJs = bench(() => { let s = 0; for (let i = 0; i < N; i++) s = addJs(s, 1); return s; });
const tWasm = bench(() => { let s = 0; for (let i = 0; i < N; i++) s = addWasm(s, 1); return s; });
const tInWasm = bench(() => W.sum_i32(N));
console.log(`JS 本地函数 ${f(tJs, 1)}ms (${f(nsPer(tJs, N), 3)} ns/call)`);
console.log(`跨边界调用 ${f(tWasm, 1)}ms (${f(nsPer(tWasm, N), 3)} ns/call)`);
console.log(`循环留在 wasm 内 ${f(tInWasm, 2)}ms (${f(nsPer(tInWasm, N), 4)} ns/iter)`);
console.log(`→ 500 万次跨边界比"一次调用干 500 万次"慢 ${f(tWasm / tInWasm, 1)} 倍`);
const M = 1_000_000;
const tI32 = bench(() => { let s = 1; for (let i = 0; i < M; i++) s = addWasm(s, 1); return s; });
const tI64 = bench(() => { let s = 1n; for (let i = 0; i < M; i++) s = W.i64_add(s, 1n); return s; });
console.log(`i32 穿越 ${f(tI32, 1)}ms (${f(nsPer(tI32, M), 2)} ns/call)`);
console.log(`i64/BigInt 穿越 ${f(tI64, 1)}ms (${f(nsPer(tI64, M), 2)} ns/call) → ${f(tI64 / tI32, 2)}x`);
const pool = Array.from({ length: 8 }, () => new WebAssembly.Instance(coreMod, {}).exports.add);
const tMega = bench(() => { let s = 0; for (let i = 0; i < N; i++) s = pool[i & 7](s, 1); return s; });
console.log(`8 实例轮转调用 ${f(tMega, 1)}ms (${f(nsPer(tMega, N), 2)} ns/call) → 比单实例慢 ${f(tMega / tWasm, 2)}x`);
R.groups.G1 = {
N, tJs, tWasm, tInWasm,
nsJs: nsPer(tJs, N), nsWasm: nsPer(tWasm, N), nsInWasm: nsPer(tInWasm, N),
chattyVsBatch: tWasm / tInWasm, tI32, tI64, i64Ratio: tI64 / tI32,
tMega, nsMega: nsPer(tMega, N), megaVsMono: tMega / tWasm,
};
assert('G1 跨边界调用 0.5~10ns/call', nsPer(tWasm, N) > 0.5 && nsPer(tWasm, N) < 10, `${f(nsPer(tWasm, N), 2)}ns`);
assert('G1 JS 本地调用比跨边界快', nsPer(tJs, N) < nsPer(tWasm, N), `${f(nsPer(tJs, N), 3)} vs ${f(nsPer(tWasm, N), 3)}ns`);
assert('G1 500 万次跨边界比留在 wasm 内慢 5x 以上', tWasm / tInWasm > 5, `${f(tWasm / tInWasm, 1)}x`);
assert('G1 i64/BigInt 穿越明显更贵', tI64 / tI32 > 1.5, `${f(tI64 / tI32, 2)}x`);
assert('G1 多实例轮转调用点显著变慢(megamorphic)', tMega / tWasm > 1.2, `${f(tMega / tWasm, 2)}x`);
}
// ============ G2 ============
console.log('\n=== G2 同算法对位(JS vs WASM) ===');
{
const N = 20_000_000;
const tWasmArith = bench(() => W.sum_f64_arith(N));
const tJsArith = bench(() => { let s = 0; for (let i = 0; i < N; i++) s = s * 1.0000001 + i; return s; });
const COUNT = 10_000_000;
const ptr = 65536;
const fill = f64v(ptr, COUNT);
for (let i = 0; i < COUNT; i++) fill[i] = (i % 97) * 0.5;
const tWasmSum = bench(() => W.sum_f64(ptr, COUNT), { rounds: 5 });
const tJsSum = bench(() => { let s = 0; const a = f64v(ptr, COUNT); for (let i = 0; i < COUNT; i++) s += a[i]; return s; }, { rounds: 5 });
const ITER = 20_000_000;
const tWasmHash = bench(() => W.hash32(1, ITER));
const tJsHash = bench(() => {
let x = 1;
for (let i = 0; i < ITER; i++) { x ^= x << 13; x |= 0; x ^= x >>> 17; x ^= x << 5; x |= 0; }
return x;
});
const LIMIT = 5_000_000;
const SBUF = 1 << 24;
const tWasmSieve = bench(() => W.sieve(LIMIT, SBUF), { rounds: 5 });
const tJsSieve = bench(() => {
const buf = new Uint8Array(LIMIT);
buf.fill(1);
for (let i = 2; i * i < LIMIT; i++) if (buf[i]) for (let j = i * i; j < LIMIT; j += i) buf[j] = 0;
let c = 0;
for (let i = 2; i < LIMIT; i++) c += buf[i];
return c;
}, { rounds: 5 });
console.log(`浮点算术 2e7 次 JS ${f(tJsArith, 1)}ms WASM ${f(tWasmArith, 1)}ms JS/WASM ${f(tJsArith / tWasmArith, 2)}x`);
console.log(`内存求和 1e7 f64 JS ${f(tJsSum, 1)}ms WASM ${f(tWasmSum, 1)}ms JS/WASM ${f(tJsSum / tWasmSum, 2)}x (${f((COUNT * 8) / 1048576, 0)}MB)`);
console.log(`xorshift 2e7 轮 JS ${f(tJsHash, 1)}ms WASM ${f(tWasmHash, 1)}ms JS/WASM ${f(tJsHash / tWasmHash, 2)}x`);
console.log(`素数筛 5e6 上限 JS ${f(tJsSieve, 1)}ms WASM ${f(tWasmSieve, 1)}ms JS/WASM ${f(tJsSieve / tWasmSieve, 2)}x`);
R.groups.G2 = {
N, tWasmArith, tJsArith, arithRatio: tJsArith / tWasmArith,
COUNT, sumMB: (COUNT * 8) / 1048576, tWasmSum, tJsSum, sumRatio: tJsSum / tWasmSum,
ITER, tWasmHash, tJsHash, hashRatio: tJsHash / tWasmHash,
LIMIT, tWasmSieve, tJsSieve, sieveRatio: tJsSieve / tWasmSieve,
};
assert('G2 浮点算术:JS/WASM 在 1.3x 内(打平)', tJsArith / tWasmArith < 1.3, `${f(tJsArith / tWasmArith, 2)}x`);
assert('G2 内存求和:JS/WASM 在 1.3x 内(打平)', tJsSum / tWasmSum < 1.3, `${f(tJsSum / tWasmSum, 2)}x`);
assert('G2 整数 kernel:JS/WASM 在 1.3x 内(打平)', tJsHash / tWasmHash < 1.3, `${f(tJsHash / tWasmHash, 2)}x`);
assert('G2 素数筛:wasm 明显快(>1.3x)', tJsSieve / tWasmSieve > 1.3, `${f(tJsSieve / tWasmSieve, 2)}x`);
}
// ============ G3 ============
console.log('\n=== G3 SIMD vs 标量(同一份 wasm 代码) ===');
{
const LEN = 1 << 20;
const A = 1 << 16, B = A + LEN * 4;
f32v(A, LEN).forEach((_, i, arr) => { arr[i] = (i % 31) * 0.25; });
f32v(B, LEN).forEach((_, i, arr) => { arr[i] = (i % 17) * 0.5; });
const runScalar = () => W.dot_f32(A, B, LEN);
const runSimd = () => W.dot_f32_simd(A, B, LEN);
const dotJs = () => { const a = f32v(A, LEN), b = f32v(B, LEN); let s = 0; for (let i = 0; i < LEN; i++) s += a[i] * b[i]; return s; };
const v1 = runScalar(), v2 = runSimd(), v3 = dotJs();
const tScalar = bench(runScalar, { rounds: 9 });
const tSimd = bench(runSimd, { rounds: 9 });
const tJs = bench(dotJs, { rounds: 9 });
R.groups.G3 = { LEN, tScalar, tSimd, tJs, simdSpeedup: tScalar / tSimd, jsVsScalar: tJs / tScalar, v1, v2, v3 };
console.log(`wasm 标量 ${f(tScalar, 2)}ms SIMD ${f(tSimd, 2)}ms → ${f(tScalar / tSimd, 2)}x`);
console.log(`纯 JS ${f(tJs, 2)}ms (相对 wasm 标量 ${f(tJs / tScalar, 2)}x)`);
assert('G3 SIMD 结果与标量一致(<0.1%)', Math.abs(v2 - v1) / Math.abs(v1) < 1e-3, `${f(v1, 1)} vs ${f(v2, 1)}`);
assert('G3 SIMD 加速 > 1.5x', tScalar / tSimd > 1.5, `${f(tScalar / tSimd, 2)}x`);
assert('G3 JS 标量点积与 wasm 标量同级(<1.5x)', tJs / tScalar < 1.5, `${f(tJs / tScalar, 2)}x`);
}
// ============ G4 ============
console.log('\n=== G4 字符串胶水(成本分解 + ABI 改造) ===');
{
const N = 20000;
const S = '前端研发系列-Day33-WebAssembly-字符串穿越测试-ABCDEFGH';
const strs = Array.from({ length: N }, (_, i) => `${S}${i}`);
const enc = new TextEncoder();
const dec = new TextDecoder();
const cached = strs.map((s) => enc.encode(s));
const lens = new Int32Array(N);
const BASE = 1 << 22;
const PTRARR = BASE + (1 << 21);
const layout = () => {
const mem = u8();
const dv = i32v(PTRARR, N);
let off = BASE;
for (let k = 0; k < N; k++) {
const b = cached[k];
mem.set(b, off);
mem[off + b.length] = 0;
lens[k] = b.length;
dv[k] = off;
off += b.length + 1;
}
return off;
};
layout();
const tEnc = bench(() => { let t = 0; for (let k = 0; k < N; k++) t += enc.encode(strs[k]).length; return t; });
const scratch = new Uint8Array(4096);
const tEncInto = bench(() => { let t = 0; for (let k = 0; k < N; k++) t += enc.encodeInto(strs[k], scratch).written; return t; });
const tCopy = bench(() => { const mem = u8(); let off = BASE; for (let k = 0; k < N; k++) { mem.set(cached[k], off); off += cached[k].length + 1; } return off; });
const tCalls = bench(() => { let t = 0; const dv = i32v(PTRARR, N); for (let k = 0; k < N; k++) t += W.strlen(dv[k]); return t; });
const tInside = bench(() => W.strlens(PTRARR, N));
const tDecode = bench(() => { let t = 0; const mem = u8(); const dv = i32v(PTRARR, N); for (let k = 0; k < N; k++) t += dec.decode(mem.subarray(dv[k], dv[k] + lens[k])).length; return t; });
const tJs = bench(() => { let t = 0; for (let k = 0; k < N; k++) t += strs[k].length; return t; });
const tNaive = bench(() => {
let total = 0;
const mem = u8();
let bump = BASE + (1 << 20);
for (let k = 0; k < N; k++) {
const bytes = enc.encode(strs[k]);
mem.set(bytes, bump);
total += W.strlen(bump);
bump += bytes.length + 1;
}
return total;
}, { rounds: 5 });
const p = (t) => nsPer(t, N);
R.groups.G4 = {
N, tEnc, tEncInto, tCopy, tCalls, tInside, tDecode, tJs, tNaive,
perNaive: p(tNaive), perEnc: p(tEnc), perEncInto: p(tEncInto), perCopy: p(tCopy),
perCalls: p(tCalls), perInside: p(tInside), perDecode: p(tDecode), perJs: p(tJs),
boundaryTaxPerString: p(tCalls) - p(tInside), encIntoGain: tEnc / tEncInto,
};
console.log(`TextEncoder.encode(每次分配) ${f(tEnc, 2)}ms ${f(p(tEnc), 1)} ns/条`);
console.log(`TextEncoder.encodeInto(复用) ${f(tEncInto, 2)}ms ${f(p(tEncInto), 1)} ns/条 → 省 ${f(tEnc / tEncInto, 2)}x`);
console.log(`写进线性内存(mem.set) ${f(tCopy, 2)}ms ${f(p(tCopy), 1)} ns/条`);
console.log(`逐条调用 strlen(2 万次穿越) ${f(tCalls, 2)}ms ${f(p(tCalls), 1)} ns/条`);
console.log(`一次调用内部处理 2 万条 ${f(tInside, 2)}ms ${f(p(tInside), 1)} ns/条 → 批量省 ${f(tCalls / tInside, 2)}x`);
console.log(`TextDecoder.decode 回 JS ${f(tDecode, 2)}ms ${f(p(tDecode), 1)} ns/条`);
console.log(`纯 JS .length ${f(tJs, 2)}ms ${f(p(tJs), 2)} ns/条`);
console.log(`naive 全流程(encode+set+call) ${f(tNaive, 2)}ms ${f(p(tNaive), 1)} ns/条 → 比纯 JS 贵 ${f(tNaive / tJs, 0)}x`);
assert('G4 逐条往返是百 ns 级', p(tNaive) > 100 && p(tNaive) < 2000, `${f(p(tNaive), 1)}ns`);
assert('G4 边界税本身极小(<10ns/条)', p(tCalls) - p(tInside) < 10, `${f(p(tCalls) - p(tInside), 1)}ns/条`);
assert('G4 单项 encode 就占 naive 一半以上', tEnc / tNaive > 0.5, `encode 占 ${f((tEnc / tNaive) * 100, 0)}%`);
assert('G4 逐条临时分配的残余成本显著(>100ns/条)', p(tNaive) - p(tEnc) - p(tCopy) - p(tCalls) > 100, `${f(p(tNaive) - p(tEnc) - p(tCopy) - p(tCalls), 0)}ns/条(分配+GC)`);
assert('G4 encodeInto 复用缓冲比 encode 快 2x 以上', tEnc / tEncInto > 2, `${f(tEnc / tEncInto, 2)}x`);
assert('G4 纯 JS 处理字符串比穿越便宜 10x 以上', tNaive / tJs > 10, `${f(tNaive / tJs, 0)}x`);
}
// ============ G5 ============
console.log('\n=== G5 线性内存模型(两个必踩的坑) ===');
{
const before = MEM.buffer.byteLength;
const cachedView = new Uint8Array(MEM.buffer);
cachedView[0] = 42;
const oldPages = W.size();
const growRet = W.grow(1);
const after = MEM.buffer.byteLength;
const detachedLen = cachedView.byteLength;
const detachedRead = cachedView[0];
const freshRead = new Uint8Array(MEM.buffer)[0];
console.log(`grow(1) 前 ${f(before / 1048576, 1)}MB → 后 ${f(after / 1048576, 1)}MB(grow 返回 ${growRet},旧 size=${oldPages})`);
console.log(`缓存的旧视图 byteLength=${detachedLen}, cachedView[0]=${detachedRead}, 新视图[0]=${freshRead}`);
assert('G5 grow 后旧视图被分离(byteLength=0)', detachedLen === 0, `len=${detachedLen}`);
assert('G5 grow 后旧视图读不到数据', detachedRead === undefined || detachedRead === 0, `${detachedRead}`);
assert('G5 数据本身没丢(新视图仍读到 42)', freshRead === 42, `${freshRead}`);
assert('G5 grow 返回旧页数而不是新页数', growRet === oldPages, `${growRet} vs ${oldPages}`);
const lastByte = MEM.buffer.byteLength - 4;
const inBounds = W.read_at(lastByte);
let trapName = 'no-trap';
try { W.read_at(lastByte + 4); } catch (e) { trapName = e.constructor.name; }
console.log(`内存内 read_at(${lastByte}) = ${inBounds}(静默,不报错)`);
console.log(`越出内存 read_at(${lastByte + 4}) → ${trapName}`);
assert('G5 内存范围内越界静默(只查线性内存边界)', inBounds === 0, `${inBounds}`);
assert('G5 越出线性内存抛 RuntimeError', /RuntimeError|RangeError/.test(trapName), trapName);
const tView = bench(() => { let s = 0; for (let i = 0; i < 200_000; i++) { const v = new Uint8Array(MEM.buffer); s += v.length > 0 ? 1 : 0; } return s; }, { rounds: 5 });
const heapUsedMB = process.memoryUsage().heapUsed / 1048576;
const memMB = MEM.buffer.byteLength / 1048576;
console.log(`new Uint8Array(memory.buffer) × 20 万 = ${f(tView, 2)}ms (${f(nsPer(tView, 200_000), 1)} ns/次)`);
console.log(`线性内存 ${f(memMB, 0)}MB,JS heapUsed 仅 ${f(heapUsedMB, 1)}MB → wasm 内存不在 JS 堆里`);
R.groups.G5 = { before, after, growRet, oldPages, detachedLen, detachedRead, freshRead, inBounds, trapName, tView, nsPerView: nsPer(tView, 200_000), memMB, heapUsedMB };
assert('G5 wasm 线性内存不计入 JS 堆统计', memMB > heapUsedMB, `${f(memMB, 0)}MB vs ${f(heapUsedMB, 1)}MB`);
}
// ============ G6 ============
console.log('\n=== G6 启动税(编译 / 实例化 / 首次调用) ===');
{
const mk = (nFuncs) => {
const b = new ModuleBuilder({ memoryPages: 16, exportMemory: false, name: 'sized' });
for (let i = 0; i < nFuncs; i++) {
b.func(`h${i}`, [T.i32, T.i32], [T.i32], [T.i32, T.i32], [
op.local_get(0), op.local_set(2),
op.i32_const(0), op.local_set(3),
op.block, op.loop,
op.local_get(3), op.local_get(1), op.i32_ge_s, op.br_if(1),
op.local_get(2), op.local_get(2), op.i32_const(13), op.i32_shl, op.i32_xor,
op.local_get(2), op.i32_const(17), op.i32_shr_u, op.i32_xor, op.local_set(2),
op.local_get(2), op.local_get(2), op.i32_const(5), op.i32_shl, op.i32_xor, op.local_set(2),
op.local_get(3), op.i32_const(1), op.i32_add, op.local_set(3),
op.br(0),
op.end, op.end,
op.local_get(2),
]);
}
return b.build();
};
const rows = [];
for (const n of [1, 200, 1600]) {
const bytes = mk(n);
const tCompile = bench(() => { new WebAssembly.Module(bytes); }, { rounds: 5 });
const mod = new WebAssembly.Module(bytes);
const tInst = bench(() => { new WebAssembly.Instance(mod, {}); }, { rounds: 7 });
const tFirst = bench(() => {
const m = new WebAssembly.Module(bytes);
const i = new WebAssembly.Instance(m, {});
return i.exports.h0(7, 200_000);
}, { rounds: 7 });
const inst = new WebAssembly.Instance(mod, {});
inst.exports.h0(7, 200_000);
const tWarm = bench(() => inst.exports.h0(7, 200_000), { rounds: 7 });
rows.push({ n, bytes: bytes.length, tCompile, tInst, tFirst, tWarm });
console.log(`${String(bytes.length).padStart(7)}B (${String(n).padStart(4)} 函数) compile ${f(tCompile, 3)}ms instantiate ${f(tInst, 3)}ms 首调 ${f(tFirst, 3)}ms 热调 ${f(tWarm, 3)}ms`);
}
const slope = (rows[2].tCompile - rows[0].tCompile) / ((rows[2].bytes - rows[0].bytes) / 1024);
console.log(`→ 编译成本斜率 ≈ ${f(slope, 4)} ms/KB`);
const tCoreCompile = bench(() => { new WebAssembly.Module(coreBytes); }, { rounds: 7 });
const tPool8 = bench(() => { const a = []; for (let i = 0; i < 8; i++) a.push(new WebAssembly.Instance(coreMod, {})); return a.length; }, { rounds: 7 });
console.log(`core.wasm ${coreBytes.length}B:compile ${f(tCoreCompile, 3)}ms,建 8 实例 ${f(tPool8, 3)}ms → ${f(tPool8 / 8, 3)}ms/实例`);
R.groups.G6 = { rows, msPerKB: slope, tCoreCompile, tPool8, tPerInst: tPool8 / 8, coreBytes: coreBytes.length };
assert('G6 模块越大编译越久(斜率 > 0)', slope > 0, `${f(slope, 4)}ms/KB`);
assert('G6 小模块编译 < 5ms', rows[0].tCompile < 5, `${f(rows[0].tCompile, 3)}ms`);
assert('G6 复用 Module 后实例化便宜', rows[0].tInst < 1, `${f(rows[0].tInst, 3)}ms`);
// 首调溢价随任务变长而被摊薄
const smallMod = new WebAssembly.Module(mk(1)); // 16 页内存,避免 128MB 预留干扰
const fresh = (iters) => bench(() => {
const i = new WebAssembly.Instance(smallMod, {});
return i.exports.h0(7, iters);
}, { rounds: 15 });
const hot = (iters) => {
const i = new WebAssembly.Instance(smallMod, {});
i.exports.h0(7, iters);
return bench(() => i.exports.h0(7, iters), { rounds: 15 });
};
const shortFirst = fresh(20_000), shortHot = hot(20_000);
const longFirst = fresh(2_000_000), longHot = hot(2_000_000);
const premShort = shortFirst / shortHot - 1, premLong = longFirst / longHot - 1;
R.groups.G6.firstCall = { shortFirst, shortHot, longFirst, longHot, premShort, premLong };
console.log(`首调溢价:20k 次迭代 ${f(premShort * 100, 1)}%(${f(shortFirst, 3)} vs ${f(shortHot, 3)}ms)、200 万次 ${f(premLong * 100, 1)}%(${f(longFirst, 3)} vs ${f(longHot, 3)}ms)`);
const startup = (tCoreCompile + tPool8 / 8) * 1e-3; // 秒
R.groups.G6.startupMS = tCoreCompile + tPool8 / 8;
console.log(`compile+实例化 = ${f(tCoreCompile + tPool8 / 8, 3)}ms(占 1ms 任务 ${f((tCoreCompile + tPool8 / 8) * 100, 1)}%)`);
assert('G6 首调溢价随任务变长而被摊薄', premShort > premLong, `${f(premShort * 100, 1)}% → ${f(premLong * 100, 1)}%`);
assert('G6 长任务上首调溢价 < 10%', premLong < 0.10, `${f(premLong * 100, 1)}%`);
}
// ============ G7 ============
console.log('\n=== G7 边界的真面目(ToNumber + ToInt32,不是类型检查) ===');
{
const N = 2_000_000;
const k = (a, b) => a + b;
const mono = () => { let s = 0; for (let i = 0; i < N; i++) s = k(s, 1); return s; };
const poly = () => {
let s = 0;
for (let i = 0; i < N; i++) {
s = i % 3 === 0 ? k(s, 1) : i % 3 === 1 ? k(String(s), '1') : k({ v: s }, s);
}
return s;
};
const tMono = bench(mono);
const tPoly = bench(poly);
const coerce = (label, fn) => { try { return { label, ok: true, v: fn() }; } catch (e) { return { label, ok: false, v: e.constructor.name }; } };
const cases = [
coerce("add('1', 2)", () => W.add('1', 2)),
coerce("add('abc', 2)", () => W.add('abc', 2)),
coerce("add({}, 2)", () => W.add({}, 2)),
coerce("add(null, 2)", () => W.add(null, 2)),
coerce("add(1.9, 2.9)", () => W.add(1.9, 2.9)),
coerce("add(-1.9, 0)", () => W.add(-1.9, 0)),
coerce("add(2**31, 0)", () => W.add(2 ** 31, 0)),
coerce("add(2n, 2)", () => W.add(2n, 2)),
coerce("add(1) 缺参", () => W.add(1)),
];
for (const c of cases) console.log(` ${c.label.padEnd(18)} → ${c.v}`);
const byLabel = Object.fromEntries(cases.map((c) => [c.label, c.v]));
console.log(`JS 单态调用点 ${f(tMono, 2)}ms (${f(nsPer(tMono, N), 2)} ns/call)`);
console.log(`JS 多态调用点 ${f(tPoly, 2)}ms (${f(nsPer(tPoly, N), 2)} ns/call) → 慢 ${f(tPoly / tMono, 2)}x`);
R.groups.G7 = { N, tMono, tPoly, polyPenalty: tPoly / tMono, cases: byLabel };
assert('G7 边界对字符串做 ToNumber(\'1\' → 1,不报错)', byLabel["add('1', 2)"] === 3, `= ${byLabel["add('1', 2)"]}`);
assert('G7 非数字字符串静默变 0(\'abc\' → 0)', byLabel["add('abc', 2)"] === 2, `= ${byLabel["add('abc', 2)"]}`);
assert('G7 对象/null 静默变 0', byLabel["add({}, 2)"] === 2 && byLabel["add(null, 2)"] === 2, `${byLabel["add({}, 2)"]} / ${byLabel["add(null, 2)"]}`);
assert('G7 小数静默截断(向零)', byLabel["add(1.9, 2.9)"] === 3 && byLabel["add(-1.9, 0)"] === -1, `${byLabel["add(1.9, 2.9)"]} / ${byLabel["add(-1.9, 0)"]}`);
assert('G7 2**31 静默回绕成负数(无警告)', byLabel["add(2**31, 0)"] === -2147483648, `= ${byLabel["add(2**31, 0)"]}`);
assert('G7 BigInt 传 i32 抛 TypeError(唯一会报错的一类)', byLabel["add(2n, 2)"] === 'TypeError', `= ${byLabel["add(2n, 2)"]}`);
assert('G7 缺参静默当 0', byLabel["add(1) 缺参"] === 1, `= ${byLabel["add(1) 缺参"]}`);
assert('G7 JS 多态调用点明显慢于单态', tPoly / tMono > 1.5, `${f(tPoly / tMono, 2)}x`);
}
const failed = checks.filter((c) => !c.ok);
R.checks = checks;
R.failed = failed.length;
writeFileSync(new URL('./results.json', import.meta.url), JSON.stringify(R, null, 2));
console.log(`\n=== 结果:${checks.length - failed.length}/${checks.length} 条断言通过 ===`);
if (failed.length) {
for (const c of failed) console.log(` FAILED: ${c.name} (${c.detail})`);
process.exit(1);
}
console.log('全部通过 → exit 0');
extract-and-test.mjs ------ 从文章提取代码块并逐一执行自检
javascript
// extract-and-test.mjs ------ 从正文 Markdown 里抽出代码块,落地成 .mjs 并跑断言
// 目的:证明「文章里贴的代码」本身可运行,而不是另写一份更好的版本
// 运行:node extract-and-test.mjs (在 day33-assets 目录下)
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { buildCore } from './kernels.mjs';
const MD = new URL('../Day33-WASM.md', import.meta.url);
const md = readFileSync(MD, 'utf8');
const between = (a, b) => md.slice(md.indexOf(a), md.indexOf(b));
const jsBlocks = (sec) => [...sec.matchAll(/```js\n([\s\S]*?)```/g)].map((m) => m[1]);
mkdirSync(new URL('./.extracted/', import.meta.url), { recursive: true });
const out = (name, code) => {
const u = new URL(`./.extracted/${name}.mjs`, import.meta.url);
writeFileSync(u, code);
return u;
};
// 2.5 Arena、2.6 WasmPool、2.7 i32 守卫
const arenaCode = jsBlocks(between('### 2.5', '### 2.6'))[0];
const poolCode = jsBlocks(between('### 2.6', '### 2.7'))[0];
const guardCode = jsBlocks(between('### 2.7', '### 2.8'))[0];
const arenaUrl = out('Arena', arenaCode);
const poolUrl = out('WasmPool', `import { Arena } from './Arena.mjs';\n${poolCode}`);
const guardUrl = out('guard', guardCode);
const { Arena } = await import(arenaUrl);
const { WasmPool } = await import(poolUrl);
const { i32 } = await import(guardUrl);
const checks = [];
const t = (name, fn) => {
try { const d = fn(); checks.push([name, true]); console.log(`PASS ${name}${d !== undefined ? ` ${d}` : ''}`); }
catch (e) { checks.push([name, false]); console.log(`FAIL ${name} ${e.message}`); }
};
const eq = (a, b, m) => { if (a !== b) throw new Error(`${m ?? '不等'}: ${a} !== ${b}`); return a; };
const W = new WebAssembly.Instance(new WebAssembly.Module(buildCore({ memoryPages: 16, maxPages: 4096 })), {}).exports;
const arena = new Arena(W.memory, { base: 65536 });
t('文中 Arena.alloc:8 字节对齐 + allocF64/allocI32', () => {
arena.reset();
const a = arena.alloc(1), b = arena.alloc(1);
eq(a % 8, 0); eq(b - a, 8);
eq(arena.f64(arena.allocF64(4), 4).length, 4);
eq(arena.i32(arena.allocI32(4), 4).length, 4);
return `a=${a}`;
});
t('文中 Arena:grow 后旧视图分离,数据不丢', () => {
arena.reset();
const p = arena.allocF64(4);
arena.f64(p, 4)[0] = 2.5;
const stale = arena.f64(p, 4);
const before = arena.bytes;
arena.alloc(before);
eq(stale.byteLength, 0, '旧视图未分离');
eq(arena.f64(p, 4)[0], 2.5);
if (arena.bytes <= before) throw new Error('buffer 未增长');
return `${before} → ${arena.bytes}`;
});
t('文中 Arena:writeStr/readStr 往返(中英 + emoji)', () => {
arena.reset();
for (const s of ['轩哥 Day33', '🚀 wasm 中文', 'a'.repeat(100)]) {
const { ptr, len } = arena.writeStr(s);
eq(arena.readStr(ptr, len), s);
eq(len, new TextEncoder().encode(s).length);
}
return '3/3';
});
t('文中 Arena:reset 归位 + stats 水位', () => {
arena.alloc(4096);
if (arena.stats().live === 0) throw new Error('live 应为正');
arena.reset();
eq(arena.stats().live, 0);
return `highWater=${arena.stats().highWater}`;
});
t('文中 Arena + 批量 ABI:200 条字符串 1 次穿越', () => {
arena.reset();
const strs = Array.from({ length: 200 }, (_, i) => `前端${i}`);
const ptrArr = arena.allocI32(strs.length);
const ptrs = arena.i32(ptrArr, strs.length);
let expect = 0;
strs.forEach((s, i) => { const { ptr, len } = arena.writeStr(s); ptrs[i] = ptr; expect += len; });
eq(W.strlens(ptrArr, strs.length), expect);
return `200 条 / ${expect}B`;
});
const mod = new WebAssembly.Module(buildCore({ memoryPages: 16, maxPages: 128 }));
t('文中 WasmPool:复用实例 + acquire 复位 arena', () => {
const pool = new WasmPool(mod, {}, 2);
const a = pool.acquire();
a.arena.writeStr('占点内存');
if (a.arena.stats().live === 0) throw new Error('live 应为正');
pool.release(a);
const b = pool.acquire();
eq(b, a, '未复用槽位');
eq(b.arena.stats().live, 0, 'arena 未复位');
pool.release(b);
return `size=${pool.stats().size}`;
});
t('文中 WasmPool:满员抛错(暴露未归还)', () => {
const pool = new WasmPool(mod, {}, 1);
const a = pool.acquire();
let m = '';
try { pool.acquire(); } catch (e) { m = e.message; }
if (!m.includes('池已满')) throw new Error('未抛预期错误');
pool.release(a);
return m.slice(0, 12);
});
t('文中 WasmPool.warmup:预热首调', () => {
const pool = new WasmPool(mod, {}, 1).warmup();
const slot = pool.acquire();
const v = slot.inst.exports.hash32(7, 1000);
pool.release(slot);
if (typeof v !== 'number') throw new Error('预热后调用异常');
return 'ok';
});
t('文中 i32 守卫:9 种输入分类正确', () => {
const cases = [['1', 'TypeError'], [1.5, 'TypeError'], [NaN, 'TypeError'], [Infinity, 'TypeError'],
[2 ** 31, 'RangeError'], [-2147483649, 'RangeError'], [-2147483648, null],
[0, null], [2147483647, null]];
for (const [v, want] of cases) {
let got = null;
try { i32(v, 'n'); } catch (e) { got = e.constructor.name; }
eq(got, want, `i32(${v})`);
}
return '9/9';
});
const bad = checks.filter((c) => !c[1]);
console.log(`\n=== 从正文抽出的代码块:${checks.length - bad.length}/${checks.length} 通过 ===`);
if (bad.length) process.exit(1);
console.log('全部通过 → exit 0');
test-article-code.mjs ------ 文章代码断言(正文每条结论的验证)
javascript
// test-article-code.mjs ------ 验证正文里贴的胶水层代码(abi.mjs)真的能跑
// node test-article-code.mjs → 全通过 exit 0
import { buildCore } from './kernels.mjs';
import { Arena, WasmPool, i32 } from './abi.mjs';
const checks = [];
const t = (name, fn) => {
try {
const detail = fn();
checks.push({ name, ok: true, detail });
console.log(`PASS ${name}${detail !== undefined ? ` ${detail}` : ''}`);
} catch (e) {
checks.push({ name, ok: false, detail: e.message });
console.log(`FAIL ${name} ${e.message}`);
}
};
const eq = (a, b, msg) => { if (a !== b) throw new Error(`${msg ?? '不相等'}: ${a} !== ${b}`); return a; };
const bytes = buildCore({ memoryPages: 16, maxPages: 4096 });
const mod = new WebAssembly.Module(bytes);
const inst = new WebAssembly.Instance(mod, {});
const W = inst.exports;
const arena = new Arena(W.memory, { base: 65536 });
// --- 1. 分配与对齐 ---
t('Arena.alloc 8 字节对齐', () => {
arena.reset();
const a = arena.alloc(1), b = arena.alloc(1);
eq(a % 8, 0, '首地址未对齐');
eq(b - a, 8, '第二个块没按 8 对齐');
return `a=${a} b=${b}`;
});
t('Arena.allocF64 按 8 对齐且尺寸正确', () => {
arena.reset();
const p = arena.allocF64(100);
eq(p % 8, 0, '未按 8 对齐');
eq(arena.f64(p, 100).length, 100);
return `ptr=${p}`;
});
// --- 2. grow 让视图失效,sync 自愈 ---
t('grow 后旧视图被分离且 sync 报告重建', () => {
arena.reset();
const p = arena.allocF64(8);
arena.f64(p, 8)[0] = 3.14;
const staleView = arena.f64(p, 8); // 故意缓存一份
const before = arena.bytes;
arena.alloc(before); // 强制撞到边界 → grow
const rebuilt = arena.sync();
eq(staleView.byteLength, 0, '旧视图竟然没被分离');
eq(rebuilt, false, 'sync 应已在上面的 alloc 内重建过');
eq(arena.f64(p, 8)[0], 3.14, '数据丢了');
if (arena.bytes <= before) throw new Error('buffer 没变大');
return `${before} → ${arena.bytes}`;
});
t('reset 把 brk 归位(bump allocator 的 O(1) 回收)', () => {
arena.alloc(1024);
if (arena.stats().live === 0) throw new Error('reset 前 live 应为 0(上个用例已 reset)');
arena.reset();
eq(arena.stats().live, 0);
return `highWater=${arena.stats().highWater}`;
});
// --- 3. 字符串通道 ---
t('writeStr/readStr 中文往返 + 字节长度正确', () => {
arena.reset();
const s = '轩哥的前端研发系列 Day33';
const { ptr, len } = arena.writeStr(s);
eq(arena.readStr(ptr, len), s);
eq(len, new TextEncoder().encode(s).length, 'UTF-8 字节长度不符');
return `len=${len}B`;
});
t('writeStr 处理代理对(emoji 4 字节)', () => {
arena.reset();
const s = '🚀 wasm';
const { ptr, len } = arena.writeStr(s);
eq(arena.readStr(ptr, len), s);
eq(len, new TextEncoder().encode(s).length);
return `len=${len}B`;
});
t('writeStr 拒绝非 string(防止 undefined 静默变 0)', () => {
arena.reset();
let name = '';
try { arena.writeStr(undefined); } catch (e) { name = e.constructor.name; }
eq(name, 'TypeError');
return name;
});
// --- 4. 入参守卫 ---
t('i32 守卫:字符串 / 小数 / 越界 / 合法', () => {
const cases = [
['1', 'TypeError'], [1.5, 'TypeError'], [NaN, 'TypeError'],
[2 ** 31, 'RangeError'], [-2147483649, 'RangeError'], [2147483647, null],
];
for (const [v, want] of cases) {
let got = null;
try { i32(v, 'n'); } catch (e) { got = e.constructor.name; }
eq(got, want, `i32(${v}) 期望 ${want} 实际 ${got}`);
}
return '6/6';
});
// --- 5. 批量 ABI:一次穿越处理 N 条字符串 ---
t('批量 ABI:打包 N 条字符串 + 一次 strlens 调用', () => {
arena.reset();
const strs = Array.from({ length: 500 }, (_, i) => `前端${i}-Day33`);
const ptrArr = arena.allocI32(strs.length);
const ptrs = arena.i32(ptrArr, strs.length);
let expect = 0;
strs.forEach((s, i) => {
const { ptr, len } = arena.writeStr(s); // withNul=true,供 strlens 使用
ptrs[i] = ptr;
expect += len;
});
const got = W.strlens(ptrArr, strs.length);
eq(got, expect, '批量总长不符');
return `500 条 / ${got}B / 1 次穿越`;
});
// --- 6. 实例池 ---
const smallMod = new WebAssembly.Module(buildCore({ memoryPages: 16, maxPages: 128 }));
t('WasmPool 复用实例并在 acquire 时复位 arena', () => {
const pool = new WasmPool(smallMod, {}, 2);
const a = pool.acquire();
a.arena.writeStr('占点内存');
const live = a.arena.stats().live;
if (live === 0) throw new Error('写入后 live 应为正');
pool.release(a);
const b = pool.acquire();
eq(b, a, '应复用同一个槽位');
eq(b.arena.stats().live, 0, 'acquire 时 arena 没复位');
pool.release(b);
return `size=${pool.stats().size}`;
});
t('WasmPool 满员时抛错(暴露未归还)', () => {
const pool = new WasmPool(smallMod, {}, 1);
const a = pool.acquire();
let msg = '';
try { pool.acquire(); } catch (e) { msg = e.message; }
if (!msg.includes('池已满')) throw new Error(`未按预期抛错:${msg}`);
pool.release(a);
return msg.slice(0, 20);
});
t('WasmPool.warmup 把首调溢价提前付掉', () => {
const pool = new WasmPool(smallMod, {}, 1);
pool.warmup((slot) => slot.inst.exports.hash32(7, 200_000));
const slot = pool.acquire();
const v = slot.inst.exports.hash32(7, 1000);
pool.release(slot);
if (typeof v !== 'number') throw new Error('预热后调用失败');
return 'ok';
});
const failed = checks.filter((c) => !c.ok);
console.log(`\n=== 文中代码:${checks.length - failed.length}/${checks.length} 条断言通过 ===`);
if (failed.length) process.exit(1);
console.log('全部通过 → exit 0');