Wanderer X头像

MiniMax-H3(ref2va)架构与推理方法笔记

本文的"实测"指在单张 80 GB 显存卡、官方 diffusers 实现上跑出来的读数;"估算"会单独标注。未经实测的推断不写成结论。

1. 模型定位

MiniMax-H3 是联合生成视频与立体声音频的扩散模型。官方 diffusers 集成给出三个 workflow:

workflow条件权重分区
t2va纯文本transformer/
fl2va首帧 / 尾帧关键帧transformer/
ref2va图像、视频、音频等多模态参考transformer_ref/

同一个仓库里放两套 Transformer 分区,选哪个 workflow 就只加载对应的那一套。本文讨论的 ref2va 用的是 transformer_ref,这也是很多移植代码出错的地方:写死 pipe.transformer 会直接 AttributeError。

该 checkpoint 是引导蒸馏(guidance-distilled)的:引导强度已经烘进权重,所以没有 guider、没有 negative_prompt、没有 guidance_scale,每个去噪步只跑一次前向。这一点决定了后文的耗时与显存模型:不需要按 CFG 把前向数乘二。

2. 输入输出契约

帧率固定 24 fps,时长必须在 5–15 秒之间,帧数必须是视频 VAE 能编码的网格点:

N=17n+5,n∈{0,1,2,…},124≤N≤345 N = 17n + 5,\qquad n \in \{0,1,2,\ldots\},\qquad 124 \le N \le 345 N=17n+5,n∈{0,1,2,…},124≤N≤345

T=N24 s T = \frac{N}{24}\ \mathrm{s} T=24N​ s

上限 345345345 帧对应 14.37514.37514.375 秒。视频 VAE 的 clip_length = 17、tokens_chunk_size = 5,所以对齐规则就是"每 17 帧像素换 5 个 latent 帧,再加 2 个边界帧"。请求的帧数会被向上对齐到最近一个 17n+517n+517n+5,对齐后如果超出 15 秒会直接报错。

画布不是固定常量,而是由配置解析出来:

  • 短边从 768 起;
  • 面积上限 768×1344768 \times 1344768×1344;
  • 两条边都对齐到 32 的倍数,因为 VAE 空间压缩 16 倍、Transformer 的 patch 再 2 倍:16×2=3216 \times 2 = 3216×2=32;
  • 支持的宽高比范围是 1:4 到 4:1。

输出画布(生成目标的 height / width)可以另行指定,但参考视频的归一化画布与输出画布无关,这一点在第 7 节会看到,是显存天花板的根因。

最终产物是 h264 视频加 aac 音轨,采样率 32 kHz、立体声。官方导出接口是 diffusers.utils.export_utils.encode_video(..., fps=24, audio=..., audio_sample_rate=...)。

3. 组件构成

下面是从 checkpoint 配置里读到的确定值,不是按论文复述的近似:

组件类关键参数
transformer_ref / transformerMiniMaxH3Transformer3DModel50 层主干 + 2 层 refiner;hidden 5376;56 个注意力头 × 128;FFN 14336;patch (1,2,2)(1,2,2)(1,2,2);视频输入 24 通道;音频输入 32 通道;文本维度 5120;RoPE 频率维 16、theta 10000;qk-norm eps 10−510^{-5}10−5
text_encoderQwen3VLForConditionalGeneration文本侧 64 层、hidden 5120、64 头 × 128;视觉塔 27 层、hidden 1152、patch 16、空间合并 2、时间 patch 2;条件取第 50 层 hidden,而不是最后一层
vaeAutoencoderKLMiniMaxH33 通道进、24 通道 latent;空间压缩 16 倍;时间压缩 4 倍;6 个 block;解码器 36 层、32 头 × 64;latent 有逐通道 mean/std 归一化
audio_vaeAutoencoderKLMiniMaxH3Audio32 kHz 输入;下采样总倍数 800,即每秒 40 个 latent;latent 通道 32;编码器 rates 为 2/4/4/5/5
schedulerMiniMaxH3Scheduler视频 shift = 12.0
audio_schedulerMiniMaxH3Scheduler音频 shift = 3.0
processor / tokenizerQwen3VLProcessor / Qwen2TokenizerFast视觉与视频预处理,temporal_patch_size = 2、merge_size = 2

