mirror of
https://github.com/borgbackup/borg.git
synced 2026-10-12 02:11:36 +00:00
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]>
324 lines
13 KiB
Python
Executable File
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())
|