Repository navigation
nemotron CUDA: RMSNorm rows run on the shared norms and combine (#548, step 1) - #1353
Conversation
|
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 |
|
One question before we rework #1353/#1354, on what "no worse" should mean. The two steps change Nemotron's summation order, not its precision: the shared kernels sum the same f32 values in another order. A different order moves the last bits toward or away from the float64 value at random, so against a fixed main about half of any set of measurements comes out slightly worse by chance. Our receipts look like that noise rather than a trend:
A per-projection-class, worst-column test asked as "no worse in every class" is many comparisons of an extreme value. Two equally accurate orders would rarely pass it, so the only way to pass for sure is to reproduce main's bytes, i.e. keep Nemotron's own order inside the shared kernels. Would "not worse beyond statistical noise" work for you instead? Concretely: paired differences per class (prompt and decode separately) within 2 se of zero, with no class significantly worse after correcting for the number of classes. Then the shared kernels keep one order for every family. If you need byte equality to main, we'll keep Nemotron's order in the shared kernels instead. Either way, the prefill cost goes (the combine fuses back into the norm, as on main), and the |
|
One addition: this is the criterion #796 already used when Nemotron's To make the noise level concrete rather than argued, I'll add two receipts on these PRs:
|
|
The two receipts. Lightning MLX 4-bit, GB10. Placebo = main e9b69ed with
Per projection class, placebo vs main, 1,152 rows: paired rel-L2 moves −0.0007 … −0.0028 in prompt and −0.0008 … −0.0016 in decode. That is past 2 se in most classes, all on the better side, because rows within one run are correlated and the per-row se is too narrow. The worst column flips from 4.42 to 0.45 (in units of its layer's RMS) on prompt passage a. On main, one row (162, layer 18, column 2206) reads 52.5 where float64 has −1.18. The placebo doesn't hit that outlier, and the other two passages show ≈0.45 on both arms. An order change of the same precision moves every one of these metrics as far as #1353/#1354 do. So by #796's criterion both steps are within noise. We'd keep the shared kernels' single order, fuse the combine back into |
|
Thanks, this is convincing. The placebo settles it: reversing one existing fold on main moves the float64 error, the argmax misses and the teacher-forced loss as far as #1353 and #1354 do, so a per-class worst-column test would mostly measure luck. "Not worse beyond noise" is the right bar for a reorder of the same precision, as on #796:
Please keep the shared kernels' single order. Two things from the earlier review still stand, because they aren't statistical:
Every token check stays as it is: drafted equals plain, concurrent equals solo, and resumed equals fresh on the new order. Separately, the row on main that reads 52.5 where float64 has −1.18 (row 162, layer 18, column 2206) looks like more than reordering noise. Could you open an issue with how you found it (prompt, passage and position)? We'd like to look at it on its own. |
|
Thanks. All three points are done. Both branches are pushed as new commits on top, with no rebase: #1353 is now at 1. Prefill. Two launches caused it, one in each step:
Both keep the shared kernels' single order. In Served A/B on a GB10, Lightning MLX 4-bit, fresh server each arm-round, alternating order. Cold TTFT is the median of 3 per round; the table gives the median of the paired per-round ratios against main:
Plain decode (no drafts), 3 rounds, 1 and 4 streams:
Drafted decode moves with acceptance, because the bytes differ from main's, so it isn't a speed comparison here. 2. Zero guard ( Token checks, on both heads: drafted == plain, together 1/2/4/8 == solo, windows == pages, widths 63/63, shared widths 504/504, prompt cuts, resume and paged all pass. Glue is 1470/1470 on #1353 and 1472/1472 on #1354. The 16k needle is found. lean_check finds nothing new on either branch, and 3. The row-162 outlier is now #1489. |
|
Thanks for fusing the combine back into Two asks:
A small point: after the fusion this step no longer calls Neither ask changes the bytes, so we'll start the GB10 A/B of prefill and decode now, with the token checks on Lightning, Super VL and Bolt, whose Mamba group norms move too. |
|
Thanks. Both asks and the small point are done, pushed as three commits on top (fast-forward): #1353 is now at 1. The moe kinds (
2. The description is updated for the fused kernel (128 registers on all five SMs), the new records and the new speed table. Prompt time (
Small point ( Checks on
|
|
Thanks @jschmied for the three commits. Before they arrived, we ran the head up to
Could you find where the bytes part from main? For example, compare each new launch's output with main's |
|
Thanks for the run. Fixed in one commit on top (fast-forward): #1353 is now at Where the bytes parted. I built
Fix (
Tokens (#1353 merged into
Decode. On the code prompt, nsys shows the old head's slower decode came from its different tokens, not from kernel time: 75 rounds and 180 accepted against Served A/B against
Checks on
|
…art#548 step 1) nemotron_norms.cu is gone. Its bf16 embed is embed_rows' bf16 copy; the MoE residual is moe_glue's combine into delta, then norms' add_rms_h; the MTP input halves and Mamba's group norm are norms_tail's output norm on strided parts of rows (tf_rms_rows_bf16h_parts and _round, appended to norms.cu, the tail's body unchanged). Nemotron's bytes change once: the shared ops sum in their own orders. glue_ref follows those orders, so the glue test stays byte-exact. The contract registers add_rms_h (unregistered before), both parts kernels and Nemotron's combine shape. Every other fatbin is byte-equal and norms.fatbin's existing functions keep their SASS on all five SMs.
…art#548 step 1) Step 1 ran moe_glue's combine into a bf16 delta and then add_rms_h, one launch more than main and a delta row through memory: prefill 1.1-1.8% slower. add_rms_h takes the expert slots as its residual instead (SK >= 0): each thread sums its 8 columns over the slots in slot order from 0 with fp32 fmas, rounds to bf16 as the combine stores it, and adds it to x. The kernel, tf_moe_add_rms_rows_bf16h, gives the bytes of combine then add_rms_h, so the shared order and every output byte of step 1 stay; glue_ref is unchanged and the glue test stays byte-exact. The contract registers the fused kernel on one bf16 slot of weight 1 (the residual's bytes) at Nemotron's shapes.
…t#548 step 1) lean_check flagged the new multi-line and over-long comments; the text is shorter, the code unchanged.
With the combine fused into add_rms_h, no step-1 launch uses MoeGlue: the engine, its Glue and the glue rig no longer load it, and the combine contract drops the 8 x 2,688 shapes (Nemotron makes no such launch now). The bytes are unchanged.
…nted control (ashhart#548 step 1) The moe kinds ran one bf16 slot of weight 1, where the combine is the slot itself. They now run Nemotron's 8 slots (6 routed weights, sigmoid scores normalized to 2.5, then 2 shared of weight 1), as bf16 slots (the prompt's down projection) and fp32 slots (decode's). h's sum is declared: the slots' fp32 fma chain, depth 8, rounded to bf16 before the add; y's depth is that chain plus the squares'. The float64 check takes the combine as a range over the chain's gamma(8), rounded to bf16 at both ends, and gate 4 gives h's kinds exact slot terms with one past fp16's range. A planted control keeps the combine in fp32 before the add and goes red at the shapes gate. add_rms_h and its 16-byte helpers move from norms.cu to norms_vec.cuh, unchanged but for a KEEP template flag (false in norms.cu), so the control in contract_controls.cu instantiates the same body. norms.fatbin's SASS is unchanged.
… step 1) The shared parts norm ran one 256-thread block a 512-wide group, 64 threads holding data: Lightning's prompt pass spent 16.8 ms in it at 8k and 67.6 ms at 32k, where main's tf_nemo_group_rmsnorm took 14.3 and 57.3 ms, and cold TTFT was 0.2-0.3% slower. tf_rms_rows_bf16h_parts4 and _parts4_round run parts up to 512 wide four a block, 64 threads each; rmsParts picks them by width. Each thread's 8-term chain and the warp tree are the same, and warp 0 + warp 1 is the 8-warp tree's total when warps 2-7 hold zeros, so the bits are the old kernel's: y and xs byte-equal over 224 calls (widths 64-512, 1-2,048 rows, both roundings, NaN, +-inf, tiny values, with and without sums). At 8 groups x 512: 143 us at 2,048 rows and 578 us at 8,192 (was 181 and 710). The contract's parts kinds now cover both forms.
…al main's (ashhart#548 step 1) The shared forms reordered Nemotron's sums: tokens differed from main on 17 of 20 Lightning rows. Replaying main's own calls (a real layer's inputs, decode's fp32 and the prompt's bf16 slots) through each kernel, the first output to part is layer 0's Mamba group norm: its sum of squares (8 contiguous elements a thread, trees) and its 64-value sums (8 in order, then a lane tree) ran in another order than tf_nemo_group_rmsnorm's. The MoE residual summed the shared slots into the routed chain, and the MTP norms took the tail's order. Eps and rounding points never moved. norms_strided.cuh now holds main's order as templates: a thread holds elements t, t + T, ... (fma chain, xor butterfly, warps added in order, each 64-value group summed in order by one thread). tf_rms_rows_bf16h_parts (256 threads, to 4,096 wide) and _parts_round (128 threads, to 1,024) run it on parts of rows, the MTP halves again in one launch; tf_moe_add_rms_rows_bf16h is the fused residual with the routed fma chain and the shared sum kept apart (a `routed` argument). The parts4 forms and the 16-byte combine go; add_rms_h is main's again. Every dumped call's outputs equal main's bytes.
8e1115e to
ebb3189
Compare
What moves
Step 1 of #548. Nemotron's four norm kernels in
nemotron_norms.cumove onto shared ops, and the file is deleted.tf_nemo_embed16: nowembed_rows' bf16 row copy.tf_nemo_add_moe_norm: nownorms.cu'stf_moe_add_rms_rows_bf16h, one launch. It isadd_rms_hwith the expert slots as its residual: each thread sums its 8 columns over the slots in slot order from 0 with fp32 fmas, rounds the sum to bf16, adds it to x, and then runs the shared norm. That is the arithmetic ofmoe_glue's combine followed bytf_add_rms_rows_bf16h, so the bytes are those of the two-launch form, without the second launch or the delta row.add_rms_hand its 16-byte helpers live innorms_vec.cuh, whichnorms.cuincludes.tf_nemo_concat_normsandtf_nemo_group_rmsnorm: now two newnorms.cuentry points,tf_rms_rows_bf16h_parts(rounded once) andtf_rms_rows_bf16h_parts_round(MLX rounding). Each runs one block per row part. Parts up to 512 wide (Mamba's groups) run ontf_rms_rows_bf16h_parts4/_parts4_roundinstead: four parts a block, 64 threads each. Each thread's 8-term chain and the warp tree are the same, and warp 0 + warp 1 is the 8-warp tree's total when warps 2-7 hold zeros, so the bits are the one-block form's (y and the sums byte-equal over 224 calls, widths 64-512, 1-2,048 rows, both roundings, NaN, ±inf, tiny values). The one-block forms are thin wrappers that offset the pointers sonorms_tail.cuh'stail<ONCE>/tail<MLX>(unchanged) lands each strided part.moe_glue: none of its launches remain here.Bytes: this step changes Nemotron's bytes. The shared tail sums the squares in its own order, not in the old per-family order. Other families are untouched: every other serving fatbin is byte-identical to
main,norms.fatbin's existing functions keep their SASS on sm_80/86/89/120/121, andcontract_controls.fatbingains one control.Kernel contract
norms_tailharness covers Nemotron's norm kernels at its shapes:add_yandadd_hfortf_add_rms_rows_bf16h, 16 and 300 rows × 2,688. Its h is one rounded add, so it is checked element-wise with no sums.moe_bf16_y,moe_bf16_h,moe_f32_y,moe_f32_hfortf_moe_add_rms_rows_bf16h, at the same shapes, with Nemotron's 8 slots: 6 routed weights (sigmoid scores normalized to 2.5) and 2 shared slots of weight 1, as bf16 slots (the prompt's down projection writes them) and as fp32 slots (decode's). h declares its sum: the slots' fp32 fma chain, depth 8, rounded to bf16 before the add; y's depth is that chain plus the squares'. The float64 check takes the combine as a range over the chain's gamma(8), rounded to bf16 at both ends, and checks h and y against it. Gate 4 gives h's kinds slot terms that are exact in fp32 in any order, with one term past fp16's range.parts_once_yfor 32 and 2 rows × 2,688;parts_round_yfor 128 and 2,400 rows × 512 (Mamba's 8 groups). Each parts kind runs the one-block and the four-a-block form.tf_ctl_tail_moe_keep_f32, runs the sameadd_rms_hbody with the combine kept in fp32 before the add. It goes red at shapes, as planted (row 0 h[1] = 1.8671875 outside float64's [1.875, 1.875]).tf_moe_add_rms_rows_bf16h128 regs on all five, 32 B static, bound 256.tf_add_rms_rows_bf16h80/80/80/98/98 regs, 32 B static. Both parts kernels 64 regs on all five, 32 B static, bound 256._parts436/34/34/36/36 and_parts4_round36/34/34/35/35 regs, 32 B static, bound 256. nvcc 13.2 compiles them the same.kernel-contracton the norm kinds: the 4 moe kinds and the 12 other norm kinds pass gates 1-4; the control is red as planted.kernel-contract-limitsat nvcc 13.0 and 13.2: every record of this step current, and the failing lines are the same as on the base.Nemotron's gates (Lightning MLX 4-bit, GB10)
Distance to a float64 CPU reference: MLX weights dequantized exactly, every op in float64. It covers 3 passages × 320 scored rows after a 64-token prefix. The fused kernel gives the same bytes as the two-launch form these rows were measured on (kl dump sha
c94ddc4a3f2fon both).Every argmax miss on either tree sits at a reference top-1 margin of at most 0.63 logits; nearly all are below 0.22. 23 rows miss only here and 13 miss only on
main. McNemar gives p ≈ 0.1, so the difference is not significant at 960 rows. Mean rel-L2 is equal and mean KL is 5% lower. The teacher-forced loss over seven public texts (3,584 tokens) is +0.0008 ± 0.0046 againstmain.Speed
Served A/B on a GB10, Lightning MLX 4-bit,
main2e33e297dagainst8a77e3ec0, 12 rounds. Each arm and round gets a fresh server, in alternating order. Cold TTFT is the median of 3 a round; plain decode is a fresh--no-draftsserver, 256 tokens, 3 reps. The table gives the median of the paired per-round ratios:Before the group-norm commit, cold TTFT was 0.1-0.3% slower than
main; an nsys trace put that on the one-block parts norm (16.8 / 67.6 ms at 8k / 32k againsttf_nemo_group_rmsnorm's 14.3 / 57.3). Drafted decode moves with acceptance, since the bytes differ frommain's, so it is not a speed comparison here.Receipt
8a77e3ec0onorigin/main2e33e297d; GB10 (sm_121), CUDA 13.0, Zig 0.17.0.zig build test -Dnvcc=/usr/local/cuda/bin/nvcc: exit 0. lean_check: 0 new.