File size: 8,197 Bytes
a9b5c94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# 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.