Skip to content

Commit a581673

Browse files
authored
Arm backend: Add Qwen3 VL E2E coverage (pytorch#20274)
Add full Qwen3 VL language and vision model tests for the Arm backend in FP32 and BF16 modes. Cover both TOSA and VGF no-quant paths, with BF16 VGF using an explicit FP profile that advertises BF16 support. Relax the FP32 language-model tolerance to match observed TOSA reference drift for the full decoder stack. cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani Signed-off-by: Baris Demir <baris.demir@arm.com>
1 parent 551e90e commit a581673

1 file changed

Lines changed: 268 additions & 0 deletions

File tree

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
# Copyright 2026 Arm Limited and/or its affiliates.
2+
#
3+
# This source code is licensed under the BSD-style license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
6+
from __future__ import annotations
7+
8+
from dataclasses import dataclass
9+
from typing import Tuple
10+
11+
import pytest
12+
import torch
13+
import torch.nn.functional as F
14+
from executorch.backends.arm.test import common
15+
from executorch.backends.arm.test.models.Qwen3_VL.qwen3_vl_test_config import (
16+
get_qwen3_vl_2b_instruct_checkpoint_config,
17+
)
18+
from executorch.backends.arm.test.tester.test_pipeline import (
19+
TosaPipelineFP,
20+
VgfPipeline,
21+
)
22+
from transformers.models.qwen3_vl.modeling_qwen3_vl import (
23+
Qwen3VLTextModel,
24+
Qwen3VLVisionModel,
25+
)
26+
27+
input_t = Tuple[torch.Tensor, ...]
28+
29+
30+
def _make_qwen3_vl_2b_instruct_layer_config():
31+
config = get_qwen3_vl_2b_instruct_checkpoint_config()
32+
config.text_config._attn_implementation = "sdpa"
33+
config.vision_config._attn_implementation = "sdpa"
34+
return config
35+
36+
37+
def _make_text_position_ids(
38+
batch_size: int, seq_length: int, device: torch.device
39+
) -> torch.Tensor:
40+
return torch.arange(seq_length, device=device).unsqueeze(0).repeat(batch_size, 1)
41+
42+
43+
def _make_image_grid_thw(device: torch.device) -> torch.Tensor:
44+
return torch.tensor([[1, 4, 4]], dtype=torch.long, device=device)
45+
46+
47+
def _make_pixel_values(config, device: torch.device) -> torch.Tensor:
48+
grid_thw = _make_image_grid_thw(device)
49+
patch_volume = (
50+
config.vision_config.in_channels
51+
* config.vision_config.temporal_patch_size
52+
* config.vision_config.patch_size
53+
* config.vision_config.patch_size
54+
)
55+
num_patches = int(torch.prod(grid_thw[0]).item())
56+
return torch.randn(num_patches, patch_volume, device=device)
57+
58+
59+
class Qwen3VLModelTestModule(torch.nn.Module):
60+
@classmethod
61+
def prepare_model_and_inputs(cls):
62+
raise NotImplementedError
63+
64+
65+
def _to_bfloat16_model_and_floating_inputs(
66+
model: torch.nn.Module, inputs: input_t
67+
) -> tuple[torch.nn.Module, input_t]:
68+
"""Convert model and floating inputs for BF16 backend coverage."""
69+
70+
return model.to(torch.bfloat16), tuple(
71+
(
72+
x.to(torch.bfloat16)
73+
if isinstance(x, torch.Tensor) and x.is_floating_point()
74+
else x
75+
)
76+
for x in inputs
77+
)
78+
79+
80+
class TextModelWrapper(Qwen3VLModelTestModule):
81+
def __init__(self, config) -> None:
82+
super().__init__()
83+
self.model = Qwen3VLTextModel(config.text_config)
84+
85+
def forward(
86+
self,
87+
input_ids: torch.Tensor,
88+
attention_mask: torch.Tensor,
89+
position_ids: torch.Tensor,
90+
) -> torch.Tensor:
91+
outputs = self.model(
92+
input_ids=input_ids,
93+
attention_mask=attention_mask,
94+
position_ids=position_ids,
95+
)
96+
return outputs.last_hidden_state
97+
98+
@classmethod
99+
def prepare_model_and_inputs(cls):
100+
torch.manual_seed(0)
101+
config = _make_qwen3_vl_2b_instruct_layer_config()
102+
model = cls(config).eval()
103+
input_ids = torch.randint(0, 128, (2, 8), dtype=torch.long)
104+
attention_mask = torch.ones_like(input_ids)
105+
position_ids = _make_text_position_ids(2, 8, input_ids.device)
106+
return model, (input_ids, attention_mask, position_ids)
107+
108+
109+
class LowerableVisionModelWrapper(Qwen3VLModelTestModule):
110+
def __init__(self, config) -> None:
111+
super().__init__()
112+
self.visual = Qwen3VLVisionModel(config.vision_config)
113+
114+
with torch.no_grad():
115+
grid_thw = _make_image_grid_thw(self.visual.pos_embed.weight.device)
116+
pos_embeds = self.visual.fast_pos_embed_interpolate(grid_thw)
117+
118+
rotary_pos_emb = self.visual.rot_pos_emb(grid_thw)
119+
emb = torch.cat((rotary_pos_emb, rotary_pos_emb), dim=-1)
120+
cos = emb.cos()
121+
sin = emb.sin()
122+
123+
cu_seqlens = torch.repeat_interleave(
124+
grid_thw[:, 1] * grid_thw[:, 2], grid_thw[:, 0]
125+
).cumsum(dim=0, dtype=torch.int32)
126+
cu_seqlens = F.pad(cu_seqlens, (1, 0), value=0)
127+
128+
self.register_buffer("pos_embeds", pos_embeds)
129+
self.register_buffer("cos", cos)
130+
self.register_buffer("sin", sin)
131+
self.register_buffer("cu_seqlens", cu_seqlens)
132+
133+
def forward(self, pixel_values: torch.Tensor) -> torch.Tensor:
134+
hidden_states = self.visual.patch_embed(pixel_values)
135+
hidden_states = hidden_states + self.pos_embeds
136+
137+
position_embeddings = (self.cos, self.sin)
138+
deepstack_feature_lists = []
139+
for layer_num, blk in enumerate(self.visual.blocks):
140+
hidden_states = blk(
141+
hidden_states,
142+
cu_seqlens=self.cu_seqlens,
143+
position_embeddings=position_embeddings,
144+
)
145+
if layer_num in self.visual.deepstack_visual_indexes:
146+
deepstack_feature = self.visual.deepstack_merger_list[
147+
self.visual.deepstack_visual_indexes.index(layer_num)
148+
](hidden_states)
149+
deepstack_feature_lists.append(deepstack_feature)
150+
151+
hidden_states = self.visual.merger(hidden_states)
152+
153+
# Keep deepstack feature extraction in the exported graph without
154+
# changing the model output.
155+
deepstack_residual = hidden_states.new_zeros(())
156+
for deepstack_feature in deepstack_feature_lists:
157+
deepstack_residual = deepstack_residual + deepstack_feature.sum() * 0
158+
159+
return hidden_states + deepstack_residual
160+
161+
@classmethod
162+
def prepare_model_and_inputs(cls):
163+
torch.manual_seed(0)
164+
config = _make_qwen3_vl_2b_instruct_layer_config()
165+
model = cls(config).eval()
166+
pixel_values = _make_pixel_values(config, torch.device("cpu"))
167+
return model, (pixel_values,)
168+
169+
170+
@dataclass(frozen=True)
171+
class Qwen3VLModelTestCase:
172+
model_cls: type[Qwen3VLModelTestModule]
173+
run_on_vulkan_runtime: bool = True
174+
atol: float = 1e-3
175+
rtol: float = 1e-3
176+
177+
178+
TOSA_FP_TEST_CASES: dict[str, Qwen3VLModelTestCase] = {
179+
"vision_model": Qwen3VLModelTestCase(
180+
model_cls=LowerableVisionModelWrapper,
181+
),
182+
"text_model": Qwen3VLModelTestCase(
183+
model_cls=TextModelWrapper,
184+
atol=3e-2,
185+
rtol=1e-2,
186+
),
187+
}
188+
189+
VGF_NO_QUANT_TEST_CASES: dict[str, Qwen3VLModelTestCase] = {
190+
"vision_model": Qwen3VLModelTestCase(
191+
model_cls=LowerableVisionModelWrapper,
192+
run_on_vulkan_runtime=False,
193+
),
194+
"text_model": Qwen3VLModelTestCase(
195+
model_cls=TextModelWrapper,
196+
run_on_vulkan_runtime=False,
197+
),
198+
}
199+
200+
201+
@pytest.mark.slow
202+
@common.parametrize("test_case", TOSA_FP_TEST_CASES)
203+
def test_qwen3_vl_full_models_tosa_FP(test_case: Qwen3VLModelTestCase):
204+
model, inputs = test_case.model_cls.prepare_model_and_inputs()
205+
with torch.no_grad():
206+
pipeline = TosaPipelineFP[input_t](
207+
model,
208+
inputs,
209+
aten_op=[],
210+
exir_op=[],
211+
atol=test_case.atol,
212+
rtol=test_case.rtol,
213+
)
214+
pipeline.run()
215+
216+
217+
@pytest.mark.slow
218+
@common.parametrize("test_case", TOSA_FP_TEST_CASES)
219+
def test_qwen3_vl_full_models_tosa_FP_bf16(test_case: Qwen3VLModelTestCase):
220+
model, inputs = test_case.model_cls.prepare_model_and_inputs()
221+
model, inputs = _to_bfloat16_model_and_floating_inputs(model, inputs)
222+
with torch.no_grad():
223+
pipeline = TosaPipelineFP[input_t](
224+
model,
225+
inputs,
226+
aten_op=[],
227+
exir_op=[],
228+
tosa_extensions=["bf16"],
229+
atol=1e-1,
230+
rtol=1e-1,
231+
)
232+
pipeline.run()
233+
234+
235+
@pytest.mark.slow
236+
@common.SkipIfNoModelConverter
237+
@common.parametrize("test_case", VGF_NO_QUANT_TEST_CASES)
238+
def test_qwen3_vl_full_models_vgf_no_quant(test_case: Qwen3VLModelTestCase):
239+
model, inputs = test_case.model_cls.prepare_model_and_inputs()
240+
with torch.no_grad():
241+
pipeline = VgfPipeline[input_t](
242+
model,
243+
inputs,
244+
aten_op=[],
245+
exir_op=[],
246+
quantize=False,
247+
run_on_vulkan_runtime=test_case.run_on_vulkan_runtime,
248+
)
249+
pipeline.run()
250+
251+
252+
@pytest.mark.slow
253+
@common.SkipIfNoModelConverter
254+
@common.parametrize("test_case", VGF_NO_QUANT_TEST_CASES)
255+
def test_qwen3_vl_full_models_vgf_no_quant_bf16(test_case: Qwen3VLModelTestCase):
256+
model, inputs = test_case.model_cls.prepare_model_and_inputs()
257+
model, inputs = _to_bfloat16_model_and_floating_inputs(model, inputs)
258+
with torch.no_grad():
259+
pipeline = VgfPipeline[input_t](
260+
model,
261+
inputs,
262+
aten_op=[],
263+
exir_op=[],
264+
quantize=False,
265+
run_on_vulkan_runtime=test_case.run_on_vulkan_runtime,
266+
tosa_spec="TOSA-1.0+FP+bf16",
267+
)
268+
pipeline.run()

0 commit comments

Comments
 (0)