在今年歐洲的JSConf上Emil Bay進行了一場題為《Hand-Crafting WebAssembly》的演講。Emil表示:“現在已經有很多關於WebAssembly(WASM)的演講。遺憾的是,大多數演講是關於如何把高階語言編譯成wasm的,他們把wasm當成一個半透明的盒子。WebAssembly是一門有趣的語言,你可以用它寫出效能低於C的程式碼”。在這此的演講中,Emil向我們演示瞭如何寫WAT(WebAssembly的文字格式)以及當擁有大記憶體時,如何推理演算法,如何將高階結構(如迴圈)轉換為基礎指令,同時獲得樂趣!Emil演示瞭如何把一些難度逐漸遞增的演算法轉換成基礎指令,在沒有抽象的情況下每一個演算法的實現都充滿著挑戰。即時你在工作中並沒有使用WASM,學習計算機的最低階指令可以撥開抽象的迷霧,揭示計算機的神奇。在開始正文之前讓我們先一睹大佬風采 ?
什麼是WebAssembly
“WebAssembly(縮寫Wasm)是執行在一個基於棧的虛擬機器上的二進位制指令格式。Wasm是為了把像C/C++/Rust等高階語言編譯成行動式的目標而設計的,可以被部署到Web端和服務端應用”。 這是WebAssembly官網的解釋,聽起來不錯,但是今天我們可以忘記這些,因為我們今天用不到這些高深的技術術語。通過“WebAssembly”這個單詞你可能猜想它執行在瀏覽器端的組合語言。實際上,它既不是很Web,也不是很Assembly(Not very Web, not very Assembly)。
為什麼這麼說WebAssembly “Not very Web, not very Assembly”呢?
- 它不能直接使用Web API。
- WebAssembly程式碼不是直接執行在物理機上的,雖然它很接近物理機,但它仍然是一個抽象出來的執行環境。
- 不能系統呼叫,除非你通過JavaScript給它呼叫通道。
- 不能使用新的硬體裝置。例如:藍芽。
- 沒什麼魔法,只是計算。
吐槽了那麼多,到底WebAssembly是什麼呢?
-
64位整型(i64)
WebAssembly最讓我興奮的的是它可以使用64位的整型數字,這讓我們可以精確的描述那些需要數字計算的事物。由於我的工作是關於密碼學的,我們經常需要處理256位或者512位長度的二進位制數字,64位整型數字的支援對效能提升確實很有效。 -
效能提升(Performance Boost)
人們通常通過把程式碼轉換成WebAssembly來獲得效能的提升,但是根據我的經驗通常收益不像想象的那麼大。我通過以前一些實驗得出WebAssembly相對JavaScript效能大約提升了20%至30%。因為JavaScript在一些新的JavaScript引擎(v8、SipderMonkey等)上已經執行的很快了! -
精度/可預測性(Precision/Predictable)
使用JavaScript寫程式碼的時候,你通常不知道寫出來的程式碼效能怎麼樣,除非你研究過底層的虛擬機器。使用WebAssembly你更接近程式碼的底層執行,所以程式碼的表現或效能將更加可預測。 -
Run anywhere
另一件,讓人感到興奮的的事是WebAssembly可能在不久以後成為唯一一個可以跨平臺、跨端執行的語言。我已經看到有人在使用WebAssembly寫Linux核心的專案,還有人在瀏覽器里載入WebAssembly模組。
WebAssembly不是什麼未來的黑科技,現在丹麥已經有超過77%的瀏覽器支援,而全球也已經有超過73%的瀏覽器支援,而且Node.js 8.0以上也支援WebAssembly,所以你現在就可以使用它。
WebAssembly Text-format
下面我們要手擼WebAssembly,而不是通過高階程式語言編譯成WebAssembly。 WebAssembly是一種二進位制格式的低階(low level)類組合語言,官方為了讓人類能夠閱讀和編輯它,還提供了相應的文字格式(wat)。
1. 平方運算
從一個簡單的平方計算的函式開始我們的第一個WebAssembly模組:
(module
(func $square
(export "square")
(param $x i32)
(result i32)
(return (i32.mul (get_local $x)
(get_local $x)))))
複製程式碼
這裡我們定義了一個平方運算的函式square,它接受一個你i32型別的引數,返回結果也是i32。通過這個模組我們應該注意到以下幾點:
- wat文字採用的是S-expressions的語法(類似LISP)。
- 模組是WebAssembly的基本單位,這點和ES6的模組很像。
- 標籤(引數名、變數名和函式名)使用 $ 字首宣告。
- 明確的型別,引數、變數、函式返回都有型別宣告。
- 運算操作是通過
type.op
形式的指令呼叫,type代表運算結果的型別,op是要做的運算操作。如:i32.mul
表示要做乘法運算(mul),運算操作的結果的型別是i32(32位整型數字)。 - 顯示訪問,當要使用一個變數時,我們需要顯示訪問。如:
get_local $x
,我們使用get_local
顯示訪問了本地變數x
。
我們來看下這個模組是如何使用的?
-
將上面的“First module”儲存到
square.wat
檔案。你也可以從handcrafting-webassembly這個倉庫直接克隆獲取原始碼。 -
$ git clone --recursive https://github.com/WebAssembly/wabt $ cd wabt $ make #cmake, git, make required $ npm i -g wat2js 複製程式碼
- 安裝完成後將 /wabt/bin目錄新增到系統的環境。mac是新增到*/etc/paths*檔案。
-
生成wasm模組和JavaScript膠水程式碼檔案
$ wat2wasm square.wat #生成square.wasm檔案 $ wat2js square.wat -o square.js #生成載入wasm模組的CommonJS模組 複製程式碼
-
使用wasm模組。新建example.js,新增如下程式碼:
var wasm = require(`./square.js`); console.log(wasm.exports.square(2)); // 4 複製程式碼
通過這個簡單的WebAssembly小模組,我們應該已經掌握了WebAssembly文字格式一些基本語法以及如何使用它。接下來我們來看下Emil在實際工作中寫的程式碼。
2. 計算兩點之間距離
下面的這段程式碼定義並匯出了一個f64.distance
的函式,它接受四個引數分別是x1、y1、x2、y2,返回一個64位浮點型數字。這段程式碼還是比較好理解的,有了之前的“First Module”的經驗你應該已經知道如何使用它。同樣,你可以在handcrafting-webassembly找到它的原始碼。
(module
(func $square
(export "square")
(param $x i32)
(result i32)
(return (i32.mul (get_local $x)
(get_local $x))))
(func $f64.distance
(export "f64.distance")
(param $x1 i32) (param $y1 i32)
(param $x2 i32) (param $y2 i32)
(result f64)
(local $x.dist i32)
(local $y.dist i32)
(set_local $x.dist (i32.sub (get_local $x1)
(get_local $x2)))
(set_local $y.dist (i32.sub (get_local $y1)
(get_local $y2)))
(return (f64.sqrt (f64.convert_u/i32 (i32.add (call $square (get_local $x.dist))
(call $square (get_local $y.dist)))))))
)
複製程式碼
讓我們把難度再提升一個等級。
3. 計算向量間的距離
向量間距離計算,其實相當於兩個陣列間距離的計算。這段程式碼的難度就增加了很多!這裡用到了WebAssembly的線性記憶體(Linear memory)和loop指令。
- memory是WebAssembly的一個重要的概念,它是用來實現JavaScript和WebAssebly模組間通訊的,本質上就是一個大的共享陣列。下面的模組中,我們建立並匯出了一頁(64KiB)大小的momery例項。匯出的memory例項是提供給JavaScript使用。通過JavaScript把外部陣列的資料存到memory,然後我們可以WebAssebly模組裡訪問它。
loop
指令用來定義迴圈程式碼塊。緊跟在loop指令後面需要定義一個標籤,在這裡我們定義的是“continue”。WebAssembly的迴圈和JavaScript有些不同。在JavaScript的迴圈裡有continue和break兩個分支。WebAssembly的迴圈比較像do-while迴圈,但它只有一個條件分支,當br_if
條件為真的時候,繼續執行指定標籤的迴圈。
(module
(memory (export "memory") 1)
(func $i8.distance
(export "i8.distance")
(param $v.ptr i32)
(param $w.ptr i32)
(param $len i32)
(result f64)
(local $distance.sq i32)
(local $elm i32)
(local $offset i32)
(set_local $distance.sq (i32.const 0))
(loop $continue
;; $elm = $w[$offset] - $v[$offset]
(set_local $elm (i32.sub (i32.load8_u (i32.add (get_local $w.ptr)
(get_local $offset)))
(i32.load8_u (i32.add (get_local $v.ptr)
(get_local $offset)))))
;; $distance.sq += $elm ** 2
(set_local $distance.sq (i32.add (get_local $distance.sq)
(i32.mul (get_local $elm)
(get_local $elm))))
;; $offset++ < $len ? continue : break
(br_if $continue (i32.lt_u (tee_local $offset
(i32.add (get_local $offset)
(i32.const 4))) ;; bytewidth of i32
(get_local $len))))
(return (f64.sqrt (f64.convert_u/i32 (get_local $distance.sq))))))
複製程式碼
通過這個例子,我們來看下數字在memory裡面是如何儲存的。如下圖,我們可以看到i8
型別表示的是八個位元位(bit)整型數字,也就是一個位元組(byte)。i32
表示的是四個位元組長度的整型數字,f64
表示的是八個位元組的浮點型數字。所以說memory其實就是一個位元組陣列。在JavaScript裡面數字只有Number
型別,我們也不需要關心數字在記憶體中是如何儲存的,但是在WebAssembly裡,你必須知道如何為一個數字分配合適它的記憶體(定義合適的型別)。
那我們是如何解析陣列的呢?答案是通過指標(pointer)和陣列的長度(length)。在這裡指標也就相當於陣列的下標(index),長度也就是陣列分配的記憶體大小。
總結
通過手擼三個難度遞增的的WebAssembly模組,對於理解WebAssembly在記憶體使用和執行機制應該有所收益。但是還是要提醒大家手擼WebAssembly並不符合它的設計初衷。演講的最後階段,Emil還介紹了自己加密演算法庫sodium-native和sodium-universal(廣告時間 啊哈~),如果你感興趣的話可以移步到他的gayhub。(完