### What does this PR do?
**Type of change:** new example / refactor (example self-containment)
The published `examples/diffusers/fastgen` DMD2 Qwen-Image distillation
example previously relied on
**local, unpublished modifications to the sibling `nemo_automodel`
package** (data path, collate, a
partial-load checkpointer, and Qwen-Image preprocessing). An external
user running the published
Model-Optimizer example against **official stock `nemo_automodel`**
would hit import/attribute errors.
This PR makes the example **self-contained on stock
`nemo_automodel>=0.4.0`** — with the changes kept
**as small as possible**: the team's actual AutoModel delta is only ~430
lines, so wherever the
upstream module is importable, the delta is expressed as a thin subclass
/ small reimplementation rather than a
full-file copy.
- **`fastgen_data/`** — the DMD2 data path:
- `collate_fns.py` — **reuses the upstream `SequentialBucketSampler` and
reimplements the collate**:
it builds the DMD2 batch directly from the vendored dataset's per-item
output (`image_latents` /
`text_embeddings` / `text_embeddings_mask` + an optional broadcast
`negative_text_embeddings` for
CFG) and deliberately does **not** call upstream
`collate_fn_production`, which stacks
model-specific token keys (`clip_tokens` / `t5_tokens`) absent from the
Qwen-Image cache. The
builder loads an optional `negative_prompt_embedding_path`.
- `text_to_image_dataset.py` — a **faithful vendored copy** of the
upstream reader (its
`prompt_embeds_mask` emission is interleaved with cache loading, so
wrapping it would force a
redundant per-item `torch.load`; carried verbatim instead).
- **`fastgen_checkpoint.py`** — `PartialLoadCheckpointer(Checkpointer)`
that overrides only
`load_optimizer` (FSDP2 `DefaultLoadPlanner(allow_partial_load=True)`)
so optimizer resume works
without patching upstream. Injected via an in-place re-bless of
`self.checkpointer` in the
recipe's `load_checkpoint` (model-state load stays strict).
- **`preprocess/`** — Qwen-Image preprocessing
(`preprocessing_multiprocess.py` + `processors/`),
trimmed to the image path (drops the flux/wan/hunyuan processors and the
video base class).
It lives in AutoModel's top-level `tools/` tree, which is **not**
shipped in the pip package, so
it cannot be wrapped and is vendored; `MultiTierBucketCalculator` is
imported from stock upstream.
- **`make_negative_prompt_embedding.py`** — generates the optional CFG
negative-prompt embedding.
- All `configs/*.yaml` target `fastgen_data.build_*` (a test enumerates
every config).
- Licensing: the AutoModel-copied files are NVIDIA-authored Apache-2.0,
so they carry only the
standard NVIDIA SPDX header (managed by the `insert-license` hook) — no
per-file provenance note,
no duplicated license, no pre-commit exclusion, and no separate
`LICENSE` note.
`nemo_automodel[diffusion]` version bound in `requirements.txt`.
The DMD2 math in `modelopt/torch/fastgen/` is **unchanged** — only
example/training-time glue moved.
### Usage
```bash
# Install example deps (stock nemo_automodel) from a source checkout
pip install -r examples/diffusers/fastgen/requirements.txt
# Build the training cache from raw images (Qwen-Image VAE latents + text embeddings)
python examples/diffusers/fastgen/preprocess_qwen_image.py image \
--image_dir <raw images> --output_dir <cache dir> --processor qwen_image \
--caption_format meta_json
# Generate the CFG negative-prompt embedding once
python examples/diffusers/fastgen/make_negative_prompt_embedding.py \
--output <cache dir>/negative_prompt_embedding.pt
# Point the config's data.dataloader.cache_dir + negative_prompt_embedding_path at the cache, then train.
```
See `examples/diffusers/fastgen/README.md` → "Requirements &
self-contained data path".
### Testing
- New `tests/examples/diffusers/fastgen/test_vendored_migration.py`:
environment-independent
invariants (every config targets a vendored builder; no `tools.*`
imports; each former AutoModel
patch is vendored / wrapped / a documented exclusion; the
former-vendored files carry the standard NVIDIA SPDX header, no
provenance note or duplicate license) plus
dependency-guarded structural tests (the collate emits the batch
contract + broadcasts the
negative embedding; the builder accepts
`negative_prompt_embedding_path`; the checkpointer
overrides only `load_optimizer`; the Qwen-Image processor
self-registers).
- **Validated 9/9 against a pure stock `nemo_automodel` 0.4.0 worktree**
(none of the local patches
present) via SLURM — re-run after this slim-down.
- The migrated code path is exercised by a live multi-GPU DMD2 run that
resumed from a checkpoint
through the vendored `PartialLoadCheckpointer`.
- `ruff check` + `ruff format --check` clean on all changed files.
### Before your PR is "*Ready for review*"
- Is this change backward compatible?: ✅ (additive; bundled configs
target the vendored builders)
- If you copied code from any other sources or added a new PIP
dependency, did you follow guidance in `CONTRIBUTING.md`: ✅
(NeMo-AutoModel @ `e42584e3`, Apache-2.0; per review these
NVIDIA-authored files carry the standard NVIDIA SPDX header, no separate
provenance / `LICENSE` note; `nemo_automodel` was already a dependency)
- Did you write any new necessary tests?: ✅
- Did you update Changelog?: N/A (example-only change)
- Did you get Claude approval on this PR?: ❌ (pending — opened as draft)
### Additional Information
Opened as a **draft** pending: OSRB review of the vendored
NeMo-AutoModel (Apache-2.0) code, CI green,
and (optional) a multi-GPU smoke-train + resume on stock upstream.
Vendored from
NVIDIA-NeMo/Automodel at commit `e42584e3`.
### Update (post-review)
- **Mid-run resume data-correctness fix** (`6ffbc52c9`): on resume the
`StatefulDataLoader`'s restored state did not advance past the resume
point, so each window re-served the same data slice and multi-window
(SLURM-windowed) runs under-covered the dataset. Fixed by rebuilding a
fresh loader and skipping the deterministic sampler to the position
implied by `global_step`; added a SLURM-free, GPU-free CPU regression
test (`tests/examples/diffusers/fastgen/test_resume_dataloader.py`).
- **Licensing review** (`be832ae95`): the AutoModel-copied files are
NVIDIA-authored Apache-2.0, so they now carry only the standard NVIDIA
SPDX header — dropped the per-file provenance note, the duplicated
original-license block, the `insert-license` pre-commit exclusion, and
the `LICENSE` note.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added vendored Qwen‑Image preprocessing (multi-process) with processor
registry support.
* Updated DMD2 data loading with a dedicated dataset/collation pipeline,
including negative-prompt embedding/mask handling.
* Improved training resume behavior by rebuilding dataloader state and
making checkpoint optimizer restore tolerant of partial FSDP2 optimizer
shards.
* **Documentation**
* Refreshed the fastgen README and config notes for real-data training;
removed the prior mock-data smoke workflow.
* **Tests**
* Added regression and migration tests covering vendored wiring,
collate/dataloader contracts, processor registration, and
resume/checkpoint behavior.
* **Chores**
* Updated licenses/attribution, vendoring/tooling guards, requirements
pinning, linting configuration, and repository ownership rules.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Signed-off-by: Jingyu Xin <jingyux@nvidia.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
9.2 KiB
DMD2 distillation for Qwen-Image
Distill Qwen/Qwen-Image into a few-step
generator with DMD2 (Distribution Matching Distillation). The distilled student
produces images in as few as 1–4 sampling steps while matching the base model's
output distribution. Built on modelopt.torch.fastgen and NeMo AutoModel's
TrainDiffusionRecipe.
Note
Qwen-Image is a third-party model with its own license terms. Review the Qwen-Image model card before downloading or redistributing weights or derivatives.
Requirements & self-contained data path
This example runs against stock upstream nemo_automodel (>=0.4.0,<1.0; see
requirements.txt) from a source checkout of Model-Optimizer — the examples/ tree is not
shipped in the nvidia-modelopt pip package. Install the example dependencies with:
pip install -r examples/diffusers/fastgen/requirements.txt
Tip
Prefer not to install
nemo_automodelyourself? Use the NeMo AutoModel container, which bundles it (with the diffusion extras) — then you only need a source checkout of Model-Optimizer for theexamples/tree and can skip thepip installabove:docker run --gpus all -it --rm --shm-size=8g nvcr.io/nvidia/nemo-automodel:26.04
The DMD2 data loading (fastgen_data/) and raw-image preprocessing (preprocess/) are
vendored into this example (from NeMo-AutoModel, Apache-2.0) so that no modifications to
nemo_automodel are required. The entry points put this directory on sys.path, so the
configs reference the vendored builders as _target_: fastgen_data.build_*. The DMD2 math in
modelopt/torch/fastgen/ is unchanged.
Build the training cache from raw images (Qwen-Image VAE latents + text embeddings):
python examples/diffusers/fastgen/preprocess_qwen_image.py image \
--image_dir <raw images> --output_dir <cache dir> --processor qwen_image \
--caption_format meta_json
The CFG negative-prompt embedding (the config's negative_prompt_embedding_path) is generated
once from the same Qwen text encoder:
python examples/diffusers/fastgen/make_negative_prompt_embedding.py \
--output <cache dir>/negative_prompt_embedding.pt
Then point the config's data.dataloader.cache_dir at <cache dir> and its
negative_prompt_embedding_path at <cache dir>/negative_prompt_embedding.pt, and train (below).
How DMD2 works
DMD2 trains three networks together:
| Model | Role |
|---|---|
| Student | the few-step generator you keep |
| Fake-score | a diffusion model that tracks the student's current output distribution |
| Teacher | the frozen base Qwen-Image model (the target distribution) |
The distribution-matching gradient pushes the student toward the teacher and away from
the fake-score. Training alternates between two phases, controlled by student_update_freq:
each step:
if step % student_update_freq == 0: # student phase
update the student (distribution-matching [+ optional GAN] loss)
update the student EMA
else: # fake-score phase
update the fake-score network to track the student
The canonical config additionally enables CFG (classifier-free guidance on the teacher) and a lightweight GAN branch (a discriminator head on a teacher feature block, plus an R1 gradient penalty) for sharper samples.
Install
From the repo root:
pip install -e ".[all]" # ModelOpt + torch + diffusers
pip install -r examples/diffusers/fastgen/requirements.txt # nemo_automodel
nemo_automodel[diffusion] pulls in diffusers, accelerate, and the TrainDiffusionRecipe
this example subclasses.
Real-data training
configs/dmd2_qwen_image.yaml is the canonical config: 4-step student, CFG, and the
GAN + R1 branch, trained on a preprocessed latent cache. Before launching, provide:
- A preprocessed Qwen-Image latent cache — set
data.dataloader.cache_dir. - A precomputed negative-prompt embedding (required for CFG) — set
data.dataloader.negative_prompt_embedding_path. - An output directory — set
checkpoint.checkpoint_dir.
The model path defaults to Qwen/Qwen-Image; point it at a local snapshot to avoid
re-downloading on every job. Then:
torchrun --nproc-per-node=8 \
examples/diffusers/fastgen/dmd2_finetune.py \
--config examples/diffusers/fastgen/configs/dmd2_qwen_image.yaml \
--step_scheduler.max_steps=5000
Any DMDConfig field can be overridden on the CLI (e.g. --dmd2.guidance_scale=3.5).
Checkpoints & resuming
Checkpoints land under checkpoint.checkpoint_dir. Alongside the student, the recipe
saves the DMD2 sidecars needed to resume exactly: the fake-score model + optimizer, the
student EMA (ema_shadow.pt), and the DMD iteration counter (dmd_state.pt). With
restore_from: LATEST a re-launch auto-resumes from the newest checkpoint; pin a
specific one with --checkpoint.restore_from=epoch_0_step_500.
Inference
After training, sample from the distilled student. The pipeline loads your consolidated student transformer plus the base Qwen-Image VAE / text encoder / tokenizer:
import torch
from inference_dmd2_qwen_image import QwenImageDMDInferencePipeline
pipe = QwenImageDMDInferencePipeline.from_pretrained(
student_path="/path/to/checkpoint/epoch_0_step_500/model/consolidated",
base_pipeline_path="Qwen/Qwen-Image",
ema_path=None, # or ".../ema_shadow.pt" to sample the EMA weights
torch_dtype=torch.bfloat16,
).to("cuda")
image = pipe(
prompt="a small red cube on a white table",
num_inference_steps=4, # match the student_sample_steps you trained with
height=1024, width=1024,
generator=torch.Generator("cuda").manual_seed(42),
).images[0]
image.save("sample.png")
Or run the bundled CLI for a quick check:
python examples/diffusers/fastgen/inference_dmd2_qwen_image.py \
--student_path /path/to/checkpoint/.../model/consolidated \
--base_pipeline_path Qwen/Qwen-Image \
--prompt "a small red cube on a white table" \
--height 512 --width 512
Set num_inference_steps to the number of steps the student was trained for
(dmd2.student_sample_steps — e.g. 4 for the canonical config, or 1 for a single-step
student).
Config reference
| Section | Key | Role |
|---|---|---|
model |
pretrained_model_name_or_path |
Qwen-Image HF id or local snapshot. |
model |
mode |
finetune — loads the pretrained weights. |
step_scheduler |
global_batch_size, local_batch_size, max_steps, ckpt_every_steps, log_every |
Standard AutoModel scheduling knobs. |
dmd2 |
recipe_path |
Built-in fastgen recipe to hydrate DMDConfig from (general/distillation/dmd2_qwen_image). |
dmd2 |
pipeline_plugin |
qwen_image — selects QwenImageDMDPipeline (2×2 patch packing / img_shapes). |
dmd2 |
student_sample_steps |
Number of student sampling steps (e.g. 4). |
dmd2 |
guidance_scale |
CFG strength on the teacher (null disables CFG; requires a negative-prompt embedding when set). |
dmd2 |
gan_loss_weight_gen, gan_r1_reg_weight, gan_feature_indices, … |
GAN branch (set gan_loss_weight_gen: 0 to disable). |
dmd2 |
fake_score_lr, discriminator_lr |
Separate LRs for the fake-score / discriminator optimizers. |
dmd2 |
sample_t_cfg, ema |
Timestep sampling + student EMA settings. |
optim |
learning_rate, optimizer.* |
Student AdamW knobs. |
fsdp |
dp_size, tp_size, activation_checkpointing, … |
FSDP2 parallelism (set dp_size to your GPU count). |
data |
dataloader._target_, cache_dir, negative_prompt_embedding_path |
Latent cache dir + optional CFG negative-prompt embedding. |
checkpoint |
checkpoint_dir, model_save_format, restore_from |
Output dir, save format, resume behavior. |
Troubleshooting
CUDA out of memory. Training holds three Qwen-Image transformers (student + teacher
- fake-score) plus optimizer state. Shard across more GPUs (raise
--fsdp.dp_size), or enable--fsdp.activation_checkpointing=true.
Loss is NaN on step 0. Almost always an out-of-range timestep — confirm you haven't
overridden dmd2.pred_type away from flow (Qwen-Image is a rectified-flow model) or
changed the timestep schedule.
guidance_scale is set but negative_encoder_hidden_states was not provided. CFG needs
a precomputed negative-prompt embedding. Set data.dataloader.negative_prompt_embedding_path,
or set dmd2.guidance_scale: null to disable CFG.
Dataloader yields empty batches. Ensure your cache has at least
local_batch_size * fsdp.dp_size items; the distributed sampler drops incomplete batches.
Reference
- Fastgen library:
modelopt/torch/fastgen/ - Built-in recipe:
modelopt_recipes/general/distillation/dmd2_qwen_image.yaml - AutoModel recipe this example subclasses:
nemo_automodel/recipes/diffusion/train.py