-
Notifications
You must be signed in to change notification settings - Fork 8.9k
fix: truncate xgrammar accepted_tokens in place on rollback #31728
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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]) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To ensure the robustness of the
Suggested change
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is a good practice to defensively guard against negative values of
kto prevent unexpected behavior or cryptic errors from the underlying C++ matcher. Adding a check fork < 0and raising a clearValueErrorimproves robustness.There was a problem hiding this comment.
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