Skip to content

[Common] Don't call CUDA from static destructors at exit - #3733

Draft
ConnorBaker wants to merge 2 commits into
NVIDIA:mainfrom
ConnorBaker:fix-cuda-driver-cache-exit
Draft

ConnorBaker wants to merge 2 commits into
NVIDIA:mainfrom
ConnorBaker:fix-cuda-driver-cache-exit

Conversation

@ConnorBaker

@ConnorBaker ConnorBaker commented Oct 23, 2026 •

Copy link
Copy Markdown

This description was written entirely with AI (Claude Code, Claude Opus 5.5).

Description

Processes that ran an NVRTC-compiled kernel (norms and softmax since #3156, and others) or captured cuDNN fused attention into a CUDA graph can segfault at exit, after all their work is done. In pytest this turns a passing test file into exit status 139.

Both crashes come from static destructors that call CUDA after main returns. The CUDA Programming Guide, §2.1.6 says that "Using any of these interfaces (implicitly or explicitly) during program initiation or termination after main will result in undefined behavior", and it's Mandatory rule CUDA 1.1 (p. 8) in NVIDIA's CUDA C++ Guidelines v3.0.1.

Whether the KernelManager case crashes depends on the libstdc++ TE was built with. With GCC 16's, the lookup walks freed nodes and faults. With GCC 13's (the NGC pytorch:26.09-py3 container), the destroyed map looks empty, so cuda_driver::call inserts into it, writing to freed memory, and the process exits 0. With MALLOC_PERTURB_=165, both crash.

Fix: KernelManager and the graph caches are allocated with new and never destroyed (rtc.cpp#L156-L158, graph_cache.h#L133-L135), so their destructors never run. TE already avoids exit-time teardown this way: TVMFFIConfigCache::create() (#3137) and the fused-attention debug registry (#2964) are never destroyed, and HandleManager never releases its handles (#1389). cuda_driver::call's symbol cache and mutex are also never destroyed (cuda_driver.h#L43-L46), so a lookup never reads a destroyed map.

Both caches only ever insert (rtc.cpp#L294, graph_cache.h#L120), so nothing changes before exit. The one behavioural difference is that a program that dlcloses libtransformer_engine.so and keeps running no longer unloads the NVRTC modules or destroys the cached plans.

Fixes # (no existing issue found)

Type of change

  • Documentation change (change only to the documentation, either a fix or a new content)
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Infra/Build change
  • Code refactoring

Changes

  • transformer_engine/common/util/rtc.cpp: KernelManager::instance() is never destroyed.
  • transformer_engine/common/fused_attn/graph_cache.h: the fused-attention graph caches are never destroyed.
  • transformer_engine/common/util/cuda_driver.h: cuda_driver::call's symbol cache and its mutex are never destroyed.
  • tests/cpp/util/CMakeLists.txt: the util tests run with MALLOC_PERTURB_=165, so ctest fails a test whose process crashes at exit. UtilTest.NVRTC drives rtc::KernelManager directly (test_nvrtc.cpp#L66-L79).

Testing

On an RTX 4090 (driver 615.71.09) with CUDA 13.4, cuDNN 9.27, cudnn-frontend 1.31.0 (without NVIDIA/cudnn-frontend#1565), PyTorch 2.14.1 and GCC 16, building main (c9c9225) with and without this PR. The Python scripts ran 3 times plain and 3 times with MALLOC_PERTURB_=165.

main this PR
ctest on the util tests, with this PR's MALLOC_PERTURB_ UtilTest.NVRTC and UtilTest.NVRTCConcurrentCompile pass, then segfault at exit (3 of 3 runs) all 5 pass (3 of 3 runs)
te.LayerNorm forward and backward exit 139 in 5 of 6 exit 0 in 6 of 6
DotProductAttention (cuDNN) captured in a CUDA graph exit 139 in 6 of 6 exit 0 in 6 of 6

I also tested both commits backported to TE 2.20.2 (NixOS/nixpkgs#577950) on the same 4090, with cudnn-frontend's fix applied to both builds. Without this PR, test_torch_compile.py exited 139 in 4 of 4 runs, and 12 of 20 L0 PyTorch test files exited 139 after their tests finished. With it, none did. The test counts and failing tests were the same: test_numerics.py tolerance failures, and out-of-memory errors in test_fused_rope.py on the 24 GiB card.

I haven't rerun the GB10 since adding the second commit.

Checklist:

  • I have read and followed the contributing guidelines
  • The functionality is complete
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

🤖 Generated with Claude Code

rtc::KernelManager's static destructor unloads its CUDA modules through
cuda_driver::call. Each instantiation of call caches driver entry points in
function-local statics, and those caches are usually first constructed after
the KernelManager, so they are destroyed before it. ~Kernel then reads a
destroyed unordered_map: with GCC 16's libstdc++ the process segfaults at exit;
with GCC 13's it writes into freed memory instead.

Allocate the cache and its mutex with new and never destroy them, and add a
test that the process exits cleanly after running NVRTC kernels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Connor Baker <connorb@nvidia.com>
@github-actions github-actions Bot added the community-contribution PRs from external contributor outside the core maintainers, representing community-driven work. label Oct 23, 2026

@timmoon10 timmoon10 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.

Hm, this is an ugly bug and I don't have great clarity on the nicest solution. Leaking the CUDA Driver symbol cache does seem like it will work.

Comment thread tests/pytorch/test_process_exit.py Outdated
Comment on lines +27 to +36
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Requires a GPU")
@pytest.mark.skipif(bool(int(os.getenv("NVTE_DISABLE_NVRTC", "0"))), reason="Requires NVRTC")
def test_exit_after_nvrtc_kernels():
# MALLOC_PERTURB_ makes glibc overwrite freed memory, so an exit-time destructor that reads an
# already-destroyed object crashes every time rather than only sometimes.
env = dict(os.environ, MALLOC_PERTURB_="165")
result = subprocess.run(
[sys.executable, "-c", SCRIPT], env=env, capture_output=True, text=True, timeout=600
)
assert result.returncode == 0, f"exit status {result.returncode}\n{result.stderr}"

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.

LayerNorm makes no guarantee to call the NVRTC kernel. It does by default, but it can switch to cuDNN by setting NVTE_NORM_FWD_USE_CUDNN=1/NVTE_NORM_BWD_USE_CUDNN=1. We ignore this envvar in certain cases (e.g. MXFP8 LayerNorm) and we may change the defaults in the future. It may be safer to make this test name more descriptive (test_exit_after_layernorm).

Suggested change
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Requires a GPU")
@pytest.mark.skipif(bool(int(os.getenv("NVTE_DISABLE_NVRTC", "0"))), reason="Requires NVRTC")
def test_exit_after_nvrtc_kernels():
# MALLOC_PERTURB_ makes glibc overwrite freed memory, so an exit-time destructor that reads an
# already-destroyed object crashes every time rather than only sometimes.
env = dict(os.environ, MALLOC_PERTURB_="165")
result = subprocess.run(
[sys.executable, "-c", SCRIPT], env=env, capture_output=True, text=True, timeout=600
)
assert result.returncode == 0, f"exit status {result.returncode}\n{result.stderr}"
def test_exit_after_layernorm():
# MALLOC_PERTURB_ makes glibc overwrite freed memory, so an exit-time destructor that reads an
# already-destroyed object crashes every time rather than only sometimes.
env = dict(
os.environ,
# Make glibc overwrite freed memory, so an exit-time destructor that reads an
# already-destroyed object crashes every time rather than only sometimes.
MALLOC_PERTURB_="165",
# Disable cuDNN LayerNorm kernel and use NVRTC kernel instead
NVTE_NORM_FWD_USE_CUDNN="0",
NVTE_NORM_BWD_USE_CUDNN="0",
)
result = subprocess.run(
[sys.executable, "-c", SCRIPT], env=env, capture_output=True, text=True, timeout=600
)
assert result.returncode == 0, f"exit status {result.returncode}\n{result.stderr}"

ConnorBaker added a commit to ConnorBaker/nixpkgs that referenced this pull request Oct 24, 2026
…ss exit

TransformerEngine caches CUDA driver entry points in function-local
statics inside cuda_driver::call. The static KernelManager, which owns the
NVRTC-compiled kernels, is usually constructed before those caches, so
it's destroyed after them. Its destructor unloads the kernels' modules
through the destroyed caches, and processes that ran an NVRTC kernel
(LayerNorm, RMSNorm, softmax and others) can segfault at exit.

Apply NVIDIA/TransformerEngine#3733, which never
destroys the caches. Only cuda_driver.h is taken; the rest of the commit
is its test and CI wiring.

Assisted-by: Claude Code (Claude Opus 5.5)
ConnorBaker added a commit to ConnorBaker/nixpkgs that referenced this pull request Oct 24, 2026
…ss exit

TransformerEngine caches CUDA driver entry points in function-local
statics inside cuda_driver::call. The static KernelManager, which owns the
NVRTC-compiled kernels, is usually constructed before those caches, so
it's destroyed after them. Its destructor unloads the kernels' modules
through the destroyed caches, and processes that ran an NVRTC kernel
(LayerNorm, RMSNorm, softmax and others) can segfault at exit.

Apply NVIDIA/TransformerEngine#3733, which never
destroys the caches. Only cuda_driver.h is taken; the rest of the commit
is its test and CI wiring.

The patch is applied only while the version is 2.20.2; any other version
fails evaluation, so the next update has to check whether the release
contains the fix.

Assisted-by: Claude Code (Claude Opus 5.5)
…h caches

Both were function-local statics whose destructors call into CUDA after main
returns: ~Kernel unloads modules through the driver, and destroying a cached
cuDNN plan that a CUDA graph retains releases a CUDA user object. The CUDA
Programming Guide makes any use of CUDA during program termination undefined
behavior, and both crashed at exit depending on static destruction order.

Allocate both with new and never delete them, as TVMFFIConfigCache::create()
and the fused-attention debug registry already do. Both caches only ever
insert, so nothing changes while the process runs.

Replace tests/pytorch/test_process_exit.py, whose LayerNorm only runs NVRTC
kernels while the NVTE_NORM_FWD_USE_CUDNN and NVTE_NORM_BWD_USE_CUDNN switches
are off, with MALLOC_PERTURB_=165 for the C++ util tests. UtilTest.NVRTC drives
rtc::KernelManager directly, and ctest checks each test's exit status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Connor Baker <connorb@nvidia.com>
@ConnorBaker

Copy link
Copy Markdown
Author

This comment was written entirely with AI (Claude Code, Claude Opus 5.5).

Thanks for the review. I pushed ec38bcc, which changes the fix and the test.

Fix. The underlying problem is calling CUDA from static destructors at all. The CUDA Programming Guide, §2.1.6 says that "Using any of these interfaces (implicitly or explicitly) during program initiation or termination after main will result in undefined behavior". The legacy guide says the same in §6.2.1, and it's Mandatory rule CUDA 1.1 (p. 8) in NVIDIA's CUDA C++ Guidelines v3.0.1. Keeping the symbol cache alive only stopped ~Kernel's driver calls (rtc.cpp#L73-L92) from crashing; they still ran after main.

The fused-attention graph cache has the same problem. Destroying a cuDNN plan that a CUDA graph retains releases a CUDA user object (cuda_graph_retention.h#L159-L166), resolved through get_cuda_symbol()'s function-local map (cudnn_frontend_shim.h#L152-L171). That's NVIDIA/cudnn-frontend#1486.

KernelManager and the graph caches are now allocated with new and never deleted (rtc.cpp#L156-L158, graph_cache.h#L133-L135). TE already does this elsewhere:

Both caches only ever insert (rtc.cpp#L294, graph_cache.h#L120), so nothing changes before exit. The one behavioural difference is that a program that dlcloses libtransformer_engine.so and keeps running no longer unloads the NVRTC modules or destroys the cached plans. I kept the symbol-cache change as defence in depth.

Test. You're right that the LayerNorm test depended on the norm backend. NVTE_NORM_{FWD,BWD}_USE_CUDNN change production behaviour:

So test_process_exit.py is gone. Instead, UtilTest.NVRTC, which drives rtc::KernelManager directly (test_nvrtc.cpp#L66-L79), runs with MALLOC_PERTURB_=165 (CMakeLists.txt#L24-L26). ctest fails it if the process crashes at exit.

Results on an RTX 4090 (driver 615.71.09) with CUDA 13.4, cuDNN 9.27, cudnn-frontend 1.31.0 (without NVIDIA/cudnn-frontend#1565), PyTorch 2.14.1 and GCC 16, building main (c9c9225) with and without this PR. The Python scripts ran 3 times plain and 3 times with MALLOC_PERTURB_=165.

main this PR
ctest on the util tests, with this PR's MALLOC_PERTURB_ UtilTest.NVRTC and UtilTest.NVRTCConcurrentCompile pass, then segfault at exit (3 of 3 runs) all 5 pass (3 of 3 runs)
te.LayerNorm forward and backward exit 139 in 5 of 6 exit 0 in 6 of 6
DotProductAttention (cuDNN) captured in a CUDA graph exit 139 in 6 of 6 exit 0 in 6 of 6

I haven't rerun the GB10.

The CUDA-graph attention case has no test here, because the C++ tests have no attention tests. I can add a PyTorch one that pins NVTE_FUSED_ATTN=1 and NVTE_FLASH_ATTN=0, if you want it in this PR.

ConnorBaker added a commit to ConnorBaker/nixpkgs that referenced this pull request Oct 24, 2026
…cess exit

TransformerEngine calls CUDA from two static destructors at exit, and
processes can segfault there:

- The static KernelManager, which owns the NVRTC-compiled kernels
  (LayerNorm, RMSNorm, softmax and others), unloads their modules through
  cuda_driver::call, whose symbol caches are function-local statics.
  Those are usually constructed after the KernelManager, so they're
  destroyed first.
- The fused-attention graph caches destroy cuDNN plans retained by CUDA
  graphs. That resolves CUDA symbols through cudnn-frontend's
  get_cuda_symbol(), whose map can already be destroyed (fixed in
  cudnn-frontend by the previous commit).

Apply both commits of NVIDIA/TransformerEngine#3733.
The first never destroys cuda_driver::call's symbol caches; the second
never destroys the KernelManager or the graph caches. Only the library
changes are taken; the rest is test and CI changes.

The patches are applied only while the version is 2.20.2; any other
version fails evaluation, so the next update has to check whether the
release contains the fix.

Assisted-by: Claude Code (Claude Opus 5.5)
@ConnorBaker ConnorBaker changed the title [Common] Keep cuda_driver::call's symbol cache alive through exit [Common] Don't call CUDA from static destructors at exit Oct 24, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-contribution PRs from external contributor outside the core maintainers, representing community-driven work.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants