Fourier transforms in a graph#
eagle.fft makes a transform a graph step. It runs on the CPU (pocketfft) or the GPU (cuFFT), allocates nothing when it runs, and can sit inside a captured graph beside your kernels. Each function returns a node for Graph.add_node.
Data layout#
eagle has no complex dtype. Complex data is a float32 or float64 array whose last axis has extent 2 (real part, imaginary part). axes count the data axes only; the trailing 2 is not one. A NumPy complex array is refused by name: pass a float view of (re, im) pairs.
norm is NumPy’s: "backward" (the default: forward unscaled, inverse scaled by 1/n), "forward" or "ortho".
Complex to complex#
import numpy as np
import eagle
import eagle.fft as F
from eagle import arrays
x = np.random.default_rng(0).uniform(-1, 1, (4, 8, 2)) # 4 signals of 8 points, (re, im)
xa = arrays.asarray(x)
ya = arrays.zeros((4, 8, 2), "float64")
g = eagle.Graph().add_node(F.c2c(xa, ya, axes=(1,))).build()
g.launch()
spectrum = np.asarray(ya) # same as np.fft.fft(x[..., 0] + 1j * x[..., 1], axis=1)
The leading axis is the batch: every row is its own transform. forward=False runs the inverse. out is written in place in your buffer, and a read-only out is refused by name. Pass arrays.asarray(..., device="cuda:0") (or a CuPy, torch or DLPack array) to run on the GPU; x and out must be on the same device.
Real signals#
r2c takes a real array and writes the half spectrum, n // 2 + 1 points along the last of axes. c2r goes back and leaves its input unchanged.
r = arrays.asarray(np.random.default_rng(1).uniform(-1, 1, (4, 8)))
half = arrays.zeros((4, 5, 2), "float64")
back = arrays.zeros((4, 8), "float64")
g = (eagle.Graph()
.add_node(F.r2c(r, half, axes=(1,)))
.add_node(F.c2r(half, back, axes=(1,)))
.build())
g.launch()
Derivatives#
A transform is linear, so F.jvp(node, tangent, out) is the same transform of the tangent. F.vjp(node, cotangent, out) is the opposite-direction transform with "backward" and "forward" swapped, so the vjp of the default forward is the unscaled inverse. A real transform also weights the interior of the half spectrum, as the adjoint needs. Both are graph steps, so one graph can hold a forward and its backward:
node = F.r2c(r, half, axes=(1,))
cot = arrays.asarray(np.ones((4, 5, 2)))
bar = arrays.zeros((4, 8), "float64")
g = eagle.Graph().add_node(node).add_node(F.vjp(node, cot, bar)).build()
g.launch()
eagle.diff.Reverse pulls an fft node back through the same rule. See Gradients through a graph.
On the GPU, inside a captured graph#
The transform plan is built when you call F.c2c, F.r2c or F.c2r, never when the graph runs. Building a node while a stream capture is open is refused by name (a plan is built outside a stream capture): create the nodes first, build and capture after. Once built, a replay allocates nothing and gives the same bits each time.
cuFFT is an optional extra#
The CPU path needs nothing extra. The GPU path opens cuFFT the first time a plan is built. Install it with the extra that matches your CUDA:
pip install 'aether-dsc[cufft12]' # CUDA 12
pip install 'aether-dsc[cufft13]' # CUDA 13
Without it, a GPU transform raises aether.FftUnavailable, and the message names the extra to install.
The API is on the Python API page.