marcokl.ch
← All work
Chapter 02 / 04VisionGenerative ML

Diffusion Models

A from-scratch implementation of time- and class-conditioned diffusion models for image generation — implementing and experimenting with a range of U-Net backbones.

PythonPyTorch

Overview

DiffusionLab is a from-scratch implementation of Denoising Diffusion Probabilistic Models (DDPM) for image generation. Rather than fine-tuning an existing library, the entire pipeline — forward noising, reverse sampling, the training loop, noise schedules, and evaluation — is implemented directly in PyTorch.

The project is organized around one central decision: a generic diffusion wrapper owns the diffusion mechanics and the conditioning (time step plus class label), while a pluggable backbone only has to predict the noise. Because the wrapper builds the conditioning and the backbone stays agnostic to it, architectures can be swapped in and out while keeping the exact same training and inference pipeline — making controlled architecture and scaling experiments directly comparable.

At a glance

8.64
best FID on CIFAR-10 (class-conditional)
~237M
parameters in the final model
7
architecture/technique iterations
8
backbones implemented

Motivation

Understanding by construction

Most diffusion code is either a black box (a high-level library call) or a large research codebase that is hard to trace end to end. The goal here was to build it from first principles and to treat the U-Net backbone as a variable under study.

  • →Implement q(x_t | x_0), the reverse process, and the noise schedules by hand
  • →Isolate conditioning — show that time-only and time+class are just different embeddings for the same backbone
  • →Explore architecture scaling empirically (depth, width, attention, schedule, EMA, training length)
  • →Reuse one pipeline across 2D toy data, MNIST, and CIFAR-10

Architecture

One wrapper, swappable backbones

Data flow
x0 ──▶ forward diffusion  q(x_t|x_0)      (training: random t per sample)
        │
        ▼
   xt = √ᾱ_t·x0 + √(1−ᾱ_t)·ε
        │
        ▼
   wrapper (cond_emb = time [+ class]) ──▶ backbone ──▶ ε̂
        │
        ▼
   loss = MSE(ε̂, ε)  ──▶  backprop (AdamW + grad clip + EMA update)
        │
        ▼
   reverse sampling: x_T ~ N(0,I) ──▶ iterative denoising ──▶ x0

Every backbone implements a single interface — forward(x, cond_emb) → ε̂ — where the wrapper supplies the conditional embedding and the backbone predicts the noise. The backbones are built on shared DownBlock / UpBlock / AttentionBlock primitives (GroupNorm, SiLU, skip connections, residual projections).

Backbones (the variable under study)

BackboneNotesParams
MLPDenoiserProof of concept for 2D toy datatiny
CNNDenoiserSmall U-Net, 2 down/up stages~3.3M / ~13M
CNNDenoiserLargeWider, 3 stages~53M
CNNDenoiserLargeAttention+ self-attention at 4×4 / 8×8~58M
CNNDenoiser1515 conv blocks (2/stage) + attention~172M
CNNDenoiser2121 conv blocks (3/stage) + attention~237M
CNNDenoiser2727 conv blocks (4/stage), not yet trained~302M

Results

From a proof-of-concept to a single-digit FID

Each stage is measured by FID on CIFAR-10. Rows 3–5 are the same architecture with only the training/eval technique changing (36.75 → 29.54 → 13.09), which isolates the effect of EMA and the cosine schedule. The final model reaches FID 8.64 with class-conditional generation.

CNNDenoiser
71.85
LargeAttention
69.59
15 blocks
36.75
15 · EMA
29.54
15 · EMA · cosine
13.09
21 blocks
35.21
21 · EMA · cosine
8.64

FID vs. epoch — the two headline runs

0255075100100200300400500
CNNDenoiser15 · EMA · cosineCNNDenoiser21 · EMA · cosinelower is better

Loss keeps falling while FID rises (CNNDenoiser21 · EMA · cosine)

EpochTrain lossFID
1000.054131.08
2000.05289.40
3000.05018.64
4000.046611.75
5000.043218.02
Best CIFAR-10 sample grid from the final model
Best CIFAR-10 samples — CNNDenoiser21 · EMA · cosine (FID 8.64).

Challenges

What the experiments taught me

  • Exploding gradients on deep models

    During CNNDenoiser21 training the loss jumped from ~0.3 to 5.2e14 in one epoch. Fixed with gradient clipping (max_norm=1.0), a reduced learning rate, and AdamW with weight decay — plus a guard to skip non-finite-loss batches.

  • The loss / FID divergence

    Across every run the training loss kept falling while FID plateaued. The noise-prediction objective is not the only proxy for perceptual quality.

  • Deterministic sampling collapses

    Without injecting noise during the reverse process, all samples converge to a blurry mean image. Stochastic sampling is what buys diversity.

  • Normalization matters for diffusion

    BatchNorm mixes noise states across a batch; switching to GroupNorm keeps each sample's noise state independent.

  • Memory is the real scaling constraint

    Batch size dropped from 128 to 64 for larger models, and FID sample counts were reduced (2048 → 1500) to fit vRAM — motivating batched FID computation.

  • Generality was worth it

    The same trainer/sampler runs on 2D toy data with an MLP and on RGB images with a U-Net — the only change is the architecture plugged into the wrapper.

Takeaway

Training loss decreasing does not guarantee FID improving. On CIFAR-10, models around ~250M parameters can produce usable results with a few days of A100 training — but the schedule and EMA matter as much as raw size.