認識 WebAssembly 與 Rust 實踐

簡介

先來說下在 WebAssembly(後續稱WASM) 官網上的介紹,主要有四點:

    1. 高效:WASM 有一套完整的語義,實際上 WASM 是體積小且加載快的二進制格式, 其目標就是充分發揮硬件的能力以達到原生語言的執行效率
    1. 安全:WASM 運行在一個內存安全,沙箱化的執行環境中,甚至可以在現有的 JavaScript 虛擬機中實現。在 Web 環境中 ,WASM 將會嚴格遵守同源策略以及瀏覽器安全策略
    1. 開放:WASM 設計了一個非常規整的文本格式用來、調試、測試、實驗、優化、學習、教學或者編寫程序。可以以這種文本格式在 Web 頁面上查看 WASM 模塊的源碼
    1. 標準:WASM 在 Web 中被設計成無版本、特性可測試、向後兼容的。WASM 可以被 JavaScript 調用,進入 JavaScript 上下文,也可以像 Web API 一樣調用瀏覽器的功能。WASM 不僅可以運行在瀏覽器上,也可以運行在非 Web 環境下(如 Node.js、Deno、物聯網設備等。

發展歷史

    1. 2013年:Asm.js 發佈,虛幻引擎被編譯成 Asm.js 移植到瀏覽器中,這是 WASM 的前身
    1. 2015年:各大瀏覽器廠商開始合作開發 WASM,成立 W3C WebAssembly Community Group
    1. 2017年:各大瀏覽器開始支持 WASM
    1. 2018年:2月發佈首個公開的草案
    1. 2019年:WASM 發佈 1.0 正式版
    1. 2022年:4月發佈了 2.0 的草案

兼容性

可以看到目前的主流瀏覽器:Chrome、Edge、Safari、Firefox、Opera 都已經支持,Safari 11版本(對應 IOS 11)以上的移動端對於 WASM 的支持也比較好了,如果是低於 IOS 11 以下的系統就需要做邏輯兜底的處理了。所以如果是 B 端的項目,可以放心大膽的去在項目中進行落地,如果是 C 端的項目,可能會有一小部分用戶的系統會不支持,這時候可以使用 wasm2js 工具來做代碼轉換兜底。

在正式去了解 WASM 之前我們先來了解一下 LLVM 👇

LLVM

LLVM 是模塊化和可重用的編譯器和工具鏈技術的集合,它是由 C++ 編寫的。儘管叫做 LLVM,但它跟傳統虛擬機幾乎沒啥關係。“LLVM” 這個名稱本身並不是首字母縮寫(並不是 Low Level Virtual Machine),LLVM 就是它的全稱。它用於優化以任意的編程語言編寫的程序的編譯時間、鏈接時間、運行時間以及空閒時間,經過各種優化後,輸出一套適合編譯器系統的中間語言,目前採用它來做轉換的語言有很多:Swift、Object-C、C#、Rust、Java字節碼等。WASM 編譯器底層也使用了LLVM 去將原生代碼(如Rust、C、C++等)轉換成 WASM 二進制代碼。

編譯器

編譯器包括三部分:

    1. 前端:負責處理源語言
    1. 優化器:負責優化代碼
    1. 後端:負責處理目標語言

前端

前端在接收到代碼的時候就會去解析它,然後檢查代碼是否有語法或語法問題,然後代碼就會轉換成中間表示產物(intermediate representation) IR。

優化器

優化器會去分析 IR 並將其轉換成更加高效的代碼,很少有編譯器會有多箇中間產物。優化器相當於一箇中間產物到中間產物的轉換器,其實就是在中間做了一層加工優化處理,優化器包括移除冗餘的計算,去掉執行不到的冗餘代碼,還有一些其它的可以進行優化的選項。

後端

後端會接收中間產物並轉換它到其它語言(如機器碼),它也可以鏈接多個後端去轉換代碼到一些其它語言。爲了產生高效的機器代碼,後端應該理解執行代碼的體系結構。

LLVM 的功能

LLVM 的核心是負責提供獨立於源、目標的優化,併爲許多 CPU 架構生成代碼。這使得語言開發人員可以只創建一個前端,從源語言生成 LLVM 兼容的 IR 或 LLVM IR。LLVM 不是一個單一項目,它是子項目和其他項目的集合。

    1. LLVM 使用一種簡單的低級語言,風格類似C語言
    1. LLVM 是強類型的
    1. LLVM 有嚴格定義的語義
    1. LLVM 具有精確的垃圾回收
    1. LLVM 提供了各種優化,可以根據需求選擇。它具有積極的、標量的、過程間的、簡單循環的和概要文件驅動的優化
    1. LLVM 提供了各種編譯模型。分別是鏈接時間、安裝時間、運行時和脫機
    1. LLVM 爲各種目標架構生成機器碼
    1. LLVM 提供 DWARF 調試信息(DWARF 是一種調試文件格式,許多編譯器和調試器都使用它來支持源代碼級別的調試)

瞭解了 LLVM 我們就正式進入 WASM 的內容介紹。

WASM 和 JS

它被設計用於高效執行和緊湊表達,它可以以接近原生代碼的速度在所有 JS 引擎上執行 (手機、電腦瀏覽器、Node.js)。每個 WASM 文件都是一個高效、最優且自給自足的模塊,稱爲 WASM 模塊,它運行在沙盒上,內存安全,沒有權限獲取超出沙盒限制以外的東西,WASM 是一個虛擬指令集結構。

JavaScript 是如何執行的?**

    1. 把整個文件加載完成
    1. 將代碼解析成抽象語法樹
    1. 解釋器進行解釋然後編譯再執行
    1. 最後再進行垃圾回收

JavaScript 既是解釋語言又是編譯語言,所以 JavaScript 引擎在解析後啓動執行。解釋器執行代碼的速度很快,但它每次解釋時都會編譯代碼。JavaScript 引擎有監視器 (在某些瀏覽器中稱爲分析器)。監視器跟蹤代碼執行情況,如果一個特定的代碼塊被頻繁地執行,那麼監視器將其標記爲熱代碼。引擎使用即時 (JIT) 編譯器編譯代碼塊。引擎會花費一些時間進行編譯,比如以納秒爲單位。花在這裏的時間是值得的,因爲下次調用函數時,執行速度會比之前快得多,因爲編譯型代碼比解釋型代碼要快,這個階段是優化代碼階段。JavaScript 引擎增加了一(或兩)層優化,監視器會持續監視代碼的執行,監視器標記那些被執行頻次更高的代碼爲高熱點代碼,引擎將進一步優化這段代碼,這個優化需要很長時間。這個階段產生運行速度非常快的高度優化過的代碼,該階段的優化代碼執行速度要比上一段說的優化過的代碼還要快得多。顯然,引擎在這一階段花費了更多時間,比如以毫秒爲單位,這裏耗費的時間將由代碼性能和執行效率來進行補償。JavaScript 是一種動態類型的語言,引擎所能做的所有優化都是基於類型的推斷。如果推斷失敗,那麼將重新解釋並執行代碼,並刪除優化過的代碼,而不是拋出運行時異常。JavaScript 引擎實現必要的類型檢查,並在推斷的類型發生變化時提取優化的代碼,但是如果重新推斷類型,那花在上述代碼優化階段的功夫就白費了。

開發中我們可以通過使用 TypeScript 來防止一些與類型相關的問題,使用 TypeScript,可以避免一些多態代碼 (接受不同類型的代碼) 的出現。在 JavaScript 引擎中,只接受一種類型的代碼總是比多態代碼運行得快,但是如果是 TS 裏帶有泛型的代碼,那也會被影響到執行速度。最後一步是垃圾回收,將刪除內存中的所有活動對象,JavaScript 引擎中的垃圾回收採用標記清除算法,在垃圾回收過程中,JavaScript 引擎從根對象 (類似於 Node.js 中的全局對象) 開始。它查找從根對象開始引用的所有對象,並將它們標記爲可訪問對象,它將剩餘的對象標記爲不可訪問的對象,最後清除不可訪問的對象。

WASM 是怎麼執行的?

WASM 是二進制格式並且已經被編譯和優化過了,首先 JS 引擎會去加載 WASM 代碼,然後解碼並轉換成模塊的內部表達(即 AST)。這個階段是解碼階段,解碼階段要遠遠比 JS 的編譯階段要快。接下來,解碼後的 WASM 進入編譯階段,在這個階段,對模塊進行驗證,在驗證期間,對代碼進行某些條件檢查,以確保模塊是安全的,沒有任何有害的代碼,在驗證過程中對函數、指令序列和堆棧的使用進行類型檢查,然後將驗證過的代碼編譯爲機器碼。由於 WASM 二進制代碼已經提前編譯和優化過了,所以在其編譯階段會更快,在這個階段,WASM 代碼會被轉換爲機器碼。最後編譯過的代碼進入執行階段,執行階段,模塊會被實例化並執行。在實例化的時候,JS 引擎會實例化狀態和執行棧,最後再執行模塊。WASM 的另一個優點是模塊可以從第一個字節開始編譯和實例化,因此,JS 引擎不需要等到整個模塊被下載,這可以進一步提高 WASM 的性能。WASM 快的原因是因爲它的執行步驟要比 JS 的執行步驟少,其二進制代碼已經經過了優化和編譯,並且可以進行流式編譯。但是總的來說,WASM 並不是總是比原生JS 代碼執行速度要快的,因爲 WASM 代碼和 JS 引擎交互和實例化也是要耗費時間的,所以需要考慮好使用場景,在一些簡單的計算場景裏,WASM 和 JS 引擎的交互時間都會遠遠超出其本身的執行時間,這種時候還不如直接使用 JS 來編寫代碼來得快,另一方面,也要減少 WASM 和 JS 引擎之間的數據交互,因爲每次兩者的數據交互都會耗費一定的時間。

基礎部分了解了,下面我們來看一下 WASM 的開發部分。

WASM 開發語言的選擇

要寫 WASM 應用的話首先不能選用有 GC 的語言,不然垃圾收集器的代碼也會佔用很大一部分的體積,對 WASM 文件的初始化加載並不友好,比較好的選擇就是 C/C++/Rust 這幾個沒有 GC 的語言,當然使用 Go、C#、TypeScript 這些也是可以的,但是性能也會沒有C/C++/Rust 這麼好。從上面幾個語言來看 Rust 對於前端選手來說會稍微親切一些,從語法上看和 TS 有一點點的相似(但是學下去還是要比 TS 難得多的), Rust 的官方和社區對於 WASM 都有着一流的支持,而且它也是一門系統級編程語言,有一個和 NPM 一樣好用的包管理器 Cargo,同時 Rust 也擁有着很好的性能,用來寫 WASM 再好不過了。同時它的社區熱度也在不斷的上升中。

Rust 開發 WASM

Rust 提供了對 WASM 一流的支持,Rust 無需 GC 、零運行時開銷的特點也讓它成爲了 WASM 的完美候選者。Rust 是怎麼編譯成 WASM 代碼的:


從零開始 WASM 項目

  • Rust 安裝

首先需要安裝好 Rust的開發環境。安裝好之後控制檯運行 rustc --version 顯示版本號即可。

  • wasm-pack(WASM 打包器)

一個專門用於打包、發佈 WASM 的工具,可以用於構建可在 NPM 發佈的 WASM 工具包。當我們開發完 WASM 模塊時,可以直接使用 wasm-pack publish 命令把我們開發的 WASM 包發佈到 NPM 上。使用cargo install wasm-pack 命令來進行安裝。

開發環境搭****建

接下來我們舉個例子從零開始搭建一個 WASM 開發目錄

  • 創建 Rust 工程

首先創建 Rust 工程目錄:cargo new example --lib
然後在其目錄下控制檯運行
npm init -y
package.json 內容如下,配置好 package.json 之後先安裝依賴,執行 npm install


{
  "name": "example",
  "version": "0.1.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "rimraf dist pkg && webpack",
    "start": "rimraf dist pkg && webpack-dev-server",
    "test": "cargo test && wasm-pack test --headless"
  },
  "devDependencies": {
    "@wasm-tool/wasm-pack-plugin": "^1.6.0",
    "html-webpack-plugin": "^5.5.0",
    "rimraf": "^3.0.2",
    "webpack": "^5.75.0",
    "webpack-cli": "^4.10.0",
    "webpack-dev-server": "^4.11.1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

cargo.toml 依賴如下:


[package]
categories = ["wasm"]
description = ""
edition = "2021"
name = "example"
version = "0.1.0"

[lib]
# 一個動態的系統庫將會產生,類似於C共享庫。當編譯一個從其它語言加載調用的動態庫時這屬性將會被使用
crate-type = ["cdylib"]

[features]

[dependencies]
# 用於將實體從 Rust 綁定到 JavaScript,或反過來。
# 提供了 JS 和 WASM 之間的通道,用來傳遞對象、字符串、數組這些數據類型
wasm-bindgen = "0.2.83"
wee_alloc = {version = "0.4.5", optional = true}

# web-sys 可以和 JS 的 API 進行交互,比如 DOM
[dependencies.web-sys]
features = ["console"]
version = "0.3.60"

[dev-dependencies]
# 用於所有JS環境 (如Node.js和瀏覽器)中的 JS 全局對象和函數的綁定
js-sys = "0.3.60"

# 0 – 不優化
# 1 – 基礎優化
# 2 – 更多優化
# 3 – 全量優化,關注性能時建議開啓此項
# s – 優化二進制大小
# z – 優化二進制大小同時關閉循環向量,關注體積時建議開啓此項
[profile.dev]
debug = true
# link time optimize LLVM 的鏈接時間優化,false 時只會優化當前包,true/fat會跨依賴尋找關係圖裏的所有包進行優化
# 其它選項還有 off-關閉優化,thin是fat的更快版本
lto = true
opt-level = 'z'

[profile.release]
debug = false
lto = true
opt-level = 'z'
  • 內存分配器(可選)

上面我們在依賴中加入了 wee_alloc 這個內存分配器,對比默認的 10kb 大小的分配器,它只有 1kb 的大小,但是它要比默認的分配器速度要慢,所以默認不開啓,爲減少模塊打包時的大小,可以使用這個內存分配器。在src/lib.rs 中使用的代碼如下:


#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
  • 入口文件

項目根目錄下創建 index.html 文件,寫入以下內容:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>WASM Hello World</title>
  </head>
  <body></body>
  <script src="index.js"></script>
</html>
  • Webpack 配置

項目根目錄下新建 webpack.config.js 並新建 js/index.js 文件用於調用 WASM 側暴露的函數。WasmPackPlugin 這個插件會幫我們在運行 Webpack 時自動去打包 WASM ,生成可直接用於發佈的 NPM 模塊。


const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const WasmPackPlugin = require("@wasm-tool/wasm-pack-plugin");

const dist = path.resolve(__dirname, "dist");

module.exports = {
  mode: "development",
  entry: {
    index: "./js/index.js",
  },
  output: {
    path: dist,
    filename: "[name].js",
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, "./index.html"),
      inject: false,
    }),
    new WasmPackPlugin({
      crateDirectory: __dirname,
    }),
  ],
  experiments: {
    asyncWebAssembly: true,
  },
  resolve: {
    extensions: [".ts", ".js"],
  },
};

