Files
090b1c5114 fastgen DMD2: make the Qwen-Image example self-contained on stock nemo_automodel (#1688)
### 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>
2026-06-22 16:22:38 -07:00

9.2 KiB
Raw Permalink Blame History

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_automodel yourself? Use the NeMo AutoModel container, which bundles it (with the diffusion extras) — then you only need a source checkout of Model-Optimizer for the examples/ tree and can skip the pip install above:

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