Files
AILang/design/models/rc-uniqueness.md
T
Brummel 14a91f0ae5 iter boehm-retirement.1 (DONE 10/10): retire the transitional Boehm GC backend
Closes Gitea #4. Removes the Boehm-Demers-Weiser conservative GC
backend wholesale across six layers in one atomic iteration. After
this iter, `AllocStrategy` has two variants (`Rc`, `Bump`),
`--alloc=gc` is rejected at CLI parse with `unknown --alloc value`,
the libgc link arm is gone, and the design ledger describes RC
(canonical) + bump (raw-alloc bench-floor) as the only allocators.

Layer-by-layer summary:

  CLI surface — `crates/ail/src/main.rs`:
    `parse_alloc_strategy` arm `"gc" => Ok(AllocStrategy::Gc)`
    removed; error wording updated to `(expected `rc` or `bump`)`;
    clap-derive `value_parser = ["gc","bump","rc"]` allowlist on
    BOTH `Build` and `Run` subcommands DROPPED so that
    `parse_alloc_strategy` remains the sole gatekeeper for the
    unknown-value diagnostic (otherwise clap shadows the runtime
    diagnostic with `invalid value 'gc' for '--alloc'`, which would
    miss the milestone-pin's stderr substring check). The
    `default_value = "rc"` stays.

  Codegen — `crates/ailang-codegen/src/lib.rs`:
    `AllocStrategy::Gc` variant + `Default` derive removed (no
    caller of `AllocStrategy::default()` existed in the workspace,
    so the trait derivation was dead). `fn_name` (spec called it
    `runtime_alloc_fn` loosely; actual identifier is `fn_name`)
    drops the `Gc => "GC_malloc"` arm. `lower_workspace` and
    `lower_workspace_staticlib` defaults flip from `Gc` to `Rc`.
    In-source negative-complement codegen test (mod tests, lib.rs:3571ff)
    retargets from `AllocStrategy::Gc` to `AllocStrategy::Bump`
    (bump also doesn't emit per-type drop fns; the test's semantic
    "no drop fns under non-RC" is preserved).

  Link branch — `crates/ail/src/main.rs:2389ff`:
    The `match strategy { AllocStrategy::Gc => { ... cmd.arg("-lgc"); ... } }`
    arm and its libgc-link block are entirely gone. The surviving
    match exhausts on `Bump` and `Rc` (Rust's exhaustiveness check
    confirms; no `error[E0004]`). Staticlib-guard diagnostic
    rewritten to drop the "shared Boehm collector" phrasing while
    preserving the prefix `staticlib (swarm) artefact is RC-only`
    verbatim (the surviving `staticlib_bump_is_rejected` test
    depends on that substring).

  Test suite — 3 pure-differential e2e tests deleted
    (`gc_handles_recursive_list_construction`,
    `alloc_rc_produces_same_stdout_as_gc`,
    `alloc_rc_matches_gc_on_std_list_demo`); 9 RC-feature tests
    stripped of their `stdout_gc` build call and differential
    `assert_eq!(stdout_gc, stdout_rc, ...)` (absolute
    `assert_eq!(stdout_rc.trim(), "<n>")` pin retained as
    correctness oracle); `staticlib_gc_is_rejected` deleted; new
    milestone-pin `crates/ail/tests/boehm_retirement_pin.rs`
    asserts `ail build --alloc=gc` exits ≠ 0 with stderr containing
    `unknown --alloc value` and `\`gc\``; `examples/gc_stress.ail`
    fixture deleted (no remaining references).

    Implementer expansion (not in plan): `iter17a_local_box_alloca`
    (in `e2e.rs`) carried an IR-shape assertion against
    `@GC_malloc`-absence as the witness for non-escaping
    allocation. After the Task-2 codegen default flip, the witness
    shifts to `@ailang_rc_alloc`-absence in escape-targeted
    positions; assertion + doc-comment updated. Property
    protected ("no heap allocation in non-escaping contexts") is
    unchanged; only the named allocator shifts.

  Bench harness — `bench/run.sh` 9→6 column compaction
    (workload + bump(s) + rc(s) + rc/bump + bump RSS + rc RSS);
    gc-arm `bench_latency_implicit_gc` build call + harness
    invocation dropped from latency block; header comment reframed
    from "GC-overhead bench harness" to "RC-overhead bench
    harness"; "Decision 10's Boehm-retirement target (1.3x)"
    rewording to "RC-overhead-vs-bump bench-health regression gate".

    `bench/check.py:62` header-sentinel changes from
    `"gc(s)" in line` to `"bump(s)" in line`; column-count check
    at `:72` flips from `!= 9` to `!= 6`; per-workload field set
    drops `gc_s`/`gc_over_bump`/`gc_rss_kb`; `ARM_LABEL_TO_KEY`
    drops the `"implicit @ gc": "implicit_at_gc"` entry.
    `bench/baseline.json` regenerated via `--update-baseline`.

    Implementer note (planner-defect): `write_new_baseline`
    iterated over the *existing* baseline's metric list when
    emitting the regenerated file, so even after parser-level
    `gc_*` removal, the fallback emitted them back into the JSON.
    Scrubbed post-update; the cleaner fix (have
    `write_new_baseline` emit only keys present in
    `parsed_throughput[workload]`) is a follow-up if the script
    becomes load-bearing for further allocator changes.

  Design ledger — `design/models/rc-uniqueness.md` excises the
    `## Dual allocator — RC canonical, Boehm parity oracle`
    section and the `Boehm-Demers-Weiser conservative GC` choice
    block + rationale + trade-offs; the per-fn-alloca section
    generalises Boehm-specific language to allocator-agnostic;
    the memory-model section's `## Choice.` paragraph reframes the
    1.3× target from "Boehm-retirement gate" to "bench-health
    regression gate".

    `design/models/pipeline.md` drops the `--alloc=gc → links libgc`
    arm of the pipeline diagram and replaces it with
    `--alloc=bump → links bump-floor`; the accompanying prose
    rewrites accordingly.

    `design/contracts/scope-boundaries.md` rewrites the
    "Memory management via Boehm conservative GC" bullet to
    describe RC + per-fn-arena present-tense; the dead reference
    to `examples/gc_stress.ail.json` (file never existed; the
    fixture only ever had a `.ail` form, deleted by this iter) is
    dropped along with the `examples/std_list_stress.ail.json`
    reference whose purpose was Boehm-only soak testing.
    `:67`'s `@printf` / `@GC_malloc` parenthetical updated.

    `design/contracts/memory-model.md:232` drops the
    "leaks like the pre-Boehm era" phrase; the RC inc/dec
    instrumentation is wired up, so the "until then" conditional
    that referenced pre-Boehm is closed.

    `design/contracts/embedding-abi.md:42-44` rewrites the
    staticlib-guard prose to drop the `--alloc=gc` clause (gc is
    now a CLI-parser-level unknown-value, not a staticlib-guard
    rejection) and reframe the swarm-safety justification around
    `--alloc=bump` (leak-only bench instrument) rather than the
    historical Boehm collector.

  Honesty pin — `crates/ailang-core/tests/docs_honesty_pin.rs`
    inverts the polarity: the present-tense Boehm-anchor assertion
    on `pipeline.md` (`:116-117`) is deleted, and four
    absence-pins are added to `design_md_has_no_wunschdenken`
    against the Boehm-zombie strings `transitional Boehm`,
    `parity oracle`, `GC_malloc`, `libgc`. The
    `design_corpus()` already includes `rc-uniqueness.md` so no
    path-list change was needed for the new pins to scan.

    `crates/ailang-core/tests/design_index_pin.rs:166` drops the
    `"pre-Boehm"` token from the protected-exception comment list
    (the phrase no longer appears in `memory-model.md` after this
    iter, so the exception is dead).

  Runtime docs — `runtime/bump.c`, `runtime/rc.c`, `runtime/str.c`
    header comments scrubbed of Boehm/`GC_malloc`/`libgc`
    references. `bump.c`'s function signature description still
    documents `void *bump_malloc(size_t)` as the bench-floor
    allocator interface, but no longer cross-references libgc.

  Example fixtures — `examples/bench_latency_implicit.ail`,
    `bench_latency_explicit.ail`, `escape_local_demo.ail`,
    `reuse_as_demo.ail`, `rc_pin_recurse_implicit.ail` doc-comment
    headers scrubbed of `--alloc=gc` / Boehm references. The
    `.ail` surface (AST) is untouched in every case; round-trip
    invariant holds (`cargo test -p ailang-surface --test round_trip`
    green).

  Skill / agent prompts — `skills/audit/agents/ailang-bencher.md`
    rewritten to use an RC-vs-bump worked example pattern for the
    hypothesis-driven bench tutorial, replacing the recurring
    "RC vs Boehm under heap pressure" example.
    `skills/implement/agents/ailang-implementer.md` Decision-10 /
    Boehm references replaced with present-tense RC-commitment
    framing.

  IR snapshots — the 5 checked-in snapshots
    (`crates/ail/tests/snapshots/{hello,list,max3,sum,ws_main}.ll`)
    regenerated via `UPDATE_SNAPSHOTS=1 cargo test -p ail --test
    ir_snapshot`. Each previously contained
    `declare ptr @GC_malloc(i64)` and (for `list.ll`) a `call ptr
    @GC_malloc(...)` invocation; post-flip the snapshots contain
    `declare ptr @ailang_rc_alloc(i64)` plus the rc inc/dec runtime
    declarations.

Spec-vs-acceptance addendum (caught at orchestrator end-report,
absorbed here rather than in a follow-up spec edit): spec §6
acceptance criteria said "Boehm-grep returns matches ONLY in
docs_honesty_pin.rs". The plan itself prescribed historical Boehm
references in 3 additional files: (a) the new milestone-pin
`boehm_retirement_pin.rs` (must literally invoke `--alloc=gc` to
assert its rejection), (b) `embed_staticlib_alloc_guard.rs` file
doc-comment historical note ("`--alloc=gc` no longer exists as a
CLI value"), (c) `embedding-abi.md:44-45` contract historical
clause ("see the Boehm-retirement iter"). All three are
prescribed; the spec's grep wording was too narrow. The four
absence-pins in `docs_honesty_pin.rs` catch the actual zombies
(Boehm-narrative re-emerging in the design ledger), which is the
substantive intent the spec was aiming at — the four extra
documented-by-design exceptions are the cost of having an
explicit milestone-pin and contract-level historical anchors.

Net delta:
  - 32 files modified, 2 new (boehm_retirement_pin.rs + stats),
    1 deleted (gc_stress.ail);
  - workspace tests: every binary `0 failed`. Pass-count delta:
    -3 net (4 e2e tests deleted, 1 new milestone-pin test added);
  - boehm-grep state: hits only in the four by-design exceptions
    documented above;
  - `bench/check.py` exit 0 against regenerated baseline;
  - CLI must-fail fixture: `ail build --alloc=gc examples/hello.ail`
    exits non-zero with stderr containing `unknown --alloc value`
    and `\`gc\``;
  - design ledger present-tense honest (Boehm-narrative gone from
    `rc-uniqueness.md` + `pipeline.md`; the few historical
    references in `embedding-abi.md` / `boehm_retirement_pin.rs` /
    `embed_staticlib_alloc_guard.rs` are explicit milestone-pins
    or contract anchors, not silent ledger residue).

Bench measurement variance noted: closure-chain and hof-pipeline
are ±1-5% jittery between runs; one regeneration flagged 2
metrics as `regressed` before a second run returned 0. The
captured baseline is within self-comparison range. Existing
per-metric tolerances absorb the jitter.

Stats file:
`bench/orchestrator-stats/2026-05-20-iter-boehm-retirement.1.json`.

closes #4
2026-05-20 20:51:53 +02:00

257 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# RC + Uniqueness — memory model whitepaper
## Per-fn arena via stack `alloca`
This optimisation is layered on top of the canonical RC runtime.
`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 the runtime allocator. Allocations that may escape
continue to use the runtime allocator. The runtime is unaffected;
escape analysis 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 @ailang_rc_alloc(i64 <size>)` (or the bump-mode
equivalent). 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.
## Memory model — RC + Uniqueness with LLM-author annotations
**AILang commits to reference counting with static uniqueness
inference as the canonical memory model, extended with mandatory
LLM-author mode annotations (`borrow` / `own`), explicit `clone`,
first-class `reuse-as`, and `drop-iterative` data attrs.**
RC's costs are bounded and analysable per program point; the
canonical position is "RC + inference" sharpened with the five
LLM-author mechanisms below. A corpus committed to one memory
model is expensive to switch — the commitment lives in the
contracts ([memory-model](../contracts/memory-model.md),
[language-constraints](../contracts/language-constraints.md)).
**Choice.** AILang's canonical [memory model](../contracts/memory-model.md)
is reference counting with static uniqueness inference **and
explicit LLM-author annotations on fn signatures**, in the lineage
of Lean 4 / Roc / Koka. The RC pipeline tracks the bump-allocator
raw-alloc floor: a bench-health regression gate requires RC overhead
≤ 1.3× bump on the linear/tree corpus, with a wider ±15% band on
the closure-chain corpus (representational cost of the closure-pair
layout). See `bench/run.sh` for the active check.
**Workload scope of the 1.3× target.** The 1.3× target was
calibrated on the original `bench/run.sh` corpus: linear list
sum (`bench_list_sum`) and tree walk (`bench_tree_walk`) — uniform
single-allocation-per-step workloads where one inc/dec pair
amortises against one allocation. The corpus was later extended
with `bench_closure_chain` (closure-pair allocation: each step
allocates *two* heap objects, the closure cell and its captured
env struct) and `bench_hof_pipeline` (poly-ADT + indirect
dispatch). The closure-chain fixture measures wider than the 1.3×
linear/tree target: each step pays two allocs and two decs against
one bump-pointer bump, doubling the allocation tax on closure
construction (current ratio recorded in
`bench/orchestrator-stats/` and the bench iter commit bodies).
This is a representational cost of the closure-pair layout,
not a defect in the RC implementation; a future closure-pair
slab/pool optimisation for fixed-shape pair cells would compress
this ratio without changing semantics.
The 1.3× bench-health regression gate therefore applies to the
linear / tree / poly-ADT subset of the corpus. Closure-heavy
workloads are tracked under a wider band (the closure-chain
baseline records its rc/bump ratio as the `rc_over_bump`
reference value with ±15% tolerance) and are excluded from the
linear/tree 1.3× regression gate; the closure-chain corpus has
its own ±15% band until a slab/pool optimisation ships. The
[memory model](../contracts/memory-model.md)'s RC commitment is
unchanged; what is scoped is the *quantitative* regression band,
not the choice of memory model.
The architecture has two layers:
1. **Inference.** A post-typecheck pass produces a per-node
uniqueness side table. Codegen uses it to elide inc/dec
wherever provably redundant.
2. **LLM-author annotations.** Fn signatures carry mandatory
`(borrow T)` / `(own T)` mode markers. Authors mark
sharing-vs-consumption explicitly. The compiler verifies
rather than guesses.
The combination plays to what LLMs are good at (writing slightly
more annotation per definition) and avoids what compilers are
bad at (proving sharing absent in the face of recursion +
closures + match).
## The LLM-aware sharpening
A mainstream RC implementation (think Lean 4 in default mode)
infers everything from naked AST plus a few optional hints. The
inference is conservative; whatever it can't prove unique becomes
shared and pays runtime inc/dec. AILang exploits its target
audience to push that conservative ceiling higher.
Five mechanisms.
**(1) Mandatory `(borrow T)` / `(own T)` on fn signatures.**
```
(fn list_length
(type (fn-type (params (borrow (List Int))) (ret (con Int))))
...)
(fn sum_list_consume
(type (fn-type (params (own (List Int))) (ret (con Int))))
...)
```
`(borrow T)` declares the parameter is read-only and lives at
most until the call returns; the caller still owns it; the
callee performs no inc/dec on it. `(own T)` declares ownership
transfer; the callee consumes the value and is responsible for
its end-of-life. The declaration is structural (visible in JSON)
and binding (the typechecker rejects bodies that contradict it).
For a Lean 4 / Roc author this is all *optional* and inferred
when omitted. AILang makes it mandatory because the LLM author
can carry the cognitive cost trivially, and the compiler gains a
precise contract at every call site instead of a probabilistic
guess.
**(2) Linear-by-default consumption with explicit `(clone X)`**
(the `Term::Clone` schema entry lives in
[Data model](../contracts/data-model.md)).
In bodies, every binder is consumed by exactly one `own`-mode
use. If the LLM writes:
```
(let p (expensive_fn x)
(let r1 (consume_a p) ; consume_a takes (own); consumes p
(let r2 (consume_b p) ; ERROR: p already consumed
...)))
```
the compiler emits a structured non-linear-use diagnostic with
concrete `suggested_rewrites`:
- "make consume_a borrow": refactor consume_a's signature, no
body change at the call site;
- "explicit clone": insert `(clone p)` at the first use;
- "fuse traversal": replace the two separate calls with a fused fn.
The LLM picks one. There is no implicit clone — sharing always
costs visible source.
**(3) Reuse hints as first-class.**
```
(fn map_inc
(type (fn-type (params (own (List Int))) (ret (own (List Int)))))
(params xs)
(body
(match xs
(case Nil Nil)
(case (Cons h t)
(reuse-as xs (term-ctor List Cons (app + h 1) (app map_inc t)))))))
```
`(reuse-as SRC NEW-CTOR)` asks the codegen to allocate `NEW-CTOR`
in `SRC`'s memory slot. Compiler verifies: `SRC` is owned, this
is its last use, sizes match. On a hit: no malloc, no free — the
box is overwritten in place. On a miss: structured diagnostic
explains which precondition failed; the LLM either adjusts the
surrounding code or removes the hint.
This matches Lean 4 / Roc reuse analysis but lifts it from
"compiler-inferred when possible" to "author-asserted, compiler-
verified". The LLM applies it everywhere it expects to fire and
lets the compiler bounce the request when it can't.
**(4) `(drop-iterative)` annotation on data declarations.**
```
(data Tree (vars a)
(ctor Leaf)
(ctor Node a (Tree a) (Tree a))
(drop-iterative))
```
When the refcount of a `Tree` value reaches zero, the synthesised
dec-on-zero traversal is iterative (worklist + heap-allocated
stack) instead of recursive. Avoids stack overflow on deep
structures. The LLM adds the annotation where appropriate; the
compiler refuses to emit recursive dec-cascade on annotated types.
**(5) Structured compiler diagnostics with `suggested_rewrites`.**
Every RC-mode error (use-after-consume, mode-mismatch,
reuse-as-fail, drop-cascade-too-deep) emits a JSON object
containing the failure kind, the source span, and a list of
concrete rewrite suggestions in form-A AILang. The LLM consumes
these without prose-parsing. This is the missing half of the
LLM-as-author story: the language spec defines not only what
compiles, but what the compiler tells the author when it doesn't.
## Inference algorithm
Post-typecheck, post-`lift_letrecs` (see [pipeline](pipeline.md)),
pre-codegen pass over the elaborated module. For each `Term` node that produces or binds a
boxed value, the pass computes a uniqueness flag:
- **Unique:** at this program point, the reference is the only
outstanding reference to its referent.
- **Shared:** there may be multiple outstanding references.
A reference is *unique* if every path from its allocation to the
current program point passes through exactly one binding. The
inference is a forward dataflow over the AST. The annotations
(`borrow` / `own`) provide the inter-fn contract; the
inference fills in intra-fn detail.