Skip to content

GGUF vision towers (mmproj): survey and plan #794

Open

Description

@jason-fxz

Sub-issue of #671 (GGUF → Vision towers). It records what we found about loading llama.cpp vision towers (mmproj) for the families FreeToken serves images for. Checked against public main 546c5fe and llama.cpp master 339f3242c.

Warning

This is a first scan and may be wrong in places. For most models we only read the GGUF headers and HF repo listings. The weight comparisons ran on CPU outside FreeToken. Check each claim when that family is implemented.

Where it stands

  • feat(gguf): take image input on Qwen and Gemma-4 GGUF checkpoints from an mmproj #803 (merged): image input from an mmproj for Qwen3.5 / 3.6 / 3.8 and Gemma-4 (26B-A4B, 31B, 12B unified); the behavior is under Behavior.
  • feat(gguf): serve image input on glm5-next ggufs from a glm5v mmproj #810 (in review): GLM-5.3-Flash (glm5v) on the same base.
  • The other families still load GGUF text only.
  • llama.cpp always writes the vision tower to a separate file, mmproj-*.gguf:
    • general.architecture = clip, general.type = mmproj.
    • Tensors are named v.* / mm.*; metadata sits under clip.vision.*.
    • The projector type is in clip.projector_type or clip.vision.projector_type.
  • llama.cpp loads it with --mmproj FILE. When it downloads a model with -hf, it also picks the sibling *mmproj*.gguf whose quant bits are closest to the text model.
  • Old Ollama GGUFs put v.* / mm.* inside the text file. Ollama is migrating them to split files (compatmigrate). None of the HF repos we checked ship that layout.

Per family

Checked by comparing a BF16 mmproj with the HF vision weights after undoing llama.cpp's transforms (CPU only).

