Skip to content

Do not force sum padding for extend in batch scenario - #32889

Closed
fzyzcjy wants to merge 12 commits into
mainfrom
tom/revert-pr10414
Closed

fzyzcjy wants to merge 12 commits into
mainfrom
tom/revert-pr10414

Conversation

@fzyzcjy

@fzyzcjy fzyzcjy commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

CI States

Latest PR Test (Base): 🚫 Run #30590369825
Latest PR Test (Extra): 🚫 Run #30590369703

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@fzyzcjy

fzyzcjy commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

/tag-and-rerun-ci

@fzyzcjy fzyzcjy changed the title Revert #10414 Do not force sum padding for extend in batch scenario Jul 30, 2026
@fzyzcjy

fzyzcjy commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Posted autonomously by Claude Code acting on the user's behalf. Triaged the base-b CUDA failures on this PR by pulling the job logs and reading the fast-fail root-cause markers. Please push back if any conclusion is off.

Classification: CI infra (crates.io download failure), not a code failure.

Root cause job: base-b-test-1-gpu-small (1) — the dependency install step failed while building sglang-grpc:

error: failed to get `portable-atomic` as a dependency of package `pyo3 v0.29.0`
Caused by: unable to update registry `crates-io`
Caused by: download of po/rt/portable-atomic failed
Caused by: curl failed
Caused by: [16] Error in the HTTP2 framing layer
error: `cargo metadata --manifest-path ../rust/sglang-grpc/Cargo.toml --format-version 1` failed with code 101
##[error]Process completed with exit code 1.

That failure tripped wait-for-base-b (##[error]base-b jobs failed: base-b-test-1-gpu-small / base-b-test-1-gpu-small (1)), and every other red base-b job is a fast-fail cascade, e.g. base-b-test-1-gpu-large (3):

##[error]Fast-fail: skipping — root cause job(s): wait-for-base-b, base-b-test-1-gpu-small / base-b-test-1-gpu-small (1)

No test actually ran and failed. Next step: wait for the current run to complete, then /rerun-failed-ci.

Also note the pr-test-extra-finish / pr-test-amd-extra-finish reds are just the missing run-ci-extra label gate, and AMD/NPU lanes are out of scope. Full status: https://github.com/sgl-project/sglang/pull/32889/checks

@fzyzcjy

fzyzcjy commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

/rerun-failed-ci

@fzyzcjy

fzyzcjy commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Posted by an AI coding agent (Claude Code) on behalf of @fzyzcjy.

CI triage for run 30529599065 (head 4a3af7620) plus the two leftover reds from the previous run 30517904898.

Not related to this PR

Check Why
call-gate / pr-gate (in PR Test Extra and PR Test Extra (AMD)) Gate step prints Missing required label 'run-ci-extra'. Add the label (e.g. via /tag-and-rerun-ci extra) to opt this PR into the extra test workflow. — opt-in label is absent by design. job
pr-test-extra-finish, pr-test-amd-extra-finish Downstream aggregates of the gate above; no actual test job failed in either run.
build-test Belongs to the PR Test (Arm64) workflow (run), not a CUDA lane.
base-c-test-4-gpu-b200 (0) (previous run) Check-run annotation: Fast-fail: skipping — root cause job(s): base-c-test-deepep-4-gpu-h100 / base-c-test-deepep-4-gpu-h100 (0) — cascade victim.

Pre-existing breakage on main, not caused by this PR

base-b-test-1-gpu-large (2)test/registered/moe/test_torch_compile_moe.py::TestTorchCompileMoe fails during decode CUDA-graph capture with:

File "python/sglang/srt/layers/moe/fused_moe_native.py", line 26, in fused_moe_forward_native
    x, x_scale, topk_output = dispatch_output
ValueError: too many values to unpack (expected 3)

StandardDispatchOutput gained a fourth field (hidden_states_pre_quant) in python/sglang/srt/layers/moe/token_dispatcher/standard.py, but fused_moe_forward_native still unpacks three. Both the 4-field NamedTuple and the 3-way unpack are present on current main — the server in this job runs with dp_size=1, so DP attention (the only thing this PR touches) is not even active, and the crash is in the decode path rather than prefill.

Under investigation, plausibly related

base-c-test-deepep-4-gpu-h100 (0)test/registered/ep/test_mooncake_ep_small.py::TestPureDP (sibling test_deepep_small.py passed in the same job). Server launched with --enable-dp-attention --dp 4 --tp 4; every scheduler rank crashes in the extend path:

File "python/sglang/srt/model_executor/runner/eager_runner.py", line 205, in execute
    return self._execute_extend(forward_batch, pp_proxy_tensors)
...
File "python/sglang/srt/layers/logits_processor.py", line 526, in _get_pruned_states
    pruned_states = hidden_states[last_index]
IndexError: index is out of bounds for dimension with size 0

This is DP attention + prefill + dp_size > 1, i.e. exactly the path this PR changes, so it is being treated as a candidate real regression rather than a flake. The same lane is re-running on the current head and the result will be reported here.

fzyzcjy added 4 commits July 30, 2026 19:25
An idle DP rank reports global_num_tokens == [0], so max_len and sum_len
are both zero and the communication-cost heuristic evaluates 0 >= 0 and
returns MAX_LEN. Communication cost is identical either way for an empty
batch, but MAX_LEN additionally sends the rank through the idle -> extend
fabricated-row conversion in ForwardBatch, which builds a dummy request
with extend_seq_lens == [0]. LogitsProcessor then computes
last_index = cumsum([0]) - 1 = [-1] and indexes a zero-row hidden_states:

    File "python/sglang/srt/layers/logits_processor.py", line 526
        pruned_states = hidden_states[last_index]
    IndexError: index is out of bounds for dimension with size 0

Reproduced on 4xH200 with
test/registered/ep/test_mooncake_ep_small.py::TestPureDP
(--tp 4 --dp 4 --enable-dp-attention --elastic-ep-backend mooncake):
every non-zero rank crashed during server warm-up. With this change the
same test passes (2 passed, 1 skipped).

The early return is placed after the max_len_with_idle branch so the
hybrid-SSM MAX_LEN path is unaffected.
Hybrid-SSM models reach the fabricated-row idle conversion in ForwardBatch
unconditionally, and that conversion asserts the rank is empty. A
non-empty extend batch that selects MAX_LEN therefore fails with
"extend-idle conversion expects an empty rank" during server warm-up
(observed on Qwen3-Next-80B-A3B-Instruct-FP8 with --tp 8 --dp 8).

Restrict them to the pre-existing behaviour instead of blocking the
heuristic for every model: MAX_LEN when a rank is idle (the path the
conversion is written for), SUM_LEN otherwise. Everything else now uses
the communication-cost heuristic. A TODO records what has to be fixed
before hybrid-SSM can join.
@fzyzcjy

fzyzcjy commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

/tag-and-rerun-ci extra

@fzyzcjy

fzyzcjy commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

/tag-and-rerun-ci extra

fzyzcjy added 2 commits July 30, 2026 23:01
The carve-out gated on dp.max_len_with_idle, which is only set when
hf_config exposes hybrid_override_pattern. ForwardBatch decides whether to
run the fabricated-row conversion with mambaish_config() instead, and the
two predicates disagree: Qwen3-Next, Qwen3.5, Kimi-Linear, LFM2 and the
other class-based families are mambaish but have no
hybrid_override_pattern, so they fell through to the communication-cost
heuristic, picked MAX_LEN for a mixed decode/extend global batch and hit

    File "python/sglang/srt/model_executor/forward_batch_info.py", line 1331
        self.seq_lens.shape[0] == 0
    AssertionError: extend-idle conversion expects an empty rank

on the ranks that were decoding while another rank prefilled.

Materialize a dp.hybrid_ssm flag from the same predicate ForwardBatch uses
(as a superset of max_len_with_idle, so no hybrid model can lose its
previous mode) and gate on that. Inside the branch the mode choice is
unchanged, so every hybrid-SSM family keeps exactly its mainline
behaviour; only non-hybrid models get the heuristic.
MAX_LEN pads every DP rank up to the global max token count, and those pad
rows are only handled on the paths explicitly written for them: the
idle-rank fabricated-row conversion in ForwardBatch, mask_dp_pad_moe_topk_ids
for MoE topk, and num_token_non_padded (which is None unless
moe_ep_size > 1). Where none of those apply, the pad rows run the model and
their outputs are not discarded.

GLM-5.2-FP8 with --tp 8 --dp 8 --enable-dp-attention plus HiSparse scored
0.656 on gsm8k against a 0.94 threshold once extend batches were allowed to
select MAX_LEN. This is the accuracy failure #10414 originally fixed; the
earlier padding-mode experiments missed it because they never covered this
runner/offload combination.

Require min(global_num_tokens) == max(global_num_tokens) for extend
batches instead. MAX_LEN then rewrites global_num_tokens to values it
already had, so not a single pad row is materialized and the mode is a pure
choice of collective (all_gather + reduce_scatter over symmetric memory
instead of all_reduce) -- which is exactly the uniform-prefill case the
16% regression was measured on. Skewed batches keep SUM_LEN.
Cover the three cases that broke while reverting #10414: an all-zero batch,
a skewed extend batch (idle rank or not), and the hybrid-SSM families whose
fabricated-row conversion only accepts an empty rank. Also pin the two
behaviours the revert is meant to deliver: a uniform extend batch selects
MAX_LEN, and decode batches keep the communication-cost heuristic.
@fzyzcjy

fzyzcjy commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Posted by an AI coding agent (Claude Code) on behalf of @fzyzcjy.

CI status on 290ee4fa32. All CUDA lanes have completed; four are red and none of them is caused by this PR.

The two failures this PR did cause are fixed, and CI confirms it

Opting into the extra workflow surfaced two real regressions from the first version of this branch:

Failure Where
AssertionError: extend-idle conversion expects an empty rank job 90896956839
gsm8k 0.656 not greater than or equal to 0.94 job 90896956826

Both come from the same mechanism: MAX_LEN pads every DP rank up to the global max, and those pad rows are only handled on the paths written for them (the idle-rank fabricated-row conversion in ForwardBatch, mask_dp_pad_moe_topk_ids, and num_token_non_padded, which is None unless moe_ep_size > 1).

The fix restricts extend batches to min(global_num_tokens) == max(global_num_tokens). MAX_LEN then rewrites global_num_tokens to the values it already had, so no pad row is materialized and the mode becomes a pure choice of collective — which is exactly the uniform-prefill case the throughput regression was measured on. Skewed batches keep SUM_LEN. Separately, the hybrid-SSM carve-out now uses the same mambaish_config() predicate ForwardBatch uses, instead of the narrower hybrid_override_pattern check.

On this head every extra-b lane is green, including extra-b-test-8-gpu-h200 and extra-b-test-4-gpu-b200, which is where those two failures lived.

test/registered/unit/layers/test_dp_padding_mode.py pins the behaviour; the regression cases were verified to fail against the pre-fix revisions of dp_attention.py and pass on this one.

The four remaining CUDA failures

Check Cause
base-b-test-1-gpu-large (2) Pre-existing on main: StandardDispatchOutput has four fields since hidden_states_pre_quant was added, but fused_moe_native.py:26 still unpacks three — ValueError: too many values to unpack (expected 3). The server in this job runs dp_size=1, so DP attention is not active.
base-b-test-1-gpu-large (7) Pre-existing on main: test_dsa_indexer.py builds a fake ServerArgs from a hardcoded attribute list, and dsa_backend.py:553 reads enable_two_batch_overlap, which the fake lacks. The read was added by #31888 (e4a40a71f8) without updating the fake; main's copy of the test is missing the same attribute.
base-c-test-8-gpu-h200 (0) Infra: unable to update registry crates-iocurl failed[16] Error in the HTTP2 framing layercargo metadata --manifest-path ../rust/sglang-grpc/Cargo.toml --format-version 1 failed with code 101. Re-run requested.
extra-a-test-2-gpu-large (1) Pre-existing on main: multimodal/processors/pixtral.py:118 does old_feature[i : i + 1] on a CudaIpcTensorTransportProxy, which is not subscriptable, so MMMU requests return 500 and the score lands at 0.4444 against a 0.45 threshold. The line is byte-identical on main and the proxy comes from #30904 / #31227. This job runs --tp-size 2 with no DP attention.

The non-CUDA reds (xpu, npu, amd, arm64 build-test) and the aggregate finish jobs follow from the above.

fzyzcjy added 2 commits July 31, 2026 07:15
# Conflicts:
#	python/sglang/srt/layers/dp_attention.py
The previous restriction required min(global_num_tokens) ==
max(global_num_tokens) for extend batches. Real prefill batches are
almost never exactly uniform, so that condition selected SUM_LEN nearly
always and gave back the throughput this branch is meant to recover.

Measurements on Qwen3-8B / 8xH200 separate the two cases: padding an
already-active rank up to the global max costs ~2% of extra rows, while
padding an idle rank inflates every rank's gathered buffer to
max_len * dp_size and multiplies prefill GEMM work by 6.5x. The idle
rank is also the only case that reaches the fabricated-row conversion in
ForwardBatch, which is where the extra-CI failures came from.

So guard on min(global_num_tokens) == 0 and let merely uneven batches
fall through to the communication-cost heuristic.
@fzyzcjy

fzyzcjy commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Posted by an AI coding agent (Claude Code) on behalf of @fzyzcjy.

Writing down what a naive revert of #10414 actually breaks, with the evidence for each item, since this branch has now hit all of them in CI.

A naive revert is deleting the guard so extend batches fall back to the communication-cost heuristic:

if is_extend_in_batch:
    return DpPaddingMode.SUM_LEN

That heuristic then selects MAX_LEN for most extend batches, and MAX_LEN rewrites every rank's token count to max(global_num_tokens). Everything below follows from those pad tokens.

Why this was never caught by ordinary testing

_disable_breakable_cudagraph_if_incompatible disables the breakable prefill CUDA graph for MLA models on its very first rule:

rules = [
    # MLA prefill takes a different attn-forward path under BCG.
    ("MLA attention", lambda: self.use_mla_backend()),

validate_hisparse restricts --enable-hisparse to DSA models (DeepSeek V3.2, GLM-5) and DeepSeek V4, all of which are AttentionArch.MLA. So for those models the prefill-CG MAX_LEN force at forward_batch_info.py:1254 never fires either. Combined with #10414 forcing SUM_LEN, MLA models have never taken the MAX_LEN path on an extend batch on main. Reverting turns that path on for exactly the model family that has no coverage for it — which is where the failures below come from.

1. Uninitialized attention output on padded rows

This is the bug #10414 was originally titled after: "Fix cutlass moe accuracy drop caused by attention UB from DP padding mode" (72dfa96).

The mechanism is visible in the padding code. For a non-empty extend rank, bs is left at self.batch_size, so _pad_inputs_to_size(model_runner, num_tokens, bs) pads only the token-dimension tensors:

Tensor padded to effect
input_ids, positions, out_cache_loc num_tokens grows, tail filled with 0
req_pool_indices, seq_lens, extend_seq_lens bs (unchanged) no-op

So sum(extend_seq_lens) < num_tokens. Attention backends derive cu_seqlens from extend_seq_lens, so the varlen kernel covers only the first sum(extend_seq_lens) rows, while the output buffer is allocated uninitialized — o = torch.empty_like(q) at triton_backend.py:1239/1701, dsa_backend.py:3016/3094, aiter_backend.py:2105/2233/2527, torch_native_backend.py:291/354, and so on across every backend.

The rows in [sum(extend_seq_lens), num_tokens) are therefore whatever was in that memory before. They flow into the MoE, where per-block FP8 quantization scales are computed over tiles that mix real and garbage rows, so a garbage row's magnitude corrupts the scale applied to the real rows sharing its tile. (The tile-level scale corruption is a mechanism inference from the code, not something measured.)

Observed failure: extra-b-test-8-gpu-h200, GLM-5.2-FP8 with hisparse and DP8 — gsm8k AssertionError: 0.656 not greater than or equal to 0.94 (job 90896956826).

One hypothesis that turns out not to apply: padded out_cache_loc entries are 0, but slot 0 is a reserved padding sink (memory_pool.py: "The padded slot 0 is used for writing dummy outputs from padded tokens"), so the padded KV writes are harmless.

2. Hybrid-SSM ranks hit the fabricated-row assert

forward_batch_info.py converts an idle rank into a one-request extend batch so its padded tokens have a defined shape, but the conversion only supports an empty rank:

assert self.seq_lens.shape[0] == 0, "extend-idle conversion expects an empty rank"

Hybrid-SSM families enter that branch on every rank, not just idle ones. Once MAX_LEN is available to extend batches, a non-empty hybrid-SSM rank reaches the assert and the scheduler dies.

Observed failure: extra-b-test-4-gpu-b200 (job 90896956839).

Worth noting the predicate matters here: ForwardBatch decides "is this hybrid-SSM" with mambaish_config(model_config), which covers Qwen3-Next, Qwen3.5, Kimi-Linear, LFM2, Falcon-H1, NemotronH and friends. A narrower check such as hf_config.hybrid_override_pattern is not None misses most of them.

3. IndexError when every rank is empty

With global_num_tokens == [0], the cost heuristic computes sum_len * 2 >= max_len * dp_size as 0 >= 0, which is true, so it returns MAX_LEN. The rank then takes the fabricated-row conversion with num_tokens == 0, producing extend_seq_lens == [0], and logits_processor.py:525 does:

last_index = torch.cumsum(logits_metadata.extend_seq_lens, dim=0) - 1
pruned_states = hidden_states[last_index]

last_index is [-1] against a zero-length hidden_states.

Reproduced on a 4×H200 devbox running test/registered/ep/test_mooncake_ep_small.py (--tp 4 --dp 4 --enable-dp-attention --moe-a2a-backend mooncake), where all four DP ranks crashed simultaneously:

File "/sgl-workspace/sglang/python/sglang/srt/layers/logits_processor.py", line 526, in _get_pruned_states
    pruned_states = hidden_states[last_index]
IndexError: index is out of bounds for dimension with size 0

4. Idle ranks make MAX_LEN much slower, not faster

MAX_LEN inflates the gathered buffer from sum_len to max_len * dp_size, and the MLP is sharded by TP but runs the whole buffer. When one rank is idle that is a pure multiplier on wasted work.

Measured on Qwen3-8B, 8×H200, --tp 8 --dp 8 --enable-dp-attention, with a bs=1 isl=16384 prefill so one rank does everything and seven idle:

total GEMM across 8 ranks input throughput median TTFT
MAX_LEN behaviour 2188–2191 ms ~20.2k tok/s ~827 ms
SUM_LEN behaviour 335–336 ms ~31.6k tok/s ~530 ms

Ratio 6.52×, with <0.5% spread inside each group across four configurations and two independent runs. MAX_LEN eager with no CUDA graph at all lands in the same group as the graph-enabled MAX_LEN configs, so this is the padding mode itself and not a graph artifact. A device-side probe inside the prefill graph confirms the shape directly: seven ranks report real_local_tokens=0 padded_local_tokens=1024, i.e. 1024 real tokens driving 8×1024 rows of attention.

By contrast, on batches where no rank is idle, MAX_LEN padding costs 0.0–2.2% and is genuinely faster than SUM_LEN (AllReduce bytes roughly halve, and AllGather + symmetric memory become available). So the useful split is idle vs non-idle, not uniform vs non-uniform.

Summary

A naive revert is unsafe for three independent reasons (uninitialized attention rows, the hybrid-SSM assert, and the all-empty IndexError) and is also a throughput regression on any batch containing an idle rank. The parts of #10414 that can be given back safely are extend batches where every rank has work, which is where the measured win actually lives.

The underlying fix, for whoever picks it up: teach the fabricated-row conversion to handle a non-empty rank by appending one dummy request of length num_tokens - sum(extend_seq_lens) instead of asserting the rank is empty. That makes the padded rows defined without touching any attention backend, and would let both the hybrid-SSM special case and the idle-rank guard go away.

@Jiminator
Jiminator deleted the tom/revert-pr10414 branch September 14, 2026 04:42
@alexnails
alexnails restored the tom/revert-pr10414 branch September 14, 2026 05:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant