Latent Diffusion Models
Latent Diffusion Models (LDMs) solve a fundamental efficiency problem with diffusion models: running the iterative denoising process directly in pixel space is extraordinarily expensive — a 512×512 image contains 786,432 pixels, and each denoising step requires a full network forward pass. LDMs compress images into a compact latent space using a Variational Autoencoder, then run diffusion entirely in that lower-dimensional space. The result is a model that is 4–8× cheaper to train and run while producing equivalent or superior image quality.
Three-Component Architecture
An LDM consists of three largely independent components that can be trained separately and combined modularly:
Text prompt → [Text Encoder] → text embeddings ─────────────────────────────┐
↓
Image → [VAE Encoder] → latent z → [Noisy latent z_t] → [UNet Denoiser] → [VAE Decoder] → Generated image
(forward process) (uses cross-attn
with text embeddings)
Component 1: The Variational Autoencoder
The VAE is trained separately (not end-to-end with the diffusion UNet). It learns to compress images into a latent space that is approximately 8× smaller in each spatial dimension (a 512×512×3 image becomes a 64×64×4 latent):
import torch
import torch.nn as nn
import torch.nn.functional as F
class LDMEncoder(nn.Module):
"""
VAE encoder for Latent Diffusion Models.
Compresses spatial image to latent space: (B, 3, H, W) → (B, 8, H/8, W/8)
The 8 channels encode mean and log-variance (4 each) of the latent distribution.
"""
def __init__(self, in_channels: int = 3, latent_channels: int = 4,
channel_mult: tuple = (1, 2, 4, 4), num_res_blocks: int = 2):
super().__init__()
base_channels = 128
channels = [base_channels * m for m in channel_mult]
# Initial conv
self.conv_in = nn.Conv2d(in_channels, base_channels, 3, padding=1)
# Downsampling blocks
self.down_blocks = nn.ModuleList()
in_ch = base_channels
for out_ch in channels:
block = nn.Sequential(
*[ResBlock(in_ch if i == 0 else out_ch, out_ch)
for i in range(num_res_blocks)],
nn.Conv2d(out_ch, out_ch, 3, stride=2, padding=1) # 2× downsample
)
self.down_blocks.append(block)
in_ch = out_ch
# Bottleneck with attention
self.mid = nn.Sequential(
ResBlock(in_ch, in_ch),
SelfAttention(in_ch),
ResBlock(in_ch, in_ch)
)
# Output: 2 * latent_channels (mean + log_var)
self.norm_out = nn.GroupNorm(32, in_ch)
self.conv_out = nn.Conv2d(in_ch, 2 * latent_channels, 1)
def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
h = self.conv_in(x)
for block in self.down_blocks:
h = block(h)
h = self.mid(h)
h = self.norm_out(h)
h = F.silu(h)
h = self.conv_out(h)
mean, log_var = h.chunk(2, dim=1)
return mean, log_var
def reparameterize(mean: torch.Tensor, log_var: torch.Tensor,
scale_factor: float = 0.18215) -> torch.Tensor:
"""
Reparameterization trick: sample z ~ N(mean, exp(log_var/2)).
Scale factor 0.18215 normalizes latent variance to ~1 (empirical).
This normalization is critical: diffusion training assumes unit-variance noise.
"""
std = torch.exp(0.5 * log_var)
eps = torch.randn_like(std)
z = mean + eps * std
return z * scale_factor # scale into diffusion-friendly range
Component 2: The UNet Denoiser
The UNet operates entirely in latent space. Its key modification from a standard UNet: cross-attention layers that condition on text embeddings at every resolution level:
class CrossAttention(nn.Module):
"""
Cross-attention for conditioning the UNet denoiser on text embeddings.
Query comes from image features; key/value from text encoder output.
"""
def __init__(self, query_dim: int, context_dim: int, num_heads: int = 8,
head_dim: int = 64):
super().__init__()
inner_dim = num_heads * head_dim
self.num_heads = num_heads
self.head_dim = head_dim
self.scale = head_dim ** -0.5
self.to_q = nn.Linear(query_dim, inner_dim, bias=False)
self.to_k = nn.Linear(context_dim, inner_dim, bias=False)
self.to_v = nn.Linear(context_dim, inner_dim, bias=False)
self.to_out = nn.Linear(inner_dim, query_dim)
def forward(self, x: torch.Tensor, context: torch.Tensor) -> torch.Tensor:
"""
x: (B, HW, query_dim) — flattened spatial features
context: (B, seq_len, context_dim) — text encoder output
"""
B, N, _ = x.shape
h = self.num_heads
q = self.to_q(x).reshape(B, N, h, self.head_dim).transpose(1, 2)
k = self.to_k(context).reshape(B, -1, h, self.head_dim).transpose(1, 2)
v = self.to_v(context).reshape(B, -1, h, self.head_dim).transpose(1, 2)
# Scaled dot-product attention over text tokens
attn = torch.softmax(q @ k.transpose(-2, -1) * self.scale, dim=-1)
out = (attn @ v).transpose(1, 2).reshape(B, N, -1)
return self.to_out(out)
class TransformerBlock(nn.Module):
"""
Transformer block within the UNet: self-attn + cross-attn + FFN.
Applied at each spatial resolution, with cross-attention conditioning on text.
"""
def __init__(self, dim: int, context_dim: int, num_heads: int = 8):
super().__init__()
self.norm1 = nn.LayerNorm(dim)
self.self_attn = CrossAttention(dim, dim, num_heads) # self-attn: context=x
self.norm2 = nn.LayerNorm(dim)
self.cross_attn = CrossAttention(dim, context_dim, num_heads)
self.norm3 = nn.LayerNorm(dim)
self.ff = nn.Sequential(
nn.Linear(dim, dim * 4),
nn.GELU(),
nn.Linear(dim * 4, dim)
)
def forward(self, x: torch.Tensor, context: torch.Tensor) -> torch.Tensor:
B, C, H, W = x.shape
x_flat = x.reshape(B, C, H * W).transpose(1, 2) # (B, HW, C)
# Self-attention on spatial features
x_flat = x_flat + self.self_attn(self.norm1(x_flat), self.norm1(x_flat))
# Cross-attention: attend to text embeddings
x_flat = x_flat + self.cross_attn(self.norm2(x_flat), context)
# Feed-forward
x_flat = x_flat + self.ff(self.norm3(x_flat))
return x_flat.transpose(1, 2).reshape(B, C, H, W)
Component 3: The Text Encoder
Stable Diffusion 1.x uses CLIP ViT-L/14 — a contrastive vision-language model whose text encoder produces 768-dimensional token embeddings. The full 77-token sequence is passed as context to all cross-attention layers. SD 2.x switched to OpenCLIP, and SDXL uses two encoders concatenated (OpenCLIP ViT-bigG + CLIP ViT-L).
The Diffusion Process in Latent Space
Forward Process (Adding Noise)
Given a latent $z_0 = \text{Enc}(x)$, the forward process gradually adds Gaussian noise over $T$ timesteps:
$$q(z_t | z_0) = \mathcal{N}(z_t;; \sqrt{\bar{\alpha}_t}, z_0,; (1 - \bar{\alpha}_t)\mathbf{I})$$
where $\bar{\alpha}t = \prod{s=1}^t (1 - \beta_s)$ is the cumulative noise schedule. This allows sampling $z_t$ directly without iterating through all intermediate steps:
$$z_t = \sqrt{\bar{\alpha}_t}, z_0 + \sqrt{1 - \bar{\alpha}_t}, \epsilon, \quad \epsilon \sim \mathcal{N}(0, \mathbf{I})$$
Denoising Training Objective
The UNet $\epsilon_\theta$ is trained to predict the noise $\epsilon$ that was added at each timestep, conditioned on the text embedding $c$:
$$\mathcal{L} = \mathbb{E}{z_0, \epsilon, t, c}!\left[|\epsilon - \epsilon\theta(z_t, t, c)|_2^2\right]$$
def ldm_training_step(
encoder, unet, text_encoder,
images: torch.Tensor, # (B, 3, H, W) normalized to [-1, 1]
text_tokens: torch.Tensor, # (B, 77) tokenized prompts
noise_scheduler,
optimizer
) -> torch.Tensor:
"""One LDM training step."""
# 1. Encode images to latent space
with torch.no_grad():
mean, log_var = encoder(images)
z0 = reparameterize(mean, log_var) # (B, 4, H/8, W/8)
# Encode text
text_embeddings = text_encoder(text_tokens) # (B, 77, 768)
# 2. Sample random timesteps and add noise
B = z0.shape[0]
t = torch.randint(0, noise_scheduler.num_timesteps, (B,), device=z0.device)
noise = torch.randn_like(z0)
z_t = noise_scheduler.add_noise(z0, noise, t) # (B, 4, H/8, W/8)
# 3. Predict noise with UNet (conditioned on text)
noise_pred = unet(z_t, t, context=text_embeddings)
# 4. MSE loss between predicted and actual noise
loss = F.mse_loss(noise_pred, noise)
optimizer.zero_grad()
loss.backward()
optimizer.step()
return loss
Classifier-Free Guidance (CFG)
Classifier-free guidance (Ho & Salimans, 2022) dramatically improves prompt adherence without requiring a separate classifier. During training, 10–20% of text conditions are dropped (replaced with null embeddings). At inference, two UNet forward passes are run:
$$\tilde{\epsilon}\theta(z_t, t, c) = \epsilon\theta(z_t, t, \varnothing) + \gamma \cdot \bigl(\epsilon_\theta(z_t, t, c) - \epsilon_\theta(z_t, t, \varnothing)\bigr)$$
where $\gamma$ is the guidance scale (typically 7–12 for Stable Diffusion). Higher $\gamma$ produces images that more closely match the prompt but with less diversity; $\gamma = 1$ is equivalent to no guidance.
@torch.no_grad()
def ldm_inference(
unet, vae_decoder, text_encoder,
prompt: str,
negative_prompt: str = "",
guidance_scale: float = 7.5,
num_inference_steps: int = 50,
height: int = 512,
width: int = 512
) -> torch.Tensor:
"""Standard Stable Diffusion inference with CFG."""
# Encode both positive and negative (empty/negative) prompts
text_emb = text_encoder(tokenize(prompt)) # (1, 77, 768)
uncond_emb = text_encoder(tokenize(negative_prompt)) # (1, 77, 768)
# Batch both for single UNet call
embeddings = torch.cat([uncond_emb, text_emb]) # (2, 77, 768)
# Start from pure noise in latent space
latents = torch.randn(1, 4, height // 8, width // 8)
# DDIM/PNDM scheduler denoising
for t in noise_scheduler.timesteps: # e.g., 50 steps from T=1000 to 0
# Duplicate latents for classifier-free guidance
latent_input = torch.cat([latents, latents]) # (2, 4, H, W)
# Two forward passes in one batched call
noise_pred_both = unet(latent_input, t, context=embeddings)
noise_pred_uncond, noise_pred_cond = noise_pred_both.chunk(2)
# Guidance formula
noise_pred = noise_pred_uncond + guidance_scale * (
noise_pred_cond - noise_pred_uncond
)
# Scheduler step: z_t → z_{t-1}
latents = noise_scheduler.step(noise_pred, t, latents).prev_sample
# Decode latents to pixel space
latents = latents / 0.18215 # reverse the encoder scale factor
image = vae_decoder(latents) # (1, 3, H, W), range [-1, 1]
return (image + 1) / 2 # normalize to [0, 1]
Evolution: SDXL, SD3, and FLUX
Stable Diffusion XL (SDXL)
- Two text encoders concatenated (OpenCLIP ViT-bigG + CLIP ViT-L → 2816-dim context)
- Larger UNet with more attention heads and cross-attention at more resolutions
- Native 1024×1024 resolution training (1024→128 latent)
- Refiner model (specialized for fine detail at the final denoising steps)
Stable Diffusion 3 (SD3)
- Replaces the UNet with a Multimodal Diffusion Transformer (MMDiT)
- Text and image tokens processed jointly with full bidirectional attention
- Three text encoders (CLIP L, CLIP G, T5-XXL) for richer semantic conditioning
- Flow Matching training objective (rectified flow) instead of DDPM
FLUX (Black Forest Labs)
- Hybrid architecture: Transformer blocks alternating between full joint attention and single-stream image-only attention
- Rotary Position Embeddings (RoPE) in image space
- Flow Matching on continuous-time noise schedules
- Scales to FLUX.1-pro with state-of-the-art text-image alignment
The LDM architecture established the template that every major image generation system now builds on: compress to latent space, denoise with a transformer-enhanced UNet, and condition via cross-attention from a powerful text encoder.