Skip to content

Commit 8d7ec74

Browse files
VM-native HOF dispatch — body execution for all user fns
Closes the last gap where compiled bytecode was driven by tree-walk: the higher-order array builtins. arr_map / arr_filter / arr_reduce / arr_any / arr_all / arr_find previously dispatched their per-element callable through call_first_class_function → invoke_user_function, which runs the body via the AST walker. Now, when the callable is a VM-compiled function, each invocation routes through run_function directly. Architecture: try_dispatch_vm_hof intercepts at the Op::Call layer, ahead of vm_call_builtin. The new vm_invoke_callable helper centralizes the captured-env push/pop bookkeeping (also refactored the existing `call(fn, args)` intercept to use it — less duplication, identical semantics). Hot-path detail: vm_invoke_callable borrows &CompiledFunction directly from module.functions instead of cloning. The clone was eating ~half the speedup on small-body benchmarks — Vec<Op> + Vec<Const> + the inline-cache Vec<Cell<u8>> add up fast when invoked per-element across a 1000-element array. With the borrow, lifetimes align cleanly since run_function takes the same &Module. Measured (200 reps × 1000-element arrays, 200k inner calls each): tree-walk VM speedup arr_map(double) 131 ms 59 ms 2.22x arr_filter(is_even) 127 ms 65 ms 1.95x arr_reduce(add) 160 ms 68 ms 2.35x These match the ~2.4x ceiling already established for direct Op::Call dispatch on recursive_fib. The remaining tree-walk paths (call_first_class_function fallback for callables whose body lives only in interp.functions, not module.functions) still work — the new helper returns None for those and falls through. 40/40 functional examples still produce byte-identical output under tree-walk and VM. 92/92 unit tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 45ad07e commit 8d7ec74

2 files changed

Lines changed: 220 additions & 43 deletions

File tree

examples/benchmarks.omc

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,66 @@ time_direct("is_fibonacci", N_RESONANCE);
182182
time_direct("harmony_value", N_RESONANCE);
183183
time_direct("recursive_fib", N_FIB);
184184

185+
# --- Higher-order builtin benchmarks ---------------------------------------
186+
# Tests arr_map / arr_filter / arr_reduce with a user-defined callable.
187+
# Pre VM-native HOF dispatch, the per-element body invocation routed through
188+
# tree-walk (call_first_class_function → invoke_user_function) even under
189+
# OMC_VM=1. After: every body invocation hits run_function directly. The
190+
# delta here is the cost of the body × N — small bodies still show ~1.4×
191+
# because the per-call overhead dominates them.
192+
193+
println("");
194+
println(str_repeat("=", 70));
195+
println("Higher-order builtins (arr_map/filter/reduce, VM-native dispatch)");
196+
println(str_repeat("=", 70));
197+
198+
h N_HOF = 200; # reps over a 1000-element array
199+
h N_HOF_INNER = 1000; # array size
200+
201+
fn _hof_double(x) { return x * 2; }
202+
fn _hof_is_even(x) { return x % 2 == 0; }
203+
fn _hof_add(a, b) { return a + b; }
204+
205+
fn _hof_build_xs(n) {
206+
h xs = [];
207+
h i = 0;
208+
while i < n { arr_push(xs, i); i = i + 1; }
209+
return xs;
210+
}
211+
212+
h _hof_xs = _hof_build_xs(N_HOF_INNER);
213+
214+
fn bench_hof_map(reps) {
215+
h i = 0;
216+
while i < reps {
217+
h ys = arr_map(_hof_xs, _hof_double);
218+
i = i + 1;
219+
}
220+
return reps;
221+
}
222+
223+
fn bench_hof_filter(reps) {
224+
h i = 0;
225+
while i < reps {
226+
h ys = arr_filter(_hof_xs, _hof_is_even);
227+
i = i + 1;
228+
}
229+
return reps;
230+
}
231+
232+
fn bench_hof_reduce(reps) {
233+
h i = 0;
234+
while i < reps {
235+
h s = arr_reduce(_hof_xs, _hof_add, 0);
236+
i = i + 1;
237+
}
238+
return reps;
239+
}
240+
241+
run_bench("arr_map(double)", bench_hof_map, N_HOF);
242+
run_bench("arr_filter(is_even)", bench_hof_filter, N_HOF);
243+
run_bench("arr_reduce(add)", bench_hof_reduce, N_HOF);
244+
185245
println(str_repeat("=", 70));
186246
println("Done. Run with OMC_VM=1 to compare against the Rust bytecode VM.");
187247
println(str_repeat("=", 70));

