三天下來,檔案有了、瀏覽器叫得動、資料進得去出得來。剩下的問題是:它比 JS 快嗎?今天在同一個專案裡量給你看。先講結論的形狀:答案不是一個倍數,是一張表,有贏有輸,而且輸的那題最有意思。
量測的程式碼在 src/lib/bench.ts,規矩全寫在那裡,少守一條,下面的數字就是雜訊:
還有一條不在數字裡的:包裝 wasm 函式的 JS 一律是 module 層級的函式,不能是 closure。我第一次用 factory 回傳的 closure 包 add(),量出來慢一倍,結論整個反過來;那是 V8 對 closure 的 inline 決策,跟 wasm 無關。量測方法本身就能讓你得出相反的結論,這是今天第一個要帶走的。
四題各針對一種形狀:mandelbrot(純 f64 熱迴圈,一次呼叫做完整張圖)、convolve 3×3(整數加大量 Uint8Array 索引,1 MB 的圖)、兩百萬次 add()(函式本體是雜訊,量的是跨邊界)、word count(字串進出,2.8 MB 的文字)。每題一份 Rust、一份 JS,逐行對應,不調校,兩邊回傳同一個 checksum。
crate/Cargo.toml 多一段,今天要量時間,讓 LLVM 把整個 crate 當一個單位最佳化:
[profile.release]
lto = true
codegen-units = 1
panic = "abort"
四支 kernel 太長不全貼,貼最短的那對,其餘三對長得一樣。Rust:
#[unsafe(no_mangle)]
pub extern "C" fn sum_iters(seed: u32, iterations: u32) -> u32 {
let mut acc = seed;
for i in 0..iterations {
acc = acc.wrapping_mul(1664525).wrapping_add(i);
}
acc
}
JS 雙胞胎,src/lib/kernels-js.ts:
export function sumIters(seed: number, iterations: number): number {
let acc = seed;
for (let i = 0; i < iterations; i++) {
acc = (Math.imul(acc, 1664525) + i) >>> 0;
}
return acc;
}
Math.imul 跟 >>> 0 不是裝飾,它們對應 Rust 的 wrapping_mul 跟 wrapping_add,兩邊 checksum 才會一樣;昨天那個 u32 讀成負數的坑,今天靠它擋。convolve 跟 word count 要把資料交進去,包裝就是昨天那四個動作,複製的時間算在 wasm 那邊,這是故意的:
export function convolve3x3(src: Uint8Array, width: number, height: number): number {
const ptr = wasm.alloc(src.length);
new Uint8Array(wasm.memory.buffer, ptr, src.length).set(src);
const out = wasm.convolve3x3(ptr, src.length, width, height) >>> 0;
wasm.dealloc(ptr, src.length);
return out;
}
add 昨天那聲 log 拿掉了,不然兩百萬次呼叫會變成兩百萬行 Console。npm run wasm,12,905 B。
先在 src/routes/+layout.ts 放一行 export const ssr = false;:wasm 是瀏覽器裡的事,這個專案從今天起是純 SPA,SvelteKit 不再在伺服器端先跑一次元件,onMount 留著只是等畫面掛上再開始量。然後開一條新路由 src/routes/bench/+page.svelte:熱迴圈全部在純 TypeScript 裡跑完,才把結果放進 $state,量測中間不碰響應式狀態。npm run dev,開 http://localhost:5173/bench,把分頁留在前景,半分鐘內表格會長出來。
Apple M1 Pro、16 GB,Chrome 152 核心,每題熱身 5 輪、量 15 輪取中位數。倍數是 JS 中位數除以 wasm 中位數,大於 1 是 wasm 快:
| 題目 | JS | wasm | wasm 是 JS 的幾倍 |
|---|---|---|---|
| mandelbrot 900×600 @ 200 iter | 78.9 ms | 83.4 ms | 0.95× |
| convolve 3×3,1024×1024 | 10.7 ms | 3.1 ms | 3.45× |
2,000,000 次 add() |
18.5 ms | 26.1 ms | 0.71× |
| word count,2,799,522 chars | 8.2 ms | 7.7 ms | 1.06× |
再跑一次,四個倍數在 0.96、3.37、0.77、1.18,抖動就這個量級。
一題一句。mandelbrot 平手:單型別、無多型的純數值 JS 正是 V8 的最佳狀況,它產出的機器碼跟 wasm 幾乎一樣,wasm 沒有魔法可以贏。convolve 三倍半:JS 每次 src[i] 都要付邊界檢查跟型別確認,wasm 的線性記憶體不用,這一題的形狀就是 wasm 贏的形狀,而且每次呼叫還多複製了 1 MB 進去,照樣贏。add() 輸三成:函式本體只有一個加法,量到的全是跨邊界;但輸的對象並不是 JS,是「不存在的函式呼叫」,V8 能把 JS 版的 add 整個 inline 進迴圈消失掉,卻沒辦法 inline 進 wasm。word count 打平:wasm 那邊多做了 TextEncoder.encode 加一次 2.8 MB 的複製,JS 那邊字串本來就在手上,這個不對稱是題目的一部分,不是不公平。
從 add() 那題推:(26.1 − 18.5) ms ÷ 2,000,000 ≈ 3.8 ns 一次。比想像中小很多。那問題就變成:每次呼叫要做多少事才攤得掉?總工作量固定在 2²⁴ 單位,只改每次呼叫做幾單位(就是上面那支 sum_iters),跑出來是這樣:
| 每次呼叫 | 呼叫次數 | JS | wasm | 倍數 |
|---|---|---|---|---|
0 單位(純呼叫,即 add()) |
2,000,000 | 18.5 ms | 26.1 ms | 0.71× |
| 1 單位 | 16,777,216 | 301.0 ms | 300.9 ms | 1.00× |
| 4 單位 | 4,194,304 | 89.5 ms | 87.0 ms | 1.03× |
| 16 單位 | 1,048,576 | 34.5 ms | 20.8 ms | 1.66× |
| 64 單位 | 262,144 | 20.9 ms | 8.8 ms | 2.37× |
| 256 單位 | 65,536 | 17.5 ms | 5.2 ms | 3.37× |
| 1024 單位 | 16,384 | 16.4 ms | 4.4 ms | 3.73× |
每次呼叫做一單位有意義的工作就打平,往上爬到三倍多就停了,那個天花板是這支 kernel 本身的極限,不是邊界的。所以「wasm 呼叫很貴,要少叫」這句只對了一半:貴的不是邊界,是你把工作切得太碎。
最後一句限縮:今天比的是「跟 JS」,不是「跟 native」。多數工作負載 wasm 比 native 慢,效能從來不是它免費附送的東西。它在瀏覽器裡的價值是「一顆檔案,誰載都一樣」,快是特定形狀下的贈品。
一句帶走:wasm 沒有普遍比較快,它贏在特定形狀的工作:算得密、資料一次交過去;輸的時候,對手常常是「不存在的函式呼叫」,而跨一次邊界只要幾奈秒,貴的是把工作切得太碎。