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
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Accuracy characterization for the permanent (FP64 vs mpmath reference).
|
|
2
|
+
|
|
3
|
+
The CPU-runnable, throughput-free half of docs/DESIGN.md §9: relative error vs matrix
|
|
4
|
+
size and vs the Glynn condition number ``kappa``, measured against the
|
|
5
|
+
independent arbitrary-precision reference. This is the "measured boundary" of
|
|
6
|
+
docs/DESIGN.md §6 -- the thing that tells a user exactly when FP64 suffices and when
|
|
7
|
+
the (GPU) double-double tier is required.
|
|
8
|
+
|
|
9
|
+
Writes a timestamped, self-describing JSON artifact to ``results/accuracy/``
|
|
10
|
+
(append-only). **No timing is recorded here** -- throughput numbers come only
|
|
11
|
+
from scripted rented-GPU sessions (docs/DESIGN.md §8 CI policy).
|
|
12
|
+
|
|
13
|
+
uv run python -m bench.accuracy_permanent --sizes 2-12 --seeds 8 --dps 60
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import platform
|
|
21
|
+
import subprocess
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from importlib.metadata import version
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import mpmath
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
from cpu_ref import cancellation_ratio, permanent_glynn
|
|
31
|
+
from cpu_ref.permanent import permanent_glynn_dd
|
|
32
|
+
from highprec_ref import permanent_mp
|
|
33
|
+
|
|
34
|
+
from ._inputs import make_cancellation_matrix, random_complex
|
|
35
|
+
|
|
36
|
+
DEFAULT_OUT = Path(__file__).resolve().parent.parent / "results" / "accuracy"
|
|
37
|
+
CANCELLATION_DELTAS = [1e-1, 1e-2, 1e-4, 1e-6, 1e-8, 1e-10, 1e-12, 1e-14]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _git_commit() -> str | None:
|
|
41
|
+
try:
|
|
42
|
+
out = subprocess.run(
|
|
43
|
+
["git", "rev-parse", "--short", "HEAD"],
|
|
44
|
+
capture_output=True, text=True, cwd=Path(__file__).parent,
|
|
45
|
+
)
|
|
46
|
+
return out.stdout.strip() or None
|
|
47
|
+
except Exception:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _rel_err_mp(approx: complex, exact_mp: Any, dps: int) -> float:
|
|
52
|
+
"""Relative error of an FP64 result vs the mpmath reference, computed in
|
|
53
|
+
mpmath so the reference's full precision is used (not truncated to FP64)."""
|
|
54
|
+
with mpmath.workdps(dps):
|
|
55
|
+
a = mpmath.mpc(approx.real, approx.imag)
|
|
56
|
+
return float(abs(a - exact_mp) / abs(exact_mp))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run(
|
|
60
|
+
sizes: list[int],
|
|
61
|
+
seeds_per_size: int,
|
|
62
|
+
dps: int = 60,
|
|
63
|
+
out_dir: Path | None = None,
|
|
64
|
+
tag: str = "",
|
|
65
|
+
) -> tuple[dict[str, Any], Path]:
|
|
66
|
+
"""Run the accuracy sweeps and write one append-only artifact. Returns it."""
|
|
67
|
+
size_sweep = []
|
|
68
|
+
for n in sizes:
|
|
69
|
+
rel_errs, kappas = [], []
|
|
70
|
+
for seed in range(seeds_per_size):
|
|
71
|
+
A = random_complex(n, seed=1000 * n + seed)
|
|
72
|
+
exact_mp = permanent_mp(A, dps=dps)
|
|
73
|
+
approx = permanent_glynn(A)
|
|
74
|
+
rel_errs.append(_rel_err_mp(approx, exact_mp, dps))
|
|
75
|
+
kappas.append(cancellation_ratio(A, perm_value=complex(exact_mp)))
|
|
76
|
+
size_sweep.append(
|
|
77
|
+
{
|
|
78
|
+
"n": n,
|
|
79
|
+
"rel_err_median": float(np.median(rel_errs)),
|
|
80
|
+
"rel_err_max": float(np.max(rel_errs)),
|
|
81
|
+
"kappa_median": float(np.median(kappas)),
|
|
82
|
+
"rel_errs": [float(x) for x in rel_errs],
|
|
83
|
+
"kappas": [float(x) for x in kappas],
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Conditioning axis: drive kappa with the cancellation knob at fixed n.
|
|
88
|
+
cancellation_sweep = []
|
|
89
|
+
cancel_dps = max(dps, 80)
|
|
90
|
+
for delta in CANCELLATION_DELTAS:
|
|
91
|
+
A = make_cancellation_matrix(6, delta=delta, seed=1)
|
|
92
|
+
exact_mp = permanent_mp(A, dps=cancel_dps)
|
|
93
|
+
cancellation_sweep.append(
|
|
94
|
+
{
|
|
95
|
+
"n": 6,
|
|
96
|
+
"delta": delta,
|
|
97
|
+
"kappa": cancellation_ratio(A, perm_value=complex(exact_mp)),
|
|
98
|
+
"rel_err_fp64": _rel_err_mp(permanent_glynn(A), exact_mp, cancel_dps),
|
|
99
|
+
"rel_err_dd": _rel_err_mp(permanent_glynn_dd(A), exact_mp, cancel_dps),
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
artifact = {
|
|
104
|
+
"kind": "accuracy_permanent",
|
|
105
|
+
"created_utc": datetime.now(timezone.utc).isoformat(),
|
|
106
|
+
"env": {
|
|
107
|
+
"python": platform.python_version(),
|
|
108
|
+
"platform": platform.platform(),
|
|
109
|
+
"numpy": version("numpy"),
|
|
110
|
+
"mpmath": version("mpmath"),
|
|
111
|
+
"git_commit": _git_commit(),
|
|
112
|
+
},
|
|
113
|
+
"params": {
|
|
114
|
+
"sizes": sizes,
|
|
115
|
+
"seeds_per_size": seeds_per_size,
|
|
116
|
+
"dps": dps,
|
|
117
|
+
"dtype": "complex128",
|
|
118
|
+
"input_family": "random_complex (size sweep) / cancellation block (kappa sweep)",
|
|
119
|
+
},
|
|
120
|
+
"size_sweep": size_sweep,
|
|
121
|
+
"cancellation_sweep": cancellation_sweep,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
out_dir = Path(out_dir) if out_dir is not None else DEFAULT_OUT
|
|
125
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
127
|
+
suffix = f"_{tag}" if tag else ""
|
|
128
|
+
path = out_dir / f"accuracy_permanent_{stamp}{suffix}.json"
|
|
129
|
+
path.write_text(json.dumps(artifact, indent=2))
|
|
130
|
+
return artifact, path
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _print_summary(artifact: dict[str, Any], path: Path) -> None:
|
|
134
|
+
print(f"# accuracy_permanent -> {path}")
|
|
135
|
+
print(f"# env: py {artifact['env']['python']}, numpy {artifact['env']['numpy']}, "
|
|
136
|
+
f"mpmath {artifact['env']['mpmath']}, commit {artifact['env']['git_commit']}")
|
|
137
|
+
print("\n size sweep (FP64 vs mpmath, random complex):")
|
|
138
|
+
print(f" {'n':>3} {'rel_err median':>15} {'rel_err max':>13} {'kappa median':>13}")
|
|
139
|
+
for row in artifact["size_sweep"]:
|
|
140
|
+
print(f" {row['n']:>3} {row['rel_err_median']:>15.2e} "
|
|
141
|
+
f"{row['rel_err_max']:>13.2e} {row['kappa_median']:>13.2e}")
|
|
142
|
+
print("\n conditioning sweep (the measured FP64<->DD boundary, n=6):")
|
|
143
|
+
print(f" {'delta':>8} {'kappa':>11} {'rel_err FP64':>13} {'rel_err DD':>13}")
|
|
144
|
+
for row in artifact["cancellation_sweep"]:
|
|
145
|
+
print(f" {row['delta']:>8.0e} {row['kappa']:>11.2e} "
|
|
146
|
+
f"{row['rel_err_fp64']:>13.2e} {row['rel_err_dd']:>13.2e}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _parse_sizes(s: str) -> list[int]:
|
|
150
|
+
if "-" in s:
|
|
151
|
+
lo, hi = s.split("-")
|
|
152
|
+
return list(range(int(lo), int(hi) + 1))
|
|
153
|
+
return [int(x) for x in s.split(",")]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def main() -> None:
|
|
157
|
+
p = argparse.ArgumentParser(description=__doc__)
|
|
158
|
+
p.add_argument("--sizes", type=_parse_sizes, default=_parse_sizes("2-12"))
|
|
159
|
+
p.add_argument("--seeds", type=int, default=8)
|
|
160
|
+
p.add_argument("--dps", type=int, default=60)
|
|
161
|
+
p.add_argument("--out", type=Path, default=None)
|
|
162
|
+
p.add_argument("--tag", type=str, default="")
|
|
163
|
+
args = p.parse_args()
|
|
164
|
+
artifact, path = run(args.sizes, args.seeds, args.dps, args.out, args.tag)
|
|
165
|
+
_print_summary(artifact, path)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
main()
|
bench/calibrate_auto.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Calibrate precision="auto": is kappa a trustworthy FP64-risk indicator? (docs/DESIGN.md §6)
|
|
2
|
+
|
|
3
|
+
``precision="auto"`` trusts the FP64 result when the cancellation indicator
|
|
4
|
+
``kappa = sum|terms| / |result|`` (``cpu_ref.summation_condition_number``) is below
|
|
5
|
+
``_AUTO_KAPPA_MAX`` (1e8), and reruns in the high-precision tier otherwise. ``kappa`` is a
|
|
6
|
+
**heuristic**, NOT a rigorous error certificate: the model is ``rel_err_fp64 ~ kappa * eps``,
|
|
7
|
+
but ``kappa`` is itself formed from the (FP64-inexact) computed ``|result|``, so under extreme
|
|
8
|
+
cancellation it can mis-estimate. So we MEASURE the relationship on physical / loss /
|
|
9
|
+
adversarial ensembles and calibrate the threshold rather than assume it:
|
|
10
|
+
|
|
11
|
+
* per ``(func, regime, size)``: ``kappa``, the actual FP64 relative error vs the mpmath
|
|
12
|
+
ground truth, and whether ``auto`` would trust FP64 (``kappa < threshold``);
|
|
13
|
+
* a calibration summary: the **worst FP64 error among the TRUSTED cases** (the accuracy
|
|
14
|
+
``auto`` actually delivers), the **false-trust count** (``kappa < threshold`` yet the error
|
|
15
|
+
is large), and the kappa-vs-error correlation.
|
|
16
|
+
|
|
17
|
+
The headline a truthful claim can make: on these ensembles, ``kappa < 1e8`` keeps the FP64
|
|
18
|
+
error below ``max_rel_err_when_trusted`` with ``false_trust_count`` exceptions -- a calibrated
|
|
19
|
+
heuristic, stated with its measured failure rate, not a guarantee.
|
|
20
|
+
|
|
21
|
+
uv run python -m bench.calibrate_auto --dps 60
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import math
|
|
29
|
+
import platform
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
import mpmath
|
|
35
|
+
import numpy as np
|
|
36
|
+
|
|
37
|
+
import cpu_ref
|
|
38
|
+
import gbskernels
|
|
39
|
+
import highprec_ref
|
|
40
|
+
from bench import _inputs, _provenance
|
|
41
|
+
|
|
42
|
+
DEFAULT_OUT = Path(__file__).resolve().parent.parent / "results" / "accuracy"
|
|
43
|
+
|
|
44
|
+
_MP = {"perm": highprec_ref.permanent_mp, "haf": highprec_ref.hafnian_mp,
|
|
45
|
+
"lhaf": highprec_ref.loop_hafnian_mp, "tor": highprec_ref.torontonian_mp}
|
|
46
|
+
_FN = {"perm": gbskernels.perm, "haf": gbskernels.haf, "lhaf": gbskernels.lhaf, "tor": gbskernels.tor}
|
|
47
|
+
_EPS = float(np.finfo(np.float64).eps)
|
|
48
|
+
_SIZES = {"perm": [6, 8], "haf": [6, 8], "lhaf": [6, 8], "tor": [6, 8]} # tor dim = 2*modes
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _pearson(pts: list[tuple[float, float]]) -> float | None:
|
|
52
|
+
n = len(pts)
|
|
53
|
+
if n < 2:
|
|
54
|
+
return None
|
|
55
|
+
xs = [p[0] for p in pts]; ys = [p[1] for p in pts]
|
|
56
|
+
mx, my = sum(xs) / n, sum(ys) / n
|
|
57
|
+
num = sum((x - mx) * (y - my) for x, y in pts)
|
|
58
|
+
dx = math.sqrt(sum((x - mx) ** 2 for x in xs)); dy = math.sqrt(sum((y - my) ** 2 for y in ys))
|
|
59
|
+
return float(num / (dx * dy)) if dx > 0 and dy > 0 else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _eval(func: str, A, backend: str, threshold: float):
|
|
63
|
+
"""(FP64 value, kappa, trusted) for one input on ``backend``. On the GPU backend the kappa
|
|
64
|
+
comes from the on-device ``*_kappa`` kernel and the FP64 value from the GPU FP64 path -- so
|
|
65
|
+
the calibration measures the GPU AUTO decision (not a CPU proxy); ``trusted`` == auto would
|
|
66
|
+
keep FP64 (tier 'fp64'). On CPU it uses cpu_ref + summation_condition_number."""
|
|
67
|
+
if backend == "gpu":
|
|
68
|
+
_val, diag = _FN[func](A, precision="auto", backend="gpu", return_diagnostics=True)
|
|
69
|
+
kappa = float(diag["cancellation"])
|
|
70
|
+
fp = complex(_FN[func](A, precision="fp64", backend="gpu"))
|
|
71
|
+
return fp, kappa, (diag["tier"] == "fp64")
|
|
72
|
+
fp = complex(_FN[func](A, precision="fp64", backend="cpu"))
|
|
73
|
+
kappa = float(cpu_ref.summation_condition_number(func, A, fp))
|
|
74
|
+
return fp, kappa, (kappa < threshold)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run(dps: int = 60, seeds: int = 5, threshold: float | None = None,
|
|
78
|
+
backend: str | None = None, out_dir: Path | None = None) -> tuple[dict[str, Any], Path]:
|
|
79
|
+
threshold = gbskernels._AUTO_KAPPA_MAX if threshold is None else threshold
|
|
80
|
+
# Calibrate the REAL path the claim is used for: the GPU auto path when the extension is
|
|
81
|
+
# importable (its kappa comes from the device *_kappa kernels), else CPU.
|
|
82
|
+
if backend is None:
|
|
83
|
+
backend = "gpu" if gbskernels.gpu_available() else "cpu"
|
|
84
|
+
rows: list[dict[str, Any]] = []
|
|
85
|
+
for func in ("perm", "haf", "lhaf", "tor"):
|
|
86
|
+
for regime in _inputs.BENCH_REGIMES:
|
|
87
|
+
for dim in _SIZES[func]:
|
|
88
|
+
for s in range(seeds):
|
|
89
|
+
A = _inputs.bench_batch(func, dim, 1, regime, 1000 + dim + s)[0]
|
|
90
|
+
fp, kappa, trusted = _eval(func, A, backend, threshold)
|
|
91
|
+
with mpmath.workdps(dps):
|
|
92
|
+
exact = _MP[func](A, dps=dps)
|
|
93
|
+
rel = float(abs(mpmath.mpc(fp) - exact) / max(abs(exact), mpmath.mpf("1e-300")))
|
|
94
|
+
rows.append({"func": func, "regime": regime, "dim": dim, "seed": s,
|
|
95
|
+
"kappa": kappa, "rel_err_fp64": rel, "trusted": trusted})
|
|
96
|
+
|
|
97
|
+
trusted = [r for r in rows if r["trusted"]]
|
|
98
|
+
per_regime = {}
|
|
99
|
+
for regime in _inputs.BENCH_REGIMES:
|
|
100
|
+
rr = [r for r in rows if r["regime"] == regime]
|
|
101
|
+
tr = [r for r in rr if r["trusted"]]
|
|
102
|
+
per_regime[regime] = {
|
|
103
|
+
"n": len(rr), "n_trusted": len(tr),
|
|
104
|
+
"max_kappa": max(r["kappa"] for r in rr),
|
|
105
|
+
"max_rel_err_when_trusted": max((r["rel_err_fp64"] for r in tr), default=0.0),
|
|
106
|
+
}
|
|
107
|
+
summary = {
|
|
108
|
+
"threshold": threshold, "eps": _EPS,
|
|
109
|
+
"n_total": len(rows), "n_trusted": len(trusted),
|
|
110
|
+
"max_rel_err_when_trusted": max((r["rel_err_fp64"] for r in trusted), default=0.0),
|
|
111
|
+
"false_trust_count": sum(1 for r in trusted if r["rel_err_fp64"] > 1e-6),
|
|
112
|
+
"log_kappa_vs_log_relerr_corr": _pearson(
|
|
113
|
+
[(math.log10(max(r["kappa"], 1.0)), math.log10(max(r["rel_err_fp64"], 1e-300))) for r in rows]),
|
|
114
|
+
"per_regime": per_regime,
|
|
115
|
+
}
|
|
116
|
+
artifact = {
|
|
117
|
+
"kind": "auto_calibration",
|
|
118
|
+
"created_utc": datetime.now(timezone.utc).isoformat(),
|
|
119
|
+
**_provenance.provenance(),
|
|
120
|
+
"indicator": "kappa = sum|terms| / |result| (a-posteriori; a HEURISTIC indicator, "
|
|
121
|
+
"NOT a rigorous error certificate)",
|
|
122
|
+
"model": "rel_err_fp64 ~ kappa * eps",
|
|
123
|
+
"backend": backend, # the path calibrated (gpu kappa kernels or cpu)
|
|
124
|
+
"gpu_backend": gbskernels.gpu_backend_kind(), # real device vs host-shim (honest provenance)
|
|
125
|
+
"params": {"dps": dps, "seeds": seeds, "backend": backend},
|
|
126
|
+
"env": {"platform": platform.platform()},
|
|
127
|
+
"summary": summary,
|
|
128
|
+
"rows": rows,
|
|
129
|
+
}
|
|
130
|
+
out = Path(out_dir) if out_dir is not None else DEFAULT_OUT
|
|
131
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
133
|
+
path = out / f"auto_calibration_{stamp}.json"
|
|
134
|
+
path.write_text(json.dumps(artifact, indent=2))
|
|
135
|
+
return artifact, path
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def main() -> None:
|
|
139
|
+
p = argparse.ArgumentParser(description=__doc__)
|
|
140
|
+
p.add_argument("--dps", type=int, default=60)
|
|
141
|
+
p.add_argument("--seeds", type=int, default=5)
|
|
142
|
+
p.add_argument("--backend", choices=["gpu", "cpu"], default=None,
|
|
143
|
+
help="path to calibrate (default: gpu if the extension is importable)")
|
|
144
|
+
p.add_argument("--out", type=Path, default=None)
|
|
145
|
+
args = p.parse_args()
|
|
146
|
+
art, path = run(dps=args.dps, seeds=args.seeds, backend=args.backend, out_dir=args.out)
|
|
147
|
+
s = art["summary"]
|
|
148
|
+
print(f"# precision='auto' calibration ({art['backend']} path / {art['gpu_backend']}) -> {path}")
|
|
149
|
+
print(f"# commit {art['commit']}; trust when kappa < {s['threshold']:.0e}; eps = {s['eps']:.2e}")
|
|
150
|
+
print(f" trusted {s['n_trusted']}/{s['n_total']}; worst FP64 rel.err when trusted = "
|
|
151
|
+
f"{s['max_rel_err_when_trusted']:.2e}; false-trust (>1e-6) = {s['false_trust_count']}")
|
|
152
|
+
print(f" log(kappa) vs log(rel.err) correlation = {s['log_kappa_vs_log_relerr_corr']}")
|
|
153
|
+
for regime, r in s["per_regime"].items():
|
|
154
|
+
print(f" {regime:>11}: max kappa {r['max_kappa']:.1e}; "
|
|
155
|
+
f"worst rel.err when trusted {r['max_rel_err_when_trusted']:.1e}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
main()
|
bench/crossover.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Batch-size sweep + accuracy-normalized GPU/CPU/Walrus crossover (docs/DESIGN.md §9).
|
|
2
|
+
|
|
3
|
+
Sweeps the public-path end-to-end throughput over a range of batch sizes and records, per
|
|
4
|
+
(function, matrix size), the GPU per-eval rate at each batch against two ~flat baselines on
|
|
5
|
+
the **same shared workload** (bench._inputs.bench_batch): our CPU reference, and the
|
|
6
|
+
**same-instance The Walrus** (measured once -- Walrus is one-at-a-time, so its per-eval rate
|
|
7
|
+
is batch-independent). At small batch the GPU's fixed H2D/launch/D2H overhead loses; as the
|
|
8
|
+
batch grows the GPU's batched throughput overtakes. The **crossover batch** -- the smallest
|
|
9
|
+
batch where the GPU median overtakes a baseline -- is the headline of the batched-throughput
|
|
10
|
+
thesis, reported separately vs the CPU and vs The Walrus.
|
|
11
|
+
|
|
12
|
+
To find the *actual* crossover (not just "the smallest batch we tried"), the sweep must include
|
|
13
|
+
**low** batches -- the default starts at 1 -- so the GPU genuinely loses at the small end.
|
|
14
|
+
|
|
15
|
+
Evidence discipline (docs/DESIGN.md §9): the artifact retains **median + IQR + the raw repetitions**
|
|
16
|
+
for GPU/CPU at every batch and for the Walrus baseline (not just medians); each series is tagged
|
|
17
|
+
with the **achieved FP64 error** (vs mpmath) and its **precision tier**; and an **official** run
|
|
18
|
+
(``strict=True``) is **fatal** if e2e reports a GPU/CPU public-path checksum disagreement at any
|
|
19
|
+
batch, or if the same-instance Walrus baseline is unavailable -- so no crossover is published
|
|
20
|
+
past a backend mismatch or without its baseline.
|
|
21
|
+
|
|
22
|
+
uv run python -m bench.crossover --batches 1,4,16,64,256,1024,4096,16384 --repeats 7
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
import numpy as np
|
|
34
|
+
|
|
35
|
+
import gbskernels
|
|
36
|
+
from bench import _inputs, _provenance, throughput_end_to_end as e2e, walrus_baseline
|
|
37
|
+
|
|
38
|
+
DEFAULT_OUT = Path(__file__).resolve().parent.parent / "results" / "throughput"
|
|
39
|
+
DEFAULT_BATCHES = [1, 4, 16, 64, 256, 1024, 4096, 16384]
|
|
40
|
+
|
|
41
|
+
_MP = {"perm": "permanent_mp", "haf": "hafnian_mp",
|
|
42
|
+
"lhaf": "loop_hafnian_mp", "tor": "torontonian_mp"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _achieved_error(func: str, dim: int, regime: str, n: int, dps: int) -> float | None:
|
|
46
|
+
"""Max relative error of the public FP64 GPU path vs the mpmath ground truth on ``n``
|
|
47
|
+
elements of the shared workload -- the accuracy the throughput is bought at. The SAME
|
|
48
|
+
matrices the sweep timed (seed convention ``1000+dim``). ``n<=0`` or ``dps<=0`` disables."""
|
|
49
|
+
if n <= 0 or dps <= 0:
|
|
50
|
+
return None
|
|
51
|
+
import highprec_ref
|
|
52
|
+
stack = _inputs.bench_batch(func, dim, n, regime, 1000 + dim)
|
|
53
|
+
fp = np.asarray(getattr(gbskernels, f"{func}_batched")(stack, backend="gpu", precision="fp64"))
|
|
54
|
+
mp_fn = getattr(highprec_ref, _MP[func])
|
|
55
|
+
rel = 0.0
|
|
56
|
+
for k in range(n):
|
|
57
|
+
ref = complex(mp_fn(stack[k], dps=dps))
|
|
58
|
+
rel = max(rel, abs(complex(fp[k]) - ref) / max(abs(ref), 1e-300))
|
|
59
|
+
return rel
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _pull(summary, raw, func, dim, backend):
|
|
63
|
+
"""(median, iqr, raw evals/sec list) for (func, dim, backend) from an e2e artifact --
|
|
64
|
+
so the crossover keeps the dispersion + raw repetitions, not just the median."""
|
|
65
|
+
row = next((r for r in summary if r["func"] == func and r["matrix_dim"] == dim), {})
|
|
66
|
+
sub = row.get(backend, {})
|
|
67
|
+
rawlist = [r["evals_per_sec"] for r in raw
|
|
68
|
+
if r["backend"] == backend and r["func"] == func and r["matrix_dim"] == dim]
|
|
69
|
+
return sub.get("evals_per_sec_median"), sub.get("evals_per_sec_iqr"), rawlist
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run(batches: list[int], repeats: int = 7, seed: int = 0, warmup: int = 2,
|
|
73
|
+
regime: str = "physical", error_n: int = 2, error_dps: int = 30,
|
|
74
|
+
strict: bool = True, out_dir: Path | None = None) -> tuple[dict[str, Any], Path]:
|
|
75
|
+
base = Path(out_dir) if out_dir is not None else DEFAULT_OUT
|
|
76
|
+
tmp = base / "_sweep_tmp"
|
|
77
|
+
|
|
78
|
+
# GPU + CPU per-eval median/IQR/raw at each batch (same shared workload as the Walrus
|
|
79
|
+
# baseline). An e2e GPU/CPU checksum disagreement is FATAL for an official (strict) run.
|
|
80
|
+
curves: dict[tuple[str, int], list[dict[str, Any]]] = {}
|
|
81
|
+
for b in sorted(set(batches)):
|
|
82
|
+
art, _ = e2e.run(batch=b, repeats=repeats, seed=seed, warmup=warmup,
|
|
83
|
+
regime=regime, out_dir=tmp)
|
|
84
|
+
if strict and not art["all_backends_agree"]:
|
|
85
|
+
bad = [f"{r['func']}/d{r['matrix_dim']}" for r in art["summary"] if not r["backends_agree"]]
|
|
86
|
+
raise RuntimeError(
|
|
87
|
+
f"crossover ABORT: e2e GPU/CPU public-path checksum disagreement at batch {b} "
|
|
88
|
+
f"(regime {regime}) on {bad} -- no official crossover from a backend mismatch.")
|
|
89
|
+
for row in art["summary"]:
|
|
90
|
+
func, dim = row["func"], row["matrix_dim"]
|
|
91
|
+
gm, gi, gr = _pull(art["summary"], art["raw"], func, dim, "gpu")
|
|
92
|
+
cm, ci, cr = _pull(art["summary"], art["raw"], func, dim, "cpu")
|
|
93
|
+
curves.setdefault((func, dim), []).append({
|
|
94
|
+
"batch": b, "gpu_median": gm, "gpu_iqr": gi, "gpu_raw": gr,
|
|
95
|
+
"cpu_median": cm, "cpu_iqr": ci, "cpu_raw": cr})
|
|
96
|
+
|
|
97
|
+
# The Walrus per-eval rate is batch-independent (one-at-a-time) -> measure it ONCE on the
|
|
98
|
+
# same workload, keeping median + IQR + raw. Unavailable Walrus is FATAL for a strict run.
|
|
99
|
+
walrus: dict[tuple[str, int], dict[str, Any]] = {}
|
|
100
|
+
walrus_meta: dict[str, Any]
|
|
101
|
+
try:
|
|
102
|
+
wb_batch = max(min(max(batches), 128), 8)
|
|
103
|
+
wart, _ = walrus_baseline.run(batch=wb_batch, repeats=repeats, seed=seed,
|
|
104
|
+
warmup=warmup, regime=regime, out_dir=tmp)
|
|
105
|
+
for r in wart["rows"]:
|
|
106
|
+
rraw = [x["evals_per_sec"] for x in wart["raw"]
|
|
107
|
+
if x["func"] == r["func"] and x["matrix_dim"] == r["matrix_dim"]]
|
|
108
|
+
walrus[(r["func"], r["matrix_dim"])] = {
|
|
109
|
+
"median": r["evals_per_sec_median"], "iqr": r["evals_per_sec_iqr"], "raw": rraw}
|
|
110
|
+
walrus_meta = {"library_version": wart["library_version"], "batch": wb_batch}
|
|
111
|
+
except Exception as e: # pragma: no cover - depends on the box
|
|
112
|
+
walrus_meta = {"skipped": str(e)}
|
|
113
|
+
if strict and "skipped" in walrus_meta:
|
|
114
|
+
raise RuntimeError(
|
|
115
|
+
f"crossover ABORT: the same-instance The Walrus baseline is unavailable "
|
|
116
|
+
f"({walrus_meta['skipped']}) -- an official crossover requires it.")
|
|
117
|
+
|
|
118
|
+
series = []
|
|
119
|
+
for (func, dim), pts in sorted(curves.items()):
|
|
120
|
+
pts.sort(key=lambda p: p["batch"])
|
|
121
|
+
wb = walrus.get((func, dim), {})
|
|
122
|
+
wmed = wb.get("median")
|
|
123
|
+
|
|
124
|
+
def _gt(p, beat):
|
|
125
|
+
return p["gpu_median"] is not None and beat is not None and p["gpu_median"] > beat
|
|
126
|
+
|
|
127
|
+
x_cpu = next((p["batch"] for p in pts if _gt(p, p["cpu_median"])), None)
|
|
128
|
+
x_wal = next((p["batch"] for p in pts if _gt(p, wmed)), None)
|
|
129
|
+
x_best = next((p["batch"] for p in pts
|
|
130
|
+
if p["gpu_median"] is not None
|
|
131
|
+
and any(v is not None for v in (p["cpu_median"], wmed))
|
|
132
|
+
and p["gpu_median"] > max(v for v in (p["cpu_median"], wmed) if v is not None)),
|
|
133
|
+
None)
|
|
134
|
+
series.append({
|
|
135
|
+
"func": func, "matrix_dim": dim, "precision_tier": "fp64",
|
|
136
|
+
"achieved_rel_err_fp64": _achieved_error(func, dim, regime, error_n, error_dps),
|
|
137
|
+
"walrus_median": wmed, "walrus_iqr": wb.get("iqr"), "walrus_raw": wb.get("raw"),
|
|
138
|
+
"crossover_batch_vs_cpu": x_cpu,
|
|
139
|
+
"crossover_batch_vs_walrus": x_wal,
|
|
140
|
+
"crossover_batch_vs_best": x_best,
|
|
141
|
+
"points": pts,
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
artifact = {
|
|
145
|
+
"kind": "crossover_batch_sweep",
|
|
146
|
+
"created_utc": datetime.now(timezone.utc).isoformat(),
|
|
147
|
+
**_provenance.provenance(),
|
|
148
|
+
"gpu_backend": gbskernels.gpu_backend_kind(),
|
|
149
|
+
"metric": "per-eval evals/sec (public path) vs batch; GPU rises, CPU & same-instance "
|
|
150
|
+
"Walrus are ~flat baselines on the same workload; crossover = smallest batch "
|
|
151
|
+
"where GPU median > baseline; median+IQR+raw retained; achieved FP64 error per series",
|
|
152
|
+
"params": {"batches": sorted(set(batches)), "repeats": repeats, "warmup": warmup,
|
|
153
|
+
"regime": regime, "seed": seed, "strict": strict,
|
|
154
|
+
"error_n": error_n, "error_dps": error_dps},
|
|
155
|
+
"walrus": walrus_meta,
|
|
156
|
+
"series": series,
|
|
157
|
+
}
|
|
158
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
160
|
+
path = base / f"crossover_{stamp}.json"
|
|
161
|
+
path.write_text(json.dumps(artifact, indent=2))
|
|
162
|
+
return artifact, path
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def main() -> None:
|
|
166
|
+
p = argparse.ArgumentParser(description=__doc__)
|
|
167
|
+
p.add_argument("--batches", default=",".join(map(str, DEFAULT_BATCHES)),
|
|
168
|
+
help="comma-separated batch sizes to sweep (start low to find the real crossover)")
|
|
169
|
+
p.add_argument("--repeats", type=int, default=7)
|
|
170
|
+
p.add_argument("--warmup", type=int, default=2)
|
|
171
|
+
p.add_argument("--seed", type=int, default=0)
|
|
172
|
+
p.add_argument("--regime", default="physical", choices=list(_inputs.BENCH_REGIMES))
|
|
173
|
+
p.add_argument("--error-n", type=int, default=2, help="elements checked vs mpmath (0=off)")
|
|
174
|
+
p.add_argument("--error-dps", type=int, default=30, help="mpmath precision for the error check")
|
|
175
|
+
p.add_argument("--no-strict", dest="strict", action="store_false",
|
|
176
|
+
help="do NOT fail on e2e disagreement / missing Walrus (non-official run)")
|
|
177
|
+
p.add_argument("--out", type=Path, default=None)
|
|
178
|
+
args = p.parse_args()
|
|
179
|
+
batches = [int(x) for x in args.batches.split(",")]
|
|
180
|
+
artifact, path = run(batches, args.repeats, seed=args.seed, warmup=args.warmup, regime=args.regime,
|
|
181
|
+
error_n=args.error_n, error_dps=args.error_dps, strict=args.strict, out_dir=args.out)
|
|
182
|
+
w = artifact["walrus"]
|
|
183
|
+
print(f"# batch-size sweep / crossover ({artifact['gpu_backend']}) -> {path}")
|
|
184
|
+
print(f"# commit {artifact['commit']}; container {artifact['container_digest']}; "
|
|
185
|
+
f"regime={args.regime}; strict={args.strict}; batches={batches}")
|
|
186
|
+
print(f"# walrus baseline: {w.get('library_version', 'SKIPPED: ' + str(w.get('skipped')))}")
|
|
187
|
+
print(f" {'func':>5} {'dim':>4} {'fp64 rel.err':>12} {'x vs cpu':>9} {'x vs walrus':>11}")
|
|
188
|
+
for s in artifact["series"]:
|
|
189
|
+
err = s["achieved_rel_err_fp64"]
|
|
190
|
+
print(f" {s['func']:>5} {s['matrix_dim']:>4} {('%.2e' % err) if err is not None else 'n/a':>12} "
|
|
191
|
+
f"{str(s['crossover_batch_vs_cpu']):>9} {str(s['crossover_batch_vs_walrus']):>11}")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
main()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Static per-thread local-memory footprint of the one-eval-per-thread kernels.
|
|
2
|
+
|
|
3
|
+
This is the *static* half of "profile register spills / local-memory traffic"
|
|
4
|
+
(perf research item 5): it accounts, from the kernel sources, the per-thread local
|
|
5
|
+
arrays each kernel declares -- the quantity that drives register spilling and the
|
|
6
|
+
local-memory (off-chip) traffic that bounds the hard kernels. The *dynamic* half
|
|
7
|
+
(ncu/nvprof spill counts, achieved occupancy, DRAM throughput) needs a real device
|
|
8
|
+
and is run in a GPU session.
|
|
9
|
+
|
|
10
|
+
Why this matters, and why it is NOT optimization-by-analogy: the measured cooperative
|
|
11
|
+
results map exactly onto the footprint below. The permanent's per-thread state is a
|
|
12
|
+
single length-n vector (sub-KB -> lives in registers, no spill), so its bottleneck is
|
|
13
|
+
the *serial 2^(n-1) Glynn chain* and splitting that across a group wins ~5x. The
|
|
14
|
+
hafnian / loop hafnian / torontonian instead carry several n x n matrices per thread
|
|
15
|
+
(tens of KB -> spills to local/off-chip memory), so they are memory-bound on the
|
|
16
|
+
*per-subset* work; splitting their (short, 2^(N/2)) subset sum cannot help and adds
|
|
17
|
+
launch + global-partials overhead -- which is exactly what was measured (haf 0.6x,
|
|
18
|
+
lhaf 0.5x, tor 1.2x). The lever for the hard kernels is the per-thread footprint.
|
|
19
|
+
|
|
20
|
+
uv run python -m bench.kernel_footprint
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
CPLX = 16 # bytes per cuDoubleComplex (2 x double)
|
|
31
|
+
DBL = 8
|
|
32
|
+
|
|
33
|
+
# Per-thread local arrays each one-eval-per-thread kernel declares, as
|
|
34
|
+
# (name, element_bytes, size_expr(maxdim)). maxdim is the kernel's compile-time cap
|
|
35
|
+
# (HAF_MAX_N etc.); the arrays are sized for it REGARDLESS of the actual matrix size,
|
|
36
|
+
# which is the core inefficiency for small inputs. Transcribed from core/*.cu.
|
|
37
|
+
_KERNELS: dict[str, dict[str, Any]] = {
|
|
38
|
+
"permanent": {
|
|
39
|
+
"cap": 28, "cap_name": "PERM_MAX_N",
|
|
40
|
+
"buffers": [("rowsum", CPLX, lambda M: M)],
|
|
41
|
+
},
|
|
42
|
+
"hafnian": {
|
|
43
|
+
"cap": 20, "cap_name": "HAF_MAX_N",
|
|
44
|
+
"buffers": [("BX", CPLX, lambda M: M * M), ("P", CPLX, lambda M: M * M),
|
|
45
|
+
("T", CPLX, lambda M: M * M), ("p", CPLX, lambda M: M + 1),
|
|
46
|
+
("e", CPLX, lambda M: M + 1), ("pidx", 4, lambda M: M // 2)],
|
|
47
|
+
},
|
|
48
|
+
"loop_hafnian": {
|
|
49
|
+
"cap": 20, "cap_name": "LHAF_MAX_N",
|
|
50
|
+
"buffers": [("C", CPLX, lambda M: M * M), ("P", CPLX, lambda M: M * M),
|
|
51
|
+
("Q", CPLX, lambda M: M * M), ("T", CPLX, lambda M: M * M),
|
|
52
|
+
("d", CPLX, lambda M: M), ("w", CPLX, lambda M: M),
|
|
53
|
+
("kg", CPLX, lambda M: M + 1), ("e", CPLX, lambda M: M + 1),
|
|
54
|
+
("pidx", 4, lambda M: M // 2)],
|
|
55
|
+
},
|
|
56
|
+
"torontonian": {
|
|
57
|
+
"cap": 24, "cap_name": "TOR_MAX_DIM",
|
|
58
|
+
"buffers": [("sub", CPLX, lambda M: M * M), ("idx", 4, lambda M: M)],
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# A 64 KB/thread guideline: above this, an SM (with e.g. 64-99 KB usable as registers
|
|
63
|
+
# + L1) cannot hold even a handful of threads' working sets, so the per-thread arrays
|
|
64
|
+
# spill to off-chip local memory. The practical spill cliff for these kernels is well
|
|
65
|
+
# below: the register file is 256 KB/SM = 1 KB / thread at full occupancy, so anything
|
|
66
|
+
# past ~1 KB already starts spilling. We report both the footprint and the implied
|
|
67
|
+
# occupancy ceiling so the GPU session knows what to confirm with ncu.
|
|
68
|
+
_REGFILE_PER_SM = 256 * 1024 # bytes (Ampere/Ada: 64K 32-bit registers)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def footprint(kernel: str, dim: int) -> int:
|
|
72
|
+
"""Per-thread local-array bytes for `kernel` if its buffers were sized to `dim`."""
|
|
73
|
+
return sum(eb * sz(dim) for _, eb, sz in _KERNELS[kernel]["buffers"])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def report() -> dict[str, Any]:
|
|
77
|
+
rows = []
|
|
78
|
+
for name, spec in _KERNELS.items():
|
|
79
|
+
cap = spec["cap"]
|
|
80
|
+
at_cap = footprint(name, cap)
|
|
81
|
+
# threads/SM the register file can hold before spilling (rough occupancy ceiling)
|
|
82
|
+
threads = _REGFILE_PER_SM // max(at_cap, 1)
|
|
83
|
+
# for the hard kernels the matrices are usually small; show a small/typical size
|
|
84
|
+
small = 8 if name != "torontonian" else 8 # dim 8 (haf/lhaf N=8; tor 2n=8)
|
|
85
|
+
rows.append({
|
|
86
|
+
"kernel": name, "cap_name": spec["cap_name"], "cap": cap,
|
|
87
|
+
"bytes_at_cap": at_cap, "kb_at_cap": round(at_cap / 1024, 1),
|
|
88
|
+
"bytes_at_dim8": footprint(name, small),
|
|
89
|
+
"kb_at_dim8": round(footprint(name, small) / 1024, 2),
|
|
90
|
+
"occupancy_threads_per_sm_at_cap": threads,
|
|
91
|
+
"n_nxn_buffers": sum(1 for nm, _, sz in spec["buffers"] if sz(10) == 100),
|
|
92
|
+
})
|
|
93
|
+
return {"kind": "kernel_footprint", "regfile_per_sm_bytes": _REGFILE_PER_SM, "rows": rows}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main() -> None:
|
|
97
|
+
p = argparse.ArgumentParser(description=__doc__)
|
|
98
|
+
p.add_argument("--json", type=Path, default=None, help="also write the report as JSON")
|
|
99
|
+
args = p.parse_args()
|
|
100
|
+
rep = report()
|
|
101
|
+
print("# per-thread local-memory footprint (static; the spill source)")
|
|
102
|
+
print(f"# register file/SM = {_REGFILE_PER_SM // 1024} KB "
|
|
103
|
+
f"(~1 KB/thread at full occupancy -> anything past that spills)")
|
|
104
|
+
print(f" {'kernel':>13} {'cap':>14} {'KB@cap':>7} {'KB@dim8':>8} {'nxn bufs':>9} "
|
|
105
|
+
f"{'occ thr/SM@cap':>15}")
|
|
106
|
+
for r in rep["rows"]:
|
|
107
|
+
print(f" {r['kernel']:>13} {r['cap_name']+'='+str(r['cap']):>14} "
|
|
108
|
+
f"{r['kb_at_cap']:>7} {r['kb_at_dim8']:>8} {r['n_nxn_buffers']:>9} "
|
|
109
|
+
f"{r['occupancy_threads_per_sm_at_cap']:>15}")
|
|
110
|
+
print("\n# Reading: the permanent's <1 KB lives in registers (no spill); the hard")
|
|
111
|
+
print("# kernels carry several n x n matrices (tens of KB) -> spill -> memory-bound")
|
|
112
|
+
print("# on the per-subset work. Sizing buffers to the ACTUAL dim (size specialization)")
|
|
113
|
+
print("# is the lever -- e.g. hafnian at dim 8 is ~6x smaller than at the cap.")
|
|
114
|
+
if args.json:
|
|
115
|
+
args.json.write_text(json.dumps(rep, indent=2))
|
|
116
|
+
print(f"\n# wrote {args.json}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
main()
|