Skip to content

Instantly share code, notes, and snippets.

@mizchi
Last active September 4, 2026 14:29
Show Gist options
  • Select an option

  • Save mizchi/9fb6627ffa370f55d482c82bc8d36fcf to your computer and use it in GitHub Desktop.

Select an option

Save mizchi/9fb6627ffa370f55d482c82bc8d36fcf to your computer and use it in GitHub Desktop.
Generating small SIMD WebAssembly with Zig 0.16 / Zig 0.16で小さなSIMD WebAssemblyを生成する

Zig 0.16で小さなSIMD WebAssemblyを生成する

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-opt 116
  • importしたlinear memoryを使うwasm32-freestanding

添付したkernel.watは、最小kernelのkernel.raw.wasmをwasm-tools printで整形した出力である。

コンパイラの出力は言語仕様による保証ではない。ツールチェーンを固定し、Zig更新時には計測し直すこと。

なぜ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::wasm32 intrinsicを使う。
  • 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を変更したら比較を再実行する。

最小の型安全なSIMDカーネル

次の関数は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に応じた推奨ベクトル幅より、固定幅の方が扱いやすい。

SIMDが実際に有効になるようにビルドする

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を満たすことも確認する。

Zigソースから推測せず、出力を検査する

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.wat

SIMD命令が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.wasm

配布候補をすべて計測する

custom 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 -c

wasm-opt -Ozが必ず小さくするとは限らない。あるtable-drivenなreaction kernelでは、strip後の525 bytesが625 bytesに増えた。検証済みのうち小さい方を配布する。また、gzip/Brotli後のサイズも記録する。特殊化で反復コードが増えても圧縮は効く場合がある一方、parse/compile costは圧縮前の構造にも影響される。

moduleを小さく保ちやすい手法

Wasm ABIを狭くする

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を検査する。

vector本体とtail処理を分ける

最小のhot functionが必要なら、「長さは16の倍数」を明示的なコントラクトにし、端数はhostまたは別のexport wrapperで処理する。実測した数字カウントkernelでは、scalar tailの追加で最小artifactが150 bytesから198 bytesに増えた。

ただし、境界で安価かつ確実にコントラクトを守れる場合に限る。そうでなければtailを残す。数bytesよりmemory safetyが重要である。

振る舞いが増えたらdata-drivenなruleを優先する

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の両方で判断する。

reductionは複数の正しい表現を試す

意味が同じZigコードでもloweringは異なり得る。boolean laneには@bitCast + @popCountが小さかった。一方、i32x4の加算kernelでは、明示的なlane加算が@reduce(.Add, vector)よりmodule全体で34 bytes小さかった(実測295 bytes対329 bytes)。

コンパイラ固有の経験則をAPI全体へ埋め込まない。同じテスト済みコントラクトの裏に明確な実装を2つ用意し、実験として両方をcompileして、固定したtoolchainで小さく速い方を残す。

通常のinline判断はcompilerに任せる

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増加を上回る速度向上があるなら、大きな実装が正しい場合もある。

runtime i8x16.swizzle: 不安定なescape hatch

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. 1つのadapter moduleに隔離する。
  2. Zigを固定し、LLVM backendを明示的に利用・検査する(上のbuildに-fllvmを加えるなど)。
  3. 範囲外indexが0になる場合を含め、semanticsをtestする。
  4. i8x16.swizzleが存在し、llvm.wasm.swizzle importが残らないことをCIでassertする。
  5. loweringが変化した場合のportable fallbackを用意するか、buildを失敗させる。

小さな実験では、vector load 2回、swizzle 1回、vector store 1回を持つブラウザから呼べるmemory ABI wrapperが、wasm-opt -Oz後に85 bytesだった。これは実現可能性を示すだけで、API安定性を示すものではない。

推奨するCI gate

exportするkernelごとに次を行う。

  1. 固定edge caseとrandom bufferに対し、scalar referenceとSIMD実装のtestを実行する。
  2. 固定したZigとbaseline+simd128でbuildする。
  3. wasm-toolsでmoduleをvalidateする。
  4. 期待するSIMD opcodeを検査・計数する。
  5. 想定外のimport/exportを拒否する。
  6. raw、stripped、wasm-opt -Ozの候補を生成する。
  7. すべての候補に同じconformance testを実行する。
  8. 圧縮前後のsizeを記録し、budgetまたはregression thresholdを課す。
  9. 代表的な入力sizeをbenchmarkする。小さい入力では、kernelが優秀でもcall/setup overheadに負ける場合がある。

