mlx-signal-processing 0.1.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.
- mlx_signal_processing/__init__.py +62 -0
- mlx_signal_processing/_array.py +130 -0
- mlx_signal_processing/_arraytools.py +50 -0
- mlx_signal_processing/_cache.py +44 -0
- mlx_signal_processing/_config.py +121 -0
- mlx_signal_processing/_fft.py +21 -0
- mlx_signal_processing/_fft_core.py +136 -0
- mlx_signal_processing/_fourstep.py +408 -0
- mlx_signal_processing/_fourstep_metal.py +303 -0
- mlx_signal_processing/_lfilter_metal.py +301 -0
- mlx_signal_processing/_ola_metal.py +77 -0
- mlx_signal_processing/_peaks_metal.py +164 -0
- mlx_signal_processing/_sosfilt_metal.py +290 -0
- mlx_signal_processing/_stft_metal.py +731 -0
- mlx_signal_processing/_upfirdn_metal.py +325 -0
- mlx_signal_processing/convolution.py +506 -0
- mlx_signal_processing/filtering.py +1056 -0
- mlx_signal_processing/peaks.py +397 -0
- mlx_signal_processing/resampling.py +635 -0
- mlx_signal_processing/spectral.py +932 -0
- mlx_signal_processing/windows.py +35 -0
- mlx_signal_processing-0.1.0.dist-info/METADATA +321 -0
- mlx_signal_processing-0.1.0.dist-info/RECORD +26 -0
- mlx_signal_processing-0.1.0.dist-info/WHEEL +4 -0
- mlx_signal_processing-0.1.0.dist-info/licenses/LICENSE +21 -0
- mlx_signal_processing-0.1.0.dist-info/licenses/NOTICE +7 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""mlx-signal: Metal/MLX-accelerated signal processing for Apple Silicon.
|
|
2
|
+
|
|
3
|
+
A scipy.signal-compatible API where the heavy lifting (batched FFTs, polyphase
|
|
4
|
+
resampling, FFT convolution) runs on the GPU through MLX. Inputs can be NumPy
|
|
5
|
+
or MLX arrays; outputs are MLX arrays in unified memory, so ``np.array(result)``
|
|
6
|
+
is cheap and feeding an MLX model requires zero copies.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ._config import (
|
|
10
|
+
Config,
|
|
11
|
+
DowncastWarning,
|
|
12
|
+
FallbackWarning,
|
|
13
|
+
config_context,
|
|
14
|
+
get_config,
|
|
15
|
+
set_config,
|
|
16
|
+
)
|
|
17
|
+
from ._fft import next_fast_len
|
|
18
|
+
from .convolution import convolve, correlate, correlation_lags, fftconvolve, oaconvolve
|
|
19
|
+
from .filtering import filtfilt, firwin, firwin2, hilbert, lfilter, sosfilt, sosfiltfilt
|
|
20
|
+
from .peaks import find_peaks, peak_prominences, peak_widths
|
|
21
|
+
from .resampling import decimate, resample, resample_poly, upfirdn
|
|
22
|
+
from .spectral import coherence, csd, istft, periodogram, spectrogram, stft, welch
|
|
23
|
+
from .windows import get_window
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Config",
|
|
29
|
+
"DowncastWarning",
|
|
30
|
+
"FallbackWarning",
|
|
31
|
+
"coherence",
|
|
32
|
+
"convolve",
|
|
33
|
+
"config_context",
|
|
34
|
+
"correlate",
|
|
35
|
+
"decimate",
|
|
36
|
+
"correlation_lags",
|
|
37
|
+
"csd",
|
|
38
|
+
"filtfilt",
|
|
39
|
+
"fftconvolve",
|
|
40
|
+
"firwin",
|
|
41
|
+
"firwin2",
|
|
42
|
+
"find_peaks",
|
|
43
|
+
"get_config",
|
|
44
|
+
"get_window",
|
|
45
|
+
"hilbert",
|
|
46
|
+
"istft",
|
|
47
|
+
"lfilter",
|
|
48
|
+
"next_fast_len",
|
|
49
|
+
"oaconvolve",
|
|
50
|
+
"peak_prominences",
|
|
51
|
+
"peak_widths",
|
|
52
|
+
"periodogram",
|
|
53
|
+
"resample",
|
|
54
|
+
"resample_poly",
|
|
55
|
+
"set_config",
|
|
56
|
+
"sosfilt",
|
|
57
|
+
"sosfiltfilt",
|
|
58
|
+
"spectrogram",
|
|
59
|
+
"stft",
|
|
60
|
+
"upfirdn",
|
|
61
|
+
"welch",
|
|
62
|
+
]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Array conversion and dtype policy: float32/complex64 in, MLX arrays out."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
import mlx.core as mx
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from ._config import DowncastWarning, _config
|
|
11
|
+
|
|
12
|
+
_MX_FLOAT64 = getattr(mx, "float64", None)
|
|
13
|
+
_MX_COMPLEX128 = getattr(mx, "complex128", None)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _downcast_notice(src: str) -> None:
|
|
17
|
+
if _config.float64 == "strict":
|
|
18
|
+
raise TypeError(
|
|
19
|
+
f"{src} input is not supported with float64='strict' (Metal has no "
|
|
20
|
+
"float64). Cast to float32/complex64, or set "
|
|
21
|
+
"mlx_signal_processing.set_config(float64='downcast')."
|
|
22
|
+
)
|
|
23
|
+
if _config.warn_on_downcast:
|
|
24
|
+
warnings.warn(
|
|
25
|
+
f"{src} input downcast to 32-bit for Metal (no float64 on the GPU); "
|
|
26
|
+
"results are float32-accurate",
|
|
27
|
+
DowncastWarning,
|
|
28
|
+
stacklevel=4,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def to_mlx(x) -> mx.array:
|
|
33
|
+
"""Convert input to an MLX array with dtype float32 or complex64.
|
|
34
|
+
|
|
35
|
+
Python scalars/lists convert silently; explicit float64/complex128 arrays
|
|
36
|
+
are downcast with a :class:`DowncastWarning` (or raise under the "strict"
|
|
37
|
+
dtype policy).
|
|
38
|
+
"""
|
|
39
|
+
if isinstance(x, mx.array):
|
|
40
|
+
d = x.dtype
|
|
41
|
+
if d in (mx.float32, mx.complex64):
|
|
42
|
+
return x
|
|
43
|
+
if _MX_FLOAT64 is not None and d == _MX_FLOAT64:
|
|
44
|
+
_downcast_notice("float64")
|
|
45
|
+
return x.astype(mx.float32)
|
|
46
|
+
if _MX_COMPLEX128 is not None and d == _MX_COMPLEX128:
|
|
47
|
+
_downcast_notice("complex128")
|
|
48
|
+
return x.astype(mx.complex64)
|
|
49
|
+
if d in (mx.float16, mx.bfloat16):
|
|
50
|
+
return x.astype(mx.float32)
|
|
51
|
+
return x.astype(mx.float32) # integers / bool
|
|
52
|
+
if not isinstance(x, np.ndarray):
|
|
53
|
+
# lists / scalars: let MLX pick its native 32-bit defaults, then normalize
|
|
54
|
+
return to_mlx(mx.array(x))
|
|
55
|
+
if x.ndim == 0:
|
|
56
|
+
# mx.array(np 0-d) yields shape (1,); convert as 1-D (so the dtype
|
|
57
|
+
# policy — DowncastWarning / strict — still applies) and reshape back
|
|
58
|
+
return to_mlx(x.reshape(1)).reshape(())
|
|
59
|
+
if x.dtype == np.float32 or x.dtype == np.complex64:
|
|
60
|
+
return mx.array(np.ascontiguousarray(x))
|
|
61
|
+
if x.dtype.kind == "f":
|
|
62
|
+
if x.dtype.itemsize > 4:
|
|
63
|
+
_downcast_notice(str(x.dtype))
|
|
64
|
+
return mx.array(np.ascontiguousarray(x, dtype=np.float32))
|
|
65
|
+
if x.dtype.kind == "c":
|
|
66
|
+
_downcast_notice(str(x.dtype))
|
|
67
|
+
return mx.array(np.ascontiguousarray(x, dtype=np.complex64))
|
|
68
|
+
if x.dtype.kind in "iub":
|
|
69
|
+
return mx.array(np.ascontiguousarray(x, dtype=np.float32))
|
|
70
|
+
raise TypeError(f"unsupported input dtype: {x.dtype}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def check_strict(x) -> None:
|
|
74
|
+
"""Raise under float64='strict' for 64-bit signal inputs on ANY dispatch path."""
|
|
75
|
+
if _config.float64 != "strict":
|
|
76
|
+
return
|
|
77
|
+
bad = False
|
|
78
|
+
if isinstance(x, mx.array):
|
|
79
|
+
d = x.dtype
|
|
80
|
+
bad = (_MX_FLOAT64 is not None and d == _MX_FLOAT64) or (
|
|
81
|
+
_MX_COMPLEX128 is not None and d == _MX_COMPLEX128
|
|
82
|
+
)
|
|
83
|
+
elif isinstance(x, np.ndarray):
|
|
84
|
+
bad = (x.dtype.kind == "f" and x.dtype.itemsize > 4) or (
|
|
85
|
+
x.dtype.kind == "c" and x.dtype.itemsize > 8
|
|
86
|
+
)
|
|
87
|
+
if bad:
|
|
88
|
+
raise TypeError(
|
|
89
|
+
"float64/complex128 input is not supported with float64='strict' "
|
|
90
|
+
"(results are float32 on every dispatch path). Cast to "
|
|
91
|
+
"float32/complex64, or set mlx_signal_processing.set_config(float64='downcast')."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def signal_np(x) -> np.ndarray:
|
|
96
|
+
"""to_numpy for signal arguments of scipy fallbacks: enforces the strict policy."""
|
|
97
|
+
check_strict(x)
|
|
98
|
+
return to_numpy(x)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def to_numpy(x) -> np.ndarray:
|
|
102
|
+
"""Convert MLX or array-like input to a NumPy array (used by scipy fallbacks)."""
|
|
103
|
+
if isinstance(x, mx.array):
|
|
104
|
+
return np.array(x)
|
|
105
|
+
return np.asarray(x)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def result_to_mlx(a) -> mx.array:
|
|
109
|
+
"""Convert a scipy-fallback result to MLX with the library's output dtypes.
|
|
110
|
+
|
|
111
|
+
Silent by design: the caller chose (or was routed to) the scipy path, and the
|
|
112
|
+
contract is that both paths return identical dtypes.
|
|
113
|
+
"""
|
|
114
|
+
a = np.asarray(a)
|
|
115
|
+
if a.dtype.kind == "f" and a.dtype.itemsize > 4:
|
|
116
|
+
a = a.astype(np.float32)
|
|
117
|
+
elif a.dtype.kind == "c" and a.dtype.itemsize > 8:
|
|
118
|
+
a = a.astype(np.complex64)
|
|
119
|
+
elif a.dtype.kind in "iub":
|
|
120
|
+
a = a.astype(np.float32)
|
|
121
|
+
if a.ndim == 0: # scipy scalar results stay 0-d (mx.array would make (1,))
|
|
122
|
+
return mx.array(np.ascontiguousarray(a.reshape(1))).reshape(())
|
|
123
|
+
return mx.array(np.ascontiguousarray(a))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def input_size(x) -> int:
|
|
127
|
+
"""Number of elements in an array-like, without forcing a conversion."""
|
|
128
|
+
if isinstance(x, mx.array):
|
|
129
|
+
return x.size
|
|
130
|
+
return int(np.size(x))
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Signal-edge extensions along the last axis (mirrors scipy.signal._arraytools)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import mlx.core as mx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _check_ext_len(x: mx.array, n: int) -> None:
|
|
9
|
+
if n > x.shape[-1] - 1:
|
|
10
|
+
raise ValueError(
|
|
11
|
+
f"The extension length n ({n}) is too big. It must not exceed "
|
|
12
|
+
f"x.shape[-1]-1, which is {x.shape[-1] - 1}."
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def even_ext(x: mx.array, n: int) -> mx.array:
|
|
17
|
+
"""Even (mirror, edge not repeated) extension: [x[n]..x[1], x, x[-2]..x[-n-1]]."""
|
|
18
|
+
if n < 1:
|
|
19
|
+
return x
|
|
20
|
+
_check_ext_len(x, n)
|
|
21
|
+
left = x[..., n:0:-1]
|
|
22
|
+
right = x[..., -2 : -(n + 2) : -1]
|
|
23
|
+
return mx.concatenate([left, x, right], axis=-1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def odd_ext(x: mx.array, n: int) -> mx.array:
|
|
27
|
+
"""Odd (antisymmetric about the edge values) extension."""
|
|
28
|
+
if n < 1:
|
|
29
|
+
return x
|
|
30
|
+
_check_ext_len(x, n)
|
|
31
|
+
left = 2 * x[..., :1] - x[..., n:0:-1]
|
|
32
|
+
right = 2 * x[..., -1:] - x[..., -2 : -(n + 2) : -1]
|
|
33
|
+
return mx.concatenate([left, x, right], axis=-1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def const_ext(x: mx.array, n: int) -> mx.array:
|
|
37
|
+
"""Constant (repeat edge value) extension."""
|
|
38
|
+
if n < 1:
|
|
39
|
+
return x
|
|
40
|
+
left = mx.broadcast_to(x[..., :1], x.shape[:-1] + (n,))
|
|
41
|
+
right = mx.broadcast_to(x[..., -1:], x.shape[:-1] + (n,))
|
|
42
|
+
return mx.concatenate([left, x, right], axis=-1)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def zero_ext(x: mx.array, n: int) -> mx.array:
|
|
46
|
+
"""Zero-pad extension."""
|
|
47
|
+
if n < 1:
|
|
48
|
+
return x
|
|
49
|
+
z = mx.zeros(x.shape[:-1] + (n,), dtype=x.dtype)
|
|
50
|
+
return mx.concatenate([z, x, z], axis=-1)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Byte-budget LRU for materialized GPU constant vectors (twiddles, coeffs).
|
|
2
|
+
|
|
3
|
+
Entry-count LRU limits are the wrong tool for arrays whose size scales with
|
|
4
|
+
the transform length: 64 cached autocorrelation twiddles once retained
|
|
5
|
+
~257 MiB. This cache evicts least-recently-used entries until the total
|
|
6
|
+
payload fits a byte budget, so memory stays bounded no matter how many
|
|
7
|
+
distinct lengths a workload touches while repeated lengths stay warm.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections import OrderedDict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ByteBudgetCache:
|
|
16
|
+
def __init__(self, budget_bytes: int):
|
|
17
|
+
self.budget = int(budget_bytes)
|
|
18
|
+
self._entries: OrderedDict[tuple, tuple[object, int]] = OrderedDict()
|
|
19
|
+
self._total = 0
|
|
20
|
+
|
|
21
|
+
def get(self, key: tuple):
|
|
22
|
+
entry = self._entries.get(key)
|
|
23
|
+
if entry is None:
|
|
24
|
+
return None
|
|
25
|
+
self._entries.move_to_end(key)
|
|
26
|
+
return entry[0]
|
|
27
|
+
|
|
28
|
+
def put(self, key: tuple, value, nbytes: int) -> None:
|
|
29
|
+
if int(nbytes) > self.budget:
|
|
30
|
+
return # oversized entries are returned to the caller uncached
|
|
31
|
+
if key in self._entries:
|
|
32
|
+
self._total -= self._entries.pop(key)[1]
|
|
33
|
+
self._entries[key] = (value, int(nbytes))
|
|
34
|
+
self._total += int(nbytes)
|
|
35
|
+
while self._total > self.budget and len(self._entries) > 1:
|
|
36
|
+
_, (_, freed) = self._entries.popitem(last=False)
|
|
37
|
+
self._total -= freed
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
#: shared budget for all FFT twiddle/coefficient vectors (complex64). Sized
|
|
41
|
+
#: to hold the working set of one >8M-sample four-step resample (~114 MiB of
|
|
42
|
+
#: concurrent twiddles) with headroom for a second transform length; a sweep
|
|
43
|
+
#: over arbitrarily many lengths still caps here instead of growing linearly.
|
|
44
|
+
TWIDDLES = ByteBudgetCache(192 << 20)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Global configuration: dispatch policy, dtype policy, and fallback warnings.
|
|
2
|
+
|
|
3
|
+
Dispatch modes
|
|
4
|
+
--------------
|
|
5
|
+
- ``"auto"`` (default): route to the MLX/Metal path when the amount of work is
|
|
6
|
+
above ``gpu_min_size`` elements, otherwise use scipy.signal (kernel-launch
|
|
7
|
+
overhead makes the GPU a net loss on tiny inputs). Size-based routing is
|
|
8
|
+
silent by design; both paths return MLX arrays with identical dtypes.
|
|
9
|
+
- ``"mlx"``: always use the MLX path. Calls the MLX path cannot handle
|
|
10
|
+
(e.g. IIR filtering) raise ``NotImplementedError`` instead of falling back —
|
|
11
|
+
useful for tests and for pinning the GPU path.
|
|
12
|
+
- ``"scipy"``: always use scipy.signal (still returns MLX arrays).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import dataclasses
|
|
19
|
+
import warnings
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Config",
|
|
23
|
+
"DowncastWarning",
|
|
24
|
+
"FallbackWarning",
|
|
25
|
+
"config_context",
|
|
26
|
+
"get_config",
|
|
27
|
+
"set_config",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
_DISPATCH_MODES = ("auto", "mlx", "scipy")
|
|
31
|
+
_FLOAT64_MODES = ("downcast", "strict")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FallbackWarning(UserWarning):
|
|
35
|
+
"""A call was routed to scipy.signal because the MLX path does not cover it."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DowncastWarning(UserWarning):
|
|
39
|
+
"""float64/complex128 input was downcast to float32/complex64 (Metal has no fp64)."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclasses.dataclass
|
|
43
|
+
class Config:
|
|
44
|
+
dispatch: str = "auto"
|
|
45
|
+
#: elements of work below which "auto" dispatch uses scipy
|
|
46
|
+
gpu_min_size: int = 1 << 15
|
|
47
|
+
float64: str = "downcast" # or "strict" (raise on float64 input)
|
|
48
|
+
warn_on_downcast: bool = True
|
|
49
|
+
warn_on_fallback: bool = True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_config = Config()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_config() -> Config:
|
|
56
|
+
"""Return a copy of the current global configuration."""
|
|
57
|
+
return dataclasses.replace(_config)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def set_config(**kwargs) -> None:
|
|
61
|
+
"""Update global configuration options.
|
|
62
|
+
|
|
63
|
+
Examples
|
|
64
|
+
--------
|
|
65
|
+
>>> import mlx_signal_processing
|
|
66
|
+
>>> mlx_signal_processing.set_config(dispatch="mlx", warn_on_downcast=False)
|
|
67
|
+
"""
|
|
68
|
+
# validate everything before mutating so a failure leaves state untouched
|
|
69
|
+
for key in kwargs:
|
|
70
|
+
if not hasattr(_config, key):
|
|
71
|
+
raise TypeError(f"unknown config option {key!r}")
|
|
72
|
+
if kwargs.get("dispatch", _config.dispatch) not in _DISPATCH_MODES:
|
|
73
|
+
raise ValueError(f"dispatch must be one of {_DISPATCH_MODES}")
|
|
74
|
+
if kwargs.get("float64", _config.float64) not in _FLOAT64_MODES:
|
|
75
|
+
raise ValueError(f"float64 must be one of {_FLOAT64_MODES}")
|
|
76
|
+
size = kwargs.get("gpu_min_size", _config.gpu_min_size)
|
|
77
|
+
if not isinstance(size, int) or isinstance(size, bool) or size < 0:
|
|
78
|
+
raise ValueError("gpu_min_size must be a non-negative integer")
|
|
79
|
+
for key, value in kwargs.items():
|
|
80
|
+
setattr(_config, key, value)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@contextlib.contextmanager
|
|
84
|
+
def config_context(**kwargs):
|
|
85
|
+
"""Temporarily override configuration options within a ``with`` block."""
|
|
86
|
+
old = dataclasses.asdict(_config)
|
|
87
|
+
try:
|
|
88
|
+
set_config(**kwargs)
|
|
89
|
+
yield
|
|
90
|
+
finally:
|
|
91
|
+
_config.__dict__.update(old)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def use_mlx(work_size: int) -> bool:
|
|
95
|
+
"""Decide whether the MLX path should run for ``work_size`` elements of work."""
|
|
96
|
+
if _config.dispatch == "mlx":
|
|
97
|
+
return True
|
|
98
|
+
if _config.dispatch == "scipy":
|
|
99
|
+
return False
|
|
100
|
+
return work_size >= _config.gpu_min_size
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def capability_fallback(func_name: str, reason: str) -> None:
|
|
104
|
+
"""Record a scipy fallback taken because the MLX path can't handle the call.
|
|
105
|
+
|
|
106
|
+
Under ``dispatch="mlx"`` this raises ``NotImplementedError`` instead, so the
|
|
107
|
+
GPU path can be pinned; otherwise it emits a :class:`FallbackWarning` (unlike
|
|
108
|
+
size-based routing, capability fallbacks are loud so nobody ships a pipeline
|
|
109
|
+
believing it runs on the GPU when it doesn't).
|
|
110
|
+
"""
|
|
111
|
+
if _config.dispatch == "mlx":
|
|
112
|
+
raise NotImplementedError(
|
|
113
|
+
f"mlx_signal_processing.{func_name}: {reason} has no MLX path; "
|
|
114
|
+
"use dispatch='auto' to allow the scipy fallback"
|
|
115
|
+
)
|
|
116
|
+
if _config.warn_on_fallback:
|
|
117
|
+
warnings.warn(
|
|
118
|
+
f"mlx_signal_processing.{func_name}: {reason}; falling back to scipy.signal",
|
|
119
|
+
FallbackWarning,
|
|
120
|
+
stacklevel=3,
|
|
121
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""FFT sizing helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["next_fast_len"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def next_fast_len(target: int, real: bool = True) -> int:
|
|
9
|
+
"""Next FFT length >= ``target`` that is fast on Metal.
|
|
10
|
+
|
|
11
|
+
MLX's Metal FFT is fastest at powers of two (other sizes route through
|
|
12
|
+
slower mixed-radix/Bluestein paths), so unlike ``scipy.fft.next_fast_len``
|
|
13
|
+
this always returns the next power of two. The ``real`` argument is
|
|
14
|
+
accepted for scipy signature compatibility and ignored.
|
|
15
|
+
"""
|
|
16
|
+
target = int(target)
|
|
17
|
+
if target < 0:
|
|
18
|
+
raise ValueError("Target length must be positive")
|
|
19
|
+
if target <= 1:
|
|
20
|
+
return target # scipy: next_fast_len(0) == 0, (1) == 1
|
|
21
|
+
return 1 << (target - 1).bit_length()
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Safe FFT wrappers working around broken Metal FFT lengths in MLX 0.32.
|
|
2
|
+
|
|
3
|
+
Empirically (M4 Max, macOS 26.2, mlx 0.32.0), the Metal FFT backend has two
|
|
4
|
+
distinct failure modes for large 1-D transform lengths:
|
|
5
|
+
|
|
6
|
+
- lengths in (2^19, 2^21] other than 2^20, and exactly 2^22, fail to load
|
|
7
|
+
their four-step sub-kernels and raise
|
|
8
|
+
("Unable to load function four_step_mem_...");
|
|
9
|
+
- every other length above 2^20 runs but returns silently incorrect values
|
|
10
|
+
(relative error ~1.0 versus a float64 reference).
|
|
11
|
+
|
|
12
|
+
Lengths at or below 2^19, and exactly 2^20, are verified accurate (~1e-6
|
|
13
|
+
relative, forward and inverse). These wrappers therefore trust the GPU only in
|
|
14
|
+
that verified region and route every other length through the MLX CPU stream:
|
|
15
|
+
same lazy graph, same unified memory, correct results — just slower for that
|
|
16
|
+
one op. Higher-level code additionally avoids big single FFTs where an
|
|
17
|
+
algorithm choice can (fftconvolve switches to blocked overlap-add), which
|
|
18
|
+
keeps long-signal filtering on the GPU.
|
|
19
|
+
|
|
20
|
+
If a future MLX release fixes the Metal FFT, relaxing ``metal_fft_broken`` is
|
|
21
|
+
the only change needed to reclaim full GPU performance.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import mlx.core as mx
|
|
27
|
+
|
|
28
|
+
_SAFE_MAX = 1 << 19
|
|
29
|
+
_SAFE_EXACT = 1 << 20
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def metal_fft_broken(n: int) -> bool:
|
|
33
|
+
"""True if MLX's Metal FFT cannot be *trusted* for a length-``n`` transform."""
|
|
34
|
+
if not mx.metal.is_available():
|
|
35
|
+
return False
|
|
36
|
+
n = int(n)
|
|
37
|
+
return not (n <= _SAFE_MAX or n == _SAFE_EXACT)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _kw(n: int) -> dict:
|
|
41
|
+
return {"stream": mx.cpu} if metal_fft_broken(int(n)) else {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _one_point(a: mx.array, axis: int) -> mx.array:
|
|
45
|
+
"""First element along ``axis`` (zero if the axis is empty), length-1 axis kept."""
|
|
46
|
+
ax = axis % a.ndim
|
|
47
|
+
if a.shape[ax] >= 1:
|
|
48
|
+
sl = [slice(None)] * a.ndim
|
|
49
|
+
sl[ax] = slice(0, 1)
|
|
50
|
+
return a[tuple(sl)]
|
|
51
|
+
shape = tuple(1 if i == ax else d for i, d in enumerate(a.shape))
|
|
52
|
+
return mx.zeros(shape, dtype=a.dtype)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def rfft(a, n=None, axis=-1):
|
|
56
|
+
length = int(a.shape[axis] if n is None else n)
|
|
57
|
+
if length == 1: # MLX's Metal length-1 batched rfft returns garbage
|
|
58
|
+
return _one_point(a, axis).astype(mx.complex64)
|
|
59
|
+
if metal_fft_broken(length):
|
|
60
|
+
from . import _fourstep
|
|
61
|
+
|
|
62
|
+
out = _fourstep.rfft_large(a, length, axis)
|
|
63
|
+
if out is not None:
|
|
64
|
+
return out
|
|
65
|
+
return mx.fft.rfft(a, n=n, axis=axis, **_kw(length))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def irfft(a, n=None, axis=-1):
|
|
69
|
+
length = int(2 * (a.shape[axis] - 1) if n is None else n)
|
|
70
|
+
if length == 1: # irfft to one sample is the real part of bin 0
|
|
71
|
+
return mx.real(_one_point(a, axis)).astype(mx.float32)
|
|
72
|
+
if metal_fft_broken(length):
|
|
73
|
+
from . import _fourstep
|
|
74
|
+
|
|
75
|
+
out = _fourstep.irfft_large(a, length, axis)
|
|
76
|
+
if out is not None:
|
|
77
|
+
return out
|
|
78
|
+
return mx.fft.irfft(a, n=n, axis=axis, **_kw(length))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def fft(a, n=None, axis=-1):
|
|
82
|
+
length = int(a.shape[axis] if n is None else n)
|
|
83
|
+
if length == 1: # a one-point DFT is the identity
|
|
84
|
+
return _one_point(a, axis).astype(mx.complex64)
|
|
85
|
+
if metal_fft_broken(length):
|
|
86
|
+
from . import _fourstep
|
|
87
|
+
|
|
88
|
+
out = _fourstep.fft_large(a, length, axis)
|
|
89
|
+
if out is not None:
|
|
90
|
+
return out
|
|
91
|
+
return mx.fft.fft(a, n=n, axis=axis, **_kw(length))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def ifft(a, n=None, axis=-1):
|
|
95
|
+
length = int(a.shape[axis] if n is None else n)
|
|
96
|
+
if length == 1: # a one-point inverse DFT is the identity
|
|
97
|
+
return _one_point(a, axis).astype(mx.complex64)
|
|
98
|
+
if metal_fft_broken(length):
|
|
99
|
+
from . import _fourstep
|
|
100
|
+
|
|
101
|
+
out = _fourstep.ifft_large(a, length, axis)
|
|
102
|
+
if out is not None:
|
|
103
|
+
return out
|
|
104
|
+
return mx.fft.ifft(a, n=n, axis=axis, **_kw(length))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _any_broken(a, s, axes) -> bool:
|
|
108
|
+
lens = s if s is not None else [a.shape[ax] for ax in (axes or range(a.ndim))]
|
|
109
|
+
# length-1 axes also route to the CPU stream: the Metal n-d path returns
|
|
110
|
+
# garbage for a length-1 real transform axis (same bug class as the 1-D
|
|
111
|
+
# wrappers' guards)
|
|
112
|
+
return any(metal_fft_broken(int(x)) or int(x) == 1 for x in lens)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def rfftn(a, s=None, axes=None):
|
|
116
|
+
if _any_broken(a, s, axes):
|
|
117
|
+
return mx.fft.rfftn(a, s=s, axes=axes, stream=mx.cpu)
|
|
118
|
+
return mx.fft.rfftn(a, s=s, axes=axes)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def irfftn(a, s=None, axes=None):
|
|
122
|
+
if _any_broken(a, s, axes):
|
|
123
|
+
return mx.fft.irfftn(a, s=s, axes=axes, stream=mx.cpu)
|
|
124
|
+
return mx.fft.irfftn(a, s=s, axes=axes)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def fftn(a, s=None, axes=None):
|
|
128
|
+
if _any_broken(a, s, axes):
|
|
129
|
+
return mx.fft.fftn(a, s=s, axes=axes, stream=mx.cpu)
|
|
130
|
+
return mx.fft.fftn(a, s=s, axes=axes)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def ifftn(a, s=None, axes=None):
|
|
134
|
+
if _any_broken(a, s, axes):
|
|
135
|
+
return mx.fft.ifftn(a, s=s, axes=axes, stream=mx.cpu)
|
|
136
|
+
return mx.fft.ifftn(a, s=s, axes=axes)
|