flopscope 0.2.0__py3-none-any.whl

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