KDA 3D FFTs on Packed Hermitian Half-Spectra

CuTe-DSL forward (real-to-complex) and inverse (complex-to-real) unnormalized 3D FFTs of one real grid, in fp32 and fp64, for the NVIDIA RTX 5090. The spectrum is the packed half-spectrum that structured-grid spectral solvers use: x innermost, interleaved (re, im), an x-row stride of exactly nx // 2 + 1. The results are those of torch.fft.rfftn and torch.fft.irfftn(..., norm="forward").

Against cuFFT's own 3D plans, geometric mean over eight grids from 32^3 to 256^3: r2c 1.99x and c2r 1.85x in fp32, r2c 2.63x and c2r 2.58x in fp64. Against torch.fft, which runs a 3D transform as two cuFFT plans and a copy, about 3x. Any grid runs FFT kernels: on random grids up to 256 per axis, 1.5x to 1.9x over cuFFT (geomean), and no sampled grid slower than cuFFT.

Requirements

  • NVIDIA GPU with compute capability 12.0; tuned and tested on the RTX 5090 only
  • CUDA 13 compatible driver
  • PyTorch 2.12.1 with CUDA 13.0 (tested)
  • nvidia-cutlass-dsl[cu13]==4.6.0 (tested)

Usage

import torch
from kernels import get_kernel

fft3d = get_kernel("humanfia-lab/fft3d", version=1, trust_remote_code=True)

u = torch.randn(96, 96, 144, device="cuda")  # [nz, ny, nx], fp32 or fp64
spectrum = fft3d.r2c(u)  # [96, 96, 73, 2] == torch.view_as_real(torch.fft.rfftn(u))
back = fft3d.c2r(spectrum)  # [96, 96, 144], unnormalized: 96 * 96 * 144 * u

launch = fft3d.prepare_r2c(u)  # plan once for this buffer...
u.copy_(torch.randn_like(u))
spectrum = launch()  # ...then transform its current contents
  • r2c(x) takes a contiguous [nz, ny, nx] CUDA tensor, float32 or float64. c2r(X) takes a [nz, ny, nf, 2] half-spectrum of the same dtypes and returns [nz, ny, 2 * (nf - 1)]. Each call returns a new tensor and leaves its input unchanged. Other dtypes, devices and shapes raise TypeError or ValueError. The work runs on the input's device (which need not be the current one), on its current CUDA stream.
  • prepare_r2c(x) and prepare_c2r(X) plan for that input buffer and return launch(), which transforms the buffer's current contents into one output tensor that it reuses on every call. Calls of one launch must be ordered (one stream). Keep launch alive as long as a CUDA graph captured from it. fp64 r2c has no two-phase form; its launch() calls r2c(x). A launch() on another stream than the one prepare_* ran on waits for prepare_*'s work and keeps its output and workspaces reserved for that stream's work, even if the launch object is dropped before the work ran.
  • Calls on different CUDA streams may run concurrently, including calls of the same shape. Calls may also come from several threads: CuTe DSL is not thread-safe while it compiles or makes the first call of a compiled function, so a new shape's plan is built, and makes its first call, under a lock that all kda kernels share (later calls take no lock).
  • A call costs about 13 to 17 us of host time (TVM-FFI launches on the current stream). Timed per call as the requests' benchmark does (flashinfer-bench: synchronize, event, call, event), every kernel is faster than torch.fft on every grid tried, the smallest included (see Performance).
  • The first call for a new shape compiles its kernels (CuTe DSL JIT), typically in 1 to 15 s, up to about 4 minutes for a very long z axis over a tiny y-x plane (such as 4096x8x16). That first call may itself be inside CUDA graph capture.
  • c2r expects a Hermitian-consistent half-spectrum, such as the output of r2c or Fourier-space data that keeps its symmetry. On other input the result depends on the order of the 1D passes, as with cuFFT; these kernels then match torch.fft.irfftn.
  • Tuned on the eight grids below; any grid with nz, ny >= 8 and nx >= 16 is accepted. Within 256 per axis every grid of every kernel runs FFT kernels, odd and prime extents included, checked on RTX 5090s by sweeps over every plan signature, and on grids up to 1024x1024x512. Beyond 256 per axis, axis lengths with two prime factors above 16 (fp32 c2r) or a squared prime factor above 13 (fp64, such as 289) still take slow generic kernels. fp64 r2c raises NotImplementedError when a y or z axis needs a direct DFT longer than 6336 points; fp64 c2r raises it for nz from 1280 to 4096, ny from 3072 to 4096 or nx / 2 from 3072 to 4096. The fp64 kernels address with 32-bit offsets: fp64 r2c raises ValueError past 2^30 complex outputs and fp64 c2r on 2^31 or more half-spectrum elements.

Accuracy

fp32 kernels compute in fp32 from twiddle tables computed in float64 on the host: no reduced precision, no tensor cores, no approximate math. Against the float64 result the relative L2 error is 1.4e-7 to 1.8e-7 on the grids below, where cuFFT's is 2.0e-7 to 2.8e-7. Every element is within 1e-2, or within 1e-3 of its own magnitude. On other grids within 256 per axis the relative L2 error is 1e-7 to 3.4e-7, every element passes that rule on random input, and large prime extents stay below cuFFT's error (c2r of a random 251x251x254 half-spectrum: max 0.006, cuFFT 0.016).

fp64 kernels are IEEE float64 throughout, within 3.5e-16 to 5.9e-16 relative L2 of torch.fft's float64 result. The RTX 5090 runs FP64 at 1/64 of the FP32 rate, so both fp64 kernels compute part of their float64 additions with a software IEEE add (round to nearest even) on the integer units, which run alongside the FP64 unit. It returns exactly what the FP64 unit returns for every input, subnormal operands and results, overflow, signed zeros, Inf and NaN included (2.5e10 operand pairs per routine compared with the native instructions). Until 2026-10-24 it was exact only for finite normal operands and results, and data near the bottom of the normal range came out wrong (see Changes). Data with a large mean keeps cuFFT-level accuracy or better in every kernel.

Repeated calls give bitwise identical results.

Performance

Speedup over cuFFT's 3D plan:

grid (nz x ny x nx) r2c fp32 c2r fp32 r2c fp64 c2r fp64
32x32x32 3.33x 2.60x 3.93x 2.22x
64x64x64 2.13x 2.00x 2.48x 2.40x
80x80x112 2.22x 1.85x 2.63x 2.67x
96x96x96 2.24x 2.14x 2.43x 2.52x
96x96x144 1.92x 1.77x 2.13x 2.39x
128x128x128 1.80x 1.65x 2.51x 2.91x
192x192x192 1.39x 1.44x 2.75x 2.95x
256x256x256 1.42x 1.59x 2.47x 2.65x
geomean 1.99x 1.85x 2.63x 2.58x
geomean, 128^3 and up 1.53x 1.56x 2.58x 2.83x

Times in microseconds (torch.fft / cuFFT 3D plan / kernel):

grid r2c fp32 c2r fp32 r2c fp64 c2r fp64
32x32x32 10.4 / 8.6 / 2.6 9.3 / 7.0 / 2.7 26.4 / 26.9 / 6.8 20.5 / 19.1 / 8.6
64x64x64 11.6 / 9.4 / 4.4 14.3 / 9.0 / 4.5 35.3 / 37.4 / 15.1 41.5 / 37.5 / 15.6
80x80x112 21.1 / 15.1 / 6.8 20.1 / 13.9 / 7.5 98.7 / 92.7 / 35.2 83.6 / 89.7 / 33.6
96x96x96 23.4 / 16.5 / 7.4 25.6 / 16.7 / 7.8 96.4 / 88.8 / 36.5 96.2 / 89.4 / 35.5
96x96x144 31.4 / 18.5 / 9.6 31.4 / 18.7 / 10.6 128.1 / 115.8 / 54.5 146.0 / 116.1 / 48.6
128x128x128 42.0 / 24.2 / 13.4 56.8 / 23.4 / 14.2 201.4 / 193.9 / 77.1 233.7 / 205.0 / 70.4
192x192x192 123.2 / 66.9 / 48.1 126.4 / 71.2 / 49.3 701.7 / 642.8 / 233.5 682.8 / 634.7 / 215.0
256x256x256 346.8 / 163.0 / 114.5 300.2 / 187.4 / 117.5 1688.8 / 1323.1 / 536.0 1671.9 / 1337.3 / 505.1

On small grids cuFFT's several kernels are launch-bound, and one or two fused kernels win most. On large fp32 grids the kernels make fewer passes over memory. In fp64 the FP64 unit is the limit, and the integer-unit adder adds throughput beside it.

Measurements use one RTX 5090 (driver 580.82.07) with CUPTI cold-L2 kernel time over CUDA graph replay, 3 warmup and 30 measured iterations, median of 3 trials, each trial timing cuFFT before and after the kernel. cuFFT is the cuFFT 12.0 that PyTorch 2.12.1+cu130 ships, as one cufftPlan3d per grid made before timing and executed out of place; its C2R overwrites its input and is given a copy. torch.fft is rfftn / irfftn on the same library. The fp64 columns were measured again after the IEEE-complete adders (see Changes), on an RTX 5090 that runs both cuFFT and the kernels 10-20% slower in absolute time; the speedups moved by at most 2%. Speeds differ by a few percent between RTX 5090 cards.

Beyond the eight grids, against cuFFT's 3D plans on fixed seeded samples of the requests' range (nz, ny 8..256, nx 16..256, even nx for c2r; random input), geomean with the slowest grid in brackets:

sample r2c fp32 c2r fp32 r2c fp64 c2r fp64
60 uniform 1.81x (1.15x) 1.63x (1.11x) 1.93x (1.15x) 1.67x (1.18x)
60 with 7-smooth axes 1.49x (1.06x) 1.81x (1.28x) 1.83x (1.29x) 1.76x (1.17x)
40 with a prime factor >= 17 1.72x (1.22x) 1.62x (1.15x) 1.88x (1.20x) 1.71x (1.16x)

Every sampled grid is faster than cuFFT's 3D plan and than torch.fft. Large prime extents gain most: 251x251x254 takes 0.25 ms in fp32 r2c (cuFFT 0.59 ms) and 2.6 ms in fp64 c2r (cuFFT 4.9 ms).

Timed per call the way the requests' benchmark (flashinfer-bench 0.1.2) does it, on fresh copies of the input with a cleared L2 and the host's launch work included, against the requests' baseline (torch.fft):

eight grids (geomean) 8x8x16 to 48x40x36 (slowest)
r2c fp32 1.91x 1.54x
c2r fp32 1.72x 1.11x
r2c fp64 2.20x 1.29x
c2r fp64 2.23x 1.12x

Changes

Updated on 2026-10-24 with fixes found by sweeping every planner signature over nz, ny in 8..256 and nx in 16..256 (the requests' range), on a B300 with the RTX 5090's plans:

  • fp32 r2c returned garbage, often NaN, on 11,765 grids of that range, all with nx >= 84 and ny >= 84 (such as 8x126x140, 8x252x156, 70x104x224): their plane or y-split plan needs a whole plane or sub-plane as one row block, and the x pass halved the rows that did not fit in shared memory. Those grids now take another plan; the plans of the eight grids in the tables above are unchanged.
  • The fp64 software adders were exact only for finite normal operands and results: fp64 r2c of a 96x96x144 grid with a quarter of its z planes at 1e-308 to 1e-300 returned NaN everywhere, and data scaled by 1e-300 or 1e-310, subnormal bands of a spectrum and single NaN or Inf inputs failed on many grids. They now handle every IEEE case, and fp64 c2r no longer routes 2x - y through software on five tuned grids. On an RTX 5090 the eight grids' speedups moved by at most 2% (the fp64 columns above).
  • fp64 inputs on a CUDA device other than the current one raised (ValueError, BufferError); every call now runs under the input's device.

Updated again on 2026-10-24 after a sweep on RTX 5090s over every plan signature of the four kernels within 256 per axis (about 45,000 grids, random and Hermitian c2r input, spectrum-shaped and offset data, prepare, graph and two-stream checks):

  • fp32 c2r missed the 1e-2 / 1e-3 tolerance on about 6,900 grids of 6.5M points and more with a large prime extent (such as 251x251x254) on random half-spectra, the requests' own input: up to 69 elements per grid off by up to 0.03, where cuFFT's fp32 plan has none. Now at most 0.006.
  • fp32 r2c returned garbage on grids of 2^24 points and more with an odd ny (such as 256x297x256, beyond 256 per axis).
  • Most grids ran slow fallbacks: fp32 r2c ran every grid whose nx is not a multiple of 4 or that has a prime factor above 13 (96.8% of the range) as O(n^2) direct DFTs, 17 to 180 times slower than torch.fft (96x96x98: 0.97 ms against cuFFT's 0.020 ms); fp32 c2r ran 77% of the range on a global-memory generic path, and fp64 ran axes with a prime factor above 13 as direct DFTs (fp64 c2r: the whole grid, about 90% of the range), 3 to 10 times slower than cuFFT. They now run general FFT kernels (two real rows per complex FFT for odd x, Rader's or Bluestein's algorithm for large primes), faster than cuFFT on every sampled grid (Performance). Sweeps over every new kernel signature found no wrong result.
  • fp64 r2c on data with a large mean (randn + 1e3) missed atol 1e-6 on 21 of 100 sample grids of 1M points and more, where cuFFT passed; now at most 1.07x cuFFT's error.
  • Per-call host time: the fp64 kernels spent about 40 to 50 us of host time per call (0.48x to 0.69x of torch.fft on small grids when timed per call, as the requests' benchmark does). All kernels now launch through TVM-FFI, at about 13 to 17 us; outputs are bitwise unchanged. fp64 c2r now transforms an input that is not 16-byte aligned from an aligned copy instead of raising.

The eight grids keep their plans and their outputs are bitwise unchanged.

Updated on 2026-10-27 with the fixes of a second audit on RTX 5090s and B300s (threads, streams, CUDA graph capture, 32-bit limits; no wrong result was found within 256 per axis):

  • Threads making first calls at once failed: CuTe DSL is not thread-safe while it compiles or makes the first call of a TVM-FFI function. Concurrent plan builds raised MLIR errors or ended the process, and two first calls of one shape at once left a spin lock held, so that the next first call of any kernel in the process hung for good. Plans are now built, and make their first call, under one process-wide lock; later calls take none.
  • The first call of a shape inside CUDA graph capture raised; its tables are now copied on a stream of the package's own, outside the graph.
  • A launch() right after prepare_* on another stream could run before work still queued on prepare_*'s stream, which then overwrote its output, and a launch object dropped before its kernels ran handed its buffers to new tensors on prepare_*'s stream. Such a launch now waits for prepare_*'s stream and keeps its buffers until its work is done; same-stream launches are unchanged.
  • fp32 r2c transforms a contiguous input that is not 16-byte aligned from an aligned copy instead of raising.
  • fp64 r2c past 2^30 complex outputs (an illegal memory access) and fp64 c2r on 2^31 or more half-spectrum elements (a wrong result) now raise ValueError, beyond 256 per axis.
  • fp64 axes of 1529 points and more whose big-prime tile does not fit in shared memory failed to compile (such as 8x6329x16); they keep the direct DFT.

The eight grids keep their plans; their outputs and speeds are unchanged.

Source and licensing

Written by Claude Opus 5.5 in four 12-hour runs of KDA (kernel design agents), one per KDA task (fft3d_r2c, fft3d_c2r, fft3d_r2c_f64, fft3d_c2r_f64), built from the requests in NVlabs/kda#7, and accepted by the KDA judge. Published from commit e539498 of humanfia/kda-kernels-internal, directory rtx5090/fft3d/ (now sm120/fft3d/), which also holds its tests and the fixes made after the judge's checks; updated from commits b0811f8 and 2238a9e (see Changes). MIT-licensed.

Available functions

  • c2r
  • prepare_c2r
  • prepare_r2c
  • r2c
Downloads last month
-
cuda
cutlass
cute-dsl
fft
fp32
fp64
blackwell
rtx-5090
MIT
Supported hardwares new
CUDA
12.0
DGX Spark
GB10
128GB
GPU
RTX PRO 6000 WS
96GB
GPU
RTX PRO 6000 Max-Q
96GB
GPU
RTX PRO 5000
48GB
GPU
RTX PRO 4500 WS
32GB
RTX
RTX 5090
32GB
RTX
RTX 5090 D
32GB
GPU
RTX PRO 4000
24GB
GPU
RTX PRO 4000 SFF
24GB
RTX
RTX 5090 Mobile
24GB
GPU
RTX PRO 2000
16GB
RTX
RTX 5080
16GB
RTX
RTX 5080 Mobile
16GB
RTX
RTX 5070 Ti
16GB
RTX
RTX 5060 Ti
16GB
RTX
RTX 5070
12GB
RTX
RTX 5070 Ti Mobile
12GB
RTX
RTX 5070 Mobile
8GB
RTX
RTX 5060
8GB
RTX
RTX 5060 Mobile
8GB
RTX
RTX 5050
8GB
RTX
RTX 5050 Mobile
8GB
Kernel Builder
375561f