上面的所有配置都完成之後,命令行執行 npm start 即可啓動項目,然後會自動生成 pkg 目錄,這個就是最終可以發佈打包到 NPM 上的的 WASM 庫。最終的項目目錄長這個樣子:

Hello World

在上面的環境搭建好之後,我們就開始試着在瀏覽器控制檯上打印出 Hello World 吧,進入到 src/lib.rs 文件,寫入以下代碼:


use wasm_bindgen::prelude::*;
use web_sys::console;

#[wasm_bindgen]
pub fn hello_world() {
    console::log_1(&JsValue::from_str("Hello World!"));
}

然後到 JS 側去進行調用,在 js/index.js 文件中寫入以下代碼:

async function main() {
  const module = await import('../pkg/index');
  module.hello_world();
}

main();

運行 npm start 之後,打開 localhost:8080 端口,就能看到 Hello World 被打印出來咯。

JS 和 Rust 之間的數據傳遞

  • JS 調用 Rust

這裏我們在 src/lib.rs 裏寫一個斐波那契函數,可以返回一個 i32 數字類型,JS 端就可以拿到對應的返回結果:


// 斐波那契數列,時間複雜度 O(2^n)
#[wasm_bindgen]
pub fn fib(n: i32) -> i32 {
    match n {
        1 => 1,
        2 => 1,
        _ => fib(n - 1) + fib(n - 2),
        }
}

