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.
@@ -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)