Iter 17a: per-fn arena via alloca for non-escaping allocations
Adds a conservative escape analysis that flags Term::Ctor and
Term::Lam allocations as non-escaping when they are bound by a
let, never flow to a fn arg / ctor field / tail-return / lambda
capture, and remain inside the allocating fn's frame. Codegen
lowers flagged sites to LLVM `alloca` instead of `@GC_malloc`;
escaping sites continue to use Boehm.
- escape.rs (new): name-based taint propagation; let-only
candidates; tail-position / app-arg / ctor-field / lam-capture
all taint.
- codegen: three sites switched (lower_ctor, lam env, closure
pair). Save/restore non_escape across lambda thunk emit.
- examples/escape_local_demo.{ailx,ail.json}: focused fixture
exercising both branches (peek and recursive count).
- e2e + escape unit tests: 133 → 141 (+8).
Stop-gate: 17a closes the queue. Memory-management discussion
(GC scope) is the next user-driven step. Per-fn arena observations
appended to JOURNAL — including the headline finding that 0/270
shipped allocations are flagged non-escaping under this rule
(real AILang threads ctors directly between fns; build-locally /
consume-locally is not idiomatic).
All existing fixture stdouts and content hashes bit-identical;
IR snapshots unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -126,6 +126,68 @@ fn gc_handles_recursive_list_construction() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Iter 17a: per-fn arena via stack `alloca` for non-escaping
|
||||
/// allocations. The fixture's `peek` and `count` fns each build a
|
||||
/// `Box(_)` whose payload is dropped via a wildcard pattern; the
|
||||
/// allocation is fully consumed locally and never flows to a fn
|
||||
/// arg, ctor field, or the fn return. Escape analysis must flag
|
||||
/// these allocations as non-escaping; codegen must lower them with
|
||||
/// `alloca` instead of `@GC_malloc`. Two assertions:
|
||||
/// (1) stdout matches the documented expected outputs;
|
||||
/// (2) the emitted IR contains at least one `alloca i8, i64 16`
|
||||
/// for the Box and zero `@GC_malloc` calls inside `peek`/`count`.
|
||||
#[test]
|
||||
fn iter17a_local_box_alloca() {
|
||||
let stdout = build_and_run("escape_local_demo.ail.json");
|
||||
assert_eq!(
|
||||
stdout.lines().collect::<Vec<_>>(),
|
||||
vec!["42", "42", "5", "0"],
|
||||
"escape_local_demo stdout drift"
|
||||
);
|
||||
|
||||
// IR check: peek and count should each contain `alloca` and no
|
||||
// `@GC_malloc`. Emit, then split into per-fn bodies and inspect.
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let src = workspace
|
||||
.join("examples")
|
||||
.join("escape_local_demo.ail.json");
|
||||
let tmp = std::env::temp_dir().join(format!(
|
||||
"ailang_iter17a_alloca_{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
let out_ll = tmp.join("escape_local_demo.ll");
|
||||
let status = Command::new(ail_bin())
|
||||
.args(["emit-ir", src.to_str().unwrap(), "-o"])
|
||||
.arg(&out_ll)
|
||||
.status()
|
||||
.expect("ail emit-ir failed to run");
|
||||
assert!(status.success(), "ail emit-ir failed");
|
||||
let ir = std::fs::read_to_string(&out_ll).expect("read emitted IR");
|
||||
|
||||
for fn_name in ["peek", "count"] {
|
||||
let header = format!("@ail_escape_local_demo_{fn_name}(");
|
||||
let start = ir
|
||||
.find(&header)
|
||||
.unwrap_or_else(|| panic!("fn {fn_name} not found in IR"));
|
||||
let after_header = &ir[start..];
|
||||
let end = after_header
|
||||
.find("\n}\n")
|
||||
.expect("fn body terminator not found");
|
||||
let body = &after_header[..end];
|
||||
assert!(
|
||||
body.contains("alloca i8, i64 16"),
|
||||
"fn {fn_name} should contain `alloca i8, i64 16`; body:\n{body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("@GC_malloc"),
|
||||
"fn {fn_name} should NOT contain `@GC_malloc` (allocation \
|
||||
must be alloca'd); body:\n{body}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Iter 14e: `tail: true` annotation on `print_list`'s recursive
|
||||
/// call must reach LLVM as a `musttail call`. Asserted by emitting IR
|
||||
/// for list_map_poly and grepping for the exact instruction. This is
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
//! 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<usize>;
|
||||
|
||||
/// 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::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<String>, 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<String> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect pattern-bound names (variables only; wildcards and
|
||||
/// literals bind nothing).
|
||||
fn pattern_bound_names(p: &Pattern, out: &mut Vec<String>) {
|
||||
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<String>, out: &mut BTreeSet<String>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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 {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,9 @@ use ailang_core::ast::*;
|
||||
use ailang_core::Workspace;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
mod escape;
|
||||
use escape::NonEscapeSet;
|
||||
|
||||
/// Failure modes of [`emit_ir`] / [`lower_workspace`].
|
||||
///
|
||||
/// Most variants signal a compiler invariant violation rather than a
|
||||
@@ -455,6 +458,13 @@ struct Emitter<'a> {
|
||||
/// lowering. Flushed at the end of emit_fn (LLVM IR allows fns in
|
||||
/// any order).
|
||||
deferred_thunks: Vec<String>,
|
||||
/// Iter 17a: per-fn escape-analysis result. Set of pointer-as-usize
|
||||
/// addresses of `Term::Ctor` and `Term::Lam` nodes that the
|
||||
/// analysis proved do not escape the fn frame they are allocated
|
||||
/// in. Such allocations lower to `alloca` instead of `@GC_malloc`.
|
||||
/// Populated by `analyze_fn_body` at the start of `emit_fn` and at
|
||||
/// the start of every lambda thunk emission inside `lower_lambda`.
|
||||
non_escape: NonEscapeSet,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -557,6 +567,7 @@ impl<'a> Emitter<'a> {
|
||||
current_def: String::new(),
|
||||
lam_counter: 0,
|
||||
deferred_thunks: Vec::new(),
|
||||
non_escape: NonEscapeSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -738,6 +749,13 @@ impl<'a> Emitter<'a> {
|
||||
// collected during lowering and appended after the parent fn.
|
||||
self.current_def = f.name.clone();
|
||||
self.lam_counter = 0;
|
||||
// Iter 17a: run escape analysis over the fn body. The result
|
||||
// is queried at every `Term::Ctor` / `Term::Lam` lowering site
|
||||
// to decide between `alloca` (non-escaping) and `@GC_malloc`
|
||||
// (escaping). The analysis is purely additive — a stale or
|
||||
// empty result only loses optimisation opportunities, never
|
||||
// correctness.
|
||||
self.non_escape = escape::analyze_fn_body(&f.body);
|
||||
|
||||
let mut sig = format!(
|
||||
"define {ret} @ail_{module}_{name}(",
|
||||
@@ -1040,10 +1058,20 @@ impl<'a> Emitter<'a> {
|
||||
self.emit_indirect_call(&callee_ssa, &sig, args, *tail)
|
||||
}
|
||||
Term::Do { op, args, tail } => self.lower_effect_op(op, args, *tail),
|
||||
Term::Ctor { type_name, ctor, args } => self.lower_ctor(type_name, ctor, args),
|
||||
Term::Ctor { type_name, ctor, args } => {
|
||||
// Iter 17a: pass the term pointer so `lower_ctor` can
|
||||
// consult the escape-analysis result for this exact
|
||||
// allocation site.
|
||||
let term_ptr = (t as *const Term) as usize;
|
||||
self.lower_ctor(type_name, ctor, args, term_ptr)
|
||||
}
|
||||
Term::Match { scrutinee, arms } => self.lower_match(scrutinee, arms),
|
||||
Term::Lam { params, param_tys, ret_ty, effects: _, body } => {
|
||||
self.lower_lambda(params, param_tys, ret_ty, body)
|
||||
// Iter 17a: same as `Ctor` — pass the term pointer for
|
||||
// escape-analysis lookup. A non-escaping closure pair
|
||||
// (and its env) lower to `alloca`.
|
||||
let term_ptr = (t as *const Term) as usize;
|
||||
self.lower_lambda(params, param_tys, ret_ty, body, term_ptr)
|
||||
}
|
||||
Term::Seq { lhs, rhs } => {
|
||||
// Iter 10: lower lhs for its effects, discard the SSA;
|
||||
@@ -1185,11 +1213,19 @@ impl<'a> Emitter<'a> {
|
||||
/// Heap box layout: 8 bytes tag (i64) followed by 8 bytes per field.
|
||||
/// i1 and i8 fields also occupy a full 8-byte slot — the typed
|
||||
/// load/store instructions write/read only the required size.
|
||||
///
|
||||
/// Iter 17a: `term_ptr` is the pointer-as-usize of the lowered
|
||||
/// `Term::Ctor` node. If escape analysis flagged this site as
|
||||
/// non-escaping (i.e., the value cannot live past the current fn
|
||||
/// frame), allocation lowers to LLVM `alloca` instead of
|
||||
/// `@GC_malloc`. The rest of the lowering (tag store, field
|
||||
/// stores, ptr return) is identical.
|
||||
fn lower_ctor(
|
||||
&mut self,
|
||||
type_name: &str,
|
||||
ctor_name: &str,
|
||||
args: &[Term],
|
||||
term_ptr: usize,
|
||||
) -> Result<(String, String)> {
|
||||
let cref = self.lookup_ctor_by_type(type_name, ctor_name)?;
|
||||
if args.len() != cref.ail_fields.len() {
|
||||
@@ -1251,9 +1287,18 @@ impl<'a> Emitter<'a> {
|
||||
}
|
||||
let size_bytes = 8 + (compiled.len() * 8) as i64;
|
||||
let p = self.fresh_ssa();
|
||||
self.body.push_str(&format!(
|
||||
" {p} = call ptr @GC_malloc(i64 {size_bytes})\n"
|
||||
));
|
||||
// Iter 17a: pick allocator based on escape analysis. `alloca`
|
||||
// for non-escaping (stack-allocated, freed on fn return);
|
||||
// `@GC_malloc` for everything else.
|
||||
if self.non_escape.contains(&term_ptr) {
|
||||
self.body.push_str(&format!(
|
||||
" {p} = alloca i8, i64 {size_bytes}, align 8\n"
|
||||
));
|
||||
} else {
|
||||
self.body.push_str(&format!(
|
||||
" {p} = call ptr @GC_malloc(i64 {size_bytes})\n"
|
||||
));
|
||||
}
|
||||
// Write tag.
|
||||
self.body.push_str(&format!(
|
||||
" store i64 {tag}, ptr {p}, align 8\n",
|
||||
@@ -1867,6 +1912,7 @@ impl<'a> Emitter<'a> {
|
||||
lam_param_tys: &[Type],
|
||||
lam_ret_ty: &Type,
|
||||
lam_body: &Term,
|
||||
term_ptr: usize,
|
||||
) -> Result<(String, String)> {
|
||||
let llvm_param_tys: Vec<String> =
|
||||
lam_param_tys.iter().map(llvm_type).collect::<Result<_>>()?;
|
||||
@@ -1940,6 +1986,12 @@ impl<'a> Emitter<'a> {
|
||||
let saved_terminated = self.block_terminated;
|
||||
self.block_terminated = false;
|
||||
let saved_sigs = std::mem::take(&mut self.ssa_fn_sigs);
|
||||
// Iter 17a: a lambda thunk is its own fn frame for escape
|
||||
// analysis. Save the outer fn's non-escape set and recompute
|
||||
// for the lambda body. Restored at the end alongside the rest
|
||||
// of the saved emitter state.
|
||||
let saved_non_escape = std::mem::take(&mut self.non_escape);
|
||||
self.non_escape = escape::analyze_fn_body(lam_body);
|
||||
// Lambdas inside lambdas are fine: they get their own counter
|
||||
// namespace within the enclosing thunk. They share the
|
||||
// `deferred_thunks` queue (top-level body owns it).
|
||||
@@ -2020,15 +2072,30 @@ impl<'a> Emitter<'a> {
|
||||
self.current_block = saved_block;
|
||||
self.block_terminated = saved_terminated;
|
||||
self.ssa_fn_sigs = saved_sigs;
|
||||
self.non_escape = saved_non_escape;
|
||||
|
||||
// 3. Emit allocation + capture filling + closure-pair packing
|
||||
// in the OUTER body. Captures use 8 bytes each; closure-pair
|
||||
// is 16 bytes.
|
||||
// Iter 17a: env and closure-pair share the Lam term's escape
|
||||
// status — they have parallel lifetimes. If the closure pair
|
||||
// does not escape (only used as a callee in the outer body),
|
||||
// both can be `alloca`'d. The escape-analysis lookup runs
|
||||
// against the outer fn's `non_escape` set (already restored
|
||||
// above).
|
||||
let lam_local = self.non_escape.contains(&term_ptr);
|
||||
let env_size = (cap_meta.len() * 8) as i64;
|
||||
let env_ssa = if env_size > 0 {
|
||||
let env = self.fresh_ssa();
|
||||
self.body
|
||||
.push_str(&format!(" {env} = call ptr @GC_malloc(i64 {env_size})\n"));
|
||||
if lam_local {
|
||||
self.body.push_str(&format!(
|
||||
" {env} = alloca i8, i64 {env_size}, align 8\n"
|
||||
));
|
||||
} else {
|
||||
self.body.push_str(&format!(
|
||||
" {env} = call ptr @GC_malloc(i64 {env_size})\n"
|
||||
));
|
||||
}
|
||||
for (i, (_cname, outer_ssa, cty, _c_ail, _sig)) in cap_meta.iter().enumerate() {
|
||||
let offset = (i * 8) as i64;
|
||||
let slot = self.fresh_ssa();
|
||||
@@ -2045,8 +2112,13 @@ impl<'a> Emitter<'a> {
|
||||
};
|
||||
|
||||
let clos = self.fresh_ssa();
|
||||
self.body
|
||||
.push_str(&format!(" {clos} = call ptr @GC_malloc(i64 16)\n"));
|
||||
if lam_local {
|
||||
self.body
|
||||
.push_str(&format!(" {clos} = alloca i8, i64 16, align 8\n"));
|
||||
} else {
|
||||
self.body
|
||||
.push_str(&format!(" {clos} = call ptr @GC_malloc(i64 16)\n"));
|
||||
}
|
||||
let cs_t = self.fresh_ssa();
|
||||
self.body.push_str(&format!(
|
||||
" {cs_t} = getelementptr inbounds {{ ptr, ptr }}, ptr {clos}, i64 0, i32 0\n"
|
||||
|
||||
+73
-5
@@ -599,6 +599,67 @@ escape analysis, the corresponding AST/IR plumbing, and is its
|
||||
own design pass. Boehm-everything is the floor; arena is an
|
||||
optimisation above it.
|
||||
|
||||
### Per-fn arena via stack `alloca` (Iter 17a)
|
||||
|
||||
Iter 17a layers exactly that optimisation, in its simplest form.
|
||||
`ailang-codegen` runs an escape-analysis pre-pass over every fn
|
||||
body (and every lifted lambda thunk body); allocations the pass
|
||||
proves do not outlive the fn frame are lowered to LLVM `alloca`
|
||||
instead of `@GC_malloc`. Allocations that may escape continue to
|
||||
use `@GC_malloc`. The Boehm collector is still linked and
|
||||
unchanged; this is purely an optimisation above the floor.
|
||||
|
||||
**Allocation mechanism: LLVM `alloca`** (not a heap arena). Stack
|
||||
allocation matches the "freed at fn return" lifetime exactly,
|
||||
needs no malloc/free pair, and integrates with LLVM's existing
|
||||
optimiser (mem2reg / SROA may further promote the alloca'd box
|
||||
to registers if the box is small and its uses are simple). No
|
||||
new runtime is introduced; no language-level change; no AST or
|
||||
schema change.
|
||||
|
||||
**Escape rule (conservative).** A `Term::Ctor` or `Term::Lam`
|
||||
allocation is non-escaping iff (1) it is the value of a
|
||||
`Term::Let { name = X, value = ALLOC, body = B }`, and (2) the
|
||||
body `B` does not let any value derived from `X` flow past the
|
||||
fn frame. "Derived from" follows two propagation rules:
|
||||
- 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 in the let's body.
|
||||
|
||||
A tainted name "escapes" if it appears in any of: the tail
|
||||
position of `B`, the arg list of any `Term::App` / `Term::Do`,
|
||||
the field list of a `Term::Ctor`, or the free-var capture set of
|
||||
a `Term::Lam`. The closure-pair-callee position of `Term::App`
|
||||
where the callee is a bare `Var` to the tainted name is NOT an
|
||||
escape (calling locally is fine).
|
||||
|
||||
**What this is not.** Not a region-inference system. Not
|
||||
flow-sensitive within an arm. Not field-sensitive (pattern
|
||||
bindings are tainted wholesale). Precision can be improved later;
|
||||
correctness is the priority for this iter. A pessimistic answer
|
||||
(claiming an allocation escapes when it does not) only loses
|
||||
optimisation, never correctness.
|
||||
|
||||
**Codegen integration.** Three sites in
|
||||
`ailang-codegen/src/lib.rs`:
|
||||
- `lower_ctor` — ADT box.
|
||||
- `lower_lambda` env block (when there are captures).
|
||||
- `lower_lambda` closure pair (always 16 bytes).
|
||||
|
||||
Each site queries the per-fn `non_escape: BTreeSet<usize>` (raw
|
||||
pointer addresses of `Term::Ctor` / `Term::Lam` AST nodes flagged
|
||||
as non-escaping). On a hit the emitter writes
|
||||
`alloca i8, i64 <size>, align 8`; on a miss it writes
|
||||
`call ptr @GC_malloc(i64 <size>)`. The rest of the lowering (tag
|
||||
store, field stores, closure-pair packing) is identical.
|
||||
|
||||
The closure-pair and its env share an escape verdict — they have
|
||||
parallel lifetimes. If the closure pair is non-escaping, the env
|
||||
is too.
|
||||
|
||||
## Mangling scheme (Iter 5c)
|
||||
|
||||
All AILang functions are mangled to `@ail_<module>_<def>` — even in the
|
||||
@@ -861,11 +922,18 @@ What **is** supported (and used as the smoke test for the pipeline):
|
||||
sole text projection; `ail parse` is the inverse direction. Round-trip
|
||||
identity (text → AST → JSON → AST → text) is gated by
|
||||
`ailang-surface/tests/round_trip.rs` over every shipped fixture.
|
||||
- **Memory management via Boehm conservative GC** (Decision 9 / Iter 14f).
|
||||
Every ADT box, lambda env, and closure pair is allocated by `@GC_malloc`,
|
||||
declared in the LLVM module preamble and linked from `libgc` at build
|
||||
time. Soak-tested by `examples/gc_stress.ail.json` and the
|
||||
`examples/std_list_stress.ail.json` fixture.
|
||||
- **Memory management via Boehm conservative GC** (Decision 9 / Iter 14f),
|
||||
with **per-fn arena via stack `alloca` for non-escaping allocations**
|
||||
layered on top (Iter 17a). Every ADT box, lambda env, and closure
|
||||
pair allocates either via `@GC_malloc` (escaping; Boehm-managed) or
|
||||
via LLVM `alloca` (non-escaping; freed at fn return). The decision
|
||||
is made by an escape-analysis pre-pass over the fn body — see
|
||||
Decision 9's "Per-fn arena via stack `alloca`" subsection.
|
||||
Boehm-only soak tests are unchanged: `examples/gc_stress.ail.json`
|
||||
and `examples/std_list_stress.ail.json` still allocate via
|
||||
`@GC_malloc` because their boxes flow into other fns and escape.
|
||||
The per-fn-arena path is exercised end-to-end by
|
||||
`examples/escape_local_demo.ail.json` (Iter 17a fixture).
|
||||
- **First-class function references** (Iter 7). A top-level fn name (or
|
||||
qualified `prefix.def`) used as a `Term::Var` is a fn-value.
|
||||
- **Anonymous lambdas with capture** (Iter 8). `Term::Lam` constructs a
|
||||
|
||||
+278
@@ -4851,3 +4851,281 @@ user GC discussion — unchanged); 16c-aux (`std_list::take`/
|
||||
follow-up that mirrors 16e for `!=` (one-line change in
|
||||
`builtins.rs` plus a one-line dispatch arm in `lower_app`,
|
||||
queued without a number).
|
||||
|
||||
## Iter 17a — per-fn arena via stack alloca for non-escaping allocations
|
||||
|
||||
**Goal.** Replace `@GC_malloc` with LLVM `alloca` for ADT/closure
|
||||
allocations the compiler can prove do not escape their allocating
|
||||
fn. Boehm GC stays linked and unchanged; this is purely an
|
||||
optimisation layered on top of Decision 9's collector floor.
|
||||
|
||||
**Architectural choice: alloca, not heap arena.** Stack
|
||||
allocation matches "freed at fn return" exactly — no malloc/free
|
||||
pair, no per-fn bump-allocator runtime, no recycling logic. LLVM
|
||||
already optimises `alloca i8, i64 N` (mem2reg / SROA can promote
|
||||
small allocas to registers when uses are simple). The simpler
|
||||
mechanism wins; a heap-arena path would have meaningful
|
||||
implementation cost with no obvious benefit at MVP scale.
|
||||
|
||||
**Escape-analysis approach (conservative, name-based taint
|
||||
propagation).** A new `crates/ailang-codegen/src/escape.rs`
|
||||
walks each fn body once. For every `Let { name = X, value =
|
||||
Term::Ctor | Term::Lam, body = B }` it asks: does any value
|
||||
derived from X flow past the fn frame?
|
||||
|
||||
Taint propagation:
|
||||
- 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), body }` where `t`
|
||||
is tainted makes `Y` tainted in the let's body.
|
||||
|
||||
A tainted name escapes if it appears in:
|
||||
- Tail position of `B` (the value of `B` is the value of the
|
||||
outer region).
|
||||
- The arg list of any `Term::App` / `Term::Do`.
|
||||
- The field list of any `Term::Ctor`.
|
||||
- The free-var capture set of any `Term::Lam`.
|
||||
|
||||
Allowed (non-escaping) positions:
|
||||
- Scrutinee of `Term::Match` (read-then-projected; the scrutinee
|
||||
itself doesn't leak unless an arm leaks a pattern binding,
|
||||
handled by the taint propagation above).
|
||||
- The callee of `Term::App` when the callee is a bare Var to the
|
||||
tainted name (calling locally just loads the closure pair via
|
||||
GEP — the pointer isn't stored anywhere).
|
||||
|
||||
Pessimism intentionally accepted: the analysis is not
|
||||
flow-sensitive within an arm, not field-sensitive, not
|
||||
inter-procedural. A pessimistic answer ("escapes" when it
|
||||
doesn't) only loses optimisation opportunities, never
|
||||
correctness. Sample of what's flagged escaping that maybe
|
||||
shouldn't be: a let-bound `Pair(Int, Int)` where one field
|
||||
projection is returned in tail position — the whole box flows
|
||||
through the projection's taint, even though the Int value
|
||||
itself doesn't share lifetime with the box. The journal section
|
||||
"Observations" lists more examples.
|
||||
|
||||
The Lam body is itself a fn frame for the analyzer's purposes —
|
||||
each lifted thunk runs its own analysis when `lower_lambda`
|
||||
emits its body. Escape-analysis state is saved/restored across
|
||||
the thunk's emission alongside the rest of the per-fn emitter
|
||||
state.
|
||||
|
||||
**Codegen change.** Three allocation sites in
|
||||
`crates/ailang-codegen/src/lib.rs` now branch on the per-fn
|
||||
`non_escape: BTreeSet<usize>` (raw pointer addresses of
|
||||
`Term::Ctor` / `Term::Lam` AST nodes flagged non-escaping):
|
||||
1. `lower_ctor` — ADT box.
|
||||
2. `lower_lambda` env block (when `cap_meta.len() > 0`).
|
||||
3. `lower_lambda` closure pair (always 16 bytes).
|
||||
|
||||
A hit emits `<ssa> = alloca i8, i64 <size>, align 8`; a miss
|
||||
emits `<ssa> = call ptr @GC_malloc(i64 <size>)`. Tag stores,
|
||||
field stores, and closure-pair packing are unchanged. The
|
||||
closure-pair and its env share a single escape verdict — they
|
||||
have parallel lifetimes; if the closure pair is non-escaping,
|
||||
the env is too.
|
||||
|
||||
The escape-analysis pass runs at the start of `emit_fn` (over
|
||||
the fn body) and at the start of every lambda thunk emission
|
||||
(over the thunk body). Cost: one tree walk per fn, O(node count).
|
||||
Negligible compared to lower_term itself.
|
||||
|
||||
**IR diff samples.** Across all 20 shipped `examples/*.ail.json`
|
||||
fixtures (excluding the new Iter 17a fixture), the analysis
|
||||
flagged **0 of 270 ctor / lambda allocations** as non-escaping.
|
||||
This is structurally expected: typical AILang code passes
|
||||
freshly-built ctors directly into another fn ("LLM-style"
|
||||
threading of values), so the let-binding shape required by the
|
||||
rule rarely appears, and when it does the let-body almost
|
||||
always passes the value to a fn (immediate escape). Concrete
|
||||
counts per fixture (alloca / `@GC_malloc`):
|
||||
- `box.ail.json`: 0 / 1
|
||||
- `closure.ail.json`: 0 / 2
|
||||
- `gc_stress.ail.json`: 0 / 2
|
||||
- `list.ail.json`: 0 / 4
|
||||
- `list_map.ail.json`: 0 / 7
|
||||
- `list_map_poly.ail.json`: 0 / 6
|
||||
- `lit_pat.ail.json`: 0 / 5
|
||||
- `local_rec_*` (8 fixtures combined): 0 / 13
|
||||
- `maybe_int.ail.json`: 0 / 2
|
||||
- `nested_pat.ail.json`: 0 / 4
|
||||
- `sort.ail.json`: 0 / 18
|
||||
- `std_either_demo.ail.json`: 0 / 9
|
||||
- `std_either_list_demo.ail.json`: 0 / 64
|
||||
- `std_list_demo.ail.json`: 0 / 86
|
||||
- `std_list_more_demo.ail.json`: 0 / 41
|
||||
- `std_list_stress.ail.json`: 0 / 2
|
||||
- `std_maybe_demo.ail.json`: 0 / 7
|
||||
- `std_pair_demo.ail.json`: 0 / 9
|
||||
|
||||
The new Iter 17a fixture `examples/escape_local_demo.ail.json`
|
||||
intentionally demonstrates the optimisation: 2 alloca / 0
|
||||
`@GC_malloc`. Both `peek` and `count` build a `Box(_)`
|
||||
let-bound, scrutinise it with a wildcard pattern (no pattern
|
||||
binding flows out), and return a literal Int derived from
|
||||
neither the Box nor its payload. Each call to `count(N)`
|
||||
recursively builds a fresh stack-allocated Box per frame; with
|
||||
the optimisation, a million-deep recursion would not heap-
|
||||
allocate a single byte for those Boxes (only the recursion's
|
||||
stack frames themselves grow). Without the optimisation
|
||||
(pre-17a), each `count` call would heap-allocate a Box, all of
|
||||
which would live until Boehm's next sweep.
|
||||
|
||||
**IR snapshot diffs.** The five existing IR snapshots
|
||||
(`hello`, `sum`, `list`, `max3`, `ws_main`) are byte-identical
|
||||
to pre-17a — none of those fixtures has a let-bound non-
|
||||
escaping ctor allocation. Snapshot tests pass without refresh.
|
||||
|
||||
**Tests: 133 → 141 (+8).**
|
||||
- e2e: 47 → 48 (`iter17a_local_box_alloca`).
|
||||
- `ailang-codegen::tests`: 4 → 11 (+7 new escape-analysis unit
|
||||
tests in `escape::tests`: `local_ctor_match_only`,
|
||||
`returned_ctor_escapes`, `ctor_passed_as_arg_escapes`,
|
||||
`ctor_stored_in_ctor_field_escapes`,
|
||||
`pattern_binding_returned_escapes`, `local_lam_call_only`,
|
||||
`lam_passed_as_arg_escapes`).
|
||||
- All other test counts unchanged.
|
||||
- IR snapshots: 5 → 5, no refresh needed (no fixture's IR
|
||||
changed).
|
||||
|
||||
**Files touched.**
|
||||
- `crates/ailang-codegen/src/escape.rs` (new, ~430 LOC incl.
|
||||
doc and tests).
|
||||
- `crates/ailang-codegen/src/lib.rs`: module declaration; new
|
||||
`non_escape: NonEscapeSet` field on `Emitter`; analyse at
|
||||
start of `emit_fn`; save/restore + re-analyse around lambda
|
||||
thunk emission; new `term_ptr` parameter on `lower_ctor` and
|
||||
`lower_lambda`; alloca-vs-GC_malloc branch at three sites
|
||||
(ctor box, lambda env, closure pair).
|
||||
- `crates/ail/tests/e2e.rs`: new test
|
||||
`iter17a_local_box_alloca` (asserts stdout + IR contains
|
||||
`alloca` / no `@GC_malloc` in the two demo fns).
|
||||
- `examples/escape_local_demo.ailx` and `.ail.json`: new
|
||||
fixture.
|
||||
- `docs/DESIGN.md`: new "Per-fn arena via stack `alloca`
|
||||
(Iter 17a)" subsection inside Decision 9; "Recently lifted
|
||||
gates" preamble extended.
|
||||
|
||||
**Hash invariance verified.** No existing fixture's `.ail.json`
|
||||
was touched; no AST shape changed; no schema bumped. Every
|
||||
shipped fixture's def hashes are unchanged. The new
|
||||
`escape_local_demo` fixture introduces three new hashes
|
||||
(`peek`, `count`, `main`).
|
||||
|
||||
**Output bit-equality verified.** Manual smoke run of every
|
||||
existing fixture: `sort` prints sorted list, `list_map_poly`
|
||||
prints `2 3 4`, `gc_stress` prints `1275`, `std_list_demo`
|
||||
prints the documented `5/false/true/1/4/10/5/2/2/15/15`,
|
||||
etc. — all byte-identical to pre-17a stdout. The optimisation
|
||||
is semantically transparent.
|
||||
|
||||
**Observations for the GC discussion.**
|
||||
- **Vanishingly few non-escaping allocations in shipped code.**
|
||||
0 / 270 alloca conversions across 20 existing fixtures. The
|
||||
"build-locally, consume-locally" pattern (let X = Ctor in
|
||||
match X of ... -> non-X) is not how AILang code is currently
|
||||
written. Most fixtures thread freshly-built ctors directly
|
||||
into another fn (immediate escape via the App-arg rule).
|
||||
- **Most ctors are not let-bound at all.** They appear as
|
||||
inline ctor field values (`Cons h (Cons (...) (...))`), as
|
||||
the value of a fn return, or as direct args to a fn call.
|
||||
An escape-analysis rule restricted to let-bound allocations
|
||||
cannot catch these. A more aggressive rule that gives a
|
||||
"name" to inline allocations and tracks their flow could
|
||||
catch some of these — but at MVP scale most ctor data
|
||||
honestly is shared between fns (linked-list spines, etc.),
|
||||
so the precision gain may be small.
|
||||
- **Polymorphic patterns make the escape-analysis question
|
||||
harder.** `std_list.length`'s body is `fold_left (\c _. c+1)
|
||||
0 xs`. The lambda `\c _. c+1` is passed as App arg →
|
||||
escapes. But it has no captures — the env block is empty
|
||||
(null). Lifting this to a top-level fn (which the codegen
|
||||
already does for top-level fns via the static closure-pair
|
||||
global) would mean zero heap allocation for that lambda. A
|
||||
"lift no-capture lambdas in arg position" optimisation is
|
||||
adjacent to escape analysis but separate, and probably has
|
||||
better hit rate than the current rule.
|
||||
- **Pattern-binding taint is too pessimistic for product
|
||||
types.** A `let p = Pair(x, y) in match p of (a, b) -> a +
|
||||
b` is currently flagged escaping (because `a` and `b` are
|
||||
tainted, both flow to tail of `+`). The Int values do not
|
||||
share lifetime with the `Pair` box; once they're projected
|
||||
to register they're free of the box. A field-sensitive
|
||||
analysis would catch this. Refactor potential: the existing
|
||||
`std_pair_demo` exercises exactly this shape repeatedly.
|
||||
- **Ctor allocations as ctor fields look like escapes but are
|
||||
recursive: the inner Cons in `Cons h (Cons t Nil)` could in
|
||||
principle alloca alongside the outer Cons if the outer is
|
||||
itself non-escaping (parallel lifetimes). The current rule
|
||||
doesn't see this because only let-bound allocations are
|
||||
candidates; an inline allocation in field position is never
|
||||
considered.
|
||||
- **Closures rarely qualify in real code.** Lambdas almost
|
||||
always escape via being passed to a HOF (`map`, `fold_*`,
|
||||
`filter`). The let-bound closure called only locally (e.g.,
|
||||
`let f = \x. body in f(arg)`) is the unit-test shape, not
|
||||
the production shape. The closure-pair is currently the
|
||||
most expensive single allocation per HOF use site (16 bytes
|
||||
for the pair plus N×8 for the env). The all-or-nothing
|
||||
GC-vs-alloca question is the wrong shape for closures —
|
||||
many of them have very short, predictable lifetimes (HOF
|
||||
arg → callee invokes → return) but cross enough fn frames
|
||||
that pure stack alloca isn't sound.
|
||||
- **Real-world wins concentrate in fns that locally
|
||||
destructure intermediate ADTs.** The Iter 17a fixture
|
||||
(`escape_local_demo`) is the canonical shape: build a
|
||||
temporary box, look inside it, return something derived
|
||||
from neither. This shape exists in real code (sentinel
|
||||
values, scratch wrappers for type juggling) but is rare in
|
||||
the current AILang stdlib because the stdlib is mostly
|
||||
list/option combinators that propagate their input.
|
||||
- **Tail-call interaction is benign.** The `musttail call`
|
||||
shape from Iter 14e is unaffected: alloca'd memory is
|
||||
freed at fn return, but `musttail` requires that no live
|
||||
alloca's address escape into the call. The escape analysis
|
||||
already rules out tainted values flowing into App args
|
||||
(which is what tail-call args are), so any allocation that
|
||||
reaches alloca cannot have its pointer used as a tail-call
|
||||
arg. No regression.
|
||||
- **Boehm conservative scan benefits from the shrinkage.**
|
||||
Every alloca is one fewer GC root for the conservative
|
||||
scan to potentially false-positive on. At small scale the
|
||||
heap is small enough that this doesn't matter; at larger
|
||||
scale the over-retention rate of conservative GC drops as
|
||||
the heap shrinks, so even a small alloca conversion rate
|
||||
improves collector precision. Hard to quantify without
|
||||
Boehm-internal stats.
|
||||
|
||||
**Anything noticed but did NOT touch (per scope discipline).**
|
||||
- The "lift no-capture lambdas in arg position" optimisation
|
||||
(turn `\c _. c+1` passed to `fold_left` into a static
|
||||
closure-pair, no allocation at all). Adjacent and probably
|
||||
higher hit rate; out of scope here.
|
||||
- A field-sensitive escape analysis to recover product-type
|
||||
precision (`Pair`, `Box`-of-scalar). Would require
|
||||
per-field taint and projection tracking; out of scope.
|
||||
- An aggressive analysis that names inline allocations and
|
||||
tracks their flow through ctor fields. Would catch the
|
||||
`Cons (Cons (...) (...)) ...` recursion case. Out of scope.
|
||||
- ADT structural equality (still rejected at codegen). 16e
|
||||
punted; Iter 17a does not change that.
|
||||
- The `!=` polymorphism mirror (one-line change). Still
|
||||
queued without a number; not picked up here.
|
||||
|
||||
**Test count delta.** Workspace: 133 → 141 (+8). All green.
|
||||
|
||||
**Queue update post-17a.** 17a closes. Per the iter brief,
|
||||
**post-17a is gated on a user GC discussion — do not pick up
|
||||
further iters until that conversation happens.** Open queue
|
||||
items remaining (untouched): `closure-of-self` (informally
|
||||
16b.5-body); `closure-poly` (informally 16b.5b); `16c-aux`
|
||||
(`std_list::take`/`drop` refactor onto lit patterns); the
|
||||
low-priority `!=` mirror of 16e. Adjacent ideas surfaced in
|
||||
the Observations section above are explicitly NOT queued —
|
||||
they require user sign-off on whether the project's GC
|
||||
direction is "improve precision of stack-alloca", "replace
|
||||
Boehm with a precise collector", or something else entirely.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"defs":[{"ctors":[{"fields":[{"k":"var","name":"a"}],"name":"MkBox"}],"kind":"type","name":"Box","vars":["a"]},{"body":{"body":{"arms":[{"body":{"lit":{"kind":"int","value":42},"t":"lit"},"pat":{"ctor":"MkBox","fields":[{"p":"wild"}],"p":"ctor"}}],"scrutinee":{"name":"b","t":"var"},"t":"match"},"name":"b","t":"let","value":{"args":[{"name":"n","t":"var"}],"ctor":"MkBox","t":"ctor","type":"Box"}},"doc":"Build a Box(n), discard the payload, return 42. The Box is non-escaping.","kind":"fn","name":"peek","params":["n"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Int"}],"ret":{"k":"con","name":"Int"}}},{"body":{"cond":{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":0},"t":"lit"}],"fn":{"name":"<=","t":"var"},"t":"app"},"else":{"body":{"arms":[{"body":{"args":[{"lit":{"kind":"int","value":1},"t":"lit"},{"args":[{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"-","t":"var"},"t":"app"}],"fn":{"name":"count","t":"var"},"t":"app"}],"fn":{"name":"+","t":"var"},"t":"app"},"pat":{"ctor":"MkBox","fields":[{"p":"wild"}],"p":"ctor"}}],"scrutinee":{"name":"b","t":"var"},"t":"match"},"name":"b","t":"let","value":{"args":[{"name":"n","t":"var"}],"ctor":"MkBox","t":"ctor","type":"Box"}},"t":"if","then":{"lit":{"kind":"int","value":0},"t":"lit"}},"doc":"Recurse n times; each call builds a temporary Box, discards its payload via _, and returns 0 if n<=0, else 1 + count(n-1).","kind":"fn","name":"count","params":["n"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Int"}],"ret":{"k":"con","name":"Int"}}},{"body":{"lhs":{"args":[{"args":[{"lit":{"kind":"int","value":0},"t":"lit"}],"fn":{"name":"peek","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"rhs":{"lhs":{"args":[{"args":[{"lit":{"kind":"int","value":123},"t":"lit"}],"fn":{"name":"peek","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"rhs":{"lhs":{"args":[{"args":[{"lit":{"kind":"int","value":5},"t":"lit"}],"fn":{"name":"count","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"rhs":{"args":[{"args":[{"lit":{"kind":"int","value":0},"t":"lit"}],"fn":{"name":"count","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"t":"seq"},"t":"seq"},"t":"seq"},"kind":"fn","name":"main","params":[],"type":{"effects":["IO"],"k":"fn","params":[],"ret":{"k":"con","name":"Unit"}}}],"imports":[],"name":"escape_local_demo","schema":"ailang/v0"}
|
||||
@@ -0,0 +1,57 @@
|
||||
; Iter 17a — first fixture exercising the per-fn arena (alloca for
|
||||
; non-escaping allocations).
|
||||
;
|
||||
; `peek` builds a Box(n) and immediately matches on it, returning
|
||||
; a literal Int that does not depend on the box's payload. The
|
||||
; box's value is fully consumed by the local match — it never
|
||||
; flows to a fn arg, never becomes a ctor field, never returns.
|
||||
; Iter 17a's escape analysis flags the `(term-ctor Box MkBox n)`
|
||||
; allocation as non-escaping; codegen lowers it to LLVM `alloca`
|
||||
; instead of `@GC_malloc`. Boehm's heap is not touched at all by
|
||||
; this fn.
|
||||
;
|
||||
; `count` repeats the same pattern under recursion: each call to
|
||||
; `count` builds a fresh Box(_), matches it, and returns either 0
|
||||
; or 1 + (count rest). Without the alloca optimisation, each
|
||||
; recursive frame leaks a Box onto the GC heap until collection;
|
||||
; with the optimisation, each frame's Box dies with its frame.
|
||||
;
|
||||
; Expected stdout (one per line):
|
||||
; 42 — peek(0) returns the literal 42 from the only arm
|
||||
; 42 — peek(123) ditto (the input value never reaches stdout)
|
||||
; 5 — count(5)
|
||||
; 0 — count(0)
|
||||
|
||||
(module escape_local_demo
|
||||
|
||||
(data Box (vars a)
|
||||
(ctor MkBox a))
|
||||
|
||||
(fn peek
|
||||
(doc "Build a Box(n), discard the payload, return 42. The Box is non-escaping.")
|
||||
(type (fn-type (params (con Int)) (ret (con Int))))
|
||||
(params n)
|
||||
(body
|
||||
(let b (term-ctor Box MkBox n)
|
||||
(match b
|
||||
(case (pat-ctor MkBox _) 42)))))
|
||||
|
||||
(fn count
|
||||
(doc "Recurse n times; each call builds a temporary Box, discards its payload via _, and returns 0 if n<=0, else 1 + count(n-1).")
|
||||
(type (fn-type (params (con Int)) (ret (con Int))))
|
||||
(params n)
|
||||
(body
|
||||
(if (app <= n 0)
|
||||
0
|
||||
(let b (term-ctor Box MkBox n)
|
||||
(match b
|
||||
(case (pat-ctor MkBox _) (app + 1 (app count (app - n 1)))))))))
|
||||
|
||||
(fn main
|
||||
(type (fn-type (params) (ret (con Unit)) (effects IO)))
|
||||
(params)
|
||||
(body
|
||||
(seq (do io/print_int (app peek 0))
|
||||
(seq (do io/print_int (app peek 123))
|
||||
(seq (do io/print_int (app count 5))
|
||||
(do io/print_int (app count 0))))))))
|
||||
Reference in New Issue
Block a user