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/_stats.py ADDED
@@ -0,0 +1,137 @@
1
+ """Benchmark scipy.stats-compatible distribution methods (pdf/cdf/ppf).
2
+
3
+ These ops use raw numpy internally (no ufunc layer), so measurement_mode
4
+ is ``custom`` (no overhead subtraction). The analytical cost is
5
+ ``numel(input)`` for all methods — the weight captures the per-element
6
+ FP cost.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import statistics
12
+
13
+ from benchmarks._perf import measure_flops
14
+
15
+ # 8 distributions × 3 methods = 24 ops
16
+ DISTRIBUTIONS = [
17
+ "norm",
18
+ "uniform",
19
+ "expon",
20
+ "cauchy",
21
+ "logistic",
22
+ "laplace",
23
+ "lognorm",
24
+ "truncnorm",
25
+ ]
26
+ METHODS = ["pdf", "cdf", "ppf"]
27
+
28
+ _FORMULA_STRINGS: dict[str, str] = {
29
+ f"stats.{dist}.{method}": "numel(input)"
30
+ for dist in DISTRIBUTIONS
31
+ for method in METHODS
32
+ }
33
+
34
+
35
+ def _setup_code(dist: str, method: str, n: int, di: int) -> str:
36
+ """Build setup code for a stats benchmark.
37
+
38
+ The import of the stats distribution object is done HERE in setup
39
+ (not in bench_code) so the import cost is not measured — only the
40
+ actual computation is in the hot loop.
41
+ """
42
+ seeds = [42, 123, 7]
43
+ seed = seeds[di % len(seeds)]
44
+
45
+ # Import the distribution in setup so it's available in the bench loop
46
+ import_line = f"from flopscope.stats import {dist} as _dist"
47
+
48
+ if method == "ppf":
49
+ return (
50
+ f"import numpy as np; {import_line}; "
51
+ f"rng = np.random.default_rng({seed}); x = rng.uniform(0.01, 0.99, {n})"
52
+ )
53
+ else:
54
+ setups = [
55
+ f"import numpy as np; {import_line}; x = np.random.default_rng({seed}).standard_normal({n})",
56
+ f"import numpy as np; {import_line}; x = np.random.default_rng({seed}).uniform(-5, 5, {n})",
57
+ f"import numpy as np; {import_line}; x = np.random.default_rng({seed}).uniform(0.01, 10, {n})",
58
+ ]
59
+ return setups[di % len(setups)]
60
+
61
+
62
+ def _bench_code(dist: str, method: str) -> str:
63
+ """Build benchmark code — just the computation, no imports.
64
+
65
+ The distribution object is imported as ``_dist`` in setup_code.
66
+ We call _compute_* to avoid budget.deduct overhead in measurement.
67
+ """
68
+ if dist == "lognorm":
69
+ return f"_dist._compute_{method}(x, s=0.5)"
70
+ elif dist == "truncnorm":
71
+ return f"_dist._compute_{method}(x, a=-2, b=2)"
72
+ else:
73
+ return f"_dist._compute_{method}(x)"
74
+
75
+
76
+ def benchmark_stats(
77
+ n: int = 10_000_000,
78
+ dtype: str = "float64",
79
+ repeats: int = 10,
80
+ **kwargs,
81
+ ) -> tuple[dict[str, float], dict[str, dict]]:
82
+ """Benchmark all stats distribution methods.
83
+
84
+ Parameters
85
+ ----------
86
+ n : int
87
+ Array size for benchmarks.
88
+ dtype : str
89
+ Ignored (stats always use float64).
90
+ repeats : int
91
+ Repetitions per distribution.
92
+
93
+ Returns
94
+ -------
95
+ tuple[dict[str, float], dict[str, dict]]
96
+ (alphas, details) — alphas maps op name to median alpha,
97
+ details maps op name to benchmark metadata.
98
+ """
99
+ distributions = 3
100
+ results: dict[str, float] = {}
101
+ details: dict[str, dict] = {}
102
+
103
+ for dist in DISTRIBUTIONS:
104
+ for method in METHODS:
105
+ op_name = f"stats.{dist}.{method}"
106
+ dist_values: list[float] = []
107
+ dist_raw_totals: list[int] = []
108
+
109
+ for di in range(distributions):
110
+ setup = _setup_code(dist, method, n, di)
111
+ bench = _bench_code(dist, method)
112
+
113
+ try:
114
+ result = measure_flops(setup, bench, repeats=repeats)
115
+ except RuntimeError:
116
+ continue
117
+
118
+ # Analytical cost = numel(input) = n
119
+ alpha = result.total_flops / (n * repeats)
120
+ dist_values.append(alpha)
121
+ dist_raw_totals.append(result.total_flops)
122
+
123
+ if dist_values:
124
+ results[op_name] = statistics.median(dist_values)
125
+ details[op_name] = {
126
+ "category": "counted_custom",
127
+ "measurement_mode": "custom",
128
+ "analytical_formula": "numel(input)",
129
+ "analytical_flops": n,
130
+ "benchmark_size": f"x: ({n},)",
131
+ "bench_code": _bench_code(dist, method),
132
+ "repeats": repeats,
133
+ "perf_instructions_total": dist_raw_totals,
134
+ "distribution_alphas": dist_values,
135
+ }
136
+
137
+ return results, details
benchmarks/_window.py ADDED
@@ -0,0 +1,92 @@
1
+ """Benchmark window function operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import statistics
6
+
7
+ from benchmarks._perf import measure_flops
8
+
9
+ WINDOW_OPS: list[str] = ["bartlett", "blackman", "hamming", "hanning", "kaiser"]
10
+
11
+ _ANALYTICAL_COST: dict[str, int] = {
12
+ "bartlett": 1, # multiplied by n
13
+ "blackman": 3, # multiplied by n
14
+ "hamming": 1, # multiplied by n
15
+ "hanning": 1, # multiplied by n
16
+ "kaiser": 3, # multiplied by n
17
+ }
18
+
19
+ _FORMULA_STRINGS: dict[str, str] = {
20
+ "bartlett": "n",
21
+ "blackman": "3*n",
22
+ "hamming": "n",
23
+ "hanning": "n",
24
+ "kaiser": "3*n",
25
+ }
26
+
27
+
28
+ def benchmark_window(
29
+ n: int = 10_000_000,
30
+ dtype: str = "float64",
31
+ repeats: int = 10,
32
+ ) -> tuple[dict[str, float], dict[str, dict]]:
33
+ """Benchmark window ops, returning alpha(op) = measured/analytical.
34
+
35
+ Parameters
36
+ ----------
37
+ n : int
38
+ Window size.
39
+ dtype : str
40
+ Accepted for interface consistency; window functions always produce
41
+ float64.
42
+ repeats : int
43
+ Number of repetitions per measurement.
44
+
45
+ Returns
46
+ -------
47
+ tuple[dict[str, float], dict[str, dict]]
48
+ A pair of (alphas, details). ``alphas`` maps op name to median
49
+ alpha(op). ``details`` maps op name to a dict of raw benchmark
50
+ metadata.
51
+ """
52
+ results: dict[str, float] = {}
53
+ details: dict[str, dict] = {}
54
+
55
+ for op in WINDOW_OPS:
56
+ dist_values: list[float] = []
57
+ dist_raw_totals: list[int] = []
58
+ analytical = _ANALYTICAL_COST[op] * n
59
+ bench = ""
60
+
61
+ # Window functions are deterministic (no input distribution),
62
+ # but we still take 3 measurements with different seeds for
63
+ # consistency (they'll be nearly identical).
64
+ for _seed in [42, 123, 999]:
65
+ setup = "import numpy as np"
66
+ if op == "kaiser":
67
+ bench = f"np.kaiser({n}, 14.0)"
68
+ else:
69
+ bench = f"np.{op}({n})"
70
+
71
+ try:
72
+ result = measure_flops(setup, bench, repeats=repeats)
73
+ except RuntimeError:
74
+ continue
75
+ dist_values.append(result.total_flops / (analytical * repeats))
76
+ dist_raw_totals.append(result.total_flops)
77
+
78
+ if dist_values:
79
+ results[op] = statistics.median(dist_values)
80
+ details[op] = {
81
+ "category": "counted_custom",
82
+ "measurement_mode": "custom",
83
+ "analytical_formula": _FORMULA_STRINGS[op],
84
+ "analytical_flops": analytical,
85
+ "benchmark_size": f"output: ({n},)",
86
+ "bench_code": bench,
87
+ "repeats": repeats,
88
+ "perf_instructions_total": dist_raw_totals,
89
+ "distribution_alphas": dist_values,
90
+ }
91
+
92
+ return results, details
File without changes
@@ -0,0 +1,138 @@
1
+ """Cold and warm-call latency benchmarks for einsum_accumulation_cost.
2
+
3
+ Run: `uv run python benchmarks/accumulation/bench_cost_compute.py`
4
+
5
+ Failure threshold: cold-call latency for the whole suite must stay below
6
+ COLD_CALL_BUDGET_SECONDS. This is the R7 risk gate from the spec — protects
7
+ against a regime implementation that's accidentally O(n²) on partition counts.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+
14
+ import numpy as np
15
+
16
+ import flopscope as fps
17
+
18
+ COLD_CALL_BUDGET_SECONDS = 0.5 # whole suite, no cache hits
19
+
20
+
21
+ CASES = [
22
+ {
23
+ "name": "matrix_chain_n3",
24
+ "subscripts": "ij,jk",
25
+ "output": "ik",
26
+ "per_op_sym": (None, None),
27
+ "sizes": {"i": 3, "j": 3, "k": 3},
28
+ },
29
+ {
30
+ "name": "symmetric_matvec",
31
+ "subscripts": "ij,j",
32
+ "output": "i",
33
+ "per_op_sym": ("symmetric", None),
34
+ "sizes": {"i": 4, "j": 4},
35
+ },
36
+ {
37
+ "name": "fully_symmetric_self_contract",
38
+ "subscripts": "ijk,ijk",
39
+ "output": "",
40
+ "per_op_sym": ("symmetric", "symmetric"),
41
+ "sizes": {"i": 4, "j": 4, "k": 4},
42
+ },
43
+ {
44
+ "name": "triple_outer",
45
+ "subscripts": "ia,ib,ic",
46
+ "output": "abc",
47
+ "per_op_sym": (None, None, None),
48
+ "sizes": {"i": 4, "a": 4, "b": 4, "c": 4},
49
+ },
50
+ {
51
+ "name": "cyclic_t_to_ab",
52
+ "subscripts": "abc",
53
+ "output": "ab",
54
+ "per_op_sym": ({"type": "cyclic", "axes": [0, 1, 2]},),
55
+ "sizes": {"a": 3, "b": 3, "c": 3},
56
+ },
57
+ ]
58
+
59
+
60
+ def _build_operands(case_def):
61
+ parts = case_def["subscripts"].split(",")
62
+ operands = []
63
+ for op_idx, part in enumerate(parts):
64
+ shape = tuple(case_def["sizes"][lbl] for lbl in part)
65
+ op = np.zeros(shape) if shape else np.zeros(1)
66
+ sym_decl = case_def["per_op_sym"][op_idx]
67
+ if sym_decl == "symmetric":
68
+ axes = tuple(range(len(part)))
69
+ op = fps.as_symmetric(op, symmetry=axes)
70
+ elif isinstance(sym_decl, dict) and sym_decl.get("type") == "cyclic":
71
+ from flopscope._perm_group import SymmetryGroup
72
+
73
+ axes = tuple(sym_decl.get("axes", range(len(part))))
74
+ group = SymmetryGroup.cyclic(axes=axes)
75
+ op = fps.as_symmetric(op, symmetry=group)
76
+ operands.append(op)
77
+ return operands
78
+
79
+
80
+ def bench_cold_call() -> dict[str, float]:
81
+ """Time each case with a fresh cache."""
82
+ timings = {}
83
+ for case in CASES:
84
+ from flopscope._einsum import _accumulation_cache, _path_cache
85
+
86
+ _accumulation_cache.cache_clear()
87
+ _path_cache.cache_clear()
88
+
89
+ operands = _build_operands(case)
90
+ subscripts = case["subscripts"] + "->" + case["output"]
91
+
92
+ start = time.perf_counter()
93
+ fps.einsum_accumulation_cost(subscripts, *operands)
94
+ elapsed = time.perf_counter() - start
95
+ timings[case["name"]] = elapsed
96
+ return timings
97
+
98
+
99
+ def bench_warm_call() -> dict[str, float]:
100
+ """Time each case after a warm-up call (cache hit)."""
101
+ timings = {}
102
+ for case in CASES:
103
+ operands = _build_operands(case)
104
+ subscripts = case["subscripts"] + "->" + case["output"]
105
+ fps.einsum_accumulation_cost(subscripts, *operands)
106
+ start = time.perf_counter()
107
+ for _ in range(100):
108
+ fps.einsum_accumulation_cost(subscripts, *operands)
109
+ elapsed = (time.perf_counter() - start) / 100
110
+ timings[case["name"]] = elapsed
111
+ return timings
112
+
113
+
114
+ def main():
115
+ cold = bench_cold_call()
116
+ warm = bench_warm_call()
117
+ print("Cold-call latency (seconds):")
118
+ for name, t in cold.items():
119
+ print(f" {name:40s} {t * 1000:8.2f} ms")
120
+ cold_total = sum(cold.values())
121
+ print(f" {'TOTAL':40s} {cold_total * 1000:8.2f} ms")
122
+
123
+ print("\nWarm-call latency (averaged over 100 iterations):")
124
+ for name, t in warm.items():
125
+ print(f" {name:40s} {t * 1e6:8.2f} µs")
126
+
127
+ if cold_total > COLD_CALL_BUDGET_SECONDS:
128
+ print(
129
+ f"\nFAIL: cold-call total {cold_total:.3f}s exceeds budget {COLD_CALL_BUDGET_SECONDS}s"
130
+ )
131
+ raise SystemExit(1)
132
+ print(
133
+ f"\nOK: cold-call total {cold_total:.3f}s within budget {COLD_CALL_BUDGET_SECONDS}s"
134
+ )
135
+
136
+
137
+ if __name__ == "__main__":
138
+ main()
@@ -0,0 +1,312 @@
1
+ """Benchmark dashboard with rich terminal and HTML output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html as _html
6
+ from io import StringIO
7
+ from typing import Any
8
+
9
+ _REDUCTION_NAMES = frozenset(
10
+ {
11
+ "sum",
12
+ "mean",
13
+ "std",
14
+ "var",
15
+ "max",
16
+ "min",
17
+ "prod",
18
+ "any",
19
+ "all",
20
+ "cumsum",
21
+ "cumprod",
22
+ "nansum",
23
+ "nanmean",
24
+ "nanstd",
25
+ "nanvar",
26
+ "nanmax",
27
+ "nanmin",
28
+ "nanprod",
29
+ "median",
30
+ "nanmedian",
31
+ "percentile",
32
+ "nanpercentile",
33
+ "quantile",
34
+ "nanquantile",
35
+ "count_nonzero",
36
+ "average",
37
+ "argmax",
38
+ "argmin",
39
+ "nancumprod",
40
+ "nancumsum",
41
+ }
42
+ )
43
+ _SORTING_NAMES = frozenset(
44
+ {
45
+ "sort",
46
+ "argsort",
47
+ "partition",
48
+ "argpartition",
49
+ "searchsorted",
50
+ "unique",
51
+ "lexsort",
52
+ }
53
+ )
54
+ _POLY_NAMES = frozenset(
55
+ {
56
+ "polyval",
57
+ "polyfit",
58
+ "polyder",
59
+ "polyint",
60
+ "polyadd",
61
+ "polysub",
62
+ "polymul",
63
+ "polydiv",
64
+ "poly",
65
+ "roots",
66
+ }
67
+ )
68
+
69
+
70
+ def _categorize_weights(weights: dict[str, float]) -> dict[str, dict[str, float]]:
71
+ """Group weights by category based on op_name prefix."""
72
+ categories: dict[str, dict[str, float]] = {}
73
+ for op, w in sorted(weights.items()):
74
+ if op.startswith("linalg."):
75
+ cat = "Linalg"
76
+ elif op.startswith("fft."):
77
+ cat = "FFT"
78
+ elif op.startswith("random."):
79
+ cat = "Random"
80
+ elif op in _REDUCTION_NAMES:
81
+ cat = "Reductions"
82
+ elif op in _SORTING_NAMES:
83
+ cat = "Sorting"
84
+ elif op in _POLY_NAMES:
85
+ cat = "Polynomial"
86
+ else:
87
+ cat = "Pointwise"
88
+ categories.setdefault(cat, {})[op] = w
89
+ return {k: v for k, v in categories.items() if v}
90
+
91
+
92
+ def _bar_text(value: float, max_value: float, width: int = 20) -> str:
93
+ """Create a unicode bar string scaled to max_value."""
94
+ if max_value <= 0:
95
+ return ""
96
+ ratio = min(value / max_value, 1.0)
97
+ full = int(ratio * width)
98
+ return "\u2588" * max(full, 0)
99
+
100
+
101
+ def render_terminal(
102
+ meta: dict[str, Any],
103
+ weights: dict[str, float],
104
+ baseline_fpe: float,
105
+ total_ops: int,
106
+ duration_seconds: float,
107
+ ) -> str:
108
+ """Render benchmark results to a terminal string.
109
+
110
+ Uses ``rich`` for styled output, falling back to plain text.
111
+ """
112
+ try:
113
+ return _render_terminal_rich(
114
+ meta, weights, baseline_fpe, total_ops, duration_seconds
115
+ )
116
+ except Exception:
117
+ return _render_terminal_plain(
118
+ meta, weights, baseline_fpe, total_ops, duration_seconds
119
+ )
120
+
121
+
122
+ def _render_terminal_rich(
123
+ meta: dict[str, Any],
124
+ weights: dict[str, float],
125
+ baseline_fpe: float,
126
+ total_ops: int,
127
+ duration_seconds: float,
128
+ ) -> str:
129
+ from rich.console import Console
130
+ from rich.panel import Panel
131
+ from rich.table import Table
132
+
133
+ buf = StringIO()
134
+ console = Console(file=buf, force_terminal=True, width=100)
135
+
136
+ hw = meta.get("hardware", {})
137
+ sw = meta.get("software", {})
138
+ bc = meta.get("benchmark_config", {})
139
+ mode = bc.get("measurement_mode", "unknown")
140
+
141
+ header_lines = [
142
+ f"Timestamp : {meta.get('timestamp', 'N/A')[:19]}",
143
+ f"CPU : {hw.get('cpu_model', 'N/A')} ({hw.get('cpu_cores', '?')} cores, {hw.get('arch', '?')})",
144
+ f"dtype : {bc.get('dtype', 'N/A')}",
145
+ f"NumPy : {sw.get('numpy', 'N/A')} ({sw.get('blas', 'N/A')})",
146
+ f"Mode : {mode}"
147
+ + (" (wall-clock proxy)" if mode == "timing" else " (hardware counters)"),
148
+ ]
149
+ console.print(
150
+ Panel("\n".join(header_lines), title="flopscope FLOP Weight Benchmark")
151
+ )
152
+
153
+ categories = _categorize_weights(weights)
154
+
155
+ for cat_name, ops in categories.items():
156
+ # Scale bars per-category so each category uses its full width
157
+ cat_max = max(ops.values()) if ops else 1.0
158
+
159
+ table = Table(title=cat_name, show_lines=False)
160
+ table.add_column("Operation", style="cyan", min_width=25)
161
+ table.add_column("Weight", justify="right", style="green", min_width=10)
162
+ table.add_column("", min_width=22) # bar column
163
+
164
+ for op, w in sorted(ops.items(), key=lambda x: -x[1]):
165
+ bar = _bar_text(w, cat_max, width=20)
166
+ table.add_row(op, f"{w:.2f}", f"[yellow]{bar}[/yellow]")
167
+ console.print(table)
168
+ console.print()
169
+
170
+ mins = int(duration_seconds // 60)
171
+ secs = int(duration_seconds % 60)
172
+ footer_lines = [
173
+ f"Baseline: np.add = 1.0 ({baseline_fpe:.4f} raw units/elem)",
174
+ f"Total operations benchmarked: {total_ops}",
175
+ f"Duration: {mins}m {secs:02d}s",
176
+ ]
177
+ console.print(Panel("\n".join(footer_lines), title="Summary"))
178
+
179
+ return buf.getvalue()
180
+
181
+
182
+ def _render_terminal_plain(
183
+ meta: dict[str, Any],
184
+ weights: dict[str, float],
185
+ baseline_fpe: float,
186
+ total_ops: int,
187
+ duration_seconds: float,
188
+ ) -> str:
189
+ lines: list[str] = []
190
+ hw = meta.get("hardware", {})
191
+ sw = meta.get("software", {})
192
+ bc = meta.get("benchmark_config", {})
193
+
194
+ lines.append("=" * 70)
195
+ lines.append(" flopscope FLOP Weight Benchmark")
196
+ lines.append("=" * 70)
197
+ lines.append(f" Timestamp : {meta.get('timestamp', 'N/A')[:19]}")
198
+ lines.append(f" CPU : {hw.get('cpu_model', 'N/A')}")
199
+ lines.append(f" dtype : {bc.get('dtype', 'N/A')}")
200
+ lines.append(f" NumPy : {sw.get('numpy', 'N/A')}")
201
+ lines.append("")
202
+
203
+ categories = _categorize_weights(weights)
204
+
205
+ for cat_name, ops in categories.items():
206
+ cat_max = max(ops.values()) if ops else 1.0
207
+ lines.append(f" --- {cat_name} ---")
208
+ for op, w in sorted(ops.items(), key=lambda x: -x[1]):
209
+ bar = _bar_text(w, cat_max, width=20)
210
+ lines.append(f" {op:<28s} {w:>10.2f} {bar}")
211
+ lines.append("")
212
+
213
+ lines.append("-" * 70)
214
+ lines.append(f" Baseline: np.add = {baseline_fpe:.4f} raw units/elem")
215
+ lines.append(f" Total operations: {total_ops}")
216
+ lines.append(f" Duration: {duration_seconds:.1f}s")
217
+ lines.append("=" * 70)
218
+
219
+ return "\n".join(lines)
220
+
221
+
222
+ def render_html(
223
+ meta: dict[str, Any],
224
+ weights: dict[str, float],
225
+ baseline_fpe: float,
226
+ total_ops: int,
227
+ duration_seconds: float,
228
+ ) -> str:
229
+ """Render benchmark results as a standalone HTML document."""
230
+ hw = meta.get("hardware", {})
231
+ sw = meta.get("software", {})
232
+ bc = meta.get("benchmark_config", {})
233
+ categories = _categorize_weights(weights)
234
+
235
+ esc = _html.escape
236
+
237
+ parts: list[str] = []
238
+ parts.append("""\
239
+ <!DOCTYPE html>
240
+ <html lang="en">
241
+ <head>
242
+ <meta charset="utf-8">
243
+ <title>flopscope FLOP Weight Benchmark</title>
244
+ <style>
245
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
246
+ max-width: 960px; margin: 2rem auto; padding: 0 1rem; color: #333; }
247
+ h1 { border-bottom: 2px solid #2563eb; padding-bottom: .5rem; }
248
+ .meta { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;
249
+ padding: 1rem 1.5rem; margin-bottom: 1.5rem; line-height: 1.8; }
250
+ .meta b { min-width: 80px; display: inline-block; }
251
+ h2 { color: #1e40af; margin-top: 2rem; }
252
+ table { width: 100%; border-collapse: collapse; margin-bottom: 1.5rem; }
253
+ th { text-align: left; border-bottom: 2px solid #cbd5e1; padding: .5rem; }
254
+ td { padding: .4rem .5rem; border-bottom: 1px solid #e2e8f0; }
255
+ .bar-cell { width: 40%; }
256
+ .bar { height: 1.2rem; background: linear-gradient(90deg, #22c55e, #16a34a);
257
+ border-radius: 3px; min-width: 2px; }
258
+ .summary { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px;
259
+ padding: 1rem 1.5rem; margin-top: 2rem; }
260
+ </style>
261
+ </head>
262
+ <body>
263
+ <h1>flopscope FLOP Weight Benchmark</h1>
264
+ """)
265
+
266
+ parts.append('<div class="meta">')
267
+ parts.append(f"<b>Timestamp:</b> {esc(str(meta.get('timestamp', 'N/A'))[:19])}<br>")
268
+ parts.append(
269
+ f"<b>CPU:</b> {esc(str(hw.get('cpu_model', 'N/A')))} "
270
+ f"({hw.get('cpu_cores', '?')} cores, {esc(str(hw.get('arch', '?')))})<br>"
271
+ )
272
+ parts.append(f"<b>dtype:</b> {esc(str(bc.get('dtype', 'N/A')))} | ")
273
+ parts.append(
274
+ f"<b>NumPy:</b> {esc(str(sw.get('numpy', 'N/A')))} ({esc(str(sw.get('blas', 'N/A')))})<br>"
275
+ )
276
+ mode = bc.get("measurement_mode", "unknown")
277
+ mode_label = (
278
+ "hardware counters (perf)" if mode == "perf" else "wall-clock time (proxy)"
279
+ )
280
+ parts.append(f"<b>Mode:</b> {esc(mode_label)}")
281
+ parts.append("</div>")
282
+
283
+ for cat_name, ops in categories.items():
284
+ cat_max = max(ops.values()) if ops else 1.0
285
+ parts.append(f"<h2>{esc(cat_name)}</h2>")
286
+ parts.append(
287
+ "<table><tr><th>Operation</th><th>Weight</th>"
288
+ "<th class='bar-cell'></th></tr>"
289
+ )
290
+ for op, w in sorted(ops.items(), key=lambda x: -x[1]):
291
+ pct = (w / cat_max * 100) if cat_max > 0 else 0
292
+ parts.append(
293
+ f"<tr><td><code>{esc(op)}</code></td>"
294
+ f"<td style='text-align:right'>{w:.2f}</td>"
295
+ f'<td class="bar-cell">'
296
+ f'<div class="bar" style="width:{pct:.1f}%"></div>'
297
+ f"</td></tr>"
298
+ )
299
+ parts.append("</table>")
300
+
301
+ mins = int(duration_seconds // 60)
302
+ secs = int(duration_seconds % 60)
303
+ parts.append('<div class="summary">')
304
+ parts.append(
305
+ f"<b>Baseline:</b> np.add = 1.0 ({baseline_fpe:.4f} raw units/elem)<br>"
306
+ )
307
+ parts.append(f"<b>Total operations:</b> {total_ops}<br>")
308
+ parts.append(f"<b>Duration:</b> {mins}m {secs:02d}s")
309
+ parts.append("</div>")
310
+ parts.append("</body></html>")
311
+
312
+ return "\n".join(parts)