JOURNAL: Iter 18g.1 — outer-cell shallow-dec at tail-call sites
Records the diagnosis (18f.2 bencher finding -> tail-call drop elision in match arms whose body consumes pattern-binders into the tail-call), the new pre-tail-call seam in lower_match, the opt-in rc-stats counter test infrastructure (18g.0), the TDD record, and the end-to-end verification on bench_latency_explicit (11M -> 1M live cells; the 1M residue is the persistent Tree cache and queues as 18g.2).
This commit is contained in:
+116
@@ -7812,6 +7812,28 @@ Two open questions, both implementer territory:
|
||||
After both land, the retirement decision (Path A vs Path B
|
||||
from 18f's entry) gets a real evidence base.
|
||||
|
||||
### Update (later same day, post-18g.1)
|
||||
|
||||
The first item — outer-cell shallow-dec for moved-from
|
||||
scrutinees — shipped as Iter 18g.1 (commit `ae2eb2e`,
|
||||
preceded by `fc5f459` which added the `AILANG_RC_STATS=1`
|
||||
counter infrastructure used by the regression test). Net
|
||||
effect on `bench_latency_explicit` under `--alloc=rc`:
|
||||
`allocs=11068575 frees=10020000 live=1048575`. The 10 M
|
||||
freed cells correspond to the 20000 ops × 500-cell IntLists
|
||||
that previously leaked. The remaining `live=1048575` is the
|
||||
persistent depth-19 Tree cache (524287 TNode + 524288 TLeaf =
|
||||
1048575). That's a separate at-program-exit cleanup issue,
|
||||
not part of the same scope-close drop debt — queued as a
|
||||
follow-up rather than a re-bench blocker.
|
||||
|
||||
A re-run of the latency bench post-18g.1 is owed before the
|
||||
retirement question can move; the Iter 18g.1 fix did not
|
||||
touch the latency path's instruction shape (it only inserts
|
||||
one extra `ailang_rc_dec` per consumed list element), so the
|
||||
tail-latency win recorded above is not at risk, but the
|
||||
median may shift slightly.
|
||||
|
||||
### What this iter (18f.2) ships
|
||||
|
||||
- `bench/latency_harness.py` (committed in `ac70011`).
|
||||
@@ -7823,3 +7845,97 @@ from 18f's entry) gets a real evidence base.
|
||||
The 18f throughput entry's "Path A vs Path B" framing remains
|
||||
open and is now joined by the outer-cell-shallow-dec finding;
|
||||
both feed the eventual retirement decision.
|
||||
|
||||
## 2026-05-08 — Iter 18g.1: outer-cell shallow-dec at tail-call sites
|
||||
|
||||
`ailang-bencher`'s 18f.2 finding (above) localised: in
|
||||
`(case (LCons h t) (tail-app sum_acc t (...)))`, the
|
||||
pattern-binder `t` is consumed into the tail-call (uniqueness
|
||||
records `consume_count > 0`), and the LCons outer cell becomes
|
||||
a husk — all its ptr fields are moved into binders that the
|
||||
downstream frame owns, but no drop site emits a shallow free
|
||||
for the outer cell itself. The two existing 18d.4 emission
|
||||
seams (Iter A: arm-close pattern-binder dec; Iter B: Own-param
|
||||
dec at fn return) both run AFTER `lower_term(arm.body)`; for a
|
||||
tail-call body, lower_term sets `block_terminated = true`, so
|
||||
both seams skip. The husk leaks once per recursion step.
|
||||
|
||||
### Fix
|
||||
|
||||
A new pre-tail-call seam in `lower_match`, emitted *before*
|
||||
`lower_term(arm.body)` so it lands ahead of the `musttail
|
||||
call`. Conditions, all required:
|
||||
|
||||
1. `alloc = Rc`.
|
||||
2. `arm.body` is structurally `Term::App { tail: true, .. }`
|
||||
or `Term::Do { tail: true, .. }`.
|
||||
3. `scrutinee_is_owned` (existing 18d.4 Iter A param-mode
|
||||
gate, hoisted to be shared between the new seam and Iter
|
||||
A).
|
||||
4. Every ptr-typed slot in this ctor's pattern is in
|
||||
`moved_slots[scrutinee]`. A Wild-bound ptr would still hold
|
||||
a live ref that the shallow free would strand; this
|
||||
condition rules out that case.
|
||||
|
||||
The dec is a *shallow* `ailang_rc_dec`, not a
|
||||
`field_drop_call` / `drop_<m>_<T>` — the per-type drop fn
|
||||
would re-walk the ptr fields and dec'ing values now owned by
|
||||
the downstream frame is exactly the bug 18d.3's `moved_slots`
|
||||
infrastructure was set up to prevent.
|
||||
|
||||
### Test infrastructure (Iter 18g.0, shipped first)
|
||||
|
||||
`runtime/rc.c` gained two non-atomic uint64_t counters
|
||||
(`g_rc_alloc_count`, `g_rc_free_count`) on the alloc / dec-to-
|
||||
zero paths. An `__attribute__((constructor))` registers an
|
||||
atexit handler IFF `AILANG_RC_STATS` is set in the
|
||||
environment at startup; the handler prints
|
||||
|
||||
ailang_rc_stats: allocs=N frees=M live=K
|
||||
|
||||
to stderr. Default-disabled. The e2e test layer added a
|
||||
`build_and_run_with_rc_stats(example) -> (stdout, allocs,
|
||||
frees, live)` helper that compiles with `--alloc=rc`, runs
|
||||
with `AILANG_RC_STATS=1`, and parses the atexit summary. This
|
||||
is now the standard way to assert RC correctness invariants
|
||||
("live == 0 at exit" for fixtures that own no live ADTs at
|
||||
return).
|
||||
|
||||
### TDD record
|
||||
|
||||
Red: `alloc_rc_explicit_mode_tail_sum_does_not_leak_outer_cells`
|
||||
on `examples/rc_tail_sum_explicit_leak.ail.json` (a 100-element
|
||||
tail-recursive list-sum with full mode annotations). Pre-fix:
|
||||
`live = 100` (one LCons leak per consumed element). Post-fix:
|
||||
`live = 0`. Kept as regression coverage.
|
||||
|
||||
### End-to-end verification
|
||||
|
||||
`bench_latency_explicit` under `--alloc=rc`:
|
||||
- Pre-fix: `allocs=11068575 frees=20000 live=11048575`.
|
||||
- Post-fix: `allocs=11068575 frees=10020000 live=1048575`.
|
||||
|
||||
The 10 M freed cells correspond to the 20000 × 500 IntList
|
||||
cells that previously leaked. The remaining `live=1048575` =
|
||||
524287 TNode + 524288 TLeaf, exactly matches the depth-19
|
||||
Tree cache the bench fixture intentionally keeps live across
|
||||
the whole loop. That cache's deallocation is a separate
|
||||
issue: main's let-scope close should dec the Tree at program
|
||||
exit, but doesn't on this fixture — queued as Iter 18g.2
|
||||
follow-up, not a regression introduced here.
|
||||
|
||||
### Carve-outs (deliberate)
|
||||
|
||||
- The seam fires only at *match* arms whose body is a tail
|
||||
call. A let-bound owned value passed into a tail-call as
|
||||
an argument (e.g. `(let xs (cons_n n) (tail-app sum_acc xs))`)
|
||||
has no match — its drop emission still happens at the
|
||||
let-scope close, which IS reached because the let body's
|
||||
result is the tail-call's return value. No fix needed there.
|
||||
- Tail-app of `Term::Do` (tail-effects) is gated identically;
|
||||
in practice no fixture exercises a moved-from scrutinee at
|
||||
a tail-effect site, but the symmetry is correct.
|
||||
- A scrutinee that is a let-binder *holding* a non-Own-param
|
||||
alias (the same carve-out as 18d.4 Iter A's fix) still
|
||||
evades the param-mode gate; the carve-out is unchanged
|
||||
and still queued as a propagation-pass iter.
|
||||
|
||||
Reference in New Issue
Block a user