Skip to content

Commit cdf9ba2

Browse files
committed
[fix]: Z-Image reuses the shared Qwen3 encoder; fix its batched RoPE positions
main gained a config-driven Qwen3 text encoder (Qwen3ForCausalLM + Qwen3TextConfig) via the Flux2 Klein port (hao-ai-lab#1349). Reuse it for Z-Image instead of the parallel ~540-line bespoke Qwen3Model this PR originally added: drop the duplicate encoder + config, and map Z-Image-Turbo's "Qwen3Model" architecture string to the shared encoder in the registry. update_model_arch populates Z-Image's dims from config.json. Validating the reuse on L40S surfaced a real, batch-only divergence in the shared encoder: it built position_ids as [1, seq_len], but the rotary layer flattens positions to num_tokens and reshapes q/k to (num_tokens, -1, head_dim). For batch>1 that folded the batch dim into the head dim and misaligned RoPE (fp32 mean_diff 0.22 at batch=2; batch=1 was fine). Fix: expand position_ids to [batch_size, seq_len]. batch=1 is byte-identical, so Flux2 Klein is unaffected; this also fixes a latent batch bug in the shared encoder. Encoder parity now PASSES on L40S (Z-Image-Turbo): fp32 bit-exact against the shared encoder (both batch elements, max=0.0000); bf16 within the existing thresholds (last_hidden mean ~0.016, pre-norm mean ~0.07-0.08). Also in this PR (unchanged): the gated scheduler option use_reference_discrete_timesteps (default False) for Z-Image timestep parity, the Z-Image component parity tests (encoder/scheduler/tokenizer/VAE), and the encoder parity test's strict-load allowlist + OOM-safe two-model handling (free HF ref + gc.collect before loading FastVideo).
1 parent fa3b90b commit cdf9ba2

4 files changed

Lines changed: 100 additions & 43 deletions

File tree

fastvideo/models/encoders/qwen3.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,9 +368,15 @@ def forward(
368368
residual = None
369369

370370
if position_ids is None:
371+
# Expand to [batch_size, seq_len]: the rotary layer flattens
372+
# positions to ``num_tokens`` and reshapes q/k to
373+
# ``(num_tokens, -1, head_dim)``. A bare [1, seq_len] only matches
374+
# ``num_tokens`` when batch_size == 1; for batched inputs it folds
375+
# the batch dim into the head dim and misaligns RoPE. Expanding to
376+
# ``batch_size * seq_len`` tokens keeps the layout correct.
371377
position_ids = torch.arange(
372378
0, hidden_states.shape[1], device=hidden_states.device
373-
).unsqueeze(0)
379+
).unsqueeze(0).expand(hidden_states.shape[0], -1)
374380

375381
all_hidden_states: tuple[Any, ...] | None = (
376382
() if output_hidden_states else None

tests/local_tests/zimage/PORT_STATUS.md

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@
1414
- phase: Phase 4 (component parity)
1515
- status: in_progress
1616
- owner: parity
17-
- last_updated: 2026-05-12
17+
- last_updated: 2026-06-21
1818

1919
## Component Matrix
2020
| Component | Type | Reuse/Port | Official Definition | Official Instantiation | FastVideo Target | Prototype | Conversion | Parity | Open Issues |
2121
|---|---|---|---|---|---|---|---|---|---|
22-
| Text encoder (Qwen3) | text_encoder | ported | `zimage.Qwen3Model` (HF-Qwen3 layout) | `Qwen3ForCausalLM` checkpoint at `<weights>/text_encoder/` | `fastvideo/models/encoders/qwen3.py` + `fastvideo/configs/models/encoders/qwen3.py` | DONE | not_needed (raw safetensors load) | PASS (fp32 + bf16) | I001, I003 |
22+
| Text encoder (Qwen3) | text_encoder | reused | `zimage.Qwen3Model` (HF-Qwen3 layout) | `Qwen3ForCausalLM` checkpoint at `<weights>/text_encoder/` | shared `fastvideo/models/encoders/qwen3.py::Qwen3ForCausalLM` (+ `Qwen3TextConfig`), added for Flux2 Klein; Z-Image's `Qwen3Model` arch string routes to it in the registry | DONE | not_needed (raw safetensors load) | PASS (fp32 bit-exact + bf16, L40S 2026-06-21) | I001, I003 |
2323
| Tokenizer | tokenizer | reused | `AutoTokenizer` | `<weights>/tokenizer/` | `fastvideo/models/loader/component_loader.py::TokenizerLoader` | DONE | not_needed | PASS | none |
2424
| VAE | vae | reused | `zimage.AutoencoderKL` (Diffusers-compatible) | `<weights>/vae/` | `fastvideo/models/vaes/autoencoder_kl.py` | DONE | not_needed | PASS (decode only) | encode-path parity deferred to pipeline |
2525
| Scheduler | scheduler | reused (with extension) | `zimage.FlowMatchEulerDiscreteScheduler` | `<weights>/scheduler/scheduler_config.json` | `fastvideo/models/schedulers/scheduling_flow_match_euler_discrete.py` (added `use_reference_discrete_timesteps`) | DONE | not_needed | PASS | I002 |
@@ -30,7 +30,7 @@
3030
- conversion_script: `<none — encoder loads raw safetensors via tests/local_tests/zimage/test_zimage_encoder_parity.py helpers; VAE/tokenizer/scheduler use HF subfolder loaders>`
3131
- converted_weights_dir: `<none>`
3232
- source_layout: diffusers
33-
- strict_load_status: `pass_with_documented_exclusions` (Qwen3Model.ALLOWED_UNEXPECTED_KEYS = {"lm_head.weight"})
33+
- strict_load_status: `pass_with_documented_exclusions` (allowlist `{"lm_head.weight"}` asserted in the encoder parity test; the shared encoder is body-only and owns no LM head)
3434
- passthrough_components: VAE config, scheduler config, tokenizer assets
3535
- retry_history: `<none>`
3636

@@ -40,23 +40,23 @@
4040
| Scheduler | `pytest tests/local_tests/zimage/test_zimage_scheduler_parity.py -v -s` | PASS (2/2) on Z-Image-Turbo, A40, 2026-05-12 | full `scheduler_config.json` now forwarded (was 3 keys) |
4141
| Tokenizer | `pytest tests/local_tests/zimage/test_zimage_tokenizer_parity.py -v -s` | PASS (2/2) on Z-Image-Turbo, A40, 2026-05-12 | tokenizer resolves to `Qwen2TokenizerFast`; `apply_chat_template` parity included |
4242
| VAE decode | `pytest tests/local_tests/zimage/test_zimage_vae_parity.py -v -s` | PASS (1/1) on Z-Image-Turbo, A40, 2026-05-12 | encode-path deferred |
43-
| Text encoder fp32 | `pytest tests/local_tests/zimage/test_zimage_encoder_parity.py::test_zimage_qwen3_encoder_parity_forward[fp32]` | PASS on Z-Image-Turbo, A40, 2026-05-12 | bit-exact (`last_hidden_state` max=0.0000, `hidden_states[-2]` max=0.0012) across both batches — FastVideo Qwen3 port is numerically correct |
44-
| Text encoder bf16 | `pytest tests/local_tests/zimage/test_zimage_encoder_parity.py::test_zimage_qwen3_encoder_parity_forward[bf16]` | PASS on Z-Image-Turbo, A40, 2026-05-12 | empirical (worst across 2 batches): `last_hidden_state` mean=0.0168 median=0.0127 (thresholds 0.025 / 0.020, 1.5x headroom); `hidden_states[-2]` mean=0.0739 median=0.0625 (thresholds 0.120 / 0.100, 1.6x headroom). Per-layer diag confirms monotonic accumulation across 35 layers, no single-layer spike — textbook bf16-tail signature |
45-
| Per-layer bf16 diag | `pytest tests/local_tests/zimage/test_zimage_encoder_parity.py::test_zimage_qwen3_encoder_per_layer_bf16_diagnostic -v -s` | PASS (informational only) on Z-Image-Turbo, A40, 2026-05-12 | prints 37 hidden-state diffs (embedding + 35 layers + post-norm) for future debugging |
43+
| Text encoder fp32 | `pytest tests/local_tests/zimage/test_zimage_encoder_parity.py::test_zimage_qwen3_encoder_parity_forward[fp32]` | PASS on Z-Image-Turbo, L40S, 2026-06-21 | Bit-exact against the shared `Qwen3ForCausalLM` (both batch elements): `last_hidden_state` max=0.0000 mean=0.0000; `hidden_states[-2]` max=0.0010 mean=0.0000. Confirms the batched-RoPE `position_ids` fix. |
44+
| Text encoder bf16 | `pytest tests/local_tests/zimage/test_zimage_encoder_parity.py::test_zimage_qwen3_encoder_parity_forward[bf16]` | PASS on Z-Image-Turbo, L40S, 2026-06-21 | `last_hidden_state` mean=0.0163/0.0164 median=0.0127/0.0137 (thresholds 0.025 / 0.020); `hidden_states[-2]` mean=0.0804/0.0717 median=0.0625 (thresholds 0.120 / 0.100). In line with the prior bespoke profile — existing thresholds hold on L40S, no recalibration needed. |
45+
| Per-layer bf16 diag | `pytest tests/local_tests/zimage/test_zimage_encoder_parity.py::test_zimage_qwen3_encoder_per_layer_bf16_diagnostic -v -s` | PASS (informational) on Z-Image-Turbo, L40S, 2026-06-21 | 37 hidden states (embedding + 35 layers + post-norm); smooth monotonic drift, post-norm mean=0.0154. `hidden_states` length/indexing matches HF. |
4646

4747
## Open Questions
4848
| ID | Question | Owner | Needed By Phase | Status | Resolution |
4949
|---|---|---|---|---|---|
5050
| Q001 | Pin a Z-Image reference clone SHA in the README before handoff | prep | Phase 1 | resolved | Pinned `Tongyi-MAI/Z-Image@26f23eda626ffadda020b04ff79488e1d72004cd` (2026-05-12) |
5151
| Q002 | Final HF id for published Z-Image weights | prep | Phase 1 | resolved | `Tongyi-MAI/Z-Image-Turbo` (6B, 8 NFE, fits 16 GB) and `Tongyi-MAI/Z-Image` (full, 32.9 GB). Both Apache-2.0 |
52-
| Q003 | Does Z-Image use Qwen3 chat-template tokenization at pipeline runtime? Currently `Qwen3Config.is_chat_model=False` | pipeline | Phase 6 | open | |
52+
| Q003 | Does Z-Image use Qwen3 chat-template tokenization at pipeline runtime? The shared `Qwen3TextConfig` (from Flux2 Klein) defaults `is_chat_model=True`; Z-Image's removed bespoke config had `False`. Reconcile when wiring the pipeline preset. | pipeline | Phase 6 | open | |
5353

5454
## Issues And Blockers
5555
| ID | Phase | Component | Severity | Issue | Evidence | Owner | Status | Resolution |
5656
|---|---|---|---|---|---|---|---|---|
57-
| I001 | Phase 4 | text_encoder | medium | `Qwen3ForCausalLM` checkpoints ship `lm_head.weight`; encoder-only `Qwen3Model` does not own an LM head. Strict-load must allowlist this key. | `fastvideo/models/encoders/qwen3.py::Qwen3Model.ALLOWED_UNEXPECTED_KEYS`; encoder parity test asserts the unexpected-key set ⊆ allowlist | parity | resolved | Allowlist landed in this PR; loader raises on any other unexpected key. |
57+
| I001 | Phase 4 | text_encoder | medium | Z-Image-Turbo ships a full Qwen3 checkpoint with `lm_head.weight`; the shared encoder (`Qwen3ForCausalLM`) is body-only and owns no LM head, so that key goes unmatched. Must be allowlisted; anything else is a real silent drop. | encoder parity test asserts the unexpected-key set ⊆ `_ALLOWED_UNEXPECTED_KEYS = {"lm_head.weight"}` | parity | resolved | Allowlist enforced in the parity test (the shared encoder's loader is intentionally lenient as it serves multiple models, so the contract lives test-side). |
5858
| I002 | Phase 5/6 | scheduler | high | `scheduler_config.json` at `<weights>/Z-Image/scheduler/` does not pin `use_reference_discrete_timesteps=True`. Stock loaders will silently fall back to default timestep mode (numerically different — parity tests prove the divergence). | `tests/local_tests/zimage/test_zimage_scheduler_parity.py` sets the flag programmatically | pipeline | open | Pin the flag in `scheduler_config.json` when wiring the pipeline preset. |
59-
| I003 | Phase 6 | text_encoder | low | `Qwen3ArchConfig.text_len=512``tokenizer_kwargs.max_length=512`, but parity tests tokenize at 96/128. Pipeline preset must reconcile. | `fastvideo/configs/models/encoders/base.py::TextEncoderArchConfig.__post_init__` | pipeline | open | Set the correct `text_len` from the Z-Image preset config when adding the pipeline preset. |
59+
| I003 | Phase 6 | text_encoder | low | The shared `Qwen3TextArchConfig.text_len=512``tokenizer_kwargs.max_length=512`, but parity tests tokenize at 96/128. Pipeline preset must reconcile. | `fastvideo/configs/models/encoders/base.py::TextEncoderArchConfig.__post_init__` | pipeline | open | Set the correct `text_len` from the Z-Image preset config when adding the pipeline preset. |
6060
| I004 | Phase 4 | transformer | high | `ZImageTransformer2DModel` not yet ported | PR body, in-progress | port | open | Future PR. |
6161
| I005 | Phase 6 | pipeline | high | No FastVideo pipeline class, registry entry, preset, or example yet | n/a | pipeline | open | Future PR. |
6262

@@ -67,11 +67,14 @@
6767
## Decisions
6868
| Date | Decision | Rationale | Impact |
6969
|---|---|---|---|
70+
| 2026-06-21 | Fix the shared Qwen3 encoder's RoPE positions for batched inputs: expand `position_ids` to `[batch_size, seq_len]`. | L40S re-validation caught a parity divergence that was present **only at batch>1** (batch=1 matched; batch=2 fp32 mean_diff 0.22). Root cause: the rotary layer flattens `position_ids` to `num_tokens` and reshapes q/k to `(num_tokens, -1, head_dim)`; the encoder built `position_ids` as `[1, seq_len]`, so for batch>1 `num_tokens` stayed `seq_len` and the batch dim got folded into the head dim, misaligning RoPE. (Two earlier hypotheses — causal mask, and `sdpa_mask` mask construction — were both falsified by re-runs: bidirectional was *worse*, and `sdpa_mask` gave byte-identical results since Z-Image is right-padded.) | One-line fix in `Qwen3ForCausalLM.forward` (`.expand(batch_size, -1)`); batch=1 is byte-identical so Flux2 Klein is unaffected. This is also a latent batch bug in the shared encoder. Needs the L40S re-run to confirm. |
71+
| 2026-06-21 | Reuse the shared `Qwen3ForCausalLM` encoder (+ `Qwen3TextConfig`) instead of the bespoke `Qwen3Model` this PR originally added. | `main` gained a config-driven Qwen3 text encoder via the Flux2 Klein port (#1349); it returns `BaseEncoderOutput(last_hidden_state, hidden_states)`, supports GQA + the same qkv/gate_up fusion, and `update_model_arch` populates Z-Image-Turbo's dims (2048/24/16) from its `config.json`. Keeping our parallel encoder would have been ~540 lines of duplicate maintenance. | Dropped `fastvideo/models/encoders/qwen3.py` + `fastvideo/configs/models/encoders/qwen3.py` from this PR; added a 1-line registry map (`"Qwen3Model" → Qwen3ForCausalLM`). Encoder parity needs re-validation on A40 against the shared class (see Parity Commands). |
7072
| 2026-05-12 | bf16 encoder parity uses distribution checks (mean + median) instead of element-wise `assert_close`; thresholds calibrated to empirical Z-Image-Turbo numbers on A40 + 1.5–1.6x headroom. | Z-Image-Turbo's Qwen3 text encoder is 35 layers (not the 24 originally assumed). Cross-kernel bf16 (FastVideo's fused QKVParallel + MergedColumnParallel + SiluAndMul vs HF's unfused equivalents) accumulates into a long max tail (~4.0 at layer 34) but median stays low (0.06). Per-layer diagnostic test confirmed growth is smooth and monotonic with no single-layer spike — textbook bf16-tail signature, fp32 is bit-exact. Element-wise `assert_close` is meaningless on this profile; mean + median + the per-layer diag together detect real bugs (which push mean ≫ atol AND median > 0.01). | Final assertion shape: `last_hidden_state` mean < 0.025, median < 0.020; `hidden_states[-2]` mean < 0.120, median < 0.100. Validated on NVIDIA A40 (driver 565.57.01, 46068 MiB) 2026-05-12. |
7173
| 2026-05-12 | `AutoModel.from_pretrained` uses `dtype=` (not `torch_dtype=`). | transformers 4.57.3 emits `torch_dtype is deprecated! Use dtype instead!` warning. Mrinaald's original `dtype=` kwarg was correct; the temporary switch to `torch_dtype=` (in response to a Copilot review comment) was reverted. ||
7274
| 2026-05-12 | Scheduler parity forwards the full `scheduler_config.json` dict (minus Diffusers loader keys), not 3 hand-picked keys. | Future on-disk fields (`time_shift_type`, `invert_sigmas`, etc.) would have been silently dropped. | Makes parity reflect the actual on-disk config. |
7375

7476
## Handoff Notes
75-
- Component parity (scheduler / tokenizer / VAE / Qwen3 fp32 + bf16) is **fully validated** on `Tongyi-MAI/Z-Image-Turbo` weights on A40 as of 2026-05-12.
76-
- Loader-side strictness is contract-asserted in the encoder parity test; do not relax `ALLOWED_UNEXPECTED_KEYS` without updating the test.
77+
- Component parity (scheduler / tokenizer / VAE on A40 2026-05-12; text encoder fp32 + bf16 on the shared `Qwen3ForCausalLM`, L40S 2026-06-21) is **fully validated** on `Tongyi-MAI/Z-Image-Turbo`. fp32 is bit-exact; bf16 holds the existing thresholds.
78+
- Encoder reuse required one shared-encoder fix: batched RoPE `position_ids` (`.expand(batch_size, -1)`) — see Decisions. batch=1 is byte-identical so Flux2 Klein is unaffected.
79+
- Loader-side strictness is contract-asserted in the encoder parity test via `_ALLOWED_UNEXPECTED_KEYS`; do not relax it without updating the test.
7780
- Next port-stack steps (separate PR, not in #1339 scope): `ZImageTransformer2DModel` port (I004), pipeline preset including `use_reference_discrete_timesteps=True` pinned in `scheduler_config.json` (I002), conversion-or-direct-load story, SSIM media regression (blocked on PR #1321's `media_extension` helper landing for T2I `.png` output).

tests/local_tests/zimage/README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,23 +62,26 @@ pytest tests/local_tests/zimage/ -v -s
6262
| Scheduler (`FlowMatchEulerDiscreteScheduler` + `use_reference_discrete_timesteps`) | [`test_zimage_scheduler_parity.py`](./test_zimage_scheduler_parity.py) | full `scheduler_config.json` forwarded; pipeline must also pin the new flag at load time | PASS |
6363
| Tokenizer (`TokenizerLoader` vs `AutoTokenizer`) | [`test_zimage_tokenizer_parity.py`](./test_zimage_tokenizer_parity.py) | `apply_chat_template` parity included | PASS |
6464
| VAE decode (`AutoencoderKL`) | [`test_zimage_vae_parity.py`](./test_zimage_vae_parity.py) | decode-only; encode path deferred until pipeline | PASS |
65-
| Text encoder (`Qwen3Model`) | [`test_zimage_encoder_parity.py`](./test_zimage_encoder_parity.py) | parametrized fp32 + bf16; bf16 uses calibrated `atol=0.05` + diagnostic prints + abs-mean drift check | PASS |
65+
| Text encoder (shared `Qwen3ForCausalLM`, reused) | [`test_zimage_encoder_parity.py`](./test_zimage_encoder_parity.py) | parametrized fp32 + bf16; bf16 uses calibrated distribution checks + diagnostic prints. Z-Image's `Qwen3Model` checkpoint routes to the shared encoder via the registry | PASS (fp32 bit-exact + bf16, L40S 2026-06-21) |
6666

6767
## Known Blockers / Open Items
6868

6969
See [`PORT_STATUS.md`](./PORT_STATUS.md) for the live tracker. Highlights:
7070

71-
- `Qwen3Model.ALLOWED_UNEXPECTED_KEYS = {"lm_head.weight"}``Qwen3ForCausalLM`
72-
checkpoints carry an LM head the encoder bucket does not need. Loader raises
73-
if any other unexpected key appears.
71+
- The text encoder reuses the shared `Qwen3ForCausalLM` (added for Flux2 Klein,
72+
#1349); Z-Image-Turbo's `Qwen3Model` architecture string routes to it via the
73+
model registry. Z-Image-Turbo's full Qwen3 checkpoint carries an `lm_head.weight`
74+
the body-only encoder does not own, so the encoder parity test allowlists exactly
75+
that key (`_ALLOWED_UNEXPECTED_KEYS`) and fails on any other unmatched key.
7476
- `tests/local_tests/zimage/test_zimage_scheduler_parity.py` builds the
7577
FastVideo scheduler with `use_reference_discrete_timesteps=True` programmatically.
7678
When the pipeline lands, `<repo_root>/official_weights/Z-Image/scheduler/scheduler_config.json`
7779
must pin this flag, otherwise stock loaders will silently fall back to the
7880
default Diffusers timestep mode (numerically different).
79-
- `Qwen3ArchConfig.text_len = 512` derives `tokenizer_kwargs.max_length = 512`
81+
- The shared `Qwen3TextArchConfig.text_len = 512` derives `tokenizer_kwargs.max_length = 512`
8082
via `TextEncoderArchConfig.__post_init__`, but the parity tests tokenize at
81-
`max_length=96..128`. Reconcile when the pipeline preset lands.
83+
`max_length=96..128`. Reconcile when the pipeline preset lands. (Also note the
84+
shared config defaults `is_chat_model=True`, vs Z-Image's removed bespoke `False`.)
8285

8386
## Review Notes
8487

0 commit comments

Comments
 (0)