FNet Fast Fourier Transform NKI Kernels for AWS Neuron
NeuronCore kernels that compute the 2D fast Fourier transform used by FNet entirely on-device, written in NKI. The 128-point DFT base case runs as a matrix multiply on the Tensor Engine.
Drop-in replacement for FNetBasicFourierTransform.
Why this kernel exists
torch.fft.fftn has no on-device implementation on Neuron -- complex dtypes are
not supported by the compiler. Verified still true on PyTorch Native Beta 5
(neuronx-cc 2.27, NKI 0.6); see "Compiler support" below.
Measured on the real 12-layer google/fnet-base, swapping only
FNetBasicFourierTransform.forward:
| NKI kernel | stock torch.fft |
|
|---|---|---|
torch.compile(backend="neuron") |
3.07 ms | fails to compile |
| eager, Beta 5 default (strict fallback) | 27.33 ms | raises |
| eager, permissive fallback | 27.33 ms | 27.26 ms |
The kernel's value is that it keeps the layer on device, which is what makes the
model compilable. With torch.compile it is 8.5x faster than the best eager
fallback; on Beta 5 defaults the stock model does not run at all. In plain eager mode
with permissive fallback the two are within ~10% -- the transform is not the eager
bottleneck at this size.
Usage
from transformers import AutoModel, KernelConfig
kernel_config = KernelConfig({
"FNetBasicFourierTransform":
"jburtoft/fnet-fast-fourier-transform-neuron-kernels:NeuronFNetFastFourierTransformForward",
})
model = AutoModel.from_pretrained(
"google/fnet-base",
kernel_config=kernel_config,
device_map="neuron",
)
Or load the kernel directly:
from kernels import get_kernel
k = get_kernel("jburtoft/fnet-fast-fourier-transform-neuron-kernels",
version=2, trust_remote_code=True)
out = k.nki_fast_fourier_transform_2d_real(hidden_states) # (B, S, D) -> (B, S, D)
How it works
FNet applies torch.fft.fftn(hidden_states, dim=(1, 2)).real to (B, S, D).
This factors into two passes of 1D transforms, each of which is half a complex
transform:
| Pass | Axis | Structure | Why |
|---|---|---|---|
| 1 | hidden dim D |
real-to-complex (r2c) |
hidden_states is real, so the imaginary input is all zeros |
| 2 | sequence dim S |
complex-to-real (c2r) |
only .real is consumed, so the imaginary output is never read |
Specializing each pass to its actual role halves the Tensor Engine matmul work versus running a full complex-to-complex transform twice.
Each 1D transform uses flat radix-2 Cooley-Tukey:
- 128-point: direct DFT via
nc_matmulon the 128x128 Tensor Engine - 256-point: 2 groups of 128 + 1 butterfly level
- 512-point: 4 groups of 128 + 2 butterfly levels
Performance
End-to-end: real google/fnet-base, 12 layers, on device
This is the number that matters. Measured on the actual transformers FNetModel
(12 layers, hidden 768, batch 1, seq 128) with FNetBasicFourierTransform.forward
swapped per variant. trn2.3xlarge, PyTorch Native Beta 5 (torch 2.12.1,
torch-neuronx 2.12.3.0.1636, neuronx-cc 2.27.2878, NKI 0.6.0), driver 2.30.2, p50.
| Fourier implementation | strict (Beta 5 default) | permissive | torch.compile |
|---|---|---|---|
| NKI kernel | 27.33 ms | 27.33 ms | 3.07 ms |
torch.fft.fftn(...).real (what transformers ships) |
RAISES | 27.26 ms | fails to compile |
same + .contiguous() |
RAISES | 28.98 ms | fails to compile |
explicit .cpu() round trip |
23.80 ms | 26.18 ms | fails to compile |
Three conclusions, in order of importance:
- Under
torch.compile(backend="neuron")the kernel is the only option that works, and it is 8.5x faster than the best eager fallback (3.07 ms vs 26.18 ms). Everytorch.fftvariant fails to compile withfailed to legalize operation 'torch.operator'. The kernel compiles into one graph with zero fallback ops, and compiling is worth 8.9x on the kernel path alone (27.33 -> 3.07 ms). This is where the kernel's value actually lives. - In Beta 5's default strict mode, the stock model cannot run at all.
torch.fftraisesCPU fallback disabled ... Operation aten::_fft_c2c received a complex-dtype input. The kernel is not an optimization here; it is a prerequisite. - In eager permissive mode the four variants are within ~10% of each other (23.8-29.0 ms). At FNet-base's size the transform is not the bottleneck in eager mode -- embeddings, 12 dense FFN blocks and LayerNorms dominate. Eager microbenchmarks of the transform alone are therefore a poor guide to model-level impact.
⚠ Correction to the previously published CPU-fallback speedup
Earlier versions of this card claimed 233-1499x faster than the CPU fallback. That claim was wrong and has been withdrawn.
The baseline was
torch.fft.fftn(h, dim=(1,2)).real.to(device)..realon a complex tensor returns a non-contiguous view (stride..., 2), and transferring that view host-to-device degenerates into a pathological element-wise DMA:
Host-to-device transfer of a 393 KB tensor Time contiguous 0.139 ms non-contiguous .realview686.8 ms .real.contiguous()0.178 ms One
.contiguous()call is worth 1685x. The old baseline measured a bad transfer, not the cost of computing an FFT on the host.The v1-vs-v2 comparison is unaffected (both measured identically, on-device), so the 4.0-9.6x v2-over-v1 improvement still stands.
Isolated transform latency (context, not the headline)
Single transform, no surrounding model. Included for completeness -- and as a caution that these numbers do not predict model-level behavior:
| Config | kernel | torch.fft (host fallback) |
honest CPU fallback |
|---|---|---|---|
| B=1 S=128 D=128 | 0.74 ms | 0.32 ms | 0.21 ms |
| B=1 S=128 D=512 | 1.22 ms | 0.79 ms | 0.63 ms |
| B=1 S=512 D=512 | 2.18 ms | 1.31 ms | 0.71 ms |
| B=1 S=128 D=768 (FNet-base) | 1.33 ms | 0.66 ms | 0.40 ms |
| B=1 S=512 D=768 | 1.54 ms | 1.79 ms | 1.27 ms |
| B=8 S=128 D=512 | 6.65 ms | 2.58 ms | 1.12 ms |
In eager mode at small sizes a host round trip is genuinely faster than this kernel.
The kernel is dominated by eager per-tile dispatch (a B1 S128 D768 call issues 7
separate @nki.jit launches), not arithmetic. That penalty disappears under
torch.compile, which is why the isolated ranking inverts at the model level.
A queue-pressure sweep (0-16 matmuls queued before the transform, forcing the fallback to drain them) narrowed the gap from 1.66x to 1.20x but did not flip it in eager mode. Pipeline-drain cost is real but modest at this scale; the compile barrier dominates.
When to use this kernel
Use it when:
- You compile with
torch.compile(backend="neuron")--torch.fftcannot be compiled at all, so this is the only way to keep the layer in a compiled graph. Worth 8.5x end-to-end. - You run on Beta 5 defaults -- the stock path raises.
- You need zero host syncs -- to keep an async pipeline full, or inside a larger fused region.
- Transforms are large (
S x D >= 512 x 768), where the kernel wins even in eager mode.
Reach for the host path instead when you are in eager mode, at small transform sizes, with permissive fallback available, and you do not care about host syncs.
v2 vs v1
Unaffected by the baseline correction -- both versions measured identically and entirely on-device. Beta 4, p50 of 30 iters:
| Config | v2 | v1 | speedup |
|---|---|---|---|
| B=1 S=128 D=128 | 0.50 ms | 2.11 ms | 4.19x |
| B=1 S=128 D=512 | 1.08 ms | 5.03 ms | 4.68x |
| B=1 S=256 D=256 | 1.12 ms | 7.30 ms | 6.50x |
| B=1 S=512 D=512 | 2.06 ms | 10.25 ms | 4.97x |
| B=1 S=128 D=768 (FNet-base) | 1.68 ms | 6.76 ms | 4.02x |
| B=1 S=256 D=768 | 1.37 ms | 13.23 ms | 9.64x |
| B=1 S=512 D=768 | 1.88 ms | 16.35 ms | 8.70x |
| B=4 S=128 D=768 | 4.18 ms | 30.19 ms | 7.22x |
| B=8 S=128 D=512 | 6.09 ms | 39.27 ms | 6.45x |
v2 is 4.0-9.6x faster than v1.
Where the v2 speedup comes from
The static instruction-count reduction is only ~1.6x at best, so it cannot by itself
explain 4-9.6x. Ablation isolates two effects that multiply. Both runs cache constants,
so the only difference in the "kernel" column is the r2c/c2r specialization plus the
removed W transpose:
| Config | v1 | v1 + constant cache | v2 | cache win | kernel win |
|---|---|---|---|---|---|
| B=1 S=128 D=128 | 2.99 ms | 1.37 ms | 0.57 ms | 2.18x | 2.42x |
| B=1 S=128 D=512 | 7.23 ms | 2.39 ms | 1.09 ms | 3.03x | 2.19x |
| B=1 S=512 D=512 | 18.35 ms | 3.83 ms | 2.36 ms | 4.79x | 1.62x |
| B=1 S=128 D=768 | 9.72 ms | 3.35 ms | 1.33 ms | 2.90x | 2.52x |
| B=1 S=512 D=768 | 21.02 ms | 5.05 ms | 1.76 ms | 4.16x | 2.87x |
| B=8 S=128 D=512 | 38.63 ms | 16.26 ms | 6.52 ms | 2.38x | 2.49x |
- Constant caching: 2.2-4.8x. The largest win, and not a kernel change at all.
v1 rebuilt its DFT and twiddle matrices in NumPy and re-uploaded them every call.
Instrumented at
B=1 S=512 D=768: v1 performs 36 host-to-device transfers totalling 3.15 MB per forward pass; v2 performs 0. - Kernel specialization: 1.6-2.9x. Larger than the raw matmul count suggests,
because removing the
Wtransposes also removes 2 PSUM->SBUF round-trips per DFT, and thec2rpath drops the final butterfly's imaginary half entirely.
Do not attribute the full 4-9.6x to the matmul specialization.
Batch folding measured as neutral (within noise) once constants are cached.
Compiler support for torch.fft (Beta 5, neuronx-cc 2.27)
Tested directly on Beta 5. Complex dtypes and the 2D FFT are still not supported on device:
| Operation | Result |
|---|---|
torch.zeros(dtype=complex64, device=...) |
fails -- Torch-MLIR cannot lower to StableHLO |
complex add / mul / matmul / abs / .real |
fails -- same lowering error |
torch.fft.fft(dim=-1), rfft |
runs, reports aten::_fft_r2c fallback |
torch.fft.fft2, fftn |
runs, reports aten::_fft_c2c fallback |
torch.compile(backend="neuron") with any torch.fft |
fails -- failed to legalize operation 'torch.operator' |
Every torch.fft path that "works" executes on the host, confirmed by
torch_neuronx.get_fallback_ops(). The kernel reports no fallback ops.
Beta 5 also sets TORCH_NEURONX_FALLBACK_ONLY_FOR_UNIMPLEMENTED_OPS=1 during
import torch, so unsupported ops raise by default:
RuntimeError: CPU fallback disabled because
TORCH_NEURONX_FALLBACK_ONLY_FOR_UNIMPLEMENTED_OPS=1.
Operation aten::_fft_c2c received a complex-dtype input; complex dtypes are unsupported.
Permissive fallback must be re-enabled explicitly (set the variable to 0 after
importing torch) for the host path to work at all.
Note: torch.compile requires the kernel to be importable as a real module. A
module loaded via importlib.spec_from_file_location without registering it in
sys.modules fails under Dynamo with ModuleNotFoundError.
Instruction counts
ISA operations per 128-point tile, counting both FNet passes:
| Transform size | Before | After | Reduction | Matmuls before | after | Reduction |
|---|---|---|---|---|---|---|
| 128 | 42 | 23 | -45% | 8 | 4 | -50% |
| 256 | 116 | 72 | -38% | 16 | 12 | -25% |
| 512 | 248 | 158 | -36% | 32 | 24 | -25% |
Sources of the reduction:
r2c/c2rspecialization -- skips matmuls against a zero imaginary input (pass 1) and matmuls producing a discarded imaginary output (pass 2).- No DFT-matrix transpose.
W[k,n] = exp(-2*pi*i*k*n/N)depends onk*nand is therefore exactly symmetric. Angles are built by reducing the integer productk*nmoduloNbefore scaling, which makes the float32 matrix symmetric to 0 ULP, soWis fed tonc_matmuldirectly. This removes 2nc_transposeplus 2 PSUM->SBUF copies per DFT. - Real-only final butterfly (
c2rpath) -- 5 vector ops instead of 10. - Fused output assembly -- butterflies write straight into their output halves instead of writing temporaries and copying.
- Cached constants -- DFT matrices, twiddle factors and
-W_imagare built once per(size, device)instead of being rebuilt in NumPy and re-uploaded on every call. Previously aB=4, S=512, D=768forward pass issued 144 separate host-to-device transfers. - Batch folding -- the batch is folded into the tile dimension
(
(B,S,D) -> (B*S,D)) instead of being looped over in Python.
Supported sizes
| Transform length | Path |
|---|---|
| 128, 256, 512 | NKI kernels (radix-2, Tensor Engine base case) |
| any other | true N-point DFT as an on-device matrix multiply |
The fallback is a genuine N-point DFT, not a zero-padded power-of-two transform. Zero-padding would change the frequency grid and produce a different result.
| Model | Hidden D |
Seq S |
Pass 1 (D) |
Pass 2 (S) |
|---|---|---|---|---|
google/fnet-base |
768 | 512 | dense DFT | NKI |
google/fnet-large |
1024 | 512 | dense DFT | NKI |
custom, D in {128,256,512} |
128-512 | 128-512 | NKI | NKI |
Note that google/fnet-base has hidden_size=768, which is not a power of two, so
its pass 1 runs the dense on-device DFT and only pass 2 uses the NKI kernels. Both
paths stay on-device; the transform is never sent to the host.
Accuracy
Validated against torch.fft.fftn(...).real (float64 reference) on trn2.3xlarge
hardware (PyTorch Native Beta 4, SDK 2.31). test_e2e.py runs 30 checks; all pass.
| Kernel | max abs error | relative |
|---|---|---|
r2c N=128 / 256 / 512 |
4.7e-06 / 7.2e-06 / 1.0e-05 | ~1.5e-07 |
c2r N=128 / 256 / 512 |
7.0e-06 / 1.0e-05 / 1.7e-05 | ~1.5e-07 |
full 2D, B=1 S=128 D=128 |
6.1e-05 | 1.6e-07 |
full 2D, B=1 S=128 D=512 |
1.9e-04 | 2.3e-07 |
full 2D, B=1 S=128 D=768 (FNet-base) |
1.8e-04 | 1.9e-07 |
full 2D, B=1 S=512 D=1024 (FNet-large) |
4.6e-04 | 1.9e-07 |
v2 is 171-1091x more accurate than v1, measured on the same hardware in the same run. The modular angle reduction is the cause; it costs nothing at runtime.
| Config | v1 relative error | v2 relative error | v2 better by |
|---|---|---|---|
| B=1 S=128 D=128 | 2.9e-05 | 1.7e-07 | 175x |
| B=1 S=128 D=512 | 3.6e-05 | 2.0e-07 | 185x |
| B=1 S=512 D=512 | 4.4e-05 | 1.8e-07 | 248x |
| B=1 S=128 D=768 (FNet-base) | 1.8e-04 | 1.9e-07 | 944x |
| B=1 S=512 D=768 | 2.0e-04 | 2.1e-07 | 947x |
The gap is widest at D=768, where v1's error reached 2.0e-04 relative. That is the
FNet-base hidden size, i.e. the configuration most users will hit.
Note that bfloat16 input yields ~2.9e-03 relative error in both versions. That is
inherent to bf16's 8-bit mantissa, not a kernel defect -- the FFT arithmetic is always
performed in float32 and only the returned tensor is cast back.
Requirements
- AWS Neuron SDK 2.29+ (NKI 0.3.0+); validated on NKI 0.5.0 / SDK 2.31
- PyTorch Native (torch-neuronx) -- the orchestration layer runs eagerly
transformerswithKernelConfigsupport, and thekernelslibrary
Framework note: the @nki.jit kernels use only ISA-level nisa.* operations and
work under both PyTorch Native and the standard torch-neuronx XLA trace path. The
orchestration layer (nki_fast_fourier_transform_2d_real) uses eager tensor
operations and therefore needs PyTorch Native. Using these kernels inside an XLA
trace requires a trace-compatible orchestration layer.
API
NeuronFNetFastFourierTransformForward HF forward class (use this in KernelConfig)
NeuronFNetFastFourierTransformLayout weight layout (empty -- no learnable params)
nki_fast_fourier_transform_2d_real (B,S,D) -> (B,S,D) real part of 2D FFT
_fast_fourier_transform_{128,256,512}_r2c real -> complex @nki.jit kernels
_fast_fourier_transform_{128,256,512}_c2r complex -> real @nki.jit kernels
_dft_r2c / _dft_c2r / _dft_c2c 128-pt DFT primitives via nc_matmul
Changelog
v2.3 -- Documentation-only. Added end-to-end measurements on the real 12-layer
google/fnet-base, which is the benchmark that actually answers "is this kernel
worth using." Result: under torch.compile(backend="neuron") the kernel runs at
3.07 ms and is the only variant that compiles at all (every torch.fft path
fails to legalize), making it 8.5x faster than the best eager fallback. On Beta 5
defaults the stock model raises. In eager permissive mode all four variants are
within ~10%. Prior versions of this card only ever measured the transform in
isolation, which understated the kernel's value (it looked slower) by omitting the
compile barrier and the strict-mode failure.
v2.2 -- Documentation-only correction. Withdrew the "233-1499x faster than the
CPU fallback" claim, which was measured against a pathological non-contiguous
host-to-device transfer (.real on a complex tensor yields a stride-2 view; sending it
H2D costs 686.8 ms vs 0.178 ms with .contiguous(), a 1685x difference). Added Beta 5
compiler-support results confirming complex dtypes and 2D FFT are still unsupported on
device. No code changes.
v2.1 -- Hardware benchmark on trn2.3xlarge (PyTorch Native Beta 4, SDK 2.31):
4.0-9.6x faster than v1, 171-1091x more accurate, test_e2e.py 30/30 PASS.
Attribution: constant caching 2.2-4.8x (v1 issued 36 H2D transfers per forward, v2
issues 0), kernel specialization 1.6-2.9x, batch folding neutral. Added benchmarks/.
v2 -- r2c/c2r specialization, symmetric-W transpose elimination, real-only
final butterfly, fused output assembly, constant caching, batch folding, exact
modular angle reduction. Static counts: 36-45% fewer ISA ops, 25-50% fewer matmuls.
Public class renamed to NeuronFNetFastFourierTransformForward. Corrected
documentation: the previous release's README incorrectly stated that
non-power-of-two sizes were zero-padded to the next power of two and truncated; the
implementation has always used a true N-point DFT.
v1 -- initial release: complex-to-complex 128/256/512 NKI kernels,
NeuronFNetFourierForward.
License
Apache 2.0
- Downloads last month
- -