然後在根目錄下的 js/index.js 中編寫如下代碼進行調用:

async function main() {
  const module = await import("../pkg/index");
  console.log(module.fib(30));
}

main();

控制檯上就能看到對應的結果了:

再看 wasm-pack 給我們生成的 WASM 膠水代碼,它在 pkg/index_bg.js 中,可以看到生成的代碼中已經幫我們做好了一些邊界判斷和異常處理,然後 JS 側直接引入這個文件去調用我們編寫好的函數即可。如果你不想使用 webpack 的插件來生成 WASM 包,也可以自己手動執行 wasm-pack build 命令來生成。

import * as wasm from './index_bg.wasm';

const lTextDecoder = typeof TextDecoder === 'undefined' ? (0, module.require)('util').TextDecoder : TextDecoder;

let cachedTextDecoder = new lTextDecoder('utf-8', { ignoreBOM: true, fatal: true });

cachedTextDecoder.decode();

let cachedUint8Memory0 = new Uint8Array();

function getUint8Memory0() {
  if (cachedUint8Memory0.byteLength === 0) {
    cachedUint8Memory0 = new Uint8Array(wasm.memory.buffer);
  }
  return cachedUint8Memory0;
}

function getStringFromWasm0(ptr, len) {
  return cachedTextDecoder.decode(getUint8Memory0().subarray(ptr, ptr + len));
}

function _assertNum(n) {
  if (typeof(n) !== 'number') throw new Error('expected a number argument');
}
/**
* @param {number} n
* @returns {number}
*/
export function fib(n) {
  _assertNum(n);
  const ret = wasm.fib(n);
  return ret;
}

export function __wbindgen_throw(arg0, arg1) {
  throw new Error(getStringFromWasm0(arg0, arg1));
};
返回數組

#[wasm_bindgen]
pub fn send_array_to_js() -> Box<[JsValue]> {
    vec![
        JsValue::NULL,
        JsValue::UNDEFINED,
        JsValue::from_str("123"),
        JsValue::TRUE,
        JsValue::FALSE,
    ]
    .into_boxed_slice()
}
返回對象

在 cargo.toml 的 dependencies 加入下面兩個依賴:


[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde-wasm-bindgen = "0.4"

src/lib.rs 中編寫代碼:

use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;

#[derive(Serialize, Deserialize)]
pub struct Obj {
    pub field1: HashMap<u32, String>,
    pub field2: Vec<Vec<i32>>,
    pub field3: [f32; 4],
    pub field4: bool,
    pub field5: String,
}

#[wasm_bindgen]
pub fn send_obj_to_js() -> JsValue {
    let mut map = HashMap::new();
    map.insert(0, String::from("ex"));

    let obj = Obj {
        field1: map,
        field2: vec![vec![1, 2], vec![3, 4]],
        field3: [1., 2., 3., 4.],
        field4: true,
        field5: "哈哈哈".to_string(),
    };

    serde_wasm_bindgen::to_value(&obj).unwrap()
}

JS 側進行調用:


async function main() {
  const module = await import('../pkg/index');
  console.log(module.send_obj_to_js());
  console.log(module.send_array_to_js());
}

main();

打印結果:

  • Rust 調用 JS

項目根目錄下創建一個 js2rust 目錄,然後新建 point.js 文件,裏面的代碼是給 Rust 側調用的:

export class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  get_x() {
    return this.x;
  }

  get_y() {
    return this.y;
  }

  set_x(x) {
    this.x = x;
  }

  set_y(y) {
    this.y = y;
  }

  add(p1) {
    this.x += p1.x;
    this.y += p1.y;
  }
}

