Skip to content

Fix spec decoding with grammar in disagg - #24082

Merged
hnyls2002 merged 19 commits into
sgl-project:mainfrom
ashishdatta:ashish/specdec-disagg-structured-output
Jun 18, 2026
Merged

hnyls2002 merged 19 commits into
sgl-project:mainfrom
ashishdatta:ashish/specdec-disagg-structured-output

Conversation

@ashishdatta

@ashishdatta ashishdatta commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

Motivation

Fix structured output generation when speculative decoding is used in disaggregated decode mode.
Spec-v2 decode can accept multiple draft tokens in one step. With grammar-constrained decoding, the scheduler must process grammar state updates before launching the next overlapping disagg decode batch, and it must stop accepting speculative tokens once the grammar reaches a terminal state.

Modifications

  • Process the previous overlapped disagg decode result before launching a new spec-v2 grammar decode batch.
  • For spec-v2 requests with grammar constraints, accept tokens one at a time and stop once the request finishes.
  • Trim speculative tokens accepted after grammar completion so stale over-advanced state is not reused.

Accuracy Tests

Not a model-forward or kernel accuracy change. Adds a CPU unit test and a PD-disagg + overlap + Spec V2 (topk=1) + grammar regression; validated single-node and cross-node on H200 (see PR comment). Validated changed files with:

python3 -m py_compile \
  python/sglang/srt/disaggregation/decode.py \
  python/sglang/srt/managers/scheduler_components/batch_result_processor.py

Speed Tests and Profiling

No expected steady-state speed impact. The grammar sync path only applies to spec-v2 + grammar + disaggregated overlap decode when a prior result is pending.
No benchmarking run.

Checklist


CI States

Latest PR Test (Base): ✅ Run #27744167980
Latest PR Test (Extra): ✅ Run #27744167737

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@Jiminator Jiminator self-assigned this May 19, 2026
Jiminator added a commit that referenced this pull request May 27, 2026
…ort #24082)

Port PR #24082 onto the post-refactor scheduler component layout. The v0.5.11
fix only covered the Spec V1 grammar-finish path; it did not cover Spec V2 or
the PD-disaggregated overlap decode event loop.

- decode.py: in event_loop_overlap_disagg_decode, process the previously queued
  batch result (advancing the grammar) before launching the next Spec V2 grammar
  decode batch, and skip the duplicate last-batch pop so it is not processed
  twice.
- batch_result_processor.py: in process_batch_result_decode, for is_spec_v2 and
  req.grammar is not None, accept proposed tokens one at a time and stop at
  grammar completion, trimming output_ids, the grammar FSM, reasoning state, and
  logprob bookkeeping to the accepted prefix.

Tests:
- CPU unit test for the trimming loop (runs the real process_batch_result_decode).
- PD disagg + overlap + EAGLE Spec V2 (topk=1) + grammar regression test.

Co-authored-by: Ashish Datta <1856117+ashishdatta@users.noreply.github.com>
…ort sgl-project#24082)

Port PR sgl-project#24082 onto the post-refactor scheduler component layout. The v0.5.11
fix only covered the Spec V1 grammar-finish path; it did not cover Spec V2 or
the PD-disaggregated overlap decode event loop.

- decode.py: in event_loop_overlap_disagg_decode, process the previously queued
  batch result (advancing the grammar) before launching the next Spec V2 grammar
  decode batch, and skip the duplicate last-batch pop so it is not processed
  twice.
- batch_result_processor.py: in process_batch_result_decode, for is_spec_v2 and
  req.grammar is not None, accept proposed tokens one at a time and stop at
  grammar completion, trimming output_ids, the grammar FSM, reasoning state, and
  logprob bookkeeping to the accepted prefix.

Tests:
- CPU unit test for the trimming loop (runs the real process_batch_result_decode).
- PD disagg + overlap + EAGLE Spec V2 (topk=1) + grammar regression test.

Co-authored-by: jimmy.shong <jimmy.shong@radixark.ai>
Jiminator added a commit that referenced this pull request May 27, 2026
…ort #24082)

Port PR #24082 onto the post-refactor scheduler component layout. The v0.5.11
fix only covered the Spec V1 grammar-finish path; it did not cover Spec V2 or
the PD-disaggregated overlap decode event loop.

