Diffusion did not stop working — it changed shape. The denoiser became a transformer over patchified latents, the timestep became a scale, a shift and a gate, and the 1000-step noise schedule became a straight line between a data point and a noise draw. Same MSE, same training loop, 20 steps instead of 1000 — and SD3, FLUX, Z-Image and every 2026 text-to-image model on the other side.
DiT cuts a latent into patches and runs a ViT-shaped transformer over the tokens; adaLN-Zero turns the timestep into a scale, a shift and a gate that start at zero, so a deep stack starts as a pass-through. MMDiT keeps text and image in separate weight streams inside one joint attention, and FLUX follows with double-stream blocks at the top and cheaper single-stream blocks at depth.
32×32×4 latent → 256 tokens · one block 167,328 params · the tiny model 715,02002 / ONE STRAIGHT LINE
Noise and data, joined by a line.
Rectified flow defines x_t = (1 − t)·x_0 + t·ε and trains the network to predict the direction of travel, v = ε − x_0, which is constant along that line. Sampling starts at noise and walks the field back with Euler's method — 20–30 steps instead of DDPM's 1000, and 1–4 after distillation. The loss starts at E‖ε − x_0‖² = 1 + E[x_0²] ≈ 1.036, measured on this page's blob stand-in.
1000 DDPM steps → 20–30 Euler → 1–4 distilled · loss starts at 1.035603 / WHAT CHANGES, WHAT DOESN'T
The sampler and the network. Not the loss.
Both objectives are a plain MSE at a random timestep, both still use classifier-free guidance — rectified-flow models at a lower scale (3.5–5 against SD1.5's 7.5, and 0 for distilled schnell). What changed is the network and the trajectory. The shelf is dated: 6.2B to 32.2B, Apache-2.0 to research-only, and every row a DiT.
Diffusion became a transformer regression problem: cut the latent into tokens, turn the timestep and the prompt into a scale, shift and gate, predict the velocity along the straight line from a data point to a noise draw, then integrate that field backwards — and because the target is a direction rather than a noise level, the same trained weights can be walked in 1000 steps, in 20, or in 4 after distillation.
By the end you will be able to do the patchify arithmetic out loud (4,096 values → 256 tokens → 65,536 attention pairs per head) and say why 2×2 is the default; read an AdaLN-Zero block and explain why it starts as the identity; justify MMDiT’s two streams and FLUX’s double-to-single schedule; write the six-line rectified-flow train step and the five-line Euler sampler and say when each number appears (loss ≈ 1.036 at init, 715,020 parameters, straightness 1.2423 against DDIM’s 1.3932); and choose a checkpoint from a dated table by step count, size and licence instead of by demo image.
01
THE U-NET'S REPLACEMENT
The denoiser changed. The job did not.
Lesson 10 built a DDPM with a U-Net denoiser — the recipe that produced Stable Diffusion 1.5, SD 2.1 and DALL-E 2. Every state-of-the-art text-to-image model since 2024 has kept the objective and thrown the network away.
A diffusion model has two parts: a fixed recipe that destroys data with noise, and a learned network that predicts what the recipe did. Lesson 10’s network was a U-Net — a convolutional encoder–decoder with skip connections, borrowed from segmentation — and its schedule was a 1000-step ladder of small Gaussian additions. Both choices worked, and both are gone.
The replacements arrived in four steps. DiT (Peebles and Xie, 2023) kept the diffusion loss and swapped the U-Net for a transformer operating on patchified latents. MMDiT(Stable Diffusion 3, 2024) gave text and image their own weight streams and let them meet inside one attention. FLUX (Black Forest Labs, 2024) starts double-stream like SD3 and finishes single-stream to buy depth more cheaply. And the 2025 generation — Z-Image at 6.2B, Qwen-Image at 20.4B — are single-stream and hybrid DiTs at scales where efficiency, not raw size, is the pitch.
Four architectures, one job: predict the diffusion target from a noisy latent and a timestep. What changed is where the mixing happens — local convolutions, global attention, or global attention with a per-modality vocabulary. Every 2024+ text-to-image model in the lesson’s landscape table is somewhere on this line.
the recipe, unchanged since Lesson 10
destroy data with noise → learn to predict the damage → run it backwards
what changed
2020 U-Net local convolutions, 1000 step schedule
2023 DiT latents → patches → tokens → transformer blocks
2024 MMDiT text tokens and image tokens, joint attention
2024 FLUX double-stream blocks, then shared single-stream blocks
2025+ single-stream at 6–20B — scale is not the only lever
the loss never changed
DDPM predict the noise ε, sampled at a random t
flow predict the velocity ε − x_0, sampled at a random t
both a plain MSE, one network, no adversary
Why the swap is not a fashion: a convolution only ever sees a local neighbourhood, so a prompt that says “the red cube is behind the blue sphere” has to survive many layers of stacking before the two objects meet. Self-attention over 256 patch tokens lets every token read every other token in one layer, and the token format is the same one a text encoder emits — which is why the same family of architecture that models language models images.
The shift matters because it is the reason text-to-image became controllable (inpainting, editing and control nets are all the same denoiser nudged in the loop), prompt-accurate (SD3 and SD4 render text in images), and production-fast (a rectified-flow model samples in 20–30 steps, and its distilled sibling in 1–4). Understanding DiT plus rectified flow is understanding the 2026 generative-image stack.
02
CUT THE LATENT INTO TOKENS
A transformer reads sequences. So the image becomes one.
The diffusion target lives on a grid — a 32×32×4 latent, say — and a transformer wants a list of vectors. Patchify is the interface between the two, and the patch size you choose decides how much attention costs.
Start from the compressed representation Lesson 11 introduced: a 512×512×3 image is 786,432 values, and a variational autoencoder shrinks it to a 64×64×4 latent — 16,384 values, 48× fewer dimensions. Diffusion operates there. Now cut that latent into non-overlapping patches, flatten each patch into a vector and project it to the model width. A 32×32×4 latent — the size the DiT paper used — becomes 256 tokens of 16 values each, raised to 96 dimensions by one convolution.
This is the ViT trick from Lesson 14, reused verbatim: a Conv2d whose kernel size and stride are the patch size does the cutting and the projecting in one operation. What changes between a ViT and a DiT is only what the tokens are asked to predict — a class label there, a velocity here.
the latent (Lesson 11) 512 × 512 × 3 pixels → 64 × 64 × 4 latent
786,432 values 16,384 values (48× smaller)
patchify the 32 × 32 × 4 teaching latent
4,096 values → 2 × 2 patches → 16 × 16 = 256 tokens
each token carries 2 · 2 · 4 = 16 values → Linear/Conv → 96 dims
patch size decides the bill (one attention head, one head of three)
patch tokens values per token token × token pairs
1 × 1 32 × 32 = 1,024 4 1,048,576
2 × 2 16 × 16 = 256 16 65,536 ← DiT / SD3 / FLUX
4 × 4 8 × 8 = 64 64 4,096
double the patch side → a quarter of the tokens → 1/16 the attention cost
the same 32 × 32 × 4 latent, three patch sizes — the table the explorer lab animates
patch
tokens
values per token
attention pairs per head
1 × 1
32 × 32 = 1,024
4
1,048,576
2 × 2
16 × 16 = 256
16
65,536
4 × 4
8 × 8 = 64
64
4,096
the interface: patch in, patch outpython
class TinyDiT(nn.Module):
def __init__(self, image_size=16, patch_size=2, in_channels=3, dim=96, depth=4, heads=3):
super().__init__()
self.patch_size = patch_size
self.num_patches = (image_size // patch_size) ** 2
self.patch = nn.Conv2d(in_channels, dim, kernel_size=patch_size, stride=patch_size)
self.pos = nn.Parameter(torch.zeros(1, self.num_patches, dim))
def forward(self, x, t):
n = x.size(0)
x = self.patch(x) # [B, 4, 32, 32] → [B, dim, 16, 16]
x = x.flatten(2).transpose(1, 2) + self.pos # [B, 256, dim]
t_emb = self.time_mlp(timestep_embedding(t, self.pos.size(-1)))
for blk in self.blocks:
x = blk(x, t_emb) # adaLN + attention + MLP, × depth
x = self.norm_out(x)
x = self.head(x) # [B, 256, patch² · channels]return self._unpatchify(x, n) # [B, 4, 32, 32] againdef _unpatchify(self, x, n):
p = self.patch_size
h = w = int(self.num_patches ** 0.5)
x = x.view(n, h, w, p, p, -1).permute(0, 5, 1, 3, 2, 4)
return x.reshape(n, -1, h * p, w * p)
Adapted from the source's main.py. The positional table is learned and added after patchify: 16×16 = 256 tokens at dim 96 is 24,576 parameters — small, and the only place the model is told where a patch came from.
Patchify, modulate, attend, unpatch
The U-Net is gone: a latent is cut into patches, every patch becomes a token, and the timestep reaches the block as a scale, a shift and a gate. Step through the six stages, or change the patch size and watch the token count move.
The arithmetic to internalise: doubling the patch side divides the token count by four, and attention cost is tokens². At patch 2 on a 64×64 latent you get 1,024 tokens and 1,048,576 token pairs — the reason SD3 and FLUX chose exactly this lattice.
Quick check
You keep the 32×32×4 latent but switch from 2×2 patches to 4×4. What exactly does the model lose and gain?
03
CONDITION BY MODULATION
The timestep does not get added. It gets to set the dials.
A DDPM adds a time embedding to a feature map and hopes for the best. A DiT uses the conditioning to predict a scale, a shift and a gate for every feature — and initialises them to zero so the block starts as a pass-through.
The network has to know two things beyond the noisy latent: how much noise is on it (the timestep) and what it is supposed to draw the text and the class. Both arrive as one conditioning vector. Instead of adding that vector into the tokens, a DiT block uses it to modulate them:
conditioning cond = time_mlp(sinusoidal(t)) [B, 96]
(+ text embeddings in a real model)
adaLN-Zero scale, shift, gate = MLP(cond).chunk(3) three × [B, 96]
h = LayerNorm(x) · (1 + scale) + shift
x ← x + gate · Attention(h)
… then the same trick again before the MLP
at initialization MLP weights = 0 → scale = shift = gate = 0
h = LayerNorm(x) (the norm, unchanged)
x ← x + 0 · Attention(h) = x ← an identity block
parameter check, dim 96, one MLP
Linear(96 → 288) = 96 · 288 + 288 = 27,936
two per block = 55,872 → 33.4% of a 167,328-parameter block
Two properties earn this design its place in every modern diffusion transformer. First, the modulation is per feature, per token: scale, shift and gate are 96-dimensional vectors broadcast across the token sequence, so the block can treat the same token differently at t = 900 and t = 50 — which is exactly what a denoiser needs. Second, the zero initialisation. With scale, shift and gate at zero, the block computes the identity: the LayerNorm still normalises, but the gate multiplies the attention output by zero before it is added back. Gradients then move the block away from doing nothing only when that helps, which is what makes 28-block and 38-block stacks trainable.
Notice the structure: every block has two modulated sublayers, one wrapping attention and one wrapping the MLP, exactly like a pre-norm transformer with two residual adds. A DiT block is a transformer block with the conditioning wired into the normalisation instead of the input.
The idea has an ancestor: FiLM, the feature-wise modulation layers that U-Net diffusion models used to inject the timestep. FiLM also predicts a scale and a shift — but from the timestep alone, applied to a whole feature map, with no gate and no zero initialisation. AdaLN-Zero adds the two details that matter: the modulation is predicted from the full conditioning vector (time plus text, not time alone), and the gate lets the block choose how much of its own output to admit. The first detail is why a DiT does not need cross-attention to obey a prompt; the second is why it is trainable at depth.
The conditioning that starts as nothing
A DiT block does not add the timestep to its features — it uses the timestep to predict a scale, a shift and a gate. At initialization those are exactly zero, so the block is an identity map and a deep stack trains without exploding. Move the sliders and watch the same token change.
token mean
0.5500
mean of the 4 features
token std
0.2937
√(mean of squared deviations)
scale
0.00
from the cond MLP
shift
0.00
from the cond MLP
gate
0.00
from the cond MLP
one token’s four features through h = LayerNorm(x)·(1 + scale) + shift, then the gated residual x + gate · Attention(h)
feature
x
norm
(1+scale)·norm + shift
Attention(h) stand-in
gate · attn
output = x + gate·attn
feature 1
0.90
1.1918
1.1918
0.20
0.0000
0.9000
feature 2
0.40
-0.5108
-0.5108
-0.10
0.0000
0.4000
feature 3
0.75
0.6810
0.6810
0.40
0.0000
0.7500
feature 4
0.15
-1.3620
-1.3620
0.05
0.0000
0.1500
output − input
every row is 0.0000 — the block is an identity map, which is exactly what zero initialisation buys
AdaLN MLP: Linear(96 → 288) = 27,936 params · 16.7% of the 167,328-parameter block
two AdaLN MLPs per block = 55,872 params · 33.4% of the 167,328-parameter block
attention = 37,248 params · 22.3% of the 167,328-parameter block
the MLP = 74,208 params · 44.3% of the 167,328-parameter block
Zero init is the DiT version of ResNet’s zero-initialised residual: the block starts as the identity function and training moves it away. The real thing predicts a separate scale, shift and gate per feature (96 numbers each), not one number for the whole token — the board uses one scalar trio so you can check the arithmetic by hand. The attention output is a fixed stand-in.
the source's block, in fullpython
class AdaLNZero(nn.Module):
"""
Adaptive LayerNorm with a gate. Predicts (scale, shift, gate) from the conditioning.
Init such that the whole block starts as identity ("zero init").
"""def __init__(self, dim, cond_dim):
super().__init__()
self.norm = nn.LayerNorm(dim, elementwise_affine=False)
self.mlp = nn.Linear(cond_dim, dim * 3)
nn.init.zeros_(self.mlp.weight)
nn.init.zeros_(self.mlp.bias)
def forward(self, x, cond):
scale, shift, gate = self.mlp(cond).chunk(3, dim=-1)
h = self.norm(x) * (1 + scale.unsqueeze(1)) + shift.unsqueeze(1)
return h, gate.unsqueeze(1)
class DiTBlock(nn.Module):
def __init__(self, dim=96, heads=3, mlp_ratio=4, cond_dim=96):
super().__init__()
self.adaln1 = AdaLNZero(dim, cond_dim)
self.attn = nn.MultiheadAttention(dim, heads, batch_first=True)
self.adaln2 = AdaLNZero(dim, cond_dim)
self.mlp = nn.Sequential(
nn.Linear(dim, dim * mlp_ratio),
nn.GELU(),
nn.Linear(dim * mlp_ratio, dim),
)
def forward(self, x, cond):
h, gate1 = self.adaln1(x, cond)
a, _ = self.attn(h, h, h, need_weights=False)
x = x + gate1 * a
h, gate2 = self.adaln2(x, cond)
x = x + gate2 * self.mlp(h)
return x
The source's AdaLNZero and DiTBlock, unchanged. LayerNorm is created with elementwise_affine=False because the affine transform is not learned per layer — it is predicted per timestep. The gate multiplies the sublayer output, not the residual stream, so a zero gate leaves the residual stream untouched.
04
TWO STREAMS, ONE ATTENTION
Text is not a picture. Stop making them share weights.
Stable Diffusion 1.5 concatenated a text embedding onto a convolution stack and trained the two together. SD3’s MMDiT gives each modality its own weights and lets them meet in attention — the single change credited with prompt adherence and text rendering.
A text token from a language model and a patch token from a VAE latent live in different distributions. Forcing them through the same linear layers makes the network spend capacity translating between the two. MMDiT keeps two parallel streams — text tokens in one, image tokens in the other, each with its own projections, its own normalisation and its own MLP — and joins them only inside the attention operation, where every token of one stream can read every token of the other.
That is the whole idea, and it is worth separating from the text encoder story. The encoder decides what the words mean: SD3 uses three of them (two CLIP models plus T5-XXL), and FLUX two (CLIP-L plus T5-XXL). The block decides how those meanings steer the image. T5-XXL alone is ≈4.7B parameters — more than twice the whole 2B SD3 Medium denoiser — so with a DiT at its centre, the prompt-following half of a text-to-image system is not the diffusion model.
Separate weights preserve each modality’s statistics; one shared attention lets them interact. SD3 calls this MMDiT. The figure shows the first of FLUX’s two block types — its later blocks concatenate the two rows and share every weight, which is why FLUX is described as double-stream then single-stream.
concat-and-share (SD 1.5 era)
[ text ⊕ image tokens ] → one linear layer → one attention
the two modalities fight over the same weights
two streams, joint attention (SD3 · MMDiT)
text tokens → W_text^q, W_text^k, W_text^v ┐
├→ one attention matrix
image tokens → W_img^q, W_img^k, W_img^v ┘ → every token reads every token
FLUX (2024) — the hybrid
blocks 1…N double-stream: text and image keep separate weights
blocks N+1…end single-stream: concatenate the streams, share every weight
result depth without paying two full stacks to the end
classifier-free guidance still applies
training drop the text ~10% of the time → learn an unconditional model too
inference v = v_uncond + w · (v_cond − v_uncond)
w ≈ 3.5–5 rectified-flow models (SD3 · FLUX-dev) ← lower than SD1.5's 7.5
w = 0 FLUX.1-schnell — guidance was baked in by distillation
Rectified flow changes the sampler, not the conditioning. The classifier-free guidance mix you met in Lesson 11 works identically on a velocity prediction — you mix v instead of ε — and modern models use a lower guidance scale, 3.5–5 rather than 7.5, because a rectified-flow model follows the prompt more tightly without help. The distilled variants drop guidance entirely: FLUX.1-schnell runs at guidance_scale = 0.0 in the source’s four-step snippet.
Quick check
SD3's MMDiT keeps separate weight streams for text and image but shares one attention. What does the sharing buy that separate attention would not?
05
STRAIGHT LINES, NOT SCHEDULES
Stop pouring noise in. Draw a line instead.
DDPM defines a 1000-step corruption recipe and learns to undo it one small step at a time. Rectified flow throws the recipe away: pick a data point, pick a noise draw, connect them with a straight line, and learn the direction of travel along it.
Here is the whole idea in one line of arithmetic. Take a clean sample x_0 and a noise draw ε. Define a point that slides between them as t goes from 0 to 1: x_t = (1 − t)·x_0 + t·ε. At t = 0 you are holding the data; at t = 1 you are holding pure noise; in between you are on the straight line joining them. That is the entire forward process — no beta schedule, no Markov chain, no cumulative product.
The training target is the direction of that line, v = ε − x_0 — the velocity. It does not change as you move along the line, because the line is straight. The network’s job is to answer one question at any point: which way is the noise? Sample a random t, land on the line, ask, and regress. When you sample, you start from noise at t = 1 and walk down the field with Euler’s method until t = 0:
rectified flow (data at t = 0, noise at t = 1 — the source's code)
interpolation x_t = (1 − t) · x_0 + t · ε t ∈ [0, 1]
velocity target v = ε − x_0 constant along the line
loss MSE( v_θ(x_t, t), ε − x_0 )
sampling x ← ε, then x ← x − dt · v_θ(x, t), t: 1 → 0
the same line, read from the other end (noise at t = 0)
x_t = (1 − t) · ε + t · x_0 the identical set of points
v = x_0 − ε the same vector with the opposite sign
the sign flips because the clock runs the other way — nothing else changes
why it needs fewer steps
DDPM reverse a curved trajectory: each step is a local linearisation,
and the error grows with the step size → 1000 small steps
rectified flow the target is constant along each pair's line, and the
learned field is smooth → 20–30 Euler steps
distill the model and 1–4 steps is a serving choice
measured on this lesson's 2D toy (8 noise seeds, 64 steps)
straightness DDIM 1.3932 · rectified flow 1.2423 · a line 1.0000
distance to data at 4 steps DDIM 0.1071 RF 0.0327
at 8 steps DDIM 0.0436 RF 0.0102
at 128 steps DDIM 0.0073 RF 0.0035
The interpolation is straight by construction. The model does not get that for free — it has to learn a field whose trajectories are close to straight, and the two labs below show both halves of that sentence. The first puts a measured DDIM path and a measured rectified-flow path side by side with step markers, so you can see where a coarse solver stops short and where the chord-to-path ratio comes from. The second shows the field itself: arrows that start out random and end up pointing from noise to data.
One noise start, two trajectories
The grey walk is DDIM on Lesson 10’s 1000-step schedule: it curves, and at low step counts it stops short. The blue walk is Euler integration of a rectified-flow velocity field on the same 2D toy. Both paths are exact optimal models for this toy — computed, not simulated — and the dashed chord is the denominator of the straightness metric.
start x_1 (0.786, 1.117)
steps 16
straightness @16 DDIM 1.8606 · RF 1.1873
straightness @64 DDIM 1.3932 · RF 1.2423
a straight line 1.0000
distance to arc DDIM 0.0160 · RF 0.0046
8-seed average at 4 steps: DDIM 0.1071 · RF 0.0327
at 8 steps: DDIM 0.0436 · RF 0.0102
Straightness is path length divided by the straight-line distance from start to end: 1.0000 for a line, more for a curve. The rectified-flow path is not perfectly straight — a learned field never is — but it starts straight and is integrated by Euler, so a few steps already land on the data. The reference row is the 8-seed average at 64 steps; this single seed will differ.
Teach the field to point home
The arrows are the velocity a network is asked to predict; the dots are particles sliding down that field from noise (t = 1) to data (t = 0). At 0% training the field is random and nothing lands; at 100% it is the exact optimal field for this toy — computed in closed form, not learned. The in-between states are a blend, and are labelled as a simulation.
training progress 35% (simulated blend)
field alignment 59.8% of the exact field
sampling time t 1.000
particles 12 · mean distance to arc 1.0003
exact field v = (x_t − x̂_0)/t
pair target v = ε − x_0, constant along x_t
at 0% alignment 0.021 — noise
at 100% alignment 1.000 — every particle lands
What training does is turn the arrows from noise into the direction of the data. The target the loss writes down for one pair is constant along its line ε − x_0; the network sees only x_t and t, so where two pairs disagree it predicts the average. Averaging is exactly where the curvature in the last lab came from.
Quick check
In the source's convention, the training target is v = ε − x_0. What is it in the sampling-first presentation, where t = 0 is noise and t = 1 is data?
06
TRAIN THE FIELD, WALK IT BACK
Six lines to train it. Five lines to sample it.
The whole rectified-flow objective fits in one function, and the sampler in another. What is left is the engineering: an AdaLN transformer under 100 lines, and the judgement to know when fewer steps stop helping.
Training is the same five-move loop as ever — zero the gradients, run the forward pass, compute the loss, backprop, step — with the batch replaced by a line through it. Take a batch of clean samples x_0, draw a timestep and a noise field, land on the interpolation at that t, predict the velocity, and score it against the truth. There is no schedule to precompute and no posterior to derive: x_0, ε, t, x_t = (1 − t)·x_0 + t·ε, target ε − x_0, MSE.
the objective, in fullpython
def rectified_flow_train_step(model, x0, optimizer, device):
model.train()
x0 = x0.to(device)
n = x0.size(0)
t = torch.rand(n, device=device) # one random t per sample
epsilon = torch.randn_like(x0) # one noise draw per sample
x_t = (1 - t[:, None, None, None]) * x0 + t[:, None, None, None] * epsilon
target_velocity = epsilon - x0 # the direction of the line
pred_velocity = model(x_t, t) # AdaLN DiT, one forward pass
loss = F.mse_loss(pred_velocity, target_velocity)
optimizer.zero_grad()
loss.backward()
optimizer.step()
return loss.item()
Adapted from the source's main.py. At initialization the model outputs ≈ 0, so the first loss is E‖ε − x_0‖² = E‖ε‖² + E[x_0²]. On the source's blob dataset that is 1.0000 + 0.0356 = 1.0356 — measured on this page's re-implementation, where discs cover ~11% of the pixel-channels and a uniform colour in [−1, 1] has mean square 1/3.
Sampling is Euler’s method on that field. Start at pure noise, t = 1, and take steps equal jumps down to zero; each jump asks the model for the local velocity and subtracts a scaled copy. The loop is identical for 4 steps and for 128 — the only thing that changes is dt, and therefore how much the straight-line approximation has to stretch.
the sampler, in fullpython
@torch.no_grad()
def rectified_flow_sample(model, shape, steps=20, device="cpu"):
model.eval()
x = torch.randn(shape, device=device) # start in pure noise, t = 1
dt = 1.0 / steps
t = torch.ones(shape[0], device=device)
for _ in range(steps):
v = model(x, t) # one network call
x = x - dt * v # Euler step toward the data
t = t - dt
return x
The last step also subtracts dt·v; because the model was trained to be accurate everywhere on the line, the endpoint lands on the data. Twenty steps is the default here — the source's '20 steps produces results comparable to 1000-step DDPM' claim, and the step-count lab measures what 4, 8 and 128 do on the toy.
the tiny model, parameter-counted (source's TinyDiT, 16 × 16 images, patch 2)
patch conv Conv2d(3 → 96, k = 2, s = 2) 2·2·3·96 + 96 = 1,248
positional 64 patches × 96 dims = 6,144
time MLP Linear(96 → 192) + Linear(192 → 96) = 37,152
one block 2 × 27,936 adaLN + 37,248 attention + 74,208 MLP = 167,328
four blocks = 669,312
output head Linear(96 → 2·2·3) = 1,164
total = 715,020
attention is 22.3% of a block · the MLPs that say "what time is it" are 33.4%
the same recipe at SD3.5 Large scale: 8,146,280,768 parameters (11,400× more)
what the loss prints
step 0 ≈ 1.0356 measured on the blob stand-in (see below)
step 50 ≈ 0.4 expected: the field has the coarse structure
step 300 ≈ 0.1–0.2 expected: blobs visible (source's 500-step smoke test)
the floor → 0 only a perfect field reaches it
at step 0 the model predicts ≈ 0, so the loss is E‖v‖² = E‖ε − x_0‖²
= E‖ε‖² + E[x_0²] = 1 + 0.0356 = 1.0356
How many steps does it actually take?
The chart is the honest version of the 20-versus-1000 claim, measured on the toy: mean distance from each sampler’s endpoint to the data arc, against the number of network calls. The curved DDIM path needs more calls to settle; the rectified-flow path is already close after a handful.
at 8 steps DDIM RF
distance to arc 0.0436 0.0102
path straightness 1.3480 1.2035
ratio (DDIM ÷ RF) 4.26×
sweep (8 seeds)
at 4 DDIM 0.1071 RF 0.0327
at 8 DDIM 0.0436 RF 0.0102
at 32 DDIM 0.0084 RF 0.0083
at 128 DDIM 0.0073 RF 0.0035
real images (published, not measured here)
DDPM 1000 calls · DDIM 20–50 · SD3/FLUX 20–30 · schnell 1–4
Read the curves with the right expectations: on a 2D toy both samplers are near the arc by 16–32 calls, so the gap is a trend, not a benchmark. What transfers is the shape of the curves and the published result: rectified-flow models are served at 20–30 steps, and their distilled variants at 1–4. The table's 8-step row is the same measurement the chart draws.
Quick check
A rectified-flow model is trained on the blob dataset and the first printed loss is ≈1.036, not 0. Why that number?
07
THE 2026 SHELF
Same blocks, different envelopes. Read the card before you build.
Every model below is a DiT trained with a rectified-flow objective. What separates them is size, efficiency, text encoders and paperwork — and all four move faster than any table can.
This is a dated snapshot, not a leaderboard. The parameter counts come from the tensor metadata on each model card and the licences from the card’s own flag, both read on 16 September 2026. Treat the numbers as examples of the trade-offs, and the links as the source of truth.
the shelf as this lesson was written · verify on the card
model
architecture
parameters
licence
SD3 Medium
MMDiT
≈2B
SAI Community
SD3.5 Large
MMDiT
8.1B
SAI Community
FLUX.1-dev
double + single stream
11.9B
non-commercial
FLUX.1-schnell
same, distilled
11.9B
Apache-2.0 · 1–4 steps
FLUX.2-dev
FLUX.2 family
32.2B
card flag: other
Z-Image
single-stream DiT
6.2B
Apache-2.0
Qwen-Image
DiT + Qwen text tower
20.4B
Apache-2.0
Four readings of that table are worth keeping. MMDiT first appeared at 2B. SD3 Medium showed the block, not the scale, was the change. FLUX.1 ships as a pair: dev and schnell are 0.086% apart in parameter count — 11,901,408,320 against 11,891,178,560 — and differ by distillation, which is why one needs 20–30 guided steps and the other 1–4 unguided. Efficiency is a real branch of the tree: Z-Image at 6.2B is roughly half of FLUX.1 and a third of FLUX.2, and it is Apache-2.0. And the text tower is not optional: Qwen-Image’s 20.4B includes a descendant of its own LLM family, the same design choice that made prompt reasoning the selling point.
Pick a model from the 2026 shelf
Architecture, parameter count and licence for the models this lesson talks about. These are dated examples — counts and licence flags were read from the model cards on 16 September 2026, and the shelf moves fast. Choose what you are optimising for and see which trade wins.
snapshot · 16 September 2026 · parameter counts from the card metadata, licences as flagged (click a name for the card)
1–4 steps with no classifier-free guidance, Apache-2.0, and the same 11.9B architecture as dev. Distillation, not a smaller model, is what bought the speed.
Parameter counts are total tensors on the card, not the size of a quantised download.
“Card flag: other” is exactly what the metadata says — read the licence text itself.
Check the inference step count on the card; it is a training choice, not a property of DiT.
Text encoders are part of the download: T5-XXL alone is ≈4.7B parameters.
priority fewest steps
recommended FLUX.1-schnell
shelf · 16 Sep 2026
SD3 Medium ≈2B SAI Community
SD3.5 Large 8.1B SAI Community
FLUX.1-dev 11.9B non-commercial
FLUX.1-schnell 11.9B Apache-2.0 · 1–4 steps
FLUX.2-dev 32.2B card flag: other
Z-Image 6.2B Apache-2.0
Qwen-Image 20.4B Apache-2.0
licence is a product decision: the two Apache-2.0
checkpoints here are schnell and Qwen-Image.
The lesson’s point is architectural, not commercial: every row is a DiT, every recent row trains with a rectified-flow objective, and the step count is decided by distillation and licence by paperwork. Verify both on the card before you build.
use it: two models, four numbers that matterpython
from diffusers import FluxPipeline, StableDiffusion3Pipeline
import torch
# the distilled pair: 4 steps, no classifier-free guidance
pipe = FluxPipeline.from_pretrained(
"black-forest-labs/FLUX.1-schnell",
torch_dtype=torch.bfloat16,
).to("cuda")
out = pipe(
prompt="a golden retriever surfing a tsunami, hyperrealistic, studio lighting",
guidance_scale=0.0, # schnell was trained without CFG
num_inference_steps=4,
max_sequence_length=256,
).images[0]
out.save("surf.png")
# the full-quality MMDiT: 28 steps, guidance 3.5
sd3 = StableDiffusion3Pipeline.from_pretrained(
"stabilityai/stable-diffusion-3.5-large",
torch_dtype=torch.bfloat16,
).to("cuda")
out = sd3(prompt, guidance_scale=3.5, num_inference_steps=28).images[0]
Adapted from the source's Use It section. Four numbers decide the serving bill: the model id, the step count, the guidance scale and the maximum sequence length (how many text tokens the encoder is allowed to keep). Swapping schnell for dev changes the steps from 4 to 20–30 and the guidance from 0 to ≈3.5 — the architecture behind both calls is identical.
This is also the lesson’s two deliverables in practice. The variant chooser above is the model-picker decision the source asks you to write down — quality against latency against licence, with the dated numbers attached. The two training functions in chapter 06 are the rectified-flow trainer: a block, a train step and an Euler sampler small enough to hold in your head, and the same three pieces a real training run scales up.
What survives all of this churn is the architecture in this lesson: latents are patchified into tokens, a timestep and a prompt are turned into a scale, shift and gate, transformer blocks predict a velocity, and an ODE solver walks the field back to an image. New models change the envelope — parameters, encoders, distillation, licence — not the shape.
08
CHECK YOURSELF
Five questions. Then the terms worth keeping.
Answer before you look. The rectified-flow question and the adaLN-Zero question are the two that separate “I have seen DiT code” from “I can say why it trains” — and the license question at the end is the one that decides whether a demo becomes a product.
0 / 5 answered · 0 correct
01Why did 2024+ text-to-image models (SD3, FLUX, Z-Image, Qwen-Image) replace the U-Net denoiser with a Diffusion Transformer?
02Rectified flow trains the model to predict the velocity v = ε − x_0 along the straight interpolation x_t = (1−t)·x_0 + t·ε. Why does that enable 20-step sampling instead of 1000?
03MMDiT (SD3) keeps two separate weight streams — one for text tokens and one for image tokens — that share a single joint attention layer. Why?
04What does AdaLN-Zero mean in a DiT block, and why does it help?
05FLUX.1-schnell produces an image in 4 steps. Which technique lets it do that?
Key terms, demystified
Click a card to swap the lazy description for what it actually means — every definition carries the number that makes it checkable.
Exercises from the lesson
Four problems with exact numbers: the patch arithmetic and why 2×2 wins, training the TinyDiT and comparing Euler step counts, adding class conditioning with the parameter delta worked out, and the honest rectified-flow-versus-DDPM comparison with its traps. Try first; a worked answer is one click away.
Do the patchify arithmetic for a 32×32×4 latent: how many tokens at patch 1×1, 2×2 and 4×4, how many values does each token carry, and how many attention pairs does each choice create? Then say which choice you would ship and why.Show one worked answer
The latent is 32 × 32 × 4 = 4,096 values. Patch 2×2: 16 × 16 = 256 tokens, each carrying 2·2·4 = 16 values, and 256² = 65,536 attention pairs per head (196,608 for three heads). Patch 1×1: 32 × 32 = 1,024 tokens, 4 values each, 1,048,576 pairs — 16× the attention cost of patch 2, for 4× finer spatial detail. Patch 4×4: 8 × 8 = 64 tokens, 64 values each, 4,096 pairs — cheap, but each token now spans a large patch and the attention has less to say about fine structure. Patch 2×2 is the balance SD3 and FLUX chose: on a 64×64×4 latent it gives 1,024 tokens and 1,048,576 pairs, which is affordable, while patch 1 on the same latent would be 4,096 tokens and 16,777,216 pairs. Note the pattern: double the patch side, quarter the token count and divide attention cost by 16.
Train the source's TinyDiT on the synthetic-blob dataset for 500 steps, then sample with 10, 20 and 50 Euler steps. What should the loss print, what should the samples look like, and what changes with the step count?Show one worked answer
Expect the loss to start at ≈1.036, not at 0: the target is v = ε − x_0 and an untrained network predicts ≈0, so the first MSE is E‖ε − x_0‖² = E‖ε‖² + E[x_0²]. E[x_0²] ≈ 0.0356 on the blobs (200 images, discs on a black background — ~11% of pixel-channels are non-zero, and a uniform colour in [−1, 1] has mean square 1/3), so the starting point is 1.000 + 0.036. After 500 steps at batch 32 it should be somewhere in the 0.1–0.3 band and still falling; the source's 128-image training run (this page's stand-in draws 200 images) is a demonstration, not a training run. The model is 715,020 parameters, so this fits anywhere. Samples: faint coloured blobs — a disc with a colour, not a photograph. At 10 steps the blob is usually there with a soft edge; at 20 it is close to the 50-step result; at 50 the difference is measured in hundredths of the distance-to-manifold, not in visible quality. That is the lesson: the field does most of its work early, and the step count buys accuracy you cannot see once the trajectory is near-straight. Score it numerically rather than by eye — mean distance from the sample to the nearest blob colour is the toy version of a sample-quality metric.
Add class conditioning: concatenate a learned 96-dimensional embedding for 10 blob colour classes to the 96-dimensional time embedding, then sample classes 0, 5 and 9 and verify the colours match. What changes in the model, and what does guidance do at inference?Show one worked answer
Ten classes is a 10 × 96 = 960-parameter embedding table. Concatenating gives a 192-dimensional conditioning vector, so the AdaLN-Zero MLP grows from Linear(96 → 288) = 27,936 parameters to Linear(192 → 288) = 55,584 — an extra 27,648 per MLP, two per block, so 4 blocks × 2 × 27,648 = 221,184 more parameters, taking the model from 715,020 to about 936,204. At inference, classifier-free guidance mixes the conditional and unconditional velocity: v = v_uncond + w·(v_cond − v_uncond), with v_uncond computed by feeding the null class. At w = 1 you get the raw conditional prediction; w ≈ 3–5 sharpens class adherence at the cost of diversity. Verify by sampling each class N times and comparing the mean colour of the disc against the training colour for that class — a per-channel distance, not a glance.
Build the honest comparison: train a rectified-flow model and a DDPM/DDIM model of the same size on the same data for the same number of steps, and report (a) a FID-style distance between samples and data and (b) the straightness of each model's trajectories. What should you expect, and what are the traps?Show one worked answer
Hold everything constant except the objective: same network class, same parameter count, same data, same optimiser, same seed. For (a), FID needs thousands of samples to be stable (the usual guidance is ≥10,000 for a 2048-dimensional feature space; on the blobs, a mean nearest-neighbour distance or a two-sample energy distance is the honest small-run substitute). For (b), straightness is path length ÷ end-to-end chord, computed by recording every intermediate x_t of the sampler: 1.0000 for a line, and measured on this lesson's 2D toy at 64 steps, 1.3932 for the DDIM walk against 1.2423 for the exact rectified-flow field. Expected outcome: at 4–8 steps rectified flow is clearly ahead (endpoint error 0.0327 against 0.1071 at 4 steps, 0.0102 against 0.0436 at 8), both converge by ~32–64 steps on a toy problem, and a reflow pass on the rectified-flow model straightens it further. Traps: (1) comparing at equal *wall-clock* rather than equal steps quietly gives one side more calls; (2) a 2D toy saturates long before real images do, so the toy's convergence is not the real-image result; (3) if you reflow, you are measuring a second-stage model against a first-stage one — an unfair comparison unless both are given the same budget.
Terms this lesson borrows from later lessons (or outside)
You do not need to master these here. Each one gets a proper treatment in its own lesson; the one-line meaning is enough to keep reading. Orange dotted underlines in the prose point back to this list.
U-Net DDPM — Lesson 10's denoiser and noise schedule: a convolutional encoder–decoder with skip connections, trained to predict ε with a plain MSE over 1000 timesteps, sampled by DDPM (1000 calls) or DDIM (20–50). This lesson keeps its training loop and its loss and replaces both the network and the schedule.
ViT patch embedding — Phase 4, Lesson 14. A ViT cuts an image into patches and projects each patch into a token with a convolution whose kernel and stride are the patch size — exactly the Conv2d(4 → 96, k = 2, s = 2) at the top of a DiT. On a 224×224 image, 16×16 patches give 196 tokens; on a 64×64×4 latent, 2×2 patches give 1,024.
Self-attention — Phase 7, Lesson 02. Every token forms a query, key and value; the attention matrix is tokens × tokens, so its cost grows with the square of the token count: 256 tokens is 65,536 pairs per head, 1,024 tokens is 1,048,576 — the number that decides the patch size and the resolution a DiT can afford.
Latent diffusion — Lesson 11's VAE: images are compressed 8× before diffusion runs, so a 512×512×3 image (786,432 values) becomes a 64×64×4 latent (16,384 values) — 48× fewer dimensions. DiT operates on that latent, and 'patchify' turns its 16,384 values into 1,024 tokens of 16 numbers each.
Classifier-free guidance — Lesson 11. Training drops the text condition ~10% of the time so the model can also predict unconditionally; at inference you mix v = v_uncond + w·(v_cond − v_uncond). Rectified flow changes the sampler, not the conditioning: SD3 runs at w ≈ 3.5, the SD1.5 generation at 7.5, and distilled schnell at w = 0 because guidance was baked into its training.
LayerNorm and zero-initialised residuals — Phase 3. A residual block that starts as the identity trains stably at depth — the trick from ResNet and the reason 'zero' is in AdaLN-Zero: the modulation MLP is initialised so that scale, shift and gate are exactly 0, and gradients nudge the block away from doing nothing only when it helps.
KEEP GOING
A picture is a start. Practice is the rest.
This lesson is a port of an open course. Everything here traces back to it — and the next step is running the code yourself.
Lesson text adapted from AI Engineering from Scratch (Phase 04, Lesson 23) and the Math Foundations Notebook reference build. The six labs — the DiT block explorer, the adaLN board, the trajectory comparator, the velocity-field playground, the step-count lab and the dated variant chooser — are original to this page, as are the patchify arithmetic (4,096 → 256 tokens, 65,536 pairs per head, the 1×1/4×4 comparison), the parameter counts (adaLN 27,936, attention 37,248, MLP 74,208, block 167,328, TinyDiT 715,020), the measured initialization loss (E[x_0²] = 0.0356 → 1.0356), the two-convention reconciliation of the velocity sign, the measured straightness comparison (DDIM 1.3932 against rectified flow 1.2423 and a straight line 1.0000) with its step-count table (0.1071 vs 0.0327 at 4 steps, 0.0436 vs 0.0102 at 8), the sampling-latency arithmetic at 30 ms per call (30 s → 0.12 s), and the model-card metadata read on 16 September 2026 (params and licence flags for SD3, SD3.5, FLUX.1, FLUX.2, Z-Image and Qwen-Image). The 2D arc and the blob dataset are labelled teaching stand-ins, and the velocity-field lab's training progress is explicitly a simulated blend, not a training run.