diffusion.cobanov.dev

Built by Mert Cobanov

Diffusion,
from noise to image.

Companion pieces: kvcache.cobanov.dev and memory.cobanov.dev

A diffusion model learns to remove a small amount of noise from an image. Do that backwards a thousand times, starting from pure static, and an image appears. That sentence is the whole idea, and yet the first time I read the DDPM paper I did not believe it could work. This page is my attempt to make each step tangible: every section pairs a short explanation with Python and something you can drag, scrub, or train yourself, right here in the browser. Thirteen sections, from the forward noising process to the latent, guided, rectified-flow models that generate images today.

These started as my own notes while working through the diffusion lessons in Rohit Ghumare’s ai-engineering-from-scratch repo. I kept rewriting them with my own visualizations until they made sense, then added the parts I wished the lessons had drawn for me.

Fixed, no learning

01

The forward process: destroying an image slowly

A generative model has one job: produce new samples that look like they came from the training data. Diffusion models approach this sideways. Instead of learning to build an image from nothing, they first define a process that destroys images in a very controlled way, and then learn to run that process in reverse.

The destruction is a Markov chain. Take an image with pixels scaled to . Shrink it slightly and add a small amount of Gaussian noise to get . Do the same to to get . Keep going for steps:

is a small variance, typically growing linearly from to over steps. The factor is not decoration. If has unit variance, then has variance . The chain shuffles variance from signal to noise without ever changing the total, which is why it is called variance preserving, and why after enough steps is simply a standard Gaussian, with no trace of the image left in it.

forward_process.pypython
import torch

T = 1000
betas = torch.linspace(1e-4, 0.02, T)     # beta_1 ... beta_T, tiny variances

def forward_step(x_prev, t):
    """One step of the Markov chain: q(x_t | x_{t-1})."""
    beta = betas[t - 1]
    eps = torch.randn_like(x_prev)          # fresh Gaussian noise every step
    return (1 - beta).sqrt() * x_prev + beta.sqrt() * eps

def diffuse(x0, steps):
    """Walk the chain from a clean image x0 to x_steps."""
    x = x0
    trajectory = [x0]
    for t in range(1, steps + 1):
        x = forward_step(x, t)
        trajectory.append(x)
    return trajectory                       # x_0, x_1, ..., x_steps

# x0: an image scaled to [-1, 1], shape (3, H, W)
# diffuse(x0, 1000)[-1] is indistinguishable from torch.randn_like(x0)
q(x_t | x_{t-1}), one image, one step at a time
t
0 / 1000
beta_t
-
signal left
100.0%
pixel std
0.000
x_0flower.jpg
pixel value histogram
mean 0.00
-30+3
pixels of x_t N(0, 1)
Drag the slider or press play. Every step multiplies the previous image by sqrt(1 - beta_t) and adds fresh N(0, beta_t) noise. The histogram tracks pixel values sliding from the image's own distribution into a standard bell curve.

Two things are worth noticing in the demo. The image survives far longer than the per-step noise would suggest, because each is tiny. And the endpoint does not depend on the input at all: swap in your own picture and the histogram at is the same bell curve. That second point is the whole trick. If every image ends in the same place, then a model that can walk the chain backwards can start from that place, pure noise, and arrive at an image.

The forward process has no parameters and nothing to learn. It is a fixed recipe. Everything a diffusion model learns is about the reverse direction, and to train that efficiently we first need a shortcut through the chain.

Why training is cheap

02

The closed-form jump

Simulating a thousand steps to get one noisy image would make training hopeless. Fortunately the chain folds. A Gaussian scaled by a constant is Gaussian, and the sum of two independent Gaussians is Gaussian with the variances added. So the composition of noising steps is itself a single Gaussian step, and its parameters have a clean form. Write and . Then:

which is the same as saying

You can check the two-step case by hand. After step one, . After step two, . The two noise terms are independent, so their variances add: . That is , and induction does the rest.

This single equation is the reason diffusion is practical. During training you pick a random , draw one , and produce in one line. No chain, no loop. is simply “how much of the original signal is left after steps”, and every schedule, sampler, and loss on this page is written in terms of it.

q_sample.pypython
import torch

def linear_beta_schedule(T=1000, beta_start=1e-4, beta_end=2e-2):
    return torch.linspace(beta_start, beta_end, T)


def precompute_schedule(betas):
    alphas = 1.0 - betas
    alphas_cumprod = torch.cumprod(alphas, dim=0)        # alpha_bar_t
    return {
        "betas": betas,
        "alphas": alphas,
        "alphas_cumprod": alphas_cumprod,
        "sqrt_alphas_cumprod": torch.sqrt(alphas_cumprod),
        "sqrt_one_minus_alphas_cumprod": torch.sqrt(1.0 - alphas_cumprod),
        "sqrt_recip_alphas": torch.sqrt(1.0 / alphas),
    }


def q_sample(x0, t, noise, schedule):
    """Jump straight from x0 to x_t. t is a batch of timesteps."""
    sqrt_a = schedule["sqrt_alphas_cumprod"][t].view(-1, 1, 1, 1)
    sqrt_one_minus_a = schedule["sqrt_one_minus_alphas_cumprod"][t].view(-1, 1, 1, 1)
    return sqrt_a * x0 + sqrt_one_minus_a * noise


schedule = precompute_schedule(linear_beta_schedule(T=1000))
# alpha_bar[  0] = 0.9999   alpha_bar[499] = 0.0655   alpha_bar[999] = 0.00004
chain vs jump, same t
t
300
alpha_bar_t
0.3964
signal amplitude
0.630
noise amplitude
0.777
chain: 300 stepsstd 0.000
jump: 1 stepstd 0.000
alpha_bar_t = prod (1 - beta_s), linear schedule
flower.jpg
t = 0, alpha_bar = 1t = 1000, alpha_bar = 4.0e-5
Left: the actual Markov chain from section 01, t steps of fresh noise. Right: one draw from q(x_t | x_0). They are different samples from the same distribution, so the pixel statistics match while the exact noise pattern does not.

