iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0

前言:值不值得

三天下來,檔案有了、瀏覽器叫得動、資料進得去出得來。剩下的問題是:它比 JS 快嗎?今天在同一個專案裡量給你看。先講結論的形狀:答案不是一個倍數,是一張表,有贏有輸,而且輸的那題最有意思。

第零步:五條規矩

量測的程式碼在 src/lib/bench.ts,規矩全寫在那裡,少守一條,下面的數字就是雜訊:

  • 先熱身再測。 JS 的 JIT 跟 wasm 的 tier-up 都要進穩態,前五輪一律丟掉。
  • 取中位數,不取平均。 GC 跟排程抖動是單邊離群值,平均會把它們當成「執行期的真實表現」報出來。
  • 每個結果都餵進 sink。 回傳值沒人用的函式,最佳化器有權整段刪掉。
  • 兩邊必須算出同一個 checksum。 對不上就不出數字,不然比的是兩個不同的程式。
  • 分頁在背景就拒測。 Chrome 會降低隱藏分頁的優先權,而且兩邊不是等比例地慢。

還有一條不在數字裡的:包裝 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_mulwrapping_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,0003.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 呼叫很貴,要少叫」這句只對了一半:貴的不是邊界,是你把工作切得太碎。

什麼時候快

  • 快:計算密集、資料一次交過去算完(convolve 那種);既有的 C/Rust 程式碼庫要搬進網頁;要穩定的影格時間,因為沒有 deopt、沒有 GC 停頓。
  • 不快:函式太小、字串來回、跟 DOM 綁死的工作,這三種 JS 本來就在主場。

最後一句限縮:今天比的是「跟 JS」,不是「跟 native」。多數工作負載 wasm 比 native 慢,效能從來不是它免費附送的東西。它在瀏覽器裡的價值是「一顆檔案,誰載都一樣」,快是特定形狀下的贈品。

結語

一句帶走:wasm 沒有普遍比較快,它贏在特定形狀的工作:算得密、資料一次交過去;輸的時候,對手常常是「不存在的函式呼叫」,而跨一次邊界只要幾奈秒,貴的是把工作切得太碎。


上一篇
Day 04|import 表與線性記憶體
下一篇
Day 06|三個概念,一個專案
系列文
Tool Use Is All You Need:30 天用 WebAssembly 試圖控制 Agent 的手腳9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言