Perf/minimax h3 dit swiglu rope cache npu (#6410)

Signed-off-by: Wenjia Li <wjialish@gmail.com>
Co-authored-by: WeiQing Chen <40507679+david6666666@users.noreply.github.com>
This commit is contained in:
Wenjia LiandWeiQing Chen authored and GitHub committed 2026-08-29 09:14:52 +00:00
1 parent bb5bf8fd0a
commit d9980c75e8
11 files changed
+920 -33

No files matched your search

+12
View File
@@ -10,6 +10,18 @@ steps:
-m 'core_model and npu and A3 and omni'
--run-level 'core_model'
- label: "Diffusion NPU layer tests"
key: ready-diffusion-npu-layer-tests
depends_on: upload-ready-pipeline
mirror_hardwares: a3_npu_2
commands:
- >-
pytest -s -v
tests/diffusion/layers/test_activation.py
tests/diffusion/layers/test_fused_qk_norm_rope_npu.py
-m 'core_model and npu and A3'
--run-level 'core_model'
- group: ":card_index_dividers: Omni Model Test"
key: ready-omni-group
depends_on: upload-ready-pipeline
+241 -1
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
Unit tests for TeaCache extractor functions.
@@ -18,6 +18,7 @@ Currently implemented:
- TestMiniMaxH3Extractor: MiniMaxH3DiTModel extractor
"""
import math
from abc import ABC, abstractmethod
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
@@ -650,6 +651,29 @@ class TestMiniMaxH3Extractor(BaseExtractorTest):
torch.testing.assert_close(actual_video, expected_video)
torch.testing.assert_close(actual_audio, expected_audio)
def test_extractor_uses_prepared_rope_table(
self,
minimax_h3_module,
sample_inputs,
monkeypatch,
):
"""TeaCache must consume the denoise-loop RoPE table without rebuilding it."""
rope_table = minimax_h3_module.prepare_rope_table(
sample_inputs["img_position_ids"],
seq_len=sample_inputs["x"].shape[1],
)
def unexpected_rope_build(*args, **kwargs):
raise AssertionError("TeaCache extractor rebuilt the prepared RoPE table")
monkeypatch.setattr(minimax_h3_module, "prepare_rope_table", unexpected_rope_build)
monkeypatch.setattr(minimax_h3_module.rope, "forward", unexpected_rope_build)
context = extract_minimax_h3_context(
minimax_h3_module,
**{**sample_inputs, "rope_table": rope_table},
)
context.run_transformer_blocks()
def test_run_transformer_blocks_forwards_packed_layout(
self,
minimax_h3_module,
@@ -705,6 +729,222 @@ class TestMiniMaxH3Extractor(BaseExtractorTest):
assert video_logits.shape[0] == sample_inputs["img_pos_info"]["position_ids"].shape[0]
assert audio_logits.shape[0] == sample_inputs["audio_pos_info"]["position_ids"].shape[0]
@pytest.mark.sp
def test_teacache_strict_sp_keeps_cache_state_rank_local(
self,
minimax_h3_module,
sample_inputs,
monkeypatch,
):
"""Strict SP must not mix local TeaCache state with gathered block rows."""
from vllm_omni.diffusion.attention.ops import minimax_h3_modulation
from vllm_omni.diffusion.cache.teacache.config import TeaCacheConfig
from vllm_omni.diffusion.cache.teacache.hook import TeaCacheHook
from vllm_omni.diffusion.distributed import parallel_state
seq_len = sample_inputs["x"].shape[1]
local_len = seq_len // 2
hidden_size = minimax_h3_module.hidden_size
video_width = minimax_h3_module.arch.latents_dim * math.prod(minimax_h3_module.arch.patch_size)
audio_width = minimax_h3_module.arch.audio_latents_dim
captured_modulation_indices: list[torch.Tensor] = []
class _StrictSPPrepare(nn.Module):
def __init__(self):
super().__init__()
self.global_indices: list[torch.Tensor] = []
def forward(self, hidden, rope_table, combined_indices):
assert hidden.shape[0] == local_len
assert rope_table.shape[0] == local_len
self.global_indices.append(combined_indices.clone())
return hidden, rope_table, combined_indices.narrow(0, 0, local_len)
class _StrictSPGather(nn.Module):
def __init__(self):
super().__init__()
self.inputs: list[torch.Tensor] = []
self.outputs: list[torch.Tensor] = []
def forward(self, tensor):
self.inputs.append(tensor.clone())
if tensor.shape[-1] == hidden_size:
raise AssertionError("TeaCache must not gather hidden states in strict SP")
assert tensor.shape == (local_len, video_width + audio_width)
# Model the all-gather of compact logits from every SP rank.
# postprocess() indexes the resulting global layout with
# infer_out_pos/audio_pos, including rows owned by another rank.
gathered = tensor.repeat(seq_len // local_len, 1)
assert gathered.shape == (seq_len, video_width + audio_width)
self.outputs.append(gathered)
return gathered
class _StrictSPGroup:
def __init__(self):
super().__init__()
self.local_decisions: list[int] = []
self.ops = []
def all_reduce(self, decision, op):
self.local_decisions.append(int(decision.item()))
self.ops.append(op)
# Simulate another rank requiring the second step to execute
# the collective block path even though this rank would cache.
if len(self.local_decisions) == 2:
decision.fill_(1)
return decision
class _StrictSPBlock(nn.Module):
def __init__(self):
super().__init__()
self.block_indices: list[torch.Tensor] = []
def adaln_proj(self, t_emb):
del t_emb
zeros = torch.zeros(3, hidden_size, dtype=torch.float32)
return zeros, zeros
def norm1(self, hidden):
return hidden.clone()
def forward(self, hidden, *, combined_indices, rope_table, **kwargs):
del kwargs
assert hidden.shape[0] == local_len
assert rope_table.shape[0] == local_len
self.block_indices.append(combined_indices.clone())
return hidden + 1
class _StrictSPFinalLayer(nn.Module):
def forward(self, hidden, *, t_emb, inverse_indices):
del t_emb, inverse_indices
assert hidden.shape == (local_len, hidden_size)
return hidden[:, :video_width].float(), hidden[:, :audio_width].float()
local_prepare = _StrictSPPrepare()
gather = _StrictSPGather()
sp_group = _StrictSPGroup()
block = _StrictSPBlock()
minimax_h3_module.local_sp_prepare = local_prepare
minimax_h3_module.sp_gather = gather
minimax_h3_module.blocks = nn.ModuleList([block])
minimax_h3_module.final_layer = _StrictSPFinalLayer()
monkeypatch.setattr(minimax_h3_module, "_rope_local_span", lambda _seq_len: (0, local_len))
monkeypatch.setattr(
minimax_h3_module,
"_embed",
lambda **kwargs: (
torch.arange(local_len * hidden_size, dtype=torch.float32).reshape(local_len, hidden_size),
torch.zeros(3, hidden_size, dtype=torch.float32),
),
)
monkeypatch.setattr(
minimax_h3_module,
"prepare_rope_table",
lambda *args, **kwargs: torch.zeros(local_len, 6, dtype=torch.float32),
)
monkeypatch.setattr(parallel_state, "get_sp_group", lambda: sp_group)
original_indexed_scale_shift = minimax_h3_modulation.indexed_scale_shift_
def capture_indexed_scale_shift(hidden, shift, scale, indices):
captured_modulation_indices.append(indices.clone())
return original_indexed_scale_shift(hidden, shift, scale, indices)
monkeypatch.setattr(minimax_h3_modulation, "indexed_scale_shift_", capture_indexed_scale_shift)
local_img_pos = torch.tensor([0, 1])
local_audio_pos = torch.tensor([2, 3])
token_tags = torch.full((seq_len,), -1, dtype=torch.long)
token_tags[local_img_pos] = 0
token_tags[local_audio_pos] = 2
inverse_indices = torch.zeros(seq_len, dtype=torch.long)
inputs = {
**sample_inputs,
"inverse_indices": inverse_indices,
"token_tags": token_tags,
"update_mask": torch.ones(local_img_pos.numel(), dtype=torch.bool),
"prompt_embeds": torch.empty(0, 6),
"img_pos_info": {"position_ids": local_img_pos},
"audio_pos_info": {"position_ids": local_audio_pos},
"text_pos_info": {"position_ids": torch.empty(0, dtype=torch.long)},
"img_pos_for_infer_output_info": {"position_ids": local_img_pos},
"refiner_packed_seq_params": {
"cu_seqlens_q": torch.tensor([0, 0], dtype=torch.int32),
"max_seqlen_q": 0,
},
}
expected_local_indices = (inverse_indices * 3 + token_tags.clamp(min=0))[:local_len]
hook = TeaCacheHook(
TeaCacheConfig(
transformer_type="MiniMaxH3DiTModel",
rel_l1_thresh=0.3,
)
)
hook.initialize_hook(minimax_h3_module)
hook.new_forward(minimax_h3_module, **inputs)
hook.new_forward(minimax_h3_module, **inputs)
video_logits, audio_logits = hook.new_forward(minimax_h3_module, **inputs)
state = hook.state_manager.get_state()
assert state.previous_residual is not None
assert state.previous_residual.shape == (local_len, hidden_size)
assert len(captured_modulation_indices) == 3
assert all(torch.equal(indices, expected_local_indices) for indices in captured_modulation_indices)
assert sp_group.local_decisions == [1, 0, 0]
assert len(sp_group.ops) == 3
assert all(op == torch.distributed.ReduceOp.MAX for op in sp_group.ops)
assert len(local_prepare.global_indices) == 2
assert all(indices.shape == (seq_len,) for indices in local_prepare.global_indices)
assert len(block.block_indices) == 2
assert all(torch.equal(indices, expected_local_indices) for indices in block.block_indices)
assert len(gather.inputs) == 3
assert video_logits.shape == (local_img_pos.numel(), video_width)
assert audio_logits.shape == (local_audio_pos.numel(), audio_width)
@pytest.mark.cpu
def test_teacache_non_strict_sp_gathers_hidden_once(
self,
minimax_h3_module,
sample_inputs,
monkeypatch,
):
"""The non-strict SP cache path must not gather global hidden rows twice."""
from vllm_omni.diffusion.cache.teacache.config import TeaCacheConfig
from vllm_omni.diffusion.cache.teacache.hook import TeaCacheHook
class _Prepare(nn.Module):
def forward(self, hidden, rope_table, combined_indices):
return hidden, rope_table, combined_indices
class _Gather(nn.Module):
def __init__(self):
super().__init__()
self.inputs: list[torch.Tensor] = []
def forward(self, tensor):
self.inputs.append(tensor.clone())
return tensor
seq_len = sample_inputs["x"].shape[1]
gather = _Gather()
minimax_h3_module.sp_prepare = _Prepare()
minimax_h3_module.sp_gather = gather
monkeypatch.setattr(minimax_h3_module, "_rope_local_span", lambda _seq_len: (0, seq_len))
hook = TeaCacheHook(
TeaCacheConfig(
transformer_type="MiniMaxH3DiTModel",
rel_l1_thresh=0.3,
)
)
hook.initialize_hook(minimax_h3_module)
hook.new_forward(minimax_h3_module, **sample_inputs)
assert len(gather.inputs) == 1
assert gather.inputs[0].shape == (seq_len, minimax_h3_module.hidden_size)
def test_invalid_module_raises_error(self):
"""Test that invalid module without blocks raises ValueError."""
invalid_module = Mock()
+32
View File
@@ -0,0 +1,32 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import pytest
import torch
import torch.nn.functional as F
from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.layers.activation import SiluAndMul
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
@pytest.mark.cpu
def test_silu_and_mul_native_matches_packed_reference() -> None:
packed = torch.randn(257, 256, dtype=torch.float32)
gate, up = packed.chunk(2, dim=-1)
torch.testing.assert_close(SiluAndMul().forward_native(packed), F.silu(gate) * up)
@hardware_test(res={"npu": "A3"}, num_cards=1)
def test_silu_and_mul_npu_matches_packed_reference() -> None:
packed = torch.randn(257, 256, device="npu", dtype=torch.bfloat16)
gate, up = packed.chunk(2, dim=-1)
torch.testing.assert_close(
SiluAndMul()(packed),
F.silu(gate) * up,
atol=2e-2,
rtol=2e-2,
)
@@ -0,0 +1,194 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import sys
import types
import pytest
import torch
import torch.nn.functional as F
from tests.helpers.mark import hardware_test
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
_HEAD_DIM = 128
_ROTARY_DIM = 96
_EPS = 1e-5
def _reference(
q: torch.Tensor,
k: torch.Tensor,
q_weight: torch.Tensor,
k_weight: torch.Tensor,
rope_table: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
half = _ROTARY_DIM // 2
def apply(x: torch.Tensor, weight: torch.Tensor) -> torch.Tensor:
x = F.rms_norm(x, (_HEAD_DIM,), weight, _EPS)
cos = rope_table[..., :half].unsqueeze(1)
sin = rope_table[..., half:].unsqueeze(1)
return torch.cat(
(
x[..., :half] * cos - x[..., half:_ROTARY_DIM] * sin,
x[..., half:_ROTARY_DIM] * cos + x[..., :half] * sin,
x[..., _ROTARY_DIM:],
),
dim=-1,
)
return apply(q, q_weight), apply(k, k_weight)
def _inputs() -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
torch.manual_seed(7)
tokens, heads = 11, 4
q = torch.randn(tokens, heads, _HEAD_DIM, dtype=torch.bfloat16)
k = torch.randn(tokens, heads, _HEAD_DIM, dtype=torch.bfloat16)
q_weight = torch.randn(_HEAD_DIM, dtype=torch.bfloat16)
k_weight = torch.randn(_HEAD_DIM, dtype=torch.bfloat16)
freqs = torch.randn(tokens, _ROTARY_DIM // 2)
rope_table = torch.cat((torch.cos(freqs), torch.sin(freqs)), dim=-1).to(torch.bfloat16)
return q, k, q_weight, k_weight, rope_table
@pytest.mark.cpu
def test_npu_qk_norm_rope_uses_torch_npu_fused_primitives_without_mindiesd(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from vllm_omni.diffusion.layers import fused_qk_norm_rope as fused
calls: dict[str, int] = {"rms_norm": 0, "rotary_mul": 0}
def npu_rms_norm(x: torch.Tensor, gamma: torch.Tensor, epsilon: float):
calls["rms_norm"] += 1
return (F.rms_norm(x, (_HEAD_DIM,), gamma, epsilon),)
def npu_rotary_mul(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor):
calls["rotary_mul"] += 1
assert x.shape[-1] == _ROTARY_DIM
assert cos.shape == (1, x.shape[1], 1, _ROTARY_DIM)
assert sin.shape == cos.shape
half = _ROTARY_DIM // 2
return torch.cat(
(
x[..., :half] * cos[..., :half] - x[..., half:] * sin[..., :half],
x[..., half:] * cos[..., half:] + x[..., :half] * sin[..., half:],
),
dim=-1,
)
monkeypatch.setitem(
sys.modules,
"torch_npu",
types.SimpleNamespace(npu_rms_norm=npu_rms_norm, npu_rotary_mul=npu_rotary_mul),
)
monkeypatch.setattr(fused, "find_spec", lambda _: None)
q, k, q_weight, k_weight, rope_table = _inputs()
actual_q, actual_k = fused._npu_qk_norm_rope(
q,
k,
q_weight,
k_weight,
rope_table,
_EPS,
_ROTARY_DIM,
)
expected_q, expected_k = _reference(q, k, q_weight, k_weight, rope_table)
assert calls == {"rms_norm": 2, "rotary_mul": 2}
torch.testing.assert_close(actual_q, expected_q, atol=0, rtol=0)
torch.testing.assert_close(actual_k, expected_k, atol=0, rtol=0)
@pytest.mark.cpu
def test_npu_qk_norm_rope_uses_mindiesd_and_normalizes_packed_layout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from vllm_omni.diffusion.layers import fused_qk_norm_rope as fused
calls: dict[str, int] = {"rms_norm": 0, "mindiesd_rope": 0}
def npu_rms_norm(x: torch.Tensor, gamma: torch.Tensor, epsilon: float):
calls["rms_norm"] += 1
return (F.rms_norm(x, (_HEAD_DIM,), gamma, epsilon),)
def rotary_position_embedding(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor, **kwargs):
calls["mindiesd_rope"] += 1
assert x.dim() == 4
assert x.shape[0] == 1
assert x.shape[-1] == _ROTARY_DIM
assert cos.shape == (1, x.shape[1], 1, _ROTARY_DIM)
assert sin.shape == cos.shape
assert kwargs == {
"rotated_mode": "rotated_half",
"head_first": False,
"fused": True,
}
half = _ROTARY_DIM // 2
return torch.cat(
(
x[..., :half] * cos[..., :half] - x[..., half:] * sin[..., :half],
x[..., half:] * cos[..., half:] + x[..., :half] * sin[..., half:],
),
dim=-1,
)
monkeypatch.setitem(sys.modules, "torch_npu", types.SimpleNamespace(npu_rms_norm=npu_rms_norm))
monkeypatch.setitem(
sys.modules,
"mindiesd",
types.SimpleNamespace(rotary_position_embedding=rotary_position_embedding),
)
monkeypatch.setattr(fused, "find_spec", lambda _: object())
q, k, q_weight, k_weight, rope_table = _inputs()
actual_q, actual_k = fused._npu_qk_norm_rope(
q,
k,
q_weight,
k_weight,
rope_table,
_EPS,
_ROTARY_DIM,
)
expected_q, expected_k = _reference(q, k, q_weight, k_weight, rope_table)
assert calls == {"rms_norm": 2, "mindiesd_rope": 2}
torch.testing.assert_close(actual_q, expected_q, atol=0, rtol=0)
torch.testing.assert_close(actual_k, expected_k, atol=0, rtol=0)
@hardware_test(res={"npu": "A3"}, num_cards=1)
def test_fused_qk_norm_rope_npu_matches_reference(monkeypatch: pytest.MonkeyPatch) -> None:
"""Exercise the public Ascend dispatch with real torch_npu/MindIE operators."""
from vllm_omni.diffusion.layers import fused_qk_norm_rope as fused
q, k, q_weight, k_weight, rope_table = (tensor.to("npu") for tensor in _inputs())
calls = 0
original_npu_impl = fused._npu_qk_norm_rope
def capture_npu_impl(*args, **kwargs):
nonlocal calls
calls += 1
return original_npu_impl(*args, **kwargs)
monkeypatch.setattr(fused, "_npu_qk_norm_rope", capture_npu_impl)
actual_q, actual_k = fused.fused_qk_norm_rope(
q,
k,
q_weight,
k_weight,
rope_table,
_EPS,
head_dim=_HEAD_DIM,
rotary_dim=_ROTARY_DIM,
)
expected_q, expected_k = _reference(q, k, q_weight, k_weight, rope_table)
assert calls == 1
torch.testing.assert_close(actual_q, expected_q, atol=2e-2, rtol=2e-2)
torch.testing.assert_close(actual_k, expected_k, atol=2e-2, rtol=2e-2)
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import pytest
import torch
@@ -113,6 +113,146 @@ def test_h3_rope_table_materializes_local_rows_in_fused_kernel_layout():
torch.testing.assert_close(actual, expected, atol=0, rtol=0)
def test_h3_prepared_rope_table_is_rank_local_and_validated(monkeypatch):
from vllm_omni.diffusion.models.minimax_h3 import minimax_h3_transformer as h3
class Rope(nn.Module):
def forward(self, position_ids):
freqs_half = position_ids[0].float()
return torch.cat((freqs_half, freqs_half), dim=-1)
model = object.__new__(h3.MiniMaxH3DiTModel)
nn.Module.__init__(model)
model.arch = h3.MiniMaxH3DiTArchConfig(rope_inv_freq_len=1)
model.rope = Rope()
model.local_sp_prepare = nn.Identity()
monkeypatch.setattr(h3, "_sequence_parallel_local_span", lambda *args, **kwargs: (1, 2))
position_ids = torch.arange(12, dtype=torch.long).reshape(1, 4, 3)
actual = model.prepare_rope_table(position_ids, seq_len=4)
expected = h3._build_rope_table(model.rope(position_ids[:, 1:3]))
torch.testing.assert_close(actual, expected, atol=0, rtol=0)
model._validate_prepared_rope_table(actual, local_len=2, device=torch.device("cpu"))
with pytest.raises(ValueError, match="local_seq_len"):
model._validate_prepared_rope_table(actual[:1], local_len=2, device=torch.device("cpu"))
def test_denoise_branch_prepares_rope_table_once_per_npu_run(monkeypatch: pytest.MonkeyPatch):
from vllm_omni.diffusion.models.minimax_h3 import denoise_loop
from vllm_omni.diffusion.models.minimax_h3.denoise_loop import (
MiniMaxH3DenoiseBranch,
minimax_h3_denoise_loop,
)
monkeypatch.setattr(denoise_loop.current_omni_platform, "is_npu", lambda: True)
packed = {
"seq_len": torch.tensor(3),
"img_pos": torch.tensor([1]),
"audio_pos": torch.tensor([2]),
"text_pos": torch.tensor([0]),
"update_mask": torch.tensor([True]),
"cu_seqlens": torch.tensor([0, 3, 3], dtype=torch.int32),
"img_position_ids": torch.zeros(3, 3, dtype=torch.long),
"latent_grid": torch.tensor([1, 1, 1]),
"video_row_start": torch.tensor(1),
}
branch = MiniMaxH3DenoiseBranch(
packed=packed,
text_embeddings=torch.zeros(1, 2),
token_tags=torch.zeros(3, dtype=torch.long),
device=torch.device("cpu"),
)
class Model:
def __init__(self):
self.rope_table = torch.zeros(3, 6, dtype=torch.bfloat16)
self.prepare_calls = 0
self.forward_calls = 0
def prepare_rope_table(self, img_position_ids, *, seq_len):
assert img_position_ids is branch.static_kwargs["img_position_ids"]
assert seq_len == 3
self.prepare_calls += 1
return self.rope_table
def __call__(self, **kwargs):
assert kwargs["rope_table"] is self.rope_table
self.forward_calls += 1
return torch.zeros(1, 96), torch.zeros(1, 32)
model = Model()
minimax_h3_denoise_loop(
model=model,
positive=branch,
initial_video_rows=torch.zeros(1, 96),
initial_audio_rows=torch.zeros(1, 32),
keyframe_cond_rows=None,
sigmas_video=[1.0, 0.5, 0.0],
sigmas_audio=[1.0, 0.5, 0.0],
device=torch.device("cpu"),
)
assert model.prepare_calls == 1
assert model.forward_calls == 2
def test_denoise_branch_keeps_rope_construction_on_the_reference_path_off_npu(
monkeypatch: pytest.MonkeyPatch,
):
from vllm_omni.diffusion.models.minimax_h3 import denoise_loop
from vllm_omni.diffusion.models.minimax_h3.denoise_loop import (
MiniMaxH3DenoiseBranch,
minimax_h3_denoise_loop,
)
monkeypatch.setattr(denoise_loop.current_omni_platform, "is_npu", lambda: False)
packed = {
"seq_len": torch.tensor(3),
"img_pos": torch.tensor([1]),
"audio_pos": torch.tensor([2]),
"text_pos": torch.tensor([0]),
"update_mask": torch.tensor([True]),
"cu_seqlens": torch.tensor([0, 3, 3], dtype=torch.int32),
"img_position_ids": torch.zeros(3, 3, dtype=torch.long),
"latent_grid": torch.tensor([1, 1, 1]),
"video_row_start": torch.tensor(1),
}
branch = MiniMaxH3DenoiseBranch(
packed=packed,
text_embeddings=torch.zeros(1, 2),
token_tags=torch.zeros(3, dtype=torch.long),
device=torch.device("cpu"),
)
class Model:
def __init__(self):
self.forward_calls = 0
def prepare_rope_table(self, *_args, **_kwargs):
raise AssertionError("the reference path must not pre-build a rope_table")
def __call__(self, **kwargs):
assert "rope_table" not in kwargs
self.forward_calls += 1
return torch.zeros(1, 96), torch.zeros(1, 32)
model = Model()
minimax_h3_denoise_loop(
model=model,
positive=branch,
initial_video_rows=torch.zeros(1, 96),
initial_audio_rows=torch.zeros(1, 32),
keyframe_cond_rows=None,
sigmas_video=[1.0, 0.5, 0.0],
sigmas_audio=[1.0, 0.5, 0.0],
device=torch.device("cpu"),
)
assert model.forward_calls == 2
def test_strict_sp_local_span_uses_rank_owned_rows(monkeypatch):
from vllm_omni.diffusion import forward_context
from vllm_omni.diffusion.distributed import parallel_state
+84 -17
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
Model-specific extractors for TeaCache.
@@ -13,6 +13,7 @@ all model-specific information needed for generic caching, including preprocessi
transformer execution, and postprocessing logic.
"""
import math
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
@@ -1263,7 +1264,6 @@ def extract_minimax_h3_context(
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
_FORWARD_SUPPORTED_KWARGS,
MINIMAX_H3_ADALN_MODALITY_NUM,
_build_rope_table,
_required_kwarg,
)
@@ -1320,8 +1320,20 @@ def extract_minimax_h3_context(
if inverse_indices.shape[0] != seq_len:
raise ValueError(f"inverse_indices must be [{seq_len}], got {list(inverse_indices.shape)}")
device = x.device
rope_table = _build_rope_table(module.rope(img_position_ids).to(device))
local_span = module._rope_local_span(seq_len)
local_start, local_len = local_span
rope_table = kwargs.get("rope_table")
if rope_table is None:
rope_table = module.prepare_rope_table(
img_position_ids,
seq_len=seq_len,
)
else:
module._validate_prepared_rope_table(
rope_table,
local_len=local_len,
device=device,
)
decoder_input, t_emb = module._embed(
x=x,
@@ -1336,28 +1348,59 @@ def extract_minimax_h3_context(
num_requests=num_requests,
seq_len=seq_len,
device=device,
local_span=(0, seq_len),
local_span=local_span,
)
combined_indices = (inverse_indices * MINIMAX_H3_ADALN_MODALITY_NUM + token_tags.clamp(min=0)).to(device)
inverse_indices = inverse_indices.to(device)
cu_seqlens = cu_seqlens.to(device)
# In strict SP, _embed already returns this rank's packed rows while
# combined_indices still describes the full packed sequence. TeaCache uses
# the first block's modulation as part of its rank-local cache state, so
# the modulation indices must be sliced to the same local layout. Keep the
# full indices below: local_sp_prepare owns the global-to-local conversion
# for transformer-block inputs.
state_combined_indices = combined_indices.narrow(0, local_start, local_len)
if local_len == seq_len:
state_combined_indices = combined_indices
shift_msa, scale_msa, *_ = module.blocks[0].adaln_proj(t_emb)
modulated_input = module.blocks[0].norm1(decoder_input)
modulated_input = indexed_scale_shift_(
modulated_input,
shift_msa,
scale_msa,
combined_indices,
state_combined_indices,
)
def run_transformer_blocks() -> tuple[torch.Tensor, ...]:
hidden, block_rope, block_combined = module.sp_prepare(
decoder_input,
rope_table,
combined_indices,
def synchronize_cache_decision(local_should_compute: bool) -> bool:
if local_len == seq_len:
return local_should_compute
from vllm_omni.diffusion.distributed.parallel_state import get_sp_group
decision = torch.tensor(
int(local_should_compute),
dtype=torch.int32,
device=device,
)
get_sp_group().all_reduce(decision, op=torch.distributed.ReduceOp.MAX)
return bool(decision.item())
def run_transformer_blocks() -> tuple[torch.Tensor, ...]:
if local_len == seq_len:
hidden, block_rope, block_combined = module.sp_prepare(
decoder_input,
rope_table,
combined_indices,
)
else:
hidden, block_rope, block_combined = module.local_sp_prepare(
decoder_input,
rope_table,
combined_indices,
)
for block in module.blocks:
hidden = block(
hidden,
@@ -1369,15 +1412,36 @@ def extract_minimax_h3_context(
packed_total=seq_len,
video_layout=video_layout,
)
hidden = module.sp_gather(hidden)
# TeaCache stores residuals as block_output - decoder_input. Strict SP
# must keep both tensors rank-local until postprocess projects local
# rows to compact logits and gathers them exactly once.
if local_len == seq_len:
hidden = module.sp_gather(hidden)
return (hidden,)
def postprocess(hidden: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
video_logits, audio_logits = module.final_layer(
hidden,
t_emb=t_emb,
inverse_indices=inverse_indices,
)
if local_len == seq_len:
video_logits, audio_logits = module.final_layer(
hidden,
t_emb=t_emb,
inverse_indices=inverse_indices,
)
else:
local_inverse_indices = inverse_indices.narrow(
0,
local_start,
local_len,
)
video_logits, audio_logits = module.final_layer(
hidden,
t_emb=t_emb,
inverse_indices=local_inverse_indices,
)
compact_logits = torch.cat((video_logits, audio_logits), dim=-1)
compact_logits = module.sp_gather(compact_logits)
video_width = module.arch.latents_dim * math.prod(module.arch.patch_size)
video_logits = compact_logits[..., :video_width]
audio_logits = compact_logits[..., video_width:]
video_logits = video_logits.index_select(0, infer_out_pos.to(device))
audio_logits = audio_logits.index_select(0, audio_pos.to(device))
if not skip_mask_out_condition:
@@ -1396,6 +1460,9 @@ def extract_minimax_h3_context(
temb=t_emb,
run_transformer_blocks=run_transformer_blocks,
postprocess=postprocess,
extra_states={
"synchronize_cache_decision": synchronize_cache_decision,
},
)
+10 -1
View File
@@ -138,7 +138,16 @@ class TeaCacheHook(ModelHook):
state = self.state_manager.get_state()
# Decide whether to compute or cache based on modulated input similarity
should_compute = self._should_compute_full_transformer(state, ctx.modulated_input)
local_should_compute = self._should_compute_full_transformer(state, ctx.modulated_input)
sync_cache_decision = (ctx.extra_states or {}).get("synchronize_cache_decision")
if sync_cache_decision is not None:
should_compute = sync_cache_decision(local_should_compute)
# A rank that locally chose the cache path must reset its counter
# when another SP rank requires a full collective block execution.
if should_compute and not local_should_compute:
state.accumulated_rel_l1_distance = 0.0
else:
should_compute = local_should_compute
if not should_compute and state.previous_residual is not None:
# ============================================================================
+4 -1
View File
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Custom activation functions for diffusion models."""
import torch
@@ -31,7 +32,9 @@ class SiluAndMul(CustomOp):
return out
def forward_npu(self, x: torch.Tensor) -> torch.Tensor:
return self.forward_native(x)
import torch_npu
return torch_npu.npu_swiglu(x, dim=-1)
def forward_xpu(self, x: torch.Tensor) -> torch.Tensor:
return self.forward_native(x)
@@ -11,11 +11,14 @@ The public contract is shared by diffusion attention implementations:
``[cos(theta), sin(theta)]`` with ``theta`` of width ``rotary_dim // 2``.
The CUDA fast path fuses RMSNorm and RoPE without materializing normalized Q/K
or rotary-product intermediates. Unsupported inputs use the eager reference.
or rotary-product intermediates. Ascend composes its RMSNorm and rotary fused
primitives; unsupported inputs use the eager reference.
"""
from __future__ import annotations
from importlib.util import find_spec
import torch
import torch.nn.functional as F
from torch.library import Library
@@ -23,6 +26,8 @@ from vllm.platforms import current_platform
from vllm.triton_utils import HAS_TRITON, tl, triton
from vllm.utils.torch_utils import direct_register_custom_op
from vllm_omni.platforms import current_omni_platform
_FUSED_HEAD_DIM = 128
_FUSED_ROTARY_DIM = 96
_HEADS_PER_PROGRAM = 8
@@ -130,6 +135,72 @@ def _eager_qk_norm_rope(
)
def _npu_apply_rope_table(
x: torch.Tensor,
rope_table: torch.Tensor,
rotary_dim: int,
) -> torch.Tensor:
"""Apply H3's rotary dimensions through Ascend's fused rotary kernel.
``mindiesd.rotary_position_embedding`` takes a 4-D BSND tensor, whereas
MiniMax-H3 keeps packed activations as ``[tokens, heads, head_dim]``.
The shared MindIE-SD wrapper normalizes this 3-D layout to BSND and
restores it afterwards. It receives the half-width cos/sin values from
the packed table; the wrapper expands them to H3's non-interleaved 96-D
rotary layout. The 32 non-rotary head dimensions bypass the kernel.
Some CANN environments package ``torch_npu`` without MindIE-SD. Keep
those deployments on the same Ascend fused rotary primitive rather than
silently falling back to eager elementwise RoPE.
"""
import torch_npu
half = rotary_dim // 2
x_rot, x_pass = x[..., :rotary_dim], x[..., rotary_dim:]
cos = rope_table[..., :half]
sin = rope_table[..., half:]
if find_spec("mindiesd") is not None:
from vllm_omni.diffusion.layers.rope import apply_rotary_emb_mindiesd
x_rot = apply_rotary_emb_mindiesd(
x_rot,
cos,
sin,
interleaved=False,
half_head_dim=True,
)
else:
# npu_rotary_mul uses BSND and full rotary-width cos/sin. H3 uses
# NeoX/rotated-half ordering, so duplicate rather than interleave the
# half-width table along the final dimension.
cos = cos.unsqueeze(0).unsqueeze(2).repeat(1, 1, 1, 2)
sin = sin.unsqueeze(0).unsqueeze(2).repeat(1, 1, 1, 2)
x_rot = torch_npu.npu_rotary_mul(x_rot.unsqueeze(0), cos, sin).squeeze(0)
return torch.cat((x_rot, x_pass), dim=-1)
def _npu_qk_norm_rope(
q: torch.Tensor,
k: torch.Tensor,
q_weight: torch.Tensor,
k_weight: torch.Tensor,
rope_table: torch.Tensor,
eps: float,
rotary_dim: int,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Use Ascend RMSNorm and RoPE fused primitives for MiniMax-H3 DiT."""
import torch_npu
q_norm = torch_npu.npu_rms_norm(q, q_weight, epsilon=eps)[0]
k_norm = torch_npu.npu_rms_norm(k, k_weight, epsilon=eps)[0]
return (
_npu_apply_rope_table(q_norm, rope_table, rotary_dim),
_npu_apply_rope_table(k_norm, rope_table, rotary_dim),
)
def _fused_cuda_supported(
q: torch.Tensor,
k: torch.Tensor,
@@ -148,6 +219,24 @@ def _fused_cuda_supported(
)
def _fused_npu_supported(
q: torch.Tensor,
k: torch.Tensor,
head_dim: int,
rotary_dim: int,
) -> bool:
"""Return whether the MiniMax-H3 Ascend fused-op contract is satisfied."""
return (
current_omni_platform.is_npu()
and q.device.type == "npu"
and k.device.type == "npu"
and q.dtype == torch.bfloat16
and k.dtype == torch.bfloat16
and head_dim == _FUSED_HEAD_DIM
and rotary_dim == _FUSED_ROTARY_DIM
)
def _launch_fused_rms_norm_rope(
x: torch.Tensor,
weight: torch.Tensor,
@@ -192,6 +281,16 @@ def _fused_qk_norm_rope_impl(
head_dim: int,
rotary_dim: int,
) -> tuple[torch.Tensor, torch.Tensor]:
if _fused_npu_supported(q, k, head_dim, rotary_dim):
return _npu_qk_norm_rope(
q,
k,
q_weight,
k_weight,
rope_table,
eps,
rotary_dim,
)
if not _fused_cuda_supported(q, k, head_dim, rotary_dim):
return _eager_qk_norm_rope(
q,
@@ -273,6 +372,16 @@ def fused_qk_norm_rope(
q_weight = q_weight.contiguous()
k_weight = k_weight.contiguous()
rope_table = rope_table.contiguous()
if _fused_npu_supported(q, k, head_dim, rotary_dim):
return _npu_qk_norm_rope(
q,
k,
q_weight,
k_weight,
rope_table,
eps,
rotary_dim,
)
if not _fused_cuda_supported(q, k, head_dim, rotary_dim):
return _fused_qk_norm_rope_impl(
q,
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""MiniMax H3 cfg-distilled full denoise loop.
Per step, the positive presentation is forwarded exactly once. Video and audio
@@ -19,6 +20,7 @@ from vllm_omni.diffusion.forward_context import (
set_forward_context_denoise_step_idx,
set_forward_context_denoise_timestep,
)
from vllm_omni.platforms import current_omni_platform
from .scheduling_minimax_h3_euler_ancestral import (
minimax_h3_euler_eta0_step,
@@ -137,6 +139,22 @@ class MiniMaxH3DenoiseBranch:
latent_grid=(int(grid[0]), int(grid[1]), int(grid[2])),
)
def prepare_rope_table(self, model: Any) -> None:
"""Materialize the branch-local DiT RoPE table once per denoise run.
``img_position_ids`` is immutable for this branch while latents and
timesteps change every scheduler step. Keeping the table in
``static_kwargs`` makes every model call reuse the exact same BF16
tensor without extending its lifetime beyond this request branch.
"""
prepare = getattr(model, "prepare_rope_table", None)
if not callable(prepare):
return
self.static_kwargs["rope_table"] = prepare(
self.static_kwargs["img_position_ids"],
seq_len=self.seq_len,
)
def forward_kwargs(
self,
*,
@@ -253,6 +271,11 @@ def minimax_h3_denoise_loop(
raise ValueError("video/audio sigma schedules must have equal length")
if len(sigmas_video) < 2:
raise ValueError("sigma schedules need at least 2 entries")
# Keep CUDA/CPU on the reference path: its accuracy CI compares the
# generated video against the official artifact. The table reuse is an
# Ascend-specific performance optimization and must not alter that path.
if current_omni_platform.is_npu():
positive.prepare_rope_table(model)
video_rows, audio_rows, cond_anchor, audio_anchor = minimax_h3_prepare_denoise_rows(
positive=positive,
initial_video_rows=initial_video_rows,
@@ -49,6 +49,7 @@ from vllm_omni.diffusion.layers.fused_qk_norm_rope import fused_qk_norm_rope
from vllm_omni.diffusion.layers.norm import RMSNorm
from vllm_omni.diffusion.layers.rope import RotaryEmbedding
from vllm_omni.diffusion.models.host_weight_contract import FinalLayoutModelContract
from vllm_omni.platforms import current_omni_platform
if TYPE_CHECKING:
from vllm.model_executor.layers.quantization.base_config import (
@@ -179,6 +180,7 @@ _FORWARD_SUPPORTED_KWARGS = frozenset(
"packed_seq_params",
"refiner_packed_seq_params",
"video_token_layout",
"rope_table",
}
)
@@ -1139,6 +1141,54 @@ class MiniMaxH3DiTModel(nn.Module):
for _, param in self.named_parameters():
param.missing_param_init = "error"
def _rope_local_span(self, seq_len: int) -> tuple[int, int]:
"""Return the sequence-parallel rows owned by this DiT rank."""
local_sp_registry = getattr(self.local_sp_prepare, "_hook_registry", None)
hooks_applied = local_sp_registry is not None
if local_sp_registry is not None:
local_sp_hook = local_sp_registry.get_hook(_LOCAL_SP_PREPARE_HOOK)
hooks_applied = local_sp_hook is not None
return _sequence_parallel_local_span(
seq_len,
hooks_applied=hooks_applied,
)
def prepare_rope_table(
self,
img_position_ids: torch.Tensor,
*,
seq_len: int,
) -> torch.Tensor:
"""Build the static local RoPE table once for one denoise branch.
A MiniMax-H3 denoise branch reuses its packed position IDs at every
scheduler step. The returned table is local to the current sequence-
parallel rank, and therefore must be built by the model that will
consume it rather than cached globally across requests or ranks.
"""
local_start, local_len = self._rope_local_span(seq_len)
rope_position_ids = img_position_ids.narrow(1, local_start, local_len)
return _build_rope_table(self.rope(rope_position_ids).to(img_position_ids.device))
def _validate_prepared_rope_table(
self,
rope_table: torch.Tensor,
*,
local_len: int,
device: torch.device,
) -> None:
expected_width = 6 * self.arch.rope_inv_freq_len
if rope_table.dim() != 2 or tuple(rope_table.shape) != (local_len, expected_width):
raise ValueError(
"rope_table must be [local_seq_len, rotary_dim] for the current "
f"sequence-parallel rank, got {list(rope_table.shape)}; expected "
f"[{local_len}, {expected_width}]."
)
if rope_table.device != device:
raise ValueError(f"rope_table device {rope_table.device} must match x device {device}.")
if rope_table.dtype != _BF16_DTYPE:
raise ValueError(f"rope_table must be {_BF16_DTYPE}, got {rope_table.dtype}.")
def post_load_weights(self) -> None:
for name, param in self.named_parameters():
if name in MINIMAX_H3_FP32_PARAM_NAMES and param.dtype != _FP32_DTYPE:
@@ -1362,18 +1412,26 @@ class MiniMaxH3DiTModel(nn.Module):
if inverse_indices.shape[0] != seq_len:
raise ValueError(f"inverse_indices must be [{seq_len}], got {list(inverse_indices.shape)}")
device = x.device
local_sp_registry = getattr(self.local_sp_prepare, "_hook_registry", None)
hooks_applied = local_sp_registry is not None
if local_sp_registry is not None:
local_sp_hook = local_sp_registry.get_hook(_LOCAL_SP_PREPARE_HOOK)
hooks_applied = local_sp_hook is not None
local_span = _sequence_parallel_local_span(
seq_len,
hooks_applied=hooks_applied,
)
local_span = self._rope_local_span(seq_len)
local_start, local_len = local_span
rope_position_ids = img_position_ids.narrow(1, local_start, local_len)
rope_table = _build_rope_table(self.rope(rope_position_ids).to(device))
rope_table = kwargs.get("rope_table")
if rope_table is None:
if current_omni_platform.is_npu():
rope_table = self.prepare_rope_table(
img_position_ids,
seq_len=seq_len,
)
else:
# Keep CUDA/CPU numerically and structurally identical to the
# main-branch reference path used by the H100 accuracy suite.
rope_position_ids = img_position_ids.narrow(1, local_start, local_len)
rope_table = _build_rope_table(self.rope(rope_position_ids).to(device))
else:
self._validate_prepared_rope_table(
rope_table,
local_len=local_len,
device=device,
)
decoder_input, t_emb = self._embed(
x=x,