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