Skip to content

Commit bf4fb3d

Browse files
ds-hwangchanglan
authored andcommitted
Support Splash Attention when seq_len is not a multiple of the block size.
GitOrigin-RevId: eceb692
1 parent e9dbd65 commit bf4fb3d

6 files changed

Lines changed: 425 additions & 45 deletions

File tree

axlearn/common/attention_bias.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -732,7 +732,7 @@ def mask(query_position: Tensor, key_position: Tensor):
732732
def and_masks(*mask_fns: ConfigOr[MaskFn]) -> MaskFn:
733733
"""Returns a MaskFn that's the intersection of provided MaskFn's."""
734734

735-
def mask(query_position: Tensor, key_position: Tensor):
735+
def mask(query_position: Tensor, key_position: Tensor) -> Tensor:
736736
fns = [maybe_instantiate(arg) for arg in mask_fns]
737737
result = fns[0](query_position, key_position)
738738
for mask in fns[1:]:
@@ -754,14 +754,31 @@ def sliding_window_causal_mask(sliding_window_size: int) -> MaskFn:
754754
sliding_window_size: Left context of sliding window mask.
755755
"""
756756

757-
def mask(query_position: Tensor, key_position: Tensor):
757+
def mask(query_position: Tensor, key_position: Tensor) -> Tensor:
758758
pos_mask = query_position - key_position <= sliding_window_size
759759
return pos_mask
760760

761761
fun = and_masks(causal_mask, mask)
762762
return fun
763763

764764

765+
def truncated_key_mask(valid_k_len: int) -> MaskFn:
766+
"""Creates a mask function that cuts off attention beyond valid_k_len.
767+
768+
Args:
769+
valid_k_len: The valid key length. Positions >= valid_k_len will be masked out.
770+
771+
Returns:
772+
A mask function that returns True for key_position < valid_k_len, False otherwise.
773+
"""
774+
775+
def mask_fn(query_position: Tensor, key_position: Tensor) -> Tensor:
776+
del query_position
777+
return key_position < valid_k_len
778+
779+
return mask_fn
780+
781+
765782
def make_causal_biases(seq_len: int) -> Tensor:
766783
"""Generates attention logit biases for causal masking.
767784

axlearn/common/attention_bias_test.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
SegmentIdAttentionBias,
1717
TensorAttentionBias,
1818
sliding_window_causal_mask,
19+
truncated_key_mask,
1920
)
2021
from axlearn.common.utils import Tensor
2122

@@ -49,6 +50,20 @@ def test_sliding_window_mask_with_time_step(self, time_step, expected):
4950
out_mask = bool_mask.astype(jnp.int32)
5051
self.assertEqual(out_mask.tolist(), expected)
5152

53+
@parameterized.parameters(
54+
[3, [[1, 1, 1, 0, 0]]],
55+
[5, [[1, 1, 1, 1, 1]]],
56+
)
57+
def test_key_limit_mask(self, valid_k_len, expected):
58+
"""Test make_key_limit_mask masks keys beyond valid_k_len."""
59+
mask_fn = truncated_key_mask(valid_k_len)
60+
seq_len = 5
61+
query_positions = jnp.arange(seq_len)[:, None]
62+
key_positions = jnp.arange(seq_len)[None, :]
63+
bool_mask = mask_fn(query_positions, key_positions)
64+
out_mask = bool_mask.astype(jnp.int32)
65+
self.assertEqual(out_mask.tolist(), expected)
66+
5267

