[None][fix] Release MoE workspaces before erasing the CUDA graph pool - #17403
Draft
KleinBlueC wants to merge 3 commits into
Draft
[None][fix] Release MoE workspaces before erasing the CUDA graph pool#17403KleinBlueC wants to merge 3 commits into
KleinBlueC wants to merge 3 commits into
Conversation
FusedMoeRunner allocates its workspace under isCapturing (moeOp.cpp getWorkspaceInfo, the only thop unit that does), so the tensor comes from the graph's private memory pool, and caches it in mStreamWorkspaces keyed by the capture stream. That map hangs off MoERunner.runner_dict, a class attribute, so it outlives the executor that captured the graph. With KV-cache-size estimation on, that executor is torn down between the two warmups; the workspace is still held when clear() erases the pool, and the next clear_all_workspaces() frees it against a pool that no longer exists -- SIGSEGV inside the caching allocator: _Rb_tree_decrement -> free_block -> local_raw_delete -> ~TensorImpl -> moeOp.cpp:979 (WorkspaceInfo) -> moeOp.cpp:363 (clearWorkspaces) Releasing the workspaces at the top of clear() keeps them inside the pool's lifetime. Blast radius is exactly FusedMoeRunner: moeOp.cpp is the only thop translation unit that allocates under isCapturing and caches the result. Established on the Inkling NVFP4 tp=4/ep=4 + CUDA graph reproducer, five arms on one tree with the call site chosen by env: no call SIGSEGV before graph.reset() clean after graph.reset() clean no call, CUDA graph off clean after empty_cache() SIGSEGV The last arm is why this is a cause and not a coincidence: it makes the same call in the same process and still crashes, so the fix does not work by perturbing allocator layout. It also locates the deadline precisely -- both sides of graph.reset() are clean, and only crossing empty_cache() faults, so the boundary is where release_cached_blocks() erases the PrivatePool, not reset(), which merely drops its use_count. The accompanying test asserts the ordering only. The fault itself cannot be staged without the real allocator layout: in pure torch a held block keeps cudaMalloc_count above zero, so the pool is never erased and a stale handle and an erased pool cannot coexist. Ordering is checkable with no GPU at all, so the guard runs in any lane. Not Inkling-specific: tp=4/ep=4 is the most common layout in the accuracy suite (20 configs across 10 model classes). Those models simply do not land the workspace where erasure catches it; the hazard is in the shared path. Signed-off-by: KleinBlueC <102355066+KleinBlueC@users.noreply.github.com>
The release was at the top of clear(), which is the wrong side of a hazard the codebase already documents: PyExecutor.shutdown() warns that freeing a GPU workspace referenced by raw pointers inside captured CUDA graphs, ahead of the graph teardown, can trigger a device-wide cudaErrorIllegalAddress. The MoE workspace is exactly such a buffer -- getWorkspaceInfo() allocates it under isCapturing so the graph can replay against a stable address. Both bounds are load-bearing, and the reproducer showed the second one directly: placing the call after graph.reset() is clean, and only moving it past empty_cache() crashes again. So the window is between the two -- after no graph references the workspace, before release_cached_blocks() erases the pool. Signed-off-by: KleinBlueC <102355066+KleinBlueC@users.noreply.github.com>
Allocations made during CUDA graph capture and still held when the pool is
torn down fault the caching allocator when they are finally freed. That is the
hazard CUDAGraphRunner.clear() closes by releasing the MoE workspaces before
empty_cache(), and until now it could only be observed on a full Inkling NVFP4
run at tp=4/ep=4.
The reproduction needs two ingredients together, and the test pins both:
expandable_segments pre-expansion result
on yes SIGSEGV
on no clean
off yes clean
off no clean
expandable_segments:True being required matches the necessary condition
measured on the real crash, which is the evidence that the two are the same
hazard. Under CUDA_LAUNCH_BLOCKING=1 the backtrace holds no kernel frame at
all: the fault is a host-side dereference of a neighbour Block* inside
try_merge_blocks, reached from a tensor destructor, which is the same allocator
path the Inkling stack enters from ~WorkspaceInfo.
A SIGSEGV cannot be caught in-process, so each arm runs in a fresh subprocess
and the assertion is on its exit status. Fresh matters: an already initialised
CUDA context changes the allocator layout under test.
Signed-off-by: kleinc <kleinc@nvidia.com>
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.
What breaks
CUDAGraphRunner.clear()erases the CUDA graph's private memory pool while theC++
FusedMoeRunnerstill holds a workspace tensor allocated from it. The nextMoERunner.clear_all_workspaces()frees that tensor against a pool that nolonger exists, and the PyTorch caching allocator segfaults:
This is memory corruption, not exhaustion — running out of memory raises
torch.OutOfMemoryError; it does not walk the allocator's own free-block treeoff a cliff.
Why
moeOp.cppgetWorkspaceInfo()allocates the workspace unconditionally whenisCapturing(stream), so the graph can replay against a stable buffer.PyTorch routes allocations on the capturing stream into the graph-private
mempool, so this tensor is a private-pool block.
moeOp.cppis the only unitunder
cpp/tensorrt_llm/thop/that allocates underisCapturing.It is cached in
mStreamWorkspaces, keyed by the capture stream, on arunner held in
MoERunner.runner_dict— a class attribute, soprocess-lifetime. The runner outlives the executor that captured the graph.
With KV-cache-size estimation on (the default), an executor is built, warmed
up (capturing graphs), then torn down before the real one is built. Shutdown
reaches
CUDAGraphRunner.clear(), which resets the graphs, drops the poolhandle and calls
empty_cache()— whererelease_cached_blocks()erases thePrivatePool.The next warmup calls
MoERunner.clear_all_workspaces(), which frees thestep-1 tensor against the pool erased in step 3.
The deadline is
empty_cache(), notgraph.reset():reset()only drops thepool's
use_count. This patch releases the workspaces between the two — afterthe graphs are gone, before the pool is erased. See Side effects for why both
bounds matter.
Why Inkling hits it and other MoE models do not
The hazard is in the shared path, not in Inkling.
tp=4 / ep=4is the mostcommon layout in the accuracy suite — 20 configs across 10 model classes
(DeepSeek-V3Lite / V4Flash / R1, GPT-OSS, NemotronV3, Qwen3, Step3 …), all
capturing CUDA graphs successfully. So neither the config nor the CUDA driver is
the differentiator.
What varies is whether step 3 actually erases the pool, which happens only when
its segments are free at that instant — an allocator-layout question. Both knobs
that appear to control this bug are layout knobs: expert-parallel size moves the
MoE blocks around, and BF16 vs NVFP4 changes the weight footprint from ~40 GiB
to ~127 GiB per GPU. Inkling's layouts reach the erasing state; the other ten
models' evidently do not.
To be clear about the limit of that claim: this is characterised, not fully
explained. The hazard is demonstrably in the shared path and demonstrably
layout-sensitive, but I cannot give a segment-level account of why the other
models' blocks stay put. The fix does not depend on that answer — it removes the
window regardless of which layouts would have landed in it.
Validation
Reproduced on Inkling NVFP4 at
tp=4 / ep=4with CUDA graphs on, whichsegfaults reliably, and clean with the patch. Moving the same call to after
empty_cache()crashes again, which is what rules out the fix working by merelyperturbing allocator layout rather than by closing the window.
Related: this is the crash that
PR #17062 currently guards
against at load time in
InklingForCausalLM._assert_inkling_moe_parallel("
moe_tp_size=1… segfaults during CUDA-graph capture"). That guard was addedbecause the cause was unknown; it can be removed once this lands.
Side effects
Placement is constrained on both sides. The release sits between
graph.reset()andempty_cache(), and neither bound is arbitrary. Beforegraph.reset()is unsafe: captured graphs hold raw pointers into thisworkspace, and
PyExecutor.shutdown()already documents that freeing such abuffer ahead of graph teardown risks a device-wide
cudaErrorIllegalAddress.After
empty_cache()is too late, because that is where the pool is erased.The call is global; the method is not.
MoERunner.clear_all_workspaces()walks
MoERunner.runner_dict, a class attribute, so it clears everyFusedMoeRunnerin the process — not only the ones whose workspaces came fromthe pool being erased.
That has a concrete consequence in this codebase.
PyExecutor.shutdown()tearsengines down in a loop:
Each engine owns its own
CUDAGraphRunner, graphs and pool. When the targetengine's
clear()runs, it frees the draft engine's MoE workspace too — whilethe draft engine's captured graphs are still alive and still hold raw pointers
into it. That is the same hazard the placement above is careful to avoid within
one runner, reintroduced across runners.
I have not observed this fire; it needs speculative decoding with a draft model
that captures graphs and runs MoE. But it is the same mechanism as a documented
one, so it should be weighed rather than assumed benign. The clean form of this
fix releases only the workspaces belonging to the pool being erased, which needs
a per-stream or per-pool entry point on the C++ side (
moeOp.cppcurrentlyexposes only
clear_workspaces(), all-or-nothing). This PR trades a narrower,unobserved window for a reproducible crash; whether that trade is acceptable, or
whether the C++ API should be extended first, is a reviewer call.
Cost. One workspace re-allocation per stream on next MoE use, on a teardown
path that already calls
empty_cache()— nothing on a steady-state path.clear()is only reached fromPyExecutor.shutdown()and the model engine'scleanup, both verified teardown-only.
Numerics. None. The workspace is scratch;
getWorkspaceInfo()alreadyreallocates it whenever capture is active or the required size grows.
No MoE.
runner_dictis empty and the call is a no-op.Not touched.
EncoderCUDAGraphRunner.clear()has the same shape, butwhether an encoder tower ever allocates a MoE workspace is unconfirmed, so this
does not add a call there on the strength of the pattern alone.
Not measured. No performance or accuracy regression run on a configuration
that was already healthy. The reasoning above says there should be none.
Alternatives considered
This patch is the smallest change that closes the window. Two other shapes were
considered and are worth stating, because the side effect above is a property of
this shape rather than of the fix.
Split
clear()intodestroy_graphs()andrelease_pool(). Python only,no C++ change.
PyExecutor.shutdown()would then destroy every engine's graphs,release the workspaces once, and only then erase the pools:
That places the global call at the one instant when it is safe for every
engine, so the cross-engine window disappears. Cost is a wider API — one method
becomes three — and edits to
PyExecutor.shutdown()and the model engine'scleanup path. Arguably it is also the more honest decomposition:
clear()currently couples two things with different lifetimes, which is what created
the window in the first place.
Per-pool release on the C++ side. Add something like
clear_workspaces_for_stream()tomoeOp.cpp, so the release granularitymatches the pool granularity and
clear()frees only what it is erasing.Semantically the cleanest, and it removes the global call entirely. Cost is a
C++ change plus a rebuild, and
CUDAGraphRunnerwould have to know its capturestreams.
I went with the minimal version here on the grounds that it fixes a
reproducible crash with one call and no API change. If reviewers would rather
not take the cross-engine window, the split above is the cheaper of the two
alternatives and needs no C++ work.
Test
tests/unittest/_torch/executor/test_cuda_graph_runner_clear.pyasserts thatclear()releases the MoE workspaces beforeempty_cache(), by mocking bothand checking their order. No GPU, no model, no CUDA, so it runs in any lane.
Triggering the fault needs the real allocator layout — in pure torch a held
block keeps the pool's
cudaMalloc_countabove zero, so the pool is nevererased and a stale handle and an erased pool cannot coexist. The ordering is
what regresses, and the ordering is checkable anywhere.
Please review the following before submitting your PR:
PR description clearly explains what and why. If using CodeRabbit's summary, please make sure it makes sense.
PR Follows TRT-LLM CODING GUIDELINES to the best of your knowledge.
Test cases are provided for new code paths (see test instructions)
If PR introduces API changes, an appropriate PR label is added - either
api-compatibleorapi-breaking. Forapi-breaking, includeBREAKINGin the PR title.Any new dependencies have been scanned for license and vulnerabilities
CODEOWNERS updated if ownership changes
Documentation updated as needed
Update tava architecture diagram if there is a significant design change in PR.
The reviewers assigned automatically/manually are appropriate for the PR.
Please check this after reviewing the above items as appropriate for this PR.
GitHub Bot Help
To see a list of available CI bot commands, please comment
/bot help.