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:
+73
-5
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user