[Model][MiniMax-H3] Multi-GPU serving fixes and layer-wise offload component selection (#7047)
Signed-off-by: heyuanliu-intel <heyuan.liu@intel.com>
This commit is contained in:
1 parent
2f1a18e69f
commit
40f8e94400
21 files changed
+1857
-60
No files matched your search
@@ -77,7 +77,9 @@ Use it when plain Ulysses-SP would otherwise fail because:
|
||||
|
||||
- `auto_pad=True` pads sequence tokens in `_sp_plan` and requires attention backends that support `attention_mask`.
|
||||
- `advanced_uaa` does not depend on mask-based token padding inside Ulysses attention. It is therefore a better fit for non-divisible head counts and uneven Ulysses shard sizes.
|
||||
- `auto_pad=True` remains incompatible with Ring attention because the ring backend does not consume `attention_mask`.
|
||||
- `auto_pad=True` remains incompatible with Ring attention because the ring backend does not consume arbitrary
|
||||
`attention_mask` values. A model with contiguous suffix padding may instead publish `valid_kv_length`; Ring trims
|
||||
each circulated K/V block against that global valid prefix.
|
||||
- `advanced_uaa` is still experimental and hybrid mode remains limited by Ring's equal-shape requirement.
|
||||
|
||||
---
|
||||
@@ -416,7 +418,7 @@ _sp_plan = {
|
||||
| Constraint | Description |
|
||||
|------------|-------------|
|
||||
| **Attention Backend Compatibility** | The attention backends must support `attention_mask`. Currently only `TORCH_SDPA` and `FLASH_ATTN` (default for diffusion models) are supported. |
|
||||
| **Ring Attention Limitation** | Ring attention does not support `attention_mask`. Therefore, when using `auto_pad=True`, the combination of Ulysses + Ring attention is not feasible. |
|
||||
| **Ring Attention Limitation** | Ring attention does not support arbitrary `attention_mask` values, so `auto_pad=True` remains incompatible. Contiguous suffix padding is supported when the model publishes the global `valid_kv_length` prefix. |
|
||||
|
||||
1. Enable `auto_pad=True` for all sequence-dimension inputs in `_sp_plan`:
|
||||
```python
|
||||
|
||||
@@ -34,5 +34,34 @@ that provide their own `setup_compile()` implementation manage their compilation
|
||||
policy independently. Compilation is lazy, so backend or graph errors can first
|
||||
surface on the initial request.
|
||||
|
||||
## Pinning one packed shape (MiniMax-H3)
|
||||
|
||||
Compiled regions are keyed by input shape. MiniMax-H3 packs a request into a
|
||||
row count that follows the request -- a longer prompt, more frames, a larger
|
||||
frame -- and rounds it up to the next 64-row boundary, so two requests can land
|
||||
on two different packed lengths.
|
||||
|
||||
Under the default `diffusion_compile_dynamic: true` a new packed length is
|
||||
absorbed by the dynamic shape, so it is not by itself a recompilation. It still
|
||||
hands the backend a shape it has not autotuned, which is the risk this knob
|
||||
exists to remove: a fixed-shape deployment (`--no-diffusion-compile-dynamic`)
|
||||
recompiles the transformer blocks per shape, and a first-seen large shape can
|
||||
push autotuning into its worst-case memory use.
|
||||
|
||||
A request pins the length with `extra_args["pad_seq_len"]`:
|
||||
|
||||
```python
|
||||
sampling_params = SamplingParams(extra_args={"pad_seq_len": 54080})
|
||||
```
|
||||
|
||||
The value must be a positive multiple of 64 and must cover the rows the request
|
||||
actually uses; the packed sequence is then padded to it instead of to the next
|
||||
64-row boundary. Pick a bucket that covers every request dimension the
|
||||
deployment accepts -- prompt length, frame count, frame size and reference
|
||||
blocks all change the used row count. The server logs the effective length as
|
||||
`MiniMax H3 packed sequence: ... pad_seq_len=... used=... seq_len=...` whenever
|
||||
a request pins it. The padding rows are masked out, so their cost is the
|
||||
attention and feed-forward work on those rows.
|
||||
|
||||
Use `--enforce-eager` to disable the model runner's generic compile setup.
|
||||
Pipelines that compile internally define their own eager-mode behavior.
|
||||
@@ -1240,8 +1240,9 @@ vllm serve "${MODEL_ROOT}/FL2VA" \
|
||||
- `--cfg-parallel-size > 1` is rejected by design (CFG-distilled, no negative branch).
|
||||
- VAE patch parallelism requires size 1 or the full DiT group size and supports the
|
||||
H3 native `tile` mode only.
|
||||
- A U2 x Ring2 hybrid currently fails with an attention-mask length mismatch; use
|
||||
pure Ulysses.
|
||||
- Ulysses x Ring hybrid attention supports H3's single-request contiguous suffix
|
||||
padding. Arbitrary attention masks and multi-request packed batches remain
|
||||
unsupported on the Ring path.
|
||||
- Online FP8 with DLO AllGather temporarily materializes the complete FP8 model
|
||||
in host memory on every rank during startup before retaining only each rank's
|
||||
shard. Size startup host memory for that transient peak.
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.attention.backends import ring_flash_attn, ring_pytorch_attn
|
||||
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
|
||||
from vllm_omni.diffusion.attention.backends.ring.ring_selector import AttnType
|
||||
from vllm_omni.diffusion.attention.backends.ring.ring_utils import ring_kv_block_valid_length
|
||||
from vllm_omni.diffusion.attention.parallel import ring as ring_parallel
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
class _SingleRankComm:
|
||||
rank = 0
|
||||
world_size = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _FakeSequenceParallelGroup:
|
||||
ring_group: object
|
||||
|
||||
|
||||
def _fake_attention_result(q, _k, _v, **_kwargs):
|
||||
return torch.zeros_like(q), torch.zeros(q.shape[0], q.shape[2], q.shape[1])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("block_rank", "expected"), [(0, 4), (1, 1), (2, 0)])
|
||||
def test_global_prefix_is_partitioned_across_ring_blocks(block_rank, expected):
|
||||
assert ring_kv_block_valid_length(5, 4, block_rank, 3) == expected
|
||||
|
||||
|
||||
def test_absent_prefix_length_keeps_the_full_ring_block():
|
||||
assert ring_kv_block_valid_length(None, 4, 2, 3) == 4
|
||||
|
||||
|
||||
def test_pytorch_ring_trims_padding_from_each_kv_block(monkeypatch):
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_attention(q, k, v, **kwargs):
|
||||
captured.update(k=k, v=v, **kwargs)
|
||||
return _fake_attention_result(q, k, v, **kwargs)
|
||||
|
||||
monkeypatch.setattr(ring_pytorch_attn, "RingComm", lambda _group: _SingleRankComm())
|
||||
monkeypatch.setattr(ring_pytorch_attn, "pytorch_attn_forward", fake_attention)
|
||||
qkv = torch.randn(1, 4, 2, 8)
|
||||
|
||||
output = ring_pytorch_attn.ring_pytorch_attn_func(
|
||||
qkv,
|
||||
qkv,
|
||||
qkv,
|
||||
group=object(),
|
||||
valid_kv_length=3,
|
||||
)
|
||||
|
||||
assert output.shape == qkv.shape
|
||||
assert captured["k"].shape[1] == 3
|
||||
assert captured["v"].shape[1] == 3
|
||||
|
||||
|
||||
def test_pytorch_ring_trims_circulated_blocks_by_source_rank(monkeypatch):
|
||||
observed_lengths = []
|
||||
# Each ring step sends K and V separately.
|
||||
blocks = [torch.randn(1, 4, 2, 8) for _ in range(4)]
|
||||
|
||||
class FakeRingComm:
|
||||
rank = 2
|
||||
world_size = 3
|
||||
|
||||
def __init__(self, _group):
|
||||
self._next_block = 0
|
||||
|
||||
def send_recv(self, _tensor):
|
||||
block = blocks[self._next_block]
|
||||
self._next_block += 1
|
||||
return block
|
||||
|
||||
def commit(self):
|
||||
pass
|
||||
|
||||
def wait(self):
|
||||
pass
|
||||
|
||||
def fake_attention(q, k, v, **kwargs):
|
||||
observed_lengths.append(k.shape[1])
|
||||
return _fake_attention_result(q, k, v, **kwargs)
|
||||
|
||||
monkeypatch.setattr(ring_pytorch_attn, "RingComm", FakeRingComm)
|
||||
monkeypatch.setattr(ring_pytorch_attn, "pytorch_attn_forward", fake_attention)
|
||||
qkv = torch.randn(1, 4, 2, 8)
|
||||
|
||||
ring_pytorch_attn.ring_pytorch_attn_func(
|
||||
qkv,
|
||||
qkv,
|
||||
qkv,
|
||||
group=object(),
|
||||
valid_kv_length=5,
|
||||
)
|
||||
|
||||
# Rank 2 starts with global block 2, then receives blocks 1 and 0.
|
||||
# The first block is empty and therefore does not launch attention.
|
||||
assert observed_lengths == [1, 4]
|
||||
|
||||
|
||||
def test_flash_ring_trims_padding_from_each_kv_block(monkeypatch):
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_attention(q, k, v, **kwargs):
|
||||
captured.update(k=k, v=v, **kwargs)
|
||||
return _fake_attention_result(q, k, v, **kwargs)
|
||||
|
||||
monkeypatch.setattr(ring_flash_attn, "RingComm", lambda _group: _SingleRankComm())
|
||||
monkeypatch.setattr(ring_flash_attn, "select_flash_attn_impl", lambda *_args, **_kwargs: fake_attention)
|
||||
qkv = torch.randn(1, 4, 2, 8)
|
||||
|
||||
output, _ = ring_flash_attn.ring_flash_attn_forward(
|
||||
object(),
|
||||
qkv,
|
||||
qkv,
|
||||
qkv,
|
||||
softmax_scale=0.25,
|
||||
causal=False,
|
||||
attn_type=AttnType.FA,
|
||||
valid_kv_length=3,
|
||||
)
|
||||
|
||||
assert output.shape == qkv.shape
|
||||
assert captured["k"].shape[1] == 3
|
||||
assert captured["v"].shape[1] == 3
|
||||
|
||||
|
||||
def test_ring_dispatch_forwards_global_valid_kv_length(monkeypatch):
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_ring_attention(*_args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return "output"
|
||||
|
||||
monkeypatch.setattr(ring_pytorch_attn, "ring_pytorch_attn_func", fake_ring_attention)
|
||||
strategy = ring_parallel.RingParallelAttention(
|
||||
_FakeSequenceParallelGroup(ring_group=object()),
|
||||
attn_backend_pref="sdpa",
|
||||
)
|
||||
metadata = AttentionMetadata(extra={"valid_kv_length": 3})
|
||||
query = torch.randn(1, 4, 2, 8)
|
||||
|
||||
assert strategy.run_attention(query, query, query, metadata) == "output"
|
||||
assert captured["valid_kv_length"] == 3
|
||||
|
||||
|
||||
def test_pytorch_ring_rejects_causal_with_a_valid_prefix(monkeypatch):
|
||||
"""Trimming a K/V block under causal=True would silently shift the diagonal.
|
||||
|
||||
The query block keeps the full padded length while the circulated K/V block
|
||||
is trimmed, and a causal mask over a rectangular tile is bottom-right
|
||||
aligned, so the combination must be refused rather than computed.
|
||||
"""
|
||||
monkeypatch.setattr(ring_pytorch_attn, "RingComm", lambda _group: _SingleRankComm())
|
||||
monkeypatch.setattr(ring_pytorch_attn, "pytorch_attn_forward", _fake_attention_result)
|
||||
qkv = torch.randn(1, 4, 2, 8)
|
||||
|
||||
with pytest.raises(ValueError, match="valid_kv_length is not supported with causal=True"):
|
||||
ring_pytorch_attn.ring_pytorch_attn_func(
|
||||
qkv,
|
||||
qkv,
|
||||
qkv,
|
||||
causal=True,
|
||||
group=object(),
|
||||
valid_kv_length=3,
|
||||
)
|
||||
|
||||
|
||||
def test_flash_ring_rejects_causal_with_a_valid_prefix(monkeypatch):
|
||||
"""Same refusal on the Flash-Attention ring path."""
|
||||
monkeypatch.setattr(ring_flash_attn, "RingComm", lambda _group: _SingleRankComm())
|
||||
monkeypatch.setattr(
|
||||
ring_flash_attn,
|
||||
"select_flash_attn_impl",
|
||||
lambda *_args, **_kwargs: _fake_attention_result,
|
||||
)
|
||||
qkv = torch.randn(1, 4, 2, 8)
|
||||
|
||||
with pytest.raises(ValueError, match="valid_kv_length is not supported with causal=True"):
|
||||
ring_flash_attn.ring_flash_attn_forward(
|
||||
object(),
|
||||
qkv,
|
||||
qkv,
|
||||
qkv,
|
||||
softmax_scale=0.25,
|
||||
causal=True,
|
||||
attn_type=AttnType.FA,
|
||||
valid_kv_length=3,
|
||||
)
|
||||
|
||||
|
||||
def test_ring_paths_still_accept_causal_without_a_valid_prefix(monkeypatch):
|
||||
"""The guard is scoped to the padded case: plain causal Ring is unchanged."""
|
||||
monkeypatch.setattr(ring_flash_attn, "RingComm", lambda _group: _SingleRankComm())
|
||||
monkeypatch.setattr(
|
||||
ring_flash_attn,
|
||||
"select_flash_attn_impl",
|
||||
lambda *_args, **_kwargs: _fake_attention_result,
|
||||
)
|
||||
qkv = torch.randn(1, 4, 2, 8)
|
||||
|
||||
output, _ = ring_flash_attn.ring_flash_attn_forward(
|
||||
object(),
|
||||
qkv,
|
||||
qkv,
|
||||
qkv,
|
||||
softmax_scale=0.25,
|
||||
causal=True,
|
||||
attn_type=AttnType.FA,
|
||||
)
|
||||
|
||||
assert output.shape == qkv.shape
|
||||
@@ -459,6 +459,7 @@ def test_combined_task_inference_and_transformer_routing():
|
||||
("fl2va", "fl2va", 1, "FL2VA", ["FL2VA"], {"t2va", "fl2va"}, False),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("trust_remote_code", [False, True])
|
||||
def test_pipeline_loads_task_selected_components_with_encoder_ownership(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
@@ -469,6 +470,7 @@ def test_pipeline_loads_task_selected_components_with_encoder_ownership(
|
||||
source_partitions,
|
||||
expected_tasks,
|
||||
load_text_encoder,
|
||||
trust_remote_code,
|
||||
):
|
||||
from vllm_omni.diffusion.data import (
|
||||
DiffusionParallelConfig,
|
||||
@@ -500,6 +502,7 @@ def test_pipeline_loads_task_selected_components_with_encoder_ownership(
|
||||
(tmp_path / partition_name / component).mkdir()
|
||||
|
||||
created: dict[str, list[Any]] = {"dit": [], "text_encoder": [], "video_vae": [], "audio_vae": []}
|
||||
created_kwargs: dict[str, list[Any]] = {"text_encoder": [], "video_vae": [], "audio_vae": []}
|
||||
|
||||
class FakeModule(torch.nn.Module):
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -513,6 +516,7 @@ def test_pipeline_loads_task_selected_components_with_encoder_ownership(
|
||||
def component_factory(name):
|
||||
def create(path, *args, **kwargs):
|
||||
created[name].append(str(path))
|
||||
created_kwargs[name].append(kwargs)
|
||||
return FakeModule()
|
||||
|
||||
return create
|
||||
@@ -589,6 +593,7 @@ def test_pipeline_loads_task_selected_components_with_encoder_ownership(
|
||||
cfg_parallel_size=1,
|
||||
text_encoder_tp_size=1,
|
||||
),
|
||||
trust_remote_code=trust_remote_code,
|
||||
)
|
||||
pipeline = pipeline_module.MiniMaxH3Pipeline(od_config=od_config)
|
||||
|
||||
@@ -598,6 +603,13 @@ def test_pipeline_loads_task_selected_components_with_encoder_ownership(
|
||||
component_path = tmp_path / component_partition
|
||||
assert created["video_vae"] == [str(component_path / "video_vae")]
|
||||
assert created["audio_vae"] == [str(component_path / "audio_vae")]
|
||||
# The checkpoint VAEs execute the modeling code shipped with the weights, so
|
||||
# the pipeline must hand them the engine's trust_remote_code decision. Both
|
||||
# VAEs are created for every parametrization, so this stays unconditional.
|
||||
for component in ("video_vae", "audio_vae"):
|
||||
assert [kwargs["trust_remote_code"] for kwargs in created_kwargs[component]] == [trust_remote_code]
|
||||
# The tokenizer/processor "loaded once" assertions are asserted per
|
||||
# load_text_encoder branch below, matching upstream's own expectations.
|
||||
expected_dit_modules = ["transformer", "transformers_ref"] if expected_dits == 2 else ["transformer"]
|
||||
assert pipeline._dit_modules == expected_dit_modules
|
||||
assert hasattr(pipeline, "transformers_ref") is (expected_dits == 2)
|
||||
@@ -1122,6 +1134,64 @@ def test_packed_attention_keeps_padding_mask_for_other_backends():
|
||||
)
|
||||
|
||||
|
||||
def test_ring_packed_attention_publishes_prefix_length_without_global_mask():
|
||||
attention = _fake_packed_attention("CUDNN_ATTN")
|
||||
attention.attention.use_ring = True
|
||||
q = torch.randn(4, 2, 4)
|
||||
|
||||
attention._run_packed_attention(
|
||||
q,
|
||||
q,
|
||||
q,
|
||||
cu_seqlens=torch.tensor([0, 5, 8], dtype=torch.int32),
|
||||
max_seqlen=5,
|
||||
packed_total=8,
|
||||
)
|
||||
|
||||
metadata = attention.attention.metadata
|
||||
assert metadata.attn_mask is None
|
||||
assert metadata.extra["valid_kv_length"] == 5
|
||||
|
||||
|
||||
def test_ring_packed_attention_keeps_mask_when_runtime_sp_is_inactive(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import minimax_h3_transformer
|
||||
|
||||
class InactiveContext:
|
||||
sp_active = False
|
||||
|
||||
attention = _fake_packed_attention("CUDNN_ATTN")
|
||||
attention.attention.use_ring = True
|
||||
monkeypatch.setattr(
|
||||
minimax_h3_transformer,
|
||||
"is_forward_context_available",
|
||||
lambda: True,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
minimax_h3_transformer,
|
||||
"get_forward_context",
|
||||
InactiveContext,
|
||||
raising=False,
|
||||
)
|
||||
q = torch.randn(8, 2, 4)
|
||||
|
||||
attention._run_packed_attention(
|
||||
q,
|
||||
q,
|
||||
q,
|
||||
cu_seqlens=torch.tensor([0, 5, 8], dtype=torch.int32),
|
||||
max_seqlen=5,
|
||||
packed_total=8,
|
||||
)
|
||||
|
||||
metadata = attention.attention.metadata
|
||||
assert torch.equal(
|
||||
metadata.attn_mask,
|
||||
torch.tensor([[True, True, True, True, True, False, False, False]]),
|
||||
)
|
||||
assert metadata.extra["npu_attn_varlen"] is True
|
||||
|
||||
|
||||
def _fake_packed_attention(
|
||||
backend_name: str,
|
||||
*,
|
||||
@@ -1143,6 +1213,10 @@ def _fake_packed_attention(
|
||||
def supports_multi_doc_packed_varlen(cls) -> bool:
|
||||
return supports_multi_doc
|
||||
|
||||
@classmethod
|
||||
def supports_packed_mask_free(cls) -> bool:
|
||||
return False
|
||||
|
||||
class FakeAttention(torch.nn.Module):
|
||||
attn_backend = FakeBackend
|
||||
|
||||
@@ -1712,7 +1786,7 @@ def test_video_vae_keeps_reference_fp32_weights(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
vae_module,
|
||||
"_load_remote_component",
|
||||
lambda _path, _config: FakeRemote(),
|
||||
lambda _path, _config, *, trust_remote_code: FakeRemote(),
|
||||
)
|
||||
|
||||
video_vae = vae_module.MiniMaxH3VideoVAE(
|
||||
@@ -1834,7 +1908,7 @@ def test_video_vae_can_load_on_cpu_for_staged_gpu_residency(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
vae_module,
|
||||
"_load_remote_component",
|
||||
lambda _path, _config: FakeRemote(),
|
||||
lambda _path, _config, *, trust_remote_code: FakeRemote(),
|
||||
)
|
||||
|
||||
video_vae = vae_module.MiniMaxH3VideoVAE(
|
||||
@@ -1869,7 +1943,7 @@ def test_video_vae_uses_snapshot_stager_for_accelerator_target(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
vae_module,
|
||||
"_load_remote_component",
|
||||
lambda _path, _config: FakeRemote(),
|
||||
lambda _path, _config, *, trust_remote_code: FakeRemote(),
|
||||
)
|
||||
monkeypatch.setattr(vae_module, "PinnedModuleStager", stager_cls)
|
||||
|
||||
@@ -1890,6 +1964,123 @@ def test_video_vae_uses_snapshot_stager_for_accelerator_target(monkeypatch):
|
||||
stager.offload.assert_called_once_with()
|
||||
|
||||
|
||||
def test_remote_component_refuses_untrusted_checkpoint_code(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module
|
||||
|
||||
loaded = []
|
||||
|
||||
def spy_get_class(class_reference, component_path):
|
||||
loaded.append((class_reference, component_path))
|
||||
raise AssertionError("checkpoint code must not be resolved when untrusted")
|
||||
|
||||
monkeypatch.setattr(vae_module, "get_class_from_dynamic_module", spy_get_class)
|
||||
|
||||
with pytest.raises(ValueError, match="--trust-remote-code"):
|
||||
vae_module._load_remote_component(
|
||||
"/checkpoints/video_vae",
|
||||
{"auto_map": {"AutoModel": "modeling_vae.VideoVAE"}},
|
||||
trust_remote_code=False,
|
||||
)
|
||||
|
||||
assert loaded == []
|
||||
|
||||
|
||||
def test_remote_component_resolves_checkpoint_code_when_trusted(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module
|
||||
|
||||
class FakeComponent(torch.nn.Module):
|
||||
@classmethod
|
||||
def from_pretrained(cls, component_path):
|
||||
module = cls()
|
||||
module.loaded_from = component_path
|
||||
return module
|
||||
|
||||
loaded = []
|
||||
|
||||
def spy_get_class(class_reference, component_path):
|
||||
loaded.append((class_reference, component_path))
|
||||
return FakeComponent
|
||||
|
||||
monkeypatch.setattr(vae_module, "get_class_from_dynamic_module", spy_get_class)
|
||||
|
||||
module = vae_module._load_remote_component(
|
||||
"/checkpoints/video_vae",
|
||||
{"auto_map": {"AutoModel": "modeling_vae.VideoVAE"}},
|
||||
trust_remote_code=True,
|
||||
)
|
||||
|
||||
# Negative control: the trusted path stays byte-for-byte the pre-gate call.
|
||||
assert loaded == [("modeling_vae.VideoVAE", "/checkpoints/video_vae")]
|
||||
assert module.loaded_from == "/checkpoints/video_vae"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("vae_cls_name", ["MiniMaxH3VideoVAE", "MiniMaxH3AudioVAE"])
|
||||
@pytest.mark.parametrize("trusted", [True, False])
|
||||
def test_vae_forwards_trust_remote_code_to_loader(monkeypatch, vae_cls_name, trusted):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module
|
||||
|
||||
class FakeRemote(torch.nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.model = torch.nn.Linear(1, 1)
|
||||
|
||||
forwarded = []
|
||||
|
||||
def spy_load(_component_path, _config, *, trust_remote_code):
|
||||
forwarded.append(trust_remote_code)
|
||||
return FakeRemote()
|
||||
|
||||
monkeypatch.setattr(
|
||||
vae_module,
|
||||
"_load_component_config",
|
||||
lambda _path: {
|
||||
"latent_channels": 1,
|
||||
"latents_mean": [0.0],
|
||||
"latents_std": [1.0],
|
||||
"sample_rate": 32000,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(vae_module, "_load_remote_component", spy_load)
|
||||
|
||||
vae_cls = getattr(vae_module, vae_cls_name)
|
||||
vae_cls(
|
||||
"unused",
|
||||
device=torch.device("cpu"),
|
||||
trust_remote_code=trusted,
|
||||
)
|
||||
|
||||
assert forwarded == [trusted]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("vae_cls_name", ["MiniMaxH3VideoVAE", "MiniMaxH3AudioVAE"])
|
||||
def test_vae_defaults_to_refusing_checkpoint_code(monkeypatch, vae_cls_name):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module
|
||||
|
||||
forwarded = []
|
||||
|
||||
def spy_load(_component_path, _config, *, trust_remote_code):
|
||||
forwarded.append(trust_remote_code)
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
vae_module,
|
||||
"_load_component_config",
|
||||
lambda _path: {
|
||||
"latent_channels": 1,
|
||||
"latents_mean": [0.0],
|
||||
"latents_std": [1.0],
|
||||
"sample_rate": 32000,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(vae_module, "_load_remote_component", spy_load)
|
||||
|
||||
vae_cls = getattr(vae_module, vae_cls_name)
|
||||
with pytest.raises(AssertionError, match="unreachable"):
|
||||
vae_cls("unused", device=torch.device("cpu"))
|
||||
|
||||
assert forwarded == [False]
|
||||
|
||||
|
||||
def test_video_vae_encode_uses_configured_parallel_tiling():
|
||||
from vllm_omni.diffusion.models.minimax_h3.vae import (
|
||||
MiniMaxH3VideoVAE,
|
||||
@@ -2889,3 +3080,112 @@ def test_encoder_load_places_every_source_in_its_own_rows(tmp_path):
|
||||
}
|
||||
for source, rows in slices.items():
|
||||
assert torch.equal(rows, torch.full_like(rows, _SOURCE_FILL[source])), source
|
||||
|
||||
|
||||
def _pad_seq_len_batch(pad_seq_len):
|
||||
"""A t2va request that pins the packed length through ``extra_args``."""
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
extra_args = {"task": "t2va", "aspect_ratio": "16:9"}
|
||||
if pad_seq_len is not None:
|
||||
extra_args["pad_seq_len"] = pad_seq_len
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
quality="lossless",
|
||||
width=1344,
|
||||
height=768,
|
||||
fps=24,
|
||||
num_frames=124,
|
||||
num_inference_steps=None,
|
||||
extra_args=extra_args,
|
||||
)
|
||||
return DiffusionRequestBatch(
|
||||
[
|
||||
OmniDiffusionRequest(
|
||||
prompt="pinned packed length",
|
||||
sampling_params=sampling,
|
||||
request_id="pad-seq-len",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pinned, expected", [(None, None), (54080, 54080)])
|
||||
def test_pad_seq_len_reaches_diffuse_from_extra_args(pinned, expected):
|
||||
"""The request field must arrive at ``diffuse``, not stop at validation."""
|
||||
diffuse_calls: list[dict[str, Any]] = []
|
||||
pipeline = _distilled_pipeline(diffuse_calls, {"fl2va": None, "ref2va": None})
|
||||
|
||||
pipeline.forward(_pad_seq_len_batch(pinned))
|
||||
|
||||
assert [call["pad_seq_len"] for call in diffuse_calls] == [expected]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("task", ["t2va", "ref2va"])
|
||||
def test_pad_seq_len_reaches_the_packer_for_every_task_family(task, monkeypatch):
|
||||
"""``diffuse`` must hand the pin to whichever packer the task uses."""
|
||||
import vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 as module
|
||||
|
||||
class _CapturedError(Exception):
|
||||
pass
|
||||
|
||||
recorded = {}
|
||||
|
||||
def _capture(**kwargs):
|
||||
recorded.update(kwargs)
|
||||
raise _CapturedError
|
||||
|
||||
monkeypatch.setattr(module, "minimax_h3_packed_sequence", _capture)
|
||||
monkeypatch.setattr(module, "minimax_h3_packed_sequence_ref2va_blocks", _capture)
|
||||
|
||||
pipeline = _distilled_pipeline([], {"fl2va": None, "ref2va": None})
|
||||
pipeline._initial_noise = lambda **kwargs: (torch.zeros(1), torch.zeros(1))
|
||||
|
||||
with pytest.raises(_CapturedError):
|
||||
pipeline._build_denoise_inputs(
|
||||
task=task,
|
||||
text_embeddings=torch.ones(7, 2),
|
||||
text_tags=torch.ones(7, dtype=torch.long),
|
||||
seed=0,
|
||||
latent_t=2,
|
||||
latent_h=4,
|
||||
latent_w=6,
|
||||
audio_t=3,
|
||||
num_frames=5,
|
||||
num_steps=2,
|
||||
video_shift=12.0,
|
||||
audio_shift=3.0,
|
||||
base_schedule=None,
|
||||
visual_condition=None,
|
||||
visual_condition_shape=(1, 4, 6),
|
||||
audio_condition=None,
|
||||
ref_audio_t=1,
|
||||
pad_seq_len=192,
|
||||
)
|
||||
|
||||
assert recorded["seq_len"] == 192
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [0, -64, 100, "128", True, 1.5, (1 << 20) + 64])
|
||||
def test_pad_seq_len_request_validation_rejects_bad_values(value):
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
|
||||
_resolve_pad_seq_len,
|
||||
)
|
||||
from vllm_omni.errors import OmniClientError
|
||||
|
||||
with pytest.raises(OmniClientError):
|
||||
_resolve_pad_seq_len(value)
|
||||
|
||||
|
||||
def test_pad_seq_len_request_validation_accepts_aligned_values():
|
||||
from vllm_omni.diffusion.models.minimax_h3.packed_sequence import (
|
||||
MINIMAX_H3_MAX_PAD_SEQ_LEN,
|
||||
)
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
|
||||
_resolve_pad_seq_len,
|
||||
)
|
||||
|
||||
assert _resolve_pad_seq_len(None) is None
|
||||
assert _resolve_pad_seq_len(54080) == 54080
|
||||
assert _resolve_pad_seq_len(MINIMAX_H3_MAX_PAD_SEQ_LEN) == MINIMAX_H3_MAX_PAD_SEQ_LEN
|
||||
@@ -1,7 +1,8 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from contextlib import contextmanager
|
||||
import weakref
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
@@ -76,6 +77,247 @@ def test_requested_model_offload_without_backend_uses_resident_components():
|
||||
pipeline.text_encoder.encode_ids.assert_called_once()
|
||||
|
||||
|
||||
def test_decoded_output_is_unchanged_without_active_model_offload():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline._model_cpu_offload_modules = []
|
||||
output = Mock()
|
||||
|
||||
assert pipeline._offload_model_cpu_stage_output(output) is output
|
||||
output.cpu.assert_not_called()
|
||||
|
||||
|
||||
def test_reply_rank_copies_decoded_output_to_host(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline._model_cpu_offload_modules = [Mock()]
|
||||
monkeypatch.setattr(pipeline, "_is_output_owner_rank", lambda: True)
|
||||
output = Mock()
|
||||
host_output = Mock()
|
||||
output.cpu.return_value = host_output
|
||||
|
||||
assert pipeline._offload_model_cpu_stage_output(output) is host_output
|
||||
output.cpu.assert_called_once_with()
|
||||
|
||||
|
||||
def test_non_reply_rank_drops_decoded_output_storage(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline._model_cpu_offload_modules = [Mock()]
|
||||
monkeypatch.setattr(pipeline, "_is_output_owner_rank", lambda: False)
|
||||
output = torch.ones((2, 3), dtype=torch.float16)
|
||||
|
||||
placeholder = pipeline._offload_model_cpu_stage_output(output)
|
||||
|
||||
assert placeholder.device.type == "meta"
|
||||
assert placeholder.shape == output.shape
|
||||
assert placeholder.dtype == output.dtype
|
||||
|
||||
|
||||
def test_decode_releases_only_audio_before_return(monkeypatch):
|
||||
"""Video is released by the callers of ``decode`` -- after quantization."""
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
|
||||
video = torch.ones((1, 1, 4, 4))
|
||||
audio = torch.ones((1, 8))
|
||||
pipeline = SimpleNamespace(
|
||||
device=torch.device("cpu"),
|
||||
video_vae=Mock(decode_latent=Mock(return_value=video)),
|
||||
audio_vae=Mock(decode_latent=Mock(return_value=audio)),
|
||||
_component_on_device=lambda component: nullcontext(),
|
||||
_offload_model_cpu_stage_output=Mock(side_effect=lambda value: value),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module.current_omni_platform,
|
||||
"create_autocast_context",
|
||||
lambda **kwargs: nullcontext(),
|
||||
)
|
||||
|
||||
actual_video, actual_audio = MiniMaxH3Pipeline.decode(
|
||||
pipeline,
|
||||
torch.zeros(1),
|
||||
torch.zeros(1),
|
||||
height=2,
|
||||
width=3,
|
||||
)
|
||||
|
||||
assert actual_video.shape[-2:] == (2, 3)
|
||||
assert actual_audio is audio
|
||||
calls = pipeline._offload_model_cpu_stage_output.call_args_list
|
||||
assert len(calls) == 1
|
||||
assert calls[0].args[0] is audio
|
||||
|
||||
|
||||
def _recording_release(events, releases):
|
||||
"""Stand-in for ``_offload_model_cpu_stage_output`` that records what it got."""
|
||||
|
||||
def release(value):
|
||||
events.append("release")
|
||||
releases.append(value)
|
||||
return value
|
||||
|
||||
return release
|
||||
|
||||
|
||||
def test_forward_releases_video_after_quantization_and_before_next_seed(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
# MiniMaxH3VideoVAE.decode_latent ends in frames.float(), so this is what
|
||||
# the release hook would see if it ran before the quantizer.
|
||||
decoded_dtype = torch.float32
|
||||
events: list[str] = []
|
||||
releases: list[torch.Tensor] = []
|
||||
|
||||
def diffuse(**kwargs):
|
||||
events.append(f"diffuse:{kwargs['seed']}")
|
||||
return torch.zeros(1), torch.zeros(1)
|
||||
|
||||
def decode(video_latent, audio_latent, *, height, width):
|
||||
events.append("decode")
|
||||
return (
|
||||
torch.zeros((1, 3, 2, height, width), dtype=decoded_dtype),
|
||||
torch.zeros((1, 8)),
|
||||
)
|
||||
|
||||
# ``forward`` indexes ``preencode_mp4`` (#7018): this test drives the
|
||||
# tensor-decode branch, which is the one the release hook wraps.
|
||||
context = {"num_outputs": 2, "seed": 41, "height": 4, "width": 4, "preencode_mp4": False}
|
||||
pipeline = SimpleNamespace(
|
||||
od_config=SimpleNamespace(),
|
||||
_extract_prompt=lambda raw: ("a prompt", {}),
|
||||
_extract_text_conditioning=lambda raw: None,
|
||||
_extract_prepared_reference_videos=lambda raw: None,
|
||||
_prepare_request_inputs=lambda **kwargs: context,
|
||||
_denoise_kwargs=lambda ctx: {},
|
||||
diffuse=diffuse,
|
||||
decode=decode,
|
||||
_offload_model_cpu_stage_output=_recording_release(events, releases),
|
||||
)
|
||||
request = SimpleNamespace(prompts=["a prompt"], sampling_params=SimpleNamespace())
|
||||
|
||||
output = MiniMaxH3Pipeline.forward(pipeline, request)
|
||||
|
||||
# The release must see the quantized frames, not the decoded floats.
|
||||
assert len(releases) == 2
|
||||
assert [tensor.dtype for tensor in releases] == [torch.uint8, torch.uint8]
|
||||
assert decoded_dtype is not torch.uint8
|
||||
# ... and it must still happen before the next seed reloads the DiT.
|
||||
assert events == ["diffuse:41", "decode", "release", "diffuse:42", "decode", "release"]
|
||||
assert output.output[0].dtype is torch.uint8
|
||||
|
||||
|
||||
def test_forward_drops_the_decoded_video_before_the_next_seed_diffuses(monkeypatch):
|
||||
"""Releasing the copy is not enough if a local still pins the original.
|
||||
|
||||
``_offload_model_cpu_stage_output`` hands back a *new* tensor, so the
|
||||
decoded frames stay alive for as long as anything still references them.
|
||||
The whole point of this branch is that they are gone before the next seed
|
||||
reloads the DiT, and ``diffuse`` is where that reload happens.
|
||||
"""
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
decoded_refs: list[weakref.ref] = []
|
||||
alive_at_diffuse: list[int] = []
|
||||
|
||||
def diffuse(**kwargs):
|
||||
alive_at_diffuse.append(sum(1 for ref in decoded_refs if ref() is not None))
|
||||
return torch.zeros(1), torch.zeros(1)
|
||||
|
||||
def decode(video_latent, audio_latent, *, height, width):
|
||||
video = torch.zeros((1, 3, 2, height, width), dtype=torch.float32)
|
||||
decoded_refs.append(weakref.ref(video))
|
||||
return video, torch.zeros((1, 8))
|
||||
|
||||
# ``forward`` indexes ``preencode_mp4`` (#7018): this test drives the
|
||||
# tensor-decode branch, which is the one the release hook wraps.
|
||||
context = {"num_outputs": 2, "seed": 41, "height": 4, "width": 4, "preencode_mp4": False}
|
||||
pipeline = SimpleNamespace(
|
||||
od_config=SimpleNamespace(),
|
||||
_extract_prompt=lambda raw: ("a prompt", {}),
|
||||
_extract_text_conditioning=lambda raw: None,
|
||||
_extract_prepared_reference_videos=lambda raw: None,
|
||||
_prepare_request_inputs=lambda **kwargs: context,
|
||||
_denoise_kwargs=lambda ctx: {},
|
||||
diffuse=diffuse,
|
||||
decode=decode,
|
||||
_offload_model_cpu_stage_output=lambda value: value.clone(),
|
||||
)
|
||||
request = SimpleNamespace(prompts=["a prompt"], sampling_params=SimpleNamespace())
|
||||
|
||||
MiniMaxH3Pipeline.forward(pipeline, request)
|
||||
|
||||
assert len(decoded_refs) == 2
|
||||
# Seed 1's diffuse sees nothing decoded yet; seed 2's must not see seed 1's
|
||||
# decoded frames still resident.
|
||||
assert alive_at_diffuse == [0, 0]
|
||||
|
||||
|
||||
def test_post_decode_releases_video_after_quantization(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
|
||||
events: list[str] = []
|
||||
releases: list[torch.Tensor] = []
|
||||
|
||||
def decode(video_latent, audio_latent, *, height, width):
|
||||
events.append("decode")
|
||||
return (
|
||||
torch.zeros((1, 3, 2, height, width), dtype=torch.float16),
|
||||
torch.zeros((1, 8)),
|
||||
)
|
||||
|
||||
pipeline = SimpleNamespace(
|
||||
od_config=SimpleNamespace(),
|
||||
_unpack_denoised_rows=lambda *args, **kwargs: (torch.zeros(1), torch.zeros(1)),
|
||||
decode=decode,
|
||||
_offload_model_cpu_stage_output=_recording_release(events, releases),
|
||||
)
|
||||
state = SimpleNamespace(
|
||||
latents=torch.zeros(1),
|
||||
extra={
|
||||
module._STEP_BRANCH: object(),
|
||||
module._STEP_AUDIO_ROWS: torch.zeros(1),
|
||||
module._STEP_SHAPE: {
|
||||
"latent_t": 1,
|
||||
"latent_h": 1,
|
||||
"latent_w": 1,
|
||||
"audio_t": 1,
|
||||
"height": 4,
|
||||
"width": 4,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
output = MiniMaxH3Pipeline.post_decode(pipeline, state)
|
||||
|
||||
assert events == ["decode", "release"]
|
||||
assert len(releases) == 1
|
||||
assert releases[0].dtype is torch.uint8
|
||||
assert output.output[0] is releases[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("available", "initialized", "rank", "expected"),
|
||||
[(False, False, 7, True), (True, False, 7, True), (True, True, 0, True), (True, True, 1, False)],
|
||||
)
|
||||
def test_output_owner_rank_matches_executor_contract(monkeypatch, available, initialized, rank, expected):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
|
||||
monkeypatch.setattr(module.dist, "is_available", lambda: available)
|
||||
monkeypatch.setattr(module.dist, "is_initialized", lambda: initialized)
|
||||
monkeypatch.setattr(module.dist, "get_rank", lambda: rank)
|
||||
|
||||
assert MiniMaxH3Pipeline._is_output_owner_rank() is expected
|
||||
|
||||
|
||||
def test_h3_model_cpu_offload_registers_direct_vae_stages(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
|
||||
@@ -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
|
||||
@@ -190,3 +190,90 @@ def test_condition_noise_accepts_a_reference_video_longer_than_the_target():
|
||||
noise_aug=0.5,
|
||||
)
|
||||
assert result.shape == rows.shape
|
||||
|
||||
|
||||
def test_explicit_seq_len_pins_one_shape_across_prompt_lengths():
|
||||
"""Prompts of different token counts must land on the same packed length.
|
||||
|
||||
Without the pin they fall into different 64-row buckets, and each bucket is
|
||||
a packed shape the compiled transformer has not seen before.
|
||||
"""
|
||||
from vllm_omni.diffusion.models.minimax_h3.packed_sequence import (
|
||||
minimax_h3_packed_sequence,
|
||||
)
|
||||
|
||||
common = dict(
|
||||
latent_t=2,
|
||||
latent_h=4,
|
||||
latent_w=6,
|
||||
audio_t=3,
|
||||
include_keyframe_cond=False,
|
||||
)
|
||||
# used = text_len + 18 rows: 22 -> bucket 64; 118 -> bucket 128.
|
||||
short = minimax_h3_packed_sequence(text_len=4, **common)
|
||||
long = minimax_h3_packed_sequence(text_len=100, **common)
|
||||
assert int(short["seq_len"]) == 64
|
||||
assert int(long["seq_len"]) == 128
|
||||
|
||||
short_pinned = minimax_h3_packed_sequence(text_len=4, seq_len=192, **common)
|
||||
long_pinned = minimax_h3_packed_sequence(text_len=100, seq_len=192, **common)
|
||||
assert int(short_pinned["seq_len"]) == int(long_pinned["seq_len"]) == 192
|
||||
# The pin only adds padding rows; the used prefix is untouched.
|
||||
assert int(short_pinned["cu_seqlens"][1]) == int(short["cu_seqlens"][1]) == 22
|
||||
assert int(long_pinned["cu_seqlens"][1]) == int(long["cu_seqlens"][1]) == 118
|
||||
assert short_pinned["img_pos"].tolist() == short["img_pos"].tolist()
|
||||
|
||||
|
||||
def test_the_ref2va_packer_pins_one_shape_across_prompt_lengths_too():
|
||||
"""Both packers share one resolver, so both honour the pin."""
|
||||
from vllm_omni.diffusion.models.minimax_h3.packed_sequence import (
|
||||
minimax_h3_packed_sequence_ref2va_blocks,
|
||||
)
|
||||
|
||||
common = dict(
|
||||
latent_t=2,
|
||||
latent_h=4,
|
||||
latent_w=6,
|
||||
audio_t=3,
|
||||
ref_blocks=[{"kind": "audio", "ref_audio_t": 1}],
|
||||
)
|
||||
short = minimax_h3_packed_sequence_ref2va_blocks(text_len=4, **common)
|
||||
long = minimax_h3_packed_sequence_ref2va_blocks(text_len=100, **common)
|
||||
assert int(short["seq_len"]) != int(long["seq_len"])
|
||||
|
||||
short_pinned = minimax_h3_packed_sequence_ref2va_blocks(text_len=4, seq_len=192, **common)
|
||||
long_pinned = minimax_h3_packed_sequence_ref2va_blocks(text_len=100, seq_len=192, **common)
|
||||
assert int(short_pinned["seq_len"]) == int(long_pinned["seq_len"]) == 192
|
||||
assert int(short_pinned["cu_seqlens"][1]) == int(short["cu_seqlens"][1])
|
||||
assert int(long_pinned["cu_seqlens"][1]) == int(long["cu_seqlens"][1])
|
||||
|
||||
|
||||
def test_explicit_seq_len_below_used_rows_is_a_client_error():
|
||||
"""Both packers reject an under-sized pin as a 4xx, not a server error."""
|
||||
from vllm_omni.diffusion.models.minimax_h3.packed_sequence import (
|
||||
minimax_h3_packed_sequence,
|
||||
minimax_h3_packed_sequence_ref2va_blocks,
|
||||
)
|
||||
from vllm_omni.errors import OmniClientError
|
||||
|
||||
with pytest.raises(OmniClientError, match="used rows"):
|
||||
minimax_h3_packed_sequence(
|
||||
text_len=4,
|
||||
latent_t=2,
|
||||
latent_h=4,
|
||||
latent_w=6,
|
||||
audio_t=3,
|
||||
include_keyframe_cond=False,
|
||||
seq_len=8,
|
||||
)
|
||||
|
||||
with pytest.raises(OmniClientError, match="used rows"):
|
||||
minimax_h3_packed_sequence_ref2va_blocks(
|
||||
text_len=4,
|
||||
latent_t=2,
|
||||
latent_h=4,
|
||||
latent_w=6,
|
||||
audio_t=3,
|
||||
ref_blocks=[{"kind": "audio", "ref_audio_t": 1}],
|
||||
seq_len=8,
|
||||
)
|
||||
@@ -0,0 +1,473 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""CPU tests for the pinned tiled-VAE gather buffer in the MiniMax-H3 video VAE.
|
||||
|
||||
The checkpoint's ``_all_gather_tiled_results`` allocates its gather output on
|
||||
every call, so with a per-request ``empty_cache()`` the collective lands on a
|
||||
new device address every request and XCCL's per-address registrations pile up.
|
||||
On XPU the adapter replaces that method with an equal-shape gather into a
|
||||
buffer it keeps alive. These tests pin the properties that make it a fix
|
||||
rather than a rewrite: the address is stable, the tiles come back
|
||||
bit-identical and in the same order, the caller owns what it gets back, and no
|
||||
other device is touched.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import types
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module
|
||||
from vllm_omni.diffusion.models.minimax_h3.vae import MiniMaxH3VideoVAE
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
TILE = (1, 3, 2, 4, 4)
|
||||
|
||||
|
||||
class _CheckpointGatherSentinel:
|
||||
"""Stands in for the checkpoint's own bound method.
|
||||
|
||||
Identity is the point: a test asserts this exact object is still installed
|
||||
on the devices the adapter must leave alone.
|
||||
"""
|
||||
|
||||
def __call__(self, tasks, num_tiles): # pragma: no cover - never invoked
|
||||
raise AssertionError("the checkpoint gather must not run in these tests")
|
||||
|
||||
|
||||
class _FakeCheckpointModel:
|
||||
def __init__(self):
|
||||
self._all_gather_tiled_results = _CheckpointGatherSentinel()
|
||||
|
||||
|
||||
def _local_tiles(all_tiles, num_tiles, rank, sp_size):
|
||||
"""One rank's round-robin share, as ``klvae.py:275`` computes it."""
|
||||
return [all_tiles[index] for index in range(rank, num_tiles, sp_size)]
|
||||
|
||||
|
||||
def _reference_gather(all_tiles, num_tiles, sp_size):
|
||||
"""The checkpoint's variable-shape list gather, reimplemented verbatim.
|
||||
|
||||
Written from the control flow of ``_all_gather_tiled_results`` in the
|
||||
checkpoint's ``klvae.py`` (lines 252-273) -- stack each rank's share, gather
|
||||
the list, walk it back into global tile order -- so that it is an
|
||||
independent oracle rather than a restatement of the code under test.
|
||||
"""
|
||||
gathered = [torch.stack(_local_tiles(all_tiles, num_tiles, rank, sp_size), dim=0) for rank in range(sp_size)]
|
||||
results = [None] * num_tiles
|
||||
for rank, rank_tensors in enumerate(gathered):
|
||||
for k in range(rank_tensors.shape[0]):
|
||||
global_index = k * sp_size + rank
|
||||
if global_index >= num_tiles:
|
||||
break
|
||||
results[global_index] = rank_tensors[k]
|
||||
return results
|
||||
|
||||
|
||||
def _peer_stacks(all_tiles, num_tiles, sp_size):
|
||||
"""What every rank hands to the collective, padded the way the adapter pads."""
|
||||
max_tasks = -(-num_tiles // sp_size)
|
||||
stacks = []
|
||||
for rank in range(sp_size):
|
||||
share = _local_tiles(all_tiles, num_tiles, rank, sp_size)
|
||||
stacked = share[0].new_zeros((max_tasks, *share[0].shape))
|
||||
stacked[: len(share)] = torch.stack(share, dim=0)
|
||||
stacks.append(stacked)
|
||||
return stacks
|
||||
|
||||
|
||||
def _tiles(num_tiles, seed=0, dtype=torch.float32):
|
||||
generator = torch.Generator().manual_seed(seed)
|
||||
return [torch.rand(TILE, generator=generator).to(dtype) for _ in range(num_tiles)]
|
||||
|
||||
|
||||
def _workspace_bytes(num_tiles, sp_size, dtype=torch.float32):
|
||||
"""Bytes one gather needs: ``sp_size`` copies of the padded local stack."""
|
||||
max_tasks = -(-num_tiles // sp_size)
|
||||
element_size = torch.empty(0, dtype=dtype).element_size()
|
||||
numel = 1
|
||||
for dim in TILE:
|
||||
numel *= dim
|
||||
return sp_size * max_tasks * numel * element_size
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_collective(monkeypatch):
|
||||
"""Replace the collective with a single-process stand-in.
|
||||
|
||||
``all_gather_into_tensor`` is the only distributed call on this path, so
|
||||
filling the output buffer from a caller-supplied peer list exercises the
|
||||
real padding, keying, addressing and unpacking code without a process
|
||||
group.
|
||||
"""
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
def fake_all_gather_into_tensor(output, input_tensor, group=None, async_op=False):
|
||||
peers = calls[-1]["peers"]
|
||||
assert output.shape == (len(peers), *input_tensor.shape)
|
||||
calls[-1]["sent"] = input_tensor.clone()
|
||||
calls[-1]["buf_ptr"] = output.data_ptr()
|
||||
for rank, peer in enumerate(peers):
|
||||
output[rank].copy_(peer)
|
||||
|
||||
monkeypatch.setattr(dist, "all_gather_into_tensor", fake_all_gather_into_tensor)
|
||||
return calls
|
||||
|
||||
|
||||
class _Records(logging.Handler):
|
||||
"""vllm's logger sets ``propagate=False``, so caplog never sees these."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(level=logging.DEBUG)
|
||||
self.messages = []
|
||||
self.records = []
|
||||
|
||||
def emit(self, record):
|
||||
self.messages.append(record.getMessage())
|
||||
self.records.append((record.levelno, record.getMessage()))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gather_log():
|
||||
handler = _Records()
|
||||
logger = vae_module.logger
|
||||
previous = logger.level
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
try:
|
||||
yield handler
|
||||
finally:
|
||||
logger.removeHandler(handler)
|
||||
logger.setLevel(previous)
|
||||
|
||||
|
||||
def _vae(sp_size, sp_rank=0):
|
||||
"""An adapter instance with the override installed, no checkpoint needed."""
|
||||
state = {
|
||||
"sp_size": sp_size,
|
||||
"sp_rank": sp_rank,
|
||||
"sp_enabled": sp_size > 1,
|
||||
"sp_process_group": object(),
|
||||
}
|
||||
module = types.ModuleType("fake_gather_ckpt.parallel")
|
||||
module.get_parallel_state = lambda: state # type: ignore[attr-defined]
|
||||
sys.modules.setdefault("fake_gather_ckpt", types.ModuleType("fake_gather_ckpt"))
|
||||
sys.modules["fake_gather_ckpt.parallel"] = module
|
||||
|
||||
vae = object.__new__(MiniMaxH3VideoVAE)
|
||||
vae.remote = type("Remote", (), {"__module__": "fake_gather_ckpt.klvae"})()
|
||||
vae.model = _FakeCheckpointModel()
|
||||
vae._device_target = torch.device("xpu")
|
||||
vae._tile_gather_workspace = None
|
||||
vae._tile_gather_stats = {"hits": 0, "allocs": 0, "workspace_bytes": 0}
|
||||
vae._checkpoint_tile_gather = None
|
||||
vae._install_persistent_tile_gather()
|
||||
return vae
|
||||
|
||||
|
||||
def _run(vae, calls, all_tiles, num_tiles, sp_size, sp_rank):
|
||||
calls.append({"peers": _peer_stacks(all_tiles, num_tiles, sp_size)})
|
||||
return vae.model._all_gather_tiled_results(_local_tiles(all_tiles, num_tiles, sp_rank, sp_size), num_tiles)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The address is stable across calls. This is what the accumulation is about:
|
||||
# drop the workspace and every call registers a new receive address. And the
|
||||
# workspace is one grow-only block, so varying request geometries cannot keep
|
||||
# adding resident buffers the way a per-geometry cache would.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_landing_buffer_address_is_stable_across_calls(fake_collective):
|
||||
vae = _vae(4)
|
||||
pointers = []
|
||||
for request in range(3):
|
||||
_run(vae, fake_collective, _tiles(8, seed=request), 8, 4, 0)
|
||||
pointers.append(fake_collective[-1]["buf_ptr"])
|
||||
|
||||
assert len(set(pointers)) == 1, f"gather buffer moved across calls: {pointers}"
|
||||
assert vae._tile_gather_stats == {
|
||||
"hits": 2,
|
||||
"allocs": 1,
|
||||
"workspace_bytes": _workspace_bytes(8, 4),
|
||||
}
|
||||
assert vae._tile_gather_workspace.numel() == _workspace_bytes(8, 4)
|
||||
assert vae._tile_gather_workspace.dtype is torch.uint8
|
||||
|
||||
|
||||
def test_distinct_shapes_share_the_one_workspace(fake_collective):
|
||||
"""One decode gathers several temporal clips, and the head/tail clips differ."""
|
||||
vae = _vae(4)
|
||||
_run(vae, fake_collective, _tiles(8), 8, 4, 0)
|
||||
first = fake_collective[-1]["buf_ptr"]
|
||||
_run(vae, fake_collective, _tiles(12), 12, 4, 0)
|
||||
second = fake_collective[-1]["buf_ptr"]
|
||||
_run(vae, fake_collective, _tiles(8, seed=5), 8, 4, 0)
|
||||
third = fake_collective[-1]["buf_ptr"]
|
||||
|
||||
# The larger geometry did not fit, so the block was replaced -- not added to.
|
||||
assert second == third
|
||||
assert vae._tile_gather_stats["allocs"] == 2
|
||||
assert vae._tile_gather_stats["hits"] == 1
|
||||
assert vae._tile_gather_stats["workspace_bytes"] == _workspace_bytes(12, 4)
|
||||
# ``first`` is deliberately not compared: the replaced block is freed, so
|
||||
# the allocator is free to hand its address back for the larger one.
|
||||
assert isinstance(first, int)
|
||||
|
||||
|
||||
def test_workspace_is_bounded_across_geometries(fake_collective):
|
||||
"""Alternating geometries must not each retain a buffer of their own."""
|
||||
sp_size = 4
|
||||
large, small, largest = 12, 8, 20
|
||||
vae = _vae(sp_size)
|
||||
|
||||
pointers = []
|
||||
for round_index in range(3):
|
||||
for num_tiles in (large, small):
|
||||
_run(vae, fake_collective, _tiles(num_tiles, seed=round_index), num_tiles, sp_size, 0)
|
||||
pointers.append(fake_collective[-1]["buf_ptr"])
|
||||
|
||||
assert len(pointers) == 6
|
||||
assert len(set(pointers)) == 1, f"workspace moved across geometries: {pointers}"
|
||||
assert vae._tile_gather_stats["allocs"] == 1
|
||||
assert vae._tile_gather_stats["workspace_bytes"] == _workspace_bytes(large, sp_size)
|
||||
assert vae._tile_gather_workspace.numel() == _workspace_bytes(large, sp_size)
|
||||
|
||||
# A geometry that does not fit grows the single block; it does not add one.
|
||||
_run(vae, fake_collective, _tiles(largest), largest, sp_size, 0)
|
||||
grown = fake_collective[-1]["buf_ptr"]
|
||||
assert vae._tile_gather_stats["allocs"] == 2
|
||||
assert vae._tile_gather_stats["workspace_bytes"] == _workspace_bytes(largest, sp_size)
|
||||
assert vae._tile_gather_workspace.numel() == _workspace_bytes(largest, sp_size)
|
||||
|
||||
# ...and the geometries seen before it now fit, so nothing reallocates again.
|
||||
for num_tiles in (large, small):
|
||||
_run(vae, fake_collective, _tiles(num_tiles, seed=7), num_tiles, sp_size, 0)
|
||||
assert fake_collective[-1]["buf_ptr"] == grown
|
||||
assert vae._tile_gather_stats["allocs"] == 2
|
||||
assert vae._tile_gather_stats["workspace_bytes"] == _workspace_bytes(largest, sp_size)
|
||||
|
||||
|
||||
def test_workspace_grows_only_upward(fake_collective):
|
||||
"""Resident bytes track the largest geometry seen, not the sum of them."""
|
||||
sp_size = 4
|
||||
small, large = 8, 12
|
||||
vae = _vae(sp_size)
|
||||
|
||||
_run(vae, fake_collective, _tiles(small), small, sp_size, 0)
|
||||
assert vae._tile_gather_stats["workspace_bytes"] == _workspace_bytes(small, sp_size)
|
||||
|
||||
_run(vae, fake_collective, _tiles(large), large, sp_size, 0)
|
||||
|
||||
assert vae._tile_gather_stats["allocs"] == 2
|
||||
assert vae._tile_gather_stats["hits"] == 0
|
||||
assert vae._tile_gather_stats["workspace_bytes"] == _workspace_bytes(large, sp_size)
|
||||
assert vae._tile_gather_stats["workspace_bytes"] < _workspace_bytes(small, sp_size) + _workspace_bytes(
|
||||
large, sp_size
|
||||
)
|
||||
assert vae._tile_gather_workspace.numel() == _workspace_bytes(large, sp_size)
|
||||
|
||||
|
||||
def test_mixed_dtype_shares_one_workspace(fake_collective):
|
||||
"""The workspace is bytes, so a narrower dtype reuses it instead of adding one."""
|
||||
sp_size, num_tiles = 4, 8
|
||||
vae = _vae(sp_size)
|
||||
fp32_bytes = _workspace_bytes(num_tiles, sp_size, dtype=torch.float32)
|
||||
|
||||
pointers = []
|
||||
for round_index in range(2):
|
||||
for dtype in (torch.float32, torch.bfloat16):
|
||||
tiles = _tiles(num_tiles, seed=round_index, dtype=dtype)
|
||||
produced = _run(vae, fake_collective, tiles, num_tiles, sp_size, 0)
|
||||
pointers.append(fake_collective[-1]["buf_ptr"])
|
||||
assert all(tile.dtype is dtype for tile in produced)
|
||||
for index, tile in enumerate(produced):
|
||||
assert torch.equal(tile, tiles[index])
|
||||
|
||||
assert len(set(pointers)) == 1, f"workspace moved across dtypes: {pointers}"
|
||||
assert vae._tile_gather_stats["allocs"] == 1
|
||||
assert vae._tile_gather_stats["hits"] == 3
|
||||
assert vae._tile_gather_stats["workspace_bytes"] == fp32_bytes
|
||||
assert vae._tile_gather_workspace.numel() == fp32_bytes
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Equivalence with the checkpoint's own gather, padded branch included. A
|
||||
# reordered tile grid is silent at runtime, so order is asserted as well.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_tiles", [5, 8, 9, 28])
|
||||
@pytest.mark.parametrize("sp_rank", [0, 3])
|
||||
def test_gathered_tiles_match_the_checkpoint_gather(fake_collective, num_tiles, sp_rank):
|
||||
sp_size = 4
|
||||
all_tiles = _tiles(num_tiles, seed=num_tiles)
|
||||
vae = _vae(sp_size, sp_rank=sp_rank)
|
||||
|
||||
produced = _run(vae, fake_collective, all_tiles, num_tiles, sp_size, sp_rank)
|
||||
expected = _reference_gather(all_tiles, num_tiles, sp_size)
|
||||
|
||||
assert len(produced) == num_tiles
|
||||
assert all(tile is not None for tile in produced)
|
||||
for index, (got, want) in enumerate(zip(produced, expected)):
|
||||
assert torch.equal(got, want), f"tile {index} differs from the checkpoint gather"
|
||||
# ...and it is the tile that belongs at that index, not a permutation that
|
||||
# happens to agree with an equally permuted oracle.
|
||||
for index, tile in enumerate(produced):
|
||||
assert torch.equal(tile, all_tiles[index])
|
||||
|
||||
|
||||
def test_the_padded_branch_is_actually_exercised(fake_collective):
|
||||
"""9 tiles over 4 ranks: ranks 1-3 are one short, so the padding runs."""
|
||||
sp_size, num_tiles = 4, 9
|
||||
all_tiles = _tiles(num_tiles, seed=9)
|
||||
assert len(_local_tiles(all_tiles, num_tiles, 1, sp_size)) < -(-num_tiles // sp_size)
|
||||
|
||||
vae = _vae(sp_size, sp_rank=1)
|
||||
produced = _run(vae, fake_collective, all_tiles, num_tiles, sp_size, 1)
|
||||
|
||||
sent = fake_collective[-1]["sent"]
|
||||
assert sent.shape[0] == 3
|
||||
assert torch.equal(sent[2], torch.zeros_like(sent[2]))
|
||||
for index, tile in enumerate(produced):
|
||||
assert torch.equal(tile, all_tiles[index])
|
||||
|
||||
|
||||
def test_an_empty_local_share_still_raises(fake_collective):
|
||||
"""A rank with no tiles fails loudly, exactly as the checkpoint does."""
|
||||
vae = _vae(4)
|
||||
with pytest.raises(ValueError, match="empty tasks on sp rank"):
|
||||
vae.model._all_gather_tiled_results([], 8)
|
||||
|
||||
|
||||
def test_more_tiles_than_round_robin_allows_is_refused(fake_collective):
|
||||
"""The unpacking arithmetic is only valid for the checkpoint's ownership."""
|
||||
vae = _vae(4)
|
||||
with pytest.raises(ValueError, match="round-robin"):
|
||||
vae.model._all_gather_tiled_results(_tiles(5), 8)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Ownership: the buffer is overwritten by the next gather, so returned tiles
|
||||
# must not alias it.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_returned_tiles_do_not_alias_the_pinned_buffer(fake_collective):
|
||||
sp_size, num_tiles = 4, 8
|
||||
first_tiles = _tiles(num_tiles, seed=1)
|
||||
vae = _vae(sp_size)
|
||||
produced = _run(vae, fake_collective, first_tiles, num_tiles, sp_size, 0)
|
||||
|
||||
buffer_storage = vae._tile_gather_workspace.untyped_storage().data_ptr()
|
||||
for tile in produced:
|
||||
assert tile.untyped_storage().data_ptr() != buffer_storage
|
||||
|
||||
# The next gather reuses that memory; the first call's tiles must survive it.
|
||||
_run(vae, fake_collective, _tiles(num_tiles, seed=2), num_tiles, sp_size, 0)
|
||||
for index, tile in enumerate(produced):
|
||||
assert torch.equal(tile, first_tiles[index])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Non-XPU behaviour is unchanged: the checkpoint's own method stays installed.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _construct(monkeypatch, device, model):
|
||||
"""Run the real ``__init__`` against a stub checkpoint component."""
|
||||
|
||||
class _Remote(torch.nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.weight = torch.nn.Parameter(torch.zeros(1))
|
||||
self.model = model
|
||||
|
||||
monkeypatch.setattr(
|
||||
vae_module,
|
||||
"_load_component_config",
|
||||
lambda path: {"latent_channels": 1, "latents_mean": [0.0], "latents_std": [1.0]},
|
||||
)
|
||||
# **kwargs: the loader gained a trust_remote_code argument at some point,
|
||||
# and this stub does not care either way.
|
||||
monkeypatch.setattr(vae_module, "_load_remote_component", lambda path, config, **kwargs: _Remote())
|
||||
# The adapter only stores this handle; resolving it for real needs an
|
||||
# accelerator, which a CPU test runner does not have.
|
||||
monkeypatch.setattr(torch, "get_device_module", lambda *args, **kwargs: types.SimpleNamespace())
|
||||
return MiniMaxH3VideoVAE("unused", device=torch.device(device))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("device", ["cpu", "meta"])
|
||||
def test_non_xpu_devices_keep_the_checkpoint_gather(monkeypatch, device):
|
||||
model = _FakeCheckpointModel()
|
||||
original = model._all_gather_tiled_results
|
||||
|
||||
vae = _construct(monkeypatch, device, model)
|
||||
|
||||
assert vae._tile_gather_reuse_enabled() is False
|
||||
assert vae.model._all_gather_tiled_results is original
|
||||
assert vae._checkpoint_tile_gather is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("device", "enabled"),
|
||||
[("xpu", True), ("cpu", False), ("cuda", False), ("meta", False)],
|
||||
)
|
||||
def test_only_xpu_asks_for_the_pinned_buffer(device, enabled):
|
||||
vae = object.__new__(MiniMaxH3VideoVAE)
|
||||
vae._device_target = torch.device(device)
|
||||
assert vae._tile_gather_reuse_enabled() is enabled
|
||||
|
||||
|
||||
def test_construction_installs_the_override_when_the_device_asks_for_it(monkeypatch):
|
||||
"""Close the loop: ``__init__`` must consult the predicate, not ignore it."""
|
||||
monkeypatch.setattr(MiniMaxH3VideoVAE, "_tile_gather_reuse_enabled", lambda self: True)
|
||||
model = _FakeCheckpointModel()
|
||||
original = model._all_gather_tiled_results
|
||||
|
||||
vae = _construct(monkeypatch, "cpu", model)
|
||||
|
||||
assert vae.model._all_gather_tiled_results is not original
|
||||
assert vae._checkpoint_tile_gather is original
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The decision-point receipt carries quantities, not just presence: a line
|
||||
# that only says "installed" cannot tell reuse from reallocation.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_receipt_line_reports_hits_allocs_and_the_address(fake_collective, gather_log):
|
||||
vae = _vae(4)
|
||||
install = [
|
||||
(level, line) for level, line in gather_log.records if "persistent tile gather installed device=xpu" in line
|
||||
]
|
||||
# The one-shot install line is what an operator needs at info level.
|
||||
assert install and all(level == logging.INFO for level, _ in install)
|
||||
gather_log.messages.clear()
|
||||
gather_log.records.clear()
|
||||
|
||||
_run(vae, fake_collective, _tiles(8), 8, 4, 0)
|
||||
first_ptr = fake_collective[-1]["buf_ptr"]
|
||||
_run(vae, fake_collective, _tiles(8, seed=3), 8, 4, 0)
|
||||
|
||||
records = [(level, line) for level, line in gather_log.records if "[H3_VAE_GATHER] reuse=" in line]
|
||||
# The per-gather line fires once per decoder tile batch, so it is debug.
|
||||
assert all(level == logging.DEBUG for level, _ in records)
|
||||
lines = [line for _, line in records]
|
||||
assert len(lines) == 2
|
||||
assert "reuse=alloc" in lines[0] and "hits=0 allocs=1" in lines[0]
|
||||
assert "reuse=hit" in lines[1] and "hits=1 allocs=1" in lines[1]
|
||||
# Resident bytes are reported, not just the bytes this one gather needed.
|
||||
needed = _workspace_bytes(8, 4) / (1024.0 * 1024.0)
|
||||
for line in lines:
|
||||
assert f"need_mib={needed:.2f}" in line
|
||||
assert f"workspace_mib={needed:.2f}" in line
|
||||
assert f"buf_ptr=0x{first_ptr:x}" in lines[0]
|
||||
assert f"buf_ptr=0x{first_ptr:x}" in lines[1]
|
||||
@@ -198,7 +198,7 @@ def test_video_vae_installs_exact_optimizations(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
vae_module,
|
||||
"_load_remote_component",
|
||||
lambda _path, _config: FakeRemote(),
|
||||
lambda _path, _config, **_kwargs: FakeRemote(),
|
||||
)
|
||||
install = Mock(return_value=True)
|
||||
monkeypatch.setattr(vae_module, "install_h3_vae_optimizations", install)
|
||||
|
||||
@@ -635,6 +635,32 @@ def test_i2v_resize_policy_can_defer_to_pipeline(monkeypatch):
|
||||
}
|
||||
|
||||
|
||||
def test_i2v_minimax_h3_preserves_reference_geometry():
|
||||
engine = FakeAsyncOmni()
|
||||
engine.model_class_name = "MiniMaxH3Pipeline"
|
||||
handler = OmniOpenAIServingVideo.for_diffusion(
|
||||
diffusion_engine=engine,
|
||||
model_name="MiniMaxAI/MiniMax-H3",
|
||||
)
|
||||
image = Image.new("RGB", (48, 32))
|
||||
|
||||
asyncio.run(
|
||||
handler._run_and_extract(
|
||||
VideoGenerationRequest(prompt="A bear playing with yarn.", width=96, height=64),
|
||||
"minimax-h3-reference-geometry",
|
||||
reference_image=ReferenceImage(image),
|
||||
)
|
||||
)
|
||||
|
||||
assert engine.captured_prompt is not None
|
||||
assert engine.captured_sampling_params_list is not None
|
||||
input_image = engine.captured_prompt["multi_modal_data"]["image"]
|
||||
assert isinstance(input_image, Image.Image)
|
||||
assert input_image.size == (48, 32)
|
||||
sampling_params = engine.captured_sampling_params_list[0]
|
||||
assert (sampling_params.width, sampling_params.height) == (96, 64)
|
||||
|
||||
|
||||
def test_i2v_extra_params_dimensions_preserve_input_image_geometry(test_client, mocker: MockerFixture):
|
||||
image_bytes = _make_test_image_bytes((48, 48))
|
||||
mocker.patch(
|
||||
|
||||
@@ -290,6 +290,16 @@ def test_reference_image_size_policy_threads_revision(monkeypatch: pytest.Monkey
|
||||
assert captured == {"model": "org/model", "revision": "pinned-revision"}
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("model_class_name", ["MiniMaxH3Pipeline", "MiniMaxH3ModularPipeline"])
|
||||
def test_minimax_h3_preserves_reference_image_size(model_class_name: str) -> None:
|
||||
assert should_preserve_reference_image_size(
|
||||
model_class_name,
|
||||
model="MiniMaxAI/MiniMax-H3",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
def test_ming_flash_omni_extra_registry_declares_request_and_response_params() -> None:
|
||||
|
||||
@@ -215,8 +215,9 @@ class AttentionMetadata:
|
||||
# variable-length query/key sequences for FlashAttention.
|
||||
# "max_seqlen_q" / "max_seqlen_k": maximum sequence lengths paired with
|
||||
# the packed cu_seqlens tensors.
|
||||
# "valid_kv_length": int — contiguous valid K/V prefix length for a
|
||||
# backend that advertises supports_prefix_kv_slicing.
|
||||
# "valid_kv_length": int — contiguous valid K/V prefix length. Local
|
||||
# backends may slice it directly; ring backends trim each circulated
|
||||
# K/V block from the same global prefix.
|
||||
# "npu_attn_varlen": bool — model opt-in for the NPU packed varlen path
|
||||
# (TND npu_fusion_attention driven by cu_seqlens, mask never read).
|
||||
# Requires the [real, pad] two-document packing contract; see
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
# Copyright (c) 2024, Jiarui Fang.
|
||||
# Adapted from https://github.com/feifeibear/long-context-attention
|
||||
|
||||
@@ -9,7 +10,33 @@ import torch.nn.functional as F
|
||||
|
||||
LseLayout = Literal["bhs", "bsh"]
|
||||
|
||||
__all__ = ["update_out_and_lse", "flatten_varlen_lse", "unflatten_varlen_lse"]
|
||||
__all__ = [
|
||||
"ring_kv_block_valid_length",
|
||||
"update_out_and_lse",
|
||||
"flatten_varlen_lse",
|
||||
"unflatten_varlen_lse",
|
||||
]
|
||||
|
||||
|
||||
def ring_kv_block_valid_length(
|
||||
valid_kv_length: int | None,
|
||||
block_size: int,
|
||||
block_rank: int,
|
||||
world_size: int,
|
||||
) -> int:
|
||||
"""Return the valid prefix length in one globally ordered ring K/V block."""
|
||||
if valid_kv_length is None:
|
||||
return block_size
|
||||
if isinstance(valid_kv_length, bool) or not isinstance(valid_kv_length, int):
|
||||
raise ValueError("valid_kv_length must be a Python integer")
|
||||
total_length = block_size * world_size
|
||||
if not 0 < valid_kv_length <= total_length:
|
||||
raise ValueError(
|
||||
"valid_kv_length must be within the global ring K/V sequence, "
|
||||
f"got {valid_kv_length} for length {total_length}"
|
||||
)
|
||||
block_start = block_rank * block_size
|
||||
return max(0, min(block_size, valid_kv_length - block_start))
|
||||
|
||||
|
||||
def _normalize_lse(block_lse: torch.Tensor, block_out: torch.Tensor, lse_layout: LseLayout) -> torch.Tensor:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
# Copyright (c) 2024, Jiarui Fang.
|
||||
# Adapted from https://github.com/feifeibear/long-context-attention
|
||||
|
||||
@@ -6,7 +7,10 @@
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.attention.backends.ring.ring_selector import AttnType, select_flash_attn_impl
|
||||
from vllm_omni.diffusion.attention.backends.ring.ring_utils import update_out_and_lse
|
||||
from vllm_omni.diffusion.attention.backends.ring.ring_utils import (
|
||||
ring_kv_block_valid_length,
|
||||
update_out_and_lse,
|
||||
)
|
||||
from vllm_omni.diffusion.distributed.comm import RingComm
|
||||
|
||||
|
||||
@@ -27,6 +31,7 @@ def ring_flash_attn_forward(
|
||||
joint_tensor_key=None,
|
||||
joint_tensor_value=None,
|
||||
joint_strategy="front",
|
||||
valid_kv_length: int | None = None,
|
||||
):
|
||||
# Validate causal + joint_strategy combination
|
||||
# When causal=True and joint_strategy="rear", the causal mask would incorrectly
|
||||
@@ -42,6 +47,21 @@ def ring_flash_attn_forward(
|
||||
"from seeing the joint tokens."
|
||||
)
|
||||
|
||||
# Trimming the circulated K/V blocks changes their length while the query
|
||||
# block keeps the full padded length. Flash-Attention aligns a causal mask
|
||||
# to the bottom-right of a rectangular (seqlen_q, seqlen_k) tile, so a
|
||||
# trimmed block would shift the diagonal and let queries attend to the
|
||||
# wrong key positions -- silently, with no shape error. The two are
|
||||
# therefore an unsupported combination rather than a slow path: the caller
|
||||
# must either drop the padding before the ring or run non-causal.
|
||||
if causal and valid_kv_length is not None:
|
||||
raise ValueError(
|
||||
"valid_kv_length is not supported with causal=True in Ring Attention. "
|
||||
"Trimming a circulated K/V block shortens seqlen_k while seqlen_q keeps "
|
||||
"the padded length, and the causal diagonal is bottom-right aligned, so "
|
||||
"the mask would silently shift. Unpad before the ring, or use causal=False."
|
||||
)
|
||||
|
||||
comm = RingComm(process_group)
|
||||
|
||||
out = None
|
||||
@@ -66,8 +86,15 @@ def ring_flash_attn_forward(
|
||||
comm.commit()
|
||||
|
||||
if not causal or step <= comm.rank:
|
||||
step_k = k
|
||||
step_v = v
|
||||
block_rank = (comm.rank - step) % comm.world_size
|
||||
block_valid_length = ring_kv_block_valid_length(
|
||||
valid_kv_length,
|
||||
k.shape[1],
|
||||
block_rank,
|
||||
comm.world_size,
|
||||
)
|
||||
step_k = k[:, :block_valid_length]
|
||||
step_v = v[:, :block_valid_length]
|
||||
if step == 0 and joint_tensor_key is not None:
|
||||
if joint_strategy == "front":
|
||||
step_k = torch.cat([joint_tensor_key, step_k], dim=1)
|
||||
@@ -76,27 +103,28 @@ def ring_flash_attn_forward(
|
||||
step_k = torch.cat([step_k, joint_tensor_key], dim=1)
|
||||
step_v = torch.cat([step_v, joint_tensor_value], dim=1)
|
||||
|
||||
fn = select_flash_attn_impl(attn_type, stage="fwd-only", attn_processor=attn_processor)
|
||||
block_out, block_lse = fn(
|
||||
q,
|
||||
step_k,
|
||||
step_v,
|
||||
dropout_p=dropout_p,
|
||||
softmax_scale=softmax_scale,
|
||||
causal=causal and step == 0,
|
||||
window_size=window_size,
|
||||
softcap=softcap,
|
||||
alibi_slopes=alibi_slopes,
|
||||
return_softmax=True and dropout_p > 0,
|
||||
)
|
||||
if step_k.shape[1] > 0:
|
||||
fn = select_flash_attn_impl(attn_type, stage="fwd-only", attn_processor=attn_processor)
|
||||
block_out, block_lse = fn(
|
||||
q,
|
||||
step_k,
|
||||
step_v,
|
||||
dropout_p=dropout_p,
|
||||
softmax_scale=softmax_scale,
|
||||
causal=causal and step == 0,
|
||||
window_size=window_size,
|
||||
softcap=softcap,
|
||||
alibi_slopes=alibi_slopes,
|
||||
return_softmax=True and dropout_p > 0,
|
||||
)
|
||||
|
||||
# Ensure block_out is contiguous if needed, though usually it is from FA
|
||||
# Ensure block_out is contiguous if needed, though usually it is from FA
|
||||
|
||||
if attn_type == AttnType.SPARSE_SAGE:
|
||||
out, lse = block_out, block_lse
|
||||
else:
|
||||
# Ring kernel wrappers canonicalize LSE to (B, H, S).
|
||||
out, lse = update_out_and_lse(out, lse, block_out, block_lse, lse_layout="bhs")
|
||||
if attn_type == AttnType.SPARSE_SAGE:
|
||||
out, lse = block_out, block_lse
|
||||
else:
|
||||
# Ring kernel wrappers canonicalize LSE to (B, H, S).
|
||||
out, lse = update_out_and_lse(out, lse, block_out, block_lse, lse_layout="bhs")
|
||||
|
||||
if step + 1 != comm.world_size:
|
||||
comm.wait()
|
||||
@@ -132,6 +160,7 @@ class RingFlashAttnFunc(torch.autograd.Function):
|
||||
joint_tensor_key=None,
|
||||
joint_tensor_value=None,
|
||||
joint_strategy="front",
|
||||
valid_kv_length: int | None = None,
|
||||
):
|
||||
if softmax_scale is None:
|
||||
softmax_scale = q.shape[-1] ** (-0.5)
|
||||
@@ -158,6 +187,7 @@ class RingFlashAttnFunc(torch.autograd.Function):
|
||||
joint_tensor_key=joint_tensor_key,
|
||||
joint_tensor_value=joint_tensor_value,
|
||||
joint_strategy=joint_strategy,
|
||||
valid_kv_length=valid_kv_length,
|
||||
)
|
||||
return out if not return_softmax else (out, softmax_lse, None)
|
||||
|
||||
@@ -193,6 +223,7 @@ def ring_flash_attn_qkvpacked_func(
|
||||
None, # joint_tensor_key
|
||||
None, # joint_tensor_value
|
||||
"front", # joint_strategy
|
||||
None, # valid_kv_length
|
||||
)
|
||||
|
||||
|
||||
@@ -228,6 +259,7 @@ def ring_flash_attn_kvpacked_func(
|
||||
None, # joint_tensor_key
|
||||
None, # joint_tensor_value
|
||||
"front", # joint_strategy
|
||||
None, # valid_kv_length
|
||||
)
|
||||
|
||||
|
||||
@@ -249,6 +281,7 @@ def ring_flash_attn_func(
|
||||
joint_tensor_key=None,
|
||||
joint_tensor_value=None,
|
||||
joint_strategy="front",
|
||||
valid_kv_length: int | None = None,
|
||||
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor, None]:
|
||||
"""Ring Attention forward pass using Flash Attention backend.
|
||||
|
||||
@@ -290,6 +323,8 @@ def ring_flash_attn_func(
|
||||
Defaults to None.
|
||||
joint_strategy (str): Concatenation strategy ("front" or "back").
|
||||
Defaults to "front".
|
||||
valid_kv_length (int | None): Length of the contiguous valid prefix in
|
||||
the global ring K/V sequence. Defaults to the full sequence.
|
||||
|
||||
Returns:
|
||||
Union[torch.Tensor, Tuple[torch.Tensor, torch.Tensor, None]]:
|
||||
@@ -314,4 +349,5 @@ def ring_flash_attn_func(
|
||||
joint_tensor_key,
|
||||
joint_tensor_value,
|
||||
joint_strategy,
|
||||
valid_kv_length,
|
||||
)
|
||||
@@ -1,4 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
# Copyright (c) 2024, Jiarui Fang.
|
||||
# Adapted from https://github.com/feifeibear/long-context-attention
|
||||
|
||||
@@ -10,7 +11,10 @@ import torch
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.diffusion.attention.backends.ring.ring_kernels import pytorch_attn_forward
|
||||
from vllm_omni.diffusion.attention.backends.ring.ring_utils import update_out_and_lse
|
||||
from vllm_omni.diffusion.attention.backends.ring.ring_utils import (
|
||||
ring_kv_block_valid_length,
|
||||
update_out_and_lse,
|
||||
)
|
||||
from vllm_omni.diffusion.distributed.comm import RingComm
|
||||
|
||||
logger = init_logger(__name__)
|
||||
@@ -33,6 +37,7 @@ def ring_pytorch_attn_func(
|
||||
joint_tensor_key=None,
|
||||
joint_tensor_value=None,
|
||||
joint_strategy="front",
|
||||
valid_kv_length: int | None = None,
|
||||
):
|
||||
return RingAttentionFunc.apply(
|
||||
group,
|
||||
@@ -45,6 +50,7 @@ def ring_pytorch_attn_func(
|
||||
joint_tensor_key,
|
||||
joint_tensor_value,
|
||||
joint_strategy,
|
||||
valid_kv_length,
|
||||
)
|
||||
|
||||
|
||||
@@ -64,6 +70,7 @@ class RingAttentionFunc(torch.autograd.Function):
|
||||
joint_tensor_key=None,
|
||||
joint_tensor_value=None,
|
||||
joint_strategy="front",
|
||||
valid_kv_length: int | None = None,
|
||||
):
|
||||
# Validate causal + joint_strategy combination
|
||||
# When causal=True and joint_strategy="rear", the causal mask would incorrectly
|
||||
@@ -79,6 +86,22 @@ class RingAttentionFunc(torch.autograd.Function):
|
||||
"from seeing the joint tokens."
|
||||
)
|
||||
|
||||
# Trimming the circulated K/V blocks changes their length while the
|
||||
# query block keeps the full padded length. A causal SDPA mask over a
|
||||
# rectangular (seqlen_q, seqlen_k) tile is aligned to the bottom right,
|
||||
# so a trimmed block would shift the diagonal and let queries attend to
|
||||
# the wrong key positions -- silently, with no shape error. The two are
|
||||
# therefore an unsupported combination rather than a slow path: the
|
||||
# caller must either drop the padding before the ring or run
|
||||
# non-causal.
|
||||
if is_causal and valid_kv_length is not None:
|
||||
raise ValueError(
|
||||
"valid_kv_length is not supported with causal=True in Ring Attention. "
|
||||
"Trimming a circulated K/V block shortens seqlen_k while seqlen_q keeps "
|
||||
"the padded length, and the causal diagonal is bottom-right aligned, so "
|
||||
"the mask would silently shift. Unpad before the ring, or use causal=False."
|
||||
)
|
||||
|
||||
comm = RingComm(group)
|
||||
# Ensure tensors are contiguous for P2P communication
|
||||
q = q.contiguous()
|
||||
@@ -98,8 +121,15 @@ class RingAttentionFunc(torch.autograd.Function):
|
||||
comm.commit()
|
||||
|
||||
if not is_causal or step <= comm.rank:
|
||||
step_k = k
|
||||
step_v = v
|
||||
block_rank = (comm.rank - step) % comm.world_size
|
||||
block_valid_length = ring_kv_block_valid_length(
|
||||
valid_kv_length,
|
||||
k.shape[1],
|
||||
block_rank,
|
||||
comm.world_size,
|
||||
)
|
||||
step_k = k[:, :block_valid_length]
|
||||
step_v = v[:, :block_valid_length]
|
||||
if step == 0 and joint_tensor_key is not None:
|
||||
if joint_strategy == "front":
|
||||
step_k = torch.cat([joint_tensor_key, step_k], dim=1)
|
||||
@@ -108,15 +138,16 @@ class RingAttentionFunc(torch.autograd.Function):
|
||||
step_k = torch.cat([step_k, joint_tensor_key], dim=1)
|
||||
step_v = torch.cat([step_v, joint_tensor_value], dim=1)
|
||||
|
||||
block_out, block_lse = pytorch_attn_forward(
|
||||
q,
|
||||
step_k,
|
||||
step_v,
|
||||
softmax_scale=sm_scale,
|
||||
causal=is_causal and step == 0,
|
||||
op_type=op_type,
|
||||
)
|
||||
out, lse = update_out_and_lse(out, lse, block_out, block_lse, lse_layout="bhs")
|
||||
if step_k.shape[1] > 0:
|
||||
block_out, block_lse = pytorch_attn_forward(
|
||||
q,
|
||||
step_k,
|
||||
step_v,
|
||||
softmax_scale=sm_scale,
|
||||
causal=is_causal and step == 0,
|
||||
op_type=op_type,
|
||||
)
|
||||
out, lse = update_out_and_lse(out, lse, block_out, block_lse, lse_layout="bhs")
|
||||
|
||||
if step + 1 != comm.world_size:
|
||||
comm.wait()
|
||||
|
||||
@@ -156,9 +156,11 @@ class RingParallelAttention:
|
||||
# Extract joint tensors
|
||||
joint_key, joint_value = None, None
|
||||
joint_strategy = "front"
|
||||
valid_kv_length = None
|
||||
if attn_metadata is not None:
|
||||
joint_key = attn_metadata.joint_key
|
||||
joint_value = attn_metadata.joint_value
|
||||
valid_kv_length = attn_metadata.extra.get("valid_kv_length")
|
||||
if attn_metadata.joint_strategy is not None:
|
||||
joint_strategy = attn_metadata.joint_strategy
|
||||
|
||||
@@ -176,6 +178,7 @@ class RingParallelAttention:
|
||||
joint_tensor_key=joint_key,
|
||||
joint_tensor_value=joint_value,
|
||||
joint_strategy=joint_strategy,
|
||||
valid_kv_length=valid_kv_length,
|
||||
)
|
||||
|
||||
# Ring only implements local Flash/AITER and SDPA kernels. HuggingFace
|
||||
@@ -261,4 +264,5 @@ class RingParallelAttention:
|
||||
joint_tensor_key=joint_key,
|
||||
joint_tensor_value=joint_value,
|
||||
joint_strategy=joint_strategy,
|
||||
valid_kv_length=valid_kv_length,
|
||||
)
|
||||
@@ -44,6 +44,10 @@ from vllm_omni.diffusion.distributed.sp_plan import (
|
||||
SequenceParallelInput,
|
||||
SequenceParallelOutput,
|
||||
)
|
||||
from vllm_omni.diffusion.forward_context import (
|
||||
get_forward_context,
|
||||
is_forward_context_available,
|
||||
)
|
||||
from vllm_omni.diffusion.layers.activation import SiluAndMul
|
||||
from vllm_omni.diffusion.layers.fused_qk_norm_rope import fused_qk_norm_rope
|
||||
from vllm_omni.diffusion.layers.norm import RMSNorm
|
||||
@@ -67,6 +71,15 @@ logger = init_logger(__name__)
|
||||
# packing, and ``_run_packed_attention`` re-checks it per forward; a name-only
|
||||
# gate would let FLASH_ATTN's NPU/XPU code paths through even though those
|
||||
# variants would silently attend across request boundaries.
|
||||
def _ring_sequence_parallel_is_active(attention_layer: Attention) -> bool:
|
||||
"""Match :meth:`Attention._get_active_parallel_strategy` for Ring."""
|
||||
if not getattr(attention_layer, "use_ring", False) or getattr(attention_layer, "skip_sequence_parallel", False):
|
||||
return False
|
||||
if is_forward_context_available() and not get_forward_context().sp_active:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _attention_isolates_packed_requests(attention_layer: Any) -> bool:
|
||||
"""True if this attention layer keeps N-document packed boundaries.
|
||||
|
||||
@@ -78,7 +91,7 @@ def _attention_isolates_packed_requests(attention_layer: Any) -> bool:
|
||||
backend = getattr(attention_layer, "attn_backend", None)
|
||||
if backend is None or not backend.supports_multi_doc_packed_varlen():
|
||||
return False
|
||||
return not getattr(attention_layer, "use_ring", False)
|
||||
return not _ring_sequence_parallel_is_active(attention_layer)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -508,6 +521,7 @@ class MiniMaxH3Attention(nn.Module):
|
||||
)
|
||||
attn_mask = None
|
||||
mask_free_packed_padding = False
|
||||
use_ring = _ring_sequence_parallel_is_active(self.attention)
|
||||
if num_requests > 1:
|
||||
# A step-mode batch packs one document per request, so its valid
|
||||
# rows are block-diagonal rather than a prefix: neither a KV prefix
|
||||
@@ -534,12 +548,15 @@ class MiniMaxH3Attention(nn.Module):
|
||||
# supports_packed_mask_free: backend consumes the packed metadata
|
||||
# without ever reading attn_mask (CUDA packed varlen, NPU
|
||||
# npu_attn_varlen opt-in with its own fallback rebuild).
|
||||
use_ring = getattr(self.attention, "use_ring", False)
|
||||
mask_free_packed_padding = not use_ring and self.attention.attn_backend.supports_packed_mask_free()
|
||||
no_mask = not use_ring and (
|
||||
self.attention.attn_backend.supports_prefix_kv_slicing or mask_free_packed_padding
|
||||
)
|
||||
if used < packed_total and not no_mask:
|
||||
# Hybrid Ulysses reshards Q to one ring partition before the ring
|
||||
# kernel runs, so a global [packed_total] mask cannot pass its
|
||||
# query-length check. Ring consumes valid_kv_length directly and
|
||||
# trims the circulated K/V blocks instead.
|
||||
if used < packed_total and not no_mask and not use_ring:
|
||||
attn_mask = torch.arange(packed_total, device=q.device)[None] < used
|
||||
metadata = AttentionMetadata(
|
||||
attn_mask=attn_mask,
|
||||
@@ -563,7 +580,7 @@ class MiniMaxH3Attention(nn.Module):
|
||||
# quadratic full_qk mask is never materialized. Ring attention
|
||||
# is excluded: it keeps the aligned padding rows for its
|
||||
# fixed-size P2P buffers and still needs the mask.
|
||||
"npu_attn_varlen": not getattr(self.attention, "use_ring", False),
|
||||
"npu_attn_varlen": not use_ring,
|
||||
# fp16-range protection for the ascend_laser_attention kernel
|
||||
# (see MINIMAX_H3_LASER_INPUT_SCALE). Ignored by every other
|
||||
# backend/path.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""MiniMax H3 packed-sequence materialization for FL2VA and Ref2VA tasks.
|
||||
|
||||
Layout: [text L | imgvid_cond C | audio A(=t*2ch) | video_target V | pad P].
|
||||
@@ -18,6 +19,8 @@ from collections.abc import Mapping, Sequence
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from vllm_omni.errors import OmniClientError
|
||||
|
||||
MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES: tuple[tuple[int, ...], ...] = (
|
||||
(0,),
|
||||
(-1,),
|
||||
@@ -38,13 +41,42 @@ _INTERP = 32
|
||||
_T_GROUP = 5
|
||||
_FRAME_PER_TOKEN = (1, 4, 4, 4, 4)
|
||||
_FRAME_RESCALE = 5.0 / 3.0
|
||||
_SEQ_ALIGN = 64
|
||||
MINIMAX_H3_SEQ_ALIGN = 64
|
||||
"""Row alignment of the packed sequence.
|
||||
|
||||
An explicit ``seq_len`` must be a multiple of this, so that pinning a length
|
||||
lands on a bucket the default rounding could also produce.
|
||||
"""
|
||||
|
||||
MINIMAX_H3_MAX_PAD_SEQ_LEN = 1 << 20
|
||||
"""Ceiling for a request-pinned packed length.
|
||||
|
||||
Every packed row costs a fixed number of bytes across the structural tensors
|
||||
built below, so an unbounded request value would size those allocations. The
|
||||
largest shape this pipeline is documented against -- 672x384 at 209 frames --
|
||||
packs about 16k rows, so this ceiling leaves roughly two orders of magnitude of
|
||||
headroom while keeping the structural tensors in the tens of megabytes.
|
||||
"""
|
||||
# MiniMax H3 packs video latents with a fixed [1, 2, 2] (t, h, w) patch.
|
||||
_PATCH_T = 1
|
||||
_PATCH_H = 2
|
||||
_PATCH_W = 2
|
||||
|
||||
|
||||
def _resolve_padded_seq_len(used: int, seq_len: int | None) -> int:
|
||||
"""Resolve the padded row count for a packed sequence.
|
||||
|
||||
Without an explicit ``seq_len`` the used rows are rounded up to the next
|
||||
:data:`MINIMAX_H3_SEQ_ALIGN` boundary. An explicit value is honored as-is
|
||||
and must cover the used rows.
|
||||
"""
|
||||
if seq_len is None:
|
||||
return ((used + MINIMAX_H3_SEQ_ALIGN - 1) // MINIMAX_H3_SEQ_ALIGN) * MINIMAX_H3_SEQ_ALIGN
|
||||
if seq_len < used:
|
||||
raise OmniClientError(f"seq_len {seq_len} < used rows {used}")
|
||||
return seq_len
|
||||
|
||||
|
||||
def _keyframe_cond_frame_indices(
|
||||
*,
|
||||
include_keyframe_cond: bool,
|
||||
@@ -124,10 +156,15 @@ def minimax_h3_packed_sequence(
|
||||
include_keyframe_cond: bool,
|
||||
keyframe_frame_indices: list[int] | tuple[int, ...] | None = None,
|
||||
frame_count: int | None = None,
|
||||
seq_len: int | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Build the packed-sequence structural fields for one CFG branch.
|
||||
|
||||
The used length is padded up to a multiple of 64.
|
||||
The used length is padded up to a multiple of
|
||||
:data:`MINIMAX_H3_SEQ_ALIGN`, or to an explicit ``seq_len`` that covers the
|
||||
used rows -- the same contract
|
||||
:func:`minimax_h3_packed_sequence_ref2va_blocks` already offers. Pinning
|
||||
the length keeps requests of different prompt lengths on one packed shape.
|
||||
"""
|
||||
ph, pw = latent_h // _PATCH_H, latent_w // _PATCH_W
|
||||
frame_rows = ph * pw
|
||||
@@ -143,7 +180,7 @@ def minimax_h3_packed_sequence(
|
||||
video_rows = latent_t * frame_rows
|
||||
audio_rows = audio_t * audio_channel
|
||||
used = text_len + cond_rows + audio_rows + video_rows
|
||||
seq_len = ((used + _SEQ_ALIGN - 1) // _SEQ_ALIGN) * _SEQ_ALIGN
|
||||
seq_len = _resolve_padded_seq_len(used, seq_len)
|
||||
|
||||
text_sl = slice(0, text_len)
|
||||
cond_sl = slice(text_len, text_len + cond_rows)
|
||||
@@ -376,10 +413,7 @@ def minimax_h3_packed_sequence_ref2va_blocks(
|
||||
audio_rows = audio_t * audio_channel
|
||||
ref_rows = ref_visual_rows + ref_audio_rows
|
||||
used = text_len + ref_rows + audio_rows + video_rows
|
||||
if seq_len is None:
|
||||
seq_len = ((used + _SEQ_ALIGN - 1) // _SEQ_ALIGN) * _SEQ_ALIGN
|
||||
if seq_len < used:
|
||||
raise ValueError(f"seq_len {seq_len} < used rows {used}")
|
||||
seq_len = _resolve_padded_seq_len(used, seq_len)
|
||||
|
||||
text_sl = slice(0, text_len)
|
||||
cursor = text_len
|
||||
|
||||
@@ -137,6 +137,8 @@ from .npu.lora import (
|
||||
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,
|
||||
)
|
||||
@@ -288,6 +290,7 @@ _MINIMAX_H3_DENOISE_INPUT_KEYS = (
|
||||
"visual_condition_shapes",
|
||||
"audio_condition_lengths",
|
||||
"keyframe_frame_indices",
|
||||
"pad_seq_len",
|
||||
)
|
||||
|
||||
# ``StepRequestState.extra`` keys owned by the step-execution path.
|
||||
@@ -543,6 +546,28 @@ def _resolve_minimax_h3_num_outputs(value: Any) -> int:
|
||||
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))]
|
||||
|
||||
@@ -1077,11 +1102,13 @@ class MiniMaxH3Pipeline(
|
||||
os.path.join(model_path, "video_vae"),
|
||||
device=self.device,
|
||||
load_device=component_load_device,
|
||||
trust_remote_code=od_config.trust_remote_code,
|
||||
)
|
||||
self.audio_vae = MiniMaxH3AudioVAE(
|
||||
os.path.join(model_path, "audio_vae"),
|
||||
device=self.device,
|
||||
load_device=component_load_device,
|
||||
trust_remote_code=od_config.trust_remote_code,
|
||||
)
|
||||
# Registry-side VAE patch-parallel discovery uses ``pipeline.vae``.
|
||||
self.vae = self.video_vae
|
||||
@@ -1674,6 +1701,31 @@ class MiniMaxH3Pipeline(
|
||||
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 _encode_visual_conditions(
|
||||
self,
|
||||
images: list[Image.Image],
|
||||
@@ -1932,6 +1984,7 @@ class MiniMaxH3Pipeline(
|
||||
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.
|
||||
|
||||
@@ -1961,6 +2014,7 @@ class MiniMaxH3Pipeline(
|
||||
latent_w=latent_w,
|
||||
audio_t=audio_t,
|
||||
ref_blocks=ref_blocks,
|
||||
seq_len=pad_seq_len,
|
||||
)
|
||||
else:
|
||||
packed = minimax_h3_packed_sequence(
|
||||
@@ -1972,7 +2026,19 @@ class MiniMaxH3Pipeline(
|
||||
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()
|
||||
@@ -2108,6 +2174,7 @@ class MiniMaxH3Pipeline(
|
||||
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,
|
||||
@@ -2131,6 +2198,7 @@ class MiniMaxH3Pipeline(
|
||||
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)
|
||||
@@ -2253,6 +2321,7 @@ class MiniMaxH3Pipeline(
|
||||
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
|
||||
@@ -2561,6 +2630,7 @@ class MiniMaxH3Pipeline(
|
||||
base_schedule, num_steps = self._resolve_sigma_positions(task, sampling)
|
||||
video_shift = float(extra.get("flow_shift", self.default_video_shift))
|
||||
audio_shift = float(extra.get("audio_flow_shift", self.default_audio_shift))
|
||||
pad_seq_len = _resolve_pad_seq_len(extra.get("pad_seq_len"))
|
||||
quality_plan = self._quality_policy.resolve(
|
||||
quality=quality,
|
||||
num_inference_steps=num_steps,
|
||||
@@ -2587,6 +2657,7 @@ class MiniMaxH3Pipeline(
|
||||
"visual_condition_shapes": visual_shapes,
|
||||
"audio_condition_lengths": audio_lengths,
|
||||
"keyframe_frame_indices": keyframe_frame_indices,
|
||||
"pad_seq_len": pad_seq_len,
|
||||
"seed": seed,
|
||||
"num_steps": num_steps,
|
||||
"video_shift": video_shift,
|
||||
@@ -2643,7 +2714,13 @@ class MiniMaxH3Pipeline(
|
||||
height=context["height"],
|
||||
width=context["width"],
|
||||
)
|
||||
videos.append(_prepare_minimax_h3_video_output(video))
|
||||
# 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
|
||||
@@ -2966,7 +3043,7 @@ class MiniMaxH3Pipeline(
|
||||
height=shape["height"],
|
||||
width=shape["width"],
|
||||
)
|
||||
video = _prepare_minimax_h3_video_output(video)
|
||||
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),
|
||||
|
||||
@@ -75,11 +75,23 @@ def _load_component_config(component_path: str) -> dict[str, Any]:
|
||||
def _load_remote_component(
|
||||
component_path: str,
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
trust_remote_code: bool,
|
||||
) -> nn.Module:
|
||||
auto_map = config.get("auto_map") or {}
|
||||
class_reference = auto_map.get("AutoModel")
|
||||
if not isinstance(class_reference, str):
|
||||
raise ValueError(f"{component_path}/config.json must define auto_map.AutoModel")
|
||||
if not trust_remote_code:
|
||||
raise ValueError(
|
||||
f"Loading {component_path} executes the modeling code shipped with "
|
||||
f"the checkpoint (auto_map.AutoModel = {class_reference}). Pass "
|
||||
"--trust-remote-code (or trust_remote_code=True) to allow it."
|
||||
)
|
||||
# ``trust_remote_code`` is checked here rather than forwarded to
|
||||
# ``get_class_from_dynamic_module``: that helper takes no such argument and
|
||||
# would silently absorb it into ``**kwargs``, so forwarding it would read
|
||||
# like a gate while executing the remote code unconditionally.
|
||||
component_cls = get_class_from_dynamic_module(
|
||||
class_reference,
|
||||
component_path,
|
||||
@@ -144,6 +156,7 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
|
||||
*,
|
||||
device: torch.device,
|
||||
load_device: torch.device | None = None,
|
||||
trust_remote_code: bool = False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._device_target = device
|
||||
@@ -151,6 +164,7 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
|
||||
self.remote = _load_remote_component(
|
||||
component_path,
|
||||
self.config_dict,
|
||||
trust_remote_code=trust_remote_code,
|
||||
)
|
||||
# Match the reference loader contract before installing inference-only
|
||||
# decoder fast paths. Keyframe encoding remains FP32; decoder Linear
|
||||
@@ -176,6 +190,11 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
|
||||
self.use_slicing = False
|
||||
self.parallel_size = 1
|
||||
self.device_module = torch.get_device_module()
|
||||
self._tile_gather_workspace: torch.Tensor | None = None
|
||||
self._tile_gather_stats = {"hits": 0, "allocs": 0, "workspace_bytes": 0}
|
||||
self._checkpoint_tile_gather = None
|
||||
if self._tile_gather_reuse_enabled():
|
||||
self._install_persistent_tile_gather()
|
||||
|
||||
def load_to_device(self) -> None:
|
||||
if self._stager is not None:
|
||||
@@ -241,6 +260,145 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
|
||||
parallel_module = importlib.import_module(f"{package}.parallel")
|
||||
return parallel_module.get_parallel_state()
|
||||
|
||||
def _tile_gather_reuse_enabled(self) -> bool:
|
||||
"""Whether this device needs the tiled-VAE gather address kept stable.
|
||||
|
||||
XPU only. The accumulation this guards against is an XCCL-side
|
||||
registration that is kept for every distinct receive-buffer address and
|
||||
never reclaimed; no other backend in tree does that, so everywhere else
|
||||
the checkpoint's own method is left in place and behaviour is unchanged.
|
||||
The stable address comes from one grow-only byte workspace, so what the
|
||||
adapter holds resident is one buffer for the largest geometry it has
|
||||
seen rather than one per geometry.
|
||||
"""
|
||||
|
||||
return self._device_target.type == "xpu"
|
||||
|
||||
def _install_persistent_tile_gather(self) -> None:
|
||||
"""Route the checkpoint's tiled-VAE gather through a bounded workspace.
|
||||
|
||||
The checkpoint's ``_all_gather_tiled_results`` allocates its gather
|
||||
output afresh on every call. Model-level offload calls ``empty_cache()``
|
||||
once per request, so the next request's output lands on a new device
|
||||
address; XCCL registers a non-reclaimable resource per new receive
|
||||
address, and the registrations accumulate until the card is full. The
|
||||
replacement below keeps a single byte workspace alive on this adapter
|
||||
and carves every gather out of its front, so the address the collective
|
||||
writes to is the same one every request.
|
||||
|
||||
The workspace only ever grows: a geometry that fits in the current
|
||||
block reuses it as is, and a larger one replaces the block, which
|
||||
drops the previous allocation before allocating its own. Resident
|
||||
memory is therefore bounded by one buffer for the largest geometry the
|
||||
adapter has seen, not by one buffer per distinct geometry.
|
||||
|
||||
The override is bound on the checkpoint *instance*, not its class: the
|
||||
class is remote code shared by every component loaded from the same
|
||||
checkpoint, and only this adapter knows the device it runs on.
|
||||
"""
|
||||
|
||||
self._checkpoint_tile_gather = self.model._all_gather_tiled_results
|
||||
self.model._all_gather_tiled_results = self._persistent_tile_gather
|
||||
logger.info(
|
||||
"[H3_VAE_GATHER] persistent tile gather installed device=%s",
|
||||
self._device_target.type,
|
||||
)
|
||||
|
||||
def _persistent_tile_gather(
|
||||
self,
|
||||
tasks: list[torch.Tensor],
|
||||
num_tiles: int,
|
||||
) -> list[torch.Tensor]:
|
||||
"""Equal-shape replacement for the checkpoint's tiled-result gather.
|
||||
|
||||
Contract kept identical to the checkpoint's method: return a list of
|
||||
``num_tiles`` tensors in global tile order, and raise on an empty local
|
||||
share so a rank that owns no tile cannot silently skip the collective.
|
||||
|
||||
Tile ownership is round-robin (``range(sp_rank, num_tiles, sp_size)``),
|
||||
so every rank can compute every other rank's task count from
|
||||
``num_tiles`` and ``sp_size`` alone. That makes the per-rank payloads
|
||||
equal once the leading task dimension is padded to ``max_tasks``, which
|
||||
is what lets a single ``all_gather_into_tensor`` into a stable buffer
|
||||
replace the variable-shape gather.
|
||||
|
||||
The landing buffer is a view onto the front of one byte workspace that
|
||||
this adapter keeps alive and only ever grows, so its address stays
|
||||
stable across requests while resident memory stays bounded by the
|
||||
largest geometry seen rather than growing per geometry. A byte
|
||||
workspace is dtype-agnostic, so mixed-precision requests share it too.
|
||||
|
||||
Returned tiles are cloned out of the buffer: the buffer is overwritten
|
||||
by the next call and callers hold the tiles past that point.
|
||||
"""
|
||||
|
||||
state = self._native_parallel_state()
|
||||
group = state["sp_process_group"]
|
||||
sp_size = int(state["sp_size"])
|
||||
sp_rank = int(state["sp_rank"])
|
||||
|
||||
if not tasks:
|
||||
raise ValueError(f"Found empty tasks on sp rank {sp_rank}")
|
||||
|
||||
max_tasks = -(-num_tiles // sp_size)
|
||||
if len(tasks) > max_tasks:
|
||||
raise ValueError(
|
||||
f"sp rank {sp_rank} holds {len(tasks)} tiles but round-robin "
|
||||
f"ownership of {num_tiles} tiles across {sp_size} ranks allows "
|
||||
f"at most {max_tasks}"
|
||||
)
|
||||
if len(tasks) == max_tasks:
|
||||
stacked = torch.stack(tasks, dim=0)
|
||||
else:
|
||||
# Pad the leading (task) dimension only. The padded slots belong to
|
||||
# ranks whose share is short by construction, and the unpacking loop
|
||||
# below never reads them back.
|
||||
stacked = tasks[0].new_empty((max_tasks, *tasks[0].shape))
|
||||
torch.stack(tasks, dim=0, out=stacked[: len(tasks)])
|
||||
stacked[len(tasks) :].zero_()
|
||||
|
||||
need_bytes = sp_size * stacked.numel() * stacked.element_size()
|
||||
workspace = self._tile_gather_workspace
|
||||
if workspace is None or workspace.device != stacked.device or workspace.numel() < need_bytes:
|
||||
# Drop the previous block before allocating its replacement so the
|
||||
# two are never resident at once; only one workspace is ever held.
|
||||
workspace = None
|
||||
self._tile_gather_workspace = None
|
||||
workspace = torch.empty(need_bytes, dtype=torch.uint8, device=stacked.device)
|
||||
self._tile_gather_workspace = workspace
|
||||
self._tile_gather_stats["allocs"] += 1
|
||||
self._tile_gather_stats["workspace_bytes"] = need_bytes
|
||||
reuse = "alloc"
|
||||
else:
|
||||
self._tile_gather_stats["hits"] += 1
|
||||
reuse = "hit"
|
||||
buffer = workspace[:need_bytes].view(stacked.dtype).view(sp_size, *stacked.shape)
|
||||
# Quantities, not just presence: a line that only says "installed"
|
||||
# cannot distinguish a buffer that is being reused from one that is
|
||||
# reallocated every request, which is the whole failure being fixed.
|
||||
# Debug level, because this fires once per decoder tile batch (12 times
|
||||
# per request on the canonical 1344x768 geometry) and the one-shot
|
||||
# "installed" line above is what an operator needs at info level.
|
||||
logger.debug(
|
||||
"[H3_VAE_GATHER] reuse=%s shape=%s/%s need_mib=%.2f workspace_mib=%.2f buf_ptr=0x%x hits=%d allocs=%d",
|
||||
reuse,
|
||||
tuple(stacked.shape),
|
||||
stacked.dtype,
|
||||
need_bytes / (1024.0 * 1024.0),
|
||||
self._tile_gather_stats["workspace_bytes"] / (1024.0 * 1024.0),
|
||||
workspace.data_ptr(),
|
||||
self._tile_gather_stats["hits"],
|
||||
self._tile_gather_stats["allocs"],
|
||||
)
|
||||
dist.all_gather_into_tensor(buffer, stacked, group=group)
|
||||
|
||||
results: list[torch.Tensor] = [None] * num_tiles # type: ignore[list-item]
|
||||
for rank in range(sp_size):
|
||||
num_rank_tasks = -(-(num_tiles - rank) // sp_size)
|
||||
for k in range(num_rank_tasks):
|
||||
results[k * sp_size + rank] = buffer[rank][k].clone()
|
||||
return results
|
||||
|
||||
def _decoder_tile_count(self, latent: torch.Tensor) -> int:
|
||||
"""Number of decoder tiles the checkpoint will split ``latent`` into.
|
||||
|
||||
@@ -464,6 +622,7 @@ class MiniMaxH3AudioVAE(nn.Module):
|
||||
*,
|
||||
device: torch.device,
|
||||
load_device: torch.device | None = None,
|
||||
trust_remote_code: bool = False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._device_target = device
|
||||
@@ -471,6 +630,7 @@ class MiniMaxH3AudioVAE(nn.Module):
|
||||
self.remote = _load_remote_component(
|
||||
component_path,
|
||||
self.config_dict,
|
||||
trust_remote_code=trust_remote_code,
|
||||
)
|
||||
# The checkpoint's audio VAE contract is FP32 for both reference
|
||||
# encoding and waveform decoding.
|
||||
|
||||
@@ -189,6 +189,16 @@ def default_image_to_image_prompt(
|
||||
return result
|
||||
|
||||
|
||||
def _always_preserve_reference_image_size(
|
||||
*,
|
||||
model: str | None,
|
||||
revision: str | None = None,
|
||||
) -> bool:
|
||||
"""Declare that a pipeline owns reference-image geometry."""
|
||||
del model, revision
|
||||
return True
|
||||
|
||||
|
||||
_EXTRA_SPECS: dict[str, dict[str, Any]] = {
|
||||
"BagelPipeline": {
|
||||
"extra_body_params": BAGEL_EXTRA_BODY_PARAMS,
|
||||
@@ -230,6 +240,15 @@ _EXTRA_SPECS: dict[str, dict[str, Any]] = {
|
||||
# Shared T2I/I2V envelopes select the output modality. LingBot's
|
||||
# pipeline owns model-specific validation and normalization.
|
||||
},
|
||||
**{
|
||||
model_class_name: {
|
||||
"reference_image_size_resolver": _always_preserve_reference_image_size,
|
||||
}
|
||||
for model_class_name in (
|
||||
"MiniMaxH3Pipeline",
|
||||
"MiniMaxH3ModularPipeline",
|
||||
)
|
||||
},
|
||||
**{
|
||||
model_class_name: {
|
||||
"extra_body_params": LTX_EXTRA_BODY_PARAMS,
|
||||
|
||||
Reference in new issue
Block a user