Skip to content

Commit 9f2ab58

Browse files
committed
[Memory] Implement QK attention head chunking to prevent HBM OOM
Currently, evaluating the QK dot product natively in MLA materializes the full [batch, count_of_heads, q_len, kv_len] attention scores tensor. This causes severe memory constraints (HBM OOM) for large context lengths. This change introduces the Config variable `qk_head_chunk_size` alongside a `jax.lax.scan` algorithm. Since the `heads` dimension is locally dense and unsharded (unlike `q_len` which is Context Parallel), we scan over groups of heads iteratively computing query-key projections and their softmax partials, avoiding vast intermediate allocations.
1 parent d557e88 commit 9f2ab58

4 files changed

Lines changed: 136 additions & 26 deletions

File tree

src/maxtext/configs/base.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1336,3 +1336,6 @@ elastic_enabled: false
13361336
elastic_timeout_seconds: 300
13371337
elastic_max_retries: 10
13381338
elastic_min_slice_count: -1
1339+
1340+
# Limits HBM footprint by sequentially evaluating the QK matrix across the unsharded local heads dimension natively. Size must evenly divide the number of attention heads.
1341+
qk_head_chunk_size: 0

src/maxtext/configs/types.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,16 @@ class Attention(BaseModel):
637637
force_q_layout: bool = Field(False, description="Force the Q layout")
638638
use_qk_clip: bool = Field(False, description="Whether to use QK-Clip (MuonClip) for training stability.")
639639
qk_clip_threshold: float = Field(100.0, description="Threshold for QK-Clip (tau).")
640+
qk_head_chunk_size: int = Field(
641+
0,
642+
ge=0,
643+
description=(
644+
"Chunk size over heads dimension for QK attention dot product. "
645+
"Default is 0 (no chunking). Must divide both the attention query "
646+
"heads (num_query_heads) and indexer heads (indexer_n_heads) evenly. "
647+
"This reduces memory footprint at the cost of time, especially helpful in long context."
648+
),
649+
)
640650

641651

642652
class MoBa(BaseModel):

src/maxtext/layers/attention_mla.py

