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 /// conservative GC; `bump` swaps every `@GC_malloc` for a
/// no-free 256 MB bump-allocator stub from `runtime/bump.c`. /// no-free 256 MB bump-allocator stub from `runtime/bump.c`.
/// The bump path is bench-only — it leaks every allocation. /// 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, alloc: String,
}, },
/// Build into a tempdir and execute. Exits with the binary's exit code. /// Build into a tempdir and execute. Exits with the binary's exit code.
@@ -142,7 +147,7 @@ enum Cmd {
#[arg(long, default_value = "-O0")] #[arg(long, default_value = "-O0")]
opt: String, opt: String,
/// Bench iter: heap allocator. See `build --alloc` for details. /// 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, alloc: String,
/// Args passed through to the compiled program. /// Args passed through to the compiled program.
#[arg(last = true)] #[arg(last = true)]
@@ -1522,7 +1527,10 @@ fn parse_alloc_strategy(s: &str) -> Result<ailang_codegen::AllocStrategy> {
match s { match s {
"gc" => Ok(ailang_codegen::AllocStrategy::Gc), "gc" => Ok(ailang_codegen::AllocStrategy::Gc),
"bump" => Ok(ailang_codegen::AllocStrategy::Bump), "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`. /// Iter 9b: shared build helper for `Cmd::Build` and `Cmd::Run`.
/// Loads the workspace, runs the typechecker, emits IR, and links via /// Loads the workspace, runs the typechecker, emits IR, and links via
/// clang. On typecheck failure, prints diagnostics to stderr and exits /// 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 /// targets. Default `Gc` keeps the entire pipeline (IR text, link
/// command) byte-identical to pre-bench. `Bump` declares /// command) byte-identical to pre-bench. `Bump` declares
/// `@bump_malloc` instead of `@GC_malloc` and links `runtime/bump.c` /// `@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( fn build_to(
path: &Path, path: &Path,
out: Option<PathBuf>, out: Option<PathBuf>,
@@ -1662,6 +1701,36 @@ fn build_to(
} }
clang.arg(&bump_obj); 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")?; let status = clang.status().context("running clang")?;
if !status.success() { if !status.success() {
+68
View File
@@ -37,6 +37,45 @@ fn build_and_run(example: &str) -> String {
String::from_utf8(output.stdout).expect("stdout utf8") String::from_utf8(output.stdout).expect("stdout utf8")
} }
/// Iter 18b: build with an explicit `--alloc=<alloc>` and run.
/// Mirrors [`build_and_run`] but threads the allocator selector through
/// to `ail build`. Used by the alloc-equivalence tests that assert the
/// `gc` and `rc` paths produce byte-identical stdout.
fn build_and_run_with_alloc(example: &str, alloc: &str) -> String {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let src = workspace.join("examples").join(example);
let tmp = std::env::temp_dir().join(format!(
"ailang_e2e_alloc_{}_{}_{}",
alloc,
example.replace('.', "_"),
std::process::id()
));
std::fs::create_dir_all(&tmp).unwrap();
let out = tmp.join("bin");
let status = Command::new(ail_bin())
.args([
"build",
src.to_str().unwrap(),
&format!("--alloc={alloc}"),
"-o",
])
.arg(&out)
.status()
.expect("ail build failed to run");
assert!(
status.success(),
"ail build --alloc={alloc} failed for {example}"
);
let output = Command::new(&out).output().expect("execute binary");
assert!(
output.status.success(),
"binary {} (--alloc={alloc}) exited non-zero",
out.display()
);
String::from_utf8(output.stdout).expect("stdout utf8")
}
#[test] #[test]
fn sum_1_to_10_is_55() { fn sum_1_to_10_is_55() {
let stdout = build_and_run("sum.ail.json"); let stdout = build_and_run("sum.ail.json");
@@ -1265,3 +1304,32 @@ fn eq_demo() {
] ]
); );
} }
/// Iter 18b: --alloc=rc routes allocation through ailang_rc_alloc
/// (8-byte refcount header, libc malloc backing). With no inc/dec
/// emission yet (18c work), programs leak under this mode but must
/// still produce correct stdout — that's the validation 18b ships.
#[test]
fn alloc_rc_produces_same_stdout_as_gc() {
// Pick a fixture with non-trivial allocation: list-building + match.
let example = "list.ail.json";
let stdout_gc = build_and_run_with_alloc(example, "gc");
let stdout_rc = build_and_run_with_alloc(example, "rc");
assert_eq!(stdout_gc, stdout_rc, "alloc=rc must match alloc=gc");
assert_eq!(stdout_rc.trim(), "42");
}
/// Iter 18b: extends `alloc_rc_produces_same_stdout_as_gc` to a larger
/// fixture (`std_list_demo`) so more allocation sites — folds, maps,
/// cross-module ctors — are exercised under `--alloc=rc`. Same
/// invariant: stdout must be byte-identical to the `gc` build.
#[test]
fn alloc_rc_matches_gc_on_std_list_demo() {
let example = "std_list_demo.ail.json";
let stdout_gc = build_and_run_with_alloc(example, "gc");
let stdout_rc = build_and_run_with_alloc(example, "rc");
assert_eq!(
stdout_gc, stdout_rc,
"alloc=rc must match alloc=gc on std_list_demo"
);
}
+10 -1
View File
@@ -116,11 +116,19 @@ type Result<T> = std::result::Result<T, CodegenError>;
/// `Bump` swaps every `@GC_malloc` for `@bump_malloc`, which is supplied /// `Bump` swaps every `@GC_malloc` for `@bump_malloc`, which is supplied
/// by `runtime/bump.c` — a no-free, statically-sized arena allocator /// by `runtime/bump.c` — a no-free, statically-sized arena allocator
/// used purely to quantify the GC's overhead via an A/B comparison. /// used purely to quantify the GC's overhead via an A/B comparison.
/// The IR is otherwise byte-identical between the two strategies. /// `Rc` (Iter 18b, Decision 10) routes allocation through
/// `@ailang_rc_alloc` from `runtime/rc.c`, which prefixes every payload
/// with an 8-byte refcount header. Iter 18b stops at allocator routing —
/// codegen does not yet emit `inc`/`dec` calls, so programs leak
/// every allocation under `Rc`. The actual instrumentation arrives in
/// Iter 18c once uniqueness inference is wired up.
/// The IR is otherwise byte-identical between the three strategies
/// modulo the allocator symbol name.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AllocStrategy { pub enum AllocStrategy {
Gc, Gc,
Bump, Bump,
Rc,
} }
impl Default for AllocStrategy { impl Default for AllocStrategy {
@@ -135,6 +143,7 @@ impl AllocStrategy {
match self { match self {
AllocStrategy::Gc => "GC_malloc", AllocStrategy::Gc => "GC_malloc",
AllocStrategy::Bump => "bump_malloc", AllocStrategy::Bump => "bump_malloc",
AllocStrategy::Rc => "ailang_rc_alloc",
} }
} }
} }
+120
View File
@@ -0,0 +1,120 @@
/* AILang reference-counting runtime — Iter 18b.
*
* This is the allocator + counter primitives for `ail build --memory=rc`.
* It establishes the memory layout (8-byte refcount header preceding
* every allocation) and the three runtime entry points the codegen will
* eventually call: ailang_rc_alloc / ailang_rc_inc / ailang_rc_dec.
*
* Iter 18b deliberately stops at the *layout* and the *alloc*. The
* codegen routes `Term::Ctor` / `Term::Lam` env / closure-pair sites
* through `ailang_rc_alloc` instead of `GC_malloc` / `bump_malloc`, but
* does NOT yet emit `inc` or `dec` calls anywhere. Programs running
* under `--memory=rc` therefore leak every allocation — the same
* behaviour as the pre-Boehm era. This is intentional: the next iter
* (18c) ships uniqueness inference and the codegen pass that emits
* inc/dec. 18b is purely about plumbing the allocator and validating
* that compiled programs still produce correct output under the new
* allocator.
*
* Layout:
*
* high address ┐
* │ payload (size bytes, 8-byte aligned)
* ┤ ← returned pointer (`p`)
* │ uint64_t refcount ← header (8 bytes)
* low address ┘ ← ailang_rc_alloc's internal allocation
*
* The returned pointer points to the *payload*. The header is at
* `p - 8`. Codegen treats the returned pointer exactly like a
* `GC_malloc`-returned pointer; it stores the ADT tag at offset 0,
* fields from offset 8, env-cells from offset 0 in lambda envs, etc.
*
* Single-threaded: counter ops are non-atomic. AILang has no
* concurrency primitives yet; when it acquires them, atomic-vs-non-
* atomic becomes a separate decision per allocation kind (see
* Decision 10's "Does not commit to atomic refcounts" clause).
*/
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Header lives in the 8 bytes preceding every payload. */
typedef uint64_t ailang_rc_header_t;
#define HEADER_SIZE ((size_t)sizeof(ailang_rc_header_t))
static inline ailang_rc_header_t *header_of(void *payload) {
return (ailang_rc_header_t *)((uint8_t *)payload - HEADER_SIZE);
}
/* Allocate `size` bytes of payload, prefixed by an 8-byte refcount
* header initialised to 1. Returns a pointer to the payload.
*
* Aborts on out-of-memory; AILang has no exception machinery yet, and
* Boehm's behaviour on OOM is also "abort", so this matches.
*
* Zero-initialises the payload to match `GC_malloc`'s contract — codegen
* may rely on uninitialised fields reading as zero in some paths. */
void *ailang_rc_alloc(size_t size) {
void *block = malloc(HEADER_SIZE + size);
if (block == NULL) {
fprintf(stderr,
"ailang_rc_alloc: out of memory (requested payload %zu bytes)\n",
size);
abort();
}
ailang_rc_header_t *hdr = (ailang_rc_header_t *)block;
*hdr = 1;
void *payload = (uint8_t *)block + HEADER_SIZE;
memset(payload, 0, size);
return payload;
}
/* Refcount += 1. No-op on null (codegen never asks for inc on a known-
* null pointer, but defensive — top-level fn-value pointers may be
* null-env closure pairs in static memory which must not be incremented). */
void ailang_rc_inc(void *payload) {
if (payload == NULL) {
return;
}
/* Heuristic for "static, do not touch": the static closure-pair env
* pointers (Iter 8b) live in the LLVM data segment, not in heap
* memory we allocated. We cannot trivially distinguish them at
* runtime without a flag bit; for Iter 18b, we accept that inc on
* static memory is undefined behaviour. Iter 18c's codegen will
* elide inc/dec for known-static pointers, so this path will not
* be reached for them in practice. */
ailang_rc_header_t *hdr = header_of(payload);
*hdr += 1;
}
/* Refcount -= 1. If it reaches zero, frees the underlying block.
*
* Iter 18b deliberately does NOT recursively dec child references.
* That requires per-type traversal info (which fields are pointer-
* typed, which are unboxed), which is added in Iter 18c when the
* codegen learns to emit per-ctor `dec` cascades. For now, free-on-
* zero just frees the box; any boxed children leak.
*
* Iter 18b never emits `dec` calls from codegen, so this fn is
* effectively dead code in 18b. It exists so the runtime ABI is
* complete and 18c can wire codegen up against a stable surface. */
void ailang_rc_dec(void *payload) {
if (payload == NULL) {
return;
}
ailang_rc_header_t *hdr = header_of(payload);
if (*hdr == 0) {
fprintf(stderr,
"ailang_rc_dec: refcount underflow at %p (already zero)\n",
payload);
abort();
}
*hdr -= 1;
if (*hdr == 0) {
free(hdr);
}
}