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:
@@ -2011,3 +2011,116 @@ fn alloc_rc_pattern_bind_in_implicit_fn_does_not_dec_borrowed_children() {
|
||||
"alloc=rc must match alloc=gc on rc_pin_recurse_implicit"
|
||||
);
|
||||
}
|
||||
|
||||
/// Iter 18g.0: build the example under `--alloc=rc`, run with
|
||||
/// `AILANG_RC_STATS=1`, and return the parsed `(allocs, frees, live)`
|
||||
/// triple from the runtime's atexit summary on stderr. The summary
|
||||
/// line shape is fixed by `runtime/rc.c`'s `ailang_rc_stats_atexit`:
|
||||
///
|
||||
/// ailang_rc_stats: allocs=N frees=M live=K
|
||||
///
|
||||
/// Tests that need to assert allocation accounting (leaks, prompt-
|
||||
/// drop coverage, refcount-based correctness) consume this triple
|
||||
/// directly. `live == 0` at exit is the leak-free invariant for
|
||||
/// fixtures whose `main` body returns Int/Unit; fixtures that
|
||||
/// intentionally leak (e.g. Implicit-mode coverage) are gated to a
|
||||
/// non-zero expected value naming the leak.
|
||||
fn build_and_run_with_rc_stats(example: &str) -> (String, u64, u64, i64) {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let src = workspace.join("examples").join(example);
|
||||
let tmp = std::env::temp_dir().join(format!(
|
||||
"ailang_e2e_rcstats_{}_{}",
|
||||
example.replace('.', "_"),
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
let out = tmp.join("bin");
|
||||
let status = Command::new(ail_bin())
|
||||
.args([
|
||||
"build",
|
||||
src.to_str().unwrap(),
|
||||
"--alloc=rc",
|
||||
"-o",
|
||||
])
|
||||
.arg(&out)
|
||||
.status()
|
||||
.expect("ail build failed to run");
|
||||
assert!(status.success(), "ail build --alloc=rc failed for {example}");
|
||||
let output = Command::new(&out)
|
||||
.env("AILANG_RC_STATS", "1")
|
||||
.output()
|
||||
.expect("execute binary");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"binary {} (--alloc=rc, AILANG_RC_STATS=1) exited non-zero",
|
||||
out.display()
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
|
||||
let stderr = String::from_utf8(output.stderr).expect("stderr utf8");
|
||||
let line = stderr
|
||||
.lines()
|
||||
.find(|l| l.starts_with("ailang_rc_stats:"))
|
||||
.unwrap_or_else(|| {
|
||||
panic!("missing ailang_rc_stats line in stderr; stderr was:\n{stderr}")
|
||||
});
|
||||
let mut allocs: Option<u64> = None;
|
||||
let mut frees: Option<u64> = None;
|
||||
let mut live: Option<i64> = None;
|
||||
for tok in line.split_whitespace() {
|
||||
if let Some(v) = tok.strip_prefix("allocs=") {
|
||||
allocs = v.parse().ok();
|
||||
} else if let Some(v) = tok.strip_prefix("frees=") {
|
||||
frees = v.parse().ok();
|
||||
} else if let Some(v) = tok.strip_prefix("live=") {
|
||||
live = v.parse().ok();
|
||||
}
|
||||
}
|
||||
(
|
||||
stdout,
|
||||
allocs.expect("missing allocs= field"),
|
||||
frees.expect("missing frees= field"),
|
||||
live.expect("missing live= field"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Iter 18g.1 regression: explicit-mode tail-recursive list-sum must
|
||||
/// not leak the LCons outer cells.
|
||||
///
|
||||
/// Background: 18f.2's tail-latency bench found that
|
||||
/// `bench_latency_explicit.ailx` peaks at the same RSS as the
|
||||
/// implicit-mode variant despite carrying mode annotations
|
||||
/// throughout the hot path. Diagnosis: in
|
||||
///
|
||||
/// (case (LCons h t) (tail-app sum_acc t (...)))
|
||||
///
|
||||
/// the pattern-binder `t` is consumed into the tail-call
|
||||
/// (`consume_count > 0`), so 18d.4 Iter A skips the arm-close drop;
|
||||
/// the outer LCons cell is now a husk (its only ptr field `t` was
|
||||
/// moved out) but no drop site emits the shallow `ailang_rc_dec`
|
||||
/// for it. One outer cell leaks per consumed list element.
|
||||
///
|
||||
/// Pre-fix on this fixture: `live = 100` (one LCons leak per
|
||||
/// element of the 100-cell list).
|
||||
/// Post-fix: `live = 0` (LNil + LCons cells all reach refcount
|
||||
/// zero before exit).
|
||||
///
|
||||
/// Kept as regression coverage. The `(drop-iterative)` annotation
|
||||
/// on IntList means the freeing happens via the worklist allocator,
|
||||
/// not direct recursion — both paths must accumulate frees.
|
||||
#[test]
|
||||
fn alloc_rc_explicit_mode_tail_sum_does_not_leak_outer_cells() {
|
||||
let (stdout, allocs, frees, live) =
|
||||
build_and_run_with_rc_stats("rc_tail_sum_explicit_leak.ail.json");
|
||||
assert_eq!(
|
||||
stdout.trim(),
|
||||
"4950",
|
||||
"sum 0..99 must equal 4950 regardless of leak status"
|
||||
);
|
||||
assert_eq!(
|
||||
live, 0,
|
||||
"explicit-mode tail-sum leaks {live} cells (allocs={allocs} frees={frees}); \
|
||||
18d.4 Iter A skips the arm-close drop because t is consumed into the tail-call, \
|
||||
and no drop site emits the shallow rc_dec for the moved-from outer LCons cell"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"defs":[{"ctors":[{"fields":[],"name":"LNil"},{"fields":[{"k":"con","name":"Int"},{"k":"con","name":"IntList"}],"name":"LCons"}],"drop-iterative":true,"kind":"type","name":"IntList"},{"body":{"cond":{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":0},"t":"lit"}],"fn":{"name":"==","t":"var"},"t":"app"},"else":{"args":[{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"-","t":"var"},"t":"app"},{"args":[{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"-","t":"var"},"t":"app"},{"name":"acc","t":"var"}],"ctor":"LCons","t":"ctor","type":"IntList"}],"fn":{"name":"cons_n_acc","t":"var"},"t":"app","tail":true},"t":"if","then":{"name":"acc","t":"var"}},"kind":"fn","name":"cons_n_acc","params":["n","acc"],"type":{"effects":[],"k":"fn","param_modes":["implicit","own"],"params":[{"k":"con","name":"Int"},{"k":"con","name":"IntList"}],"ret":{"k":"con","name":"IntList"},"ret_mode":"own"}},{"body":{"args":[{"name":"n","t":"var"},{"args":[],"ctor":"LNil","t":"ctor","type":"IntList"}],"fn":{"name":"cons_n_acc","t":"var"},"t":"app"},"kind":"fn","name":"cons_n","params":["n"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Int"}],"ret":{"k":"con","name":"IntList"},"ret_mode":"own"}},{"body":{"arms":[{"body":{"name":"acc","t":"var"},"pat":{"ctor":"LNil","fields":[],"p":"ctor"}},{"body":{"args":[{"name":"t","t":"var"},{"args":[{"name":"acc","t":"var"},{"name":"h","t":"var"}],"fn":{"name":"+","t":"var"},"t":"app"}],"fn":{"name":"sum_acc","t":"var"},"t":"app","tail":true},"pat":{"ctor":"LCons","fields":[{"name":"h","p":"var"},{"name":"t","p":"var"}],"p":"ctor"}}],"scrutinee":{"name":"xs","t":"var"},"t":"match"},"kind":"fn","name":"sum_acc","params":["xs","acc"],"type":{"effects":[],"k":"fn","param_modes":["own","implicit"],"params":[{"k":"con","name":"IntList"},{"k":"con","name":"Int"}],"ret":{"k":"con","name":"Int"}}},{"body":{"args":[{"name":"xs","t":"var"},{"lit":{"kind":"int","value":0},"t":"lit"}],"fn":{"name":"sum_acc","t":"var"},"t":"app"},"kind":"fn","name":"sum_list","params":["xs"],"type":{"effects":[],"k":"fn","param_modes":["own"],"params":[{"k":"con","name":"IntList"}],"ret":{"k":"con","name":"Int"}}},{"body":{"args":[{"args":[{"args":[{"lit":{"kind":"int","value":100},"t":"lit"}],"fn":{"name":"cons_n","t":"var"},"t":"app"}],"fn":{"name":"sum_list","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"kind":"fn","name":"main","params":[],"type":{"effects":["IO"],"k":"fn","params":[],"ret":{"k":"con","name":"Unit"}}}],"imports":[],"name":"rc_tail_sum_explicit_leak","schema":"ailang/v0"}
|
||||
@@ -0,0 +1,69 @@
|
||||
; Iter 18g.1 RED-test fixture: minimal tail-recursive list-sum
|
||||
; under explicit-mode + (drop-iterative). Demonstrates the leak
|
||||
; surfaced by the 18f.2 latency bench: the LCons outer cell, whose
|
||||
; only ptr field `t` is moved into the tail-app of `sum_acc`, has
|
||||
; no drop site and leaks once per element.
|
||||
;
|
||||
; Built and run with `AILANG_RC_STATS=1` under `--alloc=rc`. The
|
||||
; stderr summary should show `allocs == frees + small_const`,
|
||||
; where `small_const` covers the Tree-shaped drop-frames (zero
|
||||
; here — the program returns Int, no live ADTs at exit).
|
||||
;
|
||||
; Pre-fix: `live = N` (one outer LCons cell per consumed element).
|
||||
; Post-fix: `live = 0`.
|
||||
|
||||
(module rc_tail_sum_explicit_leak
|
||||
|
||||
(data IntList
|
||||
(ctor LNil)
|
||||
(ctor LCons (con Int) (con IntList))
|
||||
(drop-iterative))
|
||||
|
||||
(fn cons_n_acc
|
||||
(type
|
||||
(fn-type
|
||||
(params (con Int) (own (con IntList)))
|
||||
(ret (own (con IntList)))))
|
||||
(params n acc)
|
||||
(body
|
||||
(if (app == n 0)
|
||||
acc
|
||||
(tail-app cons_n_acc
|
||||
(app - n 1)
|
||||
(term-ctor IntList LCons (app - n 1) acc)))))
|
||||
|
||||
(fn cons_n
|
||||
(type
|
||||
(fn-type
|
||||
(params (con Int))
|
||||
(ret (own (con IntList)))))
|
||||
(params n)
|
||||
(body
|
||||
(app cons_n_acc n (term-ctor IntList LNil))))
|
||||
|
||||
(fn sum_acc
|
||||
(type
|
||||
(fn-type
|
||||
(params (own (con IntList)) (con Int))
|
||||
(ret (con Int))))
|
||||
(params xs acc)
|
||||
(body
|
||||
(match xs
|
||||
(case (pat-ctor LNil) acc)
|
||||
(case (pat-ctor LCons h t)
|
||||
(tail-app sum_acc t (app + acc h))))))
|
||||
|
||||
(fn sum_list
|
||||
(type
|
||||
(fn-type
|
||||
(params (own (con IntList)))
|
||||
(ret (con Int))))
|
||||
(params xs)
|
||||
(body
|
||||
(app sum_acc xs 0)))
|
||||
|
||||
(fn main
|
||||
(type (fn-type (params) (ret (con Unit)) (effects IO)))
|
||||
(params)
|
||||
(body
|
||||
(do io/print_int (app sum_list (app cons_n 100))))))
|
||||
Reference in New Issue
Block a user