Another way to read the closed form: a diffusion model never sees the chain during training. It only ever sees pairs produced by the jump, drawn at random timesteps. The chain matters for the theory, and for sampling, but the training set is just “clean image, mixed with noise, in a known ratio”.

The shape of alpha_bar

03

Noise schedules

The forward process is defined entirely by the sequence , or equivalently by the curve . That curve decides how much signal is left at every timestep, and therefore what the network is asked to do at every timestep. DDPM used a linear ramp, and it worked, but if you plot for it you find a problem: the signal is essentially gone by . The last third of the chain is spent turning noise into slightly different noise.

Every training step samples uniformly, so a third of the training budget goes to timesteps where there is nothing to learn. Sampling has the same waste in reverse. Nichol and Dhariwal proposed defining directly as a cosine curve and deriving the from it, which spreads the destruction more evenly across the chain. Sigmoid schedules do something similar with a tunable steepness. A useful single number for comparing them is the signal-to-noise ratio , usually plotted in decibels.

schedules.pypython
import math
import torch

def linear_betas(T=1000, beta_start=1e-4, beta_end=0.02):
    return torch.linspace(beta_start, beta_end, T)


def cosine_betas(T=1000, s=0.008):
    """Nichol & Dhariwal (2021). Define alpha_bar directly, derive betas."""
    steps = torch.arange(T + 1, dtype=torch.float64)
    f = torch.cos(((steps / T + s) / (1 + s)) * math.pi / 2) ** 2
    alpha_bar = f / f[0]                               # alpha_bar_0 = 1
    betas = 1 - alpha_bar[1:] / alpha_bar[:-1]
    return betas.clamp(max=0.999).float()


def sigmoid_betas(T=1000, k=8.0):
    steps = torch.arange(T + 1, dtype=torch.float64)
    g = torch.sigmoid(k * (0.5 - steps / T))
    alpha_bar = (g - g[-1]) / (g[0] - g[-1])          # runs from 1 to 0
    betas = 1 - alpha_bar[1:] / alpha_bar[:-1]
    return betas.clamp(1e-8, 0.999).float()


def snr(betas):
    alpha_bar = torch.cumprod(1 - betas, dim=0)
    return alpha_bar / (1 - alpha_bar)

# where does the signal effectively vanish (alpha_bar < 0.01)?
for name, b in [("linear", linear_betas()), ("cosine", cosine_betas()), ("sigmoid", sigmoid_betas())]:
    ab = torch.cumprod(1 - b, dim=0)
    print(name, int((ab > 0.01).sum()), "of 1000 steps still carry signal")
# linear   ~ 620   <- the last ~38% of the chain is already pure noise
# cosine   ~ 936
# sigmoid  ~ 790
schedule designer
T
alpha_bar_t (signal kept)
t = 0alpha_bar < 0.01 from t = 674t = T
log SNR (dB)
+40 dB (all signal)0 dB-40 dB (all noise)
linearcosinesigmoid
alpha_bar at 0.25T
0.524
at 0.5T
0.079
at 0.75T
0.003
at T
4.0e-5
useful steps
67%
t=0
t=100
t=200
t=300
t=400
t=500
t=600
t=700
t=800
t=900
t=1000
Pick a schedule family and tune it. The strip shows x_t at eleven evenly spaced timesteps. Watch how much of the strip is already pure noise under the linear schedule, and how the cosine schedule keeps structure visible almost to the end.

Two further details matter in practice. First, the schedule should actually reach zero signal. Stable Diffusion 1.x used a “scaled linear” schedule whose , meaning the model never saw pure noise during training but was fed exactly that at inference. The mismatch shows up as an inability to produce very dark or very bright images, and the fix (Lin et al., 2023) is to rescale the schedule to zero terminal SNR. Second, the right schedule depends on resolution. Adding the same per-pixel noise to a 1024-pixel image destroys less information than adding it to a 64-pixel one, because neighbouring pixels are redundant, so high-resolution models shift their schedules toward more noise. Rectified-flow models in section 11 take this to its conclusion and make the schedule a straight line.

The reverse process

04

What the network actually predicts

Running the chain backwards means sampling from , and that distribution is not known in general. Two facts rescue us. When is small, the reverse conditional is close to Gaussian. And if you also know the clean image , the reverse conditional is exactly Gaussian, with a mean that Bayes’ rule gives in closed form. So a network that can estimate from can estimate the mean of the reverse step.

DDPM does not ask the network for directly. It asks for the noise that was mixed in, since the closed form makes the two interchangeable:

Why noise and not the image? Partly empirical: Ho et al. found it trained better. Partly structural: at small the noise is a small perturbation and predicting it is a well-scaled regression, whereas predicting would mean reproducing the input almost exactly. The catch is at large , where is tiny and the division above amplifies any error in enormously. That is why samplers take many small steps and re-predict every time rather than trusting one , and why later work introduced -prediction, a rotation of the pair that stays well-conditioned at both ends.

parameterizations.pypython
import torch

def to_x0(x_t, eps, alpha_bar):
    """Every parameterization is a change of variables around this identity."""
    return (x_t - (1 - alpha_bar).sqrt() * eps) / alpha_bar.sqrt()


def to_eps(x_t, x0, alpha_bar):
    return (x_t - alpha_bar.sqrt() * x0) / (1 - alpha_bar).sqrt()


def to_v(x0, eps, alpha_bar):
    """v-prediction (Salimans & Ho, 2022): a rotation of (x0, eps)."""
    return alpha_bar.sqrt() * eps - (1 - alpha_bar).sqrt() * x0


def v_to_x0_eps(x_t, v, alpha_bar):
    a, b = alpha_bar.sqrt(), (1 - alpha_bar).sqrt()
    x0  = a * x_t - b * v
    eps = b * x_t + a * v
    return x0, eps


def posterior_mean(x_t, eps, t, sch):
    """mu_theta(x_t, t): the mean of p(x_{t-1} | x_t) given a noise estimate."""
    beta = sch["betas"][t]
    coef = beta / sch["sqrt_one_minus_alphas_cumprod"][t]
    return sch["sqrt_recip_alphas"][t] * (x_t - coef * eps)

# Score-function view: the noise estimate is a scaled gradient of log density.
#   eps_theta(x_t, t) = -sqrt(1 - alpha_bar_t) * grad_x log p_t(x_t)
# Predicting noise and predicting "which way is more probable" are the same job.
one denoising step, with an imperfect oracle
t
400
alpha_bar_t
0.1951
error gain
2.03x
PSNR of x0_hat
0.0 dB
one reverse step at t = 400
input x_t
predicted eps
recovered x0_hat
mean of x_t-1
the three regression targets at t = 400
eps (DDPM)pure noise
x0the image
vmostly -x0

At small t the recovered image is nearly perfect even with a noisy estimate. At large t, dividing by sqrt(alpha_bar) turns the same estimate error into garbage, so a sampler only trusts x0_hat for the direction of its next small step and re-predicts. The v target smoothly changes from “predict the noise” near t = 0 to “predict the image” near t = T.

The 'network' here is an oracle that knows the true noise, corrupted by the error slider. Move t toward 1000 and watch how a small error in the noise estimate wrecks the recovered image, while the same error barely matters at small t. The bottom row shows what the three common prediction targets look like at this t.

One more reading of the same object, because it shows up constantly in the literature. Since is Gaussian, the gradient of its log density with respect to is . Averaging over the data, the optimal noise predictor is a scaled version of , the score of the noisy data distribution. Predicting the noise, predicting the clean image, and predicting which direction increases the probability of are three coordinate systems for one function. Song et al. built the same models from the score side and showed DDPM is a discretization of a stochastic differential equation, which is what makes the fast ODE samplers in section 08 possible.

U-Net, time embeddings, skips

05

The network: a denoiser that knows what time it is

Whatever the parameterization, the model is a function that maps an image-shaped tensor to an image-shaped tensor. Any architecture that preserves spatial shape works. Convolutional U-Nets were the choice from 2015 through the Stable Diffusion era, and transformers on patches (DiT) have taken over since; section 11 covers that shift. The U-Net is still the clearest way to see what the denoiser needs.

It needs three things. First, a way to look at large regions: the encoder halves the resolution at each level so deep layers see the whole image and can decide that a blob of pixels is a petal. Second, a way to keep fine detail: skip connections copy each encoder level straight across to the matching decoder level, so the output can be sharp even though the bottleneck is coarse. Third, the timestep. The same network must remove a whisper of noise at and hallucinate global structure from static at . Without knowing it would have to infer the noise level from the input, which is possible but wasteful.

The timestep enters through a sinusoidal embedding, the same trick transformers use for positions. Each of the embedding’s channels is a sine or cosine of at a different frequency, from one full cycle per step to one per ten thousand steps. Nearby timesteps get nearby vectors, every timestep gets a distinct one, and a small MLP turns the vector into a per-channel shift (or a scale and shift, called FiLM or adaptive normalization) added to the feature maps at every level.

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

def timestep_embedding(t, dim=64):
    """Sinusoidal embedding of an integer timestep, transformer-style."""
    half = dim // 2
    freqs = torch.exp(-math.log(10000) * torch.arange(half, device=t.device) / half)
    args = t[:, None].float() * freqs[None]
    return torch.cat([args.sin(), args.cos()], dim=-1)


class TinyUNet(nn.Module):
    def __init__(self, img_channels=3, base=32, t_dim=64):
        super().__init__()
        self.t_dim = t_dim
        self.t_mlp = nn.Sequential(nn.Linear(t_dim, base * 4), nn.SiLU(), nn.Linear(base * 4, base * 4))
        self.time_proj = nn.Linear(base * 4, base * 2)

        self.enc1 = nn.Conv2d(img_channels, base, 3, padding=1)           # H x W
        self.enc2 = nn.Conv2d(base, base * 2, 4, stride=2, padding=1)      # H/2 x W/2
        self.mid  = nn.Conv2d(base * 2, base * 2, 3, padding=1)
        self.dec1 = nn.ConvTranspose2d(base * 2, base, 4, stride=2, padding=1)
        self.dec2 = nn.Conv2d(base * 2, img_channels, 3, padding=1)        # cat(skip, up) -> eps

    def forward(self, x, t):
        t_emb = self.t_mlp(timestep_embedding(t, self.t_dim))
        t_proj = self.time_proj(t_emb)[:, :, None, None]   # broadcast over H, W

        h1 = F.silu(self.enc1(x))
        h2 = F.silu(self.enc2(h1)) + t_proj                # time enters here
        h3 = F.silu(self.mid(h2))
        d1 = F.silu(self.dec1(h3))
        d2 = torch.cat([d1, h1], dim=1)                    # skip connection
        return self.dec2(d2)                               # same shape as x
sinusoidal timestep embedding
embedding of t = 25032 channels
02054106158211000sin, fast to slowcos, fast to slow
+1 0 -1

The vector is a set of clock hands turning at different speeds. Read together they pin down t exactly, yet each hand moves smoothly, so the network can interpolate to timesteps it rarely saw.

Rows are timesteps, columns are embedding channels. Low-index channels spin fast and tell nearby timesteps apart; high-index channels drift slowly and encode the coarse position in the chain.
U-Net data flow
stage
idle
emb(t) → MLPx_tenc 1enc 2enc 3middec 3dec 2dec 1eps_hatsolid: features dashed: time embedding cyan: skip connection
mid512 x 8 x 8
Bottleneck with attention over all 64 positions. Global layout decisions happen here.
Press play to follow one forward pass. Solid arrows carry feature maps, dashed arrows carry the time embedding into every block, and the horizontal arrows are skip connections.

