fix(#1157): NemotronH decoded with STALE input ids, and the recurrent carry was never the cause - #1221
Open
localai-bot wants to merge 8 commits into
Open
fix(#1157): NemotronH decoded with STALE input ids, and the recurrent carry was never the cause#1221localai-bot wants to merge 8 commits into
localai-bot wants to merge 8 commits into
Conversation
…-prefill discriminator The A3 gate diverges on real weights while the CPU gate is 12/12 green, so neither arm can say whether the decode step reads the state the previous step wrote. Two instruments, both off by default. `VT_NEMOTRON_H_DIAG` prints, per step, the decode/prefill split, the recurrent slot indices, the has-initial mask, and per Mamba2 layer the L2 of the state gathered in and the state written out. On the CPU fixture it reads a healthy carry, which is what makes it usable as a negative control on the device. `nemotron-h-gen --fresh-prefill` generates the same stream one token per completion from a growing prompt, so every token comes out of a prefill and nothing is carried. Same engine, same weights, same public entry point, so a stream that is right this way and wrong the normal way names the carry. This is scaffolding for the #1157 measurement, not the repair. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [claude-code]
…slot can bisect The CPU arm now decodes this checkpoint token-exact against the oracle golden, so the divergence is on the device side and the question is which device arm. The MoE block is 23 of the model's 52 layers and its own gate exercises T=4 and T=2 only, never the T=1 a decode step is, so it is the arm with the least coverage at the shape that fails. `VT_NEMOTRON_H_DEVICE_MOE=0` routes those layers back through the host reference the CPU run proves correct, and `--both-modes` runs the decode and fresh-prefill streams over ONE engine load, which is the only affordable shape when a load is minutes long. Both default to today's behaviour, so nothing moves unless a diagnostic asks it to. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [claude-code]
… the decode one `NemotronHMoeBlockDevice` runs on 23 of this model's 52 layers, and its gate measured T=4 and T=2. Both are prefill shapes. Every token after the first comes out of a step carrying exactly one token, so the width the model spends its entire decode in was the width nothing measured. It is not a cosmetic gap. `MarlinMoeAlignBlockSizeSelect` and `MarlinMoeAlignSizes` branch on the token count relative to the expert count, and T=1 against 128 experts is on the other side of that branch from T=4. A width loop rather than a third copy, so the three cannot drift, with the covered count asserted afterwards: a loop that ran over nothing would otherwise report a clean pass, which is the shape this tree keeps finding. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [claude-code]
…he failing model is a third `fa2_decode_qwen3` is DEFAULT ON for any bf16 causal pure-decode at head_dim 128. Its own comment scopes it to "only Qwen3-dense hits this", and that stopped being true when NemotronH-3.5-Lightning landed: it is head_dim 128 as well, and its decode goes through the same launcher. Every case in this file measured 16/8 and 32/8 — ngroups 2 and 4, the two Qwen3-dense gate configs. NemotronH is 32 query heads over 2 KV heads, ngroups 16, four times the widest group count the swapped presentation was ever measured at. The launcher packs ngroups as seqlen_q, so the group count is not a detail of the geometry, it is the grid. The ratio is added to all five d128 cases rather than to one, so the prefill arm, the plain-varlen decode, the group-swap decode, the swap-vs-plain near-tie and the num_splits cap all see it. Nothing else moves. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [claude-code]
…very decode step saw the same token `ModelForwardInput::device_token_ids` is non-null exactly when the async runner's device combine has already spliced each DECODE row's sampled token into its own device buffer and left the host `token_ids` stale on purpose — not materializing it on the host is the synchronize ENG-ASYNC-SCHED W4 exists to remove. `NemotronHPagedForward` uploaded the host vector anyway, so on the default CUDA path every decode step embedded the same placeholder id. The measurement, all on the released 30B NVFP4 checkpoint and the committed oracle golden, through `include/vllm.h` and nothing else: CPU, decode 96/96, STRICT PASS, full rows 3 GB10, decode 4/24 GB10, fresh-prefill 24/24 (one token per completion: no decode step) and the per-layer trace names the mechanism rather than inferring it. At the prefill step CPU and GB10 agree to six digits on every one of the 52 layers. At the first decode step the gathered conv/SSM state is IDENTICAL on the two — the recurrent carry is exact — while layer 0's embedding row differs, and it reads 0.228135 on GB10 at BOTH decode steps although they consume different tokens. A constant embedding is a constant input id. This refutes the cause on record. #1157 reasoned that `gm.num_decodes` might classify a decode as a prefill and hand the mixer zeros; the trace reports `nd=1 np=0 init=[1]` on every decode step, and mutating that mask to 0 turns the existing A2-P CPU gate RED (1 case, 6 assertions), so the gate was never blind to it. Kimi-Linear was cut from this same divergence (kimi_linear_device.cpp:2270) and every other registered forward already honours the field. Nothing could see that this one did not: the runner sets the pointer only under VLLM_CPP_CUDA with a live device mirror, so no CPU gate can reach the branch. The host-side id range check stays on the host branch only. Validating device ids would need the D2H synchronize this path exists to delete. Closes #1157 FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [claude-code]
… the seam that allowed it The row's public record still said the A3 gate was pending on a toolchain, and `#1157` still carried a cause that the measurement refutes. Both are corrected against numbers rather than re-quoted. `benchmark-record.md` carries the three-arm table the diagnosis rests on — host 96/96, GB10 decode 4/24, GB10 fresh-prefill 24/24, one binary and one checkpoint — plus the per-layer trace that puts the divergence at layer 0's embedding row while the recurrent state gathered identical on both sides. `docs/STATUS.md` and `docs/BENCHMARKS.md` move the row from "gate pending" to "host gate passes, device fixed, sm_121a re-run pending a lease", inside the STATUS size ratchet. [#1217](#1217) is filed and listed under the spec's `## Owed`: the runner hands `device_token_ids` to whatever model the step routes to, its own comment claims a model that ignores it is never given one, and nothing enforces that. Two models have now been cut from the identical divergence. Not fixed here because both closes change a shared seam or checker semantics. The two diagnostic knobs are documented in `docs/ENVIRONMENT.md` rather than allowlisted as kernel-internal: `VT_NEMOTRON_H_DIAG` is how this bug was separated from the carry, and the next reader of this model should find it. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [claude-code]
FOLLOWING_AGENTS_PROTOCOL origin/main moved 26 files under this branch while the #1157 diagnosis was running on a leased GB10. Merged rather than rebased so the branch keeps the measurement order: the instruments, then the host and device arms they produced, then the fix those arms name. No conflict in either direction; nothing this branch owns was touched. Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [claude-code]
… not call itself temporary The block header said TEMPORARY while `docs/ENVIRONMENT.md` documents the knob and the next divergence on this model will be diagnosed with it. It now says what it is for instead: the runner hands a decode step a device-resident input id AND a recurrent page, and when the tokens come out wrong only the per-layer numbers say which of the two the step actually read. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [claude-code]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The A3 token gate diverged on real weights and the cause on record was the
recurrent carry. It was not.
NemotronHPagedForwardembedded the HOSTinput.token_idswhileModelForwardInput::device_token_idswas non-null, andthat field's contract is that the host vector is STALE for decode rows
(
model_registry.h:314-324) — not materialising it on the host is thesynchronize ENG-ASYNC-SCHED W4 exists to remove. So on the default CUDA path
every decode step embedded the same placeholder id.
FOLLOWING_AGENTS_PROTOCOL
What the measurement says
One binary, one checkpoint (released
nemotron-3.5-lightning-30b-nvfp4,revision
29f2d174), one committed oracle golden, driven throughinclude/vllm.halone:STRICT PASS, full rows 3The GB10
gotstreams reproduce the earlier recorded run byte for byte, so thisis the same defect on a fresh build and not drift. The build was not degraded:
CUDA 13.x from the
ubuntu2404/sbsalane,CFG_RC=0,cutlass-nvfp4/cutlass-fp8/marlin-nvfp4/fa2allENABLED for [121a],BUILD_RC=0,compile_errors=0, binary sha256b4677cdb7cf5….The per-layer trace puts it at the first operation of the decode step rather
than inferring it. At the prefill step host and GB10 agree to six digits on all
52 layers. At the first decode step the gathered conv/SSM state is IDENTICAL on
the two —
|conv|=310.374,|ssm|=3985.8on both, so the recurrent carry isexact — while layer 0's embedding row differs, and it reads
0.228135on GB10at BOTH decode steps although they consumed different tokens. A constant
embedding is a constant input id.
What this refutes
#1157 reasoned that
gm.num_decodesmight classify a decode as a prefill so the gather would handthe mixer zeros. On real weights the trace reports
nd=1 np=0 init=[1]on everydecode step, and mutating that mask to
0turns the A2-P CPU gate RED (1 case,6 assertions) — that gate was never blind to that defect. It was blind to this
one structurally: the runner sets
device_token_idsonly underVLLM_CPP_CUDAwith a live device mirror, so no CPU gate can reach the branchat all. That is why the red-before here is a device measurement rather than a
unit test.
What else is in the change
Instruments, all off by default.
VT_NEMOTRON_H_DIAGprints thedecode/prefill split, the state indices, the has-initial mask, and per Mamba2
layer the L2 of the state gathered in and written out — it is what separated the
carry from the input id, and it reads a healthy carry on the CPU fixture, which
is what makes it usable as a negative control.
nemotron-h-gen --fresh-prefill/
--both-modesgenerate the same stream without ever taking a decode step,over one engine load.
VT_NEMOTRON_H_DEVICE_MOE=0was the bisect lever for thedevice MoE. Both env vars are documented in
docs/ENVIRONMENT.md.Two coverage gaps found while bisecting, both at the decode shape. The
device MoE gate measured T=4 and T=2, two prefill widths, never the T=1 the
model spends its whole decode in. The FA-2 d128 decode op gate measured GQA
ratios 2 and 4 (
16/8,32/8), and NemotronH is32/2, ngroups 16 — the pathis default-ON for any bf16 causal pure-decode at head_dim 128, so it is not
Qwen3-dense-only as its comment claims. Neither new case has run on a GPU
yet; a lease is queued to measure them, and they skip loudly everywhere else.
#1217 is filed and owed,
not fixed here. The runner hands
device_token_idsto whatever model the steproutes to; the field's comment claims a model that ignores it is never given
one, and nothing enforces that. Kimi-Linear was cut from this same divergence
(
kimi_linear_device.cpp:2270-2280) and NemotronH is the second. Both closes —an explicit
ModelFactory::honors_device_token_idswith a runner fallback, or achecker over the registered
.forwardentry points — change a shared seam orchecker semantics, which AGENTS.md sends through its own spec.
Still owed
The sm_121a green-after re-run of the full 96-token gate under this fix. The
fleet lease for it is queued;
docs/BENCHMARKS.mdrecords the device leg aspending rather than as a pass, and the host leg as the pass it is.
Note for the merger: #1158 is
open against the same
benchmark-record.md,STATUS.md,BENCHMARKS.mdandboth specs, and records the failure this change explains. Whichever lands second
should take the target-branch version whole and re-apply its own scoped edit.
Closes #1157
Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5 [claude-code]