Skip to content

Commit 05e0987

Browse files
committed
feat(attention): 2D Ulysses+Ring custom attention with fixed-m (pre-a2a norm reduction, unpadded ring K/V)
Adds fixed-m to the 2D Ulysses + Ring custom-kernel path (`_ulysses_ring_custom_attention`, attention=ulysses_ring_custom_fixed_m and the new ulysses_ring_custom_fixed_m_per_q_block), plus the supporting producer, guard and layout fixes it needed. Ring fixed-m (R > 1) - `_ring_fixed_m_norms_pre_a2a` computes every fixed-m input on the PRE-a2a shards (each device holds all heads x 1/(U*R) of the sequence): Q row norms, K max norms and the V max are reduced in ONE pmax over (ulysses, ring), then sliced to the heads this Ulysses rank owns after the a2a. Keeping these off the post-a2a layout lets them overlap the all-to-all instead of sitting between it and the `lax.cond`. With per_q_block=True a small ulysses-axis all_to_all of the Q row norms is also issued. - V safety: `v_ok = (max|V|^2 from that pmax <= 256^2) & dtype_safe`. It is derived from the pmax of V norms, so it is bit-identical on every (ulysses, ring) rank with no further collective. (The ring kernel's own pmin, which reduces global-bound eligibility, only runs when the caller omits `all_fixed_global`; the production caller always passes it.) - `all_fixed_global = all(qn * kn <= bound(N_total)) & v_ok`, also reduced over batch, is passed to the ring kernel and is the top-level accumulate-vs-LSE `lax.cond` predicate (`pregathered_mk=True`). It is mesh-uniform by construction. Blast radius: one ineligible head / Q-block on any device sends every rank to `_lse_scan` for that layer; per_q_block=True only refines dispatch inside the LSE path. How often that happens in production is not measured. - Only Q and K go through the optimization_barrier that stops XLA duplicating their producer chains into the norm reductions; V is left out so the Q/K a2a does not wait on V's producer. - Ring K/V are passed unpadded when the ring-shard KV length is 8-aligned (`kv_pad_size=1`; the kernel slices the tail, see custom_splash_unpadded_test). - Q * log2(e) is applied before the Ulysses a2a (exact; fuses into Q's producer instead of being wrapped in relayout copies after the collective). Virtual K-centering - Centering is virtual on both R == 1 and R > 1: k_mean goes to the kernel and q . k_bar is added to the fixed bound in VMEM; K is never re-written. For R > 1, k_mean = pmean(mean(K)) over (ulysses, ring) on the pre-a2a shards, and K norms are taken around that global mean. - Controlled by `use_k_centering` ("auto" | bool, new key in base_wan_27b.yml, defaulted in pyconfig). "auto" (`resolve_k_centering`) is ON for non-ring ulysses_custom_fixed_m* and OFF on every ring path, R == 1 included. The Wan pipelines do not pass this key to the attention layer in this PR, so Wan runs the layer default. Ring is kept OFF by default because, measured at the stack tip (Wan 2.2 T2V-A14B, 720p/81f/40 steps, warm AOT, DVFS unpinned), centering is never faster and it changes output: v6e-8 R == 1 127.2 s vs 127.2 s (neutral), R = 2 129.1 s vs 128.6 s (+0.4%); tpu7x-8 U=2/R=2 108.04 s vs 107.66 s (+0.35%), U=4 neutral. Turning it on changes the bf16 trajectory, with no visible quality difference. use_k_centering=True still forces it on. Other changes - `fused_rmsnorm_rope` (kernels/fused_producers.py): XLA-level fused RMSNorm + RoPE producer for self-attention Q/K, matching nnx.RMSNorm's association (x * (rsqrt(var + eps) * scale)) bit-for-bit. It is `jax.jit`-wrapped (heads/dim/eps static): inlined under the pipeline's outer jit, but when a full-size block is called eagerly (as wan_vace_transformer_test does at 75,600 tokens) XLA fuses the chain instead of materialising ~1.5 GB of extra intermediates, which OOMed that test on the v4-8 CI runner. - GQA: kv_heads plumbed through the custom Ulysses / ring paths; flash, tokamax, non-custom ulysses and ulysses_ring raise NotImplementedError on GQA. - Guards: non-ring ulysses kernels reject a ulysses_shards they cannot honour; ring kernels warn once when U == CP makes the ring degenerate (R = 1) and suggest a valid U for the mesh and head count. - Dot-product fallback: fixes the split_head_dim reshape that silently mis-read [B, H, S, D] inputs as [B, S, H*D]. - tile_size_grid_search: rejects iters < 1; `_broadcast_winner` handles bkv_compute=None. wan_block_benchmark / LTX2 / pyconfig know the new kernel names. Tests - ring_fixed_m_test.py (24): adds a U=2/R=2 integration class through `_ulysses_ring_custom_attention` vs a dense f32 reference (per_q_block x centering x GQA grid; ragged ring KV with kv_pad_size=1; a sink head that must force `_lse_scan` on all 4 devices; a single V outlier at B=2 that must make v_ok and all_fixed_global False on all 4 devices; and B=2 `_accumulate_scan` vmap + sub-128 head_dim `k_mean` padding at d=64). The branch predicates are checked through a probe of `_ring_fixed_m_norms_pre_a2a`, not the production cond itself. Needs >= 4 devices (explicit skipIf). - custom_splash_fixed_m_test.py (32), attention_config_guards_test.py (20), custom_splash_unpadded_test.py (12, TPU-only), dot_fallback_layout_test.py (5), fused_producers_test.py (6), tile_size_grid_search_test.py (16). - CI: 18 more tests skip in GitHub Actions (36 of 115 cumulative): custom_splash_unpadded_test (12), the GQA ring numerics test (1), and the 5 U=2/R=2 integration tests. Contract, guard, dot-fallback layout, fused producer and grid-search tests run in CI. run_wan_stack_tests.sh gains this PR's five new test files. Verified (final tree): TPU v6e-8 (jax 0.11.2, end_to_end/tpu/run_wan_stack_tests.sh): 115 passed, 107 subtests passed.
1 parent 2b5c462 commit 05e0987

19 files changed

Lines changed: 2674 additions & 384 deletions

‎end_to_end/tpu/run_wan_stack_tests.sh‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,11 @@ TESTS=(
2828
# Fixed-m custom splash kernel and ring (feat/fixed-m-kernel)
2929
"$T/custom_splash_fixed_m_test.py"
3030
"$T/ring_fixed_m_test.py"
31+
# Ulysses x Ring attention (feat/ring-attention)
32+
"$T/attention_config_guards_test.py"
33+
"$T/custom_splash_unpadded_test.py"
34+
"$T/dot_fallback_layout_test.py"
35+
"$T/fused_producers_test.py"
36+
"$T/tile_size_grid_search_test.py"
3137
)
3238
PYTHONPATH="src${PYTHONPATH:+:$PYTHONPATH}" exec "${PYTHON:-python3}" -m pytest -q -rs "${TESTS[@]}" "$@"

‎src/maxdiffusion/configs/base_wan_27b.yml‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ svg_low_noise_density: -1.0
103103
# {"block_q":3328,"block_kv":2816,"block_kv_compute":256,
104104
# "block_kv_compute_in":256,"heads_per_tile":1,"vmem_limit_bytes":67108864}.
105105
svg_flash_block_sizes: {}
106-
attention: 'flash' # Supported attention: dot_product, flash, tokamax_flash, cudnn_flash_te, ring, tokamax_ring, tokamax_ring_custom, ulysses, ulysses_custom, ulysses_ring, ulysses_ring_custom, ulysses_ring_custom_bidir
106+
attention: 'flash' # Supported attention: dot_product, flash, tokamax_flash, cudnn_flash_te, ring, tokamax_ring, tokamax_ring_custom, ulysses, ulysses_custom, ulysses_custom_fixed_m, ulysses_custom_fixed_m_per_q_block, ulysses_ring, ulysses_ring_custom, ulysses_ring_custom_fixed_m, ulysses_ring_custom_fixed_m_per_q_block, ulysses_ring_custom_bidir
107107

108108

109109
#
@@ -112,9 +112,27 @@ attention: 'flash' # Supported attention: dot_product, flash, tokamax_flash, cud
112112
# CP4 (v7x-8): ulysses_shards=2 (R=2), BQ=9472
113113
# CP8 (v7x-8): ulysses_shards=4 (R=2), BQ=9472
114114
# CP16 (v7x-16): ulysses_shards=8 (R=2), BQ=9472
115+
#
116+
# WARNING: ulysses_shards splits the context axis into (U, R = CP / U) on the
117+
# *ring* variants and is validated (must be -1 or equal to CP) on the non-ring
118+
# ulysses/ulysses_custom* variants:
119+
# - Setting U == CP on a ring variant gives R=1, which is a DEGENERATE ring:
120+
# no KV is rotated and the result is mathematically equivalent to the
121+
# non-ring ulysses_custom* kernel (fixed-m numerics may differ because
122+
# "auto" K-centering is off for ring variants and on for non-ring). Such a
123+
# run must not be reported as a ring result. The attention layer logs a
124+
# warning when this happens.
125+
# - The non-ring ulysses/ulysses_custom* kernels always use the full context
126+
# axis, so their Ulysses degree is fixed at CP. Passing ulysses_shards > 0
127+
# that does not equal CP raises ValueError instead of being silently ignored.
115128
use_base2_exp: True
116129
use_experimental_scheduler: True
117-
# For attention=ulysses_ring, hidden Ulysses shard count; ring shards are context / this.
130+
# auto: on for non-ring ulysses_custom_fixed_m* (virtual, no copy), off for ring
131+
# paths (virtual K-centering via k_mean, plus a pmean across ring shards when R>1).
132+
# Note: the Wan pipelines do not pass this key to the attention layer yet, so
133+
# the layer's default ("auto") applies.
134+
use_k_centering: "auto"
135+
# For attention=ulysses_ring*, hidden Ulysses shard count; ring shards are context / this.
118136
ulysses_shards: -1
119137
# Splits Ulysses all-to-all into head-group chunks. The last chunk carries any remainder.
120138
# For communication-compute overlap to be effective, enable the following XLA flags:

‎src/maxdiffusion/configs/ltx2_3_video.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ skip_jax_distributed_system: False
55
# dot_product, flash, tokamax_flash, tokamax_ring, tokamax_ring_custom,
66
# ulysses, ulysses_custom, ulysses_custom_fixed_m,
77
# ulysses_custom_fixed_m_per_q_block, ulysses_ring,
8-
# ulysses_ring_custom, ulysses_ring_custom_fixed_m, ulysses_ring_custom_bidir,
8+
# ulysses_ring_custom, ulysses_ring_custom_fixed_m,
9+
# ulysses_ring_custom_fixed_m_per_q_block, ulysses_ring_custom_bidir,
910
# and cudnn_flash_te (GPU only).
1011
attention: 'flash'
1112
use_base2_exp: False

‎src/maxdiffusion/configs/ltx2_video.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ skip_jax_distributed_system: False
55
# dot_product, flash, tokamax_flash, tokamax_ring, tokamax_ring_custom,
66
# ulysses, ulysses_custom, ulysses_custom_fixed_m,
77
# ulysses_custom_fixed_m_per_q_block, ulysses_ring,
8-
# ulysses_ring_custom, ulysses_ring_custom_fixed_m, ulysses_ring_custom_bidir,
8+
# ulysses_ring_custom, ulysses_ring_custom_fixed_m,
9+
# ulysses_ring_custom_fixed_m_per_q_block, ulysses_ring_custom_bidir,
910
# and cudnn_flash_te (GPU only).
1011
attention: 'flash'
1112
use_base2_exp: False
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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+
17+
"""Optimized fused producers for Wan Attention."""
18+
19+
import functools
20+
from typing import Tuple
21+
22+
import jax
23+
import jax.numpy as jnp
24+
25+
26+
# Static (non-array) parameters of the producer. They select shapes/epsilons and
27+
# must be compile-time constants so the jitted wrapper below can trace them.
28+
_STATIC_ARGNAMES = ("q_heads", "kv_heads", "dim_head", "eps", "k_eps")
29+
30+
31+
@functools.partial(jax.jit, static_argnames=_STATIC_ARGNAMES)
32+
def fused_rmsnorm_rope(
33+
raw_q: jax.Array,
34+
raw_k: jax.Array,
35+
q_norm_scale: jax.Array,
36+
k_norm_scale: jax.Array,
37+
freqs_cis: jax.Array,
38+
q_heads: int,
39+
kv_heads: int | None = None,
40+
dim_head: int = 128,
41+
eps: float = 1e-6,
42+
k_eps: float | None = None,
43+
) -> Tuple[jax.Array, jax.Array]:
44+
"""XLA-fused FP32 RMSNorm + BF16 RoPE + Head Transposition producer.
45+
46+
Performs FP32 RMSNorm normalization for numerical stability, casts to input
47+
dtype (e.g. BF16), and applies RoPE rotation and head transposition in input
48+
dtype precision, avoiding FP32 RoPE intermediates on long sequence lengths.
49+
Accepts an optional `kv_heads` for GQA (`kv_heads != q_heads`); defaults to
50+
`q_heads` (MHA, as used by `FlaxWanAttention`).
51+
52+
The function is `jax.jit`-wrapped (shape/epsilon parameters static). Inside the
53+
pipeline's outer jit this is inlined and changes nothing; when called eagerly
54+
(e.g. a full-size transformer block exercised in a unit test without an outer
55+
jit) it lets XLA fuse the normalize/rotate chain instead of materialising
56+
every intermediate, which otherwise exceeds HBM on long sequences.
57+
58+
Args:
59+
raw_q: Raw query projection of shape [B, Sq, Dq] (where Dq = q_heads * dim_head).
60+
raw_k: Raw key projection of shape [B, Sk, Dk] (where Dk = kv_heads * dim_head).
61+
q_norm_scale: RMSNorm scale parameter for query of shape [Dq].
62+
k_norm_scale: RMSNorm scale parameter for key of shape [Dk].
63+
freqs_cis: Complex rotary embedding tensor of shape [1, 1, S, dim_head // 2].
64+
q_heads: Number of query attention heads.
65+
kv_heads: Number of key/value attention heads (defaults to q_heads for MHA).
66+
dim_head: Dimension of each attention head.
67+
eps: Epsilon for query RMSNorm numerical stability (and key if k_eps is None).
68+
k_eps: Optional separate epsilon for key RMSNorm numerical stability.
69+
70+
Returns:
71+
Transposed and RoPE-rotated (q_out, k_out) of shapes [B, q_heads, Sq, dim_head]
72+
and [B, kv_heads, Sk, dim_head].
73+
"""
74+
kv_heads = q_heads if kv_heads is None else kv_heads
75+
effective_k_eps = eps if k_eps is None else k_eps
76+
B, Sq, Dq = raw_q.shape
77+
_, Sk, Dk = raw_k.shape
78+
79+
if Dq != q_heads * dim_head:
80+
raise ValueError(f"raw_q feature dim ({Dq}) must equal q_heads ({q_heads}) * dim_head ({dim_head})")
81+
if Dk != kv_heads * dim_head:
82+
raise ValueError(f"raw_k feature dim ({Dk}) must equal kv_heads ({kv_heads}) * dim_head ({dim_head})")
83+
84+
# 1. FP32 RMSNorm for stability, then cast directly to target activation dtype.
85+
#
86+
# Association matters: Flax's `_normalize` computes `mul = rsqrt(var + eps)`,
87+
# then `mul *= scale`, then `y = x * mul` -- i.e. x * (rsqrt * scale). Folding
88+
# left-to-right as (x * rsqrt) * scale rounds differently and makes this path
89+
# drift from `nnx.RMSNorm` bit-for-bit. Keep the parenthesisation below in
90+
# step with Flax so the fused producer stays a pure fusion, not a numerical
91+
# change.
92+
q_fp32 = raw_q.astype(jnp.float32)
93+
q_rms = jax.lax.rsqrt(jnp.mean(jnp.square(q_fp32), axis=-1, keepdims=True) + eps)
94+
q_norm = (q_fp32 * (q_rms * q_norm_scale.astype(jnp.float32))).astype(raw_q.dtype)
95+
96+
k_fp32 = raw_k.astype(jnp.float32)
97+
k_rms = jax.lax.rsqrt(jnp.mean(jnp.square(k_fp32), axis=-1, keepdims=True) + effective_k_eps)
98+
k_norm = (k_fp32 * (k_rms * k_norm_scale.astype(jnp.float32))).astype(raw_k.dtype)
99+
100+
# 2. Reshape and transpose to [B, heads, S, dim_head]
101+
q_h = q_norm.reshape(B, Sq, q_heads, dim_head).transpose(0, 2, 1, 3)
102+
k_h = k_norm.reshape(B, Sk, kv_heads, dim_head).transpose(0, 2, 1, 3)
103+
104+
# 3. Direct RoPE with freqs_cis [1, 1, S, dim_head // 2] in input dtype
105+
cos = jnp.real(freqs_cis).astype(raw_q.dtype)
106+
sin = jnp.imag(freqs_cis).astype(raw_q.dtype)
107+
cos_q, sin_q = cos[:, :, :Sq, :], sin[:, :, :Sq, :]
108+
cos_k, sin_k = cos[:, :, :Sk, :], sin[:, :, :Sk, :]
109+
110+
q_pairs = q_h.reshape(B, q_heads, Sq, -1, 2)
111+
q_0, q_1 = q_pairs[..., 0], q_pairs[..., 1]
112+
q_out_0 = q_0 * cos_q - q_1 * sin_q
113+
q_out_1 = q_0 * sin_q + q_1 * cos_q
114+
q_out = jnp.stack([q_out_0, q_out_1], axis=-1).reshape(B, q_heads, Sq, dim_head)
115+
116+
k_pairs = k_h.reshape(B, kv_heads, Sk, -1, 2)
117+
k_0, k_1 = k_pairs[..., 0], k_pairs[..., 1]
118+
k_out_0 = k_0 * cos_k - k_1 * sin_k
119+
k_out_1 = k_0 * sin_k + k_1 * cos_k
120+
k_out = jnp.stack([k_out_0, k_out_1], axis=-1).reshape(B, kv_heads, Sk, dim_head)
121+
122+
return q_out, k_out

0 commit comments

Comments
 (0)