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

Wires up reference-counting allocator end-to-end without any
inc/dec emission. Programs run under --alloc=rc and produce
correct stdout (validated against --alloc=gc); they leak every
allocation, exactly like the pre-Boehm era. The point of 18b is
to establish the runtime contract before 18c adds the
inc/dec-emission codegen pass.

runtime/rc.c — new file. 8-byte uint64 refcount header
prepended to every payload; ailang_rc_alloc(size) returns a
ptr to the payload (header at ptr-8). ailang_rc_inc / dec are
declared but never called by codegen yet; they exist so 18c
can wire codegen against a stable runtime ABI. dec frees on
zero refcount but does NOT recursively dec child references —
that's 18c's job once it has per-ctor type info.

crates/ailang-codegen/src/lib.rs — AllocStrategy::Rc variant
added; fn_name() returns "ailang_rc_alloc". Single-line
extension because Iter 18a's bump path had already centralised
the allocator-symbol decision on fn_name() for all four
allocation sites.

crates/ail/src/main.rs — --alloc=rc accepted by Build/Run;
parse_alloc_strategy extended; locate_rc_runtime() helper
mirrors locate_bump_runtime; new Rc arm in build_to compiles
runtime/rc.c with clang -O2 -c and links the resulting .o
into the final binary (no -lgc).

E2E coverage: alloc_rc_produces_same_stdout_as_gc on
list.ail.json (42), alloc_rc_matches_gc_on_std_list_demo for
broader allocation-site coverage. Total e2e bundle: 51 tests
(was 49).

Hand-verified on:
  sum.ail.json --alloc=rc → 55
  list.ail.json --alloc=rc → 42
  borrow_own_demo.ail.json --alloc=rc → 3 then 6
  std_list_demo.ail.json --alloc=rc → matches --alloc=gc

cargo build/test --workspace green; git diff examples/ empty.
This commit is contained in:
2026-05-08 02:13:08 +02:00
parent 1e3697926b
commit 1eed78c41e
4 changed files with 271 additions and 5 deletions
+73 -4
View File
@@ -129,7 +129,12 @@ enum Cmd {
/// conservative GC; `bump` swaps every `@GC_malloc` for a
/// no-free 256 MB bump-allocator stub from `runtime/bump.c`.
/// The bump path is bench-only — it leaks every allocation.
#[arg(long, default_value = "gc", value_parser = ["gc", "bump"])]
/// `rc` (Iter 18b plumbing) routes through `runtime/rc.c`'s
/// `@ailang_rc_alloc` (libc-malloc backing + 8-byte refcount
/// header); inc/dec instrumentation arrives in 18c, so 18b
/// programs leak under this mode but must produce correct
/// stdout.
#[arg(long, default_value = "gc", value_parser = ["gc", "bump", "rc"])]
alloc: String,
},
/// Build into a tempdir and execute. Exits with the binary's exit code.
@@ -142,7 +147,7 @@ enum Cmd {
#[arg(long, default_value = "-O0")]
opt: String,
/// Bench iter: heap allocator. See `build --alloc` for details.
#[arg(long, default_value = "gc", value_parser = ["gc", "bump"])]
#[arg(long, default_value = "gc", value_parser = ["gc", "bump", "rc"])]
alloc: String,
/// Args passed through to the compiled program.
#[arg(last = true)]
@@ -1522,7 +1527,10 @@ fn parse_alloc_strategy(s: &str) -> Result<ailang_codegen::AllocStrategy> {
match s {
"gc" => Ok(ailang_codegen::AllocStrategy::Gc),
"bump" => Ok(ailang_codegen::AllocStrategy::Bump),
other => anyhow::bail!("unknown --alloc value `{other}` (expected `gc` or `bump`)"),
"rc" => Ok(ailang_codegen::AllocStrategy::Rc),
other => anyhow::bail!(
"unknown --alloc value `{other}` (expected `gc`, `bump`, or `rc`)"
),
}
}
@@ -1561,6 +1569,35 @@ fn locate_bump_runtime() -> Result<PathBuf> {
)
}
/// Locate the workspace-root `runtime/rc.c` file relative to the
/// `ail` binary, mirroring [`locate_bump_runtime`]. The `--alloc=rc`
/// path (Iter 18b) compiles this stub and links it instead of `-lgc`;
/// it supplies `ailang_rc_alloc` / `ailang_rc_inc` / `ailang_rc_dec`
/// against libc malloc/free.
fn locate_rc_runtime() -> Result<PathBuf> {
let candidates = [
std::env::current_exe().ok(),
std::env::current_dir().ok(),
];
for start in candidates.iter().flatten() {
let mut cur: &Path = start.as_path();
loop {
let candidate = cur.join("runtime").join("rc.c");
if candidate.exists() {
return Ok(candidate);
}
match cur.parent() {
Some(p) => cur = p,
None => break,
}
}
}
anyhow::bail!(
"could not locate `runtime/rc.c` (required for --alloc=rc). \
Run `ail` from inside the AILang workspace."
)
}
/// Iter 9b: shared build helper for `Cmd::Build` and `Cmd::Run`.
/// Loads the workspace, runs the typechecker, emits IR, and links via
/// clang. On typecheck failure, prints diagnostics to stderr and exits
@@ -1578,7 +1615,9 @@ fn locate_bump_runtime() -> Result<PathBuf> {
/// targets. Default `Gc` keeps the entire pipeline (IR text, link
/// command) byte-identical to pre-bench. `Bump` declares
/// `@bump_malloc` instead of `@GC_malloc` and links `runtime/bump.c`
/// in lieu of `-lgc`.
/// in lieu of `-lgc`. `Rc` (Iter 18b) declares `@ailang_rc_alloc`
/// instead and links `runtime/rc.c` — RC runtime, alloc-only — Iter
/// 18b plumbing; inc/dec instrumentation arrives in 18c.
fn build_to(
path: &Path,
out: Option<PathBuf>,
@@ -1662,6 +1701,36 @@ fn build_to(
}
clang.arg(&bump_obj);
}
ailang_codegen::AllocStrategy::Rc => {
// Iter 18b: link the RC runtime stub from `runtime/rc.c`.
// Provides `ailang_rc_alloc` / `inc` / `dec` against libc
// malloc/free; no Boehm dependency, so we deliberately do
// NOT pass `-lgc`. Compiled at -O2 to match the bump path
// shape; cached at <tmpdir>/rc.o per build invocation.
//
// 18b only routes allocation here — codegen does not yet
// emit `inc`/`dec` calls, so programs leak under this
// mode. The point is to validate compiled-program
// correctness and the runtime ABI before 18c wires up
// inc/dec emission.
let rc_src = locate_rc_runtime()?;
let rc_obj = tmpdir.join("rc.o");
let cstatus = std::process::Command::new("clang")
.arg("-O2")
.arg("-c")
.arg(&rc_src)
.arg("-o")
.arg(&rc_obj)
.status()
.context("compiling runtime/rc.c")?;
if !cstatus.success() {
anyhow::bail!(
"clang failed compiling rc.c (status {})",
cstatus
);
}
clang.arg(&rc_obj);
}
}
let status = clang.status().context("running clang")?;
if !status.success() {