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
+73 -5
View File
@@ -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