Marginalia

Rust+wasmでSVGを生成したら10倍速くなった

https://contrib.rocks はGitHubのAPIから取得したコントリビューター情報からSVG画像を生成している。これまでは SVG.js を使ったTypeScriptでの実装だったが、興味本位でRustで実装したものをWebAssembly(wasm)として実行するようにしたところ、パフォーマンスが顕著に向上したためそのまま採用することにした。

Rustもwasmもまともに触ったのは今回がはじめてだったため、実装には洗練する余地が多分にあるだろうが、この記事ではとりあえず作業の記録を書き残す。

NxワークスペースにRustをセットアップする

まずはじめに、Nxのワークスペース内でRustの開発環境を整えた。Cargoにもワークスペース機能があり、複数のプロジェクトの依存関係解決を集約できる。

ドキュメントに従い、ワークスペースのルートディレクトリに Cargo.toml を作成して members を設定した。そしてNxワークスペースに libs/renderer-rust プロジェクトを作成し、プロジェクトルートにも Cargo.toml を作成した。

# Cargo.toml
[workspace]
members = [
  "libs/renderer-rust"
]
# libs/renderer-rust/Cargo.toml
[package]
edition = "2021"
name = "renderer"
version = "0.1.0"

その後、パッケージのエントリポイントとなる libs/renderer-rust/src/lib.rs を作成し、最低限の環境が整った。

Nxの implicitDependenciesCargo.toml を追加

Cargo.toml に変更があればNxのキャッシュを破棄するように nx.jsonimplicitDependencies を変更した。

{
	"implicitDependencies": {
    "angular.json": "*",
    "package.json": "*",
    "tsconfig.json": "*",
    "tslint.json": "*",
    ".eslintrc.json": "*",
    "nx.json": "*",
    "Cargo.toml": "*"
  }
}

各種タスクの定義

Rustプロジェクトへの自動テストやLintなどのタスクをNxで実行できるターゲットとして定義した。今回は @nrwl/workspace:run-commands でシェルコマンドを実行した。 cargo コマンドは実行したカレントディレクトリを起点にして実行する思想なようで、 tsc--project のようなスコーピングの仕組みが見当たらなかったため、 cwd オプションでカレントディレクトリを変更した。

  "renderer-rust": {
    "root": "libs/renderer-rust",
    "sourceRoot": "libs/renderer-rust/src",
    "projectType": "library",
    "architect": {
      "lint": {
        "builder": "@nrwl/workspace:run-commands",
        "options": {
          "command": "cargo clippy",
          "cwd": "libs/renderer-rust"
        }
      },
      "test": {
        "builder": "@nrwl/workspace:run-commands",
        "options": {
          "command": "cargo test",
          "cwd": "libs/renderer-rust"
        }
      }
    }
  },

wasm-packを使ったビルド

今回はRustプロジェクトをNode.jsのプロジェクトから読み込めるwasmパッケージにビルドしたいため、wasm-packを利用した。

wasm-packbuild コマンドは --out-dir オプションで出力先ディレクトリを変更できるが、この引数は常にパッケージルートからの相対パスで解決されるようだったため、他のプロジェクトと同じようにワークスペースルートの dist ディレクトリ内へ出力させるために一工夫した。


  "build": {
    "builder": "@nrwl/workspace:run-commands",
    "outputs": ["dist/libs/renderer-rust"],
    "options": {
      "command": "wasm-pack build --target nodejs --out-dir ../../dist/libs/renderer-rust",
      "cwd": "libs/renderer-rust"
    },
    "configurations": {
      "production": {}
    }
  }

Rust実装

今回Rustで実装した関数は、TypeScriptでは次のようなシグニチャである。コントリビューターオブジェクトの配列を受け取り、SVG文字列を返すことを意味している。

type Renderer = (contributors: Contributor[]) => Promise<string>;

このシグニチャはそのままで、SVG文字列を生成する内部実装だけをwasmで置換することを目標とした。

JSから配列を受け取るRust関数

コントリビューターオブジェクトの配列をRustの関数へ渡すには、Box<[JsValue]> 型の引数を定義する(この情報にたどり着くのに少し苦労した)。 JsValuewasm-bindgen が提供する型で、JS側から受け取れる任意のオブジェクトに対応する型のようで、このままでは使えない。

Rustでも Contributor 構造体を定義しておいて、 JsValue 型から Contributor 型に変換するには into_serde() メソッドを使った。

詳細はよくわかっていないが、 wasm-bindgenserde-serialize 機能を有効にすると、 serde という別のライブラリを使ったJSONと構造体の間のシリアライズ・デシリアライズができるようになるようだ。ドキュメントに従い、次のように Contributor 構造体に必要なtraitを実装した。

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug)]
pub struct Contributor {
    pub avatar_url: String,
    pub contributions: i32,
    pub html_url: String,
    pub id: i32,
    pub login: String,
}

これで Box<[JsValue]>[Contributor] に変換できるようになった。

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn render(contributors: Box<[JsValue]>) -> String {
    let parsed = contributors
            .iter()
            .map(|item| item.into_serde().unwrap())
            .collect::<Vec<Contributor>>()
            .as_slice();
		//...
}

