Conversation
There was a problem hiding this comment.
Code Review
This pull request updates the grammar matcher to support non-contiguous bitmasks and logits by correctly respecting strides instead of assuming a compact layout. It also adds comprehensive tests to verify row addressing across various memory layouts. The review feedback highlights potential security and memory safety issues, suggesting the addition of bounds checks on indices and validation of row strides to prevent overlapping memory writes or out-of-bounds accesses.
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.
aba4425 to
4145138
Compare
fill_next_token_bitmask addressed bitmask rows as `data + index * buffer_size`, assuming a compact layout, while apply_token_bitmask_inplace addresses them as `data + idx * strides[0]`. For a non-contiguous bitmask the two disagree: the mask is written to one row and read from another, leaving the requested logits row entirely -inf. Nothing raises, and the shape checks cannot catch it because a strided view has exactly the same shape as a contiguous one. apply became stride-aware in mlc-ai#359 (CUDA) and mlc-ai#390 (CPU/Triton); fill never followed. Before mlc-ai#390 both sides assumed the compact layout and therefore agreed, so non-contiguous bitmasks worked end to end -- this is a regression first released in v0.1.23. Address rows by strides[0] when strides are present, matching apply. All three callers of CheckAndGetBitmaskPtr are covered by the one change. Since a row is read as a single packed DynamicBitset, the vocabulary dimension must remain unit-stride; reject anything else rather than silently misreading it, matching the constraint mlc-ai#359 already documents for CUDA. Also fall back to the compact layout when strides is NULL in ApplyMask32Bits and ApplyMask16Bits, which dereferenced it unconditionally. The vendored DLPack is v1.0, where NULL strides is legal and means compact, and that header ships in the package's include dir.
Address code-review feedback: a row spans shape[-1] elements, so a row stride smaller than that overlaps adjacent rows and lets a write run past the buffer (the OOB case noted for stride(0) < buffer_size). Reject such layouts with a clear error in CheckAndGetBitmaskPtr (fill) and, symmetrically, in ApplyMask32Bits/ApplyMask16Bits (apply), rather than corrupting memory. Only guard when there is more than one row: a single row cannot overlap and legitimately carries an unconstrained stride (e.g. a (1, vocab) logits view with stride0 == 1), which a naive ndim==2 guard would wrongly reject.
4145138 to
8eb2e0b
Compare
| int64_t row_stride = buffer_size; | ||
| if (token_bitmask.strides != nullptr) { | ||
| int64_t vocab_stride = token_bitmask.strides[token_bitmask.ndim - 1]; | ||
| XGRAMMAR_CHECK(vocab_stride == 1) |
There was a problem hiding this comment.
Maybe we can use xgrammar_log(fatal) here? Since it's a user's input error.
| // A row spans buffer_size int32s; with more than one row a smaller stride would overlap | ||
| // adjacent rows and let a write run past the buffer. A single row cannot overlap, whatever | ||
| // its stride, so only guard when rows > 1. Reject rather than corrupt memory. | ||
| XGRAMMAR_CHECK(token_bitmask.shape[0] <= 1 || row_stride >= buffer_size) |
| // span (shape[-1]) overlaps adjacent rows -- corrupting on the logits -inf write. A single row | ||
| // cannot overlap and legitimately carries an unconstrained stride, so only guard when rows > 1. | ||
| if (logits_shape.first > 1) { | ||
| XGRAMMAR_CHECK(logits_stride0 >= logits_shape.second) |
There was a problem hiding this comment.
ditto. xgrammar_check is used to check the correctness of inner logic, while xgammar_log(fatal) is used to check the users' inputs.
There was a problem hiding this comment.
Thanks — and no objection to the change itself: XGRAMMAR_LOG(FATAL) does read better for an input error, since it drops the Check failed: (cond) is false: prefix and leaves just the message. I have the conversion done locally for all six guards this PR adds.
Before I push it, though, I want to check one thing, because the header documents the mapping the other way round. In cpp/support/logging.h:
XGRAMMAR_CHECK(~:207) — documented as being for checking the correctness of user inputsXGRAMMAR_ICHECK(~:213) — documented as being for internal conditionsXGRAMMAR_DCHECK(~:220) — same, debug-only, and preferred overICHECK
Those comments came in with #354, and internal assertions in cpp/ mostly go through DCHECK rather than CHECK.
So I'd rather not guess. Which do you mean?
- The doc comments are stale and
XGRAMMAR_LOG(FATAL)is now the intended form for input validation — I push the conversion as is, and can update the header comments in the same PR or a follow-up if you want. - The guidance is really about the message format (no
Check failed:prefix for user-facing errors), in which caseXGRAMMAR_LOG(FATAL)is right here and the header could use a note. XGRAMMAR_CHECKis fine per the header and these should stay as they are.
Happy with any of them — just say which and I'll push accordingly.
Summary
fill_next_token_bitmaskaddresses bitmask rows asdata + index * buffer_size(compact layout),while
apply_token_bitmask_inplaceaddresses them asdata + idx * strides[0]. For anon-contiguous bitmask the two disagree: the mask is written to one row and read from another, and
the requested logits row comes out entirely
-inf. Nothing raises — it is silent. The shape checkcan't catch it:
bm[::2]and a contiguous bitmask have identical shapes; only the strides differ.Why this matters: non-contiguous bitmasks worked end to end before #390 — this is a regression,
not a new feature. The fix makes fill use
strides[0]too, so both sides agree on where a rowlives, as they did before.
Trigger:
bitmask.stride(0) != GetBitmaskSize(vocab_size). Row slicesbm[:n]are contiguous andsafe.
Reproduction (pure Python, public API, CPU — on
main/ 0.2.3)First two rows are negative controls:
This is a regression from #390 (first in v0.1.23), not a revert
Before #390, apply addressed rows as
idx * shape[1], and fill enforcesshape[1] == buffer_size(
grammar_matcher.cc:167), so both sides computed the same offset. #390 changed apply tostrides[0](correctly, to fix logits stride, vLLM#19493) but left fill unchanged, so the two
diverged. Building both versions and running the identical script confirms it:
The old version computes the correct mask (surviving
[0], not merely "not all -inf"). This isnot a revert of #390 — its logits-stride fix is correct and stays; the defect is that fill is
still not stride-aware, consistent with the direction #359 and #390 took (apply supports
non-contiguous bitmasks).
Changes (
cpp/grammar_matcher.cc)CheckAndGetBitmaskPtr— row offset becomesindex * row_stride, withrow_stride = strides ? strides[0] : buffer_size. All three callers(
fill_next_token_bitmask,_DebugGetMaskedTokensFromBitmask,_IsSingleTokenBitmask) gothrough this one function.
DynamicBitset, so havingtrusted
strides[0]the vocab dim must stay unit-stride; rejectbm[:, ::2]rather thansilently misread it (same constraint Support incontiguous logits and bitmask. #359 documents for CUDA). Verified to fire.
ApplyMask32Bits/ApplyMask16Bits— fall back to compact layout whenstridesis NULLinstead of dereferencing it. Vendored DLPack is v1.0, where NULL means compact, and that header
ships in the package's include dir, so a C++ caller can follow it, pass NULL, and segfault.
shape[-1]elements, so with morethan one row a stride smaller than that overlaps adjacent rows and lets a write run past the
buffer (the
stride(0) < buffer_sizeOOB case). Reject it with a clear error rather thancorrupt memory. Gated on row count > 1: a single row cannot overlap and legitimately carries an
unconstrained stride (e.g. a
(1, vocab)logits view withstride0 == 1).Tests
tests/python/test_token_bitmask_operations.py, next to #390's own stride test. Four parametrizedlayouts (
allocate/row_slicecontiguous controls;strided/col_croppednon-contiguous), plusguard tests for
bm[:, ::2](vocab stride) and an overlapping-row layout. The load-bearing one istest_fill_apply_round_trip_row_addressing: it passes pre-#390 and fails onmain, which is whatmakes the regression checkable.
Results (CPU, macOS): the whole file is 55 passed / 0 failed (57 skipped, CUDA/Triton/MLX);
full
tests/python/2456 passed / 0 failed.Scope / honesty
7aab6e2) and SGLang(
947a14d), reproduced each in standalone torch: 13 paths, 0 trigger — they pass the wholetensor + a row index, or a step-1 slice. But it's out of reach, not defended: a
fill(bitmask[i:i+1])refactor makes it active. (Secondary: withstride(0) < buffer_size, e.g.expand, it's an out-of-bounds write — contrived, raises the severity ceiling not theprobability; change 4 now rejects it with a clear error.)
and kills usages that worked pre-Fix apply_bitmask logit for both CPU and triton versions when shape and stride doesn't match #390. Happy to switch if maintainers prefer that contract.
strides is legal and means compact; apply dereferenced it unconditionally. It's a defensive
change, reachable only from C++. Happy to split it out.
indicespath (raised in review) is pre-existingbehaviour this PR doesn't touch, so I kept it out of a focused stride fix — happy to do it in a
follow-up.
handles non-contiguous bitmasks per Support incontiguous logits and bitmask. #359.
Related, none duplicate