[Feature][MiniMax-H3] Support diffusion continuous batching (#5810)

Signed-off-by: princepride <wangzhipeng628@gmail.com>
Signed-off-by: lishunyang12 <lishunyang12@163.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: lishunyang12 <lishunyang12@163.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
authored and GitHub committed 2026-08-24 21:08:53 +08:00
1 parent aa69acbd09
commit d150a4fde7
15 files changed
+1834 -140

No files matched your search

@@ -821,6 +821,82 @@ def test_packed_attention_keeps_padding_mask_for_other_backends():
)
def _fake_packed_attention(
backend_name: str,
*,
prefix_kv_slicing: bool = False,
supports_multi_doc: bool = False,
):
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
MiniMaxH3Attention,
)
class FakeBackend:
supports_prefix_kv_slicing = prefix_kv_slicing
@staticmethod
def get_name() -> str:
return backend_name
@classmethod
def supports_multi_doc_packed_varlen(cls) -> bool:
return supports_multi_doc
class FakeAttention(torch.nn.Module):
attn_backend = FakeBackend
def __init__(self):
super().__init__()
self.metadata = None
def forward(self, query, key, value, metadata):
self.metadata = metadata
return query
attention = object.__new__(MiniMaxH3Attention)
torch.nn.Module.__init__(attention)
attention.attention = FakeAttention()
return attention
def test_packed_attention_drops_prefix_semantics_for_a_co_batched_layout():
"""A batch's valid rows are block-diagonal, so no prefix can describe them."""
attention = _fake_packed_attention("FLASH_ATTN", supports_multi_doc=True)
q = torch.randn(12, 2, 4)
attention._run_packed_attention(
q,
q,
q,
# Two requests: rows + padding tail each.
cu_seqlens=torch.tensor([0, 5, 6, 11, 12], dtype=torch.int32),
max_seqlen=5,
packed_total=12,
num_requests=2,
)
assert attention.attention.metadata.attn_mask is None
assert attention.attention.metadata.extra["valid_kv_length"] == 12
@pytest.mark.parametrize("backend_name", ["TORCH_SDPA", "FLASH_ATTN"])
def test_packed_attention_rejects_backends_without_multi_doc_capability(backend_name):
"""Backend names alone must not allow cross-request attention."""
attention = _fake_packed_attention(backend_name, supports_multi_doc=False)
q = torch.randn(12, 2, 4)
with pytest.raises(ValueError, match=backend_name):
attention._run_packed_attention(
q,
q,
q,
cu_seqlens=torch.tensor([0, 5, 6, 11, 12], dtype=torch.int32),
max_seqlen=5,
packed_total=12,
num_requests=2,
)
def test_reference_image_resize_contract():
from PIL import Image
@@ -0,0 +1,471 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""MiniMax H3 step-wise execution (continuous batching) contract tests.
These run on CPU against a stand-in DiT, so they cover the packing, the
per-request scheduler math, and the runner-facing state wiring without needing
checkpoint weights.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
import torch
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
_HIDDEN = 8
class _SegmentMeanModel:
"""Stand-in DiT whose output depends on packed-document boundaries.
Every row is mixed with the mean of its own ``cu_seqlens`` document and with
its own timestep, so a wrong document boundary, row offset, or timestep
assignment shows up as a numeric difference instead of passing silently.
"""
def __call__(self, **kwargs):
x = kwargs["x"][0]
audio_x = kwargs["audio_x"][0]
bounds = kwargs["packed_seq_params"]["cu_seqlens_q"].tolist()
img_pos = kwargs["img_pos_info"]["position_ids"]
audio_pos = kwargs["audio_pos_info"]["position_ids"]
pooled_video = torch.zeros_like(x)
pooled_audio = torch.zeros_like(audio_x)
for start, stop in zip(bounds[:-1], bounds[1:]):
if stop <= start:
continue
pooled_video[start:stop] = x[start:stop].mean(dim=0, keepdim=True)
pooled_audio[start:stop] = audio_x[start:stop].mean(dim=0, keepdim=True)
row_timesteps = kwargs["unique_timesteps"][kwargs["inverse_indices"]].unsqueeze(-1)
video = (pooled_video + 0.5 * x + row_timesteps)[img_pos]
audio = (pooled_audio + 0.5 * audio_x + row_timesteps)[audio_pos]
if not kwargs.get("skip_mask_out_condition", False):
video = video * kwargs["update_mask"].view(-1).unsqueeze(-1)
return video, audio
def _make_branch(*, text_len: int, latent_t: int, latent_h: int, latent_w: int, audio_t: int, seed: int):
from vllm_omni.diffusion.models.minimax_h3.denoise_loop import MiniMaxH3DenoiseBranch
from vllm_omni.diffusion.models.minimax_h3.packed_sequence import minimax_h3_packed_sequence
packed = minimax_h3_packed_sequence(
text_len=text_len,
latent_t=latent_t,
latent_h=latent_h,
latent_w=latent_w,
audio_t=audio_t,
include_keyframe_cond=False,
)
generator = torch.Generator().manual_seed(seed)
text_embeddings = torch.randn(text_len, _HIDDEN, generator=generator, dtype=torch.float32)
branch = MiniMaxH3DenoiseBranch(
packed=packed,
text_embeddings=text_embeddings,
token_tags=packed["token_tags"],
device=torch.device("cpu"),
)
video_rows = torch.randn(int(branch.img_pos.shape[0]), 96, generator=generator, dtype=torch.float32)
audio_rows = torch.randn(int(branch.audio_pos.shape[0]), 32, generator=generator, dtype=torch.float32)
return branch, video_rows, audio_rows
def _sigmas(num_points: int, shift: float) -> list[float]:
from vllm_omni.diffusion.models.minimax_h3.time_request import minimax_h3_time_shift_sigmas
return minimax_h3_time_shift_sigmas(num_steps=num_points, shift_scale=shift)
def _make_state(request_id: str, model, branch, video_rows, audio_rows, sigmas_video, sigmas_audio):
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as mod
from vllm_omni.diffusion.worker.utils import StepRequestState
state = StepRequestState(request_id=request_id, sampling=SimpleNamespace())
state.latents = video_rows.clone()
state.timesteps = torch.tensor([1.0 - sigma for sigma in sigmas_video[:-1]], dtype=torch.float32)
state.step_index = 0
state.extra = {
mod._STEP_BRANCH: branch,
# Co-batched requests must share one DiT instance, or denoise_step()
# treats the batch as mixed-task and falls back to one forward each.
mod._STEP_TRANSFORMER: model,
mod._STEP_AUDIO_ROWS: audio_rows.clone(),
mod._STEP_COND_ANCHOR: None,
mod._STEP_AUDIO_ANCHOR: None,
mod._STEP_SIGMAS_VIDEO: sigmas_video,
mod._STEP_SIGMAS_AUDIO: sigmas_audio,
}
return state
def _step_pipeline(model, *, packed_batch_supported: bool = True):
"""A pipeline instance carrying only what the step methods touch."""
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
pipeline.transformer = model
pipeline.device = torch.device("cpu")
pipeline._transformer_for_task = lambda task: model
pipeline._packed_batch_supported = lambda transformer: packed_batch_supported
return pipeline
def test_step_execution_matches_request_mode_denoise_loop():
"""Stepping through the contract must reproduce the request-mode loop."""
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as mod
from vllm_omni.diffusion.models.minimax_h3.denoise_loop import minimax_h3_denoise_loop
model = _SegmentMeanModel()
branch, video_rows, audio_rows = _make_branch(text_len=9, latent_t=2, latent_h=4, latent_w=6, audio_t=3, seed=5)
sigmas_video = _sigmas(6, 12.0)
sigmas_audio = _sigmas(6, 3.0)
reference_video, reference_audio = minimax_h3_denoise_loop(
model=model,
positive=branch,
initial_video_rows=video_rows,
initial_audio_rows=audio_rows,
keyframe_cond_rows=None,
sigmas_video=sigmas_video,
sigmas_audio=sigmas_audio,
device=torch.device("cpu"),
)
pipeline = _step_pipeline(model)
state = _make_state("req-0", model, branch, video_rows, audio_rows, sigmas_video, sigmas_audio)
input_batch = SimpleNamespace(states=(state,))
steps = 0
while not state.denoise_completed:
noise_pred = pipeline.denoise_step(input_batch, states=[state])
pipeline.step_scheduler(state, noise_pred)
steps += 1
assert steps == len(sigmas_video) - 1
assert state.total_steps == steps
torch.testing.assert_close(state.latents, reference_video)
torch.testing.assert_close(state.extra[mod._STEP_AUDIO_ROWS], reference_audio)
def test_batched_step_execution_matches_independent_requests():
"""Two co-batched requests must land where they would have landed alone."""
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as mod
model = _SegmentMeanModel()
specs = [
dict(text_len=9, latent_t=2, latent_h=4, latent_w=6, audio_t=3, seed=6),
# Exactly 64 packed rows exercises the no-padding-tail boundary case.
dict(text_len=46, latent_t=7, latent_h=2, latent_w=4, audio_t=2, seed=7),
]
# Different step counts, so the batch composition changes mid-flight.
schedules = [(_sigmas(6, 12.0), _sigmas(6, 3.0)), (_sigmas(4, 12.0), _sigmas(4, 3.0))]
pipeline = _step_pipeline(model)
alone: list[tuple[torch.Tensor, torch.Tensor]] = []
for spec, (sigmas_video, sigmas_audio) in zip(specs, schedules):
branch, video_rows, audio_rows = _make_branch(**spec)
state = _make_state("solo", model, branch, video_rows, audio_rows, sigmas_video, sigmas_audio)
while not state.denoise_completed:
pipeline.step_scheduler(state, pipeline.denoise_step(SimpleNamespace(states=(state,)), states=[state]))
alone.append((state.latents, state.extra[mod._STEP_AUDIO_ROWS]))
states = []
for index, (spec, (sigmas_video, sigmas_audio)) in enumerate(zip(specs, schedules)):
branch, video_rows, audio_rows = _make_branch(**spec)
if index == 1:
assert branch.used_len == branch.seq_len
states.append(_make_state(f"req-{index}", model, branch, video_rows, audio_rows, sigmas_video, sigmas_audio))
active = list(states)
while active:
noise_pred = pipeline.denoise_step(SimpleNamespace(states=tuple(active)), states=active)
offset = 0
for state in active:
rows = state.latents.shape[0]
pipeline.step_scheduler(state, noise_pred[offset : offset + rows])
offset += rows
assert offset == noise_pred.shape[0]
# Finished requests leave the batch, exactly like the runner drops them.
active = [state for state in active if not state.denoise_completed]
for state, (expected_video, expected_audio) in zip(states, alone):
torch.testing.assert_close(state.latents, expected_video)
torch.testing.assert_close(state.extra[mod._STEP_AUDIO_ROWS], expected_audio)
def test_both_modes_publish_denoise_progress_for_gated_attention():
"""TRTLLM's skip gate and RAINFUSION's warmup stay dense without these."""
from vllm_omni.diffusion import forward_context as fc
from vllm_omni.diffusion.models.minimax_h3.denoise_loop import minimax_h3_denoise_loop
model = _SegmentMeanModel()
branch, video_rows, audio_rows = _make_branch(text_len=9, latent_t=2, latent_h=4, latent_w=6, audio_t=3, seed=14)
sigmas_video = _sigmas(4, 12.0)
sigmas_audio = _sigmas(4, 3.0)
published: list[tuple[int | None, float | None]] = []
class _Recorder:
def __init__(self):
self.denoise_step_idx = None
self.denoise_timestep = None
def __setattr__(self, name, value):
object.__setattr__(self, name, value)
if name == "denoise_timestep":
published.append((self.denoise_step_idx, value))
recorder = _Recorder()
published.clear() # drop the pair __init__ recorded
original = fc._forward_context
fc._forward_context = recorder
try:
minimax_h3_denoise_loop(
model=model,
positive=branch,
initial_video_rows=video_rows,
initial_audio_rows=audio_rows,
keyframe_cond_rows=None,
sigmas_video=sigmas_video,
sigmas_audio=sigmas_audio,
device=torch.device("cpu"),
)
request_mode = list(published)
published.clear()
pipeline = _step_pipeline(model)
state = _make_state("req-0", model, branch, video_rows, audio_rows, sigmas_video, sigmas_audio)
while not state.denoise_completed:
pipeline.step_scheduler(state, pipeline.denoise_step(SimpleNamespace(states=(state,)), states=[state]))
step_mode = list(published)
finally:
fc._forward_context = original
num_steps = len(sigmas_video) - 1
expected = [(step, sigmas_video[step]) for step in range(num_steps)]
assert request_mode == expected + [(None, None)]
# Step mode has no loop to close, so it publishes only the per-step pairs.
assert step_mode == expected
def test_mixed_step_batch_leaves_gated_attention_dense():
"""A batch spanning different steps has no single progress point to publish."""
from vllm_omni.diffusion import forward_context as fc
model = _SegmentMeanModel()
first, first_video, first_audio = _make_branch(text_len=9, latent_t=2, latent_h=4, latent_w=6, audio_t=3, seed=15)
second, second_video, second_audio = _make_branch(
text_len=5, latent_t=3, latent_h=6, latent_w=4, audio_t=2, seed=16
)
sigmas_video, sigmas_audio = _sigmas(6, 12.0), _sigmas(6, 3.0)
states = [
_make_state("req-0", model, first, first_video, first_audio, sigmas_video, sigmas_audio),
_make_state("req-1", model, second, second_video, second_audio, sigmas_video, sigmas_audio),
]
states[1].step_index = 2 # admitted later, so it trails the batch
recorder = SimpleNamespace(denoise_step_idx="unset", denoise_timestep="unset")
original = fc._forward_context
fc._forward_context = recorder
try:
_step_pipeline(model).denoise_step(SimpleNamespace(states=tuple(states)), states=states)
finally:
fc._forward_context = original
assert recorder.denoise_step_idx is None
assert recorder.denoise_timestep is None
def test_prepare_encode_seeds_runner_visible_state(monkeypatch):
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as mod
branch, video_rows, audio_rows = _make_branch(text_len=9, latent_t=2, latent_h=4, latent_w=6, audio_t=3, seed=8)
sigmas_video = _sigmas(6, 12.0)
sigmas_audio = _sigmas(6, 3.0)
context = {
"height": 96,
"width": 64,
"latent_t": 2,
"latent_h": 4,
"latent_w": 6,
"audio_t": 3,
**{key: None for key in mod._MINIMAX_H3_DENOISE_INPUT_KEYS},
}
pipeline = _step_pipeline(_SegmentMeanModel())
monkeypatch.setattr(mod.MiniMaxH3Pipeline, "_extract_prompt", staticmethod(lambda _: ("a prompt", {})))
monkeypatch.setattr(mod.MiniMaxH3Pipeline, "_prepare_request_inputs", lambda self, **_: context)
monkeypatch.setattr(
mod.MiniMaxH3Pipeline,
"_build_denoise_inputs",
lambda self, **_: {
"branch": branch,
"video_rows": video_rows,
"audio_rows": audio_rows,
"cond_anchor": None,
"audio_anchor": None,
"sigmas_video": sigmas_video,
"sigmas_audio": sigmas_audio,
},
)
from vllm_omni.diffusion.worker.utils import StepRequestState
state = StepRequestState(
request_id="req-0",
sampling=SimpleNamespace(num_outputs_per_prompt=1),
prompt="a prompt",
)
pipeline.prepare_encode(state)
# The runner slices the batched velocity by this row count.
assert state.latents.shape == (int(branch.img_pos.shape[0]), 96)
assert state.total_steps == len(sigmas_video) - 1
assert state.step_index == 0
assert state.do_true_cfg is False
torch.testing.assert_close(state.current_timestep, torch.tensor(1.0 - sigmas_video[0]))
assert state.extra[mod._STEP_BRANCH] is branch
assert state.extra[mod._STEP_SHAPE]["height"] == 96
def test_prepare_encode_rejects_request_mode_only_features():
"""Multi-output and DLO have no representation in the step contract."""
from vllm_omni.diffusion.worker.utils import StepRequestState
from vllm_omni.errors import OmniClientError
# A request state carries exactly one latent tensor.
multi_output = StepRequestState(
request_id="req-0",
sampling=SimpleNamespace(num_outputs_per_prompt=2),
prompt="a prompt",
)
with pytest.raises(OmniClientError, match="one output per request"):
_step_pipeline(_SegmentMeanModel()).prepare_encode(multi_output)
# Distributed layerwise offload streams the DiT around a whole denoise loop.
single_output = StepRequestState(
request_id="req-1",
sampling=SimpleNamespace(num_outputs_per_prompt=1),
prompt="a prompt",
)
dlo_pipeline = _step_pipeline(_SegmentMeanModel())
dlo_pipeline._dlo_residency_controller = object()
with pytest.raises(ValueError, match="distributed layerwise offload"):
dlo_pipeline.prepare_encode(single_output)
def test_prepare_encode_rejects_high_quality_cache_dit():
"""quality=high installs a transformer-scoped Cache-DiT profile that
would leak across interleaved or co-batched step-mode requests."""
from vllm_omni.diffusion.worker.utils import StepRequestState
from vllm_omni.errors import OmniClientError
state = StepRequestState(
request_id="req-hi",
sampling=SimpleNamespace(num_outputs_per_prompt=1, quality="high"),
prompt="a prompt",
)
with pytest.raises(OmniClientError, match="quality=high"):
_step_pipeline(_SegmentMeanModel()).prepare_encode(state)
def _fake_attention_module(
*,
use_ring: bool,
backend: str = "FLASH_ATTN",
supports_multi_doc: bool = True,
):
"""Build a bare MiniMaxH3Attention with the attributes ``_packed_batch_supported`` reads.
``supports_multi_doc`` models the platform-dependent capability probe on
``AttentionBackend`` (e.g. FLASH_ATTN returns True on CUDA/ROCm/MUSA and
False on NPU/XPU); ``backend`` remains only for logging/back-compat.
"""
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import MiniMaxH3Attention
attn = object.__new__(MiniMaxH3Attention)
attn.attention = SimpleNamespace(
attn_backend=SimpleNamespace(
get_name=lambda: backend,
supports_multi_doc_packed_varlen=lambda: supports_multi_doc,
),
use_ring=use_ring,
)
return attn
class _FakeTransformer:
"""The minimal ``modules()`` protocol ``_packed_batch_supported`` walks."""
def __init__(self, attentions):
self._attentions = list(attentions)
def modules(self):
return iter([self, *self._attentions])
@pytest.mark.parametrize(
"attention",
[
_fake_attention_module(use_ring=True),
_fake_attention_module(use_ring=False, backend="XFORMERS", supports_multi_doc=False),
_fake_attention_module(use_ring=False, backend="FLASH_ATTN", supports_multi_doc=False),
],
)
def test_packed_batch_rejects_backends_that_cannot_isolate_requests(attention):
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
assert MiniMaxH3Pipeline._packed_batch_supported(_FakeTransformer([attention])) is False
def test_broadcast_rank0_exception_single_rank_reraises():
"""Single-rank execution has no group; the helper just reraises."""
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
_broadcast_rank0_exception,
)
from vllm_omni.errors import OmniClientError
_broadcast_rank0_exception(None)
with pytest.raises(OmniClientError, match="bad ref"):
_broadcast_rank0_exception(OmniClientError("bad ref"))
def test_broadcast_rank0_exception_propagates_to_non_zero_ranks(monkeypatch):
"""A rank-0 error becomes a matching client error on every other DiT rank."""
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as mod
from vllm_omni.errors import OmniClientError
def fake_rank_world(rank):
return lambda: (object(), rank, 4)
def make_broadcast(rank0_payload):
def fake_broadcast(payload_list, *, src, group):
payload_list[0] = rank0_payload
return fake_broadcast
monkeypatch.setattr(mod, "_dit_rank_world", fake_rank_world(0))
err = OmniClientError("invalid reference-video file", status_code=422, error_type="UnprocessableEntityError")
rank0_payload = {
"type": type(err).__name__,
"message": str(err),
"status_code": err.status_code,
"error_type": err.error_type,
}
monkeypatch.setattr(mod.dist, "broadcast_object_list", make_broadcast(rank0_payload))
with pytest.raises(OmniClientError) as rank0_info:
mod._broadcast_rank0_exception(err)
assert rank0_info.value is err
monkeypatch.setattr(mod, "_dit_rank_world", fake_rank_world(2))
with pytest.raises(OmniClientError) as rank2_info:
mod._broadcast_rank0_exception(None)
assert rank2_info.value.status_code == 422
assert rank2_info.value.error_type == "UnprocessableEntityError"
assert "invalid reference-video file" in str(rank2_info.value)