Files
borg/scripts/bench_chunker_kernels.py
Thomas WaldmannandClaude Fable 5.1 372a1c7b50 scripts: add bench_chunker_kernels.py, the chunkers x kernels benchmark table
Drives `borg benchmark cpu --chunking --json` once per scan kernel
combination and prints one throughput table over all chunkers and all
kernels this build and CPU can run, which is how the per-platform kernel
defaults were and are decided (#10160).

`borg benchmark cpu` reports a chunker it cannot construct - e.g. because
an unusable kernel was requested - as an {"error": ...} row and carries on
with exit code 0, so the script treats such a row as the run's failure;
that is also how it discovers which kernels exist (an invalid name makes
borg list the valid ones).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-02 02:08:48 +02:00

324 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
"""Benchmark every chunker scan kernel of a borg build and print a chunkers x kernels table.
borg picks a default scan kernel per platform (the *_kernel_default() functions of
the C kernels); BORG_FASTCDC_KERNEL / BORG_BUZHASH64_KERNEL / BORG_AES_CHUNKER_KERNEL
name another one. Which kernel is fastest is not predictable from the instruction set
- NEON beats the sequential loop on an Apple M3, while on a Zen 4 the sequential loop
beats AVX-512 - so the only way to know is to measure on the machine in question.
That is what this does.
It drives `borg benchmark cpu --chunking --json` once per kernel combination, so it
needs nothing but a `borg` binary in $PATH (or --borg PATH) and python3. It works on
arm64 and x86-64, on Linux, the BSDs and macOS; which kernels exist is discovered
from the binary, not assumed.
Usage:
scripts/bench_chunker_kernels.py # 3 repetitions, ~1 GiB per row
scripts/bench_chunker_kernels.py --reps 5
scripts/bench_chunker_kernels.py --smoke # tiny data, just checks it works
scripts/bench_chunker_kernels.py --json out.json # keep the raw samples too
Note on the data: `borg benchmark cpu` makes its own random buffer per process, so
every run chunks different (but equally random, equally large) bytes. Repetitions and
the median column absorb that; the reported spread says how much it mattered.
"""
import argparse
import json
import os
import platform
import re
import shutil
import statistics
import subprocess
import sys
import time
# Which env var selects the scan kernel of which chunker. A chunker that is in no
# group has no kernel to select and is reported in the "n/a" column.
KERNEL_ENVS = [
("BORG_FASTCDC_KERNEL", ["fastcdc"]),
("BORG_BUZHASH64_KERNEL", ["buzhash64"]),
("BORG_AES_CHUNKER_KERNEL", ["toeplitz-aes", "rabin-aes", "goldilocks-aes"]),
]
NO_KERNEL = "n/a" # column for chunkers that have no selectable kernel
# borg reports its kernels most-specialised first ("avx512,avx2,blockwise,scalar");
# reversed, the table reads left to right from the simplest kernel to the fanciest.
VALID_VALUES_RE = re.compile(r"Valid values:\s*(\S.*?)\s*$", re.MULTILINE)
# Keeps a probe run to 128 KiB and one iteration per row: enough to construct every
# chunker (which is what validates a kernel request) without benchmarking anything.
PROBE_ENV = {"_BORG_BENCHMARK_CPU_TEST": "1"}
def log(msg):
"""Progress goes to stderr, so that stdout stays a clean table."""
print(msg, file=sys.stderr, flush=True)
def cpu_model():
"""A human-readable CPU name, or the bare architecture if nothing better is found."""
system = platform.system()
try:
if system == "Darwin":
out = subprocess.check_output(["sysctl", "-n", "machdep.cpu.brand_string"], text=True)
return out.strip()
if system == "Linux":
with open("/proc/cpuinfo") as f:
for line in f:
# x86 says "model name", arm64 often only has "Model"/"CPU part"
if line.split(":")[0].strip() in ("model name", "Model"):
return line.split(":", 1)[1].strip()
else: # the BSDs
out = subprocess.check_output(["sysctl", "-n", "hw.model"], text=True)
return out.strip()
except (OSError, subprocess.SubprocessError, IndexError):
pass
return platform.processor() or platform.machine()
def run_borg(borg, extra_env, smoke):
"""One `borg benchmark cpu --chunking --json` run.
Returns (rows, error): rows is the parsed chunkers list on success, error is a
one-line reason on failure (an unsupported kernel, a borg too old, ...).
"""
env = dict(os.environ)
# start from a clean slate, so that a kernel var set in the caller's shell
# cannot silently apply to runs that mean to measure the default
for envvar, _ in KERNEL_ENVS:
env.pop(envvar, None)
env.pop("_BORG_BENCHMARK_CPU_TEST", None)
env.update(extra_env)
if smoke:
env.update(PROBE_ENV)
proc = subprocess.run(
[borg, "benchmark", "cpu", "--chunking", "--json"],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
output = proc.stdout + proc.stderr
if proc.returncode != 0:
return None, error_line(output, proc.returncode)
try:
# tolerate anything borg may print around the JSON document
start = proc.stdout.index("{")
doc, _ = json.JSONDecoder().raw_decode(proc.stdout[start:])
rows = doc["chunkers"]
except (ValueError, KeyError) as e:
return None, "could not parse the --json output (%s)" % e
# a chunker that could not be constructed (e.g. an unusable kernel was
# requested) is reported as an error row, with the benchmark going on
for row in rows:
if "error" in row:
return None, row["error"]
return rows, None
def error_line(output, returncode):
"""The most informative single line of a failed run."""
for line in output.splitlines():
# "Error:" alone is just the header borg prints above the real message
if "ValueError:" in line or ("Error:" in line and line.strip() != "Error:"):
return line.strip()
return "borg exited with rc=%d" % returncode
def discover_kernels(borg, envvar):
"""The kernels of <envvar> that this build has AND this CPU can run.
The build's list comes from borg itself: an impossible value makes it name the
valid ones. Each is then probed, because a kernel can be compiled in and still
be unusable here (an AVX-512 binary on a CPU without it), and a kernel that
cannot run must not enter the plan - borg refuses rather than falling back, so
it would take an entire run down with it.
"""
_, err = run_borg(borg, {envvar: "!invalid!"}, smoke=True)
if err is None:
log(" %s: this borg does not reject an invalid kernel - skipping" % envvar)
return []
match = VALID_VALUES_RE.search(err)
if not match:
log(" %s: not supported by this borg (%s)" % (envvar, err))
return []
names = [n.strip() for n in match.group(1).split(",") if n.strip()]
usable = []
for name in reversed(names): # simplest kernel first, see VALID_VALUES_RE
_, err = run_borg(borg, {envvar: name}, smoke=True)
if err is None:
usable.append(name)
else:
log(" %s=%s: unusable here (%s)" % (envvar, name, err))
log(" %s: %s" % (envvar, ", ".join(usable) if usable else "none usable"))
return usable
def build_plan(kernel_sets):
"""The runs of one repetition, as a list of {envvar: kernel} dicts.
The env vars are independent, so a single run measures one kernel of each of
them at once: as many runs as the longest kernel list, with the shorter lists
cycling. Every kernel is measured at least once per repetition.
"""
n_runs = max((len(ks) for ks in kernel_sets.values()), default=0)
plan = []
for i in range(n_runs):
plan.append({envvar: ks[i % len(ks)] for envvar, ks in kernel_sets.items() if ks})
return plan
def kernel_of(algo, env_of_algo, run_env):
"""Which kernel produced this chunker's row in this run."""
envvar = env_of_algo.get(algo)
return run_env.get(envvar, NO_KERNEL) if envvar else NO_KERNEL
def render(rows, columns, samples):
"""The chunkers x kernels table, throughput only."""
label_w = max(len(r) for r in rows)
cells = {}
for row in rows:
for col in columns:
values = samples.get((row, col))
cells[(row, col)] = "%.1f" % statistics.median(values) if values else "-"
widths = [max([len(col)] + [len(cells[(row, col)]) for row in rows]) for col in columns]
out = []
header = " " * label_w + " |" + "|".join(" %*s " % (w, c) for w, c in zip(widths, columns))
out.append(header)
out.append("-" * (label_w + 1) + "+" + "+".join("-" * (w + 2) for w in widths))
for row in rows:
line = "%-*s |" % (label_w, row)
line += "|".join(" %*s " % (w, cells[(row, col)]) for w, col in zip(widths, columns))
out.append(line)
return "\n".join(out)
def main():
parser = argparse.ArgumentParser(
description="Benchmark all chunker scan kernels of a borg build (throughput in MB/s).",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--borg", default=None, help="borg binary to drive (default: borg from $PATH)")
parser.add_argument("--reps", type=int, default=3, help="repetitions of the whole matrix (default: 3)")
parser.add_argument(
"--smoke",
action="store_true",
help="run on minimal data - checks that the matrix works, the numbers are meaningless",
)
parser.add_argument("--json", metavar="FILE", help="also write the raw samples (MB/s per run) there")
parser.add_argument(
"--warmup",
type=int,
default=1,
help="discarded runs before measuring, to let the CPU clock ramp up (default: 1)",
)
args = parser.parse_args()
borg = args.borg or shutil.which("borg")
if not borg:
log("no borg binary in $PATH - pass --borg PATH")
return 2
try:
version = subprocess.check_output([borg, "--version"], text=True).strip()
except (OSError, subprocess.SubprocessError) as e:
log("cannot run %s: %s" % (borg, e))
return 2
log("borg: %s (%s)" % (version, borg))
log("discovering kernels ...")
kernel_sets = {envvar: discover_kernels(borg, envvar) for envvar, _ in KERNEL_ENVS}
plan = build_plan(kernel_sets)
if not plan:
log("no selectable kernels at all - nothing to compare")
return 1
env_of_algo = {algo: envvar for envvar, algos in KERNEL_ENVS for algo in algos}
samples = {} # (row label, kernel) -> [MB/s, ...]
row_order = [] # chunker labels, in the order the benchmark reports them
started = time.time()
total = len(plan) * args.reps
done = 0
# A cold CPU is a slow CPU: the first runs of a session come out low while the
# clock ramps up, which skews whichever kernel happened to go first.
for i in range(args.warmup):
log("[warmup %d/%d]" % (i + 1, args.warmup))
run_borg(borg, plan[0], args.smoke)
for rep in range(1, args.reps + 1):
for run_env in plan:
done += 1
what = " ".join("%s=%s" % (k.split("_", 1)[1], v) for k, v in sorted(run_env.items()))
log("[%d/%d] %s" % (done, total, what))
rows, err = run_borg(borg, run_env, args.smoke)
if err:
log(" failed: %s" % err)
continue
for row in rows:
algo = row["algo"]
label = "%s,%s" % (algo, row["algo_params"]) if row.get("algo_params") else algo
if label not in row_order:
row_order.append(label)
mbs = row["size"] / row["time"] / 1e6
samples.setdefault((label, kernel_of(algo, env_of_algo, run_env)), []).append(mbs)
if not samples:
log("every run failed - no results")
return 1
# Columns in env var order, simplest kernel first, kernel-less chunkers last.
# Different env vars share kernel names (fastcdc and buzhash64 both have
# 'scalar'), and one column per name is enough: the rows keep them apart.
columns = []
for envvar, _ in KERNEL_ENVS:
columns.extend(k for k in kernel_sets[envvar] if k not in columns)
if any(col == NO_KERNEL for _, col in samples):
columns.append(NO_KERNEL)
title = "%s (%s, %s)" % (cpu_model(), platform.machine(), platform.system())
print(title)
print(
"%s, median of %d repetitions, MB/s%s"
% (version, args.reps, " [SMOKE - not real numbers]" if args.smoke else "")
)
print()
print(render(row_order, columns, samples))
spreads = [((max(v) - min(v)) / max(v), key) for key, v in samples.items() if len(v) > 1 and max(v) > 0]
if spreads:
worst, (row, col) = max(spreads)
print()
print(
"run-to-run spread: %.1f%% median, %.1f%% worst (%s / %s)"
% (100 * statistics.median(s for s, _ in spreads), 100 * worst, row, col)
)
log("total time: %.0fs" % (time.time() - started))
if args.json:
with open(args.json, "w") as f:
json.dump(
{
"cpu": title,
"borg": version,
"reps": args.reps,
"smoke": args.smoke,
"samples_mbs": {"%s|%s" % key: values for key, values in samples.items()},
},
f,
indent=1,
sort_keys=True,
)
log("raw samples: %s" % args.json)
return 0
if __name__ == "__main__":
sys.exit(main())