diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index b80701f..6294809 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -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 { 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 { ) } +/// 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 { + 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 { /// 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, @@ -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 /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() { diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index c16dcf8..54fe007 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -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=` 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" + ); +} diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index 60089db..85d5cef 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -116,11 +116,19 @@ type Result = std::result::Result; /// `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", } } } diff --git a/runtime/rc.c b/runtime/rc.c new file mode 100644 index 0000000..c49fe0f --- /dev/null +++ b/runtime/rc.c @@ -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 +#include +#include +#include +#include + +/* 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); + } +}