t2va.py 12.7 KB
#!/usr/bin/env python
"""MiniMax-H3 FL2VA (NF4) Text -> Video+Audio on RTX 2080 Ti (Turing / sm_75).

Turing deltas vs the official example (all deliberate):
  * FP16 everywhere, never BF16 -- sm_75 has no bf16 SDPA kernel at all
    (EFFICIENT and FLASH both report "No available kernel"), so bf16 silently
    falls back to the MATH backend and OOMs on a ~14k-token sequence.
  * DIFFSYNTH_ATTENTION_IMPLEMENTATION=torch -- FlashAttention 2/3/4 and
    SageAttention are sm_80+. xformers works here but would be auto-selected
    ahead of torch; pin the backend so runs are reproducible.
  * vram_limit from mem_get_info()[0] (FREE) not [1] (TOTAL) -- other processes
    share these GPUs.
  * offload to "cpu", never "disk" -- /data is a 7200rpm HDD (184 MB/s).
"""
import os, sys, time, argparse

BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
os.environ.setdefault("DIFFSYNTH_ATTENTION_IMPLEMENTATION", "torch")
os.environ.setdefault("DIFFSYNTH_MODEL_BASE_PATH", os.path.join(BASE, "models"))

p = argparse.ArgumentParser()
p.add_argument("--prompt", default="A girl is very happy, she is speaking in english: "
                                   "“I enjoy working with Diffsynth-Studio, it's a perfect framework.”")
p.add_argument("--height", type=int, default=480)
p.add_argument("--width",  type=int, default=832)
p.add_argument("--frames", type=int, default=124)
p.add_argument("--steps",  type=int, default=4)
p.add_argument("--flow-shift", type=float, default=None, help="default 6.0 with turbo, 12.0 without")
p.add_argument("--seed",   type=int, default=0)
p.add_argument("--turbo",  action="store_true", default=True)
p.add_argument("--no-turbo", dest="turbo", action="store_false")
p.add_argument("--dtype", choices=["fp32","fp16"], default="fp32",
               help="fp32=唯一能在 sm_75 上出片的选择(默认);fp16 只用于复现全黑故障")
p.add_argument("--no-fp32-residual", dest="fp32_residual", action="store_false",
               help="关闭 DiT 残差流的 fp32 兜底(默认开启;关掉在 sm_75 上会出全黑视频)")
p.add_argument("--no-fp32-cond", dest="fp32_cond", action="store_false",
               help="关闭 condition_proj 的 fp32 兜底(默认开启;关掉在 sm_75 上会出全黑视频)")
p.add_argument("--fp32-accum", action="store_true",
               help="disable fp16 reduced-precision reduction (~2x slower, use if output is NaN/artifacted)")
p.add_argument("--vram-reserve", type=float, default=None,
               help="留给激活的显存(GB)。默认按 dtype 自适应:fp32=14(实测全分辨率峰值 12.2G),fp16=5")
p.add_argument("--out", default=None)
args = p.parse_args()

import torch
if args.fp32_accum:
    torch.backends.cuda.matmul.allow_fp16_reduced_precision_reduction = False

from diffsynth.pipelines.minimax_h3_audio_video import MiniMaxH3Pipeline, ModelConfig
from diffsynth.utils.data.audio_video import write_video_audio

# dtype 决定这条线能不能出片。实测(2026-08-18, sm_75, 448x256x28f, 唯一变量是 dtype):
#   fp32 -> 视频 std=50.6、音频 finite ✅        fp16 -> 124 帧纯黑、音频 NaN ❌
# 原因是 fp16 的动态范围装不下这个 bf16 训练的模型:dit.blocks.0 输入 absmax 89
# 已放大到 38176,50 层残差累积后必然突破 fp16 上限 65504。
# bf16 在 sm_75 上不可用(无硬件支持),所以 fp32 是这张卡上唯一能出片的选择。
# 保留 --dtype fp16 只为复现/对照,不要用它出片。
DTYPE = {"fp32": torch.float32, "fp16": torch.float16}[args.dtype]
free_gb = torch.cuda.mem_get_info("cuda")[0] / 1024**3  # [0]=free, official example wrongly uses [1]=total
if args.vram_reserve is None:
    # fp32 的激活是 fp16 的两倍,权重要多让位。实测 832x480x124f fp32:
    # vram_limit 7.3G 时峰值 12.2G,跑通;vram_limit 19.3G 时 OOM。
    args.vram_reserve = 14.0 if DTYPE is torch.float32 else 5.0
vram_limit = max(4.0, free_gb - args.vram_reserve)
if vram_limit + 1.0 > free_gb:
    print(f"[warn] 空闲显存仅 {free_gb:.1f}G,vram_limit={vram_limit:.1f}G 几乎没有余量,"
          f"很可能 OOM。先停掉占显存的服务(见 qwen38-deploy/RESTORE-GPU.md)", flush=True)

prop = torch.cuda.get_device_properties(0)
print(f"[env] GPU={prop.name} sm_{prop.major}{prop.minor}  free={free_gb:.1f}G  vram_limit={vram_limit:.1f}G", flush=True)
print(f"[env] dtype={DTYPE}  attention={os.environ['DIFFSYNTH_ATTENTION_IMPLEMENTATION']}  "
      f"fp16_reduced_accum={torch.backends.cuda.matmul.allow_fp16_reduced_precision_reduction}", flush=True)
assert prop.major == 7, "this script is tuned for Turing; re-check dtype choices on other archs"

vram_config = dict(
    offload_dtype=DTYPE,   offload_device="cpu",     # not "disk": /data is a spinning HDD
    onload_dtype=DTYPE,    onload_device="cpu",
    preparing_dtype=DTYPE, preparing_device="cuda",
    computation_dtype=DTYPE, computation_device="cuda",
)
NF4 = "DiffSynth-Studio/MiniMax-H3-NF4"

t0 = time.time()
pipe = MiniMaxH3Pipeline.from_pretrained(
    torch_dtype=DTYPE, device="cuda",
    model_configs=[
        ModelConfig(model_id=NF4, origin_file_pattern="minimax-h3-fl2va-nf4.safetensors",        **vram_config),
        ModelConfig(model_id=NF4, origin_file_pattern="minimax-h3-text-encoder-nf4.safetensors", **vram_config),
        ModelConfig(model_id=NF4, origin_file_pattern="video_vae_nf4.safetensors",               **vram_config),
        ModelConfig(model_id=NF4, origin_file_pattern="audio_vae_nf4.safetensors",               **vram_config),
    ],
    processor_config=ModelConfig(model_id="MiniMax/MiniMax-H3", origin_file_pattern="FL2VA/processor/"),
    vram_limit=vram_limit,
)
print(f"[load] pipeline ready in {time.time()-t0:.0f}s", flush=True)

if args.turbo:
    # LoRA rides on the NF4 layers: AutoWrappedQuantizedModule inherits LoRAHotLoadMixin,
    # so lora_forward() adds x@A@B to the quantized output -- no base dequantisation needed.
    pipe.load_lora(pipe.dit, ModelConfig(
        model_id="lightx2v/Minimax-h3-Turbo",
        origin_file_pattern="minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors"))
# --- sm_75 必需:condition_proj 在 fp16 下累加溢出 -> inf -> 全管线 NaN -> 全黑视频 ---
# 实测(2026-08-18):该层输入 absmax≈17808、权重 absmax≈4.66、K=5120,
# fp16 累加器(上限 65504)有 0.04% 的输出溢出成 inf,随后污染下游 999 个模块。
# 它只是 (1,5120)x(5376,5120) 的一次投影,改走 fp32 的算力开销可忽略。
# 注意:--fp32-accum 对此无效——溢出发生在累加器量级,不是 reduced-precision reduction。
def _force_fp32_linear(mod, tag):
    """该层在 fp32 里算,且**输出保持 fp32**流入 token_refiner。

    实测(2026-08-18, sm_75):
      文本编码器输出 absmax 15328(有限、健康,源头不在它);
      condition_proj 的 fp32 真值 absmax 可达 79554,而 fp16 上限 65504。
      产生的 inf 会污染下游 2985 个模块,最终 124 帧纯黑(且原脚本退出码为 0)。
    根因是 Turing 无 bf16:模型按 bf16 训练(bf16 有 fp32 的动态范围),
    fp16 装不下这条条件路径。

    走过的四条弯路,都记下来免得后人重走:
      1) 替换整个 forward —— 绕过 preparing/onload 换入机制。
      2) 只换 linear_forward、最后仍 `.to(fp16)` —— 无效:fp32 算得对,
         溢出发生在那次类型转换上。自检若只查转换前的张量会永远显示"正常"。
      3) 钳位到 65504 再降回 fp16 —— **反而更糟**:token_refiner.blocks.0 是
         `h = x + attn(norm1(x))`,x 恰好等于 fp16 最大可表示值时,
         再加任何正数立刻变 inf(实测 3.45% 非有限)。钳位不留余量等于自造故障。
      4) 输出保持 fp32 但 vram_limit=19.3G —— 数值对了但 CUDA OOM。
         配 `--vram-reserve 5`(vram_limit 16.3G) + PYTORCH_ALLOC_CONF=expandable_segments:True
         后峰值 17.2G,有余量。

    库里 _embed() 在 token_refiner 之后本来就有 `text_embed.to(dtype)`,
    那时已归一化、量级回落,那次转换是安全的。
    下游 RMSNorm 会因 dtype 不一致发性能警告(非错误),可忽略。
    """
    mod.computation_dtype = torch.float32
    orig = mod.forward
    def fwd(x, *a, **kw):
        out = orig(x.float(), *a, **kw)
        if not torch.isfinite(out).all():
            print(f"[warn] {tag} 即使 fp32 仍非有限", flush=True)
        return out          # 保持 fp32:既不降回也不钳位
    mod.forward = fwd
    return mod

# --- sm_75 必需 #2:DiT 残差流保持 fp32 ---
# 实测(2026-08-18):dit.blocks.0 输入 absmax 89.25 → 输出 38176,且有极少量元素溢出。
# 50 个 block × 每个 2 次残差相加 = 100 个溢出点,累积后必然爆 fp16 的 65504。
#
# 病灶与 condition_proj 同构,见 minimax_h3_dit.py:61-67:
#   def _modulate_gate(x, gate, other, indices):
#       return (x + gate.index_select(0, indices) * other).to(x.dtype)
#                                                           ^^^^^^^^^^ 提升到 fp32 算完又降回 fp16
# 库作者自己的注释也写了 "index_select on the AdaLN params can promote the expression",
# 但把结果降回 fp16 恰恰丢掉了提升带来的动态范围。
#
# 做法是标准混合精度:**累加器(残差流)fp32,喂给子模块的张量仍 fp16**。
#   _modulate_gate        -> 返回 fp32(它的输出就是残差流,下游是 RMSNorm,容忍混合 dtype)
#   _modulate_scale_shift -> 显式返回 fp16(它的输出喂给 attn/mlp 的 Linear4bit,必须是 fp16)
# 额外显存约 300 MB(残差流 (seq,5376) 一份 fp32),实测峰值仍在 16 GB 以内。
def _patch_dit_residual_fp32():
    from diffsynth.models import minimax_h3_dit as _m

    def _mss(x, shift, scale, indices):
        return (x.float() * (1.0 + scale.index_select(0, indices).float())
                + shift.index_select(0, indices).float()).to(torch.float16)

    def _mg(x, gate, other, indices):
        # 不降回 fp16:这一份就是残差流
        return x.float() + gate.index_select(0, indices).float() * other.float()

    _m._modulate_scale_shift = _mss
    _m._modulate_gate = _mg
    print("[fix ] DiT 残差流已切到 fp32(_modulate_gate 不再降回 fp16)", flush=True)

# 这两个补丁是 fp16 专用的挽救措施(最终证明救不回来,见 README)。
# fp32 下必须失效,否则强制 .to(float16) 会与 fp32 权重冲突:
#   RuntimeError: mat1 and mat2 must have the same dtype, but got Half and Float
if args.fp32_residual and DTYPE is torch.float16:
    _patch_dit_residual_fp32()

if args.fp32_cond and DTYPE is torch.float16:
    _force_fp32_linear(pipe.dit.condition_proj, "dit.condition_proj")
    print("[fix ] dit.condition_proj 已切到 fp32 计算(规避 sm_75 fp16 累加溢出)", flush=True)

flow_shift = args.flow_shift if args.flow_shift is not None else (6.0 if args.turbo else 12.0)

print(f"[gen ] {args.width}x{args.height} {args.frames}f steps={args.steps} "
      f"turbo={args.turbo} flow_shift={flow_shift} seed={args.seed}", flush=True)
t1 = time.time()
video, audio = pipe(
    prompt=args.prompt,
    height=args.height, width=args.width, num_frames=args.frames,
    num_inference_steps=args.steps, seed=args.seed, flow_shift=flow_shift,
)
gen_s = time.time() - t1

import torch as _t
_bad = []
if not _t.isfinite(audio if _t.is_tensor(audio) else _t.tensor(0.)).all():
    _bad.append("audio 含非有限值")
# video 是 PIL 帧列表;原脚本从不检查它,导致全黑视频也返回 0 —— 实测踩过
def _video_stats(v):
    import numpy as _np
    if _t.is_tensor(v):
        a = v.float()
        return float(a.mean()), float(a.std()), bool(_t.isfinite(a).all())
    try:
        arrs = [_np.asarray(f.convert("RGB"), dtype=_np.float32) for f in v[:: max(1, len(v)//8)]]
        a = _np.stack(arrs)
        return float(a.mean()), float(a.std()), True
    except Exception:
        return None, None, True
_mean, _std, _fin = _video_stats(video)
if not _fin:
    _bad.append("video 含非有限值")
if _std is not None and _std < 1.0:
    _bad.append(f"video 近乎纯色 (std={_std:.3f}, mean={_mean:.3f})")
if _bad:
    print("[FAIL] " + ";".join(_bad), flush=True)

out = args.out or os.path.join(BASE, "outputs", f"t2va_{time.strftime('%Y%m%d-%H%M%S')}.mp4")
os.makedirs(os.path.dirname(out), exist_ok=True)
write_video_audio(video=video, audio=audio, output_path=out, fps=24, audio_sample_rate=32000)

print(f"[done] generation {gen_s:.0f}s ({gen_s/max(args.steps,1):.1f}s/step), "
      f"peak VRAM {torch.cuda.max_memory_allocated()/1024**3:.1f}G", flush=True)
print(f"[out ] {out}  ({os.path.getsize(out)/1024**2:.1f} MB)", flush=True)
if _bad:
    sys.exit(3)   # 不要用退出码 0 报告一个坏产物