Real diffusion U-Nets add self-attention blocks at the lower resolutions so distant pixels can coordinate, and (for text conditioning) cross-attention blocks that let each spatial position read from the prompt’s token embeddings. Stable Diffusion 1.5’s U-Net has about 860M parameters, most of them in those attention layers. The convolutions are the cheap part.

Live, in your browser

06

Training: one MSE, sampled at random timesteps

Put the pieces together and the training loop is almost embarrassingly short. Take a batch of clean images. For each one draw a random timestep and a random . Use the closed form to build . Ask the network for the noise. Take the mean squared error between its answer and the you actually used:

Ho et al. derived this by starting from the variational bound that Sohl-Dickstein’s 2015 paper had used, working through the KL terms between the true reverse conditionals and the model’s, and noticing that with the noise parameterization every term becomes a weighted squared error on . Then they dropped the weights. The unweighted version trains better, and it is equivalent to reweighting the objective toward the middle timesteps, where the interesting perceptual work happens.

Pixels are expensive to train on in a browser tab, so the demo below swaps them for 2D points. The math does not care: a point is a two-dimensional image, the noise schedule is the same linear one, and the network is a small MLP that takes , the sinusoidal embedding of , and a class label. Around ten thousand parameters, trained live with hand-written backprop. Watch the loss fall and the samples sharpen.

train_step.pypython
import torch
import torch.nn.functional as F

def train_step(model, x0, schedule, optimizer, device, T=1000):
    """Algorithm 1 of the DDPM paper. One batch, one optimizer step."""
    model.train()
    x0 = x0.to(device)
    bs = x0.size(0)

    t = torch.randint(0, T, (bs,), device=device)      # 1. random timestep per image
    noise = torch.randn_like(x0)                        # 2. random noise
    x_t = q_sample(x0, t, noise, schedule)              # 3. jump to x_t (closed form)

    pred = model(x_t, t)                                # 4. predict the noise
    loss = F.mse_loss(pred, noise)                      # 5. plain MSE

    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    return loss.item()

# That is the entire training loop. No discriminator, no adversarial game,
# no mode collapse. The loss goes down and stays down.
train a diffusion model on three 2D datasets
model
pretrained
trained steps
40,000
params
9,922
this run
-
loss (ema)
-
training data, 3 classes
ringspiralmoons
loss, log scale
idle
0.10.31

Batch 256, Adam, cosine learning-rate decay. Each dot on the curve is an EMA over the last few dozen steps. The floor around 0.1 to 0.3 is the noise the model cannot predict.

samples, DDIM 20 steps

140 samples per class, class-conditional, same seed every redraw so you can watch the shape settle.

The page ships with a model pretrained for 40,000 steps. Press 'train from scratch' to watch a fresh one learn, or 'continue' to fine-tune the current one. The samples panel redraws every few hundred steps using 20 DDIM steps. Sections 07 to 11 use whatever model is current here.

Notice what the loss does. It drops quickly to around 0.3 and then creeps down slowly, and it never reaches zero. It cannot: at large the input is nearly pure noise and the best possible prediction of is only slightly better than a guess. The irreducible part of the loss is the entropy of the data, not a failure of the model. This is also why a diffusion loss curve is a poor guide to sample quality, and why people look at samples (or FID) rather than the number.

DDPM, ancestral, 1000 steps

07

Sampling: walking the chain backwards

With a trained noise predictor, generation is the reverse loop. Start from . At each step ask the network for , compute the posterior mean from section 04, add a fresh Gaussian kick of variance , and move to :

The added noise is not a bug. Each reverse step is a sample from a distribution, not a point estimate, and skipping the term (while keeping the DDPM update) produces blurry, over-averaged output. The randomness is also what makes two runs from the same starting noise diverge: DDPM is an ancestral sampler, every step branches.

The demo runs the actual loop on the toy model from the previous section, one thousand network calls per run. The solid points are . The faint points are the model’s current guess of the clean data, , recomputed at every step. Early on that guess is a smear near the origin; as the noise level drops it snaps onto the shape while is still visibly noisy. That gap between “what the model believes” and “where the sample currently is” is exactly what the faster samplers in the next section exploit.

sample_ddpm.pypython
@torch.no_grad()
def sample_ddpm(model, schedule, shape, T=1000, device="cpu"):
    """Algorithm 2 of the DDPM paper: ancestral sampling, T network calls."""
    model.eval()
    x = torch.randn(shape, device=device)                     # x_T ~ N(0, I)
    betas = schedule["betas"].to(device)
    sqrt_one_minus_a = schedule["sqrt_one_minus_alphas_cumprod"].to(device)
    sqrt_recip_alphas = schedule["sqrt_recip_alphas"].to(device)

    for t in reversed(range(T)):
        t_batch = torch.full((shape[0],), t, dtype=torch.long, device=device)
        eps = model(x, t_batch)                               # one forward pass
        coef = betas[t] / sqrt_one_minus_a[t]
        mean = sqrt_recip_alphas[t] * (x - coef * eps)        # mu_theta(x_t, t)
        if t > 0:
            x = mean + torch.sqrt(betas[t]) * torch.randn_like(x)   # add sigma_t z
        else:
            x = mean                                          # last step: no noise
    return x

