TAI BUI
Back to curriculum
BuildPython

Latent Diffusion & Stable Diffusion

Pixel-space diffusion on 512×512 images is a computational war crime. Rombach et al. (2022) noticed that you do not need all 786k dimensions to generate an image. You need enough to capture semantic structure, plus a separate decoder for the rest. Run diffusion inside a VAE's latent space, and that one idea is Stable Diffusion.

TIME: ~75 minutesPREREQUISITES: Phase 8 · 02 (VAE), Phase 8 · 06 (DDPM), Phase 7 · 09 (ViT)

Learning Objectives

  • Explain why running diffusion in a VAE's latent space cuts compute compared with pixel space, using the spatial and channel arithmetic.
  • Describe the two-stage latent diffusion pipeline and how text conditioning reaches the denoiser through cross-attention.
  • Apply classifier-free guidance and explain how the scale `w` trades diversity for prompt adherence.
  • Diagnose common failure modes: VAE scaling-factor mismatch, mixed latent spaces, a wrong text encoder, and an over-high guidance scale.
  • Plan staggered loading, quantization, and CPU offload to run a 12B diffusion transformer on an 8GB GPU.

The Problem

Latent diffusion is diffusion run on the compressed latent code of a VAE instead of on raw pixels. The reason to do it is cost. Pixel-space diffusion at 512² means the U-Net runs on tensors of shape [B, 3, 512, 512]. Each sampling step is ~100 GFLOPS for a 500M-param U-Net, so fifty steps is 5 TFLOPS per image. Train on a billion images and the compute bill is absurd.

Most of those FLOPs go to pushing perceptually unimportant detail through the net, like the high-frequency texture a lossy VAE could compress away. Rombach's idea: train a VAE once (the first stage), freeze it, and run diffusion in the 4-channel 64×64 latent space (the second stage). Same U-Net, but 8× smaller along each axis (64×64 positions instead of 512×512, so 64× fewer spatial positions) and 4 latent channels instead of 3 pixel channels (~48× fewer values in total). Per-step compute falls by roughly the position-count factor, ~64×, for comparable quality.

That is the Stable Diffusion recipe. SD 1.x / 2.x used an 860M U-Net over 64×64×4 latents, SDXL used a 2.6B U-Net over 128×128×4, and SD3 swapped the U-Net for a Diffusion Transformer (DiT) with flow matching. Flux.1-dev (Black Forest Labs, 2024) ships a 12B-param DiT-MMDiT. All of them run on the same two-stage substrate.

Warm-up0/3
1. In a trained VAE, what does the decoder do?Warm-up
A VAE drawn left to right: an 8 by 8 toy image, an encoder box, a 2 by 2 latent code, a highlighted decoder box, and an output box marked with a question mark.
Toy pixels and code values are illustrative, not from a real VAE.
2. What does the network in a DDPM learn to predict at each noising step?Warm-up
An 8 by 8 toy image gets noise added, the noisy result goes into a network along with the step number t, and the network's output is a box marked with a question mark.
Toy image; noisy version computed with numpy at step 20 of the lesson's 40-step linear schedule.
3. A conv net processes a 512×512 image and then a 64×64 image with the same kernels. Roughly how does its work compare?Warm-up
A large square labelled 512 by 512 next to a much smaller square labelled 64 by 64, drawn at the same scale.
Check: The Problem0/2
1. What is latent diffusion?Define
A four-row table of models with their latent shapes and parameter counts: SD 1.5 at 64 by 64 by 4 and 860M, SDXL at 128 by 128 by 4, SD3 and Flux.1-dev at 128 by 128 by 16.
Values copied from the lesson's architecture table.
2. Why does diffusion in a VAE latent space cost much less per step than diffusion in pixel space?Explain
A large square for pixel space, tensor shape B by 3 by 512 by 512, and a much smaller square for latent space, shape B by 4 by 64 by 64, drawn at the same scale.

The Concept

Latent diffusion: VAE compression + diffusion in latent space

Latent diffusion splits generation into two stages, trained separately.

  1. Stage 1: VAE. Encoder E(x) → z, decoder D(z) → x. The target is 8× downsampling along each spatial axis, with a small channel count (4 for SD 1.x), so a 64×64×4 latent holds ~1/48 as many values as a 512×512×3 image. Loss = reconstruction (L1 + LPIPS perceptual) + KL. The KL weight is small so z isn't forced too close to Gaussian, since we don't need exact sampling from z. An adversarial loss is often added so decoded images come out sharp.

  2. Stage 2: diffusion on z. Treat z = E(x_real) as the data and train a U-Net (or DiT) to denoise z_t. At inference, sample z_0 with diffusion, then decode x = D(z_0).

Text conditioning adds two components. A frozen text encoder produces the tokens (CLIP-L for SD 1.x, CLIP-L + OpenCLIP-G for SD 2/XL, T5-XXL for SD3 and Flux). Cross-attention injects them: each U-Net block takes [Q = image features, K = V = text tokens] and mixes them in. Text reaches the image through those tokens.

The loss function is identical to Lesson 06. Same DDPM / flow matching MSE on noise, with a different data domain underneath.

Check: The Concept0/6
1. In latent diffusion, what is the first stage?Define
Two boxes labelled stage 1 and stage 2, trained separately, above four cards for parts of an SD 1.x model: VAE encoder, VAE decoder, a U-Net with 860M parameters, and the CLIP-L text encoder with 77 tokens.
2. In latent diffusion, what is the second stage?Define
Generation flow for SD 1.x: random noise of shape 64 by 64 by 4, a box marked stage 2 equals question mark, a latent z_0 of the same shape, then the decoder D giving a 512 by 512 by 3 image.
3. How does text conditioning reach the denoiser in Stable Diffusion?Define
A frozen CLIP-L text encoder produces 77 text tokens on the left. On the right a noisy latent enters a stack of three U-Net blocks. Dashed lines with a question mark join the tokens to each block.
The number of U-Net blocks drawn is schematic.
4. Which loss terms train the Stage 1 VAE?Define
The Stage 1 VAE: image x goes through encoder E to latent z and decoder D to a reconstruction x̂. Dashed lines from x and x̂ lead to a box marked training loss equals question mark.
5. During inference, when does the VAE decoder run in latent diffusion?Explain
A row of latents z_T, z_T-1, an ellipsis, z_1 and z_0 joined by arrows for sampling steps, with a dashed box above reading VAE decoder D runs when.
Latent shape from the lesson's SD 1.x numbers.
6. You switch from 512×512 to 1024×1024 output with an SD-style first stage (8× per axis, 4 channels). What latent shape does the denoiser work on?Apply
A 1024 by 1024 by 3 image goes into an encoder labelled 8 times per axis, and the output is a box marked question mark by question mark by question mark for the latent the denoiser sees.

Architecture variants

ModelYearBackboneLatent shapeText encoderParams
SD 1.52022U-Net64×64×4CLIP-L (77 tokens)860M
SD 2.12022U-Net64×64×4OpenCLIP-H865M
SDXL2023U-Net + refiner128×128×4CLIP-L + OpenCLIP-G2.6B + 6.6B
SDXL-Turbo2023Distilled128×128×4same1-4 step sampling
SD32024MMDiT (multimodal DiT)128×128×16T5-XXL + CLIP-L + CLIP-G2B / 8B
Flux.1-dev2024MMDiT128×128×16T5-XXL + CLIP-L12B
Flux.1-schnell2024MMDiT distilled128×128×16T5-XXL + CLIP-L12B, 1-4 step

The trend across these models: U-Net gives way to DiT (a transformer over latent patches), the text encoder gets bigger (T5 beats CLIP for prompt adherence), and latent channels go up (4 → 16 gives more headroom for detail).

Check: Architecture variants0/4
1. What is a DiT?Define
A latent grid split into 16 numbered patches in a 4 by 4 layout, and below it the same 16 patches lined up as a row of tokens.
The patch count is illustrative; the lesson gives no patch size.
2. What is an MMDiT?Define
Two groups of tokens for SD3: text tokens from T5-XXL, CLIP-L and CLIP-G on the left, and image tokens from patches of a 128 by 128 by 16 latent on the right, with a question mark circle between them.
Token counts are illustrative; encoder and latent names are from the lesson's table.
3. Which text encoder do SD3 and Flux.1-dev use that SD 1.5 does not?Explain
A table of text encoders: SD 1.5 uses CLIP-L with 77 tokens, SD3 uses a hidden encoder plus CLIP-L and CLIP-G, and Flux.1-dev uses the same hidden encoder plus CLIP-L.
Rows copied from the lesson's architecture table with one name replaced by a question mark.
4. Which pair of changes does the Architecture variants section name as the trend from SD 1.5 to Flux?Explain
A table of four models by year and parameter count: SD 1.5 in 2022 with 860M, SDXL in 2023 with 2.6B plus 6.6B, SD3 in 2024 with 2B or 8B, and Flux.1-dev in 2024 with 12B.
Values copied from the lesson's architecture table.

Build It

code/main.py stacks a toy 1-D first stage (a fixed rescale, encode(x) = 0.5x and decode(z) = 2z, standing in for a real conv-net VAE) under a small diffusion model and adds class conditioning with classifier-free guidance. The denoiser is a 2-hidden-layer tanh MLP with a hand-written backward pass, and the script carries its own linear beta schedule (T=40), so it needs no code from Lesson 06. It shows the key insight: the same diffusion loss works on raw 1-D values and on encoded values.

Step 1: encoder/decoder

python
def encode(x):    return x * 0.5          # toy "compression" to smaller scale
def decode(z):    return z * 2.0

A real VAE has trained weights. Here a linear map is enough to show that diffusion operates on z without caring about the original data space.

Step 2: diffusion in z-space

Same noise-prediction objective as Lesson 06, just with the script's own schedule and MLP in place of a U-Net. The data the net sees is z = E(x). After sampling z_0, decode with D(z_0).

Step 3: classifier-free guidance

Classifier-free guidance blends a conditional and an unconditional prediction. During training, drop the class label 10% of the time (replace with a null token). At inference, compute both ε_cond and ε_uncond, then:

python
eps_cfg = (1 + w) * eps_cond - w * eps_uncond

w = 0 is no guidance (the plain conditional prediction). This is the (1+w) form from Ho & Salimans, and it is what main.py computes. Many libraries write the same blend as eps_uncond + s * (eps_cond - eps_uncond) and call s the guidance scale. Expanding shows the two are identical when s = 1 + w: eps_uncond + (1 + w) * (eps_cond - eps_uncond) = (1 + w) * eps_cond - w * eps_uncond. So s = 1 is no guidance, and a diffusers guidance_scale of 7.5 is w = 6.5 here. The w values in this lesson (the sweep, the 3-7 range, the w > 10 warning) use the (1+w) convention. As a production rule of thumb, w around 3 is a common default and w of 7 or more starts to look saturated and over-sharp. The toy script does not measure either claim: it sweeps w in {0, 1, 3, 7} and prints the per-class mean over 200 samples at each setting.

Step 4: text conditioning (concept, not code)

Replace the class label with a frozen text encoder output. Feed the text embedding to the U-Net via cross-attention:

python
h = h + CrossAttention(Q=h, K=text_embed, V=text_embed)

Swapping the class label for a text embedding and adding this attention step is what separates a class-conditional diffusion model from Stable Diffusion.

Check: Build It0/10
1. What is classifier-free guidance (CFG)?Define
A single trained net is called twice with the same noisy value and step: once with class label 1, giving ε_cond of -1.32, and once with the null token, giving ε_uncond of +0.29. A box marked ε_cfg equals question mark follows.
Values captured by replaying the lesson's main.py (seed 11, 4000 training steps): class 1 and the null class at z = -1.0, t = 20.
2. What is the null token used for in classifier-free guidance?Define
Three one-hot vectors of length 3 from the toy script: class 0 as 1,0,0, class 1 as 0,1,0 and the null token as 0,0,1, which is index 2.
Vectors computed with the lesson's one_hot and NULL_CLASS.
3. Why is the class label dropped about 10% of the time during CFG training?Explain
Two rows of 8 chips each for the first 16 training steps: the class label drawn, and the label actually fed to the net, where one chip in the first 16 shows null instead of its class.
Captured by replaying main.py with seed 11; the first 16 of 4000 steps are shown.
4. What does raising w from 3 to 7 do to the generated images?Explain
Bar charts of the two blend weights: for w = 3, plus 4 on ε_cond and minus 3 on ε_uncond; for w = 7, plus 8 on ε_cond and minus 7 on ε_uncond.
Weights computed from the formula.
5. The CFG formula is (1 + w)·ε_cond − w·ε_uncond. What does setting w = 0 give you?Apply
The guidance formula with w replaced by 0: (1 + 0) times ε_cond minus 0 times ε_uncond equals a question mark.
6. In the toy script, `decode(z)` returns `z * 2.0`. The diffusion sampler returns z_0 = -1.0. What does decode give back in data space?Apply
Two lines of main.py defining encode as x times 0.5 and decode as z times 2.0, then a flow: the sampler returns z_0 equals minus 1.0, which goes into decode, giving a question mark.
7. With ε_cond = 0.8, ε_uncond = 0.2 and w = 2, what does (1+w)·ε_cond − w·ε_uncond equal?Apply
A number line from 0 to 2.6 with a point for ε_uncond at 0.2 and a point for ε_cond at 0.8, and a line below asking for (1+w) times ε_cond minus w times ε_uncond with w equal to 2.
8. A diffusers example uses `guidance_scale = 7.5`. Under the lesson's (1+w) convention, what is w?Apply
Two typed code snippets of the same guidance blend: a diffusers-style one with s equal to 7.5, and the lesson's (1+w) style with w left as a question mark.
Typed from the lesson, not captured output.
9. A colleague copies `guidance_scale = 7.5` from a diffusers example and sets w = 7.5 in a (1+w) implementation. What happens?Analyze
Two typed snippets: a diffusers call with guidance_scale equal to 7.5, and a (1+w) implementation where w was set to 7.5.
Typed from the stem, not captured output.
10. The toy script round-trips x through encode and decode with no loss. Why can that mislead you about a real first stage?Analyze
Two rows tracing x = -2.0 and x = +2.0 through x times 0.5 to z = -1.0 and +1.0, then z times 2 back to -2.0 and +2.0.
Computed with the lesson's encode and decode.

Pitfalls

  • VAE-scale mismatch. SD 1.x VAEs have a scaling constant (scaling_factor ≈ 0.18215) applied after encoding (you multiply the latents by it, which is the same as dividing by ~5.5). Forget it and the U-Net trains on latents with wildly wrong variance. Each VAE family has its own value (SDXL's differs from SD 1.x's), so read it from the checkpoint's config.
  • Text encoder silently wrong. SD3 needs T5-XXL with >=128 tokens, and the fallback to CLIP-only is lossy. Check use_t5=True, or prompt fidelity craters.
  • Mixing latent spaces. SDXL, SD3, and Flux use different VAEs, so a LoRA trained on SDXL latents will not work on SD3. Hugging Face diffusers 0.30+ refuses to load mismatched checkpoints.
  • CFG too high. w > 10 produces saturated, oily images and over-fits the prompt at the cost of diversity. The sweet spot is w = 3-7.
  • Negative prompts leaking. An empty negative prompt becomes the null token, while a filled one becomes ε_uncond. These are different things, and some pipelines silently default to the null.
