perf(core): typed calling convention for words with a known stack effect

Such a word now compiles to a fast entry (i32 x p) -> (i32 x q) carrying
its stack items as WASM values, plus the usual ( -- ) wrapper that keeps
the table slot, so EXECUTE / interpreter / host words / CATCH see the
unchanged memory ABI. Fib(25) 1035 -> 366 us, 4.3x slower than sf64 ->
1.2x; the default guards-on config 1740 -> 361 us. WS-006.
This commit is contained in:
Oleksandr Kozachuk
2026-08-09 09:20:10 +02:00
parent e6c10a6fa1
commit fc34bd9b24
9 changed files with 1024 additions and 114 deletions
+47
View File
@@ -5,6 +5,53 @@ All notable changes to WAFER are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- **A typed calling convention for words with a known stack effect.** Such a
word now compiles to two entry points: a fast one whose signature is
`(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.
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 ~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: ~36 memory touches per node. The stack simulator
that already promoted loop and `IF` bodies into WASM locals refused any
body containing a call or an `EXIT` -- exactly the words where the
convention cost the most. It now handles both.
Fibonacci(25) goes from 1035 to 366 µs, 4.3x slower than `sf64` to 1.2x.
Loop-heavy benchmarks are unchanged (they were already promoted, and
already beat `sf64`). Words that keep the memory convention: 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.
`CONSOLIDATE` extends this across words, since it puts them all in one
module: the effects are solved to a fixpoint from the leaves outward, and
105 of 187 words in a booted dictionary end up typed.
Stack guards get cheap as a side effect -- they hang off the memory-stack
push/pop choke points, and a typed word barely has any. The default
guards-on configuration that the REPL and the web build use went from 1631
to 365 µs on the same benchmark.
`WAFER_TYPED_CALLS=0` falls back to the memory-stack convention.
### Fixed
- The Forth 2012 Core suite now also runs against consolidated code
(`compliance_core_after_consolidate`). `CONSOLIDATE` had no correctness
test at all before -- only benchmarks.
## [0.2.6] - 2026-08-07 ## [0.2.6] - 2026-08-07
### Fixed ### Fixed
+17 -8
View File
@@ -11,6 +11,7 @@ An optimizing Forth 2012 compiler targeting WebAssembly. WAFER JIT-compiles each
- **Faster than gforth** on all benchmarks in release mode (2-10x faster) - **Faster than gforth** on all benchmarks in release mode (2-10x faster)
- **JIT compilation** — each `:` definition compiles to its own WASM module - **JIT compilation** — each `:` definition compiles to its own WASM module
- **Self-recursive direct calls** — RECURSE compiles to native `call` instead of `call_indirect` - **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
- **Consolidation mode** — recompile all words into a single optimized WASM module - **Consolidation mode** — recompile all words into a single optimized WASM module
- **Interactive REPL** with line editing (rustyline) - **Interactive REPL** with line editing (rustyline)
- **Browser REPL** — runs entirely in the browser via wasm-pack + js-sys - **Browser REPL** — runs entirely in the browser via wasm-pack + js-sys
@@ -79,23 +80,31 @@ git submodule update --init
## Performance ## Performance
WAFER beats gforth (the GNU Forth reference implementation) on all benchmarks in release mode: WAFER beats gforth (the GNU Forth reference implementation) on all benchmarks in release mode, and is within
reach of SwiftForth `sf64`, which compiles to native code:
``` ```
Benchmark WAFER CONSOL gforth WAFER/gf Benchmark WAFER CONSOL gforth sf64 WAFER/gf WAFER/sf
Fibonacci(25) 1629 1535 3422 0.45x Fibonacci(25) 378 359 3238 296 0.11x 1.21x
Factorial(12)x10K 340 339 638 0.53x Factorial(12)x10K 335 320 633 183 0.51x 1.75x
GCD-bench(500) 18 15 30 0.50x GCD-bench(500) 14 15 29 31 0.48x 0.45x
NestedLoops(50) 84 73 720 0.10x NestedLoops(50) 72 69 698 207 0.10x 0.33x
Collatz(2K) 1212 1202 3914 0.31x Collatz(2K) 994 997 3940 668 0.25x 1.49x
``` ```
Times in microseconds. WAFER/gf < 1.0 means WAFER is faster. CONSOL = after `CONSOLIDATE`. Times in microseconds. WAFER/gf < 1.0 means WAFER is faster. CONSOL = after `CONSOLIDATE`.
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.
## Testing ## Testing
```bash ```bash
# All tests (~570 currently passing) # All tests (~620 currently passing)
cargo test --workspace cargo test --workspace
# Forth 2012 compliance suite # Forth 2012 compliance suite
+3 -1
View File
@@ -263,7 +263,8 @@ fn cmd_run(file: &str) -> anyhow::Result<()> {
} }
/// `WaferConfig` for CLI-created VMs. `WAFER_STACK_GUARDS=0|1` overrides /// `WaferConfig` for CLI-created VMs. `WAFER_STACK_GUARDS=0|1` overrides
/// the per-command default (REPL/file execution on, build off). /// the per-command default (REPL/file execution on, build off);
/// `WAFER_TYPED_CALLS=0` falls back to the memory-stack calling convention.
fn vm_config(default_guards: bool) -> wafer_core::config::WaferConfig { fn vm_config(default_guards: bool) -> wafer_core::config::WaferConfig {
let mut cfg = wafer_core::config::WaferConfig::all(); let mut cfg = wafer_core::config::WaferConfig::all();
cfg.codegen.stack_guards = match std::env::var("WAFER_STACK_GUARDS").ok().as_deref() { cfg.codegen.stack_guards = match std::env::var("WAFER_STACK_GUARDS").ok().as_deref() {
@@ -271,6 +272,7 @@ fn vm_config(default_guards: bool) -> wafer_core::config::WaferConfig {
Some(_) => true, Some(_) => true,
None => default_guards, None => default_guards,
}; };
cfg.codegen.typed_calls = std::env::var("WAFER_TYPED_CALLS").ok().as_deref() != Some("0");
cfg cfg
} }
File diff suppressed because it is too large Load Diff
+7
View File
@@ -12,6 +12,11 @@ pub struct CodegenOpts {
/// corrupting stack pointers. On by default; benchmarks and /// corrupting stack pointers. On by default; benchmarks and
/// exported production modules turn it off. /// exported production modules turn it off.
pub stack_guards: bool, pub stack_guards: bool,
/// Compile words with a statically known stack effect to a typed entry
/// point that carries stack items in WASM values, so a call keeps them
/// in registers instead of round-tripping through the memory stack.
/// On by default; `WAFER_TYPED_CALLS=0` turns it off.
pub typed_calls: bool,
} }
/// Master configuration for all WAFER optimizations. /// Master configuration for all WAFER optimizations.
@@ -38,6 +43,7 @@ impl WaferConfig {
codegen: CodegenOpts { codegen: CodegenOpts {
stack_to_local_promotion: true, stack_to_local_promotion: true,
stack_guards: true, stack_guards: true,
typed_calls: true,
}, },
} }
} }
@@ -56,6 +62,7 @@ impl WaferConfig {
codegen: CodegenOpts { codegen: CodegenOpts {
stack_to_local_promotion: false, stack_to_local_promotion: false,
stack_guards: false, stack_guards: false,
typed_calls: false,
}, },
} }
} }
+9 -9
View File
@@ -21,7 +21,7 @@ mod tests {
// Empty word list should produce nothing (but we guard against this at call site) // Empty word list should produce nothing (but we guard against this at call site)
let words = vec![]; let words = vec![];
let map = HashMap::new(); let map = HashMap::new();
let result = compile_consolidated_module(&words, &map, 16, None); let result = compile_consolidated_module(&words, &map, 16, None, true);
// Empty is valid -- should produce a valid module with no functions // Empty is valid -- should produce a valid module with no functions
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -31,7 +31,7 @@ mod tests {
let words = vec![(WordId(1), vec![IrOp::PushI32(42)])]; let words = vec![(WordId(1), vec![IrOp::PushI32(42)])];
let mut map = HashMap::new(); let mut map = HashMap::new();
map.insert(WordId(1), 1u32); // function index 1 (after emit import) map.insert(WordId(1), 1u32); // function index 1 (after emit import)
let result = compile_consolidated_module(&words, &map, 16, None); let result = compile_consolidated_module(&words, &map, 16, None, true);
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -49,7 +49,7 @@ mod tests {
map.insert(WordId(1), 1u32); map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32); map.insert(WordId(2), 2u32);
map.insert(WordId(3), 3u32); map.insert(WordId(3), 3u32);
let result = compile_consolidated_module(&words, &map, 16, None); let result = compile_consolidated_module(&words, &map, 16, None, true);
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -59,7 +59,7 @@ mod tests {
let words = vec![(WordId(3), vec![IrOp::Call(WordId(99))])]; let words = vec![(WordId(3), vec![IrOp::Call(WordId(99))])];
let mut map = HashMap::new(); let mut map = HashMap::new();
map.insert(WordId(3), 1u32); map.insert(WordId(3), 1u32);
let result = compile_consolidated_module(&words, &map, 256, None); let result = compile_consolidated_module(&words, &map, 256, None, true);
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -72,7 +72,7 @@ mod tests {
let mut map = HashMap::new(); let mut map = HashMap::new();
map.insert(WordId(1), 1u32); map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32); map.insert(WordId(2), 2u32);
let result = compile_consolidated_module(&words, &map, 16, None); let result = compile_consolidated_module(&words, &map, 16, None, true);
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -95,7 +95,7 @@ mod tests {
let mut map = HashMap::new(); let mut map = HashMap::new();
map.insert(WordId(1), 1u32); map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32); map.insert(WordId(2), 2u32);
let result = compile_consolidated_module(&words, &map, 16, None); let result = compile_consolidated_module(&words, &map, 16, None, true);
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -120,7 +120,7 @@ mod tests {
let mut map = HashMap::new(); let mut map = HashMap::new();
map.insert(WordId(1), 1u32); map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32); map.insert(WordId(2), 2u32);
let result = compile_consolidated_module(&words, &map, 16, None); let result = compile_consolidated_module(&words, &map, 16, None, true);
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -141,7 +141,7 @@ mod tests {
let mut map = HashMap::new(); let mut map = HashMap::new();
map.insert(WordId(1), 1u32); map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32); map.insert(WordId(2), 2u32);
let result = compile_consolidated_module(&words, &map, 16, None); let result = compile_consolidated_module(&words, &map, 16, None, true);
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -163,7 +163,7 @@ mod tests {
let mut map = HashMap::new(); let mut map = HashMap::new();
map.insert(WordId(1), 1u32); map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32); map.insert(WordId(2), 2u32);
let result = compile_consolidated_module(&words, &map, 16, None); let result = compile_consolidated_module(&words, &map, 16, None, true);
assert!(result.is_ok()); assert!(result.is_ok());
} }
} }
+1
View File
@@ -126,6 +126,7 @@ pub fn export_module(
table_size, table_size,
&export_sections, &export_sections,
vm.stack_guard_param(), vm.stack_guard_param(),
vm.typed_calls(),
) )
.map_err(|e| anyhow::anyhow!("export codegen error: {e}"))?; .map_err(|e| anyhow::anyhow!("export codegen error: {e}"))?;
+75
View File
@@ -2644,6 +2644,7 @@ impl<R: Runtime> ForthVM<R> {
&local_fn_map, &local_fn_map,
table_size, table_size,
self.stack_guard_param(), self.stack_guard_param(),
self.config.codegen.typed_calls,
) )
.map_err(|e| anyhow::anyhow!("consolidation codegen error: {e}"))?; .map_err(|e| anyhow::anyhow!("consolidation codegen error: {e}"))?;
@@ -2674,6 +2675,7 @@ impl<R: Runtime> ForthVM<R> {
&local_fn_map, &local_fn_map,
table_size, table_size,
self.stack_guard_param(), self.stack_guard_param(),
self.config.codegen.typed_calls,
) )
.map_err(|e| anyhow::anyhow!("batch compile error: {e}"))?; .map_err(|e| anyhow::anyhow!("batch compile error: {e}"))?;
@@ -2966,6 +2968,11 @@ impl<R: Runtime> ForthVM<R> {
.then_some(self.stack_fault_id) .then_some(self.stack_fault_id)
} }
/// Whether words with a known stack effect get a typed entry point.
pub(crate) fn typed_calls(&self) -> bool {
self.config.codegen.typed_calls
}
/// Codegen configuration for compiling one word. /// Codegen configuration for compiling one word.
fn codegen_config(&mut self, base_fn_index: u32) -> CodegenConfig { fn codegen_config(&mut self, base_fn_index: u32) -> CodegenConfig {
CodegenConfig { CodegenConfig {
@@ -2973,6 +2980,7 @@ impl<R: Runtime> ForthVM<R> {
table_size: self.table_size(), table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion, stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
stack_guards: self.stack_guard_param(), stack_guards: self.stack_guard_param(),
typed_calls: self.config.codegen.typed_calls,
} }
} }
@@ -8180,6 +8188,73 @@ mod tests {
use super::*; use super::*;
use crate::runtime_native::NativeRuntime; use crate::runtime_native::NativeRuntime;
// -- Typed calling convention -------------------------------------
#[test]
fn test_typed_word_recursion_matches_the_memory_convention() {
// FIB is the shape the typed entry exists for: self-recursive with
// an early EXIT, so it is compiled as WASM values in and out.
let (stack, _) = eval(
": FIB DUP 2 < IF EXIT THEN DUP 1- RECURSE SWAP 2 - RECURSE + ; \
0 FIB 1 FIB 2 FIB 10 FIB 25 FIB",
);
assert_eq!(stack, vec![75025, 55, 1, 1, 0]);
}
#[test]
fn test_typed_word_keeps_the_items_below_its_arguments() {
// The wrapper may only move the cells the word declared; anything
// deeper has to still be there afterwards.
let (stack, _) = eval(": SQ DUP * ; 7 8 9 SQ");
assert_eq!(stack, vec![81, 8, 7]);
}
#[test]
fn test_typed_word_is_reachable_through_execute() {
// EXECUTE goes through the table, which holds the `( -- )` wrapper.
let (stack, _) = eval(": SQ DUP * ; 6 ' SQ EXECUTE");
assert_eq!(stack, vec![36]);
}
#[test]
fn test_catch_sees_a_typed_word_underflow() {
// A typed word's stack guards live in its wrapper, where the
// arguments come off the memory stack. They still THROW -4 rather
// than corrupting the stack pointer, and CATCH still reports it.
let (stack, _) = eval(": SQ DUP * ; : CHK ['] SQ CATCH ; CHK");
assert_eq!(stack, vec![-4]);
}
#[test]
fn test_catch_restores_the_stack_around_a_caller_of_typed_code() {
// T contains THROW, a host word, so T itself keeps the memory
// convention and reaches SQ through its wrapper. CATCH restores the
// depth it saved across that boundary -- not the contents, so the 3
// that SQ squared stays squared. gforth agrees: `1 2 9 5 4`.
let (stack, _) = eval(": SQ DUP * ; : T SQ 5 THROW ; : CHK 1 2 3 ['] T CATCH DEPTH ; CHK");
assert_eq!(stack, vec![4, 5, 9, 2, 1]);
}
#[test]
fn test_typed_word_underflow_still_throws() {
// The stack guards for a typed word live in its wrapper, where the
// arguments are taken off the memory stack.
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
vm.evaluate(": SQ DUP * ;").unwrap();
vm.take_output();
let err = vm.evaluate("SQ");
assert!(err.is_err(), "empty-stack SQ should throw, got {err:?}");
}
#[test]
fn test_deep_typed_recursion_unwinds_cleanly() {
// 10k levels of a typed self-call: results come back in WASM values,
// so nothing is left on the memory stack afterwards.
let (stack, _) =
eval(": COUNT-DOWN DUP 0= IF EXIT THEN 1- RECURSE ; 10000 COUNT-DOWN DEPTH");
assert_eq!(stack, vec![1, 0]);
}
fn eval(input: &str) -> (Vec<i32>, String) { fn eval(input: &str) -> (Vec<i32>, String) {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap(); let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
vm.evaluate(input).unwrap(); vm.evaluate(input).unwrap();
+29
View File
@@ -344,3 +344,32 @@ fn compliance_tools() {
let errors = run_suite(&mut vm, "toolstest.fth"); let errors = run_suite(&mut vm, "toolstest.fth");
assert_eq!(errors, 0, "Programming-Tools: {errors} test failures"); assert_eq!(errors, 0, "Programming-Tools: {errors} test failures");
} }
/// The Forth 2012 Core suite against consolidated code.
///
/// `CONSOLIDATE` recompiles the whole dictionary into one WASM module, which
/// is where cross-word typed calls live: a word with a known stack effect
/// gets a fast entry taking and returning its stack items as WASM values,
/// and its `() -> ()` wrapper keeps the table slot. Nothing else covers that
/// path for correctness, so run the suite on top of it.
#[test]
fn compliance_core_after_consolidate() {
let mut vm = ForthVM::<NativeRuntime>::new().expect("Failed to create ForthVM");
let tester_path = format!("{SUITE_DIR}/tester.fr");
let f1 = load_file(&mut vm, &tester_path);
assert_load_fails_within_baseline(&tester_path, f1);
vm.evaluate("CONSOLIDATE").expect("CONSOLIDATE failed");
vm.take_output();
let core_path = format!("{SUITE_DIR}/core.fr");
let f2 = load_file(&mut vm, &core_path);
assert_load_fails_within_baseline(&core_path, f2);
let _ = vm.evaluate("DECIMAL #ERRORS @");
let errors = vm.data_stack().first().copied().unwrap_or(-1);
assert_eq!(
errors, 0,
"Core word set after CONSOLIDATE: {errors} failures"
);
}