EVERYTHING AIAI engineering, made visual
0/28 complete
LESSON 23 · COMPUTER VISION × AI · LEARN + BUILD

The U-Net is gone.
The straight line remains.

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.

75 MIN · 7 CHAPTERS + CHECKPREREQ · P4 L10 · P4 L14 · P7 L02
FIG. 23 / A LIVE BLOCK TOUR · PATCHIFY → MODULATE → ATTEND → FLOW
text tokens image tokens DDIM walk rectified flow
LESSON 23TYPE · LEARN + BUILD~75 MINPREREQ · PHASE 4 · LESSON 10 · PHASE 4 · LESSON 14 · PHASE 7 · LESSON 02ORIGINAL LESSON ↗
THE 60-SECOND VERSIONSee the problem ↓
01 / THE U-NET'S REPLACEMENT

The denoiser became a transformer.

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,020
02 / 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.0356
03 / 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.

FLUX.1-dev 11.9B non-commercial · schnell 11.9B Apache-2.0, 1–4 steps · Qwen-Image 20.4B Apache-2.0
MENTAL MODEL IN ONE SENTENCE

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.

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.

DDPM U-Net2020 · convDiT2023 · patches + adaLNMMDiT2024 · two streamsFLUX2024 · double → singlewhat changedthe networkthe conditioningmodality and costU-NetconvolutionsFiLM / additive embeddingone concat stream, skipsDiTtransformer blocksadaLN-Zero: scale, shift, gateclass or text embeddingMMDiTtwo weight streamsadaLN per modalityjoint attention, 2× projectionsFLUXdouble → single streamadaLNshared blocks at depth
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.

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
patchtokensvalues per tokenattention pairs per head
1 × 132 × 32 = 1,02441,048,576
2 × 216 × 16 = 2561665,536
4 × 48 × 8 = 64644,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] again

    def _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.

stage 1 / 6 · LATENT latent [B, 4, 32, 32] 4,096 values patch 2×2 16 × 16 = 256 tokens per token 16 values → 96 dims attention 65,536 pairs/head block params 167,328 patch 1 → 1024 tokens · patch 4 → 64 tokens

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?

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)
featurexnorm(1+scale)·norm + shiftAttention(h) stand-ingate · attnoutput = x + gate·attn
feature 10.901.19181.19180.200.00000.9000
feature 20.40-0.5108-0.5108-0.100.00000.4000
feature 30.750.68100.68100.400.00000.7500
feature 40.15-1.3620-1.36200.050.00000.1500
output − inputevery 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
norm (x − 0.5500) / 0.2937 scale 0.00 shift 0.00 gate 0.00 feature 1 x 0.90 norm 1.1918 modulated 1.1918 residual add 0.0000 output 0.9000 identity? yes — gate 0 means output = input per block 2 × 27,936 adaLN + 37,248 attention + 74,208 mlp = 167,328 parameters

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.
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.

TEXT STREAM · CLIP + T5-XXL“agoldenretrieversurfing”its own Q, K, V and MLP weightsIMAGE STREAM · 256 PATCH TOKENSits own Q, K, V and MLP weightsJOINT SELF-ATTENTION · every token sees every token, both modalitiesSD3: two weight streams, one shared attention · FLUX: this, for the first N blocks, then one shared stream
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?

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?

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?

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
modelarchitectureparameterslicence
SD3 MediumMMDiT≈2BSAI Community
SD3.5 LargeMMDiT8.1BSAI Community
FLUX.1-devdouble + single stream11.9Bnon-commercial
FLUX.1-schnellsame, distilled11.9BApache-2.0 · 1–4 steps
FLUX.2-devFLUX.2 family32.2Bcard flag: other
Z-Imagesingle-stream DiT6.2BApache-2.0
Qwen-ImageDiT + Qwen text tower20.4BApache-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)
modelarchitectureparamslicencesteps
Stable Diffusion 3 MediumMMDiT · two text/image streams + joint attention≈2B (card)SAI Community (card flag: other)~28 with CFG
Stable Diffusion 3.5 LargeMMDiT8.1B (8,146,280,768)SAI Community (card flag: other)28–30 with CFG
FLUX.1-devdouble-stream + single-stream DiT11.9B (11,901,408,320)non-commercial (card flag: other)20–30 with CFG
FLUX.1-schnellsame blocks, step-distilled11.9B (11,891,178,560)Apache-2.01–4, no CFG
FLUX.2-deviterated FLUX family (check the card)32.2B (32,223,281,152)card flag: other (gated)check the card
Z-Imagesingle-stream DiT · undistilled base6.2B (6,154,908,736)Apache-2.0base supports CFG; a Turbo sibling targets few steps
Qwen-ImageDiT + Qwen text tower20.4B (20,430,401,088)Apache-2.0check the card
IF YOU ARE OPTIMISING FOR · FEWEST STEPS
FLUX.1-schnell

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.

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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 DDPMLesson 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 embeddingPhase 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-attentionPhase 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 diffusionLesson 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 guidanceLesson 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 residualsPhase 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.

Original lessonDiffusion Transformers & Rectified FlowAI Engineering from Scratch · the source text, quiz and main.py: the AdaLN-Zero block, the TinyDiT, the rectified-flow train step, the Euler sampler and the model-landscape table this lesson dates and verifies.DiT paperScalable Diffusion Models with TransformersPeebles & Xie (2023) · the paper that replaced the U-Net with a transformer over patchified latents and introduced adaptive layer norm conditioning. Its 32×32×4 latent at patch 2 is the 256-token example this lesson counts.SD3 paperScaling Rectified Flow Transformers for High-Resolution Image SynthesisEsser et al. (2024) · MMDiT and rectified flow at scale: two weight streams with joint attention, and the flow-matching objective behind SD3's 20–30 step sampling.Rectified flowFlow Straight and Fast: Learning to Generate and Transfer Data with Rectified FlowLiu, Gong & Liu (2022) · where the straight-line interpolation and the velocity loss come from — including the reflow step that straightens trajectories further and makes the path to 1–4 step models possible.Model familyFLUX.1-dev model card (Black Forest Labs)The double-stream + single-stream DiT this lesson's fourth chapter describes, with the [dev] / [schnell] split and the licence text behind the card's 'other' flag. The repository is github.com/black-forest-labs/flux.ToolingHugging Face diffusers — DiT and DiT pipeline docsThe library the source's Use It section runs: DiT as a diffusers model and pipeline, next to the FluxPipeline and StableDiffusion3Pipeline used for FLUX.1-schnell at 4 steps and SD3.5 at 28.

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.