Repository navigation
[Common] Keep cuda_driver::call's symbol cache alive through exit - #3733
Draft
ConnorBaker wants to merge 1 commit into
Draft
ConnorBaker wants to merge 1 commit into
ConnorBaker wants to merge 1 commit 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>
2 of 13 tasks
timmoon10
reviewed
Oct 24, 2026
timmoon10
left a comment
Member
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.
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}" |
Member
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).
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)
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Processes that ran an NVRTC-compiled kernel (LayerNorm, RMSNorm, softmax and others since #3156) can segfault at exit, after all their work is done. In pytest this turns a passing test file into exit status 139.
Cause: an exit-time destructor uses function-local statics that were already destroyed.
cuda_driver::callcaches driver entry points in function-local statics,symbol_cacheandcache_mutex. Each instantiation of the template has its own, andcall<int *, int>(forcuDeviceGet) is a different instantiation fromcall<CUcontext *, int>(forcuDevicePrimaryCtxRetain).Kernel::~Kernelunloads its modules through five driver calls:cuDeviceGet,cuDevicePrimaryCtxRetain,cuCtxSetCurrent,cuModuleUnloadandcuDevicePrimaryCtxRelease.KernelManager::~KernelManager, the destructor of theKernelManager::instance()static. Static destructors run in reverse order of construction (__cxa_atexit). So if an instantiation's cache was constructed after theKernelManager, it's already destroyed when~Kerneluses it.In the common case, that's every one of them:
KernelManager::compileconstructs theKernelManager.Kernel::get_function, which makes the firstcuDeviceGet,cuDevicePrimaryCtxRetainand similar calls in the process. That constructs those instantiations' caches after theKernelManager.Which caches outlive the
KernelManagerdepends on which driver calls ran before the first NVRTC compile. For example,cuda::supports_multicast()also callscuDeviceGet.Traced in gdb on an unpatched build (watchpoints on the guard variables, and breakpoints on
__cxa_atexitand onoperator deleteof the cache's bucket array):KernelManageris constructed, and its destructor registered, fromcompile.call<int *, int>cache is constructed, and its destructor registered, fromget_function.__run_exit_handlersfrees that cache's bucket array.~KernelManagerstart.~Kernelfaults incuda_driver::call("cuDeviceGet", ...), reading that cache:Calling
transformer_engine_torch.device_supports_multicast()before the first LayerNorm constructs thecall<int *, int>cache first. Its destructor then runs after~KernelManager, and the crash moves to the next call in~Kernel: thecall<CUcontext *, int>cache, forcuDevicePrimaryCtxRetain. That cache was still constructed byget_function. This held in all 3 crashes sampled with each order.Whether it crashes depends on the C++ standard library the extension was built with. The use of a destroyed cache happens either way.
~_Hashtablefrees the nodes and the bucket array but leaves_M_element_countand_M_before_beginas they were. The lookup then walks a freed node whose first two words glibc has overwritten with free-list links, and faults.PYTHONFAULTHANDLER=1set.pytorch:26.09-py3container, TE 2.19.0+5e52befd):~_Hashtablecallsclear()first, so the destroyed cache looks empty. The lookup misses, andcuda_driver::callinserts the symbol back into the destroyed map. At exit, the map holds a new node while its bucket pointer is still the freed array, which is a write to freed memory, and the process exits 0.MALLOC_PERTURB_=165(glibc overwrites freed memory), both crashed in 3 of 3 runs, with the stack above.The code involved is unchanged on
main.Fix: allocate
symbol_cacheandcache_mutexwithnewand never destroy them. Any static destructor can then call into the driver throughcuda_driver::call, whatever the construction order. Today that's~KernelManager, but it would also cover later ones. The cost is one map and one mutex per instantiation, left for the OS to reclaim at exit.~Kernelitself is unchanged and still unloads its modules. With the fix, at exit on the GB10, all five of its driver calls returnedCUDA_SUCCESSfor each of the 3 loaded modules.Fixes # (no existing issue found)
Type of change
Changes
transformer_engine/common/util/cuda_driver.h:cuda_driver::call's symbol cache and its mutex are never destroyed.tests/pytorch/test_process_exit.py(new): runs LayerNorm forward and backward in a subprocess withMALLOC_PERTURB_=165and checks that the subprocess exits with status 0.qa/L0_pytorch_unittest/test.sh: runs the new test.Testing
On each platform, two builds of TE against PyTorch 2.14.1 and CUDA 13.4, differing only by this change:
main(c9c9225).main.mainin 3 of 3, 4090mainin 5 of 5MALLOC_PERTURB_=165te.LayerNormonly,MALLOC_PERTURB_=165te.LayerNormafterdevice_supports_multicast(),MALLOC_PERTURB_=165main: 2; 4090main: 3), in thecuDevicePrimaryCtxRetaincachemain: 2; 4090main: 3)te.Linearonly (no NVRTC kernels),MALLOC_PERTURB_=165tests/pytorch/test_process_exit.pyL0 files: the GB10 2.20.2 builds ran 18 files of
qa/L0_pytorch_unittest/test.sh, and the 4090mainbuilds ran 7. With and without the change, every file had the same pass, fail, skip and xfail counts.test_recipe.py,test_jit.py,test_cuda_graphs.py,test_quantized_tensor.py,test_torch_compile.pyandtest_fused_optimizer.py.On the RTX 4090,
test_torch_compile.pycan still exit 139 with this change, from a different bug: NVIDIA/cudnn-frontend#1486.fused_attn'sGraphCachestatic destroys cuDNN plans that were captured into a CUDA graph. Releasing their graph user objects goes through cudnn-frontend'sget_cuda_symbol(), whose function-local handle map has already been destroyed. It's the same pattern as this bug.developthe same way this PR does, but it isn't in a release yet: 1.31.0 predates it, and TE requiresnvidia-cudnn-frontend>=1.29.0.MALLOC_PERTURB_=165.test_torch_compile.pyexited 139 in 1 of 2 plain runs and 2 of 2 runs withMALLOC_PERTURB_=165.The compiler warnings are the same with and without the change, for all three pairs of builds, apart from nvcc's per-build temporary file names and hashes.
Checklist:
🤖 Generated with Claude Code