Use native batched llguidance mask generation (#32412)

Co-authored-by: Alec S <10566873+alecsolder@users.noreply.github.com>
This commit is contained in:
Lianmin Zheng
2026-07-25 16:36:32 -07:00
committed by GitHub
co-authored by Alec S
parent fae84ac0f9
commit 9989077f24
5 changed files with 335 additions and 15 deletions
@@ -11,7 +11,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""The baseclass of a backend for grammar-guided constrained decoding."""
"""The base class of a backend for grammar-guided constrained decoding."""
import logging
import time
@@ -22,10 +22,13 @@ from typing import Dict, List, NamedTuple, Optional, Tuple
import torch
from sglang.srt.parser.reasoning_parser import ReasoningParser
from sglang.srt.runtime_context import get_resources
from sglang.srt.server_args import ServerArgs
logger = logging.getLogger(__name__)
GRAMMAR_BACKEND_REGISTRY = {}
@dataclass
class GrammarStats:
@@ -39,6 +42,13 @@ class GrammarStats:
num_timeout: int = 0
class GrammarRow(NamedTuple):
"""Grammar and destination row for a batched vocab-mask fill."""
row: int
grammar: "BaseGrammarObject"
class BaseGrammarObject:
def __init__(self):
@@ -69,6 +79,19 @@ class BaseGrammarObject:
def fill_vocab_mask(self, vocab_mask: torch.Tensor, idx: int) -> None:
raise NotImplementedError()
@staticmethod
def fill_vocab_mask_batched(
entries: List[GrammarRow], vocab_mask: torch.Tensor
) -> None:
"""Fill listed rows, leaving unlisted rows untouched."""
for entry in entries:
entry.grammar.fill_vocab_mask(vocab_mask, entry.row)
@staticmethod
def reset_vocab_mask(vocab_mask: torch.Tensor) -> None:
"""Restore a reusable mask to the backend's unconstrained state."""
raise NotImplementedError()
@staticmethod
def move_vocab_mask(vocab_mask: torch.Tensor, device) -> torch.Tensor:
raise NotImplementedError()
@@ -148,6 +171,16 @@ class BaseGrammarBackend:
self.executor = ThreadPoolExecutor()
self.cache: Dict[Tuple[str, str], BaseGrammarObject] = {}
def initialize_vocab_mask_buffer(
self,
name: str,
vocab_size: int,
max_rows: int,
device,
) -> Optional[torch.Tensor]:
"""Initialize a reusable mask buffer when supported by the backend."""
return None
def _not_supported(self, key_type: str, key_string: str) -> BaseGrammarObject:
logger.warning(f"Skip unsupported {key_type=}, {key_string=}")
return InvalidGrammarObject()
@@ -226,7 +259,49 @@ class BaseGrammarBackend:
self.cache.clear()
GRAMMAR_BACKEND_REGISTRY = {}
def register_vocab_mask_buffer(
name: str, vocab_mask: torch.Tensor, max_rows: int
) -> torch.Tensor:
"""Register a fixed-capacity mask buffer, preserving an equivalent one."""
if max_rows <= 0:
raise ValueError(f"Grammar mask max_rows must be positive, got {max_rows}")
if vocab_mask.ndim == 0 or vocab_mask.shape[0] != max_rows:
raise ValueError(
f"Grammar mask buffer {name!r} must have {max_rows} rows, "
f"got shape {tuple(vocab_mask.shape)}"
)
buffers = get_resources().buffers
existing = buffers.get(name)
if existing is not None:
if (
existing.shape != vocab_mask.shape
or existing.dtype != vocab_mask.dtype
or existing.device != vocab_mask.device
):
raise RuntimeError(
f"Grammar mask buffer {name!r} was already initialized as "
f"{tuple(existing.shape)}, {existing.dtype}, {existing.device}; "
f"new buffer is {tuple(vocab_mask.shape)}, {vocab_mask.dtype}, "
f"{vocab_mask.device}"
)
return existing
buffers[name] = vocab_mask
return vocab_mask
def get_vocab_mask_buffer(name: str, rows: int) -> Optional[torch.Tensor]:
"""Return the active rows of a registered mask buffer, if available."""
vocab_mask = get_resources().buffers.get(name)
if vocab_mask is None:
return None
if rows > vocab_mask.shape[0]:
raise ValueError(
f"Grammar batch needs {rows} mask rows, exceeding initialized "
f"capacity {vocab_mask.shape[0]} for {name!r}"
)
return vocab_mask[:rows]
def register_grammar_backend(name, init_func):
@@ -15,26 +15,76 @@
import json
import logging
import os
from typing import Iterable, List, Optional, Tuple, Union
from functools import cache
from typing import Iterable, List, NamedTuple, Optional, Tuple, Union
import torch
from llguidance import LLMatcher, LLTokenizer, StructTag, grammar_from
from llguidance import LLExecutor, LLMatcher, LLTokenizer, StructTag, grammar_from
from llguidance.hf import from_tokenizer
from llguidance.torch import (
allocate_token_bitmask,
apply_token_bitmask_inplace,
fill_next_token_bitmask,
fill_next_token_bitmask_par,
fill_next_token_bitmask_par_with_draft_tokens,
)
from sglang.srt.constrained.base_grammar_backend import (
BaseGrammarBackend,
BaseGrammarObject,
GrammarRow,
InvalidGrammarObject,
register_vocab_mask_buffer,
)
from sglang.srt.constrained.utils import is_legacy_structural_tag
from sglang.srt.utils import get_int_env_var
logger = logging.getLogger(__name__)
_LLGUIDANCE_LOG_LEVEL = get_int_env_var("LLGUIDANCE_LOG_LEVEL", 1)
class GrammarDraftRow(NamedTuple):
"""Grammar, destination block, and tokens for a draft-chain mask fill."""
base_row: int
grammar: "GuidanceGrammar"
draft_tokens: List[int]
@cache
def _get_or_init_mask_executor() -> LLExecutor:
return LLExecutor()
def fill_token_bitmask_with_draft_tokens(
entries: List[GrammarDraftRow],
vocab_mask: torch.Tensor,
) -> None:
"""Fill speculative draft-chain masks with llguidance's native kernel.
Each matcher is advanced over its legal draft prefix and rolled back by the
number of consumed parser tokens before returning. Rows after the first
illegal token retain the caller's all-allow value. Correctness requires
matcher rollback to restore parser-token state exactly. Matcher errors retain
the native executor's behavior; this wrapper adds no per-matcher Python probe.
"""
if not entries:
return
matchers = [(e.grammar.ll_matcher, e.base_row, e.draft_tokens) for e in entries]
fill_next_token_bitmask_par_with_draft_tokens(
_get_or_init_mask_executor(), matchers, vocab_mask
)
def fill_token_bitmask_batched(
entries: List[GrammarRow],
vocab_mask: torch.Tensor,
) -> None:
"""Fill regular-decode mask rows with llguidance's native kernel."""
if not entries:
return
matchers = [(e.grammar.ll_matcher, e.row) for e in entries]
fill_next_token_bitmask_par(_get_or_init_mask_executor(), matchers, vocab_mask)
def _normalize_eos_token_ids(
@@ -47,15 +97,26 @@ def _normalize_eos_token_ids(
class GuidanceGrammar(BaseGrammarObject):
def __init__(self, llguidance_tokenizer: LLTokenizer, serialized_grammar: str):
def __init__(
self,
llguidance_tokenizer: LLTokenizer,
serialized_grammar: str,
*,
ll_matcher: Optional[LLMatcher] = None,
):
super().__init__()
self.llguidance_tokenizer = llguidance_tokenizer
self.serialized_grammar = serialized_grammar
self.ll_matcher = LLMatcher(
self.llguidance_tokenizer,
self.serialized_grammar,
log_level=int(os.environ.get("LLGUIDANCE_LOG_LEVEL", "1")),
# A request copy reuses the cached template's compiled matcher.
self.ll_matcher = (
ll_matcher
if ll_matcher is not None
else LLMatcher(
self.llguidance_tokenizer,
self.serialized_grammar,
log_level=_LLGUIDANCE_LOG_LEVEL,
)
)
self._check_err()
@@ -87,6 +148,24 @@ class GuidanceGrammar(BaseGrammarObject):
fill_next_token_bitmask(self.ll_matcher, vocab_mask, idx)
self._check_err()
@staticmethod
def fill_vocab_mask_batched(
entries: List[GrammarRow], vocab_mask: torch.Tensor
) -> None:
"""Use the native fill when every entry is a plain llguidance grammar."""
if all(isinstance(entry.grammar, GuidanceGrammar) for entry in entries):
fill_token_bitmask_batched(entries, vocab_mask)
return
BaseGrammarObject.fill_vocab_mask_batched(entries, vocab_mask)
@staticmethod
def reset_vocab_mask(vocab_mask: torch.Tensor) -> None:
if vocab_mask.dtype != torch.int32:
raise TypeError(
f"llguidance requires a packed int32 mask, got {vocab_mask.dtype}"
)
vocab_mask.fill_(-1)
def allocate_vocab_mask(
self, vocab_size: int, batch_size: int, device
) -> torch.Tensor:
@@ -101,9 +180,12 @@ class GuidanceGrammar(BaseGrammarObject):
apply_token_bitmask_inplace(logits, vocab_mask)
def copy(self):
# Cache templates are pristine, so cloning their matcher creates a fresh
# request grammar without recompiling the serialized grammar.
return GuidanceGrammar(
llguidance_tokenizer=self.llguidance_tokenizer,
serialized_grammar=self.serialized_grammar,
ll_matcher=self.ll_matcher.deep_copy(),
)
def try_jump_forward(self, tokenizer) -> Optional[Tuple[List[int], str]]:
@@ -146,6 +228,21 @@ class GuidanceBackend(BaseGrammarBackend):
n_vocab,
eos_token=_normalize_eos_token_ids(eos_token_ids),
)
# Initialize the shared executor here so the first batched mask fill
# does not pay its one-time setup cost on the request path.
_get_or_init_mask_executor()
def initialize_vocab_mask_buffer(
self,
name: str,
vocab_size: int,
max_rows: int,
device,
) -> torch.Tensor:
vocab_mask = allocate_token_bitmask(
max_rows, self.llguidance_tokenizer.vocab_size
)
return register_vocab_mask_buffer(name, vocab_mask, max_rows)
def _from_serialized(self, serialized_grammar) -> BaseGrammarObject:
try:
@@ -10,6 +10,7 @@ import sglang.srt.sampling.penaltylib as penaltylib
from sglang.srt.constrained.base_grammar_backend import (
BaseGrammarObject,
GrammarMask,
GrammarRow,
)
from sglang.srt.runtime_context import get_server_args
from sglang.srt.sampling.custom_logit_processor import CustomLogitProcessor
@@ -244,17 +245,20 @@ class SamplingBatchInfo:
# Find a grammar from the list
first_grammar = next(grammar for grammar in self.grammars if grammar)
# TODO(lianmin): Maybe we can reuse the existing mask?
vocab_mask = first_grammar.allocate_vocab_mask(
vocab_size=self.vocab_size,
batch_size=len(self.temperatures),
device=self.device,
)
# Apply the mask
for i, grammar in enumerate(self.grammars):
if grammar and not grammar.finished and not grammar.is_terminated():
grammar.fill_vocab_mask(vocab_mask, i)
# Rows omitted here (finished / terminated / non-grammar requests) retain
# the freshly allocated buffer's unconstrained value.
entries = [
GrammarRow(row=row, grammar=grammar)
for row, grammar in enumerate(self.grammars)
if grammar and not grammar.finished and not grammar.is_terminated()
]
first_grammar.fill_vocab_mask_batched(entries, vocab_mask)
# Move the mask to the device if needed
vocab_mask = first_grammar.move_vocab_mask(vocab_mask, self.device)