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:
Executable
+169
@@ -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"
|
||||||
+108
-17
@@ -125,6 +125,12 @@ enum Cmd {
|
|||||||
/// Optimization (e.g. `-O2`); default `-O0` for debuggability.
|
/// Optimization (e.g. `-O2`); default `-O0` for debuggability.
|
||||||
#[arg(long, default_value = "-O0")]
|
#[arg(long, default_value = "-O0")]
|
||||||
opt: String,
|
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.
|
/// Build into a tempdir and execute. Exits with the binary's exit code.
|
||||||
/// Convenience wrapper around `build` + invocation of the resulting
|
/// Convenience wrapper around `build` + invocation of the resulting
|
||||||
@@ -135,6 +141,9 @@ enum Cmd {
|
|||||||
/// Optimization (e.g. `-O2`); default `-O0` for debuggability.
|
/// Optimization (e.g. `-O2`); default `-O0` for debuggability.
|
||||||
#[arg(long, default_value = "-O0")]
|
#[arg(long, default_value = "-O0")]
|
||||||
opt: String,
|
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.
|
/// Args passed through to the compiled program.
|
||||||
#[arg(last = true)]
|
#[arg(last = true)]
|
||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
@@ -427,21 +436,23 @@ fn main() -> Result<()> {
|
|||||||
None => print!("{ir}"),
|
None => print!("{ir}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Cmd::Build { path, out, opt } => {
|
Cmd::Build { path, out, opt, alloc } => {
|
||||||
let bin = build_to(&path, out, &opt)?;
|
let strategy = parse_alloc_strategy(&alloc)?;
|
||||||
|
let bin = build_to(&path, out, &opt, strategy)?;
|
||||||
eprintln!("built {}", bin.display());
|
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
|
// Iter 9b: build into a fresh tempdir per run, exec, propagate
|
||||||
// exit code. The artefact dir is left around (no cleanup) so
|
// exit code. The artefact dir is left around (no cleanup) so
|
||||||
// it can be inspected in case of a crash; OS temp policy
|
// it can be inspected in case of a crash; OS temp policy
|
||||||
// collects them.
|
// collects them.
|
||||||
|
let strategy = parse_alloc_strategy(&alloc)?;
|
||||||
let tmpdir = std::env::temp_dir().join(format!(
|
let tmpdir = std::env::temp_dir().join(format!(
|
||||||
"ailang-run-{}",
|
"ailang-run-{}",
|
||||||
std::process::id()
|
std::process::id()
|
||||||
));
|
));
|
||||||
std::fs::create_dir_all(&tmpdir)?;
|
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)
|
let status = std::process::Command::new(&bin)
|
||||||
.args(&args)
|
.args(&args)
|
||||||
.status()
|
.status()
|
||||||
@@ -1507,6 +1518,49 @@ fn render_workspace_diff_text(r: &WorkspaceDiffReport) -> String {
|
|||||||
out
|
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`.
|
/// Iter 9b: shared build helper for `Cmd::Build` and `Cmd::Run`.
|
||||||
/// Loads the workspace, runs the typechecker, emits IR, and links via
|
/// Loads the workspace, runs the typechecker, emits IR, and links via
|
||||||
/// clang. On typecheck failure, prints diagnostics to stderr and exits
|
/// 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
|
/// LetRecs that capture `Term::Let`-bound names, whose types are
|
||||||
/// only known after typecheck). The lifted workspace then goes to
|
/// only known after typecheck). The lifted workspace then goes to
|
||||||
/// codegen unchanged.
|
/// 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 ws = ailang_core::load_workspace(path)?;
|
||||||
let diags = ailang_check::check_workspace(&ws);
|
let diags = ailang_check::check_workspace(&ws);
|
||||||
if !diags.is_empty() {
|
if !diags.is_empty() {
|
||||||
@@ -1556,23 +1621,49 @@ fn build_to(path: &Path, out: Option<PathBuf>, opt: &str) -> Result<PathBuf> {
|
|||||||
modules: lifted_modules,
|
modules: lifted_modules,
|
||||||
root_dir: ws.root_dir.clone(),
|
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()));
|
let tmpdir = std::env::temp_dir().join(format!("ailang-{}", std::process::id()));
|
||||||
std::fs::create_dir_all(&tmpdir)?;
|
std::fs::create_dir_all(&tmpdir)?;
|
||||||
let ll_path = tmpdir.join(format!("{}.ll", ws.entry));
|
let ll_path = tmpdir.join(format!("{}.ll", ws.entry));
|
||||||
std::fs::write(&ll_path, &ir)?;
|
std::fs::write(&ll_path, &ir)?;
|
||||||
let out_bin = out.unwrap_or_else(|| Path::new(".").join(&ws.entry).with_extension(""));
|
let out_bin = out.unwrap_or_else(|| Path::new(".").join(&ws.entry).with_extension(""));
|
||||||
let status = std::process::Command::new("clang")
|
let mut clang = std::process::Command::new("clang");
|
||||||
.arg(opt)
|
clang.arg(opt).arg("-o").arg(&out_bin).arg(&ll_path);
|
||||||
.arg("-o")
|
match alloc {
|
||||||
.arg(&out_bin)
|
ailang_codegen::AllocStrategy::Gc => {
|
||||||
.arg(&ll_path)
|
// Boehm conservative GC (Decision 9 / Iter 14f). The lowered
|
||||||
// Boehm conservative GC (Decision 9 / Iter 14f). The lowered IR
|
// IR calls @GC_malloc; libgc supplies it. Pthread/dl are
|
||||||
// calls @GC_malloc; libgc supplies it. Pthread/dl are pulled in
|
// pulled in transitively via libgc.so on Linux, so a single
|
||||||
// transitively via libgc.so on Linux, so a single -lgc suffices.
|
// -lgc suffices.
|
||||||
.arg("-lgc")
|
clang.arg("-lgc");
|
||||||
.status()
|
}
|
||||||
.context("running clang")?;
|
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() {
|
if !status.success() {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"clang failed (status {}); ll at {}",
|
"clang failed (status {}); ll at {}",
|
||||||
|
|||||||
@@ -110,6 +110,35 @@ pub enum CodegenError {
|
|||||||
|
|
||||||
type Result<T> = std::result::Result<T, 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`
|
/// 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
|
/// itself as the entry module. Returns the full LLVM IR text, ready to
|
||||||
/// be written to disk and handed to `clang`.
|
/// be written to disk and handed to `clang`.
|
||||||
@@ -145,6 +174,16 @@ pub fn emit_ir(m: &Module) -> Result<String> {
|
|||||||
lower_workspace(&ws)
|
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
|
/// Multi-module entry point. Lowers an entire [`Workspace`] (entry
|
||||||
/// module plus its transitive imports, as produced by
|
/// module plus its transitive imports, as produced by
|
||||||
/// `ailang_core::load_workspace`) to a single `.ll` string and emits
|
/// `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
|
/// Use [`emit_ir`] for the single-file shortcut when there are no
|
||||||
/// imports.
|
/// imports.
|
||||||
pub fn lower_workspace(ws: &Workspace) -> Result<String> {
|
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.
|
// Iter 16a: desugar every module before any lowering work runs.
|
||||||
// The pass is idempotent and structurally identical to what
|
// The pass is idempotent and structurally identical to what
|
||||||
// `ailang-check` runs at its public entries, so the codegen
|
// `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_ctor_index,
|
||||||
&module_consts,
|
&module_consts,
|
||||||
import_map,
|
import_map,
|
||||||
|
alloc,
|
||||||
);
|
);
|
||||||
emitter
|
emitter
|
||||||
.emit_module()
|
.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 @printf(ptr, ...)\n");
|
||||||
out.push_str("declare i32 @puts(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
|
// Iter 16e: `==` on `Str` lowers to `@strcmp` followed by
|
||||||
// `icmp eq i32 0`. NUL-terminated strings make this a one-liner;
|
// `icmp eq i32 0`. NUL-terminated strings make this a one-liner;
|
||||||
// libc supplies `strcmp` so no extra link flag is needed.
|
// 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
|
/// Populated by `analyze_fn_body` at the start of `emit_fn` and at
|
||||||
/// the start of every lambda thunk emission inside `lower_lambda`.
|
/// the start of every lambda thunk emission inside `lower_lambda`.
|
||||||
non_escape: NonEscapeSet,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -514,6 +566,7 @@ impl<'a> Emitter<'a> {
|
|||||||
module_ctor_index: &'a BTreeMap<String, BTreeMap<String, CtorRef>>,
|
module_ctor_index: &'a BTreeMap<String, BTreeMap<String, CtorRef>>,
|
||||||
module_consts: &'a BTreeMap<String, BTreeMap<String, ConstDef>>,
|
module_consts: &'a BTreeMap<String, BTreeMap<String, ConstDef>>,
|
||||||
import_map: BTreeMap<String, String>,
|
import_map: BTreeMap<String, String>,
|
||||||
|
alloc: AllocStrategy,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let mut types: BTreeMap<String, Vec<CtorInfo>> = BTreeMap::new();
|
let mut types: BTreeMap<String, Vec<CtorInfo>> = BTreeMap::new();
|
||||||
for def in &module.defs {
|
for def in &module.defs {
|
||||||
@@ -568,6 +621,7 @@ impl<'a> Emitter<'a> {
|
|||||||
lam_counter: 0,
|
lam_counter: 0,
|
||||||
deferred_thunks: Vec::new(),
|
deferred_thunks: Vec::new(),
|
||||||
non_escape: NonEscapeSet::new(),
|
non_escape: NonEscapeSet::new(),
|
||||||
|
alloc,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1296,7 +1350,8 @@ impl<'a> Emitter<'a> {
|
|||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
self.body.push_str(&format!(
|
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.
|
// Write tag.
|
||||||
@@ -2093,7 +2148,8 @@ impl<'a> Emitter<'a> {
|
|||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
self.body.push_str(&format!(
|
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() {
|
for (i, (_cname, outer_ssa, cty, _c_ail, _sig)) in cap_meta.iter().enumerate() {
|
||||||
@@ -2116,8 +2172,10 @@ impl<'a> Emitter<'a> {
|
|||||||
self.body
|
self.body
|
||||||
.push_str(&format!(" {clos} = alloca i8, i64 16, align 8\n"));
|
.push_str(&format!(" {clos} = alloca i8, i64 16, align 8\n"));
|
||||||
} else {
|
} else {
|
||||||
self.body
|
self.body.push_str(&format!(
|
||||||
.push_str(&format!(" {clos} = call ptr @GC_malloc(i64 16)\n"));
|
" {clos} = call ptr @{}(i64 16)\n",
|
||||||
|
self.alloc.fn_name()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let cs_t = self.fresh_ssa();
|
let cs_t = self.fresh_ssa();
|
||||||
self.body.push_str(&format!(
|
self.body.push_str(&format!(
|
||||||
|
|||||||
+193
@@ -5129,3 +5129,196 @@ the Observations section above are explicitly NOT queued —
|
|||||||
they require user sign-off on whether the project's GC
|
they require user sign-off on whether the project's GC
|
||||||
direction is "improve precision of stack-alloca", "replace
|
direction is "improve precision of stack-alloca", "replace
|
||||||
Boehm with a precise collector", or something else entirely.
|
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=<gc|bump>` 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.
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -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))))))
|
||||||
@@ -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"}
|
||||||
@@ -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))))))
|
||||||
@@ -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 <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
#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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user