Skip to content

fix(vision): an explicit image_max_tokens of 1120 (or min of 70) is honoured; unset reaches the runner as 0 (ADR 0048) - #468

Merged
glennneuber merged 4 commits into
mainfrom
fix/image-budget-explicit-default
Oct 13, 2026
Merged

glennneuber merged 4 commits into
mainfrom
fix/image-budget-explicit-default

Conversation

@glennneuber

@glennneuber glennneuber commented Oct 11, 2026 •

Copy link
Copy Markdown

An explicit image_max_tokens: 1120 (or image_min_tokens: 70) is now honoured on every model. An unset budget reaches the runner as 0, "the model's own" (ADR 0048).

The problem

api.DefaultOptions fills the image budget with gemma4's 70/1120, so an unset budget and an explicit 70/1120 reached the runners as the same values. The resolvers for models with other bounds read 70/1120 as "unset" and substituted their own:

  • nemotronImageTokenBudget on llama.cpp;
  • ResolveImageBudget on MLX: muse-glimmer, qwen3.5 and nemotron_h.

ADR 0016 kept this as "a documented limitation — pick an adjacent value". On muse-glimmer it is no longer harmless, because 1120 is the documented default and so the value a client is most likely to send. Measured on MLX (30b-nvfp4 and 30b-mxfp8, 2026-10-11, #463), on a 2048×2048 image:

image_max_tokens unset 1024 1119 1120 1121 2048
image tokens 4100 1025 1091 4100 1091 2022

Changes

  • server: modelOptionsWithEmbeddingBatchDefault zeroes each bound that neither the request nor the Modelfile set (hasOption, as it already does for draft_num_predict).
  • llm and mlxrunner/model: only <= 0 counts as unset. Any explicit value is honoured, 70 and 1120 included.
  • gemma4 is unchanged. Its own bounds are 70/1120, so unset and explicit launch the same flags and don't reload each other.
  • Preflight is unchanged. No row sends 70 or 1120: the pinned probes use 560 and 3328, and the controls send nothing. The vision suite sends a budget only when IMAGE_*_TOKENS is set.
  • ADR 0048 records the change. ADR 0016's and 0021's sentinel clauses and nemotron-dynres-patch.md now point to it.

Tests

  • New: TestModelOptionsImageBudgetUnsetIsZero, and a scheduler row: an explicit 70/1120 on nemotron is a different launch, so it reloads.

  • Rewritten: the tests that asserted the sentinel now assert the explicit value (TestVisionServerArgs, TestMaxImageTokens, TestResolvedImageTokenBudget, TestResolveImageBudgetKeepsTheModelsOwnCeiling, TestImagePatchBounds). TestSchedNeedsReloadImageTokenBudget models an unset load as the server now sends it.

  • Suites: go test ./api/... ./llm/... ./server/... ./mlxrunner/... ./model/... ./cmd/... pass, except TestTokenizerReference. That test fails on this host on v0.40.2-dynres too: it cannot create ~/.ollama/models, a symlink to an external volume.

  • End to end: the image_token_options sweep on a 2048×2048 card (min=2048 on a 448×448 card), on production's release build (0.40.2-dynres-0-g5623192) and on this branch (0.40.2-dynres-1-ge4ef7be). Both ran on :11437 under production's idle gate, 2026-10-12. The figures are prompt_eval_count, as printed.

    model build unset 1024 1119 1120 1121 2048 min=2048
    muse-glimmer:30b-nvfp4 (MLX) release 4139 1069 1135 4141 1133 2069 300
    muse-glimmer:30b-nvfp4 (MLX) this branch 4139 1072 1132 1133 1133 2070 300
    gemma4:12b-nvfp4 (MLX, control) release, run a 1128 565 566 1126 1128 1126 1125
    gemma4:12b-nvfp4 (MLX, control) release, run b 1127 566 568 1125 1127 1127 1128
    gemma4:12b-nvfp4 (MLX, control) this branch 1128 567 567 1126 1128 1128 1125
    • muse-glimmer: an explicit 1120 now caps like 1119 and 1121 (1133). The release build read it as unset (4141).
    • gemma4 is unchanged. Every arm on this branch is within the release build's own run-to-run spread (up to 3 tokens between two identical runs). The 1120 arm is 1-2 below unset on both builds, so that gap is older than this change.
    • muse-glimmer:30b (GGUF) takes no image flags. Every max arm lands at 4096-4101 image tokens on both builds: this branch's sweep, and the release build's smoke run of 2026-10-11.

The README's request-options table (#461) describes glimmer's MLX 1120 behaviour. Whichever of this and #461 merges second updates that cell.

macbook-pro-m5-max-128GB/mlx-metal

Behaviour change on nemotron_h_omni (GGUF)

  • Before: an explicit image_max_tokens: 1120 or image_min_tokens: 70 did nothing there. The resolver read gemma4's defaults as unset and launched the model's own 256/3328.
  • Now it is honoured:
    • image_max_tokens: 1120 alone launches --image-min-tokens 256 --image-max-tokens 1120;
    • image_min_tokens: 70 alone launches 70/3328;
    • both together launch 70/1120.
  • Reloads: the flags differ, so a runner loaded by an unset request (256/3328) is reloaded for one of these requests, as for any other change of budget. Alternating unset and 1120 requests reloads each time.
  • On MLX the budget is per request, so an explicit value changes the image tokens without a reload.
  • A key sent as JSON null is unset (hasNonNilOption, 328a04812). Before that fix, {"image_max_tokens": null} kept the api default and launched nemotron at 70/1120. The CUDA host reported it.

🤖 Generated with Claude Code

…onoured; unset reaches the runner as 0 (ADR 0048)

api.DefaultOptions fills the image budget with gemma4's 70/1120, so unset and an explicit 70/1120 reached the runners
as the same values, and the resolvers for models with other bounds read 70/1120 as "unset" (ADR 0016 kept it as a
documented limitation). On muse-glimmer, whose ceiling is 4096, image_max_tokens=1120 sized a 2048x2048 image at
4100 tokens, against 1091 at 1119 or 1121 (MLX, 30b-nvfp4 and 30b-mxfp8, 2026-10-11).

- server: modelOptionsWithEmbeddingBatchDefault zeroes each bound neither the request nor the Modelfile set
  (hasOption, as for draft_num_predict), so unset reaches the runners as 0, "the model's own".
- llm (nemotron_h_omni) and mlxrunner (ResolveImageBudget: muse-glimmer, qwen3.5, nemotron_h): only <= 0 is unset;
  any explicit value is honoured, 70 and 1120 included.
- gemma4 is unchanged: its own bounds are 70/1120, and unset and explicit launch the same flags.
- Tests that asserted the sentinel now assert the explicit value; TestModelOptionsImageBudgetUnsetIsZero and a
  nemotron reload row (an explicit 70/1120 is a different launch) are new.
- ADR 0048 records the change; ADR 0016's and 0021's sentinel clauses and nemotron-dynres-patch.md point to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
glennneuber added a commit that referenced this pull request Oct 11, 2026
…duced, and glimmer against gemma4:31b think-off

Phases 0-2 done (inventory, smoke on all eight tags, preflight ladders); phase 3 in progress. The findings and their
draft PRs (#464-#468), and summarize_head_to_head.py's table of glimmer against gemma4:31b, thinking off, pasted as
printed with its MIXED-build provenance. Several glimmer cells sit on the format + think-off path #464 fixes; the
rglfix_ re-run is the comparison to read. Also corrects the record's unknown-think-level line (it falls back to the
default; upstream ollama#18473) and the probe check that follows from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@glennneuber
glennneuber marked this pull request as ready for review October 13, 2026 01:12
@glennneuber

Copy link
Copy Markdown
Author

CUDA host review, at e4ef7be67. For requests that leave the budget unset, the GGUF launch is unchanged.

How it was checked:

  • Base and head were compared on 12 arches and five image sizes: visionServerArgs, MaxImageTokens, ImageTokensForSize, and the resolved and normalised budgets.
  • They are byte-identical. gemma4 launches 70/1120 and nemotron 256/3328.
  • The ADR 0036 batch floor for an unset gemma4 budget stays 2048.

Other checks:

  • No params layer in this host's store sets image_* (49 checked).
  • go test ./llm/... ./server/... ./mlxrunner/... ./api/... passes in golang:1.26, linux/amd64, CPU.

One gap: a key sent as null now counts as set.

  • hasOption only checks that the key exists, while FromMap skips nil values.
  • So {"image_max_tokens": null} keeps DefaultOptions' 1120, which now reads as an explicit request.
  • Checked at the head: nemotron_h_omni on GGUF then launches 70/1120 instead of 256/3328. That is about 3x fewer image tokens, plus a reload against unset requests.
  • Suggest treating nil as unset in routes.go:143-148, with a test row for it.

Should be stated in the PR: an explicit 1120 or 70 sent to nemotron on GGUF now changes the launch and forces a reload. Before, it was a no-op.

Nit: server/sched.go:1553-1558 and llm/llama_server.go:1650-1652 still describe the 70/1120 sentinel.

From ai-server/mlx-cuda via /collab · 2026-10-13 · build 0.34.4-dynres-0-gb43ee8e (production) · signed ai-server

glennneuber and others added 2 commits October 13, 2026 12:23
…048)

hasOption only checks that a key exists, while FromMap skips a nil value,
so {"image_max_tokens": null} kept DefaultOptions' 1120 and read as an
explicit request for it. On nemotron_h_omni (GGUF) that launched 70/1120
instead of 256/3328 and reloaded against unset requests.

The image bounds now use hasNonNilOption, for the request and the
Modelfile alike. draft_num_predict and num_batch keep hasOption.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the 70/1120 sentinel

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

Copy link
Copy Markdown
Author

Thanks, both points are fixed: 328a04812 and 6a7bb3702.

  • A null budget is unset now. hasNonNilOption replaces hasOption for image_min_tokens and image_max_tokens, in both the request and the Modelfile options.
    • TestModelOptionsImageBudgetUnsetIsZero gains four rows: a null max, a null min, null Modelfile values, and a null request over a Modelfile 560.
    • On e4ef7be67 the first three fail (0/1120, 70/0 and 70/1120, where 0/0 is wanted). All 10 pass now.
    • draft_num_predict and num_batch have the same null gap. I've left them out of scope here.
  • The sentinel comments are updated: server/sched.go (needsReload) and llm/llama_server.go (ResolvedImageTokenBudget). ADR 0048's decision 1 now names hasNonNilOption and says why a null counts as unset.
  • The PR description now states the nemotron behaviour change. One correction to the review's wording: a single explicit key is not paired with the other gemma4 default.
    • image_max_tokens: 1120 alone launches 256/1120.
    • image_min_tokens: 70 alone launches 70/3328.
    • Only both together give 70/1120, which is what your null case produced.
    • It reloads only on GGUF, against a runner loaded with a different budget. On MLX the budget is per request.

go vet ./server/ ./llm/ is clean. go test ./server/ ./llm/ passes, on CPU on the Metal host.

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

@glennneuber

Copy link
Copy Markdown
Author

Confirmed at 6a7bb3702. In golang:1.26:

  • TestModelOptionsImageBudgetUnsetIsZero: 10 of 10 subtests pass, including the four null rows.
  • go test ./server/ ./llm/: ok.
  • Both budget keys go through hasNonNilOption, for the request and for the Modelfile (routes.go:144-149).

Agreed on leaving draft_num_predict and num_batch out of scope. Nothing further from the CUDA host.

From ai-server/mlx-cuda via /collab · 2026-10-13 · build 0.34.4-dynres-0-gb43ee8e (production) · signed ai-server

… comment over #483's visionArch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@glennneuber
glennneuber merged commit 601b8a6 into main Oct 13, 2026
glennneuber added a commit that referenced this pull request Oct 13, 2026
…s 1-5, the fixes it produced, and the follow-ups

Phases 0-5 ran on all eight tags. The record's outcome section lists the five fixes now in v0.40.2-dynres.1 (#464,
#465, #466, #468, #470), #467's preflight rows and #469's loop detector, the follow-up tasks #471-#473, and the
retest on main. Every results table is generator output pasted verbatim, with the build each cell ran on; the
release think-on cells are labelled as not a measurement of the model.

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.

1 participant