我們上面創建了一個 JS 側的 Point 對象,然後在 Rust 端我們看看如何進行調用:先去到 src/lib.rs 目錄下,加入下面的代碼:

// 調用 JS 中的方法
#[wasm_bindgen(module = "/js2rust/point.js")]
extern "C" {
    pub type Point;

    #[wasm_bindgen(constructor)]
    fn new(x: i32, y: i32) -> Point;

    #[wasm_bindgen(method, getter)]
    fn get_x(this: &Point) -> i32;

    #[wasm_bindgen(method, getter)]
    fn get_y(this: &Point) -> i32;

    #[wasm_bindgen(method, setter)] //5
    fn set_x(this: &Point, x: i32) -> i32;

    #[wasm_bindgen(method, setter)]
    fn set_y(this: &Point, y: i32) -> i32;

    #[wasm_bindgen(method)]
    fn add(this: &Point, p: Point);
}

// 這個函數 JS 側可以繼續進行調用,最終會返回一個 point 對象實例
#[wasm_bindgen]
pub fn test_point() -> Point {
    let p = Point::new(10, 10);
    let p1 = Point::new(6, 3);
    p.add(p1);
    p
}

發佈

當我們調試好代碼之後,就可以在 NPM 上發佈我們的 WASM 包了。直接 cd 到 pkg 目錄下,修改我們的 package.json 的 name 爲 example-fib, 然後執行 npm publish 就可以發佈到 NPM 上了,後續可以在我們自己的項目中 npm install example-fib 下載來調用:


import { fib } from "example-fib";

async function main() {
  // const module = await import("../pkg/index");
  // console.log(module.fib(30));

  console.log(fib(40));
}

main();

我們通過 NPM 包的形式引入我們的 WASM 斐波那契函數,可以看到一樣可以調用成功。


WASM 和 JS 性能比較

我寫了一段測試代碼測試上面寫的斐波那契數列執行時間,WASM 版本和 JS 版本的執行時間比較如下:

(測試電腦的CPU爲10代i7,不同電腦、不同瀏覽器的執行時間可能都會不一樣)

JS 版本的 Fibonacci 函數:

function jsFib(n) {
  if (n === 1 || n === 2) return 1;
  return jsFib(n - 1) + jsFib(n - 2);
}

從結果中我們可以看到,在時間複雜度爲 O(2^n) 的算法中, WASM 的性能是要好於 JS 的,i 的值越大,WASM 的優勢就會越明顯,但是如果 i 的值比較小,WASM 的性能不一定比得過 JS,因爲其中 JS 和 WASM 的交互就有一定的時間成本,當然這裏的比較也是在 WASM 和 JS 側數據交互比較少的情況,如果數據交互量大了,那麼速度也是會受到一定的影響的,所以在業務開發中如果使用到 WASM 模塊,那麼就需要儘可能減少 JS 和 WASM 之間的數據傳輸。

同時這裏也放一篇相關的文章供大家參考,這篇文章主要講 Rust 版本的 Markdown 解析器編譯到 WASM 後和 JS 版本的 Markdown 解析器做性能對比:https://sendilkumarn.com/blog/increase-rust-wasm-performance/

主要對比的是 Rust 的 comrak 庫 和 JS 的 marked 庫

下面貼一下作者的最終對比結果

未經過優化的 WASM 代碼,WASM 表現不佳

Rust 開啓 lto 優化和優化級別“3”,性能最優


Rust 開啓 lto 和 優化級別 "z",性能有所降低,但是打包出來的 WASM 模塊體積會更小。

從比對結果中可以看到, WASM 在開啓了優化的情況下性能比 JS 要好

代碼體積優化

  • WASM 內存模型

在 JS 引擎內部,WASM 和 JS 在不同的位置運行。跨越它們之間的邊界進行交互是有成本的。瀏覽器內部用了一些手段來降低這個成本,但是當程序跨越這個邊界時,這個行爲很快就會成爲程序的主要性能瓶頸。以減少邊界跨越的方式設計 WASM 程序是很重要。但是一旦程序變大,就很難控制。爲了防止邊界跨越,WASM 模塊附帶了內存模型。WASM 模塊中的內存是線性內存的向量。線性內存模型是一種內存尋址技術,其中內存被組織在一個塊線性地址空間中,它也被稱爲扁平內存模型。線性內存模型使理解、編程和表示內存變得更容易。但是它也有巨大的缺點,例如重新排列內存中的元素需要大量的執行時間,並且會浪費大量的內存區域。在這裏,內存表示一個包含未解釋數據的原始字節向量。WASM 使用可調整大小的數組緩衝區來保存內存的原始字節。創建的內存可以從 JS 和 WASM 模塊中進行訪問和改變。

  • WASM 內存分析

