Skip to content

Commit c7e344b

Browse files
[None][fix] Restore Qwen3-VL vision-block RoPE shape after EXAONE-4.5 rebase
The qwen3vl_opt branch's ``1c4e86892d "Further optimize Qwen3-VL"`` removed ``rope_position_ids`` from the per-block call and added a ``rotary_pos_emb.flatten(1).repeat(1, 2).cos()/.sin()`` chain in the vision tower forward. At authoring time that matched the HF ``apply_rotary_pos_emb_vision`` ABI (cos/sin shape ``(seq, head_dim)``), which ``Qwen2_5_VLVisionAttention.forward`` used. After upstream ``7279d6322d "EXAONE-4.5 Support"`` refactored that attention class to route through the new ``apply_rope`` helper (``RotaryEmbedding.apply_rotary_pos_emb`` / FlashInfer), the expected ``cos.shape[-1]`` became ``head_dim // 2``: ``apply_rotary_pos_emb`` computes ``rot_dim = cos.shape[-1] * 2`` and chunks q/k into halves of size ``cos.shape[-1]``. With Qwen3-VL's ``head_dim = 72`` (``72 % 64 != 0``, so the FlashInfer gate misses and the PyTorch fallback runs), the prior ``.repeat(1, 2)`` made cos/sin ``(total, 72)`` and the elementwise multiply against ``q.chunk(2)[i]`` (shape ``(..., 36)``) raised ``RuntimeError: The size of tensor a (36) must match the size of tensor b (72) at non-singleton dimension 2`` -- silently broken at rebase time, missed by every unit test on this branch because they verified isolated cos/sin construction (pos_ids, lru_cache identity, gather equivalence) but never piped them through the actual attention block. CI's model-level test would have caught it. Fix: * ``Qwen3VisionModel.forward``: drop ``cos.repeat(1, 2)`` / ``sin.repeat(1, 2)``. The cos/sin tuple returned by ``rot_pos_emb`` is already at the post-EXAONE shape ``(total_tokens, 2 * freq_dim) = (total_tokens, head_dim // 2)``. * ``Qwen3VisionModel.forward``: restore the pre-1c4e86892d ``rope_position_ids = torch.arange(seq_len, dtype=int32, pin_memory=prefer_pinned()).to(device, non_blocking=True)`` and pass ``position_ids=rope_position_ids`` into each block. This keeps the FlashInfer ``position_ids is not None`` gate satisfied for any future config where ``head_dim % 64 == 0``; for Qwen3-VL itself it still falls through to the PyTorch path, which now has the correct broadcast. All prior optimizations on this branch are preserved: cos/sin pre-computed init buffers (``rope_cos_cache`` / ``rope_sin_cache``), L2 per-(t, h, w) cache, batched Triton pos-embed kernel, rot_pos_ids lru_cache, async_tensor_h2d helper, argsort inverse permutation, deepstack dict lookup. The fix is a layout correction on the output side of ``rot_pos_emb``, not a revert of the optimizations. Test: ``test_qwen3vl_vision_block_forward_end_to_end`` builds one ``Qwen3VLVisionBlock`` with random weights and runs forward with the post-EXAONE cos/sin shape and ``position_ids``. Without this fix it raises the same broadcasting error; with it the block returns the expected ``(seq_len, hidden)`` tensor. Any future regression that re-introduces a stray ``.repeat(1, 2)`` on cos/sin (or drops ``position_ids``) will surface here, not only at CI time. Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
1 parent af0731d commit c7e344b

2 files changed

Lines changed: 117 additions & 4 deletions

File tree