SVG文字列の生成

SVGの組み立ては svg ライブラリを利用した。

特に癖のないAPIだったためSVG自体の知識があれば難なく使えた。逆にいままで SVG.js が暗黙的に設定していた属性に依存していたことがわかり、その調査に手間取ることになった。

パフォーマンスのベンチマーク

同じ入力から同等の出力が得られるようになったところで、従来の実装と新しいRust+wasm実装でSVG生成にかかる時間を計測し、パフォーマンスを比較してみた。

計測は同じ引数での関数呼び出しを十分な回数繰り返し、合計時間を回数で割った平均時間を比較することとした。入力の配列の長さによる傾向も知りたかったので、データサイズごとに計測した。

async function benchmark_js(times: number, contributors: Contributor[]) {
  performance.mark('js:start');
  for (let index = 0; index < times; index++) {
    await renderer_js(contributors);
  }
  performance.mark('js:stop');
  performance.measure('js', 'js:start', 'js:stop');
}

async function benchmark_rust(times: number, contributors: Contributor[]) {
  performance.mark('rust:start');
  for (let index = 0; index < times; index++) {
    await renderer_rust(contributors);
  }
  performance.mark('rust:stop');
  performance.measure('rust', 'rust:start', 'rust:stop');
}

async function main() {
  console.log('start benchmark');
  const times = 1000;

  async function run(dataSize: number) {
    console.log(`=== data size: ${dataSize} ===`);
    const contributors = new Array(dataSize).fill(null).map((_, i) => createMockContributor({ login: `login${i}` }));
    await benchmark_js(times, contributors);
    await benchmark_rust(times, contributors);
    for (const measure of performance.getEntriesByType('measure')) {
      console.log(`${measure.name}: ${(measure.duration / times).toPrecision(2)}ms`);
    }
    performance.clearMarks();
    performance.clearMeasures();
  }

  await run(1);
  await run(2);
  await run(5);
  await run(10);
  await run(20);
  await run(50);
  await run(100);
}

main();

その結果、ざっくり比較してRust+wasm実装のほうがJS実装よりも10倍近く速いことがわかった。

start benchmark
=== data size: 1 ===
js: 0.30ms
rust: 0.017ms
=== data size: 2 ===
js: 0.30ms
rust: 0.023ms
=== data size: 5 ===
js: 0.61ms
rust: 0.052ms
=== data size: 10 ===
js: 1.1ms
rust: 0.094ms
=== data size: 20 ===
js: 2.1ms
rust: 0.18ms
=== data size: 50 ===
js: 5.2ms
rust: 0.48ms
=== data size: 100 ===
js: 10ms
rust: 0.89ms

実際のユースケースでは100件のデータで生成することはよくあるため、1回あたり10msかかる処理が1ms以下になるとなればなかなかの恩恵といえる。

wasmパッケージをアプリケーションから読み込む

Nxワークスペースで管理する別のアプリケーションから wasm-pack でビルドしたパッケージを読み込むにあたって工夫が必要なことがあった。

wasm-pack のNode.js向けビルドは内部で __dirname を使って隣接する .wasm ファイルを読み込むようになっているため、webpackなどでバンドルするアプリケーションから読み込むときにファイルが見つからなくなる。この問題は @nrwl/node:build (最新では @nrwl/node:webpack ) の assets オプションでアプリケーションの出力ディレクトリへ .wasm ファイルをコピーして解決した。

"assets": [
  {
    "input": "dist/libs/renderer-rust",
    "glob": "*.wasm",
    "output": "."
  }
]

これは wasm-pack のビルドターゲットを bundle にすればいいものだが、webpack v5ではデフォルトでWebAssemblyモジュール機能が無効になっている。実験的フラグを有効にするためにwebpackコンフィグに手を入れる必要があるため、今回は上記の解決方法でよしとした。

GitHub ActionsでのCI

ビルドやテスト、デプロイはGitHub Actionsで実行しているため、各ジョブでNode.jsに加えてRustの環境を整える必要がある。今回ははじめてローカルアクションの機能を使ってみた。共有のステップ群を簡単に再利用可能なアクションとしてまとめるものだ。

.github/actions/setup-rust/action.yml を次のように作成し、CI/CDの各ワークフローから uses: ./.github/actions/setup-rust のように呼び出した。Compositeローカルアクションへの切り出しは非常にシンプルで使いやすかった。

name: Setup Rust

runs:
  using: "composite"
  steps:
    - uses: actions-rs/toolchain@v1
      with:
        toolchain: stable
        override: true
        profile: minimal
        components: clippy, rustfmt
    - run: cargo install wasm-pack
      shell: bash

まとめ

Rustの学習を兼ねて思いつきでやってみたが、思っていた以上の効果が得られて驚いた。JSからRust+wasmへの置き換えで高速化するという話はいろんな場所で聞いていたが、実際に自分の小さなアプリケーションでも再現したのはいい経験になった。

今回はSVG生成部分だけを置き換えたが、他の処理も今後置き換えを進めてみたい。