RGBA-Image-2.1-CoreAI / host /host_contract.md
mlboydaisuke's picture
RGBA-Image-2.1: Core AI port of Qwen-Image-2.1 (macOS 27, bf16 DiT + w16a32 encoder + fp32 RGBA VAE)
a9b5c94 verified
|
Raw
History Blame Contribute Delete
8.2 kB
# 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.