Skip to content

Commit 182357d

Browse files
committed
perf(wan): batch-sharded cross-attn KV cache and batch-aware VAE mesh
Two memory fixes for Wan 2.2 T2V at per-device batch >= 1, motivated by a B=1..16 batch-scaling sweep at 720p/81f/40 steps on v6e-8 and tpu7x-8. 1. Cross-attention KV cache (transformer_wan.py) compute_kv_cache ran an eager nnx.vmap over all 40 layers on replicated prompt embeds, so every device materialised the full CFG-doubled cache plus the FP32 RMSNorm temporaries for every layer at once. It is now one jitted executable that pins the batch axis to ('data', 'fsdp') on input and output and scans the layers one at a time. The cross-attn consumer already re-constrains K/V to that layout, so nothing is resharded at use time; each device just holds its own B_local slice. If the batch is not divisible by data * fsdp (e.g. data_parallelism > 1 with per_device_batch_size = 1 and CFG off, the case the pipelines already tolerate for the CFG cache) the batch constraint is skipped and the cache stays replicated as before, rather than raising inside the jit. Sharding the 512-token text axis on 'context' as well was tried and rejected: XLA hoists the per-layer all-gather out of the layer scan, so the forward program's reservation grew by 5.8 GiB (net loss on v6e-8). 2. VAE mesh (wan_pipeline.py), T2V pipelines only With vae_spatial=-1 (auto) the VAE mesh is now ('redundant', 'vae_spatial') = (N, 1) when the global batch is a multiple of the device count N, i.e. one video per device with no cross-chip collectives, and (1, N) otherwise (unchanged). Mixed meshes were measured and are not a win: at B=2 on v6e-8 a (2, 4) mesh needed a 21.2 GiB decode program vs 15.5 GiB for (1, 8). An explicit vae_spatial still overrides. The batch-aware choice is gated by a new batch_parallel_vae argument on _create_common_components, enabled only by wan_pipeline_2_1 / 2_2. The VAE hard-constrains activations to P('redundant', ..., 'vae_spatial', ...), so a (N, 1) mesh requires every tensor through the VAE to have a batch divisible by N. That holds for T2V (only the final full-batch decode) but not for VACE (reference images encoded one at a time, [1, 1, H, W, C]) or the i2v / animate conditioning encodes; those keep the (1, N) mesh. Tests: new tests/wan/wan_vae_mesh_test.py (CPU, 8 host devices) covers the _auto_vae_spatial table for B in {0..16} gated and ungated, a guard that only the T2V pipelines set the gate, and _batch_sharding / _shard_kv_cache_on_batch layouts including the non-divisible fallback. Measured on main (DP=2, CP=4 unless noted, use_kv_cache=true): tpu7x-8 B=2 peak HBM 27.49 -> 16.65 GiB, denoise 270.5 -> 269.8 s tpu7x-8 B=8 peak HBM 68.12 -> 24.32 GiB, VAE decode 41.6 -> 6.3 s, denoise 897.8 -> 893.4 s v6e-8 B=8 (DP=1, FSDP=2, CP=4, vae_decode_chunk=1) main OOMs in compute_kv_cache (6.25 GiB alloc, 4.99 GiB free); with this change it completes: peak 17.41 GiB, denoise 1302.6 s, VAE decode 4.8 s.
1 parent f1d0faf commit 182357d

5 files changed

Lines changed: 275 additions & 11 deletions

File tree

‎src/maxdiffusion/models/wan/transformers/transformer_wan.py‎

Lines changed: 97 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from typing import Tuple, Optional, Dict, Union, Any
1818
import contextlib
19+
import functools
1920
import math
2021
import jax
2122
import jax.numpy as jnp
@@ -38,6 +39,90 @@
3839
from ...gradient_checkpoint import GradientCheckpointType
3940

4041
BlockSizes = common_types.BlockSizes
42+
BATCH = common_types.BATCH
43+
44+
45+
def _batch_sharding(mesh, ndim: int, batch_axis: int, batch_size: Optional[int] = None):
46+
"""NamedSharding with the activation-batch mesh axes on ``batch_axis``, rest replicated.
47+
48+
Returns None (caller skips the constraint, array stays replicated) when there
49+
is no mesh, when the ambient ``axis_rules`` context does not map BATCH to any
50+
mesh axis, or when ``batch_size`` is not divisible by the product of the
51+
batch mesh axes -- e.g. ``data_parallelism > 1`` with ``per_device_batch_size
52+
= 1`` and CFG off, which the pipelines already tolerate for the CFG cache.
53+
``with_sharding_constraint`` would otherwise raise inside the jit.
54+
"""
55+
if mesh is None:
56+
return None
57+
batch_mesh_axes = nn.logical_to_mesh_axes((BATCH,))[0]
58+
if batch_mesh_axes is None:
59+
return None
60+
if batch_size is not None:
61+
axes = (batch_mesh_axes,) if isinstance(batch_mesh_axes, str) else tuple(batch_mesh_axes)
62+
num_shards = math.prod(mesh.shape[a] for a in axes)
63+
if batch_size % num_shards != 0:
64+
return None
65+
spec = [None] * ndim
66+
spec[batch_axis] = batch_mesh_axes
67+
return jax.sharding.NamedSharding(mesh, jax.sharding.PartitionSpec(*spec))
68+
69+
70+
def _shard_kv_cache_on_batch(kv_cache, mesh):
71+
"""Pins every K/V cache array to the activation batch sharding.
72+
73+
Cache arrays are either [B, S, H*D] (one layer) or [L, B, S, H*D] (stacked),
74+
so the batch axis is the first non-layer axis. The cross-attention consumer
75+
re-constrains K/V to (BATCH, CROSS_ATTN_HEAD, None, D_KV), i.e. batch on
76+
('data', 'fsdp') and everything else replicated, so this is exactly the
77+
layout it needs — no resharding at use time, and each device only holds its
78+
own B_local slice instead of the full CFG-doubled batch.
79+
"""
80+
if mesh is None:
81+
return kv_cache
82+
83+
def _constrain(x):
84+
batch_axis = 1 if x.ndim == 4 else 0
85+
sharding = _batch_sharding(mesh, x.ndim, batch_axis, batch_size=x.shape[batch_axis])
86+
return x if sharding is None else jax.lax.with_sharding_constraint(x, sharding)
87+
88+
return jax.tree.map(_constrain, kv_cache)
89+
90+
91+
@functools.partial(jax.jit, static_argnames=("num_layers", "mesh"))
92+
def _scanned_compute_kv(blocks_graphdef, blocks_state, encoder_hidden_states, encoder_attention_mask, num_layers, mesh):
93+
"""Cross-attention K/V cache for all layers as ONE sharded executable.
94+
95+
Replaces the eager ``nnx.vmap`` over the stacked blocks. Eager vmap
96+
materialises the K/V projections, the FP32 RMSNorm upcast and the bf16
97+
downcast for all ``num_layers`` at once *and* on every device (the inputs
98+
are replicated), which is what blew the 32 GiB parts at B=8.
99+
100+
Here the batch axis is sharded on ('data', 'fsdp') before the projections,
101+
and ``nnx.scan`` processes one layer per iteration so the FP32 temporaries
102+
are bounded to a single layer and freed before the next. The stacked
103+
[L, B, S, H*D] outputs stay batch-sharded.
104+
"""
105+
blocks = nnx.merge(blocks_graphdef, blocks_state)
106+
batch_size = encoder_hidden_states.shape[0]
107+
in_sharding = _batch_sharding(mesh, encoder_hidden_states.ndim, 0, batch_size=batch_size)
108+
if in_sharding is not None:
109+
encoder_hidden_states = jax.lax.with_sharding_constraint(encoder_hidden_states, in_sharding)
110+
if encoder_attention_mask is not None:
111+
encoder_attention_mask = jax.lax.with_sharding_constraint(
112+
encoder_attention_mask, _batch_sharding(mesh, encoder_attention_mask.ndim, 0, batch_size=batch_size)
113+
)
114+
115+
def scan_fn(carry, block):
116+
kv = block.compute_kv(encoder_hidden_states, encoder_attention_mask)
117+
return carry, _shard_kv_cache_on_batch(kv, mesh)
118+
119+
_, kv_cache = nnx.scan(
120+
scan_fn,
121+
length=num_layers,
122+
in_axes=(nnx.Carry, 0),
123+
out_axes=(nnx.Carry, 0),
124+
)(None, blocks)
125+
return _shard_kv_cache_on_batch(kv_cache, mesh)
41126

42127

43128
def get_frequencies(max_seq_len: int, theta: int, attention_head_dim: int):
@@ -588,6 +673,7 @@ def __init__(
588673
out_channels = out_channels or in_channels
589674
self.num_layers = num_layers
590675
self.scan_layers = scan_layers
676+
self.mesh = mesh
591677
self.enable_jax_named_scopes = enable_jax_named_scopes
592678
attention_config = {
593679
"use_base2_exp": False,
@@ -741,16 +827,17 @@ def compute_kv_cache(
741827
encoder_attention_mask = jnp.concatenate([encoder_attention_mask, text_mask], axis=1)
742828

743829
if self.scan_layers:
744-
745-
@nnx.vmap(
746-
in_axes=(0, None, None),
747-
out_axes=0,
748-
transform_metadata={nnx.PARTITION_NAME: "layers_per_stage"},
830+
# Jitted + scanned + batch-sharded; see _scanned_compute_kv for why the
831+
# previous eager nnx.vmap was a 6.8 GiB-per-video HBM hit on every device.
832+
blocks_graphdef, blocks_state = nnx.split(self.blocks)
833+
kv_cache = _scanned_compute_kv(
834+
blocks_graphdef,
835+
blocks_state,
836+
encoder_hidden_states,
837+
encoder_attention_mask,
838+
num_layers=self.num_layers,
839+
mesh=self.mesh,
749840
)
750-
def _compute_kv(block, enc_states, enc_mask):
751-
return block.compute_kv(enc_states, enc_mask)
752-
753-
kv_cache = _compute_kv(self.blocks, encoder_hidden_states, encoder_attention_mask)
754841
else:
755842
kv_cache_list = []
756843
for block in self.blocks:
@@ -761,6 +848,7 @@ def _compute_kv(block, enc_states, enc_mask):
761848
k_list = [d[k][0] for d in kv_cache_list]
762849
v_list = [d[k][1] for d in kv_cache_list]
763850
kv_cache[k] = (jnp.stack(k_list, axis=0), jnp.stack(v_list, axis=0))
851+
kv_cache = _shard_kv_cache_on_batch(kv_cache, self.mesh)
764852

765853
return kv_cache, encoder_attention_mask
766854

‎src/maxdiffusion/pipelines/wan/wan_pipeline.py‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -994,6 +994,45 @@ def _decode_latents_to_video(self, latents: jax.Array, trace: Optional[dict] = N
994994
video = np.asarray(video)
995995
return video
996996

997+
@staticmethod
998+
def _auto_vae_spatial(config, total_devices: int, batch_parallel_vae: bool = False) -> int:
999+
"""Picks the `vae_spatial` mesh extent when the config leaves it at -1 (auto).
1000+
1001+
The VAE mesh is ('redundant', 'vae_spatial') with redundant * vae_spatial ==
1002+
total_devices. Batch is sharded on 'redundant' and width / conv channels on
1003+
'vae_spatial'. Spatial sharding is what lets a single video fit, but with
1004+
vae_spatial == total_devices the batch axis is fully replicated, so at
1005+
B == total_devices every chip holds all B videos' activations and the
1006+
causal conv stack pays a ~10x slowdown (41.6s -> 6.3s at B=8 on tpu7x-8)
1007+
and OOMs on 32 GiB parts.
1008+
1009+
Measured on v6e-8 / tpu7x-8 (720p, 81 frames):
1010+
* B % total_devices == 0: pure data parallelism (vae_spatial == 1) is
1011+
strictly better -- 1 video per device, zero cross-chip collectives.
1012+
* 1 < B < total_devices: mixed meshes are NOT a win. At B=2 on v6e-8
1013+
the (2, 4) mesh needed a 21.2 GiB decode program vs 15.5 GiB for
1014+
(1, 8), even though per-device activation volume is identical.
1015+
So this only switches to vae_spatial == 1 when the batch is a multiple of
1016+
the device count and keeps full spatial sharding otherwise. Setting
1017+
`vae_spatial` explicitly in the config still overrides this.
1018+
1019+
`batch_parallel_vae` gates the batch-aware choice. The VAE hard-constrains
1020+
every activation to P('redundant', ..., 'vae_spatial', ...), so a
1021+
vae_spatial == 1 mesh requires *every* tensor that goes through the VAE to
1022+
have a batch divisible by total_devices. That holds for the T2V pipelines
1023+
(the only VAE call is the final decode of the full batch), but not for
1024+
pipelines that also encode conditioning through the VAE at a different
1025+
batch -- VACE encodes reference images one at a time ([1, 1, H, W, C]),
1026+
animate / i2v encode conditioning clips. Those pipelines leave the gate
1027+
off and keep the full-spatial (1, N) mesh.
1028+
"""
1029+
if not batch_parallel_vae:
1030+
return total_devices
1031+
batch_size = int(getattr(config, "global_batch_size_to_train_on", 1) or 1)
1032+
if batch_size >= total_devices and batch_size % total_devices == 0:
1033+
return 1
1034+
return total_devices
1035+
9971036
@classmethod
9981037
def _create_common_components(
9991038
cls,
@@ -1002,6 +1041,7 @@ def _create_common_components(
10021041
load_text_encoder=True,
10031042
load_scheduler=True,
10041043
i2v=False,
1044+
batch_parallel_vae=False,
10051045
):
10061046
devices_array = max_utils.create_device_mesh(config)
10071047
mesh = Mesh(devices_array, config.mesh_axes)
@@ -1010,7 +1050,7 @@ def _create_common_components(
10101050
total_devices = math.prod(devices_array.shape)
10111051

10121052
if vae_spatial == -1:
1013-
vae_spatial = total_devices
1053+
vae_spatial = cls._auto_vae_spatial(config, total_devices, batch_parallel_vae=batch_parallel_vae)
10141054

10151055
assert (
10161056
total_devices % vae_spatial == 0
@@ -1021,7 +1061,8 @@ def _create_common_components(
10211061

10221062
vae_mesh = Mesh(vae_devices_array, ("redundant", "vae_spatial"))
10231063
max_logging.log(
1024-
f"Created VAE specific mesh with axes ('redundant', 'vae_spatial') to support spatial sharding of {vae_spatial}."
1064+
f"Created VAE specific mesh with axes ('redundant', 'vae_spatial') = "
1065+
f"({total_devices // vae_spatial}, {vae_spatial}) to support spatial sharding of {vae_spatial}."
10251066
)
10261067

10271068
# logical axis rules for VAE encoding/decoding

‎src/maxdiffusion/pipelines/wan/wan_pipeline_2_1.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ def _load_and_init(
6868
load_vae=load_vae,
6969
load_text_encoder=load_text_encoder,
7070
load_scheduler=load_scheduler,
71+
# T2V only runs the VAE on the final full-batch decode, so the
72+
# batch-aware (N, 1) mesh is safe here; see _auto_vae_spatial.
73+
batch_parallel_vae=True,
7174
)
7275
transformer = None
7376
try:

‎src/maxdiffusion/pipelines/wan/wan_pipeline_2_2.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ def _load_and_init(
7979
load_vae=load_vae,
8080
load_text_encoder=load_text_encoder,
8181
load_scheduler=load_scheduler,
82+
# T2V only runs the VAE on the final full-batch decode, so the
83+
# batch-aware (N, 1) mesh is safe here; see _auto_vae_spatial.
84+
batch_parallel_vae=True,
8285
)
8386
low_noise_transformer, high_noise_transformer = None, None
8487
transformer_executor = concurrent.futures.ThreadPoolExecutor(max_workers=2)
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""
2+
Copyright 2026 Google LLC
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
https://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
16+
CPU checks for the batch-aware VAE mesh selection and the batch-sharded
17+
cross-attention KV cache helpers.
18+
"""
19+
20+
import os
21+
import types
22+
23+
os.environ.setdefault("XLA_FLAGS", "--xla_force_host_platform_device_count=8")
24+
os.environ.setdefault("JAX_PLATFORMS", "cpu")
25+
26+
import jax
27+
import jax.numpy as jnp
28+
import numpy as np
29+
import pytest
30+
from flax.linen import partitioning as nn_partitioning
31+
from jax.sharding import Mesh, NamedSharding, PartitionSpec as P
32+
33+
from maxdiffusion.pipelines.wan.wan_pipeline import WanPipeline
34+
from maxdiffusion.models.wan.transformers import transformer_wan
35+
36+
37+
def _cfg(batch_size):
38+
return types.SimpleNamespace(global_batch_size_to_train_on=batch_size)
39+
40+
41+
@pytest.mark.parametrize(
42+
"batch_size, total_devices, expected",
43+
[
44+
(0, 8, 8),
45+
(1, 8, 8),
46+
(2, 8, 8),
47+
(3, 8, 8),
48+
(4, 8, 8),
49+
(8, 8, 1),
50+
(12, 8, 8),
51+
(16, 8, 1),
52+
(4, 4, 1),
53+
(1, 1, 1),
54+
],
55+
)
56+
def test_auto_vae_spatial_batch_parallel(batch_size, total_devices, expected):
57+
"""With the gate on, B % N == 0 picks pure data parallelism, otherwise full spatial."""
58+
assert WanPipeline._auto_vae_spatial(_cfg(batch_size), total_devices, batch_parallel_vae=True) == expected
59+
60+
61+
@pytest.mark.parametrize("batch_size", [0, 1, 2, 8, 16])
62+
def test_auto_vae_spatial_default_is_full_spatial(batch_size):
63+
"""Without the gate (i2v / vace / animate) auto always resolves to the old value."""
64+
assert WanPipeline._auto_vae_spatial(_cfg(batch_size), 8) == 8
65+
assert WanPipeline._auto_vae_spatial(_cfg(batch_size), 8, batch_parallel_vae=False) == 8
66+
67+
68+
def test_only_t2v_pipelines_enable_batch_parallel_vae():
69+
"""Guard against the gate leaking into pipelines that VAE-encode at batch != global batch."""
70+
pipeline_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "pipelines", "wan")
71+
enabled = set()
72+
for name in sorted(os.listdir(pipeline_dir)):
73+
if not name.endswith(".py"):
74+
continue
75+
with open(os.path.join(pipeline_dir, name)) as f:
76+
if "batch_parallel_vae=True" in f.read():
77+
enabled.add(name)
78+
assert enabled == {"wan_pipeline_2_1.py", "wan_pipeline_2_2.py"}
79+
80+
81+
@pytest.fixture
82+
def mesh_and_rules():
83+
devices = np.array(jax.devices()).reshape(2, 1, 4, 1)
84+
mesh = Mesh(devices, ("data", "fsdp", "context", "tensor"))
85+
rules = (("activation_batch", ("data", "fsdp")),)
86+
return mesh, rules
87+
88+
89+
def test_batch_sharding_divisible(mesh_and_rules):
90+
mesh, rules = mesh_and_rules
91+
with mesh, nn_partitioning.axis_rules(rules):
92+
sharding = transformer_wan._batch_sharding(mesh, ndim=3, batch_axis=0, batch_size=4)
93+
assert sharding is not None
94+
assert sharding.spec == P(("data", "fsdp"), None, None)
95+
96+
97+
def test_batch_sharding_not_divisible_returns_none(mesh_and_rules):
98+
"""B=3 on data*fsdp=2 must skip the constraint instead of raising inside the jit."""
99+
mesh, rules = mesh_and_rules
100+
with mesh, nn_partitioning.axis_rules(rules):
101+
assert transformer_wan._batch_sharding(mesh, ndim=3, batch_axis=0, batch_size=3) is None
102+
103+
104+
def test_batch_sharding_without_rules_or_mesh(mesh_and_rules):
105+
mesh, _ = mesh_and_rules
106+
assert transformer_wan._batch_sharding(None, ndim=3, batch_axis=0) is None
107+
with mesh, nn_partitioning.axis_rules(()):
108+
assert transformer_wan._batch_sharding(mesh, ndim=3, batch_axis=0) is None
109+
110+
111+
def test_shard_kv_cache_on_batch_layouts(mesh_and_rules):
112+
"""[B,S,D] shards on axis 0, stacked [L,B,S,D] on axis 1; odd batches stay replicated."""
113+
mesh, rules = mesh_and_rules
114+
cache = {
115+
"text": (jnp.zeros((4, 16, 8)), jnp.zeros((2, 4, 16, 8))),
116+
"odd": (jnp.zeros((3, 16, 8)), jnp.zeros((2, 3, 16, 8))),
117+
}
118+
with mesh, nn_partitioning.axis_rules(rules):
119+
out = jax.jit(lambda c: transformer_wan._shard_kv_cache_on_batch(c, mesh))(cache)
120+
k, v = out["text"]
121+
# JAX canonicalises the spec of the returned array (size-1 'fsdp' is dropped),
122+
# so compare semantically rather than by literal PartitionSpec.
123+
assert k.sharding.is_equivalent_to(NamedSharding(mesh, P(("data", "fsdp"), None, None)), k.ndim)
124+
assert v.sharding.is_equivalent_to(NamedSharding(mesh, P(None, ("data", "fsdp"), None, None)), v.ndim)
125+
assert k.addressable_shards[0].data.shape[0] == 2
126+
assert v.addressable_shards[0].data.shape[1] == 2
127+
k_odd, v_odd = out["odd"]
128+
assert k_odd.shape == (3, 16, 8) and v_odd.shape == (2, 3, 16, 8)
129+
assert k_odd.sharding.is_equivalent_to(NamedSharding(mesh, P()), k_odd.ndim)

0 commit comments

Comments
 (0)