Compare commits

...
Author SHA1 Message Date
SolitaryThinker 5e72bfd97d [test] Run LTX-2 attention QAT nightly on GB200 2026-07-25 17:41:10 -07:00
SolitaryThinker a8ed7aa282 [test] Verify LTX-2 attention QAT on GB200 2026-07-25 17:41:10 -07:00
SolitaryThinker cc3e57e809 [feat] Train and validate LTX-2 with attention QAT 2026-07-25 17:41:10 -07:00
SolitaryThinker 5bbbaa92f9 [docs] Pin the NVFP4 FA4 dependency stack 2026-07-25 17:41:10 -07:00
SolitaryThinker 137b089e74 [feat] Validate LTX-2 QAT with NVFP4 FA4 2026-07-25 17:41:10 -07:00
SolitaryThinker 233c75a7f1 [test] Seed LTX-2 NVFP4 QAT GB200 gradient norm
Record 0.3661 from a single-GB200 forward/backward using the corrected 576-linear deployment target profile.
2026-07-25 17:41:10 -07:00
SolitaryThinker b6b0f042a7 [test] Fix LTX-2 loader monkeypatch target 2026-07-25 17:41:10 -07:00
SolitaryThinker 5651f57a74 [bugfix] Align LTX-2 QAT with the NVFP4 deployment profile
Reuse the curated LTX-2 NVFP4 linear target set for QAT while preserving the existing Wan-style profile. This changes LTX-2 attachment from 1,344 broad substring matches to the 576 linears used by deployment.

Forward the role-local attention backend through LTX2Model so transformer construction remains compatible with the model-loader API merged in #1619. Add CPU parser/export reconstruction and target-profile coverage, hardware gates, and update the documented YAML surface.
2026-07-25 17:41:10 -07:00
Will Lin 43f667dd4c [feat] Add NVFP4 QAT support to modular trainer for LTX-2 fine-tuning
Resolve string pipeline.dit_config.quant_config values (e.g.
nvfp4_qat_train) into registered QuantizationConfig instances during
config parsing. The resolver lives in _parse_pipeline_config so it
covers training, resume, and dcp_to_diffusers export (which rebuilds
from checkpoint metadata and bypasses load_run_config).

nvfp4_qat_train runs attention/FFN linear forwards through real NVFP4
GEMMs (flashinfer cutlass) with a straight-through-estimator backward
into dense fp32 master weights; no extra params or buffers, so FSDP2
sharding, DCP checkpoints, and export are unchanged. Attention stays
dense, matching LTX-2 NVFP4 deployment.

Add an NVFP4 QAT overfit config, a QAT smoke test asserting the quant
method attaches to all 1344 matched linears (GB200 grad-norm ref
seeded), and an ltx2_nvfp4_qat nightly e2e case.

Verified on 4x GB200: 300-step cat-sample overfit, ~2x dense step
time, train loss -> 0.022, validation mean MS-SSIM vs the training
clip 0.14 (step 0) -> 0.955 (step 300, nightly e2e PASSED; bf16 dense
twin reaches 0.964).
2026-07-25 17:41:10 -07:00
20 changed files with 603 additions and 54 deletions
+19
View File
@@ -212,6 +212,25 @@ pipeline:
flow_shift: 8
```
Registered transformer linear-quantization configs can also be selected by
name. For example, the LTX-2 NVFP4-QAT recipe applies real FP4 forward GEMMs
with a straight-through-estimator backward to its deployment-targeted
attention/FFN projections:
```yaml
pipeline:
dit_config:
quant_config: nvfp4_qat_train
```
The LTX-2 recipe in
`examples/train/configs/overfit_ltx2_t2v_nvfp4_qat.yaml` combines that linear
configuration with `models.student.attention_backend: ATTN_QAT_TRAIN` for
video-attention forward/backward. On sm120, its validation callback temporarily
switches those layers to `ATTN_QAT_INFER`.
On GB200, set `callbacks.validation.attn_qat_infer: false` to keep validation on
the train-time QAT backend; the inference kernel is sm120-only.
---
## Training Methods
+4
View File
@@ -195,6 +195,10 @@ callbacks:
# ------------------------------------------------------------------------------
pipeline:
flow_shift: 3 # default: null (model-specific)
# LTX-2 linear QAT (SM100+ with FlashInfer); distinct from the role-local
# attention_backend option above, which selects attention math for Wan.
# dit_config:
# quant_config: nvfp4_qat_train
# flow_shift_sr: null # default: null (super-resolution shift)
# embedded_cfg_scale: 6.0 # default: 6.0
# is_causal: false # default: false
@@ -0,0 +1,111 @@
# LTX-2 T2V overfitting test config with NVFP4 quantization-aware
# training (QAT).
#
# Same recipe as overfit_ltx2_t2v.yaml, but the curated attention/FFN
# linear set used by LTX-2 NVFP4 deployment runs its forward through
# real NVFP4 GEMMs (flashinfer cutlass) with a straight-through-estimator
# backward into the dense fp32 master weights. No extra parameters or
# buffers are added, so FSDP sharding, checkpointing, and export are
# identical to dense training. Video attention uses ATTN_QAT_TRAIN for its
# quantized forward and STE backward, then ATTN_QAT_INFER during validation.
# Head-dim-64 audio attention and masked text attention remain dense.
#
# Validation requires an sm_120 GPU with the attn_qat_infer extension.
#
# Preprocess data first (same data as the bf16 overfit):
# CUDA_VISIBLE_DEVICES=0 python fastvideo/pipelines/preprocess/preprocess_ltx2_overfit.py
#
# Run on multi-GPU sm_120 hardware:
# NUM_GPUS=4 \
# bash examples/train/run.sh examples/train/configs/overfit_ltx2_t2v_nvfp4_qat.yaml
#
# GB200 can train and validate with ATTN_QAT_TRAIN, but cannot load the
# sm_120-only inference kernel. Disable only the validation-time swap:
# NUM_GPUS=4 \
# bash examples/train/run.sh examples/train/configs/overfit_ltx2_t2v_nvfp4_qat.yaml \
# --callbacks.validation.attn_qat_infer false
models:
student:
_target_: fastvideo.train.models.ltx2.LTX2Model
init_from: FastVideo/LTX2-Distilled-Diffusers
trainable: true
enable_gradient_checkpointing_type: full
attention_backend: ATTN_QAT_TRAIN
method:
_target_: fastvideo.train.methods.fine_tuning.finetune.FineTuneMethod
training:
distributed:
num_gpus: 4
sp_size: 1
tp_size: 1
hsdp_replicate_dim: 1
hsdp_shard_dim: 4
data:
data_path: data/ltx2_overfit_preprocessed
dataloader_num_workers: 0
train_batch_size: 1
# LTX2Model requires 0.0: CFG dropout would zero post-connector
# embeddings, which is not the model's unconditional input.
training_cfg_rate: 0.0
seed: 42
num_latent_t: 11 # (81 - 1) / 8 + 1
num_height: 480
num_width: 832
num_frames: 81
optimizer:
learning_rate: 5.0e-5
betas: [0.9, 0.999]
weight_decay: 0.0
lr_scheduler: constant
lr_warmup_steps: 0
loop:
max_train_steps: 300
gradient_accumulation_steps: 1
checkpoint:
output_dir: outputs/ltx2_overfit_nvfp4_attn_qat
# A full training-state checkpoint is ~150GB for the 13B trainable
# video branch; disable saves for the overfit smoke run.
training_state_checkpointing_steps: 0
checkpoints_total_limit: 1
tracker:
trackers: [wandb]
project_name: fastvideo_ltx2
run_name: ltx2_overfit_nvfp4_attn_qat
model:
# LTX2Model.predict_noise converts the DiT's x0 output to velocity,
# so the default noise-minus-clean target reproduces the official
# unweighted masked-MSE (mask is all-ones for plain T2V).
precondition_outputs: false
enable_gradient_checkpointing_type: full
callbacks:
grad_clip:
_target_: fastvideo.train.callbacks.grad_clip.GradNormClipCallback
max_grad_norm: 1.0
validation:
_target_: fastvideo.train.callbacks.validation.ValidationCallback
pipeline_target: fastvideo.pipelines.basic.ltx2.ltx2_pipeline.LTX2Pipeline
dataset_file: data/ltx2_overfit_preprocessed/validation_prompts.json
every_steps: 50
sampling_steps: [8]
guidance_scale: 1.0
num_frames: 81
# Validation reuses the live transformer and temporarily replaces its
# ATTN_QAT_TRAIN implementations with the sm120 inference kernel.
attn_qat_infer: true
# quant_config selects NVFP4 QAT for the same curated linear prefixes as
# LTX-2 NVFP4 deployment. They fake-quantize through real FP4 GEMMs with
# an STE backward. The string resolves to NVFP4QATTrainConfig at parse.
pipeline:
dit_config:
quant_config: nvfp4_qat_train
@@ -51,7 +51,8 @@ def _get_attn_qat_infer() -> Callable[..., torch.Tensor] | None:
def is_attn_qat_infer_available() -> bool:
return _get_attn_qat_infer() is not None
return (torch.cuda.is_available() and torch.cuda.get_device_capability() == (12, 0)
and _get_attn_qat_infer() is not None)
class AttnQatInferBackend(AttentionBackend):
+4 -3
View File
@@ -150,14 +150,15 @@ def _cached_get_attn_backend(
from fastvideo.platforms import current_platform
if (selected_backend is not None and selected_backend not in supported_attention_backends):
fallback_backend = (default_backend if default_backend in supported_attention_backends else None)
logger.warning(
"Requested attention backend %s is not supported by this "
"layer; supported backends are %s. Falling back to automatic "
"selection.",
"layer; supported backends are %s. Falling back to %s.",
selected_backend.name,
[b.name for b in supported_attention_backends],
fallback_backend.name if fallback_backend is not None else "automatic selection",
)
selected_backend = None
selected_backend = fallback_backend
attention_cls = current_platform.get_attn_backend_cls(selected_backend, head_size, dtype)
if not attention_cls:
raise ValueError(f"Invalid attention backend for {current_platform.device_name}")
+25 -18
View File
@@ -58,6 +58,29 @@ _LTX2_REFINE_ONLY_SUFFIXES = (
".video_to_audio_attn.to_v",
)
_LTX2_NVFP4_BLOCK_LINEAR_SUFFIXES = (
"attn1.to_q",
"attn1.to_k",
"attn1.to_v",
"attn1.to_out",
"attn2.to_q",
"attn2.to_out",
"audio_to_video_attn.to_q",
"audio_to_video_attn.to_out",
"video_to_audio_attn.to_k",
"video_to_audio_attn.to_v",
"ffn.fc_in",
"ffn.fc_out",
)
_LTX2_NVFP4_LINEAR_PREFIXES = frozenset(f"ltx2.blocks.{block_idx}.{suffix}" for block_idx in range(48)
for suffix in _LTX2_NVFP4_BLOCK_LINEAR_SUFFIXES) | frozenset(
("ltx2.adaln_single.linear", ))
def is_ltx2_nvfp4_linear_prefix(prefix: str) -> bool:
"""Return whether *prefix* belongs to the LTX-2 NVFP4 deployment set."""
return prefix in _LTX2_NVFP4_LINEAR_PREFIXES
def _is_ltx2_refine_only_prefix(prefix: str) -> bool:
return any(prefix.endswith(suffix) for suffix in _LTX2_REFINE_ONLY_SUFFIXES)
@@ -422,24 +445,7 @@ class NVFP4Config(QuantizationConfig):
# Use the superset at build/load time, then switch active subset
# dynamically in NVFP4QuantizeMethod.apply based on stage profile.
fp4_layers = [[
f"ltx2.blocks.{i}.attn1.to_q",
f"ltx2.blocks.{i}.attn1.to_k",
f"ltx2.blocks.{i}.attn1.to_v",
f"ltx2.blocks.{i}.attn1.to_out",
f"ltx2.blocks.{i}.attn2.to_q",
f"ltx2.blocks.{i}.attn2.to_out",
f"ltx2.blocks.{i}.audio_to_video_attn.to_q",
f"ltx2.blocks.{i}.audio_to_video_attn.to_out",
f"ltx2.blocks.{i}.video_to_audio_attn.to_k",
f"ltx2.blocks.{i}.video_to_audio_attn.to_v",
f"ltx2.blocks.{i}.ffn.fc_in",
f"ltx2.blocks.{i}.ffn.fc_out",
] for i in range(48)]
fp4_layers.append([
"ltx2.adaln_single.linear",
])
if isinstance(layer, LinearBase) and any(prefix in layer_names for layer_names in fp4_layers):
if isinstance(layer, LinearBase) and is_ltx2_nvfp4_linear_prefix(prefix):
return NVFP4QuantizeMethod(layer_prefix=prefix)
return None
@@ -485,4 +491,5 @@ __all__ = [
"NVFP4Config",
"NVFP4QuantizeMethod",
"convert_model_to_nvfp4",
"is_ltx2_nvfp4_linear_prefix",
]
@@ -9,10 +9,11 @@ straight-through estimator), so the model learns to absorb FP4 linear error.
That STE already exists in ``fastvideo.layers.fp4linear._LinearFWD4BWD16Fn``
(FP4 forward, full-precision backward) but is otherwise unwired. This method
bridges it into the standard ``quant_config`` path, so it activates via
``transformer_quant="nvfp4_qat_train"`` on the same Wan-2.1 layers as nvfp4_qat
(to_q/k/v/out + ffn). No ``convert_model_to_fp4`` is needed: the weight is kept
in full precision and quantized on the fly each step.
bridges it into the standard ``quant_config`` path. Wan-style prefixes retain
the broad attention/FFN projection profile used by ``nvfp4_qat``; LTX-2
prefixes use the exact curated target set from its ``NVFP4`` deployment config.
No ``convert_model_to_fp4`` is needed: the weight is kept in full precision and
quantized on the fly each step.
"""
import logging
from typing import Any
@@ -21,6 +22,8 @@ import torch
from torch.nn.parameter import Parameter
from fastvideo.layers.quantization.base_config import QuantizationConfig, QuantizeMethodBase
from fastvideo.layers.quantization.nvfp4_config import is_ltx2_nvfp4_linear_prefix
from fastvideo.layers.quantization.nvfp4_qat_config import DEFAULT_FP4_LAYERS
from fastvideo.models.utils import set_weight_attrs
logger = logging.getLogger(__name__)
@@ -72,7 +75,10 @@ class NVFP4QATTrainConfig(QuantizationConfig):
def get_quant_method(self, layer: torch.nn.Module, prefix: str):
from fastvideo.layers.linear import LinearBase
fp4_layers = ["ffn.fc_in", "ffn.fc_out", "to_q", "to_k", "to_v", "to_out"]
if isinstance(layer, LinearBase) and any(layer_name in prefix for layer_name in fp4_layers):
if not isinstance(layer, LinearBase):
return None
matches = (is_ltx2_nvfp4_linear_prefix(prefix) if prefix.startswith("ltx2.") else any(
layer_name in prefix for layer_name in DEFAULT_FP4_LAYERS))
if matches:
return NVFP4QATTrainQuantizeMethod()
return None
+12 -8
View File
@@ -1571,6 +1571,7 @@ class LTXSelfAttention(nn.Module):
softmax_scale=None,
causal=False,
supported_attention_backends=(AttentionBackendEnum.TORCH_SDPA,),
default_backend=AttentionBackendEnum.TORCH_SDPA,
)
def forward(
@@ -1853,12 +1854,15 @@ class BasicAVTransformerBlock(torch.nn.Module):
# Text cross-attention uses LocalAttention (text embeddings are replicated)
SelfAttnCls = LTXDistributedSelfAttention if use_distributed_attention else LTXSelfAttention
CrossAttnCls = LTXSelfAttention # Text cross-attention is always local
video_self_attn_backends = (
video_attn_backends = (
AttentionBackendEnum.FLASH_ATTN,
AttentionBackendEnum.TORCH_SDPA,
AttentionBackendEnum.VIDEO_SPARSE_ATTN,
AttentionBackendEnum.ATTN_QAT_INFER,
AttentionBackendEnum.ATTN_QAT_TRAIN,
)
dense_attn_backends = (
video_self_attn_backends = video_attn_backends + (
AttentionBackendEnum.VIDEO_SPARSE_ATTN, )
audio_attn_backends = (
AttentionBackendEnum.FLASH_ATTN,
AttentionBackendEnum.TORCH_SDPA,
)
@@ -1885,7 +1889,7 @@ class BasicAVTransformerBlock(torch.nn.Module):
dim_head=video.d_head,
norm_eps=norm_eps,
rope_type=rope_type,
supported_attention_backends=dense_attn_backends,
supported_attention_backends=video_attn_backends,
apply_gated_attention=video.apply_gated_attention,
quant_config=quant_config,
prefix=f"{prefix}.blocks.{idx}.attn2",
@@ -1911,7 +1915,7 @@ class BasicAVTransformerBlock(torch.nn.Module):
dim_head=audio.d_head,
norm_eps=norm_eps,
rope_type=rope_type,
supported_attention_backends=dense_attn_backends,
supported_attention_backends=audio_attn_backends,
apply_gated_attention=audio.apply_gated_attention,
quant_config=quant_config,
prefix=f"{prefix}.blocks.{idx}.audio_attn1",
@@ -1924,7 +1928,7 @@ class BasicAVTransformerBlock(torch.nn.Module):
dim_head=audio.d_head,
norm_eps=norm_eps,
rope_type=rope_type,
supported_attention_backends=dense_attn_backends,
supported_attention_backends=audio_attn_backends,
apply_gated_attention=audio.apply_gated_attention,
quant_config=quant_config,
prefix=f"{prefix}.blocks.{idx}.audio_attn2",
@@ -1949,7 +1953,7 @@ class BasicAVTransformerBlock(torch.nn.Module):
dim_head=audio.d_head,
norm_eps=norm_eps,
rope_type=rope_type,
supported_attention_backends=dense_attn_backends,
supported_attention_backends=audio_attn_backends,
apply_gated_attention=video.apply_gated_attention,
quant_config=quant_config,
prefix=f"{prefix}.blocks.{idx}.audio_to_video_attn",
@@ -1963,7 +1967,7 @@ class BasicAVTransformerBlock(torch.nn.Module):
dim_head=audio.d_head,
norm_eps=norm_eps,
rope_type=rope_type,
supported_attention_backends=dense_attn_backends,
supported_attention_backends=audio_attn_backends,
apply_gated_attention=audio.apply_gated_attention,
quant_config=quant_config,
prefix=f"{prefix}.blocks.{idx}.video_to_audio_attn",
@@ -54,6 +54,24 @@ def test_role_override_invalidates_cached_backend(monkeypatch) -> None:
selector.global_force_attn_backend(None)
def test_unsupported_role_override_honors_layer_default(monkeypatch) -> None:
monkeypatch.setattr(platforms, "_current_platform", _FakePlatform())
monkeypatch.setattr(selector, "resolve_obj_by_qualname", lambda name: name)
selector.global_force_attn_backend(None)
try:
with selector.global_force_attn_backend_context_manager(
AttentionBackendEnum.ATTN_QAT_TRAIN):
assert selector.get_attn_backend(
head_size=128,
dtype=torch.bfloat16,
supported_attention_backends=(AttentionBackendEnum.TORCH_SDPA, ),
default_backend=AttentionBackendEnum.TORCH_SDPA,
) == "TORCH_SDPA"
finally:
selector.global_force_attn_backend(None)
def test_explicit_backend_config_rejects_typos() -> None:
try:
selector.coerce_attn_backend("attn_qat_typo")
@@ -1,17 +1,18 @@
"""E2E overfit tests for LTX-2 fine-tuning on the modular trainer (fastvideo/train).
Parametrized over LTX-2.0 and LTX-2.3 distilled checkpoints. Each case
downloads the single-sample cats dataset, preprocesses it with the
matching LTX-2 VAE + Gemma text encoder into the t2v parquet format,
trains LTX2Model with FineTuneMethod for 300 steps on 4 GPUs, and
checks that the final validation video reproduces the training clip
(MS-SSIM against the preprocessed ground-truth clip) better than the
step-0 baseline.
Parametrized over dense LTX-2.0 and LTX-2.3 distilled checkpoints plus
an LTX-2.0 NVFP4 linear-QAT case. Each case downloads the single-sample
cats dataset, preprocesses it with the matching LTX-2 VAE + Gemma text
encoder into the t2v parquet format, trains LTX2Model with FineTuneMethod
for 300 steps on 4 GPUs, and checks that the final validation video
reproduces the training clip (MS-SSIM against the preprocessed ground-truth
clip) better than the step-0 baseline.
GPU assumptions: 4 x large-memory GPUs (developed on 4x GB200 192GB;
the 18.9B-param DiT trains FSDP-sharded with fp32 master weights).
Requires the FastVideo LTX-2 distilled checkpoints (set HF_HOME to a
cache that contains them, or allow ~60GB of downloads per version).
The NVFP4-QAT case additionally requires FlashInfer FP4 support and SM100+.
Guarded by FASTVIDEO_NIGHTLY=1 so the default test suite stays fast.
"""
@@ -25,6 +26,7 @@ import sys
from pathlib import Path
import pytest
import torch
from huggingface_hub import snapshot_download
@@ -52,6 +54,16 @@ _CASES = {
"prep_dir": Path(DATA_DIR) / "ltx2_3_overfit_preprocessed",
"out_dir": Path(DATA_DIR) / "outputs_ltx2_3_overfit",
},
# NVFP4 quantization-aware training: same LTX-2.0 data, attention/FFN
# linears forward through real FP4 GEMMs with an STE backward.
# Requires flashinfer FP4 kernels (sm_100-class GPU).
"ltx2_nvfp4_qat": {
"model": "FastVideo/LTX2-Distilled-Diffusers",
"config": "examples/train/configs/overfit_ltx2_t2v_nvfp4_qat.yaml",
"prep_dir": Path(DATA_DIR) / "ltx2_overfit_preprocessed",
"out_dir": Path(DATA_DIR) / "outputs_ltx2_nvfp4_qat_overfit",
"requires_nvfp4": True,
},
}
@@ -93,10 +105,26 @@ def run_training(case: dict):
"--callbacks.validation.dataset_file",
str(case["prep_dir"] / "validation_prompts.json"),
]
if (case.get("requires_nvfp4")
and torch.cuda.get_device_capability(0) != (12, 0)):
cmd.extend(["--callbacks.validation.attn_qat_infer", "false"])
print(f"Running training: {cmd}")
subprocess.run(cmd, check=True, env=env)
def test_run_training_disables_sm120_only_validation_on_gb200(monkeypatch):
commands = []
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda _: (10, 0))
monkeypatch.setattr(subprocess, "run",
lambda cmd, **_: commands.append(cmd))
run_training(_CASES["ltx2_nvfp4_qat"])
assert commands[0][-2:] == [
"--callbacks.validation.attn_qat_infer", "false"
]
def _validation_videos_by_step(out_dir: Path) -> dict[int, str]:
pattern = os.path.join(str(out_dir), "validation_step_*_video_0.mp4")
videos: dict[int, str] = {}
@@ -114,6 +142,18 @@ def _validation_videos_by_step(out_dir: Path) -> dict[int, str]:
@pytest.mark.parametrize("case_id", _CASES.keys())
def test_e2e_ltx2_overfit_new_stack(case_id: str):
case = _CASES[case_id]
if case.get("requires_nvfp4"):
if (not torch.cuda.is_available() or
torch.cuda.get_device_capability(0) < (10, 0)):
pytest.skip("NVFP4 QAT requires an SM100+ GPU")
try:
from flashinfer import ( # noqa: F401
SfLayout,
mm_fp4,
nvfp4_quantize,
)
except ImportError:
pytest.skip("NVFP4 QAT requires flashinfer with FP4 kernels")
download_data()
run_preprocessing(case)
run_training(case)
@@ -18,6 +18,10 @@ from fastvideo.layers.quantization.nvfp4_config import (
NVFP4Config,
NVFP4QuantizeMethod,
)
from fastvideo.layers.quantization.nvfp4_qat_train_config import (
NVFP4QATTrainConfig,
NVFP4QATTrainQuantizeMethod,
)
from fastvideo.models.dits.ltx2 import (
BasicAVTransformerBlock,
FeedForward,
@@ -183,3 +187,46 @@ def test_basic_av_block_propagates_quant_config_to_all_children() -> None:
for linear in unquantized_projections:
assert isinstance(linear, ReplicatedLinear)
assert isinstance(linear.quant_method, UnquantizedLinearMethod)
def test_qat_train_uses_the_ltx2_deployment_target_profile() -> None:
kwargs = {
"idx": 3,
"video": TransformerConfig(dim=64, heads=2, d_head=32, context_dim=128),
"audio": TransformerConfig(dim=64, heads=2, d_head=32, context_dim=128),
"rope_type": LTXRopeType.INTERLEAVED,
"norm_eps": 1e-6,
"use_distributed_attention": False,
"prefix": "ltx2",
}
deployed = BasicAVTransformerBlock(
**kwargs,
quant_config=NVFP4Config(),
)
qat = BasicAVTransformerBlock(
**kwargs,
quant_config=NVFP4QATTrainConfig(),
)
deployed_names = {
name for name, module in deployed.named_modules()
if isinstance(getattr(module, "quant_method", None),
NVFP4QuantizeMethod)
}
qat_names = {
name for name, module in qat.named_modules()
if isinstance(getattr(module, "quant_method", None),
NVFP4QATTrainQuantizeMethod)
}
assert qat_names == deployed_names
assert len(qat_names) == 12
def test_qat_train_preserves_wan_style_targeting() -> None:
linear = ReplicatedLinear(
16,
16,
quant_config=NVFP4QATTrainConfig(),
prefix="blocks.0.attn1.to_k",
)
assert isinstance(linear.quant_method, NVFP4QATTrainQuantizeMethod)
@@ -82,6 +82,7 @@ class TestConstructor:
assert cb.overlay_actions is False
assert cb.offload_training_state is False
assert cb.unload_pipeline_after_validation is False
assert cb.attn_qat_infer is False
# Lazy fields not yet populated.
assert cb._pipeline is None
assert cb._sampling_param is None
@@ -102,6 +103,7 @@ class TestConstructor:
overlay_actions=1, # type: ignore[arg-type]
offload_training_state="1", # type: ignore[arg-type]
unload_pipeline_after_validation="false", # type: ignore[arg-type]
attn_qat_infer="true", # type: ignore[arg-type]
)
assert cb.every_steps == 50
assert cb.sampling_steps == [20, 40]
@@ -111,6 +113,7 @@ class TestConstructor:
assert cb.overlay_actions is True
assert cb.offload_training_state is True
assert cb.unload_pipeline_after_validation is False
assert cb.attn_qat_infer is True
def test_pipeline_kwargs_collected(self) -> None:
cb = ValidationCallback(
@@ -219,6 +222,57 @@ class TestOnValidationBegin:
assert cb.run_calls == [0]
class TestAttnQatInferValidation:
def test_context_swaps_and_restores_qat_attention(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from fastvideo.attention.backends import attn_qat_infer
from fastvideo.attention.backends.attn_qat_infer import (
AttnQatInferImpl, )
from fastvideo.attention.backends.attn_qat_train import (
AttnQatTrainImpl, )
from fastvideo.platforms import AttentionBackendEnum
monkeypatch.setattr(attn_qat_infer, "is_attn_qat_infer_available", lambda: True)
impl = AttnQatTrainImpl(
num_heads=1,
head_size=128,
causal=False,
softmax_scale=128**-0.5,
)
holder = torch.nn.Module()
holder.attn_impl = impl
holder.num_heads = 1
holder.head_size = 128
holder.num_kv_heads = 1
holder.softmax_scale = 128**-0.5
holder.backend = AttentionBackendEnum.ATTN_QAT_TRAIN
transformer = torch.nn.Module()
transformer.add_module("attention", holder)
cb = _make_callback()
cb.attn_qat_infer = True
with pytest.raises(RuntimeError, match="stop"):
with cb._attn_qat_infer_context(transformer):
assert isinstance(holder.attn_impl, AttnQatInferImpl)
assert holder.backend is AttentionBackendEnum.ATTN_QAT_INFER
raise RuntimeError("stop")
assert holder.attn_impl is impl
assert holder.backend is AttentionBackendEnum.ATTN_QAT_TRAIN
def test_context_requires_qat_training_attention(self) -> None:
cb = _make_callback()
cb.attn_qat_infer = True
with pytest.raises(RuntimeError, match="no ATTN_QAT_TRAIN layers"):
with cb._attn_qat_infer_context(torch.nn.Linear(1, 1)):
pass
# ---------------------------------------------------------------------------
# C. _find_ema_callback
# ---------------------------------------------------------------------------
@@ -0,0 +1,51 @@
# Minimum config to run a single NVFP4-QAT training step of
# FineTuneMethod on LTX2Model for the per-method smoke test. Identical
# to ltx2_t2v_finetune_min.yaml except pipeline.dit_config.quant_config
# selects the LTX-2 deployment-matched NVFP4 QAT-train profile (STE
# through real FP4 GEMMs). Requires flashinfer FP4 kernels (sm_100-class GPU); the test
# additionally skips below 60GB GPU memory.
models:
student:
_target_: fastvideo.train.models.ltx2.LTX2Model
init_from: FastVideo/LTX2-Distilled-Diffusers
trainable: true
attention_backend: ATTN_QAT_TRAIN
method:
_target_: fastvideo.train.methods.fine_tuning.finetune.FineTuneMethod
training:
dit_precision: bf16
distributed:
num_gpus: 1
sp_size: 1
tp_size: 1
data:
seed: 42
train_batch_size: 1
training_cfg_rate: 0.0
num_latent_t: 3
num_height: 256
num_width: 256
num_frames: 17
optimizer:
learning_rate: 1.0e-6
betas: [0.9, 0.999]
weight_decay: 0.01
lr_scheduler: constant
lr_warmup_steps: 0
loop:
max_train_steps: 1
gradient_accumulation_steps: 1
model:
precondition_outputs: false
pipeline:
dit_config:
quant_config: nvfp4_qat_train
@@ -9,6 +9,9 @@
"test_ltx2_finetune": {
"GB200": 0.3127
},
"test_ltx2_nvfp4_qat_finetune": {
"GB200": 0.2421
},
"test_matrixgame2_finetune": {
"H200": 4.8247
},
@@ -2,12 +2,12 @@
"""Per-method GPU smoke test: ``LTX2Model`` + ``FineTuneMethod``.
Mirrors ``test_wan_finetune.py`` for the LTX-2 plugin, parametrized
over LTX-2.0 and LTX-2.3 checkpoints. LTX2-specific differences: the
synthetic ``raw_batch`` carries a single post-connector Gemma
embedding (3840-d for 2.0, 4096-d for 2.3 which has no in-DiT caption
projection) and 128-channel VAE latents; the DiT is 18.9B params, so
the test skips on GPUs with less than 60GB memory (e.g. the L40S CI
runner).
over dense LTX-2.0/LTX-2.3 and LTX-2.0 NVFP4-QAT checkpoints.
LTX2-specific differences: the synthetic ``raw_batch`` carries a
single post-connector Gemma embedding (3840-d for 2.0, 4096-d for 2.3
which has no in-DiT caption projection) and 128-channel VAE latents;
the DiT is 18.9B params, so the test skips on GPUs with less than 60GB
memory (e.g. the L40S CI runner).
"""
from __future__ import annotations
@@ -34,10 +34,17 @@ from .grad_norm_regression import (
_FIXTURE_DIR = Path(__file__).resolve().parent.parent / "fixtures"
# id -> (fixture, post-connector text embedding width, grad-norm ref name)
# id -> (fixture, post-connector text embedding width, grad-norm ref name,
# expected NVFP4-QAT attachment count or None for dense training)
_CASES = {
"ltx2": ("ltx2_t2v_finetune_min.yaml", 3840, "test_ltx2_finetune"),
"ltx2_3": ("ltx2_3_t2v_finetune_min.yaml", 4096, "test_ltx2_3_finetune"),
"ltx2": ("ltx2_t2v_finetune_min.yaml", 3840, "test_ltx2_finetune", None),
"ltx2_3": ("ltx2_3_t2v_finetune_min.yaml", 4096, "test_ltx2_3_finetune", None),
"ltx2_nvfp4_qat": (
"ltx2_t2v_qat_finetune_min.yaml",
3840,
"test_ltx2_nvfp4_qat_finetune",
576,
),
}
_LTX2_TEXT_LEN = 1024
@@ -52,6 +59,18 @@ def _gpu_too_small() -> bool:
return total < _MIN_GPU_MEMORY_GB * 1024**3
def _flashinfer_fp4_available() -> bool:
try:
from flashinfer import ( # noqa: F401
SfLayout,
mm_fp4,
nvfp4_quantize,
)
except ImportError:
return False
return True
def _build_synthetic_batch(
device: torch.device,
dtype: torch.dtype,
@@ -85,8 +104,21 @@ def test_ltx2_finetune_single_train_step(
pytest.skip(f"requires a CUDA GPU with >= {_MIN_GPU_MEMORY_GB}GB "
"memory (LTX-2 DiT is 18.9B params)")
fixture_name, text_dim, ref_name = _CASES[case]
fixture_name, text_dim, ref_name, expected_qat_linears = _CASES[case]
if expected_qat_linears is not None:
if torch.cuda.get_device_capability(0) < (10, 0):
pytest.skip("NVFP4 QAT requires an SM100+ GPU")
if not _flashinfer_fp4_available():
pytest.skip("requires flashinfer with FP4 kernels")
cfg = load_run_config(str(_FIXTURE_DIR / fixture_name))
if expected_qat_linears is not None:
from fastvideo.layers.quantization.nvfp4_qat_train_config import (
NVFP4QATTrainConfig, )
assert isinstance(
cfg.training.pipeline_config.dit_config.quant_config,
NVFP4QATTrainConfig,
)
device = torch.device("cuda:0")
dtype = torch.bfloat16
@@ -105,9 +137,30 @@ def test_ltx2_finetune_single_train_step(
init_from=cfg.models["student"]["init_from"],
training_config=cfg.training,
trainable=True,
attention_backend=cfg.models["student"].get("attention_backend"),
)
model.transformer = model.transformer.to(device=device, dtype=dtype)
if expected_qat_linears is not None:
from fastvideo.layers.quantization.nvfp4_qat_train_config import (
NVFP4QATTrainQuantizeMethod, )
quantized = sum(
isinstance(getattr(module, "quant_method", None),
NVFP4QATTrainQuantizeMethod)
for module in model.transformer.modules())
assert quantized == expected_qat_linears, (
f"expected {expected_qat_linears} deployment-matched NVFP4-QAT "
f"linears on the LTX-2 DiT, found {quantized}")
from fastvideo.attention.backends.attn_qat_train import (
AttnQatTrainImpl, )
qat_attention = sum(
isinstance(getattr(module, "attn_impl", None), AttnQatTrainImpl)
for module in model.transformer.modules())
assert qat_attention == 96, (
"expected ATTN_QAT_TRAIN on attn1 and attn2 in all 48 LTX-2 "
f"blocks, found {qat_attention} implementations")
method = FineTuneMethod(
cfg=cfg,
role_models={"student": model},
@@ -34,6 +34,7 @@ from fastvideo.forward_context import (
set_forward_context,
)
from fastvideo.pipelines import ForwardBatch
from fastvideo.platforms import AttentionBackendEnum
from fastvideo.train.models.ltx2 import LTX2Model
from fastvideo.train.utils.config import load_run_config
@@ -109,6 +110,31 @@ def test_ltx2_rejects_audio_training_before_loading(
)
def test_ltx2_forwards_role_attention_backend_to_loader(
monkeypatch: pytest.MonkeyPatch) -> None:
cfg = load_run_config(str(_FIXTURE_DIR / _CASES["ltx2"][0]))
transformer = _TinyLTX2Transformer(is_ltx2_3=False)
captured: list[AttentionBackendEnum | None] = []
def fake_load_module_from_path(**kwargs):
captured.append(kwargs.get("attention_backend"))
return transformer
monkeypatch.setattr(
"fastvideo.train.models.ltx2.ltx2.load_module_from_path",
fake_load_module_from_path,
)
model = LTX2Model(
init_from=cfg.models["student"]["init_from"],
training_config=cfg.training,
trainable=False,
attention_backend="TORCH_SDPA",
)
assert model.attention_backend is AttentionBackendEnum.TORCH_SDPA
assert captured == [AttentionBackendEnum.TORCH_SDPA]
@pytest.mark.parametrize("case", _CASES.keys())
def test_ltx2_wrapper_contract_runs_on_cpu(
case: str, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -245,6 +245,46 @@ def test_betas_parses_list_and_string_forms(
assert cfg.training.optimizer.betas == expected
def test_pipeline_quant_config_resolves_for_load_and_export(tmp_path: Path) -> None:
from fastvideo.layers.quantization.nvfp4_qat_train_config import (
NVFP4QATTrainConfig, )
from fastvideo.train.entrypoint.dcp_to_diffusers import (
_run_config_from_raw, )
data = _minimal_yaml()
data["models"]["student"]["init_from"] = (
"FastVideo/LTX2-Distilled-Diffusers")
data["pipeline"] = {
"dit_config": {
"quant_config": "nvfp4_qat_train"
}
}
cfg = load_run_config(_write_yaml(tmp_path, data))
assert isinstance(cfg.training.pipeline_config.dit_config.quant_config,
NVFP4QATTrainConfig)
export_cfg = _run_config_from_raw(cfg.raw)
assert isinstance(
export_cfg.training.pipeline_config.dit_config.quant_config,
NVFP4QATTrainConfig,
)
def test_pipeline_quant_config_rejects_unknown_name(tmp_path: Path) -> None:
data = _minimal_yaml()
data["models"]["student"]["init_from"] = (
"FastVideo/LTX2-Distilled-Diffusers")
data["pipeline"] = {
"dit_config": {
"quant_config": "not_a_quantization_method"
}
}
with pytest.raises(ValueError, match="Invalid quantization method"):
load_run_config(_write_yaml(tmp_path, data))
def test_data_path_mapping_parses_repeat_counts(tmp_path: Path) -> None:
# Config loading should preserve structured multi-dataset paths so the
# dataset layer can interpret repeat counts later.
+41 -1
View File
@@ -133,6 +133,7 @@ class ValidationCallback(Callback):
keyboard_value_scale: float = 1.0,
offload_training_state: bool = False,
unload_pipeline_after_validation: bool = False,
attn_qat_infer: bool = False,
**pipeline_kwargs: Any,
) -> None:
self.pipeline_target = str(pipeline_target)
@@ -150,6 +151,7 @@ class ValidationCallback(Callback):
self.metrics_config = self._parse_metrics_config(metrics_config)
self.offload_training_state = self._coerce_bool(offload_training_state)
self.unload_pipeline_after_validation = self._coerce_bool(unload_pipeline_after_validation)
self.attn_qat_infer = self._coerce_bool(attn_qat_infer)
self.pipeline_kwargs = dict(pipeline_kwargs)
# Set after on_train_start.
@@ -273,7 +275,7 @@ class ValidationCallback(Callback):
# EMA weights during validation.
ema_cb = self._find_ema_callback()
ctx = ema_cb.ema_context(transformer) if ema_cb is not None else contextlib.nullcontext(transformer)
with ctx as t:
with ctx as t, self._attn_qat_infer_context(t):
self._run_validation_inner(
method,
step,
@@ -283,6 +285,44 @@ class ValidationCallback(Callback):
if self.unload_pipeline_after_validation:
self._clear_pipeline_cache()
@contextlib.contextmanager
def _attn_qat_infer_context(self, transformer: torch.nn.Module):
if not self.attn_qat_infer:
yield
return
from fastvideo.attention.backends.attn_qat_infer import (AttnQatInferImpl, is_attn_qat_infer_available)
from fastvideo.attention.backends.attn_qat_train import (
AttnQatTrainImpl, )
from fastvideo.platforms import AttentionBackendEnum
layers = [
module for module in transformer.modules()
if isinstance(getattr(module, "attn_impl", None), AttnQatTrainImpl)
]
if not layers:
raise RuntimeError("attn_qat_infer validation requested, but the transformer has no ATTN_QAT_TRAIN layers")
if not is_attn_qat_infer_available():
raise RuntimeError("attn_qat_infer validation requires the sm120 fastvideo-kernel extension")
previous = [(layer, layer.attn_impl, layer.backend) for layer in layers]
try:
for layer, impl, _ in previous:
layer.attn_impl = AttnQatInferImpl(
num_heads=layer.num_heads,
head_size=layer.head_size,
num_kv_heads=layer.num_kv_heads,
causal=impl.causal,
softmax_scale=layer.softmax_scale,
)
layer.backend = AttentionBackendEnum.ATTN_QAT_INFER
logger.info("Enabled ATTN_QAT_INFER for %d validation attention layers.", len(layers))
yield
finally:
for layer, impl, backend in previous:
layer.attn_impl = impl
layer.backend = backend
@contextlib.contextmanager
def _validation_memory_context(
self,
+5
View File
@@ -38,6 +38,7 @@ from fastvideo.forward_context import set_forward_context
from fastvideo.logger import init_logger
from fastvideo.models.dits.ltx2 import VideoLatentShape
from fastvideo.pipelines import ForwardBatch, TrainingBatch
from fastvideo.platforms import AttentionBackendEnum
from fastvideo.training.activation_checkpoint import (
apply_activation_checkpointing, )
@@ -94,6 +95,7 @@ class LTX2Model(WanModel):
transformer_override_safetensor: str
| None = None,
lora: LoraConfig | dict[str, Any] | None = None,
attention_backend: AttentionBackendEnum | str | None = None,
train_audio: bool = False,
timestep_uniform_prob: float = 0.1,
) -> None:
@@ -126,6 +128,7 @@ class LTX2Model(WanModel):
enable_gradient_checkpointing_type=(enable_gradient_checkpointing_type),
transformer_override_safetensor=(transformer_override_safetensor),
lora=lora,
attention_backend=attention_backend,
)
if trainable:
@@ -149,6 +152,7 @@ class LTX2Model(WanModel):
enable_gradient_checkpointing_type: str | None,
training_config: TrainingConfig,
transformer_override_safetensor: str | None = None,
attention_backend: AttentionBackendEnum | str | None = None,
) -> torch.nn.Module:
transformer = load_module_from_path(
model_path=init_from,
@@ -157,6 +161,7 @@ class LTX2Model(WanModel):
disable_custom_init_weights=(disable_custom_init_weights),
override_transformer_cls_name=(self._transformer_cls_name),
transformer_override_safetensor=(transformer_override_safetensor),
attention_backend=attention_backend,
)
ckpt_type = (enable_gradient_checkpointing_type or getattr(
getattr(training_config, "model", None),
+19
View File
@@ -282,9 +282,28 @@ def _parse_pipeline_config(
pipeline_config = PipelineConfig.from_kwargs(kwargs)
_apply_training_dit_arch_overrides(pipeline_config, dit_arch_overrides)
_resolve_dit_quant_config(pipeline_config)
return pipeline_config
def _resolve_dit_quant_config(pipeline_config: Any) -> None:
"""Resolve a string ``pipeline.dit_config.quant_config`` (e.g.
``nvfp4_qat_train``) into its registered QuantizationConfig instance.
Model construction calls ``quant_config.get_quant_method()`` inside
``LinearBase.__init__``, so a bare YAML string would crash there.
This must live in ``_parse_pipeline_config`` (not ``load_run_config``)
because ``dcp_to_diffusers`` rebuilds models from a checkpoint's raw
config by calling ``_parse_pipeline_config`` directly.
"""
dit_config = getattr(pipeline_config, "dit_config", None)
quant = getattr(dit_config, "quant_config", None)
if isinstance(quant, str):
from fastvideo.layers.quantization import (
get_quantization_config, )
dit_config.quant_config = get_quantization_config(quant)()
def _split_training_dit_arch_overrides(pipeline_raw: Any) -> tuple[Any, dict[str, Any]]:
"""Remove train-only DiT arch overrides before generic config parsing."""
if not isinstance(pipeline_raw, dict) or not isinstance(pipeline_raw.get("dit_config"), dict):