From 65e280bb705a8e19ee341c9663bb3a6b7e96862f Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 8 May 2026 00:06:20 +0200 Subject: [PATCH] Bench: GC overhead via bump-allocator comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds --alloc= 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) --- bench/run.sh | 169 ++++++++++++++++++++++++++ crates/ail/src/main.rs | 125 ++++++++++++++++--- crates/ailang-codegen/src/lib.rs | 68 ++++++++++- docs/JOURNAL.md | 193 ++++++++++++++++++++++++++++++ examples/bench_list_sum.ail.json | 1 + examples/bench_list_sum.ailx | 99 +++++++++++++++ examples/bench_tree_walk.ail.json | 1 + examples/bench_tree_walk.ailx | 78 ++++++++++++ runtime/bump.c | 40 +++++++ 9 files changed, 752 insertions(+), 22 deletions(-) create mode 100755 bench/run.sh create mode 100644 examples/bench_list_sum.ail.json create mode 100644 examples/bench_list_sum.ailx create mode 100644 examples/bench_tree_walk.ail.json create mode 100644 examples/bench_tree_walk.ailx create mode 100644 runtime/bump.c diff --git a/bench/run.sh b/bench/run.sh new file mode 100755 index 0000000..8d8c2d0 --- /dev/null +++ b/bench/run.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# +# GC-overhead bench harness (Bench iter). +# +# Builds each fixture twice — `--alloc=gc` (Boehm conservative GC) and +# `--alloc=bump` (no-free 256 MB arena from `runtime/bump.c`). Runs each +# binary N times, drops the slowest run, takes the median wall time. +# The bump number minus the gc number is the upper-bound cost of GC. +# +# Output: a table with gc-median, bump-median, overhead %, and max RSS +# for both modes. Designed to be captured verbatim into a JOURNAL entry. +# +# Requirements: bash, /usr/bin/time -v (GNU coreutils), bc, sort, awk, +# a release-mode `ail` binary. +# +# Usage: bench/run.sh [-n RUNS] +# -n RUNS number of timed runs per binary (default 5; min 3 so we +# can drop the slowest and still take a median over 4). + +set -euo pipefail + +RUNS=5 +while getopts "n:" opt; do + case $opt in + n) RUNS="$OPTARG" ;; + *) echo "usage: $0 [-n RUNS]" >&2; exit 2 ;; + esac +done + +if (( RUNS < 3 )); then + echo "RUNS must be >= 3" >&2 + exit 2 +fi + +# Anchor at the workspace root regardless of CWD. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT" + +# We measure wall-clock + max RSS via a small Python helper that wraps +# the binary, calls `time.monotonic()` around `os.waitpid`, and reads +# `getrusage(RUSAGE_CHILDREN).ru_maxrss` (KB on Linux). This avoids a +# /usr/bin/time dependency (Arch / minimal containers often don't have +# the GNU coreutils `time` binary installed) without sacrificing +# either signal: monotonic clocks for wall time, kernel-reported peak +# resident set for RSS. +PY="$(command -v python3 || true)" +if [[ -z "$PY" ]]; then + echo "error: python3 is required for the timing helper" >&2 + exit 2 +fi + +# Build the release `ail` binary if needed. +echo ">>> ensuring release ail binary" +cargo build --release -p ail >/dev/null +AIL="$ROOT/target/release/ail" +[[ -x "$AIL" ]] || { echo "ail binary missing: $AIL" >&2; exit 1; } + +OUTDIR="$ROOT/target/bench" +mkdir -p "$OUTDIR" + +# Compile both modes for both fixtures up front so the bench loop only +# measures runtime, not build time. +fixtures=(bench_list_sum bench_tree_walk) +modes=(gc bump) +echo ">>> compiling fixtures (-O2)" +for f in "${fixtures[@]}"; do + src="$ROOT/examples/$f.ail.json" + [[ -f "$src" ]] || { echo "missing fixture: $src" >&2; exit 1; } + for m in "${modes[@]}"; do + bin="$OUTDIR/${f}_${m}" + echo " $f --alloc=$m -> $bin" + "$AIL" build --opt=-O2 --alloc="$m" "$src" -o "$bin" >/dev/null + done +done + +# Time one binary one time. Wraps the binary in a Python helper that +# measures wall-clock via time.monotonic() and max RSS (KB) via +# getrusage(RUSAGE_CHILDREN).ru_maxrss after the child exits. Stdout +# of the binary is discarded; we already verified correctness via a +# smoke run earlier. Output: "wall_seconds rss_kb" on a single line. +time_one() { + local bin="$1" + "$PY" -c ' +import os, resource, subprocess, sys, time +bin_path = sys.argv[1] +t0 = time.monotonic() +p = subprocess.Popen([bin_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +p.wait() +t1 = time.monotonic() +ru = resource.getrusage(resource.RUSAGE_CHILDREN) +# ru_maxrss is in KB on Linux. We want the peak across only this child; +# RUSAGE_CHILDREN is cumulative across all children of the helper, but +# the helper only spawns this one child per invocation, so the value is +# this run. +print(f"{t1 - t0:.3f} {ru.ru_maxrss}") +sys.exit(0 if p.returncode == 0 else 1) +' "$bin" +} + +# Run a binary RUNS times, drop the slowest run by wall time, return +# median of the rest as `wall rss` (rss = max across the kept runs). +median_run() { + local bin="$1" + local times=() + local rsses=() + for ((i = 0; i < RUNS; i++)); do + read -r w r < <(time_one "$bin") + times+=("$w") + rsses+=("$r") + done + # Compute index of slowest (largest wall) and drop it. + local slowest_idx=0 + for ((i = 1; i < ${#times[@]}; i++)); do + if [[ $(awk -v a="${times[$i]}" -v b="${times[$slowest_idx]}" 'BEGIN { print (a > b) ? 1 : 0 }') == 1 ]]; then + slowest_idx=$i + fi + done + local kept_t=() + local kept_r=() + for ((i = 0; i < ${#times[@]}; i++)); do + if [[ $i -ne $slowest_idx ]]; then + kept_t+=("${times[$i]}") + kept_r+=("${rsses[$i]}") + fi + done + # Median wall over kept runs. + local sorted_t + sorted_t=$(printf "%s\n" "${kept_t[@]}" | sort -g) + local n=${#kept_t[@]} + local mid=$((n / 2)) + local median_t + if (( n % 2 == 1 )); then + median_t=$(echo "$sorted_t" | sed -n "$((mid + 1))p") + else + local a b + a=$(echo "$sorted_t" | sed -n "${mid}p") + b=$(echo "$sorted_t" | sed -n "$((mid + 1))p") + median_t=$(awk -v a="$a" -v b="$b" 'BEGIN { printf "%.3f", (a + b) / 2 }') + fi + # Max RSS across kept runs (peak memory is the natural per-run agg). + local max_r=0 + for r in "${kept_r[@]}"; do + if (( r > max_r )); then max_r=$r; fi + done + printf "%s %s\n" "$median_t" "$max_r" +} + +echo +echo ">>> timing (RUNS=$RUNS, drop slowest, median of $((RUNS - 1)))" +echo + +# Header. +printf "%-22s | %12s | %12s | %12s | %14s | %14s\n" \ + "workload" "gc median(s)" "bump median(s)" "overhead %" "gc max RSS(KB)" "bump max RSS(KB)" +printf -- "-----------------------+--------------+--------------+--------------+----------------+----------------\n" + +for f in "${fixtures[@]}"; do + read -r gc_t gc_r < <(median_run "$OUTDIR/${f}_gc") + read -r bp_t bp_r < <(median_run "$OUTDIR/${f}_bump") + # overhead = (gc - bump) / bump * 100. Negative would mean GC + # faster, which would itself be a finding worth reporting. + overhead=$(awk -v g="$gc_t" -v b="$bp_t" 'BEGIN { printf "%.1f", (g - b) / b * 100 }') + printf "%-22s | %12s | %12s | %12s | %14s | %14s\n" \ + "$f" "$gc_t" "$bp_t" "$overhead" "$gc_r" "$bp_r" +done + +echo +echo ">>> done" diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index b3aba54..b80701f 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -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, @@ -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 { + 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 { + // 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, opt: &str) -> Result { +/// +/// 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, + opt: &str, + alloc: ailang_codegen::AllocStrategy, +) -> Result { 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, opt: &str) -> Result { 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 + // /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 {}", diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index ee8a4aa..d3b0c41 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -110,6 +110,35 @@ pub enum CodegenError { type Result = std::result::Result; +/// 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 { 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 { + 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 { /// Use [`emit_ir`] for the single-file shortcut when there are no /// imports. pub fn lower_workspace(ws: &Workspace) -> Result { + lower_workspace_inner(ws, AllocStrategy::Gc) +} + +fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy) -> Result { // 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 { &module_ctor_index, &module_consts, import_map, + alloc, ); emitter .emit_module() @@ -354,7 +398,11 @@ pub fn lower_workspace(ws: &Workspace) -> Result { 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: AllocStrategy, } #[derive(Debug, Clone)] @@ -514,6 +566,7 @@ impl<'a> Emitter<'a> { module_ctor_index: &'a BTreeMap>, module_consts: &'a BTreeMap>, import_map: BTreeMap, + alloc: AllocStrategy, ) -> Self { let mut types: BTreeMap> = 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!( diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 4b85023..9b9df10 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -5129,3 +5129,196 @@ the Observations section above are explicitly NOT queued — they require user sign-off on whether the project's GC direction is "improve precision of stack-alloca", "replace Boehm with a precise collector", or something else entirely. + +## Bench — GC overhead via bump-allocator comparison + +Single-purpose data-gathering iter, not a feature. Goal: quantify +how much of the runtime spent by AILang programs is paid to the +Boehm conservative collector by comparing the same program built +two ways — `--alloc=gc` (default, current behavior, links `-lgc`) +and `--alloc=bump` (a no-free 256 MB statically-allocated bump +arena from `runtime/bump.c`). The IR text for the two builds is +byte-identical except that every `@GC_malloc` callsite and the +`declare ptr @GC_malloc(i64)` declaration become `@bump_malloc`. +The link command swaps `-lgc` for `runtime/bump.o`. Nothing else +changes. + +### Methodology + +Two fixtures, both designed to drive heap allocation hard enough +that the collector / arena is on the hot path: + +- **`examples/bench_list_sum.ail.json`**. Local `IntList` ADT. + Builds three lists (lengths 100k / 1M / 3M) by tail-recursive + `cons_n_acc`, sums each via tail-recursive `sum_acc`, prints + the three sums. Both build and sum are written in accumulator + form with `tail-app` because at 3M elements a non-tail + recursion overflows the 8 MB system stack. Each `ICons` cell is + 24 B; total heap traffic ≈ 99 MB across the run (the bump + arena's 256 MB ceiling was the constraint that capped the + largest size at 3M, not 10M). +- **`examples/bench_tree_walk.ail.json`**. Local `Tree` ADT + (`Leaf | Node Int Tree Tree`). Builds and sums balanced trees + of depth 16 / 18 / 20. At depth 20 the tree has 2^20 − 1 nodes, + ~32 B per `Node`, ~64 MB heap traffic for the depth-20 phase + alone. Recursion in `build_tree` / `sum_tree` is constructor- + blocked so it cannot be `tail-app`'d, but the recursion depth + equals the tree depth (≤ 20), so it fits trivially. + +Build configuration: `clang -O2`, both modes. The harness +(`bench/run.sh`) runs each binary 5 times under a Python wrapper +that reads `getrusage(RUSAGE_CHILDREN).ru_maxrss` for peak RSS +and `time.monotonic()` deltas around `subprocess.Popen.wait` for +wall time. Slowest run is dropped, median wall over the kept 4 is +reported. The harness runs `cargo build --release -p ail` first, +then compiles each `(fixture, mode)` pair once before the timing +loop, so build time is excluded from measurements. + +### Numbers (Linux 7.0.3-1-cachyos, single machine, RUNS=5) + +``` +workload | gc median(s) | bump median(s) | overhead % | gc max RSS(KB) | bump max RSS(KB) +-----------------------+--------------+--------------+--------------+----------------+---------------- +bench_list_sum | 0.145 | 0.050 | 190.0 | 103788 | 97640 +bench_tree_walk | 0.105 | 0.038 | 176.3 | 73452 | 55452 +``` + +A second run with RUNS=9 (median of 8) corroborates within noise: + +``` +bench_list_sum | 0.141 | 0.048 | 193.7 | 103784 | 97884 +bench_tree_walk | 0.103 | 0.039 | 164.1 | 73448 | 55396 +``` + +Overhead = `(gc - bump) / bump * 100` — i.e. the GC-mode runtime is +~2.7–2.9× the bump-mode runtime. Equivalently, ~63–65 % of the +GC-mode wall time is GC overhead (collector pauses + write +barriers + allocation-path complexity vs. a single bump pointer). + +### Bucket + +**Large.** GC takes roughly two-thirds of total runtime on these +allocation-heavy workloads. For comparison, the typical Boehm +conservative-GC overhead reported in the literature on +allocation-heavy workloads sits in the 20–60 % range; ~190 % puts +this firmly past that envelope. Caveat below. + +### Caveats + +- **Single-machine measurement.** No cross-machine confirmation, + no isolation from background load. Variance across the kept-4 + runs was ≤ 5 ms in absolute terms, but a bigger machine / + smaller machine / different libgc version could shift these + numbers materially. +- **Allocation-heavy workloads.** Both fixtures spend almost their + entire runtime in the allocator (Cons cell construction, Node + cell construction). Real programs that compute as well as + allocate would have a smaller GC-overhead share. The numbers + here are therefore an *upper bound* on the GC's share of any + realistic workload. +- **Bump leaks everything.** The bump-mode binary never frees a + byte; max RSS reflects the working-set after every allocation + the program ever made, plus committed pages from the 256 MB + arena. For `bench_list_sum`'s 99 MB heap traffic, GC's heap + (~100 MB RSS) is essentially identical to bump's (~97 MB). + Where the workload actually leaks past bump's arena (~256 MB + cells × any factor), GC would win on RSS by reusing freed + memory; this bench does not exhibit that regime. +- **No warmup theatrics.** Each timed run is a cold process start. + AILang has no JIT and no per-process allocation-path tuning, + so first-run / steady-state distinction does not apply here. + Variance was within noise even on the first kept run. +- **Hardcoded N.** No env-var / argv plumbing in AILang yet, so + the workload sizes are baked into the source. The three sizes + per fixture provide enough variety to detect a wildly + size-dependent overhead (none observed — both fixtures show a + flat ~2.8x ratio across all three calls). +- **The bench measures `GC_malloc` overhead, not full GC.** Boehm's + collector runs inline on allocation when the heap grows past a + threshold; we never observe it as a separate cost. A program + with a long-lived heap that causes repeated full marks would + see a different (likely larger) overhead share. Neither fixture + here triggers that. + +### Implementation summary + +- `crates/ailang-codegen/src/lib.rs`: new public `AllocStrategy` + enum (`Gc` / `Bump`); new public entry `lower_workspace_with_alloc`; + `lower_workspace` delegates to it with `Gc`. The single + declaration line and the three `@GC_malloc` callsites + (`lower_ctor`, lambda env, closure pair) all read the + Emitter's `alloc` field. +- `crates/ail/src/main.rs`: `--alloc=` flag added to + both `build` and `run`, default `gc`. Threaded into a now-four- + arg `build_to`; on `Bump`, the helper `locate_bump_runtime()` + walks up from the binary path / cwd to find `runtime/bump.c`, + compiles it inline (`clang -O2 -c`) into a tempdir-scoped + `bump.o`, and links that instead of `-lgc`. +- `runtime/bump.c`: 256 MB static arena, single bump pointer, + 8-byte alignment, `abort()` on overflow. Single function + `void *bump_malloc(size_t)`. +- `examples/bench_list_sum.{ailx,ail.json}` and + `examples/bench_tree_walk.{ailx,ail.json}`: the two fixtures + described above. List builder rewritten to accumulator form + to fit in 8 MB stack at 3M elements. +- `bench/run.sh`: harness as specified. Python helper for + monotonic clock + RUSAGE_CHILDREN max RSS (avoids the + `/usr/bin/time` dependency, which is not on Arch by default). + `awk` replaces `bc` for the same reason. +- `docs/DESIGN.md` not touched. The CLI flag is opt-in, the + default behavior is identical to pre-bench, and the bump path + is bench-only — it does not deserve language-spec status. + +### Cross-iter regression verified + +- Default `--alloc=gc` is byte-identical to pre-bench. The five + IR snapshots (`hello`, `sum`, `list`, `max3`, `ws_main`) pass + unchanged. All workspace tests pass: 141 → 141 (no test + count delta from this iter; no e2e additions). +- Manual smoke run of representative existing fixtures + (`sum`, `list`, `list_map`, `gc_stress`, `std_list_demo`, + `escape_local_demo`) under `--alloc=gc` produces identical + stdout to the documented expected outputs. +- The bump-mode IR, after a textual `s/GC_malloc/bump_malloc/g` + on the gc-mode IR, is `diff`-clean against a real `--alloc=bump` + build. The IR is byte-identical except for the allocator + symbol name. + +### Did anything surprise + +- **The overhead is large.** ~2.8x slowdown is at the high end of + what one expects for a modern conservative collector on + allocation-heavy code. Two factors likely contributing: (a) + Boehm's `GC_malloc` does conservative root scanning of the + C stack on every collection — for workloads that allocate + heavily, the collector triggers often; (b) AILang's escape + analysis (Iter 17a) flags 0 of 270 ctor sites in shipped code + as non-escaping, and 0 of the allocations in either bench + fixture, so the entire allocation traffic goes through the + collector. Workloads that converted more allocations to + `alloca` would see a smaller GC share. +- **No segfault from the bump leak.** The bump arena is + 256 MB; the heaviest workload (3M-element list) consumes + ~99 MB. We have headroom even at the largest configured size. + 10M elements (the original spec value) would have been + 240 MB — uncomfortably close to the ceiling, justified the + reduction to 3M. +- **GC's max RSS is barely larger than bump's.** I expected GC's + max RSS to be substantially smaller than bump's (because GC + reclaims dead memory). It isn't — the bump fixtures' working + sets are simply not large enough to pressure the collector + into reclaiming much. The list fixture builds the entire + 3M-element list before summing, so all allocations are live + at once anyway. Different workloads (e.g. a fold that builds + intermediate lists discarded between iterations) would surface + the RSS gap. +- **Tail-call discipline matters.** The original spec's "tail- + recursive sum" is a misnomer for `sum_list (Cons h t) = h + + sum_list t` — that's constructor-blocked, not tail-recursive. + Naïvely transcribing the spec produced a binary that + segfaulted at 3M elements. Both fixtures' linear-recursion fns + had to be rewritten in accumulator form with explicit + `tail-app` markers. Captured here because it is a real + consequence of how AILang is structured: an LLM author who + ports a textbook recursive sum into AILang at scale will + hit the stack ceiling unless they know about Decision 8. diff --git a/examples/bench_list_sum.ail.json b/examples/bench_list_sum.ail.json new file mode 100644 index 0000000..cf0aad3 --- /dev/null +++ b/examples/bench_list_sum.ail.json @@ -0,0 +1 @@ +{"defs":[{"ctors":[{"fields":[],"name":"INil"},{"fields":[{"k":"con","name":"Int"},{"k":"con","name":"IntList"}],"name":"ICons"}],"kind":"type","name":"IntList"},{"body":{"cond":{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":0},"t":"lit"}],"fn":{"name":"==","t":"var"},"t":"app"},"else":{"args":[{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"-","t":"var"},"t":"app"},{"args":[{"args":[{"name":"n","t":"var"},{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"-","t":"var"},"t":"app"},{"name":"acc","t":"var"}],"ctor":"ICons","t":"ctor","type":"IntList"}],"fn":{"name":"cons_n_acc","t":"var"},"t":"app","tail":true},"t":"if","then":{"name":"acc","t":"var"}},"doc":"Tail-recursive list builder. Result = accumulator-prepended list.","kind":"fn","name":"cons_n_acc","params":["n","acc"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Int"},{"k":"con","name":"IntList"}],"ret":{"k":"con","name":"IntList"}}},{"body":{"args":[{"name":"n","t":"var"},{"args":[],"ctor":"INil","t":"ctor","type":"IntList"}],"fn":{"name":"cons_n_acc","t":"var"},"t":"app"},"doc":"Build [0, 1, ..., n-1] :: IntList. Order doesn't matter for sum.","kind":"fn","name":"cons_n","params":["n"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Int"}],"ret":{"k":"con","name":"IntList"}}},{"body":{"arms":[{"body":{"name":"acc","t":"var"},"pat":{"ctor":"INil","fields":[],"p":"ctor"}},{"body":{"args":[{"name":"t","t":"var"},{"args":[{"name":"acc","t":"var"},{"name":"h","t":"var"}],"fn":{"name":"+","t":"var"},"t":"app"}],"fn":{"name":"sum_acc","t":"var"},"t":"app","tail":true},"pat":{"ctor":"ICons","fields":[{"name":"h","p":"var"},{"name":"t","p":"var"}],"p":"ctor"}}],"scrutinee":{"name":"xs","t":"var"},"t":"match"},"doc":"Tail-recursive sum.","kind":"fn","name":"sum_acc","params":["xs","acc"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"IntList"},{"k":"con","name":"Int"}],"ret":{"k":"con","name":"Int"}}},{"body":{"args":[{"name":"xs","t":"var"},{"lit":{"kind":"int","value":0},"t":"lit"}],"fn":{"name":"sum_acc","t":"var"},"t":"app"},"doc":"Sum every element. Calls sum_acc with seed 0.","kind":"fn","name":"sum_list","params":["xs"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"IntList"}],"ret":{"k":"con","name":"Int"}}},{"body":{"args":[{"args":[{"args":[{"name":"n","t":"var"}],"fn":{"name":"cons_n","t":"var"},"t":"app"}],"fn":{"name":"sum_list","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"doc":"Build a list of length n, sum it, print the sum.","kind":"fn","name":"run_one","params":["n"],"type":{"effects":["IO"],"k":"fn","params":[{"k":"con","name":"Int"}],"ret":{"k":"con","name":"Unit"}}},{"body":{"lhs":{"args":[{"lit":{"kind":"int","value":100000},"t":"lit"}],"fn":{"name":"run_one","t":"var"},"t":"app"},"rhs":{"lhs":{"args":[{"lit":{"kind":"int","value":1000000},"t":"lit"}],"fn":{"name":"run_one","t":"var"},"t":"app"},"rhs":{"args":[{"lit":{"kind":"int","value":3000000},"t":"lit"}],"fn":{"name":"run_one","t":"var"},"t":"app"},"t":"seq"},"t":"seq"},"kind":"fn","name":"main","params":[],"type":{"effects":["IO"],"k":"fn","params":[],"ret":{"k":"con","name":"Unit"}}}],"imports":[],"name":"bench_list_sum","schema":"ailang/v0"} \ No newline at end of file diff --git a/examples/bench_list_sum.ailx b/examples/bench_list_sum.ailx new file mode 100644 index 0000000..21f9e97 --- /dev/null +++ b/examples/bench_list_sum.ailx @@ -0,0 +1,99 @@ +; Bench fixture (Bench iter): linked-list build + sum. +; +; Drives the heap allocator hard via a recursive Cons spine. Every +; ICons cell is one allocation (24 bytes: tag + Int payload + tail). +; A list of length N therefore costs N allocations. We invoke the +; workload at three different sizes within a single program run to +; cover small / medium / large territory. +; +; Both `cons_n` (build) and `sum_list` (traverse) are written in +; accumulator form so the recursive call sits in tail position and +; can be marked `tail-app`. This is essential at the sizes used here: +; without `musttail`, three million stack frames overflow the default +; thread stack and segfault. +; +; Workload sizes (hardcoded — AILang has no env-var/argv pipeline): +; +; 100_000 * 24 B = 2.4 MB +; 1_000_000 * 24 B = 24 MB +; 3_000_000 * 24 B = 72 MB +; +; (3M was chosen as the largest size that comfortably fits inside the +; bump allocator's 256 MB arena with headroom for closure pairs and +; misc allocations.) +; +; Build is allocation-heavy; sum is pure traversal of already-allocated +; heap (the interesting one for GC pressure / barrier overhead). +; +; Expected stdout (one int per line, the sum 0+1+...+(N-1) = N*(N-1)/2): +; 100_000 -> 4999950000 +; 1_000_000 -> 499999500000 +; 3_000_000 -> 4499998500000 + +(module bench_list_sum + + (data IntList + (ctor INil) + (ctor ICons (con Int) (con IntList))) + + (fn cons_n_acc + (doc "Tail-recursive list builder. Result = accumulator-prepended list.") + (type + (fn-type + (params (con Int) (con IntList)) + (ret (con IntList)))) + (params n acc) + (body + (if (app == n 0) + acc + (tail-app cons_n_acc + (app - n 1) + (term-ctor IntList ICons (app - n 1) acc))))) + + (fn cons_n + (doc "Build [0, 1, ..., n-1] :: IntList. Order doesn't matter for sum.") + (type + (fn-type + (params (con Int)) + (ret (con IntList)))) + (params n) + (body + (app cons_n_acc n (term-ctor IntList INil)))) + + (fn sum_acc + (doc "Tail-recursive sum.") + (type + (fn-type + (params (con IntList) (con Int)) + (ret (con Int)))) + (params xs acc) + (body + (match xs + (case (pat-ctor INil) acc) + (case (pat-ctor ICons h t) + (tail-app sum_acc t (app + acc h)))))) + + (fn sum_list + (doc "Sum every element. Calls sum_acc with seed 0.") + (type + (fn-type + (params (con IntList)) + (ret (con Int)))) + (params xs) + (body + (app sum_acc xs 0))) + + (fn run_one + (doc "Build a list of length n, sum it, print the sum.") + (type (fn-type (params (con Int)) (ret (con Unit)) (effects IO))) + (params n) + (body + (do io/print_int (app sum_list (app cons_n n))))) + + (fn main + (type (fn-type (params) (ret (con Unit)) (effects IO))) + (params) + (body + (seq (app run_one 100000) + (seq (app run_one 1000000) + (app run_one 3000000)))))) diff --git a/examples/bench_tree_walk.ail.json b/examples/bench_tree_walk.ail.json new file mode 100644 index 0000000..2a698fb --- /dev/null +++ b/examples/bench_tree_walk.ail.json @@ -0,0 +1 @@ +{"defs":[{"ctors":[{"fields":[],"name":"Leaf"},{"fields":[{"k":"con","name":"Int"},{"k":"con","name":"Tree"},{"k":"con","name":"Tree"}],"name":"Node"}],"kind":"type","name":"Tree"},{"body":{"cond":{"args":[{"name":"depth","t":"var"},{"lit":{"kind":"int","value":0},"t":"lit"}],"fn":{"name":"==","t":"var"},"t":"app"},"else":{"args":[{"lit":{"kind":"int","value":1},"t":"lit"},{"args":[{"args":[{"name":"depth","t":"var"},{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"-","t":"var"},"t":"app"}],"fn":{"name":"build_tree","t":"var"},"t":"app"},{"args":[{"args":[{"name":"depth","t":"var"},{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"-","t":"var"},"t":"app"}],"fn":{"name":"build_tree","t":"var"},"t":"app"}],"ctor":"Node","t":"ctor","type":"Tree"},"t":"if","then":{"args":[],"ctor":"Leaf","t":"ctor","type":"Tree"}},"doc":"Balanced binary tree of given depth, every value = 1.","kind":"fn","name":"build_tree","params":["depth"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Int"}],"ret":{"k":"con","name":"Tree"}}},{"body":{"arms":[{"body":{"lit":{"kind":"int","value":0},"t":"lit"},"pat":{"ctor":"Leaf","fields":[],"p":"ctor"}},{"body":{"args":[{"name":"v","t":"var"},{"args":[{"args":[{"name":"l","t":"var"}],"fn":{"name":"sum_tree","t":"var"},"t":"app"},{"args":[{"name":"r","t":"var"}],"fn":{"name":"sum_tree","t":"var"},"t":"app"}],"fn":{"name":"+","t":"var"},"t":"app"}],"fn":{"name":"+","t":"var"},"t":"app"},"pat":{"ctor":"Node","fields":[{"name":"v","p":"var"},{"name":"l","p":"var"},{"name":"r","p":"var"}],"p":"ctor"}}],"scrutinee":{"name":"t","t":"var"},"t":"match"},"doc":"Sum every Node value via match recursion. Constructor-blocked: not tail-recursive, but recursion depth = tree depth so fits.","kind":"fn","name":"sum_tree","params":["t"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Tree"}],"ret":{"k":"con","name":"Int"}}},{"body":{"args":[{"args":[{"args":[{"name":"depth","t":"var"}],"fn":{"name":"build_tree","t":"var"},"t":"app"}],"fn":{"name":"sum_tree","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"doc":"Build a tree of given depth, sum it, print the sum.","kind":"fn","name":"run_one","params":["depth"],"type":{"effects":["IO"],"k":"fn","params":[{"k":"con","name":"Int"}],"ret":{"k":"con","name":"Unit"}}},{"body":{"lhs":{"args":[{"lit":{"kind":"int","value":16},"t":"lit"}],"fn":{"name":"run_one","t":"var"},"t":"app"},"rhs":{"lhs":{"args":[{"lit":{"kind":"int","value":18},"t":"lit"}],"fn":{"name":"run_one","t":"var"},"t":"app"},"rhs":{"args":[{"lit":{"kind":"int","value":20},"t":"lit"}],"fn":{"name":"run_one","t":"var"},"t":"app"},"t":"seq"},"t":"seq"},"kind":"fn","name":"main","params":[],"type":{"effects":["IO"],"k":"fn","params":[],"ret":{"k":"con","name":"Unit"}}}],"imports":[],"name":"bench_tree_walk","schema":"ailang/v0"} \ No newline at end of file diff --git a/examples/bench_tree_walk.ailx b/examples/bench_tree_walk.ailx new file mode 100644 index 0000000..5ca0124 --- /dev/null +++ b/examples/bench_tree_walk.ailx @@ -0,0 +1,78 @@ +; Bench fixture (Bench iter): balanced-tree build + sum. +; +; Allocates a balanced binary tree of `Node value left right` cells, +; then walks it summing the value field. Distinct from +; `bench_list_sum` in two ways: +; 1. Each tree node has an additional pointer field versus a list +; cell — 32-byte alloc instead of 24-byte. The branching shape +; means the recursion structure is genuinely tree-shaped: the +; build cannot be made tail-recursive without explicit +; continuation passing, so this fixture is constrained to depths +; where the recursion stack fits. +; 2. The traversal pattern hits two children per node, exercising +; the GC's mark-phase pointer-chasing heuristics differently from +; a plain linked-list walk. +; +; Depth picked: 20 -> 2^20 - 1 = 1_048_575 nodes -> 32 MB heap usage. +; Recursion depth in `build_tree` and `sum_tree` matches `depth`, +; which fits comfortably in the default 8 MB system stack. +; +; Hardcoded multi-call form: build/sum the same tree thrice for +; signal averaging. The depths are different per call so the GC has +; to deal with three independent live-set sizes. +; +; Expected stdout (one int per line): +; depth 16: 2^16 - 1 = 65535 nodes, sum = 65535 +; depth 18: 2^18 - 1 = 262143 nodes, sum = 262143 +; depth 20: 2^20 - 1 = 1048575 nodes, sum = 1048575 +; +; (Each node stores literal `1`; sum is therefore node count.) + +(module bench_tree_walk + + (data Tree + (ctor Leaf) + (ctor Node (con Int) (con Tree) (con Tree))) + + (fn build_tree + (doc "Balanced binary tree of given depth, every value = 1.") + (type + (fn-type + (params (con Int)) + (ret (con Tree)))) + (params depth) + (body + (if (app == depth 0) + (term-ctor Tree Leaf) + (term-ctor Tree Node + 1 + (app build_tree (app - depth 1)) + (app build_tree (app - depth 1)))))) + + (fn sum_tree + (doc "Sum every Node value via match recursion. Constructor-blocked: not tail-recursive, but recursion depth = tree depth so fits.") + (type + (fn-type + (params (con Tree)) + (ret (con Int)))) + (params t) + (body + (match t + (case (pat-ctor Leaf) 0) + (case (pat-ctor Node v l r) + (app + v (app + (app sum_tree l) (app sum_tree r))))))) + + (fn run_one + (doc "Build a tree of given depth, sum it, print the sum.") + (type (fn-type (params (con Int)) (ret (con Unit)) (effects IO))) + (params depth) + (body + (do io/print_int (app sum_tree (app build_tree depth))))) + + (fn main + (type (fn-type (params) (ret (con Unit)) (effects IO))) + (params) + (body + (seq (app run_one 16) + (seq (app run_one 18) + (app run_one 20)))))) diff --git a/runtime/bump.c b/runtime/bump.c new file mode 100644 index 0000000..dc0d4a9 --- /dev/null +++ b/runtime/bump.c @@ -0,0 +1,40 @@ +/* Bench-only bump allocator stub. + * + * Used by `ail build --alloc=bump` (Bench iter) to A/B compare AILang + * binaries against the default Boehm-GC build. The whole runtime is a + * 256 MB statically-allocated arena and a single bump pointer; there + * is no `free`, no scan, no anything. If the workload exceeds 256 MB + * we abort — this is bench code, the right response to overflow is to + * notice and pick a smaller workload. + * + * The signature mirrors `GC_malloc` from libgc: `void *bump_malloc(size_t)`. + * The codegen replaces every `call ptr @GC_malloc` with + * `call ptr @bump_malloc` when `--alloc=bump` is set, so the AILang IR + * is otherwise byte-identical between the two strategies. + */ + +#include +#include +#include +#include + +#define ARENA_BYTES (256ul * 1024ul * 1024ul) + +static uint8_t arena[ARENA_BYTES]; +static size_t cursor = 0; + +void *bump_malloc(size_t n) { + /* Align bump pointer up to 8 bytes — AILang's allocations are all + * 8-byte aligned (tag + 8-byte fields, env pointers, closure pairs + * of two `ptr`s). Matches the alignment Boehm gives us. */ + size_t aligned = (cursor + 7ul) & ~((size_t)7ul); + if (aligned + n > ARENA_BYTES) { + fprintf(stderr, + "bump_malloc: arena exhausted (cursor=%zu, requested=%zu, arena=%zu)\n", + aligned, n, (size_t)ARENA_BYTES); + abort(); + } + void *p = (void *)(arena + aligned); + cursor = aligned + n; + return p; +}