Skip to content

[Fix] Keep residual rows and sums consistent across appends and pipeline handoffs - #43454

Merged
ch-wan merged 6 commits into
mainfrom
cheng/hot-fix/stage-boundary-rows-and-pp-handoff
Oct 10, 2026
Merged

ch-wan merged 6 commits into
mainfrom
cheng/hot-fix/stage-boundary-rows-and-pp-handoff

Conversation

@ch-wan

@ch-wan ch-wan commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

This PR is part of a stack (oldest at bottom):

Motivation

Five defects in how a stage boundary places the residual and completes sums, each reachable on main:

  1. After an append that ends on an attention which always leaves its sum, the next append rebuilt that attention's incoming boundary from a declaration without its history, took the attention as the start of the stack, and sliced a residual that was already on this rank's attention-TP slice a second time. ZAYA1's one-stage appends hit this with an a2a MoE and TP > 1: a size mismatch, or on a small batch wrong values without an error.
  2. A stack (or a pipeline rank) ending on such attentions after an FFN that runs on its own rows (a2a MoE, or a dense FFN fully data-parallel) left the residual on the slice, while the next rank and the final norm expect the attention's rows.
  3. DeepSeek on a later pipeline rank read the batch size and device off the owed-sum handle it received on an input-scattered batch: AttributeError on every later rank.
  4. The FFN before a pipeline handoff resolved the rows its residual is on while it runs with two different rules, one for its entry and one for its exit. With input-scattered attention the handoff sent the attention-TP slice where the next rank expects every row; with attention DP and a dense FFN fully data-parallel, binding failed with NotImplementedError when a rank's last FFN followed such a dense layer.
  5. to_pp() sent a declared partial sum unreduced for the next rank to complete. The pipeline sends one slice per attention-TP rank and gathers the slices on the receiver, so the receiver reassembled a mixture of different ranks' partials. Nemotron-H cut between a mixer and the FFN after it, and DeepSeek with input-scattered attention and a reduce-scatter boundary reduction, produced wrong outputs under PP with attention TP > 1; with an all-reduce boundary reduction, input-scattered attention summed the received output a second time.

Modifications

  • Bind an append after an attention with the residual rows it ran on. The stack keeps the boundary each pending stage is bound with and hands it to the stage after it. The attention-after-attention step that keeps the residual on the slice reduce-scatters over the full TP group, so binding rejects it under attention DP instead of summing across ranks that hold other sequences.
  • Return the residual on the attention's rows before a stack's last attention. That FFN returns the residual on the attention's rows, as an FFN does when it ends the stack.
  • Read a DeepSeek pipeline rank's batch from positions, not hidden states.
  • Keep an FFN's residual on one set of rows across a pipeline handoff. The entry rewrite is removed; entry and exit use the FFN's own resolution.
  • Complete every sum an output owes before a pipeline handoff. to_pp() completes every sum before transport and from_pp() declares none; the receiving stage takes the path it already binds for an input whose sum is complete.
  • Test that a pipeline cut keeps a stack's rows and values. For stacks of two to four stages (an attention that always leaves its sum, a mixer whose exit may leave its sum to an FFN, a dense FFN, a sparse FFN), cut at every stage boundary under four parallel configurations and two boundary reductions (6,384 cuts): within each rank both sides of every boundary agree on the residual's rows, the handoff carries the residual on the attention's rows, the stages away from the cut bind as they do uncut, and a cut rejects only what the stack rejects uncut. A second test runs the sender, to_pp(), a transport that sends one slice per attention-TP rank and gathers them back, from_pp() and the receiver on simulated attention-TP ranks, and checks the values against the uncut run, that every owed sum completes once and that the receiver sums nothing.

Accuracy Tests

How the stack is checked:

  • Unit tests: the 39 layer-boundary test files pass at the head of every PR in the stack, apart from the two AMD-only test_aiter_allreduce_fusion_amd cases that fail on NVIDIA on main too; the test files that changed most recently pass on every commit. The full test/registered/unit suite (926 files, one process per file) shows no failure on the stack's head that main does not have.
  • Plan comparison: an out-of-tree harness binds every stage plan for seven stack shapes (decoder layers with and without an output transform, MHC, Nemotron-H patterns, Kimi K3, Qwen4-exp, NemotronNAS-style attention-only layers, single-stage appends) over 23 parallel configurations, four boundary reductions and one or two pipeline ranks (3,928 scenarios), and compares each bound plan leaf by leaf with the parent commit's.
  • GPU: dummy weights are seeded per TP rank and scaled (0.05, norms 1.0), so that a sum taken twice or a mixture of rank partials changes the output; the default dummy weights give every rank the same shard and would hide both.
  • GPU results below were measured on these commits as based on main 516fd1c1b5 unless marked as on the merge candidate; rebasing onto current main had no conflicts, and the CPU checks were run again after it.
  • On the merge candidate (head of this stack against main 961bcf481d, H200, asymmetric dummy weights unless noted): DeepSeek-V2-Lite input-scattered PP2 is bit-identical to PP1 (main crashes on it); Nemotron-H cut between a mixer and its FFN differs from PP1 only at the rounding level of the FFN-to-mixer cuts (main: 0.93 and 2.21); Llama, Qwen3-MoE, GPT-OSS and DeepSeek-V2-Lite are bit-identical to main at PP1 and PP2; GSM8K (real DeepSeek-V2-Lite weights, 250 questions, TP4 with attention DP2 and the dense FFN fully data-parallel) gives 0.360 at PP2, 0.364 at PP1 and 0.368 on main at PP1, medians of three runs whose run-to-run spread is 0.012–0.016. The last revision of the stack changed only comments, docstrings, an equivalent condition and explicitly set attributes after these runs; its bound plans are identical.

For this PR:

  • Each fix has a regression test that fails on its parent commit.
  • (1) and (2): running ZAYA1-shaped stacks (attention, a2a MoE, attention, FFN; and attention, FFN, attention with a final norm) on simulated attention-TP ranks with fake collectives, the parent fails with a size mismatch or gives wrong values on small batches; this PR matches a per-token reference exactly.
  • (3)-(5) on H200, PP2 against PP1 of the same tree (DeepSeek on this PR's tree; the other two on the stack's head, whose PP path is this PR's):
    • DeepSeek-V2-Lite (synthetic 4 layers, q_lora), TP2, input-scattered attention, rs with 2+2 and 1+3 layer splits and ar with 2+2: PP2 is bit-identical to PP1 (before: crash at the receiver, then wrong outputs, up to 2.4 in prompt logprob).
    • Nemotron-H (6 layers), TP2, cut between a mixer and its FFN: the PP2 difference drops from 0.93 / 2.21 (prefill logprob, first token diverging) to 0.011 / 0.016, the level of the FFN-to-mixer cut that never carried a partial.
    • TP4, attention DP2, dense FFN fully data-parallel, PP2: the 2+2 split is bit-identical to PP1. The 1+3 split differs by 0.098 because the dense FFN's output is written into the residual before the handoff (bf16 add, then norm on the next rank) instead of a fused add+norm; with the fused kernel replaced by add-then-norm in both runs, PP2 and PP1 are bit-identical. Main shows the same 0.098 on the 1+3 split: its PP2 output is bit-identical to this PR's (the 2+2 split fails to construct on main).
  • The plan comparison differs from the parent only in the configurations each fix names.

Speed Tests and Profiling

The sum a PP rank used to leave to the next rank now completes before the handoff (one all-reduce or reduce-scatter over attention TP on the sending rank instead of one on the receiving rank). No other path changes.

Measured on the merge candidate against main (H200, dummy weights, decode with CUDA graph, three runs per tree, a difference counted only if it exceeds the same-tree spread): Qwen3-8B at TP1 and TP2, DeepSeek-V2-Lite at TP2, and DeepSeek-V2-Lite at TP4 with attention DP2, the dense FFN fully data-parallel and PP2 (1+26 layers) show no difference in prefill throughput (4k tokens, batch 1 and 8), decode throughput (batch 1, 16, 64), peak allocated / reserved memory per rank, or steady-state memory. DeepSeek-V2-Lite with input-scattered attention at TP2 PP2 runs only on this stack (main crashes), so it has no baseline.

Checklist


CI States

Latest PR Test (Base): 🚫 Run #38080746857
Latest PR Test (Extra): 🚫 Run #38080746610
Latest PR Test (AMD ROCm 10): ❌ Run #38080746826

@mintlify

mintlify Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
sglang-doc 🟢 Ready View Preview Oct 10, 2026, 7:43 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

When an append ends on an attention that always leaves its sum, the next
append's first stage rebuilt the attention's incoming boundary from a
declaration whose history had been dropped, so it took the attention as
the start of the stack and its residual as on full rows. After an FFN
that returns the residual on this rank's attention-TP slice (an a2a MoE,
or a dense FFN fully data-parallel), that consumer sliced the residual a
second time: a size mismatch, or, on a small batch, wrong values without
an error. ZAYA1's one-stage appends hit this with an a2a MoE backend and
TP > 1.

The stack now keeps the boundary each pending stage is bound with and
hands it to the stage after it.

An attention that follows such an attention now keeps the residual on
the slice, as it does on an input-scattered batch. The step for that
reduce-scatters over the full TP group, which under attention DP spans
ranks holding other sequences, so binding rejects it there instead of
summing across them.
…ention

An attention that always leaves its sum has no exit, so when a stack
ends on such attentions, the next pipeline rank or the final read takes
the residual on the rows they ran on: where the FFN before them returned
it. An FFN on its own rows (an a2a MoE, or a dense FFN fully
data-parallel) returns it on this rank's attention-TP slice, while the
next rank rebuilds it on the attention's rows and the final norm pairs it
with the attention's full output.

That FFN now returns the residual on the attention's rows, as an FFN does
when it ends the stack or hands off itself.
On an input-scattered batch, the stage boundary hands a later pipeline
rank the attention's partial sum as an owed handle, which the first
stage completes. The model read the batch size and device off that
handle before the first stage ran, so every later rank failed with
AttributeError. It now reads both from positions.
The FFN before a pipeline handoff must exit on the attention's rows. For
such an FFN, its incoming boundary rewrote the rows the residual is on
while it runs, while its exit was bound from the FFN's own resolution of
them, and the two disagreed:

- on an input-scattered batch, the entry kept the residual on this
  rank's attention-TP slice while the exit started from every row, so
  the handoff sent the slice where the next rank expects every row;
- under attention DP with a dense FFN fully data-parallel, when the
  rank's last FFN followed such a dense layer, binding failed with
  NotImplementedError.

The rewrite is gone: the entry uses the FFN's own resolution, as its exit
does, and as the same FFN does when it is not the last before a handoff.
to_pp() sent a statically declared partial sum unreduced, for the next
rank's first stage to complete. But the pipeline sends each tensor as one
slice per attention-TP rank and all-gathers the slices on the receiving
rank, which reassembles only a tensor every attention-TP rank holds
alike: a partial sum arrived as rank 0's slice of its partial next to
rank 1's slice of its own, and the receiver summed that mixture again.
NemotronH split between a Mamba or attention layer and the MLP or MoE
after it, and DeepSeek with input-scattered attention and a
reduce-scatter boundary reduction, produced wrong outputs under
pipeline parallelism with attention TP > 1. With an all-reduce boundary
reduction, input-scattered attention summed the received output a second
time: the sender had completed it at its exit, and the receiver still
declared it.

to_pp() now completes every sum before transport, and from_pp()
declares none: the receiving stage's entry takes the path for a sum
already completed, which it binds next to the one for a sum its producer
on the same rank leaves. The declared partial no longer crosses ranks.
Cut a layer stack between two pipeline ranks and check what each rank
binds and what the ranks compute.

Binding: stacks of two to four stages (an attention that always leaves
its sum, a mixer whose exit may leave its sum to an FFN, a dense FFN and
a sparse one) are cut at every stage boundary under attention TP2, TP2
with an a2a MoE, DP2 x TP2 with the dense FFN fully data-parallel and
TP4 with input-scattered attention, with all-reduce and reduce-scatter
boundary reductions. Each rank binds its stages with the other rank's
stage next to them as their neighbour. Within each rank, each side of a
boundary takes the residual on the rows the other side leaves it on;
the sender hands the residual on, and the receiver takes it, on the
attention's rows; the stages before the sender's last FFN and after the
receiver's first bind as they do uncut; and a cut rejects only what the
stack rejects uncut. Under input-scattered attention a rank's stages
end on an FFN, as only models whose layers end with one run it.

Values: simulated attention-TP ranks run the sender's stages, to_pp(),
a transport that sends each tensor as one slice per attention-TP rank
and gathers the slices back, from_pp() and the receiver's stages: for a
mixer whose sum is left to the FFN after it, an input-scattered FFN
with a reduce-scatter or an all-reduce boundary reduction, and an a2a
MoE that writes its output into the residual at the handoff. Every
rank's output and residual match the uncut run and a closed-form
reference, the sender completes what its output owes once, and the
receiver sums nothing it receives. A partial sum sent unsummed reaches
the receiver as a mixture of the ranks' partials, and a complete one
summed again is multiplied by the group size; both change the values.
@ch-wan
ch-wan force-pushed the cheng/hot-fix/stage-boundary-rows-and-pp-handoff branch from 56f5dac to c726421 Compare October 10, 2026 19:40
@ch-wan
ch-wan merged commit 514feb1 into main Oct 10, 2026
24 of 28 checks passed
@ch-wan
ch-wan deleted the cheng/hot-fix/stage-boundary-rows-and-pp-handoff branch October 10, 2026 19:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deepseek documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant