Skip to content

Commit b74a185

Browse files
[feat]: FA4-FP4 ATTN_QAT_INFER on sm_100/sm_103 + NVFP4 weight purge
1 parent 7a592ff commit b74a185

9 files changed

Lines changed: 484 additions & 28 deletions

File tree

examples/inference/basic/basic_ltx2_distilled_fast_profile.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,21 @@ def main() -> None:
191191
print(f"Using refine upsampler: {refine_upsampler_path}")
192192

193193
pipeline_config = PipelineConfig.from_pretrained(model_root)
194+
# LTX-2 NVFP4 deploy contract (train==deploy surface):
195+
# * Linears: NVFP4 block-scaled GEMMs (per-16 E2M1 + E4M3 SFs) on every
196+
# arch, via flashinfer.
197+
# * ATTN_QAT_INFER attention differs per arch: sm_120a/sm_121a use the
198+
# fastvideo-kernel CUTLASS (SageAttention3-FP4) scheme that
199+
# ATTN_QAT_TRAIN simulates; sm_100a (GB200) / sm_103a (GB300) use the
200+
# FP4 FA4 kernel (flash-attention-fp4) with per-16 block-scaled NVFP4
201+
# Q/K and BF16 P/V -- a train-sim mismatch that is gated by MS-SSIM
202+
# measurement, not assumed equal. The selection receipt is logged at
203+
# backend resolution ("ATTN_QAT_INFER resolved: ...").
204+
# Original-weight retention: default (None) purges the always-FP4 layers'
205+
# bf16 originals after conversion and retains only refine-only layers,
206+
# which this two-stage fast profile still runs dense during stage 1.
207+
# Single-stage deploys (no refine stage) can purge everything with
208+
# NVFP4Config(retain_original_weights=False).
194209
pipeline_config.dit_config.quant_config = NVFP4Config()
195210
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
196211
torch_compile_kwargs = {

examples/train/configs/overfit_ltx2_t2v_nvfp4_qat.yaml

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,16 @@
1010
# quantized forward and STE backward, then ATTN_QAT_INFER during validation.
1111
# Head-dim-64 audio attention and masked text attention remain dense.
1212
#
13-
# Validation requires an sm_120 GPU with the attn_qat_infer extension.
13+
# Validation-time ATTN_QAT_INFER is arch-aware:
14+
# * sm_120a/sm_121a: fastvideo-kernel CUTLASS extension -- the exact
15+
# quantization scheme ATTN_QAT_TRAIN simulates.
16+
# * sm_100a (GB200) / sm_103a (GB300): FP4 FA4 kernel
17+
# (github.com/hao-ai-lab/flash-attention-fp4, branch fp4; per-16
18+
# block-scaled NVFP4 Q/K, BF16 P/V). This scheme DIFFERS from the
19+
# CUTLASS one the training simulation matches, so sm_100/sm_103
20+
# validation and deployment carry a train-sim mismatch -- gate quality
21+
# by MS-SSIM measurement rather than assuming parity. The resolution
22+
# receipt ("ATTN_QAT_INFER resolved: ...") records arch + scheme.
1423
#
1524
# Preprocess data first (same data as the bf16 overfit):
1625
# CUDA_VISIBLE_DEVICES=0 python fastvideo/pipelines/preprocess/preprocess_ltx2_overfit.py
@@ -19,8 +28,8 @@
1928
# NUM_GPUS=4 \
2029
# bash examples/train/run.sh examples/train/configs/overfit_ltx2_t2v_nvfp4_qat.yaml
2130
#
22-
# GB200 can train and validate with ATTN_QAT_TRAIN, but cannot load the
23-
# sm_120-only inference kernel. Disable only the validation-time swap:
31+
# On GB200 without flash-attention-fp4 installed (or any other arch with no
32+
# ATTN_QAT_INFER kernel), disable only the validation-time swap:
2433
# NUM_GPUS=4 \
2534
# bash examples/train/run.sh examples/train/configs/overfit_ltx2_t2v_nvfp4_qat.yaml \
2635
# --callbacks.validation.attn_qat_infer false

fastvideo/attention/backends/attn_qat_infer.py

Lines changed: 142 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -54,29 +54,108 @@ def _get_attn_qat_infer() -> Callable[..., torch.Tensor] | None:
5454
# kernel is compiled for (sm_120a / sm_121a -- see fastvideo-kernel/README.md).
5555
_SUPPORTED_DEVICE_CAPABILITIES = frozenset({(12, 0), (12, 1)})
5656

57-
58-
def _device_capability_supported() -> bool:
57+
# Datacenter-Blackwell capabilities served by the FP4 FA4 kernel
58+
# (flash-attention-fp4 @ fp4, sm_100a/sm_103a) through #1221's plumbing:
59+
# per-16 block-scaled NVFP4 Q/K (E4M3 scale factors), BF16 P/V. This is a
60+
# DIFFERENT quantization scheme from the sm_12x CUTLASS extension above --
61+
# ATTN_QAT_TRAIN simulates the CUTLASS scheme, so sm_100/sm_103 deployment
62+
# carries a train-sim mismatch that is measured (MS-SSIM gate), not assumed.
63+
_FA4_FP4_CAPABILITIES = frozenset({(10, 0), (10, 3)})
64+
65+
_FA4_INSTALL_HINT = ("pip install flash-attention-fp4 from "
66+
"https://github.com/hao-ai-lab/flash-attention-fp4 (branch fp4); "
67+
"see docs/inference/optimizations.md")
68+
69+
_fa4_fp4_import_ok: bool | None = None
70+
71+
72+
def _fa4_fp4_available() -> bool:
73+
"""flash_attn.cute (FA4) import probe, cached. Reuses #1221's guarded
74+
import chain in fastvideo.attention.utils.flash_attn_cute (which maps
75+
cutlass-dsl version skew to ImportError with a loud warning)."""
76+
global _fa4_fp4_import_ok
77+
if _fa4_fp4_import_ok is None:
78+
try:
79+
from fastvideo.attention.utils.flash_attn_cute import ( # noqa: F401
80+
flash_attn_fp4_func, )
81+
_fa4_fp4_import_ok = True
82+
except ImportError:
83+
_fa4_fp4_import_ok = False
84+
return _fa4_fp4_import_ok
85+
86+
87+
def _active_capability() -> tuple[int, int] | None:
5988
if not torch.cuda.is_available():
60-
return False
89+
return None
6190
try:
62-
return tuple(torch.cuda.get_device_capability()) in _SUPPORTED_DEVICE_CAPABILITIES
91+
return tuple(torch.cuda.get_device_capability())
6392
except Exception: # pragma: no cover - defensive: never break backend selection
64-
return False
93+
return None
94+
95+
96+
def _resolved_kernel() -> str | None:
97+
"""Which ATTN_QAT_INFER kernel serves the active device, or None.
98+
99+
Per-arch resolution (single source of truth -- extend the capability
100+
sets above, do not add equality checks elsewhere):
101+
* sm_12x consumer Blackwell -> fastvideo-kernel CUTLASS extension
102+
(modified SageAttention3 FP4).
103+
* sm_100a/sm_103a datacenter Blackwell -> FP4 FA4 (flash-attention-fp4)
104+
via the merged #1221 plumbing.
105+
"""
106+
cap = _active_capability()
107+
if cap in _SUPPORTED_DEVICE_CAPABILITIES and _get_attn_qat_infer() is not None:
108+
return "cutlass_sm12x"
109+
if cap in _FA4_FP4_CAPABILITIES and _fa4_fp4_available():
110+
return "fa4_fp4"
111+
return None
112+
113+
114+
def attn_qat_infer_receipt() -> str:
115+
"""One-line receipt of the resolution decision (arch + kernel + quant
116+
knobs), for the selection log and for tooling. The FA4 knobs are the
117+
repo's tuned defaults passed through verbatim: qk_mode=nvfp4
118+
(per-16 E4M3 SFs), pv_mode=bf16 -- see flash_attn/cute/README.md in
119+
the kernel repo."""
120+
cap = _active_capability()
121+
arch = f"sm_{cap[0]}{cap[1]}" if cap is not None else "no-cuda"
122+
kernel = _resolved_kernel()
123+
if kernel == "cutlass_sm12x":
124+
return f"arch={arch} kernel=fastvideo-kernel-cutlass scheme=sage3-fp4-sm120"
125+
if kernel == "fa4_fp4":
126+
return (f"arch={arch} kernel=flash-attention-fp4 qk_mode=nvfp4(per-16-e4m3-sf) "
127+
f"pv_mode=bf16 train_sim_mismatch=measured")
128+
supported = "sm_120a/sm_121a via fastvideo-kernel build.sh; sm_100a/sm_103a via flash-attention-fp4"
129+
if cap is not None and cap in _FA4_FP4_CAPABILITIES:
130+
return f"arch={arch} kernel=none (flash_attn.cute not importable -- {_FA4_INSTALL_HINT})"
131+
return f"arch={arch} kernel=none (supported: {supported})"
132+
133+
134+
_receipt_logged = False
135+
136+
137+
def _log_receipt_once() -> None:
138+
# One line per process, not per layer (the validation swap constructs
139+
# one impl per attention layer).
140+
global _receipt_logged
141+
if not _receipt_logged:
142+
_receipt_logged = True
143+
logger.info("ATTN_QAT_INFER resolved: %s", attn_qat_infer_receipt())
65144

66145

67146
def is_attn_qat_infer_available() -> bool:
68-
"""True only when the extension imports AND the active device is a
69-
consumer-Blackwell (sm_120/sm_121) GPU the kernel is compiled for.
147+
"""True only when the active device has a built ATTN_QAT_INFER kernel.
70148
71149
The import check alone is not sufficient: CUDA 13 wheel builds can
72-
carry the sm_120/sm_121 extension on any host (e.g. H100, GB200),
73-
where the import succeeds, backend selection picks this backend, and
74-
the first kernel call then fails with an unsupported-capability error
75-
instead of ever reaching the documented FlashAttention fallback in
150+
carry the sm_12x extension on any host (e.g. H100, GB200), where the
151+
import succeeds, backend selection picks this backend, and the first
152+
kernel call then fails with an unsupported-capability error instead of
153+
ever reaching the documented FlashAttention fallback in
76154
fastvideo.platforms.cuda. Gating on the active device's capability
77-
keeps that fallback working on every non-sm_120/121 GPU.
155+
keeps that fallback working on every unsupported GPU, while
156+
sm_100a/sm_103a now resolve to the FP4 FA4 kernel (#1221).
78157
"""
79-
return _device_capability_supported() and _get_attn_qat_infer() is not None
158+
return _resolved_kernel() is not None
80159

81160

82161
class AttnQatInferBackend(AttentionBackend):
@@ -122,6 +201,12 @@ def __init__(
122201
if dropout_p > 0:
123202
raise NotImplementedError(f"attn_qat_infer does not support dropout (got dropout_p={dropout_p}). "
124203
"The QAT inference kernel applies no stochastic dropout.")
204+
# Kernel resolution is per-forward, not per-construction: callers
205+
# (the validation swap, backend selection) gate on
206+
# is_attn_qat_infer_available() first, and constructing an impl on a
207+
# host without the kernel must stay legal (pre-existing contract the
208+
# validation-swap test pins).
209+
_log_receipt_once()
125210

126211
def forward(
127212
self,
@@ -130,10 +215,13 @@ def forward(
130215
value: torch.Tensor,
131216
attn_metadata: AttentionMetadata,
132217
) -> torch.Tensor:
218+
if _resolved_kernel() == "fa4_fp4":
219+
return self._forward_fa4_fp4(query, key, value)
220+
133221
attn_qat_infer = _get_attn_qat_infer()
134222
if attn_qat_infer is None:
135-
raise ImportError("attn_qat_infer is not available. Please ensure the "
136-
"attn_qat_infer kernel package is installed.")
223+
raise ImportError(f"attn_qat_infer is not available ({attn_qat_infer_receipt()}). "
224+
"Please ensure an ATTN_QAT_INFER kernel is installed for this device.")
137225

138226
query = query.transpose(1, 2).contiguous()
139227
key = key.transpose(1, 2).contiguous()
@@ -148,3 +236,42 @@ def forward(
148236
sm_scale=self.softmax_scale,
149237
)
150238
return output.transpose(1, 2).contiguous()
239+
240+
def _forward_fa4_fp4(
241+
self,
242+
query: torch.Tensor,
243+
key: torch.Tensor,
244+
value: torch.Tensor,
245+
) -> torch.Tensor:
246+
"""sm_100a/sm_103a path: FP4 FA4 with the repo's tuned defaults
247+
(NVFP4 per-16 block-scaled Q/K, BF16 V) -- mirrors
248+
FlashAttentionImpl._forward_nvfp4 (#1221). Inputs/outputs are
249+
(batch, seqlen, nheads, headdim); no transpose."""
250+
from fastvideo.attention.backends.flash_attn import (
251+
_nvfp4_quantize_for_fa4, )
252+
from fastvideo.attention.utils.flash_attn_cute import (
253+
flash_attn_fp4_func, )
254+
255+
orig_seqlen_q = query.shape[1]
256+
orig_seqlen_k = key.shape[1]
257+
258+
q_fp4, q_sf = _nvfp4_quantize_for_fa4(query)
259+
k_fp4, k_sf = _nvfp4_quantize_for_fa4(key)
260+
261+
# FP4/SF buffers are padded to a 128 multiple; FA4 masks to the
262+
# original lengths so padding never biases the softmax.
263+
q_fp4 = q_fp4[:, :orig_seqlen_q]
264+
k_fp4 = k_fp4[:, :orig_seqlen_k]
265+
266+
output = flash_attn_fp4_func(
267+
q_fp4,
268+
k_fp4,
269+
value,
270+
q_sf,
271+
k_sf,
272+
softmax_scale=self.softmax_scale,
273+
causal=self.causal,
274+
)
275+
if isinstance(output, tuple):
276+
output = output[0]
277+
return output

fastvideo/layers/quantization/nvfp4_config.py

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,11 @@ def __init__(self, layer_prefix: str = ""):
309309
self.x_global_sf = torch.tensor(1.0, device="cuda", dtype=torch.float32)
310310
self.layer_prefix = layer_prefix
311311
self._is_refine_only_layer = _is_ltx2_refine_only_prefix(layer_prefix)
312+
# Set from NVFP4Config.retain_original_weights in get_quant_method:
313+
# None = auto (retain only refine-only layers, which can still take
314+
# the stage-profile dense path); True = retain all; False = purge all
315+
# (single-stage deploys that never run the base profile).
316+
self._retain_original_weights: bool | None = None
312317

313318
def create_weights(self, layer: torch.nn.Module, input_size_per_partition: int, output_partition_sizes: list[int],
314319
input_size: int, output_size: int, params_dtype: torch.dtype, **extra_weight_attrs):
@@ -349,16 +354,25 @@ def apply(
349354
| None = None,
350355
) -> torch.Tensor:
351356
SfLayout, _, _ = _require_flashinfer()
352-
out_dim = layer.weight.shape[0]
357+
# The original bf16 weight may have been purged after FP4 conversion
358+
# (see convert_model_to_nvfp4); the packed FP4 weight keeps the
359+
# output dim as its first dimension (only K is packed 2-per-byte).
360+
weight = getattr(layer, "weight", None)
361+
out_dim = weight.shape[0] if weight is not None else layer._nvfp4_weight.shape[0]
353362
original_shape = x.shape
354363

355364
# Stage-aware profile: keep refine-only FP4 layers in dense mode
356365
# during stage-1 denoising so the base path doesn't pay the
357366
# quantize/dequantize tax for layers it never touches.
358367
stage_profile = _get_ltx2_fp4_stage_profile(default="refine")
359368
if self._is_refine_only_layer and stage_profile == "base":
360-
out = (F.linear(x, layer.weight, bias) if torch.cuda.is_available() or bias is None else F.linear(
361-
x, layer.weight, bias.to(x.dtype)))
369+
if weight is None:
370+
raise RuntimeError(f"NVFP4 layer {self.layer_prefix!r} hit the stage-profile dense path, "
371+
"but its original weights were purged "
372+
"(NVFP4Config(retain_original_weights=False)). Streaming/two-stage "
373+
"deploys must load with retain_original_weights left unset (auto) or True.")
374+
out = (F.linear(x, weight, bias) if torch.cuda.is_available() or bias is None else F.linear(
375+
x, weight, bias.to(x.dtype)))
362376
return out.view(*original_shape[:-1], out_dim)
363377
if pre_quantized is not None:
364378
x_fp4, x_scale, x_global_sf = pre_quantized
@@ -416,11 +430,19 @@ class NVFP4Config(QuantizationConfig):
416430
instead of hardcoding it here.
417431
"""
418432

419-
def __init__(self, layer_profile: str = "refine"):
433+
def __init__(self, layer_profile: str = "refine", retain_original_weights: bool | None = None):
420434
super().__init__()
421435
# ``base``: stage-1 set (no attn2.to_out, no cross-modal AV
422436
# projections). ``refine``: full stage-2 set.
423437
self.layer_profile = layer_profile
438+
# Original bf16 ``layer.weight`` retention after FP4 conversion.
439+
# None (auto): purge always-FP4 layers, retain refine-only layers --
440+
# those still take the dense path when the streaming stage profile
441+
# flips to ``base``. True: retain everything (pre-purge behavior).
442+
# False: purge everything -- for single-stage deploys (e.g. the
443+
# LTX-2 QAD distilled 8-step deploy) where no dense stage ever runs;
444+
# a purged layer hitting the dense path raises with this flag named.
445+
self.retain_original_weights = retain_original_weights
424446

425447
def get_name(self):
426448
return "nvfp4"
@@ -438,22 +460,30 @@ def get_config_filenames():
438460

439461
@classmethod
440462
def from_config(cls, config: dict[str, Any]) -> NVFP4Config:
441-
return cls(layer_profile=config.get("layer_profile", "refine"))
463+
return cls(
464+
layer_profile=config.get("layer_profile", "refine"),
465+
retain_original_weights=config.get("retain_original_weights"),
466+
)
442467

443468
def get_quant_method(self, layer: torch.nn.Module, prefix: str):
444469
from fastvideo.layers.linear import LinearBase
445470

446471
# Use the superset at build/load time, then switch active subset
447472
# dynamically in NVFP4QuantizeMethod.apply based on stage profile.
448473
if isinstance(layer, LinearBase) and is_ltx2_nvfp4_linear_prefix(prefix):
449-
return NVFP4QuantizeMethod(layer_prefix=prefix)
474+
method = NVFP4QuantizeMethod(layer_prefix=prefix)
475+
method._retain_original_weights = self.retain_original_weights
476+
return method
450477
return None
451478

452479

453480
def convert_model_to_nvfp4(model: torch.nn.Module) -> None:
454481
SfLayout, _, _ = _require_flashinfer()
455482
from torch.distributed.tensor import DTensor # type: ignore
456483

484+
purged = 0
485+
retained = 0
486+
purged_bytes = 0
457487
for mod in model.modules():
458488
qm = getattr(mod, "quant_method", None)
459489
if isinstance(qm, NVFP4QuantizeMethod):
@@ -486,6 +516,29 @@ def convert_model_to_nvfp4(model: torch.nn.Module) -> None:
486516
persistent=False,
487517
)
488518

519+
retain_flag = getattr(qm, "_retain_original_weights", None)
520+
retain = retain_flag if retain_flag is not None else qm._is_refine_only_layer
521+
if retain:
522+
retained += 1
523+
elif isinstance(weight, DTensor):
524+
# ponytail: purging FSDP-sharded originals needs per-shard
525+
# resharding bookkeeping; skip until a sharded deploy needs it.
526+
retained += 1
527+
else:
528+
purged_bytes += weight.numel() * weight.element_size()
529+
purged += 1
530+
mod.register_parameter("weight", None)
531+
532+
if purged or retained:
533+
logger.info(
534+
"NVFP4 weight purge receipt: purged %d original bf16 weight tensors "
535+
"(%.2f GiB freed); retained %d (refine-only dense fallback or "
536+
"retain_original_weights).",
537+
purged,
538+
purged_bytes / (1 << 30),
539+
retained,
540+
)
541+
489542

490543
__all__ = [
491544
"NVFP4Config",

fastvideo/platforms/cuda.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,13 @@ def get_attn_backend_cls(cls, selected_backend: AttentionBackendEnum | None, hea
142142
logger.info("Sage Attention 3 backend is not installed. Fall back to Flash Attention.")
143143
elif selected_backend == AttentionBackendEnum.ATTN_QAT_INFER:
144144
from fastvideo.attention.backends.attn_qat_infer import ( # noqa: F401
145-
AttnQatInferBackend, is_attn_qat_infer_available)
145+
AttnQatInferBackend, attn_qat_infer_receipt, is_attn_qat_infer_available)
146146
if is_attn_qat_infer_available():
147-
logger.info("Using Attn-QAT inference (modified SageAttention3 FP4) backend.")
147+
logger.info("Using Attn-QAT inference backend (%s).", attn_qat_infer_receipt())
148148
return "fastvideo.attention.backends.attn_qat_infer.AttnQatInferBackend"
149-
logger.info("Attn-QAT inference kernel is not built. Fall back to Flash Attention.")
149+
# Keep the trailing sentence stable: downstream receipts grep for it.
150+
logger.info("Attn-QAT inference kernel is not built (%s). Fall back to Flash Attention.",
151+
attn_qat_infer_receipt())
150152
elif selected_backend == AttentionBackendEnum.ATTN_QAT_TRAIN:
151153
from fastvideo.attention.backends.attn_qat_train import ( # noqa: F401
152154
AttnQatTrainBackend, is_attn_qat_train_available)

0 commit comments

Comments
 (0)