使用 Rust + WebAssembly 編寫 crc32

前端深夜告解室發表於2018-07-13

背景

WebAssembly 在最近幾年裡可以說是如火如荼了。從基於 LLVM 的 Emscripten ,到嘗試打造全流程工具鏈的 binaryen ,再到 Rust 社群出現的wasm-bindgen……現在 webpack 4 已經內建了wasm的引入,甚至連 Go 社群也不甘落後地推出了相關的計劃

作為一個普通的前端開發者,我雖然一直在關注它的發展,但始終沒有直接操刀使用的機會。直到最近,我們想使用 crc32 演算法做一些字串校驗時,我想看看 WebAssembly 是否能夠在這項計算任務上,比原生 JavaScript 更具有效能優勢。

有關 crc32

crc32 演算法是一個專門用於 校驗資料 是否被意外篡改的演算法。它在計算量上比md5 、 sha 這類密碼學資訊摘要演算法要小很多,但修改任何一個位元組,都會引起校驗和發生變化。crc32 並不是密碼學安全的,構造兩組校驗和相同的資料並不困難。因此,crc32 適合用在意外篡改的檢查上,而不適合用在對抗人工篡改的環境下。

在原理上, crc32 可以被看作是使用資料對某個選定的數字(Polynomial,常被縮寫為“Poly”,實際是一個生成數字的多項式簡寫形式),進行某種形式的除法。除法產生的餘數,就是校驗和。具體的演算法原理略微複雜一些,大家可以參考這篇《無痛理解CRC》

不同的數字會對演算法有很強的影響。在計算機領域,有好幾個不同的數字在不同的領域採用。gzip 用的 crc32 的數字,就和 ext4 檔案系統用的不同。

歷史上, crc32 的演算法也被改進過多次。從最簡單的逐位計算,到採用查詢表進行優化,再到使用多張查詢表優化,其效能被提升了數百倍之多。關於這點,大家可以在 Fast CRC32 上檢視詳情。對於大部分場景,我們追求效能而不是程式碼體積,因此儘可能利用查詢表,能夠讓演算法發揮最強的效能。

要想對比 JS 版和 Rust 版 crc32 的效能差距,首先要排除掉演算法實現不同帶來的影響。因此,下面我在進行效能對比時所採用的 crc32 演算法,都是我自己參考第三方程式碼來寫的,並不直接採用現成的包。

不過由於 crc32 的實現版本太多,這裡只挑取其中效能較好同時查詢表體積適中的 Slicing-by-8 實現來寫。

用 JavaScript 寫一個 crc32

現在我們新建一個crc32.js檔案,存放我寫的 crc32 。這種 crc32 的實現需要進行兩個步驟,第一個步驟是生成查詢表:

// crc32.js
const POLY = 0xedb88320;
const TABLE = makeTable(POLY);
const TABLE8 = (function () {
  const tab = Array(8);
  for (let i = 0; i < 8; i++) {
    tab[i] = new Uint32Array(256);
  }
  tab[0] = makeTable(POLY);
  for (let i = 0; i <= 0xFF; i++) {
    tab[1][i] = (tab[0][i] >>> 8) ^ tab[0][tab[0][i] & 0xFF];
    tab[2][i] = (tab[1][i] >>> 8) ^ tab[0][tab[1][i] & 0xFF];
    tab[3][i] = (tab[2][i] >>> 8) ^ tab[0][tab[2][i] & 0xFF];
    tab[4][i] = (tab[3][i] >>> 8) ^ tab[0][tab[3][i] & 0xFF];
    tab[5][i] = (tab[4][i] >>> 8) ^ tab[0][tab[4][i] & 0xFF];
    tab[6][i] = (tab[5][i] >>> 8) ^ tab[0][tab[5][i] & 0xFF];
    tab[7][i] = (tab[6][i] >>> 8) ^ tab[0][tab[6][i] & 0xFF];
  }
  return tab;
})();

function makeTable(poly) {
  const tab = new Uint32Array(256);
  for (let i = 0; i < 256; i++) {
    let crc = i;
    for (let j = 0; j < 8; j++) {
      if (crc & 1 === 1) {
        crc = (crc >>> 1) ^ poly;
      } else {
        crc >>>= 1;
      }
      tab[i] = crc;
    }
  }
  return tab;
}
複製程式碼

這個步驟我放在模組全域性了,因為查詢表只需要生成一次,後面實際進行 crc32 的計算時,只讀就可以了。

