DESIGN/JOURNAL: Decision 10 reframed — RC + LLM-author annotations
Replaces the earlier "RC + inference, no annotations" position with a two-layer architecture: inference (intra-fn) + mandatory LLM-author mode annotations on fn signatures (inter-fn). Adds structured rationale for rejecting region inference (regions assume stack-shaped lifetimes; real programs need non-stack-shaped lifetimes for caches / memo tables) and linear types as primary mechanism. Schema additions enumerated for the 18-series: Type::Borrow / Type::Own (Iter 18a) Term::Clone (Iter 18c) Term::ReuseAs (Iter 18d) TypeDef.drop_iterative (Iter 18e) Migration plan extended from 18a-d (4 iters) to 18a-f (6 iters). JOURNAL records the regions excursion, the scope-shape reduction, and the LLM-author lever as the structural advantage Decision 10 cashes in on.
This commit is contained in:
+343
-1
@@ -543,7 +543,15 @@ constructor-blocked recursions in `map`, `sort`, `insert`
|
||||
remain unmarked — they cannot benefit from `musttail`
|
||||
without a source-level rewrite.
|
||||
|
||||
## Decision 9: memory management — Boehm conservative GC
|
||||
## Decision 9: transitional allocator — Boehm conservative GC
|
||||
|
||||
**Status: superseded by Decision 10 (RC + Uniqueness, 2026-05-08).**
|
||||
This section documents the active runtime as of Iter 17a / the
|
||||
GC bench, but it is no longer the canonical memory model. Boehm
|
||||
remains linked behind `--alloc=gc` (the current default) until
|
||||
the RC pipeline (Iters 18a–18d) is validated and the default
|
||||
flips. The `--alloc=bump` mode introduced for the bench is a
|
||||
measurement tool, not a production target.
|
||||
|
||||
Through Iter 14e, every ADT box, lambda env, and closure pair was
|
||||
allocated with bare `malloc` and never freed. That worked for the
|
||||
@@ -660,6 +668,340 @@ 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.
|
||||
|
||||
## Decision 10: memory model — RC + Uniqueness with LLM-author annotations
|
||||
|
||||
**Committed 2026-05-08, after the GC bench (`bench/run.sh`) showed
|
||||
Boehm contributing ~60% of runtime on allocation-heavy workloads
|
||||
that hold the heap fully live. Sharpened later the same day: the
|
||||
mainstream "RC + inference" position was extended with mandatory
|
||||
LLM-author mode annotations (`borrow` / `own`), explicit `clone`,
|
||||
first-class `reuse-as`, and `drop-iterative` data attrs.**
|
||||
|
||||
The cost of GC is structurally in the allocate path — Boehm's
|
||||
`GC_malloc` is ~2.8× slower than a bump pointer, and the bench
|
||||
workloads exercised allocate cost without collection cost.
|
||||
Tracing GC's irreducible variability cannot be tuned away; RC's
|
||||
costs are bounded and analysable per program point. A corpus
|
||||
committed to one memory model is expensive to switch — pre-
|
||||
stdlib is the cheapest moment to commit.
|
||||
|
||||
**Choice.** AILang's canonical memory model is reference counting
|
||||
with static uniqueness inference **and explicit LLM-author
|
||||
annotations on fn signatures**, in the lineage of Lean 4 / Roc /
|
||||
Koka. Boehm becomes a transitional allocator (Decision 9) and is
|
||||
retired when the RC pipeline matches the bump-allocator floor
|
||||
within an acceptable margin (target 1.3× on `bench/run.sh`).
|
||||
|
||||
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).
|
||||
|
||||
### Why not other memory models
|
||||
|
||||
**Tracing GC.** Open-ended. Boehm's bench number (~60% allocate-
|
||||
path overhead) is structural; tuning Boehm cannot move that
|
||||
needle. A precise tracing GC would need read/write barriers,
|
||||
root maps, generational machinery — months of work, with
|
||||
irreducible pause-time variability at the end. The user's
|
||||
framing was decisive: "Am GC kannst du ewig rumschrauben (und
|
||||
bekommst trotzdem auch in 100 Jahren keine berechnbare
|
||||
Performance)."
|
||||
|
||||
**Region inference (Tofte/Talpin / MLton-style).** Considered
|
||||
seriously; rejected. Regions tie lifetimes to dynamic scope —
|
||||
every value lives in some region, regions stack on entry/exit,
|
||||
deallocation is bulk-by-region. The reduction: a region is an
|
||||
explicit allocator with sugar plus a static check that nothing
|
||||
escapes its lifetime. The deeper problem: regions assume
|
||||
**stack-shaped lifetimes**. Real programs have non-stack-shaped
|
||||
lifetimes — caches, memo tables, registries, lookup structures
|
||||
whose lifetimes are not nested. Regions would either force these
|
||||
into a `letregion` at the top of `main` (everyone's allocator
|
||||
becomes the root region — useless), or require a region per
|
||||
cache variant (combinatorial). RC has no lifetime-shape
|
||||
assumption; it works for any DAG.
|
||||
|
||||
**Linear / ownership types as primary mechanism (Rust-style).**
|
||||
Considered as a general-purpose alternative; rejected as primary.
|
||||
Rust's borrow-checker is a great mechanism but requires the
|
||||
author to thread lifetimes through every signature and accept
|
||||
that some programs cannot be expressed without `unsafe`. For an
|
||||
LLM-targeted language with recursion + closures everywhere, that
|
||||
cost is too high — most of the source surface would be lifetime
|
||||
annotations rather than logic. AILang uses a *subset* of these
|
||||
ideas (mode annotations, linear consumption discipline)
|
||||
selectively, layered on top of RC, where the annotations buy
|
||||
concrete optimisations and never have to thread lifetimes
|
||||
through callees.
|
||||
|
||||
### 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)`.**
|
||||
|
||||
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.
|
||||
|
||||
### Language-design constraints (binding)
|
||||
|
||||
The four constraints below are necessary preconditions for RC to
|
||||
be sound and complete *without* a cycle-collector backstop. They
|
||||
are not new — AILang already satisfies all four — but Decision 10
|
||||
makes them load-bearing rather than incidental:
|
||||
|
||||
1. **Strict evaluation.** Every `Term::App` argument is fully
|
||||
evaluated before the call. No laziness, no thunks. (Already
|
||||
true.)
|
||||
2. **No recursive value bindings.** `(let x EXPR ...)` evaluates
|
||||
`EXPR` in a scope where `x` is *not* bound. Recursion is
|
||||
exclusively via `Term::LetRec` (which binds a fn, not a value)
|
||||
and module-level fn defs. (Already true.)
|
||||
3. **No shared mutable refs.** Values are immutable once
|
||||
constructed. There is no `ref`, `IORef`, `Mutex`, or any
|
||||
primitive that allows a value to be mutated from a position
|
||||
outside its allocation. (Already true.)
|
||||
4. **ADTs are acyclic by construction.** Strict evaluation +
|
||||
no-recursive-value-bindings + no-shared-mutable-refs together
|
||||
guarantee that any value graph reachable from a binding is a
|
||||
DAG. The reference graph has no cycles. (Follows from 1–3.)
|
||||
|
||||
A future iter that proposes any of laziness, recursive value
|
||||
bindings, shared mutable state, or any feature that creates
|
||||
cycles must either prove the cycle is collectible by an
|
||||
extension (e.g. linear ownership) or be rejected at design time.
|
||||
|
||||
### Schema additions
|
||||
|
||||
**Iter 18a — mode wrappers on `Type`.**
|
||||
|
||||
```
|
||||
Type::Borrow(Box<Type>) ; `(borrow T)` in form-A
|
||||
Type::Own(Box<Type>) ; `(own T)` in form-A
|
||||
```
|
||||
|
||||
Both are transparent for unification (they erase to their inner
|
||||
`Type` for HM purposes) but carry binding mode metadata for the
|
||||
RC pipeline. Skipped during JSON serialisation when absent so
|
||||
canonical hashes of every pre-18a fixture stay bit-identical.
|
||||
The legacy `(con T)` form is treated as `(own T)` semantically
|
||||
throughout the 18-series; a later iter (deferred) makes the
|
||||
explicit annotation mandatory and rejects bare `(con T)` for
|
||||
boxed parameter types.
|
||||
|
||||
**Iter 18c/18d — new `Term` variants.**
|
||||
|
||||
```
|
||||
Term::Clone { value: Box<Term> } ; `(clone X)` — explicit RC inc
|
||||
Term::ReuseAs { source: Box<Term>, body: Box<Term> } ; `(reuse-as SRC NEW-CTOR)`
|
||||
```
|
||||
|
||||
**Iter 18e — `TypeDef` attribute.**
|
||||
|
||||
```
|
||||
TypeDef.drop_iterative: bool ; `(drop-iterative)`
|
||||
```
|
||||
|
||||
All four are skipped during serialisation when absent / false /
|
||||
None so canonical-JSON hashes of every fixture remain stable
|
||||
until the fixture intentionally adopts the feature.
|
||||
|
||||
### Inference algorithm (Iter 18c)
|
||||
|
||||
Post-typecheck, post-`lift_letrecs`, 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
|
||||
from 18a (`borrow` / `own`) provide the inter-fn contract; the
|
||||
inference fills in intra-fn detail. (Implementer-level detail in
|
||||
the Iter 18c brief.)
|
||||
|
||||
### Codegen contract (Iter 18b, 18c)
|
||||
|
||||
Memory layout (Iter 18b):
|
||||
|
||||
- Every heap allocation has an 8-byte refcount header, followed
|
||||
by the payload. `ailang_rc_alloc(size)` returns a pointer to
|
||||
the *payload*; the header is at `ptr - 8`.
|
||||
- `ailang_rc_inc(ptr)`: load `ptr - 8`, +1, store. Non-atomic
|
||||
(single-threaded).
|
||||
- `ailang_rc_dec(ptr)`: load, -1, store; if zero, recurse-dec
|
||||
child references and `free(ptr - 8)`. For `(drop-iterative)`
|
||||
types, the recursion is replaced by a worklist loop (Iter 18e).
|
||||
|
||||
Codegen for `Term::Ctor` / `Term::Lam` env / closure pair under
|
||||
`--memory=rc` calls `ailang_rc_alloc(SIZE)`. Iter 18b stops there
|
||||
— inc/dec instrumentation is added in Iter 18c, once the
|
||||
inference is wired up. Until then, `--memory=rc` deliberately
|
||||
leaks like the pre-Boehm era; this is purely about plumbing.
|
||||
|
||||
### Migration plan
|
||||
|
||||
1. **Iter 18a:** `(borrow T)` / `(own T)` annotations as a
|
||||
language feature. Schema, parser, JSON, typechecker. No
|
||||
codegen change. `(con T)` ≡ `(own T)`. Existing fixtures
|
||||
unchanged.
|
||||
2. **Iter 18b:** RC runtime. `runtime/rc.c` with header layout +
|
||||
alloc/inc/dec. Codegen `--memory=rc` routes allocation through
|
||||
`rc_alloc`; **no inc/dec yet**. `--memory=gc` remains default.
|
||||
3. **Iter 18c:** uniqueness inference + codegen inc/dec
|
||||
instrumentation. Combines 18a's annotations with intra-fn
|
||||
dataflow. Linear-by-default enforcement turns on. `(clone X)`
|
||||
in the `Term` schema.
|
||||
4. **Iter 18d:** reuse hints + reuse analysis. `(reuse-as ...)`
|
||||
form. Codegen rewrites dec+malloc into in-place overwrite when
|
||||
the precondition holds.
|
||||
5. **Iter 18e:** `(drop-iterative)` data attr. Worklist-based
|
||||
free for annotated types.
|
||||
6. **Iter 18f:** RC validation bench. If RC within 1.3× of bump
|
||||
on `bench/run.sh`, retire Boehm: flip default to RC, drop
|
||||
`-lgc`, mark Decision 9 historical.
|
||||
|
||||
### Adjacent extensions for mutability (out of Decision 10's scope)
|
||||
|
||||
If future workloads need mutable arrays, hash tables, or other
|
||||
inherently mutable primitives, the answer is **not** a tracing GC
|
||||
backstop. The answer is a separate ownership/linear extension
|
||||
that gates mutability behind static single-owner discipline. RC
|
||||
+ uniqueness is the universal floor; ownership extends it for
|
||||
specific high-performance primitives without rebreaking the
|
||||
acyclicity invariant. Concrete design deferred until the need
|
||||
materialises with a real workload.
|
||||
|
||||
### What this Decision deliberately does not do
|
||||
|
||||
- **Does not infer everything.** An earlier draft of this
|
||||
Decision said "uniqueness is fully inferred" — that was the
|
||||
mainstream RC position. The 2026-05-08 follow-up discussion
|
||||
replaced it: AILang demands annotations *because* the LLM
|
||||
author can produce them effortlessly. The compiler does
|
||||
inference *plus* verification of contracts.
|
||||
- **Does not require existing fixtures to migrate immediately.**
|
||||
Iter 18a treats `(con T)` as `(own T)`. Existing JSON hashes
|
||||
stay bit-identical until the fixture is intentionally updated.
|
||||
- **Does not commit to atomic refcounts.** AILang is currently
|
||||
single-threaded. If/when concurrency arrives, atomic-vs-non-
|
||||
atomic will be a separate decision per allocation kind.
|
||||
- **Does not introduce regions.** Regions were considered and
|
||||
rejected; see "Why not other memory models" above.
|
||||
|
||||
## Mangling scheme (Iter 5c)
|
||||
|
||||
All AILang functions are mangled to `@ail_<module>_<def>` — even in the
|
||||
|
||||
Reference in New Issue
Block a user