Family Text GGUF projector_type Tensor transforms Fixed defaults missing from the mmproj Weights vs HF
Qwen3.5 / 3.6 qwen35, qwen35moe (#701) qwen3vl_merger stack v.patch_embd.weight + .weight.1 into the Conv3d; qkv is already fused min/max pixels 65536 / 16777216 333/333 bit-exact (Qwen3.6-27B); served in #803
Qwen3.8-Flash-Next qwen4exp (#690) qwen3vl_merger (no DeepStack, as Qwen3.5) same as Qwen3.5 same as Qwen3.5 served in #803
Qwen3-VL qwen3vl, qwen3vlmoe (#807) qwen3vl_merger with DeepStack layers as Qwen3.5, plus the DeepStack mergers same as Qwen3.5 351/351 (Qwen3-VL-8B). Qwen3.5 and later refuse a DeepStack mmproj
Gemma-4 26B-A4B / 31B gemma4 (#690, #706) gemma4v patch embedding CHW → HWC gelu_tanh, pooling 3, vision rope theta 100, max_soft_tokens 280; clip.vision.image_size = 224 is a placeholder 356/356 (26B-A4B, 31B); served in #803
Gemma-4 12B (unified) gemma4, the same arch as 26B / 31B; the mmproj tells them apart gemma4uv (+ gemma4ua audio, not loaded) as gemma4v, plus the position-embedding permute as gemma4v 10/10; served in #803
GLM-5.3-Flash glm5-next (#721) glm5v stack the patch embedding halves; qkv is already fused none (min/max pixels are in the metadata) 347/347
MiniMax-M3 minimax-m3 (#725) minimax_m3 stack the patch embedding halves; undo llama.cpp's per-head q/k permute (head_dim 80) vision rope theta 10000; min/max pixels 3136 / 451584 395/395
Muse-Glimmer muse-glimmer (#724) muse-glimmer undo the q/k rope permute. llama.cpp sums the two temporal slabs of the patch embedding, which cannot be undone; images are unaffected (bf16(sum) in the current Linear: 2.0e-3 relative difference vs the HF tower output), video cannot be supported layer_types rule, temporal patch 2, gelu 808/809 (the patch embedding is the one that differs)
DeepSeek-V4-Flash-Vision-Exp deepseek4 (#774) deepseek4v cat(gate, up); cat(q, k, v) for unsloth's file (ggml-org's is already fused) vision rope theta 10000, max_n_token 384, max_wh_ratio 8 267/267
DeepSeek-V4.1 no text GGUF support (llama.cpp has no converter) deepseek41v (unsloth) n/a n/a not checked

Notes:

  • Where mmprojs are published:
    • unsloth, ggml-org, bartowski and lmstudio publish one for every family above except MiniMax-M3.
    • For MiniMax-M3, 9 of 26 GGUF repos ship one (bartowski, AesSedai, ...); unsloth does not.
  • Weight types:
    • Most mmprojs are BF16, F16 or F32.
    • ggml-org also publishes Q8_0.
    • lmstudio's Muse-Glimmer mmproj is Q4_K_M.
  • Image token ids: the mmproj does not store image / boi / eoi token ids. They come from the text GGUF's tokenizer, looked up by token string.
  • DeepSeek-V4 specifics:
    • The text GGUF has no image tokens. The img_start / pad / end embeddings live in the mmproj.
    • ggml-org's chat template cannot render images, so the tokenizer needs an image-placeholder fallback.
  • Image processors: for Qwen, Gemma-4 and GLM-5.3, an image processor built from the mmproj metadata plus the defaults above gives the same output as AutoImageProcessor on the HF release. MiniMax-M3 and Muse-Glimmer have not been compared yet. DeepSeek-V4 has its own preprocessing.

Precision

Step 1 does not quantize the vision tower. Every mmproj is dequantized to bf16 at load.

mmproj type Result in bf16
BF16 / F32 exact
F16 loses f16 subnormals only (|x| ≤ 7.6e-6, max error 3e-8)
Q8_0 the loss is already in the file. Per-tensor cos ≥ 0.9999, but on GLM-5.3 the worst-token cos of the tower output drops from 0.74 to 0.50

Why not quantize:

  • Every FreeToken tower is plain bf16. Qwen's tower takes config.quant, but GGUFConfig maps no visual.* names.
  • The FP8 / NVFP4 / MXFP8 HF releases we checked all keep vision in bf16.
  • The towers are 0.1–3.7 GB, and host placement already covers memory pressure.
  • vLLM and SGLang:
    • Neither has a feature that quantizes ViT weights on its own initiative; the tower follows the checkpoint.
    • SGLang hardcodes quant_config=None for the Qwen3-VL / Qwen3.5 and MiniMax-M3 towers.
    • vLLM's GGUF plugin handles mmproj for Gemma3 only, and runs it at the file's types.

Behavior

As shipped in #803, for the families with GGUF image input (Qwen3.5 / 3.6 / 3.8, Gemma-4).

  • Only separate llama.cpp mmproj files are read. A text GGUF that carries v.* / mm.* tensors itself (old Ollama layout) serves text, with a warning that those tensors are not read.
  • --mmproj FILE picks the file. It must be a llama.cpp vision mmproj and needs a .gguf model. A file the model cannot use (another tower type, another output width, image tokens missing from the text tokenizer) stops startup with an error.
  • Without --mmproj, FreeToken looks for *mmproj*.gguf in the model file's own folder only, as llama-server does for a --models-dir entry. It does not look in parent folders, so a repo that keeps each quant in a subfolder and the mmproj at the top needs --mmproj (17 of the 200 most downloaded Qwen GGUF repos).
    • It skips a file that cannot be read, whose output width differs from the text model, or that fails the family's checks; each skip is logged.
    • When the files belong to several models, it takes the one named like the text model. If none matches, the model serves text, with a warning.
    • Among the rest it prefers BF16, then F32, then F16, then quantized files. llama.cpp -hf picks the file whose quant bits are closest to the text model instead.
  • No usable mmproj: the model serves text and rejects image requests. --mm-disable vision and --text-model-only leave the mmproj unloaded the same way.
  • Families without GGUF image input do not look for an mmproj, and --mmproj has no effect on them.
  • Log: one line per process names the mmproj, how it was found, its projector type, its file type -> bf16, and its size. Another line lists the files not used. A quantized file gets a warning. Without an mmproj, the log says vision is disabled and why.
  • Precision: the tower runs in bf16. A quantized mmproj is dequantized at load and keeps the quantization error of the file.
  • Config: comes from the GGUF files alone, with no HF repo. The mmproj metadata, the text GGUF tokenizer (image token ids) and per-projector defaults supply the values. Image tokens, budgets, --image-min-tokens / --image-max-tokens and --mm-processor-kwargs work as for the HF checkpoint of the same model.
  • FTW: an FTW converted from a GGUF serves text only.

Open

  • FTW converted from a GGUF keeps no vision tower.
  • --mmproj on a family without GGUF image input is ignored without a message.

Work items

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions