Iter 18c.3: uniqueness inference + non-recursive RC inc/dec

Ends the leak-everything era under --alloc=rc. Codegen now emits
ailang_rc_inc at every Term::Clone site and ailang_rc_dec at
end-of-scope of trackable RC-allocated let-binders. Default
--alloc=boehm path is byte-identical to before this iter.

Two parts:

(A) New module ailang-check::uniqueness — post-typecheck
    dataflow producing UniquenessTable: BTreeMap<(def, binder),
    UniquenessInfo>. consume_count is max-over-paths: at if/match
    the walker saves state, walks each arm against the snapshot,
    and merges by taking the per-binder max. Term::Clone is a
    Borrow on its inner term — the explicit clone is the user's
    signal that a fresh ref is being produced via inc.

(B) Codegen emission in ailang-codegen. Term::Clone emits inc
    under --alloc=rc, gated on val_ty == "ptr" and
    !val_ssa.starts_with('@') (top-level fn closure-pair globals
    live in the LLVM data segment, not in runtime/rc.c's heap).
    Term::Let emits dec at scope close iff the value is
    rc_alloc'd (Term::Ctor / Term::Lam in escape set under
    --alloc=rc), val_ty == "ptr", consume_count == 0 (no callee
    or outer site already took ownership), body's tail SSA is
    not the binder itself (caller-takes-ownership case), and the
    block isn't already terminated.

Header declares of @ailang_rc_inc / @ailang_rc_dec are gated on
--alloc=rc so Boehm/Bump IR shape is unchanged.

Fixture examples/rc_box_drop.ail.json: single-cell Box(Int) ADT
where shallow free is sufficient (no boxed children). E2E test
alloc_rc_emits_dec_for_unique_let_bound_box runs it under both
gc and rc, asserts byte-identical stdout, clean exit, expected
output (42).

Scope cuts deferred to 18c.4: per-type drop fn / recursive dec
cascades for ADTs with boxed children (List, Tree still leak
their tails). Fn params have no dec emission yet (no static
"caller handed off ownership" signal under Implicit mode);
under explicit (own T) the signal exists but wiring is 18d.

