上一章節中,我們有提到Web­Assembly比JavaScript的效能還要快很多,但始終沒有舉出一個例子來比較它們實際的效能差異。在這篇文章中,會介紹如何把任意現有的Rust函式庫編譯成Web­Assembly,然後移植到網頁瀏覽器上執行,並且比較Rust函式庫做出來的Web­Assembly模組以及原生JS模組在網頁瀏覽器上執行的效能差異。



ShortCrypt

ShortCrypt是筆者開發的跨程式語言的資料加解密函式庫,其介紹文章的網址如下:

在本篇文章中,將會嘗試把用Rust程式語言實作的ShortCrypt函式庫編譯成Web­Assembly模組,並與用原生JS程式實作的ShortCrypt一同用Webpack打包,直接在網頁瀏覽器上實際測試Rust-Web­Assembly實作和JS實作的效能差異。

ShortCrypt Web­Assembly

建立專案目錄

要將現有的Rust函式庫編譯成Web­Assembly模組,不一定需要去修改原本Rust函式庫程式專案的程式碼。我們同樣可以依照上一章介紹的方式,利用wasm-pack-template模板建立出一個新的Cargo程式專案,名為short-crypt-webassembly

指令如下:

cargo generate --git https://github.com/rustwasm/wasm-pack-template.git -n short-crypt-webassembly

引用short-crypt套件(crate)

編輯Cargo.toml,在[dependencies]區塊下加上:

short-crypt = "*"

引用serde框架

為了方便實作,我們會引用serde框架來協助處理序列化和反序列化的動作。另外,為了讓產生出來的JS的膠水代碼也能夠支援serde框架的序列化和反序列化,我們還要使用serde-wasm-bindgen套件。

編輯Cargo.toml,在[dependencies]區塊下加上:

serde = { version = "*", features = ["derive"] }
serde-wasm-bindgen = "*"

Rust程式實作

由於#[wasm_bindgen]屬性無法用在非目前這個crate底下的結構體、列舉、或是函數、方法,因此我們要另外建立一個Wrapper結構體,將short-crypt這個crate下的ShortCrypt結構體包裹起來。

程式如下:

...

#[wasm_bindgen]
pub struct ShortCrypt(short_crypt::ShortCrypt);

接著參考ShortCrypt的API文件,將想要暴露(export)給JavaScript使用的函數、方法寫進有套用#[wasm_bindgen]屬性的impl區塊中。程式如下:

...

use serde::{Deserialize, Serialize};

...

#[derive(Serialize, Deserialize)]
struct Cipher {
    pub base: u8,
    pub body: Vec<u8>,
}

#[wasm_bindgen]
impl ShortCrypt {
    #[wasm_bindgen(constructor)]
    pub fn new(key: &str) -> ShortCrypt {
        ShortCrypt(short_crypt::ShortCrypt::new(key))
    }

    pub fn encrypt_string(&self, plaintext: &str) -> JsValue {
        let (base, body) = self.0.encrypt(plaintext);

        serde_wasm_bindgen::to_value(&Cipher {
            base,
            body,
        })
        .unwrap()
    }

    pub fn encrypt_binary(&self, plaintext: &[u8]) -> JsValue {
        let (base, body) = self.0.encrypt(plaintext);

        serde_wasm_bindgen::to_value(&Cipher {
            base,
            body,
        })
        .unwrap()
    }

    pub fn decrypt_to_binary(&self, cipher: JsValue) -> Option<Vec<u8>> {
        let cipher: Cipher = serde_wasm_bindgen::from_value(cipher).ok()?;

        self.0.decrypt(&(cipher.base, cipher.body)).ok()
    }

    pub fn decrypt_to_string(&self, cipher: JsValue) -> Option<String> {
        let cipher: Cipher = serde_wasm_bindgen::from_value(cipher).ok()?;

        match self.0.decrypt(&(cipher.base, cipher.body)) {
            Ok(v) => String::from_utf8(v).ok(),
            Err(_) => None,
        }
    }

    pub fn encrypt_string_to_url_component(&self, data: &str) -> String {
        self.0.encrypt_to_url_component(data)
    }

    pub fn encrypt_binary_to_url_component(&self, data: &[u8]) -> String {
        self.0.encrypt_to_url_component(data)
    }

    pub fn decrypt_url_component_to_binary(&self, url_component: &str) -> Option<Vec<u8>> {
        self.0.decrypt_url_component(url_component).ok()
    }

    pub fn decrypt_url_component_to_string(&self, url_component: &str) -> Option<String> {
        match self.0.decrypt_url_component(url_component) {
            Ok(v) => String::from_utf8(v).ok(),
            Err(_) => None,
        }
    }

    pub fn encrypt_string_to_qr_code_alphanumeric(&self, data: &str) -> String {
        self.0.encrypt_to_qr_code_alphanumeric(data)
    }

    pub fn encrypt_binary_to_qr_code_alphanumeric(&self, data: &[u8]) -> String {
        self.0.encrypt_to_qr_code_alphanumeric(data)
    }

    pub fn decrypt_qr_code_alphanumeric_to_binary(&self, url_component: &str) -> Option<Vec<u8>> {
        self.0.decrypt_qr_code_alphanumeric(url_component).ok()
    }

    pub fn decrypt_qr_code_alphanumeric_to_string(&self, url_component: &str) -> Option<String> {
        match self.0.decrypt_qr_code_alphanumeric(url_component) {
            Ok(v) => String::from_utf8(v).ok(),
            Err(_) => None,
        }
    }
}