Check: Pitfalls0/7
1. What is the VAE scaling factor?Define
A table of three numbers around an SD 1.x VAE: scaling_factor about 0.18215, latent channels 4, and downsample per axis 8.
Values from the lesson's Pitfalls section and Concept section; typed, not read from a checkpoint file.
2. An SD 1.x encoder outputs a latent value of 2.0. After applying the scaling factor of about 0.18215, roughly what is it?Apply
A number line from 0 to 2.6 with a point at 2.0 for the encoder output, and a line below asking for the value after applying the scaling factor of about 0.18215.
3. You encode images with an SD 1.x VAE but skip the scaling factor before training a denoiser. What goes wrong?Analyze
A pipeline: image into the SD 1.x VAE encoder, then a dashed red box for the multiply by 0.18215 step marked skipped, then the denoiser training on z.
4. A LoRA you trained on SDXL latents fails to work on SD3. What is the most likely reason?Analyze
A box for a LoRA trained on SDXL latents with an arrow into an SD3 pipeline box that reads LoRA does not work.
5. An SD3 pipeline runs without errors but prompt fidelity is poor. What should you check first?Analyze
An SD3 pipeline as four boxes in a row: prompt, text encoding, denoiser MMDiT, VAE decode. Below it a red-bordered note says it runs without errors but prompt fidelity is poor.
6. Your outputs look saturated and oily at w = 12. What is the usual fix?Analyze
Two bars for w = 12: plus 13 on ε_cond and minus 12 on ε_uncond, next to a note that outputs look saturated and oily.
Weights computed from the formula.
7. At w = 5 your negative prompt "blurry" has no effect, and the output matches a run with no negative prompt. What is the likely cause?Analyze
Two run cards. Run A sets negative_prompt to blurry with w equal to 5. Run B has no negative prompt and w equal to 5. Arrows from both lead to the words outputs match.
Typed from the stem, not captured output.

Use It

These are the production stacks in 2026, by target:

TargetRecommended backbone
Narrow domain, paired data, training a model from scratchSDXL fine-tune (LoRA / full), fastest to ship
Open-domain text-to-image, open weightsFlux.1-dev (12B, Apache / non-commercial) or SD3.5-Large
Fastest inference, open weightsFlux.1-schnell (1-4 step, Apache) or SDXL-Lightning
Best prompt adherence, hostedGPT-Image / DALL-E 3 (still), Midjourney v7, Imagen 4
Edit workflowsFlux.1-Kontext (Dec 2024), natively accepts image + text
Research, baselineSD 1.5, ancient but well-studied
Check: Use It0/2
1. You need the fastest open-weights text-to-image inference for a live demo. Which choice fits?Design
Two requirement boxes, open weights and lowest latency, above a row of numbered sampling steps 1 to 7 continuing to N, and the line time per image is about N times time per step.
Step boxes are schematic; the cost relation follows the Problem section.
2. Your product must take a user photo plus a text instruction and return an edited image. Which backbone does the lesson point to?Design
A user photo plus a text instruction go into a box marked backbone question mark, which outputs an edited image. All pictures are plain gray toy tiles.
Illustrative toy tiles, not real images.

Ship It

Save outputs/skill-sd-prompter.md. The skill takes a text prompt and a target style, and outputs a model + checkpoint, CFG scale, sampler, negative prompt, resolution, an optional ControlNet/IP-Adapter combo, and a per-step QA checklist.

Author's Note

I'd run the guidance sweep in main.py before reading anything else. The sample function evaluates the net twice per step, once with the class and once with the null class, then blends the two with w. Watching the per-class means as w goes from 0 to 7 is the quickest way I know to get a feel for what the scale does, and the whole script finishes in seconds in pure Python.

My bet is that the toy encoder hides what a real first stage adds. In the script, encode multiplies by 0.5 and decode multiplies by 2.0, so the round trip loses nothing. A learned VAE compresses, and the scaling-factor and mismatched-latent pitfalls above come from that stage, which the toy version skips. Exercise 2 is where I'd go next, since it swaps in a learned encoder with a reconstruction loss.

For the ship-it skill, I'd write the QA checklist straight from the pitfalls list: CFG range, how the negative prompt is handled, and whether the VAE matches the checkpoint.

