flopscope 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.
- benchmarks/__init__.py +1 -0
- benchmarks/__main__.py +6 -0
- benchmarks/_baseline.py +171 -0
- benchmarks/_bitwise.py +231 -0
- benchmarks/_complex.py +176 -0
- benchmarks/_contractions.py +291 -0
- benchmarks/_fft.py +198 -0
- benchmarks/_impl_urls.py +139 -0
- benchmarks/_linalg.py +197 -0
- benchmarks/_linalg_delegates.py +407 -0
- benchmarks/_metadata.py +141 -0
- benchmarks/_misc.py +653 -0
- benchmarks/_perf.py +321 -0
- benchmarks/_perm_group_calibration.py +175 -0
- benchmarks/_pointwise.py +372 -0
- benchmarks/_polynomial.py +193 -0
- benchmarks/_random.py +209 -0
- benchmarks/_reductions.py +136 -0
- benchmarks/_sorting.py +289 -0
- benchmarks/_stats.py +137 -0
- benchmarks/_window.py +92 -0
- benchmarks/accumulation/__init__.py +0 -0
- benchmarks/accumulation/bench_cost_compute.py +138 -0
- benchmarks/dashboard.py +312 -0
- benchmarks/runner.py +636 -0
- flopscope/__init__.py +273 -0
- flopscope/_accumulation/__init__.py +13 -0
- flopscope/_accumulation/_bipartite.py +121 -0
- flopscope/_accumulation/_burnside.py +51 -0
- flopscope/_accumulation/_cache.py +146 -0
- flopscope/_accumulation/_components.py +153 -0
- flopscope/_accumulation/_cost.py +1414 -0
- flopscope/_accumulation/_cost_descriptions.py +63 -0
- flopscope/_accumulation/_detection.py +318 -0
- flopscope/_accumulation/_ladder.py +191 -0
- flopscope/_accumulation/_output_orbit.py +104 -0
- flopscope/_accumulation/_partition.py +290 -0
- flopscope/_accumulation/_path_info.py +211 -0
- flopscope/_accumulation/_public.py +169 -0
- flopscope/_accumulation/_reduction.py +310 -0
- flopscope/_accumulation/_regimes.py +303 -0
- flopscope/_accumulation/_shape.py +33 -0
- flopscope/_accumulation/_wreath.py +209 -0
- flopscope/_budget.py +1027 -0
- flopscope/_config.py +118 -0
- flopscope/_counting_ops.py +451 -0
- flopscope/_display.py +478 -0
- flopscope/_docstrings.py +59 -0
- flopscope/_dtypes.py +20 -0
- flopscope/_einsum.py +717 -0
- flopscope/_errstate.py +25 -0
- flopscope/_flops.py +282 -0
- flopscope/_free_ops.py +2654 -0
- flopscope/_ndarray.py +1126 -0
- flopscope/_opt_einsum/LICENSE +21 -0
- flopscope/_opt_einsum/NOTICE +59 -0
- flopscope/_opt_einsum/__init__.py +209 -0
- flopscope/_opt_einsum/_contract.py +1478 -0
- flopscope/_opt_einsum/_helpers.py +164 -0
- flopscope/_opt_einsum/_hsluv.py +273 -0
- flopscope/_opt_einsum/_path_random.py +462 -0
- flopscope/_opt_einsum/_paths.py +1653 -0
- flopscope/_opt_einsum/_subgraph_symmetry.py +544 -0
- flopscope/_opt_einsum/_symmetry.py +140 -0
- flopscope/_opt_einsum/_typing.py +37 -0
- flopscope/_perm_group.py +717 -0
- flopscope/_pointwise.py +2522 -0
- flopscope/_polynomial.py +278 -0
- flopscope/_registry.py +3216 -0
- flopscope/_sorting_ops.py +571 -0
- flopscope/_symmetric.py +812 -0
- flopscope/_symmetry_transport.py +510 -0
- flopscope/_symmetry_utils.py +669 -0
- flopscope/_type_info.py +12 -0
- flopscope/_unwrap.py +70 -0
- flopscope/_validation.py +83 -0
- flopscope/_version_check.py +46 -0
- flopscope/_weights.py +195 -0
- flopscope/_window.py +177 -0
- flopscope/accounting.py +565 -0
- flopscope/data/default_weights.json +462 -0
- flopscope/data/weights.csv +509 -0
- flopscope/errors.py +197 -0
- flopscope/numpy/__init__.py +878 -0
- flopscope/numpy/fft/__init__.py +55 -0
- flopscope/numpy/fft/_free.py +51 -0
- flopscope/numpy/fft/_transforms.py +695 -0
- flopscope/numpy/linalg/__init__.py +105 -0
- flopscope/numpy/linalg/_aliases.py +126 -0
- flopscope/numpy/linalg/_compound.py +161 -0
- flopscope/numpy/linalg/_decompositions.py +353 -0
- flopscope/numpy/linalg/_properties.py +533 -0
- flopscope/numpy/linalg/_solvers.py +444 -0
- flopscope/numpy/linalg/_svd.py +122 -0
- flopscope/numpy/random/__init__.py +684 -0
- flopscope/numpy/random/_cost_formulas.py +115 -0
- flopscope/numpy/random/_counted_classes.py +241 -0
- flopscope/numpy/testing/__init__.py +13 -0
- flopscope/numpy/typing/__init__.py +30 -0
- flopscope/py.typed +0 -0
- flopscope/stats/__init__.py +84 -0
- flopscope/stats/_base.py +77 -0
- flopscope/stats/_cauchy.py +146 -0
- flopscope/stats/_erf.py +190 -0
- flopscope/stats/_expon.py +146 -0
- flopscope/stats/_laplace.py +150 -0
- flopscope/stats/_logistic.py +148 -0
- flopscope/stats/_lognorm.py +160 -0
- flopscope/stats/_ndtri.py +133 -0
- flopscope/stats/_norm.py +149 -0
- flopscope/stats/_truncnorm.py +186 -0
- flopscope/stats/_uniform.py +141 -0
- flopscope-0.2.0.dist-info/METADATA +23 -0
- flopscope-0.2.0.dist-info/RECORD +115 -0
- flopscope-0.2.0.dist-info/WHEEL +4 -0
benchmarks/runner.py
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
"""Benchmark runner — CLI orchestrator for flopscope benchmarks.
|
|
2
|
+
|
|
3
|
+
Methodology v2.0
|
|
4
|
+
-----------------
|
|
5
|
+
Every benchmark category returns ``alpha(op)`` values: the ratio of measured
|
|
6
|
+
hardware cost (FP instructions or elapsed time) to the analytical FLOP count.
|
|
7
|
+
The runner normalises all alphas by ``alpha(add)`` (the baseline) to produce
|
|
8
|
+
the final weights::
|
|
9
|
+
|
|
10
|
+
weight(op) = alpha(op) / alpha(add)
|
|
11
|
+
|
|
12
|
+
This unification means *all* categories — including linalg and FFT — go
|
|
13
|
+
through the same normalisation path. Raw alpha values are preserved in
|
|
14
|
+
``meta.validation.absolute_correction_factors`` for scientific analysis.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import time as _time
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from benchmarks._baseline import BaselineResult, measure_baseline, measure_baselines
|
|
27
|
+
from benchmarks._fft import benchmark_fft
|
|
28
|
+
from benchmarks._linalg import benchmark_linalg
|
|
29
|
+
from benchmarks._metadata import collect_metadata
|
|
30
|
+
from benchmarks._pointwise import benchmark_pointwise
|
|
31
|
+
from benchmarks._polynomial import benchmark_polynomial
|
|
32
|
+
from benchmarks._random import benchmark_random
|
|
33
|
+
from benchmarks._reductions import benchmark_reductions
|
|
34
|
+
from benchmarks._sorting import benchmark_sorting
|
|
35
|
+
from benchmarks.dashboard import render_html, render_terminal
|
|
36
|
+
|
|
37
|
+
# -- optional new-category imports (modules may not exist yet) -------------
|
|
38
|
+
try:
|
|
39
|
+
from benchmarks._contractions import benchmark_contractions
|
|
40
|
+
except ImportError:
|
|
41
|
+
benchmark_contractions = None # type: ignore[assignment]
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
from benchmarks._misc import benchmark_misc
|
|
45
|
+
except ImportError:
|
|
46
|
+
benchmark_misc = None # type: ignore[assignment]
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
from benchmarks._window import benchmark_window
|
|
50
|
+
except ImportError:
|
|
51
|
+
benchmark_window = None # type: ignore[assignment]
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
from benchmarks._bitwise import benchmark_bitwise
|
|
55
|
+
except ImportError:
|
|
56
|
+
benchmark_bitwise = None # type: ignore[assignment]
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
from benchmarks._complex import benchmark_complex
|
|
60
|
+
except ImportError:
|
|
61
|
+
benchmark_complex = None # type: ignore[assignment]
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
from benchmarks._linalg_delegates import benchmark_linalg_delegates
|
|
65
|
+
except ImportError:
|
|
66
|
+
benchmark_linalg_delegates = None # type: ignore[assignment]
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
from benchmarks._stats import benchmark_stats
|
|
70
|
+
except ImportError:
|
|
71
|
+
benchmark_stats = None # type: ignore[assignment]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
ALL_CATEGORIES = sorted(
|
|
75
|
+
{
|
|
76
|
+
"pointwise",
|
|
77
|
+
"reductions",
|
|
78
|
+
"sorting",
|
|
79
|
+
"random",
|
|
80
|
+
"polynomial",
|
|
81
|
+
"linalg",
|
|
82
|
+
"fft",
|
|
83
|
+
"contractions",
|
|
84
|
+
"misc",
|
|
85
|
+
"window",
|
|
86
|
+
"bitwise",
|
|
87
|
+
"complex",
|
|
88
|
+
"linalg_delegates",
|
|
89
|
+
"stats",
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
_BENCHMARK_FUNCS: dict[str, Any] = {
|
|
94
|
+
"pointwise": benchmark_pointwise,
|
|
95
|
+
"reductions": benchmark_reductions,
|
|
96
|
+
"linalg": benchmark_linalg,
|
|
97
|
+
"fft": benchmark_fft,
|
|
98
|
+
"sorting": benchmark_sorting,
|
|
99
|
+
"random": benchmark_random,
|
|
100
|
+
"polynomial": benchmark_polynomial,
|
|
101
|
+
}
|
|
102
|
+
# Register new categories only if their modules are available.
|
|
103
|
+
if benchmark_contractions is not None:
|
|
104
|
+
_BENCHMARK_FUNCS["contractions"] = benchmark_contractions
|
|
105
|
+
if benchmark_misc is not None:
|
|
106
|
+
_BENCHMARK_FUNCS["misc"] = benchmark_misc
|
|
107
|
+
if benchmark_window is not None:
|
|
108
|
+
_BENCHMARK_FUNCS["window"] = benchmark_window
|
|
109
|
+
if benchmark_bitwise is not None:
|
|
110
|
+
_BENCHMARK_FUNCS["bitwise"] = benchmark_bitwise
|
|
111
|
+
if benchmark_complex is not None:
|
|
112
|
+
_BENCHMARK_FUNCS["complex"] = benchmark_complex
|
|
113
|
+
if benchmark_linalg_delegates is not None:
|
|
114
|
+
_BENCHMARK_FUNCS["linalg_delegates"] = benchmark_linalg_delegates
|
|
115
|
+
if benchmark_stats is not None:
|
|
116
|
+
_BENCHMARK_FUNCS["stats"] = benchmark_stats
|
|
117
|
+
|
|
118
|
+
# Approximate op counts per category (for progress bar total)
|
|
119
|
+
_APPROX_OP_COUNTS: dict[str, int] = {
|
|
120
|
+
"pointwise": 70,
|
|
121
|
+
"reductions": 28,
|
|
122
|
+
"linalg": 14,
|
|
123
|
+
"fft": 14,
|
|
124
|
+
"sorting": 7,
|
|
125
|
+
"random": 10,
|
|
126
|
+
"polynomial": 10,
|
|
127
|
+
"contractions": 10,
|
|
128
|
+
"misc": 25,
|
|
129
|
+
"window": 5,
|
|
130
|
+
"bitwise": 14,
|
|
131
|
+
"complex": 11,
|
|
132
|
+
"linalg_delegates": 15,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def normalize_weights(
|
|
137
|
+
raw_alpha: dict[str, float], alpha_add: float
|
|
138
|
+
) -> dict[str, float]:
|
|
139
|
+
"""Old normalization: divide by alpha(add). Kept for timing validation."""
|
|
140
|
+
if alpha_add == 0:
|
|
141
|
+
alpha_add = 1.0
|
|
142
|
+
return {k: v / alpha_add for k, v in raw_alpha.items()}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def normalize_weights_v2(
|
|
146
|
+
raw_alpha: dict[str, float],
|
|
147
|
+
all_details: dict[str, dict],
|
|
148
|
+
baselines: BaselineResult,
|
|
149
|
+
) -> dict[str, float]:
|
|
150
|
+
"""Subtract per-category ufunc overhead for counted operations.
|
|
151
|
+
|
|
152
|
+
Each operation's raw alpha is adjusted by subtracting the overhead
|
|
153
|
+
attributable to numpy's ufunc dispatch layer. The ``measurement_mode``
|
|
154
|
+
field in each op's detail dict determines which overhead to subtract:
|
|
155
|
+
|
|
156
|
+
- ``ufunc_unary``: subtract alpha(abs) — pure unary ufunc overhead
|
|
157
|
+
- ``ufunc_binary``: subtract alpha(add) - 1.0 — binary ufunc overhead
|
|
158
|
+
- ``ufunc_reduction``: same as unary
|
|
159
|
+
- ``blas``, ``linalg``, ``custom``: subtract 0 (no ufunc layer)
|
|
160
|
+
- ``instructions``: subtract 0 (different counter, different overhead)
|
|
161
|
+
|
|
162
|
+
After subtraction, clamp to 0.0 (no negative weights). Values below 1.0
|
|
163
|
+
are expected for ops with less FP work than the overhead measurement
|
|
164
|
+
(e.g., bitwise ops that generate 0 FP instructions). Known analytical
|
|
165
|
+
zero-FLOP operations are injected separately with weight 0.0 because they
|
|
166
|
+
are not benchmarked.
|
|
167
|
+
|
|
168
|
+
Note: BLAS/linalg ops that are pure FMA loops will show weight ≈ 1.0
|
|
169
|
+
because both flopscope's FMA=2 analytical count and
|
|
170
|
+
``fp_arith_inst_retired`` count each FMA as 2 ops, so the ratio cancels.
|
|
171
|
+
"""
|
|
172
|
+
weights = {}
|
|
173
|
+
for op, alpha in raw_alpha.items():
|
|
174
|
+
mode = all_details.get(op, {}).get("measurement_mode", "ufunc_unary")
|
|
175
|
+
overhead = baselines.overhead_for_mode(mode)
|
|
176
|
+
weights[op] = max(alpha - overhead, 0.0)
|
|
177
|
+
return weights
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _load_known_free_ops() -> set[str]:
|
|
181
|
+
"""Return analytical zero-FLOP operations from the live registry."""
|
|
182
|
+
repo_root = os.path.dirname(os.path.dirname(__file__))
|
|
183
|
+
src_root = os.path.join(repo_root, "src")
|
|
184
|
+
if src_root not in sys.path:
|
|
185
|
+
sys.path.insert(0, src_root)
|
|
186
|
+
|
|
187
|
+
from flopscope._registry import REGISTRY # type: ignore
|
|
188
|
+
|
|
189
|
+
return {name for name, info in REGISTRY.items() if info.get("category") == "free"}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _compute_validation_stats(
|
|
193
|
+
perf_weights: dict[str, float],
|
|
194
|
+
timing_weights: dict[str, float],
|
|
195
|
+
) -> dict[str, Any]:
|
|
196
|
+
"""Compute Pearson r, Spearman rho, and max divergence between two weight sets.
|
|
197
|
+
|
|
198
|
+
Returns a dict suitable for ``meta.validation.perf_vs_timing``.
|
|
199
|
+
If scipy is unavailable or there are fewer than 3 common ops, returns
|
|
200
|
+
an empty dict with a ``note`` explaining why.
|
|
201
|
+
"""
|
|
202
|
+
common_ops = sorted(set(perf_weights) & set(timing_weights))
|
|
203
|
+
if len(common_ops) < 3:
|
|
204
|
+
return {"note": f"too few common ops ({len(common_ops)}) for validation"}
|
|
205
|
+
|
|
206
|
+
perf_vals = [perf_weights[op] for op in common_ops]
|
|
207
|
+
timing_vals = [timing_weights[op] for op in common_ops]
|
|
208
|
+
|
|
209
|
+
stats: dict[str, Any] = {}
|
|
210
|
+
|
|
211
|
+
# Pearson & Spearman (optional scipy dependency)
|
|
212
|
+
try:
|
|
213
|
+
from scipy.stats import pearsonr, spearmanr
|
|
214
|
+
|
|
215
|
+
r, _ = pearsonr(perf_vals, timing_vals)
|
|
216
|
+
rho, _ = spearmanr(perf_vals, timing_vals)
|
|
217
|
+
stats["pearson_r"] = round(r, 6)
|
|
218
|
+
stats["spearman_rho"] = round(rho, 6)
|
|
219
|
+
except ImportError:
|
|
220
|
+
stats["note"] = "scipy not available — correlation stats skipped"
|
|
221
|
+
|
|
222
|
+
# Max divergence
|
|
223
|
+
max_div_op = ""
|
|
224
|
+
max_div_ratio = 0.0
|
|
225
|
+
for op in common_ops:
|
|
226
|
+
pw = perf_weights[op]
|
|
227
|
+
tw = timing_weights[op]
|
|
228
|
+
if tw > 0:
|
|
229
|
+
ratio = abs(pw / tw - 1.0)
|
|
230
|
+
elif pw > 0:
|
|
231
|
+
ratio = float("inf")
|
|
232
|
+
else:
|
|
233
|
+
ratio = 0.0
|
|
234
|
+
if ratio > max_div_ratio:
|
|
235
|
+
max_div_ratio = ratio
|
|
236
|
+
max_div_op = op
|
|
237
|
+
|
|
238
|
+
if max_div_op:
|
|
239
|
+
stats["max_divergence"] = {
|
|
240
|
+
"op": max_div_op,
|
|
241
|
+
"perf_weight": round(perf_weights[max_div_op], 4),
|
|
242
|
+
"timing_weight": round(timing_weights[max_div_op], 4),
|
|
243
|
+
"ratio": round(
|
|
244
|
+
perf_weights[max_div_op] / timing_weights[max_div_op]
|
|
245
|
+
if timing_weights[max_div_op]
|
|
246
|
+
else float("inf"),
|
|
247
|
+
4,
|
|
248
|
+
),
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return stats
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _unpack_benchmark_result(
|
|
255
|
+
result: dict[str, float] | tuple[dict[str, float], dict[str, dict]],
|
|
256
|
+
) -> tuple[dict[str, float], dict[str, dict]]:
|
|
257
|
+
"""Unpack a benchmark function return value.
|
|
258
|
+
|
|
259
|
+
Handles both the legacy ``dict`` return and the new ``(alphas, details)``
|
|
260
|
+
tuple return for backward compatibility.
|
|
261
|
+
"""
|
|
262
|
+
if isinstance(result, tuple):
|
|
263
|
+
return result
|
|
264
|
+
return result, {}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _run_category_loop(
|
|
268
|
+
cats: list[str],
|
|
269
|
+
dtype: str,
|
|
270
|
+
repeats: int,
|
|
271
|
+
log_fn: Any,
|
|
272
|
+
use_rich: bool = False,
|
|
273
|
+
console: Any = None,
|
|
274
|
+
) -> tuple[dict[str, float], dict[str, dict]]:
|
|
275
|
+
"""Run all benchmark categories and collect raw alpha(op) values.
|
|
276
|
+
|
|
277
|
+
Returns a tuple of ``(alphas, details)`` where *alphas* maps op names to
|
|
278
|
+
raw alpha values and *details* maps op names to per-op benchmark metadata.
|
|
279
|
+
"""
|
|
280
|
+
alphas: dict[str, float] = {}
|
|
281
|
+
all_details: dict[str, dict] = {}
|
|
282
|
+
|
|
283
|
+
if use_rich:
|
|
284
|
+
from rich.progress import (
|
|
285
|
+
BarColumn,
|
|
286
|
+
MofNCompleteColumn,
|
|
287
|
+
Progress,
|
|
288
|
+
SpinnerColumn,
|
|
289
|
+
TextColumn,
|
|
290
|
+
TimeElapsedColumn,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
total_ops = sum(_APPROX_OP_COUNTS.get(c, 10) for c in cats)
|
|
294
|
+
progress = Progress(
|
|
295
|
+
SpinnerColumn(),
|
|
296
|
+
TextColumn("[bold blue]{task.fields[current_op]}"),
|
|
297
|
+
BarColumn(bar_width=40),
|
|
298
|
+
MofNCompleteColumn(),
|
|
299
|
+
TimeElapsedColumn(),
|
|
300
|
+
console=console,
|
|
301
|
+
transient=False,
|
|
302
|
+
)
|
|
303
|
+
with progress:
|
|
304
|
+
task_id = progress.add_task(
|
|
305
|
+
"Benchmarking", total=total_ops, current_op="starting..."
|
|
306
|
+
)
|
|
307
|
+
for cat in cats:
|
|
308
|
+
if cat not in _BENCHMARK_FUNCS:
|
|
309
|
+
continue
|
|
310
|
+
progress.update(task_id, current_op=f"benchmarking {cat}")
|
|
311
|
+
func = _BENCHMARK_FUNCS[cat]
|
|
312
|
+
raw_alphas, raw_details = _unpack_benchmark_result(
|
|
313
|
+
func(dtype=dtype, repeats=repeats)
|
|
314
|
+
)
|
|
315
|
+
alphas.update(raw_alphas)
|
|
316
|
+
all_details.update(raw_details)
|
|
317
|
+
progress.advance(
|
|
318
|
+
task_id, advance=_APPROX_OP_COUNTS.get(cat, len(raw_alphas))
|
|
319
|
+
)
|
|
320
|
+
progress.update(task_id, current_op="done")
|
|
321
|
+
else:
|
|
322
|
+
for cat in cats:
|
|
323
|
+
if cat not in _BENCHMARK_FUNCS:
|
|
324
|
+
log_fn(f"Unknown category: {cat!r}, skipping.")
|
|
325
|
+
continue
|
|
326
|
+
log_fn(f"Benchmarking {cat} ...")
|
|
327
|
+
func = _BENCHMARK_FUNCS[cat]
|
|
328
|
+
raw_alphas, raw_details = _unpack_benchmark_result(
|
|
329
|
+
func(dtype=dtype, repeats=repeats)
|
|
330
|
+
)
|
|
331
|
+
alphas.update(raw_alphas)
|
|
332
|
+
all_details.update(raw_details)
|
|
333
|
+
|
|
334
|
+
return alphas, all_details
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _enrich_details(
|
|
338
|
+
all_details: dict[str, dict],
|
|
339
|
+
*,
|
|
340
|
+
weights: dict[str, float],
|
|
341
|
+
alpha_add: float,
|
|
342
|
+
baseline_n: int,
|
|
343
|
+
baseline_bench_code: str,
|
|
344
|
+
repeats: int,
|
|
345
|
+
timing_baseline: float,
|
|
346
|
+
) -> None:
|
|
347
|
+
"""Enrich per-op detail dicts with computed fields, URLs, and notes.
|
|
348
|
+
|
|
349
|
+
Mutates *all_details* in place.
|
|
350
|
+
"""
|
|
351
|
+
# -- registry notes ----------------------------------------------------
|
|
352
|
+
try:
|
|
353
|
+
from flopscope._registry import REGISTRY
|
|
354
|
+
except ImportError:
|
|
355
|
+
REGISTRY = {} # type: ignore[assignment]
|
|
356
|
+
|
|
357
|
+
# -- implementation URLs -----------------------------------------------
|
|
358
|
+
try:
|
|
359
|
+
from benchmarks._impl_urls import build_url_map
|
|
360
|
+
|
|
361
|
+
url_map = build_url_map(list(all_details.keys()))
|
|
362
|
+
except Exception:
|
|
363
|
+
url_map = {}
|
|
364
|
+
|
|
365
|
+
for op, detail in all_details.items():
|
|
366
|
+
# weight-derived fields
|
|
367
|
+
detail["perf_weight"] = weights.get(op, 0.0)
|
|
368
|
+
detail["absolute_alpha"] = round(weights.get(op, 0.0) * alpha_add, 6)
|
|
369
|
+
|
|
370
|
+
# baseline reference fields
|
|
371
|
+
detail["baseline_alpha"] = round(alpha_add, 6)
|
|
372
|
+
detail["baseline_analytical_flops"] = baseline_n
|
|
373
|
+
detail["baseline_bench_code"] = baseline_bench_code
|
|
374
|
+
detail["baseline_perf_instructions_total"] = round(
|
|
375
|
+
alpha_add * baseline_n * repeats, 2
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
# baseline timing (from validation loop, if available)
|
|
379
|
+
if timing_baseline > 0:
|
|
380
|
+
detail["baseline_timing_ns_total"] = round(
|
|
381
|
+
timing_baseline * baseline_n * repeats, 2
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
# registry notes
|
|
385
|
+
detail["notes"] = REGISTRY.get(op, {}).get("notes", "")
|
|
386
|
+
|
|
387
|
+
# implementation URL
|
|
388
|
+
url = url_map.get(op, "")
|
|
389
|
+
if url:
|
|
390
|
+
detail["cost_impl_url"] = url
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def run_benchmarks(
|
|
394
|
+
dtype: str = "float64",
|
|
395
|
+
output: str | None = None,
|
|
396
|
+
html: str | None = None,
|
|
397
|
+
categories: list[str] | None = None,
|
|
398
|
+
repeats: int = 10,
|
|
399
|
+
) -> dict[str, Any]:
|
|
400
|
+
"""Run the full benchmark suite and return the results dict."""
|
|
401
|
+
|
|
402
|
+
if categories is None or "all" in categories:
|
|
403
|
+
cats = list(ALL_CATEGORIES)
|
|
404
|
+
else:
|
|
405
|
+
cats = list(categories)
|
|
406
|
+
|
|
407
|
+
# -- measurement mode --------------------------------------------------
|
|
408
|
+
from benchmarks._perf import measurement_mode
|
|
409
|
+
|
|
410
|
+
mode = measurement_mode()
|
|
411
|
+
|
|
412
|
+
# -- try rich ----------------------------------------------------------
|
|
413
|
+
try:
|
|
414
|
+
from rich.console import Console
|
|
415
|
+
|
|
416
|
+
console = Console(stderr=True)
|
|
417
|
+
use_rich = True
|
|
418
|
+
except ImportError:
|
|
419
|
+
console = None
|
|
420
|
+
use_rich = False
|
|
421
|
+
|
|
422
|
+
def _log(msg: str) -> None:
|
|
423
|
+
if use_rich:
|
|
424
|
+
console.print(msg) # type: ignore[union-attr]
|
|
425
|
+
else:
|
|
426
|
+
print(msg, file=sys.stderr)
|
|
427
|
+
|
|
428
|
+
_log(
|
|
429
|
+
f"[bold]Measurement mode:[/bold] {mode}"
|
|
430
|
+
if use_rich
|
|
431
|
+
else f"Measurement mode: {mode}"
|
|
432
|
+
)
|
|
433
|
+
if mode == "timing":
|
|
434
|
+
_log(
|
|
435
|
+
" [dim](perf not available — using wall-clock time as proxy)[/dim]"
|
|
436
|
+
if use_rich
|
|
437
|
+
else " (perf not available — using wall-clock time as proxy)"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
t_start = _time.monotonic()
|
|
441
|
+
|
|
442
|
+
_log("Collecting metadata ...")
|
|
443
|
+
meta = collect_metadata(dtype=dtype, repeats=repeats, distributions=3)
|
|
444
|
+
meta["benchmark_config"]["measurement_mode"] = mode
|
|
445
|
+
|
|
446
|
+
# -- baseline ----------------------------------------------------------
|
|
447
|
+
_log("Measuring baselines (np.add + np.abs for overhead) ...")
|
|
448
|
+
baselines = measure_baselines(dtype=dtype, repeats=repeats)
|
|
449
|
+
baseline_fpe = baselines.alpha_add # backwards compat
|
|
450
|
+
|
|
451
|
+
# -- baseline constants ---------------------------------------------------
|
|
452
|
+
_BASELINE_N = 10_000_000 # default n in measure_baselines()
|
|
453
|
+
_BASELINE_BENCH_CODE = "np.add(x, y, out=_out)"
|
|
454
|
+
|
|
455
|
+
# -- primary measurement loop ------------------------------------------
|
|
456
|
+
_log("Running primary measurement loop ...")
|
|
457
|
+
raw_alphas, all_details = _run_category_loop(
|
|
458
|
+
cats, dtype, repeats, _log, use_rich=use_rich, console=console
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# -- normalise: subtract per-category overhead, clamp to 1.0 -----------
|
|
462
|
+
weights = normalize_weights_v2(raw_alphas, all_details, baselines)
|
|
463
|
+
|
|
464
|
+
# -- dual-mode validation (Step 1.2) -----------------------------------
|
|
465
|
+
# If primary mode is perf, re-run in timing mode for validation.
|
|
466
|
+
# If primary mode is timing, we have no perf to compare against.
|
|
467
|
+
validation: dict[str, Any] = {
|
|
468
|
+
"absolute_correction_factors": {k: round(v, 6) for k, v in raw_alphas.items()},
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
timing_weights: dict[str, float] = {}
|
|
472
|
+
timing_alphas: dict[str, float] = {}
|
|
473
|
+
timing_baseline: float = 0.0
|
|
474
|
+
|
|
475
|
+
if mode == "perf" and not os.environ.get("FLOPSCOPE_SKIP_VALIDATION"):
|
|
476
|
+
_log("Running timing-mode validation loop ...")
|
|
477
|
+
# Force timing mode for the re-run
|
|
478
|
+
_orig_env = os.environ.get("FLOPSCOPE_FORCE_TIMING")
|
|
479
|
+
os.environ["FLOPSCOPE_FORCE_TIMING"] = "1"
|
|
480
|
+
try:
|
|
481
|
+
timing_baseline = measure_baseline(dtype=dtype, repeats=repeats)
|
|
482
|
+
timing_alphas, _timing_details = _run_category_loop(
|
|
483
|
+
cats, dtype, repeats, _log, use_rich=False, console=None
|
|
484
|
+
)
|
|
485
|
+
finally:
|
|
486
|
+
if _orig_env is None:
|
|
487
|
+
os.environ.pop("FLOPSCOPE_FORCE_TIMING", None)
|
|
488
|
+
else:
|
|
489
|
+
os.environ["FLOPSCOPE_FORCE_TIMING"] = _orig_env
|
|
490
|
+
|
|
491
|
+
timing_weights = normalize_weights(timing_alphas, timing_baseline)
|
|
492
|
+
timing_weights = {k: round(v, 4) for k, v in timing_weights.items()}
|
|
493
|
+
validation["timing_weights"] = timing_weights
|
|
494
|
+
validation["perf_vs_timing"] = _compute_validation_stats(
|
|
495
|
+
weights, timing_weights
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# -- inject timing data into per-op details -------------------------
|
|
499
|
+
# Use the raw timing from _timing_details directly — no
|
|
500
|
+
# reconstruction needed. In timing mode, perf_instructions_total
|
|
501
|
+
# IS the raw elapsed_ns (TimingResult.total_flops = elapsed_ns).
|
|
502
|
+
for op in timing_alphas:
|
|
503
|
+
if op in all_details:
|
|
504
|
+
td = _timing_details.get(op, {})
|
|
505
|
+
raw_timing = td.get("perf_instructions_total")
|
|
506
|
+
if raw_timing is not None:
|
|
507
|
+
# raw_timing is the cumulative elapsed_ns across all
|
|
508
|
+
# distributions. For a single value it's the total;
|
|
509
|
+
# for a list it's per-distribution totals.
|
|
510
|
+
if isinstance(raw_timing, list):
|
|
511
|
+
# Take the median distribution's value
|
|
512
|
+
import statistics as _stats
|
|
513
|
+
|
|
514
|
+
all_details[op]["timing_ns_total"] = round(
|
|
515
|
+
_stats.median(raw_timing), 2
|
|
516
|
+
)
|
|
517
|
+
else:
|
|
518
|
+
all_details[op]["timing_ns_total"] = round(float(raw_timing), 2)
|
|
519
|
+
if op in timing_weights and op in all_details:
|
|
520
|
+
all_details[op]["timing_weight"] = timing_weights[op]
|
|
521
|
+
|
|
522
|
+
for op in _load_known_free_ops():
|
|
523
|
+
weights[op] = 0.0
|
|
524
|
+
|
|
525
|
+
# -- methodology metadata (Step 1.3 + 1.4) ----------------------------
|
|
526
|
+
meta["methodology"] = {
|
|
527
|
+
"version": "3.0",
|
|
528
|
+
"formula": (
|
|
529
|
+
"counted weight(op) = max(alpha_raw(op) - overhead_for_category, 0.0), "
|
|
530
|
+
"where alpha_raw = median(perf_instructions / analytical_FLOPs). "
|
|
531
|
+
"Known analytical zero-FLOP ops are emitted with weight 0.0."
|
|
532
|
+
),
|
|
533
|
+
"baseline_alpha_add_raw": round(baselines.alpha_add, 6),
|
|
534
|
+
"baseline_alpha_abs_raw": round(baselines.alpha_abs, 6),
|
|
535
|
+
**baselines.to_dict(),
|
|
536
|
+
"note": (
|
|
537
|
+
"analytical_FLOPs from flopscope registry (FMA=2 textbook); "
|
|
538
|
+
"perf_instructions are SIMD-width-weighted "
|
|
539
|
+
"fp_arith_inst_retired counts; "
|
|
540
|
+
"ufunc overhead subtracted per category; "
|
|
541
|
+
"integer/bitwise ops use instructions counter; "
|
|
542
|
+
"known zero-FLOP ops are injected from the curated registry"
|
|
543
|
+
),
|
|
544
|
+
}
|
|
545
|
+
meta["validation"] = validation
|
|
546
|
+
|
|
547
|
+
# -- round weights -----------------------------------------------------
|
|
548
|
+
weights = {k: round(v, 4) for k, v in weights.items()}
|
|
549
|
+
|
|
550
|
+
# -- enrich per-op details ---------------------------------------------
|
|
551
|
+
_enrich_details(
|
|
552
|
+
all_details,
|
|
553
|
+
weights=weights,
|
|
554
|
+
alpha_add=baseline_fpe,
|
|
555
|
+
baseline_n=_BASELINE_N,
|
|
556
|
+
baseline_bench_code=_BASELINE_BENCH_CODE,
|
|
557
|
+
repeats=repeats,
|
|
558
|
+
timing_baseline=timing_baseline,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
# -- store per-op details in meta --------------------------------------
|
|
562
|
+
meta["per_op_details"] = all_details
|
|
563
|
+
|
|
564
|
+
duration = round(_time.monotonic() - t_start, 1)
|
|
565
|
+
meta["duration_seconds"] = duration
|
|
566
|
+
|
|
567
|
+
result = {"meta": meta, "weights": weights}
|
|
568
|
+
|
|
569
|
+
# -- write JSON --------------------------------------------------------
|
|
570
|
+
if output:
|
|
571
|
+
_log(f"Writing JSON to {output} ...")
|
|
572
|
+
with open(output, "w") as f:
|
|
573
|
+
json.dump(result, f, indent=2)
|
|
574
|
+
|
|
575
|
+
# -- terminal dashboard ------------------------------------------------
|
|
576
|
+
summary = render_terminal(meta, weights, baseline_fpe, len(weights), duration)
|
|
577
|
+
print(summary, file=sys.stderr)
|
|
578
|
+
|
|
579
|
+
# -- optional HTML report ----------------------------------------------
|
|
580
|
+
if html:
|
|
581
|
+
_log(f"Writing HTML report to {html} ...")
|
|
582
|
+
html_content = render_html(meta, weights, baseline_fpe, len(weights), duration)
|
|
583
|
+
with open(html, "w") as f:
|
|
584
|
+
f.write(html_content)
|
|
585
|
+
|
|
586
|
+
return result
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def main() -> None:
|
|
590
|
+
"""CLI entry-point for the benchmark runner."""
|
|
591
|
+
parser = argparse.ArgumentParser(
|
|
592
|
+
description="Run flopscope benchmarks and produce FPE weight tables.",
|
|
593
|
+
)
|
|
594
|
+
parser.add_argument(
|
|
595
|
+
"--dtype",
|
|
596
|
+
default="float64",
|
|
597
|
+
help="NumPy dtype to benchmark (default: float64)",
|
|
598
|
+
)
|
|
599
|
+
parser.add_argument(
|
|
600
|
+
"--output",
|
|
601
|
+
default=None,
|
|
602
|
+
help="Path to write JSON results",
|
|
603
|
+
)
|
|
604
|
+
parser.add_argument(
|
|
605
|
+
"--html",
|
|
606
|
+
default=None,
|
|
607
|
+
help="Path to write HTML dashboard report",
|
|
608
|
+
)
|
|
609
|
+
parser.add_argument(
|
|
610
|
+
"--category",
|
|
611
|
+
dest="categories",
|
|
612
|
+
action="append",
|
|
613
|
+
default=None,
|
|
614
|
+
help="Category to benchmark (may be repeated; default: all)",
|
|
615
|
+
)
|
|
616
|
+
parser.add_argument(
|
|
617
|
+
"--repeats",
|
|
618
|
+
type=int,
|
|
619
|
+
default=10,
|
|
620
|
+
help="Number of repeat measurements (default: 10)",
|
|
621
|
+
)
|
|
622
|
+
args = parser.parse_args()
|
|
623
|
+
|
|
624
|
+
categories = args.categories if args.categories else ["all"]
|
|
625
|
+
|
|
626
|
+
run_benchmarks(
|
|
627
|
+
dtype=args.dtype,
|
|
628
|
+
output=args.output,
|
|
629
|
+
html=args.html,
|
|
630
|
+
categories=categories,
|
|
631
|
+
repeats=args.repeats,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
if __name__ == "__main__":
|
|
636
|
+
main()
|