|
| 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() |
0 commit comments