Skip to content

PERF: Cache GPU context, plan, and buffers across transforms; add leak test and export VKFFT_BACKEND - #83

Merged
hjmjohnson merged 4 commits into
InsightSoftwareConsortium:mainfrom
hjmjohnson:fix-context-leak
Jun 19, 2026
Merged

hjmjohnson merged 4 commits into
InsightSoftwareConsortium:mainfrom
hjmjohnson:fix-context-leak

Conversation

@hjmjohnson

@hjmjohnson hjmjohnson commented Jun 17, 2026 •

Copy link
Copy Markdown
Member

Makes VkFFT usable for iterative workloads (e.g. registration in ANTsX/ANTs#1331) by caching the GPU context, the compiled VkFFT plan, and the per-shape device buffers across transforms, and by exporting the backend selection so out-of-tree consumers link an ABI-compatible library. Three commits, each independently reviewable:

  1. PERF context caching — fixes a per-Update() context leak and removes per-call context creation.
  2. PERF plan + buffer caching — keeps the compiled plan and GPU buffers alive across same-shape calls; per call only the host↔device copy + VkFFTAppend run.
  3. COMP export VKFFT_BACKEND — fixes a SIGSEGV that hit every out-of-tree consumer.
1 — Context-leak root cause

VkCommon::Run() did m_VkGPU = vkGPU; at the top — overwriting the cached VkGPU (and its live context handle) with the caller's empty one — before calling ReleaseBackend(). So ReleaseBackend() saw a null handle and freed nothing, while the previous call's context was abandoned. The reconfigure guard also compared VkParameters via operator!=, which includes the per-call CPU buffer pointers, so it fired on every call. Net effect: every transform created a new device context/command queue and leaked the previous one; device memory grew until clCreateContext returned -6 (CL_OUT_OF_HOST_MEMORY) / VkFFT 4045 (or cuCtxCreate failed) after ~50 calls.

Fix: Run() (outside any #if VKFFT_BACKEND) now reconfigures only when the device or the transform shape changes, via a new VkParameters::SameShapeAs() that ignores the buffer pointers/sizes. ReleaseBackend() nulls the freed CUDA/OpenCL handles so a genuine shape-change reconfigure cannot double-free. Because the fix is in the shared dispatcher, it covers CUDA, OpenCL, Level Zero, and Metal at once.

2 — Plan + buffer caching

The compiled VkFFTApplication and the per-shape GPU buffers are now cached members, built once per shape and released in ReleaseBackend(). Each call only copies host↔device and runs VkFFTAppend (buffers bound per-call via VkFFTLaunchParams). This removes the per-call plan build — on CUDA that was the nvrtc JIT; on OpenCL the (cached) clBuildProgram — so CUDA and OpenCL converge to compute-bound timings.

Two non-obvious traps fixed (detail in the commit message): (a) CUDA runtime calls act on the current context, so with separate forward/inverse filters each owning a context a cached buffer became invalid under the other's context — fixed by cuCtxSetCurrent per instance; (b) embedding VkFFTApplication by value made VkCommon's layout depend on sizeof(VkFFTApplication) at every include site — fixed by heap-allocating it.

3 — Export VKFFT_BACKEND (fixes out-of-tree SIGSEGV)

VkCommon embeds backend-selected members (VkGPU handles, per-shape GPU buffers) whose type and size depend on VKFFT_BACKEND, and the templated Vk*FFTImageFilter holds a VkCommon by value. The backend was set only via add_compile_definitions(), which is build-tree scoped and never reaches the target's INTERFACE_COMPILE_DEFINITIONS. An installed-module consumer therefore compiled itkVkCommon.h without VKFFT_BACKEND, hit the defensive #define VKFFT_BACKEND OPENCL fallback in itkVkDefinitions.h, and built a VkCommon whose member offsets disagreed with the library. With plan/buffer caching the library dereferences a cached member pointer in ReleaseBackend(), so the mismatch read a null m_VkFFTApplication and crashed (EXC_BAD_ACCESS) on the first transform.

Fix: target_compile_definitions(VkFFTBackend PUBLIC VKFFT_BACKEND=${VKFFT_BACKEND}) so every consumer compiles the header with the same backend the library was built with. This is the path ANTs (#1331) uses; the in-tree ctest never exercised it because it compiles with the module's private define.

Results — VkFFT 256³ (mean ms)
backend original + context cache + plan/buffer cache memory over 200 iters
OpenCL (RTX 6000 Ada) 222 ms 37 ms ~20 ms flat (was OOM by ~iter 54)
CUDA (RTX 6000 Ada) 316 ms 147 ms ~20 ms flat (was OOM)
Metal (Apple Silicon) 29 ms — 8.7 ms flat (leak test passes 200/200)

Round-trip error 0.000 throughout, including across shape changes. With both caching layers CUDA and OpenCL converge (compute-bound, plan reuse removes the JIT asymmetry). On Apple Silicon unified memory the original per-call cost is far below the discrete-GPU numbers (no host↔device copy); the full caching stack drops VkFFT 256³ from 29 ms to 8.7 ms — fast enough that VkFFT-Metal beats the PocketFFT CPU backend for sizes ≥192³. (Metal context-only was not separately measured; the — cell reflects that, not a regression.)

Regression test

itkVkFFTRepeatedTransformLeakTest reuses a single forward+inverse filter pair for 200 transforms (the pattern an iterative registration uses) and checks a constant round-trips. Fails at ~iteration 54 (out-of-memory abort) on the pre-fix code and passes all 200 on the fixed code (ctest, 2.65 s on OpenCL). Also built and run on Metal (Apple Silicon): passes all 200 with flat device memory. Built and run locally before submitting.

VkCommon::Run() overwrote the cached VkGPU (clearing the live context
handle) before ReleaseBackend() could free it, and the reconfigure guard
compared VkParameters including the per-call CPU buffer pointers, so every
Update() tore down and recreated the device context. The cleared handle
meant ReleaseBackend() freed nothing, so each transform leaked one GPU
context/command queue and device memory grew until clCreateContext (or
cuCtxCreate) failed with an out-of-memory error after a few dozen calls.

Reconfigure only when the requested device or the transform shape changes
(a new SameShapeAs() comparison that ignores the buffer pointers), and keep
the cached context for the lifetime of the VkCommon, released once in the
destructor. ReleaseBackend() now nulls the freed CUDA/OpenCL handles so a
later shape-change reconfigure cannot double-free (Level Zero and Metal
already did this).

The change is in the backend-agnostic Run(), so it fixes CUDA, OpenCL,
Level Zero, and Metal in one place. Besides removing the leak it eliminates
the per-call context creation: VkFFT becomes transform-size-dependent
(compute-bound) instead of a fixed ~220 ms/call. Verified on an RTX 6000
Ada: OpenCL 256^3 222 -> 37 ms, CUDA 256^3 316 -> 147 ms, round-trip error
0.000, device memory flat across 200 iterations.

Add itkVkFFTRepeatedTransformLeakTest, which reuses one filter for 200
forward+inverse transforms. It fails at ~iteration 54 with the original
out-of-memory abort and passes to completion with the fix.
Building on the context caching, this also reuses the compiled VkFFT plan and
the device buffers across same-shape transforms. Previously every PerformFFT
allocated GPU buffers and called initializeVkFFT -- which JIT-compiles the FFT
kernels (nvrtc on CUDA, clBuildProgram on OpenCL) -- then freed them again, so
the per-call cost was dominated by (re)compilation and allocation rather than
the transform.

The VkFFTApplication and the three device buffers are now members, built once
per shape (guarded by m_PlanConfigured) and released in ReleaseBackend() on the
next device/shape change or at destruction. Each call only copies the host
buffers and runs VkFFTAppend, with the buffers bound per call via
VkFFTLaunchParams. Distinct, non-aliased buffers are freed exactly once.

Two correctness points the caching exposed:
- CUDA runtime calls act on the current context; with several filters (each its
  own VkCommon and context) the current one belongs to whichever configured
  last, so PerformFFT and ReleaseBackend now make this instance's context
  current before touching its cached buffers/plan.
- VkFFTApplication is heap-allocated rather than an inline member, so its size
  is computed in the library TU that populates it; embedding it by value makes
  the class layout depend on sizeof(VkFFTApplication) at every include site,
  which can differ and corrupt the following members.

Verified on an RTX 6000 Ada (OpenCL and CUDA), compute-sanitizer clean, leak
regression test passing: VkFFT is now compute-bound and the CUDA/OpenCL results
converge (e.g. 256^3 ~20 ms both, vs 222 ms OpenCL / 316 ms CUDA originally).
Level Zero and Metal carry the same structural change but were not exercised on
this hardware.
@hjmjohnson
hjmjohnson marked this pull request as ready for review June 17, 2026 23:36
… ABI

VkCommon embeds backend-selected members (VkGPU handles, per-shape GPU
buffers) whose type and size depend on VKFFT_BACKEND, and the templated
Vk*FFTImageFilter holds a VkCommon by value. The backend was set only via
add_compile_definitions(), which is build-tree scoped and never reaches the
target's INTERFACE_COMPILE_DEFINITIONS. An installed-module consumer thus
compiled itkVkCommon.h without VKFFT_BACKEND, fell back to the OpenCL default
in itkVkDefinitions.h, and built a VkCommon whose member offsets disagreed
with the library. With plan/buffer caching the library dereferences a cached
member pointer in ReleaseBackend(), so the mismatch read a null
m_VkFFTApplication and crashed (EXC_BAD_ACCESS) on the first transform.

Export the selection on the target so every consumer compiles the header with
the same backend the library was built with. Verified: a default external
build (no -DVKFFT_BACKEND) now runs the Metal benchmark to completion,
round-trip error 0.000.
@hjmjohnson hjmjohnson changed the title PERF: Cache the GPU context across transforms; add leak regression test PERF: Cache GPU context, plan, and buffers across transforms; add leak test and export VKFFT_BACKEND Jun 18, 2026
@hjmjohnson

Copy link
Copy Markdown
Member Author

Re-validated on Apple Silicon (macOS) built from this PR's current tip (26e296d), as a default external consumer of the installed module — no manual -DVKFFT_BACKEND. This is the path that previously SIGSEGV'd; the export fix in this PR makes it Just Work (consumer auto-receives VKFFT_BACKEND=5 from INTERFACE_COMPILE_DEFINITIONS).

Latest Metal measurements (VkFFT vs CPU backends, 3D float)

Mean ms per forward+inverse, default external build:

size PocketFFT FFTW VkFFT (Metal)
128³ 1.62 3.58 1.78
192³ 5.21 10.26 4.56
200³ ~6–7 9.96 5.31
256³ 12.4–12.8 34–38 8.5–8.8

256³ is the 30-iter, 3-rep steady-state (PocketFFT 12.4/12.5/12.8, VkFFT 8.48/8.83/8.53); the other rows are 10-iter. Round-trip error 0.000 at every size. VkFFT-Metal beats the PocketFFT CPU backend for sizes ≥192³.

Leak regression test on Metal

itkVkFFTRepeatedTransformLeakTest float 64 200, also built as a default external consumer: exit 0 — "Completed 200 forward+inverse transforms at 64³ without GPU resource exhaustion." Device memory flat across the loop.

@hjmjohnson

Copy link
Copy Markdown
Member Author

Added fffc8a6: make the fetched FFT headers relocatable so this module can be consumed from an installed ITK (the ANTs-against-ITKv6 case). This is a pure module-side fix — it removes the need for any ITK core change (ITK#6465 can be closed). Thanks to @blowekamp for pointing at the SYSTEM_GENEX_INCLUDE_DIRS mechanism.

Two coupled install defects this fixes

A — install(EXPORT ITKTargets) generation failure (CMake ≥ 4.x). The fetched build-tree include dirs (metal-cpp, vkFFT) were appended to VkFFTBackend_SYSTEM_INCLUDE_DIRS, which ITKModuleMacros records verbatim in the exported targets → "INTERFACE_INCLUDE_DIRECTORIES … prefixed in the build directory." Fix: set VkFFTBackend_SYSTEM_GENEX_INCLUDE_DIRS directly with a $<BUILD_INTERFACE:> genex (per @blowekamp), so the path is build-only and never exported. No ITK core macro change needed.

B — fetched headers were never installed (the deeper, latent one). itkVkCommon.h is an installed PUBLIC header that #includes vkFFT.h and (on Apple) metal-cpp's Foundation/Foundation.hpp etc., but those fetched headers live only in _deps/ in the build tree. Consumers compiled only while the build tree still existed; a relocated install fails with Foundation/Foundation.hpp file not found. Fixing A actually unmasks B (it stops the legacy include path from accidentally exporting the build-tree header location). Fix: install(DIRECTORY …) the fetched vkFFT (all backends) and metal-cpp (Metal) headers into ITK's include dir.

Verification (macOS / Metal, no ITK core patch)
  • ITK configures + installs cleanly; ITKTargets.cmake carries VKFFT_BACKEND=5 and no build-tree paths.
  • vkFFT.h and Foundation/Foundation.hpp now land under include/ITK-6.0/.
  • A fresh external consumer builds with zero _deps/ references (proving relocatability) and runs (round-trip error 0.000), resolving every header from the install.
  • pre-commit run (incl. gersemi) clean.

@blowekamp

Copy link
Copy Markdown
Member

Just an FYI, there are two approaches to these type of third party libraries with regard to the cmake interface. 1) The library, headers and interface can be exported as part of the monolithic ITK exports, this approach should use the itk modular macros and involves duplicating the install and export code of the library. 2) The third-party library itself can export as a separate package/project and be imported in the exports code.

Neither approach is without goblins and orcs so be careful and what ever is easiest and works is good.

P.S. Using file sets can make the install and include paths easier as they also become a property of the target and not separate cmake code.

@dzenanz dzenanz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good on a glance.

@hjmjohnson

Copy link
Copy Markdown
Member Author

@dzenanz @blowekamp FYI: I did a lot of testing for ANTs' use of this on "256^3" images, and for those usage patterns, transfer + compute time, the default PocketFFT was always the fastest; the GPU variants could get close, but never beat the "transfer + compute" times. PocketFFT ~0 transfer time; GPU very small compute time.

itkVkCommon.h is an installed PUBLIC header that includes the fetched
vkFFT.h and (on Apple) the metal-cpp headers, but those headers lived
only in the build tree's _deps/, so two install defects broke consuming
this module from a relocated ITK install (e.g. ANTs against installed
ITKv6):

1. The fetched build-tree include dirs were added to
   VkFFTBackend_SYSTEM_INCLUDE_DIRS, which ITKModuleMacros records
   verbatim in install(EXPORT ITKTargets); CMake >= 4.x then fails
   generation with "INTERFACE_INCLUDE_DIRECTORIES ... prefixed in the
   build directory."

2. The fetched headers were never installed, so a relocated install
   failed with "vkFFT.h / Foundation.hpp file not found."

Fix: install the fetched vkFFT (all backends) and metal-cpp (Metal)
headers into ITK_INSTALL_INCLUDE_DIR alongside itkVkCommon.h, where
every ITK consumer already searches. Carry the build-tree locations on
the VkFFTBackend target as a build-only property
(target_include_directories SYSTEM PUBLIC $<BUILD_INTERFACE:...>): the
in-tree library, test driver, and header test resolve the headers at
build time, while BUILD_INTERFACE keeps the build path out of
install(EXPORT) so the exported target stays relocatable. Using a target
property rather than VkFFTBackend_SYSTEM_GENEX_INCLUDE_DIRS also works
on ITK 5.4, which has no such variable, per blowekamp's suggestion to
make includes a property of the target.

Verified building and running (FFT round-trip + repeated-transform leak
test) against both ITK 5.4 and ITK 6.0 with VKFFT_BACKEND=3 (OpenCL),
and a DESTDIR install carries no build-tree paths in the exported
config.
@hjmjohnson

Copy link
Copy Markdown
Member Author

Thanks @blowekamp — took your steer to make the fetched-header include a property of the target rather than separate include-dir variables.

The relocatable rewrite of fffc8a6 was using VkFFTBackend_SYSTEM_GENEX_INCLUDE_DIRS, which only exists in ITK 6.0+; against the pinned ITK v5.4.6 that variable is silently ignored, so the in-tree build lost vkFFT.h and all 6 CI jobs went red. Reworked to carry the build-tree paths on the VkFFTBackend target via target_include_directories(... SYSTEM PUBLIC "$<BUILD_INTERFACE:...>") + install the fetched headers next to itkVkCommon.h. BUILD_INTERFACE keeps install(EXPORT) relocatable, and it works on both ITK 5.4 and 6.0.

Local verification (ITK 5.4.0 + ITK 6.0, OpenCL)

Reproduced the vkFFT.h: No such file or directory failure against ITK 5.4.0 first, then on the fix confirmed on both ITK versions:

  • library + test driver + header test compile
  • FFT round-trip tests pass
  • itkVkFFTRepeatedTransformLeakTest passes
  • DESTDIR install puts vkFFT.h beside itkVkCommon.h; exported .cmake carries no build-tree paths

@hjmjohnson
hjmjohnson merged commit 4d29249 into InsightSoftwareConsortium:main Jun 19, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants