Conversation
rollback(k) rebuilt the whole accepted-token history with a slice-copy (accepted_tokens[:-k]), which is O(output_len) per call — and the EAGLE spec-decode tree traversal calls rollback(1) once per draft-tree node on the scheduler hot path. The slice form also made rollback(0) clear the entire list ([:-0] == [:0]). Use guarded in-place deletion instead: O(k) work per call and a safe no-op for k=0. Fixes sgl-project#31711
There was a problem hiding this comment.
Code Review
This pull request optimizes the rollback method in XGrammarGrammar to truncate the accepted tokens list in place rather than using a slice-copy, which avoids O(len) overhead and fixes a bug where k=0 cleared the entire history. It also introduces a comprehensive unit test suite for this behavior. The reviewer recommended adding defensive validation to raise a ValueError if k is negative, along with corresponding unit tests to cover negative values of k and values of k larger than the history length.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| 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:] |
There was a problem hiding this comment.
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.
| 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:] |
There was a problem hiding this comment.
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
| grammar = _make_grammar([1, 2, 3]) | ||
| grammar.rollback(0) | ||
| self.assertEqual(grammar.accepted_tokens, [1, 2, 3]) | ||
|
|
There was a problem hiding this comment.
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.
| 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, []) | |
There was a problem hiding this comment.
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.
|
Per the PR Merge Process, pinging Merge Oncalls to start the process — the assignment bot doesn't seem to have picked this one up (no assignee, 4 days). @hnyls2002 @Qiaolin-Yu (speculative decoding — the hot caller is One-line summary: |
|
Update after the branch refresh (2026-09-04): head is now 567cb90. Upstream main was merged without rewriting history; the required minimum base is now an ancestor, and GitHub reports no merge conflict. Fresh local evidence is in the PR body: 7 real-xgrammar regression tests pass; the same tests fail 4/7 on unpatched main. The CPU constrained suite passes 136 tests (3 CUDA skips; GPU model E2E explicitly not run locally), and all applicable pre-commit hooks pass. The existing reviewer requests target the constrained CODEOWNERS. Both inline bot suggestions now have replies grounded in the pinned matcher implementation. The newly triggered base CI gate still exits with Missing required label run-ci; base-a-test-cpu is skipped. This is a permission gate, not a regression-test failure. Full upstream tests and formal review remain pending. Could a maintainer enable run-ci for the refreshed branch and review this small bookkeeping change? No workflow or matcher-validation changes were made. Thanks! |
|
@DarkSharpness @hnyls2002 @JustinTong0323 — a brief follow-up on the September 4 refresh: could one of you review this rollback bookkeeping fix and enable Head |
Motivation
Fixes #31711
XGrammarGrammar.rollback()slice-copies the entire retained accepted-token history on each rollback. The speculative-decoding tree traversal callsrollback(1), so the Python bookkeeping cost grows with output history. Also,[:-0]unexpectedly clears the history for a zero-token rollback.Modifications
matcher.rollback(k)first, then truncate Python history in place withif k > 0: del self.accepted_tokens[-k:].CustomTestCase,unittest.main(), andregister_cpu_ci(2.0, "base-a-test-cpu").dae126d510b786a2424684c17ca4e5e70d74e8f7without rewriting the original PR history. This snapshot includes minimum basea5f07b1241fc2c2f38058850c377a35066485ad3.567cb90c002db4b21b6ab8a767348161c0b6fd59.Accuracy Tests
Local environment: Linux amd64 Docker emulated on Apple Silicon, Python 3.12.14, torch 2.13.0+cpu, xgrammar 0.2.1, pytest 9.1.1. These are actual production imports and real matcher calls, not substituted modules.
The same seven regression tests against the unpatched upstream snapshot: 4 failed, 3 passed. Failures reproduce zero rollback clearing history and history reallocation.
Refreshed candidate, CPU constrained tests:
136 passed, 3 skipped, 20 subtests passed. The three skips require CUDA. The explicitly excluded E2E file launches a GPU model server; it was not run locally.
Direct CI-style entry point:
python test/registered/unit/constrained/test_xgrammar_backend.py -v— 7 passed.pre-commit run --files python/sglang/srt/constrained/xgrammar_backend.py test/registered/unit/constrained/test_xgrammar_backend.py— passed (exact repository hooks; full registered-test tree present for registry validation).git diff --check— passed.Oversized rollback is intentionally tested as rejection, not successful history clearing: xgrammar 0.2.1 checks saved history before modifying matcher state (native implementation).
This is focused CPU verification, not the full upstream CUDA CI result. Maintainer-enabled CI and formal review are still required; no approval or merge is claimed.
Speed Tests and Profiling
Python-bookkeeping-only microbenchmark of the real
XGrammarGrammar.rollback(1)method. The native matcher is replaced only for this timing measurement with a no-op boundary, and list construction is outside the timed interval. Median of 21 samples per size, same Linux/Python environment above:This isolates removal of the retained-history copy; it is not end-to-end decoding throughput, and does not claim constant cost for arbitrary
k. Emulation, scheduling and memory effects affect the timings.Reproduction script (run with each checkout on PYTHONPATH)
Checklist
Documentation update is not applicable to this internal bookkeeping fix.
Disclosure: this fix and follow-up were prepared with AI assistance. Reported results are from actual local executions; upstream approval is not implied.
CI States
Latest PR Test (Base): ❌ Run #33856594474
Latest PR Test (Extra): ❌ Run #33856593952
Latest PR Test (AMD ROCm 7.2): ❌ Run #33856594395