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