Skip to content

Commit 96c937f

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 96c937f

4 files changed

Lines changed: 141 additions & 26 deletions

File tree

src/maxtext/configs/base.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,8 @@ logits_dot_in_fp32: false # whether to use fp32 in logits_dense or shared_embed
192192
cast_logits_to_fp32: true # whether to cast the logits to fp32. the higher precision is generally beneficial, but it can vary slightly.
193193
float32_qk_product: false # in dot_product attention, whether to cast to fp32 the inputs to qk product
194194
float32_logits: false # in dot_product attention, whether to cast to fp32 the inputs to softmax
195+
# 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. Note: This parameter is currently specific to MLA attention.
196+
mla_qk_head_chunk_size: 0
195197
float32_weight_sum: true # whether to use full fp32 precision to sum expert weights for numerical stability
196198
float32_gate_logits: false # whether to cast inputs to fp32 to compute MoE gate logits for numerical stability
197199

@@ -1336,3 +1338,5 @@ elastic_enabled: false
13361338
elastic_timeout_seconds: 300
13371339
elastic_max_retries: 10
13381340
elastic_min_slice_count: -1
1341+
1342+

src/maxtext/configs/types.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,14 @@ 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+
mla_qk_head_chunk_size: int = Field(
641+
0,
642+
ge=0,
643+
description=(
644+
"Chunk size over heads dimension for QK attention dot product in mla. "
645+
"Default is 0 (no chunking). Reduces memory footprint at the cost of time."
646+
),
647+
)
640648

641649

642650
class MoBa(BaseModel):
@@ -3248,6 +3256,19 @@ def calculate_global_batch_sizes(per_device_batch_size, expansion_factor, num_de
32483256
raise ValueError("MoBA is only supported with dot_product attention.")
32493257
if self.decoder_block == DecoderBlockType.DEEPSEEK4 and self.attention != "dot_product":
32503258
raise ValueError("DeepSeek4 decoder block currently only supports dot_product attention.")
3259+
if self.mla_qk_head_chunk_size > 0:
3260+
if self.attention != "dot_product":
3261+
raise ValueError("`mla_qk_head_chunk_size` is only supported with `dot_product` attention.")
3262+
if self.mla_qk_head_chunk_size > self.num_query_heads or self.num_query_heads % self.mla_qk_head_chunk_size != 0:
3263+
raise ValueError(
3264+
f"`mla_qk_head_chunk_size` ({self.mla_qk_head_chunk_size}) must cleanly divide exactly into "
3265+
f"`num_query_heads` ({self.num_query_heads})."
3266+
)
3267+
if self.use_indexer and (self.mla_qk_head_chunk_size > self.indexer_n_heads or self.indexer_n_heads % self.mla_qk_head_chunk_size != 0):
3268+
raise ValueError(
3269+
f"`mla_qk_head_chunk_size` ({self.mla_qk_head_chunk_size}) must cleanly divide exactly into "
3270+
f"`indexer_n_heads` ({self.indexer_n_heads})."
3271+
)
32513272
if self.use_indexer:
32523273
if self.q_lora_rank == 0:
32533274
raise NotImplementedError("Sparse indexer has not implemented for q_lora_rank = 0.")

src/maxtext/layers/attention_mla.py

Lines changed: 101 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -361,18 +361,57 @@ 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, "mla_qk_head_chunk_size", 0)
378+
379+
def chunked_head_loop(chunk_size):
380+
381+
num_chunks = h // chunk_size
382+
# q: [b, t, h, d] -> [h, b, t, d] -> [num_chunks, head_chunk_size, b, t, d]
383+
q_h = q.transpose(2, 0, 1, 3).reshape(num_chunks, chunk_size, b, t, d)
384+
# weights: [b, t, h] -> [h, b, t] -> [num_chunks, head_chunk_size, b, t]
385+
w_h = weights.transpose(2, 0, 1).reshape(num_chunks, chunk_size, b, t)
386+
387+
def scan_body_indexer(carry, xs):
388+
q_c = xs["q"] # [h_chunk, b, t, d]
389+
w_c = xs["w"] # [h_chunk, b, t]
390+
391+
# Directly use the chunked shapes in einsum to avoid transposes inside the loop
392+
logits_inner = jnp.einsum("hbtd, bsd -> btsh", q_c, k, precision=self.config.matmul_precision)
393+
logits_inner = jax.nn.relu(logits_inner)
394+
395+
score_chunk = jnp.einsum(
396+
"btsh, hbt -> bts",
397+
logits_inner,
398+
w_c,
399+
precision=self.config.matmul_precision,
400+
)
401+
return carry + score_chunk.astype(jnp.float32), None
402+
403+
init_score = jnp.zeros((b, t, k.shape[1]), dtype=jnp.float32)
404+
idx_score, _ = jax.lax.scan(jax.checkpoint(scan_body_indexer), init_score, {"q": q_h, "w": w_h})
405+
return idx_score.astype(q.dtype)
406+
407+
if head_chunk_size > 0:
408+
indexer_score = chunked_head_loop(head_chunk_size)
409+
410+
else:
411+
# Aggregate head-wise logits: logits @ weights natively
412+
logits = jnp.einsum("bthd, bsd -> btsh", q, k, precision=self.config.matmul_precision)
413+
logits = jax.nn.relu(logits)
414+
indexer_score = jnp.einsum("btsh, bth -> bts", logits, weights, precision=self.config.matmul_precision)
376415

377416
internal_padding_mask = None
378417
if cached_s is not None:
@@ -1086,25 +1125,65 @@ def calculate_indexer_loss(
10861125
query = jax.lax.stop_gradient(query)
10871126
key = jax.lax.stop_gradient(key)
10881127

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-
1128+
# Ensure indexer_score updates identically in all branches
10921129
if sparse_loss:
1093-
# indexer_mask is already pre-filtered with the attention_mask if any
1094-
attention_scores = attention_scores + indexer_mask[:, None, :, :]
10951130
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)
11021131
indexer_probs = jax.nn.softmax(indexer_score.astype(jnp.float32), axis=-1)
11031132

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)
1133+
batch, q_len, heads, dim = query.shape
1134+
1135+
# Chunk across the 'heads' dimension manually using jax.lax.scan
1136+
# Control the HBM footprint of QK tensor: [batch, q_len, s_len, heads]
1137+
# If set to 0, it falls back to native implementation.
1138+
head_chunk_size = getattr(self.config, "mla_qk_head_chunk_size", 0)
1139+
if head_chunk_size > 0:
1140+
1141+
num_chunks = heads // head_chunk_size
1142+
1143+
# Transpose and reshape to put chunk dimension first for jax.lax.scan
1144+
# query: [b, t, h, d] -> [h, b, t, d] -> [num_chunks, head_chunk_size, b, t, d]
1145+
q_h = query.transpose(2, 0, 1, 3).reshape(num_chunks, head_chunk_size, batch, q_len, dim)
1146+
k_h = key.transpose(2, 0, 1, 3).reshape(num_chunks, head_chunk_size, batch, key.shape[1], dim)
1147+
1148+
def scan_body_heads(carry, xs):
1149+
q_c = xs["q"] # [h_chunk, b, t, d]
1150+
k_c = xs["k"] # [h_chunk, b, s, d]
1151+
1152+
# Directly use the chunked shapes in einsum to avoid transposes inside the loop
1153+
attn_chunk = jnp.einsum(
1154+
"hbtd, hbsd -> bhts",
1155+
q_c,
1156+
k_c,
1157+
precision=self.config.matmul_precision,
1158+
)
1159+
1160+
if sparse_loss:
1161+
attn_chunk = attn_chunk + indexer_mask[:, None, :, :]
1162+
elif attention_mask is not None:
1163+
attn_chunk = attn_chunk + attention_mask[:, None, :, :]
1164+
1165+
probs_chunk = jax.nn.softmax(attn_chunk.astype(jnp.float32), axis=-1)
1166+
probs_chunk_sum = jnp.sum(probs_chunk, axis=1) # [b, t, s]
1167+
1168+
return carry + probs_chunk_sum, None
1169+
1170+
init_probs = jnp.zeros((batch, q_len, key.shape[1]), dtype=jnp.float32)
1171+
attention_probs, _ = jax.lax.scan(scan_body_heads, init_probs, {"q": q_h, "k": k_h})
1172+
1173+
else:
1174+
# Native implementation (default) if chunking is disabled
1175+
attention_scores = jnp.einsum(
1176+
"bthd, bshd -> bhts",
1177+
query,
1178+
key,
1179+
precision=self.config.matmul_precision,
1180+
)
1181+
if sparse_loss:
1182+
attention_scores = attention_scores + indexer_mask[:, None, :, :]
1183+
elif attention_mask is not None:
1184+
attention_scores = attention_scores + attention_mask[:, None, :, :]
1185+
attention_probs = jnp.sum(jax.nn.softmax(attention_scores.astype(jnp.float32), axis=-1), axis=1)
1186+
attention_probs = jax.lax.optimization_barrier(attention_probs)
11081187
# L1 normalize aggregated target distribution
11091188
attention_probs = attention_probs / (jnp.sum(attention_probs, axis=-1, keepdims=True) + EPS)
11101189

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, mla_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+
mla_qk_head_chunk_size=mla_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, mla_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+
mla_qk_head_chunk_size=mla_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+
"mla_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, mla_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+
mla_qk_head_chunk_size=mla_qk_head_chunk_size,
981992
)
982993

983994
jax_mla = attention_mla.MLA(

0 commit comments

Comments
 (0)