Skip to content

feat(examples): Qwen-Image 20B MMDiT text-to-image on Trainium - #85

Open
ymwangg wants to merge 3 commits into
mainfrom
feat/qwen-image-mmdit
Open

feat(examples): Qwen-Image 20B MMDiT text-to-image on Trainium#85
ymwangg wants to merge 3 commits into
mainfrom
feat/qwen-image-mmdit

Conversation

@ymwangg

@ymwangg ymwangg commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a device-only, TP-mandatory Qwen-Image 20B MMDiT text-to-image pipeline to examples/models/qwen_image/. The full pipeline runs on trn2 (TP=4):

  • Denoiser — 60-block dual-stream MMDiT (3D RoPE, QK-RMSNorm, per-stream modulation) with fused on-device CFG + FlowMatchEuler sampling; the packed latent stays resident across all steps and the host feeds only per-step scalars.
  • Text encoder — prefill Qwen2.5-VL decoder LM (28 layers, GQA 28/4, SwiGLU), reusing the qwen3 block kernels, Megatron-sharded. Device rel_l2 6.7e-3 bf16.
  • VAE decoder — fp32 one-shot; at T=1 the WAN-style 3D causal video VAE collapses exactly to a 2D conv decoder. Device rel_l2 4.4e-6 at 64×64 → 512×512.
  • Host keeps only trivial glue (tokenizer/chat template, embedding lookup, latent pack/denorm, scalar flow-match schedule).

Produces correct 512px images. End-to-end demo.sh reuses the compile cache, runs CPU correctness tests, then generates via torchrun.

Sample output

512px, TP=4, 50 steps, guidance 4.0 — prompt: "a coffee shop entrance with a chalkboard sign".

Qwen-Image sample output

Notable details

  • RoPE perf fix: interleave-scatter rewrite drops the denoiser from 14.1s → 0.30s/step. Qwen uses the interleaved (2i, 2i+1) pair convention, not qwen3's (i, i+half) split.
  • VAE denorm: the pipeline stores latents_std as its reciprocal (latents / std_recip); dividing by raw std instead is off by ~std².
  • 1024px limit: the 60-block denoiser unrolls into one HLO graph; at grid 64×64 the serialized proto exceeds protobuf's 2 GB limit. Works today at ≤512px; durable fix is a device-side scan over one compiled block.

Startup & serving perf

A cold launch was dominated by host-side weight prep (~350s extract/shard), not compile or generation. Three additions cut that:

  • Shard cache (--weight-cache-dir, default on): extract+shard once, then reload per-rank safetensors shards on later launches (bf16 = what uploads; fp32 for the VAE). ~350s → memory-mapped read; peak RAM ~200GB → ~45GB. --no-weight-cache disables.
  • Resident mode (--prompts-file / --interactive): keep the process and all TP ranks alive, generating one image per prompt so the weight upload (~25s) + NEFF load (~17s) that no disk cache can remove are paid once — additional images then cost only generate() (~2.8s at 8 steps).
  • Text-length bucketing (--text-bucket, default 64): round the encoder seq and denoiser text length up to a bucket so varying-length prompts reuse one compiled kernel per bucket instead of recompiling per exact length. Exact (causal encoder + masked pad text tokens); --text-bucket 0 restores per-exact-length, bitwise-stable behavior.

Test plan

  • CPU correctness tests: uv run pytest examples/models/qwen_image/tests/
  • On-device TP check (opt-in, needs hardware): QWEN_IMAGE_TP_DEVICE_TEST=1 uv run pytest tests/test_tp_device.py
  • Validated end-to-end on trn2 at TP=4, 512px.

See examples/models/qwen_image/README.md for full run instructions and the durable lessons/limits.

Add a device-only, TP-mandatory Qwen-Image text-to-image pipeline
(MMDiT denoiser + Qwen2.5 text-encoder + VAE decoder), with fused
on-device CFG + FlowMatchEuler sampling and an end-to-end demo.sh.

Includes the RoPE interleave-scatter perf fix (denoiser 14.1s ->
0.30s/step), 512px default, in-memory weights, and device-wrapper
refactor onto a _DeviceModule base.
@ymwangg
ymwangg requested a review from a team September 4, 2026 19:24
Cut Qwen-Image launch and per-image cost. Cold launch was dominated by
host-side weight prep (~350s extract/shard), not compile or generation.

- Shard cache (--weight-cache-dir, default ./weight_cache): extract+shard
  once, then reload per-rank safetensors shards on later launches (bf16 for
  denoiser/text-encoder = what uploads, fp32 for VAE). ~350s -> mmap read,
  peak RAM ~200GB -> ~45GB. --no-weight-cache disables. Default
  _select_weight_keys now sorts, so the safetensors key re-sort can't change
  the NEFF's HLO parameter order (avoids a spurious VAE recompile on the
  first cached run).

- Resident mode (--prompts-file / --interactive): keep the process and all TP
  ranks alive, generating one image per prompt so the weight upload (~25s) +
  NEFF load (~17s) that no disk cache can remove are paid once. Rank 0
  broadcasts (prompt, seed) so ranks run generate() in lockstep. Additional
  images then cost only generate() exec (~2.8s at 8 steps).

- Text-length bucketing (--text-bucket, default 64): round the encoder seq and
  denoiser txt length up to a bucket so varying prompts reuse one compiled
  kernel per bucket instead of recompiling per exact length. Exact: the
  encoder is causal (real tokens never attend to the right-pad tail, sliced
  off after) and the denoiser masks the pad text tokens; only bf16 GEMM
  reduction order shifts with the kernel shape. --text-bucket 0 restores
  per-exact-length (bitwise-stable) behavior.

CPU tests pass; validated end-to-end on trn2 (TP=4, 512px).
512px/TP4/50-step generation for the coffee-shop prompt, shown in the README
and used as the PR's sample image.
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