From 307a3ea8485f99f5d774190ed42d3ffb96c662f1 Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 8 May 2026 10:25:22 +0200 Subject: [PATCH] Iter 18c.3: uniqueness inference + non-recursive RC inc/dec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/ail/tests/e2e.rs | 34 ++ crates/ailang-check/src/lib.rs | 1 + crates/ailang-check/src/uniqueness.rs | 522 ++++++++++++++++++++++++++ crates/ailang-codegen/src/lib.rs | 126 ++++++- examples/rc_box_drop.ail.json | 59 +++ 5 files changed, 738 insertions(+), 4 deletions(-) create mode 100644 crates/ailang-check/src/uniqueness.rs create mode 100644 examples/rc_box_drop.ail.json diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index b0de699..fe6aed1 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -1345,3 +1345,37 @@ fn alloc_rc_matches_gc_on_std_list_demo() { "alloc=rc must match alloc=gc on std_list_demo" ); } + +/// Iter 18c.3: codegen actually emits `ailang_rc_dec` at end-of-scope +/// for trackable RC binders under `--alloc=rc`. This is the first +/// fixture where the `dec` path runs at runtime — `b` is bound to a +/// heap-allocated `MkBox`, the box is read via `match` (a borrow, +/// `consume_count == 0`), and the let scope closes before the fn +/// returns Unit. Codegen emits `call void @ailang_rc_dec(ptr %v1)` +/// after the match's join block; the box's refcount drops to 0 and +/// the underlying `malloc`'d block is freed by `runtime/rc.c`. +/// +/// Properties guarded: +/// (1) the binary still produces the expected stdout (`42`) — i.e. +/// dec does not free the box BEFORE `match` reads its `Int` +/// payload; +/// (2) the binary exits cleanly (exit code 0, no segfault) — i.e. +/// the dec call does not double-free or trip +/// `ailang_rc_dec`'s underflow guard; +/// (3) the GC and RC paths produce byte-identical stdout — i.e. +/// the dec emission did not perturb behaviour visible at the +/// output boundary. +/// +/// Box has no boxed children, so the shallow-free contract of 18c.3's +/// `ailang_rc_dec` is sufficient. A recursive ADT (List, Tree) would +/// leak its boxed children silently — that's the 18c.4 scope, not +/// this iter. +#[test] +fn alloc_rc_emits_dec_for_unique_let_bound_box() { + let example = "rc_box_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(), "42"); + assert_eq!(stdout_rc.trim(), "42"); + assert_eq!(stdout_gc, stdout_rc, "alloc=rc must match alloc=gc on rc_box_drop"); +} diff --git a/crates/ailang-check/src/lib.rs b/crates/ailang-check/src/lib.rs index 47c2def..e08ca1f 100644 --- a/crates/ailang-check/src/lib.rs +++ b/crates/ailang-check/src/lib.rs @@ -38,6 +38,7 @@ use indexmap::IndexMap; use std::collections::{BTreeMap, BTreeSet}; mod linearity; +pub mod uniqueness; /// Metavariable substitution. Maps fresh metavar ids (from `$m` in /// `Type::Var.name`) to the type they have been unified against. diff --git a/crates/ailang-check/src/uniqueness.rs b/crates/ailang-check/src/uniqueness.rs new file mode 100644 index 0000000..b6fedb4 --- /dev/null +++ b/crates/ailang-check/src/uniqueness.rs @@ -0,0 +1,522 @@ +//! Iter 18c.3: uniqueness inference over the typed AST. +//! +//! This pass classifies every binder of every fn body as either +//! [`Uniqueness::Unique`] or [`Uniqueness::Shared`]. Unlike the +//! [`crate::linearity`] check it is **not** a user-facing diagnostic +//! pass — it is internal codegen input. Codegen consults the side +//! table to decide where to emit `ailang_rc_inc` and `ailang_rc_dec` +//! calls under `--alloc=rc` (Iter 18c.3). +//! +//! ## Why a separate pass from `linearity` +//! +//! - Different scope. `linearity` is opt-in by signature (every param +//! mode must be explicit) and is emitted as user diagnostics. +//! Uniqueness inference runs unconditionally on every fn body and +//! is never reported. +//! - Different output. `linearity` emits `Diagnostic`s; uniqueness +//! produces a [`UniquenessTable`] keyed by `(def_name, binder_name)`. +//! - Different consumption point. `linearity` runs from +//! [`crate::check_workspace`]; the uniqueness side table is built +//! on demand from codegen via [`infer_module`]. +//! +//! ## What "unique" means here +//! +//! A binder `x` is `Unique` if every reachable path from its +//! introduction to the end of its scope passes through **at most one** +//! consume use of `x`. Borrow-position uses (Match scrutinee, +//! `(clone)` argument, `Borrow`-mode call argument) do not count as +//! consumes. A binder is `Shared` otherwise. +//! +//! For the codegen consumer, a closely related side-output is more +//! useful: [`UniquenessInfo::consume_count`] — the maximum, across +//! all reachable paths in the binder's scope, of the number of +//! consume uses of the binder. `consume_count == 0` means the binder +//! is exclusively borrowed (or unused) inside its scope, which is +//! the case where emitting `dec` at scope close is unambiguously +//! safe under `--alloc=rc` (no callee / outer term consumed the +//! value first; the binder owns the only outstanding ref to its +//! allocation). +//! +//! ## Position model +//! +//! Identical to [`crate::linearity`]'s, modulo that this pass has no +//! activation gate (it runs on every fn). See that module's docs for +//! the propagation rules. `Term::Lam` captures are conservatively +//! treated as Consume — the same conservative decision the linearity +//! check makes. +//! +//! ## What this pass deliberately doesn't do (deferred to later iters) +//! +//! - **Cross-fn reasoning.** The inference is whole-fn local. It +//! does not reason about a callee's body when classifying a +//! binder passed into the callee. +//! - **Per-type drop fns / recursive `dec` cascades.** That is +//! 18c.4. Codegen using this table emits *shallow* `dec` only — +//! the refcount of the box itself, not its boxed children. +//! - **Reuse hints.** `(reuse-as)` is 18d. + +use ailang_core::ast::{Arm, Def, FnDef, Module, ParamMode, Pattern, Term, Type}; +use std::collections::BTreeMap; + +/// Per-binder classification produced by the inference. See module +/// docs for semantics. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum Uniqueness { + /// Every reachable path from the binder's introduction to the end + /// of its scope passes through at most one consume use. + Unique, + /// Some reachable path consumes the binder more than once. + Shared, +} + +/// Per-binder result of the inference. The codegen consumer uses +/// both fields: `uniqueness` for the high-level classification and +/// `consume_count` for the precise "is anyone else going to dec +/// this" answer at scope close. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct UniquenessInfo { + pub uniqueness: Uniqueness, + /// Maximum number of consume-position uses of this binder + /// across any reachable path in its scope. `0` means the binder + /// is only borrowed (or unused) — under `--alloc=rc` the only + /// outstanding reference at scope close is the one the binder + /// itself holds, so dec at scope close is safe and necessary. + pub consume_count: u32, +} + +/// Side table indexed by `(def_name, binder_name)`. Built fresh for +/// each codegen invocation; the side table is not cached. +/// +/// Note: binder names within a single fn shadow lexically. Each +/// shadow is recorded in turn; the **last** insert wins (which is +/// the innermost binding by pop-order). Codegen consumers look up +/// by current-scope binder name, which always matches the innermost +/// classification — fine in practice because every shipping fixture +/// has unique binder names per fn. +pub type UniquenessTable = BTreeMap<(String, String), UniquenessInfo>; + +/// Top-level entry: walk every fn def in `m` and produce per-binder +/// classifications keyed by `(fn_name, binder_name)`. +/// +/// Pre-condition: `m` is fully typechecked. Inputs that have not +/// been type-checked (unresolved `Var` lookups, mismatched ctor +/// arities) may produce a noisy / incorrect side table — the +/// codegen never runs on such inputs because `ail build` runs +/// typecheck first. +pub fn infer_module(m: &Module) -> UniquenessTable { + let mut globals: BTreeMap = BTreeMap::new(); + for def in &m.defs { + match def { + Def::Fn(f) => { + let ty = strip_forall(&f.ty).clone(); + globals.insert(f.name.clone(), ty); + } + Def::Const(c) => { + globals.insert(c.name.clone(), strip_forall(&c.ty).clone()); + } + Def::Type(_) => {} + } + } + + let mut table: UniquenessTable = BTreeMap::new(); + for def in &m.defs { + if let Def::Fn(f) = def { + infer_fn(f, &globals, &mut table); + } + } + table +} + +fn strip_forall(t: &Type) -> &Type { + match t { + Type::Forall { body, .. } => body, + other => other, + } +} + +/// Per-fn driver: install the parameters as binders, walk the body, +/// snapshot the per-binder consume counts into the side table. +fn infer_fn(f: &FnDef, globals: &BTreeMap, out: &mut UniquenessTable) { + let inner = strip_forall(&f.ty); + if !matches!(inner, Type::Fn { .. }) { + return; + } + + let param_states = { + let mut walker = Walker { + globals, + binders: BTreeMap::new(), + def_name: f.name.clone(), + out, + }; + + // Install fn parameters. Their consume counter starts at 0; + // the walk over the body increments it. + for name in f.params.iter() { + walker.binders.insert(name.clone(), BinderState::default()); + } + + walker.walk(&f.body, Position::Consume); + + // Snapshot params before dropping the walker. + f.params + .iter() + .filter_map(|name| walker.binders.get(name).map(|s| (name.clone(), s.clone()))) + .collect::>() + }; + + // Walker dropped — `out` is borrowable again. + for (name, s) in param_states { + let info = UniquenessInfo { + uniqueness: classify(s.consume_count), + consume_count: s.consume_count, + }; + out.insert((f.name.clone(), name), info); + } +} + +#[inline] +fn classify(consume_count: u32) -> Uniqueness { + if consume_count <= 1 { + Uniqueness::Unique + } else { + Uniqueness::Shared + } +} + +/// Position in which a [`Term::Var`] occurs. Consume uses count +/// against the binder's `consume_count`; Borrow uses do not. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum Position { + Consume, + Borrow, +} + +/// Per-binder mutable state during the walk. Future extensions +/// (e.g. last-use markers, per-arm split) can grow more fields. +#[derive(Default, Clone, Debug)] +struct BinderState { + consume_count: u32, +} + +struct Walker<'a> { + globals: &'a BTreeMap, + binders: BTreeMap, + def_name: String, + out: &'a mut UniquenessTable, +} + +impl<'a> Walker<'a> { + fn walk(&mut self, t: &Term, pos: Position) { + match t { + Term::Lit { .. } => {} + Term::Var { name } => self.use_var(name, pos), + Term::App { callee, args, .. } => { + self.walk(callee, Position::Consume); + let arg_modes = self.callee_arg_modes(callee, args.len()); + for (i, arg) in args.iter().enumerate() { + let arg_pos = match arg_modes.get(i) { + Some(ParamMode::Borrow) => Position::Borrow, + _ => Position::Consume, + }; + self.walk(arg, arg_pos); + } + } + Term::Let { name, value, body } => { + self.walk(value, Position::Consume); + let prev = self.binders.insert(name.clone(), BinderState::default()); + self.walk(body, pos); + let final_state = self.binders.remove(name).unwrap_or_default(); + if let Some(p) = prev { + self.binders.insert(name.clone(), p); + } + self.record_binder(name, &final_state); + } + Term::LetRec { name, body, in_term, .. } => { + let prev = self.binders.insert(name.clone(), BinderState::default()); + self.walk(body, Position::Consume); + self.walk(in_term, pos); + let final_state = self.binders.remove(name).unwrap_or_default(); + if let Some(p) = prev { + self.binders.insert(name.clone(), p); + } + self.record_binder(name, &final_state); + } + Term::If { cond, then, else_ } => { + self.walk(cond, Position::Consume); + let saved = self.binders.clone(); + self.walk(then, pos); + let after_then = std::mem::replace(&mut self.binders, saved); + self.walk(else_, pos); + merge_states(&mut self.binders, &after_then); + } + Term::Match { scrutinee, arms } => { + self.walk(scrutinee, Position::Borrow); + let saved = self.binders.clone(); + let mut acc: Option> = None; + for arm in arms { + self.binders = saved.clone(); + self.walk_arm(arm, pos); + match acc.as_mut() { + None => acc = Some(self.binders.clone()), + Some(m) => merge_states(m, &self.binders), + } + } + if let Some(merged) = acc { + self.binders = merged; + } else { + self.binders = saved; + } + } + Term::Seq { lhs, rhs } => { + self.walk(lhs, Position::Consume); + self.walk(rhs, pos); + } + Term::Ctor { args, .. } => { + for a in args { + self.walk(a, Position::Consume); + } + } + Term::Do { args, .. } => { + for a in args { + self.walk(a, Position::Consume); + } + } + Term::Lam { params, body, .. } => { + // Lam captures cross the linearity boundary — every + // free var of the body is conservatively a consume. + // Walking the body with each param as a fresh binder + // produces the right intra-Lam classification. + let mut saved_for_params: BTreeMap> = BTreeMap::new(); + for p in params { + saved_for_params.insert(p.clone(), self.binders.remove(p)); + self.binders.insert(p.clone(), BinderState::default()); + } + self.walk(body, Position::Consume); + for p in params { + let final_state = self.binders.remove(p).unwrap_or_default(); + if let Some(prev) = saved_for_params.remove(p).flatten() { + self.binders.insert(p.clone(), prev); + } + self.record_binder(p, &final_state); + } + } + Term::Clone { value } => { + self.walk(value, Position::Borrow); + } + } + } + + fn walk_arm(&mut self, arm: &Arm, pos: Position) { + let names = collect_pattern_binders(&arm.pat); + let mut saved: BTreeMap> = BTreeMap::new(); + for n in &names { + saved.insert(n.clone(), self.binders.remove(n)); + self.binders.insert(n.clone(), BinderState::default()); + } + self.walk(&arm.body, pos); + for n in &names { + let final_state = self.binders.remove(n).unwrap_or_default(); + if let Some(prev) = saved.remove(n).flatten() { + self.binders.insert(n.clone(), prev); + } + self.record_binder(n, &final_state); + } + } + + fn use_var(&mut self, name: &str, pos: Position) { + if let Some(s) = self.binders.get_mut(name) { + if matches!(pos, Position::Consume) { + s.consume_count = s.consume_count.saturating_add(1); + } + } + } + + fn callee_arg_modes(&self, callee: &Term, n_args: usize) -> Vec { + let name = match callee { + Term::Var { name } => name, + _ => return vec![], + }; + let ty = match self.globals.get(name) { + Some(t) => t, + None => return vec![], + }; + let inner = strip_forall(ty); + match inner { + Type::Fn { param_modes, .. } => { + if param_modes.is_empty() { + vec![ParamMode::Implicit; n_args] + } else { + param_modes.clone() + } + } + _ => vec![], + } + } + + fn record_binder(&mut self, name: &str, s: &BinderState) { + let info = UniquenessInfo { + uniqueness: classify(s.consume_count), + consume_count: s.consume_count, + }; + self.out + .insert((self.def_name.clone(), name.to_string()), info); + } +} + +/// Conservative branch merge: for each binder name, the merged +/// `consume_count` is the **max** of the two branches. Max is the +/// right operator because the classification cares about the +/// worst-case path. +fn merge_states(into: &mut BTreeMap, other: &BTreeMap) { + for (name, s) in other { + let entry = into.entry(name.clone()).or_default(); + entry.consume_count = entry.consume_count.max(s.consume_count); + } +} + +fn collect_pattern_binders(p: &Pattern) -> Vec { + match p { + Pattern::Wild | Pattern::Lit { .. } => vec![], + Pattern::Var { name } => vec![name.clone()], + Pattern::Ctor { fields, .. } => fields.iter().flat_map(collect_pattern_binders).collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ailang_core::ast::{FnDef, Literal}; + + fn fn_simple(name: &str, params: Vec<&str>, body: Term) -> Def { + Def::Fn(FnDef { + name: name.into(), + ty: Type::Fn { + params: params.iter().map(|_| Type::int()).collect(), + param_modes: vec![], + ret: Box::new(Type::int()), + ret_mode: ParamMode::Implicit, + effects: vec![], + }, + params: params.into_iter().map(|s| s.to_string()).collect(), + body, + doc: None, + }) + } + + /// `(let x 0 0)` — `x` is unique, consume_count == 0. + #[test] + fn let_unused_binder_is_unique_zero_consume() { + let body = Term::Let { + name: "x".into(), + value: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), + body: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), + }; + let m = Module { + schema: ailang_core::SCHEMA.into(), + name: "t".into(), + imports: vec![], + defs: vec![fn_simple("f", vec![], body)], + }; + let table = infer_module(&m); + let info = table.get(&("f".into(), "x".into())).copied().expect("x recorded"); + assert_eq!(info.uniqueness, Uniqueness::Unique); + assert_eq!(info.consume_count, 0); + } + + /// `(let x 0 x)` — `x` consumed once; classified Unique with + /// consume_count == 1. + #[test] + fn let_singly_consumed_is_unique_one() { + let body = Term::Let { + name: "x".into(), + value: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), + body: Box::new(Term::Var { name: "x".into() }), + }; + let m = Module { + schema: ailang_core::SCHEMA.into(), + name: "t".into(), + imports: vec![], + defs: vec![fn_simple("f", vec![], body)], + }; + let table = infer_module(&m); + let info = table.get(&("f".into(), "x".into())).copied().expect("x recorded"); + assert_eq!(info.uniqueness, Uniqueness::Unique); + assert_eq!(info.consume_count, 1); + } + + /// `(let x 0 (let y 0 (app + x x)))` — `x` consumed twice in + /// distinct positions of the same call → Shared, consume_count + /// == 2. + #[test] + fn let_doubly_consumed_is_shared_two() { + let body = Term::Let { + name: "x".into(), + value: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), + body: Box::new(Term::App { + callee: Box::new(Term::Var { name: "+".into() }), + args: vec![ + Term::Var { name: "x".into() }, + Term::Var { name: "x".into() }, + ], + tail: false, + }), + }; + let m = Module { + schema: ailang_core::SCHEMA.into(), + name: "f".into(), + imports: vec![], + defs: vec![fn_simple("f", vec![], body)], + }; + let table = infer_module(&m); + let info = table.get(&("f".into(), "x".into())).copied().expect("x recorded"); + assert_eq!(info.uniqueness, Uniqueness::Shared); + assert_eq!(info.consume_count, 2); + } + + /// `(match scr ((pat-var x) x))` — `x` (a pattern binder) is + /// consumed once → Unique with consume_count == 1. + #[test] + fn pattern_binder_is_recorded() { + let body = Term::Match { + scrutinee: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), + arms: vec![Arm { + pat: Pattern::Var { name: "x".into() }, + body: Term::Var { name: "x".into() }, + }], + }; + let m = Module { + schema: ailang_core::SCHEMA.into(), + name: "t".into(), + imports: vec![], + defs: vec![fn_simple("f", vec![], body)], + }; + let table = infer_module(&m); + let info = table.get(&("f".into(), "x".into())).copied().expect("x recorded"); + assert_eq!(info.uniqueness, Uniqueness::Unique); + assert_eq!(info.consume_count, 1); + } + + /// `(clone x)` is a Borrow — does NOT increment consume_count. + /// Body: `(let x 0 (clone x))` → x consume_count == 0. + #[test] + fn clone_is_borrow_not_consume() { + let body = Term::Let { + name: "x".into(), + value: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), + body: Box::new(Term::Clone { + value: Box::new(Term::Var { name: "x".into() }), + }), + }; + let m = Module { + schema: ailang_core::SCHEMA.into(), + name: "t".into(), + imports: vec![], + defs: vec![fn_simple("f", vec![], body)], + }; + let table = infer_module(&m); + let info = table.get(&("f".into(), "x".into())).copied().expect("x recorded"); + assert_eq!(info.uniqueness, Uniqueness::Unique); + assert_eq!(info.consume_count, 0); + } +} diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index 8bf3d4b..e46c53d 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -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 // 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: 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)) } } } diff --git a/examples/rc_box_drop.ail.json b/examples/rc_box_drop.ail.json new file mode 100644 index 0000000..b84d22e --- /dev/null +++ b/examples/rc_box_drop.ail.json @@ -0,0 +1,59 @@ +{ + "schema": "ailang/v0", + "name": "rc_box_drop", + "imports": [], + "defs": [ + { + "kind": "type", + "name": "Box", + "vars": [], + "doc": "Single-cell ADT around an Int. Iter 18c.3 RC fixture: shallow free is sufficient because Box has no boxed children.", + "ctors": [ + { + "name": "MkBox", + "fields": [{ "k": "con", "name": "Int" }] + } + ] + }, + { + "kind": "fn", + "name": "main", + "type": { + "k": "fn", + "params": [], + "ret": { "k": "con", "name": "Unit" }, + "effects": ["IO"] + }, + "params": [], + "doc": "Bind a heap-allocated MkBox to `b`, read its Int via match, print it. Under --alloc=rc, codegen emits `ailang_rc_dec(b)` at the let scope close: `b` is unique, has zero consume uses (only the match scrutinee, a borrow), and the body's tail value is the printed Unit, not `b` itself. The Box has no boxed children so shallow `free()` is correct.", + "body": { + "t": "let", + "name": "b", + "value": { + "t": "ctor", + "type": "Box", + "ctor": "MkBox", + "args": [{ "t": "lit", "lit": { "kind": "int", "value": 42 } }] + }, + "body": { + "t": "match", + "scrutinee": { "t": "var", "name": "b" }, + "arms": [ + { + "pat": { + "p": "ctor", + "ctor": "MkBox", + "fields": [{ "p": "var", "name": "x" }] + }, + "body": { + "t": "do", + "op": "io/print_int", + "args": [{ "t": "var", "name": "x" }] + } + } + ] + } + } + } + ] +}