TAI BUI
Back to curriculum
BuildPython

Stable Diffusion — Architecture & Fine-Tuning

Strip away the demo and Stable Diffusion is a DDPM running in the latent space of a frozen VAE, steered by text through cross-attention, pushed toward the prompt by classifier-free guidance, and sampled with a fast deterministic ODE solver.

TIME: ~75 minutesPREREQUISITES: Phase 4 Lesson 10 (Diffusion), Phase 7 Lesson 02 (Self-Attention)

Learning Objectives

  • Trace the five pieces of a Stable Diffusion pipeline: VAE, text encoder, U-Net, scheduler, safety checker — and what each of them actually does
  • Explain latent diffusion and why training in a 4x64x64 latent space (instead of a 3x512x512 image) reduces compute by 48x without quality loss
  • Use `diffusers` to generate images, run image-to-image, inpainting, and ControlNet-guided generation
  • Fine-tune Stable Diffusion with LoRA on a small custom dataset and load the LoRA adapter at inference

The Problem

Training a DDPM directly on 512x512 RGB images is expensive. Every training step backprops through a U-Net that sees 3x512x512 = 786,432 input values, and sampling needs 50 or more forward passes through that same network. At Stable Diffusion 1.5's quality level (released 2022), pixel-space diffusion would cost roughly 256 GPU-months to train and 10 to 30 seconds per image on a consumer GPU.

The trick that made open-weight text-to-image practical was latent diffusion (Rombach et al., CVPR 2022): train a VAE that maps a 3x512x512 image to a 4x64x64 latent tensor and back, then run the diffusion process in that latent space instead. Compute drops by (3*512*512)/(4*64*64) = 48x, and sampling time drops from tens of seconds to under two seconds on the same GPU.

Nearly every modern image-generation model (SDXL, SD3, FLUX, HunyuanDiT, Wan-Video) is a latent diffusion model at heart, differing mainly in the autoencoder, the denoiser (U-Net or DiT), and how text conditioning gets injected. Learn Stable Diffusion and you've learned the template the rest of the field builds on.

Warm-up0/3
1. In a plain DDPM, the kind you built in the Diffusion lesson, what does the network's forward pass predict at each denoising step?Warm-up
A denoising network takes a noisy input x_t and a timestep t and produces an unlabeled output that the sampler uses to compute x_(t-1).
2. In self-attention, a query vector is compared against a set of key vectors. What do the resulting scores control?Warm-up
A query vector scored against three key vectors by dot product and softmax, next to three value vectors, with how they combine left unresolved.
scores computed with numpy, seed 7, illustrative toy vectors
3. A program draws random numbers while generating a result. You want to rerun it tomorrow and get exactly the same output. What do you fix?Warm-up
One program's random draws from a run today, next to an unknown set of draws for a rerun tomorrow that has to match it exactly.
today's draws captured from numpy, unseeded
Check: The Problem0/2
1. What is latent diffusion?Define
A frozen VAE with its encode and decode tensor shapes next to a DDPM sampler's step sequence, with the way the two are combined left unresolved.
2. Why does Stable Diffusion run its diffusion process in a 4x64x64 latent space rather than directly on 3x512x512 pixel images?Explain
The per-step input size in pixel space, 3 by 512 by 512, next to the latent size, 4 by 64 by 64, with the reduction factor left blank.

The Concept

The pipeline

Mermaid diagram

  • VAE: a frozen autoencoder. The encoder turns an image into latents (used for img2img and training); the decoder turns latents back into an image.
  • Text encoder: a CLIP text encoder (SD 1.x/2.x), CLIP-L + CLIP-G (SDXL), or T5-XXL (SD3/FLUX). Produces a sequence of token embeddings.
  • U-Net: the denoiser. Cross-attention layers attend from the latents to the text embedding at every resolution level.
  • Scheduler: the sampling algorithm (DDIM, Euler, DPM-Solver++). Picks sigmas and blends predicted noise back into the latent.
  • Safety checker: an optional NSFW / illegal-content filter on the output image.

Classifier-free guidance (CFG)

Plain text conditioning learns epsilon_theta(x_t, t, c) for every prompt c. CFG trains that same network with c dropped 10% of the time (swapped for an empty embedding), so one model ends up predicting both the conditional and the unconditional noise. At inference:

eps = eps_uncond + w * (eps_cond - eps_uncond)

w is the guidance scale. w=0 gives you the unconditional prediction, w=1 the plain conditional one, and w>1 exaggerates the prompt's pull at the cost of sample diversity. SD's default is w=7.5.

CFG is why text-to-image works at production quality at all. Without it, the prompt barely nudges the output. With it, the prompt drives.

Latent space geometry

The VAE's 4-channel latent is a manifold, not just a compressed image: arithmetic on it roughly tracks semantic edits (prompt engineering and interpolation both live here), and it's the only region the diffusion U-Net was ever trained to work in. Decode a random 4x64x64 latent instead of one the model produced, and you don't get a random-looking image. You get garbage, because only a specific submanifold of latents decodes into anything valid.

Two consequences follow:

  1. Img2img encodes the image into a latent, adds partial noise, runs the denoiser, and decodes back. Structure survives because encoding is nearly invertible; content shifts with the prompt.
  2. Inpainting works the same way, except the denoiser only updates the masked region. Unmasked pixels stay locked to their original encoded latent.

The U-Net architecture

The SD U-Net is the TinyUNet from Lesson 10, scaled up, with three additions:

  • Transformer blocks at every spatial resolution, containing self-attention plus cross-attention to the text embedding.
  • Time embedding via an MLP on top of a sinusoidal encoding.
  • Skip connections between encoder and decoder at matching resolutions.

Parameter counts: roughly 860M for SD 1.5, 2.6B for SDXL, 12B for FLUX. Most of that growth sits in the attention layers, not the convolutions.

LoRA fine-tuning

Full fine-tuning of Stable Diffusion needs 20+ GB of VRAM and updates all 860M parameters. LoRA (Low-Rank Adaptation) freezes the base model and injects small rank-decomposition matrices into the attention layers instead. A LoRA adapter for SD typically runs 10-50 MB, trains in 10-60 minutes on a single consumer GPU, and loads at inference time as a drop-in modification.

Original: W_q : (d_in, d_out)   frozen
LoRA:     W_q + alpha * (A @ B)   where A : (d_in, r), B : (r, d_out)

r is typically 4-32.

LoRA is how nearly every community fine-tune gets distributed. CivitAI and Hugging Face host millions of them.

Schedulers you will see

  • DDIM: deterministic, about 50 steps, simple.
  • Euler ancestral: stochastic, 30-50 steps, slightly more creative samples.
  • DPM-Solver++ 2M Karras: deterministic, 20-30 steps, the production default.
  • LCM / TCD / Turbo: consistency models and distilled variants, 1-4 steps at the cost of some quality.

Swapping schedulers is a one-line change in diffusers, and it sometimes fixes sample issues with zero retraining.

Check: The Concept0/13
1. What does classifier-free guidance (CFG) do at inference?Define
The lesson's generation call with guidance_scale set to 7.5, pointing at an unresolved box for what classifier-free guidance does with that number at inference.
2. What is the scheduler in a Stable Diffusion pipeline?Define
The lesson's one-line assignment to pipe.scheduler followed by a generation call, with what the assigned object does left unresolved.
3. What is a LoRA adapter?Define
The lesson's two-line load of a LoRA adapter onto a base Stable Diffusion checkpoint, with the contents of the adapter left unresolved.
4. What is cross-attention in the Stable Diffusion U-Net?Define
The four signals the lesson says reach a U-Net transformer block, with the pair that cross-attention runs over left unresolved.
5. What is DPM-Solver++?Define
The display name and diffusers class for DPM-Solver++ 2M Karras, above an empty row for its order, its determinism and its typical step count.
6. Which text encoder does the lesson name for SD 1.x/2.x versus SD3/FLUX, and what does the safety checker do?Define
The five named pieces of the Stable Diffusion pipeline listed side by side, with the text encoder and safety checker highlighted and their specifics left as open questions.
7. Why does CFG training drop the text conditioning (swap in an empty embedding) for about 10% of examples?Explain
A row of ten training examples, nine keeping the real caption and one with the caption dropped, all feeding the U-Net being trained, with the effect left unresolved.
8. How does inpainting differ from plain img2img?Explain
The img2img call signature next to the inpainting call signature, which takes an extra mask argument, with the difference in behaviour left unresolved.
9. Why does img2img keep the composition of the input while the prompt changes its content?Explain
An img2img run at strength 0.6 on dog.png whose output keeps the original layout under a new prompt, with the reason left unresolved.
10. From SD 1.5 (860M) to SDXL (2.6B) to FLUX (12B), where does most of the growth in denoiser parameters sit?Explain
A bar chart of total denoiser parameters for SD 1.5, SDXL and FLUX, 860 million, 2.6 billion and 12 billion, with no breakdown by layer type.
11. Why is LoRA fine-tuning popular for Stable Diffusion instead of full fine-tuning?Explain
Full fine-tuning's cost from the lesson text, all 860 million parameters updated and 20 or more gigabytes of VRAM, next to an empty box for LoRA.
12. In the CFG formula, one noise value has eps_uncond = 0.5 and eps_cond = 1.5. What is eps at the default guidance scale w = 7.5?Apply
The classifier-free guidance formula with eps_uncond 0.5, eps_cond 1.5 and w 7.5 filled in, and the result left blank.
13. You generate with guidance_scale=15 and the image comes out over-saturated and crunchy. What should you check first?Analyze
The lesson's generation call with every argument shown and guidance_scale set to 15, next to the reported result, with what to check first left unresolved.

