mirror of
https://github.com/NVIDIA/Model-Optimizer.git
synced 2026-10-30 07:34:44 +08:00
### 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>
266 lines
8.7 KiB
Python
266 lines
8.7 KiB
Python
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""
|
|
Qwen-Image model processor for preprocessing.
|
|
|
|
Handles Qwen/Qwen-Image T2I models with:
|
|
- VAE for image encoding
|
|
- Qwen2 text encoder for text conditioning
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
import torch
|
|
|
|
from .base import BaseModelProcessor
|
|
from .registry import ProcessorRegistry
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@ProcessorRegistry.register("qwen_image")
|
|
class QwenImageProcessor(BaseModelProcessor):
|
|
"""
|
|
Processor for Qwen-Image T2I models.
|
|
|
|
Qwen-Image uses a VAE for image encoding and a Qwen2 text encoder
|
|
for text conditioning.
|
|
"""
|
|
|
|
@property
|
|
def model_type(self) -> str:
|
|
return "qwen_image"
|
|
|
|
@property
|
|
def default_model_name(self) -> str:
|
|
return "Qwen/Qwen-Image"
|
|
|
|
def load_models(self, model_name: str, device: str) -> dict[str, Any]:
|
|
"""
|
|
Load Qwen-Image models.
|
|
|
|
Args:
|
|
model_name: HuggingFace model path (e.g., 'Qwen/Qwen-Image')
|
|
device: Device to load models on
|
|
|
|
Returns:
|
|
Dict containing:
|
|
- vae: AutoencoderKL
|
|
- tokenizer: Qwen2 tokenizer
|
|
- text_encoder: Qwen2 text encoder
|
|
"""
|
|
from diffusers import QwenImagePipeline
|
|
|
|
logger.info("[Qwen-Image] Loading models from %s...", model_name)
|
|
|
|
# Load pipeline without transformer (not needed for preprocessing)
|
|
pipeline = QwenImagePipeline.from_pretrained(
|
|
model_name,
|
|
transformer=None,
|
|
torch_dtype=torch.bfloat16,
|
|
)
|
|
|
|
models = {}
|
|
|
|
logger.info(" Configuring VAE...")
|
|
models["vae"] = pipeline.vae.to(device=device, dtype=torch.bfloat16)
|
|
models["vae"].eval()
|
|
|
|
logger.info(" Configuring Qwen2 text encoder...")
|
|
pipeline.text_encoder.to(device)
|
|
pipeline.text_encoder.eval()
|
|
|
|
# Keep pipeline for encode_prompt — it owns the tokenizer, text_encoder,
|
|
# chat template, and system-token dropping logic.
|
|
models["pipeline"] = pipeline
|
|
|
|
torch.cuda.empty_cache()
|
|
|
|
logger.info("[Qwen-Image] Models loaded successfully!")
|
|
return models
|
|
|
|
def encode_image(
|
|
self,
|
|
image_tensor: torch.Tensor,
|
|
models: dict[str, Any],
|
|
device: str,
|
|
) -> torch.Tensor:
|
|
"""
|
|
Encode image to latent space using VAE.
|
|
|
|
Args:
|
|
image_tensor: Image tensor (1, 3, H, W), normalized to [-1, 1]
|
|
models: Dict containing 'vae'
|
|
device: Device to use
|
|
|
|
Returns:
|
|
Latent tensor (C, H//8, W//8), FP16
|
|
"""
|
|
vae = models["vae"]
|
|
image_tensor = image_tensor.to(device, dtype=torch.bfloat16)
|
|
|
|
# Qwen-Image VAE expects 5D input (B, C, T, H, W) — add frame dim for single image
|
|
if image_tensor.ndim == 4:
|
|
image_tensor = image_tensor.unsqueeze(2)
|
|
|
|
with torch.no_grad():
|
|
latent = vae.encode(image_tensor).latent_dist.sample()
|
|
|
|
# Normalize using per-channel latents_mean / latents_std
|
|
latents_mean = (
|
|
torch.tensor(vae.config.latents_mean)
|
|
.view(1, -1, 1, 1, 1)
|
|
.to(latent.device, latent.dtype)
|
|
)
|
|
latents_std = (
|
|
torch.tensor(vae.config.latents_std)
|
|
.view(1, -1, 1, 1, 1)
|
|
.to(latent.device, latent.dtype)
|
|
)
|
|
latent = (latent - latents_mean) / latents_std
|
|
|
|
# Remove frame dim if added, then batch dim → (C, H, W)
|
|
return latent.detach().cpu().to(torch.float16).squeeze(2).squeeze(0)
|
|
|
|
def encode_text(
|
|
self,
|
|
prompt: str,
|
|
models: dict[str, Any],
|
|
device: str,
|
|
) -> dict[str, torch.Tensor]:
|
|
"""
|
|
Encode text using the QwenImagePipeline's encode_prompt.
|
|
|
|
Delegates to the diffusers pipeline which applies the correct chat
|
|
template, tokenization, system-token dropping, and attention masking.
|
|
|
|
Args:
|
|
prompt: Text prompt
|
|
models: Dict containing 'pipeline' (QwenImagePipeline)
|
|
device: Device to use
|
|
|
|
Returns:
|
|
Dict containing:
|
|
- prompt_embeds: Qwen2 hidden states [1, seq_len, hidden_dim]
|
|
- prompt_embeds_mask: Attention mask [1, seq_len]
|
|
"""
|
|
pipeline = models["pipeline"]
|
|
|
|
with torch.no_grad():
|
|
prompt_embeds, prompt_embeds_mask = pipeline.encode_prompt(
|
|
prompt=prompt,
|
|
device=device,
|
|
)
|
|
|
|
# Persist the attention mask Qwen-Image's encode_prompt returns (as the docstring
|
|
# promises, and as the negative-prompt path already does). Without it the dataset falls
|
|
# back to an all-ones mask, which is only exact when the cached embeds are trimmed to the
|
|
# real prompt length; keeping the true mask makes the contract explicit and robust.
|
|
encodings = {"prompt_embeds": prompt_embeds.detach().cpu().to(torch.bfloat16)}
|
|
if prompt_embeds_mask is not None:
|
|
encodings["prompt_embeds_mask"] = prompt_embeds_mask.detach().cpu().to(torch.long)
|
|
return encodings
|
|
|
|
def verify_latent(
|
|
self,
|
|
latent: torch.Tensor,
|
|
models: dict[str, Any],
|
|
device: str,
|
|
) -> bool:
|
|
"""
|
|
Verify latent can be decoded back to reasonable image.
|
|
|
|
Args:
|
|
latent: Encoded latent (C, H, W)
|
|
models: Dict containing 'vae'
|
|
device: Device to use
|
|
|
|
Returns:
|
|
True if verification passes
|
|
"""
|
|
try:
|
|
vae = models["vae"]
|
|
|
|
# (C, H, W) → (B, C, T, H, W) for Qwen-Image VAE
|
|
latent = latent.unsqueeze(0).unsqueeze(2).to(device).float()
|
|
|
|
with torch.no_grad():
|
|
# Denormalize: reverse (latent - mean) / std
|
|
latents_mean = (
|
|
torch.tensor(vae.config.latents_mean)
|
|
.view(1, -1, 1, 1, 1)
|
|
.to(device, latent.dtype)
|
|
)
|
|
latents_std = (
|
|
torch.tensor(vae.config.latents_std)
|
|
.view(1, -1, 1, 1, 1)
|
|
.to(device, latent.dtype)
|
|
)
|
|
latent = latent * latents_std + latents_mean
|
|
decoded = vae.decode(latent).sample
|
|
|
|
# decoded is 5D (B, C, T, H, W) — take first frame
|
|
decoded = decoded[:, :, 0]
|
|
_, c, h, w = decoded.shape
|
|
if c != 3:
|
|
return False
|
|
|
|
return not (torch.isnan(decoded).any() or torch.isinf(decoded).any())
|
|
|
|
except Exception as e:
|
|
logger.warning("[Qwen-Image] Verification failed: %s", e)
|
|
return False
|
|
|
|
def get_cache_data(
|
|
self,
|
|
latent: torch.Tensor,
|
|
text_encodings: dict[str, torch.Tensor],
|
|
metadata: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Construct cache dictionary for Qwen-Image.
|
|
|
|
Args:
|
|
latent: Encoded latent
|
|
text_encodings: Dict from encode_text()
|
|
metadata: Additional metadata
|
|
|
|
Returns:
|
|
Dict to save with torch.save()
|
|
"""
|
|
cache = {
|
|
# Image latent
|
|
"latent": latent,
|
|
# Text embeddings
|
|
"prompt_embeds": text_encodings["prompt_embeds"],
|
|
# Metadata
|
|
"original_resolution": metadata["original_resolution"],
|
|
"bucket_resolution": metadata["bucket_resolution"],
|
|
"crop_offset": metadata["crop_offset"],
|
|
"prompt": metadata["prompt"],
|
|
"image_path": metadata["image_path"],
|
|
"bucket_id": metadata["bucket_id"],
|
|
"aspect_ratio": metadata["aspect_ratio"],
|
|
# Model info
|
|
"model_type": self.model_type,
|
|
}
|
|
# Carry the positive-prompt attention mask through to the cache when present, so the
|
|
# dataset uses the real mask instead of synthesizing an all-ones one.
|
|
if "prompt_embeds_mask" in text_encodings:
|
|
cache["prompt_embeds_mask"] = text_encodings["prompt_embeds_mask"]
|
|
return cache
|