【文章內容使用 Gemini 1.5 Pro 自動翻譯產生】
使用 Dart 和 WebAssembly 進行實驗
作者:Liam Appelbe 和 Michael Thomsen
WebAssembly(通常縮寫為 Wasm)是一種「基於堆疊的虛擬機器的二進制指令格式」。儘管 Wasm 最初設計用於在 Web 上運行原生程式碼,但後來發展成為一種跨多個平台運行編譯程式碼的通用技術。Dart 已經是一種高度可移植和跨平台的語言,因此我們非常感興趣 Wasm 如何讓我們擴展 Dart 的這些特性。
為什麼要嘗試 Wasm?
Wasm 已經獲得了瀏覽器供應商(例如 Chrome、Edge、Firefox 和 WebKit)的廣泛支援。這使得 Wasm 成為在瀏覽器中運行二進制程式碼的一個非常有趣的方案。然而,Wasm 最初並不是為具有垃圾回收 (GC) 的程式語言(例如 Dart 和 Java/Kotlin)設計的,這使得將基於 GC 的語言有效地編譯為 Wasm 變得困難。透過參與 Wasm 專案最近的 GC 建議,我們希望既能對建議提供技術回饋,又能了解更多關於透過 Wasm 程式碼運行基於 Dart 的 Web 應用程式可能獲得的收益。
Wasm 的第二個特性是二進制 Wasm 模組與平台無關。這可能使與現有程式碼的互操作性變得更加實用:如果現有程式碼可以編譯為 Wasm,那麼所有平台上的 Dart 應用程式都可以依賴於單個共享的二進制 Wasm 模組。
在這篇文章的其餘部分,我們將討論我們使用 Wasm 和 Dart 進行的兩種形式的實驗:
Dart 到 Wasm 編譯:擴展我們的 AOT 編譯器,以支援將 Dart 原始程式碼編譯為 Wasm 二進制程式碼(問題 32894)。
Dart 到 Wasm 互操作:支援從 Dart 程式碼調用到編譯的 Wasm 模組(問題 37355 和 37882)。
將 Dart 編譯為 Wasm
如前所述,Wasm 最初是作為在 Web 上運行原生程式碼的一種方式。Web 傳統上由 JavaScript 程式碼提供支援,這些程式碼在虛擬機器 (VM) 中運行,該虛擬機器在 Web 應用程式運行時對 JavaScript 程式碼執行即時 (JIT) 編譯為原生程式碼。在目前以 Web 為目標的 Dart 架構中,例如 Flutter Web,Dart 應用程式程式碼會被編譯為優化的 JavaScript 以進行部署,然後當應用程式運行時,這個 JavaScript 會被 Web 平台 JIT 編譯為原生程式碼。
我們正在研究將 Dart 程式碼直接編譯為 Wasm 原生程式碼,以查看是否可以獲得在 Web 上運行原生程式碼的更直接途徑。Wasm 組合語言格式是低階的,並且比 JavaScript 更接近機器程式碼的抽象級別,這可以縮短啟動時間,並且通常可以提高效率的可預測性。
Dart 對編譯為 Wasm 的支援還處於早期研究階段,編譯器尚不完整,但我們正在進行實驗以學習。我們一直對 Wasm 作為 Dart 的編譯目標感興趣,但它的原始形式不適用於具有垃圾回收的語言。Wasm 缺乏內建的垃圾回收支援,因此像 Dart 這樣的語言必須在編譯的 Wasm 模組中包含垃圾回收實作。包含 GC 實作將非常複雜,會增加編譯的 Wasm 程式碼的大小並損害啟動時間,並且不利於與瀏覽器系統其餘部分的物件級別互操作。
幸運的是,WebAssembly 社群正在進行一項名為 Wasm GC 的工作,正在探索透過對垃圾回收語言的直接和高效能支援來擴展 Wasm 的可能性。鑑於我們長期以來對 Wasm 的興趣,我們看到了參與社群並透過編寫將 Dart 翻譯為 Wasm GC 的編譯器來提供實務經驗的機會。
現在預測這可能會帶我們走向何方還為時過早,但我們最初的原型設計顯示出非常積極的結果,初步基準測試顯示,第一幀的時間和平均幀時間/吞吐量都更快。如果您有興趣了解更多關於該專案的資訊,請查看 wasm_prototype 原始程式碼。
與 Wasm 程式碼的互操作性 (package:wasm)
除了編譯為 Wasm 之外,我們還有興趣研究 Wasm 是否可以用於以更跨平台的方式與現有程式碼整合。幾種語言支援編譯為遵循 C 調用約定的模組,並且使用 Dart FFI,您可以與這些模組進行互操作。Dart FFI 可以成為利用現有原始程式碼和函式庫的好方法,而不必在 Dart 中重新實作程式碼。
然而,由於 C 模組是平台特定的,因此使用原生 C 模組分發共享套件很複雜:它需要通用建構系統,或分發多個二進制模組(每個所需平台一個)。如果可以在所有平台上使用單個 Wasm 二進制組合語言格式,那麼分發將會更容易。然後,您不必將函式庫編譯為每個目標平台的特定於平台的二進制程式碼,而可以將其一次編譯為 Wasm 二進制模組並在任何地方運行。這可能為在 pub.dev 上輕鬆分發包含原生程式碼的套件打開了大門。
我們正在使用一個新的套件 package:wasm 來實驗 Wasm 互操作支援。這個原型是建立在 Wasmer 運行時之上的,它支援 WASI 進行作業系統互動。請注意,我們目前的原型尚不完整,僅支援桌面平台(Windows、Linux 和 macOS)。
範例:調用到 Brotli 壓縮函式庫
讓我們來看一個使用 package:wasm 來利用編譯為 Wasm 模組的 Brotli 壓縮函式庫 的範例。在範例中,我們將讀取一個輸入檔案,壓縮它,報告其壓縮率,然後解壓縮它並驗證我們是否得到了輸入。請參閱 GitHub 儲存庫以獲取完整的 範例原始程式碼。由於 package:wasm 建立在 dart:ffi 之上,如果您有 FFI 的經驗,您可能會發現這些步驟很熟悉。
有幾種方法可以將 C 程式碼編譯為 Wasm,但在這種情況下,我們使用了 wasienv。完整的詳細資訊可在 README 中找到。
對於此範例,我們將嘗試調用這些 Brotli 函數來壓縮和解壓縮資料:
1 | int BrotliEncoderCompress( |
1 | int BrotliDecoderDecompress( |
quality
、lgwin
和 mode
參數是編碼器的調整參數。這些細節與範例無關,因此我們將僅使用這些參數的預設值。另一件需要注意的事情是 output_size
是一個輸入輸出參數。當我們調用這些函數時,output_size
必須使用我們分配的 output_buffer
的大小進行初始化,之後它將被設定為實際使用的緩衝區的數量。
第一步是使用我們編譯的 Wasm 二進制檔案來構造一個 WasmModule
物件。二進制資料應該是一個 Uint8List
,我們可以使用 file.readAsBytesSync()
從檔案中讀取它來獲取。
1 | var brotliPath = Platform.script.resolve('libbrotli.wasm'); |
一個非常有用的除錯工具,用於確保我們的 Wasm 模組具有我們期望的 API,是 module.describe()
。這將返回一個列出所有模組的導入和導出的字串。
1 | print(module.describe()); |
對於我們的 Brotli 函式庫,這是輸出:
1 | import function: int32 wasi_unstable::fd_close(int32) |
我們可以看到該模組導入了一些 WASI 函數,並導出其記憶體和一堆 Brotli 函數。我們感興趣的兩個函數已導出,但它們的簽章看起來有點不同。這是因為 Wasm 只支援 32 位和 64 位整數和浮點數。指標已成為導出記憶體中的 int32 索引。
下一步是實例化模組。在實例化期間,我們必須填寫模組期望的每個導入。實例化使用建構器模式 (module.instantiate().initialization… .build()
)。我們的函式庫只導入 WASI 函數,因此我們可以只調用 enableWasi()
:
1 | var instance = module.instantiate().enableWasi().build(); |
如果我們有額外的非 WASI 函數導入,我們可以使用 addFunction()
將 Dart 函數導入到 Wasm 函式庫中。
現在我們有了一個 WasmInstance
,我們可以查詢它的任何導出函數,或檢查它的記憶體:
1 | var memory = instance.memory; |
接下來我們要做的是在我們的輸入檔案上使用 compress
和 decompress
函數。但是我們不能直接將資料傳遞給這些函數。C 函數採用指向資料的 uint8_t
指標,但在 Wasm 程式碼中,這些指標成為實例記憶體中的 int32
索引。Brotli 還使用 size_t
指標報告壓縮和解壓縮資料的大小,這些指標也變成了 int32
。
因此,要將我們的資料傳遞給函數,我們必須將其複製到實例的記憶體中,並將其索引傳遞給函數。我們需要 5 個記憶體區域:輸入資料、壓縮資料、壓縮大小、解壓縮資料和解壓縮大小。為了簡單起見,我們只獲取一些未使用的記憶體區域,但您也可以在函式庫中導出 malloc()
和 free()
。
為了確保我們將資料放入未使用的記憶體中,我們將增加實例記憶體並將新區域用於我們的資料:
1 | var inputPtr = memory.lengthInBytes; |
我們的記憶體區域如下所示:
1 | [初始實例記憶體][輸入][輸出][輸出大小][解碼][解碼大小] |
接下來,我們將輸入資料載入到記憶體中,並調用我們的壓縮函數:
1 | memoryView.setRange( |
範例的其餘部分也類似。結果如下:
1 | 載入 lipsum.txt |
試用 package:wasm
如果您有興趣嘗試 Wasm 互操作,請查看 package:wasm 的 README 以獲取說明。
路線圖
Wasm 編譯和 Wasm 互操作都是實驗。如果這些實驗證明 fruitful,我們計劃繼續開發它們,並最終將它們產品化為穩定、受支援的版本。但是,如果我們了解到某些東西沒有按預期工作,或者看到缺乏興趣,我們將停止實驗。
我們正在進行這些實驗以學習,其中包含兩個主要組成部分。首先,我們想了解技術上支援 Wasm 的可行性,以及這種支援的特性可能是什麼。它可以使 Dart 程式碼更快、更小或更可預測嗎?其次,我們有興趣探索 Wasm 可能解鎖哪些新的技術功能,以及這些功能可能為 Dart 開發人員帶來哪些新的使用案例。我們可以使與原生程式碼的互操作更具可移植性嗎?
您認為 Wasm 如何應用於您的需求?您認為您將用它做什麼?我們很樂意聽到您的想法。請在 Dart misc 討論群組 上告訴我們。