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
|
After both land, the retirement decision (Path A vs Path B
|
||||||
from 18f's entry) gets a real evidence base.
|
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
|
### What this iter (18f.2) ships
|
||||||
|
|
||||||
- `bench/latency_harness.py` (committed in `ac70011`).
|
- `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
|
The 18f throughput entry's "Path A vs Path B" framing remains
|
||||||
open and is now joined by the outer-cell-shallow-dec finding;
|
open and is now joined by the outer-cell-shallow-dec finding;
|
||||||
both feed the eventual retirement decision.
|
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