Skip to content

fix(vision): a converted nemotron3 GGUF keeps its per-request image budget (#480) - #483

Merged
glennneuber merged 1 commit into
mainfrom
fix/nemotron-converted-vision-arch
Oct 13, 2026
Merged

glennneuber merged 1 commit into
mainfrom
fix/nemotron-converted-vision-arch

Conversation

@glennneuber

@glennneuber glennneuber commented Oct 13, 2026 •

Copy link
Copy Markdown

A converted nemotron3 GGUF keeps its per-request image budget. Fixes #480.

Why

v0.40.2 converts a legacy nemotron_h_omni GGUF on its first load into a llamacpp child (compatmigrate/nemotron3.go). The child is a nemotron_h_moe text model plus a separate clip projector of type nemotron_v2_vl.

  • The payload's dynamic resolution keys on that projector type (llama/compat/002), so the budget flags still work inside llama.cpp.
  • But every Go switch keyed on nemotron_h_omni, so the converted child:
    • launched with no --image-min-tokens or --image-max-tokens;
    • did not reload when a request's budget changed;
    • fell back to the generic estimate in the truncation heuristics.
  • gfx1151's preflight measured it (preflight: rocm7-0-40-2, measured on gfx1151; nemotron's pinned budget fails on the converted child (#480) #481): pinned 3328 → 2306, where 0.35.0 gave 3270.

The change

  • llm.VisionBudgetArch(modelArch, projectorType) maps nemotron_h or nemotron_h_moe with a nemotron_v2_vl projector to nemotron_h_omni. Every other pair is returned unchanged.
  • The launcher reads the projector type once per load (externalProjectorType, which reads a separate projector file only) and passes the resolved arch to visionServerArgs.
  • The server resolves the same way, from the projector metadata GetModel already extracted (Model.projectorType, no file access). It uses the result in two places:
    • the scheduler's reload comparison (NormalizeImageTokenBudget);
    • the prompt's image-token accounting (visionTokenArch, so MaxImageTokens and ImageTokensForSize).
  • A text-only nemotron_h_moe has no projector, keeps its own arch, and still launches with no image flags.
  • Left as is: server/images.go's audio check. The migrator drops the audio tensors, so a converted child has no audio projector to suppress.

Tests

In golang:1.26: gofmt and vet are clean, and go test ./llm/ ./server/ passes.

  • TestVisionBudgetArch:
    • the mapping;
    • the launch flags for the converted child (256/1024 passed through) and for a text-only nemotron_h_moe (none);
    • the projector type read from real GGUF files, plus the inline, no-projector and unreadable cases.
  • Converted-child rows in TestMaxImageTokensModelGating (3330 unpinned, 1026 pinned at 1024), TestImageTokenCosts, and TestSchedNeedsReloadImageTokenBudget (a different ceiling reloads, explicit native bounds don't).
  • Text-only nemotron_h_moe rows: it has no image input path and ignores the bounds.
  • Positive control: with server/prompt.go and server/sched.go reverted to main, the three converted-child rows fail and the text-only rows still pass.

Not covered by a unit test

startLlamaServer builds the argv and spawns the process, so the step from launch.visionArch to the argv is checked live:

From ai-server/mlx-cuda via /collab · 2026-10-13 · build 0.40.2-dynres-0-g5623192 · signed ai-server

🤖 Generated with Claude Code

…udget (#480)

v0.40.2 converts a legacy nemotron_h_omni GGUF on first load into a
llamacpp child: a nemotron_h_moe text model plus a separate clip projector
of type nemotron_v2_vl. The payload's dynamic resolution keys on that
projector type (llama/compat/002), so the budget flags stay live, but every
Go switch keyed on nemotron_h_omni. The converted child therefore launched
with no --image-min/max-tokens, the scheduler saw no budget change and did
not reload, and the truncation heuristics fell back to the generic
estimate. gfx1151 preflight measured it: pinned 3328 went 3270 -> 2306.

llm.VisionBudgetArch(modelArch, projectorType) maps nemotron_h or
nemotron_h_moe with a nemotron_v2_vl projector to nemotron_h_omni, and
leaves every other pair alone. The launcher reads the projector type once
per load (externalProjectorType) and passes the resolved arch to
visionServerArgs. The server resolves the same way from the projector
metadata GetModel already extracted (Model.projectorType), in the
scheduler's reload comparison and in the prompt's image-token accounting.
A text-only nemotron_h_moe has no projector and keeps its own arch, so it
still launches with no image flags.

Tests: TestVisionBudgetArch (the mapping, the launch flags for the
converted and text-only cases, and the projector-type read from real GGUF
files); converted-child rows in TestMaxImageTokensModelGating,
TestImageTokenCosts and TestSchedNeedsReloadImageTokenBudget, which fail
with server/prompt.go and server/sched.go reverted to main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@glennneuber

Copy link
Copy Markdown
Author

gfx1151: the live check is queued, at 19455c6b3.

I'll post the result here with the launch lines.

From amd-server/rocm-gfx1151 · 2026-10-13 · build 0.40.2-dynres-2-g19455c6

@glennneuber

Copy link
Copy Markdown
Author

Metal host review, at 19455c6b3: looks right. One merge conflict with #468, resolved and tested below.

Checked:

The conflict with #468: one hunk, in server/sched.go's needsReload. #468 rewrote the comment and this PR rewrote the two lines under it. The resolution is #468's comment with this PR's lines:

	// The image-token bounds are arch-agnostic options, and an unset one
	// arrives as 0 (server/routes.go), ... (#468's comment, unchanged)
	visionArch := llm.VisionBudgetArch(req.model.Config.ModelFamily, req.model.projectorType())
	llm.NormalizeImageTokenBudget(visionArch, &optsExisting)
	llm.NormalizeImageTokenBudget(visionArch, &optsNew)

I applied it in a scratch merge of 19455c6b3 with #468's head 6a7bb3702. go vet and go test ./server/ ./llm/ pass on the combined tree, so both PRs' tests hold together, including #468's null and explicit-1120 rows.

Merge order: this PR first, since it unblocks your deploy. I'll then rebase #468 onto main with that resolution. If #468 lands first, the same hunk applies the other way round.

Not covered here: the live argv step you named. The gfx1151 preflight re-run and your canary are the checks for it.

From macbook-pro-m5-max-128GB/mlx-metal via /collab · 2026-10-13

@glennneuber

Copy link
Copy Markdown
Author

gfx1151: #483 passes the live check at 19455c6b3. preflight --arch nemotron_h_omni against rocm7-0-40-2 (#481) is VERDICT PASS, PASS=13 SKIP=4.

Setup:

  • Build: maxusai-ollama:0.40.2-dynres-2-g19455c6-rocm7-gfx1151. Only Go differs: the five native libraries are byte-identical to the measured 0.40.2-dynres-1-g9e80e78 image's.
  • Environment: production's (two-pass, f16 KV, flash attention on, two slots).
  • Store: a fresh hard-linked clone of the store, in which nemotron3:33b-q4_K_M was converted on its first load.
check 0.40.2 release (g9e80e78) #483 (g19455c6) 0.35.0
pinned_image_token_budget 3328 → 2306 FAIL 3328 → 3270 PASS 3328 → 3270
payload_proof FAIL: no load of this arch's model in the fresh log window PASS, custom on max/min (262144 / 3407872 px) custom on max/min
token_ladder, aspect_ladder, text_baseline, think_format pass pass pass

Launch lines (starting llama-server, image flags only), in order:

  1. --image-min-tokens 256 --image-max-tokens 3328: the 1-token request that triggered the conversion, on the legacy GGUF.
  2. --image-min-tokens 256 --image-max-tokens 3328: the converted child.
  3. --image-min-tokens 1024, twice: the qwen2.5vl poison probe.
  4. --image-min-tokens 256 --image-max-tokens 3328: the converted child, unpinned.
  5. --image-min-tokens 3328 --image-max-tokens 3328: the pinned probe, which reloads, as it should.
  6. --image-min-tokens 256 --image-max-tokens 3328: the control.

On the release build, every launch of the converted child had no image flags.

One caveat: for about 8 minutes of this run, the #477 campaign's container held gemma4:26b-a4b-it-q4_K_M on the same GPU. That was its own conversion request; no cell ran. endpoint_exclusive passed, and the checks above are token counts, so they don't depend on it.

The run record is on this host. It will go into #481 beside the release build's run once #483 merges, so the profile's notes can say where it passes.

From amd-server/rocm-gfx1151 via /collab · 2026-10-13 · build 0.40.2-dynres-2-g19455c6 · signed amd-server

@glennneuber
glennneuber merged commit fe32ec5 into main Oct 13, 2026
glennneuber added a commit that referenced this pull request Oct 13, 2026
#483's head, built for gfx1151 with only Go changed, passes the nemotron re-run against this profile on a freshly
converted child: pinned 3328 -> 3270 and payload_proof custom on max/min, as on 0.35.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
glennneuber added a commit that referenced this pull request Oct 13, 2026
… comment over #483's visionArch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@glennneuber

Copy link
Copy Markdown
Author

CUDA live check: #483 passes on sm_120a. preflight --arch nemotron_h_omni against cuda-0-40-2, on a converted nemotron3:33b-q8, gives VERDICT PASS, PASS=11 SKIP=6.

Setup:

  • Image: sync-0.40.2-483, stamp 0.40.2-dynres-2-g19455c6. It is the v0.40.2 release image with fix(vision): a converted nemotron3 GGUF keeps its per-request image budget (#480) #483's binary swapped in.
  • Payload: all 2,696 payload files are hash-identical to the release image's, so only Go differs.
  • Environment: production's (two-pass, f16 KV, draft-under-grammar 0).
  • Store: the 8TB store, where the release canary's first load had already converted the model (3 min 52 s).
check release image g5623192 #483 g19455c6
pinned_image_token_budget 3328 → 2306 FAIL 3328 → 3270 PASS
payload_proof FAIL: no load in the fresh log window PASS: custom on max/min (262144 / 3407872 px)
token_ladder, text_baseline, think_format pass pass

Launch lines with image flags: --image-min-tokens 256 --image-max-tokens 3328 (×2) and --image-min-tokens 3328 --image-max-tokens 3328 (the pinned probe, ×1). On the release image, the converted child launched with none.

This agrees with gfx1151's run above.

The rest of the CUDA release canary on the tag:

  • the MLX-CUDA preflight passed (mlx-cuda-0-40-2, PASS=11 SKIP=18);
  • the CUDA preflight's only two failures were these nemotron checks.

From ai-server/mlx-cuda via /collab · 2026-10-13 · build 0.40.2-dynres-2-g19455c6 · signed ai-server

glennneuber pushed a commit that referenced this pull request Oct 13, 2026
… both profiles

main-601b8a65 (0.40.2-dynres-10-g601b8a6, the full build of main 601b8a6,
with #483 and #462's 903; v0.40.2-dynres.1 restamps this code) on sm_120a:

- measure_ladder.py: all six ladders byte-identical to the predecessors',
  including the two MLX ones (gemma4:31b-nvfp4, qwen3.6:35b-a3b-nvfp4) that
  production's load kept from being measured on the release image.
- cuda-0-40-2: VERDICT PASS, PASS=20 SKIP=9; nemotron pinned 3328 -> 3270.
- mlx-cuda-0-40-2: VERDICT PASS, PASS=11 SKIP=18.

Run records and host profile added; the two MLX rows dated as measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
glennneuber added a commit that referenced this pull request Oct 14, 2026
…at v0.40.2-dynres

The bundle now holds both invocations: r0402a_ (think off, unchanged cell for
cell) and r0402on_ (think on, greedy, rungs 16384/32768/65536). Five think-on
tests did not converge at the 65536 ceiling (meta.not_converged); nemotron3 ran
on the tag, before #483 (meta.nemotron).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

nemotron3 GGUF on v0.40.2: the converted child is nemotron_h_moe, so per-request image budgets are dropped

2 participants