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/_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()
|
benchmarks/dashboard.py
ADDED
|
@@ -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)
|