size snapshotはtoolchainごとに管理する。compiler更新による20 bytesの変化は問題でないかもしれないが、新baselineを受け入れる前にWAT diffを確認する価値がある。

実用的なdefault

まず次の構成から始める。

  • 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の両方を計測し、根拠がある変更だけを残す。

参考資料

Generating Small SIMD WebAssembly with Zig 0.16

Japanese version: README.ja.md

Zig's @Vector gives SIMD kernels a type-safe source representation, while wasm32-freestanding can keep the generated WebAssembly close to handwritten WAT. The important caveat is that vector source code does not guarantee vector instructions: enable Wasm SIMD explicitly, inspect the emitted module, and measure every post-processing variant.

This guide targets small, browser-oriented leaf kernels rather than complete WASI applications. Commands and byte counts were checked with:

  • Zig 0.16.0
  • wasm-tools 1.245.1
  • Binaryen wasm-opt 116
  • wasm32-freestanding with imported linear memory

The companion kernel.wat file is the formatted wasm-tools print output of the minimal kernel's kernel.raw.wasm build.

Compiler output is not a language guarantee. Pin the toolchain and repeat the measurements when upgrading Zig.

Why Zig?

Zig was not selected because it won every byte. It was selected because it produced near-minimal Wasm while keeping the SIMD body in typed, maintainable source.

The following snapshot implements the same kernel and ABI in six forms. Every candidate imports only env.memory, exports count_ascii_digits(input, length) -> count, processes 16 bytes per iteration, and was checked for v128.load plus i8x16 instructions. All smallest artifacts passed the same execution test.

best is the smaller valid result of the stripped module and the Binaryen -Oz module.

Source/toolchain Source LOC Raw Stripped wasm-opt -Oz Best Best gzip
Handwritten 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

This is a fixed-overhead microkernel, not a general language ranking. The snapshot used Zig 0.16.0 (also as the C/Clang driver), Rust 1.98.0, AssemblyScript 0.28.20, MoonBit 0.1.20260824, wasm-tools 1.245.1, and wasm-opt 116.

The result makes the trade-off concrete:

  • Handwritten WAT is the 131-byte lower-bound reference and gives exact instruction control, but control flow, types, refactoring, and larger rule sets become increasingly expensive to maintain.
  • C is one byte smaller than Zig after optimization, but uses the target-specific intrinsic header and exposes more unchecked pointer/intrinsic surface.
  • Rust ties Zig at 150 bytes and is an equally credible choice, especially in an existing Rust codebase. This sample needs no_std, a cdylib, target-feature/linker configuration, and explicit core::arch::wasm32 intrinsics.
  • Zig reaches essentially the same output through normal vector comparisons and operators. @Vector(16, u8) keeps lane width and element types visible, while freestanding exports and imported memory can be controlled without a language runtime.
  • AssemblyScript is also close, but the kernel is expressed with WebAssembly-specific builtins and a stub runtime rather than a general native/Wasm vector abstraction.
  • MoonBit's inline Wasm gives exact instruction-level escape hatches and is attractive when the surrounding system is already MoonBit. In this tiny module, its export wrapper and indirect-function table leave more fixed overhead.

The reason to use Zig here is therefore WAT-like output size with a scalable typed implementation language. It is not a promise that Zig will always beat Rust or C. Keep WAT as a lower-bound oracle, keep cross-language kernels behaviorally identical, and re-run this comparison when either the kernel or toolchain changes.

A minimal type-safe SIMD kernel

This function counts ASCII digits in 16-byte blocks. Its contract is intentionally narrow: length must be a multiple of 16, and the host must ensure that [input, input + length) is inside the imported 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;
}

What the types buy us:

  • The lane type and lane count are checked at compile time.
  • Arithmetic, comparisons, and bitwise operators are lane-wise.
  • *align(1) states that an input address need not be 16-byte aligned.
  • A boolean vector can be bit-cast to a mask and reduced with scalar @popCount.

For Wasm SIMD128, explicit 128-bit shapes are predictable: @Vector(16, u8), @Vector(8, u16), @Vector(4, u32), and @Vector(2, u64). Host-dependent helpers such as a suggested native vector width are less suitable when the same .wasm must behave consistently across browsers.

Build so SIMD is actually enabled

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

