Skip to content

Commit 30dfddc

Browse files
Phase 4: hot-builtin fast path — VM no longer slower than tree-walk
The bytecode VM was paradoxically slower than tree-walk on arr_push/arr_get loops, str_concat loops, and str_split/str_join loops. Cause: vm_call_builtin's synthetic-arg shim allocates a fresh scope, formats __vm_arg_N strings, builds Expression::Variable nodes, and dispatches through call_function — per call. For tight inner loops over hot builtins, the shim cost dominates the dispatch. Two fixes, both surgical: 1. Inline arr_get / dict_get to Op::ArrayIndex in the compiler. Op::ArrayIndex (already polymorphic over array/dict) skips the shim entirely — same dispatch `xs[i]` / `d[k]` already used. The fn-call form `arr_get(xs, i)` had been the slow path; this aligns it with bracket syntax. Hottest single fix. 2. vm_fast_dispatch in interpreter.rs intercepts pure builtins on value args before vm_call_builtin's shim runs. Covers str_concat, str_len, str_chars, str_slice, str_split, str_join, to_int / to_float / to_string, and println / print. Long-tail builtins still go through the shim (no behavior change there). Bonus alignment: str_concat tree-walk now uses to_display_string (Phase 1's bare-number formatter) instead of to_string. The old to_string produced "HInt(42, φ=..., HIM=...)" for numeric args — useless output nobody wanted; tests just happened to assert against it. concat_many already used the bare form; aligning str_concat removes the inconsistency. Measured (200k inner ops where applicable): pre-Phase-4 post tree-walk arr_push + arr_get walk 168000 ns/op 100600 101600 str_concat 2200 1200 1200 str_split + str_join 3350 1500 1350 VM is now ≥ tree-walk on every benchmark. HOF speedups (2.0-2.3×) and direct-call speedup (2.1×) preserved. 41/41 functional examples identical; 92/92 unit tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a5ed502 commit 30dfddc

2 files changed

Lines changed: 119 additions & 5 deletions

File tree

omnimcode-core/src/compiler.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,20 @@ impl Compiler {
492492
self.emit(Op::ArrayLen);
493493
return Ok(());
494494
}
495+
// arr_get(arr, idx) is the hottest array call —
496+
// inline to ArrayIndex so we skip vm_call_builtin's
497+
// synthetic-arg shim. This is the same dispatch
498+
// `arr[idx]` already uses; aligning fn syntax with
499+
// bracket syntax was the gap that made the
500+
// arr_push+arr_get benchmark slower under VM than
501+
// tree-walk. ArrayIndex is polymorphic over arrays
502+
// and dicts, so dict_get(d, k) inlines too.
503+
("arr_get", 2) | ("dict_get", 2) => {
504+
self.compile_expr(&args[0])?;
505+
self.compile_expr(&args[1])?;
506+
self.emit(Op::ArrayIndex);
507+
return Ok(());
508+
}
495509
_ => {}
496510
}
497511
}

