Skip to content

Commit 3d082f7

Browse files
Merge pull request #2420 from AI-Hypercomputer:moba_naive_v3
PiperOrigin-RevId: 816452982
2 parents 9246ba9 + 3ab6ecf commit 3d082f7

4 files changed

Lines changed: 630 additions & 0 deletions

File tree

src/MaxText/configs/base.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,12 @@ use_post_attn_norm: False
293293
use_post_ffw_norm: False
294294
mla_naive_kvcache: True
295295

296+
297+
# Adding Mixture of Block Attention Support (MoBA): https://github.com/MoonshotAI/MoBA/blob/master/MoBA_Tech_Report.pdf
298+
moba: False
299+
moba_chunk_size: 1024
300+
moba_topk: 8
301+
296302
# MLA parameters
297303
q_lora_rank: 0
298304
kv_lora_rank: 512

src/MaxText/layers/attention_op.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,138 @@ def generate_attention_mask(
673673

674674
return jnp.where(output_mask, 0.0, DEFAULT_MASK_VALUE) if output_mask is not None else None
675675

676+
def calculate_moba_gate_logic(self, q_item, k_item, q_pos_item):
677+
"""Computes the block-level MoBA gating intermediates for one batch item.
678+
679+
Args:
680+
q_item: Query tensor shaped `[q_len, n_q_heads, head_dim]`.
681+
k_item: Key tensor shaped `[kv_len, n_kv_heads, head_dim]`.
682+
q_pos_item: Absolute query positions shaped `[q_len]`, used to derive the
683+
chunk index for each query.
684+
For example, during prefill after 128 tokens
685+
have been processed `q_pos_item` is `jnp.arange(128, 128 + q_len)`,
686+
while in autoregressive decode with a single query token it is
687+
`jnp.array([kv_len - 1])`.
688+
689+
Returns:
690+
`need_attend`, a boolean mask of shape `[n_kv_heads, g, q_len, num_block]`
691+
indicating which key blocks each query should attend to. The additional
692+
values in the returned tuple are debug intermediates used for logging and
693+
diagnostics when inspecting the gating behaviour.
694+
"""
695+
q_len, n_q_heads, head_dim = q_item.shape
696+
kv_len, n_kv_heads, _ = k_item.shape
697+
g = n_q_heads // n_kv_heads
698+
699+
q_item_f32 = q_item.astype(jnp.float32).reshape(q_len, n_kv_heads, g, head_dim) # grouped-query attention (GQA)
700+
701+
moba_chunk_size = self.config.moba_chunk_size
702+
moba_topk = self.config.moba_topk
703+
704+
num_block = math.ceil(kv_len / moba_chunk_size)
705+
706+
block_ids = jnp.arange(kv_len, dtype=jnp.int32) // moba_chunk_size # chunk index for each key position
707+
# Sum key vectors per chunk so we can later average within each block.
708+
key_gate_weight_sum = jax.ops.segment_sum(
709+
k_item.astype(jnp.float32), block_ids, num_segments=num_block
710+
) # [num_block, n_kv_heads, head_dim]
711+
# Count how many tokens end up in each chunk so we can take the mean.
712+
block_counts = jax.ops.segment_sum(
713+
jnp.ones((kv_len,), dtype=jnp.float32), block_ids, num_segments=num_block
714+
) # [num_block]
715+
# Mean Pooling, Avoid division by zero for empty blocks.
716+
key_gate_weight = key_gate_weight_sum / jnp.maximum(
717+
block_counts[:, None, None], 1
718+
) # [num_block, n_kv_heads, head_dim]
719+
720+
# Take the dot product between each query and every key chunk to get a score.
721+
gate = jnp.einsum("skgd,Nkd->kgsN", q_item_f32, key_gate_weight) # [n_kv_heads, g, q_len, num_block]
722+
gate_before_masking = gate
723+
724+
q_block_idx = q_pos_item // moba_chunk_size # chunk id for each query
725+
block_indices = jnp.arange(num_block) # list every key chunk index
726+
727+
q_block_idx_b = jnp.expand_dims(q_block_idx, axis=-1) # [q_len, 1]
728+
block_indices_b = jnp.expand_dims(block_indices, axis=0) # [1, num_block]
729+
730+
# Block-causal masking: a query can't attend to future key blocks,
731+
# and must attend to its own key block.
732+
mask_future = q_block_idx_b > block_indices_b
733+
gate = jnp.where(mask_future, gate, -float("inf"))
734+
mask_diag = q_block_idx_b == block_indices_b
735+
gate = jnp.where(mask_diag, float("inf"), gate)
736+
gate_after_masking = gate
737+
738+
k_for_topk = min(moba_topk, num_block)
739+
gate_top_k_val, gate_top_k_idx = jax.lax.top_k(gate, k=k_for_topk) # [n_kv_heads, g, q_len, k_for_topk]
740+
gate_top_k_val_min = jnp.min(gate_top_k_val, axis=-1, keepdims=True) # [n_kv_heads, g, q_len, 1]
741+
need_attend_threshold_mask = gate >= gate_top_k_val_min # [n_kv_heads, g, q_len, num_block]
742+
743+
# Tie-breaking: if multiple blocks have the same gate value as the k-th
744+
# block, we only select the ones that appear in the top-k indices.
745+
gate_idx_mask = jnp.sum(
746+
jax.nn.one_hot(gate_top_k_idx, num_block, dtype=jnp.bool_), axis=-2
747+
) # [n_kv_heads, g, q_len, num_block]
748+
need_attend = jnp.logical_and(need_attend_threshold_mask, gate_idx_mask) # [n_kv_heads, g, q_len, num_block]
749+
750+
return (
751+
key_gate_weight,
752+
gate_before_masking,
753+
gate_after_masking,
754+
gate_top_k_val,
755+
gate_top_k_idx,
756+
gate_top_k_val_min,
757+
need_attend_threshold_mask,
758+
gate_idx_mask,
759+
need_attend, # [n_kv_heads, g, q_len, num_block]
760+
)
761+
762+
def generate_moba_mask_single_item(self, q_item, k_item, q_positions):
763+
"""Generates the token-level MoBA additive mask for a single batch item."""
764+
q_len, _, _ = q_item.shape
765+
kv_len, _, _ = k_item.shape
766+
moba_chunk_size = self.config.moba_chunk_size
767+
768+
# Run the gating logic to find which key blocks this query cares about.
769+
*_, need_attend = self.calculate_moba_gate_logic(q_item, k_item, q_positions)
770+
771+
# Expand the block-level `need_attend` mask to a token-level mask.
772+
k_block_indices = jnp.arange(kv_len, dtype=jnp.int32) // moba_chunk_size
773+
token_level_need_attend = need_attend[..., k_block_indices]
774+
775+
# Convert the boolean mask to float mask values.
776+
gate = jnp.where(token_level_need_attend, 0.0, -float("inf"))
777+
778+
# Apply a final per-token causal mask to ensure causality within chunks.
779+
k_indices = jax.lax.broadcasted_iota(jnp.int32, (q_len, kv_len), 1)
780+
q_indices = q_positions[:, None]
781+
causal_mask = q_indices >= k_indices
782+
gate = jnp.where(causal_mask, gate, -float("inf"))
783+
784+
# Return the additive mask for this batch item.
785+
return gate
786+
787+
def _generate_moba_mask(self, query: Array, key: Array, q_positions: Array) -> Array:
788+
"""Builds the token-level MoBA additive mask for the whole batch.
789+
790+
Args:
791+
query: Query tensor shaped `[batch, q_len, n_q_heads, head_dim]`.
792+
key: Key tensor shaped `[batch, kv_len, n_kv_heads, head_dim]`.
793+
q_positions: Absolute query positions shaped `[q_len]`, shared across the
794+
batch, identifying the starting offset of each query token.
795+
For example, in prefill after 128 tokens we pass
796+
`jnp.arange(128, 128 + q_len)`, while in autoregressive decode with a
797+
single new token the vector is `[kv_len - 1]` for each batch element.
798+
799+
Returns:
800+
Additive attention mask with shape
801+
`[batch, n_kv_heads, n_q_heads // n_kv_heads, q_len, kv_len]` containing
802+
`0.` for permitted positions and `-inf` for masked ones.
803+
"""
804+
# vmap over the batch dimension of query and key. q_positions is constant across the batch.
805+
moba_mask = jax.vmap(self.generate_moba_mask_single_item, in_axes=(0, 0, None))(query, key, q_positions)
806+
return moba_mask
807+
676808
def apply_attention(
677809
self,
678810
query: Array,
@@ -1330,9 +1462,27 @@ def apply_attention_dot(
13301462
# Casting softmaxt computation for float32 for model stability.
13311463
if self.float32_logits:
13321464
attn_weights = attn_weights.astype(jnp.float32)
1465+
13331466
attn_mask = self.generate_attention_mask(
13341467
query, key, decoder_segment_ids, model_mode, previous_chunk, bidirectional_mask
13351468
)
1469+
if self.config.moba:
1470+
kv_seq_len = key.shape[1]
1471+
# This logic for `next_pos` is duplicated from `generate_attention_mask`.
1472+
# It determines the starting position of the query sequence.
1473+
next_pos = 0
1474+
if previous_chunk is not None:
1475+
next_pos = previous_chunk.shape[1]
1476+
elif model_mode == MODEL_MODE_AUTOREGRESSIVE and q_seq_len == 1:
1477+
next_pos = kv_seq_len - 1
1478+
q_positions = jnp.arange(next_pos, next_pos + q_seq_len)
1479+
1480+
# The gate calculation in MoBA uses the unscaled query.
1481+
# With scaled query, the gate values are scaled, but since the top-k selection
1482+
# is scale-invariant, we can use the scaled query directly.
1483+
moba_mask = self._generate_moba_mask(query, key, q_positions)
1484+
attn_weights += moba_mask
1485+
13361486
if self.is_partition_in_decode(q_seq_len):
13371487
attn_mask = partitioning.with_sharding_constraint(attn_mask, (KV_LENGTH, HEAD, None, None, None))
13381488
elif model_mode == MODEL_MODE_PREFILL:

src/MaxText/pyconfig.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ def validate_attention_type(s: str) -> None:
8686
raise ValueError("Invalid attention type was passed. Valid options ", valid_attention_types)
8787

8888

89+
def validate_moba_attention(moba, attention) -> None:
90+
if moba and attention in ("autoselected", "flash", "cudnn_flash_te", "cudnn_flash_jax", "paged"):
91+
raise ValueError("MoBA is only supported dot_product attention")
92+
93+
8994
def validate_attention_window_params(
9095
attention_type: str,
9196
chunk_attn_window_size: int,
@@ -167,6 +172,7 @@ def validate_vocab_tiling(num_vocab_tiling: int, per_device_batch_size: int, max
167172
def validate_keys(keys):
168173
validate_attention_kernel(keys["attention"])
169174
validate_attention_type(keys["attention_type"])
175+
validate_moba_attention(keys["moba"], keys["attention"])
170176
validate_attention_window_params(
171177
keys["attention_type"], keys.get("chunk_attn_window_size"), keys.get("sliding_window_size")
172178
)

0 commit comments

Comments
 (0)