wasi-threadsの概要とComponentModelでの利用の現状とその先
2024-01-22
JS
で書かれたツールをRust
で書き直し、Wasm
で提供する。ということを考える中でwasi-threads
をベースにrayon
を使用したComponent
を作りたい。という思いがでてきたのだが、それが可能なのか、もし可能であればどうやるのかが分からなかったため調査した。
上記をwasmtime
とNode.js
で使用できることを目標とする。
WARNING
この記事は古くなっており、動作しないサンプルを含んでいます。 wasm32-wasip1-threadsでrayonを使ったコードをNode.jsで動かすを参照してください。
目次
TL;DR
wasi-threads
をベースにrayon
の使用は実現できそうNode.js
におけるwasi
対応はまだまだexperimental
で、自分でshim
を用意する必要がありそうwasi-threads
を使用したwasm
をComponent
化するのは現状難しそう- 将来的には
shared-everything-threads
によりthread
周りがbuilt-in
になりComponent Model
へフィードバックされそう
wasi-threads
について
proposal
を見る
wasi-threads
は以下に記載がある。現在はphase1
のようだ。
Wasm
にthreads
は存在するが、shared memory
,atomic
,wait/notify
について定義されいるだけでspawn
については定義されていない。wasi-threads
はこのspawn
を定義しようというものだ。
wit
を見ればわかるが、interface
としては以下のみのシンプルなものとなっている。
thread-spawn: func(
/// A value being passed to a start function (`wasi_thread_start()`).
start-arg: start-arg,
) -> thread-spawn-result
ゴールとしてはpthreads
のサポートだが、full POSIX compatibity
を目指すものでもないと書いてある。 次は簡単なサンプルを動かしてみる。
minimum
なサンプルを動かしてみる
Rust
で以下のようなコードを用意し、wasm32-wasi-preview1-threads
を target に build する。
use std::thread;
fn main() {
let mut x = 10;
thread::scope(|s| {
s.spawn(|| {
x += 20;
});
});
println!("{x}");
}
以下で動く。(WASMTIME_NEW_CLI
はcli
のinterface
が変わるようでひとまず 0 としている)
❯ WASMTIME_NEW_CLI=0 wasmtime run wasi-threads.wasm --wasm-features=threads --wasi-modules=experimental-wasi-threads
30
wasmtime
では簡単に動くがNode.js
だと自分でthread-spawn
を用意する必要がありそうでこれは、後述する。
wasmtime
でサンプルがどのように動いているのか
上記サンプルがどのように動いているかはspec
とwasmtime
のwasi-threads
実装を見るのがよい。
細かい点を省略・簡略化して要点だけ抜き出すと以下だ。
pub fn spawn(&self, host: T, thread_start_arg: i32) -> Result<i32> {
let instance_pre = self.instance_pre.clone();
// ...omitted
let wasi_thread_id = random_thread_id();
let builder = thread::Builder::new() // ...omitted
builder.spawn(move || {
let result = catch_unwind(AssertUnwindSafe(|| {
let mut store = Store::new(&instance_pre.module().engine(), host);
let instance = instance_pre.instantiate(&mut store).unwrap();
let thread_entry_point = instance
.get_typed_func::<(i32, i32), ()>(&mut store, "wasi_thread_start")
.unwrap();
match thread_entry_point.call(&mut store, (wasi_thread_id, thread_start_arg)) {
// ...omitted
}
}));
})
}
大枠は以下の手順となっている。
host
側でthread
をspawn
する
Store
からwasm instance
を取り出す
- 2 で取り出した
instance
からexport
されているwasi_thread_start
関数を取り出す
- 2 で取り出した
- 3 で得られた
entrypoint
にスレッドの ID とthread_start_arg
を渡す
- 3 で得られた
thread_start_arg
はwit
を見返せばわかるがthread-spawn
の引数として渡される、thread
内で実行すべきwasm
内の関数へのポインタと引数だ。
順番は前後するがwasi-threads
のREADME
にもhost
が何をすべきかは記載されている。
Upon a call to wasi_thread_spawn, the WASI host must:
1. instantiate the module again — this child instance will be used for the new thread
2. in the child instance, import all of the same WebAssembly objects, including the above mentioned shared memories, as the parent
3. optionally, spawn a new host-level thread (other spawning mechanisms are possible)
4. calculate a positive, non-duplicate thread ID, tid, and return it to the caller; any error in the previous steps is indicated by returning a negative error code.
5. in the new thread, call the child instance's exported entry function with the thread ID and the start argument: wasi_thread_start(tid, start_arg)
大体齟齬はないように見える。wat
側も軽く眺めたがwasi_thread_start
が呼ばれるとatomic
でthread
の起動を待ち合わせ、start_arg
から呼ぶべき関数を特定し、call_indirect
で呼び出しているようだった。
wasm
におけるthread
、あまりイメージが湧いていなかったが、今回で解像度が上がった。
Node.js
でサンプルを動かす PoC
wasmtime
での動かし方やどう動いているかは理解した。次はNode.js
で動かすことを考えてみる。
Node.js
ではworker threads
を使用することになると思うのだが、その際wasm instance
を共有できないため、このspec
は実現が難しいのでは?と思っていたのだが、Twitter(X)
でいろいろアドバイスをいただき、単にmemory
だけ共有しつつworker
側でinstantiate
し直せばいいのでは。という結論にひとまず至った。
確かにhost
がすべきことの最初のステップがinstantiate the module again
と書いてある。これが同じ挙動を指しているのかは分からないが。
とりあえず作ってみたPoC
が以下だ。
全文は上記なのだが、少し分解し記載する。
main
側
以下がwasm
からみてmain thread
側。 wasm
をinstantiate
するところまで難しいところはないがmemory
がshared
である点に注意。
const { Worker } = require("node:worker_threads");
const { readFile } = require("node:fs/promises");
const { WASI } = require("wasi");
const { argv, env } = require("node:process");
const { join } = require("node:path");
const wasi = new WASI({ version: "preview1", args: argv, env });
const file = readFile(join(__dirname, "wasi-threads.wasm"));
(async () => {
const wasm = await WebAssembly.compile(await file);
const opts = { initial: 17, maximum: 17, shared: true };
const memory = new WebAssembly.Memory(opts);
const instance = await WebAssembly.instantiate(wasm, {
...wasi.getImportObject(),
wasi: {
"thread-spawn": (start_arg) => {
const worker = new Worker(WORKER_FILE, { workerData: { memory } });
worker.postMessage(start_arg);
},
},
env: { memory },
});
wasi.start(instance);
})();
instantiate
時にはwasi-threads
を使用するために不足しているものとしてwasi.thread-spawn
を自分で差し込む必要がある。 これによりRust(wasm)
側でspawn
が呼ばれとこの関数が呼び出される。 まずはnew Worker
でworker
を作成し、memory
を共有しておく。
その後postMessage
でstart_arg
を共有する。このstart_arg
は前述したように呼び出すべき関数の情報と引数を持ったデータ構造へのポインタだ。すなわちリニアメモリのindex
となる。
wasi: {
"thread-spawn": (start_arg) => {
const worker = new Worker(WORKER_FILE, { workerData: { memory } });
worker.postMessage(start_arg);
},
},
worker
側
対してworker
側は以下のようになる。 ほぼ同じ処理なのだが、wasi_thread_start
を読んでいるのがポイントだ。
main
側からmessage
を受信するとinstantiate
し、そこから取り出したwasi_thread_start
を読んでいる。 これはwasi-threads
のspec
により定められた関数でwasm
からexport
されることになっている。
引数はthread_id
とstart_arg
だ。thread_id
はhost
のthread_id
を送る必要はないようだ。wasmtime
でもランダムなid
を採番していた。
const { Worker, workerData, parentPort } = require("node:worker_threads");
const { readFile } = require("node:fs/promises");
const { WASI } = require("wasi");
const { argv, env } = require("node:process");
const { join } = require("node:path");
const wasi = new WASI({ version: "preview1", args: argv, env });
const file = readFile(join(__dirname, "wasi-threads.wasm"));
parentPort.on("message", async (start_arg) => {
const wasm = await WebAssembly.compile(await file);
const { memory } = workerData;
const instance = await WebAssembly.instantiate(wasm, {
...wasi.getImportObject(),
wasi: {
"thread-spawn": (arg) => {
const worker = new Worker(WORKER_FILE, { workerData: { memory } });
worker.postMessage(arg);
},
},
env: { memory },
});
// thread id and start_arg
instance.exports.wasi_thread_start(1, start_arg);
process.exit(0);
});
本番で動かすにはまだま考慮事項がありそうだが、ひとまずサンプルは動いた。 rayon
も試しに使用してみたがこちらも動いてそうだ。
また、X
で教えてもらったこちらも参考になりそうなので後でコードを読んでみたい。ざっと眺めた感じ、似たようなことをやっているようには見える。
wasi-threads
とComponent Model
について
ここまででwasi-threads
の概要とwasmtime
,Node.js
での動作について確認できたのでComponent Model
についての調査を記載しいていく。 結論からいうと、現状今回作成したwasm
をComponent
化するのは難しそうだ。
具体的には以下のコマンドでエラーが発生する。
wasm-tools component new wasi-threads.wasm -o component.wasm --adapt ./wasi_snapshot_preview1.wasm
error: failed to encode a component from module
Caused by:
0: module is only allowed to import functions
(base)
これはwasm
が(import "env" "memory" (memory (;0;) 17 17 shared))
を要求しており、かつ、Component Model
としてはfunction
しかimport
できないため。つまりはwasm-tools
のバグや未実装などではなく、仕様レベルで検討が必要な話となる。
この点に関しては質問したのだが、以下の回答をもらっており、つまりはshared-everything-threads
というproposal
で議論されていく。とのことだ。
The current plan is to extend threading support in core wasm through the shared-everything-threads proposal. The meat of this proposal is adding shared attributes to everything (not just memory), allowing whole instances to be shared (avoiding the O(n^2) function table usage with previous approaches) as well as a new solution for thread-local storage. There's also possibly a thread.spawn instruction for actually creating a new thread, but it's contentious (since it's a lot more work for browsers) so it's possible instead that we'll need to add thread.spawn as a canonical built-in to the Component Model (which would be importable by core wasm with the shared func type added by the shared-everything-threads proposal).
proposal
は上記。まだ生煮えではありそうで、理解は今後の課題とするが、軽く眺めた感じ以下のようなことに言及しているように見える。
wasm threads
ではatomic
やnotify
などを定義したがspawn
などは定義しなかった- これにより
wasi-threads
などが提案されたが、これはwasm
側の仕様として検討すべき - 現在やテーブルが共有できないことによるオーバーヘッドや参照がスレッド間で共有できないことにより
GC
言語間でのthread
運用に問題がある - そのため、たとえば
pthreads
に特化しない、あらゆる言語を考慮したthread
を定義する。 thread.spawn
,thread.hw_concurrency
などをbuilt-in
で用意し、Component Model
に修正を提案するshared
というannotation
を用意し、共有状態を静的に示す
つまりはComponent Model
でwasi-threads
は利用できないし、そもそもthreads
周りはまだまだ改良が入りそうで、その結果はComponent Model
にフィードバックされていきそう。と解釈した。
まとめ
いまいちぼんやりしたイメージだったwasi-threads
の概要がこれで把握できた。また、host
やwasm
がどのような動きをしているかの動作イメージの解像度も上がったように思う。
と、同時に現状のspec
では問題や実現できないことがありそうで、まだまだ改良が入りComponent Model
にもフィードバックが取り込まれていきそうという情報もキャッチアップできた。
次はshared-everything-threads
の動向を見ながら理解を進めていきたい。
以上。