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:
+108
-17
@@ -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 {}",
|
||||
|
||||
@@ -110,6 +110,35 @@ pub enum CodegenError {
|
||||
|
||||
type Result<T> = std::result::Result<T, CodegenError>;
|
||||
|
||||
/// Bench iter: which heap-allocation runtime the emitted IR targets.
|
||||
///
|
||||
/// `Gc` is the default (Boehm conservative GC, Decision 9 / Iter 14f).
|
||||
/// `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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AllocStrategy {
|
||||
Gc,
|
||||
Bump,
|
||||
}
|
||||
|
||||
impl Default for AllocStrategy {
|
||||
fn default() -> Self {
|
||||
AllocStrategy::Gc
|
||||
}
|
||||
}
|
||||
|
||||
impl AllocStrategy {
|
||||
/// LLVM IR-level name of the allocator fn (without leading `@`).
|
||||
fn fn_name(self) -> &'static str {
|
||||
match self {
|
||||
AllocStrategy::Gc => "GC_malloc",
|
||||
AllocStrategy::Bump => "bump_malloc",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-module entry point. Lowers `m` to a `.ll` string with `m`
|
||||
/// itself as the entry module. Returns the full LLVM IR text, ready to
|
||||
/// be written to disk and handed to `clang`.
|
||||
@@ -145,6 +174,16 @@ pub fn emit_ir(m: &Module) -> Result<String> {
|
||||
lower_workspace(&ws)
|
||||
}
|
||||
|
||||
/// Bench iter: variant of [`lower_workspace`] that selects the heap
|
||||
/// allocator at codegen time. `AllocStrategy::Gc` produces IR
|
||||
/// byte-identical to [`lower_workspace`]; `AllocStrategy::Bump` swaps
|
||||
/// every `@GC_malloc` site for `@bump_malloc` (supplied by
|
||||
/// `runtime/bump.c`). Used by `ail build --alloc=bump` to quantify the
|
||||
/// GC's runtime overhead via an A/B comparison.
|
||||
pub fn lower_workspace_with_alloc(ws: &Workspace, alloc: AllocStrategy) -> Result<String> {
|
||||
lower_workspace_inner(ws, alloc)
|
||||
}
|
||||
|
||||
/// Multi-module entry point. Lowers an entire [`Workspace`] (entry
|
||||
/// module plus its transitive imports, as produced by
|
||||
/// `ailang_core::load_workspace`) to a single `.ll` string and emits
|
||||
@@ -171,6 +210,10 @@ pub fn emit_ir(m: &Module) -> Result<String> {
|
||||
/// Use [`emit_ir`] for the single-file shortcut when there are no
|
||||
/// imports.
|
||||
pub fn lower_workspace(ws: &Workspace) -> Result<String> {
|
||||
lower_workspace_inner(ws, AllocStrategy::Gc)
|
||||
}
|
||||
|
||||
fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy) -> Result<String> {
|
||||
// Iter 16a: desugar every module before any lowering work runs.
|
||||
// The pass is idempotent and structurally identical to what
|
||||
// `ailang-check` runs at its public entries, so the codegen
|
||||
@@ -293,6 +336,7 @@ pub fn lower_workspace(ws: &Workspace) -> Result<String> {
|
||||
&module_ctor_index,
|
||||
&module_consts,
|
||||
import_map,
|
||||
alloc,
|
||||
);
|
||||
emitter
|
||||
.emit_module()
|
||||
@@ -354,7 +398,11 @@ pub fn lower_workspace(ws: &Workspace) -> Result<String> {
|
||||
|
||||
out.push_str("declare i32 @printf(ptr, ...)\n");
|
||||
out.push_str("declare i32 @puts(ptr)\n");
|
||||
out.push_str("declare ptr @GC_malloc(i64)\n");
|
||||
// Bench iter: the allocator declaration name follows `alloc`.
|
||||
// Default `Gc` keeps the emitted IR byte-identical to the pre-bench
|
||||
// pipeline; `Bump` declares `@bump_malloc` instead, supplied by
|
||||
// `runtime/bump.c` and linked in lieu of `-lgc`.
|
||||
out.push_str(&format!("declare ptr @{}(i64)\n", alloc.fn_name()));
|
||||
// Iter 16e: `==` on `Str` lowers to `@strcmp` followed by
|
||||
// `icmp eq i32 0`. NUL-terminated strings make this a one-liner;
|
||||
// libc supplies `strcmp` so no extra link flag is needed.
|
||||
@@ -465,6 +513,10 @@ struct Emitter<'a> {
|
||||
/// Populated by `analyze_fn_body` at the start of `emit_fn` and at
|
||||
/// the start of every lambda thunk emission inside `lower_lambda`.
|
||||
non_escape: NonEscapeSet,
|
||||
/// Bench iter: which allocator the heap-allocation paths target.
|
||||
/// Decided at the top-level entry point (`lower_workspace_inner`)
|
||||
/// and propagated to every site that emits a `call ptr @<alloc>(...)`.
|
||||
alloc: AllocStrategy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -514,6 +566,7 @@ impl<'a> Emitter<'a> {
|
||||
module_ctor_index: &'a BTreeMap<String, BTreeMap<String, CtorRef>>,
|
||||
module_consts: &'a BTreeMap<String, BTreeMap<String, ConstDef>>,
|
||||
import_map: BTreeMap<String, String>,
|
||||
alloc: AllocStrategy,
|
||||
) -> Self {
|
||||
let mut types: BTreeMap<String, Vec<CtorInfo>> = BTreeMap::new();
|
||||
for def in &module.defs {
|
||||
@@ -568,6 +621,7 @@ impl<'a> Emitter<'a> {
|
||||
lam_counter: 0,
|
||||
deferred_thunks: Vec::new(),
|
||||
non_escape: NonEscapeSet::new(),
|
||||
alloc,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1296,7 +1350,8 @@ impl<'a> Emitter<'a> {
|
||||
));
|
||||
} else {
|
||||
self.body.push_str(&format!(
|
||||
" {p} = call ptr @GC_malloc(i64 {size_bytes})\n"
|
||||
" {p} = call ptr @{}(i64 {size_bytes})\n",
|
||||
self.alloc.fn_name()
|
||||
));
|
||||
}
|
||||
// Write tag.
|
||||
@@ -2093,7 +2148,8 @@ impl<'a> Emitter<'a> {
|
||||
));
|
||||
} else {
|
||||
self.body.push_str(&format!(
|
||||
" {env} = call ptr @GC_malloc(i64 {env_size})\n"
|
||||
" {env} = call ptr @{}(i64 {env_size})\n",
|
||||
self.alloc.fn_name()
|
||||
));
|
||||
}
|
||||
for (i, (_cname, outer_ssa, cty, _c_ail, _sig)) in cap_meta.iter().enumerate() {
|
||||
@@ -2116,8 +2172,10 @@ impl<'a> Emitter<'a> {
|
||||
self.body
|
||||
.push_str(&format!(" {clos} = alloca i8, i64 16, align 8\n"));
|
||||
} else {
|
||||
self.body
|
||||
.push_str(&format!(" {clos} = call ptr @GC_malloc(i64 16)\n"));
|
||||
self.body.push_str(&format!(
|
||||
" {clos} = call ptr @{}(i64 16)\n",
|
||||
self.alloc.fn_name()
|
||||
));
|
||||
}
|
||||
let cs_t = self.fresh_ssa();
|
||||
self.body.push_str(&format!(
|
||||
|
||||
Reference in New Issue
Block a user