Overlap grammar (constrained decoding) with speculative decode verify (#31488)

Co-authored-by: Jason Park <jasonjk@fb.com>
This commit is contained in:
Lianmin Zheng
2026-07-20 17:36:05 -07:00
committed by GitHub
co-authored by Jason Park
parent 8905cbd42f
commit e7e8aaa73c
12 changed files with 214 additions and 47 deletions
@@ -26,7 +26,8 @@ from xgrammar import (
StructuralTag,
StructuralTagItem,
TokenizerInfo,
allocate_token_bitmask,
bitmask_dtype,
get_bitmask_shape,
)
from sglang.srt.constrained.base_grammar_backend import (
@@ -56,6 +57,18 @@ logger = logging.getLogger(__name__)
MAX_ROLLBACK_TOKENS = 200
def _allocate_token_bitmask(vocab_size: int, batch_size: int) -> torch.Tensor:
# Always allocate a pinned bitmask so the later H2D to the device can be a
# genuine non_blocking copy (a pageable source silently downgrades it to a
# blocking copy).
return torch.full(
get_bitmask_shape(batch_size, vocab_size),
-1,
dtype=bitmask_dtype,
pin_memory=True,
)
class XGrammarGrammar(BaseGrammarObject):
def __init__(
@@ -100,7 +113,7 @@ class XGrammarGrammar(BaseGrammarObject):
def allocate_vocab_mask(
self, vocab_size: int, batch_size: int, device
) -> torch.Tensor:
return allocate_token_bitmask(batch_size, vocab_size)
return _allocate_token_bitmask(vocab_size, batch_size)
def fill_vocab_mask(self, vocab_mask: torch.Tensor, idx: int) -> None:
self.matcher.fill_next_token_bitmask(vocab_mask, idx)
@@ -228,7 +241,7 @@ class XGrammarGrammarBackend(BaseGrammarBackend):
@staticmethod
def allocate_vocab_mask(vocab_size: int, batch_size: int, device) -> torch.Tensor:
return allocate_token_bitmask(batch_size, vocab_size)
return _allocate_token_bitmask(vocab_size, batch_size)
@staticmethod
def move_vocab_mask(vocab_mask: torch.Tensor, device) -> torch.Tensor:
@@ -3008,6 +3008,7 @@ class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin):
forward_mode=self.forward_mode,
out_cache_loc=self.out_cache_loc,
return_logprob=self.return_logprob,
has_grammar=self.has_grammar,
decoding_reqs=self.decoding_reqs,
spec_algorithm=self.spec_algorithm,
spec_info=self.spec_info,
+30 -11
View File
@@ -1646,19 +1646,34 @@ class Scheduler(
and last_batch_is_extend
)
# We do not support overlap + spec + grammar yet,
# so we need to turn off overlap for this batch.
# TODO(lsyin): support overlap + spec + grammar
# Spec algorithms that don't advance the grammar FSM inside verify() (see
# supports_grammar_overlap) still need overlap forced off for grammar decode
# batches, so the FSM is advanced before the next batch's bitmask.
need_grammar_sync = (
batch
and not batch.spec_algorithm.is_none()
and not batch.spec_algorithm.supports_grammar_overlap()
and batch.has_grammar
and batch.forward_mode.is_decode()
and len(self.result_queue) > 0
)
# Algorithms that support grammar overlap advance the FSM inside verify()
# via the grammar barrier (overlapping the target forward), which resolves
# whatever result is still pending in the queue — including the
# extend->decode boundary — so no grammar-specific overlap disable is needed.
return disable_overlap_for_batch or need_grammar_sync
def _advance_pending_grammar(self):
"""Grammar barrier (spec-v2 overlap): advance the FSM over any not-yet
-processed decode result still in the queue, so a following verify()'s
bitmask sees the previous batch's committed tokens. Invoked mid-worker
(before generate_token_bitmask) so the CPU advance overlaps the target
verify forward. Idempotent; no-op when the queue is empty or has no grammar.
"""
for prev_batch, prev_result in self.result_queue:
self.batch_result_processor.advance_grammar_fsm(prev_result, prev_batch)
@scheduler_nvtx_method("scheduler.process_input_requests")
def process_input_requests(self, recv_reqs: List):
now = time.monotonic()
@@ -3337,15 +3352,19 @@ class Scheduler(
# Spec_v2 fires on_publish mid-worker (between verify and
# draft_extend) so schedule prep can overlap with draft_extend.
# Non-spec has no later work — scheduler publishes after return.
fwd_kwargs = (
{
"on_publish": partial(
self.future_map.publish, future_indices
fwd_kwargs = {}
if not batch.spec_algorithm.is_none():
fwd_kwargs["on_publish"] = partial(
self.future_map.publish, future_indices
)
# Grammar-overlap-capable workers advance the grammar FSM
# inside verify() before building the bitmask; hand them the
# barrier that resolves the previous batch's committed
# tokens (overlapping the target forward).
if batch.spec_algorithm.supports_grammar_overlap():
fwd_kwargs["grammar_barrier"] = (
self._advance_pending_grammar
)
}
if not batch.spec_algorithm.is_none()
else {}
)
# FIXME: pp is not compatible with overlap
batch_result = self.model_worker.forward_batch_generation(
@@ -273,7 +273,9 @@ class SchedulerBatchResultProcessor:
if req.grammar is not None:
self._apply_prefill_grammar(
req=req, next_token_id=next_token_id
req=req,
next_token_id=next_token_id,
already_advanced=result.grammar_advanced,
)
else:
@@ -487,17 +489,24 @@ class SchedulerBatchResultProcessor:
)
return hidden_state_offset
def _apply_prefill_grammar(self, *, req: Req, next_token_id: int) -> None:
# FIXME: this try-except block is for handling unexpected xgrammar issue.
try:
req.grammar.accept_token(next_token_id)
except ValueError as e:
# Grammar accept_token can raise ValueError if the token is not in the grammar.
# This can happen if the grammar is not set correctly or the token is invalid.
logger.error(
f"Grammar accept_token failed for req {req.rid} with token {next_token_id}: {e}"
)
req.to_finish = FINISH_ABORT()
def _apply_prefill_grammar(
self, *, req: Req, next_token_id: int, already_advanced: bool = False
) -> None:
# The grammar barrier may have already advanced the FSM over this prefilled
# token (spec overlap path); only advance if not, but always sync
# grammar.finished.
if not already_advanced:
# FIXME: this try-except block is for handling unexpected xgrammar issue.
try:
req.grammar.accept_token(next_token_id)
except ValueError as e:
# Grammar accept_token can raise ValueError if the token is not in the
# grammar. This can happen if the grammar is not set correctly or the
# token is invalid.
logger.error(
f"Grammar accept_token failed for req {req.rid} with token {next_token_id}: {e}"
)
req.to_finish = FINISH_ABORT()
req.grammar.finished = req.finished()
def _apply_chunked_prefill_logprobs(
@@ -565,6 +574,13 @@ class SchedulerBatchResultProcessor:
result.num_correct_drafts_per_req_cpu, batch_size=len(batch.reqs)
)
# Advance the grammar FSM over this batch's committed tokens (idempotent):
# the EAGLE overlap path already did this inside verify() via the grammar
# barrier; otherwise advance now. advance_grammar_fsm self-gates on per-req
# grammar (the queued batch.copy() does not carry has_grammar) and consumes
# result.grammar_retained_tokens below instead of re-advancing.
self.advance_grammar_fsm(result, batch)
predict_tokens = []
# In adaptive spec-v2, the worker state may already have switched when this
# delayed result is processed. Use the draft token count recorded on result.
@@ -580,11 +596,9 @@ class SchedulerBatchResultProcessor:
pass
else:
if req.grammar is not None:
# Stop accepting once the grammar terminates, so the
# over-drafted suffix is never committed to KV nor emitted.
# This advances the grammar FSM; the result loop only syncs
# grammar.finished.
accept_tokens = self._accept_grammar_tokens(req, accept_tokens)
# FSM already advanced + truncated by advance_grammar_fsm; reuse
# the retained (grammar-legal) run instead of advancing again.
accept_tokens = result.grammar_retained_tokens[i]
# Commit the full accepted run (drafts + bonus).
num_accept_tokens = len(accept_tokens)
@@ -636,6 +650,64 @@ class SchedulerBatchResultProcessor:
req.to_finish = FINISH_ABORT()
return retained
def advance_grammar_fsm(
self, result: GenerationBatchResult, batch: ScheduleBatch
) -> None:
"""Advance each req's grammar FSM over the tokens THIS batch committed, and
(for decode) memoize the grammar-truncated run on ``result``.
This is the single place the spec-v2 FSM advances. It is idempotent
(``result.grammar_advanced``) so it runs either eagerly — inside ``verify(N)``
via the scheduler's grammar barrier, so the advance overlaps the target-verify
forward — or lazily from the result processors on the non-overlap / non-EAGLE
paths. It handles both decode (the accepted spec run) and extend (the single
prefilled token) results, so the barrier can resolve whatever the previous
batch was — e.g. the extend->decode boundary.
"""
if result.grammar_advanced or not batch.has_grammar:
return
is_decode = batch.forward_mode.is_decode()
if not (is_decode or batch.forward_mode.is_extend()):
return
if result.copy_done is not None:
result.copy_done.synchronize()
next_token_ids = result.next_token_ids.tolist()
if not is_decode:
# Extend: advance over the single token each completed-prefill req emitted
# (mirrors process_batch_result_prefill's per-req token indexing).
for i, req in enumerate(batch.reqs):
if (
req.grammar is None
or req.is_retracted
or req.finished()
or req.inflight_middle_chunks > 0
):
continue
self._accept_grammar_tokens(req, next_token_ids[i])
result.grammar_advanced = True
return
# Decode: only the spec-v2 path reaches here (the grammar barrier for
# spec-overlap workers and _resolve_spec_v2_tokens). Non-spec grammar decode
# advances its FSM in process_batch_result_decode and has no accept_lens, so
# bail out defensively.
if result.accept_lens is None:
return
accept_lens = result.accept_lens.tolist()
stride = result.speculative_num_draft_tokens
assert stride is not None, "spec-v2 result missing speculative_num_draft_tokens"
retained = [None] * len(batch.reqs)
for i, req in enumerate(batch.reqs):
if req.grammar is None or req.is_retracted or req.finished():
continue
accept_tokens = next_token_ids[i * stride : i * stride + accept_lens[i]]
# Stop accepting once the grammar terminates so the over-drafted suffix
# is never committed to KV nor emitted; this advances the FSM.
retained[i] = self._accept_grammar_tokens(req, accept_tokens)
result.grammar_retained_tokens = retained
result.grammar_advanced = True
def process_batch_result_idle(
self,
batch: ScheduleBatch,
+7
View File
@@ -66,6 +66,13 @@ class GenerationBatchResult:
future_indices: Optional[torch.Tensor] = None
speculative_num_draft_tokens: Optional[int] = None
# Grammar FSM advance memoization (spec-v2 overlap). advance_grammar_fsm sets
# these once — eagerly via the scheduler's grammar barrier inside verify(), or
# lazily in _resolve_spec_v2_tokens — and the latter consumes
# grammar_retained_tokens instead of re-advancing the FSM.
grammar_advanced: bool = False
grammar_retained_tokens: Optional[list] = None
# FIXME(lsyin): maybe move to a better place?
# sync path: forward stream -> output processor
accept_lens: Optional[torch.Tensor] = None
@@ -9,7 +9,7 @@ from sglang.kernels.ops.speculative.cache_locs import (
)
from sglang.kernels.ops.speculative.eagle import fill_bonus_tokens_func
from sglang.srt.layers.logprob_processor import compute_spec_v2_logprobs
from sglang.srt.managers.utils import GenerationBatchResult
from sglang.srt.managers.utils import GenerationBatchResult, _async_d2h
from sglang.srt.model_executor.forward_batch_info import (
CaptureHiddenMode,
ForwardBatch,
@@ -467,6 +467,7 @@ def run_eagle_verify(
device: str,
metadata_ready_pre_pad: bool,
finalize_tree_path: bool,
grammar_barrier=None,
) -> GenerationBatchResult:
"""Shared verify step: target-verify forward, sampling, acceptance bookkeeping.
@@ -527,13 +528,21 @@ def run_eagle_verify(
),
)
# Prepare grammar data on CPU if needed
# Prepare grammar data on CPU if needed. Use async pinned D2H copies (not
# blocking .cpu()) and record an event. The copies are issued before the
# target verify launch below so they run right after the draft, but the
# host does not block here. We wait on grammar_copy_done only just before
# the CPU bitmask traversal reads the buffers, so the traversal (and these
# copies) overlap the target verify forward instead of stalling the GPU.
grammar_copy_done = None
if batch.has_grammar:
retrieve_next_token_cpu = verify_input.retrieve_next_token.cpu()
retrieve_next_sibling_cpu = verify_input.retrieve_next_sibling.cpu()
draft_tokens_cpu = verify_input.draft_token.view(
verify_input.retrieve_next_token.shape
).cpu()
retrieve_next_token_cpu = _async_d2h(verify_input.retrieve_next_token)
retrieve_next_sibling_cpu = _async_d2h(verify_input.retrieve_next_sibling)
draft_tokens_cpu = _async_d2h(
verify_input.draft_token.view(verify_input.retrieve_next_token.shape)
)
grammar_copy_done = torch.get_device_module(device).Event()
grammar_copy_done.record()
if metadata_ready_pre_pad:
# Multi-layer eagle preserved-verbatim behavior: metadata init is
@@ -561,6 +570,17 @@ def run_eagle_verify(
# Generate vocab mask for constrained decoding
vocab_mask = None
if batch.has_grammar:
# Grammar barrier: advance the previous batch's grammar FSM over its
# committed tokens before building this batch's bitmask. Runs after the
# target forward launch, so the FSM advance and the traversal below both
# overlap the target verify forward. No-op if there is nothing pending.
if grammar_barrier is not None:
grammar_barrier()
# Wait for the async draft/verify-input D2H copies above to land before
# the CPU traversal reads them. The event was recorded right after the
# copies (before the target verify launch), so this wait — and the
# traversal below — overlap the target verify forward.
grammar_copy_done.synchronize()
# Generate the logit mask for structured output.
vocab_mask = generate_token_bitmask(
batch.reqs,
@@ -573,7 +593,12 @@ def run_eagle_verify(
if vocab_mask is not None:
assert verify_input.grammar is not None
vocab_mask = vocab_mask.to(verify_input.retrieve_next_token.device)
# non_blocking H2D so the mask copy overlaps the tail of the target
# verify forward instead of syncing the host; stream ordering keeps
# it before eagle_sample's apply_vocab_mask below.
vocab_mask = vocab_mask.to(
verify_input.retrieve_next_token.device, non_blocking=True
)
# NOTE: otherwise, this vocab mask will be the one from the previous extend stage
# and will be applied to produce wrong results
batch.sampling_info.vocab_mask = None
@@ -1119,7 +1119,9 @@ class EAGLEWorkerV2(BaseSpecWorker):
),
)
def forward_batch_generation(self, batch: ScheduleBatch, on_publish=None):
def forward_batch_generation(
self, batch: ScheduleBatch, on_publish=None, grammar_barrier=None
):
if batch.forward_mode.is_extend() or batch.is_extend_in_batch:
# Target prefill
target_capture_mode = (
@@ -1192,7 +1194,7 @@ class EAGLEWorkerV2(BaseSpecWorker):
verify_input: EagleVerifyInput = self.draft_worker.draft(batch)
assert verify_input.is_verify_input()
batch.spec_info = verify_input
batch_output = self.verify(batch)
batch_output = self.verify(batch, grammar_barrier=grammar_barrier)
# Publish before draft_extend so the fence is at verify-end.
if on_publish is not None:
on_publish(batch_output.new_seq_lens)
@@ -1495,7 +1497,7 @@ class EAGLEWorkerV2(BaseSpecWorker):
)
dw._rebuild_topk1_chain_buffers()
def verify(self, batch: ScheduleBatch):
def verify(self, batch: ScheduleBatch, grammar_barrier=None):
return run_eagle_verify(
batch,
target_worker=self.target_worker,
@@ -1509,6 +1511,7 @@ class EAGLEWorkerV2(BaseSpecWorker):
device=self.device,
metadata_ready_pre_pad=False,
finalize_tree_path=True,
grammar_barrier=grammar_barrier,
)
def update_weights_from_tensor(self, recv_req: UpdateWeightsFromTensorReqInput):
@@ -708,7 +708,9 @@ class FrozenKVMTPWorkerV2(EAGLEWorkerV2):
self._draft_worker.draft_attn_backend,
)
def forward_batch_generation(self, batch: ScheduleBatch, on_publish=None):
def forward_batch_generation(
self, batch: ScheduleBatch, on_publish=None, grammar_barrier=None
):
# Mirrors EAGLEWorkerV2.forward_batch_generation; the only frozen-specific
# change is the idle draft-input (FrozenKVMTPDraftInput + recurrent hidden
# size). The draft / seed-based draft-extend hooks are FrozenKVMTPDraftWorker's.
@@ -758,7 +760,7 @@ class FrozenKVMTPWorkerV2(EAGLEWorkerV2):
verify_input = self.draft_worker.draft(batch)
assert verify_input.is_verify_input()
batch.spec_info = verify_input
batch_output = self.verify(batch)
batch_output = self.verify(batch, grammar_barrier=grammar_barrier)
# Publish before draft-extend so the fence is at verify-end.
if on_publish is not None:
on_publish(batch_output.new_seq_lens)
@@ -927,7 +927,9 @@ class MultiLayerEagleWorkerV2(BaseSpecWorker):
),
)
def forward_batch_generation(self, batch: ScheduleBatch, on_publish=None):
def forward_batch_generation(
self, batch: ScheduleBatch, on_publish=None, grammar_barrier=None
):
if batch.forward_mode.is_extend() or batch.is_extend_in_batch:
# Target prefill
target_capture_mode = (
@@ -974,14 +976,14 @@ class MultiLayerEagleWorkerV2(BaseSpecWorker):
verify_input: EagleVerifyInput = self.draft_worker.draft(batch)
assert verify_input.is_verify_input()
batch.spec_info = verify_input
batch_output = self.verify(batch)
batch_output = self.verify(batch, grammar_barrier=grammar_barrier)
# Publish before draft_extend so the fence is at verify-end.
if on_publish is not None:
on_publish(batch_output.new_seq_lens)
self.draft_worker._draft_extend_for_decode(batch, batch_output)
return batch_output
def verify(self, batch: ScheduleBatch):
def verify(self, batch: ScheduleBatch, grammar_barrier=None):
return run_eagle_verify(
batch,
target_worker=self.target_worker,
@@ -995,4 +997,5 @@ class MultiLayerEagleWorkerV2(BaseSpecWorker):
device=self.device,
metadata_ready_pre_pad=False,
finalize_tree_path=False,
grammar_barrier=grammar_barrier,
)
@@ -133,6 +133,11 @@ class SpeculativeAlgorithm(Enum):
graphs in the decode cuda graph runner."""
return self.is_dspark()
def supports_grammar_overlap(self) -> bool:
# Whether the worker advances the grammar FSM inside verify() (via the
# scheduler's grammar barrier), letting spec + grammar decode overlap.
return self.is_eagle()
def has_draft_kv(self) -> bool:
"""Whether the draft phase writes KV chains. NGRAM does not (its tree
lives only in the verify mask), so per-decode KV sizing needs no
@@ -95,6 +95,11 @@ class CustomSpecAlgo:
def supports_ragged_verify(self) -> bool:
return False
def supports_grammar_overlap(self) -> bool:
# Whether the worker advances the grammar FSM inside verify() (via the
# scheduler's grammar barrier), letting spec + grammar decode overlap.
return False
def has_draft_kv(self) -> bool:
# Conservative default: the larger KV reserve.
return True