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
+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.