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
+278
View File
@@ -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.