Repository navigation
[Common] Don't call CUDA from static destructors at exit - #3733
ConnorBaker wants to merge 2 commits into
Conversation
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>
timmoon10
left a comment
There was a problem hiding this comment.
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.
| @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}" |
There was a problem hiding this comment.
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).
| @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}" |
…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)
…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>
|
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 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
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 Test. You're right that the LayerNorm test depended on the norm backend.
So 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
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 |
…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)
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
mainreturns. 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.rtc::KernelManager: its destructor runs~Kernel, which unloads modules throughcuda_driver::call(rtc.cpp#L73-L92).cuda_driver::callcaches driver entry points in function-local statics, which are usually first constructed after theKernelManager, when its first kernel is loaded. Statics are destroyed in reverse order of construction, so those caches are already destroyed when~Kernelreads them.get_cuda_symbol()'s function-local map (cudnn_frontend_shim.h#L152-L171), which can likewise already be destroyed. That's [Bug]: CUDA graph plan retention can SIGSEGV during Python process teardown cudnn-frontend#1486. [Auto-FE] shim: keep the CUDA library handle map alive through process teardown (#1486) cudnn-frontend#1565 keeps that map alive ondevelop, but no release has it yet.Whether the
KernelManagercase 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 NGCpytorch:26.09-py3container), the destroyed map looks empty, socuda_driver::callinserts into it, writing to freed memory, and the process exits 0. WithMALLOC_PERTURB_=165, both crash.Fix:
KernelManagerand the graph caches are allocated withnewand 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, andHandleManagernever 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
dlcloseslibtransformer_engine.soand keeps running no longer unloads the NVRTC modules or destroys the cached plans.Fixes # (no existing issue found)
Type of change
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 withMALLOC_PERTURB_=165, soctestfails a test whose process crashes at exit.UtilTest.NVRTCdrivesrtc::KernelManagerdirectly (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 withMALLOC_PERTURB_=165.maincteston the util tests, with this PR'sMALLOC_PERTURB_UtilTest.NVRTCandUtilTest.NVRTCConcurrentCompilepass, then segfault at exit (3 of 3 runs)te.LayerNormforward and backwardDotProductAttention(cuDNN) captured in a CUDA graphI 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.pyexited 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.pytolerance failures, and out-of-memory errors intest_fused_rope.pyon the 24 GiB card.I haven't rerun the GB10 since adding the second commit.
Checklist:
🤖 Generated with Claude Code