omnimcode-core/src/interpreter.rs

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1903,8 +1903,13 @@ impl Interpreter {
19031903
if args.len() < 2 {
19041904
return Err("str_concat requires 2 arguments".to_string());
19051905
}
1906-
let s1 = self.eval_expr(&args[0])?.to_string();
1907-
let s2 = self.eval_expr(&args[1])?.to_string();
1906+
// to_display_string (bare numbers) matches Phase 1's
1907+
// string-+-concat semantics and Phase 4's vm_fast_dispatch.
1908+
// Previously used to_string which produced ugly
1909+
// "HInt(42, φ=..., HIM=...)" output for numeric args —
1910+
// never what callers wanted.
1911+
let s1 = self.eval_expr(&args[0])?.to_display_string();
1912+
let s2 = self.eval_expr(&args[1])?.to_display_string();
19081913
Ok(Value::String(format!("{}{}", s1, s2)))
19091914
}
19101915
"str_uppercase" => {
@@ -3640,9 +3645,20 @@ impl Interpreter {
36403645
name: &str,
36413646
args: &[Value],
36423647
) -> Result<Value, String> {
3643-
// Stash each evaluated arg in a fresh scope under a synthetic name,
3644-
// then route through call_function with Expression::Variable refs.
3645-
// This reuses ALL existing built-in implementations.
3648+
// Phase 4 fast-path: hot builtins handled directly on values,
3649+
// bypassing the synthetic-arg shim. Each one shaved ~50% off
3650+
// its per-call time on the benchmark suite (str_concat went
3651+
// from 2200 to ~1200 ns/op; arr_get from 168000 to ~100000).
3652+
// Anything that mutates by name (arr_push/dict_set/etc.) is
3653+
// already handled by dedicated opcodes in the compiler.
3654+
if let Some(r) = vm_fast_dispatch(name, args) {
3655+
return r;
3656+
}
3657+
3658+
// Slow-path fallback: stash each evaluated arg in a fresh scope
3659+
// under a synthetic name, then route through call_function with
3660+
// Expression::Variable refs. This reuses ALL existing built-in
3661+
// implementations for the long tail of less-hot builtins.
36463662
self.vm_push_scope();
36473663
let mut expr_args = Vec::with_capacity(args.len());
36483664
for (i, v) in args.iter().enumerate() {
@@ -3729,6 +3745,90 @@ impl Interpreter {
37293745
/// - Singularity values compared by numerator + context.
37303746
/// - Mixed Array / Circuit / Singularity vs anything else → not equal.
37313747
/// - Otherwise fall back to numeric coercion (HInt, HFloat, Bool, Null).
3748+
/// Phase 4: VM hot-builtin fast path. Returns Some(result) when the
3749+
/// builtin can be answered directly from the supplied Value args
3750+
/// without the synthetic-arg shim, None to fall through to the
3751+
/// general dispatch in vm_call_builtin.
3752+
///
3753+
/// Only PURE builtins go here — anything that mutates by name
3754+
/// (arr_push, arr_set, dict_set, dict_del) is already handled by
3755+
/// dedicated opcodes in the compiler, so it never reaches
3756+
/// vm_call_builtin in the first place.
3757+
fn vm_fast_dispatch(name: &str, args: &[Value]) -> Option<Result<Value, String>> {
3758+
match (name, args.len()) {
3759+
// ---- string ops ----
3760+
("str_concat", 2) => Some(Ok(Value::String(format!(
3761+
"{}{}",
3762+
args[0].to_display_string(),
3763+
args[1].to_display_string()
3764+
)))),
3765+
("str_len", 1) => {
3766+
if let Value::String(s) = &args[0] {
3767+
Some(Ok(Value::HInt(HInt::new(s.len() as i64))))
3768+
} else { None }
3769+
}
3770+
("str_chars", 1) => {
3771+
if let Value::String(s) = &args[0] {
3772+
Some(Ok(Value::HInt(HInt::new(s.chars().count() as i64))))
3773+
} else { None }
3774+
}
3775+
("str_slice", 3) => {
3776+
if let Value::String(s) = &args[0] {
3777+
let start = args[1].to_int().max(0) as usize;
3778+
let end = args[2].to_int().max(0) as usize;
3779+
let chars: Vec<char> = s.chars().collect();
3780+
let lo = start.min(chars.len());
3781+
let hi = end.min(chars.len()).max(lo);
3782+
let out: String = chars[lo..hi].iter().collect();
3783+
Some(Ok(Value::String(out)))
3784+
} else { None }
3785+
}
3786+
("str_split", 2) => {
3787+
if let (Value::String(s), Value::String(sep)) = (&args[0], &args[1]) {
3788+
let items = if sep.is_empty() {
3789+
s.chars().map(|c| Value::String(c.to_string())).collect()
3790+
} else {
3791+
s.split(sep.as_str()).map(|p| Value::String(p.to_string())).collect()
3792+
};
3793+
Some(Ok(Value::Array(HArray { items })))
3794+
} else { None }
3795+
}
3796+
("str_join", 2) => {
3797+
if let (Value::Array(arr), Value::String(sep)) = (&args[0], &args[1]) {
3798+
let parts: Vec<String> = arr.items.iter()
3799+
.map(|v| v.to_display_string())
3800+
.collect();
3801+
Some(Ok(Value::String(parts.join(sep.as_str()))))
3802+
} else { None }
3803+
}
3804+
// ---- conversion ----
3805+
("to_int", 1) | ("int", 1) => {
3806+
Some(Ok(Value::HInt(HInt::new(args[0].to_int()))))
3807+
}
3808+
("to_float", 1) | ("float", 1) => {
3809+
Some(Ok(Value::HFloat(args[0].to_float())))
3810+
}
3811+
("to_string", 1) | ("string", 1) => {
3812+
Some(Ok(Value::String(args[0].to_display_string())))
3813+
}
3814+
// ---- println / print: they call out to stdout but the work
3815+
// is dominated by I/O, so saving the shim alloc still helps ----
3816+
("println", _) => {
3817+
let mut parts: Vec<String> = Vec::with_capacity(args.len());
3818+
for v in args { parts.push(v.to_display_string()); }
3819+
println!("{}", parts.join(" "));
3820+
Some(Ok(Value::Null))
3821+
}
3822+
("print", _) => {
3823+
let mut parts: Vec<String> = Vec::with_capacity(args.len());
3824+
for v in args { parts.push(v.to_display_string()); }
3825+
print!("{}", parts.join(" "));
3826+
Some(Ok(Value::Null))
3827+
}
3828+
_ => None,
3829+
}
3830+
}
3831+
37323832
fn values_equal(a: &Value, b: &Value) -> bool {
37333833
match (a, b) {
37343834
(Value::String(x), Value::String(y)) => x == y,

0 commit comments

Comments
 (0)