以上程式中,比較需要特別注意的部份有以下幾點:

  • Rust程式語言提供的i8u32f64等基本資料型別(不包括128位元的整數)可以直接與JavaScript的Number型別的值做對應。而String字串結構體和&str字串切片則可以直接與JavaScript的字串型別的值做對應。Vec<T>可以直接與JavaScript的陣列型別的值做對應。
  • wasm_bindgen提供的JsValue可以與JavaScript的任意值做對應。
  • #[wasm_bindgen(constructor)]屬性可以用來指定要把哪個關聯函數當作是JavaScript物件的建構子(constructor)。也就是在JS中使用new關鍵字時會呼叫的函數。
  • 因為wasm_bindgen不支援有使用泛型的函數,所以這邊把原先有用到泛型的函數拆開來寫了(將輸入和輸出的值分為binarystring版本)。
  • Option列舉的變體None,對應著JavaScript的undefined

然後您可能還會有一個疑問:為什麼這邊要使用JsValue結構體,怎麼不要像以下這樣也把Cipher結構體加上#[wasm_bindgen]屬性,使它能夠直接被函數回傳,或是直接作為函數的參數來傳入?

#[wasm_bindgen]
pub struct Cipher {
    pub base: u8,
    pub body: Vec<u8>,
}

是的,這樣的確是非常直覺的作法,但這實際上會讓程式編譯失敗,因為Vec<T>並沒有實作Copy特性,它無法作為JavaScript物件的公開(public)欄位。

建置專案

寫完Rust程式後,就可以執行以下指令將它編譯成.wasm檔案,以及產生出相對應的膠水代碼!

wasm-pack build

把WebAssembly套用在HTML網頁

依照上一章介紹的方式,我們可以在Cargo程式專案目錄中,新增一個www目錄,並完成基本的TypeScript+Webpack程式專案。然後再繼續進行下面的修改。

www目錄執行以下指令加入short-crypt套件:

pnpm i git+https://github.com/magiclen/ts-short-crypt.git
import { ShortCrypt } from "short-crypt";

import { ShortCrypt as ShortCryptWASM } from "../../pkg/short_crypt_webassembly.js";

const TIMES = 100;

const KEY = "magickey";
const TEXT = "articles";

class BenchResult {
    constructor(public readonly result1: string, public readonly result2: string, public readonly duration: number) {

    }

    printLog() {
        console.log((this.duration / TIMES * 1000) + "us", this.result1, this.result2);
    }
}

export const benchShortCrypt = () => {
    let result1 = "";
    let result2 = "";

    let duration = 0;

    for (let i = 0;i < TIMES;i++) {
        const start = performance.now();
    
        const sc = new ShortCrypt(KEY);
        result1 = sc.encryptToURLComponent(TEXT);
        result2 = sc.encryptToQRCodeAlphanumeric(TEXT);
    
        duration += performance.now() - start;
    }

    return new BenchResult(result1, result2, duration);
};

export const benchShortCryptWASM = () => {
    let result1 = "";
    let result2 = "";

    let duration = 0;

    for (let i = 0;i < TIMES;i++) {
        const start = performance.now();
    
        const sc = new ShortCryptWASM(KEY);
        result1 = sc.encrypt_string_to_url_component(TEXT);
        result2 = sc.encrypt_string_to_qr_code_alphanumeric(TEXT);
    
        duration += performance.now() - start;
    }

    return new BenchResult(result1, result2, duration);
};
import { benchShortCrypt, benchShortCryptWASM } from "./lib.js";

benchShortCrypt().printLog();
benchShortCryptWASM().printLog();

在Webpack的進入點中,我們分別測量了原生JavaScript版本的ShortCrypt和WebAssembly版本的ShortCrypt的執行時間。會將結果輸出到網頁瀏覽器的主控台(console)中。

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>ShortCrypt</title>
</head>
<body>
    Please see the console.

    <script src="./js/bundle.min.js"></script>
    </body>
</html>

然後就可以在www目錄中執行npm run build:webpack指令來打包專案。接著再使用VSCode的Live Server開啟Webpack輸出的index.html,正常情況下會成功在網頁瀏覽器的主控台輸出結果。

在Firefox 60.7.2esr上的執行結果如下:

610us 2E87Wx52-Tvo 3BHNNR45XZH8PU
40us 2E87Wx52-Tvo 3BHNNR45XZH8PU

在Chromium 75.0.3770.90上的執行結果如下:

641.2999999702151us 2E87Wx52-Tvo 3BHNNR45XZH8PU
24.650000013934914us 2E87Wx52-Tvo 3BHNNR45XZH8PU

由此可知WebAssembly版本的ShortCrypt的效能大概是原生JavaScript版本的20倍!

適合或不適合編譯成WebAssembly的Rust程式

WebAssembly固然強大,但我們並沒有辦法將所有的Rust程式都移植成WebAssembly程式。像是若Rust程式有用到C/C++函式庫,那它就無法被成功編譯為WebAssembly程式。

crates.io上有很多Rust的套件可以取用,其中很適合用於WebAssembly的分類有:不使用標準函式庫的no-std、解析資料的parser-implementationstext-processing、讓Rust語法更方便的rust-patterns。而有些套件則是原本就專門針對WebAssembly來設計,會被分在wasm

總結

在這個章節中,我們學習了把現有Rust函式庫編譯為WebAssembly程式的方式,並且對於WebAssembly的程式的效能有所認識。從下一章節開始,要開始實作大一點的專案,利用WebAssembly來高效地操作HTML的Canvas,並使它能與使用者互動。

下一章:實作康威生命遊戲