5368
class AttentionBiasTest(test_utils.TestCase):
5469
@parameterized.parameters(

axlearn/common/flash_attention/common.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from functools import partial
1111
from typing import Any, Literal, NamedTuple, Optional
1212

13+
import chex
1314
import jax
1415
import jax.numpy as jnp
1516
import numpy as np
@@ -163,7 +164,7 @@ class Config(Configurable.Config):
163164

164165
def __init__(self, cfg: Config):
165166
super().__init__(cfg)
166-
self.cfg: BaseFlashAttention.Config = self.config
167+
self.cfg = cfg
167168

168169
def get_backend_overrides(self, name: str, default: Any) -> Any:
169170
return (self.cfg.backend_overrides or {}).get(name, default)
@@ -575,3 +576,64 @@ def get_tpu_dot_precision(dtype) -> jax.lax.Precision:
575576
if dtype == jnp.bfloat16:
576577
return jax.lax.Precision.DEFAULT
577578
raise ValueError(f"Unsupported dtype {dtype}")
579+
580+
581+
def maybe_pad_inputs(
582+
block_size: int, query: Tensor, key: Tensor, value: Tensor, segment_id: Optional[Tensor]
583+
) -> tuple[Tensor, Tensor, Tensor, Optional[Tensor]]:
584+
"""Pads query, key, value, and segment_id tensors to align with block_size requirements.
585+
586+
This function ensures that the sequence length dimension of input tensors is divisible by
587+
block_size, which is required for efficient block-based attention computation. Padding is
588+
applied to the sequence length dimension (axis=1) as needed.
589+
590+
Args:
591+
block_size: The block size that sequence lengths must be divisible by. Must be positive.
592+
query: Query tensor of shape [batch_size, query_len, num_heads, per_head_dim].
593+
key: Key tensor of shape [batch_size, key_len, num_kv_heads, per_head_dim].
594+
value: Value tensor of shape [batch_size, value_len, num_kv_heads, per_head_dim].
595+
Must have the same shape as key.
596+
segment_id: Optional segment ID tensor of shape [batch_size, seq_len]. If provided,
597+
it will be padded with -1 values to match query padding.
598+
599+
Returns:
600+
A tuple containing:
601+
- Padded query tensor
602+
- Padded key tensor
603+
- Padded value tensor
604+
- Padded segment_id tensor (or None if input was None)
605+
606+
Raises:
607+
AssertionError: If block_size is not positive, or if key and value shapes don't match.
608+
"""
609+
# Input validation with chex assertions
610+
chex.assert_scalar_positive(block_size)
611+
chex.assert_equal_rank((query, key, value))
612+
chex.assert_rank(query, 4)
613+
chex.assert_equal_shape([key, value])
614+
chex.assert_equal(query.shape[0], key.shape[0])
615+
616+
if segment_id is not None:
617+
chex.assert_rank(segment_id, 2)
618+
chex.assert_equal(segment_id.shape, query.shape[:2])
619+
620+
def pad_fn(x, pad):
621+
return jnp.pad(x, ((0, 0), (0, pad), (0, 0), (0, 0)))
622+
623+
query_pad = -query.shape[1] % block_size
624+
if query_pad > 0:
625+
# This part will be cut after attention, so any value is fine.
626+
query = pad_fn(query, query_pad)
627+
if segment_id is not None:
628+
# segment_ids == 0 means padding.
629+
segment_id = jnp.pad(segment_id, ((0, 0), (0, query_pad)))
630+
631+
key_pad = -key.shape[1] % block_size
632+
if key_pad > 0:
633+
key = pad_fn(key, key_pad)
634+
value = pad_fn(value, key_pad)
635+
636+
chex.assert_equal(query.shape[1] % block_size, 0)
637+
chex.assert_equal(key.shape[1] % block_size, 0)
638+
chex.assert_equal(value.shape[1] % block_size, 0)
639+
return query, key, value, segment_id

axlearn/common/flash_attention/common_test.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22

33
"""Tests for common utilities"""
44

5-
from absl.testing import parameterized
5+
import jax.numpy as jnp
6+
from absl.testing import absltest, parameterized
67

78
from axlearn.common.attention_bias import sliding_window_causal_mask
8-
from axlearn.common.flash_attention.common import build_mask, build_sliding_window_mask
9+
from axlearn.common.flash_attention.common import (
10+
build_mask,
11+
build_sliding_window_mask,
12+
maybe_pad_inputs,
13+
)
914
from axlearn.common.test_utils import TestCase
1015

1116

@@ -20,3 +25,51 @@ def test_sliding_window_fast_path(self, sliding_window_sz, seq_len, block_size):
2025
mask = build_mask(sliding_window_causal_mask(sliding_window_sz), **args)
2126
sliding_mask = build_sliding_window_mask(**args, sliding_window_size=sliding_window_sz)
2227
self.assertNestedEqual(sliding_mask, mask)
28+
29+
30+
class UtilsTest(TestCase):
31+
@parameterized.parameters(
32+
dict(block_size=8, input_len=7, output_len=8),
33+
dict(block_size=8, input_len=16, output_len=16),
34+
dict(block_size=16, input_len=15, output_len=16),
35+
dict(block_size=64, input_len=63, output_len=64),
36+
)
37+
def test_maybe_pad_inputs(self, block_size, input_len, output_len):
38+
query = jnp.ones((2, input_len, 4, 64))
39+
key = jnp.ones((2, input_len, 2, 64))
40+
value = jnp.ones((2, input_len, 2, 64))
41+
segment_id = jnp.ones((2, input_len), dtype=jnp.int32) * 5
42+
43+
orig_q_len, orig_k_len = query.shape[1], key.shape[1]
44+
padded_query, padded_key, padded_value, padded_segment_id = maybe_pad_inputs(
45+
block_size, query, key, value, segment_id
46+
)
47+
48+
self.assertEqual(padded_query.shape[1], output_len)
49+
self.assertEqual(padded_key.shape[1], output_len)
50+
self.assertEqual(padded_value.shape[1], output_len)
51+
self.assertEqual(padded_segment_id.shape[1], output_len)
52+
self.assertEqual(orig_q_len, input_len)
53+
self.assertEqual(orig_k_len, input_len)
54+
self.assertEqual(padded_query.shape[1] % block_size, 0)
55+
56+
self.assertNestedAllClose(padded_query[:, :input_len], query)
57+
self.assertNestedAllClose(padded_segment_id[:, :input_len], segment_id)
58+
59+
if output_len > input_len:
60+
self.assertNestedAllClose(
61+
padded_query[:, input_len:], jnp.zeros_like(padded_query[:, input_len:])
62+
)
63+
self.assertNestedAllClose(
64+
padded_segment_id[:, input_len:], jnp.zeros_like(padded_segment_id[:, input_len:])
65+
)
66+
67+
padded_query2, _, _, padded_segment_id2 = maybe_pad_inputs(
68+
block_size, query, key, value, None
69+
)
70+
self.assertEqual(padded_query2.shape[1], output_len)
71+
self.assertIsNone(padded_segment_id2)
72+
73+
74+
if __name__ == "__main__":
75+
absltest.main()

0 commit comments

Comments
 (0)