Skip to content

QVAC-21396 fix(mtmd/qwen3vl): grid selection rewrite + --image-max-tiles override#175

Merged
gianni-cor merged 5 commits into
temp-9341from
tetherto/fix/qwen3vl-grid-select
Jul 6, 2026
Merged

QVAC-21396 fix(mtmd/qwen3vl): grid selection rewrite + --image-max-tiles override#175
gianni-cor merged 5 commits into
temp-9341from
tetherto/fix/qwen3vl-grid-select

Conversation

@yingying0906

@yingying0906 yingying0906 commented Jun 30, 2026

Copy link
Copy Markdown

Summary

Rewrites Qwen3-VL multi-tile grid selection, adds an --image-max-tiles override, collapses the batched-attention loop, and makes sequential the default tile mode.

  • Grid selection rewrite (mtmd-image.cpp): deterministic best-grid search over downscaling grids; falls back to dyn_size when no grid fits. Adds a global overview thumbnail at entries[0].
  • --image-max-tiles override (arg.cpp, common.h, mtmd.{h,cpp}, server/cli wiring): user-facing cap on tiles per image (1..256), applied after load_hparams so GGUF defaults don't clobber it.
  • Collapse batched attention loop + sequential default (clip.cpp, clip.h, qwen3vl.cpp): zero-copy 3D views over the batched QKV buffer instead of a per-tile loop; fixes the M-RoPE section-major position layout for batch_size>1; defaults --image-tile-mode to sequential.

Grid selection mechanism

For an nx × ny image with per-tile side tile_px (from model hparams) and a tile budget max_tiles. Each image is processed independently:

  1. Enumerate every col × row grid with col*row ≤ max_tiles, excluding 1×1, keeping only grids that downscale in both axes (col*tile_px ≤ nx and row*tile_px ≤ ny) — upscaling adds no real detail.
  2. Score each by log-ratio error ratio_err = |log(nx/ny) − log(col/row)|. Log space makes 2:1 and 1:2 symmetric around square. This is the per-axis stretch the resize-to-canvas step will impose.
  3. No fit → dyn_size. If no grid downscales, use dyn_size (aspect-correct downscale at native detail, single tile).
  4. Tolerance band + max tiles. Take all grids within RATIO_ERR_TOLERANCE (0.2) of the best ratio_err and pick the most tiles (more spatial detail), tie-breaking on smaller ratio_err. The band stops a high-tile-count grid from winning when a near-perfect-ratio, fewer-tile grid exists (e.g. 2×2 beating 2×1 on a 2:1 image and squashing it 2×).
  5. Compose. Stretch the image to the chosen col × row canvas, prepend a tile_px × tile_px global overview thumbnail (recovers text split across tile seams), then crop into col × row tiles packed row-major. Tiles are encoded per --image-tile-mode: sequential (default, one-by-one), batched (one collapsed attention pass), or disabled (single tile).

Performance

Candidate (this PR) vs published @qvac/llm-llamacpp 0.31.0. Qwen3.5-0.8B, deterministic at temp 0 seed 42, image_max_tokens=4096. The disc-ocr set is 9 real getomni-ai OCR docs scaled aspect-preserved into the band where baseline falls back to dyn_size and candidate tiles.

Addon benchmark: disc-ocr run

All rows from the same run. Speed, lower is faster. Desktop reports mmproj vision-encode; mobile reports TTFT.

Platform · device metric baseline candidate Δ
linux · GPU encode ms 211 92 −56%
macos · GPU encode ms 1510 538 −64%
s25 · CPU TTFT ms 36636 13101 −64%
s25 · GPU TTFT ms 50740 19727 −61%
pixel9 · CPU TTFT ms 112012 38523 −66%
pixel9 · GPU TTFT ms 118463 41925 −65%

Accuracy, CER, lower is better.

Platform · device baseline candidate Δ
linux · GPU 0.775 0.777 +0.002
macos · GPU 0.782 0.782 0.000
s25 · CPU 0.791 0.782 −0.009
s25 · GPU 0.787 0.785 −0.002
pixel9 · CPU 0.791 0.782 −0.009
pixel9 · GPU 0.781 0.800 +0.019

Raw fabric CLI on-device, per-image mean encode

Platform · backend baseline encode candidate encode Δ
macOS · Metal 2824 ms 791 ms −72%
S25 · OpenCL 33642 ms 3895 ms −88%
Pixel 9 · Vulkan 283382 ms 30450 ms −89%

Test plan

Performance numbers above are sequential-mode only (baseline is sequential too), so they stand independent of the batched-mode verification.

@yingying0906 yingying0906 requested a review from a team as a code owner June 30, 2026 15:38
@yingying0906 yingying0906 changed the title fix(mtmd/qwen3vl): grid selection rewrite + --image-max-tiles override QVAC-21396 fix(mtmd/qwen3vl): grid selection rewrite + --image-max-tiles override Jun 30, 2026
@yingying0906 yingying0906 force-pushed the tetherto/fix/qwen3vl-grid-select branch 2 times, most recently from 1c1311d to 31a107e Compare July 1, 2026 15:09
@github-actions github-actions Bot added documentation Improvements or additions to documentation testing labels Jul 1, 2026
@yingying0906 yingying0906 force-pushed the tetherto/fix/qwen3vl-grid-select branch from 31a107e to fcce509 Compare July 2, 2026 06:23
Comment thread tools/mtmd/clip.cpp Outdated
loader.load_hparams(ctx_vision->model, CLIP_MODALITY_VISION);
// apply CLI overrides after load_hparams so GGUF defaults don't clobber them
if (ctx_params.image_max_tiles != -1) {
ctx_vision->model.hparams.preproc_max_tiles = ctx_params.image_max_tiles;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[security] image_max_tiles is unvalidated at this library boundary → potential OOM/DoS.

The [1,256] bound is enforced only in common/arg.cpp (the CLI/env path). But clip_context_params/mtmd_context_params are the public MTMD_API surface that native bindings populate directly (e.g. this monorepo's packages/llm-llamacpp). Here the override only tests != -1 and assigns straight into hparams.preproc_max_tiles. A large value then reaches fitting.reserve((size_t)(max_tiles * std::log(max_tiles))) in mtmd-image.cpp — an INT_MAX-ish value requests hundreds of GB and throws std::bad_alloc, and the per-image preprocess() call in mtmd.cpp's tokenize path has no surrounding try/catch, so the whole process can crash.

The correct pattern already exists in this same PR two functions up (the GGUF-read path clamps preproc_max_tiles to [1,256] with a warning). Please re-validate here too, ideally via a shared constant reused by both sites.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The override now clamps to [1, CLIP_PREPROC_MAX_TILES_LIMIT] (256) at this boundary before assigning, with a warning, so a binding that populates clip_context_params directly can no longer drive the grid-fitting reserve into bad_alloc. Uses the same shared constant as the GGUF-read path.

Comment thread tools/mtmd/clip.cpp Outdated
LOG_INF("%s: preproc_max_tiles: %d (custom value)\n", __func__, ctx_vision->model.hparams.preproc_max_tiles);
// models with a min-tiles floor (e.g. InternVL) would produce an empty candidate
// set if the override drops max below min; clamp min so the invariant holds.
if (ctx_vision->model.hparams.preproc_min_tiles > ctx_vision->model.hparams.preproc_max_tiles) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] This override (and the min-tiles clamp) is a silent no-op for InternVL.

InternVL builds hparams.image_res_candidates exactly once, inside load_hparams (set_internvl_dhr_res_candidates, called only from that switch case). clip_init applies this --image-max-tiles override after load_hparams, and nothing rebuilds the candidate list afterward. InternVL's tiling (mtmd_image_preprocessor_llava_uhd::get_slice_instructions) reads only image_res_candidates — it never reads preproc_min_tiles/preproc_max_tiles live. So for InternVL, mutating these fields here has zero effect on tiling, yet we still log preproc_max_tiles: N (custom value), and this min-tiles clamp is dead code (the "empty candidate set" it guards against can't occur, since the candidates are already fixed). This contradicts the flag's own help text.

(It works correctly for Qwen3VL, which reads preproc_max_tiles at preprocessor construction, post-override.)

Fix options: rebuild image_res_candidates after applying the override for candidate-list-based preprocessors, or scope the flag + docs to Qwen3VL only.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Scoped the override to PROJECTOR_TYPE_QWEN3VL which is the only preprocessor that reads preproc_max_tiles live. Non-Qwen3VL projectors now get a "only supported for Qwen3VL; ignoring" warning instead of a silent no-op + misleading (custom value) log. Removed the min-tiles clamp entirely: it only guarded InternVL, whose candidate list is frozen in load_hparams, so it was dead code.

Comment thread common/arg.cpp
"for your model size (Qwen3VL defaults to 4, too low for 8B+ variants)",
[](common_params & params, int value) {
if (value < 1) { throw std::invalid_argument("--image-max-tiles must be >= 1"); }
if (value > 256) { throw std::invalid_argument("--image-max-tiles must be <= 256"); }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[consistency] The [1,256] bound is a hardcoded literal duplicated across two sites, and one enforcement site is missing.

This is one of three independent hardcodings of 256: here (CLI), the GGUF-read validation in clip.cpp (preproc_max_tiles < 1 || > 256), and it is absent at the clip_init override site (ctx_vision->model.hparams.preproc_max_tiles = ctx_params.image_max_tiles;) which is the actual library boundary embedders hit. Suggest extracting a shared constexpr (e.g. IMAGE_MAX_TILES_LIMIT = 256) and using it at all three places so the invariant can't drift and the override path gets the bound for free.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The override site now enforces the bound via a shared constexpr CLIP_PREPROC_MAX_TILES_LIMIT in clip-impl.h, reused at both clip.cpp sites. Left the literal here on purpose: arg.cpp is in common/ (includes only common.h) and can't reach clip-impl.h without coupling the common lib to mtmd internals for one int. Since the safety-critical bound is now at the library boundary, this CLI check is just a friendly early-reject, a drift here can't cause the OOM.

Comment thread tools/mtmd/clip.cpp Outdated
const char * model_name =
model.proj_type == PROJECTOR_TYPE_QWEN2VL ? "Qwen2VL" :
model.proj_type == PROJECTOR_TYPE_QWEN25VL ? "Qwen2.5VL" : "Qwen3VL";
LOG_WRN("%s: %s large ViT detected (n_head=%d, n_layer=%d) but "

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[consistency] This warning tells the user to --image-max-tiles for projectors where the flag is inert.

This block lives in a switch case shared by PROJECTOR_TYPE_QWEN2VL, QWEN25VL, and QWEN3VL, and the ternary above names "Qwen2VL"/"Qwen2.5VL". But per mtmd.cpp's preprocessor-selection switch, QWEN2VL/QWEN25VL use mtmd_image_preprocessor_dyn_size, which never reads preproc_max_tiles — tiling is Qwen3VL-only. So suggesting --image-max-tiles to Qwen2/2.5VL users is misleading (the flag does nothing there). Either scope this default/validation/warning block to PROJECTOR_TYPE_QWEN3VL, or drop the --image-max-tiles suggestion from the non-Qwen3VL branches.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "set --image-max-tiles" suggestion is now gated to PROJECTOR_TYPE_QWEN3VL; Qwen2/2.5VL (dyn_size) get the same large-ViT warning without the flag suggestion, since the flag is inert for them.

yingying0906 pushed a commit that referenced this pull request Jul 2, 2026
…e to Qwen3VL

Addresses review on PR #175:

- Clamp the preproc_max_tiles override to [1,256] in clip_init. Bindings populate
  clip_context_params directly and bypass the CLI's range check; an unbounded value
  reached the O(max_tiles*log max_tiles) grid-fitting reserve in mtmd-image.cpp and
  could request hundreds of GB -> std::bad_alloc -> process crash.
- Extract a shared CLIP_PREPROC_MAX_TILES_LIMIT (clip-impl.h) and use it at both the
  GGUF-read and override sites so the bound can't drift.
- Scope the override to PROJECTOR_TYPE_QWEN3VL (the only preprocessor that reads the
  field live); warn and ignore for other projectors. Drop the dead InternVL min-tiles
  clamp (InternVL reads a candidate list frozen in load_hparams, not the live field).
- Gate the "set --image-max-tiles" suggestion in the shared Qwen2/2.5/3 case to
  Qwen3VL only; the flag is inert for Qwen2/2.5VL (dyn_size).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
yingying0906 pushed a commit that referenced this pull request Jul 2, 2026
…e to Qwen3VL

Addresses review on PR #175:

- Clamp the preproc_max_tiles override to [1,256] in clip_init. Bindings populate
  clip_context_params directly and bypass the CLI's range check; an unbounded value
  reached the O(max_tiles*log max_tiles) grid-fitting reserve in mtmd-image.cpp and
  could request hundreds of GB -> std::bad_alloc -> process crash.
- Extract a shared CLIP_PREPROC_MAX_TILES_LIMIT (clip-impl.h) and use it at both the
  GGUF-read and override sites so the bound can't drift.
- Scope the override to PROJECTOR_TYPE_QWEN3VL (the only preprocessor that reads the
  field live); warn and ignore for other projectors. Drop the dead InternVL min-tiles
  clamp (InternVL reads a candidate list frozen in load_hparams, not the live field).
- Gate the "set --image-max-tiles" suggestion in the shared Qwen2/2.5/3 case to
  Qwen3VL only; the flag is inert for Qwen2/2.5VL (dyn_size).
@yingying0906 yingying0906 force-pushed the tetherto/fix/qwen3vl-grid-select branch from 5f0ed67 to 4847e7c Compare July 2, 2026 10:16
yingying0906 added a commit that referenced this pull request Jul 2, 2026
…e to Qwen3VL

Addresses review on PR #175:

- Clamp the preproc_max_tiles override to [1,256] in clip_init. Bindings populate
  clip_context_params directly and bypass the CLI's range check; an unbounded value
  reached the O(max_tiles*log max_tiles) grid-fitting reserve in mtmd-image.cpp and
  could request hundreds of GB -> std::bad_alloc -> process crash.
- Extract a shared CLIP_PREPROC_MAX_TILES_LIMIT (clip-impl.h) and use it at both the
  GGUF-read and override sites so the bound can't drift.
- Scope the override to PROJECTOR_TYPE_QWEN3VL (the only preprocessor that reads the
  field live); warn and ignore for other projectors. Drop the dead InternVL min-tiles
  clamp (InternVL reads a candidate list frozen in load_hparams, not the live field).
- Gate the "set --image-max-tiles" suggestion in the shared Qwen2/2.5/3 case to
  Qwen3VL only; the flag is inert for Qwen2/2.5VL (dyn_size).
@yingying0906 yingying0906 force-pushed the tetherto/fix/qwen3vl-grid-select branch from 4847e7c to 80de190 Compare July 2, 2026 10:24
yingying0906 added a commit to tetherto/qvac that referenced this pull request Jul 2, 2026
Phase A overlay validation for fabric PR tetherto/qvac-fabric-llm.cpp#175
(grid selection rewrite + --image-max-tiles override).

- add shared overlay port packages/ports/qvac-fabric (REF pinned to fabric
  commit 1c1311da7, version 9341.2.0)
- wire overlay-ports ["../ports"] into all 6 consumer vcpkg-configuration.json

Registry baselines and consumer version>= constraints untouched; overlay
bypasses version resolution.
yingying0906 added a commit to tetherto/qvac that referenced this pull request Jul 2, 2026
Phase A overlay validation for fabric PR tetherto/qvac-fabric-llm.cpp#175
(grid selection rewrite + --image-max-tiles override).

- add shared overlay port packages/ports/qvac-fabric (REF pinned to fabric
  commit 1c1311da7, version 9341.2.0)
- wire overlay-ports ["../ports"] into all 6 consumer vcpkg-configuration.json

Registry baselines and consumer version>= constraints untouched; overlay
bypasses version resolution.
yingying0906 added a commit that referenced this pull request Jul 2, 2026
…e to Qwen3VL

Addresses review on PR #175:

- Clamp the preproc_max_tiles override to [1,256] in clip_init. Bindings populate
  clip_context_params directly and bypass the CLI's range check; an unbounded value
  reached the O(max_tiles*log max_tiles) grid-fitting reserve in mtmd-image.cpp and
  could request hundreds of GB -> std::bad_alloc -> process crash.
- Extract a shared CLIP_PREPROC_MAX_TILES_LIMIT (clip-impl.h) and use it at both the
  GGUF-read and override sites so the bound can't drift.
- Scope the override to PROJECTOR_TYPE_QWEN3VL (the only preprocessor that reads the
  field live); warn and ignore for other projectors. Drop the dead InternVL min-tiles
  clamp (InternVL reads a candidate list frozen in load_hparams, not the live field).
- Gate the "set --image-max-tiles" suggestion in the shared Qwen2/2.5/3 case to
  Qwen3VL only; the flag is inert for Qwen2/2.5VL (dyn_size).
@yingying0906 yingying0906 force-pushed the tetherto/fix/qwen3vl-grid-select branch from 80de190 to dfc03ff Compare July 2, 2026 10:30

@DmitryMalishev DmitryMalishev left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yingying0906 Looks good to me overall. Couple corner case concerns worth to check and fix:

1. Flag silently dead for InternVL. --image-max-tiles is documented as generic ("multi-tile vision models") but has no effect on InternVL. InternVL bakes its resolution candidates from preproc_min/max_tiles into hparams.image_res_candidates during load_hparams (clip.cpp:1290, 2730–2748); the override in clip_init (clip.cpp:2767) mutates preproc_max_tiles afterwards but never rebuilds that candidate list, so preprocessing keeps using the old budget. Worse, the adjacent comment ("models with a min-tiles floor (e.g. InternVL) … clamp min so the invariant holds") reads as if InternVL were handled, giving reviewers false confidence. Either recompute the candidates after the override or scope the flag/docs to Qwen3-VL.

2. No API-side validation. Range validation exists only at the CLI (arg.cpp enforces 1–256). The mtmd_context_params.image_max_tiles / clip_context_params.image_max_tiles API path — which is what the QVAC addons actually use — applies any value other than -1 unchecked. 0 sets preproc_max_tiles = 0, which makes the qwen3vl grid loop never run and silently forces single-tile mode forever; a huge value allows unbounded tile counts (memory blow-up during preprocessing). The GGUF-read path got a [1,256] sanity check in this same PR — the override path should get the identical check in clip_init.

3. Two conflicting "defaults". The default flipped from batched to sequential, but only for callers going through mtmd_context_params_default(). The raw enum still has 0 = batched (clip.h: "0=batched, 1=sequential (default)"), so any consumer that zero-initializes the params struct silently gets batched — the unverified path from C1. A C API where "default" and "zero value" disagree is a long-term footgun; consider renumbering so 0 = sequential, or at minimum call this out loudly in mtmd.h.

yingying0906 added a commit that referenced this pull request Jul 2, 2026
…t the default

Addresses PR #175 review (#3): the shipped enum is 0=batched while the default mode
is sequential, so a zero-initialized clip_context_params/mtmd_context_params silently
selects the batched (unverified ne[3]) path instead of the intended default.

Renumbering to 0=sequential would be the clean fix, but the values are already released
as a string API ("0"/"1"/"2" in consumers), so renumbering would break existing callers.
Instead, document the trap prominently in clip.h and mtmd.h and point callers at
mtmd_context_params_default() (which sets sequential).
@yingying0906

yingying0906 commented Jul 2, 2026

Copy link
Copy Markdown
Author

Thanks for the comments and all three addressed.

(1) InternVL no-op + misleading comment — resolved in dfc03ffad (I think you saw an earlier diff state). The clip_init override is now scoped to PROJECTOR_TYPE_QWEN3VL, the only preprocessor that reads preproc_max_tiles live. Any other projector (InternVL, Qwen2/2.5VL) now logs "--image-max-tiles is only supported for Qwen3VL; ignoring for this model" and leaves hparams untouched — no more silent no-op mutation or misleading (custom value) log. The "min-tiles floor (e.g. InternVL)… clamp min" comment and its dead clamp are removed (InternVL's candidate list is frozen in load_hparams, so the clamp never applied).

(2) no API-side validation — resolved in the same commit. clip_init now re-validates at the library boundary via a shared CLIP_PREPROC_MAX_TILES_LIMIT (the same [1,256] bound as the GGUF-read path): 0 clamps to 1 with a warning (no more silent single-tile-forever), and a huge value clamps to 256 so it can't reach the grid-fitting reserve() and blow up. Bindings that populate clip_context_params directly get the bound for free.

(3) 0=batched vs sequential default — agreed it's a real footgun. For now I've documented it in clip.h and mtmd.h (5707f9b7c): the comments warn that 0 (batched) is the zero value but not the default, and that callers should init via mtmd_context_params_default() (sequential) or set the field explicitly. My concern with renumbering to 0=sequential is that "0"/"1"/"2" already shipped as a string API (parsed by consumers, e.g. the QVAC addon), so flipping it would silently change existing callers. Maybe worth raising the renumbering separately to decide if we want to take that break.

Comment thread tools/mtmd/clip.cpp Outdated
// Only the Qwen3VL preprocessor reads preproc_max_tiles live; Qwen2/2.5VL use dyn_size
// and InternVL uses a candidate list already frozen in load_hparams, so the override is
// inert for them — apply (and validate) it only where it takes effect.
if (ctx_params.image_max_tiles != -1) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[compatibility] image_max_tiles zero-initializes to an explicit override.

image_max_tiles uses -1 as the unset sentinel, but this is a new public struct field. Existing bindings/direct C API callers that zero-initialize clip_context_params/mtmd_context_params will pass 0 unless they are updated. This branch treats any value except -1 as an override, then clamps 0 to 1, so those callers silently force Qwen3VL to one max tile instead of using the GGUF/model default.

The CLI path is fine because common_params initializes image_max_tiles = -1, but public API consumers can regress in model quality: multi-tile preprocessing is effectively disabled for large/high-resolution images, especially hurting OCR/doc/screenshot cases where tiling preserves small local details. Consider treating 0 as unset at this library boundary, or adding/updating a public default initializer that bindings use before passing params into clip_init().

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. image_max_tiles now treats only a positive value as an explicit override, so both -1 and 0 fall through to the GGUF/model default. A zero-initialized clip_context_params / mtmd_context_params (bindings / direct C API callers that never set the field) keeps the model default instead of being clamped to single-tile. Dropped the dead lower clamp, kept the upper [1,256] bound. Updated the field docs in clip.h and mtmd.h to note -1 or 0 = default, only positive overrides.

Rewrite Qwen3VL multi-tile grid selection: enumerate CxR grids that downscale
the image in both dimensions (col*tile_px<=nx, row*tile_px<=ny), exclude 1x1
(equivalent to dyn_size), then pick within a ratio-error tolerance band by max
tile count and min log-ratio error. Fall back to dyn_size when no grid fits. Add
a global overview thumbnail at entries[0].

Add the --image-max-tiles CLI/env override (1..256), applied after load_hparams
so GGUF defaults don't clobber it, and clamp preproc_min_tiles so a low override
can't produce an empty candidate set on InternVL-family models. Guard the
log-ratio division against zero-dimension images.

Hardening:
- Validate clip.vision.preproc_max_tiles read from the GGUF: clamp to [1,256]
  (the --image-max-tiles range) and fall back to 4 with a warning, matching the
  InternVL branch. A crafted/corrupt value could otherwise drive an oversized
  fitting.reserve() and OOM on load or first inference.
- Size fitting.reserve() to max_tiles*ln(max_tiles) (the actual candidate count)
  instead of max_tiles*2, which undershot ~3x at the top of the range.
- Fail a zero-dimension image gracefully (LOG_ERR + return false) instead of
  GGML_ASSERT, so one malformed upload fails its own request rather than
  aborting the whole server. Callers already treat false as a per-request error.
- Doc/log consistency: reflow --image-max-tiles help to the sibling --image-*
  style, add the --image-tile-mode/--image-max-tiles rows to the cli/server
  READMEs, and log preproc_max_tiles as "(custom value)" when overridden.
Replace the per-tile attention loop (a ggml_new_tensor_2d accumulator with
ggml_set_2d writes and per-tile ggml_view_1d position slices, one rope pair
per tile per layer) with zero-copy 3D views over the batched QKV buffer: a
single rope pair runs on the collapsed n_pos*batch_size sequence, then 4D
views feed one block-diagonal build_attn per layer. Fewer graph nodes and
tensor descriptors, scaling with n_layer instead of n_layer*batch_size.

Fix the M-RoPE position layout for batch_size>1: the mrope kernel reads
section s of token i at positions[s*N + i] (N = n_pos*batch_size), so the
buffer must be section-major across the full batched sequence, not tile-
major. The old layout mis-indexed every section beyond the first tile,
encoding (y,y) instead of (y,x). batch_size==1 (the sequential path) is
unchanged. Default --image-tile-mode to sequential.
…e to Qwen3VL

Addresses review on PR #175:

- Clamp the preproc_max_tiles override to [1,256] in clip_init. Bindings populate
  clip_context_params directly and bypass the CLI's range check; an unbounded value
  reached the O(max_tiles*log max_tiles) grid-fitting reserve in mtmd-image.cpp and
  could request hundreds of GB -> std::bad_alloc -> process crash.
- Extract a shared CLIP_PREPROC_MAX_TILES_LIMIT (clip-impl.h) and use it at both the
  GGUF-read and override sites so the bound can't drift.
- Scope the override to PROJECTOR_TYPE_QWEN3VL (the only preprocessor that reads the
  field live); warn and ignore for other projectors. Drop the dead InternVL min-tiles
  clamp (InternVL reads a candidate list frozen in load_hparams, not the live field).
- Gate the "set --image-max-tiles" suggestion in the shared Qwen2/2.5/3 case to
  Qwen3VL only; the flag is inert for Qwen2/2.5VL (dyn_size).
…t the default

Addresses PR #175 review (#3): the shipped enum is 0=batched while the default mode
is sequential, so a zero-initialized clip_context_params/mtmd_context_params silently
selects the batched (unverified ne[3]) path instead of the intended default.

Renumbering to 0=sequential would be the clean fix, but the values are already released
as a string API ("0"/"1"/"2" in consumers), so renumbering would break existing callers.
Instead, document the trap prominently in clip.h and mtmd.h and point callers at
mtmd_context_params_default() (which sets sequential).
Zero-initialized clip_context_params/mtmd_context_params pass 0, which
was clamped to 1 and silently forced single-tile preprocessing. Only a
positive value now overrides the GGUF/model default.
@yingying0906 yingying0906 force-pushed the tetherto/fix/qwen3vl-grid-select branch from 5707f9b to bfa536c Compare July 6, 2026 05:13
@yingying0906 yingying0906 requested review from a team as code owners July 6, 2026 05:13
@gianni-cor gianni-cor merged commit 42fb8f4 into temp-9341 Jul 6, 2026
34 of 52 checks passed
@yingying0906 yingying0906 deleted the tetherto/fix/qwen3vl-grid-select branch July 8, 2026 07:08
makaveli10 pushed a commit to makaveli10/qvac-ext-lib-llama.cpp that referenced this pull request Jul 13, 2026
…e to Qwen3VL

Addresses review on PR tetherto#175:

- Clamp the preproc_max_tiles override to [1,256] in clip_init. Bindings populate
  clip_context_params directly and bypass the CLI's range check; an unbounded value
  reached the O(max_tiles*log max_tiles) grid-fitting reserve in mtmd-image.cpp and
  could request hundreds of GB -> std::bad_alloc -> process crash.
- Extract a shared CLIP_PREPROC_MAX_TILES_LIMIT (clip-impl.h) and use it at both the
  GGUF-read and override sites so the bound can't drift.
- Scope the override to PROJECTOR_TYPE_QWEN3VL (the only preprocessor that reads the
  field live); warn and ignore for other projectors. Drop the dead InternVL min-tiles
  clamp (InternVL reads a candidate list frozen in load_hparams, not the live field).
- Gate the "set --image-max-tiles" suggestion in the shared Qwen2/2.5/3 case to
  Qwen3VL only; the flag is inert for Qwen2/2.5VL (dyn_size).
makaveli10 pushed a commit to makaveli10/qvac-ext-lib-llama.cpp that referenced this pull request Jul 13, 2026
…t the default

Addresses PR tetherto#175 review (tetherto#3): the shipped enum is 0=batched while the default mode
is sequential, so a zero-initialized clip_context_params/mtmd_context_params silently
selects the batched (unverified ne[3]) path instead of the intended default.

Renumbering to 0=sequential would be the clean fix, but the values are already released
as a string API ("0"/"1"/"2" in consumers), so renumbering would break existing callers.
Instead, document the trap prominently in clip.h and mtmd.h and point callers at
mtmd_context_params_default() (which sets sequential).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation mtmd server testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants