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
+63 -5
View File
@@ -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!(