From c947b33ef8f5bd23004118ab40debffbec2805ee Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 8 Jul 2026 15:12:00 +0000 Subject: [PATCH] Extend fp8 black-frame deny to HunyuanVideo-1.5; keep LTX-2 on fp8 Measured the fp8 DiT auto-quant path across the remaining dense-pipeline video families on B200 (production torch._scaled_mm per-row fp8, no MSLK): - HunyuanVideo-1.5 (480p + 720p repacks): every frame black (mean luma 0.0, LPIPS 0.82); int8 is clean (mean 102.7 vs dense 99.9). Same failure as Wan / qwen-image. - LTX-2: fp8 renders clean (mean 153.7, matches int8's 157.7) -- NOT a black-frame family. So deny fp8/mxfp8/nvfp4 for hunyuanvideo-1.5 and hunyuanvideo-1.5-720p (fall to int8), and deliberately leave LTX-2 on fp8. The deny stays measured per family, not a blanket video rule: a blanket deny would have wrongly forced LTX-2 off fp8. Adds a Hunyuan deny test that also asserts LTX-2 keeps fp8; 49/49 transformer-quant tests pass. video_speedmem_bench.py gains guidance_via_guider support (HunyuanVideo-1.5 sets CFG on a guider component and its __call__ takes no guidance_scale / callback_on_step_end), so the harness can drive Hunyuan the same way the loader does. --- scripts/video_speedmem_bench.py | 35 +++++++++++------- .../inference/diffusion_transformer_quant.py | 36 +++++++++++-------- .../tests/test_diffusion_transformer_quant.py | 12 +++++++ 3 files changed, 56 insertions(+), 27 deletions(-) diff --git a/scripts/video_speedmem_bench.py b/scripts/video_speedmem_bench.py index 7034d509b9..882d61a15d 100644 --- a/scripts/video_speedmem_bench.py +++ b/scripts/video_speedmem_bench.py @@ -310,7 +310,7 @@ def _apply_levers(pipe, cfg: dict, *, fam_name: str, fam_obj, force_fp32_vae: bo def _timed_video(pipe, *, steps, width, height, num_frames, guidance, seed, cache_mode, - dit_quant_active, default_steps, logger=None): + dit_quant_active, default_steps, guidance_via_guider=False, logger=None): """One clip generation. Re-checks FBCache per generation (maybe_toggle_step_cache) exactly like the loader, then times total + per-step. Returns (output, total_s, [per_step_ms]).""" import torch @@ -337,18 +337,26 @@ def _timed_video(pipe, *, steps, width, height, num_frames, guidance, seed, cach last[0] = now return kw + kwargs = dict( + prompt=PROMPT, width=width, height=height, num_frames=num_frames, + num_inference_steps=steps, generator=g, + ) + if guidance_via_guider: + # HunyuanVideo-1.5: CFG lives on a guider component and __call__ takes no + # guidance_scale / callback_on_step_end (the loader writes the scale onto pipe.guider). + guider = getattr(pipe, "guider", None) + if guider is not None and hasattr(guider, "guidance_scale"): + try: + guider.guidance_scale = guidance + except Exception: + pass + else: + kwargs["guidance_scale"] = guidance + kwargs["callback_on_step_end"] = _cb + _sync() t0 = time.perf_counter() - out = pipe( - prompt=PROMPT, - width=width, - height=height, - num_frames=num_frames, - num_inference_steps=steps, - guidance_scale=guidance, - generator=g, - callback_on_step_end=_cb, - ) + out = pipe(**kwargs) _sync() return out, (time.perf_counter() - t0), step_ts @@ -365,6 +373,7 @@ def _run_config(name: str, cfg: dict, *, family: str, steps: int, width: int, he guidance = spec.get("guidance", 5.0) fam_obj = detect_video_family(repo) default_steps = getattr(fam_obj, "default_steps", 50) + gvg = bool(getattr(fam_obj, "guidance_via_guider", False)) _empty(); _reset_peak() pipe = _build_pipe(repo, force_fp32) @@ -382,7 +391,7 @@ def _run_config(name: str, cfg: dict, *, family: str, steps: int, width: int, he _timed_video( pipe, steps=steps, width=width, height=height, num_frames=num_frames, guidance=guidance, seed=seed, cache_mode=cache_mode, dit_quant_active=dit_active, default_steps=default_steps, - logger=logger, + guidance_via_guider=gvg, logger=logger, ) _reset_peak() dts, steps_ms = [], [] @@ -391,7 +400,7 @@ def _run_config(name: str, cfg: dict, *, family: str, steps: int, width: int, he last_out, dt, st = _timed_video( pipe, steps=steps, width=width, height=height, num_frames=num_frames, guidance=guidance, seed=seed, cache_mode=cache_mode, dit_quant_active=dit_active, default_steps=default_steps, - logger=logger, + guidance_via_guider=gvg, logger=logger, ) dts.append(dt) steps_ms.append(_median(st) if st else 0.0) diff --git a/studio/backend/core/inference/diffusion_transformer_quant.py b/studio/backend/core/inference/diffusion_transformer_quant.py index 8c409fc77c..bf3a80cdd5 100644 --- a/studio/backend/core/inference/diffusion_transformer_quant.py +++ b/studio/backend/core/inference/diffusion_transformer_quant.py @@ -133,20 +133,23 @@ _AUTO_LADDER: tuple[tuple[tuple[int, int], tuple[str, ...]], ...] = ( # qwen-image + mxfp8 -> real semantic damage at 1024px (CLIP delta mean 0.0146, worst # cases 0.064 / 0.102 -- 2x the per-case bound). # qwen-image + nvfp4 -> LPIPS mean 0.51 vs bf16: unusable. -# wan2.2 + fp8 -> every frame black (mean luma 0.0000, LPIPS ~0.80 vs bf16), -# reproduced on B200 at 512x320 and 704x480 with the production -# torch._scaled_mm per-row fp8 path (no MSLK): the Wan DiT's -# activation outliers exceed per-row fp8's range, the same failure -# mode as qwen-image. int8 dynamic (per-token) is clean on Wan -# (non-black, correct contrast; First-Block-Cache engages normally -# instead of over-caching the degenerate black activations). -# int8 dynamic (per-token) is excellent on Qwen (LPIPS mean 0.069 / SSIM 0.958) and clean on -# Wan, so the auto ladder falls through to it. mxfp8 / nvfp4 are denied alongside fp8 on the -# Wan families conservatively (the same per-block scaled_mm family as the confirmed-black fp8, -# and mxfp8 is a prototype) so auto lands on the battle-tested int8; they can be re-enabled per -# family once separately validated in-bar, like the nvfp4 auto-ladder TODO. The deny also -# applies to an EXPLICIT request: a scheme that renders black frames has no legitimate use, and -# returning None gives the caller the same fallback contract as an unsupported scheme (GGUF). +# wan2.2 / hunyuanvideo-1.5 + fp8 -> every frame black (mean luma 0.0000, LPIPS ~0.80-0.82 +# vs bf16), reproduced on B200 with the production torch._scaled_mm +# per-row fp8 path (no MSLK): the DiT's activation outliers exceed +# per-row fp8's range, the same failure mode as qwen-image. int8 +# dynamic (per-token) is clean on both (non-black, correct contrast; +# First-Block-Cache engages normally instead of over-caching the +# degenerate black activations). +# NOTE this is NOT universal across video: ltx-2 + fp8 renders clean (mean 153.7, non-black) on +# the same stack, so it is deliberately NOT denied -- the deny is measured per family, not a +# blanket video rule. int8 dynamic (per-token) is excellent on Qwen (LPIPS mean 0.069 / SSIM +# 0.958) and clean on Wan / Hunyuan, so the auto ladder falls through to it there. mxfp8 / nvfp4 +# are denied alongside fp8 on the black-frame families conservatively (the same per-block +# scaled_mm family as the confirmed-black fp8, and mxfp8 is a prototype) so auto lands on the +# battle-tested int8; they can be re-enabled per family once separately validated in-bar, like +# the nvfp4 auto-ladder TODO. The deny also applies to an EXPLICIT request: a scheme that renders +# black frames has no legitimate use, and returning None gives the caller the same fallback +# contract as an unsupported scheme (GGUF build). _FAMILY_SCHEME_DENY: dict[str, frozenset[str]] = { "qwen-image": frozenset({TQ_FP8, TQ_MXFP8, TQ_NVFP4}), "qwen-image-edit": frozenset({TQ_FP8, TQ_MXFP8, TQ_NVFP4}), # same DiT + activations @@ -154,6 +157,11 @@ _FAMILY_SCHEME_DENY: dict[str, frozenset[str]] = { # 5B TI2V and the A14B MoE share the DiT class + activation profile, so both deny -> int8. "wan2.2-ti2v-5b": frozenset({TQ_FP8, TQ_MXFP8, TQ_NVFP4}), "wan2.2-t2v-a14b": frozenset({TQ_FP8, TQ_MXFP8, TQ_NVFP4}), + # HunyuanVideo-1.5 DiT (HunyuanVideo15Transformer3DModel): fp8 renders black frames (measured, + # LPIPS 0.82); int8 is clean. The 480p and 720p repacks share the DiT + activations, so both + # deny -> int8. (ltx-2 fp8 measures clean on the same stack, so it is intentionally absent.) + "hunyuanvideo-1.5": frozenset({TQ_FP8, TQ_MXFP8, TQ_NVFP4}), + "hunyuanvideo-1.5-720p": frozenset({TQ_FP8, TQ_MXFP8, TQ_NVFP4}), } diff --git a/studio/backend/tests/test_diffusion_transformer_quant.py b/studio/backend/tests/test_diffusion_transformer_quant.py index 2a6184ce47..b35bbc7a7d 100644 --- a/studio/backend/tests/test_diffusion_transformer_quant.py +++ b/studio/backend/tests/test_diffusion_transformer_quant.py @@ -575,6 +575,18 @@ def test_family_deny_refuses_explicit_fp8_for_wan(monkeypatch): assert select_transformer_quant_scheme(_target(), "int8", family = "wan2.2-ti2v-5b") == TQ_INT8 +def test_family_deny_auto_skips_fp8_for_hunyuan(monkeypatch): + # HunyuanVideo-1.5 DiT also renders black frames on fp8 (measured, LPIPS 0.82); both the + # 480p and 720p repacks deny fp8/mxfp8/nvfp4 and fall to int8. ltx-2 is NOT denied (its + # fp8 measures clean), so the deny is per family, not a blanket video rule. + _stub_torch(monkeypatch, cc = (10, 0)) + _allow(monkeypatch, {TQ_FP8, TQ_NVFP4, TQ_MXFP8, TQ_INT8}) + assert select_transformer_quant_scheme(_target(), "auto", family = "hunyuanvideo-1.5") == TQ_INT8 + assert select_transformer_quant_scheme(_target(), "auto", family = "hunyuanvideo-1.5-720p") == TQ_INT8 + # ltx-2 keeps the ladder head (fp8) -- it is not a black-frame family. + assert select_transformer_quant_scheme(_target(), "auto", family = "ltx-2") == TQ_FP8 + + def test_family_deny_no_family_keeps_ladder(monkeypatch): # Without a family (or an unknown one) the ladder is unchanged: fp8 first on B200. _stub_torch(monkeypatch, cc = (10, 0))