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
flopscope/_budget.py
ADDED
|
@@ -0,0 +1,1027 @@
|
|
|
1
|
+
"""Budget context manager and operation recording for flopscope."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import sys as _sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import weakref
|
|
10
|
+
from typing import Any, Literal, NamedTuple
|
|
11
|
+
|
|
12
|
+
from flopscope.errors import BudgetExhaustedError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OpRecord(NamedTuple):
|
|
16
|
+
"""Record of a single counted operation."""
|
|
17
|
+
|
|
18
|
+
op_name: str
|
|
19
|
+
subscripts: str | None
|
|
20
|
+
shapes: tuple
|
|
21
|
+
flop_cost: int
|
|
22
|
+
cumulative: int
|
|
23
|
+
namespace: str | None = None
|
|
24
|
+
flopscope_context_start_offset_s: float | None = (
|
|
25
|
+
None # seconds since active BudgetContext start
|
|
26
|
+
)
|
|
27
|
+
flopscope_backend_duration_s: float | None = (
|
|
28
|
+
None # wall-clock seconds of the backend call
|
|
29
|
+
)
|
|
30
|
+
flopscope_overhead_duration_s: float | None = (
|
|
31
|
+
None # per-op flopscope dispatch time (preamble + deduct body + bookkeeping + postamble)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Why cooperative (not signal-based) deadline enforcement?
|
|
37
|
+
#
|
|
38
|
+
# We deliberately avoid SIGALRM / signal-based preemption because:
|
|
39
|
+
# 1. Python signal handlers only run between bytecodes — they cannot
|
|
40
|
+
# interrupt C extensions (numpy/LAPACK/BLAS), which are exactly the
|
|
41
|
+
# operations where time limits matter most.
|
|
42
|
+
# 2. signal.alarm() is POSIX-only (no Windows) and integer-second only.
|
|
43
|
+
# 3. Signals are main-thread-only and can interfere with numpy internals.
|
|
44
|
+
#
|
|
45
|
+
# The hard enforcement boundary is the container/OS level: flopscope
|
|
46
|
+
# submissions run inside Docker containers with kernel-level time limits
|
|
47
|
+
# (cgroups / rlimit) that deliver SIGKILL when exceeded.
|
|
48
|
+
#
|
|
49
|
+
# The in-library wall_time_limit_s is a UX feature: it gives participants
|
|
50
|
+
# a clean, informative TimeExhaustedError (with op name, elapsed time,
|
|
51
|
+
# and configured limit) rather than a brutal container kill.
|
|
52
|
+
#
|
|
53
|
+
# The deadline is checked:
|
|
54
|
+
# 1. Pre-op: in BudgetContext.deduct() before the numpy call starts.
|
|
55
|
+
# 2. Post-op: in _OpTimer.__exit__() after the numpy call completes.
|
|
56
|
+
#
|
|
57
|
+
# This bounds overshoot to the duration of a single numpy call.
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _OpTimer:
|
|
62
|
+
"""Timer for a counted op's numpy call window.
|
|
63
|
+
|
|
64
|
+
Used as a context manager around the numpy call::
|
|
65
|
+
|
|
66
|
+
with budget.deduct(op_name, ...):
|
|
67
|
+
result = _call_numpy(np_func, ...)
|
|
68
|
+
|
|
69
|
+
On __exit__, the block's wall time is split into:
|
|
70
|
+
- backend duration: sum of _call_numpy durations reported during the block
|
|
71
|
+
- in-block overhead: block_duration - backend duration
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
__slots__ = (
|
|
75
|
+
"_budget",
|
|
76
|
+
"_op_index",
|
|
77
|
+
"_block_t0",
|
|
78
|
+
"_backend_duration_s",
|
|
79
|
+
"_prev_timer",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def __init__(self, budget: BudgetContext, op_index: int):
|
|
83
|
+
self._budget = budget
|
|
84
|
+
self._op_index = op_index
|
|
85
|
+
self._block_t0: float | None = None
|
|
86
|
+
self._backend_duration_s: float = 0.0
|
|
87
|
+
self._prev_timer: _OpTimer | None = None
|
|
88
|
+
|
|
89
|
+
def __enter__(self) -> _OpTimer:
|
|
90
|
+
self._block_t0 = time.perf_counter()
|
|
91
|
+
# Stack discipline supports the rare case of nested deduct() blocks
|
|
92
|
+
self._prev_timer = self._budget._current_op_timer
|
|
93
|
+
self._budget._current_op_timer = self
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> Literal[False]:
|
|
97
|
+
if self._block_t0 is not None:
|
|
98
|
+
block_duration = time.perf_counter() - self._block_t0
|
|
99
|
+
in_block_overhead = max(block_duration - self._backend_duration_s, 0.0)
|
|
100
|
+
|
|
101
|
+
self._budget._total_flopscope_backend_time += self._backend_duration_s
|
|
102
|
+
self._budget._total_flopscope_overhead_time += in_block_overhead
|
|
103
|
+
|
|
104
|
+
op = self._budget._op_log[self._op_index]
|
|
105
|
+
self._budget._op_log[self._op_index] = op._replace(
|
|
106
|
+
flopscope_backend_duration_s=self._backend_duration_s,
|
|
107
|
+
flopscope_overhead_duration_s=(op.flopscope_overhead_duration_s or 0.0)
|
|
108
|
+
+ in_block_overhead,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Post-op deadline check (preserves existing behavior)
|
|
112
|
+
if (
|
|
113
|
+
exc_type is None
|
|
114
|
+
and self._budget._deadline is not None
|
|
115
|
+
and time.perf_counter() > self._budget._deadline
|
|
116
|
+
):
|
|
117
|
+
from flopscope.errors import TimeExhaustedError
|
|
118
|
+
|
|
119
|
+
raise TimeExhaustedError(
|
|
120
|
+
op.op_name,
|
|
121
|
+
elapsed_s=time.perf_counter() - self._budget._start_time, # type: ignore[operator]
|
|
122
|
+
limit_s=self._budget._wall_time_limit_s, # type: ignore[arg-type]
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
self._budget._current_op_timer = self._prev_timer
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _call_numpy(fn: Any, *args: Any, **kwargs: Any) -> Any:
|
|
130
|
+
"""Invoke a numpy callable, attributing only its wall time to backend time.
|
|
131
|
+
|
|
132
|
+
All numpy calls inside counted-op wrappers MUST go through this helper so
|
|
133
|
+
that view-casts, copyto, errstate setup, and other flopscope-internal work
|
|
134
|
+
surrounding the call are correctly bucketed as flopscope_overhead.
|
|
135
|
+
|
|
136
|
+
Reports the call's backend duration to the active _OpTimer (via
|
|
137
|
+
budget._current_op_timer). When no timer is active (e.g. helper called
|
|
138
|
+
from a non-counted code path), it is a transparent passthrough.
|
|
139
|
+
|
|
140
|
+
Annotated as ``Any -> Any`` to match the existing untyped-internal-helper
|
|
141
|
+
convention used by ``_call_with_optional_out``. Wrappers' explicit return
|
|
142
|
+
annotations carry the public type contract.
|
|
143
|
+
"""
|
|
144
|
+
t0 = time.perf_counter()
|
|
145
|
+
try:
|
|
146
|
+
return fn(*args, **kwargs)
|
|
147
|
+
finally:
|
|
148
|
+
d = time.perf_counter() - t0
|
|
149
|
+
budget = get_active_budget()
|
|
150
|
+
if budget is not None and budget._current_op_timer is not None:
|
|
151
|
+
budget._current_op_timer._backend_duration_s += d
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _counted_wrapper(fn):
|
|
155
|
+
"""Decorator that brackets a flopscope wrapper and bills its non-numpy,
|
|
156
|
+
non-nested-overhead time to flopscope_overhead_time.
|
|
157
|
+
|
|
158
|
+
Formula: wall - backend_delta - overhead_delta. Handles nesting naturally
|
|
159
|
+
(outer attributes only its own remainder), so no re-entrancy guard.
|
|
160
|
+
|
|
161
|
+
Per-op attribution: wrapper-own overhead is distributed equally across
|
|
162
|
+
ops created during this call (typically exactly 1 across this codebase).
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
@functools.wraps(fn)
|
|
166
|
+
def wrapped(*args, **kwargs):
|
|
167
|
+
from flopscope._validation import require_budget
|
|
168
|
+
|
|
169
|
+
budget = require_budget()
|
|
170
|
+
fs_t0 = time.perf_counter()
|
|
171
|
+
backend_baseline = budget._total_flopscope_backend_time
|
|
172
|
+
overhead_baseline = budget._total_flopscope_overhead_time
|
|
173
|
+
ops_before = len(budget._op_log)
|
|
174
|
+
try:
|
|
175
|
+
return fn(*args, **kwargs)
|
|
176
|
+
finally:
|
|
177
|
+
wall = time.perf_counter() - fs_t0
|
|
178
|
+
backend_delta = budget._total_flopscope_backend_time - backend_baseline
|
|
179
|
+
overhead_delta = budget._total_flopscope_overhead_time - overhead_baseline
|
|
180
|
+
wrapper_own_overhead = max(wall - backend_delta - overhead_delta, 0.0)
|
|
181
|
+
budget._total_flopscope_overhead_time += wrapper_own_overhead
|
|
182
|
+
ops_added = list(range(ops_before, len(budget._op_log)))
|
|
183
|
+
if ops_added and wrapper_own_overhead > 0:
|
|
184
|
+
per_op = wrapper_own_overhead / len(ops_added)
|
|
185
|
+
for idx in ops_added:
|
|
186
|
+
op = budget._op_log[idx]
|
|
187
|
+
budget._op_log[idx] = op._replace(
|
|
188
|
+
flopscope_overhead_duration_s=(
|
|
189
|
+
op.flopscope_overhead_duration_s or 0.0
|
|
190
|
+
)
|
|
191
|
+
+ per_op
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return wrapped
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ----- Stack-walk tripwire for issue #69 -----
|
|
198
|
+
#
|
|
199
|
+
# Every @_counted_wrapper call creates a new inner `wrapped` function
|
|
200
|
+
# instance, but Python compiles its body once — `wrapped.__code__` is the
|
|
201
|
+
# SAME `code` object across all decorator invocations. We capture it once
|
|
202
|
+
# here as a stable marker. `__array_function__` and `__array_ufunc__` walk
|
|
203
|
+
# the call stack looking for a frame with this code object: if found, the
|
|
204
|
+
# protocol was triggered from inside an fnp wrapper (= a wrapper forgot to
|
|
205
|
+
# strip a FlopscopeArray before calling raw numpy) — that's a bug, raise.
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _capture_wrapped_code():
|
|
209
|
+
"""Capture the `code` object of `_counted_wrapper`'s inner `wrapped`."""
|
|
210
|
+
|
|
211
|
+
@_counted_wrapper
|
|
212
|
+
def _probe(*args, **kwargs):
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
return _probe.__code__
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
_WRAPPED_CO = _capture_wrapped_code()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _called_from_wrapper() -> bool:
|
|
222
|
+
"""True iff a `_counted_wrapper.wrapped` frame appears in the call stack.
|
|
223
|
+
|
|
224
|
+
Used by `FlopscopeArray.__array_function__` and `__array_ufunc__` to
|
|
225
|
+
distinguish "user wrote np.<f>(whest) at top level" (depth=0, auto-route
|
|
226
|
+
with warning) from "an fnp wrapper forgot to strip and leaked WhestArray
|
|
227
|
+
into raw numpy" (depth>0, raise loudly).
|
|
228
|
+
|
|
229
|
+
Implementation: walks `frame.f_back` chain and compares `f.f_code is
|
|
230
|
+
_WRAPPED_CO` (single C-level pointer comparison per frame). Cost is
|
|
231
|
+
O(stack depth) and only paid on actual protocol entries.
|
|
232
|
+
"""
|
|
233
|
+
f = _sys._getframe(1) # skip this frame
|
|
234
|
+
while f is not None:
|
|
235
|
+
if f.f_code is _WRAPPED_CO:
|
|
236
|
+
return True
|
|
237
|
+
f = f.f_back
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
_thread_local = threading.local()
|
|
242
|
+
_all_budget_contexts: weakref.WeakSet[BudgetContext] = weakref.WeakSet()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def get_active_budget() -> BudgetContext | None:
|
|
246
|
+
"""Return the active BudgetContext, or None if outside any context."""
|
|
247
|
+
return getattr(_thread_local, "active_budget", None)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class _NamespaceScope:
|
|
251
|
+
__slots__ = ("_budget", "_segment")
|
|
252
|
+
|
|
253
|
+
def __init__(self, budget: BudgetContext, segment: str):
|
|
254
|
+
self._budget = budget
|
|
255
|
+
self._segment = segment
|
|
256
|
+
|
|
257
|
+
def __enter__(self) -> BudgetContext:
|
|
258
|
+
fs_t0 = time.perf_counter()
|
|
259
|
+
try:
|
|
260
|
+
self._budget._push_namespace(self._segment)
|
|
261
|
+
return self._budget
|
|
262
|
+
finally:
|
|
263
|
+
self._budget._total_flopscope_overhead_time += time.perf_counter() - fs_t0
|
|
264
|
+
|
|
265
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> Literal[False]:
|
|
266
|
+
fs_t0 = time.perf_counter()
|
|
267
|
+
try:
|
|
268
|
+
self._budget._pop_namespace(self._segment)
|
|
269
|
+
return False
|
|
270
|
+
finally:
|
|
271
|
+
self._budget._total_flopscope_overhead_time += time.perf_counter() - fs_t0
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _validate_namespace_segment(name: str) -> str:
|
|
275
|
+
if not isinstance(name, str):
|
|
276
|
+
raise ValueError("namespace segment must be a string")
|
|
277
|
+
segment = name.strip()
|
|
278
|
+
if not segment:
|
|
279
|
+
raise ValueError("namespace segment must be non-empty")
|
|
280
|
+
if "." in segment:
|
|
281
|
+
raise ValueError("namespace segment must not contain '.'")
|
|
282
|
+
return segment
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def namespace(name: str) -> _NamespaceScope:
|
|
286
|
+
"""Create a nested namespace scope for the active budget context.
|
|
287
|
+
|
|
288
|
+
Parameters
|
|
289
|
+
----------
|
|
290
|
+
name : str
|
|
291
|
+
Namespace segment to append to the active budget namespace. The
|
|
292
|
+
segment must be non-empty and must not contain ``"."``.
|
|
293
|
+
|
|
294
|
+
Returns
|
|
295
|
+
-------
|
|
296
|
+
_NamespaceScope
|
|
297
|
+
Context manager that attributes counted operations to the nested
|
|
298
|
+
namespace while the scope is active.
|
|
299
|
+
|
|
300
|
+
Raises
|
|
301
|
+
------
|
|
302
|
+
NoBudgetContextError
|
|
303
|
+
If called outside an active :class:`BudgetContext`.
|
|
304
|
+
ValueError
|
|
305
|
+
If ``name`` is not a valid namespace segment.
|
|
306
|
+
|
|
307
|
+
Examples
|
|
308
|
+
--------
|
|
309
|
+
>>> import flopscope as flops
|
|
310
|
+
>>> import flopscope.numpy as fnp
|
|
311
|
+
>>>
|
|
312
|
+
>>> with flops.BudgetContext(flop_budget=100, quiet=True) as budget:
|
|
313
|
+
... with flops.namespace("encoder"):
|
|
314
|
+
... _ = fnp.add(fnp.array([1.0]), fnp.array([2.0]))
|
|
315
|
+
>>> budget.op_log[-1].namespace
|
|
316
|
+
'encoder'
|
|
317
|
+
"""
|
|
318
|
+
from flopscope.errors import NoBudgetContextError
|
|
319
|
+
|
|
320
|
+
budget = get_active_budget()
|
|
321
|
+
if budget is None:
|
|
322
|
+
raise NoBudgetContextError()
|
|
323
|
+
return _NamespaceScope(budget, _validate_namespace_segment(name))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _update_operation_summary(ops: dict[str, dict], op: OpRecord) -> None:
|
|
327
|
+
bucket = ops.setdefault(
|
|
328
|
+
op.op_name,
|
|
329
|
+
{
|
|
330
|
+
"flop_cost": 0,
|
|
331
|
+
"calls": 0,
|
|
332
|
+
"flopscope_backend_time_s": 0.0,
|
|
333
|
+
"flopscope_overhead_time_s": 0.0,
|
|
334
|
+
},
|
|
335
|
+
)
|
|
336
|
+
bucket["flop_cost"] += op.flop_cost
|
|
337
|
+
bucket["calls"] += 1
|
|
338
|
+
if op.flopscope_backend_duration_s is not None:
|
|
339
|
+
bucket["flopscope_backend_time_s"] += op.flopscope_backend_duration_s
|
|
340
|
+
if op.flopscope_overhead_duration_s is not None:
|
|
341
|
+
bucket["flopscope_overhead_time_s"] += op.flopscope_overhead_duration_s
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _summarize_operations(op_log: list[OpRecord]) -> dict[str, dict]:
|
|
345
|
+
ops: dict[str, dict] = {}
|
|
346
|
+
for op in op_log:
|
|
347
|
+
_update_operation_summary(ops, op)
|
|
348
|
+
return ops
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _summarize_by_namespace(op_log: list[OpRecord]) -> dict[str | None, dict]:
|
|
352
|
+
by_namespace: dict[str | None, dict] = {}
|
|
353
|
+
for op in op_log:
|
|
354
|
+
bucket = by_namespace.setdefault(
|
|
355
|
+
op.namespace,
|
|
356
|
+
{
|
|
357
|
+
"flops_used": 0,
|
|
358
|
+
"calls": 0,
|
|
359
|
+
"flopscope_backend_time_s": 0.0,
|
|
360
|
+
"flopscope_overhead_time_s": 0.0,
|
|
361
|
+
"operations": {},
|
|
362
|
+
},
|
|
363
|
+
)
|
|
364
|
+
bucket["flops_used"] += op.flop_cost
|
|
365
|
+
bucket["calls"] += 1
|
|
366
|
+
if op.flopscope_backend_duration_s is not None:
|
|
367
|
+
bucket["flopscope_backend_time_s"] += op.flopscope_backend_duration_s
|
|
368
|
+
if op.flopscope_overhead_duration_s is not None:
|
|
369
|
+
bucket["flopscope_overhead_time_s"] += op.flopscope_overhead_duration_s
|
|
370
|
+
_update_operation_summary(bucket["operations"], op)
|
|
371
|
+
return by_namespace
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _timing_summary(
|
|
375
|
+
wall_time_s: float | None,
|
|
376
|
+
flopscope_backend_time_s: float | None,
|
|
377
|
+
overhead_time_s: float | None,
|
|
378
|
+
) -> tuple[float | None, float, float, float | None]:
|
|
379
|
+
backend = flopscope_backend_time_s or 0.0
|
|
380
|
+
overhead = overhead_time_s or 0.0
|
|
381
|
+
if wall_time_s is None:
|
|
382
|
+
return None, backend, overhead, None
|
|
383
|
+
residual = wall_time_s - backend - overhead
|
|
384
|
+
if residual < 0 and abs(residual) < 1e-12:
|
|
385
|
+
residual = 0.0
|
|
386
|
+
return wall_time_s, backend, overhead, max(residual, 0.0)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class BudgetContext:
|
|
390
|
+
"""Context manager for FLOP budget enforcement.
|
|
391
|
+
|
|
392
|
+
Parameters
|
|
393
|
+
----------
|
|
394
|
+
flop_budget : int
|
|
395
|
+
Maximum number of FLOPs allowed. Must be > 0.
|
|
396
|
+
flop_multiplier : float, optional
|
|
397
|
+
Multiplier applied to all FLOP costs. Default 1.
|
|
398
|
+
quiet : bool, optional
|
|
399
|
+
When ``True``, suppress the startup banner printed on context entry.
|
|
400
|
+
namespace : str | None, optional
|
|
401
|
+
Root namespace prefix used for operation attribution inside this
|
|
402
|
+
context. Nested ``flops.namespace(...)`` scopes append dotted segments.
|
|
403
|
+
wall_time_limit_s : float | None, optional
|
|
404
|
+
Cooperative wall-clock limit for the entire context. The timer starts
|
|
405
|
+
when the context is entered and is checked before and after each
|
|
406
|
+
counted NumPy call. If the deadline is exceeded, flopscope raises
|
|
407
|
+
``TimeExhaustedError`` at the next operation boundary. This is a
|
|
408
|
+
diagnostic UX limit, not a hard preemptive kill.
|
|
409
|
+
|
|
410
|
+
Examples
|
|
411
|
+
--------
|
|
412
|
+
>>> import flopscope as flops
|
|
413
|
+
>>> import flopscope.numpy as fnp
|
|
414
|
+
>>>
|
|
415
|
+
>>> with flops.BudgetContext(flop_budget=100, quiet=True) as budget:
|
|
416
|
+
... x = fnp.array([1.0, 2.0, 3.0])
|
|
417
|
+
... _ = fnp.einsum("i->", x)
|
|
418
|
+
>>> budget.summary_dict()["flops_used"] > 0
|
|
419
|
+
True
|
|
420
|
+
"""
|
|
421
|
+
|
|
422
|
+
def __init__(
|
|
423
|
+
self,
|
|
424
|
+
flop_budget: int,
|
|
425
|
+
flop_multiplier: float = 1.0,
|
|
426
|
+
quiet: bool = False,
|
|
427
|
+
namespace: str | None = None,
|
|
428
|
+
wall_time_limit_s: float | None = None,
|
|
429
|
+
):
|
|
430
|
+
# Capture creation time as the very first thing so any work done in
|
|
431
|
+
# __init__ (validation, list/dict allocation) is included in the
|
|
432
|
+
# eventual wall_time_s span, billed to flopscope_overhead_time. See
|
|
433
|
+
# issue #82.
|
|
434
|
+
self._creation_time = time.perf_counter()
|
|
435
|
+
if flop_budget <= 0:
|
|
436
|
+
raise ValueError(f"flop_budget must be > 0, got {flop_budget}")
|
|
437
|
+
self._flop_budget = flop_budget
|
|
438
|
+
self._flop_multiplier = flop_multiplier
|
|
439
|
+
self._flops_used = 0
|
|
440
|
+
self._op_log: list[OpRecord] = []
|
|
441
|
+
self._quiet = quiet
|
|
442
|
+
self._root_namespace = namespace
|
|
443
|
+
self._namespace_stack: list[str] = []
|
|
444
|
+
self._previous_budget: BudgetContext | None = None
|
|
445
|
+
self._wall_time_limit_s = wall_time_limit_s
|
|
446
|
+
self._start_time: float | None = None
|
|
447
|
+
self._deadline: float | None = None
|
|
448
|
+
self._wall_time_s: float | None = None
|
|
449
|
+
self._total_flopscope_backend_time: float = 0.0
|
|
450
|
+
self._total_flopscope_overhead_time: float = 0.0
|
|
451
|
+
self._pre_enter_overhead: float = 0.0
|
|
452
|
+
self._current_op_timer: _OpTimer | None = None
|
|
453
|
+
self._recorded_flops_used = 0
|
|
454
|
+
self._recorded_op_count = 0
|
|
455
|
+
self._recorded_flopscope_backend_time = 0.0
|
|
456
|
+
self._recorded_overhead_time: float = 0.0
|
|
457
|
+
self._budget_recorded = False
|
|
458
|
+
_all_budget_contexts.add(self)
|
|
459
|
+
|
|
460
|
+
@property
|
|
461
|
+
def flop_budget(self) -> int:
|
|
462
|
+
return self._flop_budget
|
|
463
|
+
|
|
464
|
+
@property
|
|
465
|
+
def flops_used(self) -> int:
|
|
466
|
+
return self._flops_used
|
|
467
|
+
|
|
468
|
+
@property
|
|
469
|
+
def flops_remaining(self) -> int:
|
|
470
|
+
return self._flop_budget - self._flops_used
|
|
471
|
+
|
|
472
|
+
@property
|
|
473
|
+
def flop_multiplier(self) -> float:
|
|
474
|
+
return self._flop_multiplier
|
|
475
|
+
|
|
476
|
+
@property
|
|
477
|
+
def op_log(self) -> list[OpRecord]:
|
|
478
|
+
return self._op_log
|
|
479
|
+
|
|
480
|
+
@property
|
|
481
|
+
def namespace(self) -> str | None:
|
|
482
|
+
if not self._namespace_stack:
|
|
483
|
+
return self._root_namespace
|
|
484
|
+
suffix = ".".join(self._namespace_stack)
|
|
485
|
+
if self._root_namespace is None:
|
|
486
|
+
return suffix
|
|
487
|
+
return f"{self._root_namespace}.{suffix}"
|
|
488
|
+
|
|
489
|
+
@property
|
|
490
|
+
def wall_time_limit_s(self) -> float | None:
|
|
491
|
+
return self._wall_time_limit_s
|
|
492
|
+
|
|
493
|
+
@property
|
|
494
|
+
def wall_time_s(self) -> float | None:
|
|
495
|
+
"""Total wall-clock seconds spanned by the context.
|
|
496
|
+
|
|
497
|
+
Measured from ``__init__`` start to the end of ``__exit__`` (after the
|
|
498
|
+
accumulator-record and active-budget restoration work). ``None`` until
|
|
499
|
+
``__exit__`` has run.
|
|
500
|
+
|
|
501
|
+
The decomposition ``wall_time_s == flopscope_backend_time
|
|
502
|
+
+ flopscope_overhead_time + residual_wall_time`` holds within numerical
|
|
503
|
+
tolerance. The pre-``__enter__`` slice (``__init__`` body + banner print)
|
|
504
|
+
and the post-``__exit__`` body slice (accumulator-record,
|
|
505
|
+
active-budget restore) are both attributed to
|
|
506
|
+
``flopscope_overhead_time``.
|
|
507
|
+
"""
|
|
508
|
+
return self._wall_time_s
|
|
509
|
+
|
|
510
|
+
@property
|
|
511
|
+
def elapsed_s(self) -> float:
|
|
512
|
+
if self._start_time is None:
|
|
513
|
+
return 0.0
|
|
514
|
+
return time.perf_counter() - self._start_time
|
|
515
|
+
|
|
516
|
+
@property
|
|
517
|
+
def flopscope_backend_time(self) -> float:
|
|
518
|
+
return self._total_flopscope_backend_time
|
|
519
|
+
|
|
520
|
+
@property
|
|
521
|
+
def flopscope_overhead_time(self) -> float:
|
|
522
|
+
"""Wall-clock seconds spent inside flopscope's own dispatch code.
|
|
523
|
+
|
|
524
|
+
Includes wrapper preambles, BudgetContext.deduct() body, _OpTimer
|
|
525
|
+
bookkeeping, the flopscope-internal parts of the timed block (view-
|
|
526
|
+
casts, copyto, dispatch), wrapper postambles (including
|
|
527
|
+
maybe_check_nan_inf when opted in), and namespace push/pop.
|
|
528
|
+
|
|
529
|
+
Measured per op via the @_counted_wrapper decorator. Aggregated per
|
|
530
|
+
namespace via summary_dict(by_namespace=True).
|
|
531
|
+
"""
|
|
532
|
+
return self._total_flopscope_overhead_time
|
|
533
|
+
|
|
534
|
+
@property
|
|
535
|
+
def residual_wall_time(self) -> float | None:
|
|
536
|
+
"""Wall time minus flopscope backend and overhead time.
|
|
537
|
+
|
|
538
|
+
This is the measured wall-clock remainder outside the backend calls and
|
|
539
|
+
flopscope's own dispatch/accounting work: user Python between ops,
|
|
540
|
+
time.sleep, GC pauses, and un-instrumented NumPy.
|
|
541
|
+
"""
|
|
542
|
+
if self._wall_time_s is None:
|
|
543
|
+
return None
|
|
544
|
+
residual = (
|
|
545
|
+
self._wall_time_s
|
|
546
|
+
- self._total_flopscope_backend_time
|
|
547
|
+
- self._total_flopscope_overhead_time
|
|
548
|
+
)
|
|
549
|
+
if residual < 0 and abs(residual) < 1e-12:
|
|
550
|
+
return 0.0
|
|
551
|
+
return max(residual, 0.0)
|
|
552
|
+
|
|
553
|
+
def deduct(
|
|
554
|
+
self, op_name: str, *, flop_cost: int, subscripts: str | None, shapes: tuple
|
|
555
|
+
) -> _OpTimer:
|
|
556
|
+
"""Deduct FLOPs from the budget and return a timer context manager."""
|
|
557
|
+
fs_t0 = time.perf_counter()
|
|
558
|
+
appended = False
|
|
559
|
+
try:
|
|
560
|
+
from flopscope._weights import get_weight
|
|
561
|
+
|
|
562
|
+
weight = get_weight(op_name)
|
|
563
|
+
adjusted_cost = int(flop_cost * self._flop_multiplier * weight)
|
|
564
|
+
if adjusted_cost > self.flops_remaining:
|
|
565
|
+
raise BudgetExhaustedError(
|
|
566
|
+
op_name,
|
|
567
|
+
flop_cost=adjusted_cost,
|
|
568
|
+
flops_remaining=self.flops_remaining,
|
|
569
|
+
)
|
|
570
|
+
self._flops_used += adjusted_cost
|
|
571
|
+
|
|
572
|
+
now = time.perf_counter()
|
|
573
|
+
context_start_offset_s = (
|
|
574
|
+
now - self._start_time if self._start_time is not None else None
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
self._op_log.append(
|
|
578
|
+
OpRecord(
|
|
579
|
+
op_name=op_name,
|
|
580
|
+
subscripts=subscripts,
|
|
581
|
+
shapes=shapes,
|
|
582
|
+
flop_cost=adjusted_cost,
|
|
583
|
+
cumulative=self._flops_used,
|
|
584
|
+
namespace=self.namespace,
|
|
585
|
+
flopscope_context_start_offset_s=context_start_offset_s,
|
|
586
|
+
)
|
|
587
|
+
)
|
|
588
|
+
appended = True
|
|
589
|
+
|
|
590
|
+
if self._deadline is not None and now > self._deadline:
|
|
591
|
+
from flopscope.errors import TimeExhaustedError
|
|
592
|
+
|
|
593
|
+
raise TimeExhaustedError(
|
|
594
|
+
op_name,
|
|
595
|
+
elapsed_s=now - self._start_time, # type: ignore[operator]
|
|
596
|
+
limit_s=self._wall_time_limit_s, # type: ignore[arg-type]
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
op_index = len(self._op_log) - 1
|
|
600
|
+
return _OpTimer(self, op_index=op_index)
|
|
601
|
+
finally:
|
|
602
|
+
deduct_body_time = time.perf_counter() - fs_t0
|
|
603
|
+
self._total_flopscope_overhead_time += deduct_body_time
|
|
604
|
+
if appended:
|
|
605
|
+
op = self._op_log[-1]
|
|
606
|
+
self._op_log[-1] = op._replace(
|
|
607
|
+
flopscope_overhead_duration_s=(
|
|
608
|
+
op.flopscope_overhead_duration_s or 0.0
|
|
609
|
+
)
|
|
610
|
+
+ deduct_body_time
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
def summary_dict(self, by_namespace: bool = False) -> dict:
|
|
614
|
+
"""Return structured summary data for this budget context.
|
|
615
|
+
|
|
616
|
+
Returns a dict with keys ``flop_budget``, ``flops_used``,
|
|
617
|
+
``flops_remaining``, ``operations``, ``wall_time_s``,
|
|
618
|
+
``flopscope_backend_time_s``, ``flopscope_overhead_time_s``,
|
|
619
|
+
``residual_wall_time_s``, and optionally ``by_namespace`` with per-
|
|
620
|
+
namespace buckets that each include the same timing keys.
|
|
621
|
+
|
|
622
|
+
Decomposition: ``wall_time_s == flopscope_backend_time_s
|
|
623
|
+
+ flopscope_overhead_time_s + residual_wall_time_s`` (within numerical
|
|
624
|
+
tolerance).
|
|
625
|
+
"""
|
|
626
|
+
wall_time = self._wall_time_s
|
|
627
|
+
if wall_time is None and self._start_time is not None:
|
|
628
|
+
wall_time = self.elapsed_s
|
|
629
|
+
wall_time, backend_time, overhead_time, residual_wall_time = _timing_summary(
|
|
630
|
+
wall_time,
|
|
631
|
+
self._total_flopscope_backend_time,
|
|
632
|
+
self._total_flopscope_overhead_time,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
result = {
|
|
636
|
+
"flop_budget": self._flop_budget,
|
|
637
|
+
"flops_used": self._flops_used,
|
|
638
|
+
"flops_remaining": self.flops_remaining,
|
|
639
|
+
"operations": _summarize_operations(self._op_log),
|
|
640
|
+
"wall_time_s": wall_time,
|
|
641
|
+
"flopscope_backend_time_s": backend_time,
|
|
642
|
+
"flopscope_overhead_time_s": overhead_time,
|
|
643
|
+
"residual_wall_time_s": residual_wall_time,
|
|
644
|
+
}
|
|
645
|
+
if by_namespace:
|
|
646
|
+
result["by_namespace"] = _summarize_by_namespace(self._op_log)
|
|
647
|
+
return result
|
|
648
|
+
|
|
649
|
+
def summary(self, by_namespace: bool = False) -> str:
|
|
650
|
+
"""Return a pretty-printed FLOP budget summary."""
|
|
651
|
+
from flopscope._display import _format_budget_summary_text
|
|
652
|
+
|
|
653
|
+
header = "flopscope FLOP Budget Summary"
|
|
654
|
+
if self.namespace:
|
|
655
|
+
header += f" [{self.namespace}]"
|
|
656
|
+
return _format_budget_summary_text(
|
|
657
|
+
self.summary_dict(by_namespace=by_namespace),
|
|
658
|
+
by_namespace=by_namespace,
|
|
659
|
+
header=header,
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
def _push_namespace(self, segment: str) -> None:
|
|
663
|
+
self._namespace_stack.append(segment)
|
|
664
|
+
|
|
665
|
+
def _pop_namespace(self, expected: str) -> None:
|
|
666
|
+
actual = self._namespace_stack.pop()
|
|
667
|
+
if actual != expected:
|
|
668
|
+
raise RuntimeError(
|
|
669
|
+
f"Namespace stack corrupted: expected {expected!r}, got {actual!r}"
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
def _snapshot_record(self) -> NamespaceRecord:
|
|
673
|
+
wall_time = self.wall_time_s
|
|
674
|
+
if wall_time is None and self._start_time is not None:
|
|
675
|
+
wall_time = self.elapsed_s
|
|
676
|
+
|
|
677
|
+
backend_delta = (
|
|
678
|
+
self._total_flopscope_backend_time - self._recorded_flopscope_backend_time
|
|
679
|
+
)
|
|
680
|
+
if backend_delta < 0 and abs(backend_delta) < 1e-12:
|
|
681
|
+
backend_delta = 0.0
|
|
682
|
+
|
|
683
|
+
overhead_delta = (
|
|
684
|
+
self._total_flopscope_overhead_time - self._recorded_overhead_time
|
|
685
|
+
)
|
|
686
|
+
if overhead_delta < 0 and abs(overhead_delta) < 1e-12:
|
|
687
|
+
overhead_delta = 0.0
|
|
688
|
+
|
|
689
|
+
return NamespaceRecord(
|
|
690
|
+
namespace=self.namespace,
|
|
691
|
+
flop_budget=0 if self._budget_recorded else self.flop_budget,
|
|
692
|
+
flops_used=max(self._flops_used - self._recorded_flops_used, 0),
|
|
693
|
+
op_log=list(self._op_log[self._recorded_op_count :]),
|
|
694
|
+
wall_time_s=wall_time,
|
|
695
|
+
total_flopscope_backend_time=max(backend_delta, 0.0),
|
|
696
|
+
total_flopscope_overhead_time=max(overhead_delta, 0.0),
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
def _has_unrecorded_activity(self) -> bool:
|
|
700
|
+
return self._flops_used > self._recorded_flops_used
|
|
701
|
+
|
|
702
|
+
def _mark_recorded(self) -> None:
|
|
703
|
+
self._recorded_flops_used = self._flops_used
|
|
704
|
+
self._recorded_op_count = len(self._op_log)
|
|
705
|
+
self._recorded_flopscope_backend_time = self._total_flopscope_backend_time
|
|
706
|
+
self._recorded_overhead_time = self._total_flopscope_overhead_time
|
|
707
|
+
self._budget_recorded = True
|
|
708
|
+
|
|
709
|
+
def _mark_reset_baseline(self) -> None:
|
|
710
|
+
self._recorded_flops_used = self._flops_used
|
|
711
|
+
self._recorded_op_count = len(self._op_log)
|
|
712
|
+
self._recorded_flopscope_backend_time = self._total_flopscope_backend_time
|
|
713
|
+
self._recorded_overhead_time = self._total_flopscope_overhead_time
|
|
714
|
+
self._budget_recorded = False
|
|
715
|
+
|
|
716
|
+
def __enter__(self) -> BudgetContext:
|
|
717
|
+
current = get_active_budget()
|
|
718
|
+
if current is not None and current is not _global_default:
|
|
719
|
+
raise RuntimeError("Cannot nest BudgetContexts")
|
|
720
|
+
self._previous_budget = current # save (may be global default or None)
|
|
721
|
+
_thread_local.active_budget = self
|
|
722
|
+
self._wall_time_s = None
|
|
723
|
+
self._deadline = None
|
|
724
|
+
if not self._quiet:
|
|
725
|
+
import sys
|
|
726
|
+
|
|
727
|
+
import flopscope
|
|
728
|
+
|
|
729
|
+
banner = (
|
|
730
|
+
f"flopscope {flopscope.__version__} "
|
|
731
|
+
f"(numpy {flopscope.__numpy_version__} backend) | "
|
|
732
|
+
f"budget: {self._flop_budget:.2e} FLOPs"
|
|
733
|
+
)
|
|
734
|
+
if self._wall_time_limit_s is not None:
|
|
735
|
+
banner += f" | time limit: {self._wall_time_limit_s:.1f}s"
|
|
736
|
+
print(banner, file=sys.stderr)
|
|
737
|
+
# Mark wall start AFTER all enter setup (including the banner print).
|
|
738
|
+
# The slice from _creation_time → _start_time is pre-enter overhead;
|
|
739
|
+
# __exit__ adds it to flopscope_overhead_time. See issue #82.
|
|
740
|
+
self._start_time = time.perf_counter()
|
|
741
|
+
self._pre_enter_overhead = self._start_time - self._creation_time
|
|
742
|
+
if self._wall_time_limit_s is not None:
|
|
743
|
+
self._deadline = self._start_time + self._wall_time_limit_s
|
|
744
|
+
return self
|
|
745
|
+
|
|
746
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
747
|
+
# body_end snapshots the user-code window (start_time → here). Then
|
|
748
|
+
# we do the post-exit accumulator work and snapshot post_exit_end.
|
|
749
|
+
# wall_time_s spans creation → post_exit_end, and the pre-enter +
|
|
750
|
+
# post-exit slices are billed to flopscope_overhead_time. See #82.
|
|
751
|
+
body_end = time.perf_counter()
|
|
752
|
+
if self._start_time is not None:
|
|
753
|
+
_accumulator.record(self)
|
|
754
|
+
_thread_local.active_budget = self._previous_budget
|
|
755
|
+
post_exit_end = time.perf_counter()
|
|
756
|
+
self._wall_time_s = post_exit_end - self._creation_time
|
|
757
|
+
self._total_flopscope_overhead_time += self._pre_enter_overhead + (
|
|
758
|
+
post_exit_end - body_end
|
|
759
|
+
)
|
|
760
|
+
else:
|
|
761
|
+
# Defensive: __exit__ called without __enter__.
|
|
762
|
+
_thread_local.active_budget = self._previous_budget
|
|
763
|
+
return None
|
|
764
|
+
|
|
765
|
+
def __call__(self, func):
|
|
766
|
+
"""Use BudgetContext as a decorator."""
|
|
767
|
+
|
|
768
|
+
@functools.wraps(func)
|
|
769
|
+
def wrapper(*args, **kwargs):
|
|
770
|
+
with self:
|
|
771
|
+
return func(*args, **kwargs)
|
|
772
|
+
|
|
773
|
+
return wrapper
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def budget(
|
|
777
|
+
flop_budget: int,
|
|
778
|
+
flop_multiplier: float = 1.0,
|
|
779
|
+
quiet: bool = False,
|
|
780
|
+
namespace: str | None = None,
|
|
781
|
+
wall_time_limit_s: float | None = None,
|
|
782
|
+
) -> BudgetContext:
|
|
783
|
+
"""Create a ``BudgetContext`` usable as a context manager or decorator.
|
|
784
|
+
|
|
785
|
+
This helper accepts the same arguments as ``BudgetContext(...)`` and is
|
|
786
|
+
convenient when you want a short, function-style entrypoint.
|
|
787
|
+
|
|
788
|
+
Parameters
|
|
789
|
+
----------
|
|
790
|
+
flop_budget : int
|
|
791
|
+
Maximum number of FLOPs allowed inside the context.
|
|
792
|
+
flop_multiplier : float, optional
|
|
793
|
+
Multiplier applied to every charged FLOP cost. Default ``1.0``.
|
|
794
|
+
quiet : bool, optional
|
|
795
|
+
If ``True``, suppress the startup banner on context entry.
|
|
796
|
+
namespace : str or None, optional
|
|
797
|
+
Root namespace prefix for attribution.
|
|
798
|
+
wall_time_limit_s : float or None, optional
|
|
799
|
+
Cooperative wall-clock limit checked at operation boundaries.
|
|
800
|
+
|
|
801
|
+
Returns
|
|
802
|
+
-------
|
|
803
|
+
BudgetContext
|
|
804
|
+
A context manager that can also be used as a decorator.
|
|
805
|
+
|
|
806
|
+
Examples
|
|
807
|
+
--------
|
|
808
|
+
>>> import flopscope as flops
|
|
809
|
+
>>> import flopscope.numpy as fnp
|
|
810
|
+
>>> with flops.budget(1_000):
|
|
811
|
+
... _ = fnp.add(fnp.array([1.0]), fnp.array([2.0]))
|
|
812
|
+
"""
|
|
813
|
+
return BudgetContext(
|
|
814
|
+
flop_budget=flop_budget,
|
|
815
|
+
flop_multiplier=flop_multiplier,
|
|
816
|
+
quiet=quiet,
|
|
817
|
+
namespace=namespace,
|
|
818
|
+
wall_time_limit_s=wall_time_limit_s,
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
# ---------------------------------------------------------------------------
|
|
823
|
+
# Global default BudgetContext
|
|
824
|
+
# ---------------------------------------------------------------------------
|
|
825
|
+
|
|
826
|
+
_global_default: BudgetContext | None = None
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _get_default_budget_amount() -> int:
|
|
830
|
+
"""Read default budget from env var, falling back to 1e15."""
|
|
831
|
+
import os
|
|
832
|
+
|
|
833
|
+
raw = os.environ.get("FLOPSCOPE_DEFAULT_BUDGET")
|
|
834
|
+
if raw is not None:
|
|
835
|
+
return int(float(raw))
|
|
836
|
+
return int(1e15)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _get_global_default() -> BudgetContext:
|
|
840
|
+
"""Return the global default BudgetContext, creating it lazily."""
|
|
841
|
+
global _global_default
|
|
842
|
+
if _global_default is None:
|
|
843
|
+
_global_default = BudgetContext(
|
|
844
|
+
flop_budget=_get_default_budget_amount(),
|
|
845
|
+
quiet=True,
|
|
846
|
+
namespace=None,
|
|
847
|
+
)
|
|
848
|
+
_thread_local.active_budget = _global_default
|
|
849
|
+
return _global_default
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _reset_global_default() -> None:
|
|
853
|
+
"""Reset the global default context. For testing and core library use."""
|
|
854
|
+
global _global_default
|
|
855
|
+
if (
|
|
856
|
+
_global_default is not None
|
|
857
|
+
and getattr(_thread_local, "active_budget", None) is _global_default
|
|
858
|
+
):
|
|
859
|
+
_thread_local.active_budget = None
|
|
860
|
+
_global_default = None
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
# ---------------------------------------------------------------------------
|
|
864
|
+
# Session-level accumulator
|
|
865
|
+
# ---------------------------------------------------------------------------
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
class NamespaceRecord(NamedTuple):
|
|
869
|
+
"""Snapshot of a BudgetContext's state at close time."""
|
|
870
|
+
|
|
871
|
+
namespace: str | None
|
|
872
|
+
flop_budget: int
|
|
873
|
+
flops_used: int
|
|
874
|
+
op_log: list[OpRecord]
|
|
875
|
+
wall_time_s: float | None = None
|
|
876
|
+
total_flopscope_backend_time: float | None = None
|
|
877
|
+
total_flopscope_overhead_time: float | None = None
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _snapshot_namespace_record(ctx: BudgetContext) -> NamespaceRecord:
|
|
881
|
+
return ctx._snapshot_record()
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
class BudgetAccumulator:
|
|
885
|
+
"""Collects budget records across multiple BudgetContext sessions."""
|
|
886
|
+
|
|
887
|
+
def __init__(self) -> None:
|
|
888
|
+
self._records: list[NamespaceRecord] = []
|
|
889
|
+
|
|
890
|
+
def record(self, ctx: BudgetContext) -> None:
|
|
891
|
+
"""Snapshot a BudgetContext and store it."""
|
|
892
|
+
self._records.append(_snapshot_namespace_record(ctx))
|
|
893
|
+
ctx._mark_recorded()
|
|
894
|
+
|
|
895
|
+
def get_data(self, by_namespace: bool = False) -> dict:
|
|
896
|
+
"""Return aggregated budget data across all recorded contexts."""
|
|
897
|
+
total_budget = 0
|
|
898
|
+
total_used = 0
|
|
899
|
+
total_wall_time: float | None = None
|
|
900
|
+
total_backend: float | None = None
|
|
901
|
+
total_overhead: float | None = None
|
|
902
|
+
all_ops: list[OpRecord] = []
|
|
903
|
+
|
|
904
|
+
for rec in self._records:
|
|
905
|
+
total_budget += rec.flop_budget
|
|
906
|
+
total_used += rec.flops_used
|
|
907
|
+
all_ops.extend(rec.op_log)
|
|
908
|
+
if rec.wall_time_s is not None:
|
|
909
|
+
total_wall_time = (total_wall_time or 0.0) + rec.wall_time_s
|
|
910
|
+
if rec.total_flopscope_backend_time is not None:
|
|
911
|
+
total_backend = (
|
|
912
|
+
total_backend or 0.0
|
|
913
|
+
) + rec.total_flopscope_backend_time
|
|
914
|
+
if rec.total_flopscope_overhead_time is not None:
|
|
915
|
+
total_overhead = (
|
|
916
|
+
total_overhead or 0.0
|
|
917
|
+
) + rec.total_flopscope_overhead_time
|
|
918
|
+
|
|
919
|
+
wall_time, backend_time, overhead_time, residual_wall_time = _timing_summary(
|
|
920
|
+
total_wall_time, total_backend, total_overhead
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
result = {
|
|
924
|
+
"flop_budget": total_budget,
|
|
925
|
+
"flops_used": total_used,
|
|
926
|
+
"flops_remaining": total_budget - total_used,
|
|
927
|
+
"operations": _summarize_operations(all_ops),
|
|
928
|
+
"wall_time_s": wall_time,
|
|
929
|
+
"flopscope_backend_time_s": backend_time,
|
|
930
|
+
"flopscope_overhead_time_s": overhead_time,
|
|
931
|
+
"residual_wall_time_s": residual_wall_time,
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
if by_namespace:
|
|
935
|
+
result["by_namespace"] = _summarize_by_namespace(all_ops)
|
|
936
|
+
|
|
937
|
+
return result
|
|
938
|
+
|
|
939
|
+
def reset(self) -> None:
|
|
940
|
+
"""Clear all recorded data."""
|
|
941
|
+
self._records.clear()
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
_accumulator = BudgetAccumulator()
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _snapshot_records() -> list[NamespaceRecord]:
|
|
948
|
+
records = list(_accumulator._records)
|
|
949
|
+
active = get_active_budget()
|
|
950
|
+
if _global_default is not None and _global_default._has_unrecorded_activity():
|
|
951
|
+
records.append(_snapshot_namespace_record(_global_default))
|
|
952
|
+
if (
|
|
953
|
+
active is not None
|
|
954
|
+
and active is not _global_default
|
|
955
|
+
and active._has_unrecorded_activity()
|
|
956
|
+
):
|
|
957
|
+
records.append(_snapshot_namespace_record(active))
|
|
958
|
+
return records
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def budget_summary_dict(by_namespace: bool = False) -> dict:
|
|
962
|
+
"""Return aggregated budget data across all recorded contexts.
|
|
963
|
+
|
|
964
|
+
Parameters
|
|
965
|
+
----------
|
|
966
|
+
by_namespace : bool, optional
|
|
967
|
+
If ``True``, include a ``"by_namespace"`` key with per-namespace
|
|
968
|
+
breakdowns. Default ``False``.
|
|
969
|
+
|
|
970
|
+
Returns
|
|
971
|
+
-------
|
|
972
|
+
dict
|
|
973
|
+
Dictionary with keys ``"flop_budget"``, ``"flops_used"``,
|
|
974
|
+
``"flops_remaining"``, ``"operations"``, ``"wall_time_s"``,
|
|
975
|
+
``"flopscope_backend_time_s"``, ``"flopscope_overhead_time_s"``,
|
|
976
|
+
``"residual_wall_time_s"``, and optionally ``"by_namespace"``.
|
|
977
|
+
|
|
978
|
+
Examples
|
|
979
|
+
--------
|
|
980
|
+
>>> import flopscope as flops
|
|
981
|
+
>>> import flopscope.numpy as fnp
|
|
982
|
+
>>> with flops.BudgetContext(flop_budget=100):
|
|
983
|
+
... _ = fnp.add(fnp.array([1.0]), fnp.array([2.0]))
|
|
984
|
+
>>> summary = flops.budget_summary_dict()
|
|
985
|
+
>>> sorted(summary)
|
|
986
|
+
['flop_budget', 'flops_remaining', 'flops_used', 'flopscope_backend_time_s', 'flopscope_overhead_time_s', 'operations', 'residual_wall_time_s', 'wall_time_s']
|
|
987
|
+
"""
|
|
988
|
+
acc_copy = BudgetAccumulator()
|
|
989
|
+
acc_copy._records = _snapshot_records()
|
|
990
|
+
return acc_copy.get_data(by_namespace=by_namespace)
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def budget_reset() -> None:
|
|
994
|
+
"""Clear accumulated session-wide budget data.
|
|
995
|
+
|
|
996
|
+
Parameters
|
|
997
|
+
----------
|
|
998
|
+
None
|
|
999
|
+
|
|
1000
|
+
Returns
|
|
1001
|
+
-------
|
|
1002
|
+
None
|
|
1003
|
+
Removes all recorded budget summaries and resets live baselines for
|
|
1004
|
+
active contexts.
|
|
1005
|
+
|
|
1006
|
+
Notes
|
|
1007
|
+
-----
|
|
1008
|
+
This is primarily useful in tests, notebooks, and long-lived processes
|
|
1009
|
+
where you want a fresh session-wide summary.
|
|
1010
|
+
|
|
1011
|
+
Examples
|
|
1012
|
+
--------
|
|
1013
|
+
>>> import flopscope as flops
|
|
1014
|
+
>>> import flopscope.numpy as fnp
|
|
1015
|
+
>>>
|
|
1016
|
+
>>> flops.budget_reset()
|
|
1017
|
+
>>> with flops.BudgetContext(flop_budget=100, quiet=True):
|
|
1018
|
+
... _ = fnp.add(fnp.array([1.0]), fnp.array([2.0]))
|
|
1019
|
+
>>> flops.budget_summary_dict()["flops_used"] > 0
|
|
1020
|
+
True
|
|
1021
|
+
>>> flops.budget_reset()
|
|
1022
|
+
>>> flops.budget_summary_dict()["flops_used"]
|
|
1023
|
+
0
|
|
1024
|
+
"""
|
|
1025
|
+
_accumulator.reset()
|
|
1026
|
+
for ctx in list(_all_budget_contexts):
|
|
1027
|
+
ctx._mark_reset_baseline()
|