Lines changed: 108 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -361,18 +361,60 @@ def __call__(
361361
if k.shape[1] <= self.indexer_topk:
362362
return None, None, None
363363

364-
# Compute Index Scores
365-
# QK product: relu(q @ k.T), [b, t, s, h]
366-
# Similar to MQA, each key is shared by h query head
367-
logits = jnp.einsum("bthd, bsd -> btsh", q, k, precision=self.config.matmul_precision)
368-
logits = jax.nn.relu(logits)
369364
# Compute head weights: project from input, [b, t, embed_dim] -> [b, t, h]
370365
weights = self.weights_proj(inputs_q)
371366
# Weights scaling affect indexer_score, but does not affect topk_indices. Keep scaling for numerical stability.
372367
# https://github.com/deepseek-ai/DeepSeek-V3.2-Exp/blob/87e509a2e5a100d221c97df52c6e8be7835f0057/inference/model.py#L478-L480
373368
weights = weights * (self.n_heads**-0.5) * self.softmax_scale
374-
# Aggregate head-wise logits: logits @ weights
375-
indexer_score = jnp.einsum("btsh, bth -> bts", logits, weights, precision=self.config.matmul_precision) # [b, t, s]
369+
370+
# Compute Index Scores
371+
# When qk_head_chunk_size > 0, compute Index Scores by chunking the 'heads' dimension to reduce memory
372+
# The naive evaluation materializes [b, t, s, h].
373+
# We use jax.lax.scan to compute the score iteratively over head chunks.
374+
b, t, h, d = q.shape
375+
# Control the HBM footprint of QK tensor: [batch, q_len, s_len, heads]
376+
# If set to 0 (defaults), it falls back to native materialization.
377+
head_chunk_size = getattr(self.config, "qk_head_chunk_size", 0)
378+
379+
def chunked_head_loop(chunk_size):
380+
if chunk_size > h or h % chunk_size != 0:
381+
raise ValueError(
382+
f"qk_head_chunk_size ({chunk_size}) must be <= number of indexer heads ({h}) " "and divide it evenly."
383+
)
384+
num_chunks = h // chunk_size
385+
# q: [b, t, h, d] -> [h, b, t, d] -> [num_chunks, head_chunk_size, b, t, d]
386+
q_h = q.transpose(2, 0, 1, 3).reshape(num_chunks, chunk_size, b, t, d)
387+
# weights: [b, t, h] -> [h, b, t] -> [num_chunks, head_chunk_size, b, t]
388+
w_h = weights.transpose(2, 0, 1).reshape(num_chunks, chunk_size, b, t)
389+
390+
def scan_body_indexer(carry, xs):
391+
q_c = xs["q"] # [h_chunk, b, t, d]
392+
w_c = xs["w"] # [h_chunk, b, t]
393+
394+
# Directly use the chunked shapes in einsum to avoid transposes inside the loop
395+
logits_inner = jnp.einsum("hbtd, bsd -> btsh", q_c, k, precision=self.config.matmul_precision)
396+
logits_inner = jax.nn.relu(logits_inner)
397+
398+
score_chunk = jnp.einsum(
399+
"btsh, hbt -> bts",
400+
logits_inner,
401+
w_c,
402+
precision=self.config.matmul_precision,
403+
)
404+
return carry + score_chunk.astype(jnp.float32), None
405+
406+
init_score = jnp.zeros((b, t, k.shape[1]), dtype=jnp.float32)
407+
idx_score, _ = jax.lax.scan(jax.checkpoint(scan_body_indexer), init_score, {"q": q_h, "w": w_h})
408+
return idx_score.astype(q.dtype)
409+
410+
if head_chunk_size > 0:
411+
indexer_score = chunked_head_loop(head_chunk_size)
412+
413+
else:
414+
# Aggregate head-wise logits: logits @ weights natively
415+
logits = jnp.einsum("bthd, bsd -> btsh", q, k, precision=self.config.matmul_precision)
416+
logits = jax.nn.relu(logits)
417+
indexer_score = jnp.einsum("btsh, bth -> bts", logits, weights, precision=self.config.matmul_precision)
376418

377419
internal_padding_mask = None
378420
if cached_s is not None:
@@ -1086,25 +1128,69 @@ def calculate_indexer_loss(
10861128
query = jax.lax.stop_gradient(query)
10871129
key = jax.lax.stop_gradient(key)
10881130

1089-
# Compute attention scores: [b, t, h, d] @ [b, s, h, d] -> [b, h, t, s]
1090-
attention_scores = jnp.einsum("bthd, bshd -> bhts", query, key, precision=self.config.matmul_precision)
1091-
1131+
# Ensure indexer_score updates identically in all branches
10921132
if sparse_loss:
1093-
# indexer_mask is already pre-filtered with the attention_mask if any
1094-
attention_scores = attention_scores + indexer_mask[:, None, :, :]
10951133
indexer_score = indexer_score + indexer_mask
1096-
elif attention_mask is not None:
1097-
# indexer_score already applies attention_mask; updating attention_scores only
1098-
attention_scores = attention_scores + attention_mask[:, None, :, :]
1099-
1100-
# Use float32 for softmax numerical stability.
1101-
attention_probs = jax.nn.softmax(attention_scores.astype(jnp.float32), axis=-1)
11021134
indexer_probs = jax.nn.softmax(indexer_score.astype(jnp.float32), axis=-1)
11031135

1104-
# Aggregate heads: [b, h, t, s] -> [b, t, s]
1105-
attention_probs = jnp.sum(attention_probs, axis=1)
1106-
# Force materialization and prevent fusion across this point to reuse the intermediate tensor
1107-
attention_probs = jax.lax.optimization_barrier(attention_probs)
1136+
batch, q_len, heads, dim = query.shape
1137+
1138+
# Chunk across the 'heads' dimension manually using jax.lax.scan
1139+
# Control the HBM footprint of QK tensor: [batch, q_len, s_len, heads]
1140+
# If set to 0, it falls back to native implementation.
1141+
head_chunk_size = getattr(self.config, "qk_head_chunk_size", 0)
1142+
if head_chunk_size > 0:
1143+
if head_chunk_size > heads or heads % head_chunk_size != 0:
1144+
raise ValueError(
1145+
f"qk_head_chunk_size ({head_chunk_size}) must be <= number of attention query heads ({heads}) "
1146+
"and divide it evenly."
1147+
)
1148+
num_chunks = heads // head_chunk_size
1149+
1150+
# Transpose and reshape to put chunk dimension first for jax.lax.scan
1151+
# query: [b, t, h, d] -> [h, b, t, d] -> [num_chunks, head_chunk_size, b, t, d]
1152+
q_h = query.transpose(2, 0, 1, 3).reshape(num_chunks, head_chunk_size, batch, q_len, dim)
1153+
k_h = key.transpose(2, 0, 1, 3).reshape(num_chunks, head_chunk_size, batch, key.shape[1], dim)
1154+
1155+
def scan_body_heads(carry, xs):
1156+
q_c = xs["q"] # [h_chunk, b, t, d]
1157+
k_c = xs["k"] # [h_chunk, b, s, d]
1158+
1159+
# Directly use the chunked shapes in einsum to avoid transposes inside the loop
1160+
attn_chunk = jnp.einsum(
1161+
"hbtd, hbsd -> bhts",
1162+
q_c,
1163+
k_c,
1164+
precision=self.config.matmul_precision,
1165+
)
1166+
1167+
if sparse_loss:
1168+
attn_chunk = attn_chunk + indexer_mask[:, None, :, :]
1169+
elif attention_mask is not None:
1170+
attn_chunk = attn_chunk + attention_mask[:, None, :, :]
1171+
1172+
probs_chunk = jax.nn.softmax(attn_chunk.astype(jnp.float32), axis=-1)
1173+
probs_chunk_sum = jnp.sum(probs_chunk, axis=1) # [b, t, s]
1174+
1175+
return carry + probs_chunk_sum, None
1176+
1177+
init_probs = jnp.zeros((batch, q_len, key.shape[1]), dtype=jnp.float32)
1178+
attention_probs, _ = jax.lax.scan(scan_body_heads, init_probs, {"q": q_h, "k": k_h})
1179+
1180+
else:
1181+
# Native implementation (default) if chunking is disabled
1182+
attention_scores = jnp.einsum(
1183+
"bthd, bshd -> bhts",
1184+
query,
1185+
key,
1186+
precision=self.config.matmul_precision,
1187+
)
1188+
if sparse_loss:
1189+
attention_scores = attention_scores + indexer_mask[:, None, :, :]
1190+
elif attention_mask is not None:
1191+
attention_scores = attention_scores + attention_mask[:, None, :, :]
1192+
attention_probs = jnp.sum(jax.nn.softmax(attention_scores.astype(jnp.float32), axis=-1), axis=1)
1193+
attention_probs = jax.lax.optimization_barrier(attention_probs)
11081194
# L1 normalize aggregated target distribution
11091195
attention_probs = attention_probs / (jnp.sum(attention_probs, axis=-1, keepdims=True) + EPS)
11101196

tests/unit/deepseek32_vs_reference_test.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -751,7 +751,7 @@ def get_jax_mla_weights(pt_mla, cfg):
751751
}
752752

753753

754-
def get_cfg_and_mesh(config, run_name, dtype, batch_size, seq_len, attention, indexer_topk):
754+
def get_cfg_and_mesh(config, run_name, dtype, batch_size, seq_len, attention, indexer_topk, qk_head_chunk_size=0):
755755
"""Returns MaxText configuration and mesh."""
756756
cfg = pyconfig.initialize(
757757
[None, get_test_config_path()],
@@ -769,6 +769,7 @@ def get_cfg_and_mesh(config, run_name, dtype, batch_size, seq_len, attention, in
769769
max_prefill_predict_length=seq_len,
770770
attention=attention,
771771
indexer_topk=indexer_topk,
772+
qk_head_chunk_size=qk_head_chunk_size,
772773
**asdict(config),
773774
)
774775
devices_array = maxtext_utils.create_device_mesh(cfg)
@@ -831,8 +832,8 @@ def get_data(self, seq_len):
831832
class DeepseekV32IndexerTest(DeepseekTestBase):
832833
"""Tests for the Sparse Indexer (Top-K Selection)."""
833834

834-
# indexer_topk=4
835-
def test_indexer_match(self, seq_len=8):
835+
@parameterized.parameters(0, 2, 4)
836+
def test_indexer_match(self, qk_head_chunk_size, seq_len=8):
836837
"""Verifies Indexer output matches PyTorch output."""
837838
torch_inputs, jax_inputs = self.get_data(seq_len)
838839
pt_mask = torch_inputs["mask"]
@@ -865,6 +866,7 @@ def test_indexer_match(self, seq_len=8):
865866
seq_len=self.seq_len,
866867
attention="dot_product",
867868
indexer_topk=4,
869+
qk_head_chunk_size=qk_head_chunk_size,
868870
)
869871

870872
# Indexer specific RoPE (interleave=False)
@@ -936,6 +938,14 @@ class DeepseekV32MLATest(DeepseekTestBase):
936938
"indexer_topk": 128,
937939
"check_norm": True,
938940
},
941+
{
942+
"testcase_name": "dot_product_s128_k128_c4",
943+
"attention": "dot_product",
944+
"seq_len": 128,
945+
"indexer_topk": 128,
946+
"check_norm": True,
947+
"qk_head_chunk_size": 4,
948+
},
939949
{
940950
"testcase_name": "flash_s128_k4",
941951
"attention": "flash",
@@ -951,7 +961,7 @@ class DeepseekV32MLATest(DeepseekTestBase):
951961
"check_norm": True,
952962
},
953963
)
954-
def test_mla_parity(self, attention, seq_len, indexer_topk, check_norm=False):
964+
def test_mla_parity(self, attention, seq_len, indexer_topk, check_norm=False, qk_head_chunk_size=0):
955965
"""Verifies JAX MLA output against the PyTorch reference implementation."""
956966
torch_inputs, jax_inputs = self.get_data(seq_len)
957967

@@ -978,6 +988,7 @@ def test_mla_parity(self, attention, seq_len, indexer_topk, check_norm=False):
978988
seq_len=self.seq_len,
979989
attention=attention,
980990
indexer_topk=indexer_topk,
991+
qk_head_chunk_size=qk_head_chunk_size,
981992
)
982993

983994
jax_mla = attention_mla.MLA(

0 commit comments

Comments
 (0)