Exercises

  1. Easy. Run code/main.py with the guidance sweep w ∈ {0, 1, 3, 7} (the script's own list). Compare the printed per-class means against the real data means of -2 and +2. At what w does a class mean first move past its real mean? Then add 15 to the list and see whether the trend continues.
  2. Medium. Swap the toy linear encoder for a tanh-MLP encoder/decoder pair with a reconstruction loss. Retrain diffusion on the new latents. Does sample quality change?
  3. Hard. Set up a real Stable Diffusion inference with diffusers: load sdxl-base, run 30 Euler steps with CFG=7, time it. Now switch to sdxl-turbo with 4 steps and CFG=0. Same subject, different quality. Describe what changed and why.

Key Terms

TermWhat people sayWhat it actually means
First stage"The VAE"Trained encoder/decoder pair; compresses 512² to 64².
Second stage"The U-Net"Diffusion model over the latent space.
CFG"Guidance scale"(1+w)·ε_cond - w·ε_uncond; tunes conditioning strength.
Null token"Empty prompt embed"Unconditional embed used for ε_uncond.
Cross-attention"How text gets in"Each U-Net block attends to text tokens as K and V.
DiT"Diffusion Transformer"Replace U-Net with a transformer over latent patches; scales better.
MMDiT"Multi-modal DiT"SD3's architecture: text and image streams with joint attention.
VAE scaling factor"Magic number"Multiply latents by ~0.18215 after encoding (divide by ~5.5) so diffusion operates in roughly unit-variance space.

Production note: running Flux-12B on an 8GB consumer GPU

The reference Flux integration is the canonical "I have a consumer GPU, can I ship this?" recipe. It applies the same three-knob recipe the production inference literature lists to a diffusion DiT:

  1. Staggered loading. Flux has four networks that do not need to sit in VRAM at the same time: the T5-XXL text encoder (~10 GB in fp32), CLIP-L (small), the 12B MMDiT, and the VAE. Encode the prompt first, delete the encoders, load the DiT, denoise, delete the DiT, load the VAE, decode. A consumer 8GB GPU fits one stage at a time.
  2. 4-bit quantization via bitsandbytes. Use BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_compute_dtype=torch.bfloat16) on both the T5 encoder and the DiT. It cuts memory 8×, and the quality drop is imperceptible for text-to-image per Aritra's benchmarks (linked in the notebook).
  3. CPU offload. pipe.enable_model_cpu_offload() swaps modules between CPU and GPU as each forward pass advances. It adds 10-20% latency but lets the pipeline run at all.

The memory accounting: 10 GB T5 / 8 = 1.25 GB quantized, 12 B params × 0.5 bytes = ~6 GB for the quantized DiT, plus activations. In stas00's terms this is the extreme end of TP=1 inference, with no model parallelism and maximum quantization. For production you'd run TP=2 or TP=4 on H100s. For a single dev laptop, this is the recipe.

Check: Production note: running Flux-12B on an 8GB consumer GPU0/5
1. Why does the Flux-on-8GB recipe load the encoders, then the DiT, then the VAE in stages?Explain
Three steps in order: encode the prompt with T5-XXL and CLIP-L, denoise with the 12B MMDiT, decode with the VAE, above a bar for an 8 GB GPU.
2. What does CPU offload trade away to make a Flux pipeline fit on a small GPU?Explain
Two memory regions: CPU memory holding T5-XXL, CLIP-L, the 12B MMDiT and the VAE, and a small GPU memory holding one module in use, with arrows swapping modules between them.
Layout is schematic; the call name and the swap behaviour are from the production note.
3. The 12B-parameter Flux DiT is quantized to 4 bits (0.5 bytes per parameter). Roughly how much memory do its weights need?Apply
An equation: 12 B parameters times 0.5 byte each (4 bits) equals a box marked question mark GB.
4. You load the T5-XXL encoder, the 12B DiT and the VAE onto an 8GB GPU together and run out of memory. Which change targets the cause?Analyze
Three boxes for the T5-XXL text encoder, the 12B DiT and the VAE, all loaded onto one 8 GB GPU, above a terminal showing a run ending with CUDA out of memory.
Terminal text is typed to match the stem, not captured; run_flux.py is a placeholder name.
5. You must run Flux.1-dev text-to-image on a single 8GB GPU. Which plan fits the memory budget?Design
A list of the four Flux.1-dev networks with sizes where the lesson gives them (T5-XXL about 10 GB in fp32, the MMDiT 12B parameters), above a bar for an 8 GB GPU and a longer bar for the T5 encoder.
Bars at 40 px per GB; the lesson gives a GB figure only for T5-XXL.

Further Reading

Code

Ships With