几个值得注意的对应关系:Transformer 的 24 通道视频输入正好等于视频 VAE 的 latent 通道数,32 通道音频输入正好等于音频 VAE 的 latent 通道数,文本维度 5120 正好等于 Qwen3-VL 的 hidden size。也就是说这不是"文本特征 + 视频 latent"的两个独立塔,而是把三种模态搬到同一宽度后拼进一条序列。

4. 打包序列:一次前向覆盖三种模态

H3 去噪的对象是一条打包序列,同时容纳文本条件、视觉条件 latent、音频 latent 与待生成的视频 latent。每个行带一个模态标签(modality tag):

video=0,text=1,audio=2 \mathrm{video} = 0,\qquad \mathrm{text} = 1,\qquad \mathrm{audio} = 2 video=0,text=1,audio=2

这些标签是 checkpoint 契约,用来索引 Transformer 里的 AdaLN 表,改动它们等价于换模型。视频行里,条件行排在前面、生成行排在后面;音频行同理,参考音轨的行排在前面。调度器每一步只写生成行,条件行在整个循环里保持不动,于是条件在构造上就不会被逐步侵蚀。

一次 Transformer 前向同时输出视频行的速度与音频行的速度;两个调度器在同一层调用里各自步进,而不是分两遍跑视频和音频。这解释了为什么 H3 的单步耗时明显高于同尺寸的纯视频模型:序列里同时装着视频、音频和大量条件行。

5. Token 预算:可以写死的精确式

把画布、帧数、参考尺寸代进去,序列长度基本可以提前算出来,这对显存与耗时规划非常有用。

latent 帧数。 官方 helper video_latent_num_frames 的定义与实测完全一致:

L(N)=5n+2=5N−2517+2 L(N) = 5n + 2 = \frac{5N - 25}{17} + 2 L(N)=5n+2=175N−25​+2

每个 latent 帧的 token 数。 先做 16 倍空间压缩,再用 2×22 \times 22×2 的 patch 切分,所以一个 token 覆盖 32×3232 \times 3232×32 个像素:

K(H,W)=HW1024 K(H,W) = \frac{HW}{1024} K(H,W)=1024HW​

例如输出 544×960544 \times 960544×960 时 K=510K = 510K=510;参考视频归一化到官方画布 768×1344768 \times 1344768×1344 时 K=1008K = 1008K=1008。

视频行数。 目标是每 latent 帧 510 个 token。参考视频同样被截断到生成时长,由实测 token 数可唯一反推出它也有 LLL 个 latent 帧、每帧 1008 个 token,因此:

ℓv=L(N)(HW1024+1008) \ell_v = L(N)\left(\frac{HW}{1024} + 1008\right) ℓv​=L(N)(1024HW​+1008)

音频行数。 每秒 40 个 latent,立体声拆成两段通道优先的块,每块一行,所以:

ℓa=2×40×N24=103N \ell_a = 2 \times 40 \times \frac{N}{24} = \frac{10}{3}N ℓa​=2×40×24N​=310​N

文本行数没有闭式:条件编码器以 2 fps 读取参考视频,Qwen3-VL 再把采样帧按时间 patch 两两合并成视觉块,每块前加时间戳标签,最后拼上 prompt;处理器还会按自己的像素预算做缩放。所以文本行只能实测,不能套公式。

四个实测点(同一段参考视频,分别截断到对应帧数)如下。视频行与音频行用上面的式子算出,文本行是总长减去前两项的残差:

帧数 Nlatent 帧 L视频行音频行文本行实测总长
1243756,1664138,62965,208
1925786,52664010,66097,826
29487132,06698013,677146,723
345102154,8361,15015,676171,662

345 帧这一行可以直接从运行记录里拿到三项分量的原始读数(video_tokens = 154836、audio_tokens = 1150、text_tokens = 15676、seq_len = 171662),三项相加与总长严格相等。这带来两个关键结论:

  • 视频行占整条序列约 90%;
  • 其中参考视频单独贡献 102×1008=102,816102 \times 1008 = 102{,}816102×1008=102,816 个 token,约占整条序列的 60%。

第二点就是后文"输出分辨率降得再低也会 OOM"的原因:参考视频的 token 数由官方画布决定,跟输出画布无关。

6. 推理流程(ref2va)

