前回の記事では、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名や設定方法が変更される可能性がある。

ABOUT ME
Yuusuke
業務システムの要件定義・設計・実装を担当。Python、PHP、JavaScript、VBA、Oracle、MySQLなど、実務と個人開発で検証した内容を発信しています。