Iter 18d.3: move-aware pattern bindings via codegen-side bookkeeping

Closes 18d.2's per-field-dec scope cut without mutating the
source. Codegen Emitter gains a per-fn-body side table:

  moved_slots: BTreeMap<binder_name, BTreeSet<field_idx>>

When lower_match destructures a pattern and binds a non-wildcard
pointer-typed slot, codegen records (scrutinee_binder, slot_idx)
in moved_slots — but does NOT mutate the source's slot. The
source box stays untouched; the bookkeeping carries the
move-out information through codegen.

Two consumer sites:

(1) Term::Let scope close. If moved_slots[binder] is non-empty,
codegen inlines a per-field dec sequence: load each pointer-
typed field whose index is NOT in moved_slots, dispatch through
field_drop_call, then ailang_rc_dec on the outer cell. If
moved_slots[binder] is empty (the common case for every shipping
fixture today), the call to drop_<m>_<T>(ptr) is unchanged from
18c.4 — no IR shape regression.

(2) lower_reuse_as_rc reuse arm. Per-field dec is now safe — the
moved-out slots are skipped via moved_slots lookup; non-moved
slots are dec'd via field_drop_call's null-guarded drop fns.
Replaces the 18d.2 punt rationale block with the new invariant.

drop_<m>_<T> itself is unchanged: it operates on a fully-owned
pointer (the cascade has no notion of "partially moved"). The
partial-move complexity is confined to top-level emission sites
that have the static info.

Why static tracking, not source-mutation: the previous attempt
(null-out the source's slot at pattern-bind point) ran into two
structural issues: (i) borrowed scrutinees must not be mutated —
null-out violates 18a's borrow contract; (ii) Desugarer creates
chains that re-scrutinise the same scrutinee across arms, and
mutation in arm 1 makes arm 2's re-load see garbage. Static
tracking sidesteps both: the source box stays bit-identical
across the entire match.

Tests:
- new fixture examples/pat_extract_partial_drop — Pair(IntList,
  IntList) destructured (a, _) — exercises moved + wildcard
  pointer slots.
- new e2e alloc_rc_partial_drop_skips_moved_keeps_wildcarded
  asserts stdout 6 under both gc and rc, plus IR shape: main's
  let-close inlines drop on the wildcarded slot but NOT on the
  moved slot.
- updated reuse_as_demo_under_rc_uses_inplace_rewrite IR-shape
  assertion — the canonical fixture moves the only pointer
  slot, so the reuse arm should contain neither @drop_ nor
  @ailang_rc_dec for fields.

Test deltas: e2e 56 -> 57 (+1). All other buckets unchanged.
cargo test --workspace green.

Known regression — narrow, deliberate, queued for 18d.4:

The prior alloc_rc_borrow_only_recursive_list_drop fixture from
18c.4 had a pattern (Cons h t) where t was bound but never
consumed downstream. Under 18c.4, drop_<m>_<IntList>(xs) at
let-close cascaded through xs.tail and freed the entire 5-cell
chain. Under 18d.3 with strategy (b), xs.tail is in
moved_slots, so the cascade is interrupted there — but t is
never consumed, so its 4-cell tail now leaks. The fixture
stdout is unchanged (still "11"), but memory hygiene under
--alloc=rc is strictly worse.

The fix is symmetric to 18c.4's known fn-parameter-dec debt:
pattern-bound binders whose consume_count is 0 at arm close
should also get a drop call emitted. Same emission seam as
let-close-drop. Queued as 18d.4. Until 18d.4 ships, fixtures
with pattern-extracted unused pointer binders leak under
--alloc=rc; this is documented and bounded (shipping fixtures
beyond the test suite generally consume every binder they
bind).
This commit is contained in:
2026-05-08 12:18:12 +02:00
parent 509c9700bf
commit c96940ea96
4 changed files with 416 additions and 28 deletions
+243 -28
View File
@@ -552,6 +552,33 @@ struct Emitter<'a> {
/// `--alloc=gc`/`--alloc=bump` has no drop fn and is freed by
/// the collector / arena.
closure_drops: BTreeMap<String, String>,
/// Iter 18d.3: per-fn-body move tracking. Keyed by binder name, maps
/// to the set of positional ctor-field indices that have been
/// "moved out" via a pattern destructure. A field is moved when a
/// `(case (Ctor h t) <body>)` arm binds a non-wildcard, pointer-
/// typed slot — the load-into-binder is treated as a transfer of
/// ownership from the source slot to the binder's SSA. The source
/// slot is NOT mutated; codegen merely remembers, statically, that
/// the binder's new owner now holds the only live reference along
/// this path.
///
/// Consulted at two call sites that emit dec sequences against the
/// source binder:
/// 1. `Term::Let` scope close (`is_rc_heap_allocated` path) —
/// when the entry is non-empty, codegen inlines a per-field
/// dec sequence that skips slots in the moved set. When the
/// entry is empty (the common case), the existing
/// `drop_<m>_<T>(ptr)` call is emitted unchanged.
/// 2. `lower_reuse_as_rc`'s reuse arm — moved-out slots are
/// skipped (they no longer hold a live reference); non-moved
/// slots are dec'd via `field_drop_call` before the new field
/// values overwrite them.
///
/// Reset to empty at the top of every fn body (`emit_fn` and the
/// thunk-emission section of `lower_lambda`). Entries for a
/// particular binder are removed when that binder leaves scope
/// (on `Term::Let` body close, on match-arm body close).
moved_slots: BTreeMap<String, BTreeSet<usize>>,
}
#[derive(Debug, Clone)]
@@ -665,6 +692,7 @@ impl<'a> Emitter<'a> {
alloc,
uniqueness,
closure_drops: BTreeMap::new(),
moved_slots: BTreeMap::new(),
}
}
@@ -1031,6 +1059,8 @@ impl<'a> Emitter<'a> {
// collected during lowering and appended after the parent fn.
self.current_def = f.name.clone();
self.lam_counter = 0;
// Iter 18d.3: move tracking is per-fn-body.
self.moved_slots.clear();
// Iter 17a: run escape analysis over the fn body. The result
// is queried at every `Term::Ctor` / `Term::Lam` lowering site
// to decide between `alloca` (non-escaping) and `@GC_malloc`
@@ -1215,6 +1245,72 @@ impl<'a> Emitter<'a> {
}
}
/// Iter 18d.3: emit a partial-drop sequence inline at a let-close
/// site whose binder has moved-out pattern slots. Replaces the
/// uniform `drop_<m>_<T>(ptr)` call: load each pointer-typed
/// field whose slot index is NOT in `moved`, dispatch through
/// `field_drop_call` (the same per-type or shallow drop the
/// recursive cascade picks), then `ailang_rc_dec` the outer
/// box. Skips slots in `moved` entirely — those values were
/// transferred to a pattern-bound binder that owns the dec
/// for them.
///
/// `value` must be a `Term::Ctor` — `is_rc_heap_allocated` only
/// returns true for `Term::Ctor` / `Term::Lam`, and a `Term::Lam`
/// binder cannot accumulate moved slots (closures are not pattern-
/// matched into positional fields). The match-or-error pattern
/// guards that invariant.
fn emit_inlined_partial_drop(
&mut self,
value: &Term,
val_ssa: &str,
moved: &BTreeSet<usize>,
) -> Result<()> {
let (type_name, ctor_name) = match value {
Term::Ctor { type_name, ctor, .. } => (type_name.as_str(), ctor.as_str()),
_ => {
return Err(CodegenError::Internal(
"emit_inlined_partial_drop: binder value is not a Term::Ctor; \
moved_slots should only accumulate against Ctor binders"
.into(),
));
}
};
let cref = self.lookup_ctor_by_type(type_name, ctor_name)?;
// Per-field dec for non-moved pointer-typed slots. ail_fields
// are the AILang-level field types; field_drop_call resolves
// them to either `drop_<owner>_<T>` (ADTs cascade) or
// `ailang_rc_dec` (Str / closures / vars).
for (idx, fty_ail) in cref.ail_fields.iter().enumerate() {
let lty = llvm_type(fty_ail).unwrap_or_else(|_| "ptr".into());
if lty != "ptr" {
continue;
}
if moved.contains(&idx) {
continue;
}
let off = 8 + (idx as i64) * 8;
let addr = self.fresh_ssa();
self.body.push_str(&format!(
" {addr} = getelementptr inbounds i8, ptr {val_ssa}, i64 {off}\n"
));
let v = self.fresh_ssa();
self.body.push_str(&format!(
" {v} = load ptr, ptr {addr}, align 8\n"
));
let drop_call = self.field_drop_call(fty_ail);
self.body.push_str(&format!(
" call void @{drop_call}(ptr {v})\n"
));
}
// Finally dec the outer box. The per-type drop fn would have
// done this in its `join` block; we replicate it here.
self.body.push_str(&format!(
" call void @ailang_rc_dec(ptr {val_ssa})\n"
));
Ok(())
}
/// Lowers a term to (SSA value string, LLVM type).
fn lower_term(&mut self, t: &Term) -> Result<(String, String)> {
match t {
@@ -1303,6 +1399,14 @@ impl<'a> Emitter<'a> {
.push((name.clone(), val_ssa.clone(), val_ty.clone(), val_ail));
let r = self.lower_term(body);
self.locals.pop();
// Iter 18d.3: lift the binder's move set out of the
// side table. The binder is leaving scope here; we
// remove the entry whether or not we end up using it
// for the dec emission below. `take` returns a
// by-value `BTreeSet<usize>` (or empty) so we can
// both consult and clear in one move.
let moves_for_binder: BTreeSet<usize> =
self.moved_slots.remove(name).unwrap_or_default();
// Iter 18c.3: emit a drop call at scope close iff:
// - we're tracking this binder (heap RC alloc above),
@@ -1336,10 +1440,31 @@ impl<'a> Emitter<'a> {
Err(_) => true, // Don't emit on error path either.
};
if consume_count == 0 && !body_returns_binder {
let drop_sym = self.drop_symbol_for_binder(value, &val_ssa);
self.body.push_str(&format!(
" call void @{drop_sym}(ptr {val_ssa})\n"
));
// Iter 18d.3: when the binder has moved-out
// pattern slots, the uniform `drop_<m>_<T>`
// would re-dec values that have already been
// transferred to other binders. Inline a
// per-field dec sequence that skips moved
// slots; non-moved pointer slots dec via
// `field_drop_call` (null-guarded drop fns
// matching the recursive cascade in
// `emit_drop_fn_for_type`). For the empty-
// moves common case (every fixture pre-18d.3
// and most fixtures post-18d.3) we emit the
// identical `drop_<m>_<T>(ptr)` call as
// 18c.4, preserving IR shape.
if !moves_for_binder.is_empty() {
self.emit_inlined_partial_drop(
value,
&val_ssa,
&moves_for_binder,
)?;
} else {
let drop_sym = self.drop_symbol_for_binder(value, &val_ssa);
self.body.push_str(&format!(
" call void @{drop_sym}(ptr {val_ssa})\n"
));
}
}
}
@@ -1815,6 +1940,21 @@ impl<'a> Emitter<'a> {
"reuse-as source type must be ptr, got {src_ty}"
)));
}
// Iter 18d.3: the source binder name keys into `moved_slots`
// so the reuse arm can skip dec'ing fields that earlier
// pattern-extracted out of the source. Linearity already
// ensures `source` is `Term::Var` here; the var-resolution
// is just defensive.
let source_binder: Option<String> = match source {
Term::Var { name } => {
if self.locals.iter().any(|(n, _, _, _)| n == name) {
Some(name.clone())
} else {
None
}
}
_ => None,
};
// The source's drop symbol — needed on the fresh arm to
// release this caller's share of the source (refcount > 1,
// by the branch we are inside). We deliberately use the
@@ -1929,33 +2069,59 @@ impl<'a> Emitter<'a> {
" br i1 {is_one}, label %{reuse_lbl}, label %{fresh_lbl}\n"
));
// 5. Reuse arm: overwrite tag and field slots in place. The
// old pointer-typed field values are NOT dec'd here.
// 5. Reuse arm: overwrite tag and field slots in place.
//
// Rationale: 18c.4's RC story does not yet emit a dec on
// pattern-bound fields at scope close (see 18c.4's "fn
// parameters / pattern-move debt" — patterns LOAD field
// pointers without inc'ing, treating the load as a
// logical move-out into the binder). Under that model,
// by the time control reaches the reuse-as site, every
// pointer-typed old field that the body actually
// consumes has already been "moved out" of the slot;
// dec'ing it again here would either double-free (when
// the body's evaluation freed it) or, worse, free a box
// the new field value aliases (the canonical case:
// `(reuse-as xs (Cons (+ h 1) (map_inc t)))` returns
// `t`'s in-place-rewritten box as the new tail). A
// self-dec that cascades through B.field[1]=C while C
// is also the new tail produces a use-after-free.
// Iter 18d.3: per-field dec is now safe — moved-out slots
// are skipped via the codegen `moved_slots` side table;
// non-moved slots are dec'd via `field_drop_call`'s null-
// guarded drop fns BEFORE the new field values overwrite
// them. The shape check (18d.2) guarantees source's old
// ctor and body's new ctor have matching field counts and
// types, so `qualified_ail_fields` is correct for both.
//
// The cost: an old pointer-typed field that the body did
// NOT pattern-move (e.g. `match xs (Cons _ _ → ...)`
// with both fields wildcarded) leaks here. That leak is
// the same shape 18c.4 already accepts for non-trackable
// pattern fields, so 18d.2 doesn't regress the baseline.
// Closing the leak requires the move-aware pattern story
// (queued for 18d.3 / 18e alongside fn-parameter dec).
// Why this is sound under the canonical
// `(reuse-as xs (Cons (+ h 1) (map_inc t)))` pattern:
// the `Cons` arm of the enclosing match recorded both
// field 0 and field 1 of `xs` as moved; both are skipped
// here and no dec executes against the reused slots.
// `(map_inc t)`'s in-place-rewritten box is stored into
// field 1 unchanged — no use-after-free.
//
// For a fixture like `(match xs (Cons _ _ → reuse-as xs
// (Cons 0 0)))` where neither slot is moved, both
// pointer-typed fields are dec'd via `field_drop_call`,
// closing the 18d.2 leak.
self.start_block(&reuse_lbl);
// Iter 18d.3: dec non-moved pointer-typed slots before the
// overwrite. The dec fns are null-guarded, so an empty slot
// is a no-op. Clone the move set out of the side table so
// the loop below can mutably borrow `self` for SSA allocation.
let moves_for_src: BTreeSet<usize> = source_binder
.as_ref()
.and_then(|sb| self.moved_slots.get(sb).cloned())
.unwrap_or_default();
for (idx, fty_ail) in qualified_ail_fields.iter().enumerate() {
let lty = llvm_type(fty_ail).unwrap_or_else(|_| "ptr".into());
if lty != "ptr" {
continue;
}
if moves_for_src.contains(&idx) {
continue;
}
let off = 8 + (idx as i64) * 8;
let addr = self.fresh_ssa();
self.body.push_str(&format!(
" {addr} = getelementptr inbounds i8, ptr {src_ssa}, i64 {off}\n"
));
let v = self.fresh_ssa();
self.body.push_str(&format!(
" {v} = load ptr, ptr {addr}, align 8\n"
));
let drop_call = self.field_drop_call(fty_ail);
self.body.push_str(&format!(
" call void @{drop_call}(ptr {v})\n"
));
}
// Store new tag at offset 0. Even when the new tag equals the
// old tag (most reuse-as fixtures: Cons → Cons), we emit the
// store unconditionally — codegen doesn't have to special-
@@ -2031,6 +2197,23 @@ impl<'a> Emitter<'a> {
"match on non-ADT scrutinee (got {s_ty}); MVP supports only ADTs"
)));
}
// Iter 18d.3: move tracking key — the bare-Var binder name of
// the scrutinee, if it has one. A complex expression scrutinee
// (e.g. the result of `(map_inc xs)` matched directly) has no
// binder we can key off; moves through such matches are
// untracked (see the assignment's "untracked scrutinee"
// fallback). Only record when the var actually resolves to a
// local binder.
let scrutinee_binder: Option<String> = match scrutinee {
Term::Var { name } => {
if self.locals.iter().any(|(n, _, _, _)| n == name) {
Some(name.clone())
} else {
None
}
}
_ => None,
};
// Load tag.
let tag = self.fresh_ssa();
@@ -2136,6 +2319,12 @@ impl<'a> Emitter<'a> {
_ => None,
};
let mut pushed = 0usize;
// Iter 18d.3: collect the binder names this arm pushes so
// we can drop their `moved_slots` entries when the arm
// body closes (these binders are themselves freshly named
// here — their move tracking starts empty and ends at arm
// body close).
let mut arm_pushed_binders: Vec<String> = Vec::new();
for (idx, binding) in bindings.iter().enumerate() {
if let Some(bname) = binding {
let raw_ail = cref.ail_fields.get(idx).cloned().unwrap_or(Type::unit());
@@ -2161,7 +2350,22 @@ impl<'a> Emitter<'a> {
self.body.push_str(&format!(
" {v} = load {fty}, ptr {addr}, align 8\n"
));
// Iter 18d.3: a non-wildcard, pointer-typed slot
// bound here is treated as moved out of the
// scrutinee binder. The source slot is NOT
// mutated; codegen records the move statically so
// a later top-level dec sequence (let-close,
// reuse-arm) skips this slot.
if fty == "ptr" {
if let Some(sb) = &scrutinee_binder {
self.moved_slots
.entry(sb.clone())
.or_default()
.insert(idx);
}
}
self.locals.push((bname.clone(), v, fty, bind_ail));
arm_pushed_binders.push(bname.clone());
pushed += 1;
}
}
@@ -2170,6 +2374,12 @@ impl<'a> Emitter<'a> {
for _ in 0..pushed {
self.locals.pop();
}
// Iter 18d.3: arm-bound binders go out of scope at arm
// body close. Drop their move-tracking entries (any moves
// recorded against `h`/`t` belonged to this arm only).
for bname in &arm_pushed_binders {
self.moved_slots.remove(bname);
}
// Iter 14e: if the arm body lowered to a `musttail call` +
// `ret`, the block is already terminated. Skip the
// fall-through `br` and exclude this arm from the join phi.
@@ -2694,6 +2904,10 @@ impl<'a> Emitter<'a> {
// of the saved emitter state.
let saved_non_escape = std::mem::take(&mut self.non_escape);
self.non_escape = escape::analyze_fn_body(lam_body);
// Iter 18d.3: a lambda thunk is its own fn frame for move
// tracking — the outer fn's binders are not in scope inside
// the thunk body (only its captures + params). Save and reset.
let saved_moved = std::mem::take(&mut self.moved_slots);
// Lambdas inside lambdas are fine: they get their own counter
// namespace within the enclosing thunk. They share the
// `deferred_thunks` queue (top-level body owns it).
@@ -2775,6 +2989,7 @@ impl<'a> Emitter<'a> {
self.block_terminated = saved_terminated;
self.ssa_fn_sigs = saved_sigs;
self.non_escape = saved_non_escape;
self.moved_slots = saved_moved;
// 3. Emit allocation + capture filling + closure-pair packing
// in the OUTER body. Captures use 8 bytes each; closure-pair