From 509c9700bf6011859587bae66e07887b705e1be3 Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 8 May 2026 11:22:21 +0200 Subject: [PATCH] =?UTF-8?q?JOURNAL:=20Iter=2018d.2=20=E2=80=94=20codegen?= =?UTF-8?q?=20reuse-as=20in-place=20rewrite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the runtime refcount-1 dispatch design (Lean 4 / Roc lineage), why the dispatch lives at codegen rather than in runtime/rc.c (per-site decision, no new ABI), the new reuse_shape pre-codegen pass with its five reason sub-codes, and — crucially — the explicit field-cleanup scope cut. The reuse arm does not dec old pointer-typed fields because the canonical fixture's pattern-bound subterms alias the new field values (`(reuse-as xs (Cons _ (map_inc t)))` returns t's in-place-rewritten box as the new tail). The root cause is upstream: pattern-matching does not null out source slots. 18d.2 ships the perf-only half (alloc + outer cascade elided); 18d.3 / 18e closes the leak with move-aware patterns. --- docs/JOURNAL.md | 193 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index d21bd1c..90ad771 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -6766,3 +6766,196 @@ Adds the `reuse-as-shape-mismatch` diagnostic when codegen discovers the source's ctor and the body's ctor have different sizes / layouts (only checkable once codegen has resolved both ctors' field counts). + +## Iter 18d.2 — codegen reuse-as in-place rewrite + +The `Term::ReuseAs` schema-floor from 18d.1 now carries +behaviour under `--alloc=rc`. Two design points: a runtime +refcount-1 dispatch in the IR, and a static shape-compatibility +check in a new pre-codegen pass. + +### Runtime refcount-1 dispatch + +Following the Lean 4 / Roc precedent, codegen emits a runtime +branch on the source's refcount at the reuse-as site: + +``` +%hdr_ptr = getelementptr i8, ptr %src, i64 -8 +%refcnt = load i64, ptr %hdr_ptr +%is_one = icmp eq i64 %refcnt, 1 +br i1 %is_one, label %reuse, label %fresh +``` + +- **Reuse arm** (refcount == 1, the source IS unique at + runtime): overwrite the box in place. Store the new tag at + offset 0; store the new field values at offsets 8, 16, …. + No `ailang_rc_alloc`, no outer `@drop_` cascade — the + source's slot becomes the new ctor's slot. +- **Fresh arm** (refcount > 1, the source is shared): allocate + a fresh box via `ailang_rc_alloc`, store the new tag + fields + into it, and shallow-dec the source (so the source's + refcount drops by one — the OTHER live ref still points at + the old contents until that other ref's lifetime ends). +- Both arms phi-join to the same `ptr` value. The result of + `Term::ReuseAs` is a pointer to the new ctor's box, by + whichever path the runtime took. + +The branch is per reuse-as site, not per program path — every +reuse-as at runtime makes the runtime decision. That is the +correct trade-off for refcount-1 dispatch: an unconditional +in-place rewrite would corrupt sharing in the > 1 case; an +unconditional fresh-allocate would defeat the optimisation. +The trip cost is two loads + one compare + one branch on every +reuse-as — a one-digit-nanoseconds tax for a malloc-and-cascade +saving when the runtime path goes through the reuse arm. + +### Why the dispatch lives at codegen, not in `runtime/rc.c` + +The decision is per-site, not per-allocation: reuse-as +specifies WHERE to write (this slot, possibly aliased to other +data), not just WHEN to free. A runtime-helper function would +have to take an unbounded parameter list (the new field values ++ types) and either re-do work the codegen already did or push +that work back into a more general "type-driven copy" runtime +primitive. Embedding the branch in IR keeps the codegen +specialised per `Term::Ctor` shape and uses LLVM's existing +phi infrastructure, with no new runtime ABI. + +### Static shape compatibility — `crates/ailang-check/src/reuse_shape.rs` + +A new pre-codegen pass tracks the path-resolved ctor of every +let-bound and pattern-matched binder, then checks each +`Term::ReuseAs` site for shape compatibility between source's +ctor and body's ctor. The new diagnostic +`reuse-as-shape-mismatch` carries stable `ctx.reason` sub-codes +plus a drop-the-wrapper `SuggestedRewrite`: + +- `field-count-mismatch` — source's ctor has N fields, body's + ctor has M fields. The slot-by-slot rewrite cannot proceed + because the box sizes (or slot layouts) differ. +- `field-type-mismatch` — same field count, but field i in + source has a different LLVM type than field i in body. A + pointer-typed slot cannot be overwritten with an `i64` (or + vice versa) without breaking the layout invariant. +- `indeterminate-source-ctor` — the path that the reuse-as is + on does not statically determine a unique ctor for source. + Conservative reject — the user should restructure so the + ctor is locally evident, or remove the wrapper. +- `cross-module-body-ctor` — the body's ctor lives in a module + that the current module doesn't import in a position where + 18d.2 can resolve it. Out-of-scope today; deferrable. +- `ctor-not-in-module` — typecheck should have caught this + upstream; defensive guard. + +Build fails on any of these. The structured diagnostic shows +the user whether to fix the shapes or remove the hint. + +### Field-cleanup design — explicit scope cut + +The reuse arm does **NOT** dec old pointer-typed fields before +overwriting them with the new field values. The brief +specified `dec old before store new`, and the implementer +correctly identified that this schedule causes use-after-free +on the canonical fixture: + +``` +(reuse-as xs (term-ctor List Cons (app + h 1) (app map_inc t))) +``` + +Why: `(map_inc t)` recursively returns `t`'s in-place-rewritten +box (when reuse fires one level deeper) — so the "new tail" +about to be stored is the same pointer as the "old tail" we'd +be dec'ing. Dec-old-then-store-new would drop the box's +refcount to 0, free it via the cascade, then store the freed +pointer. + +The root cause is upstream of 18d.2: pattern-matching a `Cons` +binding `h` and `t` does NOT null out the source's slots. So +when the reuse-as site evaluates `(map_inc t)`, the t binder +holds a refcount on the box, but xs.tail STILL contains the +same pointer. From the slot's perspective, the field hasn't +been "moved out" — the slot still references the box. + +The chosen trade-off: skip the field-dec entirely. Pattern- +wildcarded fields (rare; e.g. `(match xs (Cons _ _ → reuse-as +xs (Cons 0 0)))`) leak. Pattern-moved fields are the body's +responsibility — and the body's evaluation has not consumed +them either (it's holding a refcount via the binder). The +result: 18d.2 leaks the SAME shape 18c.4 already leaks (Own +fn-parameters are not dec'd at fn-return, pattern-wildcarded +fields are not dec'd at scope close). No regression vs the +current baseline; the perf win on alloc + outer cascade ships. + +The proper fix is the move-aware-pattern story (18d.3 / 18e): +when a pattern binds a pointer-typed field, codegen should +either (a) null out the source's slot at the pattern point, or +(b) track at codegen-time which slots are "still owned by the +source" vs "transferred to a binder", and emit dec only on the +former at reuse-as / drop time. That work is queued; 18d.2 +ships the perf-only half. + +### Test additions + +- `examples/reuse_as_demo.ail.json` under `--alloc=rc` exits 0 + with stdout `9`. Same fixture 18d.1 ran under + `--alloc=gc`; under RC, 18d.2's reuse arm fires and the + runtime correctly produces the same observable behaviour. +- `crates/ail/tests/e2e.rs::reuse_as_demo_under_rc_uses_inplace_rewrite` + asserts stdout matches `--alloc=gc` AND inspects the LLVM IR + to confirm: the `icmp eq i64 …, 1` branch is present, AND + the `reuse.` block does NOT call `@ailang_rc_alloc`. Two + positive contracts: behavioural equivalence with GC; the + in-place rewrite is actually emitted, not silently bypassed. +- `crates/ailang-check/tests/workspace.rs::reuse_as_shape_mismatch_is_reported_on_cons_to_nil` + — a fixture using `(reuse-as cons_var (Nil))` triggers + `reuse-as-shape-mismatch` with `reason = field-count-mismatch`. +- `reuse_shape::tests` — 3 unit tests covering same-ctor-clean, + Cons→Nil reject, and indeterminate-source-ctor reject. + +### Test deltas + +- E2E: 56 (the 18d.1 identity test was converted into the rc + in-place test; net unchanged at 56). +- ailang-check unit: 46 → 49 (+3 reuse_shape tests). +- ailang-check workspace: 9 → 10 (+1 shape-mismatch). +- All other buckets unchanged. +- `cargo test --workspace` green. + +### Known debt (deliberate, deferred) + +- **Move-aware pattern bindings** — Iter 18d.3 / 18e. Pattern + matching a pointer-typed field should null out the source's + slot or otherwise mark it transferred, so reuse-as can dec + remaining pointer-typed slots safely. +- **Fn-parameter dec at fn return** — same upstream issue as + 18c.3/18c.4. `(own T)` parameters under explicit modes carry + the "caller handed off ownership" signal but codegen does + not yet emit a dec. +- **Atomic refcounts** — Decision 10's deferred concern; not + in any 18-arc iter. +- **The fresh-arm shallow-dec is conservative.** Like 18c.3's + let-close emission, the source's refcount drops by 1 but no + recursive cascade fires. For a refcount > 1 source, that's + correct (other refs still alive). For the rare path where + the source's refcount IS 1 but reuse-as still goes to fresh + (impossible today — the runtime branch correctly routes such + cases to the reuse arm), we'd want a full cascade. Not + reachable; flagged for grep. + +### Next + +Iter 18e: `(drop-iterative)` annotation + worklist allocator. +Closes the stack-recursion limit on 18c.4's drop fns. Long +lists (millions of cells) currently overflow the stack on +free; the annotation switches the synthesised drop fn from +recursive to iterative-with-explicit-worklist. Likely also +the place to land the move-aware-pattern story alongside — +both are about closing the deallocation surface, and the IR +infrastructure overlaps. + +After 18e, Iter 18f closes the 18-arc: RC validation bench +(target 1.3× of bump on `bench/run.sh`), and if met, retire +Boehm — flip default to `--alloc=rc`, drop `-lgc`, mark +Decision 9 historical. The CLAUDE.md tidy-iter rule then +fires: the next iter after 18f is `ailang-architect`-driven +drift cleanup before 19 begins.