Skip to content

Commit 42fb8f4

Browse files
authored
Merge pull request #175 from tetherto/tetherto/fix/qwen3vl-grid-select
QVAC-21396 fix(mtmd/qwen3vl): grid selection rewrite + --image-max-tiles override
2 parents 6796d62 + bfa536c commit 42fb8f4

14 files changed

Lines changed: 235 additions & 95 deletions

File tree

common/arg.cpp

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2270,6 +2270,17 @@ common_params_context common_params_parser_init(common_params & params, llama_ex
22702270
else { throw std::invalid_argument("unknown --image-tile-mode: " + value + " (use batched, sequential, or disabled)"); }
22712271
}
22722272
).set_examples(mmproj_examples).set_env("LLAMA_ARG_IMAGE_TILE_MODE"));
2273+
add_opt(common_arg(
2274+
{"--image-max-tiles"}, "N",
2275+
"maximum number of tiles for multi-tile vision models (e.g. Qwen3VL), overrides the\n"
2276+
"value from the GGUF; use when the GGUF lacks the key or the model default is wrong\n"
2277+
"for your model size (Qwen3VL defaults to 4, too low for 8B+ variants)",
2278+
[](common_params & params, int value) {
2279+
if (value < 1) { throw std::invalid_argument("--image-max-tiles must be >= 1"); }
2280+
if (value > 256) { throw std::invalid_argument("--image-max-tiles must be <= 256"); }
2281+
params.image_max_tiles = value;
2282+
}
2283+
).set_examples(mmproj_examples).set_env("LLAMA_ARG_IMAGE_MAX_TILES"));
22732284
if (llama_supports_rpc()) {
22742285
add_opt(common_arg(
22752286
{"--rpc"}, "SERVERS",

common/common.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,7 @@ struct common_params {
595595
int image_min_tokens = -1;
596596
int image_max_tokens = -1;
597597
common_image_tile_mode image_tile_mode = COMMON_IMAGE_TILE_MODE_SEQUENTIAL;
598+
int image_max_tiles = -1; // override preproc_max_tiles from GGUF; -1 = use model default
598599

599600
// finetune
600601
struct lr_opt lr;

tools/cli/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,8 @@
165165
| `--image, --audio FILE` | path to an image or audio file. use with multimodal models, use comma-separated values for multiple files |
166166
| `--image-min-tokens N` | minimum number of tokens each image can take, only used by vision models with dynamic resolution (default: read from model)<br/>(env: LLAMA_ARG_IMAGE_MIN_TOKENS) |
167167
| `--image-max-tokens N` | maximum number of tokens each image can take, only used by vision models with dynamic resolution (default: read from model)<br/>(env: LLAMA_ARG_IMAGE_MAX_TOKENS) |
168+
| `--image-tile-mode MODE` | tile encoding mode for multi-tile vision models (e.g. Qwen3VL):<br/> batched - all tiles in one forward pass<br/> sequential - tiles encoded one-by-one (default)<br/> disabled - tiling disabled, single tile only<br/>(env: LLAMA_ARG_IMAGE_TILE_MODE) |
169+
| `--image-max-tiles N` | maximum number of tiles for multi-tile vision models (e.g. Qwen3VL), overrides the<br/>value from the GGUF; use when the GGUF lacks the key or the model default is wrong<br/>for your model size (Qwen3VL defaults to 4, too low for 8B+ variants)<br/>(env: LLAMA_ARG_IMAGE_MAX_TILES) |
168170
| `--chat-template-kwargs STRING` | sets additional params for the json template parser, must be a valid json object string, e.g. '{"key1":"value1","key2":"value2"}'<br/>(env: LLAMA_CHAT_TEMPLATE_KWARGS) |
169171
| `--jinja, --no-jinja` | whether to use jinja template engine for chat (default: enabled)<br/>(env: LLAMA_ARG_JINJA) |
170172
| `--reasoning-format FORMAT` | controls whether thought tags are allowed and/or extracted from the response, and in which format they're returned; one of:<br/>- none: leaves thoughts unparsed in `message.content`<br/>- deepseek: puts thoughts in `message.reasoning_content`<br/>- deepseek-legacy: keeps `<think>` tags in `message.content` while also populating `message.reasoning_content`<br/>(default: auto)<br/>(env: LLAMA_ARG_THINK) |

tools/mtmd/clip-impl.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717

1818
#define MTMD_INTERNAL_HEADER
1919

20+
// Upper bound on preproc_max_tiles, enforced at every site that sets it (GGUF read,
21+
// CLI/binding override). A larger value would flow into the O(max_tiles·log max_tiles)
22+
// grid-fitting reserve in mtmd-image.cpp and can request hundreds of GB -> std::bad_alloc.
23+
constexpr int CLIP_PREPROC_MAX_TILES_LIMIT = 256;
24+
2025
#define KEY_FTYPE "general.file_type"
2126
#define KEY_NAME "general.name"
2227
#define KEY_DESCRIPTION "general.description"

tools/mtmd/clip.cpp

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ struct clip_ctx {
178178
size_t fa_mem_capacity = 0; // device memory used to cap the cutoff (0 = unknown)
179179

180180
bool debug_output_embeddings = false;
181-
clip_image_tile_mode tile_mode = CLIP_IMAGE_TILE_MODE_BATCHED;
181+
clip_image_tile_mode tile_mode = CLIP_IMAGE_TILE_MODE_SEQUENTIAL;
182182

183183
// When the GPU backend lacks bf16 support but the GGUF has bf16 weights,
184184
// we declare the in-context tensors as f16 and convert on disk-load.
@@ -1529,8 +1529,34 @@ struct clip_model_loader {
15291529
hparams.image_resize_algo = RESIZE_ALGO_BILINEAR;
15301530
get_u32(KEY_SPATIAL_MERGE_SIZE, hparams.n_merge, false);
15311531
get_u32(KEY_WIN_ATTN_PATTERN, hparams.n_wa_pattern, model.proj_type == PROJECTOR_TYPE_QWEN25VL); // only 2.5 requires it
1532-
// optional multi-tile cap; absent in GGUF → stays 0 and the qwen3vl preprocessor falls back to 4
1532+
// SigLIP2-Large ViT (n_head=16, n_layer=24) is used for 2B/4B — max_tiles=4 confirmed.
1533+
// Larger ViT variants (8B+) use SigLIP2-SO-400M with unknown max_tiles.
1534+
// Read from GGUF if present; otherwise default to 4 and warn for unknown ViTs.
1535+
hparams.preproc_max_tiles = 4;
1536+
const bool has_max_tiles_key = gguf_find_key(ctx_gguf.get(), KEY_PREPROC_MAX_TILES) >= 0;
15331537
get_u32(KEY_PREPROC_MAX_TILES, hparams.preproc_max_tiles, false);
1538+
if (has_max_tiles_key && (hparams.preproc_max_tiles < 1 || hparams.preproc_max_tiles > CLIP_PREPROC_MAX_TILES_LIMIT)) {
1539+
LOG_WRN("%s: clip.vision.preproc_max_tiles=%d out of range [1,%d] in GGUF — defaulting to 4\n",
1540+
__func__, hparams.preproc_max_tiles, CLIP_PREPROC_MAX_TILES_LIMIT);
1541+
hparams.preproc_max_tiles = 4;
1542+
}
1543+
if (!has_max_tiles_key && (hparams.n_head > 16 || hparams.n_layer > 24)) {
1544+
const char * model_name =
1545+
model.proj_type == PROJECTOR_TYPE_QWEN2VL ? "Qwen2VL" :
1546+
model.proj_type == PROJECTOR_TYPE_QWEN25VL ? "Qwen2.5VL" : "Qwen3VL";
1547+
// preproc_max_tiles is only read by the Qwen3VL preprocessor; Qwen2/2.5VL
1548+
// use dyn_size, so --image-max-tiles is inert there and must not be suggested.
1549+
if (model.proj_type == PROJECTOR_TYPE_QWEN3VL) {
1550+
LOG_WRN("%s: %s large ViT detected (n_head=%d, n_layer=%d) but "
1551+
"clip.vision.preproc_max_tiles not in GGUF — defaulting to 4. "
1552+
"If using an 8B+ model, set the correct value with --image-max-tiles.\n",
1553+
__func__, model_name, hparams.n_head, hparams.n_layer);
1554+
} else {
1555+
LOG_WRN("%s: %s large ViT detected (n_head=%d, n_layer=%d) but "
1556+
"clip.vision.preproc_max_tiles not in GGUF — defaulting to 4.\n",
1557+
__func__, model_name, hparams.n_head, hparams.n_layer);
1558+
}
1559+
}
15341560
// ref: https://huggingface.co/Qwen/Qwen2.5-VL-7B-Instruct/blob/main/preprocessor_config.json
15351561
hparams.set_limit_image_tokens(8, 4096);
15361562
hparams.warmup_image_size = hparams.image_size; // warmup at actual tile size to match inference graph shape
@@ -2934,6 +2960,33 @@ struct clip_init_result clip_init(const char * fname, struct clip_context_params
29342960
if (loader.has_vision) {
29352961
ctx_vision = new clip_ctx(ctx_params);
29362962
loader.load_hparams(ctx_vision->model, CLIP_MODALITY_VISION);
2963+
// apply CLI/binding overrides after load_hparams so GGUF defaults don't clobber them.
2964+
// Only the Qwen3VL preprocessor reads preproc_max_tiles live; Qwen2/2.5VL use dyn_size
2965+
// and InternVL uses a candidate list already frozen in load_hparams, so the override is
2966+
// inert for them — apply (and validate) it only where it takes effect.
2967+
//
2968+
// Treat only a POSITIVE value as an explicit override. -1 is the documented unset
2969+
// sentinel, and 0 is what a zero-initialized clip_context_params / mtmd_context_params
2970+
// passes (bindings / direct C API callers that never set the field). Both are treated
2971+
// as unset here so those callers keep the GGUF/model default instead of silently
2972+
// forcing single-tile and losing multi-tile preprocessing on large/high-res images.
2973+
if (ctx_params.image_max_tiles > 0) {
2974+
if (ctx_vision->model.proj_type != PROJECTOR_TYPE_QWEN3VL) {
2975+
LOG_WRN("%s: --image-max-tiles is only supported for Qwen3VL; ignoring for this model\n", __func__);
2976+
} else {
2977+
int max_tiles = ctx_params.image_max_tiles;
2978+
// Re-validate at this library boundary: bindings populate clip_context_params
2979+
// directly and bypass the CLI's [1,256] check. An unbounded value reaches the
2980+
// grid-fitting reserve in mtmd-image.cpp and can OOM the process.
2981+
if (max_tiles > CLIP_PREPROC_MAX_TILES_LIMIT) {
2982+
LOG_WRN("%s: --image-max-tiles=%d exceeds [1,%d]; clamping\n",
2983+
__func__, max_tiles, CLIP_PREPROC_MAX_TILES_LIMIT);
2984+
max_tiles = CLIP_PREPROC_MAX_TILES_LIMIT;
2985+
}
2986+
ctx_vision->model.hparams.preproc_max_tiles = max_tiles;
2987+
LOG_INF("%s: preproc_max_tiles: %d (custom value)\n", __func__, max_tiles);
2988+
}
2989+
}
29372990
loader.load_tensors(*ctx_vision);
29382991
if (ctx_params.warmup) {
29392992
loader.warmup(*ctx_vision);
@@ -3624,21 +3677,27 @@ bool clip_image_batch_encode(clip_ctx * ctx, const int n_threads, const clip_ima
36243677
GGML_ASSERT(pw % merge_ratio == 0 && ph % merge_ratio == 0 &&
36253678
"tile dimensions must be divisible by n_merge");
36263679
const int n_pos_tile = pw * ph; // raw patches per tile == n_patches (graph sequence length)
3627-
// positions layout: tile-major [tile0: y,x,y,x | tile1: y,x,y,x
3628-
// | ...] each tile's block starts at b * n_pos_tile * 4
3629-
// (matches ggml_view_1d in graph)
3630-
std::vector<int32_t> positions((size_t)n_pos_tile * (size_t)batch_size * 4);
3680+
// positions layout: section-major over the FULL batched sequence. The mrope kernel
3681+
// reads section s of token i as positions[s * N + i], where N = n_pos_tile * batch_size
3682+
// is the rope tensor's ne[2]. The four sections are [y, x, y, x]. Every tile gets the
3683+
// same local-coordinate block (per-tile absolute offsets cancel under relative
3684+
// attention), so each section just repeats the tile coordinates batch_size times.
3685+
// For batch_size == 1 this is identical to the old tile-major layout; for
3686+
// batch_size > 1 (batched mode) it is the only layout the kernel reads correctly —
3687+
// a tile-major buffer would mis-index every section beyond the first tile.
3688+
const size_t N = (size_t)n_pos_tile * (size_t)batch_size;
3689+
std::vector<int32_t> positions(N * 4);
36313690
for (int b = 0; b < batch_size; b++) {
3632-
const size_t base = (size_t)b * (size_t)n_pos_tile * 4;
3691+
const size_t tile_off = (size_t)b * (size_t)n_pos_tile;
36333692
int ptr = 0;
36343693
for (int y = 0; y < ph; y += merge_ratio) {
36353694
for (int x = 0; x < pw; x += merge_ratio) {
36363695
for (int dy = 0; dy < merge_ratio; dy++) {
36373696
for (int dx = 0; dx < merge_ratio; dx++) {
3638-
positions[base + ptr] = y + dy;
3639-
positions[base + (size_t)n_pos_tile + ptr] = x + dx;
3640-
positions[base + (size_t)2 * n_pos_tile + ptr] = y + dy;
3641-
positions[base + (size_t)3 * n_pos_tile + ptr] = x + dx;
3697+
positions[0 * N + tile_off + ptr] = y + dy;
3698+
positions[1 * N + tile_off + ptr] = x + dx;
3699+
positions[2 * N + tile_off + ptr] = y + dy;
3700+
positions[3 * N + tile_off + ptr] = x + dx;
36423701
ptr++;
36433702
}
36443703
}

tools/mtmd/clip.h

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,16 @@ enum clip_flash_attn_type {
3535
CLIP_FLASH_ATTN_TYPE_ENABLED = 1,
3636
};
3737

38+
// WARNING: value 0 is BATCHED, which is NOT the default mode (sequential is).
39+
// A zero-initialized clip_context_params/mtmd_context_params (`{}`, memset, calloc)
40+
// therefore selects BATCHED — the one-forward-pass path whose ne[3] batching is not
41+
// yet verified on every backend (see the NOTE in models/qwen3vl.cpp). Do not rely on
42+
// zero-init for the default: initialize via mtmd_context_params_default() (sets
43+
// sequential) or set image_tile_mode explicitly. These values are part of the shipped
44+
// API (string "0"/"1"/"2" in consumers), so they are intentionally not renumbered.
3845
enum clip_image_tile_mode {
39-
CLIP_IMAGE_TILE_MODE_BATCHED = 0,
40-
CLIP_IMAGE_TILE_MODE_SEQUENTIAL = 1,
46+
CLIP_IMAGE_TILE_MODE_BATCHED = 0, // NOT the default; zero-init lands here — see warning above
47+
CLIP_IMAGE_TILE_MODE_SEQUENTIAL = 1, // the default (via mtmd_context_params_default)
4148
CLIP_IMAGE_TILE_MODE_DISABLED = 2,
4249
};
4350

@@ -51,7 +58,8 @@ struct clip_context_params {
5158
ggml_backend_sched_eval_callback cb_eval;
5259
void * cb_eval_user_data;
5360
const char * backend_device; // optional, if null will use env var or default GPU backend
54-
int image_tile_mode; // 0=batched (default), 1=sequential, 2=disabled
61+
int image_tile_mode; // 0=batched, 1=sequential (default), 2=disabled. NOTE: 0 (batched) is the zero value but NOT the default — init via mtmd_context_params_default() or set explicitly.
62+
int image_max_tiles; // override preproc_max_tiles; -1 or 0 = use GGUF/model default (only a positive value overrides)
5563
};
5664

5765
struct clip_init_result {

tools/mtmd/debug/mtmd-debug.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ int main(int argc, char ** argv) {
8888
mparams.warmup = params.warmup;
8989
mparams.image_min_tokens = params.image_min_tokens;
9090
mparams.image_max_tokens = params.image_max_tokens;
91+
mparams.image_tile_mode = (int) params.image_tile_mode;
92+
mparams.image_max_tiles = params.image_max_tiles;
9193
{
9294
// always enable debug callback
9395
mparams.cb_eval_user_data = &cb_data;

tools/mtmd/models/qwen3vl.cpp

Lines changed: 34 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ ggml_cgraph * clip_graph_qwen3vl::build() {
99
// QKV/FFN run fully batched; attention is per-tile (each tile attends only to its own tokens).
1010
// M-RoPE offsets are mathematically inert in the vision encoder: relative attention cancels
1111
// any per-tile absolute offset. Tile arrangement reaches the LM via decoder positions in mtmd.cpp.
12-
const int n_pos = n_patches; // patches per tile
13-
const int n_pos_total = n_pos * batch_size; // total sequence length (all tiles)
14-
const int num_position_ids = n_pos_total * 4; // M-RoPE: 4 coords per patch
12+
const int n_pos = n_patches; // patches per tile
13+
const int64_t n_pos_total = (int64_t)n_pos * batch_size; // total sequence length (all tiles)
14+
const int64_t num_position_ids = n_pos_total * 4; // M-RoPE: 4 coords per patch
1515

1616
norm_type norm_t = NORM_TYPE_NORMAL;
1717

@@ -120,36 +120,37 @@ ggml_cgraph * clip_graph_qwen3vl::build() {
120120
cb(Kcur, "Kcur", il);
121121
cb(Vcur, "Vcur", il);
122122

123-
// Per-tile attention: each tile attends only to its own n_pos tokens.
124-
// QKV/FFN linear layers above are fully batched and run in parallel.
125-
ggml_tensor * attn_out = ggml_new_tensor_2d(ctx0, GGML_TYPE_F32, n_embd, n_pos * batch_size);
126-
for (int b = 0; b < batch_size; b++) {
127-
ggml_tensor * Q_b = ggml_view_3d(ctx0, Qcur, d_head, n_head, n_pos,
128-
Qcur->nb[1], Qcur->nb[2], (size_t)b * Qcur->nb[3]);
129-
ggml_tensor * K_b = ggml_view_3d(ctx0, Kcur, d_head, n_head, n_pos,
130-
Kcur->nb[1], Kcur->nb[2], (size_t)b * Kcur->nb[3]);
131-
ggml_tensor * V_b = ggml_view_3d(ctx0, Vcur, d_head, n_head, n_pos,
132-
Vcur->nb[1], Vcur->nb[2], (size_t)b * Vcur->nb[3]);
133-
134-
ggml_tensor * pos_b = ggml_view_1d(ctx0, positions, n_pos * 4,
135-
(size_t)b * n_pos * 4 * sizeof(int32_t));
136-
137-
Q_b = ggml_rope_multi(ctx0, Q_b, pos_b, nullptr,
138-
d_head/2, mrope_sections, GGML_ROPE_TYPE_VISION, 32768, 10000, 1, 0, 1, 32, 1);
139-
K_b = ggml_rope_multi(ctx0, K_b, pos_b, nullptr,
140-
d_head/2, mrope_sections, GGML_ROPE_TYPE_VISION, 32768, 10000, 1, 0, 1, 32, 1);
141-
142-
cb(Q_b, "Qcur_rope", il);
143-
cb(K_b, "Kcur_rope", il);
144-
145-
ggml_tensor * out_b = build_attn(layer.o_w, layer.o_b,
146-
Q_b, K_b, V_b, nullptr, kq_scale, il);
147-
148-
attn_out = ggml_set_2d(ctx0, attn_out,
149-
ggml_reshape_2d(ctx0, out_b, n_embd, n_pos),
150-
n_embd * sizeof(float),
151-
(size_t)b * n_embd * n_pos * sizeof(float));
152-
}
123+
// Batched block-diagonal attention: each tile attends only to its own n_pos tokens.
124+
// nb[3] = nb[2] * n_pos on the 4D views, so tiles are contiguous in sequence space.
125+
// Create zero-copy 3D non-contiguous views [d_head, n_head, n_pos*batch_size] with
126+
// stride nb[2] between positions — same stride the old per-tile loop used per tile.
127+
// rope assert: ne[2]*4 == positions.ne[0] → n_pos*batch_size*4 == num_position_ids ✅
128+
// After rope, rebuild 4D via ggml_view_4d (no copy). ggml_mul_mat in build_attn
129+
// treats ne[3] as batch dim, computing an independent n_pos×n_pos attention per tile.
130+
// NOTE: verify on Metal, Vulkan (Android), and OpenCL that ggml_mul_mat correctly
131+
// iterates ne[3] for both KQ and KQV products before merging.
132+
ggml_tensor * Q3 = ggml_view_3d(ctx0, Qcur, d_head, n_head, n_pos * batch_size,
133+
Qcur->nb[1], Qcur->nb[2], 0);
134+
ggml_tensor * K3 = ggml_view_3d(ctx0, Kcur, d_head, n_head, n_pos * batch_size,
135+
Kcur->nb[1], Kcur->nb[2], 0);
136+
137+
Q3 = ggml_rope_multi(ctx0, Q3, positions, nullptr,
138+
d_head/2, mrope_sections, GGML_ROPE_TYPE_VISION, 32768, 10000, 1, 0, 1, 32, 1);
139+
K3 = ggml_rope_multi(ctx0, K3, positions, nullptr,
140+
d_head/2, mrope_sections, GGML_ROPE_TYPE_VISION, 32768, 10000, 1, 0, 1, 32, 1);
141+
142+
// Rebuild 4D view from rope output (same non-contiguous stride layout as Qcur/Kcur)
143+
ggml_tensor * Q4 = ggml_view_4d(ctx0, Q3, d_head, n_head, n_pos, batch_size,
144+
Q3->nb[1], Q3->nb[2], Q3->nb[2] * n_pos, 0);
145+
ggml_tensor * K4 = ggml_view_4d(ctx0, K3, d_head, n_head, n_pos, batch_size,
146+
K3->nb[1], K3->nb[2], K3->nb[2] * n_pos, 0);
147+
148+
cb(Q4, "Qcur_rope", il);
149+
cb(K4, "Kcur_rope", il);
150+
151+
// build_attn output: [n_embd, n_pos * batch_size]
152+
ggml_tensor * attn_out = build_attn(layer.o_w, layer.o_b,
153+
Q4, K4, Vcur, nullptr, kq_scale, il);
153154
cb(attn_out, "attn_out", il);
154155

155156
// [n_embd, n_pos * batch_size] → [n_embd, n_pos, batch_size]

0 commit comments

Comments
 (0)