[ROCm][K3] Extend FP8 asm MLA prefill to non-divisor small head counts (#51040)
Signed-off-by: Xiaohu Guo <Xiaohu.Guo@amd.com> Co-authored-by: Cursor <cursoragent@cursor.com>
xiaohuguo2023 committed
Aug 26, 2026 at 18:47 UTC
1a085dadf254b7cb2b5f904747a5750256d413fa
2 files changed
+249
-45
tests/kernels/attention/test_rocm_aiter_mla_fp8_prefill.py
+104
-21
@@ -43,8 +43,20 @@ pytestmark = pytest.mark.skipif(
43
)
44
45
# DeepSeek-style MLA head dims after kv_b_proj decompression (q/k carry the
46
-# nope+rope dims, v carries v_head_dim). FP8 prefill requires 16-aligned heads.
47
-NUM_HEADS = 16
46
+# nope+rope dims, v carries v_head_dim).
47
+#
48
+# The kernel requires 16-aligned heads, so _mla_fp8_prefill_attn replicate-pads
49
+# q/k/v up to get_fp8_prefill_num_heads(num_heads) and slices the output back.
50
+# Cover all three regimes:
51
+# 12 -> padded to 16 (non-divisor, tile+slice). Live case: a K3 rank at TP8.
52
+# 16 -> no padding, output aliases the caller's buffer.
53
+# 24 -> padded to 32. No current model and TP reaches this band (96 heads
54
+# would need TP4, which does not fit in 288 GiB; 128-head models give
55
+# 128/64/32/16/8), so this case exists to keep the general path honest
56
+# for future architectures: it is the only one of the three where
57
+# padding above 16 actually happens, so it is what proves the
58
+# replicate-pad/slice-back argument holds for a target other than 16.
59
+HEAD_COUNTS = [12, 16, 24]
60
QK_NOPE_HEAD_DIM = 128
61
QK_ROPE_HEAD_DIM = 64
62
QK_HEAD_DIM = QK_NOPE_HEAD_DIM + QK_ROPE_HEAD_DIM # 192
@@ -56,6 +68,10 @@ SCALE = 1.0 / math.sqrt(QK_HEAD_DIM)
68
# elements.
69
ATOL, RTOL = 1e-1, 5e-2
70
71
+# The assembly kernel writes bf16 through a raw output pointer. Keep its tensors
72
+# and workspace reservation on one dtype.
73
+ATTN_OUT_DTYPE = torch.bfloat16
74
+
75
76
@pytest.fixture(autouse=True)
77
def _workspace_manager():
@@ -73,14 +89,14 @@ def _workspace_manager():
89
reset_workspace_manager()
90
91
76
-def _make_impl():
92
+def _make_impl(num_heads: int):
93
"""Minimal AiterMLAImpl exposing only what _mla_fp8_prefill_attn reads."""
94
from aiter import mla_prefill_ps_asm_fwd, mla_reduce_v1
95
96
from vllm.v1.attention.backends.mla.rocm_aiter_mla import AiterMLAImpl
97
98
impl = object.__new__(AiterMLAImpl)
83
- impl.num_heads = NUM_HEADS
99
+ impl.num_heads = num_heads
100
impl.v_head_dim = V_HEAD_DIM
101
impl.scale = SCALE
102
impl._mla_prefill_ps_asm_fwd = mla_prefill_ps_asm_fwd
@@ -88,8 +104,13 @@ def _make_impl():
104
return impl
105
106
91
-def _build_prefill_metadata(seq_lens: list[int], device: torch.device):
92
- """Build fp8_prefill_* metadata via the real builder (no hand-rolling)."""
107
+def _build_prefill_metadata(
108
+ seq_lens: list[int],
109
+ device: torch.device,
110
+ num_heads: int,
111
+ attn_out_dtype: torch.dtype = ATTN_OUT_DTYPE,
112
+):
113
+ """Build metadata with the production builder and output dtype."""
114
from vllm.v1.attention.backends.mla.rocm_aiter_mla import AiterMLAMetadataBuilder
115
116
qo_indptr_cpu = torch.zeros(len(seq_lens) + 1, dtype=torch.int32)
@@ -99,12 +120,13 @@ def _build_prefill_metadata(seq_lens: list[int], device: torch.device):
120
max_q = max(seq_lens)
121
122
builder = object.__new__(AiterMLAMetadataBuilder)
102
- builder.num_heads = NUM_HEADS
123
+ builder.num_heads = num_heads
124
builder.mla_dims = SimpleNamespace(v_head_dim=V_HEAD_DIM)
125
builder._init_fp8_prefill_ps_buffers(
126
max_num_reqs=len(seq_lens),
127
max_prefill_qlen=max_q,
128
max_num_batched_tokens=total_q,
129
+ attn_out_dtype=attn_out_dtype,
130
device=device,
131
)
132
@@ -115,7 +137,7 @@ def _build_prefill_metadata(seq_lens: list[int], device: torch.device):
137
metadata = SimpleNamespace(prefill=prefill, num_decodes=0)
138
common = SimpleNamespace(query_start_loc_cpu=qo_indptr_cpu)
139
builder._build_fp8_prefill_ps_metadata(metadata, common)
118
- return metadata, total_q
140
+ return metadata, total_q, builder
141
142
143
def _reference(q: torch.Tensor, k: torch.Tensor, v: torch.Tensor) -> torch.Tensor:
@@ -128,34 +150,95 @@ def _reference(q: torch.Tensor, k: torch.Tensor, v: torch.Tensor) -> torch.Tenso
150
return out.transpose(0, 1) # [S, H, Dv]
151
152
153
+@pytest.mark.parametrize("num_heads", HEAD_COUNTS)
154
@pytest.mark.parametrize("seq_len", [128, 512])
155
@torch.inference_mode()
133
-def test_fp8_prefill_matches_reference(seq_len: int) -> None:
156
+def test_fp8_prefill_matches_reference(seq_len: int, num_heads: int) -> None:
157
device = torch.device("cuda")
158
torch.manual_seed(0)
159
137
- metadata, total_q = _build_prefill_metadata([seq_len], device)
138
-
139
- q = torch.randn(
140
- total_q, NUM_HEADS, QK_HEAD_DIM, dtype=torch.bfloat16, device=device
141
- )
142
- k = torch.randn(
143
- total_q, NUM_HEADS, QK_HEAD_DIM, dtype=torch.bfloat16, device=device
144
- )
145
- v = torch.randn(total_q, NUM_HEADS, V_HEAD_DIM, dtype=torch.bfloat16, device=device)
160
+ metadata, total_q, _ = _build_prefill_metadata([seq_len], device, num_heads)
161
+
162
+ # Lock the workspace, as GPUModelRunner does after warmup, so the forward
163
+ # cannot quietly grow it. The builder reserves scratch at num_head_k and
164
+ # the forward requests it at its own padded width; if a future change makes
165
+ # the forward's width exceed the builder's, that is an under-reservation
166
+ # which is invisible on an unlocked workspace (it just grows) and fatal in
167
+ # a real serve. Locking turns it into a failure here.
168
+ #
169
+ # Note this does not catch the reverse -- a builder wider than the forward
170
+ # merely over-reserves -- so it would not have flagged the pre-PR
171
+ # max(16, num_heads) sizing, which over-reserves 2.8x at 24 heads.
172
+ from vllm.v1.worker.workspace import lock_workspace
173
+
174
+ lock_workspace()
175
+
176
+ qkv_kwargs = dict(dtype=ATTN_OUT_DTYPE, device=device)
177
+ q = torch.randn(total_q, num_heads, QK_HEAD_DIM, **qkv_kwargs)
178
+ k = torch.randn(total_q, num_heads, QK_HEAD_DIM, **qkv_kwargs)
179
+ v = torch.randn(total_q, num_heads, V_HEAD_DIM, **qkv_kwargs)
180
out = torch.zeros(
147
- total_q, NUM_HEADS * V_HEAD_DIM, dtype=torch.bfloat16, device=device
181
+ total_q, num_heads * V_HEAD_DIM, dtype=ATTN_OUT_DTYPE, device=device
182
)
183
150
- impl = _make_impl()
184
+ impl = _make_impl(num_heads)
185
impl._mla_fp8_prefill_attn(q, k, v, metadata, out)
186
187
out_ref = _reference(q, k, v)
188
189
assert torch.isfinite(out).all()
190
torch.testing.assert_close(
157
- out.view(total_q, NUM_HEADS, V_HEAD_DIM).float(),
191
+ out.view(total_q, num_heads, V_HEAD_DIM).float(),
192
out_ref.float(),
193
atol=ATOL,
194
rtol=RTOL,
195
)
196
+
197
+
198
+@pytest.mark.parametrize("num_heads", HEAD_COUNTS)
199
+def test_fp8_prefill_metadata_width_matches_forward(num_heads: int) -> None:
200
+ """The PS metadata must be built for the head count the kernel is handed.
201
+
202
+ ``_init_fp8_prefill_ps_buffers``/``build()`` size the work and reduce maps
203
+ from ``num_head_k``, while ``_mla_fp8_prefill_attn`` pads q/k/v to its own
204
+ target. Divergence is not a numerical bug in either direction -- maps built
205
+ narrower than the kernel width still cover the real heads, since padding
206
+ only ever appends duplicate heads above them. It is a sizing bug: narrower
207
+ maps mean a lower head alignment, hence more partial tiles and a larger
208
+ reservation, while wider ones under-reserve the forward's scratch. Pin the
209
+ two to one helper rather than to independent expressions.
210
+ """
211
+ from vllm.v1.attention.backends.mla.rocm_aiter_mla import AiterMLAHelper
212
+
213
+ width = AiterMLAHelper.get_fp8_prefill_num_heads(num_heads)
214
+ assert width % AiterMLAHelper._AITER_MIN_MLA_HEADS == 0
215
+ assert width >= num_heads
216
+ assert width - num_heads < AiterMLAHelper._AITER_MIN_MLA_HEADS
217
+ # Every count the enable gate accepts must have a usable padded width.
218
+ assert AiterMLAHelper.is_valid_num_heads(num_heads)
219
+
220
+ # The builder must actually size its maps at that width. Assert against the
221
+ # buffer it allocated rather than recomputing the width here, or the test
222
+ # passes no matter what _init_fp8_prefill_ps_buffers does.
223
+ #
224
+ # fp8_ps_reduce_partial_map is sized by the partial-tile count, which is
225
+ # gcd-driven and so is a sharp probe of the width used: a lower head
226
+ # alignment yields *more* tiles. At 24 heads, sizing at the unpadded count
227
+ # gives 4x the tiles (64 vs 16) and ~2.8x the scratch reservation (193.6
228
+ # vs 68.6 MiB at batch=1/qlen=512) -- not wrong numerically (the 24-wide
229
+ # maps still cover the real heads) but the reason to pin the widths
230
+ # together.
231
+ from aiter import get_ps_metadata_info_v1
232
+
233
+ from vllm.v1.attention.backends.mla.rocm_aiter_mla import _FP8_PREFILL_TILE_Q
234
+
235
+ def _tiles(nhk: int) -> int:
236
+ return get_ps_metadata_info_v1(
237
+ batch_size=1,
238
+ num_head_k=nhk,
239
+ max_qlen=512,
240
+ qlen_granularity=_FP8_PREFILL_TILE_Q,
241
+ )[5][0]
242
+
243
+ _, _, builder = _build_prefill_metadata([512], torch.device("cuda"), num_heads)
244
+ assert builder.fp8_ps_reduce_partial_map.numel() == _tiles(width)
vllm/v1/attention/backends/mla/rocm_aiter_mla.py
+145
-24
@@ -414,9 +414,15 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
414
device=device,
415
)
416
417
- # FP8 MLA prefill (kn_mla_reduce_v1) only supports 16-aligned heads.
418
- self._fp8_prefill_enabled = (
419
- _fp8_mla_prefill_supported() and self.num_heads % 16 == 0
417
+ # The assembly prefill requires FP8 KV, bf16 output, and 16-aligned
418
+ # heads. It writes bf16 through a raw output pointer, so fp16 must use
419
+ # the standard prefill path. Head counts that are not a multiple of 16
420
+ # are replicate-padded up to one in _mla_fp8_prefill_attn, so the gate
421
+ # is the same head-count predicate the decode path uses.
422
+ self._fp8_prefill_enabled = _fp8_mla_prefill_supported() and (
423
+ kv_cache_dtype_str == "fp8"
424
+ and vllm_config.model_config.dtype == torch.bfloat16
425
+ and AiterMLAHelper.is_valid_num_heads(self.num_heads)
426
)
427
if self._fp8_prefill_enabled:
428
max_prefill_qlen = min(
@@ -427,6 +433,7 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
433
max_num_reqs,
434
max_prefill_qlen,
435
vllm_config.scheduler_config.max_num_batched_tokens,
436
+ vllm_config.model_config.dtype,
437
device,
438
)
439
@@ -444,6 +451,7 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
451
max_num_reqs: int,
452
max_prefill_qlen: int,
453
max_num_batched_tokens: int,
454
+ attn_out_dtype: torch.dtype,
455
device: torch.device,
456
) -> None:
457
"""Pre-allocate persistent buffers for FP8 MLA prefill PS metadata.
@@ -466,13 +474,30 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
474
is bounded by this budget rather than by a single request's
475
``max_prefill_qlen`` — concurrent requests can sum to more than
476
``max_model_len`` when ``max_model_len < max_num_batched_tokens``.
477
+ attn_out_dtype: Dtype of the attention output buffer, used to size
478
+ the padded-head output scratch (small head counts only).
479
device: Target device for the buffers.
480
"""
481
from aiter import get_ps_metadata_info_v1
482
483
# After kv_b_proj decompression, K has num_heads heads (same as Q).
484
# So gqa_ratio=1 and num_head_k=num_heads for the PS kernel.
475
- num_head_k = self.num_heads
485
+ # Head counts that are not a multiple of 16 (K3: 12/rank at TP8) are
486
+ # replicate-padded up to one in _mla_fp8_prefill_attn; build the PS
487
+ # metadata for that same padded count so the work/reduce maps and the
488
+ # scratch reservations describe the width the kernel is handed.
489
+ #
490
+ # This was previously max(16, num_heads), which agrees with the helper
491
+ # at every head count reachable today (12 and 16 both give 16) and
492
+ # differs only above 16: at 24 it leaves num_head_k=24 while the
493
+ # forward pads to 32. That is not a correctness bug -- the 24-wide work
494
+ # maps still cover head-tiles 0..23, which are the real heads -- but it
495
+ # is expensive, because a lower head alignment yields more partial
496
+ # tiles: gcd-driven, 24 heads -> 64 tiles vs 32 heads -> 16, i.e.
497
+ # 193.6 MiB of reservations instead of 68.6 MiB at batch=1/qlen=512
498
+ # (measured on gfx950). Sizing both from one helper keeps the widths
499
+ # equal and takes the cheaper tiling.
500
+ num_head_k = AiterMLAHelper.get_fp8_prefill_num_heads(self.num_heads)
501
v_head_dim = self.mla_dims.v_head_dim
502
# gqa_ratio = 1
503
# qlen_granularity = _FP8_PREFILL_TILE_Q // max(gqa_ratio, 1)
@@ -516,7 +541,7 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
541
from vllm.v1.worker.workspace import current_workspace_manager
542
543
max_num_partial_tiles = reduce_partial_map_size
519
- current_workspace_manager().get_simultaneous(
544
+ reservations: list[tuple[tuple[int, ...], torch.dtype]] = [
545
(
546
(max_num_partial_tiles * _FP8_PREFILL_TILE_Q, num_head_k, v_head_dim),
547
torch.float32,
@@ -526,7 +551,15 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
551
torch.float32,
552
),
553
((max_num_batched_tokens, num_head_k), torch.float32),
529
- )
554
+ ]
555
+ if self.num_heads < num_head_k:
556
+ # Padded head counts also take their kernel output buffer from the
557
+ # workspace: the caller's [total_q, num_heads * v_head_dim] output
558
+ # cannot back a num_head_k-head view (see _mla_fp8_prefill_attn).
559
+ reservations.append(
560
+ ((max_num_batched_tokens, num_head_k, v_head_dim), attn_out_dtype)
561
+ )
562
+ current_workspace_manager().get_simultaneous(*reservations)
563
564
logger.info(
565
"FP8 MLA prefill PS buffers allocated "
@@ -566,7 +599,22 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
599
kv_indptr_cpu = qo_indptr_cpu.clone()
600
seq_lens_cpu = (qo_indptr_cpu[1:] - qo_indptr_cpu[:-1]).to(torch.int32)
601
569
- num_head_k = self.num_heads
602
+ # Head counts that are not a multiple of 16 (K3: 12/rank at TP8) are
603
+ # replicate-padded up to one in _mla_fp8_prefill_attn; build the PS
604
+ # metadata for that same padded count so the work/reduce maps and the
605
+ # scratch reservations describe the width the kernel is handed.
606
+ #
607
+ # This was previously max(16, num_heads), which agrees with the helper
608
+ # at every head count reachable today (12 and 16 both give 16) and
609
+ # differs only above 16: at 24 it leaves num_head_k=24 while the
610
+ # forward pads to 32. That is not a correctness bug -- the 24-wide work
611
+ # maps still cover head-tiles 0..23, which are the real heads -- but it
612
+ # is expensive, because a lower head alignment yields more partial
613
+ # tiles: gcd-driven, 24 heads -> 64 tiles vs 32 heads -> 16, i.e.
614
+ # 193.6 MiB of reservations instead of 68.6 MiB at batch=1/qlen=512
615
+ # (measured on gfx950). Sizing both from one helper keeps the widths
616
+ # equal and takes the cheaper tiling.
617
+ num_head_k = AiterMLAHelper.get_fp8_prefill_num_heads(self.num_heads)
618
# gqa_ratio = 1
619
# qhead_granularity = max(gqa_ratio, 1)
620
# qlen_granularity = _FP8_PREFILL_TILE_Q // qhead_granularity
@@ -927,8 +975,39 @@ class AiterMLAHelper:
975
return -(-num_heads // m) * m
976
977
@staticmethod
930
- def get_mla_padded_q(num_heads: int, q: torch.Tensor) -> torch.Tensor:
931
- m = AiterMLAHelper.get_actual_mla_num_heads(num_heads)
978
+ def get_fp8_prefill_num_heads(num_heads: int) -> int:
979
+ """Head count the FP8 PS asm prefill runs at: the next multiple of 16.
980
+
981
+ Deliberately *not* ``get_actual_mla_num_heads``. That one carves out
982
+ native H24 when ``_aiter_mla_native_h24_supported()``, which probes the
983
+ asm *decode* reducer and metadata. The prefill is a different kernel
984
+ pair (``mla_prefill_ps_asm_fwd`` + ``mla_reduce_v1``) with no such
985
+ probe, so 24 heads pad to 32 here even on a native-H24 build.
986
+
987
+ The PS metadata in ``_init_fp8_prefill_ps_buffers``/``build()`` must be
988
+ sized with this same function, or the work/reduce maps describe a
989
+ different head count than the kernel is handed.
990
+
991
+ This function itself has no upper bound; the ceiling comes from the
992
+ ``is_valid_num_heads`` gate, which rejects counts above
993
+ ``_AITER_MAX_PADDED_MLA_HEADS`` (128) unless they are already
994
+ 16-aligned. That bound was established for the asm *decode* padding, so
995
+ an architecture with, say, 136 heads per rank would be refused the
996
+ prefill here for a reason that was never measured against this kernel
997
+ pair. Revisit the constant rather than special-casing prefill.
998
+ """
999
+ m = AiterMLAHelper._AITER_MIN_MLA_HEADS
1000
+ return -(-num_heads // m) * m
1001
+
1002
+ @staticmethod
1003
+ def get_mla_padded_q(
1004
+ num_heads: int, q: torch.Tensor, target_heads: int | None = None
1005
+ ) -> torch.Tensor:
1006
+ m = (
1007
+ target_heads
1008
+ if target_heads is not None
1009
+ else AiterMLAHelper.get_actual_mla_num_heads(num_heads)
1010
+ )
1011
if num_heads == m:
1012
return q
1013
if m % num_heads == 0:
@@ -1036,10 +1115,14 @@ class AiterMLAImpl(MLACommonImpl[AiterMLAMetadata]):
1115
self.flash_attn_varlen_func = flash_attn_varlen_func
1116
1117
# FP8 MLA prefill kernel imports (lazy, only when enabled).
1039
- # Auto-enabled on gfx950 when AITER ships the kernels.
1040
- # FP8 MLA prefill (kn_mla_reduce_v1) only supports 16-aligned heads.
1041
- self._fp8_prefill_enabled = (
1042
- _fp8_mla_prefill_supported() and self.num_heads % 16 == 0
1118
+ # Auto-enabled on gfx950 when AITER ships the kernels. Only runs when the
1119
+ # KV cache is FP8. Head counts that are not a multiple of 16 are
1120
+ # replicate-padded up to one (see _mla_fp8_prefill_attn).
1121
+ from vllm.utils.torch_utils import is_quantized_kv_cache
1122
+
1123
+ self._fp8_prefill_enabled = _fp8_mla_prefill_supported() and (
1124
+ is_quantized_kv_cache(kv_cache_dtype)
1125
+ and AiterMLAHelper.is_valid_num_heads(self.num_heads)
1126
)
1127
if self._fp8_prefill_enabled:
1128
from aiter import mla_prefill_ps_asm_fwd, mla_reduce_v1
@@ -1082,7 +1165,36 @@ class AiterMLAImpl(MLACommonImpl[AiterMLAMetadata]):
1165
1166
fp8_dtype = current_platform.fp8_dtype()
1167
total_q = q.shape[0]
1085
- nhead = self.num_heads
1168
+ # PS asm prefill + mla_reduce_v1 require 16-aligned heads, and the PS
1169
+ # metadata is built for get_fp8_prefill_num_heads(num_heads). For head
1170
+ # counts that are not a multiple of 16 (K3 = 12/rank at TP8)
1171
+ # replicate-pad q/k/v up to that count, then slice the output back to
1172
+ # the real head count.
1173
+ #
1174
+ # Counts above 16 that are not multiples of 16 (24, 40, ...) are
1175
+ # handled by the same code but are not reached by any current model
1176
+ # and TP that fits: 96 heads would need TP4, whose weights exceed a
1177
+ # 288 GiB GPU, and 128-head models land on 128/64/32/16/8. The path is
1178
+ # kept general for future architectures and its numerics are covered by
1179
+ # test_fp8_prefill_matches_reference[num_heads=24]. Note the cost is
1180
+ # (padded - real)/real extra FLOPs and q/k/v bytes, which is worst just
1181
+ # above a multiple of 16 (17 heads pad to 32); a future arch landing
1182
+ # there should measure against the flash_attn_varlen_func fallback
1183
+ # rather than assume the asm path wins.
1184
+ #
1185
+ # Exact, not approximate: after kv_b_proj gqa_ratio is 1, so q, k and v
1186
+ # all carry num_heads heads and attention is independent per head.
1187
+ # Padding all three identically makes padded head j a duplicate of real
1188
+ # head j % num_heads, so the real heads [0:num_heads] are bit-identical
1189
+ # to the unpadded result. Same argument as the decode path; only the
1190
+ # target width differs.
1191
+ _real_nhead = self.num_heads
1192
+ nhead = AiterMLAHelper.get_fp8_prefill_num_heads(_real_nhead)
1193
+ _pad = nhead != _real_nhead
1194
+ if _pad:
1195
+ q = AiterMLAHelper.get_mla_padded_q(_real_nhead, q, nhead)
1196
+ k = AiterMLAHelper.get_mla_padded_q(_real_nhead, k, nhead)
1197
+ v = AiterMLAHelper.get_mla_padded_q(_real_nhead, v, nhead)
1198
v_head_dim = self.v_head_dim
1199
tile_q = _FP8_PREFILL_TILE_Q
1200
@@ -1106,19 +1218,25 @@ class AiterMLAImpl(MLACommonImpl[AiterMLAMetadata]):
1218
num_partial_tiles = attn_metadata.fp8_prefill_num_partial_tiles
1219
assert num_partial_tiles is not None
1220
1109
- # Reuse the caller's output buffer to skip the per-call alloc + copy.
1110
- # The ASM and reduce kernels both write to a [total_q, nhead, v_head_dim]
1111
- # view, which aliases the [total_q, nhead * v_head_dim] storage of out.
1112
- out_3d = out.view(total_q, nhead, v_head_dim)
1113
-
1114
- # Per-call scratch (logits, attn_lse, final_lse) is served from the
1115
- # workspace manager so allocator churn in the prefill hot path is
1116
- # bounded after warmup, matching the pattern in PR #41002.
1117
- logits, attn_lse, final_lse = current_workspace_manager().get_simultaneous(
1221
+ # Per-call scratch is served from the workspace manager so allocator
1222
+ # churn in the prefill hot path is bounded after warmup, matching the
1223
+ # pattern in PR #41002. The builder reserves the maximum shape of every
1224
+ # tensor requested here before the workspace is locked.
1225
+ scratch: list[tuple[tuple[int, ...], torch.dtype]] = [
1226
((num_partial_tiles * tile_q, nhead, v_head_dim), torch.float32),
1227
((num_partial_tiles * tile_q, nhead), torch.float32),
1228
((total_q, nhead), torch.float32),
1121
- )
1229
+ ]
1230
+ if _pad:
1231
+ # The ASM and reduce kernels write a [total_q, nhead, v_head_dim]
1232
+ # buffer. With unpadded heads that aliases the caller's
1233
+ # [total_q, nhead * v_head_dim] output, so write straight into it;
1234
+ # padded heads do not fit that storage and need their own buffer.
1235
+ scratch.append(((total_q, nhead, v_head_dim), out.dtype))
1236
+
1237
+ workspace = current_workspace_manager()
1238
+ logits, attn_lse, final_lse, *pad_out = workspace.get_simultaneous(*scratch)
1239
+ out_3d = pad_out[0] if _pad else out.view(total_q, nhead, v_head_dim)
1240
1241
# Phase 1: persistent-scheduling assembly prefill kernel.
1242
self._mla_prefill_ps_asm_fwd(
@@ -1156,6 +1274,9 @@ class AiterMLAImpl(MLACommonImpl[AiterMLAMetadata]):
1274
final_lse,
1275
)
1276
1277
+ if _pad:
1278
+ out.view(total_q, _real_nhead, v_head_dim).copy_(out_3d[:, :_real_nhead, :])
1279
+
1280
def forward_mha(
1281
self,
1282
q: torch.Tensor,