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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user