[LingBot World] Speed up streaming VAE decode and RGB frame delivery (#7749)
Signed-off-by: tzhouam <tzhouam@connect.ust.hk> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
9f7dadaad5
commit
ba3d48e1c7
7 files changed
+915
-30
No files matched your search
@@ -0,0 +1,182 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""The exact Wan decoder fast path must be bit-identical to the plain streaming decode.
|
||||
|
||||
Two decoders are driven frame by frame through ``WanStreamingDecoder`` over two chunks of one session and
|
||||
a fresh session (the persistent buffers' causal front frames go zeros -> cache -> zeros), on CPU: plain
|
||||
diffusers modules in one process, and the spatially sharded wrappers across two gloo ranks with real halo
|
||||
exchange, each in fp32 and under bf16 autocast (where the parameter cast applies). Every output must
|
||||
``torch.equal`` the untouched decoder's on every rank.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
import torch.multiprocessing as mp
|
||||
from diffusers.models.autoencoders.autoencoder_kl_wan import WanCausalConv3d, WanDecoder3d
|
||||
|
||||
from vllm_omni.diffusion.distributed.autoencoders.wan_decoder_fast_path import install_wan_decoder_fast_path
|
||||
from vllm_omni.diffusion.distributed.autoencoders.wan_spatial_shard import (
|
||||
WanDistCausalConv3d,
|
||||
WanDistConv2d,
|
||||
install_wan_spatial_shard_decode,
|
||||
)
|
||||
from vllm_omni.experimental.ar_diffusion.streaming_decode import WanStreamingDecoder
|
||||
|
||||
|
||||
def _make_vae(seed: int = 0) -> SimpleNamespace:
|
||||
torch.manual_seed(seed)
|
||||
# Three levels: the default temporal-upsample list gives one upsample2d and one upsample3d resample.
|
||||
decoder = WanDecoder3d(dim=8, z_dim=4, dim_mult=[1, 2, 4], num_res_blocks=1, attn_scales=[])
|
||||
post_quant_conv = WanCausalConv3d(4, 4, 1)
|
||||
for p in list(decoder.parameters()) + list(post_quant_conv.parameters()):
|
||||
p.data.uniform_(-0.5, 0.5)
|
||||
decoder.eval()
|
||||
count = sum(1 for m in decoder.modules() if m.__class__.__name__ == "WanCausalConv3d")
|
||||
return SimpleNamespace(decoder=decoder, post_quant_conv=post_quant_conv, _cached_conv_counts={"decoder": count})
|
||||
|
||||
|
||||
def _run(vae, latents: list[torch.Tensor], autocast: bool) -> list[torch.Tensor]:
|
||||
decoder = WanStreamingDecoder(vae)
|
||||
outs = []
|
||||
ctx = torch.autocast("cpu", dtype=torch.bfloat16) if autocast else torch.no_grad()
|
||||
with torch.no_grad(), ctx:
|
||||
state = decoder.new_decode_state("a")
|
||||
outs.append(decoder.decode_chunk(latents[0], state))
|
||||
outs.append(decoder.decode_chunk(latents[1], state))
|
||||
fresh = decoder.new_decode_state("b")
|
||||
outs.append(decoder.decode_chunk(latents[2], fresh))
|
||||
return [o.float() for o in outs]
|
||||
|
||||
|
||||
def _latents() -> list[torch.Tensor]:
|
||||
torch.manual_seed(1)
|
||||
return [torch.randn(1, 4, 2, 6, 10), torch.randn(1, 4, 1, 6, 10), torch.randn(1, 4, 2, 6, 10)]
|
||||
|
||||
|
||||
def _check_pair(reference, candidate, autocast: bool, sharded: bool, level: str = "exact") -> None:
|
||||
dtype = torch.bfloat16 if autocast else None
|
||||
counts = install_wan_decoder_fast_path(candidate, conv_dtype=dtype, level=level)
|
||||
assert counts["level"] == level
|
||||
assert counts["upsamples"] == 2
|
||||
convs = sum(1 for m in candidate.decoder.modules() if isinstance(m, torch.nn.Conv3d | torch.nn.Conv2d)) + 1
|
||||
assert counts["conv_params_cast"] == (convs if autocast else 0)
|
||||
dist_convs = sum(1 for m in candidate.decoder.modules() if isinstance(m, WanDistCausalConv3d | WanDistConv2d))
|
||||
assert counts["persistent_input_buffers"] == dist_convs
|
||||
assert (dist_convs > 0) == sharded
|
||||
if level == "fused":
|
||||
assert (
|
||||
counts["fused_norm_silu"]
|
||||
== 2 * sum(1 for m in candidate.decoder.modules() if m.__class__.__name__ == "WanResidualBlock") + 1
|
||||
)
|
||||
assert counts["channels_last_convs"] == convs
|
||||
assert counts["resample_forwards"] == 2
|
||||
with pytest.raises(ValueError, match="already installed"):
|
||||
install_wan_decoder_fast_path(candidate, conv_dtype=dtype, level="exact")
|
||||
# Idempotent: a second install is a no-op that reports the same counts.
|
||||
assert install_wan_decoder_fast_path(candidate, conv_dtype=dtype, level=level) is counts
|
||||
latents = _latents()
|
||||
expected = _run(reference, latents, autocast)
|
||||
actual = _run(candidate, latents, autocast)
|
||||
assert len(expected) == len(actual) == 3
|
||||
for e, a in zip(expected, actual):
|
||||
assert e.shape == a.shape
|
||||
if level == "exact":
|
||||
assert torch.equal(e, a)
|
||||
else:
|
||||
# The fused level changes layouts only on CPU (the kernels need CUDA); the eager norm's reduction
|
||||
# order may differ with the layout, so the outputs are close, not identical.
|
||||
assert torch.allclose(e, a, atol=1e-2 if autocast else 1e-5, rtol=1e-2 if autocast else 1e-4), (
|
||||
(e - a).abs().max()
|
||||
)
|
||||
if sharded:
|
||||
bufs = [m._input_buf for m in candidate.decoder.modules() if isinstance(m, WanDistCausalConv3d | WanDistConv2d)]
|
||||
assert all(b is not None for b in bufs)
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("autocast", [False, True])
|
||||
def test_fast_path_is_bit_identical(autocast: bool) -> None:
|
||||
reference = _make_vae()
|
||||
candidate = copy.deepcopy(reference)
|
||||
_check_pair(reference, candidate, autocast, sharded=False)
|
||||
|
||||
|
||||
def _swap_norms_to_rmsnorm_vae(vae) -> int:
|
||||
"""Ensure the decoder uses RMSNormVAE and return the number of matching norms.
|
||||
|
||||
Collecting pipeline tests can already apply patch_wan_rms_norm process-wide before this test runs.
|
||||
"""
|
||||
from vllm_omni.diffusion.models.wan2_2.norm import RMSNormVAE
|
||||
|
||||
count = 0
|
||||
for module in list(vae.decoder.modules()):
|
||||
for name, child in list(module.named_children()):
|
||||
if isinstance(child, RMSNormVAE):
|
||||
count += 1
|
||||
continue
|
||||
if child.__class__.__name__ == "WanRMS_norm":
|
||||
images = child.gamma.dim() == 3 # (dim, 1, 1) for the attention block's 4D norm
|
||||
norm = RMSNormVAE(child.gamma.shape[0], channel_first=child.channel_first, images=images, bias=False)
|
||||
norm.gamma.data.copy_(child.gamma.data.reshape(norm.gamma.shape))
|
||||
setattr(module, name, norm)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("norm", ["default", "RMSNormVAE"])
|
||||
@pytest.mark.parametrize("autocast", [False, True])
|
||||
def test_fused_level_plumbing_on_cpu(autocast: bool, norm: str) -> None:
|
||||
reference = _make_vae()
|
||||
if norm == "RMSNormVAE":
|
||||
assert _swap_norms_to_rmsnorm_vae(reference) > 0
|
||||
candidate = copy.deepcopy(reference)
|
||||
_check_pair(reference, candidate, autocast, sharded=False, level="fused")
|
||||
|
||||
|
||||
def _sharded_worker(rank: int, world_size: int, port: str, autocast: bool, return_dict, level: str = "exact") -> None:
|
||||
os.environ["MASTER_ADDR"] = "127.0.0.1"
|
||||
os.environ["MASTER_PORT"] = port
|
||||
dist.init_process_group("gloo", rank=rank, world_size=world_size)
|
||||
try:
|
||||
torch.manual_seed(0)
|
||||
reference = _make_vae()
|
||||
candidate = copy.deepcopy(reference)
|
||||
for vae in (reference, candidate):
|
||||
install_wan_spatial_shard_decode(vae, dist.group.WORLD, split_dim="width", dst=None)
|
||||
_check_pair(reference, candidate, autocast, sharded=True, level=level)
|
||||
return_dict[rank] = "ok"
|
||||
except BaseException as exc: # report, the parent asserts
|
||||
return_dict[rank] = f"{type(exc).__name__}: {exc}"
|
||||
raise
|
||||
finally:
|
||||
dist.destroy_process_group()
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("level", ["exact", "fused"])
|
||||
@pytest.mark.parametrize("autocast", [False, True])
|
||||
def test_sharded_fast_path_on_every_rank(autocast: bool, level: str) -> None:
|
||||
manager = mp.get_context("spawn").Manager()
|
||||
return_dict = manager.dict()
|
||||
port = str(29610 + int(autocast) + 2 * (level == "fused"))
|
||||
mp.spawn(_sharded_worker, args=(2, port, autocast, return_dict, level), nprocs=2, join=True)
|
||||
for rank in range(2):
|
||||
assert return_dict.get(rank) == "ok", f"rank {rank}: {return_dict.get(rank)}"
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
def test_conv_dtype_requires_half() -> None:
|
||||
with pytest.raises(ValueError, match="conv_dtype"):
|
||||
install_wan_decoder_fast_path(_make_vae(), conv_dtype=torch.float32)
|
||||
@@ -0,0 +1,192 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Exact fast path for the Wan VAE decoder on a streaming (frame-at-a-time) decode.
|
||||
|
||||
A kernel ledger of the LingBot-World served path (4xH200, 480x832, width-sharded bf16 decode) put the
|
||||
decoder at 91 ms of GPU time per chunk per rank, half of it in ~2,300 launches of glue around the
|
||||
convolutions: autocast re-casting every conv weight and bias on every call (the autocast weight cache only
|
||||
serves leaves that require grad, so under ``torch.no_grad`` it never hits), the nearest upsample's fp32
|
||||
round trip, and a freshly allocated, zero-filled, concatenated input tensor per conv call. None of that
|
||||
touches a value that reaches the convolution, so it can go without changing a single output bit:
|
||||
|
||||
- ``conv_dtype``: the decoder's convolution parameters are cast once to the dtype the decode already runs
|
||||
under (``decode_autocast_dtype``), the same cast autocast applied per call. Norm parameters stay in their
|
||||
own dtype, so the normalisation arithmetic and its fp32 promotion are untouched.
|
||||
- ``WanUpsample`` runs its nearest-exact gather on the activation dtype directly instead of via
|
||||
``x.float()`` and ``type_as``: a gather moves values, so the result is identical.
|
||||
- The spatially sharded conv wrappers keep one input buffer per conv across calls
|
||||
(``reuse_input_buffer``), writing only the activation interior and the halo slots into it.
|
||||
|
||||
Numerics: bit-identical to the plain path (``tests/diffusion/distributed/test_wan_decoder_fast_path.py``).
|
||||
Memory: one persistent input buffer per sharded conv (about the size of that conv's input).
|
||||
|
||||
Level ``"fused"`` adds the rest of what the ledger showed, at the cost of exactness (it is gated with the bf16
|
||||
decode option, whose quality gate it shares): the decoder runs channels_last_3d end to end so cuDNN uses its
|
||||
NHWC kernels without the NCHW<->NHWC transposes it otherwise inserts around every convolution, every
|
||||
``WanRMS_norm -> SiLU`` chain becomes one Triton kernel that reads and writes the activation dtype (the eager
|
||||
chain promotes to fp32 at ``* gamma`` and leaves the cast to the next convolution), the nearest upsample is a
|
||||
channels_last gather, and the ``upsample3d`` time-pair interleave writes channels_last_3d directly. Values
|
||||
differ from eager only through the norm's fp32 reduction order and the single rounding at the SiLU output.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import MethodType
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.diffusion.distributed.autoencoders.wan_decoder_kernels import (
|
||||
can_use_wan_rmsnorm_silu,
|
||||
wan_rmsnorm_silu,
|
||||
wan_rmsnorm_silu_reference,
|
||||
)
|
||||
from vllm_omni.diffusion.distributed.autoencoders.wan_decoder_utils import (
|
||||
_dense_channels_last_3d,
|
||||
_is_nearest_upsample,
|
||||
_resample_forward_channels_last,
|
||||
_upsample_forward,
|
||||
_upsample_forward_channels_last,
|
||||
)
|
||||
from vllm_omni.diffusion.distributed.autoencoders.wan_spatial_shard import WanDistCausalConv3d, WanDistConv2d
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
_INSTALLED_ATTR = "_vllm_omni_wan_decoder_fast_path"
|
||||
LEVELS = ("exact", "fused")
|
||||
# diffusers' WanRMS_norm, and vLLM-Omni's RMSNormVAE that patch_wan_rms_norm swaps in for every Wan-family VAE.
|
||||
_NORM_CLASSES = ("WanRMS_norm", "RMSNormVAE")
|
||||
|
||||
|
||||
class FusedWanRMSNormSiLU(nn.Module):
|
||||
"""``WanRMS_norm`` followed by SiLU as one kernel on channels_last_3d CUDA tensors.
|
||||
|
||||
Keeps the norm's parameters registered under their own names (``...norm1.gamma``), so state dicts and
|
||||
weight loading are unchanged. Off the kernel's domain (CPU, other layouts, grad) it runs the eager op
|
||||
chain, and a channels-first CUDA input is converted to channels_last_3d once (the attention block's
|
||||
gathered frame is the only such producer).
|
||||
"""
|
||||
|
||||
def __init__(self, norm: nn.Module) -> None:
|
||||
super().__init__()
|
||||
self.gamma = norm.gamma
|
||||
bias = norm.bias
|
||||
if isinstance(bias, torch.Tensor):
|
||||
self.bias = bias
|
||||
self._bias_value = 0.0
|
||||
else:
|
||||
self.bias = None
|
||||
self._bias_value = float(bias) if bias is not None else 0.0
|
||||
self.scale = float(norm.scale)
|
||||
# diffusers' WanRMS_norm normalises x.float() with F.normalize's default eps; vLLM-Omni's RMSNormVAE
|
||||
# (patched in for every Wan-family VAE it loads) normalises x itself with its own epsilon.
|
||||
self.upcast = norm.__class__.__name__ == "WanRMS_norm"
|
||||
self.eps = 1e-12 if self.upcast else float(getattr(norm, "epsilon", 1e-6))
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
if x.dim() == 5 and x.is_cuda and not (torch.is_grad_enabled() and x.requires_grad):
|
||||
if not _dense_channels_last_3d(x):
|
||||
x = x.contiguous(memory_format=torch.channels_last_3d)
|
||||
if can_use_wan_rmsnorm_silu(x, self.gamma, self.bias):
|
||||
return wan_rmsnorm_silu(x, self.gamma, self.bias, self.scale, self.eps, self.upcast)
|
||||
bias = self.bias if self.bias is not None else self._bias_value
|
||||
return wan_rmsnorm_silu_reference(x, self.gamma, bias, self.scale, self.eps, self.upcast)
|
||||
|
||||
|
||||
def _install_fused(decoder: nn.Module, post_quant_conv: nn.Module | None, counts: dict[str, int]) -> None:
|
||||
"""Channels_last weights and buffers, fused norm+SiLU, layout-preserving resample. Fails closed."""
|
||||
blocks = [m for m in decoder.modules() if m.__class__.__name__ == "WanResidualBlock"]
|
||||
for m in blocks:
|
||||
for name in ("norm1", "norm2"):
|
||||
norm = getattr(m, name, None)
|
||||
if norm.__class__.__name__ not in _NORM_CLASSES or not getattr(norm, "channel_first", False):
|
||||
raise ValueError(
|
||||
f"fused fast path needs a channel-first Wan RMSNorm at {name}; got {type(norm).__name__}"
|
||||
)
|
||||
if not isinstance(m.nonlinearity, nn.SiLU) or m.nonlinearity.inplace:
|
||||
raise ValueError("fused fast path needs a plain SiLU residual block nonlinearity")
|
||||
head_norm = getattr(decoder, "norm_out", None)
|
||||
if head_norm.__class__.__name__ not in _NORM_CLASSES or not isinstance(
|
||||
getattr(decoder, "nonlinearity", None), nn.SiLU
|
||||
):
|
||||
raise ValueError("fused fast path needs the standard WanRMS_norm + SiLU decoder head")
|
||||
for m in blocks:
|
||||
m.norm1 = FusedWanRMSNormSiLU(m.norm1)
|
||||
m.norm2 = FusedWanRMSNormSiLU(m.norm2)
|
||||
m.nonlinearity = nn.Identity()
|
||||
counts["fused_norm_silu"] += 2
|
||||
decoder.norm_out = FusedWanRMSNormSiLU(decoder.norm_out)
|
||||
decoder.nonlinearity = nn.Identity()
|
||||
counts["fused_norm_silu"] += 1
|
||||
roots = [decoder] + ([post_quant_conv] if post_quant_conv is not None else [])
|
||||
for root in roots:
|
||||
for module in root.modules():
|
||||
if isinstance(module, nn.Conv3d):
|
||||
module.weight.data = module.weight.data.contiguous(memory_format=torch.channels_last_3d)
|
||||
counts["channels_last_convs"] += 1
|
||||
elif isinstance(module, nn.Conv2d):
|
||||
module.weight.data = module.weight.data.contiguous(memory_format=torch.channels_last)
|
||||
counts["channels_last_convs"] += 1
|
||||
if isinstance(module, WanDistCausalConv3d):
|
||||
module.input_memory_format = torch.channels_last_3d
|
||||
elif isinstance(module, WanDistConv2d):
|
||||
module.input_memory_format = torch.channels_last
|
||||
if _is_nearest_upsample(module):
|
||||
module.forward = MethodType(_upsample_forward_channels_last, module)
|
||||
if module.__class__.__name__ == "WanResample" and str(getattr(module, "mode", "")).startswith("upsample"):
|
||||
module.forward = MethodType(_resample_forward_channels_last, module)
|
||||
counts["resample_forwards"] += 1
|
||||
|
||||
|
||||
def install_wan_decoder_fast_path(vae: Any, *, conv_dtype: torch.dtype | None, level: str = "exact") -> dict[str, int]:
|
||||
"""Install the fast path on ``vae``'s decoder (and ``post_quant_conv``); idempotent.
|
||||
|
||||
``conv_dtype`` must be the dtype the decode runs under (``decode_autocast_dtype``): with ``None`` the
|
||||
parameters are left alone, because casting them without autocast would change the convolution's
|
||||
arithmetic rather than remove a cast. ``level`` is ``"exact"`` or ``"fused"`` (see the module docstring).
|
||||
"""
|
||||
if level not in LEVELS:
|
||||
raise ValueError(f"level must be one of {LEVELS}; got {level!r}")
|
||||
installed = getattr(vae, _INSTALLED_ATTR, None)
|
||||
if installed is not None:
|
||||
if installed.get("level") != level:
|
||||
raise ValueError(f"Wan decoder fast path already installed at level {installed.get('level')!r}")
|
||||
return installed
|
||||
decoder = getattr(vae, "decoder", None)
|
||||
if decoder is None:
|
||||
raise ValueError("Wan decoder fast path requires a decoder module.")
|
||||
if conv_dtype is not None and conv_dtype not in (torch.float16, torch.bfloat16):
|
||||
raise ValueError(f"conv_dtype must be float16, bfloat16 or None; got {conv_dtype!r}")
|
||||
roots = [decoder]
|
||||
post_quant_conv = getattr(vae, "post_quant_conv", None)
|
||||
if isinstance(post_quant_conv, nn.Module):
|
||||
roots.append(post_quant_conv)
|
||||
counts: dict[str, Any] = {
|
||||
"level": level,
|
||||
"conv_params_cast": 0,
|
||||
"upsamples": 0,
|
||||
"persistent_input_buffers": 0,
|
||||
"fused_norm_silu": 0,
|
||||
"channels_last_convs": 0,
|
||||
"resample_forwards": 0,
|
||||
}
|
||||
for root in roots:
|
||||
for module in root.modules():
|
||||
if conv_dtype is not None and isinstance(module, (nn.Conv2d, nn.Conv3d)):
|
||||
if any(p.dtype != conv_dtype for p in module.parameters(recurse=False)):
|
||||
module.to(dtype=conv_dtype)
|
||||
counts["conv_params_cast"] += 1
|
||||
if _is_nearest_upsample(module) and not getattr(module, "_vllm_omni_fast_path_upsample", False):
|
||||
module.forward = MethodType(_upsample_forward, module)
|
||||
module._vllm_omni_fast_path_upsample = True
|
||||
counts["upsamples"] += 1
|
||||
if isinstance(module, (WanDistCausalConv3d, WanDistConv2d)):
|
||||
module.reuse_input_buffer = True
|
||||
counts["persistent_input_buffers"] += 1
|
||||
if level == "fused":
|
||||
_install_fused(decoder, post_quant_conv if isinstance(post_quant_conv, nn.Module) else None, counts)
|
||||
setattr(vae, _INSTALLED_ATTR, counts)
|
||||
logger.info("Installed the %s Wan decoder fast path (conv dtype %s): %s", level, conv_dtype, counts)
|
||||
return counts
|
||||
@@ -0,0 +1,278 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Fused kernels for the Wan VAE decoder on channels_last_3d activations.
|
||||
|
||||
Adapted from SGLang's ``sglang.kernels.ops.diffusion`` (``norm/wan_rmsnorm_silu_triton.py`` and
|
||||
``layout/nearest_upsample_nhwc_triton.py``, Apache-2.0), reduced to what the LingBot-World streaming decode
|
||||
uses. Each kernel comes with a ``can_use_*`` predicate and a torch reference of the eager op chain it
|
||||
replaces; the predicate decides, the kernel raises on an input it does not support.
|
||||
|
||||
- :func:`wan_rmsnorm_silu`: ``SiLU(F.normalize(x, dim=1) * scale * gamma + bias)`` on a dense channels_last_3d
|
||||
``[B, C, T, H, W]`` tensor, one program per pixel. fp32 channel statistics; the intermediate dtype
|
||||
boundaries of the eager chain are kept (normalise and ``* scale`` in ``x.dtype``, ``* gamma`` in the
|
||||
promoted dtype, SiLU in fp32) and the result is stored in ``x.dtype``, which is the dtype the following
|
||||
convolution reads under autocast anyway. Not bit-identical to aten (the channel reduction order differs),
|
||||
so it is gated with the bf16 decode option rather than mounted unconditionally.
|
||||
- :func:`nearest_upsample_nhwc`: integer-factor nearest upsample of a dense channels_last ``[N, C, H, W]``
|
||||
tensor as a gather, bit-exact vs ``nn.Upsample`` in ``nearest`` and ``nearest-exact`` modes; aten's own NHWC
|
||||
nearest kernel is several times slower than its NCHW one, which is what a channels_last decoder would hit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
try: # Triton is optional: CPU tests and non-CUDA platforms use the torch references.
|
||||
import triton
|
||||
import triton.language as tl
|
||||
|
||||
_HAS_TRITON = True
|
||||
except ImportError: # pragma: no cover
|
||||
_HAS_TRITON = False
|
||||
|
||||
_MAX_CHANNELS = 1024
|
||||
_MAX_INT32 = 2**31 - 1
|
||||
_SUPPORTED_DTYPES = (torch.bfloat16, torch.float16, torch.float32)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- RMSNorm + SiLU
|
||||
def wan_rmsnorm_silu_reference(
|
||||
x: torch.Tensor,
|
||||
gamma: torch.Tensor,
|
||||
bias: torch.Tensor | float,
|
||||
scale: float,
|
||||
eps: float = 1e-12,
|
||||
upcast: bool = True,
|
||||
) -> torch.Tensor:
|
||||
"""The eager norm chain followed by SiLU, cast back to ``x.dtype``.
|
||||
|
||||
``upcast=True`` is diffusers' ``WanRMS_norm`` (normalise in fp32, round to ``x.dtype``); ``upcast=False``
|
||||
is vLLM-Omni's ``RMSNormVAE`` (``F.normalize`` on ``x`` itself, so every step rounds to ``x.dtype``).
|
||||
"""
|
||||
if upcast:
|
||||
normalized = F.normalize(x.float(), dim=1, eps=eps).to(x.dtype)
|
||||
else:
|
||||
normalized = F.normalize(x, dim=1, eps=eps)
|
||||
y = normalized * scale * gamma + bias
|
||||
return F.silu(y).to(x.dtype)
|
||||
|
||||
|
||||
def _dense_channels_last_3d(x: torch.Tensor) -> bool:
|
||||
if x.dim() != 5 or x.shape[1] <= 1:
|
||||
return False
|
||||
b, c, t, h, w = x.shape
|
||||
return x.stride() == (t * h * w * c, 1, h * w * c, w * c, c)
|
||||
|
||||
|
||||
def can_use_wan_rmsnorm_silu(x: torch.Tensor, gamma: torch.Tensor, bias: torch.Tensor | float | None) -> bool:
|
||||
return (
|
||||
_HAS_TRITON
|
||||
and x.is_cuda
|
||||
and not (torch.is_grad_enabled() and x.requires_grad)
|
||||
and x.dtype in _SUPPORTED_DTYPES
|
||||
and x.numel() > 0
|
||||
and 0 < x.shape[1] <= _MAX_CHANNELS
|
||||
and _dense_channels_last_3d(x)
|
||||
and gamma.is_cuda
|
||||
and gamma.device == x.device
|
||||
and gamma.numel() == x.shape[1]
|
||||
and (gamma.dtype == x.dtype or gamma.dtype == torch.float32)
|
||||
and (
|
||||
not isinstance(bias, torch.Tensor)
|
||||
or (bias.device == x.device and bias.numel() == x.shape[1] and bias.dtype in (x.dtype, torch.float32))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if _HAS_TRITON:
|
||||
|
||||
@triton.jit
|
||||
def _wan_rmsnorm_silu_kernel(
|
||||
x_ptr,
|
||||
gamma_ptr,
|
||||
bias_ptr,
|
||||
out_ptr,
|
||||
channels: tl.constexpr,
|
||||
rms_scale,
|
||||
eps,
|
||||
has_bias: tl.constexpr,
|
||||
round_affine: tl.constexpr,
|
||||
upcast: tl.constexpr,
|
||||
block_c: tl.constexpr,
|
||||
):
|
||||
row = tl.program_id(0).to(tl.int64)
|
||||
offsets = tl.arange(0, block_c)
|
||||
mask = offsets < channels
|
||||
# Dense channels_last_3d stores each pixel as one contiguous channel row.
|
||||
row_offsets = row * channels + offsets
|
||||
x = tl.load(x_ptr + row_offsets, mask=mask, other=0.0).to(tl.float32)
|
||||
norm = tl.sqrt(tl.sum(x * x, axis=0))
|
||||
if upcast:
|
||||
# WanRMS_norm: fp32 normalise, one rounding to x.dtype.
|
||||
y = (x / tl.maximum(norm, eps)).to(x_ptr.dtype.element_ty)
|
||||
else:
|
||||
# RMSNormVAE: F.normalize on x itself, so the norm, the clamp and the quotient each round to x.dtype.
|
||||
denom = tl.maximum(norm.to(x_ptr.dtype.element_ty).to(tl.float32), eps)
|
||||
denom = denom.to(x_ptr.dtype.element_ty).to(tl.float32)
|
||||
y = (x / denom).to(x_ptr.dtype.element_ty)
|
||||
# Then the eager boundaries: * scale in x.dtype, * gamma / + bias in the promoted dtype, SiLU in fp32,
|
||||
# stored in x.dtype.
|
||||
y = (y * rms_scale).to(x_ptr.dtype.element_ty)
|
||||
gamma = tl.load(gamma_ptr + offsets, mask=mask, other=1.0).to(tl.float32)
|
||||
y = y.to(tl.float32) * gamma
|
||||
if round_affine:
|
||||
# gamma (and bias) share x's dtype: the eager product and sum are rounded to it.
|
||||
y = y.to(x_ptr.dtype.element_ty).to(tl.float32)
|
||||
if has_bias:
|
||||
bias = tl.load(bias_ptr + offsets, mask=mask, other=0.0).to(tl.float32)
|
||||
y = y + bias
|
||||
if round_affine:
|
||||
y = y.to(x_ptr.dtype.element_ty).to(tl.float32)
|
||||
y = y * tl.sigmoid(y)
|
||||
tl.store(out_ptr + row_offsets, y, mask=mask)
|
||||
|
||||
|
||||
def wan_rmsnorm_silu(
|
||||
x: torch.Tensor,
|
||||
gamma: torch.Tensor,
|
||||
bias: torch.Tensor | float | None,
|
||||
scale: float,
|
||||
eps: float = 1e-12,
|
||||
upcast: bool = True,
|
||||
) -> torch.Tensor:
|
||||
"""Fused ``SiLU(normalize(x) * scale * gamma + bias)`` on a dense channels_last_3d tensor; raises otherwise."""
|
||||
if not can_use_wan_rmsnorm_silu(x, gamma, bias):
|
||||
raise ValueError(
|
||||
"wan_rmsnorm_silu needs a CUDA, dense channels_last_3d, bf16/fp16/fp32 [B, C, T, H, W] tensor with "
|
||||
f"C <= {_MAX_CHANNELS}; got {x.device.type} {tuple(x.shape)} {x.dtype} strides {tuple(x.stride())}"
|
||||
)
|
||||
b, c, t, h, w = x.shape
|
||||
out = torch.empty_strided(x.shape, x.stride(), device=x.device, dtype=x.dtype)
|
||||
has_bias = isinstance(bias, torch.Tensor)
|
||||
bias_arg = bias.reshape(-1) if isinstance(bias, torch.Tensor) else gamma
|
||||
# With affine parameters in x's dtype the eager chain rounds after ``* gamma`` and ``+ bias``; fp32
|
||||
# parameters promote the chain to fp32 and the only rounding left is the store.
|
||||
round_affine = gamma.dtype == x.dtype and x.dtype != torch.float32
|
||||
block_c = triton.next_power_of_2(c)
|
||||
num_warps = 1 if block_c <= 64 else 4 if block_c <= 512 else 8
|
||||
with torch.get_device_module(x.device).device(x.device):
|
||||
_wan_rmsnorm_silu_kernel[(b * t * h * w,)](
|
||||
x,
|
||||
gamma.reshape(-1),
|
||||
bias_arg,
|
||||
out,
|
||||
c,
|
||||
float(scale),
|
||||
float(eps),
|
||||
has_bias,
|
||||
round_affine,
|
||||
upcast,
|
||||
block_c,
|
||||
num_warps=num_warps,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- nearest upsample, NHWC
|
||||
def _integer_scale(scale) -> tuple[int, int] | None:
|
||||
if isinstance(scale, bool):
|
||||
return None
|
||||
if isinstance(scale, (int, float)):
|
||||
scale = (scale, scale)
|
||||
if not isinstance(scale, (tuple, list)) or len(scale) != 2:
|
||||
return None
|
||||
out = []
|
||||
for s in scale:
|
||||
if isinstance(s, bool) or not isinstance(s, (int, float)):
|
||||
return None
|
||||
f = float(s)
|
||||
if not math.isfinite(f) or f < 1.0 or f != int(f):
|
||||
return None
|
||||
out.append(int(f))
|
||||
return out[0], out[1]
|
||||
|
||||
|
||||
def canonical_nhwc(x: torch.Tensor) -> bool:
|
||||
"""Dense channels_last with ``C > 1`` and canonical strides on every dim, size-1 dims included."""
|
||||
if x.dim() != 4:
|
||||
return False
|
||||
_, c, h, w = x.shape
|
||||
return c > 1 and x.stride() == (h * w * c, 1, w * c, c)
|
||||
|
||||
|
||||
def can_use_nearest_upsample_nhwc(x: torch.Tensor, scale_factor, mode: str) -> bool:
|
||||
return (
|
||||
_HAS_TRITON
|
||||
and isinstance(x, torch.Tensor)
|
||||
and x.is_cuda
|
||||
and not (torch.is_grad_enabled() and x.requires_grad)
|
||||
and mode in ("nearest", "nearest-exact")
|
||||
and x.dim() == 4
|
||||
and x.numel() > 0
|
||||
and x.dtype in _SUPPORTED_DTYPES
|
||||
and canonical_nhwc(x)
|
||||
and _integer_scale(scale_factor) is not None
|
||||
)
|
||||
|
||||
|
||||
if _HAS_TRITON:
|
||||
|
||||
@triton.jit
|
||||
def _nearest_upsample_nhwc_kernel(
|
||||
x_ptr, out_ptr, total, channels, out_h, out_w, fh, fw, sxn, sxh, sxw, idx64: tl.constexpr, block: tl.constexpr
|
||||
):
|
||||
pid = tl.program_id(0)
|
||||
if idx64:
|
||||
offs = pid.to(tl.int64) * block + tl.arange(0, block).to(tl.int64)
|
||||
else:
|
||||
offs = pid * block + tl.arange(0, block)
|
||||
mask = offs < total
|
||||
# Output is dense NHWC: offs = ((n * out_h + h) * out_w + w) * channels + c.
|
||||
c = offs % channels
|
||||
t = offs // channels
|
||||
w = t % out_w
|
||||
t = t // out_w
|
||||
h = t % out_h
|
||||
n = t // out_h
|
||||
src = n * sxn + (h // fh) * sxh + (w // fw) * sxw + c
|
||||
vals = tl.load(x_ptr + src, mask=mask)
|
||||
tl.store(out_ptr + offs, vals, mask=mask)
|
||||
|
||||
|
||||
def nearest_upsample_nhwc(x: torch.Tensor, scale_factor) -> torch.Tensor:
|
||||
"""Integer-factor nearest upsample of a canonical channels_last ``[N, C, H, W]`` tensor, dense channels_last out."""
|
||||
factors = _integer_scale(scale_factor)
|
||||
if factors is None or not can_use_nearest_upsample_nhwc(x, scale_factor, "nearest"):
|
||||
raise ValueError(
|
||||
"nearest_upsample_nhwc needs a CUDA, canonical channels_last 4D bf16/fp16/fp32 tensor and an integer "
|
||||
f"scale; got {x.device.type} {tuple(x.shape)} strides {tuple(x.stride())} scale {scale_factor!r}"
|
||||
)
|
||||
fh, fw = factors
|
||||
n, c, h, w = x.shape
|
||||
out_h, out_w = h * fh, w * fw
|
||||
out = torch.empty((n, c, out_h, out_w), device=x.device, dtype=x.dtype, memory_format=torch.channels_last)
|
||||
total = out.numel()
|
||||
if total == 0:
|
||||
return out
|
||||
sxn, _, sxh, sxw = x.stride()
|
||||
block = 1024
|
||||
with torch.get_device_module(x.device).device(x.device):
|
||||
_nearest_upsample_nhwc_kernel[(triton.cdiv(total, block),)](
|
||||
x,
|
||||
out,
|
||||
total,
|
||||
c,
|
||||
out_h,
|
||||
out_w,
|
||||
fh,
|
||||
fw,
|
||||
sxn,
|
||||
sxh,
|
||||
sxw,
|
||||
idx64=total >= _MAX_INT32 or x.numel() >= _MAX_INT32,
|
||||
block=block,
|
||||
)
|
||||
return out
|
||||
@@ -0,0 +1,114 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Utility functions for the streaming Wan decoder fast path."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
from vllm_omni.diffusion.distributed.autoencoders.wan_decoder_kernels import (
|
||||
can_use_nearest_upsample_nhwc,
|
||||
canonical_nhwc,
|
||||
nearest_upsample_nhwc,
|
||||
)
|
||||
|
||||
_CACHE_T = 2 # diffusers.models.autoencoders.autoencoder_kl_wan.CACHE_T
|
||||
|
||||
|
||||
def _upsample_forward(self: nn.Upsample, x: torch.Tensor) -> torch.Tensor:
|
||||
# ``WanUpsample.forward`` is ``super().forward(x.float()).type_as(x)``; a nearest gather never changes a
|
||||
# value, so running it on ``x`` itself is the same tensor without the two casts.
|
||||
return nn.Upsample.forward(self, x)
|
||||
|
||||
|
||||
def _is_nearest_upsample(module: nn.Module) -> bool:
|
||||
return (
|
||||
module.__class__.__name__ == "WanUpsample"
|
||||
and isinstance(module, nn.Upsample)
|
||||
and str(getattr(module, "mode", "")) in ("nearest", "nearest-exact")
|
||||
)
|
||||
|
||||
|
||||
def _upsample_forward_channels_last(self: nn.Upsample, x: torch.Tensor) -> torch.Tensor:
|
||||
if x.dim() == 4 and self.size is None and can_use_nearest_upsample_nhwc(x, self.scale_factor, self.mode):
|
||||
return nearest_upsample_nhwc(x, self.scale_factor)
|
||||
return nn.Upsample.forward(self, x)
|
||||
|
||||
|
||||
def _dense_channels_last_3d(x: torch.Tensor) -> bool:
|
||||
if x.dim() != 5 or x.shape[1] <= 1:
|
||||
return False
|
||||
b, c, t, h, w = x.shape
|
||||
return x.stride() == (t * h * w * c, 1, h * w * c, w * c, c)
|
||||
|
||||
|
||||
def _interleave_time_pairs(x: torch.Tensor, b: int, c: int, t: int, h: int, w: int) -> torch.Tensor:
|
||||
"""``[B, 2C, T, H, W] -> [B, C, 2T, H, W]``: the time-conv's two channel halves interleaved along time.
|
||||
|
||||
Same values as the eager ``reshape / stack / reshape``; on a dense channels_last_3d input the result is
|
||||
written channels_last_3d with one copy instead of materialising it channels-first.
|
||||
"""
|
||||
if _dense_channels_last_3d(x):
|
||||
out = torch.empty((b, c, t * 2, h, w), device=x.device, dtype=x.dtype, memory_format=torch.channels_last_3d)
|
||||
out.view(b, c, t, 2, h, w).copy_(x.view(b, 2, c, t, h, w).permute(0, 2, 3, 1, 4, 5))
|
||||
return out
|
||||
x = x.reshape(b, 2, c, t, h, w)
|
||||
x = torch.stack((x[:, 0], x[:, 1]), 3)
|
||||
return x.reshape(b, c, t * 2, h, w)
|
||||
|
||||
|
||||
def _frames_nhwc(x: torch.Tensor) -> torch.Tensor:
|
||||
"""``[B, C, T, H, W] -> [B*T, C, H, W]`` for the 2D resample, with canonical NHWC strides when the data is
|
||||
laid out that way (a size-1 batch leaves a degenerate stride that aten's layout test rejects)."""
|
||||
b, c, t, h, w = x.shape
|
||||
frames = x.permute(0, 2, 1, 3, 4).reshape(b * t, c, h, w)
|
||||
if frames.is_contiguous(memory_format=torch.channels_last) and not canonical_nhwc(frames) and frames.stride(1) == 1:
|
||||
frames = frames.as_strided((b * t, c, h, w), (h * w * c, 1, w * c, c))
|
||||
return frames
|
||||
|
||||
|
||||
def _resample_forward_channels_last(self: nn.Module, x: torch.Tensor, feat_cache=None, feat_idx=[0]) -> torch.Tensor: # noqa: B006
|
||||
"""``WanResample.forward`` for the upsample modes with the layout-preserving interleave and frame view."""
|
||||
b, c, t, h, w = x.size()
|
||||
if self.mode == "upsample3d" and feat_cache is not None:
|
||||
idx = feat_idx[0]
|
||||
if feat_cache[idx] is None:
|
||||
feat_cache[idx] = "Rep"
|
||||
feat_idx[0] += 1
|
||||
else:
|
||||
cache_x = x[:, :, -_CACHE_T:, :, :].clone()
|
||||
if cache_x.shape[2] < 2 and feat_cache[idx] is not None and feat_cache[idx] != "Rep":
|
||||
cache_x = torch.cat([feat_cache[idx][:, :, -1, :, :].unsqueeze(2).to(cache_x.device), cache_x], dim=2)
|
||||
if cache_x.shape[2] < 2 and feat_cache[idx] is not None and feat_cache[idx] == "Rep":
|
||||
cache_x = torch.cat([torch.zeros_like(cache_x).to(cache_x.device), cache_x], dim=2)
|
||||
if feat_cache[idx] == "Rep":
|
||||
x = self.time_conv(x)
|
||||
else:
|
||||
x = self.time_conv(x, feat_cache[idx])
|
||||
feat_cache[idx] = cache_x
|
||||
feat_idx[0] += 1
|
||||
x = _interleave_time_pairs(x, b, c, t, h, w)
|
||||
t = x.shape[2]
|
||||
frames = self.resample(_frames_nhwc(x))
|
||||
return frames.view(b, t, frames.size(1), frames.size(2), frames.size(3)).permute(0, 2, 1, 3, 4)
|
||||
|
||||
|
||||
def _persistent_input_buffer(module: nn.Module, shape: tuple[int, ...], reference: torch.Tensor) -> torch.Tensor:
|
||||
"""One zero-initialised input buffer per conv, reallocated only when the shape, dtype or device changes.
|
||||
|
||||
The callers write only the activation interior and the halo slots, so the padding rows and columns stay
|
||||
zero for the buffer's lifetime and are never filled again.
|
||||
"""
|
||||
memory_format = getattr(module, "input_memory_format", torch.contiguous_format)
|
||||
buf = module._input_buf
|
||||
if (
|
||||
buf is None
|
||||
or buf.shape != shape
|
||||
or buf.dtype != reference.dtype
|
||||
or buf.device != reference.device
|
||||
or not buf.is_contiguous(memory_format=memory_format)
|
||||
):
|
||||
buf = torch.empty(shape, dtype=reference.dtype, device=reference.device, memory_format=memory_format).zero_()
|
||||
module._input_buf = buf
|
||||
return buf
|
||||
@@ -34,6 +34,7 @@ from diffusers.models.autoencoders.autoencoder_kl_wan import unpatchify
|
||||
from diffusers.models.autoencoders.vae import DecoderOutput
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.diffusion.distributed.autoencoders.wan_decoder_utils import _persistent_input_buffer
|
||||
from vllm_omni.diffusion.models.interface import DecodedChunkConsumer
|
||||
|
||||
logger = init_logger(__name__)
|
||||
@@ -241,7 +242,10 @@ def gather_and_trim_extent(
|
||||
|
||||
|
||||
def _ensure_recv_buf(recv_buf: torch.Tensor | None, reference: torch.Tensor) -> torch.Tensor:
|
||||
memory_format = _halo_memory_format(reference)
|
||||
# Send and receive buffers are dense in the default order whatever the activation's layout: every
|
||||
# backend accepts that (gloo rejects a channels_last buffer), the halo is a single row or column, and the
|
||||
# copy into the assembled input handles the layout.
|
||||
memory_format = torch.contiguous_format
|
||||
if (
|
||||
recv_buf is None
|
||||
or recv_buf.shape != reference.shape
|
||||
@@ -271,7 +275,7 @@ def _halo_exchange_p2p(
|
||||
p2p_ops = []
|
||||
if rank > 0:
|
||||
prev_rank = _global_rank(group, rank - 1)
|
||||
top_row = top_row_ref.contiguous(memory_format=_halo_memory_format(top_row_ref))
|
||||
top_row = top_row_ref.contiguous()
|
||||
p2p_ops.append(dist.P2POp(dist.irecv, recv_top_buf, prev_rank, group))
|
||||
p2p_ops.append(dist.P2POp(dist.isend, top_row, prev_rank, group))
|
||||
else:
|
||||
@@ -279,7 +283,7 @@ def _halo_exchange_p2p(
|
||||
|
||||
if rank < world_size - 1:
|
||||
next_rank = _global_rank(group, rank + 1)
|
||||
bottom_row = bottom_row_ref.contiguous(memory_format=_halo_memory_format(bottom_row_ref))
|
||||
bottom_row = bottom_row_ref.contiguous()
|
||||
p2p_ops.append(dist.P2POp(dist.isend, bottom_row, next_rank, group))
|
||||
p2p_ops.append(dist.P2POp(dist.irecv, recv_bottom_buf, next_rank, group))
|
||||
else:
|
||||
@@ -402,18 +406,27 @@ class WanDistConv2d(nn.Conv2d):
|
||||
self._halo_recv_top_buf: torch.Tensor | None = None
|
||||
self._halo_recv_bottom_buf: torch.Tensor | None = None
|
||||
self._trim_cache: dict[int, tuple[int, int, int]] = {}
|
||||
# Opt-in (install_wan_decoder_fast_path): assemble the conv input in one buffer kept across calls
|
||||
# instead of allocating, zero-filling and concatenating a fresh one every call. Same bytes.
|
||||
self.reuse_input_buffer = False
|
||||
self.input_memory_format = torch.contiguous_format
|
||||
self._input_buf: torch.Tensor | None = None
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
x = F.pad(x, self._non_split_padding)
|
||||
x_padded, self._halo_recv_top_buf, self._halo_recv_bottom_buf = halo_exchange(
|
||||
x,
|
||||
group=self.group,
|
||||
halo_size=self.halo_size,
|
||||
split_dim=self.split_dim,
|
||||
recv_top_buf=self._halo_recv_top_buf,
|
||||
recv_bottom_buf=self._halo_recv_bottom_buf,
|
||||
)
|
||||
shift, start, upper_bound = self._get_trim_params(x.shape[self.split_tensor_dim])
|
||||
if self.reuse_input_buffer:
|
||||
x_padded, local_extent = self._assemble_input(x)
|
||||
else:
|
||||
x = F.pad(x, self._non_split_padding)
|
||||
x_padded, self._halo_recv_top_buf, self._halo_recv_bottom_buf = halo_exchange(
|
||||
x,
|
||||
group=self.group,
|
||||
halo_size=self.halo_size,
|
||||
split_dim=self.split_dim,
|
||||
recv_top_buf=self._halo_recv_top_buf,
|
||||
recv_bottom_buf=self._halo_recv_bottom_buf,
|
||||
)
|
||||
local_extent = x.shape[self.split_tensor_dim]
|
||||
shift, start, upper_bound = self._get_trim_params(local_extent)
|
||||
if shift:
|
||||
x_padded = _narrow_along_dim(
|
||||
x_padded,
|
||||
@@ -425,6 +438,47 @@ class WanDistConv2d(nn.Conv2d):
|
||||
out = _trim_local_conv_output(out, self.halo_size, start, upper_bound, split_dim=self.split_dim)
|
||||
return _zero_invalid_extent(out, split_dim=self.split_dim)
|
||||
|
||||
def _assemble_input(self, x: torch.Tensor) -> tuple[torch.Tensor, int]:
|
||||
"""The padded, halo-exchanged conv input built in one buffer that persists across calls.
|
||||
|
||||
Byte for byte what ``F.pad`` followed by ``halo_exchange`` produced -- the non-split zero padding
|
||||
around the activation, the neighbours' halo rows or columns outside it -- without the two
|
||||
allocations and the three copies those took per call. Returns the assembled tensor and the extent
|
||||
along the split dimension before halos (what the trim parameters are keyed by).
|
||||
"""
|
||||
left, right, top, bottom = self._non_split_padding
|
||||
rank, world_size = _rank_world(self.group)
|
||||
halo = self.halo_size if world_size > 1 else 0
|
||||
dim = self.split_tensor_dim
|
||||
halo_h = halo if self.split_dim == "height" else 0
|
||||
halo_w = halo if self.split_dim == "width" else 0
|
||||
batch, channels, height, width = x.shape
|
||||
total_height = halo_h + top + height + bottom + halo_h
|
||||
total_width = halo_w + left + width + right + halo_w
|
||||
buf = _persistent_input_buffer(self, (batch, channels, total_height, total_width), x)
|
||||
buf[:, :, halo_h + top : halo_h + top + height, halo_w + left : halo_w + left + width].copy_(x)
|
||||
local_extent = buf.shape[dim] - 2 * halo
|
||||
if halo:
|
||||
# What halo_exchange sends: the padded activation's first and last ``halo`` rows along the
|
||||
# split dimension, non-split padding included; the neighbours' rows land in the outer slots.
|
||||
padded = _narrow_along_dim(buf, dim, halo, local_extent)
|
||||
top_row_ref = _narrow_along_dim(padded, dim, 0, halo)
|
||||
bottom_row_ref = _narrow_along_dim(padded, dim, local_extent - halo, halo)
|
||||
self._halo_recv_top_buf = _ensure_recv_buf(self._halo_recv_top_buf, top_row_ref)
|
||||
self._halo_recv_bottom_buf = _ensure_recv_buf(self._halo_recv_bottom_buf, bottom_row_ref)
|
||||
_halo_exchange_p2p(
|
||||
rank=rank,
|
||||
world_size=world_size,
|
||||
group=self.group,
|
||||
top_row_ref=top_row_ref,
|
||||
bottom_row_ref=bottom_row_ref,
|
||||
recv_top_buf=self._halo_recv_top_buf,
|
||||
recv_bottom_buf=self._halo_recv_bottom_buf,
|
||||
)
|
||||
_narrow_along_dim(buf, dim, 0, halo).copy_(self._halo_recv_top_buf)
|
||||
_narrow_along_dim(buf, dim, buf.shape[dim] - halo, halo).copy_(self._halo_recv_bottom_buf)
|
||||
return buf, local_extent
|
||||
|
||||
def _get_trim_params(self, local_extent: int) -> tuple[int, int, int]:
|
||||
trim_params = self._trim_cache.get(local_extent)
|
||||
if trim_params is None:
|
||||
@@ -503,6 +557,11 @@ class WanDistCausalConv3d(nn.Conv3d):
|
||||
self._halo_recv_top_buf: torch.Tensor | None = None
|
||||
self._halo_recv_bottom_buf: torch.Tensor | None = None
|
||||
self._trim_cache: dict[int, tuple[int, int, int]] = {}
|
||||
# Opt-in (install_wan_decoder_fast_path): assemble the conv input in one buffer kept across calls
|
||||
# instead of allocating, zero-filling and concatenating a fresh one every call. Same bytes.
|
||||
self.reuse_input_buffer = False
|
||||
self.input_memory_format = torch.contiguous_format
|
||||
self._input_buf: torch.Tensor | None = None
|
||||
|
||||
def forward(self, x: torch.Tensor, cache_x: torch.Tensor | None = None) -> torch.Tensor:
|
||||
x_padded, local_extent = self._assemble_input(x, cache_x)
|
||||
@@ -550,22 +609,33 @@ class WanDistCausalConv3d(nn.Conv3d):
|
||||
total_frames = pad_t0 + cache_frames + frames + pad_t1
|
||||
total_height = top + height + pad_h1 + halo_h
|
||||
total_width = left + width + pad_w1 + halo_w
|
||||
buf = torch.empty((batch, channels, total_frames, total_height, total_width), dtype=x.dtype, device=x.device)
|
||||
# Zero only what the conv must read as zeros: the causal frames in
|
||||
# front (and any trailing ones) and the spatial padding rows/columns.
|
||||
# The halo columns are filled below.
|
||||
if pad_t0:
|
||||
buf[:, :, :pad_t0].zero_()
|
||||
if pad_t1:
|
||||
buf[:, :, total_frames - pad_t1 :].zero_()
|
||||
if pad_h0:
|
||||
buf[:, :, :, halo_h:top].zero_()
|
||||
if pad_h1:
|
||||
buf[:, :, :, total_height - halo_h - pad_h1 : total_height - halo_h].zero_()
|
||||
if pad_w0:
|
||||
buf[:, :, :, :, halo_w:left].zero_()
|
||||
if pad_w1:
|
||||
buf[:, :, :, :, total_width - halo_w - pad_w1 : total_width - halo_w].zero_()
|
||||
shape = (batch, channels, total_frames, total_height, total_width)
|
||||
if self.reuse_input_buffer:
|
||||
# The copies below touch only the activation interior and the halo slots, so the spatial padding
|
||||
# stays zero for the buffer's lifetime. The causal front frames alternate between zeros (a
|
||||
# session's first frame) and cached frames, so they are re-zeroed whenever this call needs zeros.
|
||||
buf = _persistent_input_buffer(self, shape, x)
|
||||
if pad_t0:
|
||||
buf[:, :, :pad_t0].zero_()
|
||||
if pad_t1:
|
||||
buf[:, :, total_frames - pad_t1 :].zero_()
|
||||
else:
|
||||
buf = torch.empty(shape, dtype=x.dtype, device=x.device)
|
||||
# Zero only what the conv must read as zeros: the causal frames in
|
||||
# front (and any trailing ones) and the spatial padding rows/columns.
|
||||
# The halo columns are filled below.
|
||||
if pad_t0:
|
||||
buf[:, :, :pad_t0].zero_()
|
||||
if pad_t1:
|
||||
buf[:, :, total_frames - pad_t1 :].zero_()
|
||||
if pad_h0:
|
||||
buf[:, :, :, halo_h:top].zero_()
|
||||
if pad_h1:
|
||||
buf[:, :, :, total_height - halo_h - pad_h1 : total_height - halo_h].zero_()
|
||||
if pad_w0:
|
||||
buf[:, :, :, :, halo_w:left].zero_()
|
||||
if pad_w1:
|
||||
buf[:, :, :, :, total_width - halo_w - pad_w1 : total_width - halo_w].zero_()
|
||||
interior = buf[:, :, pad_t0 : pad_t0 + cache_frames + frames, top : top + height, left : left + width]
|
||||
if cache_frames:
|
||||
interior[:, :, :cache_frames].copy_(cache_x)
|
||||
|
||||
@@ -26,6 +26,7 @@ from vllm.model_executor.models.utils import AutoWeightsLoader
|
||||
|
||||
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import DistributedAutoencoderKLWan
|
||||
from vllm_omni.diffusion.distributed.autoencoders.wan_decoder_fast_path import install_wan_decoder_fast_path
|
||||
from vllm_omni.diffusion.distributed.autoencoders.wan_spatial_shard import install_wan_spatial_shard_decode
|
||||
from vllm_omni.diffusion.distributed.utils import get_local_device
|
||||
from vllm_omni.diffusion.interaction.mixin import InteractionMixin
|
||||
@@ -61,6 +62,7 @@ from vllm_omni.diffusion.models.lingbot_world.transformer import (
|
||||
LingBotAttentionCache,
|
||||
LingBotTransformerCache,
|
||||
)
|
||||
from vllm_omni.diffusion.models.lingbot_world.utils import _vae_decode_fast_path
|
||||
from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin
|
||||
from vllm_omni.diffusion.models.schedulers import FlowUniPCMultistepScheduler
|
||||
from vllm_omni.diffusion.models.utils import _load_json
|
||||
@@ -338,7 +340,22 @@ def _uint8_frames(video: torch.Tensor) -> np.ndarray:
|
||||
# the decoder dtype, then widen, scale and round.
|
||||
frames = (video[0] / 2 + 0.5).clamp(0, 1).permute(1, 2, 3, 0)
|
||||
frames = frames.float().mul_(255).round_().to(torch.uint8)
|
||||
return np.ascontiguousarray(frames.cpu().numpy())
|
||||
pixels = frames.cpu().numpy()
|
||||
frame_count, height, width, channels = pixels.shape
|
||||
# A planar GPU result stays planar after the host copy. NumPy's generic
|
||||
# contiguous conversion is slow for this three-channel interleave; copying
|
||||
# each plane into its output channel preserves every byte and vectorizes it.
|
||||
if channels == 3 and pixels.strides == (
|
||||
height * width,
|
||||
width,
|
||||
1,
|
||||
frame_count * height * width,
|
||||
):
|
||||
contiguous = np.empty(pixels.shape, dtype=np.uint8)
|
||||
for channel in range(3):
|
||||
contiguous[..., channel] = pixels[..., channel]
|
||||
return contiguous
|
||||
return np.ascontiguousarray(pixels)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
@@ -695,6 +712,12 @@ class LingBotWorldCausalDMDPipeline(
|
||||
sequence_parallel_size = int(getattr(parallel_config, "sequence_parallel_size", 1) or 1)
|
||||
if sequence_parallel_size > 1 and vae_sharding:
|
||||
self._install_sharded_vae_decode(sequence_parallel_size)
|
||||
fast_path_level = _vae_decode_fast_path(model_config)
|
||||
if fast_path_level is not None:
|
||||
# Install after sharding so its conv wrappers get persistent buffers. The VAE parameters
|
||||
# already use the stage dtype, so no autocast parameter cast is needed.
|
||||
counts = install_wan_decoder_fast_path(self.vae, conv_dtype=None, level=fast_path_level)
|
||||
logger.info("LingBot World VAE decode fast path installed: %s", counts)
|
||||
self.setup_diffusion_pipeline_profiler(
|
||||
profiler_targets=[
|
||||
"vae.encode",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Configuration helpers for the LingBot World pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _vae_decode_fast_path(model_config: dict) -> str | None:
|
||||
"""Resolve ``lingbot_vae_decode_fast_path`` from the model config: ``None``, ``"exact"`` or ``"fused"``.
|
||||
|
||||
Off by default. ``true``/``exact`` installs the decoder's exact fast path after the decode dtype and the
|
||||
spatial shard are configured (see ``wan_decoder_fast_path``): no output bit changes, only launches
|
||||
disappear. ``fused`` adds the channels_last decoder with the fused RMSNorm+SiLU kernel, which is not
|
||||
bit-exact and requires subjective video quality review before enabling.
|
||||
"""
|
||||
value = model_config.get("lingbot_vae_decode_fast_path", False)
|
||||
if isinstance(value, bool) or value is None:
|
||||
return "exact" if value else None
|
||||
name = str(value).strip().lower()
|
||||
if name in ("", "0", "false", "no", "off", "none"):
|
||||
return None
|
||||
if name in ("1", "true", "yes", "on", "exact"):
|
||||
return "exact"
|
||||
if name == "fused":
|
||||
return "fused"
|
||||
raise ValueError(f"lingbot_vae_decode_fast_path must be off, exact or fused; got {value!r}")
|
||||
Reference in new issue
Block a user