我們將 openui-lang 解析器用 Rust 編寫並編譯成 WASM。邏輯聽起來很合理:Rust 速度快,WASM 在瀏覽器中提供近乎原生的速度,而且我們的解析器是一個相當複雜的多階段管線。為什麼不選擇 Rust 呢?
結果發現我們優化錯了地方。
openui-lang 解析器將 LLM 輸出的自訂語言轉換為 React 組件樹。它在每個串流區塊上運行 — 因此延遲非常重要。該管線有六個階段:
每次呼叫 WASM 解析器,無論 Rust 程式碼本身運行多快,都會產生固定的額外開銷:
Rust 解析本身從來都不是慢的部分。額外開銷完全在邊界上:複製字串進去,將結果序列化為 JSON 字串,複製 JSON 字串出來,然後 V8 將其反序列化回 JS 物件。
自然的問題是:如果 WASM 直接返回 JS 物件,跳過 JSON 序列化步驟會怎樣?我們整合了 serde-wasm-bindgen,它正是做這件事 — 它將 Rust 結構轉換為 JsValue 並直接返回。
原因如下。JS 無法將 WASM 線性記憶體中的 Rust 結構位元組讀取為原生 JS 物件 — 這兩個運行時使用完全不同的記憶體佈局。為了從 Rust 資料建構 JS 物件,serde-wasm-bindgen 必須在每次 parse() 調用時,遞迴地將 Rust 資料具體化為實際的 JS 陣列和物件,這涉及跨運行時邊界的許多細粒度轉換。
與 JSON 方法相比:serde_json::to_string() 在純 Rust 中運行,沒有邊界穿越,產生一個字串,一個 memcpy 將其複製到 JS 堆,然後 V8 的原生 C++ JSON.parse 在單一優化通道中處理它。較少、較大、更優化的操作勝過許多小操作。
我們立即撤銷了這個變更。
我們將整個解析器管線移植到了 TypeScript。相同的六階段架構,相同的 ParseResult 輸出形狀 — 沒有 WASM,沒有邊界,完全在 V8 堆中運行。
測量內容:對完成的輸出字串進行單次 parse(completeString) 調用。這隔離了每次調用的解析器成本。
運行方式:30 次預熱迭代以穩定 JIT,然後使用 performance.now() (µs 精度) 進行 1000 次計時迭代。報告中位數。測試案例是真實的 LLM 生成的組件樹,以每種格式的真實串流語法序列化。
消除 WASM 解決了每次調用的成本,但串流架構仍然存在更深層的低效率。
解析器在每個 LLM 區塊上被呼叫。樸素的方法會累積區塊並每次從頭開始重新解析整個字串:
對於以 20 個字元區塊傳送的 1000 個字元輸出:50 次解析調用,總共處理約 25,000 個字元。區塊數的複雜度為 O(N²)。
由深度為 0 的換行符終止的語句是不可變的 — LLM 不會回來修改它們。我們添加了一個串流解析器,可以快取已完成語句的 AST:
已完成的語句不再重新解析。每個區塊只重新解析最後一個進行中的語句。複雜度為 O(總長度) 而非 O(N²)。
測量內容:一個完整文件在每個區塊調用中累積的總解析開銷。這與一次性基準測試不同 — 它測量真實串流期間所有解析調用的總和,而不是單次調用。這是影響使用者實際響應能力的數字。
運行方式:文件以 20 個字元區塊重播。每個區塊觸發一次 parse() (樸素) 或 push() (增量) 調用。記錄所有調用的總時間。重播 100 個完整串流,取中位數。
簡單表格測試案例是單一語句 — 沒有什麼可以快取的,所以兩種方法是等效的。好處隨著語句數量的增加而擴大,因為文件中有更多的內容被快取並在每個區塊中跳過。
一次性表格顯示 contact-form 為 13.4µs;串流表格顯示 316µs (樸素)。這並不矛盾 — 它們測量不同的東西:
最終結果:每次調用快 2.2-4.6 倍,總串流成本降低 2.6-3.3 倍。
這次經驗讓我們更清晰地思考 WASM 的正確使用場景:
✅ 計算密集型且互動極少:圖像/影片處理、加密、物理模擬、音訊編解碼器。大型輸入 → 純量輸出或原地修改。邊界穿越很少。
✅ 可攜式原生函式庫:將 C/C++ 函式庫 (SQLite, OpenCV, libpng) 運送到瀏覽器,無需完全重寫為 JS。
❌ 將結構化文字解析為 JS 物件:無論如何你都要支付序列化成本。解析計算足夠快,以至於 V8 的 JIT 消除了任何 Rust 優勢。邊界開銷佔主導地位。
❌ 對小型輸入頻繁調用的函式:如果函式在串流中被調用 50 次,而計算需要 5µs,你無法攤銷邊界成本。
在選擇實作語言之前,請分析時間實際花在哪裡。對我們來說,成本從來不在於計算 — 而總是在於跨 WASM-JS 邊界進行資料傳輸。
透過 serde-wasm-bindgen 的「直接物件傳遞」並非更便宜。從 Rust 建構 JS 物件的欄位涉及比單一 JSON 字串傳輸更多的邊界穿越,而不是更少。邊界穿越發生在單一 FFI 調用內部,是看不見的。
演算法複雜度的改進主導了語言層級的優化。在串流情況下從 O(N²) 變為 O(N) 比從 WASM 轉為 TypeScript 產生了更大的實際影響。
WASM 和 JS 不共享堆。WASM 擁有一個線性的記憶體 (WebAssembly.Memory),JS 可以將其讀取為原始位元組,但這些位元組是 Rust 的內部佈局 — 指標、列舉判別符、對齊填充 — 對 JS 運行時來說完全不透明。總是需要轉換,而且總是需要成本。