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
+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
/// 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
}
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
/// 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,
},
}
}
+9 -9
View File
@@ -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());
}
}
+1
View File
@@ -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}"))?;
+75
View File
@@ -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();
+29
View File
@@ -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"
);
}