[Perf][MiniMax-H3] Cut video VAE encode/decode memory peaks for low-memory serving (#7241)
Signed-off-by: brandneway <gyuan4892@gmail.com> Co-authored-by: WeiQing Chen <40507679+david6666666@users.noreply.github.com>
This commit is contained in:
1 parent
a9abd563f9
commit
b2b6c6c7a9
10 files changed
+1584
-29
No files matched your search
@@ -167,7 +167,7 @@ def test_inventory_matches_reviewed_snapshot_counts():
|
||||
EnvironmentVariableCategory.PUBLIC_OMNI: 28,
|
||||
EnvironmentVariableCategory.INHERITED_VLLM: 20,
|
||||
EnvironmentVariableCategory.PLATFORM_EXTERNAL: 27,
|
||||
EnvironmentVariableCategory.MODEL_SPECIFIC: 65,
|
||||
EnvironmentVariableCategory.MODEL_SPECIFIC: 67,
|
||||
EnvironmentVariableCategory.BENCHMARK_TRANSITIONAL: 20,
|
||||
EnvironmentVariableCategory.INTERNAL: 2,
|
||||
}
|
||||
@@ -182,7 +182,7 @@ def test_inventory_matches_reviewed_snapshot_counts():
|
||||
ModelEnvironmentVariableDisposition.REQUEST_SCOPE: 6,
|
||||
ModelEnvironmentVariableDisposition.EXTERNAL: 0,
|
||||
ModelEnvironmentVariableDisposition.INTERNALIZE: 16,
|
||||
ModelEnvironmentVariableDisposition.DEPRECATE_REMOVE: 5,
|
||||
ModelEnvironmentVariableDisposition.DEPRECATE_REMOVE: 7,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ def test_decode_releases_only_audio_before_return(monkeypatch):
|
||||
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),
|
||||
_release_stage_cache=Mock(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module.current_omni_platform,
|
||||
@@ -152,6 +153,7 @@ def test_forward_releases_video_after_quantization_and_before_next_seed(monkeypa
|
||||
diffuse=diffuse,
|
||||
decode=decode,
|
||||
_offload_model_cpu_stage_output=_recording_release(events, releases),
|
||||
_release_stage_cache=lambda: None,
|
||||
)
|
||||
request = SimpleNamespace(prompts=["a prompt"], sampling_params=SimpleNamespace())
|
||||
|
||||
@@ -201,6 +203,7 @@ def test_forward_drops_the_decoded_video_before_the_next_seed_diffuses(monkeypat
|
||||
diffuse=diffuse,
|
||||
decode=decode,
|
||||
_offload_model_cpu_stage_output=lambda value: value.clone(),
|
||||
_release_stage_cache=lambda: None,
|
||||
)
|
||||
request = SimpleNamespace(prompts=["a prompt"], sampling_params=SimpleNamespace())
|
||||
|
||||
@@ -231,6 +234,7 @@ def test_post_decode_releases_video_after_quantization(monkeypatch):
|
||||
_unpack_denoised_rows=lambda *args, **kwargs: (torch.zeros(1), torch.zeros(1)),
|
||||
decode=decode,
|
||||
_offload_model_cpu_stage_output=_recording_release(events, releases),
|
||||
_release_stage_cache=lambda: None,
|
||||
)
|
||||
state = SimpleNamespace(
|
||||
latents=torch.zeros(1),
|
||||
@@ -529,7 +533,7 @@ def test_h3_model_cpu_offload_shares_embedded_and_standalone_audio_scope(monkeyp
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.audio_vae = Mock()
|
||||
pipeline._model_cpu_offload_modules = [pipeline.audio_vae]
|
||||
events = []
|
||||
events: list[tuple] = []
|
||||
|
||||
@contextmanager
|
||||
def record_component(value):
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""CPU tests for in-place decode revert and stage-boundary cache release.
|
||||
|
||||
``_revert_decoded_inplace`` must be numerically identical to the checkpoint's
|
||||
``revert_tensor`` (denormalize → clamp → rearrange) with zero whole-video
|
||||
copies, and ``_release_stage_cache`` must force the allocator-cache release
|
||||
(or fall back to a plain empty_cache when no component cache exists).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
import vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 as pipeline_module
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3.vae import MiniMaxH3VideoVAE
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
DENORM_MEAN = (-2.11628412, -2.03571429, -1.80444444) # -imagenet_mean/std
|
||||
DENORM_STD = (4.36681223, 4.46428571, 4.44444444) # 1/imagenet_std
|
||||
|
||||
|
||||
class _FakeDenormalize:
|
||||
def __init__(self, mean, std):
|
||||
self.mean = mean
|
||||
self.std = std
|
||||
|
||||
|
||||
class _FakeProcessor:
|
||||
def __init__(self):
|
||||
self.transform_rev = _FakeDenormalize(DENORM_MEAN, DENORM_STD)
|
||||
self.use_3d_conv = True
|
||||
self.revert_calls = 0
|
||||
|
||||
def revert_tensor(self, tensor):
|
||||
self.revert_calls += 1
|
||||
return _legacy_revert(tensor)
|
||||
|
||||
|
||||
class _FakeCheckpointModel:
|
||||
def __init__(self):
|
||||
self.processor = _FakeProcessor()
|
||||
|
||||
|
||||
def _vae(model=None):
|
||||
vae = object.__new__(MiniMaxH3VideoVAE)
|
||||
vae.model = model if model is not None else _FakeCheckpointModel()
|
||||
return vae
|
||||
|
||||
|
||||
def _legacy_revert(tensor: torch.Tensor) -> torch.Tensor:
|
||||
"""The checkpoint's revert_tensor, op for op, on the CPU (B == 1)."""
|
||||
if tensor.ndim == 4:
|
||||
tensor = tensor.unsqueeze(2)
|
||||
assert tensor.ndim == 5
|
||||
# Normalize runs on the (b t) c h w rearrangement; at B == 1 that is a
|
||||
# view of (B, C, T, H, W), so the per-channel broadcast is equivalent.
|
||||
mean = torch.tensor(DENORM_MEAN).view(1, 3, 1, 1, 1)
|
||||
std = torch.tensor(DENORM_STD).view(1, 3, 1, 1, 1)
|
||||
tensor = (tensor - mean) / std
|
||||
return tensor.clamp(0, 1)
|
||||
|
||||
|
||||
def test_revert_inplace_matches_legacy_bitwise():
|
||||
decoded = torch.randn(1, 3, 7, 6, 8).clamp_(-3, 3)
|
||||
expected = _legacy_revert(decoded.clone())
|
||||
out = _vae()._revert_decoded_inplace(decoded.clone())
|
||||
assert out.shape == expected.shape == (1, 3, 7, 6, 8)
|
||||
assert torch.equal(out, expected)
|
||||
|
||||
|
||||
def test_revert_inplace_mutates_without_extra_copies():
|
||||
decoded = torch.randn(1, 3, 5, 4, 4)
|
||||
out = _vae()._revert_decoded_inplace(decoded)
|
||||
assert out is decoded # in-place: no whole-video copy materializes
|
||||
assert out.is_contiguous()
|
||||
|
||||
|
||||
def test_revert_inplace_unsqueezes_4d_input():
|
||||
# 4-D decoded tensors follow the (B, C, H, W) image contract: unsqueeze(2)
|
||||
# lands T=1, matching the checkpoint's revert_tensor branch.
|
||||
decoded = torch.randn(1, 3, 6, 8)
|
||||
expected = _legacy_revert(decoded.clone())
|
||||
out = _vae()._revert_decoded_inplace(decoded.clone())
|
||||
assert torch.equal(out, expected)
|
||||
|
||||
|
||||
def test_revert_inplace_falls_back_without_contract():
|
||||
vae = _vae()
|
||||
vae.model.processor.transform_rev = type("Empty", (), {})()
|
||||
decoded = torch.randn(1, 3, 5, 4, 4)
|
||||
out = vae._revert_decoded_inplace(decoded)
|
||||
assert vae.model.processor.revert_calls == 1
|
||||
assert torch.equal(out, _legacy_revert(decoded))
|
||||
|
||||
|
||||
def test_revert_inplace_falls_back_on_bad_arity():
|
||||
vae = _vae()
|
||||
vae.model.processor.transform_rev = _FakeDenormalize((0.5, 0.5), (1.0, 1.0, 1.0))
|
||||
vae._revert_decoded_inplace(torch.randn(1, 3, 5, 4, 4))
|
||||
assert vae.model.processor.revert_calls == 1
|
||||
|
||||
|
||||
class _RecordingCache:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def release_if_needed(self, *, force=False):
|
||||
self.calls.append(force)
|
||||
return True
|
||||
|
||||
|
||||
def _pipeline():
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
pipeline._dlo_component_cache = None
|
||||
return pipeline
|
||||
|
||||
|
||||
def test_stage_cache_release_forces_component_cache():
|
||||
pipeline = _pipeline()
|
||||
cache = _RecordingCache()
|
||||
pipeline._dlo_component_cache = cache
|
||||
pipeline._release_stage_cache()
|
||||
assert cache.calls == [True]
|
||||
|
||||
|
||||
def test_stage_cache_release_falls_back_to_empty_cache(monkeypatch):
|
||||
released = []
|
||||
monkeypatch.setattr(
|
||||
pipeline_module.current_omni_platform,
|
||||
"empty_cache",
|
||||
lambda: released.append(True),
|
||||
raising=True,
|
||||
)
|
||||
pipeline = _pipeline()
|
||||
pipeline._release_stage_cache()
|
||||
assert released == [True]
|
||||
@@ -0,0 +1,243 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""CPU tests for the streaming uint8 preparation in the MiniMax-H3 video VAE.
|
||||
|
||||
``_stream_prepare_video_tensor`` must be numerically identical to the
|
||||
checkpoint's whole-video numpy path (``convert_numpy_to_tensor`` →
|
||||
``transform_tensor`` → ``transpose``) while padding on the host, so the
|
||||
device-side copies never materialize.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3.vae import MiniMaxH3VideoVAE
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
IMAGENET_MEAN = (0.485, 0.456, 0.406)
|
||||
IMAGENET_STD = (0.229, 0.224, 0.225)
|
||||
CLIP_LENGTH = 4
|
||||
|
||||
|
||||
class _FakeNormalize:
|
||||
def __init__(self, mean, std):
|
||||
self.mean = mean
|
||||
self.std = std
|
||||
|
||||
|
||||
class _FakeProcessor:
|
||||
# Read by the stream prep via getattr(..., False); annotation only so
|
||||
# tests can attach it per case.
|
||||
isolated_last_frame: bool
|
||||
|
||||
def __init__(self):
|
||||
self.transform = _FakeNormalize(IMAGENET_MEAN, IMAGENET_STD)
|
||||
|
||||
|
||||
class _FakeCheckpointModel:
|
||||
def __init__(self, **overrides):
|
||||
self.clip_length = CLIP_LENGTH
|
||||
self.isolated_first_frame = False
|
||||
self.frame_pre_padding = 0
|
||||
self.processor = _FakeProcessor()
|
||||
for key, value in overrides.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
|
||||
def _vae(model=None):
|
||||
vae = object.__new__(MiniMaxH3VideoVAE)
|
||||
vae.model = model if model is not None else _FakeCheckpointModel()
|
||||
return vae
|
||||
|
||||
|
||||
def _legacy_reference(frames: np.ndarray) -> torch.Tensor:
|
||||
"""The checkpoint's numpy path, op for op, on the CPU.
|
||||
|
||||
convert_numpy_to_tensor: astype(fp32) → permute → ÷255
|
||||
transform_tensor: Normalize = (x - mean) / std
|
||||
encode_videos: transpose(0, 1)
|
||||
"""
|
||||
tensor = torch.from_numpy(frames.astype(np.float32)).permute(0, 3, 1, 2)
|
||||
tensor = tensor / 255.0
|
||||
mean = torch.tensor(IMAGENET_MEAN).view(1, 3, 1, 1)
|
||||
std = torch.tensor(IMAGENET_STD).view(1, 3, 1, 1)
|
||||
tensor = (tensor - mean) / std
|
||||
return tensor.transpose(0, 1).contiguous()
|
||||
|
||||
|
||||
def _video(num_frames, height=6, width=8, seed=0):
|
||||
rng = np.random.default_rng(seed)
|
||||
return rng.integers(0, 256, size=(num_frames, height, width, 3), dtype=np.uint8)
|
||||
|
||||
|
||||
def _pad_frames(frames: np.ndarray, pad: int) -> np.ndarray:
|
||||
if not pad:
|
||||
return frames
|
||||
return np.concatenate([frames, np.repeat(frames[-1:], pad, axis=0)])
|
||||
|
||||
|
||||
def test_stream_prep_matches_legacy_path_bitwise():
|
||||
frames = _video(10)
|
||||
out = _vae()._stream_prepare_video_tensor(frames, torch.device("cpu"))
|
||||
assert out is not None
|
||||
assert out.shape == (3, 12, 6, 8) # pad = (0 - 10) % 4 == 2
|
||||
assert out.dtype == torch.float32
|
||||
assert torch.equal(out, _legacy_reference(_pad_frames(frames, 2)))
|
||||
|
||||
|
||||
def test_stream_prep_isolated_first_frame_without_last_frame_skips_pad():
|
||||
# Asymmetric checkpoint (isolated first frame, no isolated last frame):
|
||||
# trim-stable lengths are k*clip + 0, which never satisfy the
|
||||
# encode_temporal offset of 1, so the remote re-pads through its own
|
||||
# whole-video cat regardless. Padding here would only upload frames the
|
||||
# remote trim discards, so the legacy frame count is kept.
|
||||
model = _FakeCheckpointModel(isolated_first_frame=True)
|
||||
frames = _video(10)
|
||||
out = _vae(model)._stream_prepare_video_tensor(frames, torch.device("cpu"))
|
||||
assert out is not None
|
||||
assert out.shape[1] == 10
|
||||
assert torch.equal(out, _legacy_reference(frames))
|
||||
|
||||
|
||||
def test_stream_prep_symmetric_isolated_frames_pad_to_trim_stable():
|
||||
# isolated first + last frames: offset == tail % clip == 1, so padding
|
||||
# to the next trim-stable length makes the remote trim a no-op and keeps
|
||||
# encode_temporal from re-padding: T=10, clip=4, tail=1 → 3*4+1 == 13.
|
||||
model = _FakeCheckpointModel(isolated_first_frame=True)
|
||||
model.processor.isolated_last_frame = True
|
||||
frames = _video(10)
|
||||
out = _vae(model)._stream_prepare_video_tensor(frames, torch.device("cpu"))
|
||||
assert out is not None
|
||||
assert out.shape[1] == 13
|
||||
assert out.shape[1] % 4 == 1
|
||||
assert torch.equal(out, _legacy_reference(_pad_frames(frames, 3)))
|
||||
|
||||
|
||||
def test_stream_prep_uses_processor_align_when_available():
|
||||
calls = []
|
||||
|
||||
class _AlignedProcessor(_FakeProcessor):
|
||||
def align_video_length(self, video_length, mode="pad", granularity="chunk"):
|
||||
calls.append((video_length, mode, granularity))
|
||||
return 5
|
||||
|
||||
model = _FakeCheckpointModel()
|
||||
model.processor = _AlignedProcessor()
|
||||
frames = _video(10)
|
||||
out = _vae(model)._stream_prepare_video_tensor(frames, torch.device("cpu"))
|
||||
assert out is not None
|
||||
assert calls == [(10, "pad", "chunk")]
|
||||
assert out.shape[1] == 15
|
||||
assert torch.equal(out, _legacy_reference(_pad_frames(frames, 5)))
|
||||
|
||||
|
||||
def test_stream_prep_pads_with_last_frame():
|
||||
frames = _video(2) # pad = (0 - 2) % 4 == 2
|
||||
out = _vae()._stream_prepare_video_tensor(frames, torch.device("cpu"))
|
||||
padded = np.concatenate([frames, np.repeat(frames[-1:], 2, axis=0)])
|
||||
assert torch.equal(out, _legacy_reference(padded))
|
||||
|
||||
|
||||
def test_stream_prep_aligned_input_needs_no_pad():
|
||||
frames = _video(8) # 8 % 4 == 0
|
||||
out = _vae()._stream_prepare_video_tensor(frames, torch.device("cpu"))
|
||||
assert out.shape[1] == 8
|
||||
assert torch.equal(out, _legacy_reference(frames))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"frames",
|
||||
[
|
||||
_video(5).astype(np.float32), # not uint8
|
||||
_video(5)[:, :, :, :1], # single channel
|
||||
np.zeros((5, 6, 8, 4), dtype=np.uint8), # four channels
|
||||
np.zeros((5, 6, 8), dtype=np.uint8), # missing channel axis
|
||||
np.zeros((0, 6, 8, 3), dtype=np.uint8), # empty
|
||||
],
|
||||
)
|
||||
def test_stream_prep_rejects_unsupported_inputs(frames):
|
||||
assert _vae()._stream_prepare_video_tensor(frames, torch.device("cpu")) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
_FakeCheckpointModel(clip_length=None),
|
||||
_FakeCheckpointModel(clip_length=0),
|
||||
type("NoProcessor", (), {"clip_length": CLIP_LENGTH})(),
|
||||
type(
|
||||
"NoTransform",
|
||||
(),
|
||||
{"clip_length": CLIP_LENGTH, "processor": type("P", (), {})()},
|
||||
)(),
|
||||
],
|
||||
)
|
||||
def test_stream_prep_rejects_missing_checkpoint_contract(model):
|
||||
frames = _video(5)
|
||||
assert _vae(model)._stream_prepare_video_tensor(frames, torch.device("cpu")) is None
|
||||
|
||||
|
||||
def test_stream_prep_rejects_bad_norm_arity():
|
||||
model = _FakeCheckpointModel()
|
||||
model.processor.transform = _FakeNormalize((0.5, 0.5), IMAGENET_STD)
|
||||
assert _vae(model)._stream_prepare_video_tensor(_video(5), torch.device("cpu")) is None
|
||||
|
||||
|
||||
class _RecordingModel(_FakeCheckpointModel):
|
||||
"""Feeds encode_video a well-shaped latent and records its input."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.received = None
|
||||
self.latent = torch.randn(1, 4, 3, 4, 4) # (B, C, T', H', W'), even H/W
|
||||
|
||||
def encode_videos(self, videos, use_fp16_latent=False, **kwargs):
|
||||
self.received = videos
|
||||
assert use_fp16_latent is True
|
||||
return [self.latent]
|
||||
|
||||
|
||||
def _vae_for_encode_video():
|
||||
vae = object.__new__(MiniMaxH3VideoVAE)
|
||||
vae.model = _RecordingModel()
|
||||
vae.device_module = torch
|
||||
vae.config_dict = {
|
||||
"latent_channels": 4,
|
||||
"latents_mean": [0.0] * 4,
|
||||
"latents_std": [1.0] * 4,
|
||||
}
|
||||
# A plain tensor (not nn.Parameter): nn.Module.__setattr__ refuses
|
||||
# Parameters on instances built via object.__new__.
|
||||
vae._dummy_parameter = torch.zeros(1, dtype=torch.float32)
|
||||
vae.parameters = lambda: iter([vae._dummy_parameter])
|
||||
return vae
|
||||
|
||||
|
||||
def test_encode_video_streams_uint8_numpy(monkeypatch):
|
||||
monkeypatch.delenv("VLLM_OMNI_VAE_ENCODE_LEGACY_PREP", raising=False)
|
||||
vae = _vae_for_encode_video()
|
||||
rows, shape = vae.encode_video(_video(10))
|
||||
assert isinstance(vae.model.received, list)
|
||||
assert isinstance(vae.model.received[0], torch.Tensor)
|
||||
assert vae.model.received[0].shape == (3, 12, 6, 8)
|
||||
assert rows.shape[0] == shape[0] * shape[1] * shape[2] // 4 # patchify rows
|
||||
|
||||
|
||||
def test_encode_video_legacy_env_escapes_to_checkpoint_path(monkeypatch):
|
||||
monkeypatch.setenv("VLLM_OMNI_VAE_ENCODE_LEGACY_PREP", "1")
|
||||
vae = _vae_for_encode_video()
|
||||
frames = _video(10)
|
||||
vae.encode_video(frames)
|
||||
assert vae.model.received is frames # untouched numpy, checkpoint converts
|
||||
|
||||
|
||||
def test_encode_video_falls_back_when_preparation_unsupported(monkeypatch):
|
||||
monkeypatch.delenv("VLLM_OMNI_VAE_ENCODE_LEGACY_PREP", raising=False)
|
||||
vae = _vae_for_encode_video()
|
||||
vae.model.clip_length = None # contract not discoverable
|
||||
frames = _video(10)
|
||||
vae.encode_video(frames)
|
||||
assert vae.model.received is frames
|
||||
@@ -0,0 +1,218 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""CPU tests for the split encoder/decoder residency of the MiniMax-H3 VAE.
|
||||
|
||||
The encode half (CNN encoder + quant_conv) and the decode half
|
||||
(post_quant_conv + ViT decoder) stage independently: loading one half must
|
||||
rebind only its own parameters while the other half stays on its pinned
|
||||
CPU master.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3.vae import (
|
||||
MiniMaxH3VideoVAE,
|
||||
_VideoVAEPartProxy,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
class _FakeRemoteModel(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.encoder = nn.Linear(8, 8)
|
||||
self.quant_conv = nn.Linear(4, 4)
|
||||
self.post_quant_conv = nn.Linear(4, 4)
|
||||
self.decoder = nn.Linear(16, 16)
|
||||
|
||||
|
||||
class _FakeRemote(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.model = _FakeRemoteModel()
|
||||
|
||||
|
||||
def _vae() -> MiniMaxH3VideoVAE:
|
||||
vae = object.__new__(MiniMaxH3VideoVAE)
|
||||
nn.Module.__init__(vae) # bare instance: enable Module attribute assignment
|
||||
vae._device_target = torch.device("cpu")
|
||||
vae.remote = _FakeRemote()
|
||||
vae.model = vae.remote.model
|
||||
vae._stager = None
|
||||
vae._encoder_stager = None
|
||||
vae._decoder_stager = None
|
||||
vae._build_residency_stagers(torch.device("cpu"))
|
||||
vae.encoder_component = _VideoVAEPartProxy(vae, "encoder")
|
||||
vae.decoder_component = _VideoVAEPartProxy(vae, "decoder")
|
||||
return vae
|
||||
|
||||
|
||||
def _ptr(param: torch.nn.Parameter) -> int:
|
||||
return param.untyped_storage().data_ptr()
|
||||
|
||||
|
||||
def test_split_stagers_snapshot_disjoint_halves():
|
||||
vae = _vae()
|
||||
enc_master = _ptr(vae.model.encoder.weight)
|
||||
dec_master = _ptr(vae.model.decoder.weight)
|
||||
pqc_master = _ptr(vae.model.post_quant_conv.weight)
|
||||
assert enc_master != dec_master
|
||||
|
||||
vae._load_part_to_device("encoder")
|
||||
# The encoder half is rebound to freshly allocated staging storage...
|
||||
assert _ptr(vae.model.encoder.weight) != enc_master
|
||||
assert _ptr(vae.model.quant_conv.weight) != enc_master
|
||||
# ...while every decode-half tensor stays on its own CPU master.
|
||||
assert _ptr(vae.model.decoder.weight) == dec_master
|
||||
assert _ptr(vae.model.post_quant_conv.weight) == pqc_master
|
||||
|
||||
vae._offload_part_to_cpu("encoder")
|
||||
assert _ptr(vae.model.encoder.weight) == enc_master
|
||||
|
||||
|
||||
def test_part_load_is_idempotent_and_independent():
|
||||
vae = _vae()
|
||||
vae._load_part_to_device("decoder")
|
||||
staged_ptr = _ptr(vae.model.decoder.weight)
|
||||
vae._load_part_to_device("decoder")
|
||||
assert _ptr(vae.model.decoder.weight) == staged_ptr
|
||||
vae._offload_part_to_cpu("decoder")
|
||||
|
||||
|
||||
def test_whole_component_load_and_offload_cover_both_halves():
|
||||
vae = _vae()
|
||||
enc_master = _ptr(vae.model.encoder.weight)
|
||||
dec_master = _ptr(vae.model.decoder.weight)
|
||||
vae.load_to_device()
|
||||
assert _ptr(vae.model.encoder.weight) != enc_master
|
||||
assert _ptr(vae.model.decoder.weight) != dec_master
|
||||
vae.offload_to_cpu()
|
||||
assert _ptr(vae.model.encoder.weight) == enc_master
|
||||
assert _ptr(vae.model.decoder.weight) == dec_master
|
||||
|
||||
|
||||
def test_part_values_survive_a_load_offload_cycle():
|
||||
vae = _vae()
|
||||
expected = vae.model.encoder.weight.detach().clone()
|
||||
vae._load_part_to_device("encoder")
|
||||
assert torch.equal(vae.model.encoder.weight.detach(), expected)
|
||||
vae._offload_part_to_cpu("encoder")
|
||||
assert torch.equal(vae.model.encoder.weight.detach(), expected)
|
||||
|
||||
|
||||
def test_proxy_routes_to_the_matching_half():
|
||||
vae = _vae()
|
||||
enc_master = _ptr(vae.model.encoder.weight)
|
||||
dec_master = _ptr(vae.model.decoder.weight)
|
||||
vae.encoder_component.load_to_device()
|
||||
assert _ptr(vae.model.encoder.weight) != enc_master
|
||||
assert _ptr(vae.model.decoder.weight) == dec_master
|
||||
vae.encoder_component.offload_to_cpu()
|
||||
assert _ptr(vae.model.encoder.weight) == enc_master
|
||||
|
||||
|
||||
def test_proxy_does_not_register_the_adapter_as_submodule():
|
||||
vae = _vae()
|
||||
proxy = vae.encoder_component
|
||||
assert proxy._vae is vae
|
||||
# The back-reference must not recurse through module traversal.
|
||||
assert all(child is not vae for child in proxy.children())
|
||||
assert len(list(proxy.parameters())) == 0
|
||||
|
||||
|
||||
def test_component_on_device_unwraps_proxy_for_sequential_offload(monkeypatch):
|
||||
"""Model-level CPU offload hooks the real video_vae, never the part
|
||||
proxies: _component_on_device must hand the hooked module to the
|
||||
sequential context, or _get_sequential_offload_hook raises on the
|
||||
hook-less proxy on the first encode/decode of every request."""
|
||||
from contextlib import contextmanager
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as pipeline_module
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
vae = _vae()
|
||||
seen: list[nn.Module] = []
|
||||
|
||||
@contextmanager
|
||||
def fake_sequential_offload_component(component):
|
||||
seen.append(component)
|
||||
yield
|
||||
|
||||
monkeypatch.setattr(pipeline_module, "sequential_offload_component", fake_sequential_offload_component)
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
nn.Module.__init__(pipeline)
|
||||
pipeline._model_cpu_offload_modules = [vae]
|
||||
|
||||
with pipeline._component_on_device(vae.encoder_component):
|
||||
pass
|
||||
with pipeline._component_on_device(vae.decoder_component):
|
||||
pass
|
||||
# A plain component passes through unchanged.
|
||||
with pipeline._component_on_device(vae):
|
||||
pass
|
||||
# Mocks auto-create any attribute, so the unwrap must be a type check:
|
||||
# a Mock component must reach the sequential context as itself.
|
||||
mock_vae = Mock()
|
||||
pipeline._model_cpu_offload_modules = [mock_vae]
|
||||
with pipeline._component_on_device(mock_vae):
|
||||
pass
|
||||
|
||||
assert seen == [vae, vae, vae, mock_vae]
|
||||
|
||||
|
||||
def test_sequential_offload_target_is_the_hooked_module():
|
||||
from vllm_omni.diffusion.offloader.sequential_backend import _get_sequential_offload_hook
|
||||
|
||||
vae = _vae()
|
||||
proxy = vae.encoder_component
|
||||
# The pre-fix failure mode: the proxy carries no hook registry.
|
||||
with pytest.raises(RuntimeError, match="no sequential offload hook"):
|
||||
_get_sequential_offload_hook(proxy)
|
||||
assert proxy.sequential_offload_target is vae
|
||||
|
||||
|
||||
def test_cache_retention_forwarded_to_both_stagers():
|
||||
vae = _vae()
|
||||
cache = object()
|
||||
vae.set_omni_component_cache(cache)
|
||||
assert vae._encoder_stager.cache_retention is cache
|
||||
assert vae._decoder_stager.cache_retention is cache
|
||||
|
||||
|
||||
def test_unknown_part_raises():
|
||||
vae = _vae()
|
||||
with pytest.raises(ValueError, match="unknown video VAE part"):
|
||||
vae._part_stager("middle")
|
||||
|
||||
|
||||
def test_missing_checkpoint_structure_falls_back_to_whole_stager():
|
||||
vae = object.__new__(MiniMaxH3VideoVAE)
|
||||
nn.Module.__init__(vae)
|
||||
vae._device_target = torch.device("cpu")
|
||||
|
||||
class BareRemote(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.model = nn.Linear(4, 4) # no encoder/decoder submodules
|
||||
|
||||
vae.remote = BareRemote()
|
||||
vae.model = vae.remote.model
|
||||
vae._stager = None
|
||||
vae._encoder_stager = None
|
||||
vae._decoder_stager = None
|
||||
vae._build_residency_stagers(torch.device("cpu"))
|
||||
assert vae._stager is not None
|
||||
assert vae._encoder_stager is None
|
||||
assert vae._decoder_stager is None
|
||||
# Part loads fall back to the whole-module stager.
|
||||
vae._load_part_to_device("decoder")
|
||||
assert vae._stager.loaded
|
||||
vae._offload_part_to_cpu("decoder")
|
||||
assert not vae._stager.loaded
|
||||
@@ -0,0 +1,335 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""CPU tests for the temporal VAE forks and the uint8 output transfer.
|
||||
|
||||
The encode fork must feed bit-identical frames to the checkpoint's chunk
|
||||
loop (padding moves from a whole-video cat into the tail chunk), the decode
|
||||
fork must produce the same video as the legacy fp32-buffer chain
|
||||
(revert -> clamp -> *255 -> round -> uint8), and the output transfer must
|
||||
match the previous ``.to(uint8, contiguous)`` conversion.
|
||||
"""
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
|
||||
_prepare_minimax_h3_video_output,
|
||||
)
|
||||
from vllm_omni.diffusion.models.minimax_h3.vae_temporal import (
|
||||
_decode_temporal_streaming_uint8,
|
||||
_encode_temporal_tail_pad,
|
||||
install_temporal_stream_patches,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
CLIP = 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# encode_temporal fork
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _EncodeModel:
|
||||
"""Minimal stand-in implementing the checkpoint's encode_temporal."""
|
||||
|
||||
# Attached per test case as capability probes for the install checks;
|
||||
# annotations only, the fork must keep working when they are absent.
|
||||
encode_temporal: Any
|
||||
_decode_temporal_streaming: Any
|
||||
processor: Any
|
||||
|
||||
def __init__(self, *, isolated_first=False, isolated_last=False, key_frame=False, frame_drop=0):
|
||||
self.clip_length = CLIP
|
||||
self.isolated_first_frame = isolated_first
|
||||
self.frame_pre_padding = 0
|
||||
self.isolated_key_frame = key_frame
|
||||
self.token_drop = 0
|
||||
self.isolated_last_frame = isolated_last
|
||||
self.frame_drop = frame_drop
|
||||
self.encoded = []
|
||||
|
||||
def _adaptive_encode(self, clip):
|
||||
self.encoded.append(clip.clone())
|
||||
return clip # identity: z_list entries are the clips themselves
|
||||
|
||||
def reference_encode_temporal(self, x):
|
||||
"""The checkpoint's original: whole-video pad, then chunk."""
|
||||
offset_frame = 1 if self.isolated_first_frame and self.frame_pre_padding == 0 else 0
|
||||
if x.shape[2] % self.clip_length != offset_frame:
|
||||
pad_size = (offset_frame - x.shape[2]) % self.clip_length
|
||||
pad_frames = x[:, :, -1:].repeat(1, 1, pad_size, 1, 1)
|
||||
x = torch.cat([x, pad_frames], dim=2)
|
||||
num_chunks = (x.shape[2] - offset_frame) // self.clip_length
|
||||
z_list = []
|
||||
for i in range(num_chunks):
|
||||
start_idx = i * self.clip_length + offset_frame
|
||||
end_idx = (i + 1) * self.clip_length + offset_frame
|
||||
clip_x = x[:, :, start_idx:end_idx, :, :]
|
||||
if self.isolated_key_frame:
|
||||
key_frame = clip_x[:, :, :1, :, :]
|
||||
z_key = self._adaptive_encode(key_frame)
|
||||
if clip_x.shape[2] > 1:
|
||||
z_video = self._adaptive_encode(clip_x[:, :, 1:, :, :])
|
||||
z = torch.cat([z_key, z_video], dim=2)
|
||||
else:
|
||||
z = z_key
|
||||
else:
|
||||
z = self._adaptive_encode(clip_x)
|
||||
z_list.append(z)
|
||||
z = torch.cat(z_list, dim=2)
|
||||
if self.token_drop > 0:
|
||||
z = z[:, :, : -self.token_drop]
|
||||
if self.isolated_first_frame:
|
||||
z_first = self._adaptive_encode(x[:, :, :1, :, :])
|
||||
z = torch.cat([z_first, z], dim=2)
|
||||
if self.isolated_last_frame:
|
||||
frame_num = x.shape[2]
|
||||
last_idx = frame_num - self.frame_drop + offset_frame
|
||||
z_last = self._adaptive_encode(x[:, :, last_idx : last_idx + 1, :, :])
|
||||
z = torch.cat([z, z_last], dim=2)
|
||||
return z
|
||||
|
||||
|
||||
def _video_x(t, c=3, h=4, w=4):
|
||||
return torch.arange(t * c * h * w, dtype=torch.float32).reshape(1, c, t, h, w) / 255.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_frames", [8, 10, 11, 13])
|
||||
@pytest.mark.parametrize(
|
||||
"flags",
|
||||
[
|
||||
{},
|
||||
{"isolated_first": True},
|
||||
{"isolated_last": True},
|
||||
{"isolated_first": True, "isolated_last": True},
|
||||
{"key_frame": True},
|
||||
],
|
||||
)
|
||||
def test_encode_fork_matches_whole_video_pad_bitwise(num_frames, flags):
|
||||
x = _video_x(num_frames)
|
||||
ref_model = _EncodeModel(**flags)
|
||||
expected = ref_model.reference_encode_temporal(x.clone())
|
||||
# Empty-slice encodes (out-of-range isolated-last indexes are no-ops in
|
||||
# the checkpoint) carry no content; compare only real calls.
|
||||
ref_calls = [c.clone() for c in ref_model.encoded if c.shape[2] > 0]
|
||||
|
||||
fork_model = _EncodeModel(**flags)
|
||||
out = _encode_temporal_tail_pad(fork_model, x.clone())
|
||||
|
||||
assert out.shape == expected.shape
|
||||
assert torch.equal(out, expected)
|
||||
assert len(fork_model.encoded) == len(ref_calls)
|
||||
for got, want in zip(fork_model.encoded, ref_calls):
|
||||
assert torch.equal(got, want)
|
||||
|
||||
|
||||
def test_encode_fork_avoids_whole_video_pad_for_misaligned_input():
|
||||
# 375 frames with an asymmetric config (isolated last frame only):
|
||||
# the checkpoint always pads (375 % 17 != 0); the fork must encode the
|
||||
# tail chunk with an in-chunk pad instead of concatenating the video.
|
||||
model = _EncodeModel(isolated_last=True, frame_drop=16)
|
||||
model.clip_length = 17
|
||||
x = _video_x(375, c=1, h=2, w=2)
|
||||
_encode_temporal_tail_pad(model, x)
|
||||
# 375 % 17 == 1 → pad 16 inside the tail chunk; the isolated last frame
|
||||
# resolves to the final real frame (pad frames replicate it).
|
||||
assert len(model.encoded) == (375 // 17) + 1 + 1
|
||||
assert torch.equal(model.encoded[-1].flatten(), x[:, :, -1:, :, :].flatten())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _decode_temporal_streaming uint8 fork
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DENORM_MEAN = (-2.11628412, -2.03571429, -1.80444444)
|
||||
DENORM_STD = (4.36681223, 4.46428571, 4.44444444)
|
||||
|
||||
|
||||
class _FakeDenorm:
|
||||
def __init__(self):
|
||||
self.mean = DENORM_MEAN
|
||||
self.std = DENORM_STD
|
||||
|
||||
|
||||
class _FakeProcessor:
|
||||
def __init__(self):
|
||||
self.transform_rev = _FakeDenorm()
|
||||
|
||||
|
||||
class _DecodeModel:
|
||||
"""Minimal stand-in for the checkpoint's streaming decode contract."""
|
||||
|
||||
def __init__(self, *, token_drop=0, frame_overlap=0):
|
||||
self.tokens_chunk_size = 2
|
||||
self.vae_ratio_t = 1
|
||||
self.token_overlap = 1 if token_drop else 0
|
||||
self.token_drop = token_drop
|
||||
self.frame_pre_padding = 0
|
||||
self.frame_overlap = frame_overlap
|
||||
self.processor = _FakeProcessor()
|
||||
|
||||
def _adaptive_decode(self, clip_z):
|
||||
# Deterministic pseudo-decode: map latent tokens to frames 1:1.
|
||||
return torch.sin(clip_z * 3.0)
|
||||
|
||||
def blend(self, a, b, overlap, dim=-3):
|
||||
return (a + b) / 2.0
|
||||
|
||||
def _decode_temporal_pad_frames(self, z, pad_tokens):
|
||||
return int(pad_tokens)
|
||||
|
||||
def _decode_temporal_output_frame_plan(self, z, z_head, z_tail, num_chunks, pad_tokens):
|
||||
total = num_chunks * self.tokens_chunk_size * self.vae_ratio_t
|
||||
if z_head is not None:
|
||||
total += 1
|
||||
if z_tail is not None:
|
||||
total += 1
|
||||
if self.token_drop > 0:
|
||||
total += self.frame_overlap
|
||||
return int(total), int(pad_tokens), int(total - pad_tokens)
|
||||
|
||||
def legacy_streaming(self, z, z_head, z_tail, num_chunks, pad_tokens, temporal_cat_dtype):
|
||||
"""The checkpoint's original: accumulate in the cat dtype, quantize later."""
|
||||
total_frames, _, output_frames = self._decode_temporal_output_frame_plan(
|
||||
z, z_head, z_tail, num_chunks, pad_tokens
|
||||
)
|
||||
chunk_dec = self.tokens_chunk_size * self.vae_ratio_t
|
||||
split_count = int(self.token_drop > 0) + 1
|
||||
dec = None
|
||||
dec_overlap = None
|
||||
write_pos = 0
|
||||
|
||||
def write_part(part):
|
||||
nonlocal dec, write_pos
|
||||
if dec is None:
|
||||
out_shape = list(part.shape)
|
||||
out_shape[2] = output_frames
|
||||
dec = torch.empty(out_shape, dtype=part.dtype)
|
||||
n = min(part.shape[2], dec.shape[2] - write_pos)
|
||||
if n > 0:
|
||||
dec[:, :, write_pos : write_pos + n].copy_(part[:, :, :n])
|
||||
write_pos += n
|
||||
|
||||
for i in range(num_chunks):
|
||||
t_start = i * self.tokens_chunk_size
|
||||
t_end = t_start + self.tokens_chunk_size + self.token_overlap
|
||||
clip_z = z[:, :, t_start:t_end]
|
||||
clip_dec = self._adaptive_decode(clip_z)
|
||||
if temporal_cat_dtype is not None and clip_dec.dtype != temporal_cat_dtype:
|
||||
clip_dec = clip_dec.to(temporal_cat_dtype)
|
||||
for j in range(split_count):
|
||||
chunk = clip_dec[:, :, j * chunk_dec : min(j * chunk_dec + chunk_dec, clip_dec.shape[2])]
|
||||
if j == 0:
|
||||
if dec_overlap is not None:
|
||||
chunk = self.blend(dec_overlap, chunk, self.frame_overlap, dim=-3)
|
||||
dec_overlap = None
|
||||
write_part(chunk)
|
||||
else:
|
||||
dec_overlap = chunk.contiguous()
|
||||
if i == num_chunks - 1 and dec_overlap is not None:
|
||||
write_part(dec_overlap)
|
||||
dec_overlap = None
|
||||
# Legacy post-decode chain: revert in place in the decoded dtype, then
|
||||
# the adapter's ``frames.float()`` before the pipeline's quantizer.
|
||||
assert dec is not None
|
||||
mean = torch.tensor(DENORM_MEAN, dtype=dec.dtype).view(1, 3, 1, 1, 1)
|
||||
std = torch.tensor(DENORM_STD, dtype=dec.dtype).view(1, 3, 1, 1, 1)
|
||||
dec.sub_(mean).div_(std).clamp_(0.0, 1.0)
|
||||
dec = dec.float()
|
||||
dec.mul_(255.0).round_()
|
||||
return dec.to(torch.uint8)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token_drop,frame_overlap", [(0, 0), (1, 1)])
|
||||
@pytest.mark.parametrize("temporal_cat_dtype", [None, torch.float16, torch.bfloat16])
|
||||
def test_decode_fork_matches_legacy_chain_bitwise(token_drop, frame_overlap, temporal_cat_dtype):
|
||||
# The quantizer must see FP32 no matter the cat dtype: the legacy adapter
|
||||
# returned frames.float() before the pipeline multiplied and rounded, so
|
||||
# an fp16/bf16 cat dtype only narrows denormalization, never the rounding.
|
||||
torch.manual_seed(0)
|
||||
tokens = 2 * 2 + (1 if token_drop else 0) # num_chunks * tokens_chunk_size
|
||||
z = torch.randn(1, 3, tokens, 4, 4)
|
||||
legacy = _DecodeModel(token_drop=token_drop, frame_overlap=frame_overlap)
|
||||
expected = legacy.legacy_streaming(z.clone(), None, None, 2, 0, temporal_cat_dtype)
|
||||
|
||||
fork = _DecodeModel(token_drop=token_drop, frame_overlap=frame_overlap)
|
||||
out = _decode_temporal_streaming_uint8(fork, z.clone(), None, None, 2, 0, temporal_cat_dtype)
|
||||
assert out.dtype == torch.uint8
|
||||
assert out.shape == expected.shape
|
||||
assert torch.equal(out, expected)
|
||||
|
||||
|
||||
def test_decode_fork_rejects_empty_plan():
|
||||
model = _DecodeModel()
|
||||
model.tokens_chunk_size = 0
|
||||
with pytest.raises(ValueError, match="non-positive output_frames"):
|
||||
_decode_temporal_streaming_uint8(model, torch.randn(1, 3, 0, 4, 4), None, None, 0, 0, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# install + escape hatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_install_binds_both_forks():
|
||||
model = _EncodeModel()
|
||||
# cast(Any, ...) keeps mypy from narrowing the probes to the lambda type,
|
||||
# which has no __func__ for the bound-method assertions below.
|
||||
model.encode_temporal = cast(Any, lambda x: x) # placeholder for the capability check
|
||||
model._decode_temporal_streaming = cast(Any, lambda *a: None)
|
||||
model.processor = _FakeProcessor()
|
||||
install_temporal_stream_patches(model)
|
||||
assert model.encode_temporal.__func__ is _encode_temporal_tail_pad
|
||||
assert model._decode_temporal_streaming.__func__ is _decode_temporal_streaming_uint8
|
||||
|
||||
|
||||
def test_install_skips_decode_fork_without_denorm_contract():
|
||||
model = _EncodeModel()
|
||||
model.encode_temporal = cast(Any, lambda x: x)
|
||||
model._decode_temporal_streaming = cast(Any, lambda *a: None)
|
||||
install_temporal_stream_patches(model) # no processor -> keep original
|
||||
assert model.encode_temporal.__func__ is _encode_temporal_tail_pad
|
||||
assert not hasattr(model._decode_temporal_streaming, "__func__")
|
||||
|
||||
|
||||
def test_install_respects_legacy_env(monkeypatch):
|
||||
monkeypatch.setenv("VLLM_OMNI_VAE_LEGACY_TEMPORAL", "1")
|
||||
model = _EncodeModel()
|
||||
install_temporal_stream_patches(model)
|
||||
assert not hasattr(getattr(model, "encode_temporal", None), "__func__")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _prepare_minimax_h3_video_output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_prepare_output_matches_previous_conversion():
|
||||
video = torch.randn(1, 3, 4, 6, 8).clamp_(-1, 2)
|
||||
legacy = (
|
||||
video.detach()
|
||||
.float()
|
||||
.clamp(0, 1)
|
||||
.mul(255)
|
||||
.round()
|
||||
.permute(0, 2, 3, 4, 1)
|
||||
.to(dtype=torch.uint8, memory_format=torch.contiguous_format)
|
||||
)
|
||||
out = _prepare_minimax_h3_video_output(video.clone())
|
||||
assert out.dtype == torch.uint8
|
||||
assert out.shape == (1, 4, 6, 8, 3)
|
||||
assert out.is_contiguous()
|
||||
assert torch.equal(out, legacy)
|
||||
|
||||
|
||||
def test_prepare_output_uint8_passthrough():
|
||||
video = torch.randint(0, 256, (1, 3, 4, 6, 8), dtype=torch.uint8)
|
||||
out = _prepare_minimax_h3_video_output(video)
|
||||
assert out.dtype == torch.uint8
|
||||
assert out.is_contiguous()
|
||||
assert torch.equal(out, video.permute(0, 2, 3, 4, 1).contiguous())
|
||||
@@ -214,6 +214,8 @@ _MODEL_INTERNALIZE = (
|
||||
_MODEL_DEPRECATE_REMOVE = (
|
||||
"HIGGS_AUDIO_V2_TOKENIZER_PATH",
|
||||
"VLLM_OMNI_LINGBOT_ACTION_ROOT",
|
||||
"VLLM_OMNI_VAE_ENCODE_LEGACY_PREP",
|
||||
"VLLM_OMNI_VAE_LEGACY_TEMPORAL",
|
||||
"VLLM_OMNI_VOXCPM_CODE_PATH",
|
||||
"XCODEC1_PATH",
|
||||
"model_stage",
|
||||
|
||||
@@ -8,7 +8,7 @@ import json
|
||||
import math
|
||||
import os
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from dataclasses import fields, replace
|
||||
from itertools import groupby
|
||||
from pathlib import Path
|
||||
@@ -138,9 +138,11 @@ from .time_request import (
|
||||
MINIMAX_H3_SHAPE_PLANNER,
|
||||
minimax_h3_time_shift_sigmas,
|
||||
)
|
||||
from .vae import MiniMaxH3AudioVAE, MiniMaxH3VideoVAE
|
||||
from .vae import MiniMaxH3AudioVAE, MiniMaxH3VideoVAE, _VideoVAEPartProxy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.diffusion.worker.input_batch import InputBatch
|
||||
from vllm_omni.diffusion.worker.utils import StepRequestState
|
||||
|
||||
@@ -376,12 +378,20 @@ def _minimax_h3_post_process(output, output_type: str = "np"):
|
||||
|
||||
def _prepare_minimax_h3_video_output(video: torch.Tensor) -> torch.Tensor:
|
||||
"""Quantize decoded frames in place before worker-to-engine transfer."""
|
||||
video = video.detach().float()
|
||||
video = video.detach()
|
||||
if video.dtype == torch.uint8:
|
||||
# Streaming decode already quantized and clamped; only the transfer
|
||||
# layout remains.
|
||||
return video.permute(0, 2, 3, 4, 1).contiguous()
|
||||
video = video.float()
|
||||
video.clamp_(0, 1).mul_(255).round_()
|
||||
return video.permute(0, 2, 3, 4, 1).to(
|
||||
dtype=torch.uint8,
|
||||
memory_format=torch.contiguous_format,
|
||||
)
|
||||
permuted = video.permute(0, 2, 3, 4, 1)
|
||||
out = torch.empty(permuted.shape, dtype=torch.uint8, device=video.device)
|
||||
# copy_ fuses the layout change and the cast into one kernel;
|
||||
# ``.to(dtype=uint8, memory_format=contiguous_format)`` materializes a
|
||||
# contiguous FP32 intermediate first (~4.2GB for a 15s clip).
|
||||
out.copy_(permuted)
|
||||
return out
|
||||
|
||||
|
||||
def _register_dlo_component_cache(cache: BoundedAllocatorCache, *components: Any) -> None:
|
||||
@@ -1342,6 +1352,15 @@ class MiniMaxH3Pipeline(
|
||||
@contextmanager
|
||||
def _component_on_device(self, component: nn.Module):
|
||||
if getattr(self, "_model_cpu_offload_modules", None):
|
||||
# Sequential offload hooks whole modules (enable_omni_model_cpu_offload
|
||||
# registers them on the discovered components, e.g. the real
|
||||
# video_vae). Split-residency proxies carry no hook, so unwrap to the
|
||||
# hooked module before entering the context — whole-module movement
|
||||
# has no half-residency benefit anyway. The check is a type check, not
|
||||
# getattr: Mock components auto-create any attribute, which would
|
||||
# unwrap them to a child mock and break scope tracking.
|
||||
if isinstance(component, _VideoVAEPartProxy):
|
||||
component = component.sequential_offload_target
|
||||
with sequential_offload_component(component):
|
||||
yield
|
||||
return
|
||||
@@ -1381,6 +1400,50 @@ class MiniMaxH3Pipeline(
|
||||
"""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 _encode_visual_conditions(
|
||||
self,
|
||||
images: list[Image.Image],
|
||||
prepared_videos: list[dict[str, Any]] | None,
|
||||
*,
|
||||
video_count: int,
|
||||
) -> tuple[torch.Tensor | None, list[tuple[int, int, int]]]:
|
||||
rows: list[torch.Tensor] = []
|
||||
shapes: list[tuple[int, int, int]] = []
|
||||
_, rank, _ = _dit_rank_world()
|
||||
# Keep image and video references in one residency window when both
|
||||
# appear in a request; otherwise the video branch would reload the VAE.
|
||||
# Encoding touches only the CNN encoder half, so the 9GB ViT decoder
|
||||
# stays off the device for the whole window.
|
||||
needs_video_vae = video_count > 0 or (rank == 0 and bool(images))
|
||||
video_vae_context = (
|
||||
self._component_on_device(self.video_vae.encoder_component) if needs_video_vae else nullcontext()
|
||||
)
|
||||
with video_vae_context:
|
||||
if images:
|
||||
image_rows = None
|
||||
if rank == 0:
|
||||
image_rows = torch.cat([self.video_vae.encode_image(image) for image in images])
|
||||
rows.append(
|
||||
_broadcast_tensor(
|
||||
image_rows,
|
||||
dtype=torch.float32,
|
||||
device=self.device,
|
||||
)
|
||||
)
|
||||
shapes.extend((1, image.height // 16, image.width // 16) for image in images)
|
||||
if video_count:
|
||||
video_rows, video_shapes = self._encode_video_conditions_resident(
|
||||
prepared_videos,
|
||||
count=video_count,
|
||||
)
|
||||
rows.append(video_rows)
|
||||
shapes.extend(video_shapes)
|
||||
# The latents are extracted; the encode's input/staging pages are idle
|
||||
# and must not stay mapped through the denoise and decode peaks.
|
||||
if needs_video_vae:
|
||||
self._release_stage_cache()
|
||||
return (torch.cat(rows) if rows else None), shapes
|
||||
|
||||
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.
|
||||
|
||||
@@ -1768,6 +1831,26 @@ class MiniMaxH3Pipeline(
|
||||
raise ValueError("MiniMax H3 chunked MP4 encoding currently expects one output per decoder")
|
||||
return videos[0]
|
||||
|
||||
def _release_stage_cache(self) -> None:
|
||||
"""Return idle allocator pages to the device at stage boundaries.
|
||||
|
||||
The bounded component cache releases only past its idle-cache bound
|
||||
(>25% of device capacity), which on large devices lets a finished
|
||||
stage's freed activations -- the DiT's denoise buffers, an encode
|
||||
input, a decoded frame tensor -- stay physically mapped across the
|
||||
next stage's peak. Forcing the release at a boundary is exact (only
|
||||
free pages are returned; live tensors are untouched) and costs one
|
||||
remap per later allocation.
|
||||
"""
|
||||
cache = getattr(self, "_dlo_component_cache", None)
|
||||
if cache is not None:
|
||||
try:
|
||||
cache.release_if_needed(force=True)
|
||||
except BaseException:
|
||||
logger.exception("Failed to release retained allocator cache at stage boundary")
|
||||
return
|
||||
current_omni_platform.empty_cache()
|
||||
|
||||
def decode(
|
||||
self,
|
||||
video_latent: torch.Tensor,
|
||||
@@ -1776,7 +1859,11 @@ class MiniMaxH3Pipeline(
|
||||
height: int,
|
||||
width: int,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
with self._component_on_device(self.video_vae):
|
||||
# Denoise just ended: its freed activation pages must not stay mapped
|
||||
# through the VAE decode peak. Decoding needs only the ViT decoder
|
||||
# half of the VAE, so the CNN encoder stays off the device.
|
||||
self._release_stage_cache()
|
||||
with self._component_on_device(self.video_vae.decoder_component):
|
||||
with current_omni_platform.create_autocast_context(
|
||||
device_type=self.device.type,
|
||||
dtype=torch.float16,
|
||||
@@ -2199,6 +2286,11 @@ class MiniMaxH3Pipeline(
|
||||
# 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)
|
||||
# The FP32 decoded frames are quantized into the appended uint8
|
||||
# tensor; drop the reference and return the idle pages instead of
|
||||
# holding them through the next output's denoise/decode.
|
||||
del video
|
||||
self._release_stage_cache()
|
||||
audios.append(audio)
|
||||
if videos and isinstance(videos[0], bytes):
|
||||
video = videos[0] if len(videos) == 1 else videos
|
||||
@@ -2518,6 +2610,7 @@ class MiniMaxH3Pipeline(
|
||||
width=shape["width"],
|
||||
)
|
||||
video = self._offload_model_cpu_stage_output(_prepare_minimax_h3_video_output(video))
|
||||
self._release_stage_cache()
|
||||
return DiffusionOutput(
|
||||
output=(video, audio),
|
||||
post_process_func=get_minimax_h3_post_process_func(self.od_config),
|
||||
|
||||
@@ -6,11 +6,13 @@ from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from contextlib import AbstractContextManager, contextmanager, nullcontext
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
import torch.nn as nn
|
||||
@@ -33,11 +35,15 @@ from vllm_omni.diffusion.offloader.module_residency import (
|
||||
from .chunked_decode import decode_h3_chunks
|
||||
from .ops import install_h3_vae_optimizations
|
||||
from .packed_tokens import minimax_h3_patchify_video_latent
|
||||
from .vae_temporal import install_temporal_stream_patches
|
||||
|
||||
MINIMAX_H3_KEYFRAME_ENCODE_SEED = 42
|
||||
MINIMAX_H3_AUDIO_SAMPLE_RATE = 32000
|
||||
MINIMAX_H3_AUDIO_CHANNELS = 2
|
||||
|
||||
# Escape hatch back to the checkpoint's whole-video numpy preparation path.
|
||||
_VAE_ENCODE_LEGACY_PREP_ENV = "VLLM_OMNI_VAE_ENCODE_LEGACY_PREP"
|
||||
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
@@ -63,6 +69,11 @@ def _minimax_h3_keyframe_encode_context(
|
||||
yield
|
||||
|
||||
|
||||
def _legacy_encode_prep_enabled() -> bool:
|
||||
value = os.environ.get(_VAE_ENCODE_LEGACY_PREP_ENV, "0")
|
||||
return value.strip().lower() not in ("", "0", "false", "off")
|
||||
|
||||
|
||||
def _load_component_config(component_path: str) -> dict[str, Any]:
|
||||
config_path = Path(component_path) / "config.json"
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
@@ -264,6 +275,49 @@ class _AudioVAEDeterminismContext(AbstractContextManager):
|
||||
return False
|
||||
|
||||
|
||||
class _VideoVAEPartProxy(nn.Module):
|
||||
"""Residency proxy for one half of the split video VAE.
|
||||
|
||||
The pipeline's ``_component_on_device`` drives whatever object it is
|
||||
given through ``load_to_device``/``offload_to_cpu``. The checkpoint's
|
||||
ViT decoder holds ~9GB of FP32 weights while the CNN encoder holds
|
||||
~0.7GB, and each is used by exactly one direction, so routing those
|
||||
calls per half keeps the decoder off the device while reference videos
|
||||
encode and the encoder off while latents decode. ``object.__setattr__``
|
||||
keeps the back-reference out of the module registry (registering the
|
||||
adapter as a submodule would recurse through ``parameters()``).
|
||||
"""
|
||||
|
||||
def __init__(self, vae: MiniMaxH3VideoVAE, part: str) -> None:
|
||||
super().__init__()
|
||||
object.__setattr__(self, "_vae", vae)
|
||||
object.__setattr__(self, "_part", part)
|
||||
|
||||
def load_to_device(self) -> None:
|
||||
self._vae._load_part_to_device(self._part)
|
||||
|
||||
def offload_to_cpu(self) -> None:
|
||||
self._vae._offload_part_to_cpu(self._part)
|
||||
|
||||
def set_omni_component_cache(self, cache: BoundedAllocatorCache | None) -> None:
|
||||
self._vae.set_omni_component_cache(cache)
|
||||
|
||||
@property
|
||||
def sequential_offload_target(self) -> MiniMaxH3VideoVAE:
|
||||
"""The module model-level CPU offload actually hooks.
|
||||
|
||||
``enable_omni_model_cpu_offload`` registers the sequential hook on the
|
||||
real ``video_vae``, and the hook moves the whole module through
|
||||
``parameters()`` — entering the sequential context through this proxy
|
||||
would find no ``_hook_registry`` and raise, and whole-module movement
|
||||
has no half-residency benefit anyway. ``_component_on_device`` unwraps
|
||||
through this property before entering its sequential-offload branch;
|
||||
the manual per-half staging above stays proxy-driven, where split
|
||||
residency is the whole point.
|
||||
"""
|
||||
return self._vae
|
||||
|
||||
|
||||
class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
|
||||
"""Adapter around the checkpoint's native parallel-tiled video VAE."""
|
||||
|
||||
@@ -303,14 +357,15 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
|
||||
decoder,
|
||||
device=device,
|
||||
)
|
||||
self._stager = None
|
||||
if initial_device.type == "cpu" and device.type not in ("cpu", "meta"):
|
||||
self._stager = PinnedModuleStager(
|
||||
self.remote,
|
||||
device,
|
||||
pin_memory=True,
|
||||
)
|
||||
install_temporal_stream_patches(self.remote.model)
|
||||
self.model = self.remote.model
|
||||
self._stager = None
|
||||
self._encoder_stager = None
|
||||
self._decoder_stager = None
|
||||
if initial_device.type == "cpu" and device.type not in ("cpu", "meta"):
|
||||
self._build_residency_stagers(device)
|
||||
self.encoder_component = _VideoVAEPartProxy(self, "encoder")
|
||||
self.decoder_component = _VideoVAEPartProxy(self, "decoder")
|
||||
self.use_tiling = True
|
||||
self.use_slicing = False
|
||||
self.parallel_size = 1
|
||||
@@ -321,27 +376,97 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
|
||||
if self._tile_gather_reuse_enabled():
|
||||
self._install_persistent_tile_gather()
|
||||
|
||||
def load_to_device(self) -> None:
|
||||
def _build_residency_stagers(self, device: torch.device) -> None:
|
||||
"""Stage the encode and decode halves of the remote separately.
|
||||
|
||||
The encode path touches only ``encoder`` + ``quant_conv`` and the
|
||||
decode path only ``post_quant_conv`` + ``decoder`` (verified against
|
||||
the checkpoint's ``AutoencoderKLLegacy``: the one cross reference,
|
||||
``tiled_decode``'s ``getattr(self.encoder, "mask_enabled")``, is
|
||||
short-circuited by ``self.training`` at inference). No storage is
|
||||
shared across the two groups, so independent staging is exact. When
|
||||
the checkpoint's structure is not discoverable, fall back to
|
||||
whole-module staging (the previous single-stager behavior).
|
||||
"""
|
||||
part_names = ("encoder", "quant_conv", "post_quant_conv", "decoder")
|
||||
if all(isinstance(getattr(self.model, name, None), nn.Module) for name in part_names):
|
||||
self._encoder_stager = PinnedModuleStager(
|
||||
[self.model.encoder, self.model.quant_conv],
|
||||
device,
|
||||
pin_memory=True,
|
||||
)
|
||||
self._decoder_stager = PinnedModuleStager(
|
||||
[self.model.post_quant_conv, self.model.decoder],
|
||||
device,
|
||||
pin_memory=True,
|
||||
)
|
||||
return
|
||||
self._stager = PinnedModuleStager(
|
||||
self.remote,
|
||||
device,
|
||||
pin_memory=True,
|
||||
)
|
||||
|
||||
def _part_stager(self, part: str) -> PinnedModuleStager | None:
|
||||
if part == "encoder":
|
||||
return self._encoder_stager
|
||||
if part == "decoder":
|
||||
return self._decoder_stager
|
||||
raise ValueError(f"unknown video VAE part {part!r}")
|
||||
|
||||
def _load_part_to_device(self, part: str) -> None:
|
||||
stager = self._part_stager(part)
|
||||
if stager is not None:
|
||||
stager.load()
|
||||
elif self._stager is not None:
|
||||
self._stager.load()
|
||||
else:
|
||||
# No staged residency (the component lives on the device already
|
||||
# or is fully CPU-resident): fall back to whole-module placement.
|
||||
self.remote.to(self._device_target)
|
||||
|
||||
def _offload_part_to_cpu(self, part: str) -> None:
|
||||
stager = self._part_stager(part)
|
||||
if stager is not None:
|
||||
stager.offload()
|
||||
return
|
||||
if self._stager is not None:
|
||||
self._stager.offload()
|
||||
return
|
||||
self.remote.to("cpu")
|
||||
self._release_component_cache()
|
||||
|
||||
def _release_component_cache(self) -> None:
|
||||
cache = getattr(self, "_omni_component_cache", None)
|
||||
if cache is None:
|
||||
torch.accelerator.empty_cache()
|
||||
else:
|
||||
cache.release_if_needed()
|
||||
|
||||
def load_to_device(self) -> None:
|
||||
if self._encoder_stager is not None:
|
||||
self._encoder_stager.load()
|
||||
self._decoder_stager.load()
|
||||
elif self._stager is not None:
|
||||
self._stager.load()
|
||||
else:
|
||||
self.remote.to(self._device_target)
|
||||
|
||||
def set_omni_component_cache(self, cache: BoundedAllocatorCache | None) -> None:
|
||||
self._omni_component_cache = cache
|
||||
if self._stager is not None:
|
||||
self._stager.set_cache_retention(cache)
|
||||
for stager in (self._encoder_stager, self._decoder_stager, self._stager):
|
||||
if stager is not None:
|
||||
stager.set_cache_retention(cache)
|
||||
|
||||
def offload_to_cpu(self) -> None:
|
||||
if self._stager is not None:
|
||||
if self._encoder_stager is not None:
|
||||
self._encoder_stager.offload()
|
||||
self._decoder_stager.offload()
|
||||
elif self._stager is not None:
|
||||
self._stager.offload()
|
||||
else:
|
||||
self.remote.to("cpu")
|
||||
cache = getattr(self, "_omni_component_cache", None)
|
||||
if cache is None:
|
||||
torch.accelerator.empty_cache()
|
||||
else:
|
||||
cache.release_if_needed()
|
||||
self._release_component_cache()
|
||||
|
||||
def set_parallel_size(
|
||||
self,
|
||||
@@ -650,6 +775,91 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
|
||||
patch_size=(1, 2, 2),
|
||||
).float()
|
||||
|
||||
def _stream_prepare_video_tensor(
|
||||
self,
|
||||
frames: np.ndarray,
|
||||
device: torch.device,
|
||||
) -> torch.Tensor | None:
|
||||
"""Upload a uint8 ``(T, H, W, 3)`` video as one normalized FP32 tensor.
|
||||
|
||||
The checkpoint's numpy path uploads the whole video as FP32, then
|
||||
``transform_tensor`` materializes a second normalized copy, then
|
||||
``encode_temporal`` pads through a full-video ``torch.cat``: three
|
||||
resident pixel-scale copies on the device. Here the padding is
|
||||
replicated on the host, a single preallocated ``(3, T', H, W)`` tensor
|
||||
receives clip-by-clip uploads, and the ``÷255 → (x-mean)/std`` chain
|
||||
runs in place on each clip's staging buffer in the same op order as
|
||||
``convert_numpy_to_tensor`` → ``transform_tensor``, so peak device
|
||||
memory is the output tensor plus one clip instead of three copies.
|
||||
|
||||
Returns ``None`` whenever the checkpoint contract this mirrors (uint8
|
||||
frames, ``clip_length`` alignment, processor ``transform`` constants)
|
||||
is not discoverable; callers fall back to the legacy path.
|
||||
"""
|
||||
if frames.dtype != np.uint8 or frames.ndim != 4 or frames.shape[-1] != 3:
|
||||
return None
|
||||
if int(frames.shape[0]) == 0:
|
||||
return None
|
||||
model = self.model
|
||||
clip_length = getattr(model, "clip_length", None)
|
||||
transform = getattr(getattr(model, "processor", None), "transform", None)
|
||||
mean = getattr(transform, "mean", None)
|
||||
std = getattr(transform, "std", None)
|
||||
if not isinstance(clip_length, int) or clip_length <= 0:
|
||||
return None
|
||||
if mean is None or std is None or len(mean) != 3 or len(std) != 3:
|
||||
return None
|
||||
# Mirror the checkpoint's temporal alignment so the device-side
|
||||
# ``get_suitable_video_length`` trim is a no-op and the
|
||||
# ``encode_temporal`` padding ``torch.cat`` never triggers. A
|
||||
# trim-stable length is ``k * clip_length + tail`` (tail = frame
|
||||
# overlap plus the isolated last frame); it also satisfies
|
||||
# encode_temporal only when ``tail % clip_length == offset_frame``.
|
||||
# With an asymmetric checkpoint (isolated last frame but no isolated
|
||||
# first frame) no trim-stable length can satisfy encode_temporal, so
|
||||
# the remote re-pads through a whole-video cat regardless and any
|
||||
# host-side pad would be trimmed away before that -- keep the legacy
|
||||
# frame count there instead of uploading dead frames.
|
||||
isolated_first_frame = bool(getattr(model, "isolated_first_frame", False))
|
||||
frame_pre_padding = int(getattr(model, "frame_pre_padding", 0) or 0)
|
||||
offset = 1 if isolated_first_frame and frame_pre_padding == 0 else 0
|
||||
processor = getattr(model, "processor", None)
|
||||
tail = int(getattr(processor, "frame_overlap", 0) or 0)
|
||||
if bool(getattr(processor, "isolated_last_frame", False)):
|
||||
tail += 1
|
||||
num_frames = int(frames.shape[0])
|
||||
pad = 0
|
||||
if tail % clip_length == offset:
|
||||
align = getattr(processor, "align_video_length", None)
|
||||
if callable(align):
|
||||
pad = max(0, int(align(num_frames, mode="pad", granularity="chunk")))
|
||||
else:
|
||||
chunks = -(-(num_frames - tail) // clip_length)
|
||||
pad = max(max(chunks, 1) * clip_length + tail - num_frames, 0)
|
||||
if pad:
|
||||
frames = np.concatenate([frames, np.repeat(frames[-1:], pad, axis=0)])
|
||||
num_frames += pad
|
||||
mean_t = torch.as_tensor(mean, dtype=torch.float32, device=device).view(3, 1, 1, 1)
|
||||
std_t = torch.as_tensor(std, dtype=torch.float32, device=device).view(3, 1, 1, 1)
|
||||
height, width = int(frames.shape[1]), int(frames.shape[2])
|
||||
out = torch.empty(
|
||||
(3, num_frames, height, width),
|
||||
dtype=torch.float32,
|
||||
device=device,
|
||||
)
|
||||
for start in range(0, num_frames, clip_length):
|
||||
end = min(start + clip_length, num_frames)
|
||||
# Frame slices of a C-contiguous (T, H, W, 3) array stay
|
||||
# contiguous, so each clip uploads at uint8 width (4x less host
|
||||
# traffic than the legacy FP32 upload) before the in-place
|
||||
# normalization chain. permute lands on the checkpoint's
|
||||
# (3, T, H, W) layout directly.
|
||||
chunk = torch.from_numpy(frames[start:end]).to(device=device)
|
||||
chunk = chunk.permute(3, 0, 1, 2).to(torch.float32).div_(255.0)
|
||||
chunk = chunk.sub_(mean_t).div_(std_t)
|
||||
out[:, start:end].copy_(chunk)
|
||||
return out
|
||||
|
||||
@torch.inference_mode()
|
||||
def encode_video(
|
||||
self,
|
||||
@@ -679,6 +889,14 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
|
||||
for device in devices:
|
||||
with self.device_module.device(device):
|
||||
self.device_module.manual_seed(MINIMAX_H3_KEYFRAME_ENCODE_SEED)
|
||||
prepared = None
|
||||
if isinstance(frames, np.ndarray) and not _legacy_encode_prep_enabled():
|
||||
prepared = self._stream_prepare_video_tensor(frames, parameter.device)
|
||||
if prepared is not None:
|
||||
# Tensor inputs already carry the checkpoint's expected
|
||||
# (3, T, H, W) normalized-FP32 contract, so encode_videos
|
||||
# skips its own convert/transform/pad whole-video copies.
|
||||
frames = [prepared]
|
||||
latent = self.model.encode_videos(
|
||||
frames,
|
||||
use_fp16_latent=True,
|
||||
@@ -738,7 +956,12 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
|
||||
raise RuntimeError("MiniMax H3 encode-only video VAE cannot decode latents")
|
||||
with self._decode_tiling_context(latent):
|
||||
decoded = self.model.decode_base(self._denormalize_latent(latent))
|
||||
return self._normalize_decoded_frames(self.model.processor.revert_tensor(decoded))
|
||||
if decoded.dtype == torch.uint8:
|
||||
# The streaming uint8 write-back already ran the revert and the
|
||||
# output quantizer inside write_part; nothing remains but the
|
||||
# shape contract.
|
||||
return self._normalize_decoded_frames(decoded)
|
||||
return self._normalize_decoded_frames(self._revert_decoded_inplace(decoded))
|
||||
|
||||
def _denormalize_latent(self, latent: torch.Tensor) -> torch.Tensor:
|
||||
channels = int(self.config_dict["latent_channels"])
|
||||
@@ -755,7 +978,7 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
|
||||
frames = frames.unsqueeze(0).transpose(1, 2)
|
||||
if frames.ndim != 5:
|
||||
raise ValueError(f"unexpected decoded video shape {tuple(frames.shape)}")
|
||||
return frames.float()
|
||||
return frames if frames.dtype == torch.uint8 else frames.float()
|
||||
|
||||
# Chunks are reverted through the checkpoint's processor, which
|
||||
# denormalizes and clamps into the unit interval.
|
||||
@@ -791,6 +1014,33 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
|
||||
with self._decode_tiling_context(z):
|
||||
decode_h3_chunks(self, z, on_chunk, group=group)
|
||||
|
||||
def _revert_decoded_inplace(self, decoded: torch.Tensor) -> torch.Tensor:
|
||||
"""In-place counterpart of the processor's ``revert_tensor``.
|
||||
|
||||
The checkpoint version materializes a denormalized copy, a clamped
|
||||
copy, and a contiguous copy of the whole decoded video -- three
|
||||
pixel-scale tensors resident at the decode peak. Decoding owns the
|
||||
tensor here, so the same op order (torchvision ``Normalize`` is
|
||||
``(x - mean) / std``, then ``clamp(0, 1)``) runs in place on the
|
||||
original ``(B, C, T, H, W)`` layout, which is bit-identical
|
||||
elementwise to normalizing the ``(b t) c h w`` rearrangement and
|
||||
returns with zero whole-video copies.
|
||||
|
||||
Falls back to ``processor.revert_tensor`` when the checkpoint's
|
||||
denormalization constants are not discoverable.
|
||||
"""
|
||||
processor = getattr(self.model, "processor", None)
|
||||
transform_rev = getattr(processor, "transform_rev", None)
|
||||
mean = getattr(transform_rev, "mean", None)
|
||||
std = getattr(transform_rev, "std", None)
|
||||
if mean is None or std is None or len(mean) != 3 or len(std) != 3:
|
||||
return processor.revert_tensor(decoded)
|
||||
if bool(getattr(processor, "use_3d_conv", True)) and decoded.ndim == 4:
|
||||
decoded = decoded.unsqueeze(2)
|
||||
mean_t = torch.as_tensor(mean, dtype=decoded.dtype, device=decoded.device).view(1, 3, 1, 1, 1)
|
||||
std_t = torch.as_tensor(std, dtype=decoded.dtype, device=decoded.device).view(1, 3, 1, 1, 1)
|
||||
return decoded.sub_(mean_t).div_(std_t).clamp_(0.0, 1.0)
|
||||
|
||||
|
||||
class MiniMaxH3AudioVAE(nn.Module):
|
||||
def __init__(
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Memory-optimized forks of the checkpoint's temporal VAE loops.
|
||||
|
||||
Two method replacements installed on the checkpoint's ``AutoencoderKLLegacy``
|
||||
at adapter construction:
|
||||
|
||||
* ``encode_temporal`` pads misaligned frame counts by concatenating repeated
|
||||
last frames onto the *whole* video before the chunk loop (~4GB copy for a
|
||||
15s clip). The fork pads inside the tail chunk instead: the chunks see
|
||||
bit-identical frames while the copy shrinks to a single clip.
|
||||
|
||||
* ``_decode_temporal_streaming`` accumulates the whole decoded video in a
|
||||
floating-point buffer that is denormalized, clamped, and quantized to
|
||||
uint8 only after the final chunk lands. The fork fuses the adapter's
|
||||
in-place revert and the output quantizer into ``write_part`` and
|
||||
accumulates directly into a uint8 buffer -- the same per-element op order
|
||||
(``(x - mean) / std -> clamp(0, 1) -> *255 -> round -> uint8``), so the
|
||||
delivered video is bit-identical while the resident decode buffer shrinks
|
||||
4x.
|
||||
|
||||
Set ``VLLM_OMNI_VAE_LEGACY_TEMPORAL=1`` to keep the checkpoint's methods.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import MethodType
|
||||
|
||||
import torch
|
||||
from vllm.logger import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
_LEGACY_TEMPORAL_ENV = "VLLM_OMNI_VAE_LEGACY_TEMPORAL"
|
||||
|
||||
|
||||
def _legacy_temporal_enabled() -> bool:
|
||||
value = os.environ.get(_LEGACY_TEMPORAL_ENV, "0")
|
||||
return value.strip().lower() not in ("", "0", "false", "off")
|
||||
|
||||
|
||||
def _encode_temporal_tail_pad(self, x):
|
||||
"""Drop-in fork of the checkpoint's ``encode_temporal``.
|
||||
|
||||
Frame handling is identical to the original; only the padding site
|
||||
moves from a whole-video ``torch.cat`` into the tail chunk.
|
||||
"""
|
||||
offset_frame = 1 if self.isolated_first_frame and self.frame_pre_padding == 0 else 0
|
||||
|
||||
orig_frames = int(x.shape[2])
|
||||
pad_size = (offset_frame - orig_frames) % self.clip_length
|
||||
padded_frames = orig_frames + pad_size
|
||||
num_chunks = (padded_frames - offset_frame) // self.clip_length
|
||||
|
||||
z_list = []
|
||||
for i in range(num_chunks):
|
||||
start_idx = i * self.clip_length + offset_frame
|
||||
end_idx = (i + 1) * self.clip_length + offset_frame
|
||||
clip_x = x[:, :, start_idx : min(end_idx, orig_frames), :, :]
|
||||
if end_idx > orig_frames:
|
||||
# The original pads the whole video up front by repeating the
|
||||
# last frame; repeating it inside the tail chunk feeds the
|
||||
# identical frames to the identical encodes.
|
||||
clip_x = torch.cat(
|
||||
[clip_x, clip_x[:, :, -1:].repeat(1, 1, end_idx - orig_frames, 1, 1)],
|
||||
dim=2,
|
||||
)
|
||||
|
||||
if self.isolated_key_frame:
|
||||
key_frame = clip_x[:, :, :1, :, :]
|
||||
z_key = self._adaptive_encode(key_frame)
|
||||
|
||||
if clip_x.shape[2] > 1:
|
||||
video_frames = clip_x[:, :, 1:, :, :]
|
||||
z_video = self._adaptive_encode(video_frames)
|
||||
z = torch.cat([z_key, z_video], dim=2)
|
||||
else:
|
||||
z = z_key
|
||||
else:
|
||||
z = self._adaptive_encode(clip_x)
|
||||
|
||||
z_list.append(z)
|
||||
|
||||
z = torch.cat(z_list, dim=2)
|
||||
if self.token_drop > 0:
|
||||
z = z[:, :, : -self.token_drop]
|
||||
|
||||
if self.isolated_first_frame:
|
||||
input_first_frame = x[:, :, :1, :, :]
|
||||
z_first_frame = self._adaptive_encode(input_first_frame)
|
||||
|
||||
if self.frame_pre_padding == 0:
|
||||
z = torch.cat([z_first_frame, z], dim=2)
|
||||
else:
|
||||
z = torch.cat([z_first_frame, z[:, :, 1:, :, :]], dim=2)
|
||||
|
||||
if self.isolated_last_frame:
|
||||
# The original indexes into the padded video: pad frames replicate
|
||||
# the last real frame, out-of-range indexes produce an empty slice
|
||||
# (a no-op), and negative indexes wrap. Reproduce that mapping
|
||||
# against the unpadded tensor instead of materializing the pad.
|
||||
last_frame_idx = padded_frames - self.frame_drop + offset_frame
|
||||
if last_frame_idx < 0:
|
||||
last_frame_idx += padded_frames
|
||||
if 0 <= last_frame_idx < padded_frames:
|
||||
src_idx = min(last_frame_idx, orig_frames - 1)
|
||||
input_last_frame = x[:, :, src_idx : src_idx + 1, :, :]
|
||||
z_last_frame = self._adaptive_encode(input_last_frame)
|
||||
z = torch.cat([z, z_last_frame], dim=2)
|
||||
|
||||
return z
|
||||
|
||||
|
||||
def _decode_temporal_streaming_uint8(self, z, z_head, z_tail, num_chunks, pad_tokens, temporal_cat_dtype):
|
||||
"""Drop-in fork of the checkpoint's ``_decode_temporal_streaming``.
|
||||
|
||||
The chunk loop, overlap blending, frame plan, and assertions are
|
||||
identical to the original. ``write_part`` additionally runs the
|
||||
denormalize -> clamp -> quantize chain in place (the same op order the
|
||||
pipeline applies after decode) and accumulates into a uint8 buffer.
|
||||
"""
|
||||
total_frames, pad_frames, output_frames = self._decode_temporal_output_frame_plan(
|
||||
z, z_head, z_tail, num_chunks, pad_tokens
|
||||
)
|
||||
if output_frames <= 0:
|
||||
raise ValueError(
|
||||
f"decode_temporal streaming planned non-positive output_frames={output_frames} "
|
||||
f"total_frames={total_frames} pad_frames={pad_frames}"
|
||||
)
|
||||
|
||||
chunk_dec = self.tokens_chunk_size * self.vae_ratio_t
|
||||
split_count = int(self.token_drop > 0) + 1
|
||||
dec = None
|
||||
dec_overlap = None
|
||||
norm = None
|
||||
write_pos = 0
|
||||
logical_frames = 0
|
||||
dropped_frames = 0
|
||||
decoded_count = 0
|
||||
|
||||
def write_part(part):
|
||||
nonlocal dec, dec_overlap, norm, write_pos, logical_frames, dropped_frames
|
||||
part_frames = int(part.shape[2])
|
||||
if part_frames <= 0:
|
||||
return
|
||||
logical_frames += part_frames
|
||||
if dec is None:
|
||||
out_shape = list(part.shape)
|
||||
out_shape[2] = output_frames
|
||||
dec = torch.empty(out_shape, dtype=torch.uint8, device=part.device)
|
||||
transform_rev = self.processor.transform_rev
|
||||
mean = torch.as_tensor(transform_rev.mean, dtype=part.dtype, device=part.device).view(1, 3, 1, 1, 1)
|
||||
std = torch.as_tensor(transform_rev.std, dtype=part.dtype, device=part.device).view(1, 3, 1, 1, 1)
|
||||
norm = (mean, std)
|
||||
# revert_tensor ((x - mean) / std, clamp) followed by the output
|
||||
# quantizer (*255, round, uint8), in place on the part: the blended
|
||||
# raw values are consumed before this point, and every write into
|
||||
# ``dec`` goes through here, so the final buffer matches the legacy
|
||||
# post-decode chain element for element. Denormalization and clamping
|
||||
# run in the decoded dtype exactly like the legacy adapter; the
|
||||
# quantizer then runs in FP32 because the legacy adapter returned
|
||||
# ``frames.float()`` before the pipeline multiplied and rounded —
|
||||
# rounding fp16/bf16 directly flips borderline pixels by one.
|
||||
mean_t, std_t = norm
|
||||
part.sub_(mean_t).div_(std_t).clamp_(0.0, 1.0)
|
||||
part = part.float()
|
||||
part.mul_(255.0).round_()
|
||||
|
||||
remaining = int(dec.shape[2]) - write_pos
|
||||
copy_frames = min(part_frames, max(0, remaining))
|
||||
if copy_frames > 0:
|
||||
dec[:, :, write_pos : write_pos + copy_frames, :, :].copy_(part[:, :, :copy_frames, :, :])
|
||||
write_pos += copy_frames
|
||||
dropped_frames += part_frames - copy_frames
|
||||
|
||||
for i in range(num_chunks):
|
||||
t_start_idx = i * self.tokens_chunk_size
|
||||
t_end_idx = t_start_idx + self.tokens_chunk_size + self.token_overlap
|
||||
clip_z = z[:, :, t_start_idx:t_end_idx, :, :]
|
||||
|
||||
if i == 0 and z_head is not None:
|
||||
clip_z = torch.cat([z_head, clip_z], dim=2)
|
||||
|
||||
if i == num_chunks - 1 and z_tail is not None:
|
||||
clip_z = torch.cat([clip_z, z_tail], dim=2)
|
||||
|
||||
clip_dec = self._adaptive_decode(clip_z)
|
||||
decoded_count += 1
|
||||
if temporal_cat_dtype is not None and clip_dec.dtype != temporal_cat_dtype:
|
||||
clip_dec = clip_dec.to(temporal_cat_dtype)
|
||||
if clip_dec.device != z.device:
|
||||
clip_dec = clip_dec.to(z.device)
|
||||
|
||||
dec_tail = None
|
||||
if i == 0 and z_head is not None:
|
||||
write_part(clip_dec[:, :, self.vae_ratio_t - 1 : self.vae_ratio_t, :, :])
|
||||
clip_dec = clip_dec[:, :, self.vae_ratio_t :, :, :]
|
||||
|
||||
if i == num_chunks - 1 and z_tail is not None:
|
||||
dec_tail = clip_dec[:, :, -1:, :, :]
|
||||
clip_dec = clip_dec[:, :, : -self.vae_ratio_t, :, :]
|
||||
|
||||
for j in range(split_count):
|
||||
f_start_idx = j * chunk_dec
|
||||
f_end_idx = min(f_start_idx + chunk_dec, clip_dec.shape[2])
|
||||
clip_dec_chunk = clip_dec[:, :, f_start_idx:f_end_idx, :, :]
|
||||
clip_dec_chunk = clip_dec_chunk[:, :, self.frame_pre_padding :, :, :]
|
||||
|
||||
if j == 0:
|
||||
if dec_overlap is not None:
|
||||
clip_dec_chunk = self.blend(dec_overlap, clip_dec_chunk, self.frame_overlap, dim=-3)
|
||||
dec_overlap = None
|
||||
write_part(clip_dec_chunk)
|
||||
else:
|
||||
# Break the view's reference to the full decoded clip so earlier
|
||||
# temporal chunks can be released before the final output exists.
|
||||
dec_overlap = clip_dec_chunk.contiguous()
|
||||
|
||||
if i == num_chunks - 1:
|
||||
if dec_overlap is not None:
|
||||
write_part(dec_overlap)
|
||||
dec_overlap = None
|
||||
if dec_tail is not None:
|
||||
write_part(dec_tail)
|
||||
|
||||
del clip_dec, clip_z
|
||||
|
||||
if dec is None:
|
||||
raise RuntimeError("decode_temporal streaming produced no output tensor")
|
||||
if logical_frames != total_frames or dropped_frames != pad_frames or write_pos != output_frames:
|
||||
raise RuntimeError(
|
||||
"decode_temporal streaming frame plan mismatch: "
|
||||
f"logical_frames={logical_frames} total_frames={total_frames} "
|
||||
f"dropped_frames={dropped_frames} pad_frames={pad_frames} "
|
||||
f"write_pos={write_pos} output_frames={output_frames}"
|
||||
)
|
||||
|
||||
return dec
|
||||
|
||||
|
||||
def _processor_denorm_available(model) -> bool:
|
||||
transform_rev = getattr(getattr(model, "processor", None), "transform_rev", None)
|
||||
mean = getattr(transform_rev, "mean", None)
|
||||
std = getattr(transform_rev, "std", None)
|
||||
return mean is not None and std is not None and len(mean) == 3 and len(std) == 3
|
||||
|
||||
|
||||
def install_temporal_stream_patches(model) -> None:
|
||||
"""Install the memory-optimized temporal forks on the checkpoint model.
|
||||
|
||||
Each fork is installed only when the checkpoint contract it mirrors is
|
||||
discoverable; otherwise the checkpoint's own method keeps running (the
|
||||
adapter's post-decode revert already covers that path).
|
||||
"""
|
||||
if _legacy_temporal_enabled():
|
||||
logger.info("MiniMax-H3 VAE temporal stream patches disabled by %s", _LEGACY_TEMPORAL_ENV)
|
||||
return
|
||||
if callable(getattr(model, "encode_temporal", None)) and hasattr(model, "clip_length"):
|
||||
model.encode_temporal = MethodType(_encode_temporal_tail_pad, model)
|
||||
logger.info("MiniMax-H3 VAE encode_temporal tail-padding patch installed")
|
||||
else:
|
||||
logger.warning("MiniMax-H3 VAE encode_temporal contract not found; keeping checkpoint method")
|
||||
if callable(getattr(model, "_decode_temporal_streaming", None)) and _processor_denorm_available(model):
|
||||
model._decode_temporal_streaming = MethodType(_decode_temporal_streaming_uint8, model)
|
||||
logger.info("MiniMax-H3 VAE streaming uint8 decode patch installed")
|
||||
else:
|
||||
logger.warning("MiniMax-H3 VAE streaming decode contract not found; keeping checkpoint method")
|
||||
|
||||
|
||||
__all__ = ["install_temporal_stream_patches"]
|
||||
Reference in new issue
Block a user