bench: 21'f — explicit-mode pair, full alloc+dec vs malloc+free

Closes the apples-to-apples gap from 21'e. Adds:
- examples/bench_list_sum_explicit.ailx — same algorithm and sizes
  as bench_list_sum, fully (borrow)/(own)/(drop-iterative)
  annotated so codegen emits proper inc/dec instrumentation.
- bench/reference/list_sum_explicit_free.c — same algorithm
  with explicit free() walking the chain after sum.

The full alloc+dec vs malloc+free comparison reveals two non-
trivial conclusions:

1. AILang's full RC pipeline is only 26% slower than glibc
   malloc+free on this workload (rc/c = 1.26x). The implicit-
   mode comparison's 1.42x was misleading — it counted neither
   pipeline's free path. The fair ratio is 1.26x, materially
   better than the previous read.

2. RC's dec is cheaper per cell than glibc free(). AILang
   dec-tax: ~3 ns/cell. C free-tax: ~5.5 ns/cell. Plausible
   cause: ailang_rc_dec operates on a known-shape cell with a
   fixed-offset refcount and a static per-type drop fn — no
   free-list bucketing, no header introspection, no global lock.

bump's advantage expresses fully: bench_list_sum_explicit.bump/c
= 0.42x means AILang at bump is 2.4x faster than C malloc+free.
Sets a useful upper bound on a slab/pool RC allocator's potential.

The 21'-family arc — bench-regression infrastructure — is now
substantively complete: 21'a (bench/check.py), 21'b (corpus
widening), 21'c (compile_check.py), 21'd (pure-compute fixtures
+ harness hardening), 21'e (cross-language hand-C), 21'f (explicit
apples-to-apples). 63 runtime metrics + 18 compile metrics + 25
cross-lang metrics under regression coverage. Any future iter
that regresses any axis beyond tolerance gets caught at the next
family close.

