Skip to content
Merged
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions tests/unit_tests/backends/diffusion/test_wan_vae_decoder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
##########################################################################
# Copyright (c) 2025, Advanced Micro Devices, Inc.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a newly added file, so the copyright year should be 2026.

#
# See LICENSE for license information.
###########################################################################

import torch

from primus.backends.diffusion.models.wan.vae2_2 import Decoder3d, count_conv3d


def _make_decoder():
# A tiny decoder with a single temporal-upsample stage: enough to exercise
# cache indexing, the "Rep" sentinel, and first_chunk propagation without
# a channel-changing residual shortcut (which vae2_2's ResidualBlock does
# not route through the feat_cache, and would desync feat_idx from
# count_conv3d(decoder)).
# Seed only within a forked RNG scope so this doesn't mutate global RNG
# state and leak into other tests run later in the same process.
with torch.random.fork_rng(devices=[]):
torch.manual_seed(0)
Comment thread
Copilot marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestIntel PR Steward: Good catch — fixed in 8204f01 by passing devices=[] to torch.random.fork_rng(), so the seeding scope no longer forks CUDA RNG state on GPU-enabled runners.

decoder = Decoder3d(
dim=8,
z_dim=4,
dim_mult=[1, 1],
num_res_blocks=1,
attn_scales=[],
temperal_upsample=[True],
dropout=0.0,
)
decoder.eval()
return decoder


def _decode_streaming(decoder, x, conv_num):
"""Replay WanVAE_.decode's per-frame chunked-decode protocol: one latent
frame per call, a single feat_cache list reused across calls, feat_idx
reset to [0] for every frame, and first_chunk=True only on the first
call."""
feat_map = [None] * conv_num
outputs = []
for i in range(x.shape[2]):
feat_idx = [0]
frame_out = decoder(
x[:, :, i : i + 1, :, :],
feat_cache=feat_map,
feat_idx=feat_idx,
first_chunk=(i == 0),
)
# Every CausalConv3d on the cached path consumes exactly one slot.
assert feat_idx[0] == conv_num
outputs.append(frame_out)
return outputs, feat_map


def test_forward_streaming_decode_matches_wan22_chunked_protocol():
# Direct Decoder3d output is 12 channels (patchified latent space); the
# conversion to 3 RGB channels happens later, in unpatchify.
decoder = _make_decoder()
conv_num = count_conv3d(decoder)
x = torch.randn(1, 4, 3, 4, 4)
feat_map = [None] * conv_num

outputs = []
with torch.no_grad():
for i in range(x.shape[2]):
feat_idx = [0]
frame_out = decoder(
x[:, :, i : i + 1, :, :],
feat_cache=feat_map,
feat_idx=feat_idx,
first_chunk=(i == 0),
)
# Every CausalConv3d on the cached path consumes exactly one slot.
assert feat_idx[0] == conv_num

if i == 0:
# After the first chunk every cache slot is populated; the
# temporal-upsample stage's slot holds the "Rep" sentinel
# until a second chunk gives it real history to work with.
assert all(slot is not None for slot in feat_map)
assert any(isinstance(slot, str) and slot == "Rep" for slot in feat_map)
else:
# Once real history is available, "Rep" must have been
# replaced by an actual cached tensor.
assert not any(isinstance(slot, str) and slot == "Rep" for slot in feat_map)

outputs.append(frame_out)

# First chunk has no cached history yet, so its upsample3d stage can only
# emit its own frame; later chunks have history and double their
# temporal contribution.
assert outputs[0].shape == (1, 12, 1, 8, 8)
assert outputs[1].shape == (1, 12, 2, 8, 8)
assert outputs[2].shape == (1, 12, 2, 8, 8)

out = torch.cat(outputs, dim=2)
assert out.shape == (1, 12, 5, 8, 8)
assert torch.isfinite(out).all()
Comment on lines +116 to +118

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestIntel PR Steward: Declining this one after checking the architecture in vae2_2.py. Resample.forward (mode upsample3d) only performs the temporal 2x doubling inside the if feat_cache is not None: branch — when feat_cache is None it falls through to spatial-only upsampling and the time dimension is left unchanged. So there isn't a "single-shot, no-cache" decode path in this model that reproduces the streamed result: WanVAE_.decode itself always drives Decoder3d frame-by-frame through a shared feat_cache/feat_idx, exactly like this test does. A full-sequence call with a fresh cache and first_chunk=True also isn't equivalent, since the temporal-upsample branch only skips doubling on the very first cache slot access regardless of how many frames are in that call, so it wouldn't reproduce the expected 5-frame streamed output either. Given that, the cache-bookkeeping + determinism assertions already in this PR are the correct/only feasible way to pin down this behavior; a "no-cache reference" comparison would just be comparing against a different, non-equivalent computation. Happy to revisit if you see a concrete construction that sidesteps this.



def test_forward_streaming_decode_is_deterministic_after_cache_reset():
decoder = _make_decoder()
conv_num = count_conv3d(decoder)
x = torch.randn(1, 4, 3, 4, 4)

with torch.no_grad():
outputs_1, _ = _decode_streaming(decoder, x, conv_num)
outputs_2, _ = _decode_streaming(decoder, x, conv_num)

torch.testing.assert_close(
torch.cat(outputs_1, dim=2),
torch.cat(outputs_2, dim=2),
rtol=0,
atol=1e-6,
)