You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Qwen Image 2.1 transformer is incompatible with torch.compile #14821
QwenImage21Rope features a call to image_pad_mask.tolist(). When the transformer in the Qwen Image 2.1 pipeline is torch.compile-ed and then run, it causes the following error:
Unsupported: Tensor.tolist() with non-integer tensor
Explanation: Dynamo currently does not support tracing `tolist()` on non-integer tensors.
Hint: Ensure the input tensor to `tolist()` is an integer type (e.g., int8, int16, int32, int64).
Developer debug context: call_method TensorVariable() to_list
For more details about this graph break, please visit: https://meta-pytorch.github.io/compile-graph-break-site/gb/gb0109.html
from user code:
File "[redacted]\.venv\Lib\site-packages\diffusers\models\transformers\transformer_qwenimage21.py", line 925, in forward
rotary_emb = self.pos_embed(img_shapes[0], image_pad_mask, device=hidden_states.device)
File "[redacted]\.venv\Lib\site-packages\diffusers\models\transformers\transformer_qwenimage21.py", line 686, in forward
is_image_token = image_pad_mask.tolist()
Reproduction
importtorchfromdiffusersimportQwenImage21Pipelinepipe=QwenImage21Pipeline.from_pretrained(
"Qwen/Qwen-Image-2.1",
torch_dtype=torch.bfloat16,
).to("cuda")
# Some of this might be unrelatedtorch._inductor.config.conv_1x1_as_mm=Truetorch._inductor.config.coordinate_descent_tuning=Truetorch._inductor.config.epilogue_fusion=Falsetorch._inductor.config.coordinate_descent_check_all_directions=Truetorch._dynamo.config.capture_scalar_outputs=Truepipe.transformer.compile(
mode="max-autotune", fullgraph=True,
)
image=pipe(
prompt="The Mona Lisa or whatever.",
width=2048, height=2048,
num_inference_steps=40,
generator=torch.Generator("cuda").manual_seed(42),
).images[0]
System Info
🤗 Diffusers version: 0.41.0.dev0
Platform: Windows-11-10.0.26200-SP0
Running on Google Colab?: No
Python version: 3.14.2
PyTorch version (GPU?): 2.14.0+cu130 (True)
Huggingface_hub version: 1.32.0
Transformers version: 5.17.0
Accelerate version: 1.15.0
PEFT version: not installed
Safetensors version: 0.8.0
xFormers version: not installed
Accelerator: NVIDIA GeForce RTX 5090, 32607 MiB
Using GPU in script?: yes
Using distributed or parallel set-up in script?: no
Hi @abel1502, thanks for the report and the repro.
I agree with the diagnosis. pipe.transformer.compile(..., fullgraph=True) traces QwenImage21Transformer2DModel.forward, which calls QwenImage21Rope.forward, and that does is_image_token = image_pad_mask.tolist() (transformer_qwenimage21.py around line 686). The traceback matches the source on main. The inductor flags in the repro are unrelated.
Root cause:
image_pad_mask is bool. It is torch.repeat_interleave(img_mask[0], repeats), and the pipeline builds img_mask as a bool tensor. Dynamo only traces Tensor.tolist() for int8, int16, int32, and int64 (gb0109), so a bool mask fails before the rest of the forward is traced. fullgraph=True turns that into a hard error. This is QwenImage21Rope, which runs for both attention processors.
Casting the mask with .to(torch.int32).tolist() only satisfies the dtype check. Integer tolist() is lowered to one .item() per token, and the Python list.index / range walk then branches on those values. That is still a host sync and still not one graph. This file already treats tolist() as a device sync in _qwenimage21_prefix_segments.
The same forward has three more host-dependent spots, so fixing RoPE alone still leaves this repro unable to compile:
build_token_metadata uses nonzero, so the index vector length depends on mask values.
use_kv_cache defaults to True, so step 0 is "extract" and later steps are "cached". prefix_len = int((~target_token_mask).sum()) syncs a scalar on every step and then slices with it.
On the default QwenImage21AttnProcessor, _qwenimage21_prefix_segments calls tolist() on the whole prefix.
compile_repeated_blocks() does not cover this. RoPE runs in the parent forward, and _repeated_blocks is only QwenImage21TransformerBlock.
Proposed approach:
img_shapes already gives each block's (frame, height, width) as Python ints. The mask only says where those blocks sit between text tokens. The current walk is:
text tokens get frame = height = width, advancing by 1
an image block shares one frame id and a zero-centered height/width grid
after the block, the position advances by max(height, width)
Build that with static-shape tensor ops. Let mask be 0/1 and seq_len = mask.shape[0]:
img_before = cumsum(mask) - mask
text_before = cumsum(1 - mask) - (1 - mask)
for each block, once seen image tokens have been consumed, add max(height, width) wherever img_before >= seen
frame_index = text_before + that advance
Checked against the current Python walk for text/image/text, adjacent images, and a trailing text tail. For example, mask F F T T T T F with one 2x2 block produces frame ids [0, 1, 2, 2, 2, 2, 4].
The centered grid does not need the mask. Per block:
Repeat height across width and width down height, then concatenate in block order. masked_scatter writes that static vector onto the True positions. height_index and width_index start as clones of frame_index, so text tokens keep the frame id on all three axes. torch._check(mask.sum() == image_token_count) keeps the scatter length static for Dynamo. Eager keeps today's ValueError when img_shapes and the mask disagree.
The other three spots, in the same change:
The image-token count is already sum(math.prod(shape) for shape in img_shapes). Build image_ids and target_token_mask with masked_scatter of a static id vector, and use the same torch._check instead of nonzero.
The pipeline appends the target as a suffix, so prefix_len = seq_len - math.prod(img_shapes[0][-1]) matches int((~target_token_mask).sum()) with no .item().
Prefix segment count is static: one text gap and one image block per condition image, then the text before the target. Block starts come from argmax on the image cumsum. The SDPA loop stays a Python loop over that static count, with 0-dim tensor slice bounds. A zero-length text gap (two image blocks with no text between them, which build_token_metadata already calls out) is an empty slice, and torch.cat drops it.
QwenImage21Rope also rebinds self.freqs with .to(device) on every forward. Those tables are fixed at init and are not buffers, so they stay unregistered. QwenEmbedRope._get_device_freqs already caches this transfer behind lru_cache_unless_export. I would use that here so the first call moves them and later calls do not.
Tests:
On the tiny config already in tests/models/transformers/test_models_transformer_qwenimage21.py:
Eager RoPE matches the current Python implementation for text/image/text, adjacent images, odd and even spatial sizes, and a target-only sequence.
torch.compile(model, fullgraph=True) matches eager, then a second call with error_on_recompile=True.
Same for QwenImage21AttnProcessor and QwenImage21FlexAttnProcessor.
KV cache "extract" then "cached" under fullgraph=True.
Add TorchCompileTesterMixin to this model. The Qwen-Image 1.x transformer tests already have it. This 2.1 file does not, which is consistent with compile being broken.
I would be glad to open a PR for this once the approach looks right.
Activity
chinmayirm commentedon Sep 22, 2026
Hi @abel1502, thanks for the report and the repro.
I agree with the diagnosis.
pipe.transformer.compile(..., fullgraph=True)tracesQwenImage21Transformer2DModel.forward, which callsQwenImage21Rope.forward, and that doesis_image_token = image_pad_mask.tolist()(transformer_qwenimage21.pyaround line 686). The traceback matches the source onmain. The inductor flags in the repro are unrelated.Root cause:
image_pad_maskis bool. It istorch.repeat_interleave(img_mask[0], repeats), and the pipeline buildsimg_maskas a bool tensor. Dynamo only tracesTensor.tolist()forint8,int16,int32, andint64(gb0109), so a bool mask fails before the rest of the forward is traced.fullgraph=Trueturns that into a hard error. This isQwenImage21Rope, which runs for both attention processors.Casting the mask with
.to(torch.int32).tolist()only satisfies the dtype check. Integertolist()is lowered to one.item()per token, and the Pythonlist.index/rangewalk then branches on those values. That is still a host sync and still not one graph. This file already treatstolist()as a device sync in_qwenimage21_prefix_segments.The same forward has three more host-dependent spots, so fixing RoPE alone still leaves this repro unable to compile:
build_token_metadatausesnonzero, so the index vector length depends on mask values.use_kv_cachedefaults toTrue, so step 0 is"extract"and later steps are"cached".prefix_len = int((~target_token_mask).sum())syncs a scalar on every step and then slices with it.QwenImage21AttnProcessor,_qwenimage21_prefix_segmentscallstolist()on the whole prefix.compile_repeated_blocks()does not cover this. RoPE runs in the parentforward, and_repeated_blocksis onlyQwenImage21TransformerBlock.Proposed approach:
img_shapesalready gives each block's(frame, height, width)as Python ints. The mask only says where those blocks sit between text tokens. The current walk is:frame = height = width, advancing by 1max(height, width)Build that with static-shape tensor ops. Let
maskbe 0/1 andseq_len = mask.shape[0]:img_before = cumsum(mask) - masktext_before = cumsum(1 - mask) - (1 - mask)seenimage tokens have been consumed, addmax(height, width)whereverimg_before >= seenframe_index = text_before + that advanceChecked against the current Python walk for text/image/text, adjacent images, and a trailing text tail. For example, mask
F F T T T T Fwith one2x2block produces frame ids[0, 1, 2, 2, 2, 2, 4].The centered grid does not need the mask. Per block:
Repeat height across width and width down height, then concatenate in block order.
masked_scatterwrites that static vector onto theTruepositions.height_indexandwidth_indexstart as clones offrame_index, so text tokens keep the frame id on all three axes.torch._check(mask.sum() == image_token_count)keeps the scatter length static for Dynamo. Eager keeps today'sValueErrorwhenimg_shapesand the mask disagree.The other three spots, in the same change:
sum(math.prod(shape) for shape in img_shapes). Buildimage_idsandtarget_token_maskwithmasked_scatterof a static id vector, and use the sametorch._checkinstead ofnonzero.prefix_len = seq_len - math.prod(img_shapes[0][-1])matchesint((~target_token_mask).sum())with no.item().argmaxon the image cumsum. The SDPA loop stays a Python loop over that static count, with 0-dim tensor slice bounds. A zero-length text gap (two image blocks with no text between them, whichbuild_token_metadataalready calls out) is an empty slice, andtorch.catdrops it.QwenImage21Ropealso rebindsself.freqswith.to(device)on every forward. Those tables are fixed at init and are not buffers, so they stay unregistered.QwenEmbedRope._get_device_freqsalready caches this transfer behindlru_cache_unless_export. I would use that here so the first call moves them and later calls do not.Tests:
On the tiny config already in
tests/models/transformers/test_models_transformer_qwenimage21.py:torch.compile(model, fullgraph=True)matches eager, then a second call witherror_on_recompile=True.QwenImage21AttnProcessorandQwenImage21FlexAttnProcessor."extract"then"cached"underfullgraph=True.TorchCompileTesterMixinto this model. The Qwen-Image 1.x transformer tests already have it. This 2.1 file does not, which is consistent with compile being broken.I would be glad to open a PR for this once the approach looks right.