Skip to content

grammar: grammar-constrained decoding on the Zig side over xgrammar 0.2.8's C++ core (DeepSeek-V4.1 stack 5/14) - #638

Closed
jayleaton wants to merge 7 commits into
ashhart:mainfrom
jayleaton:stack/05-grammar
Closed

jayleaton wants to merge 7 commits into
ashhart:mainfrom
jayleaton:stack/05-grammar

Conversation

@jayleaton

Copy link
Copy Markdown
Contributor

Part 5 of 14 of the DeepSeek-V4.1 Zig engine stack.

Depends on #637; review only the commits new in this layer:

  • a5fda7a6 Grammar-constrained decoding on the Zig side: xgrammar 0.2.8's C++ core and a grammar module over it.

What it adds and why

Grammar-constrained decoding for the Zig engines: JSON, JSON schema, regex, choice, grammar and tool-call tag
constraints compiled by xgrammar 0.2.8's C++ core, with a token mask per row. DeepSeek-V4.1 uses it for structured output and its tool-call tags.

How

  • zig/vendor/xgrammar/: xgrammar 0.2.8's C++ core, unmodified; provenance in its README and THIRD_PARTY_NOTICES.md,
    credit in NOTICE.
  • zig/src/core/grammar/: a small C ABI (xgr_c.cc / .h) and Zig modules over it (grammars, constraint, rows,
    pack, tags, vocab), built by zig/build/grammar.zig.
  • golden_test.zig walks checked-in goldens (zig/tests/grammar/fixtures/) step by step.

Test evidence

Host only: Linux x86_64, Zig 0.17.0, one job at a time with -j4, a fresh empty --cache-dir for every run. Base is main at 38de96bf.

python3 tools/zig/lean_check.py
zig fmt --check <the Zig files this stack adds or changes>
zig build -j4
zig build test -j4
Check Base main This layer
lean_check.py 542 problems 542 problems, all inherited from main: 0 new fingerprints
zig fmt --check 59 files, 0 unformatted
zig build 8/8 steps 12/12 steps
zig build test 37/37 steps; 400/400 tests passed 46/46 steps; 463/464 tests passed (1 skipped)

Not run: test-golden (not available on Linux, base main included), any GPU or device step and any checkpoint gate. This code, before its rebase onto current main and the generic renames, serves DeepSeek-V4.1 in production on two DGX Spark nodes; this tree itself has had no GPU run.

Decisions for the maintainer

  • Vendor exemption. tools/zig/lean_check.py gets an exact-prefix exemption for zig/vendor/xgrammar/ (with a
    neighbour test in lean_check_test.py), so the vendored C++ is not held to the Zig lean rule; the wrappers are
    checked normally. Please confirm a vendored tree with this exemption is acceptable.
  • Golden generator. zig/tests/grammar/gen_goldens.py wrote the goldens from a Python reference grammar module
    that is not in this repository. The goldens are checked in, so the test runs without it. Keep the script as
    provenance, or drop it?

…alls them.

graph_cache.Cache captures one executable graph per caller-named key and replays it when the key comes back; ranks agree on every capture, eviction and fallback through an Agree hook, and a memory floor turns misses eager.
arena.Arena sub-allocates DeviceBuffer.alloc from a few large chunks (VMM or plain cuMemAlloc) through memory.setHook; borrowed spans and pointers it does not own are freed as before.
…; CUresult names fall back to a table when the driver cannot answer.

cuFuncGetName is optional (CUDA 12.3+), so an older driver still opens. During
the exit's teardown cuGetErrorName fails too, which printed
CUDA_ERROR_DEINITIALIZED as "CUDA_ERROR_UNKNOWN_CODE (4)"; code 4 now says the
process is exiting.
…d RoCE collectives, plan link, fail-fast.

zig/src/fabric/tp brings a rank up from TF_TP_WORLD, TF_TP_RANK, TF_TP_MASTER and TF_TP_PORT, agrees the settings
digest over every rank, and gives a family all-gather, all-reduce and broadcast over NCCL (TF_COMM_BACKEND=nccl,
the default), a pinned host mailbox for ranks on one host, or RoCE verbs between two hosts (TF_TP_ROCE_*), with an
NCCL fallback when RoCE setup fails. Rank 0 sends each round's plan to the others over its own link (TF_TP_PLAN_*).
A failure or a vanished rank ends every rank shortly after (TF_TP_FAILFAST, TF_TP_FAILFAST_GRACE_S).
No family uses it yet. zig build test-tp runs the host tests; zig build tf-tp-test builds the two-GPU runner
(-Dtp-nvcc= or -Dtp-fatbin= for the mailbox kernel). The RoCE all-gather adapts b12x's RoCEnante protocol.
… the stop word with no fail-fast exit; a rank killed mid-drain still fails the other fast.
…idency budget, streamed NVMe tier.