第二個步驟就是 crc32 本身的計算:

// 續crc32.js
// 讀取和拼裝32位整數
function readU32(buf, offset) {
  return buf[0 + offset] + ((buf[1 + offset]) << 8) + ((buf[2 + offset]) << 16) + ((buf[3 + offset]) << 24);
}

// 實際計算
function crc32(buf) {
  let crc = ~0;
  let leftLength = buf.byteLength;
  let bufPos = 0;
  while (leftLength >= 8) {
    crc ^= readU32(buf, bufPos);
    crc = TABLE8[0][buf[7 + bufPos]] ^
    TABLE8[1][buf[6 + bufPos]] ^
    TABLE8[2][buf[5 + bufPos]] ^
    TABLE8[3][buf[4 + bufPos]] ^
    TABLE8[4][(crc >>> 24) & 0xFF] ^
    TABLE8[5][(crc >>> 16) & 0xFF] ^
    TABLE8[6][(crc >>> 8) & 0xFF] ^
    TABLE8[7][crc & 0xFF];
    bufPos += 8;
    leftLength -= 8;
  }
  for (let byte = 0; byte < leftLength; byte++) {
    crc = TABLE[(crc & 0xFF) ^ buf[byte + bufPos]] ^ (crc >>> 8);
  }
  return ~crc;
}

module.exports = crc32;
複製程式碼

為方便未來對比,我將這個函式匯入並重新命名,然後搭建一個對比測試的環境:

// index.js
const Benchmark = require('benchmark');
const crc32ByJs = require('./crc32');
// 匯入測試文字資料
const testSource = fs.readFileSync('./fixture/jquery.js.txt', 'utf-8');
const text = testSource;
// 為了遮蔽掉編碼帶來的效能影響,我預先就將字串編碼
const textInU8 = stringToU8(text);

// 輔助工具函式,幫我們把字串編碼 的二進位制資料
function stringToU8(text) {
  return Buffer.from(text, 'utf8');
}
複製程式碼

注意,這裡雖然使用了 UTF-8 ,但其實也可以選擇其他的編碼,比如 UTF-16 或者 UTF-32,只不過 UTF-8 的支援更加廣泛一些,另外不必關心位元組序,也更方便於解碼。

現在我們可以開始搞 WebAssembly 版的 crc32ByWasm了。

WebAssembly 與 Rust

WebAssembly 本身是非常類似機器碼的一種語言,它緊湊且使用二進位制來表達,因此在體積上天然有優勢。但要讓開發者去寫機器碼,開發成本會非常高,因此伴隨著 WebAsssembly 出現的還有相應的人類可讀的文字描述—— S 表示式描述

(module
  (type $type0 (func (param i32 i32) (result i32)))
  (table 0 anyfunc)
  (memory 1)
  (export "memory" memory)
  (export "add" $func0)
  (func $func0 (param $var0 i32) (param $var1 i32) (result i32)
    get_local $var1
    get_local $var0
    i32.add
  )
)
複製程式碼

S表示式已經比機器碼可讀性強很多了,但我們能使用的依然是一些非常底層的操作,比較類似組合語言。因此,目前更常見的玩法,是將其他程式語言編譯到 WebAssembly,而不是直接去寫 WebAssembly 或者 S 表示式。

Rust 社群在這方面目前進展比較不錯,有專門的工作小組來支援這件事。我雖然之前沒有太多 Rust 經驗,但這次非常想利用社群的工作成果,以避開其他語言生成 WebAssembly 的各種不便。

Rust與WebAssembly

打造 Rust 工具鏈

Rust 社群和 JavaScript 社群有一些相似,大家都是樂於在工程化上投入精力,並致力於提升開發舒適度的群體。搭建一個 Rust 開發環境其實非常簡單,總共只需要3步:

  1. 下載並安裝 rustup。這一步和安裝 nvm 差不多。
  2. 使用 rustup 來安裝和使用 nightly 版的 rust。這一步相當於使用 nvm 安裝具體的 Node.js 版本
  3. 繼續使用 rustup,下載安裝名為 wasm32-unknown-unknown 的編譯目標。這一步是 rust 獨有的了,不過實際上任何能交叉編譯的編譯器,都要來這麼一遍。

這裡稍微說一下什麼叫做“交叉編譯”。

