4 Commits

Author SHA1 Message Date
Oleksandr Kozachuk 4980648982 feat(bench): SwiftForth sf64 lane in cross-engine performance report
CI / check (push) Has been cancelled
sf64 discovery + stdin runner (no -e flag; input lines truncate at
~256 chars, so one statement per line), ucounter-based µs timing —
same wrapper shape as gforth utime. New sf64 + WAFER/sf columns,
informational only (no regression limit). Justfile: bench-compare
target; CARGO_PROFILE_RELEASE_STRIP=none for Darwin 27 dlopen bug.
2026-08-04 17:07:47 +02:00
Oleksandr Kozachuk 31dc6c6397 fix(core): no SystemTime on wasm32 — fixed boot seed + UTIME Forth error
CI / check (push) Has been cancelled
2026-07-29 16:56:31 +02:00
Oleksandr Kozachuk 35b78193fd feat(boot): add -ROT <= >= gforth extensions
CI / check (push) Has been cancelled
Non-standard but ubiquitous words; absence aborted otherwise-valid
gforth programs with unknown-word errors.
2026-07-18 15:40:39 +02:00
ok2 d5acdc0e7b fix: Rust 1.95 clippy — match guards + map_or
CI / check (push) Has been cancelled
Rust 1.95 promoted collapsible_match and map_unwrap_or; CI runs
-D warnings so they break the build. Collapse nested `if`s into
match guards across codegen/optimizer/export, and swap
map().unwrap_or(..) for map_or / is_ok_and.
2026-04-21 17:00:21 +02:00
7 changed files with 213 additions and 117 deletions
+4
View File
@@ -43,6 +43,10 @@ bench:
bench-opts:
cargo test -p wafer-core --test benchmark_report -- --nocapture --ignored
# Cross-engine performance report: WAFER vs gforth vs SwiftForth (sf64)
bench-compare:
CARGO_PROFILE_RELEASE_STRIP=none cargo test -p wafer-core --release --test comparison -- --nocapture --ignored performance_report
# Check dependency licenses and advisories
deny:
cargo deny check
+13
View File
@@ -72,6 +72,19 @@
1-
REPEAT ;
\ ---------------------------------------------------------------
\ Common extensions (not in Forth 2012, gforth-compatible)
\ ---------------------------------------------------------------
\ -ROT ( x1 x2 x3 -- x3 x1 x2 ) rotate top item to third place
: -ROT ROT ROT ;
\ <= ( n1 n2 -- flag ) true if n1 <= n2 (signed)
: <= > 0= ;
\ >= ( n1 n2 -- flag ) true if n1 >= n2 (signed)
: >= < 0= ;
\ ---------------------------------------------------------------
\ Phase 2: Double-cell arithmetic
\ ---------------------------------------------------------------
+22 -24
View File
@@ -2012,14 +2012,12 @@ fn emit_promoted_op(f: &mut Function, op: &IrOp, sim: &mut StackSim) {
// Outside loops, RFetch shouldn't appear in promoted code
}
IrOp::LoopJ => {
if sim.loop_index_stack.len() >= 2 {
let (outer_index, _) = sim.loop_index_stack[sim.loop_index_stack.len() - 2];
let result = sim.alloc();
f.instruction(&Instruction::LocalGet(outer_index));
f.instruction(&Instruction::LocalSet(result));
sim.push(result);
}
IrOp::LoopJ if sim.loop_index_stack.len() >= 2 => {
let (outer_index, _) = sim.loop_index_stack[sim.loop_index_stack.len() - 2];
let result = sim.alloc();
f.instruction(&Instruction::LocalGet(outer_index));
f.instruction(&Instruction::LocalSet(result));
sim.push(result);
}
IrOp::Exit => {
@@ -2147,15 +2145,15 @@ fn needs_f64_locals(ops: &[IrOp]) -> bool {
return true;
}
}
IrOp::DoLoop { body, .. } | IrOp::BeginUntil { body } | IrOp::BeginAgain { body } => {
if needs_f64_locals(body) {
return true;
}
IrOp::DoLoop { body, .. } | IrOp::BeginUntil { body } | IrOp::BeginAgain { body }
if needs_f64_locals(body) =>
{
return true;
}
IrOp::BeginWhileRepeat { test, body } => {
if needs_f64_locals(test) || needs_f64_locals(body) {
return true;
}
IrOp::BeginWhileRepeat { test, body }
if needs_f64_locals(test) || needs_f64_locals(body) =>
{
return true;
}
IrOp::BeginDoubleWhileRepeat {
outer_test,
@@ -2209,15 +2207,15 @@ fn body_needs_return_stack(ops: &[IrOp]) -> bool {
return true;
}
}
IrOp::DoLoop { body, .. } | IrOp::BeginUntil { body } | IrOp::BeginAgain { body } => {
if body_needs_return_stack(body) {
return true;
}
IrOp::DoLoop { body, .. } | IrOp::BeginUntil { body } | IrOp::BeginAgain { body }
if body_needs_return_stack(body) =>
{
return true;
}
IrOp::BeginWhileRepeat { test, body } => {
if body_needs_return_stack(test) || body_needs_return_stack(body) {
return true;
}
IrOp::BeginWhileRepeat { test, body }
if body_needs_return_stack(test) || body_needs_return_stack(body) =>
{
return true;
}
IrOp::BeginDoubleWhileRepeat {
outer_test,
+2 -4
View File
@@ -131,10 +131,8 @@ pub fn export_module(
fn collect_external_calls(ops: &[IrOp], ir_ids: &HashSet<WordId>, host_ids: &mut HashSet<WordId>) {
for op in ops {
match op {
IrOp::Call(id) | IrOp::TailCall(id) => {
if !ir_ids.contains(id) {
host_ids.insert(*id);
}
IrOp::Call(id) | IrOp::TailCall(id) if !ir_ids.contains(id) => {
host_ids.insert(*id);
}
IrOp::If {
then_body,
+14 -16
View File
@@ -591,15 +591,15 @@ fn contains_call_to(ops: &[IrOp], target: WordId) -> bool {
return true;
}
}
IrOp::DoLoop { body, .. } | IrOp::BeginUntil { body } | IrOp::BeginAgain { body } => {
if contains_call_to(body, target) {
return true;
}
IrOp::DoLoop { body, .. } | IrOp::BeginUntil { body } | IrOp::BeginAgain { body }
if contains_call_to(body, target) =>
{
return true;
}
IrOp::BeginWhileRepeat { test, body } => {
if contains_call_to(test, target) || contains_call_to(body, target) {
return true;
}
IrOp::BeginWhileRepeat { test, body }
if contains_call_to(test, target) || contains_call_to(body, target) =>
{
return true;
}
IrOp::BeginDoubleWhileRepeat {
outer_test,
@@ -651,15 +651,13 @@ fn contains_exit(ops: &[IrOp]) -> bool {
return true;
}
}
IrOp::DoLoop { body, .. } | IrOp::BeginUntil { body } | IrOp::BeginAgain { body } => {
if contains_exit(body) {
return true;
}
IrOp::DoLoop { body, .. } | IrOp::BeginUntil { body } | IrOp::BeginAgain { body }
if contains_exit(body) =>
{
return true;
}
IrOp::BeginWhileRepeat { test, body } => {
if contains_exit(test) || contains_exit(body) {
return true;
}
IrOp::BeginWhileRepeat { test, body } if contains_exit(test) || contains_exit(body) => {
return true;
}
_ => {}
}
+52 -16
View File
@@ -393,18 +393,24 @@ impl<R: Runtime> ForthVM<R> {
substitutions: Arc::new(Mutex::new(HashMap::new())),
search_order: Arc::new(Mutex::new(vec![1])),
next_wid: Arc::new(Mutex::new(2)),
// SystemTime::now() PANICS on wasm32-unknown-unknown (no time
// source), which turned VM construction into an `unreachable`
// trap in the browser. Seed from the wall clock only where one
// exists; wasm hosts start deterministic and reseed via RND-SEED.
#[cfg(not(target_arch = "wasm32"))]
rng_state: {
use std::time::{SystemTime, UNIX_EPOCH};
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0xDEAD_BEEF_CAFE_BABE);
.map_or(0xDEAD_BEEF_CAFE_BABE, |d| d.as_nanos() as u64);
Arc::new(Mutex::new(if seed == 0 {
0xDEAD_BEEF_CAFE_BABE
} else {
seed
}))
},
#[cfg(target_arch = "wasm32")]
rng_state: Arc::new(Mutex::new(0xDEAD_BEEF_CAFE_BABE)),
compile_frames: Vec::new(),
compiling_word_addr: 0,
};
@@ -5384,20 +5390,30 @@ impl<R: Runtime> ForthVM<R> {
/// UTIME ( -- ud ) push microseconds since epoch as a double-cell value.
fn register_utime(&mut self) -> anyhow::Result<()> {
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
use std::time::{SystemTime, UNIX_EPOCH};
let us = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_micros() as u64;
let lo = us as i32;
let hi = (us >> 32) as i32;
// Push double: lo first (deeper), then hi on top
let sp = ctx.get_dsp();
let new_sp = sp - 2 * CELL_SIZE;
ctx.mem_write_i32(new_sp as u32, hi as i32);
ctx.mem_write_slice(new_sp as u32 + 4, &lo.to_le_bytes());
ctx.set_dsp((new_sp as i32) as u32);
Ok(())
// SystemTime::now() panics on wasm32-unknown-unknown; fail as a
// catchable Forth error instead of poisoning the VM with a trap.
#[cfg(target_arch = "wasm32")]
{
let _ = ctx;
Err(anyhow::anyhow!("UTIME: no time source on this platform"))
}
#[cfg(not(target_arch = "wasm32"))]
{
use std::time::{SystemTime, UNIX_EPOCH};
let us = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_micros() as u64;
let lo = us as i32;
let hi = (us >> 32) as i32;
// Push double: lo first (deeper), then hi on top
let sp = ctx.get_dsp();
let new_sp = sp - 2 * CELL_SIZE;
ctx.mem_write_i32(new_sp as u32, hi as i32);
ctx.mem_write_slice(new_sp as u32 + 4, &lo.to_le_bytes());
ctx.set_dsp((new_sp as i32) as u32);
Ok(())
}
});
self.register_host_primitive("UTIME", false, func)?;
@@ -7463,6 +7479,12 @@ mod tests {
assert_eq!(eval_stack("1 2 3 ROT"), vec![1, 3, 2]);
}
#[test]
fn test_minus_rot() {
// ( 1 2 3 -- 3 1 2 ) top-first: [2, 1, 3]
assert_eq!(eval_stack("1 2 3 -ROT"), vec![2, 1, 3]);
}
// -- Comparison --
#[test]
@@ -7483,6 +7505,20 @@ mod tests {
assert_eq!(eval_stack("3 5 >"), vec![0]);
}
#[test]
fn test_less_or_equal() {
assert_eq!(eval_stack("3 5 <="), vec![-1]);
assert_eq!(eval_stack("5 5 <="), vec![-1]);
assert_eq!(eval_stack("5 3 <="), vec![0]);
}
#[test]
fn test_greater_or_equal() {
assert_eq!(eval_stack("5 3 >="), vec![-1]);
assert_eq!(eval_stack("5 5 >="), vec![-1]);
assert_eq!(eval_stack("3 5 >="), vec![0]);
}
// -- Logic --
#[test]
+106 -57
View File
@@ -1,8 +1,10 @@
#![allow(dead_code)]
//! Cross-engine comparison tests: WAFER vs gforth.
//! Cross-engine comparison tests: WAFER vs gforth (and `SwiftForth` for perf).
//!
//! Validates that WAFER produces identical output to gforth for standard
//! Forth programs, and benchmarks performance of both engines.
//! Forth programs, and benchmarks performance of the engines. `SwiftForth`
//! (`sf64`, native-code commercial compiler) joins the performance report
//! as an upper-bound reference when installed.
//!
//! WAFER-only correctness: `cargo test -p wafer-core --test comparison`
//! Full comparison + perf: `cargo test -p wafer-core --test comparison -- --nocapture --ignored`
@@ -26,8 +28,7 @@ fn probe_gforth(candidate: &str) -> bool {
.arg("-e")
.arg("bye")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
.is_ok_and(|o| o.status.success())
}
fn find_gforth() -> Option<&'static str> {
@@ -64,6 +65,46 @@ fn find_gforth_fast() -> Option<&'static str> {
.as_deref()
}
// -----------------------------------------------------------------------
// SwiftForth (sf64) discovery (cached)
// -----------------------------------------------------------------------
static SF64_PATH: OnceLock<Option<String>> = OnceLock::new();
/// Probe sf64 by piping `bye` via stdin — sf64 has no `-e` flag; it takes
/// Forth source from stdin or as bare command-line arguments.
fn probe_sf64(candidate: &str) -> bool {
run_via_stdin(candidate, "bye\n").is_some_and(|o| o.status.success())
}
fn find_sf64() -> Option<&'static str> {
SF64_PATH
.get_or_init(|| {
for candidate in &["/Applications/ForthInc/SwiftForth/bin/macos/sf64", "sf64"] {
if probe_sf64(candidate) {
return Some(candidate.to_string());
}
}
None
})
.as_deref()
}
/// Spawn `binary`, write `input` to its stdin, and collect the output.
fn run_via_stdin(binary: &str, input: &str) -> Option<std::process::Output> {
Command::new(binary)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(input.as_bytes())?;
child.wait_with_output()
})
.ok()
}
// -----------------------------------------------------------------------
// Engine runners
// -----------------------------------------------------------------------
@@ -699,31 +740,11 @@ fn measure_wafer_release(wafer: &str, bench: &PerfBenchmark) -> Option<u64> {
define = bench.define,
run = bench.run_code,
);
let output = Command::new(wafer)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(code.as_bytes())?;
child.wait_with_output()
})
.ok()?;
let output = run_via_stdin(wafer, &code)?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut times: Vec<u64> = stdout
.trim()
.lines()
.filter_map(|l| l.trim().parse::<u64>().ok())
.collect();
times.sort();
if times.is_empty() {
return None;
}
Some(times[times.len() / 2])
median_printed_time(&output.stdout)
}
/// Measure WAFER execution time after CONSOLIDATE (direct calls between all words).
@@ -735,21 +756,17 @@ fn measure_wafer_consolidated(wafer: &str, bench: &PerfBenchmark) -> Option<u64>
define = bench.define,
run = bench.run_code,
);
let output = Command::new(wafer)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(code.as_bytes())?;
child.wait_with_output()
})
.ok()?;
let output = run_via_stdin(wafer, &code)?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
median_printed_time(&output.stdout)
}
/// Parse the microsecond values printed by TIMED-BENCH (one per line) and
/// return the median.
fn median_printed_time(stdout: &[u8]) -> Option<u64> {
let stdout = String::from_utf8_lossy(stdout);
let mut times: Vec<u64> = stdout
.trim()
.lines()
@@ -779,18 +796,28 @@ fn measure_gforth(gforth: &str, bench: &PerfBenchmark) -> Option<u64> {
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
// Parse the 3 timing values and take the median
let mut times: Vec<u64> = stdout
.trim()
.lines()
.filter_map(|l| l.trim().parse::<u64>().ok())
.collect();
times.sort();
if times.is_empty() {
median_printed_time(&output.stdout)
}
/// Measure `SwiftForth` (`sf64`) execution time using Forth-level `ucounter`
/// (double-cell microsecond counter; `2swap d- drop` yields elapsed us —
/// the same wrapper shape as gforth's `utime`). Timing excludes startup.
/// sf64 has no `-e` flag, so the program is piped via stdin — one statement
/// per line, because sf64 truncates input lines at ~256 chars.
/// Returns microseconds, or None if sf64 is unavailable or fails.
fn measure_sf64(sf64: &str, bench: &PerfBenchmark) -> Option<u64> {
let code = format!(
"{define}\n{run}\n\
: TIMED-BENCH ucounter {run} ucounter 2swap d- drop . cr ;\n\
TIMED-BENCH\nTIMED-BENCH\nTIMED-BENCH\nbye\n",
define = bench.define,
run = bench.run_code,
);
let output = run_via_stdin(sf64, &code)?;
if !output.status.success() {
return None;
}
Some(times[times.len() / 2])
median_printed_time(&output.stdout)
}
#[test]
@@ -831,18 +858,31 @@ fn performance_report() {
);
}
let sep = "=".repeat(80);
let thin = "-".repeat(80);
let sf64 = find_sf64();
if sf64.is_none() {
eprintln!("NOTE: sf64 (SwiftForth) not found — column skipped");
}
let sep = "=".repeat(100);
let thin = "-".repeat(100);
println!("\n{sep}");
println!(" WAFER vs Gforth Performance Comparison (release mode)");
println!(" WAFER vs Gforth vs SwiftForth Performance Comparison (release mode)");
println!("{sep}\n");
println!(
"{:<22} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10}",
"Benchmark", "WAFER", "CONSOL", "gforth", "gf-fast", "WAFER/gf", "limit"
"{:<22} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9}",
"Benchmark",
"WAFER",
"CONSOL",
"gforth",
"gf-fast",
"sf64",
"WAFER/gf",
"WAFER/sf",
"limit"
);
println!(
"{:<22} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10}",
"", "(us)", "(us)", "(us)", "(us)", "", ""
"{:<22} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9}",
"", "(us)", "(us)", "(us)", "(us)", "(us)", "", "", ""
);
println!("{thin}");
@@ -857,9 +897,11 @@ fn performance_report() {
.unwrap_or(0);
let gf = gforth.and_then(|g| measure_gforth(g, bench));
let gf_fast = gforth_fast.and_then(|g| measure_gforth(g, bench));
let sf = sf64.and_then(|s| measure_sf64(s, bench));
let gf_str = gf.map_or_else(|| "-".to_string(), |v| format!("{v}"));
let gf_fast_str = gf_fast.map_or_else(|| "-".to_string(), |v| format!("{v}"));
let sf_str = sf.map_or_else(|| "-".to_string(), |v| format!("{v}"));
let best_wafer = if consol > 0 && consol < wafer {
consol
} else {
@@ -873,11 +915,15 @@ fn performance_report() {
}
});
let ratio = ratio_val.map_or_else(|| "-".to_string(), |r| format!("{r:.2}x"));
let sf_ratio = sf.filter(|&s| s > 0).map_or_else(
|| "-".to_string(),
|s| format!("{:.2}x", best_wafer as f64 / s as f64),
);
let limit_str = format!("{:.2}x", bench.max_ratio);
println!(
"{:<22} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10}",
bench.name, wafer, consol, gf_str, gf_fast_str, ratio, limit_str
"{:<22} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9}",
bench.name, wafer, consol, gf_str, gf_fast_str, sf_str, ratio, sf_ratio, limit_str
);
// Check regression limits
@@ -900,6 +946,9 @@ fn performance_report() {
println!("{thin}");
println!(" WAFER = all optimizations, CONSOL = after CONSOLIDATE");
println!(" WAFER/gf = best(WAFER,CONSOL) vs gforth, < 1.0 means WAFER faster");
println!(
" WAFER/sf = best(WAFER,CONSOL) vs SwiftForth sf64 (native code; informational, no limit)"
);
println!("{sep}\n");
if !regressions.is_empty() {