[Model][Feat] MiniMax H3 online FP8 support (#5737)

Signed-off-by: mglyn <1203789601@qq.com>
This commit is contained in:
Mu GuanLin authored and GitHub committed 2026-08-04 11:35:01 +00:00
1 parent 5215e03a91
commit b9a51592f0
8 files changed
+742 -62

No files matched your search

+7 -1
View File
@@ -483,12 +483,18 @@ steps:
- pytest -sv tests/diffusion/offloader -m "full_model and cuda"
mirror_hardwares: l4_1
- label: ":full_moon: Diffusion · Quantization Test with H100"
- label: ":full_moon: Diffusion · Quantization Test with H100 · Single-GPU"
timeout_in_minutes: 60
commands:
- pytest -sv tests/diffusion/quantization -m 'full_model and cuda and H100' --run-level "full_model"
mirror_hardwares: h100_1
- label: ":full_moon: Diffusion · MiniMax H3 FP8 Quality Test with H100 · 2-GPU"
timeout_in_minutes: 60
commands:
- pytest -sv tests/diffusion/models/minimax_h3/test_minimax_h3_quantization_quality.py -m 'full_model and cuda and H100' --run-level "full_model"
mirror_hardwares: h100_2
- label: ":full_moon: Diffusion · Quantization Test with L4"
timeout_in_minutes: 60
commands:
+1
View File
@@ -93,6 +93,7 @@ warmup_quack_fp8([(14040, 2048, 6144), (14040, 2048, 2048)])
| HunyuanImage-3.0 | `tencent/HunyuanImage-3.0`, `tencent/HunyuanImage-3.0-Instruct` | Yes | Yes | All layers; use the Hunyuan stage config for multi-stage runs | None | |
| HunyuanVideo-1.5 | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v`, `720p_t2v`, `480p_i2v` | Yes | Yes | All layers | None | |
| Cosmos3 | `nvidia/Cosmos3-Nano`, `nvidia/Cosmos3-Super` | Yes | Not validated | All layers | None | |
| MiniMax H3 | `MiniMaxAI/MiniMax-H3` | Yes | Not validated | DiT linears except patch, timestep, and final projections; not compatible with layerwise offload | None | |
### Multi-Stage Omni/TTS Model (Qwen3-Omni, Qwen3-TTS)
+45
View File
@@ -164,6 +164,42 @@ command with:
export MODEL="${MODEL_ROOT}/Ref2VA"
```
### Online FP8 quantization
MiniMax H3 supports load-time FP8 quantization of the DiT. The checkpoint
remains BF16 on disk; vLLM-Omni quantizes eligible weights while loading and
uses dynamic activation scaling during inference. By default, attention and
MLP linears in the token refiner and main DiT blocks, the condition
projection, and all AdaLN projections use FP8. Patch, timestep, and final
projections remain FP32; the text encoder and VAEs are unchanged.
Add this option to an existing H3 server command:
```bash
--quantization fp8
```
Use `ignored_layers` to keep any otherwise eligible linear in BF16. H3
resolves the `transformer` component before constructing the DiT, so names do
not start with `transformer.`. Entries are exact runtime linear prefixes; a
parent name such as `blocks.0.attn` does not exclude its children.
Eligible names are the `attn.qkv_proj`, `attn.out_proj`, `mlp.fc1`, and
`mlp.fc2` children under `token_refiner.blocks.<0-1>` or `blocks.<0-49>`.
The other eligible names are `condition_proj`,
`blocks.<0-49>.adaln_proj.linear`, and `final_layer.adaln_proj.linear`.
For example, keep the first main block's attention projections in BF16 with:
```bash
--diffusion-quantization-config \
'{"transformer":{"method":"fp8","ignored_layers":["blocks.0.attn.qkv_proj","blocks.0.attn.out_proj"]}}'
```
The structured option replaces `--quantization fp8`. Online FP8 is currently
incompatible with H3 layerwise offload because the offload path produces a
weight stride rejected by the Cutlass FP8 kernel. Use resident FP8 with tensor
parallelism and VAE tiling instead.
## HTTP API examples
The following requests use the synchronous endpoint so the returned body can
@@ -310,12 +346,21 @@ throughput guarantee. Multi-video Ref2VA is much slower because the two
reference videos expand both the Qwen3-VL vision sequence and the packed DiT
attention sequence.
## Validated FP8 evidence
With eager DiT/text-encoder TP2 and VAE tiling, the 384x672, 107-frame,
10-step quality case measured LPIPS 0.1156 (limit 0.20), PSNR 23.6316 dB,
audio spectral cosine 0.9589 (minimum 0.80), and audio RMS ratio 0.9342. The
resident per-GPU peak was 68.52 GiB for BF16 and 53.51 GiB for FP8, a 22%
reduction.
## Known limitations
- Each server process loads only one checkpoint partition.
- H3 currently executes one generation request per diffusion batch.
- The first regional-compile request is a warmup and should not be included in
steady-state performance measurements.
- Online FP8 is not compatible with layerwise offload.
- Image+audio Ref2VA accepts exactly one image and one audio reference.
- Video Ref2VA accepts one or more video files, but not an additional standalone
audio reference.
@@ -16,6 +16,17 @@ pytestmark = [
MODEL_ENV = "VLLM_TEST_MINIMAX_H3_FL2VA_MODEL"
def _assert_joint_output(outputs):
assert len(outputs) == 1
frames = np.asarray(outputs[0].images[0])
assert frames.shape == (39, 256, 448, 3)
multimodal = outputs[0].multimodal_output
assert multimodal is not None
assert np.asarray(multimodal["audio"]).shape[1] == 2
assert multimodal["audio_sample_rate"] == 32000
assert multimodal["fps"] == 24
@pytest.mark.skipif(
not os.environ.get(MODEL_ENV),
reason=f"set {MODEL_ENV} to an authorized FL2VA checkpoint path",
@@ -59,11 +70,56 @@ def test_minimax_h3_t2va_ulysses8_smoke():
finally:
engine.close()
assert len(outputs) == 1
frames = np.asarray(outputs[0].images[0])
assert frames.shape == (39, 256, 448, 3)
multimodal = outputs[0].multimodal_output
assert multimodal is not None
assert np.asarray(multimodal["audio"]).shape[1] == 2
assert multimodal["audio_sample_rate"] == 32000
assert multimodal["fps"] == 24
_assert_joint_output(outputs)
@pytest.mark.skipif(
not os.environ.get(MODEL_ENV),
reason=f"set {MODEL_ENV} to an authorized FL2VA checkpoint path",
)
def test_minimax_h3_t2va_fp8_single_gpu_smoke():
import torch
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
if torch.accelerator.device_count() < 1:
pytest.skip("MiniMax H3 FP8 smoke requires a CUDA device")
engine = Omni(
model=os.environ[MODEL_ENV],
quantization_config={
"transformer": {"method": "fp8"},
"text_encoder": None,
"video_vae": None,
"audio_vae": None,
},
parallel_config=DiffusionParallelConfig(ulysses_degree=1),
trust_remote_code=True,
enable_cpu_offload=True,
enforce_eager=True,
)
try:
outputs = engine.generate(
"A quiet cinematic night scene with matching ambient sound.",
OmniDiffusionSamplingParams(
height=256,
width=448,
num_frames=29,
fps=24,
num_inference_steps=2,
seed=42,
output_type="np",
extra_args={
"task": "t2va",
"flow_shift": 12.0,
"audio_flow_shift": 3.0,
},
),
use_tqdm=False,
)
finally:
engine.close()
_assert_joint_output(outputs)
@@ -0,0 +1,213 @@
# SPDX-License-Identifier: Apache-2.0
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
import torch
import torch.nn as nn
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
class _FakeLinear(nn.Module):
def __init__(
self,
*args,
bias=False,
params_dtype=None,
quant_config=None,
prefix="",
total_num_heads=None,
total_num_kv_heads=None,
**kwargs,
):
del args, kwargs
super().__init__()
self.prefix = prefix
self.quant_config = quant_config
self.weight = nn.Parameter(torch.empty(1, dtype=params_dtype))
if bias:
self.bias = nn.Parameter(torch.empty(1, dtype=params_dtype))
else:
self.register_parameter("bias", None)
self.num_heads = total_num_heads
self.num_kv_heads = total_num_kv_heads
class _FakeAttention(nn.Module):
def __init__(self, **kwargs):
del kwargs
super().__init__()
def _small_od_config():
arch = {
"num_layers": 1,
"token_refiner_num_layers": 1,
"hidden_size": 8,
"num_attention_heads": 2,
"attention_head_dim": 4,
"ffn_hidden_size": 16,
"latents_dim": 2,
"audio_latents_dim": 2,
"patch_size": (1, 2, 2),
"text_dim": 6,
"timestep_input_dim": 4,
"time_embed_hidden_size": 8,
"time_embed_dim": 4,
"adaln_out_features": 18 * 8,
"final_adaln_out_features": 2 * 8,
"rope_inv_freq_len": 2,
}
return SimpleNamespace(
tf_model_config=arch,
parallel_config=SimpleNamespace(ulysses_degree=1),
)
def test_fp8_scope_and_prefix_propagation(monkeypatch):
from vllm.model_executor.layers.linear import LinearBase, UnquantizedLinearMethod
from vllm.model_executor.layers.quantization.fp8 import Fp8Config
from vllm.model_executor.layers.quantization.utils.quant_utils import (
is_layer_skipped,
)
from vllm_omni.diffusion.models.minimax_h3 import minimax_h3_transformer as h3
monkeypatch.setattr(h3, "ColumnParallelLinear", _FakeLinear)
monkeypatch.setattr(h3, "MergedColumnParallelLinear", _FakeLinear)
monkeypatch.setattr(h3, "QKVParallelLinear", _FakeLinear)
monkeypatch.setattr(h3, "RowParallelLinear", _FakeLinear)
monkeypatch.setattr(h3, "Attention", _FakeAttention)
monkeypatch.setattr(h3, "get_tensor_model_parallel_world_size", lambda: 1)
ignored_layers = {
"token_refiner.blocks.0.mlp.fc2",
"blocks.0.attn.qkv_proj",
"condition_proj",
"blocks.0.adaln_proj.linear",
"final_layer.adaln_proj.linear",
}
fp8_config = Fp8Config(ignored_layers=sorted(ignored_layers))
model = h3.MiniMaxH3DiTModel(
_small_od_config(),
quant_config=fp8_config,
)
linears = {module.prefix: module.quant_config for module in model.modules() if isinstance(module, _FakeLinear)}
quantized = {
"token_refiner.blocks.0.attn.qkv_proj",
"token_refiner.blocks.0.attn.out_proj",
"token_refiner.blocks.0.mlp.fc1",
"token_refiner.blocks.0.mlp.fc2",
"blocks.0.attn.qkv_proj",
"blocks.0.attn.out_proj",
"blocks.0.mlp.fc1",
"blocks.0.mlp.fc2",
"condition_proj",
"blocks.0.adaln_proj.linear",
"final_layer.adaln_proj.linear",
}
full_precision = {
"video_patch_proj",
"audio_patch_proj",
"time_embedder.proj_in",
"time_embedder.proj_out",
"final_layer.video_out",
"final_layer.audio_out",
}
assert quantized <= linears.keys()
assert full_precision <= linears.keys()
assert all(linears[prefix] is fp8_config for prefix in quantized)
assert all(linears[prefix] is None for prefix in full_precision)
assert {prefix for prefix in quantized if is_layer_skipped(prefix, fp8_config.ignored_layers)} == ignored_layers
linear = Mock(spec=LinearBase)
assert all(
isinstance(
fp8_config.get_quant_method(linear, prefix),
UnquantizedLinearMethod,
)
for prefix in ignored_layers
)
class _WeightTarget(nn.Module):
def __init__(self, loader):
super().__init__()
self.weight = nn.Parameter(torch.empty(1))
self.weight.weight_loader = loader
def test_model_load_weights_transforms_before_calling_vllm_loader():
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
MiniMaxH3DiTArchConfig,
MiniMaxH3DiTModel,
)
qkv_calls = []
fc1_calls = []
def qkv_loader(param, loaded_weight):
del param
qkv_calls.append(loaded_weight.clone())
def fc1_loader(param, loaded_weight, shard_id):
del param
fc1_calls.append((shard_id, loaded_weight.clone()))
model = object.__new__(MiniMaxH3DiTModel)
nn.Module.__init__(model)
model.arch = MiniMaxH3DiTArchConfig(
hidden_size=1,
num_attention_heads=2,
attention_head_dim=1,
ffn_hidden_size=2,
)
model.blocks = nn.ModuleList([nn.Module()])
model.blocks[0].attn = nn.Module()
model.blocks[0].attn.qkv_proj = _WeightTarget(qkv_loader)
model.blocks[0].mlp = nn.Module()
model.blocks[0].mlp.fc1 = _WeightTarget(fc1_loader)
qkv = torch.arange(6, dtype=torch.float32).reshape(6, 1)
fc1 = torch.arange(4, dtype=torch.float32).reshape(4, 1)
loaded = model.load_weights(
[
("blocks.0.attn.qkv_proj.weight", qkv),
("blocks.0.mlp.fc1.weight", fc1),
]
)
assert loaded == {
"blocks.0.attn.qkv_proj.weight",
"blocks.0.mlp.fc1.weight",
}
assert qkv_calls[0][:, 0].tolist() == [0, 3, 1, 4, 2, 5]
assert [(shard_id, tensor[:, 0].tolist()) for shard_id, tensor in fc1_calls] == [
(0, [0, 1]),
(1, [2, 3]),
]
def test_pipeline_resolves_transformer_component_quant_config():
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
_resolve_component_quant_config,
)
from vllm_omni.quantization import build_quant_config
ignored_layers = ["blocks.0.attn.qkv_proj"]
component_config = build_quant_config(
{
"transformer": {
"method": "fp8",
"ignored_layers": ignored_layers,
}
}
)
transformer_config = component_config.resolve("transformer")
assert transformer_config.ignored_layers == ignored_layers
assert _resolve_component_quant_config(component_config, "transformer") is transformer_config
assert _resolve_component_quant_config(transformer_config, "transformer") is transformer_config
@@ -0,0 +1,297 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from pathlib import Path
import numpy as np
import pytest
import torch
from tests.diffusion.quantization.test_quantization_quality import (
_OUTPUT_DIR,
QualityTestConfig,
_compute_lpips,
_compute_psnr_and_mae,
_free_gpu_memory,
_maybe_save_output,
)
from tests.helpers.mark import hardware_marks
_MINIMAX_H3_REPO = "MiniMaxAI/MiniMax-H3"
_MINIMAX_H3_REVISION = "48d93ede732756e404a3b1b2f3b3a9b5a22f6cfc"
_MINIMAX_H3_AUDIO_SAMPLE_RATE = 32000
_MIN_AUDIO_SPECTRAL_COSINE = 0.80
_MIN_AUDIO_RMS_RATIO = 0.50
_MAX_AUDIO_RMS_RATIO = 2.00
_QUALITY_CONFIG = QualityTestConfig(
id="fp8_minimax_h3_fl2va",
model=_MINIMAX_H3_REPO,
quantization={"transformer": {"method": "fp8"}},
task="t2v",
prompt=(
"A bright daytime cinematic tracking shot of a golden retriever running through a sunlit meadow "
"filled with colorful wildflowers, vivid blue sky, crisp natural colors."
),
max_lpips=0.20,
height=384,
width=672,
num_frames=107,
num_inference_steps=10,
)
def _resolve_fl2va_model_ref() -> str:
from huggingface_hub import snapshot_download
repo_root = Path(
snapshot_download(
repo_id=_MINIMAX_H3_REPO,
revision=_MINIMAX_H3_REVISION,
allow_patterns=["FL2VA/**"],
)
)
fl2va_root = repo_root / "FL2VA"
if not (fl2va_root / "model_index.json").is_file():
raise FileNotFoundError(f"MiniMax H3 FL2VA model_index.json not found under {repo_root}")
return str(fl2va_root)
def _canonical_audio(audio: object) -> np.ndarray:
"""Return H3 audio as float32 [channels, samples]."""
array = np.asarray(audio, dtype=np.float32)
while array.ndim > 2 and array.shape[0] == 1:
array = array[0]
if array.ndim == 1:
array = array[None, :]
if array.ndim != 2:
raise ValueError(f"Expected mono/stereo audio, got shape {array.shape}")
if array.shape[0] > array.shape[1] and array.shape[1] <= 8:
array = array.T
if not 1 <= array.shape[0] <= 8:
raise ValueError(f"Expected channel-first audio, got shape {array.shape}")
if not np.isfinite(array).all():
raise ValueError("Audio contains non-finite samples")
return np.ascontiguousarray(array)
def _audio_quality_metrics(baseline: object, quantized: object) -> tuple[float, float]:
"""Compare phase-tolerant spectral structure and overall signal energy."""
baseline_array = _canonical_audio(baseline)
quantized_array = _canonical_audio(quantized)
if baseline_array.shape != quantized_array.shape:
raise ValueError(
"Audio shapes do not match for metric computation: "
f"baseline={baseline_array.shape}, quantized={quantized_array.shape}"
)
baseline_tensor = torch.from_numpy(baseline_array)
quantized_tensor = torch.from_numpy(quantized_array)
similarities: list[float] = []
for n_fft in (512, 1024, 2048):
window = torch.hann_window(n_fft)
baseline_magnitude = torch.stft(
baseline_tensor,
n_fft=n_fft,
hop_length=n_fft // 4,
window=window,
return_complex=True,
).abs()
quantized_magnitude = torch.stft(
quantized_tensor,
n_fft=n_fft,
hop_length=n_fft // 4,
window=window,
return_complex=True,
).abs()
cosine = torch.nn.functional.cosine_similarity(
baseline_magnitude.flatten(1),
quantized_magnitude.flatten(1),
dim=1,
eps=1e-12,
)
similarities.extend(float(value) for value in cosine)
baseline_rms = float(torch.sqrt(torch.mean(baseline_tensor.square())))
quantized_rms = float(torch.sqrt(torch.mean(quantized_tensor.square())))
rms_ratio = quantized_rms / max(baseline_rms, 1e-12)
return float(np.mean(similarities)), rms_ratio
def _generate_joint_output(omni, config: QualityTestConfig):
"""Generate H3 video/audio, returning both payloads and peak GPU memory."""
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.platforms import current_omni_platform
generator = torch.Generator(device=current_omni_platform.device_type).manual_seed(config.seed)
torch.accelerator.reset_peak_memory_stats()
outputs = omni.generate(
{"prompt": config.prompt, "negative_prompt": config.negative_prompt},
OmniDiffusionSamplingParams(
height=config.height,
width=config.width,
generator=generator,
num_inference_steps=config.num_inference_steps,
num_frames=config.num_frames,
guidance_scale=config.guidance_scale,
sigmas=config.sigmas,
),
)
main_process_peak_mem = torch.accelerator.max_memory_allocated() / (1024**3)
first = outputs[0]
worker_peak_memory_mb = float(getattr(first, "peak_memory_mb", 0.0) or 0.0)
if hasattr(first, "request_output") and isinstance(first.request_output, list):
first = first.request_output[0]
worker_peak_memory_mb = max(
worker_peak_memory_mb,
float(getattr(first, "peak_memory_mb", 0.0) or 0.0),
)
if not hasattr(first, "images") or not first.images:
raise ValueError("Could not extract video frames from H3 output")
frames = first.images[0]
if isinstance(frames, torch.Tensor):
frames = frames.detach().cpu()
if frames.dim() == 5:
frames = frames[0]
if frames.dim() == 4 and frames.shape[0] in (3, 4):
frames = frames.permute(1, 2, 3, 0)
if frames.is_floating_point():
frames = frames.clamp(-1, 1) * 0.5 + 0.5
video = frames.float().numpy()
else:
video = np.asarray(frames)
if video.ndim == 5:
video = video[0]
multimodal = getattr(first, "multimodal_output", None)
if not multimodal or "audio" not in multimodal:
raise ValueError("Could not extract audio from H3 output")
sample_rate = int(multimodal.get("audio_sample_rate", 0))
peak_mem = worker_peak_memory_mb / 1024 if worker_peak_memory_mb > 0 else main_process_peak_mem
return video, _canonical_audio(multimodal["audio"]), sample_rate, peak_mem
@pytest.mark.full_model
@pytest.mark.diffusion
@pytest.mark.parametrize(
"config",
[
pytest.param(
_QUALITY_CONFIG,
id=_QUALITY_CONFIG.id,
marks=hardware_marks(res={"cuda": _QUALITY_CONFIG.gpu}, num_cards=2),
)
],
)
def test_minimax_h3_quantization_quality(config: QualityTestConfig):
from vllm_omni.entrypoints.omni import Omni
model_ref = _resolve_fl2va_model_ref()
common_kwargs = {
"model": model_ref,
"enforce_eager": True,
"tensor_parallel_size": 2,
"text_encoder_tp_size": 2,
"vae_use_tiling": True,
}
omni_bl = Omni(**common_kwargs)
baseline_out, baseline_audio, baseline_sample_rate, bl_mem = _generate_joint_output(omni_bl, config)
omni_bl.shutdown()
del omni_bl
_free_gpu_memory()
_maybe_save_output(_OUTPUT_DIR, config, "baseline", baseline_out)
quantization = config.quantization_ref()
omni_qt = Omni(**common_kwargs, quantization_config=quantization)
quant_out, quant_audio, quant_sample_rate, qt_mem = _generate_joint_output(omni_qt, config)
omni_qt.shutdown()
del omni_qt
_free_gpu_memory()
_maybe_save_output(_OUTPUT_DIR, config, "quantized", quant_out)
lpips_score = _compute_lpips(baseline_out, quant_out, config.task)
psnr_score, mae_score = _compute_psnr_and_mae(baseline_out, quant_out, config.task)
if baseline_sample_rate != _MINIMAX_H3_AUDIO_SAMPLE_RATE or quant_sample_rate != baseline_sample_rate:
raise AssertionError(
f"Unexpected H3 audio sample rate: baseline={baseline_sample_rate}, quantized={quant_sample_rate}"
)
audio_spectral_cosine, audio_rms_ratio = _audio_quality_metrics(baseline_audio, quant_audio)
assert lpips_score <= config.max_lpips, (
f"LPIPS {lpips_score:.4f} exceeds threshold {config.max_lpips} "
f"for {config.quantization_ref()} on {config.quantized_ref()}"
)
assert audio_spectral_cosine >= _MIN_AUDIO_SPECTRAL_COSINE, (
f"Audio spectral cosine {audio_spectral_cosine:.4f} is below threshold "
f"{_MIN_AUDIO_SPECTRAL_COSINE} for {config.id}"
)
assert _MIN_AUDIO_RMS_RATIO <= audio_rms_ratio <= _MAX_AUDIO_RMS_RATIO, (
f"Audio RMS ratio {audio_rms_ratio:.4f} is outside "
f"[{_MIN_AUDIO_RMS_RATIO}, {_MAX_AUDIO_RMS_RATIO}] for {config.id}"
)
mem_reduction = (bl_mem - qt_mem) / bl_mem * 100 if bl_mem > 0 else 0
print(f"\n{'=' * 60}")
print(f"Quantization Quality: {config.id}")
print(f"{'=' * 60}")
print(f" Baseline: {config.baseline_ref()}")
print(f" Quantized: {config.quantized_ref()}")
print(f" Method: {config.quantization_ref()}")
print(f" LPIPS: {lpips_score:.4f} (threshold: {config.max_lpips})")
print(f" PSNR: {psnr_score:.4f} dB (higher is better)")
print(f" MAE: {mae_score:.6f} (lower is better)")
print(f" Audio cosine: {audio_spectral_cosine:.4f} (threshold: {_MIN_AUDIO_SPECTRAL_COSINE})")
print(f" Audio RMS: {audio_rms_ratio:.4f}x (range: {_MIN_AUDIO_RMS_RATIO}-{_MAX_AUDIO_RMS_RATIO})")
print(f" BF16 memory: {bl_mem:.2f} GiB")
print(f" Quant memory: {qt_mem:.2f} GiB ({mem_reduction:.0f}% reduction)")
print(" Result: PASS")
print(f"{'=' * 60}\n")
assert np.isfinite(psnr_score) or np.isinf(psnr_score), f"PSNR is invalid for {config.id}: {psnr_score}"
assert np.isfinite(mae_score), f"MAE is not finite for {config.id}: {mae_score}"
def test_resolve_fl2va_model_ref(tmp_path, monkeypatch):
fl2va_root = tmp_path / "FL2VA"
fl2va_root.mkdir()
(fl2va_root / "model_index.json").write_text("{}", encoding="utf-8")
def fake_snapshot_download(*, repo_id, revision, allow_patterns):
assert repo_id == _MINIMAX_H3_REPO
assert revision == _MINIMAX_H3_REVISION
assert allow_patterns == ["FL2VA/**"]
return str(tmp_path)
monkeypatch.setattr("huggingface_hub.snapshot_download", fake_snapshot_download)
assert _resolve_fl2va_model_ref() == str(fl2va_root)
def test_audio_quality_metrics_ignore_phase_but_reject_spectral_drift():
sample_rate = _MINIMAX_H3_AUDIO_SAMPLE_RATE
time = np.arange(sample_rate, dtype=np.float32) / sample_rate
baseline = np.stack(
[
np.sin(2 * np.pi * 440 * time),
np.sin(2 * np.pi * 880 * time),
]
)
phase_shifted = np.stack(
[
np.sin(2 * np.pi * 440 * time + np.pi / 2),
np.sin(2 * np.pi * 880 * time + np.pi / 2),
]
)
different = np.stack(
[
np.sin(2 * np.pi * 3000 * time),
np.sin(2 * np.pi * 4000 * time),
]
)
phase_cosine, phase_rms_ratio = _audio_quality_metrics(baseline, phase_shifted)
different_cosine, _ = _audio_quality_metrics(baseline, different)
assert phase_cosine >= _MIN_AUDIO_SPECTRAL_COSINE
assert phase_rms_ratio == pytest.approx(1.0, abs=1e-5)
assert different_cosine < _MIN_AUDIO_SPECTRAL_COSINE
@@ -248,7 +248,8 @@ class MiniMaxH3TimeEmbedder(nn.Module):
def __init__(
self,
arch: MiniMaxH3DiTArchConfig,
quant_config: QuantizationConfig | None,
*,
prefix: str,
) -> None:
super().__init__()
self.frequency_embedding_size = arch.timestep_input_dim
@@ -258,7 +259,8 @@ class MiniMaxH3TimeEmbedder(nn.Module):
bias=True,
gather_output=True,
params_dtype=_FP32_DTYPE,
quant_config=quant_config,
quant_config=None,
prefix=f"{prefix}.proj_in",
)
self.proj_out = RowParallelLinear(
arch.time_embed_hidden_size,
@@ -266,7 +268,8 @@ class MiniMaxH3TimeEmbedder(nn.Module):
bias=True,
input_is_parallel=False,
params_dtype=_FP32_DTYPE,
quant_config=quant_config,
quant_config=None,
prefix=f"{prefix}.proj_out",
)
def forward(self, t: torch.Tensor) -> torch.Tensor:
@@ -323,6 +326,7 @@ class MiniMaxH3Attention(nn.Module):
arch: MiniMaxH3DiTArchConfig,
quant_config: QuantizationConfig | None,
*,
prefix: str,
skip_sequence_parallel: bool = False,
) -> None:
super().__init__()
@@ -338,11 +342,11 @@ class MiniMaxH3Attention(nn.Module):
bias=False,
params_dtype=_BF16_DTYPE,
quant_config=quant_config,
prefix=f"{prefix}.qkv_proj",
return_bias=True,
)
self.num_heads = self.qkv_proj.num_heads
self.num_kv_heads = self.qkv_proj.num_kv_heads
self._install_qkv_weight_loader(arch)
self.q_norm = _norm(arch.attention_head_dim, eps=arch.qk_norm_eps)
self.k_norm = _norm(arch.attention_head_dim, eps=arch.qk_norm_eps)
self.out_proj = RowParallelLinear(
@@ -352,6 +356,7 @@ class MiniMaxH3Attention(nn.Module):
input_is_parallel=True,
params_dtype=_BF16_DTYPE,
quant_config=quant_config,
prefix=f"{prefix}.out_proj",
)
self.attention = Attention(
num_heads=self.num_heads,
@@ -362,24 +367,6 @@ class MiniMaxH3Attention(nn.Module):
skip_sequence_parallel=skip_sequence_parallel,
)
def _install_qkv_weight_loader(self, arch: MiniMaxH3DiTArchConfig) -> None:
base_loader = self.qkv_proj.weight.weight_loader
def _weight_loader(param: torch.Tensor, loaded_weight: torch.Tensor) -> None:
# The grouped checkpoint layout is
# [num_query_groups, q_per_group + k + v] before splitting.
# MiniMax H3 uses MHA, so checkpoint rows are per-head [q, k, v],
# while qkv_proj expects [q_all, k_all, v_all].
reordered = _reorder_grouped_qkv_to_qkv(
loaded_weight,
num_query_groups=arch.num_attention_heads,
heads_per_group=1,
head_dim=arch.attention_head_dim,
)
base_loader(param, reordered)
self.qkv_proj.weight.weight_loader = _weight_loader
@torch.compiler.disable
def _run_packed_attention(
self,
@@ -472,6 +459,8 @@ class MiniMaxH3MLP(nn.Module):
self,
arch: MiniMaxH3DiTArchConfig,
quant_config: QuantizationConfig | None,
*,
prefix: str,
) -> None:
super().__init__()
self.fc1 = MergedColumnParallelLinear(
@@ -481,8 +470,8 @@ class MiniMaxH3MLP(nn.Module):
gather_output=False,
params_dtype=_BF16_DTYPE,
quant_config=quant_config,
prefix=f"{prefix}.fc1",
)
self._install_fc1_weight_loader()
# Chunk the fused fc1 output as [gate, up], then compute
# silu(gate) * up.
self.fc2 = RowParallelLinear(
@@ -492,23 +481,9 @@ class MiniMaxH3MLP(nn.Module):
input_is_parallel=True,
params_dtype=_BF16_DTYPE,
quant_config=quant_config,
prefix=f"{prefix}.fc2",
)
def _install_fc1_weight_loader(self) -> None:
base_loader = self.fc1.weight.weight_loader
def _weight_loader(param: torch.Tensor, loaded_weight: torch.Tensor) -> None:
if loaded_weight.shape[0] % 2:
raise ValueError(
"MiniMax H3 fc1 checkpoint rows must split evenly into "
f"gate/up matrices, got {tuple(loaded_weight.shape)}"
)
gate, up = loaded_weight.chunk(2, dim=0)
base_loader(param, gate, 0)
base_loader(param, up, 1)
self.fc1.weight.weight_loader = _weight_loader
def forward(self, x: torch.Tensor) -> torch.Tensor:
hidden, _ = self.fc1(x)
gate, up = hidden.chunk(2, dim=-1)
@@ -534,6 +509,7 @@ class MiniMaxH3AdalnProj(nn.Module):
*,
expand_ratio: int,
modality_num: int,
prefix: str,
) -> None:
super().__init__()
if out_features != expand_ratio * arch.hidden_size * modality_num:
@@ -550,12 +526,13 @@ class MiniMaxH3AdalnProj(nn.Module):
gather_output=True,
params_dtype=_BF16_DTYPE,
quant_config=quant_config,
prefix=f"{prefix}.linear",
)
def forward(self, t_emb: torch.Tensor) -> tuple[torch.Tensor, ...]:
"""t_emb: [M, t_dim] -> expand_ratio tensors of [M*modality_num, H]."""
x = nn.functional.silu(t_emb)
x, _ = self.linear(x.to(self.linear.weight.dtype))
x, _ = self.linear(x.to(_BF16_DTYPE))
m = x.shape[0]
x = x.view(m * self.modality_num, self.expand_ratio * self.hidden_size)
return tuple(x.chunk(self.expand_ratio, dim=-1))
@@ -568,6 +545,8 @@ class MiniMaxH3TokenRefinerBlock(nn.Module):
self,
arch: MiniMaxH3DiTArchConfig,
quant_config: QuantizationConfig | None,
*,
prefix: str,
) -> None:
super().__init__()
self.norm1 = _norm(arch.hidden_size, eps=arch.norm_eps)
@@ -578,9 +557,14 @@ class MiniMaxH3TokenRefinerBlock(nn.Module):
self.attn = MiniMaxH3Attention(
arch,
quant_config,
prefix=f"{prefix}.attn",
skip_sequence_parallel=True,
)
self.mlp = MiniMaxH3MLP(arch, quant_config)
self.mlp = MiniMaxH3MLP(
arch,
quant_config,
prefix=f"{prefix}.mlp",
)
def forward(
self,
@@ -604,10 +588,19 @@ class MiniMaxH3TokenRefiner(nn.Module):
self,
arch: MiniMaxH3DiTArchConfig,
quant_config: QuantizationConfig | None,
*,
prefix: str,
) -> None:
super().__init__()
self.blocks = nn.ModuleList(
[MiniMaxH3TokenRefinerBlock(arch, quant_config) for _ in range(arch.token_refiner_num_layers)]
[
MiniMaxH3TokenRefinerBlock(
arch,
quant_config,
prefix=f"{prefix}.blocks.{i}",
)
for i in range(arch.token_refiner_num_layers)
]
)
self.final_norm = _norm(arch.hidden_size, eps=arch.final_norm_eps)
@@ -628,18 +621,29 @@ class MiniMaxH3DiTBlock(nn.Module):
self,
arch: MiniMaxH3DiTArchConfig,
quant_config: QuantizationConfig | None,
*,
prefix: str,
) -> None:
super().__init__()
self.norm1 = _norm(arch.hidden_size, eps=arch.norm_eps)
self.norm2 = _norm(arch.hidden_size, eps=arch.norm_eps)
self.attn = MiniMaxH3Attention(arch, quant_config)
self.mlp = MiniMaxH3MLP(arch, quant_config)
self.attn = MiniMaxH3Attention(
arch,
quant_config,
prefix=f"{prefix}.attn",
)
self.mlp = MiniMaxH3MLP(
arch,
quant_config,
prefix=f"{prefix}.mlp",
)
self.adaln_proj = MiniMaxH3AdalnProj(
arch,
arch.adaln_out_features,
quant_config,
expand_ratio=6,
modality_num=MINIMAX_H3_ADALN_MODALITY_NUM,
prefix=f"{prefix}.adaln_proj",
)
def forward(
@@ -693,6 +697,8 @@ class MiniMaxH3FinalLayer(nn.Module):
self,
arch: MiniMaxH3DiTArchConfig,
quant_config: QuantizationConfig | None,
*,
prefix: str,
) -> None:
super().__init__()
video_patch_dim = arch.latents_dim * arch.patch_size[0] * arch.patch_size[1] * arch.patch_size[2]
@@ -703,6 +709,7 @@ class MiniMaxH3FinalLayer(nn.Module):
quant_config,
expand_ratio=2,
modality_num=1,
prefix=f"{prefix}.adaln_proj",
)
self.video_out = ColumnParallelLinear(
arch.hidden_size,
@@ -710,7 +717,8 @@ class MiniMaxH3FinalLayer(nn.Module):
bias=True,
gather_output=True,
params_dtype=_FP32_DTYPE,
quant_config=quant_config,
quant_config=None,
prefix=f"{prefix}.video_out",
)
self.audio_out = ColumnParallelLinear(
arch.hidden_size,
@@ -718,7 +726,8 @@ class MiniMaxH3FinalLayer(nn.Module):
bias=True,
gather_output=True,
params_dtype=_FP32_DTYPE,
quant_config=quant_config,
quant_config=None,
prefix=f"{prefix}.audio_out",
)
def forward(
@@ -864,7 +873,8 @@ class MiniMaxH3DiTModel(nn.Module):
bias=True,
gather_output=True,
params_dtype=_FP32_DTYPE,
quant_config=quant_config,
quant_config=None,
prefix="video_patch_proj",
)
self.audio_patch_proj = ColumnParallelLinear(
arch.audio_latents_dim,
@@ -872,7 +882,8 @@ class MiniMaxH3DiTModel(nn.Module):
bias=True,
gather_output=True,
params_dtype=_FP32_DTYPE,
quant_config=quant_config,
quant_config=None,
prefix="audio_patch_proj",
)
self.condition_proj = ColumnParallelLinear(
arch.text_dim,
@@ -881,14 +892,35 @@ class MiniMaxH3DiTModel(nn.Module):
gather_output=True,
params_dtype=_BF16_DTYPE,
quant_config=quant_config,
prefix="condition_proj",
)
self.time_embedder = MiniMaxH3TimeEmbedder(
arch,
prefix="time_embedder",
)
self.time_embedder = MiniMaxH3TimeEmbedder(arch, quant_config)
self.rope = MiniMaxH3Rope(arch.rope_inv_freq_len)
self.token_refiner = MiniMaxH3TokenRefiner(arch, quant_config)
self.blocks = nn.ModuleList([MiniMaxH3DiTBlock(arch, quant_config) for _ in range(arch.num_layers)])
self.token_refiner = MiniMaxH3TokenRefiner(
arch,
quant_config,
prefix="token_refiner",
)
self.blocks = nn.ModuleList(
[
MiniMaxH3DiTBlock(
arch,
quant_config,
prefix=f"blocks.{i}",
)
for i in range(arch.num_layers)
]
)
self.sp_prepare = MiniMaxH3SPPrepare()
self.sp_gather = MiniMaxH3SPGather()
self.final_layer = MiniMaxH3FinalLayer(arch, quant_config)
self.final_layer = MiniMaxH3FinalLayer(
arch,
quant_config,
prefix="final_layer",
)
self._mark_missing_params_required()
def _mark_missing_params_required(self) -> None:
@@ -917,7 +949,27 @@ class MiniMaxH3DiTModel(nn.Module):
logger.warning("Skipping MiniMax H3 weight not present in model: %s", name)
continue
weight_loader = getattr(param, "weight_loader", default_weight_loader)
weight_loader(param, loaded_weight)
if name.endswith(".attn.qkv_proj.weight"):
# Transform checkpoint layout before entering vLLM's loader so
# online FP8 can keep ``online_process_loader`` outermost.
loaded_weight = _reorder_grouped_qkv_to_qkv(
loaded_weight,
num_query_groups=self.arch.num_attention_heads,
heads_per_group=1,
head_dim=self.arch.attention_head_dim,
)
weight_loader(param, loaded_weight)
elif name.endswith(".mlp.fc1.weight"):
if loaded_weight.shape[0] % 2:
raise ValueError(
"MiniMax H3 fc1 checkpoint rows must split evenly into "
f"gate/up matrices, got {tuple(loaded_weight.shape)}"
)
gate, up = loaded_weight.chunk(2, dim=0)
weight_loader(param, gate, 0)
weight_loader(param, up, 1)
else:
weight_loader(param, loaded_weight)
loaded.add(name)
return loaded
@@ -88,6 +88,12 @@ MINIMAX_H3_REFERENCE_IMAGE_SHORT_EDGE = 2048
MINIMAX_H3_REFERENCE_IMAGE_MULTIPLE = 32
def _resolve_component_quant_config(quant_config, component: str):
if hasattr(quant_config, "resolve"):
return quant_config.resolve(component)
return quant_config
def _minimax_h3_post_process(output, output_type: str = "np"):
"""Convert the joint video/audio output without capturing worker state.
@@ -293,9 +299,13 @@ class MiniMaxH3Pipeline(
fall_back_to_pt=False,
)
]
transformer_quant_config = _resolve_component_quant_config(
od_config.quantization_config,
"transformer",
)
self.transformer = MiniMaxH3DiTModel(
od_config,
quant_config=od_config.quantization_config,
quant_config=transformer_quant_config,
)
self.tokenizer = Qwen2TokenizerFast.from_pretrained(