Skip to content

Nemotron Parse on HIP: NVIDIA's checkpoint, token-exact against its HF example - #1941

Open
laudney wants to merge 8 commits into
ashhart:mainfrom
laudney:pr-ocr-nemotron-parse
Open

laudney wants to merge 8 commits into
ashhart:mainfrom
laudney:pr-ocr-nemotron-parse

Conversation

@laudney

@laudney laudney commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Part of #1927.

This serves NVIDIA's nvidia/Nemotron-3.5-Parse (revision 4035ffb1d8f8bafcdb867dfaf6f7514f101464f9) natively on HIP. The checkpoint's original FP32 safetensors stay unchanged. The loader validates all 510 tensors and owns BF16 device copies of the 509 floating tensors. The RADIO image tower, the horizontal neck, the causal mBART decoder and the trained one-step proposal run in Zig and HIP, with no Python in the served engine. It is the checkpoint other engines publish numbers for and the one users download first, so serving the publisher's weights matters for benchmarking and adoption.

Before (main) After (this PR)
nvidia/Nemotron-3.5-Parse on HIP not supported (its NFKC tokenizer did not load before #1931) served natively on Radeon AI PRO R9700 (gfx1201)
Python in the served engine — none; one pinned HF oracle under tools/zig/, adapted from the model repository's example
HTTP token arrays vs HF (two 512×256 pages, one 1664×2048 page) — all three match, plain and drafted, solo and concurrent
Prompt KV and final-hidden bytes (full vs chunked, solo vs shared, after suffix rollback) — identical
Kernel contract on gfx1201 52 kernels pass gates 1-4 64 kernels pass gates 1-4 (12 new operator cases), 5/5 controls red
Inherited limit problems (live gfx1201 / offline, 4 chips) 124 / 503 124 / 503, the same entries (0 new)
Existing HIP families — unchanged: their sampling results are the same; new serving hooks are opt-in

Prerequisites #1929, #1931 (NFKC tokenizer) and #1933 (no-reasoning replies) are merged. This branch still carries their earlier copies at its base, so a rebase onto main leaves only the Parse commits, 7fb39aaed^..e29b3e2f3.

Commits

  1. Checkpoint and image preparation: the checkpoint schema, the image processor with Pillow and NumPy fingerprints, and the HF oracle tools/zig/nemotron_parse_reference.py with its processor command. The oracle carries NVIDIA's OpenMDW-1.1 SPDX header, and LICENSES/NVIDIA-OpenMDW-1.1.txt is added.
  2. feat(hip): shared dense vision operators. The ops image gains embedding, biased linear, layer norm, pointwise, layout, resize and content-normalized position kernels, with a vision handle that loads them. Twelve new kernel-contract cases cover gates 1-4, and a record step compiles the ops image for gfx1030, gfx1100, gfx1151 and gfx1201, so the launch records come from real objects. Gate 4 now names FP32 sums that pass within a derived bound, instead of calling them exact.
  3. feat(hip_serve): shared load options and FP32 score processing.
    • The load options move into hip_serve with an image field.
    • The host draw can apply a checkpoint repetition penalty over prompt and reply tokens, OpenAI penalties and a forced final EOS on FP32 scores, without rounding back to BF16.
    • Images carry their visible size before padding.
    • A family that declares penalties, an image hook, a prompt cache or raw-text capability gets them.
    • For other families, the defaults leave the draw unchanged.
  4. The family and its registration: the engine, the encoder-decoder cache, the template bridge, the recipe docs/recipes/nemotron-parse.md, and the rest of the oracle (reference, positions, compare). It also sets .repeat_keyed = true (Prompt cache: an opt-in rule for families to count keyed image prompts as repeats #1929) and pub const reasoning = false; (Server: one engine flag for models whose replies carry no reasoning block #1933). Cancelling one request ends only that request, even inside a shared verification round.
  5. Review follow-ups: a registry test asserts !reasoning(parse), and the oracle pins the SHA-256 of all 16 non-weight sources at 4035ffb1d and the reference package versions, with a sources command that checks a checkpoint without a GPU.

Known limits

  • BF16 intermediate tensors differ from HF's CPU GEMM rounding. The saved seam errors are diagnostic, and the output token arrays are checked separately.
  • These are qualified only on gfx1201: CUDA, Metal and other AMD hardware are not. PNG is tested. JPEG uses the pinned libjpeg-turbo decoder but has no qualification receipt. Structured output has no real-model receipt.
  • Sampled plain-vs-drafted equality passes in six cells at T 1.5 on the two small pages (seeds 3, 7 and 11, one with penalties), which leave the greedy path. It does not compare TensorFold's keyed sampler with HF's random draws.
  • This PR claims no speed result.

Gates

  • Kernel contract on a Radeon AI PRO R9700 (gfx1201): SUMMARY gfx1201 64 kernels: 64 pass gates 1-4, 0 fail, 2 known findings; 5 controls: 5 red as planted, 0 missed; 124 limit problems. The upstream/main base reports 52 kernels ... 124 limit problems on the same GPU, with the same limit entries. An offline audit for four chips lists the same 503 entries as upstream/main.
  • Host:
    • test-nemotron-parse-host, test-tool-parse-host and test-lane-api-host pass.
    • zig test zig/src/core/tokenizer/tokenizer.zig: 43/43.
    • zig build test -Dhip=true: 497/497 steps.
    • zig build test: every Parse step passes. One known-flaky CUDA disk test, which this PR does not touch, failed once and passed on rerun.
  • Commits: every commit builds native-hip for gfx1201.
  • Lean checker: zero new problems over the base.

@laudney

laudney commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

The #1938 review asked dots.mocr for pinned sources, a device receipt and a per-family reasoning assertion. This PR gets the same now, in two new commits on top (6bd72817e, e29b3e2f3), with no force-push.

Item Before Now
Publisher sources the oracle hashed only hf_nemotron_parse_modeling.py the SHA-256 of all 16 non-weight sources at 4035ffb1d (model, processor and example code, configuration, tokenizer, template, model card) and the Torch, TorchVision, Transformers and timm versions, refused on any difference; python3 tools/zig/nemotron_parse_reference.py sources --model <checkpoint> checks them without a GPU
Reasoning assertion the registry test from #1933 names no family a test asserts !reasoning(parse)
Device receipt the PR body's account below, from a new build of the PR head
Sampled plain vs drafted two seeds at T 0.7, which reproduce the greedy IDs also six cells at T 1.5 (pages 0 and 1, seeds 3, 7 and 11, one with penalties) that leave the greedy path; plain = drafted in all six

config.json names remote code in nvidia/C-RADIOv4-SO400M in its encoder auto_map. The pinned hf_nemotron_parse_modeling.py does not use it: it builds the full encoder from this checkpoint and imports only timm's Block, so the timm version is pinned.

Pinned sources at 4035ffb1d (SHA-256)
File SHA-256
README.md a97942afdd486e6e413afd66b5f7d955a995ad78b8eaf4a73cd010ac829813ef
chat_template.jinja 86e90f392a2779c96c96b5848248981a05750e7a11e0380b092a8c35e2c3e32f
config.json bff3460ae530573ad2e276627be86cab1b6859754b76e8b196410f17e7f0b7d5
generation_config.json 0ec0669b5d3df6c4f2fcbf41334c3d64f44fdee1d1e5f158a33b112edf1646c3
hf_logits_processor.py 37bb9612db8840eeda67f3bc9a2d8837aeebc3b206315229671d6d56c570704c
hf_nemotron_parse_config.py 5f4d2530457e41dc100df1fa614485aec8c94715f25cd69ba6e6ebdaf4611899
hf_nemotron_parse_modeling.py ec43d337be2797e2ad137326a68578af67694763eac304fd85b604b6775b93cc
hf_nemotron_parse_processor.py 983fa8aeb0d872da01296035b30d60c84fd857879a37c939c5c7203bad3c8b81
hf_prefix_geometry.py 41406f33399fd39bd0601fd2acd320f87fec5f9865f13fbcb4e4577d94b97048
postprocessing.py cef5a7379720d479f7fd6fe47fb0e83097ff47bc053f751e747134c882d5a3fe
preprocessor_config.json 16d54d73fc265b31f3771caf39770dc85213cae7594823ba7a6ac3f085315ada
processor_config.json b3e2ff2cc94f3d9412f179acf06d8bc5543305ec27966617977b3bd47fee9e5e
run_transformers_inference.py fc3568bb3e9cf48dbcb8ce0cd4e25ee7360cec9fa9da331141497ecee5dcea41
special_tokens_map.json 7073bb56328d2b66724495a7cf53841fccdcc9efcafb1e1b7cc916baa80e3c8c
tokenizer.json 34f566e728995769d971fc2707596bfb92c83298d357bc6ec29a7a1d3d12a74a
tokenizer_config.json 701dad9bcc5f964a6fa020e7d5018c71efc93aad79d471cfca541b9bb4f6d012

Device receipt

identity value
candidate PR head e29b3e2f3ab7; binary sha256 bfd14b52862a4e85427e790d74f84c40fb49327239e6a563390ee1966075c8bc
base upstream/main 71932143f95a; binary sha256 2e17004a34f084c3e2d1f84557cdb0c5f2ede94ff806913e94acf974b9806e33
build zig build native-hip -Dhip=true -Dhip-arch=gfx1201 -Doptimize=ReleaseFast (+ hipcc, device-lib paths); Zig 0.17.0
ROCm hipcc HIP 7.16.26385, AMD clang 24.0.0git (d6f6cb69); ROCm core 10.1.0; libamdhip64 bddbf3553ee6, libhsa-runtime64 1.21.0 0457249a6842, libamd_comgr 3.5.0 15f311387472
device AMD Radeon AI PRO R9700, gfx1201, 64 CU, PCI 0000:c3:00.0, one visible device, 29.8 GiB free at start
checkpoint nvidia/Nemotron-3.5-Parse @ 4035ffb1; model.safetensors sha256 81dd7609c589f3694ce75520c25af5010953231358de6aec4d03e8c6aac787e9
sources modeling ec43d337be27, processor 983fa8aeb0d8, config bff3460ae530, tokenizer 34f566e72899, chat template 86e90f392a27 (16 files in JSON)
HF oracle CPU, BF16, Torch 2.11.0+cpu, TorchVision 0.26.0+cpu, Transformers 5.14.1, timm 1.0.22, Python 3.13.11; greedy, 96 new tokens, repetition penalty 1.1
serve --backend rocm --device 0 --vision --name parse --parallel 2 --context 4096 --mtp-drafts 1 --prompt-cache-gib 0.25 --no-update-check
request temperature 0, max_tokens 96, return_token_ids, draft true or false; server default repetition penalty 1.1

Hashes: ascii = sha256 of ids joined by , (the server token_sha256); le = sha256 of ids as little-endian u32. Table shows 12 hex; full values in JSON.

page PNG sha256 prompt rows (visual + text) HF new ids (ends EOS 2) ascii le
page0 512x256 3ad6d5b2c802 135 (129 + 6) 29 767a18325068 6039fa85b6e4
page1 512x256 df640c2a9c44 135 (129 + 6) 30 b9a54161ae68 4e6e9a6c4ad5
page2 1664x2048 3ddb4d21151f 3335 (3329 + 6) 30 15e07bd7062a 80663eb0d877
check native HF / expected equal
before: base serve exit 1, cannot read <checkpoint>/tokenizer.json (UnsupportedNormalizer), no load refusal yes
prompt ids, 3 pages text tail [2,0,50004,50008,50001,50009] after 129/129/3329 image rows HF prompt_ids yes 3/3
fresh plain 767a18325068 / b9a54161ae68 / 15e07bd7062a 767a18325068 / b9a54161ae68 / 15e07bd7062a yes 3/3
fresh drafted 767a18325068 / b9a54161ae68 / 15e07bd7062a 767a18325068 / b9a54161ae68 / 15e07bd7062a yes 3/3
prompt cache cold, drafted 767a18325068 / b9a54161ae68 / 15e07bd7062a 767a18325068 / b9a54161ae68 / 15e07bd7062a yes 3/3
resumed, drafted 767a18325068 / b9a54161ae68 / 15e07bd7062a 767a18325068 / b9a54161ae68 / 15e07bd7062a yes 3/3
resumed, plain 767a18325068 / b9a54161ae68 / 15e07bd7062a 767a18325068 / b9a54161ae68 / 15e07bd7062a yes 3/3
resumed rows reused cached 134 / 134 / 3334 (drafted and plain) > 0 yes
concurrent (--parallel 2): pairs 01, 02, 12, triple 012 drafted; pair 01 plain; resumed pair 02 13 of 13 streams equal own page HF per page yes
page0 after other images cached 767a18325068 767a18325068 yes
forced EOS, max_tokens 4 [50011, 51035, 12782, 2] first 3 HF ids + 2 yes
sampled page0 T 0.7, top-k 20, top-p 0.95, seed 3 drafted 767a18325068, repeat equal: yes plain 767a18325068 yes (= greedy)
sampled page0 T 0.7, top-k 20, top-p 0.95, seed 7, presence 0.4, frequency 0.1 drafted 767a18325068, repeat equal: yes plain 767a18325068 yes (= greedy)
sampled pages 0,1 T 1.5, top-k 50, seeds 3/7/11 (7 with penalties) drafted, 6 cells off greedy plain yes 6/6
cancel in full-page prefill (disconnect at 1 s) reason=client-left prompt=3335 tokens=0 after=1.66s client-left, 0 tokens yes
exact after prefill cancel, page0 and page2 767a18325068 / 15e07bd7062a HF yes
cancel in decode (stream, ignore_eos, 512 max; disconnect after 3 chunks) reason=client-left prompt=135 tokens=3 after=1.26s client-left, 0 < n < 512 yes
exact after decode cancel, page0 767a18325068 HF yes
reject invalid image bytes 400 image 1 could not be decoded; send PNG, JPEG or WebP 400 yes
reject excess context (3335 + 1000 > 4096) 400 context_length_exceeded 400 yes
reject text-only request 400 Nemotron Parse requires image content parts 400 yes
exact after rejections, page0 767a18325068 HF yes

All 49 matrix cells and 6 supplementary sampled cells passed; all three server runs exited 0 after SIGTERM. Note: responses report enable_thinking: true (no reasoning content). The T 0.7 cells reproduce the greedy ids, so the T 1.5 cells carry the sampled evidence. Every greedy native token array ends with EOS 2 and finish_reason: stop, including the forced max_tokens 4 cell.

The replies report enable_thinking: true, which is the server default for the prompt flag. Since #1933, the family's reasoning = false keeps every token in content, and the prompt IDs above equal HF's, so the flag does not change Parse's output. JPEG is not in this receipt: the host's libturbojpeg is 3.2.1, and the engine accepts only the pinned 3.1.4.1, so it refused JPEG and every check used PNG.

Kernel contract and limits (the two new commits change no kernel)

upstream/main this PR
gfx1201 contract 52 kernels pass gates 1-4, 5/5 controls red 64 kernels pass gates 1-4, 5/5 controls red
Limit problems (live gfx1201 / offline, 4 chips) 124 / 503 124 / 503, the same entries

Ordering: #1929, #1931 and #1933 are now on main as your versions, so this branch shows one conflict with main, in zig/src/native/hip.zig (the #1933 copy beside the new Parse test). A rebase onto main leaves only the Parse commits. We do not force-push open PRs. If you prefer, we can push a merge of main instead.

@ashhart

ashhart commented Nov 2, 2026

Copy link
Copy Markdown
Owner

Thanks @laudney. One change, plus small points. The change: native/hip.zig's flagRefused now refuses --vision for every HIP family without its own flagRefused, and only Parse declares one. So Qwen 3.5, Qwen 3.5 MoE, Flash Next and Nemotron on HIP would stop at start-up, where main starts them text-only. Please take #1965's text-only start with its log line (fixed there in its 21:41 push), ideally from one shared HIP-OCR plumbing PR that #1938, #1941 and #1965 all build on. The rest holds: the tokenizer, repeat_keyed and Info.reasoning hunks are copies of merged PRs and drop out on rebase; the other shared edits are opt-in; all 510 tensors are validated by name, dtype and shape before any allocation; images are bounded before pixels are allocated; and the image identity hashes content and sizes, never a path.

@laudney

laudney commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, Ash. Fixed in f191e170c: the global --vision refusal is gone. Only Parse's own check runs, and it still requires --vision. Qwen 3.5, Qwen 3.5 MoE, Flash Next and Nemotron start text-only under --vision with the same log line as #1965 (--vision: the HIP engine has no image tower for <model_type>; serving text only, and image requests are refused), and an image request gets main's 400. A host test covers the four families. The shared plumbing PR then replaces this copy and #1965's with one host rule, so #1938, #1941 and #1965 all build on it. I will base it on main after #1940 lands.

@ashhart

ashhart commented Nov 3, 2026

Copy link
Copy Markdown
Owner

Thanks @laudney: the global --vision refusal is gone. The four HIP text families start text-only with #1965's log line and image requests get main's 400, while Parse still refuses an unreadable tower, with a flagRefused test. Please rebase: 16 carried files drop out. We have no AMD GPU, so your gfx1201 receipts on the rebased head are the device record. It lands after the shared plumbing PR. Small: no test of the log line or the 400.

Nemotron 3.5 Parse needs its own family before any device work, so
this validates the Parse config graph and every stored tensor of the
checkpoint, and ports its image processor with the same resize,
white padding, normalization and patch order. A host test checks the
processor against independent Pillow and NumPy fingerprints that the
new nemotron_parse_reference.py processor subcommand prints, and the
script keeps NVIDIA's OpenMDW notice with its license under LICENSES.
Image towers on native HIP need dense operators that the text kernels
do not provide, so the ops image gains embedding, biased linear,
layer norm, pointwise, layout, resize and content-normalized position
kernels, with a vision handle that loads them. The kernel contract
registers twelve new cases for gates 1 to 4, a record step compiles
the ops image for every served RDNA chip so their launch records come
from actual objects, and gate 4 now names FP32 sums that pass within
a derived bound instead of calling them exact.
An OCR family on native HIP needs serving hooks that the text families
never asked for, so the load options move into hip_serve with an image
field, and the host draw can apply a checkpoint repetition penalty
over prompt and reply tokens, OpenAI penalties and a forced final EOS
on FP32 scores without rounding back to BF16. Images carry their
visible size before padding for content-normalized positions, and the
native host takes a family's penalties, image hook, prompt cache and
raw-text capability when it declares them.
Nemotron 3.5 Parse now serves on one native HIP rank: its RADIO tower,
horizontal neck, causal mBART decoder and trained one-step proposal
head run in native kernels behind the shared lane verifier, a built-in
media template bridges chat image parts to the checkpoint's visual
prefix, and prompt reuse opts in to keyed repeats so a page served
again resumes from its image state. The family declares replies
without reasoning, so all OCR text is answer content, and cancelling
one request leaves the other admitted streams running. Host tests,
focused HIP operator and position checks, a model-seam probe, the
reference script's reference, positions and compare subcommands, and
a recipe cover the port.
The registry test from ashhart#1933 checks the hook on two anonymous
namespaces, so it does not name Nemotron Parse. A new registry test
asserts that the registered Parse family declares no reasoning block,
which keeps its OCR text in message content. Only this family's
assertion is added, so the other OCR families do not conflict with it.
The oracle hashed only hf_nemotron_parse_modeling.py and accepted any
checkpoint or package set. It now holds the SHA-256 of the model,
processor and example code, configuration, tokenizer and model card at
4035ffb1d, and the reference versions of Torch, TorchVision,
Transformers and timm, whose Block the encoder imports. The reference
and positions commands refuse a difference before running checkpoint
code. A new sources command checks a checkpoint and prints the pins.
The family flag check refused --vision for every HIP family without
its own refusal, so Qwen 3.5, Qwen 3.5 MoE, Flash Next and Nemotron
stopped at start where main served them text-only. Only Nemotron
Parse's own check runs now; a family without an image tower starts
text-only and logs that image requests are refused.
The note a text family prints under --vision had no test, and no
test showed that the host then offers no image tower, which is what
makes the server refuse image parts with a 400. The note now comes
from one function that returns the exact line openWith prints, and a
host test checks that line for Qwen 3.5, Qwen 3.5 MoE, Flash Next and
Nemotron, that no note is printed without --vision or for GLM-OCR and
Nemotron Parse, and that each text family's loaded engine gives the
lane host a null vision offer.
@laudney
laudney force-pushed the pr-ocr-nemotron-parse branch from f191e17 to d08e659 Compare November 3, 2026 12:37
@laudney

laudney commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on main c7952f398 (after #1940), as you asked, with the small test added. The gfx1201 record on the rebased head equals the earlier receipt in every id hash.

before after
head f191e170c d08e65998
base 71932143f plus copies of #1929, #1931, #1933 main c7952f398
commits 13 8
files 72 53
mergeable conflicting yes

Resolutions against #1940:

file resolution
hip_serve/options.zig, hip_serve/serve.zig main's files; this PR's copies are gone
core/engine_api.zig main's Ledger stays; PromptCacheCapacity/PromptCachePlan stay in engine_status.zig (identical types), which keeps the file at 570 lines
native/hip.zig registry …, glm_ocr.native, parse; flagRefused keeps GLM's tp_refusal/mtp_refusal plus Parse's hook; openWith passes .penalties beside #1940's visionOffer, and reads cache_capacity only from a family that declares it (Parse does not, as before)
tests/hip/contract_main.zig, hip_records.zig GLM-OCR's kernels and records, then the vision ones; the vision kernels stay last, since the vision-only diagnostic run takes the tail
intermediate commit c57adb68e the old global --vision refusal skips a family whose Loaded has a tower, so GLM-OCR keeps --vision at every commit; c63f9b65f then removes that refusal, as before

Your small point: d08e65998 adds the test. The note now comes from one function that returns the exact line openWith prints. A host test checks that line for Qwen 3.5, Qwen 3.5 MoE, Flash Next and Nemotron. It checks that no note is printed without --vision or for GLM-OCR and Parse. It also checks that each text family's Loaded gives the lane host a null vision offer, which is what makes messages.zig return its 400 (the 400 itself is main's test in server/compact_test.zig).

Checks on d08e65998: zig fmt --check; zig build test without and with -Dhip=true -Dhip-arch=gfx1201; host suites test-nemotron-parse-host, test-glm-ocr-native-host, test-hip-family-offers-host, test-hip-native-options-host, test-draft-hip-host, test-lane-api-host, test-qwen35-hip-host, test-hip-blas-host; zig build native-hip … -Doptimize=ReleaseFast; lean 424 problems, equal to main.

Device record (gfx1201, Radeon AI PRO R9700), binary sha256 db0a87e569c95c5f3fc0a5c2ca1e84d938bc205bc01a92e0fe439cb411a51e44, with the same driver, references and serve flags as the receipt in this thread:

check result
Parse driver: prompt ids, fresh plain/drafted, cache cold/warm, concurrent pairs and triple, seeded, cancel, rejections, forced final EOS, image isolation 49/49 pass
id hashes vs the earlier receipt (204 fields) all equal; pages A 767a18325068 / b9a54161ae68 / 15e07bd7062a, equal to HF
--vision on a text family (mlx-community/Qwen3.5-9B-4bit, --parallel 1 --context 4096) start note [tensorfold] --vision: the HIP engine has no image tower for qwen3_5; serving text only, and image requests are refused; text request 200; image request 400 this server accepts text parts only; image, audio and video inputs are unsupported; SIGINT exit 0

One note for the order: #1938 and this PR both add kernels/hip/ops/vision.hpp and contract/hip_records_vision.zig. Since this PR lands after the shared plumbing PR, I will rebase it again then and merge the two vision sets.

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