正常來講,如果我在 Linux x86 的系統裡安裝一套 C++ 編譯器,那麼當我使用這套編譯器生成可執行程式的時候,它生成的就是本機能用的程式。那如果我有一臺 Windows 的機器,卻沒有在其中安裝任何編譯器,該怎麼辦呢?這時,如果有一套 C++ 編譯器能在 Linux x86 上執行,但產生的程式碼卻是執行在 Windows 上的,這套編譯器就是交叉編譯工具了。相對應的,這個過程就叫做交叉編譯。

如之前所說, WebAssembly 是一種機器碼,那麼用 Rust 編譯器(本來生成的是macOS或者Linux x86的可執行程式)生成它,自然就是一種交叉編譯了。

這個過程整理成指令碼就是如下的樣子了:

# 執行完這句話以後,和安好nvm一樣,要在命令列裡引入一下 rustup
curl https://sh.rustup.rs -sSf | sh
rustup toolchain install nightly # 安裝 nightly 版 rust
rustup target add wasm32-unknown-unknown # 安裝交叉編譯目標
複製程式碼

注意,不同的平臺上的Rust安裝過程可能略有差異,屆時需要根據具體情況來做調整。明確自己所用的 Rust 版本非常重要,因為 Rust 對 WebAssembly 的支援還在早期階段,一些工程化的程式碼隨時可能發生變化。在寫這篇文章時,我所用的 Rust 版本為 rustc 1.28.0-nightly (2a1c4eec4 2018-06-25)。

建立一個 Rust 專案

安裝好 Rust 之後,會自帶一個名為 cargo 的命令列。cargo 是 Rust 社群的包管理命令列工具,比較類似於 Node.js 社群的 npm 。建立 Rust 專案可以直接使用 cargo 進行:

cargo new crc32-example
複製程式碼

這樣我們就可以在當前目錄下建立一個新目錄 crc32-example,並在其中初始化好了我們的程式碼。cargo 預設會新建兩個檔案,分別是 Cargo.tomllib.rs(具體程式碼可參見文末的原始碼),他們的作用分別是:

  • Cargo.toml相當於是 Rust 社群的 package.json,用於存放依賴描述和一些專案元資訊。
  • lib.rs是程式碼的入口檔案,以後我們寫的 Rust 程式碼就會放在其中。

下面我們會詳細說說 WebAssembly 的呼叫。

Node.js 呼叫 WebAssembly

Node.js 不同的版本對 WebAssembly 支援各不相同,在我自己的測試中發現,Node.js 8.x的支援就算是比較穩定了,因此後面我都會用 Node.js 8.x 來寫。

WebAssembly 在 JavaScript 中如何呼叫的文章在網上比較多了,大家可以自己搜尋參考一下,這裡我只列出一些核心,不做具體的介紹了。

WebAssembly 在 JavaScript 當中可以被看作是一種特殊“模組” ,這個模組對外匯出若干函式,同時也能接受 JavaScript 向其中匯入函式。由於 JavaScript 自己的記憶體管理是通過垃圾回收器來自動做的,而其他一些靜態語言通常是開發者手動管理記憶體,WebAssembly 當中所用的記憶體,需要從普通的 JavaScript 記憶體中區分開來,單獨開闢和管理。

在使用 WebAssembly 時,首先要對其進行初始化。初始化的時候,JavaScript 引擎會校驗 WebAssembly 的合法性,並將單獨開闢記憶體、匯入函式,和模組進行關聯。 這個過程變成程式碼的話,就是如下的樣子:

// 續index.js
const wasmFile = fs.readFileSync('./target/wasm32-unknown-unknown/release/wasm_crc32_example.wasm');

const wasmValues = await WebAssembly.instantiate(wasmFile, {
  env: {
    memoryBase: 0,
    tableBase: 0,
    // 單獨開闢的記憶體
    memory: new WebAssembly.Memory({
      initial: 0,
      maximum: 65536,
    }),
    table: new WebAssembly.Table({
      initial: 0,
      maximum: 0,
      element: 'anyfunc',
    }),
    // 匯入函式,如果要在 Rust 當中使用任何 JavaScript 函式,都要像這樣匯入
    logInt: (num) => {
      console.log('logInt: ', num);
    },
  },
});
複製程式碼

WebAssembly.instantiate 將返回一個 Promise 物件,物件內部我們關心的是instance 屬性,它就是初始化後可用的 WebAssembly 物件了:

