Pipeline parallelism x speculative decoding (EAGLE/MTP) compatibility - #30775
AliceChenyy wants to merge 43 commits into
Conversation
Four related fixes to the pipeline-parallel proxy tensor plumbing that only manifest when PP is combined with speculative decoding (multi-token verify); plain decode (num_tokens_per_bs == 1) is bit-identical: 1. _allocate_decode_buffers / DecodeInputBuffers.create: hidden_states and residual proxy buffers were sized (max_bs, hidden) while every other token-axis buffer (input_ids, positions, and topk_indices in the same dict) uses max_num_token = max_bs * num_tokens_per_bs. Under TARGET_VERIFY the [:num_tokens] slice silently returned fewer rows, crashing rotary with mismatched query/positions during warmup. 2. DecodeCudaGraphRunner.load_batch: the pre-planned early-return path (taken when eagle_prepare_for_verify already ran load_batch) copied input_ids and positions but never refreshed the pp_proxy_tensors input buffers, so the last PP stage replayed verify graphs against stale hidden states. 3. DecodeCudaGraphRunner.execute: the PPProxyTensors output was sliced with [:self.bs] (request rows) instead of [:self.bs * self.num_tokens_per_bs] (token rows). With verify (3 tokens/request) only the first bs rows were forwarded downstream, corrupting every request after the first in a microbatch while single-request runs looked healthy. 4. cuda_graph_buffer_registry: the pp-proxy slot source indexed ppx.tensors[key] unconditionally; an entry can legitimately be absent (e.g. topk_indices when a DSA model runs a dense attention backend). Use .get() so the established None-skips-copy contract applies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bility Enables PP + spec-v2 (EAGLE/MTP, topk=1 chains) behind SGLANG_ALLOW_PP_SPEC=1. Default behavior is unchanged; the existing PP+spec assert stays in place unless the flag is set. Architecture (mirrors vLLM sgl-project#16568's drafter-on-last-stage): - The draft worker exists only on the last PP stage (needs final hidden states and lm_head). Non-last stages run the verify-shaped target chunk through the plain TpModelWorker, which is already PP-aware. - The last stage drafts at the TAIL of its iteration (verify -> draft_extend -> draft-for-next-round): round r's verify tokens must exist before stage 0 runs its half of round r, so drafting cannot stay at the head of the iteration as in the single-stage flow. - Spec state rides the existing last->first output-dict relay (which every stage already forwards): accept_lens, new_seq_lens, bonus token, and the tail-drafted chain for the next round. No new communication channel. - Chains are stored per-request (rid-keyed) and every stage rebuilds an identical EagleVerifyInput at compose time via build_tree_kernel_efficient with the topk=1 chain constants -- no draft model needed off the last stage, and the design is robust to microbatch recomposition (finish/retract/merge) between rounds. - Batch results are processed against a launch-time ScheduleBatch.copy() snapshot (PPBatchMetadata.fwd_batch); the live microbatch object can be merged/filtered in place before its relayed result arrives. seq_lens is scatter-updated per rid. - KV bookkeeping needs no new plumbing: kv_committed_len advances in the shared batch-result processor from the relayed CPU accept_lens, and the verify slots stay within eagle_prepare_for_decode's 2x reserve (same mechanism the overlap path relies on). Validated on 8x RTX PRO 6000 (SM120, PCIe), GLM-5.2-NVFP4, TP4xPP2 + EAGLE(2 steps / topk 1 / 3 draft tokens): - greedy decode identical to non-spec; GSM8K 20q 0.900 (= TP8 baseline) - concurrent/staggered workloads correct (relay verified token-exact) Known limitations (hence RFC): - topk=1 chains only; tree speculation needs rebuild-logic extensions - non-overlap schedule only (PP itself requires that today) - draft-model weight loading on the last stage must not apply PP filtering (the NextN/MTP embed_tokens is shared with the target model and lives in the first-stage partition; loading the draft with the target's pp_rank leaves its embedding randomly initialized and collapses acceptance length) -- follow-up in progress - depends on the PP proxy buffer fixes in the companion PR Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces support for Pipeline Parallelism combined with Speculative Decoding (PP+spec) in SGLang, ensuring draft models run only on the last PP stage and verify inputs are correctly rebuilt and relayed across stages. Feedback on the changes highlights several critical improvements: preventing a GPU memory leak and fragmentation by storing draft chains on the CPU, overriding PP parameters for the draft worker's ModelRunner to prevent embedding weights from being filtered out, avoiding potential KeyError and AttributeError crashes with safer attribute and dictionary accesses, and using get_embed_and_head() instead of fragile hardcoded attribute paths.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if chain_tokens is not None: | ||
| rows = chain_tokens.to(torch.int64).reshape( | ||
| len(batch.reqs), num_draft_tokens | ||
| ) | ||
| else: | ||
| rows = torch.zeros( | ||
| (len(batch.reqs), num_draft_tokens), | ||
| dtype=torch.int64, | ||
| device=bonus_tokens.device, | ||
| ) | ||
| rows[:, 0] = bonus_tokens.to(torch.int64) | ||
| for i, req in enumerate(batch.reqs): | ||
| if req.finished(): | ||
| self._pp_spec_chain_by_rid.pop(req.rid, None) | ||
| else: | ||
| self._pp_spec_chain_by_rid[req.rid] = rows[i] |
There was a problem hiding this comment.
Storing slice views of the rows tensor (i.e., rows[i]) in the persistent dictionary self._pp_spec_chain_by_rid keeps the entire rows tensor's underlying storage alive in GPU memory. Since requests finish at different times, this will cause a severe GPU memory leak and memory fragmentation over time.
Additionally, storing these very small 1D tensors (size 4-16) on the GPU introduces unnecessary CUDA allocation overhead and fragmentation. Moving the tensors to CPU and cloning the slice completely resolves both issues.
| if chain_tokens is not None: | |
| rows = chain_tokens.to(torch.int64).reshape( | |
| len(batch.reqs), num_draft_tokens | |
| ) | |
| else: | |
| rows = torch.zeros( | |
| (len(batch.reqs), num_draft_tokens), | |
| dtype=torch.int64, | |
| device=bonus_tokens.device, | |
| ) | |
| rows[:, 0] = bonus_tokens.to(torch.int64) | |
| for i, req in enumerate(batch.reqs): | |
| if req.finished(): | |
| self._pp_spec_chain_by_rid.pop(req.rid, None) | |
| else: | |
| self._pp_spec_chain_by_rid[req.rid] = rows[i] | |
| if chain_tokens is not None: | |
| rows = chain_tokens.to(device="cpu", dtype=torch.int64).reshape( | |
| len(batch.reqs), num_draft_tokens | |
| ) | |
| else: | |
| rows = torch.zeros( | |
| (len(batch.reqs), num_draft_tokens), | |
| dtype=torch.int64, | |
| device="cpu", | |
| ) | |
| rows[:, 0] = bonus_tokens.to(device="cpu", dtype=torch.int64) | |
| for i, req in enumerate(batch.reqs): | |
| if req.finished(): | |
| self._pp_spec_chain_by_rid.pop(req.rid, None) | |
| else: | |
| self._pp_spec_chain_by_rid[req.rid] = rows[i].clone() |
| if self.pp_size > 1: | ||
| assert ( | ||
| self.support_pp | ||
| ), "Pipeline Parallel is not compatible with this model." | ||
| import os as _os | ||
| if not (_os.environ.get("SGLANG_ALLOW_PP_SPEC") and self.is_draft_worker): | ||
| assert ( | ||
| self.support_pp | ||
| ), "Pipeline Parallel is not compatible with this model." |
There was a problem hiding this comment.
The draft worker's ModelRunner is initialized with pp_size > 1 and pp_rank = pp_size - 1. This causes the weight loader to filter out the draft model's embedding weights (thinking they belong to stage 0), leaving them randomly initialized.
Since the draft worker runs entirely locally on the last stage and does not participate in pipeline parallel partitioning, we can elegantly solve this "known gap" by overriding self.pp_size = 1 and self.pp_rank = 0 early in ModelRunner.__init__ when self.is_draft_worker is True. This completely bypasses PP filtering for the draft worker.
| if self.pp_size > 1: | |
| assert ( | |
| self.support_pp | |
| ), "Pipeline Parallel is not compatible with this model." | |
| import os as _os | |
| if not (_os.environ.get("SGLANG_ALLOW_PP_SPEC") and self.is_draft_worker): | |
| assert ( | |
| self.support_pp | |
| ), "Pipeline Parallel is not compatible with this model." | |
| import os as _os | |
| if getattr(self, "is_draft_worker", False) and _os.environ.get("SGLANG_ALLOW_PP_SPEC"): | |
| self.pp_size = 1 | |
| self.pp_rank = 0 | |
| if self.pp_size > 1: | |
| assert ( | |
| self.support_pp | |
| ), "Pipeline Parallel is not compatible with this model." |
| chain_rows = torch.stack( | ||
| [self._pp_spec_chain_by_rid[req.rid] for req in batch.reqs] | ||
| ).to(device=device, dtype=torch.int64) |
There was a problem hiding this comment.
If a request is missing from self._pp_spec_chain_by_rid (e.g., due to preemption, rescheduling, or other state inconsistencies), accessing self._pp_spec_chain_by_rid[req.rid] directly will raise a KeyError and crash the scheduler. Using .get() with a safe default row of zeros prevents this crash.
| chain_rows = torch.stack( | |
| [self._pp_spec_chain_by_rid[req.rid] for req in batch.reqs] | |
| ).to(device=device, dtype=torch.int64) | |
| default_row = torch.zeros(num_draft_tokens, dtype=torch.int64, device="cpu") | |
| chain_rows = torch.stack( | |
| [self._pp_spec_chain_by_rid.get(req.rid, default_row) for req in batch.reqs] | |
| ).to(device=device, dtype=torch.int64) |
| embed = self.draft_runner.model.model.embed_tokens.weight | ||
| head = self.target_worker.model_runner.model.lm_head.weight | ||
| self.draft_runner.model.set_embed_and_head(embed, head) |
There was a problem hiding this comment.
Hardcoding self.draft_runner.model.model.embed_tokens.weight is fragile and can break across different model architectures (e.g., those using different embedding attribute names). Using the standard get_embed_and_head() method is much more robust and maintainable.
| embed = self.draft_runner.model.model.embed_tokens.weight | |
| head = self.target_worker.model_runner.model.lm_head.weight | |
| self.draft_runner.model.set_embed_and_head(embed, head) | |
| embed, _ = self.draft_runner.model.get_embed_and_head() | |
| head = self.target_worker.model_runner.model.lm_head.weight | |
| self.draft_runner.model.set_embed_and_head(embed, head) |
|
Also need PP with speculative decoding. Willing to test if it is ready to merge. |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NextN/MTP draft layers carry no embedding of their own in the checkpoint, and under PP the target's embed_tokens lives on the first stage (PPMissingLayer on the last stage where the draft runs), so the draft embedding stayed randomly initialized and accept_length collapsed to ~1 (correct output, zero speculative gain). Load the target's embed_tokens from the checkpoint safetensors directly for the draft, routed through VocabParallelEmbedding.weight_loader for TP sharding. Skipped under --load-format dummy. Also fix a porting slip in _pp_launch_batch: self.cur_batch -> the cur_batch local (the attribute does not exist on Scheduler in the PP event loop). Validated on 8x RTX 6000D, GLM-5.2-NVFP4, TP4xPP2 + EAGLE(2s/topk1/3d): accept_length 2.71-2.91 during GSM8K and serving bench (TP8+MTP baseline on the same host: 2.60-3.00; before the fix: ~1.07), GSM8K 20q 0.900, bs=1 median TPOT 13.5ms vs 25.5ms without MTP. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Validated the PP+spec path on a second model family (DeepSeek-V4-Flash, DeepseekV4ForCausalLM) and fixed the two portability gaps it surfaced: - The draft embedding checkpoint tensor is looked up from a candidate list: DeepSeek-V4 checkpoints store it as embed.weight, not the GLM/DeepSeek-V3 style model.embed_tokens.weight. - The verify-input rebuild fallback bound now uses model_config.context_len instead of attn_backend.max_context_len, which only some backends expose (DeepseekV4AttnBackend does not; on FlashInfer backends the two values are identical). Validation on 4x RTX 6000D, DeepSeek-V4-Flash (FP8), TP2xPP2 + EAGLE(2s/topk1/3d): accept_length 2.44-2.72 sustained under GSM8K and serving load, GSM8K 20q 0.95-1.00, greedy output correct; bench at concurrency 1/4/8/16 completes end to end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes from an adversarial review pass over the branch diff: Ungated default-path fixes (gate off must be behavior-identical): - Non-overlap spec path passed pp_proxy_tensors= to every spec worker; only the V2 EAGLE worker accepts it (NGRAM/DFLASH/etc. would crash). Pass it only when pp_size > 1, mirroring the non-spec branch. - on_verify_complete_cpu: replace the hasattr guard with a no-op hook on BaseTpWorker (repo convention: no defensive hasattr). Gate hardening: - Register SGLANG_ENABLE_PP_SPEC as a typed EnvBool in environ.py (renamed from SGLANG_ALLOW_PP_SPEC per naming conventions) and use envs.*.get() at all sites — raw string truthiness treated =0/=false as enabled. Removes the scattered function-local os imports. - Assert PP+spec is incompatible with --speculative-adaptive: the relay slices results with the configured num_draft_tokens, which adaptive spec changes at runtime. - init_lm_head PP branch fails fast on --speculative-token-map and EAGLE3 drafts (their head wiring is skipped by this branch). - _load_checkpoint_tensor asserts the model path is a local directory (HF hub ids resolve into the loader's cache, not model_path). PP+spec path fixes: - _pp_spec_chain_by_rid leaked one GPU chain row per completed request (stored before finish flags are set, and finished reqs never re-enter a batch). Initialize it in init_pp_loop_state and drop finished rids after process_batch_result. - Draft-worker seed sync: broadcast within the stage's TP group (exactly the ranks holding a draft worker) instead of adopting the node-local server_args seed, which diverges across nodes when --random-seed is unset. The caller's global rank comes from tp_group.ranks (the draft worker is constructed with pp_rank=0). - Deduplicate the draft-local memory-profiling predicate into ModelRunner.pp_spec_draft_local computed once in __init__. Validated on 8x RTX 6000D: GLM-5.2 TP4xPP2+MTP and DSv4-Flash TP2xPP2+MTP dummy smokes pass with the gate on; DSv4 TP4+MTP with --disable-overlap-schedule passes with the gate off (the ungated regression case); GLM-5.2 TP4xPP2+MTP real weights: GSM8K 20q 0.900, accept_length 2.67-2.80 (unchanged from before this commit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Conflicts resolved: - decode_cuda_graph_runner.py: kept the PP-proxy input-buffer refresh, adopted upstream's is_dflash_family()/is_ragged condition (sgl-project#30261), and renamed num_tokens_per_bs -> num_tokens_per_req in the PP proxy output slice (sgl-project#30977). - eagle_worker_v2.py: dropped the local _get_plan_stream (extracted to a shared get_plan_stream upstream, sgl-project#31008/sgl-project#30857) and re-applied the pp_proxy_tensors parameter on the deduplicated forward_batch_generation signature. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Zero-semantic cleanups from the file-by-file pass: give the gated non-overlap assert a message, make the fwd_batch fallback an explicit None check, and drop blank lines left behind by the os-import removal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # python/sglang/srt/managers/scheduler_pp_mixin.py # python/sglang/srt/managers/tp_worker.py # python/sglang/srt/model_executor/model_runner.py # python/sglang/srt/model_executor/model_runner_kv_cache_mixin.py # python/sglang/srt/speculative/eagle_worker_v2.py
rows[i] kept the whole rows storage alive per entry, and when chain_tokens is already int64 the .to() is a no-op view of the relay buffer -- a later relay reusing that buffer would corrupt stored chains (the row root is force-accepted by verify). Addresses the gemini-code-assist finding on _pp_spec_store_bonus. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # python/sglang/srt/arg_groups/validation_hook.py # python/sglang/srt/model_executor/model_runner.py
The embedding read hardcoded model.model.embed_tokens, which misses draft families that hang the embedding elsewhere (Bailing MTP word_embeddings, Mistral consolidated tok_embeddings). Scan for the one VocabParallelEmbedding that is not the ParallelLMHead, extend the checkpoint-name table with both spellings (verified against the published Ling-2.0 and Mistral-Large-3 indexes), and pin the scan on real Bailing/Mistral draft classes in a unit test. Validated on 8x RTX PRO 6000: GLM-5.2 TP4xPP2+MTP GSM8K-100 0.950 / accept 2.78-2.84; DeepSeek-V4-Flash TP2xPP2+MTP 0.960 / accept 2.55-2.70. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # python/sglang/srt/speculative/spec_info.py
# Conflicts: # python/sglang/srt/speculative/eagle_worker_v2.py
fa350b9 to
f5c1ad0
Compare
Motivation
PP and speculative decoding are currently mutually exclusive (
server_args.pyasserts). On PCIe-only multi-GPU boxes (no NVLink), TP-only scaling is bottlenecked by per-layer all-reduces, and PP is the right scaling axis. Combining the two requires PP x spec compatibility, which vLLM has (vllm-project/vllm#16568) and SGLang doesn't.This PR implements it behind
SGLANG_ENABLE_PP_SPEC=1; default behavior is unchanged.Dependencies: none outstanding. #26928 (SM120 GLM DSA) merged on 2026-07-28 and is in main. The former companion PRs #30774 (proxy buffer fixes) and #30994 (dense MLA kwargs) are closed — #30774's fixes are folded into this diff, #30994 is no longer needed on the sparse path.
Design
Key observation: non-spec PP decode already relies on a last->first output-dict relay (next sampled token becomes the next round's input, and every stage forwards the dict). Spec decoding is structurally identical — the relay payload just grows to
{accept_lens, new_seq_lens, bonus_token, next draft tree}.SpecInput:PPSpecRelayInputcarries the proposed tokens plus the topology they were arranged by (parent_list/top_scores_index), rides onScheduleBatch.spec_info, and implements thefilter_batch/merge_batchhooks, so the existing recomposition path carries it — a request that finishes, is retracted, or merges in from a just-finished prefill takes its row along. Every stage rebuilds an identicalEagleVerifyInputfrom it viabuild_tree_kernel_efficient, so no draft model is needed off the last stage. The type is deliberately the algorithm-agnostic half: another speculative algorithm supplies only its own rebuild.kv_committed_lenadvances in the shared result processor from the relayed CPUaccept_lens; verify slots stay withineagle_prepare_for_decode's 2x reserve (same mechanism the overlap path uses).A subtle but load-bearing detail: non-last stages must run
eagle_prepare_for_verifyinsidescheduler._forward_isolation— it mutatesScheduleBatch.forward_modeto TARGET_VERIFY, and without the restore the nextget_next_batch_to_runtreats the decode batch as extend and re-merges it (duplicate requests).Two hazards that only appear once the relay carries several GPU tensors, both fixed here:
pp_rankparity when the spec relay is active; non-spec PP keeps the existing behavior. Credit to [PP&Spec] enable speculative decoding (eagle_worker_v2) under PP #31139 for finding this.next_token_idsis a placeholder, and storing it seeded the next verify round with a token the model never emitted.Validation
4u8g-gen-0210, 8x RTX PRO 6000 Blackwell Server (SM120, PCIe, no NVLink), driver 595.58.03, imagelmsysorg/sglang:nightly-dev-cu13-20260826-41e7612d, torch 2.13.0+cu130 / sgl-kernel 0.4.6.post1 / flashinfer 0.6.17 / sgl-deep-gemm 0.1.5.post3. GLM-5.2-NVFP4, FP8 KV, EAGLE (2 steps / topk 1 / 3 draft tokens). Plain NCCL all-reduce on both configs.GSM8K 200 questions, 8-shot, greedy, two runs per config — see the caveat below on why one run is not enough.
Accuracy and accept length match; PP wins TTFT (2.0x at bs8, 2.7x at bs32), throughput (+42% / +81%) and KV capacity (1.61x).
Correction to the earlier revision of this description. It claimed PP beating TP8 by 3-6x across TTFT/TPOT/throughput. That was measured in July; re-running the same benchmark on today's main, TP8+MTP is now 13% faster than PP at bs1 TPOT (8.73 vs 9.87 ms) — TP8's bs1 TPOT improved 1.85x and its bs32 throughput 2.6x over that period, while the PP numbers barely moved. The cause looks structural: PP+spec forces
--disable-overlap-schedule, and the overlap path is where much of that improvement landed, so PP cannot pick it up. Making PP+spec work under the overlap scheduler is the obvious follow-up and probably matters more than anything else in this PR. PP's remaining advantages — TTFT, throughput at batch, KV capacity — all grow with concurrency.Measurement caveat. GSM8K on this stack has +-2-3% run-to-run variance: the same build gave 0.940 and 0.965 on consecutive 200-question runs, because batch composition follows arrival timing and the reductions follow batch composition. Greedy output is not token-reproducible here either. So single runs cannot resolve differences of this size, and the accuracy claim above is "no detectable regression", not bit-identical behavior.
Also validated on DeepSeek-V4-Flash FP8, TP2xPP2 (4x RTX 6000D): GSM8K 0.95-1.00, accept_length 2.44-2.72 — the PP+spec layer carried over with only two portability fixes (checkpoint embed tensor name, backend-agnostic context-len bound).
Tests
test/registered/pp/test_pp_spec.py:TestPPSpecConsistencyruns GSM8K under plain spec and under PP2+spec and requires the PP run to match on both accuracy and accept length. Accuracy alone cannot distinguish a working relay from a broken one: if the relayed tree is lost, the bonus token is still force-accepted and the rest rejected, so the output stays correct and only acceptance collapses. Both a topk=1 chain and a topk=2 tree are covered.TestPPSpecGatepins that the gate is off by default (the existing PP+spec ban still fires) and that the combinations the relay cannot reproduce identically on every stage are rejected. It resolves argument state only, so it stays off the GPU.Known gaps
--speculative-adaptive),--speculative-token-map, and EAGLE3 drafts are rejected at startup (the relay/head-wiring paths don't cover them yet).topk>1withpage_size>1, and DSA forcespage_size=64.Relationship to the other PP x spec PRs
#31139 (PP + EAGLE) reached essentially the same design independently — draft on the last stage, tail-draft, relay the raw tree, rebuild per stage, proxy buffers sized by token count. That convergence is probably the most useful signal here: the shape of the solution is not in dispute. Differences worth a maintainer's attention:
load_weights, which is the more idiomatic mechanism but carries per-model changes.Whichever base the maintainers pick, happy to contribute the pieces that are specific to this one — the model-agnostic embed loading, the ring-ordering fix, the tests, and the SM120 / PCIe validation.
🤖 Generated with Claude Code
CI States
Latest PR Test (Base): ⏳ Run #35013704529
Latest PR Test (Extra): 🚫 Run #35013704124
Latest PR Test (AMD ROCm 10): ❌ Run #35013704196