Remaining queue is back to substantive language work — Family 21
(typeclasses / polymorphic ADTs at runtime / pattern-binding
generalisation) is now an orchestrator-level fork that needs
direct user input.
This commit is contained in:
2026-05-09 01:21:15 +02:00
parent c897d2eef0
commit 75f7fda788
8 changed files with 369 additions and 25 deletions
+10
View File
@@ -54,6 +54,16 @@
"gc_rss_kb": { "baseline": 13624, "tolerance_pct": 15 },
"bump_rss_kb": { "baseline": 13860, "tolerance_pct": 15 },
"rc_rss_kb": { "baseline": 13880, "tolerance_pct": 15 }
},
"bench_list_sum_explicit": {
"gc_s": { "baseline": 0.139, "tolerance_pct": 10 },
"bump_s": { "baseline": 0.048, "tolerance_pct": 10 },
"rc_s": { "baseline": 0.152, "tolerance_pct": 10 },
"gc_over_bump": { "baseline": 2.89, "tolerance_pct": 8 },
"rc_over_bump": { "baseline": 3.14, "tolerance_pct": 8 },
"gc_rss_kb": { "baseline": 103772, "tolerance_pct": 5 },
"bump_rss_kb": { "baseline": 97636, "tolerance_pct": 5 },
"rc_rss_kb": { "baseline": 142424, "tolerance_pct": 8 }
}
},
+42 -20
View File
@@ -6,89 +6,111 @@
"fixtures": {
"bench_list_sum": {
"ail_rc_s": {
"baseline": 0.141597,
"baseline": 0.138906,
"tolerance_pct": 15
},
"ail_bump_s": {
"baseline": 0.048038,
"baseline": 0.050245,
"tolerance_pct": 15
},
"c_s": {
"baseline": 0.095312,
"baseline": 0.09749,
"tolerance_pct": 15
},
"rc_over_c": {
"baseline": 1.485614,
"baseline": 1.42483,
"tolerance_pct": 12
},
"bump_over_c": {
"baseline": 0.504003,
"baseline": 0.515393,
"tolerance_pct": 12
}
},
"bench_tree_walk": {
"ail_rc_s": {
"baseline": 0.097028,
"baseline": 0.100173,
"tolerance_pct": 15
},
"ail_bump_s": {
"baseline": 0.038905,
"baseline": 0.038673,
"tolerance_pct": 15
},
"c_s": {
"baseline": 0.037159,
"baseline": 0.036567,
"tolerance_pct": 15
},
"rc_over_c": {
"baseline": 2.611185,
"baseline": 2.739449,
"tolerance_pct": 12
},
"bump_over_c": {
"baseline": 1.046998,
"baseline": 1.057606,
"tolerance_pct": 12
}
},
"bench_compute_intsum": {
"ail_rc_s": {
"baseline": 0.00044,
"baseline": 0.000362,
"tolerance_pct": 15
},
"ail_bump_s": {
"baseline": 0.000393,
"baseline": 0.000371,
"tolerance_pct": 15
},
"c_s": {
"baseline": 0.000374,
"baseline": 0.000361,
"tolerance_pct": 15
},
"rc_over_c": {
"baseline": 1.177715,
"baseline": 1.002676,
"tolerance_pct": 12
},
"bump_over_c": {
"baseline": 1.050856,
"baseline": 1.028255,
"tolerance_pct": 12
}
},
"bench_compute_collatz": {
"ail_rc_s": {
"baseline": 0.056878,
"baseline": 0.057994,
"tolerance_pct": 15
},
"ail_bump_s": {
"baseline": 0.056547,
"baseline": 0.057159,
"tolerance_pct": 15
},
"c_s": {
"baseline": 0.05748,
"baseline": 0.05776,
"tolerance_pct": 15
},
"rc_over_c": {
"baseline": 0.989523,
"baseline": 1.00405,
"tolerance_pct": 12
},
"bump_over_c": {
"baseline": 0.983756,
"baseline": 0.989593,
"tolerance_pct": 12
}
},
"bench_list_sum_explicit": {
"ail_rc_s": {
"baseline": 0.150754,
"tolerance_pct": 15
},
"ail_bump_s": {
"baseline": 0.0495,
"tolerance_pct": 15
},
"c_s": {
"baseline": 0.119204,
"tolerance_pct": 15
},
"rc_over_c": {
"baseline": 1.264667,
"tolerance_pct": 12
},
"bump_over_c": {
"baseline": 0.415253,
"tolerance_pct": 12
}
}
+5 -4
View File
@@ -47,10 +47,11 @@ OUTDIR = ROOT / "target" / "cross_lang"
# (ailang fixture stem, C reference stem) — both must exist as
# `examples/<ail>.ail.json` and `bench/reference/<c>.c`.
CORPUS = [
("bench_list_sum", "list_sum"),
("bench_tree_walk", "tree_walk"),
("bench_compute_intsum", "compute_intsum"),
("bench_compute_collatz", "compute_collatz"),
("bench_list_sum", "list_sum"),
("bench_tree_walk", "tree_walk"),
("bench_compute_intsum", "compute_intsum"),
("bench_compute_collatz", "compute_collatz"),
("bench_list_sum_explicit", "list_sum_explicit_free"),
]
+76
View File
@@ -0,0 +1,76 @@
// Hand-C reference for bench_list_sum_explicit — full malloc + free.
//
// Companion to list_sum.c (which leaks); this version adds explicit
// `free()` calls to mirror AILang's explicit-mode RC dec-tax. Pairs
// with examples/bench_list_sum_explicit.ailx via bench/cross_lang.py.
//
// The free pass walks the list after sum, freeing each cell. This is
// genuinely O(N) work that bench_list_sum.c never paid; the rc/c
// ratio for this fixture is therefore the honest answer to "how
// much does RC's dec-and-free pipeline cost vs hand-C's malloc-free
// pipeline on the same workload?".
//
// Note: we sum first, then free, rather than fold-with-free. This
// matches the AILang fixture's structure: sum_list owns the chain
// and consumes it; the dec walk happens implicitly as sum_acc
// pattern-matches LCons and re-binds the tail to the recursive call.
// AILang's pattern there is "consume during sum" — codegen emits
// dec on the consumed cells as the match arm closes. The C version
// could in principle interleave free into the sum loop too; doing it
// in two passes is structurally cleaner and the cost is the same
// (one O(N) walk + one O(N) walk-and-free).
//
// Build: clang -O2 -o list_sum_explicit_free list_sum_explicit_free.c
// Expected stdout (one int per line):
// 4999950000
// 499999500000
// 4499998500000
#include <stdio.h>
#include <stdlib.h>
typedef struct cell {
long head;
struct cell *tail;
} cell_t;
static cell_t *cons_n(long n) {
cell_t *acc = NULL;
for (long i = n - 1; i >= 0; i--) {
cell_t *c = (cell_t *) malloc(sizeof(cell_t));
c->head = i;
c->tail = acc;
acc = c;
}
return acc;
}
static long sum_list(const cell_t *xs) {
long acc = 0;
while (xs) {
acc += xs->head;
xs = xs->tail;
}
return acc;
}
static void free_list(cell_t *xs) {
while (xs) {
cell_t *next = xs->tail;
free(xs);
xs = next;
}
}
static void run_one(long n) {
cell_t *xs = cons_n(n);
printf("%ld\n", sum_list(xs));
free_list(xs);
}
int main(void) {
run_one(100000);
run_one(1000000);
run_one(3000000);
return 0;
}
+1 -1
View File
@@ -61,7 +61,7 @@ mkdir -p "$OUTDIR"
# Compile both modes for both fixtures up front so the bench loop only
# measures runtime, not build time.
fixtures=(bench_list_sum bench_tree_walk bench_closure_chain bench_hof_pipeline bench_compute_collatz)
fixtures=(bench_list_sum bench_tree_walk bench_closure_chain bench_hof_pipeline bench_compute_collatz bench_list_sum_explicit)
modes=(gc bump rc)
echo ">>> compiling fixtures (-O2)"
for f in "${fixtures[@]}"; do
+140
View File
@@ -10135,6 +10135,146 @@ fixture and baseline-file additions only.
- **Family 21+** — typeclasses, polymorphic ADTs at runtime,
pattern-binding generalisation. Orchestrator-level fork.
## 2026-05-09 — Iter 21'f: explicit-mode pair, full alloc+dec vs malloc+free
Closes the apples-to-apples gap from 21'e. Until this iter, every
AILang/C ratio compared *implicit-mode* AILang (alloc-tax-only,
no dec) against *malloc-and-leak* C — both leaking, both unfair to
the dec-cost question. 21'f ships the matched pair:
- **`examples/bench_list_sum_explicit.ailx`** — same algorithm and
sizes as `bench_list_sum`, fully annotated with `(borrow)` /
`(own)` / `(drop-iterative)` so codegen emits proper `inc`/`dec`
instrumentation. Each cell allocated by `cons_n_acc` is dec'd as
`sum_acc` consumes the chain via the LCons-arm move-into-tail-call.
- **`bench/reference/list_sum_explicit_free.c`** — same C
algorithm with explicit `free()` walking the chain after sum.
### The full alloc+dec vs malloc+free numbers
```
fixture | AILang_rc | AILang_bump | C | rc/c | bump/c
-----------------------------+-----------+-------------+--------+-------+-------
bench_list_sum (implicit) | 138.9 ms | 50.3 ms | 97.5 ms| 1.42× | 0.52×
bench_list_sum_explicit | 150.8 ms | 49.5 ms |119.2 ms| 1.26× | 0.42×
```
### What this tells us
**Subtraction reveals the per-axis tax:**
- AILang RC dec-tax: `150.8 - 138.9 = ~12 ms` ≈ 8% of rc time.
This is the cost of emitting and executing `ailang_rc_dec` for
every consumed cell + the iterative-drop walker.
- C free-tax: `119.2 - 97.5 = ~22 ms` ≈ 18% of c+free time.
This is glibc's free-list-management overhead per free() call.
Two non-trivial conclusions:
**1. AILang's full RC pipeline is only 26% slower than glibc's
full malloc+free pipeline on this workload (rc/c = 1.26×).**
The implicit-mode comparison's 1.42× was misleading — it was
comparing AILang-with-alloc-tax-only vs C-with-malloc-only, which
counted neither pipeline's free path. The fair number is 1.26×,
materially better than the previous read.
**2. RC's dec is cheaper than glibc's free.** AILang dec-tax is
~12 ms on 4M cells (3 ns/cell); C free-tax is ~22 ms (5.5 ns/cell).
Plausible cause: AILang's `ailang_rc_dec` operates on a known-
shape cell with a fixed-offset refcount header and a static
per-type drop fn — no free-list bucketing decision, no header
introspection, no global lock contention. glibc's `free()` is a
general-purpose allocator with all of those concerns.
**3. bump's no-free advantage now expresses itself fully.**
`bench_list_sum_explicit.bump/c = 0.42×` means AILang at bump
allocator is **2.4× faster than C at malloc+free** on this
workload. The bump arm pays neither dec nor free; it's the
no-malloc-overhead floor. The 0.42× ratio sets a useful upper
bound on how fast a slab/pool RC allocator could plausibly run
(if Path B from 18f's two-paths analysis ever ships).
### Runtime bench: bench_list_sum_explicit added
`bench/run.sh`'s `fixtures` array extends to include the explicit
fixture. The runtime numbers show the dec-tax visible inside the
gc/rc/bump comparison too:
```
| gc(s) | bump(s) | rc(s) | gc/bump | rc/bump | rc RSS(KB)
bench_list_sum | 0.141 | 0.049 | 0.140 | 2.88× | 2.87× | 193640
bench_list_sum_explicit| 0.139 | 0.048 | 0.152 | 2.89× | 3.14× | 142424
```
`rc_over_bump` jumps from 2.87× (implicit, no dec) to 3.14×
(explicit, full dec). The dec-tax is now in the regression-check
band. RC RSS drops from 193 MB (leaking) to 142 MB (actually
freeing) — the first time a non-bump-baseline fixture has
demonstrated RC's free-path actually working under regression
coverage.
### Baseline file: 55 → 63 metrics
8 new metrics for `bench_list_sum_explicit` (same shape as the
implicit counterpart, slightly looser RSS tolerance at 8% because
the active-free working set is more variable than the leaking
peak).
`bench/baseline_cross_lang.json` extended too — 5 new ratio
metrics for the explicit pair.
### What this iter does NOT do
- **No tree-walk explicit pair.** Could be done identically to
list_sum (add `bench_tree_walk_explicit.ailx` with full modes
+ `tree_walk_explicit_free.c`). Useful but adds another 5
metrics for a workload class already represented; deferred
unless a specific question motivates it.
- **No HOF / closure explicit pair.** Same reasoning.
- **No methodology upgrade for the latency dispersion.** Still
queued.
### Test state
288 / 0 / 3, unchanged. No Rust changes; iter is bench-fixture
additions only.
### JOURNAL queue (updated)
The 21' family arc — bench-regression infrastructure — is now
substantively complete:
- 21'a: bench/check.py + baseline.json (runtime regressions).
- 21'b: closure_chain + hof_pipeline corpus.
- 21'c: bench/compile_check.py (compile regressions).
- 21'd: pure-compute fixtures, harness hardening.
- 21'e: cross-language hand-C reference + bench/cross_lang.py.
- 21'f: explicit-mode pair, full apples-to-apples ratios.
CLAUDE.md `Performance regressions` section codifies the three
scripts as co-equal tidy-iter gates. Any future iter that
regresses runtime, compile-time, or AILang/C ratios beyond
tolerance gets caught at the next family close.
Remaining queue:
- **`FnDef::synthetic(...)` factor-out** — unchanged; awaits next
schema-additive `FnDef` field.
- **Boehm full retirement** — unchanged; gating condition still
≥3 families with no oracle wins.
- **Latency methodology upgrade** — n=10+ captures or tighter
fixture for `explicit_at_rc.p99` dispersion. Could ship as a
short standalone iter if the next tidy-iter sees the tolerance
regularly squeezed.
- **Optional explicit-mode pairs**`bench_tree_walk_explicit`,
`bench_hof_pipeline_explicit`. Add when a specific question
demands the data.
- **Deferred richer integration paths** (from 20f) — tool-use,
MCP, LSP. Long-horizon.
- **Family 21+** — typeclasses, polymorphic ADTs at runtime,
pattern-binding generalisation. **Orchestrator-level fork
with multiple substantive options none of which is clearly
default; needs direct user input before dispatch.**
## 2026-05-09 — Iter 21'e: cross-language reference + AILang/C ratios
Closes the question CLAUDE.md has carried since day one — *"the
@@ -0,0 +1 @@
{"defs":[{"ctors":[{"fields":[],"name":"INil"},{"fields":[{"k":"con","name":"Int"},{"k":"con","name":"IntList"}],"name":"ICons"}],"doc":"Singly-linked Int list. drop-iterative so Own-param drops are O(1) stack.","drop-iterative":true,"kind":"type","name":"IntList"},{"body":{"cond":{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":0},"t":"lit"}],"fn":{"name":"==","t":"var"},"t":"app"},"else":{"args":[{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"-","t":"var"},"t":"app"},{"args":[{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"-","t":"var"},"t":"app"},{"name":"acc","t":"var"}],"ctor":"ICons","t":"ctor","type":"IntList"}],"fn":{"name":"cons_n_acc","t":"var"},"t":"app","tail":true},"t":"if","then":{"name":"acc","t":"var"}},"doc":"Tail-recursive: prepend (n-1, n-2, ..., 0) onto acc. Returns owned chain.","kind":"fn","name":"cons_n_acc","params":["n","acc"],"type":{"effects":[],"k":"fn","param_modes":["implicit","own"],"params":[{"k":"con","name":"Int"},{"k":"con","name":"IntList"}],"ret":{"k":"con","name":"IntList"},"ret_mode":"own"}},{"body":{"args":[{"name":"n","t":"var"},{"args":[],"ctor":"INil","t":"ctor","type":"IntList"}],"fn":{"name":"cons_n_acc","t":"var"},"t":"app"},"doc":"Build [0..n-1] :: IntList. Returns owned chain; caller owns and consumes.","kind":"fn","name":"cons_n","params":["n"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Int"}],"ret":{"k":"con","name":"IntList"},"ret_mode":"own"}},{"body":{"arms":[{"body":{"name":"acc","t":"var"},"pat":{"ctor":"INil","fields":[],"p":"ctor"}},{"body":{"args":[{"name":"t","t":"var"},{"args":[{"name":"acc","t":"var"},{"name":"h","t":"var"}],"fn":{"name":"+","t":"var"},"t":"app"}],"fn":{"name":"sum_acc","t":"var"},"t":"app","tail":true},"pat":{"ctor":"ICons","fields":[{"name":"h","p":"var"},{"name":"t","p":"var"}],"p":"ctor"}}],"scrutinee":{"name":"xs","t":"var"},"t":"match"},"doc":"Tail-recursive sum. Owns xs; consumes via LCons-arm move-into-tail-call.","kind":"fn","name":"sum_acc","params":["xs","acc"],"type":{"effects":[],"k":"fn","param_modes":["own","implicit"],"params":[{"k":"con","name":"IntList"},{"k":"con","name":"Int"}],"ret":{"k":"con","name":"Int"}}},{"body":{"args":[{"name":"xs","t":"var"},{"lit":{"kind":"int","value":0},"t":"lit"}],"fn":{"name":"sum_acc","t":"var"},"t":"app"},"doc":"Sum every element. Owns xs, hands it to sum_acc which consumes it.","kind":"fn","name":"sum_list","params":["xs"],"type":{"effects":[],"k":"fn","param_modes":["own"],"params":[{"k":"con","name":"IntList"}],"ret":{"k":"con","name":"Int"}}},{"body":{"args":[{"args":[{"args":[{"name":"n","t":"var"}],"fn":{"name":"cons_n","t":"var"},"t":"app"}],"fn":{"name":"sum_list","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"doc":"Build, sum, print. The owned list flows from cons_n into sum_list and is fully consumed.","kind":"fn","name":"run_one","params":["n"],"type":{"effects":["IO"],"k":"fn","params":[{"k":"con","name":"Int"}],"ret":{"k":"con","name":"Unit"}}},{"body":{"lhs":{"args":[{"lit":{"kind":"int","value":100000},"t":"lit"}],"fn":{"name":"run_one","t":"var"},"t":"app"},"rhs":{"lhs":{"args":[{"lit":{"kind":"int","value":1000000},"t":"lit"}],"fn":{"name":"run_one","t":"var"},"t":"app"},"rhs":{"args":[{"lit":{"kind":"int","value":3000000},"t":"lit"}],"fn":{"name":"run_one","t":"var"},"t":"app"},"t":"seq"},"t":"seq"},"kind":"fn","name":"main","params":[],"type":{"effects":["IO"],"k":"fn","params":[],"ret":{"k":"con","name":"Unit"}}}],"imports":[],"name":"bench_list_sum_explicit","schema":"ailang/v0"}
+94
View File
@@ -0,0 +1,94 @@
; Bench fixture: explicit-mode pair of bench_list_sum.
;
; Same algorithm and same sizes as bench_list_sum.ailx (build a list of
; 0..N-1, sum it, print) but with full `(borrow)` / `(own)` annotations
; on every fn-param signature in the hot path. Under --alloc=rc the
; codegen now emits `inc`/`dec` instructions: each cell allocated by
; `cons_n_acc` is dec'd as `sum_list_acc` walks the chain.
;
; The IntList is `(drop-iterative)` so the param drop on the LCons arm
; stays O(1) stack regardless of length — without this, dec-ing a
; 3M-cell chain at scope close would recursive-overflow.
;
; This is the "RC-fair" arm of the cross-language bench (21'f);
; pairs with bench/reference/list_sum_explicit_free.c which adds
; matching free() calls. Together they answer "what does RC's full
; alloc+dec cost look like vs C's full malloc+free cost?".
;
; Sizes match bench_list_sum exactly:
; 100_000 * 24 B = 2.4 MB
; 1_000_000 * 24 B = 24 MB
; 3_000_000 * 24 B = 72 MB
;
; Expected stdout (one int per line):
; 100_000 -> 4999950000
; 1_000_000 -> 499999500000
; 3_000_000 -> 4499998500000
(module bench_list_sum_explicit
(data IntList
(doc "Singly-linked Int list. drop-iterative so Own-param drops are O(1) stack.")
(ctor INil)
(ctor ICons (con Int) (con IntList))
(drop-iterative))
(fn cons_n_acc
(doc "Tail-recursive: prepend (n-1, n-2, ..., 0) onto acc. Returns owned chain.")
(type
(fn-type
(params (con Int) (own (con IntList)))
(ret (own (con IntList)))))
(params n acc)
(body
(if (app == n 0)
acc
(tail-app cons_n_acc
(app - n 1)
(term-ctor IntList ICons (app - n 1) acc)))))
(fn cons_n
(doc "Build [0..n-1] :: IntList. Returns owned chain; caller owns and consumes.")
(type
(fn-type
(params (con Int))
(ret (own (con IntList)))))
(params n)
(body (app cons_n_acc n (term-ctor IntList INil))))
(fn sum_acc
(doc "Tail-recursive sum. Owns xs; consumes via LCons-arm move-into-tail-call.")
(type
(fn-type
(params (own (con IntList)) (con Int))
(ret (con Int))))
(params xs acc)
(body
(match xs
(case (pat-ctor INil) acc)
(case (pat-ctor ICons h t)
(tail-app sum_acc t (app + acc h))))))
(fn sum_list
(doc "Sum every element. Owns xs, hands it to sum_acc which consumes it.")
(type
(fn-type
(params (own (con IntList)))
(ret (con Int))))
(params xs)
(body (app sum_acc xs 0)))
(fn run_one
(doc "Build, sum, print. The owned list flows from cons_n into sum_list and is fully consumed.")
(type (fn-type (params (con Int)) (ret (con Unit)) (effects IO)))
(params n)
(body
(do io/print_int (app sum_list (app cons_n n)))))
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(seq (app run_one 100000)
(seq (app run_one 1000000)
(app run_one 3000000))))))