Skip to content

Commit a6569cb

Browse files
ds-hwangchanglan
authored andcommitted
Support general mask_fn via ComputableMask
GitOrigin-RevId: b907e3a
1 parent 301d9ce commit a6569cb

5 files changed

Lines changed: 268 additions & 31 deletions

File tree

axlearn/common/attention_bias.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
import functools
2121
import typing
2222
from typing import (
23-
Callable,
2423
Generic,
2524
Iterable,
2625
Optional,
@@ -717,28 +716,30 @@ def __eq__(self, other):
717716
return type(other) is type(self)
718717

719718

720-
def _composite_masks(op: Callable[[Tensor, Tensor], Tensor], *mask_fns: ConfigOr[MaskFn]):
721-
if len(mask_fns) == 0:
722-
raise RuntimeError(f"Input must not be empty: {mask_fns}")
719+
def or_masks(*mask_fns: ConfigOr[MaskFn]) -> MaskFn:
720+
"""Returns a MaskFn that's the union of provided MaskFn's."""
723721

724722
def mask(query_position: Tensor, key_position: Tensor):
725723
fns = [maybe_instantiate(arg) for arg in mask_fns]
726724
result = fns[0](query_position, key_position)
727725
for mask in fns[1:]:
728-
result = op(result, mask(query_position, key_position))
726+
result = result | mask(query_position, key_position)
729727
return result
730728

731729
return mask
732730

733731

734-
def or_masks(*mask_fns: ConfigOr[MaskFn]) -> MaskFn:
735-
"""Returns a MaskFn that's the union of provided MaskFn's."""
736-
return _composite_masks(jnp.logical_or, *mask_fns)
737-
738-
739732
def and_masks(*mask_fns: ConfigOr[MaskFn]) -> MaskFn:
740733
"""Returns a MaskFn that's the intersection of provided MaskFn's."""
741-
return _composite_masks(jnp.logical_and, *mask_fns)
734+
735+
def mask(query_position: Tensor, key_position: Tensor):
736+
fns = [maybe_instantiate(arg) for arg in mask_fns]
737+
result = fns[0](query_position, key_position)
738+
for mask in fns[1:]:
739+
result = result & mask(query_position, key_position)
740+
return result
741+
742+
return mask
742743

743744

744745
def sliding_window_causal_mask(sliding_window_size: int) -> MaskFn:
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Splash attention mask.
2+
3+
This code is adapted from the jax_ml/jax library, specifically from the
4+
https://github.com/jax-ml/jax/blob/9bcfac6542a330b77f29d5cc5dcf4a57f55b2947/jax/experimental/pallas/ops/tpu/splash_attention/splash_attention_mask.py
5+
TODO(dhwang2): Delete this file once JAX is upgraded and LocalMask becomes a computable mask.
6+
"""
7+
8+
from typing import Callable
9+
10+
import numpy as np
11+
from jax.experimental.pallas.ops.tpu.splash_attention.splash_attention_mask import _ComputableMask
12+
13+
from axlearn.common.utils import Tensor
14+
15+
16+
class ComputableMask(_ComputableMask):
17+
"""Computable mask for splash attention that supports custom mask functions.
18+
19+
This mask accepts any Jax/Numpy exchangeable mask function following the MaskFn protocol
20+
from attention_bias.py, such as causal_mask or sliding_window_causal_mask.
21+
22+
Attributes:
23+
mask_fn: A callable mask function that takes query_position and key_position
24+
tensors and returns a boolean mask tensor.
25+
"""
26+
27+
mask_fn: Callable[[Tensor, Tensor], Tensor]
28+
29+
def __init__(
30+
self,
31+
shape: tuple[int, int],
32+
mask_fn: Callable[[Tensor, Tensor], Tensor],
33+
shard_count: int = 1,
34+
):
35+
"""Initialize ComputableMask.
36+
37+
Args:
38+
shape: The shape of the attention mask (q_len, kv_len).
39+
mask_fn: A callable that implements the MaskFn protocol from attention_bias.py.
40+
Takes (query_position, key_position) and returns a boolean mask.
41+
shard_count: Number of shards.
42+
"""
43+
self.mask_fn = mask_fn
44+
45+
def mask_function(q_ids, kv_ids):
46+
"""Computes the attention mask using the provided mask_fn."""
47+
assert q_ids.ndim == 2
48+
assert kv_ids.ndim == 2
49+
return self.mask_fn(q_ids, kv_ids)
50+
51+
super().__init__(
52+
shape=shape,
53+
mask_function=mask_function,
54+
shard_count=shard_count,
55+
)
56+
57+
def __eq__(self, other: object):
58+
if not isinstance(other, type(self)):
59+
return False
60+
61+
return (
62+
self.shape == other.shape
63+
and self.mask_fn == other.mask_fn
64+
and np.array_equal(self.q_sequence, other.q_sequence)
65+
)
66+
67+
def __hash__(self):
68+
return hash(
69+
(
70+
type(self),
71+
self.shape,
72+
id(self.mask_fn),
73+
self.q_sequence.tobytes() if self.q_sequence is not None else None,
74+
)
75+
)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Tests for splash attention mask.
2+
3+
This code is adapted from the jax_ml/jax library, specifically from the
4+
https://github.com/jax-ml/jax/blob/9bcfac6542a330b77f29d5cc5dcf4a57f55b2947/tests/pallas/tpu_splash_attention_mask_test.py
5+
TODO(dhwang2): Delete this file once JAX is upgraded and LocalMask becomes a computable mask.
6+
"""
7+
8+
import numpy as np
9+
from absl.testing import absltest, parameterized
10+
from jax.experimental.pallas.ops.tpu.splash_attention import splash_attention_mask as mask_lib
11+
12+
from axlearn.common.attention_bias import causal_mask, sliding_window_causal_mask
13+
from axlearn.common.flash_attention.splash_attention_mask import ComputableMask
14+
from axlearn.common.test_utils import TestCase
15+
16+
17+
class SplashAttentionMaskTest(TestCase):
18+
@parameterized.parameters(
19+
[
20+
((256, 256), (1024, 1024)),
21+
((256, 128), (1024, 1024)),
22+
((128, 256), (1024, 1024)),
23+
((256, 256), (1024, 2048)),
24+
((256, 128), (1024, 2048)),
25+
((128, 256), (1024, 2048)),
26+
((256, 256), (2048, 1024)),
27+
((256, 128), (2048, 1024)),
28+
((128, 256), (2048, 1024)),
29+
]
30+
)
31+
def test_causal_mask_fn(self, block_size, shape):
32+
"""Test ComputableMask with causal_mask function from attention_bias.py."""
33+
# Create expected dense causal mask
34+
q_len, kv_len = shape
35+
q_ids = np.arange(q_len)[:, None]
36+
kv_ids = np.arange(kv_len)[None, :]
37+
dense_mask = causal_mask(q_ids, kv_ids)
38+
39+
# Create ComputableMask with mask_fn
40+
lazy_mask = ComputableMask(shape=shape, mask_fn=causal_mask)
41+
42+
self._compare_masks(dense_mask, lazy_mask, block_size)
43+
44+
@parameterized.parameters(
45+
[
46+
((256, 256), (1024, 1024), 128),
47+
((256, 128), (1024, 1024), 128),
48+
((128, 256), (1024, 1024), 128),
49+
((256, 256), (1024, 2048), 256),
50+
((256, 128), (1024, 2048), 256),
51+
((128, 256), (1024, 2048), 256),
52+
((256, 256), (2048, 1024), 512),
53+
((256, 128), (2048, 1024), 512),
54+
((128, 256), (2048, 1024), 512),
55+
]
56+
)
57+
def test_sliding_window_causal_mask_fn(self, block_size, shape, window_size):
58+
"""Test ComputableMask with sliding_window_causal_mask from attention_bias.py."""
59+
# Create expected dense sliding window causal mask
60+
q_len, kv_len = shape
61+
q_ids = np.arange(q_len)[:, None]
62+
kv_ids = np.arange(kv_len)[None, :]
63+
mask_fn = sliding_window_causal_mask(sliding_window_size=window_size)
64+
dense_mask = mask_fn(q_ids, kv_ids)
65+
66+
# Create ComputableMask with mask_fn
67+
lazy_mask = ComputableMask(shape=shape, mask_fn=mask_fn)
68+
69+
self._compare_masks(dense_mask, lazy_mask, block_size)
70+
71+
def _compare_masks(
72+
self,
73+
dense_mask: np.ndarray,
74+
lazy_mask: mask_lib.Mask,
75+
block_size: tuple[int, int],
76+
):
77+
self.assertEqual(dense_mask.shape, lazy_mask.shape)
78+
79+
*prefix, width, height = dense_mask.shape
80+
81+
assert width % block_size[0] == 0
82+
assert height % block_size[1] == 0
83+
84+
full_lazy_mask = lazy_mask[(*[slice(p) for p in prefix], slice(None), slice(None))]
85+
self.assertNestedEqual(dense_mask, full_lazy_mask)
86+
for i, j in np.ndindex(width // block_size[0], height // block_size[1]):
87+
indexer = (
88+
*[slice(p) for p in prefix],
89+
slice(i * block_size[0], (i + 1) * block_size[0]),
90+
slice(j * block_size[1], (j + 1) * block_size[1]),
91+
)
92+
dense_chunk = dense_mask[indexer]
93+
lazy_chunk = lazy_mask[indexer]
94+
self.assertNestedEqual(dense_chunk, lazy_chunk)
95+
96+
97+
if __name__ == "__main__":
98+
absltest.main()

axlearn/common/flash_attention/tpu_attention.py

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import jax
99
import jax.ad_checkpoint
1010
import jax.numpy as jnp
11-
import numpy as np
1211
from jax import lax
1312
from jax._src.mesh import thread_resources
1413
from jax.experimental import pallas as pl
@@ -48,6 +47,7 @@
4847
repeat_kv_heads,
4948
)
5049
from axlearn.common.flash_attention.remat import FLASH_ATTN_RESIDUAL_NAME
50+
from axlearn.common.flash_attention.splash_attention_mask import ComputableMask
5151
from axlearn.common.flash_attention.types import FlashAttentionWithShardMapSpecs
5252
from axlearn.common.kv_cache.base_kv_cache import BaseKVCache
5353
from axlearn.common.utils import Nested, Tensor
@@ -72,21 +72,7 @@ def _to_splash_mask(
7272
return splash_attention_mask.LocalMask(
7373
shape=mask_shape, window_size=(left_size, 0), offset=0, shard_count=q_seq_shards
7474
)
75-
76-
# Because mask.mask() may use jnp ops. e.g. jnp.logical_and.
77-
with jax.ensure_compile_time_eval():
78-
# This code is reached only when `kv_cache_type=None` (i.e., forward and prefill) and
79-
# `target_len == source_len` (i.e., self-attention) (see `check_tpu_splash_attention`).
80-
# `target_positions` and `source_positions` are always in the range [0, seq_len].
81-
target_positions = np.arange(mask_shape[0])[None, :, None]
82-
source_positions = np.arange(mask_shape[1])[None, None, :]
83-
# `mask.mask` expects rank 3 tensors.
84-
mask_array = np.asarray(mask.mask(target_positions, source_positions))
85-
mask_array = np.squeeze(mask_array, axis=0)
86-
87-
# NumpyMask is backed by a dense [target_len, source_len] numpy array.
88-
# May consume a large amount of host memory for long sequences at compile time.
89-
return splash_attention_mask.NumpyMask(array=mask_array)
75+
return ComputableMask(shape=mask_shape, shard_count=q_seq_shards, mask_fn=mask.mask)
9076

9177

9278
# The following code is adapted from jax-ml/jax:

axlearn/common/flash_attention/tpu_attention_test.py

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from axlearn.common.flash_attention import tpu_attention
3030
from axlearn.common.flash_attention.common import ReferenceMHA
3131
from axlearn.common.flash_attention.layer import default_mha_dim_to_partition_spec
32+
from axlearn.common.flash_attention.splash_attention_mask import ComputableMask
3233
from axlearn.common.flash_attention.test_utils import generate_attention_data
3334
from axlearn.common.test_utils import TestCase, Tolerance, is_supported_mesh_shape
3435
from axlearn.common.utils import Tensor
@@ -56,6 +57,9 @@ def mask(query_position: Tensor, key_position: Tensor):
5657
return fun
5758

5859

60+
_singleton_mask_fn = jax_fn_mask(5)
61+
62+
5963
@skipIfGPU
6064
class TestFlashAttention(TestCase):
6165
"""Tests FlashAttention layer."""
@@ -107,13 +111,11 @@ def test_sliding_window_mask_equivalence(self, seq_len, sliding_window_size):
107111
],
108112
[
109113
MaskFnAttentionBias(
110-
jax_fn_mask(5),
114+
_singleton_mask_fn,
111115
target_positions=jnp.arange(8)[None],
112116
source_positions=jnp.arange(8)[None],
113117
),
114-
splash_attention_mask.NumpyMask(
115-
array=np.array(jax_fn_mask(5)(jnp.arange(8)[:, None], jnp.arange(8)[None, :]))
116-
),
118+
ComputableMask(shape=(8, 8), mask_fn=_singleton_mask_fn),
117119
],
118120
)
119121
def test_to_splash_mask(self, mask, expected):
@@ -126,6 +128,81 @@ def inside_tracing(mask):
126128

