English version: README.md
Zigの@Vectorを使うと、SIMDカーネルを型安全に記述しつつ、wasm32-freestandingから手書きWATに近い小さなWebAssemblyを生成できる。ただし、ベクトル型で書いたことはSIMD命令の生成を保証しない。Wasm SIMDを明示的に有効化し、出力されたモジュールを検査し、後処理ごとのサイズを実測する必要がある。
このガイドはWASIアプリケーション全体ではなく、ブラウザから呼ぶ小さなleaf kernelを対象にする。コマンドとバイト数は次の環境で確認した。
- Zig 0.16.0
- wasm-tools 1.245.1
- Binaryen
wasm-opt116 - importしたlinear memoryを使う
wasm32-freestanding
添付したkernel.watは、最小kernelのkernel.raw.wasmをwasm-tools printで整形した出力である。
コンパイラの出力は言語仕様による保証ではない。ツールチェーンを固定し、Zig更新時には計測し直すこと。
Zigを選んだ理由は、すべての比較で1 byteでも小さかったからではない。SIMD bodyを型付きで保守可能なソースとして書きながら、最小に近いWasmを生成できたためである。
次のsnapshotでは、同じkernelとABIを6通りで実装した。すべてenv.memoryだけをimportし、count_ascii_digits(input, length) -> countをexportし、1 iterationで16 bytesを処理する。全候補についてv128.loadとi8x16命令を検査し、最小artifactを同じ実行testへ通した。
bestはstrip後とBinaryen -Oz後のうち、小さく、かつvalidationと実行testを通った方である。
| Source/toolchain | Source LOC | Raw | Stripped | wasm-opt -Oz |
Best | Best gzip |
|---|---|---|---|---|---|---|
| 手書きWAT | 41 | 199 B | 131 B | 158 B | 131 B | 143 B |
C + wasm_simd128.h |
13 | 178 B | 178 B | 149 B | 149 B | 139 B |
Zig @Vector |
14 | 177 B | 177 B | 150 B | 150 B | 140 B |
Rust core::arch::wasm32 |
19 | 171 B | 171 B | 150 B | 150 B | 139 B |
| AssemblyScript SIMD builtins | 12 | 157 B | 157 B | 157 B | 157 B | 145 B |
MoonBit extern "wasm" |
37 | 305 B | 185 B | 177 B | 177 B | 162 B |
これは固定overheadを見るmicrokernelであり、言語全体のランキングではない。計測にはZig 0.16.0(CのClang driverとしても使用)、Rust 1.98.0、AssemblyScript 0.28.20、MoonBit 0.1.20260824、wasm-tools 1.245.1、wasm-opt 116を使用した。
この結果からtrade-offが具体的に見える。
- 手書きWATは131 bytesのlower-bound referenceであり、命令を完全に制御できる。一方、規模が大きくなるほどcontrol flow、型、refactoring、rule集合の保守コストが増える。
- Cは最適化後にZigより1 byte小さいが、target固有のintrinsic headerを使い、未検査pointerとintrinsicのsurfaceが広い。
- RustはZigと同じ150 bytesであり、既存のRust codebaseなら同等に有力である。このsampleでは
no_std、cdylib、target feature/linker設定、明示的なcore::arch::wasm32intrinsicを使う。 - Zigは通常のvector比較とoperatorから、ほぼ同じ出力へ到達する。
@Vector(16, u8)によってlane幅とelement型がソース上で明確になり、language runtimeを持ち込まずにfreestanding exportとimport memoryを制御できる。 - AssemblyScriptも小さいが、汎用的なnative/Wasm vector抽象というより、WebAssembly固有builtinとstub runtimeでkernelを表現する。
- MoonBitのinline Wasmは命令単位の正確なescape hatchになり、周辺システムがMoonBitなら魅力がある。この小さなmoduleではexport wrapperとindirect-function tableによる固定overheadが残った。
したがって、ここでZigを使う理由は、WATに近い出力サイズと、スケールする型付き実装言語の両立である。Zigが常にRustやCより小さいという意味ではない。WATをlower-bound oracleとして残し、言語間でkernelの振る舞いを揃え、kernelまたはtoolchainを変更したら比較を再実行する。
次の関数は16バイト単位でASCII数字を数える。意図的にコントラクトを狭くしており、lengthは16の倍数、[input, input + length)はimport memory内の有効な範囲でなければならない。呼び出し側がこれを保証する。
const U8x16 = @Vector(16, u8);
export fn count_ascii_digits(input: u32, length: u32) u32 {
var offset: u32 = 0;
var count: u32 = 0;
while (offset < length) : (offset += 16) {
const bytes: *align(1) const U8x16 = @ptrFromInt(input + offset);
const lower: U8x16 = @splat('0');
const upper: U8x16 = @splat('9');
const digits = (bytes.* >= lower) & (bytes.* <= upper);
const mask: u16 = @bitCast(digits);
count += @popCount(mask);
}
return count;
}この型付けから得られるものは次の通り。
- laneの型と個数をコンパイル時に検査できる。
- 算術、比較、ビット演算はlane単位に適用される。
*align(1)により、入力アドレスが16バイト境界に揃っていなくてもよいことを表現できる。- 真偽値ベクトルをビットマスクへcastし、scalarの
@popCountで集約できる。
Wasm SIMD128では、@Vector(16, u8)、@Vector(8, u16)、@Vector(4, u32)、@Vector(2, u64)のように128 bit幅を明示すると予測しやすい。同じ.wasmを複数ブラウザで一貫して動かす用途では、ホストCPUに応じた推奨ベクトル幅より、固定幅の方が扱いやすい。
zig build-exe kernel.zig \
-target wasm32-freestanding \
-mcpu=baseline+simd128 \
-O ReleaseSmall \
-fno-entry \
--import-memory \
--export=count_ascii_digits \
-femit-bin=kernel.raw.wasm重要なのは-mcpu=baseline+simd128である。小さな実験では、これを外すと16バイトのベクトルロード1回が16個のi32.load8_uにscalarizeされ、raw moduleは177 bytesから438 bytesに増えた。
サイズ制約のあるkernelでは、まずReleaseSmallを試す。これはサイズ最適化を有効にし、runtime safetyを無効にする。したがってABIコントラクト自体が正しさの一部になる。ポインタ範囲、長さ、整数overflow、ブロック幅の条件は、JavaScript側または独立したchecked wrapperで検証する。
stackを一切使わないことを確認済みのleaf kernelでは、次の指定が数bytesを削る場合がある。
--stack 0 --initial-memory=65536--stack 0を機械的に付けてはいけない。実装を変更するたびに再検証し、供給するimport memoryがmoduleのminimumを満たすことも確認する。
wasm-tools validate --features simd kernel.raw.wasm
wasm-tools print kernel.raw.wasm > kernel.wat
rg 'v128|i8x16|i16x8|i32x4|i64x2|f32x4|f64x2' kernel.watSIMD命令が1個あるだけでは不十分で、hot loop全体を見る。確認点は次の通り。
- 意図したブロックごとにvector load/storeが1回ずつか。
- 比較、mask、select、算術がvector命令になっているか。
- 不要なscalar lane抽出がないか。
- loop bodyや定数が複製されていないか。
- 想定外のimport、export、global、trap、memory sectionがないか。
SIMD命令が必須なら、SIMDを無効化したvalidationが失敗することをCIで確認する方法もある。
! wasm-tools validate --features=-simd kernel.raw.wasmcustom sectionの除去、optimizerの書き換え、転送時圧縮は別の問題である。分けて計測する。
wasm-tools strip -a kernel.raw.wasm -o kernel.stripped.wasm
wasm-opt kernel.stripped.wasm -Oz --enable-simd -o kernel.oz.wasm
wc -c kernel.raw.wasm kernel.stripped.wasm kernel.oz.wasm
gzip -9 -n -c kernel.stripped.wasm | wc -c
gzip -9 -n -c kernel.oz.wasm | wc -cwasm-opt -Ozが必ず小さくするとは限らない。あるtable-drivenなreaction kernelでは、strip後の525 bytesが625 bytesに増えた。検証済みのうち小さい方を配布する。また、gzip/Brotli後のサイズも記録する。特殊化で反復コードが増えても圧縮は効く場合がある一方、parse/compile costは圧縮前の構造にも影響される。
export関数はWasmのscalar型とimport memory上のoffsetを中心にする。
step(input_ptr: u32, output_ptr: u32, length: u32) -> u32
可能ならallocationはhostが所有し、kernelにはmemory regionだけ渡す。exportは意図したentry pointに限定する。formatting、filesystem、汎用allocator、豊富なerror runtimeを必要としないleaf moduleは監査しやすく、通常は小さくなる。
stdをimportしただけで大きくなるわけではない。Zigはdeclarationをlazyに解析する。小さなkernelから重い機構を実際に呼ぶとcode/dataが入る。単純な「std禁止」ではなく、importとsectionを検査する。
最小のhot functionが必要なら、「長さは16の倍数」を明示的なコントラクトにし、端数はhostまたは別のexport wrapperで処理する。実測した数字カウントkernelでは、scalar tailの追加で最小artifactが150 bytesから198 bytesに増えた。
ただし、境界で安価かつ確実にコントラクトを守れる場合に限る。そうでなければtailを残す。数bytesよりmemory safetyが重要である。
compile-time specializationは魅力的だが、material、opcode、ruleごとに比較、定数、分岐が複製される。似たruleが多い場合はparameterをcompactなtableに置き、1つのgeneric SIMD bodyで処理する。
16 ruleのmicrokernelでは次の結果になった。
| 構成 | 最小artifact |
|---|---|
| compactなpacked table + generic loop | 252 B |
inline forでruleごとに特殊化 |
463 B |
同じ実験で、完全に特殊化したZig ruleはほぼ線形に増えた。
| Rule数 | Size |
|---|---|
| 4 | 271 B |
| 8 | 383 B |
| 16 | 607 B |
このsampleでは固定費に加え、ruleあたり約28 bytes増えた。一因は、laneごとに展開されたv128.constが繰り返し埋め込まれたことだった。scalar parameterを読み、共通bodyでsplatする方が、特殊化ごとに異なるvector定数を埋め込むより小さくなる場合がある。
頻出する少数caseだけを特殊化し、long tailをtableにするhybridも有効である。サイズと実workloadの両方で判断する。
意味が同じZigコードでもloweringは異なり得る。boolean laneには@bitCast + @popCountが小さかった。一方、i32x4の加算kernelでは、明示的なlane加算が@reduce(.Add, vector)よりmodule全体で34 bytes小さかった(実測295 bytes対329 bytes)。
コンパイラ固有の経験則をAPI全体へ埋め込まない。同じテスト済みコントラクトの裏に明確な実装を2つ用意し、実験として両方をcompileして、固定したtoolchainで小さく速い方を残す。
compile-time伝播または実測上の根拠がなければ、通常のfnを使う。強制inlineはSIMD body全体を複製し得る。あるkernelでは次の結果になった。
| 実装 | 最小artifact |
|---|---|
| compact loop | 150 B |
| 手動で4回inline/unroll | 333 B |
1つのnoinline helperを4回call |
225 B |
noinlineも最終binaryの保証ではない。後段optimizerが再びinlineすることがあるため、後処理後のmoduleを検査する。
ReleaseSmallと比較せず、Debug、ReleaseSafe、ReleaseFastを使う。小さな数字カウントkernelの最小artifactは順に756、183、323、150 bytesだった。simd128を指定せず、vector演算をscalarizeさせる。- 大きなvector bodyの手動unrollや強制
inline。 - 増え続けるrule/material集合に対する
inline for。 - 特殊化ごとに別の
v128.constになる@splat(comptime_value)の反復。 - scalar tail、validation、dispatch、allocation、diagnosticをすべてのhot entry pointへ混ぜる。
- convenience APIを多数exportし、本来deadになる実装を保持させる。
- 高水準のreductionやshuffleが、想定した単一Wasm命令になると思い込む。
- strip後と比較せず
wasm-opt -Oz出力を配布する。 - 圧縮後の転送量だけを最適化し、圧縮前のparse/compile sizeを無視する。
これらは計測すべき兆候であり、絶対的な禁止事項ではない。bytes増加を上回る速度向上があるなら、大きな実装が正しい場合もある。
Zig 0.16のpublicな@shuffleはcompile-time maskを取るため、Wasmのruntime indexによるi8x16.swizzleを表現できない。LLVM backendでは、現在のところ次の宣言がLLVM WebAssembly intrinsicを経由して直接i8x16.swizzleへloweringされ、importは残らない。
const U8x16 = @Vector(16, u8);
extern fn @"llvm.wasm.swizzle"(table: U8x16, indices: U8x16) U8x16;
fn runtimeSwizzle(table: U8x16, indices: U8x16) U8x16 {
return @"llvm.wasm.swizzle"(table, indices);
}これはstableなZig APIではなく、LLVM backendのimplementation detailとして扱う。
- 1つのadapter moduleに隔離する。
- Zigを固定し、LLVM backendを明示的に利用・検査する(上のbuildに
-fllvmを加えるなど)。 - 範囲外indexが0になる場合を含め、semanticsをtestする。
i8x16.swizzleが存在し、llvm.wasm.swizzleimportが残らないことをCIでassertする。- loweringが変化した場合のportable fallbackを用意するか、buildを失敗させる。
小さな実験では、vector load 2回、swizzle 1回、vector store 1回を持つブラウザから呼べるmemory ABI wrapperが、wasm-opt -Oz後に85 bytesだった。これは実現可能性を示すだけで、API安定性を示すものではない。
exportするkernelごとに次を行う。
- 固定edge caseとrandom bufferに対し、scalar referenceとSIMD実装のtestを実行する。
- 固定したZigと
baseline+simd128でbuildする。 wasm-toolsでmoduleをvalidateする。- 期待するSIMD opcodeを検査・計数する。
- 想定外のimport/exportを拒否する。
- raw、stripped、
wasm-opt -Ozの候補を生成する。 - すべての候補に同じconformance testを実行する。
- 圧縮前後のsizeを記録し、budgetまたはregression thresholdを課す。
- 代表的な入力sizeをbenchmarkする。小さい入力では、kernelが優秀でもcall/setup overheadに負ける場合がある。
size snapshotはtoolchainごとに管理する。compiler更新による20 bytesの変化は問題でないかもしれないが、新baselineを受け入れる前にWAT diffを確認する価値がある。
まず次の構成から始める。
wasm32-freestanding-mcpu=baseline+simd128-O ReleaseSmall- imported memoryとscalarだけのABI
- 明示的な128 bit
@Vector型 - 1つのcompactなvector loop
- host側validationまたは独立したchecked wrapper
- stripped版と
-Oz版を作り、test済みの小さい方を配布 - CIでopcode、import/export、correctness、size、throughputを監視
その後、specialization、unroll、tail、helper、backend intrinsicを1つずつ追加する。runtimeの挙動とbyte costの両方を計測し、根拠がある変更だけを残す。