Skip to content

nemotron CUDA: the router top-k is moe_glue's sigmoid top-k (#548, step 2) - #1354

Merged
ashhart merged 9 commits into
ashhart:mainfrom
jschmied:shared-nemotron
Oct 30, 2026
Merged

ashhart merged 9 commits into
ashhart:mainfrom
jschmied:shared-nemotron

Conversation

@jschmied

@jschmied jschmied commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1353 (step 1); its six commits come first here.

What moves

Step 2 of #548. Nemotron's router top-k moves onto moe_glue.

  • tf_nemo_topk and tf_nemo_topk_wide (nemotron_route.cu) are replaced by moe_glue.topk's warp form with two new entry points: tf_moe_topk_sigmoid_score_warp_128 (up to 128 experts, 4 a lane: Lightning) and tf_moe_topk_sigmoid_score_warp (up to 512, 16 a lane: Bolt, Super). Both rank by sigmoid(logit) + bias, as NemotronH does. The existing tf_moe_topk_sigmoid_warp (Kolibri, Flash-Next) ranks by logit + bias and keeps its SASS.
  • The score kernels add the router's K slices themselves, in slice order (slices up to 8, the host refuses more). Every slice's load and the bias's are issued before the sums, as tf_nemo_topk did, so no launch sums the slices first.
  • They write any number of shared slots (Nemotron has 2), each at weight 1.0 with ids e..e+shared-1. The routed weights are w / (sum + 1e-20) × scale: picks that all underflow to 0 weigh 0, not NaN. moe_glue.Route gains score, shared_slots and slices.
  • tf_latent_sum (the latent experts' sum, Bolt and Super) becomes moe_glue.combine with bf16 output. This is byte-equal: the fma chain is the same and fma is commutative in its multiplicands.
  • glue_ref.topk is a float64 twin of the rule. The glue test checks the ids exactly and the weights to within 2^-20 relative. routeUnderflow routes three rows whose logits are two slices of -50 each: the ids match float64's, the routed weights are 0 and the shared slots are 1. Without the + 1e-20 it goes red.

Bytes: this step changes Nemotron's bytes. The old kernel used tf_exp; the shared kernels use expf. The picks are identical except at near-ties, but the weights move by a few ulp. Other families are untouched. On sm_80/86/89/120/121 (nvcc 13.0), against main: moe_glue.fatbin's existing functions keep their SASS and the two score kernels are added; norms.fatbin gains step 1's five functions with the rest unchanged; nemotron_route and nemotron_ops only lose functions; contract_controls gains step 1's control; every other fatbin is byte-identical.

Kernel contract

  • Two score routes, checked against a float64 sigmoid + bias rule, with near-ties allowed within 2^-20·(|best|+1) and weights within 2^-20: Nemotron (128 experts, top 6, normalized ×2.5, 2 shared; 16 and 300 rows), which runs _128, and Bolt (496 experts, top 9), which runs the 16-a-lane kernel, with the tiled edges of 8 to 33 experts on _128.
  • A control runs the logit-ranked warp kernel under the score rule. It goes red at shapes, as planted.
  • Combine serves 9 slots × 1,024 (Bolt's latent sum) at 16 and 300 rows.
  • Records, nvcc 13.0 (sm_80/86/89/120/121): tf_moe_topk_sigmoid_score_warp 219/219/219/210/210 regs, _128 62/62/62/48/48, static 0, bound 256, no spills. nvcc 13.2 compiles _128 the same and the 16-a-lane kernel at 219/219/219/203/203, which is in its 13.2 set. The tf_nemo_topk and _wide records are removed, the 13.3 one for tf_nemo_topk_wide included. nvcc 13.3 is not installed here, so its compile of the score kernels is unchecked.
  • kernel-contract on the touched kinds: both score routes, the 4 combine kinds, step 1's 4 moe norm kinds pass gates 1-4, and both controls are red as planted. kernel-contract-limits at 13.0 and 13.2: every record of this PR is current; the failing and stale lines are other families' (qwen35, qwen4, GLM, ...), none in an image this PR touches.

Nemotron's gates (Lightning MLX 4-bit, GB10)

gate main this step
drafted == plain, together 1/2/4/8 == solo, repeats, windows == pages, sampled repeat equal equal
widths, shared widths 8 63/63, 504/504 63/63, 504/504
prompt cuts, resume, paged 8k pass pass
glue vs host references 1470/1470 1472/1472 (top-k weights within 2^-20 of float64)
segments 1-4 (state and reply) same, rounds differ equal everywhere
greedy chat haiku haiku (new bytes)
16k needle found found

Distance to the float64 CPU reference, over the same 3 passages × 320 rows as step 1 (kl dump sha 19475174fbfe, unchanged by the load hoist):

passage rel-L2 mean main / step argmax agree main / step KL(ref‖tf) mean main / step
CONTRIBUTING.md 0.0366 / 0.0368 96.9% / 96.3% 0.0058 / 0.0052
README.md 0.0442 / 0.0449 95.3% / 94.4% 0.0073 / 0.0073
docs/kernel-contract.md 0.0408 / 0.0419 95.0% / 94.4% 0.0093 / 0.0096
all 960 rows 0.0406 / 0.0412 95.7% / 95.0% 0.0075 / 0.0074

Paired over the 960 rows, rel-L2 moves by +0.00063 ± 0.00096 (2 se). Argmax misses go from 41 to 48: 25 rows miss only here and 18 only on main, McNemar p = 0.36. Both are within noise. The teacher-forced loss over seven public texts (3,584 tokens) is +0.0024 ± 0.0047 against main.

Bolt

Bolt's router and latent sum move here too (496- to 240-expert layers on the 16-a-lane kernel, its latent sum on moe_glue.combine). The receipts above are Lightning's; Bolt was token-checked separately on NVIDIA-Nemotron-3.5-Bolt-74B-A9B-24GB (IQ2_XXS experts, NVFP4 Mamba) on a GB10, at 458dd2d8c; the group-norm commit since is bit-equal. Bolt has no MTP head, so the drafted lanes, prompt cuts, resume, paged and segments checks do not apply; every check that runs without drafts does:

check (Bolt, no drafts) this PR
together 1/2/4/8 == solo, repeat, windows == pages equal
widths, shared widths 8 63/63, 504/504
sampled repeat (T 0.8, seed 7) equal
greedy chat, 8 prompts, against main 7 of 8 byte-identical; the Romeo and Juliet summary words its second sentence differently
greedy haiku the same as main (Soft rain on red leaves, …)
16k needle found

The glue test also runs the router, top-k and latent sum at Bolt's shapes against the host references.

Speed

The score top-k alone, at Lightning's shape (128 experts, top 6 + 2 shared, normalized ×2.5, 6 slices), a CUDA graph of back-to-back launches on a GB10, µs a call:

rows 1 4 16 2,048 8,192
main tf_nemo_topk 3.39 3.39 3.40 12.3 80.6
this PR, slices in a loop (before) 5.49 5.55 5.76 16.7 90.4
this PR, loads first (_128) 2.00 2.08 2.33 6.0 53.0

At Bolt's shapes (top 9, 8 slices) the 16-a-lane kernel takes 5.0-5.7 µs at 1-16 rows for 240 experts (main 6.3) and 5.3-6.7 µs for 496 (main 7.1). The loop form and the new one are byte-equal over 6,000 calls (1-300 rows, 33-512 experts, top 1-16, 1-8 slices, ties, NaN, ±inf, -0, all-underflow rows, with and without bias).

Served A/B on a GB10, Lightning MLX 4-bit, main 41abcf402 against 9d142df90, 12 rounds. Each arm and round gets a fresh server, in alternating order. Cold TTFT is the median of 3 a round with drafts on; plain decode is a fresh --no-drafts server, 256 tokens, 3 reps. The table gives the median of the paired per-round ratios:

metric main this PR paired median rounds this PR is faster
plain decode, 1 stream, chat 108.5 tok/s 109.1 tok/s +0.6% 12 of 12
plain decode, 1 stream, code 108.8 tok/s 109.3 tok/s +0.6% 12 of 12
plain decode, 4 streams, chat 350.7 tok/s 351.9 tok/s +0.4% 11 of 12, 1 level
plain decode, 4 streams, code 347.2 tok/s 348.6 tok/s +0.4% 12 of 12
cold TTFT 2k 264.3 ms 264.1 ms -0.3% 8 of 12
cold TTFT 8k 1,025.2 ms 1,023.8 ms -0.1% 7 of 12, 1 level
cold TTFT 32k 4,817 ms 4,807 ms -0.2% 9 of 12, 3 level, none slower

Drafted decode moves with acceptance, since the bytes differ from main's, so it is not a speed comparison here.

On the previous head (458dd2d8c, 14 rounds) cold TTFT was 0.1-0.3% slower; an nsys trace put that on step 1's Mamba group norm. Step 1 now runs it four groups a block (#1353's 8a77e3ec0, bit-equal), and TTFT is level or faster at 2k, 8k and 32k.

Receipt

@ashhart

ashhart commented Oct 26, 2026

Copy link
Copy Markdown
Owner

Review of #1353 and #1354 (#548 steps 1 and 2) against main e9b69ed. Moving Nemotron's norms and router onto the shared glue is a good direction, and nothing here is lossy. But both steps change Nemotron's output bytes, and the float64 receipts get slightly worse rather than better:

A refactor that changes bytes needs its error to be no worse than before, and we can't take a prefill slowdown. Two ways forward: keep Nemotron's current summation order inside the shared kernels (a variant that reproduces today's bytes), or show per-projection-class worst-column float64 error, prompt and decode separately, that is no worse. Also, dropping the +1e-20 turns 0 into NaN when every top-k sigmoid underflows; please keep a guard and add that case to the tests.

@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thanks for moving the slice sum into the top-k and for restoring the guard with a test. We checked the head against our 26 Oct review: the extra launch is gone (zig/kernels/cuda/moe_glue.cu:160), the weights divide by sum + 1e-20 (moe_glue.cu:193), and routeUnderflow (zig/tests/cuda/glue_tests.zig:116) goes red without the guard, since expf(100) overflows and every score is exactly 0.

Three asks:

  1. Please rebase. A merge with current main conflicts in docs/kernel-contract.md, and main's nvcc_133_records.zig:140 (added in 0293760e7) holds a 13.3 record for router tf_nemo_topk_wide, which this branch deletes, so test-cuda-host fails registry_sets_test with UnregisteredOverride. Drop that record, and check whether 13.3 compiles the score top-k differently.
  2. You measured plain decode 0.9-1.3 % slower at 1 and 4 streams. As with prefill, we can't take that. One candidate: main's tf_nemo_topk issues every slice's load before the sums (nemotron_route.cu:204-210 on main), while the score kernel adds the slices in a runtime loop (moe_glue.cu:160). Please compare against tf_nemo_topk itself at Lightning's shape (128 experts, top 6, 6 slices, 1-16 rows), not against combine plus top-k.
  3. The description still has the combine against a row of ones, w / sum, the old speed table and the old stack. Please update it, and say how Bolt was checked: its router and latent sum move here too, and the receipts are Lightning's.

Once decode is level we'll A/B Lightning on a GB10 and run Bolt's token checks.

@jschmied
jschmied force-pushed the shared-nemotron branch 2 times, most recently from 458dd2d to 9d142df Compare October 28, 2026 02:25
@jschmied

Copy link
Copy Markdown
Contributor Author

Thanks. All three are done. #1354 is rebased onto main 41abcf402 and force-pushed: the head is now 9d142df90. It carries #1353's six commits (cherry-picked onto the new base, so their SHAs differ from #1353's 8a77e3ec0), then step 2's six.

1. Rebase.

  • docs/kernel-contract.md: the 13.3 sentence keeps main's text without tf_nemo_topk.
  • The 13.3 record for router tf_nemo_topk_wide (nvcc_133_records.zig) is dropped in the commit that deletes the kernel.
  • test-cuda-host passes, and zig build test exits 0.
  • nvcc 13.3 is not installed on this box (no /usr/local/cuda-13.3), so I could not check whether 13.3 compiles the score kernels differently. 13.0 and 13.2 are checked (below).

2. Decode. You were right about the loads. Against tf_nemo_topk itself at Lightning's shape (128 experts, top 6 + 2 shared, normalized ×2.5, 6 slices), the loop form took 5.5-5.8 µs a call at 1-16 rows, against 3.4 for tf_nemo_topk. Each lane waited on its slices one dependent load at a time.

The last step-2 commit (9d142df90) issues every slice's load and the bias's before the sums (part[P][8]), as tf_nemo_topk does, and sums in the same slice order. A second instance, tf_moe_topk_sigmoid_score_warp_128, holds 4 experts a lane up to 128 experts, so its loads fit in 48-62 registers. Past 128 experts (Bolt, Super), the 16-a-lane kernel runs at 210-219 registers, no spills.

µs a call, CUDA graph 1 row 4 16 2,048 8,192
tf_nemo_topk (main) 3.39 3.39 3.40 12.3 80.6
loop form (before) 5.49 5.55 5.76 16.7 90.4
loads first (_128) 2.00 2.08 2.33 6.0 53.0

At Bolt's shapes (top 9, 8 slices), 1-16 rows: 5.0-5.7 µs at 240 experts (main 6.3), 5.3-6.7 µs at 496 (main 7.1).

The change is bit-equal. Picks and weights are byte-equal to the loop form over 6,000 calls: 1-300 rows, 33-512 experts, top 1-16, 1-8 slices, with ties, NaN, ±inf, -0 and all-underflow rows, with and without bias. On Lightning, the kl dump sha (19475174fbfe), the chat sha (1a5f995f724e) and the segment state (02330cdb8830) are unchanged.

Records, re-derived:

  • nvcc 13.0: 219/219/219/210/210 for the 16-a-lane kernel and 62/62/62/48/48 for _128.
  • nvcc 13.2: 219/219/219/203/203, in its 13.2 set. _128 compiles the same as under 13.0.

SASS against main on sm_80/86/89/120/121: moe_glue.fatbin's existing functions are unchanged, and the two score kernels are added.

Served A/B, Lightning MLX 4-bit. main 41abcf402 against 9d142df90, 12 rounds, a fresh server for each arm and round, alternating order. Medians of the paired per-round ratios:

main #1354 paired rounds faster
plain decode, 1 stream, chat 108.5 tok/s 109.1 +0.6% 12 of 12
plain decode, 1 stream, code 108.8 109.3 +0.6% 12 of 12
plain decode, 4 streams, chat 350.7 351.9 +0.4% 11 of 12, 1 level
plain decode, 4 streams, code 347.2 348.6 +0.4% 12 of 12
cold TTFT 2k 264.3 ms 264.1 -0.3% 8 of 12
cold TTFT 8k 1,025.2 ms 1,023.8 -0.1% 7 of 12, 1 level
cold TTFT 32k 4,817 ms 4,807 -0.2% 9 of 12, 3 level, none slower

Plain decode is faster at 1 and 4 streams, and cold TTFT is level or faster. On 458dd2d8c (14 rounds) TTFT had still been 0.1-0.3% slower; an nsys trace put that on step 1's Mamba group norm, which ran one 256-thread block a 512-wide group with 64 threads holding data. #1353 now runs it four groups a block (8a77e3ec0, bit-equal: the idle warps only added zeros), and this stack carries that commit.

3. Description. It is updated: no combine against a row of ones, w / (sum + 1e-20), the two score kernels, the new tables and the current stack.

Bolt: its router and latent sum move here, and the receipts so far were Lightning's. I ran its token checks on NVIDIA-Nemotron-3.5-Bolt-74B-A9B-24GB on 458dd2d8c, before the bit-equal group-norm commit. Bolt has no MTP head, so the drafted lanes, prompt cuts, resume, paged and segments do not run on it.

  • Without drafts: together 1/2/4/8 == solo, the repeat, windows == pages and the sampled repeat are all equal. Widths are 63/63, shared widths 504/504, and the 16k needle is found.
  • Against main, 7 of the 8 greedy chats are byte-identical. The eighth (the Romeo and Juliet summary) words its second sentence differently. The glue test covers Bolt's router, top-k and latent-sum shapes against the host references.

Other checks on 9d142df90:

  • Lightning token checks: drafted == plain, together 1/2/4/8 == solo, windows == pages, the sampled repeat, widths 63/63, shared widths 504/504, prompt cuts, resume and paged pass. Glue is 1472/1472 and the needle is found.
  • kernel-contract: both score routes, the four combine kinds and step 1's four moe norm kinds pass, and both controls are red.
  • lean_check finds nothing new.

@ashhart

ashhart commented Oct 28, 2026

Copy link
Copy Markdown
Owner

Thanks @jschmied. The three asks are answered:

  • the rebase, with tf_nemo_topk_wide's 13.3 record dropped;
  • every slice's load issued before the sums, and your table puts the score kernel under tf_nemo_topk at every row
    count;
  • the description now explains how Bolt was checked.

We'll record the score kernels' nvcc 13.3 limits on our side.

Two things before we run it:

  1. This branch carries nemotron CUDA: RMSNorm rows run on the shared norms and combine (#548, step 1) #1353's six commits. Our GB10 run on nemotron CUDA: RMSNorm rows run on the shared norms and combine (#548, step 1) #1353 (at 76ab46f) found tokens that differ from main on
    Lightning and Super-VL, and slower decode. nemotron CUDA: the router top-k is moe_glue's sigmoid top-k (#548, step 2) #1354 waits on that answer.

  2. Step 2's own bytes. Its one arithmetic difference from main's tf_nemo_topk is the sigmoid: score_warp uses
    expf (zig/kernels/cuda/moe_glue.cu:170), where main uses tf_sigmoid (nemotron_route.cu:222). The slice
    sums, the tie rule and the + 1e-20 sum already match.

    • glue_math.cuh's tf_exp is built from explicit _rn operations only, so it gives the same bits under
      moe_glue's flags.
    • The two score kernels are new, so using tf_sigmoid in them leaves tf_moe_topk_sigmoid_warp as it is.
    • That should make Nemotron's picks and weights byte-equal to main, and settle step 2 without a float64 argument.

    Could you switch it, then check picks and weights against main's tf_nemo_topk over your 6,000-call sweep?

Then, on a GB10:

  • Lightning (MLX 4-bit) and Super-VL (NVFP4) against main: token hashes on the same 20 prompt rows, plain decode at one
    and two streams and after long prompts, and cold TTFT;
  • Bolt's token checks;
  • the contract with its limits.

The zig-windows job on this head stalled, so we've restarted it.

@jschmied

Copy link
Copy Markdown
Contributor Author

#1354 is restacked onto #1353's 8e1115e05 (force-with-lease; head e6e217b7d). Step 2 had one more difference from main.

Top-k. Replayed on main's own router outputs, the score top-k picked the same experts. But its weights were 1 ulp off on up to 14 % of the prompt's slots, because its sigmoid took expf where tf_nemo_topk takes glue_math's tf_sigmoid.

  • score_warp now calls tf_glue::tf_sigmoid, and a NaN score plus bias ranks as −inf, as tf_nemo_topk ranks it.
  • The glue test checks the weights against main's host reference bit for bit again, the underflow case included. The float64 twin and Rig.near go.
  • Replay: ids and weights equal main's in all dumped calls (1, 16 and 2,048 rows).
  • Records: score_warp 192/192/192/216/216 registers, and _128 58/58/58/48/48.

Tokens (#1354 merged into main db3fd5aba, against main):

Served A/B against main (four rounds, the same runs as #1353's). Medians, + = faster:

  • Cold TTFT 2k–64k: −0.2 / −0.1 / +0.1 / +0.2 / +0.1 / 0.0 %.
  • Decode: plain +−0.2 %. Drafted 1 stream +0.2 % (code) and −0.4 % (chat). 2 streams +2.9 % and +17.7 %.
  • Decode after 16k / 32k / 64k: +6.9 / −5.9 / +0.9 %. That's 2 requests each, and these cells spread ±10 % within one arm, with the same tokens as main.
  • nsys on the code prompt: the same rounds as main (63), 5.15 vs 5.20 ms a token, and the top-k at 2.74 µs a call against tf_nemo_topk's 4.37.
  • Bolt: tokens checked as above, speed not measured here. Its top-k is the score_warp form at 192–216 registers.

Checks:

  • SASS against 41abcf402: 0 changed functions. tf_nemo_topk, _wide and tf_latent_sum go, and the score forms and strided norms are added.
  • kernel-contract: score, moe, norm and tail pass, and the controls are red.
  • lean_check finds nothing new, and zig build test exits 0.

jschmied and others added 9 commits October 30, 2026 22:16
 step 2)

Nemotron's tf_nemo_topk/_wide become moe_glue.topk's warp form through a new
tf_moe_topk_sigmoid_score_warp (ranks sigmoid(logit) + bias, any number of
shared slots); tf_moe_topk_sigmoid_warp is unchanged. The K-sliced logits
are summed by moe_glue's combine against a row of ones (byte-equal), and
tf_latent_sum becomes moe_glue.combine (byte-equal).

Nemotron's bytes change once: expf and w/sum replace tf_exp and
w/(sum+1e-20). Other fatbins' SASS is unchanged.
…shhart#548 step 2)

tf_nemo_topk normalized over the picks' sum + 1e-20; the shared score top-k
dropped it, so a row whose picked sigmoids all underflow to 0 in fp32 got
0 / 0 = NaN weights. tf_moe_topk_sigmoid_score_warp adds it back, and the
float64 references (glue_ref.topk, the contract's score check) divide by the
same sum + 1e-20. A new glue case routes three rows of logits -100: the ids
match float64's, the routed weights are 0 and the shared slots 1.
…hart#548 step 2)

Step 2 summed the router's K-slice partials with a combine launch (fp32
weights 1.0 from Buffers.ones) before the top-k, one launch per MoE layer
more than main's tf_nemo_topk. tf_moe_topk_sigmoid_score_warp now takes
`slices` and adds the partials [slices, rows, experts] in slice order before
the sigmoid, the same chain as the combine's fma(1, p, acc): the bytes stay,
the launch and Buffers.ones go. Route.slices (default 1) carries it; only the
score form takes it. The glue test checks the partials unsummed and routes the
all-underflow rows as two slices of -50. Records: score_warp 64/64/64/112/112
registers on 13.0, 64/64/64/128/128 on 13.2.
… step 2)

lean_check flagged the new multi-line and over-long comments; the text is shorter, the code unchanged.
…ums (ashhart#548 step 2)

Plain decode was 0.9-1.3% slower than main at 1 and 4 streams. The score
top-k added the router's K slices in a runtime loop a lane's expert at a
time, so each lane waited on up to 16 x 6 dependent loads; main's
tf_nemo_topk issues every slice's load first. score_warp now does the same
(part[P][8], the slices summed in the same order afterwards), and a second
instance, tf_moe_topk_sigmoid_score_warp_128, holds 4 experts a lane for up
to 128 experts (Lightning) so its loads fit in 62 registers or fewer; past
128 the 16-a-lane form runs (210-219 registers, no spills). The host
refuses more than 8 slices.

Bits are unchanged: picks and weights are byte-equal to the loop form over
13,000 calls (1-300 rows, 33-512 experts, top 1-16, 1-8 slices, ties, NaN,
+-inf, -0, all-underflow rows, with and without bias). At Lightning's shape
(128 experts, top 6 + 2 shared, 6 slices) a call takes 2.0-2.3 us at 1-16
rows (main's tf_nemo_topk 3.4, the loop form 5.5-5.8); at 2,048 and 8,192
rows 6.0 and 50 us (main 12.5 and 86).
…qual main's (ashhart#548 step 2)

Replayed on main's own router outputs, the score top-k picked the same experts but 1-ulp different weights on up to
14 % of the prompt's slots: its sigmoid took expf, tf_nemo_topk's takes glue_math's tf_sigmoid. score_warp now calls
tf_glue::tf_sigmoid, and a NaN score plus bias ranks as -inf, as tf_nemo_topk ranks it. The glue test checks the
weights against main's host reference bit for bit again (the float64 twin and Rig.near go), the underflow case too.
Main moved glue_math.cuh to zig/kernels/cuda/common/ after this branch added its
include; every other unit includes it from there.

Co-authored-by: Jürgen Schmied <600316+jschmied@users.noreply.github.com>
@ashhart
ashhart merged commit 65cd6a9 into ashhart:main Oct 30, 2026
ashhart added a commit that referenced this pull request Oct 30, 2026
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