Skip to content

[BI] Close the cudagraph paths that pick a graph per step, and warn about the one that stays - #25

Open
aoshen02 wants to merge 23 commits into
bi/basefrom
bi/cudagraph-mode
Open

aoshen02 wants to merge 23 commits into
bi/basefrom
bi/cudagraph-mode

Conversation

@aoshen02

@aoshen02 aoshen02 commented Aug 15, 2026 •

Copy link
Copy Markdown
Owner

What

Batch invariance is an absolute claim: a request's output must not depend on
which other requests share its engine step. Several components schedule
cudagraphs of their own, keyed on what the step happens to carry, and
cudagraph_mode does not reach them. This closes the ones whose condition is
exact, and warns where the answer is a per-model property config validation
cannot establish.

  • Microbatching is refused. It decides per step whether to split and which
    graph to replay, and never consults cudagraph_mode — so unlike everything
    else here it is not gated on the mode, and --enforce-eager does not switch
    it off either. Opt-in (enable_dbo / ubatch_size), and the condition is
    exact, so refuse rather than silently disable.
  • Speculative-decoding proposers drop to NONE. They pick PIECEWISE
    whenever the target's mixed mode is PIECEWISE or FULL, which puts a
    per-step graph/eager choice back on the draft path.
  • A warning when an explicit capture envelope is too small. If a deployment
    sets max_cudagraph_capture_size below its own largest decode step, batches
    above it fall back to eager, so which numeric path a request takes depends on
    how many requests share its step. Only an explicit choice is checked — the
    defaults come from the platform, the model (some override the cap) and, under
    speculative decoding, from query-length alignment, so re-deriving "what the
    default would have been" only produces false alarms on stock configs. The
    answer is snapshotted before try_verify_and_update_config() can let a model
    write its own capture defaults, and stored so it survives replace().
  • A warning for piecewise modes. A piecewise mode picks the per-step path
    from batch properties, so a request's decode step runs under a path its
    neighbours chose. That is harmless exactly while every selectable path is
    bit-identical for this model — verified on DeepSeek-V4, unknown elsewhere.
    cudagraph_mode=FULL removes the choice.

Why the mode is no longer pinned

An earlier version of this PR pinned cudagraph_mode to FULL under
VLLM_BATCH_INVARIANT. The leak that motivated it turned out to be a
DeepSeek-V4 model bug — a host-side indexer predicate evaluated once against
the capture-time dummy batch and baked into the graph — fixed upstream in
vllm-project#52492. With that fixed, a 50-round soak of the full 43-layer model under
FULL_AND_PIECEWISE is bitwise clean, where the same soak leaked in 38.7% of
rounds before. Pinning would now cost the mixed-step graph for nothing, so the
mode is left alone and the burden moves to the warning: a deployment that has
not checked its own model should not read silence as safety.

Tests

tests/test_config.py -k batch_invariant → 13 passed, covering: the
requested piecewise mode survives; the warning fires; resolve_cudagraph_mode_ and_sizes is no longer special-cased (BI on and off return the same mode);
microbatching is refused under both FULL and NONE; the capture-envelope
warning fires on an explicit undersized choice and stays quiet on defaults; and
a model-written capture default is not mistaken for a deployment choice.

tests/v1/determinism/test_batch_invariant_dispatchers.py → 1 passed: the
microbatching decision point as a unit test over the branch itself, no weights,
no GPU.

Negative control: with the old pinned build in place, 6 of those 13 fail — the
assertions are testing the removal, not restating it.

Non-BI behaviour unchanged, checked explicitly: with the flag off, both
piecewise-bearing modes survive and the mixed-mode downgrade still picks
FULL_AND_PIECEWISE.

CI: the determinism suite now also runs when vllm/config/,
vllm/v1/spec_decode/ or gpu_model_runner.py change — previously it only
triggered on kernel changes, so config-level regressions here were untested.

Model evaluation

Not applicable to the current contents: nothing here changes numerics on any
configuration we run (microbatching off, speculative decoding off, capture
sizes default). The piecewise measurements that justify not pinning are in
the soak referenced above — full 43-layer DeepSeek-V4-Flash-Base TP4/EP4,
50/50 rounds with 0 differing cases under FULL_AND_PIECEWISE, and GSM8K
1319/1319 per-question agreement across two independent re-runs at concurrency
64.

Scope, and what was deliberately dropped

Two more dispatchers of the same shape were found and then removed from this
PR (2026-08-18): the multimodal encoder's cudagraph manager, and Gemma4's
self-managed centroid graphs. Both were motivated by the pin — it promised no
numeric path is selected from the batch, so every escape had to be closed. With
the pin gone that obligation goes too, and both would change numerics on models
the submitter has never loaded (the Gemma4 one is not a one-liner: skipping the
capture needs an eager centroid path of its own, or the base class silently
substitutes a full-vocab argmax). They are recorded for whoever has that
hardware.

Review follow-up

An external review (codex, gpt-5.6) raised five points on this branch; each
was checked against the source before acting.

  • True, fixed. The microbatching refusal fired on use_ubatching, the
    configured flag, not on whether a step can actually be split. should_ubatch
    stays False unless data_parallel_size > 1, and check_ubatch_thresholds
    needs a step at least as large as one of the two DBO thresholds — unreachable
    once both exceed the token budget. Either case was a startup failure for a
    configuration that could never microbatch. Now refused only when it can run.
  • True, fixed. The capture-envelope warning multiplied max_num_seqs by
    the global num_speculative_tokens. Dynamic speculative decoding pairs the
    large draft counts with small batches, so that product is a step the schedule
    forbids, and the warning fired on an envelope that already covered every real
    step. The estimate now follows the schedule, extracted into
    _largest_uniform_decode_step so the arithmetic is testable without a draft
    model.
  • True, warned. cudagraph_mm_encoder greedy-packs the images scheduled
    this step and picks a captured budget from their combined token count,
    consulting neither cudagraph_mode nor --enforce-eager. Warn rather than
    refuse: it is opt-in and no model exercised here has an encoder, so a refusal
    would be a promise about a deployment that was never tested.
  • True, warned. The two draft-side dispatchers switched off here are the
    legacy ones; the V2 speculators (AutoRegressiveSpeculator,
    DFlashSpeculator) build cudagraph managers of their own that this check
    never reaches, and upstream [Feature]: Batch Invariant Feature and Performance Optimization vllm-project/vllm#27433 records that batch invariance under
    speculative decoding is unfinished. Said so, rather than let partial coverage
    read as coverage.
  • Out of scope, deliberately. Gemma4's centroid graphs are the same class
    of gap, and skipping their capture needs an eager centroid path or the base
    class silently substitutes a full-vocab argmax. Changing numerics on a model
    nobody here has run is not something the submitter can defend; the analysis is
    kept as a draft.

The review also confirmed clean: _capture_sizes_user_specified is invisible to
dataclass equality and compute_hash(), survives with_hf_config() and the
engine's deepcopy/cloudpickle round-trips, and the snapshot ordering does
separate a CLI choice from GPT-OSS's model hook.

Notes

  • Not a duplicate: [Bugfix][DSv4] Keep indexer scoring in breakable graphs vllm-project/vllm#52492 fixes the DeepSeek-V4 indexer predicate, a model bug;
    this is about dispatchers that schedule graphs outside cudagraph_mode's
    reach.
  • Path-disjoint from the other five BI PRs (vllm/config/vllm.py,
    vllm/v1/spec_decode/* and the two test files only).
  • AI assistance was used (Claude); every line human-reviewed before merge.

🤖 Generated with Claude Code

@aoshen02

Copy link
Copy Markdown
Owner Author

Added a second commit: [BI] Warn when a decode step can escape the cudagraph capture set.

Pinning cudagraph_mode=FULL under VLLM_BATCH_INVARIANT closes the leak for
the default capture set, but it leaves one hole. FULL resolves to
FULL_DECODE_ONLY here, so path selection depends on the kind of step rather
than its size — and that only holds because max_cudagraph_capture_size defaults
to min(max_num_seqs * decode_query_len * 2, 512), which a decode step can never
reach. If someone sets cudagraph_capture_sizes / max_cudagraph_capture_size
explicitly below max_num_seqs * decode_query_len, large decode batches fall
back to eager and the same neighbour-dependent leak returns under FULL.

The new commit warns at config time when that combination is requested. It only
warns — it does not raise, and it does not raise the ceiling on its own, since
either would change behaviour for configs that are otherwise fine.

Placement matters: the check sits after _set_cudagraph_sizes() so
max_cudagraph_capture_size is already final. Two regressions added in
tests/test_config.py (one asserting the warning fires, one asserting it stays
quiet when the capture set does cover the largest decode step). Both need a real
ModelConfig — with model_config=None, max_cudagraph_capture_size resolves
to 0 and the guard correctly skips.

Verified: tests/test_config.py -k batch_invariant → 5 passed.


Separately, the root cause behind this whole issue has since been found, and
it is a model bug rather than a cudagraph-policy one: DeepseekV4Indexer.forward
evaluates a host-side short-context predicate that gets frozen at capture time,
so every graph-replayed step silently runs the "select the earliest candidates"
fallback instead of the real top-k. That fix is a separate one-line change on
bi/indexer-shortcut — it is independent of batch invariance, so it is not
bundled here. With it applied, PIECEWISE itself becomes bit-identical to FULL
and eager (8/8 and 2/9 differing filler counts → 0/8 and 0/9).

This PR is still worth having on its own: it is the defence-in-depth layer, and
it is what keeps a batch-invariant run from silently depending on the capture-set
configuration.

@aoshen02

Copy link
Copy Markdown
Owner Author

Pushed 8aa2f9f0: the pin stays, but the warning no longer claims piecewise
cudagraphs break batch invariance, because on DSv4 they measurably no longer do.

Once bi/indexer-shortcut bounds the indexer's short-context predicate on a
static length, a 50-round soak of the full 43-layer model (TP4/EP4, GB200) under
the production-default FULL_AND_PIECEWISE is bitwise clean. The same soak
leaked at a 38.7% per-round rate before that fix, so 50 clean rounds is ~1.5e-11
by chance.

Getting that measurement required stepping over this very pin, so the run carries
its own vacuity guard — it is only counted if all three hold: the pin warning
never fires, the resolved mode really is FULL_AND_PIECEWISE, and both PIECEWISE
and FULL captures are non-zero. Any one missing and the cell aborts rather than
reporting a pass, because "the path under test was never taken" is exactly how
this investigation produced a false negative once before.

So what changed is the justification, not the behaviour:

  • before: piecewise breaks invariance, therefore pin it.
  • now: piecewise makes a request's numeric path follow the token count of
    the batch it lands in. That is harmless only while every selectable path is
    bit-identical. It is bit-identical here because no host-side value read inside
    a captured region is frozen against the capture-time dummy batch — the indexer
    predicate was the one instance, found by a tree-wide search that turned up no
    second one. Nothing in __post_init__ can check that property for the next
    model or the next patch, so the pin is kept as a policy.

Reviewers who want the graph back on a model they have audited themselves now
have an accurate warning to reason about, and the code comment says what the pin
is actually buying.

Measured cost of keeping it: none detectable on our workload — BI=1 warm median
0.980s with the pin against 0.99s before it. Decode steps keep their FULL graph;
only mixed prefill/decode steps fall back to eager.

@aoshen02 aoshen02 changed the title [Bugfix][BI] Keep piecewise cudagraphs out of batch-invariant runs [BI] Close the cudagraph paths that pick a graph per step, and warn about the one that stays Aug 17, 2026
@aoshen02

Copy link
Copy Markdown
Owner Author

Pushed 58111df8 and retitled. The FULL pin is gone; the PR is now about the
cudagraph paths that pick a graph per step without consulting cudagraph_mode,
plus a warning for the one path that does.

Owner decision, and the measurement supports it: with the indexer predicate fixed
(upstream vllm-project#52492, merged 2026-08-17), a 50-round soak of the full
DeepSeek-V4-Flash-Base TP4/EP4 under the production-default FULL_AND_PIECEWISE
is bitwise clean — the same soak leaked in 38.7% of rounds before. Pinning to FULL
was costing the mixed-step graph for a guarantee it was not actually providing.

What went with the pin, and why each had to:

  • the "can use neither piecewise nor full" refusal — it existed only because
    the pin ran late and would silently undo vLLM's own downgrades. Nothing to undo
    now.
  • _full_cudagraph_unsupported_reason — that refusal was its only caller.
    Leaving a helper nothing calls would be worse than deleting it.
  • the batch-invariant branches in resolve_cudagraph_mode_and_sizes — two
    tests now assert those fallbacks resolve identically with and without
    invariance, which is the inverse of what they asserted before.

What stayed, because none of it depends on the pin: the capture-set escape
warning, and the refusals for microbatching, prefill context parallel, the
multimodal encoder dispatcher and the speculative-decode dispatchers. Those close
paths where a graph is chosen per step and cudagraph_mode is never consulted, so
no mode setting can fix them.

Tests: tests/test_config.py -k the cudagraph/batch-invariant selection, 13
passed
. Negative control — re-inserting the pin fails exactly the two
mode-survives cases and nothing else, so the test is watching the behaviour and
not the wording.

One gap a reviewer should not inherit. The soak that justifies this ran with
our own version of the indexer fix, which bounded the predicate on a static length
so both paths did full scoring — agreement by construction. vllm-project#52492 instead keeps
the eager shortcut, so a graphed step does full scoring while a non-capturing step
may still take the shortcut. Those agree only while "candidates <= topk" makes the
shortcut's set identical to the real top-k, and an identical set is not an
identical order — the order sensitivity of FlashMLA sparse decode measured
321-361 bf16 ULP under permutation in this project and was never explained.
Enabling piecewise on top of vllm-project#52492 warrants re-running that soak rather than
inheriting this result. The warning text is deliberately worded so a deployment
that has not done so does not read silence as safety.

@aoshen02

Copy link
Copy Markdown
Owner Author

Retracting the "Not claimed" paragraph in 58111df8's commit message. It says a
deployment enabling piecewise on top of upstream vllm-project#52492 should re-run the
50-round soak, because vllm-project#52492 keeps the eager shortcut while a captured step
does full scoring, and an identical top-k set is not an identical order.

That premise is wrong, and reading the kernel settles it without a GPU. Both
top-k kernels this model can dispatch to write ascending indices when the
candidates fit — csrc/libtorch_stable/persistent_topk.cuh:915 and
cooperative_topk.cuh:491 carry the same trivial branch:

if (seq_len <= TopK) { row_output[i] = (i < seq_len) ? i : -1; }

which is exactly what _fill_short_context_topk_indices writes. So wherever the
shortcut applies, vllm-project#52492's two branches emit a bitwise identical index array —
same set, same order — and a graphed step cannot disagree with an eager one on
that account.

It also means the two fixes are equivalent on this deployment
(index_topk=512, compress_ratios ∈ {4, 128}, max_model_len=8192): the
ratio-128 layers never exceed 64 candidates so both paths take the trivial
branch, and the ratio-4 layers sit at 2048 > 512 so both do real scoring.

The probe that motivated the caveat measured 321-361 bf16 ULP under a random
permutation of the index list. No code path produces a non-monotonic list, so
that input does not occur in production and the measurement says nothing about
vllm-project#52492. That line is closed; its record is marked accordingly. Still unchecked,
and out of scope here: whether the kernels require monotonic indices at all, and
the third dispatch path top_k_per_row_decode, which this model never takes
(index_topk is 512).

Nothing in the code or tests changes — the retraction is of a caveat, not of a
claim the PR makes.

@aoshen02

Copy link
Copy Markdown
Owner Author

Dropped the multimodal encoder and Gemma4 dispatcher changes (6494fe8e6c), and
rewrote the body — it still described pinning cudagraph_mode to FULL, which
was removed in 58111df82e.

Both dropped changes existed because of the pin. The pin promised that no
numeric path is selected from the batch, so every dispatcher that schedules
graphs of its own had to be closed too — otherwise the promise was false in a
way a deployment could not see. What replaced the pin is a warning, which makes
no promise on the deployment's behalf, so that obligation is gone.

What is left is two changes to models nobody on this side has ever loaded. The
Gemma4 one is not a one-liner either: skipping the centroid capture requires an
eager centroid path of its own, or the base class silently substitutes a
full-vocab argmax. Changing numerics on unrun models is not something the
submitter can defend line by line, which is the bar this repo asks for.

Kept: microbatching (the only one that never consults cudagraph_mode and that
--enforce-eager does not reach either), the speculative-decoding proposer, the
capture-envelope warning, the piecewise warning, and the CI wiring.

Verification after the change: tests/test_config.py -k batch_invariant
13 passed, test_batch_invariant_dispatchers.py 1 passed, and the non-BI
control unchanged on all three assertions. Negative control: against the old
pinned build, 6 of the 13 fail — so they are testing the removal, not restating
it.

aoshen02 and others added 20 commits August 18, 2026 15:08
Piecewise cudagraph replay is not numerically equivalent to full-graph replay
or to eager, and it is not batch-invariant on its own either. Since
FULL_AND_PIECEWISE picks the per-step mode from batch properties (is the step a
uniform decode? does it fit in max_cudagraph_capture_size?), a request's output
ends up depending on its neighbours.

Measured on DeepSeek-V4-Flash-Base TP4/EP4 with the batch composition pinned by
a hand-driven LLMEngine (no HTTP races), victim token-1 logprob across
neighbour counts: eager 19/19 identical; FULL 17/17 identical and bit-equal to
eager; PIECEWISE not invariant; FULL_AND_PIECEWISE flipping between the FULL
and PIECEWISE values step by step. Online this showed up as 29/75 checker
rounds with a differing logprob, and 0/22 with --enforce-eager.

Ruled out first, each with a single-variable experiment: cudagraph batch
padding (dense capture sizes [1..128] still reproduce), the tile-scheduler plan
family (graphs plus a forced skeleton plan still leaks at the same rate), and
scheduling differences (eager is ~3.5x slower and changes co-scheduling, hence
the pinned-composition harness).

Pin cudagraph_mode to FULL under VLLM_BATCH_INVARIANT and stop the mixed-mode
downgrade from handing piecewise back; it uses FULL_DECODE_ONLY instead. That
keeps the decode-side graph win rather than forcing eager everywhere. Dynamic
speculative decoding on the v1 runner needs piecewise, so that combination now
raises instead of being silently overridden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 09b2ec8bd1359bda9f4b0f7162678b1ce7dbbccb)
Pinning cudagraph_mode to FULL only preserves batch invariance while every
decode step stays inside the capture set. Above max_cudagraph_capture_size a
step falls back to eager, and eager is not bit-identical to graph replay, so
which side of that boundary a request lands on -- a function of how many
requests share the step -- changes its output.

The default capture sizes are min(max_num_seqs * decode_query_len * 2, 512 or
1024), i.e. 2x headroom, so this only fires when the capture set is overridden
explicitly. Warn rather than raise: the remedy (a larger capture set) costs
capture time and memory that the user should opt into.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pinning cudagraph_mode=FULL under VLLM_BATCH_INVARIANT only guarded the
mixed-mode downgrade. Two later branches in resolve_cudagraph_mode_and_sizes
re-resolve to PIECEWISE without consulting the flag, so an unsupported backend
or spec-decode query length silently reinstated the per-step mode choice the
pin exists to remove. Both now drop to NONE under batch invariance: correct but
eager, rather than fast and batch-dependent.

A KV connector that requires piecewise for layerwise async operations hit the
same problem from the other side -- it sets PIECEWISE before the pin runs, and
the pin overrode a hard requirement. Refuse that combination instead.

Also correct the capture-set warning. It ignored an explicit
scheduler_config.max_num_scheduled_tokens, so a deployment that caps the token
budget below max_num_batched_tokens could get a bogus warning. The comment
claimed full cudagraphs and eager agree bit-for-bit; that is a per-model
observation, not something config validation establishes, and it contradicted
the warning's own premise. Say what the check actually covers.

tests/test_config.py: parametrized regression over both re-resolution paths.
Container run: pytest tests/test_config.py -k batch_invariant -> 7 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 2 of the audit found the pin still overrode two more downgrades. Pooling
models and, on ROCm, decode/prefill context parallel all set PIECEWISE because
full cudagraphs genuinely do not work there; pinning FULL afterwards reinstated
an unsupported mode. Generalize the KV-connector check into
_full_cudagraph_unsupported_reason() and refuse the whole class.

The capture-set warning was also wrong in the other direction: it compared
against the decode fan-out alone, but the defaults themselves cap at 512 (1024
on Blackwell), so a stock max_num_seqs=1024 deployment on H100 got a warning
telling it to fix a configuration it never chose. Compare against what this
deployment would capture by default instead, so only an actual shrink warns.

Last bit-for-bit claim in compilation.py removed for the same reason as the one
in vllm.py: whether graph and eager agree is a per-model property.

tests/test_config.py: the quiet-path test is parametrized over max_num_seqs and
pins the capability family so it does not depend on the host GPU. Negative
control with the old criterion: max_num_seqs=1024 fails, 64 passes. 8 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 3 found the refusal added last round was still reachable around: pooling,
KV connectors and the ROCm context-parallel hooks all downgrade to PIECEWISE,
but set_splitting_ops_for_v1() runs before the batch-invariance pin and can
raise that back to FULL (sequence parallelism, fuse_attn_quant). A check gated
on "the mode still reads as piecewise" then sees nothing to refuse and the run
proceeds on a mode the configuration cannot support. Check unconditionally.

The capture-set warning gets narrowed rather than made cleverer. Three rounds of
re-deriving "what the default would have been" produced three different wrong
answers: it ignored the platform cap, then model-level overrides (GPT-OSS
defaults to 1024 on non-Blackwell), then speculative decoding's query-length
alignment. Record whether the deployment actually chose a capture envelope and
only check explicit ones; a config that never chose has nothing to fix. The
message now says what it can and flags the spec-decode caveat instead of
pretending the number is exact.

tests/test_config.py: refusal regression for the pooling case. 9 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 4: the flag distinguishing a deployment's capture configuration from a
default was sampled inside _set_cudagraph_sizes, which runs after
try_verify_and_update_config(). A model is allowed to write its own capture
defaults there -- gpt-oss sets 1024 when the user set neither field -- so a
stock gpt-oss deployment read as "explicitly configured" and got a warning
about a configuration nobody wrote. Snapshot at the top of __post_init__.

The refusal regression was also not discriminating: its input was already
PIECEWISE so the pooling downgrade never happened, and enable_sp is disabled at
tensor_parallel_size=1, so the mode never got raised back and the old
piecewise-gated check would have passed it too. Use attention-quant fusion
instead, which does raise the pooling downgrade back to FULL before the pin.
Negative control against the piecewise-gated version: 1 failed.

tests/test_config.py: the flag itself is covered directly, with capture sizes
wide enough not to consume the warning the next test asserts on. 11 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The warning test's docstring still described the "below what this deployment
would capture by default" criterion, which was replaced two commits ago because
re-deriving the default kept getting it wrong. Say what the check does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 5: the flag distinguishing a deployment's capture configuration from a
default lived on VllmConfig, which breaks twice. with_hf_config() rebuilds the
config through replace(), which re-runs __post_init__ against already-resolved
fields, so the rebuild read its own defaults as a choice; and vllm's replace()
walks __dict__ expecting every entry to be a field, so a dynamic attribute there
raises outright.

Store it on the compilation config, which passes through replace() by
reference, and only record it the first time.

tests/test_config.py: the flag is asserted after a with_hf_config() rebuild, and
a monkeypatched hook that writes capture sizes during
try_verify_and_update_config stands in for gpt-oss so the ordering is pinned
without needing that model. Negative control without the first-time guard:
1 failed. 12 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 5: the dynamic-speculative-decoding refusal was still nested inside the
"mode still reads as piecewise" branch, so it was reachable around by exactly
the path the other refusals were hoisted out of -- dynamic SD downgrades to
PIECEWISE, attention-quant fusion or sequence parallelism raises it back to
FULL, and by the pin there is no piecewise left to see. Fold it into
_full_cudagraph_unsupported_reason() with the rest.

The pin's comment also still asserted piecewise is not numerically equivalent
to full or eager. On this model it is, once the indexer predicate is fixed.
State the argument that survives: the per-step mode is chosen from batch
properties, which is only harmless while every selectable path agrees, and
nothing checks that here.

13 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 6 enumerated every cudagraph_mode writer and every "X cannot use full
cudagraphs" rule, and found three the pin still walks past:

The speculative-decoding proposers pick PIECEWISE whenever the target model's
mixed mode is PIECEWISE *or FULL*, so pinning the target to FULL handed the
draft path a piecewise dispatcher -- which then chooses graph or eager per step
from the token count. That is the batch-dependent numeric path this whole
change exists to remove, reintroduced one model down. Force NONE for the
proposer under batch invariance.

PCP rejects full cudagraphs in the worker on every platform, not just ROCm; the
helper only checked it under is_rocm(), so CUDA MLA PCP was pinned to FULL and
then crashed at worker init. Only the DCP downgrade is ROCm-specific.

Voxtral Realtime asserts against any full mode at model construction. Explicit
PIECEWISE worked before; batch invariance pinned it to FULL and the assert
fired. Refuse it in the helper instead.

tests/test_config.py: PCP regression through the helper. 14 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 7 found a second dispatcher the pin never reaches. The encoder batches
this step's multimodal items together, picks a graph from their combined token
count, greedy-packs to a graph size and falls back to eager over budget -- the
same batch-dependent path selection, one level down and outside
cudagraph_mode's reach.

It is opt-in (cudagraph_mm_encoder defaults to False), so refuse the
combination rather than disabling it behind the user's back.

Gemma4's self-managed centroid graph is the remaining one of this shape and is
recorded as a known gap; it is not reachable from config alone.

15 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 8 enumerated every scheduling point that picks a graph per step. Three
were still outside the pin's reach:

Gemma4's MTP proposer captures centroid graphs at fixed sizes at load time and
replays whichever fits the step's token count, eager above 64. It never
consults cudagraph_mode, so the proposer PIECEWISE ban did not reach it. Skip
the capture under batch invariance -- lighter than refusing the model.

Microbatching decides per step whether to split and which graph to replay.
Opt-in (enable_dbo / ubatch_size), so refuse.

The multimodal encoder refusal added last round was too broad: the flag is
inert on a text-only model, so it now only fires for multimodal ones.

tests/test_config.py: both refusals through the helper, plus the text-only case
that must stay quiet. 15 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 9: the multimodal-encoder and microbatching refusals were added inside
_full_cudagraph_unsupported_reason(), which only runs when the main
cudagraph_mode is not NONE. But neither of those consults cudagraph_mode at all
-- the encoder manager keys on cudagraph_mm_encoder and multimodal support, and
microbatching on enable_dbo / ubatch_size. So under --enforce-eager both kept
scheduling their own graphs while the refusals never ran, and the message's
"run with --enforce-eager" was the wrong remedy for exactly those two. Check
them up front instead, ungated.

Skipping Gemma4's centroid capture also changed behaviour rather than just
disabling a graph: with no sizes captured, _greedy_sample fell through to the
base class and its full-vocab argmax instead of the model's own centroid
top-k. Keep the eager centroid path -- the same computation, no graph -- and
route the over-the-largest-size case through it too, which was the pre-existing
fallback.

tests/test_config.py: the encoder refusal is now asserted with cudagraph_mode
NONE as well, which is the case that was slipping through. 15 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The config-time refusal for cudagraph_mm_encoder rejected every multimodal
model, but the dispatcher is only created for models that also implement
SupportsEncoderCudaGraph -- Llava is multimodal and does not, so a
configuration where the flag was inert now failed to start. That capability is
not knowable until the model is loaded.

Handle it where it is: _create_encoder_cudagraph_manager returns None under
batch invariance, with a warning naming the reason. Same treatment as Gemma4's
centroid graphs, and for the same reason.

Microbatching keeps its config-time refusal -- enable_dbo / ubatch_size is an
exact condition, and it is equally ungated on cudagraph_mode.

tests/test_config.py: microbatching refused under both FULL and NONE, and the
encoder flag asserted *not* to be a config-time error. 15 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The batch-invariance check ran before supports_encoder_cudagraph(), so a
multimodal model that does not implement the protocol -- and would never have
got this manager -- was still told its encoder cudagraphs were being disabled.
Move it after, so only deployments that would really have had one hear about it.

tests/v1/determinism/test_batch_invariant_dispatchers.py: the decision points
for the three dispatchers that schedule graphs outside cudagraph_mode's reach,
as unit tests over the branches themselves -- no weights, no GPU. Covers the
encoder ordering (with and without the capability), Gemma4's centroid capture
including that skipping it keeps the eager centroid path rather than the base
class's argmax, and microbatching's refusal under both FULL and NONE.

7 passed; test_config.py -k batch_invariant still 15 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The B200 batch-invariance job lists its test files one by one;
source_file_dependencies only decides whether the job runs, it does not
discover new files. This one was never executed by any job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…anges

None of the six files this PR changes are under the paths the B200 Batch
Invariance job watches. The job runs here only because the PR adds a test
file; a later change to the cudagraph-mode resolution, to a spec-decode
proposer, or to the encoder manager would not run the tests written for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The warning said piecewise cudagraphs break batch invariance. On DSv4 they
no longer do: once the indexer's short-context predicate is bounded on a
static length, a 50-round soak under FULL_AND_PIECEWISE is bitwise clean,
where the same soak leaked at a 38.7% per-round rate before.

So the pin is a policy, not a measured necessity, and the message should
say the hazard instead of asserting a breakage that has been measured
away. What makes a piecewise mode unsafe is that a request's numeric path
follows the token count of the batch it lands in, which is only harmless
while every selectable path is bit-identical -- and that holds here
because no host-side value read inside a captured region is frozen
against the capture-time dummy batch. Nothing in this function can check
that for the next model or the next patch, so the pin stays.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The pin dropped any piecewise mode to FULL under VLLM_BATCH_INVARIANT, on the
premise that a per-step mode chosen from batch properties cannot be invariant.
That premise was measured and does not hold here: with the indexer's
short-context predicate no longer frozen against the capture-time dummy batch
(upstream vllm-project#52492), a 50-round soak of the full DeepSeek-V4-Flash-Base under
FULL_AND_PIECEWISE is bitwise clean, where the same soak leaked in 38.7% of
rounds before. Keeping the pin now costs the mixed-step graph for a guarantee
it was not providing.

So the mode is left alone and the warning carries what the pin used to: a
piecewise mode is invariant only while every path it can select is bitwise
equal for that model, which is verified for DeepSeek-V4 and unknown elsewhere.

Removed with it:
- the refusal for configurations that can use neither piecewise nor full,
  which existed only because the pin would otherwise undo their downgrades;
- `_full_cudagraph_unsupported_reason`, whose only caller that refusal was.
  vLLM's own downgrades to piecewise now stand unopposed, which is what they
  were written to do;
- the batch-invariant branches in `resolve_cudagraph_mode_and_sizes`, so those
  fallbacks resolve identically with and without invariance. Two tests now
  assert exactly that, rather than asserting the divergence.

Kept: every check that does not depend on the pin -- the capture-set escape
warning, and the refusals for microbatching, prefill context parallel, the
multimodal encoder dispatcher and the speculative-decode dispatchers. Those
close paths where a graph is chosen per step without consulting
cudagraph_mode, which no mode setting can fix.

Tested: tests/test_config.py -k the cudagraph/batch-invariant selection,
13 passed. Negative control: re-inserting the pin fails exactly the two
mode-survives cases and nothing else.

Not claimed: the soak that justifies this ran with the predicate bounded on a
static length, so both paths did full scoring. vllm-project#52492 instead keeps the eager
shortcut, and graph and eager then agree only while "candidates <= topk" makes
the shortcut's set equal to the real top-k -- an equal set is not an equal
order, and the order sensitivity of FlashMLA sparse decode is unresolved in
our measurements. A deployment enabling piecewise on top of vllm-project#52492 should
re-run that soak rather than inherit this result.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Both were found while closing every way to escape the FULL pin: the pin
promised that no numeric path is selected from the batch, so every dispatcher
that schedules graphs of its own had to be shut too. The pin is gone -- a
50-round soak showed DSv4's piecewise paths are bitwise equal -- and with it
that obligation. What replaced the pin is a warning, which makes no promise on
the deployment's behalf.

That leaves two changes to models nobody here has ever loaded. The Gemma4 one
is not a one-liner either: skipping the centroid capture requires an eager
centroid path of its own, or the base class silently substitutes a full-vocab
argmax. Changing numerics on unrun models is not something the submitter can
defend, so remove them; the analysis is kept as a draft for whoever has the
hardware.

Kept: microbatching (never consults cudagraph_mode, and --enforce-eager does
not reach it either), the speculative-decoding proposer, the capture-envelope
warning, and the piecewise warning.

tests/v1/determinism/test_batch_invariant_dispatchers.py keeps the
microbatching case; the two removed decision points take their tests with them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aoshen02

Copy link
Copy Markdown
Owner Author

Rebased onto fork/bi/base, which now points at upstream aa9903490 — the exact
commit the nightly container image is built from, so the files copied into
site-packages can no longer be half-old. The previous base was 3ee2df303,
218 commits behind, and upstream had touched every file this stack changes.

Two things upstream did for us in that window:

Post-rebase verification: tests/v1/determinism (the six files this stack adds
or touches) → 120 passed, 0 failed on GB200 against the new image.

Four issues from an external review of this branch.

Microbatching: the refusal fired on `use_ubatching`, which is the configured
flag, not whether a step can be split. `should_ubatch` stays False unless
data_parallel_size > 1, and check_ubatch_thresholds needs a step at least as
large as one of the two thresholds -- unreachable once both exceed the token
budget. Both cases were a startup failure for a configuration that could
never microbatch. Refuse only when it can actually run.

Capture envelope: `num_speculative_tokens` is the global maximum, but dynamic
speculative decoding pairs the large draft counts with small batches, so
max_num_seqs times the global maximum is a step the schedule forbids -- and
the warning then fired on an envelope that already covered every real step.
Take the largest step the schedule allows, in a helper so the arithmetic is
testable without a draft model.

Multimodal encoder: it greedy-packs the images scheduled this step and picks
a captured budget from their combined token count, consulting neither
cudagraph_mode nor --enforce-eager, so nothing here reached it. Warn.

Speculative decoding: the two draft-side dispatchers this branch switches off
are the legacy ones; the V2 speculators build cudagraph managers of their own
that this check does not reach. Say so, rather than let partial coverage read
as coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aoshen02 and others added 2 commits August 19, 2026 01:28
The _largest_uniform_decode_step helper was added between
@config(config=ConfigDict(...)) and class VllmConfig, so the decorator wrapped
the function and the class was never registered: importing vllm.config died in
pydantic with "'function' object has no attribute '__bases__'". Move the helper
above the decorator and assert VllmConfig is still a dataclass, since nothing
else in the file fails in a way that names the cause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VllmConfig has its own, earlier assertion that microbatching needs a
DeepEP/nixl all2all backend. The refusal test passed only because the batch
invariance check fired first, and the new "does not refuse what cannot run"
test hit that assertion instead of exercising the check at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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