Diffusers

huggingface/diffusers is the standard library for diffusion models, the place where a new sampler, backbone, or adapter usually lands first and where the whole zoo of Stable Diffusion, SDXL, DiT, and Flux systems share one set of interfaces. Its load-bearing idea is a three-way separation. A model is a plain neural network that denoises, a scheduler is the pure math that turns denoiser outputs into the next sample, and a pipeline is the recipe that wires them to a text encoder and a VAE and runs the loop. This chapter is three things at once, a practical tutorial for generating and training, a walkthrough that follows one text-to-image call from from_pretrained down through the denoising loop and back up to a decoded image, and a staged guide to reading the repository. It pairs with the derivations in the diffusion and large vision models class and ends with runnable labs, understanding checks with model answers, and a compact framework for keeping the whole design in your head.

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/diffusers

The 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 memory

Swapping 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.

SchedulerIdeaDeterministicTypical steps
DDPMSchedulerancestral reverse of the training SDEno (adds noise)~1000
DDIMSchedulernon-Markovian, skippableyes (eta=0)50
PNDMSchedulerpseudo-linear multistepyes50
EulerDiscreteSchedulerEuler on the probability-flow ODEyes30
DPMSolverMultistepSchedulerhigh-order exponential integratoryes20
UniPCMultistepSchedulerpredictor-corrector solveryes15 to 20
FlowMatchEulerDiscreteSchedulerrectified flow velocity fieldyes4 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_pred tensor.
  • 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.

  1. 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.
  2. 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.
  3. 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.
  4. Ho and Salimans, Classifier-Free Diffusion Guidance, 2022. The two-branch prediction and the extrapolation that every __call__ applies under the guidance_scale knob.
  5. 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.
  6. 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_input step.
  7. 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.
  8. Peebles and Xie, Scalable Diffusion Models with Transformers, 2022. The DiT backbone and the adaLN-zero conditioning that SD3 and Flux descend from.
  9. 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.
  10. Hu et al., LoRA, Low-Rank Adaptation of Large Language Models, 2021. The low-rank delta the loaders package injects, through the engine described in the PEFT walkthrough.
  11. 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.
  12. 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.

Key takeaway: Diffusers shows that a sprawling, fast-moving field fits behind three small interfaces. Make the model a plain denoiser, make the scheduler the pure sampling math, make the pipeline the recipe that wires them together, load every one of them through the same config-plus-weights contract, and add control by composition rather than by forking, and one library can carry Stable Diffusion, SDXL, DiT, and Flux, images and video and audio, sampling and training, without any single piece having to know about the rest.