导语:编译本身才是首屏瓶颈
很多人以为 WASM 的首屏慢在「执行」,实测往往相反:一个 2 MB 的模块,网络下载可能 80 ms,而编译可能花 200 ms 以上。也就是说,用户等待的时间里,大部分被「把字节翻译成机器码」吃掉了,而不是业务逻辑。于是优化的第一性问题变成:如何让编译更快、更早开始、更少重复。
本文把 WASM 从字节到可执行的全过程拆开:流水线各阶段能重叠什么、instantiateStreaming 为什么快、编译缓存怎么用、分层编译如何在启动速度与峰值性能之间取舍、AOT 预编译能省多少,以及如何用一套可复现的度量把每一步的收益量化。
目录
- 1. 编译与实例化流水线
- 2. instantiateStreaming 实践
- 3. 编译缓存机制
- 4. 分层编译与 lazy tiering
- 5. 体积与编译时间权衡
- 6. AOT 预编译产物
- 7. CDN 与分块加载
- 8. 首字节到可执行的度量
- 9. 优化清单
- 10. 常见陷阱与对策
1. 编译与实例化流水线
1.1 阶段划分
┌───────┐ ┌─────────┐ ┌─────────────┐ ┌──────┐ ┌───────┐
│ fetch │──▶│ compile │──▶│ instantiate │──▶│ init │──▶│ ready │
└───────┘ └─────────┘ └─────────────┘ └──────┘ └───────┘
│ ▲
└── 流式重叠 ──┘ 下载与编译并行,instantiate 依赖 compile 完成
1.2 可重叠的部分
串行(最差):fetch 全部 → compile 全部 → instantiate → init
流式(推荐):fetch 边下 → compile 边编(增量)→ 完成后 instantiate
预编译(最优):启动前已完成 compile → 只剩 instantiate + init
重叠的核心是「编译以流为输入」:
只要引擎支持增量编译,下载与编译就能并行,总时间 ≈ max(下载, 编译)。
一句话总结:流水线五阶段里 fetch 与 compile 可重叠,instantiate 必须等 compile 完成;优化的主线是「让编译提前开始、让编译结果可复用」。
2. instantiateStreaming 实践
2.1 基本用法
// 推荐:直接吃 Response 流,边下边编
const response = fetch('./app.wasm'); // 注意:不要 await
const { instance } = await WebAssembly.instantiateStreaming(response, imports);
instance.exports.main();
2.2 为什么快
instantiateStreaming 的优势来源:
1. 无需把整块字节读进 JS 内存再交给引擎 → 少一次拷贝
2. 引擎边接收流边编译 → 下载与编译并行
3. 内部用 WebAssembly.compileStreaming,同样流式
前提条件(缺一即失败):
Content-Type 必须是 application/wasm
响应必须可被流式读取(非 no-store、非跨源无 CORS 头)
否则会抛 TypeError: Incorrect response MIME type。
// 兜底:MIME 不对时退回 arrayBuffer 再编译
async function loadWasm(url, imports) {
try {
return await WebAssembly.instantiateStreaming(fetch(url), imports);
} catch (e) {
const buf = await (await fetch(url)).arrayBuffer();
return WebAssembly.instantiate(buf, imports);
}
}
务必配好服务器 MIME 类型,这是 instantiateStreaming 最常见的失败原因,也是收益最大的一行配置。
一句话总结:
instantiateStreaming让下载与编译并行且少一次拷贝;前提是Content-Type: application/wasm且响应可流式,否则退回arrayBuffer路径。
3. 编译缓存机制
3.1 浏览器内置缓存
主流引擎的行为(Chrome / Firefox):
对同一 URL 的 wasm,引擎内部维护「编译结果缓存」
再次 instantiate 同一 URL 时可直接复用机器码,跳过编译
触发条件:URL 完全一致(含查询串)、响应可缓存、未被禁用缓存。
实践:把内容哈希放进文件名而非查询串,既命中 HTTP 缓存又命中编译缓存。
3.2 手动缓存编译产物
// 用 Cache API 缓存编译后的 Module(浏览器可结构化克隆 Module)
const cache = await caches.open('wasm-modules-v1');
let module;
const cached = await cache.match('/app.wasm');
if (cached) {
module = await WebAssembly.compile(await cached.arrayBuffer());
} else {
module = await WebAssembly.compileStreaming(fetch('/app.wasm'));
await cache.put('/app.wasm', new Response(/* 原始字节 */));
}
两级缓存策略:
HTTP 缓存 存字节,减少下载(Cache-Control: immutable + 哈希文件名)
编译缓存 存机器码,减少编译(引擎内置 or 手动存 Module)
两者互补:字节缓存省网络,编译缓存省 CPU,都要开。
编译缓存失效的三种情况:
引擎版本升级 → 内部缓存格式变化,全部重编
内存压力 → 引擎丢弃缓存的机器码,退化为重新编译
跨会话 → 部分引擎的缓存不跨浏览器重启保留
因此「首次冷启动」的编译成本永远存在,只能靠流式与预编译压缩。
一句话总结:引擎会按 URL 缓存编译结果,所以文件名要带内容哈希;再叠加 Cache API 存 Module 与 HTTP 缓存存字节,网络与 CPU 一起省。
4. 分层编译与 lazy tiering
4.1 两档编译器
主流引擎普遍采用两档编译:
Liftoff(基线) 极快编译、代码质量一般 → 先用它把模块跑起来
TurboFan(优化) 慢编译、生成高质量机器码 → 后台把热函数替换掉
结果:启动几乎不受优化编译拖累,峰值性能靠后台补上。
4.2 控制分层行为
浏览器侧的开关(调试与取舍用):
Chrome --js-flags="--liftoff --no-wasm-tier-up" 可强制只用基线
Firefox 类似地可关闭 tier-up
权衡:
打开分层 启动快、首帧好,但前若干次调用跑在慢代码上
关闭分层 启动慢(要一次编到最优),但首调用即峰值性能
长驻、对首帧敏感 → 保留分层;短时高频计算 → 可考虑关闭。
// 用 WebAssembly.compile 的编译提示(部分引擎支持)
const module = await WebAssembly.compile(bytes);
// 引擎自行决定 tier-up 时机,开发者通常只能通过 flag 影响
一句话总结:分层编译用「先跑起来、后台再优化」换取启动速度;首帧敏感保留分层,短时高频计算可关闭 tier-up 换首调用性能。
5. 体积与编译时间权衡
5.1 相关而非线性
| 优化级别 | 体积 | 编译时间 | 运行速度 |
|---|---|---|---|
| -O0 | 大 | 快 | 慢 |
| -O2 | 中 | 中 | 快 |
| -Oz | 小 | 较慢 | 略慢 |
| -Oz + LTO | 最小 | 慢 | 略慢 |
关键关系:编译时间大体随「指令条数」增长,而非字节数。
过度内联会增大指令数 → 体积小但编译更慢
-Oz 常把函数内联展开后又被裁剪,指令数未必增加
经验:-Oz 通常同时改善体积与网络时间,编译时间变化在 ±20% 内。
5.2 裁剪与拆分
降低编译量的三种手段:
1. 移除未用代码(dead code elimination):链接期裁剪 + wasm-opt
2. 拆分模块:把「首屏必需」与「按需功能」拆成多个 wasm,分批加载
3. 减少泛型实例化:Rust/C++ 泛型膨胀是体积与指令数的主要来源
一句话总结:编译时间随指令条数而非字节数增长;用 DCE、模块拆分、减少泛型膨胀从源头降低要编译的量,比单纯压字节更有效。
6. AOT 预编译产物
6.1 原理
浏览器不给「把编译产物存成文件」的标准化 API(安全考虑)。
但服务端运行时(wasmtime / wasmtime-js)提供:
Engine::precompile_module(bytes) -> 序列化产物
启动时 Engine::deserialize(产物) -> 直接可用,跳过编译
效果:把编译从「每次冷启动」移到「构建期」,冷启动可降一个数量级。
// wasmtime:构建期预编译,运行期反序列化
use wasmtime::{Engine, Module};
let engine = Engine::default();
let module = Module::new(&engine, &wasm_bytes)?;
std::fs::write("app.cwasm", module.serialize()?)?; // 构建期产物
预编译产物的三条注意:
1. 与运行时版本强绑定(换 wasmtime 版本要重新预编译)
2. 与目标 CPU 特性绑定(SIMD/AVX 需匹配部署机器)
3. 不可移植到浏览器 —— 这是服务端专属优化
预编译产物的三种形态:
wasmtime 的 .cwasm 平台相关、运行时版本相关,启动最快
V8 的 code cache 由引擎自动管理,不可移植
Wasm 字节本身 完全可移植,但每次都要编译
选择依据:部署环境是否可控。可控则预编译,不可控(浏览器)只能流式。
一句话总结:AOT 预编译把编译搬到构建期,服务端冷启动可降一个数量级;代价是与运行时版本和 CPU 特性强绑定,且不能用于浏览器。
7. CDN 与分块加载
7.1 传输优化
wasm 传输四件套:
1. Brotli/gzip 压缩(wasm 压缩率通常 60%~70%)
2. CDN 边缘缓存 + immutable 长缓存(配合哈希文件名)
3. 预加载提示 <link rel="preload" as="fetch" crossorigin>
4. HTTP/2 多路复用,避免拆分过多小文件反而增加 RTT
<link rel="preload" href="/app.wasm" as="fetch" type="application/wasm" crossorigin>
7.2 分块策略
拆分维度:
按路由拆分 首屏一个 wasm,其他页面按需加载
按功能拆分 把重功能(编解码、AI 推理)拆成独立 wasm 懒加载
按数据拆分 模型权重等大块数据用独立流式加载,不塞进代码段
注意:拆得越细,编译与实例化的固定开销被乘上更多份,需权衡。
分块不是越多越好:每块都有自己的编译与实例化开销,收益来自「首屏不需要的部分不下载」,一旦拆到小模块反而净亏。
分块加载的度量方法:
记录「首屏模块」的 readyToExecute,再对比全量加载的版本
若拆分后首屏可执行时间没有下降,说明收益被固定开销抵消
一句话总结:传输靠 Brotli + CDN + preload,拆分按路由/功能/数据三个维度;拆分的收益是首屏少下载,成本是固定开销乘份数,过细即亏。
8. 首字节到可执行的度量
8.1 分段打点
const t = {};
t.start = performance.now();
const resp = await fetch('/app.wasm');
t.response = performance.now();
const { instance } = await WebAssembly.instantiateStreaming(resp, imports);
t.ready = performance.now();
t.firstCall = performance.now();
instance.exports.warmup();
t.firstCallDone = performance.now();
console.table({
download: t.response - t.start,
compileAndInstantiate: t.ready - t.response,
firstCall: t.firstCallDone - t.firstCall,
});
8.2 关键指标
必测四项:
TTFB 首字节到达时间(网络)
downloadComplete 下载完成(受压缩与 CDN 影响)
readyToExecute 实例化完成(编译 + 实例化)
firstMeaningfulPaint 真正画出内容
优化目标:把 readyToExecute 压到 downloadComplete 附近,
即让编译几乎与下载完全重叠 —— 这是流式编译的上限。
一句话总结:用
performance.now()分段打点,测 TTFB / 下载完成 / 可执行 / 首屏四段;目标是把「可执行」压到贴近「下载完成」,让编译与下载完全重叠。
9. 优化清单
9.1 按收益排序
[ ] 服务器配置 Content-Type: application/wasm(零成本、高收益)
[ ] 用 instantiateStreaming 而非先 arrayBuffer 再 instantiate
[ ] 开启 Brotli 压缩与 CDN,文件名带内容哈希
[ ] 预加载提示 <link rel="preload" as="fetch">,让下载尽早开始
[ ] 构建期 -Oz + LTO + wasm-opt -Oz,减少要编译的指令数
[ ] 拆分首屏与按需模块,首屏只加载必需部分
[ ] 服务端用 AOT 预编译,把编译移到构建期
9.2 度量纪律
[ ] 每次发版跑同一套打点脚本,记录四段耗时
[ ] 用真实中低端设备与 4G 网络档位,不用开发机
[ ] 把 readyToExecute 与 downloadComplete 的差值纳入回归门禁
[ ] 体积变化超过阈值时阻断合并
一句话总结:先做零成本的 MIME 与流式实例化,再压体积、拆模块、上 AOT;所有优化都必须用同一套打点脚本在中低端设备上验证。
10. 常见陷阱与对策
10.1 高频陷阱
[ ] 先 await fetch 再 instantiate → 白白丢失流式重叠
[ ] MIME 不是 application/wasm → instantiateStreaming 直接抛错
[ ] 文件名带随机查询串 → 引擎编译缓存永不命中
[ ] 跨源加载未带 crossorigin → preload 与 streaming 都失效
[ ] 用 -O0 调试版上线 → 体积与编译时间双输
[ ] 拆分成十几个小 wasm → 固定开销乘份数,反而更慢
10.2 对策
对策速查:
流式 fetch 不加 await,直接喂给 instantiateStreaming
MIME 服务端静态资源显式配置 wasm 类型
缓存 哈希进文件名,去掉每次变化的查询串
跨源 preload 与 fetch 都加 crossorigin="anonymous" 且带 CORS 头
版本 release 用 -Oz + LTO,debug 版绝不进生产
拆分 先量「首屏需要多少」,再决定拆不拆、拆几块
把这些陷阱写进代码评审清单。它们大多不是「不懂原理」,而是「配置漏了一行」,靠人肉记忆必然反复踩。
一句话总结:高频陷阱集中在「await 丢失流式、MIME 错误、查询串破坏缓存、跨源缺头、debug 版上线、过度拆分」六项;对策是配置化加评审清单,而非依赖记忆。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。