WASI 0.3はHTTPをどこまで消せる?Native Rust・Wasm Component・Unix Socket・localhost HTTPを100万回ベンチマークする
前回の記事では、WASI 0.3によってWebAssembly Component同士を非同期に合成し、これまでHTTP越しだったサービス境界を同一プロセス内へ持ち込める可能性を見た。
では、本当に速いのか。
「HTTPを使わなければ速い」。
それだけなら測るまでもない。
今回知りたいのは、もっと細かい。
- 普通のRust関数呼び出しは何nsなのか
- WebAssembly Component境界を越えると何ns増えるのか
- Canonical ABIそのものにはどの程度のコストがあるのか
- Unix Domain Socketまで離すと何倍になるのか
- localhost HTTPではどこまで増えるのか
- HTTPの遅さはTCPなのか、HTTP parserなのか、serializationなのか
- Wasmtimeのコンパイル時間と実行時間を混ぜると何を見誤るのか
- 平均値では見えないp95・p99はどう変わるのか
- syscallとcontext switchは実際にどれだけ発生するのか
そこで今回は、同じ「入力値に1を足して返すだけ」の処理を4種類の境界で実行する。
Native Rust function
vs
WebAssembly Component
Canonical ABI
vs
Unix Domain Socket
vs
localhost HTTP/1.1
Keep-Alive
そして100万回単位で呼び出し、レイテンシだけでなくCPU、syscall、context switchまで観測する。
最初に重要なこと:今回はWASI 0.3 asyncそのものを測らない
前回の記事はWASI 0.3のNative Asyncがテーマだった。
なら今回もasync funcで測るべきではないか、と思うかもしれない。
あえてやらない。
理由は、最初に測るべきなのがComponent境界そのもののコストだからだ。
async scheduling
future
stream
HTTP
network
serialization
runtime
全部を一度に測る
↓
何が遅いのか分からない
今回は最小の型を使う。
ping: func(value: u64) -> u64
処理内容も全方式で同じ。
value + 1
これなら処理本体がほぼゼロなので、境界のコストが見えやすくなる。
今回測る4つの境界
1. Native Rust
Rust
↓
Rust function
比較基準。
プロセスもABIもネットワークも越えない。
2. WebAssembly Component
Rust Host
↓
Wasmtime
↓
Component Function
↓
Canonical ABI
↓
Core Wasm
同一プロセスだが、WebAssembly Componentの型境界を越える。
今回最も知りたい値である。
3. Unix Domain Socket
Client Process
↓
Unix Socket
↓
Kernel
↓
Server Thread
HTTPもTCP/IPも使わないが、OSのI/O境界を越える。
4. localhost HTTP
Client
↓
HTTP serialization
↓
TCP socket
↓
Kernel TCP/IP
↓
Server
↓
HTTP parse
マイクロサービス的な構成に近い。
ただし今回は接続確立時間の影響を除くため、HTTP/1.1 Keep-AliveでTCP接続を使い回す。
HTTPで毎回connectする比較は別枠にする
localhost HTTPを測るときに毎回TCP接続すると、測定値の大部分が接続確立コストになる。
しかし実際のHTTPクライアントはconnection poolを使う。
そこで本比較では、
HTTP Keep-Alive
TCP connection
↓
request
request
request
request
...
を基本とする。
毎回connectするケースは「HTTP fresh connection」として別途測る。
ベンチマークの構成
wasi03-boundary-bench/
├── Cargo.toml
└── src/
└── main.rs
今回はできるだけ外部ライブラリを減らす。
HTTPサーバーまでRust標準ライブラリで実装する。
フレームワーク性能を測りたいのではなく、境界の違いを見たいからだ。
プロジェクトを作る
cargo new wasi03-boundary-bench
cd wasi03-boundary-bench
Cargo.tomlを次のようにする。
[package]
name = "wasi03-boundary-bench"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1"
hdrhistogram = "7"
wasmtime = { version = "48", features = ["component-model"] }
WasmtimeはComponent Model APIを利用する。
ComponentをWATで直接作る理由
今回はRust Guestをコンパイルせず、Component Text Formatを直接使う。
これにも理由がある。
Rust標準ライブラリ、wit-bindgen、Guest側allocatorなどを最初から混ぜると、どこで時間を使ったか分からなくなる。
測定対象を、
Host
↓
Component Function
↓
Canonical Lift
↓
Core Wasm Function
まで削る。
Component内部は次の構造になる。
(component
Core Wasm module
ping(i64) -> i64
↓
canon lift
↓
Component function
ping(u64) -> u64
)
完全なmain.rs
src/main.rsをすべて次の内容へ置き換える。
use anyhow::{Context, Result};
use hdrhistogram::Histogram;
use std::fs;
use std::hint::black_box;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::Path;
use std::thread;
use std::time::{Duration, Instant};
use wasmtime::component::{Component, Linker, TypedFunc};
use wasmtime::{Engine, Store};
const TOTAL_CALLS: u64 = 1_000_000;
const WARMUP_CALLS: u64 = 50_000;
const BATCH_SIZE: u64 = 100;
const SOCKET_PATH: &str = "/tmp/wasi03-boundary-bench.sock";
const COMPONENT_WAT: &str = r#"
(component
(core module $m
(func (export "ping")
(param i64)
(result i64)
local.get 0
i64.const 1
i64.add
)
)
(core instance $i
(instantiate $m)
)
(func
(export "ping")
(param "value" u64)
(result u64)
(canon lift
(core func $i "ping")
)
)
)
"#;
#[derive(Debug)]
struct BenchResult {
name: String,
p50_ns: u64,
p95_ns: u64,
p99_ns: u64,
mean_ns: f64,
ops_per_sec: f64,
}
fn native_ping(value: u64) -> u64 {
black_box(value.wrapping_add(1))
}
struct ComponentBench {
store: Store<()>,
func: TypedFunc<(u64,), (u64,)>,
}
impl ComponentBench {
fn new() -> Result<Self> {
let engine = Engine::default();
let component = Component::new(
&engine,
COMPONENT_WAT,
)
.context("failed to compile component")?;
let linker = Linker::new(&engine);
let mut store = Store::new(&engine, ());
let instance = linker
.instantiate(&mut store, &component)
.context("failed to instantiate component")?;
let func = instance
.get_typed_func::<(u64,), (u64,)>(
&mut store,
"ping",
)
.context("failed to get typed component function")?;
Ok(Self {
store,
func,
})
}
fn call(&mut self, value: u64) -> Result<u64> {
let (result,) = self
.func
.call(
&mut self.store,
(value,),
)?;
self.func.post_return(&mut self.store)?;
Ok(result)
}
}
struct UdsClient {
stream: UnixStream,
}
impl UdsClient {
fn connect() -> Result<Self> {
let stream = UnixStream::connect(SOCKET_PATH)?;
stream.set_nodelay_like();
Ok(Self {
stream,
})
}
fn call(&mut self, value: u64) -> Result<u64> {
self.stream.write_all(&value.to_le_bytes())?;
let mut response = [0u8; 8];
self.stream.read_exact(&mut response)?;
Ok(u64::from_le_bytes(response))
}
}
trait UnixStreamExt {
fn set_nodelay_like(&self);
}
impl UnixStreamExt for UnixStream {
fn set_nodelay_like(&self) {
// Unix Domain SocketにはTCP_NODELAYは存在しない。
// API形状を揃えるため何もしない。
}
}
fn start_uds_server() -> Result<thread::JoinHandle<()>> {
if Path::new(SOCKET_PATH).exists() {
fs::remove_file(SOCKET_PATH)?;
}
let listener = UnixListener::bind(SOCKET_PATH)?;
let handle = thread::spawn(move || {
for incoming in listener.incoming() {
let Ok(mut stream) = incoming else {
continue;
};
thread::spawn(move || loop {
let mut input = [0u8; 8];
if stream.read_exact(&mut input).is_err() {
break;
}
let value = u64::from_le_bytes(input);
let result = value.wrapping_add(1);
if stream
.write_all(&result.to_le_bytes())
.is_err()
{
break;
}
});
}
});
Ok(handle)
}
struct HttpClient {
writer: TcpStream,
reader: BufReader<TcpStream>,
}
impl HttpClient {
fn connect(address: &str) -> Result<Self> {
let writer = TcpStream::connect(address)?;
writer.set_nodelay(true)?;
let reader = BufReader::new(
writer.try_clone()?,
);
Ok(Self {
writer,
reader,
})
}
fn call(&mut self, value: u64) -> Result<u64> {
write!(
self.writer,
"GET /ping?v={} HTTP/1.1\r\n\
Host: localhost\r\n\
Connection: keep-alive\r\n\
\r\n",
value,
)?;
self.writer.flush()?;
let mut status = String::new();
self.reader.read_line(&mut status)?;
if !status.starts_with("HTTP/1.1 200") {
anyhow::bail!(
"unexpected HTTP status: {}",
status.trim()
);
}
let mut content_length = None;
loop {
let mut line = String::new();
self.reader.read_line(&mut line)?;
if line == "\r\n" {
break;
}
let lower = line.to_ascii_lowercase();
if let Some(value) =
lower.strip_prefix("content-length:")
{
content_length = Some(
value
.trim()
.parse::<usize>()?
);
}
}
let length = content_length
.context("missing Content-Length")?;
let mut body = vec![0u8; length];
self.reader.read_exact(&mut body)?;
let result = std::str::from_utf8(&body)?
.trim()
.parse::<u64>()?;
Ok(result)
}
}
fn start_http_server()
-> Result<(String, thread::JoinHandle<()>)>
{
let listener = TcpListener::bind(
"127.0.0.1:0",
)?;
let address = listener.local_addr()?.to_string();
let handle = thread::spawn(move || {
for incoming in listener.incoming() {
let Ok(stream) = incoming else {
continue;
};
let _ = stream.set_nodelay(true);
thread::spawn(move || {
let mut writer = match stream.try_clone() {
Ok(stream) => stream,
Err(_) => return,
};
let mut reader = BufReader::new(stream);
loop {
let mut request_line = String::new();
match reader.read_line(&mut request_line) {
Ok(0) | Err(_) => return,
Ok(_) => {}
}
if request_line.trim().is_empty() {
continue;
}
let value = parse_request_value(
&request_line,
)
.unwrap_or(0);
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) | Err(_) => return,
Ok(_) => {}
}
if line == "\r\n" {
break;
}
}
let body =
value.wrapping_add(1).to_string();
let response = format!(
"HTTP/1.1 200 OK\r\n\
Content-Length: {}\r\n\
Content-Type: text/plain\r\n\
Connection: keep-alive\r\n\
\r\n\
{}",
body.len(),
body,
);
if writer
.write_all(response.as_bytes())
.is_err()
{
return;
}
if writer.flush().is_err() {
return;
}
}
});
}
});
Ok((address, handle))
}
fn parse_request_value(
request_line: &str,
) -> Option<u64> {
let target = request_line
.split_whitespace()
.nth(1)?;
let query = target.split('?').nth(1)?;
for pair in query.split('&') {
let (key, value) = pair.split_once('=')?;
if key == "v" {
return value.parse().ok();
}
}
None
}
fn bench<F>(
name: &str,
mut call: F,
) -> Result<BenchResult>
where
F: FnMut(u64) -> Result<u64>,
{
println!("warming up: {name}");
for i in 0..WARMUP_CALLS {
let result = call(i)?;
black_box(result);
}
let samples = TOTAL_CALLS / BATCH_SIZE;
let mut histogram =
Histogram::<u64>::new_with_max(
10_000_000_000,
3,
)?;
println!(
"benchmarking: {} ({} calls)",
name,
TOTAL_CALLS,
);
let overall_start = Instant::now();
for sample in 0..samples {
let base = sample * BATCH_SIZE;
let start = Instant::now();
for offset in 0..BATCH_SIZE {
let value = base + offset;
let result = call(value)?;
black_box(result);
}
let elapsed = start.elapsed();
let ns_per_op =
elapsed.as_nanos() as u64 / BATCH_SIZE;
histogram.record(ns_per_op.max(1))?;
}
let overall_elapsed = overall_start.elapsed();
let ops_per_sec =
TOTAL_CALLS as f64
/ overall_elapsed.as_secs_f64();
Ok(BenchResult {
name: name.to_string(),
p50_ns: histogram.value_at_quantile(0.50),
p95_ns: histogram.value_at_quantile(0.95),
p99_ns: histogram.value_at_quantile(0.99),
mean_ns: histogram.mean(),
ops_per_sec,
})
}
fn benchmark_native()
-> Result<BenchResult>
{
bench(
"native",
|value| Ok(native_ping(value)),
)
}
fn benchmark_component()
-> Result<BenchResult>
{
let mut component =
ComponentBench::new()?;
bench(
"wasm-component",
move |value| component.call(value),
)
}
fn benchmark_uds()
-> Result<BenchResult>
{
let _server = start_uds_server()?;
thread::sleep(
Duration::from_millis(100),
);
let mut client = UdsClient::connect()?;
bench(
"unix-domain-socket",
move |value| client.call(value),
)
}
fn benchmark_http()
-> Result<BenchResult>
{
let (address, _server) =
start_http_server()?;
thread::sleep(
Duration::from_millis(100),
);
let mut client =
HttpClient::connect(&address)?;
bench(
"localhost-http-keepalive",
move |value| client.call(value),
)
}
fn measure_component_startup() -> Result<()> {
println!();
println!("=== Component startup ===");
let start = Instant::now();
let engine = Engine::default();
let engine_time = start.elapsed();
let start = Instant::now();
let component = Component::new(
&engine,
COMPONENT_WAT,
)?;
let compile_time = start.elapsed();
let linker = Linker::new(&engine);
let mut store = Store::new(&engine, ());
let start = Instant::now();
let instance = linker.instantiate(
&mut store,
&component,
)?;
let instantiate_time = start.elapsed();
let func = instance
.get_typed_func::<(u64,), (u64,)>(
&mut store,
"ping",
)?;
let start = Instant::now();
let _ = func.call(
&mut store,
(123,),
)?;
func.post_return(&mut store)?;
let first_call_time = start.elapsed();
println!(
"engine_create_ns={}",
engine_time.as_nanos()
);
println!(
"component_compile_ns={}",
compile_time.as_nanos()
);
println!(
"instantiate_ns={}",
instantiate_time.as_nanos()
);
println!(
"first_call_ns={}",
first_call_time.as_nanos()
);
Ok(())
}
fn print_results(
results: &[BenchResult],
) {
println!();
println!("=== Results ===");
println!(
"{:<28} {:>12} {:>12} {:>12} {:>14} {:>16}",
"name",
"p50(ns)",
"p95(ns)",
"p99(ns)",
"mean(ns)",
"ops/sec",
);
for result in results {
println!(
"{:<28} {:>12} {:>12} {:>12} {:>14.1} {:>16.0}",
result.name,
result.p50_ns,
result.p95_ns,
result.p99_ns,
result.mean_ns,
result.ops_per_sec,
);
}
println!();
println!(
"name,p50_ns,p95_ns,p99_ns,mean_ns,ops_per_sec"
);
for result in results {
println!(
"{},{},{},{},{:.1},{:.0}",
result.name,
result.p50_ns,
result.p95_ns,
result.p99_ns,
result.mean_ns,
result.ops_per_sec,
);
}
}
fn main() -> Result<()> {
let only = std::env::args()
.skip(1)
.find_map(|arg| {
arg.strip_prefix("--only=")
.map(str::to_owned)
});
measure_component_startup()?;
let mut results = Vec::new();
let should_run = |name: &str| {
only.as_deref()
.map(|selected| selected == name)
.unwrap_or(true)
};
if should_run("native") {
results.push(
benchmark_native()?,
);
}
if should_run("component") {
results.push(
benchmark_component()?,
);
}
if should_run("uds") {
results.push(
benchmark_uds()?,
);
}
if should_run("http") {
results.push(
benchmark_http()?,
);
}
print_results(&results);
if Path::new(SOCKET_PATH).exists() {
let _ = fs::remove_file(
SOCKET_PATH,
);
}
Ok(())
}
ここでpost_returnが出てくる理由
Component ModelのTypedFuncを呼び出した後、WasmtimeではComponent関数によってはpost-return処理が必要になる。
今回のu64だけの関数ではほとんど何も起きないが、文字列やlistなどCanonical ABIがGuestメモリを使用する値では重要になる。
Component call
↓
return value
↓
Host lifts value
↓
post_return
↓
Guest側一時領域の後処理
この仕組みは後でstringやrecordを測定するときに効いてくる。
ビルドする
cargo build --release
ベンチマークでは必ずrelease buildを使う。
debug buildを使うとRust側の最適化不足を測ることになり、比較にならない。
実行する
./target/release/wasi03-boundary-bench
結果は次のCSV形式でも出力される。
name,p50_ns,p95_ns,p99_ns,mean_ns,ops_per_sec
native,...
wasm-component,...
unix-domain-socket,...
localhost-http-keepalive,...
この記事では意図的に「私のPCでは○nsだった」という固定値を書かない。
CPU、OS、kernel、Wasmtime、Cranelift、CPU governorなどで値が大きく変わるからだ。
重要なのは、自分の環境で4つを同条件で比較することである。
100万回を1回ずつ計測してはいけない
今回のコードでは100回を1バッチとして測っている。
BATCH_SIZE = 100
なぜか。
Native Rustの関数呼び出しは非常に短い。
毎回、
Instant::now()
ping()
Instant::now()
とすると、時計を読むコスト自体が測定対象と同程度、あるいはそれ以上になる可能性がある。
そこで、
start
100 calls
end
elapsed / 100
としてタイマーの観測コストを薄めている。
これはナノ秒級ベンチマークではかなり重要である。
black_boxも重要
Native版で単純に、
fn ping(v: u64) -> u64 {
v + 1
}
を100万回呼ぶだけでは、LLVMが計算そのものを消してしまう可能性がある。
そこで、
std::hint::black_box()
を使い、ベンチマーク対象を過剰に最適化されにくくしている。
Wasmtimeのコンパイル時間を分離する
WebAssemblyのベンチマークでありがちな間違いがある。
Component::new()
instantiate()
function call()
を全部まとめて「Wasmの速度」として測ってしまうことだ。
これは別の処理である。
Component compile
↓
machine code生成
Instantiation
↓
instance作成
Steady-state call
↓
実際のサービス呼び出し
長時間動くバックエンドサービスで重要なのは主にsteady-state callである。
逆にServerlessや短命処理ではstartupも重要になる。
そこで今回のコードは、
engine_create_ns
component_compile_ns
instantiate_ns
first_call_ns
も別に表示する。
CPUを固定すると結果が安定する
Linuxでさらに厳密に測るなら、プロセスを一つのCPUへ固定する。
taskset -c 2 \
./target/release/wasi03-boundary-bench
これによってCPU migrationによる揺れを減らせる。
現在のCPUは負荷に応じて周波数も変化するため、複数回測定する。
for i in $(seq 1 10); do
taskset -c 2 \
./target/release/wasi03-boundary-bench
done
perfでCPU内部まで見る
ここからが面白い。
レイテンシだけ見ても「なぜその差が出たのか」は分からない。
Linuxならperfを使う。
perf stat -r 5 \
-e cycles \
-e instructions \
-e branches \
-e branch-misses \
-e cache-references \
-e cache-misses \
-e context-switches \
-e cpu-migrations \
-e page-faults \
./target/release/wasi03-boundary-bench \
--only=component
Nativeも測る。
perf stat -r 5 \
-e cycles \
-e instructions \
-e branches \
-e branch-misses \
-e cache-misses \
-e context-switches \
./target/release/wasi03-boundary-bench \
--only=native
Unix Socket。
perf stat -r 5 \
-e cycles \
-e instructions \
-e context-switches \
-e cpu-migrations \
./target/release/wasi03-boundary-bench \
--only=uds
HTTP。
perf stat -r 5 \
-e cycles \
-e instructions \
-e context-switches \
-e cpu-migrations \
./target/release/wasi03-boundary-bench \
--only=http
特に見るべきはcontext-switches
Component呼び出しとSocket通信の決定的な違いの一つがここにある。
Component
Host thread
↓
Component
↓
Host thread
Socket
Client thread
↓
Kernel
↓
Server thread
↓
Kernel
↓
Client thread
Socket通信ではschedulerやkernelを介した実行切り替えが生じ得る。
Component境界は同一プロセス内の実行なので、同じ種類のOSスケジューリング境界ではない。
レイテンシだけでなくcontext switchを見ると、この差が見える。
straceでsyscallを見る
さらにLinuxならstraceを使う。
strace -f -c \
./target/release/wasi03-boundary-bench \
--only=uds
そしてHTTP。
strace -f -c \
./target/release/wasi03-boundary-bench \
--only=http
Socket系では、
read
write
recvfrom
sendto
futex
epoll
poll
などが現れる可能性がある。
一方、steady-stateの単純Component function callでは、毎回ネットワークI/O用syscallを発生させる必要がない。
ここが「同一プロセスサービス」の大きな意味である。
ただし今回のComponentはズルくないか?
かなり有利な条件である。
その通り。
今回Componentへ渡しているのはu64一つだけ。
u64
↓
u64
Canonical ABIは複雑なmemory transferをほとんど必要としない。
だから、ここでComponentが非常に速かったとしても、
Wasm ComponentはHTTPより常にこの比率で速い
とは言えない。
次に必要なのがpayload benchmarkである。
次はstringを渡す
WITで、
echo: func(value: string) -> string
を作る。
この瞬間、Canonical ABIの仕事が増える。
Rust String
↓ Lower
pointer
length
↓
Guest linear memory
↓
Core Wasm
↓ Lift
Rust String
u64ではほぼ見えなかったメモリコピー、allocation、UTF-8処理が見え始める。
payloadサイズを変える
8 B
64 B
256 B
1 KB
4 KB
16 KB
64 KB
256 KB
1 MB
HTTPでも同じpayloadを送る。
すると、グラフの形が重要になってくる。
Latency
^
|
| HTTP
| /
| /
| /
| UDS
| /
| Component
|___/____________________> payload size
実際の曲線がどうなるかは測らなければ分からない。
重要なのは「1バイトのRPCが何倍速い」ではなく、payloadが増えたとき境界コストがどうスケールするかである。
recordも測るべき
実際のサービスはu64一個ではない。
例えば在庫照会なら、
record inventory-request {
product-code: string,
warehouse-code: string,
}
record inventory-response {
product-code: string,
warehouse-code: string,
quantity: s64,
reserved: s64,
}
という構造になる。
これを、
WIT record
vs
JSON
vs
binary fixed layout
vs
Protobuf
で比較すると、本当に面白い。
HTTPのコストをさらに分解する
「HTTPが遅い」と一括りにしてはいけない。
少なくとも次へ分解できる。
Application
↓ serialization
HTTP encode
↓
TCP write
↓
Kernel
↓
TCP read
↓
HTTP parse
↓ deserialization
Application
さらに別プロセスならscheduler。
別ホストならNIC。
TLSなら暗号化。
DNSやService Meshまで入れば、さらに増える。
今回localhost HTTPを測る理由は、ネットワーク距離をほぼゼロにしても残るHTTP境界のコストを見るためである。
Unix Domain Socketを入れた理由
NativeとHTTPだけ比較すると、間に何があるのか分からない。
UDSを挟むことで、
Native
↓
Component
↓
Kernel IPC
↓
TCP/IP + HTTP
と段階的に境界を増やせる。
これで「プロセスを越えた時点で増えたのか」「HTTPを載せたことでさらに増えたのか」を分離できる。
もう一つ測るべきもの:Throughput
Latencyだけでなく、1秒に何回呼べるかを見る。
ops/sec
単発レイテンシが小さくても、CPUを大量消費する方式では並列負荷時に崩れる場合がある。
次の実験ではthreadsを増やす。
1
2
4
8
16
32
64
そして、
requests/sec
p50
p95
p99
CPU utilization
を見る。
ここでWASI 0.3のasyncが効いてくる
今回の実験は同期呼び出し。
しかし、実際のサービスでは待ち時間がある。
Inventory Component
↓
Database wait
Pricing Component
↓
Cache wait
Order Component
↓
Storage wait
WASI 0.3ではComponent Model自身が、
async func
future<T>
stream<T>
を理解する。
したがって次の実験は、
Component A
async call
↓
Component B
↓
wait
Component C
↓
wait
Shared Runtime Scheduler
に進める。
ここからがWASI 0.3の本丸である。
将来的に比較したい本当のService Chain
最終的には、次を同じ業務処理で比較する。
HTTP Microservices
Gateway
↓ HTTP
Auth
↓ HTTP
Inventory
↓ HTTP
Pricing
↓ HTTP
Order
vs
WASI Component Chain
Gateway
↓
Auth Component
↓
Inventory Component
↓
Pricing Component
↓
Order Component
各サービスで1msの疑似I/Oを入れる。
そして1000同時requestを流す。
見るべきものは、
- 総Latency
- p99
- CPU使用率
- Memory
- context switch
- syscall
- 同時処理数
- scheduler overhead
ここまでやれば、WASI 0.3のService Chainingが単なる仕様上の美しさなのか、実際のバックエンドで意味があるのかが見えてくる。
そして「速さ」以外にも差がある
ここは重要である。
仮にComponent呼び出しがHTTPより圧倒的に速かったとしても、それだけでHTTPマイクロサービスを置き換える理由にはならない。
ネットワーク境界には利点もある。
- プロセス障害を分離できる
- 別マシンへ配置できる
- サービス単位でスケールできる
- 別々にデプロイできる
- OS単位のResource制御ができる
Wasm Componentの価値は「HTTPより速い」ではない。
今までネットワーク境界にしなければ得にくかった明確なソフトウェア境界を、同一プロセス内にも作れる。
ここが本質だと思う。
MonolithとMicroservicesの間に新しい点ができる
Native Monolith
│
│ 最速
│ 密結合
│
├────────────
│
│ Wasm Components
│ 型付き境界
│ Capability境界
│ 言語非依存
│ 同一プロセス可能
│
├────────────
│
│ Unix IPC
│
├────────────
│
│ HTTP Microservices
│ プロセス分離
│ ホスト分離
│ 独立scale
│
└────────────
これまでは、かなり両極端だった。
関数呼び出し
or
ネットワークRPC
Component Modelはその間へ、新しいソフトウェア境界を作っているように見える。
個人的に一番期待しているのは業務システム
巨大な業務システムは、何でもマイクロサービスへ分割すればよいわけではない。
在庫、原価、受注、生産計画、請求などは強く関連し、ネットワーク越しの分散トランザクションにすると別の複雑さが生まれる。
一方、全部を一つのコードベースへ詰め込むと密結合になる。
そこで、
Business Application
│
├── Inventory Component
├── Cost Component
├── Scheduling Component
├── Forecast Component
└── Accounting Component
という構成はかなり面白い。
さらに計算特性に合わせて、
在庫ロジック
Rust
需要予測
Python
帳票ロジック
C#
Web側
JavaScript / PHP
と実装言語を変えても、WITが境界になる世界が成熟すれば、現在とは違うPolyglot Architectureが成立する可能性がある。
今回の実験で重要なのは「順位」ではない
おそらくNative Rustが最速になる。
それ自体には何の驚きもない。
本当に知りたいのは、
Componentという強いソフトウェア境界を得るために、Native function callからどれだけ余計なコストを支払うのか。
である。
仮にその追加コストが十分小さいなら、アーキテクチャ上の選択肢としてかなり面白い。
逆にrecordや巨大payloadになると急激に重くなるなら、「小さな高頻度RPC」と「大きなデータ転送」で使い分けるべきことが分かる。
ベンチマークで絶対にやってはいけないこと
- debug buildとrelease buildを比較する
- Wasmだけcompile時間を含める
- HTTPだけ毎回TCP接続する
- Native処理をLLVMに消される
- 1回だけ測って結論を出す
- 平均値だけを見る
- CPU周波数変動を無視する
- payloadサイズを明記しない
- 同期処理と非同期処理を一緒に比較する
- フレームワーク差をプロトコル差だと思う
特に技術記事では「何倍速かった」という数字より、どう測ったかの方が重要である。
今回の結果から次に進む順番
STEP 1
u64 -> u64
境界だけ測る
↓
STEP 2
string
Canonical ABI memory transfer
↓
STEP 3
record / list
実用データ構造
↓
STEP 4
payload 8B〜1MB
scalingを見る
↓
STEP 5
1〜64 concurrent callers
throughputを見る
↓
STEP 6
WASI 0.3 async
future / stream
↓
STEP 7
複数Component Service Chain
↓
STEP 8
HTTP Microservicesと実アプリ比較
最初からSTEP 8だけを測ると、何が速さを生んだのか説明できない。
一層ずつ境界を追加することで、WASI 0.3のどこに価値があるのかが見える。
まとめ
前回、WASI 0.3について「マイクロサービスからHTTPが消える可能性」を考えた。
今回はその最初の検証として、
Native Rust
Wasm Component
Unix Domain Socket
localhost HTTP
という4つの境界を同じ処理で比較できる環境を作った。
ここで重要なのは、WasmがHTTPより何倍速いかという単純なランキングではない。
コンピュータ内部で、境界を一つ増やすたびに何が起きるのかを見ることだ。
function boundary
↓
Canonical ABI boundary
↓
kernel IPC boundary
↓
TCP/IP boundary
↓
HTTP protocol boundary
WebAssembly Component Modelが面白いのは、この階段のかなり上側へ「型付きで、言語非依存で、独立したソフトウェア境界」を作ろうとしている点にある。
もしそのコストが十分低ければ、
Monolith
vs
Microservices
という二択そのものが変わる。
サービスとして分離する。
しかしネットワークでは分離しない。
言語も自由。
型はWITで共有する。
必要なCapabilityだけ与える。
負荷や障害分離が必要になった部分だけ、後から別プロセス・別ホストへ移す。
WASI 0.3が面白いのはHTTPを高速化するからではない。
「HTTPを使わなければサービスを分離できない」という前提を崩し始めたからだ。
次回
次はu64ではなく、Canonical ABIが本格的に動くデータを流す。
string
record
list<u8>
8 B
64 B
256 B
1 KB
4 KB
16 KB
64 KB
256 KB
1 MB
Wasm Component、JSON/HTTP、Unix Socketで同じpayloadを往復させ、サイズが増えたときにどこで性能曲線が変わるのかを測る。
さらに、その次はWASI 0.3の本丸であるNative Asyncへ進む。
async func
future<T>
stream<T>
↓
複数Component
↓
Shared Scheduler
↓
1000 concurrent requests
そこで初めて、「WASI 0.3によるService Chainingは実際のマイクロサービスをどこまで置き換えられるのか」を数字で判断できる。
参考資料
- Bytecode Alliance — WASI 0.3 Launched
- WebAssembly Component Model — Native Async with WASI 0.3
- WebAssembly Component Model — Canonical ABI
- Wasmtime — Rust API Documentation / Component Model
- Wasmtime — component::bindgen documentation
- WebAssembly Component Model Specification
補足:WasmtimeおよびComponent Model周辺は現在も開発速度が速い。この記事ではWasmtime 48系のRust APIを基準としているため、将来のバージョンでは一部API名や設定方法が変更される可能性がある。