counted-float 1.0.1__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.
- counted_float/__init__.py +39 -0
- counted_float/_core/__init__.py +0 -0
- counted_float/_core/_backwards_compat.py +6 -0
- counted_float/_core/_optional_deps.py +49 -0
- counted_float/_core/benchmarking/__init__.py +10 -0
- counted_float/_core/benchmarking/_flops_benchmark_suite.py +189 -0
- counted_float/_core/benchmarking/_flops_micro_benchmark.py +64 -0
- counted_float/_core/benchmarking/_micro_benchmark.py +113 -0
- counted_float/_core/benchmarking/_models.py +31 -0
- counted_float/_core/benchmarking/_time_utils.py +70 -0
- counted_float/_core/counting/__init__.py +3 -0
- counted_float/_core/counting/_builtin_data.py +25 -0
- counted_float/_core/counting/_context_managers.py +95 -0
- counted_float/_core/counting/_counted_float.py +201 -0
- counted_float/_core/counting/_global_counter.py +98 -0
- counted_float/_core/counting/config/__init__.py +6 -0
- counted_float/_core/counting/config/_config.py +54 -0
- counted_float/_core/counting/config/_defaults.py +49 -0
- counted_float/_core/counting/models/__init__.py +13 -0
- counted_float/_core/counting/models/_base.py +6 -0
- counted_float/_core/counting/models/_flop_counts.py +75 -0
- counted_float/_core/counting/models/_flop_type.py +47 -0
- counted_float/_core/counting/models/_flop_weights.py +85 -0
- counted_float/_core/counting/models/_flops_benchmark_result.py +69 -0
- counted_float/_core/counting/models/_fpu_instruction.py +22 -0
- counted_float/_core/counting/models/_fpu_specs.py +94 -0
- counted_float/_core/data/__init__.py +0 -0
- counted_float/_core/data/benchmarks/__init__.py +0 -0
- counted_float/_core/data/benchmarks/apple_m3_max.json +103 -0
- counted_float/_core/data/benchmarks/intel_i5_7200u.json +103 -0
- counted_float/_core/data/benchmarks/intel_i7_1265u.json +103 -0
- counted_float/_core/data/specs/__init__.py +0 -0
- counted_float/_core/data/specs/amd_zen4_r9_7900x.json +52 -0
- counted_float/_core/data/specs/intel_gen11_tiger_lake.json +52 -0
- counted_float/benchmarking/__init__.py +14 -0
- counted_float/config/__init__.py +15 -0
- counted_float-1.0.1.dist-info/METADATA +289 -0
- counted_float-1.0.1.dist-info/RECORD +40 -0
- counted_float-1.0.1.dist-info/WHEEL +4 -0
- counted_float-1.0.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import counted_float.config as config
|
|
2
|
+
|
|
3
|
+
from ._core.counting import BuiltInData, CountedFloat, FlopCountingContext, PauseFlopCounting
|
|
4
|
+
from ._core.counting.models import (
|
|
5
|
+
FlopCounts,
|
|
6
|
+
FlopsBenchmarkDurations,
|
|
7
|
+
FlopsBenchmarkResults,
|
|
8
|
+
FlopType,
|
|
9
|
+
FlopWeights,
|
|
10
|
+
FPUInstruction,
|
|
11
|
+
SystemInfo,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"config",
|
|
16
|
+
"CountedFloat",
|
|
17
|
+
"FlopCountingContext",
|
|
18
|
+
"FlopCounts",
|
|
19
|
+
"FlopsBenchmarkDurations",
|
|
20
|
+
"FlopsBenchmarkResults",
|
|
21
|
+
"FlopType",
|
|
22
|
+
"FlopWeights",
|
|
23
|
+
"FPUInstruction",
|
|
24
|
+
"PauseFlopCounting",
|
|
25
|
+
"SystemInfo",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
# -------------------------------------------------------------------------
|
|
29
|
+
# Optional [benchmarking] dependencies
|
|
30
|
+
# -------------------------------------------------------------------------
|
|
31
|
+
from ._core._optional_deps import FLAG_BENCHMARK_DEPS
|
|
32
|
+
|
|
33
|
+
if FLAG_BENCHMARK_DEPS:
|
|
34
|
+
import counted_float.benchmarking as benchmarking
|
|
35
|
+
|
|
36
|
+
__all__.append("benchmarking")
|
|
37
|
+
|
|
38
|
+
# delete variable again, we don't want to expose this
|
|
39
|
+
del FLAG_BENCHMARK_DEPS
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module implementing boolean flags indicating whether optional dependencies are available & decorators to
|
|
3
|
+
safeguard execution of functions that require these dependencies.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import functools
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
# -------------------------------------------------------------------------
|
|
10
|
+
# Flags
|
|
11
|
+
# -------------------------------------------------------------------------
|
|
12
|
+
try:
|
|
13
|
+
import numba
|
|
14
|
+
import numpy
|
|
15
|
+
import psutil
|
|
16
|
+
|
|
17
|
+
FLAG_BENCHMARK_DEPS = True
|
|
18
|
+
except ImportError:
|
|
19
|
+
FLAG_BENCHMARK_DEPS = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# -------------------------------------------------------------------------
|
|
23
|
+
# Decorators
|
|
24
|
+
# -------------------------------------------------------------------------
|
|
25
|
+
def requires_benchmark_deps(fun: Callable) -> Callable:
|
|
26
|
+
"""
|
|
27
|
+
Decorator to mark a function that requires some deps to be installed.
|
|
28
|
+
|
|
29
|
+
USAGE:
|
|
30
|
+
|
|
31
|
+
@requires_benchmark_deps
|
|
32
|
+
def fun_that_needs_numba_or_numpy_or_psutil():
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
This will ensure that the function - when it is called for the first time - will check if the required
|
|
36
|
+
dependencies are available. If not, an ImportError will be raised with an informative message.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
@functools.wraps(fun)
|
|
40
|
+
def wrapped_fun(*args, **kwargs):
|
|
41
|
+
if not FLAG_BENCHMARK_DEPS:
|
|
42
|
+
raise ImportError(
|
|
43
|
+
"This function requires counted_float to be installed with benchmark optional dependencies. "
|
|
44
|
+
+ "Please install this package as counted_float[benchmark]."
|
|
45
|
+
)
|
|
46
|
+
else:
|
|
47
|
+
return fun(*args, **kwargs)
|
|
48
|
+
|
|
49
|
+
return wrapped_fun
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from counted_float._core._optional_deps import requires_benchmark_deps
|
|
2
|
+
from counted_float._core.counting.models import FlopsBenchmarkResults
|
|
3
|
+
|
|
4
|
+
from ._flops_benchmark_suite import FlopsBenchmarkSuite
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@requires_benchmark_deps
|
|
8
|
+
def run_flops_benchmark() -> FlopsBenchmarkResults:
|
|
9
|
+
"""Run the flops benchmark suite with default settings returns a FlopsBenchmarkResults object."""
|
|
10
|
+
return FlopsBenchmarkSuite().run()
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import platform
|
|
2
|
+
|
|
3
|
+
import psutil
|
|
4
|
+
|
|
5
|
+
from counted_float._core._optional_deps import requires_benchmark_deps
|
|
6
|
+
from counted_float._core.counting.models import (
|
|
7
|
+
BenchmarkSettings,
|
|
8
|
+
FlopsBenchmarkDurations,
|
|
9
|
+
FlopsBenchmarkResults,
|
|
10
|
+
FlopType,
|
|
11
|
+
Quantiles,
|
|
12
|
+
SystemInfo,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from ._flops_micro_benchmark import FlopsMicroBenchmark
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FlopsBenchmarkSuite:
|
|
19
|
+
# -------------------------------------------------------------------------
|
|
20
|
+
# Main API
|
|
21
|
+
# -------------------------------------------------------------------------
|
|
22
|
+
def run(
|
|
23
|
+
self,
|
|
24
|
+
array_size: int = 1000,
|
|
25
|
+
n_runs_total: int = 30,
|
|
26
|
+
n_runs_warmup: int = 10,
|
|
27
|
+
n_seconds_per_run_target: float = 0.5,
|
|
28
|
+
) -> FlopsBenchmarkResults:
|
|
29
|
+
"""
|
|
30
|
+
Run entire flops benchmarking suite and return the results as a FlopsBenchmarkResults object.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
# run actual benchmarks
|
|
34
|
+
benchmarks = self.get_flops_benchmarking_suite(size=array_size)
|
|
35
|
+
results_dict: dict[FlopType | None, Quantiles] = {
|
|
36
|
+
flop_type: benchmark.run_many(
|
|
37
|
+
n_runs_total=n_runs_total,
|
|
38
|
+
n_runs_warmup=n_runs_warmup,
|
|
39
|
+
n_seconds_per_run_target=n_seconds_per_run_target,
|
|
40
|
+
).summary_stats()
|
|
41
|
+
for flop_type, benchmark in benchmarks.items()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# put results in appropriate format & return
|
|
45
|
+
return FlopsBenchmarkResults(
|
|
46
|
+
system_info=SystemInfo(
|
|
47
|
+
platform_processor=platform.processor(),
|
|
48
|
+
platform_machine=platform.machine(),
|
|
49
|
+
platform_system=platform.system(),
|
|
50
|
+
platform_release=platform.release(),
|
|
51
|
+
platform_python_version=platform.python_version(),
|
|
52
|
+
platform_python_implementation=platform.python_implementation(),
|
|
53
|
+
platform_python_compiler=platform.python_compiler(),
|
|
54
|
+
psutil_cpu_count_logical=psutil.cpu_count(logical=True),
|
|
55
|
+
psutil_cpu_count_physical=psutil.cpu_count(logical=False),
|
|
56
|
+
),
|
|
57
|
+
benchmark_settings=BenchmarkSettings(
|
|
58
|
+
array_size=array_size,
|
|
59
|
+
n_runs_total=n_runs_total,
|
|
60
|
+
n_runs_warmup=n_runs_warmup,
|
|
61
|
+
n_seconds_per_run_target=n_seconds_per_run_target,
|
|
62
|
+
),
|
|
63
|
+
results_ns=FlopsBenchmarkDurations(
|
|
64
|
+
baseline=results_dict[None],
|
|
65
|
+
flops={flop_type: results_dict[flop_type] for flop_type in FlopType},
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# -------------------------------------------------------------------------
|
|
70
|
+
# Static methods
|
|
71
|
+
# -------------------------------------------------------------------------
|
|
72
|
+
@staticmethod
|
|
73
|
+
@requires_benchmark_deps
|
|
74
|
+
def get_flops_benchmarking_suite(size: int) -> dict[FlopType | None, FlopsMicroBenchmark]:
|
|
75
|
+
"""
|
|
76
|
+
Returns a benchmark for each FlopType + None (=baseline test), of requested array size.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
# --- late import of optional dependencies --------
|
|
80
|
+
import numba
|
|
81
|
+
import numpy as np
|
|
82
|
+
|
|
83
|
+
# --- define all test functions -------------------
|
|
84
|
+
@numba.njit(parallel=False)
|
|
85
|
+
def baseline(n: int, in_f1: np.ndarray, in_f2: np.ndarray, out_f: np.ndarray, out_i: np.ndarray):
|
|
86
|
+
"""baseline benchmark to measure the overhead of the benchmarking framework + iteration"""
|
|
87
|
+
for i in range(n):
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
@numba.njit(parallel=False)
|
|
91
|
+
def flop_abs(n: int, in_f1: np.ndarray, in_f2: np.ndarray, out_f: np.ndarray, out_i: np.ndarray):
|
|
92
|
+
for i in range(n):
|
|
93
|
+
out_f[i] = abs(in_f1[i])
|
|
94
|
+
|
|
95
|
+
@numba.njit(parallel=False)
|
|
96
|
+
def flop_minus(n: int, in_f1: np.ndarray, in_f2: np.ndarray, out_f: np.ndarray, out_i: np.ndarray):
|
|
97
|
+
for i in range(n):
|
|
98
|
+
out_f[i] = -in_f1[i]
|
|
99
|
+
|
|
100
|
+
@numba.njit(parallel=False)
|
|
101
|
+
def flop_equals(n: int, in_f1: np.ndarray, in_f2: np.ndarray, out_f: np.ndarray, out_i: np.ndarray):
|
|
102
|
+
for i in range(n):
|
|
103
|
+
out_i[i] = in_f1[i] == in_f2[i] # assign to integer output, to avoid unnecessary conversion overhead
|
|
104
|
+
|
|
105
|
+
@numba.njit(parallel=False)
|
|
106
|
+
def flop_gte(n: int, in_f1: np.ndarray, in_f2: np.ndarray, out_f: np.ndarray, out_i: np.ndarray):
|
|
107
|
+
for i in range(n):
|
|
108
|
+
out_i[i] = in_f1[i] >= in_f2[i] # assign to integer output, to avoid unnecessary conversion overhead
|
|
109
|
+
|
|
110
|
+
@numba.njit(parallel=False)
|
|
111
|
+
def flop_lte(n: int, in_f1: np.ndarray, in_f2: np.ndarray, out_f: np.ndarray, out_i: np.ndarray):
|
|
112
|
+
for i in range(n):
|
|
113
|
+
out_i[i] = in_f1[i] <= in_f2[i] # assign to integer output, to avoid unnecessary conversion overhead
|
|
114
|
+
|
|
115
|
+
@numba.njit(parallel=False)
|
|
116
|
+
def flop_gte_zero(n: int, in_f1: np.ndarray, in_f2: np.ndarray, out_f: np.ndarray, out_i: np.ndarray):
|
|
117
|
+
for i in range(n):
|
|
118
|
+
out_i[i] = in_f1[i] >= 0.0 # assign to integer output, to avoid unnecessary conversion overhead
|
|
119
|
+
|
|
120
|
+
@numba.njit(parallel=False)
|
|
121
|
+
def flop_rnd(n: int, in_f1: np.ndarray, in_f2: np.ndarray, out_f: np.ndarray, out_i: np.ndarray):
|
|
122
|
+
for i in range(n):
|
|
123
|
+
out_i[i] = np.ceil(in_f1[i])
|
|
124
|
+
|
|
125
|
+
@numba.njit(parallel=False)
|
|
126
|
+
def flop_add(n: int, in_f1: np.ndarray, in_f2: np.ndarray, out_f: np.ndarray, out_i: np.ndarray):
|
|
127
|
+
for i in range(n):
|
|
128
|
+
out_f[i] = in_f1[i] + in_f2[i]
|
|
129
|
+
|
|
130
|
+
@numba.njit(parallel=False)
|
|
131
|
+
def flop_sub(n: int, in_f1: np.ndarray, in_f2: np.ndarray, out_f: np.ndarray, out_i: np.ndarray):
|
|
132
|
+
for i in range(n):
|
|
133
|
+
out_f[i] = in_f1[i] - in_f2[i]
|
|
134
|
+
|
|
135
|
+
@numba.njit(parallel=False)
|
|
136
|
+
def flop_mul(n: int, in_f1: np.ndarray, in_f2: np.ndarray, out_f: np.ndarray, out_i: np.ndarray):
|
|
137
|
+
for i in range(n):
|
|
138
|
+
out_f[i] = in_f1[i] * in_f2[i]
|
|
139
|
+
|
|
140
|
+
@numba.njit(parallel=False)
|
|
141
|
+
def flop_div(n: int, in_f1: np.ndarray, in_f2: np.ndarray, out_f: np.ndarray, out_i: np.ndarray):
|
|
142
|
+
for i in range(n):
|
|
143
|
+
out_f[i] = in_f1[i] / in_f2[i]
|
|
144
|
+
|
|
145
|
+
@numba.njit(parallel=False)
|
|
146
|
+
def flop_sqrt(n: int, in_f1: np.ndarray, in_f2: np.ndarray, out_f: np.ndarray, out_i: np.ndarray):
|
|
147
|
+
for i in range(n):
|
|
148
|
+
out_f[i] = np.sqrt(in_f1[i])
|
|
149
|
+
|
|
150
|
+
@numba.njit(parallel=False)
|
|
151
|
+
def flop_pow2(n: int, in_f1: np.ndarray, in_f2: np.ndarray, out_f: np.ndarray, out_i: np.ndarray):
|
|
152
|
+
for i in range(n):
|
|
153
|
+
out_f[i] = 2 ** in_f1[i]
|
|
154
|
+
|
|
155
|
+
@numba.njit(parallel=False)
|
|
156
|
+
def flop_log2(n: int, in_f1: np.ndarray, in_f2: np.ndarray, out_f: np.ndarray, out_i: np.ndarray):
|
|
157
|
+
for i in range(n):
|
|
158
|
+
out_f[i] = np.log2(in_f1[i])
|
|
159
|
+
|
|
160
|
+
@numba.njit(parallel=False)
|
|
161
|
+
def flop_pow(n: int, in_f1: np.ndarray, in_f2: np.ndarray, out_f: np.ndarray, out_i: np.ndarray):
|
|
162
|
+
for i in range(n):
|
|
163
|
+
out_f[i] = in_f1[i] ** in_f2[i]
|
|
164
|
+
|
|
165
|
+
# --- return in appropriate format ----------------
|
|
166
|
+
return {
|
|
167
|
+
key: FlopsMicroBenchmark(name=name, f=f, size=size)
|
|
168
|
+
for key, name, f in [
|
|
169
|
+
(key, key.long_name() if key else "baseline", f)
|
|
170
|
+
for key, f in [
|
|
171
|
+
(None, baseline),
|
|
172
|
+
(FlopType.ABS, flop_abs),
|
|
173
|
+
(FlopType.CMP_ZERO, flop_gte_zero),
|
|
174
|
+
(FlopType.RND, flop_rnd),
|
|
175
|
+
(FlopType.MINUS, flop_minus),
|
|
176
|
+
(FlopType.EQUALS, flop_equals),
|
|
177
|
+
(FlopType.GTE, flop_gte),
|
|
178
|
+
(FlopType.LTE, flop_lte),
|
|
179
|
+
(FlopType.ADD, flop_add),
|
|
180
|
+
(FlopType.SUB, flop_sub),
|
|
181
|
+
(FlopType.MUL, flop_mul),
|
|
182
|
+
(FlopType.SQRT, flop_sqrt),
|
|
183
|
+
(FlopType.DIV, flop_div),
|
|
184
|
+
(FlopType.POW2, flop_pow2),
|
|
185
|
+
(FlopType.LOG2, flop_log2),
|
|
186
|
+
(FlopType.POW, flop_pow),
|
|
187
|
+
]
|
|
188
|
+
]
|
|
189
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from typing import Callable
|
|
2
|
+
|
|
3
|
+
from counted_float._core._optional_deps import requires_benchmark_deps
|
|
4
|
+
|
|
5
|
+
from ._micro_benchmark import MicroBenchmark
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FlopsMicroBenchmark(MicroBenchmark):
|
|
9
|
+
"""
|
|
10
|
+
Base class for benchmark that checks speed of a certain type of floating point operation.
|
|
11
|
+
|
|
12
|
+
This is set up as follows:
|
|
13
|
+
- we configure the benchmark with a 'size' and a function 'f'
|
|
14
|
+
- we prepare the inputs: 2 1D numpy arrays of size 'size': in_f1, in_f2
|
|
15
|
+
- initialized as random floating point numbers in range [0, 10]
|
|
16
|
+
- we prepare the output arrays: of size 'size': out_f, out_i
|
|
17
|
+
- 1 output array per type of result: float, int
|
|
18
|
+
- initialized with zeros
|
|
19
|
+
- the function f will loop over a&b and write the result to c
|
|
20
|
+
- the function should be implemented such that it does not use vectorized operations, we want to avoid
|
|
21
|
+
using vectorized CPU instructions (AVX, etc...): we want to measure the speed of the regular, scalar
|
|
22
|
+
operations
|
|
23
|
+
- we numba.jit the function, to make sure it is compiled to machine code, to avoid Python overhead to dominate
|
|
24
|
+
the benchmark
|
|
25
|
+
- 'size' should be chosen large enough to avoid the overhead of the benchmarking framework to be noticeable
|
|
26
|
+
- 'size' should be chosen small enough to fit in the CPU cache, to avoid being RAM-bandwidth limited
|
|
27
|
+
|
|
28
|
+
Despite all these measures, it is not expected that the benchmark will be fully accurate in absolute terms.
|
|
29
|
+
In real-life the speed of execution of floating point operations on a CPU will also be influenced by branching,
|
|
30
|
+
memory access patterns, etc...
|
|
31
|
+
The main goal is to find a reasonably accurate estimate of the relative cost of the main types of floating point
|
|
32
|
+
operations, so we can make representative estimates of the number of FLOPS executed by instrumented algorithms.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
@requires_benchmark_deps
|
|
36
|
+
def __init__(self, name: str, f: Callable, size: int):
|
|
37
|
+
import numpy as np # late import of optional dependency
|
|
38
|
+
|
|
39
|
+
super().__init__(name=name)
|
|
40
|
+
self.size = size
|
|
41
|
+
self.f = f
|
|
42
|
+
self.n_operations = 0
|
|
43
|
+
# input arrays
|
|
44
|
+
self.in_f1: np.ndarray = np.zeros(size, dtype=float)
|
|
45
|
+
self.in_f2: np.ndarray = np.zeros(size, dtype=float)
|
|
46
|
+
# output arrays
|
|
47
|
+
self.out_f: np.ndarray = np.zeros(size, dtype=float)
|
|
48
|
+
self.out_i: np.ndarray = np.zeros(size, dtype=int)
|
|
49
|
+
|
|
50
|
+
def _prepare_benchmark(self, n_operations: int):
|
|
51
|
+
import numpy as np # late import of optional dependency
|
|
52
|
+
|
|
53
|
+
self.n_operations = n_operations
|
|
54
|
+
# input arrays
|
|
55
|
+
self.in_f1 = 10 * np.random.rand(self.size)
|
|
56
|
+
self.in_f2 = 10 * np.random.rand(self.size)
|
|
57
|
+
# output arrays
|
|
58
|
+
self.out_f: np.ndarray = np.full(self.size, 0.0, dtype=float)
|
|
59
|
+
self.out_i: np.ndarray = np.full(self.size, 0, dtype=int)
|
|
60
|
+
|
|
61
|
+
def _run_benchmark(self):
|
|
62
|
+
# repeat 'f' n_operations times, each time on the same data
|
|
63
|
+
for _ in range(self.n_operations):
|
|
64
|
+
self.f(self.size, self.in_f1, self.in_f2, self.out_f, self.out_i)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from counted_float._core._optional_deps import requires_benchmark_deps
|
|
4
|
+
|
|
5
|
+
from ._models import MicroBenchmarkResult, SingleRunResult
|
|
6
|
+
from ._time_utils import Timer, format_time_durations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# =================================================================================================
|
|
10
|
+
# Base class for micro-benchmarks
|
|
11
|
+
# =================================================================================================
|
|
12
|
+
class MicroBenchmark(ABC):
|
|
13
|
+
"""
|
|
14
|
+
Base class for micro-benchmarks, where a child class needs to implement the following methods:
|
|
15
|
+
_prepare_benchmark --> prepares the benchmark_runs (e.g. sets up data); is called once before each _run_benchmark
|
|
16
|
+
and time spent here is not counted.
|
|
17
|
+
_run_benchmark --> runs the benchmark_runs; is called multiple times and time spent here is counted.
|
|
18
|
+
|
|
19
|
+
NOTE: this essentially offers similar functionality as the python-builtin timeit module, but with some benefits:
|
|
20
|
+
- automatic resizing of the benchmark_runs to achieve a target time per run
|
|
21
|
+
- automatic warmup_runs runs
|
|
22
|
+
- robust computation of median run time and ± range based on quantiles, rather than mean & std.
|
|
23
|
+
(=more robust to outliers)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
MAX_N_OPERATIONS_FACTOR = 10 # never adjust n_operations by more than this factor (up or down)
|
|
27
|
+
|
|
28
|
+
@requires_benchmark_deps
|
|
29
|
+
def __init__(self, name: str):
|
|
30
|
+
self.name = name
|
|
31
|
+
|
|
32
|
+
def run_many(
|
|
33
|
+
self, n_runs_total: int = 20, n_runs_warmup: int = 5, n_seconds_per_run_target: float = 0.5
|
|
34
|
+
) -> MicroBenchmarkResult:
|
|
35
|
+
"""
|
|
36
|
+
Runs the MicroBenchmark multiple times (warmup_runs & actual test runs), with provided parameters and returns
|
|
37
|
+
(q25, q50, q75) quantiles of run times in nanoseconds.
|
|
38
|
+
:param n_runs_total: (int, default=20) total number of benchmark_runs runs
|
|
39
|
+
:param n_runs_warmup: (int, default=5) number of warmup_runs runs
|
|
40
|
+
- the first n_runs_warmup of n_runs_total are not included in timing stats
|
|
41
|
+
- warmup_runs runs serve to initialize n_operations & get processor, cache, ...
|
|
42
|
+
in a stable, representative state
|
|
43
|
+
:param n_seconds_per_run_target: (float, default=0.5) target time (sec) per benchmark_runs run (prepare + run).
|
|
44
|
+
n_operations will be iteratively adjusted to achieve this target time.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
print(f"{self.name.ljust(35)}: ", end="")
|
|
48
|
+
|
|
49
|
+
# repeat benchmark_runs n_runs_total times
|
|
50
|
+
n_operations = 1 # start with a benchmark_runs of 1 operation and scale up as needed
|
|
51
|
+
warmup_runs: list[SingleRunResult] = []
|
|
52
|
+
benchmark_runs: list[SingleRunResult] = []
|
|
53
|
+
for i in range(n_runs_total):
|
|
54
|
+
# --- run benchmark_runs ---
|
|
55
|
+
with Timer() as t:
|
|
56
|
+
single_run_result = self.run_once(n_operations)
|
|
57
|
+
t_tot_seconds = t.t_elapsed_sec() # total time (sec) of prepare + run
|
|
58
|
+
|
|
59
|
+
# --- capture result ---
|
|
60
|
+
if i < n_runs_warmup:
|
|
61
|
+
# warmup run that doesn't count
|
|
62
|
+
print("w", end="", flush=True)
|
|
63
|
+
warmup_runs.append(single_run_result)
|
|
64
|
+
else:
|
|
65
|
+
# benchmark run that does count
|
|
66
|
+
print(".", end="", flush=True)
|
|
67
|
+
benchmark_runs.append(single_run_result)
|
|
68
|
+
|
|
69
|
+
# --- adjust n_ops ---
|
|
70
|
+
n_ops_min = max(1, int(n_operations / self.MAX_N_OPERATIONS_FACTOR))
|
|
71
|
+
n_ops_max = int(n_operations * self.MAX_N_OPERATIONS_FACTOR)
|
|
72
|
+
n_operations = max(n_ops_min, min(n_ops_max, int(n_operations * n_seconds_per_run_target / t_tot_seconds)))
|
|
73
|
+
|
|
74
|
+
# final results
|
|
75
|
+
benchmark_result = MicroBenchmarkResult(
|
|
76
|
+
warmup_runs=warmup_runs,
|
|
77
|
+
benchmark_runs=benchmark_runs,
|
|
78
|
+
)
|
|
79
|
+
stats = benchmark_result.summary_stats()
|
|
80
|
+
print(f" {format_time_durations(nsec_q25=stats.q25, nsec_q50=stats.q50, nsec_q75=stats.q75)}")
|
|
81
|
+
|
|
82
|
+
# return quantiles
|
|
83
|
+
return benchmark_result
|
|
84
|
+
|
|
85
|
+
def run_once(self, n_operations: int) -> SingleRunResult:
|
|
86
|
+
"""Runs benchmark_runs once for a given # of operations and returns time in nanoseconds per operation"""
|
|
87
|
+
|
|
88
|
+
# prepare
|
|
89
|
+
self._prepare_benchmark(n_operations)
|
|
90
|
+
|
|
91
|
+
# run
|
|
92
|
+
with Timer() as t:
|
|
93
|
+
self._run_benchmark()
|
|
94
|
+
|
|
95
|
+
# report
|
|
96
|
+
return SingleRunResult(
|
|
97
|
+
n_operations=n_operations,
|
|
98
|
+
t_nsecs=t.t_elapsed_nsec(),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
@abstractmethod
|
|
102
|
+
def _prepare_benchmark(self, n_operations: int):
|
|
103
|
+
"""
|
|
104
|
+
Prepare benchmark_runs (e.g. set up data) based on requested number of operations. This argument is adjusted each
|
|
105
|
+
run by the MicroBenchmarkRunner class to ensure that the benchmark_runs runs for a reasonable amount of time
|
|
106
|
+
(e.g. 1 second per run).
|
|
107
|
+
"""
|
|
108
|
+
raise NotImplementedError()
|
|
109
|
+
|
|
110
|
+
@abstractmethod
|
|
111
|
+
def _run_benchmark(self):
|
|
112
|
+
"""Run benchmark_runs. This method is called multiple times and the time spent here is measured."""
|
|
113
|
+
raise NotImplementedError()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from counted_float._core.counting.models import MyBaseModel, Quantiles
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SingleRunResult(MyBaseModel):
|
|
5
|
+
"""Result of a single run of our micro-benchmark_runs for a give # of operations."""
|
|
6
|
+
|
|
7
|
+
n_operations: int
|
|
8
|
+
t_nsecs: float
|
|
9
|
+
|
|
10
|
+
def nsecs_per_op(self) -> float:
|
|
11
|
+
return self.t_nsecs / self.n_operations
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MicroBenchmarkResult(MyBaseModel):
|
|
15
|
+
"""Results of all runs in the micro-benchmark_runs (warmup_runs + actual benchmark_runs runs)."""
|
|
16
|
+
|
|
17
|
+
warmup_runs: list[SingleRunResult]
|
|
18
|
+
benchmark_runs: list[SingleRunResult]
|
|
19
|
+
|
|
20
|
+
def get_nsec_per_op_quantile(self, q: float) -> float:
|
|
21
|
+
"""Returns a specific quantile of all results in the 'benchmark_runs' category expressed as nsec/op."""
|
|
22
|
+
import numpy as np # late import of optional dependency
|
|
23
|
+
|
|
24
|
+
return float(np.quantile([el.nsecs_per_op() for el in self.benchmark_runs], q))
|
|
25
|
+
|
|
26
|
+
def summary_stats(self) -> Quantiles:
|
|
27
|
+
return Quantiles(
|
|
28
|
+
q25=self.get_nsec_per_op_quantile(q=0.25),
|
|
29
|
+
q50=self.get_nsec_per_op_quantile(q=0.50),
|
|
30
|
+
q75=self.get_nsec_per_op_quantile(q=0.75),
|
|
31
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# =================================================================================================
|
|
5
|
+
# Formatting
|
|
6
|
+
# =================================================================================================
|
|
7
|
+
def format_time_durations(nsec_q25: float, nsec_q50: float, nsec_q75: float) -> str:
|
|
8
|
+
if nsec_q50 < 1e3:
|
|
9
|
+
formatter = _format_nsec_as_ns
|
|
10
|
+
elif nsec_q50 < 1e6:
|
|
11
|
+
formatter = _format_nsec_as_us
|
|
12
|
+
elif nsec_q50 < 1e9:
|
|
13
|
+
formatter = _format_nsec_as_ms
|
|
14
|
+
else:
|
|
15
|
+
formatter = _format_nsec_as_s
|
|
16
|
+
|
|
17
|
+
return f"{formatter(nsec_q50)} ± {formatter((nsec_q75 - nsec_q25) / 2)} / operation"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _format_nsec_as_ns(nsec: float) -> str:
|
|
21
|
+
return f"{nsec:7.2f} ns"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _format_nsec_as_us(nsec: float) -> str:
|
|
25
|
+
return f"{nsec / 1e3:7.2f} µs"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _format_nsec_as_ms(nsec: float) -> str:
|
|
29
|
+
return f"{nsec / 1e6:7.2f} ms"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _format_nsec_as_s(nsec: float) -> str:
|
|
33
|
+
return f"{nsec / 1e9:7.2f} s"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# =================================================================================================
|
|
37
|
+
# Timing
|
|
38
|
+
# =================================================================================================
|
|
39
|
+
class Timer:
|
|
40
|
+
"""
|
|
41
|
+
Class acting as a context manager to measure time elapsed. Elapsed time can be retrieved using t_elapsed().
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
# --- constructor -------------------------------------
|
|
45
|
+
def __init__(self):
|
|
46
|
+
self._start: float | None = None
|
|
47
|
+
self._end: float | None = None
|
|
48
|
+
|
|
49
|
+
# --- context manager ---------------------------------
|
|
50
|
+
def __enter__(self):
|
|
51
|
+
self._start = time.perf_counter_ns()
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
55
|
+
self._end = time.perf_counter_ns()
|
|
56
|
+
|
|
57
|
+
# --- extract results ---------------------------------
|
|
58
|
+
def t_elapsed_nsec(self) -> float:
|
|
59
|
+
if self._start is None:
|
|
60
|
+
raise RuntimeError("Timer has not been started.")
|
|
61
|
+
|
|
62
|
+
if self._end is None:
|
|
63
|
+
# timer still running
|
|
64
|
+
return time.perf_counter_ns() - self._start
|
|
65
|
+
else:
|
|
66
|
+
# timer finished
|
|
67
|
+
return self._end - self._start
|
|
68
|
+
|
|
69
|
+
def t_elapsed_sec(self) -> float:
|
|
70
|
+
return self.t_elapsed_nsec() / 1e9
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from importlib.resources import files
|
|
2
|
+
|
|
3
|
+
from counted_float._core.counting.models import FlopsBenchmarkResults, InstructionLatencies
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BuiltInData:
|
|
7
|
+
"""
|
|
8
|
+
A class that provides access to built-in data for the counted_float package.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def benchmarks(cls) -> dict[str, FlopsBenchmarkResults]:
|
|
13
|
+
return {
|
|
14
|
+
file.stem: FlopsBenchmarkResults.model_validate_json(file.read_text())
|
|
15
|
+
for file in files("counted_float._core.data.benchmarks").iterdir()
|
|
16
|
+
if file.is_file() and file.name.endswith(".json")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def specs(cls) -> dict[str, InstructionLatencies]:
|
|
21
|
+
return {
|
|
22
|
+
file.stem: InstructionLatencies.model_validate_json(file.read_text())
|
|
23
|
+
for file in files("counted_float._core.data.specs").iterdir()
|
|
24
|
+
if file.is_file() and file.name.endswith(".json")
|
|
25
|
+
}
|