125 lines
5.1 KiB
Python
125 lines
5.1 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|||
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||
|
|
"""GPU correctness/size tests for device-side video reduction.
|
||
|
|
|
||
|
|
The reduction must be byte-identical to the current production output (SHM
|
||
|
|
widens bf16->f32, then diffusers postprocess, then the API server's ``*255``
|
||
|
|
rounding), otherwise enabling it would silently change pixels.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import numpy as np
|
||
|
|
import pytest
|
||
|
|
import torch
|
||
|
|
|
||
|
|
from tests.helpers.mark import hardware_marks
|
||
|
|
from vllm_omni.diffusion.postprocess.device_reduction import reduce_video_to_uint8_frames
|
||
|
|
|
||
|
|
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 _device() -> str:
|
||
|
|
return "cuda:0"
|
||
|
|
|
||
|
|
|
||
|
|
def _reference_uint8(video: torch.Tensor, *, compute_dtype: torch.dtype | None = torch.float32) -> np.ndarray:
|
||
|
|
"""The diffusers postprocess plus the API server's rounding, for the same input.
|
||
|
|
|
||
|
|
``compute_dtype`` selects the precision the denormalize runs at.
|
||
|
|
``torch.float32`` is the precision the device reduction uses; ``None`` keeps
|
||
|
|
the input's own dtype, which is what the engine's postprocess actually does.
|
||
|
|
"""
|
||
|
|
from diffusers.video_processor import VideoProcessor
|
||
|
|
|
||
|
|
source = video if compute_dtype is None else video.to(compute_dtype)
|
||
|
|
processed = VideoProcessor(vae_scale_factor=8).postprocess_video(source, output_type="np")
|
||
|
|
scaled = np.clip(processed.astype(np.float32), 0.0, 1.0) * 255.0
|
||
|
|
np.rint(scaled, out=scaled)
|
||
|
|
return scaled.astype(np.uint8)
|
||
|
|
|
||
|
|
|
||
|
|
@requires_gpu
|
||
|
|
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16, torch.float32])
|
||
|
|
def test_device_reduction_matches_a_float32_reference(dtype: torch.dtype) -> None:
|
||
|
|
"""The reduction must be the float32 computation, exactly, for every input dtype."""
|
||
|
|
torch.manual_seed(0)
|
||
|
|
# Values slightly outside [-1, 1] so the clamp is actually exercised.
|
||
|
|
video = (torch.rand(1, 3, 8, 64, 96, device=_device()) * 2.4 - 1.2).to(dtype)
|
||
|
|
|
||
|
|
reference = _reference_uint8(video)
|
||
|
|
produced = reduce_video_to_uint8_frames(video).cpu().numpy()
|
||
|
|
|
||
|
|
assert produced.shape == reference.shape == (1, 8, 64, 96, 3)
|
||
|
|
assert produced.dtype == np.uint8
|
||
|
|
np.testing.assert_array_equal(produced, reference)
|
||
|
|
|
||
|
|
|
||
|
|
@requires_gpu
|
||
|
|
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
|
||
|
|
def test_device_reduction_deviates_from_a_narrow_postprocess_by_at_most_one(dtype: torch.dtype) -> None:
|
||
|
|
"""Pin the cost of computing in float32: at most one 255th.
|
||
|
|
|
||
|
|
A postprocess that denormalizes in the VAE's own dtype (WAN, HunyuanVideo,
|
||
|
|
LTX-2, Cosmos3) does not match this reduction bit for bit; ~20% of pixels
|
||
|
|
differ by one on a real 480x832x81 WAN generation. Keep that a bound rather
|
||
|
|
than a surprise, so a regression widening it fails here.
|
||
|
|
"""
|
||
|
|
torch.manual_seed(0)
|
||
|
|
video = (torch.rand(1, 3, 8, 64, 96, device=_device()) * 2.4 - 1.2).to(dtype)
|
||
|
|
|
||
|
|
narrow = _reference_uint8(video, compute_dtype=None)
|
||
|
|
produced = reduce_video_to_uint8_frames(video).cpu().numpy()
|
||
|
|
|
||
|
|
deviation = np.abs(produced.astype(np.int16) - narrow.astype(np.int16))
|
||
|
|
assert deviation.max() <= 1
|
||
|
|
# The narrow path really is different, so the bound above is not vacuous.
|
||
|
|
assert deviation.any()
|
||
|
|
|
||
|
|
|
||
|
|
@requires_gpu
|
||
|
|
def test_device_reduction_shrinks_hop1_payload_4x() -> None:
|
||
|
|
"""The whole point: the D2H payload drops from float32 to uint8 (4x)."""
|
||
|
|
video = torch.rand(1, 3, 8, 704, 1280, device=_device(), dtype=torch.bfloat16) * 2 - 1
|
||
|
|
|
||
|
|
# Production transports bf16 widened to float32 over SHM.
|
||
|
|
widened_f32_bytes = video.numel() * 4
|
||
|
|
reduced = reduce_video_to_uint8_frames(video)
|
||
|
|
reduced_bytes = reduced.numel() * reduced.element_size()
|
||
|
|
|
||
|
|
assert reduced.dtype == torch.uint8
|
||
|
|
assert widened_f32_bytes / reduced_bytes == pytest.approx(4.0, rel=1e-6)
|
||
|
|
|
||
|
|
|
||
|
|
@requires_gpu
|
||
|
|
def test_device_reduction_stays_on_device() -> None:
|
||
|
|
"""Reduction must happen before D2H, so the result is still on the GPU."""
|
||
|
|
video = torch.rand(1, 3, 4, 32, 48, device=_device(), dtype=torch.bfloat16) * 2 - 1
|
||
|
|
reduced = reduce_video_to_uint8_frames(video)
|
||
|
|
assert reduced.device.type == "cuda"
|
||
|
|
assert reduced.is_contiguous()
|
||
|
|
|
||
|
|
|
||
|
|
@requires_gpu
|
||
|
|
def test_device_reduction_without_denormalize_matches_already_unit_range() -> None:
|
||
|
|
"""Pipelines with do_normalize=False emit [0,1]; skip the denorm step."""
|
||
|
|
video = torch.rand(1, 3, 4, 16, 16, device=_device(), dtype=torch.float32) # already [0,1]
|
||
|
|
|
||
|
|
produced = reduce_video_to_uint8_frames(video, do_denormalize=False).cpu().numpy()
|
||
|
|
|
||
|
|
expected = video.permute(0, 2, 3, 4, 1).cpu().numpy()
|
||
|
|
expected = np.rint(np.clip(expected, 0, 1) * 255.0).astype(np.uint8)
|
||
|
|
np.testing.assert_array_equal(produced, expected)
|
||
|
|
|
||
|
|
|
||
|
|
def test_device_reduction_rejects_non_5d() -> None:
|
||
|
|
"""A wrong-rank tensor is a programming error, not a silent reshape."""
|
||
|
|
with pytest.raises(ValueError, match=r"\[B, C, F, H, W\]"):
|
||
|
|
reduce_video_to_uint8_frames(torch.rand(3, 8, 64, 64))
|