zig/src/sessions keeps a chat's KV pages after its request ends: entries are found by a trie over chained page
digests, held in RAM within a residency budget (chats and pool bytes), and the least recently used idle chat is
parked to an NVMe tier and streamed back on resume. Ranks that split KV run every store call in the same order, so
their books stay identical. It complements core/prompt_cache, which snapshots a whole family state; a family that
uses it reports the resident bytes as Memory.cache and its budget as Info.prompt_cache_plan. No family uses it yet;
zig build test-sessions runs the host tests, the NVMe tier on real files.
…re and a grammar module over it.

zig/vendor/xgrammar holds XGrammar 0.2.8's C++ sources (tag v0.2.8, commit 97787376), byte-identical to the release with
no local patches; its README records the version, commit and patch list. Zig's C++ toolchain builds them into a static
library with a small C ABI (zig/src/core/grammar/xgr_c.cc). The grammar module compiles a request's spec (JSON, JSON
schema, regex, choice, EBNF, or a tools spec as a structural tag), keeps one matcher a reply, fills token bitmasks for
drafted rows and cuts a draft where the grammar ends, packs a spec for other ranks, and carries a PTX mask kernel for
logits rows. zig build test-grammar checks masks, cuts, compiles and refusals against goldens recorded from xgrammar's
Python package on a synthetic tokenizer. No family uses it yet.

lean_check.py skips exactly the vendored prefix (VENDORED, beside the Flash Next generator outputs), so the upstream
copy can stay byte-identical and be updated by replacing it. lean_check_test.py shows the prefix is skipped and that
files right beside it, our grammar wrappers included, are still checked.
@ashhart

ashhart commented Oct 11, 2026

Copy link
Copy Markdown
Owner

Thank you, @jayleaton, for this layer and for keeping the vendored copy byte for byte with its provenance written down.

We can't take the vendored core, and it's a rule, not your code: outside libraries come in only at runtime, as an
optional library built from a pinned release and loaded with dlopen, with nothing of theirs in the tree (#564). For
xgrammar that library is libtfgrammar from #596, which lands in 1.0.6 with Nemotron enforcing grammars on Metal and
CUDA, tool_choice: "required" and named functions as grammars, and the library in each release archive's lib/.

What your layer needs from DeepSeek-V4.1 fits on that path, and we'd like to carry it there ourselves if the family
lands, crediting you on those commits:

  • masks over one rank's vocabulary columns for TP=2 (your rows.span): the lane core already hands the backend one
    bitmask per row, so each rank masks its own columns;
  • the tool-call tag, from xgrammar's DeepSeek-V4.1 structural tag style;
  • golden masks from xgrammar's Python package checked in a test that runs where the library is built.

One idea of yours is in 1.0.6: a grammar row that allows no token now fails the reply instead of reaching the draw.

@jayleaton

Copy link
Copy Markdown
Contributor Author

Thanks, that is clear, and thank you for offering to carry the DeepSeek-V4.1 pieces on libtfgrammar. I'm closing this in favour of #596.

Nothing later in the stack uses this layer: the remaining layers rebase onto main without it (two lines of build.zig change), and their PRs will depend on #637 instead.

When the family lands, the three pieces you list are what it needs. One addition for TP=2: rank 1 has to get its half of each row's bits too, over the plan link. For the record, what the family's grammar path needs beyond main today:

  • TP=2: rank 1's half of each row's mask over the plan link (or a matcher replicated on rank 1).
  • A per-rank column offset in the mask kernel.
  • The DSML tool-call tag: a DSML style plus a family hook, since tool_grammar requires <tool_call> markup today.
  • tool_choice: "auto" constrained.
  • Tokenizer views: two views plus a "never" set of special tokens, to be confirmed with goldens for the DSML and think tokens.

Happy to hand over the DSML tool-call tag builder, the column masking and the golden generator, and to test the result on two Sparks. Glad the empty-row refusal made it into 1.0.6.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants