//! Per-fn escape analysis for ADT and closure allocations (Iter 17a). //! //! Identifies `Term::Ctor` and `Term::Lam` allocations whose value //! does not escape the function in which they are allocated. Such //! allocations can be lowered to LLVM `alloca` instead of `@GC_malloc`, //! producing stack-allocated boxes that are auto-freed at fn return — //! semantically equivalent to the all-GC version but bypassing the //! collector entirely for fully-local data. //! //! The analysis is *purely an optimisation*. A pessimistic answer //! (claiming an allocation escapes when it does not) only loses //! efficiency, never correctness. //! //! ## Rule (conservative, name-based taint propagation) //! //! For each allocation site that is the value of a `Term::Let`: //! //! `let X = ALLOC in B` //! //! we ask whether the body `B` makes the value of `X` (or any value //! derived from it via projection) reachable past the fn return. //! //! Taint propagation rule: //! - The bound name `X` is **tainted** in `B`. //! - A `Term::Match` whose scrutinee is a `Var` referring to a tainted //! name propagates taint to every pattern-bound name in every arm. //! (Pattern bindings hold field projections of the scrutinee, which //! live inside the same allocation.) //! - A `Term::Let { name = Y, value = Var(t), ... }` where `t` is //! tainted makes `Y` tainted too in the let's body. //! //! Escape rule (any tainted name appearing in any of these positions //! marks the allocation as escaping): //! - **Tail position** of `B` (the value of `B` is the value of the //! enclosing region; the let-body's value flows up). //! - **`Term::App` arg position** (arg flows into a callee, which may //! keep the value). //! - **`Term::Do` arg position** (effect ops are runtime calls). //! - **`Term::Ctor` field position** (storing into another box; the //! parent box may itself escape). //! - **`Term::Lam` capture** (free var of an enclosed lambda — the //! lambda's env, possibly heap-allocated, holds the value past the //! current fn frame). //! - **`Term::App` callee position** when the callee is **not** the //! simple Var-tainted call (we only special-case calling a //! tainted-Var-bound closure as non-escaping; any other indirect //! callee derived from the tainted value is escape). //! //! Allowed (non-escaping) positions: //! - **Scrutinee of `Term::Match`** (scrutinee is read-then-projected; //! the box itself doesn't escape unless an arm's body escapes a //! pattern binding, handled by the taint propagation above). //! - **Callee of `Term::App` when the callee is a bare `Var` to the //! tainted name** (the closure pair is loaded and called locally). //! //! ## Lambda-specific note //! //! Closures often escape (passed as args to higher-order fns). The //! same rule applies. A `let f = \x. body in B` allocation is //! non-escaping iff `f` appears in `B` exclusively in the callee //! position of `Term::App` (so the closure pair is loaded and //! invoked locally). //! //! The Lam's *body* is itself a fresh fn frame from the analyzer's //! perspective — the Lam body's captures already cross a fn //! boundary, so the analyzer does not recurse into it. (The Lam's //! body would be analyzed in its own top-level pass over the lifted //! thunk, but in this codebase lambdas are not lifted to top-level //! before codegen — they are emitted as deferred thunks during the //! same fn-emission pass. We handle this by running the analysis on //! every fn body and on every Lam body recursively.) //! //! ## What this analysis is NOT //! //! - Not a region inference system. Per-fn only. //! - Not field-sensitive (we taint pattern bindings wholesale). //! - Not flow-sensitive within an arm (we just check if any tainted //! name appears in any escape position). //! //! Precision can be improved later; correctness is the priority for //! this iter. use ailang_core::ast::{Pattern, Term}; use std::collections::BTreeSet; /// Set of allocation sites (identified by raw pointer address cast to /// `usize`) that the escape analysis has proven do not escape their /// allocating fn. /// /// Pointer identity is stable for the duration of codegen because the /// `Module` AST is borrowed immutably throughout. pub type NonEscapeSet = BTreeSet; /// Run the analysis over a fn body. Returns the set of allocation /// sites (pointers to `Term::Ctor` / `Term::Lam` nodes) that may /// be safely lowered with `alloca` instead of `@GC_malloc`. pub fn analyze_fn_body(body: &Term) -> NonEscapeSet { let mut out = NonEscapeSet::new(); walk(body, &mut out); out } /// Walk the term, looking for `Let { name, value: Ctor | Lam, body }` /// shapes. For each such shape, run the escape check on `body` with /// the bound name as the initial tainted seed. If the check returns /// false (no escape), record the value's pointer in `out`. /// /// Then recurse into all sub-terms so that nested let-allocations and /// lambdas are analyzed too. fn walk(t: &Term, out: &mut NonEscapeSet) { match t { Term::Let { name, value, body } => { // Candidate shape: let X = Ctor|Lam in B. let alloc_kind = match value.as_ref() { Term::Ctor { .. } => Some("ctor"), Term::Lam { .. } => Some("lam"), _ => None, }; if alloc_kind.is_some() { let mut tainted = BTreeSet::new(); tainted.insert(name.clone()); // The let's body is in tail position iff the let is — // but we don't know the outer context here. Treat the // body as if it were tail-of-the-fn: safer (more // conservative). If body's tail value contains the // tainted name, it escapes the let, which transitively // escapes the fn. if !escapes(body, &tainted, true) { let ptr = (value.as_ref() as *const Term) as usize; out.insert(ptr); } } // Recurse into value and body for any nested allocations. walk(value, out); walk(body, out); } Term::App { callee, args, .. } => { walk(callee, out); for a in args { walk(a, out); } } Term::Do { args, .. } => { for a in args { walk(a, out); } } Term::Ctor { args, .. } => { for a in args { walk(a, out); } } Term::Match { scrutinee, arms } => { walk(scrutinee, out); for arm in arms { walk(&arm.body, out); } } Term::If { cond, then, else_ } => { walk(cond, out); walk(then, out); walk(else_, out); } Term::Seq { lhs, rhs } => { walk(lhs, out); walk(rhs, out); } Term::Lam { body, .. } => { // Recurse into the lambda body — it is itself a fn frame // for our analysis purposes (each lifted thunk runs the // same analysis when emit_fn is called for it; for // un-lifted closures we still want to flag inner local // allocations). walk(body, out); } Term::LetRec { body, in_term, .. } => { // Iter 16b.x: LetRec is normally eliminated before codegen. // Still walk for completeness. walk(body, out); walk(in_term, out); } Term::Clone { value } => { // Iter 18c.1: clone is identity at IR level — only walk // sub-terms looking for Let-allocation candidates. walk(value, out); } Term::ReuseAs { source, body } => { // Iter 18d.1: identity at IR level (codegen lowers `body`, // ignores `source`). Walk both children for completeness. walk(source, out); walk(body, out); } Term::Lit { .. } | Term::Var { .. } => {} } } /// Returns `true` if any tainted name escapes from the term `t`. /// /// `in_tail` indicates whether `t`'s value flows up to the fn return /// (or to the let-body whose value flows up, transitively). At the /// initial call from `walk`, we set `in_tail = true` for the let's /// body because the body's value is the let's value, which we cannot /// see beyond — better to assume escape. fn escapes(t: &Term, tainted: &BTreeSet, in_tail: bool) -> bool { match t { Term::Lit { .. } => false, Term::Var { name } => { // A bare Var reference whose value flows to tail = escape. in_tail && tainted.contains(name) } Term::App { callee, args, .. } => { // Callee position. Special case: a direct call to a // tainted Var (calling the bound closure) is not escape; // the closure pair is loaded and invoked locally. match callee.as_ref() { Term::Var { name: cname } if tainted.contains(cname) => { // Calling locally is fine; don't flag the callee as escape. } _ => { if escapes(callee, tainted, false) { return true; } } } // Args: any tainted Var as arg = escape. Sub-expressions // recurse with in_tail=false. for a in args { if let Term::Var { name } = a { if tainted.contains(name) { return true; } } if escapes(a, tainted, false) { return true; } } false } Term::Do { args, .. } => { for a in args { if let Term::Var { name } = a { if tainted.contains(name) { return true; } } if escapes(a, tainted, false) { return true; } } false } Term::Ctor { args, .. } => { for a in args { if let Term::Var { name } = a { if tainted.contains(name) { return true; } } if escapes(a, tainted, false) { return true; } } false } Term::Let { name, value, body } => { if escapes(value, tainted, false) { return true; } // If the let's value is a tainted Var, the new binding // inherits taint. (Same for `Var`-only sub-expr; for any // other shape we'd need a stronger analysis to detect // taint flowing through computation. Conservative cut: // only direct Var aliasing propagates.) let mut local = tainted.clone(); if let Term::Var { name: vn } = value.as_ref() { if tainted.contains(vn) { local.insert(name.clone()); } } // Body: tail position propagates from the enclosing let. escapes(body, &local, in_tail) } Term::Match { scrutinee, arms } => { if escapes(scrutinee, tainted, false) { return true; } // If the scrutinee is a tainted Var, propagate taint to // pattern bindings (each arm independently). let scrutinee_tainted = match scrutinee.as_ref() { Term::Var { name } => tainted.contains(name), _ => false, }; for arm in arms { let mut arm_tainted = tainted.clone(); if scrutinee_tainted { let mut binds = Vec::new(); pattern_bound_names(&arm.pat, &mut binds); for b in binds { arm_tainted.insert(b); } } if escapes(&arm.body, &arm_tainted, in_tail) { return true; } } false } Term::If { cond, then, else_ } => { if escapes(cond, tainted, false) { return true; } if escapes(then, tainted, in_tail) { return true; } if escapes(else_, tainted, in_tail) { return true; } false } Term::Seq { lhs, rhs } => { if escapes(lhs, tainted, false) { return true; } escapes(rhs, tainted, in_tail) } Term::Lam { params, body, .. } => { // A lambda captures free vars. If any free var is tainted, // the closure escapes the value via its env. (Even if the // closure pair itself doesn't escape, capturing creates a // store of the tainted value into a heap env, which the // collector keeps. For correctness we just need: tainted // value's lifetime <= current fn frame.) // // The lambda's own body is analyzed independently when its // turn comes (via `walk`); here we only check captures. let mut frees = BTreeSet::new(); let mut bound_in_lam: BTreeSet = params.iter().cloned().collect(); collect_free_vars(body, &mut bound_in_lam, &mut frees); for f in &frees { if tainted.contains(f) { return true; } } false } Term::LetRec { .. } => { // LetRec is normally eliminated by the lift-pass before // codegen. If one slips through, be conservative and // declare escape. true } Term::Clone { value } => { // Iter 18c.1: clone is identity. The wrapper's escape // verdict is exactly that of its inner term, threaded // through with the same `in_tail` context. escapes(value, tainted, in_tail) } Term::ReuseAs { source, body } => { // Iter 18d.1: identity at IR level. The whole expression's // value is `body`, so its escape verdict is the body's // (threaded through `in_tail`). The `source` is evaluated // but its value is discarded; it cannot escape via the // tail position, but a tainted name reachable inside it // could still escape — walk it in non-tail mode. if escapes(source, tainted, false) { return true; } escapes(body, tainted, in_tail) } } } /// Collect pattern-bound names (variables only; wildcards and /// literals bind nothing). fn pattern_bound_names(p: &Pattern, out: &mut Vec) { match p { Pattern::Wild | Pattern::Lit { .. } => {} Pattern::Var { name } => out.push(name.clone()), Pattern::Ctor { fields, .. } => { for f in fields { pattern_bound_names(f, out); } } } } /// Collect free variables of a term relative to a `bound` set. Used /// only to check whether a Lam captures any tainted name. Mirrors /// `Emitter::collect_captures` in lib.rs but without the builtin / /// top-level filter (those don't matter for taint). fn collect_free_vars(t: &Term, bound: &mut BTreeSet, out: &mut BTreeSet) { match t { Term::Lit { .. } => {} Term::Var { name } => { if !bound.contains(name) { out.insert(name.clone()); } } Term::App { callee, args, .. } => { collect_free_vars(callee, bound, out); for a in args { collect_free_vars(a, bound, out); } } Term::Do { args, .. } => { for a in args { collect_free_vars(a, bound, out); } } Term::Ctor { args, .. } => { for a in args { collect_free_vars(a, bound, out); } } Term::Let { name, value, body } => { collect_free_vars(value, bound, out); let inserted = bound.insert(name.clone()); collect_free_vars(body, bound, out); if inserted { bound.remove(name); } } Term::If { cond, then, else_ } => { collect_free_vars(cond, bound, out); collect_free_vars(then, bound, out); collect_free_vars(else_, bound, out); } Term::Match { scrutinee, arms } => { collect_free_vars(scrutinee, bound, out); for arm in arms { let mut binds = Vec::new(); pattern_bound_names(&arm.pat, &mut binds); let mut newly = Vec::new(); for n in &binds { if bound.insert(n.clone()) { newly.push(n.clone()); } } collect_free_vars(&arm.body, bound, out); for n in newly { bound.remove(&n); } } } Term::Lam { params, body, .. } => { let mut newly = Vec::new(); for p in params { if bound.insert(p.clone()) { newly.push(p.clone()); } } collect_free_vars(body, bound, out); for n in newly { bound.remove(&n); } } Term::Seq { lhs, rhs } => { collect_free_vars(lhs, bound, out); collect_free_vars(rhs, bound, out); } Term::LetRec { body, in_term, .. } => { collect_free_vars(body, bound, out); collect_free_vars(in_term, bound, out); } Term::Clone { value } => { // Iter 18c.1: clone is identity for free-var collection. collect_free_vars(value, bound, out); } Term::ReuseAs { source, body } => { // Iter 18d.1: free vars are the union of source and body. collect_free_vars(source, bound, out); collect_free_vars(body, bound, out); } } } #[cfg(test)] mod tests { use super::*; use ailang_core::ast::{Arm, Literal, Type}; fn lit_int(v: i64) -> Term { Term::Lit { lit: Literal::Int { value: v }, } } fn var(s: &str) -> Term { Term::Var { name: s.into() } } fn ctor(ty: &str, c: &str, args: Vec) -> Term { Term::Ctor { type_name: ty.into(), ctor: c.into(), args, } } /// Match-only consumption: `let xs = Cons(1, Nil) in match xs of _ -> 0`. /// xs only used as match scrutinee; arm body returns a literal. /// Expected: alloca-eligible. #[test] fn local_ctor_match_only() { let alloc = ctor( "L", "Cons", vec![lit_int(1), ctor("L", "Nil", vec![])], ); let alloc_ptr = (&alloc as *const Term) as usize; let body = Term::Let { name: "xs".into(), value: Box::new(alloc.clone()), body: Box::new(Term::Match { scrutinee: Box::new(var("xs")), arms: vec![Arm { pat: Pattern::Wild, body: lit_int(0), }], }), }; let neset = analyze_fn_body(&body); // The allocation site we care about is the `value` field of // the Let. Since `body` was constructed inline (the Let's // value is a Box copy of `alloc`), use the boxed copy's addr. // Walk the AST to find it. let let_value_ptr = match &body { Term::Let { value, .. } => (value.as_ref() as *const Term) as usize, _ => unreachable!(), }; assert!( neset.contains(&let_value_ptr), "expected let-bound ctor to be non-escaping; neset={:?}", neset ); // Avoid unused warning in case ptr identity changed. let _ = alloc_ptr; } /// Returned value: `let xs = Cons(1, Nil) in xs`. xs in tail /// position. Expected: NOT alloca-eligible. #[test] fn returned_ctor_escapes() { let alloc = ctor( "L", "Cons", vec![lit_int(1), ctor("L", "Nil", vec![])], ); let body = Term::Let { name: "xs".into(), value: Box::new(alloc), body: Box::new(var("xs")), }; let neset = analyze_fn_body(&body); let let_value_ptr = match &body { Term::Let { value, .. } => (value.as_ref() as *const Term) as usize, _ => unreachable!(), }; assert!( !neset.contains(&let_value_ptr), "let-bound ctor that flows to tail must escape" ); } /// Passed to a fn: `let xs = Cons(1, Nil) in length(xs)`. xs in /// arg position. Expected: NOT alloca-eligible. #[test] fn ctor_passed_as_arg_escapes() { let alloc = ctor("L", "Nil", vec![]); let body = Term::Let { name: "xs".into(), value: Box::new(alloc), body: Box::new(Term::App { callee: Box::new(var("length")), args: vec![var("xs")], tail: false, }), }; let neset = analyze_fn_body(&body); let let_value_ptr = match &body { Term::Let { value, .. } => (value.as_ref() as *const Term) as usize, _ => unreachable!(), }; assert!( !neset.contains(&let_value_ptr), "let-bound ctor passed as arg must escape" ); } /// Stored into another ctor: `let h = Mk(1) in Cons(h, Nil)`. /// h is a field of the outer Cons. Expected: NOT alloca-eligible. #[test] fn ctor_stored_in_ctor_field_escapes() { let h_alloc = ctor("X", "Mk", vec![lit_int(1)]); let body = Term::Let { name: "h".into(), value: Box::new(h_alloc), body: Box::new(ctor( "L", "Cons", vec![var("h"), ctor("L", "Nil", vec![])], )), }; let neset = analyze_fn_body(&body); let let_value_ptr = match &body { Term::Let { value, .. } => (value.as_ref() as *const Term) as usize, _ => unreachable!(), }; assert!( !neset.contains(&let_value_ptr), "let-bound ctor stored as ctor field must escape" ); } /// Match arm returns a pattern-bound name: `let xs = Cons(1, Nil) /// in match xs of Cons h _ -> h | Nil -> 0`. h is tainted via the /// scrutinee, returned in arm tail. Expected: NOT alloca-eligible. #[test] fn pattern_binding_returned_escapes() { let alloc = ctor( "L", "Cons", vec![lit_int(1), ctor("L", "Nil", vec![])], ); let body = Term::Let { name: "xs".into(), value: Box::new(alloc), body: Box::new(Term::Match { scrutinee: Box::new(var("xs")), arms: vec![ Arm { pat: Pattern::Ctor { ctor: "Cons".into(), fields: vec![ Pattern::Var { name: "h".into() }, Pattern::Wild, ], }, body: var("h"), }, Arm { pat: Pattern::Ctor { ctor: "Nil".into(), fields: vec![], }, body: lit_int(0), }, ], }), }; let neset = analyze_fn_body(&body); let let_value_ptr = match &body { Term::Let { value, .. } => (value.as_ref() as *const Term) as usize, _ => unreachable!(), }; assert!( !neset.contains(&let_value_ptr), "scrutinee whose pattern binding flows to arm tail must escape" ); } /// Lambda call-only: `let f = \x. x in f(42)`. f only used as /// callee. Expected: alloca-eligible. #[test] fn local_lam_call_only() { let lam = Term::Lam { params: vec!["x".into()], param_tys: vec![Type::int()], ret_ty: Box::new(Type::int()), effects: vec![], body: Box::new(var("x")), }; let body = Term::Let { name: "f".into(), value: Box::new(lam), body: Box::new(Term::App { callee: Box::new(var("f")), args: vec![lit_int(42)], tail: false, }), }; let neset = analyze_fn_body(&body); let let_value_ptr = match &body { Term::Let { value, .. } => (value.as_ref() as *const Term) as usize, _ => unreachable!(), }; assert!( neset.contains(&let_value_ptr), "let-bound lambda used only as callee must be non-escaping; got {:?}", neset ); } /// Lambda passed to HOF: `let f = \x. x in map(f, xs)`. f passed /// as arg. Expected: NOT alloca-eligible. #[test] fn lam_passed_as_arg_escapes() { let lam = Term::Lam { params: vec!["x".into()], param_tys: vec![Type::int()], ret_ty: Box::new(Type::int()), effects: vec![], body: Box::new(var("x")), }; let body = Term::Let { name: "f".into(), value: Box::new(lam), body: Box::new(Term::App { callee: Box::new(var("map")), args: vec![var("f"), var("xs")], tail: false, }), }; let neset = analyze_fn_body(&body); let let_value_ptr = match &body { Term::Let { value, .. } => (value.as_ref() as *const Term) as usize, _ => unreachable!(), }; assert!( !neset.contains(&let_value_ptr), "let-bound lambda passed as arg must escape" ); } }