# 1000 forward passes per batch of samples. Correct, slow, and the
# baseline every faster sampler is measured against.
DDPM sampling on the toy model
t
1000 / 1000
alpha_bar_t
4.0e-5
network calls
0
compute
0 ms
steps / frame
x_t (current sample)x0_hat (model's guess)training data

400 points sampled in parallel, one network call per step for all of them. Seed 1. The model is the one currently loaded in section 06 (pretrained).

Press run. Pick a class or leave it unconditional. The step counter is the real t; speed controls how many reverse steps happen per animation frame. Try running the same seed twice: the endpoints differ, because every step adds fresh noise.

For images the same loop costs a thousand U-Net evaluations per sample, tens of seconds on a GPU. That is the reason the original DDPM paper was greeted with “impressive, but who would use this”, and the reason the next two years of research were mostly about doing it in fifty steps, then twenty, then four.

Skipping timesteps without retraining

08

DDIM: the same model, twenty times fewer steps

Song, Meng and Ermon noticed that the training objective only ever uses , the marginals of the forward process. It never uses the fact that the chain is Markov. So you are free to define a different, non-Markovian forward process with the same marginals, and its reverse process is a different sampler for the same trained network. One family of such processes has a knob . At you get DDPM back. At the reverse step becomes deterministic.

The deterministic update reads naturally. Predict the noise, use it to estimate , then move directly to the point that would occupy at the next noise level:

Because there is no injected noise, nothing forces to be . You can jump from 1000 to 980 to 960 and the update is still consistent. Fifty steps give samples close to the thousand-step DDPM result. The same determinism means a fixed always produces the same image, which is what makes interpolation in noise space and image editing by inversion possible.

The deeper view is that the deterministic sampler is integrating an ordinary differential equation, the probability flow ODE that shares its marginals with the diffusion SDE. Once you see it as an ODE, the whole numerical-methods toolbox applies: Euler, Heun, multistep methods. DPM-Solver++ and the Karras samplers are higher-order solvers tuned to the specific shape of this ODE, and they reach DDIM-50 quality in twenty steps or fewer. Every production pipeline in 2026 uses one of them.

sample_ddim.pypython
@torch.no_grad()
def sample_ddim(model, schedule, shape, steps=50, T=1000, device="cpu", eta=0.0):
    """Song et al. (2020). Same trained model, far fewer steps."""
    model.eval()
    x = torch.randn(shape, device=device)
    alphas_cumprod = schedule["alphas_cumprod"].to(device)

    ts = torch.linspace(T - 1, 0, steps + 1).long()      # a sparse subset of timesteps
    for i in range(steps):
        t, t_prev = int(ts[i]), int(ts[i + 1])
        t_batch = torch.full((shape[0],), t, dtype=torch.long, device=device)
        eps = model(x, t_batch)

        a_t, a_prev = alphas_cumprod[t], alphas_cumprod[t_prev]
        x0_pred = (x - torch.sqrt(1 - a_t) * eps) / torch.sqrt(a_t)     # "where is this going"
        sigma = eta * torch.sqrt((1 - a_prev) / (1 - a_t) * (1 - a_t / a_prev))
        dir_xt = torch.sqrt(1 - a_prev - sigma ** 2) * eps              # "re-noise to level t_prev"
        noise = sigma * torch.randn_like(x) if eta > 0 else 0
        x = torch.sqrt(a_prev) * x0_pred + dir_xt + noise
    return x

# eta = 0: deterministic. Same x_T always gives the same x_0.
# eta = 1: recovers DDPM's stochastic update on the chosen subsequence.
two samplers, one starting noise
sampler A:
network calls
0
compute
0 ms
dist. to data
-
sampler B:
network calls
0
compute
0 ms
dist. to data
-
mean endpoint distance A vs B
run to compare

Endpoint distance is the average distance between the i-th point of A and the i-th point of B, which started at the same x_T. Two deterministic DDIM runs at different step counts land within a few hundredths of each other. Any run involving fresh noise lands somewhere else on the shape. Distance to data is the mean distance to the nearest training point, a crude quality score.

Both panels start from the identical x_T. Compare DDIM at 20 steps against DDPM at 1000: similar quality, fifty times fewer network calls. Then compare DDIM-20 with DDIM-50 at eta = 0 and note how close the endpoints land, point by point. Push eta to 1 and the endpoints scatter again.

What DDIM cannot do is go much below ten steps on a model trained with the DDPM objective. The ODE trajectories curve, and a coarse Euler-style walk along a curved path drifts off it. Fixing that needs either a better solver, a straighter path (section 11), or a model distilled to take big steps on purpose.

The VAE

09

Latent diffusion: shrink the problem first

Everything so far works on pixels, and pixels are expensive. A 512×512 RGB image is 786,432 numbers, every U-Net call touches all of them at full resolution, and the model spends most of its capacity on imperceptible high-frequency detail. Rombach et al. made the observation that turned diffusion into a product: most of those bits are perceptually irrelevant, so compress first, then diffuse in the compressed space.

The compressor is a variational autoencoder. The encoder maps a image to a latent, an 8× spatial reduction and 48× fewer numbers. The decoder maps it back. It is trained with a reconstruction loss, a perceptual loss, a small GAN loss for sharpness, and a very weak KL penalty that keeps the latents roughly Gaussian so the diffusion model has something well-behaved to noise. Once trained it is frozen. The diffusion model never sees a pixel again: it is trained on latents, samples latents, and the decoder is the last thing that runs.

The latent is not a small image. Decoding a random tensor gives garbage, because only a thin manifold of latents decodes to valid pictures. But it behaves like an image in the ways that matter: nearby latents decode to nearby images, and the closed-form noising still applies. That is what makes image-to-image possible. Encode a photo, add noise up to , and denoise from there: the composition survives, the details are regenerated under the new prompt. Inpainting is the same idea with a mask deciding which latent positions get overwritten each step.

latent_diffusion.pypython
import torch
from diffusers import AutoencoderKL

vae = AutoencoderKL.from_pretrained("stabilityai/sd-vae-ft-mse").to("cuda", torch.float16)
SCALE = 0.18215                      # rescales raw latents to roughly unit variance

@torch.no_grad()
def encode(image):                   # image: (B, 3, 512, 512) in [-1, 1]
    dist = vae.encode(image).latent_dist
    return dist.sample() * SCALE     # (B, 4, 64, 64): 48x fewer numbers

@torch.no_grad()
def decode(latents):
    return vae.decode(latents / SCALE).sample   # back to (B, 3, 512, 512)


# The diffusion loop never touches pixels. Training and sampling both
# happen on the (4, 64, 64) tensor; the VAE is frozen throughout.
def train_step_latent(unet, images, text_emb, scheduler):
    z0 = encode(images)
    t = torch.randint(0, 1000, (z0.size(0),), device=z0.device)
    noise = torch.randn_like(z0)
    z_t = scheduler.add_noise(z0, noise, t)
    return F.mse_loss(unet(z_t, t, text_emb).sample, noise)


# img2img is the same loop started part-way: encode, noise to t = strength * T, denoise.
def img2img(unet, image, text_emb, scheduler, strength=0.6, steps=30):
    z = encode(image)
    t_start = int(strength * 1000)
    z = scheduler.add_noise(z, torch.randn_like(z), torch.tensor([t_start]))
    for t in scheduler.timesteps_from(t_start, steps):
        z = scheduler.step(unet(z, t, text_emb).sample, t, z).prev_sample
    return decode(z)
what the denoiser sees: pixels vs latents
image
autoencoder
pixel tensor
3×512×512
latent tensor
4×64×64
numbers per call
786.4 K → 16.4 K
reduction
48×
what one denoiser call has to process (log scale)
pixels786,432 values
latents16,384 values

A diffusion transformer with 2×2 patches would see 1,024 tokens here. Attention cost grows with the square of that, which is why the autoencoder’s spatial factor matters more than its channel count.

input512²
encoder (pooled)64² positions
decoderupsampled

The middle panel is what a 8× spatial reduction looks like if you only average pixels. A trained VAE stores far more than the average in its 4 channels per position, and its decoder rebuilds texture the pooling threw away. Compression is a good name for it; downsampling is not.

Choose an image resolution and an autoencoder. The bars compare the tensor the U-Net or DiT has to process. The picture strip uses average pooling as a stand-in for the encoder, which is much dumber than a real VAE, but it shows the spatial resolution the model actually works at.

The autoencoder has kept changing since 2022. Stable Diffusion 1 through SDXL use 4 latent channels; SD3 and FLUX moved to 16, which costs little in the denoiser and recovers most of the fine texture the 4-channel VAE lost. Some newer models trade the other way, with deeper compression (16× or 32× spatially) and more channels, so that the expensive transformer sees fewer tokens. In every case the split is the same: a cheap, frozen autoencoder that handles pixels, and an expensive denoiser that never has to.

Making the prompt matter

10

Conditioning and classifier-free guidance

A diffusion model conditioned on is just : the same network with another input. For a class label, is an embedding added alongside the time embedding. For text, is a sequence of token embeddings from a frozen encoder (CLIP in Stable Diffusion 1 and 2, CLIP plus T5 in SD3 and FLUX), read through cross-attention layers so every spatial position can look at every word. Train with the condition present and the model learns .

In practice that alone gives weak prompt adherence. The model learns that the prompt is a factor, not the dominant one, and samples drift toward generic images. Dhariwal and Nichol first fixed this with an external classifier whose gradient pushed samples toward the class. Ho and Salimans then removed the classifier. Train a single model with the condition randomly dropped 10-20% of the time, so it can predict both and . At sampling time, extrapolate:

The difference in brackets is the direction in which the condition changes the prediction. Scaling it by pushes harder in that direction than the model would on its own. Through the score-function lens this is sampling from , a distribution sharpened toward inputs the condition explains well. Stable Diffusion shipped with . Higher values buy prompt fidelity with diversity, and past a point they buy saturation and artifacts. The cost is two network calls per step, which is why guidance-distilled models like FLUX.1-dev fold the scale in as an input and run one pass.

classifier_free_guidance.pypython
import torch

P_DROP = 0.1                                  # Ho & Salimans: drop the label 10-20% of the time

def train_step_cfg(model, x0, cond, schedule):
    """Same loss as before; the only change is occasionally hiding the condition."""
    bs = x0.size(0)
    drop = torch.rand(bs, device=x0.device) < P_DROP
    cond = torch.where(drop[:, None], NULL_EMBEDDING.expand_as(cond), cond)
    t = torch.randint(0, 1000, (bs,), device=x0.device)
    noise = torch.randn_like(x0)
    x_t = q_sample(x0, t, noise, schedule)
    return F.mse_loss(model(x_t, t, cond), noise)


@torch.no_grad()
def guided_eps(model, x_t, t, cond, w=7.5):
    """Two forward passes, one extrapolation. w = 1 is plain conditioning."""
    eps_cond   = model(x_t, t, cond)
    eps_uncond = model(x_t, t, NULL_EMBEDDING)
    return eps_uncond + w * (eps_cond - eps_uncond)

# In practice the two passes are batched: cat([x_t, x_t]) with cat([cond, null]),
# so guidance doubles the batch, not the number of kernel launches.
# A "negative prompt" simply replaces NULL_EMBEDDING with the embedding of
# text you want to move away from.
guidance scale on the toy model
w
1.0
spread
-
dist. to data
-
samples, DDIM 30 steps, w = 1.0
400 points
guidance direction at t = 500
eps_uncond - eps_cond

Spread is the mean distance of samples from their centroid, a diversity proxy; distance to data is the mean distance to the nearest training point, a fidelity proxy. Moderate w sharpens the shape; large w pushes samples away from the other classes and off the data. The arrows show, at each location, the extra push a sample receives per unit of w; longer and brighter means the model is more certain which class lives there. At large t the field is smooth and global; at small t it becomes local and mostly stops mattering.

Left: 400 samples for the chosen class at guidance scale w. At w = 0 the class is ignored and you get the mixture; at w = 1 plain conditioning; between 1.5 and 3 the samples sharpen onto the shape and, for the spiral, the spread stat drops. Past about w = 4 the distance-to-data stat climbs as samples get pushed off the shape entirely. Right: the guidance direction eps_uncond - eps_cond as a vector field at timestep t, which is the direction guidance pushes every sample.

The 2D version is honest about a subtlety that image demos hide. Guidance does not move samples toward the “most typical” ring point; it moves them away from wherever the other classes sit. At moderate scales that sharpens the shape. Push to 5 or 8 and the ring bulges away from the spiral and the moons, with samples leaving the data entirely: the 2D version of oversaturation. On images the same mechanism amplifies whatever the model associates with the prompt relative to the unconditional average, which is where the characteristic “CFG look” comes from, and why the sweet spot is a model-specific number rather than “as high as possible”.

What 2024-2026 models actually run

11

Rectified flow and diffusion transformers

Look again at the closed form. The DDPM path from data to noise is , with coefficients that sit on a quarter circle and a schedule that spends most of its steps near the noise end. The paths a trained model actually follows at sampling time inherit that shape: they curve, because at high noise every trajectory first heads for the average of the data and only later bends toward a specific sample. There is nothing sacred about any of this. Flow matching (Lipman et al.) and rectified flow (Liu et al.) ask for the simplest alternative: a straight line.

The network now predicts a velocity, the constant direction from the data point to its noise partner. Training is still one MSE at a random . Sampling is Euler integration of the learned velocity field from back to . The intuition usually offered is that straight training paths give straight sampling trajectories, which a few large Euler steps can follow. The toy below shows that this is only half the story. With data and noise paired at random, the learned trajectories still curve; on this toy they curve more than the DDPM model’s, because at high noise the velocity points at the data mean and every sample dives through the centre before heading out. What the flow model has instead is a well-conditioned target. The velocity is bounded everywhere, whereas recovering from an estimate at high noise divides by and amplifies every error, which is exactly what section 04 showed. Four Euler steps on the flow model beat four DDIM steps by a wide margin, and twenty match a thousand-step DDPM. Actually straightening the trajectories is a separate step: Liu et al. retrain on the (noise, sample) pairs the model itself produces, called reflow, and after a round or two the paths are straight enough for a single step.

The other half of the modern recipe is the denoiser. Peebles and Xie showed that a plain vision transformer on latent patches (DiT) scales more cleanly than a U-Net: no convolutions, no skip connections, just patch embedding, a stack of transformer blocks, and an unpatchify head. Timestep and conditioning enter through adaptive layer norm: an MLP turns the condition into a scale, a shift and a gate applied around each attention and MLP block, with the gates initialised to zero so every block starts as the identity. SD3 extended this to two streams, text tokens and image tokens with separate weights sharing one joint attention (MMDiT). FLUX uses that for its first blocks and then concatenates the streams. Every 2026 image and video model follows one of these two layouts.

rectified_flow.pypython
import torch
import torch.nn.functional as F

def rectified_flow_train_step(model, x0, optimizer):
    """Same skeleton as DDPM training. Straight-line path, velocity target."""
    n = x0.size(0)
    t = torch.rand(n, device=x0.device)                      # continuous t in [0, 1]
    eps = torch.randn_like(x0)
    tt = t[:, None, None, None]
    x_t = (1 - tt) * x0 + tt * eps                            # linear interpolation
    v_target = eps - x0                                       # d x_t / d t, constant along the path
    loss = F.mse_loss(model(x_t, t), v_target)
    optimizer.zero_grad(); loss.backward(); optimizer.step()
    return loss.item()


@torch.no_grad()
def rectified_flow_sample(model, shape, steps=20, device="cpu"):
    """Euler integration from t = 1 (noise) to t = 0 (data)."""
    x = torch.randn(shape, device=device)
    dt = 1.0 / steps
    t = torch.ones(shape[0], device=device)
    for _ in range(steps):
        v = model(x, t)
        x = x - dt * v                                        # follow the velocity backwards
        t = t - dt
    return x

# SD3 samples t from a logit-normal instead of uniform, concentrating
# training on the middle of the path where the velocity is hardest to predict.
curved paths vs straight paths, and what that buys at low step counts
steps for the bottom row
eps model: probability-flow ODE, DDIM 100 steps
36 trajectories
path length / straight line
-
1.000 = perfectly straight
VP schedule
flow model: Euler, 100 steps
36 trajectories
path length / straight line
-
1.000 = perfectly straight
linear path

Same starting noise (gray dots) in both panels, integrated finely enough that the curves are the true learned trajectories. Neither is straight, and on this toy the flow model’s paths are the more curved of the two: at high noise its velocity points at the data mean, so every point first dives toward the centre and only then heads out to the shape. The variance-preserving model barely moves at high noise (it keeps the norm of x_t) and then travels more directly. Straight training paths do not, on their own, give straight sampling paths.

eps model + DDIM, 4 steps
network calls
4
dist. to data
-
flow model + Euler, 4 steps
network calls
4
dist. to data
-

Both models have the same architecture and were trained for the same 40,000 steps on the same data. The only differences are the interpolation path and the regression target, and yet at four steps the flow model is far ahead. The win is conditioning, not geometry: the velocity target is bounded everywhere, while DDIM has to recover x0 from an epsilon estimate by dividing by sqrt(alpha_bar), which is near zero exactly where the big early steps happen. At one step the flow output is the average velocity, so samples collapse toward the mean of the class; from two steps on it is already recognisable, while DDIM needs roughly ten.

Top: the trajectories the two trained toy models actually follow from the same starting noise, integrated with 100 fine steps, with the ratio of total path length to total straight-line distance. Bottom: the same two models at a coarse step count. DDIM on the epsilon model falls apart under about ten steps; Euler on the flow model degrades gracefully, despite its longer paths.

A well-conditioned target gets you to twenty steps. Getting to one to four needs either reflow or distillation: train a student to jump directly to where the teacher’s trajectory ends. Consistency models, LCM, adversarial diffusion distillation (SDXL Turbo), and FLUX.1-schnell are different ways of doing that, and every serious model now ships a full-quality checkpoint alongside a distilled few-step variant. The ideas from earlier sections all survive the transition: the closed-form jump is still a jump, guidance still works on velocities, and the latent autoencoder is unchanged.

Where each piece lives

12

The pipeline, end to end

A text-to-image system is five components, and by now every one of them has appeared on this page. A tokenizer and one or more frozen text encoders turn the prompt into a sequence of embeddings. A latent tensor is initialised with Gaussian noise. A denoiser, U-Net or DiT, is called once per step, twice if classifier-free guidance is on, and a scheduler turns its prediction into the next latent. After the last step a frozen VAE decoder turns the latent into pixels. Optionally a safety checker looks at the result.

The compute is almost entirely in the loop. For SD3 at 28 steps with guidance, that is 56 transformer forward passes on a 16×128×128 latent; the text encoders and the VAE decoder run once each and together cost less than two of those passes. That is why the sampler research of sections 08 and 11 translated so directly into product: halving the step count halves the bill.

pipeline.pypython
import torch
from diffusers import StableDiffusion3Pipeline, FluxPipeline

# Everything on this page, wired together, is a one-liner in diffusers.
pipe = StableDiffusion3Pipeline.from_pretrained(
    "stabilityai/stable-diffusion-3.5-medium", torch_dtype=torch.bfloat16
).to("cuda")

image = pipe(
    prompt="a cosmos flower in a field at golden hour, macro photograph",
    negative_prompt="blurry, oversaturated",   # replaces the null branch of CFG
    guidance_scale=4.5,                        # w, section 10
    num_inference_steps=28,                    # Euler steps on the rectified flow, section 11
    height=1024, width=1024,                   # 16 x 128 x 128 latent, section 09
    generator=torch.Generator("cuda").manual_seed(0),   # fixes x_T, section 08
).images[0]

# Roughly what happens inside pipe(...):
#   text_emb  = text_encoders(tokenize(prompt))            # CLIP-L, CLIP-G, T5-XXL
#   z         = randn(1, 16, 128, 128)                     # x_T in latent space
#   for t in scheduler.timesteps:                          # 28 of them
#       v_c, v_u = transformer(cat([z, z]), t, cat([text_emb, null_emb])).chunk(2)
#       v        = v_u + w * (v_c - v_u)                   # classifier-free guidance
#       z        = scheduler.step(v, t, z).prev_sample     # one Euler step
#   image     = vae.decode(z / vae.config.scaling_factor)

# A distilled model skips guidance and most of the steps:
fast = FluxPipeline.from_pretrained("black-forest-labs/FLUX.1-schnell", torch_dtype=torch.bfloat16).to("cuda")
image = fast(prompt="...", guidance_scale=0.0, num_inference_steps=4).images[0]
one text-to-image call, stage by stage
steps
text embedding, reused every step× 2text encoderx_Tdenoiser × NVAE decodeimage
stage
idle
denoiser calls
0 / 16
text encoder calls
0
VAE decoder calls
0
press run
Each stage lights up the component doing the work and counts how many times the expensive part, the denoiser, has been called.
Press run and follow one generation through the pipeline. The counter tracks denoiser calls, which is where nearly all of the time goes. Toggle guidance and change the step count to see how the budget moves.
six years of diffusion, in the pieces that changed

Latent diffusion, Stable Diffusion

2022
Rombach et al., CompVis, Stability, Runway

Diffuse in a frozen VAE's 4x64x64 latent instead of pixels. Cross-attention to CLIP text. Open weights; the ecosystem starts here.

sections 09, 10
Click a node. Each entry lists what it changed relative to what came before, in terms of the sections above.

The names change every few months and the architecture diagrams keep getting busier, but the skeleton has been stable since 2022: compress, noise, learn to denoise conditioned on text, sample with an ODE solver, guide, decode. Video models add a time axis to the latent and otherwise run the same loop. Audio models run it on spectrograms. When a new model lands, the useful questions are the ones this page has been asking all along: what is the path, what does the network predict, how many steps, what does the autoencoder look like, and how is the condition injected.

Closing

13

Where this leaves us

Across these sections we followed one thread. A fixed process turns any image into Gaussian noise. Because that process folds into a single closed-form jump, a network can be trained on random timesteps with a plain squared error to predict the noise. Because the noise prediction is also a score, the reverse direction is a well-posed sampling problem, and once you see it as an ODE the step count falls from a thousand to twenty. Latent autoencoders shrink the problem, classifier-free guidance makes the prompt dominant, straight-line paths and transformers make it fast and scalable. None of these is the main idea. The systems that generate images today ship all of them at once, and the gap between the 2020 paper and a 2026 product is the composition.

Building this page changed how I hold the subject. I had read the equations before and could reproduce them, but I did not really believe the closed-form jump until I watched the chain and the one-step sample land on the same histogram, and I did not appreciate why guidance oversaturates until I saw the arrows pointing away from the other classes rather than toward the right one. Training a model in a browser tab, watching the loss stall at the entropy floor while the samples kept improving, taught me more about that loss curve than any number of plots in papers. The interactive form is not decoration here; it is how I checked that I understood.

I do not know what the next reformulation looks like. The path from noise to data keeps getting straighter and the step counts keep falling, the denoisers keep getting more transformer-shaped, and the boundary between “image model” and “video model” and “world model” keeps thinning. What seems stable is the set of questions: what is the corruption process, what does the network predict, how is the condition injected, how many evaluations does a sample cost. I will keep updating this page as the answers change, because they clearly have not stopped changing.