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:
2026-05-07 23:31:35 +02:00
parent 937782e36d
commit aee6e9d3bf
7 changed files with 1236 additions and 14 deletions
+81 -9
View File
@@ -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"