Skip to content

Commit 80a97ff

Browse files
committed
fix: norm scaling
1 parent 0517d1b commit 80a97ff

4 files changed

Lines changed: 278 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
### Changed
1212

1313
### Fixed
14+
* Fixed `norm="forward"` and `norm="ortho"` scaling in `mkl_fft.fftn`, `ifftn`, `rfftn`, `irfftn` and the `fft2`/`ifft2`/`rfft2`/`irfft2` family when only a subset of the input axes is transformed and `s` is not given. The scale factor was computed over the full array shape instead of over the transformed axes, over-normalizing the result by the product of the untransformed axis lengths (for example `fft2` on a 3-D array, or `fftn(x, axes=(0,))`). The `mkl_fft.interfaces.numpy_fft` and `mkl_fft.interfaces.scipy_fft` wrappers were unaffected, as they resolve `s` before delegating
15+
* Fixed `norm="forward"` and `norm="ortho"` scaling in `mkl_fft.irfftn` and `irfft2`, which normalized over the input length `n` along the last transformed axis rather than the complex-to-real output length `2 * (n - 1)`. This applied even when every axis was transformed
1416
* Declared `f_ndim` as a C `int` in `_allocate_result` so the buffer size is computed in C rather than through a Python object, resolving a Coverity out-of-bounds (OVERRUN) false positive [gh-364](https://github.com/IntelPython/mkl_fft/pull/364)
1517
* Silenced a Coverity `UNUSED_VALUE` finding in `__create_descriptor_1d` by marking the `DftiFreeDescriptor` status (used only by a debug-only `assert`) as intentionally unused [gh-365](https://github.com/IntelPython/mkl_fft/pull/365)
1618

mkl_fft/_fft_utils.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,43 @@ def _compute_fwd_scale(norm, n, shape):
7878
return np.sqrt(fsc)
7979

8080

81+
def _compute_nd_scale_shape(x, s, axes, norm=None, invreal=False):
82+
"""
83+
Resolve the lengths that a norm-scaled N-D transform normalizes over.
84+
85+
``_compute_fwd_scale`` falls back to the full array shape when ``s`` is
86+
None. That over-normalizes when only a subset of axes is transformed, and
87+
for c2r transforms the basis is the *output* length along the last
88+
transformed axis rather than the input one.
89+
90+
This mirrors what the ``numpy_fft`` and ``scipy_fft`` interfaces already
91+
do by calling ``_cook_nd_args`` before delegating. Only the scale basis is
92+
resolved here; ``s`` itself is left alone so that dispatch in
93+
``_c2c_fftnd_impl`` is unchanged.
94+
95+
``norm`` is accepted only to skip the work for the unscaled norms, whose
96+
scale is 1.0 regardless of shape. Invalid values fall through to
97+
``_compute_fwd_scale``, which validates them.
98+
"""
99+
100+
if s is not None or norm in (None, "backward"):
101+
return s
102+
try:
103+
if axes is None:
104+
ss = list(x.shape)
105+
last = len(ss) - 1
106+
else:
107+
ss = [x.shape[ai] for ai in axes]
108+
last = axes[-1]
109+
if invreal:
110+
ss[-1] = 2 * (x.shape[last] - 1)
111+
except (IndexError, TypeError):
112+
# invalid or empty axes; leave the scale alone and let the
113+
# transform itself raise
114+
return s
115+
return tuple(ss)
116+
117+
81118
def _cook_nd_args(a, s=None, axes=None, invreal=False):
82119
if s is None:
83120
shapeless = True

mkl_fft/_mkl_fft.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
_c2c_fftnd_impl,
2828
_c2r_fftnd_impl,
2929
_compute_fwd_scale,
30+
_compute_nd_scale_shape,
3031
_r2c_fftnd_impl,
3132
)
3233

@@ -68,12 +69,14 @@ def ifft2(x, s=None, axes=(-2, -1), norm=None, out=None):
6869

6970

7071
def fftn(x, s=None, axes=None, norm=None, out=None):
71-
fsc = _compute_fwd_scale(norm, s, x.shape)
72+
ss = _compute_nd_scale_shape(x, s, axes, norm)
73+
fsc = _compute_fwd_scale(norm, ss, x.shape)
7274
return _c2c_fftnd_impl(x, s=s, axes=axes, out=out, direction=+1, fsc=fsc)
7375

7476

7577
def ifftn(x, s=None, axes=None, norm=None, out=None):
76-
fsc = _compute_fwd_scale(norm, s, x.shape)
78+
ss = _compute_nd_scale_shape(x, s, axes, norm)
79+
fsc = _compute_fwd_scale(norm, ss, x.shape)
7780
return _c2c_fftnd_impl(x, s=s, axes=axes, out=out, direction=-1, fsc=fsc)
7881

7982

@@ -96,10 +99,12 @@ def irfft2(x, s=None, axes=(-2, -1), norm=None, out=None):
9699

97100

98101
def rfftn(x, s=None, axes=None, norm=None, out=None):
99-
fsc = _compute_fwd_scale(norm, s, x.shape)
102+
ss = _compute_nd_scale_shape(x, s, axes, norm)
103+
fsc = _compute_fwd_scale(norm, ss, x.shape)
100104
return _r2c_fftnd_impl(x, s=s, axes=axes, out=out, fsc=fsc)
101105

102106

103107
def irfftn(x, s=None, axes=None, norm=None, out=None):
104-
fsc = _compute_fwd_scale(norm, s, x.shape)
108+
ss = _compute_nd_scale_shape(x, s, axes, norm, invreal=True)
109+
fsc = _compute_fwd_scale(norm, ss, x.shape)
105110
return _c2r_fftnd_impl(x, s=s, axes=axes, out=out, fsc=fsc)
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
"""Cross-library equivalence checks for axis and axes dispatch.
2+
3+
``third_party/scipy/test_basic.py::test_fft_with_order`` already checks that
4+
mkl_fft agrees with *itself* across C, Fortran, and non-contiguous layouts. It
5+
does not compare against an external reference, so a dispatch change that is
6+
consistently wrong in every layout passes it.
7+
8+
The defect recorded in ``_fft_utils._iter_complementary`` was exactly that
9+
kind: values correct, but an element placed in the other half of the output
10+
relative to NumPy. These tests therefore use ``numpy.fft`` as the reference.
11+
12+
Two deliberate choices:
13+
14+
* Every axis length differs, so an axis permutation cannot produce a
15+
correctly shaped result and hide behind a shape assertion.
16+
* Output dtype is asserted alongside values, so a dispatch change cannot
17+
silently upcast.
18+
19+
These cover the paths that dispatch on *which* axes are requested: full-axes
20+
transforms reach the batched N-D descriptor, strict subsets iterate the
21+
complementary axes, and 1-D transforms of rank > 2 arrays are batched only for
22+
the first and last axis.
23+
"""
24+
25+
import itertools
26+
27+
import numpy as np
28+
import pytest
29+
from numpy.testing import assert_allclose
30+
31+
import mkl_fft
32+
33+
_SHAPE_3D = (8, 7, 13)
34+
_SHAPE_4D = (4, 5, 6, 7)
35+
36+
_DTYPES = ["float32", "float64", "complex64", "complex128"]
37+
_REAL_DTYPES = ["float32", "float64"]
38+
39+
_ORDERS = ["C", "F", "non-contiguous"]
40+
41+
# Relative tolerance by input precision. Single-precision transforms of
42+
# random data over these lengths stay well inside 2e-5.
43+
_TOL = {
44+
"float32": 2e-5,
45+
"complex64": 2e-5,
46+
"float64": 1e-12,
47+
"complex128": 1e-12,
48+
}
49+
50+
# every non-empty subset of the axes of a 3-D array, plus None
51+
_AXES_3D = [
52+
ax for n in (1, 2, 3) for ax in itertools.combinations(range(3), n)
53+
] + [None]
54+
55+
56+
def _make(shape, dtype, seed=42):
57+
rng = np.random.default_rng(seed)
58+
dt = np.dtype(dtype)
59+
if dt.kind == "c":
60+
x = rng.standard_normal(shape) + 1j * rng.standard_normal(shape)
61+
else:
62+
x = rng.standard_normal(shape)
63+
return x.astype(dt)
64+
65+
66+
def _relayout(x, order):
67+
"""Return *x* laid out as requested; data content may differ by order."""
68+
if order == "F":
69+
return np.asfortranarray(x)
70+
if order == "non-contiguous":
71+
return x[::-1]
72+
return np.ascontiguousarray(x)
73+
74+
75+
def _check(got, want, dtype):
76+
assert got.dtype == want.dtype, f"dtype {got.dtype} != {want.dtype}"
77+
assert got.shape == want.shape, f"shape {got.shape} != {want.shape}"
78+
tol = _TOL[dtype]
79+
assert_allclose(
80+
got, want, rtol=tol, atol=tol * max(1.0, float(np.abs(want).max()))
81+
)
82+
83+
84+
# ---------------------------------------------------------------------------
85+
# N-D complex transforms over a subset of axes
86+
# ---------------------------------------------------------------------------
87+
88+
89+
@pytest.mark.parametrize("func", ["fftn", "ifftn"])
90+
@pytest.mark.parametrize("dtype", _DTYPES)
91+
@pytest.mark.parametrize("axes", _AXES_3D)
92+
@pytest.mark.parametrize("order", _ORDERS)
93+
def test_fftn_axes_subset(func, dtype, axes, order):
94+
x = _relayout(_make(_SHAPE_3D, dtype), order)
95+
got = getattr(mkl_fft, func)(x, axes=axes)
96+
want = getattr(np.fft, func)(x, axes=axes)
97+
_check(got, want, dtype)
98+
99+
100+
@pytest.mark.parametrize("func", ["rfftn", "irfftn"])
101+
@pytest.mark.parametrize("dtype", _DTYPES)
102+
@pytest.mark.parametrize("axes", _AXES_3D)
103+
@pytest.mark.parametrize("order", _ORDERS)
104+
def test_rfftn_axes_subset(func, dtype, axes, order):
105+
if func == "rfftn" and dtype not in _REAL_DTYPES:
106+
pytest.skip("rfftn takes real input")
107+
x = _relayout(_make(_SHAPE_3D, dtype), order)
108+
got = getattr(mkl_fft, func)(x, axes=axes)
109+
want = getattr(np.fft, func)(x, axes=axes)
110+
_check(got, want, dtype)
111+
112+
113+
# ---------------------------------------------------------------------------
114+
# 1-D transforms along each axis of a higher-rank array
115+
# ---------------------------------------------------------------------------
116+
117+
118+
@pytest.mark.parametrize("func", ["fft", "ifft"])
119+
@pytest.mark.parametrize("dtype", _DTYPES)
120+
@pytest.mark.parametrize("axis", range(len(_SHAPE_3D)))
121+
@pytest.mark.parametrize("order", _ORDERS)
122+
def test_fft_axis_3d(func, dtype, axis, order):
123+
x = _relayout(_make(_SHAPE_3D, dtype), order)
124+
got = getattr(mkl_fft, func)(x, axis=axis)
125+
want = getattr(np.fft, func)(x, axis=axis)
126+
_check(got, want, dtype)
127+
128+
129+
@pytest.mark.parametrize("func", ["fft", "ifft", "rfft"])
130+
@pytest.mark.parametrize("dtype", ["float64", "complex128"])
131+
@pytest.mark.parametrize("axis", range(len(_SHAPE_4D)))
132+
@pytest.mark.parametrize("order", _ORDERS)
133+
def test_fft_axis_4d(func, dtype, axis, order):
134+
"""A rank-4 array has two interior axes, so the per-vector fallback in the
135+
C backend is exercised twice within one sweep.
136+
"""
137+
if func == "rfft" and dtype != "float64":
138+
pytest.skip("rfft takes real input")
139+
x = _relayout(_make(_SHAPE_4D, dtype), order)
140+
got = getattr(mkl_fft, func)(x, axis=axis)
141+
want = getattr(np.fft, func)(x, axis=axis)
142+
_check(got, want, dtype)
143+
144+
145+
@pytest.mark.parametrize("func", ["rfft", "irfft"])
146+
@pytest.mark.parametrize("dtype", _DTYPES)
147+
@pytest.mark.parametrize("axis", range(len(_SHAPE_3D)))
148+
@pytest.mark.parametrize("order", _ORDERS)
149+
def test_rfft_axis_3d(func, dtype, axis, order):
150+
if func == "rfft" and dtype not in _REAL_DTYPES:
151+
pytest.skip("rfft takes real input")
152+
x = _relayout(_make(_SHAPE_3D, dtype), order)
153+
got = getattr(mkl_fft, func)(x, axis=axis)
154+
want = getattr(np.fft, func)(x, axis=axis)
155+
_check(got, want, dtype)
156+
157+
158+
# ---------------------------------------------------------------------------
159+
# norm interacts with the scale factor applied at dispatch time
160+
# ---------------------------------------------------------------------------
161+
162+
163+
@pytest.mark.parametrize("func", ["fftn", "ifftn"])
164+
@pytest.mark.parametrize("dtype", ["float64", "complex128"])
165+
@pytest.mark.parametrize("axes", [(0,), (1,), (2,), (1, 2), None])
166+
@pytest.mark.parametrize("norm", [None, "backward", "forward", "ortho"])
167+
def test_fftn_axes_subset_norm(func, dtype, axes, norm):
168+
x = _make(_SHAPE_3D, dtype)
169+
got = getattr(mkl_fft, func)(x, axes=axes, norm=norm)
170+
want = getattr(np.fft, func)(x, axes=axes, norm=norm)
171+
_check(got, want, dtype)
172+
173+
174+
@pytest.mark.parametrize("func", ["rfftn", "irfftn"])
175+
@pytest.mark.parametrize("dtype", ["float64", "complex128"])
176+
@pytest.mark.parametrize("axes", [(0,), (1,), (2,), (1, 2), None])
177+
@pytest.mark.parametrize("norm", [None, "backward", "forward", "ortho"])
178+
def test_rfftn_axes_subset_norm(func, dtype, axes, norm):
179+
"""Includes ``axes=None``: for c2r the scale basis is the *output* length
180+
along the last transformed axis, so a full-axes irfftn is normalized over
181+
``2 * (n - 1)`` rather than ``n``.
182+
"""
183+
if func == "rfftn" and dtype != "float64":
184+
pytest.skip("rfftn takes real input")
185+
x = _make(_SHAPE_3D, dtype)
186+
got = getattr(mkl_fft, func)(x, axes=axes, norm=norm)
187+
want = getattr(np.fft, func)(x, axes=axes, norm=norm)
188+
_check(got, want, dtype)
189+
190+
191+
@pytest.mark.parametrize("func", ["fft2", "ifft2", "rfft2", "irfft2"])
192+
@pytest.mark.parametrize("dtype", ["float64", "complex128"])
193+
@pytest.mark.parametrize("norm", [None, "backward", "forward", "ortho"])
194+
def test_fft2_on_3d_norm(func, dtype, norm):
195+
"""``fft2`` on a rank-3 array transforms 2 of 3 axes, so it is a subset
196+
transform even though the caller passed no ``axes``.
197+
"""
198+
if func == "rfft2" and dtype != "float64":
199+
pytest.skip("rfft2 takes real input")
200+
x = _make(_SHAPE_3D, dtype)
201+
got = getattr(mkl_fft, func)(x, norm=norm)
202+
want = getattr(np.fft, func)(x, norm=norm)
203+
_check(got, want, dtype)
204+
205+
206+
@pytest.mark.parametrize("func", ["fft", "ifft"])
207+
@pytest.mark.parametrize("dtype", ["float64", "complex128"])
208+
@pytest.mark.parametrize("axis", range(len(_SHAPE_3D)))
209+
@pytest.mark.parametrize("norm", [None, "backward", "forward", "ortho"])
210+
def test_fft_axis_norm(func, dtype, axis, norm):
211+
x = _make(_SHAPE_3D, dtype)
212+
got = getattr(mkl_fft, func)(x, axis=axis, norm=norm)
213+
want = getattr(np.fft, func)(x, axis=axis, norm=norm)
214+
_check(got, want, dtype)
215+
216+
217+
# ---------------------------------------------------------------------------
218+
# out= must not change results on any dispatch path
219+
# ---------------------------------------------------------------------------
220+
221+
222+
@pytest.mark.parametrize("dtype", ["complex64", "complex128"])
223+
@pytest.mark.parametrize("axes", _AXES_3D)
224+
def test_fftn_axes_subset_out(dtype, axes):
225+
x = _make(_SHAPE_3D, dtype)
226+
want = np.fft.fftn(x, axes=axes)
227+
out = np.empty(want.shape, dtype=x.dtype)
228+
got = mkl_fft.fftn(x, axes=axes, out=out)
229+
assert got is out, "out= should be returned"
230+
_check(got, want, dtype)

0 commit comments

Comments
 (0)