Understanding Winch’s Fallback Compiler Path
Winch is Shopify’s baseline compiler for WebAssembly. Its job is to generate machine code quickly — usually in under a millisecond — trading peak performance for fast compilation. To keep the implementation simple, many rarely used instructions aren’t implemented directly. Instead, Winch relies on Cranelift’s existing wasm-to-Cranelift-IR lowering to generate code for those cases. This fallback still works correctly, but the extra layer adds overhead.
The strategy when adding a new instruction is systematic. First, you check the Wasm spec for the instruction’s operand stack behavior. Then you need to find or implement a Cranelift instruction that matches the operation, add a low-level rule in Winch for that IR instruction, and finally wire up a code generation path with the proper masks and type checks. The bulk of the effort usually lands in finding the right pre-existing MachInst lowering in the ISA backend you target.
Selecting a Target Instruction
The first decision point is choosing a concrete example that’s worth implementing from scratch. A good candidate is one that is exercised often enough to justify the work, but whose semantics leave some freedom in implementation. Clz (count leading zeros) on the i32 type fits that bill. Wasm defines i32.clz as counting the number of zero bits before the first one bit; for the input 0, the result is 32.
Cranelift exposes a clz instruction that operates on its own types. The CPU instruction you’d use on x86-64 is LZCNT, but Winch’s target is more general — you need a lowering that works across ISAs. The trick is that LZCNT doesn’t produce the same result as the Wasm spec on zero input, so you must handle the zero case explicitly.
Implementing the Code Generation
The approach starts by writing a helper that emits the ISA-specific instruction sequence. In practice you use cranelift_codegen::isa::unwind::winx64 and the generic Inst enum. For x86-64, the sequence is three instructions: a test to set flags, a lzcnt, and then a conditional move to replace the result with 32 when the input was zero. That’s a few CPU cycles of work, but it’s still constant time and much cheaper than the fallback path.
The example also touches on broader patterns. When you want to contribute a new instruction to Winch, you modify the winch inst module to emit the operation. The wasm frontend translates the Wasm bytecode directly. Each new instruction requires care in three places: the operand stack encoding, the result type handling, and the possible trap conditions.
In the case of i32.clz, there are no traps, and the encoding is a single wasm opcode followed by an unused byte that the validator requires. But for something like i32.div_s, the divisor zero condition introduces an explicit trap check that Winch must lower. The structure becomes visible when you look at how the interpreter route is tested: unit tests compare Winch’s output to the Cranelift interpreter on exhaustive cases, so the test suite gives fast feedback.
Testing and Verification Strategy
Testing a new lowering has two levels. At the unit level, you construct the IR and check the emitted machine code via the final assembly. A typical loop runs through all 32 input bit patterns, verifying both the stack value and that the correct flags are set. The key is that stack checks confirm the type system worked correctly, not just the value.
The second level is within a full Wasm function. You write a module for each instruction, then use a wasmtime version built with Winch’s execution strategy. Adding Winch support is usually incremental, so you compare against the non-Winch, interpreter-backed mode over random inputs. Running the entire spec suite for the instruction category guarantees compliance with the byte-for-byte semantics.
A simpler intermediate step is the speculative “call-trampoline” test — invoke the wasm through a direct entry point that exercises the Winch fastpath. For example, after clz code is added, you’d write a caller that passes 0 through and assert you receive 32. This triggers the actual machine code path including the conditional move.
The final gate is running the full Wasm spec test suite. For clz, the JS API surface must still line up. That means tests live under tests/spec_testsuite/proposals, where each .wasm file has a matching .assert_return entry. You add both textual and binary encodings to catch the parser and decoder drift.
What this dissection shows is that contributing a Wasm instruction to Winch isn’t magic. You invert the process you’d use for writing a normal Cranelift instruction: you start from the emitted web assembly bytecode, translate it through the wasm parser, and ensure code emission matches the architecture’s native idioms. With the wide variety in Wasm’s numeric instructions — from clz and ctz to more niche rotl/rotr — the fastest route into the codebase is picking one with a clean spec and a relatively low-level Cranelift rule to exercise.
How Winch Grows Its Instruction Set
Winch, Wasmtime's baseline compiler, exists to make WebAssembly compilation fast. It does that by trading optimization for speed: instead of spending time finding the best possible machine code, it emits straightforward native code for each Wasm instruction it encounters. That design choice puts a premium on having broad instruction coverage, and adding a new instruction touches several distinct layers of the compiler.
A recent pull request from Shopify developer Jeff Charles added support for a batch of Wasm instructions to Winch. The work illustrates the full path an instruction takes through the codebase, from parsing to code emission to testing.
Layer One: The Visitor
Winch parses Wasm modules using the wasmparser crate. When parsing a function body, that crate can be configured to invoke an implementation of its VisitOperator trait, which has visit methods for every Wasm instruction. Winch implements that trait in a visitor module under its codegen package.
To support a new instruction, the visitor needs a corresponding method that dispatches into the rest of the codegen pipeline. The visitor delegates to two components: CodeGenContext and MacroAssembler. A typical visitor method for the i32.clz instruction, which counts leading zeros in the value at the top of the Wasm stack, is small — it sets up the call chain and lets the lower layers do the real work.
CodeGenContext: Stack and Register Bookkeeping
CodeGenContext coordinates three things: the register allocator, the value stack, and the frame of the function being compiled. Values on the Wasm stack can be constants, registers, indices into Wasm locals, or offsets in linear memory. The register allocator tracks which CPU registers are in use and hands them out on request.
When no register is free, the allocator can spill a value. Spilling moves a value from a register into a memory offset and then updates every reference to that register on the value stack so it points at the new location. The net effect is that the register becomes available while the value survives on the stack. Spills cost extra instructions, so the goal is to keep register pressure low. CodeGenContext methods exist to pop operands off the stack and, where possible, pass them as immediates (constants embedded directly in an instruction, like the 10 in add eax, 10) rather than loading them into registers. This layer is deliberately ISA-agnostic; architecture-specific logic belongs in the MacroAssembler.
MacroAssembler: ISA Independence, Architecture Dependence
The MacroAssembler is an architecture-independent interface for emitting native instructions. Winch maintains one implementation per supported ISA (currently x86_64 and AArch64), so a single Wasm instruction can lower to different machine code on different platforms. When the MacroAssembler needs to emit an actual instruction, it delegates to an ISA-specific assembler built on Cranelift's machine code emission layer.
Some Wasm instructions map cleanly onto a single machine instruction, and the MacroAssembler can simply pass through to the assembler. Others need more care:
- Shift left (
shl) requires the shift-count operand to live in theclregister unless the count is an immediate, so theMacroAssemblerhas to move operands into the right places before emitting the shift. - Wasm's
eqcompiles to an x86_64cmpfollowed by an instruction that copies the zero flag into a destination register. clz(count leading zeros) normally maps to thelzcntinstruction. Since some x86_64 CPUs lacklzcnt, theMacroAssemblerchecks CPU capability at compile time and falls back to a sequence built onbsr(bit scan reverse) when needed.
The fallback path is a good example of the subtleties involved. bsr sets the zero flag when its operand is zero and clears it otherwise. A setne instruction then copies 0 or 1 into a scratch register based on that flag. The subtraction logic had to be reworked too, because x86_64 has no way to subtract a register from an immediate value directly. Assembler methods ending in _ir operate on an immediate plus a register; _rr methods take two registers.
The Assembler and Cranelift's ISLE
The assembler exposes methods that mirror the raw machine instruction set. It sits on top of Cranelift, whose x86_64 instruction definitions are written in ISLE, an S-expression-based domain-specific language. Cranelift's code generator converts ISLE definitions into Rust code that the assembler calls into. ISLE definitions often carry useful comments explaining what each instruction does; for new work they also serve as a reference for what machine instructions are available.
To figure out which instructions to emit, the workflow was a combination of searching ISLE definitions, consulting ChatGPT for candidate x86_64 sequences, and verifying those suggestions against other references. The ChatGPT-generated assembly was occasionally subtly wrong, which is why verification mattered. The relevant instructions were found by searching the ISLE files for operations like or; for bsr and lzcnt, the definition is referenced through a UnaryRmR instruction type that takes the operation as a parameter. The assembler method then instantiates UnaryRmR with Bsr or Lzcnt to emit the right machine code.
Testing Without Executing
Testing compiler changes looks unfamiliar to developers used to unit tests. Winch relies on two forms of automated testing: filetests and differential fuzzing.
Filetests are written in a specially commented Wasm Text format. A single .wat file contains the target architecture and CPU flags as comments, a Wasm module, and the machine code expected to be produced. Since filetests generate code but don't run it, they work fine on an AArch64 development machine targeting x86_64. The test author writes the architecture and module portions, runs the test, and inspects the emitted code for the expected shapes: register widths (e-prefixed names for 32-bit registers, r-prefixed for 64-bit), the presence of expected instructions, and the correct choice between immediates and registers. Typical tests exercise constants, function parameters, locals, and both 32- and 64-bit variants. These tests also protect against regressions during later refactoring.
What filetests can't do is prove that the emitted instructions produce the right result. That verification comes from differential fuzzing.
Differential Fuzzing
The differential fuzzer generates an endless stream of random Wasm modules and executes functions from each module with random inputs. Each input runs twice: once through Winch and once through a different Wasm engine. If the results differ, the fuzzer treats it as a bug. This catches a much broader set of cases than hand-written integration tests, and it surfaced several issues during this work.
Differential fuzzing for x86_64 must run on actual x86_64 hardware. Before an instruction can be fuzzed, it has to be listed in a winch_supports_module match statement inside the fuzz target. Winch doesn't support every Wasm instruction yet, so any generated module using an unsupported instruction gets skipped.
Shipping the Change
The complete process for adding a Wasm instruction to Winch breaks down into seven steps:
- Add a method to the
visitorthat calls intoMacroAssemblerandCodeGenContext. - Add a
MacroAssemblermethod that invokes one or more assembler methods, unless a suitable method already exists. - Add
CodeGenContexthelper logic when operands must be popped from the stack and prepared in registers first. - Implement assembler methods that emit the actual x86_64 instructions.
- Write filetests and review the generated code.
- Add the instruction to the supported list for fuzzing.
- Run differential fuzzing for at least a few minutes, fixing any failures that come up.
Once the work was in good shape, Charles opened a draft PR against his personal Wasmtime fork to collect internal Shopify feedback. After revisions, he rebased to a single commit and opened the PR against the upstream Wasmtime repository.



