Skip to content

Nemotron on HIP: read each matrix at its own oQ width (part 2 of 3) - #3242

Open
gilby wants to merge 9 commits into
ashhart:mainfrom
gilby:strix/nemotron-oq-loader
Open

gilby wants to merge 9 commits into
ashhart:mainfrom
gilby:strix/nemotron-oq-loader

Conversation

@gilby

@gilby gilby commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Nemotron 3.5 Lightning on HIP read only mlx-q4g64. With this, the loader, admission and the memory plan read each matrix at the width config.json states for it, so the oQ mixed-width MLX checkpoints the family's Metal backend already serves load on HIP: MLX affine at 4, 5, 6 or 8 bits in groups of 64, a width per matrix. No kernel source or launch changes: the shared HIP tiles already decode these widths (part 1 adds the relu² tile's contract records at them), and the single width lived in the Nemotron HIP admission, loader and plan.

  • The loader's table returns the per-matrix width config.zig already parses (as qwen3_5 does on HIP); routed stacks are read at their own width and their geometry checked against it.
  • The plan counts every matrix at its own width, the draft rows at lm_head's.
  • Admission admits 4/5/6/8 bits in groups of 64 and refuses the rest by name; the start-up line names the widths, e.g. Nemotron-H model mlx-q4g64+q5g64+q6g64+q8g64 18.14 GiB.
  • Still refused by name, each with a host test: other bit widths and group sizes, unquantized (bf16) projections and embeddings, mtp.layers.* inside the main shards.
  • New in this part: the MTP head file beside a model not stored 4-bit in groups of 64 is refused by name, before any weights load. On this part alone loadMtp builds the head's table at the model's width and admission plans the head at c.bits, so an oQ6 or oQ8 model beside the stock 4-bit mtp-4bit.safetensors would pass admission and then fail at load. The refusal reads mtp-4bit.safetensors sits beside a model stored 6-bit in groups of 64: Nemotron on HIP reads the MTP head beside a model stored 4-bit in groups of 64 only; move mtp-4bit.safetensors out of the folder to serve it without drafts. Such a checkpoint is served on this part without drafts by moving the head file out of the folder; --mtp-drafts 0 does not help, because admission reads the folder before the policy decides whether to load the head. An oQ4e checkpoint, whose base width is 4-bit in groups of 64, keeps its head. Part 3 removes the refusal, and its test becomes part 3's acceptance test.

Part 2 of 3, for Nemotron 3.5 Lightning's oQ checkpoints on HIP. Part 3 (#3251) reads the MTP head file at its own width.

Checks on this head

  • Head commit: be9fe6237 (771530870 plus the changelog number). The receipt and the device checks below were measured on 5e4fc8035, before the two merges, against main 223da7659; I have not remeasured on the merged head. Since 223da7659 main has changed the routed prompt tile on gfx1151 (perf(hip): the routed prompt tile takes K-parallel passes for an item of few rows #2878), which this family's prompt pass uses on main and here alike; this PR's own diff against main is the same 13 files before and after the merges.
  • Lean checker, python3 tools/zig/lean_check.py, on the base and on this head: 341 problems on main aaac299d1 and 341 here, 0 new (341 on 223da7659, part 1 and 5e4fc8035 too). python3 tools/pr_check.py --base origin/main --pr 3242: 0 errors, 0 warnings.
  • Tests run (on 5e4fc8035): zig build test -Dhip=true --summary all: 896 of 899 steps succeeded, 13,756 of 13,875 tests passed, 116 skipped, 3 failed. Main at 223da7659: 896 of 899 steps succeeded, 13,748 of 13,867 tests passed, 116 skipped, 3 failed. The three failures fail the same way on main: dashboard_test "dashboard page polls the public stats snapshot without private details", and the Flash Next HIP host tests gather: the parked pool's parts copy the same bytes as one thread for every id count and gather: a cold call wakes the pool and the same ids gathered again stay on the caller. zig fmt --check build.zig zig and git diff --check pass.
  • New host tests, each red with its line reverted: the head file beside a 6-bit and an 8-bit model is refused by name and admitted beside a mixed model whose base is 4/64 (red with the refusal removed: 13,755 of 13,875 passed, 4 failed: this test and main's three); the loader's table reads each matrix at config.json's width for it (red when width ignores its key); every refusal by name (red when every width is accepted); the plan's bytes equal the real oQ6 and oQ8e header bytes (red with a 4-bit-only count); the stock 4-bit test still pins the checkpoint at exactly 17,775,339,392 bytes, so the uniform path is unchanged. (The reverts after the first were checked on nemotron hip: read oQ mixed-width MLX checkpoints (4, 5, 6 and 8 bits, a width per matrix) #3068's head; the code under them is unchanged here.)
  • On the GPU (gfx1151): zig build hip-kernel-contract, exit 1: SUMMARY gfx1151 162 kernels: 162 pass gates 1-4, 0 fail, 2 known findings; 44 controls: 44 red as planted, 0 missed; 173 limit problems (part 1's, as this part changes no kernel or record). zig build hip-kernel-test: 159 of 165 passed, 6 skipped, 0 failed, the same as main.
  • Smoke on this head (one Strix Halo iGPU, gfx1151, page cache dropped first): the stock 4-bit checkpoint with its head loads (Nemotron-H model mlx-q4g64 17.34 GiB, MTP head loaded) and drafts (91 of 108 drafts accepted on one 128-token reply); a local oQ6e build (6-bit base, 8-bit elsewhere) with the stock head beside it is refused by name with the message above before any weights load (device memory unchanged), with --mtp-drafts 0 too; the same folder with the head file left out loads (Nemotron-H model mlx-q6g64+q8g64 24.43 GiB, MTP head off) and replies.
  • On the merged code (771530870, this head less the changelog number) against main aaac299d1, on the gfx1151 box, checks only: zig build test -Dhip=true --summary all 747 of 747 steps, 10,036 of 10,115 tests passed, 79 skipped (main 747 of 747, 10,028 of 10,107, 79 skipped); lean 341 and 341; zig build hip-kernel-contract, exit 1, SUMMARY gfx1151 164 kernels: 164 pass gates 1-4, 0 fail, 2 known findings; 45 controls: 45 red as planted, 0 missed; 174 limit problems (main: 161 kernels, 42 controls, 174 limit problems, exit 1); hip-kernel-test 216 passed, 6 skipped, 0 failed on both.
  • Prompt state against chunk size (on 5e4fc8035): tf-nemotron-hip-test teacher over a 1,000-id history in chunks of 256 and 300 gives the last chunk's rows bit-identical to the one-chunk run on the oQ4e, oQ6, oQ8e and stock 4-bit checkpoints (232 of 232 and 100 of 100 rows each). A last chunk under 16 rows (8 rows) differs in the output head's logits by at most 0.015625 with the same greedy tokens, on this head and on main alike. Details and raw files in the comment below.
  • CI: all checks that ran passed on be9fe6237.
  • Not run, and why: other HIP chips (no card); TP > 1 on the GPU with an oQ checkpoint (host tests cover the split); Metal and CUDA (untouched).

Receipt

One Strix Halo iGPU (gfx1151), fresh servers with the page cache dropped before each, --context 131072 --parallel 4 --prompt-cache-gib 0; every number below was measured on this head (5e4fc8035) against main (223da7659), binaries built from those two heads (sha256 f8a6c92a10bb… for this head, ae9d7e30e28e… for main). The power mode read performance at the start and end of every arm, and no other process on the box used more than 20% of a core during any arm (load and top-process records are in the raw files). Sampled cells carry the tools' default seeds: tools/bench_concurrent.py sends --seed (default 1234), stream i at T=1.0 with seed 1234 + i; tools/bench_openai.py sends 1234 + rep for rep 0–2 (and 1234 for its warm-up). Raw tool JSON, server logs and driver scripts for every arm: https://gist.github.com/gilby/77553fdd074a0c4a59ec30b3212bb4e1.

A. The 4-bit checkpoint, main against this head (TensorFold/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-MLX-4bit at d9d758fb with mtp-4bit.safetensors; arms main, this, this, main). Aggregate tok/s, both arms of each side:

cell main this head change
bench_openai code, T=0 / T=1.0 122.8 / 124.6, 117.2 / 117.1 124.6 / 124.6, 116.0 / 117.1 +0.8% / −0.6%
bench_openai chat, T=0 / T=1.0 99.1 / 98.5, 91.7 / 92.9 98.8 / 99.2, 90.9 / 93.0 +0.2% / −0.4%
1 stream, T=0 / T=1.0 131.6 / 132.7, 124.5 / 124.4 131.5 / 131.5, 124.4 / 124.4 −0.5% / 0.0%
2 streams, T=0 / T=1.0 144.7 / 144.5, 137.1 / 136.9 144.5 / 144.6, 135.7 / 135.9 0.0% / −0.9%
4 streams, T=0 / T=1.0 175.6 / 176.4, 168.0 / 168.0 175.3 / 176.1, 168.2 / 168.1 −0.2% / +0.1%
--no-drafts code / chat, T=0 88.1 / 88.0 87.9 / 87.9 −0.2% / −0.1%
cold first token, 2,048 / 7,099 / 28,333 tokens 0.724 / 0.729 s, 2.660 / 2.675 s, 13.20 / 13.49 s 0.731 / 0.729 s, 2.685 / 2.676 s, 13.45 / 13.44 s +0.4% / +0.5% / +0.8%
  • The widest per-pair differences (this head's arm against main's arm in the same position): 2 streams T=1.0 at −1.0% and −0.7%, bench_openai code T=1.0 at −1.1% and 0.0%, 1 stream T=0 at −0.1% and −0.9%; no cell is 2% or more slower in both pairs, and the aggregate changes above are all under 1%. Cold first token is the first request of each arm (it reports zero cached tokens; the 12 cold rows of every arm are cold). bench_openai chat at T=0 reads either about 95 or about 99 tok/s from one repetition to the next on both builds (main's first arm 95.4 in one of three repetitions, this head's first arm 94.9 in one), so its medians move by that step.
  • Tokens. Per-request token hashes (tools/bench_concurrent.py and bench_cache.py token_sha256, bench_openai.py token_sha, 12 hex digits) of this head equal main's in every request: for each of this head's two arms against each of main's two arms, 66 of 66 equal, 0 unequal, 0 failed (12 cold replies, 42 requests at 1, 2 and 4 streams greedy and seeded T=1.0, 12 bench_openai requests); main's two arms equal each other and this head's two arms equal each other, 66 of 66. With --no-drafts: 12 of 12 equal between main and this head, and each build's drafted bench_openai replies equal its own --no-drafts replies, 12 of 12. In every cell of every arm concurrent equals alone (42 equal, 0 unequal, 0 failed) and alone equals "draft": false (24 equal, 0 unequal, 0 failed). Server-log totals per arm: 19,172 reply tokens in every arm of both builds, 2.29–2.31 tokens a round on main and 2.30–2.31 here.
  • Memory on the device is 24.42 GiB (GTT in use once loaded) on main and here; the start-up line reads Nemotron-H model 17.34 GiB, MTP head loaded on main and Nemotron-H model mlx-q4g64 17.34 GiB, MTP head loaded here.

B. Mixed-width public checkpoints on this head (they do not load on main: an oQ4e fails at load with backbone.layers.0.mixer.in_proj: packed words and group tables have incompatible or unrepresentable geometry, an 8-bit one is refused by name). The oQ4e with the stock head beside it (its base is 4/64); the 6- and 8-bit ones with the head file moved out, as this part requires:

checkpoint widths, planned GiB on device plain code / chat, T=0 drafted code / chat, T=0 / T=1.0 1 / 2 / 4 streams, T=0 / T=1.0 cold first token 2K / 7K / 28K
user3892r4 mlx-oQ4e (3948ddc5), head beside 4+5+6+8, 18.14 25.19 GiB 67.1 / 67.0 code 112.4 / 112.4, 98.5 / 96.2; chat 91.0 / 90.9, 81.7 / 81.3 T=0: 117.4 / 141.7 / 170.6 and 117.3 / 143.1 / 170.3; T=1.0: 118.7 / 130.1 / 160.6 and 118.7 / 130.1 / 160.6 0.727 / 2.674 / 13.43 s and 0.728 / 2.672 / 13.49 s
TensorFold oQ6 (8cf893c6), no head 6+8, 24.20 30.69 GiB 61.4 / 61.4 n/a (no head in this part) T=0: 68.1 / 83.5 / 109.2; T=1.0: 67.3 / 83.5 / 108.2 0.692 / 2.529 / 12.49 s
user3892r4 mlx-oQ8e (5b942665), no head 8, 31.27 37.82 GiB 53.5 / 53.5 n/a (no head in this part) T=0: 54.1 / 70.1 / 91.0; T=1.0: 58.5 / 70.5 / 91.9 0.718 / 2.634 / 12.77 s
  • The oQ4e rows show both arms (code and chat tok/s at T=0 then T=1.0; streams at 1 / 2 / 4); the oQ6 and oQ8e rows are one arm each. "Plain" is a separate --no-drafts arm (one for the oQ4e, one each for the oQ6 and the oQ8e); the oQ6 and oQ8e streams and cold columns come from the default-policy arm without a head, where the n-gram drafter still runs (889 of 1,126 and 778 of 1,098 drafted tokens accepted; 1.05 tokens a round), which is why its decode is not the plain figure. Main refuses or fails all three on the same box: the oQ4e at load with backbone.layers.0.mixer.in_proj: packed words and group tables have incompatible or unrepresentable geometry, the oQ6 with Nemotron on HIP reads MLX affine 4-bit groups of 64; this checkpoint is 6-bit in groups of 64, the oQ8e with the same message for 8-bit.
  • Tokens: concurrent equals alone (42 equal, 0 unequal, 0 failed) and alone equals "draft": false (24 equal, 0 unequal, 0 failed) in every cell of the five default-policy arms and, for the oQ4e, both arms equal each other (66 of 66) and its drafted bench_openai replies equal its --no-drafts replies (12 of 12); the oQ6 and oQ8e default-policy replies equal their --no-drafts replies (12 of 12 each). Every cold row is cold (4 of 4 at each size, in every arm).
  • Plan memory against device memory. GTT in use once loaded, less the start-up line's planned GiB: 7.08 on the stock 4-bit (main and this head alike), 7.05 on the oQ4e (head loaded), 6.49 on the oQ6 and 6.55 on the oQ8e (head off). The plan follows the real bytes at every width; the 0.5 GiB between the head-loaded and head-off figures is memory the plan does not account for, which I did not trace.
  • A checkpoint with a bf16 embedding and bf16 attention stays refused by name, as Nemotron on HIP: read oQ mixed-width MLX checkpoints (4, 5, 6 and 8 bits, a width per tensor) #3061 asks about; so does a head stored inside the main shards.

Fidelity against an independent reference

Reference: mlx_lm 0.32.2 on mlx 0.32.4 (Metal), loading the same public checkpoint files (each file checked against the revision's LFS sha256 on both machines). The 16 prompts of tools/bench_prompts.json (main 223da7659) were each followed by the reference's own greedy reply of up to 256 tokens. Engine: tf-nemotron-hip-test at this head (5e4fc8035) on gfx1151, teacher-forced on those histories with --no-mtp: teacher for every row on the prompt path, decode --steps K for every reply row on the decode path. I compared each row's top token. Every row's top-32 ids and logits are identical to the same run at 5474a338e, the earlier head of #3068. A margin is a side's top-1 logit minus its logit for the other side's token.

checkpoint widths rows, prompt / decode differences, prompt / decode largest margin among differences (ref / engine)
user3892r4/...-mlx-oQ4e @ 3948ddc5 4, 5, 6, 8 4,601 / 4,096 75 / 48 1.75 / 2.25
TensorFold/...-oQ6 @ 8cf893c6 6 (routed experts), 8 4,601 / 4,096 53 / 47 4.5 / 1.625
user3892r4/...-mlx-oQ8e @ 5b942665 8 4,536 / 4,031 64 / 44 3.25 / 3.75
control: TensorFold/...-MLX-4bit @ d9d758fb (main serves it) 4 4,534 / 4,029 67 / 55 2.125 / 1.75
planted control: the 4-bit checkpoint's engine against oQ4e's reference – 4,601 / – 343 / – 12.625 / 9.125

Most differences are larger than one bf16 step, and every one is listed in differences.md. Twelve have a margin of 2 or more on one side (oQ4e 1, oQ6 6, oQ8e 4, 4-bit 1), and the largest is oQ6, heist-story, row 210: reference the 31.0 against a 26.5, engine 29.25 against 29.5. None of this is specific to a width: the uniform 4-bit checkpoint that main serves has the same count and the same spread of logits (p99 per-row error 2.4 to 2.75 at every width), while the planted control has 7.5% of rows different and 212 at a margin of 2 or more.

The engine's prompt path and decode path also disagree with each other on 43 to 50 top tokens per ~4,000 reply rows at every width. Main 223da7659 on the 4-bit checkpoint does the same, and the earlier head's prompt rows were bit-identical to main's. Whether Nemotron on HIP is meant to hold prompt rows equal to decoded rows is a separate question for main, which I will raise in its own issue. The tools, both sides' top-32 rows for every position, every difference with both margins, and the prompt-versus-decode measurements on main: https://gist.github.com/gilby/68a3888329b36947d80b7f277a202d24

  • Main against branch: see Receipt A.
  • Boxes it reaches: HIP devices serving Nemotron-H. Shared code touched: none outside the family; no launch or kernel changes for any family.
  • New switch? None. No default changes; the 4-bit checkpoint's tokens, speed and memory are shown equal to main's in Receipt A.
  • Environment: Zig 0.17.0, zig build native-hip -Dhip=true -Dhip-arch=gfx1151 -Doptimize=ReleaseFast, system hipcc (HIP 7.1, clang 21), one Strix Halo iGPU (gfx1151), Linux 7.0.
  • Paths and platforms: the default path (the uniform 4-bit checkpoint) and the new per-matrix widths, gfx1151 run; other HIP chips not run (no card).
  • Exactness: from tools/bench_concurrent.py --alone --serial --temperatures 1.0,0: this head equals main in 66 of 66 requests for each pair of arms (Receipt A), concurrent equals alone (42 equal) and alone equals "draft": false (24 equal) in every cell of every default-policy arm, 0 unequal and 0 failed anywhere.
  • Decode speed before and after, from tools/bench_openai.py --tokens 256 --reps 3: within −0.6% to +0.8% on code and chat, T=0 and T=1.0 (Receipt A); --no-drafts 87.9 / 87.9 against 88.1 / 88.0 tok/s code / chat.
  • Prompt speed before and after, from tools/bench_cache.py --require-cold at 2K, 7K and 28K tokens: first token +0.4%, +0.5% and +0.8% over main's mean of two arms (Receipt A), every row cold.
  • Licence of any new weights or drafter: n/a (no weights added; the checkpoints named are public uploads read as published).

Checklist

  • The title and description match the head commit, including the receipt and the stack.
  • 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 in the diff or its commits. No AI attribution lines.
  • 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.
  • Decode and prompt processing are no slower than main at one stream and the widest admitted; with --drafter, no slower than without it, greedy and sampled

Gates 1-4 run the relu^2 stream tile at every width Nemotron's oQ
checkpoints store it (4, 5, 6 and 8 bits in groups of 64), each wider
width with a planted control that reads its codes as 4-bit and goes red
at gate 3. Gate 5 records the 24 launches at 5, 6 and 8 bits on the four
RDNA chips. The 1x8 tile at those widths spills to scratch on gfx1100;
the records name it as a known finding and a host test holds the list
to the records.
The loader, the memory plan and admission read config.json's per-module
widths: MLX affine 4, 5, 6 and 8 bits in groups of 64, one width per
tensor. Routed stacks are checked against their stated width. Other
widths, unquantized projections and an MTP head inside the model's
shards are refused by name. The uniform 4-bit checkpoint reads as before.

The MTP head file is still read at the model's own width, so admission
refuses it by name beside a model not stored 4-bit in groups of 64.
@gilby

gilby commented Nov 13, 2026

Copy link
Copy Markdown
Contributor Author

CI on be9fe6237: all checks that ran passed (fmt, zig, zig-windows, zig-macos-dev-tools, pytest, release-paths, and benchmark-clients on Ubuntu and Windows); host-nightly-linux and host-nightly-macos were skipped, as they are for pull requests. Main has added changelog lines since, so GitHub shows a conflict again; it is in CHANGELOG.md only.

@gilby

gilby commented Nov 13, 2026

Copy link
Copy Markdown
Contributor Author

The gate item "prompt state independent of chunk size", measured on part 2's head 5e4fc8035 with the public oQ checkpoints, one Strix Halo iGPU (gfx1151), Zig 0.17.0, tf-nemotron-hip-test built from the head's own tree (zig build tf-nemotron-hip-test -Dhip=true -Dhip-arch=gfx1151 -Doptimize=ReleaseFast).

tf-nemotron-hip-test teacher <model dir> <ids.bin> <logits.bin> --no-mtp --chunk N teacher-forces a 1,000-id history (the same ids in every run: id i is (i × 7919) mod 120000 + 100) and writes the logits of the rows of its last chunk only, so one run in a single chunk (--chunk 2048, all 1,000 rows kept) is the reference, and a run in chunks of N is compared with the same last rows of the reference, bit for bit over every float of every row:

checkpoint chunk 256 (last chunk 232 rows) chunk 300 (last chunk 100 rows)
user3892r4 mlx-oQ4e (3948ddc5) 232 of 232 rows bit-identical 100 of 100 bit-identical
TensorFold oQ6 (8cf893c6) 232 of 232 100 of 100
user3892r4 mlx-oQ8e (5b942665) 232 of 232 100 of 100
stock 4-bit (d9d758fb), on this head 232 of 232 100 of 100
stock 4-bit, on main 223da7659 232 of 232 100 of 100

In every run the logits are finite and 0 rows differ, with a maximum absolute logit difference of 0.

A last chunk under 16 rows. --chunk 124 over the same 1,000 ids leaves a last chunk of 8 rows. On the stock 4-bit checkpoint, compared with the one-chunk run:

build rows bit-identical rows differing largest absolute logit difference greedy token of the 8 rows
this head (5e4fc8035) 0 of 8 8 0.015625 the same 8 as the one-chunk run
main (223da7659) 0 of 8 8 0.015625 the same 8 as the one-chunk run

So the shorter last chunk changes the output head's logits on this head exactly as it does on main, here by at most 0.015625 on this history, with the same greedy tokens; this PR does not change that behaviour. The chunk sizes whose last chunk is 16 rows or more (232 and 100 rows above) are bit-identical on every checkpoint. I did not run the trunk comparison for the 8-row case.

The box's load average and top five processes before and after each run are in the raw files (no process other than the test and the tooling above 1% of a core; the power mode was not recorded for these runs). Raw per-run logs and comparison JSON: https://gist.github.com/gilby/b5177e19b978d05604050ec41df4744b.

@ashhart

ashhart commented Nov 14, 2026

Copy link
Copy Markdown
Owner

Thanks @gilby. The loading and byte planning tests pass. The temporary MTP refusal is explicit, so this part does not silently read a head at the body's width.

We will run the device check on our side for stock 4 bit, uniform 8 bit and mixed oQ6 bodies. We will check actual loaded bytes, the named MTP refusal, concurrent and resumed replies, and prompt and decode speed. Stock tokens must match main; each newly admitted checkpoint must match its own plain target.

  • Synthena (Ash's Agent)

This branch has not been deployed

No deployments
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