Skip to content

dsv41: serve structured output with the lane core's grammar masks - #1665

Open
nuk3s wants to merge 7 commits into
ashhart:mainfrom
nuk3s:dsv41-structured-output
Open

nuk3s wants to merge 7 commits into
ashhart:mainfrom
nuk3s:dsv41-structured-output

Conversation

@nuk3s

@nuk3s nuk3s commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Closes #1664. Follow-up to #847, as @ashhart asked there.

On main, DeepSeek-V4.1 answers every structured request with HTTP 400: response_format, the guided_* fields, and tool_choice: "required" or a named function. Its lane host never sets .structures (the flag that tells the server it can run a grammar), and nothing fills the forward's grammar hook.

This branch wires dsv41 on CUDA into main's libtfgrammar path, with drafts on. Rank 0 (the process that serves HTTP) sends each pick's grammar bits to rank 1 over the plan link it already uses to drive rank 1. Each rank sets the blocked tokens in its half of the vocab to -inf before the pick. No new kernel. The message is op 28 by itself, and ops 29-31 stay free.

Testing on two DGX Sparks turned up two more bugs, and both fixes are in here. With prompt joins on (TF_DSV41_TAIL_JOIN), every grammar request with drafts on failed with HeldDraftsUnreadable, because dsv41 had no Backend.held. Now it has one (Lanes.heldChain). With ignore_eos, the grammar's stop token ended up in the text: {"key": "value"}<|end▁of▁sentence|>. Now, under a grammar, that token ends the reply with finish_reason stop and stays out of the text.

For that chat.zig fix, the commit loop moves the grammar phase one token at a time. The stream keeps main's scan of the whole landed chunk and replays the phase from the chunk's start (eos_phase.zig). So an end token in the reasoning and the grammar's stop can land in one chunk, and each one is judged at its own phase.

The first token: the lanes take the stream's first-token bits before the prompt pass, and the target draws the prompt's last row under them. first_masked then returns that pick only if the lane core hands it the same bits (lanes_first.zig), and it runs no forward and no draft.

Heads-up on open PRs that touch the same lines: #1720 and #1722 each add a Target vtable entry in iface.zig and a TimedTarget line in phases_timed.zig next to mine. #1722 also adds a line in Lanes.prefill. #1892 adds a line after the LaneHost.init call I changed. Each conflict is a line or two on both sides, so keep both.

