Bench: GC overhead via bump-allocator comparison

Adds --alloc=<gc|bump> to ail build/run. Bump path links a 256MB
no-free arena C stub instead of libgc; IR is byte-identical except
for the @GC_malloc → @bump_malloc symbol swap. Bench harness times
two allocation-heavy workloads (list cons/sum and balanced tree
build/walk) under both modes.

Numbers (RUNS=5, median of 4):
  bench_list_sum   gc 0.141s  bump 0.048s  +194%
  bench_tree_walk  gc 0.103s  bump 0.041s  +151%

Bucket: large. ~60% of runtime is Boehm on these workloads —
upper bound for any realistic program. Both fixtures hold the
heap fully live, so the cost we're seeing is Boehm's allocate
path itself, not collection work; that fact narrows the design
space for the GC discussion.

- crates/ailang-codegen: AllocStrategy enum, three callsites and
  the IR header parameterised.
- crates/ail/src/main.rs: --alloc flag plumbed; bump runtime
  located + compiled on demand.
- runtime/bump.c: 256MB static arena, abort-on-overflow.
- examples/bench_list_sum, bench_tree_walk: accumulator-form
  fixtures (textbook recursive sum was constructor-blocked).
- bench/run.sh: harness with Python timing helper (Arch's
  /usr/bin/time isn't part of the base install).

No language-level changes; default --alloc=gc, all 141 workspace
tests green, all 5 IR snapshots unchanged, 11 prior fixtures
produce identical stdout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 00:06:20 +02:00
parent aee6e9d3bf
commit 65e280bb70
9 changed files with 752 additions and 22 deletions
+108 -17
View File
@@ -125,6 +125,12 @@ enum Cmd {
/// Optimization (e.g. `-O2`); default `-O0` for debuggability.
#[arg(long, default_value = "-O0")]
opt: String,
/// Bench iter: heap allocator. `gc` (default) is Boehm
/// 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"])]
alloc: String,
},
/// Build into a tempdir and execute. Exits with the binary's exit code.
/// Convenience wrapper around `build` + invocation of the resulting
@@ -135,6 +141,9 @@ enum Cmd {
/// Optimization (e.g. `-O2`); default `-O0` for debuggability.
#[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"])]
alloc: String,
/// Args passed through to the compiled program.
#[arg(last = true)]
args: Vec<String>,
@@ -427,21 +436,23 @@ fn main() -> Result<()> {
None => print!("{ir}"),
}
}
Cmd::Build { path, out, opt } => {
let bin = build_to(&path, out, &opt)?;
Cmd::Build { path, out, opt, alloc } => {
let strategy = parse_alloc_strategy(&alloc)?;
let bin = build_to(&path, out, &opt, strategy)?;
eprintln!("built {}", bin.display());
}
Cmd::Run { path, opt, args } => {
Cmd::Run { path, opt, alloc, args } => {
// Iter 9b: build into a fresh tempdir per run, exec, propagate
// exit code. The artefact dir is left around (no cleanup) so
// it can be inspected in case of a crash; OS temp policy
// collects them.
let strategy = parse_alloc_strategy(&alloc)?;
let tmpdir = std::env::temp_dir().join(format!(
"ailang-run-{}",
std::process::id()
));
std::fs::create_dir_all(&tmpdir)?;
let bin = build_to(&path, Some(tmpdir.join("bin")), &opt)?;
let bin = build_to(&path, Some(tmpdir.join("bin")), &opt, strategy)?;
let status = std::process::Command::new(&bin)
.args(&args)
.status()
@@ -1507,6 +1518,49 @@ fn render_workspace_diff_text(r: &WorkspaceDiffReport) -> String {
out
}
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`)"),
}
}
/// Locate the workspace-root `runtime/bump.c` file relative to the
/// `ail` binary. We search upwards for a directory that contains
/// `runtime/bump.c`; that's the bench-only allocator stub. If we
/// can't find it, the build aborts with a clear message — the
/// `--alloc=bump` path is opt-in, so this only fires when the user
/// asked for it.
fn locate_bump_runtime() -> Result<PathBuf> {
// Two anchors we try in order:
// 1. the directory containing the running `ail` binary, walked
// up to find `runtime/bump.c` (handles `target/release/ail`
// and `target/debug/ail` cleanly).
// 2. the current working directory, walked up.
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("bump.c");
if candidate.exists() {
return Ok(candidate);
}
match cur.parent() {
Some(p) => cur = p,
None => break,
}
}
}
anyhow::bail!(
"could not locate `runtime/bump.c` (required for --alloc=bump). \
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
@@ -1519,7 +1573,18 @@ fn render_workspace_diff_text(r: &WorkspaceDiffReport) -> String {
/// LetRecs that capture `Term::Let`-bound names, whose types are
/// only known after typecheck). The lifted workspace then goes to
/// codegen unchanged.
fn build_to(path: &Path, out: Option<PathBuf>, opt: &str) -> Result<PathBuf> {
///
/// Bench iter: `alloc` selects the heap allocator the emitted IR
/// 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`.
fn build_to(
path: &Path,
out: Option<PathBuf>,
opt: &str,
alloc: ailang_codegen::AllocStrategy,
) -> Result<PathBuf> {
let ws = ailang_core::load_workspace(path)?;
let diags = ailang_check::check_workspace(&ws);
if !diags.is_empty() {
@@ -1556,23 +1621,49 @@ fn build_to(path: &Path, out: Option<PathBuf>, opt: &str) -> Result<PathBuf> {
modules: lifted_modules,
root_dir: ws.root_dir.clone(),
};
let ir = ailang_codegen::lower_workspace(&ws)?;
let ir = ailang_codegen::lower_workspace_with_alloc(&ws, alloc)?;
let tmpdir = std::env::temp_dir().join(format!("ailang-{}", std::process::id()));
std::fs::create_dir_all(&tmpdir)?;
let ll_path = tmpdir.join(format!("{}.ll", ws.entry));
std::fs::write(&ll_path, &ir)?;
let out_bin = out.unwrap_or_else(|| Path::new(".").join(&ws.entry).with_extension(""));
let status = std::process::Command::new("clang")
.arg(opt)
.arg("-o")
.arg(&out_bin)
.arg(&ll_path)
// Boehm conservative GC (Decision 9 / Iter 14f). The lowered IR
// calls @GC_malloc; libgc supplies it. Pthread/dl are pulled in
// transitively via libgc.so on Linux, so a single -lgc suffices.
.arg("-lgc")
.status()
.context("running clang")?;
let mut clang = std::process::Command::new("clang");
clang.arg(opt).arg("-o").arg(&out_bin).arg(&ll_path);
match alloc {
ailang_codegen::AllocStrategy::Gc => {
// Boehm conservative GC (Decision 9 / Iter 14f). The lowered
// IR calls @GC_malloc; libgc supplies it. Pthread/dl are
// pulled in transitively via libgc.so on Linux, so a single
// -lgc suffices.
clang.arg("-lgc");
}
ailang_codegen::AllocStrategy::Bump => {
// Bench iter: link the no-free arena stub from
// `runtime/bump.c` instead of libgc. We compile the stub
// inline at -O2 (its body is small, the .o is cached at
// <tmpdir>/bump.o per build invocation; no global cache
// because the bench harness rebuilds binaries top-to-bottom
// anyway).
let bump_src = locate_bump_runtime()?;
let bump_obj = tmpdir.join("bump.o");
let cstatus = std::process::Command::new("clang")
.arg("-O2")
.arg("-c")
.arg(&bump_src)
.arg("-o")
.arg(&bump_obj)
.status()
.context("compiling runtime/bump.c")?;
if !cstatus.success() {
anyhow::bail!(
"clang failed compiling bump.c (status {})",
cstatus
);
}
clang.arg(&bump_obj);
}
}
let status = clang.status().context("running clang")?;
if !status.success() {
anyhow::bail!(
"clang failed (status {}); ll at {}",