The decisive flag is -mcpu=baseline+simd128. In a small experiment, omitting it scalarized one 16-byte vector load into sixteen i32.load8_u operations and increased the raw module from 177 to 438 bytes.

ReleaseSmall is the default starting point for a size-constrained kernel. It enables size optimization and disables runtime safety. That makes the ABI contract part of correctness: validate pointer ranges, lengths, overflow, and block-size requirements in JavaScript or in a separate checked boundary function.

For a proven leaf kernel that never uses the stack, these flags can remove a few more bytes in some builds:

--stack 0 --initial-memory=65536

Do not apply --stack 0 mechanically. Re-test it whenever the implementation changes, and ensure the supplied/imported memory satisfies the module's declared minimum.

Inspect the output; do not infer it from Zig source

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.wat

Check the complete hot loop, not just the presence of one SIMD opcode. Look for:

  • one vector load/store per intended block;
  • vector comparisons, masks, selects, and arithmetic;
  • accidental scalar lane extraction;
  • duplicated loop bodies or constants;
  • imports, exports, globals, traps, and memory sections you did not expect.

A useful negative CI check is to validate the same module with SIMD disabled and expect failure when SIMD instructions are mandatory:

! wasm-tools validate --features=-simd kernel.raw.wasm

Measure every deliverable candidate

Custom sections, optimizer rewrites, and transport compression answer different questions. Keep them separate:

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 -c

Never assume wasm-opt -Oz wins. In one table-driven reaction kernel it changed 525 stripped bytes into 625 bytes. Keep the smaller validated artifact. Also record gzip/Brotli size: repeated specialized code may compress well even when its uncompressed module is much larger, while parse/compile cost still follows the uncompressed structure.

Techniques that usually keep the module small

Keep the Wasm ABI narrow

Prefer exported functions made from Wasm scalar types and offsets into imported memory:

step(input_ptr: u32, output_ptr: u32, length: u32) -> u32

Let the host own allocation and pass regions to the kernel when practical. Export only intentional entry points. A leaf module that does not need formatting, filesystem access, a general allocator, or a rich error runtime is easier to audit and usually smaller.

Importing std by itself is not the problem—Zig analyzes declarations lazily. Calling machinery-heavy facilities from a tiny kernel is what can introduce code and data. Inspect imports and sections instead of following a blanket “never use std” rule.

Separate the vector body from the tail

For the smallest hot function, make “length is a multiple of 16” an explicit contract and handle the tail in the host or a second exported wrapper. In the measured digit kernel, adding a scalar tail increased the smallest artifact from 150 to 198 bytes.

This is worthwhile only when the call boundary can enforce the contract cheaply. Otherwise, keep the tail and accept the size; memory safety is more important than a few bytes.

Prefer data-driven rules when behavior grows

Compile-time specialization is attractive, but every material, opcode, or rule can duplicate comparisons, constants, and branches. For many similar rules, store parameters in compact tables and run one generic SIMD body.

In one 16-rule microkernel:

Shape Smallest artifact
Compact packed table + generic loop 252 B
inline for with a specialized body per rule 463 B

Fully specialized Zig rules grew approximately linearly in the same experiment:

Rules Size
4 271 B
8 383 B
16 607 B

That sample was about 28 bytes per added rule plus fixed overhead. One cause was repeated lane-expanded v128.const values. Loading scalar parameters and using a shared body can be smaller than embedding a different vector constant in every specialization.

A good compromise is a hybrid: specialize a few dominant cases and put the long tail in a table. Decide with both size and workload benchmarks.

Express reductions in more than one correct form

Equivalent Zig can lower differently. For boolean lanes, @bitCast plus @popCount was compact. In an i32x4 accumulation kernel, explicit lane additions were 34 bytes smaller than @reduce(.Add, vector) (295 versus 329 bytes for the complete measured module).

Do not encode compiler folklore into abstractions. Keep two clear implementations behind the same tested contract, compile both in an experiment, and retain the smaller/faster form for the pinned toolchain.

Let the compiler choose ordinary inlining

Use a normal fn unless compile-time propagation or measurement requires otherwise. Forced inline can duplicate the whole SIMD body. In one kernel:

Implementation Smallest artifact
Compact loop 150 B
Four manually inlined/unrolled bodies 333 B
Four calls to one noinline helper 225 B

noinline is not a final-binary guarantee: a later optimizer can inline again. Inspect the post-processed module.

