gbskernels 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bench/__init__.py +7 -0
- bench/_inputs.py +348 -0
- bench/_provenance.py +104 -0
- bench/accuracy.py +182 -0
- bench/accuracy_permanent.py +169 -0
- bench/calibrate_auto.py +159 -0
- bench/crossover.py +195 -0
- bench/kernel_footprint.py +120 -0
- bench/plot_crossover.py +94 -0
- bench/repeated_ab.py +127 -0
- bench/sampler_throughput.py +165 -0
- bench/throughput.py +229 -0
- bench/throughput_end_to_end.py +204 -0
- bench/throughput_gpu.py +126 -0
- bench/tightness.py +174 -0
- bench/walrus_baseline.py +148 -0
- cpu_ref/__init__.py +50 -0
- cpu_ref/certified.py +499 -0
- cpu_ref/dd.py +114 -0
- cpu_ref/diagnostics.py +170 -0
- cpu_ref/hafnian.py +134 -0
- cpu_ref/loop_hafnian.py +120 -0
- cpu_ref/permanent.py +194 -0
- cpu_ref/repeated.py +228 -0
- cpu_ref/tor_recursive.py +101 -0
- cpu_ref/torontonian.py +69 -0
- gbskernels/__init__.py +846 -0
- gbskernels-0.2.0.dist-info/METADATA +255 -0
- gbskernels-0.2.0.dist-info/RECORD +41 -0
- gbskernels-0.2.0.dist-info/WHEEL +5 -0
- gbskernels-0.2.0.dist-info/licenses/LICENSE +202 -0
- gbskernels-0.2.0.dist-info/top_level.txt +5 -0
- highprec_ref/__init__.py +16 -0
- highprec_ref/hafnian.py +46 -0
- highprec_ref/loop_hafnian.py +38 -0
- highprec_ref/permanent.py +85 -0
- highprec_ref/torontonian.py +46 -0
- sampling/__init__.py +18 -0
- sampling/boson_sampling.py +90 -0
- sampling/gbs.py +301 -0
- sampling/sampler.py +446 -0
bench/plot_crossover.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Render the batch-size crossover curves from a sweep artifact (docs/DESIGN.md §9 figures).
|
|
2
|
+
|
|
3
|
+
Reads a ``crossover_*.json`` (from ``bench.crossover``) and draws, per (function,
|
|
4
|
+
matrix size), GPU vs CPU per-eval rate against batch size on log-log axes, marking the
|
|
5
|
+
crossover batch. These are the paper figures for the batched-throughput thesis.
|
|
6
|
+
|
|
7
|
+
matplotlib is imported lazily and is **optional** -- without it (or with ``--data-only``)
|
|
8
|
+
the script emits the plot data as CSV so the curves are reproducible regardless. The
|
|
9
|
+
package keeps numpy/mpmath as its only hard deps; matplotlib is a dev/plotting extra.
|
|
10
|
+
|
|
11
|
+
uv run python -m bench.plot_crossover results/throughput/crossover_*.json -o fig/
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import csv
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load(path: Path) -> dict[str, Any]:
|
|
25
|
+
return json.loads(Path(path).read_text())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def write_csv(artifact: dict[str, Any], out: Path) -> Path:
|
|
29
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
with out.open("w", newline="") as f:
|
|
31
|
+
w = csv.writer(f)
|
|
32
|
+
w.writerow(["func", "matrix_dim", "batch", "gpu_median", "gpu_iqr", "cpu_median", "cpu_iqr",
|
|
33
|
+
"walrus_median", "crossover_batch_vs_cpu", "crossover_batch_vs_walrus",
|
|
34
|
+
"achieved_rel_err_fp64", "precision_tier"])
|
|
35
|
+
for s in artifact["series"]:
|
|
36
|
+
for p in s["points"]:
|
|
37
|
+
w.writerow([s["func"], s["matrix_dim"], p["batch"], p.get("gpu_median"), p.get("gpu_iqr"),
|
|
38
|
+
p.get("cpu_median"), p.get("cpu_iqr"), s.get("walrus_median"),
|
|
39
|
+
s.get("crossover_batch_vs_cpu"), s.get("crossover_batch_vs_walrus"),
|
|
40
|
+
s.get("achieved_rel_err_fp64"), s.get("precision_tier")])
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def plot(artifact: dict[str, Any], out_dir: Path) -> list[Path]:
|
|
45
|
+
try:
|
|
46
|
+
import matplotlib
|
|
47
|
+
matplotlib.use("Agg")
|
|
48
|
+
import matplotlib.pyplot as plt
|
|
49
|
+
except Exception:
|
|
50
|
+
print("# matplotlib not available -- emitting CSV only "
|
|
51
|
+
"(pip install matplotlib, or use the CSV).", file=sys.stderr)
|
|
52
|
+
return [write_csv(artifact, out_dir / "crossover.csv")]
|
|
53
|
+
|
|
54
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
funcs = sorted({s["func"] for s in artifact["series"]})
|
|
56
|
+
written = []
|
|
57
|
+
for func in funcs:
|
|
58
|
+
fig, ax = plt.subplots(figsize=(6, 4))
|
|
59
|
+
for s in (s for s in artifact["series"] if s["func"] == func):
|
|
60
|
+
bs = [p["batch"] for p in s["points"]]
|
|
61
|
+
ax.plot(bs, [p["gpu_median"] for p in s["points"]], "-o", label=f"GPU d={s['matrix_dim']}")
|
|
62
|
+
ax.plot(bs, [p["cpu_median"] for p in s["points"]], "--s", label=f"CPU d={s['matrix_dim']}")
|
|
63
|
+
# the same-instance Walrus baseline is batch-independent -> a horizontal line
|
|
64
|
+
wv = s.get("walrus_median")
|
|
65
|
+
if wv is not None:
|
|
66
|
+
ax.axhline(wv, ls="-.", alpha=0.4, label=f"Walrus d={s['matrix_dim']}")
|
|
67
|
+
if s.get("crossover_batch_vs_cpu"):
|
|
68
|
+
ax.axvline(s["crossover_batch_vs_cpu"], color="grey", ls=":", alpha=0.5)
|
|
69
|
+
ax.set_xscale("log"); ax.set_yscale("log")
|
|
70
|
+
ax.set_xlabel("batch size"); ax.set_ylabel("evals / sec (per eval)")
|
|
71
|
+
ax.set_title(f"{func} -- GPU vs CPU throughput crossover\n"
|
|
72
|
+
f"commit {artifact.get('commit')} | {artifact.get('gpu_backend')}")
|
|
73
|
+
ax.legend(fontsize=7); ax.grid(True, which="both", alpha=0.3)
|
|
74
|
+
p = out_dir / f"crossover_{func}.png"
|
|
75
|
+
fig.tight_layout(); fig.savefig(p, dpi=130); plt.close(fig)
|
|
76
|
+
written.append(p)
|
|
77
|
+
written.append(write_csv(artifact, out_dir / "crossover.csv")) # always emit the data too
|
|
78
|
+
return written
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main() -> None:
|
|
82
|
+
p = argparse.ArgumentParser(description=__doc__)
|
|
83
|
+
p.add_argument("artifact", type=Path, help="a crossover_*.json from bench.crossover")
|
|
84
|
+
p.add_argument("-o", "--out", type=Path, default=Path("fig"))
|
|
85
|
+
p.add_argument("--data-only", action="store_true", help="emit CSV only, skip plotting")
|
|
86
|
+
args = p.parse_args()
|
|
87
|
+
art = _load(args.artifact)
|
|
88
|
+
outs = [write_csv(art, args.out / "crossover.csv")] if args.data_only else plot(art, args.out)
|
|
89
|
+
for o in outs:
|
|
90
|
+
print(f"wrote {o}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
main()
|
bench/repeated_ab.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""R4 device A/B: the repeated-row sieve kernel vs the expanded hafnian kernel.
|
|
2
|
+
|
|
3
|
+
Times, on the SAME device through the public path (both pay H2D/launch/D2H),
|
|
4
|
+
the two ways of evaluating the identical workload — a batch of loop hafnians of
|
|
5
|
+
``A`` expanded by collision patterns ``(q, ..., q)``:
|
|
6
|
+
|
|
7
|
+
* ``gbskernels.lhaf_repeated(A, 0, reps, backend="gpu")`` — the sieve
|
|
8
|
+
(``core/repeated.cu``), cost ``prod(q+1)`` per element;
|
|
9
|
+
* ``gbskernels.haf_batched(expanded, backend="gpu")`` — the shipped power-trace
|
|
10
|
+
kernel on the ``N = M*q`` expansion, cost ``2^(N/2)`` — only where ``N`` is
|
|
11
|
+
within its cap (the rows above the cap are the capability gap the sieve
|
|
12
|
+
closes, reported as such, not as a timing).
|
|
13
|
+
|
|
14
|
+
Hygiene per docs/benchmark_protocol.md: warm-up before timing, raw repeats with
|
|
15
|
+
median + IQR, a checksum honesty guard (sieve vs expanded values must agree),
|
|
16
|
+
append-only artifact with full provenance. The MEASURED crossover here decides
|
|
17
|
+
whether the sampler's sieve path defaults on for the GPU chain
|
|
18
|
+
(the finite-difference sieve, measured against the expanded-hafnian kernel).
|
|
19
|
+
|
|
20
|
+
python -m bench.repeated_ab --batch 2048 --qs 2,3,4,5,6 --repeats 5
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import time
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
import gbskernels
|
|
33
|
+
from bench._provenance import provenance
|
|
34
|
+
|
|
35
|
+
RESULTS = Path(__file__).resolve().parent.parent / "results" / "throughput"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _median_iqr(ts: list[float]) -> dict[str, float]:
|
|
39
|
+
a = np.sort(np.asarray(ts))
|
|
40
|
+
return {"median_s": float(np.median(a)),
|
|
41
|
+
"iqr_s": float(np.percentile(a, 75) - np.percentile(a, 25)),
|
|
42
|
+
"raw_s": [float(x) for x in a]}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run(modes: int, qs: list[int], batch: int, repeats: int, warmup: int,
|
|
46
|
+
seed: int) -> dict:
|
|
47
|
+
M = 2 * modes
|
|
48
|
+
g = np.random.default_rng(seed)
|
|
49
|
+
A = g.standard_normal((M, M)) + 1j * g.standard_normal((M, M))
|
|
50
|
+
A = 0.3 * (A + A.T) # modest norm, physical-ish
|
|
51
|
+
gam = np.zeros(M, dtype=np.complex128)
|
|
52
|
+
|
|
53
|
+
rows = []
|
|
54
|
+
for q in qs:
|
|
55
|
+
reps = np.full((batch, M), q, dtype=np.int32)
|
|
56
|
+
N = M * q
|
|
57
|
+
row: dict = {"q": q, "M": M, "N_expanded": N, "batch": batch,
|
|
58
|
+
"sieve_terms": int((q + 1) ** M)}
|
|
59
|
+
|
|
60
|
+
# --- sieve kernel ---
|
|
61
|
+
for _ in range(warmup):
|
|
62
|
+
gbskernels.lhaf_repeated(A, gam, reps[: max(1, batch // 8)], backend="gpu")
|
|
63
|
+
ts = []
|
|
64
|
+
for _ in range(repeats):
|
|
65
|
+
t0 = time.perf_counter()
|
|
66
|
+
v_sieve = gbskernels.lhaf_repeated(A, gam, reps, backend="gpu")
|
|
67
|
+
ts.append(time.perf_counter() - t0)
|
|
68
|
+
chk = complex(np.sum(v_sieve)) # post-timing checksum (honesty guard)
|
|
69
|
+
row["sieve"] = _median_iqr(ts) | {"evals_per_s": batch / float(np.median(ts)),
|
|
70
|
+
"checksum": [chk.real, chk.imag]}
|
|
71
|
+
|
|
72
|
+
# --- expanded power-trace kernel (only within its cap) ---
|
|
73
|
+
cap = 20 # HAF_MAX_N
|
|
74
|
+
if N <= cap:
|
|
75
|
+
idx = [i for i in range(M) for _ in range(q)]
|
|
76
|
+
E = np.ascontiguousarray(A[np.ix_(idx, idx)])
|
|
77
|
+
stack = np.broadcast_to(E, (batch, N, N)).copy()
|
|
78
|
+
for _ in range(warmup):
|
|
79
|
+
gbskernels.haf_batched(stack[: max(1, batch // 8)], backend="gpu")
|
|
80
|
+
ts = []
|
|
81
|
+
for _ in range(repeats):
|
|
82
|
+
t0 = time.perf_counter()
|
|
83
|
+
v_exp = gbskernels.haf_batched(stack, backend="gpu")
|
|
84
|
+
ts.append(time.perf_counter() - t0)
|
|
85
|
+
chk_e = complex(np.sum(v_exp))
|
|
86
|
+
row["expanded"] = _median_iqr(ts) | {
|
|
87
|
+
"evals_per_s": batch / float(np.median(ts)),
|
|
88
|
+
"checksum": [chk_e.real, chk_e.imag]}
|
|
89
|
+
rel = abs(chk - chk_e) / max(abs(chk_e), 1e-300)
|
|
90
|
+
row["values_agree_rel"] = float(rel)
|
|
91
|
+
if not rel <= 1e-8:
|
|
92
|
+
raise SystemExit(f"HONESTY GATE: sieve vs expanded disagree at q={q}: {rel:.2e}")
|
|
93
|
+
row["speedup_sieve_over_expanded"] = (
|
|
94
|
+
row["expanded"]["median_s"] / row["sieve"]["median_s"])
|
|
95
|
+
else:
|
|
96
|
+
row["expanded"] = None # over the expanded kernel's cap:
|
|
97
|
+
row["note"] = f"N={N} > HAF cap {cap}: the sieve evaluates what the expansion cannot"
|
|
98
|
+
rows.append(row)
|
|
99
|
+
print(f"q={q}: sieve {row['sieve']['evals_per_s']:.3g} ev/s"
|
|
100
|
+
+ (f", expanded {row['expanded']['evals_per_s']:.3g} ev/s, "
|
|
101
|
+
f"speedup {row['speedup_sieve_over_expanded']:.2f}x"
|
|
102
|
+
if row["expanded"] else f" ({row['note']})"))
|
|
103
|
+
return {"bench": "repeated_ab", "params": {"modes": modes, "batch": batch,
|
|
104
|
+
"repeats": repeats, "warmup": warmup, "seed": seed},
|
|
105
|
+
"gpu_backend_kind": gbskernels.gpu_backend_kind(),
|
|
106
|
+
"provenance": provenance(), "rows": rows}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main() -> None:
|
|
110
|
+
p = argparse.ArgumentParser(description=__doc__)
|
|
111
|
+
p.add_argument("--modes", type=int, default=3)
|
|
112
|
+
p.add_argument("--qs", type=str, default="2,3,4,5,6")
|
|
113
|
+
p.add_argument("--batch", type=int, default=2048)
|
|
114
|
+
p.add_argument("--repeats", type=int, default=5)
|
|
115
|
+
p.add_argument("--warmup", type=int, default=2)
|
|
116
|
+
p.add_argument("--seed", type=int, default=0)
|
|
117
|
+
args = p.parse_args()
|
|
118
|
+
art = run(args.modes, [int(x) for x in args.qs.split(",")], args.batch,
|
|
119
|
+
args.repeats, args.warmup, args.seed)
|
|
120
|
+
RESULTS.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
out = RESULTS / f"repeated_ab_{time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())}.json"
|
|
122
|
+
out.write_text(json.dumps(art, indent=1))
|
|
123
|
+
print(f"-> {out}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
main()
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""End-to-end GBS sampler throughput -- **samples/sec**, not kernel evals/sec.
|
|
2
|
+
|
|
3
|
+
The conditional sampler (`sampling.sampler.sample`) draws photon-number samples by
|
|
4
|
+
the reduced-covariance chain rule, routing each mode's conditional-probability
|
|
5
|
+
hafnian batch through one `gbskernels.Workspace` (ragged bucketing + device-buffer
|
|
6
|
+
residency across the whole chain). This measures the headline product metric --
|
|
7
|
+
samples/sec for a real GBS workload -- on the CPU and GPU backends, with the same
|
|
8
|
+
honest provenance as the kernel benchmarks (commit, GPU/host-shim backend).
|
|
9
|
+
|
|
10
|
+
Runs wherever the GPU extension is importable: a real device times the GPU chain, a
|
|
11
|
+
host-shim build times the CPU emulation (then the GPU number is only indicative, but
|
|
12
|
+
the path and the GPU==CPU sample agreement are validated -- see tests/).
|
|
13
|
+
|
|
14
|
+
uv run python -m bench.sampler_throughput --modes 6 --num-samples 2000 --cutoff 6
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import platform
|
|
23
|
+
import subprocess
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from importlib.metadata import version
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
import gbskernels
|
|
32
|
+
from bench import _provenance
|
|
33
|
+
from sampling import sampler
|
|
34
|
+
|
|
35
|
+
DEFAULT_OUT = Path(__file__).resolve().parent.parent / "results" / "sampling"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _random_cov(modes: int, seed: int, hbar: float = 2.0) -> np.ndarray:
|
|
39
|
+
"""xxpp covariance of a zero-displacement pure GBS state (squeeze + interferometer),
|
|
40
|
+
built without third-party deps so the benchmark is self-contained on the box."""
|
|
41
|
+
g = np.random.default_rng(seed)
|
|
42
|
+
r = g.uniform(0.3, 0.6, modes)
|
|
43
|
+
z = (g.standard_normal((modes, modes)) + 1j * g.standard_normal((modes, modes))) / np.sqrt(2.0)
|
|
44
|
+
U, rr = np.linalg.qr(z); ph = np.diagonal(rr).copy(); ph /= np.abs(ph); U = U * ph
|
|
45
|
+
sq = np.block([[np.diag(np.exp(-r)), np.zeros((modes, modes))],
|
|
46
|
+
[np.zeros((modes, modes)), np.diag(np.exp(r))]]) # xxpp squeezing
|
|
47
|
+
interf = np.block([[U.real, -U.imag], [U.imag, U.real]]) # xxpp interferometer
|
|
48
|
+
S = interf @ sq
|
|
49
|
+
return (hbar / 2.0) * S @ S.T
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _quantile(xs: list[float], q: float) -> float:
|
|
53
|
+
s = sorted(xs)
|
|
54
|
+
x = q * (len(s) - 1)
|
|
55
|
+
lo = int(x); frac = x - lo
|
|
56
|
+
return s[lo] * (1 - frac) + s[lo + 1] * frac if lo + 1 < len(s) else s[lo]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run(modes: int, num_samples: int, cutoff: int, repeats: int = 7, seed: int = 0,
|
|
60
|
+
out_dir: Path | None = None, repeated_sieve: bool = False) -> tuple[dict[str, Any], Path]:
|
|
61
|
+
cov = _random_cov(modes, seed)
|
|
62
|
+
backends = ["cpu"] + (["gpu"] if gbskernels.gpu_available() else [])
|
|
63
|
+
rows = []
|
|
64
|
+
|
|
65
|
+
def _time(backend: str, resident: bool = False, sieve: bool | None = None) -> dict[str, Any]:
|
|
66
|
+
# warm-up (untimed) then RAW repetitions -> median + IQR (NOT best-of-N, which hides the
|
|
67
|
+
# spread and biases the headline; the median+IQR matches the kernel/Walrus benches).
|
|
68
|
+
# The warm-up is also a COST PROBE: if one timed draw-set already exceeds
|
|
69
|
+
# SLOW_CELL_S, drop to a single measured repetition rather than `repeats`
|
|
70
|
+
# of them -- the deep-cutoff CPU non-sieve baseline is ~minutes/rep and
|
|
71
|
+
# timing it 7x (twice: default + explicit nosieve) made the sweep
|
|
72
|
+
# intractable (it hung a cell ~2h). A slow baseline still gets ONE honest
|
|
73
|
+
# number; it just does not get the full IQR treatment it cannot afford.
|
|
74
|
+
SLOW_CELL_S = 20.0
|
|
75
|
+
import time as _t
|
|
76
|
+
sampler.sample(cov, max(num_samples // 10, 1), cutoff=cutoff, backend=backend,
|
|
77
|
+
resident=resident, seed=seed, repeated_sieve=sieve)
|
|
78
|
+
_p0 = _t.perf_counter()
|
|
79
|
+
first = sampler.samples_per_second(cov, num_samples, cutoff=cutoff, backend=backend,
|
|
80
|
+
resident=resident, seed=seed, repeated_sieve=sieve)
|
|
81
|
+
n_rep = repeats if (_t.perf_counter() - _p0) < SLOW_CELL_S else 1
|
|
82
|
+
reps = [first] + [sampler.samples_per_second(cov, num_samples, cutoff=cutoff, backend=backend,
|
|
83
|
+
resident=resident, seed=seed + rep,
|
|
84
|
+
repeated_sieve=sieve) for rep in range(1, n_rep)]
|
|
85
|
+
sps = sorted(r["samples_per_sec"] for r in reps)
|
|
86
|
+
secs = [r["seconds"] for r in reps]
|
|
87
|
+
return {"backend": reps[0]["backend"], "num_samples": num_samples, "modes": modes,
|
|
88
|
+
"cutoff": cutoff, "repeats_requested": repeats, "repeats_timed": len(reps),
|
|
89
|
+
"repeated_sieve_effective": reps[0].get("repeated_sieve_effective"),
|
|
90
|
+
"samples_per_sec_median": _quantile(sps, 0.5),
|
|
91
|
+
"samples_per_sec_iqr": _quantile(sps, 0.75) - _quantile(sps, 0.25),
|
|
92
|
+
"seconds_median": _quantile(secs, 0.5),
|
|
93
|
+
"mean_photons": reps[0]["mean_photons"], "raw_samples_per_sec": sps}
|
|
94
|
+
|
|
95
|
+
for b in backends:
|
|
96
|
+
rows.append(_time(b)) # sieve=None: the DEFAULT path, as shipped
|
|
97
|
+
if repeated_sieve: # R4 A/B rows: explicitly pinned paths
|
|
98
|
+
for b in backends:
|
|
99
|
+
rows.append(_time(b, sieve=False))
|
|
100
|
+
rows.append(_time(b, sieve=True))
|
|
101
|
+
# v3 fully on-device (resident) chain -- the before/after vs the hybrid 'gpu' backend -- when
|
|
102
|
+
# the extension exposes it and the config is within the hafnian cap (worst-case 2*modes*cutoff).
|
|
103
|
+
_ext = gbskernels._load_gpu_ext()
|
|
104
|
+
_cap = getattr(gbskernels, "_GPU_MAX_DIM", {}).get("haf", 20)
|
|
105
|
+
if (gbskernels.gpu_available() and _ext is not None and hasattr(_ext, "sample_resident")
|
|
106
|
+
and 2 * modes * cutoff <= _cap):
|
|
107
|
+
rows.append(_time("gpu", resident=True))
|
|
108
|
+
|
|
109
|
+
artifact = {
|
|
110
|
+
"kind": "sampler_throughput",
|
|
111
|
+
"created_utc": datetime.now(timezone.utc).isoformat(),
|
|
112
|
+
**_provenance.provenance(), # commit + container_digest + hostname
|
|
113
|
+
"gpu_backend": gbskernels.gpu_backend_kind(),
|
|
114
|
+
"metric": "end-to-end samples/sec (chain-rule conditional GBS sampler); "
|
|
115
|
+
"median + IQR over raw repeats; warm-up discarded; the 'gpu-resident' row is the "
|
|
116
|
+
"v3 FULLY on-device chain (before/after vs the hybrid 'gpu' row, where in-cap)",
|
|
117
|
+
"params": {"modes": modes, "num_samples": num_samples, "cutoff": cutoff,
|
|
118
|
+
"repeats": repeats, "seed": seed, "warmup": "1 untimed draw/backend"},
|
|
119
|
+
"env": {"platform": platform.platform(), "numpy": version("numpy")},
|
|
120
|
+
"rows": rows,
|
|
121
|
+
}
|
|
122
|
+
out_dir = Path(out_dir) if out_dir is not None else DEFAULT_OUT
|
|
123
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
125
|
+
path = out_dir / f"sampler_throughput_{stamp}.json"
|
|
126
|
+
path.write_text(json.dumps(artifact, indent=2))
|
|
127
|
+
return artifact, path
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main() -> None:
|
|
131
|
+
p = argparse.ArgumentParser(description=__doc__)
|
|
132
|
+
p.add_argument("--modes", type=int, default=6)
|
|
133
|
+
p.add_argument("--num-samples", type=int, default=2000)
|
|
134
|
+
p.add_argument("--cutoff", type=int, default=6)
|
|
135
|
+
p.add_argument("--repeats", type=int, default=7)
|
|
136
|
+
p.add_argument("--out", type=Path, default=None)
|
|
137
|
+
p.add_argument("--repeated-sieve", action="store_true",
|
|
138
|
+
help="also time the sieve-routed chain (cpu+sieve / gpu+sieve rows)")
|
|
139
|
+
p.add_argument("--sweep", action="store_true",
|
|
140
|
+
help="characterization grid: modes x cutoff cells (each its own artifact) "
|
|
141
|
+
"instead of the single default cell -- the resident/hybrid/sieve "
|
|
142
|
+
"story needs the surface, not one point")
|
|
143
|
+
args = p.parse_args()
|
|
144
|
+
if args.sweep:
|
|
145
|
+
# the characterization surface: shallow->deep collisions, small->mid modes.
|
|
146
|
+
# Each cell is an independent append-only artifact (same hygiene).
|
|
147
|
+
for m_, c_ in [(4, 3), (6, 4), (6, 6), (8, 5), (10, 4), (5, 8)]:
|
|
148
|
+
n_ = max(200, args.num_samples // 4)
|
|
149
|
+
print(f"--- sweep cell: modes={m_} cutoff={c_} samples={n_} ---")
|
|
150
|
+
run(m_, n_, c_, max(3, args.repeats // 2), out_dir=args.out,
|
|
151
|
+
repeated_sieve=args.repeated_sieve)
|
|
152
|
+
return
|
|
153
|
+
artifact, path = run(args.modes, args.num_samples, args.cutoff, args.repeats,
|
|
154
|
+
out_dir=args.out, repeated_sieve=args.repeated_sieve)
|
|
155
|
+
print(f"# GBS sampler throughput ({artifact['gpu_backend']}) -> {path}")
|
|
156
|
+
print(f"# {args.modes} modes, cutoff {args.cutoff}, {args.num_samples} samples, "
|
|
157
|
+
f"{args.repeats} repeats; commit {artifact['commit']}")
|
|
158
|
+
for r in artifact["rows"]:
|
|
159
|
+
print(f" {r['backend']:>4} {r['samples_per_sec_median']:>10.1f} samples/sec median "
|
|
160
|
+
f"(IQR {r['samples_per_sec_iqr']:.1f}, {r['seconds_median']:.2f}s, "
|
|
161
|
+
f"mean photons {r['mean_photons']:.2f})")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
main()
|
bench/throughput.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Throughput benchmark harness (accuracy-normalized, honesty-guarded).
|
|
2
|
+
|
|
3
|
+
The benchmark claim is **throughput at a stated accuracy**, never raw throughput
|
|
4
|
+
(docs/DESIGN.md §9). This harness measures batched-evaluation throughput (evals/sec)
|
|
5
|
+
for the GBSKernels CPU reference and, where available, The Walrus, at matched
|
|
6
|
+
accuracy, with the hygiene that makes the numbers credible:
|
|
7
|
+
|
|
8
|
+
* n >= ``repeats`` timed runs per cell, reported as median + IQR (not mean);
|
|
9
|
+
* **randomized execution order** across (engine, size) cells, so thermal/cache
|
|
10
|
+
drift can't systematically favor one engine;
|
|
11
|
+
* a **benchmark-honesty guard**: every timed call's outputs are reduced to a
|
|
12
|
+
checksum *after* timing, and the checksum is stored, so no async/early-return
|
|
13
|
+
can fake a fast time (trivially true on CPU, essential once the GPU path
|
|
14
|
+
lands);
|
|
15
|
+
* raw per-run data written append-only to ``results/throughput/``; **no composite
|
|
16
|
+
"winner" number**; CPU single-eval latency where The Walrus honestly wins is
|
|
17
|
+
reported, not hidden.
|
|
18
|
+
|
|
19
|
+
This run is a **CPU baseline**. GPU throughput rows are added in a scripted
|
|
20
|
+
rented-GPU session (docs/DESIGN.md §8/sec.10); nothing here runs on a GPU.
|
|
21
|
+
|
|
22
|
+
uv run python -m bench.throughput --sizes 4,8,12 --batch 256 --repeats 7
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import platform
|
|
30
|
+
import random
|
|
31
|
+
import time
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
from importlib.metadata import version
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Callable
|
|
36
|
+
|
|
37
|
+
import numpy as np
|
|
38
|
+
|
|
39
|
+
import gbskernels
|
|
40
|
+
from bench._inputs import random_complex
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
import thewalrus
|
|
44
|
+
|
|
45
|
+
_HAVE_WALRUS = True
|
|
46
|
+
except Exception:
|
|
47
|
+
_HAVE_WALRUS = False
|
|
48
|
+
|
|
49
|
+
DEFAULT_OUT = Path(__file__).resolve().parent.parent / "results" / "throughput"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _checksum(values: np.ndarray) -> str:
|
|
53
|
+
"""Order-independent magnitude checksum of a result vector (honesty guard)."""
|
|
54
|
+
v = np.asarray(values, dtype=np.complex128)
|
|
55
|
+
return f"{float(np.sum(np.abs(v))):.12e}|{len(v)}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _time_once(fn: Callable[[], np.ndarray]) -> tuple[float, str]:
|
|
59
|
+
"""Run ``fn``, return (elapsed_seconds, checksum-of-result-after-sync)."""
|
|
60
|
+
t0 = time.perf_counter()
|
|
61
|
+
out = fn()
|
|
62
|
+
elapsed = time.perf_counter() - t0
|
|
63
|
+
# Post-compute checksum: forces materialization of every result *after*
|
|
64
|
+
# timing so no early-return can fake a fast time (docs/DESIGN.md §8).
|
|
65
|
+
return elapsed, _checksum(out)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _gbskernels_batched(func: str, batch: list[np.ndarray]) -> Callable[[], np.ndarray]:
|
|
69
|
+
fn = {"perm": gbskernels.perm_batched, "haf": gbskernels.haf_batched}[func]
|
|
70
|
+
return lambda: fn(batch, precision="fp64")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _walrus_looped(func: str, batch: list[np.ndarray]) -> Callable[[], np.ndarray]:
|
|
74
|
+
wfn = {"perm": thewalrus.perm, "haf": thewalrus.hafnian}[func]
|
|
75
|
+
return lambda: np.array([wfn(A) for A in batch], dtype=np.complex128)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _make_batch(func: str, n: int, batch_size: int, seed: int) -> list[np.ndarray]:
|
|
79
|
+
out = []
|
|
80
|
+
for b in range(batch_size):
|
|
81
|
+
A = random_complex(n, seed=seed + b)
|
|
82
|
+
if func == "haf":
|
|
83
|
+
A = A + A.T # symmetric for the hafnian
|
|
84
|
+
out.append(A)
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def run(
|
|
89
|
+
func: str,
|
|
90
|
+
sizes: list[int],
|
|
91
|
+
batch_size: int,
|
|
92
|
+
repeats: int,
|
|
93
|
+
seed: int = 0,
|
|
94
|
+
out_dir: Path | None = None,
|
|
95
|
+
) -> tuple[dict[str, Any], Path]:
|
|
96
|
+
engines = ["gbskernels"]
|
|
97
|
+
if _HAVE_WALRUS:
|
|
98
|
+
engines.append("thewalrus")
|
|
99
|
+
|
|
100
|
+
# Build the randomized work list: (engine, size, repeat) cells, shuffled.
|
|
101
|
+
cells = [
|
|
102
|
+
(engine, n, rep)
|
|
103
|
+
for engine in engines
|
|
104
|
+
for n in sizes
|
|
105
|
+
for rep in range(repeats)
|
|
106
|
+
]
|
|
107
|
+
random.Random(seed).shuffle(cells)
|
|
108
|
+
|
|
109
|
+
# Fixed input batch per size (same inputs for both engines -> fair, and the
|
|
110
|
+
# checksum must agree across engines, a correctness cross-check for free).
|
|
111
|
+
batches = {n: _make_batch(func, n, batch_size, seed=1000 + n) for n in sizes}
|
|
112
|
+
|
|
113
|
+
raw: list[dict[str, Any]] = []
|
|
114
|
+
for engine, n, rep in cells:
|
|
115
|
+
batch = batches[n]
|
|
116
|
+
if engine == "gbskernels":
|
|
117
|
+
fn = _gbskernels_batched(func, batch)
|
|
118
|
+
else:
|
|
119
|
+
fn = _walrus_looped(func, batch)
|
|
120
|
+
elapsed, checksum = _time_once(fn)
|
|
121
|
+
raw.append(
|
|
122
|
+
{
|
|
123
|
+
"engine": engine,
|
|
124
|
+
"n": n,
|
|
125
|
+
"repeat": rep,
|
|
126
|
+
"seconds": elapsed,
|
|
127
|
+
"evals_per_sec": batch_size / elapsed if elapsed > 0 else float("inf"),
|
|
128
|
+
"checksum": checksum,
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
summary = _summarize(raw, engines, sizes, batch_size)
|
|
133
|
+
artifact = {
|
|
134
|
+
"kind": "throughput",
|
|
135
|
+
"function": func,
|
|
136
|
+
"created_utc": datetime.now(timezone.utc).isoformat(),
|
|
137
|
+
"tier": "cpu-baseline",
|
|
138
|
+
"env": {
|
|
139
|
+
"python": platform.python_version(),
|
|
140
|
+
"platform": platform.platform(),
|
|
141
|
+
"processor": platform.processor(),
|
|
142
|
+
"numpy": version("numpy"),
|
|
143
|
+
"thewalrus": version("thewalrus") if _HAVE_WALRUS else None,
|
|
144
|
+
},
|
|
145
|
+
"params": {
|
|
146
|
+
"batch_size": batch_size,
|
|
147
|
+
"repeats": repeats,
|
|
148
|
+
"sizes": sizes,
|
|
149
|
+
"precision": "fp64",
|
|
150
|
+
"dtype": "complex128",
|
|
151
|
+
"order": "randomized across (engine, size, repeat)",
|
|
152
|
+
},
|
|
153
|
+
"summary": summary,
|
|
154
|
+
"raw": raw,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
out_dir = Path(out_dir) if out_dir is not None else DEFAULT_OUT
|
|
158
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
160
|
+
path = out_dir / f"throughput_{func}_{stamp}_cpu.json"
|
|
161
|
+
path.write_text(json.dumps(artifact, indent=2))
|
|
162
|
+
return artifact, path
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _summarize(raw, engines, sizes, batch_size) -> list[dict[str, Any]]:
|
|
166
|
+
rows = []
|
|
167
|
+
for n in sizes:
|
|
168
|
+
row: dict[str, Any] = {"n": n}
|
|
169
|
+
checks = {}
|
|
170
|
+
for engine in engines:
|
|
171
|
+
eps = sorted(r["evals_per_sec"] for r in raw if r["engine"] == engine and r["n"] == n)
|
|
172
|
+
checks[engine] = {r["checksum"] for r in raw if r["engine"] == engine and r["n"] == n}
|
|
173
|
+
row[engine] = {
|
|
174
|
+
"evals_per_sec_median": float(np.median(eps)),
|
|
175
|
+
"evals_per_sec_iqr": float(np.subtract(*np.percentile(eps, [75, 25]))),
|
|
176
|
+
}
|
|
177
|
+
# cross-engine correctness: checksums must match (same inputs, same answer)
|
|
178
|
+
if len(engines) > 1:
|
|
179
|
+
allchecks = set().union(*checks.values())
|
|
180
|
+
row["checksums_agree"] = len(allchecks) == 1
|
|
181
|
+
rows.append(row)
|
|
182
|
+
return rows
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _print_summary(artifact: dict[str, Any], path: Path) -> None:
|
|
186
|
+
print(f"# throughput [{artifact['function']}, {artifact['tier']}] -> {path}")
|
|
187
|
+
e = artifact["env"]
|
|
188
|
+
print(f"# {e['platform']} | py {e['python']} | numpy {e['numpy']} | "
|
|
189
|
+
f"thewalrus {e['thewalrus']}")
|
|
190
|
+
print(f"# batch={artifact['params']['batch_size']} repeats={artifact['params']['repeats']} "
|
|
191
|
+
f"(median evals/sec, IQR); randomized order; no composite score")
|
|
192
|
+
engines = [k for k in ("gbskernels", "thewalrus") if k in artifact["summary"][0]]
|
|
193
|
+
head = f" {'n':>3}"
|
|
194
|
+
for eng in engines:
|
|
195
|
+
head += f" {eng + ' med':>16} {'IQR':>9}"
|
|
196
|
+
if len(engines) > 1:
|
|
197
|
+
head += " checks"
|
|
198
|
+
print(head)
|
|
199
|
+
for row in artifact["summary"]:
|
|
200
|
+
line = f" {row['n']:>3}"
|
|
201
|
+
for eng in engines:
|
|
202
|
+
line += f" {row[eng]['evals_per_sec_median']:>16.1f} {row[eng]['evals_per_sec_iqr']:>9.1f}"
|
|
203
|
+
if len(engines) > 1:
|
|
204
|
+
line += f" {'ok' if row['checksums_agree'] else 'MISMATCH'}"
|
|
205
|
+
print(line)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _parse_sizes(s: str) -> list[int]:
|
|
209
|
+
if "-" in s and "," not in s:
|
|
210
|
+
lo, hi = s.split("-")
|
|
211
|
+
return list(range(int(lo), int(hi) + 1))
|
|
212
|
+
return [int(x) for x in s.split(",")]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def main() -> None:
|
|
216
|
+
p = argparse.ArgumentParser(description=__doc__)
|
|
217
|
+
p.add_argument("--func", choices=["perm", "haf"], default="perm")
|
|
218
|
+
p.add_argument("--sizes", type=_parse_sizes, default=_parse_sizes("4,6,8,10"))
|
|
219
|
+
p.add_argument("--batch", type=int, default=128)
|
|
220
|
+
p.add_argument("--repeats", type=int, default=7)
|
|
221
|
+
p.add_argument("--seed", type=int, default=0)
|
|
222
|
+
p.add_argument("--out", type=Path, default=None)
|
|
223
|
+
args = p.parse_args()
|
|
224
|
+
artifact, path = run(args.func, args.sizes, args.batch, args.repeats, args.seed, args.out)
|
|
225
|
+
_print_summary(artifact, path)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
if __name__ == "__main__":
|
|
229
|
+
main()
|