Tests: E2E 52 -> 53, ailang-check unit 38 -> 43 (+5 uniqueness),
all other buckets unchanged. cargo test --workspace green.
This commit is contained in:
2026-05-08 10:25:22 +02:00
parent 7099af0a3a
commit 307a3ea848
5 changed files with 738 additions and 4 deletions
+122 -4
View File
@@ -38,6 +38,8 @@ use ailang_core::ast::*;
use ailang_core::Workspace;
use std::collections::{BTreeMap, BTreeSet};
use ailang_check::uniqueness::{infer_module, UniquenessTable};
mod escape;
use escape::NonEscapeSet;
@@ -412,6 +414,14 @@ fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy) -> Result<String>
// pipeline; `Bump` declares `@bump_malloc` instead, supplied by
// `runtime/bump.c` and linked in lieu of `-lgc`.
out.push_str(&format!("declare ptr @{}(i64)\n", alloc.fn_name()));
// Iter 18c.3: under `--alloc=rc`, also declare the inc/dec ABI from
// `runtime/rc.c` so codegen can emit refcount calls at every
// `Term::Clone` site and at end-of-scope of trackable RC binders.
// `Gc` and `Bump` keep their pre-18c IR shape — nothing to declare.
if matches!(alloc, AllocStrategy::Rc) {
out.push_str("declare void @ailang_rc_inc(ptr)\n");
out.push_str("declare void @ailang_rc_dec(ptr)\n");
}
// Iter 16e: `==` on `Str` lowers to `@strcmp` followed by
// `icmp eq i32 0`. NUL-terminated strings make this a one-liner;
// libc supplies `strcmp` so no extra link flag is needed.
@@ -526,6 +536,13 @@ struct Emitter<'a> {
/// Decided at the top-level entry point (`lower_workspace_inner`)
/// and propagated to every site that emits a `call ptr @<alloc>(...)`.
alloc: AllocStrategy,
/// Iter 18c.3: per-binder uniqueness side-table for the current
/// module, keyed by `(def_name, binder_name)`. Built once per
/// emitter and consulted by `Term::Let` lowering to decide whether
/// to emit `call void @ailang_rc_dec(ptr %v)` at scope close. The
/// table is module-scoped because the inference is whole-fn local;
/// no cross-module entries appear.
uniqueness: UniquenessTable,
}
#[derive(Debug, Clone)]
@@ -605,6 +622,12 @@ impl<'a> Emitter<'a> {
}
}
// Iter 18c.3: build the per-module uniqueness side-table once
// per emitter. The inference is pure (no I/O, no global state),
// so doing it here is cheap and keeps codegen's input self-
// contained.
let uniqueness = infer_module(module);
Self {
module,
module_name,
@@ -631,6 +654,7 @@ impl<'a> Emitter<'a> {
deferred_thunks: Vec::new(),
non_escape: NonEscapeSet::new(),
alloc,
uniqueness,
}
}
@@ -930,6 +954,38 @@ impl<'a> Emitter<'a> {
));
}
/// Iter 18c.3: predicate the `Term::Let` lowering uses to decide
/// whether a let-binder owns a fresh RC-heap allocation that
/// codegen should `dec` at scope close.
///
/// Returns `true` exactly when:
/// - the active allocator is `Rc`,
/// - `value` is a `Term::Ctor` or `Term::Lam` (the two AST shapes
/// that lower through the heap-allocation path), AND
/// - the term is *not* in the current fn's `non_escape` set —
/// escaping ctors/lambdas go through `ailang_rc_alloc`,
/// non-escaping ones become stack `alloca`s and must NOT be
/// `dec`'d (they are freed by LLVM at fn return).
///
/// Other value shapes (calls, vars, literals, matches, …) return
/// `false` here. A call's return value may itself be an
/// RC-allocated box, but the caller does not statically know that
/// — proper handling of returned boxes is part of the wider
/// uniqueness story (and tied to `(own)` ret-mode contracts in
/// later iters).
fn is_rc_heap_allocated(&self, value: &Term) -> bool {
if !matches!(self.alloc, AllocStrategy::Rc) {
return false;
}
match value {
Term::Ctor { .. } | Term::Lam { .. } => {
let term_ptr = (value as *const Term) as usize;
!self.non_escape.contains(&term_ptr)
}
_ => false,
}
}
/// Lowers a term to (SSA value string, LLVM type).
fn lower_term(&mut self, t: &Term) -> Result<(String, String)> {
match t {
@@ -1002,11 +1058,56 @@ impl<'a> Emitter<'a> {
Err(CodegenError::UnknownVar(name.clone()))
}
Term::Let { name, value, body } => {
// Iter 18c.3: decide whether this let-binder is
// trackable for `dec` emission BEFORE lowering. The
// value term must lower through `ailang_rc_alloc` —
// that means `Term::Ctor` / `Term::Lam` whose escape
// analysis says "heap" (not `alloca`) under
// `--alloc=rc`. Other value shapes (calls, vars,
// literals) lower to SSAs we don't statically own at
// this scope.
let trackable = self.is_rc_heap_allocated(value);
let val_ail = self.synth_arg_type(value)?;
let (val_ssa, val_ty) = self.lower_term(value)?;
self.locals.push((name.clone(), val_ssa, val_ty, val_ail));
self.locals
.push((name.clone(), val_ssa.clone(), val_ty.clone(), val_ail));
let r = self.lower_term(body);
self.locals.pop();
// Iter 18c.3: emit `dec(name)` at scope close iff:
// - we're tracking this binder (heap RC alloc above),
// - the value type is `ptr` (not a primitive),
// - uniqueness inference recorded `consume_count == 0`
// for the binder (no callee / outer term has already
// taken ownership; the binder owns the only ref),
// - the body's tail value is NOT the binder itself
// (a binder that flows out as the result transfers
// ownership to the caller — caller dec's), and
// - the current block is still open (a tail-call /
// `unreachable` already exited; nothing to emit).
//
// Without per-type drop fns (Iter 18c.4), `dec` is
// shallow — boxed children of the freed cell leak. The
// assignment limits 18c.3 fixtures to single-cell ADTs
// / closures whose lifecycle does not need recursion.
if trackable && val_ty == "ptr" && !self.block_terminated {
let consume_count = self
.uniqueness
.get(&(self.current_def.clone(), name.clone()))
.map(|info| info.consume_count)
.unwrap_or(u32::MAX); // Defensive: skip if missing.
let body_returns_binder = match &r {
Ok((body_ssa, _)) => body_ssa == &val_ssa,
Err(_) => true, // Don't emit on error path either.
};
if consume_count == 0 && !body_returns_binder {
self.body.push_str(&format!(
" call void @ailang_rc_dec(ptr {val_ssa})\n"
));
}
}
r
}
Term::If { cond, then, else_ } => {
@@ -1155,9 +1256,26 @@ impl<'a> Emitter<'a> {
unreachable!("Term::LetRec eliminated by desugar")
}
Term::Clone { value } => {
// Iter 18c.1: clone is identity. Iter 18c.3 will emit
// `call void @ailang_rc_inc` here under --alloc=rc.
self.lower_term(value)
// Iter 18c.3: lower the inner value, then emit
// `call void @ailang_rc_inc(ptr %v)` under `--alloc=rc`.
// Inc is skipped for non-`ptr` values (primitives like
// `i64` carry no refcount) and for `@`-prefixed SSAs
// (top-level fn closure-pair globals live in the LLVM
// data segment, not heap memory — `runtime/rc.c`'s
// header layout doesn't apply to them). Codegen elision
// here matches `runtime/rc.c`'s comment about static
// pointers.
let (val_ssa, val_ty) = self.lower_term(value)?;
if matches!(self.alloc, AllocStrategy::Rc)
&& val_ty == "ptr"
&& !val_ssa.starts_with('@')
&& !self.block_terminated
{
self.body.push_str(&format!(
" call void @ailang_rc_inc(ptr {val_ssa})\n"
));
}
Ok((val_ssa, val_ty))
}
}
}