Patterns that commonly increase size

  • Building in Debug, ReleaseSafe, or ReleaseFast without comparing ReleaseSmall. For the tiny digit kernel, the smallest artifacts were 756, 183, 323, and 150 bytes respectively (Debug, ReleaseSafe, ReleaseFast, ReleaseSmall).
  • Omitting simd128, which may scalarize vector operations.
  • Manual unrolling and forced inline, especially around large vector bodies.
  • inline for over an expanding rule/material set.
  • Repeating distinct @splat(comptime_value) expressions that become separate v128.const instructions.
  • Mixing scalar tail logic, validation, dispatch, allocation, and diagnostics into every hot entry point.
  • Exporting convenience APIs that retain otherwise-dead implementations.
  • Assuming a high-level reduction or shuffle maps to the one Wasm instruction you had in mind.
  • Shipping wasm-opt -Oz output without comparing it to a stripped-only build.
  • Optimizing only compressed transfer size while ignoring uncompressed parse/compile size.

These are signals to measure, not universal bans. A larger implementation can still be faster enough to justify its bytes.

Runtime i8x16.swizzle: an unstable escape hatch

Zig 0.16's public @shuffle takes a compile-time mask, so it does not express Wasm's runtime-indexed i8x16.swizzle. With the LLVM backend, this declaration currently lowers directly to the LLVM WebAssembly intrinsic and then to i8x16.swizzle without leaving an 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);
}

Treat this as an LLVM-backend implementation detail, not a stable Zig API:

  1. Isolate it in one adapter module.
  2. Pin Zig and explicitly use/test the LLVM backend (for example, add -fllvm to the build above).
  3. Test semantics, including out-of-range indices becoming zero.
  4. Assert in CI that i8x16.swizzle exists and no llvm.wasm.swizzle import remains.
  5. Provide a portable fallback or fail the build when lowering changes.

In a tiny experiment, a browser-callable memory-ABI wrapper containing two vector loads, one swizzle, and one vector store was 85 bytes after wasm-opt -Oz. That result demonstrates feasibility, not API stability.

Suggested CI gates

For each exported kernel:

  1. Run scalar reference tests and SIMD implementation tests over fixed edge cases and randomized buffers.
  2. Build with a pinned Zig version and baseline+simd128.
  3. Validate the module with wasm-tools.
  4. Inspect/count the expected SIMD opcodes.
  5. Reject unexpected imports and exports.
  6. Produce raw, stripped, and wasm-opt -Oz candidates.
  7. Run the same conformance tests against every candidate.
  8. Record uncompressed and compressed sizes; enforce a budget or regression threshold.
  9. Benchmark representative input sizes. Tiny inputs may lose to call and setup overhead even when the kernel is excellent.

Size snapshots should be toolchain-specific. A compiler update that changes 20 bytes may be benign, but it deserves a WAT diff before accepting the new baseline.

Practical default

Start here:

  • wasm32-freestanding
  • -mcpu=baseline+simd128
  • -O ReleaseSmall
  • imported memory and a scalar-only ABI
  • explicit 128-bit @Vector types
  • one compact vector loop
  • host-side validation or a separate checked wrapper
  • stripped and -Oz candidates, with the smaller tested artifact shipped
  • opcode, import/export, correctness, size, and throughput gates in CI

Then add specialization, unrolling, tails, helpers, or backend intrinsics one at a time. Keep a change only when measurements justify both its runtime behavior and its byte cost.

References

(module
(type (;0;) (func (param i32 i32) (result i32)))
(import "env" "memory" (memory (;0;) 16))
(table (;0;) 1 1 funcref)
(global (;0;) (mut i32) i32.const 1048576)
(export "count_ascii_digits" (func 0))
(func (;0;) (type 0) (param i32 i32) (result i32)
(local i32 i32)
i32.const 0
local.set 2
i32.const 0
local.set 3
block ;; label = @1
loop ;; label = @2
local.get 3
local.get 1
i32.ge_u
br_if 1 (;@1;)
local.get 2
local.get 0
local.get 3
i32.add
v128.load align=1
v128.const i32x4 0xd0d0d0d0 0xd0d0d0d0 0xd0d0d0d0 0xd0d0d0d0
i8x16.add
v128.const i32x4 0x0a0a0a0a 0x0a0a0a0a 0x0a0a0a0a 0x0a0a0a0a
i8x16.lt_u
i8x16.bitmask
i32.popcnt
i32.add
local.set 2
local.get 3
i32.const 16
i32.add
local.set 3
br 0 (;@2;)
end
end
local.get 2
)
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment