[Perf][Hunyuan] Optimize for vae and patch_embed (#6306)
Signed-off-by: dengyunyang <584797741@qq.com>
This commit is contained in:
1 parent
cd3867c373
commit
8d34d3b5de
19 files changed
+1583
-218
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,292 @@
|
||||
"""Unit tests for fused_adaptive_group_norm_silu operator.
|
||||
|
||||
Tests numeric correctness against PyTorch native implementation:
|
||||
F.silu(F.group_norm(x, num_groups, weight, bias, eps) * (1 + scale) + shift)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
from vllm_omni.model_executor.models.common.ops import fused_adaptive_group_norm_silu
|
||||
|
||||
# Skip tests if CUDA not available
|
||||
pytestmark = [
|
||||
pytest.mark.core_model,
|
||||
pytest.mark.cuda,
|
||||
pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required for Triton kernels"),
|
||||
]
|
||||
|
||||
|
||||
def _reference(x, weight, bias, scale, shift, num_groups, eps):
|
||||
"""AdaGN reference, computed in fp32.
|
||||
|
||||
As with fused_group_norm_silu, a same-dtype reference is unusable: the
|
||||
GroupNorm affine can cancel to near zero, and rounding its O(1) terms to
|
||||
bf16 wipes out the remaining significant digits.
|
||||
"""
|
||||
B, C = x.shape[:2]
|
||||
normed = F.group_norm(x.float(), num_groups, weight.float(), bias.float(), eps)
|
||||
scale_b = scale.float().view(B, C, *([1] * (x.ndim - 2)))
|
||||
shift_b = shift.float().view(B, C, *([1] * (x.ndim - 2)))
|
||||
return F.silu(normed * (1 + scale_b) + shift_b)
|
||||
|
||||
|
||||
def _tolerance(dtype):
|
||||
if dtype == torch.float32:
|
||||
return 1e-5, 1e-5
|
||||
if dtype == torch.float16:
|
||||
return 2e-3, 2e-3
|
||||
return 1e-2, 1e-2 # bfloat16
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch_size", [1, 2])
|
||||
@pytest.mark.parametrize("channels", [32, 64, 128])
|
||||
@pytest.mark.parametrize("spatial_size", [(16, 16), (32, 32)])
|
||||
@pytest.mark.parametrize("num_groups", [8, 32])
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
|
||||
def test_fused_adaptive_group_norm_silu_correctness(batch_size, channels, spatial_size, num_groups, dtype):
|
||||
"""Test numeric correctness against PyTorch native ops."""
|
||||
if channels % num_groups != 0:
|
||||
pytest.skip(f"channels {channels} not divisible by num_groups {num_groups}")
|
||||
|
||||
H, W = spatial_size
|
||||
eps = 1e-6
|
||||
torch.manual_seed(42)
|
||||
kw = dict(device="cuda", dtype=dtype)
|
||||
|
||||
x = torch.randn(batch_size, channels, H, W, **kw)
|
||||
weight = torch.randn(channels, **kw)
|
||||
bias = torch.randn(channels, **kw)
|
||||
scale = torch.randn(batch_size, channels, **kw)
|
||||
shift = torch.randn(batch_size, channels, **kw)
|
||||
|
||||
ref_out = _reference(x, weight, bias, scale, shift, num_groups, eps).to(dtype)
|
||||
fused_out = fused_adaptive_group_norm_silu(x, weight, bias, scale, shift, num_groups, eps)
|
||||
|
||||
rtol, atol = _tolerance(dtype)
|
||||
torch.testing.assert_close(
|
||||
fused_out,
|
||||
ref_out,
|
||||
rtol=rtol,
|
||||
atol=atol,
|
||||
msg=f"Mismatch for batch={batch_size}, C={channels}, HW={spatial_size}, groups={num_groups}, dtype={dtype}",
|
||||
)
|
||||
|
||||
|
||||
def test_broadcast_scale_shift_shapes():
|
||||
"""scale/shift may arrive as (B, C) or already broadcast to (B, C, 1, 1)."""
|
||||
B, C, H, W = 2, 64, 16, 16
|
||||
kw = dict(device="cuda", dtype=torch.float32)
|
||||
x = torch.randn(B, C, H, W, **kw)
|
||||
weight = torch.randn(C, **kw)
|
||||
bias = torch.randn(C, **kw)
|
||||
scale = torch.randn(B, C, **kw)
|
||||
shift = torch.randn(B, C, **kw)
|
||||
|
||||
flat = fused_adaptive_group_norm_silu(x, weight, bias, scale, shift, 32, 1e-6)
|
||||
expanded = fused_adaptive_group_norm_silu(x, weight, bias, scale.view(B, C, 1, 1), shift.view(B, C, 1, 1), 32, 1e-6)
|
||||
torch.testing.assert_close(flat, expanded, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_output_dtype_matches_eager():
|
||||
"""Output dtype must match what eager GroupNorm would produce."""
|
||||
for dtype in [torch.float32, torch.float16, torch.bfloat16]:
|
||||
B, C = 2, 64
|
||||
kw = dict(device="cuda", dtype=dtype)
|
||||
x = torch.randn(B, C, 16, 16, **kw)
|
||||
weight = torch.randn(C, **kw)
|
||||
bias = torch.randn(C, **kw)
|
||||
scale = torch.randn(B, C, **kw)
|
||||
shift = torch.randn(B, C, **kw)
|
||||
|
||||
eager_dtype = F.silu(F.group_norm(x, 32, weight, bias, 1e-6)).dtype
|
||||
out = fused_adaptive_group_norm_silu(x, weight, bias, scale, shift, 32, 1e-6)
|
||||
|
||||
assert out.dtype == eager_dtype, (
|
||||
f"Output dtype {out.dtype} != eager dtype {eager_dtype} for input dtype {dtype}"
|
||||
)
|
||||
assert out.shape == x.shape
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"autocast_dtype, input_dtype",
|
||||
[
|
||||
(torch.float16, torch.float16),
|
||||
(torch.bfloat16, torch.bfloat16),
|
||||
(torch.float16, torch.float32),
|
||||
(torch.bfloat16, torch.float32),
|
||||
],
|
||||
)
|
||||
def test_autocast_matches_eager(autocast_dtype, input_dtype):
|
||||
"""Inside autocast the fused op must stay a drop-in replacement.
|
||||
|
||||
Also pins the fp32-activation corner so the inlined ``is_autocast_enabled
|
||||
-> fp32`` branch cannot silently downcast an already-fp32 activation.
|
||||
"""
|
||||
B, C = 2, 64
|
||||
x = torch.randn(B, C, 16, 16, device="cuda", dtype=input_dtype)
|
||||
weight = torch.randn(C, device="cuda", dtype=torch.float32)
|
||||
bias = torch.randn(C, device="cuda", dtype=torch.float32)
|
||||
scale = torch.randn(B, C, device="cuda", dtype=torch.float32)
|
||||
shift = torch.randn(B, C, device="cuda", dtype=torch.float32)
|
||||
|
||||
with torch.autocast("cuda", dtype=autocast_dtype):
|
||||
eager_out = F.silu(
|
||||
F.group_norm(x, 32, weight, bias, 1e-6) * (1 + scale.view(B, C, 1, 1)) + shift.view(B, C, 1, 1)
|
||||
)
|
||||
fused_out = fused_adaptive_group_norm_silu(x, weight, bias, scale, shift, 32, 1e-6)
|
||||
|
||||
assert eager_out.dtype == torch.float32, "sanity check: eager group_norm is expected to return fp32 under autocast"
|
||||
assert fused_out.dtype == eager_out.dtype, (
|
||||
f"Output dtype {fused_out.dtype} != eager dtype {eager_out.dtype} "
|
||||
f"under autocast({autocast_dtype}) for input {input_dtype}"
|
||||
)
|
||||
torch.testing.assert_close(fused_out, eager_out, rtol=1e-5, atol=1e-5)
|
||||
|
||||
|
||||
def test_invalid_channels_not_divisible():
|
||||
"""Test that assertion fires when channels not divisible by num_groups."""
|
||||
B, C = 1, 33
|
||||
kw = dict(device="cuda", dtype=torch.float32)
|
||||
x = torch.randn(B, C, 16, 16, **kw)
|
||||
weight = torch.randn(C, **kw)
|
||||
bias = torch.randn(C, **kw)
|
||||
scale = torch.randn(B, C, **kw)
|
||||
shift = torch.randn(B, C, **kw)
|
||||
|
||||
with pytest.raises(AssertionError, match="must be divisible by num_groups"):
|
||||
fused_adaptive_group_norm_silu(x, weight, bias, scale, shift, 32, 1e-6)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
|
||||
@pytest.mark.parametrize("shape", [(2, 64, 3, 16, 16), (1, 32, 8, 8), (2, 64, 128)])
|
||||
def test_non_2d_spatial_matches_eager(dtype, shape):
|
||||
"""Any spatial rank must work; the wrapper collapses the spatial axes.
|
||||
|
||||
GroupNorm reduces over the whole channel group and every spatial position,
|
||||
so flattening is exact rather than an approximation.
|
||||
"""
|
||||
torch.manual_seed(0)
|
||||
B, C = shape[:2]
|
||||
kw = dict(device="cuda", dtype=dtype)
|
||||
x = torch.randn(*shape, **kw)
|
||||
weight = torch.randn(C, **kw)
|
||||
bias = torch.randn(C, **kw)
|
||||
scale = torch.randn(B, C, **kw)
|
||||
shift = torch.randn(B, C, **kw)
|
||||
|
||||
fused_out = fused_adaptive_group_norm_silu(x, weight, bias, scale, shift, 32, 1e-6)
|
||||
ref_out = _reference(x, weight, bias, scale, shift, 32, 1e-6).to(dtype)
|
||||
|
||||
assert fused_out.shape == x.shape
|
||||
rtol, atol = _tolerance(dtype)
|
||||
torch.testing.assert_close(fused_out, ref_out, rtol=rtol, atol=atol)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch_size", [1, 2, 3])
|
||||
def test_scale_shift_from_chunk_are_not_contiguous(batch_size):
|
||||
"""Regression: scale/shift arrive as halves of a torch.chunk.
|
||||
|
||||
ResBlock builds them as ``torch.chunk(emb_out, 2, dim=1)`` where emb_out is
|
||||
(B, 2C, 1, 1). For B > 1 each half is a strided view, so the wrapper must
|
||||
``reshape`` (which copies) instead of ``view`` (which raises).
|
||||
"""
|
||||
C, H, W = 64, 16, 16
|
||||
kw = dict(device="cuda", dtype=torch.float32)
|
||||
x = torch.randn(batch_size, C, H, W, **kw)
|
||||
weight = torch.randn(C, **kw)
|
||||
bias = torch.randn(C, **kw)
|
||||
|
||||
emb_out = torch.randn(batch_size, 2 * C, 1, 1, **kw)
|
||||
scale, shift = torch.chunk(emb_out, 2, dim=1)
|
||||
if batch_size > 1:
|
||||
assert not scale.is_contiguous(), "test premise: chunk half is strided"
|
||||
|
||||
fused_out = fused_adaptive_group_norm_silu(x, weight, bias, scale, shift, 32, 1e-6)
|
||||
ref_out = F.silu(F.group_norm(x, 32, weight, bias, 1e-6) * (1.0 + scale) + shift)
|
||||
|
||||
torch.testing.assert_close(fused_out, ref_out, rtol=1e-5, atol=1e-5)
|
||||
|
||||
|
||||
def test_non_contiguous_input_matches_eager():
|
||||
"""A channels-last / permuted activation must still be handled correctly."""
|
||||
B, C, H, W = 2, 64, 16, 16
|
||||
kw = dict(device="cuda", dtype=torch.float32)
|
||||
x = torch.randn(B, H, W, C, **kw).permute(0, 3, 1, 2)
|
||||
assert not x.is_contiguous(), "test premise: input is strided"
|
||||
weight = torch.randn(C, **kw)
|
||||
bias = torch.randn(C, **kw)
|
||||
scale = torch.randn(B, C, **kw)
|
||||
shift = torch.randn(B, C, **kw)
|
||||
|
||||
fused_out = fused_adaptive_group_norm_silu(x, weight, bias, scale, shift, 32, 1e-6)
|
||||
ref_out = F.silu(F.group_norm(x, 32, weight, bias, 1e-6) * (1.0 + scale.view(B, C, 1, 1)) + shift.view(B, C, 1, 1))
|
||||
|
||||
torch.testing.assert_close(fused_out, ref_out, rtol=1e-5, atol=1e-5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spatial", [32, 64])
|
||||
def test_dit_sized_activation(spatial):
|
||||
"""Shapes the HunyuanImage3 DiT actually sees.
|
||||
|
||||
The earlier kernel walked the spatial axis one position at a time with
|
||||
``spatial_size`` as a ``tl.constexpr``; at 64x64 that is 4096 serial steps
|
||||
per pass. This test pins the realistic size so a regression to that layout
|
||||
shows up as a timeout rather than silently costing throughput.
|
||||
"""
|
||||
B, C = 2, 256
|
||||
kw = dict(device="cuda", dtype=torch.bfloat16)
|
||||
x = torch.randn(B, C, spatial, spatial, **kw)
|
||||
weight = torch.randn(C, **kw)
|
||||
bias = torch.randn(C, **kw)
|
||||
scale = torch.randn(B, C, **kw)
|
||||
shift = torch.randn(B, C, **kw)
|
||||
|
||||
fused_out = fused_adaptive_group_norm_silu(x, weight, bias, scale, shift, 32, 1e-5)
|
||||
ref_out = _reference(x, weight, bias, scale, shift, 32, 1e-5).to(torch.bfloat16)
|
||||
|
||||
rtol, atol = _tolerance(torch.bfloat16)
|
||||
torch.testing.assert_close(fused_out, ref_out, rtol=rtol, atol=atol)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
|
||||
def test_large_offset_small_variance(dtype):
|
||||
"""A large constant offset with a small variance must stay stable.
|
||||
|
||||
Mirrors the fused_group_norm_silu case: ``x ~ 10000 ± 0.1`` has mean ~1e4
|
||||
and variance ~1e-2, so ``E[x^2] - E[x]^2`` cancels catastrophically in fp32
|
||||
(rounding the variance to ~8.0). The Welford reduction keeps it accurate.
|
||||
fp32 tolerance is 0.1 (not 1e-6) because ``x``'s fp32 representation noise
|
||||
(~0.001 ULP at 1e4) is amplified by ``rstd ~ 10`` to ~0.01-0.02 — the same
|
||||
floor PyTorch's own reference hits; the old naive implementation would be
|
||||
off by ~1.0 here. fp16/bf16 quantize the perturbation away, so for those
|
||||
dtypes the meaningful assertion is just "no NaN".
|
||||
"""
|
||||
torch.manual_seed(0)
|
||||
B, C, H, W = 2, 64, 16, 16
|
||||
num_groups = 32
|
||||
eps = 1e-6
|
||||
device = torch.device("cuda")
|
||||
|
||||
x = torch.full((B, C, H, W), 10000.0, device=device, dtype=dtype)
|
||||
x = x + torch.randn(B, C, H, W, device=device, dtype=dtype) * 0.1
|
||||
weight = torch.randn(C, device=device, dtype=dtype)
|
||||
bias = torch.randn(C, device=device, dtype=dtype)
|
||||
scale = torch.randn(B, C, device=device, dtype=dtype)
|
||||
shift = torch.randn(B, C, device=device, dtype=dtype)
|
||||
|
||||
fused_out = fused_adaptive_group_norm_silu(x, weight, bias, scale, shift, num_groups, eps)
|
||||
ref_out = _reference(x, weight, bias, scale, shift, num_groups, eps).to(dtype)
|
||||
|
||||
assert not torch.isnan(fused_out).any(), "fused output must not contain NaN"
|
||||
|
||||
if dtype == torch.float32:
|
||||
# ~0.02 is the representation-noise floor at offset 1e4; old impl ~1.0.
|
||||
torch.testing.assert_close(fused_out, ref_out, rtol=0.05, atol=0.1)
|
||||
# fp16/bf16: perturbation is swallowed by the dtype — no meaningful close,
|
||||
# the no-NaN assertion above is the regression guard.
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Unit tests for fused_group_norm_silu operator.
|
||||
|
||||
Tests numeric correctness against PyTorch native implementation:
|
||||
F.silu(F.group_norm(x, num_groups, weight, bias, eps))
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
from vllm_omni.model_executor.models.common.ops import fused_group_norm_silu
|
||||
|
||||
# Skip tests if CUDA not available
|
||||
pytestmark = [
|
||||
pytest.mark.core_model,
|
||||
pytest.mark.cuda,
|
||||
pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required for Triton kernels"),
|
||||
]
|
||||
|
||||
|
||||
def _tolerance(dtype):
|
||||
"""Tolerance against an fp32 reference, rounded once to ``dtype``."""
|
||||
if dtype == torch.float32:
|
||||
return 1e-5, 1e-6
|
||||
if dtype == torch.float16:
|
||||
return 2e-3, 2e-3
|
||||
return 1e-2, 1e-2 # bfloat16
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch_size", [1, 2, 4])
|
||||
@pytest.mark.parametrize("channels", [32, 64, 128])
|
||||
@pytest.mark.parametrize("spatial_size", [(16, 16), (32, 32), (64, 64)])
|
||||
@pytest.mark.parametrize("num_groups", [8, 16, 32])
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
|
||||
def test_fused_group_norm_silu_correctness(batch_size, channels, spatial_size, num_groups, dtype):
|
||||
"""Test numeric correctness against PyTorch native ops."""
|
||||
# Skip invalid configurations
|
||||
if channels % num_groups != 0:
|
||||
pytest.skip(f"channels {channels} not divisible by num_groups {num_groups}")
|
||||
|
||||
H, W = spatial_size
|
||||
device = torch.device("cuda")
|
||||
eps = 1e-6
|
||||
|
||||
# Create inputs
|
||||
torch.manual_seed(42)
|
||||
x = torch.randn(batch_size, channels, H, W, device=device, dtype=dtype)
|
||||
weight = torch.randn(channels, device=device, dtype=dtype)
|
||||
bias = torch.randn(channels, device=device, dtype=dtype)
|
||||
|
||||
# Reference implementation, computed in fp32.
|
||||
#
|
||||
# A same-dtype reference is NOT usable here: GroupNorm's affine step
|
||||
# ``(x - mean) * rstd * weight + bias`` adds two O(1) terms that can cancel
|
||||
# down to ~1e-3. Rounding those terms to bf16 (ULP ~8e-3 near 1.0) destroys
|
||||
# every remaining significant digit -- measured cases flip sign, e.g. a true
|
||||
# +0.006136 comes out as -0.000471. The low-precision "reference" is then
|
||||
# far less accurate than the kernel it is supposed to validate: the fused
|
||||
# output matches the correctly-rounded fp32 result for 99.997% of elements,
|
||||
# a bf16 reference for only 65.2%.
|
||||
ref_out = F.silu(F.group_norm(x.float(), num_groups, weight.float(), bias.float(), eps)).to(dtype)
|
||||
|
||||
# Fused implementation
|
||||
fused_out = fused_group_norm_silu(x, weight, bias, num_groups, eps)
|
||||
|
||||
# Check correctness
|
||||
# Tolerances are set from the measured worst case across this parameter
|
||||
# sweep, leaving roughly 2x headroom for each dtype.
|
||||
if dtype == torch.float32:
|
||||
rtol, atol = 1e-5, 1e-6
|
||||
elif dtype == torch.float16:
|
||||
rtol, atol = 2e-3, 2e-3
|
||||
else: # bfloat16
|
||||
rtol, atol = 1e-2, 1e-2
|
||||
|
||||
torch.testing.assert_close(
|
||||
fused_out,
|
||||
ref_out,
|
||||
rtol=rtol,
|
||||
atol=atol,
|
||||
msg=f"Mismatch for batch={batch_size}, C={channels}, HW={spatial_size}, groups={num_groups}, dtype={dtype}",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
|
||||
def test_fp32_accumulation_precision(dtype):
|
||||
"""Test that fp32 accumulation matches PyTorch's behavior.
|
||||
|
||||
GroupNorm uses fp32 for mean/variance computation regardless of input dtype.
|
||||
Our kernel should match this behavior.
|
||||
"""
|
||||
batch_size, channels, H, W = 2, 64, 32, 32
|
||||
num_groups = 32
|
||||
eps = 1e-6
|
||||
device = torch.device("cuda")
|
||||
|
||||
# Create inputs with values that expose precision differences
|
||||
torch.manual_seed(123)
|
||||
x = torch.randn(batch_size, channels, H, W, device=device, dtype=dtype) * 100
|
||||
weight = torch.ones(channels, device=device, dtype=dtype)
|
||||
bias = torch.zeros(channels, device=device, dtype=dtype)
|
||||
|
||||
# Reference: fp32 accumulation, then a single round back to ``dtype``.
|
||||
# (Same discipline as the main correctness test -- a same-dtype reference
|
||||
# wouldn't isolate the accumulation path this test is named after.)
|
||||
ref_out = F.silu(F.group_norm(x.float(), num_groups, weight.float(), bias.float(), eps)).to(dtype)
|
||||
|
||||
# Fused
|
||||
fused_out = fused_group_norm_silu(x, weight, bias, num_groups, eps)
|
||||
|
||||
# Should match closely even with large values
|
||||
if dtype == torch.float32:
|
||||
rtol, atol = 1e-5, 1e-5
|
||||
else:
|
||||
rtol, atol = 1e-2, 1e-2
|
||||
|
||||
torch.testing.assert_close(fused_out, ref_out, rtol=rtol, atol=atol)
|
||||
|
||||
|
||||
def test_edge_case_zero_input():
|
||||
"""Test with zero input."""
|
||||
x = torch.zeros(1, 32, 16, 16, device="cuda")
|
||||
weight = torch.ones(32, device="cuda")
|
||||
bias = torch.zeros(32, device="cuda")
|
||||
|
||||
ref_out = F.silu(F.group_norm(x, 32, weight, bias, 1e-6))
|
||||
fused_out = fused_group_norm_silu(x, weight, bias, 32, 1e-6)
|
||||
|
||||
torch.testing.assert_close(fused_out, ref_out, rtol=1e-5, atol=1e-6)
|
||||
|
||||
|
||||
def test_edge_case_large_eps():
|
||||
"""Test with large epsilon value."""
|
||||
x = torch.randn(1, 32, 16, 16, device="cuda")
|
||||
weight = torch.randn(32, device="cuda")
|
||||
bias = torch.randn(32, device="cuda")
|
||||
eps = 1e-2 # Larger than typical
|
||||
|
||||
ref_out = F.silu(F.group_norm(x, 32, weight, bias, eps))
|
||||
fused_out = fused_group_norm_silu(x, weight, bias, 32, eps)
|
||||
|
||||
torch.testing.assert_close(fused_out, ref_out, rtol=1e-4, atol=1e-5)
|
||||
|
||||
|
||||
def test_output_dtype_matches_eager():
|
||||
"""Output dtype must match what eager GroupNorm+SiLU would produce.
|
||||
|
||||
Outside autocast that means the input dtype; inside autocast it means fp32,
|
||||
because group_norm is on autocast's fp32 cast policy. HunyuanImage3's VAE
|
||||
runs fp32 weights under fp16 autocast, so getting this wrong would silently
|
||||
downcast the post-norm activation.
|
||||
"""
|
||||
for dtype in [torch.float32, torch.float16, torch.bfloat16]:
|
||||
x = torch.randn(2, 64, 32, 32, device="cuda", dtype=dtype)
|
||||
weight = torch.randn(64, device="cuda", dtype=dtype)
|
||||
bias = torch.randn(64, device="cuda", dtype=dtype)
|
||||
|
||||
eager_out = F.silu(F.group_norm(x, 32, weight, bias, 1e-6))
|
||||
out = fused_group_norm_silu(x, weight, bias, 32, 1e-6)
|
||||
|
||||
assert out.dtype == eager_out.dtype, (
|
||||
f"Output dtype {out.dtype} != eager dtype {eager_out.dtype} for input dtype {dtype}"
|
||||
)
|
||||
assert out.shape == x.shape, f"Output shape {out.shape} != input shape {x.shape}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"autocast_dtype, input_dtype",
|
||||
[
|
||||
(torch.float16, torch.float16),
|
||||
(torch.bfloat16, torch.bfloat16),
|
||||
(torch.float16, torch.float32),
|
||||
(torch.bfloat16, torch.float32),
|
||||
],
|
||||
)
|
||||
def test_autocast_matches_eager(autocast_dtype, input_dtype):
|
||||
"""Inside autocast the fused op must stay a drop-in replacement.
|
||||
|
||||
Mirrors the HunyuanImage3 VAE setup: fp32 module weights, activations
|
||||
arriving as fp16/bf16 from an upstream conv, all under autocast. Also pins
|
||||
the fp32-activation corner (some VAE stages) so the inlined
|
||||
``is_autocast_enabled -> fp32`` branch cannot silently downcast it.
|
||||
"""
|
||||
x = torch.randn(2, 64, 32, 32, device="cuda", dtype=input_dtype)
|
||||
weight = torch.randn(64, device="cuda", dtype=torch.float32)
|
||||
bias = torch.randn(64, device="cuda", dtype=torch.float32)
|
||||
|
||||
with torch.autocast("cuda", dtype=autocast_dtype):
|
||||
eager_out = F.silu(F.group_norm(x, 32, weight, bias, 1e-6))
|
||||
fused_out = fused_group_norm_silu(x, weight, bias, 32, 1e-6)
|
||||
|
||||
assert eager_out.dtype == torch.float32, "sanity check: eager group_norm is expected to return fp32 under autocast"
|
||||
assert fused_out.dtype == eager_out.dtype, (
|
||||
f"Output dtype {fused_out.dtype} != eager dtype {eager_out.dtype} "
|
||||
f"under autocast({autocast_dtype}) for input {input_dtype}"
|
||||
)
|
||||
torch.testing.assert_close(fused_out, eager_out, rtol=1e-5, atol=1e-5)
|
||||
|
||||
|
||||
def test_invalid_channels_not_divisible():
|
||||
"""Test that assertion fires when channels not divisible by num_groups."""
|
||||
x = torch.randn(1, 33, 16, 16, device="cuda") # 33 not divisible by 32
|
||||
weight = torch.randn(33, device="cuda")
|
||||
bias = torch.randn(33, device="cuda")
|
||||
|
||||
with pytest.raises(AssertionError, match="must be divisible by num_groups"):
|
||||
fused_group_norm_silu(x, weight, bias, num_groups=32, eps=1e-6)
|
||||
|
||||
|
||||
def test_invalid_weight_shape():
|
||||
"""Test that assertion fires with wrong weight shape."""
|
||||
x = torch.randn(1, 64, 16, 16, device="cuda")
|
||||
weight = torch.randn(32, device="cuda") # Wrong: should be 64
|
||||
bias = torch.randn(64, device="cuda")
|
||||
|
||||
with pytest.raises(AssertionError, match="doesn't match channels"):
|
||||
fused_group_norm_silu(x, weight, bias, num_groups=32, eps=1e-6)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
|
||||
@pytest.mark.parametrize("shape", [(2, 64, 3, 16, 16), (1, 32, 1, 8, 8), (2, 64, 128)])
|
||||
def test_non_2d_spatial_matches_eager(dtype, shape):
|
||||
"""Spatial ranks other than 2 must work: the 3D VAE feeds (N, C, T, H, W).
|
||||
|
||||
GroupNorm reduces over the whole channel group and every spatial position,
|
||||
so flattening the spatial axes inside the wrapper has to be exact.
|
||||
"""
|
||||
torch.manual_seed(0)
|
||||
C = shape[1]
|
||||
x = torch.randn(*shape, device="cuda", dtype=dtype)
|
||||
weight = torch.randn(C, device="cuda", dtype=dtype)
|
||||
bias = torch.randn(C, device="cuda", dtype=dtype)
|
||||
|
||||
fused_out = fused_group_norm_silu(x, weight, bias, num_groups=32, eps=1e-6)
|
||||
ref_out = F.silu(F.group_norm(x.float(), 32, weight.float(), bias.float(), 1e-6)).to(dtype)
|
||||
|
||||
assert fused_out.shape == x.shape
|
||||
rtol, atol = _tolerance(dtype)
|
||||
torch.testing.assert_close(fused_out, ref_out, rtol=rtol, atol=atol)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
|
||||
def test_permuted_input_matches_eager(dtype):
|
||||
"""A permuted (channel-stride-1) activation must still be correct.
|
||||
|
||||
This is not a hypothetical layout: HunyuanImage3's ``UNetUp`` calls this op
|
||||
on the output of ``rearrange(x, "b (h w) c -> b c h w")``. An earlier
|
||||
version of the kernel indexed such inputs through their strides, which was
|
||||
correct but uncoalesced enough to run at 0.44x of eager, so the wrapper now
|
||||
materializes them first.
|
||||
"""
|
||||
torch.manual_seed(0)
|
||||
B, C, H, W = 2, 64, 16, 16
|
||||
kw = dict(device="cuda", dtype=dtype)
|
||||
x = torch.randn(B, H * W, C, **kw).permute(0, 2, 1).reshape(B, C, H, W)
|
||||
assert not x.is_contiguous(), "test premise: input is strided"
|
||||
|
||||
weight = torch.randn(C, **kw)
|
||||
bias = torch.randn(C, **kw)
|
||||
|
||||
fused_out = fused_group_norm_silu(x, weight, bias, num_groups=32, eps=1e-6)
|
||||
ref_out = F.silu(F.group_norm(x.float(), 32, weight.float(), bias.float(), 1e-6)).to(dtype)
|
||||
|
||||
rtol, atol = _tolerance(dtype)
|
||||
torch.testing.assert_close(fused_out, ref_out, rtol=rtol, atol=atol)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch_size", [1, 4])
|
||||
@pytest.mark.parametrize("channels", [64, 128])
|
||||
def test_hunyuan_vae_config(batch_size, channels):
|
||||
"""Test with HunyuanImage3 VAE actual configuration.
|
||||
|
||||
HunyuanImage3 ResnetBlock uses:
|
||||
- num_groups=32
|
||||
- eps=1e-6
|
||||
- Typical spatial sizes: 16x16 to 128x128 (after tiling)
|
||||
"""
|
||||
spatial_sizes = [(16, 16), (32, 32), (64, 64), (128, 128)]
|
||||
|
||||
for H, W in spatial_sizes:
|
||||
x = torch.randn(batch_size, channels, H, W, device="cuda", dtype=torch.float32)
|
||||
weight = torch.randn(channels, device="cuda")
|
||||
bias = torch.randn(channels, device="cuda")
|
||||
|
||||
ref_out = F.silu(F.group_norm(x, 32, weight, bias, 1e-6))
|
||||
fused_out = fused_group_norm_silu(x, weight, bias, 32, 1e-6)
|
||||
|
||||
torch.testing.assert_close(
|
||||
fused_out,
|
||||
ref_out,
|
||||
rtol=1e-5,
|
||||
atol=1e-6,
|
||||
msg=f"HunyuanVAE config: batch={batch_size}, C={channels}, HW=({H},{W})",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
|
||||
def test_large_offset_small_variance(dtype):
|
||||
"""A large constant offset with a small variance must stay stable.
|
||||
|
||||
``x ~ 10000 ± 0.1`` has mean ~1e4 and variance ~1e-2, so the naive
|
||||
``E[x^2] - E[x]^2`` subtracts two ~1e8 quantities and rounds the variance to
|
||||
~8.0 (the ULP of 1e8) — catastrophic cancellation, producing garbage
|
||||
normalization. The Welford reduction keeps the variance accurate.
|
||||
|
||||
Why fp32 tolerance is 0.1, not the usual 1e-6: at this offset, ``x`` is stored
|
||||
in fp32 with ULP ~0.001, so ``x - mean`` carries ~0.001 of representation
|
||||
noise, and ``rstd ~ 10`` amplifies it to ~0.01-0.02. Even PyTorch's own
|
||||
GroupNorm reference hits this floor (the old naive implementation would be
|
||||
off by ~1.0 here). fp16/bf16 quantize the 0.1 perturbation away entirely, so
|
||||
for those dtypes the meaningful assertion is just "no NaN".
|
||||
"""
|
||||
torch.manual_seed(0)
|
||||
B, C, H, W = 2, 64, 16, 16
|
||||
num_groups = 32
|
||||
eps = 1e-6
|
||||
device = torch.device("cuda")
|
||||
|
||||
x = torch.full((B, C, H, W), 10000.0, device=device, dtype=dtype)
|
||||
x = x + torch.randn(B, C, H, W, device=device, dtype=dtype) * 0.1
|
||||
weight = torch.randn(C, device=device, dtype=dtype)
|
||||
bias = torch.randn(C, device=device, dtype=dtype)
|
||||
|
||||
fused_out = fused_group_norm_silu(x, weight, bias, num_groups, eps)
|
||||
# fp32 reference = PyTorch's Welford path, the ground truth.
|
||||
ref_out = F.silu(F.group_norm(x.float(), num_groups, weight.float(), bias.float(), eps)).to(dtype)
|
||||
|
||||
assert not torch.isnan(fused_out).any(), "fused output must not contain NaN"
|
||||
|
||||
if dtype == torch.float32:
|
||||
# ~0.02 is the representation-noise floor at offset 1e4; old impl ~1.0.
|
||||
torch.testing.assert_close(fused_out, ref_out, rtol=0.05, atol=0.1)
|
||||
# fp16/bf16: perturbation is swallowed by the dtype — no meaningful close,
|
||||
# the no-NaN assertion above is the regression guard.
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in new issue
Block a user