diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index c9bcb88..0b74aef 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -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__` — 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.