JOURNAL: Iter 18b — RC runtime + --alloc=rc routing

Records that the codegen change was a one-line extension because
the 18a bump path had centralised the allocator-symbol decision
on fn_name(). Documents the runtime ABI (8-byte refcount header,
ailang_rc_alloc/inc/dec) and the deliberate no-inc/dec posture:
programs leak intentionally under --alloc=rc; 18c lights up the
actual reference counting.

Next-step plan for 18c is split into three sub-iters
(clone schema / linearity check / inference + codegen) since
each wants its own verification cycle.
This commit is contained in:
2026-05-08 02:14:40 +02:00
parent 1eed78c41e
commit 5cd0a3400a
+146
View File
@@ -5837,3 +5837,149 @@ emitted yet; programs leak intentionally. The point is to
validate that compiled programs run correctly under the new validate that compiled programs run correctly under the new
allocator before 18c adds the inc/dec instrumentation that gives allocator before 18c adds the inc/dec instrumentation that gives
the runtime contract its teeth. the runtime contract its teeth.
## Iter 18b — RC runtime (`runtime/rc.c`) + codegen `--alloc=rc` routing
Pure plumbing iter. Establishes the RC runtime ABI and wires
`--alloc=rc` through the codegen + linker, but does NOT emit
any `inc` or `dec` calls. Programs running under `--alloc=rc`
allocate via `ailang_rc_alloc` and never free — the same
behaviour as pre-Boehm AILang. The point is to validate that
compiled programs still produce correct stdout under the new
allocator before 18c lights up the actual reference counting.
### Runtime ABI (`runtime/rc.c`)
Memory layout: an 8-byte `uint64_t` refcount header is prepended
to every payload. `ailang_rc_alloc(size)` allocates `size + 8`
bytes via libc `malloc`, sets the header to `1`, zero-initialises
the payload (matching `GC_malloc`'s contract), and returns a
pointer to the *payload*. The header is at `payload - 8`.
`ailang_rc_inc(p)` increments the header at `p - 8`.
`ailang_rc_dec(p)` decrements; on zero-refcount it `free()`s
the underlying block. **Crucially**, dec does NOT recursively
dec child references in 18b — that requires per-type field-
layout info, which is 18c's job to wire up. For 18b, both `inc`
and `dec` are dead code from codegen's perspective; the codegen
emits zero calls to either. They exist as ABI placeholders so
18c can wire codegen against a stable surface.
The runtime is single-threaded (counter ops are non-atomic);
when AILang acquires concurrency primitives, atomic-vs-non-
atomic becomes a separate decision per allocation kind, per
the "Does not commit to atomic refcounts" clause in Decision 10.
### Codegen
`AllocStrategy::Rc` was a one-line addition to the existing
enum. `fn_name()` returns `"ailang_rc_alloc"` for it. The rest
of the codegen — the `declare ptr @<name>(i64)` line, the four
allocation sites (`lower_ctor`, lambda env, closure pair) — is
already parameterised on `alloc.fn_name()` from the bump-bench
work, so adding a third strategy required zero further codegen
edits. This is the "Iter 18a's bump path centralised the
allocator decision; 18b just adds a new value" payoff that the
implementer flagged for the JOURNAL.
### CLI
`--alloc=rc` is accepted by both `Build` and `Run`.
`locate_rc_runtime()` mirrors `locate_bump_runtime` — searches
upward from the binary and from CWD for `runtime/rc.c`. The
link arm compiles `runtime/rc.c` with `clang -O2 -c` and passes
the resulting `.o` to the main link command. **No `-lgc`**
rc.c uses libc only.
### E2E coverage
Two new tests:
- `alloc_rc_produces_same_stdout_as_gc` builds and runs
`list.ail.json` under both `--alloc=gc` and `--alloc=rc`,
asserts they produce identical stdout (`42`).
- `alloc_rc_matches_gc_on_std_list_demo` does the same for
`std_list_demo.ail.json` — broader allocation coverage
(length / sum / reverse / take/drop chained), every fold
goes through `ailang_rc_alloc`.
E2E bundle: 51 tests (was 49 pre-18b, 50 with the 18a addition).
`cargo build/test --workspace` green. `git diff examples/`
empty — no fixture changes.
### Hand-tested
```
$ cargo run --bin ail -- run examples/sum.ail.json --alloc=rc
55
$ cargo run --bin ail -- run examples/list.ail.json --alloc=rc
42
$ cargo run --bin ail -- run examples/borrow_own_demo.ail.json --alloc=rc
3
6
$ cargo run --bin ail -- run examples/std_list_demo.ail.json --alloc=rc
... (matches --alloc=gc)
```
All correct. Programs allocate, produce output, exit. Memory
leaks all the way through (no `dec` is ever emitted), but at
the bench/test scales we run, the leak is bounded (<100 MB) and
the OS reclaims on process exit.
### Did anything surprise
- **Codegen change was a single line.** The 18a bump path had
already done the heavy lifting of centralising the allocator-
symbol decision on a single `fn_name()` method; 18b just adds
another arm to that match. This is the kind of compounding
return that pays for the bench iter retroactively — it built
the abstraction, 18b reuses it for free.
- **No need for a `Type::fn_implicit` migration in 18b.** The
18a JOURNAL flagged this as a possible cleanup; 18b confirmed
it's not blocking anything. The padding-on-read trick handles
every codegen-side construction site silently. Cleanup
remains optional, deferred to 18c if it has a reason.
- **Boehm and RC have nearly the same emitted IR.** The two
binaries differ only in (a) the `declare ptr @<name>(i64)`
symbol name and (b) the linker argument (`-lgc` vs.
`runtime/rc.o`). Allocation behaviour at the source level is
identical from codegen's perspective. This is what we wanted
— 18c's inc/dec emission can be added orthogonally without
re-architecting the allocation pipeline.
### Next
Iter 18c is the substantive next step. Three sub-pieces:
1. **`Term::Clone { value }`** as a new schema variant. Author-
spelled explicit RC inc, like Lean 4's `(@Clone)` syntax.
Schema + parser + printer + typechecker passthrough.
2. **Linearity check** as a new pass over the typechecked AST.
For functions with all-explicit-mode params, verify each
binder is consumed exactly once (or borrowed indefinitely).
Use-after-consume → structured diagnostic with
`suggested_rewrites`. Functions with any `Implicit` param
are exempt — that's the back-compat lane while existing
fixtures stay unannotated.
3. **Uniqueness inference + codegen inc/dec emission.**
Post-typecheck dataflow over AST builds a side table
`BTreeMap<NodeId, Uniqueness>`. Codegen consumes the table:
on every `Term::Var` of a shared reference that escapes its
binder, emit `call void @ailang_rc_inc(ptr %p)`. On every
binder going out of scope, emit `call void @ailang_rc_dec`.
The inference erases inc/dec on provably unique references
— that's the optimisation that closes the bump-allocator
gap on hot loops.
18c is at least a 3-agent-run iter; tackle it as 18c.1 (clone
schema), 18c.2 (linearity check + suggested_rewrites), 18c.3
(inference + codegen) sequentially. Don't attempt all three in
one pass — each builds on the previous and wants its own
verification cycle.