Skip to content

Commit af2a28c

Browse files
justinchubyCopilot
andauthored
Fix Qwen2.5/3-VL vision PackedMultiHeadAttention crash on CUDA (#358)
## Problem Building **Qwen2.5-VL-3B** with Mobius and evaluating on **CUDA** crashes at the first windowed vision block: ``` [E ... PackedMultiHeadAttention node 'vision_encoder/visual/blocks.0/attn/PackedMultiHeadAttention_node_214'] Status Message: Input 'cumulative_sequence_length' should have 1 dimension with size equal to batch_size + 1 ``` ## Root cause The EP-gated packed path (`_emit_packed_mha`, used when the EP advertises `supports_packed_multi_head_attention`, i.e. CUDA / trt-rtx) built `token_offset` as shape **`(1, N)`**, declaring `batch_size = 1` to `com.microsoft::PackedMultiHeadAttention`. ORT derives `batch_size` from `token_offset.shape[0]` and requires `cumulative_sequence_length` (our `cu_seqlens`) to have length `batch_size + 1` (`contrib_ops/cuda/bert/packed_multihead_attention.cc`). Whenever `cu_seqlens` enumerates more than one sub-sequence: - **windowed** vision blocks (`cu_window_seqlens`, length `num_windows + 1`), or - multi-frame **video** / multi-image **full-attention** blocks (length `T + 1`), `num_sub_seqs + 1 > 2`, so the kernel rejects the input and the run crashes. The Copilot diagnosis in the issue was accurate. Even in the non-crashing single-sub-sequence case, `batch_size = 1` made the kernel compute **full** attention over all `N` tokens instead of the intended **block-diagonal** attention — so the path was doubly broken. `_qwen3_vl_vision.py` had the identical copy-pasted bug (latent crash for multi-image / video). ## Fix Build `token_offset` with shape `(num_sub_seqs, max_seq_len)` following ORT's `GetPaddingOffset` convention — valid padded-layout token indices (`b * max_seq_len + s`) in packed order, then padding-slot indices. This matches the already-correct `BlockDiagonalToPackedMHA` rewrite rule, extracted into a shared `build_packed_token_offset` helper in `components/_common.py` and reused by both vision encoders. Also stop unconditionally down-casting q/k/v to float16: the PackedMHA CUDA kernel supports **both float32 and float16**, so only **bfloat16** builds (unsupported) are cast to f16; f32/f16 stay native to preserve precision. ## Verification - **CUDA parity:** with the fix, the packed path matches the standard block-diagonal attention path (CPU) on a 4-window grid (`grid_thw=[1,12,12]`) that crashed before — **cosine 1.0, max abs diff ~7e-8**. - New exact-value unit tests for `build_packed_token_offset` against ORT's own reference data (e.g. `cu=[0,1,3] -> [[0,2],[3,1]]`, including padding indices `>= token_count`, single-subsequence identity, uniform windows). - Full non-integration suite green (1318 passed); existing `qwen2_5_vl` / `qwen3_vl` graph tests and packed-attention rewrite tests unaffected. --------- Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com> Co-authored-by: Justin Chu <11205048+justinchuby@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 92d0538 commit af2a28c

7 files changed

Lines changed: 292 additions & 81 deletions

File tree

src/mobius/components/_common.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,79 @@ def create_attention_bias(
264264
return op.Unsqueeze(attention_bias, [1])
265265

266266

267+
def build_packed_token_offset(op: OpBuilder, cu_seqlens) -> ir.Value:
268+
"""Build ``token_offset`` for ``com.microsoft::PackedMultiHeadAttention``.
269+
270+
ORT's ``PackedMultiHeadAttention`` treats the packed
271+
``(token_count, hidden)`` query/key/value as ``batch_size``
272+
variable-length sub-sequences (delimited by ``cu_seqlens``) with
273+
right-padding removed, and computes block-diagonal attention *within*
274+
each sub-sequence. The kernel derives ``batch_size`` from
275+
``token_offset.shape[0]`` and requires ``cumulative_sequence_length``
276+
(i.e. ``cu_seqlens``) to have length ``batch_size + 1``.
277+
278+
``token_offset`` has shape ``(batch_size, max_seq_len)`` and follows
279+
ORT's ``GetPaddingOffset`` convention: the first ``token_count`` entries
280+
are the padded-layout indices (``b * max_seq_len + s``) of the valid
281+
tokens in packed (sub-sequence-major) order, followed by the
282+
padded-layout indices of the padding slots.
283+
284+
Example: ``cu_seqlens = [0, 1, 3]`` (two sub-sequences of length 1 and
285+
2, so ``max_seq_len = 2``) yields ``[[0, 2], [3, 1]]``.
286+
287+
Args:
288+
op: The OpBuilder.
289+
cu_seqlens: ``(batch_size + 1,)`` cumulative sequence lengths
290+
(INT32 or INT64).
291+
292+
Returns:
293+
``(batch_size, max_seq_len)`` INT32 ``token_offset`` tensor.
294+
"""
295+
# Index/axis tensors are materialised as explicit Constant nodes (rather
296+
# than relying on Python-list auto-conversion) so this helper works under
297+
# both the component OpBuilder and the onnxscript rewriter op context.
298+
axes_0 = op.Constant(value_ints=[0])
299+
axes_1 = op.Constant(value_ints=[1])
300+
neg_one = op.Constant(value_ints=[-1])
301+
start_1 = op.Constant(value_ints=[1])
302+
int_max = op.Constant(value_ints=[INT64_MAX])
303+
304+
cu_seqlens_i32 = op.Cast(cu_seqlens, to=ir.DataType.INT32)
305+
306+
# batch_size = len(cu_seqlens) - 1 (number of packed sub-sequences).
307+
batch_size = op.Cast(
308+
op.Sub(op.Size(cu_seqlens), op.Constant(value_int=1)),
309+
to=ir.DataType.INT32,
310+
)
311+
312+
# Per-sub-sequence lengths and the padded sequence dimension.
313+
starts = op.Slice(cu_seqlens_i32, axes_0, neg_one, axes_0) # cu[:-1]
314+
ends = op.Slice(cu_seqlens_i32, start_1, int_max, axes_0) # cu[1:]
315+
lengths = op.Sub(ends, starts) # (batch_size,)
316+
max_len = op.Squeeze(op.ReduceMax(lengths), axes_0) # scalar INT32
317+
318+
# Padded position grid: pos[b, s] = b * max_len + s.
319+
zero_i32 = op.Cast(op.Constant(value_int=0), to=ir.DataType.INT32)
320+
one_i32 = op.Cast(op.Constant(value_int=1), to=ir.DataType.INT32)
321+
rows = op.Range(zero_i32, batch_size, one_i32) # (batch_size,)
322+
cols = op.Range(zero_i32, max_len, one_i32) # (max_len,)
323+
pos_matrix = op.Add(
324+
op.Mul(op.Unsqueeze(rows, axes_1), max_len),
325+
op.Unsqueeze(cols, axes_0),
326+
) # (batch_size, max_len) INT32
327+
pos_matrix_shape = op.Shape(pos_matrix)
328+
329+
# Column s is a valid token for row b iff s < lengths[b].
330+
valid_mask = op.Less(op.Unsqueeze(cols, axes_0), op.Unsqueeze(lengths, axes_1))
331+
valid_mask_1d = op.Reshape(valid_mask, neg_one)
332+
pos_1d = op.Reshape(pos_matrix, neg_one)
333+
334+
# Valid positions first (packed order), then padding-slot positions.
335+
valid_indices = op.Compress(pos_1d, valid_mask_1d)
336+
padding_indices = op.Compress(pos_1d, op.Not(valid_mask_1d))
337+
return op.Reshape(op.Concat(valid_indices, padding_indices, axis=0), pos_matrix_shape)
338+
339+
267340
def create_padding_mask(
268341
op: OpBuilder,
269342
input_ids,

src/mobius/components/_common_test.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from mobius.components._common import (
1414
Embedding,
1515
Linear,
16+
build_packed_token_offset,
1617
create_attention_bias,
1718
create_padding_mask,
1819
create_sliding_window_mask,
@@ -214,6 +215,65 @@ def test_decode_single_query_is_causal(self):
214215
assert bool((out[0, 0, 0] > -1.0).all())
215216

216217

218+
class TestBuildPackedTokenOffset:
219+
"""``build_packed_token_offset`` must reproduce ORT's GetPaddingOffset.
220+
221+
ORT's ``PackedMultiHeadAttention`` derives ``batch_size`` from
222+
``token_offset.shape[0]`` and requires ``cumulative_sequence_length`` to
223+
have length ``batch_size + 1``. ``token_offset`` lists the padded-layout
224+
indices (``b * max_len + s``) of valid tokens (packed order) first, then
225+
the padding slots. We run the helper through ORT and compare exact values.
226+
"""
227+
228+
@staticmethod
229+
def _build(dtype=ir.DataType.INT64):
230+
b, op, g = create_test_builder()
231+
cu = create_test_input(b, "cu_seqlens", ["K"], dtype=dtype)
232+
token_offset = build_packed_token_offset(op, cu)
233+
token_offset.name = "token_offset"
234+
g.outputs.append(token_offset)
235+
return ir.Model(g, ir_version=10)
236+
237+
def _run(self, cu_seqlens, np_dtype=np.int64, ir_dtype=ir.DataType.INT64):
238+
sess = OnnxModelSession(self._build(ir_dtype), device="cpu")
239+
return sess.run({"cu_seqlens": np.array(cu_seqlens, dtype=np_dtype)})["token_offset"]
240+
241+
def test_ort_reference_example(self):
242+
# ORT test data: cu=[0,1,3] (lengths 1,2; max_len=2) -> [[0,2],[3,1]].
243+
out = self._run([0, 1, 3])
244+
assert out.dtype == np.int32
245+
assert np.array_equal(out, np.array([[0, 2], [3, 1]], dtype=np.int32))
246+
247+
def test_padding_indices_exceed_token_count(self):
248+
# cu=[0,2,5]: lengths [2,3], max_len=3, token_count=5.
249+
# Padded grid pos = b*3 + s -> row0 valid cols {0,1} pad col {2};
250+
# row1 valid cols {3,4,5}. valid (packed order) = [0,1,3,4,5];
251+
# padding slot = [2]. token_offset = [[0,1,3],[4,5,2]].
252+
out = self._run([0, 2, 5])
253+
assert np.array_equal(out, np.array([[0, 1, 3], [4, 5, 2]], dtype=np.int32))
254+
# Padding value (2) is a padded-layout index, here < token_count, but
255+
# the construction may yield values >= token_count for other shapes.
256+
257+
def test_single_subsequence_is_identity(self):
258+
# cu=[0,4]: one sub-sequence -> shape (1,4), identity [0,1,2,3].
259+
out = self._run([0, 4])
260+
assert out.shape == (1, 4)
261+
assert np.array_equal(out, np.array([[0, 1, 2, 3]], dtype=np.int32))
262+
263+
def test_uniform_windows(self):
264+
# Three windows of equal length 2: max_len=2, no padding.
265+
out = self._run([0, 2, 4, 6])
266+
assert out.shape == (3, 2)
267+
assert np.array_equal(out, np.array([[0, 1], [2, 3], [4, 5]], dtype=np.int32))
268+
269+
def test_int32_input(self):
270+
# The helper documents INT32 or INT64 cu_seqlens; INT32 input must
271+
# produce the same result as the INT64 reference example.
272+
out = self._run([0, 1, 3], np_dtype=np.int32, ir_dtype=ir.DataType.INT32)
273+
assert out.dtype == np.int32
274+
assert np.array_equal(out, np.array([[0, 2], [3, 1]], dtype=np.int32))
275+
276+
217277
class TestCreatePaddingMask:
218278
def test_creates_bool_mask_with_2d_input_ids(self):
219279
"""Standard path: input_ids is 2D [batch, q_len]."""

src/mobius/components/_qwen25_vl_vision.py

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@
2626
import onnx_ir as ir
2727
from onnxscript import OpBuilder, nn
2828

29-
from mobius._build_context import ep_capabilities
30-
from mobius.components._common import LayerNorm, Linear
29+
from mobius._build_context import ep_capabilities, get_build_dtype
30+
from mobius.components._common import LayerNorm, Linear, build_packed_token_offset
3131
from mobius.components._mlp import FCMLP, GatedMLP
3232
from mobius.components._rms_norm import RMSNorm
3333
from mobius.components._scan_utils import (
@@ -189,10 +189,13 @@ def _emit_packed_mha(self, op, q, k, v, cu_seqlens, seq_len_val):
189189
"""Emit com.microsoft.PackedMultiHeadAttention.
190190
191191
Uses cu_seqlens natively, avoiding the O(N^2) block-diagonal bias.
192+
The block-diagonal masking is expressed through the packed varlen
193+
contract: each sub-sequence delimited by ``cu_seqlens`` (a window or
194+
a frame) is one batch element, and attention is computed within it.
192195
193196
Args:
194197
q, k, v: (N, num_heads, head_dim) after rotary embedding
195-
cu_seqlens: (num_sub_seqs + 1,) INT32
198+
cu_seqlens: (num_sub_seqs + 1,) cumulative sequence lengths
196199
seq_len_val: (1,) shape tensor with N
197200
"""
198201
hidden_size = self.num_heads * self.head_dim
@@ -201,25 +204,25 @@ def _emit_packed_mha(self, op, q, k, v, cu_seqlens, seq_len_val):
201204
key = op.Reshape(k, op.Concat(seq_len_val, [hidden_size], axis=0))
202205
value = op.Reshape(v, op.Concat(seq_len_val, [hidden_size], axis=0))
203206

204-
# token_offset: identity mapping for packed (no-padding) input.
205-
# Shape: (1, token_count) — single batch, positions [0..N-1].
206-
token_count_scalar = op.Squeeze(seq_len_val, [0])
207-
token_offset = op.Unsqueeze(
208-
op.Range(
209-
op.Constant(value_int=0),
210-
token_count_scalar,
211-
op.Constant(value_int=1),
212-
),
213-
[0],
214-
)
215-
token_offset = op.Cast(token_offset, to=6) # INT32
207+
# token_offset: (num_sub_seqs, max_seq_len) mapping the packed tokens to
208+
# their padded (batch, seq) layout. ORT derives batch_size from
209+
# token_offset.shape[0] and requires cumulative_sequence_length to have
210+
# length batch_size + 1, so this MUST encode every sub-sequence (window
211+
# or frame), not a single (1, N) batch — otherwise the kernel rejects
212+
# windowed cu_seqlens and computes full instead of block-diagonal
213+
# attention.
214+
token_offset = build_packed_token_offset(op, cu_seqlens)
216215

217216
cu_seqlens_int32 = op.Cast(cu_seqlens, to=6) # INT32
218217

219-
# PackedMHA doesn't support bfloat16; cast to float16 if needed
220-
query_mha = op.Cast(query, to=10) # FLOAT16
221-
key_mha = op.Cast(key, to=10)
222-
value_mha = op.Cast(value, to=10)
218+
# PackedMHA supports float32 and float16 only; cast bfloat16 builds to
219+
# float16 and leave float32/float16 native to preserve precision.
220+
if get_build_dtype() == ir.DataType.BFLOAT16:
221+
query_mha = op.Cast(query, to=ir.DataType.FLOAT16)
222+
key_mha = op.Cast(key, to=ir.DataType.FLOAT16)
223+
value_mha = op.Cast(value, to=ir.DataType.FLOAT16)
224+
else:
225+
query_mha, key_mha, value_mha = query, key, value
223226

224227
# Emit PackedMultiHeadAttention
225228
attn_out = op.PackedMultiHeadAttention(
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Graph-construction tests for the Qwen2.5-VL vision encoder packed path.
5+
6+
These exercise the EP-gated ``_emit_packed_mha`` branch (only taken when the
7+
active EP advertises ``supports_packed_multi_head_attention``, e.g. CUDA),
8+
which the default CPU build does not reach.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import onnx_ir as ir
14+
15+
from mobius._build_context import build_context
16+
from mobius._execution_providers import ep_registry
17+
from mobius._testing import count_op_type, create_test_builder, create_test_input
18+
from mobius.components._qwen25_vl_vision import Qwen25VLVisionModel
19+
20+
_PATCH_DIM = 3 * 2 * 14 * 14 # in_channels * temporal_patch * patch * patch
21+
22+
23+
def _build_vision_graph(dtype: ir.DataType) -> ir.Graph:
24+
"""Build the Qwen2.5-VL vision encoder graph under the CUDA EP.
25+
26+
``fullatt_block_indexes=[1]`` makes block 0 windowed and block 1 full, so
27+
both attention variants flow through the packed path.
28+
"""
29+
module = Qwen25VLVisionModel(
30+
depth=2,
31+
hidden_size=64,
32+
intermediate_size=128,
33+
num_heads=2,
34+
patch_size=14,
35+
temporal_patch_size=2,
36+
in_channels=3,
37+
out_hidden_size=64,
38+
spatial_merge_size=2,
39+
fullatt_block_indexes=[1],
40+
window_size=112,
41+
)
42+
builder, op, graph = create_test_builder()
43+
pixel_values = create_test_input(builder, "pixel_values", ["N", _PATCH_DIM], dtype=dtype)
44+
grid = create_test_input(
45+
builder, "image_grid_thw", ["num_images", 3], dtype=ir.DataType.INT64
46+
)
47+
with build_context(ep_registry.require("cuda"), dtype):
48+
out = module(op, pixel_values, grid)
49+
out.name = "image_features"
50+
graph.outputs.append(out)
51+
return graph
52+
53+
54+
def _count_cast_to(graph: ir.Graph, target: ir.DataType) -> int:
55+
count = 0
56+
for node in graph:
57+
if node.op_type == "Cast" and int(node.attributes["to"].value) == int(target):
58+
count += 1
59+
return count
60+
61+
62+
class TestQwen25VLVisionPackedPath:
63+
def test_cuda_emits_packed_mha_with_helper_token_offset(self):
64+
graph = _build_vision_graph(ir.DataType.FLOAT)
65+
# One PackedMultiHeadAttention per transformer block, no standard
66+
# Attention fallback on CUDA.
67+
assert count_op_type(graph, "PackedMultiHeadAttention") == 2
68+
assert count_op_type(graph, "Attention") == 0
69+
# token_offset comes from build_packed_token_offset (Compress-based
70+
# valid/padding split), not the old (1, N) identity Range.
71+
assert count_op_type(graph, "Compress") >= 4 # 2 blocks x (valid + padding)
72+
73+
def test_float32_build_keeps_native_dtype(self):
74+
# f32 is supported natively by the kernel: no down-cast to float16.
75+
graph = _build_vision_graph(ir.DataType.FLOAT)
76+
assert _count_cast_to(graph, ir.DataType.FLOAT16) == 0
77+
78+
def test_bfloat16_build_casts_qkv_to_float16(self):
79+
# bf16 is unsupported by the kernel, so q/k/v are cast to float16:
80+
# 3 casts (q, k, v) per block x 2 blocks.
81+
graph = _build_vision_graph(ir.DataType.BFLOAT16)
82+
assert count_op_type(graph, "PackedMultiHeadAttention") == 2
83+
assert _count_cast_to(graph, ir.DataType.FLOAT16) == 6

src/mobius/components/_qwen3_vl_vision.py

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@
2525
import onnx_ir as ir
2626
from onnxscript import OpBuilder, nn
2727

28-
from mobius._build_context import ep_capabilities
29-
from mobius.components._common import LayerNorm, Linear
28+
from mobius._build_context import ep_capabilities, get_build_dtype
29+
from mobius.components._common import LayerNorm, Linear, build_packed_token_offset
3030
from mobius.components._mlp import FCMLP
3131
from mobius.components._scan_utils import (
3232
compact_scan_output,
@@ -219,34 +219,35 @@ def _emit_packed_mha(self, op, query, key, value, cu_seqlens, hidden_states):
219219
"""Emit com.microsoft.PackedMultiHeadAttention.
220220
221221
Uses cu_seqlens natively, avoiding the O(N^2) block-diagonal bias.
222-
PackedMHA supports float32/float16 only; bf16 inputs are cast to f16.
222+
Each sub-sequence delimited by ``cu_seqlens`` (one per image/frame)
223+
is one packed batch element, so block-diagonal masking is expressed
224+
through the varlen contract rather than an explicit bias.
223225
224226
Args:
225227
query, key: (total_seq, hidden_size) after rotary embedding
226-
value: (total_seq, 3 * hidden_size) — full QKV output, need V only
227-
cu_seqlens: (num_sub_seqs + 1,) INT32/INT64
228+
value: (total_seq, hidden_size) from QKV split
229+
cu_seqlens: (num_sub_seqs + 1,) cumulative sequence lengths
228230
hidden_states: original input, used only for shape
229231
"""
230-
total_seq = op.Shape(hidden_states, start=0, end=1)
231-
total_seq_scalar = op.Squeeze(total_seq)
232-
233-
# token_offset: identity mapping for packed (no-padding) input.
234-
token_offset = op.Unsqueeze(
235-
op.Range(
236-
op.Constant(value_int=0),
237-
total_seq_scalar,
238-
op.Constant(value_int=1),
239-
),
240-
[0],
241-
)
242-
token_offset = op.Cast(token_offset, to=6) # INT32
232+
# token_offset: (num_sub_seqs, max_seq_len) mapping packed tokens to
233+
# their padded (batch, seq) layout. ORT derives batch_size from
234+
# token_offset.shape[0] and requires cumulative_sequence_length to have
235+
# length batch_size + 1, so this MUST encode every sub-sequence (image
236+
# or frame), not a single (1, N) batch — otherwise the kernel rejects
237+
# multi-sequence cu_seqlens and computes full instead of block-diagonal
238+
# attention.
239+
token_offset = build_packed_token_offset(op, cu_seqlens)
243240

244241
cu_seqlens_int32 = op.Cast(cu_seqlens, to=6) # INT32
245242

246-
# PackedMHA doesn't support bfloat16; cast to float16 if needed
247-
query_mha = op.Cast(query, to=10) # FLOAT16
248-
key_mha = op.Cast(key, to=10)
249-
value_mha = op.Cast(value, to=10)
243+
# PackedMHA supports float32 and float16 only; cast bfloat16 builds to
244+
# float16 and leave float32/float16 native to preserve precision.
245+
if get_build_dtype() == ir.DataType.BFLOAT16:
246+
query_mha = op.Cast(query, to=ir.DataType.FLOAT16)
247+
key_mha = op.Cast(key, to=ir.DataType.FLOAT16)
248+
value_mha = op.Cast(value, to=ir.DataType.FLOAT16)
249+
else:
250+
query_mha, key_mha, value_mha = query, key, value
250251

251252
attn_output = op.PackedMultiHeadAttention(
252253
query_mha,

0 commit comments

Comments
 (0)