Skip to content

[Core][MRV2] Support eagle3 spec decode with pipeline parallel - #50514

Open
yongqinwang-cmd wants to merge 5 commits into
vllm-project:mainfrom
yongqinwang-cmd:feat/spec-decode-under-pipeline-parallel
Open

[Core][MRV2] Support eagle3 spec decode with pipeline parallel#50514
yongqinwang-cmd wants to merge 5 commits into
vllm-project:mainfrom
yongqinwang-cmd:feat/spec-decode-under-pipeline-parallel

Conversation

@yongqinwang-cmd

@yongqinwang-cmd yongqinwang-cmd commented Jul 31, 2026

Copy link
Copy Markdown

Purpose

EAGLE3-style speculative decoding (eagle3 / dflash / dspark) is rejected outright
when pipeline parallelism is enabled:

ValueError: <method> with pipeline parallel is not supported.

The drafter runs on the last PP rank, but it consumes auxiliary hidden states tapped from
target layers that may live on earlier stages, and those tensors were dropped at the stage
boundary. This PR forwards them and lifts the guard.

Lifting the guard alone is not sufficient. The code behind it had never executed, so it hid
a stack of latent defects in the shared PP path — all of them reachable only once spec decode
and PP run together, which means they were unobservable rather than latent-but-live:

  1. Draft tokens never reached the non-last ranks. Only the last rank runs the drafter, so
    req_states.draft_tokens is written nowhere else — yet the first rank owns embed_tokens
    and builds the embeddings for the whole pipeline. It was embedding the scheduler's
    PLACEHOLDER_TOKEN_ID (-1) into the draft slots, so the last rank verified real proposals
    against logits computed from placeholders. Silent corruption. Fixed by broadcasting the
    proposals from the last rank on the existing deferred slot, which has identical production
    and consumption timing.

  2. compute_need_sampled_mask ended the broadcast early. The scheduler advances
    num_computed_tokens by the full scheduled width up front and rolls the rejected part back
    in update_from_output, which under PP lands after the next batch is scheduled. Reading
    the inflated count marked requests as finishing up to num_speculative_tokens early, after
    which the last rank stopped broadcasting and the other ranks' last_sampled_tokens froze,
    repeating a stale token.

  3. The sampled-token broadcast width could mismatch. receive() always allocates
    max_sample_len columns while broadcast() sent whatever width the sampler produced, and
    the non-spec path returns width 1. NCCL does not diagnose the count mismatch: the root
    completes and the peer hangs until the watchdog fires, so any PP + spec-decode run
    deadlocked on its first prefill. Fixed by padding the send side; post_update stops at
    num_sampled, so the pad columns are never read.

Design

Aux-state forwarding is added generically on EagleModelMixin. A stage packs its own taps
plus those inherited from upstream into the IntermediateTensors payload, and sizes its recv
buffers from the same rule — a tap a is upstream iff a <= start_layer — so send and recv
counts agree with no negotiation.

Models opt in with supports_aux_hidden_states_over_pp. The previous blanket error is retained
for those that do not, now raised with the offending model named. Kimi-K3 opts in; no other
model does, so EAGLE3 and dflash under PP remain gated and untested.

Two smaller changes ride along, both no-ops for configurations that worked before (spec decode
under PP > 1 was rejected outright): the DSpark drafter gets a real vocab embedding on the last
stage, where the target's is a PPMissingLayer; and the draft parallel config stops inheriting
the target's PP size, since the drafter is instantiated only on the last stage and is never
itself pipelined.

Scope: capped at pipeline_parallel_size <= 2

GPUModelRunner.load_model raises NotImplementedError above 2.

This reflects validation coverage, not a known defect. The forwarding rule is size-agnostic by
construction and the accounting is unit-tested up to pp=8. But pp>2 is the first topology with
a middle stage, which must both adopt upstream taps and contribute its own to the same
payload, and no such run has happened on hardware.

The conservative default is deliberate because this feature does not fail loudly: a drafter fed
mis-ordered or missing taps still emits syntactically valid proposals that simply get rejected
more often, so the only symptom is a depressed acceptance rate. Lifting the cap should require
an acceptance-rate comparison at pp>2, not just a successful boot. Happy to drop the cap if
maintainers would rather have the untested-but-general path available.

Note on commit 1

The first commit is an independent bugfix — MambaHybridModelState.postprocess_state feeds an
int32 idx_mapping to index_fill_, which accepts only int64:

IndexError: index_fill_(): Expected dtype int64 for index.

This is reachable on main today with no speculative decoding involved. Its only caller is
the non-last-PP-rank path in GPUModelRunner.execute_model, so any Mamba-hybrid model (Jamba,
Falcon-H1, Nemotron-H, Qwen3-Next, MiniMax, Kimi Linear) crashes under
pipeline_parallel_size >= 2 with a chunked-prefill batch. It is included here because Kimi-K3
is a hybrid model and hits it under PP. Glad to split it into its own PR if preferred.

Test Plan

Unit. A CPU test for the aux accounting invariant, no distributed init required:

pytest tests/v1/worker/test_eagle3_aux_hidden_states_pp.py

12 cases over pp ∈ {1,2,3,4,6,8}, exercising the real EagleModelMixin and real
get_pp_indices. Covers tap ordering and the boundary-tap double-count hazard (a tap landing
exactly on start_layer must be counted as upstream by exactly one stage). The parametrization
intentionally runs past the pp<=2 cap, since the accounting is general and this is what a future
enablement would build on.

End-to-end. Kimi-K3 (93 layers, DSpark drafter, num_speculative_tokens=7) at TP8 × PP2 on
2× 8×B200 with EFA. Two checks:

  1. Correctness: greedy decode compared token-for-token against the identical topology with
    speculative decoding disabled. Speculative decoding is supposed to be output-neutral, so any
    divergence is a bug.
  2. Acceptance rate: the sensitive detector. Corruption of aux taps crossing the PP boundary
    degrades acceptance before it produces visibly wrong text.

Throughput measured on 128 fixed GSM8K prompts, same seed, concurrency 1 → 32.

Test Result

Unit: 12 passed. ruff check and ruff format --check clean.

Correctness: greedy output is token-for-token identical to the same topology without
speculative decoding.

Acceptance: 2.32 of 7 draft tokens per step.

Throughput (output tok/s, GSM8K, 128 prompts):

Concurrency No spec Spec (PP2) Speedup
1 77.30 174.52 2.26×
4 255.77 461.53 1.80×
8 423.86 714.01 1.68×
32 1138.69 1584.07 1.39×

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

@mergify mergify Bot added kimi k3 mrv2 Model Runner V2 specific labels Jul 31, 2026
@github-actions

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 whenever CI signals are needed.

Once the PR is approved or has the ready label, the PR author can also use /ci run or /ci retry. New commits do not start 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 commented Jul 31, 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, @yongqinwang-cmd.

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

@AbroadConfirm

Copy link
Copy Markdown

