wasi-threadsを使用したVRT用のgithub actionsをリリースしました
この記事は、WebAssembly Advent Calendar 2024 の 11 日目の記事です。
wasi-threads
の実例として’紹介させていただければと思います。
以前Visual Regression Test をサポートする reg-actions をリリースしたという記事を書きました。
今回はreg-actions
の画像比較・レポート作成部分をRust
で書き直しwebp
のサポートとwasi-threads
対応を行ったためその過程をまとめ、共有します。
コードはまだmainに合流できていないところもありますが、以下にあります。
reg-actions
概要
reg-actions
は以下のような設定を行うことで、プルリクエスト毎に指定ディレクトリの画像の差分を検出し、結果をコメントにて通知するgithub actions
です。
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: reg-viz/reg-actions@v3
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
image-directory-path: "./images"
今回のスコープ
reg-actions
はreg-cli
をwrap
したものです。
reg-cli
はflowtype
を使用しており、その他のスタックや仕組みも古いものとなっており、その刷新を最優先とし、その過程でのwebp
対応とprocess
をfork
していた箇所のwasi-threads
への置き換えをもう一つの目標としました。
また、dependencies
が古くても動くが一番の懸念はセキュリティです。wasm
を使用することで攻撃しにくくする意図もありました。こういった観点からどのような変化があったかはまた別の記事にまとめたい思いがあります。
対応内容
pixelmatch
の移植
まずは画像の比較部分の移植を行いました。具体的にはreg-cli
が依存しているmapbox/pixelmatch
というライブラリの移植です。
このライブラリはrgba
の byte 列を受け取って pixel 毎の diff をとるライブラリで移植自体は難しくありませんでした。
以下のように利用でき、pixel 単位での diff を算出できます。
fn main() {
let img1 = image::open("./examples/4a.png").unwrap();
let img2 = image::open("./examples/4b.png").unwrap();
let result = pixelmatch(
&img1.to_rgba(),
&img2.to_rgba(),
img1.dimensions(),
Some(PixelmatchOption {
threshold: 0.1,
include_anti_alias: true,
..PixelmatchOption::default()
}),
)
.unwrap();
}
image-diff-js
の移植
次にpixelmatch
を wrap した、画像のdecoding
やサイズ調整を行っているimage-diff-js
の移植を行いました。
基本的にはimage-rs
が多くの種類の画像に対応しているので基本image-rs
に任せれば OK なのですが、この作業の開始時にはimage-rs
がwebp
に対応していなかったため、自身でlibwebp
を併用しました。(現時点では制限はありますが、webp
をサポートしています。)
内容は古くなっていることに注意が必要ですが、当時の試行錯誤は以下に記載しています。
これにより以下のように画像のdecode
と比較が行えるようになりました。
あとはこれをreg-cli
から使えるようにすれば良さそうです。
fn main() {
let img1 = std::fs::read(actual_dir.join(path))?;
let img2 = std::fs::read(expected_dir.join(path))?;
let res = image_diff_rs::diff(
img1,
img2,
&DiffOption::default()
).unwrap();
}
reg-cli
の移植
上記で画像のdecode
や比較はできるようになったのでreg-cli
の移植に取り掛かることにしました。
reg-cli
は 1000 枚以上の画像を比較するユースケースを想定してため、process pool
を用意し、それらが順次画像を読んで比較していく構成となっています。
開発時にはまだworker thread
がなかったこともあり、以下のようなイメージでfork
し比較処理を行っています。
import { fork } from 'child_process';
import path from 'path';
import type { DiffCreatorParams, DiffResult } from './diff';
const processes = range(concurrency).map(() => fork(path.resolve(__dirname, './diff.js')));
return bluebird
.map(
images,
image => {
return p.run({
...dirs,
image,
matchingThreshold,
thresholdRate,
thresholdPixel,
enableAntialias,
});
},
{ concurrency },
);
これをwasi-threads
とrayon
に置き換えられないかと考えたのがこの移植を開始したきっかけです。結果的に以下のように置き換えられました。
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(options.concurrency.unwrap_or_else(|| 4))
.build()
.unwrap();
let result = pool
.install(|| {
targets.par_iter().map(|path| {
let img1 = std::fs::read(actual_dir.join(path))?;
let img2 = std::fs::read(expected_dir.join(path))?;
let res = image_diff_rs::diff(
img1,
img2,
&DiffOption::default(),
)?;
Ok((path.clone(), res))
})
})
.collect::<Result<Vec<(PathBuf, DiffOutput)>, CompareError>>()?;
wasi-threads
Rust
では以下のようにtarget
をwasm32-wasip1-threads
を指定してbuild
することでwasi-threads
が使用できます。
cargo build --release --target=wasm32-wasip1-threads
その際、thread
をspawn
している箇所がwasi-sdk
を介してwasi.thread-spawn
が呼ばれるようになります。これをNode.js
側で用意してやる方針で進めました。
簡略化するとmain
側では以下のようにthread-spawn
の呼び出しでWorker
を作成し、引数やMemory
などを送信します。Memory
はshared: true
とするのを忘れないようにしてください。
let nextId = 1;
const opts = { initial: 17, maximum: 20, shared: true };
const memory = new WebAssembly.Memory(opts);
let instance = await WebAssembly.instantiate(wasm, {
...new WASI().getImportObject(),
wasi: {
"thread-spawn": (startArg) => {
const worker = new Worker("./worker.js");
const tid = nextTid++;
worker.postMessage({ startArg, tid, memory });
return tid;
},
},
env: { memory },
});
Worker
側はメッセージをうけてwasm
を受け取ったMemory
とともにinstantiate
し、wasi_thread_start
にthreadId
とthread-spawn
で受け取った値を渡し、処理を開始します。
const handler = async ({ startArg, tid, memory }) => {
const wasm = await WebAssembly.compile(await file);
let instance = await WebAssembly.instantiate(wasm, { ... });
instance.exports.wasi_thread_start(tid, startArg);
};
parentPort.addListener("message", handler);
これが基本的な流れになりそうです。ただ、いくつか注意点もあり、それらはwasm32-wasip1-threadsでrayonを使ったコードをNode.jsで動かす
にも記載いたしました。もし興味があれば覗いて見てください。
rayon
ここまで準備ができるとNode.js
でもrayon
が使用できます。SiteIsolation
が必要となるもののbrowser
でも動きそうに見えます。これは個人的には悲願です。
前述したように以下のようにpar_iter()
を用いてfork
していた箇所を置き換えました。
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(options.concurrency.unwrap_or_else(|| 4))
.build()
.unwrap();
let result = pool
.install(|| {
targets.par_iter().map(|path| {
let img1 = std::fs::read(actual_dir.join(path))?;
let img2 = std::fs::read(expected_dir.join(path))?;
let res = image_diff_rs::diff(
img1,
img2,
&DiffOption::default(),
)?;
Ok((path.clone(), res))
})
})
.collect::<Result<Vec<(PathBuf, DiffOutput)>, CompareError>>()?;
ここまででコアな部分は完了になります。
あとはglob
で対象となるファイルを読んだりレポートを作成したりを粛々とrust
に移植しました。特に特筆する点もなさそうなので詳細は割愛します。
作業中ですがコードは以下です。
build
wasm32-wasip1-threads
をtarget
に、と前述しましたが、今回はlibwebp
を使用していることもあり、それだけではだめでwasi-sdk
を使用するようにしました。
これでbuild
ができるようになります。
export WASI_VERSION_FULL=24.0
export WASI_SDK_PATH=`pwd`/wasi-sdk-${WASI_VERSION_FULL}
CFLAGS="--sysroot ${WASI_SDK_PATH}/share/wasi-sysroot" cargo build --release --target=wasm32-wasip1-threads
これによりwasm
が生成されるのでこれをJS
から読んで結果を受け取れれば良さそうです。
wasm
- JS
前述したようにJS
からwasm
を実行し、結果を受け取る必要があるのですが、それは以下のように細工しました。
具体的にはmain
は空にしておきつつ、wasm_main
をJS
から呼ばせ、その返り値のpointer
から結果のJSON
を取得する案です。
#[cfg(all(target_os = "wasi", target_env = "p1"))]
pub fn main() {
// NOP
}
#[cfg(all(target_os = "wasi", target_env = "p1"))]
#[repr(C)]
pub struct WasmOutput {
pub len: usize,
pub buf: *mut u8,
}
#[cfg(all(target_os = "wasi", target_env = "p1"))]
#[no_mangle]
pub extern "C" fn wasm_main() -> *mut WasmOutput {
let res = inner().unwrap();
let mut s = serde_json::to_string_pretty(&res).unwrap();
let len = s.len();
let ptr = s.as_mut_ptr();
std::mem::forget(s);
let output = Box::new(WasmOutput { len, buf: ptr });
Box::into_raw(output)
}
その案においてJS
側は以下のようになりました。
wasi.start
はmain
が空とはいえ初期化のために呼び出す必要があります。
あとはpointer
からbuffer
へのpointer
とlength
を取得し、それをJSON
に変換することで結果をJS
側で受け取っています。そのために#[repr(C)]
をWasmOutput
につけています。
wasi.start(instance);
const res = instance.exports.wasm_main();
const view = new DataView(memory.buffer, res);
const len = view.getUint32(0, true);
const bufPtr = view.getUint32(4, true);
const stringData = new Uint8Array(memory.buffer, bufPtr, len);
const decoder = new TextDecoder('utf-8');
const string = decoder.decode(stringData);
instance.exports.free_wasm_output(m);
const report = JSON.parse(string);
このあたりのコードは以下です。
あとはこれらをreg-actions
から呼び出せば完了です。
まとめ
駆け足でしたが、古いJavaScript
のコードをlibwebp
やwasi-threads
を利用しつつRust
とwasm
に移植した実例を紹介させていただきました。参考になれば幸いです。
余談ですが、wasi-threads
はComponent Model
に対応できていません。その対応としてwasi-threads
のフィードバックを得てShared-Everything Threads Proposal
というProposal
が検討されている認識です。
なので、そのあたりが固まったら、将来的にはwasi-threads
からShared-Everything Threads
への変更を行う可能性がありそうです。その際はまた記事にできればと思います。
そのあたりは以下に書いたので合わせて御覧ください。
また、セキュリティやパフォーマンスの観点からどう変わったかは別途まとめて記事にしたいなと考えています。
Discussion