nvsubquadratic.ops: FFT convolution primitives#
This folder contains the lowest-level building blocks of the library: FFT-based convolution operators that turn an O(N · K) spatial convolution into an O(N log N) frequency-domain product. Every subquadratic mixer in the library (Hyena, CKConv, multi-head variants) is built on them. They are kept here as plain functions, with no nn.Module state and no learned parameters, so that higher-level modules can compose them freely.
If you are reading the paper alongside this codebase, this is the file to start with.
Why FFT convolution?#
A standard spatial convolution between an input x of length N and a kernel k of length K,
costs O(N · K) per channel. When K is small (e.g. a 3×3 image kernel) that is fine. The trouble starts when K is comparable to N, the regime Hyena-style models live in, where each layer’s effective receptive field can span the whole input. There the spatial cost grows quadratically with sequence length.
The convolution theorem lets us replace the spatial convolution with an element-wise product in the frequency domain:
The two FFTs and the inverse each cost O(N log N), the element-wise product is O(N), and the total cost is independent of kernel size. That is what makes “global-kernel” convolutional sequence models subquadratic.
Two flavours show up throughout the folder:
Flavour |
What it computes |
When to use |
|---|---|---|
Linear ( |
Standard convolution, zero-padded so no wrap-around occurs, then cropped to “same” size. |
Default choice; matches |
Circular ( |
Periodic convolution where the kernel wraps around the input boundary. |
When you want global mixing under periodic boundary conditions, or when input and kernel are the same size (no padding needed → cheaper). |
File map#
File |
Precision |
Conv type |
Channel mixing |
When you’d reach for it |
|---|---|---|---|---|
fp32 |
linear |
depthwise |
The default. 1D/2D/3D, causal & non-causal. |
|
fp32 |
circular |
depthwise |
Periodic boundaries (e.g. PDEs, ARC grids), or when |
|
fp32 |
per-axis BC |
depthwise |
Mixed boundaries: periodic on some spatial axes, zero-padded on others (e.g. Well’s |
|
fp32 |
linear |
depthwise |
Memory-constrained training; processes channels in chunks. Has a global flag so models can opt in transparently. |
|
fp32 / native |
linear |
depthwise |
Wraps optional fused CUDA kernels ( |
|
native |
linear |
depthwise |
|
|
fp32 |
direct causal |
depthwise |
Non-FFT 1D causal kernels ( |
The FP16 circular FFT convolution report contains the full derivation of the numerically stable fp16 circular conv (dual mean-centering + inclusion-exclusion geometric correction). Read it if you are touching the fp16 path or want to understand the math behind those T1, T2, T3, T4 terms in the code.
Naming convention#
Every function name encodes its contract:
[causal_] fftconv {1d|2d|3d} _ {fp32|fp16} _ {bhl|blh} [_w_reshape] [_chunked]
Part |
Meaning |
|---|---|
|
Output at position |
|
Spatial rank. |
|
Internal compute precision. The output dtype always matches |
|
Memory layout. |
|
Wrapper that accepts BLH input, internally reshapes to BHL (faster), and reshapes back. The recommended entry point for channels-last callers. |
|
Processes channels in groups to reduce peak GPU memory. |
So causal_fftconv1d_fp32_bhl_w_reshape is: causal 1D FFT conv, fp32 internal, accepts channels-last input, internally uses the channels-first kernel.
The CUDA-accelerated wrappers in fftconv_custom.py drop the _fp32_ / _fp16_ token because the underlying kernel manages its own precision internally, so the same name in fftconv_custom is causal_fftconv1d_bhl_w_reshape. The direct-conv wrappers in causal_conv1d_custom.py (causal_conv1d, b2b_causal_conv1d) do not follow this scheme because they are thin pass-throughs to the upstream API; see their docstrings for shapes.
Shape conventions#
Everything in this folder follows two layouts. Pick whichever matches your surrounding module:
BHL (channels-first):
x: [B, H, *spatial],kernel: [1|B, H, *K_dims]. Standard fortorch.nn.ConvNd-style modules. Faster under the hood because FFT runs on contiguous spatial axes without a transpose.BLH (channels-last):
x: [B, *spatial, H],kernel: [1|B, *K_dims, H]. Common in transformer-style code. Use the_w_reshapevariants.
The kernel’s leading dim is either 1 (shared kernel across the batch, the standard depthwise case) or B (per-sample kernel, e.g. FiLM-conditioned Hyena where each sample gets its own kernel).
The shortcut term#
Every operator accepts an optional shortcut: [H] tensor and computes
i.e. a per-channel residual scale. This is not a generic skip connection; it fuses a specific algebraic shortcut that shows up repeatedly in Hyena-style gating, saving a separate kernel launch. Pass None if you don’t need it.
Choosing a function: a decision tree#
Do I need periodic boundaries?
Yes →
circular_fftconv*. The kernel wraps around the input; useful for PDE-like signals or whenever the input is naturally periodic.No →
fftconv*. The default.
Is my model causal (1D sequence)?
Yes → use the
causal_*variant. Slightly more padding (L + Kinstead ofL + K/2), but enforces no information leak from the future.No → use the non-causal variant. Cheaper, since you only pad by
K/2.
What’s my hidden layout?
Channels-first (
[B, H, …]) → use_bhldirectly.Channels-last (
[B, …, H]) → use_bhl_w_reshape. Benchmarks show this is faster than a true_blhop because the FFT runs on contiguous spatial axes.
What’s my precision budget?
fp32 is the default, always correct.
For aggressive memory/throughput savings on power-of-2 spatial dims, use the
*_fp16_*variant. The fp16 ops usenorm="ortho"and (for circular) dual mean-centering to stay within fp16 dynamic range; see the FP16 circular FFT convolution report.If your spatial dims aren’t powers of two, stay in fp32 (cuFFT requires power-of-2 for fp16 transforms).
Am I OOMing?
Try
fftconv_chunked, which splits the channel dim into groups to cap peak memory. Default chunk size 128 gives ~26% memory savings for ~11% overhead.Or combine:
fftconv_fp16.pyalready provides_chunkedvariants that stack both savings.
Is there a fused CUDA kernel for my shape?
2D non-causal or 1D causal long-conv →
fftconv_custom.pyexposes the upstream fused FFT kernels (fft_conv2d,fft_causal_conv1d) through the same API. Wire in via thefft_backend="subq_ops"flag onCKConvND. The 1D path requiresdata_dim=1, is_causal=True; the 2D path requiresdata_dim=2, is_causal=False.2D non-causal with spatial dims ≤ 64 per axis → prefer
fft_backend="subq_ops_fused", which uses the newerfused_fft_conv2dkernel. It runs the whole rfft2/multiply/irfft2 pipeline in one launch natively in fp32/fp16/bf16 instead of upcasting to fp32 — measured at 1.9–4.9× overtorch_fftand 1.3–2.5× oversubq_opson an H100 across spatial extents 16–64 (batch 8, hidden 768, forward+backward, bf16). The margin overtorch_fftgrows with spatial extent (1.9× at 16, 4.9× at 64); the margin oversubq_opsdoes not vary monotonically. Speedups are shape- and hardware-dependent — reproduce withbenchmarks/ops/bench_fused_fftconv2d.py. Requiressubquadratic-ops-torch >= 0.3.0, and — because any extent above 32 per axis selects the 128 FFT tile — compute capability 9.0+ (Hopper/Blackwell); SM80/SM86 lack the shared memory for that tile and raise a clear error. The 64×64 cap comes from the kernel’s largest FFT tile (128) combined with itsmax(X, Y) ≤ fft_size // 2requirement, and is enforced on the first forward pass rather than at construction. Beyond that, fall back tosubq_opsortorch_fft.Already have a model on
fft_backend="torch_fft"and want the fused kernel without a config change →fftconv_lowering.pyprovides atorch.compilepre-grad pass that detects the FFT-conv chain and rewrites it. See the lowering section below.1D causal short conv (typical short_conv slot in a Hyena block) →
causal_conv1d_custom.pyexposescausal_conv1ddirectly, andnvsubquadratic.modules.subq_ops_causal_conv1d.SubqOpsCausalConv1dwraps it as a depthwisenn.Conv1d-compatible module.1D causal fused proj+gate+mixer+gate block →
b2b_causal_conv1dincausal_conv1d_custom.py. Not yet wired into aHyenavariant; exposed as a building block.
torch.compile lowering#
Inductor cannot generate code for complex operators — it warns
Torchinductor does not support code generation for complex operators and falls
back to eager cuFFT for the whole rfft2 → multiply → irfft2 chain. So
torch.compile on its own buys essentially nothing for fftconv.py.
fftconv_lowering.py closes that gap. It registers an inductor pre-grad
pass (before AOTAutograd, so autograd comes from the custom op’s registered
backward) that detects the chain and swaps in the fused kernel:
import torch
from nvsubquadratic.ops.fftconv_lowering import fused_fftconv2d_options, lowering_stats
compiled = torch.compile(model, options=fused_fftconv2d_options())
out = compiled(x)
print(lowering_stats()) # {'rewritten': 1}
fused_fftconv2d_options() is scoped to that one compiled callable and leaves
global inductor config untouched. When a trainer or framework owns the
torch.compile call and you cannot pass options, the
fused_fftconv2d_lowering() context manager does the same thing by patching
global config for its duration:
from nvsubquadratic.ops.fftconv_lowering import fused_fftconv2d_lowering
with fused_fftconv2d_lowering():
out = trainer.fit(model) # whatever compiles internally
The pass only fires when the graph is exactly fftconv.py’s recipe — the
min(N + (K+1)//2, 2N) padding, the K // 2 crop, shapes within the fused
kernel’s limits, CUDA device, and a compute capability that supports the
required FFT tile (the 128 tile needs SM90+; SM80/SM86 lack the shared memory).
Anything else is left on the eager path.
Two things to know:
Setting
fft_backend="subq_ops_fused"is the more predictable route and needs no pass at all. Reach for the lowering when you want the kernel under an existingtorch_fftmodel without touching its config.lowering_stats()only counts passes that actually ran. Inductor caches compiled artifacts on disk, and a cache hit skips every pre-grad pass, so empty counters can mean “served from cache” rather than “did not fire”. Settorch._inductor.config.force_disable_caches = Truewhen verifying.
By default the pass also rewrites fp16/bf16 graphs, which moves the convolution
from fp32-internal to native-dtype — that is where most of the speedup comes
from, and it is a real numerics change (~2e-3 normwise in bf16). Pass
fused_fftconv2d_lowering(allow_reduced_precision=False) to restrict it to
fp32, where the rewrite is numerically neutral.
Numerical notes#
All operators accept any input dtype but cast to the internal compute precision (fp32 or fp16) before the FFT. The output is returned in the original dtype of
x, so no manual cast is needed on the caller side. The one exception is thefused_fftconv2d_*family, which runs inx.dtypeend to end — that is the point of it. Measured against the fp32 reference, its normwise relative error is ~3e-7 in fp32, ~3e-4 in fp16, and ~3e-3 in bf16.The fp32 ops are correct for any input range. The fp16 ops impose two constraints: spatial dims must be powers of two (cuFFT), and the dynamic range is handled by mean-centering both
xandk(see derivation doc).The non-causal linear ops match a standard
torch.nn.ConvNd(padding='same')up to floating-point rounding. The circular ops matchtorch.nn.functional.conv*dafter a circular pad. Both are exercised intests/.