2551 lines
109 KiB
Python
2551 lines
109 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
"""vLLM-Omni pipeline for MiniMax H3 FL2VA and Ref2VA partitions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import os
|
|
from collections.abc import Iterable, Mapping, Sequence
|
|
from contextlib import contextmanager
|
|
from dataclasses import fields, replace
|
|
from itertools import groupby
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any, ClassVar
|
|
|
|
import numpy as np
|
|
import torch
|
|
import torch.distributed as dist
|
|
import torch.nn as nn
|
|
from transformers import Qwen2TokenizerFast, Qwen3VLProcessor
|
|
from vllm.logger import init_logger
|
|
from vllm.model_executor.layers.quantization.base_config import QuantizationConfig
|
|
|
|
from vllm_omni.diffusion import envs
|
|
from vllm_omni.diffusion.cache.cachedit import (
|
|
CacheDiTBackend,
|
|
RequestScopedCacheDiTRuntime,
|
|
)
|
|
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
|
|
from vllm_omni.diffusion.distributed.parallel_state import get_world_group, init_world_group
|
|
from vllm_omni.diffusion.distributed.utils import get_local_device
|
|
from vllm_omni.diffusion.forward_context import DenoiseProgressMixin
|
|
from vllm_omni.diffusion.model_loader.diffusers_loader import (
|
|
DiffusersPipelineLoader,
|
|
)
|
|
from vllm_omni.diffusion.models.interface import (
|
|
SupportAudioInput,
|
|
SupportAudioOutput,
|
|
SupportImageInput,
|
|
SupportsComponentDiscovery,
|
|
)
|
|
from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin
|
|
from vllm_omni.diffusion.offloader import (
|
|
BoundedAllocatorCache,
|
|
OffloadPlan,
|
|
apply_sequential_offload,
|
|
remove_sequential_offload,
|
|
sequential_offload_component,
|
|
)
|
|
from vllm_omni.diffusion.offloader.config import (
|
|
DIT_COMPONENT,
|
|
TEXT_ENCODER_COMPONENT,
|
|
OffloadStrategy,
|
|
resolve_offload,
|
|
should_offload_component,
|
|
)
|
|
from vllm_omni.diffusion.offloader.module_collector import ModuleDiscovery
|
|
from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import (
|
|
DiffusionPipelineProfilerMixin,
|
|
)
|
|
from vllm_omni.diffusion.sched.sigma_schedule import DMD2SigmaSchedule
|
|
from vllm_omni.diffusion.utils.media_utils import normalize_preencode_batch_frames, normalize_video_codec_options
|
|
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
|
from vllm_omni.errors import OmniClientError, client_error_from_metadata
|
|
from vllm_omni.model_executor.model_loader.weight_utils import (
|
|
download_weights_from_hf_specific,
|
|
)
|
|
from vllm_omni.model_executor.models.minimax_h3.checkpoint import (
|
|
is_minimax_h3_modular,
|
|
resolve_minimax_h3_partition,
|
|
)
|
|
from vllm_omni.model_executor.models.minimax_h3.conditioning import (
|
|
MiniMaxH3EncoderConditioning,
|
|
MiniMaxH3EncoderMediaConditioning,
|
|
MiniMaxH3EncoderMediaInput,
|
|
MiniMaxH3TextConditioning,
|
|
)
|
|
from vllm_omni.model_executor.models.minimax_h3.encoder_processing import (
|
|
PreparedEncoderInputs,
|
|
encode_media,
|
|
prepare_encoder_inputs,
|
|
)
|
|
from vllm_omni.model_executor.models.minimax_h3.preprocessing import build_minimax_h3_presentation
|
|
from vllm_omni.model_executor.models.minimax_h3.reference_video import (
|
|
MINIMAX_H3_PREPARED_REFERENCE_VIDEOS_KEY,
|
|
deserialize_prepared_reference_videos,
|
|
)
|
|
from vllm_omni.platforms import current_omni_platform
|
|
from vllm_omni.quantization import (
|
|
resolve_component_quant_config as _resolve_component_quant_config,
|
|
)
|
|
from vllm_omni.quantization.component_config import (
|
|
resolve_encoder_quant_config as _resolve_encoder_quant_config,
|
|
)
|
|
|
|
from .batched_packing import minimax_h3_batched_forward_kwargs
|
|
from .condition_noise import (
|
|
minimax_h3_audio_cond_noise_aug_rows,
|
|
minimax_h3_imgvid_cond_noise_aug_rows,
|
|
)
|
|
from .denoise_loop import (
|
|
MiniMaxH3DenoiseBranch,
|
|
minimax_h3_denoise_loop,
|
|
minimax_h3_prepare_denoise_rows,
|
|
minimax_h3_publish_denoise_progress,
|
|
)
|
|
from .encoder import MiniMaxH3Qwen3VLEncoder
|
|
from .fasth3 import FastH3WeightFusion, resolve_fasth3_fusion
|
|
from .fasth3_checkpoint import FastH3CheckpointSpec
|
|
from .lora import TurboSpec, load_minimax_h3_turbo_lora
|
|
from .minimax_h3_transformer import (
|
|
MiniMaxH3Attention,
|
|
MiniMaxH3DiTModel,
|
|
_attention_isolates_packed_requests,
|
|
)
|
|
from .npu.lora import (
|
|
MINIMAX_H3_NATIVE_INFERENCE_STEPS,
|
|
load_minimax_h3_native_lora,
|
|
)
|
|
from .packed_sequence import (
|
|
MINIMAX_H3_MAX_PAD_SEQ_LEN,
|
|
MINIMAX_H3_SEQ_ALIGN,
|
|
minimax_h3_packed_sequence,
|
|
minimax_h3_packed_sequence_ref2va_blocks,
|
|
)
|
|
from .packed_tokens import (
|
|
minimax_h3_patchify_video_latent,
|
|
minimax_h3_unpack_audio_tokens,
|
|
minimax_h3_unpatchify_video_tokens,
|
|
)
|
|
from .quality_policy import MINIMAX_H3_GENERIC_CACHE_KEY, MiniMaxH3QualityPolicy
|
|
from .scheduling_minimax_h3_euler_ancestral import (
|
|
minimax_h3_euler_eta0_step,
|
|
minimax_h3_rf_v_to_x0,
|
|
)
|
|
from .time_request import (
|
|
MINIMAX_H3_SHAPE_PLANNER,
|
|
minimax_h3_time_shift_sigmas,
|
|
)
|
|
from .vae import MiniMaxH3AudioVAE, MiniMaxH3VideoVAE
|
|
|
|
if TYPE_CHECKING:
|
|
from vllm_omni.diffusion.worker.input_batch import InputBatch
|
|
from vllm_omni.diffusion.worker.utils import StepRequestState
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
if TYPE_CHECKING:
|
|
from vllm.lora.lora_model import LoRAModel
|
|
from vllm.lora.peft_helper import PEFTHelper
|
|
|
|
from vllm_omni.lora.request import LoRARequest
|
|
|
|
MINIMAX_H3_FPS = 24
|
|
MINIMAX_H3_AUDIO_SAMPLE_RATE = 32000
|
|
MINIMAX_H3_IMGVID_COND_TIMESTEP = 0.999
|
|
MINIMAX_H3_AUDIO_REF_COND_TIMESTEP = 1.0
|
|
MINIMAX_H3_DOWNLOAD_PATTERNS = [
|
|
"FL2VA/**",
|
|
"Ref2VA/model_index.json",
|
|
"Ref2VA/transformer/**",
|
|
]
|
|
MINIMAX_H3_TASK_DOWNLOAD_PATTERNS = {
|
|
"fl2va": ["FL2VA/**"],
|
|
"ref2va": ["Ref2VA/**"],
|
|
}
|
|
MINIMAX_H3_DIFFUSION_DOWNLOAD_PATTERNS = {
|
|
"fl2va": [
|
|
"FL2VA/model_index.json",
|
|
"FL2VA/transformer/**",
|
|
"FL2VA/video_vae/**",
|
|
"FL2VA/audio_vae/**",
|
|
],
|
|
"ref2va": [
|
|
"Ref2VA/model_index.json",
|
|
"Ref2VA/transformer/**",
|
|
"Ref2VA/video_vae/**",
|
|
"Ref2VA/audio_vae/**",
|
|
],
|
|
"combined": [
|
|
"FL2VA/model_index.json",
|
|
"FL2VA/transformer/**",
|
|
"FL2VA/video_vae/**",
|
|
"FL2VA/audio_vae/**",
|
|
"Ref2VA/model_index.json",
|
|
"Ref2VA/transformer/**",
|
|
],
|
|
}
|
|
|
|
|
|
def _resolve_minimax_h3_text_encoder_quant_config(
|
|
quant_config: QuantizationConfig | None,
|
|
) -> QuantizationConfig | None:
|
|
resolved = _resolve_component_quant_config(quant_config, "text_encoder")
|
|
return _resolve_encoder_quant_config(resolved)
|
|
|
|
|
|
def _minimax_h3_partition_for_task(
|
|
task_type: str | None,
|
|
model: str | None = None,
|
|
) -> str:
|
|
return resolve_minimax_h3_partition(model or "", task_type, auto_partition="combined")
|
|
|
|
|
|
def _resolve_minimax_h3_model_root(
|
|
model: str,
|
|
revision: str | None,
|
|
partition: str,
|
|
*,
|
|
load_text_encoder: bool,
|
|
) -> Path:
|
|
path = Path(model)
|
|
if path.is_dir():
|
|
if path.name in {"FL2VA", "Ref2VA"}:
|
|
return path.parent
|
|
return path
|
|
if is_minimax_h3_modular(model, revision):
|
|
allow_patterns = ["modular_model_index.json", "fastvideo_inference.json", "provenance.json", "transformer/**"]
|
|
if load_text_encoder:
|
|
allow_patterns += ["text_encoder/**", "tokenizer/**", "processor/**"]
|
|
elif load_text_encoder:
|
|
allow_patterns = (
|
|
MINIMAX_H3_DOWNLOAD_PATTERNS if partition == "combined" else MINIMAX_H3_TASK_DOWNLOAD_PATTERNS[partition]
|
|
)
|
|
else:
|
|
allow_patterns = MINIMAX_H3_DIFFUSION_DOWNLOAD_PATTERNS[partition]
|
|
return Path(
|
|
download_weights_from_hf_specific(
|
|
model_name_or_path=model,
|
|
cache_dir=None,
|
|
allow_patterns=allow_patterns,
|
|
revision=revision,
|
|
require_all=True,
|
|
)
|
|
)
|
|
|
|
|
|
_MINIMAX_H3_DENOISE_INPUT_KEYS = (
|
|
"task",
|
|
"text_embeddings",
|
|
"text_tags",
|
|
"seed",
|
|
"latent_t",
|
|
"latent_h",
|
|
"latent_w",
|
|
"audio_t",
|
|
"num_frames",
|
|
"num_steps",
|
|
"video_shift",
|
|
"audio_shift",
|
|
"base_schedule",
|
|
"visual_condition",
|
|
"visual_condition_shape",
|
|
"audio_condition",
|
|
"ref_audio_t",
|
|
"ref_blocks",
|
|
"visual_condition_shapes",
|
|
"audio_condition_lengths",
|
|
"keyframe_frame_indices",
|
|
"pad_seq_len",
|
|
)
|
|
|
|
# ``StepRequestState.extra`` keys owned by the step-execution path.
|
|
_STEP_BRANCH = "minimax_h3_branch"
|
|
_STEP_AUDIO_ROWS = "minimax_h3_audio_rows"
|
|
_STEP_AUDIO_NOISE_PRED = "minimax_h3_audio_noise_pred"
|
|
_STEP_SIGMAS_VIDEO = "minimax_h3_sigmas_video"
|
|
_STEP_SIGMAS_AUDIO = "minimax_h3_sigmas_audio"
|
|
_STEP_COND_ANCHOR = "minimax_h3_cond_anchor"
|
|
_STEP_AUDIO_ANCHOR = "minimax_h3_audio_anchor"
|
|
_STEP_SHAPE = "minimax_h3_shape"
|
|
_STEP_TRANSFORMER = "minimax_h3_transformer"
|
|
|
|
|
|
def _minimax_h3_step_schedule(state: StepRequestState) -> dict[str, float]:
|
|
"""Return the sigma/timestep values this request needs for its current step.
|
|
|
|
Mirrors the per-iteration arithmetic of ``minimax_h3_denoise_loop`` so step
|
|
mode and request mode advance identically.
|
|
"""
|
|
step = int(state.step_index)
|
|
sigmas_video = state.extra[_STEP_SIGMAS_VIDEO]
|
|
sigmas_audio = state.extra[_STEP_SIGMAS_AUDIO]
|
|
sigma_video = float(sigmas_video[step])
|
|
sigma_audio = float(sigmas_audio[step])
|
|
t_video = 1.0 - sigma_video
|
|
t_audio = 1.0 - sigma_audio
|
|
return {
|
|
"sigma_video": sigma_video,
|
|
"sigma_video_next": float(sigmas_video[step + 1]),
|
|
"sigma_audio": sigma_audio,
|
|
"sigma_audio_next": float(sigmas_audio[step + 1]),
|
|
"t_video": t_video,
|
|
"t_audio": t_audio,
|
|
"imgvid_cond_timestep": max(t_video, MINIMAX_H3_IMGVID_COND_TIMESTEP),
|
|
"audio_ref_cond_timestep": max(t_audio, MINIMAX_H3_AUDIO_REF_COND_TIMESTEP),
|
|
}
|
|
|
|
|
|
def _read_base_schedule(release: Mapping[str, Any]) -> DMD2SigmaSchedule | None:
|
|
"""Read a partition's distilled schedule. An absent key means legacy uniform."""
|
|
return DMD2SigmaSchedule.from_metadata(release)
|
|
|
|
|
|
def resolve_minimax_h3_diffusion_model_path(
|
|
model: str,
|
|
revision: str | None,
|
|
task_type: str | None,
|
|
) -> str:
|
|
"""Resolve a repository root or Hub ID to its startup partition."""
|
|
partition = (
|
|
"combined"
|
|
if str(task_type or "").lower() == "combined"
|
|
else resolve_minimax_h3_partition(model, task_type, auto_partition="fl2va")
|
|
)
|
|
model_root = _resolve_minimax_h3_model_root(
|
|
model,
|
|
revision,
|
|
partition,
|
|
load_text_encoder=False,
|
|
)
|
|
if is_minimax_h3_modular(str(model_root), revision):
|
|
return str(model_root)
|
|
if partition == "combined":
|
|
return str(model_root)
|
|
subdir = "Ref2VA" if partition == "ref2va" else "FL2VA"
|
|
return str(model_root / subdir)
|
|
|
|
|
|
def _minimax_h3_post_process(output, output_type: str = "np"):
|
|
"""Convert the joint video/audio output without capturing worker state.
|
|
|
|
The callable crosses the multiprocessing result queue, so it must remain a
|
|
module-level function that the standard pickle module can resolve.
|
|
|
|
``_prepare_minimax_h3_video_output`` already quantises the video to uint8
|
|
frames on the accelerator, so there is nothing left to scale or transpose
|
|
here.
|
|
"""
|
|
if not isinstance(output, tuple) or len(output) != 2:
|
|
return output
|
|
video, audio = output
|
|
if isinstance(video, (bytes, bytearray, memoryview)):
|
|
video = [video]
|
|
if isinstance(video, list) and all(isinstance(item, (bytes, bytearray, memoryview)) for item in video):
|
|
encoded_videos = [bytes(item) for item in video]
|
|
else:
|
|
encoded_videos = None
|
|
if encoded_videos is not None:
|
|
return {
|
|
"video": encoded_videos,
|
|
"audio": [None] * len(encoded_videos),
|
|
"audio_sample_rate": MINIMAX_H3_AUDIO_SAMPLE_RATE,
|
|
"fps": MINIMAX_H3_FPS,
|
|
}
|
|
if video.dtype != torch.uint8 or video.ndim != 5 or video.shape[-1] not in (3, 4):
|
|
# Float or channel-first frames would reach the muxer as a black or
|
|
# banded video rather than as an error.
|
|
raise ValueError(
|
|
f"MiniMax-H3 post-processing expects (B, T, H, W, C) uint8, got {tuple(video.shape)} {video.dtype}"
|
|
)
|
|
if output_type == "latent":
|
|
return output
|
|
if output_type == "np":
|
|
video = video.detach().cpu().numpy()
|
|
audio = audio.detach().float().cpu().numpy()
|
|
video = [sample for sample in video]
|
|
return {
|
|
"video": video,
|
|
"audio": audio,
|
|
"audio_sample_rate": MINIMAX_H3_AUDIO_SAMPLE_RATE,
|
|
"fps": MINIMAX_H3_FPS,
|
|
}
|
|
|
|
|
|
def _prepare_minimax_h3_video_output(video: torch.Tensor) -> torch.Tensor:
|
|
"""Quantize decoded frames in place before worker-to-engine transfer."""
|
|
video = video.detach().float()
|
|
video.clamp_(0, 1).mul_(255).round_()
|
|
return video.permute(0, 2, 3, 4, 1).to(
|
|
dtype=torch.uint8,
|
|
memory_format=torch.contiguous_format,
|
|
)
|
|
|
|
|
|
def _register_dlo_component_cache(cache: BoundedAllocatorCache, *components: Any) -> None:
|
|
for component in components:
|
|
if component is not None:
|
|
component.set_omni_component_cache(cache)
|
|
|
|
|
|
def get_minimax_h3_post_process_func(
|
|
od_config: OmniDiffusionConfig,
|
|
):
|
|
del od_config
|
|
return _minimax_h3_post_process
|
|
|
|
|
|
def _resolve_minimax_h3_num_outputs(value: Any) -> int:
|
|
if value is None:
|
|
return 1
|
|
if isinstance(value, bool) or not isinstance(value, (int, np.integer)):
|
|
raise OmniClientError("MiniMax H3 num_outputs_per_prompt must be an integer in [1, 10]")
|
|
value = int(value)
|
|
if not 1 <= value <= 10:
|
|
raise OmniClientError(f"MiniMax H3 num_outputs_per_prompt must be in [1, 10], got {value}")
|
|
return value
|
|
|
|
|
|
def _resolve_pad_seq_len(value: object) -> int | None:
|
|
"""Validate the optional packed-length pin from ``extra_args``.
|
|
|
|
The packer itself only needs a value that covers the used rows. A request
|
|
field needs two more guards: an unaligned value would silently open yet
|
|
another compiled shape, and an unbounded one would size every structural
|
|
tensor the packer allocates.
|
|
"""
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, bool) or not isinstance(value, (int, np.integer)):
|
|
raise OmniClientError("MiniMax H3 pad_seq_len must be an integer")
|
|
pinned = int(value)
|
|
if pinned <= 0:
|
|
raise OmniClientError(f"MiniMax H3 pad_seq_len must be positive, got {pinned}")
|
|
if pinned % MINIMAX_H3_SEQ_ALIGN:
|
|
raise OmniClientError(f"MiniMax H3 pad_seq_len must be a multiple of {MINIMAX_H3_SEQ_ALIGN}, got {pinned}")
|
|
if pinned > MINIMAX_H3_MAX_PAD_SEQ_LEN:
|
|
raise OmniClientError(f"MiniMax H3 pad_seq_len must be at most {MINIMAX_H3_MAX_PAD_SEQ_LEN}, got {pinned}")
|
|
return pinned
|
|
|
|
|
|
def _minimax_h3_output_seeds(seed: int, num_outputs: int) -> list[int]:
|
|
return [int(seed) + output_index for output_index in range(int(num_outputs))]
|
|
|
|
|
|
def _dit_rank_world() -> tuple[Any, int, int]:
|
|
if not dist.is_initialized():
|
|
return None, 0, 1
|
|
group = get_world_group().device_group
|
|
return group, dist.get_rank(group), dist.get_world_size(group)
|
|
|
|
|
|
def _broadcast_rank0_exception(exc: Exception | None) -> None:
|
|
"""Synchronize a rank-0-only exception across every DiT rank.
|
|
|
|
H3 reference-video preparation runs only on rank 0; the other DiT ranks
|
|
return ``None`` without touching disk. When rank 0 raises inside that
|
|
path it exits :meth:`prepare_encode` before reaching the downstream
|
|
``dist.broadcast`` calls, and non-zero ranks then hang on those
|
|
collectives forever. Every rank calls this helper right after the
|
|
rank-0-only work, before any subsequent collective, so all ranks either
|
|
raise the same error together or all continue.
|
|
"""
|
|
group, rank, world_size = _dit_rank_world()
|
|
if world_size == 1:
|
|
if exc is not None:
|
|
raise exc
|
|
return
|
|
if rank == 0:
|
|
if exc is None:
|
|
payload: list[Any] = [None]
|
|
else:
|
|
payload = [
|
|
{
|
|
"type": type(exc).__name__,
|
|
"message": str(exc),
|
|
"status_code": getattr(exc, "status_code", None),
|
|
"error_type": getattr(exc, "error_type", None),
|
|
}
|
|
]
|
|
else:
|
|
payload = [None]
|
|
dist.broadcast_object_list(payload, src=0, group=group)
|
|
info = payload[0]
|
|
if info is None:
|
|
return
|
|
if rank == 0:
|
|
assert exc is not None
|
|
raise exc
|
|
# Rebuild a matching client-facing error on non-zero ranks so the runner's
|
|
# per-request try/except records the same 4xx status as rank 0. The exact
|
|
# subclass need not survive the wire; the message and status suffice.
|
|
status_code = info.get("status_code")
|
|
error_type = info.get("error_type")
|
|
message = f"[rank 0] {info['type']}: {info['message']}"
|
|
if status_code is not None:
|
|
raise client_error_from_metadata(
|
|
message,
|
|
status_code=int(status_code),
|
|
error_type=error_type,
|
|
)
|
|
raise RuntimeError(message)
|
|
|
|
|
|
def _broadcast_tensor(
|
|
tensor: torch.Tensor | None,
|
|
*,
|
|
dtype: torch.dtype,
|
|
device: torch.device,
|
|
) -> torch.Tensor:
|
|
group, rank, world_size = _dit_rank_world()
|
|
if world_size == 1:
|
|
if tensor is None:
|
|
raise ValueError("source tensor is required for single-rank execution")
|
|
return tensor.to(device=device, dtype=dtype)
|
|
|
|
shape = torch.zeros(5, dtype=torch.long, device=device)
|
|
if rank == 0:
|
|
if tensor is None:
|
|
raise ValueError("rank 0 must provide a tensor to broadcast")
|
|
shape[0] = tensor.ndim
|
|
shape[1 : tensor.ndim + 1] = torch.tensor(
|
|
tensor.shape,
|
|
device=device,
|
|
)
|
|
dist.broadcast(shape, src=0, group=group)
|
|
ndim = int(shape[0].item())
|
|
tensor_shape = tuple(int(v) for v in shape[1 : ndim + 1].tolist())
|
|
if rank == 0:
|
|
output = tensor.to(device=device, dtype=dtype).contiguous()
|
|
else:
|
|
output = torch.empty(tensor_shape, device=device, dtype=dtype)
|
|
dist.broadcast(output, src=0, group=group)
|
|
return output
|
|
|
|
|
|
class _SingleRankEncoderGroup:
|
|
"""Lightweight encoder group for ``text_encoder_tp_size == 1``.
|
|
|
|
Avoids creating a distributed ``GroupCoordinator`` with a single-member
|
|
rank set, which would assert on every other DiT rank that is not part of
|
|
the group. The pipeline and encoder only use the attributes below, and
|
|
all ``world_size == 1`` code paths short-circuit before any collective.
|
|
"""
|
|
|
|
world_size: int = 1
|
|
ranks: list[int] = [0]
|
|
|
|
def __init__(self, rank: int) -> None:
|
|
self.rank_in_group = 0 if rank == 0 else -1
|
|
self.device_group = None
|
|
|
|
|
|
class MiniMaxH3Pipeline(
|
|
nn.Module,
|
|
DenoiseProgressMixin,
|
|
ProgressBarMixin,
|
|
DiffusionPipelineProfilerMixin,
|
|
SupportImageInput,
|
|
SupportAudioInput,
|
|
SupportAudioOutput,
|
|
SupportsComponentDiscovery,
|
|
):
|
|
"""CFG-distilled joint video/audio generation for MiniMax H3."""
|
|
|
|
supports_step_execution: ClassVar[bool] = True
|
|
|
|
_dit_modules: ClassVar[list[str]] = ["transformer", "transformers_ref"]
|
|
_encoder_modules: ClassVar[list[str]] = ["text_encoder"]
|
|
_vae_modules: ClassVar[list[str]] = ["video_vae", "audio_vae"]
|
|
_offload_plan: ClassVar[OffloadPlan] = OffloadPlan(
|
|
offload_submodules={"token_refiner": "blocks"},
|
|
resident_dit_paths=frozenset({"transformer"}),
|
|
encoder_component_types={"text_encoder": TEXT_ENCODER_COMPONENT},
|
|
encoder_block_attrs={"text_encoder": ("vision.blocks", "text_model.layers")},
|
|
on_demand_component_paths=frozenset({"text_encoder", "video_vae", "audio_vae"}),
|
|
)
|
|
_PROFILER_TARGETS: ClassVar[list[str]] = [
|
|
"encode_prompt",
|
|
"_encode_local_media",
|
|
"diffuse",
|
|
"decode",
|
|
"video_vae.decode_latent",
|
|
"audio_vae.decode_latent",
|
|
"prepare_encode",
|
|
"denoise_step",
|
|
"post_decode",
|
|
]
|
|
dummy_run_num_frames: ClassVar[int] = 0
|
|
# Only distilled releases pin a schedule, so the default keeps the legacy
|
|
# uniform path available to partially constructed pipelines.
|
|
_base_schedule_by_partition: ClassVar[Mapping[str, DMD2SigmaSchedule | None]] = {}
|
|
# Set from --lora-path during construction; absent means no FastH3 adapter.
|
|
_fasth3: FastH3WeightFusion | None = None
|
|
_fasth3_checkpoint: FastH3CheckpointSpec | None = None
|
|
|
|
def _load_diffusion_lora_adapter(
|
|
self,
|
|
*,
|
|
lora_request: LoRARequest,
|
|
lora_path: str | Path,
|
|
dtype: torch.dtype,
|
|
) -> tuple[LoRAModel, PEFTHelper] | None:
|
|
# A cache eviction may be followed by a different adapter reusing the
|
|
# same client-supplied ID. Every real load replaces the classification.
|
|
self._turbo_lora_specs.pop(lora_request.lora_int_id, None)
|
|
self._native_lora_adapter_ids.discard(lora_request.lora_int_id)
|
|
self._lora_sigma_schedules.pop(lora_request.lora_int_id, None)
|
|
od_config = getattr(self, "od_config", None)
|
|
offload_modes = []
|
|
if od_config is not None:
|
|
resolved_offload = resolve_offload(od_config)
|
|
if resolved_offload.offloads(DIT_COMPONENT):
|
|
if resolved_offload.strategy is OffloadStrategy.MODEL_LEVEL:
|
|
offload_modes.append("model-level CPU offload")
|
|
elif resolved_offload.strategy is OffloadStrategy.LAYER_WISE:
|
|
offload_modes.append("layerwise offload")
|
|
loaded = load_minimax_h3_turbo_lora(
|
|
partition=self.partition,
|
|
lora_request=lora_request,
|
|
lora_path=lora_path,
|
|
dtype=dtype,
|
|
unsupported_offload_mode=" or ".join(offload_modes) or None,
|
|
)
|
|
if loaded is not None:
|
|
lora_model, peft_helper, turbo_spec = loaded
|
|
self._turbo_lora_specs[lora_request.lora_int_id] = turbo_spec
|
|
return lora_model, peft_helper
|
|
|
|
# Selection is by the artifact's safetensors ``key_format``, not by the
|
|
# running platform: the native loader is checkpoint-format parsing with
|
|
# no ``torch_npu`` dependency, so it needs no ``current_omni_platform``
|
|
# dispatch and binds the same adapter on NPU, CUDA and CPU.
|
|
native_loaded = load_minimax_h3_native_lora(
|
|
partition=self.partition,
|
|
lora_request=lora_request,
|
|
lora_path=lora_path,
|
|
dtype=dtype,
|
|
unsupported_offload_mode=" or ".join(offload_modes) or None,
|
|
)
|
|
if native_loaded is not None:
|
|
lora_model, peft_helper, sigma_schedule = native_loaded
|
|
self._native_lora_adapter_ids.add(lora_request.lora_int_id)
|
|
self._lora_sigma_schedules[lora_request.lora_int_id] = sigma_schedule
|
|
return lora_model, peft_helper
|
|
return None
|
|
|
|
def _validate_diffusion_lora_binding(
|
|
self,
|
|
*,
|
|
lora_model: LoRAModel,
|
|
bound_lora_names: frozenset[str],
|
|
) -> None:
|
|
if lora_model.id in self._turbo_lora_specs:
|
|
missing = sorted(set(lora_model.loras) - bound_lora_names)
|
|
if missing:
|
|
raise ValueError(
|
|
"MiniMax-H3 Turbo LoRA binding is incomplete: "
|
|
f"bound={len(bound_lora_names)}/{len(lora_model.loras)}, missing={missing[:5]}"
|
|
)
|
|
return
|
|
if lora_model.id not in self._native_lora_adapter_ids:
|
|
return
|
|
missing = sorted(set(lora_model.loras) - bound_lora_names)
|
|
if missing:
|
|
raise ValueError(
|
|
"MiniMax-H3 native LoRA binding is incomplete: "
|
|
f"bound={len(bound_lora_names)}/{len(lora_model.loras)}, missing={missing[:5]}"
|
|
)
|
|
|
|
def _active_turbo_spec(self, sampling: Any) -> TurboSpec | None:
|
|
"""Return the spec of the Turbo adapter this request actually applies.
|
|
|
|
A recognized adapter at scale 0 contributes nothing, so it neither
|
|
constrains the task nor imposes its sampler contract.
|
|
"""
|
|
|
|
lora_request = sampling.lora_request
|
|
if lora_request is None or math.isclose(0.0, float(sampling.lora_scale)):
|
|
return None
|
|
return self._turbo_lora_specs.get(lora_request.lora_int_id)
|
|
|
|
def _has_active_native_lora(self, sampling: Any) -> bool:
|
|
lora_request = sampling.lora_request
|
|
return (
|
|
lora_request is not None
|
|
and not math.isclose(0.0, float(sampling.lora_scale))
|
|
and lora_request.lora_int_id in self._native_lora_adapter_ids
|
|
)
|
|
|
|
def _validate_native_sampling(self, sampling: Any, *, task: str) -> None:
|
|
if task != "t2va":
|
|
raise OmniClientError("MiniMax-H3 native LoRA supports T2VA requests only")
|
|
# Derive the expected count from the adapter's own schedule so the
|
|
# message can never disagree with the schedule the denoise loop runs.
|
|
schedule = self._lora_sigma_schedules.get(sampling.lora_request.lora_int_id)
|
|
expected_steps = MINIMAX_H3_NATIVE_INFERENCE_STEPS if schedule is None else schedule.num_inference_steps
|
|
# Only request mode can take the count from the adapter schedule: step
|
|
# mode admits the request in ``StepScheduler``, which reads
|
|
# ``num_inference_steps`` off it before any pipeline hook runs. Reject
|
|
# omission there rather than advertise a contract that would either fail
|
|
# admission or disagree with the denoise loop.
|
|
od_config = getattr(self, "od_config", None)
|
|
omission_allowed = not getattr(od_config, "step_execution", False)
|
|
or_omitted = " or omitted" if omission_allowed else ""
|
|
sigma_steps = sampling.num_inference_steps
|
|
if sigma_steps is None:
|
|
if omission_allowed:
|
|
return
|
|
raise OmniClientError(
|
|
f"MiniMax-H3 native LoRA requires an explicit num_inference_steps={expected_steps} "
|
|
"under step execution, because the step scheduler derives the total step count from "
|
|
"the request before the adapter schedule is known"
|
|
)
|
|
if int(sigma_steps) == expected_steps + 1:
|
|
raise OmniClientError(
|
|
"MiniMax-H3 native LoRA uses the distilled interval-count contract; "
|
|
f"num_inference_steps must be {expected_steps}{or_omitted}, not {expected_steps + 1}"
|
|
)
|
|
if int(sigma_steps) != expected_steps:
|
|
raise OmniClientError(
|
|
f"MiniMax-H3 native LoRA requires num_inference_steps={expected_steps} "
|
|
f"(one denoiser evaluation per sigma interval){or_omitted}"
|
|
)
|
|
|
|
def _sigma_schedule_for_request(self, sampling: Any, task: str) -> DMD2SigmaSchedule | None:
|
|
lora_request = sampling.lora_request
|
|
if (
|
|
lora_request is not None
|
|
and not math.isclose(0.0, float(sampling.lora_scale))
|
|
and lora_request.lora_int_id in self._lora_sigma_schedules
|
|
):
|
|
adapter_schedule = self._lora_sigma_schedules[lora_request.lora_int_id]
|
|
checkpoint_schedule = self._base_schedule_for_task(task)
|
|
if checkpoint_schedule is not None:
|
|
raise OmniClientError(
|
|
"MiniMax-H3 native LoRA cannot be activated on a checkpoint that already pins base_schedule"
|
|
)
|
|
return adapter_schedule
|
|
return self._base_schedule_for_task(task)
|
|
|
|
def _validate_turbo_sampling(self, sampling: Any, spec: TurboSpec) -> None:
|
|
"""Hold a request to the contract of the artifact that is loaded.
|
|
|
|
Sigma-point count and both flow shifts vary across the Turbo family, so
|
|
each is checked against the adapter's own spec rather than a single
|
|
published configuration.
|
|
"""
|
|
|
|
extra = sampling.extra_args or {}
|
|
sigma_points = sampling.num_inference_steps
|
|
if sigma_points != spec.sigma_points:
|
|
raise OmniClientError(
|
|
f"{spec.filename} is a {spec.denoise_steps}-step artifact and requires "
|
|
f"num_inference_steps={spec.sigma_points} "
|
|
f"({spec.sigma_points} sigma points produce {spec.denoise_steps} denoiser evaluations)"
|
|
)
|
|
try:
|
|
video_shift = float(extra.get("flow_shift", self.default_video_shift))
|
|
except (TypeError, ValueError) as exc:
|
|
raise OmniClientError(f"{spec.filename} requires flow_shift={spec.video_shift:g}") from exc
|
|
if not math.isclose(video_shift, spec.video_shift):
|
|
raise OmniClientError(f"{spec.filename} requires flow_shift={spec.video_shift:g}")
|
|
try:
|
|
audio_shift = float(extra.get("audio_flow_shift", self.default_audio_shift))
|
|
except (TypeError, ValueError) as exc:
|
|
raise OmniClientError(f"{spec.filename} requires audio_flow_shift={spec.audio_shift:g}") from exc
|
|
if not math.isclose(audio_shift, spec.audio_shift):
|
|
raise OmniClientError(f"{spec.filename} requires audio_flow_shift={spec.audio_shift:g}")
|
|
|
|
def adopt_cache_dit_backend(self, backend: CacheDiTBackend) -> None:
|
|
"""Adopt runner-installed generic Cache-DiT for request transitions."""
|
|
|
|
self._cache_dit_runtime.adopt(
|
|
backend,
|
|
installation_key=MINIMAX_H3_GENERIC_CACHE_KEY,
|
|
)
|
|
|
|
def is_cache_dit_enabled(self) -> bool:
|
|
"""Return the request-scoped Cache-DiT installation state."""
|
|
|
|
return self._cache_dit_runtime.is_enabled
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
od_config: OmniDiffusionConfig,
|
|
prefix: str = "",
|
|
) -> None:
|
|
del prefix
|
|
super().__init__()
|
|
self.od_config = od_config
|
|
self.parallel_config = od_config.parallel_config
|
|
if int(self.parallel_config.cfg_parallel_size) != 1:
|
|
raise ValueError("MiniMax-H3 is CFG-distilled and has no negative branch; cfg_parallel_size must be 1")
|
|
self.device = get_local_device()
|
|
self.load_text_encoder = od_config.model_loaded.get("text_encoder", True)
|
|
self.load_vae_encoder = od_config.model_loaded.get("vae_encoder", True)
|
|
if self.load_vae_encoder is False and self.load_text_encoder is True:
|
|
raise ValueError(
|
|
"MiniMax H3 does not support local text encoding with external media conditioning; "
|
|
"set text_encoder=false or enable vae_encoder"
|
|
)
|
|
self._encoder_modules = ["text_encoder"] if self.load_text_encoder else []
|
|
self._PROFILER_TARGETS = list(type(self)._PROFILER_TARGETS)
|
|
encoder_block_attrs = dict(self._offload_plan.encoder_block_attrs)
|
|
if not self.load_text_encoder:
|
|
encoder_block_attrs.pop("text_encoder", None)
|
|
on_demand_component_paths = set(self._offload_plan.on_demand_component_paths)
|
|
if not self.load_text_encoder:
|
|
on_demand_component_paths.discard("text_encoder")
|
|
self._offload_plan = replace(
|
|
self._offload_plan,
|
|
encoder_block_attrs=encoder_block_attrs,
|
|
on_demand_component_paths=frozenset(on_demand_component_paths),
|
|
)
|
|
if not self.load_text_encoder:
|
|
self._PROFILER_TARGETS.remove("encode_prompt")
|
|
if not self.load_vae_encoder:
|
|
self._PROFILER_TARGETS.remove("_encode_local_media")
|
|
modular = is_minimax_h3_modular(str(od_config.model), od_config.revision)
|
|
self.partition = _minimax_h3_partition_for_task(
|
|
getattr(od_config, "task_type", None),
|
|
str(od_config.model),
|
|
)
|
|
if modular and str(od_config.task_type or "auto").lower() == "auto":
|
|
self.partition = "fl2va"
|
|
self._turbo_lora_specs: dict[int, TurboSpec] = {}
|
|
self._native_lora_adapter_ids: set[int] = set()
|
|
self._lora_sigma_schedules: dict[int, DMD2SigmaSchedule] = {}
|
|
model_root = _resolve_minimax_h3_model_root(
|
|
str(od_config.model),
|
|
od_config.revision,
|
|
self.partition,
|
|
load_text_encoder=self.load_text_encoder,
|
|
)
|
|
if modular:
|
|
model_path = model_root
|
|
self._fasth3_checkpoint = FastH3CheckpointSpec.from_metadata(
|
|
json.loads((model_root / "fastvideo_inference.json").read_text(encoding="utf-8"))
|
|
)
|
|
self._fasth3_checkpoint.check_serving_contract(partition=self.partition, od_config=od_config)
|
|
release = self._fasth3_checkpoint.release_metadata()
|
|
vae_model_path = self._fasth3_checkpoint.resolve_native_vaes(model_root)
|
|
else:
|
|
model_path = model_root / ("Ref2VA" if self.partition == "ref2va" else "FL2VA")
|
|
model_index = json.loads((model_path / "model_index.json").read_text(encoding="utf-8"))
|
|
release = model_index.get("_minimax_h3") or {}
|
|
vae_model_path = model_path
|
|
partition = str(release.get("partition", "")).lower()
|
|
expected_partition = "ref2va" if self.partition == "ref2va" else "fl2va"
|
|
if partition != expected_partition:
|
|
raise ValueError(f"invalid MiniMax-H3 {expected_partition} partition at {model_path}")
|
|
|
|
supported_tasks = {str(task).lower() for task in release.get("tasks", [])}
|
|
if not supported_tasks:
|
|
supported_tasks = {"ref2va"} if partition == "ref2va" else {"t2va", "fl2va"}
|
|
ref2va_model_path = None
|
|
if self.partition == "combined":
|
|
ref2va_model_path = model_root / "Ref2VA"
|
|
ref2va_index_path = ref2va_model_path / "model_index.json"
|
|
if not ref2va_index_path.is_file():
|
|
raise ValueError(f"Ref2VA partition not found at {ref2va_model_path}")
|
|
ref2va_index = json.loads(ref2va_index_path.read_text(encoding="utf-8"))
|
|
ref2va_release = ref2va_index.get("_minimax_h3") or {}
|
|
if str(ref2va_release.get("partition", "")).lower() != "ref2va":
|
|
raise ValueError(f"invalid MiniMax-H3 ref2va partition at {ref2va_model_path}")
|
|
supported_tasks.update(str(task).lower() for task in ref2va_release.get("tasks", ["ref2va"]))
|
|
|
|
self.supported_tasks = frozenset(supported_tasks)
|
|
shifts = release.get("sigma_shift_scales") or {}
|
|
self.default_video_shift = float(shifts.get("video", 12.0))
|
|
self.default_audio_shift = float(shifts.get("audio", 3.0))
|
|
# Distilled releases pin their own few-step rectified-flow positions; the
|
|
# uniform schedule derived from num_inference_steps does not match what
|
|
# such a checkpoint was trained on. Each partition carries its own
|
|
# contract, so a distilled FL2VA must not drag Ref2VA onto its schedule.
|
|
self._base_schedule_by_partition = {expected_partition: _read_base_schedule(release)}
|
|
if ref2va_model_path is not None:
|
|
self._base_schedule_by_partition["ref2va"] = _read_base_schedule(ref2va_release)
|
|
|
|
self.weights_sources = [
|
|
DiffusersPipelineLoader.ComponentSource(
|
|
model_or_path=str(model_path),
|
|
subfolder="transformer",
|
|
revision=od_config.revision,
|
|
prefix="transformer.",
|
|
fall_back_to_pt=False,
|
|
)
|
|
]
|
|
self._dit_modules = ["transformer"]
|
|
if ref2va_model_path is not None:
|
|
self.weights_sources.append(
|
|
DiffusersPipelineLoader.ComponentSource(
|
|
model_or_path=str(ref2va_model_path),
|
|
subfolder="transformer",
|
|
revision=od_config.revision,
|
|
prefix="transformers_ref.",
|
|
fall_back_to_pt=False,
|
|
)
|
|
)
|
|
self._dit_modules.append("transformers_ref")
|
|
transformer_quant_config = _resolve_component_quant_config(
|
|
od_config.quantization_config,
|
|
"transformer",
|
|
)
|
|
self.transformer = MiniMaxH3DiTModel(
|
|
od_config,
|
|
quant_config=transformer_quant_config,
|
|
diffusers_weights=modular,
|
|
)
|
|
if self._fasth3_checkpoint is not None:
|
|
self.transformer.enable_vsa_gates(sparsity=self._fasth3_checkpoint.vsa_sparsity)
|
|
logger.info(
|
|
"FastH3 V2 full checkpoint: 8 transformer forwards, video/audio shifts 10/3, VSA sparsity=0.8 tile=64"
|
|
)
|
|
if ref2va_model_path is not None:
|
|
self.transformers_ref = MiniMaxH3DiTModel(
|
|
od_config,
|
|
quant_config=transformer_quant_config,
|
|
diffusers_weights=modular,
|
|
)
|
|
|
|
self._fasth3 = resolve_fasth3_fusion(od_config, self.transformer)
|
|
if self._fasth3 is not None and self._fasth3.requires_vsa:
|
|
# The artifact assigns a compression gate per DiT block, so those
|
|
# modules have to exist before load_weights streams them in. Only
|
|
# the ``transformer.`` stream is fused, and ``check_task`` admits
|
|
# T2VA only, so the Ref2VA DiT would carry 50 gates that nothing
|
|
# ever fills or reads.
|
|
self.transformer.enable_vsa_gates()
|
|
if self._fasth3 is not None:
|
|
self._fasth3.check_serving_contract(
|
|
partition=self.partition,
|
|
od_config=od_config,
|
|
video_shift=self.default_video_shift,
|
|
audio_shift=self.default_audio_shift,
|
|
)
|
|
|
|
if self.load_text_encoder:
|
|
self.tokenizer = Qwen2TokenizerFast.from_pretrained(
|
|
str(model_path),
|
|
subfolder="tokenizer",
|
|
local_files_only=os.path.isdir(model_path),
|
|
)
|
|
self.processor = Qwen3VLProcessor.from_pretrained(
|
|
str(model_path),
|
|
subfolder="processor",
|
|
local_files_only=os.path.isdir(model_path),
|
|
)
|
|
else:
|
|
self.tokenizer = None
|
|
self.processor = None
|
|
|
|
_, rank, dit_world = _dit_rank_world()
|
|
self._dit_rank = rank
|
|
if self.load_text_encoder:
|
|
text_encoder_tp_size = int(getattr(self.parallel_config, "text_encoder_tp_size", 1))
|
|
if text_encoder_tp_size < 1:
|
|
raise ValueError(f"text_encoder_tp_size must be >= 1, got {text_encoder_tp_size}")
|
|
if text_encoder_tp_size > dit_world:
|
|
raise ValueError(
|
|
f"text_encoder_tp_size must not exceed the DiT group size ({dit_world}), got {text_encoder_tp_size}"
|
|
)
|
|
# The Qwen3-VL text model uses 64 attention heads / 8 KV heads.
|
|
if 64 % text_encoder_tp_size or 8 % text_encoder_tp_size:
|
|
raise ValueError(
|
|
"text_encoder_tp_size must divide both Qwen3-VL "
|
|
f"num_attention_heads (64) and num_key_value_heads (8), "
|
|
f"got {text_encoder_tp_size}"
|
|
)
|
|
self.text_encoder_tp_size = text_encoder_tp_size
|
|
self.text_encoder_group = self._build_text_encoder_group(text_encoder_tp_size)
|
|
self.text_encoder = MiniMaxH3Qwen3VLEncoder(
|
|
os.path.join(model_path, "text_encoder"),
|
|
device=self.device,
|
|
load_model=rank < text_encoder_tp_size,
|
|
encoder_group=self.text_encoder_group,
|
|
quant_config=_resolve_minimax_h3_text_encoder_quant_config(od_config.quantization_config),
|
|
)
|
|
if rank < text_encoder_tp_size:
|
|
self.weights_sources.append(
|
|
DiffusersPipelineLoader.ComponentSource(
|
|
model_or_path=str(model_path),
|
|
subfolder="text_encoder",
|
|
revision=od_config.revision,
|
|
prefix="text_encoder.",
|
|
fall_back_to_pt=False,
|
|
)
|
|
)
|
|
else:
|
|
self.text_encoder_tp_size = 0
|
|
self.text_encoder_group = None
|
|
self.text_encoder = None
|
|
self._encoder_modules = []
|
|
legacy_manual_components = getattr(od_config, "diffusion_offload_config", None) is None and bool(
|
|
od_config.enable_layerwise_offload or getattr(od_config, "enable_distributed_layerwise_offload", False)
|
|
)
|
|
# Preserve the legacy MiniMax-H3 low-residency path. The compact API
|
|
# deliberately limits explicit component selection to dit/text_encoder,
|
|
# so VAEs stay resident for new configurations.
|
|
component_load_device = torch.device("cpu") if legacy_manual_components else self.device
|
|
self.video_vae = MiniMaxH3VideoVAE(
|
|
os.path.join(vae_model_path, "video_vae"),
|
|
device=self.device,
|
|
load_device=component_load_device,
|
|
decode_only=not self.load_vae_encoder,
|
|
trust_remote_code=od_config.trust_remote_code,
|
|
)
|
|
self.audio_vae = MiniMaxH3AudioVAE(
|
|
os.path.join(vae_model_path, "audio_vae"),
|
|
device=self.device,
|
|
load_device=component_load_device,
|
|
decode_only=not self.load_vae_encoder,
|
|
trust_remote_code=od_config.trust_remote_code,
|
|
)
|
|
# Registry-side VAE patch-parallel discovery uses ``pipeline.vae``.
|
|
self.vae = self.video_vae
|
|
|
|
self._dlo_component_cache = None
|
|
offloads_text_encoder = should_offload_component(od_config, TEXT_ENCODER_COMPONENT)
|
|
needs_component_cache = legacy_manual_components or offloads_text_encoder
|
|
if getattr(od_config, "enable_distributed_layerwise_offload", False) and needs_component_cache:
|
|
self._dlo_component_cache = BoundedAllocatorCache(self.device)
|
|
if legacy_manual_components:
|
|
_register_dlo_component_cache(
|
|
self._dlo_component_cache,
|
|
self.text_encoder,
|
|
self.video_vae,
|
|
self.audio_vae,
|
|
)
|
|
elif offloads_text_encoder:
|
|
_register_dlo_component_cache(self._dlo_component_cache, self.text_encoder)
|
|
|
|
self._quality_policy = MiniMaxH3QualityPolicy(od_config)
|
|
self._cache_dit_runtime = RequestScopedCacheDiTRuntime(self)
|
|
|
|
self.setup_diffusion_pipeline_profiler(
|
|
enable_diffusion_pipeline_profiler=(od_config.enable_diffusion_pipeline_profiler)
|
|
)
|
|
|
|
def load_weights(
|
|
self,
|
|
weights: Iterable[tuple[str, torch.Tensor]],
|
|
) -> set[str]:
|
|
def source_prefix(item: tuple[str, torch.Tensor]) -> str:
|
|
name, _ = item
|
|
prefix = name.partition(".")[0] + "."
|
|
if prefix in {"transformer.", "transformers_ref.", "text_encoder."}:
|
|
return prefix
|
|
raise ValueError(f"unexpected MiniMax-H3 weight {name!r}")
|
|
|
|
loaded_with_prefix: set[str] = set()
|
|
loaded_prefixes: set[str] = set()
|
|
transformer_loaded: set[str] = set()
|
|
for prefix, grouped_weights in groupby(weights, key=source_prefix):
|
|
if prefix in loaded_prefixes:
|
|
raise ValueError(f"MiniMax-H3 weight source {prefix!r} is not contiguous")
|
|
loaded_prefixes.add(prefix)
|
|
component = getattr(self, prefix.removesuffix("."))
|
|
if component is None:
|
|
raise ValueError(f"MiniMax-H3 component {prefix.removesuffix('.')!r} is disabled in this deployment")
|
|
stream = ((name[len(prefix) :], tensor) for name, tensor in grouped_weights)
|
|
if prefix == "transformer." and self._fasth3 is not None:
|
|
# Fuse before the model shards anything, which is also the only
|
|
# point where the checkpoint's fused QKV/MLP layouts are intact.
|
|
stream = self._fasth3.apply(stream)
|
|
loaded = component.load_weights(stream)
|
|
if prefix == "transformer.":
|
|
transformer_loaded = set(loaded)
|
|
if prefix != "text_encoder.":
|
|
component.post_load_weights()
|
|
loaded_with_prefix.update(prefix + name for name in loaded)
|
|
# Both VAEs load eagerly in ``__init__`` rather than through
|
|
# ``weights_sources``. The text encoder uses the shared component
|
|
# loader so online quantization and offload processing follow the same
|
|
# path as the DiT.
|
|
for component_name in ("video_vae", "audio_vae"):
|
|
component = getattr(self, component_name)
|
|
if component is None:
|
|
continue
|
|
loaded_with_prefix.update(f"{component_name}.{name}" for name, _ in component.named_parameters())
|
|
if self._fasth3 is not None:
|
|
# load_weights only warns on a parameter the model does not have, so
|
|
# close the adapter against what the DiT actually consumed.
|
|
self._fasth3.validate_fully_applied(transformer_loaded)
|
|
if self._fasth3_checkpoint is not None:
|
|
required_gates = {
|
|
f"blocks.{i}.attn.to_gate_compress.weight" for i in range(self.transformer.arch.num_layers)
|
|
}
|
|
if missing_gates := required_gates - transformer_loaded:
|
|
raise ValueError(f"FastH3 V2 checkpoint is missing compression gates: {sorted(missing_gates)}")
|
|
return loaded_with_prefix
|
|
|
|
@property
|
|
def lora_is_fused(self) -> bool:
|
|
"""True when --lora-path was consumed as a load-time weight fusion."""
|
|
return self._fasth3 is not None
|
|
|
|
def _transformer_for_task(self, task: str) -> MiniMaxH3DiTModel:
|
|
if task == "ref2va" and hasattr(self, "transformers_ref"):
|
|
return self.transformers_ref
|
|
return self.transformer
|
|
|
|
def _resolve_sigma_positions(self, task: str, sampling: Any) -> tuple[tuple[float, ...] | None, int]:
|
|
"""Pick the rectified-flow positions this request denoises on.
|
|
|
|
Returns them explicitly, or ``None`` to leave the uniform ladder to be
|
|
derived from the step count, together with the count the rest of the
|
|
request speaks in.
|
|
"""
|
|
if self._fasth3 is not None:
|
|
# A fused student carries its own positions; the checkpoint
|
|
# underneath it is the many-step teacher, whose schedule does not
|
|
# apply. Its five points bound four transformer forwards, and
|
|
# forwards is the unit ``check_request``, the pinned-schedule branch
|
|
# below and Cache-DiT all speak in.
|
|
positions = self._fasth3.base_schedule
|
|
return positions, len(positions) - 1
|
|
sigma_schedule = self._sigma_schedule_for_request(sampling, task)
|
|
if sigma_schedule is None:
|
|
return None, int(sampling.num_inference_steps or 50)
|
|
# The schedule lists sigma boundaries; the denoise loop runs one step
|
|
# per interval, and that count is what requests and Cache-DiT speak in.
|
|
num_steps = sigma_schedule.num_inference_steps
|
|
requested_steps = sampling.num_inference_steps
|
|
if requested_steps is not None and int(requested_steps) != num_steps:
|
|
raise OmniClientError(
|
|
"this MiniMax H3 checkpoint pins a distilled sigma schedule; num_inference_steps "
|
|
f"must be {num_steps} or omitted, got {int(requested_steps)}"
|
|
)
|
|
return sigma_schedule.base_schedule, num_steps
|
|
|
|
def _base_schedule_for_task(self, task: str) -> DMD2SigmaSchedule | None:
|
|
"""Return the distilled schedule of the partition that serves ``task``."""
|
|
partition = "ref2va" if task == "ref2va" else "fl2va"
|
|
return self._base_schedule_by_partition.get(partition)
|
|
|
|
def _resolve_task(
|
|
self,
|
|
requested: str | None,
|
|
multi_modal_data: dict[str, Any] | None = None,
|
|
*,
|
|
turbo_spec: TurboSpec | None = None,
|
|
has_native_lora: bool = False,
|
|
) -> str:
|
|
multi_modal_data = multi_modal_data or {}
|
|
if requested is None:
|
|
# A Ref2VA-only startup has no FL2VA transformer; preserve its
|
|
# historical implicit default even for image-only references.
|
|
if self.partition == "ref2va":
|
|
requested = "ref2va"
|
|
elif multi_modal_data.get("video") is not None or multi_modal_data.get("audio") is not None:
|
|
requested = "ref2va"
|
|
elif multi_modal_data.get("image") is not None:
|
|
requested = "fl2va"
|
|
else:
|
|
requested = "t2va"
|
|
task = str(requested).lower()
|
|
if task not in self.supported_tasks:
|
|
raise OmniClientError(
|
|
f"checkpoint partition {self.partition!r} supports {sorted(self.supported_tasks)}, got task={task!r}"
|
|
)
|
|
if turbo_spec is not None and task not in turbo_spec.supported_tasks:
|
|
raise OmniClientError(
|
|
f"{turbo_spec.filename} is a {turbo_spec.task_family} Turbo artifact and serves "
|
|
f"{sorted(turbo_spec.supported_tasks)}, got task={task!r}"
|
|
)
|
|
if has_native_lora and task != "t2va":
|
|
raise OmniClientError("MiniMax-H3 native LoRA supports T2VA requests only")
|
|
if self._fasth3 is not None:
|
|
self._fasth3.check_task(task)
|
|
return task
|
|
|
|
def _build_text_encoder_group(self, text_encoder_tp_size: int) -> Any:
|
|
"""Create the encoder tensor-parallel process group.
|
|
|
|
The encoder group covers the first ``text_encoder_tp_size`` DiT ranks
|
|
(the DiT group is always global ranks ``[0, dit_world)``). Every rank
|
|
participates in ``new_group`` so the collective completes; ranks
|
|
outside the group never run encoder collectives. For a single-rank
|
|
encoder we return a lightweight placeholder so non-encoder ranks do
|
|
not need to join a ``GroupCoordinator`` that would assert on ranks
|
|
outside the group.
|
|
"""
|
|
if text_encoder_tp_size == 1:
|
|
return _SingleRankEncoderGroup(rank=self._dit_rank)
|
|
ranks = list(range(text_encoder_tp_size))
|
|
return init_world_group(
|
|
ranks=ranks,
|
|
local_rank=envs.LOCAL_RANK,
|
|
backend=current_omni_platform.dist_backend,
|
|
)
|
|
|
|
def _encoder_group_broadcast_tensor(
|
|
self,
|
|
tensor: torch.Tensor | None,
|
|
*,
|
|
dtype: torch.dtype,
|
|
device: torch.device,
|
|
) -> torch.Tensor:
|
|
"""Broadcast a tensor from encoder rank 0 over the encoder TP group."""
|
|
group = self.text_encoder_group
|
|
if group.world_size == 1:
|
|
if tensor is None:
|
|
raise ValueError("source tensor is required for single-rank execution")
|
|
return tensor.to(device=device, dtype=dtype)
|
|
|
|
shape = torch.zeros(8, dtype=torch.long, device=device)
|
|
if group.rank_in_group == 0:
|
|
if tensor is None:
|
|
raise ValueError("encoder rank 0 must provide a tensor to broadcast")
|
|
shape[0] = tensor.ndim
|
|
shape[1 : tensor.ndim + 1] = torch.tensor(tensor.shape, device=device)
|
|
torch.distributed.broadcast(shape, src=group.ranks[0], group=group.device_group)
|
|
ndim = int(shape[0].item())
|
|
tensor_shape = tuple(int(value) for value in shape[1 : ndim + 1].tolist())
|
|
if group.rank_in_group == 0:
|
|
output = tensor.to(device=device, dtype=dtype).contiguous()
|
|
else:
|
|
output = torch.empty(tensor_shape, device=device, dtype=dtype)
|
|
torch.distributed.broadcast(output, src=group.ranks[0], group=group.device_group)
|
|
return output
|
|
|
|
def _distribute_encode_inputs(
|
|
self,
|
|
ids: torch.Tensor | None,
|
|
vision_kwargs: dict[str, torch.Tensor],
|
|
) -> torch.Tensor:
|
|
"""Fan out encode inputs from encoder rank 0 to the encoder TP ranks.
|
|
|
|
Mutates ``vision_kwargs`` in place so every encoder rank ends up with
|
|
the same vision tensors, and returns the broadcast ``input_ids``.
|
|
"""
|
|
keys = ("pixel_values", "image_grid_thw", "pixel_values_videos", "video_grid_thw")
|
|
key_dtypes = {
|
|
"pixel_values": torch.bfloat16,
|
|
"pixel_values_videos": torch.bfloat16,
|
|
"image_grid_thw": torch.long,
|
|
"video_grid_thw": torch.long,
|
|
}
|
|
group = self.text_encoder_group
|
|
device = self.device
|
|
if group.world_size == 1:
|
|
if ids is None:
|
|
raise ValueError("encoder rank 0 must produce input ids")
|
|
return ids.to(device=device, dtype=torch.long)
|
|
|
|
mask = torch.zeros(len(keys), dtype=torch.long, device=device)
|
|
if group.rank_in_group == 0:
|
|
for index, key in enumerate(keys):
|
|
mask[index] = 1 if key in vision_kwargs else 0
|
|
torch.distributed.broadcast(mask, src=group.ranks[0], group=group.device_group)
|
|
|
|
if group.rank_in_group == 0:
|
|
ids = self._encoder_group_broadcast_tensor(ids, dtype=torch.long, device=device)
|
|
else:
|
|
ids = self._encoder_group_broadcast_tensor(None, dtype=torch.long, device=device)
|
|
for index, key in enumerate(keys):
|
|
if mask[index].item() == 0:
|
|
continue
|
|
source = vision_kwargs.get(key) if group.rank_in_group == 0 else None
|
|
vision_kwargs[key] = self._encoder_group_broadcast_tensor(
|
|
source,
|
|
dtype=key_dtypes[key],
|
|
device=device,
|
|
)
|
|
return ids
|
|
|
|
def _encode_text_hidden(
|
|
self,
|
|
input_ids: torch.Tensor,
|
|
vision_kwargs: dict[str, torch.Tensor],
|
|
) -> torch.Tensor:
|
|
if getattr(self, "_model_cpu_offload_modules", None):
|
|
# Invoke nn.Module.__call__ so the generic model-level offloader
|
|
# swaps the resident DiT and encoder.
|
|
return self.text_encoder(input_ids, **vision_kwargs)
|
|
|
|
if self._uses_manual_component_offload(self.text_encoder):
|
|
with self._component_on_device(self.text_encoder):
|
|
return self.text_encoder.encode_ids(input_ids, **vision_kwargs)
|
|
|
|
# Keep Qwen resident when it is not selected for layerwise offload.
|
|
self.text_encoder.load_to_device()
|
|
return self.text_encoder.encode_ids(input_ids, **vision_kwargs)
|
|
|
|
def _uses_manual_component_offload(self, component: nn.Module) -> bool:
|
|
od_config = getattr(self, "od_config", None)
|
|
if od_config is None:
|
|
return False
|
|
if getattr(od_config, "diffusion_offload_config", None) is None:
|
|
return bool(
|
|
getattr(od_config, "enable_layerwise_offload", False)
|
|
or getattr(od_config, "enable_distributed_layerwise_offload", False)
|
|
)
|
|
return component is getattr(self, "text_encoder", None) and should_offload_component(
|
|
od_config, TEXT_ENCODER_COMPONENT
|
|
)
|
|
|
|
def enable_omni_model_cpu_offload(
|
|
self,
|
|
*,
|
|
device: torch.device,
|
|
pin_memory: bool,
|
|
use_hsdp: bool,
|
|
offload_components: frozenset[str] | None = None,
|
|
) -> None:
|
|
if getattr(self, "_model_cpu_offload_modules", None):
|
|
return
|
|
|
|
components = ModuleDiscovery.discover(self)
|
|
dits = components.dits
|
|
stages = [*components.encoders, *components.vaes]
|
|
modules = [*dits, *stages]
|
|
selection_options: dict[str, Any] = {}
|
|
if offload_components is not None:
|
|
if DIT_COMPONENT in offload_components and not dits:
|
|
raise ValueError("MiniMax-H3 has no loaded DiT for selected module offload")
|
|
if TEXT_ENCODER_COMPONENT in offload_components and not components.encoders:
|
|
raise ValueError("MiniMax-H3 has no loaded text encoder for selected module offload")
|
|
selection_options = {
|
|
"offload_dit_modules": dits if DIT_COMPONENT in offload_components else (),
|
|
"offload_encoder_modules": (
|
|
components.encoders if TEXT_ENCODER_COMPONENT in offload_components else ()
|
|
),
|
|
}
|
|
apply_sequential_offload(
|
|
dit_modules=dits,
|
|
encoder_modules=stages,
|
|
device=device,
|
|
pin_memory=pin_memory,
|
|
use_hsdp=use_hsdp,
|
|
offload_initial_dits=offload_components is None or DIT_COMPONENT in offload_components,
|
|
**selection_options,
|
|
)
|
|
|
|
self._model_cpu_offload_modules = modules
|
|
logger.info(
|
|
"MiniMax-H3 model-level CPU offload enabled for selected components: %s",
|
|
sorted(offload_components) if offload_components is not None else "legacy full topology",
|
|
)
|
|
|
|
def disable_omni_model_cpu_offload(self) -> None:
|
|
modules = getattr(self, "_model_cpu_offload_modules", None)
|
|
if not modules:
|
|
return
|
|
remove_sequential_offload(modules)
|
|
self._model_cpu_offload_modules = []
|
|
|
|
@contextmanager
|
|
def _component_on_device(self, component: nn.Module):
|
|
if getattr(self, "_model_cpu_offload_modules", None):
|
|
with sequential_offload_component(component):
|
|
yield
|
|
return
|
|
staged = self._uses_manual_component_offload(component)
|
|
try:
|
|
if staged:
|
|
component.load_to_device()
|
|
yield
|
|
except BaseException:
|
|
if staged:
|
|
try:
|
|
component.offload_to_cpu()
|
|
except BaseException:
|
|
logger.exception("Failed to release %s after component failure", component.__class__.__name__)
|
|
cache = getattr(self, "_dlo_component_cache", None)
|
|
if cache is not None:
|
|
try:
|
|
cache.release_if_needed(force=True)
|
|
except BaseException:
|
|
logger.exception("Failed to release retained allocator cache after component failure")
|
|
raise
|
|
else:
|
|
if staged:
|
|
try:
|
|
component.offload_to_cpu()
|
|
except BaseException:
|
|
cache = getattr(self, "_dlo_component_cache", None)
|
|
if cache is not None:
|
|
try:
|
|
cache.release_if_needed(force=True)
|
|
except BaseException:
|
|
logger.exception("Failed to release retained allocator cache after offload failure")
|
|
raise
|
|
|
|
@staticmethod
|
|
def _is_output_owner_rank() -> bool:
|
|
"""Whether this rank's output is returned by the diffusion executor."""
|
|
return not dist.is_available() or not dist.is_initialized() or dist.get_rank() == 0
|
|
|
|
def _offload_model_cpu_stage_output(self, tensor: torch.Tensor) -> torch.Tensor:
|
|
"""Release a decoded output's storage before a later seed reloads the DiT.
|
|
|
|
Model-level CPU offload makes the VAE hook evict the DiT before decode.
|
|
For a multi-output request, however, the next seed reloads the DiT while
|
|
the preceding decoded output would otherwise still occupy the device.
|
|
The reply rank performs the D2H copy early; ranks whose outputs are not
|
|
consumed retain only a metadata placeholder for the final concatenation.
|
|
|
|
Call this on the tensor that is actually returned to the engine. Audio is
|
|
released inside ``decode``; video is released by the callers of ``decode``
|
|
only after ``_prepare_minimax_h3_video_output`` has quantized it, so the
|
|
early D2H copy carries ``uint8`` frames rather than the decoded floats.
|
|
"""
|
|
if not getattr(self, "_model_cpu_offload_modules", None):
|
|
return tensor
|
|
if self._is_output_owner_rank():
|
|
return tensor.cpu()
|
|
return torch.empty(tensor.shape, dtype=tensor.dtype, device="meta")
|
|
|
|
def _initial_noise(
|
|
self,
|
|
*,
|
|
seed: int,
|
|
latent_t: int,
|
|
latent_h: int,
|
|
latent_w: int,
|
|
audio_t: int,
|
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
video_generator = torch.Generator(device="cpu").manual_seed(seed)
|
|
video = torch.randn(
|
|
1,
|
|
24,
|
|
latent_t,
|
|
latent_h,
|
|
latent_w,
|
|
generator=video_generator,
|
|
dtype=torch.float32,
|
|
)
|
|
video_rows = minimax_h3_patchify_video_latent(
|
|
video,
|
|
patch_size=(1, 2, 2),
|
|
)
|
|
audio_generator = torch.Generator(device="cpu").manual_seed(seed)
|
|
audio_rows = torch.randn(
|
|
audio_t * 2,
|
|
32,
|
|
generator=audio_generator,
|
|
dtype=torch.float32,
|
|
)
|
|
return video_rows, audio_rows
|
|
|
|
@contextmanager
|
|
def _resident_dit_layers_on_device(self, *, enabled: bool = True):
|
|
controller = getattr(self, "_dlo_residency_controller", None)
|
|
if controller is not None and enabled:
|
|
controller.load_resident_layers()
|
|
try:
|
|
yield
|
|
finally:
|
|
if controller is not None and enabled:
|
|
controller.offload_resident_layers()
|
|
|
|
def _build_denoise_inputs(
|
|
self,
|
|
*,
|
|
task: str,
|
|
text_embeddings: torch.Tensor,
|
|
text_tags: torch.Tensor,
|
|
seed: int,
|
|
latent_t: int,
|
|
latent_h: int,
|
|
latent_w: int,
|
|
audio_t: int,
|
|
num_frames: int,
|
|
num_steps: int,
|
|
video_shift: float,
|
|
audio_shift: float,
|
|
base_schedule: Sequence[float] | None,
|
|
visual_condition: torch.Tensor | None,
|
|
visual_condition_shape: tuple[int, int, int] | None,
|
|
audio_condition: torch.Tensor | None,
|
|
ref_audio_t: int | None,
|
|
ref_blocks: list[dict[str, Any]] | None = None,
|
|
visual_condition_shapes: list[tuple[int, int, int]] | None = None,
|
|
audio_condition_lengths: list[int] | None = None,
|
|
keyframe_frame_indices: list[int] | None = None,
|
|
pad_seq_len: int | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Build the packed layout, initial rows, anchors, and sigma schedules.
|
|
|
|
Shared by request-mode :meth:`diffuse` and step-mode
|
|
:meth:`prepare_encode` so both paths start from identical state.
|
|
"""
|
|
initial_video, initial_audio = self._initial_noise(
|
|
seed=seed,
|
|
latent_t=latent_t,
|
|
latent_h=latent_h,
|
|
latent_w=latent_w,
|
|
audio_t=audio_t,
|
|
)
|
|
if task == "ref2va":
|
|
if ref_blocks is None:
|
|
if visual_condition_shape is None or ref_audio_t is None:
|
|
raise ValueError("ref2va condition metadata is missing")
|
|
_, ref_h, ref_w = visual_condition_shape
|
|
ref_blocks = [
|
|
{"kind": "image", "latent_h": ref_h, "latent_w": ref_w},
|
|
{"kind": "audio", "ref_audio_t": ref_audio_t},
|
|
]
|
|
packed = minimax_h3_packed_sequence_ref2va_blocks(
|
|
text_len=int(text_embeddings.shape[0]),
|
|
latent_t=latent_t,
|
|
latent_h=latent_h,
|
|
latent_w=latent_w,
|
|
audio_t=audio_t,
|
|
ref_blocks=ref_blocks,
|
|
seq_len=pad_seq_len,
|
|
)
|
|
else:
|
|
packed = minimax_h3_packed_sequence(
|
|
text_len=int(text_embeddings.shape[0]),
|
|
latent_t=latent_t,
|
|
latent_h=latent_h,
|
|
latent_w=latent_w,
|
|
audio_t=audio_t,
|
|
include_keyframe_cond=task == "fl2va",
|
|
keyframe_frame_indices=keyframe_frame_indices if task == "fl2va" else None,
|
|
frame_count=num_frames if task == "fl2va" else None,
|
|
seq_len=pad_seq_len,
|
|
)
|
|
# Report the effective shape at info level exactly when a request pins
|
|
# it, so a deployment can confirm the pin landed without raising the
|
|
# log level for every request.
|
|
log = logger.info if pad_seq_len is not None else logger.debug
|
|
log(
|
|
"MiniMax H3 packed sequence: task=%s pad_seq_len=%s used=%d seq_len=%d",
|
|
task,
|
|
pad_seq_len,
|
|
int(packed["cu_seqlens"][1]),
|
|
int(packed["seq_len"]),
|
|
)
|
|
|
|
tags = packed["token_tags"].clone()
|
|
tags[packed["text_pos"]] = text_tags.cpu()
|
|
branch = MiniMaxH3DenoiseBranch(
|
|
packed=packed,
|
|
text_embeddings=text_embeddings,
|
|
token_tags=tags,
|
|
device=self.device,
|
|
)
|
|
|
|
visual_anchor = visual_condition
|
|
if visual_anchor is not None:
|
|
condition_shapes = visual_condition_shapes
|
|
if condition_shapes is None and visual_condition_shape is not None:
|
|
condition_shapes = [visual_condition_shape]
|
|
if not condition_shapes:
|
|
raise ValueError("visual condition shape is missing")
|
|
visual_anchor = minimax_h3_imgvid_cond_noise_aug_rows(
|
|
visual_anchor,
|
|
condition_shapes=condition_shapes,
|
|
target_latent_t=latent_t,
|
|
imgvid_cond_num_frames=len(condition_shapes),
|
|
seed=seed,
|
|
noise_aug=MINIMAX_H3_IMGVID_COND_TIMESTEP,
|
|
)
|
|
full_video = torch.zeros(
|
|
branch.img_pos.shape[0],
|
|
96,
|
|
dtype=torch.float32,
|
|
)
|
|
full_video[branch.update_mask] = initial_video
|
|
initial_video = full_video
|
|
|
|
audio_anchor = audio_condition
|
|
if audio_anchor is not None:
|
|
condition_audio_t = audio_condition_lengths
|
|
if condition_audio_t is None and ref_audio_t is not None:
|
|
condition_audio_t = [ref_audio_t]
|
|
if not condition_audio_t:
|
|
raise ValueError("reference audio length is missing")
|
|
audio_anchor = minimax_h3_audio_cond_noise_aug_rows(
|
|
audio_anchor,
|
|
condition_audio_t=condition_audio_t,
|
|
seed=seed,
|
|
noise_aug=MINIMAX_H3_AUDIO_REF_COND_TIMESTEP,
|
|
)
|
|
full_audio = torch.zeros(
|
|
branch.audio_pos.shape[0],
|
|
32,
|
|
dtype=torch.float32,
|
|
)
|
|
full_audio[branch.audio_update_mask] = initial_audio
|
|
initial_audio = full_audio
|
|
|
|
video_sigmas = minimax_h3_time_shift_sigmas(
|
|
num_steps=num_steps,
|
|
shift_scale=video_shift,
|
|
base_schedule=base_schedule,
|
|
)
|
|
audio_sigmas = minimax_h3_time_shift_sigmas(
|
|
num_steps=num_steps,
|
|
shift_scale=audio_shift,
|
|
base_schedule=base_schedule,
|
|
)
|
|
return {
|
|
"branch": branch,
|
|
# The request-mode loop moves these onto the device itself; step mode
|
|
# keeps them resident across steps, so normalize once for both.
|
|
"video_rows": initial_video.to(device=self.device, dtype=torch.float32),
|
|
"audio_rows": initial_audio.to(device=self.device, dtype=torch.float32),
|
|
"cond_anchor": (
|
|
None if visual_anchor is None else visual_anchor.to(device=self.device, dtype=torch.float32)
|
|
),
|
|
"audio_anchor": (
|
|
None if audio_anchor is None else audio_anchor.to(device=self.device, dtype=torch.float32)
|
|
),
|
|
"sigmas_video": video_sigmas,
|
|
"sigmas_audio": audio_sigmas,
|
|
}
|
|
|
|
def _unpack_denoised_rows(
|
|
self,
|
|
branch: MiniMaxH3DenoiseBranch,
|
|
video_rows: torch.Tensor,
|
|
audio_rows: torch.Tensor,
|
|
*,
|
|
latent_t: int,
|
|
latent_h: int,
|
|
latent_w: int,
|
|
audio_t: int,
|
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
"""Select the target rows and unpack them back into VAE latents."""
|
|
target_video = video_rows[branch.update_mask_dev]
|
|
video_latent = minimax_h3_unpatchify_video_tokens(
|
|
target_video,
|
|
latent_shape=(
|
|
latent_t,
|
|
latent_h // 2,
|
|
latent_w // 2,
|
|
24,
|
|
),
|
|
patch_size=(1, 2, 2),
|
|
)
|
|
target_audio = audio_rows[branch.audio_update_mask_dev]
|
|
audio_latent = minimax_h3_unpack_audio_tokens(
|
|
target_audio,
|
|
audio_t=audio_t * 2,
|
|
audio_channel=2,
|
|
)
|
|
return video_latent, audio_latent
|
|
|
|
def diffuse(
|
|
self,
|
|
*,
|
|
task: str,
|
|
text_embeddings: torch.Tensor,
|
|
text_tags: torch.Tensor,
|
|
seed: int,
|
|
latent_t: int,
|
|
latent_h: int,
|
|
latent_w: int,
|
|
audio_t: int,
|
|
num_frames: int,
|
|
num_steps: int,
|
|
video_shift: float,
|
|
audio_shift: float,
|
|
base_schedule: Sequence[float] | None,
|
|
visual_condition: torch.Tensor | None,
|
|
visual_condition_shape: tuple[int, int, int] | None,
|
|
audio_condition: torch.Tensor | None,
|
|
ref_audio_t: int | None,
|
|
ref_blocks: list[dict[str, Any]] | None = None,
|
|
visual_condition_shapes: list[tuple[int, int, int]] | None = None,
|
|
audio_condition_lengths: list[int] | None = None,
|
|
keyframe_frame_indices: list[int] | None = None,
|
|
pad_seq_len: int | None = None,
|
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
inputs = self._build_denoise_inputs(
|
|
task=task,
|
|
text_embeddings=text_embeddings,
|
|
text_tags=text_tags,
|
|
seed=seed,
|
|
latent_t=latent_t,
|
|
latent_h=latent_h,
|
|
latent_w=latent_w,
|
|
audio_t=audio_t,
|
|
num_frames=num_frames,
|
|
num_steps=num_steps,
|
|
video_shift=video_shift,
|
|
audio_shift=audio_shift,
|
|
base_schedule=base_schedule,
|
|
visual_condition=visual_condition,
|
|
visual_condition_shape=visual_condition_shape,
|
|
audio_condition=audio_condition,
|
|
ref_audio_t=ref_audio_t,
|
|
ref_blocks=ref_blocks,
|
|
visual_condition_shapes=visual_condition_shapes,
|
|
audio_condition_lengths=audio_condition_lengths,
|
|
keyframe_frame_indices=keyframe_frame_indices,
|
|
pad_seq_len=pad_seq_len,
|
|
)
|
|
branch = inputs["branch"]
|
|
transformer = self._transformer_for_task(task)
|
|
with self._resident_dit_layers_on_device(enabled=transformer is self.transformer):
|
|
with self.progress_bar(total=len(inputs["sigmas_video"]) - 1) as progress:
|
|
video_rows, audio_rows = minimax_h3_denoise_loop(
|
|
model=transformer,
|
|
positive=branch,
|
|
initial_video_rows=inputs["video_rows"],
|
|
initial_audio_rows=inputs["audio_rows"],
|
|
keyframe_cond_rows=inputs["cond_anchor"],
|
|
audio_ref_rows=inputs["audio_anchor"],
|
|
sigmas_video=inputs["sigmas_video"],
|
|
sigmas_audio=inputs["sigmas_audio"],
|
|
device=self.device,
|
|
imgvid_cond_noise_aug_for_inference=(MINIMAX_H3_IMGVID_COND_TIMESTEP),
|
|
audio_cond_noise_aug_for_inference=(MINIMAX_H3_AUDIO_REF_COND_TIMESTEP),
|
|
on_step=lambda step, video, audio: progress.update(),
|
|
)
|
|
|
|
return self._unpack_denoised_rows(
|
|
branch,
|
|
video_rows,
|
|
audio_rows,
|
|
latent_t=latent_t,
|
|
latent_h=latent_h,
|
|
latent_w=latent_w,
|
|
audio_t=audio_t,
|
|
)
|
|
|
|
def decode_to_mp4(
|
|
self,
|
|
video_latent: torch.Tensor,
|
|
audio_latent: torch.Tensor,
|
|
*,
|
|
height: int,
|
|
width: int,
|
|
max_pending: int = 2,
|
|
batch_frames: int = 17,
|
|
video_codec_options: dict[str, str] | None = None,
|
|
) -> bytes:
|
|
"""Decode and encode one output on the worker without full-video materialization.
|
|
|
|
Audio is decoded first so the incremental mux session can attach its audio
|
|
stream before temporal video chunks arrive. The callback receives committed
|
|
float32 ``BCTHW`` frames, performs the requested-size crop and uint8
|
|
conversion in the worker, then applies bounded backpressure to the encoder.
|
|
"""
|
|
from vllm_omni.diffusion.utils.media_utils import ChunkedMP4Encoder
|
|
|
|
if batch_frames <= 0:
|
|
raise ValueError("batch_frames must be positive")
|
|
|
|
with self._component_on_device(self.audio_vae):
|
|
audio = self.audio_vae.decode_latent(audio_latent)
|
|
audio_np = audio.detach().float().cpu().numpy()
|
|
if audio_np.ndim == 3 and audio_np.shape[0] == 1:
|
|
audio_np = audio_np[0]
|
|
encoder = ChunkedMP4Encoder(
|
|
width=width,
|
|
height=height,
|
|
fps=MINIMAX_H3_FPS,
|
|
audio_waveform=audio_np,
|
|
audio_sample_rate=MINIMAX_H3_AUDIO_SAMPLE_RATE,
|
|
max_pending=max_pending,
|
|
video_codec_options=video_codec_options,
|
|
)
|
|
|
|
pending_chunks: list[torch.Tensor] = []
|
|
pending_frames = 0
|
|
|
|
def flush_pending() -> None:
|
|
nonlocal pending_frames
|
|
if not pending_chunks:
|
|
return
|
|
batched = torch.cat(pending_chunks, dim=1)
|
|
encoder.push(batched[0].cpu().numpy())
|
|
pending_chunks.clear()
|
|
pending_frames = 0
|
|
|
|
def on_chunk(frames: torch.Tensor) -> None:
|
|
nonlocal pending_frames
|
|
prepared = _prepare_minimax_h3_video_output(frames[..., :height, :width])
|
|
if prepared.shape[0] != 1:
|
|
raise ValueError("MiniMax H3 chunked MP4 encoding currently expects one output per decoder")
|
|
pending_chunks.append(prepared)
|
|
pending_frames += int(prepared.shape[1])
|
|
if pending_frames >= batch_frames:
|
|
flush_pending()
|
|
|
|
try:
|
|
with self._component_on_device(self.video_vae):
|
|
with current_omni_platform.create_autocast_context(
|
|
device_type=self.device.type,
|
|
dtype=torch.float16,
|
|
enabled=True,
|
|
):
|
|
self.video_vae.decode_with_chunks(video_latent, on_chunk=on_chunk)
|
|
flush_pending()
|
|
return encoder.finish()
|
|
except BaseException:
|
|
encoder.abort()
|
|
raise
|
|
|
|
def decode(
|
|
self,
|
|
video_latent: torch.Tensor,
|
|
audio_latent: torch.Tensor,
|
|
*,
|
|
height: int,
|
|
width: int,
|
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
with self._component_on_device(self.video_vae):
|
|
with current_omni_platform.create_autocast_context(
|
|
device_type=self.device.type,
|
|
dtype=torch.float16,
|
|
enabled=True,
|
|
):
|
|
video = self.video_vae.decode_latent(video_latent)
|
|
video = video[..., :height, :width].contiguous()
|
|
with self._component_on_device(self.audio_vae):
|
|
audio = self.audio_vae.decode_latent(audio_latent)
|
|
audio = self._offload_model_cpu_stage_output(audio)
|
|
return video, audio
|
|
|
|
@staticmethod
|
|
def _extract_prompt(raw_prompt: Any) -> tuple[str, dict[str, Any]]:
|
|
"""Split a request prompt into its text and multimodal parts."""
|
|
if isinstance(raw_prompt, str):
|
|
prompt = raw_prompt
|
|
multi_modal_data: dict[str, Any] = {}
|
|
else:
|
|
prompt = str(raw_prompt.get("prompt") or "")
|
|
multi_modal_data = raw_prompt.get("multi_modal_data") or {}
|
|
if not prompt:
|
|
raise OmniClientError("MiniMax H3 requires a non-empty prompt")
|
|
return prompt, multi_modal_data
|
|
|
|
@staticmethod
|
|
def _extract_text_conditioning(raw_prompt: Any) -> MiniMaxH3TextConditioning | None:
|
|
if isinstance(raw_prompt, str):
|
|
return None
|
|
additional_information = raw_prompt.get("additional_information") or {}
|
|
encoder_output = additional_information.get("encoder_output")
|
|
if encoder_output is None:
|
|
return None
|
|
if not isinstance(encoder_output, Mapping):
|
|
raise OmniClientError("MiniMax H3 encoder output must be a mapping")
|
|
try:
|
|
if "hidden_states" in encoder_output and "token_tags" in encoder_output:
|
|
return MiniMaxH3TextConditioning.from_payload(encoder_output)
|
|
conditioning = MiniMaxH3EncoderConditioning.from_omni_payload(encoder_output)
|
|
return MiniMaxH3TextConditioning(conditioning.hidden_states, conditioning.token_tags)
|
|
except ValueError as exc:
|
|
raise OmniClientError(str(exc)) from exc
|
|
|
|
@staticmethod
|
|
def _extract_prepared_reference_videos(raw_prompt: Any) -> list[dict[str, Any]] | None:
|
|
if isinstance(raw_prompt, str):
|
|
return None
|
|
additional_information = raw_prompt.get("additional_information") or {}
|
|
meta = additional_information.get("meta") or {}
|
|
descriptor = meta.get(MINIMAX_H3_PREPARED_REFERENCE_VIDEOS_KEY)
|
|
if descriptor is None:
|
|
return None
|
|
if not isinstance(descriptor, str):
|
|
raise OmniClientError("MiniMax H3 prepared-reference-video descriptor must be a string")
|
|
try:
|
|
_, videos = deserialize_prepared_reference_videos(descriptor)
|
|
except ValueError as exc:
|
|
raise OmniClientError(str(exc)) from exc
|
|
return videos
|
|
|
|
def encode_prompt(self, prepared: PreparedEncoderInputs | None) -> tuple[torch.Tensor, torch.Tensor]:
|
|
_, rank, _ = _dit_rank_world()
|
|
ids = tags = hidden = None
|
|
vision_kwargs: dict[str, torch.Tensor] = {}
|
|
error = None
|
|
if rank == 0:
|
|
try:
|
|
if prepared is None:
|
|
raise ValueError("rank 0 must prepare MiniMax H3 text inputs")
|
|
if prepared.images:
|
|
vision = self.processor.image_processor(images=prepared.images, return_tensors="pt")
|
|
vision_kwargs.update(
|
|
pixel_values=vision["pixel_values"],
|
|
image_grid_thw=vision["image_grid_thw"],
|
|
)
|
|
if prepared.qwen_videos:
|
|
vision = self.processor.video_processor(
|
|
videos=[frames for frames, _ in prepared.qwen_videos],
|
|
do_sample_frames=False,
|
|
return_tensors="pt",
|
|
)
|
|
vision_kwargs.update(
|
|
pixel_values_videos=vision["pixel_values_videos"],
|
|
video_grid_thw=vision["video_grid_thw"],
|
|
)
|
|
ids, tags = build_minimax_h3_presentation(
|
|
self.tokenizer,
|
|
prompt=prepared.prompt,
|
|
task=prepared.media.task,
|
|
condition_labels=prepared.condition_labels,
|
|
image_grid_thw=vision_kwargs.get("image_grid_thw"),
|
|
video_grid_thw=vision_kwargs.get("video_grid_thw"),
|
|
video_timestamps=prepared.video_timestamps,
|
|
merge_size=int(self.processor.image_processor.merge_size),
|
|
)
|
|
except Exception as exc:
|
|
error = exc
|
|
_broadcast_rank0_exception(error)
|
|
if rank < self.text_encoder_tp_size:
|
|
ids = self._distribute_encode_inputs(ids, vision_kwargs)
|
|
hidden = self._encode_text_hidden(ids, vision_kwargs)
|
|
return (
|
|
_broadcast_tensor(hidden, dtype=torch.bfloat16, device=self.device),
|
|
_broadcast_tensor(tags, dtype=torch.long, device=self.device),
|
|
)
|
|
|
|
def _distribute_media_inputs(self, media: MiniMaxH3EncoderMediaInput | None) -> MiniMaxH3EncoderMediaInput:
|
|
group, rank, world_size = _dit_rank_world()
|
|
if world_size == 1:
|
|
assert media is not None
|
|
return media
|
|
tensors = media.to_mm_tensors() if media is not None else []
|
|
header = [(media.to_metadata(), [value.dtype for value in tensors])] if media is not None else [None]
|
|
dist.broadcast_object_list(header, src=0, group=group)
|
|
metadata, dtypes = header[0]
|
|
received = []
|
|
for index, dtype in enumerate(dtypes):
|
|
value = _broadcast_tensor(tensors[index] if rank == 0 else None, dtype=dtype, device=self.device)
|
|
if rank != 0:
|
|
received.append(value.cpu())
|
|
del value
|
|
if rank == 0:
|
|
assert media is not None
|
|
return media
|
|
return MiniMaxH3EncoderMediaInput.from_mm_tensors(received, metadata)
|
|
|
|
def _broadcast_media_conditioning(
|
|
self, conditioning: MiniMaxH3EncoderMediaConditioning | None
|
|
) -> MiniMaxH3EncoderMediaConditioning:
|
|
group, rank, world_size = _dit_rank_world()
|
|
if world_size == 1:
|
|
assert conditioning is not None
|
|
return conditioning
|
|
tensor_names = ("visual_condition", "audio_condition")
|
|
header = [None]
|
|
if rank == 0:
|
|
assert conditioning is not None
|
|
metadata = {
|
|
item.name: getattr(conditioning, item.name)
|
|
for item in fields(conditioning)
|
|
if item.name not in tensor_names
|
|
}
|
|
dtypes = {
|
|
name: value.dtype if (value := getattr(conditioning, name)) is not None else None
|
|
for name in tensor_names
|
|
}
|
|
header[0] = (metadata, dtypes)
|
|
dist.broadcast_object_list(header, src=0, group=group)
|
|
metadata, dtypes = header[0]
|
|
tensors = {}
|
|
for name, dtype in dtypes.items():
|
|
source = getattr(conditioning, name) if rank == 0 else None
|
|
tensors[name] = _broadcast_tensor(source, dtype=dtype, device=self.device) if dtype is not None else None
|
|
return MiniMaxH3EncoderMediaConditioning(**metadata, **tensors)
|
|
|
|
def _encode_local_media(self, media: MiniMaxH3EncoderMediaInput | None) -> MiniMaxH3EncoderMediaConditioning:
|
|
group, rank, world_size = _dit_rank_world()
|
|
distributed_video = self.video_vae.is_distributed_enabled()
|
|
if distributed_video:
|
|
media = self._distribute_media_inputs(media)
|
|
conditioning = None
|
|
error = None
|
|
try:
|
|
if rank == 0 or distributed_video:
|
|
assert media is not None
|
|
conditioning = encode_media(
|
|
media,
|
|
video_vae=self.video_vae,
|
|
audio_vae=self.audio_vae,
|
|
emit_conditioning=rank == 0,
|
|
component_scope=self._component_on_device,
|
|
)
|
|
except ValueError as exc:
|
|
error = OmniClientError(str(exc))
|
|
except Exception as exc:
|
|
error = exc
|
|
if world_size > 1:
|
|
# All participants finish the codec phase before any latent broadcast.
|
|
errors = [None] * world_size
|
|
info = (str(error), isinstance(error, OmniClientError)) if error is not None else None
|
|
dist.all_gather_object(errors, info, group=group)
|
|
for info in errors:
|
|
if info is not None:
|
|
if error is not None:
|
|
raise error
|
|
message, is_client_error = info
|
|
raise OmniClientError(message) if is_client_error else RuntimeError(message)
|
|
elif error is not None:
|
|
raise error
|
|
return self._broadcast_media_conditioning(conditioning)
|
|
|
|
def _prepare_local_conditioning(
|
|
self,
|
|
raw_prompt: Any,
|
|
sampling: Any,
|
|
*,
|
|
require_external_text: bool = False,
|
|
) -> MiniMaxH3EncoderConditioning:
|
|
group, rank, world_size = _dit_rank_world()
|
|
prepared = text_conditioning = None
|
|
error = None
|
|
if rank == 0:
|
|
try:
|
|
_, multi_modal_data = self._extract_prompt(raw_prompt)
|
|
turbo_spec = self._active_turbo_spec(sampling)
|
|
has_native_lora = self._has_active_native_lora(sampling)
|
|
task = self._resolve_task(
|
|
(sampling.extra_args or {}).get("task"),
|
|
multi_modal_data,
|
|
turbo_spec=turbo_spec,
|
|
has_native_lora=has_native_lora,
|
|
)
|
|
if turbo_spec is not None:
|
|
self._validate_turbo_sampling(sampling, turbo_spec)
|
|
if has_native_lora:
|
|
self._validate_native_sampling(sampling, task=task)
|
|
text_conditioning = self._extract_text_conditioning(raw_prompt)
|
|
if require_external_text and text_conditioning is None:
|
|
raise OmniClientError(
|
|
"MiniMax H3 diffusion stage requires text encoder conditioning when text_encoder is not loaded"
|
|
)
|
|
prepared = prepare_encoder_inputs(
|
|
raw_prompt,
|
|
sampling,
|
|
task=task,
|
|
prepared_reference_videos=self._extract_prepared_reference_videos(raw_prompt),
|
|
)
|
|
except Exception as exc:
|
|
error = exc
|
|
_broadcast_rank0_exception(error)
|
|
reuse_text = [text_conditioning is not None]
|
|
if world_size > 1:
|
|
dist.broadcast_object_list(reuse_text, src=0, group=group)
|
|
if reuse_text[0]:
|
|
hidden = _broadcast_tensor(
|
|
text_conditioning.hidden_states if rank == 0 else None,
|
|
dtype=torch.bfloat16,
|
|
device=self.device,
|
|
)
|
|
tags = _broadcast_tensor(
|
|
text_conditioning.token_tags if rank == 0 else None, dtype=torch.long, device=self.device
|
|
)
|
|
else:
|
|
hidden, tags = self.encode_prompt(prepared)
|
|
media = self._encode_local_media(prepared.media if prepared is not None else None)
|
|
return MiniMaxH3EncoderConditioning.from_components(MiniMaxH3TextConditioning(hidden, tags), media)
|
|
|
|
def _prepare_request_inputs(self, raw_prompt: Any, sampling: Any) -> dict[str, Any]:
|
|
if self.load_text_encoder:
|
|
conditioning = self._prepare_local_conditioning(raw_prompt, sampling)
|
|
elif self.load_vae_encoder:
|
|
conditioning = self._prepare_local_conditioning(
|
|
raw_prompt,
|
|
sampling,
|
|
require_external_text=True,
|
|
)
|
|
else:
|
|
conditioning = self._extract_encoder_conditioning(raw_prompt)
|
|
return self._prepare_encoder_conditioning_inputs(conditioning, sampling)
|
|
|
|
@staticmethod
|
|
def _extract_encoder_conditioning(prompt: Any) -> MiniMaxH3EncoderConditioning:
|
|
if isinstance(prompt, list):
|
|
prompt = prompt[0] if prompt else None
|
|
additional_information = prompt.get("additional_information") if isinstance(prompt, Mapping) else None
|
|
payload = additional_information.get("encoder_output") if isinstance(additional_information, Mapping) else None
|
|
if not isinstance(payload, Mapping) or not payload:
|
|
raise OmniClientError("MiniMax H3 diffusion stage requires encoder conditioning from the encoder stage")
|
|
try:
|
|
return MiniMaxH3EncoderConditioning.from_omni_payload(payload)
|
|
except ValueError as exc:
|
|
raise OmniClientError(str(exc)) from exc
|
|
|
|
def _prepare_encoder_conditioning_inputs(
|
|
self,
|
|
conditioning: MiniMaxH3EncoderConditioning,
|
|
sampling: Any,
|
|
) -> dict[str, Any]:
|
|
extra = sampling.extra_args or {}
|
|
requested_task = extra.get("task")
|
|
if requested_task is not None and str(requested_task).lower() != conditioning.task:
|
|
raise OmniClientError(
|
|
f"MiniMax H3 encoder task {conditioning.task!r} does not match diffusion request {requested_task!r}"
|
|
)
|
|
turbo_spec = self._active_turbo_spec(sampling)
|
|
has_native_lora = self._has_active_native_lora(sampling)
|
|
task = self._resolve_task(
|
|
conditioning.task,
|
|
turbo_spec=turbo_spec,
|
|
has_native_lora=has_native_lora,
|
|
)
|
|
if turbo_spec is not None:
|
|
self._validate_turbo_sampling(sampling, turbo_spec)
|
|
if has_native_lora:
|
|
self._validate_native_sampling(sampling, task=task)
|
|
if self._fasth3_checkpoint is not None:
|
|
self._fasth3_checkpoint.check_request(
|
|
sampling,
|
|
step_execution=bool(getattr(self.od_config, "step_execution", False)),
|
|
)
|
|
if self._fasth3 is not None:
|
|
self._fasth3.check_request(
|
|
sampling,
|
|
video_shift=self.default_video_shift,
|
|
audio_shift=self.default_audio_shift,
|
|
)
|
|
|
|
if conditioning.height % 32 or conditioning.width % 32:
|
|
raise OmniClientError(
|
|
f"MiniMax H3 encoder canvas must be divisible by 32, got {conditioning.width}x{conditioning.height}"
|
|
)
|
|
expected_latent_t = MINIMAX_H3_SHAPE_PLANNER.video_latent_t(conditioning.num_frames)
|
|
expected_audio_t = MINIMAX_H3_SHAPE_PLANNER.audio_latent_t(conditioning.num_frames / MINIMAX_H3_FPS)
|
|
if (conditioning.latent_t, conditioning.audio_t) != (expected_latent_t, expected_audio_t):
|
|
raise OmniClientError(
|
|
"MiniMax H3 encoder latent shape does not match its output frame count: "
|
|
f"got ({conditioning.latent_t}, {conditioning.audio_t}), expected "
|
|
f"({expected_latent_t}, {expected_audio_t})"
|
|
)
|
|
|
|
visual_shapes = list(conditioning.visual_condition_shapes) or None
|
|
audio_lengths = list(conditioning.audio_condition_lengths) or None
|
|
|
|
base_schedule, num_steps = self._resolve_sigma_positions(task, sampling)
|
|
quality_plan = self._quality_policy.resolve(
|
|
quality=sampling.quality,
|
|
num_inference_steps=num_steps,
|
|
extra_args=extra,
|
|
)
|
|
self._cache_dit_runtime.prepare(quality_plan.cache_dit)
|
|
return {
|
|
"task": task,
|
|
"height": conditioning.height,
|
|
"width": conditioning.width,
|
|
"num_frames": conditioning.num_frames,
|
|
"latent_t": conditioning.latent_t,
|
|
"latent_h": conditioning.height // 16,
|
|
"latent_w": conditioning.width // 16,
|
|
"audio_t": conditioning.audio_t,
|
|
"text_embeddings": conditioning.hidden_states.to(device=self.device, dtype=torch.bfloat16),
|
|
"text_tags": conditioning.token_tags.to(device=self.device, dtype=torch.long),
|
|
"visual_condition": (
|
|
conditioning.visual_condition.to(device=self.device)
|
|
if conditioning.visual_condition is not None
|
|
else None
|
|
),
|
|
"visual_condition_shape": visual_shapes[0] if visual_shapes and len(visual_shapes) == 1 else None,
|
|
"audio_condition": (
|
|
conditioning.audio_condition.to(device=self.device)
|
|
if conditioning.audio_condition is not None
|
|
else None
|
|
),
|
|
"ref_audio_t": audio_lengths[0] if audio_lengths and len(audio_lengths) == 1 else None,
|
|
"ref_blocks": list(conditioning.ref_blocks) or None,
|
|
"visual_condition_shapes": visual_shapes,
|
|
"audio_condition_lengths": audio_lengths,
|
|
"keyframe_frame_indices": list(conditioning.keyframe_frame_indices) or None,
|
|
"pad_seq_len": _resolve_pad_seq_len(extra.get("pad_seq_len")),
|
|
"seed": int(sampling.seed if sampling.seed is not None else 42),
|
|
"num_steps": num_steps,
|
|
"video_shift": float(extra.get("flow_shift", self.default_video_shift)),
|
|
"audio_shift": float(extra.get("audio_flow_shift", self.default_audio_shift)),
|
|
"base_schedule": base_schedule,
|
|
"num_outputs": _resolve_minimax_h3_num_outputs(sampling.num_outputs_per_prompt),
|
|
"preencode_mp4": bool(extra.get("preencode_mp4", False)),
|
|
"preencode_batch_frames": (
|
|
normalize_preencode_batch_frames(extra.get("preencode_batch_frames", 17))
|
|
if extra.get("preencode_mp4", False)
|
|
else 17
|
|
),
|
|
"video_codec_options": normalize_video_codec_options(
|
|
extra.get("video_codec_options", {"preset": "ultrafast", "threads": "0"})
|
|
if extra.get("preencode_mp4", False)
|
|
else None
|
|
),
|
|
}
|
|
|
|
@staticmethod
|
|
def _denoise_kwargs(context: dict[str, Any]) -> dict[str, Any]:
|
|
"""Select the denoise-input arguments from a prepared request context."""
|
|
return {key: context[key] for key in _MINIMAX_H3_DENOISE_INPUT_KEYS}
|
|
|
|
@torch.no_grad()
|
|
def forward(self, request: DiffusionRequestBatch) -> DiffusionOutput:
|
|
if len(request.prompts) != 1:
|
|
raise OmniClientError("MiniMax H3 supports one request at a time")
|
|
context = self._prepare_request_inputs(
|
|
request.prompts[0],
|
|
request.sampling_params,
|
|
)
|
|
denoise_kwargs = self._denoise_kwargs(context)
|
|
num_outputs = context["num_outputs"]
|
|
videos = []
|
|
audios = []
|
|
for output_seed in _minimax_h3_output_seeds(context["seed"], num_outputs):
|
|
video_latent, audio_latent = self.diffuse(**{**denoise_kwargs, "seed": output_seed})
|
|
if context["preencode_mp4"]:
|
|
videos.append(
|
|
self.decode_to_mp4(
|
|
video_latent,
|
|
audio_latent,
|
|
height=context["height"],
|
|
width=context["width"],
|
|
video_codec_options=context["video_codec_options"],
|
|
batch_frames=context["preencode_batch_frames"],
|
|
)
|
|
)
|
|
audios.append(None)
|
|
else:
|
|
video, audio = self.decode(
|
|
video_latent,
|
|
audio_latent,
|
|
height=context["height"],
|
|
width=context["width"],
|
|
)
|
|
# Rebind rather than append the expression: the local would
|
|
# otherwise keep the decoded frames' device storage alive for the
|
|
# rest of the iteration, and the next seed's diffuse() -- the DiT
|
|
# reload this release exists to make room for -- runs before the
|
|
# loop rebinds it. post_decode() rebinds for the same reason.
|
|
video = self._offload_model_cpu_stage_output(_prepare_minimax_h3_video_output(video))
|
|
videos.append(video)
|
|
audios.append(audio)
|
|
if videos and isinstance(videos[0], bytes):
|
|
video = videos[0] if len(videos) == 1 else videos
|
|
audio = None
|
|
else:
|
|
video = videos[0] if len(videos) == 1 else torch.cat(videos, dim=0)
|
|
audio = audios[0] if len(audios) == 1 else torch.cat(audios, dim=0)
|
|
return DiffusionOutput(
|
|
output=(video, audio),
|
|
post_process_func=get_minimax_h3_post_process_func(self.od_config),
|
|
stage_durations=(self.stage_durations if hasattr(self, "_stage_durations") else {}),
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Step-wise execution (continuous batching)
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _packed_batch_supported(transformer: MiniMaxH3DiTModel) -> bool:
|
|
"""Whether every attention in this DiT honors multi-document cu_seqlens.
|
|
|
|
A packed batch is only isolated if *all* of them do: the token refiner
|
|
runs under its own attention role and can resolve to a different backend
|
|
from the DiT blocks. Ring sequence parallelism dispatches through
|
|
``RingParallelAttention``, whose kernels ignore the packed
|
|
``cu_seqlens`` metadata regardless of the configured backend; packing
|
|
multiple requests under ring would let attention cross document
|
|
boundaries, so any layer running ring disqualifies the batch.
|
|
|
|
The gate probes a per-backend capability rather than a fixed backend
|
|
name: FLASH_ATTN, for example, only isolates arbitrary N-document
|
|
packed cu_seqlens on CUDA/ROCm/MUSA. Its NPU path only accepts a
|
|
``[real, pad]`` two-document layout and its XPU path ignores
|
|
cu_seqlens outright — either would silently attend across request
|
|
boundaries.
|
|
"""
|
|
attentions = [module for module in transformer.modules() if isinstance(module, MiniMaxH3Attention)]
|
|
if not attentions:
|
|
return False
|
|
return all(_attention_isolates_packed_requests(module.attention) for module in attentions)
|
|
|
|
def prepare_encode(self, state: StepRequestState, **kwargs: Any) -> StepRequestState:
|
|
"""Run every request-level stage once and seed the per-request step state."""
|
|
del kwargs
|
|
# Two request-mode features have no place in the shared step contract:
|
|
# a request state carries exactly one latent tensor, and distributed
|
|
# layerwise offload streams the DiT around one whole denoise loop rather
|
|
# than around a single scheduler-driven step.
|
|
num_outputs = _resolve_minimax_h3_num_outputs(state.sampling.num_outputs_per_prompt)
|
|
if num_outputs != 1:
|
|
raise OmniClientError(
|
|
f"MiniMax H3 step execution produces one output per request, got num_outputs_per_prompt={num_outputs}"
|
|
)
|
|
if getattr(self, "_dlo_residency_controller", None) is not None:
|
|
raise ValueError(
|
|
"MiniMax H3 step execution is not compatible with distributed layerwise offload; "
|
|
"the resident-layer window spans a whole denoise loop, so per-step streaming would "
|
|
"reload the DiT every step. Drop --step-execution or --enable-distributed-layerwise-offload."
|
|
)
|
|
# Request-scoped Cache-DiT (quality=high) mutates hook state on the
|
|
# shared transformer rather than on ``StepRequestState``. In step mode
|
|
# two requests can interleave denoise steps, or be co-batched into a
|
|
# single forward, and the second one would then re-enter the DiT with
|
|
# cache buffers shaped for the first. Reject the profile here rather
|
|
# than let it corrupt outputs at runtime; startup-configured Cache-DiT
|
|
# is already blocked in ``DiffusionModelRunner.execute_stepwise``.
|
|
if getattr(state.sampling, "quality", None) == "high":
|
|
raise OmniClientError(
|
|
"MiniMax H3 step execution does not support the high-quality Cache-DiT profile "
|
|
"(quality=high); its hooks live on the shared transformer, so interleaved or "
|
|
"co-batched requests would reuse incompatible cache state. Drop --step-execution "
|
|
"or omit quality=high."
|
|
)
|
|
context = self._prepare_request_inputs(
|
|
state.prompt,
|
|
state.sampling,
|
|
)
|
|
inputs = self._build_denoise_inputs(**self._denoise_kwargs(context))
|
|
|
|
sigmas_video = inputs["sigmas_video"]
|
|
sigmas_audio = inputs["sigmas_audio"]
|
|
if len(sigmas_video) < 2:
|
|
raise OmniClientError(
|
|
f"MiniMax H3 step execution needs at least one denoise step, got num_inference_steps="
|
|
f"{len(sigmas_video) - 1}"
|
|
)
|
|
|
|
branch = inputs["branch"]
|
|
video_rows, audio_rows, cond_anchor, audio_anchor = minimax_h3_prepare_denoise_rows(
|
|
positive=branch,
|
|
initial_video_rows=inputs["video_rows"],
|
|
initial_audio_rows=inputs["audio_rows"],
|
|
keyframe_cond_rows=inputs["cond_anchor"],
|
|
audio_ref_rows=inputs["audio_anchor"],
|
|
device=self.device,
|
|
)
|
|
|
|
# The denoise loop consumes sigma pairs, so the schedule carries one more
|
|
# point than there are steps. ``timesteps`` holds the video branch because
|
|
# the shared contract gives a request exactly one timestep sequence; the
|
|
# audio schedule rides along in ``extra``.
|
|
state.timesteps = torch.tensor(
|
|
[1.0 - sigma for sigma in sigmas_video[:-1]],
|
|
dtype=torch.float32,
|
|
device=self.device,
|
|
)
|
|
state.step_index = 0
|
|
# Video rows are the batched tensor the runner slices per request; audio
|
|
# rows have a different width, so they stay request-private.
|
|
state.latents = video_rows
|
|
state.do_true_cfg = False # H3 checkpoints are CFG-distilled.
|
|
state.extra.update(
|
|
{
|
|
_STEP_BRANCH: branch,
|
|
_STEP_TRANSFORMER: self._transformer_for_task(context["task"]),
|
|
_STEP_AUDIO_ROWS: audio_rows,
|
|
_STEP_COND_ANCHOR: cond_anchor,
|
|
_STEP_AUDIO_ANCHOR: audio_anchor,
|
|
_STEP_SIGMAS_VIDEO: sigmas_video,
|
|
_STEP_SIGMAS_AUDIO: sigmas_audio,
|
|
_STEP_SHAPE: {
|
|
"height": context["height"],
|
|
"width": context["width"],
|
|
"latent_t": context["latent_t"],
|
|
"latent_h": context["latent_h"],
|
|
"latent_w": context["latent_w"],
|
|
"audio_t": context["audio_t"],
|
|
"preencode_mp4": context.get("preencode_mp4", False),
|
|
"preencode_batch_frames": context.get("preencode_batch_frames", 17),
|
|
"video_codec_options": context.get("video_codec_options"),
|
|
},
|
|
}
|
|
)
|
|
return state
|
|
|
|
def denoise_step(
|
|
self,
|
|
input_batch: InputBatch,
|
|
*,
|
|
states: Sequence[StepRequestState] | None = None,
|
|
**kwargs: Any,
|
|
) -> torch.Tensor | None:
|
|
"""Run one denoise forward covering every request in the batch.
|
|
|
|
Requests are concatenated into a single packed sequence that keeps one
|
|
attention document each, so the whole batch costs one DiT forward.
|
|
Backends that ignore ``cu_seqlens`` cannot express that isolation, so
|
|
they fall back to one forward per request.
|
|
"""
|
|
del kwargs
|
|
batch_states = list(states if states is not None else input_batch.states)
|
|
|
|
branches = [state.extra[_STEP_BRANCH] for state in batch_states]
|
|
video_rows = [state.latents for state in batch_states]
|
|
audio_rows = [state.extra[_STEP_AUDIO_ROWS] for state in batch_states]
|
|
schedules = [_minimax_h3_step_schedule(state) for state in batch_states]
|
|
transformers = [state.extra[_STEP_TRANSFORMER] for state in batch_states]
|
|
mixed_transformers = len({id(transformer) for transformer in transformers}) > 1
|
|
|
|
# Both execution modes must publish denoise progress for step-gated
|
|
# attention features. Requests can differ in both step index and sigma
|
|
# schedule, so a batch that is not at one single point has nothing to
|
|
# publish and those gates stay dense -- which is their safe default.
|
|
progress = {
|
|
(state.step_index, schedule["sigma_video"], len(state.extra[_STEP_SIGMAS_VIDEO]) - 1)
|
|
for state, schedule in zip(batch_states, schedules)
|
|
}
|
|
minimax_h3_publish_denoise_progress(*(progress.pop() if len(progress) == 1 else (None, None, None)))
|
|
|
|
if len(batch_states) > 1 and (mixed_transformers or not self._packed_batch_supported(transformers[0])):
|
|
if mixed_transformers:
|
|
logger.warning_once(
|
|
"MiniMax H3 step batch contains requests for different task-specific DiTs; "
|
|
"running %d requests one forward at a time.",
|
|
len(batch_states),
|
|
)
|
|
elif any(
|
|
getattr(getattr(module, "attention", None), "use_ring", False)
|
|
for module in transformers[0].modules()
|
|
if isinstance(module, MiniMaxH3Attention)
|
|
):
|
|
logger.warning_once(
|
|
"MiniMax H3 step batching is disabled when ring attention is active: "
|
|
"the ring kernels ignore packed cu_seqlens and would attend across request "
|
|
"boundaries. Running %d requests one forward at a time.",
|
|
len(batch_states),
|
|
)
|
|
else:
|
|
logger.warning_once(
|
|
"MiniMax H3 step batching needs every attention on a backend that isolates "
|
|
"packed multi-document cu_seqlens (see AttentionBackend."
|
|
"supports_multi_doc_packed_varlen); running %d requests one forward at a time.",
|
|
len(batch_states),
|
|
)
|
|
video_parts: list[torch.Tensor] = []
|
|
audio_parts: list[torch.Tensor] = []
|
|
for index, branch in enumerate(branches):
|
|
forward_kwargs = branch.forward_kwargs(
|
|
video_rows=video_rows[index],
|
|
audio_rows=audio_rows[index],
|
|
t_video=schedules[index]["t_video"],
|
|
t_audio=schedules[index]["t_audio"],
|
|
imgvid_cond_timestep=schedules[index]["imgvid_cond_timestep"],
|
|
audio_ref_cond_timestep=schedules[index]["audio_ref_cond_timestep"],
|
|
)
|
|
request_video, request_audio = transformers[index](**forward_kwargs)
|
|
video_parts.append(request_video)
|
|
audio_parts.append(request_audio)
|
|
video_velocity = torch.cat(video_parts)
|
|
audio_velocity = torch.cat(audio_parts)
|
|
else:
|
|
forward_kwargs = minimax_h3_batched_forward_kwargs(
|
|
branches=branches,
|
|
video_rows=video_rows,
|
|
audio_rows=audio_rows,
|
|
t_video=[schedule["t_video"] for schedule in schedules],
|
|
t_audio=[schedule["t_audio"] for schedule in schedules],
|
|
imgvid_cond_timesteps=[schedule["imgvid_cond_timestep"] for schedule in schedules],
|
|
audio_ref_cond_timesteps=[schedule["audio_ref_cond_timestep"] for schedule in schedules],
|
|
)
|
|
logger.debug(
|
|
"MiniMax H3 denoise step: %d request(s) packed into %d rows",
|
|
len(batch_states),
|
|
int(forward_kwargs["x"].shape[1]),
|
|
)
|
|
video_velocity, audio_velocity = transformers[0](**forward_kwargs)
|
|
|
|
# The shared contract carries one velocity tensor per step, and audio rows
|
|
# are a different width than video rows, so hand the audio branch to
|
|
# step_scheduler() through request-private state.
|
|
audio_parts_by_request = torch.split(audio_velocity, [int(branch.audio_pos.shape[0]) for branch in branches])
|
|
for state, request_audio in zip(batch_states, audio_parts_by_request, strict=True):
|
|
state.extra[_STEP_AUDIO_NOISE_PRED] = request_audio
|
|
return video_velocity
|
|
|
|
def step_scheduler(self, state: StepRequestState, noise_pred: torch.Tensor, **kwargs: Any) -> None:
|
|
"""Apply one Euler-eta0 update to this request's video and audio rows."""
|
|
del kwargs
|
|
# denoise_step() stages the audio half of this step's velocity; popping
|
|
# it keeps a second step_scheduler() call from reusing a stale one.
|
|
audio_noise_pred = state.extra.pop(_STEP_AUDIO_NOISE_PRED)
|
|
|
|
branch = state.extra[_STEP_BRANCH]
|
|
schedule = _minimax_h3_step_schedule(state)
|
|
update = branch.update_mask_dev
|
|
audio_update = branch.audio_update_mask_dev
|
|
video_rows = state.latents
|
|
audio_rows = state.extra[_STEP_AUDIO_ROWS]
|
|
cond_anchor = state.extra[_STEP_COND_ANCHOR]
|
|
audio_anchor = state.extra[_STEP_AUDIO_ANCHOR]
|
|
device = video_rows.device
|
|
|
|
x0_video = minimax_h3_rf_v_to_x0(
|
|
video_rows[update],
|
|
noise_pred.float()[update],
|
|
torch.tensor(schedule["t_video"], dtype=torch.float32, device=device),
|
|
)
|
|
new_video = minimax_h3_euler_eta0_step(
|
|
video_rows[update],
|
|
x0_video,
|
|
sigma_curr=schedule["sigma_video"],
|
|
sigma_next=schedule["sigma_video_next"],
|
|
)
|
|
video_rows = video_rows.clone()
|
|
video_rows[update] = new_video
|
|
if cond_anchor is not None:
|
|
video_rows[~update] = cond_anchor # per-step imgvid cond reset
|
|
|
|
x0_audio = minimax_h3_rf_v_to_x0(
|
|
audio_rows[audio_update],
|
|
audio_noise_pred.float()[audio_update],
|
|
torch.tensor(schedule["t_audio"], dtype=torch.float32, device=device),
|
|
)
|
|
new_audio = minimax_h3_euler_eta0_step(
|
|
audio_rows[audio_update],
|
|
x0_audio,
|
|
sigma_curr=schedule["sigma_audio"],
|
|
sigma_next=schedule["sigma_audio_next"],
|
|
)
|
|
audio_rows = audio_rows.clone()
|
|
audio_rows[audio_update] = new_audio
|
|
if audio_anchor is not None:
|
|
audio_rows[~audio_update] = audio_anchor # per-step audio ref reset
|
|
|
|
state.latents = video_rows
|
|
state.extra[_STEP_AUDIO_ROWS] = audio_rows
|
|
state.step_index += 1
|
|
|
|
def post_decode(self, state: StepRequestState, **kwargs: Any) -> DiffusionOutput:
|
|
"""Unpack the denoised rows and run the joint video/audio VAE decode."""
|
|
del kwargs
|
|
shape = state.extra[_STEP_SHAPE]
|
|
video_latent, audio_latent = self._unpack_denoised_rows(
|
|
state.extra[_STEP_BRANCH],
|
|
state.latents,
|
|
state.extra[_STEP_AUDIO_ROWS],
|
|
latent_t=shape["latent_t"],
|
|
latent_h=shape["latent_h"],
|
|
latent_w=shape["latent_w"],
|
|
audio_t=shape["audio_t"],
|
|
)
|
|
if shape.get("preencode_mp4", False):
|
|
video = self.decode_to_mp4(
|
|
video_latent,
|
|
audio_latent,
|
|
height=shape["height"],
|
|
width=shape["width"],
|
|
video_codec_options=shape.get("video_codec_options"),
|
|
batch_frames=shape.get("preencode_batch_frames", 17),
|
|
)
|
|
audio = None
|
|
else:
|
|
video, audio = self.decode(
|
|
video_latent,
|
|
audio_latent,
|
|
height=shape["height"],
|
|
width=shape["width"],
|
|
)
|
|
video = self._offload_model_cpu_stage_output(_prepare_minimax_h3_video_output(video))
|
|
return DiffusionOutput(
|
|
output=(video, audio),
|
|
post_process_func=get_minimax_h3_post_process_func(self.od_config),
|
|
stage_durations=(self.stage_durations if hasattr(self, "_stage_durations") else {}),
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"MiniMaxH3Pipeline",
|
|
"get_minimax_h3_post_process_func",
|
|
]
|