Skip to content

ggml-cuda: drop the CUDA graph cache count cap, as upstream does - #253

Merged
danielhanchen merged 2 commits into
carry/superseded-pin-optimizationsfrom
fix/cuda-graph-cache-no-cap
Oct 9, 2026
Merged

danielhanchen merged 2 commits into
carry/superseded-pin-optimizationsfrom
fix/cuda-graph-cache-no-cap

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Fixes the tensor split decode regression in unslothai/unsloth#12468: every -mix prebuilt from b10715-mix-86bd2d3 onwards decodes 2.4x to 4.3x slower than the official ggml-org build of the same tag with --split-mode tensor. Single GPU and layer split are unaffected.

Cause

ggml-cuda: key the CUDA graph cache by shape (271f947, from #144, now carried by #241) keys the graph cache by first node, last node and node count, so an alternating speculative verify batch keeps one graph per shape instead of resetting warmup on every call. To stop many distinct shapes from growing the map, it also capped it at 64 entries with LRU eviction, on top of the existing sweep that drops graphs unused for 10 s.

That cap was sized from single GPU runs (4 captures for Qwen3.8-27B). Tensor split needs about 2 * n_layers + 1 graphs per device for one shape, one per segment between the cross-device reductions, and twice that with MTP. Any model past about 31 layers exceeds 64, the LRU evicts graphs that are still in use, and every token re-captures instead of replaying. The reporter's counters show it: about 230k graphs created and about 230k evicted by the cap in two minutes on Qwen3.8-27B.

What upstream does

Upstream has no count cap. ggml-org#21611 started as a 64 entry ring buffer, was raised to 128, and in review the cap was removed because tensor parallel splits each layer into two graphs (one reviewer saw about 800 graphs with -sm tensor on gpt-oss-120b). What merged (b94050e) is only the time based sweep, and no revision of common.cuh on master has had a count cap since.

This PR does the same: it removes max_cuda_graphs and the LRU loop and keeps the sweep. The shape key stays.

Results

Qwen3-4B Q4_K_M (36 layers, so about 73 graphs per device in tensor split), 2x B200 on exclusive leases, CUDA 13.1, llama-bench -fa 1 -p 0 -n 128 -r 5, three interleaved rounds per arm. Decode tok/s, median of the round means (min to max).

Built from the carry branch head (9dd7972) with and without this change, on a quiet host:

split cap 64 this PR cap 64, graphs disabled PR / cap
tensor 140.4 (137.5 to 141.0) 220.8 (207.2 to 224.5) 128.4 (117.5 to 150.6) 1.57x
layer 315.4 (312.8 to 315.4) 314.8 (314.8 to 316.0) 289.6 (259.1 to 298.4) 1.00x
none 318.0 (316.5 to 319.5) 318.3 (318.2 to 319.8) 293.5 (281.0 to 303.0) 1.00x

Rebuilt at this PR's base, b96a713 (the commit #241 is pinned at), with and without this change. This run was on a heavily loaded host (load average about 500), so the layer and single GPU rows are noise; only the tensor row is a result:

split cap 64 this PR cap 64, graphs disabled
tensor 83.8 (78.1 to 89.2) 213.7 (143.9 to 213.7) 93.0 (75.5 to 116.3)
  • With the cap, tensor split is no faster than with CUDA graphs disabled, which is the defect. Without it, graph replay works again, and every round with this PR beats every round without it.
  • On the quiet host, layer split and single GPU are identical, and still faster than with graphs disabled, so graphs keep working there.
  • The 2x RTX 5070 Ti numbers in the issue show a larger gap (2.4x to 4.3x), since launch overhead weighs more there.

Memory

Measured with llama-server on the same model and GPUs, -c 16384, on the carry branch head with the same patch (the cache code is identical). MiB:

split VRAM per GPU, after decode after 60 distinct prompt lengths host RSS after the burst
tensor cap 64 3306 3324 1184
tensor this PR 3334 3384 1423
layer both 3218 3234 1255
none both 5462 5482 1021

The extra memory is the graphs that are now kept instead of re-captured, plus host side bookkeeping for one-off prefill shapes until the sweep drops them. It levels off: over six bursts of 60 new prompt lengths, each followed by a sweep, VRAM stayed at 3384 MiB per GPU, and host RSS grew by about 11 MiB per burst late in the run, the same rate as with the cap (about 9 to 13 MiB). Peak RSS (about 3 GB, at load) is unchanged.

Not covered

  • MTP combined with tensor split, and Windows. The reporter's runs in the issue cover both with the cap lifted.
  • Many parallel slots sending distinct prompt lengths within one 10 s window, which is the worst case for host memory.
  • If that host memory ever matters, the change that matches upstream exactly is to use the shape key only for small decode and verify batches and the plain first node key for prefill.

Branch

The fix commit (0e8ffdc72) sits on #241's old pin b96a713a. The head (20dd977ea) merges #241's current head 9dd797259 on top, so it carries the same #241 that #252 pins, plus the cap removal; against #241 the diff is still only common.cuh (+5 -14). It is pinned in #241's slot by #254, stacked on #252; replayed with additive_merge.py onto b11491, all 15 pins merge and pin_contract.py reports all intact.

Supersedes

#232 (cap raised to 512) and #238 (cap that doubles under thrash, hard limit 2048). Both target mtp/qwen4exp-nextn, which is no longer pinned, and both keep a fixed number that upstream's review already ruled out. Thanks to @floewe for the diagnosis and counter runs in the issue, and to @LeoBorcherding for #238.

To ship, #254 pins this PR in #241's slot.

The shape keyed graph cache added a 64 entry LRU cap on top of the
existing time based sweep, to bound a workload with many distinct shapes.
Tensor split needs about 2 * n_layers + 1 graphs per device for a single
shape, one per segment between the cross-device reductions, so every
model past ~31 layers exceeds 64. The LRU then evicts graphs that are
still in use and every token re-captures instead of replaying, which is
as slow as running with GGML_CUDA_DISABLE_GRAPHS=1.

Upstream tried the same cap (64, then 128) in ggml-org#21611
and dropped it in review for this reason, keeping only the sweep that
evicts graphs unused for 10 s. Do the same here. The shape key stays, so
an alternating speculative verify batch keeps one graph per shape.

Refs unslothai/unsloth#12468
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-08T11:09:42.593370Z 0e8ffdc PR opened
🔒 Security Review ✅ Completed 2026-10-08T11:10:37.554554Z 0e8ffdc PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0e8ffdc72f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// bounded only by the time based sweep below, as upstream does: tensor split mode alone
// needs about 2 * n_layers + 1 graphs per device and shape, so any count cap small enough
// to matter evicts graphs that are still in use and re-captures them on every token
std::unordered_map<uint64_t, std::unique_ptr<ggml_cuda_graph>> cuda_graphs;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep a resource bound on the shape cache

When CUDA or HIP graphs are enabled in a server and clients generate many distinct prompt or batch shapes within the 10-second retention window, each shape and tensor-split segment can now add a cache entry, including a full node_props vector and potentially a graph executable after warmup. The time-based sweep does not impose a count or byte limit, so a sufficiently fast burst can exhaust host or device memory before any entry becomes old enough to evict; it also cannot release stale entries while the server is idle because it only runs from cuda_graph(). Keep a bound sized for tensor-split workloads, or avoid shape-keying one-off prefill graphs.

Useful? React with 👍 / 👎.

…ache-no-cap

#241 moved to 9dd7972, which merges upstream master past ggml-org#29928
(the GLM-5-Next NextN graph is upstream now). Take it, so this branch stays
#241 plus the cap removal and merges onto the same base tags.
EmeraldBitTwizzler pushed a commit to EmeraldBitTwizzler/llama.cpp that referenced this pull request Oct 9, 2026
…DA graph cache count cap

unslothai#253 is unslothai#241 plus one commit that removes the 64 entry cap on the shape keyed
CUDA graph cache, which made tensor split re-capture graphs on every token
(unslothai/unsloth#12468). Its branch now merges unslothai#241's current head, so
pinning it instead of unslothai#241 carries every unslothai#241 item and the fix, and keeps
pin_contract from reading the removed cap lines as a lost unslothai#241 change.
EmeraldBitTwizzler pushed a commit to EmeraldBitTwizzler/llama.cpp that referenced this pull request Oct 9, 2026
@danielhanchen
danielhanchen merged commit 20dd977 into carry/superseded-pin-optimizations Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant