RGBA-Image-2.1: Core AI port of Qwen-Image-2.1 (macOS 27, bf16 DiT + w16a32 encoder + fp32 RGBA VAE)
a9b5c94 verified | # Host contract — RGBA-Image-2.1 (Core AI port of Qwen-Image-2.1) | |
| Everything a host computes around the three graphs, for text-to-image, batch 1, no CFG. | |
| The Python reference of every step below is in the zoo's | |
| [`conversion/qwenimage21/`](https://github.com/john-rocky/coreai-model-zoo/tree/main/conversion/qwenimage21): | |
| `qi21_tokenize.py` (tokenizer), `qi21_host.py` (RoPE, pack/unpack), `qi21_sched.py` (sampler), | |
| `pipeline_engine.py` (the whole loop on the three bundles). The first three match the diffusers-main | |
| pipeline (`Qwen/Qwen-Image-2.1` @ `790c926`) exactly: the same token ids, bit-identical RoPE tables, | |
| bit-identical sigmas and Euler steps. `pipeline_engine.py` is scored against the pipeline's fp32 images. | |
| ## 1. The graphs | |
| Every graph has one function, `main`. All tensors crossing a graph boundary are fp32, except | |
| `input_ids` (int32). | |
| | bundle | inputs | output | axes | | |
| | --- | --- | --- | --- | | |
| | `qi21_encoder_dynL_w16a32_ids_iofp32.aimodel` | `input_ids [1,Lfull]` int32 | `hidden [1,Lfull,4096]` | `Lfull` 16..512 | | |
| | `qi21_dit_full_bf16_dyn_iofp32.aimodel` | `img_tokens [1,N,64]`, `txt_feats [1,L,4096]`, `timestep [1]`, `txt_cos [1,L,64]`, `txt_sin [1,L,64]`, `img_cos [1,N,64]`, `img_sin [1,N,64]` | `vel [1,N,64]` | `L` 8..512, `N` 64..4096 | | |
| | `qi21_vae_{256,512,1024}_fp32.aimodel` | `latents_packed [1,N,64]` | `image [1,4,S,S]` | fixed: `N = (S/16)²` | | |
| - Encoder: the Qwen3-VL-8B text stack (36 layers). bf16 weights, fp32 compute. `embed_tokens` is | |
| inside the graph. The output is the residual stream after the last layer, **before** the final | |
| RMSNorm. Do not apply a norm on the host. | |
| - DiT: 32 blocks, bf16 weights and compute, fp32 boundary. | |
| - VAE: decoder only, fp32. The graph unpacks the tokens and applies `latents * std + mean` itself. | |
| ## 2. Tokenize | |
| Tokenizer: `tokenizer/tokenizer.json` (Qwen2 BPE, byte-level). No BOS token. An empty prompt is | |
| replaced by a single space `" "`. | |
| Template (text-to-image): | |
| ``` | |
| <|im_start|>system\nComprehend and analyze the provided prompt.<|im_end|>\n<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant\n | |
| ``` | |
| `\n` is a newline character. Encode the whole string once, with no padding and no truncation, to get | |
| `ids` (length `Lfull`). | |
| **`drop_idx`** is the number of tokens of the system part alone, | |
| `<|im_start|>system\nComprehend and analyze the provided prompt.<|im_end|>\n`. Compute it by encoding | |
| that string. With this tokenizer it is 14: | |
| `[151644, 8948, 198, 1092, 30782, 408, 323, 23643, 279, 3897, 9934, 13, 151645, 198]`. | |
| Check that `ids[:drop_idx]` equals those tokens. | |
| `Lfull` must be 16..512 (the encoder's axis), and the DiT needs `L = Lfull − drop_idx` in 8..512. | |
| The template around the empty-prompt substitute `" "` is 23 tokens (`L` = 9). | |
| ## 3. Encode | |
| ``` | |
| hidden = encoder(input_ids = ids as int32 [1, Lfull]) # [1, Lfull, 4096] | |
| prompt_embeds = hidden[:, drop_idx : Lfull] # [1, L, 4096], L = Lfull - drop_idx | |
| ``` | |
| Pass exactly `Lfull` tokens; the axis is dynamic, so no padding is needed. (Attention is causal, so | |
| pad tokens never reach the first `Lfull` outputs, but a longer input moves the GPU result by about | |
| 4e-6 relative.) Run the encoder once per image. | |
| ## 4. Sequence layout and RoPE | |
| The DiT's sequence is `[text L | image N]`: the `L` prompt tokens, then the image tokens in raster | |
| order. One image token covers one 16×16 px tile of the output: for an `S×S` image, | |
| `h = w = S/16` and `N = h·w` (256² → 256 tokens, 512² → 1024, 1024² → 4096). There is no 2×2 packing. | |
| Each token has a 3-axis position `(frame, height, width)`: | |
| - text token `i` (0-based) → `(i, i, i)`; | |
| - image token at row `y`, column `x` (0-based, token index `y·w + x`) → | |
| `(L, y − (h − h//2), x − (w − w//2))`. The grid is centred on zero. For `h = 16`, rows go −8..7. | |
| `host/rope_axis{a}_{cos,sin}.f32` hold one row per position −1024..8191: fp32, little-endian, | |
| row-major `[9216, P_a]` with `P = (8, 28, 28)`. The row for position `p` is `p + 1024`. A token's | |
| 64 values are the three rows concatenated in axis order: | |
| ``` | |
| cos[token] = axis0_cos[f + 1024] ++ axis1_cos[hpos + 1024] ++ axis2_cos[wpos + 1024] # 8 + 28 + 28 | |
| sin[token] = the same with the _sin tables | |
| txt_cos, txt_sin = rows of the L text tokens # [1, L, 64] | |
| img_cos, img_sin = rows of the N image tokens # [1, N, 64] | |
| ``` | |
| The tables are `torch.polar(1, outer(pos, 1 / 10000^(arange(0, d, 2)/d)))` for axis widths | |
| `d = 16, 56, 56`, the same numbers as `QwenImage21Rope.freqs` of diffusers main (bit-exact). Axes 1 | |
| and 2 have the same width, so their files are byte-identical. The four tensors depend only on `L`, | |
| `h` and `w`: build them once per image, not per step. | |
| ## 5. Noise and pack | |
| The initial latent is standard normal noise: 64 channels × `h` rows × `w` columns, channel-major. | |
| The reference pipeline draws it as `randn((1, 1, 64, h, w))` and packs it into DiT tokens: | |
| ``` | |
| x[0, y·w + x_, c] = z[c, y, x_] # = z.view(1, 64, h·w).transpose(1, 2) | |
| ``` | |
| Any N(0, 1) noise generates an image. Matching the Python engine for a given seed needs its exact | |
| draw: `torch.randn((1, 1, 64, h, w), generator=torch.Generator("cpu").manual_seed(seed))`. | |
| ## 6. Sampler (FlowMatch Euler, 40 steps, no CFG) | |
| Constants in `host/scheduler.json` (from the checkpoint's `scheduler_config.json`). All arrays fp32. | |
| ``` | |
| sigmas = linspace(1, 1/steps, steps) # steps = 40 | |
| mu = N · m + b, m = (max_shift − base_shift) / (max_image_seq_len − base_image_seq_len), | |
| b = base_shift − m · base_image_seq_len # N = image tokens | |
| sigmas = e^mu / (e^mu + (1/sigmas − 1)) # time_shift_type "exponential" | |
| sigmas = 1 − (1 − sigmas) / ((1 − sigmas[-1]) / (1 − shift_terminal)) | |
| timesteps = sigmas · num_train_timesteps # fp32 | |
| sigmas = sigmas ++ [0] # 41 values | |
| for i in 0 ..< steps: | |
| t = timesteps[i] / 1000 # fp32; this is the DiT `timestep` input | |
| vel = dit(img_tokens = x, txt_feats = prompt_embeds, timestep = [t], txt_cos, txt_sin, img_cos, img_sin) | |
| x = x + (sigmas[i+1] − sigmas[i]) · vel # fp32 | |
| ``` | |
| `mu` is 0.5 at 256² (N = 256), 0.5387… at 512², 0.6935… at 1024². Keep `t = timesteps[i] / 1000` as | |
| written (multiply by 1000, then divide) to match the reference bit for bit. `qi21_sched.py` | |
| reproduces the reference sigmas, timesteps and every Euler step bit-exactly at 256² and 512². | |
| ## 7. Decode | |
| ``` | |
| image = vae_S(latents_packed = x) # x after the last step, [1, N, 64], exactly as the sampler holds it | |
| rgba8 = round(clip(image · 0.5 + 0.5, 0, 1) · 255) # [1, 4, S, S] -> channels R, G, B, A | |
| ``` | |
| Feed the sampler's latent unchanged: no unpacking and no `* std + mean` on the host (the graph does | |
| both). The four channels are what the reference pipeline saves as an RGBA PNG. | |
| ## 8. Compile before running on the GPU | |
| On macOS 27.0 (26A428) the Python runtime crashes on the 32-block DiT when the `.aimodel` is | |
| compiled just-in-time: the MPSGraph delegate forms a Neural Engine region inside the graph and the | |
| ANE inference fails (`ANERegion.mm:414 failed assertion … Code=-19`). A plain ahead-of-time compile | |
| fails the same way. What runs is an ahead-of-time compile with `--expect-frequent-reshapes`, loaded | |
| with `SpecializationOptions.default()`: | |
| ``` | |
| xcrun coreai-build compile qi21_dit_full_bf16_dyn_iofp32.aimodel \ | |
| --output aot/qi21_dit_full_bf16_dyn_iofp32 \ | |
| --platform macOS --architecture h16c --preferred-compute gpu --expect-frequent-reshapes | |
| # -> aot/qi21_dit_full_bf16_dyn_iofp32/qi21_dit_full_bf16_dyn_iofp32.h16c.aimodelc | |
| ``` | |
| Do the same for the encoder and the VAE bundle you use; every gate of this port ran all three this | |
| way, on an M4 Max with `--architecture h16c` (without `--architecture`, `coreai-build` compiles for | |
| every supported architecture). The compiled DiT is about 1.9× its `.aimodel` (27 GB); the encoder's | |
| stays at 14.10 GiB. Whether a Swift host using `GraphModel(computeUnits: .gpu)` hits the same Neural | |
| Engine region has not been tested. | |