Hardware validation on the exact shape this caps at (TP8 × PP2, 2×8 B200, EFA) — Kimi-K3 MXFP4 + Inferact/Kimi-K3-DSpark, 1M context, vLLM main @ e578de3 (cu130 wheel 0.26.1rc1.dev292) with this PR applied at setup time:

  • Engine boots and serves. Config parse, K3DSparkModel on last stage, 5,461,456-token KV pool, 48/48 FULL + 51/51 PIECEWISE CUDA graphs captured.
  • Aux forwarding is correct. GSM8K @ temp 0: measured accept length 5.42 over 30 problems (vs the draft card's 5.64) — the forwarding path sustains the published acceptance class. Per-position acceptance shows a healthy declining curve.
  • >1M-token retrieval intact: needle-in-haystack at 1,029,433 tokens, depths 0.1/0.5/0.9, 3/3 with spec on.

Two things to share beyond "it works":

  1. The kill shot we took to get here was a main-line bug being newly exposed by this PR's shape, filed as [Bug] fastsafetensors ParallelLoader broadcasts on group.WORLD; PP-scoped draft loads deadlock #50959: weight_utils feeds the fastsafetensors ParallelLoader torch.distributed.group.WORLD unconditionally, so the drafter's (last-stage-only) weight broadcasts have no peers on PP0 and the boot hangs until the watchdog (BROADCAST NumelIn=1, 600 s). load-format auto is the workaround; whoever supports spec×PP with --load-format fastsafetensors (the Kimi-K3 recipe pins it on Blackwell) will want that loader scoped to the loading participants.
  2. For the record: the pre-PR symptom you describe in the description (broadcast width mismatch → silent peer hang) reproduces verbatim when an older (pre-[New model] Kimi K3 #50000-squash, 2026-07-27) K3 integration image is patched with this PR — folds back to "run the PR on its own substrate", which we then did.

Happy to rerun the same gates on the PR head once it's rebased (remote-synchronized needles / accept measurements are cheap on this rig), and to act as the hardware validator if you want a pp=2 multimodal or multi-request soak for the merge review.

@ywang96 ywang96 added the ready ONLY add when PR is ready to merge/full CI is needed label Aug 4, 2026
@ywang96

ywang96 commented Aug 4, 2026

Copy link
Copy Markdown
Member

Thanks for the PR! I resolved the conflict - cc @zixi-qi

@mergify mergify Bot removed the needs-rebase label Aug 5, 2026
@zixi-qi

zixi-qi commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR! The high level approach looks good, left a few comments in the code. Additionally:

  1. It would be great to add an e2e CI test with a smaller model (e.g. Qwen or Llama + EAGLE3) to guard this functionality.
  2. There seems to be a deadlock issue reported in [Bug] fastsafetensors ParallelLoader broadcasts on group.WORLD; PP-scoped draft loads deadlock #50959 based on this PR. Seems @JaredforReal already has a fix so not blocking, just FYI.

# Locate the embedding tensor: prefer the shard index, else scan the shards.
key = None
shard_path = None
index_path = os.path.join(model_dir, "model.safetensors.index.json")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Does this work when the model is not cached locally?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I changed the code such that now it runs the model's hf_to_vllm_mapper over the checkpoint names and matches on the parameter name, keeping the original name to read the tensor.

Comment thread vllm/v1/worker/gpu/pp_utils.py Outdated
# been freed writes to a slot nobody reads, and add_requests zeroes
# the row before any reuse, whereas the -1 sentinels in the filtered
# `idx_mapping` would alias the last row.
outputs["draft_update"] = (slot.draft_tokens, slot.idx_mapping)

@zixi-qi zixi-qi Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think we should use the filtered idex_mapping instead of this unfiltered one. Does below explanation make sense to you?

A pending PP entry is consumed pp_size steps after it is received. During that delay, a request can finish and its state index can be reassigned to a new request. The generation check correctly replaces that row with -1 in the sampled-token mapping, but draft_update uses the original unfiltered mapping.

In a mixed batch, this writes the finished request’s stale drafts into the new request that now owns the same index. add_requests zeroing does not prevent this because the deferred write can occur after the index has been reused and zeroed.

Comment thread vllm/config/speculative.py Outdated
Comment on lines +1294 to +1298
# The drafter is instantiated only on the last pipeline stage and is
# never itself pipelined, so it must not inherit the target's PP
# size; doing so would require the draft architecture to implement
# SupportsPP. This is a no-op for previously working setups, since
# speculative decoding under PP > 1 was rejected outright before.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Overall the additional comments in this PR can be a bit too verbose. Would be great if you could go through all the comments and make them as concise as possible and remove ones where the code already demonstrates the intent clearly.

Comment thread vllm/v1/worker/gpu/model_runner.py Outdated
Comment on lines +349 to +364
if pp_size > 2:
# The aux forwarding itself is size-agnostic: every stage
# derives what it owes downstream from the same rule, and
# the accounting is unit-tested up to pp=8. What has not
# been exercised on hardware is a *middle* stage, which
# pp>2 introduces and which must both adopt upstream taps
# and contribute its own to the same payload. Given that
# the failure mode of this feature is silently degraded
# acceptance rather than a crash, refuse rather than let it
# run unvalidated. Lifting this needs an end-to-end
# acceptance-rate comparison at pp>2, not just a boot test.
raise NotImplementedError(
f"{self.speculative_config.method} with pipeline parallel "
f"is currently supported only up to pipeline_parallel_size=2, "
f"got {pp_size}."
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

From the comments here:

The aux forwarding itself is size-agnostic: every stage

Do I understand correctly that PP > 2 is technically supposed to be supported but the guard is added here because it is not tested? If so I think we should test it and remove this guard

@yongqinwang-cmd yongqinwang-cmd Aug 6, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Originally designed for PP=2, per your comment, i re-worked this PR so that when PP>2, previous PP stages will forward their auxiliary data to the last rank (validated on both EAGLE3 on Llama-3.2-1B
and DSpark on DeepSeek-V4-Flash . This comment has more details about the exact design. My design ensures minimal communication during the drafting for the best itl.

Comment thread vllm/v1/worker/gpu/pp_utils.py Outdated
Comment on lines +267 to +274
# receive() unconditionally allocates max_sample_len columns, but
# the non-spec sampler path (num_draft_tokens == 0) returns width 1,
# so an unpadded broadcast leaves the peer waiting on a larger count
# than the root sends. NCCL does not diagnose the mismatch: the root
# completes and the receiver hangs until the watchdog fires. Pad so
# both sides agree. post_update reads each row with
# sampled_tokens.stride(0) and stops at num_sampled, so the pad
# columns are never observed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

can the comments here be more concise?

@mergify

mergify Bot commented Aug 6, 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, @yongqinwang-cmd.

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

@mergify mergify Bot added the needs-rebase label Aug 6, 2026
yongqinwang-cmd added a commit to yongqinwang-cmd/yongqin-vllm that referenced this pull request Aug 6, 2026
Address PR vllm-project#50514 review feedback: keep-mask draft_update, HF-resolve
embed load, concise comments, Llama/Qwen/DSv4 opt-in, EAGLE3×PP=2 e2e,
and remove the pp>2 guard. Aux taps no longer chain through
IntermediateTensors.
@mergify mergify Bot added the llama Related to Llama models label Aug 6, 2026
yiminyuan added a commit to yiminyuan/vllm that referenced this pull request Aug 17, 2026
Three guards refused the drafter under PP -- the draft config inherited the
target's pipeline size, so a draft model without SupportsPP was rejected; the
V2 runner raised for eagle3/dflash/dspark because their aux taps may sit on an
earlier stage; and the DSpark loader aliases the target's embed_tokens, which
is a PPMissingLayer anywhere but the first stage. Past those, warmup deadlocked:
PPHandler.receive always allocates [num_reqs, max_sample_len] while the sampler
returns a single column when there are no drafts to verify, so broadcast sent a
narrower tensor, the ranks disagreed on the element count, and the stage that
had moved on wedged in its next device sync. Give the draft
pipeline_parallel_size=1 since it is built on the last stage only, let a model
declare that it carries its EAGLE3 aux taps across the handoff, build the
target's embedding on the last stage rather than draft with uninitialized
weights, pin one broadcast payload width on both sides and send the next step's
drafts in it through the filtered index mapping, and declare the AMD DSpark
head's draft_id_to_target_id. Decode TPOT 43.9 -> 35.2 ms and output throughput
20.0 -> 24.3 tok/s against the same runner without speculation, at 19.8% draft
acceptance.

Upstream adds pipeline-parallel support for these drafters in vllm-project#50514; drop this
commit when rebasing onto a tag that already contains it.
mispa-ms added a commit to mispa-ms/srt-slurm that referenced this pull request Aug 19, 2026
Upstream refuses this outright -- vllm/v1/worker/gpu/spec_decode/dspark/utils.py
on main still raises NotImplementedError when the PP group is larger than one,
and SpeculativeConfig hands the draft the target's pipeline_parallel_size, which
asks K3DSparkForCausalLM for a SupportsPP it does not declare. That is the error
all seven of our earlier PP2 arms died on.

The support lives in our vLLM fork as 2111011d33 (adopting vllm-project/vllm#50514),
540423f2c7 and 503820ebdd: the draft stops inheriting PP size and runs whole on
the last stage, aux hidden states relay between stages as IntermediateTensors
behind a Kimi-K3-only opt-in, the last stage loads the real embedding table
instead of aliasing a PPMissingLayer, and the draft loader drops fastsafetensors
under PP because its collective runs over group.WORLD.

Applied with patch(1) after a --dry-run, and verified afterwards that the refusal
is gone and the draft's PP size is pinned to 1. A mismatch fails the job rather
than running a half-patched engine; the fork's base is 38a466e7b6 and the image
is 5894fdf98, so drift will eventually break it and should be loud when it does.

Any arm using this must ship with a GSM8K run. The adopting commit records that
draft tokens previously failed to reach earlier stages and the first rank
embedded PLACEHOLDER_TOKEN_ID(-1) -- silent corruption that ignore_eos perf arms
cannot see.
mispa-ms added a commit to xinli-sw/vllm that referenced this pull request Aug 19, 2026
…llelism

vllm-project#50514 (yongqinwang-cmd)를 우리 베이스 38a466e 위에 적용한다.
우리 자체 구현(브랜치 misunp/k3-dspark-pp)과 설계가 같고 — aux를 IntermediateTensors로
릴레이, 모델별 opt-in 플래그, Kimi-K3만 opt-in, 드래프트 config의 PP 상속 제거,
마지막 스테이지 드래프터 임베딩 — 우리가 5판을 태우고도 못 찾은 두 가지를 더 담고 있다:

1. broadcast 폭 불일치. receive()는 항상 max_sample_len 열을 받는데 broadcast()는
   샘플러가 만든 폭을 보내고 non-spec 경로는 폭 1이다. NCCL은 개수 불일치를 진단하지
   않아 root는 완료하고 peer는 watchdog까지 멈춘다 — 우리 블로커 #7의 진짜 원인이다.
2. draft 토큰이 앞 스테이지에 도달하지 않아 첫 랭크가 PLACEHOLDER_TOKEN_ID(-1)를
   임베딩했다. 조용한 오염이라 서빙에 도달했어도 못 봤을 것이다.

충돌 1건 해소: KimiLinearModel이 우리 베이스에서 SupportsQuant와
packed_modules_mapping을 갖게 되어, 양쪽을 유지하고 PR의 opt-in 플래그를 더했다.

PR 테스트 12개 통과. pipeline_parallel_size <= 2 캡이 있으며 우리 TP4xPP2가 그 안이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: misunp <misunp@nvidia.com>
(cherry picked from commit 2111011d331343cc22d02bc87020069edc22de6f)
@rnxrx

rnxrx commented Aug 21, 2026

Copy link
Copy Markdown

Retest attempt on a 2-node TP2×PP2 topology — inconclusive, with one scope question

We tried to retest this on our lab pool after the rebase. We did not get a clean validation result, and I want to report the attempt honestly rather than let silence read as "no problems found."

Topology (this is the part that may be useful to you): DeepSeek-V4-Flash NVFP4, TP=2 × PP=2 across two nodes, interconnect is 2×100G RoCE running NCCL over TCP sockets — no NVLink/NVSwitch even intra-node, and no uverbs, so GPUDirect/RDMA is unavailable and NCCL falls back to sockets. Measured inter-node ceiling ~8.3 GB/s vs 17.1 intra-node. It is a harsher PP environment than most CI or contributor hardware.

What happened

Spec-ON failed at config validation, before any of the runtime paths this PR touches:

create_engine_config → create_speculative_config (arg_utils.py:1829)
  → SpeculativeConfig._verify_args (config/speculative.py:1320)
    → self.draft_model_config.verify_with_parallel_config (config/model.py:1329)
      NotImplementedError: Pipeline parallelism is not supported for this model.
                           Supported models implement the `SupportsPP` interface.

Spec-OFF never served either, but for an unrelated reason — cross-node distributed init died after ~10 min with DistNetworkError: Failed to recv, got 0 bytes during NCCL/TCPStore rendezvous. That looks environmental on our side, so we have no baseline to compare against.

The scope question

We ran --speculative-config {"method":"deepseek_mtp","num_speculative_tokens":1} — the model's in-model MTP layer — whereas this PR is scoped to the EAGLE3-style external drafters (eagle3 / dflash / dspark) and the supports_aux_hidden_states_over_pp opt-in.

So the question: is in-model MTP (deepseek_mtp) intended to be in scope here? If it is, the validation above appears to reject the config upstream of the guard this PR lifts — _verify_args validates the draft model against the target's parallel config, and the MTP module doesn't implement SupportsPP. If it is out of scope, please disregard, and it may still be worth an explicit error that distinguishes "this drafter family isn't supported under PP" from the generic model-level message, since the generic one sent us looking in the wrong place.

Caveat on the above, stated plainly

We cannot currently confirm our test image carried this PR. One arm's engine log reports stock v0.27.1 while our PR build self-reports vllm dev, and the other arm failed before printing a version. So the SupportsPP trace above may have come from an unpatched build. We're re-running with the image digest captured at serve time, using an in-scope drafter (dspark) this time, and will follow up with a build-verified result either way.

Offer

If it would help, we're happy to run this topology against the PR head as a validation target — particularly for defect #3 in your description (the broadcast()/receive() width mismatch that NCCL doesn't diagnose and that hangs until the watchdog fires). A TCP-sockets fabric with no RDMA is a good place for that class of bug to actually show itself. Tell us which drafter/model combination you'd most like covered and we'll run it.

mispa-ms added a commit to mispa-ms/vllm that referenced this pull request Aug 21, 2026
Root cause of AIB 63781036, where a PP=2 prefiller and a PP=1 decoder answered
none of 1319 GSM8K questions. Prefill ran; decode never logged an engine step;
every PUSH_REG registration timed out at 480 s.

DSpark numbers its draft layers from the end of the target's, using
get_num_layers(parallel_config) -- which is end-start, the PP-*local* count. At
PP=2 over 93 layers stage 1 reports 46, so its draft registered
model.layers.46..50 while the PP=1 peer registered 93..97, and 46..50 also
collided with that stage's own target layers 47..92. Member routing transfers by
layer name, so the two engines disagreed about what "model.layers.46.self_attn"
is and the planner refused it, correctly, 859 times.

Symmetric PP passed only because both sides computed the same wrong number. The
decode-PP2 arms are green for that reason, not because they were right; their
0.907/0.932 stand but the mechanism behind them did not.

Upstream has the same expression in cohere_eagle and deepseek_eagle3. It was
unreachable while spec decode under PP was refused; adopting vllm-project#50514 is what made
it reachable, so it belongs to this branch to get right. Those two are left
alone -- different models, nothing here can test them -- but an upstream PR has
to say so.

Three more from a fresh review, all real:

pp_rank's fallback divided the global rank by TP alone. The documented layout is
ExternalDP x DP x PP x PCP x TP, so with DP>1 -- how K3 runs with EP -- it
returned a stage index off by a multiple of pp_size, cached for the process
lifetime. Now divides by PCP*TP and wraps by pp_size.

The pull connector inherited decode-side PP when the blanket refusal came out,
and it cannot do it: its handshake never exchanges pp_size, so handles resolve
at stage 0 unconditionally, and consumers_per_producer counts TP alone -- a
PP-sharded consumer would let the producer free blocks after one stage's notif
while another was still reading. Neither symptom is loud. It refuses at startup
now, which is what the docs already said.

_tracks_region_members keyed off _is_hma_required, a property of this stage's
layers, so a stage holding only full-attention layers advertised no members
while its hybrid peer required them. Widened to cover packed and PP as well:
advertising names a peer ignores costs a list in the handshake; withholding them
is a dead engine. This is the third time these two gates have disagreed.

332 passed. The gemma-3 gated-repo 401 fails identically without the patch.

AI assistance was used for this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mergify

mergify Bot commented Aug 21, 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, @yongqinwang-cmd.

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

@mergify mergify Bot added the needs-rebase label Aug 21, 2026
@zixi-qi

zixi-qi commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Hi @yongqinwang-cmd sorry for the delay, would you mind to resolve the merge conflict again and then I will rerun CI

@rnxrx

rnxrx commented Aug 21, 2026

Copy link
Copy Markdown

Correction: the build was patched — retracting my caveat, which makes the trace above a real result

In my previous comment I said we couldn't confirm our image carried this PR, because one arm's engine log reported stock v0.27.1. That was wrong, and I want to correct it before it costs you any time.

Our image is built as FROM vllm/vllm-openai:v0.27.1 with the PR's Python files staged over the installed package. The base image's version metadata therefore still reports 0.27.1 by construction — the version string cannot distinguish patched from unpatched in this build scheme, so it was never evidence of anything. I should have checked the code rather than the version banner.

Verified directly inside the image instead (built from PR head 886e88a3d1, "Fix aux-tap packing under compile and outside a worker"):

vllm.__version__                                     : 0.27.1   ← base metadata, not meaningful
vllm.v1.worker.gpu.pp_utils                          : PRESENT
vllm.v1.worker.gpu.spec_decode.dspark.utils          : PRESENT
vllm.model_executor.models.deepseek_eagle3           : PRESENT
grep "with pipeline parallel is not supported"       : absent   ← the lifted guard is gone

So the PR is applied, and the NotImplementedError: … SupportsPP we hit is a genuine result against this PR, not against stock. It comes from SpeculativeConfig._verify_args:

if self.draft_model_config:
    self.draft_model_config.verify_with_parallel_config(
        self.draft_parallel_config
    )

With deepseek_mtp under TP=2 × PP=2, this rejects at config-construction time — before the guard you lifted is ever reached. So the scope question in my previous comment stands and is now better evidenced: if in-model MTP drafters are meant to be in scope, this validation appears to gate them out independently of the guard. If they're out of scope, no action needed beyond possibly a clearer message.

One observation offered as a question, not a claim: I could not find supports_aux_hidden_states_over_pp anywhere in the staged package, though the PR description references it as the opt-in set. Either it's named differently at this head, lives in a file our build didn't stage, or landed after 886e88a3d1. If our snapshot is missing part of the change, tell us and we'll rebuild before running anything further.

Re-test with an in-scope drafter (dspark) on the same 2-node TP2×PP2 sockets topology is queued; we'll report the spec-OFF baseline alongside it this time.

@rnxrx

rnxrx commented Aug 21, 2026

Copy link
Copy Markdown

Correction #2 — our image was only partially patched. Please disregard the SupportsPP result.

I got this wrong twice and the third check settles it. Retracting my previous comment: our test image did not contain your behavioural changes, so the NotImplementedError: … SupportsPP we reported is not a result against this PR. I'm sorry for the noise.

What actually happened. Our image is built FROM vllm/vllm-openai:v0.27.1, staging your branch tree at /build/vllm and then pip install -e . with VLLM_USE_PRECOMPILED=1. The editable install did not take precedence — at runtime vllm still resolves to the stock site-packages tree:

vllm resolves to : /usr/local/lib/python3.12/dist-packages/vllm/__init__.py
vllm.v1.worker.gpu.pp_utils                       : PRESENT      ← new file from the PR
model_executor/models/interfaces.py
  supports_aux_hidden_states_over_pp              : NOT PRESENT  ← your edit missing
model_executor/models/llama.py
  supports_aux_hidden_states_over_pp = True       : NOT PRESENT  ← your edit missing

So the image picked up files your PR adds while missing edits your PR makes to files that already exist — which is the worst possible state, because it imports cleanly and looks patched. Our build's tripwire only asserted that the new modules import, and a partially-applied PR passes that check.

Consequences for what I posted earlier:

  • The SupportsPP rejection is stock 0.27.1 behaviour, not yours. Disregard it.
  • My scope question about in-model MTP (deepseek_mtp) is not evidenced by our run. It may still be a fair question, but we have not tested it and I shouldn't have framed it as a finding.
  • My previous comment said supports_aux_hidden_states_over_pp appeared to be missing from your branch. It is not missing. It is present at 886e88a3d1 in interfaces.py:1498, llama.py:357, qwen2.py:334, models/deepseek_v4/nvidia/model.py:996 and models/kimi_k3/nvidia/model.py:1100. It was absent only from our broken image. Please ignore that observation entirely — nothing for you to chase.

Also confirmed: we re-fetched pull/50514/head and it is byte-identical to what we built from (886e88a3d1), so this was never a staleness problem.

We're rebuilding as a direct overlay onto the installed package rather than an editable install, with a tripwire that asserts the content of your changes is what resolves at runtime — not merely that the new modules import. Then we'll run dspark under TP=2 × PP=2 with a spec-OFF baseline on the same vehicle, and report. Nothing further from us until that is build-verified.

mispa-ms added a commit to mispa-ms/vllm that referenced this pull request Aug 21, 2026
The discount I added to compute_need_sampled_mask was a no-op in the case it
was written for. num_computed_tokens is inflated by the *previous* step's
drafts -- the comment says so itself -- but the subtrahend was
num_draft_tokens_per_req, this step's count. A request given four drafts and
then none gets a discount of zero, still reads as finishing early, and the last
rank stops broadcasting while the other ranks repeat a stale token. That is the
corruption the discount exists to prevent, arriving through the discount.

The reverse mismatch (three then four) over-subtracts, which is harmless. Only
one direction is safe, so subtract a bound that is always at least the
inflation: num_speculative_steps, passed in from the handler. The existing
max(..., prefill_len) clamp absorbs it.

The price is stated and tested rather than assumed: a request stays in the
broadcast for up to num_speculative_steps extra steps past max_seq_len, then
drops out. One redundant collective against a corrupted token.

produces_sample deliberately keeps the undiscounted count -- it asks whether
this step's tokens complete the prefill, and an inflated count there errs
toward broadcasting, the safe direction again.

9 tests, covering the broken combination directly, every inflation the
scheduler can produce, the bounded tail, and no-speculation.

Not covered: tests/v1/worker/test_eagle3_aux_hidden_states_pp.py, which this
branch adopted with vllm-project#50514, fails 11 of 12 in the container against an
unpatched nightly and cannot be validated by copying modules into an installed
wheel -- adding more modules breaks the rest. It needs a real build. I had not
run it before now.

Signed-off-by: Mi Sun Park <misunp@nvidia.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolve conflicts after the DSpark lazy-import refactor and Qwen3 DFlash model subclassing while preserving embedding sharing and global draft-layer numbering.

Co-authored-by: Cursor <noreply@cursor.com>
Signed-off-by: Yongqin Wang <yongqinwang@roblox.com>
@yongqinwang-cmd

Copy link
Copy Markdown
Author

@zixi-qi The merge conflict is resolved again at 597ed4934, and GitHub now reports the PR as mergeable. I preserved upstream's Qwen3 DFlash subclass hook and DSpark lazy-import refactor while retaining global draft-layer numbering and target-embedding sharing.

Local checks: 33 unit tests passed; Ruff check and format are clean. Would you mind rerunning CI? Thanks!

@zixi-qi

zixi-qi commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

/ci run

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #85077 for commit 597ed49348b9.

@rnxrx

rnxrx commented Aug 21, 2026

Copy link
Copy Markdown

Build-verified result: the PP guard works correctly on a 2-node TCP-sockets topology

Following up my retractions above — this run is build-verified, and it's a positive result for the PR.

Setup. Rebuilt as r4: your branch at 886e88a3d1 overlaid directly onto the installed package (r3's pip install -e . never took precedence, which is what produced my earlier false report). The build now asserts content, not importability, and the serving image is re-checked at run time before any GPU is spent:

interfaces.supports_aux_hidden_states_over_pp : OK
llama opt-in                                  : OK
qwen2 opt-in                                  : OK

Topology. TP=2 × PP=2 across two nodes, 2×100G RoCE with NCCL over TCP sockets — no NVLink even intra-node, no uverbs, so no RDMA/GPUDirect. ~8.3 GB/s inter-node vs 17.1 intra-node.

Baseline (spec-OFF), Qwen3.6-35B-A3B NVFP4, serves and is coherent:

tok/s
c=1 188.9
c=8 254.3

Batching scales only 1.35× from c=1 to c=8 here, which is what a PP pipeline looks like when it's latency-bound on sockets.

Spec-ON — your guard fired, correctly and fast:

ValueError: dspark with pipeline parallel is not supported by
  Qwen3_5MoeForConditionalGeneration: it does not forward auxiliary
  hidden states across pipeline stages.
        vllm/v1/worker/gpu/model_runner.py:366  (load_model)

That is the right outcome — Qwen3_5MoeForConditionalGeneration is not in the opt-in set, and I picked it on the mistaken assumption that Qwen3.6-35B-A3B was qwen2-family. Reporting it because the failure mode is the good one: a typed error at model load, naming the class and the reason, rather than the NCCL count-mismatch hang you describe in defect #3. On a sockets fabric with no RDMA — where that class of hang is most likely to bite — we got a clean refusal in ~4 minutes instead.

Request: please add Qwen3_5MoeForConditionalGeneration to the opt-in set

This is our second such request — I asked for MiMoV2FlashForCausalLM on 12 Aug. Two independent models in one fleet blocked on the same missing opt-in suggests the list may be narrower than the feature's actual reach. Both are MoE architectures, if that's a common factor worth checking.

What we cannot test, and why — in case it's useful signal

We tried to find any pairing on hand that would exercise the aux-hidden-state path, and could not build one:

  • The path is gated on method in ("eagle3", "dflash", "dspark") and a target carrying supports_aux_hidden_states_over_pp, which at 886e88a3d1 is {llama, qwen2, deepseek_v4, kimi_k3}.
  • Every eagle3-family draft we hold targets a non-opted-in arch: gemma, gpt-oss, nemotron, qwen3_6, Qwen3_5Moe.
  • Both opted-in targets we hold — DeepSeek-V4-Flash and Llama-3.3-70B — had no eagle3-family draft.
  • DS4's in-model deepseek_mtp is an MTP method, not aux-hidden-state, so it never reaches your code (that's why our first attempt died in generic draft-model validation instead).

So the intersection of "drafts that exist in the wild" and "targets this PR opts in" may be thinner than it looks from the diff. We've now fetched yuhuili/EAGLE3-LLaMA3.3-Instruct-70B to pair with Llama-3.3-70B-Instruct-FP8 and will run the real spec-ON validation on that. Will report drafted/accepted counters and the throughput delta against the spec-OFF baseline on the same vehicle.

If there's a different pairing you'd rather see covered on this topology, say so and we'll run that instead.

yiminyuan added a commit to yiminyuan/vllm that referenced this pull request Aug 21, 2026
Three guards refused the drafter under PP -- the draft config inherited the
target's pipeline size, so a draft model without SupportsPP was rejected; the
V2 runner raised for eagle3/dflash/dspark because their aux taps may sit on an
earlier stage; and the DSpark loader aliases the target's embed_tokens, which
is a PPMissingLayer anywhere but the first stage. Past those, warmup deadlocked:
PPHandler.receive always allocates [num_reqs, max_sample_len] while the sampler
returns a single column when there are no drafts to verify, so broadcast sent a
narrower tensor, the ranks disagreed on the element count, and the stage that
had moved on wedged in its next device sync. Give the draft
pipeline_parallel_size=1 since it is built on the last stage only, let a model
declare that it carries its EAGLE3 aux taps across the handoff, build the
target's embedding on the last stage rather than draft with uninitialized
weights, pin one broadcast payload width on both sides and send the next step's
drafts in it through the filtered index mapping, and declare the AMD DSpark
head's draft_id_to_target_id. Decode TPOT 43.9 -> 35.2 ms and output throughput
20.0 -> 24.3 tok/s against the same runner without speculation, at 19.8% draft
acceptance.

Upstream adds pipeline-parallel support for these drafters in vllm-project#50514; drop this
commit when rebasing onto a tag that already contains it.
@yongqinwang-cmd

Copy link
Copy Markdown
Author

@zixi-qi

Does the ci results good good to you? I check all failed tests are unrelated to this PR.

@zixi-qi

zixi-qi commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

@zixi-qi

Does the ci results good good to you? I check all failed tests are unrelated to this PR.

Yeah CI looks good, thanks for the rebase!

@mergify mergify Bot removed the needs-rebase label Aug 21, 2026
@njhill njhill changed the title Feat/spec decode under pipeline parallel [Core][MRV2] Support spec decode with pipeline parallel Aug 21, 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.

Thanks @yongqinwang-cmd @zixi-qi

One question, is "taps" a standard/well-known term? I found it a bit confusing

Also the PR description seems to be quite out of date / incorrect

Comment on lines +1759 to +1761
# Pair the last rank's post-propose draft send.
if self.num_speculative_steps > 0:
self.pp_handler.receive_drafts(input_batch)

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.

Can we absorb this into pp_handler.receive above?

self.pp_handler.broadcast_drafts(
self.req_states.draft_tokens[input_batch.idx_mapping],
input_batch,
)

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.

Let's do the indexing inside the method if/when needed

Suggested change
)
self.pp_handler.broadcast_drafts(self.req_states.draft_tokens, input_batch)

Comment on lines +380 to +387
if self.use_pp and not supports_aux_hidden_states_over_pp(self.model):
raise ValueError(
f"{self.speculative_config.method} with pipeline parallel "
f"is not supported by {type(self.model).__name__}: it does "
"not forward auxiliary hidden states across pipeline stages."
)
if self.use_pp:
self.aux_pp_relay_keys = aux_pp_relay_keys(self.model)

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.

Suggested change
if self.use_pp and not supports_aux_hidden_states_over_pp(self.model):
raise ValueError(
f"{self.speculative_config.method} with pipeline parallel "
f"is not supported by {type(self.model).__name__}: it does "
"not forward auxiliary hidden states across pipeline stages."
)
if self.use_pp:
self.aux_pp_relay_keys = aux_pp_relay_keys(self.model)
if self.use_pp:
if not supports_aux_hidden_states_over_pp(self.model):
raise ValueError(
f"{self.speculative_config.method} with pipeline parallel "
f"is not supported by {type(self.model).__name__}: it does "
"not forward auxiliary hidden states across pipeline stages."
)
self.aux_pp_relay_keys = aux_pp_relay_keys(self.model)

Comment on lines +380 to +384
if self.use_pp and not supports_aux_hidden_states_over_pp(self.model):
raise ValueError(
f"{self.speculative_config.method} with pipeline parallel "
f"is not supported by {type(self.model).__name__}: it does "
"not forward auxiliary hidden states across pipeline stages."

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.

Actually could we change this method to be like verify_supports_aux_hidden_states_over_pp and have it raise the exception itself, to simplify the code here

Comment on lines 966 to +973
outputs = self.pp_handler.get_prev_sampled_outputs()
if outputs is not None:
# Land with the matching sampled tokens so _prepare_inputs
# splices real draft ids instead of placeholders.
draft_update = outputs.pop("draft_update", None)
if draft_update is not None:
draft_tokens, draft_idx_mapping = draft_update
self.req_states.draft_tokens[draft_idx_mapping] = draft_tokens

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.

Could we have get_prev_sampled_outputs take self.req_states.draft_tokens as a draft_tokens_to_update input arg and then move this logic into that method?

Comment on lines +1723 to +1730
if self.aux_pp_relay_keys:
# The forward packs only local taps; pass the upstream ones on.
received = model_inputs["intermediate_tensors"]
assert output_intermediate_tensors is not None
output_intermediate_tensors = IntermediateTensors(
output_intermediate_tensors.tensors
| {k: received[k] for k in self.aux_pp_relay_keys}
)

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.

Can we move this logic into PPHandler, then aux_pp_relay_keys can be a field within that class instead. i.e. a method that takes intermediate_tensors and output_intermediate_tensors to update, returns new output_intermediate_tensors.

_aux_upstream_total_cached: int = 0

def _set_aux_hidden_state_layers(self, layers: tuple[int, ...]) -> None:
self.aux_hidden_state_layers = layers

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.

this must be ordered I think

Suggested change
self.aux_hidden_state_layers = layers
self.aux_hidden_state_layers = tuple(sorted(layers))

@njhill njhill changed the title [Core][MRV2] Support spec decode with pipeline parallel [Core][MRV2] Support eagle3 spec decode with pipeline parallel Aug 22, 2026
@njhill njhill mentioned this pull request Aug 22, 2026
22 tasks
yiminyuan added a commit to yiminyuan/vllm that referenced this pull request Aug 22, 2026
Three guards refused the drafter under PP -- the draft config inherited the
target's pipeline size, so a draft model without SupportsPP was rejected; the
V2 runner raised for eagle3/dflash/dspark because their aux taps may sit on an
earlier stage; and the DSpark loader aliases the target's embed_tokens, which
is a PPMissingLayer anywhere but the first stage. Past those, warmup deadlocked:
PPHandler.receive always allocates [num_reqs, max_sample_len] while the sampler
returns a single column when there are no drafts to verify, so broadcast sent a
narrower tensor, the ranks disagreed on the element count, and the stage that
had moved on wedged in its next device sync. Give the draft
pipeline_parallel_size=1 since it is built on the last stage only, let a model
declare that it carries its EAGLE3 aux taps across the handoff, build the
target's embedding on the last stage rather than draft with uninitialized
weights, pin one broadcast payload width on both sides and send the next step's
drafts in it through the filtered index mapping, and declare the AMD DSpark
head's draft_id_to_target_id. Decode TPOT 43.9 -> 35.2 ms and output throughput
20.0 -> 24.3 tok/s against the same runner without speculation, at 19.8% draft
acceptance.

Upstream adds pipeline-parallel support for these drafters in vllm-project#50514; drop this
commit when rebasing onto a tag that already contains it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/build cpu Related to CPU backends deepseek Related to DeepSeek models dflash DSv4 k3 kimi llama Related to Llama models mrv2 Model Runner V2 specific nvidia qwen Related to Qwen models ready ONLY add when PR is ready to merge/full CI is needed speculative-decoding

Projects

Status: No status
Status: Backlog

Development

Successfully merging this pull request may close these issues.

7 participants