ml-models / Generative modeling / Diffusion U-Net advanced

Diffusion U-Net

A diffusion model is only as good as the network that guesses the noise. For most image diffusion models that network is a U-Net: a convolutional encoder that shrinks the image, a decoder that grows it back, skip connections that carry detail across, a timestep embedding injected into every block, and attention at the coarser resolutions. This page walks Stable Diffusion v1's U-Net from a 64 × 64 latent down to 8 × 8 and back, counts its 859,520,964 parameters, and ends with DiT, the transformer that replaces the U-Net.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. Input and output
  5. Down path, up path and skips
  6. ResBlock with GroupNorm
  7. Timestep embedding
  8. Self- and cross-attention at low resolution
  9. DiT: the transformer alternative
  10. Worked example
  11. Timestep embeddings at width 8
  12. GroupNorm by hand
  13. Shape walk through Stable Diffusion v1's U-Net
  14. Why attention waits for small grids
  15. Counting the parameters
  16. Implementation
  17. Tradeoffs
  18. Connections
  19. Read first
  20. Read next
  21. References

Problem

Diffusion models train one network εθ(xt, t) to predict the noise that was added to a clean image. Its input is a noisy image and its output is a tensor of the same shape: a guess for every pixel. The same network is called at every one of up to 1000 noise levels, from almost-clean images where it must find faint grain to pure static where it must invent global layout.

That asks a lot of one architecture. It must see the whole image, because deciding where a face goes is a global decision. It must also keep exact pixel positions, because the noise is per-pixel. It must know how noisy its input is. And for text-to-image it must listen to a prompt. A plain stack of convolutions sees only a small window; a classifier throws away position. The question is what shape of network does all four.

Intuition

The U-Net, introduced by Ronneberger et al. for biomedical segmentation, already had the right shape: another image-in, image-out problem. Its down path halves the resolution step by step while widening the channels, so deep layers see the whole picture coarsely. Its up path mirrors it, doubling the resolution back. The two paths drawn side by side make a U.

The trick is the skip connections across the U. Squeezing a 64 × 64 grid to 8 × 8 loses fine detail, and the up path cannot invent it back. So each down-path output is kept and concatenated onto the matching up-path input: the decoder gets both "what the image is about" from below and "exactly where things were" from the side. Think of an editor who reads a summary of the whole book and also keeps the original page open.

Diffusion adds two inputs on top. The timestep becomes a vector, the same sinusoids used for positional encoding, and every residual block adds it to its features, so every layer knows the noise level. The prompt enters through cross-attention layers placed where the grid is small enough for attention to be affordable.

The Stable Diffusion v1 U-Net: four resolutions down, a middle block, four back up, with skips On the left, four boxes step down and to the right: 64 by 64 with 320 channels, 32 by 32 with 640, 16 by 16 with 1280, and 8 by 8 with 1280. The first three hold two ResBlocks with attention each; the 8 by 8 box holds two ResBlocks without attention. Downsampling convolutions with stride 2 join them. At the bottom a middle block at 8 by 8 runs a ResBlock, attention and another ResBlock. On the right, four boxes step back up, each holding three ResBlocks, with attention at 16, 32 and 64, joined by upsampling. Dashed horizontal arrows labelled skip run from each left box to the right box at the same resolution, where features are concatenated. The input is the noisy latent with the timestep and text; the output is the predicted noise, both 4 by 64 by 64. in: z_t [b, 4, 64, 64], t, text out: ε̂ [b, 4, 64, 64] 64×64 · 320 ch 2 ResBlock + attn 64×64 · 320 ch 3 ResBlock + attn skip · concat ↓2 ↑2 32×32 · 640 ch 2 ResBlock + attn 32×32 · 640 ch 3 ResBlock + attn skip · concat ↓2 ↑2 16×16 · 1280 ch 2 ResBlock + attn 16×16 · 1280 ch 3 ResBlock + attn skip · concat ↓2 ↑2 8×8 · 1280 ch 2 ResBlock 8×8 · 1280 ch 3 ResBlock skip · concat middle · 8×8 · 1280 ch ResBlock · attn · ResBlock

