perf(core): promote per region, promote BEGIN loops, keep loops off the memory stack

Promotion was all-or-nothing per word, so one `.` or one host call put the
whole body -- hot loops included -- on the memory data stack, where a
loop-carried add costs 2.2 ns/iteration instead of 0.31. The stack simulator
now runs over each promotable stretch of a word; BEGIN/UNTIL, BEGIN/AGAIN and
BEGIN/WHILE/REPEAT join DO/LOOP as promotable when the construct is provably
stack-neutral; and the inliner no longer moves a loop-bearing callee into a
caller that can never be promoted.

Fixes a bug the BEGIN work uncovered, present since promotion was introduced
and shipped in 0.2.6: the loop fixup and the IF join copied locals one slot at
a time in index order, so a body that permutes the stack lost a value --
`: C 3 4 2 0 DO SWAP LOOP . . ;` printed `4 4` where gforth prints `4 3`.

Four of five benchmarks now beat sf64: Factorial 0.29x, Collatz 0.30x,
NestedLoops 0.27x, GCD 0.67x. Only Fibonacci is behind, at 1.24x. Also scale
GCD, Factorial and NestedLoops, which ran in 14-51 us where scatter and fixed
costs dominated -- that is what exposed GCD as a loss and pointed at BEGIN.
WS-014, WS-015, WS-016, WS-019.
This commit is contained in:
Oleksandr Kozachuk
2026-08-09 17:25:21 +02:00
parent fc34bd9b24
commit b8dcc021a2
6 changed files with 581 additions and 52 deletions
+11 -6
View File
@@ -7,7 +7,7 @@ An optimizing Forth 2012 compiler targeting WebAssembly. WAFER JIT-compiles each
## Highlights
- **200+ words** across 12 Forth 2012 word sets, all at **100% compliance**
- **Optimizing compiler** with 6 IR passes + stack-to-local promotion (loops + IF) + consolidation
- **Optimizing compiler** with 6 IR passes + stack-to-local promotion (per region, so a hot loop keeps its registers even inside a word that does I/O; `DO` and `BEGIN` loops alike) + consolidation
- **Faster than gforth** on all benchmarks in release mode (2-10x faster)
- **JIT compilation** — each `:` definition compiles to its own WASM module
- **Self-recursive direct calls** — RECURSE compiles to native `call` instead of `call_indirect`
@@ -85,15 +85,20 @@ reach of SwiftForth `sf64`, which compiles to native code:
```
Benchmark WAFER CONSOL gforth sf64 WAFER/gf WAFER/sf
Fibonacci(25) 378 359 3238 296 0.11x 1.21x
Factorial(12)x10K 335 320 633 183 0.51x 1.75x
GCD-bench(500) 14 15 29 31 0.48x 0.45x
NestedLoops(50) 72 69 698 207 0.10x 0.33x
Collatz(2K) 994 997 3940 668 0.25x 1.49x
Fibonacci(25) 356 361 3389 287 0.11x 1.24x
Factorial(12)x100K 479 495 6249 1650 0.08x 0.29x
GCD-bench(20K) 540 559 1801 801 0.30x 0.67x
NestedLoops(50)x1K 509 501 7023 1887 0.07x 0.27x
Collatz(2K) 185 213 3873 610 0.05x 0.30x
```
Times in microseconds. WAFER/gf < 1.0 means WAFER is faster. CONSOL = after `CONSOLIDATE`.
Two caveats on the `sf64` column. The SwiftForth build here is x86-64 running under Rosetta 2
while WAFER and gforth are native arm64, so it is a native-vs-emulated comparison; and sf64
uses 64-bit cells to WAFER's 32-bit. WAFER is ahead on the four loop-heavy benchmarks and
behind on Fibonacci, which is one call per node with no loop to promote.
A word whose stack effect is statically known gets a **typed entry point**: its stack items travel in and out
as WASM values instead of through the memory data stack, so cranelift keeps them in registers across a call
the way a native Forth keeps TOS in one. The word also keeps a `( -- )` wrapper, which is what the function