# Qwen-Image-2.1: the 7B is 28% of the pipeline, and the licence is not Apache-2.0

> Satyajit Ghana — Head of Engineering @ Inkers Technology
> canonical: https://ai.satyajit.si/articles/qwen-image-2-1
> date: 2026-09-20
> tags: image-generation, diffusion, qwen, inference, kv-cache, quantization, benchmarks

Two months ago Qwen shipped **Qwen-Image-3.0** as an API and published no architecture, no
parameter count and no benchmark table — a capabilities announcement, which is what
[the piece on it here](/articles/qwen-image-3) had to work with. On 20 September 2026 they
shipped **Qwen-Image-2.1**: a lower version number, released *after* 3.0, with the weights
attached.

So this time there are files to read, and I read them rather than the blog. Every number
below comes from
[`Qwen/Qwen-Image-2.1`](https://huggingface.co/Qwen/Qwen-Image-2.1) and its ModelScope
mirror — `config.json` per component, the safetensors indexes, the tensor headers pulled
over HTTP range reads, and the `LICENSE` file. They do not all say what the announcement
says, and the gaps are worth knowing before you plan around this model.

Two days on, other people have run it, and this piece has grown to take their numbers: a
measured **2.55x** for the prefix cache, native-resolution RGBA samples the release never
published, and the same pipeline run at **3.05 GiB** of VRAM and claimed at **2 GB**. Where
a measurement tests something I derived from the files, I have said which way it went. And
since every one of those numbers was taken on a GPU, I ran the thing on four CPU cores with
no GPU at all, twice. The first pass, at 8 steps, showed only that it runs. The second used the
model's own settings, and
[at 512 x 512 it gets every word on the board right in about an hour, on 9.90 GB of 4-bit weights](#running-it-on-four-cpu-cores-with-no-gpu-at-all),
with a gallery beside it.

<ModelCard
  repo="Qwen/Qwen-Image-2.1"
  claimed="7B visual generation component, 32 Single-Stream DiT layers, open-source"
  note="Measured from the safetensors headers: the denoiser is 7,115,124,736 parameters in 297 tensors, all BF16, with no bias tensors anywhere — the claim holds exactly. The pipeline around it does not: 8,767,123,696 in the Qwen3-VL text encoder, 337,740,404 in the FP32 autoencoder, and a further 9,409,813,744 if you use the prompt rewriter the README recommends. The LICENSE is the Qwen Research License Agreement, non-commercial, dated 20 September 2026 — not Apache-2.0, which every previous open Qwen image model carried."
/>

<RepoCard repo="qwenlm/qwen-image-2.1" note="The weights and the code ship under different terms: the Hub repo carries the Qwen Research License, while the inference code here is Apache-2.0. Both are needed to run it." />

## The denoiser stops being an MMDiT

Qwen-Image 1.0 and its December refresh were both `QwenImageTransformer2DModel`: 60 joint
blocks, 24 heads of 128 (3,072 wide), a separate text stream with its own modulation and
its own MLP, and a text encoder whose states arrived at `joint_attention_dim: 3584`. That
is an MMDiT, in the MM-DiT-from-SD3 sense, and it weighed 20,430,401,088 parameters.

2.1 is `QwenImage21Transformer2DModel`, and the tensor names give it away before the config
does. There is no `txt_mlp`, no `add_q_proj`, no `to_add_out` — no second stream at all.
Text is projected once by `txt_in` into the same 4,096-wide space, condition-image latents
are substituted into the sequence at the slots the vision-language encoder reserved for
them, the target image's tokens are appended, and one stack of 32 blocks runs over the lot.

| | Qwen-Image 1.0 / 2512 | Qwen-Image-2.1 |
|---|---|---|
| denoiser class | `QwenImageTransformer2DModel` | `QwenImage21Transformer2DModel` |
| blocks | 60, dual-stream | 32, single-stream |
| width | 24 x 128 = 3,072 | 32 x 128 = 4,096 |
| `mlp_ratio` | 4 (implicit) | 3 |
| modulation | per block | one shared projection |
| `patch_size` | 2 | 1 |
| denoiser params | 20,430,401,088 | 7,115,124,736 |
| text encoder | Qwen2.5-VL-7B | Qwen3-VL-8B |
| VAE | `z_dim` 16, 8x spatial, RGB | `z_dim` 64, 16x spatial, RGBA |
| licence | Apache-2.0 | Qwen Research (non-commercial) |

Two of those rows do most of the shrinking, and neither touches depth or width.
`mlp_ratio: 3` is unusual — nearly every transformer since 2017 uses 4 — and the modulation
is a single top-level `Linear(4096 → 16384)` that every block slices its scales and gates
out of, rather than 32 copies of it. The diffusers port says so in as many words: *"blocks
hold no modulation parameters of their own."* There are no bias tensors, and the final
AdaLN emits scale only, no shift.

<ParamBudget />

The 7B claim is exact. The framing around it is the part to watch: the README's "just 7B
parameters in its visual generation component" is true of the component and not of the
thing you download, which is 33.1 GB of BF16 and FP32 weights before you add the rewriter.
Some of that is dead: the Qwen3-VL text encoder ships its 622,329,856-parameter `lm_head`,
`tie_word_embeddings` is `false`, and the pipeline never generates a token. vLLM-Omni's
recipe notes the same thing from the other direction — it excludes "its vision tower and
unused `lm_head`" when quantising.

## Block-causal attention, and why the cache is exact

The interesting engineering is in how the sequence is masked. The release calls it
"mixed-granularity attention"; the implementation is one line:

```python
# diffusers/models/transformers/transformer_qwenimage21.py
allowed = ((q_idx >= kv_idx) | same_image_block) & key_valid[batch_idx, kv_idx]
```

The joint sequence is causal, the way a language model is causal — except that every image
block, each reference image and the target, is internally bidirectional. Text tokens get
token-level granularity; image tokens get chunk-level. The release's own figure is the mask
itself, which is the right way to draw it:

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen-image-2-1/fig1.png?v=15b4f27149bababa"
  alt="An attention mask drawn as a grid of query rows against key columns, divided into four segments — system prefix, optional input image, edit instruction, target image. The text segments form causal staircases; the image segments are solid blocks, bidirectional within themselves. A bracket labels the first three segments 'Compute once, Reuse K/V' and the target image segment 'Compute each step'."
  caption="The mixed-granularity mask: causal across the sequence, bidirectional inside each image block (Qwen-Image-2.1 release, architecture figure)."
/>

Causality alone would not buy anything during sampling, because a diffusion step changes
every token's activations. What makes the prefix reusable is a second switch,
`causal_condition: true`, and it is the detail worth carrying away from this release. Text
and condition-image tokens are modulated from `t = 0` instead of from the sampled timestep
— the code builds a modulation tensor with `batch_size + 1` rows and routes the prefix to
the extra one. Their activations therefore do not depend on the step, so their K and V do
not either, and caching them is exact rather than approximate. The library refuses to let
you get this wrong:

```
kv_cache requires `causal_condition=True`. The cache is only valid because text and
condition-image tokens modulate from t=0, which makes their activations independent of
the denoising step.
```

Whether that is worth anything depends entirely on how much prefix there is, and the
arithmetic is simple enough to do exactly. `patch_size` is 1 and the VAE compresses 16x, so
an image is exactly `(H/16) × (W/16)` tokens: a 1024px reference costs 4,096 of them, a
2048px target costs 16,384.

<PrefixCache />

At four references and a 1024px target that puts 19.8% of the sequence in the recomputed
part, against the "about a fifth" the vLLM-Omni recipe reports for the same configuration —
which is the cross-check I wanted, because it means the model matches the serving stack's
behaviour rather than my reading of the code. Drag the references to zero and the saving
collapses to nothing, which is also what the recipe says: "plain short-prompt
text-to-image has almost nothing to cache." This is an editing optimisation that a
text-to-image benchmark cannot see.

<Callout type="note">
The README claims up to **10 reference images**. vLLM-Omni, the serving path the same
README lists as Day-0 supported, caps a request at **four** and rejects a fifth with a 400.
Two days later there are two more implementations to ask: diffusers imposes no cap at all —
`image` takes a list and every entry becomes its own block — and the ncnn/Vulkan build
documents `-i` as repeatable "1 to 10 times". So the 4 is vLLM-Omni's policy rather than the
model's, which is the reading I could not justify from two documents and can from four.
</Callout>

### The cache, as three other people measured it

First the mechanics, from `diffusers` rather than from the announcement, because the
interesting part is that nothing here is trained to be cacheable. On the first denoising
step every layer projects Q, K and V for the whole joint sequence and keeps only the prefix
half — post-RoPE, shaped `(batch, prefix_tokens, heads, head_dim)`, one pair per layer. On
every later step the model cuts the sequence down *before* the blocks run at all:

```python
# diffusers/models/transformers/transformer_qwenimage21.py
if kv_cache_mode == "cached":
    joint_hidden_states = joint_hidden_states[:, prefix_len:]
    rotary_emb = rotary_emb[prefix_len:]
    modulation_mask = modulation_mask[prefix_len:]
```

so the target positions are the only ones projected at all, and each attention layer puts
the prefix back with `torch.cat([cached_k, key], dim=1)`. Target queries attend over
`[fixed prefix + current target]`. `use_kv_cache` defaults to `True` in the pipeline; with
`true_cfg_scale > 1` there are two caches, one per branch, so guidance doubles the cache as
well as the work.

One line in there is worth stealing on its own. The cache is written with `.clone()`, not
`contiguous()`, and the comment says why: at batch size 1 the prefix slice already counts as
contiguous, so `contiguous()` hands back the same view and the cache pins the *entire*
prefill K and V for every step of the loop. On the configuration below that is 6.13 GiB
retained instead of 4.13 GiB. One word, two gigabytes.

<CacheLedger />

2.55x is not a suspicious number. It is very close to the only number this architecture
allows. Two 1024px references and a 1024px target make a 12,544-token sequence of which the
target is 32.7%; one full step plus 39 cached ones works out to 2.62x fewer
multiply-accumulates, and the measurement lands at 97% of that. The cleaner way to see it is
to divide back out: the recomputing run does 9.18 PFLOP in 50.57 s and the cached run does
3.51 PFLOP in 19.86 s, which is 182 and 177 TFLOPS — 58% and 57% of the A100's BF16 peak.
The cache does not make the machine more efficient. It gives it less to do, and the saving
arrives where the mask says it should.

The memory goes the other way, and the arithmetic is just as tight. K and V for one prefix token, across
all 32 layers in bf16, is exactly `2 * 4096 * 2 * 32` bytes — half a mebibyte, a number worth
memorising. An 8,448-token prefix is therefore 4.13 GiB of cache, and the measured peak rose
by 3.7 G. That is 90% of the arithmetic, and the gap is the allocator reusing what the
shorter sequence stopped needing.

<Callout type="tip">
The falsifier I attached to `PrefixCache` asked for a measured step-time breakdown showing
the cached path saving *substantially less* than the token share predicts, because the win
is bandwidth-bound or because cache reads cost more than the recompute they avoid. That did
not arrive. The opposite did: at these sequence lengths the DiT is compute-bound, the
effective throughput is unchanged either way, and the arithmetic is a tight bound rather
than a loose one. A third party also reports the cached and uncached images are bit-exact,
which is what `causal_condition` promises and what I could only argue from the code.
</Callout>

Two caveats, because 2.55x is the best case in the set. It is DiT time on a warmed A100, not
a job: the second measurement in the ledger is end-to-end on an RTX 5070 Ti and comes out at
1.30x, because the text encoder and the VAE are outside the cache's reach and four steps
give it only three to work with. And at zero references the widget above still says 1.05x.
None of this touches plain short-prompt text-to-image, which remains the case the published
benchmark measures.

## The alpha channel is real; the published evidence is 684 pixels wide

The feature that actually distinguishes this checkpoint is in `vae/config.json`, one line
from the top: `in_channels: 4`, `out_channels: 4`. The autoencoder takes and returns RGBA.
`z_dim` is 64, `scale_factor_spatial` is 16, `decoder_base_dim` is 144 against a
`base_dim` of 96 — so the decoder carries 259,044,052 of the VAE's 337,740,404 parameters
and the encoder only 78,675,680, a 3.3:1 split. It is stored in FP32, alone among the three
components, which is why 337M parameters occupy 1.35 GB.

This folds in the December 2025 `Qwen-Image-Layered` model as a capability of the base
checkpoint rather than a separate download, and the prompt format is a blunt instrument:
you ask for it in English, in the prompt, with a sentence the model card spells out —
`This is an RGBA image with transparency. … The image has alpha channel and the background
is transparent.`

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen-image-2-1/fig3.png?v=ea57ada9ae06fc4f"
  alt="A cartoon dragon in Chinese new-year dress riding a lion-dance lion, holding a lantern and a firecracker, with no background. The empty area is drawn as a pale grey and white checkerboard, showing that those pixels are transparent rather than white."
  caption="A published RGBA sample, composited here onto a checkerboard rather than white so the alpha channel survives the page (Qwen-Image-2.1 release, transparency showcase)."
/>

I measured that file's alpha channel rather than taking the claim. All 256 levels are
present, so it is not a one-bit matte — but the histogram is sharply bimodal: 53.1% of
pixels fully transparent, 23.5% fully opaque, 22.6% within 31 levels of one of those two
extremes, and **0.8% of the image anywhere between alpha 32 and 223**. That is a clean cutout
with an anti-aliased fringe, not translucency. It is also a 684 x 685 web asset, well under
the 2048px the card gives as the default generation size, and downsampling manufactures
fringe alpha on its own — so even the fringe is not safely attributable to the model.
The release publishes no
native-resolution RGBA sample, which means the one claim you most want to check from the
artifacts is the one you cannot.

Two days later you can. Someone validating a local installation generated the transparency
workflow twice at 1024 x 1024 — once through the official INT8 weights and once through a
4-bit GGUF conversion of the same denoiser — and committed both PNGs. I ran the same
histogram over them.

<AlphaBands />

The midrange share at native resolution is **0.55%** and **0.48%**, against 0.8% in the
downscaled release asset. It went *down*, which is the direction the resampling argument
predicted, and it stayed down under 4-bit quantisation. Put the other way: in both native
files, **99.1% of pixels sit within seven levels of fully transparent or fully opaque**. This
is a matte generator. It is a good one — the edges are clean and the shape is right — but
if you are planning around glass, smoke or a soft shadow, plan on compositing them yourself.

There is a second thing in those files that the model card does not mention, and it will
cost somebody an afternoon:

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen-image-2-1/fig4.png?v=cebd35b03cc0b0a6"
  alt="Three panels of the same 1024 by 1024 generated teapot. Left: the RGBA file composited onto a grey and white checkerboard, showing a red teapot on transparency. Middle: the alpha channel alone, an almost binary white silhouette on black with no grey. Right: the RGB channels with the alpha discarded, showing the same teapot on a saturated purple field with faint horizontal banding along the bottom."
  caption="The same file three ways. The alpha channel is a matte, not a gradient; the RGB underneath the transparent region is a decoded purple field, not blank (generated by a third party's installation validation on an RTX 3090 through the official INT8 weights; the checkerboard and the panel layout are mine)."
/>

The transparent pixels are not empty. The VAE decodes RGB there too — a saturated purple,
mean `(153, 56, 195)` over the pixels below alpha 8, with visible horizontal banding near
the bottom edge. Anything that reads the PNG without asking for the alpha channel gets a
purple rectangle with a teapot on it: `cv2.imread` without `IMREAD_UNCHANGED`, a JPEG
export, a downstream model fed the file directly. The guide that shipped these samples warns
about it in one line; the model card does not.

## The licence is the news

Every open Qwen image model before this one is Apache-2.0. I checked all of them on the
Hub:

| Repo | Released | Licence |
|---|---|---|
| `Qwen/Qwen-Image` | Aug 2025 | Apache-2.0 |
| `Qwen/Qwen-Image-Edit` | Aug 2025 | Apache-2.0 |
| `Qwen/Qwen-Image-Edit-2509` | Sep 2025 | Apache-2.0 |
| `Qwen/Qwen-Image-Edit-2511` | Dec 2025 | Apache-2.0 |
| `Qwen/Qwen-Image-Layered` | Dec 2025 | Apache-2.0 |
| `Qwen/Qwen-Image-2512` | Dec 2025 | Apache-2.0 |
| `Qwen/Qwen-Image-Bench` | May 2026 | Apache-2.0 |
| **`Qwen/Qwen-Image-2.1`** | **Sep 2026** | **Qwen Research License** |

The `LICENSE` file is dated 20 September 2026 and says what it says:

> You are granted a non-exclusive, worldwide, non-transferable and royalty-free limited
> license … to use, reproduce, distribute, copy, create derivative works of, and make
> modifications to the Materials **FOR NON-COMMERCIAL PURPOSES ONLY**.

with `"Non-Commercial"` defined in clause 1(i) as *"for research or evaluation purposes
only"*, and commercial use routed to an email address. Both prompt-rewriting checkpoints
carry the same terms. So does the VAE — there is no separate licence for it, which matters
because autoencoders get lifted out of releases and reused more often than denoisers do,
and this is the first 16x RGBA one worth lifting.

This is not a use-restriction licence in the RAIL sense; there is no acceptable-use annex
and no list of forbidden applications. It is a plain commercial restriction, with Chinese
governing law and exclusive jurisdiction in the Hangzhou courts. "Open-source" is the word
the blog, the README and the model card all use. The [OSI definition](https://opensource.org/osd)
has said no field-of-endeavour restrictions since 1998, and this is one.

The downstream is already inconsistent about it, within hours of release. Of the two FlagOS
repackages that Qwen's own README links,
[`FlagRelease/Qwen-Image-2.1-BF16-zhenwu-FlagOS`](https://modelscope.cn/models/FlagRelease/Qwen-Image-2.1-BF16-zhenwu-FlagOS)
is labelled **apache-2.0** on ModelScope, which is not a licence that repo is in a position
to grant. Among the community GGUF conversions that appeared the same day, two carry
`qwen-research` and one carries no licence field at all. None of that is Qwen's doing, and
all of it is the predictable consequence of shipping a research licence into a family
people have spent a year treating as Apache.

Two days on, the picture is better than that paragraph suggests, and the correction belongs
here rather than in a footnote. Every redistribution people are actually installing from
carries the right terms. The official ComfyUI repack
([`Comfy-Org/Qwen-Image-2.1`](https://huggingface.co/Comfy-Org/Qwen-Image-2.1)) declares
`license_name: qwen-research` and links back to Qwen's own `LICENSE` file, which is the most
correct form any of them takes. Both `abenzerps` GGUF conversions declare `qwen-research`
with `base_model` set, missing only the link. The ncnn conversion
([`nihui-szyl/qwen-image-ncnn`](https://huggingface.co/nihui-szyl/qwen-image-ncnn)) ships the
Qwen Research License verbatim, says in the card that the files are "for research and
evaluation purposes only", that commercial use needs a separate licence, and that the
inference tool's own Apache-2.0 "does not change the licensing terms of the
Qwen-Image-2.1 model files" — a distinction most repackagers do not bother to draw. Its one
gap is metadata rather than terms: the card carries no `license:` field, so the Hub shows the
repository with no licence tag and it will not appear in a licence filter. The installers
are cleaner still, because they redistribute nothing: MIT scripts that download the official
weights by pinned revision and verify them by SHA-256. The FlagOS `apache-2.0` label remains
the outlier, not the pattern.

Three more repositories belong in that audit, because the CPU runs further down use or
measure them. Unsloth's two
([`unsloth/Qwen-Image-2.1-GGUF`](https://huggingface.co/unsloth/Qwen-Image-2.1-GGUF) and
[`unsloth/Qwen-Image-2.1-FP8`](https://huggingface.co/unsloth/Qwen-Image-2.1-FP8)) declare
`license_name: qwen-research`, link `license_link` to Qwen's own `LICENSE` and set
`base_model` — the same, most complete form as the ComfyUI repack. leejet's
[`leejet/Qwen-Image-2.1-GGUF`](https://huggingface.co/leejet/Qwen-Image-2.1-GGUF), whose files
produced every CPU image in this article, says in its README body that the quantised files
follow the Qwen Research License and links it, but carries no `license:` field, so the Hub
shows it with no licence tag — the ncnn repository's gap exactly, terms right and metadata
missing.

And one finding that cuts the other way. The text encoder inside `Qwen/Qwen-Image-2.1` is,
as far as I can check without downloading 17.5 GB twice, stock
[`Qwen/Qwen3-VL-8B-Instruct`](https://huggingface.co/Qwen/Qwen3-VL-8B-Instruct): all 750
tensors match it in name, shape, dtype and byte size, 17,534,247,392 bytes on both sides, and
the six tensors I sampled — first and last language layers, a middle MLP, a vision block, the
embeddings and `lm_head` — are byte-identical over their first 4 MB. That model is Qwen's own
Apache-2.0 release. So the research licence on this directory covers the denoiser and the VAE
in any sense that matters: 8.77B of the pipeline's 16.2B parameters are obtainable from Qwen
under Apache-2.0 already, and the GGUF encoder the CPU runs below use is Qwen's own Apache-2.0
conversion of it, `Qwen/Qwen3-VL-8B-Instruct-GGUF`.

## What the one published eval measures

There is no technical report. arXiv has no Qwen-Image-2.1 paper; the most recent Qwen image
paper is the benchmark's, from May. The GitHub repo is a README, a LICENSE, and a
`prompt_rewrite/` directory — no inference code of its own, no training code, no evaluation
harness. The entire quantitative record of this release is one chart.

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen-image-2-1/fig2.png?v=289c5b92762066ed"
  alt="A bar chart of 29 image models ranked by a single total score from 67.01 down to 46.17, with Qwen Image 2.1 highlighted in green at 60.28 in seventh place. Below each bar, a second row shows parameter counts: open models are labelled with sizes from 6B to 80B, while closed models show a padlock icon on a full-height bar and a footnote that closed models' parameter counts are not disclosed."
  caption="The only quantitative claim in the release: one aggregate score across 29 models, on Qwen's own benchmark (Qwen-Image-2.1 release, Qwen-Image-Bench evaluation comparison)."
/>

Name the denominators, because the chart does not.
[Qwen-Image-Bench](https://huggingface.co/datasets/Qwen/Qwen-Image-Bench) is **1,000
expert-written bilingual prompts**, scored across a three-level rubric — 5 pillars, 23
sub-capabilities, **56 verifiable facets**. Each facet is judged Fail, Pass or Excel, mapped
to **0, 60 and 100**, `N/A` excluded, and averaged up the tree. The judge is **Q-Judger**, a
Qwen3.6-27B fine-tune trained on 130,000+ annotated pairs from 80 art-academy annotators,
run at `temperature 0`, `seed 42`, thinking on.

So: Qwen's model, on Qwen's prompts, scored by Qwen's judge, against competitors Qwen ran
itself. That is not disqualifying — the benchmark, the judge and the dataset are all
published under Apache-2.0, which is more than most vendors do, and you can rerun it. It is
also not third-party, and the chart does not say so.

Three things the chart leaves out that its own sources state:

1. **The leaderboard it reuses is the Chinese-prompt run.** Eighteen of the 29 bars match
   the benchmark's published leaderboard to the hundredth, and that leaderboard carries a
   footnote: *"results are computed based on Q-Judger's evaluation of images generated from
   Chinese prompts. We will release the results for image generation from English prompts
   soon."* The chart inherits the denominator and does not mention it.
2. **The human-agreement figure is a model-level rank correlation.** Spearman
   $\rho = 0.92$ is impressive and it is computed over $N = 18$ **models**, not over images.
   It says the judge orders a field the way experts do. It does not say the judge is right
   about any particular picture, and it says nothing at all about whether a 0.46-point gap
   between two adjacent bars is a real difference.
3. **It is a text-to-image benchmark.** Transparency, ten-reference composition, circle- and
   mask-guided local editing, identity preservation — everything this release actually added
   — is measured by none of it. The chart scores the one capability 2.1 did not change.

The rubric is worth putting the totals back onto, because 60 is not an arbitrary number on
that scale. It is the score of a model that passes every facet and excels at none.

<RubricMath />

That gives the chart a reading it does not advertise. Qwen-Image-2.1 at 60.28 is the
lowest bar on it that could, in principle, have zero Fails — everything below is carrying a
floor on how often the judge said the image failed outright, up to 23.1% for the last bar.
Against its own line the improvement is real and large: 49.23 for Qwen-Image 1.0, 52.06 for
2512, 60.28 here, at roughly a third of 1.0's denoiser size. Against the field it is
seventh of 29, below Qwen's own `Qwen Image 3 Pro` at 62.36, and the six models above it are
all closed.

<Callout type="warn">
The second row of that chart draws every closed model's parameter count as a full-height
bar with a padlock, footnoted "closed-source models' parameter counts are not disclosed".
A bar whose height encodes *unknown* sits on the same axis as bars whose height encodes 6B
and 80B. Read the row as a lookup table, not as a chart.
</Callout>

## What it costs to run, from someone other than the vendor

The release makes an efficiency argument — "compact", "lightweight", "low computational
cost" — and publishes no latency, no throughput and no memory figure to support it. On the
day it landed the only measured numbers anywhere were third-party, in the
[vLLM-Omni recipe](https://recipes.vllm.ai/Qwen/Qwen-Image-2.1), taken on one NVIDIA GB300
at 1024 x 1024 and 40 steps:

| Config | Peak memory | Time per image |
|---|---:|---:|
| BF16 | 34.0 GB | 3,282-4,492 ms |
| DiT FP8, `img_mlp` kept BF16 | 32.0-32.7 GB | 3,356-4,706 ms |
| Text-encoder FP8 | 27.5-28.1 GB | 3,427-4,770 ms |

Two things follow. 34 GB peak means a "7B" model does not fit a 24 GB card without
offloading — the pipeline is 33.1 GB of weights before activations, of which the denoiser
is 14.2 GB. And FP8 on Blackwell buys memory, not speed: none of the quantised rows
is faster, which is what you expect when the bottleneck is bandwidth rather than tensor
cores. Qwen ships no quantised export of its own; every FP8, W8A8 and GGUF build is a
partner's or the community's.

### Down the ladder: 33.1 GB, then 3.05 GiB, then 2 GB

Two days of community work produced two much smaller numbers than that table, and they are
smaller in completely different ways.

<FootprintLadder />

The ladder carries three more kinds of row than it did. Unsloth's
[GGUF](https://huggingface.co/unsloth/Qwen-Image-2.1-GGUF) and
[FP8](https://huggingface.co/unsloth/Qwen-Image-2.1-FP8) repositories are there with byte counts
read from the Hub; the FP8 row is a GPU route. Unsloth's guide states 11 GB of VRAM for the GGUF
route, 24 GB for FP8 or INT8 (6 GB with offloading, at what it says is less than twice the
time), and 12–16 GB of RAM for
CPU-only, and says of all of them that "memory figures are estimates, not tested minimums" —
which is why the host-memory view draws them dashed. The last two rows are this article's own
runs on a four-core CPU, measured further down: 9.86 and 11.40 GiB of peak RSS at 512², under the
15.21 GiB the 3090's tightest profile needed on the host, and on the time view bars that dwarf
every GPU row, which is the honest shape of doing this without one.

The **3.05 GiB** configuration is the ComfyUI GGUF route, and it trades four separate
things, all of them stated. Precision first: the denoiser goes from bf16 to `Q4_K_M`, which
is 5.18 bits per parameter and takes 14.23 GB to 4.60 GB; the text encoder goes to an INT8
ConvRot export at 8.53 bits including scales, 17.53 GB to 9.35 GB; the VAE is cast from fp32
to bf16, 1.35 GB to 0.68 GB. The download drops from 33.1 GB to 14.63 GB. Second, the prompt
rewriter is gone — 9.41B parameters and 18.82 GB of the path Qwen's README recommends — and
the validation report says plainly that no prompt-enhancer integration was tested. A
distilled 0.75B replacement for that rewriter has since appeared, and
[the piece on it here](/articles/qwen-image-2-1-pocket-rewriter) checks what it keeps. Third,
placement: the text encoder runs on the CPU, the VAE decodes on the CPU, and the server log
shows **2,186 MiB of denoiser weights on the GPU against 2,301 MiB offloaded**, so less than
half the 4-bit model is resident and the rest streams in per step.

Fourth, and this is the number that belongs next to the footprint: **time**. At 512 x 512
and 40 steps the same package goes 111.75 s to 195.28 s as the budget tightens from 5.55 GiB
to 3.05 GiB. At 1024 x 1024 it goes 60.35 s to 145.90 s for 15.39 GiB down to 6.14 GiB. And
the memory does not disappear, it relocates: process RSS in the tightest profile is
**15.21 GiB**, against 2.4 GiB in the ordinary ones. You are buying VRAM with system RAM and
wall-clock: at 1024 that is 2.4x the time for 40% of the memory.

<Callout type="warn">
The 3.05 GiB was measured on a 24 GiB RTX 3090 launched with `--reserve-vram 20`, which the
report is explicit is "a placement hint, not a hard CUDA allocation limit", and it says so
before anyone can quote it otherwise: no physical 4, 6, 8, 12 or 16 GiB card was tested, no
RAM-constrained host, no Windows install, no AMD/Intel/Apple backend. Nor was the
transparent-RGBA workflow run at that budget — it was validated in the standard profile at
15.46 GiB. And there is no quality comparison against bf16 or INT8 anywhere in the report.
It is an honest document, which is exactly why it is worth reading before quoting the
headline from it.
</Callout>

The **2 GB** claim is a different animal, and the arithmetic is the interesting part. It
comes from an ncnn/Vulkan port — a portable executable, no CUDA, no PyTorch, no Python,
running on NVIDIA, AMD, Intel and Apple Silicon — and unlike the GGUF route it claims full
bf16 with no quantisation. That claim checks out, from eight bytes. The first four bytes of
each shipped `.bin` are `83 8b 34 01`, and `ncnn/src/modelbin.cpp` reads tag `0x01348B83`
as bfloat16; all three components carry it. The denoiser reconciles to the release exactly:
**14,230,275,488 bytes against the safetensors index's 14,230,249,472**, 26 KB apart on
14.2 GB, which is ncnn's own per-layer headers.

What is missing from the conversion is the part this article already flagged as dead. The
whole package is 31,191,738,224 bytes, which is 1.92 GB under the 33.1 GB release, and the
shortfall is two things and only two: the 622,329,856-parameter `lm_head` that the pipeline
never generates a token with (1.24 GB), and the VAE halved from fp32 (0.68 GB). Subtract
both and you land within **3.6 MB** — 0.01% — of the shipped total. Somebody else read the
same headers and drew the same conclusion, and then acted on it.

So where does 2 GB come from? Not from a smaller model. The project's own requirements
section asks for **16 GB of RAM** on Linux and macOS, and on Windows for
`(half of system RAM) + (GPU memory) >= 16GB`, because WDDM lets a Vulkan application map
only half the host's RAM. 2 GB is the GPU's share of a 16 GB budget, and that 16 GB is not
arbitrary either: the denoiser, the vision tower and the VAE are 14.23 + 1.16 + 0.66 =
16.05 GB, which is the sampling stage once the 15.14 GB text encoder has run and been
released. The weights live in host memory and the GPU reads them as it goes.

What that costs is not published. One release, dated 22 September, no benchmark table, sample
images "TBA". The floor is easy to compute and worth stating: if only about 2 GB of a
14.23 GB denoiser is resident, each of 40 steps drags most of it across the host link, so on
PCIe 4.0 x16 at a realistic 25 GB/s that is about 0.57 s per step and 23 s per image of pure
transfer before any arithmetic happens. On unified memory — Apple Silicon, an integrated GPU
— there is no link to cross at all, which is presumably why the target is Vulkan and not
CUDA. A memory figure with no latency figure next to it is a claim about where the weights
sit, not about how fast they run.

One last thing puts the two halves of this article on the same axis. The prefix cache buys
its 2.55x by *spending* memory: half a mebibyte per prefix token, 4.13 GiB at two 1024px
references and **8.13 GiB at four**. That is more than the entire GPU budget of the 3.05 GiB
profile. The configuration that makes this model fit a small card and the optimisation that
makes multi-reference editing fast are the same trade run in opposite directions, and on one
card you do not get both.

## Running it on four CPU cores, with no GPU at all

Every figure in the section above was taken on a GPU — a GB300, an A100, a 3090 told to
pretend it was smaller — and the two most-quoted numbers are both claims about where the
weights *sit* rather than about what it takes to have them. 3.05 GiB is a placement hint on
a 24 GiB card. 2 GB is the GPU's share of a 16 GB budget the *host* has to supply. Neither
answers the question a reader without a GPU actually has, which is whether a GPU is the
floor at all.

So I ran it on the box that builds this site: four cores of an Intel Xeon at 2.80GHz, 15 GB
of RAM, no `/dev/dri`, no CUDA, no Vulkan, no accelerator of any kind. It generates images.
I did it twice. The first pass was a smoke test — 8 sampling steps, a fifth of what the
vendor uses — and its pictures were fair evidence that the model runs and unfair evidence of
what it draws; the site owner looked at them and said as much. The second pass uses the
model's own settings, and pulls apart the three things the first pass had tangled together:
the step budget, the noise schedule and the quantisation. The second pass comes first here,
because it is the one that describes the model.

### The files

The runner is [`stable-diffusion.cpp`](https://github.com/leejet/stable-diffusion.cpp) at
commit `2bb7294`, built with `-DGGML_NATIVE=ON` against AVX-512F/BW/DQ/VL and AVX512-VNNI —
the project whose author also published the Qwen-Image-2.1 GGUFs. Every weight file was
fetched at a pinned revision, and every one matches the SHA-256 the Hub records for it:

| Component | Repo &middot; file | Bytes | Bits/param |
|---|---|---:|---:|
| Denoiser, pass 2 | `leejet/Qwen-Image-2.1-GGUF` &middot; `qwen_image_2.1-Q4_K.gguf` | 4,197,494,816 | 4.72 |
| Denoiser, both passes | `leejet/Qwen-Image-2.1-GGUF` &middot; `qwen_image_2.1-Q2_K.gguf` | 2,561,716,256 | 2.88 |
| Denoiser, comparison | `unsloth/Qwen-Image-2.1-GGUF` &middot; `qwen-image-2.1-Q2_K.gguf` | 2,466,137,824 | 2.77 |
| Text encoder | `Qwen/Qwen3-VL-8B-Instruct-GGUF` &middot; `Qwen3VL-8B-Instruct-Q4_K_M.gguf` | 5,027,784,800 | 4.59 |
| VAE | `Comfy-Org/Qwen-Image-2.1` &middot; `vae/qwen_image_2.1_vae_bf16.safetensors` | 675,509,688 | 16.00 |

Bits per parameter are read from each GGUF's own tensor table, not from the file name: every
one of these denoisers holds exactly 7,115,124,736 parameters, the same count the safetensors
headers gave at the top of this article. The Q4_K pipeline is **9,900,789,304 bytes**, the
Q2_K one **8,265,010,744** — against 14.63 GB for the 3090 profile and 33.1 GB for the path
the README recommends. Note which component moved. The denoiser is the 7B everybody talks
about, and at Q2_K it is the *smallest* file in the pipeline: the 4-bit text encoder is 1.96x
its size. That is this article's opening ratio surviving all the way down the quantisation
ladder.

On the first pass disk was the binding constraint — 10.93 GB free, which is why it went
straight to Q2_K. On the second it was not, and something else was.

### What "the model's own settings" are

Three sources, and they do not all say the same thing.

- **Qwen's model card.** Every example calls the pipeline with `num_inference_steps=40` and
  seed 42, the text-to-image ones at 2048 x 2048, and none of them passes a guidance argument.
- **The diffusers pipeline.** `QwenImage21Pipeline.__call__` defaults to
  `num_inference_steps: int = 40` and `true_cfg_scale: float = 1.0`, and guidance switches on
  only when `true_cfg_scale > 1` *and* a negative prompt is supplied. So the vendor's
  default is 40 steps with classifier-free guidance **off** — one denoiser pass per step, not
  two. The scheduler is `FlowMatchEulerDiscreteScheduler` with a resolution-dependent shift:
  `base_shift` 0.5, `max_shift` 0.9, anchored at 256 and 8,192 latent tokens, exponential
  time shift, and `shift_terminal: 0.02`.
- **Unsloth's guide** gives two recipes and says to use the one for the backend actually
  running the model. For diffusers: 40 steps, CFG 1.0 ("CFG disabled"), keep the pipeline's
  scheduler — which agrees with the model card. For native stable-diffusion.cpp: 20 steps
  ("CLI default and Unsloth's recorded test"), cfg **6.0** ("upstream sd.cpp example"),
  Euler, and an automatic flow shift. Unsloth's own GGUF samples were rendered at 1024²,
  20 steps, cfg 6.0.

So the vendor and the sd.cpp example disagree about the one setting that doubles the cost.
The vendor runs one denoiser pass per step; the sd.cpp example runs two, at a guidance scale
the vendor's pipeline never applies by default. My first pass borrowed guidance from the
second camp for one of its runs, and that is the run whose exposure collapsed.

The schedule disagrees too, and nobody's guide mentions it. For this model sd.cpp picks its
generic Flux schedule — `base_shift` 0.5, `max_shift` 1.15, anchored at 256 and 4,096
tokens, no terminal stretch — which I read out of `src/runtime/denoiser.hpp` and
`src/pipeline/request.cpp` at the commit I built. At 512 x 512 that gives a shift of 0.630
against the vendor's 0.539. The bigger difference is at the far end. The vendor stretches the
schedule so the last non-zero noise level is 0.02; sd.cpp's default ends at **0.211 at
8 steps**, 0.090 at 20 and 0.046 at 40, and then jumps to zero in one Euler step. At 8 steps
the final step is asked to remove a fifth of the full noise level in one move, and I expected
that to be why the first pass looked unfinished. It was not, and the run that shows it is
below.

Either way, for the second pass I did not trust "automatic". Every run hands sd.cpp the
vendor's exact sigma schedule through `--sigmas`, computed from the model's own
`scheduler_config.json` with the formula diffusers uses, and the schedules are in the data
file beside the images.

### Memory: a ceiling I did not know was there

The first attempt at the second pass died. Q4_K at 512 x 512 ran two sampling steps and was
killed during the VAE decode with signal 9, and the kernel log is specific about why: not the
machine's 15.7 GB but a **memory cgroup**. The sandbox these commands run in caps its shell at
14,345,842,688 bytes — 13.36 GiB — and in a cgroup the page cache counts. Without memory
mapping, sd.cpp reads each weight file into the page cache and then copies it into its own
buffers, so 9.90 GB of weights were charged twice until reclaim caught up. The process was
killed at 10.27 GiB of anonymous memory, having spent 413 seconds of system time — against
789 of user time — fighting the limit; the cgroup had hit it 8,373,181 times since the
container booted 18 minutes earlier. Its two sampling steps took 119.76 s and 115.78 s.

`--mmap` fixes it. The weights are mapped straight from the GGUF files, charged once, and
evictable, so pages nothing is touching — the text encoder's, once conditioning is done — can
be dropped rather than held in a private copy. The same 2-step run then finished: 89.39 s and
90.28 s per step, 9.9 s of system time, the cgroup never at its limit, and a peak of 12.08 GiB
against the 13.36 GiB cap (sampled every half second). Every second-pass run uses it. The lesson travels: inside
a container, a memory figure is a figure for *charged* memory, and whether the runner reads or
maps its weights moves it by roughly the size of the weights.

With it, peak RSS was **11.40 GiB for the Q4_K runs and 9.86 GiB for Q2_K** at 512², counting
the mapped weights, with a sampled cgroup peak of 13.34 GiB — page cache filling the space it is
allowed, not the process needing it. sd.cpp's own compute buffers at 512² are small beside the
weights and lopsided among themselves: 7.18 MB for the text encoder, 234.03 MB for the denoiser
and **2,450.50 MB for the VAE**, the one component that ever materialises anything at full
pixel resolution.

### At the vendor's own settings

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen-image-2-1/cpu2-q4k-512-s40-cfg1.png?v=f688d8607213ce11"
  alt="A 512-pixel-square generated image of a classroom chalkboard in a wooden frame. Four lines of clean white lettering read '4 CPU', 'cores,', 'no', 'GPU', every word and the comma correct. Warm light comes through a window on the left, where a small potted plant sits on the sill beside the board; wooden school desks fill the foreground."
  caption="Generated by me on four CPU cores with no GPU: 512 x 512, 40 Euler steps on the vendor's own sigma schedule, cfg 1.0 (guidance off, as the diffusers pipeline defaults), seed 42, leejet's Q4_K denoiser (4.72 bits per parameter) and Qwen's Q4_K_M Qwen3-VL encoder. 3,863 seconds wall-clock. A quarter of the pixels of the pipeline's 1024 px default and a sixteenth of the model card's 2048 px, on 4-bit weights, so still not the full-quality model — but the first image here that uses its recommended settings."
/>

Same prompt, same seed, same box. Every word on the board is right, the comma included; the
plant is beside it on the windowsill, the light is warm and the frame is properly exposed.
If anything the lettering is too clean — it reads as a typeface with a chalk texture rather
than as handwriting — but it is exactly the text that was asked for, which is the capability
this release is built around and the one its only published benchmark measures. It took
64 minutes and 23 seconds of wall-clock on a box other jobs were sharing — about 59 on a quiet
one, from the timings further down.

That one image retires two conclusions the first pass drew. Text rendering did not need
guidance: this is guidance *off*, which is what the vendor's pipeline does by default. And the
dark, underexposed frame the first pass got with guidance on was not something to trade
against legibility; at a fair step budget, with no guidance at all, neither problem appears.
What the first pass had actually measured, as the next runs show, was 8 steps.

### Steps, schedule and bits, one at a time

The first pass differed from the vendor in three ways at once — 8 steps instead of 40,
sd.cpp's schedule instead of the vendor's, 2.88-bit weights — so its pictures could not say
which one mattered. These runs change one thing at a time. All of them are 512 x 512, seed 42,
guidance off, the same prompt, generated on this box's four CPU cores.

<div className="grid gap-x-4 sm:grid-cols-2">

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen-image-2-1/cpu-512-q2k-8step.png?v=4f53037a16c75d67"
  alt="A dark chalkboard filling the frame with four lines of smudged, unreadable chalk marks and a potted plant on a wooden ledge at the lower right."
  caption="Baseline, from the first pass: leejet Q2_K, 8 steps, sd.cpp's default schedule, cfg 1.0, 512², seed 42. Generated on CPU. Unreadable."
/>

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen-image-2-1/cpu2-q2k-512-s8-cfg1.png?v=ed651bce25461cb5"
  alt="Almost the same image as the baseline: a dark chalkboard with four smudged, unreadable lines and a plant at the lower right, slightly darker overall."
  caption="Change the schedule only: leejet Q2_K, 8 steps, the vendor's sigmas, cfg 1.0, 512², seed 42. Generated on CPU. Still unreadable."
/>

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen-image-2-1/cpu2-q2k-512-s20-cfg1.png?v=017831a5f65b4dec"
  alt="The same chalkboard composition, now with four lines of clean white lettering reading '4 CPU', 'cores,', 'no', 'GPU', a potted plant on the ledge at the lower right and a blotchy white smear on the left edge of the board."
  caption="Then the steps only: leejet Q2_K, 20 steps, vendor sigmas, cfg 1.0, 512², seed 42. Generated on CPU. Every word right, at 2.88 bits per parameter."
/>

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen-image-2-1/cpu2-q4k-512-s20-cfg1.png?v=60ae542122d06b10"
  alt="A different composition: a framed chalkboard lit by a window on the left, four lines reading '4 CPU', 'cores,', 'no', 'GPU', a plant on the sill and wooden desks in the foreground."
  caption="Then the bits only: leejet Q4_K, 20 steps, vendor sigmas, cfg 1.0, 512², seed 42. Generated on CPU. Every word right; a different picture."
/>

</div>

Read left to right and down, and the confound comes apart:

| Changed | From | To | The words on the board |
|---|---|---|---|
| Schedule | sd.cpp default, Q2_K, 8 steps | vendor, Q2_K, 8 steps | unreadable, then unreadable |
| Steps | Q2_K, 8 steps | Q2_K, 20 steps | unreadable, then all correct |
| Bits | Q2_K, 20 steps | Q4_K, 20 steps | all correct, then all correct |
| Steps | Q4_K, 20 steps | Q4_K, 40 steps | all correct, then all correct |

The step budget did it. Changing the schedule at 8 steps changed almost nothing — the 0.211
terminal jump I suspected turns out not to be what smudges the text. Going from 8 steps to 20
at the *same* 2.88 bits fixed every word. Going from Q2_K to Q4_K at 20 steps did not change
legibility at all; it changed the picture. The two quantisations land on different
compositions from the same seed — the 2-bit weights keep the first pass's close-up board and
ledge, the 4-bit ones pull back to a window-lit room — and the Q2_K frame has a blotchy smear
along its left edge and a flatter finish that the Q4_K one does not. From 20 steps to 40, at
Q4_K, the image barely moves: the same composition and the same words, with the chalk a little
softer at 20.

So the first pass's central claim is withdrawn rather than adjusted. It said guidance bought
text fidelity and resolution did not, and hedged that 8 steps at 1,024 latent tokens might
simply be too few. That hedge was the finding. At 20 steps, 512 x 512 renders the text with
guidance off, at either quantisation.

### Where the bits go

A "Q2_K" is not one thing. Reading the tensor tables of every GGUF in Unsloth's repo over HTTP
range requests — no download — shows files whose names describe their largest ambition rather
than their contents:

| Unsloth rung | Bytes | Bits/param | What the tensor table actually holds, by share of parameters | Shared modulation |
|---|---:|---:|---|---|
| `Q2_K` | 2,466,137,824 | 2.77 | 99.0% Q2_K | Q2_K |
| `Q3_K_S` | 2,724,742,880 | 3.06 | 69.1% Q2_K, 25.7% Q3_K, 4.0% Q4_K | Q4_K |
| `Q3_K_M` | 3,168,290,528 | 3.56 | 65.8% Q3_K, 20.3% Q2_K, 11.8% Q4_K, 1.2% Q5_K | Q4_K |
| `Q3_K_XL` | 3,612,493,536 | 4.06 | 55.4% Q3_K, 32.3% Q4_K, 7.8% Q5_K, 3.3% Q2_K | Q5_K |
| `Q4_K_S` | 3,906,356,960 | 4.39 | 54.2% Q4_K, 33.5% Q3_K, 10.8% Q5_K | Q5_K |
| `Q4_K_M` | 4,199,565,024 | 4.72 | 90.8% Q4_K, 7.8% Q5_K | Q5_K |
| `Q5_K_S` | 4,501,948,128 | 5.06 | 64.6% Q4_K, 30.9% Q5_K, 2.9% Q8_0 | Q5_K |
| `Q5_K_M` | 5,390,223,072 | 6.06 | 65.3% Q5_K, 29.5% Q6_K, 4.5% Q8_0 | Q5_K |
| `Q6_K` | 6,271,551,200 | 7.05 | 79.5% Q6_K, 19.8% Q8_0 | Q8_0 |
| `Q6_K_XL` | 6,718,506,720 | 7.55 | 53.5% Q6_K, 45.8% Q8_0 | Q8_0 |
| `Q8_0` | 7,640,860,384 | 8.59 | 99.3% Q8_0 | Q8_0 |
| `F16` | 14,230,275,808 | 16.00 | 99.5% F16, 0.5% BF16 | F16 |
| *leejet `Q2_K`* | *2,561,716,256* | *2.88* | *98.1% Q2_K, the rest BF16* | *BF16* |
| *leejet `Q4_K`* | *4,197,494,816* | *4.72* | *98.1% Q4_K, the rest BF16* | *BF16* |

Unsloth describes these as Dynamic 2.0 quants, "upcasted to higher precision, per tensor, from a
measured sensitivity scan", and the tables bear that out, with two surprises. The rung
labelled `Q3_K_S` is mostly Q2_K by parameter count, and `Q4_K_S` has a third of its
parameters at Q3_K. And one tensor gets treated differently at the bottom than everywhere
else. This denoiser has a single modulation projection shared by all 32 blocks —
`modulation.1.weight`, 4,096 x 16,384, 67,108,864 parameters, 0.94% of the model — which this
article flagged as a place the architecture saved parameters. leejet's seven rungs are
uniform — the same 192 tensors at the named type and the same 73 in BF16 in every file, from
`Q2_K` to `Q8_0` — so it keeps that tensor in BF16 throughout. Unsloth stores it at Q4_K or
better everywhere else — Q4_K, Q5_K, Q8_0 or F16, depending on the rung — and quantises it to
2 bits in its `Q2_K`.

That makes leejet's Q2_K against Unsloth's Q2_K a fair test of whether *where* the bits go
matters at the bottom: 2,561,716,256 bytes against 2,466,137,824, and the same 192 tensors at
Q2_K in both. Diffing the two tensor tables leaves exactly 69 differences. Sixty-five go *up*
in Unsloth's file — the 64 query/key norms and the output modulation, BF16 to F32 — and three
small ones go down to Q8_0: the output projection and the two timestep-embedding layers. The
one that goes all the way down is the shared modulation, BF16 to Q2_K.

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen-image-2-1/cpu2-uq2k-512-s20-cfg1.png?v=e98425f1c0639822"
  alt="A chalkboard in a white frame with four lines of hand-lettered chalk reading '4 CPU', 'cores', 'no', 'GPU' — the comma after 'cores' is missing — and a potted plant on a white shelf at the lower left, in cool grey light."
  caption="Unsloth's Q2_K (2.77 bits per parameter, shared modulation at 2 bits), 20 steps, vendor sigmas, cfg 1.0, 512², seed 42. Generated on CPU, 1,669.06 s. Legible, one comma short, and a third composition from the same seed."
/>

sd.cpp loads Unsloth's files without complaint, although they name every tensor with a
`model.diffusion_model.` prefix that leejet's do not carry. The text survives the 2-bit shared
modulation — all four words, in a more hand-lettered chalk than either leejet file produced —
but the comma after "cores" does not, and the seed lands on yet another room: a white-framed
board, the plant on a shelf at the left, cooler light. Three weight files, one seed, three
compositions. On one prompt that is not evidence that the modulation tensor is fragile, and I
would not claim it. What it does show is that at 2 to 3 bits, which file you download
changes which picture a given seed produces — the same seed, prompt and settings gave three
different rooms across three files.

### Where the wall clock goes

<CpuLedger />

Five things from the second pass, all at 512 x 512 with guidance off:

- **87.09 s per step at Q4_K, 79.43 s at leejet's Q2_K, 79.64 s at Unsloth's** — medians of
  the per-step times sd.cpp prints. Q2_K's file is 39% smaller and its steps are only 9% faster,
  so on this CPU a step is mostly arithmetic, not bytes; cutting two-fifths of the weights buys
  disk and memory, not much time. sd.cpp kept 3.76 to 3.82 of the 4 cores busy in every clean run.
- **The headline took 3,863 s, and should have taken about 3,535.** Its wall-clock comes from
  the job queue's timestamps, because sd.cpp's own log for that run is gone: I edited the shell
  script that launched it while it was still executing, bash resumed reading the edited file at
  a stale offset when the run ended, and a fragment of the new script overwrote the log. The
  image had already been written. Fifteen per-step readings survive from checks I made during
  the run, from 86.48 s to 124.09 s, and the slow ones sit in windows where other jobs were on
  the box — my own downloads and a type-check, another agent's `pnpm validate`, and page cache
  from freshly downloaded files squeezing the mapped weights. From the clean 20-step run's
  medians, 40 steps is 4.92 + 40 x 87.09 + 46.06 = 3,535 s, about 59 minutes. That figure is
  arithmetic on measurements, not a measurement.
- **Text encoding is free.** 4.36 to 5.85 s for 8.77B parameters of Qwen3-VL, under 0.4% of a
  20-step run.
- **The VAE is not.** 39.60 to 46.06 s to decode at 512², and its compute buffer is 2,450.50 MB
  — ten times the denoiser's 234.03 MB. At 20 steps that is 2.5% of the run; the 91.36 s the
  first pass logged was most likely the same decode on a shared box.
- **20 steps is half of 40 and costs half.** 1,822.84 s for the gallery's setting against about
  3,535 for the vendor's, and on the chalkboard the two images are the same picture. Whether that
  holds on harder prompts is what the gallery tests.

### A gallery at one fixed setting

One chalkboard is a legibility test, not a look at the model. So here is a gallery at one
setting — leejet's Q4_K, 512 x 512, 20 steps on the vendor's schedule, guidance off, seed 42 —
with prompts chosen to hit what this release claims to be good at: bilingual text, a dense
layout, counting and spatial relations, a photographic portrait, a named illustration style,
fine detail, and a transparent subject. I wrote the seven new prompts down before any of them
had run. Six ran, and every image they produced is here beside the chalkboard, including the
ones that went wrong.

Twenty steps rather than forty is a budget decision — half an hour an image instead of an hour
— and it rests on evidence rather than convenience: on the chalkboard the two are the same
picture, and the poster, the densest text in the set, came out exact at 20. I started a
40-step poster as a further check and cancelled it after 26 seconds once the 20-step one had
rendered every element, because it could only have confirmed what the 20-step image already
showed, at the cost of an hour the next job was waiting for.

<div className="grid gap-x-4 sm:grid-cols-2">

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen-image-2-1/cpu2-q4k-512-s20-cfg1.png?v=60ae542122d06b10"
  alt="A framed classroom chalkboard lit by a window on the left, four lines of white lettering reading '4 CPU', 'cores,', 'no', 'GPU', a potted plant on the sill and wooden desks in the foreground."
  caption={`'a chalkboard in a classroom, the words "4 CPU cores, no GPU" written in white chalk, a small potted plant beside it' — Q4_K, 20 steps, cfg 1.0, 512², seed 42, on CPU, 1,822.84 s. Every word right.`}
/>

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen-image-2-1/cpu2-gal-g2-poster-layout.png?v=f44d4145e0e4718a"
  alt="A minimalist cream poster. At the top, bold black text reads 'EDGE AI SUMMIT' with 'Bengaluru, 14 November 2026' in smaller type beneath it; along the bottom, three words in a row read 'Talks', 'Workshops', 'Demos'. The middle of the poster is empty."
  caption={`'A minimalist conference poster on a cream background. At the top, bold black text reads "EDGE AI SUMMIT". Below it, smaller text reads "Bengaluru, 14 November 2026". At the bottom, three words in a row read "Talks", "Workshops", "Demos"' — Q4_K, 20 steps, cfg 1.0, 512², seed 42, on CPU, 2,097.40 s. All five text elements exact.`}
/>

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen-image-2-1/cpu2-gal-g1-bilingual-sign.png?v=7a7e82ad26ed0189"
  alt="A small shop front at night under a glowing red sign. The sign reads 面馆 in white characters, then a garbled glyph, then 'BAR'; the word 'NOODLE' is missing. Steam drifts out of the lit doorway and the wet street in front reflects the light."
  caption={`'A small noodle shop at night with a glowing red sign above the door that reads "面馆 NOODLE BAR" in large letters, steam rising from the doorway, the wet street reflecting the sign' — Q4_K, 20 steps, cfg 1.0, 512², seed 42, on CPU, 2,166.00 s. The Chinese is right; 'NOODLE' is missing, a garbled glyph in its place.`}
/>

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen-image-2-1/cpu2-gal-g3-counting-spatial.png?v=62f864067731eac0"
  alt="On a glossy white surface, three red cups on the left fused into a single three-lobed object with shared walls, and two separate blue bowls on the right, each reflected in the surface."
  caption={`'Three red cups to the left of two blue bowls on a plain white table, photographed from the front' — Q4_K, 20 steps, cfg 1.0, 512², seed 42, on CPU, 2,047.88 s. Count and placement right; the three cups are fused into one piece.`}
/>

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen-image-2-1/cpu2-gal-g4-portrait.png?v=bfd74275e537338f"
  alt="A photographic close portrait of an elderly man with a deeply lined, weathered face, grey hair and a full grey beard, wearing a worn yellow raincoat with the hood up, in flat overcast light against a blurred harbour background."
  caption={`'A photorealistic portrait of an elderly fisherman with a weathered face and a grey beard, wearing a yellow raincoat, soft overcast light, shallow depth of field' — Q4_K, 20 steps, cfg 1.0, 512², seed 42, on CPU, 1,871.32 s. Every element of the prompt, and no visible artifacts at this size.`}
/>

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen-image-2-1/cpu2-gal-g5-transparent-glass.png?v=56089ed816cb2989"
  alt="Two panels. Left: a green glass bottle of olive oil with a cork stopper, composited over a grey checkerboard, but the background is solid white and the checkerboard shows only in a thin fringe around the bottle and one small blob at the lower right. Right: the image's alpha channel, white almost everywhere, with a thin black outline around the bottle and the same small black blob."
  caption={`'This is an RGBA image with transparency. A clear glass bottle of green olive oil with a cork stopper. The image has alpha channel and the background is transparent.' — Q4_K, 20 steps, cfg 1.0, 512², seed 42, on CPU, 1,819.53 s. Left, the output over a checkerboard; right, its alpha channel, white meaning opaque. The transparency failed: the background came back opaque white, and only a fringe around the bottle, 1.30% of the pixels, falls below alpha 32.`}
/>

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen-image-2-1/cpu2-gal-g7-hands-knitting.png?v=16429a24158010e3"
  alt="Two hands knitting a chunky grey-brown wool piece in warm light from a window behind, fuzzy fibres visible along the yarn, the knit worked in a thick cable pattern; two short metal needle tips meet between the hands and disappear into the wool."
  caption={`'A close-up photograph of two hands knitting a chunky wool scarf, individual yarn fibres and stitches visible, warm window light' — Q4_K, 20 steps, cfg 1.0, 512², seed 42, on CPU, 1,743.39 s. Plausible hands and visible fibres; the needles are short stubs that vanish into the wool.`}
/>

</div>

Right: the chalkboard; the poster, whose five text elements — a date among them — are all
exact; the Chinese on the noodle-shop sign; the count and the left-right placement of the cups
and bowls; and a portrait that takes every element of its prompt. Wrong: the sign's English
lost a word, a garbled glyph where `NOODLE` should be; the three red cups came out fused into
one three-lobed object; and the transparency request came back with an opaque white background,
the one outright failure. The knitting is the subtle one: plausible hands with no extra fingers
and fuzzy fibres on the wool, but needles that are short stubs vanishing into the yarn instead
of running through the hands.

I pre-registered seven new prompts and the gallery shows six. The seventh — `An ukiyo-e
woodblock print of a cat sitting on a wooden bridge in the rain, with Mount Fuji in the
background` — was cut for time before it ran, when the box slowed to 146 s a step; no image of
it was produced or discarded, and the gallery has no test of a named illustration style as a
result.

Each image took 1,819.53 to 2,166.00 s, with per-step medians from 88.57 to 103.90 s. The two
slowest are the two longest prompts, so some of that is text length, but not all of it: step
times drifted from the high 80s to 146 s inside single runs while nothing else ran in the VM,
steal time read 1–2%, and the samples I took showed no I/O wait and no memory reclaim. Whatever that was, it was
outside the machine I could see, and it is why every per-step figure in this section is a
median.

The transparency failure needs more than a shrug, because the model can do this: the two
1024² samples in the alpha section above came back 24.97% and 33.65% fully transparent, one
of them through a 4-bit GGUF. So I went down the list of causes. It is not the prompt: I used
the model card's exact wording, and the diffusers pipeline has no other switch for it — no
output mode, no different system prompt, all four VAE channels post-processed the same way.
And it is not sd.cpp's alpha path, which I could test for eight minutes of CPU. The same
prompt, seed, weights and binary at 256 x 256 came back with most of its background
transparent:

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen-image-2-1/cpu2-probe-g5-glass-256-s20.png?v=c2e59709ddba25d7"
  alt="Two panels at 256 pixels. Left: a tall green glass bottle with a cork over a grey checkerboard, which shows through most of the background, interrupted by several irregular opaque white blobs to either side of the bottle. Right: the alpha channel, black for most of the background, white for the bottle and for the same scattered blobs."
  caption="A diagnostic, not part of the gallery: the glass prompt at 256 x 256, Q4_K, 20 steps, cfg 1.0, seed 42, on CPU, 496.52 s. Left over a checkerboard, right the alpha channel. 47.23% of pixels at alpha 5 or below, the most common value 1 rather than 0, with saturated purple beneath — so sd.cpp's alpha path works — but opaque white blobs are painted over the background and the glass itself is opaque."
/>

47.23% of the pixels are at alpha 5 or below, though only 1.41% at exactly zero — the most
common value is 1 — and under them the RGB is saturated purple, the same signature this
article found under the teapot's transparent pixels. So where the model makes transparency,
sd.cpp carries it through. What went wrong in both of my glass images is something else:
opaque white painted *over* the transparent background, in blobs at 256² and across the whole
frame at 512². That is in what the model generated, not in how it was decoded, and what
remains unseparated is everything that differs between my runs and the two that worked: 20
steps rather than 40, 512 or 256 pixels rather than 1024, leejet's Q4_K rather than the
community Q4_K_M or the official INT8, and a different prompt and seed. A second seed at 256²
would split the seed from the rest in eight minutes; I stopped short of it because another job
was waiting for the CPU. So I do not know which it is, and nothing here says the model cannot do
transparency on a CPU — one configuration of it did not.

The alpha floor deserves a line of its own, because another model shows it too. In the probe
the transparent region sits almost entirely between 1 and 31 — 46.32% of the pixels, most of
them at exactly 1 — with only 1.41% at 0, where the two 1024² samples that worked had a quarter
and a third of the frame at exactly 0. Ming-Image's transparent backgrounds, measured in
[the piece on Ming-Image-0.1-Design](/articles/ming-image-0-1-design#the-alpha-channel-is-a-matte-again),
also sit at alpha 1 rather than 0. That model ships Qwen-Image-Layered's RGBA autoencoder, and
it is not the same autoencoder as this one — not by hash, which would prove nothing against a
bf16 conversion, but by config: Qwen-Image-Layered's has 16 latent channels at 8x compression,
Qwen-Image-2.1's has 64 at 16x. Two different RGBA autoencoders both put a transparent
background at 1. Whether that floor comes from the models or from how a decoded value is
rounded to 8 bits I have not checked, and the practical consequence is the same either way:
code that treats `alpha > 0` as content will find a full-canvas object.

### The first pass, and what it got wrong

The first pass ran three configurations at 8 steps on sd.cpp's default schedule, and the two
that are not already in the grid above are here as the baseline they are.

<div className="grid gap-x-4 sm:grid-cols-2">

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen-image-2-1/cpu-256-q2k-8step.png?v=9300f10b35a9d2de"
  alt="A 256-pixel-square generated image of a dark classroom chalkboard. The words '4 CPU' are legible in white chalk at the top; two further lines of chalk writing are word-shaped but not readable. Below the board is a wooden ledge with a small potted plant on the left and a stack of books on the right."
  caption="First pass: leejet Q2_K, 256 x 256, 8 Euler steps, sd.cpp's default schedule, cfg 1.0, seed 42. Generated on CPU in 203.32 s. '4 CPU' legible, the rest not."
/>

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen-image-2-1/cpu-256-q2k-8step-cfg4.png?v=1d3dc21e4988e3fa"
  alt="A 256-pixel-square generated image of a very dark chalkboard carrying two lines of faint blue-white chalk: the first reads '4 CPU cores' with the words run together, the second reads 'no GPU'. A small potted plant stands on a wooden ledge below."
  caption="First pass: the same run with guidance at cfg 4.0 — a setting the vendor's pipeline does not use by default. 474.60 s on CPU. Both lines discernible, the frame badly underexposed."
/>

</div>

It drew three conclusions, and the second pass is the test of each.

- **"Guidance buys text fidelity and resolution does not."** Withdrawn. At 8 steps that was
  what the pictures showed; at 20 steps, with guidance off, 512 x 512 renders every word at
  either quantisation. The underexposed cfg 4.0 frame was not a price legibility requires; at a
  fair step budget legibility comes with guidance off. I did not run cfg 4.0 past 8 steps, so
  whether that darkness came from the steps or the guidance I cannot say.
- **"4.53x the per-step time for 4x the tokens — close to linear, slightly over."** Wrong in
  its detail. That 512² run was sharing the box: sd.cpp averaged 2.91 busy cores out of 4,
  against 3.51 in the 256² run beside it. Against the clean second-pass median for the same
  Q2_K weights at 512², 79.43 s, the ratio is **3.58x**. The 256² run itself only had 3.51
  cores busy, so the honest reading is roughly linear, not over.
- **"Disk, not RAM, was the binding constraint."** True for that pass, on 10.93 GB of free
  disk. On the second, disk was not the limit and a memory cgroup was.

What it got right survives: the pipeline runs end to end with no GPU at all, the text encoder
costs almost nothing, and the VAE's activation memory at small resolutions dwarfs the
denoiser's — 613.19 MB against 65.24 MB at 256², 2,450.50 MB at 512².

### What this does and does not settle

It settles the floor, and it moves the answer from "it runs" to "it works". No GPU anywhere in
these runs, no Vulkan, no unified memory: 9.90 GB on disk and 11.40 GiB resident for the 4-bit
pipeline, and at the vendor's own settings a 512² image in an hour that gets every word on the
board right. At 20 steps it is half an hour. The 2 GB and 3.05 GiB figures above ask how little
of a GPU you can get away with; this asks whether you need one, and at 512² the answer is no, if
you have an hour and the text is what you care about. The gallery says the same thing more
broadly and less kindly: at 20 steps the dense poster and the Chinese sign text held, and the
portrait took every element of its prompt. It failed on three things — a dropped English word,
three objects fused into one, and a transparency request that did not come back transparent —
and one seed each cannot pin those on the weights, the resolution or the model.

What it does not settle is how far below the full model this is. Every image here is 4-bit or
2-bit, at a quarter of the pipeline's default pixel count and a sixteenth of the model card's,
and nobody has published a bf16 image at the same seed to hold them against — the 3090 report
has no quality comparison either. It does not settle guidance at a fair step budget, because I
did not run cfg above 1.0 past 8 steps. It is one seed per prompt throughout, so no single
success or failure here is a rate. And it does not reach native resolution: the VAE's
compute buffer is 2,450.50 MB at 512² and grows with the pixel count, so on this box's
13.36 GiB cgroup a 1024² decode would likely need sd.cpp's tiled VAE — an inference from the
buffer sizes, not something I ran. The time that would cost is in the falsifiers, as an
extrapolation rather than a number.

## What is missing

Shortest useful list, all verified by absence:

- **No technical report.** No arXiv preprint, no PDF, no architecture section beyond eight
  bullet points in a README.
- **No training data disclosure.** Nothing about corpus, scale, filtering, or the licensing
  of what it learned from. The flagship multi-reference demo is a generated group photograph
  assembled from six publicity portraits of identifiable real people, and the release has
  nothing to say about that either.
- **No eval for anything new.** The benchmark covers text-to-image; the transparency,
  editing and reference-composition claims are supported by curated examples only. No
  GEdit, no ImgEdit, no alpha-matting metric, no identity-similarity number.
- **No first-party quantisation.** BF16 shards only, plus an FP32 VAE.
- **No inference code in the repo.** The GitHub repository ships the prompt rewriter and
  nothing else; the model runs through third-party libraries from day one, which is a
  reasonable choice and worth knowing before you file an issue.
- **No limitations, bias or intended-use section** in the model card. Not one line.
- **No separate licence for the VAE.**

## The take

Qwen-Image-2.1 is a good piece of engineering and a worse release than the six that
preceded it. The architecture work is genuine and legible from the files: a single-stream
DiT at a third of the previous denoiser's size, a shared modulation projection and
`mlp_ratio: 3` that together account for 3.69B of the parameters it does not have, a 16x
RGBA autoencoder that makes transparency a property of the latent space instead of a second
model, and a block-causal mask whose prefix cache is exact because someone thought to
modulate the condition from `t = 0`. That last one is the idea I would steal, and two days
of other people's benchmarks have not dented it: 2.55x measured against 2.62x predicted,
bit-exact output, and the same effective throughput on both paths. The engineering is the
part of this release that holds up under someone else's stopwatch.

What surrounds it is thinner than the version number suggests. One aggregate score on the
vendor's own benchmark, on the vendor's own judge, on the Chinese-prompt run, measuring the
capability that did not change. No report, no data, no numbers for the new features, no
first-party quantisation. And a licence that moves a year-old Apache-2.0 family to
non-commercial research terms while the announcement keeps saying "open-source" — which is
the one change here that decides whether you can use it at all, and the only one the blog
post does not mention.

<ChangeMyMind>
  <Falsifier claim="The denoiser is exactly 7,115,124,736 parameters and contains no second text stream.">
    Sum the shapes in
    `transformer/diffusion_pytorch_model.safetensors.index.json` and the two shard headers.
    A `txt_mlp`, `add_q_proj` or `to_add_out` tensor anywhere in those 297 names, or a
    total that does not reconcile with the index's `total_size` of 14,230,249,472 bytes at
    two bytes per BF16 parameter, falsifies it.
  </Falsifier>

  <Falsifier claim="The published quantitative comparison is the vendor's benchmark, judged by the vendor's model, on Chinese prompts.">
    A third-party evaluation of this checkpoint on a harness Qwen did not write, or an
    English-prompt Qwen-Image-Bench leaderboard that reproduces the chart's ordering, would
    change how much weight the 60.28 carries. The footnote I am relying on is on the
    dataset card at `huggingface.co/datasets/Qwen/Qwen-Image-Bench`; if it is edited or
    the English run lands, this section is stale.
  </Falsifier>

  <Falsifier claim="The prefix KV cache is worth little for plain text-to-image and a lot for multi-reference editing.">
    Originally I asked for a measured step-time breakdown showing the cached path saving
    substantially less than the token share predicts. Three now exist and none of them do;
    the A100 one lands at 97% of the arithmetic bound. What would still falsify this is the
    other tail: a measured 2-reference DiT speedup materially *above* 2.62x at 1024px and 40
    steps, which the mask does not permit and would mean the cache is doing something my
    pair counts do not model. A text-to-image-only speedup above about 1.05x would do the
    same.
  </Falsifier>

  <Falsifier claim="2.55x reconciles with the architecture because the DiT is compute-bound at these sequence lengths.">
    The whole reconciliation rests on 50.57 s and 19.86 s being DiT time on a warmed A100
    at 2 references, 1024px and 40 steps, with no classifier-free guidance. If any of those
    is wrong — if the timing is end-to-end, if guidance was on and doubled the work, if the
    references were resized to something other than about a megapixel — the predicted ratio
    moves and the agreement is a coincidence. The same run at a much larger target, where
    the target dominates the sequence, should converge toward 1.0x; if it does not, my model
    of where the saving comes from is wrong.
  </Falsifier>

  <Falsifier claim="Qwen-Image-2.1 is the first Qwen image model under a non-commercial licence.">
    A Qwen image checkpoint on the Hub or ModelScope, dated before 20 September 2026, whose
    `LICENSE` is not Apache-2.0. I checked the eight repos in the table and nothing else.
  </Falsifier>

  <Falsifier claim="Qwen-Image-2.1's transparency is a matte, not translucency — in native-resolution output, not just the published web asset.">
    Two 1024px generations, through two different quantisations, put 0.55% and 0.48% of
    pixels between alpha 32 and 223 and 99.1% within seven levels of an extreme. Both are
    opaque subjects on empty backgrounds, which is the easy case. A native 2048px glass,
    smoke or hair prompt with meaningful midrange mass would falsify this, and is the test I
    still want; so would a matting metric against a ground-truth alpha, which nobody has run
    on this checkpoint. My own glass prompt, on four CPU cores at Q4_K, 512² and 20 steps, did
    not get as far as testing it: the background came back opaque white rather than
    transparent, with 87.47% of pixels at alpha 255 and 10.75% at 224–254, 1.30% below 32
    in a fringe around the bottle, and 0.48% in the midrange. The glass body is opaque in the alpha channel too, but
    when the model has not made the background transparent that says nothing about
    translucency. Every other CPU image, none of which asked for transparency, also came back
    RGBA and at least 99.9% within five levels of opaque.
  </Falsifier>

  <Falsifier claim="The first pass's smudged text was its 8-step budget — not the 2.88-bit weights, and not sd.cpp's default schedule.">
    One prompt and one seed, changing one thing at a time: the schedule at 8 steps changed
    nothing, 8 steps to 20 at the same Q2_K fixed every word, and Q2_K to Q4_K at 20 steps did
    not change legibility. What would falsify it is the same experiment on other seeds going the
    other way — a Q2_K image at 20 steps with broken text where Q4_K's is clean, or an 8-step
    image on the vendor's schedule that reads correctly where sd.cpp's default does not. What it
    cannot say is how either quantisation compares with bf16 at the same seed; nobody has run
    that reference, and I did not have the memory to.
  </Falsifier>

  <Falsifier claim="A 1024 x 1024, 40-step image at the vendor's settings would take roughly 3.5 to 4.5 hours on these four cores.">
    An extrapolation, not a measurement, and it replaces the "ten hours" the first pass gave,
    which assumed guidance the vendor does not use. It is 87.09 s per step at 512² and Q4_K,
    multiplied by the 256-to-512 step-cost ratio — 3.58x on clean numbers, 4.53x on the first
    pass's shared-box ones — then by 40 steps, plus encode and decode. Attention grows faster
    than the rest with sequence length, so the upper end is the likelier one. A measured 1024²
    step on this box under about 280 s or over about 450 s would mean the scaling is not the
    shape I assumed.
  </Falsifier>

  <Falsifier claim="Inside a memory-limited container, reading rather than mapping the weights is what broke the Q4_K pipeline, not the pipeline's size.">
    One OOM kill without `--mmap` and one clean run with it, same weights, same resolution, same
    cgroup. The mechanism I have given — page cache and a private copy both charged — fits the
    kernel log and the 413 s of system time, but I did not isolate it further. A read-mode run
    that succeeds in the same cgroup once the page cache is dropped beforehand would show the
    kill was about cache timing rather than double-charging, and would soften the advice.
  </Falsifier>

  <Falsifier claim="The text encoder inside Qwen/Qwen-Image-2.1 is stock Qwen3-VL-8B-Instruct, which Qwen also publishes under Apache-2.0.">
    Evidence by sampling, not exhaustion: all 750 tensor names, shapes, dtypes and byte sizes
    match, and six tensors spread across the model are byte-identical over their first 4 MB.
    Any tensor whose bytes differ falsifies it, and a fine-tune of a few layers would hide from
    my sample. Hashing every tensor in both repositories over range requests would settle it
    for about 35 GB of transfer, which I did not spend.
  </Falsifier>

  <Falsifier claim="At 512 x 512, 20 steps gives up little against the vendor's 40.">
    Measured side by side on one prompt, the chalkboard, where the two are the same picture;
    the poster, the densest text in the gallery, came out exact at 20, but I cancelled its
    40-step twin. A gallery prompt that renders visibly better text, anatomy or structure at
    40 steps than at 20, same seed, would falsify it and would make the gallery an
    under-sampled view of the model.
  </Falsifier>

  <Falsifier claim="The failed transparent background is in what the model generated, not in how sd.cpp decoded it.">
    At 256² the same prompt, seed, weights and binary put 47.23% of pixels at alpha 5 or
    below with purple beneath, so the fourth channel survives the decode; the 512² failure is
    opaque white painted over the background. The same prompt, seed and step count at 512²
    through diffusers or ComfyUI coming back transparent would falsify it, and would put the
    fault back in sd.cpp's path after all.
  </Falsifier>

  <Falsifier claim="The ncnn conversion is the full-width release minus the unused lm_head and a halved VAE.">
    Sum the file sizes at `huggingface.co/nihui-szyl/qwen-image-ncnn` and subtract
    1,244,659,712 bytes for the `lm_head` and 675,480,808 for the VAE's fp32-to-2-byte cast
    from the release's 33,115,458,480. I get 31,195,317,960 against a shipped
    31,191,738,224, which is 0.01% apart. A different residual — or a bin whose leading tag
    is not `0x01348B83` — means it dropped or converted something else, and the "full bf16,
    no quantisation" claim needs restating.
  </Falsifier>
</ChangeMyMind>


## Update — 2 October 2026

Two ecosystem pieces landed after the 23 September update, both on the autoencoder this article measured rather than the denoiser. [madebyollin](https://huggingface.co/madebyollin) posted two of them. [`texture-fix-vae-for-qwen-image-2.1`](https://huggingface.co/madebyollin/texture-fix-vae-for-qwen-image-2.1) is the release's own VAE finetuned "to produce cleaner textures with no checkerboard artifacts" (reported) — a drop-in replacement on the same latent API, so the 337M-parameter autoencoder this article measured gets a better decoder without touching the 7.1B denoiser. [`taeqi2_1`](https://huggingface.co/madebyollin/taeqi2_1) (MIT) is a *tiny* distilled autoencoder on the same 16× / 64-channel / RGBA latent API — the exact geometry this article read off the config — meant for real-time preview during sampling and for resource-constrained encode/decode. Both confirm the article's aside that autoencoders get lifted out of releases and reused more than denoisers do: here the VAE is simultaneously fixed and shrunk while the denoiser is left alone.

The second item is nihui's ncnn/Vulkan port, whose earlier "2 GB" claim this article already took apart above — 2 GB is the GPU's share of a host budget, not the model's footprint. A follow-up post reports the port now rendering full BF16 at 1024×1024 with speed unchanged, on 2 GB of VRAM and 4 GB of system RAM (reported). The native-size result closes the open question the project's own requirements had left, but the 4 GB RAM figure is lower than the ~16 GB host budget this article computed from those same requirements, so it is a fresh, unreconciled claim from the author's teaser, not a number reproduced here.
---

*Sources, all read directly: the [Hugging Face repo](https://huggingface.co/Qwen/Qwen-Image-2.1)
(`model_index.json`, `transformer/config.json`, `text_encoder/config.json`,
`vae/config.json`, `scheduler/scheduler_config.json`, both safetensors indexes, the tensor
headers by range read, and `LICENSE`); the
[ModelScope mirror](https://modelscope.cn/models/Qwen/Qwen-Image-2.1), whose card is
byte-identical to the Hub's; the [GitHub repo](https://github.com/QwenLM/Qwen-Image-2.1);
the [release blog](https://qwen.ai/blog?id=qwen-image-2.1); the diffusers implementation of
`QwenImage21Transformer2DModel` and `AutoencoderKLQwenImage21`; the
[Qwen-Image-Bench dataset](https://huggingface.co/datasets/Qwen/Qwen-Image-Bench) and
[Q-Judger model](https://huggingface.co/Qwen/Qwen-Image-Bench) cards, and their paper
([arXiv:2605.28091](https://arxiv.org/abs/2605.28091)); and the third-party
[vLLM-Omni recipe](https://recipes.vllm.ai/Qwen/Qwen-Image-2.1).*

*Added 22 September, all likewise read rather than summarised: the diffusers implementation
([`transformer_qwenimage21.py`](https://github.com/huggingface/diffusers/blob/main/src/diffusers/models/transformers/transformer_qwenimage21.py),
`pipelines/qwenimage21/pipeline_qwenimage21.py` and both API doc pages) for the cache
mechanics and the `use_kv_cache` default; a public thread by Sayak Paul for the A100 DiT
timings and the memory delta; [flyweight PR #91](https://github.com/yairpatch/flyweight/pull/91)
for the RTX 5070 Ti end-to-end numbers and the bit-exactness check; the
[deployment guide](https://github.com/alesha-pro/tools/tree/main/qwen-image-2.1) and its
GGUF companion, whose `install/models.json` manifests, `docs/validation.md` reports and
committed example PNGs are the source of every 3090 figure and both alpha histograms;
[`abenzerps/Qwen-Image-2.1-GGUF`](https://huggingface.co/abenzerps/Qwen-Image-2.1-GGUF) and
[`Comfy-Org/Qwen-Image-2.1`](https://huggingface.co/Comfy-Org/Qwen-Image-2.1) for licence
metadata; and [`nihui/qwenimage-ncnn-vulkan`](https://github.com/nihui/qwenimage-ncnn-vulkan)
with [`nihui-szyl/qwen-image-ncnn`](https://huggingface.co/nihui-szyl/qwen-image-ncnn), whose
file sizes came from the Hub API and whose bf16 tag came from an 8-byte range read checked
against [`ncnn/src/modelbin.cpp`](https://github.com/Tencent/ncnn/blob/master/src/modelbin.cpp).
The first three figures are the release's own, served locally and flattened; the fourth is my
own three-panel composite of a PNG from that validation run, and both checkerboards are mine.
I measured the alpha histograms myself.*
