FFT Backend Execution
tenferro-fft separates FFT semantics from backend execution through the public FftBackend capability. This boundary applies to direct tensor calls, caller-owned repeated execution, and registered graph execution. A backend that implements only TensorBackend is not FFT-capable.
Validated request boundary
The tensor-facing and runtime entry points validate the operation, dtype, axis, transform length, input shape, and real-spectrum constraints before calling a backend. They pass the result as an immutable FftPlanSpec.
FftPlanSpec is a semantic execution request, not a vendor plan. It records:
- the FFT operation and normalization mode;
- the normalized axis and optional requested transform length;
- the validated input dtype and shape; and
- the compact column-major input requirement of the current execution path.
Backend-specific choices such as RustFFT versus cuFFT, algorithm selection, workspace size, stream identity, and device handles do not belong in the spec. Those choices remain private to each FftBackend implementation.
Placement and fallback
An FFT backend executes on the input’s existing placement. If it cannot handle the operation, dtype, layout, or placement, it returns Unsupported. It must not construct another backend, upload or download the tensor, or silently run a host implementation.
The CPU implementation uses RustFFT. It accepts ordinary host tensors and, when the CpuBackend is paired by an Apple AppleContext, matching managed tensors through guarded host mappings. The WebGPU implementation uses CubeK on the configured Metal client. Callers select either backend explicitly; neither implementation dispatches to the other. Cross-device movement remains a caller-visible operation before or after FFT execution.
CUDA/cuFFT execution
Scoped pointer safety supersedes initial async intent
An earlier design intent allowed cached cuFFT calls to remain asynchronous between cache-eviction events. An independent safety review superseded that intent for #967: scoped CubeCL raw pointers do not carry a completion witness, so a vendor call must not outlive the raw session that borrowed its allocations. The current baseline therefore enters the credentialed raw session (with_raw) and synchronizes the bound CUDA stream inside that session, after vendor enqueue and before the session (and its tensor spans, work area, and retention guards) return. Future asynchronous execution requires a separately accepted event-backed external-use ownership design; that work is outside #967.
tenferro-gpu owns the CUDA provider, including CudaBackend and CudaRuntime. The CUDA FFT adapter is owned by tenferro-fft and uses the dynamically loaded cuFFT library. Non-empty TENFERRO_CUFFT_PATH entries are tried first, followed by libcufft.so.11, libcufft.so.10, and bare libcufft.so, covering CUDA 12/cuFFT 11 and CUDA 11/cuFFT 10. It executes the existing one-dimensional fft, ifft, rfft, and irfft operations on tensors already resident on the exact borrowed CudaRuntime. It never uploads, downloads, constructs a CPU backend, or falls back to RustFFT. The common CUDA execution path is:
Move the requested transform axis to the final logical axis with the existing same-device CUDA transpose. If the axis is already final, skip the transpose. The inverse permutation restores the original logical order after execution.
For C2C and R2C, truncate or zero-pad the final axis on device to the validated requested length
n. Zero-padding allocates and fills a semantic same-device zero tail; it never derives zero from input arithmetic, so NaN and infinity inputs cannot contaminate the padding. C2R does not prepare its input this way:nis the real output length, and the already validated half-spectrum is consumed unchanged.Execute one out-of-place rank-one
cufftMakePlanMany64plan when the batch is nonzero. For canonical shape[..., n],batchis the checked product of all leading extents. The exact column-major descriptor is:Transform inembedonembedistride/ostrideidist/odistC2C nnbatch1R2C nn / 2 + 1batch1C2R n / 2 + 1nbatch1The rank-one
narray and both rank-one embed arrays are always non-null; cuFFT ignores advanced stride and distance arguments when the corresponding embed pointer is null. This layout represents interleaved column-major lanes directly and avoids in-place real-transform padding and overwrite constraints.Apply normalization on device because cuFFT is unnormalized. For
FftNorm::Backward, forward uses1and inverse uses1 / n; forForward, forward uses1 / nand inverse uses1;Orthouses1 / sqrt(n)in both directions. A real-input fullfftfirst receives the one-sided R2C result, then completes the Hermitian spectrum on device: for evenn, append the reversed conjugate of bins1..h - 1; for oddn, append bins1..h, whereh = n / 2 + 1. DC (and the Nyquist bin for evenn) is not duplicated.
If a non-transform axis is zero, the checked batch is zero. The backend allocates the correctly shaped, correctly typed resident CUDA output and returns before cuFFT loading, descriptor construction, cache lookup, or plan creation. This preserves empty CPU semantics without requiring a vendor library.
CUDA plans use the existing FftExecutionCache rather than a backend-global cache. Repeated concrete calls use the caller-owned FftExecutor cache; eager and traced calls use the owning runtime’s extension cache. The CUDA namespace is cufft-plans. Its exact structural key contains the CUDA runtime identity, device ordinal, transform dtype/kind, operation direction, n, batch, istride, idist, ostride, and odist; embed extents are derived from the kind and n. Each entry retains that exact key and checks full equality before reuse, so a cache-discriminator hash collision cannot reuse a plan for a different request. The cuFFT work area is not cached: it is a session-scoped CubeCL allocation created fresh inside each raw execution session, so logical retained bytes charge only the entry’s host-side metadata (key, plan handle, runtime and library witnesses, workspace-size requirement). Opaque cuFFT internal allocations and library-owned state are excluded.
The cuFFT adapter creates plans under CudaRuntime::with_current_context, a scoped guard that activates the tenferro primary context and restores the caller’s previous device/context on exit (best-effort; failures are logged). Execution enters the credentialed raw session (with_raw): the plan stream is bound to the captured CubeCL stream, a fresh work area is allocated, the input/output allocation handles are retained, the vendor call is enqueued, and the bound stream is synchronized before the session returns. If synchronization fails, the work area and the input/output retention guards are intentionally forgotten so allocation reclamation cannot race in-flight vendor writes (issue #967 invariant). The current CudaRuntime exposes one serialized CubeCL current stream, and the mutable CUDA FFT session serializes plan rebinding and execution on that stream. The stream is therefore omitted from the cache key, but cufftSetStream is called immediately before each execution. Subsequent CUDA normalization, Hermitian completion, and axis restoration remain stream-managed, and an explicit download synchronizes the final output. If selectable concurrent streams are added, stream identity must become part of the key before plan reuse is allowed. When an entry is evicted or a cache is cleared, retirement runs the synchronize + cufftDestroy sequence under the same context-restoring guard. Cleanup failures are reported without panicking. If context selection, synchronization, or destruction fails, the complete plan/library/runtime witness bundle is intentionally leaked rather than dropped while queued work may still be active.
Cache ownership
Every backend receives FftExecutionCache, which exposes one bounded typed ExtensionCacheStore regardless of ownership path:
| Execution path | Cache owner | Lifetime and control |
|---|---|---|
FftExecutor |
Caller-owned FftPlanCache |
Reused across direct calls; caller configures capacity and uses cache_stats / clear_cache |
| Registered extension runtime | Runtime-owned ExtensionCacheStore |
Reused across graph runs; runtime limits, selectors, aggregate stats, and clear APIs apply |
| One-shot tensor/read helper | Temporary capacity-one FftPlanCache |
Exists only for that call; it does not create hidden long-lived state |
| Host reference | Temporary capacity-one FftPlanCache |
Explicit reference execution only; never selected as backend fallback |
FftPlanCache is a backend-neutral wrapper, despite its historical name. It does not contain RustFFT-typed fields. Each backend stores private Send + Sync + 'static entries through FftExecutionCache::store_mut under a stable ExtensionCacheKey namespace. The global entry bound and LRU order are shared across namespaces, and the global logical retained-byte bound applies across those same entries. A caller’s limits are true upper bounds for the whole executor rather than per-backend multipliers.
Backends must include every field that changes plan or workspace identity in the discriminator and should retain the unhashed identity in the typed value when a hash is used. They must provide the logical bytes retained by the cache entry at insertion, or use dynamic retained-byte accounting when the value can grow. FftExecutor::cache_stats aggregates all backend namespaces, and clear_cache removes them all.
CPU RustFFT namespace
The CPU adapter owns the private rustfft-plans namespace. Its exact plan key contains transform length, forward/inverse direction, and scalar dtype (f32 or f64). The typed entry retains the exact key to reject discriminator hash collisions before reuse. The known retained-byte estimate covers the key and cache-owned Arc handle; RustFFT’s opaque internal allocations cannot be reported and are intentionally excluded.
This namespace is an implementation detail. Non-CPU backends use distinct names for vendor plans and workspaces and do not depend on RustFFT types.
CubeK Metal compilation cache
The CubeK adapter does not insert a synthetic plan in FftPlanCache: CubeK does not expose a reusable vendor-plan object. CubeCL’s configured client owns the compiled-kernel cache used by repeated launches. The caller-owned FFT cache therefore remains empty for this backend, while its capacity and statistics continue to describe entries actually owned through FftExecutionCache.
Extension requirements
A new FFT backend implementation must:
- implement
FftBackendin the crate that owns the backend integration; - validate that the input placement and layout are supported without moving the tensor;
- consume only validated request facts from
FftPlanSpec; - use a backend-specific typed cache namespace for reusable plans/workspaces;
- supply stable keys and retained-byte accounting; and
- test repeated caller-owned reuse, runtime-owned reuse, eviction/clear/stats, unsupported placement, and absence of implicit transfer.
GPU-specific kernel, stream, and device-resource rules remain governed by GPU Backend Design.