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
allocator before 18c adds the inc/dec instrumentation that gives
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.