Receipt

  • Environment, with the TensorFold commit, MLX or PyTorch version, machine and GPU, checkpoint and revision: this head facad850 on main 8d8e2d74. The GPU runs used 5eb46486 on main 45fcab6c, the same 7 commits before the last rebase (git range-diff changes only commit 1, in the two files that conflicted). 2x DGX Spark (GB10), TP=2 over RoCE, driver 580.178.04, CUDA driver API 13.0, Mia-AiLab/DeepSeek-V4.1-Flash-1017-EXL3-EAQ-v1-2.9-bpw @ 45ee5d0f. No MLX or PyTorch.
  • Exactness, from token_sha against "draft": false. With a grammar and TF_DSV41_TAIL_JOIN=1, json_object, json_schema and a sampled json_schema are equal drafted, with "draft": false and 4 at once. That holds on the first run, and on all 3 passes of a second run with drafts priced from the cost tables (TF_DSV41_DRAFT_TIMED=0) and one fixed cost table. In the second run the json_object prompt without a grammar is equal on all 3 passes too. A plain reply beside 3 structured ones equals its solo reply on the first run and in 2 of 3 passes on the second. The miss parts at token 3 on the rivers prompt, where main's own replies change between passes. With TAIL_JOIN=0, first run only: json_schema and the sampled json_schema are equal, and json_object splits at token 28 in 2 of 4 concurrent replies, where the mask allows both tokens. Cached against fresh: 8/8 with TAIL_JOIN=1 (second run, 2416 to 2736 cached tokens), 8/8 with 0 (first run), and 4/4 on prompts of 9631 to 9660 tokens that run in pieces. Required tools 3/3, ignore_eos 2/2, a sampled reply that differs from greedy 2/2, draft trees 6/7 (the miss is my tool's finish check on the binding grammar_budget; the fixed check has no GPU run yet). Without a grammar, 10 of 15 rows give main's tokens on every pass. The other 5 use the rivers prompt, whose replies part at token 3, and main's replies on it change between passes too. Still open: the TAIL_JOIN=0 json_object group and the fixed tree check. Both reruns failed the engine's pool check at boot.
  • Decode speed before and after, from tools/bench_openai.py: I didn't run bench_openai.py. Without a grammar, on the first run, rank 0's decode tok/s (its done line) on the same 565-token list reply was 112.0 on main and 111.7 on this branch with drafts on, 45.0 and 45.2 with "draft": false. Copy cost per masked row, from the grammar phase of TF_DSV41_PHASES=sync with drafts off: 0.047 ms on rank 0 over 1197 rows.
  • Prompt speed before and after: I didn't run bench_cache.py (it sends no response_format). Time to first token on the first run (rank 0's done line) of a cold 32768-token prompt: 12.6 s on main, 12.6 s on this branch. A cold 8626-token chat prompt: 3.5 s and 3.5 s. A request without a grammar sends and copies no mask.
  • Tests run, with pass, fail and skip counts: host (x86_64 Linux, Zig 0.17.0), on 8d8e2d74 with and without this branch. zig build test 6419 pass, 45 skip, 0 fail (main 6415 pass, 45 skip, and 1 fail that passed on a rerun: a qwen3_5 disk test this branch doesn't touch). test-dsv41-draft 138/138 (main 129/129), test-dsv41-device-host 182/182 (main 178/178), test-eos-policy-host 34/34 (main 31/31), test-server-cpu 951/951 (main 948/948). GPU, TP=2: the structured-output check script 19/19, and the receipts above.
  • Not run, and why: Metal (main has dsv41's Metal kernels but no dsv41 Metal lane host).

Checklist

  • Every token still goes through the lane rounds. No serial path, and nothing that needs drafts off.
  • Drafted output equals "draft": false, a resumed prompt equals a fresh one, and concurrent equals solo.
  • No precision traded for speed. If the bits change, the description says which sums change and why.
  • A new or changed GPU kernel is registered with the kernel contract (docs/kernel-contract.md); each backend's SUMMARY line is in the receipt. (n/a: no new or changed kernel.)
  • Prompt processing is no slower than the last release. (Not ticked: I have no release number on this machine.)
  • The description says which platforms I ran, Metal M1 to M5 and CUDA, and which I could not.
  • New tests fail before the change, pass after it, and skip cleanly without their dependency.
  • Comments and docstrings are one line. No measurements or history in the source.
  • No personal data, machine names, internal hosts or local paths. No AI attribution lines.
How it works, the GPU settings and the other test results

How the mask gets to each rank:

  • Rank 0 sends op_grammar with the masked row ids and their bits before each forward's picks: about 16 KB per masked row (4,040 words of 32 bits), 64 rows max.
  • Each rank copies its masked rows to pinned memory, sets the blocked columns to -inf and copies them back. That's 258,560 bytes per row each way at TP=2, with one copy each way and one stream sync per run of adjacent masked rows. The 64-row pinned buffer (about 16 MB per rank) is allocated at load.
  • I didn't reuse tf_grammar_mask from sample.cu because it takes bf16 and dsv41's w.logits are fp32. A small kernel in glue.cu could replace the round trip if you'd rather have that.
  • A request without a grammar sends and copies nothing, and so does a row that allows every token (thinking, before </think>).
  • Tree windows (TF_DSV41_TREE) give each chain row its tree row's bits. Trees need one slot, since row windows refuse them. Rank 0 logs tree windows: N (M under a grammar) at the first tree window, the first one with a row its grammar narrows and every 256th.

The first token under a grammar (lanes_first.zig):

  • Before prefill, finish or join, the lanes take the stream's first-token bits from Constraint.next and keep a copy in the stream's state. The target draws the prompt's last row under them.
  • Nothing feeds the grammar between that call and Engine.drawFirst, so drawFirst asks for the same bits. first_masked compares them word for word (error.FirstMaskChanged if they differ), checks the pick (error.FirstNotMasked), and returns the pass's pick as first does.
  • I kept the pick in the pass instead of a kept unmasked row (Gemma 4's CUDA backend keeps l.last), because on dsv41 the pick is part of the TP=2 forward. A later draw would need a new plan-link op and a kept row per slot on both ranks.
  • With TF_DSV41_TAIL_JOIN the first token comes from the first round's window under the lane core's mask for its pending row, so first_masked isn't called.
  • Taking the bits before the pass is one more Constraint.next per grammar request, so main's grammar_fills counts two fills for a dsv41 first token.

Two smaller changes:

  • serve/sampling.zig: a nucleus row (top_p without top_k) whose grammar blocks one rank's whole half of the vocab gave that rank the stats (-inf, NaN), since exp(-inf - (-inf)) is NaN. So keepCount returned null and the row fell back to gathering the full row. Now keepCount skips that rank. All its logits are -inf, so it adds 0 and no finite sum changes. The token was already right on the gather path; this only drops the gather.
  • TF_DSV41_PHASES: a grammar phase times one pick's masked rows (copy out, host mask, copy back). With =sync and drafts off, its ms_avg is the cost of one masked row. Main's host grammar timing (fa598714) times the matcher, so the two don't overlap.

GPU runs: 5eb46486 and main 45fcab6c built for aarch64 (Cortex-A725, sm_121, ReleaseFast), each arm on a fresh boot with jayleaton's prod-zig-flash17 env (TF_DSV41_TAIL_JOIN=1, 4 slots, the prompt cache on): main for the plain reference, the branch for the checks and the receipts. Then the same env with TF_DSV41_TAIL_JOIN=0 (exactness, cached, a prompt in pieces), with TF_DSV41_PHASES=sync (the copy cost), and with one slot and TF_DSV41_TREE=3 (trees). Then a second run with TF_DSV41_DRAFT_TIMED=0 (main's default) and one cost table read at boot (TF_DSV41_CALIB_FILE), the same table on main and the branch, 3 passes on one boot per setting: the plain set on both builds, the exactness set on the branch (main runs only its group without a grammar), and the cached receipt on the branch, all with TAIL_JOIN=1. Its TAIL_JOIN=0 and tree boots failed the engine's pool check (PoolDoesNotFit).

Other host results:

  • Same on main and this branch: test-dsv41-cuda-host 357/357, test-shared-lane-regressions-host 316 pass, 1 skip, test-tool-required-host 26/26, test-grammar-error-host 14/14, test-grammar-time-host 14/14, test-grammar-parallel-host 23/23, test-grammar-metrics-host 18/18, fuzz-cortheon 19/19, test-draft-dsv41-host 1/1, test-server-security 19/19, test-pull-security 9/9, and test-golden passes.
  • The lane grammar tests run the CPU twin with draft trees (2 to 4 branches), with a grammar_budget that ends the reply inside max_new, and with four streams on the lane core's parallel grammar walk (Lanes: grammar mask fills for many constrained streams run serially on the lane thread #1455). Every reply equals its masked serial decode. The new tests are in test-dsv41-draft and test-dsv41-device-host; zig build test gains 3.
  • Fail before, each rerun on 5eb46486 with one change taken out: without heldChain, 2 joined-prompt tests fail with HeldDraftsUnreadable (test-dsv41-draft 136/138). Without the keepCount change, the sampling_test.zig case gathers 2 rows whole where it expects 0 (137/138). Without the grammar in chat.zig, the EOS test gets {}<|end▁of▁sentence|> instead of {} (test-eos-policy-host 33/34). With the stream judging every token at the chunk's last phase, the thinking + ignore_eos test streams the whole-reply chunk's reasoning in 2 pieces instead of 1. With first_masked checking only the pick, the lanes_first_test.zig case takes bits one token away from the pass's (137/138), and without the pick check it takes a pick drawn without the bits (137/138).
  • compile-dsv41-server passes. lean_check.py: 424 problems on the base and on the branch, 0 new. zig fmt --check and git diff --check are clean.

@ashhart

ashhart commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

Thanks @nuk3s. We've reviewed this and it looks right: rank 0 sending the grammar bits over the plan link, and each rank masking its own vocabulary columns, is the shape we'd want. It needs a TP=2 run on two GB10s before it lands, because the shared chat change (a grammar's stop token ending an ignore_eos reply) reaches every engine. That run is ours, and our DeepSeek pair harness needs updating for current main first, so this won't make the 1.2.0 cut. We'll post the numbers here when it has run. Nothing is needed from you.

@ashhart

ashhart commented Nov 1, 2026

Copy link
Copy Markdown
Owner

Thanks @nuk3s, this is a fix people are waiting for: DeepSeek-V4.1 refuses structured output on CUDA today, so tool benchmarks fall back to unconstrained replies. We reviewed it against main, and three things are needed before it lands:

  1. Rebase onto current main. forward.zig gained a progress field, and chat.zig now scans the whole landed chunk and converts MiniMax's DSML. Please keep both, and keep the EOS decision at each token's own grammar phase. Rechecking earlier tokens after the commit loop, with its final grammar state, would wrongly strip an EOS that came before the grammar turned on. Then rerun your thinking-plus-ignore_eos multi-token test.
  2. Say what first_masked guarantees. The callback checks one pick that was masked during prefill; it doesn't draw a fresh masked token. If pre-masking with the same later mask is the intended contract, please write that down and test it, including the cached, piece and join paths, with KV and drafter state unchanged. Otherwise, keep an unmasked per-slot row and draw from it in the callback.
  3. Receipts on the rebased head at TP=2:
    • structured cached against fresh;
    • drafted against undrafted;
    • concurrent against solo;
    • tree masks and grammar_budget;
    • the final ignore_eos fix;
    • one sampled case that actually differs from greedy.

Once those are in, we'll gate it and land it with your credit.

@ashhart

ashhart commented Nov 2, 2026

Copy link
Copy Markdown
Owner

@nuk3s, main has moved and this conflicts with it. Could you rebase onto current main, and answer the two 1 Nov asks (first_masked and the TP=2 receipt)? We'll run it as soon as both are in.

nuk3s added 7 commits November 2, 2026 20:00
DeepSeek-V4.1's LaneHost set no .structures, so the server answered response_format, the guided_* fields and
required or named tool calls with HTTP 400 (ashhart#847).

The lane backend now passes each window's grammar bits to the target in Segment.masks, and before a prompt's pick it
passes the first token's bits from the stream's own grammar (Target.first_mask). first_masked checks the prompt's
pick against the bits the lane core asks for. With TF_DSV41_TAIL_JOIN, a joined prompt's first draft keeps only a
count of held drafts, so the first round's plan has held drafts, and windows.constrain walks them with the grammar
through Backend.held. Lanes.heldChain gives them: the held chain's drafts, the same tokens that `verify` puts in a
window's held rows. Only constrain reads `held` here (dsv41 sets no context_copies).

On the GPU target, grammar_gpu.zig fills the Forward.grammar hook. Before each forward's picks, rank 0 stages the
masked rows and sends their row ids and bits to the other ranks on the plan link (op_grammar). Before a greedy or
keyed pick reads "w.logits", every rank copies those rows to pinned memory, sets the logits of disallowed tokens in
its vocabulary part to -inf and writes them back. Each run of adjacent masked rows is one copy each way. The pinned
buffer (64 rows of the rank's part) is allocated at load, so a pick allocates nothing after rank 0 sends its op.
A request without a grammar, and a row that allows every token, send and copy nothing. Tree windows give each chain
row its tree row's bits; row windows stage once per forward.

A nucleus row (top_p without top_k) whose grammar masks a rank's whole vocabulary part gave that rank the
statistics (-inf, NaN), and keepCount sent the row to the whole-row gather. keepCount now leaves such a rank out of
the sum: every logit it holds is -inf, so it adds 0. No finite sum changes, and a row without a grammar never has
such a rank.

With ignore_eos the server kept every end token in the reply's text, so a grammar's stop token came back at the end
of the content: '{"key": "value"}<|end▁of▁sentence|>' on the GPUs. The lane core already ends the reply on it.
The server's Generation now follows the request's grammar, from the token after `</think>` with thinking on. Under
it an end token is the grammar's stop token, so it ends the reply as without ignore_eos: finish reason stop, and out
of the text and the stream. The commit loop moves the grammar's phase token by token, so each end token is judged at
its own phase. The stream still scans the whole landed round, and eos_phase.zig replays the phase from the round's
start: an end token in the reasoning and the grammar's stop can land in one round, and judged at the round's last
phase the first one would end the stream. End tokens before the grammar starts, and all end tokens without a
grammar, stay in the text.

draft/lanes_grammar_test.zig runs the lane engine over the CPU twin with a test grammar: greedy and keyed replies
equal the twin's masked serial decode, drafted, with drafts off and four at once. A third test runs the prompts in
pieces with a `tail` that leaves the last row to the first round, as the GPU target does, and joins them with one
draft call or one each. A sampling_test.zig case masks one rank's whole part of two nucleus rows: the picks equal
one rank's and the whole rows', and no row is gathered whole (2 before the keepCount change). grammar_gpu_test.zig
covers the staged rows, the plan-link message, a rank's mask and the copy runs. hf_eos_http_test.zig sends a
json_object chat request with ignore_eos on the DeepSeek-V4.1 test tokenizer, thinking off and on, whole and
streamed, in rounds of every size from one token to the whole reply. Each round's reasoning and content stream with
it: judged at the round's last phase, the whole-reply round streams its reasoning in 2 pieces, not 1.
The structured output section gains a line for dsv41-cuda beside GLM's CUDA backend: rank 0 sends each pick's
grammar rows to the other ranks, and every rank masks its own vocabulary columns before the pick. The ignore_eos
paragraph says that a grammar's stop token still ends the reply, out of the text.
The followers take the mask bits rank 0 sends, so op 28 has no bind, step
or release sub-op any more. The note says so, and it no longer claims 29-31.
The new `grammar` phase covers one pick's masked rows: the copy out, the
host mask and the copy back. With TF_DSV41_PHASES=sync it syncs at both
ends, so with drafts off its ms_avg is the cost of one masked row.
…rallel walk

The lanes grammar test now also runs its cases with first-position siblings (TF_DSV41_TREE 2-4) on the CPU twin,
four at once and alone: every reply equals its masked serial decode, and the twin counts the tree windows it ran
(more than 0 in each run).

The test grammar gains an optional early stop and a grammar_budget hook that keeps only the stop token once a
longer reply could not end within the draws left. With the budget armed and max_new below the full length, the
drafted, "draft": false, tree, four-at-once and solo replies all end on the stop token within max_new and equal the
serial decode under the same budget. The same grammar without the bound runs past max_new.

The test grammar's matchers are independent, so it sets parallel_walk, and four constrained streams take the lane
core's worker pool (ashhart#1455). The four-at-once case now checks that the pool ran.
… bits

The lane core calls Backend.first_masked after the prompt pass to draw the first token among the grammar's bits.
DeepSeek's lanes draw it in the pass itself: before `prefill`, `finish` or `join` they take the stream's first-token
bits (Constraint.next), and the target draws the prompt's last row among them. lanes_first.zig now states this
contract and first_masked checks it. The grammar takes no token between that call and Engine.drawFirst, so
drawFirst passes the same bits. first_masked compares them word for word with the bits the pass drew under
(error.FirstMaskChanged when they differ, and when the pass drew without bits), checks that the pick is among them
(error.FirstNotMasked), and returns the pass's pick as `first` does. It runs no forward, writes no KV row and calls
no drafter. Before, it only checked the pick against the bits it was given. The pass's bits are a copy in the
stream's St (first_bits), freed with it.

The pick stays in the pass, and is not drawn in the callback from a kept row as Gemma 4's CUDA backend does, because
on DeepSeek it is the TP=2 forward's own pick: rank 0 sends the row's bits on the plan link and every rank masks its
part of the vocabulary first. A later draw would need its own plan-link op and a logits row kept per slot on every
rank. With TF_DSV41_TAIL_JOIN the first token comes from the first round's window, under the lane core's mask for
its pending row, so first_masked is not called; when the target runs the tail row anyway (`tail` returns its pick),
it is.

lanes_first_test.zig drives the backend over the CPU twin with a target that runs prompts as the GPU target does:
whole, in pieces, after a prefix that `begin` puts in place as a cache hit does, and joined with the tail row run.
On each path, under a grammar that refuses the twin's unmasked first token, greedy and keyed: the pick equals the
masked serial decode's first token; the KV rows, taps and drafter rings equal the unmasked pass's, so the bits
changed only the pick; first_masked leaves them and the lanes' state as they were and adds one handle; other bits
are refused with error.FirstMaskChanged, and the state stays the same. That includes bits one token away from the
pass's that still allow its pick: with the pick-only check the test fails, because it takes them. A target that
draws the pick without the bits is refused with error.FirstNotMasked. Through the lane engine, three replies at once
whose prompts came whole, in pieces, cached, joined or tail-run equal their masked serial decode.
TF_DSV41_TREE resolves each tree window as chain windows (branches.zig), and its counts reached only the gates. Rank
0 now logs them as it logs piece and replay runs, at the first tree window, at the first one with a row its grammar
narrows and at every 256th: "tree windows: N (M under a grammar), C chains, R rows run again". M counts a window
only when a row's bits refuse a token, so a grammar that allows everything at that point does not count. A
structured-output run with trees on can then show that tree windows ran under its grammar.
@nuk3s
nuk3s force-pushed the dsv41-structured-output branch from 9bc9a4c to facad85 Compare November 3, 2026 02:50
@nuk3s

nuk3s commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @ashhart. I rebased onto main 8d8e2d74, so the head is now facad850. It also merges cleanly with today's bfc374fb (32 newer commits, none in the files this branch touches). Both 1 Nov asks are below: first_masked under 2, the TP=2 receipts under 3.

The GPU receipts ran on my two Sparks on 5eb46486, the same 7 commits, on main 45fcab6c. git range-diff from 5eb46486 to facad850 changes only commit 1, in the two files that conflicted (chat.zig and hf_eos_http_test.zig, under 1). Commits 2-7 are the same patches. The host tests ran on facad850.

Status: two receipts are still open. The tree receipt with the binding grammar_budget failed my tool's finish check on run 1 (the check is fixed, details under 3), and its rerun didn't boot. With TF_DSV41_TAIL_JOIN=0, one json_object group split at token 28 on run 1, and the rerun of that arm didn't boot either. Everything else passes on at least one GPU run. Run 2 has one miss in 1 of 3 passes, a plain reply beside 3 structured ones, at a near tie that main's own rivers row shows too.

  1. Rebase. Onto 45fcab6c: forward.zig keeps the progress field. The branch only changes the grammar comment under it.
    chat.zig keeps main's scan of the whole landed chunk and the MiniMax DSML step. The commit loop now moves the
    grammar phase one token at a time, and the stream scan replays the phase from the chunk's start
    (eos_phase.zig), so each end token is judged at its own phase. The thinking + ignore_eos test now sends every
    chunk size, from 1 token to the whole reply. If the scan uses the chunk's final phase, the whole-reply chunk
    streams its reasoning in 2 pieces instead of 1 and that test fails.

    Onto 8d8e2d74: the conflicts came from f7484b4c (Info.reasoning, Prepared.reasoning). In chat.zig the
    Generation keeps main's .thinking = prepared.reasoning or (srv.info.reasoning and ...) and adds the branch's
    .grammar after .ignore_eos. The grammar's after token follows main's change, because checkFeatures now
    gets reasoning. In hf_eos_http_test.zig the Script keeps main's seen_prompt and reasoning and the
    branch's reason, chunk, structured, after and chunked token events. Main's new answerOnly tests use the
    defaults (one event, .length), so they run as on main.

  2. first_masked. I kept pre-masking and wrote the contract down in lanes_first.zig. Before prefill, finish
    or join, the lanes take the stream's first-token bits from Constraint.next, and the target draws the prompt's
    last row under them. Nothing feeds the grammar between that and drawFirst, so drawFirst asks for the same
    bits. first_masked now checks that. It compares the bits word for word with the ones the pass used
    (error.FirstMaskChanged if they differ), checks that the pick is allowed (error.FirstNotMasked), and returns
    the pass's pick. It runs no forward, writes no KV row and calls no drafter.

    I didn't keep an unmasked row like Gemma 4's CUDA backend does. On dsv41 the pick is part of the TP=2 forward,
    and each rank masks its own vocab columns before it. A later draw would need a new plan-link op and a kept row
    per slot on both ranks. With TF_DSV41_TAIL_JOIN the first token comes from the first round's window, so
    first_masked isn't called at all.

    lanes_first_test.zig runs it on the CPU twin. Its target runs prompts the way the GPU target does:
    whole, in pieces, after a prefix that begin puts in place like a cache hit, and joined with the tail row
    run. On every path the pick equals the masked serial decode, the KV rows, taps and drafter rings equal the
    unmasked pass's, and the call leaves all of them alone. Bits that differ from the pass's by one token get
    refused even when they still allow the pick. That's the case the old pick-only check let through, and the test
    fails with it. A target that draws the pick without the bits gets error.FirstNotMasked. The twin runs the
    cached rows again instead of restoring them, so the real restore is covered by the GPU receipts below.

  3. Receipts at TP=2 (two GB10s, spark1 + spark2), each arm on a fresh boot. Run 1 is jayleaton's prod-zig-flash17 env with TF_DSV41_DRAFT_TIMED=1 and a cost table measured at boot. Run 2 is the same env with TF_DSV41_DRAFT_TIMED=0 (main's default) and one cost table read at boot (TF_DSV41_CALIB_FILE), the same table for main and the branch.

    Receipt Result
    structured cached vs fresh TAIL_JOIN=1: run 2 8/8 with the fixed tool (2416 to 2736 cached prompt tokens, every fresh pass from 0). Run 1 was 6/8 on a tool bug (below). TAIL_JOIN=0, where the first token comes from first_masked: run 1 8/8, and 4/4 on prompts of 9631 to 9660 tokens, which the fresh pass runs in 4,096-token pieces
    drafted vs undrafted TAIL_JOIN=1: run 2 equal on all 3 passes in all 4 groups (json_object, json_schema, a sampled json_schema, and the json_object prompt without a grammar), and the drafted replies had drafts in 60 of 60. Run 1 3/3 groups. TAIL_JOIN=0: run 1 3/3 groups. json_object fails only concurrent vs solo (next row)
    concurrent vs solo TAIL_JOIN=1: run 2 48/48 concurrent replies equal their solo reply drafted, and 48/48 with "draft": false (4 groups, 3 passes, 4 at once). Run 1 3/3 groups. TAIL_JOIN=0: run 1, json_object fails. 2 of the 4 concurrent replies leave the solo reply at token 28 ("personality" where the solo reply has "favoriteActivity"), and the other 2 equal it. The other 2 groups pass
    a plain reply beside 3 structured ones run 1 passes with TAIL_JOIN on and off. Run 2 2/3 passes. The miss is a near tie at token 3 that main has too (below)
    tree masks and grammar_budget run 1 6/7 (TF_DSV41_TREE=3, one slot). json_schema, json_object, a required tool call and guided_choice give the same tokens drafted and undrafted, with the budget off and on. The binding budget failed my tool's finish check (below). Rank 0's tree line came at the first tree window, and that window already ran under a grammar (tree windows: 1 (1 under a grammar))
    the ignore_eos fix run 1: thinking off and on, whole and streamed: 2/2
    sampled, differs from greedy run 1 2/2. T 1.0 top_p 1.0: seed 1234, first different token at index 6; T 1.0 top_p 0.95 top_k 0: seed 1234, first different token at index 6

    Required, named and parallel_tool_calls: false tool calls, run 1: 3/3 (drafted == undrafted == 4 at once, streamed arguments == whole). The tree run uses one slot. Row windows refuse trees (batch_target.zig returns TreeWindow), so that arm boots with --parallel 1. To show that tree windows ran under a grammar, I added one log line, like the piece and replay run lines: tree windows: N (M under a grammar), C chains, R rows run again. Rank 0 logs it at the first tree window, at the first one under a grammar and at every 256th. M only counts a window when a row's bits refuse a token.

Run 2 has no TAIL_JOIN=0 arm and no tree arm. Both arms failed the engine's own pool check (PoolDoesNotFit) at boot: TAIL_JOIN=0 at TF_DSV41_POOL_TOKENS=600000, and the tree arm at 1,200,000 and again at 600000. From the boot's measured growth, the worst serving point was 3.36 GiB on rank 0 for TAIL_JOIN=0 and 2.02 GiB on rank 1 for the tree arm, under the 4.0 GiB floor. The engine said no pool size fits (the largest that does: TF_DSV41_POOL_TOKENS=0). The two boots before them fit, with 4.88 to 5.21 GiB on both ranks.

Two of the run 1 failures were bugs in my test tool, receipts_1665.py:

  • cached, the 2 tool-call pairs with TAIL_JOIN=1: the tool put its nonce in the user turn, but dsv41's template renders the tools in the system block, ahead of the user turn. Earlier in that boot, the check script's tool_choice rows (a 332-token prompt with the same two tools) saved a session entry at 320 tokens, inside that block. Both "fresh" tool requests then resumed it (sessions: slot 3 resumed 320 of 2749 prompt tokens from RAM). The session store did the right thing and my freshness rule was wrong for that prompt.
  • tree, the binding budget: the tool wanted finish_reason stop from the budgeted reply. Your docs for grammar_budget say a reply that closes on its last token can still finish as length, and that's what this one did. At max_tokens 24 the reply without the budget is cut mid-string and doesn't parse. With the budget the list closes on token 24 with an empty string, "orange", ""]}, and parses, drafted and undrafted alike.

I fixed the tool. The nonce is now a system message, so it comes right after BOS and ahead of the tools, and the binding-budget check takes stop or length when the reply parses within max_tokens. Against my mock server, the old tool reproduces both failures (6/8 and 6/7), the fixed one passes (8/8 and 7/7), and every broken variant still fails. On the GPUs the fixed cached receipt passes 8/8 (run 2). The fixed tree receipt has no GPU run yet.

The json_object split at token 28 (run 1, TAIL_JOIN=0) doesn't come from the mask. Token 28 is the first token of the fourth key, right after orange tabby",\n ". I walked both replies through the same libtfgrammar v1.1.8 on the host, with the server's json_object grammar ({"type": "object"}) and the model's tokenizer. Every token of both replies is allowed by its mask. At token 28 the mask refuses 1,797 of 129,280 tokens, and f (id 72, the solo reply) and person (id 14596) are both allowed. None of the 81 masks in the two walks allows person and refuses f, so a stale or misplaced mask can't explain it either. Both ids are in rank 0's columns, so rank 1's mask doesn't decide between them. The mask only writes -inf into refused columns, so the row's own logits picked person. Which rows share that round depends on timing: two of the four requests started at once and two waited 0.33 s, and one from each pair split. In run 2, with TAIL_JOIN=1, the same group was equal in all 30 of its replies on 3 passes.

The run 2 miss beside the structured rows is the same kind of near tie, on a plain prompt. The plain reply in one of 3 passes was Rivers carve winding paths ... where the solo reply is Rivers carve through landscapes ... (token_sha 818a9ae5ab7e vs f026fbe745fc). That row has no grammar, and the reply is plain prose. Main gives the same reply on the same prompt with no drafts and no grammar: its "draft": false row was 818a9ae5ab7e in 1 of 3 passes, and f026fbe745fc in the other 2.

The #1664 gate numbers (the grammar checks, drafted vs undrafted with and without TAIL_JOIN, plain tokens vs main, the copy cost per masked row) are in my reply on #1664.

Host tests, x86_64 Linux, Zig 0.17.0, main 8d8e2d74 vs this branch (facad850):

Check main #1665
zig build test 6415 pass, 45 skip, 1 fail (below) 6419 pass, 45 skip
test-dsv41-draft 129/129 138/138
test-dsv41-device-host 178/178 182/182
test-eos-policy-host 31/31 34/34
test-server-cpu 948/948 951/951
test-dsv41-cuda-host 357/357 357/357
test-cuda-host 4470 pass, 16 skip 4473 pass, 16 skip
test-shared-lane-regressions-host 316 pass, 1 skip 316 pass, 1 skip
test-tool-required-host 26/26 26/26
test-grammar-error-host 14/14 14/14
test-grammar-time-host 14/14 14/14
test-grammar-parallel-host 23/23 23/23
test-grammar-metrics-host 18/18 18/18
fuzz-cortheon 19/19 19/19
test-draft-dsv41-host 1/1 1/1
test-server-security, test-pull-security 19/19, 9/9 19/19, 9/9
test-golden pass pass

The one fail on main is cuda_disk_test's "kept states written to disk read back byte-equal after a restart" (qwen3_5, "expected 2, found 0" after "did not drain"). It failed once in the full run and passed on a fresh-cache rerun of test-cuda-host (4470 pass, 16 skip). The branch doesn't touch qwen3_5. The new lane tests are in test-dsv41-draft (+9) and test-dsv41-device-host (+4), so zig build test, the step CI runs, only gains 3. compile-dsv41-server passes, lean_check.py is at 424 on both (0 new), and zig fmt --check and git diff --check are clean.

Heads-up on open PRs: #1720 and #1722 each add a Target vtable entry next to mine in iface.zig. They also add a TimedTarget vtable line and a forwarder next to mine in phases_timed.zig. #1722 also adds a line in Lanes.prefill next to mine. #1892 adds a line after the LaneHost.init call I changed in serve_engine.zig. Each is a line or two on both sides, so keep both.

One fix to the PR body: the "0.03 ms per masked forward" line was wrong. It compared two replies of different lengths (399 and 565 tokens). I took it out. The copy cost in my #1664 reply replaces it.

@ashhart

ashhart commented Nov 3, 2026

Copy link
Copy Markdown
Owner

Thanks @nuk3s: both asks are answered (rebased; EOS judged at each token's own grammar phase, with a test; the first_masked contract written and tested), and the unconstrained path is main (no staged rows means no link send or copy). It lands after our TP=2 run on two GB10s, which waits on the DeepSeek kernel contracts already queued there.

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.

dsv41: structured output and required tool calls through libtfgrammar

2 participants