Conversation
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a332f5b. Configure here.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 91db6bf843
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| XGRAMMAR_CHECK(retrieve_next_token->shape[tree_node_dim] == num_nodes) | ||
| << "The retrieve_next_token node dimension must match draft_tokens"; | ||
| XGRAMMAR_CHECK(retrieve_next_sibling->shape[tree_node_dim] == num_nodes) | ||
| << "The retrieve_next_sibling node dimension must match draft_tokens"; |
There was a problem hiding this comment.
Validate draft-tree link indices before dispatch
When either tree tensor contains a child or sibling value other than -1 or a valid node index, batch validation still succeeds and TraverseDraftTreeRecursive uses that value directly to index the token, tree, and bitmask buffers. A malformed request can therefore cause an out-of-bounds native read/write and crash the process instead of raising a validation error; validate every selected/shared tree row's link values before scheduling workers.
Useful? React with 👍 / 👎.
| token_bitmask_ptr, | ||
| time_threshold | ||
| time_threshold, | ||
| static_cast<int32_t>(root_position) |
There was a problem hiding this comment.
Reject root positions outside the int32 range
When Python supplies a root_position that fits in int64_t but not int32_t, this unchecked narrowing can turn it into an unrelated valid node—for example, 2**32 commonly becomes zero—so traversal silently uses the wrong root instead of reporting an out-of-range argument. Check the value against the int32_t bounds before casting it.
Useful? React with 👍 / 👎.
| XGRAMMAR_CHECK(matcher_by_row[index] == -1) | ||
| << "The index " << index << " is assigned to more than one matcher"; | ||
| matcher_by_row[index] = i; |
There was a problem hiding this comment.
Reject aliases of the same matcher before parallel traversal
When the matcher list contains the same GrammarMatcher more than once with different row indices—for example, Python passes [matcher, matcher]—the unique-index check succeeds even though copied matchers share the same PImpl. Separate workers then concurrently call AcceptToken, Rollback, and mask generation on the same parser state and scratch buffers, causing a data race that can corrupt the matcher or produce invalid masks; reject duplicate implementation pointers before dispatch.
Useful? React with 👍 / 👎.

Important
Superseded by upstream PR mlc-ai/xgrammar#740. Review and discussion have moved there.
Summary
This packages three related serving-path improvements: a native batched speculative-decoding traversal API, reusable bounded worker pools for matching and compilation, and compact compiled-grammar serialization. It also adds fail-closed validation, regression coverage, integration documentation, and a reproducible synthetic benchmark.
Guided review
Tip
Short on review time? Follow the numbered path below. It moves from the public contract, through the two concurrency-sensitive implementations, into the cache-format change, and ends at the tests that lock each behavior down. The source links are pinned to the reviewed commit, so they will not drift as the branch evolves.
This change is easiest to understand as one request moving through the system: Python describes a speculative draft tree, the FFI hands tensor views to C++, native workers traverse that tree and write token masks, and the compiler/cache layer makes the grammars feeding those matchers cheaper to build and store.
Review map
flowchart LR A["Python engine integration"] -->|"tokens, tree, roots, output"| B["TVM FFI"] B --> C["BatchGrammarMatcher validation"] C -->|"tensor views only after all checks pass"| D["Persistent native worker pool"] D --> E["Per-request draft traversal"] E --> F["Flattened token-mask rows"] G["GrammarCompiler"] -->|"compiled grammar"| E G --> H["v16: one complete FSM + compact rule views"] H -->|"deserialize and validate"| E1. Begin with the serving-engine contract
The public API change is deliberately small. The scalar matcher gains a
root_position, while the batch matcher gains one operation that accepts all active requests at once. Start with the C++ declarations and the batch method contract, then read the corresponding scalar Python wrapper and batch Python wrapper.In plain language, a caller can now provide:
indices;The result is one completion flag per matcher. The output layout stays flat so C++ can write directly into the engine-owned bitmask without assembling temporary Python objects:
The TVM FFI binding is intentionally mechanical: it forwards already-described tensors and options into the native matcher. That keeps policy and validation in one C++ implementation rather than duplicating it across language boundaries.
Important
The batch API is additive. Existing scalar callers keep their current path, and
root_position=0preserves the previous behavior.Reviewer lens: public API
2. Establish correctness in the scalar primitive first
The batch implementation is built on the same scalar traversal primitive, so the next stop is
TraverseDraftTreeRecursive. It walks the draft tree depth-first, accepts each edge token against the grammar state, emits the allowed-token mask at that node, and rolls the matcher state back before visiting the next sibling. That rollback is what prevents one draft branch from contaminating another.Two correctness details were made explicit before batching:
byte_offset. A tensor can be contiguous while still beginning at a nonzero storage offset; ignoring that field writes a valid mask to the wrong part of the allocation.This scalar path is both the compatibility path and the correctness oracle used by the batch parity tests.
Note
“Contiguous” describes strides, not necessarily the beginning of storage. The nonzero-
byte_offsetregression is therefore a real tensor-layout case, not an artificial malformed input.3. Lift that primitive into one native batch operation
Now read the full batch implementation. It has three phases, and their order is part of the safety argument.
3.1 Validate everything before scheduling anything
Lines 1940–2026 reject wrong devices, dtypes, dimensions, row counts, roots, duplicate indices, and output sizes. No worker is launched until every request is known to be valid.
That fail-closed ordering matters: if request 47 is malformed, requests 0–46 must not have partially advanced their matcher states or written partial masks.
3.2 Build zero-copy per-request views
Lines 2028–2073 select each request’s tree, tokens, and output region by rebasing lightweight DLTensor views. The implementation does not copy the draft tensors merely to batch them. It also preserves the original storage offset while calculating each row’s address, which is the batched counterpart of the scalar offset fix.
3.3 Schedule chunks, not individual requests
Lines 2075–2099 divide the selected requests into at most one task per worker. Each task resets its output rows and traverses its contiguous chunk. This makes two formerly separate operations—mask clearing and traversal—one worker pass and avoids queueing one tiny task per request.
The pool itself is created once in the
BatchGrammarMatcherconstructor. The same chunking principle is also used by batched next-token mask generation, so both hot paths avoid per-call thread construction and per-request scheduling overhead.sequenceDiagram participant P as Python caller participant B as Batch matcher participant W as Persistent workers P->>B: tensors + indices + roots + output B->>B: validate the complete batch B->>B: create zero-copy row views B->>W: one chunk per active worker par worker chunks W->>W: clear owned output rows W->>W: traverse owned requests end W-->>B: completion flags / first exception B-->>P: one result per matcherReviewer lens: native batch path
The important invariants are:
4. Verify the persistent worker pool’s failure semantics
Reusing threads is only an optimization if a failed task cannot poison later calls. The central behavior lives in
ThreadPool.Workers catch task exceptions and record the first one while still marking the task complete. Then
Wait()waits for the submitted generation, rethrows that exception on the caller thread, and clears it. Clearing after observation is the key reuse property: the next independent batch can run normally instead of seeing a stale failure.The convenience
ParallelForwaits before joining, so an exception cannot be lost during pool teardown.Warning
This code intentionally does not fall back to serial execution after a worker failure. The operation fails at its boundary, all outstanding work is drained safely, and the reusable pool remains valid for the next call.
The native pool tests exercise repeated waits,
Execute,Submit, worker exceptions, subsequent reuse, andParallelForpropagation. The matcher-native regressions additionally prove that a batch exception reaches the caller and that the same matcher pool works afterward.5. Apply the same bounded-concurrency model to compilation
Grammar compilation used to risk paying thread setup repeatedly, especially when several outer callers compiled grammars concurrently. The compiler now owns one bounded pool for its lifetime, so concurrent callers share the same native capacity instead of multiplying it.
The scheduling flow is:
This produces bounded parallelism without changing the compiled result. The concurrent shared-compiler stress test compares serial and parallel output while many callers reuse one compiler.
6. Read the serialization change as normalization, not compression magic
The previous optimized representation serialized overlapping FSM data once per rule. Large grammars therefore repeated the same edge auxiliary data hundreds of times. Version 16 stores the normalized structure instead:
CompactRuleFSMViewrecords only a rule’s start state, end states, DFA flag, and size metadata.The deserializer checks view count, required complete-FSM presence, start/end bounds, sorted unique end states, and recorded sizes before constructing anything. Nested grammar and adaptive-mask failures are propagated, rather than turning malformed child data into an apparently successful outer object. The format identifier is correspondingly bumped to v16.
Caution
This is an intentional on-disk cache break. A v15 compiled-grammar cache must be cleared during upgrade; silently interpreting it as v16 would be unsafe. The migration expectation is documented in the serialization guide.
The measured 32.55× reduction in this PR comes from eliminating structural duplication; matching behavior is expected to remain identical after a round trip.
7. Finish with the executable contracts
The tests are organized around failure modes a serving-engine reviewer should care about:
ThreadPoolgenerations andParallelForexceptionsSuggested final review checklist
byte_offset.Note
After this guided pass, the benchmark and validation sections below provide the quantitative results and exact commands/suite outcomes. The generated Cursor review summary remains at the bottom as an independent high-level map.
Relationship to open upstream work
Note
This is a point-in-time audit of the open
mlc-ai/xgrammarpull requests performed on 2026-07-27. PR status and implementation details can change after this review.Executive takeaway
No open upstream PR found in this audit duplicates either the native batched draft-tree traversal API or the compact shared-FSM v16 serialization format. There is, however, one direct overlap in compiler-owned thread pooling and three meaningful adjacent changes that touch the same tensor, compiler, matcher, or serialization paths.
cpp/grammar_compiler.cc,cpp/support/thread_pool.h, pool testsfill_next_token_bitmaskstride-awarebyte_offsethandling for draft traversal. Both are needed.cpp/grammar_matcher.cctensor helpersDetailed compare/contrast
#438: persistent thread pool
Shared goal. Both changes replace per-compilation pool creation with one pool owned by each
GrammarCompiler, preventing concurrent outer callers from multiplying native worker pools.Different implementation. mlc-ai#438 introduces a
TaskCounter/callback-oriented pool API and substantially reshapes the existingThreadPoolinterface. This draft's compiler path keeps the existing submit/future model, compiles workloads smaller than 64 adaptive states inline, and drains all scheduled futures before captured compiler state can unwind. Its pool implementation additionally defines and tests worker-exception propagation and reuse after a failed generation; those failure/recovery semantics are not described in mlc-ai#438.Reviewer action. Treat these as alternate implementations of the same pool-lifetime change, not two independent features. Pick or consolidate one pool abstraction, then preserve the inline threshold, bounded concurrency, drain-before-unwind behavior, and exception/recovery tests from this draft whichever implementation becomes the base.
#699: stride-aware token-mask filling
Shared area. Both changes harden DLTensor address calculation near token-mask generation.
Different semantics. mlc-ai#699 uses
strides[0]when locating rows forfill_next_token_bitmaskand accepts safe row-strided layouts. This draft's scalar traversal helper and validation correctly apply the tensor's basebyte_offsetfor draft-tree traversal, while the new batch traversal deliberately requires contiguous inputs.A stride describes the distance between elements or rows;
byte_offsetdescribes where the logical tensor begins in its backing allocation. Fixing one does not fix the other.Reviewer action. Preserve both fixes when rebasing: retain mlc-ai#699's row-stride semantics for mask filling and this draft's base-offset correctness for scalar and batched draft traversal. The batch API should continue to reject unsupported non-contiguous traversal tensors explicitly rather than silently interpreting them as contiguous.
#722: compilation and token-mask work reduction
Shared area. mlc-ai#722 and this draft both touch
grammar_compiler.cc,grammar_matcher.cc,compiled_grammar.cc,grammar.cc, and grammar representation internals, so textual conflicts are likely.Different layer of optimization. mlc-ai#722 precomputes/shares Earley metadata, deduplicates equivalent adaptive-mask work and stored masks, and batch-proves token-prefix subtrees. It reduces how much work compilation performs and explicitly preserves the existing serialized format. This draft changes how that work is scheduled—one bounded compiler-owned pool, an inline cutoff for small jobs, futures, and safe draining—and its v16 representation intentionally changes the format so one complete optimized FSM is stored with compact per-rule views.
Reviewer action. These optimizations are conceptually composable despite the large merge-conflict surface. After combining them, rerun the 128-schema shared-compiler concurrency stress test plus serialization byte-equality, matcher-parity, corruption-rejection, and size benchmarks. mlc-ai#722's mask deduplication should be measured together with—not used as a substitute for—the shared-FSM serialization layout.
#731: on-demand token-mask compilation
Shared area. Both changes affect adaptive-mask compilation and serialization boundaries.
Different policy. mlc-ai#731 adds opt-in lazy mask compilation, populating an adaptive mask on first state visit and materializing the complete cache before serialization to preserve the existing format. This draft retains eager compilation semantics but makes large eager work bounded and persistent-pool-backed; its cache-format change concerns the optimized FSM structure rather than when adaptive masks are created.
Reviewer action. If the two are combined, test concurrent first-use mask population through multiple matchers and verify that serialize/deserialize still materializes and validates every required mask under v16. The native batch draft path should also be exercised against lazily populated masks because it increases concurrent matcher pressure.
What appears unique to this draft
The open-PR audit did not find another implementation of:
BatchGrammarMatcher.batch_traverse_draft_treeoperation with shared/per-request trees, sparse indices, per-matcher roots, flattened output ownership, and validation-before-scheduling;byte_offsethandling specifically for scalar and batched draft traversal;The batch API builds on the scalar traversal foundations already merged in #490 and #613; it lifts that work into a serving-engine-oriented native batch boundary rather than replacing it.
Other adjacent optimization PRs
The current cold-compilation/converter optimization stack—#711, #724, #727, #729, and #735—reduces parsing, converter, FSM construction, and rule-reuse costs. Those PRs are complementary rather than duplicates of the batch traversal, persistent scheduling semantics, or compact serialization introduced here, although rebases may conflict in compiler and grammar-representation files.
Suggested integration checklist
byte_offsetcorrectness.Changes
Batched speculative decoding
BatchGrammarMatcher.batch_traverse_draft_treeacross the C++, TVM FFI, and Python APIs.indices, and per-matcherroot_positions.(batch_size * num_nodes, mask_words)bitmask.traverse_draft_treewithroot_positionand correctly honors DLTensorbyte_offsetfor offset-backed contiguous tensors.BatchGrammarMatcherinstead of recreating and joining threads on every call.Bounded persistent grammar compilation
GrammarCompiler, shared by concurrentcompile_*calls.Compact, validated serialization
v15tov16.[start, ends, is_dfa, edge_num, node_num]view.Tests, docs, and benchmark
ParallelForpropagation.examples/benchmark/bench_batch_draft_tree.py, which is self-contained and downloads no model.Performance results
Synthetic draft-tree benchmark, 96 requests × 6 nodes, vocabulary 101, median of 500 iterations in the same container:
Compiled serialization for the same 256-field schema and 259 optimized rules:
edge_aux_datacopiesmain(v15)v16)That is a 96.93% size reduction (32.55× smaller) with exact serialize → deserialize → serialize equality and functional matcher parity.
An additional stress gate compiled and deserialized 128 distinct large schemas through one shared compiler with 16 concurrent callers and
max_threads=12; serial/parallel output parity and all round trips passed.Validation
pre-commit run --all-files: passruff check python tests examples/benchmark/bench_batch_draft_tree.py: passshiftup-ai/xgrammar:mainin the identical disposable environment; none touch code changed here.Compatibility note
The compact representation intentionally changes the serialization version to
v16. Existingv15on-disk compiled-grammar caches must be cleared after upgrading, consistent with the documented versioning contract.Note
High Risk
Touches core matcher/compiler concurrency, serialization format bump to v16 (cache break), and speculative decoding correctness; regressions would affect token masks or compiled grammar loads at scale.
Overview
Title
Batch draft traversal, persistent worker pools, and compact grammar caches (serialization v16)
Intent
Serving workloads need speculative draft-tree mask generation and grammar compilation to scale without per-call thread churn or multi-megabyte serialized caches. This PR packages those paths behind native batch APIs, shared bounded pools, and a compact FSM representation while failing closed on corrupt serialized data.
Details
BatchGrammarMatcher.batch_traverse_draft_tree(C++, FFI, Python) traverses one tree per matcher, supports shared or per-request 2D trees, sparseindices, per-matcherroot_positions, and flattened(batch_size × num_nodes)mask rows; scalartraverse_draft_treegainsroot_positionand honors DLTensorbyte_offset.BatchFillNextTokenBitmaskreuses one pool perBatchGrammarMatcher, chunks work per worker, and propagates worker exceptions; newBatchTraverseDraftTreevalidates contiguity, dtypes, shapes, unique indices, and roots before scheduling.GrammarCompilerkeeps one persistentThreadPool; small grammars inline adaptive mask work below a state threshold; large work usesSubmitwith drain-on-exception semantics.complete_fsmplus per-rule compact views[start, ends, is_dfa, edge_num, node_num]; deserialization validates counts, bounds, sorted unique ends, and nested grammar/mask errors;CompiledGrammardeserialization checks nested failures.Executetasks record first exception;Wait()rethrows and clears;ParallelForcallsWait()beforeJoin().bench_batch_draft_tree.py.Roadmap for Reviewers
include/xgrammar/matcher.handpython/xgrammar/matcher.pyfor the public API surface (root_position,batch_traverse_draft_tree).cpp/grammar_matcher.cc— core traversal, bitmask offsets, batch validation, and pool chunking (large file; focus onTraverseDraftTreeRecursive,BatchTraverseDraftTree,BatchFillNextTokenBitmask).cpp/grammar_compiler.cc— persistent pool,kMinParallelStates, and exception-safe scheduling.cpp/grammar.cc+cpp/grammar_impl.h— compact serialize/deserialize and view reconstruction overcomplete_fsm.cpp/support/thread_pool.h— exception propagation and pool reuse (small, high leverage).cpp/compiled_grammar.cc— propagated deserialize errors (quick skim).cpp/tvm_ffi/tvm_ffi.cc— FFI bindings (mostly mechanical).tests/python/test_speculative_decoding.py,tests/python/test_serialization.py,tests/cpp/test_grammar_matcher.cc— skim for coverage intent.Diagrams
Reviewed by Cursor Bugbot for commit 91db6bf. Bugbot is set up for automated code reviews on this repo. Configure here.