unsloth/scripts/hunyuan_int8_profile.py
Daniel Han e58f30be5f Run video DiT dense+compile when it fits instead of a slower int8 fallback
Root-caused "HunyuanVideo-1.5 int8 is slower than dense" with a per-forward profiler
(scripts/hunyuan_int8_profile.py, dynamo-reset, back-to-back on a clean B200): int8 compiles
cleanly (0 recompiles, 0 graph breaks, steady 268.3 ms/forward) and is only ~7% slower than dense
+ regional compile (250.5 ms/forward), not the 38% a contended-GPU bench run suggested. int8 is
also less accurate (LPIPS 0.085 vs dense+compile 0.037). So for a family where fp8 is denied
(Hunyuan black-frames on per-row fp8), int8 is a MEMORY lever, not a speed win, yet the auto-quant
default quantised it even when the dense DiT already fit resident.

Fix: is_int8_memory_fallback(target, family) is True only when AUTO quant lands on int8 as a
denied/black-frame fallback on a data-center, fp8-capable GPU (fp8 would be the arch pick but is
denied for the family). The video loader now skips the auto-quant and runs dense+compile when that
holds AND the bf16 memory plan already fits resident (offload_policy == none), so there is no new
OOM risk. Scoped tightly: only an AUTO request (explicit int8/fp8 honored), only int8-fallback
families (Wan / LTX resolve to fp8 -> keep quantising), only data-center fp8-capable parts (consumer
GPUs and pre-Ada, where int8 is a genuine accelerator, keep int8), and only when dense provably
fits; a memory-constrained plan still quantises. Result: Hunyuan on a resident-fit B200 now runs
faster AND more accurate, quantising only when memory is the constraint.

Also resets dynamo per config in the video bench (so compiled graphs cannot leak across configs in
one process) and adds the per-forward profiler used for the diagnosis.
2026-07-09 02:31:22 +00:00

155 lines
5.8 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Why is HunyuanVideo-1.5 int8 DiT slower than dense+compile? Per-DiT-forward timing + dynamo
recompile/graph-break counting for dense vs int8, both under the loader's regional block compile.
Signal:
- steady-state per-forward time uniformly higher on int8 -> inherent int8 quant/dequant overhead
(memory lever, not speed) -> memory-gate the auto-quant.
- erratic slow forwards / high recompile count / eager fallback -> a fixable compile inefficiency
(int8 tensor subclass breaks dynamic-shape compile) -> fix the compile path.
Run: CUDA_VISIBLE_DEVICES=3 python scripts/hunyuan_int8_profile.py --modes dense,int8
"""
from __future__ import annotations
import argparse
import os
import sys
import time
from pathlib import Path
os.environ.setdefault("BITSANDBYTES_NOWELCOME", "1")
os.environ.setdefault("HF_HOME", "/mnt/disks/unslothai/ubuntu/workspace_81/BACKUP_05/temp/hf_cache")
_REPO_ROOT = Path(__file__).resolve().parent.parent
for _p in (str(_REPO_ROOT / "studio" / "backend"), str(_REPO_ROOT / "scripts")):
if _p not in sys.path:
sys.path.insert(0, _p)
from video_speedmem_bench import _build_pipe, _apply_levers, _target, PROMPT # noqa: E402
_REPO = "hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v"
def _dynamo_counts():
"""(recompiles, graph_breaks, unique_compiles) from dynamo counters, best-effort."""
try:
from torch._dynamo.utils import counters
rc = sum(v for k, v in counters.get("recompiles", {}).items()) if "recompiles" in counters else 0
gb = sum(counters.get("graph_break", {}).values())
# total frames compiled
stats = counters.get("stats", {})
uc = stats.get("unique_graphs", 0)
return rc, gb, uc
except Exception:
return -1, -1, -1
def _profile_mode(mode: str, *, steps: int, width: int, height: int, num_frames: int, guidance: float,
seed: int):
import torch
from core.inference.video_families import detect_video_family
from core.inference.diffusion_cache import maybe_toggle_step_cache # noqa: F401
# fresh dynamo state per mode
try:
torch._dynamo.reset()
from torch._dynamo.utils import counters
counters.clear()
except Exception:
pass
fam_obj = detect_video_family(_REPO)
gvg = bool(getattr(fam_obj, "guidance_via_guider", False))
default_steps = getattr(fam_obj, "default_steps", 50)
pipe = _build_pipe(_REPO, False)
cfg = dict(te="none", vae="none", dit=("int8" if mode == "int8" else "none"),
speed="default", attn="native", cache="off")
engaged = _apply_levers(pipe, cfg, fam_name="hunyuanvideo-1.5", fam_obj=fam_obj,
force_fp32_vae=False, default_steps=default_steps)
print(f"[{mode}] dit_scheme={engaged['dit'] or 'dense'} speed={engaged.get('_effective_speed')}",
flush=True)
# per-forward GPU timing via cuda events on the transformer.
fwd_ms: list[float] = []
starts: list = []
def _pre(mod, args, kwargs):
ev = torch.cuda.Event(enable_timing=True)
ev.record()
starts.append(ev)
return None
def _post(mod, args, output):
end = torch.cuda.Event(enable_timing=True)
end.record()
torch.cuda.synchronize()
if starts:
fwd_ms.append(starts[-1].elapsed_time(end))
h1 = pipe.transformer.register_forward_pre_hook(_pre, with_kwargs=True)
h2 = pipe.transformer.register_forward_hook(_post)
def _gen(tag):
fwd_ms.clear()
starts.clear()
g = torch.Generator(device="cuda").manual_seed(seed)
kwargs = dict(prompt=PROMPT, width=width, height=height, num_frames=num_frames,
num_inference_steps=steps, generator=g)
if gvg:
guider = getattr(pipe, "guider", None)
if guider is not None and hasattr(guider, "guidance_scale"):
guider.guidance_scale = guidance
else:
kwargs["guidance_scale"] = guidance
torch.cuda.synchronize()
t0 = time.perf_counter()
pipe(**kwargs)
torch.cuda.synchronize()
total = time.perf_counter() - t0
rc, gb, uc = _dynamo_counts()
n = len(fwd_ms)
srt = sorted(fwd_ms)
med = srt[n // 2] if n else 0.0
p10 = srt[max(0, n // 10)] if n else 0.0
p90 = srt[min(n - 1, (9 * n) // 10)] if n else 0.0
slow = sum(1 for x in fwd_ms if x > 2.0 * med) if med else 0
print(f"[{mode}:{tag}] total={total:.2f}s n_fwd={n} med={med:.1f}ms "
f"p10={p10:.1f} p90={p90:.1f} max={max(fwd_ms) if fwd_ms else 0:.1f} "
f"slow(>2x med)={slow} | recompiles={rc} graph_breaks={gb} unique_graphs={uc}",
flush=True)
return total
_gen("warmup") # pays compile
_gen("timed")
_gen("timed2")
h1.remove(); h2.remove()
del pipe
import gc
gc.collect()
torch.cuda.empty_cache()
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("--modes", default="dense,int8")
ap.add_argument("--steps", type=int, default=30)
ap.add_argument("--num-frames", type=int, default=25)
ap.add_argument("--width", type=int, default=512)
ap.add_argument("--height", type=int, default=320)
ap.add_argument("--guidance", type=float, default=6.0)
ap.add_argument("--seed", type=int, default=42)
args = ap.parse_args(argv)
for mode in [m.strip() for m in args.modes.split(",") if m.strip()]:
_profile_mode(mode, steps=args.steps, width=args.width, height=args.height,
num_frames=args.num_frames, guidance=args.guidance, seed=args.seed)
return 0
if __name__ == "__main__":
raise SystemExit(main())