按官方模块顺序,一次请求分四段:

(1)几何与条件准备。 解析 num_frames 并对齐到 17n+517n+517n+5;按宽高比解析输出画布;把参考视频重采样到 24 fps、归一化到官方画布,并截断到生成时长;参考音轨同样截断到生成时长。

(2)条件编码。

  • 视觉参考(图像或视频)经视频 VAE 编码,取后验采样(固定 encode seed 42),转 float16 后再归一化,得到条件 latent 行。视觉条件不是以 t=1.0t=1.0t=1.0 的完全干净状态进入序列,而是保持在 t=0.999t = 0.999t=0.999,因为训练时锚点做过轻微加噪,用完全干净的输入反而偏离训练分布。
  • 参考音轨经音频 VAE 编码,取后验的众数(mode(),确定性,不采样),得到干净的音轨 latent 行,按 t=1.0t = 1.0t=1.0 固定在序列前部。
  • 文本侧构造"展示串":每个参考一个标签(<Picture i>:、<Audio j>:、<Video k>:),视频参考每 0.5 秒取一帧、成对合并成一个带时间戳的视觉块,最后接 prompt 原文。整串送进 Qwen3-VL,取第 50 层 hidden state,形状为 KaTeX parse error: Expected 'EOF', got '_' at position 14: (1, \text{num_̲text_tokens}, 5…。

(3)去噪。 按行布局生成 token_tags、position_ids 和三类行索引;视频与音频各自按自己的 shift 生成时间步网格;每一步做一次前向,同时得到视频速度与音频速度,再分别交给两个调度器更新。条件行始终不被写入,因此无需每步重新注入。

(4)解码与封装。 视频 latent 经 VAE 解码成 NNN 帧,音频 latent 经音频 VAE 解码成 32 kHz 立体声,然后用 ffmpeg 封装为 h264 + aac。

关于步数有一个容易踩的坑:调度器的时间步网格包含终点 0,所以 num_inference_steps = S 实际只执行 S−1S-1S−1 次前向。实测 S=2S=2S=2 时前向次数为 1,S=20S=20S=20 时为 19。如果按 SSS 估时间会高估约 5%,反过来按"每步一次"数显存则是安全的:显存只取决于激活,与步数无关。

7. 显存:两条官方卸载路线

官方给了两种把权重挤出显存的方案,实测差异极大(544×960544 \times 960544×960,单卡 80 GB,可用显存约 78.6 GiB):

帧数时长整组件自动卸载(manager)逐 block 分组卸载(group)
1245.17 s通过,峰值 72.7 / 75.1 GiB未逐一测
1928.00 s通过,峰值 78.2 / 78.7 GiB(已饱和)未逐一测
2269.42 sOOM未逐一测
29412.25 sOOM未逐一测
34514.375 sOOM通过,峰值 41.95 / 49.32 GiB

(峰值对为 max_memory_allocated / max_memory_reserved。)

manager 路线是 ComponentsManager.enable_auto_cpu_offload,按整个组件在 CPU 与 GPU 之间换入换出。它的天花板非常明确:192 帧刚好把卡填满,226 帧起 OOM,而且所有 OOM 都落在同一个位置:Transformer 第 0 个 block 里注意力前的 RMSNorm(attn.norm_q)。调大 memory_reserve_margin(12 GB 调到 20 GB)对这一现象没有作用,因为它只调整组件之间的换入时机,管不到一次前向内部的激活峰值。

group 路线是逐 block 流式换入:offload_type='block_level'、num_blocks_per_group=1,同一时刻卡上只有正在计算的那一个 block 加激活;文本编码器用 leaf_level,视频 VAE 与音频 VAE 常驻 GPU。345 帧下峰值降到 41.95 / 49.32 GiB,在 80 GB 卡上留出约 29 GiB 余量,这才是能稳定跑满 15 秒的配置。注意 ref2va 要作用在 transformer_ref 上,写成 transformer 会直接报错。

为什么 manager 的天花板是 8 秒而不是 15 秒。 因为参考视频在条件侧不可压缩:它被归一到 768×1344768 \times 1344768×1344,每 latent 帧 1008 个 token,而 544×960544 \times 960544×960 的输出每 latent 帧只有 510 个。把输出分辨率调低只会减少后者,参考那 1008 个 token 一个都不会少。345 帧时参考单独贡献 102,816 个 token、约占整条序列 60%,同时文本行也随参考时长增长。这也说明"降分辨率换更长时长"在 manager 路线下基本无效。

group 路线的代价是速度。 单次前向实测:124 帧 manager 模式 63 秒,192 帧 manager 模式 130 秒,345 帧 group 模式 286.4 秒。不同卸载模式的数字不能直接相比;group 模式多了逐 block 的 CPU 到 GPU 传输,换来的是显存。另外注意注意力对序列长度是二次的,用线性模型做时延规划在长序列上会偏乐观。

8. 多卡:为什么默认不用上下文并行

也试过把序列切到多卡做 Ulysses 上下文并行。2 卡跑 345 帧在第 1 步就 OOM(72.34 GiB 已分配、剩余 646.94 MiB,还差约 790 MiB);4 卡能跑,单步约 29.3 秒、单条约 10 分钟,但显存贴着上限,换调度器配置或换环境就容易再次触发。

所以生产形态选择的是一卡一进程而不是一卡一切片:每张卡各自用 group 卸载完整跑一条序列,进程之间用共享队列抢样本。这样每卡显存余量约 29 GiB,且单条样本失败只影响自己。关键工程点在于管线常驻:权重文件在磁盘上约 135 GB(fp32 存储),按 bf16 载入后常驻约 70.6 GiB;如果每条样本都重启进程重新加载,既重复付出加载时间,也会让这个内存峰值反复出现。常驻之后每个 worker 只读一次,瓶颈回到计算本身。

9. 宿主内存与进程编排

group 卸载把权重放在系统内存里,所以每进程的内存预算必须显式管理。实测:一个装满权重的官方管线,峰值常驻内存 70.6 GiB(加载 + 条件编码 + 一次去噪)。这个值明显低于磁盘上的 135 GB,因为权重是 fp32 存储、按 bf16 载入,常驻约为磁盘体积的一半再加激活与开销。预算按 80 GiB 留余量:

Mhost≥80⋅W GiB M_{host} \ge 80 \cdot W\ \mathrm{GiB} Mhost​≥80⋅W GiB

其中 WWW 是 worker 数。换句话说,4 个 worker 需要至少 320 GiB 宿主内存;给 256 GiB 时应该自动降到 3 个 worker 而不是强行起 4 个。

两个容易出错的细节:

  • 在容器里 /proc/meminfo 通常是按 cgroup 命名空间的,MemTotal 等于容器内存上限,所以 MemAvailable 直接就是本作业的预算,不需要再去读整机数据。
  • 如果输出目录在 NFS 上且挂载带 nolock,那么基于 flock 的锁只能协调同一个作业内的进程,不能当跨作业的互斥使用。本文的"加载门"就是按"同作业内串行"设计的:每个 worker 在加载管线前先拿锁,加载完再放锁,于是第 kkk 个 worker 读内存预算时,前 k−1k-1k−1 份权重已经真实驻留,读数不再是并发的陈旧快照。

三层显存防线。 第一层是实测余量:最坏情况峰值 49.32 GiB(保留),离 80 GB 还很远。第二层是启动前预检:每张选定卡要求至少 55,000 MiB 空闲,不满足就打印读数并拒绝启动,而不是加载到一半再炸。第三层是运行时兜底:一旦某张卡真的 OOM,写出暂停标记并停掉该作业的全部 worker,重启必须显式确认;这样不会在同一个配置上无限重试把队列耗光。

10. 性能模型与容量规划

实测基线(345 帧,group 卸载,单卡):一次前向 286.4 秒;一次前向的单样本端到端 445–447 秒(含参考编码、文本编码、VAE 与音频解码);mp4 编码 3.9 秒;输出校验为 960×544960 \times 544960×544、345 帧、24/1、14.375 秒,音轨存在。

由此可写出规划用的估算式。单次前向以 345 帧的实测值为锚点做线性近似:

teval(N)≈0.82N+3.2 s t_{eval}(N) \approx 0.82N + 3.2\ \mathrm{s} teval​(N)≈0.82N+3.2 s

单样本总时为前向数乘单次前向,加上与帧数弱相关的编解码开销:

Tsample(N,S)≈(S−1) teval(N)+tio,tio≈159 s T_{sample}(N,S) \approx (S-1)\,t_{eval}(N) + t_{io},\qquad t_{io} \approx 159\ \mathrm{s} Tsample​(N,S)≈(S−1)teval​(N)+tio​,tio​≈159 s

S=20S = 20S=20 时 Tsample(345,20)≈19×286.4+159≈5600T_{sample}(345, 20) \approx 19 \times 286.4 + 159 \approx 5600Tsample​(345,20)≈19×286.4+159≈5600 秒,约 1.56 小时一条。这是估算:20 步没有逐条实测过,而且线性模型在小帧数上明显高估(124 帧 manager 模式实测单次前向只有 63 秒)。

批量规划直接求和:

Hgpu≈13600∑iTsample(Ni,S) H_{gpu} \approx \frac{1}{3600}\sum_i T_{sample}(N_i, S) Hgpu​≈36001​i∑​Tsample​(Ni​,S)

例如 100 条 345 帧、20 步的样本约需 156 GPU·小时,4 卡并行约 39 小时。实际排产时还要考虑非 345 帧样本更短、以及失败重试的余量。

11. 复现清单

最小可跑代码(官方 API,权重路径按实际替换):

import torch
from diffusers import ModularPipeline
from diffusers.hooks import apply_group_offloading
from diffusers.modular_pipelines.minimax_h3 import (
    MiniMaxH3ImageReference,
    MiniMaxH3VideoReference,
)

pipe = ModularPipeline.from_pretrained(
    "/path/to/MiniMax-H3-ref2va", workflow="ref2va"
)
pipe.load_components(dtype=torch.bfloat16)

device = torch.device("cuda")
stream = dict(onload_device=device, offload_device=torch.device("cpu"), use_stream=False)
transformer = pipe.transformer_ref          # ref2va 只加载这个分区
transformer.requires_grad_(False)
transformer.enable_group_offload(
    offload_type="block_level", num_blocks_per_group=1, **stream
)
apply_group_offloading(pipe.text_encoder.model, offload_type="leaf_level", **stream)
pipe.vae.to(device)
pipe.audio_vae.to(device)

result = pipe(
    prompt=prompt,
    references=references,                  # MiniMaxH3ImageReference / MiniMaxH3VideoReference
    num_frames=345, height=544, width=960,
    num_inference_steps=20,
    generator=torch.Generator(device="cpu").manual_seed(42),
    output=["videos", "audio", "sampling_rate"],
)

建议同时设置 PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True,并在加载前后各读一次 torch.cuda.max_memory_allocated 与 max_memory_reserved,把峰值写进运行记录,方便下次规划直接引用。

验证产物不要只看文件是否存在:用 ffprobe -count_frames 核对帧数、帧率、时长,再用 ffmpeg -f null - 做一次全量解码。只要其中一个不一致,就当这次生成失败,不要把它记成完成。

12. 常见坑

  • 把 transformer_ref 写成 transformer:ref2va 下会直接 AttributeError。
  • 用 num_frames 不向上对齐:VAE 解码会报帧数不可编码。
  • 画布不按 32 对齐:空间压缩加 patch 之后得不到整数行数。
  • 以为降输出分辨率能换更长时长:参考视频的 token 数由官方画布决定,几乎不变。
  • 用 num_inference_steps 直接当"前向次数":终点 0 也在网格里,实际是 S−1S-1S−1 次。
  • 用整组件自动卸载去跑 15 秒:8 秒附近就饱和了。
  • 多进程各自重新加载权重:代价是每样本重读百 GB 级权重,必须常驻复用。

13. 边界与未验证项

  • group 卸载只在 345 帧这一最坏点上实测了峰值,124 到 328 帧没有逐点测量,不能假设峰值随帧数单调,也不能把 49.32 GiB 当作其它配置的保证。
  • 20 步的整样本耗时与 GPU·小时总量都是外推,不是实测。
  • 文本行的 token 数没有闭式表达,取决于参考视频长度、分辨率和处理器的像素预算,只能按样本实测。
  • 多卡上下文并行只在"能跑但贴边"的意义上验证过,没有做稳定性长跑。
  • 音画对齐、参考保持度等主观质量只做过人工抽检,没有量化评测。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/wandererXX/article/details/169168919

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--