JOURNAL: Iter 18c.3 — uniqueness inference + non-recursive RC inc/dec
Records the inference's max-over-paths semantics, the codegen gates (consume_count == 0, body-tail-not-binder, block-not-terminated, non-escape membership), the @-prefix elide on closure-pair globals, the rc_box_drop fixture's choice of a no-boxed-children ADT (shallow free sufficient), and the explicit scope cut for per-type drop fn (18c.4). Closes with the dispatch note for 18c.4 and the reminder that the new CLAUDE.md tidy-iter rule fires after 18f when the 18-arc closes.
This commit is contained in:
+161
@@ -6304,3 +6304,164 @@ surface; 18c.3's inference is internal codegen-side bookkeeping.
|
||||
The two layers communicate only through the explicit
|
||||
`Term::Clone` markers in the source — the linearity check
|
||||
demands them, the codegen pass honours them.
|
||||
|
||||
## Iter 18c.3 — uniqueness inference + non-recursive RC `inc`/`dec`
|
||||
|
||||
End of the leak-everything era under `--alloc=rc`. Codegen now
|
||||
emits `ailang_rc_inc` at every `Term::Clone` and `ailang_rc_dec`
|
||||
at end-of-scope of trackable RC-allocated let-binders. The
|
||||
default `--alloc=boehm` path is untouched.
|
||||
|
||||
### Two parts, two files
|
||||
|
||||
**Part A — `crates/ailang-check/src/uniqueness.rs`** (new module,
|
||||
`pub mod uniqueness`). Post-typecheck dataflow producing
|
||||
`UniquenessTable: BTreeMap<(def, binder), UniquenessInfo>` with
|
||||
`Uniqueness::{Unique, Shared}` and a `consume_count: u32`
|
||||
side-output. The walk reuses the position model from 18c.2's
|
||||
linearity check (Consume vs Borrow), but runs unconditionally on
|
||||
every fn (not gated on all-explicit modes) and produces no
|
||||
diagnostics — the table is internal codegen input.
|
||||
|
||||
`consume_count` is **max-over-paths**: at `Term::If` and
|
||||
`Term::Match` the walker saves state, walks each arm against the
|
||||
saved snapshot, and `merge_states` takes the max of the
|
||||
per-binder counters. So `(let x e1 (if c x x))` gets
|
||||
`consume_count = 1` (one arm consumes once, the other arm
|
||||
consumes once, max = 1), correctly preventing codegen from
|
||||
double-dec'ing on either branch.
|
||||
|
||||
`Term::Clone { value }` is treated as a Borrow on the inner term
|
||||
— the explicit clone is the user's signal that a fresh ref is
|
||||
being produced via inc, not a fresh consume of the original
|
||||
binder.
|
||||
|
||||
5 unit tests: let unused / consumed once / consumed twice /
|
||||
pattern bindings / `(clone)` is borrow.
|
||||
|
||||
**Part B — codegen emission in
|
||||
`crates/ailang-codegen/src/lib.rs`.** Two seams:
|
||||
|
||||
- **`Term::Clone { value }`** (lib.rs:1256–1278): lower the inner
|
||||
value, then under `--alloc=rc` and `val_ty == "ptr"` and
|
||||
`!val_ssa.starts_with('@')` emit
|
||||
`call void @ailang_rc_inc(ptr %v)`. The `@`-prefix gate elides
|
||||
inc on top-level fn closure-pair globals — those live in the
|
||||
LLVM data segment, not in `runtime/rc.c`'s 8-byte-header heap.
|
||||
|
||||
- **`Term::Let { name, value, body }`** (lib.rs:1058–1110): a
|
||||
new `is_rc_heap_allocated` predicate (lib.rs:984–1012) decides
|
||||
whether `value` lowers through `ailang_rc_alloc` — true iff
|
||||
`value` is `Term::Ctor` or `Term::Lam` AND the term is in the
|
||||
current fn's escape-set (i.e. it heap-allocates rather than
|
||||
`alloca`-allocates). When true AND `val_ty == "ptr"` AND the
|
||||
uniqueness table reports `consume_count == 0` AND the body's
|
||||
tail SSA is not the binder itself AND the current block is not
|
||||
terminated, codegen emits
|
||||
`call void @ailang_rc_dec(ptr %v)` after the body lowers,
|
||||
before the let exits. The four extra gates each catch a
|
||||
failure mode:
|
||||
- `consume_count == 0`: a non-zero count means a callee/outer
|
||||
site already took ownership; double-dec is undefined
|
||||
behaviour against `runtime/rc.c`'s underflow guard.
|
||||
- body-tail-is-binder: a let whose body returns the binder
|
||||
transfers ownership to the caller — caller dec's, not us.
|
||||
- block-not-terminated: a tail call or `unreachable` already
|
||||
closed the block; emitting after is malformed LLVM IR.
|
||||
- `non_escape` membership: stack `alloca`s have no header to
|
||||
dec.
|
||||
|
||||
Header declarations of `@ailang_rc_inc` / `@ailang_rc_dec` are
|
||||
gated on `--alloc=rc` (lib.rs:421–429) so the Boehm and Bump
|
||||
modules' IR shape is byte-identical to before this iter.
|
||||
|
||||
### Fixture and E2E
|
||||
|
||||
`examples/rc_box_drop.ail.json`: a single-cell `Box(Int)` ADT,
|
||||
`main` does `(let b (MkBox 42) (match b (MkBox x) (print x)))`.
|
||||
`b` has `consume_count == 0` (only borrow-position match
|
||||
scrutinee), so codegen emits `dec` after the match join. The
|
||||
`MkBox` cell has no boxed children, so the shallow `free()` in
|
||||
`runtime/rc.c::ailang_rc_dec` is sufficient — this fixture
|
||||
deliberately avoids the recursive-dec story (deferred to 18c.4).
|
||||
|
||||
E2E test `alloc_rc_emits_dec_for_unique_let_bound_box` at
|
||||
`crates/ail/tests/e2e.rs:1349` builds the fixture under both
|
||||
`--alloc=gc` and `--alloc=rc`, asserts both emit `42`, and
|
||||
asserts byte-identical stdout. Properties guarded:
|
||||
1. dec does not free the box BEFORE the match reads its payload;
|
||||
2. dec does not double-free or trip the underflow guard;
|
||||
3. RC vs GC paths produce identical observable behaviour.
|
||||
|
||||
### Test deltas
|
||||
|
||||
- E2E: 52 → 53.
|
||||
- ailang-check unit: 38 → 43 (5 new uniqueness tests).
|
||||
- All other buckets unchanged.
|
||||
- `cargo test --workspace` green.
|
||||
|
||||
### Scope cuts (deliberate)
|
||||
|
||||
- **Recursive `dec` cascades / per-type drop fns — Iter 18c.4.**
|
||||
`runtime/rc.c::ailang_rc_dec` still frees the box without
|
||||
recursing into boxed children (its top-of-file comment already
|
||||
documents this). 18c.3 ships shallow free; recursive ADTs
|
||||
(List, Tree) under `--alloc=rc` still leak everything except
|
||||
the outermost cell. The fix is a per-type drop function the
|
||||
codegen emits for each ADT — one `void @drop_<type>(ptr)`
|
||||
symbol that walks the boxed children, dec's each, then frees
|
||||
the outer cell.
|
||||
|
||||
- **Binders with `consume_count > 0`.** No `dec` is emitted —
|
||||
the value moved to a callee or returned, and the receiving
|
||||
site is responsible. This is correct under the current ABI
|
||||
for callee-takes-ownership, but means a fixture that returns
|
||||
a heap-allocated value still leaks the outer cell (the
|
||||
caller's let-binding is a separate scope and a separate
|
||||
question). 18c.4 / 18d will tighten this.
|
||||
|
||||
- **Fn parameters.** The uniqueness table records them but
|
||||
codegen does not emit `dec` on params — there's no static
|
||||
signal yet for "this parameter is RC-allocated and the caller
|
||||
has handed off ownership". `(own T)` parameters under explicit
|
||||
modes carry exactly that semantic; wiring it through is part
|
||||
of the wider mode-aware codegen story (likely 18d alongside
|
||||
reuse hints).
|
||||
|
||||
- **`runtime/rc.c::ailang_rc_inc` UB on static globals.**
|
||||
Codegen now elides `inc` on `@`-prefixed SSAs, so the UB path
|
||||
in `runtime/rc.c` is unreachable in practice. The runtime
|
||||
itself still has no guard; a defensive check (e.g. flag bit
|
||||
in the header word) is a tidy-iter concern, not load-bearing.
|
||||
|
||||
### Why a separate inference pass from `linearity`
|
||||
|
||||
Different scope, different output, different consumption point.
|
||||
`linearity` is opt-in by signature (every param mode explicit)
|
||||
and emits user diagnostics; uniqueness inference runs
|
||||
unconditionally on every fn body and produces an internal side
|
||||
table. They share the position model but nothing else, and
|
||||
collapsing them would either force user-facing diagnostics on
|
||||
fns that don't opt in, or hide the codegen input behind a gate
|
||||
the codegen doesn't want. The two-pass design preserves
|
||||
Decision 10's separation: linearity is the user-visible
|
||||
*language* surface; uniqueness is the *implementation*.
|
||||
|
||||
### Next
|
||||
|
||||
Iter 18c.4 (queued, was implicit in 18c original splitting):
|
||||
per-type drop fn / recursive `dec` cascade. Codegen emits a
|
||||
`void @drop_<type>(ptr)` for each `Type::Type` ADT that walks
|
||||
boxed children, calls `ailang_rc_dec` on each, then frees the
|
||||
outer cell. The `Term::Let` dec-emission seam from 18c.3 then
|
||||
calls `@drop_<value-type>` instead of `@ailang_rc_dec` directly
|
||||
when the binder's type has boxed children. After 18c.4, RC
|
||||
ADTs (List, Tree) under `--alloc=rc` should be allocation-leak-
|
||||
free under `valgrind --leak-check=full`.
|
||||
|
||||
After 18c.4 closes, the 18-arc continues with 18d (reuse hints
|
||||
+ reuse analysis), 18e (drop-iterative + worklist free), 18f
|
||||
(RC validation bench + Boehm retirement). After 18f the entire
|
||||
18-arc closes — at which point the new tidy-iter rule from
|
||||
CLAUDE.md kicks in: the next iter is `ailang-architect`-driven
|
||||
drift cleanup before 19 starts.
|
||||
|
||||
Reference in New Issue
Block a user