Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion python/sglang/srt/constrained/xgrammar_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,10 @@ def accept_token(self, token: int):

def rollback(self, k: int):
self.matcher.rollback(k)
self.accepted_tokens = self.accepted_tokens[:-k]
# Truncate in place: a slice-copy (`[:-k]`) is O(len) per call on the
# spec-decode hot path, and `[:-0]` would clear the whole list.
if k > 0:
del self.accepted_tokens[-k:]
Comment on lines 106 to +111

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

It is a good practice to defensively guard against negative values of k to prevent unexpected behavior or cryptic errors from the underlying C++ matcher. Adding a check for k < 0 and raising a clear ValueError improves robustness.

Suggested change
def rollback(self, k: int):
self.matcher.rollback(k)
self.accepted_tokens = self.accepted_tokens[:-k]
# Truncate in place: a slice-copy (`[:-k]`) is O(len) per call on the
# spec-decode hot path, and `[:-0]` would clear the whole list.
if k > 0:
del self.accepted_tokens[-k:]
def rollback(self, k: int):
if k < 0:
raise ValueError(f"k must be non-negative, got {k}")
self.matcher.rollback(k)
# Truncate in place: a slice-copy (`[:-k]`) is O(len) per call on the
# spec-decode hot path, and `[:-0]` would clear the whole list.
if k > 0:
del self.accepted_tokens[-k:]

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked against the repository-pinned xgrammar 0.2.1 before adding validation. Its native rollback checks the upper history bound, and a negative count currently returns without consuming history (verified by accepting one token, calling rollback(-1), then successfully rolling back that token). Introducing a Python ValueError would add a new input contract beyond this bookkeeping fix. I have kept matcher.rollback(k) first and left validation semantics at that boundary. The refreshed commit 567cb90 includes real-matcher regression tests, rather than using a mock to define dependency behavior. Source: https://github.com/mlc-ai/xgrammar/blob/v0.2.1/cpp/grammar_matcher.cc#L1023


def is_terminated(self):
return self.matcher.is_terminated()
Expand Down
108 changes: 108 additions & 0 deletions test/registered/unit/constrained/test_xgrammar_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""
Unit tests for sglang.srt.constrained.xgrammar_backend.

Test Coverage:
- XGrammarGrammar.rollback: drops exactly the last k accepted tokens,
k=0 is a no-op, truncation happens in place (regression for #31711 —
the old slice-copy was O(output_len) per call on the EAGLE spec-decode
hot path and `[:-0]` cleared the whole token history).
- Real matcher coverage: empty/full rollback, accept/rollback/accept cycles,
and oversized rollback rejection without changing either history.

Usage:
python -m pytest test_xgrammar_backend.py -v
"""

import unittest

import xgrammar as xgr

from sglang.srt.constrained.xgrammar_backend import XGrammarGrammar
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase

register_cpu_ci(2.0, "base-a-test-cpu")


def _make_grammar(tokens):
"""Build a real digit grammar without a downloaded tokenizer or model."""
tokenizer_info = xgr.TokenizerInfo(
[str(i) for i in range(10)] + ["<eos>"], stop_token_ids=[10]
)
ctx = xgr.GrammarCompiler(tokenizer_info, max_threads=1).compile_grammar(
"root ::= [0-9]*"
)
grammar = XGrammarGrammar(
matcher=xgr.GrammarMatcher(ctx),
vocab_size=11,
ctx=ctx,
override_stop_tokens=None,
key_string="test",
)
for token in tokens:
grammar.accept_token(token)
return grammar


class TestXGrammarGrammarRollback(CustomTestCase):
"""Test XGrammarGrammar.rollback token-history bookkeeping (#31711)."""

def test_rollback_drops_last_k_tokens(self):
grammar = _make_grammar([1, 2, 3, 4, 5])
grammar.rollback(2)
self.assertEqual(grammar.accepted_tokens, [1, 2, 3])

def test_rollback_zero_on_empty_history(self):
grammar = _make_grammar([])
original = grammar.accepted_tokens
grammar.rollback(0)
self.assertIs(grammar.accepted_tokens, original)
self.assertEqual(original, [])

def test_rollback_zero_is_noop(self):
"""rollback(0) must keep the history: `[:-0]` used to clear it."""
grammar = _make_grammar([1, 2, 3])
grammar.rollback(0)
self.assertEqual(grammar.accepted_tokens, [1, 2, 3])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure the robustness of the rollback implementation, we should add unit tests covering edge cases such as negative values of k and values of k larger than the length of the accepted tokens history.

Suggested change
def test_rollback_negative_k_raises_error(self):
grammar = _make_grammar([1, 2, 3])
with self.assertRaises(ValueError):
grammar.rollback(-1)
def test_rollback_large_k_clears_history(self):
grammar = _make_grammar([1, 2, 3])
grammar.rollback(5)
self.assertEqual(grammar.accepted_tokens, [])

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expanded the tests in 567cb90 to use a real xgrammar 0.2.1 digit grammar and local vocabulary. Oversized rollback is rejected by the native matcher before state mutation, so a test expecting it to clear history would encode incorrect semantics. The new test asserts RuntimeError, unchanged Python list identity/contents, and a subsequent valid rollback to verify native history is intact. Added full/empty rollback and accept-rollback-accept coverage as well: seven real-matcher tests pass on the refreshed candidate; the same tests give 4 failures and 3 passes on unpatched main. Negative-input validation is intentionally not introduced as part of this narrow fix (see the companion reply). The PR body now has fresh CPU test, lint, and bounded benchmark evidence.

def test_rollback_truncates_in_place(self):
"""The spec-decode tree traversal calls rollback(1) per draft-tree
node; the history must be truncated in place, not slice-copied."""
grammar = _make_grammar([1, 2, 3, 4])
tokens_before = grammar.accepted_tokens
grammar.rollback(1)
self.assertIs(grammar.accepted_tokens, tokens_before)
self.assertEqual(tokens_before, [1, 2, 3])

def test_rollback_all_tokens_keeps_list_identity(self):
grammar = _make_grammar([1, 2, 3])
original = grammar.accepted_tokens
grammar.rollback(3)
self.assertIs(grammar.accepted_tokens, original)
self.assertEqual(original, [])

def test_accept_rollback_accept_cycle(self):
grammar = _make_grammar([1, 2, 3, 4, 5])
grammar.rollback(2)
grammar.accept_token(6)
grammar.accept_token(7)
self.assertEqual(grammar.accepted_tokens, [1, 2, 3, 6, 7])
grammar.rollback(5)
self.assertEqual(grammar.accepted_tokens, [])
with self.assertRaisesRegex(RuntimeError, "Intended to rollback"):
grammar.rollback(1)

def test_oversized_rollback_preserves_history(self):
grammar = _make_grammar([1, 2, 3])
original = grammar.accepted_tokens
with self.assertRaisesRegex(RuntimeError, "Intended to rollback"):
grammar.rollback(4)
self.assertIs(grammar.accepted_tokens, original)
self.assertEqual(original, [1, 2, 3])
# A rejected rollback must also leave the native matcher usable.
grammar.rollback(3)
self.assertEqual(grammar.accepted_tokens, [])


if __name__ == "__main__":
unittest.main()
Loading