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:
+73
-4
@@ -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() {
|
||||
|
||||
@@ -37,6 +37,45 @@ fn build_and_run(example: &str) -> String {
|
||||
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]
|
||||
fn sum_1_to_10_is_55() {
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,11 +116,19 @@ type Result<T> = std::result::Result<T, CodegenError>;
|
||||
/// `Bump` swaps every `@GC_malloc` for `@bump_malloc`, which is supplied
|
||||
/// by `runtime/bump.c` — a no-free, statically-sized arena allocator
|
||||
/// 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)]
|
||||
pub enum AllocStrategy {
|
||||
Gc,
|
||||
Bump,
|
||||
Rc,
|
||||
}
|
||||
|
||||
impl Default for AllocStrategy {
|
||||
@@ -135,6 +143,7 @@ impl AllocStrategy {
|
||||
match self {
|
||||
AllocStrategy::Gc => "GC_malloc",
|
||||
AllocStrategy::Bump => "bump_malloc",
|
||||
AllocStrategy::Rc => "ailang_rc_alloc",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+120
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user