[Bugfix] QAD 5090: Torch.compile and other optimizations (15/12) - #1466
Conversation
Merge ProtectionsYour pull request matches the following merge protections and will not be merged until they are valid. 🔴 PR merge requirementsWaiting for
This rule is failing.
|
There was a problem hiding this comment.
Code Review
This pull request introduces fast NVFP4 linear inference for Wan2.1 with TAEHV decoding, optimizes attention preprocessing/postprocessing in SageAttn3, and adds support for quantization-aware training (QAD) with a straight-through estimator. Key feedback includes resolving an environment-specific absolute path, rounding instead of truncating during float-to-uint8 conversion, avoiding backslashes for line continuation, and preventing division-by-zero errors when computing scale factors. Additionally, it is recommended to make NVFP4QATTrainConfig customizable to match its inference counterpart and to refactor the FSDP loader to handle mixed quantization methods without returning early.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
|
|
||
| # TAEHV checkpoint for Wan2.1. Clone https://github.com/madebyollin/taehv to get | ||
| # ``taew2_1.pth`` (Wan 2.1 / Wan 2.2-14B / Qwen-Image all use this VAE). | ||
| DEFAULT_TAEHV_CHECKPOINT = "/root/taehv/taew2_1.pth" |
There was a problem hiding this comment.
The absolute path /root/taehv/taew2_1.pth is environment-specific and will fail on other systems. It is better to use a relative path or default to a standard location, or allow it to be resolved dynamically.
| DEFAULT_TAEHV_CHECKPOINT = "/root/taehv/taew2_1.pth" | |
| DEFAULT_TAEHV_CHECKPOINT = "taew2_1.pth" |
| decoded = self.model.decode_video( | ||
| latents, parallel=True, show_progress_bar=False) | ||
| # decoded: [B, T, 3, H, W] in [0, 1]. Take batch 0, vectorize to uint8. | ||
| frames = (decoded[0].clamp(0, 1) * 255).to(torch.uint8) |
There was a problem hiding this comment.
Directly casting a float tensor in [0, 1] to uint8 after multiplying by 255 performs a floor operation (truncation), which can cause precision loss and slight color shifts. Rounding to the nearest integer before casting is recommended.
| frames = (decoded[0].clamp(0, 1) * 255).to(torch.uint8) | |
| frames = (decoded[0].clamp(0, 1) * 255).round().to(torch.uint8) |
| taehv = TaehvDecoder(resolve_taehv_checkpoint(args.taehv_checkpoint)) \ | ||
| if args.taehv else None |
There was a problem hiding this comment.
PEP 8 discourages the use of backslashes for line continuation. Wrapping the expression in parentheses is the preferred way to break long lines.
| taehv = TaehvDecoder(resolve_taehv_checkpoint(args.taehv_checkpoint)) \ | |
| if args.taehv else None | |
| taehv = ( | |
| TaehvDecoder(resolve_taehv_checkpoint(args.taehv_checkpoint)) | |
| if args.taehv | |
| else None | |
| ) |
References
- PEP 8: Avoid using backslashes for line continuation. Use parentheses or standard block structures instead. (link)
| fp4_w, fp4_s = flashinfer_mod.nvfp4_quantize( | ||
|
|
||
| # Only the reduced scalar needs fp32; avoid a full fp32 copy. | ||
| weight_absmax = (weight_local.detach().abs().nan_to_num().amax().to(dtype=torch.float32)) |
There was a problem hiding this comment.
If weight_absmax is 0 (e.g., for zero-initialized or pruned weights), weight_global_sf = (448 * 6) / weight_absmax will result in a division by zero, producing inf or NaN values. Clamping weight_absmax to a small positive epsilon prevents this.
| weight_absmax = (weight_local.detach().abs().nan_to_num().amax().to(dtype=torch.float32)) | |
| weight_absmax = (weight_local.detach().abs().nan_to_num().amax().to(dtype=torch.float32)).clamp(min=1e-12) |
| class NVFP4QATTrainConfig(QuantizationConfig): | ||
|
|
||
| def __init__(self) -> None: | ||
| super().__init__() | ||
|
|
||
| def get_name(self): | ||
| return "nvfp4_qat_train" | ||
|
|
||
| def get_supported_act_dtypes(self): | ||
| return [torch.bfloat16, torch.float16] | ||
|
|
||
| @classmethod | ||
| def get_min_capability(cls): | ||
| return 100 | ||
|
|
||
| @staticmethod | ||
| def get_config_filenames(): | ||
| return [] | ||
|
|
||
| @classmethod | ||
| def from_config(cls, config: dict[str, Any]) -> "NVFP4QATTrainConfig": | ||
| return cls() | ||
|
|
||
| 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): | ||
| return NVFP4QATTrainQuantizeMethod() | ||
| return None |
There was a problem hiding this comment.
NVFP4QATTrainConfig hardcodes fp4_layers and does not accept target_layers in its constructor, unlike NVFP4QATConfig. This inconsistency prevents users from customizing target layers during QAT training. Refactoring it to match NVFP4QATConfig ensures consistency and flexibility.
class NVFP4QATTrainConfig(QuantizationConfig):
def __init__(self, target_layers: tuple[str, ...] | None = None) -> None:
super().__init__()
from fastvideo.layers.quantization.nvfp4_qat_config import DEFAULT_FP4_LAYERS
self.target_layers = (tuple(target_layers) if target_layers else DEFAULT_FP4_LAYERS)
def get_name(self) -> str:
return "nvfp4_qat_train"
def get_supported_act_dtypes(self) -> list[torch.dtype]:
return [torch.bfloat16, torch.float16]
@classmethod
def get_min_capability(cls) -> int:
return 100
@staticmethod
def get_config_filenames() -> list[str]:
return []
@classmethod
def from_config(cls, config: dict[str, Any]) -> NVFP4QATTrainConfig:
target_layers = config.get("target_layers")
if target_layers is not None:
target_layers = tuple(target_layers)
return cls(target_layers=target_layers)
def get_quant_method(self, layer: torch.nn.Module, prefix: str):
from fastvideo.layers.linear import LinearBase
if isinstance(layer, LinearBase) and any(name in prefix for name in self.target_layers):
return NVFP4QATTrainQuantizeMethod()
return None| for mod in model.modules(): | ||
| if isinstance(getattr(mod, "quant_method", None), | ||
| NVFP4QuantizeMethod): | ||
| quant_method = getattr(mod, "quant_method", None) | ||
| # Wan-style NVFP4 linear: prequantize and pop dense weights. | ||
| if isinstance(quant_method, NVFP4QATQuantizeMethod): | ||
| logger.info( | ||
| "Converting loaded model weights for NVFP4 (QAT) linear layers") | ||
| convert_model_to_fp4(model) | ||
| return | ||
| # LTX-2 NVFP4 linear. | ||
| if isinstance(quant_method, NVFP4QuantizeMethod): | ||
| logger.info("Converting loaded model weights for NVFP4 linear layers") | ||
| convert_model_to_nvfp4(model) | ||
| return |
There was a problem hiding this comment.
The current loop returns immediately after finding the first quantization method. If a model contains mixed quantization (e.g., both NVFP4QATQuantizeMethod and NVFP4QuantizeMethod), only one of them will be converted, and the other will be silently skipped. Tracking which conversions have run instead of returning early makes the loader much more robust.
| for mod in model.modules(): | |
| if isinstance(getattr(mod, "quant_method", None), | |
| NVFP4QuantizeMethod): | |
| quant_method = getattr(mod, "quant_method", None) | |
| # Wan-style NVFP4 linear: prequantize and pop dense weights. | |
| if isinstance(quant_method, NVFP4QATQuantizeMethod): | |
| logger.info( | |
| "Converting loaded model weights for NVFP4 (QAT) linear layers") | |
| convert_model_to_fp4(model) | |
| return | |
| # LTX-2 NVFP4 linear. | |
| if isinstance(quant_method, NVFP4QuantizeMethod): | |
| logger.info("Converting loaded model weights for NVFP4 linear layers") | |
| convert_model_to_nvfp4(model) | |
| return | |
| converted_qat = False | |
| converted_nvfp4 = False | |
| for mod in model.modules(): | |
| quant_method = getattr(mod, "quant_method", None) | |
| # Wan-style NVFP4 linear: prequantize and pop dense weights. | |
| if isinstance(quant_method, NVFP4QATQuantizeMethod) and not converted_qat: | |
| logger.info( | |
| "Converting loaded model weights for NVFP4 (QAT) linear layers") | |
| convert_model_to_fp4(model) | |
| converted_qat = True | |
| # LTX-2 NVFP4 linear. | |
| elif isinstance(quant_method, NVFP4QuantizeMethod) and not converted_nvfp4: | |
| logger.info("Converting loaded model weights for NVFP4 linear layers") | |
| convert_model_to_nvfp4(model) | |
| converted_nvfp4 = True |
|
This PR has merge conflicts with the base branch. Please rebase: git fetch origin main
git rebase origin/main
# Resolve any conflicts, then:
git push --force-with-lease |
SolitaryThinker
left a comment
There was a problem hiding this comment.
Hi @loaydatrain — automated review from Gob, one of @SolitaryThinker's AI reviewers. Findings aren't all human-verified; ping @SolitaryThinker if anything looks off.
Scope: training/quant + perf (the FP4-linear torch.compile enablement + optimizations). Open PR, mergeable=CONFLICTING / mergeStateStatus=DIRTY + needs-rebase. Verdict: COMMENT — the compile fix is correct and the core logic lands on files main hasn't touched, but rebase first and confirm two things below before merge.
Major
-
Needs rebase — the branch is stacked on the pre-merge #1463 commit (
09f9b56). #1463 already squash-merged tomain(6da206e1), and that base is also ~13k lines behind currentmain(the big deletion count GitHub shows is this artifact, not intended changes). The actual textual conflict is justfastvideo/models/loader/fsdp_load.pyandfastvideo/fastvideo_args.py, and both PR versions are semantically equivalent to what #1463 already put onmain(variable name / branch order / comment wording infsdp_load.py; pure formatting reflow of the existing--transformer-quantarg infastvideo_args.py). Rebase ontomainand drop those two near-dup hunks — the substantive logic (sage_attn3,attention/layer.py,nvfp4_qat_config.py) is conflict-free. -
Activation-quant numerics change with no quality evidence —
nvfp4_qat_config.py:84,106.apply()switched the input scale from per-call dynamic(448*6)/x.absmax()to staticx_global_sf = torch.tensor(1.0). The comment ("matches the production path") is correct — the LTX-2 NVFP4 method uses the same static1.0(nvfp4_config.py:270,338), and this is exactly what removes the per-call.max()sync blocking CUDA-graph capture. But it's still a real FP4 activation-quant change and the body ships no SSIM/quality number (the memory claim is a literal "TODO MB"). Per the repo's model-PR convention, please post an SSIM delta vs dense bf16. Also note it diverges from the training STE, which still quantizes activations with the dynamic scale (fp4linear.py:50) — confirm the train-vs-inference input-scale skew is intended for QAT. -
Latent layout-contract break in the sage3 hook refactor. The old impl transposed q/k/v inside
forward()after the chunk; the newpreprocess_qkv(sage_attn3.py:58-70) doespermute(0,2,1,3)on the stacked tensor, whichlayer.py:134/ltx2.py:1303call before the replicated-QKV concat (dim=1) and the post-attention replicated split. After the permute dim-1 is heads, not sequence, so the replicated-QKV concat/split no longer mean what they did. Safe for this PR's Wan target (replicated_qis never passed there, and it's never populated anywhere in-tree today), but LTX-2's non-VSA path carries the replicated-QKV plumbing and can selectSAGE_ATTN_THREE, so the contract is silently wrong for any future caller. Either preserve the per-tensor transpose for the replicated path, or assertreplicated_q is Nonein the sage3 path so a break is loud, not silent.
Minor
- No div-by-zero guard —
nvfp4_qat_config.py:201-202:weight_global_sf = (448*6) / weight_absmaxdivides by an unclamped reduced absmax; an all-zero tile givesinf/nan. A.clamp_min(eps)is cheap insurance. - Hardcoded path —
FastWan_QAD_TAEHV.py:53:DEFAULT_TAEHV_CHECKPOINT = "/root/taehv/taew2_1.pth"is environment-specific; default it to a repo-relative / env-driven value so the example runs out of the box.
Nits
- TAEHV docstring (
FastWan_QAD_TAEHV.py:3,21-25) references a siblingfp4_linear_wan2_1_1_3b.pyand apython fp4_linear_taehv_wan2_1_1_3b.pyusage line that don't match the actual filename (FastWan_QAD_TAEHV.py). - PR body "drops DiT resident memory ... by TODO MB" — fill in the number (it's the headline benefit of the weight-pop).
Verified positives: reusing the register_fake-backed fastvideo_fp4:: custom ops instead of the old @torch.compile + raw flashinfer is the correct graph-break fix (no silently-swallowed breaks); the weight-pop memory logic is sound (persistent=False buffers + _parameters.pop + grad=None + gc/empty_cache); the FASTVIDEO_DISABLE_ATTENTION_COMPILE env-gate defaults to the historical eager behavior; title matches the merge regex; pre-commit is green. CI microscope failures are uniform exit 124 timeouts consistent with the stale/conflicting base — rebase and re-run.
— Gob (@SolitaryThinker's AI reviewer).
…opping+torch compile+single level quant changes to nvfp4_qat_config
…LE_ATTENTION_COMPILE DistributedAttention.forward (and the VSA subclass) are hard-decorated with @torch.compiler.disable, which keeps attention out of the surrounding torch.compile graph unconditionally. That blocks the inference compile path even after the FP4 linear and SageAttention3 graph-break fixes land, since the attention forward itself can never be traced. Make the disable conditional on FASTVIDEO_DISABLE_ATTENTION_COMPILE: - unset / "1" / "true" (default): keep torch.compiler.disable — current behavior - "0" / "false" / "no" / "off": drop it so attention can fold into the graph The env var is read at import time (decorators are applied at class definition), which is the right granularity for the multiproc spawn path: each worker re-imports and inherits the parent's env. Co-authored-by: Loay Rashid <42599591+loaydatrain@users.noreply.github.com> Co-authored-by: Kaiqin Kong <k1kong@ucsd.edu> Co-authored-by: William Lin <SolitaryThinker@users.noreply.github.com>
… build build.sh auto-detected Blackwell (sm_120) and exported TORCH_CUDA_ARCH_LIST=12.0 without the arch-conditional 'a' suffix. CMake's AUTO gate for the attn_qat_infer (modified SageAttention3 FP4) kernels only matches 12.0a/120a/sm_120a, so fp4attn_cuda/fp4quant_cuda were silently skipped and the ATTN_QAT_INFER backend fell back to Flash Attention at runtime. Exporting the env var also bypassed CMake's local-GPU fallback that would otherwise have enabled them. Mirror the existing 9.0 -> 9.0a Hopper handling for 12.0 -> 12.0a. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
392e1f0 to
3ab6629
Compare
|
/merge |
Purpose
Continues the QAD 5090 stack (#1225). Inference-side companion to #1463 (FP4
QAT training): wires the FP4 linear path for Wan2.1 1.3B end-to-end under
torch.compile, pops the bf16 weight after FP4 packing, removesx_global_sf, and adds TAEHV scriptsWith Sage3, running in 2it/s, 1.78s e2e.
Changes
layers/quantization/nvfp4_qat_config.py(+loader/fsdp_load.py):inference path made
torch.compile-friendly — precomputedx_global_sf(no per-call sync), single-level quant, and the dense bf16 weight is popped
after FP4 pack (only
_fp4_weight+ scales survive).attention/backends/sage_attn3.py: removed data-dependent control flow ontensor metadata so the backend traces cleanly under
torch.compile.examples/inference/optimizations/fp4_linear_wan2_1_1_3b.pyandFastWan_QAD_TAEHV.py: end-to-end Wan2.1 1.3B inference with the FP4 linearpath + sage_attn3 +
torch.compile; the TAEHV variant swaps in the TAEHVdecoder.
No new CLI flags; reuses
--transformer-quant nvfp4_qatfrom #1463.Test results (RTX 5090 / sm_120)
--transformer-quant nvfp4_qatWan2.1 1.3B runs end-to-endwith
torch.compileenabled; sample outputvideo_samples/raccoon_fp4_linear_taehv_compile.mp4.first forward by TODO MB.
Depends on #1463 (
--transformer-quantCLI +nvfp4_qatconfig). Part of #1225.