Core Concepts#
This page explains the mathematical foundations of the NUFFT and how nufftax implements them.
The Problem: Non-Uniform Data#
The standard FFT computes the Discrete Fourier Transform (DFT):
This assumes your data f[j] lives on a uniform grid with spacing \(\Delta x = 1/N\).
But what if your samples are at arbitrary locations \(x_j\)? You need the Non-Uniform DFT:
where \(x_j \in [-\pi, \pi)\) are non-uniform sample locations and \(c_j\) are the values at those locations.
Computing this directly costs \(O(MN)\) operations. The NUFFT reduces this to \(O(M + N \log N)\).
The Three Transform Types#
nufftax provides three types of NUFFT, each solving a different problem:
Type 1: Nonuniform → Uniform#
Given: Values \(c_j\) at scattered points \(x_j\)
Compute: Fourier coefficients \(f_k\) on a regular frequency grid
Use when: You have scattered measurements and want their frequency content.
from nufftax import nufft1d1
# Scattered measurement locations
x = jnp.array([0.1, 0.5, 1.2, -0.8, 2.3])
# Values at those locations
c = jnp.array([1+0j, 2+1j, 0.5-0.5j, 1+1j, 0.3+0j])
# Get 64 Fourier modes
f = nufft1d1(x, c, n_modes=64, eps=1e-6)
Type 2: Uniform → Nonuniform#
Given: Fourier coefficients \(f_k\) on a regular grid
Compute: Values \(c_j\) at arbitrary query points \(x_j\)
Use when: You have a frequency representation and want to evaluate it at specific points.
from nufftax import nufft1d2
# Fourier coefficients (e.g., from a previous analysis)
f = jnp.zeros(64, dtype=jnp.complex64)
f = f.at[10].set(1.0) # Single frequency component
# Query locations
x = jnp.linspace(-jnp.pi, jnp.pi, 100)
# Evaluate the Fourier series at those points
c = nufft1d2(x, f, eps=1e-6)
Type 3: Nonuniform → Nonuniform#
Given: Values \(c_j\) at scattered source points \(x_j\)
Compute: Transform at scattered target frequencies \(s_k\)
Use when: Both your sample locations and your target frequencies are irregular.
from nufftax import nufft1d3, compute_type3_grid_size
# Source points and values
x = jnp.array([0.1, 0.5, 1.2, -0.8])
c = jnp.array([1+0j, 2+1j, 0.5-0.5j, 1+1j])
# Target frequencies (not on a regular grid)
s = jnp.array([0.3, 1.7, 5.2, -2.1, 8.0])
# Pre-compute grid size for JIT (see below)
n_modes = compute_type3_grid_size(x, s, eps=1e-6)
# Compute the transform
f = nufft1d3(x, c, s, n_modes=n_modes, eps=1e-6)
How the Algorithm Works#
The NUFFT achieves its speed through a three-step pipeline:
Nonuniform points → [Spread] → Fine grid → [FFT] → Freq grid → [Deconvolve] → Output modes
Spreading (Type 1) / Interpolation (Type 2)
Nonuniform point values are “spread” onto a fine uniform grid using a carefully chosen kernel function. nufftax uses the Exponential of Semicircle (ES) kernel:
\[\phi(z) = e^{\beta(\sqrt{1 - z^2} - 1)}\]This kernel has excellent accuracy properties - it achieves near-optimal error for a given support width.
FFT
A standard FFT is applied to the fine grid. The grid is typically 2x oversampled (controlled internally) to ensure accuracy.
Deconvolution
The FFT result is divided by the Fourier transform of the spreading kernel, correcting for the smoothing introduced in step 1.
Precision Control#
The eps parameter controls the accuracy/speed tradeoff:
|
Accuracy |
Speed |
|---|---|---|
|
~1% relative error |
Fastest (small kernel) |
|
~0.0001% relative error |
Good balance (default) |
|
Near machine precision |
Slower (large kernel) |
The kernel width scales roughly as \(\lceil \log_{10}(1/\text{eps}) \rceil + 1\) grid points.
# Fast but approximate
f_fast = nufft1d1(x, c, n_modes=64, eps=1e-2)
# High precision
f_precise = nufft1d1(x, c, n_modes=64, eps=1e-12)
Oversampling factor (upsampfac)#
All transforms accept upsampfac, the oversampling factor of the internal
fine grid (default 2.0). A smaller factor such as 1.25 shrinks the grid
and is faster, at the cost of accuracy — note that nufftax uses a generic kernel
formula, so at 1.25 it does not reach the same precision as the hand-tuned
FINUFFT kernels.
f = nufft2d1(x, y, c, n_modes=(64, 64), upsampfac=1.25) # faster, coarser
For Type 1 / Type 2, upsampfac may also be a (forward, backward) pair:
the forward transform and its adjoint (used in reverse-mode AD) need not share
the same oversampling, which can be tuned independently for performance.
# forward at 2.0, gradient/adjoint at 1.25
f = nufft2d1(x, y, c, n_modes=(64, 64), upsampfac=(2.0, 1.25))
Coordinate Conventions#
Input range: Sample locations should be in \([-\pi, \pi)\).
# Correct: points in [-pi, pi)
x = jnp.array([-2.5, -0.5, 0.3, 1.8, 2.9])
# Also works: automatic wrapping is applied
x = jnp.array([0.0, 1.0, 7.0, -8.0]) # Will be wrapped to [-pi, pi)
Output modes: For n_modes=N, the output contains modes \(k = -N/2, ..., N/2-1\).
Sign convention: The isign parameter controls the sign of the exponent:
isign=+1(default for Type 1): Uses \(e^{+ikx}\) conventionisign=-1(default for Type 2): Uses \(e^{-ikx}\) convention
Automatic Differentiation#
A key feature of nufftax is full differentiability. The gradients are computed efficiently using the mathematical relationship between Type 1 and Type 2 transforms:
Key insight: Type 1 and Type 2 are adjoints of each other.
This means:
The gradient of Type 1 w.r.t.
cis computed using Type 2The gradient of Type 2 w.r.t.
fis computed using Type 1
For gradients w.r.t. the point locations x, the kernel derivative is used.
import jax
# Gradient w.r.t. values (uses Type 2 internally)
def loss_values(c):
f = nufft1d1(x, c, n_modes=64)
return jnp.sum(jnp.abs(f) ** 2)
grad_c = jax.grad(loss_values)(c)
# Gradient w.r.t. locations (uses kernel derivative)
def loss_positions(x):
f = nufft1d1(x, c, n_modes=64)
return jnp.sum(jnp.abs(f) ** 2)
grad_x = jax.grad(loss_positions)(x)
Custom Spreading Kernels#
The spreading and interpolation primitives are exposed directly, so you can use them on their own — without the full NUFFT pipeline — and with your own kernel instead of the built-in ES kernel. A typical use case is spreading short-range Gaussians (or any localized bump) onto a grid.
The primitives live in nufftax.core:
spread_1d/2d/3d— scatter point values onto a grid: \(\text{fw}[k] = \sum_j c_j\, \phi(k - \tilde{x}_j)\)interp_1d/2d/3d— the adjoint gather: \(c_j = \sum_k \text{fw}[k]\, \phi(k - \tilde{x}_j)\)
where \(\tilde{x}_j\) is the point mapped to grid units and \(\phi\) is
the kernel, nonzero only over nspread neighbouring grid points. By default
they use the built-in ES kernel (the same one the nufftNdM transforms use).
To use your own kernel, pass a Kernel instead — defined by its support
width and value function. The example below is a truncated Gaussian:
import jax.numpy as jnp
from nufftax.core import spread_1d, interp_1d, Kernel
# phi must be pure jnp arithmetic (it is also lowered into the GPU kernel)
def phi(z):
return jnp.exp(-0.5 * (z / 1.5) ** 2)
# Optional analytic derivative (z -> (phi, dphi/dz)); used for gradients
# w.r.t. the point coordinates. If omitted, it is obtained by autodiff.
def phi_and_dphi(z):
p = phi(z)
return p, -(z / 1.5**2) * p
kernel = Kernel(nspread=10, phi=phi, phi_and_dphi=phi_and_dphi)
fw = spread_1d(x, c, 256, kernel) # spread Gaussians onto the grid
c2 = interp_1d(x, fw, 256, kernel) # adjoint gather
These primitives keep full grad/vjp support, including gradients w.r.t.
the point coordinates (which use the kernel derivative).
Note
GPU. When the Pallas backend is enabled (opt-in; see
NUFFTAX_PALLAS_BACKEND below), custom kernels run through the same fused
Pallas spreading kernels as the ES kernel — phi is threaded into the
Triton kernel as a static closure — so there is no performance penalty beyond
the cost of phi itself. This requires phi to be pure jnp
arithmetic (no Python control flow on traced values) and passed as a static
argument; a distinct kernel triggers one Pallas recompilation.
Note
Selecting the backend. The fused Pallas GPU spreading kernels are
opt-in: set NUFFTAX_PALLAS_BACKEND=1 to enable them (much faster
spreading for large problems on GPU). By default the pure-JAX path is used
everywhere — more robust across JAX versions and GPU backends. On CPU the
pure-JAX path is always used.
Note
Custom kernels apply to the standalone spread_* / interp_*
primitives. The full nufftNdM transforms still use the ES kernel, whose
Fourier series is needed for the deconvolution step.
Type 3 and JIT Compilation#
Type 3 transforms require knowing the output grid size at JIT compile time. Use the helper functions:
from nufftax import compute_type3_grid_size
# The grid size depends on the "spread" of your source and target points
n_modes = compute_type3_grid_size(x, s, eps=1e-6)
# Now you can JIT the transform
@jax.jit
def my_type3(x, c, s):
return nufft1d3(x, c, s, n_modes=n_modes, eps=1e-6)
result = my_type3(x, c, s)
For 2D and 3D Type 3 transforms, use compute_type3_grid_sizes_2d and compute_type3_grid_sizes_3d.
Further Reading#
FINUFFT documentation - The C++ library that inspired this implementation
Accelerating the Nonuniform FFT - The foundational paper by Greengard & Lee