Files
WAFER/docs/OPTIMIZATIONS.md
T
Oleksandr Kozachuk 4f96f8860a
CI / check (push) Has been cancelled
release: 0.2.8
Ships the self-guard expansion, and corrects what the benchmark tables claim.
Measured with wafer, gforth and SwiftForth all native on x86-64 -- the macOS
sf64 build runs under Rosetta 2 and flatters us -- Fibonacci is 1.16x rather
than 0.83x, so sf64 still wins it and wafer takes the other four. README and
OPTIMIZATIONS now carry both tables.
2026-08-10 12:48:33 +02:00

560 lines
31 KiB
Markdown

# Optimizations
WAFER's compilation pipeline has a dedicated optimization stage between IR construction and WASM codegen:
```
Forth Source -> Outer Interpreter -> Vec<IrOp> -> [Optimizer] -> WASM Codegen -> wasmtime
```
The optimizer (`crates/core/src/optimizer.rs`) transforms `Vec<IrOp> -> Vec<IrOp>` through composable passes. A separate consolidation step (`crates/core/src/consolidate.rs`) can merge all JIT-compiled words into a single WASM module for cross-word optimization.
This document describes every optimization that makes sense for WAFER, why it matters, and whether it exists yet.
## Status Summary
| # | Optimization | Level | Status | Impact |
| -- | -------------------------- | ------------ | ----------- | ------- |
| 1 | Stack-to-Local Promotion | Codegen | Phase 4 | Highest |
| 2 | Peephole Optimization | IR pass | Done | High |
| 3 | Constant Folding | IR pass | Done | High |
| 4 | Inlining | IR pass | Done | High |
| 5 | Strength Reduction | IR pass | Done | Medium |
| 6 | Dead Code Elimination | IR pass | Done | Medium |
| 7 | Tail Call Optimization | IR + Codegen | Done | Medium |
| 8 | Consolidation | Architecture | Done | High |
| 9 | Compound IR Operations | IR + Codegen | Done | Medium |
| 10 | Codegen Improvements | Codegen | Done | High |
| 11 | wasmtime Configuration | Runtime | Done | Low |
| 12 | Dictionary Hash Index | Runtime | Done | Low |
| 13 | Startup Batching | Architecture | Done | Low |
| 14 | Self-Recursive Direct Call | Codegen | Done | High |
| 15 | Float / Double-Cell | Codegen | Not started | Future |
| 16 | Typed Calling Convention | Codegen | Done | Highest |
| 17 | Self-Guard Expansion | IR pass | Done | Medium |
## 1. Stack-to-Local Promotion
**Status: Phase 4 done.** Straight-line code, DO/LOOP, IF/ELSE and the BEGIN loop family use WASM locals instead of the memory stack, per region rather than per word. Stack manipulation ops (Swap, Rot, Nip, Tuck, Dup, Drop) emit zero WASM instructions. Loop index/limit stay in WASM locals (zero return stack traffic). Switchable via `WaferConfig::codegen.stack_to_local_promotion`.
- **Phase 1** — straight-line code.
- **Phase 2** — DO/LOOP (stack-neutrality check) and IF/ELSE/THEN (equal-branch-effect check).
- **Phase 3** — _per region instead of per word_. Promotion used to be all-or-nothing: one `.`, `CR`, `>R` or host call anywhere in a definition put the entire body on the memory stack, hot loops included, which costs 2.2 ns per loop-carried add instead of 0.31 — the accumulator round-trips through store-to-load forwarding rather than staying in a register. `emit_body` now partitions a body into maximal promotable stretches and runs the simulator over each, loading what a region reads and writing back what it leaves. A region may only use `I` / `J` when the DO loops naming them are inside it, and a straight-line region needs at least three operations to pay for its prologue and epilogue; a loop always does.
- **Phase 4** — `BEGIN..UNTIL`, `BEGIN..AGAIN` and `BEGIN..WHILE..REPEAT`, when the construct is provably stack-neutral: UNTIL's body nets +1 (the flag it consumes), AGAIN's nets 0, and for WHILE..REPEAT the test and the body must balance _separately_, because WHILE leaves the loop between them and a net that only added up over the pair would give the two exits different stack shapes. Bodies containing an `EXIT` stay out, the same rule DO/LOOP follows.
Still not promoted: `BeginDoubleWhileRepeat`, `>R`/`R>`, floats, `{: :}` locals, `SP@`/`DEPTH`/`EXECUTE`, and the flat forward-block IR ops.
### The Problem
WAFER simulates the Forth data stack in WASM linear memory. Every push and pop goes through a global stack pointer (`dsp`) and a memory load/store. A simple `DUP *` (square the top of stack) compiles to roughly 30 WASM instructions:
```wasm
;; DUP: peek top, push copy
global.get $dsp
i32.load ;; peek
local.set 0
global.get $dsp
i32.const 4
i32.sub
global.set $dsp ;; dsp_dec
global.get $dsp
local.get 0
i32.store ;; push copy
;; MUL: pop two, multiply, push result
global.get $dsp
i32.load
local.set 0 ;; pop first
global.get $dsp
i32.const 4
i32.add
global.set $dsp
global.get $dsp
i32.load
local.set 1 ;; pop second
global.get $dsp
i32.const 4
i32.add
global.set $dsp
local.get 1
local.get 0
i32.mul ;; the actual multiply
local.set 2
global.get $dsp
i32.const 4
i32.sub
global.set $dsp
global.get $dsp
local.get 2
i32.store ;; push result
```
With stack-to-local promotion, the same `DUP *` becomes:
```wasm
local.get 0 ;; dup: read value
local.get 0 ;; dup: second copy
i32.mul ;; multiply
local.set 0 ;; store result
```
That is a **~7x reduction** in instruction count.
### How It Works
When the compiler can statically determine the types and lifetimes of values on the stack, it maps them to WASM locals instead of memory. The `StackType` enum and `StackEffect` struct in `types.rs` already define the type system. What is missing:
1. **Stack-effect inference**: walk the IR and compute the type/local assignment for each stack slot at each point
2. **Dual-mode codegen**: emit local-based code when types are known, fall back to memory-based code at type boundaries (calls to unknown words, EXECUTE, etc.)
3. **Spill/reload at boundaries**: when calling another word, flush locals back to the memory stack (the callee expects a memory-based stack), then reload after return
### Where It Lives
- Type definitions: `crates/core/src/types.rs` (exists)
- Inference pass: new code in `optimizer.rs` or a dedicated `promote.rs`
- Codegen integration: `crates/core/src/codegen.rs` `emit_op()` needs a second code path
## 2. Peephole Optimization
**Status: Done.** Implemented in `optimizer.rs::peephole()`. Runs as fixpoint loop.
A peephole optimizer scans adjacent IR operations and replaces recognized patterns with cheaper equivalents. This is the lowest-effort, highest-return IR pass because Forth's postfix style generates many redundant sequences.
### Patterns
| Pattern | Replacement | Savings |
| -------------------- | ---------------------------------- | -------------------------- |
| `PushI32(n), Drop` | _(remove both)_ | 1 push + 1 pop |
| `Dup, Drop` | _(remove both)_ | 1 peek+push + 1 pop |
| `Swap, Swap` | _(remove both)_ | 2x(2 pops + 2 pushes) |
| `Swap, Drop` | `Nip` | 1 pop |
| `Over, Over` | `TwoDup` (new) | 1 peek+push |
| `Drop, Drop` | `TwoDrop` (new) | 1 dsp adjustment |
| `PushI32(0), Add` | _(remove both)_ | 1 push + 1 pop + add |
| `PushI32(0), Or` | _(remove both)_ | same |
| `PushI32(-1), And` | _(remove both)_ | same |
| `PushI32(1), Add` | `Inc` (new or codegen special) | avoids pushing constant |
| `PushI32(1), Sub` | `Dec` (new or codegen special) | same |
| `ZeroEq, ZeroEq` | _(remove both)_ for boolean inputs | 2 comparisons |
| `DivMod, Swap, Drop` | `Div` (new or codegen special) | avoids computing remainder |
| `DivMod, Drop` | `Mod` (new or codegen special) | avoids computing quotient |
### Implementation
A single function `fn peephole(ops: Vec<IrOp>) -> Vec<IrOp>` that makes repeated passes until no more patterns match. Recurse into control flow bodies (If/DoLoop/Begin*).
## 3. Constant Folding
**Status: Done.** Implemented in `optimizer.rs::constant_fold()`. Folds binary and unary ops on known constants.
When both operands of an operation are compile-time constants, compute the result at compile time.
### Examples
```
; Before: 5 3 + -> IR: PushI32(5), PushI32(3), Add
; After: -> IR: PushI32(8)
; Before: 0 0= -> IR: PushI32(0), ZeroEq
; After: -> IR: PushI32(-1)
; Before: 7 NEGATE -> IR: PushI32(7), Negate
; After: -> IR: PushI32(-7)
; Before: 4 3 < IF ... -> IR: PushI32(4), PushI32(3), Lt, If{...}
; After: -> IR: PushI32(0), If{...}
; (then DCE removes the dead branch entirely)
```
Constant folding composes with inlining: after inlining a word, new folding opportunities appear. Run folding after every inlining pass.
### Implementation
A function `fn constant_fold(ops: Vec<IrOp>) -> Vec<IrOp>` that simulates a compile-time stack of known constants and replaces foldable sequences. Must handle all arithmetic, comparison, logic, and unary operations in `IrOp`.
## 4. Inlining
**Status: Done.** Implemented in `optimizer.rs::inline()`. Inlines word bodies <= 8 ops, non-recursive. TailCall converted back to Call when inlining. IR bodies stored in `ForthVM::ir_bodies`.
Replace `Call(WordId)` with the callee's IR body, avoiding the `call_indirect` overhead and enabling further optimization of the combined code.
### Why It Matters
Every call in WAFER is `call_indirect` through a function table. This is slower than a direct `call` and prevents the WASM engine from optimizing across call boundaries. Inlining eliminates the call entirely and exposes the callee's operations to peephole, constant folding, and stack-to-local promotion.
### Example
```forth
: SQUARE DUP * ;
: MAIN 5 SQUARE 3 SQUARE + ;
```
Before inlining, MAIN's IR:
```
PushI32(5), Call(SQUARE), PushI32(3), Call(SQUARE), Add
```
After inlining SQUARE:
```
PushI32(5), Dup, Mul, PushI32(3), Dup, Mul, Add
```
After constant folding:
```
PushI32(25), PushI32(9), Add
```
After more folding:
```
PushI32(34)
```
### Requirements
- Store each word's IR body in the dictionary (currently discarded after compilation)
- Inline policy: inline when body size is below a threshold (e.g., 8 IR ops)
- Do not inline recursive words (detect cycles)
- Do not inline words with side effects that depend on call context (rare)
- Re-run peephole and constant folding after inlining
## 5. Strength Reduction
**Status: Done.** Implemented in `optimizer.rs::strength_reduce()`. Power-of-2 multiply to shift, zero comparisons to ZeroEq/ZeroLt.
Replace expensive operations with cheaper equivalents when one operand is a known constant.
### Patterns
| Pattern | Replacement | Why |
| ---------------------- | ------------------------------- | ------------------------------ |
| `PushI32(2^n), Mul` | `PushI32(n), Lshift` | shift is 1 cycle vs multiply |
| `PushI32(2^n), DivMod` | `PushI32(n), Rshift` (unsigned) | shift vs divide |
| `PushI32(1), Lshift` | `Dup, Add` | add is often faster than shift |
| `PushI32(0), Gt` | `ZeroGt` (if added) | avoids pushing constant |
| `PushI32(0), Eq` | `ZeroEq` | already exists as IR op |
| `PushI32(0), Lt` | `ZeroLt` | already exists as IR op |
The most common case is `CELLS` which is defined as `PushI32(4), Mul`. Strength reduction turns this into `PushI32(2), Lshift`.
## 6. Dead Code Elimination
**Status: Done.** Implemented in `optimizer.rs::dce()`. Truncates after Exit, eliminates constant-conditional branches.
Remove IR operations that can never execute or whose results are never used.
### Cases
1. **Unreachable code**: anything after `Exit` in a linear sequence
2. **Constant conditionals**: `PushI32(0), If { then, else }` -- keep only `else`; `PushI32(non-zero), If { then, else }` -- keep only `then`
3. **Push-then-drop**: `PushI32(n), Drop` -- remove both (also caught by peephole, but DCE handles it for non-adjacent cases when intervening ops are also dead)
4. **Empty control structures**: `If { [], None }` -- remove the entire If
DCE should run after constant folding, since folding can create new constant conditionals.
## 7. Tail Call Optimization
**Status: Done.** `optimizer.rs::tail_call_detect()` converts the last `Call` to `TailCall` when return stack is balanced. Codegen emits `call_indirect + return`.
### What Exists
The codegen for `TailCall` emits:
```wasm
i32.const <word_id>
call_indirect (type $void) (table 0)
return
```
This is semantically a tail call -- the current frame returns immediately after the callee. True WASM tail calls (`return_call_indirect`) are a WASM proposal not yet standard, so this is the best available approximation. It does not eliminate the call frame, but it does skip any cleanup code after the call site.
### What Is Missing
The compiler (`outer.rs`) needs to detect tail position: when the last operation before `;` (or before `Exit`) is a `Call`, convert it to `TailCall`. For `RECURSE` in tail position, this enables tail-recursive patterns like:
```forth
: GCD ( a b -- gcd ) ?DUP IF TUCK MOD RECURSE THEN ;
```
Detection rule: if the last IR op in a word body (or in a branch of an `If`) is `Call(id)`, and there are no pending return-stack items (`>R` without matching `R>`), replace with `TailCall(id)`.
## 8. Consolidation
**Status: Done.** `CONSOLIDATE` word recompiles all IR-based words into a single WASM module with direct `call` instructions. Implemented in `codegen.rs::compile_consolidated_module()` and `outer.rs::consolidate()`.
### The Idea
After interactive development, `CONSOLIDATE` recompiles all defined words into a **single WASM module**. This enables:
1. **Direct calls**: `call_indirect` through the function table becomes `call` to a known function index. Direct calls are faster and allow the WASM engine's optimizer (Cranelift) to see through them.
2. **Cross-word inlining by Cranelift**: with all functions in one module, Cranelift can inline small functions during its own optimization passes.
3. **Single instantiation**: one `Module::new()` + `Instance::new()` instead of N separate ones.
4. **Shared locals optimization**: Cranelift can allocate registers across the entire module.
### Design
- Collect all word IR bodies (requires storing them -- see Inlining prerequisite)
- Generate one WASM module with N internal functions
- Each function corresponds to a word, using direct `call` to siblings
- Re-populate the function table with the new module's exports
- The `compile_core_module()` stub in `codegen.rs` is the entry point
### Two Modes
| Mode | When | Properties |
| ------------- | ----------------------- | ------------------------------------------------ |
| JIT (current) | Interactive development | Per-word modules, `call_indirect`, fast redefine |
| Consolidated | After `CONSOLIDATE` | Single module, direct `call`, no redefine |
## 9. Compound IR Operations
**Status: Done.** `TwoDup` and `TwoDrop` IrOp variants with optimized codegen. Peephole converts `Over, Over -> TwoDup` and `Drop, Drop -> TwoDrop`.
Some common multi-op sequences have more efficient WASM implementations than emitting each op individually.
### Candidates
**`2DUP` (currently `Over, Over`)**
Current codegen: two separate Over implementations, each doing a peek + push. The compound version reads `dsp` once and copies two cells:
```wasm
;; compound 2DUP
global.get $dsp
i32.load offset=0 ;; b (top)
local.set 0
global.get $dsp
i32.load offset=4 ;; a (second)
local.set 1
global.get $dsp
i32.const 8
i32.sub
global.set $dsp ;; one dsp adjustment instead of two
global.get $dsp
local.get 1
i32.store offset=0 ;; push a
global.get $dsp
local.get 0
i32.store offset=4 ;; push b (adjusted offset)
```
**`2DROP` (currently `Drop, Drop`)**
Instead of two separate `dsp += 4`, emit one `dsp += 8`.
**`NipNip`** -- drop two items below top. Common after double-cell operations.
**`IncFetch` / `FetchInc`** -- `1+ @` or `@ 1+`, common loop patterns.
These can be added as new `IrOp` variants recognized by peephole and emitted by codegen with specialized WASM sequences.
## 10. Codegen Improvements
**Status: Done.** DSP cached in WASM local 0, DO/LOOP index/limit in WASM locals (fast path when body has no calls/RS ops), J compiled as IR primitive (was host function). Self-recursive `call` optimization in section 14.
These are improvements within `codegen.rs` `emit_op()` that do not require new IR operations.
### Global Caching
Cache `dsp` in a WASM local at function entry, write it back at function exit and before/after calls. This eliminates repeated `global.get $dsp` / `global.set $dsp` pairs within a function body:
```wasm
;; function entry
global.get $dsp
local.set $cached_dsp
;; ... use local.get/set $cached_dsp throughout ...
;; before call
local.get $cached_dsp
global.set $dsp
call_indirect ...
global.get $dsp
local.set $cached_dsp
;; function exit
local.get $cached_dsp
global.set $dsp
```
Globals in WASM are effectively memory accesses. Locals are register-allocated by Cranelift. This alone could cut 30-40% of global access instructions.
### Commutative Operand Stack Usage
For commutative operations (Add, Mul, And, Or, Xor, Eq, NotEq), the current codegen pops both operands into locals, then pushes them back onto the WASM operand stack for the operation. Instead, leave them on the operand stack:
```wasm
;; current: pop a to local, pop b to local, push both, operate
;; better for commutative ops:
global.get $dsp
i32.load ;; a on wasm stack
global.get $dsp
i32.const 4
i32.add
i32.load ;; b on wasm stack
i32.add ;; result on wasm stack
;; ... store result
```
### Loop Index in Local
**Status: Done.** DO/LOOP index and limit are kept in WASM locals. Two codegen paths:
- **Fast path** (body has no calls, no `>R`/`R>`): pure locals, zero return stack traffic. `I` reads from `local.get`. `J` also reads from outer loop's local.
- **Slow path** (body has calls or explicit RS ops): locals used for loop control but synced to return stack for LEAVE/UNLOOP compatibility.
`J` was converted from a host function (WASM-to-Rust roundtrip) to an IR primitive (`IrOp::LoopJ`) that compiles to `local.get` of the outer loop's index local.
## 11. wasmtime Configuration
**Status: Done.** NaN canonicalization disabled, module caching enabled via `cache_config_load_default()`.
### Available Knobs
| Setting | Current | Recommended | Effect |
| ---------------------------------------- | --------------- | ----------- | -------------------------------------- |
| `Config::cranelift_opt_level` | Speed (default) | Speed | Already optimal for JIT |
| `Config::cranelift_nan_canonicalization` | true | false | Skip NaN fixup (no floats yet) |
| `Config::parallel_compilation` | true | true | Already optimal |
| Module caching | none | file-based | Cache compiled modules across sessions |
| Epoch interruption | none | enable | Protect against infinite loops |
Module caching is the most impactful: `wasmtime::Config::cache_config_load_default()` enables disk-based caching of compiled WASM, so restarting WAFER with the same definitions does not re-invoke Cranelift.
## 12. Dictionary Hash Index
**Status: Done.** `HashMap<String, (u32, u32, bool)>` in Dictionary struct. `find()` uses O(1) hash lookup with linked-list fallback. Updated on `reveal()` and `toggle_immediate()`.
The dictionary lookup (`dictionary.rs` `find()`) walks a linked list from the most recent entry backward, comparing names character by character. After registering 80+ primitives plus user words, every lookup during compilation scans the full list.
### Solution
Maintain a `HashMap<String, (u32, WordId, bool)>` alongside the linked list. Update it on `create()` and `reveal()`. Lookup becomes O(1) average case. Keep the linked list for Forth-level traversal (`WORDS`, `FIND` at runtime).
This affects **compile time** (word lookup during parsing), not runtime (compiled code uses function table indices directly).
## 13. Startup Batching
**Status: Done.** All IR primitives batch-compiled into a single WASM module at boot via `compile_consolidated_module()`. Reduces boot from ~7.7ms to ~0.6ms (12x faster). The `compile_core_module()` stub has been removed.
Currently, each of the 80+ primitives registered at boot creates a separate WASM module: `wasm-encoder` builds it, `wasmparser` validates it, Cranelift compiles it, and wasmtime instantiates it. This happens 80+ times sequentially.
### Solution
Batch all IR-based primitives into a single WASM module with multiple exported functions. One `Module::new()` + one `Instance::new()` replaces 80+ pairs. This is a subset of what Consolidation (section 8) achieves, but scoped to primitives only and simpler to implement.
## 14. Self-Recursive Direct Call
**Status: Done.** When a word calls itself (RECURSE), the codegen emits `call WORD_FUNC` (direct call to the same function) instead of `call_indirect` through the function table. This eliminates the table lookup and signature check overhead.
### Impact
Fibonacci(25) with ~243K recursive calls:
- `call_indirect`: ~21ns/call → 5.0ms total
- Direct `call`: ~7ns/call → 1.6ms total (3x faster)
- gforth: ~14ns/call → 3.4ms total
The optimization is implemented in `emit_op` for `IrOp::Call`: when `ctx.self_word_id == Some(word_id)`, emit `call WORD_FUNC` (function index 1 in the word's own module). The `self_word_id` is derived from `CodegenConfig::base_fn_index`.
The numbers above are the state before section 16: they measure the call instruction, and what dominated turned out to be the calling _convention_ around it. A self-recursive word that is also typed now calls its own fast entry instead, and Fibonacci(25) is 356 microseconds rather than 1.6 ms.
## 15. Float and Double-Cell Stack
**Status: Not started.** `PushI64` and `PushF64` exist as IR ops but are stubs in codegen. Float stack operations are currently all host functions.
The float stack lives in its own memory region (0x2540--0x2D40). Float operations will have the same memory-based overhead as integer operations, but worse: `f64` values are 8 bytes, doubling the memory traffic per push/pop. Stack-to-local promotion (section 1) is even more impactful for floats because WASM has native `f64` locals and operand stack support.
## 16. Typed Calling Convention
**Status: Done.** A word whose stack effect is statically known compiles to two entry points: a fast one with signature `(i32 x p) -> (i32 x q)`, carrying its stack items as WASM values, and the usual `( -- )` wrapper that moves those items on and off the memory data stack. The wrapper keeps the function-table slot, so `EXECUTE`, the outer interpreter, host words and `CATCH` see exactly the ABI they saw before; only direct calls inside a module take the fast entry. `WAFER_TYPED_CALLS=0` falls back.
### The Problem
This is what the SwiftForth gap was made of. sf64 keeps TOS in `RBX` and the stack pointer in `RBP`, and both survive a `CALL` untouched, so its `FIB` is 16 instructions and about 7 memory touches per node. WAFER kept the whole stack in linear memory and flushed its cached `$dsp` to an imported global before every call: about 36 touches. Section 1's simulator, which already promoted loop and `IF` bodies into locals, refused any body containing a call or an `EXIT` -- exactly the words where the convention cost the most.
### The Effect Fixpoint
Self-recursion makes the stack-effect equation circular (`d = k + m*d`), so the effect is solved by iterating a guess until it reproduces itself: `FIB` settles on `(1,1)` in two rounds, while `: F 1 RECURSE ;` never settles and stays untyped. `CONSOLIDATE` extends this across words, since it puts them all in one module: the effects are solved from the leaves outward, and 105 of 187 words in a booted dictionary end up typed.
### Impact
Fibonacci(25) went from 1035 to 366 microseconds, 4.3x slower than `sf64` to 1.2x. Stack guards became nearly free as a side effect -- they hang off the memory-stack push/pop choke points, and a typed word barely has any -- so the default guards-on configuration that the REPL and the web build use went from 1631 to 365 microseconds on the same benchmark.
Untyped by design: anything using `SP@`, `DEPTH`, `EXECUTE`, `>R`/`R>`, floats or locals; anything calling a word that is itself untyped, which in the JIT path means every call except `RECURSE`; mutually recursive words; and words whose effect is not static -- branches that disagree on depth, `EXIT` at the wrong depth, a non-neutral loop body, or a recursion that grows the stack per level.
## 17. Self-Guard Expansion
**Status: Done.** A recursive word's base-case guard is duplicated into its own call sites, so the leaves of the recursion cost a test instead of a call. Implemented in `optimizer.rs::expand_self_guard`, gated on `OptConfig::self_guard`, and applied after inlining so the later passes still run over the result.
### The Shape
A recursive Forth word almost always opens with a guard that returns early:
```forth
: FIB DUP 2 < IF EXIT THEN DUP 1- RECURSE SWAP 2 - RECURSE + ;
```
Every leaf of the recursion is then a call whose entire body is `DUP 2 <`. The pass rewrites each `Call(self)` as
```forth
DUP 2 < IF ( leave it ) ELSE RECURSE THEN
```
which computes the same thing -- the callee would have run the guard, taken the branch and returned. When the guard returns a value rather than its argument (`IF DROP 0 EXIT THEN`), that value moves into the then-branch with it.
### Why It Is Bounded
The guard runs twice along the recursive path: once at the call site, once inside the callee. So it must be small and free of effects -- `MAX_GUARD_OPS` is six, and the operations are restricted to stack shuffles, arithmetic and comparisons; a call, a memory access or a branch disqualifies it. `MAX_GUARD_SITES` caps the expansion at four call sites, since each one replicates the guard. A `TailCall` is never expanded, which keeps this pass and tail-call detection from having to agree about what tail position means.
### Impact
Fibonacci(25): 356 to 237 microseconds on the arm64 development machine. In fib's tree half of all nodes are leaves, which is where the factor comes from. It does not take Fibonacci past `sf64`, though the arm64 table below says otherwise: with both engines native on x86-64, Fibonacci reads 1.16x and stays the one benchmark `sf64` wins.
## Current Performance vs Gforth
All optimizations enabled, release mode, measured with UTIME:
All three engines **native x86-64**, idle 16-vCPU Xeon Platinum 8124M @ 3.0 GHz,
median of three runs:
```
Benchmark WAFER gforth sf64 WAFER/gf WAFER/sf
Fibonacci(25) 411 3221 355 0.13x 1.16x
Factorial(12)x100K 994 7141 3058 0.14x 0.33x
GCD-bench(20K) 1591 3211 2423 0.50x 0.66x
NestedLoops(50)x1K 889 6824 2342 0.13x 0.38x
Collatz(2K) 391 3981 1659 0.10x 0.24x
```
The same suite on the arm64 development machine (M1 Ultra), which is what the
regression limits in `comparison.rs` are calibrated against:
```
Benchmark WAFER CONSOL gforth sf64 WAFER/gf WAFER/sf
Fibonacci(25) 237 242 3340 287 0.07x 0.83x
Factorial(12)x100K 480 479 6109 1594 0.08x 0.30x
GCD-bench(20K) 549 541 1830 797 0.30x 0.68x
NestedLoops(50)x1K 501 509 7092 1898 0.07x 0.26x
Collatz(2K) 196 190 3955 633 0.05x 0.30x
```
Times in microseconds. WAFER/gf < 1.0 means WAFER is faster. The two tables
disagree because the only SwiftForth build for macOS is x86-64 under Rosetta 2
while WAFER and gforth are native arm64 -- and the emulation penalty lands
hardest on the call-heavy benchmark, so Fibonacci reads 0.83x on arm64 and 1.16x
when neither engine is emulated. Believe the x86-64 table about the engines. One
caveat holds for both: sf64 uses 64-bit cells to WAFER's 32-bit.
## Remaining Opportunities
| Optimization | Status | Potential Impact |
| -------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Scoped exit for the inliner | Not started | The inliner still refuses any body containing an `EXIT`, because an inlined one would return from the caller. Compiling it as a branch to the end of a block would unlock inlining for every word with an early return, not just the guard shape section 17 handles |
| BeginDoubleWhileRepeat promotion | Not started | Rare pattern, low priority. Its promoted emitter exists but has no loop fixup and is unverified |
| LEAVE as IR primitive | Not started | Would enable fast-path for loops with LEAVE |
| Float stack-to-local | Not started | Eliminate float stack memory traffic |
| WASM tail calls proposal | Waiting on wasmtime | Would eliminate stack growth for tail-recursive words |