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:
+193
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user