Skip to content

Commit ee90fa1

Browse files
committed
fix(attention): dot-product fallback layout for 4-D inputs; tile_size_grid_search iters/bkv_compute guards
Two small fixes peeled out of the Ulysses x Ring PR so that they can be reviewed on their own. Dot-product fallback (`_apply_attention_dot`) - Sequences below `flash_min_seq_length` bypass the flash/ulysses kernels and run the dot-product fallback. Callers that apply rotary embeddings hand the dispatcher `[B, H, S, D]` (the dispatcher reads S from axis 2 when `ndim == 4`), but the `split_head_dim` path reshaped those tensors as if they were `[B, S, H*D]`. Both layouts hold the same number of elements, so the reshape succeeded and returned a wrong answer silently. 4-D inputs are now transposed to `[B, S, H, D]` (`_to_bshd`); the flat 3-D contract is unchanged. - `kv_heads` (optional, default None) is accepted so that K/V with fewer heads than Q are repeated on the head axis after the transpose, on both the `split_head_dim` and batched-heads paths; `heads % kv_heads != 0` raises ValueError instead of silently truncating the repeat factor. `dot_product_kernel` forwards `context.get("kv_heads")`. tile_size_grid_search - `time_callable` rejects `iters < 1` instead of dividing by zero. - `_broadcast_winner` encodes `bkv_compute=None` as -1 in the broadcast payload and decodes it back, so a winner without an explicit compute block is found again on the non-source hosts. Tests - dot_fallback_layout_test.py (6, new, CPU): the reviewer's counterexample (uniform softmax must give per-head token means), random 4-D inputs vs a dense reference and GQA repeat on 4-D (both over split_head_dim in {True, False}), 3-D/4-D agreement, the heads % kv_heads guard, and the dispatcher seq-axis contract: `_apply_attention` on a [B, H=128, S=32, D] query with flash_min_seq_length=64 must route to a spied dot_product kernel (S read from axis 2) that then returns the dense reference. - tile_size_grid_search_test.py (16, +2): `iters=0` raises; `_broadcast_winner` round-trips `bkv_compute=None`. - Both files are added to end_to_end/tpu/run_wan_stack_tests.sh. Verified (this head): TPU v6e-8 (jax 0.11.2), every file of end_to_end/tpu/run_wan_stack_tests.sh at this head run directly with unittest: custom_splash_fixed_m 31, ring_fixed_m 18, dot_fallback_layout 6, tile_size_grid_search 16 -- 71 tests, all OK.
1 parent 9b84d26 commit ee90fa1

5 files changed

Lines changed: 293 additions & 8 deletions

File tree

‎end_to_end/tpu/run_wan_stack_tests.sh‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,8 @@ 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+
# Dot-product fallback layout and tile-size grid-search fixes (feat/attn-dot-fallback-fixes)
32+
"$T/dot_fallback_layout_test.py"
33+
"$T/tile_size_grid_search_test.py"
3134
)
3235
PYTHONPATH="src${PYTHONPATH:+:$PYTHONPATH}" exec "${PYTHON:-python3}" -m pytest -q -rs "${TESTS[@]}" "$@"

‎src/maxdiffusion/models/attention_flax.py‎

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1725,17 +1725,52 @@ def _apply_attention_dot(
17251725
float32_qk_product: bool,
17261726
use_memory_efficient_attention: bool,
17271727
attention_mask: Array = None,
1728+
kv_heads: int | None = None,
17281729
):
17291730
"""Apply Attention."""
1731+
effective_kv_heads = kv_heads if kv_heads is not None else heads
1732+
if heads <= 0 or effective_kv_heads <= 0:
1733+
raise ValueError(f"heads ({heads}) and kv_heads ({effective_kv_heads}) must be positive integers.")
1734+
if heads % effective_kv_heads:
1735+
raise ValueError(f"heads ({heads}) must be a multiple of kv_heads ({effective_kv_heads}).")
17301736
if split_head_dim:
1731-
b = key.shape[0]
1732-
query_states = jnp.reshape(query, (b, -1, heads, dim_head))
1733-
key_states = jnp.reshape(key, (b, -1, heads, dim_head))
1734-
value_states = jnp.reshape(value, (b, -1, heads, dim_head))
1737+
1738+
def _to_bshd(x: Array, n_heads: int) -> Array:
1739+
"""Normalise to [B, S, H, D].
1740+
1741+
Callers that apply rotary embeddings hand us [B, H, S, D] (see
1742+
`_unflatten_heads`), while the flat path supplies [B, S, H*D]. Only the
1743+
latter can be reshaped into [B, S, H, D]; reinterpreting [B, H, S, D]
1744+
that way keeps the shape legal but interleaves heads with tokens, so it
1745+
corrupts the output silently. Transpose the 4-D case instead.
1746+
"""
1747+
if x.ndim == 4:
1748+
return jnp.swapaxes(x, 1, 2)
1749+
return jnp.reshape(x, (x.shape[0], -1, n_heads, dim_head))
1750+
1751+
query_states = _to_bshd(query, heads)
1752+
key_states = _to_bshd(key, effective_kv_heads)
1753+
value_states = _to_bshd(value, effective_kv_heads)
1754+
if heads != effective_kv_heads:
1755+
num_repeats = heads // effective_kv_heads
1756+
key_states = jnp.repeat(key_states, num_repeats, axis=2)
1757+
value_states = jnp.repeat(value_states, num_repeats, axis=2)
17351758
else:
17361759
query_states = _reshape_heads_to_batch_dim(query, heads)
1737-
key_states = _reshape_heads_to_batch_dim(key, heads)
1738-
value_states = _reshape_heads_to_batch_dim(value, heads)
1760+
key_states = _reshape_heads_to_batch_dim(key, effective_kv_heads)
1761+
value_states = _reshape_heads_to_batch_dim(value, effective_kv_heads)
1762+
if heads != effective_kv_heads:
1763+
num_repeats = heads // effective_kv_heads
1764+
b = query.shape[0]
1765+
s_k = key_states.shape[1]
1766+
key_states = jnp.repeat(key_states.reshape(b, effective_kv_heads, s_k, -1), num_repeats, axis=1).reshape(
1767+
b * heads, s_k, -1
1768+
)
1769+
value_states = jnp.repeat(
1770+
value_states.reshape(b, effective_kv_heads, s_k, -1),
1771+
num_repeats,
1772+
axis=1,
1773+
).reshape(b * heads, s_k, -1)
17391774

17401775
if float32_qk_product:
17411776
query_states = query_states.astype(jnp.float32)
@@ -1841,6 +1876,7 @@ def dot_product_kernel(q, k, v, context):
18411876
context["float32_qk_product"],
18421877
context["use_memory_efficient_attention"],
18431878
context["attention_mask"],
1879+
kv_heads=context.get("kv_heads", None),
18441880
)
18451881

18461882

@@ -2165,6 +2201,7 @@ def _apply_attention(
21652201
preserve_asymmetric_block_sizes: bool = False,
21662202
spatiotemporal_config: Optional[dict] = None,
21672203
spatiotemporal_shape: Optional[Tuple[int, int, int]] = None,
2204+
kv_heads: Optional[int] = None,
21682205
):
21692206
"""Routes to different attention kernels using a module-level registry."""
21702207

@@ -2213,6 +2250,7 @@ def _apply_attention(
22132250

22142251
context = {
22152252
"heads": heads,
2253+
"kv_heads": kv_heads,
22162254
"mesh": mesh,
22172255
"axis_names_q": axis_names_q,
22182256
"axis_names_kv": axis_names_kv,
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
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+
"""Layout regression tests for the short-sequence dot-product fallback.
18+
19+
Sequences below `flash_min_seq_length` bypass the flash/ulysses kernels and
20+
run `_apply_attention_dot`. Callers that apply rotary embeddings hand the
21+
dispatcher `[B, H, S, D]` -- the dispatcher says so itself, reading the
22+
sequence length from axis 2 when `ndim == 4` -- but the `split_head_dim` path
23+
reshaped those tensors as if they were `[B, S, H*D]`.
24+
25+
Both layouts hold the same number of elements, so the reshape succeeded and
26+
returned a wrong answer with no error. That silent corruption is what these
27+
tests exist to prevent. They are pure JAX and run on CPU.
28+
"""
29+
30+
import math
31+
import unittest
32+
from unittest import mock
33+
34+
import jax
35+
import jax.numpy as jnp
36+
import numpy as np
37+
38+
from maxdiffusion.models import attention_flax
39+
from maxdiffusion.models.attention_flax import _apply_attention, _apply_attention_dot
40+
41+
42+
def _call_dot(query, key, value, heads, dim_head, kv_heads=None, split_head_dim=True):
43+
"""Runs the fallback and returns [B, H, S, D].
44+
45+
`_apply_attention_dot` emits the flat `[B, S, H*D]` form, so unflatten it
46+
here rather than at each call site.
47+
"""
48+
out = _apply_attention_dot(
49+
query=query,
50+
key=key,
51+
value=value,
52+
dtype=jnp.float32,
53+
heads=heads,
54+
dim_head=dim_head,
55+
scale=1.0 / math.sqrt(dim_head),
56+
split_head_dim=split_head_dim,
57+
float32_qk_product=True,
58+
use_memory_efficient_attention=False,
59+
attention_mask=None,
60+
kv_heads=kv_heads,
61+
)
62+
batch, seq, _ = out.shape
63+
return jnp.swapaxes(out.reshape(batch, seq, heads, dim_head), 1, 2)
64+
65+
66+
def _reference_attention(query, key, value, scale):
67+
"""Dense f32 attention on explicit [B, H, S, D] inputs."""
68+
q, k, v = (x.astype(jnp.float32) for x in (query, key, value))
69+
logits = jnp.einsum("bhqd,bhkd->bhqk", q, k) * scale
70+
return jnp.einsum("bhqk,bhkd->bhqd", jax.nn.softmax(logits, axis=-1), v)
71+
72+
73+
class DotFallbackLayoutTest(unittest.TestCase):
74+
"""`_apply_attention_dot` must transpose, not reshape, 4-D inputs."""
75+
76+
def test_zero_logits_return_per_head_token_means(self):
77+
"""The reviewer's counterexample, reproduced exactly.
78+
79+
One active head dimension, three tokens, two heads. Q = K = 0 makes every
80+
logit zero, so softmax is uniform and each head's output is the mean of
81+
its values over tokens:
82+
83+
head 0 values [1, 2, 3] -> 2
84+
head 1 values [10, 20, 30] -> 20
85+
86+
Reinterpreting [B, H, S, D] as [B, S, H, D] instead walks the buffer
87+
[1, 2, 3, 10, 20, 30] as three token-pairs (1,2), (3,10), (20,30),
88+
producing [8, 14].
89+
"""
90+
heads, seq, dim_head = 2, 3, 1
91+
shape = (1, heads, seq, dim_head)
92+
query = jnp.zeros(shape, jnp.float32)
93+
key = jnp.zeros(shape, jnp.float32)
94+
value = jnp.array([[[[1.0], [2.0], [3.0]], [[10.0], [20.0], [30.0]]]], dtype=jnp.float32)
95+
self.assertEqual(value.shape, shape)
96+
97+
out = np.asarray(_call_dot(query, key, value, heads, dim_head))
98+
99+
np.testing.assert_allclose(out[0, 0], 2.0, rtol=1e-5, atol=1e-5)
100+
np.testing.assert_allclose(out[0, 1], 20.0, rtol=1e-5, atol=1e-5)
101+
# The exact wrong answer the reshape produced.
102+
self.assertFalse(np.allclose(out[0, 0], 8.0))
103+
self.assertFalse(np.allclose(out[0, 1], 14.0))
104+
105+
def test_matches_reference_on_random_4d_inputs(self):
106+
"""Both the [B, S, H, D] and the batched-heads ([B*H, S, D]) paths."""
107+
heads, seq, dim_head = 4, 8, 16
108+
shape = (2, heads, seq, dim_head)
109+
query = jax.random.normal(jax.random.PRNGKey(0), shape, jnp.float32)
110+
key = jax.random.normal(jax.random.PRNGKey(1), shape, jnp.float32)
111+
value = jax.random.normal(jax.random.PRNGKey(2), shape, jnp.float32)
112+
expected = np.asarray(_reference_attention(query, key, value, 1.0 / math.sqrt(dim_head)))
113+
114+
for split_head_dim in (True, False):
115+
with self.subTest(split_head_dim=split_head_dim):
116+
out = np.asarray(_call_dot(query, key, value, heads, dim_head, split_head_dim=split_head_dim))
117+
np.testing.assert_allclose(out, expected, rtol=1e-4, atol=1e-4)
118+
119+
def test_three_dim_inputs_agree_with_four_dim(self):
120+
"""The flat [B, S, H*D] contract must keep working, and agree."""
121+
heads, seq, dim_head = 4, 8, 16
122+
batch = 2
123+
shape = (batch, heads, seq, dim_head)
124+
query = jax.random.normal(jax.random.PRNGKey(3), shape, jnp.float32)
125+
key = jax.random.normal(jax.random.PRNGKey(4), shape, jnp.float32)
126+
value = jax.random.normal(jax.random.PRNGKey(5), shape, jnp.float32)
127+
128+
def flatten(x):
129+
return jnp.swapaxes(x, 1, 2).reshape(batch, seq, heads * dim_head)
130+
131+
out_4d = np.asarray(_call_dot(query, key, value, heads, dim_head))
132+
out_3d = np.asarray(_call_dot(flatten(query), flatten(key), flatten(value), heads, dim_head))
133+
np.testing.assert_allclose(out_4d, out_3d, rtol=1e-5, atol=1e-5)
134+
135+
def test_gqa_repeat_still_applies_on_4d(self):
136+
"""Head repetition must happen after the transpose, on the head axis."""
137+
heads, kv_heads, seq, dim_head = 4, 2, 8, 16
138+
q = jax.random.normal(jax.random.PRNGKey(6), (1, heads, seq, dim_head), jnp.float32)
139+
k = jax.random.normal(jax.random.PRNGKey(7), (1, kv_heads, seq, dim_head), jnp.float32)
140+
v = jax.random.normal(jax.random.PRNGKey(8), (1, kv_heads, seq, dim_head), jnp.float32)
141+
142+
expected = np.asarray(
143+
_reference_attention(
144+
q,
145+
jnp.repeat(k, heads // kv_heads, axis=1),
146+
jnp.repeat(v, heads // kv_heads, axis=1),
147+
1.0 / math.sqrt(dim_head),
148+
)
149+
)
150+
for split_head_dim in (True, False):
151+
with self.subTest(split_head_dim=split_head_dim):
152+
out = np.asarray(_call_dot(q, k, v, heads, dim_head, kv_heads=kv_heads, split_head_dim=split_head_dim))
153+
np.testing.assert_allclose(out, expected, rtol=1e-4, atol=1e-4)
154+
155+
def test_rejects_kv_heads_that_do_not_divide_heads(self):
156+
"""A non-integer repeat factor or non-positive head count must raise ValueError."""
157+
heads, kv_heads, seq, dim_head = 4, 3, 8, 16
158+
q = jnp.zeros((1, heads, seq, dim_head), jnp.float32)
159+
kv = jnp.zeros((1, kv_heads, seq, dim_head), jnp.float32)
160+
for split_head_dim in (True, False):
161+
with self.subTest(split_head_dim=split_head_dim):
162+
with self.assertRaisesRegex(ValueError, "multiple of kv_heads"):
163+
_call_dot(q, kv, kv, heads, dim_head, kv_heads=kv_heads, split_head_dim=split_head_dim)
164+
with self.assertRaisesRegex(ValueError, "positive integers"):
165+
_call_dot(q, kv, kv, heads, dim_head, kv_heads=0, split_head_dim=split_head_dim)
166+
with self.assertRaisesRegex(ValueError, "positive integers"):
167+
_call_dot(q, kv, kv, heads, dim_head, kv_heads=-2, split_head_dim=split_head_dim)
168+
169+
170+
class DispatcherLayoutContractTest(unittest.TestCase):
171+
"""The threshold check and the dot path must agree on where S lives."""
172+
173+
def test_dispatcher_reads_seq_len_from_axis_2_and_dot_path_honours_it(self):
174+
"""`_apply_attention` reads S from axis 2 for 4-D inputs, i.e. [B, H, S, D].
175+
176+
The shape is chosen so the two readings disagree: H = 128 is above the
177+
threshold while S = 32 is below it. Reading S from axis 2 routes to the
178+
dot-product fallback, which must then interpret the same tensor as
179+
[B, H, S, D] (the transpose, not the reshape). Reading S from axis 1 would
180+
try to run the configured flash kernel instead and never reach the spy.
181+
"""
182+
batch, heads, seq, dim_head = 1, 128, 32, 8
183+
flash_min_seq_length = 64
184+
shape = (batch, heads, seq, dim_head)
185+
query = jax.random.normal(jax.random.PRNGKey(9), shape, jnp.float32)
186+
key = jax.random.normal(jax.random.PRNGKey(10), shape, jnp.float32)
187+
value = jax.random.normal(jax.random.PRNGKey(11), shape, jnp.float32)
188+
scale = 1.0 / math.sqrt(dim_head)
189+
190+
real_kernel = attention_flax.KERNEL_REGISTRY["dot_product"]
191+
spy = mock.Mock(side_effect=real_kernel)
192+
with mock.patch.dict(attention_flax.KERNEL_REGISTRY, {"dot_product": spy}):
193+
out = _apply_attention(
194+
query,
195+
key,
196+
value,
197+
heads=heads,
198+
dim_head=dim_head,
199+
split_head_dim=True,
200+
float32_qk_product=True,
201+
attention_kernel="ulysses_custom",
202+
flash_min_seq_length=flash_min_seq_length,
203+
use_memory_efficient_attention=False,
204+
scale=scale,
205+
dtype=jnp.float32,
206+
mesh=None,
207+
axis_names_q=None,
208+
axis_names_kv=None,
209+
flash_block_sizes=None,
210+
dpa_layer=None,
211+
)
212+
213+
spy.assert_called_once()
214+
seen_query = spy.call_args.args[0]
215+
self.assertEqual(seen_query.shape, shape)
216+
self.assertLess(seen_query.shape[2], flash_min_seq_length)
217+
self.assertGreaterEqual(seen_query.shape[1], flash_min_seq_length)
218+
out = jnp.swapaxes(jnp.reshape(out, (batch, seq, heads, dim_head)), 1, 2)
219+
expected = _reference_attention(query, key, value, scale)
220+
np.testing.assert_allclose(np.asarray(out), np.asarray(expected), rtol=1e-4, atol=1e-4)
221+
222+
223+
if __name__ == "__main__":
224+
unittest.main()

‎src/maxdiffusion/tests/tile_size_grid_search_test.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ def fake_fn():
129129
self.assertLess(mean, 30.0) # ...and NOT in the steady-state mean
130130
self.assertEqual(len(times), 5)
131131

132+
def test_rejects_nonpositive_iters(self):
133+
with self.assertRaises(ValueError):
134+
time_callable(lambda: 1, iters=0)
135+
132136

133137
class OrchestratorTest(unittest.TestCase):
134138

@@ -167,6 +171,19 @@ def test_oom_configs_pruned_not_raised(self):
167171
)
168172
self.assertTrue(any(r.status == "oom" for r in res.results))
169173

174+
def test_broadcast_winner_handles_none_bkv_compute(self):
175+
from unittest import mock
176+
from maxdiffusion.utils.tile_size_grid_search import _broadcast_winner
177+
178+
cand = BenchResult(bq=1024, bkv=512, bkv_compute=None, status="ok", mean_ms=10.0)
179+
with (
180+
mock.patch("jax.process_count", return_value=2),
181+
mock.patch("jax.process_index", return_value=0),
182+
mock.patch("jax.experimental.multihost_utils.broadcast_one_to_all", side_effect=lambda x, is_source: x),
183+
):
184+
out = _broadcast_winner(cand, [cand])
185+
self.assertIs(out, cand)
186+
170187

171188
if __name__ == "__main__":
172189
unittest.main()

‎src/maxdiffusion/utils/tile_size_grid_search.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,8 @@ def time_callable(fn, *, iters: int = 10, warmup: int = 2, sync=lambda x: x):
259259
import statistics
260260
import time
261261

262+
if iters < 1:
263+
raise ValueError(f"iters must be >= 1, got {iters}.")
262264
t0 = time.perf_counter()
263265
sync(fn()) # call #1: compilation happens HERE, untimed
264266
compile_ms = (time.perf_counter() - t0) * 1e3
@@ -380,12 +382,13 @@ def _broadcast_winner(best: Optional[BenchResult], results: list[BenchResult]) -
380382
is_source = jax.process_index() == 0
381383
payload = np.zeros((4,), dtype=np.int64)
382384
if is_source and best is not None:
383-
payload[:] = (1, best.bq, best.bkv, best.bkv_compute)
385+
bkv_cmp = -1 if best.bkv_compute is None else best.bkv_compute
386+
payload[:] = (1, best.bq, best.bkv, bkv_cmp)
384387
payload = np.asarray(multihost_utils.broadcast_one_to_all(payload, is_source=is_source))
385388
if payload[0] == 0:
386389
return None
387390

388-
winner_key = tuple(int(value) for value in payload[1:])
391+
winner_key = (int(payload[1]), int(payload[2]), None if int(payload[3]) < 0 else int(payload[3]))
389392
for result in results:
390393
if (result.bq, result.bkv, result.bkv_compute) == winner_key and result.status == "ok":
391394
return result

0 commit comments

Comments
 (0)