bench: mono-vs-vdisp micro-benchmark + revised Decision 11 rationale
Hypothesis-driven measurement of "did monomorphisation actually buy us performance?" on a 100M-iter LCG hot loop, AILang mono'd code vs. four C reference variants (direct-inlinable, direct- noinline, indirect-monomorphic, indirect-polymorphic). Zen 3, clang -O2, median-of-15. Headline: H1 supported, but the mechanism is inlining, not dispatch shape. AILang mono = hand-C direct (1.000x). Indirect- monomorphic = direct-noinline (1.000x) — saturating branch predictor makes the indirect-call cost vanish on this hardware. Inlining is the actual 3.31x win; polymorphic indirect adds another 21% predictor-miss penalty. DESIGN.md Decision 11 gains a rationale paragraph reframing mono as inlining-enabler rather than indirect-call-eliminator, with explicit pointer to the bench. JOURNAL entry records the full methodology, ratios, limitations, and the side-effect mono-pass env.globals-seeding bug surfaced while building the AILang fixture (separate RED-first debug iter to follow).
This commit is contained in:
@@ -1606,6 +1606,23 @@ After this pass, the IR contains no class machinery — only ordinary
|
||||
monomorphic functions and direct calls. Codegen sees no difference
|
||||
between a hand-written `show_int` and a synthesised `show__Int`.
|
||||
|
||||
**Why mono, not virtual dispatch (the empirically-grounded version).**
|
||||
The original rationale implicitly argued that mono saves a per-call
|
||||
indirect-jump cost. The 2026-05-10 mono-vs-vdisp micro-benchmark
|
||||
(`bench/mono_dispatch.py`, JOURNAL entry of the same date) refutes
|
||||
that specific claim: on a saturating branch predictor with a
|
||||
monomorphic indirect target, indirect dispatch is free relative to
|
||||
the same-shape non-inlined direct call (1.000x on Zen 3 / clang -O2).
|
||||
The correct rationale is one level up: **mono makes the call target
|
||||
visible to the optimiser, unlocking inlining and downstream loop
|
||||
transformations that virtual dispatch prevents in principle.** The
|
||||
measured end-to-end win on a tight LCG hot loop is 3.31x vs
|
||||
non-inlinable direct call and 4.00x vs polymorphic vdisp (4 distinct
|
||||
targets cycling). On larger callee bodies or cold call sites the
|
||||
inlining win shrinks toward zero, but the architectural claim — "mono
|
||||
enables optimisations vdisp forbids" — holds across the spectrum,
|
||||
while the older "saves an indirect call" framing does not.
|
||||
|
||||
The separator is `__` rather than `#` or `@` because `#` and `@`
|
||||
are invalid in LLVM IR global identifiers (the IR verifier rejects
|
||||
them inside `@ail_<module>_<def>` mangled names). `__` is legal in
|
||||
|
||||
+102
@@ -11810,3 +11810,105 @@ milestone (gated on user-author demand for primitive
|
||||
bench re-baselining), the primitive-name-set consolidation, or
|
||||
unrelated work. The milestone-cycle dictates `brainstorm` for
|
||||
whichever lands next.
|
||||
|
||||
## 2026-05-10 — Bench: mono-vs-virtual-dispatch micro-benchmark
|
||||
|
||||
Hypothesis-driven `ailang-bencher` run, prompted by the open
|
||||
question "did monomorphisation actually buy us performance, or is
|
||||
it purely a correctness/architectural choice?" Decision 11's
|
||||
original framing in DESIGN.md (line 1462) reads "no runtime cost,
|
||||
no dictionary passing, no vtables" — a true statement at the
|
||||
mechanical level, but the **rationale** for *why* mono is faster
|
||||
than vdisp had never been measured. This iter measures it.
|
||||
|
||||
**Setup.** 100M-iter tail-recursive hot loop, body
|
||||
`acc' = acc + foo(acc + i)` with `foo(x) = x*1103515245 + 12345`
|
||||
(LCG step, defeats closed-form folding via serial dependency).
|
||||
Int-only args → zero RC traffic, isolating dispatch-shape from
|
||||
allocator effects.
|
||||
|
||||
Five binaries, all clang -O2 (matches AILang lower path), Zen 3
|
||||
(Ryzen 5900X), 15 runs each (slowest dropped, median reported):
|
||||
|
||||
| binary | median (s) | ratio |
|
||||
|-------------------------|------------|----------|
|
||||
| AILang mono | 0.0435 | 1.000x |
|
||||
| C direct (inlinable) | 0.0435 | 1.000x |
|
||||
| C direct (noinline) | 0.1439 | 3.310x |
|
||||
| C indirect (mono fnptr) | 0.1440 | 3.310x |
|
||||
| C indirect (4-fnptr) | 0.1739 | 4.000x |
|
||||
|
||||
**Three substantive findings:**
|
||||
|
||||
1. **AILang mono'd code is bit-for-perf-identical to hand-C
|
||||
direct (1.000x).** No codegen-quality gap; LLVM treats the
|
||||
mono'd direct call exactly as it does a normal C call.
|
||||
2. **Inlining is the actual win (3.31x).** When the callee body
|
||||
is visible, clang vectorises and unrolls the loop. The
|
||||
`direct-noinline` variant — same direct call, but blocked from
|
||||
inlining — runs identically to the indirect-monomorphic
|
||||
variant, which is the empirical core of this iter.
|
||||
3. **Indirect-monomorphic dispatch is essentially free on Zen 3
|
||||
(1.000x vs direct-noinline).** The branch predictor saturates
|
||||
the BTB on the single target. Polymorphic indirect (4 distinct
|
||||
fnptrs cycling) adds 21% on top — the real-world dict-passing
|
||||
penalty in a multi-instance codebase.
|
||||
|
||||
**The bench refines Decision 11's rationale.** The original
|
||||
framing implicitly argued that mono avoids per-call indirect-jump
|
||||
cost. That argument does not hold on modern x86 with a saturating
|
||||
branch predictor. The correct argument is one level up: **mono
|
||||
makes the call target visible to the optimiser, which unlocks
|
||||
inlining and downstream loop transformations that virtual
|
||||
dispatch prevents in principle.** The 3.3x measured here is an
|
||||
*upper bound* (tiny callee, hot loop, ideal-case inlining); on
|
||||
larger callee bodies or cold call sites the inlining win shrinks
|
||||
toward zero. But the architectural claim — "mono enables
|
||||
optimisations vdisp forbids" — survives across the whole
|
||||
spectrum, while the original "saves an indirect call" framing
|
||||
does not.
|
||||
|
||||
**End-to-end win for the user on this fixture:** 3.3x vs
|
||||
monomorphic vdisp, 4.0x vs polymorphic vdisp.
|
||||
|
||||
**Limitations (binding):**
|
||||
|
||||
- Synthetic micro-bench, friendliest possible case for inlining.
|
||||
Real programs span the inlining-budget spectrum.
|
||||
- Int-only args → zero RC traffic. Heap-typed args would
|
||||
additionally cost dict-RC traffic under hypothetical vdisp;
|
||||
this bench does not measure that.
|
||||
- AMD Zen 3 has a strong predictor. Older x86 / ARM would show
|
||||
larger indirect-vs-direct deltas on the monomorphic case.
|
||||
- Single-arity, single-instance call site. A multi-instance
|
||||
polymorphic call site would hit the 1.21x predictor-miss regime.
|
||||
|
||||
**Side effect: mono-pass `env.globals`-seeding bug surfaced.**
|
||||
While building the AILang fixture, a self-recursive top-level fn
|
||||
in a class-bearing module trips
|
||||
`monomorphise_workspace: unknown identifier`. Root cause:
|
||||
`mono::collect_mono_targets` (`crates/ailang-check/src/mono.rs`
|
||||
near line 475) does not seed `env.globals` from
|
||||
`env.module_globals[mname]` before calling `synth`, unlike the
|
||||
working pattern in `lib.rs:1135-1139`. The bencher worked around
|
||||
it by wrapping the recursive loop as a class method; a regular
|
||||
non-class recursive fn in a class-bearing module fails to
|
||||
compile today. RED-first debug iter follows.
|
||||
|
||||
**Files added:**
|
||||
|
||||
- `examples/bench_mono_dispatch.ail.json` — AILang side fixture
|
||||
- `bench/reference/bench_mono_direct.c` — direct-inlinable C
|
||||
- `bench/reference/bench_mono_direct_noinline.c` — direct-noinline C
|
||||
- `bench/reference/bench_mono_indirect.c` — indirect-monomorphic C
|
||||
- `bench/reference/bench_mono_indirect_polymorphic.c` — indirect-poly C
|
||||
- `bench/mono_dispatch.py` — harness (median-of-15, slowest-drop)
|
||||
|
||||
**Action items consumed by this iter:**
|
||||
|
||||
- DESIGN.md Decision 11 — performance-rationale paragraph appended,
|
||||
pointing at this entry and reframing mono as inlining-enabler.
|
||||
|
||||
**Action items spawned by this iter:**
|
||||
|
||||
- RED-first debug iter for the `env.globals` mono bug.
|
||||
|
||||
Reference in New Issue
Block a user