Stop a failed CUDA graph commit from poisoning the encoder - #4356
Merged
zcbenz merged 4 commits intoAug 23, 2026
Merged
Conversation
zcbenz
reviewed
Aug 20, 2026
CommandEncoder::commit() returns early when an exception is thrown, for example by the graph-cache thrashing check from ml-explore#2600, leaving from_nodes_, to_nodes_, the graph keys, node_map_ and graph_ holding the nodes of the failed graph. The next commit mixes them into a fresh graph and fails in cudaGraphAddDependencies with cudaErrorInvalidValue, and every evaluation after that keeps failing until the process is restarted. Split the body into commit_impl() and restore the same state the successful path restores when it throws, so a recoverable error only fails the evaluation that caused it. Two details the recovery depends on: - check_cuda_error() never calls cudaGetLastError(), so the runtime error stays pending. ~CudaHandle() skips its destroy while an error is pending, which would leak every handle released afterwards and keep the graph from being recreated. Clear it first. - CudaHandle::operator= can throw from both the destroy and the create. A graph left populated is worse than none: its nodes are still roots and run again on the next launch, while the fresh dependencies reference only the new nodes. Drop the handle even when destroying it fails. The regression test shrinks the graph cache on a fresh stream and walks distinct graph topologies until the check fires. The cache key is topological, so varying shapes alone does not produce new keys. Before this change the test sees one thrashing error followed by five cudaGraphAddDependencies failures; after it, six independent thrashing errors and no corruption.
The test shrank MLX_CUDA_GRAPH_CACHE_SIZE to 1 and never put it back, so any stream created by a later test would have picked up a one-entry graph cache. Wrap the overrides in a scope guard that restores the previous values. Also drop the MLX_USE_CUDA_GRAPHS override: use_cuda_graphs() caches the value on first use, so setting it here has no effect. Skip the test instead when graphs are disabled, since there is then no cache to thrash.
Move the clear()s into clear_graph_state() and call it from both, so the field list lives in one place. The error path additionally zeroes the node and byte counters, which commit_impl already does unconditionally further down, and keeps its own defensive handling of the graph handle. This also clears active_deps_, active_outputs_ and concurrent_nodes_ on the successful path. They are consumed by insert_graph_dependencies and are already empty when commit runs, so this is a no-op there.
zcbenz
force-pushed
the
fix/cuda-command-encoder-exception-safety
branch
2 times, most recently
from
August 23, 2026 03:41
45e94ac to
02529e8
Compare
zcbenz
force-pushed
the
fix/cuda-command-encoder-exception-safety
branch
from
August 23, 2026 03:41
02529e8 to
7486522
Compare
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.
Proposed changes
Fixes #4326.
CommandEncoder::commit()exits before resetting its in-progress graph state when an exception is thrown — for example by the cache-thrashing check added in #2600, which throws fromgraph_cache_[graph_key].from_nodes_,to_nodes_, the graph keys,node_map_andgraph_all stay populated with the nodes of the failed graph. The next commit mixes them into a fresh graph and fails incudaGraphAddDependencieswithcudaErrorInvalidValue. After that first failure the encoder stays poisoned and every later evaluation keeps failing until the process is restarted.Production logs show the transition within 29 ms:
The first error occurs once; the second then repeats for every new inference request — 870 occurrences in the captured service log — until restart.
This is not a request to change the thrashing detection. The problem is that a recoverable exception leaves
CommandEncoderin an invalid state.The fix
Split the body into
commit_impl()and restore the graph state when it throws:reset_graph_state_after_error()restores exactly the state the successful path ofcommit_impl()restores. Three details it depends on:The pending runtime error has to be cleared first.
check_cuda_error()only formats and throws; it never callscudaGetLastError(), so the error stays pending on the runtime.~CudaHandle()skips its destroy while an error is pending, so every handle released after this point would leak, and recreating the graph below would fail because of an error that has already been reported. ThecudaGraphExecUpdatefailure path incommit_impl()already does the same thing.The old graph has to be dropped even when destroying it fails.
CudaHandle::operator=callsreset()first, so both the destroy and the create can throw, leavinggraph_holding the old handle. A graph left populated is worse than no graph at all: its nodes are still roots and run again on the next launch, while the fresh dependencies reference only the new nodes — socudaGraphAddDependenciessucceeds and the stale kernels re-execute silently against buffers whose temporaries were already released.CudaHandle::release()is added so the handle is given up even ifcudaGraphDestroyfails.The recovery must not replace the error being thrown. Wrapping it keeps the caller's original
Cache thrashing is happening ...intact instead of surfacing an unrelated graph-creation failure.Fields that cannot throw are cleared before the graph is touched, so a failure while recreating it still leaves the rest of the encoder empty.
The current evaluation still fails and returns the original error. The next one no longer inherits corrupted graph nodes and dependencies.
Test
tests/cuda_graph_recovery_tests.cpp, compiled whenMLX_BUILD_CUDAis on.The cache key is topological — one
"K-"per node plus the dependency edges — and does not include shapes, so varying tensor sizes alone never produces a new key. The test instead lengthens the op chain on each iteration, and runs on a freshly created stream so that stream'sCommandEncoderpicks up the shrunken cache capacity at construction.The
1 / 5split reproduces the production signature exactly: the thrashing exception fires once, then every later commit degrades intocudaGraphAddDependencies.Verified on RTX 4060 Ti (
sm_89), CUDA 13.0, MSVC 19.44, Windows x64. Full suite: 3435 assertions, 0 failed — the four GGUF cases fail on this platform regardless of this change, sinceCMakeLists.txtforcesMLX_BUILD_GGUF OFFunder MSVC. Also exercised against a long-running inference workload with mixed model and LoRA shapes onsm_86andsm_89with the production settings (MLX_CUDA_GRAPH_CACHE_SIZE=1024, thrashing check disabled, CUDA graphs on), with no cache-thrashing orcudaGraphAddDependencies invalid argumenterrors.Out of scope
Two related things this deliberately leaves alone:
cache_misses_inLRUCacheis never reset, so once the threshold is crossed every subsequent miss keeps throwing. This change turns a permanently poisoned encoder into one failed evaluation per miss; requests that hit the cache recover, but a workload that keeps producing new graph topologies will keep seeing the thrashing error. Making the check one-shot seems like a separate decision for the author of Detect cache thrashing in LRUCache #2600.ConcurrentContext::~ConcurrentContext()callsCHECK_CUDA_ERROR(cudaGraphAddEmptyNode(...))from a destructor, which terminates if it throws in the same sticky-error state that motivates this fix. Same class of problem in the same file, but unrelated to the commit path.Note that increasing
MLX_CUDA_GRAPH_CACHE_SIZEdelays the first exception but does not make the commit path exception-safe.Checklist
Put an
xin the boxes that apply.pre-commit run --all-filesto format my code / installed pre-commit prior to committing changes