release: 0.2.9
CI / check (push) Has been cancelled

This commit is contained in:
Oleksandr Kozachuk
2026-08-11 17:24:04 +02:00
parent 35da69cf7b
commit 94a0566ce3
8 changed files with 215 additions and 92 deletions
+78 -51
View File
@@ -8,7 +8,7 @@ An optimizing Forth 2012 compiler targeting WebAssembly. WAFER JIT-compiles each
- **200+ words** across 12 Forth 2012 word sets, all at **100% compliance**
- **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 every benchmark, and past SwiftForth `sf64` -- a native-code compiler -- on four of five
- **Faster than gforth** on every benchmark, and past SwiftForth `sf64` -- a native-code compiler -- on five of six
- **JIT compilation** — each `:` definition compiles to its own WASM module
- **Self-recursive direct calls** — RECURSE compiles to native `call` instead of `call_indirect`
- **Typed calling convention** — a word with a statically known stack effect passes its stack items as WASM values, so a call keeps them in registers instead of round-tripping through memory
@@ -80,54 +80,69 @@ git submodule update --init
## Performance
WAFER beats gforth (the GNU Forth reference implementation) on every benchmark, and SwiftForth
`sf64` -- which compiles to native code -- on four of the five.
WAFER beats gforth (the GNU Forth reference implementation) on every benchmark by 3-20x, and
SwiftForth `sf64` -- which compiles to native code -- on five of the six. Fibonacci is the one it
loses: one call per node, no loop to promote, and `sf64` keeps its stack in registers across a call
the way only a native code generator can.
Measured with all three engines running **native x86-64**, on an 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
```
Times in microseconds; WAFER is the better of the JIT and `CONSOLIDATE` runs. Below 1.0 means WAFER
is faster. Fibonacci is the one WAFER loses: it is one call per node with no loop to promote, and
`sf64` keeps its stack in registers across a call the way only a native code generator can.
Fibonacci, GCD and Collatz held to within 2% across the three runs; Factorial and NestedLoops are
softer, since `sf64` varied by half there, but they are wide wins either way.
`just bench-compare` on the development machine (M1 Ultra, arm64) reports different numbers, and
they flatter WAFER:
Measured on the development machine (M1 Ultra, arm64), median of three reports:
```
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
Fibonacci(33) 11307 11407 157001 13053 0.07x 0.87x
Factorial(12)x2M 9639 9599 123950 32091 0.08x 0.30x
GCD-bench(400K) 11662 11580 38580 17001 0.30x 0.68x
NestedLoops(50)x20K 8920 9852 140518 36828 0.06x 0.24x
CrossCalls(3M) 10883 3769 87691 8240 0.04x 0.46x
Collatz(2K)x50 8838 8715 189903 28657 0.05x 0.30x
```
The only SwiftForth build for macOS is x86-64 under Rosetta 2, while WAFER and gforth are native
arm64 -- so that `sf64` column is native against emulated. The gap is not small, and it lands
exactly where it matters: Fibonacci reads 0.83x there and 1.16x when neither engine is emulated.
Treat the arm64 table as what the regression limits in `comparison.rs` are calibrated against, and
the x86-64 table as what to believe about the engines.
Times in microseconds; the ratios use the better of `WAFER` and `CONSOL`. Below 1.0 means WAFER is
faster.
A caveat applies to both: `sf64` uses 64-bit cells to WAFER's 32-bit, so WAFER does less work per
operation.
**The `sf64` column here flatters WAFER, and by enough to change an answer.** The only SwiftForth
build for macOS is x86-64 running under Rosetta 2, while WAFER and gforth are native arm64 -- so
that column compares native code against emulated code, and the penalty falls hardest on the
call-heavy benchmark. Measured with all three engines native on x86-64 (Xeon Platinum 8124M,
Ubuntu 22.04; two reports agreed within 1%), Fibonacci reads **1.21x** where the table above says
0.87x; the other five keep their wins. That native comparison is what the "five of six" above
rests on:
```
Benchmark WAFER CONSOL gforth sf64 WAFER/gf WAFER/sf
Fibonacci(33) 19512 19511 129784 16076 0.15x 1.21x
Factorial(12)x2M 22532 16601 137168 57986 0.12x 0.29x
GCD-bench(400K) 34216 34089 66595 51680 0.51x 0.66x
NestedLoops(50)x20K 10729 17827 126687 40469 0.08x 0.27x
CrossCalls(3M) 20457 7412 81303 29264 0.09x 0.25x
Collatz(2K)x50 18686 17328 188592 80857 0.09x 0.21x
```
A second caveat holds on any host: `sf64` uses 64-bit cells to WAFER's 32-bit, so WAFER does less
work per operation.
`CrossCalls` is the only benchmark with a cross-word call left in its hot loop -- the other five
have their callee inlined away or are self-recursive -- so it is the only one that measures what
`CONSOLIDATE` does, and there it is worth 2.9x. `NestedLoops` goes the other way: `CONSOLIDATE`
makes it 1.1x _slower_ on the M1 and 1.7x on x86-64 -- not worse code but worse luck. Both paths
emit identical WASM for the hot word; the delta is where the machine code lands. A tight loop
pays for straddling an instruction-fetch window (16 bytes on the M1, 32 on Skylake, where a fused
branch crossing the boundary drops the loop out of the uop cache -- the JCC erratum), Cranelift
does not align loop headers, and dead prologue bytes in the per-word JIT module happen to shift
its loops into luckier spots. Details in
[docs/OPTIMIZATIONS.md](docs/OPTIMIZATIONS.md#8-consolidation).
Every benchmark is sized to run about 10 ms. Not for the usual reason -- the timing wrapper already
excludes start-up and compilation -- but to keep a comfortable margin over timer resolution and
first-iteration effects without pushing the report past a minute. gforth is 3-20x slower than
WAFER, so it sets the wall clock.
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
table, `EXECUTE` and the outer interpreter reach, so nothing about the memory ABI changes from the outside.
Call-heavy code is what this pays for -- Fibonacci went from 4.3x slower than `sf64` to 1.2x. Set
`WAFER_TYPED_CALLS=0` to fall back to the memory-stack convention.
Only a caller inside the same module can use the fast entry -- `RECURSE` in the JIT path, every resolvable
call after `CONSOLIDATE` -- so that is exactly when it is emitted. Set `WAFER_TYPED_CALLS=0` to fall back.
Recursive words then get one more thing: their base-case guard is tested at the **call site**, so a
leaf of the recursion costs a comparison instead of a call. `: FIB DUP 2 < IF EXIT THEN ... RECURSE`
@@ -136,23 +151,35 @@ on entry anyway. Half of fib's nodes are leaves, and that is worth 1.4x.
## Testing
Everything below has a `just` target; the raw command is given where it is worth
knowing what the target does.
```bash
# All tests (~635 currently passing)
cargo test --workspace
# Forth 2012 compliance suite
cargo test -p wafer-core --test compliance
# Cross-engine comparison (WAFER vs gforth, requires gforth)
cargo test -p wafer-core --test comparison -- --nocapture --ignored
# Optimization benchmark report (WAFER-internal)
cargo test -p wafer-core --test benchmark_report -- --nocapture --ignored
# Lints
cargo clippy --workspace
just test # all tests (~638 currently passing)
just compliance # Forth 2012 compliance suite
just clippy # lints
just fmt # formatting check (Rust + Markdown)
just ci # everything CI runs
```
Benchmarks are separate, because they are `#[ignore]`d -- they take minutes, and
a debug build would measure nothing useful:
```bash
just bench-compare # WAFER vs gforth vs SwiftForth, the table in Performance
just bench-opts # WAFER against its own optimization settings
just bench # criterion micro-benchmarks
just compare-correctness # same three engines, compared on output instead of time
```
`bench-compare` needs `gforth` and `sf64` on `PATH` -- a missing engine drops its
column rather than failing. Each number in it is the best of three processes, and
each process reports the mean of its three fastest of seven timed repetitions:
benchmark noise is one-sided, so the fastest runs are the honest ones, and only a
fresh process resamples core placement and code layout. Run it on an idle
machine; a busy one produced 20-79% run-to-run spread where an idle one gives
1-6%.
## Architecture
```