Skip to content

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
mainfrom
row/MODEL-NEMOTRON-H-ABI-A2P-1157-fix
Open

fix(#1157): NemotronH decoded with STALE input ids, and the recurrent carry was never the cause#1221
localai-bot wants to merge 8 commits into
mainfrom
row/MODEL-NEMOTRON-H-ABI-A2P-1157-fix

Conversation

@localai-bot

Copy link
Copy Markdown
Collaborator

The A3 token gate diverged on real weights and the cause on record was the
recurrent carry. It was not. NemotronHPagedForward embedded the HOST
input.token_ids while ModelForwardInput::device_token_ids was non-null, and
that 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 the
synchronize 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 through
include/vllm.h alone:

arm takes a decode step? result
host, decode yes 96/96, STRICT PASS, full rows 3
GB10, decode yes 4/24
GB10, fresh-prefill no — one token per completion 24/24

The GB10 got streams reproduce the earlier recorded run byte for byte, so this
is the same defect on a fresh build and not drift. The build was not degraded:
CUDA 13.x from the ubuntu2404/sbsa lane, CFG_RC=0, cutlass-nvfp4 /
cutlass-fp8 / marlin-nvfp4 / fa2 all ENABLED for [121a], BUILD_RC=0,
compile_errors=0, binary sha256 b4677cdb7cf5….

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.8 on both, so 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 consumed different tokens. A constant
embedding is a constant input id.

What this refutes

#1157 reasoned that
gm.num_decodes might classify a decode as a prefill so the gather would hand
the mixer zeros. On real weights the trace reports nd=1 np=0 init=[1] on every
decode step, and mutating that mask to 0 turns 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_ids only under
VLLM_CPP_CUDA with a live device mirror, so no CPU gate can reach the branch
at 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_DIAG prints the
decode/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-modes generate the same stream without ever taking a decode step,
over one engine load. VT_NEMOTRON_H_DEVICE_MOE=0 was the bisect lever for the
device 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 is 32/2, ngroups 16 — the path
is 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_ids to whatever model the step
routes 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_ids with a runner fallback, or a
checker over the registered .forward entry points — change a shared seam or
checker 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.md records the device leg as
pending 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.md and
both 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]

mudler added 8 commits August 18, 2026 07:38
…-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]
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.

NemotronH A3 token gate DIVERGES on real weights: token 0 is correct on all 3 prompts, then decode collapses to a repeated token (6/96)

2 participants