Part I: The mental model
DiffusionPipeline.from_pretrained("stabilityai/stable-diffusion-xl-base-1.0")
| reads model_index.json, loads each subfolder
v
components assembled tokenizer + text_encoder (from transformers)
| vae (AutoencoderKL)
| unet / transformer = the MODEL (denoiser)
| scheduler = the MATH (sampler)
v
pipe(prompt, num_inference_steps=30, guidance_scale=7.5) __call__ = the RECIPE
|
v
encode prompt text embeddings, uncond + cond for guidance
|
v
prepare latents random Gaussian noise in VAE latent space
|
| denoising loop over scheduler.timesteps
v
for t: noise_pred = unet(x_t, t, encoder_hidden_states=text)
noise_pred = combine(uncond, cond) via classifier-free guidance
x_next = scheduler.step(noise_pred, t, x_t).prev_sample
|
v
vae.decode(latents / scaling_factor) latent grid -> pixels
|
v
postprocess -> PIL image
The one-sentence identity: Diffusers factors every diffusion
system into a denoising model, a scheduler that is pure sampling
math, and a pipeline that orchestrates them, so that a backbone, a
sampler, and a task recipe can each be swapped independently
without touching the other two. A research codebase usually
fuses these. The network, the noise schedule, the guidance trick,
and the sampling loop all live tangled in one training script, and
changing the sampler means editing the model file. Diffusers pulls
them apart. The UNet2DConditionModel or a DiT knows
only how to predict noise from a noisy latent and a timestep. The
DDIMScheduler or DPMSolverMultistepScheduler
knows only how to turn that prediction into the previous sample. The
StableDiffusionXLPipeline knows how to encode a prompt,
seed the latents, run the loop, and decode. Each is a separate,
serializable object.
Two consequences follow. First, samplers become interchangeable at
runtime. Because a scheduler is a self-contained numerical method
configured by the same handful of fields (the number of training
timesteps, the beta schedule, the prediction type), you can replace
one with another by reading the old config and building the new one,
pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config),
and the loop keeps working. Second, the repository doubles as an
executable map of the field. Nearly every published scheduler,
guidance method, and adapter has a faithful implementation here
under one API, so reading Diffusers is the shortest path from the
equations in the
diffusion class
to code you can step through. The details below are checked against
a recent main branch in mid 2026. The library moves quickly and the
model subpackages have been reorganized more than once, so where an
exact file path is likely to have shifted I name the component by
its role and say so.
Part II: Using it
Diffusers is a pip install and runs anywhere PyTorch does, CPU,
CUDA, ROCm, or Apple Silicon (MPS), though anything beyond toy
resolutions wants a GPU. A pipeline almost always needs two
companion libraries, transformers
for the CLIP or T5 text encoders and accelerate for
device placement and offloading, plus safetensors for
weights.
pip install diffusers transformers accelerate safetensors
# for the latest features, install from source:
pip install git+https://github.com/huggingface/diffusersThe canonical first program is four lines. Load a pipeline by its Hub id, move it to the GPU, and call it with a prompt.
import torch
from diffusers import DiffusionPipeline
pipe = DiffusionPipeline.from_pretrained(
"stabilityai/stable-diffusion-xl-base-1.0",
torch_dtype=torch.float16,
variant="fp16",
)
pipe = pipe.to("cuda")
image = pipe(
"a red panda astronaut floating in a nebula, cinematic lighting",
num_inference_steps=30,
guidance_scale=7.0,
).images[0]
image.save("out.png")
A few things are worth noticing. DiffusionPipeline is a
base class that reads the repo's model_index.json, sees
it describes an SDXL pipeline, and returns a
StableDiffusionXLPipeline instance. You did not have to
know the concrete class. The call returns an output object whose
.images is a list of PIL images. And torch_dtype
plus variant="fp16" load half-precision weights, which
roughly halves memory. If a full model still does not fit, offloading
trades speed for VRAM.
pipe.enable_model_cpu_offload() # keep idle submodules on the CPU
pipe.enable_vae_slicing() # decode the VAE in chunks
pipe.enable_attention_slicing() # split attention to cap peak memorySwapping the sampler is the move that shows off the design. Every scheduler is compatible with the same pipeline as long as the model was trained under a matching prediction target, so you rebuild it from the existing config and reassign.
from diffusers import DPMSolverMultistepScheduler, EulerAncestralDiscreteScheduler
# a fast deterministic ODE solver, good results in ~20 steps
pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config)
# or a stochastic ancestral sampler with a different texture
pipe.scheduler = EulerAncestralDiscreteScheduler.from_config(pipe.scheduler.config)
Now the mistakes beginners make. First, forgetting .to("cuda")
and then wondering why generation takes minutes, because the whole
thing ran on CPU. Second, mixing dtypes, passing
torch_dtype=torch.float16 but then handing in a
float32 latent or a float32 ControlNet, which raises a dtype
mismatch deep in a matmul. Third, expecting a fixed seed alone to
reproduce an image. Determinism also needs a fixed sampler, step
count, and guidance scale, and you pass the seed as a generator,
generator=torch.Generator("cuda").manual_seed(0), not
as a global. Fourth, and most conceptual: raising
guidance_scale does not simply mean better. Past a
point it oversaturates colors and burns out detail, which is the
classifier-free guidance failure mode derived in the class. Fifth,
some modern pipelines are guidance-distilled. FLUX.1-schnell wants
guidance_scale=0.0 and about four steps, and copying an
SDXL call with 30 steps and scale 7 onto it produces worse images,
not better.
# a flow-matching, timestep-distilled model: few steps, no CFG
from diffusers import FluxPipeline
pipe = FluxPipeline.from_pretrained(
"black-forest-labs/FLUX.1-schnell", torch_dtype=torch.bfloat16
).to("cuda")
image = pipe("a lighthouse at dusk", num_inference_steps=4, guidance_scale=0.0).images[0]Part III: When it is the right tool
Diffusers is the right tool when you want programmatic, composable access to diffusion models in Python, when you are building a service or a research pipeline, fine-tuning a model, training a LoRA or a ControlNet, or reading a faithful implementation of a paper. It is the library the ecosystem standardizes on, so weights on the Hub ship in its layout, new architectures arrive here early, and the same code path serves images, video, and audio diffusion. It is also the most honest reference for the sampler and adapter zoo, because each method is a small self-contained file you can read against the math.
The honest cases for alternatives. If your work is interactive image making with a node graph and a huge plugin community, ComfyUI or the classic AUTOMATIC1111 web UI are where artists live, and Diffusers is the library underneath much of that world rather than a competitor to the UI itself. If you want the smallest possible one-file catalog of samplers to study, crowsonkb/k-diffusion packs Euler, Heun, and the DPM-Solver family into a single sampling module, and several Diffusers schedulers trace their lineage to it. If you want a paper exactly as its authors released it, the original research repos (CompVis latent diffusion, Stability's generative-models for SDXL and SVD, the DiT and Flux repositories) are the ground truth, and Diffusers is the port that unifies them. For LoRA and DreamBooth training specifically, community trainers like kohya-ss and OneTrainer add convenience and presets on top, while Diffusers ships the reference training scripts those tools grew from. And for serving large language models rather than diffusion, this is the wrong library entirely, that is vLLM territory.
The architecture-shaped warning is about the split between latent space and pixel space. Almost every modern model is a latent diffusion model. The UNet or transformer denoises inside a compressed VAE latent, and only at the very end does the VAE decode to pixels. A recurring beginner failure is to reason about the denoising loop as if it operated on the image directly.
wrong mental model: loop runs on pixels
noise image (512x512x3) -> denoise -> clean image
right mental model: loop runs in latent space, decode once at the end
noise latent (64x64x4) --[ N denoising steps ]--> clean latent (64x64x4)
|
vae.decode (x8 upsample)
v
image (512x512x3)
The VAE downsamples by a factor of eight, so an SDXL 1024-pixel
image is a 128x128 latent grid with four channels, and the
scaling_factor in the VAE config (about 0.18 for SD1.x,
0.13 for SDXL) is a fixed constant that keeps the latent's variance
near one. Skipping that scale, or decoding without dividing it back
out, produces washed-out or blown-out images with no error raised,
which is exactly the kind of silent correctness detail a reference
library exists to get right for you.
Part IV: The full life of one text-to-image call
The specimen: one call to an SDXL-style text-to-image pipeline,
pipe("a photo of ...", num_inference_steps=30, guidance_scale=7.5).
The path below is the common shape of nearly every
__call__ in src/diffusers/pipelines/. The
details differ across models but the skeleton is remarkably stable.
Stage 1: from_pretrained assembles the components
Before any generation, DiffusionPipeline.from_pretrained
(in src/diffusers/pipelines/pipeline_utils.py) reads
model_index.json at the repo root. That file names the
concrete pipeline class and, for each component, a
[library, class] pair, for example
"unet": ["diffusers", "UNet2DConditionModel"],
"scheduler": ["diffusers", "EulerDiscreteScheduler"],
and "text_encoder": ["transformers", "CLIPTextModel"].
The base class imports each named class, calls its own
from_pretrained on the matching subfolder, and hands the
finished objects to the pipeline constructor, which stores them and
calls register_modules so the pipeline knows its parts.
The result is a plain Python object with attributes
pipe.unet, pipe.vae,
pipe.text_encoder, pipe.tokenizer, and
pipe.scheduler. Nothing about diffusion has happened
yet. This is just serialization done well, and it is why any folder
in the standard layout loads with one call.
Stage 2: encode the prompt
Inside __call__ the first real work is
encode_prompt. The tokenizer turns the string into
token ids, the text encoder (CLIP for SD and SDXL, with SDXL using
two text encoders concatenated, and T5 joining CLIP for SD3 and
Flux) produces a sequence of hidden states, and those become the
encoder_hidden_states the denoiser will cross-attend to.
Crucially the pipeline encodes the prompt twice, once for
the real text and once for an empty or negative prompt, because
classifier-free guidance needs both a conditioned and an
unconditioned prediction. The two are concatenated along the batch
dimension so a single forward pass computes both. SDXL additionally
builds pooled embeddings and a small vector of size and crop
conditioning, its "micro-conditioning," which is a genuine
architectural difference from SD1.x and a common source of confusion
when porting code between them.
Stage 3: prepare the initial latents
prepare_latents draws Gaussian noise shaped like the
VAE latent, (batch, 4, height/8, width/8) for the SD
family, using the passed generator so seeds are
reproducible. It then multiplies by the scheduler's
init_noise_sigma. This matters. Continuous-time
schedulers such as the Euler and DPM families begin at a large sigma
rather than at variance one, so the starting latent must be scaled to
sit at the top of the noise schedule. The scheduler also fixes the
discrete step grid here with set_timesteps(num_inference_steps),
which selects, say, 30 timesteps out of the 1000 the model was
trained on and stores them in scheduler.timesteps.
Stage 4: the denoising loop
This is the heart of the operation, and it is short enough to hold in your head. For each timestep the pipeline duplicates the latent for the two guidance branches, lets the scheduler scale the input, runs the denoiser, recombines the two predictions, and asks the scheduler for the next latent.
for t in self.scheduler.timesteps:
# 1. duplicate for the unconditional and conditional branches
latent_model_input = torch.cat([latents] * 2)
# 2. some schedulers rescale the input by the current sigma
latent_model_input = self.scheduler.scale_model_input(latent_model_input, t)
# 3. the model predicts the noise (or velocity) at this step
noise_pred = self.unet(
latent_model_input, t, encoder_hidden_states=prompt_embeds
).sample
# 4. classifier-free guidance: push away from the unconditional
noise_uncond, noise_text = noise_pred.chunk(2)
noise_pred = noise_uncond + guidance_scale * (noise_text - noise_uncond)
# 5. the scheduler turns a prediction into the previous sample
latents = self.scheduler.step(noise_pred, t, latents).prev_sample
Read those five steps as the concrete boundary between the three
abstractions. Step 3 is the whole of what the model does,
one denoiser forward, no knowledge of guidance or sampling. Step 4
is the pipeline's guidance policy, a linear extrapolation
away from the unconditional prediction, and its strength is the
guidance_scale knob. Step 5 is the whole of what the
scheduler does, and the only place the noise schedule and
the numerical integrator live. The model never sees the scheduler
and the scheduler never sees the model, they meet only through the
tensor noise_pred. Swapping any one of the three leaves
the other two untouched, which is the entire payoff of the design
made mechanical.
Stage 5: what scheduler.step actually computes
scheduler.step(model_output, timestep, sample) returns
an output object whose .prev_sample is the next, less
noisy latent, and (for many schedulers) a
.pred_original_sample, the model's current best guess
of the fully denoised latent. What happens inside depends entirely
on the scheduler. DDPMScheduler follows the ancestral
posterior of the training process and adds fresh noise at each step,
so it is stochastic and needs all thousand steps.
DDIMScheduler uses the deterministic non-Markovian
update that lets you skip steps. DPMSolverMultistepScheduler
treats the reverse process as an ODE and applies a high-order
exponential integrator that reuses previous model outputs to reach
good samples in twenty steps or fewer. The pipeline code above does
not change one character across these choices, because the interface
is fixed and the differences are sealed inside step.
This is the concrete meaning of "the scheduler is the math."
Stage 6: decode and postprocess
After the loop the latent is a clean point in VAE latent space.
The pipeline divides out the scaling_factor and calls
vae.decode, which upsamples the 4-channel latent grid
back to a 3-channel image at eight times the resolution. An image
processor denormalizes from the model's [-1, 1] range to [0, 1],
converts to a PIL image (or a numpy array or tensor if you asked),
and, for the safety-checked SD pipelines, an optional classifier can
blank flagged outputs. The result is wrapped in the pipeline's output
dataclass and returned, so .images[0] is your picture.
That closes the loop of one call, prompt in, noise seeded, denoised
across the scheduler's steps, pixels out.
Part V: Internals deep dives
Deep dive: the three mixins that make everything loadable
The separation from Part I is enforced by three small base classes,
and understanding them explains why the whole ecosystem
interoperates. ConfigMixin (in
src/diffusers/configuration_utils.py) gives an object a
JSON-serializable config. A class decorates its __init__
with @register_to_config, which captures the
constructor arguments into self.config, and gains
save_config and from_config. This is why
DPMSolverMultistepScheduler.from_config(old.config)
works, the config is just a dictionary of numeric hyperparameters
that every scheduler shares.
ModelMixin (the base for
UNet2DConditionModel, AutoencoderKL, the
transformers, and ControlNetModel) is a
torch.nn.Module that also mixes in ConfigMixin,
adding save_pretrained and from_pretrained
that write a config.json plus a
safetensors weight file, and helpers for dtype and
device movement and gradient checkpointing.
SchedulerMixin is the parallel base for schedulers,
ConfigMixin without the parameters, since a scheduler
has no weights, only math and state. Finally
DiffusionPipeline is the composition layer that knows
how to save and load a whole tree of these objects via
model_index.json. The elegance is that all four use the
same load-and-save contract, so a model, a scheduler, and a
pipeline are the same kind of thing from the filesystem's point of
view, a config plus optional weights, which is what lets an
arbitrary combination round-trip through the Hub.
Deep dive: the scheduler zoo
Schedulers are the intellectual center of the library, and they come
in two families that trip people up if conflated. The first family
is the classical DDPM-style schedulers indexed by integer training
timesteps. The second is the continuous, sigma-parameterized,
k-diffusion-lineage schedulers that require
scale_model_input and carry a sigmas array.
A third, newer family is flow matching. All share the
set_timesteps then step interface, but
their internal state and the meaning of the timestep
argument differ.
| Scheduler | Idea | Deterministic | Typical steps |
|---|---|---|---|
| DDPMScheduler | ancestral reverse of the training SDE | no (adds noise) | ~1000 |
| DDIMScheduler | non-Markovian, skippable | yes (eta=0) | 50 |
| PNDMScheduler | pseudo-linear multistep | yes | 50 |
| EulerDiscreteScheduler | Euler on the probability-flow ODE | yes | 30 |
| DPMSolverMultistepScheduler | high-order exponential integrator | yes | 20 |
| UniPCMultistepScheduler | predictor-corrector solver | yes | 15 to 20 |
| FlowMatchEulerDiscreteScheduler | rectified flow velocity field | yes | 4 to 30 |
The key configuration field they share is
prediction_type, and getting it wrong is the classic
silent failure. A model is trained to predict one specific target,
the noise ("epsilon"), the velocity
("v_prediction"), or the clean sample
("sample"), and the scheduler must be told which,
because its update algebra inverts that exact target. Pair an
epsilon scheduler with a v-prediction model and you get noise, not an
error. Two DDPM subtleties are worth internalizing from the source.
The forward marginals are precomputed as
alphas_cumprod, the running product of
1 - beta_t, so any timestep's noise level is a lookup
rather than a loop. And step internally recovers the
predicted clean sample first, then forms the posterior mean of the
previous step from it, which is exactly the "complete the square"
posterior derived in the
class. Read
scheduling_ddpm.py and scheduling_ddim.py
side by side and the difference between stochastic ancestral
sampling and deterministic skippable sampling is a dozen lines.
Flow matching deserves its own note because it is the current
frontier and its scheduler looks different.
FlowMatchEulerDiscreteScheduler, used by SD3 and Flux,
does not think in betas at all. The forward path is a straight line
that interpolates between data and noise, the model predicts a
velocity along that line, and sampling is a plain Euler integration
of an ODE from sigma one down to zero. There is no
alphas_cumprod and the step update is close
to x = x + (sigma_next - sigma) * velocity. That
simplicity, plus straighter trajectories, is why these models sample
well in very few steps.
Deep dive: UNet and DiT backbones
The denoiser has two dominant shapes in the library. The older and
still ubiquitous one is the convolutional UNet,
UNet2DConditionModel, an encoder-decoder with skip
connections. Its input is a noisy latent, a timestep, and the text
embeddings. The timestep is turned into a sinusoidal embedding and an
MLP, then injected into every residual block, and the text enters
through cross-attention layers interleaved with the convolutions. The
block vocabulary (CrossAttnDownBlock2D,
DownBlock2D, a UNetMidBlock2DCrossAttn, and
their up-sampling mirrors) is configured entirely from
config.json, which is why one class serves SD1.5, SD2,
and SDXL at different widths and depths. SDXL is a larger UNet with
more attention at lower resolutions plus the size-and-crop
micro-conditioning fed alongside the timestep.
The newer shape is the diffusion transformer, a DiT. Here the latent
is cut into patches and flattened into a sequence of tokens, and a
stack of transformer blocks does the work, with the timestep and
other conditioning injected through adaptive layer norm, the
adaLN-zero scheme, rather than by concatenation. Diffusers carries a
family of these, a generic Transformer2DModel, the
paper-faithful DiTTransformer2DModel, and the modern
multimodal variants SD3Transformer2DModel and
FluxTransformer2DModel that use joint or "MMDiT"
attention, where image and text tokens attend to each other in shared
layers. On current main these live under
src/diffusers/models/transformers/, the UNets under
models/unets/, and the VAEs under
models/autoencoders/, though this subfoldering has been
reorganized before, so search by class name if a path has moved. The
point that survives the reshuffling is that backbone shape is
just another swappable component, a DiT and a UNet expose the same
denoiser contract, so the schedulers, the pipeline loop, and the
adapters do not care which one they are driving. The attention
math inside both, and why memory-efficient kernels matter at high
resolution, is the tiling story told in the
FlashAttention chapter.
Deep dive: adapters, ControlNet and LoRA
Adapters are how Diffusers adds control and style without retraining
the base model, and they attach at two different seams. ControlNet
(ControlNetModel, driven by pipelines like
StableDiffusionControlNetPipeline) is a trainable copy
of the UNet's encoder that takes a spatial conditioning image, a
Canny edge map, a depth map, a pose skeleton, and produces a set of
residual tensors. Those residuals are added into the frozen UNet's
skip connections and mid block. The connections start as
zero-initialized convolutions, the famous "zero convs," so at the
start of training ControlNet is a no-op and the base model is
undisturbed, and it learns to nudge from there. In the pipeline the
two networks run in lockstep each step.
# inside a ControlNet pipeline's denoising loop
down_res, mid_res = self.controlnet(
latent_model_input, t,
encoder_hidden_states=prompt_embeds,
controlnet_cond=control_image,
conditioning_scale=controlnet_conditioning_scale,
return_dict=False,
)
noise_pred = self.unet(
latent_model_input, t,
encoder_hidden_states=prompt_embeds,
down_block_additional_residuals=down_res,
mid_block_additional_residual=mid_res,
).sample
LoRA attaches at a different seam. Instead of a parallel network it
injects low-rank A and B matrices into the
existing linear layers of the UNet or transformer (and often the
text encoder), adding a small learned B @ A update to
each frozen weight. Diffusers handles this through the
loaders/ package and, under the hood, the
PEFT library, which is the shared engine for
adapters across the Hugging Face stack. Loading is one call, and
because multiple adapters can coexist you can name, weight, and
combine them.
pipe.load_lora_weights("path/or/hub-id", weight_name="style.safetensors",
adapter_name="style")
pipe.load_lora_weights("other-id", adapter_name="character")
pipe.set_adapters(["style", "character"], adapter_weights=[0.8, 1.0])
# fold the adapters into the base weights for faster inference
pipe.fuse_lora()
# ... or drop them entirely
pipe.unload_lora_weights()
One more extensibility seam ties these together, the attention
processor. src/diffusers/models/attention_processor.py
defines pluggable objects that implement a block's attention, and
unet.set_attn_processor(...) swaps them. The default
AttnProcessor2_0 routes to PyTorch's
scaled_dot_product_attention for fused, memory-efficient
attention, and IP-Adapter (image prompting) injects its image
features through a custom processor at exactly this point. The lesson
is that control is added by composition, a ControlNet is a
side network, a LoRA is a low-rank delta, an IP-Adapter is an
attention processor, and none of them fork the base model.
That is the same keep-the-core-plain instinct that
torchtitan applies to parallelism.
Deep dive: the training scripts
The examples/ tree is not an afterthought, it is where
the objective becomes concrete, and the scripts are meant to be
forked. The core training loop for a latent diffusion model is only a
few lines of real math, and it exercises the scheduler from the other
direction, as a noise adder rather than a remover.
# the essence of examples/text_to_image/train_text_to_image.py
latents = vae.encode(pixel_values).latent_dist.sample() * vae.config.scaling_factor
noise = torch.randn_like(latents)
timesteps = torch.randint(0, noise_scheduler.config.num_train_timesteps, (bsz,))
# forward process: the scheduler noises the clean latent at each timestep
noisy_latents = noise_scheduler.add_noise(latents, noise, timesteps)
model_pred = unet(noisy_latents, timesteps, encoder_hidden_states).sample
# the target depends on prediction_type
if noise_scheduler.config.prediction_type == "epsilon":
target = noise
elif noise_scheduler.config.prediction_type == "v_prediction":
target = noise_scheduler.get_velocity(latents, noise, timesteps)
loss = F.mse_loss(model_pred.float(), target.float())
loss.backward()
Two observations. First, the same add_noise and the
same prediction_type that govern sampling govern
training, which is why a scheduler is a coherent object and not just
a sampler. Second, the scripts lean on
accelerate for device placement, mixed precision, and
multi-GPU, so scaling a training run is largely a matter of the
accelerate config rather than changes to the loop. The
repository ships focused variants, train_dreambooth.py,
the LoRA trainers, and a controlnet trainer, each a
small edit away from this skeleton.
Part VI: Reading the repository
The tree is large, but the load-bearing parts are a small, readable core. Paths reflect a recent main branch, and the model subpackages in particular have moved before, so navigate by class name when a path does not resolve.
Stage 0, orientation. Skim the top-level
README.md and the docs quicktour, then open one
pipeline's __call__, for instance
src/diffusers/pipelines/stable_diffusion/pipeline_stable_diffusion.py.
Questions to hold: where does the prompt get encoded, where is the
denoising loop, and which lines are guidance versus which lines are
the scheduler?
Stage 1, the abstractions. Read
configuration_utils.py for ConfigMixin and
register_to_config, then models/modeling_utils.py
for ModelMixin, then pipeline_utils.py for
DiffusionPipeline. Questions: what does
from_pretrained read, how does
model_index.json map to component classes, and why can
any scheduler be rebuilt from any other's config?
Stage 2, one scheduler end to end. Read
schedulers/scheduling_ddpm.py against the DDPM
derivation, then scheduling_ddim.py for the
deterministic skip, then scheduling_dpmsolver_multistep.py
with the exponential-integrator section of the class in hand.
Questions: what does set_timesteps precompute, what does
step return, and where does prediction_type
change the algebra?
Stage 3, one backbone. Read
models/unets/unet_2d_condition.py for how timestep and
text conditioning enter, then a transformer such as
models/transformers/transformer_2d.py or an MMDiT
variant. Questions: where do the cross-attention layers live, how is
the timestep embedded, and what in the forward signature is the
fixed denoiser contract every scheduler relies on?
Stage 4, the adapters.
models/controlnet (the ControlNetModel) and
its pipeline for the side-network pattern, then the
loaders/ package for LoRA and single-file loading, and
models/attention_processor.py for the attention seam.
Questions: how do ControlNet residuals reach the UNet, what does
load_lora_weights actually mutate, and where does PEFT
take over?
Stage 5, training and the frontier. Read one
examples/ training script top to bottom, then browse the
newest pipelines (SD3, Flux, and the video and audio models) to see
the same skeleton adapt. The experiments-flavored and
community pipelines are by nature less stable, interesting later.
Where not to start. Do not begin in the sprawl of concrete pipeline classes, there are hundreds and they share a skeleton you learn once. Do not start with the mixin metaprogramming for lazy imports and dummy objects, it is plumbing that obscures the ideas. And do not try to read every scheduler, read three across the two families and the rest are variations on the same interface.
Part VII: Hands-on labs
Labs 1 through 3 run on a single modest GPU (or slowly on CPU for the smallest models), labs 4 and 5 want a GPU with enough memory for SDXL or a ControlNet. Log and API details drift with the library's pace, so read errors as guidance rather than gospel.
Lab 1: swap the sampler, hold everything else. Concept: the model, scheduler, pipeline separation of Part I.
import torch
from diffusers import (StableDiffusionPipeline, DDIMScheduler,
DPMSolverMultistepScheduler, EulerAncestralDiscreteScheduler)
pipe = StableDiffusionPipeline.from_pretrained(
"stable-diffusion-v1-5/stable-diffusion-v1-5", torch_dtype=torch.float16).to("cuda")
prompt = "a cozy bookshop, warm light"
g = lambda: torch.Generator("cuda").manual_seed(0)
for name, sched in [
("ddim", DDIMScheduler),
("dpm", DPMSolverMultistepScheduler),
("euler_a", EulerAncestralDiscreteScheduler),
]:
pipe.scheduler = sched.from_config(pipe.scheduler.config)
pipe(prompt, num_inference_steps=20, generator=g()).images[0].save(f"{name}.png")Compare the three images at a fixed 20 steps and the same seed. DDIM and DPM should look similar and clean, the ancestral sampler will have a different texture because it injects noise. The lesson made visible: you changed only the math, not the model.
Lab 2: watch the latent denoise. Concept: the loop runs in latent space (Part III).
preds = []
def grab(pipe, step, t, kwargs):
preds.append(kwargs["latents"])
return kwargs
pipe("a mountain lake", num_inference_steps=15,
callback_on_step_end=grab).images[0]
# decode a few intermediate latents to see the image emerge from noise
for i in (0, 5, 10, 14):
img = pipe.vae.decode(preds[i] / pipe.vae.config.scaling_factor).sample
# denormalize and save img[0] ...
Observe the early latents decode to noisy blobs and the late ones to
a coherent scene. Then remove the division by
scaling_factor and see the colors blow out, which makes
the constant's job concrete.
Lab 3: build a pipeline from bare components. Concept: a pipeline is just wiring (Stages 1 to 6).
Load a UNet2DConditionModel, an AutoencoderKL,
a CLIP text encoder and tokenizer from transformers, and
a DDIMScheduler, each with its own
from_pretrained on the SD subfolders, then write the six
stages of Part IV by hand. Encode the prompt, seed latents, run the
loop calling unet and scheduler.step,
decode. When your loop produces the same image the pipeline does, you
have proven to yourself that the pipeline holds no secret math.
Lab 4: condition with ControlNet. Concept: the side-network adapter (Part V).
from diffusers import StableDiffusionControlNetPipeline, ControlNetModel
controlnet = ControlNetModel.from_pretrained(
"lllyasviel/sd-controlnet-canny", torch_dtype=torch.float16)
pipe = StableDiffusionControlNetPipeline.from_pretrained(
"stable-diffusion-v1-5/stable-diffusion-v1-5",
controlnet=controlnet, torch_dtype=torch.float16).to("cuda")
# pass a Canny edge map as the conditioning image
image = pipe("a stained glass window", image=canny_map,
controlnet_conditioning_scale=1.0).images[0]
Vary controlnet_conditioning_scale from 0.4 to 1.4 and
watch structure adherence trade against prompt freedom. Set it to
zero and confirm the output collapses back to an unconditioned
generation, the "zero conv" idea made visible.
Lab 5: load and blend LoRAs. Concept: low-rank deltas and PEFT (Part V).
Load two style LoRAs onto an SDXL pipeline with
load_lora_weights and distinct adapter_names,
then sweep set_adapters([...], adapter_weights=[a, b])
across a grid of weights and read the interpolation between styles.
Call fuse_lora and time a generation against the unfused
path, then unload_lora_weights and confirm you are back
to the base model. You are watching a rank-16 update ride on top of a
frozen network without ever forking it.
Part VIII: Questions and model answers
Understanding checks. Answer aloud before reading.
1. What is Diffusers, in one sentence?
The standard library for diffusion models, which factors every system into a denoising model, a scheduler that is pure sampling math, and a pipeline that orchestrates them with a text encoder and a VAE, so each part can be swapped independently.
2. What is the difference between a model, a scheduler, and a pipeline?
The model is a neural network that predicts noise (or velocity) from a noisy latent, a timestep, and conditioning, and knows nothing about sampling. The scheduler is a weightless numerical method that turns a model prediction into the next, less noisy sample, and holds the noise schedule. The pipeline is the recipe that encodes the prompt, seeds the latents, runs the loop, applies guidance, and decodes.
3. Why can you swap schedulers with from_config?
Because a scheduler carries no weights, only a config of shared
numeric hyperparameters (training timesteps, beta schedule,
prediction type), and every scheduler mixes in ConfigMixin.
Rebuilding a new scheduler from the old one's config preserves the
settings the model was trained under, and the pipeline loop calls the
same set_timesteps and step interface
regardless.
4. What does the model output and what does the scheduler do with it?
The model outputs a prediction of the training target at the current
timestep, usually the added noise (epsilon), sometimes the velocity
or the clean sample. The scheduler inverts that target, recovering an
estimate of the clean latent and forming the previous step from it,
and returns prev_sample. They communicate only through
that one tensor.
5. Why does the loop run in latent space, and what is the scaling_factor for?
Because a latent diffusion model denoises inside a VAE-compressed
space that is eight times smaller per side, which is what makes
high-resolution generation tractable. The VAE decodes to pixels only
at the end. The scaling_factor is a fixed constant that
normalizes the latent's variance to roughly one so the model sees a
well-scaled input, and it must be divided back out before decoding.
6. What is classifier-free guidance in the pipeline code?
The pipeline encodes both the real prompt and an empty or negative
prompt, runs the denoiser on both (batched together), and combines
the two predictions as
uncond + scale * (cond - uncond), extrapolating away
from the unconditional direction. The guidance_scale
controls the strength, and setting it too high oversaturates and
degrades detail.
7. What breaks if prediction_type is wrong?
Nothing raises, and the output is garbage. The scheduler's update
algebra assumes the model predicted a specific target, so pairing an
epsilon model with a v-prediction scheduler makes step
invert the wrong quantity and the samples never converge to an image.
It is a silent correctness bug the library guards against by matching
configs.
8. How does ControlNet attach to a UNet without retraining it?
ControlNet is a trainable copy of the UNet's encoder that consumes a spatial conditioning image and emits residual tensors added into the frozen UNet's skip connections and mid block. The connections are zero-initialized convolutions, so training starts as a no-op on the base model and learns to steer from there. Both networks run each denoising step.
9. How does LoRA differ from ControlNet as an adapter?
LoRA is not a side network. It injects low-rank matrices into the
existing linear layers of the backbone (and often the text encoder),
adding a small learned delta to each frozen weight, and Diffusers
manages it through the loaders package on top of PEFT.
ControlNet adds spatial structure through a parallel network, LoRA
adds style or a concept through a weight-space update, and multiple
LoRAs can be named, weighted, and blended.
10. What is different about flow-matching models like SD3 and Flux?
Their scheduler is FlowMatchEulerDiscreteScheduler,
which drops betas and alphas_cumprod for a straight-line
interpolation between data and noise. The model predicts a velocity
and sampling is a plain Euler integration of an ODE from high sigma to
zero, which is why they need few steps. Their backbones are MMDiT
transformers, and Flux variants are guidance-distilled, so they want
guidance_scale near zero rather than the CFG of SD.
11. When would you reach for ComfyUI or k-diffusion instead?
ComfyUI or a web UI when the job is interactive image making with a node graph and a plugin ecosystem, where Diffusers is often the engine underneath rather than a rival. k-diffusion when you want the tightest one-file catalog of samplers to study. The original research repos when you need a paper exactly as released. Diffusers when you want a programmatic, composable, faithful library to build on or read.
12. Images come out slightly wrong with no error. Name three suspects from this chapter.
A scheduler and model mismatched on prediction_type, a
missing or doubled scaling_factor around the VAE, or a
guidance scale set for the wrong model family (high CFG on a
guidance-distilled Flux, or a negative prompt fed to a model that
ignores it). All three produce plausible-looking but degraded output
rather than a crash.
13. How does a training step relate to a sampling step?
They share the scheduler from opposite ends. Training calls
add_noise to run the forward process, noising a clean
latent to a random timestep, then trains the model to predict the
target, with the loss an MSE against noise or velocity. Sampling
calls step to run the reverse process. The same noise
schedule and prediction type govern both, which is why the scheduler
is a single coherent object.
Part IX: Design lessons
Separate mechanism from policy along the natural seams. The model does forward passes, the scheduler does the sampling math, the pipeline does orchestration, and the seam between them is a single tensor. Because the seams follow the real structure of the problem, swapping any component is a local change. This is the same instinct as keeping business logic free of persistence code.
Make the config the interface. A model, a scheduler,
and a pipeline are all "a config plus optional weights" on disk, and
one from_pretrained contract loads any of them. That
uniformity is what lets arbitrary combinations round-trip through the
Hub, and it is why from_config can rebuild a sibling
scheduler for free. Represent the invariant as serializable data and
interoperability follows.
Add capability by composition, not by forking. ControlNet is a side network, LoRA is a low-rank delta, IP-Adapter is an attention processor, and none of them edit the base model. Keeping the core plain and attaching features from outside is the same discipline torchtitan uses for parallelism and PEFT uses for fine-tuning, and it is why adapters compose instead of colliding.
Ship the reference, not just the abstraction. Every
sampler and adapter is a small, readable file that matches its paper,
and the training scripts in examples/ are meant to be
forked. A library that is also a faithful catalog becomes the place
new methods land first, which compounds into ecosystem gravity.
Guard the silent failure at the boundary. The
prediction_type and scaling_factor are the
settings that fail quietly rather than loudly, so the library carries
them in configs and threads them through both training and sampling.
Where a mismatch produces bad output instead of an exception, the
right move is to make the correct pairing the default.
Part X: Memorization framework
The one-sentence summary: Diffusers loads a denoising model, a weightless scheduler, and a pipeline from one from_pretrained contract, runs a short loop that calls the model for a noise prediction, applies classifier-free guidance, and asks the scheduler for the next latent, then decodes through the VAE, and every component, backbone, sampler, and adapter, is swappable in isolation.
from_pretrained (model_index.json) -> text_encoder + vae + unet/dit + scheduler
-> encode_prompt (uncond + cond for CFG)
-> prepare_latents (Gaussian * init_noise_sigma)
-> for t in scheduler.timesteps:
noise_pred = model(x_t, t, text)
noise_pred = uncond + scale * (cond - uncond)
x_t = scheduler.step(noise_pred, t, x_t).prev_sample
-> vae.decode(x / scaling_factor) -> PIL image
The pieces mapped to the source tree:
abstractions configuration_utils.py (ConfigMixin), models/modeling_utils.py
(ModelMixin), schedulers/scheduling_utils.py (SchedulerMixin),
pipelines/pipeline_utils.py (DiffusionPipeline)
models models/unets/, models/transformers/, models/autoencoders/,
models/controlnet, models/attention_processor.py
schedulers schedulers/scheduling_{ddpm,ddim,dpmsolver_multistep,
euler_discrete,flow_match_euler_discrete}.py
pipelines pipelines/stable_diffusion/, stable_diffusion_xl/, flux/, ...
adapters loaders/ (LoRA, single-file) + PEFT integration
training examples/text_to_image, dreambooth, controlnet (+ accelerate)
Memorize these blocks:
- The trinity: model denoises, scheduler does the math, pipeline orchestrates, and they meet only through the
noise_predtensor. - Scheduler families: integer-timestep DDPM lineage, continuous sigma-parameterized k-diffusion lineage (needs
scale_model_input), and flow matching for SD3 and Flux. - prediction_type: epsilon, v_prediction, or sample, and the model and scheduler must agree or output silently breaks.
- Latent space: the loop runs on a VAE latent eight times smaller per side, decode once at the end, mind the
scaling_factor. - Adapters: ControlNet is a side network with zero convs, LoRA is a low-rank delta via PEFT, IP-Adapter is an attention processor, none fork the base model.
Part XI: Papers and further reading
The ideas in this walkthrough come from a short list of papers, and each one rewards a direct read. Where this site derives the same idea in depth, the companion link points there.
- Ho et al., Denoising Diffusion Probabilistic Models, 2020. The training objective and the ancestral sampler behind
DDPMScheduler, the baseline every other scheduler is measured against. The forward process and the posterior are derived in the diffusion note and the diffusion and large vision models class on this site. - Song et al., Denoising Diffusion Implicit Models, 2020. The deterministic non-Markovian update behind
DDIMScheduler, and the reason a thousand-step model can sample in fifty. - Rombach et al., High-Resolution Image Synthesis with Latent Diffusion Models, 2021. The move into VAE latent space that Stable Diffusion and nearly every pipeline in the library assume. The autoencoder side is worked through in the VAE note.
- Ho and Salimans, Classifier-Free Diffusion Guidance, 2022. The two-branch prediction and the extrapolation that every
__call__applies under theguidance_scaleknob. - Lu et al., DPM-Solver, A Fast ODE Solver for Diffusion Probabilistic Model Sampling in Around 10 Steps, 2022. The high-order exponential integrator inside
DPMSolverMultistepScheduler. - Karras et al., Elucidating the Design Space of Diffusion-Based Generative Models, 2022. The sigma parameterization behind the k-diffusion lineage of schedulers and their
scale_model_inputstep. - Lipman et al., Flow Matching for Generative Modeling, 2022. The straight-line forward path and velocity objective behind
FlowMatchEulerDiscreteScheduler. The surrounding family of generative models is treated in the deep generative models class. - Peebles and Xie, Scalable Diffusion Models with Transformers, 2022. The DiT backbone and the adaLN-zero conditioning that SD3 and Flux descend from.
- Zhang et al., Adding Conditional Control to Text-to-Image Diffusion Models, 2023. The ControlNet side network and its zero convolutions. Its everyday use in node graphs is covered in the ComfyUI walkthrough.
- Hu et al., LoRA, Low-Rank Adaptation of Large Language Models, 2021. The low-rank delta the
loaderspackage injects, through the engine described in the PEFT walkthrough. - Podell et al., SDXL, Improving Latent Diffusion Models for High-Resolution Image Synthesis, 2023. The larger UNet, the two text encoders, and the size-and-crop micro-conditioning that Part IV traces.
- Esser et al., Scaling Rectified Flow Transformers for High-Resolution Image Synthesis, 2024. The SD3 report, rectified flow training plus the MMDiT joint attention over image and text tokens.
Part XII: Final takeaway
If the equations behind any of this are the gap, the
diffusion and large vision models
class derives the forward process, the posterior, DDIM and the
higher-order solvers, guidance, and flow matching end to end, and
reading a scheduler file next to those derivations is the fastest way
to make both click. Then come back and open one pipeline's
__call__ once more. It will read like a short, honest
loop, which is the entire point.