omnimcode-core/src/vm.rs

Lines changed: 160 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -285,54 +285,30 @@ impl Vm {
285285
}
286286
} else if name == "call" && argvals.len() == 2 {
287287
// VM-native dispatch for reflective `call(fn, args)`.
288-
// Without this special case, every reflective call
289-
// routes through vm_call_builtin → tree-walk and
290-
// loses the bytecode-VM hot-path advantage. With
291-
// it, `call(test_name, args)` in the test runner
292-
// and `call(fn, args)` everywhere else execute the
293-
// body via run_function. Real ~2.4× speedup on
294-
// call-heavy workloads (verified on recursive fib
295-
// dispatched through `call`).
296-
//
297-
// Falls through to vm_call_builtin if the target
298-
// isn't a VM-compiled function (e.g. tree-walk
299-
// builtin called via reflection — rare but valid).
288+
// Routes through vm_invoke_callable so the body runs
289+
// as bytecode rather than tree-walk. ~2.4× speedup on
290+
// call-heavy workloads (verified: recursive fib via
291+
// `call`).
300292
let fn_v = &argvals[0];
301-
let args_v = &argvals[1];
302-
let target_name = match fn_v {
303-
Value::Function { name, .. } => Some(name.clone()),
304-
Value::String(s) => Some(s.clone()),
305-
_ => None,
306-
};
307-
let unpacked_args = match args_v {
293+
let unpacked = match &argvals[1] {
308294
Value::Array(a) => Some(a.items.clone()),
309295
_ => None,
310296
};
311-
match (target_name, unpacked_args) {
312-
(Some(tname), Some(arg_list)) if module.functions.contains_key(&tname) => {
313-
// VM-native dispatch path.
314-
let captured = if let Value::Function { captured, .. } = fn_v {
315-
captured.clone()
316-
} else {
317-
None
318-
};
319-
let pushed = captured.is_some();
320-
if let Some(env) = captured {
321-
self.interp.vm_push_closure_env(env);
322-
}
323-
let callee = module.functions.get(&tname).expect("checked above");
324-
let r = self.run_function(callee, &arg_list, module);
325-
if pushed {
326-
// Drop the captured env frame we pushed.
327-
// Use a small helper rather than poking
328-
// interp.locals directly so the VM
329-
// doesn't reach into Interpreter internals.
330-
self.interp.vm_pop_closure_env();
331-
}
332-
r?
333-
}
334-
_ => self.interp.vm_call_builtin(name, &argvals)?,
297+
match unpacked {
298+
Some(arg_list) => match self.vm_invoke_callable(fn_v, &arg_list, module) {
299+
Some(r) => r?,
300+
None => self.interp.vm_call_builtin(name, &argvals)?,
301+
},
302+
None => self.interp.vm_call_builtin(name, &argvals)?,
335303
}
304+
} else if let Some(v) = self.try_dispatch_vm_hof(name, &argvals, module)? {
305+
// VM-native higher-order builtins (arr_map / arr_filter /
306+
// arr_reduce / arr_any / arr_all / arr_find). When the
307+
// callable is a VM-compiled function, each per-element
308+
// invocation runs through run_function — closing the
309+
// last gap where compiled bytecode was being driven by
310+
// tree-walk just to satisfy a HOF iteration loop.
311+
v
336312
} else {
337313
self.interp.vm_call_builtin(name, &argvals)?
338314
};
@@ -513,6 +489,147 @@ impl Vm {
513489
self.interp.vm_pop_scope();
514490
Ok(stack.pop().unwrap_or(Value::Null))
515491
}
492+
493+
/// Invoke a Value::Function (or string naming a function) via the
494+
/// VM's bytecode hot path when possible. Returns None when the
495+
/// callee has no compiled body in module.functions — caller should
496+
/// fall back to tree-walk dispatch.
497+
///
498+
/// Centralizes the captured-env push/pop bookkeeping that was
499+
/// previously inlined at every Op::Call intercept site.
500+
fn vm_invoke_callable(
501+
&mut self,
502+
fn_v: &Value,
503+
args: &[Value],
504+
module: &Module,
505+
) -> Option<Result<Value, String>> {
506+
let (name, captured) = match fn_v {
507+
Value::Function { name, captured } => (name.clone(), captured.clone()),
508+
Value::String(s) => (s.clone(), None),
509+
_ => return None,
510+
};
511+
// Borrow the CompiledFunction directly out of module — its
512+
// lifetime is tied to the &Module we pass through to
513+
// run_function, so the immutable borrow stays valid through
514+
// the call. Avoids cloning the (Vec<Op>, Vec<Const>, ...)
515+
// payload on every HOF iteration.
516+
let callee = module.functions.get(&name)?;
517+
let pushed = captured.is_some();
518+
if let Some(env) = captured {
519+
self.interp.vm_push_closure_env(env);
520+
}
521+
let r = self.run_function(callee, args, module);
522+
if pushed {
523+
self.interp.vm_pop_closure_env();
524+
}
525+
Some(r)
526+
}
527+
528+
/// VM-native dispatch for the higher-order array builtins. Replaces
529+
/// the otherwise tree-walk path where arr_map et al. invoke
530+
/// `call_first_class_function → invoke_user_function`, which runs
531+
/// the callable's body via the AST walker. With this helper, when
532+
/// the callable is a VM-compiled function (which is the common
533+
/// case), every per-element invocation hits run_function instead.
534+
///
535+
/// Returns:
536+
/// Ok(Some(v)) — handled; v is the result the VM should push
537+
/// Ok(None) — not a HOF or no VM-native body, fall back to
538+
/// vm_call_builtin
539+
/// Err — dispatched but the body errored
540+
fn try_dispatch_vm_hof(
541+
&mut self,
542+
name: &str,
543+
argvals: &[Value],
544+
module: &Module,
545+
) -> Result<Option<Value>, String> {
546+
// All HOFs in OMC take the array first, the callable second.
547+
// arr_reduce additionally takes an initial-accumulator value.
548+
if argvals.len() < 2 {
549+
return Ok(None);
550+
}
551+
let fn_v = &argvals[1];
552+
// Cheap pre-flight: require the callable to resolve to a
553+
// VM-compiled function before we take over. Otherwise we'd
554+
// duplicate the fallback work vm_call_builtin already handles.
555+
let target_name = match fn_v {
556+
Value::Function { name, .. } => name.clone(),
557+
Value::String(s) => s.clone(),
558+
_ => return Ok(None),
559+
};
560+
if !module.functions.contains_key(&target_name) {
561+
return Ok(None);
562+
}
563+
let arr_items: Vec<Value> = match &argvals[0] {
564+
Value::Array(a) => a.items.clone(),
565+
_ => return Ok(None),
566+
};
567+
568+
match name {
569+
"arr_map" => {
570+
let mut out = Vec::with_capacity(arr_items.len());
571+
for item in arr_items {
572+
let r = self.vm_invoke_callable(fn_v, &[item], module)
573+
.expect("checked target above");
574+
out.push(r?);
575+
}
576+
Ok(Some(Value::Array(HArray { items: out })))
577+
}
578+
"arr_filter" => {
579+
let mut out = Vec::new();
580+
for item in arr_items {
581+
let r = self.vm_invoke_callable(fn_v, &[item.clone()], module)
582+
.expect("checked target above")?;
583+
if r.to_bool() {
584+
out.push(item);
585+
}
586+
}
587+
Ok(Some(Value::Array(HArray { items: out })))
588+
}
589+
"arr_reduce" => {
590+
if argvals.len() < 3 {
591+
return Ok(None);
592+
}
593+
let mut acc = argvals[2].clone();
594+
for item in arr_items {
595+
acc = self.vm_invoke_callable(fn_v, &[acc, item], module)
596+
.expect("checked target above")?;
597+
}
598+
Ok(Some(acc))
599+
}
600+
"arr_any" => {
601+
for item in arr_items {
602+
let r = self.vm_invoke_callable(fn_v, &[item], module)
603+
.expect("checked target above")?;
604+
if r.to_bool() {
605+
return Ok(Some(Value::HInt(HInt::new(1))));
606+
}
607+
}
608+
Ok(Some(Value::HInt(HInt::new(0))))
609+
}
610+
"arr_all" => {
611+
for item in arr_items {
612+
let r = self.vm_invoke_callable(fn_v, &[item], module)
613+
.expect("checked target above")?;
614+
if !r.to_bool() {
615+
return Ok(Some(Value::HInt(HInt::new(0))));
616+
}
617+
}
618+
Ok(Some(Value::HInt(HInt::new(1))))
619+
}
620+
"arr_find" => {
621+
for item in arr_items {
622+
let r = self.vm_invoke_callable(fn_v, &[item.clone()], module)
623+
.expect("checked target above")?;
624+
if r.to_bool() {
625+
return Ok(Some(item));
626+
}
627+
}
628+
Ok(Some(Value::Null))
629+
}
630+
_ => Ok(None),
631+
}
632+
}
516633
}
517634

518635
// ---------- helpers ----------

0 commit comments

Comments
 (0)