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:
Executable
+194
@@ -0,0 +1,194 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Mono-vs-virtual-dispatch micro-bench (one-off, hypothesis-driven).
|
||||||
|
#
|
||||||
|
# Hypothesis (H1): Monomorphised class-method calls in AILang are
|
||||||
|
# measurably faster than the equivalent function-pointer-indirected
|
||||||
|
# variant on a tight hot loop.
|
||||||
|
#
|
||||||
|
# What this harness measures:
|
||||||
|
# - AILang mono'd binary at -O2 --alloc=rc on
|
||||||
|
# examples/bench_mono_dispatch.ail.json
|
||||||
|
# - Hand-C reference, three variants:
|
||||||
|
# direct-inlinable — foo() inlinable, direct call
|
||||||
|
# direct-noinline — foo() noinline, direct call
|
||||||
|
# indirect — foo() noinline, called via volatile fnptr
|
||||||
|
#
|
||||||
|
# All four binaries run the same algorithm: a 100M-iter tail-recursive
|
||||||
|
# loop accumulating `acc + foo(acc + i)` where `foo(x) = x*1103515245
|
||||||
|
# + 12345`. The loop has a serial data dependency on `acc` so clang
|
||||||
|
# cannot algebraically close-form-fold it.
|
||||||
|
#
|
||||||
|
# Ratios reported (N runs, slowest dropped, median of the rest):
|
||||||
|
#
|
||||||
|
# ail / direct-inlinable — codegen-quality gap. Should be ~1.0x
|
||||||
|
# if mono produces an equivalent IR.
|
||||||
|
# direct-noinline / direct-inlinable
|
||||||
|
# — inlining benefit when callee is
|
||||||
|
# statically known.
|
||||||
|
# indirect / direct-noinline
|
||||||
|
# — THE actual mono-vs-vdisp delta on this
|
||||||
|
# hardware when both arms deny inlining.
|
||||||
|
# ail / indirect — end-to-end win: real workload (mono'd
|
||||||
|
# AILang, where LLVM CAN inline) vs
|
||||||
|
# hypothetical fnptr-dispatched AILang.
|
||||||
|
#
|
||||||
|
# What this bench does NOT show:
|
||||||
|
# - It does not measure dictionary-passing in any literal sense —
|
||||||
|
# AILang has no dict-passing implementation. The fnptr indirection
|
||||||
|
# stands in for "dispatch through an opaque target", which is the
|
||||||
|
# load-bearing optimiser barrier any vdisp scheme imposes.
|
||||||
|
# - It does not measure RC traffic. The fixture uses Int args (no
|
||||||
|
# heap allocation), so RC is a no-op. This is intentional: we
|
||||||
|
# are measuring DISPATCH cost, not RC cost. Dictionary RC traffic
|
||||||
|
# under a hypothetical vdisp implementation would be additional
|
||||||
|
# overhead on top of what `indirect` measures here.
|
||||||
|
# - It does not exercise polymorphic call sites with multiple
|
||||||
|
# instances (megamorphic dispatch). The fnptr is monomorphic
|
||||||
|
# in the C indirect variant; a multi-instance bench would
|
||||||
|
# produce a larger indirect/direct gap due to predictor misses.
|
||||||
|
#
|
||||||
|
# Usage: bench/mono_dispatch.py [-n RUNS]
|
||||||
|
# -n RUNS number of timed runs per binary (default 7; min 3).
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import statistics
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
AIL = ROOT / "target" / "release" / "ail"
|
||||||
|
EXAMPLES = ROOT / "examples"
|
||||||
|
REFERENCE = ROOT / "bench" / "reference"
|
||||||
|
OUTDIR = ROOT / "target" / "bench_mono"
|
||||||
|
|
||||||
|
EXPECTED = "-2551317978420243992"
|
||||||
|
|
||||||
|
|
||||||
|
def time_one(bin_path: Path) -> tuple[float, str]:
|
||||||
|
"""Run binary, return (wall_seconds, stdout). Stdout captured for
|
||||||
|
correctness check (all four binaries must print the same value)."""
|
||||||
|
t0 = time.monotonic()
|
||||||
|
proc = subprocess.run([str(bin_path)], capture_output=True, text=True)
|
||||||
|
t1 = time.monotonic()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(f"{bin_path} exited {proc.returncode}: {proc.stderr}")
|
||||||
|
return (t1 - t0, proc.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def median_drop_slowest(runs: list[float]) -> dict[str, float]:
|
||||||
|
if len(runs) < 2:
|
||||||
|
return {"min": runs[0], "median": runs[0], "max": runs[0]}
|
||||||
|
kept = sorted(runs)[:-1]
|
||||||
|
return {
|
||||||
|
"min": min(kept),
|
||||||
|
"median": statistics.median(kept),
|
||||||
|
"max": max(kept),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
|
ap.add_argument("-n", "--runs", type=int, default=7,
|
||||||
|
help="runs per binary; min 3, default 7")
|
||||||
|
args = ap.parse_args()
|
||||||
|
if args.runs < 3:
|
||||||
|
print("--runs must be >= 3", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
OUTDIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Build AILang fixture if needed.
|
||||||
|
if not AIL.is_file():
|
||||||
|
subprocess.run(["cargo", "build", "--release", "-p", "ail"],
|
||||||
|
cwd=str(ROOT), check=True)
|
||||||
|
print(">>> building AILang fixture (-O2, --alloc=rc)", file=sys.stderr)
|
||||||
|
ail_bin = OUTDIR / "bench_mono_dispatch_ail"
|
||||||
|
subprocess.run(
|
||||||
|
[str(AIL), "build", "--opt=-O2", "--alloc=rc",
|
||||||
|
str(EXAMPLES / "bench_mono_dispatch.ail.json"), "-o", str(ail_bin)],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build the three C references.
|
||||||
|
print(">>> building C references (clang -O2)", file=sys.stderr)
|
||||||
|
# `indirect-polymorphic` produces a DIFFERENT stdout (4 distinct
|
||||||
|
# foo bodies → different acc); the harness skips the equality
|
||||||
|
# check for it but still times it.
|
||||||
|
c_specs = [
|
||||||
|
("direct-inlinable", "bench_mono_direct.c", True),
|
||||||
|
("direct-noinline", "bench_mono_direct_noinline.c", True),
|
||||||
|
("indirect", "bench_mono_indirect.c", True),
|
||||||
|
("indirect-polymorphic", "bench_mono_indirect_polymorphic.c", False),
|
||||||
|
]
|
||||||
|
c_bins: dict[str, tuple[Path, bool]] = {}
|
||||||
|
for label, src, equality_check in c_specs:
|
||||||
|
bin_path = OUTDIR / f"bench_mono_{label.replace('-', '_')}"
|
||||||
|
subprocess.run(
|
||||||
|
["clang", "-O2", "-o", str(bin_path), str(REFERENCE / src)],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
c_bins[label] = (bin_path, equality_check)
|
||||||
|
|
||||||
|
# Equality check: ail-mono + the three monomorphic-foo C variants
|
||||||
|
# must all print the EXPECTED value. The polymorphic variant has
|
||||||
|
# different `foo` semantics → different acc; we skip the equality
|
||||||
|
# check for it.
|
||||||
|
print(">>> correctness check", file=sys.stderr)
|
||||||
|
binaries: list[tuple[str, Path, bool]] = [("ail-mono", ail_bin, True)]
|
||||||
|
binaries.extend((label, path, eq) for label, (path, eq) in c_bins.items())
|
||||||
|
for label, path, eq in binaries:
|
||||||
|
_, out = time_one(path)
|
||||||
|
if eq and out != EXPECTED:
|
||||||
|
print(f"correctness fail: {label} produced {out!r}, expected {EXPECTED!r}",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not eq:
|
||||||
|
print(f" {label} stdout = {out} (equality check skipped)", file=sys.stderr)
|
||||||
|
print(f" monomorphic-foo binaries produce {EXPECTED}", file=sys.stderr)
|
||||||
|
|
||||||
|
# Time each binary RUNS times.
|
||||||
|
print(f">>> timing (runs={args.runs}, slowest dropped)", file=sys.stderr)
|
||||||
|
timings: dict[str, dict[str, float]] = {}
|
||||||
|
for label, path, _eq in binaries:
|
||||||
|
runs = [time_one(path)[0] for _ in range(args.runs)]
|
||||||
|
timings[label] = median_drop_slowest(runs)
|
||||||
|
timings[label]["raw"] = runs # full raw data for the report
|
||||||
|
|
||||||
|
# Report.
|
||||||
|
print()
|
||||||
|
print(f"=== bench_mono_dispatch ({args.runs} runs each, slowest dropped, all times in seconds) ===")
|
||||||
|
print()
|
||||||
|
print(f"{'binary':<24} {'min':>8} {'median':>8} {'max':>8} raw runs")
|
||||||
|
print("-" * 110)
|
||||||
|
for label, _, _eq in binaries:
|
||||||
|
t = timings[label]
|
||||||
|
raw_str = " ".join(f"{r:.3f}" for r in t["raw"])
|
||||||
|
print(f"{label:<24} {t['min']:8.4f} {t['median']:8.4f} {t['max']:8.4f} [{raw_str}]")
|
||||||
|
|
||||||
|
# Ratios (always median-over-median).
|
||||||
|
print()
|
||||||
|
print("=== ratios (median over median) ===")
|
||||||
|
print()
|
||||||
|
ail_med = timings["ail-mono"]["median"]
|
||||||
|
di_med = timings["direct-inlinable"]["median"]
|
||||||
|
dn_med = timings["direct-noinline"]["median"]
|
||||||
|
ind_med = timings["indirect"]["median"]
|
||||||
|
poly_med = timings["indirect-polymorphic"]["median"]
|
||||||
|
print(f" ail-mono / direct-inlinable = {ail_med / di_med:6.3f}x (codegen-quality gap)")
|
||||||
|
print(f" direct-noinline / direct-inlinable = {dn_med / di_med:6.3f}x (inlining benefit)")
|
||||||
|
print(f" indirect / direct-noinline = {ind_med / dn_med:6.3f}x (monomorphic vdisp delta)")
|
||||||
|
print(f" indirect-polymorphic / direct-noinline = {poly_med / dn_med:6.3f}x (polymorphic vdisp delta)")
|
||||||
|
print(f" indirect-polymorphic / indirect = {poly_med / ind_med:6.3f}x (predictor-miss penalty)")
|
||||||
|
print(f" ail-mono / indirect = {ail_med / ind_med:6.3f}x (end-to-end vs mono indirect)")
|
||||||
|
print(f" ail-mono / indirect-polymorphic = {ail_med / poly_med:6.3f}x (end-to-end vs poly indirect)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Hand-C reference for bench_mono_dispatch — DIRECT, INLINABLE variant.
|
||||||
|
//
|
||||||
|
// Mirrors the post-monomorphisation AILang IR: a tail-recursive loop
|
||||||
|
// that calls `foo(i)` at every step. `foo` here has no `noinline`
|
||||||
|
// attribute, so clang -O2 will inline it into the loop body. This is
|
||||||
|
// the OPTIMISTIC bound on what AILang's mono pass can achieve once
|
||||||
|
// LLVM optimises the resulting IR.
|
||||||
|
//
|
||||||
|
// Workload: loop_call(N, 0) with
|
||||||
|
// acc' = acc + foo(acc + i)
|
||||||
|
// foo(x) = x * 1103515245 + 12345 (LCG-ish, prevents closed-form)
|
||||||
|
//
|
||||||
|
// N = 100_000_000.
|
||||||
|
//
|
||||||
|
// The result is data-dependent on every prior iteration, so clang
|
||||||
|
// cannot algebraically close-form-fold the loop; the wall-time is
|
||||||
|
// dominated by the body.
|
||||||
|
//
|
||||||
|
// Build: clang -O2 -o bench_mono_direct bench_mono_direct.c
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
static int64_t foo(int64_t x) {
|
||||||
|
return x * 1103515245 + 12345;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int64_t loop_call(int64_t i, int64_t acc) {
|
||||||
|
while (i != 0) {
|
||||||
|
acc = acc + foo(acc + i);
|
||||||
|
i = i - 1;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
printf("%lld\n", (long long)loop_call(100000000, 0));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Hand-C reference for bench_mono_dispatch — DIRECT, NOINLINE variant.
|
||||||
|
//
|
||||||
|
// Same as bench_mono_direct.c, but `foo` is marked `noinline` so the
|
||||||
|
// compiler must emit a real call instruction at every iteration. This
|
||||||
|
// isolates the "real call" cost when the target is statically known —
|
||||||
|
// the upper bound on what monomorphised dispatch can achieve when the
|
||||||
|
// callee body is not inlinable.
|
||||||
|
//
|
||||||
|
// Pair this with bench_mono_indirect.c (same noinline, but called via
|
||||||
|
// fnptr) to isolate "direct vs indirect call" cost on this hardware.
|
||||||
|
//
|
||||||
|
// Build: clang -O2 -o bench_mono_direct_noinline bench_mono_direct_noinline.c
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
static int64_t foo(int64_t x) __attribute__((noinline));
|
||||||
|
static int64_t foo(int64_t x) {
|
||||||
|
return x * 1103515245 + 12345;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int64_t loop_call(int64_t i, int64_t acc) {
|
||||||
|
while (i != 0) {
|
||||||
|
acc = acc + foo(acc + i);
|
||||||
|
i = i - 1;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
printf("%lld\n", (long long)loop_call(100000000, 0));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Hand-C reference for bench_mono_dispatch — INDIRECT (fnptr) variant.
|
||||||
|
//
|
||||||
|
// Same algorithm as bench_mono_direct_noinline.c, but `foo` is reached
|
||||||
|
// through a function pointer assigned at runtime. This represents the
|
||||||
|
// "virtual dispatch" / "dictionary-passing" lower bound: the call
|
||||||
|
// target is opaque to the optimiser at compile time, so clang cannot
|
||||||
|
// inline and emits an indirect call (`callq *<reg>`).
|
||||||
|
//
|
||||||
|
// The fnptr is assigned from main via a volatile-pointer indirection
|
||||||
|
// to defeat any speculative devirtualisation clang -O2 might attempt
|
||||||
|
// (without PGO it does not devirt monomorphic-target indirect calls,
|
||||||
|
// but the volatile guard makes that explicit and stable across clang
|
||||||
|
// versions).
|
||||||
|
//
|
||||||
|
// `foo` keeps `noinline` so we measure dispatch-shape, not body size.
|
||||||
|
// The pair (direct_noinline vs indirect) isolates exactly the
|
||||||
|
// indirect-call cost on this hardware.
|
||||||
|
//
|
||||||
|
// Build: clang -O2 -o bench_mono_indirect bench_mono_indirect.c
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
static int64_t foo(int64_t x) __attribute__((noinline));
|
||||||
|
static int64_t foo(int64_t x) {
|
||||||
|
return x * 1103515245 + 12345;
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef int64_t (*foo_fn_t)(int64_t);
|
||||||
|
|
||||||
|
static int64_t loop_call(foo_fn_t fp, int64_t i, int64_t acc) {
|
||||||
|
while (i != 0) {
|
||||||
|
acc = acc + fp(acc + i);
|
||||||
|
i = i - 1;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
// Volatile barrier so the optimiser sees the target as
|
||||||
|
// dynamically-determined — it cannot prove the fnptr is
|
||||||
|
// monomorphic at the call site without PGO.
|
||||||
|
foo_fn_t volatile fp_v = &foo;
|
||||||
|
foo_fn_t fp = fp_v;
|
||||||
|
printf("%lld\n", (long long)loop_call(fp, 100000000, 0));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Hand-C reference for bench_mono_dispatch — INDIRECT POLYMORPHIC variant.
|
||||||
|
//
|
||||||
|
// Same structure as bench_mono_indirect.c, but the fnptr is selected
|
||||||
|
// per-iteration from a small array of FOUR distinct (and genuinely
|
||||||
|
// different) `foo` implementations. This models a polymorphic class
|
||||||
|
// hierarchy under hypothetical vdisp / dict-passing: the indirect
|
||||||
|
// target varies, so the branch predictor cannot lock onto a single
|
||||||
|
// destination. The four bodies are deliberately distinct (different
|
||||||
|
// constants and operations) so clang's mergefunc cannot collapse them.
|
||||||
|
//
|
||||||
|
// This is the LOWER bound on what mono offers — predictor misses on
|
||||||
|
// indirect dispatch when the call site sees more than one instance.
|
||||||
|
//
|
||||||
|
// IMPORTANT: this binary's stdout does NOT match the monomorphic
|
||||||
|
// variants — different `foo` semantics produce a different final acc.
|
||||||
|
// The harness skips the equality check for this binary; only the
|
||||||
|
// timing matters here.
|
||||||
|
//
|
||||||
|
// Build: clang -O2 -o bench_mono_indirect_polymorphic bench_mono_indirect_polymorphic.c
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
static int64_t foo_a(int64_t x) __attribute__((noinline));
|
||||||
|
static int64_t foo_b(int64_t x) __attribute__((noinline));
|
||||||
|
static int64_t foo_c(int64_t x) __attribute__((noinline));
|
||||||
|
static int64_t foo_d(int64_t x) __attribute__((noinline));
|
||||||
|
|
||||||
|
// Four genuinely different bodies — different constants, different
|
||||||
|
// op shapes. Same instruction count and roughly same cost so the
|
||||||
|
// "average" body cost matches the monomorphic case; what differs
|
||||||
|
// is the dispatch shape.
|
||||||
|
static int64_t foo_a(int64_t x) { return x * 1103515245 + 12345; }
|
||||||
|
static int64_t foo_b(int64_t x) { return x * 1664525 + 1013904223; }
|
||||||
|
static int64_t foo_c(int64_t x) { return x * 22695477 + 1; }
|
||||||
|
static int64_t foo_d(int64_t x) { return x * 214013 + 2531011; }
|
||||||
|
|
||||||
|
typedef int64_t (*foo_fn_t)(int64_t);
|
||||||
|
|
||||||
|
static int64_t loop_call(foo_fn_t * volatile fps, int64_t i, int64_t acc) {
|
||||||
|
while (i != 0) {
|
||||||
|
// i & 3 cycles through the 4 fnptrs every iteration.
|
||||||
|
foo_fn_t fp = fps[i & 3];
|
||||||
|
acc = acc + fp(acc + i);
|
||||||
|
i = i - 1;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
foo_fn_t fps[4] = { &foo_a, &foo_b, &foo_c, &foo_d };
|
||||||
|
foo_fn_t * volatile fps_v = fps;
|
||||||
|
printf("%lld\n", (long long)loop_call(fps_v, 100000000, 0));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -1606,6 +1606,23 @@ After this pass, the IR contains no class machinery — only ordinary
|
|||||||
monomorphic functions and direct calls. Codegen sees no difference
|
monomorphic functions and direct calls. Codegen sees no difference
|
||||||
between a hand-written `show_int` and a synthesised `show__Int`.
|
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 `@`
|
The separator is `__` rather than `#` or `@` because `#` and `@`
|
||||||
are invalid in LLVM IR global identifiers (the IR verifier rejects
|
are invalid in LLVM IR global identifiers (the IR verifier rejects
|
||||||
them inside `@ail_<module>_<def>` mangled names). `__` is legal in
|
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
|
bench re-baselining), the primitive-name-set consolidation, or
|
||||||
unrelated work. The milestone-cycle dictates `brainstorm` for
|
unrelated work. The milestone-cycle dictates `brainstorm` for
|
||||||
whichever lands next.
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
{
|
||||||
|
"schema": "ailang/v0",
|
||||||
|
"name": "bench_mono_dispatch",
|
||||||
|
"imports": [],
|
||||||
|
"defs": [
|
||||||
|
{
|
||||||
|
"kind": "class", "name": "Foo", "param": "a",
|
||||||
|
"methods": [{
|
||||||
|
"name": "foo",
|
||||||
|
"type": {
|
||||||
|
"k": "fn", "params": [{ "k": "var", "name": "a" }],
|
||||||
|
"ret": { "k": "con", "name": "Int" }, "effects": []
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "instance",
|
||||||
|
"class": "Foo",
|
||||||
|
"type": { "k": "con", "name": "Int" },
|
||||||
|
"methods": [{
|
||||||
|
"name": "foo",
|
||||||
|
"body": {
|
||||||
|
"t": "lam",
|
||||||
|
"params": ["x"],
|
||||||
|
"paramTypes": [{ "k": "var", "name": "a" }],
|
||||||
|
"retType": { "k": "con", "name": "Int" },
|
||||||
|
"body": {
|
||||||
|
"t": "app",
|
||||||
|
"fn": { "t": "var", "name": "+" },
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"t": "app",
|
||||||
|
"fn": { "t": "var", "name": "*" },
|
||||||
|
"args": [
|
||||||
|
{ "t": "var", "name": "x" },
|
||||||
|
{ "t": "lit", "lit": { "kind": "int", "value": 1103515245 } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ "t": "lit", "lit": { "kind": "int", "value": 12345 } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "class", "name": "Looper", "param": "a",
|
||||||
|
"methods": [{
|
||||||
|
"name": "loop_call",
|
||||||
|
"type": {
|
||||||
|
"k": "fn", "params": [{ "k": "var", "name": "a" }, { "k": "con", "name": "Int" }],
|
||||||
|
"ret": { "k": "con", "name": "Int" }, "effects": []
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "instance",
|
||||||
|
"class": "Looper",
|
||||||
|
"type": { "k": "con", "name": "Int" },
|
||||||
|
"methods": [{
|
||||||
|
"name": "loop_call",
|
||||||
|
"body": {
|
||||||
|
"t": "lam",
|
||||||
|
"params": ["i", "acc"],
|
||||||
|
"paramTypes": [{ "k": "var", "name": "a" }, { "k": "con", "name": "Int" }],
|
||||||
|
"retType": { "k": "con", "name": "Int" },
|
||||||
|
"body": {
|
||||||
|
"t": "if",
|
||||||
|
"cond": {
|
||||||
|
"t": "app",
|
||||||
|
"fn": { "t": "var", "name": "==" },
|
||||||
|
"args": [
|
||||||
|
{ "t": "var", "name": "i" },
|
||||||
|
{ "t": "lit", "lit": { "kind": "int", "value": 0 } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": { "t": "var", "name": "acc" },
|
||||||
|
"else": {
|
||||||
|
"t": "app",
|
||||||
|
"tail": true,
|
||||||
|
"fn": { "t": "var", "name": "loop_call" },
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"t": "app",
|
||||||
|
"fn": { "t": "var", "name": "-" },
|
||||||
|
"args": [
|
||||||
|
{ "t": "var", "name": "i" },
|
||||||
|
{ "t": "lit", "lit": { "kind": "int", "value": 1 } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": "app",
|
||||||
|
"fn": { "t": "var", "name": "+" },
|
||||||
|
"args": [
|
||||||
|
{ "t": "var", "name": "acc" },
|
||||||
|
{
|
||||||
|
"t": "app",
|
||||||
|
"fn": { "t": "var", "name": "foo" },
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"t": "app",
|
||||||
|
"fn": { "t": "var", "name": "+" },
|
||||||
|
"args": [
|
||||||
|
{ "t": "var", "name": "acc" },
|
||||||
|
{ "t": "var", "name": "i" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "fn",
|
||||||
|
"name": "main",
|
||||||
|
"type": {
|
||||||
|
"k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" },
|
||||||
|
"effects": ["IO"]
|
||||||
|
},
|
||||||
|
"params": [],
|
||||||
|
"body": {
|
||||||
|
"t": "do",
|
||||||
|
"op": "io/print_int",
|
||||||
|
"args": [{
|
||||||
|
"t": "app",
|
||||||
|
"fn": { "t": "var", "name": "loop_call" },
|
||||||
|
"args": [
|
||||||
|
{ "t": "lit", "lit": { "kind": "int", "value": 100000000 } },
|
||||||
|
{ "t": "lit", "lit": { "kind": "int", "value": 0 } }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user