JOURNAL: Iter 18c.4 — per-type drop fn + recursive dec cascade
Records the drop-symbol scheme (uniform @drop_<m>_<T> for every ADT, even no-boxed-children ones; pair+env drop fns for escaping closures), field_drop_call's per-type dispatch and the three fall-back cases (Str / Fn / Type::Var), the unchanged Term::Let emission gates, and the explicit deferral of stack-recursion to 18e's worklist allocator. Also lists the four known-debt items (stack-recursive drop, closure-typed ADT fields, Type::Var fields, fn parameters) so 18d's design surface is clear.
This commit is contained in:
+174
@@ -6465,3 +6465,177 @@ After 18c.4 closes, the 18-arc continues with 18d (reuse hints
|
||||
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.
|
||||
|
||||
## Iter 18c.4 — per-type drop fn + recursive `dec` cascade
|
||||
|
||||
Closes 18c.3's main scope cut. Codegen now emits a per-ADT and
|
||||
per-closure drop function under `--alloc=rc`; the
|
||||
`Term::Let`-scope-close call site routes through these drops
|
||||
instead of `ailang_rc_dec` directly, so a recursive ADT (`List`,
|
||||
`Tree`) under RC actually frees its tail cells when the outer
|
||||
binder's drop fires.
|
||||
|
||||
### The drop-symbol scheme
|
||||
|
||||
For every `Def::Type` `T` in module `m`, codegen emits one
|
||||
`define void @drop_<m>_<T>(ptr %p)` under `--alloc=rc`. The body
|
||||
is uniformly shaped:
|
||||
|
||||
1. Null-guard the pointer (defensive — drop on null is a no-op).
|
||||
2. Load tag from offset 0.
|
||||
3. Switch on tag; one arm per ctor.
|
||||
4. In each arm, for every pointer-typed field, load the field
|
||||
and dispatch through `field_drop_call` (described below).
|
||||
5. After every field is dec'd, `call void @ailang_rc_dec(ptr %p)`
|
||||
to free the outer cell.
|
||||
6. `ret void`.
|
||||
|
||||
For closures, `Term::Lam` emits two drop fns when the closure
|
||||
escapes (heap-allocated): `drop_<m>_<lam-id>_env(ptr %env)` for
|
||||
the captured-free-vars block, and `drop_<m>_<lam-id>_pair(ptr
|
||||
%pair)` for the `{ env, fn-ptr }` pair. The pair-drop dec's the
|
||||
env via `drop_<m>_<lam-id>_env`, then dec's the pair box. A new
|
||||
`closure_drops: BTreeMap<String, String>` field on `Emitter`
|
||||
keys closure-pair SSA names to their pair-drop symbol so
|
||||
`Term::Let` lookup is O(1).
|
||||
|
||||
Decision (preempted in the brief): **always emit
|
||||
`@drop_<m>_<T>` for every ADT, even ones with no boxed
|
||||
children.** A no-boxed-children ADT (like `Box(Int)`) gets a
|
||||
drop fn whose body is just `null-guard; ailang_rc_dec; ret`.
|
||||
Call sites uniformly call `drop_<m>_<T>(ptr v)` and never
|
||||
branch on "does this type have boxed children?". Two payoffs:
|
||||
(1) the codegen call-site code is simpler; (2) any future
|
||||
codegen pass that wants to thread additional cleanup through
|
||||
the drop seam (e.g. atomic dec under threading) has one
|
||||
canonical entry point per type.
|
||||
|
||||
### `field_drop_call` — per-field dispatch
|
||||
|
||||
Given a field's `Type`, `field_drop_call` produces the LLVM
|
||||
instruction(s) that drop the field's value:
|
||||
|
||||
- `Type::Con { name, .. }` (an ADT field) → call
|
||||
`drop_<owner>_<name>` after qualifying `<owner>` via the
|
||||
`import_map`. Recursive `IntList` → recursive `drop_<m>_IntList`
|
||||
call, which is the cascade.
|
||||
- `Type::Str` → `ailang_rc_dec` directly. Strings have no boxed
|
||||
children and are not yet wrapped in their own per-type drop
|
||||
(deferred — the per-type-drop seam is uniform but `Str` is a
|
||||
primitive in DESIGN.md's eyes; no behaviour change).
|
||||
- `Type::Fn` (a closure-typed field) → fall back to
|
||||
`ailang_rc_dec`. The closure-pair dec route requires
|
||||
`closure_drops` lookup which is keyed by *expression site*,
|
||||
not *type*; a Fn-typed *field* doesn't carry the closure-pair
|
||||
ID. Documented as a 18d/18e debt — closure-typed ADT fields
|
||||
leak their captured envs.
|
||||
- `Type::Var` (parameterised type, e.g. `Cons<a>`'s head field
|
||||
in a polymorphic `List<a>`) → fall back to `ailang_rc_dec`.
|
||||
We don't have monomorphisation yet, so `a` could be `Int`
|
||||
(no-op) or `(con List Int)` (needs cascade). The conservative
|
||||
choice is shallow free; full handling is monomorphisation
|
||||
territory (orthogonal to RC, not in the 18-arc).
|
||||
|
||||
The two fall-back cases are flagged at `field_drop_call` with
|
||||
"# Iter 18c.4 debt" comments so they are greppable.
|
||||
|
||||
### `Term::Let` switch-over
|
||||
|
||||
The 18c.3 emission seam at `Term::Let` scope close now calls
|
||||
`drop_symbol_for_binder(value, val_ssa)` instead of
|
||||
`ailang_rc_dec` directly:
|
||||
|
||||
- `Term::Ctor { type, .. }` binders → `drop_<owner-m>_<type>`
|
||||
(looked up via `import_map`; falls back to current module if
|
||||
the type is local).
|
||||
- `Term::Lam` binders → the `closure_drops`-recorded pair-drop
|
||||
symbol.
|
||||
- Other expression shapes → unchanged from 18c.3 (codegen does
|
||||
not own the lifecycle of a returned-from-callee box; that is
|
||||
18d's mode-aware story).
|
||||
|
||||
The other emission gates from 18c.3 (`consume_count == 0`,
|
||||
body-tail-not-binder, block-not-terminated, `non_escape`
|
||||
membership) are unchanged. 18c.4 only swaps the *target* of the
|
||||
call.
|
||||
|
||||
### Recursion is stack-recursive (deferred to 18e)
|
||||
|
||||
`drop_<m>_IntList`'s `Cons` arm calls `drop_<m>_IntList` on the
|
||||
tail. For a 5-cell list, that's 5 stack frames. For a million-
|
||||
cell list, that's a million frames and a stack overflow. **Iter
|
||||
18e replaces this with a worklist allocator** — the recursive
|
||||
call becomes a push-onto-worklist + iterative-pop loop. The
|
||||
recursive call site in `field_drop_call` is commented with
|
||||
"replaced in 18e by worklist-based iterative free".
|
||||
|
||||
For 18c.4's shipping fixtures (5-element list), the depth is
|
||||
safely below any reasonable stack. The worklist conversion is
|
||||
mechanical once 18e introduces the worklist primitive.
|
||||
|
||||
### Test additions
|
||||
|
||||
- `examples/rc_list_drop.ail.json` — 5-element `IntList` summed
|
||||
via `sum_list`; `xs` has `consume_count == 1` (passed to
|
||||
`sum_list`), so codegen does NOT dec at main's let-close.
|
||||
Fixture's purpose is to lock in the IR shape under
|
||||
`--alloc=rc` (covered by the codegen unit test) and confirm
|
||||
the recursive ADT compiles + runs correctly under both
|
||||
allocators. Stdout: `15`.
|
||||
|
||||
- `examples/rc_list_drop_borrow.ail.json` — same 5-element
|
||||
`IntList`, but `main` only matches it (borrow) and prints the
|
||||
head. `xs` has `consume_count == 0`, so codegen DOES emit
|
||||
`call void @drop_rc_list_drop_borrow_IntList(ptr xs)` at
|
||||
scope close. The drop fn's `Cons` arm recurses on the tail,
|
||||
cascading through all 5 cells and freeing each in turn. The
|
||||
E2E test asserts identical stdout vs `--alloc=gc` AND clean
|
||||
exit (which is the implicit "no segfault, no underflow" check
|
||||
for the recursive cascade).
|
||||
|
||||
- `crates/ailang-codegen/src/lib.rs` unit test
|
||||
`rc_alloc_emits_recursive_drop_fn_for_recursive_adt` — IR
|
||||
shape lock-in. Asserts:
|
||||
1. `define void @drop_rclist_IntList(ptr %p)` is emitted
|
||||
under `--alloc=rc`.
|
||||
2. The fn body contains a recursive call
|
||||
`call void @drop_rclist_IntList(ptr %v...)` (the cascade).
|
||||
3. The fn body ends with
|
||||
`call void @ailang_rc_dec(ptr %p)` (outer-box dec).
|
||||
4. Under `--alloc=gc`, NO `@drop_rclist_IntList` symbol
|
||||
appears (negative complement — drop fns are an RC-only
|
||||
emission).
|
||||
|
||||
### Test deltas
|
||||
|
||||
- E2E: 53 → 55 (+2: `alloc_rc_recursive_list_sum`,
|
||||
`alloc_rc_borrow_only_recursive_list_drop`).
|
||||
- ailang-codegen unit: 11 → 12 (+1: IR-shape test).
|
||||
- All other buckets unchanged.
|
||||
- `cargo test --workspace` green.
|
||||
|
||||
### Known debt (deliberate, deferred)
|
||||
|
||||
- **Stack-recursive drop** — Iter 18e replaces with worklist
|
||||
allocator. Long lists overflow the stack today.
|
||||
- **Closure-typed ADT fields** fall back to shallow
|
||||
`ailang_rc_dec`. `field_drop_call`'s `Type::Fn` arm is the
|
||||
greppable seam.
|
||||
- **Parameterised type fields** (`Type::Var`) fall back to
|
||||
shallow `ailang_rc_dec`. Full handling is monomorphisation,
|
||||
orthogonal to RC.
|
||||
- **Fn parameters** still don't get dec'd at fn return — the
|
||||
caller-handed-off-ownership signal is the `(own T)` mode, but
|
||||
wiring it through codegen is part of the wider mode-aware
|
||||
story (18d alongside reuse hints).
|
||||
|
||||
### Next
|
||||
|
||||
Iter 18d: reuse hints + reuse analysis. The `(reuse-as
|
||||
old-binder NewCtor)` form lets the author signal that a freed
|
||||
cell can be overwritten in-place with a new ctor of the same
|
||||
shape, avoiding the dec+malloc round-trip. Codegen lowers it to
|
||||
a tag-overwrite + field-rewrite, eliding both the `@drop_` and
|
||||
the `@ailang_rc_alloc` calls. Inference identifies the cases
|
||||
where the rewrite is safe (the freed binder's last use is
|
||||
exactly here, the new ctor has the same shape, no aliasing).
|
||||
|
||||
Reference in New Issue
Block a user