fix: 18g.1 — pre-tail-call shallow-dec for moved-from scrutinee outer cell
18d.4 Iter A (arm-close pattern-binder dec) and Iter B (Own-param
dec at fn return) both fire AFTER the arm body lowers; for a tail-
call arm body, lower_term emits 'musttail call ... ret' and sets
block_terminated=true, so neither seam fires. The result: the
scrutinee's outer cell (whose ptr fields were all moved into the
tail-call's args via 18d.3 moved_slots) leaks one cell per
recursion step.
Surfaced by 18f.2's tail-latency bench: explicit-mode RC peaked
at the same 511 MB RSS as implicit-mode RC despite full mode
annotations — the per-op IntList chains were never being freed.
Bencher diagnosed the tail-call drop-elision; this iter implements
the fix.
Fix shape: in lower_match, BEFORE lower_term(arm.body), emit
'call void @ailang_rc_dec(ptr <scrutinee_ssa>)' iff:
- alloc=Rc,
- arm.body is structurally Term::App{tail:true} or
Term::Do{tail:true},
- scrutinee_is_owned (existing 18d.4 Iter A gate, hoisted),
- every ptr-typed slot in this ctor's pattern is in
moved_slots[scrutinee] (i.e. all ptr children are moved into
binders that own them downstream — a Wild-bound ptr would
still hold a live ref that the shallow free would strand).
Shallow rc_dec, not field_drop_call: the per-type drop fn would
re-walk ptr fields, dec'ing values now owned by the downstream
frame — exactly the bug 18d.3's moved_slots was set up to prevent.
Hoists scrutinee_is_owned to a single declaration shared by both
the new 18g.1 emission and 18d.4 Iter A.
Red: alloc_rc_explicit_mode_tail_sum_does_not_leak_outer_cells
on examples/rc_tail_sum_explicit_leak — 100 LCons cells leaked
pre-fix, 0 post-fix. Verified end-to-end on
bench_latency_explicit: pre-fix live=11068575,
post-fix live=1048575 (= the persistent tree cache, separate
issue at main-scope-close).
Test infrastructure: build_and_run_with_rc_stats helper +
AILANG_RC_STATS=1 env-var for the runtime atexit summary.
This commit is contained in:
@@ -2733,6 +2733,112 @@ impl<'a> Emitter<'a> {
|
||||
pushed += 1;
|
||||
}
|
||||
}
|
||||
// Iter 18d.4 fix + 18g.1: scrutinee ownership signal,
|
||||
// hoisted here so both the pre-tail-call shallow-dec
|
||||
// (Iter 18g.1, below) and the arm-close pattern-binder
|
||||
// dec (Iter A, further below) consult the same gate.
|
||||
//
|
||||
// If the scrutinee resolves to a fn-param whose mode is
|
||||
// `Borrow` or `Implicit`, the caller still holds a
|
||||
// reference to the heap value the pattern-binders were
|
||||
// loaded from. Dec'ing those binders (Iter A) — or the
|
||||
// scrutinee's outer cell (18g.1) — fragments the
|
||||
// caller's structure. Only `Own` carries the static
|
||||
// "caller handed off ownership" signal that makes the
|
||||
// children-dec safe.
|
||||
//
|
||||
// Non-fn-param scrutinees (let-binders, temp expressions)
|
||||
// are treated as owned: the let-binder owns its RC ref,
|
||||
// and a temp scrutinee was just produced by the local
|
||||
// frame.
|
||||
let scrutinee_is_owned: bool = match &scrutinee_binder {
|
||||
Some(sb) => match self.current_param_modes.get(sb) {
|
||||
Some(mode) => matches!(mode, ParamMode::Own),
|
||||
None => true,
|
||||
},
|
||||
None => true,
|
||||
};
|
||||
// Iter 18g.1: pre-tail-call shallow-dec for moved-from
|
||||
// scrutinee outer cell. Iter A (arm-close pattern-binder
|
||||
// dec, below) cannot fire when `arm.body` is a tail call:
|
||||
// `lower_term` emits `musttail call ... ret` and sets
|
||||
// `block_terminated = true`, so the post-arm-body Iter A
|
||||
// block is skipped. Iter B (Own-param dec at fn return) is
|
||||
// also skipped for the same reason — the fn body's tail
|
||||
// path is the tail call, not a fall-through `ret`.
|
||||
//
|
||||
// Concretely, in `(case (LCons h t) (tail-app sum_acc t (...)))`,
|
||||
// the LCons outer cell (`xs`) has its only ptr field `t`
|
||||
// moved into the tail call's args list (via
|
||||
// `arm_pushed_binders` -> `moved_slots[xs]`). After the
|
||||
// tail call, the outer cell is a husk: its tag and Int
|
||||
// fields are stale, its ptr field points to memory now
|
||||
// owned downstream. Nothing dec's the husk → one outer
|
||||
// cell leak per consumed list element. 18f.2's tail-
|
||||
// latency bench surfaced this: explicit-mode RC RSS peaks
|
||||
// at the same level as implicit-mode RC despite full mode
|
||||
// annotations.
|
||||
//
|
||||
// The fix is a shallow `ailang_rc_dec(scrutinee_ssa)`
|
||||
// emitted *before* `lower_term(arm.body)`, which lands
|
||||
// ahead of the `musttail call`. Shallow is safe iff every
|
||||
// ptr-typed slot in this ctor was bound by a non-wildcard
|
||||
// pattern (so its content is moved into a binder that
|
||||
// owns it downstream); a Wild-bound ptr slot would still
|
||||
// hold a live reference that the shallow free would
|
||||
// strand. The gate enforces both: arm.body must be a
|
||||
// tail call, scrutinee must be statically owned (the
|
||||
// 18d.4-Iter-A param-mode gate), and every ptr field of
|
||||
// the active ctor must be in `moved_slots[scrutinee]`.
|
||||
//
|
||||
// The dec lowers to `ailang_rc_dec` directly (not
|
||||
// `field_drop_call` / `drop_<m>_<T>`) because the per-
|
||||
// type drop fn would re-walk the ptr fields, dec'ing
|
||||
// values that are now owned by downstream frames —
|
||||
// that's exactly the bug 18d.3's `moved_slots` was set
|
||||
// up to prevent.
|
||||
let arm_body_is_tail_call = matches!(
|
||||
&arm.body,
|
||||
Term::App { tail: true, .. } | Term::Do { tail: true, .. }
|
||||
);
|
||||
if matches!(self.alloc, AllocStrategy::Rc)
|
||||
&& arm_body_is_tail_call
|
||||
&& scrutinee_is_owned
|
||||
{
|
||||
if let Some(sb) = &scrutinee_binder {
|
||||
let moved = self.moved_slots.get(sb).cloned().unwrap_or_default();
|
||||
let mut all_ptr_moved = true;
|
||||
let mut any_ptr_field = false;
|
||||
for idx in 0..bindings.len() {
|
||||
let raw_ail = cref.ail_fields.get(idx).cloned().unwrap_or(Type::unit());
|
||||
let qualified_ail = match &owning_module {
|
||||
Some(m) => {
|
||||
let owner_local_types = self.collect_owner_local_types(m);
|
||||
qualify_local_types_codegen(&raw_ail, m, &owner_local_types)
|
||||
}
|
||||
None => raw_ail,
|
||||
};
|
||||
let bind_ail = if arm_subst.is_empty() {
|
||||
qualified_ail
|
||||
} else {
|
||||
apply_subst_to_type(&qualified_ail, &arm_subst)
|
||||
};
|
||||
let fty = llvm_type(&bind_ail).unwrap_or_else(|_| "ptr".into());
|
||||
if fty == "ptr" {
|
||||
any_ptr_field = true;
|
||||
if !moved.contains(&idx) {
|
||||
all_ptr_moved = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if any_ptr_field && all_ptr_moved {
|
||||
self.body.push_str(&format!(
|
||||
" call void @ailang_rc_dec(ptr {s_val})\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (val, vty) = self.lower_term(&arm.body)?;
|
||||
// Iter 18d.4: arm-close pattern-binder dec. Symmetric to
|
||||
// 18c.3/18c.4's `Term::Let`-scope-close drop emission, but
|
||||
@@ -2775,13 +2881,10 @@ impl<'a> Emitter<'a> {
|
||||
// expressions) are treated as owned — the let-binder
|
||||
// owns its RC ref, and a temp scrutinee was just
|
||||
// produced by the local frame.
|
||||
let scrutinee_is_owned: bool = match &scrutinee_binder {
|
||||
Some(sb) => match self.current_param_modes.get(sb) {
|
||||
Some(mode) => matches!(mode, ParamMode::Own),
|
||||
None => true,
|
||||
},
|
||||
None => true,
|
||||
};
|
||||
//
|
||||
// The `scrutinee_is_owned` binding is hoisted above so
|
||||
// the 18g.1 pre-tail-call shallow-dec sees the same
|
||||
// gate; do not re-declare here.
|
||||
if matches!(self.alloc, AllocStrategy::Rc)
|
||||
&& !self.block_terminated
|
||||
&& scrutinee_is_owned
|
||||
|
||||
Reference in New Issue
Block a user