Build It

This lesson runs diffusers end-to-end instead of rebuilding Stable Diffusion from scratch. The pieces you'd need to rebuild (VAE, text encoder, U-Net, scheduler) each get their own lesson elsewhere; the goal here is fluency with the production API.

Step 1: Text-to-image

python
import torch
from diffusers import StableDiffusionPipeline
 
pipe = StableDiffusionPipeline.from_pretrained(
    "runwayml/stable-diffusion-v1-5",
    torch_dtype=torch.float16,
).to("cuda")
 
image = pipe(
    prompt="a dog riding a skateboard in tokyo, studio ghibli style",
    guidance_scale=7.5,
    num_inference_steps=25,
    generator=torch.Generator("cuda").manual_seed(42),
).images[0]
image.save("dog.png")

float16 halves VRAM with no visible quality loss. num_inference_steps=25 with the default DPM-Solver++ matches num_inference_steps=50 with DDIM.

Step 2: Swap the scheduler

python
from diffusers import DPMSolverMultistepScheduler, EulerAncestralDiscreteScheduler
 
pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config)
pipe.scheduler = EulerAncestralDiscreteScheduler.from_config(pipe.scheduler.config)

Scheduler state is decoupled from the U-Net's weights, so you can train with DDPM and sample with whichever scheduler fits the job.

Step 3: Image-to-image

python
from diffusers import StableDiffusionImg2ImgPipeline
from PIL import Image
 
img2img = StableDiffusionImg2ImgPipeline.from_pretrained(
    "runwayml/stable-diffusion-v1-5",
    torch_dtype=torch.float16,
).to("cuda")
 
init_image = Image.open("dog.png").convert("RGB").resize((512, 512))
out = img2img(
    prompt="a dog riding a skateboard, oil painting",
    image=init_image,
    strength=0.6,
    guidance_scale=7.5,
).images[0]

strength is how much noise to add before denoising (0.0 = unchanged, 1.0 = full regeneration). 0.5-0.7 is the standard range for style transfer.

Step 4: Inpainting

python
from diffusers import StableDiffusionInpaintPipeline
 
inpaint = StableDiffusionInpaintPipeline.from_pretrained(
    "runwayml/stable-diffusion-inpainting",
    torch_dtype=torch.float16,
).to("cuda")
 
image = Image.open("dog.png").convert("RGB").resize((512, 512))
mask = Image.open("dog_mask.png").convert("L").resize((512, 512))
 
out = inpaint(
    prompt="a cat",
    image=image,
    mask_image=mask,
    guidance_scale=7.5,
).images[0]

White pixels in the mask are the area to regenerate. Black pixels are preserved.

Step 5: LoRA loading

python
pipe.load_lora_weights("sayakpaul/sd-lora-ghibli")
pipe.fuse_lora(lora_scale=0.8)
 
image = pipe(prompt="a village square in ghibli style").images[0]

lora_scale controls strength; 0.0 = no effect, 1.0 = full effect. fuse_lora bakes the adapter into the weights in place for speed, but prevents swapping. Call pipe.unfuse_lora() before loading a different adapter.

Step 6: LoRA training (sketch)

Real LoRA training lives in peft or diffusers.training. Here's the outline:

python
# Pseudocode
for step, batch in enumerate(dataloader):
    images, prompts = batch
    latents = vae.encode(images).latent_dist.sample() * 0.18215
 
    t = torch.randint(0, num_train_timesteps, (batch_size,))
    noise = torch.randn_like(latents)
    noisy_latents = scheduler.add_noise(latents, noise, t)
 
    text_emb = text_encoder(tokenizer(prompts))
 
    pred_noise = unet(noisy_latents, t, text_emb)  # LoRA weights injected here
 
    loss = F.mse_loss(pred_noise, noise)
    loss.backward()
    optimizer.step()

Only the LoRA matrices receive gradient; the base U-Net, VAE, and text encoder are frozen. With a batch size of 1 and gradient checkpointing this fits in 8 GB of VRAM.

Step 7: ControlNet-guided generation

A text prompt alone doesn't pin down composition. ControlNet adds a second conditioning input, an edge map, depth map, or pose skeleton, that constrains the U-Net's output at every denoising step, without retraining the base model.

python
import cv2
import numpy as np
from diffusers import ControlNetModel, StableDiffusionControlNetPipeline
 
controlnet = ControlNetModel.from_pretrained(
    "lllyasviel/sd-controlnet-canny",
    torch_dtype=torch.float16,
)
control_pipe = StableDiffusionControlNetPipeline.from_pretrained(
    "runwayml/stable-diffusion-v1-5",
    controlnet=controlnet,
    torch_dtype=torch.float16,
).to("cuda")
 
source = np.array(Image.open("dog.png").convert("RGB"))
edges = cv2.Canny(source, 100, 200)
edge_image = Image.fromarray(edges).convert("RGB")
 
image = control_pipe(
    prompt="a dog riding a skateboard, cyberpunk style",
    image=edge_image,
    guidance_scale=7.5,
).images[0]

The canny edges fix where the dog and skateboard sit; the prompt is free to change everything else. Swap the checkpoint for lllyasviel/sd-controlnet-depth or -openpose to condition on depth or a pose skeleton instead.

Check: Build It0/11
1. The LoRA training sketch multiplies the VAE's encoded latents by 0.18215 before adding noise. What is this constant for?Define
The captured training-sketch lines where the encoded latent is multiplied by 0.18215 and noise is then added, with the purpose of the constant left unresolved.
lines captured from python main.py
2. What does adding a ControlNet to a Stable Diffusion pipeline let you do that a plain text prompt cannot?Define
The lesson's plain pipeline build next to its ControlNet pipeline build, with what the second one makes possible left unresolved.
3. In the lesson's img2img pipeline, the `strength` parameter controls what?Explain
The lesson's img2img call with strength set to 0.6, pointing at an unresolved box for what that one parameter controls.
4. Why can you swap the scheduler on a trained Stable Diffusion pipeline without retraining the U-Net?Explain
One set of already-trained U-Net weights connected to three named scheduler choices, with an unresolved note asking why no retraining is needed.
5. You move from DDIM at 50 steps to DPM-Solver++ 2M Karras at 25 steps. What do you expect?Apply
Two scheduler setups, DDIM at 50 steps and DPM-Solver++ 2M Karras at 25 steps, with the resulting quality and runtime left unresolved.
6. In the lesson's LoRA loading example, what does calling `pipe.fuse_lora(lora_scale=0.8)` do?Apply
The frozen base weight and the separately loaded adapter feeding into a call to fuse_lora with lora_scale 0.8, with the resulting weight state left unresolved.
7. In the inpainting example the mask is white over the dog and black elsewhere, and the prompt is "a cat". What comes out?Apply
The three inpainting inputs from the stem, an image containing a dog, a mask white over the dog and black elsewhere, and the prompt a cat, with the output left unresolved.
8. In the ControlNet example, canny edges come from dog.png and the prompt says "cyberpunk style". What happens to the output?Apply
Canny edges extracted from dog.png and the prompt cyberpunk style both feeding a U-Net with ControlNet, with the output left unresolved.
9. You called pipe.fuse_lora(lora_scale=0.8) with a Ghibli adapter, then loaded a different adapter, and the Ghibli style still shows. What step was missed?Analyze
The four calls the user made in order, the fuse call highlighted, with a note that the output still comes out in the Ghibli style and the missing call left unresolved.
10. A user runs img2img with strength=1.0 and complains that their input photo had no effect. What is the cause?Analyze
An img2img call at strength 1.0 on an input photo, with the output left unresolved and a complaint that the result looks unrelated to the input.
11. A product must match the exact pose from a reference photo while the style, subject and background change completely per prompt, with no retraining of the base model. Which design fits?Design
A requirement that the pose must match a reference while style, subject and background change freely, with no retraining allowed, and the matching design left unresolved.

Use It

In production, here's what you're actually deciding:

  • Model family: SD 1.5 for open-source community fine-tunes, SDXL for higher fidelity, SD3 / FLUX for state of the art and strict licensing requirements.
  • Scheduler: DPM-Solver++ 2M Karras for 20-30 steps, LCM-LoRA when latency is under 1s.
  • Precision: float16 on 4080/4090, bfloat16 on A100 and newer, int8 (via bitsandbytes or compel) when VRAM is tight.
  • Conditioning: plain text works; for stronger control, add ControlNet (canny, depth, pose) on top of the base pipeline.

For batch generation, AUTO1111 / ComfyUI are the community tools; for production APIs, diffusers + accelerate or optimum-nvidia with TensorRT compilation.

Check: Use It0/1
1. A product must return images in under one second on one GPU and can accept some quality loss. Which setup fits?Design
The stem's latency and quality requirement above the four deployment choices the Use It section lists, each left unfilled.

Ship It

This lesson produces:

  • outputs/prompt-sd-pipeline-planner.md: a prompt that picks SD 1.5 / SDXL / SD3 / FLUX plus scheduler and precision given a latency budget, fidelity target, and licensing constraint.
  • outputs/skill-lora-training-setup.md: a skill that writes a full LoRA training config for a custom dataset, including captions, rank, batch size, and learning rate.
Author's Note

The setting people misuse most here is guidance_scale. It's tempting to crank it to 12-15 on the assumption that higher always means "more faithful to my prompt." It does, for a while, and then the image degrades into the same over-saturated, crunchy look you get from over-sharpening a photo. If a generation reads as unmistakably "AI-ish" in that specific burned-out way, drop guidance before you touch anything else — it's almost always the first thing to check.

The other thing worth knowing going in: most of what changed between SD 1.5 and FLUX is capacity and conditioning, not the underlying idea. Once you've traced through this pipeline once, reading a new model's paper is mostly a matter of finding which box got bigger.

Exercises

  1. (Easy) Generate the same prompt with guidance_scale in [1, 3, 5, 7.5, 10, 15]. Describe how the image changes. At what guidance value do artefacts appear?
  2. (Medium) Take any real photograph, run it through StableDiffusionImg2ImgPipeline at strength in [0.2, 0.4, 0.6, 0.8, 1.0]. Which strength preserves composition while changing style? Why does 1.0 ignore the input entirely?
  3. (Hard) Train a LoRA on 10-20 images of a single subject (a pet, a logo, a character) and generate novel scenes with that subject in them. Report the LoRA rank and training steps that produced the best identity preservation without overfitting to the input images.

Key Terms

TermWhat people sayWhat it actually means
Latent diffusion"Diffuse in latents"Run the entire DDPM in the VAE latent space (4x64x64) instead of pixel space (3x512x512); 48x compute saving
VAE scale factor"0.18215"Constant that rescales the VAE's raw latent to roughly unit variance; hardcoded in every SD pipeline
Classifier-free guidance"CFG"Mix conditional and unconditional noise predictions; the single most impactful inference knob
Scheduler"Sampler"The algorithm that turns noise + model predictions into a denoised latent trajectory
LoRA"Low-rank adapter"Small rank-decomposition matrices that fine-tune attention layers without touching base weights
Cross-attention"Text-image attention"Attention from latent tokens to text tokens; injects prompt information at every U-Net level
ControlNet"Structure conditioning"A separately-trained adapter that steers SD with an extra input (canny, depth, pose, segmentation)
DPM-Solver++"The default scheduler"Second-order deterministic ODE solver; best quality at low step counts (20-30) in 2026

Further Reading