[Diffusion] Keep the Wan VAE decoder channels_last and add a Triton NHWC nearest upsample (#38182)

Co-authored-by: Xiaoyu Zhang <1182563586@qq.com>
This commit is contained in:
Dayuxiaoshui
2026-09-09 09:31:35 +08:00
committed by GitHub
co-authored by Xiaoyu Zhang
parent a67a31aa81
commit db1de6ff4c
8 changed files with 440 additions and 10 deletions
@@ -161,13 +161,20 @@ tensor copy per residual site.
`usp_merge_heads`, `pack_qkv_destination_major`, `fused_pack_qkv`,
`fused_pack_segmented_qkv`, `fused_scatter_to_padded`,
`fused_causal_conv3d_cat_pad_cuda`,
`cat_pad_channels_last_3d`, `dup_up3d_add`, `fused_temb_table_slices`,
`cat_pad_channels_last_3d`, `dup_up3d_add`, `nearest_upsample_nhwc`,
`fused_temb_table_slices`,
and `ltx2_ada_values9` are bit-exact data movement or same-order arithmetic.
`fused_layernorm_modulate_fp8_quant_raw` folds FLUX.2 LayerNorm, adaLN
modulation, and static FP8 quantization. `try_flux2_token_cat_fp8` and
`try_flux2_token_cat_nvfp4` fuse branch concatenation directly into the
quantized representation selected by the FLUX.2 checkpoint path.
`nearest_upsample_nhwc` replaces `nn.Upsample(nearest / nearest-exact,
integer factor)` on a dense channels_last input with a Triton gather: same
values and layout as aten, but aten's own NHWC nearest kernel is several times
slower than its NCHW sibling, which is what the Wan-family VAE decoders hit
once they run channels_last end-to-end.
`fused_temb_table_slices` is worth knowing about: the eager
`(table + temb.float()).chunk(6, dim=2)` materializes ~8 GB of fp32 at
704p/121f *and* hands six strided slices downstream, whose `.contiguous()`
@@ -398,6 +398,13 @@ _SPECS: tuple[tuple[str, KernelBackend, str, frozenset, str], ...] = (
_CUDA,
"Wan causal VAE main + DupUp3D(src).",
),
(
"diffusion.nearest_upsample_nhwc",
KernelBackend.TRITON,
"layout.nearest_upsample_nhwc_triton:nearest_upsample_nhwc",
_CUDA,
"Wan-family VAE channels_last integer-factor nearest upsample.",
),
(
"diffusion.flux2_token_cat_nvfp4",
KernelBackend.JIT,
@@ -542,6 +549,8 @@ _EXPORTS: dict[str, str] = {
"fused_scatter_to_padded": "layout.varlen_pack_pad_triton",
"cat_pad_channels_last_3d": "layout.wan_causal_cache_triton",
"dup_up3d_add": "layout.wan_causal_cache_triton",
"nearest_upsample_nhwc": "layout.nearest_upsample_nhwc_triton",
"can_use_nearest_upsample_nhwc": "layout.nearest_upsample_nhwc_triton",
"try_flux2_token_cat_nvfp4": "layout.flux2_token_cat_nvfp4_jit",
# Fusion-site policy: quality gate, first-sight verification, mount
"BitExactFusionGate": "sites.bitexact_gate",
@@ -0,0 +1,182 @@
# SPDX-License-Identifier: Apache-2.0
"""Bit-exact channels_last nearest upsample for the Wan-family VAE decoders.
``nn.Upsample(scale_factor=2, mode="nearest-exact")`` on a channels_last
(NHWC) input dispatches to aten's ``upsample_nearest2d_nhwc_out_frame``. On an
H200 with a ``[1, 192, 240, 416]`` bf16 input that kernel takes 0.458 ms
against 0.190 ms for aten's own NCHW kernel on the same bytes; this gather
takes 0.061 ms. The Wan / Qwen-Image VAE decoders hit the aten NHWC kernel
once per up block per chunk as soon as they run channels_last end-to-end.
Numerical contract: bit-exact vs ``nn.Upsample`` in ``nearest`` and
``nearest-exact`` mode for integer scale factors. Write an output index as
``i = k * f + r`` with ``0 <= r <= f - 1``. ``nearest`` reads ``floor(i / f)
= k``; ``nearest-exact`` reads ``floor((i + 0.5) / f) = floor(k + (r + 0.5) /
f) = k`` because ``(r + 0.5) / f < 1``. Both therefore read input ``i // f``,
so the op is a pure gather that never touches a value, and the result is
bitwise identical for any dtype the predicate admits (bf16 / fp16 / fp32, the
ones the tests cover). The kernel walks the output in its NHWC memory order,
so the stores and the gathered loads are both contiguous along ``C``.
Layout contract: the output is dense channels_last, which is what aten returns
exactly when ``suggest_memory_format()`` says channels_last. That requires
``C > 1`` (with ``C == 1`` the tensor is also NCHW-contiguous and aten picks the
NCHW kernel, returning e.g. strides ``(4, 4, 2, 1)`` for a ``[1, 1, 2, 2]``
output) and canonical NHWC strides ``(H*W*C, 1, W*C, C)`` on every dim,
including size-1 dims (aten's stride test does not skip them, unlike
``is_contiguous``). The predicate enforces both, so a call it admits is
value- and layout-identical to ``nn.Upsample``.
Verified (``torch.equal`` vs ``F.interpolate``): ``[1, 192, 240, 416]``,
``[4, 192, 120, 208]``, ``[1, 96, 480, 832]``, ``[1, 3, 5, 7]`` at factor 2
and ``[2, 3, 5, 7]`` at factor ``(3, 2)`` for all three dtypes; end-to-end
inside the Wan 2.1 (81 frames, 480x832) and Qwen-Image (1024x1024) decoders.
"""
from __future__ import annotations
import math
import torch
import triton # type: ignore
import triton.language as tl # type: ignore
_MAX_INT32 = 2**31 - 1
_SUPPORTED_DTYPES = (torch.bfloat16, torch.float16, torch.float32)
@triton.jit
def _nearest_upsample_nhwc_kernel(
x_ptr,
out_ptr,
total,
C,
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) * C + c.
c = offs % C
t = offs // C
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 _integer_scale(scale) -> tuple[int, int] | None:
"""``(fh, fw)`` when ``scale`` is a finite integer-valued factor (scalar or
pair) of at least 1; ``None`` for anything else, never an exception."""
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``: the exact condition under which
aten's nearest upsample runs its NHWC kernel and returns a dense
channels_last tensor (see the module docstring)."""
_, 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:
"""True when ``F.interpolate(x, scale_factor=..., mode=...)`` is a plain
integer-factor gather on a dense channels_last 4D tensor whose result is
value- and layout-identical to aten's. Never raises."""
return (
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
)
def nearest_upsample_nhwc(x: torch.Tensor, scale_factor) -> torch.Tensor:
"""Integer-factor nearest upsample of a channels_last ``[N, C, H, W]``
tensor, returned dense channels_last. Bit-exact vs ``nn.Upsample`` in
``nearest`` and ``nearest-exact`` modes; raises on unsupported input."""
factors = _integer_scale(scale_factor)
if factors is None:
raise ValueError(f"scale_factor must be integer-valued, got {scale_factor}")
# Re-check everything the predicate checks: a direct call must fail loudly
# rather than return a detached tensor (autograd) or a differently laid-out
# one (see the layout contract in the module docstring).
if torch.is_grad_enabled() and x.requires_grad:
raise ValueError(
"nearest_upsample_nhwc is inference-only (input requires grad)"
)
if not (x.is_cuda and x.dim() == 4 and x.dtype in _SUPPORTED_DTYPES):
raise ValueError(
"nearest_upsample_nhwc needs a CUDA 4D bf16/fp16/fp32 tensor, got "
f"{x.device.type} {x.dim()}D {x.dtype}"
)
if not _canonical_nhwc(x):
raise ValueError(
"nearest_upsample_nhwc needs dense channels_last strides with C > 1, "
f"got shape {tuple(x.shape)} strides {tuple(x.stride())}"
)
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
grid = (triton.cdiv(total, BLOCK),)
with torch.get_device_module().device(x.device):
_nearest_upsample_nhwc_kernel[grid](
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
@@ -219,6 +219,18 @@ framework-specific optimization workflow.
- Numerical contract: these are bit-exact data-movement / same-order-add
replacements and run independently of the request-gated Wan RMSNorm+SiLU
path. Unsupported layouts or padding fall back to the aten chain.
- `nearest_upsample_nhwc` (`layout/nearest_upsample_nhwc_triton.py`) is the
bit-exact Triton gather behind `GatedChannelsLastUpsample` in
`wan_vae_cuda_opt.py`; it runs on the lossless path whenever the 2D
upsample input already has canonical NHWC strides (aten's own NHWC nearest
kernel is several times slower), while the stride canonicalisation, the
channels_last_3d `upsample3d` frame interleave (`_interleave_time_pairs` in
`wanvae.py`) and the attention residual operand order (`identity + x`, so
the sum keeps channels_last_3d) stay behind the quality gate: the first two
change which cuDNN conv2d algorithm runs, and the layout change alters the
reduction order of the eager RMSNorm that follows under fp32 autocast
(Wan pipelines decode with fp32 VAE weights under bf16 autocast, where this
was measured non-bit-exact even though the add itself is commutative).
- The Qwen-Image VAE (`autoencoder_kl_qwenimage.py`) uses the same
`cat_pad_channels_last_3d` + compact-cache helper for every causal conv
slot; single-frame image decodes keep the compact cache at the reference
@@ -24,7 +24,9 @@ logger = init_logger(__name__)
try:
from sglang.kernels.ops.diffusion import (
can_use_nearest_upsample_nhwc,
can_use_wan_rmsnorm_silu,
nearest_upsample_nhwc,
wan_rmsnorm_silu,
)
@@ -74,6 +76,11 @@ class GatedChannelsLastUpsample(nn.Module):
NHWC conv2d is not guaranteed to pick the same cuDNN algorithm as the NCHW
one, so the stride canonicalisation stays behind the quality gate; with
the gate off the layout is left exactly as the eager module sees it.
The upsample itself then runs the Triton ``nearest_upsample_nhwc`` gather
(bit-exact vs ``nn.Upsample`` for integer factors) instead of aten's
``upsample_nearest2d_nhwc`` kernel, which is several times slower than
its NCHW sibling on the same bytes.
"""
def __init__(self, upsample: nn.Upsample, gate: VaeFastPathGate) -> None:
@@ -98,6 +105,15 @@ class GatedChannelsLastUpsample(nn.Module):
and x.is_contiguous(memory_format=torch.channels_last)
):
x = x.as_strided(x.shape, canonical)
# The predicate admits exactly the inputs on which aten itself would
# run its NHWC kernel and return a dense channels_last tensor, so the
# Triton gather is a layout- and value-identical replacement: it runs
# on the lossless path too (the gate only controls the stride
# canonicalisation above).
if up.size is None and can_use_nearest_upsample_nhwc(
x, up.scale_factor, up.mode
):
return nearest_upsample_nhwc(x, up.scale_factor)
return up(x)
@@ -105,8 +121,9 @@ def _install_module_gates(
decoder: nn.Module, gate: VaeFastPathGate, module_classes: tuple[type, ...]
) -> int:
"""Hand the gate to layout-sensitive modules that consult ``_sgl_gate`` in
their own forward (the attention block's residual operand order, so the
sum keeps the channels_last_3d layout)."""
their own forward: the ``Resample`` (channels_last_3d ``upsample3d`` frame
interleave, see ``resample_forward``) and the attention block (residual
operand order, so the sum keeps the channels_last_3d layout)."""
count = 0
for m in decoder.modules():
if type(m) in module_classes:
@@ -198,9 +215,12 @@ def maybe_optimize_wan_vae(vae: nn.Module) -> nn.Module:
"""Install the quality-gated CUDA Wan VAE decoder fast path."""
from sglang.multimodal_gen.runtime.models.vaes.wanvae import (
AutoencoderKLWan,
WanAttentionBlock,
WanDecoder3d,
WanResample,
WanResidualBlock,
WanRMS_norm,
WanUpsample,
)
if not isinstance(vae, AutoencoderKLWan):
@@ -210,6 +230,8 @@ def maybe_optimize_wan_vae(vae: nn.Module) -> nn.Module:
decoder_cls=WanDecoder3d,
residual_block_cls=WanResidualBlock,
rms_norm_cls=WanRMS_norm,
upsample_cls=WanUpsample,
gated_module_classes=(WanResample, WanAttentionBlock),
label="Wan VAE",
)
@@ -354,6 +354,45 @@ class WanUpsample(nn.Upsample):
return super().forward(x.float()).type_as(x)
def _interleave_time_pairs(self, x, b, c, t, h, w):
"""``time_conv`` doubles the channels; split them into two frame halves
and interleave along time: ``[B, 2C, T, H, W] -> [B, C, 2T, H, W]``.
The eager ``reshape / stack / reshape`` materialises the result in NCDHW,
which sends the following 2D upsample and conv2d down their NCHW paths
and hands every up block an NCDHW tensor (the fused RMSNorm+SiLU then
falls back and the residual adds run strided). With the decode-scoped
fast-path gate on and a channels_last_3d input, write the same values
straight into a channels_last_3d buffer with one copy instead. Values are
identical either way; the layout change is gated because the NHWC conv2d
it enables need not pick the same cuDNN algorithm as the NCHW one.
"""
gate = getattr(self, "_sgl_gate", None)
if (
gate is not None
and gate.enabled
and not torch.compiler.is_compiling()
# Dense channels_last_3d (2C >= 2 channels, so this fixes stride(C) == 1
# and the channel split below is a view).
and x.is_contiguous(memory_format=torch.channels_last_3d)
):
out = torch.empty(
(b, c, t * 2, h, w),
device=x.device,
dtype=x.dtype,
memory_format=torch.channels_last_3d,
)
# out viewed as [B, C, T, 2, H, W] receives x viewed as [B, 2, C, T, H, W]
# with the pair axis moved next to time.
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 resample_forward(self, x):
b, c, t, h, w = x.size()
first_frame = is_first_frame.get()
@@ -370,17 +409,12 @@ def resample_forward(self, x):
else:
x = _run_cached_causal_conv(self.time_conv, x, _feat_cache, idx)
_feat_idx += 1
x = x.reshape(b, 2, c, t, h, w)
x = torch.stack((x[:, 0, :, :, :, :], x[:, 1, :, :, :, :]), 3)
x = x.reshape(b, c, t * 2, h, w)
x = _interleave_time_pairs(self, x, b, c, t, h, w)
feat_cache.set(_feat_cache)
feat_idx.set(_feat_idx)
elif not first_frame and hasattr(self, "time_conv"):
x = self.time_conv(x)
x = x.reshape(b, 2, c, t, h, w)
x = torch.stack((x[:, 0, :, :, :, :], x[:, 1, :, :, :, :]), 3)
x = x.reshape(b, c, t * 2, h, w)
x = _interleave_time_pairs(self, x, b, c, t, h, w)
t = x.shape[2]
x = x.permute(0, 2, 1, 3, 4).reshape(b * t, c, h, w)
x = self.resample(x)
@@ -476,6 +510,14 @@ def attention_block_forward(self, x):
x = x.view(batch_size, num_frames, channels, height, width)
x = x.permute(0, 2, 1, 3, 4)
gate = getattr(self, "_sgl_gate", None)
if gate is not None and gate.enabled and not torch.compiler.is_compiling():
# ``identity`` carries the decoder's channels_last_3d layout; putting
# it first lets the sum inherit that layout so the next block's fused
# RMSNorm+SiLU applies. The add itself is commutative, but the layout
# changes the reduction order of the *eager* norm that follows under
# fp32 autocast, so this is quality-gated rather than lossless.
return identity + x
return x + identity