JOURNAL: Iter 18d.2 — codegen reuse-as in-place rewrite

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.
This commit is contained in:
2026-05-08 11:22:21 +02:00
parent 73545ab086
commit 509c9700bf
+193
View File
@@ -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_<type>` 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.<n>` 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.