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.
Files changed (115) hide show
  1. benchmarks/__init__.py +1 -0
  2. benchmarks/__main__.py +6 -0
  3. benchmarks/_baseline.py +171 -0
  4. benchmarks/_bitwise.py +231 -0
  5. benchmarks/_complex.py +176 -0
  6. benchmarks/_contractions.py +291 -0
  7. benchmarks/_fft.py +198 -0
  8. benchmarks/_impl_urls.py +139 -0
  9. benchmarks/_linalg.py +197 -0
  10. benchmarks/_linalg_delegates.py +407 -0
  11. benchmarks/_metadata.py +141 -0
  12. benchmarks/_misc.py +653 -0
  13. benchmarks/_perf.py +321 -0
  14. benchmarks/_perm_group_calibration.py +175 -0
  15. benchmarks/_pointwise.py +372 -0
  16. benchmarks/_polynomial.py +193 -0
  17. benchmarks/_random.py +209 -0
  18. benchmarks/_reductions.py +136 -0
  19. benchmarks/_sorting.py +289 -0
  20. benchmarks/_stats.py +137 -0
  21. benchmarks/_window.py +92 -0
  22. benchmarks/accumulation/__init__.py +0 -0
  23. benchmarks/accumulation/bench_cost_compute.py +138 -0
  24. benchmarks/dashboard.py +312 -0
  25. benchmarks/runner.py +636 -0
  26. flopscope/__init__.py +273 -0
  27. flopscope/_accumulation/__init__.py +13 -0
  28. flopscope/_accumulation/_bipartite.py +121 -0
  29. flopscope/_accumulation/_burnside.py +51 -0
  30. flopscope/_accumulation/_cache.py +146 -0
  31. flopscope/_accumulation/_components.py +153 -0
  32. flopscope/_accumulation/_cost.py +1414 -0
  33. flopscope/_accumulation/_cost_descriptions.py +63 -0
  34. flopscope/_accumulation/_detection.py +318 -0
  35. flopscope/_accumulation/_ladder.py +191 -0
  36. flopscope/_accumulation/_output_orbit.py +104 -0
  37. flopscope/_accumulation/_partition.py +290 -0
  38. flopscope/_accumulation/_path_info.py +211 -0
  39. flopscope/_accumulation/_public.py +169 -0
  40. flopscope/_accumulation/_reduction.py +310 -0
  41. flopscope/_accumulation/_regimes.py +303 -0
  42. flopscope/_accumulation/_shape.py +33 -0
  43. flopscope/_accumulation/_wreath.py +209 -0
  44. flopscope/_budget.py +1027 -0
  45. flopscope/_config.py +118 -0
  46. flopscope/_counting_ops.py +451 -0
  47. flopscope/_display.py +478 -0
  48. flopscope/_docstrings.py +59 -0
  49. flopscope/_dtypes.py +20 -0
  50. flopscope/_einsum.py +717 -0
  51. flopscope/_errstate.py +25 -0
  52. flopscope/_flops.py +282 -0
  53. flopscope/_free_ops.py +2654 -0
  54. flopscope/_ndarray.py +1126 -0
  55. flopscope/_opt_einsum/LICENSE +21 -0
  56. flopscope/_opt_einsum/NOTICE +59 -0
  57. flopscope/_opt_einsum/__init__.py +209 -0
  58. flopscope/_opt_einsum/_contract.py +1478 -0
  59. flopscope/_opt_einsum/_helpers.py +164 -0
  60. flopscope/_opt_einsum/_hsluv.py +273 -0
  61. flopscope/_opt_einsum/_path_random.py +462 -0
  62. flopscope/_opt_einsum/_paths.py +1653 -0
  63. flopscope/_opt_einsum/_subgraph_symmetry.py +544 -0
  64. flopscope/_opt_einsum/_symmetry.py +140 -0
  65. flopscope/_opt_einsum/_typing.py +37 -0
  66. flopscope/_perm_group.py +717 -0
  67. flopscope/_pointwise.py +2522 -0
  68. flopscope/_polynomial.py +278 -0
  69. flopscope/_registry.py +3216 -0
  70. flopscope/_sorting_ops.py +571 -0
  71. flopscope/_symmetric.py +812 -0
  72. flopscope/_symmetry_transport.py +510 -0
  73. flopscope/_symmetry_utils.py +669 -0
  74. flopscope/_type_info.py +12 -0
  75. flopscope/_unwrap.py +70 -0
  76. flopscope/_validation.py +83 -0
  77. flopscope/_version_check.py +46 -0
  78. flopscope/_weights.py +195 -0
  79. flopscope/_window.py +177 -0
  80. flopscope/accounting.py +565 -0
  81. flopscope/data/default_weights.json +462 -0
  82. flopscope/data/weights.csv +509 -0
  83. flopscope/errors.py +197 -0
  84. flopscope/numpy/__init__.py +878 -0
  85. flopscope/numpy/fft/__init__.py +55 -0
  86. flopscope/numpy/fft/_free.py +51 -0
  87. flopscope/numpy/fft/_transforms.py +695 -0
  88. flopscope/numpy/linalg/__init__.py +105 -0
  89. flopscope/numpy/linalg/_aliases.py +126 -0
  90. flopscope/numpy/linalg/_compound.py +161 -0
  91. flopscope/numpy/linalg/_decompositions.py +353 -0
  92. flopscope/numpy/linalg/_properties.py +533 -0
  93. flopscope/numpy/linalg/_solvers.py +444 -0
  94. flopscope/numpy/linalg/_svd.py +122 -0
  95. flopscope/numpy/random/__init__.py +684 -0
  96. flopscope/numpy/random/_cost_formulas.py +115 -0
  97. flopscope/numpy/random/_counted_classes.py +241 -0
  98. flopscope/numpy/testing/__init__.py +13 -0
  99. flopscope/numpy/typing/__init__.py +30 -0
  100. flopscope/py.typed +0 -0
  101. flopscope/stats/__init__.py +84 -0
  102. flopscope/stats/_base.py +77 -0
  103. flopscope/stats/_cauchy.py +146 -0
  104. flopscope/stats/_erf.py +190 -0
  105. flopscope/stats/_expon.py +146 -0
  106. flopscope/stats/_laplace.py +150 -0
  107. flopscope/stats/_logistic.py +148 -0
  108. flopscope/stats/_lognorm.py +160 -0
  109. flopscope/stats/_ndtri.py +133 -0
  110. flopscope/stats/_norm.py +149 -0
  111. flopscope/stats/_truncnorm.py +186 -0
  112. flopscope/stats/_uniform.py +141 -0
  113. flopscope-0.2.0.dist-info/METADATA +23 -0
  114. flopscope-0.2.0.dist-info/RECORD +115 -0
  115. 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()