上一章節中,我們有提到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框架來協助處理序列化和反序列化的動作。

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

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

另外,為了讓產生出來的JS的膠水代碼也能夠支援serde框架的序列化和反序列化,我們還需要啟用wasm-bindgen這個crate的serde-serialize特色,使它能夠提供JsValue結構體。

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);

        JsValue::from_serde(&Cipher {
            base,
            body,
        }).unwrap()
    }

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

        JsValue::from_serde(&Cipher {
            base,
            body,
        }).unwrap()
    }

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

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

    pub fn decrypt_to_string(&self, cipher: JsValue) -> Option<String> {
        let cipher: Cipher = cipher.into_serde().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目錄,然後在這個目錄中再新增以下這些檔案,來將pkg目錄中的檔案加進Webpack中。

{
  "name": "short-crypt-dev",
  "version": "0.0.0",
  "scripts": {
    "build": "webpack --mode production",
    "build:dev": "webpack --mode development",
    "start": "webpack serve --mode=production",
    "start:dev": "webpack serve --mode=development"
  },
  "devDependencies": {
    "@babel/core": "^7.18.2",
    "@babel/preset-env": "^7.18.2",
    "@babel/register": "^7.17.7",
    "babel-loader": "^8.2.5",
    "buffer": "^6.0.3",
    "html-webpack-plugin": "^5.5.0",
    "terser-webpack-plugin": "^5.3.1",
    "webpack": "^5.72.1",
    "webpack-cli": "^4.9.2",
    "webpack-dev-server": "^4.9.0"
  },
  "dependencies": {
    "short-crypt": "git+https://github.com/magiclen/js-short-crypt.git"
  }
}

注意這邊我們還把用原生JavaScript實作的ShortCrypt套件加進這個專案中。buffer套件是個polyfill。

import * as module from "../../pkg/short_crypt_webassembly";

import ShortCrypt from "short-crypt";

const times = 100;
let jsDuration = 0;
let jsResult1;
let jsResult2;
let wasmDuration = 0;
let wasmResult1;
let wasmResult2;

for (let i = 0;i < times;i++) {
    const start = performance.now();

    const sc = new ShortCrypt("magickey");
    jsResult1 = sc.encryptToURLComponent("articles");
    jsResult2 = sc.encryptToQRCodeAlphanumeric("articles");

    jsDuration += performance.now() - start;
}

for (let i = 0;i < times;i++) {
    const start = performance.now();

    const sc = new module.ShortCrypt("magickey");
    wasmResult1 = sc.encrypt_string_to_url_component("articles");
    wasmResult2 = sc.encrypt_string_to_qr_code_alphanumeric("articles");

    wasmDuration += performance.now() - start;
}

console.log((jsDuration / times * 1000) + "us", jsResult1, jsResult2);
console.log((wasmDuration / times * 1000) + "us", wasmResult1, wasmResult2);

在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>
const TerserPlugin = require("terser-webpack-plugin");

const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
    output: {
        clean: true,
        filename: "./js/bundle.min.js",
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: "./views/index.html",
            filename: "./index.html",
            minify: {
                collapseBooleanAttributes: true,
                collapseWhitespace: true,
                removeAttributeQuotes: true,
                removeComments: true,
                removeEmptyAttributes: true,
                removeRedundantAttributes: true,
                removeScriptTypeAttributes: true,
                removeStyleLinkTypeAttributes: true,
                minifyCSS: true,
                minifyJS: true,
                sortAttributes: true,
                useShortDoctype: true,
            },
            inject: false,
        }),
    ],
    module: {
        rules: [
            {
                test: /\.js$/i,
                use: {
                    loader: "babel-loader",
                    options: { presets: ["@babel/preset-env"] },
                },
            },
        ],
    },
    optimization: {
        minimizer: [
            new TerserPlugin({
                extractComments: false,
                terserOptions: {
                    format: {
                        comments: false,
                    },
                },
            }),
        ]
    },
    experiments: { asyncWebAssembly: true },
};

將以上檔案添加好後,就可以執行npm install指令來安裝套件。接著再執行npm run start指令來瀏覽結果。

在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++函式庫,或者有用到檔案I/O,又或者有用到thread::spawn函數來產生新執行緒(未來可能會支援),那它就無法被成功編譯為WebAssembly程式。

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

總結

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

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