File size: 12,774 Bytes
855e1f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# Example Model — a template for adding a new architecture to ai-toolkit

This folder is a complete, heavily commented template for wiring a brand-new
diffusion model into ai-toolkit. It assumes the worst (and most common) case:
**diffusers does not have your model**, so you vendor the network and a minimal
sampling pipeline yourself.

It is intentionally **not registered** — it never appears as a trainable arch.
It exists purely as a guide for people (and agents) adding image, editing,
video, or i2v models.

## File map

```
example/
├── README.md          <- you are here
├── __init__.py        <- exports ExampleModel (registration notes inside)
├── example_model.py   <- the BaseModel subclass: every override documented
│                         with exact inputs/outputs
└── src/               <- everything diffusers does NOT provide
    ├── model.py       <- a minimal DiT with the gradient-checkpointing pattern
    └── pipeline.py    <- a minimal embeds-only flow-matching sampler
```

## How a model gets registered

1. `toolkit/util/get_model.py:get_all_models()` scans every package directly
   under `extensions/` and `extensions_built_in/` for a module-level
   `AI_TOOLKIT_MODELS` list.
2. For models in this folder, that list lives in
   `extensions_built_in/diffusion_models/__init__.py` — import your class
   there and append it to `AI_TOOLKIT_MODELS`.
   (Alternatively, give your model its own folder under `extensions/` with its
   own `AI_TOOLKIT_MODELS` list — see `extensions/z_image_pixel/`.)
3. The class attribute `arch` (e.g. `"example"`) is matched against
   `model.arch` in the training config YAML to pick your class.
4. To expose it in the web UI, add an entry to your package's `ui.tsx`
   (`AI_TOOLKIT_UI_MODELS`, see `ui/src/extensions/README.md`; built-in
   models live in `extensions_built_in/diffusion_models/ui.tsx`). It is
   loaded at runtime, so no UI rebuild is needed.

Minimal config YAML to train it:

```yaml
model:
  arch: "example"
  name_or_path: "/path/to/weights"   # folder with transformer/, text_encoder/,
                                     # tokenizer/, vae/
  quantize: true        # optional: qfloat8 the transformer
  quantize_te: true     # optional: qfloat8 the text encoder
train:
  gradient_checkpointing: true
```

## Lifecycle — who calls what, in order

1. **Load** — `load_model()` builds the transformer, text encoder(s),
   tokenizer(s), VAE and scheduler and stores them on `self`. Everything else
   reads `self.model` / `self.vae` / `self.text_encoder`.
2. **Caching (optional)** — before training, the trainer may call
   `encode_images()` per dataset image (latent cache) and
   `get_prompt_embeds()` per caption (text-embed cache, saved via
   `AdvancedPromptEmbeds.save`, one file per caption).
3. **Train step** (every step, see `extensions_built_in/sd_trainer/SDTrainer.py`):
   1. clean latents come from the cache or `encode_images()`
   2. noise + timestep are sampled; `add_noise()` (BaseModel) mixes them
   3. `condition_noisy_latents(noisy_latents, batch)` — your hook to inject
      control/reference conditioning
   4. `get_noise_prediction(latent_model_input, timestep, text_embeddings)` —
      the forward pass, under autograd
   5. loss = MSE(prediction, `get_loss_target(noise=..., batch=...)`)
4. **Sampling previews** — `generate_images()` (BaseModel) encodes each sample
   prompt with `get_prompt_embeds()`, then calls your
   `get_generation_pipeline()` once and `generate_single_image(...)` per
   prompt. Your pipeline only ever receives **embeds, never text**.
5. **Saving** — full fine-tunes go through `save_model()`. LoRA files are
   written by the network code, with your
   `convert_lora_weights_before_save/load()` mapping keys to the public
   convention (usually the `diffusion_model.` prefix).

## Conventions to keep straight

- **Pixels** are `(B, 3, H, W)` in `[-1, 1]` (control tensors arrive in
  `[0, 1]` — multiply by 2 and subtract 1 before encoding).
- **Latents** are `(B, C, h, w)`; video latents are `(B, C, frames, h, w)`.
- **Timesteps** cross the BaseModel API on a `0..1000` scale where 1000 is
  pure noise. Convert to your model's native convention inside
  `get_noise_prediction` — and watch for models whose native time runs the
  other way (t=1 = clean); flip and/or negate there (ideogram4 does both).
