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:
2026-05-10 01:03:21 +02:00
parent 105ad3fe80
commit 4cacfcbdac
8 changed files with 626 additions and 0 deletions
+17
View File
@@ -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
View File
@@ -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.