【JSConf EU 2018】WebAssembly 的手工藝術

leyayun發表於2019-03-03

在今年歐洲的JSConf上Emil Bay進行了一場題為《Hand-Crafting WebAssembly》的演講。Emil表示:“現在已經有很多關於WebAssembly(WASM)的演講。遺憾的是,大多數演講是關於如何把高階語言編譯成wasm的,他們把wasm當成一個半透明的盒子。WebAssembly是一門有趣的語言,你可以用它寫出效能低於C的程式碼”。在這此的演講中,Emil向我們演示瞭如何寫WAT(WebAssembly的文字格式)以及當擁有大記憶體時,如何推理演算法,如何將高階結構(如迴圈)轉換為基礎指令,同時獲得樂趣!Emil演示瞭如何把一些難度逐漸遞增的演算法轉換成基礎指令,在沒有抽象的情況下每一個演算法的實現都充滿著挑戰。即時你在工作中並沒有使用WASM,學習計算機的最低階指令可以撥開抽象的迷霧,揭示計算機的神奇。在開始正文之前讓我們先一睹大佬風采 ?

【JSConf EU 2018】WebAssembly 的手工藝術

什麼是WebAssembly

“WebAssembly(縮寫Wasm)是執行在一個基於棧的虛擬機器上的二進位制指令格式。Wasm是為了把像C/C++/Rust等高階語言編譯成行動式的目標而設計的,可以被部署到Web端和服務端應用”。 這是WebAssembly官網的解釋,聽起來不錯,但是今天我們可以忘記這些,因為我們今天用不到這些高深的技術術語。通過“WebAssembly”這個單詞你可能猜想它執行在瀏覽器端的組合語言。實際上,它既不是很Web,也不是很Assembly(Not very Web, not very Assembly)。

【JSConf EU 2018】WebAssembly 的手工藝術

為什麼這麼說WebAssembly “Not very Web, not very Assembly”呢?

  • 它不能直接使用Web API。
  • WebAssembly程式碼不是直接執行在物理機上的,雖然它很接近物理機,但它仍然是一個抽象出來的執行環境。
  • 不能系統呼叫,除非你通過JavaScript給它呼叫通道。
  • 不能使用新的硬體裝置。例如:藍芽。
  • 沒什麼魔法,只是計算。
【JSConf EU 2018】WebAssembly 的手工藝術

吐槽了那麼多,到底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模組。

【JSConf EU 2018】WebAssembly 的手工藝術

WebAssembly不是什麼未來的黑科技,現在丹麥已經有超過77%的瀏覽器支援,而全球也已經有超過73%的瀏覽器支援,而且Node.js 8.0以上也支援WebAssembly,所以你現在就可以使用它。

【JSConf EU 2018】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

我們來看下這個模組是如何使用的?

  1. 將上面的“First module”儲存到square.wat檔案。你也可以從handcrafting-webassembly這個倉庫直接克隆獲取原始碼。

  2. 安裝編譯工具wabtwat2js

    $ git clone --recursive https://github.com/WebAssembly/wabt
    $ cd wabt
    $ make  #cmake, git, make required
    $ npm i -g wat2js
    複製程式碼
    • 安裝完成後將 /wabt/bin目錄新增到系統的環境。mac是新增到*/etc/paths*檔案。
  3. 生成wasm模組和JavaScript膠水程式碼檔案

    $ wat2wasm square.wat  #生成square.wasm檔案
    $ wat2js square.wat -o square.js  #生成載入wasm模組的CommonJS模組
    複製程式碼
  4. 使用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裡,你必須知道如何為一個數字分配合適它的記憶體(定義合適的型別)。

【JSConf EU 2018】WebAssembly 的手工藝術

那我們是如何解析陣列的呢?答案是通過指標(pointer)和陣列的長度(length)。在這裡指標也就相當於陣列的下標(index),長度也就是陣列分配的記憶體大小。

【JSConf EU 2018】WebAssembly 的手工藝術

總結

通過手擼三個難度遞增的的WebAssembly模組,對於理解WebAssembly在記憶體使用和執行機制應該有所收益。但是還是要提醒大家手擼WebAssembly並不符合它的設計初衷。演講的最後階段,Emil還介紹了自己加密演算法庫sodium-nativesodium-universal(廣告時間 啊哈~),如果你感興趣的話可以移步到他的gayhub。(完

相關文章