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
|
/// 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() {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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