- **Flow-matching target** in this codebase is `noise - clean`
  (`get_loss_target`), i.e. the velocity pointing from data to noise.
- `self.model` / `self.transformer` / `self.unet` are aliases for the same
  thing on BaseModel.
- **`use_old_lokr_format = False`** — set this class attribute on every NEW
  model. `BaseModel` defaults it to `True` purely for backwards-compatibility
  with LoKr checkpoints trained before the format change; all new architectures
  should use the new LoKr format. (Plain LoRA training is unaffected — this only
  matters for `network.type: "lokr"`.)

## AdvancedPromptEmbeds

`toolkit/advanced_prompt_embeds.py`. The flexible container for text
conditioning, preferred for all new models over the older `PromptEmbeds`:

- Every key holds a **list of tensors, one per batch item**
  (`AdvancedPromptEmbeds(text_embeds=[t0, t1, ...])`). Store each item at its
  natural length and pad to the batch max only at the model call
  (`src/pipeline.py:pad_prompt_embeds`) — caches stay small and any prompts
  can share a batch.
- **Keep each per-item tensor 2D `(L, D)`.** This is a hard requirement, not a
  convention: `BaseModel.predict_noise` infers the text batch size from the
  embed list, and it only counts the list as one-per-item when each tensor is
  2D (`len(text_embeds[0].shape) == 2`). A 3D per-item tensor is read as an
  already-batched `(B, L, D)` and its *first axis* is taken as the batch size —
  so a single 3D prompt of length `L` looks like a batch of `L`, and training
  dies with *"Batch size of latents must be the same or half the batch size of
  text embeddings."* If your conditioning has an extra axis (e.g. a stack of N
  encoder layers, giving `(L, N, D)`), **flatten it into the feature axis**
  (`(L, N*D)`) in `get_prompt_embeds` and **restore it** (`reshape(B, Lt, N, D)`)
  in `get_noise_prediction` / the pipeline, right before the model call.
- Add as many keys as your model needs (`pooled_embeds`, image features, …).
- Keys that must not be dtype-cast (token ids, masks) go in
  `embeds.frozen_dtype_keys`.
- CFG concat (`concat_prompt_embeds`), batch expansion, `.to()`, `.save()` /
  `.load()` for the disk cache are all handled for you.

If you ever change what `get_prompt_embeds` produces, bump the
`text_embedding_space_version` property so stale on-disk caches invalidate.

## Gradient checkpointing

With `train.gradient_checkpointing: true`, `BaseSDTrainProcess` calls
`model.enable_gradient_checkpointing()` if it exists, else sets
`model.gradient_checkpointing = True`. Your network re-runs each block under
`torch.utils.checkpoint.checkpoint(..., use_reentrant=False)` when the flag is
set **and** `torch.is_grad_enabled()` is true — never gate on `self.training`.
See `src/model.py` for the full pattern and rationale.

## Quantization

With `quantize: true`, `quantize_model` swaps every `nn.Linear` for an
`optimum.quanto` quantized one. Their matmul kernel **only accepts 2D or 3D
activations** (`assert activations.ndim in (2, 3)`) — a `Linear` you feed a 4D
tensor works fine in bf16 but throws once quantized. If your network applies a
`Linear` over a 4D tensor (e.g. projecting a `(B, L, D, N)` layer axis),
reshape to 3D for the call and back afterwards.

Also watch out for **slow bf16 kernels on vendored components**: `Conv3d` has no
fast cuDNN bf16 path (it falls back to a slow one). If a frozen sub-model carries
a `Conv3d` you don't actually run — e.g. a vision tower's patch embed on a VL
text encoder — drop it (`text_encoder.model.visual = None`) to skip loading it;
if you must run one, consider running that component in fp16/fp32.

## Attention backends (don't force flash-attn)

Reference repos very often hard-code an attention kernel — `flash_attn`,
xformers, sage — and import it at module top level. **Do not carry that
requirement over.** ai-toolkit has to import and load your model on machines
where that package isn't installed (CPU boxes, headless CI, plain installs), so
a top-level `from flash_attn import ...` turns "load the model" into an
`ImportError`.

The rule:

- **Default to torch's built-in `F.scaled_dot_product_attention`** (the
  "native" backend). It needs no extra dependency, runs on CPU and CUDA, and
  already dispatches to a fused/flash kernel on supported hardware. `src/model.py`
  does exactly this.
- **Make any other kernel OPTIONAL**, selected at runtime — never required at
  import. The clean pattern:
  1. Guard the import so a missing package is a flag, not a crash:
     ```python
     try:
         from flash_attn import flash_attn_varlen_func
         _FLASH_ATTN_AVAILABLE = True
     except ImportError:
         flash_attn_varlen_func = None
         _FLASH_ATTN_AVAILABLE = False
     ```
  2. Give each attention module an `attention_backend` flag (default
     `"native"`) and **branch inside its forward** — `"flash"` runs the flash
     kernel, anything else runs SDPA.
  3. Expose a `set_attention_backend("native"|"flash")` on the parent model
     that validates the name, raises a clear error if `"flash"` is requested
     while `_FLASH_ATTN_AVAILABLE` is `False`, and propagates the flag to every
     attention module.
  4. Wire it to a config knob so it stays opt-in, e.g.
     `model_kwargs.attention_backend: "flash"` read in `load_model`.

Branch on a per-module **flag**, don't swap the processor/module instance:
attention modules that own trained q/k/v weights (joint/dual-stream blocks)
would lose those weights if you replaced them with a different instance.

Worked implementations to copy: `../ideogram4/src/transformer.py`
(`set_attention_backend`, native+flash in one `Attention.forward`) and
`../boogu_image/src/attention_processor.py` (guarded import, per-processor
`attention_backend` flag, flash varlen branch alongside SDPA).

## Adapting this template

### Editing / instruct model (image in, image out)
- In `condition_noisy_latents`, encode `batch.control_tensor`
  (`(B, 3, H, W)` in `[0, 1]`) with the VAE and attach it to the noisy
  latents — extra channels (`torch.cat(..., dim=1)`) or extra sequence tokens.
  Slice the prediction back down in `get_noise_prediction` before returning.
  Reference: `../flux_kontext/flux_kontext.py`.
- If the text encoder must *see* the control image (VL encoders), set
  `self.encode_control_in_text_embeddings = True`; `get_prompt_embeds` then
  receives `control_images`. Reference: `../qwen_image/qwen_image_edit.py`.
- Multiple reference images: `self.has_multiple_control_images = True`
  (`batch.control_tensor_list`). Reference:
  `../qwen_image/qwen_image_edit_plus.py`.
- In `generate_single_image`, load `gen_config.ctrl_img` (a file path) and run
  the same conditioning for previews.

### Video model (t2v)
- Batches arrive as `(B, frames, 3, H, W)`; latents as
  `(B, C, frames_latent, h, w)`. Override `encode_images`/`decode_latents`
  for your video VAE (temporal compression means
  `frames_latent = (frames - 1) // 4 + 1` for most VAEs).
- `gen_config.num_frames` drives previews; return a **list of PIL frames**
  from `generate_single_image` and the harness saves a video.
- Reference: `../wan22/wan22_5b_model.py` and `../ltx2/`.

### Image-to-video (i2v)
- Same as video, plus first-frame conditioning: in `get_noise_prediction`
  take frame 0 from `batch.tensor` (declare `batch` in your signature to
  receive it), encode it, and merge it into the latent input. For previews do
  the same with `gen_config.ctrl_img`.
- Reference: `../wan22/wan22_14b_i2v_model.py` and
  `toolkit/models/wan21/wan_utils.py:add_first_frame_conditioning`.

### Other useful hooks (all on `toolkit/models/base_model.py:BaseModel`)
| Override | When you need it |
|---|---|
| `get_model_to_train()` | LoRA should attach to something other than `self.model` |
| `text_embedding_space_version` / `latent_space_version` | invalidate users' caches after a breaking change |
| `te_padding_side` | LLM text encoders that need left padding |
| `is_multistage`, `multistage_boundaries` | multi-expert models split by timestep range (`../wan22/wan22_14b_model.py`) |
| `load_training_adapter()` pattern | assistant LoRAs (de-distillation adapters), see `../z_image/z_image.py` |
| `get_latent_noise_from_latents()` | custom noise (default: `randn_like`) |
| `encode_audio()` | audio-conditioned models (`../ltx2/`) |