Files
vllm-omni/tests/diffusion/test_wan_device_reduction.py
T

348 lines
15 KiB
Python
Raw Normal View History

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""WAN reference coverage for the typed pre-D2H media contract."""
from __future__ import annotations
import numpy as np
import pytest
import torch
from tests.helpers.mark import hardware_marks
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig, VideoOutputTransportConfig
from vllm_omni.diffusion.ipc import pack_diffusion_output_shm, unpack_diffusion_output_shm
from vllm_omni.diffusion.media import (
DiffusionMediaOutput,
VideoMediaOutput,
VideoTensorEncoding,
VideoTensorLayout,
VideoTensorSpec,
VideoValueRange,
)
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import get_wan22_post_process_func
from vllm_omni.diffusion.postprocess.device_reduction import prepare_diffusion_media_for_transport
from vllm_omni.diffusion.postprocess.media import finalize_diffusion_media
from vllm_omni.entrypoints.openai.video_api_utils import _coerce_video_to_uint8_frames
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
pytestmark = [
pytest.mark.core_model,
pytest.mark.diffusion,
*hardware_marks(res={"cuda": "L4"}, num_cards=1),
]
_GPU = torch.accelerator.is_available() if hasattr(torch, "accelerator") else torch.cuda.is_available()
requires_gpu = pytest.mark.skipif(not _GPU, reason="device reduction is a GPU path")
def _decoded_wan_video(tensor: torch.Tensor) -> DiffusionMediaOutput:
return DiffusionMediaOutput(
video=VideoMediaOutput(
tensor=tensor,
spec=VideoTensorSpec(
layout=VideoTensorLayout.BCTHW,
encoding=VideoTensorEncoding.NORMALIZED_FLOAT,
value_range=VideoValueRange.NEGATIVE_ONE_TO_ONE,
),
)
)
def _config(*, enabled: bool) -> OmniDiffusionConfig:
return OmniDiffusionConfig(
model=None,
video_output_transport=VideoOutputTransportConfig(enable_device_postprocess=enabled),
)
def _encoder_frames(video_payload: np.ndarray) -> list[np.ndarray]:
return [_coerce_video_to_uint8_frames(video_payload[i]) for i in range(video_payload.shape[0])]
@requires_gpu
def test_wan_media_reduction_matches_float32_reference_and_bounds_native_path() -> None:
torch.manual_seed(0)
vae_out = torch.rand(2, 3, 8, 64, 96, device="cuda:0", dtype=torch.bfloat16) * 2.4 - 1.2
sampling = OmniDiffusionSamplingParams(output_type=None, enable_frame_interpolation=False)
legacy_postprocess = get_wan22_post_process_func(_config(enabled=False))
widened_out = legacy_postprocess(vae_out.float(), output_type="np", sampling_params=sampling)
widened_frames = _encoder_frames(widened_out["payload"]["video"])
native_out = legacy_postprocess(vae_out, output_type="np", sampling_params=sampling)
native_frames = _encoder_frames(native_out["payload"]["video"])
prepared = prepare_diffusion_media_for_transport(
_decoded_wan_video(vae_out),
od_config=_config(enabled=True),
sampling_params=sampling,
)
reduced_out = finalize_diffusion_media(prepared, sampling_params=sampling)
assert prepared.video.spec.encoding is VideoTensorEncoding.UINT8_FRAMES
assert reduced_out["payload"]["video"].dtype == np.uint8
reduced_frames = _encoder_frames(reduced_out["payload"]["video"])
assert len(widened_frames) == len(reduced_frames) == 2
for expected, native, produced in zip(widened_frames, native_frames, reduced_frames, strict=True):
assert produced.shape == expected.shape == (8, 64, 96, 3)
np.testing.assert_array_equal(produced, expected)
deviation = np.abs(produced.astype(np.int16) - native.astype(np.int16))
assert deviation.max() <= 1
@requires_gpu
def test_wan_uint8_media_finalization_does_not_rescale() -> None:
frames = torch.randint(0, 256, (1, 4, 16, 16, 3), dtype=torch.uint8, device="cuda:0")
media = DiffusionMediaOutput(
video=VideoMediaOutput(
tensor=frames,
spec=VideoTensorSpec(
layout=VideoTensorLayout.BTHWC,
encoding=VideoTensorEncoding.UINT8_FRAMES,
value_range=VideoValueRange.ZERO_TO_255,
),
),
prepared_for_transport=True,
)
out = finalize_diffusion_media(media, sampling_params=OmniDiffusionSamplingParams(output_type="np"))
np.testing.assert_array_equal(out["payload"]["video"], frames.cpu().numpy())
@requires_gpu
def test_diffusion_output_to_cpu_moves_wan_media_off_device() -> None:
prepared = prepare_diffusion_media_for_transport(
_decoded_wan_video(torch.randn(1, 3, 2, 4, 5, device="cuda:0")),
od_config=_config(enabled=False),
sampling_params=OmniDiffusionSamplingParams(output_type="np"),
)
output = DiffusionOutput(media=prepared, to_cpu=True)
assert output.media is not None
assert output.media.video.tensor.device.type == "cpu"
@requires_gpu
def test_small_typed_media_is_on_cpu_before_ipc() -> None:
sampling = OmniDiffusionSamplingParams(output_type="np")
prepared = prepare_diffusion_media_for_transport(
_decoded_wan_video(torch.randn(1, 3, 2, 4, 5, device="cuda:0")),
od_config=_config(enabled=True),
sampling_params=sampling,
)
output = DiffusionOutput(media=prepared)
pack_diffusion_output_shm(output)
assert isinstance(output.media, dict)
packed_tensor = output.media["video"]["tensor"]
assert isinstance(packed_tensor, torch.Tensor)
assert packed_tensor.device.type == "cpu"
unpack_diffusion_output_shm(output)
@requires_gpu
def test_wan_float_media_finalization_matches_the_legacy_path() -> None:
video = torch.rand(1, 3, 4, 16, 16, device="cuda:0", dtype=torch.bfloat16) * 2 - 1
sampling = OmniDiffusionSamplingParams(output_type="np")
prepared = prepare_diffusion_media_for_transport(
_decoded_wan_video(video),
od_config=_config(enabled=False),
sampling_params=sampling,
)
produced = finalize_diffusion_media(prepared, sampling_params=sampling)
expected = get_wan22_post_process_func(_config(enabled=False))(
video,
output_type="np",
sampling_params=sampling,
)
assert prepared.video.spec.encoding is VideoTensorEncoding.NORMALIZED_FLOAT
np.testing.assert_array_equal(produced["payload"]["video"], expected["payload"]["video"])
@requires_gpu
def test_oom_fallback_media_packs_without_device_widening(monkeypatch: pytest.MonkeyPatch) -> None:
# Regression: when the fp32 reduction OOMs, the fallback keeps the bf16 float
# payload. Async IPC must copy it to the host in bf16 and widen on the CPU;
# a second device-side fp32 conversion would OOM again and drop the payload.
from vllm_omni.diffusion.postprocess import device_reduction
# Large enough (> 1 MB in bf16) that packing routes the tensor through the
# shared-memory D2H path rather than the small-tensor CPU copy.
video = torch.rand(1, 3, 16, 128, 128, device="cuda:0", dtype=torch.bfloat16) * 2 - 1
def _raise_oom(*_args: object, **_kwargs: object) -> torch.Tensor:
raise torch.OutOfMemoryError("forced fp32 reduction OOM")
monkeypatch.setattr(device_reduction, "reduce_video_to_uint8_frames", _raise_oom)
prepared = prepare_diffusion_media_for_transport(
_decoded_wan_video(video),
od_config=_config(enabled=True),
sampling_params=OmniDiffusionSamplingParams(output_type="np"),
)
assert prepared.video.spec.encoding is VideoTensorEncoding.NORMALIZED_FLOAT
assert prepared.video.tensor.dtype == torch.bfloat16
output = DiffusionOutput(media=prepared)
pinned_dtypes: list[torch.dtype] = []
real_empty = torch.empty
def _spy_empty(*args: object, **kwargs: object) -> torch.Tensor:
tensor = real_empty(*args, **kwargs)
if kwargs.get("pin_memory"):
pinned_dtypes.append(tensor.dtype)
return tensor
monkeypatch.setattr(torch, "empty", _spy_empty)
d2h_stream = torch.Stream(device=torch.device("cuda", 0))
pack_diffusion_output_shm(output, d2h_stream=d2h_stream)
d2h_stream.synchronize()
assert pinned_dtypes == [torch.bfloat16], (
"async packing must D2H in the original dtype and widen on the CPU, "
"never widen bf16 to fp32 on the accelerator before the copy"
)
unpack_diffusion_output_shm(output)
assert isinstance(output.media, DiffusionMediaOutput)
assert output.media.video.tensor.dtype == torch.bfloat16
torch.testing.assert_close(output.media.video.tensor, video.cpu())
@requires_gpu
def test_oom_fallback_recovers_the_allocator(monkeypatch: pytest.MonkeyPatch) -> None:
# A real float32 OOM leaves the CUDA caching allocator in a failed state;
# the fallback must empty the cache and drain the device before marking the
# float representation transport-ready, so the worker D2H of the same tensor
# can actually run. Use a genuine allocation failure (per-process cap) rather
# than injecting the exception, which never poisons the allocator.
from vllm_omni.platforms import current_omni_platform
video = torch.empty(1, 3, 16, 128, 128, device="cuda:0", dtype=torch.bfloat16)
video.uniform_(-1.0, 1.0)
expected = video.cpu()
d2h_stream = torch.Stream(device=video.device)
base_bytes = video.numel() * video.element_size()
_free, total = current_omni_platform.get_device_memory()
# Cap the process so the fp32 conversion (2x base bytes) cannot fit, while
# retaining the original bf16 tensor. This forces a real allocator OOM at
# the .to(fp32), rather than injecting an exception from a monkeypatch.
torch.cuda.set_per_process_memory_fraction(min(0.99, (1.2 * base_bytes) / total))
real_empty_cache = current_omni_platform.empty_cache
real_synchronize = current_omni_platform.synchronize
try:
empty_calls: list[int] = []
sync_calls: list[bool] = []
def _empty_cache() -> None:
real_empty_cache()
empty_calls.append(torch.accelerator.current_device_index())
def _synchronize() -> None:
real_synchronize()
sync_calls.append(True)
monkeypatch.setattr(current_omni_platform, "empty_cache", _empty_cache)
monkeypatch.setattr(current_omni_platform, "synchronize", _synchronize)
prepared = prepare_diffusion_media_for_transport(
_decoded_wan_video(video),
od_config=_config(enabled=True),
sampling_params=OmniDiffusionSamplingParams(output_type="np"),
)
# The fallback ran past a genuine OOM and kept the bf16 float payload.
assert prepared.video.spec.encoding is VideoTensorEncoding.NORMALIZED_FLOAT
assert prepared.video.tensor.dtype == torch.bfloat16
assert empty_calls, "fallback must empty the CUDA allocator cache after a real OOM"
assert sync_calls, "fallback must drain the device after a real OOM"
# Exercise the next production step: async D2H/SHM packing of the same
# tensor must succeed after allocator recovery and preserve its values.
output = DiffusionOutput(media=prepared)
pack_diffusion_output_shm(output, d2h_stream=d2h_stream)
d2h_stream.synchronize()
unpack_diffusion_output_shm(output)
assert isinstance(output.media, DiffusionMediaOutput)
torch.testing.assert_close(output.media.video.tensor, expected)
finally:
torch.cuda.set_per_process_memory_fraction(1.0)
real_empty_cache()
real_synchronize()
@requires_gpu
def test_oom_fallback_releases_late_uint8_traceback_before_clone(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A final uint8 allocation OOM must release float intermediates first."""
from vllm_omni.diffusion.postprocess import device_reduction
from vllm_omni.platforms import current_omni_platform
# A split request is a non-compact view, so float fallback must allocate a
# request-owned clone after reduction fails.
backing = torch.empty(2, 3, 16, 128, 128, device="cuda:0", dtype=torch.bfloat16)
backing.uniform_(-1.0, 1.0)
video = backing[1:2].detach()
expected = video.cpu()
_, total = current_omni_platform.get_device_memory()
real_to = torch.Tensor.to
real_empty_cache = current_omni_platform.empty_cache
real_synchronize = current_omni_platform.synchronize
real_ensure_request_owned = device_reduction.ensure_request_owned_tensor
uint8_attempts: list[int] = []
fallback_allocations: list[int] = []
def _record_fallback_allocation(tensor: torch.Tensor) -> torch.Tensor:
fallback_allocations.append(torch.accelerator.memory_allocated(tensor.device))
return real_ensure_request_owned(tensor)
def _fail_real_uint8_allocation(
tensor: torch.Tensor,
*args: object,
**kwargs: object,
) -> torch.Tensor:
dtype = kwargs.get("dtype")
if dtype is None and args and isinstance(args[0], torch.dtype):
dtype = args[0]
if tensor.is_cuda and dtype is torch.uint8:
# Earlier float32 work runs without a cap. Immediately before the
# real final uint8 allocation, cap the allocator below that request
# but above the post-unwind request-owned bf16 clone requirement.
real_empty_cache()
allocated = torch.accelerator.memory_allocated(tensor.device)
uint8_bytes = tensor.numel()
limit_bytes = allocated + max(1, uint8_bytes // 2)
torch.cuda.set_per_process_memory_fraction(min(0.99, limit_bytes / total))
uint8_attempts.append(allocated)
return real_to(tensor, *args, **kwargs)
monkeypatch.setattr(device_reduction, "ensure_request_owned_tensor", _record_fallback_allocation)
monkeypatch.setattr(torch.Tensor, "to", _fail_real_uint8_allocation)
try:
prepared = prepare_diffusion_media_for_transport(
_decoded_wan_video(video),
od_config=_config(enabled=True),
sampling_params=OmniDiffusionSamplingParams(output_type="np"),
)
assert uint8_attempts, "the real final .to(uint8) allocation was not attempted"
assert fallback_allocations, "float fallback did not request compact storage"
assert fallback_allocations[0] < uint8_attempts[0], (
"the failed reduction's float intermediates remained live during fallback"
)
assert prepared.video.spec.encoding is VideoTensorEncoding.NORMALIZED_FLOAT
assert prepared.video.tensor.dtype is torch.bfloat16
assert prepared.video.tensor.storage_offset() == 0
assert prepared.video.tensor.untyped_storage().data_ptr() != video.untyped_storage().data_ptr()
torch.testing.assert_close(prepared.video.tensor.cpu(), expected)
finally:
torch.cuda.set_per_process_memory_fraction(1.0)
real_empty_cache()
real_synchronize()