Introduction
wasmz is a WebAssembly runtime written in Zig, designed to be fast, compact, and easy to embed. It implements a full-featured interpreter with support for modern WebAssembly proposals.
Performance
In my benchmarks, wasmz is currently the fastest WebAssembly interpreter I have tested. You can view the full benchmark report here: https://ray-d-song.github.io/wasmz/bench.html.
The benchmark chapter also includes notes on the parser and interpreter optimizations used in wasmz.
If you find workloads where wasmz has a clear disadvantage, please let me know. I will do my best to optimize them :)
Real-World Testing
wasmz passes real-world WebAssembly module tests:
- esbuild - JavaScript bundler compiled to WASM
- QuickJS - Lightweight JavaScript engine compiled to WASM
- SQLite - Database engine compiled to WASM
These integration tests validate wasmz’s compatibility with production WASM workloads.
WebAssembly Support
wasmz supports all current WebAssembly proposals.
- MVP - All core instructions and validation rules
- Multi-value - Functions and blocks with multiple return values
- Bulk operations - Memory and table bulk operations
- Sign-extension - Sign-extension instructions
- GC - Structs, arrays, and reference types with automatic memory management
- SIMD - 128-bit vector operations
- Exception Handling - Both legacy and new proposal formats
- Threading - Shared memory and atomic operations
WASI Support
WASI Preview 1 - Full implementation
- File system operations (fd_read, fd_write, path_open, etc.)
- Socket operations (sock_accept, sock_recv, sock_send, etc.)
- Environment variables and arguments
- Random number generation
- Process control (proc_exit, proc_raise)
- Clock and time operations
- Polling (poll_oneoff)
Embedding
- Zig API - Native Zig interface with full type safety
- C API - Minimal C ABI for embedding in any language
- CLI tool - Standalone command-line runner
License
MIT License - see LICENSE file for details.
Benchmark Report: wasmz vs wasmi vs wasm3 vs wamr
Date: 2026-04-16 09:54 OS: Linux 6.17.0-1010-azure x86_64 Runs per benchmark: 20 (warmup: 5)
Versions
| Runtime | Version |
|---|---|
| wasmz | dev (ReleaseFast) |
| wasmi | wasmi 2.0.0-beta.2 |
| wasm3 | Wasm3 v0.5.1 on x86_64 |
| wamr | iwasm 2.4.3 |
Binary Size
| Runtime | Size |
|---|---|
| wasmz | 892.6 KB |
| wasmi | 7.0 MB |
| wasm3 | 466.3 KB |
| wamr | 344.8 KB |
Execution Time (median ms) — lower is better
fib(30) — pure C compiled to WASM
| Runtime | Median (ms) | ± stddev |
|---|---|---|
| wasmz | 37.0 | ± 1.5 |
| wasmi | 38.4 | ± 0.6 |
| wasm3 | 39.7 | ± 1.0 |
| wamr | 49.6 | ± 1.7 |
QuickJS fib(25) — JS engine running inside WASM (1.4 MB module)
| Runtime | Median (ms) | ± stddev |
|---|---|---|
| wasmz | 174.9 | ± 3.4 |
| wasmi | 184.4 | ± 2.9 |
| wasm3 | 217.4 | ± 8.2 |
| wamr | 242.9 | ± 4.5 |
esbuild — JS bundler running inside WASM (19 MB module)
Note: wamr is excluded from esbuild tests because it does not support stdin input and causes stack overflow with large workloads.
| Runtime | Median (ms) | ± stddev |
|---|---|---|
| wasmz | 909.5 | ± 12.5 |
| wasmi | 918.2 | ± 19.9 |
| wasm3 | 2215.0 | ± 19.5 |
Peak RSS (memory) — lower is better
Peak RSS = highest resident set size seen at any point during the run. Avg RSS = time-weighted mean RSS sampled every 100 ms during one run (reflects actual memory consumption over the process lifetime, not just the spike).
fib(30)
| Runtime | Peak RSS | Avg RSS |
|---|---|---|
| wasmz | 17.3 MB | 8.7 MB |
| wasmi | 21.9 MB | 11.0 MB |
| wasm3 | 18.9 MB | 9.5 MB |
| wamr | 11.0 MB | 5.4 MB |
QuickJS fib(25)
| Runtime | Peak RSS | Avg RSS |
|---|---|---|
| wasmz | 1.8 MB | 1.2 MB |
| wasmi | 1.8 MB | 1.2 MB |
| wasm3 | 1.8 MB | 1.2 MB |
| wamr | 1.8 MB | 1.4 MB |
esbuild bundling
Note: wamr is excluded due to stdin/stack limitations.
| Runtime | Peak RSS | Avg RSS |
|---|---|---|
| wasmz | 1.8 MB | 1.6 MB |
| wasmi | 1.8 MB | 1.6 MB |
| wasm3 | 1.8 MB | 1.7 MB |
Performance Optimizations
The benchmark results above are the product of a series of targeted optimizations applied to wasmz’s compiler and runtime. This section documents each technique.
Register-Based IR (Stack-to-Register Lowering)
WebAssembly is a stack machine. wasmz translates every function’s stack bytecode into a flat array of typed register-IR ops during compilation. Each op carries explicit source and destination slot indices (16-bit unsigned), eliminating the push/pop bookkeeping that stack interpreters must perform at runtime.
Direct Threaded Code Dispatch
Rather than a traditional switch/case bytecode loop typical of high-level interpreters, wasmz uses direct threaded code (inspired by Marr et al., 2023). Each encoded instruction contains an 8-byte handler pointer followed by its operands. After executing each handler, the next() macro issues a tail call directly to the next handler via its pointer, eliminating the overhead of:
- An explicit loop-condition check at the top of the bytecode loop
- A single branch-prediction site (which saturates on complex control flow)
This approach spreads branch prediction across multiple dispatch points, improving predictor accuracy on modern CPUs.
r0 and fp0 Accumulator Registers
Inspired by the Wasm3 M3 architecture, wasmz maintains two accumulator registers:
- r0 — holds the most recent i32/i64 result
- fp0 — holds the most recent f32/f64 result (f32 values are bit-cast to f64)
Handlers that produce a numeric result write it to both the accumulator and the destination slot. This allows the CPU to keep the top-of-stack value in a real hardware register across instruction boundaries, avoiding a slot load on every back-to-back arithmetic instruction. The *_imm_r variants and other fusions leverage this by reading from r0 implicitly.
Superinstructions (Instruction Fusion)
The compiler performs a single forward pass over the IR and merges common multi-op patterns into one instruction. This reduces the total number of dispatched operations and removes redundant slot reads/writes.
The fused families currently implemented are:
| Label | Pattern | Fused Op |
|---|---|---|
| C | const + binop | binop_imm — immediate rhs embedded in the instruction |
| D | binop + local_set | binop_to_local — result written directly to a local slot |
| E | const + binop + local_set | binop_imm_to_local |
| F | compare + jump_if_z | compare_jump_if_false — one dispatch for test-and-branch |
| G | const + compare + br_if | compare_imm_jump_if_false |
| H | local_get + binop_imm + local_set (same local) | local_inplace — mutates local in-place, no temp slot |
| I | binop + ret | binop_ret — compute and return in one dispatch |
| J | compare_jump_if_false + jump | compare_jump_if_true |
| K | copy + jump_if_nz | copy_jump_if_nz — essential for br_if with a result value |
Additional local-slot specialized fusions:
binop_tee_local— writes the result to both a stack slot and a local (local.teepattern)cmp_to_local— comparison result written directly to a local slotconst_to_local— constant written directly to a local slotimm_to_local— superinstruction combining a constant-to-temp with a copy-to-local, preserving the source slot for downstream useload_to_local— i32/i64 memory load result written directly to a localglobal_get_to_local— global read result written directly to a localcall_to_local— direct call result written directly to a local slot (saves one dispatch vscall+local_set)
r0 Accumulator Variants
For long chains of const + binop_imm sequences (common in tight loops), the compiler tracks an accumulator register r0. When the previous instruction’s destination matches the next instruction’s source, the lhs slot field is elided from the encoding, producing *_imm_r variants. This shrinks the instruction and saves one memory load per dispatch.
call_leaf Superinstruction
When a direct-call site is proven to target a leaf function (a function that makes no further calls itself) whose result is not used (void call), the compiler emits call_leaf instead of call. The VM handler skips result-slot setup and the return-value copy path entirely, reducing call overhead on hot void-dispatches.
Slot Recycling
During lowering, temporary slots created for intermediate values are recycled once their last use is seen. This reduces the total slots_len per compiled function, which directly shrinks the value-stack frame allocated at call time.
Lazy Compilation
Functions are compiled on their first invocation rather than all at once at Module.compile() time. For large modules (esbuild is 19 MB), this makes startup near-instant and amortises compilation cost over actual execution. The --eager-compile flag or Config.eager_compile = true opts into up-front compilation when predictable latency matters more than startup time.
mmap-Based Memory
Two mmap optimizations reduce peak RSS:
-
File mapping — the
.wasmfile is memory-mapped rather than heap-copied. Pending (uncompiled) function bodies borrow slices directly from the mapped region, so the bytecode is never duplicated in the heap until compilation. -
Virtual reservation for linear memory — when allocating WebAssembly linear memory, wasmz reserves a large virtual address range with
mmap(PROT_NONE)and then commits pages withmprotectas the module callsmemory.grow. This avoids the RSS spike thatreallocproduces when doubling a backing buffer.
Lazy GC Heap Initialization
The GC heap inside Store is not allocated until the first GC-typed value is actually created. Modules that use only numeric types (MVP, no GC proposal) never touch the allocator, keeping RSS minimal.
Lazy WASI Initialization
The WASI Preview 1 host is only instantiated when the module’s import table contains at least one wasi_snapshot_preview1 import. Pure compute modules pay no initialization overhead.
Slot Width Reduction (u32 → u16)
All slot indices were narrowed from 32-bit to 16-bit integers. This halves the per-instruction slot storage for the most common instruction layouts, improving cache utilization in the hot interpreter loop.
Handler Ordering (Future Work)
Recent research has shown that the order of bytecode handler definitions in memory affects CPU branch-prediction performance, with potential speedups of 7–23% on specific benchmarks. Genetic algorithms can search for near-optimal orderings tailored to specific workloads and CPU architectures.
wasmz does not yet implement handler reordering, but the architecture (direct threaded code with multiple dispatch sites per handler) is well-suited for such optimization. The decision to prioritize other techniques first (superinstructions, accumulator registers, lazy compilation, mmap) reflects a pragmatic tradeoff: the gains from reducing dispatch count via fusion exceed what handler ordering alone typically provides, and fusion benefits apply uniformly across all workloads.
Getting Started
This chapter covers installing wasmz and running your first WebAssembly program.
Quick Start
# Clone and build
git clone https://github.com/anomalyco/wasmz.git
cd wasmz
zig build
# Run a WASM file
./zig-out/bin/wasmz examples/hello.wasm
# Call a specific function
./zig-out/bin/wasmz examples/add.wasm add 3 4
Next: Installation for detailed setup instructions.
Installation
Install from Release
Install the latest published GitHub release directly into your user directory:
Linux / macOS
curl -fsSL https://raw.githubusercontent.com/Ray-D-Song/wasmz/main/scripts/install.sh | bash
By default, this installs wasmz to ~/.local/bin.
Windows
powershell -ExecutionPolicy Bypass -c "iwr https://raw.githubusercontent.com/Ray-D-Song/wasmz/main/scripts/install.ps1 -UseBasicParsing | iex"
By default, this installs wasmz.exe to %LOCALAPPDATA%\wasmz\bin.
You can also download the installer scripts and run them locally:
./install.sh --help
powershell -ExecutionPolicy Bypass -File .\install.ps1
These scripts install the latest stable GitHub release by default, and also support installing a specific tag.
Prerequisites
- Zig 0.15.2 - Download from ziglang.org
- Git - For cloning the repository
- make - For build commands
Build from Source
# Clone the repository
git clone https://github.com/anomalyco/wasmz.git
cd wasmz
# Build (ReleaseSafe - recommended for development)
make build
# Build for debugging
make build-debug
# Build for maximum performance
make release
The binary will be at zig-out/bin/wasmz.
Build Options
| Command | Description |
|---|---|
make build-debug | Debug build (unoptimized, fast compile) |
make build | ReleaseSafe build (optimized, safety checks) |
make release | ReleaseFast build (maximum performance) |
make test | Run all unit tests |
make clib | Build C shared library |
Build Mode Differences
The build mode affects panic handling in the CLI binary:
| Mode | Panic Handler | Binary Size | Backtrace |
|---|---|---|---|
| Debug | Full panic handler | Larger | ✅ Readable stack trace |
| ReleaseSafe | Full panic handler | Larger | ✅ Readable stack trace |
| ReleaseFast | Minimal panic handler | ~127 KB smaller | ❌ No backtrace |
| ReleaseSmall | Minimal panic handler | ~127 KB smaller | ❌ No backtrace |
Recommendation: Use make build (ReleaseSafe) for development - it provides optimizations while keeping safety checks and readable error messages. Use make release (ReleaseFast) for production deployments where binary size matters.
Running Tests
# Run all unit tests
make test
# Run integration tests (requires building fixtures first)
zig build sqlite-wasm # Build SQLite WASM fixture
zig build test-sqlite # Run SQLite integration tests
Installation
Install to ~/.local/bin:
# Install debug build
make install
# Install release build
make install-release
After installation, ensure ~/.local/bin is in your PATH.
C Library
Build the C shared library for embedding:
make clib
Output files:
zig-out/lib/libwasmz.so(Linux)zig-out/lib/libwasmz.dylib(macOS)zig-out/lib/libwasmz.dll(Windows)zig-out/include/wasmz.h(header)
Verify Installation
# Check the CLI
wasmz --help
# Or run directly
./zig-out/bin/wasmz --help
# Run a simple test
echo '(module (func (export "add") (param i32 i32) (result i32) local.get 0 local.get 1 i32.add))' > test.wat
wat2wasm test.wat
wasmz test.wasm add 1 2
# Output: 3
CLI Usage
The wasmz command-line tool runs WebAssembly modules directly from the terminal.
Basic Usage
# List exported functions
wasmz <file.wasm>
# Call a function with i32 arguments
wasmz <file.wasm> <func_name> [i32_args...]
# Run _start with WASI arguments
wasmz <file.wasm> --args "<wasm_args...>"
Examples
List Exports
$ wasmz module.wasm
Exported functions:
add
multiply
greet
Call a Function
# Call "add" with arguments 3 and 4
$ wasmz module.wasm add 3 4
7
WASI Command Model
# Run _start with arguments passed to the WASM module
$ wasmz program.wasm --args "--verbose --output=result.txt"
Reactor Mode
# Initialize reactor and call a function
$ wasmz library.wasm --reactor --func process -- input.txt
Flags
| Flag | Description |
|---|---|
--legacy-exceptions | Use legacy exception handling proposal |
--args "<string>" | Arguments to pass to WASM module (space-separated) |
--func <name> | Exported function to call |
--reactor | Call _initialize before the function |
--mem-stats | Print memory usage after execution |
--mem-trace | Print RSS snapshots at each execution phase |
--mem-limit <MB> | Set memory limit in megabytes |
--eager-compile | Compile all functions eagerly at load time |
--smart-compile | Auto-select compile mode: eager for modules < 3 MB, lazy otherwise |
Memory Statistics
Use --mem-stats to analyze memory usage:
$ wasmz program.wasm --mem-stats
Memory usage:
Linear memory: 1.00 MB (16 pages)
GC heap: 0.12 MB (used 45.2 KB / capacity 128.0 KB)
Shared memory: 0.00 MB (none)
────────────────────────────────
Total: 1.12 MB
Memory Tracing
Use --mem-trace to print RSS snapshots at each execution phase (open, compile, instantiate, run):
$ wasmz program.wasm --mem-trace
[mem-trace] baseline (file mapped) RSS 12.3 MB (+12.3 MB)
[mem-trace] after compile RSS 18.7 MB (+6.4 MB)
[mem-trace] after instantiate RSS 20.1 MB (+1.4 MB)
[mem-trace] after _start RSS 21.5 MB (+1.4 MB)
Set the WASMZ_PHASE_DIAG=1 environment variable to print detailed wall-clock timing for each phase to stderr:
$ WASMZ_PHASE_DIAG=1 wasmz program.wasm
[phase-diag] wasmz exit=_start return
[phase-diag] open+mmap : 0.342 ms
[phase-diag] compile : 12.101 ms
[phase-diag] store+linker : 0.082 ms
[phase-diag] instantiate : 1.203 ms
[phase-diag] runStart : 0.004 ms
[phase-diag] _start : 245.881 ms
[phase-diag] total : 259.613 ms
Compilation Modes
By default, wasmz compiles functions lazily (on first call). Two flags control this behaviour:
| Flag | Description |
|---|---|
--eager-compile | Compile every function during module load. Higher startup cost, zero lazy overhead at runtime. |
--smart-compile | Automatically choose: eager for modules < 3 MB, lazy otherwise (good default for interactive use). |
Error Handling
When a trap occurs, wasmz prints the trap message:
$ wasmz module.wasm divide 1 0
error: trap: integer divide by zero
Exit Codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Error (file not found, compilation error, etc.) |
| N | WASI proc_exit code (if called by module) |
Zig API
This section documents the Zig API for embedding wasmz in your applications.
Overview
const std = @import("std");
const wasmz = @import("wasmz");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Create engine
var engine = try wasmz.Engine.init(allocator, .{});
defer engine.deinit();
// Compile module
const bytes = try std.fs.cwd().readFileAlloc(allocator, "module.wasm", 1024 * 1024);
defer allocator.free(bytes);
var module = try wasmz.Module.compile(engine, bytes);
defer module.deinit();
// Create store and instance
var store = try wasmz.Store.init(allocator, engine);
defer store.deinit();
var instance = try wasmz.Instance.init(&store, module, .empty);
defer instance.deinit();
// Call function
const result = try instance.call("add", &.{ .{ .i32 = 1 }, .{ .i32 = 2 } });
std.debug.print("Result: {d}\n", .{result.ok.?.readAs(i32)});
}
Core Types
| Type | Description |
|---|---|
Engine | Runtime engine with configuration |
Config | Engine configuration options |
Module | Compiled WebAssembly module |
Store | Runtime context for instances |
Instance | Instantiated module with memory/globals |
Linker | Host function registry |
HostFunc | Host-provided callable |
RawVal | Generic value (i32/i64/f32/f64) |
ExecResult | Execution result (ok or trap) |
Trap | Runtime trap with code |
Next Steps
- Engine & Config - Setting up the runtime
- Module - Compiling WebAssembly
- Store & Instance - Running modules
- Linker & Host Functions - Host integration
- Error Handling - Traps and errors
Engine & Config
The Engine is the core runtime that manages compilation, configuration, and shared resources.
Engine
Initialization
const wasmz = @import("wasmz");
// Default configuration
var engine = try wasmz.Engine.init(allocator, .{});
defer engine.deinit();
// With configuration
var engine = try wasmz.Engine.init(allocator, .{
.legacy_exceptions = true,
.mem_limit_bytes = 256 * 1024 * 1024, // 256 MB limit
});
defer engine.deinit();
Methods
| Method | Description |
|---|---|
init(allocator, config) | Create engine with config |
deinit() | Free engine resources |
Config
Configuration options for the engine:
const Config = struct {
/// Use legacy exception handling proposal
legacy_exceptions: bool = false,
/// Memory limit in bytes (null = unlimited)
mem_limit_bytes: ?u64 = null,
/// When true, all local functions are compiled up front during Module.compile()
/// instead of lazily on first call. Trades higher startup cost for zero
/// lazy-compilation overhead at runtime.
eager_compile: bool = false,
};
Options
| Field | Type | Default | Description |
|---|---|---|---|
legacy_exceptions | bool | false | Use legacy EH proposal |
mem_limit_bytes | ?u64 | null | Max memory allocation |
eager_compile | bool | false | Compile all functions at load time |
Example: Memory Limit
var engine = try wasmz.Engine.init(allocator, .{
.mem_limit_bytes = 128 * 1024 * 1024, // 128 MB
});
Example: Legacy Exceptions
For modules using the older exception handling proposal:
var engine = try wasmz.Engine.init(allocator, .{
.legacy_exceptions = true,
});
Example: Eager Compilation
Compile all functions up front for zero lazy overhead at runtime (useful for small modules or batch workloads):
var engine = try wasmz.Engine.init(allocator, .{
.eager_compile = true,
});
Thread Safety
- Engine - Reference counting is thread-safe (uses
zigrc.Arc). Multiple threads can clone/deinit independently. - Config - Immutable after creation, safe to share across threads.
Note: While reference counting is atomic, concurrent access to the same Engine instance (e.g., calling config()) is not synchronized. Create separate Engine instances per thread if needed.
Lifecycle
The engine must outlive any stores or modules created from it:
var engine = try wasmz.Engine.init(allocator, .{});
defer engine.deinit(); // Must be called after store/module deinit
var store = try wasmz.Store.init(allocator, engine);
defer store.deinit();
var module = try wasmz.Module.compile(engine, bytes);
defer module.deinit();
Module
A Module represents a compiled WebAssembly module (read-only).
Compilation
Basic Compilation
const bytes = try std.fs.cwd().readFileAlloc(allocator, "module.wasm", max_size);
defer allocator.free(bytes);
var module = try wasmz.Module.compile(engine, bytes);
defer module.deinit();
Arc Module (Reference Counted)
For sharing modules across multiple instances:
// Compile with reference counting
var arc_module = try wasmz.Module.compileArc(engine, bytes);
defer if (arc_module.releaseUnwrap()) |m| {
var mod = m;
mod.deinit();
};
// Retain for each instance
var instance = try wasmz.Instance.init(&store, arc_module.retain(), linker);
Methods
| Method | Description |
|---|---|
compile(engine, bytes) | Compile module from bytes |
compileArc(engine, bytes) | Compile with ref counting |
deinit() | Free module resources |
retain() | Increment ref count (Arc only) |
releaseUnwrap() | Decrement ref count (Arc only) |
Module Information
Exports
var iter = module.exports.iterator();
while (iter.next()) |entry| {
std.debug.print("Export: {s}\n", .{entry.key_ptr.*});
}
// Check for specific export
if (module.exports.get("_start")) |_| {
// Command module
}
Imports
for (module.imported_funcs) |import| {
std.debug.print("Import: {s}::{s}\n", .{ import.module_name, import.func_name });
}
Validation
Modules are validated during compilation. Errors are returned:
var module = wasmz.Module.compile(engine, bytes) catch |err| {
switch (err) {
error.InvalidWasm => std.debug.print("Invalid WASM binary\n", .{}),
error.OutOfMemory => std.debug.print("Out of memory\n", .{}),
else => return err,
}
return;
};
Module Types
Command vs Reactor
| Type | Entry Point | Description |
|---|---|---|
| Command | _start | Runs once, may call proc_exit |
| Reactor | _initialize | Library, multiple function calls |
if (module.exports.get("_start")) |_| {
// Command module
} else if (module.exports.get("_initialize")) |_| {
// Reactor module
}
Thread Safety
- Module - Not thread-safe for concurrent access.
- ArcModule - Reference counting is thread-safe (uses
zigrc.Arc). Multiple threads can safely callretain()/releaseUnwrap().
While ArcModule’s reference counting is atomic, the underlying Module data should not be accessed concurrently. Create separate ArcModule handles per thread and avoid sharing the same Module across threads.
Memory Management
- compile(): Module owned by caller, call
deinit()when done - compileArc(): Reference counted, use
retain()/releaseUnwrap()for sharing
// Single instance - use compile()
var module = try wasmz.Module.compile(engine, bytes);
defer module.deinit();
// Multiple instances - use compileArc()
var arc = try wasmz.Module.compileArc(engine, bytes);
var inst1 = try wasmz.Instance.init(&store1, arc.retain(), linker);
var inst2 = try wasmz.Instance.init(&store2, arc.retain(), linker);
// When done
_ = arc.releaseUnwrap(); // Decrements and frees if zero
Store & Instance
The Store holds runtime context, and Instance is an instantiated module.
Store
The store manages the allocator, engine reference, and runtime state.
Initialization
var store = try wasmz.Store.init(allocator, engine);
defer store.deinit();
Properties
| Property | Type | Description |
|---|---|---|
gc_heap | GCHeap | Garbage-collected heap |
memory_budget | MemoryBudget | Memory tracking |
Memory Budget
Link the memory budget for enforcement:
store.linkBudget(); // Enable memory limits
Instance
Initialization
var instance = try wasmz.Instance.init(&store, module, linker);
defer instance.deinit();
Command Model
Run _start:
if (try instance.runStartFunction()) |result| {
switch (result) {
.ok => std.debug.print("Success\n", .{}),
.trap => |t| std.debug.print("Trap: {s}\n", .{t.code}),
}
}
Reactor Model
Initialize and call functions:
// Call _initialize if present
if (try instance.initializeReactor()) |result| {
switch (result) {
.trap => |t| return error.InitFailed,
.ok => {},
}
}
// Call exported function
const result = try instance.call("process", &args);
Methods
| Method | Description |
|---|---|
init(store, module, linker) | Create instance |
deinit() | Free instance |
runStartFunction() | Run _start if present |
initializeReactor() | Run _initialize if present |
call(name, args) | Call exported function |
isCommand() | Returns true if exports _start |
isReactor() | Returns true if no _start export |
Linker
The linker provides host functions to the instance.
Creating Linker
var linker = wasmz.Linker.empty;
defer linker.deinit(allocator);
Adding Host Functions
fn host_add(ctx: ?*anyopaque, hc: *wasmz.HostContext, params: []const wasmz.RawVal, results: []wasmz.RawVal) wasmz.HostError!void {
const a = params[0].readAs(i32);
const b = params[1].readAs(i32);
results[0] = wasmz.RawVal.from(a + b);
}
try linker.define(allocator, "env", "add", wasmz.HostFunc.init(
null,
host_add,
&[_]wasmz.ValType{ .I32, .I32 },
&[_]wasmz.ValType{.I32},
));
RawVal
Generic value type for all numeric WASM types:
const RawVal = wasmz.RawVal;
// Creating values
const v1 = RawVal.from(@as(i32, 42));
const v2 = RawVal.from(@as(i64, 1000000));
const v3 = RawVal.from(@as(f32, 3.14));
const v4 = RawVal.from(@as(f64, 3.14159265359));
// Reading values
const i = v1.readAs(i32);
const j = v2.readAs(i64);
const f = v3.readAs(f32);
const d = v4.readAs(f64);
ExecResult
Result of function execution:
const result = try instance.call("add", &.{ v1, v2 });
switch (result) {
.ok => |val| {
if (val) |v| {
std.debug.print("Result: {d}\n", .{v.readAs(i32)});
}
},
.trap => |t| {
std.debug.print("Trap: {s}\n", .{@tagName(t.code)});
},
}
Complete Example
const std = @import("std");
const wasmz = @import("wasmz");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var engine = try wasmz.Engine.init(allocator, .{});
defer engine.deinit();
const bytes = try std.fs.cwd().readFileAlloc(allocator, "add.wasm", 1024 * 1024);
defer allocator.free(bytes);
var module = try wasmz.Module.compile(engine, bytes);
defer module.deinit();
var store = try wasmz.Store.init(allocator, engine);
defer store.deinit();
var instance = try wasmz.Instance.init(&store, module, .empty);
defer instance.deinit();
const result = try instance.call("add", &.{
RawVal.from(@as(i32, 3)),
RawVal.from(@as(i32, 4)),
});
if (result.ok) |val| {
std.debug.print("3 + 4 = {d}\n", .{val.readAs(i32)});
}
}
Thread Safety
- Store - Not thread-safe. Contains GC heap and mutable runtime state.
- Instance - Not thread-safe. Contains globals, memory, and execution state.
Create separate Store/Instance per thread for parallel execution. The ArcModule reference can be safely shared (retain/release is atomic), but each thread should have its own Store and Instance.
Linker & Host Functions
The linker connects WASM imports to host-provided functions.
Linker
Creating a Linker
var linker = wasmz.Linker.empty;
defer linker.deinit(allocator);
Defining Functions
try linker.define(
allocator,
"module_name", // Import module name
"func_name", // Import function name
wasmz.HostFunc.init(
null, // Context (optional)
host_function, // Function pointer
&[_]wasmz.ValType{ .I32 }, // Parameter types
&[_]wasmz.ValType{ .I32 }, // Result types
),
);
Methods
| Method | Description |
|---|---|
define(alloc, module, name, func) | Register a host function |
get(module, name) | Look up a function |
deinit(alloc) | Free linker resources |
HostFunc
Creating Host Functions
fn host_print(
ctx: ?*anyopaque,
hc: *wasmz.HostContext,
params: []const wasmz.RawVal,
results: []wasmz.RawVal,
) wasmz.HostError!void {
const value = params[0].readAs(i32);
std.debug.print("Value: {d}\n", .{value});
// No return value - results is empty
}
With Context
const MyContext = struct {
counter: u32,
};
fn host_increment(
ctx: ?*anyopaque,
hc: *wasmz.HostContext,
params: []const wasmz.RawVal,
results: []wasmz.RawVal,
) wasmz.HostError!void {
const my_ctx: *MyContext = @ptrCast(@alignCast(ctx.?));
my_ctx.counter += params[0].readAs(u32);
results[0] = wasmz.RawVal.from(@as(i32, @intCast(my_ctx.counter)));
}
var my_ctx = MyContext{ .counter = 0 };
try linker.define(allocator, "env", "increment", wasmz.HostFunc.init(
&my_ctx,
host_increment,
&[_]wasmz.ValType{.I32},
&[_]wasmz.ValType{.I32},
));
HostContext
The HostContext provides access to runtime state:
fn host_memory_access(
ctx: ?*anyopaque,
hc: *wasmz.HostContext,
params: []const wasmz.RawVal,
results: []wasmz.RawVal,
) wasmz.HostError!void {
// Access instance memory
const offset = @as(usize, @intCast(params[0].readAs(u32)));
const memory = hc.instance.memory;
const byte = memory.readByte(offset);
results[0] = wasmz.RawVal.from(@as(i32, byte));
}
Properties
| Property | Description |
|---|---|
instance | Access to the instance |
store | Access to the store |
ValType
Value types for function signatures:
const ValType = enum {
I32,
I64,
F32,
F64,
FuncRef,
ExternRef,
};
WASI Integration
Add WASI functions to the linker:
const wasi = @import("wasi").preview1;
var wasi_host = wasi.Host.init(allocator);
defer wasi_host.deinit();
// Set command-line arguments
try wasi_host.setArgs(&[_][]const u8{ "program.wasm", "--verbose" });
// Add to linker
try wasi_host.addToLinker(&linker, allocator);
Error Handling from Host
Return errors from host functions:
fn host_may_fail(
ctx: ?*anyopaque,
hc: *wasmz.HostContext,
params: []const wasmz.RawVal,
results: []wasmz.RawVal,
) wasmz.HostError!void {
const value = params[0].readAs(i32);
if (value < 0) {
return wasmz.HostError.Trap; // Will cause a trap
}
results[0] = wasmz.RawVal.from(value * 2);
}
Complete Example
const std = @import("std");
const wasmz = @import("wasmz");
fn host_add(
_: ?*anyopaque,
_: *wasmz.HostContext,
params: []const wasmz.RawVal,
results: []wasmz.RawVal,
) wasmz.HostError!void {
const a = params[0].readAs(i32);
const b = params[1].readAs(i32);
results[0] = wasmz.RawVal.from(a + b);
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Setup
var engine = try wasmz.Engine.init(allocator, .{});
defer engine.deinit();
var store = try wasmz.Store.init(allocator, engine);
defer store.deinit();
// Create linker with host function
var linker = wasmz.Linker.empty;
defer linker.deinit(allocator);
try linker.define(allocator, "env", "host_add", wasmz.HostFunc.init(
null,
host_add,
&[_]wasmz.ValType{ .I32, .I32 },
&[_]wasmz.ValType{.I32},
));
// Load and run WASM that imports "env::host_add"
const bytes = try std.fs.cwd().readFileAlloc(allocator, "module.wasm", 1024 * 1024);
defer allocator.free(bytes);
var module = try wasmz.Module.compile(engine, bytes);
defer module.deinit();
var instance = try wasmz.Instance.init(&store, module, linker);
defer instance.deinit();
_ = try instance.runStartFunction();
}
Error Handling
Traps
A Trap represents a runtime error in WebAssembly execution.
TrapCode
pub const TrapCode = enum {
Unreachable,
IntegerDivisionByZero,
IntegerOverflow,
IndirectCallToNull,
UndefinedElement,
UninitializedElement,
OutOfBoundsMemoryAccess,
OutOfBoundsTableAccess,
IndirectCallTypeMismatch,
StackOverflow,
OutOfMemory,
// ... more codes
};
ExecResult
Function calls return an ExecResult:
const result = try instance.call("func", &args);
switch (result) {
.ok => |val| {
// Success - val may be null for void functions
if (val) |v| {
std.debug.print("Result: {d}\n", .{v.readAs(i32)});
}
},
.trap => |trap| {
// Trap occurred
std.debug.print("Trap: {s}\n", .{@tagName(trap.code)});
// Get detailed message
const msg = try trap.allocPrint(allocator);
defer allocator.free(msg);
std.debug.print("Details: {s}\n", .{msg});
},
}
Trap Message
Get a human-readable trap message:
if (result.trap) |trap| {
const msg = try trap.allocPrint(allocator);
defer allocator.free(msg);
std.debug.print("Trap: {s}\n", .{msg});
}
Host Errors
Host functions can return errors:
fn host_divide(
_: ?*anyopaque,
_: *wasmz.HostContext,
params: []const wasmz.RawVal,
results: []wasmz.RawVal,
) wasmz.HostError!void {
const a = params[0].readAs(i32);
const b = params[1].readAs(i32);
if (b == 0) {
return wasmz.HostError.Trap;
}
results[0] = wasmz.RawVal.from(@divTrunc(a, b));
}
Common Errors
Module Compilation
var module = wasmz.Module.compile(engine, bytes) catch |err| {
switch (err) {
error.InvalidWasm => {
std.debug.print("Invalid WASM binary\n", .{});
},
error.UnsupportedFeature => {
std.debug.print("Feature not supported\n", .{});
},
error.OutOfMemory => {
std.debug.print("Out of memory\n", .{});
},
else => return err,
}
return;
};
Instantiation
var instance = wasmz.Instance.init(&store, module, linker) catch |err| {
switch (err) {
error.ImportNotSatisfied => {
std.debug.print("Missing imports:\n", .{});
for (module.imported_funcs) |imp| {
if (linker.get(imp.module_name, imp.func_name) == null) {
std.debug.print(" {s}::{s}\n", .{ imp.module_name, imp.func_name });
}
}
},
error.ImportSignatureMismatch => {
std.debug.print("Import signature mismatch\n", .{});
},
else => return err,
}
return;
};
Memory Limit
When memory limit is exceeded:
var engine = try wasmz.Engine.init(allocator, .{
.mem_limit_bytes = 64 * 1024 * 1024, // 64 MB
});
// If WASM tries to grow memory beyond limit:
// result.trap.code == .OutOfMemory
Stack Overflow
When call stack is exhausted:
// Recursive function without base case
// result.trap.code == .StackOverflow
C API
The C API allows embedding wasmz in any C-compatible language.
Header
#include <wasmz.h>
Lifecycle
Engine
// Create engine with default configuration
wasmz_engine_t *engine = wasmz_engine_new();
// Create engine with a memory limit (bytes)
wasmz_engine_t *engine = wasmz_engine_new_with_limit(256 * 1024 * 1024);
// Destroy (must outlive all stores and modules)
wasmz_engine_delete(engine);
Store
wasmz_store_t *store = wasmz_store_new(engine);
// ... use store ...
wasmz_store_delete(store);
Module
uint8_t *bytes = /* pointer to .wasm bytes */;
size_t len = /* number of bytes */;
wasmz_module_t *module = NULL;
wasmz_error_t *err = wasmz_module_new(engine, bytes, len, &module);
if (err) {
fprintf(stderr, "Compile error: %s\n", wasmz_error_message(err));
wasmz_error_delete(err);
return 1;
}
// When done:
wasmz_module_delete(module);
Instance
// Without host imports
wasmz_instance_t *instance = NULL;
wasmz_error_t *err = wasmz_instance_new(store, module, &instance);
// With host imports (see Linker section below)
wasmz_error_t *err = wasmz_instance_new_with_linker(store, module, linker, &instance);
if (err) {
fprintf(stderr, "Instantiate error: %s\n", wasmz_error_message(err));
wasmz_error_delete(err);
return 1;
}
// When done:
wasmz_instance_delete(instance);
Values
typedef enum {
WASMZ_VAL_I32 = 0,
WASMZ_VAL_I64 = 1,
WASMZ_VAL_F32 = 2,
WASMZ_VAL_F64 = 3,
WASMZ_VAL_V128 = 4,
WASMZ_VAL_REF_NULL = 5,
WASMZ_VAL_REF_FUNC = 6,
WASMZ_VAL_EXTERN_REF = 7,
} wasmz_val_kind_t;
typedef struct {
wasmz_val_kind_t kind;
uint8_t _pad[4];
union {
int32_t i32;
int64_t i64;
float f32;
double f64;
uint8_t v128[16]; // 128-bit SIMD vector (little-endian lane order)
uint32_t func_ref;
void *extern_ref;
} of;
} wasmz_val_t;
// Convenience constructors
wasmz_val_t v = wasmz_val_i32(42);
wasmz_val_t v = wasmz_val_i64(1000000);
wasmz_val_t v = wasmz_val_f32(3.14f);
wasmz_val_t v = wasmz_val_f64(3.14159);
Function Calls
Command Model
// Run _start
wasmz_error_t *err = wasmz_instance_call_start(instance);
if (err) {
fprintf(stderr, "Error: %s\n", wasmz_error_message(err));
wasmz_error_delete(err);
}
Reactor Model
// Initialize
wasmz_error_t *err = wasmz_instance_initialize(instance);
if (err) {
fprintf(stderr, "Init error: %s\n", wasmz_error_message(err));
wasmz_error_delete(err);
return 1;
}
// Call a function
wasmz_val_t args[2] = { wasmz_val_i32(3), wasmz_val_i32(4) };
wasmz_val_t result;
err = wasmz_instance_call(instance, "add", args, 2, &result, 1);
if (err) {
fprintf(stderr, "Call error: %s\n", wasmz_error_message(err));
wasmz_error_delete(err);
return 1;
}
printf("Result: %d\n", result.of.i32);
Module Type Detection
if (wasmz_instance_is_command(instance)) {
// Has _start export — run once
wasmz_instance_call_start(instance);
}
if (wasmz_instance_is_reactor(instance)) {
// No _start export — library mode
wasmz_instance_initialize(instance);
wasmz_instance_call(instance, "func", args, n, results, m);
}
Linker (Host Functions)
The linker lets you register host-provided functions that the WASM module can import.
Creating a Linker
wasmz_linker_t *linker = wasmz_linker_new();
// ... define functions and globals ...
// Pass to wasmz_instance_new_with_linker(), then:
wasmz_linker_delete(linker);
Defining a Host Function
// Callback type:
// host_data — opaque user pointer passed at registration
// ctx — host context; use wasmz_context_* to access memory
// params — input values (array of length param_count)
// results — output values to fill (array of length result_count)
// return 0 — success; non-zero triggers a trap
int my_add(void *host_data, void *ctx,
const wasmz_val_t *params, size_t param_count,
wasmz_val_t *results, size_t result_count)
{
results[0] = wasmz_val_i32(params[0].of.i32 + params[1].of.i32);
return 0;
}
wasmz_val_kind_t param_kinds[] = { WASMZ_VAL_I32, WASMZ_VAL_I32 };
wasmz_val_kind_t result_kinds[] = { WASMZ_VAL_I32 };
wasmz_error_t *err = wasmz_linker_define_func(
linker,
"env", "add", // import module :: function name
param_kinds, 2,
result_kinds, 1,
my_add, NULL // callback, optional user data
);
if (err) {
fprintf(stderr, "define_func: %s\n", wasmz_error_message(err));
wasmz_error_delete(err);
}
Defining a Global Import
wasmz_error_t *err = wasmz_linker_define_global(
linker,
"env", "my_global",
wasmz_val_i32(42)
);
Instantiating with a Linker
wasmz_instance_t *instance = NULL;
wasmz_error_t *err = wasmz_instance_new_with_linker(store, module, linker, &instance);
if (err) {
fprintf(stderr, "Instantiate error: %s\n", wasmz_error_message(err));
wasmz_error_delete(err);
return 1;
}
Host Context
Inside a host function callback, use wasmz_context_* to safely read and write the WASM instance’s linear memory:
int host_strlen(void *host_data, void *ctx,
const wasmz_val_t *params, size_t param_count,
wasmz_val_t *results, size_t result_count)
{
uint32_t ptr = (uint32_t)params[0].of.i32;
uint8_t *mem = wasmz_context_memory(ctx);
size_t size = wasmz_context_memory_size(ctx);
if (!mem || ptr >= size) {
wasmz_context_trap(ctx, "out of bounds pointer");
return 1;
}
int32_t len = 0;
while (ptr + len < size && mem[ptr + len] != '\0') len++;
results[0] = wasmz_val_i32(len);
return 0;
}
Context Functions
| Function | Description |
|---|---|
wasmz_context_memory(ctx) | Raw pointer to linear memory (NULL if none) |
wasmz_context_memory_size(ctx) | Size of linear memory in bytes |
wasmz_context_read_memory(ctx, addr, len, out) | Bounds-checked read (returns 0 on success) |
wasmz_context_write_memory(ctx, addr, data, len) | Bounds-checked write (returns 0 on success) |
wasmz_context_read_value(ctx, addr, out, size) | Read a typed value from memory |
wasmz_context_write_value(ctx, addr, value, size) | Write a typed value to memory |
wasmz_context_trap(ctx, msg) | Raise a trap from inside the callback |
Linear Memory (from outside host functions)
You can also access an instance’s memory after instantiation:
uint8_t *mem = wasmz_instance_memory(instance); // NULL if no memory
size_t size = wasmz_instance_memory_size(instance);
// Grow memory by pages (1 page = 64 KiB)
int32_t ok = wasmz_instance_memory_grow(instance, 1); // 0 on success
Module Introspection
// Does the module define a linear memory?
int has_mem = wasmz_module_has_memory(module);
// Initial and maximum page counts
uint32_t min_pages = wasmz_module_memory_min(module);
uint32_t max_pages = wasmz_module_memory_max(module); // UINT32_MAX = unlimited
// Enumerate exported function names
size_t n = wasmz_module_export_count(module);
for (size_t i = 0; i < n; i++) {
const char *name = wasmz_module_export_name(module, i);
printf("export[%zu]: %s\n", i, name);
}
Store User Data
Attach arbitrary state to a store for retrieval inside host callbacks:
wasmz_store_set_user_data(store, my_state_ptr);
// ... later, inside a host function or after a call:
void *state = wasmz_store_get_user_data(store);
VM Statistics
wasmz_vm_stats_t stats;
wasmz_instance_vm_stats(instance, &stats);
printf("value stack : %zu bytes (%zu slots)\n",
stats.val_stack_bytes, stats.val_stack_slots);
printf("call stack : %zu bytes (%zu frames)\n",
stats.call_stack_bytes, stats.call_stack_frames);
Error Handling
All fallible functions return wasmz_error_t *. NULL means success.
wasmz_error_t *err = wasmz_module_new(engine, bytes, len, &module);
if (err != NULL) {
fprintf(stderr, "Error: %s\n", wasmz_error_message(err));
wasmz_error_delete(err);
return 1;
}
Complete Example
#include <stdio.h>
#include <stdlib.h>
#include "wasmz.h"
// Host function: prints an i32 from WASM
static int host_print(void *data, void *ctx,
const wasmz_val_t *params, size_t param_count,
wasmz_val_t *results, size_t result_count)
{
printf("wasm says: %d\n", params[0].of.i32);
return 0;
}
int main(int argc, char **argv) {
if (argc < 2) {
fprintf(stderr, "Usage: %s <file.wasm>\n", argv[0]);
return 1;
}
// Load file
FILE *f = fopen(argv[1], "rb");
if (!f) { perror("fopen"); return 1; }
fseek(f, 0, SEEK_END);
size_t len = (size_t)ftell(f);
fseek(f, 0, SEEK_SET);
uint8_t *bytes = malloc(len);
fread(bytes, 1, len, f);
fclose(f);
// Engine + store
wasmz_engine_t *engine = wasmz_engine_new();
wasmz_store_t *store = wasmz_store_new(engine);
// Compile
wasmz_module_t *module = NULL;
wasmz_error_t *err = wasmz_module_new(engine, bytes, len, &module);
free(bytes);
if (err) {
fprintf(stderr, "Compile: %s\n", wasmz_error_message(err));
wasmz_error_delete(err);
wasmz_store_delete(store);
wasmz_engine_delete(engine);
return 1;
}
// Register a host function and instantiate
wasmz_linker_t *linker = wasmz_linker_new();
wasmz_val_kind_t p[] = { WASMZ_VAL_I32 };
err = wasmz_linker_define_func(linker, "env", "print", p, 1, NULL, 0, host_print, NULL);
if (err) {
fprintf(stderr, "define_func: %s\n", wasmz_error_message(err));
wasmz_error_delete(err);
goto cleanup;
}
wasmz_instance_t *instance = NULL;
err = wasmz_instance_new_with_linker(store, module, linker, &instance);
if (err) {
fprintf(stderr, "Instantiate: %s\n", wasmz_error_message(err));
wasmz_error_delete(err);
goto cleanup;
}
// Run
if (wasmz_instance_is_command(instance)) {
err = wasmz_instance_call_start(instance);
} else {
err = wasmz_instance_initialize(instance);
if (!err) {
wasmz_val_t result;
err = wasmz_instance_call(instance, "run", NULL, 0, &result, 0);
}
}
if (err) {
fprintf(stderr, "Runtime: %s\n", wasmz_error_message(err));
wasmz_error_delete(err);
}
wasmz_instance_delete(instance);
cleanup:
wasmz_linker_delete(linker);
wasmz_module_delete(module);
wasmz_store_delete(store);
wasmz_engine_delete(engine);
return 0;
}
Building
# Build the C shared library
zig build clib
# Compile your program against it
gcc -o myapp myapp.c -Lzig-out/lib -lwasmz -Izig-out/include
Thread Safety
- Engine — Reference counting is thread-safe. Do not access the same engine instance concurrently.
- Module — Reference counting is thread-safe. The module data is read-only after compilation and safe to share.
- Store — Not thread-safe. Contains GC heap and mutable runtime state.
- Instance — Not thread-safe. Contains mutable execution state.
For multi-threaded applications, create a separate Store and Instance per thread. Sharing a compiled Module across threads is safe.
WASI Support
wasmz implements WASI Preview 1 (snapshot 1), enabling WebAssembly modules to interact with the host system.
Implementation Status
All WASI Preview 1 functions are implemented:
Environment
| Function | Status |
|---|---|
args_get | ✅ |
args_sizes_get | ✅ |
environ_get | ✅ |
environ_sizes_get | ✅ |
Clock
| Function | Status |
|---|---|
clock_res_get | ✅ |
clock_time_get | ✅ |
File Descriptors
| Function | Status |
|---|---|
fd_advise | ✅ |
fd_allocate | ✅ |
fd_close | ✅ |
fd_datasync | ✅ |
fd_fdstat_get | ✅ |
fd_fdstat_set_flags | ✅ |
fd_fdstat_set_rights | ✅ |
fd_filestat_get | ✅ |
fd_filestat_set_size | ✅ |
fd_filestat_set_times | ✅ |
fd_pread | ✅ |
fd_prestat_get | ✅ |
fd_prestat_dir_name | ✅ |
fd_pwrite | ✅ |
fd_read | ✅ |
fd_readdir | ✅ |
fd_renumber | ✅ |
fd_seek | ✅ |
fd_sync | ✅ |
fd_tell | ✅ |
fd_write | ✅ |
Path Operations
| Function | Status |
|---|---|
path_create_directory | ✅ |
path_filestat_get | ✅ |
path_filestat_set_times | ✅ |
path_link | ✅ |
path_open | ✅ |
path_readlink | ✅ |
path_remove_directory | ✅ |
path_rename | ✅ |
path_symlink | ✅ |
path_unlink_file | ✅ |
Polling
| Function | Status |
|---|---|
poll_oneoff | ✅ |
Process
| Function | Status |
|---|---|
proc_exit | ✅ |
proc_raise | ✅ |
sched_yield | ✅ |
Random
| Function | Status |
|---|---|
random_get | ✅ |
Sockets
| Function | Status |
|---|---|
sock_accept | ✅ |
sock_recv | ✅ |
sock_send | ✅ |
sock_shutdown | ✅ |
CLI Integration
Passing Arguments
# Pass arguments to WASM module
wasmz program.wasm --args "arg1 arg2 'arg with spaces'"
Environment Variables
Environment variables are automatically inherited from the host process.
Zig API Integration
const wasi = @import("wasi").preview1;
// Create WASI host
var wasi_host = wasi.Host.init(allocator);
defer wasi_host.deinit();
// Set arguments
try wasi_host.setArgs(&[_][]const u8{
"program.wasm",
"--verbose",
"input.txt",
});
// Set environment variables
try wasi_host.setEnv("MY_VAR", "value");
// Pre-open directory
try wasi_host.preopenDir("/data", "/data");
// Add to linker
var linker = wasmz.Linker.empty;
try wasi_host.addToLinker(&linker, allocator);
Pre-opened Directories
WASI uses pre-opened directories for filesystem access. By default:
/is pre-opened as the current working directory- Additional directories can be pre-opened via the API
// Pre-open specific directories
try wasi_host.preopenDir("/tmp", "/tmp");
try wasi_host.preopenDir("/home/user/data", "/data");
Exit Code
When a WASM module calls proc_exit, the exit code is returned:
const result = try instance.call("_start", &.{});
if (result.trap) |trap| {
if (trap.code == .Exit) {
const exit_code = trap.exit_code;
// Handle exit code
}
}
Exit Callback
Register a callback for proc_exit:
fn onExit(code: u32, ctx: ?*anyopaque) void {
std.debug.print("Module exited with code {d}\n", .{code});
}
wasi_host.setOnExit(onExit, &my_context);
Preview 2
WASI Preview 2 (Component Model) is not yet supported.
Currently, almost no languages are actually using the Preview2 proposal either, Planned for future releases.
Architecture
This section describes wasmz’s internal architecture for contributors.
Overview
┌─────────────────────────────────────────────────────────────┐
│ Public API │
│ (root.zig - Engine, Module, Store, Instance, Linker) │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ wasmz Module │
│ (High-level API implementation) │
└─────────────────────────────────────────────────────────────┘
│
┌──────────────┬──────────────┼──────────────┬───────────────┐
│ Parser │ Compiler │ VM │ WASI │
│ │ │ │ │
│ Binary │ Stack-to- │ Interpreter │ Preview 1 │
│ Parser │ Register │ Engine │ Host │
│ │ Compiler │ │ │
└──────────────┴──────────────┴───────────────┴───────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ Core Types │
│ (Value types, ref types, heap types, trap, etc.) │
└─────────────────────────────────────────────────────────────┘
Pipeline
- Parse - Binary parser reads the WASM module
- Compile - Stack-to-register IR transformation
- Execute - VM interpreter runs compiled IR
Note: wasmz does not implement a validator. Use external tools like wasm-tools to validate WASM modules before execution.
Key Directories
| Directory | Purpose |
|---|---|
src/core/ | Core data types (types, values, traps) |
src/parser/ | WASM binary parser |
src/compiler/ | IR generation and optimization |
src/engine/ | Function type handling, config |
src/vm/ | Virtual machine, GC heap |
src/wasmz/ | High-level API implementation |
src/wasi/ | WASI system interface |
src/validator/ | Placeholder (not implemented) |
src/libs/ | Vendored dependencies |
Next Sections
- Parser - Binary parsing implementation
- Compiler - Stack-to-register compilation
- VM & Execution - Interpreter execution engine
- Garbage Collection - GC heap implementation
Parser
The parser reads WebAssembly binary format incrementally.
Design
The parser is designed for streaming - it can parse modules larger than available memory by processing sections incrementally.
Interface
const Parser = parser_mod.Parser.init();
while (true) {
const n = try reader.readSliceShort(pending_buf[pending_len..]);
const eof = n == 0;
var input = pending_buf[0 .. pending_len + n];
while (true) {
switch (parser.parse(input, eof)) {
.parsed => |result| {
// Handle payload
switch (result.payload) {
.module_header => |header| { /* ... */ },
.func => |func| { /* ... */ },
.code_section => |code| { /* ... */ },
else => {},
}
input = input[result.consumed..];
},
.need_more_data => {
// Buffer remaining data and read more
break;
},
.end => return,
.err => |e| return e,
}
}
}
Payloads
The parser emits payloads for each parsed element:
| Payload | Description |
|---|---|
module_header | Module magic and version |
type_section | Function types |
import_section | Imports |
func_section | Function declarations |
code_section | Function bodies |
data_section | Data segments |
elem_section | Element segments |
global_section | Globals |
memory_section | Memories |
table_section | Tables |
export_section | Exports |
start_section | Start function |
Validation
wasmz does not implement a full validator. Use external tools to validate WASM modules:
- wasm-tools - Recommended
- wabt - WebAssembly Binary Toolkit
Basic sanity checks are performed during parsing (e.g., section structure, index bounds).
Memory Model
The parser allocates:
- Payload data - Temporary, freed after handling
- Module metadata - Types, imports, exports (owned by Module)
Key Files
| File | Purpose |
|---|---|
src/parser/root.zig | Parser implementation |
src/parser/payload.zig | Payload types |
src/parser/range.zig | Source ranges for debugging |
src/parser/helper.zig | Parsing utilities |
Parallel Compilation
The parser can trigger parallel compilation:
// For each code section, after parsing body:
// 1. validate body
// 2. lower body (compile to register machine)
// These can run in separate threads
Extension Proposals
New proposals are handled by:
- Adding new payload types
- Adding new opcodes to the compiler
- Adding new validation rules
Compiler
The compiler transforms WebAssembly’s stack machine into a register-based IR for efficient interpretation.
Purpose
WebAssembly is a stack machine - values are pushed and popped from an operand stack. This is inefficient for interpretation. The compiler transforms this into a register-based IR where values are stored in named registers.
Pipeline
WASM Bytes → Parser → Stack Instructions → Validator → Compiler → Register IR
↓
Optimizer
↓
Code Gen (bytecode)
Lowering Process
Stack Machine
local.get 0
local.get 1
i32.add
local.set 2
Register IR
r0 = local[0]
r1 = local[1]
r2 = i32_add(r0, r1)
local[2] = r2
IR Structure
const IR = struct {
instructions: []Instruction,
registers: RegisterInfo,
locals: []LocalInfo,
blocks: []BlockInfo,
};
const Instruction = struct {
opcode: Opcode,
dst: ?Register,
src1: ?Register,
src2: ?Register,
// ...
};
Key Files
| File | Purpose |
|---|---|
src/compiler/root.zig | Compiler entry point |
src/compiler/ir.zig | IR data structures |
src/compiler/translate.zig | Stack-to-IR translation |
src/compiler/lower.zig | Modern lowering |
src/compiler/lower_legacy.zig | Legacy EH lowering |
src/compiler/value_stack.zig | Simulated operand stack |
Internal Checks
The compiler performs internal checks during lowering (not full WASM validation):
- Type checking - Operands match instruction requirements
- Reachability - Unreachable code is handled
- Block typing - Block inputs/outputs match
Note: These are runtime checks for compilation, not WASM specification validation. Use external tools for full validation.
Block Handling
Blocks (block, loop, if) are compiled with:
- Separate register scopes
- Branch targets
- Result values
// block $b (result i32)
// ... instructions ...
// br $b (value)
// end
Exception Handling
Two proposals are supported:
New Proposal
try $label
... instructions ...
catch $label
... exception handler ...
end
Legacy Proposal
try
... instructions ...
catch
... handler ...
rethrow
delegate $label
end
Controlled by Config.legacy_exceptions.
Thread Safety
Compilation of function bodies can be parallelized:
// Each code section can be compiled independently
for (module.functions) |func, i| {
// spawn thread for compile(func)
}
SIMD
SIMD instructions are handled specially:
- Vector operations execute directly
- SIMD-specific lowering rules
See src/core/simd/ for implementation.
Optimization
Current optimizations:
- Dead code elimination - Remove unreachable instructions
- Register coalescing - Reduce register count
- Constant folding - Evaluate constants at compile time
Future optimizations:
- Value numbering
- Common subexpression elimination
VM & Execution
The virtual machine executes compiled IR.
Execution Model
The VM is a register-based interpreter:
- Load compiled bytecode
- Execute instructions sequentially
- Handle branches and calls
- Return results or traps
Key Components
Function Execution
pub fn executeFunction(
store: *Store,
func: *const Func,
args: []const RawVal,
) ExecResult {
// Setup frame
// Execute instructions
// Return result
}
Instruction Dispatch
switch (opcode) {
.I32Add => {
const a = frame.getRegister(inst.src1).readAs(i32);
const b = frame.getRegister(inst.src2).readAs(i32);
frame.setRegister(inst.dst, RawVal.from(a + b));
},
.Call => {
// Handle function call
},
.Br => {
// Handle branch
},
// ...
}
Call Stack
Each call creates a frame:
const Frame = struct {
return_ip: usize,
return_frame: ?*Frame,
locals: []RawVal,
module: *const Module,
func_index: u32,
};
Memory
Linear memory is managed per-instance:
const Memory = struct {
data: []u8,
min: u32,
max: ?u32,
pub fn readByte(self: *Memory, offset: usize) u8;
pub fn writeByte(self: *Memory, offset: usize, value: u8);
pub fn grow(self: *Memory, pages: u32) !void;
};
Table
Tables store function references for indirect calls:
const Table = struct {
elements: []?FuncRef,
min: u32,
max: ?u32,
};
Global
Globals store mutable state:
const Global = struct {
value: RawVal,
mutable: bool,
};
Traps
Traps abort execution with an error code:
pub const TrapCode = enum {
Unreachable,
IntegerDivisionByZero,
IntegerOverflow,
// ...
};
Key Files
| File | Purpose |
|---|---|
src/vm/root.zig | VM entry point, ExecResult |
src/engine/root.zig | Engine implementation |
src/engine/func_ty.zig | Function type handling |
src/engine/code_map.zig | Compiled code storage |
Branch Handling
Branches use continuation-passing style:
// br $label
// Jump to block label, pass values
const block = frame.getBlock(inst.label);
frame.setValues(block.params);
frame.ip = block.start;
Function Calls
Direct Calls
// call $func
const callee = module.getFunc(inst.func_index);
try pushFrame(callee, args);
Indirect Calls
// call_indirect $type
const table_index = frame.getRegister(inst.src).readAs(u32);
const func_ref = table.elements[table_index];
const sig = module.types[inst.type_index];
if (!func_ref.signature.matches(sig)) {
return Trap{ .code = .IndirectCallTypeMismatch };
}
try pushFrame(func_ref, args);
Host Calls
Host functions are called through HostFunc:
pub const HostFunc = struct {
context: ?*anyopaque,
callback: *const fn (...) HostError!void,
param_types: []const ValType,
result_types: []const ValType,
};
Garbage Collection
wasmz implements the WebAssembly GC proposal with a managed heap.
GC Proposal Overview
The GC proposal adds:
- Structs - Fixed-size records with typed fields
- Arrays - Variable-size sequences of typed elements
- Reference Types - References to GC heap objects
GC Algorithm
wasmz uses a tri-color mark-and-sweep collector with an explicit worklist.
Allocation Strategy
The heap uses a free-list allocator with bump allocation fallback:
- Free-list search - Find a block >= requested size
- Block splitting - Split if remaining space can hold another FreeBlock
- Bump allocation - Fallback when no suitable free block exists
pub const GcHeap = struct {
bytes: []u8, // Contiguous byte buffer
free_list: FreeList, // Singly-linked list of free blocks
used: u32, // Bytes currently in use
live_objects: ArrayList(AllocationInfo), // Track all allocations
};
Collection Phases
Phase 1: Mark
- Seed worklist with root references (call frames, globals)
- Process worklist iteratively (BFS traversal):
- Pop object from worklist
- Mark object by setting mark bit in header
- Enqueue all child references
- Continue until worklist is empty
Phase 2: Sweep
- Iterate all live_objects in reverse
- If marked: clear mark bit (still live)
- If unmarked: free the block, remove from live list
pub fn collect(
self: *Self,
roots: []const GcRef,
composite_types: []const CompositeType,
struct_layouts: []const ?StructLayout,
array_layouts: []const ?ArrayLayout,
) void {
// Mark phase: iterative BFS with explicit worklist
var worklist = ArrayList(u32){};
for (roots) |ref| {
if (ref.isHeapRef()) {
const hdr = self.header(ref.asHeapIndex());
if (!hdr.isMarked()) {
hdr.setMark();
worklist.append(ref.asHeapIndex());
}
}
}
while (worklist.pop()) |idx| {
// Trace child references...
}
// Sweep phase
for (live_objects) |info| {
if (hdr.isMarked()) {
hdr.clearMark();
} else {
self.free(info.index, info.size);
}
}
}
Why Tri-Color Mark-and-Sweep?
- No stack overflow - Explicit worklist avoids deep recursion
- Simple implementation - Two-phase algorithm is easy to understand
- Incremental potential - Worklist design allows future incremental collection
Object Layout
Each GC object has:
- Header (8 bytes) - Metadata for GC and type information
- Payload - Field data for structs, length + elements for arrays
const GcHeader = struct {
kind_bits: u32, // High 6 bits = GcKind, bit 0 = mark bit
type_index: u32, // Type index for concrete types
};
GcKind
High 6 bits identify the object kind for subtype checking:
| Kind | Description |
|---|---|
Any | Top type for references |
Eq | Equality comparable types |
I31 | Unboxed 31-bit integer |
Struct | Struct object |
Array | Array object |
Func | Function reference |
Extern | External reference |
Exception | Exception object (internal) |
Mark Bit
Bit 0 of kind_bits is used for the GC mark phase:
fn setMark(self: *GcHeader) void {
self.kind_bits |= MARK_BIT;
}
fn isMarked(self: GcHeader) bool {
return (self.kind_bits & MARK_BIT) != 0;
}
GcRef
References are encoded as 32-bit indices:
const GcRef = struct {
// Index 0 = null
// High bits encode the kind (heap, i31, func, extern)
pub fn isHeapRef(self: GcRef) bool;
pub fn asHeapIndex(self: GcRef) ?u32;
pub fn encode(index: u32) GcRef;
};
Heap Types
const HeapType = union(enum) {
func: void,
extern: void,
any: void,
eq: void,
i31: void,
struct_type: *StructType,
array_type: *ArrayType,
// ...
};
Structs
const StructType = struct {
fields: []FieldType,
const FieldType = struct {
type: ValType,
mutable: bool,
};
};
WASM Example
(type $point (struct (field i32) (field i32)))
(func $create_point (param $x i32) (param $y i32) (result (ref $point))
struct.new $point
local.get $x
local.get $y
)
(func $get_x (param $p (ref $point)) (result i32)
local.get $p
struct.get $point 0
)
Arrays
const ArrayType = struct {
element_type: ValType,
mutable: bool,
};
WASM Example
(type $int_array (array (mut i32)))
(func $create_array (param $len i32) (result (ref $int_array))
local.get $len
i32.const 0
array.new $int_array
)
(func $get_element (param $arr (ref $int_array)) (param $idx i32) (result i32)
local.get $arr
local.get $idx
array.get $int_array
)
i31 References
Small integers stored unboxed:
const i31ref = struct {
value: i31, // 31-bit signed integer
};
WASM Example
(func $wrap_i31 (param $i i32) (result (ref i31))
ref.i31
local.get $i
)
(func $unwrap_i31 (param $r (ref i31)) (result i32)
local.get $r
i31.get_s
)
Key Files
| File | Purpose |
|---|---|
src/vm/gc/root.zig | GC entry point |
src/vm/gc/heap.zig | Heap management |
src/vm/gc/header.zig | Object header |
src/vm/gc/layout.zig | Object layout calculation |
src/core/gc_ref.zig | Reference type |
src/core/heap_type.zig | Heap type definitions |
src/core/ref_type.zig | Reference type definitions |
Memory Management
Allocation
// In struct.new or array.new
const total_size = HEADER_SIZE + payload_size;
const ref = gc_heap.alloc(total_size) orelse return error.OutOfMemory;
// Initialize header
const hdr = gc_heap.getHeader(ref);
hdr.kind_bits = GcKind.Struct;
hdr.type_index = type_index;
// Initialize fields...
Heap Growth
The heap grows exponentially (2x) when full:
const new_len = @max(min_needed, current_size * 2);
self.bytes = self.allocator.realloc(self.bytes, new_len);
Collection Trigger
Collection is triggered when memory limit is exceeded:
if (self.budget) |b| {
if (!b.canGrow(additional)) {
// Trigger GC and retry
gc_heap.collect(roots, composite_types, layouts);
}
}
No Write Barriers
Since wasmz uses mark-and-sweep, no write barriers are needed. References are traced during the mark phase by walking the object graph from roots.
Contributing
Thank you for your interest in contributing to wasmz!
Development Setup
Prerequisites
- Zig 0.15.2
- Git
Clone and Build
git clone https://github.com/anomalyco/wasmz.git
cd wasmz
make build
Running Tests
# Run all tests
make test
Project Structure
src/
├── root.zig # Public API exports
├── main.zig # CLI implementation
├── capi.zig # C API implementation
├── core/ # Core types (no dependencies)
│ ├── root.zig
│ ├── func_type.zig
│ ├── ref_type.zig
│ ├── heap_type.zig
│ ├── trap.zig
│ └── ...
├── parser/ # WASM binary parser
│ ├── root.zig
│ ├── payload.zig
│ └── tests/
├── compiler/ # Stack-to-register compiler
│ ├── root.zig
│ ├── ir.zig
│ ├── translate.zig
│ └── tests/
├── engine/ # Execution engine
│ ├── root.zig
│ ├── config.zig
│ └── func_ty.zig
├── vm/ # Virtual machine
│ ├── root.zig
│ └── gc/
├── wasmz/ # High-level API
│ ├── module.zig
│ ├── store.zig
│ ├── instance.zig
│ ├── host.zig
│ └── tests/
├── wasi/ # WASI implementation
│ ├── root.zig
│ └── preview1/
└── libs/ # Vendored dependencies
└── zigrc/
License
By contributing, you agree that your contributions will be licensed under the MIT License.