Skip to content

Commit d0da5df

Browse files
committed
perf(wan): XLA-fused RMSNorm + RoPE producer for self-attention Q/K
Adds `fused_rmsnorm_rope` (kernels/fused_producers.py) and uses it in `FlaxWanAttention.__call__` for the self-attention Q/K producer chain. What - `fused_rmsnorm_rope(raw_q, raw_k, q_scale, k_scale, freqs_cis, ...)` does FP32 RMSNorm, casts back to the input dtype, then applies RoPE and the head transposition in input dtype, so no FP32 RoPE intermediates are materialised at long sequence lengths. It mirrors nnx.RMSNorm's association (`x * (rsqrt(var + eps) * scale)`): bit-for-bit in f32, and in bf16 when the norm scale is bf16. With bf16 activations and an f32 scale, nnx.RMSNorm's `promote_dtype` rounds the scale to bf16 before folding it into the rsqrt, while the fused producer keeps the scale in f32 and rounds only the final product; the two then differ by at most one bf16 ulp. It takes separate q/k epsilons, accepts an optional `kv_heads` for GQA (defaults to `q_heads`), and validates `freqs_cis.shape[2] == Sq == Sk`. - It is `jax.jit`-wrapped with heads/dim/eps static: under the pipeline's outer jit it is simply inlined, 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. - `FlaxWanAttention` takes the fused path when `rotary_emb` is given, `qk_norm` is on and the call is self-attention, reading `self.norm_q.epsilon` and `self.norm_k.epsilon` directly; cross-attention and the no-RoPE path keep the separate norm_q / norm_k / `_apply_rope` steps. Tests - fused_producers_test.py (10, new, CPU): bit-for-bit match with nnx.RMSNorm in f32 and in bf16 (bf16 scale), the bf16-activation / f32-scale case pinned to <= 1 bf16 ulp of nnx.RMSNorm, the reassociated form is shown to differ, fused Q matches flax RMSNorm, separate q/k epsilons are applied, non-trivial RoPE in bfloat16 matches the unfused reference, GQA shapes/values, `freqs_cis` sequence-length mismatch guard, VJP parity against unfused grads, and end-to-end `FlaxWanAttention` module wiring + unfused forward parity. - Added to end_to_end/tpu/run_wan_stack_tests.sh (139 tests cumulative).
1 parent a33bb3d commit d0da5df

4 files changed

Lines changed: 535 additions & 13 deletions

File tree

‎end_to_end/tpu/run_wan_stack_tests.sh‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,7 @@ TESTS=(
3535
# Ulysses x Ring attention (feat/ring-attention)
3636
"$T/attention_config_guards_test.py"
3737
"$T/custom_splash_unpadded_test.py"
38+
# XLA-fused RMSNorm+RoPE producer (feat/fused-rmsnorm-rope-xla)
39+
"$T/fused_producers_test.py"
3840
)
3941
PYTHONPATH="src${PYTHONPATH:+:$PYTHONPATH}" exec "${PYTHON:-python3}" -m pytest -q -rs "${TESTS[@]}" "$@"
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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+
if freqs_cis.shape[2] != Sq or freqs_cis.shape[2] != Sk:
106+
raise ValueError(
107+
f"freqs_cis has {freqs_cis.shape[2]} positions, but raw_q/raw_k have Sq={Sq}, Sk={Sk}; "
108+
"fused_rmsnorm_rope requires Sq == Sk == freqs_cis.shape[2]."
109+
)
110+
cos = jnp.real(freqs_cis)
111+
sin = jnp.imag(freqs_cis)
112+
cos_q, sin_q = cos.astype(raw_q.dtype), sin.astype(raw_q.dtype)
113+
cos_k, sin_k = cos.astype(raw_k.dtype), sin.astype(raw_k.dtype)
114+
115+
q_pairs = q_h.reshape(B, q_heads, Sq, -1, 2)
116+
q_0, q_1 = q_pairs[..., 0], q_pairs[..., 1]
117+
q_out_0 = q_0 * cos_q - q_1 * sin_q
118+
q_out_1 = q_0 * sin_q + q_1 * cos_q
119+
q_out = jnp.stack([q_out_0, q_out_1], axis=-1).reshape(B, q_heads, Sq, dim_head)
120+
121+
k_pairs = k_h.reshape(B, kv_heads, Sk, -1, 2)
122+
k_0, k_1 = k_pairs[..., 0], k_pairs[..., 1]
123+
k_out_0 = k_0 * cos_k - k_1 * sin_k
124+
k_out_1 = k_0 * sin_k + k_1 * cos_k
125+
k_out = jnp.stack([k_out_0, k_out_1], axis=-1).reshape(B, kv_heads, Sk, dim_head)
126+
127+
return q_out, k_out

‎src/maxdiffusion/models/attention_flax.py‎

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from maxdiffusion.kernels.splash_attention import splash_attention_kernel as tokamax_splash_attention_kernel
2929
from maxdiffusion.kernels.splash_attention import ring_attention_kernel as tokamax_ring_attention_kernel
3030
from maxdiffusion.kernels.splash_attention import base as tokamax_splash_base
31+
from maxdiffusion.kernels.fused_producers import fused_rmsnorm_rope
3132
from einops import rearrange
3233
from .. import common_types, max_logging
3334
from maxdiffusion.tpu_utils import get_tpu_type, TpuType
@@ -3717,20 +3718,40 @@ def __call__(
37173718
with jax.named_scope("value_proj"):
37183719
value_proj = self.value(encoder_hidden_states)
37193720

3720-
if self.qk_norm:
3721-
with self.conditional_named_scope("attn_q_norm"):
3722-
query_proj = self.norm_q(query_proj)
3723-
if is_self_attention or cached_kv is None or "text" not in cached_kv:
3724-
with self.conditional_named_scope("attn_k_norm"):
3725-
key_proj = self.norm_k(key_proj)
3726-
3727-
if rotary_emb is not None:
3728-
with self.conditional_named_scope("attn_rope"):
3729-
query_proj = _unflatten_heads(query_proj, self.heads)
3730-
key_proj = _unflatten_heads(key_proj, self.heads)
3721+
if rotary_emb is not None and self.qk_norm and is_self_attention:
3722+
with self.conditional_named_scope("fused_rmsnorm_rope"):
3723+
q_scale = self.norm_q.scale[...]
3724+
k_scale = self.norm_k.scale[...]
3725+
# norm_q is built with epsilon=eps; norm_k keeps flax's default (1e-6).
3726+
q_eps = self.norm_q.epsilon
3727+
k_eps = self.norm_k.epsilon
3728+
query_proj, key_proj = fused_rmsnorm_rope(
3729+
query_proj,
3730+
key_proj,
3731+
q_scale,
3732+
k_scale,
3733+
rotary_emb,
3734+
q_heads=self.heads,
3735+
dim_head=self.dim_head,
3736+
eps=q_eps,
3737+
k_eps=k_eps,
3738+
)
37313739
value_proj = _unflatten_heads(value_proj, self.heads)
3732-
# output of _unflatten_heads Batch, heads, seq_len, head_dim
3733-
query_proj, key_proj = self._apply_rope(query_proj, key_proj, rotary_emb)
3740+
else:
3741+
if self.qk_norm:
3742+
with self.conditional_named_scope("attn_q_norm"):
3743+
query_proj = self.norm_q(query_proj)
3744+
if is_self_attention or cached_kv is None or "text" not in cached_kv:
3745+
with self.conditional_named_scope("attn_k_norm"):
3746+
key_proj = self.norm_k(key_proj)
3747+
3748+
if rotary_emb is not None:
3749+
with self.conditional_named_scope("attn_rope"):
3750+
query_proj = _unflatten_heads(query_proj, self.heads)
3751+
key_proj = _unflatten_heads(key_proj, self.heads)
3752+
value_proj = _unflatten_heads(value_proj, self.heads)
3753+
# output of _unflatten_heads Batch, heads, seq_len, head_dim
3754+
query_proj, key_proj = self._apply_rope(query_proj, key_proj, rotary_emb)
37343755

37353756
query_proj = checkpoint_name(query_proj, "query_proj")
37363757
key_proj = checkpoint_name(key_proj, "key_proj")

0 commit comments

Comments
 (0)