127129
inside_tracing(mask)
128130

131+
@parameterized.product(sliding_window_size=(None, 16), seq_len=(128, 2048))
132+
def test_computable_mask(self, sliding_window_size, seq_len):
133+
"""Test that ComputableMask with mask_fn produces same results as equivalent splash mask."""
134+
batch_size = 2
135+
num_heads = 4
136+
per_head_dim = 64
137+
num_kv_heads = num_heads // 2
138+
139+
# Generate test data
140+
q, k, v, _ = generate_attention_data(
141+
batch_size,
142+
seq_len,
143+
seq_len,
144+
num_heads,
145+
per_head_dim,
146+
num_kv_heads,
147+
mask_fn=None,
148+
dtype=jnp.bfloat16,
149+
)
150+
151+
cfg = dict(
152+
interpret=jax.default_backend() == "cpu",
153+
softmax_scale=per_head_dim**-0.5,
154+
tpu_block_size=128,
155+
)
156+
fn = tpu_attention.TPUSplashAttention.default_config().set(**cfg).instantiate()
157+
158+
# Test with ComputableMask.
159+
if sliding_window_size is not None:
160+
mask_fn = sliding_window_causal_mask(sliding_window_size=sliding_window_size)
161+
bias_with_ref_mask = SlidingWindowAttentionBias(
162+
mask=mask_fn,
163+
sliding_window_size=sliding_window_size,
164+
target_positions=jnp.arange(seq_len)[None],
165+
source_positions=jnp.arange(seq_len)[None],
166+
)
167+
else:
168+
mask_fn = causal_mask
169+
bias_with_ref_mask = CausalAttentionBias(
170+
target_positions=jnp.arange(seq_len)[None],
171+
source_positions=jnp.arange(seq_len)[None],
172+
)
173+
174+
prng_key = jax.random.PRNGKey(42)
175+
# Outputs by splash mask
176+
input_batch_ref = dict(
177+
query=q,
178+
key=k,
179+
value=v,
180+
bias=bias_with_ref_mask,
181+
prng_key=prng_key,
182+
logit_sink=None,
183+
)
184+
out_ref = fn(input_batch_ref)
185+
186+
# Outputs by computable mask
187+
bias_with_computable_mask = MaskFnAttentionBias(
188+
mask=mask_fn,
189+
target_positions=jnp.arange(seq_len)[None],
190+
source_positions=jnp.arange(seq_len)[None],
191+
)
192+
input_batch_computable = dict(
193+
query=q,
194+
key=k,
195+
value=v,
196+
bias=bias_with_computable_mask,
197+
prng_key=prng_key,
198+
logit_sink=None,
199+
)
200+
self.assertTrue(fn.is_supported(input_batch=input_batch_computable, kv_cache_type=None))
201+
out_computable = fn(input_batch_computable)
202+
203+
# Both should produce the same output
204+
self.assertNestedAllClose(out_computable, out_ref)
205+
129206
@parameterized.product(
130207
batch_size=[2],
131208
kv_len=[128],

0 commit comments

Comments
 (0)