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/_ndarray.py
ADDED
|
@@ -0,0 +1,1126 @@
|
|
|
1
|
+
"""Subclass of ``numpy.ndarray`` that routes every operation through
|
|
2
|
+
flopscope's FLOP-counted ``me.*`` functions.
|
|
3
|
+
|
|
4
|
+
``FlopscopeArray`` overrides:
|
|
5
|
+
|
|
6
|
+
- **Arithmetic / comparison / bitwise / shift dunders** so ``a + b``,
|
|
7
|
+
``a @ b``, ``a == b``, ``a & b``, ``a << b`` etc. route through
|
|
8
|
+
``me.add``, ``me.matmul``, etc.
|
|
9
|
+
- **In-place dunders** (``__iadd__``, …, ``__imatmul__``) with a
|
|
10
|
+
symmetry-corruption guard that refuses ``A_sym += B`` when the
|
|
11
|
+
result would weaken or destroy ``A_sym``'s tagged symmetry. ``A @= B``
|
|
12
|
+
on shape-changing matmul falls back to CPython's documented
|
|
13
|
+
rebind-the-name semantics.
|
|
14
|
+
- **25 ndarray methods** (``sum``, ``mean``, ``dot``, ``argsort``,
|
|
15
|
+
``compress``, ``trace``, ``round``, ``clip``, ``ptp``, …) so
|
|
16
|
+
``a.sum()`` and ``fnp.sum(a)`` produce the same FLOP count.
|
|
17
|
+
- **In-place ``sort`` / ``partition``** which refuse on a
|
|
18
|
+
``SymmetricTensor`` (the reorder would break symmetry).
|
|
19
|
+
|
|
20
|
+
It also implements two NumPy protocols:
|
|
21
|
+
|
|
22
|
+
- ``__array_ufunc__`` (NEP 13) — ``np.add(a, b)``, ``np.add.reduce(a)``,
|
|
23
|
+
``np.add.outer(a, b)``, ``np.add.at(a, idx, val)``, multi-output
|
|
24
|
+
ufuncs (``np.divmod`` / ``np.frexp`` / ``np.modf``), etc.
|
|
25
|
+
- ``__array_function__`` (NEP 18) — explicit allowlist routing
|
|
26
|
+
``np.<func>(flopscope, …)`` for ~108 callables (reductions, sorting,
|
|
27
|
+
shape ops, linear algebra, comparisons, histograms, …) plus a
|
|
28
|
+
``_PASSTHROUGH`` set of zero-FLOP type/shape queries.
|
|
29
|
+
|
|
30
|
+
Recursion-prevention helpers (``_to_base_ndarray``,
|
|
31
|
+
``_to_base_ndarray_tree``) view-cast ``FlopscopeArray`` to plain
|
|
32
|
+
``np.ndarray`` for the inner NumPy call, breaking the cycle that
|
|
33
|
+
would otherwise re-dispatch through these protocols.
|
|
34
|
+
|
|
35
|
+
Because ``FlopscopeArray`` inherits from ``numpy.ndarray``,
|
|
36
|
+
``isinstance(x, numpy.ndarray)`` returns ``True``. All ``me.*``
|
|
37
|
+
functions return ``FlopscopeArray`` (or ``SymmetricTensor`` when symmetry
|
|
38
|
+
survives the operation). See PR #67 for the complete protocol design.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import functools as _functools
|
|
44
|
+
import inspect as _inspect
|
|
45
|
+
from typing import Any
|
|
46
|
+
|
|
47
|
+
import numpy as _np
|
|
48
|
+
from numpy.typing import DTypeLike
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _me():
|
|
52
|
+
"""Lazy import of flopscope.numpy to avoid circular imports.
|
|
53
|
+
|
|
54
|
+
The dunder methods need fnp.add, fnp.multiply etc. — these counted
|
|
55
|
+
numpy ops live under flopscope.numpy in the JAX-style public API
|
|
56
|
+
(top-level flopscope exposes only primitives like BudgetContext).
|
|
57
|
+
Defer the import until first use to break the import cycle:
|
|
58
|
+
flopscope.numpy → flopscope._free_ops/etc. → this module.
|
|
59
|
+
"""
|
|
60
|
+
import flopscope.numpy as _fnp
|
|
61
|
+
|
|
62
|
+
return _fnp
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Eagerly captured at import time so ``tests/numpy_compat`` monkeypatching
|
|
66
|
+
# (which replaces ``np.<name>`` with ``fnp.<name>``) doesn't accidentally
|
|
67
|
+
# install the flopscope-replacements into ``_PASSTHROUGH``. The set holds the
|
|
68
|
+
# *original* numpy callables.
|
|
69
|
+
_PASSTHROUGH_NAMES = (
|
|
70
|
+
# Zero-FLOP type/shape queries:
|
|
71
|
+
"ndim",
|
|
72
|
+
"shape",
|
|
73
|
+
"size",
|
|
74
|
+
# Zero-FLOP type-system queries (added by Task 4 for #62/#58 followup):
|
|
75
|
+
"result_type",
|
|
76
|
+
"can_cast",
|
|
77
|
+
"min_scalar_type",
|
|
78
|
+
"promote_types",
|
|
79
|
+
"find_common_type",
|
|
80
|
+
"mintypecode",
|
|
81
|
+
# Test-harness assertion that should not count FLOPs:
|
|
82
|
+
"array_equal",
|
|
83
|
+
# Zero-FLOP memory-layout queries (#72):
|
|
84
|
+
"may_share_memory",
|
|
85
|
+
"shares_memory",
|
|
86
|
+
"byte_bounds",
|
|
87
|
+
# Zero-FLOP boolean predicates (#72 audit):
|
|
88
|
+
"iscomplexobj",
|
|
89
|
+
"isrealobj",
|
|
90
|
+
"isfortran",
|
|
91
|
+
"isscalar",
|
|
92
|
+
# Zero-FLOP shape arithmetic (#72 audit):
|
|
93
|
+
"broadcast_shapes",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _build_passthrough():
|
|
98
|
+
"""Build the ``_PASSTHROUGH`` set, eagerly at import time."""
|
|
99
|
+
s = set()
|
|
100
|
+
for name in _PASSTHROUGH_NAMES:
|
|
101
|
+
fn = getattr(_np, name, None)
|
|
102
|
+
if fn is not None:
|
|
103
|
+
s.add(fn)
|
|
104
|
+
return s
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
_INITIAL_PASSTHROUGH = _build_passthrough()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class FlopscopeArray(_np.ndarray):
|
|
111
|
+
"""A numpy ndarray subclass with FLOP-tracked operators.
|
|
112
|
+
|
|
113
|
+
Behaves exactly like numpy.ndarray except that arithmetic and
|
|
114
|
+
related operators route through flopscope's counted fnp.* functions
|
|
115
|
+
so the active BudgetContext sees them.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __new__(
|
|
119
|
+
cls, shape, dtype=float, buffer=None, offset=0, strides=None, order=None
|
|
120
|
+
):
|
|
121
|
+
return super().__new__(cls, shape, dtype, buffer, offset, strides, order)
|
|
122
|
+
|
|
123
|
+
def __array_finalize__(self, obj):
|
|
124
|
+
# Called when numpy creates a view or slice of this subclass.
|
|
125
|
+
# No subclass state to propagate.
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
def __array_wrap__(self, out_arr, context=None, return_scalar=False):
|
|
129
|
+
"""Honor numpy's `return_scalar` request from ufunc reductions.
|
|
130
|
+
|
|
131
|
+
When a ufunc reduction collapses to a single value (e.g.
|
|
132
|
+
``np.bitwise_or.reduce(np.array([True], dtype=object))``), numpy
|
|
133
|
+
passes ``return_scalar=True`` to ``__array_wrap__`` so the caller
|
|
134
|
+
receives a Python scalar rather than a 0-d ndarray. The default
|
|
135
|
+
ndarray behaviour respects this flag; we forward it explicitly so
|
|
136
|
+
the same behaviour holds when the input is a FlopscopeArray.
|
|
137
|
+
|
|
138
|
+
For non-scalar results we let numpy preserve the subclass (the
|
|
139
|
+
default behaviour) so views, slices, and ufunc outputs stay
|
|
140
|
+
FlopscopeArrays — keeping operator overloads and FLOP tracking
|
|
141
|
+
intact for chained expressions.
|
|
142
|
+
|
|
143
|
+
Flopscope does not guarantee ndarray flag fidelity for subclass
|
|
144
|
+
results. In particular, view-casting a fresh ufunc result into a
|
|
145
|
+
subclass often reports ``OWNDATA=False`` because the subclass is a
|
|
146
|
+
view over the ufunc's base ndarray. We intentionally keep that
|
|
147
|
+
no-copy behaviour because ndarray-subclass operations are on a hot
|
|
148
|
+
path and avoiding extra copies is a higher priority than exact
|
|
149
|
+
flag parity with bare ndarrays.
|
|
150
|
+
"""
|
|
151
|
+
if return_scalar:
|
|
152
|
+
return out_arr[()]
|
|
153
|
+
return super().__array_wrap__(out_arr, context, return_scalar)
|
|
154
|
+
|
|
155
|
+
# ----- numpy ufunc protocol (NEP 13) -----
|
|
156
|
+
|
|
157
|
+
_REDUCE_TO_WHEST = {
|
|
158
|
+
"add": "sum",
|
|
159
|
+
"multiply": "prod",
|
|
160
|
+
"maximum": "max",
|
|
161
|
+
"minimum": "min",
|
|
162
|
+
"logical_and": "all",
|
|
163
|
+
"logical_or": "any",
|
|
164
|
+
}
|
|
165
|
+
_ACCUMULATE_TO_WHEST = {
|
|
166
|
+
"add": "cumsum",
|
|
167
|
+
"multiply": "cumprod",
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
def __array_ufunc__(self, ufunc, method, *inputs, out=None, **kwargs):
|
|
171
|
+
"""Route ufunc calls through flopscope's counted functions.
|
|
172
|
+
|
|
173
|
+
Triggered for:
|
|
174
|
+
- ``np.add(flopscope, x)`` → method='__call__'
|
|
175
|
+
- ``np.add.reduce(flopscope)`` → method='reduce'
|
|
176
|
+
- ``np.add.accumulate(flopscope)`` → method='accumulate'
|
|
177
|
+
- ``np.add.outer(flopscope, w2)`` → method='outer'
|
|
178
|
+
- ``np.add.reduceat(flopscope, ix)`` → method='reduceat'
|
|
179
|
+
- ``np.add.at(flopscope, ix, val)`` → method='at'
|
|
180
|
+
|
|
181
|
+
``out`` is passed by NumPy as a tuple of length ``ufunc.nout``.
|
|
182
|
+
For single-output ufuncs we unwrap the 1-tuple to ``out=arr``;
|
|
183
|
+
for multi-output ufuncs (``divmod``, ``frexp``, ``modf``) we
|
|
184
|
+
forward the tuple as-is to the corresponding ``fnp.*`` wrapper,
|
|
185
|
+
which knows how to handle per-slot stripping and identity.
|
|
186
|
+
|
|
187
|
+
``reduce`` / ``accumulate`` first try the optimized routing in
|
|
188
|
+
:attr:`_REDUCE_TO_WHEST` / :attr:`_ACCUMULATE_TO_WHEST`; on a
|
|
189
|
+
miss (e.g. ``np.subtract.reduce``) they fall back to a generic
|
|
190
|
+
path in :mod:`flopscope._pointwise` that strips, charges
|
|
191
|
+
:func:`reduction_cost`, and routes back through the raw
|
|
192
|
+
``ufunc.<method>``. ``outer`` / ``reduceat`` / ``at`` always
|
|
193
|
+
use the generic path. ``ufunc.at`` refuses on SymmetricTensor
|
|
194
|
+
operands (the in-place fancy-index write would break symmetry).
|
|
195
|
+
"""
|
|
196
|
+
from flopscope._budget import _called_from_wrapper
|
|
197
|
+
|
|
198
|
+
if _called_from_wrapper():
|
|
199
|
+
raise RuntimeError(
|
|
200
|
+
f"WhestArray reached numpy.{ufunc.__name__} from inside an fnp "
|
|
201
|
+
f"wrapper — missing _to_base_ndarray() strip. Check the "
|
|
202
|
+
f"calling fnp wrapper and add a strip before the numpy call."
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Emit one-time auto-route warning (de-duped per call site).
|
|
206
|
+
import warnings as _warnings
|
|
207
|
+
|
|
208
|
+
_warnings.warn(
|
|
209
|
+
f"np.{ufunc.__name__}(WhestArray) auto-routed to fnp.{ufunc.__name__}; "
|
|
210
|
+
f"call fnp.{ufunc.__name__} directly to avoid this warning.",
|
|
211
|
+
UserWarning,
|
|
212
|
+
stacklevel=2,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
me = _me()
|
|
216
|
+
|
|
217
|
+
np_target_name = None # used to drive _filter_to_np_signature below
|
|
218
|
+
whest_fn = None
|
|
219
|
+
if method == "__call__":
|
|
220
|
+
whest_fn = getattr(me, ufunc.__name__, None)
|
|
221
|
+
np_target_name = ufunc.__name__
|
|
222
|
+
elif method == "reduce":
|
|
223
|
+
target = self._REDUCE_TO_WHEST.get(ufunc.__name__)
|
|
224
|
+
if target is not None:
|
|
225
|
+
whest_fn = getattr(me, target, None)
|
|
226
|
+
np_target_name = target
|
|
227
|
+
# NumPy's ufunc.reduce defaults to axis=0; flopscope's me.sum etc.
|
|
228
|
+
# default to axis=None (full reduction). Force NumPy default.
|
|
229
|
+
kwargs.setdefault("axis", 0)
|
|
230
|
+
elif method == "accumulate":
|
|
231
|
+
target = self._ACCUMULATE_TO_WHEST.get(ufunc.__name__)
|
|
232
|
+
if target is not None:
|
|
233
|
+
whest_fn = getattr(me, target, None)
|
|
234
|
+
np_target_name = target
|
|
235
|
+
kwargs.setdefault("axis", 0)
|
|
236
|
+
|
|
237
|
+
if whest_fn is not None:
|
|
238
|
+
# Optimized routing-table path: forward to the dedicated
|
|
239
|
+
# ``fnp.*`` wrapper.
|
|
240
|
+
#
|
|
241
|
+
# Unwrap single-output ``out`` tuple.
|
|
242
|
+
if out is not None:
|
|
243
|
+
if isinstance(out, tuple) and len(out) == 1:
|
|
244
|
+
kwargs["out"] = out[0]
|
|
245
|
+
else:
|
|
246
|
+
kwargs["out"] = out
|
|
247
|
+
# Filter kwargs against the target NumPy callable's signature so
|
|
248
|
+
# ufunc-internal kwargs (e.g. ``dtype=`` from np.all → np.add.reduce)
|
|
249
|
+
# don't reach a function-form flopscope wrapper that doesn't accept
|
|
250
|
+
# them.
|
|
251
|
+
if np_target_name is not None:
|
|
252
|
+
kwargs = _filter_to_np_signature(
|
|
253
|
+
getattr(_np, np_target_name, None), kwargs
|
|
254
|
+
)
|
|
255
|
+
return whest_fn(*inputs, **kwargs)
|
|
256
|
+
|
|
257
|
+
# Generic ufunc-method paths for ops without a dedicated flopscope
|
|
258
|
+
# equivalent. Lazy-imported to avoid the _ndarray ↔ _pointwise
|
|
259
|
+
# circular-dependency loop.
|
|
260
|
+
if method in ("outer", "reduce", "accumulate", "reduceat", "at"):
|
|
261
|
+
from flopscope._pointwise import (
|
|
262
|
+
_counted_ufunc_accumulate_generic,
|
|
263
|
+
_counted_ufunc_at,
|
|
264
|
+
_counted_ufunc_outer,
|
|
265
|
+
_counted_ufunc_reduce_generic,
|
|
266
|
+
_counted_ufunc_reduceat,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Unwrap single-output ``out`` tuple for the generic paths
|
|
270
|
+
# too. ``ufunc.at`` doesn't take ``out``.
|
|
271
|
+
out_for_generic = out
|
|
272
|
+
if out is not None and isinstance(out, tuple) and len(out) == 1:
|
|
273
|
+
out_for_generic = out[0]
|
|
274
|
+
|
|
275
|
+
if method == "outer":
|
|
276
|
+
return _counted_ufunc_outer(
|
|
277
|
+
ufunc, *inputs, out=out_for_generic, **kwargs
|
|
278
|
+
)
|
|
279
|
+
if method == "reduce":
|
|
280
|
+
kwargs.setdefault("axis", 0)
|
|
281
|
+
return _counted_ufunc_reduce_generic(
|
|
282
|
+
ufunc, *inputs, out=out_for_generic, **kwargs
|
|
283
|
+
)
|
|
284
|
+
if method == "accumulate":
|
|
285
|
+
kwargs.setdefault("axis", 0)
|
|
286
|
+
return _counted_ufunc_accumulate_generic(
|
|
287
|
+
ufunc, *inputs, out=out_for_generic, **kwargs
|
|
288
|
+
)
|
|
289
|
+
if method == "reduceat":
|
|
290
|
+
return _counted_ufunc_reduceat(
|
|
291
|
+
ufunc, *inputs, out=out_for_generic, **kwargs
|
|
292
|
+
)
|
|
293
|
+
if method == "at":
|
|
294
|
+
# ``ufunc.at`` does not accept ``out=``.
|
|
295
|
+
return _counted_ufunc_at(ufunc, *inputs, **kwargs)
|
|
296
|
+
|
|
297
|
+
return NotImplemented
|
|
298
|
+
|
|
299
|
+
# ----- numpy array-function protocol (NEP 18) -----
|
|
300
|
+
|
|
301
|
+
# Lazy-built dispatch map. Populated on first __array_function__ call
|
|
302
|
+
# because flopscope's namespace uses lazy submodule loading.
|
|
303
|
+
_ARRAY_FUNCTION_DISPATCH: dict | None = None
|
|
304
|
+
_PASSTHROUGH: set | None = None
|
|
305
|
+
|
|
306
|
+
@classmethod
|
|
307
|
+
def _get_array_function_dispatch(cls):
|
|
308
|
+
"""Build (once) and return the np-callable → flopscope-callable map."""
|
|
309
|
+
if cls._ARRAY_FUNCTION_DISPATCH is not None:
|
|
310
|
+
return cls._ARRAY_FUNCTION_DISPATCH
|
|
311
|
+
|
|
312
|
+
me = _me()
|
|
313
|
+
d: dict = {}
|
|
314
|
+
|
|
315
|
+
def _bind(np_attr_path, we_attr_path):
|
|
316
|
+
"""Look up np.<path> and me.<path>, add to dispatch map.
|
|
317
|
+
|
|
318
|
+
Silently skip pairs where one side is missing (e.g. linalg
|
|
319
|
+
functions added in newer NumPy versions).
|
|
320
|
+
"""
|
|
321
|
+
np_obj = _np
|
|
322
|
+
for part in np_attr_path.split("."):
|
|
323
|
+
np_obj = getattr(np_obj, part, None)
|
|
324
|
+
if np_obj is None:
|
|
325
|
+
return
|
|
326
|
+
we_obj = me
|
|
327
|
+
for part in we_attr_path.split("."):
|
|
328
|
+
we_obj = getattr(we_obj, part, None)
|
|
329
|
+
if we_obj is None:
|
|
330
|
+
return
|
|
331
|
+
if callable(np_obj) and callable(we_obj):
|
|
332
|
+
d[np_obj] = we_obj
|
|
333
|
+
|
|
334
|
+
# ----- Reductions -----
|
|
335
|
+
for name in (
|
|
336
|
+
"sum",
|
|
337
|
+
"prod",
|
|
338
|
+
"mean",
|
|
339
|
+
"min",
|
|
340
|
+
"max",
|
|
341
|
+
"std",
|
|
342
|
+
"var",
|
|
343
|
+
"all",
|
|
344
|
+
"any",
|
|
345
|
+
"cumsum",
|
|
346
|
+
"cumprod",
|
|
347
|
+
"argmin",
|
|
348
|
+
"argmax",
|
|
349
|
+
"ptp",
|
|
350
|
+
"median",
|
|
351
|
+
"average",
|
|
352
|
+
"percentile",
|
|
353
|
+
"quantile",
|
|
354
|
+
# NumPy 2.x retained ``amax``/``amin`` as exported aliases
|
|
355
|
+
# for ``max``/``min``. Bind them so ``np.amax(FlopscopeArray)``
|
|
356
|
+
# routes through flopscope instead of raising TypeError.
|
|
357
|
+
"amax",
|
|
358
|
+
"amin",
|
|
359
|
+
):
|
|
360
|
+
_bind(name, name)
|
|
361
|
+
|
|
362
|
+
# ----- Sorting / selection -----
|
|
363
|
+
for name in (
|
|
364
|
+
"sort",
|
|
365
|
+
"argsort",
|
|
366
|
+
"lexsort",
|
|
367
|
+
"partition",
|
|
368
|
+
"argpartition",
|
|
369
|
+
"searchsorted",
|
|
370
|
+
"digitize",
|
|
371
|
+
):
|
|
372
|
+
_bind(name, name)
|
|
373
|
+
|
|
374
|
+
# ----- Set / unique -----
|
|
375
|
+
for name in (
|
|
376
|
+
"unique",
|
|
377
|
+
"unique_all",
|
|
378
|
+
"unique_counts",
|
|
379
|
+
"unique_inverse",
|
|
380
|
+
"unique_values",
|
|
381
|
+
"in1d",
|
|
382
|
+
"isin",
|
|
383
|
+
"intersect1d",
|
|
384
|
+
"union1d",
|
|
385
|
+
"setdiff1d",
|
|
386
|
+
"setxor1d",
|
|
387
|
+
):
|
|
388
|
+
_bind(name, name)
|
|
389
|
+
|
|
390
|
+
# ----- Free / structural (asarray excluded) -----
|
|
391
|
+
for name in (
|
|
392
|
+
"where",
|
|
393
|
+
"tile",
|
|
394
|
+
"repeat",
|
|
395
|
+
"flip",
|
|
396
|
+
"roll",
|
|
397
|
+
"pad",
|
|
398
|
+
"triu",
|
|
399
|
+
"tril",
|
|
400
|
+
"diagonal",
|
|
401
|
+
"broadcast_to",
|
|
402
|
+
"meshgrid",
|
|
403
|
+
"copy",
|
|
404
|
+
"astype",
|
|
405
|
+
"trace",
|
|
406
|
+
"diff",
|
|
407
|
+
"gradient",
|
|
408
|
+
"clip",
|
|
409
|
+
"round",
|
|
410
|
+
):
|
|
411
|
+
_bind(name, name)
|
|
412
|
+
|
|
413
|
+
# ----- Shape / view ops -----
|
|
414
|
+
# The flopscope counterparts (``me.transpose``, ``me.swapaxes``,
|
|
415
|
+
# ``me.moveaxis``, etc.) handle ``SymmetricTensor`` axis
|
|
416
|
+
# remapping correctly via ``wrap_with_symmetry`` /
|
|
417
|
+
# ``remap_group_axes``. Routing through the allowlist preserves
|
|
418
|
+
# symmetry; PASSTHROUGH would silently strip it via
|
|
419
|
+
# ``_to_base_ndarray_tree`` before the raw NumPy call.
|
|
420
|
+
for name in (
|
|
421
|
+
"transpose",
|
|
422
|
+
"swapaxes",
|
|
423
|
+
"moveaxis",
|
|
424
|
+
"reshape",
|
|
425
|
+
"ravel",
|
|
426
|
+
"expand_dims",
|
|
427
|
+
"squeeze",
|
|
428
|
+
"concatenate",
|
|
429
|
+
"stack",
|
|
430
|
+
"vstack",
|
|
431
|
+
"hstack",
|
|
432
|
+
"column_stack",
|
|
433
|
+
"split",
|
|
434
|
+
"hsplit",
|
|
435
|
+
"vsplit",
|
|
436
|
+
"dsplit",
|
|
437
|
+
"atleast_1d",
|
|
438
|
+
"atleast_2d",
|
|
439
|
+
"atleast_3d",
|
|
440
|
+
"broadcast_to",
|
|
441
|
+
"matrix_transpose", # numpy 2.x ufunc-like
|
|
442
|
+
):
|
|
443
|
+
_bind(name, name)
|
|
444
|
+
|
|
445
|
+
# ----- Linear algebra -----
|
|
446
|
+
for name in (
|
|
447
|
+
"dot",
|
|
448
|
+
"matmul",
|
|
449
|
+
"einsum",
|
|
450
|
+
"tensordot",
|
|
451
|
+
"inner",
|
|
452
|
+
"outer",
|
|
453
|
+
"cross",
|
|
454
|
+
):
|
|
455
|
+
_bind(name, name)
|
|
456
|
+
|
|
457
|
+
# ----- Comparisons -----
|
|
458
|
+
for name in (
|
|
459
|
+
"allclose",
|
|
460
|
+
"isclose",
|
|
461
|
+
"array_equiv",
|
|
462
|
+
# NOTE: array_equal is in _PASSTHROUGH instead.
|
|
463
|
+
):
|
|
464
|
+
_bind(name, name)
|
|
465
|
+
|
|
466
|
+
# ----- Histograms / counts -----
|
|
467
|
+
for name in (
|
|
468
|
+
"histogram",
|
|
469
|
+
"histogram2d",
|
|
470
|
+
"histogramdd",
|
|
471
|
+
"histogram_bin_edges",
|
|
472
|
+
"bincount",
|
|
473
|
+
"vander",
|
|
474
|
+
"apply_over_axes",
|
|
475
|
+
"piecewise",
|
|
476
|
+
):
|
|
477
|
+
_bind(name, name)
|
|
478
|
+
|
|
479
|
+
# ----- linalg submodule -----
|
|
480
|
+
for name in (
|
|
481
|
+
"norm",
|
|
482
|
+
"solve",
|
|
483
|
+
"det",
|
|
484
|
+
"inv",
|
|
485
|
+
"pinv",
|
|
486
|
+
"eig",
|
|
487
|
+
"eigh",
|
|
488
|
+
"eigvals",
|
|
489
|
+
"eigvalsh",
|
|
490
|
+
"svd",
|
|
491
|
+
"qr",
|
|
492
|
+
"cholesky",
|
|
493
|
+
"matrix_rank",
|
|
494
|
+
"lstsq",
|
|
495
|
+
"multi_dot",
|
|
496
|
+
"matrix_power",
|
|
497
|
+
"slogdet",
|
|
498
|
+
):
|
|
499
|
+
_bind(f"linalg.{name}", f"linalg.{name}")
|
|
500
|
+
|
|
501
|
+
cls._ARRAY_FUNCTION_DISPATCH = d
|
|
502
|
+
return d
|
|
503
|
+
|
|
504
|
+
@classmethod
|
|
505
|
+
def _get_passthrough(cls):
|
|
506
|
+
"""Return the eagerly-captured passthrough set."""
|
|
507
|
+
if cls._PASSTHROUGH is not None:
|
|
508
|
+
return cls._PASSTHROUGH
|
|
509
|
+
cls._PASSTHROUGH = set(_INITIAL_PASSTHROUGH)
|
|
510
|
+
return cls._PASSTHROUGH
|
|
511
|
+
|
|
512
|
+
def __array_function__(self, func, types, args, kwargs):
|
|
513
|
+
"""Route ``np.<func>(flopscope, ...)`` calls through flopscope.
|
|
514
|
+
|
|
515
|
+
Two distinct paths:
|
|
516
|
+
|
|
517
|
+
- **Inside an fnp wrapper (depth > 0):** A wrapper forgot to strip a
|
|
518
|
+
``FlopscopeArray`` before calling raw numpy, and numpy's NEP 18
|
|
519
|
+
dispatch caught us. This is a flopscope bug (the polyval class from
|
|
520
|
+
issue #69). Raise ``RuntimeError`` at the leak site so the bug is
|
|
521
|
+
impossible to miss. PASSTHROUGH (zero-FLOP queries like np.shape,
|
|
522
|
+
np.ndim, np.size) is exempt — these are safe even inside a wrapper
|
|
523
|
+
and must not trigger the tripwire.
|
|
524
|
+
|
|
525
|
+
- **Top-level call (depth == 0):** A user wrote ``np.<func>(whest)``
|
|
526
|
+
directly. PASSTHROUGH set is checked first for zero-FLOP queries.
|
|
527
|
+
Otherwise we route through the allowlist to ``fnp.<func>`` AND emit
|
|
528
|
+
a ``UserWarning`` so the user knows to call ``fnp.<func>`` directly.
|
|
529
|
+
Warning is de-duped per call site by Python's ``warnings`` module.
|
|
530
|
+
"""
|
|
531
|
+
# PASSTHROUGH first: zero-FLOP queries are always safe, even inside
|
|
532
|
+
# wrappers. Reordering: putting this BEFORE the tripwire avoids false
|
|
533
|
+
# positives when a wrapper queries np.shape(a)/np.ndim(a) on a
|
|
534
|
+
# still-FlopscopeArray input.
|
|
535
|
+
if func in self._get_passthrough():
|
|
536
|
+
stripped_args = _to_base_ndarray_tree(args)
|
|
537
|
+
stripped_kwargs = {k: _to_base_ndarray_tree(v) for k, v in kwargs.items()}
|
|
538
|
+
return func(*stripped_args, **stripped_kwargs)
|
|
539
|
+
|
|
540
|
+
from flopscope._budget import _called_from_wrapper
|
|
541
|
+
|
|
542
|
+
if _called_from_wrapper():
|
|
543
|
+
raise RuntimeError(
|
|
544
|
+
f"WhestArray reached numpy.{func.__name__} from inside an fnp "
|
|
545
|
+
f"wrapper — missing _to_base_ndarray() strip. Check the "
|
|
546
|
+
f"calling fnp wrapper and add a strip before the numpy call."
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
dispatch = self._get_array_function_dispatch()
|
|
550
|
+
we_func = dispatch.get(func)
|
|
551
|
+
if we_func is None:
|
|
552
|
+
return NotImplemented
|
|
553
|
+
|
|
554
|
+
import warnings as _warnings
|
|
555
|
+
|
|
556
|
+
_warnings.warn(
|
|
557
|
+
f"np.{func.__name__}(WhestArray) auto-routed to fnp.{func.__name__}; "
|
|
558
|
+
f"call fnp.{func.__name__} directly to avoid this warning.",
|
|
559
|
+
UserWarning,
|
|
560
|
+
stacklevel=2,
|
|
561
|
+
)
|
|
562
|
+
return we_func(*args, **kwargs)
|
|
563
|
+
|
|
564
|
+
# ----- ndarray method overrides (route through me.* for budget parity) -----
|
|
565
|
+
# We forward *args, **kwargs to be forward-compatible with NumPy's
|
|
566
|
+
# evolving method signatures (dtype, out, where, casting, keepdims, axis
|
|
567
|
+
# as positional or keyword).
|
|
568
|
+
|
|
569
|
+
def sum(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
|
|
570
|
+
return _me().sum(self, *args, **kwargs)
|
|
571
|
+
|
|
572
|
+
def mean(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
|
|
573
|
+
return _me().mean(self, *args, **kwargs)
|
|
574
|
+
|
|
575
|
+
def prod(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
|
|
576
|
+
return _me().prod(self, *args, **kwargs)
|
|
577
|
+
|
|
578
|
+
def min(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
|
|
579
|
+
return _me().min(self, *args, **kwargs)
|
|
580
|
+
|
|
581
|
+
def max(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
|
|
582
|
+
return _me().max(self, *args, **kwargs)
|
|
583
|
+
|
|
584
|
+
def std(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
|
|
585
|
+
return _me().std(self, *args, **kwargs)
|
|
586
|
+
|
|
587
|
+
def var(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
|
|
588
|
+
return _me().var(self, *args, **kwargs)
|
|
589
|
+
|
|
590
|
+
def all(self, *args: Any, **kwargs: Any) -> FlopscopeArray: # type: ignore[override]
|
|
591
|
+
return _me().all(self, *args, **kwargs)
|
|
592
|
+
|
|
593
|
+
def any(self, *args: Any, **kwargs: Any) -> FlopscopeArray: # type: ignore[override]
|
|
594
|
+
return _me().any(self, *args, **kwargs)
|
|
595
|
+
|
|
596
|
+
def cumsum(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
|
|
597
|
+
return _me().cumsum(self, *args, **kwargs)
|
|
598
|
+
|
|
599
|
+
def cumprod(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
|
|
600
|
+
return _me().cumprod(self, *args, **kwargs)
|
|
601
|
+
|
|
602
|
+
def argmin(self, *args: Any, **kwargs: Any) -> FlopscopeArray: # type: ignore[override]
|
|
603
|
+
return _me().argmin(self, *args, **kwargs)
|
|
604
|
+
|
|
605
|
+
def argmax(self, *args: Any, **kwargs: Any) -> FlopscopeArray: # type: ignore[override]
|
|
606
|
+
return _me().argmax(self, *args, **kwargs)
|
|
607
|
+
|
|
608
|
+
def ptp(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
|
|
609
|
+
return _me().ptp(self, *args, **kwargs)
|
|
610
|
+
|
|
611
|
+
def trace(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
|
|
612
|
+
return _me().trace(self, *args, **kwargs)
|
|
613
|
+
|
|
614
|
+
def round(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
|
|
615
|
+
return _me().round(self, *args, **kwargs)
|
|
616
|
+
|
|
617
|
+
def clip(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
|
|
618
|
+
return _me().clip(self, *args, **kwargs)
|
|
619
|
+
|
|
620
|
+
def astype( # type: ignore[override]
|
|
621
|
+
self,
|
|
622
|
+
dtype: DTypeLike,
|
|
623
|
+
order: Any = "K",
|
|
624
|
+
casting: Any = "unsafe",
|
|
625
|
+
subok: bool = True,
|
|
626
|
+
copy: bool = True,
|
|
627
|
+
) -> FlopscopeArray:
|
|
628
|
+
return _np.ndarray.astype(
|
|
629
|
+
self, dtype, order=order, casting=casting, subok=subok, copy=copy
|
|
630
|
+
) # type: ignore[return-value]
|
|
631
|
+
|
|
632
|
+
# ----- Other ndarray methods -----
|
|
633
|
+
|
|
634
|
+
def dot(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
|
|
635
|
+
return _me().dot(self, *args, **kwargs)
|
|
636
|
+
|
|
637
|
+
def conj(self) -> FlopscopeArray:
|
|
638
|
+
return _me().conjugate(self)
|
|
639
|
+
|
|
640
|
+
def conjugate(self) -> FlopscopeArray:
|
|
641
|
+
return _me().conjugate(self)
|
|
642
|
+
|
|
643
|
+
def argsort(self, *args: Any, **kwargs: Any) -> FlopscopeArray:
|
|
644
|
+
return _me().argsort(self, *args, **kwargs)
|
|
645
|
+
|
|
646
|
+
def argpartition(self, kth: Any, *args: Any, **kwargs: Any) -> FlopscopeArray:
|
|
647
|
+
return _me().argpartition(self, kth, *args, **kwargs)
|
|
648
|
+
|
|
649
|
+
def take(self, indices: Any, *args: Any, **kwargs: Any) -> FlopscopeArray: # type: ignore[override]
|
|
650
|
+
return _me().take(self, indices, *args, **kwargs)
|
|
651
|
+
|
|
652
|
+
def repeat(self, repeats: Any, *args: Any, **kwargs: Any) -> FlopscopeArray:
|
|
653
|
+
return _me().repeat(self, repeats, *args, **kwargs)
|
|
654
|
+
|
|
655
|
+
def searchsorted(self, v: Any, *args: Any, **kwargs: Any) -> FlopscopeArray: # type: ignore[override]
|
|
656
|
+
return _me().searchsorted(self, v, *args, **kwargs)
|
|
657
|
+
|
|
658
|
+
def compress(self, condition: Any, *args: Any, **kwargs: Any) -> FlopscopeArray:
|
|
659
|
+
# ndarray.compress(condition) -> np.compress(condition, arr)
|
|
660
|
+
return _me().compress(condition, self, *args, **kwargs)
|
|
661
|
+
|
|
662
|
+
# In-place sort/partition: NumPy mutates self and returns None.
|
|
663
|
+
# Charge FLOPs through me.sort/partition, then copy result into self.
|
|
664
|
+
# Guard against in-place mutation that would silently break symmetry.
|
|
665
|
+
|
|
666
|
+
def _check_inplace_breaks_symmetry(self, op_name):
|
|
667
|
+
"""Refuse in-place ops that would invalidate SymmetricTensor metadata.
|
|
668
|
+
|
|
669
|
+
``self._symmetry`` is set by SymmetricTensor; plain FlopscopeArrays
|
|
670
|
+
do not have it (or it's None). Guarding via getattr keeps this
|
|
671
|
+
method valid on both subclasses without a forward reference.
|
|
672
|
+
"""
|
|
673
|
+
sym = getattr(self, "_symmetry", None)
|
|
674
|
+
if sym is not None:
|
|
675
|
+
raise ValueError(
|
|
676
|
+
f"in-place {op_name} on a SymmetricTensor would break "
|
|
677
|
+
f"symmetry on axes {sym.axes}; call fnp.{op_name}(arr) for "
|
|
678
|
+
f"an unsymmetric copy instead."
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
def sort(self, *args: Any, **kwargs: Any) -> None:
|
|
682
|
+
self._check_inplace_breaks_symmetry("sort")
|
|
683
|
+
result = _me().sort(self, *args, **kwargs)
|
|
684
|
+
# Strip both self and result before np.copyto: keeps the
|
|
685
|
+
# invariant ("never pass a flopscope subclass to a raw NumPy call")
|
|
686
|
+
# explicit even though np.copyto is currently NOT in the
|
|
687
|
+
# __array_function__ allowlist.
|
|
688
|
+
_np.copyto(_to_base_ndarray(self), _to_base_ndarray(result))
|
|
689
|
+
|
|
690
|
+
def partition(self, kth: Any, *args: Any, **kwargs: Any) -> None:
|
|
691
|
+
self._check_inplace_breaks_symmetry("partition")
|
|
692
|
+
result = _me().partition(self, kth, *args, **kwargs)
|
|
693
|
+
_np.copyto(_to_base_ndarray(self), _to_base_ndarray(result))
|
|
694
|
+
|
|
695
|
+
def _inplace_from_result(self, result, op_name):
|
|
696
|
+
"""Apply ``result`` into ``self`` in place; refuse if the
|
|
697
|
+
operation would destroy or weaken symmetry metadata.
|
|
698
|
+
|
|
699
|
+
Compare ``self.symmetry`` and ``result.symmetry`` directly via
|
|
700
|
+
``SymmetryGroup.__eq__`` (PR #51 made these value-equal with an
|
|
701
|
+
identity short-circuit and per-instance canonical-action cache).
|
|
702
|
+
Scalar in-place ops (``a += 1.0``) keep every group identically,
|
|
703
|
+
so the comparison passes via the identity short-circuit and the
|
|
704
|
+
copy proceeds cleanly.
|
|
705
|
+
|
|
706
|
+
``_to_base_ndarray(self)`` is required around ``np.copyto``
|
|
707
|
+
because ``np.copyto`` could otherwise dispatch via
|
|
708
|
+
``__array_function__`` if it ever lands in the allowlist --
|
|
709
|
+
without the strip, the call would recurse back through flopscope.
|
|
710
|
+
"""
|
|
711
|
+
self_sym = getattr(self, "_symmetry", None)
|
|
712
|
+
result_sym = getattr(result, "_symmetry", None)
|
|
713
|
+
if self_sym is not None:
|
|
714
|
+
if self_sym != result_sym:
|
|
715
|
+
raise ValueError(
|
|
716
|
+
f"in-place {op_name} would destroy or weaken symmetry "
|
|
717
|
+
f"metadata on axes {self_sym.axes} (result symmetry: "
|
|
718
|
+
f"{result_sym.axes if result_sym is not None else None}); "
|
|
719
|
+
f"use ``self = fnp.{op_name}(self, other)`` to accept the "
|
|
720
|
+
f"new result explicitly."
|
|
721
|
+
)
|
|
722
|
+
_np.copyto(_to_base_ndarray(self), _to_base_ndarray(result))
|
|
723
|
+
return self
|
|
724
|
+
|
|
725
|
+
# ----- Binary arithmetic -----
|
|
726
|
+
|
|
727
|
+
def __add__(self, other: Any) -> FlopscopeArray:
|
|
728
|
+
return _me().add(self, other)
|
|
729
|
+
|
|
730
|
+
def __radd__(self, other: Any) -> FlopscopeArray:
|
|
731
|
+
return _me().add(other, self)
|
|
732
|
+
|
|
733
|
+
def __iadd__(self, other: Any) -> FlopscopeArray:
|
|
734
|
+
result = _me().add(self, other)
|
|
735
|
+
return self._inplace_from_result(result, "add")
|
|
736
|
+
|
|
737
|
+
def __sub__(self, other: Any) -> FlopscopeArray: # type: ignore[override]
|
|
738
|
+
return _me().subtract(self, other)
|
|
739
|
+
|
|
740
|
+
def __rsub__(self, other: Any) -> FlopscopeArray: # type: ignore[override]
|
|
741
|
+
return _me().subtract(other, self)
|
|
742
|
+
|
|
743
|
+
def __isub__(self, other: Any) -> FlopscopeArray:
|
|
744
|
+
result = _me().subtract(self, other)
|
|
745
|
+
return self._inplace_from_result(result, "subtract")
|
|
746
|
+
|
|
747
|
+
def __mul__(self, other: Any) -> FlopscopeArray:
|
|
748
|
+
return _me().multiply(self, other)
|
|
749
|
+
|
|
750
|
+
def __rmul__(self, other: Any) -> FlopscopeArray:
|
|
751
|
+
return _me().multiply(other, self)
|
|
752
|
+
|
|
753
|
+
def __imul__(self, other: Any) -> FlopscopeArray:
|
|
754
|
+
result = _me().multiply(self, other)
|
|
755
|
+
return self._inplace_from_result(result, "multiply")
|
|
756
|
+
|
|
757
|
+
def __truediv__(self, other: Any) -> FlopscopeArray: # type: ignore[override]
|
|
758
|
+
return _me().true_divide(self, other)
|
|
759
|
+
|
|
760
|
+
def __rtruediv__(self, other: Any) -> FlopscopeArray:
|
|
761
|
+
return _me().true_divide(other, self)
|
|
762
|
+
|
|
763
|
+
def __itruediv__(self, other: Any) -> FlopscopeArray:
|
|
764
|
+
result = _me().true_divide(self, other)
|
|
765
|
+
return self._inplace_from_result(result, "true_divide")
|
|
766
|
+
|
|
767
|
+
def __floordiv__(self, other: Any) -> FlopscopeArray: # type: ignore[override]
|
|
768
|
+
return _me().floor_divide(self, other)
|
|
769
|
+
|
|
770
|
+
def __rfloordiv__(self, other: Any) -> FlopscopeArray:
|
|
771
|
+
return _me().floor_divide(other, self)
|
|
772
|
+
|
|
773
|
+
def __ifloordiv__(self, other: Any) -> FlopscopeArray:
|
|
774
|
+
result = _me().floor_divide(self, other)
|
|
775
|
+
return self._inplace_from_result(result, "floor_divide")
|
|
776
|
+
|
|
777
|
+
def __mod__(self, other: Any) -> FlopscopeArray:
|
|
778
|
+
return _me().mod(self, other)
|
|
779
|
+
|
|
780
|
+
def __rmod__(self, other: Any) -> FlopscopeArray:
|
|
781
|
+
return _me().mod(other, self)
|
|
782
|
+
|
|
783
|
+
def __imod__(self, other: Any) -> FlopscopeArray:
|
|
784
|
+
result = _me().mod(self, other)
|
|
785
|
+
return self._inplace_from_result(result, "mod")
|
|
786
|
+
|
|
787
|
+
def __pow__(self, other: Any) -> FlopscopeArray:
|
|
788
|
+
return _me().power(self, other)
|
|
789
|
+
|
|
790
|
+
def __rpow__(self, other: Any) -> FlopscopeArray:
|
|
791
|
+
return _me().power(other, self)
|
|
792
|
+
|
|
793
|
+
def __ipow__(self, other: Any) -> FlopscopeArray:
|
|
794
|
+
result = _me().power(self, other)
|
|
795
|
+
return self._inplace_from_result(result, "power")
|
|
796
|
+
|
|
797
|
+
def __matmul__(self, other: Any) -> FlopscopeArray:
|
|
798
|
+
return _me().matmul(self, other)
|
|
799
|
+
|
|
800
|
+
def __rmatmul__(self, other: Any) -> FlopscopeArray:
|
|
801
|
+
return _me().matmul(other, self)
|
|
802
|
+
|
|
803
|
+
def __imatmul__(self, other: Any) -> FlopscopeArray:
|
|
804
|
+
# __imatmul__ is special: matmul output shape may differ from
|
|
805
|
+
# self.shape, in which case in-place mutation is impossible.
|
|
806
|
+
# CPython's documented in-place fallback rebinds the name to the
|
|
807
|
+
# new (out-of-place) result. NumPy raises ValueError on shape
|
|
808
|
+
# mismatch; we follow the CPython fallback so typical pipelines
|
|
809
|
+
# using ``A @= B`` to grow state work cleanly.
|
|
810
|
+
result = _me().matmul(self, other)
|
|
811
|
+
if result.shape != self.shape:
|
|
812
|
+
return result
|
|
813
|
+
return self._inplace_from_result(result, "matmul")
|
|
814
|
+
|
|
815
|
+
# ----- Unary arithmetic -----
|
|
816
|
+
|
|
817
|
+
def __neg__(self) -> FlopscopeArray:
|
|
818
|
+
return _me().negative(self)
|
|
819
|
+
|
|
820
|
+
def __pos__(self) -> FlopscopeArray:
|
|
821
|
+
return _me().positive(self)
|
|
822
|
+
|
|
823
|
+
def __abs__(self) -> FlopscopeArray:
|
|
824
|
+
return _me().abs(self)
|
|
825
|
+
|
|
826
|
+
def __invert__(self) -> FlopscopeArray:
|
|
827
|
+
return _me().invert(self)
|
|
828
|
+
|
|
829
|
+
# ----- Comparison -----
|
|
830
|
+
|
|
831
|
+
def __eq__(self, other: Any) -> FlopscopeArray:
|
|
832
|
+
return _me().equal(self, other)
|
|
833
|
+
|
|
834
|
+
def __ne__(self, other: Any) -> FlopscopeArray:
|
|
835
|
+
return _me().not_equal(self, other)
|
|
836
|
+
|
|
837
|
+
def __lt__(self, other: Any) -> FlopscopeArray:
|
|
838
|
+
return _me().less(self, other)
|
|
839
|
+
|
|
840
|
+
def __le__(self, other: Any) -> FlopscopeArray:
|
|
841
|
+
return _me().less_equal(self, other)
|
|
842
|
+
|
|
843
|
+
def __gt__(self, other: Any) -> FlopscopeArray:
|
|
844
|
+
return _me().greater(self, other)
|
|
845
|
+
|
|
846
|
+
def __ge__(self, other: Any) -> FlopscopeArray:
|
|
847
|
+
return _me().greater_equal(self, other)
|
|
848
|
+
|
|
849
|
+
def __hash__(self) -> int: # type: ignore[override]
|
|
850
|
+
# numpy ndarray is unhashable; preserve that.
|
|
851
|
+
raise TypeError(f"unhashable type: '{type(self).__name__}'")
|
|
852
|
+
|
|
853
|
+
# ----- Bitwise -----
|
|
854
|
+
|
|
855
|
+
def __and__(self, other: Any) -> FlopscopeArray:
|
|
856
|
+
return _me().bitwise_and(self, other)
|
|
857
|
+
|
|
858
|
+
def __rand__(self, other: Any) -> FlopscopeArray:
|
|
859
|
+
return _me().bitwise_and(other, self)
|
|
860
|
+
|
|
861
|
+
def __iand__(self, other: Any) -> FlopscopeArray:
|
|
862
|
+
result = _me().bitwise_and(self, other)
|
|
863
|
+
return self._inplace_from_result(result, "bitwise_and")
|
|
864
|
+
|
|
865
|
+
def __or__(self, other: Any) -> FlopscopeArray:
|
|
866
|
+
return _me().bitwise_or(self, other)
|
|
867
|
+
|
|
868
|
+
def __ror__(self, other: Any) -> FlopscopeArray:
|
|
869
|
+
return _me().bitwise_or(other, self)
|
|
870
|
+
|
|
871
|
+
def __ior__(self, other: Any) -> FlopscopeArray:
|
|
872
|
+
result = _me().bitwise_or(self, other)
|
|
873
|
+
return self._inplace_from_result(result, "bitwise_or")
|
|
874
|
+
|
|
875
|
+
def __xor__(self, other: Any) -> FlopscopeArray:
|
|
876
|
+
return _me().bitwise_xor(self, other)
|
|
877
|
+
|
|
878
|
+
def __rxor__(self, other: Any) -> FlopscopeArray:
|
|
879
|
+
return _me().bitwise_xor(other, self)
|
|
880
|
+
|
|
881
|
+
def __ixor__(self, other: Any) -> FlopscopeArray:
|
|
882
|
+
result = _me().bitwise_xor(self, other)
|
|
883
|
+
return self._inplace_from_result(result, "bitwise_xor")
|
|
884
|
+
|
|
885
|
+
def __lshift__(self, other: Any) -> FlopscopeArray:
|
|
886
|
+
return _me().left_shift(self, other)
|
|
887
|
+
|
|
888
|
+
def __rlshift__(self, other: Any) -> FlopscopeArray:
|
|
889
|
+
return _me().left_shift(other, self)
|
|
890
|
+
|
|
891
|
+
def __ilshift__(self, other: Any) -> FlopscopeArray:
|
|
892
|
+
result = _me().left_shift(self, other)
|
|
893
|
+
return self._inplace_from_result(result, "left_shift")
|
|
894
|
+
|
|
895
|
+
def __rshift__(self, other: Any) -> FlopscopeArray:
|
|
896
|
+
return _me().right_shift(self, other)
|
|
897
|
+
|
|
898
|
+
def __rrshift__(self, other: Any) -> FlopscopeArray:
|
|
899
|
+
return _me().right_shift(other, self)
|
|
900
|
+
|
|
901
|
+
def __irshift__(self, other: Any) -> FlopscopeArray:
|
|
902
|
+
result = _me().right_shift(self, other)
|
|
903
|
+
return self._inplace_from_result(result, "right_shift")
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def wrap_module_returns(module, skip_names=None, check_module=True):
|
|
907
|
+
"""Patch all public callables in a module to wrap return values.
|
|
908
|
+
|
|
909
|
+
Walks the module's namespace, finds public functions defined in
|
|
910
|
+
that module, and replaces them with wrappers that convert any
|
|
911
|
+
numpy.ndarray return value into a FlopscopeArray (zero-copy view).
|
|
912
|
+
|
|
913
|
+
Tuple/list of arrays are also handled element-wise.
|
|
914
|
+
|
|
915
|
+
Args:
|
|
916
|
+
module: The module object to patch.
|
|
917
|
+
skip_names: Optional set of function names to leave unwrapped
|
|
918
|
+
(e.g. functions that return scalars or shape tuples).
|
|
919
|
+
check_module: If True (default), only wrap functions whose
|
|
920
|
+
__module__ matches the module being patched.
|
|
921
|
+
Set to False for modules that re-export from
|
|
922
|
+
sub-modules (e.g. flopscope.numpy.linalg).
|
|
923
|
+
"""
|
|
924
|
+
import functools
|
|
925
|
+
|
|
926
|
+
skip = set(skip_names or ())
|
|
927
|
+
|
|
928
|
+
for name in list(vars(module)):
|
|
929
|
+
if name.startswith("_") or name in skip:
|
|
930
|
+
continue
|
|
931
|
+
obj = getattr(module, name)
|
|
932
|
+
if not callable(obj):
|
|
933
|
+
continue
|
|
934
|
+
if check_module:
|
|
935
|
+
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
|
|
936
|
+
continue
|
|
937
|
+
|
|
938
|
+
original = obj
|
|
939
|
+
|
|
940
|
+
@functools.wraps(original)
|
|
941
|
+
def wrapped(*args, _orig=original, **kwargs):
|
|
942
|
+
result = _orig(*args, **kwargs)
|
|
943
|
+
if isinstance(result, _np.ndarray):
|
|
944
|
+
return _asflopscope(result)
|
|
945
|
+
if isinstance(result, tuple):
|
|
946
|
+
wrapped_elems = [
|
|
947
|
+
_asflopscope(r) if isinstance(r, _np.ndarray) else r for r in result
|
|
948
|
+
]
|
|
949
|
+
# Preserve named tuple type (e.g. UniqueAllResult).
|
|
950
|
+
if type(result) is not tuple and hasattr(type(result), "_fields"):
|
|
951
|
+
return type(result)(*wrapped_elems)
|
|
952
|
+
return tuple(wrapped_elems)
|
|
953
|
+
if isinstance(result, list):
|
|
954
|
+
return [
|
|
955
|
+
_asflopscope(r) if isinstance(r, _np.ndarray) else r for r in result
|
|
956
|
+
]
|
|
957
|
+
return result
|
|
958
|
+
|
|
959
|
+
wrapped.__wrapped__ = original
|
|
960
|
+
|
|
961
|
+
setattr(module, name, wrapped)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _asflopscope(x):
|
|
965
|
+
"""Convert any array-like to FlopscopeArray without forcing ownership copies.
|
|
966
|
+
|
|
967
|
+
- FlopscopeArray: returned as-is
|
|
968
|
+
- numpy.ndarray subclass (e.g. SymmetricTensor): returned as-is to
|
|
969
|
+
preserve subclass metadata
|
|
970
|
+
- plain numpy.ndarray: view-cast to FlopscopeArray (zero-copy)
|
|
971
|
+
- other: np.asarray first, then view-cast (also zero-copy with
|
|
972
|
+
respect to the ndarray returned by ``np.asarray``)
|
|
973
|
+
|
|
974
|
+
Flopscope deliberately does not promise ``OWNDATA`` parity for subclass
|
|
975
|
+
results. Avoiding extra copies is preferred because this conversion
|
|
976
|
+
sits on a hot path for many small-array operations.
|
|
977
|
+
"""
|
|
978
|
+
if isinstance(x, FlopscopeArray):
|
|
979
|
+
return x
|
|
980
|
+
if type(x) is not _np.ndarray and isinstance(x, _np.ndarray):
|
|
981
|
+
# Other ndarray subclass (e.g. SymmetricTensor) — preserve as-is.
|
|
982
|
+
return x
|
|
983
|
+
if isinstance(x, _np.ndarray):
|
|
984
|
+
return x.view(FlopscopeArray)
|
|
985
|
+
arr = _np.asarray(x)
|
|
986
|
+
return arr.view(FlopscopeArray)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _asplainflopscope(x):
|
|
990
|
+
"""Convert any array-like to a base FlopscopeArray.
|
|
991
|
+
|
|
992
|
+
Unlike :func:`_asflopscope`, this always drops ndarray subclasses so callers can
|
|
993
|
+
explicitly return a plain tracked array after metadata becomes invalid.
|
|
994
|
+
As with :func:`_asflopscope`, this is intentionally no-copy: the result may
|
|
995
|
+
report ``OWNDATA=False`` even when the underlying base ndarray owns the
|
|
996
|
+
data.
|
|
997
|
+
"""
|
|
998
|
+
arr = _np.asarray(x)
|
|
999
|
+
return arr.view(FlopscopeArray)
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _to_base_ndarray(x):
|
|
1003
|
+
"""View a flopscope array as a plain ``np.ndarray`` (zero-copy).
|
|
1004
|
+
|
|
1005
|
+
Distinct from :func:`_asplainflopscope` (which returns a ``FlopscopeArray`` —
|
|
1006
|
+
still a numpy subclass that triggers ``__array_function__``).
|
|
1007
|
+
Required before calling ``_np.<func>(x)`` from inside our own
|
|
1008
|
+
``__array_ufunc__`` / ``__array_function__`` handlers, so that
|
|
1009
|
+
NumPy's protocol dispatch (which would route FlopscopeArray inputs back
|
|
1010
|
+
through ``me.<func>``) does not recurse infinitely.
|
|
1011
|
+
|
|
1012
|
+
Only ``FlopscopeArray`` instances (and subclasses like
|
|
1013
|
+
``SymmetricTensor``) are stripped — other ``numpy.ndarray``
|
|
1014
|
+
subclasses (e.g. ``np.matrix``, user-defined ``ArraySubclass``)
|
|
1015
|
+
pass through unchanged so NumPy's standard subclass-propagation
|
|
1016
|
+
behaviour preserves their type on the result.
|
|
1017
|
+
|
|
1018
|
+
Non-array inputs (Python scalars, lists) pass through unchanged so
|
|
1019
|
+
that NEP 50 weak-typing rules continue to apply when flopscope wrappers
|
|
1020
|
+
forward these to NumPy.
|
|
1021
|
+
|
|
1022
|
+
Examples
|
|
1023
|
+
--------
|
|
1024
|
+
>>> a = FlopscopeArray((4,), dtype=float)
|
|
1025
|
+
>>> type(_to_base_ndarray(a)) is _np.ndarray
|
|
1026
|
+
True
|
|
1027
|
+
>>> _to_base_ndarray(2.0) == 2.0
|
|
1028
|
+
True
|
|
1029
|
+
"""
|
|
1030
|
+
if isinstance(x, FlopscopeArray):
|
|
1031
|
+
return x.view(_np.ndarray)
|
|
1032
|
+
return x
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _to_base_ndarray_tree(x):
|
|
1036
|
+
"""Recursively strip flopscope subclasses from arrays inside tuples/lists.
|
|
1037
|
+
|
|
1038
|
+
Use for arguments that accept *containers* of arrays passed through
|
|
1039
|
+
``__array_function__``:
|
|
1040
|
+
- ``np.lexsort(keys)`` — keys is a sequence of arrays.
|
|
1041
|
+
- ``np.meshgrid(*xi)`` — xi is a tuple of arrays.
|
|
1042
|
+
- ``np.concatenate(arrays)`` / ``np.stack(arrays)`` — arrays is a sequence.
|
|
1043
|
+
- ``out=(out1, out2)`` — multi-output ufunc out tuples.
|
|
1044
|
+
|
|
1045
|
+
Plain ``_to_base_ndarray`` only handles a single array; using it on
|
|
1046
|
+
a list of FlopscopeArrays leaves the inner FlopscopeArrays intact and
|
|
1047
|
+
recursion can still happen through them.
|
|
1048
|
+
|
|
1049
|
+
Preserves namedtuples (e.g. ``UniqueAllResult``) by re-constructing
|
|
1050
|
+
the type with stripped fields.
|
|
1051
|
+
|
|
1052
|
+
Intentional scope: tuples, lists, namedtuples, and bare arrays only.
|
|
1053
|
+
Dicts and other generic mappings are NOT recursed into.
|
|
1054
|
+
|
|
1055
|
+
Only ``FlopscopeArray`` instances (and subclasses like
|
|
1056
|
+
``SymmetricTensor``) are stripped — other ``numpy.ndarray``
|
|
1057
|
+
subclasses (e.g. ``np.matrix``, user-defined ``ArraySubclass``)
|
|
1058
|
+
pass through unchanged so NumPy's standard subclass-propagation
|
|
1059
|
+
behaviour preserves their type on the result.
|
|
1060
|
+
"""
|
|
1061
|
+
if isinstance(x, FlopscopeArray):
|
|
1062
|
+
return x.view(_np.ndarray)
|
|
1063
|
+
if isinstance(x, tuple):
|
|
1064
|
+
stripped = tuple(_to_base_ndarray_tree(e) for e in x)
|
|
1065
|
+
# Preserve namedtuple type if present.
|
|
1066
|
+
if type(x) is not tuple and hasattr(type(x), "_fields"):
|
|
1067
|
+
return type(x)(*stripped)
|
|
1068
|
+
return stripped
|
|
1069
|
+
if isinstance(x, list):
|
|
1070
|
+
return [_to_base_ndarray_tree(e) for e in x]
|
|
1071
|
+
return x
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
@_functools.cache
|
|
1075
|
+
def _signature_kwargs_accepted(np_func):
|
|
1076
|
+
"""Return frozenset of kwarg names accepted by ``np_func``.
|
|
1077
|
+
|
|
1078
|
+
Returns:
|
|
1079
|
+
- ``None`` if ``np_func`` is ``None`` or signature inspection fails.
|
|
1080
|
+
- Empty frozenset if ``np_func`` accepts ``**kwargs`` (sentinel meaning
|
|
1081
|
+
"accepts every kwarg name; do not filter").
|
|
1082
|
+
- Otherwise a frozenset of accepted parameter names.
|
|
1083
|
+
|
|
1084
|
+
Cached: NumPy callable identities are stable for the process
|
|
1085
|
+
lifetime, so ``@functools.cache`` is safe and necessary on this hot
|
|
1086
|
+
path. PR #51 memoized similar per-call introspection
|
|
1087
|
+
(``unique_elements_for_shape``, ``_canonical_axis_action``); this
|
|
1088
|
+
follows the same pattern. See PR #51 perf comment for why per-call
|
|
1089
|
+
``inspect.signature`` is unacceptable.
|
|
1090
|
+
"""
|
|
1091
|
+
if np_func is None:
|
|
1092
|
+
return None
|
|
1093
|
+
try:
|
|
1094
|
+
sig = _inspect.signature(np_func)
|
|
1095
|
+
except (ValueError, TypeError):
|
|
1096
|
+
return None
|
|
1097
|
+
for p in sig.parameters.values():
|
|
1098
|
+
if p.kind == _inspect.Parameter.VAR_KEYWORD:
|
|
1099
|
+
return frozenset() # sentinel: accepts everything
|
|
1100
|
+
return frozenset(
|
|
1101
|
+
n
|
|
1102
|
+
for n, p in sig.parameters.items()
|
|
1103
|
+
if p.kind != _inspect.Parameter.VAR_POSITIONAL
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def _filter_to_np_signature(np_func, kwargs):
|
|
1108
|
+
"""Drop kwargs that ``np_func`` does not accept.
|
|
1109
|
+
|
|
1110
|
+
NumPy's ``ufunc.reduce`` always supplies ``dtype=`` / ``keepdims=`` /
|
|
1111
|
+
``out=``, but the equivalent function-form wrapper (``np.all``,
|
|
1112
|
+
``np.any``, etc.) accepts only a subset. Forwarding the full
|
|
1113
|
+
ufunc-reduce kwarg set to those function-form wrappers raises
|
|
1114
|
+
``TypeError``.
|
|
1115
|
+
|
|
1116
|
+
Falls back to the original kwargs when ``inspect.signature`` cannot
|
|
1117
|
+
introspect (e.g. C-implemented functions). Per-function signature
|
|
1118
|
+
lookup is cached via :func:`_signature_kwargs_accepted` — this is on
|
|
1119
|
+
the per-ufunc-call hot path; uncached lookup would be a perf cliff.
|
|
1120
|
+
"""
|
|
1121
|
+
accepted = _signature_kwargs_accepted(np_func)
|
|
1122
|
+
if accepted is None:
|
|
1123
|
+
return kwargs
|
|
1124
|
+
if not accepted: # empty frozenset = sentinel for **kwargs accepted
|
|
1125
|
+
return kwargs
|
|
1126
|
+
return {k: v for k, v in kwargs.items() if k in accepted}
|