Mechanics

Input and output

Stable Diffusion runs in the latent space of a VAE: a 512 × 512 RGB image becomes a 4 × 64 × 64 latent, and the U-Net denoises that (the latent diffusion idea of Rombach et al.). The U-Net's input and output both have shape [b, 4, 64, 64]. A 3 × 3 convolution, conv_in, lifts the 4 channels to the base width of 320; at the end a GroupNorm, SiLU and 3 × 3 convolution bring 320 channels back to 4, the predicted noise.

Down path, up path and skips

The down path has four levels with channel multipliers [1, 2, 4, 4] on the base width, so 1, 2, 4, 4 × 320 = 320, 640, 1280, 1280 channels. Each level runs 2 residual blocks, then (except the last) a downsample: a 3 × 3 convolution with stride 2 that halves height and width. Every block's output, including conv_in's and each downsample's, is pushed onto a stack: 12 saved tensors.

The up path walks the levels in reverse with 3 residual blocks per level, one more than the down path, so it pops all 12 saved tensors. Each up block concatenates the popped tensor onto its input along the channel axis, torch.cat([h, skip], dim=1), which is why up-block inputs are wide (up to 2560 channels) and the first convolution of each up block narrows them. At the end of each level except the finest, an upsample doubles the resolution: nearest-neighbour interpolation (nn.Upsample(scale_factor=2, mode="nearest")) followed by a 3 × 3 convolution. The original U-Net used a learned transposed convolution (nn.ConvTranspose2d) here instead; resize-then-convolve avoids the checkerboard artifacts transposed convolutions can produce when the kernel size is not divisible by the stride.

skip: concat([h, s], dim = 1)  ·  channelsin = Ch + Cs

Concatenation, not addition, is the U-Net choice: the up block can weigh the skip and the upsampled path however it likes, at the cost of wider convolutions. A ResNet's skip adds; a U-Net's skip stacks.

ResBlock with GroupNorm

The workhorse is a pre-activation residual block. With input x of Cin channels and timestep embedding e:

h = Conv3×3(SiLU(GN(x))) + Linear(SiLU(e))[:, :, None, None]
y = skip(x) + Conv3×3(Dropout(SiLU(GN(h))))

The skip is the identity when channels match and a 1 × 1 convolution when they change. The last convolution is initialized to zero, so every block starts as the identity and the untrained network passes its input straight through; the same zero init is used on the output convolution and on each attention block's output projection.

GroupNorm (Wu and He) splits the C channels into G groups and normalizes each group over its channels and all spatial positions, separately for every sample. LayerNorm is the case G = 1 and InstanceNorm the case G = C. Unlike BatchNorm, nothing depends on the other samples in the batch, which matters when a large model fits only a few images per GPU. Stable Diffusion uses G = 32 everywhere, so every channel count must be a multiple of 32; nn.GroupNorm raises a ValueError otherwise. SiLU, also called Swish, is x · σ(x).

Timestep embedding

The integer timestep t becomes sinusoids of geometrically spaced frequencies, the transformer's position encoding with t as the position. With embedding width d and half = d/2:

ωi = 10000−i/half,   i = 0 … half − 1  ·  emb(t) = [cos(tω0), …, cos(tωhalf−1), sin(tω0), …, sin(tωhalf−1)]

That is the latent-diffusion and DiT convention. The original DDPM code divides by half − 1 instead of half and puts sin before cos; the two are equivalent up to a permutation and a slight change of frequencies, and any one model only ever sees its own. Stable Diffusion embeds t at width 320 and passes it through Linear(320, 1280) → SiLU → Linear(1280, 1280). Each ResBlock then projects the 1280-wide vector to its own channel count and adds it to every spatial position: a per-channel bias that depends on the noise level. Guided-diffusion's ADM variant instead produces a scale and a shift and applies them after the second GroupNorm (adaptive GroupNorm), which Dhariwal and Nichol found improved sample quality.

Self- and cross-attention at low resolution

