Skip to content

fix(server): raise the CUDA graph-cache default to survive long-lived shape-diverse decode (#818)#820

Merged
inureyes merged 4 commits into
mainfrom
fix/issue-818-cuda-graph-cache-default
Jul 19, 2026
Merged

fix(server): raise the CUDA graph-cache default to survive long-lived shape-diverse decode (#818)#820
inureyes merged 4 commits into
mainfrom
fix/issue-818-cuda-graph-cache-default

Conversation

@inureyes

Copy link
Copy Markdown
Member

Summary

On the CUDA backend, a long-lived shape-diverse mlxcel-server (batched decode, and especially speculative decoding) aborts the whole process with an uncaught C++ std::runtime_error("Cache thrashing is happening, please set the environment variable MLX_CUDA_GRAPH_CACHE_SIZE to a larger value than 400 ..."), dropping every in-flight request. Root cause in vendored MLX 0.32.1: mlx/backend/cuda/lru_cache.h throws once a graph-cache miss counter that never resets passes 2 * capacity, and mlx/backend/cuda/device.cpp sets that capacity to 400, so the throw threshold is 800 lifetime misses. Speculative and batched decode multiply distinct graph shapes (draft/verify phases times batch sizes times sequence-length buckets), so the abort is inevitable at the default. MLX_CUDA_GRAPH_CACHE_SIZE=2000 was verified to eliminate the crash (13/13 requests across bursts on GB10).

This ships the short-term startup default (proposal 1 in the issue), mirroring the existing apply_metal_ops_per_buffer_default pattern. The upstream lifetime-miss fix (proposal 3) and the FFI-boundary exception catch (proposal 2) remain separate follow-ups; the process is unrecoverable after the throw, so the startup default is the shippable fix.

What changed

  • src/lib/mlxcel-core/src/hardware.rs: added cuda_graph_cache_default() -> Option<u32>, a pure, cfg-gated function returning Some(2000) on a cuda-feature build and None otherwise, and apply_cuda_graph_cache_default(), which sets MLX_CUDA_GRAPH_CACHE_SIZE to that value before any MLX op unless the operator already set it (an explicit value always wins), emitting one tracing::info! line. Both carry the same doc-comment rationale and the same SAFETY comment shape as the Metal analogue.
  • src/main.rs, src/bin/mlx_server.rs, src/bin/bench_decode.rs: call apply_cuda_graph_cache_default() immediately after the existing apply_metal_ops_per_buffer_default() at the top of main, before any model load or MLX op.
  • src/main.rs --help env-var listing: documented MLX_CUDA_GRAPH_CACHE_SIZE next to MLX_MAX_OPS_PER_BUFFER.
  • docs/environment-variables.md: added an MLX_CUDA_GRAPH_CACHE_SIZE row (CUDA-only, raised default 2000, why MLX's own 400 aborts long-lived servers, explicit value overrides).
  • src/lib/mlxcel-core/src/hardware.rs tests: added cuda_graph_cache_default_matches_build_feature, mirroring the Metal gating test and cfg-gated to both feature states (avoids a flaky global-env test, matching the existing pattern that does not test the apply_* mutator).

2000 is an LRU capacity cap, not a preallocation, so it only costs memory as distinct graph shapes accumulate, and the variable is read only by MLX's CUDA backend so it is a harmless no-op on Metal/CPU.

Test plan

  • cargo check -p mlxcel-core --lib --features cuda (exit 0)
  • cargo test -p mlxcel-core --lib --features cuda hardware (26 passed, 0 failed)
  • cargo clippy -p mlxcel-core --lib --tests --features cuda -- -D warnings (exit 0)

Closes #818

On the CUDA backend, MLX keeps an LRU cache of captured CUDA graphs keyed by graph shape (mlx/backend/cuda/device.cpp, default capacity 400), and mlx/backend/cuda/lru_cache.h also counts a lifetime graph-cache miss counter that never resets, throwing a fatal std::runtime_error("Cache thrashing is happening ...") once lifetime misses pass 2 * capacity (800 at the default). Any long-lived, shape-diverse CUDA server crosses that threshold over its lifetime, and speculative or batched decode reaches it fastest because the draft and verify phases across varying batch sizes and sequence-length buckets multiply the number of distinct graph shapes. The throw is a whole-process abort, not a request-level error, so it drops every in-flight request. MLX_CUDA_GRAPH_CACHE_SIZE=2000 was verified to eliminate the crash (13/13 requests across bursts on GB10).

Mirror the existing apply_metal_ops_per_buffer_default startup pattern with a CUDA analogue in mlxcel-core: cuda_graph_cache_default() returns Some(2000) on a cuda-feature build and None otherwise, and apply_cuda_graph_cache_default() sets MLX_CUDA_GRAPH_CACHE_SIZE to that value before any MLX op unless the operator already set it (an explicit value always wins), logging one info line. The three entry points that already apply the Metal default (src/main.rs, src/bin/mlx_server.rs, src/bin/bench_decode.rs) now apply the CUDA default immediately after. 2000 is an LRU capacity cap, not a preallocation, so it only costs memory as distinct graph shapes accumulate, and the variable is read only by MLX's CUDA backend so it is a harmless no-op on Metal/CPU.

The upstream lifetime-miss fix and the FFI-boundary exception catch are left as separate follow-ups (proposals 2 and 3 in the issue); this startup default is the shippable fix. Documented in docs/environment-variables.md and the mlxcel --help env-var listing.

Verified on mlxcel-core with the cuda feature: cargo check passes, 26 hardware unit tests pass (including cuda_graph_cache_default_matches_build_feature), and clippy is clean under -D warnings.

Refs #818
@inureyes inureyes added type:bug Bug fixes, error corrections, or issue resolutions priority:high High priority area:inference Generation, sampling, decoding (incl. speculative, DRY) status:review Under review labels Jul 19, 2026
inureyes added 3 commits July 19, 2026 22:47
…oo (#818)

The #818 startup default (MLX_CUDA_GRAPH_CACHE_SIZE=2000 on CUDA builds) was wired into mlxcel, mlxcel-server, and the decode benchmark, but not into speculative_bench, which runs the exact workload issue #818 names as the fastest trigger of MLX's fatal "Cache thrashing" abort: a long-lived process that sweeps many distinct draft/verify graph shapes across pairings and K values. Without the default, speculative_bench on a CUDA build can still cross MLX's 800-lifetime-miss threshold at capacity 400 and abort mid-sweep.

Call apply_cuda_graph_cache_default() at the top of main, right after arg parsing and before any model load or MLX op, mirroring the placement already used in bench_decode. Scoped to the CUDA default only (the metal MLX_MAX_OPS_PER_BUFFER knob is #353, out of scope here). No-op off CUDA; an explicit MLX_CUDA_GRAPH_CACHE_SIZE always wins.

Refs #818
The operator notice requested by issue #818 was dead: apply_cuda_graph_cache_default emitted a tracing::info! from the top of every main(), before the server installs its tracing subscriber (initialize_server_logging in start_server), so the line was discarded on every path while the env var itself was still set correctly.

Move the notice into start_server, right after the "Runtime device" log where a subscriber is guaranteed installed and runtime.device is in scope. It is gated on the cuda feature plus runtime.device == Gpu so it never fires on Metal or CPU, reads the effective MLX_CUDA_GRAPH_CACHE_SIZE, and reports that mlxcel raises MLX's default of 400 to 2000 unless an operator override is set. The env-setting helper keeps its early-main() behavior on all four binaries; only the dead log leaves. Observability lives on the server path only, matching the silent sibling apply_metal_ops_per_buffer_default.

Verified with cargo check -p mlxcel-core --lib --features cuda, cargo clippy -p mlxcel-core --lib --features cuda -- -D warnings, and cargo check --bin mlxcel-server --features cuda.

Refs #818
Complete the polish flagged by review on PR #820. The SAFETY comment in `apply_cuda_graph_cache_default` (src/lib/mlxcel-core/src/hardware.rs) listed only three in-tree callers, missing the fourth added later, `src/bin/speculative_bench.rs`; the comment now lists all four so the single-threaded-access rationale it documents is accurate. `docs/CONTINUOUS_BATCHING.md`'s speculative-decoding operator-controls section already cross-references environment-variables.md for the MTP/QMV knobs, so it gets a concise pointer to the new `MLX_CUDA_GRAPH_CACHE_SIZE` default, since speculative decode's draft/verify shape diversity is exactly what drives MLX's captured-graph-cache LRU hardest on CUDA.

Validation:
- cargo fmt --all -- --check (clean)
- cargo clippy -p mlxcel-core --lib --features cuda -- -D warnings (clean)

Refs #818
@inureyes inureyes added status:done Completed and removed status:review Under review labels Jul 19, 2026
@inureyes
inureyes merged commit bac0fca into main Jul 19, 2026
5 checks passed
@inureyes
inureyes deleted the fix/issue-818-cuda-graph-cache-default branch July 19, 2026 14:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:inference Generation, sampling, decoding (incl. speculative, DRY) priority:high High priority status:done Completed type:bug Bug fixes, error corrections, or issue resolutions

Projects

None yet

1 participant