Skip to content

Commit 37fa0c5

Browse files
Path A.3: bench tree-walk vs VM vs JIT-via-dispatch vs JIT-direct
Closes the open question from Session E: how much of the 272x JIT speedup is "vs tree-walk" vs "vs the bytecode VM"? Same workload (bench_loop(N) = sum factorial(12) over N iters) run through four modes: Mode Per-iter ns vs tree-walk Tree-walk 14,462 1.0x Bytecode VM 6,929 2.1x JIT-via-dispatch 58.2 249x JIT-direct 53.1 272x Two findings worth surfacing: 1. The bytecode VM is 2.1x faster than tree-walk, not 10x. Most of the JIT's measured speedup is JIT itself, not VM-vs-tree-walk. The honest comparison: JIT is 119x faster than VM on this workload, on top of VM being 2x faster than tree-walk. The known VM shim costs (vm_call_builtin synthetic-arg path) cap how much the bytecode VM can win without further optimization. 2. JIT-via-dispatch is essentially as fast as JIT-direct (58.2 vs 53.1 ns, 9% overhead). The OMC tree-walk loop wrapping a JIT'd fn body adds negligible cost — Session E's 272x microbench number IS what real OMC programs experience when the hot fn is JIT'd. The implication: enabling OMC_HBIT_JIT=1 on a program with a JIT- eligible hot fn delivers close to the full 250x on those calls, not just the 5% headroom over JIT-direct that microbenches measure. docs/jit_benchmark.md updated with the Path A.3 section. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent af35e73 commit 37fa0c5

2 files changed

Lines changed: 158 additions & 0 deletions

File tree

docs/jit_benchmark.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,24 @@ Each function is called 200,000 times in a tight loop. Wall-clock per call is re
5252
| `factorial(12)` — 12 recursive calls + multiplies | 14,309 ns | 52.6 ns | **272×** |
5353
| `sum_to(100)` — 100-iter while loop with locals | 53,202 ns | 267 ns | **200×** |
5454

55+
## Path A.3: same workload, four execution modes
56+
57+
Closes the comparison gap from Session E (which only timed tree-walk vs JIT-direct). The same `bench_loop(N) = sum factorial(12) over N iters` workload runs through four execution strategies; per-iteration time reported as total/N.
58+
59+
| Mode | Per-iter ns | Speedup vs tree-walk | Notes |
60+
|---|--:|--:|---|
61+
| Tree-walk only | 14,462 | 1.0× | reference |
62+
| Bytecode VM | 6,929 | 2.1× | OMC's existing fast-dispatch path |
63+
| JIT-via-dispatch | 58.2 | 249× | Tree-walk runs the loop; factorial routed through JIT |
64+
| JIT-direct (Rust loop) | 53.1 | 272× | Bypasses OMC entirely on the hot path |
65+
66+
**Two findings:**
67+
68+
1. **The bytecode VM is 2.1× faster than tree-walk, not 10×.** This matches the prior known costs of `vm_call_builtin`'s synthetic-arg shim and other VM-side dispatch overhead. The JIT's real comparison advantage is **119× faster than the bytecode VM**, on top of VM being 2× faster than tree-walk.
69+
2. **JIT-via-dispatch (58.2 ns) is essentially as fast as JIT-direct (53.1 ns)** — only ~9% overhead from routing through the Interpreter's dispatch hook. This means the 272× number from the Session E microbench is what real OMC programs experience; the OMC tree-walk loop wrapping a JIT'd fn body is negligible.
70+
71+
The implication: enabling `OMC_HBIT_JIT=1` on a real OMC program where the hot fn is JIT-eligible (pure-int, no strings/arrays/builtins) will give close to the full ~250× speedup on that fn's invocations.
72+
5573
## Path A.1: `@hbit + @harmony + @predict` (Sessions F+G)
5674

5775
After Sessions F (phi_shadow → divergent β) and G (harmony() intrinsic + extern call), an OMC fn can use harmony as a runtime signal to choose between cheap and expensive code paths. The bench source:

omnimcode-cli/src/bench.rs

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,20 @@ fn no_pred_always_expensive(x) {
8787
fn no_pred_always_cheap(x) {
8888
return cheap_path(x);
8989
}
90+
91+
# --- Path A.3: same workload, four execution modes ---
92+
# A loop wrapper around factorial(12). Lets the VM and tree-walk
93+
# benches measure on the same bytecode shape as JIT does. Per-iter
94+
# time = total_call_time / N_INNER.
95+
fn bench_loop(iters) {
96+
h sum = 0;
97+
h k = 0;
98+
while k < iters {
99+
sum = sum + factorial(12);
100+
k = k + 1;
101+
}
102+
return sum;
103+
}
90104
"#;
91105

92106
fn main() {
@@ -105,6 +119,12 @@ fn main() {
105119
println!();
106120
bench_fn("sum_to", iters, 100);
107121

122+
println!();
123+
println!("=== Path A.3: same workload, four execution modes ===");
124+
println!("Workload: bench_loop(N) = sum factorial(12) over N inner iters.");
125+
println!();
126+
bench_four_modes(50_000);
127+
108128
println!();
109129
println!("=== Path A.1: harmony-gated branch elision ===");
110130
println!("Two regimes:");
@@ -172,6 +192,126 @@ fn bench_fn(fn_name: &str, iters: usize, arg: i64) {
172192
}
173193
}
174194

195+
fn bench_four_modes(n_inner: usize) {
196+
use omnimcode_codegen::JittedFn;
197+
use omnimcode_core::value::HInt;
198+
use std::collections::HashMap;
199+
use std::rc::Rc;
200+
201+
let n_inner_i = n_inner as i64;
202+
println!("--- N_INNER = {} (inner loop count) ---", n_inner);
203+
204+
// Mode 1: tree-walk only.
205+
{
206+
let mut parser = Parser::new(SOURCE);
207+
let statements = parser.parse().expect("parse");
208+
let mut interp = Interpreter::new();
209+
interp.execute(statements).expect("exec");
210+
let start = Instant::now();
211+
let _ = interp
212+
.call_function_with_values("bench_loop", &[Value::HInt(HInt::new(n_inner_i))])
213+
.expect("call");
214+
let total_ns = start.elapsed().as_nanos() as f64;
215+
println!(
216+
" tree-walk total={:>10.2}ms per-iter={:>10.1}ns",
217+
total_ns / 1.0e6,
218+
total_ns / n_inner as f64
219+
);
220+
}
221+
222+
// Mode 2: bytecode VM. Compose a tiny program whose `__main__` is
223+
// `bench_loop(N)` and run it through Vm::run_module. The VM sets
224+
// up its own scope/dispatch, so we measure the run_module call.
225+
{
226+
let mut parser = Parser::new(SOURCE);
227+
let mut statements = parser.parse().expect("parse");
228+
// Append a top-level call so __main__ runs bench_loop(N).
229+
let extra = format!("h __vm_result = bench_loop({});", n_inner_i);
230+
let mut extra_stmts = Parser::new(&extra).parse().expect("parse extra");
231+
statements.append(&mut extra_stmts);
232+
let module = omnimcode_core::compiler::compile_program(&statements).expect("compile");
233+
let mut vm = omnimcode_core::vm::Vm::new();
234+
vm.interp_mut().register_user_functions(&statements);
235+
let start = Instant::now();
236+
let _ = vm.run_module(&module).expect("run_module");
237+
let total_ns = start.elapsed().as_nanos() as f64;
238+
println!(
239+
" bytecode VM total={:>10.2}ms per-iter={:>10.1}ns",
240+
total_ns / 1.0e6,
241+
total_ns / n_inner as f64
242+
);
243+
}
244+
245+
// Mode 3: JIT-via-dispatch. Tree-walk runs the outer loop; each
246+
// factorial(12) call is intercepted by the JIT dispatch hook and
247+
// routed through native code. This is what the CLI's
248+
// OMC_HBIT_JIT=1 path produces for real OMC programs.
249+
{
250+
let mut parser = Parser::new(SOURCE);
251+
let statements = parser.parse().expect("parse");
252+
let module = omnimcode_core::compiler::compile_program(&statements).expect("compile");
253+
let context = Context::create();
254+
let jit = JitContext::new(&context).expect("jit");
255+
let jitted = jit.jit_module(&module).expect("jit_module");
256+
let jitted_for_hook: HashMap<String, JittedFn> = jitted.clone();
257+
let dispatch: omnimcode_core::interpreter::JitDispatch = Rc::new(
258+
move |name: &str, args: &[Value]| {
259+
let jf = jitted_for_hook.get(name)?;
260+
if args.len() != jf.arity {
261+
return None;
262+
}
263+
let mut int_args = Vec::with_capacity(args.len());
264+
for a in args {
265+
match a {
266+
Value::HInt(h) => int_args.push(h.value),
267+
Value::Bool(b) => int_args.push(if *b { 1 } else { 0 }),
268+
_ => return None,
269+
}
270+
}
271+
jf.call(&int_args).map(|r| Ok(Value::HInt(HInt::new(r))))
272+
},
273+
);
274+
let mut interp = Interpreter::new();
275+
interp.set_jit_dispatch(Some(dispatch));
276+
interp.execute(statements).expect("exec");
277+
let start = Instant::now();
278+
let _ = interp
279+
.call_function_with_values("bench_loop", &[Value::HInt(HInt::new(n_inner_i))])
280+
.expect("call");
281+
let total_ns = start.elapsed().as_nanos() as f64;
282+
println!(
283+
" JIT-via-dispatch total={:>10.2}ms per-iter={:>10.1}ns (loop is tree-walk, factorial is JIT)",
284+
total_ns / 1.0e6,
285+
total_ns / n_inner as f64
286+
);
287+
}
288+
289+
// Mode 4: JIT-direct. Skip OMC entirely for the loop — call
290+
// factorial's fn pointer in a native Rust loop. This is the
291+
// theoretical best (no OMC dispatch on the hot path).
292+
{
293+
let mut parser = Parser::new(SOURCE);
294+
let statements = parser.parse().expect("parse");
295+
let module = omnimcode_core::compiler::compile_program(&statements).expect("compile");
296+
let context = Context::create();
297+
let jit = JitContext::new(&context).expect("jit");
298+
let jitted = jit.jit_module(&module).expect("jit_module");
299+
let factorial = jitted.get("factorial").expect("factorial JIT'd");
300+
let start = Instant::now();
301+
let mut sum: i64 = 0;
302+
for _ in 0..n_inner {
303+
sum = sum.wrapping_add(factorial.call(&[12]).expect("call"));
304+
}
305+
let total_ns = start.elapsed().as_nanos() as f64;
306+
let _ = sum;
307+
println!(
308+
" JIT-direct total={:>10.2}ms per-iter={:>10.1}ns (Rust loop, no OMC dispatch)",
309+
total_ns / 1.0e6,
310+
total_ns / n_inner as f64
311+
);
312+
}
313+
}
314+
175315
fn bench_predict(iters: usize) {
176316
let mut parser = Parser::new(SOURCE);
177317
let statements = parser.parse().expect("parse");

0 commit comments

Comments
 (0)