tensorrt_llm/_torch/models/modeling_qwen3vl.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,22 +1132,34 @@ def forward(
11321132
grid_rows = grid_thw.tolist()
11331133
# ``rot_pos_emb`` returns (cos, sin) gathered from the pre-computed
11341134
# cos/sin buffers -- no ``.cos()``/``.sin()`` kernels in forward.
1135-
# Each half has shape (total_tokens, 2*freq_dim); ``.repeat(1, 2)``
1136-
# below expands to head_dim so the per-token layout matches the
1137-
# prior ``freqs.flatten(1).repeat(1, 2).cos()`` chain bit-for-bit.
1135+
# Each half has shape (total_tokens, 2*freq_dim) = (total_tokens,
1136+
# rotary_dim), which is what ``Qwen2_5_VLVisionAttention.apply_rope``
1137+
# / ``RotaryEmbedding.apply_rotary_pos_emb`` expects (it computes
1138+
# ``rot_dim = cos.shape[-1] * 2 = head_dim`` and chunks q/k into
1139+
# halves of size ``cos.shape[-1]``).
11381140
cos, sin = self.rot_pos_emb(grid_rows)
11391141
pos_embeds = self.fast_pos_embed_interpolate(grid_rows)
11401142

11411143
hidden_states = self.patch_embed(pixel_values)
11421144
hidden_states += pos_embeds
11431145
hidden_states = hidden_states.flatten(1)
11441146

1145-
position_embeddings = (cos.repeat(1, 2), sin.repeat(1, 2))
1147+
# Vision RoPE backend (FlashInfer path) gates on ``position_ids is
1148+
# not None``; supply trivial 0..seq_len-1 positions on device so
1149+
# the gate clears when ``head_dim % 64 == 0``. For Qwen3-VL
1150+
# (head_dim=72) the gate misses and we fall through to the
1151+
# PyTorch path, which broadcasts cos/sin over the chunked q/k.
1152+
seq_len = hidden_states.shape[0]
1153+
rope_position_ids = torch.arange(seq_len, dtype=torch.int32, pin_memory=prefer_pinned()).to(
1154+
device=self.device, non_blocking=True
1155+
)
1156+
position_embeddings = (cos, sin)
11461157

11471158
deepstack_feature_lists = []
11481159
for layer_num, block in enumerate(self.blocks):
11491160
hidden_states = block(
11501161
hidden_states,
1162+
position_ids=rope_position_ids,
11511163
attn_metadata=attn_metadata,
11521164
position_embeddings=position_embeddings,
11531165
)

tests/unittest/_torch/modeling/test_modeling_qwen3vl.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -951,3 +951,104 @@ def test_argsort_matches_scatter_inverse(n):
951951
torch.testing.assert_close(
952952
identity, torch.arange(n, device="cuda", dtype=window_index.dtype), atol=0, rtol=0
953953
)
954+
955+
956+
# ---------------------------------------------------------------------------
957+
# End-to-end Qwen3VLVisionBlock forward path.
958+
#
959+
# The pre-EXAONE-4.5 ``apply_rotary_pos_emb_vision`` (HF) accepted
960+
# ``cos / sin`` of shape ``(seq, head_dim)``; the post-EXAONE-4.5
961+
# ``RotaryEmbedding.apply_rotary_pos_emb`` (TRT-LLM) expects
962+
# ``(seq, head_dim // 2)`` and broadcasts. This test pushes one block
963+
# through with the post-EXAONE shape so any future regression (e.g.
964+
# re-introducing a stray ``.repeat(1, 2)`` on cos/sin) surfaces here
965+
# rather than only at full-model CI.
966+
# ---------------------------------------------------------------------------
967+
968+
969+
def test_qwen3vl_vision_block_forward_end_to_end():
970+
"""One ``Qwen3VLVisionBlock.forward`` call with random weights and the
971+
correct (post-EXAONE) ``cos/sin`` shape must not raise a broadcasting
972+
error in ``apply_rope``."""
973+
from transformers import Qwen3VLConfig
974+
975+
from tensorrt_llm._torch.attention_backend.utils import get_attention_backend
976+
from tensorrt_llm._torch.model_config import ModelConfig
977+
from tensorrt_llm._torch.models.modeling_qwen3vl import Qwen3VLVisionBlock
978+
979+
hf_config = Qwen3VLConfig(
980+
text_config={
981+
"hidden_size": 256,
982+
"num_attention_heads": 4,
983+
"num_hidden_layers": 1,
984+
"intermediate_size": 256,
985+
"head_dim": 64,
986+
"rope_scaling": {
987+
"mrope_section": [8, 8, 8],
988+
"mrope_interleaved": True,
989+
"rope_type": "default",
990+
},
991+
"max_position_embeddings": 1024,
992+
"rms_norm_eps": 1e-6,
993+
"num_key_value_heads": 4,
994+
"vocab_size": 1024,
995+
"dtype": "bfloat16",
996+
"rope_theta": 10000.0,
997+
"tie_word_embeddings": False,
998+
"model_type": "qwen3_vl_text",
999+
},
1000+
vision_config={
1001+
"hidden_size": 1152,
1002+
"num_heads": 16, # head_dim = 72
1003+
"depth": 1,
1004+
"intermediate_size": 4304,
1005+
"patch_size": 16,
1006+
"spatial_merge_size": 2,
1007+
"temporal_patch_size": 2,
1008+
"in_channels": 3,
1009+
"num_position_embeddings": 2304,
1010+
"out_hidden_size": 256,
1011+
"deepstack_visual_indexes": [],
1012+
"hidden_act": "gelu_pytorch_tanh",
1013+
"model_type": "qwen3_vl",
1014+
"initializer_range": 0.02,
1015+
},
1016+
architectures=["Qwen3VLForConditionalGeneration"],
1017+
image_token_id=151655,
1018+
video_token_id=151656,
1019+
vision_start_token_id=151652,
1020+
vision_end_token_id=151653,
1021+
)
1022+
1023+
mc = ModelConfig(pretrained_config=hf_config, attn_backend="TRTLLM")
1024+
block = Qwen3VLVisionBlock(mc, layer_idx=0).to("cuda", dtype=torch.bfloat16)
1025+
1026+
seq_len = 256
1027+
head_dim = 72
1028+
freq_dim = head_dim // 2 # 36 -- the (post-EXAONE) cos/sin last-dim
1029+
1030+
hidden = torch.randn(seq_len, 1152, device="cuda", dtype=torch.bfloat16)
1031+
cos = torch.randn(seq_len, freq_dim, device="cuda", dtype=torch.float32)
1032+
sin = torch.randn(seq_len, freq_dim, device="cuda", dtype=torch.float32)
1033+
position_ids = torch.arange(seq_len, dtype=torch.int32, device="cuda")
1034+
1035+
metadata_cls = get_attention_backend("TRTLLM").Metadata
1036+
attn_metadata = metadata_cls(
1037+
max_num_requests=64,
1038+
max_num_tokens=seq_len,
1039+
kv_cache_manager=None,
1040+
)
1041+
attn_metadata.num_contexts = 1
1042+
attn_metadata.request_ids = [1]
1043+
attn_metadata.prompt_lens = [seq_len]
1044+
attn_metadata.seq_lens = torch.tensor([seq_len], dtype=torch.int)
1045+
attn_metadata.max_seq_len = seq_len
1046+
attn_metadata.prepare()
1047+
1048+
out = block(
1049+
hidden,
1050+
position_ids=position_ids,
1051+
position_embeddings=(cos, sin),
1052+
attn_metadata=attn_metadata,
1053+
)
1054+
assert out.shape == hidden.shape

0 commit comments

Comments
 (0)