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:
@@ -1423,6 +1423,23 @@ fn reuse_as_demo_under_rc_uses_inplace_rewrite() {
|
||||
!reuse_arm.contains("@ailang_rc_alloc"),
|
||||
"reuse arm must not call @ailang_rc_alloc (the slot is reused in place). Reuse arm IR was:\n{reuse_arm}"
|
||||
);
|
||||
// Iter 18d.3: in this canonical fixture the pattern `(Cons h t)`
|
||||
// moves the only pointer-typed slot of the source's old ctor
|
||||
// (slot 1, the tail; slot 0 is Int and never a dec candidate).
|
||||
// The reuse arm therefore must emit NEITHER a per-field drop
|
||||
// call nor an outer `ailang_rc_dec` against the source — only
|
||||
// the unconditional tag store and field stores. The 18d.2 leak
|
||||
// (full slot leaking under the reuse arm regardless of moves)
|
||||
// is closed: the precise-skip behaviour matches what 18d.3's
|
||||
// moved_slots side table records for `xs` here.
|
||||
assert!(
|
||||
!reuse_arm.contains("@drop_"),
|
||||
"reuse arm must not call any `@drop_<m>_<T>` — both pointer slots are moved in this fixture. Reuse arm IR was:\n{reuse_arm}"
|
||||
);
|
||||
assert!(
|
||||
!reuse_arm.contains("@ailang_rc_dec"),
|
||||
"reuse arm must not call `@ailang_rc_dec` against the source (refcount-1 path keeps the slot alive in place). Reuse arm IR was:\n{reuse_arm}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Iter 18b: extends `alloc_rc_produces_same_stdout_as_gc` to a larger
|
||||
@@ -1534,3 +1551,93 @@ fn alloc_rc_borrow_only_recursive_list_drop() {
|
||||
"alloc=rc must match alloc=gc on rc_list_drop_borrow"
|
||||
);
|
||||
}
|
||||
|
||||
/// Iter 18d.3: move-aware pattern bindings — let-close inlines a
|
||||
/// per-field dec sequence that skips moved slots and dec's non-moved
|
||||
/// (e.g. wildcarded) pointer-typed slots.
|
||||
///
|
||||
/// The fixture defines a `Pair (IntList, IntList)` and pattern-matches
|
||||
/// it as `(Pair a _)` — the first slot is moved into `a` (consumed by
|
||||
/// `sum_list a`), the second slot is wildcarded and remains owned by
|
||||
/// the Pair box. At p's let-close, codegen replaces the uniform
|
||||
/// `drop_<m>_Pair` call with an inline sequence:
|
||||
///
|
||||
/// 1. NO load+drop for slot 0 (offset 8) — it is in
|
||||
/// `moved_slots["p"]` → skipped.
|
||||
/// 2. YES load+drop for slot 1 (offset 16) via
|
||||
/// `@drop_<m>_IntList(<slot1_v>)` — it is non-moved.
|
||||
/// 3. YES `@ailang_rc_dec(<p>)` for the outer Pair box.
|
||||
///
|
||||
/// Properties guarded:
|
||||
/// 1. The binary runs to completion under both `--alloc=gc` and
|
||||
/// `--alloc=rc`.
|
||||
/// 2. Stdout matches `--alloc=gc` byte-for-byte (`6` — the sum of
|
||||
/// the first list, [1,2,3]).
|
||||
/// 3. The IR at p's let-close contains an inlined per-field drop
|
||||
/// for slot 1 (the wildcarded IntList) but NOT the uniform
|
||||
/// `drop_<m>_Pair` call against `p`.
|
||||
#[test]
|
||||
fn alloc_rc_partial_drop_skips_moved_keeps_wildcarded() {
|
||||
let example = "pat_extract_partial_drop.ail.json";
|
||||
let stdout_gc = build_and_run_with_alloc(example, "gc");
|
||||
let stdout_rc = build_and_run_with_alloc(example, "rc");
|
||||
assert_eq!(stdout_gc.trim(), "6");
|
||||
assert_eq!(stdout_rc.trim(), "6");
|
||||
assert_eq!(
|
||||
stdout_gc, stdout_rc,
|
||||
"alloc=rc must match alloc=gc on pat_extract_partial_drop"
|
||||
);
|
||||
|
||||
// Re-lower under rc to inspect the IR shape directly.
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let json_path = workspace.join("examples").join(example);
|
||||
let ws = ailang_core::load_workspace(&json_path).expect("load workspace");
|
||||
let mut lifted_modules = std::collections::BTreeMap::new();
|
||||
for (mname, m) in &ws.modules {
|
||||
let desugared = ailang_core::desugar::desugar_module(m);
|
||||
let lifted = ailang_check::lift_letrecs(&desugared)
|
||||
.unwrap_or_else(|e| panic!("lift_letrecs in module `{mname}`: {e:?}"));
|
||||
lifted_modules.insert(mname.clone(), lifted);
|
||||
}
|
||||
let lifted_ws = ailang_core::Workspace {
|
||||
entry: ws.entry.clone(),
|
||||
modules: lifted_modules,
|
||||
root_dir: ws.root_dir.clone(),
|
||||
};
|
||||
let ir = ailang_codegen::lower_workspace_with_alloc(
|
||||
&lifted_ws,
|
||||
ailang_codegen::AllocStrategy::Rc,
|
||||
)
|
||||
.expect("lower workspace under rc");
|
||||
|
||||
// The inline partial drop must dispatch through the IntList drop
|
||||
// fn for the wildcarded slot 1. The presence of THIS specific
|
||||
// call — `@drop_pat_extract_partial_drop_IntList(ptr ...)` — in
|
||||
// main's body proves a non-moved pointer slot was dec'd inline.
|
||||
assert!(
|
||||
ir.contains("call void @drop_pat_extract_partial_drop_IntList(ptr "),
|
||||
"expected an inline `@drop_pat_extract_partial_drop_IntList(ptr ...)` for the wildcarded slot 1 of `p`. IR was:\n{ir}"
|
||||
);
|
||||
// Conversely, the uniform Pair drop fn (which would cascade
|
||||
// through BOTH slots — the wrong shape because slot 0 was
|
||||
// moved) must NOT be called at p's let-close. The Pair drop
|
||||
// fn is still EMITTED as a per-type definition (used by any
|
||||
// potential ADT-recursive cascade), but the call site at
|
||||
// let-close must be the inlined sequence.
|
||||
let main_start = ir
|
||||
.find("define i8 @ail_pat_extract_partial_drop_main(")
|
||||
.expect("main definition present");
|
||||
let main_end_offset = ir[main_start..]
|
||||
.find("\n}\n")
|
||||
.expect("main definition ends with `}`");
|
||||
let main_body = &ir[main_start..main_start + main_end_offset];
|
||||
assert!(
|
||||
!main_body.contains("@drop_pat_extract_partial_drop_Pair("),
|
||||
"main's body must not call the uniform `@drop_pat_extract_partial_drop_Pair(...)` — at let-close, the moved-slot-aware inline sequence replaces it. main body was:\n{main_body}"
|
||||
);
|
||||
assert!(
|
||||
main_body.contains("call void @ailang_rc_dec(ptr "),
|
||||
"main's body must call `@ailang_rc_dec(ptr ...)` for the outer Pair box at let-close. main body was:\n{main_body}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"defs":[{"ctors":[{"fields":[],"name":"Nil"},{"fields":[{"k":"con","name":"Int"},{"k":"con","name":"IntList"}],"name":"Cons"}],"doc":"Recursive Int list — boxed.","kind":"type","name":"IntList"},{"ctors":[{"fields":[{"k":"con","name":"IntList"},{"k":"con","name":"IntList"}],"name":"Pair"}],"doc":"Two IntLists side by side. Both fields are pointer-typed; the test exercises move-tracking against EACH slot independently.","kind":"type","name":"Pair"},{"body":{"arms":[{"body":{"lit":{"kind":"int","value":0},"t":"lit"},"pat":{"ctor":"Nil","fields":[],"p":"ctor"}},{"body":{"args":[{"name":"h","t":"var"},{"args":[{"name":"t","t":"var"}],"fn":{"name":"sum_list","t":"var"},"t":"app"}],"fn":{"name":"+","t":"var"},"t":"app"},"pat":{"ctor":"Cons","fields":[{"name":"h","p":"var"},{"name":"t","p":"var"}],"p":"ctor"}}],"scrutinee":{"name":"xs","t":"var"},"t":"match"},"doc":"Consume xs, sum its elements.","kind":"fn","name":"sum_list","params":["xs"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"IntList"}],"ret":{"k":"con","name":"Int"}}},{"body":{"body":{"arms":[{"body":{"args":[{"args":[{"name":"a","t":"var"}],"fn":{"name":"sum_list","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"pat":{"ctor":"Pair","fields":[{"name":"a","p":"var"},{"p":"wild"}],"p":"ctor"}}],"scrutinee":{"name":"p","t":"var"},"t":"match"},"name":"p","t":"let","value":{"args":[{"args":[{"lit":{"kind":"int","value":1},"t":"lit"},{"args":[{"lit":{"kind":"int","value":2},"t":"lit"},{"args":[{"lit":{"kind":"int","value":3},"t":"lit"},{"args":[],"ctor":"Nil","t":"ctor","type":"IntList"}],"ctor":"Cons","t":"ctor","type":"IntList"}],"ctor":"Cons","t":"ctor","type":"IntList"}],"ctor":"Cons","t":"ctor","type":"IntList"},{"args":[{"lit":{"kind":"int","value":4},"t":"lit"},{"args":[{"lit":{"kind":"int","value":5},"t":"lit"},{"args":[{"lit":{"kind":"int","value":6},"t":"lit"},{"args":[],"ctor":"Nil","t":"ctor","type":"IntList"}],"ctor":"Cons","t":"ctor","type":"IntList"}],"ctor":"Cons","t":"ctor","type":"IntList"}],"ctor":"Cons","t":"ctor","type":"IntList"}],"ctor":"Pair","t":"ctor","type":"Pair"}},"doc":"Build Pair([1,2,3], [4,5,6]); pattern-bind first slot only; sum first list. The wildcarded second slot's IntList lives only inside the Pair box; closing the Pair's let scope must dec it (non-moved) while skipping the moved first slot.","kind":"fn","name":"main","params":[],"type":{"effects":["IO"],"k":"fn","params":[],"ret":{"k":"con","name":"Unit"}}}],"imports":[],"name":"pat_extract_partial_drop","schema":"ailang/v0"}
|
||||
@@ -0,0 +1,65 @@
|
||||
; Iter 18d.3 — move-aware pattern bindings: let-close emits an
|
||||
; inlined per-field dec sequence that skips moved slots and dec's
|
||||
; non-moved (wildcarded) pointer-typed slots.
|
||||
;
|
||||
; Fixture shape:
|
||||
; - Pair carries two `IntList` fields (both pointer-typed).
|
||||
; - main builds a Pair, pattern-matches it binding only the FIRST
|
||||
; field, wildcarding the SECOND.
|
||||
; - The matched binding `a` is consumed by `sum_list`. The
|
||||
; wildcarded slot is logically retained by `p` (no binder owns
|
||||
; it), so at p's let-close it must be dec'd.
|
||||
;
|
||||
; Expected IR shape under --alloc=rc at p's let-close:
|
||||
; - NO `call void @drop_pat_extract_partial_drop_Pair(ptr <p>)`
|
||||
; (the uniform drop fn is replaced by inline partial drop)
|
||||
; - YES a `getelementptr ... ptr <p>, i64 16` + `load ptr` +
|
||||
; `call void @drop_pat_extract_partial_drop_IntList(ptr <slot1_v>)`
|
||||
; (slot 1 was NOT moved → emit per-field dec)
|
||||
; - YES `call void @ailang_rc_dec(ptr <p>)` (outer cell)
|
||||
; - NO load+dec for slot 0 (moved → skipped)
|
||||
;
|
||||
; Expected stdout: 6 (1 + 2 + 3 = 6, sum of the first list).
|
||||
|
||||
(module pat_extract_partial_drop
|
||||
|
||||
(data IntList
|
||||
(doc "Recursive Int list — boxed.")
|
||||
(ctor Nil)
|
||||
(ctor Cons (con Int) (con IntList)))
|
||||
|
||||
(data Pair
|
||||
(doc "Two IntLists side by side. Both fields are pointer-typed; the test exercises move-tracking against EACH slot independently.")
|
||||
(ctor Pair (con IntList) (con IntList)))
|
||||
|
||||
(fn sum_list
|
||||
(doc "Consume xs, sum its elements.")
|
||||
(type
|
||||
(fn-type
|
||||
(params (con IntList))
|
||||
(ret (con Int))))
|
||||
(params xs)
|
||||
(body
|
||||
(match xs
|
||||
(case (pat-ctor Nil) 0)
|
||||
(case (pat-ctor Cons h t)
|
||||
(app + h (app sum_list t))))))
|
||||
|
||||
(fn main
|
||||
(doc "Build Pair([1,2,3], [4,5,6]); pattern-bind first slot only; sum first list. The wildcarded second slot's IntList lives only inside the Pair box; closing the Pair's let scope must dec it (non-moved) while skipping the moved first slot.")
|
||||
(type (fn-type (params) (ret (con Unit)) (effects IO)))
|
||||
(params)
|
||||
(body
|
||||
(let p
|
||||
(term-ctor Pair Pair
|
||||
(term-ctor IntList Cons 1
|
||||
(term-ctor IntList Cons 2
|
||||
(term-ctor IntList Cons 3
|
||||
(term-ctor IntList Nil))))
|
||||
(term-ctor IntList Cons 4
|
||||
(term-ctor IntList Cons 5
|
||||
(term-ctor IntList Cons 6
|
||||
(term-ctor IntList Nil)))))
|
||||
(match p
|
||||
(case (pat-ctor Pair a _)
|
||||
(do io/print_int (app sum_list a))))))))
|
||||
Reference in New Issue
Block a user