使用 twiggy 這個 crate

cargo install twiggy

使用這個包可以看到相關代碼大小佔用以及尋找某些編譯器不知道如何進行優化的冗餘代碼。

這樣的一段代碼編譯成 WASM 之後,我們看一下其大小,輸入命令 twiggy top -n 10 ./pkg/index_bg.wasm 對輸出的 pkg/index_bg.wasm 文件進行代碼分析可以看到下面的結果,top -n 10 表示取排名前十的文件,我們可以看到這個 WASM 文件總共佔了 8kb 的大小,我們可以根據相關的信息來進行代碼優化,越複雜的應用最後展示的信息會越明朗,因爲我們這裏的代碼比較簡單,展示出來的基本都是一些內置函數的代碼大小,更多相關信息可以查看 twiggy 文檔。

  • 進一步壓縮體積

使用 wasm-opt這個 C++ 編寫的工具可以進一步去壓縮 WASM 模塊的體積大小。下載完後將其解壓放到 ~/.cargo/bin 目錄下,然後 wasm-opt -h 之後控制檯能打印出幫助信息表示安裝成功了。我們拿上面說到的 8kb 的 WASM 文件試着壓縮一下,在項目根目錄下執行 wasm-opt -Oz pkg/index_bg.wasm -o pkg/index_opt_bg.wasm,Oz 選項代表極致壓縮大小。然後查看生成的 index_opt_bg.wasm 文件,壓縮前 7.86 kb,壓縮後 6.12 kb。根據官網的介紹,其正常的壓縮效率會在 10%~20% 左右。

  • WASM 包發佈

cd 到根目錄下的 pkg 目錄下,然後執行 npm publish 就能把包發佈到 NPM 倉庫上,然後在 JS 端 Webpack 開啓 WASM 實驗性配置,就能使用起來了,在一些複雜的計算場景中可以使用 WASM 來提高大量的性能,使用 WASM 之後可以將一些複雜計算邏輯放到客戶端來做,這樣就能夠減少服務器的壓力了,節省服務器的一些成本。

進一步認識 WASM

其它WASM 開發工具

編譯器可以將高級代碼轉換爲 WASM 二進制代碼,但是生成的二進制文件都是經過了相關的壓縮和性能優化的。它很難理解、調試和驗證 (它是一堆十六進制數)。轉換 WASM 二進制到原始源代碼很難。WebAssembly 二進制工具包 (WABT) 幫助將 WASM 二進制轉換爲人類可讀的格式,例如 WASM 文本 (WAST) 格式或 C 語言原生代碼。WABT 工具包在 WASM 的開發生態中很重要,是我們開發 WASM 中的重要一環。WABT(WebAssembly Binary ToolKit) 有以下的能力:

  1. wat2wasm:轉換 WAST 到 WASM

  2. wasm2wat:轉換 WASM 到 WAST

  3. wasm2c:轉換 WASM 到 C 語言

  4. wast2json:轉換 WAST 到 JSON

  5. wasm-validate:驗證 WASM 是否按照規範來構建

  6. wasm-decomplie:反編譯 WASM 代碼到類似於 C 語言的語法的可讀代碼

  7. 還有一些其它的能力可以參考上面的地址

▐ ****WASM 開發****框架

開發軟件時使用 WASM 的方式有幾種:

  1. 純 WASM 實現,包括 UI 和邏輯

  2. UI 使用 HTML/CSS/JS,邏輯計算使用 WASM

  3. 複用其它語言中的庫,使用 WASM 來移植到已有的 Web 軟件中

如果需要使用純 WASM 來開發應用,不同語言和 WASM 開發相關的框架:

  1. Rust:Yew(語法類似於 React)、Seed、Perseus

  2. Go:Vecty、Vugu

  3. C#:Blazor

雖然現在可以用 WASM 來編寫 Web 應用了,但是還存在一定的侷限性,就是無法直接從 WASM 中直接操作 DOM 和一些其它的瀏覽器 API,還是需要通過 FFI (外部函數接口) 來進行調用 JS 提供的能力。

▐ ****WASM 相關的庫

圖片處理:Photon,這是一個高性能的 Rust 編寫的圖片處理庫,支持 Rust 原生調用、瀏覽器中使用 WASM 調用、在 Node 中使用 WASM 調用。

音視頻處理:ffmpeg.wasm,C語言編寫的音視頻處理工具,通過 WASM 移植到了瀏覽器內。

WASM 運行時:Wasmer,Wasmtime,其可以嵌入到任何編程語言並且可以在多種設備上去運行 WASM 。根據 Wasmer 官網介紹,quick.js 引擎的 WASM 版本也可以在一些脫離於瀏覽器之外的其它環境上去運行,甚至你還可以在瀏覽器的 V8 Js 引擎中去跑 WASM 版本的 quick.js 引擎,拿其來做一些動態代碼下發的事情,同時達成套娃成就。

▐ ****現有的使用 WASM 編寫的應用

  • PSPDFKit

其產品官網介紹了他們的 Web 版本是如何使用 WASM 進行優化的,其介紹的相關文章:優化 WASM 的啓動性能,他們主要做的加載優化主要是以下的 4 個方面:

  1. 文件緩存,因爲 .wasm 文件和 .js 文件類似,靜態資源是從網絡進行加載的,所以可以進行瀏覽器緩存,可以強制或者協商緩存到本地,這個一般需要服務端來配合。

  2. 使用流實例化

  3. 把已經編譯好的 WASM 模塊緩存到 IndexDB 中加快後續加載速度

  4. 使用對象池緩存預熱實例

這是他們給出的一段主要代碼:

const MODULE_VERSION = 1;

// 從 IndexDB 加載緩存
const cache = await getCache('WASMCache');
let compiledModule = await cache.get(MODULE_VERSION);

// 創建一個 WebAssembly.Module 實例,如果緩存中存在則直接返回緩存
if (compiledModule) {
    return WebAssembly.instantiate(compiledModule, imports);
}

const fetchPromise = fetch('module.wasm');

let instantiatePromise;

// 檢測瀏覽器是否支持 WebAssembly.instantiateStreaming 流式實例化
const isInstantiateStreamingSupported =
    typeof WebAssembly.instantiateStreaming == 'function';

if (isInstantiateStreamingSupported) {
    instantiatePromise = WebAssembly.instantiateStreaming(
        fetchPromise,
        imports,
    );
} else {
    // 不支持則採用原始的實例化方式
    instantiatePromise = fetchPromise
        .then((response) => response.arrayBuffer())
        .then((buffer) => WebAssembly.instantiate(buffer, imports));
}

const result = await instantiatePromise;

// 將加載結果緩存到 IndexDB 中
cache.put(MODULE_VERSION, result.module);
return result.instance;

其中流實例化這個方式還是比較新的特性,目前兼容性並不是特別好,所以需要做好兜底處理,從下圖可以看到在 Safari 15 以上才支持。

PSPDFKit 使用流實例化和未使用流實例化,在 Firefox 上測試結果,使用後快了 1.8 倍。

  • 谷歌地球

可以看到谷歌地球的加載過程中除了 WASM 模塊文件外還有大量的 WebWorker 相關的文件,可以說爲了在瀏覽器跑起這個大型 3D 應用是下足了苦心的。


  • Figma

一個基於瀏覽器的多人實時協作 UI 設計工具,以前的 Figma 使用 asm.js 來加快文件讀取速度,現在改用 WASM 後,它的速度又飆升了很多。從它的官網上,也是可以瞄到有 WASM 的痕跡, wasm.br 結尾的文件是使用 Brotil 技術來進行壓縮過的,其壓縮率比 gzip 都要高,Brotil 目前已經被大多數瀏覽器實現,如果客戶端聲稱接受 Accept-Encoding: br,服務器就可以返回 wasm.br 文件。

  • AutoCAD Web

AutoCAD 是一款自動計算機輔助設計軟件,用於繪製二維製圖和基本三維設計,用於土木建築,裝飾裝潢,工業製圖,工程製圖,電子工業,服裝加工等多方面領域。

  • ebay的條形碼掃描案例

大概意思就是 eBay 想在他們的 H5 頁面上做條形碼掃描功能,起初用純 JS 實現的版本掃描成功率在 20% 左右,然後他們就把自研的的 C++ 編寫的掃描庫通過 WASM 移植到 Web 上之後,成功率到了 60%,最後他們就去嘗試了用 C 語言編寫的 ZBar 開源庫,使得成功率到了 80%,然後剩下的 20% 的情況自研庫卻表現得良好,然後他們就使用 Web Worker 將這兩者結合使用,採用競爭取勝,看哪個線程先返回結果,最後掃描成功率到了 95%,在最後再把 JS 版本的加入另一個線程後,掃描成功率接近了 100%。上線一段時間後,他們對條形碼掃描情況進行了統計,結果發現有 53% 的成功掃描來自於 ZBar,34% 來自於自研的 C++ 庫。剩下的 13% 則來自於第三方的 JS 庫實現。其中通過 WASM 實現(自研 C++ 庫、Zbar)得到的掃描結果佔據了總成功次數的 87%。

  • TensorflowJS

TensorflowJS做的一個 Benchmark 人臉檢測輕量模型性能對比,在這個模型場景下,WASM 計算後端平均比純 JS 計算後端快 8~20倍左右,和 WebGL 計算後端比,取決於機器設備,WASM 有些設備比 WebGL 快,有些比它慢。但當 WASM 啓用了單指令多數據流(SIMD) 和 多線程(threads) 特性,速度還會提升很多。

  • 其它

當然還有很多軟件也用了 WASM,比如 B 站的視頻投稿頁、Web 版本 PhotoShop 等。


WASM 具有 Native 性能的關鍵

SIMD 和 多線程是 WASM 宣稱其接近 Native 性能的兩大重要特點。WASM 多線程和 JS 的 Web Worker 不同,其不需要複製數據,並通過 PostMessage 在不同線程之間使用事件循環來進行通信,開啓多線程後,WASM 數據線性內存會從 ArrayBuffer 變成 ShareArrayBuffer, 所有的線程都可以直接去讀寫該段內存中的數據,不需要像 JS 一樣耗費時間進行線程間的事件通信。SIMD 指的是單指令多數據流,比如目前瀏覽器已經支持的 128 位定寬 SIMD,可以用一條彙編指令執行 4個 32 位的計算,而不使用 SIMD 則需要使用 4條指令去單獨執行每一個 32位計算。

下面是一些別人使用 SIMD 的性能測試結果


WASM 適用和不適用場景

  • 適用場景

  1. 移植 C/C++/Rust/Go 等語言開發的庫到 Web 端
  2. 需要高算力的場景,如下面這些:圖片/視頻編輯、遊戲、流媒體應用、圖像識別、直播、虛擬現實、CAD軟件、加密/解密工具、可視化/仿真平臺
  3. 小型物聯網設備、雲計算平臺等
  • 不適用場景

  1. 需要 JS 和 WASM 頻繁進行數據交互的場景
  2. 計算量較少,且需要進行雙向數據傳遞,此時的數據傳輸時間可能會大於邏輯本身的執行時間

WASM 是否要去代替 JavaScript?

答案是否,它主要是被設計爲 JavaScript 的一個完善補充,而不是代替品。其它語言編寫的庫可以很好的去移植到 Web 中,和 JavaScript 的內容結合到一起使用,大多數 HTML/CSS/JavaScript 應用結合幾個高性能的 WASM 模塊(例如,繪圖,模擬,圖像/聲音/視頻處理,可視化,動畫,壓縮等等我們今天可以在 Asm.js 中看到的例子)能夠允許開發者像今天我們所用的 JS 庫一樣去重用流行的 WASM 庫。

WASM 目前的侷限性

  1. 凡是能夠使用 WASM 來實現的功能,現階段都可以通過 JavaScript 來實現;而能夠使用 JavaScript 來實現的功能,其中部分還無法直接通過 WASM 實現(比如操作 DOM)

  2. 複雜數據類型需要進行編解碼,對於除“數字、字符串”以外的類型(例如:對象、數組)需要先編碼成二進制再存放到 WASM 的內存段裏。

  3. 與 JavaScript 膠水代碼的交互帶來的性能損耗在一定程度上抵消了 WASM 本身帶來的性能紅利。

WASM 的未來

WASI(WebAssembly System Interface),一種使用標準化系統接口在任何系統上可移植地運行 WebAssembly 的方法。隨着 WASM 的性能越來越高,WASI 可以證明是在任何操作系統上運行任何代碼的可行方式,其不受操作系統限制去操作系統級接口/資源,它是標準化 WASM 的模塊和 Native 宿主環境之間的一個調用接口,這個接口和上層的編程語言是無關的。比如在其實現中的 wasi-libc 提供了 libc 的支持,把原來的像底層和內核對接的系統調用接口換成了 WASI 的 Interface,這樣大家可以在 WebAssembly 裏面繼續調用類似於 FileOpen 這樣的系統調用,可以在所有的 Runtime 上運行,達到一個很好的跨平臺特性。

WASM 的出現到今天還不夠 10 年,WASM 在 2019 年發佈 1.0 版本,2022 年 4 月發佈了 2.0 版本的草案,目前還在蓬勃的發展當中。預計幾年後,隨着 WASM 的完善,將會在越來越多的場景下有所作爲。

最後貼一個 WASM 的特性支持列表,截至今天,可以看到定寬SIMD(Fixed-width SIMD)、多線程(Threads)都已經支持了,寬鬆 SIMD(Relaxed SIMD)、尾部調用優化(Tail Call)目前還沒支持,這些都是爲了提升 WASM 字節碼在特定情況下的執行性能,最終爲了儘可能地讓 WASM 的執行效率能夠最大化。

[圖片上傳中...(image-da7974-1678372748793-29)]

[圖片上傳中...(image-da3436-1678372748790-0)] 寫在最後

這篇文章是由於我對於 WASM 的興趣而寫下,這提供了一種未來在業務中遇到性能問題時的優化手段和思路。對於非前端程序員來說,WASM 爲他們提供了一扇進入 Web 的窗口;而對於前端程序員來說,WASM 將會是前端未來基建中的重要一部分,我們要以發展的眼光去看待事物,現在 WASM 還在蓬勃的發展中,即使只是抱着學習的心態去了解,我們也仍能從這個學習過程當中學到很多超出所學習事物本身的額外知識。

參考

  1. 《Pratical WebAssembly》

  2. 《WebAssembly 入門》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章