mirror of
https://github.com/NVIDIA/Model-Optimizer.git
synced 2026-10-12 04:40:29 +08:00
## Summary LTX-2 (`ltx-core`, `ltx-pipelines`, `ltx-trainer`) is a third-party dependency developed and provided by Lightricks. It is governed by the [LTX Community License Agreement](https://github.com/Lightricks/LTX-2/blob/main/LICENSE), **not** the Apache 2.0 license that covers NVIDIA Model Optimizer. Per legal guidance, all integration points must clearly surface this to users. - Add `[!WARNING]` license notice blocks at the top of all LTX-2-related READMEs (`examples/diffusers`, `examples/diffusers/distillation`, `examples/windows/diffusers/qad_example`) - Add `warnings.warn(UserWarning)` at every LTX package import site in Python files, covering both top-level and lazy imports: - `examples/diffusers/distillation/distillation_trainer.py` - `examples/diffusers/quantization/calibration.py` - `examples/diffusers/quantization/pipeline_manager.py` - `examples/windows/diffusers/qad_example/sample_example_qad_diffusers.py` - `modelopt/torch/export/diffusers_utils.py` - `modelopt/torch/quantization/plugins/diffusion/ltx2.py` - Add license notice comment to `requirements.txt` files that list LTX packages, so the obligation is visible at install time - Update `.github/CODEOWNERS` so all `requirements*.txt` files (covering variants like `requirements-dev.txt`) are owned by `@NVIDIA/modelopt-setup-codeowners` regardless of location, via a last-match-wins rule **Design notes:** - For library files (`diffusers_utils.py`, `ltx2.py`), the warning is placed at the lazy import site inside functions — it fires only when LTX-2 code paths are actually invoked, not at module import time, to avoid polluting non-LTX users - For example entry-point scripts that are LTX-2-only, the warning fires at module load time (after all imports, to satisfy ruff E402) ## Test plan - [ ] Confirm `pre-commit run --all-files` passes (ruff, mypy, markdownlint, bandit all clean) - [ ] Verify warning appears at runtime when running an LTX-2 quantization or distillation example - [ ] Confirm non-LTX code paths (FLUX, SDXL, SD3) do not emit the warning 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Added third-party license notices across documentation and requirements files clarifying LTX-2 packages are governed by the LTX Community License Agreement rather than NVIDIA Model Optimizer's Apache 2.0 license. * **Chores** * Updated code ownership configuration for requirements files. * Added runtime warnings to notify when LTX-2 dependencies are accessed. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1008 lines
38 KiB
Python
1008 lines
38 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-FileCopyrightText: Copyright (c) 2024 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.
|
|
|
|
"""
|
|
QAD (Quantization-Aware Distillation) for LTX-2 using the native LTX training loop + ModelOpt.
|
|
|
|
Uses:
|
|
- LtxvTrainer: training loop, dataset, strategies (masked loss, audio/video split)
|
|
- ModelOpt: mtq.quantize for calibration, mtd.convert for distillation
|
|
|
|
Usage:
|
|
# Training
|
|
accelerate launch --config_file configs/accelerate/fsdp.yaml ltx2_qad_ltx_pipeline.py \
|
|
train --config configs/ltx2_full_finetune.yaml \
|
|
--calib-size 512 \
|
|
--kd-loss-weight 0.5
|
|
|
|
# Create inference checkpoint from existing trained weights
|
|
python ltx2_qad_ltx_pipeline.py create-inference \
|
|
--trained path/to/model_weights_step_02200.safetensors \
|
|
--base path/to/ltx-video-2b-v0.9.5.safetensors \
|
|
--output path/to/inference.safetensors
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import gc
|
|
import json
|
|
import logging
|
|
import os
|
|
import struct
|
|
import sys
|
|
import warnings
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
|
|
import torch
|
|
import torch.distributed as dist
|
|
|
|
# LTX imports
|
|
from ltx_trainer.config import LtxTrainerConfig
|
|
from ltx_trainer.datasets import PrecomputedDataset
|
|
from ltx_trainer.model_loader import load_transformer
|
|
from ltx_trainer.timestep_samplers import SAMPLERS
|
|
from ltx_trainer.trainer import LtxvTrainer
|
|
from ltx_trainer.training_strategies import get_training_strategy
|
|
from torch.utils.data import DataLoader
|
|
|
|
# ModelOpt imports
|
|
import modelopt.torch.distill as mtd
|
|
import modelopt.torch.opt as mto
|
|
import modelopt.torch.quantization as mtq
|
|
from modelopt.torch.distill.distillation_model import DistillationModel
|
|
from modelopt.torch.quantization.config import NVFP4_DEFAULT_CFG
|
|
from modelopt.torch.utils import safe_load
|
|
|
|
warnings.warn(
|
|
"LTX-2 packages (ltx-core, ltx-pipelines, ltx-trainer) are provided by Lightricks and are "
|
|
"NOT covered by the Apache 2.0 license governing NVIDIA Model Optimizer. You MUST comply "
|
|
"with the LTX Community License Agreement when installing and using LTX-2 with NVIDIA Model "
|
|
"Optimizer. Any derivative models or fine-tuned weights from LTX-2 (including quantized or "
|
|
"distilled checkpoints) remain subject to the LTX Community License Agreement, not Apache "
|
|
"2.0. See: https://github.com/Lightricks/LTX-2/blob/main/LICENSE",
|
|
UserWarning,
|
|
stacklevel=1,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ─── Constants ────────────────────────────────────────────────────────────────
|
|
|
|
QUANTIZER_KEYWORDS = [
|
|
"_amax",
|
|
"_zero_point",
|
|
"input_quantizer",
|
|
"weight_quantizer",
|
|
"output_quantizer",
|
|
]
|
|
TEACHER_KEYWORDS = ["_teacher_model"]
|
|
LOSS_KEYWORDS = ["_loss_modules"]
|
|
|
|
NON_TRANSFORMER_PREFIXES = [
|
|
"vae.",
|
|
"audio_vae.",
|
|
"vocoder.",
|
|
"text_embedding_projection.",
|
|
"text_encoders.",
|
|
"first_stage_model.",
|
|
"cond_stage_model.",
|
|
"conditioner.",
|
|
]
|
|
STRIP_PREFIXES = ["diffusion_model.", "transformer.", "_orig_mod.", "model."]
|
|
CORRECT_PREFIX = "model.diffusion_model."
|
|
|
|
SENSITIVE_LAYER_PATTERNS = [
|
|
"*patchify_proj*",
|
|
"*adaln_single*",
|
|
"*caption_projection*",
|
|
"*proj_out*",
|
|
"*audio_patchify_proj*",
|
|
"*audio_adaln_single*",
|
|
"*audio_caption_projection*",
|
|
"*audio_proj_out*",
|
|
"*av_ca_video_scale_shift_adaln_single*",
|
|
"*av_ca_a2v_gate_adaln_single*",
|
|
"*av_ca_audio_scale_shift_adaln_single*",
|
|
"*av_ca_v2a_gate_adaln_single*",
|
|
]
|
|
|
|
|
|
# ─── Multi-node safety ───────────────────────────────────────────────────────
|
|
|
|
|
|
def is_global_rank0() -> bool:
|
|
"""Global rank 0 check — safe for multi-node shared filesystem writes."""
|
|
if dist.is_initialized():
|
|
return dist.get_rank() == 0
|
|
return os.environ.get("RANK", "0") == "0"
|
|
|
|
|
|
# ─── Format detection and loading ─────────────────────────────────────────────
|
|
|
|
|
|
def detect_format(path: str) -> str:
|
|
"""Detect whether file is safetensors or torch pickle."""
|
|
with open(path, "rb") as f:
|
|
magic = f.read(2)
|
|
if magic == b"PK" or magic[:1] == b"\x80":
|
|
return "torch"
|
|
return "safetensors"
|
|
|
|
|
|
def load_state_dict_any_format(path: str, label: str = "", **kwargs) -> tuple[dict, dict | None]:
|
|
"""Load state dict from either torch pickle or safetensors."""
|
|
fmt = detect_format(path)
|
|
logger.info(f"[{label}] Detected format: {fmt} for {path}")
|
|
|
|
if fmt == "torch":
|
|
raw = safe_load(path, map_location="cpu", **kwargs)
|
|
if isinstance(raw, dict) and "state_dict" in raw:
|
|
return raw["state_dict"], None
|
|
return raw, None
|
|
else:
|
|
try:
|
|
from safetensors.torch import load_file, safe_open
|
|
|
|
with safe_open(path, framework="pt", device="cpu") as f:
|
|
metadata = f.metadata() or {}
|
|
return load_file(path, device="cpu"), metadata
|
|
except Exception as e:
|
|
logger.warning(f"safe_open failed ({e}), trying manual parse...")
|
|
return _load_safetensors_manual(path)
|
|
|
|
|
|
def _load_safetensors_manual(path: str) -> tuple[dict, dict]:
|
|
"""Manual safetensors parser for files with oversized headers."""
|
|
dtype_map = {
|
|
"F64": torch.float64,
|
|
"F32": torch.float32,
|
|
"F16": torch.float16,
|
|
"BF16": torch.bfloat16,
|
|
"I64": torch.int64,
|
|
"I32": torch.int32,
|
|
"I16": torch.int16,
|
|
"I8": torch.int8,
|
|
"U8": torch.uint8,
|
|
"BOOL": torch.bool,
|
|
}
|
|
with open(path, "rb") as f:
|
|
header_size = struct.unpack("<Q", f.read(8))[0]
|
|
header = json.loads(f.read(header_size))
|
|
metadata = header.pop("__metadata__", {})
|
|
data_start = 8 + header_size
|
|
|
|
state_dict = {}
|
|
with open(path, "rb") as f:
|
|
for k, info in header.items():
|
|
torch_dtype = dtype_map[info["dtype"]]
|
|
start, end = info["data_offsets"]
|
|
f.seek(data_start + start)
|
|
tensor = torch.frombuffer(bytearray(f.read(end - start)), dtype=torch_dtype)
|
|
if info["shape"]:
|
|
tensor = tensor.reshape(info["shape"])
|
|
state_dict[k] = tensor
|
|
return state_dict, metadata
|
|
|
|
|
|
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def is_removable_key(k: str) -> str | None:
|
|
"""Return removal reason if key should be removed, else None."""
|
|
if any(kw in k for kw in QUANTIZER_KEYWORDS):
|
|
return "quantizer"
|
|
if any(kw in k for kw in TEACHER_KEYWORDS):
|
|
return "teacher"
|
|
if any(kw in k for kw in LOSS_KEYWORDS):
|
|
return "loss"
|
|
return None
|
|
|
|
|
|
def is_non_transformer(k: str) -> bool:
|
|
return any(k.startswith(p) for p in NON_TRANSFORMER_PREFIXES)
|
|
|
|
|
|
def extract_amax_values(state_dict: dict) -> dict:
|
|
"""Extract all amax tensors into a JSON-serializable dict."""
|
|
amax_dict = {}
|
|
for k, v in state_dict.items():
|
|
if "_amax" in k:
|
|
amax_dict[k] = float(v.item()) if v.numel() == 1 else v.cpu().float().tolist()
|
|
return amax_dict
|
|
|
|
|
|
def move_batch_to_device(batch: dict, device: torch.device) -> dict:
|
|
"""Recursively move batch tensors to device."""
|
|
result = {}
|
|
for k, v in batch.items():
|
|
if isinstance(v, dict):
|
|
result[k] = {
|
|
ik: iv.to(device) if isinstance(iv, torch.Tensor) else iv for ik, iv in v.items()
|
|
}
|
|
elif isinstance(v, torch.Tensor):
|
|
result[k] = v.to(device)
|
|
else:
|
|
result[k] = v
|
|
return result
|
|
|
|
|
|
def apply_connectors(batch, text_encoder):
|
|
"""Apply text encoder connectors to transform pre-computed prompt embeddings."""
|
|
conditions = batch["conditions"]
|
|
device = conditions["prompt_embeds"].device
|
|
text_encoder.to(device)
|
|
|
|
video_embeds, audio_embeds, attention_mask = text_encoder._run_connectors(
|
|
conditions["prompt_embeds"], conditions["prompt_attention_mask"]
|
|
)
|
|
conditions["video_prompt_embeds"] = video_embeds
|
|
conditions["audio_prompt_embeds"] = audio_embeds
|
|
conditions["prompt_attention_mask"] = attention_mask
|
|
|
|
|
|
# ─── Quantization config builder ─────────────────────────────────────────────
|
|
|
|
|
|
def build_quant_config(
|
|
exclude_blocks: list[int] | None = None,
|
|
) -> dict:
|
|
"""Build the NVFP4 quantization config with sensitive layers excluded.
|
|
|
|
Args:
|
|
exclude_blocks: Transformer block indices to exclude from quantization.
|
|
Defaults to [0, 1, 46, 47] (first 2 and last 2).
|
|
"""
|
|
if exclude_blocks is None:
|
|
exclude_blocks = [0, 1, 46, 47]
|
|
|
|
_nvfp4_cfg = {
|
|
"num_bits": (2, 1),
|
|
"block_sizes": {-1: 16, "type": "dynamic", "scale_bits": (4, 3)},
|
|
"axis": None,
|
|
}
|
|
quant_cfg = [
|
|
{"quantizer_name": "*weight_quantizer", "cfg": _nvfp4_cfg, "enable": True},
|
|
{"quantizer_name": "*input_quantizer", "cfg": _nvfp4_cfg, "enable": True},
|
|
*[{"quantizer_name": pattern, "enable": False} for pattern in SENSITIVE_LAYER_PATTERNS],
|
|
*[
|
|
{"quantizer_name": f"*transformer_blocks.{i}.*", "enable": False}
|
|
for i in exclude_blocks
|
|
],
|
|
]
|
|
|
|
return {
|
|
"quant_cfg": quant_cfg,
|
|
"algorithm": NVFP4_DEFAULT_CFG["algorithm"],
|
|
}
|
|
|
|
|
|
# ─── Distillation loss ───────────────────────────────────────────────────────
|
|
|
|
|
|
class DiffusionMSELoss(torch.nn.modules.loss._Loss):
|
|
"""MSE loss between student and teacher outputs for distillation.
|
|
|
|
Handles the new model output format where forward returns a tuple
|
|
of (video_pred, audio_pred) instead of a single tensor.
|
|
"""
|
|
|
|
def __init__(self, video_weight: float = 0.95, audio_weight: float = 0.05):
|
|
super().__init__()
|
|
self.video_weight = video_weight
|
|
self.audio_weight = audio_weight
|
|
|
|
def forward(self, student_output, teacher_output):
|
|
if isinstance(student_output, tuple):
|
|
video_student, audio_student = student_output
|
|
video_teacher, audio_teacher = teacher_output
|
|
loss = self.video_weight * torch.nn.functional.mse_loss(
|
|
video_student.float(), video_teacher.float()
|
|
)
|
|
if audio_student is not None and audio_teacher is not None:
|
|
loss = loss + self.audio_weight * torch.nn.functional.mse_loss(
|
|
audio_student.float(), audio_teacher.float()
|
|
)
|
|
return loss
|
|
return torch.nn.functional.mse_loss(student_output.float(), teacher_output.float())
|
|
|
|
|
|
# ─── QAD Trainer ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
class LtxvQADTrainer(LtxvTrainer):
|
|
"""Extends LtxvTrainer with ModelOpt quantization and distillation.
|
|
|
|
Execution order:
|
|
1. super().__init__() loads models, sets up accelerator
|
|
2. _prepare_models_for_training() is OVERRIDDEN to:
|
|
a. Quantize the raw model (PTQ calibration)
|
|
b. Wrap with distillation (teacher + student)
|
|
c. THEN do accelerator.prepare() (FSDP wrapping)
|
|
3. train() runs normal LTX training loop
|
|
4. _training_step() is OVERRIDDEN to add KD loss
|
|
5. _save_checkpoint() is OVERRIDDEN for clean safetensors output
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
trainer_config: LtxTrainerConfig,
|
|
quant_cfg: dict,
|
|
calib_size: int = 512,
|
|
kd_loss_weight: float = 0.5,
|
|
):
|
|
self._quant_cfg = quant_cfg
|
|
self._calib_size = calib_size
|
|
self._kd_loss_weight = kd_loss_weight
|
|
super().__init__(trainer_config)
|
|
|
|
# ── Model preparation ─────────────────────────────────────────────────
|
|
|
|
def _prepare_models_for_training(self):
|
|
"""Override: quantize + distill BEFORE FSDP wrapping."""
|
|
self._transformer.set_gradient_checkpointing(
|
|
self._config.optimization.enable_gradient_checkpointing
|
|
)
|
|
|
|
self._run_calibration()
|
|
self._setup_distillation()
|
|
|
|
self._vae_decoder = self._vae_decoder.to("cpu")
|
|
if self._vae_encoder is not None:
|
|
self._vae_encoder = self._vae_encoder.to("cpu")
|
|
|
|
self._transformer.to(torch.bfloat16)
|
|
self._transformer = self._accelerator.prepare(self._transformer)
|
|
|
|
gc.collect()
|
|
torch.cuda.empty_cache()
|
|
|
|
if torch.cuda.is_available():
|
|
vram_gb = torch.cuda.memory_allocated() / 1024**3
|
|
logger.info(f"GPU memory after model preparation: {vram_gb:.2f} GB")
|
|
|
|
# ── Calibration ───────────────────────────────────────────────────────
|
|
|
|
def _run_calibration(self):
|
|
"""Run PTQ calibration using LTX's own dataset and training strategy."""
|
|
logger.info("Running PTQ calibration...")
|
|
|
|
if not hasattr(self, "_training_strategy") or self._training_strategy is None:
|
|
self._training_strategy = get_training_strategy(self._config.training_strategy)
|
|
|
|
data_sources = self._training_strategy.get_data_sources()
|
|
dataset = PrecomputedDataset(
|
|
self._config.data.preprocessed_data_root,
|
|
data_sources=data_sources,
|
|
)
|
|
torch.manual_seed(42)
|
|
calib_loader = DataLoader(
|
|
dataset,
|
|
batch_size=self._config.optimization.batch_size,
|
|
shuffle=False,
|
|
num_workers=0,
|
|
drop_last=True,
|
|
)
|
|
|
|
sampler_cls = SAMPLERS[self._config.flow_matching.timestep_sampling_mode]
|
|
timestep_sampler = sampler_cls(**self._config.flow_matching.timestep_sampling_params)
|
|
|
|
calib_steps = min(self._calib_size, len(dataset))
|
|
strategy = self._training_strategy
|
|
device = self._accelerator.device
|
|
text_encoder = self._text_encoder
|
|
|
|
self._transformer.to(device)
|
|
if text_encoder is not None:
|
|
text_encoder.to(device)
|
|
|
|
def calibration_forward_loop(model):
|
|
model.eval()
|
|
data_iter = iter(calib_loader)
|
|
failures = 0
|
|
with torch.no_grad():
|
|
for i in range(calib_steps):
|
|
try:
|
|
batch = next(data_iter)
|
|
except StopIteration:
|
|
data_iter = iter(calib_loader)
|
|
batch = next(data_iter)
|
|
|
|
batch = move_batch_to_device(batch, device)
|
|
|
|
try:
|
|
if text_encoder is not None and "conditions" in batch:
|
|
apply_connectors(batch, text_encoder)
|
|
|
|
model_inputs = strategy.prepare_training_inputs(batch, timestep_sampler)
|
|
model(
|
|
video=model_inputs.video,
|
|
audio=model_inputs.audio,
|
|
perturbations=None,
|
|
)
|
|
|
|
except Exception as e:
|
|
failures += 1
|
|
if failures == 1:
|
|
import traceback
|
|
|
|
logger.warning(
|
|
f"Calibration batch {i} failed:\n{traceback.format_exc()}"
|
|
)
|
|
elif failures <= 5:
|
|
logger.warning(f"Calibration batch {i} failed: {e}")
|
|
if failures > calib_steps * 0.5:
|
|
logger.error(
|
|
f"Too many calibration failures ({failures}/{i + 1}), aborting"
|
|
)
|
|
return
|
|
continue
|
|
|
|
if (i + 1) % 50 == 0 or (i + 1) == calib_steps:
|
|
logger.info(f"Calibrated {i + 1}/{calib_steps} batches")
|
|
|
|
if failures > 0:
|
|
logger.warning(
|
|
f"Calibration completed with {failures}/{calib_steps} failed batches"
|
|
)
|
|
|
|
mtq.quantize(self._transformer, self._quant_cfg, calibration_forward_loop)
|
|
logger.info("PTQ calibration complete")
|
|
if is_global_rank0():
|
|
mtq.print_quant_summary(self._transformer)
|
|
|
|
# ── Distillation setup ────────────────────────────────────────────────
|
|
|
|
def _setup_distillation(self):
|
|
"""Load teacher from same checkpoint and wrap with DistillationModel."""
|
|
logger.info("Setting up distillation...")
|
|
|
|
checkpoint_path = self._config.model.model_path
|
|
|
|
teacher = load_transformer(
|
|
checkpoint_path=checkpoint_path,
|
|
device="cpu",
|
|
dtype=torch.bfloat16,
|
|
)
|
|
|
|
distill_config = {
|
|
"teacher_model": (lambda: teacher, (), {}),
|
|
"criterion": DiffusionMSELoss(),
|
|
"loss_balancer": mtd.StaticLossBalancer(kd_loss_weight=self._kd_loss_weight),
|
|
"expose_minimal_state_dict": False,
|
|
}
|
|
|
|
mtd.convert(self._transformer, mode=[("kd_loss", distill_config)])
|
|
logger.info(f"Distillation model created (kd_loss_weight={self._kd_loss_weight})")
|
|
|
|
# ── Training step ─────────────────────────────────────────────────────
|
|
|
|
def _training_step(self, batch):
|
|
"""Override: use strategy's loss + add distillation loss."""
|
|
conditions = batch["conditions"]
|
|
video_embeds, audio_embeds, attention_mask = self._text_encoder._run_connectors(
|
|
conditions["prompt_embeds"], conditions["prompt_attention_mask"]
|
|
)
|
|
conditions["video_prompt_embeds"] = video_embeds
|
|
conditions["audio_prompt_embeds"] = audio_embeds
|
|
conditions["prompt_attention_mask"] = attention_mask
|
|
|
|
model_inputs = self._training_strategy.prepare_training_inputs(
|
|
batch, self._timestep_sampler
|
|
)
|
|
|
|
video_pred, audio_pred = self._transformer(
|
|
video=model_inputs.video,
|
|
audio=model_inputs.audio,
|
|
perturbations=None,
|
|
)
|
|
hard_loss = self._training_strategy.compute_loss(video_pred, audio_pred, model_inputs)
|
|
|
|
unwrapped = self._accelerator.unwrap_model(self._transformer)
|
|
if isinstance(unwrapped, DistillationModel) and unwrapped.training:
|
|
return unwrapped.compute_kd_loss(student_loss=hard_loss)
|
|
|
|
return hard_loss
|
|
|
|
# ── Checkpoint saving ─────────────────────────────────────────────────
|
|
|
|
def _save_checkpoint(self) -> Path:
|
|
"""Override: save clean student weights as real safetensors + modelopt state.
|
|
|
|
Fixes vs original:
|
|
- Uses safetensors.save_file() directly (no silent fallback to pickle)
|
|
- Atomic save (write to .tmp, rename on success)
|
|
- Multi-node safe (global rank 0 only for writes)
|
|
- Extracts and saves amax values as separate JSON
|
|
"""
|
|
from safetensors.torch import save_file
|
|
|
|
self._accelerator.wait_for_everyone()
|
|
save_dir = Path(self._config.output_dir) / "checkpoints"
|
|
|
|
# FSDP collective — all ranks must call this
|
|
state_dict = self._accelerator.get_state_dict(self._transformer)
|
|
|
|
prefix = "model" if self._config.model.training_mode == "full" else "lora"
|
|
filename = f"{prefix}_weights_step_{self._global_step:05d}.safetensors"
|
|
saved_weights_path = save_dir / filename
|
|
|
|
if is_global_rank0() and state_dict is not None:
|
|
save_dir.mkdir(exist_ok=True, parents=True)
|
|
|
|
# 1. Extract amax values BEFORE filtering
|
|
amax_dict = extract_amax_values(state_dict)
|
|
if amax_dict:
|
|
amax_path = save_dir / f"amax_step_{self._global_step:05d}.json"
|
|
with open(amax_path, "w") as f:
|
|
json.dump(
|
|
{"total_amax_keys": len(amax_dict), "amax_values": amax_dict},
|
|
f,
|
|
indent=2,
|
|
sort_keys=True,
|
|
)
|
|
logger.info(f"Saved {len(amax_dict)} amax values to {amax_path}")
|
|
|
|
# 2. Filter out teacher, loss, and quantizer keys
|
|
clean_state = {}
|
|
removed = {"teacher": 0, "loss": 0, "quantizer": 0}
|
|
for k, v in state_dict.items():
|
|
reason = is_removable_key(k)
|
|
if reason:
|
|
removed[reason] += 1
|
|
else:
|
|
clean_state[k] = v
|
|
del state_dict
|
|
|
|
logger.info(
|
|
f"Filtered: kept {len(clean_state)} keys, "
|
|
f"removed {sum(removed.values())} "
|
|
f"(teacher={removed['teacher']}, loss={removed['loss']}, "
|
|
f"quantizer={removed['quantizer']})"
|
|
)
|
|
|
|
# 3. Match dtypes with base model
|
|
try:
|
|
from safetensors.torch import load_file as _load_base
|
|
|
|
base_state = _load_base(self._config.model.model_path)
|
|
dtype_fixed = 0
|
|
for k in clean_state:
|
|
base_key = f"{CORRECT_PREFIX}{k}"
|
|
if base_key in base_state:
|
|
ref_dtype = base_state[base_key].dtype
|
|
elif k in base_state:
|
|
ref_dtype = base_state[k].dtype
|
|
else:
|
|
ref_dtype = (
|
|
torch.bfloat16 if clean_state[k].dtype == torch.float32 else None
|
|
)
|
|
|
|
if ref_dtype is not None and clean_state[k].dtype != ref_dtype:
|
|
clean_state[k] = clean_state[k].to(ref_dtype)
|
|
dtype_fixed += 1
|
|
del base_state
|
|
if dtype_fixed:
|
|
logger.info(f"Fixed {dtype_fixed} tensor dtypes to match base model")
|
|
except Exception as e:
|
|
logger.warning(f"Could not load base model for dtype matching: {e}")
|
|
|
|
# 4. Save as safetensors (atomic: write to .tmp, then rename)
|
|
save_size_gb = sum(v.numel() * v.element_size() for v in clean_state.values()) / (
|
|
1024**3
|
|
)
|
|
logger.info(f"Saving checkpoint: {len(clean_state)} keys, {save_size_gb:.2f} GB")
|
|
|
|
tmp_path = saved_weights_path.with_suffix(".safetensors.tmp")
|
|
save_file(clean_state, str(tmp_path))
|
|
tmp_path.rename(saved_weights_path)
|
|
del clean_state
|
|
|
|
# 5. Save modelopt state
|
|
try:
|
|
unwrapped = self._accelerator.unwrap_model(self._transformer)
|
|
modelopt_state = mto.modelopt_state(unwrapped)
|
|
from modelopt.torch.quantization.utils import get_quantizer_state_dict
|
|
|
|
modelopt_state["modelopt_state_weights"] = get_quantizer_state_dict(unwrapped)
|
|
modelopt_path = save_dir / f"modelopt_state_step_{self._global_step:05d}.pth"
|
|
torch.save(modelopt_state, str(modelopt_path))
|
|
logger.info(f"Saved modelopt state to {modelopt_path}")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to save modelopt state: {e}")
|
|
|
|
self._accelerator.wait_for_everyone()
|
|
self._checkpoint_paths.append(saved_weights_path)
|
|
self._cleanup_checkpoints()
|
|
return saved_weights_path
|
|
|
|
|
|
# ─── Standalone inference checkpoint creation ─────────────────────────────────
|
|
|
|
|
|
def create_inference_checkpoint(
|
|
trained_path: str,
|
|
base_path: str,
|
|
output_path: str,
|
|
):
|
|
"""Create inference checkpoint by merging base and trained weights.
|
|
|
|
Handles both torch pickle and safetensors input formats.
|
|
Always outputs real safetensors format with atomic save.
|
|
|
|
Strategy:
|
|
1. Load trained checkpoint (any format)
|
|
2. Extract amax values, then strip teacher/loss/quantizer keys
|
|
3. Load base checkpoint, match dtypes
|
|
4. Add 'model.diffusion_model.' prefix (ComfyUI compatibility)
|
|
5. Merge: base non-transformer + base embeddings_connectors + trained transformer
|
|
6. Save as safetensors with base model metadata
|
|
"""
|
|
from safetensors.torch import save_file
|
|
|
|
trained_path = Path(trained_path)
|
|
base_path = Path(base_path)
|
|
output_path = Path(output_path)
|
|
|
|
for p, label in [(trained_path, "Trained"), (base_path, "Base")]:
|
|
if not p.exists():
|
|
print(f"ERROR: {label} checkpoint not found: {p}")
|
|
sys.exit(1)
|
|
|
|
print("\n" + "=" * 80)
|
|
print("Creating Inference Checkpoint")
|
|
print("=" * 80)
|
|
|
|
# ── Step 1: Load trained checkpoint ──
|
|
print(f"\n[1/7] Loading trained checkpoint: {trained_path}")
|
|
trained_state, _ = load_state_dict_any_format(str(trained_path), label="trained")
|
|
print(f" Loaded {len(trained_state)} keys")
|
|
|
|
# ── Step 2: Extract amax values ──
|
|
print("\n[2/7] Extracting amax values...")
|
|
amax_dict = extract_amax_values(trained_state)
|
|
if amax_dict:
|
|
amax_path = output_path.parent / (output_path.stem + "_amax.json")
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(amax_path, "w") as f:
|
|
json.dump(
|
|
{"total_amax_keys": len(amax_dict), "amax_values": amax_dict},
|
|
f,
|
|
indent=2,
|
|
sort_keys=True,
|
|
)
|
|
print(f" Saved {len(amax_dict)} amax values to: {amax_path}")
|
|
else:
|
|
print(" No amax values found")
|
|
|
|
# ── Step 3: Remove teacher / loss / quantizer keys ──
|
|
print("\n[3/7] Cleaning: removing teacher, loss, and quantizer keys...")
|
|
removal_counts = {"quantizer": 0, "teacher": 0, "loss": 0}
|
|
cleaned = {}
|
|
for k, v in trained_state.items():
|
|
reason = is_removable_key(k)
|
|
if reason:
|
|
removal_counts[reason] += 1
|
|
else:
|
|
cleaned[k] = v
|
|
del trained_state
|
|
|
|
for reason, cnt in removal_counts.items():
|
|
if cnt > 0:
|
|
print(f" Removed {cnt} {reason} keys")
|
|
print(f" Remaining: {len(cleaned)} keys")
|
|
|
|
# ── Step 4: Load base checkpoint ──
|
|
print(f"\n[4/7] Loading base checkpoint: {base_path}")
|
|
base_state, base_metadata = load_state_dict_any_format(str(base_path), label="base")
|
|
if base_metadata is None:
|
|
base_metadata = {}
|
|
print(f" Loaded {len(base_state)} keys")
|
|
|
|
# ── Step 5: Match dtypes with base ──
|
|
print("\n[5/7] Matching dtypes with base model...")
|
|
dtype_fixed = 0
|
|
dtype_mismatches = []
|
|
for k in cleaned:
|
|
base_key = f"{CORRECT_PREFIX}{k}"
|
|
if base_key in base_state:
|
|
ref_dtype = base_state[base_key].dtype
|
|
match_source = base_key
|
|
elif k in base_state:
|
|
ref_dtype = base_state[k].dtype
|
|
match_source = k
|
|
else:
|
|
ref_dtype = torch.bfloat16 if cleaned[k].dtype == torch.float32 else None
|
|
match_source = "fallback (fp32->bf16)"
|
|
|
|
if ref_dtype is not None and cleaned[k].dtype != ref_dtype:
|
|
dtype_mismatches.append(
|
|
{
|
|
"key": k,
|
|
"trained": str(cleaned[k].dtype),
|
|
"base": str(ref_dtype),
|
|
"source": match_source,
|
|
}
|
|
)
|
|
cleaned[k] = cleaned[k].to(ref_dtype)
|
|
dtype_fixed += 1
|
|
|
|
print(f" Fixed {dtype_fixed} tensor dtypes")
|
|
if dtype_mismatches:
|
|
conversion_counter = Counter()
|
|
for m in dtype_mismatches:
|
|
conversion_counter[f"{m['trained']} -> {m['base']}"] += 1
|
|
for conv, cnt in conversion_counter.most_common():
|
|
print(f" {conv}: {cnt} tensors")
|
|
|
|
dtype_log_path = output_path.parent / (output_path.stem + "_dtype_fixes.json")
|
|
with open(dtype_log_path, "w") as f:
|
|
json.dump({"total": dtype_fixed, "fixes": dtype_mismatches}, f, indent=2)
|
|
print(f" Dtype fix log saved to: {dtype_log_path}")
|
|
|
|
# ── Step 6: Add prefix ──
|
|
print(f"\n[6/7] Adding '{CORRECT_PREFIX}' prefix to transformer keys...")
|
|
prefixed = {}
|
|
stats = {"already_correct": 0, "non_transformer_skipped": 0, "fixed": 0}
|
|
|
|
for k, v in cleaned.items():
|
|
if is_non_transformer(k):
|
|
stats["non_transformer_skipped"] += 1
|
|
continue
|
|
elif k.startswith(CORRECT_PREFIX):
|
|
prefixed[k] = v
|
|
stats["already_correct"] += 1
|
|
else:
|
|
clean_k = k
|
|
for pfx in STRIP_PREFIXES:
|
|
if clean_k.startswith(pfx):
|
|
clean_k = clean_k[len(pfx) :]
|
|
break
|
|
prefixed[f"{CORRECT_PREFIX}{clean_k}"] = v
|
|
stats["fixed"] += 1
|
|
|
|
del cleaned
|
|
print(f" Already correct: {stats['already_correct']}")
|
|
print(f" Prefix added: {stats['fixed']}")
|
|
print(f" Non-transformer: {stats['non_transformer_skipped']} (skipped)")
|
|
|
|
# ── Step 7: Merge with base ──
|
|
print("\n[7/7] Merging with base model...")
|
|
|
|
base_non_transformer = {k: v for k, v in base_state.items() if is_non_transformer(k)}
|
|
base_connectors = {
|
|
k: v
|
|
for k, v in base_state.items()
|
|
if "embeddings_connector" in k and k.startswith(CORRECT_PREFIX)
|
|
}
|
|
del base_state
|
|
|
|
print(f" Base non-transformer: {len(base_non_transformer)} keys")
|
|
print(f" Base embeddings_connectors: {len(base_connectors)} keys (NOT trained)")
|
|
print(f" Trained transformer: {len(prefixed)} keys")
|
|
|
|
merged = {}
|
|
merged.update(base_non_transformer)
|
|
merged.update(base_connectors)
|
|
merged.update(prefixed)
|
|
del base_non_transformer, base_connectors, prefixed
|
|
|
|
total_params = sum(v.numel() for v in merged.values())
|
|
total_gb = sum(v.numel() * v.element_size() for v in merged.values()) / (1024**3)
|
|
print(f"\n Final: {len(merged)} keys, {total_params:,} params, {total_gb:.2f} GB")
|
|
|
|
# ── Save (atomic) ──
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp_path = output_path.with_suffix(".safetensors.tmp")
|
|
print("\n Saving as safetensors...")
|
|
save_file(merged, str(tmp_path), metadata=base_metadata)
|
|
tmp_path.rename(output_path)
|
|
|
|
file_size_gb = output_path.stat().st_size / (1024**3)
|
|
|
|
print("\n" + "=" * 80)
|
|
print("Inference Checkpoint Created!")
|
|
print("=" * 80)
|
|
print(f" Path: {output_path}")
|
|
print(" Format: safetensors")
|
|
print(f" Size: {file_size_gb:.2f} GB")
|
|
print(f" Keys: {len(merged)}")
|
|
if amax_dict:
|
|
print(f" Amax: {output_path.stem}_amax.json ({len(amax_dict)} values)")
|
|
print("=" * 80)
|
|
|
|
del merged
|
|
gc.collect()
|
|
|
|
|
|
# ─── Main ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser(
|
|
description="QAD for LTX-2 (Native Trainer + ModelOpt)",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
|
|
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
|
|
|
# ── Train command ──
|
|
train_parser = subparsers.add_parser("train", help="Run QAD training")
|
|
train_parser.add_argument(
|
|
"--config",
|
|
type=str,
|
|
required=True,
|
|
help="Path to LTX training config YAML",
|
|
)
|
|
train_parser.add_argument(
|
|
"--calib-size",
|
|
type=int,
|
|
default=512,
|
|
help="Number of calibration batches for PTQ",
|
|
)
|
|
train_parser.add_argument(
|
|
"--kd-loss-weight",
|
|
type=float,
|
|
default=0.5,
|
|
help="KD loss weight (0=pure hard loss, 1=pure KD loss)",
|
|
)
|
|
train_parser.add_argument(
|
|
"--exclude-blocks",
|
|
type=int,
|
|
nargs="*",
|
|
default=[0, 1, 46, 47],
|
|
help="Transformer block indices to exclude from quantization",
|
|
)
|
|
train_parser.add_argument(
|
|
"--skip-inference-ckpt",
|
|
action="store_true",
|
|
help="Skip creating inference checkpoint after training",
|
|
)
|
|
|
|
# ── Create inference checkpoint command ──
|
|
infer_parser = subparsers.add_parser(
|
|
"create-inference",
|
|
help="Create inference checkpoint from trained weights",
|
|
)
|
|
infer_parser.add_argument(
|
|
"--trained",
|
|
type=str,
|
|
required=True,
|
|
help="Path to trained checkpoint (any format)",
|
|
)
|
|
infer_parser.add_argument(
|
|
"--base",
|
|
type=str,
|
|
required=True,
|
|
help="Path to base model checkpoint",
|
|
)
|
|
infer_parser.add_argument(
|
|
"--output",
|
|
type=str,
|
|
required=True,
|
|
help="Output path for inference .safetensors",
|
|
)
|
|
|
|
# Backward compatibility: if no subcommand, treat as train
|
|
args, remaining = parser.parse_known_args()
|
|
if args.command is None:
|
|
if "--config" in sys.argv:
|
|
args = train_parser.parse_args(sys.argv[1:])
|
|
args.command = "train"
|
|
elif "--trained" in sys.argv:
|
|
old_parser = argparse.ArgumentParser()
|
|
old_parser.add_argument("--create-inference", action="store_true")
|
|
old_parser.add_argument("--trained-checkpoint", "--trained", type=str)
|
|
old_parser.add_argument("--base-checkpoint", "--base", type=str)
|
|
old_parser.add_argument("--output-checkpoint", "--output", type=str)
|
|
args = old_parser.parse_args()
|
|
args.command = "create-inference"
|
|
args.trained = args.trained_checkpoint
|
|
args.base = args.base_checkpoint
|
|
args.output = args.output_checkpoint
|
|
else:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
return args
|
|
|
|
|
|
def main():
|
|
# Only rank 0 gets INFO logging; other ranks get WARNING to reduce noise
|
|
log_level = logging.INFO if is_global_rank0() else logging.WARNING
|
|
logging.basicConfig(
|
|
level=log_level,
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
)
|
|
# Suppress verbose logging from ModelOpt / third-party libs on all ranks
|
|
for noisy_logger in ["modelopt", "torch.distributed", "accelerate"]:
|
|
logging.getLogger(noisy_logger).setLevel(
|
|
logging.WARNING if is_global_rank0() else logging.ERROR
|
|
)
|
|
|
|
args = parse_args()
|
|
|
|
if args.command == "create-inference":
|
|
create_inference_checkpoint(
|
|
trained_path=args.trained,
|
|
base_path=args.base,
|
|
output_path=args.output,
|
|
)
|
|
return
|
|
|
|
# ── Train ──
|
|
import yaml
|
|
|
|
with open(args.config) as f:
|
|
config_dict = yaml.safe_load(f)
|
|
|
|
# Extract QAD-specific config (not part of LtxTrainerConfig)
|
|
qad_config = config_dict.pop("qad", {})
|
|
|
|
config = LtxTrainerConfig(**config_dict)
|
|
|
|
# Resolve QAD params: CLI args override YAML values, YAML overrides defaults
|
|
calib_size = args.calib_size if args.calib_size != 512 else qad_config.get("calib_size", 512)
|
|
kd_loss_weight = (
|
|
args.kd_loss_weight if args.kd_loss_weight != 0.5 else qad_config.get("kd_loss_weight", 0.5)
|
|
)
|
|
exclude_blocks = (
|
|
args.exclude_blocks
|
|
if args.exclude_blocks != [0, 1, 46, 47]
|
|
else qad_config.get("exclude_blocks", [0, 1, 46, 47])
|
|
)
|
|
skip_inference_ckpt = args.skip_inference_ckpt or qad_config.get("skip_inference_ckpt", False)
|
|
|
|
quant_cfg = build_quant_config(exclude_blocks=exclude_blocks)
|
|
|
|
logger.info("=" * 80)
|
|
logger.info("QAD for LTX-2 (Native LTX Trainer + ModelOpt)")
|
|
logger.info("=" * 80)
|
|
logger.info(f"Config: {args.config}")
|
|
logger.info(f"Model: {config.model.model_path}")
|
|
logger.info(f"Data: {config.data.preprocessed_data_root}")
|
|
logger.info(f"Output: {config.output_dir}")
|
|
logger.info(f"Calib size: {calib_size}")
|
|
logger.info(f"KD loss weight: {kd_loss_weight}")
|
|
logger.info(f"Excluded blocks: {exclude_blocks}")
|
|
|
|
trainer = LtxvQADTrainer(
|
|
trainer_config=config,
|
|
quant_cfg=quant_cfg,
|
|
calib_size=calib_size,
|
|
kd_loss_weight=kd_loss_weight,
|
|
)
|
|
|
|
saved_path, stats = trainer.train()
|
|
|
|
logger.info(f"Training complete! Checkpoint: {saved_path}")
|
|
logger.info(
|
|
f"Steps/sec: {stats.steps_per_second:.2f}, Peak GPU: {stats.peak_gpu_memory_gb:.2f} GB"
|
|
)
|
|
|
|
if not skip_inference_ckpt and is_global_rank0() and saved_path is not None:
|
|
create_inference_checkpoint(
|
|
trained_path=str(saved_path),
|
|
base_path=config.model.model_path,
|
|
output_path=str(Path(config.output_dir) / "ltx2_qad_inference.safetensors"),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|