- decode.py: in event_loop_overlap_disagg_decode, process the previously queued
  batch result (advancing the grammar) before launching the next Spec V2 grammar
  decode batch, and skip the duplicate last-batch pop so it is not processed
  twice.
- batch_result_processor.py: in process_batch_result_decode, for is_spec_v2 and
  req.grammar is not None, accept proposed tokens one at a time and stop at
  grammar completion, trimming output_ids, the grammar FSM, reasoning state, and
  logprob bookkeeping to the accepted prefix.

Tests:
- CPU unit test for the trimming loop (runs the real process_batch_result_decode).
- PD disagg + overlap + EAGLE Spec V2 (topk=1) + grammar regression test.

Co-authored-by: Ashish Datta <1856117+ashishdatta@users.noreply.github.com>
@Jiminator
Jiminator force-pushed the ashish/specdec-disagg-structured-output branch from 3f0fd7c to f0bb40c Compare May 27, 2026 23:26
@Jiminator

Copy link
Copy Markdown
Collaborator

Validated on current main (8×H200, PD disagg + overlap + EAGLE Spec V2 topk=1 + xgrammar):

code CPU unit PD single-node PD cross-node
this PR ✅ 2 passed ✅ 3 passed
2 source changes reverted ❌ 1 failed ❌ 2 failed (Tokens not accepted → invalid JSON) ❌ bug repro

Reverting just the two source changes reproduces the bug, so the fix is load-bearing. Both changes are independently necessary — the disagg grammar-sync is guarded by the PD regression, the Spec V2 token-trim by the unit test.

@Jiminator

Copy link
Copy Markdown
Collaborator

/tag-and-rerun-ci

Jiminator and others added 4 commits May 28, 2026 00:51
…st test ctx-len

- batch_result_processor.py: extract the Spec V2 grammar token-accept loop into
  _accept_spec_v2_grammar_tokens(); replace the grammar_advanced flag with an
  is_spec_v2_grammar predicate; de-duplicate the accept_token error handling.
- test_disaggregation_spec_grammar.py: use --context-length 2048 so the target
  and EAGLE3-draft ModelConfigs agree, instead of overriding
  SGLANG_ALLOW_OVERWRITE_LONGER_CONTEXT_LEN to ignore the mismatch.
- decode.py: drop a restating comment on the need_grammar_sync guard.

The need_grammar_sync overlap-ordering mechanism (from sgl-project#24082) is unchanged;
turning it into an enforced scheduler invariant is tracked as follow-up.
_resolve_spec_overlap_tokens advances req.kv_committed_len by (accept_lens - 1)
based on the full spec-accepted count, before _accept_spec_v2_grammar_tokens
knows the grammar may terminate partway through. When grammar terminates
mid-list, the dropped tokens are committed-but-unused and leak when the request
releases — caught by SchedulerInvariantChecker._report_leak("pool") on current
main (added in sgl-project#25623 after the original port was written).

Decrement kv_committed_len by len(proposed) - len(accept_tokens) at the end of
the helper so the resolver's over-commit is undone for the dropped suffix. No
effect when grammar doesn't terminate mid-list (dropped == 0).

Repro on the merge head (7e2f8df) without the fix: TestEagleConstrainedDecodingV2
hits `pool memory leak detected ... total=187911, available=187648, evictable=262`
(1 slot imbalance). With the fix: 12/12 V2 cases pass on the same hardware.
Comment on lines +1664 to +1666
if not need_grammar_sync:
tmp_batch, tmp_result = self.result_queue.popleft()
self.process_batch_result(tmp_batch, tmp_result)

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.

Why not just set self.last_batch = None in previous if need_grammar_sync block?

Comment on lines +1644 to +1654
need_grammar_sync = (
batch
and batch.is_spec_v2
and batch.has_grammar
and batch.forward_mode.is_decode()
and len(self.result_queue) > 0
)
if need_grammar_sync:
tmp_batch, tmp_result = self.result_queue.popleft()
self.process_batch_result(tmp_batch, tmp_result)

@ShangmingCai ShangmingCai May 30, 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.

Something like this:

Suggested change
need_grammar_sync = (
batch
and batch.is_spec_v2
and batch.has_grammar
and batch.forward_mode.is_decode()
and len(self.result_queue) > 0
)
if need_grammar_sync:
tmp_batch, tmp_result = self.result_queue.popleft()
self.process_batch_result(tmp_batch, tmp_result)
need_grammar_sync = (
batch
and batch.is_spec_v2
and batch.has_grammar
and batch.forward_mode.is_decode()
and self.last_batch is not None
and len(self.result_queue) > 0
)
if need_grammar_sync:
tmp_batch, tmp_result = self.result_queue.popleft()
self.process_batch_result(tmp_batch, tmp_result)
self.last_batch = None

@ShangmingCai ShangmingCai May 30, 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.

On second thought, without token-by-token acceptance, grammar could terminate at a token, but the bonus following token is already appended to output_ids. The trimming logic is correct in this case. But I am not fully sure about the modification of these if not is_spec_v2_grammar blocks, you should find some experts to review this part.

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.

cc @fzyzcjy if you have time, can you take a look at this?

@ShangmingCai ShangmingCai May 30, 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'd better add this test to test_disaggregation_basic, instead of creating another one. This could help us save some CI resources.

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.

Done, TestDisaggregationSpecV2Grammar lives in test_disaggregation_basic.py now. Bumped the module's est_time to 750 to cover the added class. Each class still runs different server flags.

@ShangmingCai ShangmingCai left a comment

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.

cc: @Ubospica, do you have time to help us review this PR?

Jiminator added 2 commits May 30, 2026 08:56
…gg sync, merge tests

- decode.py (per @ShangmingCai): tighten `need_grammar_sync` predicate with
  `self.last_batch is not None`, set `self.last_batch = None` inside the block,
  and remove the `if not need_grammar_sync` guard from the later "Process the
  last batch" branch. Reads cleaner and removes the only special-case
  fallthrough.

- batch_result_processor.py (per @ShangmingCai): revert the Spec V2 grammar
  trimming branch + `_accept_spec_v2_grammar_tokens` helper + kv_committed_len
  rollback. Experiment: with the disagg sync in place, the trim never fires in
  the PR's primary path (Option A: 0 TRIM_PROBE hits across 3 passes). In the
  non-disagg Eagle V2 path it fired once across 12 tests, but plain main
  passes that test without the trim too (12 passed, 0 leaks, 0 grammar
  errors), and Option A passes equivalently (3 passed, 0 leaks). The kv leak
  the rollback patched was self-induced by the trim's over-commit.

- test_disaggregation_basic.py (per @ShangmingCai): merge
  `TestDisaggregationSpecV2Grammar` here as a new class so the PD fixture
  amortizes across the existing Spec PD tests instead of spinning up another
  module. Bump `est_time` to 750s.

- delete `test_disaggregation_spec_grammar.py` (merged) and
  `test_batch_result_processor_spec_grammar.py` (no helper to test).
@ShangmingCai edited their earlier comment to "On second thought, without
token-by-token acceptance, grammar could terminate at a token, but the bonus
following token is already appended to output_ids. The trimming logic is
correct in this case." Restoring the trim/helper/rollback I had removed in
7e7a487: the spec-v2 bonus-token after grammar termination needs to be
dropped from output_ids, the grammar FSM, and KV bookkeeping — my earlier
experiment didn't trigger that exact case so it looked dead, but the scenario
is real and the trim is the correct guard.

Re-adds:
- batch_result_processor.py: _accept_spec_v2_grammar_tokens helper, the
  is_spec_v2_grammar predicate + elif branch in process_batch_result_decode,
  the `if not is_spec_v2_grammar` guards on reasoning/finish, the grammar
  terminal-flag sync (in lieu of _apply_decode_grammar), and the
  kv_committed_len rollback for the dropped tokens.
- test_batch_result_processor_spec_grammar.py: CPU unit test exercising the
  helper (3 tokens proposed, grammar terminates after 2, assert trimmed
  prefix + finish state + grammar advance).

The decode.py simplification and the test_disaggregation_basic.py merged
class from 7e7a487 are kept as-is. @ShangmingCai flagged the
`if not is_spec_v2_grammar` guard pattern for expert review (cc @Ubospica
already pinged) — guards are intentional so per-token state isn't
double-applied after the helper, happy to refactor if there's a cleaner shape.
@ShangmingCai

Copy link
Copy Markdown
Collaborator

/rerun-test test/registered/disaggregation/test_disaggregation_basic.py

@github-actions

github-actions Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor

Results for /rerun-test test/registered/disaggregation/test_disaggregation_basic.py:

🚀 2-gpu-h100 (1 test): ✅ View workflow run

cd test/ && python3 registered/disaggregation/test_disaggregation_basic.py

@Jiminator
Jiminator requested a review from Ubospica May 31, 2026 20:50
@Ubospica

Ubospica commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Thanks for your contribution @ashishdatta and the review request from @ShangmingCai @Jiminator! This fix is definitely useful. The fix generally looks great to me, and I am further looking into the details of it.

# Conflicts:
#	test/registered/disaggregation/test_disaggregation_basic.py
Align spec grammar handling with the current spec_algorithm API and remove a disagg sync path made redundant by the mainline overlap guard.

Record the intentional KV rollback owner and keep the unit fake aligned with the current result processor hook.
Spec V2 is now the default speculative path, and the old SGLANG_ENABLE_SPEC_V2 descriptor was removed on main. Launch the regression test directly so it stays compatible after merging main.

@Ubospica Ubospica left a comment

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.

The logic generally looks great to me. Just a few comments

logger.error(
f"Grammar accept_token failed for req {req.rid} with token {proposed}: {e}"
)
self.abort_request(AbortReq(rid=req.rid))

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.

Do we need to call update_finish_state() here?

accept_tokens.append(token_id)
self._maybe_update_reasoning_tokens(req, token_id)
req.grammar.accept_token(token_id)
req.update_finish_state()

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.

Maybe we should accept token first, then append it into output_ids and accept_tokens, in case the token get rejected?

self.assertTrue(req.grammar.finished)
# Logprob bookkeeping sees only the retained tokens.
self.assertEqual(req.logprob.output_token_logprobs_val, [-0.1, -0.2])
self.assertEqual(req.logprob.output_token_logprobs_idx, [101, 102])

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.

shall we check kv_committed_len as well

"""Unit test for the Spec V2 + grammar trimming in process_batch_result_decode.

Companion to the GPU regression in
test/registered/disaggregation/test_disaggregation_spec_grammar.py (PR #24082).

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.

Looks the file is deleted

Jiminator and others added 2 commits June 16, 2026 11:15
Accept grammar tokens before mutating request output state, assert KV commit rollback in the CPU regression, and fix the stale disaggregation test reference called out in review.
@JustinTong0323

Copy link
Copy Markdown
Collaborator

@ashishdatta pushed a small cleanup pass on the tests/comments. Main changes: moved the processor stub test to CPU CI, folded the spec_verify_ct check into the existing e2e requests, and trimmed the added comments/docstrings. Could you take a quick look?

JustinTong0323 and others added 2 commits June 16, 2026 15:51
Consume the pending FINISH_ABORT after grammar accept_token fails so the decode-result cleanup sees the request as finished in the same pass.

@Ubospica Ubospica left a comment

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.

LGTM!

Codex and others added 2 commits June 18, 2026 07:20
@hnyls2002
hnyls2002 merged commit 9fc9d37 into sgl-project:main Jun 18, 2026
168 of 180 checks passed
Chronostasys pushed a commit to MindLab-Research/sglang that referenced this pull request Aug 24, 2026
Co-authored-by: jimmy.shong <jimmy.shong@radixark.ai>
Co-authored-by: Jimmy Shong <69131491+Jiminator@users.noreply.github.com>
Co-authored-by: Xinyuan Tong <xinyuantong.cs@gmail.com>
Co-authored-by: Xinyuan Tong <115166877+JustinTong0323@users.noreply.github.com>
Co-authored-by: Codex <codex@example.com>
jakki-amd pushed a commit to jakki-amd/sglang that referenced this pull request Sep 9, 2026
Co-authored-by: jimmy.shong <jimmy.shong@radixark.ai>
Co-authored-by: Jimmy Shong <69131491+Jiminator@users.noreply.github.com>
Co-authored-by: Xinyuan Tong <xinyuantong.cs@gmail.com>
Co-authored-by: Xinyuan Tong <115166877+JustinTong0323@users.noreply.github.com>
Co-authored-by: Codex <codex@example.com>
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.

6 participants