Skip to content

[Feature] Add Qwen3.5 vision model support - #3474

Open
gnguralnick wants to merge 19 commits into
mlc-ai:mainfrom
gnguralnick:qwen35-vision-pr
Open

[Feature] Add Qwen3.5 vision model support#3474
gnguralnick wants to merge 19 commits into
mlc-ai:mainfrom
gnguralnick:qwen35-vision-pr

Conversation

@gnguralnick

@gnguralnick gnguralnick commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add Qwen3.5 vision model support with custom ViT encoder (2D RoPE, spatial patch merging)
  • Vision-aware conversation protocol handling for Qwen3.5V image tokens
  • Image embedding size computation for Qwen3.5V

Stacked on #3473 and #3429 .

Test plan

  • Unit tests for Qwen3.5V model config, vision encoder, and weight loading (test_qwen35v.py)
  • End-to-end: compile and chat with a Qwen3.5V model with image input

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces support for Gemma 3 Vision and Qwen 3.5 Vision models. It includes the implementation of the SigLIP vision encoder, model architectures, weight loaders, and conversation templates for these VLMs. Additionally, it updates image processing utilities to handle model-specific normalization and tokenization requirements. Feedback focuses on correcting API usage in the Qwen 3.5 vision encoder, fixing a missing import in the attention operator, and improving the dynamic calculation of image embedding sizes in the serving layer.

Comment on lines +212 to +213
cos = nn.wrap_nested(cos, "rope_cos")
sin = nn.wrap_nested(sin, "rope_sin")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The nn module (imported as tvm.relax.frontend.nn) does not have a wrap_nested attribute. This function is located within the nn.op module. Since you have already imported op from nn, you should use op.wrap_nested instead.

Suggested change
cos = nn.wrap_nested(cos, "rope_cos")
sin = nn.wrap_nested(sin, "rope_sin")
cos = op.wrap_nested(cos, "rope_cos")
sin = op.wrap_nested(sin, "rope_sin")


# rotate_half: [-x2, x1] where x1, x2 are first/second half of last dim
x1, x2 = op.split(x, 2, axis=-1)
rotated = op.concat([op.negative(x2), x1], dim=-1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The op.concat function in the nn.op module uses the keyword argument axis, not dim. Using dim will result in a TypeError at runtime.

Suggested change
rotated = op.concat([op.negative(x2), x1], dim=-1)
rotated = op.concat([op.negative(x2), x1], axis=-1)

v = v.repeat(h_q // h_kv, axis=2)

target = tvm.target.Target("cuda")
target = _extern.get_store().target or tvm.target.Target("cuda")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The variable _extern is used here but it is not imported in the file's header. This will cause a NameError when the _fallback path is executed. Please ensure _extern is correctly imported from the local module.

Suggested change
target = _extern.get_store().target or tvm.target.Target("cuda")
target = nn.op.extern.get_store().target or tvm.target.Target("cuda")

Comment on lines +154 to +160
if model_type == "gemma3_v":
# fixed to 256 per image
return 256

if model_type == "qwen3_5_vision":
# (image_size / patch_size / spatial_merge_size)^2 = (448/16/2)^2 = 196
return 196

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The image embedding sizes for gemma3_v and qwen3_5_vision are currently hardcoded. This prevents the engine from correctly handling images if the model is compiled with a different resolution or patch configuration. It is better to compute these values dynamically using the provided config.

Suggested change
if model_type == "gemma3_v":
# fixed to 256 per image
return 256
if model_type == "qwen3_5_vision":
# (image_size / patch_size / spatial_merge_size)^2 = (448/16/2)^2 = 196
return 196
if model_type == "gemma3_v":
# (image_size // patch_size // 4)^2 = (896 // 14 // 4)^2 = 256
vision_cfg = config["model_config"]["vision_config"]
image_size = vision_cfg.get("image_size", 896)
patch_size = vision_cfg.get("patch_size", 14)
return (image_size // patch_size // 4) ** 2
if model_type == "qwen3_5_vision":
# (image_size / patch_size / spatial_merge_size)^2 = (448/16/2)^2 = 196
vision_cfg = config["model_config"]["vision_config"]
image_size = config["model_config"].get("image_size", 448)
patch_size = vision_cfg.get("patch_size", 16)
merge_size = vision_cfg.get("spatial_merge_size", 2)
return (image_size // patch_size // merge_size) ** 2

@gnguralnick gnguralnick changed the title [Feature] Add Qwen3.5 vision and Gemma3 vision model support [Feature] Add Qwen3.5 vision-language model support Apr 2, 2026
@gnguralnick gnguralnick changed the title [Feature] Add Qwen3.5 vision-language model support [Feature] Add Qwen3.5 vision model support Apr 2, 2026
Gabriel Guralnick added 19 commits April 2, 2026 10:50
Add dedicated conversation templates for Qwen3.5 models instead of
using the generic ChatML format. Includes two variants:
- qwen3_5: thinking enabled (assistant opens a <think> block)
- qwen3_5_nothink: thinking disabled (empty <think> block prefix)

Sculptor: gabeguralnick
…ix type hint

- Use self.config.vision_config.image_size instead of hardcoded 896 in
  gemma3v image_preprocess
- Refactor normalize/normalize_siglip into shared _normalize_impl to
  reduce duplication in ImageProcessor
- Fix SigLIPEncoder.forward return type hint (Tensor -> Tuple[Tensor, ...])
…igLIP

- Move trailing \n into else branch so gemma3_v doesn't get extra newline after EOI
- Remove unused image_token_index from Gemma3VConfig
- Fix misleading comment on mm_soft_emb_norm (Gemma +1 is fused during weight loading)
- Remove redundant gemma3_vision_instruction template (identical to gemma3_instruction)
- Simplify SigLIP encoder: remove tuple wrapping, state accumulation, unused logger
Adapts to the quantization refactoring on main that replaced per-model
quantization files with make_quantization_functions.
- test_gemma3v.py: model registration, TVM IR export with VLM
  composition (vision_tower + language_model + projector), config
  validation
- test_siglip_vision.py: SigLIP vision encoder export with expected
  parameter components
- test_attention_fallback.py: correctness test for naive matmul+softmax
  fallback when head_dim % 16 != 0 (e.g. SigLIP head_dim=72), compared
  against numpy reference
- Update gemma3v_model.py to use tirx namespace (matching upstream migration)
- Remove head_dim%16 attention fallback from op/attention.py (TVM bug is fixed)
- Keep Optional type hints and target detection fix in attention.py
- Remove test_attention_fallback.py (no longer needed)
Use the standard loader for gate_up concatenation and 1:1 param fallback,
matching the pattern established in gemma3_loader.py. Only vision-specific
overrides (mm_input_projection transpose, norm +1) remain manual.
TVM FFI objects don't support setting arbitrary Python attributes.
Expose embed_size via a new ImageDataGetEmbedSize FFI function
instead of storing it as a Python attribute.
Add vision encoder, VLM wrapper, and weight loader for Qwen3.5 VLM
(model type: qwen3_5_vision). The vision encoder uses a custom ViT
with 2D RoPE and 2x2 spatial patch merging, producing 196 image tokens
at 448x448 resolution.

Also adds qwen3_5 and qwen3_5_nothink conversation templates with
correct Qwen3.5 stop token IDs (248046/248044) and thinking support.

Tested with Qwen3.5-4B (q4f16_1, Metal) for both text and vision.
Raise explicit error when vision_config is None instead of silently
leaving it unset (which causes AttributeError later in grid_h/grid_w).
Remove spurious blank line in conversation_template imports.
Tests model registration, TVM IR export, config validation, and
standalone vision encoder creation following existing patterns
(test_gemma3v.py, test_siglip_vision.py).
@gnguralnick
gnguralnick marked this pull request as ready for review April 2, 2026 17:52
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