// 續index.js
const wasmInstance = wasmValues.instance;

const {
  // 將 WebAssembly 匯出的函式 crc32 重新命名為 crc32ByWasm
  // 因為我們已經有一個 JavaScript 的實現,以防混淆
  crc32: crc32ByWasm,
} = wasmInstance.exports;

const text = testSource;
const checksum = crc32ByWasm(text);
複製程式碼

上面的程式碼嘗試使用 WebAssembly 匯出的函式,來計測試文字的校驗和。

然而,這種程式碼其實是行不通的。最大的問題在於,WebAssembly 是沒有真正的字串型別的

WebAssembly 在當前的設計中,能夠使用型別其實只有各種型別的數字,從8位整數到64位整數都有。但這裡面沒有布林值,也沒有字串等相對比較有爭議的型別。

因此,在 JavaScript 和 WebAssembly 之間傳遞字串,要靠開闢出的記憶體來進行輔助傳遞。

共享記憶體塊

有 C 程式設計基礎的同學可能這裡會比較容易理解,這個字串的傳遞,其實就是把 JavaScript 中的字串,編碼為 UTF-8 ,然後逐位元組複製到記憶體當中:

// 續index.js
function copyToMemory(textInU8, memory, offset) {
  const byteLength = textInU8.byteLength;

  const view = new Uint8Array(memory.buffer);
  for (let i = 0; i < byteLength; i++) {
    view[i + offset] = textInU8[i];
  }
  return byteLength;
}
複製程式碼

實際在使用記憶體塊時,往往需要更加精細的記憶體管理,以便同一塊記憶體塊可以儘可能地多次使用而又不破壞先前的資料。

上面的memory來自wasmInstance.exports,所以我們的程式碼需要稍微調整一下了:

// 續index.js
const {
  // 注意這裡需要匯出的 memory
  memory,
	crc32: crc32ByWasm,
} = wasmInstance.exports;

const text = "testSource";
const textInU8 = stringToU8(text);
const offset = 10 * 1024;
const byteLength = copyToMemory(textInU8, memory, offset);
crc32ByWasm(offset, byteLength);
複製程式碼

注意crc32ByWasm的第一個引數,這個引數所代表的含義是字串資料在記憶體塊的偏移量。

在進行測試時,我發現記憶體塊的開頭有時會出現其他資料,因此這裡我偏移了 10KB ,以防和這些資料發生衝突。我沒有深究,但我覺得這些資料很有可能是 WebAssembly 機器碼附帶的資料,比如查詢表。

用 Rust 寫一個 crc32

Rust 社群有自己的包管理工具,同時也有自己的依賴託管網站,我在其中找到了crc32這個模組。但如同前面所說,我們希望這次做效能測試的時候,能夠排除演算法實現差異帶來的影響,因此 Rust 版的 crc32 我沒有直接使用它,而是自己從rust-snappy 裡複製出來了一份相似的實現,然後稍微做了些改動。

演算法的實現和 JavaScript 差不多,因此不詳細貼在這裡了,唯獨這個實現的匯出,可能大家會有些不解,因此我下面稍作一些解釋,剩下的大家看文末的原始碼就可以了:

// no_mangle 標記會告知編譯器,crc32 這個函式的名字和引數不要進行改動
// 因為我們要保持這個函式的介面對 WebAssembly 可用
#[no_mangle]
pub extern fn crc32(ptr: *mut u8, length: u32) -> u32 {
  // std::slice::from_raw_parts 對於編譯器來說會產生不可知的後果,這裡需要 unsafe 來去除編譯器的報錯
	unsafe {
		// 將我們傳遞進來的偏移量和長度,轉化為 Rust 當中的陣列型別
		let buf : &[u8] = std::slice::from_raw_parts(ptr, length as usize);
    return crc32_internal(buf);
  }
}
複製程式碼

每一行的含義基本都寫在註釋裡了,這裡面唯一比較難理解概念,大概是unsafe 了。

Rust 這門語言的設計哲學當中包含一項“記憶體安全”。也就是說,使用 Rust 寫出的程式碼 ,都應該不會引發記憶體使用上帶來的問題。Rust 做到這一點,靠的是編譯器的靜態分析,這就要求所有記憶體使用,在編譯時就確定下來。但是在我們的程式碼當中,我們需要使用 WebAssembly 當中的記憶體塊,而記憶體塊的實際情況,是在執行時才真正能夠確定的。

這種矛盾就體現在我們需要 Rust 信任我們傳遞過來的“偏移量”上。因此這段程式碼需要被標記為 unsafe,以便讓編譯器充分地信任我們所寫的程式碼。

Benchmark 與效能調優

好了,現在 WebAssembly 版的程式碼和 JavaScript 版的程式碼都有了,我想看看他們誰跑的更快一些,所以弄了個簡單的 benchmark :

// 續index.js
const suite = new Benchmark.Suite;
const offset = 10 * 1024;
const byteLength = copyToMemory(textInU8, memory, offset);

suite
  .add('crc32ByWasm', function () {
    crc32ByWasm(offset, byteLength);
  })
  .add('crc32ByJs', function () {
    crc32ByJs(textInU8);
  })
  .on('cycle', function (event) {
    console.log(String(event.target));
  })
  .on('complete', function () {
    console.log('Fastest is ' + this.filter('fastest').map('name'));
  })
  .run({
  	'async': true
});
複製程式碼

是騾子是馬拉出來溜溜了!

crc32ByJs x 22,444 ops/sec ±1.20% (83 runs sampled)
crc32ByWasm x 37,590 ops/sec ±0.90% (89 runs sampled)
Fastest is crc32ByWasm
複製程式碼

太好了,效能有67%的提升。現在我們可以證明 WebAssembly 版的 crc32 確實比 JavaScript 版的快了。

但這裡還有一個問題被忽略了,那就是如果我們要使用 WebAssembly 版的 crc32 ,我們就不得不將其複製到 WebAssembly 的記憶體塊中;而如果我們使用 JavaScript 版本的就不必這樣。於是,我又重新做了一次效能測試,這次我在測試中充分考慮了記憶體複製:

crc32ByJs x 21,383 ops/sec ±2.36% (80 runs sampled)
crc32ByWasm x 34,938 ops/sec ±0.86% (84 runs sampled)
crc32ByWasm (copy) x 16,957 ops/sec ±1.74% (79 runs sampled)
Fastest is crc32ByWasm
複製程式碼

可以看出,增加了記憶體複製和編碼之後,WebAssembly 版本的效能跌落非常明顯,和 JavaScript 相比已經沒有優勢了。不過這是 Node.js 當中的情況,瀏覽器中會不會有什麼不同呢?於是我嘗試了一下在瀏覽器中進行測試。

在瀏覽器中嘗試 WebAssembly

除了IE,其他比較先進的瀏覽器都已經支援了 WebAssembly。這裡我就使用 Chrome 67 來進嘗試。

這裡,瀏覽器和 Node.js 環境差別不大,只是字串的編碼,沒有 Buffer 幫我們去做了,我們需要呼叫 API 來進行:

function stringToU8(text) {
  const encoder = new TextEncoder();
  return encoder.encode(text);
}
複製程式碼

Webpack 4雖然已經支援了 WebAssembly ,但為了能夠自定義初始化 WebAssembly 模組,我還是採用了單獨的arraybuffer-loader 來載入 WebAssembly 模組。具體的配置和程式碼可以參考我的原始碼。

測試結果是,JavaScript 版的 crc32 更慢了, JavaScript 版的實現雖然看起來比帶記憶體複製的 WebAssembly 版更快,但優勢不明顯:

crc32ByJs x 10,801 ops/sec ±1.28% (52 runs sampled)
crc32ByWasm x 28,142 ops/sec ±1.13% (51 runs sampled)
crc32ByWasm (copy) x 11,604 ops/sec ±1.16% (54 runs sampled)
Fastest is crc32ByWasm
複製程式碼

考慮到實際在業務中使用時,幾乎總是要進行記憶體複製的,WebAssembly 版本的 crc32 即使在計算上有優勢,也會被記憶體問題給掩蓋,實用性大打折扣。

在某些情況下 Webpack 4 自帶的 uglify 會產出帶有語法錯誤的檔案,因此在實際測試時我關掉了 uglify 。

優化尺寸

執行效能上的對比暫時告一段落了,但我們前端工程師除了關注執行效能外,還關注模組的實際體積。

在 webpack 打包時,我刻意留意了 WebAssembly 相關檔案的打包,結果令人大跌眼鏡:

webpack v4.12.0

6d1b9c1ec10ef7b04017
  size     name  module                                                           status
  489 B    0     (webpack)/buildin/global.js                                      built
  32.4 kB  1     ./fixture/jquery.js.txt                                          built
  879 kB   2     ./target/wasm32-unknown-unknown/release/wasm_crc32_example.wasm  built
  1.8 kB   8     ./crc32.js                                                       built
  497 B    10    (webpack)/buildin/module.js                                      built
  2.25 kB  12    ./browser.js                                                     built

  size     name  asset                                                            status
  1.03 MB  main  app.js                                                           emitted

  Δt 3837ms (7 modules hidden)
複製程式碼

結果中, wasm_crc32_example.wasm 佔據了令人驚訝的 879 kB。而 crc32.js 只佔 1.8 kB

說好的更緊湊的二進位制呢!區區一個 crc32 怎麼會這麼大呢?順著社群的指引,我開始使用 wasm-gc 來嘗試優化體積。

使用之後的情況:

webpack v4.12.0

0f45cfd553d632ac59ce
  size     name  module                                                           status
  489 B    0     (webpack)/buildin/global.js                                      built
  32.4 kB  1     ./fixture/jquery.js.txt                                          built
  313 kB   2     ./target/wasm32-unknown-unknown/release/wasm_crc32_example.wasm  built
  1.8 kB   8     ./crc32.js                                                       built
  497 B    10    (webpack)/buildin/module.js                                      built
  2.25 kB  12    ./browser.js                                                     built

  size     name  asset                                                            status
  459 kB   main  app.js                                                           emitted

  Δt 3376ms (7 modules hidden)
複製程式碼

wasm_crc32_example.wasm 的體積被縮減到了 313 kB。但我還是覺得不夠滿意——我明明也就寫了幾十行程式碼而已。為此我藉助 twiggy 檢查了生成的 wasm 檔案包含什麼:

Shallow Bytes │ Shallow % │ Item
───────────────┼───────────┼──────────────────────────────────────────────────────────────────────────────
        227783 ┊    34.62% ┊ "function names" subsection
        87802 ┊    13.34% ┊ data[0]
        21853 ┊     3.32% ┊ data[1]
          4161 ┊     0.63% ┊ core::num::flt2dec::strategy::dragon::format_shortest::hf5755820aea88984
          3471 ┊     0.53% ┊ core::num::flt2dec::strategy::dragon::format_exact::hc11617164ea3324a
          3466 ┊     0.53% ┊ dlmalloc::dlmalloc::Dlmalloc::malloc::hc22818825fdee93b
          2325 ┊     0.35% ┊ core::num::flt2dec::strategy::grisu::format_shortest_opt::he434a538cbbb5c09
          2247 ┊     0.34% ┊ <std::net::ip::Ipv6Addr as core::fmt::Display>::fmt::hee517e812c10fa59
複製程式碼

注意,分析結果已經過精簡。實際分析結果非常冗長,這裡只擷取了最有助於判斷的部分。

從分析結果裡可以看出,尺寸佔比最大的是函式名。另外,大量我們沒有使用到的函式,也在檔案當中包含了。比如 std::net::ip::Ipv6Addr ,我們根本沒用到。

為什麼會這樣呢?最大的問題在於我們引入了 std 這個 “包裝箱”(英文為 crate,Rust 社群對包的稱呼)。

引入 std 的原因主要在兩方面。

首先,在初始化 crc32 查詢表時,程式碼採用了 lazy_static 這個包裝箱提供的功能,它能夠初始化一些比較複雜的變數——比如我們的查詢表。但其實查詢表是固定的,我完全可以寫成純靜態的。這個 lazy_static 是從 rust-snappy 裡複製的,現在可以我幹掉它,自己在原始碼中直接寫出構造好的查詢表。

其次,我們程式碼裡使用了 std::slice::from_raw_parts 這個來自 std 的方法,來把指標轉換為陣列。對於我這個 Rust 新手來說,這個就有些懵了。為此,我專門在 StackOverflow 上求解了一番,換用 core::slice::from_raw_parts 來進行轉換。

這樣,我們就可以擺脫掉 std 了:

實際擺脫掉 std 需要多做一些其他事情,大家可以在原始碼的src/lib-shrinked.rs檔案中詳細檢視。縮減 Rust 編譯結果的體積是一個比較繁瑣的話題,且根據 Rust 版本不同而不同,具體大家可以參考官方的指南

webpack v4.12.0

8af21121a96f83596bfa
  size     name  module                                                           status
  489 B    0     (webpack)/buildin/global.js                                      built
  32.4 kB  1     ./fixture/jquery.js.txt                                          built
  13.2 kB  2     ./target/wasm32-unknown-unknown/release/wasm_crc32_example.wasm  built
  1.8 kB   8     ./crc32.js                                                       built
  497 B    10    (webpack)/buildin/module.js                                      built
  2.25 kB  12    ./browser.js                                                     built

  size     name  asset                                                            status
  160 kB   main  app.js                                                           emitted

  Δt 3317ms (7 modules hidden)
複製程式碼

不錯,現在 wasm_crc32_example.wasm 只佔 13.2 KB 了。這 13.2 KB 還能不能縮呢?其實還是能縮的,但再縮下去需要犧牲一些效能了。原因是我們靜態的查詢表一共需要 9 * 256 項資料,每項資料佔 4 位元組,因此查詢表本身就佔去了 9 KB。大家可以去 ./target/wasm32-unknown-unknown/release/ 目錄下看看,其實真正 wasm 當中的程式碼實際只有約 1KB ,但由於 webpack 在打包二進位制資料時使用了 base64 編碼,因此整個檔案的尺寸發生了膨脹。

如果還想把查詢表也去掉的話,就必須要在執行時動態生成查詢表,效能必定會有一些犧牲。 JavaScript 版本的 crc32 查詢表就是動態生成的,如果我把它硬編碼出來,它其實也是這麼大。

在我們之前的效能測試中,我們沒有將查詢表的生成時間計入,因此還算公平。

總結

WebAssembly 雖然在計算時效能優異,但其實在實際使用中困難重重,有一些門檻可以跨過,而有一些則需要等待標準進一步演化和解決。下面總結了幾個 Rust + WebAssembly 的坑:

  • WebAssembly 只支援整數和浮點數,其他高階型別需要自己序列化和反序列化,這個過程可能會非常耗時,甚至成為效能瓶頸
  • WebAssembly 的記憶體獨立,除了記憶體複製之外,沒有其他共享 JavaScript 一側記憶體的方案
  • WebAssembly 的記憶體塊是分頁的,一頁記憶體塊64KB,需要處理更多內容時,要麼對內容進行拆分,要麼擴容記憶體塊,這樣程式碼可能會更加複雜
  • Rust 編譯出的 WebAssembly 機器碼通常因為 std 模組的參與而變得體積龐大,替換掉 std 是可能的,但需要花很多心思
    • 如果不加任何處理,編譯出的 WebAssembly 模組有 600KB 多
    • 通過各種策略,我能夠將程式碼縮減到13.2 KB,這裡面有 9KB 是 crc32 演算法所需要的表
    • 排除查詢表所佔體積,實際 WebAssembly 機器碼所佔體積會比 JavaScript 略小,但經過 base64 編碼後會發生膨脹,在我的例子裡和 JavaScript 相比優勢不明顯
  • WebAssembly 機器碼在除錯上目前還無法和 JavaScript 程式碼並肩,除錯比較困難
  • WebAssembly 目前只在部分瀏覽器版本中支援,日常使用仍然需要編寫 JavaScript 版本的程式碼進行降級
  • 儘管 WebAssembly 已經非常接近彙編機器碼,但一些 CPU 高階指令並不在 WebAssembly 當中包含,而這些指令往往對效能有巨大提升
    • 例如 SIMD 、CRC32 等(對,有些 CPU 直接實現了 crc32)

當然,如果這些對你來說都不是問題,那麼 WebAssembly 依然可以一戰。但是就我目前的觀察來看, WebAssembly 離日常開發還有很多路要走,希望它越變越好。

最後附上已經上傳至 Github 的原始碼連結,大家可以在其中探索。如果有錯漏之處,也歡迎開 Issues 給我,多謝了。

後續補遺

在本文成文之後,我和 Rust 社群的大佬們溝通後發現如果在 Rust 中啟用 LTO (連結時優化,一種優化技術),則會在編譯時自動移除大量 std 的內容,從而使最終的 wasm 檔案體積顯著減小。

根據測算,如果不手動移除 std 依賴,生成的 wasm 檔案大約 30KB ;手動移除後,是否啟用 LTO 沒有明顯變化。

未來在 Rust 編譯 WebAssembly 檔案時啟用 LLD (LLVM提供的連結器)之後, wasm 檔案體積會自動變小,不再需要大家操心。

相關文章