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:
@@ -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/),
|
||||
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
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -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)
|
||||
- **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
|
||||
- **Consolidation mode** — recompile all words into a single optimized WASM module
|
||||
- **Interactive REPL** with line editing (rustyline)
|
||||
- **Browser REPL** — runs entirely in the browser via wasm-pack + js-sys
|
||||
@@ -79,23 +80,31 @@ git submodule update --init
|
||||
|
||||
## 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
|
||||
Fibonacci(25) 1629 1535 3422 0.45x
|
||||
Factorial(12)x10K 340 339 638 0.53x
|
||||
GCD-bench(500) 18 15 30 0.50x
|
||||
NestedLoops(50) 84 73 720 0.10x
|
||||
Collatz(2K) 1212 1202 3914 0.31x
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
# All tests (~570 currently passing)
|
||||
# All tests (~620 currently passing)
|
||||
cargo test --workspace
|
||||
|
||||
# Forth 2012 compliance suite
|
||||
|
||||
@@ -263,7 +263,8 @@ fn cmd_run(file: &str) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
/// `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 {
|
||||
let mut cfg = wafer_core::config::WaferConfig::all();
|
||||
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,
|
||||
None => default_guards,
|
||||
};
|
||||
cfg.codegen.typed_calls = std::env::var("WAFER_TYPED_CALLS").ok().as_deref() != Some("0");
|
||||
cfg
|
||||
}
|
||||
|
||||
|
||||
+820
-80
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,11 @@ pub struct CodegenOpts {
|
||||
/// corrupting stack pointers. On by default; benchmarks and
|
||||
/// exported production modules turn it off.
|
||||
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.
|
||||
@@ -38,6 +43,7 @@ impl WaferConfig {
|
||||
codegen: CodegenOpts {
|
||||
stack_to_local_promotion: true,
|
||||
stack_guards: true,
|
||||
typed_calls: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -56,6 +62,7 @@ impl WaferConfig {
|
||||
codegen: CodegenOpts {
|
||||
stack_to_local_promotion: false,
|
||||
stack_guards: false,
|
||||
typed_calls: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ mod tests {
|
||||
// Empty word list should produce nothing (but we guard against this at call site)
|
||||
let words = vec![];
|
||||
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
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
@@ -31,7 +31,7 @@ mod tests {
|
||||
let words = vec![(WordId(1), vec![IrOp::PushI32(42)])];
|
||||
let mut map = HashMap::new();
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ mod tests {
|
||||
map.insert(WordId(1), 1u32);
|
||||
map.insert(WordId(2), 2u32);
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ mod tests {
|
||||
let words = vec![(WordId(3), vec![IrOp::Call(WordId(99))])];
|
||||
let mut map = HashMap::new();
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ mod tests {
|
||||
let mut map = HashMap::new();
|
||||
map.insert(WordId(1), 1u32);
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ mod tests {
|
||||
let mut map = HashMap::new();
|
||||
map.insert(WordId(1), 1u32);
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ mod tests {
|
||||
let mut map = HashMap::new();
|
||||
map.insert(WordId(1), 1u32);
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ mod tests {
|
||||
let mut map = HashMap::new();
|
||||
map.insert(WordId(1), 1u32);
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ mod tests {
|
||||
let mut map = HashMap::new();
|
||||
map.insert(WordId(1), 1u32);
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ pub fn export_module(
|
||||
table_size,
|
||||
&export_sections,
|
||||
vm.stack_guard_param(),
|
||||
vm.typed_calls(),
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("export codegen error: {e}"))?;
|
||||
|
||||
|
||||
@@ -2644,6 +2644,7 @@ impl<R: Runtime> ForthVM<R> {
|
||||
&local_fn_map,
|
||||
table_size,
|
||||
self.stack_guard_param(),
|
||||
self.config.codegen.typed_calls,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("consolidation codegen error: {e}"))?;
|
||||
|
||||
@@ -2674,6 +2675,7 @@ impl<R: Runtime> ForthVM<R> {
|
||||
&local_fn_map,
|
||||
table_size,
|
||||
self.stack_guard_param(),
|
||||
self.config.codegen.typed_calls,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("batch compile error: {e}"))?;
|
||||
|
||||
@@ -2966,6 +2968,11 @@ impl<R: Runtime> ForthVM<R> {
|
||||
.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.
|
||||
fn codegen_config(&mut self, base_fn_index: u32) -> CodegenConfig {
|
||||
CodegenConfig {
|
||||
@@ -2973,6 +2980,7 @@ impl<R: Runtime> ForthVM<R> {
|
||||
table_size: self.table_size(),
|
||||
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
|
||||
stack_guards: self.stack_guard_param(),
|
||||
typed_calls: self.config.codegen.typed_calls,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8180,6 +8188,73 @@ mod tests {
|
||||
use super::*;
|
||||
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) {
|
||||
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
|
||||
vm.evaluate(input).unwrap();
|
||||
|
||||
@@ -344,3 +344,32 @@ fn compliance_tools() {
|
||||
let errors = run_suite(&mut vm, "toolstest.fth");
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user