CompilationConfig.set_splitting_ops_for_v1 returns early when the compilation
mode is not VLLM_COMPILE, which skips the guard that would otherwise disable
piecewise cudagraphs for an empty splitting_ops. The resulting configuration —
piecewise cudagraphs with attention inside the graph — silently produces wrong
results for any batch whose per-request query length differs from the captured
one. Speculative decoding is the case where that happens in practice, and the state is reachable in stock vLLM via VLLM_USE_BREAKABLE_CUDAGRAPH (auto-enabled for several architectures).
The early return
def set_splitting_ops_for_v1(self, all2all_backend, data_parallel_size=1):
if self.mode != CompilationMode.VLLM_COMPILE:
if self.splitting_ops is None:
self.splitting_ops = []
return # <-- skips everything below
The guard that is skipped:
elif len(self.splitting_ops) == 0:
if self.cudagraph_mode == CUDAGraphMode.PIECEWISE:
logger.warning_once(
"Piecewise compilation with empty splitting_ops does not contain "
"piecewise cudagraph. Setting cudagraph_mode to NONE. ...")
self.cudagraph_mode = CUDAGraphMode.NONE
So mode=NONE + cudagraph_mode=PIECEWISE leaves splitting_ops=[] and
cudagraph_mode=PIECEWISE, which the guard exists to prevent. The guard itself
is correct; it is simply not reached.
Why the resulting state is unsound
With splitting_ops=[] the attention op is inside the captured region. Piecewise
graph entries are keyed by BatchDescriptor (forward_context.py), whose fields
are:
num_tokens: int
num_reqs: int | None # "Can be None for PIECEWISE ... can handle any number of requests"
uniform: bool # "True if all the requests in the batch have the same number of tokens"
has_lora: bool
num_active_loras: int
No field records the per-request query length. So these two batches share a key:
| batch |
reqs x query_len |
num_tokens |
uniform |
| plain decode |
16 x 1 |
16 |
True |
| spec-decode verify |
2 x 8 |
16 |
True |
_is_compatible states the intent explicitly:
# desc.uniform_token_count=None (PIECEWISE) can handle any uniform_token_count
That assumption is sound only while attention is outside the graph. With
splitting_ops=[] it is not: attention's behaviour depends on query_start_loc,
which is frozen at capture and replayed against a different query structure.
Result: wrong logits on the verify pass, no error raised.
Observed effect
8xMI325X (gfx942), Qwen3.8-27B (architecture Qwen3_5ForConditionalGeneration) + DFlash2 drafter, TP=8, num_speculative_tokens=7,
VLLM_USE_BREAKABLE_CUDAGRAPH=1 (see Scope below for why that flag matters):
| configuration |
compilation mode |
mean acceptance length |
cudagraph_mode=PIECEWISE -> breakable path, attention inside graph |
NONE |
1.00 (0 tokens accepted, drafts still generated) |
--enforce-eager (no graphs at all) |
NONE |
4.03-5.17 |
VLLM_USE_BREAKABLE_CUDAGRAPH=0 -> VLLM_COMPILE, attention split out |
VLLM_COMPILE |
4.47 |
The first two rows share the same compilation mode (NONE) — the only difference
is whether the forward is captured into a graph, which isolates the graph as the
cause. The third row is the corrected configuration; end-to-end it is ~3x a
non-speculative deployment of the same model on a coding workload (187-207 vs
66.8 tok/s).
A non-speculative deployment of the same model is unaffected, because it only ever
produces uniform 1-query decode batches — there is only one query structure, so
nothing collides. That is why this is easy to miss: it needs speculative decoding
to surface.
Scope — how this state is reached
We initially believed we reached the unguarded state through a local patch. That
was wrong: everything involved is stock vLLM.
VLLM_USE_BREAKABLE_CUDAGRAPH is an upstream env
(envs.py: "Experimental: breakable cudagraph does not rely on torch.compile"),
and config/vllm.py auto-enables it for an architecture allow-list
(DeepseekV4, Inkling, KimiK3, KimiLinear, ...) unless the env is explicitly set.
- With it enabled, compilation mode is forced to
NONE while
cudagraph_mode can remain piecewise-capable, and
BreakableCUDAGraphWrapper captures the forward without torch.compile —
its docstring says exactly that ("PW CUDA graph without torch.compile").
set_splitting_ops_for_v1's early return then leaves splitting_ops=[]
without the downgrade the guard would have applied.
- Our deployment set the env to 1 for a model outside the allow-list
(the Qwen3.8-27B model above); an allow-listed model with speculative decoding and cudagraph_mode
explicitly set to PIECEWISE should reach the same state (the allow-listed
defaults are FULL variants, which are keyed differently — see below); we have
not tested one directly.
Why FULL variants are safe (and a workaround): in _init_candidates, the
separate-decode routine used by the FULL modes captures one graph per decode
query length and puts it in the key:
if separate_decode_routine and decode_mode:
for decode_query_len in decode_query_lens:
desc = BatchExecutionDescriptor(
cg_mode=decode_mode,
num_reqs=rounded_num_reqs,
uniform_token_count=decode_query_len, # <- query length in the key
)
if mixed_mode:
# for PIECEWISE graphs there is no limit on requests when replaying
num_reqs = None # <- PIECEWISE: no query length
and _is_compatible's first clause checks uniform_token_count. So under
FULL_AND_PIECEWISE a verify batch and a plain decode batch cannot share a key,
which matches what we measure: our DeepSeek-V4 production deployment
(allow-listed, breakable auto-enabled, FULL_AND_PIECEWISE, DSpark speculative
decoding) has a healthy ~3.2 mean acceptance length. Switching
cudagraph_mode from PIECEWISE to any FULL variant therefore avoids the
collision — though for this drafter the FULL path has its own unresolved
problem on our hardware (GPU memory faults, reported separately on the DFlash2
PR), so we run VLLM_USE_BREAKABLE_CUDAGRAPH=0 instead.
Suggested fixes (either would have caught this)
- Move the
splitting_ops == [] and cudagraph_mode == PIECEWISE -> NONE guard so
the early return cannot skip it.
- Or add the per-request query length to
BatchDescriptor when speculative
decoding is enabled, so verify batches cannot share a key with plain decode.
The second is the more general fix: it removes the dependence on "attention is
outside the graph" being maintained by every future code path.
CompilationConfig.set_splitting_ops_for_v1returns early when the compilationmode is not
VLLM_COMPILE, which skips the guard that would otherwise disablepiecewise cudagraphs for an empty
splitting_ops. The resulting configuration —piecewise cudagraphs with attention inside the graph — silently produces wrong
results for any batch whose per-request query length differs from the captured
one. Speculative decoding is the case where that happens in practice, and the state is reachable in stock vLLM via
VLLM_USE_BREAKABLE_CUDAGRAPH(auto-enabled for several architectures).The early return
The guard that is skipped:
So
mode=NONE+cudagraph_mode=PIECEWISEleavessplitting_ops=[]andcudagraph_mode=PIECEWISE, which the guard exists to prevent. The guard itselfis correct; it is simply not reached.
Why the resulting state is unsound
With
splitting_ops=[]the attention op is inside the captured region. Piecewisegraph entries are keyed by
BatchDescriptor(forward_context.py), whose fieldsare:
No field records the per-request query length. So these two batches share a key:
_is_compatiblestates the intent explicitly:# desc.uniform_token_count=None (PIECEWISE) can handle any uniform_token_countThat assumption is sound only while attention is outside the graph. With
splitting_ops=[]it is not: attention's behaviour depends onquery_start_loc,which is frozen at capture and replayed against a different query structure.
Result: wrong logits on the verify pass, no error raised.
Observed effect
8xMI325X (gfx942), Qwen3.8-27B (architecture
Qwen3_5ForConditionalGeneration) + DFlash2 drafter, TP=8,num_speculative_tokens=7,VLLM_USE_BREAKABLE_CUDAGRAPH=1(see Scope below for why that flag matters):cudagraph_mode=PIECEWISE-> breakable path, attention inside graph--enforce-eager(no graphs at all)VLLM_USE_BREAKABLE_CUDAGRAPH=0->VLLM_COMPILE, attention split outThe first two rows share the same compilation mode (
NONE) — the only differenceis whether the forward is captured into a graph, which isolates the graph as the
cause. The third row is the corrected configuration; end-to-end it is ~3x a
non-speculative deployment of the same model on a coding workload (187-207 vs
66.8 tok/s).
A non-speculative deployment of the same model is unaffected, because it only ever
produces uniform 1-query decode batches — there is only one query structure, so
nothing collides. That is why this is easy to miss: it needs speculative decoding
to surface.
Scope — how this state is reached
We initially believed we reached the unguarded state through a local patch. That
was wrong: everything involved is stock vLLM.
VLLM_USE_BREAKABLE_CUDAGRAPHis an upstream env(
envs.py: "Experimental: breakable cudagraph does not rely on torch.compile"),and
config/vllm.pyauto-enables it for an architecture allow-list(DeepseekV4, Inkling, KimiK3, KimiLinear, ...) unless the env is explicitly set.
NONEwhilecudagraph_modecan remain piecewise-capable, andBreakableCUDAGraphWrappercaptures the forward without torch.compile —its docstring says exactly that ("PW CUDA graph without torch.compile").
set_splitting_ops_for_v1's early return then leavessplitting_ops=[]without the downgrade the guard would have applied.
(the Qwen3.8-27B model above); an allow-listed model with speculative decoding and
cudagraph_modeexplicitly set to
PIECEWISEshould reach the same state (the allow-listeddefaults are FULL variants, which are keyed differently — see below); we have
not tested one directly.
Why FULL variants are safe (and a workaround): in
_init_candidates, theseparate-decode routine used by the FULL modes captures one graph per decode
query length and puts it in the key:
and
_is_compatible's first clause checksuniform_token_count. So underFULL_AND_PIECEWISEa verify batch and a plain decode batch cannot share a key,which matches what we measure: our DeepSeek-V4 production deployment
(allow-listed, breakable auto-enabled,
FULL_AND_PIECEWISE, DSpark speculativedecoding) has a healthy ~3.2 mean acceptance length. Switching
cudagraph_modefromPIECEWISEto any FULL variant therefore avoids thecollision — though for this drafter the FULL path has its own unresolved
problem on our hardware (GPU memory faults, reported separately on the DFlash2
PR), so we run
VLLM_USE_BREAKABLE_CUDAGRAPH=0instead.Suggested fixes (either would have caught this)
splitting_ops == [] and cudagraph_mode == PIECEWISE -> NONEguard sothe early return cannot skip it.
BatchDescriptorwhen speculativedecoding is enabled, so verify batches cannot share a key with plain decode.
The second is the more general fix: it removes the dependence on "attention is
outside the graph" being maintained by every future code path.