Convolutions are local; attention lets every position see every other. Attention compares all pairs of positions, so its cost grows with the square of the token count, and the U-Net places it only where the grid is small. DDPM used self-attention at 16 × 16 only; Stable Diffusion v1 places a transformer block after every ResBlock at 64, 32 and 16 and in the middle block at 8 (configured as attention_resolutions [4, 2, 1], which are downsampling factors, not sizes).

Each block first applies GroupNorm and a 1 × 1 convolution, then flattens the grid to a sequence, [b, C, H, W] → [b, HW, C], and runs a pre-norm transformer layer: self-attention over the HW positions, cross-attention whose queries come from the image and whose keys and values come from the 77 × 768 CLIP text-encoder output, and a GEGLU feed-forward (Linear to 8C, split in half, one half times GELU of the other, Linear 4C → C). A zero-initialized 1 × 1 convolution maps back and the result is added to the block input. Cross-attention is how the prompt reaches the pixels: each position asks which words concern it. With 8 heads at every level, the head width is 40, 80, 160 as the channels grow.

ComponentShape/ParamsMeaningSource
nn.Conv2dconv_in 4 → 320, 3 × 3, padding 1; downsample 3 × 3, stride 2; skip 1 × 1Every spatial layer of the U-Net, and the 1 × 1 projections around attention.conv.py 388–565
Conv2d.__init__stride=1, padding=0, bias=True by defaultThe U-Net sets padding=1 on 3 × 3 convolutions so sizes are kept.512–544
nn.ConvTranspose2dkernel 2, stride 2 in the original U-NetLearned upsampling; Stable Diffusion uses resize + conv instead.conv.py 1012–1207
nn.Upsamplesize=None, scale_factor=None, mode="nearest"With scale_factor=2, doubles height and width before a 3 × 3 conv.upsampling.py 13–198
Upsample.forward[b, C, H, W] → [b, C, 2H, 2W]Calls F.interpolate.170–182
nn.GroupNormGroupNorm(32, C), eps=1e-5, weight and bias [C]Per-sample normalization over each group of C/32 channels and all positions.normalization.py 239–340
F.group_normnum_groups, weight, bias, epsThe functional form GroupNorm.forward calls.functional.py 3015–3050
nn.SiLUno parametersx · σ(x), before every convolution and in the time MLP.activation.py 436–483
nn.Lineartime MLP 320 → 1280 → 1280; per block 1280 → CTurns the timestep embedding into a per-channel bias.linear.py 53–140
nn.MultiheadAttentionembed_dim=C, num_heads=8; kdim=vdim=768 for cross-attentionSelf- and cross-attention over the flattened grid.activation.py 1090–1573
nn.LayerNormweight and bias [C]Pre-norms inside each transformer block (and DiT's blocks, without affine).normalization.py 105–236
nn.GELUapproximate="none" or "tanh"Gate of the GEGLU feed-forward; DiT's MLP uses the tanh form.activation.py 778–823
Inside one level: a ResBlock with the timestep added, then a transformer block with cross-attention Top row, the ResBlock: input x, GroupNorm, SiLU, a 3 by 3 convolution, an addition where the timestep embedding, passed through SiLU and a Linear layer, is added, then GroupNorm, SiLU and a zero-initialized 3 by 3 convolution, and a final addition with a skip line from x that is the identity or a 1 by 1 convolution. Bottom row, the transformer block: GroupNorm and a 1 by 1 convolution that flattens to b by HW by C, LayerNorm and self-attention with a residual, LayerNorm and cross-attention whose keys and values come from text tokens of shape b by 77 by 768, with a residual, LayerNorm and a GEGLU feed-forward with a residual, then a zero-initialized 1 by 1 convolution reshaped back and added to the block input. ResBlock skip: identity, or 1×1 conv when channels change x [b,C,H,W] GroupNorm 32 groups SiLU Conv 3×3 C → C′ GroupNorm SiLU Conv 3×3 zero init + + timestep t sinusoid · MLP · SiLU · Linear Transformer block (where attention runs) block residual: x + proj_out(…) GN · 1×1 in → [b, HW, C] LN · self-attn + residual LN · cross-attn + residual LN · GEGLU FF + residual 1×1 out zero init · reshape + text [b, 77, 768] keys and values come from the prompt

DiT: the transformer alternative

Peebles and Xie asked whether the U-Net's convolutional inductive bias is needed at all. A Diffusion Transformer (DiT) treats the latent the way a Vision Transformer treats an image: cut the 4 × 32 × 32 latent into p × p patches (a Conv2d with kernel and stride p), add fixed sin-cos position embeddings, and run a stack of plain transformer blocks at one constant width. There is no down path, no up path and no skip; a final linear layer turns each token back into a p × p × 8 patch (4 channels of noise and 4 of learned variance), and the patches are reassembled.

Conditioning changes too. The timestep (embedded with the same sinusoids at width 256, then an MLP) and the class label (a learned embedding table with one extra row for "no class", used for classifier-free guidance) are summed into one vector c. Instead of being added to features, c drives adaLN-Zero: a SiLU and Linear(D, 6D) produce a shift, a scale and a gate for each of the attention and MLP branches. The LayerNorms have no learned affine; the shift and scale come from c, and each branch output is multiplied by its gate before the residual add. The Linear is initialized to zero, so every gate starts at zero and every block starts as the identity, the same trick as the U-Net's zero-initialized convolutions. Among the conditioning schemes the paper compared (in-context tokens, cross-attention, adaLN and adaLN-Zero), adaLN-Zero gave the best FID.

x ← x + α1 · Attn(LN(x)(1 + γ1) + β1)  ·  x ← x + α2 · MLP(LN(x)(1 + γ2) + β2),   (β, γ, α)1,2 = Linear(SiLU(c))

The paper's finding was that DiTs scale cleanly: more compute per forward pass, whether from a larger model or from smaller patches (more tokens), gave better samples, and DiT-XL/2 set a new state of the art on class-conditional ImageNet at 256 × 256. Later large text-to-image models moved to transformer backbones in this spirit.

Worked example

All numbers below come from tested helpers in ml_models_docs.py (unet_walk, dit_params, timestep_embedding_ldm, timestep_embedding_ddpm, unet_group_norm), written in plain Python. The Stable Diffusion count reproduces the 859.52M the latent diffusion code reports for this configuration, and the DiT-XL/2 count matches the paper's 675M.

Timestep embeddings at width 8

With d = 8, half = 4 and the frequencies are 1, 0.1, 0.01, 0.001: one cycle every 2π ≈ 6.3 steps at the fastest, one every 6,283 steps at the slowest.

tLDM / DiT: [cos …, sin …]DDPM: [sin …, cos …]
01.00 1.00 1.00 1.00 0.00 0.00 0.00 0.000.00 0.00 0.00 0.00 1.00 1.00 1.00 1.00
10.54 1.00 1.00 1.00 0.84 0.10 0.01 0.000.84 0.05 0.00 0.00 0.54 1.00 1.00 1.00
10-0.84 0.54 1.00 1.00 -0.54 0.84 0.10 0.01-0.54 0.45 0.02 0.00 -0.84 0.89 1.00 1.00
500-0.88 0.96 0.28 0.88 -0.47 -0.26 -0.96 0.48-0.47 -0.94 0.88 0.05 -0.88 -0.35 0.47 1.00
9991.00 0.81 -0.84 0.54 -0.03 -0.59 -0.54 0.84-0.03 0.68 0.84 0.10 1.00 -0.73 -0.55 1.00

At t = 0 every cosine is 1 and every sine 0. Neighbouring steps 0 and 1 differ mostly in the fast dimensions; steps 500 and 999 differ in the slow ones too. The DDPM rows are not a reordering of the LDM rows, because DDPM's frequencies run from 1 down to exactly 10000−1 while LDM's stop one step short, at 0.001. Each model learns the MLP that follows, so either works.

GroupNorm by hand

One sample with 4 channels on a 2-pixel grid, values per channel [1.0, 3.0], [2.0, 2.0], [10.0, 14.0], [12.0, 12.0], normalized with 2 groups (no affine). Channels 0–1 form group 1 and channels 2–3 group 2.

GroupValues pooledMeanVarianceOutput
142.00.5[-1.414, 1.414] [0.000, 0.000]
2412.02.0[-1.414, 1.414] [0.000, 0.000]

Each group lands at mean 0 and variance 1 on its own, so the large values of group 2 do not drown out group 1. With one group (LayerNorm over channels and positions) the shared mean is 7.0 and variance 26.25; channel 0 becomes [-1.171, -0.781] and the difference between its two pixels shrinks from 2 to about 0.39. No other sample is consulted either way, which is the point compared with BatchNorm.

Shape walk through Stable Diffusion v1's U-Net

Down path, with every row's output saved for a skip (shapes are [b, C, H, W]):

#BlockOutput shape
1conv_in[b, 320, 64, 64]
2ResBlock + attention[b, 320, 64, 64]
3ResBlock + attention[b, 320, 64, 64]
4Downsample[b, 320, 32, 32]
5ResBlock + attention[b, 640, 32, 32]
6ResBlock + attention[b, 640, 32, 32]
7Downsample[b, 640, 16, 16]
8ResBlock + attention[b, 1280, 16, 16]
9ResBlock + attention[b, 1280, 16, 16]
10Downsample[b, 1280, 8, 8]
11ResBlock[b, 1280, 8, 8]
12ResBlock[b, 1280, 8, 8]
—middle: ResBlock + attention + ResBlock[b, 1280, 8, 8]

Up path, popping the saved tensors in reverse:

#ResolutionInput + skip = concatOutputAttentionThen
18×81280 + 1280 = 25601280no
28×81280 + 1280 = 25601280no
38×81280 + 1280 = 25601280noupsample to 16
416×161280 + 1280 = 25601280yes
516×161280 + 1280 = 25601280yes
616×161280 + 640 = 19201280yesupsample to 32
732×321280 + 640 = 1920640yes
832×32640 + 640 = 1280640yes
932×32640 + 320 = 960640yesupsample to 64
1064×64640 + 320 = 960320yes
1164×64320 + 320 = 640320yes
1264×64320 + 320 = 640320yes

Up block 1 takes the middle block's 1280 channels plus the last saved tensor, the 8 × 8 ResBlock output; up block 12 takes the 320 channels from block 11 plus conv_in's output, the very first thing saved. The concatenated widths vary within a level because the saved tensors come from blocks of different widths: at 16 × 16 the third up block pairs 1280 channels with the 640-channel downsample output from 32 × 32. After the last up block, GroupNorm, SiLU and a 3 × 3 convolution return [b, 4, 64, 64].

Why attention waits for small grids

Attention scores per head per image at each resolution (self: tokens²; cross: tokens × 77 text tokens):

GridTokensSelf-attention scoresCross-attention scores
64×644,09616,777,216315,392
32×321,0241,048,57678,848
16×1625665,53619,712
8×8 (middle only)644,0964,928

Each halving of the side cuts the tokens by 4 and the self-attention scores by 16. At 64 × 64 a single head already builds a 4,096 × 4,096 matrix, which is why latent diffusion runs at 64 × 64 rather than on 512 × 512 pixels, where the same layer would need 68,719,476,736 scores per head, and why memory-efficient attention kernels matter so much for this model. Cross-attention stays cheap because the prompt has only 77 tokens.

Counting the parameters

PartParametersShare
Time MLP (320 → 1280 → 1280)2,050,5600.2%
conv_in (4 → 320, 3 × 3)11,8400.0%
22 ResBlocks (8 down, 2 middle, 12 up)537,670,40062.6%
16 transformer blocks267,239,36031.1%
3 downsample + 3 upsample convolutions52,536,6406.1%
Output GroupNorm + conv (320 → 4)12,1640.0%
Total859,520,964100%

The up-path ResBlocks are the heaviest because their first convolution reads the concatenated input. The 16 transformer blocks are 6 down, 1 middle and 9 up. The 1280-channel levels hold most of the weight: a 3 × 3 convolution from 2560 to 1280 channels alone has 29,492,480 parameters, while the whole 64 × 64 level is light. The convolutions are cheap where the grid is large and expensive where it is small, the opposite of the activations.

DiT, at 256 × 256 images (a 32 × 32 × 4 latent):

ModelWidthDepthHeadsPatchTokensParameters
DiT-S/2384126225632,865,056
DiT-B/276812122256130,315,808
DiT-L/2102424162256457,840,672
DiT-XL/2115228162256674,834,720
DiT-XL/411522816464675,000,704
DiT-XL/811522816816675,664,640

One DiT-XL block has 23,905,152 parameters, of which 7,969,536 are the adaLN Linear(1152, 6912): more than the attention's 5,313,024. The fixed position table (256 × 1152 = 294,912 numbers) is not trained and not counted. Changing the patch from 2 to 8 barely changes the parameters but cuts the tokens from 256 to 16, so attention gets 256 times cheaper and the samples get worse: in DiT, compute, not parameter count, tracks quality.

Why does nn.GroupNorm(32, 48) fail, and what group counts would work for 48 channels?

The channels must split evenly into groups, and 48 is not a multiple of 32, so the constructor raises ValueError. Any divisor of 48 works: 1, 2, 3, 4, 6, 8, 12, 16, 24 or 48. This is why U-Net widths are multiples of 32.

Remove the skip connections. What goes wrong first, coarse layout or fine detail?

Fine detail. The 8 × 8 bottleneck can still encode layout, but per-pixel noise at 64 × 64 cannot pass through it, so low-noise steps, which need exact positions, degrade most. The skips are the only route for full-resolution information to reach the output.

Implementation

A two-level U-Net with timestep conditioning in plain PyTorch. It keeps every idea from Stable Diffusion's network at toy size: GroupNorm-SiLU-conv ResBlocks, the embedding added per channel, a stride-2 downsample, nearest upsample plus conv, concatenated skips, and zero-initialized last convolutions.

import math
import torch
import torch.nn as nn
import torch.nn.functional as F


def timestep_embedding(t, dim, max_period=10000):
    half = dim // 2
    freqs = torch.exp(-math.log(max_period) * torch.arange(half, dtype=torch.float32) / half)
    args = t[:, None].float() * freqs[None]
    return torch.cat([torch.cos(args), torch.sin(args)], dim=-1)


class ResBlock(nn.Module):
    def __init__(self, cin, cout, emb_dim, groups=32):
        super().__init__()
        self.norm1 = nn.GroupNorm(groups, cin)
        self.conv1 = nn.Conv2d(cin, cout, 3, padding=1)
        self.emb = nn.Linear(emb_dim, cout)
        self.norm2 = nn.GroupNorm(groups, cout)
        self.conv2 = nn.Conv2d(cout, cout, 3, padding=1)
        nn.init.zeros_(self.conv2.weight)
        nn.init.zeros_(self.conv2.bias)
        self.skip = nn.Identity() if cin == cout else nn.Conv2d(cin, cout, 1)

    def forward(self, x, emb):
        h = self.conv1(F.silu(self.norm1(x)))
        h = h + self.emb(F.silu(emb))[:, :, None, None]
        h = self.conv2(F.silu(self.norm2(h)))
        return self.skip(x) + h


class TinyUNet(nn.Module):
    def __init__(self, cin=4, ch=64, emb_dim=256):
        super().__init__()
        self.ch = ch
        self.time = nn.Sequential(nn.Linear(ch, emb_dim), nn.SiLU(), nn.Linear(emb_dim, emb_dim))
        self.conv_in = nn.Conv2d(cin, ch, 3, padding=1)
        self.down1 = ResBlock(ch, ch, emb_dim)
        self.down = nn.Conv2d(ch, ch, 3, stride=2, padding=1)
        self.down2 = ResBlock(ch, 2 * ch, emb_dim)
        self.mid = ResBlock(2 * ch, 2 * ch, emb_dim)
        self.up2 = ResBlock(4 * ch, 2 * ch, emb_dim)
        self.up = nn.Sequential(nn.Upsample(scale_factor=2, mode="nearest"), nn.Conv2d(2 * ch, 2 * ch, 3, padding=1))
        self.up1 = ResBlock(3 * ch, ch, emb_dim)
        self.out = nn.Sequential(nn.GroupNorm(32, ch), nn.SiLU(), nn.Conv2d(ch, cin, 3, padding=1))
        nn.init.zeros_(self.out[-1].weight)
        nn.init.zeros_(self.out[-1].bias)

    def forward(self, x, t):
        emb = self.time(timestep_embedding(t, self.ch))
        h1 = self.down1(self.conv_in(x), emb)               # [b, 64, 32, 32], saved
        h2 = self.down2(self.down(h1), emb)                 # [b, 128, 16, 16], saved
        h = self.mid(h2, emb)
        h = self.up2(torch.cat([h, h2], dim=1), emb)         # 128 + 128 -> 128
        h = self.up1(torch.cat([self.up(h), h1], dim=1), emb)  # 128 + 64 -> 64, at 32 x 32
        return self.out(h)


model = TinyUNet()
x = torch.randn(8, 4, 32, 32)
t = torch.randint(0, 1000, (8,))
assert model(x, t).shape == x.shape

Attention slots in after a ResBlock at a small resolution. A simplified block (Stable Diffusion's adds the 1 × 1 projections and a GEGLU feed-forward) using nn.MultiheadAttention, whose kdim and vdim let keys and values come from a text encoder of a different width:

class SpatialAttention(nn.Module):
    def __init__(self, ch, ctx_dim, heads=8):
        super().__init__()
        self.norm = nn.GroupNorm(32, ch)
        self.ln1, self.ln2 = nn.LayerNorm(ch), nn.LayerNorm(ch)
        self.self_attn = nn.MultiheadAttention(ch, heads, batch_first=True)
        self.cross_attn = nn.MultiheadAttention(ch, heads, kdim=ctx_dim, vdim=ctx_dim, batch_first=True)

    def forward(self, x, ctx):
        b, c, height, width = x.shape
        h = self.norm(x).flatten(2).transpose(1, 2)        # [b, H*W, C]
        q = self.ln1(h)
        h = h + self.self_attn(q, q, q, need_weights=False)[0]
        q = self.ln2(h)
        h = h + self.cross_attn(q, ctx, ctx, need_weights=False)[0]
        return x + h.transpose(1, 2).reshape(b, c, height, width)

Tradeoffs

  • U-Net versus DiT. The U-Net's multi-resolution convolutions are a strong prior for images and work well at moderate scale; its design has many knobs (levels, multipliers, where attention goes). DiT has one width, one block and a patch size, scales predictably with compute, and reuses transformer tooling, but it is less data-efficient and its token count, and cost, grows quickly as patches shrink.
  • Where attention goes. Attention at fine resolutions improves global coherence and prompt following but its cost grows with the fourth power of the side. Stable Diffusion v1 pays for it at 64 × 64; DDPM kept it at 16 × 16 only.
  • Concatenated skips. They preserve detail but make up-path convolutions wide; most of the parameters sit in the 1280-channel levels.
  • Conditioning. Adding the embedding is cheapest; adaptive normalization (scale and shift, as in ADM's AdaGN or DiT's adaLN-Zero) is more expressive for global signals like timestep and class; cross-attention is needed for sequences like prompts.
  • Zero initialization. Starting every residual branch at zero makes deep networks stable from step one, at no cost.
  • Fixed sizes. The latent side must survive repeated halving (a multiple of 8 for three downsamples), and a network trained at one resolution drifts at others without fine-tuning.
DiT-XL/4 has 675,000,704 parameters, almost exactly XL/2's 674,834,720. Why is XL/2 the better model?

The weights are nearly the same, but XL/2 sees 256 tokens instead of 64, so it does about four times the compute per forward pass (more for attention). The DiT paper found sample quality tracks that compute, and smaller patches gave better FID at every model size.

Connections

Read first

Read next

No page builds on this one yet.

References