Skip to content

Commit 93c738d

Browse files
committed
feat(splash-attention): heads_per_tile + configurable vmem_limit_bytes for ulysses ring attention
1 parent f62927f commit 93c738d

4 files changed

Lines changed: 410 additions & 31 deletions

File tree

src/maxdiffusion/kernels/splash_attention/ring_attention_kernel_test.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from absl.testing import parameterized
2222
import jax
2323
from jax import random
24+
from jax.experimental import multihost_utils
2425
import jax.numpy as jnp
2526
import numpy as np
2627
from . import base
@@ -160,5 +161,137 @@ def ring_attn(ring_kernel, q, k, v, segment_ids):
160161
self._assert_allclose(dv, dv_ref, rtol=1e-2, atol=1e-2)
161162

162163

164+
class RingAttentionHeadsPerTileTest(test_utils.SplashAttentionTestCase):
165+
"""`heads_per_tile` (multi-head-per-tile) invariance for ring attention.
166+
167+
heads_per_tile is a pure tiling/scheduling choice for the forward kernel: for a
168+
fixed input, running with heads_per_tile=N must produce the same output as
169+
heads_per_tile=1. This guards against the block-index class of bug (a wrong
170+
head-tile mapping compiles and runs but silently returns garbage).
171+
"""
172+
173+
def setUp(self):
174+
if jax.default_backend() != "tpu":
175+
self.skipTest("Multi-head-per-tile ring attention runs on TPU.")
176+
super().setUp()
177+
178+
@parameterized.product(
179+
heads_per_tile=[2, 4],
180+
head_dim=[128],
181+
dtype=[jnp.bfloat16],
182+
)
183+
def test_heads_per_tile_matches_single_head(self, heads_per_tile, head_dim, dtype):
184+
# Use ALL devices so the sharded outputs are addressable on every process
185+
# (a subset mesh like jax.devices()[:2] lives only on process 0, making the
186+
# result non-addressable on the other hosts). jax.devices() is itself a
187+
# global barrier, so every host must launch this test together regardless.
188+
ring_size = jax.device_count()
189+
num_heads = 8 # MHA (num_q_heads == num_kv_heads); divisible by heads_per_tile.
190+
if ring_size < 2:
191+
self.skipTest(f"This test needs at least 2 devices, but has {ring_size}.")
192+
193+
ring_axis = "ring"
194+
devices = np.asarray(jax.devices()).reshape(1, ring_size)
195+
mesh = jax.sharding.Mesh(devices, ("heads", ring_axis))
196+
seq_len = 1024 * ring_size
197+
198+
k1, k2, k3 = random.split(random.key(0), 3)
199+
scale = head_dim**-0.5
200+
q = random.normal(k1, (num_heads, seq_len, head_dim), dtype=dtype) * scale
201+
k = random.normal(k2, (num_heads, seq_len, head_dim), dtype=dtype) * scale
202+
v = random.normal(k3, (num_heads, seq_len, head_dim), dtype=dtype) * scale
203+
204+
# The mhpt fast path supports full MHA + static FullMask + HEAD_DIM_MINOR only.
205+
mask = mask_lib.FullMask(_shape=(seq_len, seq_len))
206+
q_spec = P(None, ring_axis, None)
207+
kv_spec = q_spec
208+
209+
def run_multi_head_kernel(hpt):
210+
config = splash.SplashConfig.get_default()
211+
config = dataclasses.replace(
212+
config,
213+
use_base2_exp=False,
214+
fuse_reciprocal=True,
215+
heads_per_tile=hpt,
216+
)
217+
ring_kernel = ring_attention_kernel.make_ring_attention(
218+
mask,
219+
is_mqa=False,
220+
ring_axis=ring_axis,
221+
config=config,
222+
save_residuals=False,
223+
q_seq_shards=ring_size,
224+
kv_seq_shards=ring_size,
225+
)
226+
kernel_spec = ring_kernel.manual_sharding_spec()
227+
228+
@partial(
229+
jax.shard_map,
230+
mesh=mesh,
231+
in_specs=(kernel_spec, q_spec, kv_spec, kv_spec, None),
232+
out_specs=q_spec,
233+
check_vma=False,
234+
)
235+
def ring_attn(ring_kernel, q, k, v, segment_ids):
236+
return ring_kernel(q, k, v, segment_ids)
237+
238+
return ring_attn(ring_kernel, q, k, v, None)
239+
240+
out_ref = run_multi_head_kernel(1) # baseline: single head per tile (flash_attention_kernel)
241+
out_mhpt = run_multi_head_kernel(heads_per_tile) # multi-head-per-tile (flash_attention_kernel_mhpt)
242+
243+
# Pure tiling => numerically equivalent to the single-head-per-tile baseline.
244+
# Outputs are sharded across all hosts; all-gather to a fully-replicated host
245+
# array on every process, then compare with the standard helper.
246+
out_mhpt = multihost_utils.process_allgather(out_mhpt, tiled=True)
247+
out_ref = multihost_utils.process_allgather(out_ref, tiled=True)
248+
self._assert_allclose(out_mhpt, out_ref, rtol=5e-3, atol=5e-3)
249+
250+
251+
class RingAttentionHeadsPerTileGuardTest(test_utils.SplashAttentionTestCase):
252+
"""Negative scenarios: configurations the heads_per_tile > 1 fast path rejects.
253+
254+
The mhpt kernel only supports full-MHA static-FullMask ring attention;
255+
`_validate_heads_per_tile_support` must reject everything else with
256+
NotImplementedError instead of letting the kernel silently miscompute.
257+
Pure-Python validation, so no TPU is required.
258+
"""
259+
260+
_EMPTY_MASK_INFO = splash.MaskInfo(None, None, None, None, None, None, None)
261+
262+
def _validate(self, **overrides):
263+
kwargs = dict(
264+
config=splash.SplashConfig.get_default(),
265+
dynamic_grid=False,
266+
is_mqa=False,
267+
q_heads_per_kv_head=1,
268+
mask_info=self._EMPTY_MASK_INFO,
269+
mask_function=None,
270+
sinks=None,
271+
max_logit_value=None,
272+
)
273+
kwargs.update(overrides)
274+
splash._validate_heads_per_tile_support(**kwargs) # pylint: disable=protected-access
275+
276+
def test_supported_config_is_accepted(self):
277+
self._validate() # full MHA + static FullMask: must not raise
278+
279+
def test_mqa_is_rejected(self):
280+
with self.assertRaisesRegex(NotImplementedError, "MHA"):
281+
self._validate(is_mqa=True)
282+
283+
def test_gqa_is_rejected(self):
284+
with self.assertRaisesRegex(NotImplementedError, "MHA"):
285+
self._validate(q_heads_per_kv_head=2)
286+
287+
def test_dynamic_grid_is_rejected(self):
288+
with self.assertRaisesRegex(NotImplementedError, "static ring attention grids"):
289+
self._validate(dynamic_grid=True)
290+
291+
def test_non_full_mask_is_rejected(self):
292+
with self.assertRaisesRegex(NotImplementedError, "FullMask"):
293+
self._validate(mask_function=lambda *args: True)
294+
295+
163296
if __name__ == "__main__":
164297
absltest.main()

0 commit comments

Comments
 (0)