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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+836
-96
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