Skip to content

[EXL3] allow CUDA-graph decode for dense checkpoints by priming exl3_gemm autotune - #314

Open
malaiwah wants to merge 2 commits into
local-inference-lab:codex/gg-exl3-r7-k345-20260810from
malaiwah:feat/exl3-graph-decode-priming
Open

[EXL3] allow CUDA-graph decode for dense checkpoints by priming exl3_gemm autotune#314
malaiwah wants to merge 2 commits into
local-inference-lab:codex/gg-exl3-r7-k345-20260810from
malaiwah:feat/exl3-graph-decode-priming

Conversation

@malaiwah

Copy link
Copy Markdown

Companion to #312. Makes CUDA graphs usable for dense EXL3 checkpoints.

Problem

Exl3Config._require_enforce_eager() raises for every checkpoint without rank_sliced_metadata, because exl3_gemm autotunes with timing launches that fault under capture, and the comment notes that "m-bucketing means a warmup pass cannot reliably cover every bucket". So a dense checkpoint is eager-only, which on a 27B dense model costs 46-50 % of decode throughput (measured below).

Why the m-bucketing objection does not hold for decode

gemm_autotune_hash (exllamav3_ext/quant/exl3_gemm.cu) mixes MIN(roundup_pow2(MAX(size_m,2)),16), size_k, size_n, K, c_fp32, device, cc, SM count and the codebook selector — shape and codebook only, no weight pointers. The effective bucket set is therefore {2,4,8,16}, and vLLM's decode capture sizes are a known finite list. Priming the exact capture sizes is a strict superset of bucket coverage. The first call per device also does a lazy cudaMalloc in DevCtx::get_locks, which likewise must happen before capture.

Change

  1. _graph_decode_capture_rows() builds the row plan: cudagraph_capture_sizes, plus the superset adjust_cudagraph_sizes_for_spec_decode() can produce after weight loading, plus size // q per-request rows, clamped to max_cudagraph_capture_size.
  2. Exl3LinearMethod._prime_graph_decode_shapes() runs one zero-filled fp16 GEMM per plan row for every shard that routes through _exl3_gemm, memoised in _EXL3_GEMM_PRIMED_SIGNATURES keyed (device, m, k, n, bits, codebook) — mirroring the existing _EXL3_ONLINE_WARMED_SIGNATURES. K6+mcg shards are skipped (native B12X, already prepared and warmed). One arena per geometry. A priming failure is converted into the enforce-eager ValueError, so the relaxation stays fail-closed.
  3. _require_enforce_eager permits non-eager only when VLLM_EXL3_GRAPH_DECODE=1 and the mode captures decode only (mixed_mode() == NONE) and no DBO/ubatching and the capture list is non-empty; otherwise it raises the original error with the failing condition named. Default is unchanged: off.
  4. _require_eager_moe_experts() keeps refusing for non-rank-sliced EXL3 MoE, whose per-expert row counts only the router knows. R7 rank-sliced and online-K6 paths are untouched.

VLLM_EXL3_GRAPH_DECODE registration for vllm/envs.py is attached in the sibling patch file in the linked repo; happy to fold it into this PR if you prefer one commit.

Verification

r34 image, 1x RTX PRO 6000 Blackwell (SM120), driver 595.58.03, TP1, dense EXL3 Qwen3.8-27B (193 EXL3 matrices: MLP K4/mcg, lm_head K6/mul1, attention BF16 for the b6 overlay).

Startup, with --compilation-config '{"cudagraph_mode":"FULL_DECODE_ONLY"}' and no --enforce-eager:

INFO exl3.py:1575 EXL3 graph decode enabled by VLLM_EXL3_GRAPH_DECODE: cudagraph_mode=FULL_DECODE_ONLY
                  captures decode only; priming exl3_gemm for 5 row counts (m=1..16) during weight loading.
INFO exl3.py:925  EXL3 graph-decode priming: autotuned exl3_gemm for 5 capture row counts (m=1..16)
                  at K=5120,  N=17408,  bits=4, codebook=1.
INFO exl3.py:925  ... at K=17408, N=5120,   bits=4, codebook=1.
INFO exl3.py:925  ... at K=5120,  N=248320, bits=6, codebook=2.
Capturing CUDA graphs (decode, FULL): 100%|##########| 4/4
INFO gpu_model_runner.py:6971 Graph capturing finished in 2 secs, took 0.06 GiB

Exactly three priming lines, one per shard geometry, and no mixed prefill-decode capture bar.

Throughput (--max-num-seqs 8, greedy, ignore_eos, 256 output tokens, warmup discarded):

configuration C1 C4 C8
eager (today) 28.77 103.47 215.84
graphs (this PR) 55.39 190.59 428.12
gain +92.5 % +84.2 % +98.4 %

For scale, graphs are worth only +7.7/+9.0/+11.4 % to the BF16 model on the same box; eager EXL3 pays per-call dispatch on 193 quantized matmuls, which is why the win is ~9x larger.

Correctness: greedy text and a multimodal request both answer correctly, and distribution parity against eager is exact — full-vocabulary KL(eager || graphs) over 32 contexts x 2047 positions is 0.000000 with top-1 agreement 1.000000.

Negative controls behave as intended: without the env var, and with the default mixed-capturing mode, startup still raises with the failing condition named; --enforce-eager is unaffected.

Not covered, deliberately

Mixed/prefill capture (refused, token counts not enumerable), non-rank-sliced MoE experts (refused), DBO/ubatching (refused), and the pre-existing rank-sliced path (untouched). Spec-decode row plans are a superset computed at weight-load time; if a future capture-size transform appears the failure mode is a fault at capture, not silent corruption.

…gemm

exl3_gemm autotunes with timing launches, so _require_enforce_eager refuses
non-eager execution for every dense tensor_storage checkpoint. Decode capture
sizes are a known finite set, so prime every serialized shard over them during
process_weights_after_loading -- the same pattern _warm_decode_shapes already
uses for the online-K6 path -- and permit decode-only capture behind
VLLM_EXL3_GRAPH_DECODE.

Measured on Qwen3.8-27B EXL3 K4 (RTX PRO 6000, SM120, TP1): decode throughput
+92%/+84%/+98% at C1/C4/C8, distribution parity vs eager exactly 0.000000 KLD
over 32 contexts.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • dev/*

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cb3c3309-dd22-4b92-864f-ce598b6a4658

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@malaiwah

Copy link
Copy Markdown
Author

Tested this PR on AIBoss: RTX 5090 (SM120), driver 610.57.04, TP1, GG r34 base image sha256:820181f...20592b, public malaiwah/Qwen3.8-27B-K4 revision 49f6d5ab5632b443458c774ffc0caeb19e950d3b.

The derivative image contains the exact exl3.py from PR head d4578d69446fba2f44620968eec52de9e348ca5e (sha256:789ed7ab...6ce58c05efb8e9) plus the required VLLM_EXL3_GRAPH_DECODE registration in vllm/envs.py; that registration is not part of this PR diff.

Confirmed fail-closed behavior first: non-eager FULL_DECODE_ONLY with VLLM_EXL3_GRAPH_DECODE=0 exited before weight loading with the expected ValueError explaining that graph decode was not permitted. With the opt-in set to 1, both MTP0 and MTP1 compiled and captured successfully.

Decode results

Greedy, fixed 512 output tokens, ignore_eos, no prefix caching, one warmup; aggregate output tok/s:

mode C1 C4 C8
eager MTP0 23.97 88.93 182.24
graph MTP0 62.46 227.79 450.19
graph MTP1 98.13 366.05 679.79

The eager run used 131072 max context / 0.85 utilization; graph runs used native 262144 / 0.98, so the graph-vs-eager percentages are preliminary rather than a controlled performance claim. MTP1 versus graph MTP0 used the same graph/native-context configuration and improved C1/C4/C8 by 57.1% / 60.7% / 51.0%. MTP1 accepted 3536/4145 draft tokens (85.31%).

Capture and operational checks

  • MTP0 primed 5 row counts (m=1..16) for all three dense geometries and captured 4 decode graphs; actual graph pool 0.07 GiB.
  • MTP1 primed 24 row counts (m=1..32) including speculative rows and captured 8 decode graphs; actual graph pool 0.54 GiB.
  • Native context fits on this 32 GiB GPU: MTP0 KV capacity 355657 tokens; MTP1 capacity 289577 tokens, or 1.10x a 262144-token request.
  • A real 259838-token needle-retrieval request completed in 326.5 s and returned exact passkey NATIVE-CONTEXT-262144 (259851 total tokens).
  • Native tool call and continuation: HTTP 200, get_status({}), then coherent result summary.
  • Vision: 96x96 half-red/half-blue PNG returned HTTP 200 and exact red, blue.
  • No kernel NVRM/Xid entries; process remained healthy after the 259838-token request.

Correctness caveat

A separate 32-prompt free-running greedy corpus was 29/32 token-sequence exact between the preliminary eager run and graph MTP0; the three differences appeared after autoregressive branch points. Because those two runs did not have matched memory/context settings, I am not treating this as a regression or as parity proof. A matched rerun was interrupted by an unrelated NAS outage. This test also does not reproduce the PR's full-distribution KLD harness, so it makes no model-fidelity claim.

Bottom line: graph startup/capture, fail-closed gating, MTP1 capture-row priming, tools, vision, and near-native context all work on SM120/RTX 5090, with a large decode gain. Matched eager/graph numerical parity remains the one item I would close before calling this production-qualified.

@malaiwah

Copy link
Copy Markdown
Author

Follow-up with the matched eager/graph run that was missing from my earlier qualification.

Both servers used the same RTX 5090, image/model revision, max_model_len=131072, max_num_seqs=8, gpu_memory_utilization=0.85, MTP disabled, FP8 KV cache, seed 314159, temperature 0, 32 prompts, 32 generated tokens, and top-5 logprobs. The only launch difference was --enforce-eager versus FULL_DECODE_ONLY plus VLLM_EXL3_GRAPH_DECODE=1.

Results:

C1 C4 C8
Eager 25.16 96.81 192.40 tok/s
Graph 62.43 227.50 449.51 tok/s

Graph gain: +148.1%, +135.0%, +133.6%.

Exact cross-mode token/text parity was 29/32, not 32/32. Repeating the corpus without restarting gave 32/32 identical sequences within eager and 32/32 within graph, so the three differences are reproducible mode differences rather than run-to-run nondeterminism.

All three first divergences are near-tie decisions:

  • Ottawa, a city ...: eager has that and situated tied at -1.45323; graph has situated -1.42318 versus that -1.54818.
  • Loopback explanation: eager has local and network tied at -0.99897; graph has network -0.93466 versus local -1.05966.
  • Ampere explanation: eager has defined -0.97476 versus and -1.09976; graph ties both at -1.01977.

Across 967 identical pre-divergence chosen tokens, chosen-token logprob mean absolute delta was 0.00994 and maximum was 0.62842. The generated answers remain semantically valid, but exact eager/graph top-1 parity does not hold on this SM120/RTX 5090 test.

Receipt SHA-256 values:

  • matched eager: e1759e5cd550142995adcc5dfb901ef8131be2e294da0295af34c03eee47afc0
  • matched graph: 530634bda234496d61d76f1931ec603854575d90194faf39aea3f5dd523b656a
  • comparison summary: 7ba42b171fe53920c977156bf4dd7e48f194d8b7e8b05fe048a22e91454d9a37

This closes the configuration mismatch from my previous comment and narrows the residual discrepancy to graph/eager numerical behavior at tied or near-tied decode decisions.

@malaiwah

Copy link
Copy Markdown
Author

Final AIBoss/RTX 5090 qualification disposition for this head (d4578d69446fba2f44620968eec52de9e348ca5e):

  • Opt-in SM120 runtime qualification: PASS. Dense K4 graph capture, MTP0/MTP1 startup, tools, vision, 259,838-token prompt retrieval, repeated decode, and GPU fault checks all passed.
  • Performance qualification: PASS. In the matched MTP0 configuration graph decode delivered +148.1%/+135.0%/+133.6% aggregate throughput at C1/C4/C8. Graph MTP1 reached 98.13/366.05/679.79 tok/s with 85.31% draft acceptance.
  • Exact eager/graph parity: NOT MET. Each mode is internally deterministic at 32/32 repeats, but cross-mode output is 29/32 exact on RTX 5090. The three differences occur at tied or near-tied top-1 decisions; no broad instability or semantic failure was observed.
  • Production promotion: HOLD. Keep this opt-in/experimental until maintainers decide whether the reproducible near-tie drift is within the intended numerical tolerance and the required VLLM_EXL3_GRAPH_DECODE environment registration lands in the maintained integration path.

No NVRM/Xid faults occurred. The host has been returned to its healthy NVFP4 production service. Full receipts and source provenance are preserved with SHA-256 manifest in gg-pr314-qualification-2026-08-14.tar.gz (archive SHA-256 aeb7c929c256ed7e3cb382a9e8d7988d73c80e1162d2a670a135a0145f019e52).

@malaiwah

Copy link
Copy Markdown
Author

Thank you for the qualification - it found a real defect in my evidence, and the follow-up changes what the HOLD should be measured against. Three parts: a retraction, a refuted hypothesis, and a control that I think resolves the open question.

1. My parity receipt was measuring the wrong path. Retracted.

I reported KLD 0.000000 / top-1 1.000000 for graph-vs-eager. That number is real but it is not a decode-parity measurement: my harness captures one prefill forward through a hook on the final norm, and cudagraph_mode=FULL_DECODE_ONLY captures no prefill graph. So it compared two runs of identical eager prefill code and could not have detected what you found. The defensible claim from that receipt is only "enabling graph decode does not change prefill numerics". Your 29/32 stands unopposed by it.

2. I reproduced your finding on a second device, and it is slightly larger here

Proper harness (real decode steps through the OpenAI endpoint, 32 prompts x 32 tokens, temperature 0, fixed seed, logprobs 5, ignore_eos, each corpus run twice per mode): decode_parity.py.

1x RTX PRO 6000 Blackwell (SM120), driver 595.58.03, dense Qwen3.8-27B EXL3 K5/K6, seed 314159:

pair exact sequences mean abs delta logprob max self-repeat
eager vs graph 24/32 0.01180 0.139 32/32 both modes

Same shape as yours - internally deterministic, cross-mode divergence at near-ties, e.g. known (-2.8823) vs home (-2.9020). 24/32 here vs your 29/32 on the 5090.

3. The zero-priming hypothesis: tested, refuted

My first suspicion was this patch's fault: exl3_gemm autotune selects a kernel configuration by measured time, and I primed it on an all-zero arena, so graph mode could have been locked to a different winner than eager's first real call selects.

Primed instead on realistic activations (0.05 * randn):

pair exact sequences mean abs delta logprob
eager vs graph, randomised priming 25/32 0.01179

Unchanged to five digits. Not the cause. I am keeping the randomised priming anyway - probing a timing-based autotuner with zeros is indefensible on its own terms - but it is not a fix and I will not present it as one.

4. The control that I think resolves the HOLD

If the drift is not from priming, is it from EXL3 at all? Same harness, same prompts, same seed, on the unquantised BF16 Qwen/Qwen3.8-27B - which touches no EXL3 kernel, no online overlay, no autotune priming:

pair exact sequences mean abs delta logprob max self-repeat
EXL3 K5K6: eager vs graph 24/32 0.01180 0.139 32/32
BF16, no EXL3 code path: eager vs graph 24/32 0.01282 0.125 32/32

The BF16 model drifts by the same amount as the EXL3 model. So the reproducible near-tie drift you measured is a property of this build's CUDA-graph decode path in general, not of this patch or of EXL3's kernels. This patch does not introduce it; it makes graphs reachable for dense EXL3, and graphs then behave exactly as they do for an unquantised model on the same stack.

That suggests the gate should be restated: not "graph == eager bit-exact" - no graph path here satisfies that, BF16 included - but "graph drift within the same envelope as an unquantised model on the same stack", which is measured above and is satisfied. If you would rather hold on the stronger criterion, that is a decision about CUDA graphs in this build, and it should apply to BF16 too.

Honest limit on all of this, mine and yours: 32 x 32 greedy tokens is a numerical probe, not a task evaluation. Neither of us has shown whether flipped near-ties matter downstream, and that remains open - for graphs generally, not for EXL3.

Changed in this PR since your test

  • Priming arena is 0.05 * randn instead of zeros, with a comment stating it is a defensibility fix and not a fix for the drift.
  • No functional change to the priming set, the capability gate, or the env registration.

Receipts and method: docs/27-graph-decode-drift-control.md, with the four JSON reports (parity-eager, parity-graph, parity-graphrand, parity-bf16-*) and the harness in tools/.

exl3_gemm's autotuner picks a kernel configuration by measured time, so an
all-zero probe can select a different winner than a real activation does.
Measured on SM120: this does NOT change eager-vs-graph near-tie drift
(25/32 vs 24/32 exact sequences, identical logprob deltas), and BF16 with no
EXL3 code drifts the same amount (24/32), so the drift is ambient to the
graph path. Kept because probing a timing-based autotuner with zeros is
indefensible on its own terms.
@malaiwah

Copy link
Copy Markdown
Author

Data point: this PR's doctrine (initialise every autotune/plan bucket eagerly, before any capture) turned out to generalise to B12X. Our b12x warm ran once per device; bits=4/5 mixed-Trellis plans therefore initialised lazily — potentially inside decode-graph capture — which is the surviving hypothesis for a plausible-garbage-under-replay corruption we isolated (the bits=5 GEMM itself proves bit-clean eagerly, cos 1.000000 on real checkpoint tensors). Warm is now keyed on (device, bits), mirroring this PR's per-bucket priming. Verification run queued; _EXL3_GEMM_PRIMED_SIGNATURES from this PR has been carried in our deployment throughout with no autotune-under-capture faults across ~140 boots.

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