[Attention][MLA] Per-request scheduling for MLA chunked context (#50613)
Signed-off-by: Matthew Bonanni <mbonanni@redhat.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: OpenAI Codex <noreply@openai.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Matthew Bonanni committed
Aug 6, 2026 at 12:45 UTC
b38e111d3e4806a553ec2798e2b075da7a8b03d3
15 files changed
+940
-552
tests/kernels/attention/test_merge_attn_states.py
+1
-31
@@ -11,9 +11,6 @@ from vllm._custom_ops import (
11
scaled_fp8_quant,
12
)
13
from vllm.platforms import current_platform
14
-from vllm.v1.attention.ops.triton_merge_attn_states import (
15
- mask_empty_context,
16
-)
14
from vllm.v1.attention.ops.triton_merge_attn_states import (
15
merge_attn_states as merge_attn_states_triton,
16
)
@@ -76,32 +73,6 @@ DTYPES = [torch.float32, torch.half, torch.bfloat16]
73
all_case_info: list[tuple] = []
74
75
79
-def test_mask_empty_context() -> None:
80
- query_lens = torch.tensor([2] + [1] * 31 + [131, 1], dtype=torch.int32)
81
- query_start_loc = torch.cat(
82
- (torch.zeros(1, dtype=torch.int32), query_lens.cumsum(0))
83
- ).cuda()
84
- context_lens = torch.tensor([4] * 32 + [0, 3], dtype=torch.int32)
85
- context_start_loc = torch.cat(
86
- (torch.zeros(1, dtype=torch.int32), context_lens.cumsum(0))
87
- ).cuda()
88
- num_heads, num_tokens, head_dim = 4, 165, 16
89
- lse = torch.randn(num_heads, num_tokens, device="cuda")
90
- output = torch.randn(num_tokens, num_heads, head_dim, device="cuda")
91
- # Empty-context rows carry undefined (possibly non-finite) attention output.
92
- output[33:164] = float("nan")
93
-
94
- expected_lse = lse.clone()
95
- expected_lse[:, 33:164] = float("-inf")
96
- expected_output = output.clone()
97
- expected_output[33:164] = 0.0
98
-
99
- mask_empty_context(lse, output, query_start_loc, context_start_loc)
100
-
101
- torch.testing.assert_close(lse, expected_lse)
102
- torch.testing.assert_close(output, expected_output)
103
-
104
-
76
@pytest.mark.parametrize("merge_fn", [merge_attn_states_cuda, merge_attn_states_triton])
77
@pytest.mark.parametrize("output_dtype", [torch.float32, torch.half, torch.bfloat16])
78
def test_merge_attn_states_both_empty(merge_fn, output_dtype) -> None:
@@ -117,8 +88,7 @@ def test_merge_attn_states_both_empty(merge_fn, output_dtype) -> None:
88
)
89
suffix_lse = torch.randn(num_heads, num_tokens, device="cuda")
90
120
- # Tokens 2 and 3 are empty on both sides (mask_empty_context already zeroed
121
- # their outputs and set both LSEs to -inf).
91
+ # Tokens 2 and 3 are empty on both sides.
92
empty = slice(2, 4)
93
prefix_lse[:, empty] = float("-inf")
94
suffix_lse[:, empty] = float("-inf")
tests/v1/attention/test_mla_backends.py
+102
-26
@@ -280,6 +280,10 @@ BATCH_SPECS = {
280
"spec_decode_medium": BatchSpec(
281
seq_lens=[512, 1024, 2048, 512, 1024, 2048], query_lens=[8, 8, 8, 8, 8, 8]
282
),
283
+ "chunked_context_prefill": BatchSpec(
284
+ seq_lens=[1568, 520, 8, 80, 96, 8],
285
+ query_lens=[32, 8, 8, 16, 16, 8],
286
+ ),
287
}
288
289
@@ -1049,6 +1053,7 @@ def run_attention_backend(
1053
k_scale: float,
1054
kv_cache_dtype: str = "auto",
1055
prefill_backend: MLAPrefillBackendEnum | None = None,
1056
+ chunked_prefill_workspace_size: int | None = None,
1057
) -> torch.Tensor:
1058
"""Run attention computation using the specified backend's AttentionImpl."""
1059
@@ -1137,6 +1142,13 @@ def run_attention_backend(
1142
1143
# Build metadata
1144
builder = builder_cls(kv_cache_spec, layer_names, vllm_config, device)
1145
+ if chunked_prefill_workspace_size is not None:
1146
+ builder.chunked_prefill_workspace_size = chunked_prefill_workspace_size
1147
+ builder.chunked_prefill_workspace = (
1148
+ builder.chunked_prefill_workspace.new_empty(
1149
+ chunked_prefill_workspace_size, head_size
1150
+ )
1151
+ )
1152
attn_metadata = builder.build(
1153
common_prefix_len=0,
1154
common_attn_metadata=common_attn_metadata,
@@ -1156,32 +1168,7 @@ def run_attention_backend(
1168
return output
1169
1170
1159
-@pytest.mark.parametrize(
1160
- "batch_spec_name",
1161
- [
1162
- "small_decode",
1163
- "small_prefill",
1164
- "mixed_small",
1165
- "medium_decode",
1166
- "medium_prefill",
1167
- "mixed_medium",
1168
- "large_decode",
1169
- "large_prefill",
1170
- "single_decode",
1171
- "single_prefill",
1172
- "spec_decode_small",
1173
- "spec_decode_medium",
1174
- ],
1175
-)
1176
-@pytest.mark.parametrize("model", ["deepseek-ai/DeepSeek-R1"])
1177
-@pytest.mark.parametrize("tensor_parallel_size", [1, 4, 8, 16])
1178
-@pytest.mark.parametrize("kv_cache_dtype", ["auto", "fp8", "fp8_e4m3"])
1179
-@pytest.mark.parametrize(("q_scale", "k_scale"), [(1.0, 1.0), (2.0, 3.0)])
1180
-@pytest.mark.parametrize(
1181
- ("prefill_backend", "qk_nope_head_dim", "v_head_dim"),
1182
- _prefill_backend_dimension_params(),
1183
-)
1184
-def test_backend_correctness(
1171
+def _run_backend_correctness(
1172
default_vllm_config,
1173
dist_init,
1174
workspace_init,
@@ -1194,6 +1181,7 @@ def test_backend_correctness(
1181
prefill_backend: MLAPrefillBackendEnum,
1182
qk_nope_head_dim: int,
1183
v_head_dim: int,
1184
+ chunked_prefill_workspace_size: int | None = None,
1185
):
1186
"""
1187
Test that all backends produce similar outputs to a reference implementation
@@ -1592,6 +1580,7 @@ def test_backend_correctness(
1580
q_scale=q_scale,
1581
k_scale=k_scale,
1582
kv_cache_dtype=kv_cache_dtype,
1583
+ chunked_prefill_workspace_size=chunked_prefill_workspace_size,
1584
)
1585
1586
# Use backend_idx to get the correct SDPA output for this backend
@@ -1643,3 +1632,90 @@ def test_backend_correctness(
1632
summary = f"{len(failures)} backend(s) failed: {', '.join(backend_names)}"
1633
detailed_msg = "\n".join(failures)
1634
pytest.fail(f"{summary}\n{detailed_msg}")
1635
+
1636
+
1637
+@pytest.mark.parametrize(
1638
+ "batch_spec_name",
1639
+ [
1640
+ "small_decode",
1641
+ "small_prefill",
1642
+ "mixed_small",
1643
+ "medium_decode",
1644
+ "medium_prefill",
1645
+ "mixed_medium",
1646
+ "large_decode",
1647
+ "large_prefill",
1648
+ "single_decode",
1649
+ "single_prefill",
1650
+ "spec_decode_small",
1651
+ "spec_decode_medium",
1652
+ ],
1653
+)
1654
+@pytest.mark.parametrize("model", ["deepseek-ai/DeepSeek-R1"])
1655
+@pytest.mark.parametrize("tensor_parallel_size", [1, 4, 8, 16])
1656
+@pytest.mark.parametrize("kv_cache_dtype", ["auto", "fp8", "fp8_e4m3"])
1657
+@pytest.mark.parametrize(("q_scale", "k_scale"), [(1.0, 1.0), (2.0, 3.0)])
1658
+@pytest.mark.parametrize(
1659
+ ("prefill_backend", "qk_nope_head_dim", "v_head_dim"),
1660
+ _prefill_backend_dimension_params(),
1661
+)
1662
+def test_backend_correctness(
1663
+ default_vllm_config,
1664
+ dist_init,
1665
+ workspace_init,
1666
+ batch_spec_name: str,
1667
+ model: str,
1668
+ tensor_parallel_size: int,
1669
+ kv_cache_dtype: str,
1670
+ q_scale: float,
1671
+ k_scale: float,
1672
+ prefill_backend: MLAPrefillBackendEnum,
1673
+ qk_nope_head_dim: int,
1674
+ v_head_dim: int,
1675
+):
1676
+ _run_backend_correctness(
1677
+ default_vllm_config,
1678
+ dist_init,
1679
+ workspace_init,
1680
+ batch_spec_name,
1681
+ model,
1682
+ tensor_parallel_size,
1683
+ kv_cache_dtype,
1684
+ q_scale,
1685
+ k_scale,
1686
+ prefill_backend,
1687
+ qk_nope_head_dim,
1688
+ v_head_dim,
1689
+ )
1690
+
1691
+
1692
+@pytest.mark.parametrize(
1693
+ ("prefill_backend", "qk_nope_head_dim", "v_head_dim"),
1694
+ _prefill_backend_dimension_params(),
1695
+)
1696
+@pytest.mark.parametrize("kv_cache_dtype", ["auto", "fp8"])
1697
+def test_chunked_context_backend_correctness(
1698
+ default_vllm_config,
1699
+ dist_init,
1700
+ workspace_init,
1701
+ prefill_backend: MLAPrefillBackendEnum,
1702
+ qk_nope_head_dim: int,
1703
+ v_head_dim: int,
1704
+ kv_cache_dtype: str,
1705
+):
1706
+ """Split, packed, and context-free requests match the SDPA reference."""
1707
+ _run_backend_correctness(
1708
+ default_vllm_config,
1709
+ dist_init,
1710
+ workspace_init,
1711
+ batch_spec_name="chunked_context_prefill",
1712
+ model="deepseek-ai/DeepSeek-R1",
1713
+ tensor_parallel_size=16,
1714
+ kv_cache_dtype=kv_cache_dtype,
1715
+ q_scale=1.0,
1716
+ k_scale=1.0,
1717
+ prefill_backend=prefill_backend,
1718
+ qk_nope_head_dim=qk_nope_head_dim,
1719
+ v_head_dim=v_head_dim,
1720
+ chunked_prefill_workspace_size=1024,
1721
+ )
tests/v1/attention/test_mla_context_chunks.py
new
+288
@@ -0,0 +1,288 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Per-request scheduling of MLA chunked-context prefill.
4
+
5
+These cover the metadata contract `_compute_prefill_context` relies on: every
6
+context row is gathered exactly once, no chunk exceeds the workspace, and a
7
+chunk never covers a prefill without context (which is why the partial no
8
+longer needs an empty-span masking pass).
9
+"""
10
+
11
+import pytest
12
+import torch
13
+
14
+from vllm.model_executor.layers.attention.mla_attention import (
15
+ build_mla_chunked_context_metadata,
16
+ init_mla_context_partial,
17
+ reorg_kvcache,
18
+)
19
+
20
+BLOCK_SIZE = 16
21
+
22
+
23
+def build_chunked_context(
24
+ context_lens: list[int],
25
+ query_lens: list[int],
26
+ workspace_size: int,
27
+ block_size: int = BLOCK_SIZE,
28
+ dcp_world_size: int = 1,
29
+ dcp_local_block_size: int = 1,
30
+):
31
+ query_start_loc = torch.zeros(len(query_lens) + 1, dtype=torch.int32)
32
+ query_start_loc[1:] = torch.tensor(query_lens, dtype=torch.int32).cumsum(0)
33
+ workspace_rows = workspace_size + workspace_size // dcp_world_size
34
+ return build_mla_chunked_context_metadata(
35
+ context_lens_cpu=torch.tensor(context_lens, dtype=torch.int32),
36
+ prefill_query_start_loc_cpu=query_start_loc,
37
+ chunked_prefill_workspace=torch.empty((workspace_rows, 1)),
38
+ chunked_prefill_workspace_size=workspace_size,
39
+ block_size=block_size,
40
+ align_chunk_to_block=True,
41
+ device=torch.device("cpu"),
42
+ dcp_world_size=dcp_world_size,
43
+ dcp_local_block_size=dcp_local_block_size,
44
+ dcp_virtual_block_size=dcp_local_block_size * dcp_world_size,
45
+ )
46
+
47
+
48
+@pytest.mark.parametrize(
49
+ "context_lens,workspace_size",
50
+ [
51
+ # Heterogeneous batch: the long request must not shrink the chunk the
52
+ # short ones share.
53
+ ([960, 16, 16, 16, 16, 16, 16, 16], 1024),
54
+ # A single request that does not fit is split on its own.
55
+ ([2048, 320], 1024),
56
+ # Every request needs its own chunk.
57
+ ([1024, 1024, 1024], 1024),
58
+ # Unaligned tails.
59
+ ([37, 1000, 5], 1024),
60
+ ([1], 1024),
61
+ ],
62
+)
63
+def test_chunks_gather_every_context_row_exactly_once(context_lens, workspace_size):
64
+ """Chunks tile the batch's context without gaps, overlap, or overflow.
65
+
66
+ This is the invariant the whole schedule rests on: attention over a chunk
67
+ only sees the rows that chunk gathered, so a row counted twice or dropped
68
+ silently corrupts the context partial.
69
+ """
70
+ query_lens = [4] * len(context_lens)
71
+ metadata = build_chunked_context(context_lens, query_lens, workspace_size)
72
+ assert metadata is not None
73
+
74
+ gathered: dict[int, list[tuple[int, int]]] = {}
75
+ previous_request_start = -1
76
+ for chunk in metadata.chunks:
77
+ assert chunk.num_context_tokens <= workspace_size
78
+ assert chunk.num_context_tokens == int(chunk.cu_seq_lens[-1])
79
+ assert chunk.token_to_seq.shape[0] == chunk.num_context_tokens
80
+ # Chunks are emitted in request order, which is what lets the partial
81
+ # treat only a chunk's first request as a continuation.
82
+ assert chunk.request_slice.start >= previous_request_start
83
+ previous_request_start = chunk.request_slice.start
84
+
85
+ starts = chunk.starts.tolist()
86
+ seq_lens = chunk.seq_lens.tolist()
87
+ assert len(starts) == len(seq_lens) == chunk.num_requests
88
+ for offset, (start, length) in enumerate(zip(starts, seq_lens)):
89
+ assert length > 0, "a chunk must not cover an empty context span"
90
+ gathered.setdefault(chunk.request_slice.start + offset, []).append(
91
+ (start, length)
92
+ )
93
+
94
+ expected = {i: length for i, length in enumerate(context_lens) if length > 0}
95
+ assert gathered.keys() == expected.keys()
96
+ for request, spans in gathered.items():
97
+ cursor = 0
98
+ for start, length in spans:
99
+ assert start == cursor
100
+ cursor += length
101
+ assert cursor == expected[request]
102
+
103
+
104
+def test_continuation_is_confined_to_a_chunks_first_request():
105
+ """Only a chunk's first request may continue an earlier chunk.
106
+
107
+ `accumulate_mla_context_chunk` merges the continuation token slice and
108
+ writes the rest, so a chunk that continues a request other than its first,
109
+ or reports the wrong boundary, folds the partial into the wrong tokens.
110
+ """
111
+ context_lens = [2048, 32, 32]
112
+ query_lens = [3, 5, 7]
113
+ metadata = build_chunked_context(context_lens, query_lens, 1024)
114
+ assert metadata is not None
115
+ query_start_loc = [0, 3, 8, 15]
116
+
117
+ assert [chunk.is_continuation for chunk in metadata.chunks] == [
118
+ False,
119
+ True,
120
+ False,
121
+ ]
122
+ for chunk in metadata.chunks:
123
+ starts = chunk.starts.tolist()
124
+ assert chunk.is_continuation == (starts[0] > 0)
125
+ # Requests after the first always start at the beginning of their
126
+ # context, so they can only ever be initialized, never merged.
127
+ assert all(start == 0 for start in starts[1:])
128
+ request_start = chunk.request_slice.start
129
+ request_end = chunk.request_slice.stop
130
+ assert chunk.continuation_token_end == query_start_loc[request_start + 1]
131
+ assert chunk.token_slice == slice(
132
+ query_start_loc[request_start], query_start_loc[request_end]
133
+ )
134
+ assert chunk.query_start_loc.tolist() == [
135
+ offset - chunk.token_slice.start
136
+ for offset in query_start_loc[request_start : request_end + 1]
137
+ ]
138
+
139
+
140
+def test_tail_splitting_minimizes_chunks():
141
+ """A tail request may fill one chunk and continue at the next chunk's head.
142
+
143
+ Without tail splitting these contexts need three chunks. Splitting on the
144
+ block boundary reduces that to the workspace lower bound of two.
145
+ """
146
+ metadata = build_chunked_context([768, 512, 512], [3, 5, 7], 1024)
147
+ assert metadata is not None
148
+
149
+ assert len(metadata.chunks) == 2
150
+ first, second = metadata.chunks
151
+ assert first.starts.tolist() == [0, 0]
152
+ assert first.seq_lens.tolist() == [768, 256]
153
+ assert not first.is_continuation
154
+
155
+ assert second.starts.tolist() == [256, 0]
156
+ assert second.seq_lens.tolist() == [256, 512]
157
+ assert second.is_continuation
158
+
159
+
160
+def test_prefills_without_context_are_skipped_and_neutralized():
161
+ """Context-free prefills get neutral partials instead of chunk work.
162
+
163
+ If the full-query partial leaves either an internal or trailing gap
164
+ uninitialized, the final merge combines the suffix with undefined scratch.
165
+ """
166
+ metadata = build_chunked_context([64, 0, 64, 0], [4, 4, 4, 4], 1024)
167
+ assert metadata is not None
168
+
169
+ covered = {
170
+ request
171
+ for chunk in metadata.chunks
172
+ for request in range(chunk.request_slice.start, chunk.request_slice.stop)
173
+ }
174
+ assert covered == {0, 2}
175
+ assert metadata.empty_token_slices == [slice(4, 8), slice(12, 16)]
176
+
177
+ output, output_lse = init_mla_context_partial(
178
+ metadata,
179
+ attn_output=torch.empty(4, 2, 3),
180
+ attn_softmax_lse=torch.empty(2, 4),
181
+ num_tokens=16,
182
+ )
183
+ assert output.shape == (16, 2, 3)
184
+ assert output_lse.shape == (2, 16)
185
+ for token_slice in metadata.empty_token_slices:
186
+ assert not torch.count_nonzero(output[token_slice])
187
+ assert torch.isneginf(output_lse[:, token_slice]).all()
188
+
189
+
190
+def test_no_context_needs_no_chunks():
191
+ assert build_chunked_context([0, 0], [4, 4], 1024) is None
192
+
193
+
194
+def test_dcp_chunks_fit_the_per_rank_row_budget():
195
+ """Under DCP each rank gathers its own shard, so the budget is 1/world.
196
+
197
+ The local starts and lengths must tile the rank's padded context the same
198
+ way the global ones tile the real context, since `reorg_kvcache` unpads
199
+ using them.
200
+ """
201
+ dcp_world_size, interleave, workspace_size = 2, 64, 1024
202
+ context_lens = [3000, 200, 200]
203
+ metadata = build_chunked_context(
204
+ context_lens,
205
+ [4] * len(context_lens),
206
+ workspace_size,
207
+ block_size=128,
208
+ dcp_world_size=dcp_world_size,
209
+ dcp_local_block_size=interleave,
210
+ )
211
+ assert metadata is not None
212
+ virtual_block_size = interleave * dcp_world_size
213
+
214
+ local_cursor: dict[int, int] = {}
215
+ for chunk in metadata.chunks:
216
+ assert chunk.num_local_context_tokens <= workspace_size // dcp_world_size
217
+ assert chunk.local_starts == chunk.starts.tolist()
218
+ assert chunk.padded_local_cu_seq_lens.tolist() == [0] + list(
219
+ torch.tensor(chunk.padded_local_seq_lens).cumsum(0)
220
+ )
221
+ assert (
222
+ chunk.padded_local_token_to_seq.shape[0] == chunk.num_local_context_tokens
223
+ )
224
+ for offset, (start, length) in enumerate(
225
+ zip(chunk.local_starts, chunk.padded_local_seq_lens)
226
+ ):
227
+ request = chunk.request_slice.start + offset
228
+ assert start == local_cursor.get(request, 0)
229
+ local_cursor[request] = start + length
230
+
231
+ for request, length in enumerate(context_lens):
232
+ padded_local = -(-length // virtual_block_size) * interleave
233
+ assert local_cursor[request] == padded_local
234
+
235
+
236
+def test_dcp_reorg_uses_each_chunks_local_starts():
237
+ """Reorganization drops DCP padding at each request's own chunk offset."""
238
+ dcp_world_size, interleave, workspace_size = 2, 64, 1024
239
+ metadata = build_chunked_context(
240
+ [3000, 200, 200],
241
+ [4, 4, 4],
242
+ workspace_size,
243
+ block_size=128,
244
+ dcp_world_size=dcp_world_size,
245
+ dcp_local_block_size=interleave,
246
+ )
247
+ assert metadata is not None
248
+
249
+ for chunk in metadata.chunks:
250
+ assert chunk.padded_local_seq_lens is not None
251
+ assert chunk.local_context_lens_allranks is not None
252
+ assert chunk.local_starts is not None
253
+
254
+ toks = chunk.num_local_context_tokens
255
+ rank_buffers = [torch.full((toks, 1, 1), -1) for _ in range(dcp_world_size)]
256
+ expected = []
257
+ src_token_idx = 0
258
+ for request, (padded_len, local_lens, local_start) in enumerate(
259
+ zip(
260
+ chunk.padded_local_seq_lens,
261
+ chunk.local_context_lens_allranks,
262
+ chunk.local_starts,
263
+ )
264
+ ):
265
+ for rank, local_len in enumerate(local_lens):
266
+ actual_len = min(max(0, local_len - local_start), padded_len)
267
+ values = (
268
+ request * 100_000
269
+ + rank * 10_000
270
+ + torch.arange(local_start, local_start + actual_len)
271
+ ).view(-1, 1, 1)
272
+ rank_buffers[rank][src_token_idx : src_token_idx + actual_len] = values
273
+ expected.append(values)
274
+ src_token_idx += padded_len
275
+
276
+ allgathered = torch.cat(rank_buffers)
277
+ reorganized, _ = reorg_kvcache(
278
+ allgathered,
279
+ allgathered,
280
+ padded_local_chunk_seq_lens_lst=chunk.padded_local_seq_lens,
281
+ local_context_lens_allranks=chunk.local_context_lens_allranks,
282
+ local_starts=chunk.local_starts,
283
+ sum_seq_len=chunk.num_context_tokens,
284
+ max_seq_len=chunk.max_seq_len,
285
+ toks=toks,
286
+ )
287
+
288
+ torch.testing.assert_close(reorganized, torch.cat(expected))
tests/v1/attention/test_mla_prefill_registry.py
+2
-2
@@ -24,7 +24,7 @@ class CustomMLAPrefillBackend(MLAPrefillBackend):
24
def run_prefill_new_tokens(self, q, k, v, return_softmax_lse):
25
raise NotImplementedError
26
27
- def run_prefill_context_chunk(self, chunk_idx, q, k, v):
27
+ def run_prefill_context_chunk(self, chunk, q, k, v):
28
raise NotImplementedError
29
30
@@ -112,7 +112,7 @@ def test_register_custom_backend_as_decorator():
112
def run_prefill_new_tokens(self, q, k, v, return_softmax_lse):
113
raise NotImplementedError
114
115
- def run_prefill_context_chunk(self, chunk_idx, q, k, v):
115
+ def run_prefill_context_chunk(self, chunk, q, k, v):
116
raise NotImplementedError
117
118
assert MLAPrefillBackendEnum.CUSTOM.is_overridden()
tests/v1/attention/test_sparse_mla_backends.py
+3
-3
@@ -875,7 +875,7 @@ def test_masked_mha_workspace_fits_single_request_boundary(max_query_len, expect
875
_masked_mha_workspace_fits(
876
batch_size=1,
877
max_query_len=max_query_len,
878
- context_chunk_max_seq_lens=None,
878
+ max_context_chunk_seq_len=0,
879
workspace_numel=GLOBAL_TOPK_MASK_MAX_BYTES // torch.int32.itemsize,
880
)
881
is expected
@@ -884,7 +884,7 @@ def test_masked_mha_workspace_fits_single_request_boundary(max_query_len, expect
884
885
def test_masked_mha_workspace_fits_accounts_for_batch_and_context():
886
"""Request count and context chunk length are independent multipliers."""
887
- base = dict(batch_size=2, max_query_len=2048, context_chunk_max_seq_lens=[2048])
887
+ base = dict(batch_size=2, max_query_len=2048, max_context_chunk_seq_len=2048)
888
exact = math.prod(_topk_mask_shape(2, 2048, 2048))
889
890
assert _masked_mha_workspace_fits(**base, workspace_numel=exact)
@@ -892,7 +892,7 @@ def test_masked_mha_workspace_fits_accounts_for_batch_and_context():
892
**{**base, "batch_size": 3}, workspace_numel=exact
893
)
894
assert not _masked_mha_workspace_fits(
895
- **{**base, "context_chunk_max_seq_lens": [4096]}, workspace_numel=exact
895
+ **{**base, "max_context_chunk_seq_len": 4096}, workspace_numel=exact
896
)
897
898
vllm/model_executor/layers/attention/mla_attention.py
+454
-272
@@ -133,9 +133,19 @@ fixed workspace size.
133
134
The chunked prefill approach is as follows:
135
136
-MCC Max chunk of context to process per iter, computed dynamically,
136
+W Workspace rows, i.e. the context rows we may gather at once,
137
used to bound the memory usage
138
139
+The context is scheduled per request: requests are packed in order, splitting
140
+the next request on an aligned boundary when needed to fill `W`.
141
+So a chunk covers a contiguous run of prefills and only ever the tokens and
142
+context rows of those prefills — attention, up-projection and merging are
143
+charged only to the requests it covers, and no chunk contains an empty context
144
+span. See `plan_mla_context_chunks`.
145
+
146
+Only a chunk's first request can be a continuation, so accumulation performs at
147
+most one request-slice merge and one bulk write per chunk.
148
+
149
q_c = h_t @ W_DQ
150
q_nope = (q_c @ W_UQ).view(Sq, N, P)
151
q_pe = RoPE(q_c @ W_QR).view(Sq, N, R)
@@ -157,10 +167,12 @@ curr_o, curr_lse = scaled_dot_product_attention(
167
return_softmax_lse=True
168
)
169
160
-// Compute attention with the already existing context
161
-for chunk_idx in range(cdiv(C, MCC)):
162
- chunk_start = chunk_idx * MCC
163
- chunk_end = min(chunk_start + MCC, C)
170
+// Compute attention with the already existing context. Shown for a single
171
+// request; with a batch, a chunk covers a run of requests and both `q` and the
172
+// gathered context below are sliced to that run.
173
+for chunk_idx in range(cdiv(C, W)):
174
+ chunk_start = chunk_idx * W
175
+ chunk_end = min(chunk_start + W, C)
176
Sc = chunk_end - chunk_start
177
cache_kv_c_chunk = cache_kv_c[chunk_start:chunk_end]
178
cache_k_pe_chunk = cache_k_pe[chunk_start:chunk_end]
@@ -188,11 +200,15 @@ return curr_o @ W_O
200
"""
201
202
import functools
203
+import itertools
204
+import math
205
from abc import abstractmethod
206
+from collections.abc import Callable
207
from dataclasses import dataclass
208
from enum import Enum
209
from typing import ClassVar, Generic, TypeVar, cast
210
211
+import numpy as np
212
import torch
213
import torch.nn as nn
214
from tqdm import tqdm
@@ -256,6 +272,7 @@ from vllm.utils.torch_utils import (
272
direct_register_custom_op,
273
is_quantized_kv_cache,
274
kv_cache_dtype_str_to_dtype,
275
+ np_to_pinned_tensor,
276
)
277
from vllm.v1.attention.backend import (
278
AttentionBackend,
@@ -278,7 +295,6 @@ from vllm.v1.attention.backends.utils import (
295
from vllm.v1.attention.ops.common import cp_lse_ag_out_ar, cp_lse_ag_out_rs
296
from vllm.v1.attention.ops.dcp_alltoall import dcp_a2a_lse_reduce
297
from vllm.v1.attention.ops.merge_attn_states import merge_attn_states
281
-from vllm.v1.attention.ops.triton_merge_attn_states import mask_empty_context
298
from vllm.v1.attention.selector import get_attn_backend
299
from vllm.v1.kv_cache_interface import (
300
AttentionSpec,
@@ -1375,28 +1391,42 @@ class MLACommonPrefillMetadata:
1391
"""Prefill Specific Metadata"""
1392
1393
@dataclass
1378
- class ChunkedContextMetadata:
1379
- # New for MLA (compared to FlashAttention)
1380
- # For handling chunked prefill
1394
+ class ContextChunk:
1395
+ """One workspace-sized slice of paged context for a run of prefills."""
1396
+
1397
+ index: int
1398
+ request_slice: slice
1399
+ token_slice: slice
1400
+ continuation_token_end: int
1401
+ is_continuation: bool
1402
+ num_context_tokens: int
1403
+ query_start_loc: torch.Tensor
1404
+ max_query_len: int
1405
cu_seq_lens: torch.Tensor
1406
starts: torch.Tensor
1383
- seq_tot: list[int]
1384
- max_seq_lens: list[int]
1407
+ max_seq_len: int
1408
seq_lens: torch.Tensor
1386
- context_lens: torch.Tensor
1387
- workspace: torch.Tensor
1409
token_to_seq: torch.Tensor
1389
- chunk_total_token: list[int]
1390
- has_empty_context: list[bool]
1410
1411
# for mla DCP
1393
- padded_local_chunk_seq_lens: list[list[int]] | None = None
1412
+ padded_local_seq_lens: list[int] | None = None
1413
local_context_lens_allranks: list[list[int]] | None = None
1414
padded_local_cu_seq_lens: torch.Tensor | None = None
1415
padded_local_token_to_seq: torch.Tensor | None = None
1397
- cu_seq_lens_lst: list[list[int]] | None = None
1398
- chunk_size: int | None = None
1399
- prefill_tokens_with_context: int | None = None
1416
+ num_local_context_tokens: int = 0
1417
+ local_starts: list[int] | None = None
1418
+
1419
+ @property
1420
+ def num_requests(self) -> int:
1421
+ return self.request_slice.stop - self.request_slice.start
1422
+
1423
+ @dataclass
1424
+ class ChunkedContextMetadata:
1425
+ context_lens: torch.Tensor
1426
+ workspace: torch.Tensor
1427
+ chunks: "list[MLACommonPrefillMetadata.ContextChunk]"
1428
+ context_lens_list: list[int]
1429
+ empty_token_slices: list[slice]
1430
1431
block_table: torch.Tensor
1432
query_start_loc: torch.Tensor
@@ -1566,11 +1596,98 @@ def backend_supports_prefill_query_quantization() -> bool:
1596
)
1597
1598
1599
+@dataclass
1600
+class _ContextChunkPlan:
1601
+ """Request-space layout of one context chunk, before tensors are built."""
1602
+
1603
+ request_start: int
1604
+ request_end: int
1605
+ # Per-request context offset and row count, in request order.
1606
+ starts: list[int]
1607
+ seq_lens: list[int]
1608
+ is_continuation: bool
1609
+
1610
+
1611
+def plan_mla_context_chunks(
1612
+ context_lens: list[int],
1613
+ row_budget: int,
1614
+ max_context_chunk: int,
1615
+ split_alignment: int,
1616
+ padded_rows: Callable[[int], int],
1617
+) -> list[_ContextChunkPlan]:
1618
+ """Pack per-request contexts into workspace-sized chunks."""
1619
+ assert max_context_chunk > 0
1620
+ assert split_alignment > 0
1621
+
1622
+ def aligned_split_len(start: int, remaining: int, available: int) -> int:
1623
+ max_split = min(
1624
+ max_context_chunk,
1625
+ round_down(remaining - 1, split_alignment),
1626
+ )
1627
+ low, high = 0, max_split // split_alignment
1628
+ while low < high:
1629
+ mid = (low + high + 1) // 2
1630
+ length = mid * split_alignment
1631
+ rows = padded_rows(start + length) - padded_rows(start)
1632
+ if rows <= available:
1633
+ low = mid
1634
+ else:
1635
+ high = mid - 1
1636
+ return low * split_alignment
1637
+
1638
+ plans: list[_ContextChunkPlan] = []
1639
+ num_requests = len(context_lens)
1640
+ request = 0
1641
+ start = 0
1642
+ while request < num_requests:
1643
+ context_len = context_lens[request]
1644
+ if context_len == 0:
1645
+ assert start == 0
1646
+ request += 1
1647
+ continue
1648
+
1649
+ request_start = request
1650
+ starts: list[int] = []
1651
+ seq_lens: list[int] = []
1652
+ rows = 0
1653
+ while request < num_requests and context_lens[request] > 0:
1654
+ remaining = context_lens[request] - start
1655
+ request_rows = padded_rows(start + remaining) - padded_rows(start)
1656
+ if rows + request_rows > row_budget:
1657
+ split_len = aligned_split_len(start, remaining, row_budget - rows)
1658
+ if split_len > 0:
1659
+ starts.append(start)
1660
+ seq_lens.append(split_len)
1661
+ rows += padded_rows(start + split_len) - padded_rows(start)
1662
+ start += split_len
1663
+ break
1664
+ rows += request_rows
1665
+ starts.append(start)
1666
+ seq_lens.append(remaining)
1667
+ request += 1
1668
+ start = 0
1669
+ assert seq_lens
1670
+ plans.append(
1671
+ _ContextChunkPlan(
1672
+ request_start=request_start,
1673
+ request_end=request_start + len(seq_lens),
1674
+ starts=starts,
1675
+ seq_lens=seq_lens,
1676
+ is_continuation=starts[0] > 0,
1677
+ )
1678
+ )
1679
+ return plans
1680
+
1681
+
1682
+def _flat_int32(values: list[int] | np.ndarray) -> torch.Tensor:
1683
+ """Pinned int32 CPU tensor backing one concatenated per-chunk field."""
1684
+ return np_to_pinned_tensor(np.asarray(values, dtype=np.int32))
1685
+
1686
+
1687
def build_mla_chunked_context_metadata(
1688
*,
1689
context_lens_cpu: torch.Tensor,
1690
prefill_query_start_loc_cpu: torch.Tensor,
1573
- num_prefills: int,
1691
chunked_prefill_workspace: torch.Tensor,
1692
chunked_prefill_workspace_size: int,
1693
block_size: int,
@@ -1582,14 +1699,13 @@ def build_mla_chunked_context_metadata(
1699
) -> "MLACommonPrefillMetadata.ChunkedContextMetadata | None":
1700
"""Build chunked-context metadata for an MLA prefill.
1701
1585
- Shared by dense and sparse builders. Splits each prefill's context
1586
- into workspace-sized chunks and, under DCP, plans the per-rank interleaved
1587
- local chunks the all-gather reduction consumes.
1702
+ Shared by dense and sparse builders. Packs the prefill contexts into a flat
1703
+ list of workspace-sized per-request chunks and, under DCP, plans the
1704
+ per-rank interleaved local chunks the all-gather reduction consumes.
1705
1706
Args:
1707
context_lens_cpu: Per-prefill context length (seq_len - query_len).
1708
prefill_query_start_loc_cpu: Prefill query cumulative offsets (0-based).
1592
- num_prefills: Number of prefill requests.
1709
chunked_prefill_workspace: Scratch buffer the context gather writes to.
1710
chunked_prefill_workspace_size: Row capacity of the workspace.
1711
block_size: KV cache page size for chunk-start alignment.
@@ -1604,147 +1720,189 @@ def build_mla_chunked_context_metadata(
1720
"""
1721
# NOTE: it is recommended you read the `Chunked Prefill` section in the
1722
# comment at the top of the file before trying to understand this code.
1607
- max_context_len = context_lens_cpu.max().item()
1608
- if max_context_len <= 0:
1723
+ context_lens = context_lens_cpu.tolist()
1724
+ if max(context_lens, default=0) <= 0:
1725
return None
1610
- num_prefills_with_context = int((context_lens_cpu > 0).sum().item())
1611
-
1612
- # Currently we allocate an equal amount of workspace for each prefill with
1613
- # context; we could probably use a more advanced algorithm here and allocate
1614
- # more workspace to prefills with longer context lengths.
1615
- max_context_chunk = chunked_prefill_workspace_size // num_prefills_with_context
1616
- if align_chunk_to_block:
1617
- # The `gather_and_maybe_dequant_cache` kernel cannot handle chunk
1618
- # starts that are not aligned to block_size, so round down.
1619
- max_context_chunk = round_down(max_context_chunk, block_size)
1620
- assert max_context_chunk > 0
1726
1622
- num_chunks = cdiv(max_context_len, max_context_chunk)
1623
- # e.g. max_context_chunk=256, num_chunks=3, num_prefills=4 ->
1624
- # [[0, 0, 0, 0], [256, 256, 256, 256], [512, 512, 512, 512]]
1625
- # Note(simon): this is done on CPU because of downstream's use of `to_list`.
1626
- chunk_starts = torch.empty(
1627
- num_chunks, num_prefills, dtype=torch.int32, pin_memory=True
1628
- ).copy_(
1629
- torch.arange(num_chunks, dtype=torch.int32)
1630
- .multiply_(max_context_chunk)
1631
- .unsqueeze(1)
1632
- )
1633
- chunk_ends = torch.min(
1634
- context_lens_cpu.unsqueeze(0), chunk_starts + max_context_chunk
1635
- )
1636
- chunk_seq_lens = chunk_ends - chunk_starts
1637
- chunk_seq_lens.clamp_(min=0)
1638
- has_empty_context = torch.any(chunk_seq_lens == 0, dim=1).tolist()
1727
+ # The `gather_and_maybe_dequant_cache` kernel cannot handle chunk starts
1728
+ # that are not aligned to block_size, so a split request advances in
1729
+ # block-aligned steps.
1730
+ chunk_alignment = block_size if align_chunk_to_block else 1
1731
+ if dcp_world_size > 1:
1732
+ # Each rank gathers only its own shard, so a chunk's workspace cost is
1733
+ # 1/dcp_world_size of its context rows, rounded up to the interleave
1734
+ # block size. Sub-chunks are additionally aligned to the virtual block
1735
+ # size so that every rank's local start stays interleave-aligned.
1736
+ row_budget = chunked_prefill_workspace_size // dcp_world_size
1737
+ chunk_alignment = math.lcm(chunk_alignment, dcp_virtual_block_size)
1738
+
1739
+ def padded_rows(rows: int) -> int:
1740
+ return cdiv(rows, dcp_virtual_block_size) * dcp_local_block_size
1741
+ else:
1742
+ row_budget = chunked_prefill_workspace_size
1743
+
1744
+ def padded_rows(rows: int) -> int:
1745
+ return rows
1746
1640
- cu_seq_lens_cpu = torch.zeros(
1641
- num_chunks, num_prefills + 1, dtype=torch.int32, pin_memory=True
1747
+ max_context_chunk = round_down(chunked_prefill_workspace_size, chunk_alignment)
1748
+ assert max_context_chunk > 0, (
1749
+ f"chunked prefill workspace ({chunked_prefill_workspace_size} rows) is "
1750
+ f"smaller than the context chunk alignment ({chunk_alignment} rows)"
1751
)
1643
- torch.cumsum(chunk_seq_lens, dim=1, out=cu_seq_lens_cpu[:, 1:], dtype=torch.int32)
1644
- chunk_total_token = cu_seq_lens_cpu[:, -1]
1752
1646
- max_tokens_over_chunk = chunk_total_token.max().item()
1647
- token_to_seq_cpu = torch.zeros(
1648
- (num_chunks, max_tokens_over_chunk), dtype=torch.int32, pin_memory=True
1753
+ plans = plan_mla_context_chunks(
1754
+ context_lens,
1755
+ row_budget,
1756
+ max_context_chunk,
1757
+ chunk_alignment,
1758
+ padded_rows,
1759
)
1650
- req_indices = torch.arange(num_prefills, dtype=torch.int32)
1651
- for i in range(num_chunks):
1652
- token_to_seq = torch.repeat_interleave(req_indices, chunk_seq_lens[i])
1653
- token_to_seq_cpu[i, : token_to_seq.shape[0]] = token_to_seq
1760
1655
- prefill_tokens_with_context = prefill_query_start_loc_cpu[
1656
- num_prefills_with_context
1657
- ].item()
1761
+ query_start_loc = prefill_query_start_loc_cpu.tolist()
1762
+ empty_token_slices = [
1763
+ slice(query_start_loc[i], query_start_loc[i + 1])
1764
+ for i, context_len in enumerate(context_lens)
1765
+ if context_len == 0
1766
+ ]
1767
1659
- metadata_cls = MLACommonPrefillMetadata.ChunkedContextMetadata
1660
- if dcp_world_size > 1:
1661
- local_context_lens_allranks = get_dcp_local_seq_lens(
1768
+ use_dcp = dcp_world_size > 1
1769
+ local_context_lens_allranks = (
1770
+ get_dcp_local_seq_lens(
1771
context_lens_cpu, dcp_world_size, None, dcp_local_block_size
1772
+ ).tolist()
1773
+ if use_dcp
1774
+ else None
1775
+ )
1776
+
1777
+ # Concatenate the per-chunk fields so each one costs a single
1778
+ # host-to-device transfer, and remember where every chunk's slice lands.
1779
+ starts_flat: list[int] = []
1780
+ seq_lens_flat: list[int] = []
1781
+ cu_seq_lens_flat: list[int] = []
1782
+ cu_seqlens_q_flat: list[int] = []
1783
+ token_to_seq_parts: list[np.ndarray] = []
1784
+ padded_local_cu_seq_lens_flat: list[int] = []
1785
+ padded_local_token_to_seq_parts: list[np.ndarray] = []
1786
+ local_starts_per_chunk: list[list[int]] = []
1787
+ local_seq_lens_per_chunk: list[list[int]] = []
1788
+ layouts: list[tuple[slice, slice, slice, slice]] = []
1789
+
1790
+ request_offset = boundary_offset = token_offset = local_token_offset = 0
1791
+ for plan in plans:
1792
+ num_requests = len(plan.seq_lens)
1793
+ num_tokens = sum(plan.seq_lens)
1794
+ num_local_tokens = num_tokens
1795
+
1796
+ seq_lens_flat.extend(plan.seq_lens)
1797
+ cu_seq_lens_flat.extend(itertools.accumulate(plan.seq_lens, initial=0))
1798
+ query_base = query_start_loc[plan.request_start]
1799
+ cu_seqlens_q_flat.extend(
1800
+ query_start_loc[request] - query_base
1801
+ for request in range(plan.request_start, plan.request_end + 1)
1802
)
1664
- # Note(qcs): Per-rank local context lengths, padded to
1665
- # `dcp_local_block_size`.
1666
- padded_local_context_lens_cpu: torch.Tensor = (
1667
- cdiv(context_lens_cpu, dcp_virtual_block_size) * dcp_local_block_size
1668
- )
1669
- # Note(hc): The above max_context_chunk already enforces block_size
1670
- # alignment; DCP only requires block_size be divisible by dcp_world_size,
1671
- # because DCP uses cp_gather_cache, which does not require chunk starts
1672
- # aligned to block_size.
1673
- assert max_context_chunk % dcp_world_size == 0
1674
- padded_local_max_context_chunk = (
1675
- cdiv(max_context_chunk, dcp_virtual_block_size) * dcp_local_block_size
1676
- )
1677
- local_chunk_starts = torch.empty(
1678
- num_chunks, num_prefills, dtype=torch.int32, pin_memory=True
1679
- ).copy_(
1680
- torch.arange(num_chunks, dtype=torch.int32)
1681
- .multiply_(padded_local_max_context_chunk)
1682
- .unsqueeze(1)
1683
- )
1684
- local_chunk_ends = torch.min(
1685
- padded_local_context_lens_cpu.unsqueeze(0),
1686
- local_chunk_starts + padded_local_max_context_chunk,
1803
+ token_to_seq_parts.append(
1804
+ np.repeat(np.arange(num_requests, dtype=np.int32), plan.seq_lens)
1805
)
1688
- padded_local_chunk_seq_lens = local_chunk_ends - local_chunk_starts
1689
- padded_local_chunk_seq_lens.clamp_(min=0)
1806
1691
- padded_local_cu_seq_lens_cpu = torch.zeros(
1692
- num_chunks, num_prefills + 1, dtype=torch.int32, pin_memory=True
1693
- )
1694
- torch.cumsum(
1695
- padded_local_chunk_seq_lens,
1696
- dim=1,
1697
- out=padded_local_cu_seq_lens_cpu[:, 1:],
1698
- dtype=torch.int32,
1807
+ if use_dcp:
1808
+ # A request's local rows are its context rows sharded across ranks
1809
+ # and rounded up to the interleave block size, so the local start of
1810
+ # a continuation is the padded row count of the context before it.
1811
+ local_starts = [padded_rows(start) for start in plan.starts]
1812
+ local_seq_lens = [
1813
+ padded_rows(start + length) - local_start
1814
+ for start, length, local_start in zip(
1815
+ plan.starts, plan.seq_lens, local_starts
1816
+ )
1817
+ ]
1818
+ local_starts_per_chunk.append(local_starts)
1819
+ local_seq_lens_per_chunk.append(local_seq_lens)
1820
+ padded_local_cu_seq_lens_flat.extend(
1821
+ itertools.accumulate(local_seq_lens, initial=0)
1822
+ )
1823
+ padded_local_token_to_seq_parts.append(
1824
+ np.repeat(np.arange(num_requests, dtype=np.int32), local_seq_lens)
1825
+ )
1826
+ num_local_tokens = sum(local_seq_lens)
1827
+ # The gather takes per-rank local offsets under DCP.
1828
+ starts_flat.extend(local_starts)
1829
+ else:
1830
+ starts_flat.extend(plan.starts)
1831
+ assert num_local_tokens <= row_budget
1832
+
1833
+ layouts.append(
1834
+ (
1835
+ slice(request_offset, request_offset + num_requests),
1836
+ slice(boundary_offset, boundary_offset + num_requests + 1),
1837
+ slice(token_offset, token_offset + num_tokens),
1838
+ slice(local_token_offset, local_token_offset + num_local_tokens),
1839
+ )
1840
)
1700
- max_padded_local_tokens = padded_local_cu_seq_lens_cpu[:, -1].max().item()
1701
- padded_local_token_to_seq_cpu = torch.zeros(
1702
- (num_chunks, max_padded_local_tokens), dtype=torch.int32
1841
+ request_offset += num_requests
1842
+ boundary_offset += num_requests + 1
1843
+ token_offset += num_tokens
1844
+ local_token_offset += num_local_tokens
1845
+
1846
+ seq_lens_cpu = _flat_int32(seq_lens_flat)
1847
+ starts = _flat_int32(starts_flat).to(device, non_blocking=True)
1848
+ cu_seq_lens = _flat_int32(cu_seq_lens_flat).to(device, non_blocking=True)
1849
+ cu_seqlens_q = _flat_int32(cu_seqlens_q_flat).to(device, non_blocking=True)
1850
+ token_to_seq = _flat_int32(np.concatenate(token_to_seq_parts)).to(
1851
+ device, non_blocking=True
1852
+ )
1853
+ if use_dcp:
1854
+ padded_local_cu_seq_lens = _flat_int32(padded_local_cu_seq_lens_flat).to(
1855
+ device, non_blocking=True
1856
)
1704
- for i in range(num_chunks):
1705
- tts = torch.repeat_interleave(req_indices, padded_local_chunk_seq_lens[i])
1706
- padded_local_token_to_seq_cpu[i, : tts.shape[0]] = tts
1707
-
1708
- chunked_context_metadata = metadata_cls(
1709
- cu_seq_lens=cu_seq_lens_cpu.to(device, non_blocking=True),
1710
- starts=local_chunk_starts.to(device, non_blocking=True),
1711
- seq_tot=padded_local_chunk_seq_lens.sum(dim=1).tolist(),
1712
- max_seq_lens=chunk_seq_lens.max(dim=1).values.tolist(),
1713
- seq_lens=chunk_seq_lens,
1714
- context_lens=context_lens_cpu.to(device, non_blocking=True),
1715
- token_to_seq=token_to_seq_cpu.to(device, non_blocking=True),
1716
- chunk_total_token=chunk_total_token.tolist(),
1717
- workspace=chunked_prefill_workspace,
1718
- has_empty_context=has_empty_context,
1719
- prefill_tokens_with_context=prefill_tokens_with_context,
1720
- padded_local_chunk_seq_lens=padded_local_chunk_seq_lens.tolist(),
1721
- local_context_lens_allranks=local_context_lens_allranks.tolist(),
1722
- padded_local_cu_seq_lens=padded_local_cu_seq_lens_cpu.to(
1723
- device, non_blocking=True
1857
+ padded_local_token_to_seq = _flat_int32(
1858
+ np.concatenate(padded_local_token_to_seq_parts)
1859
+ ).to(device, non_blocking=True)
1860
+
1861
+ chunks: list[MLACommonPrefillMetadata.ContextChunk] = []
1862
+ for index, (plan, layout) in enumerate(zip(plans, layouts)):
1863
+ request_slice, boundary_slice, token_slice, local_token_slice = layout
1864
+ query_lens = [
1865
+ query_start_loc[request + 1] - query_start_loc[request]
1866
+ for request in range(plan.request_start, plan.request_end)
1867
+ ]
1868
+ chunk = MLACommonPrefillMetadata.ContextChunk(
1869
+ index=index,
1870
+ request_slice=slice(plan.request_start, plan.request_end),
1871
+ token_slice=slice(
1872
+ query_start_loc[plan.request_start], query_start_loc[plan.request_end]
1873
),
1725
- padded_local_token_to_seq=padded_local_token_to_seq_cpu.to(
1726
- device, non_blocking=True
1727
- ),
1728
- cu_seq_lens_lst=cu_seq_lens_cpu.tolist(),
1729
- chunk_size=padded_local_max_context_chunk,
1730
- )
1731
- else:
1732
- chunked_context_metadata = metadata_cls(
1733
- cu_seq_lens=cu_seq_lens_cpu.to(device, non_blocking=True),
1734
- starts=chunk_starts.to(device, non_blocking=True),
1735
- seq_tot=chunk_seq_lens.sum(dim=1).tolist(),
1736
- max_seq_lens=chunk_seq_lens.max(dim=1).values.tolist(),
1737
- seq_lens=chunk_seq_lens,
1738
- context_lens=context_lens_cpu.to(device, non_blocking=True),
1739
- token_to_seq=token_to_seq_cpu.to(device, non_blocking=True),
1740
- chunk_total_token=chunk_total_token,
1741
- workspace=chunked_prefill_workspace,
1742
- has_empty_context=has_empty_context,
1743
- prefill_tokens_with_context=prefill_tokens_with_context,
1874
+ continuation_token_end=query_start_loc[plan.request_start + 1],
1875
+ is_continuation=plan.is_continuation,
1876
+ num_context_tokens=token_slice.stop - token_slice.start,
1877
+ query_start_loc=cu_seqlens_q[boundary_slice],
1878
+ max_query_len=max(query_lens),
1879
+ cu_seq_lens=cu_seq_lens[boundary_slice],
1880
+ starts=starts[request_slice],
1881
+ max_seq_len=max(plan.seq_lens),
1882
+ seq_lens=seq_lens_cpu[request_slice],
1883
+ token_to_seq=token_to_seq[token_slice],
1884
+ num_local_context_tokens=local_token_slice.stop - local_token_slice.start,
1885
)
1745
-
1746
- assert max(chunked_context_metadata.max_seq_lens) <= chunked_prefill_workspace_size
1747
- return chunked_context_metadata
1886
+ if use_dcp:
1887
+ assert local_context_lens_allranks is not None
1888
+ chunk.padded_local_seq_lens = local_seq_lens_per_chunk[index]
1889
+ chunk.local_context_lens_allranks = local_context_lens_allranks[
1890
+ plan.request_start : plan.request_end
1891
+ ]
1892
+ chunk.padded_local_cu_seq_lens = padded_local_cu_seq_lens[boundary_slice]
1893
+ chunk.padded_local_token_to_seq = padded_local_token_to_seq[
1894
+ local_token_slice
1895
+ ]
1896
+ chunk.local_starts = local_starts_per_chunk[index]
1897
+ chunks.append(chunk)
1898
+
1899
+ return MLACommonPrefillMetadata.ChunkedContextMetadata(
1900
+ context_lens=context_lens_cpu.to(device, non_blocking=True),
1901
+ workspace=chunked_prefill_workspace,
1902
+ chunks=chunks,
1903
+ context_lens_list=context_lens,
1904
+ empty_token_slices=empty_token_slices,
1905
+ )
1906
1907
1908
class MLACommonMetadataBuilder(AttentionMetadataBuilder[M]):
@@ -1796,13 +1954,7 @@ class MLACommonMetadataBuilder(AttentionMetadataBuilder[M]):
1954
64 * 1024,
1955
)
1956
1799
- # Enforce that we enough for at least 1 page per request
1800
- chunked_prefill_workspace_size = max(
1801
- chunked_prefill_workspace_size,
1802
- scheduler_config.max_num_seqs * cache_config.block_size,
1803
- )
1804
-
1805
- return chunked_prefill_workspace_size
1957
+ return max(chunked_prefill_workspace_size, cache_config.block_size)
1958
1959
@staticmethod
1960
def determine_prefill_query_data_type(
@@ -2063,7 +2215,6 @@ class MLACommonMetadataBuilder(AttentionMetadataBuilder[M]):
2215
chunked_context_metadata = build_mla_chunked_context_metadata(
2216
context_lens_cpu=context_lens_cpu,
2217
prefill_query_start_loc_cpu=prefill_query_start_loc_cpu,
2066
- num_prefills=num_prefills,
2218
chunked_prefill_workspace=self.chunked_prefill_workspace,
2219
chunked_prefill_workspace_size=self.chunked_prefill_workspace_size,
2220
block_size=self.page_size,
@@ -2138,10 +2289,9 @@ def reorg_kvcache(
2289
allgatered_k_pe: torch.Tensor,
2290
padded_local_chunk_seq_lens_lst: list[int],
2291
local_context_lens_allranks: list[list[int]],
2292
+ local_starts: list[int],
2293
sum_seq_len: int,
2294
max_seq_len: int,
2143
- chunk_size: int,
2144
- chunk_idx: int,
2295
toks: int,
2296
) -> tuple[torch.Tensor, torch.Tensor]:
2297
"""
@@ -2155,19 +2305,18 @@ def reorg_kvcache(
2305
padded_local_chunk_seq_lens_lst: local chunk context lengths
2306
under current CP rank.
2307
local_context_lens_allranks: local context lengths on each CP rank.
2308
+ local_starts: per-request local offset into the context this chunk
2309
+ starts at.
2310
sum_seq_len: the sum of cp_chunk_seq_lens_lst.
2311
max_seq_len: the max value of cp_chunk_seq_lens_lst.
2160
- chunk_size: the local padded max context chunk from
2161
- chunked_context_metadata building.
2162
- chunk_idx: chunk idx of chunked_prefill.
2312
toks: the number of tokens for local gather cache.
2313
"""
2314
kv_c_segments = []
2315
k_pe_segments = []
2316
src_token_idx = 0
2317
max_seq_len_check = 0
2169
- for padded_local_chunk_seq_len, local_context_lens in zip(
2170
- padded_local_chunk_seq_lens_lst, local_context_lens_allranks
2318
+ for padded_local_chunk_seq_len, local_context_lens, local_start in zip(
2319
+ padded_local_chunk_seq_lens_lst, local_context_lens_allranks, local_starts
2320
):
2321
cur_seq_len = 0
2322
for rank, local_context_len in enumerate(local_context_lens):
@@ -2179,7 +2328,7 @@ def reorg_kvcache(
2328
# local_chunk_len in dcp1: |-----|-----|--|
2329
# so we need update the last chunk length in dcp1.
2330
local_chunk_len = min(
2182
- max(0, local_context_len - chunk_idx * chunk_size),
2331
+ max(0, local_context_len - local_start),
2332
padded_local_chunk_seq_len,
2333
)
2334
if local_chunk_len != 0:
@@ -2206,6 +2355,66 @@ def reorg_kvcache(
2355
return reorganized_kv_c_normed, reorganized_k_pe
2356
2357
2358
+def init_mla_context_partial(
2359
+ chunked_context: "MLACommonPrefillMetadata.ChunkedContextMetadata",
2360
+ attn_output: torch.Tensor,
2361
+ attn_softmax_lse: torch.Tensor,
2362
+ num_tokens: int,
2363
+) -> tuple[torch.Tensor, torch.Tensor]:
2364
+ """Allocate the running context partial over all prefill tokens.
2365
+
2366
+ Laid out like the chunk partials so the final whole-batch merge against the
2367
+ suffix partial sees matching head strides.
2368
+ """
2369
+ output = torch.empty(
2370
+ (num_tokens, *attn_output.shape[1:]),
2371
+ dtype=attn_output.dtype,
2372
+ device=attn_output.device,
2373
+ )
2374
+ output_lse = torch.empty(
2375
+ (attn_softmax_lse.shape[0], num_tokens),
2376
+ dtype=attn_softmax_lse.dtype,
2377
+ device=attn_softmax_lse.device,
2378
+ )
2379
+ # No chunk covers a prefill without context, so neutralize its partial.
2380
+ for token_slice in chunked_context.empty_token_slices:
2381
+ output[token_slice].zero_()
2382
+ output_lse[:, token_slice].fill_(float("-inf"))
2383
+ return output, output_lse
2384
+
2385
+
2386
+def accumulate_mla_context_chunk(
2387
+ chunk: "MLACommonPrefillMetadata.ContextChunk",
2388
+ attn_output: torch.Tensor,
2389
+ attn_softmax_lse: torch.Tensor,
2390
+ output: torch.Tensor,
2391
+ output_lse: torch.Tensor,
2392
+) -> None:
2393
+ """Fold one chunk's partial into the running context partial.
2394
+
2395
+ Only the first request may be a continuation; its tokens are merged and the
2396
+ remaining token range is initialized.
2397
+ """
2398
+ token_start = chunk.token_slice.start
2399
+ token_end = chunk.token_slice.stop
2400
+ init_start = token_start
2401
+ if chunk.is_continuation:
2402
+ init_start = chunk.continuation_token_end
2403
+ num_merged = init_start - token_start
2404
+ merge_attn_states(
2405
+ output=output[token_start:init_start],
2406
+ output_lse=output_lse[:, token_start:init_start],
2407
+ prefix_output=output[token_start:init_start],
2408
+ prefix_lse=output_lse[:, token_start:init_start],
2409
+ suffix_output=attn_output[:num_merged],
2410
+ suffix_lse=attn_softmax_lse[:, :num_merged],
2411
+ )
2412
+ if init_start < token_end:
2413
+ written = init_start - token_start
2414
+ output[init_start:token_end].copy_(attn_output[written:])
2415
+ output_lse[:, init_start:token_end].copy_(attn_softmax_lse[:, written:])
2416
+
2417
+
2418
class MLACommonBaseImpl(MLAAttentionImpl[A], Generic[A]):
2419
"""
2420
Shared MLA base providing dense-MHA prefill (via the selected
@@ -2282,53 +2491,53 @@ class MLACommonBaseImpl(MLAAttentionImpl[A], Generic[A]):
2491
assert attn_metadata.prefill is not None
2492
prefill_metadata = attn_metadata.prefill
2493
assert prefill_metadata.prefill_backend is not None
2285
- assert prefill_metadata.chunked_context is not None
2494
+ chunked_context = prefill_metadata.chunked_context
2495
+ assert chunked_context is not None
2496
2497
use_fp8_prefill = prefill_metadata.q_data_type == current_platform.fp8_dtype()
2498
kv_b_proj_input_dtype = _get_kv_b_proj_input_dtype(
2499
self.kv_b_proj, use_fp8_prefill
2500
)
2291
-
2292
- output = None
2293
- merge_output = None
2294
- iters = len(prefill_metadata.chunked_context.seq_tot)
2295
- workspace = prefill_metadata.chunked_context.workspace
2501
+ workspace = chunked_context.workspace
2502
2503
if use_fp8_prefill:
2504
q = q.to(prefill_metadata.q_data_type)
2505
2300
- for i in range(iters):
2301
- toks = prefill_metadata.chunked_context.seq_tot[i]
2506
+ output = None
2507
+ output_lse = None
2508
+ for chunk in chunked_context.chunks:
2509
+ toks = chunk.num_context_tokens
2510
+ block_table = prefill_metadata.block_table[chunk.request_slice]
2511
if self.kv_cache_dtype == "fp8_ds_mla":
2512
ops.cp_gather_and_upconvert_fp8_kv_cache(
2513
src_cache=kv_c_and_k_pe_cache,
2514
dst=workspace[:toks],
2306
- block_table=prefill_metadata.block_table,
2307
- workspace_starts=prefill_metadata.chunked_context.cu_seq_lens[i],
2308
- batch_size=attn_metadata.num_prefills,
2309
- seq_starts=prefill_metadata.chunked_context.starts[i],
2515
+ block_table=block_table,
2516
+ workspace_starts=chunk.cu_seq_lens,
2517
+ batch_size=chunk.num_requests,
2518
+ seq_starts=chunk.starts,
2519
)
2520
elif not use_fp8_prefill:
2521
ops.gather_and_maybe_dequant_cache(
2522
src_cache=kv_c_and_k_pe_cache,
2523
dst=workspace,
2315
- block_table=prefill_metadata.block_table,
2316
- cu_seq_lens=prefill_metadata.chunked_context.cu_seq_lens[i],
2317
- token_to_seq=prefill_metadata.chunked_context.token_to_seq[i],
2318
- num_tokens=prefill_metadata.chunked_context.chunk_total_token[i],
2524
+ block_table=block_table,
2525
+ cu_seq_lens=chunk.cu_seq_lens,
2526
+ token_to_seq=chunk.token_to_seq,
2527
+ num_tokens=toks,
2528
kv_cache_dtype=self.kv_cache_dtype,
2529
scale=k_scale,
2321
- seq_starts=prefill_metadata.chunked_context.starts[i],
2530
+ seq_starts=chunk.starts,
2531
)
2532
else:
2533
# FP8 path: gather cache without dequantization
2534
ops.cp_gather_cache(
2535
src_cache=kv_c_and_k_pe_cache,
2536
dst=workspace,
2328
- block_table=prefill_metadata.block_table,
2329
- cu_seq_lens=prefill_metadata.chunked_context.cu_seq_lens[i],
2330
- batch_size=attn_metadata.num_prefills,
2331
- seq_starts=prefill_metadata.chunked_context.starts[i],
2537
+ block_table=block_table,
2538
+ cu_seq_lens=chunk.cu_seq_lens,
2539
+ batch_size=chunk.num_requests,
2540
+ seq_starts=chunk.starts,
2541
)
2542
2543
# Extract kv_c_normed from workspace
@@ -2351,37 +2560,28 @@ class MLACommonBaseImpl(MLAAttentionImpl[A], Generic[A]):
2560
2561
attn_output, attn_softmax_lse = (
2562
prefill_metadata.prefill_backend.run_prefill_context_chunk(
2354
- chunk_idx=i,
2355
- q=q,
2563
+ chunk=chunk,
2564
+ q=q[chunk.token_slice],
2565
k=k,
2566
v=v,
2567
)
2568
)
2360
- if prefill_metadata.chunked_context.has_empty_context[i]:
2361
- mask_empty_context(
2362
- attn_softmax_lse,
2363
- attn_output,
2364
- prefill_metadata.query_start_loc,
2365
- prefill_metadata.chunked_context.cu_seq_lens[i],
2366
- )
2569
2570
if output is None:
2369
- output = attn_output
2370
- output_lse = attn_softmax_lse
2371
- else:
2372
- if merge_output is None:
2373
- merge_output = torch.empty_like(output)
2374
- merge_output_lse = torch.empty_like(output_lse)
2375
- merge_attn_states(
2376
- output=merge_output,
2377
- output_lse=merge_output_lse,
2378
- prefix_output=output,
2379
- prefix_lse=output_lse,
2380
- suffix_output=attn_output,
2381
- suffix_lse=attn_softmax_lse,
2571
+ if (
2572
+ len(chunked_context.chunks) == 1
2573
+ and not chunked_context.empty_token_slices
2574
+ ):
2575
+ return attn_output, attn_softmax_lse
2576
+ output, output_lse = init_mla_context_partial(
2577
+ chunked_context,
2578
+ attn_output,
2579
+ attn_softmax_lse,
2580
+ num_tokens=q.shape[0],
2581
)
2383
- output, merge_output = merge_output, output
2384
- output_lse, merge_output_lse = merge_output_lse, output_lse
2582
+ accumulate_mla_context_chunk(
2583
+ chunk, attn_output, attn_softmax_lse, output, output_lse
2584
+ )
2585
2586
return output, output_lse
2587
@@ -2396,62 +2596,57 @@ class MLACommonBaseImpl(MLAAttentionImpl[A], Generic[A]):
2596
assert attn_metadata.prefill is not None
2597
prefill_metadata = attn_metadata.prefill
2598
assert prefill_metadata.prefill_backend is not None
2399
- assert prefill_metadata.chunked_context is not None
2400
- assert prefill_metadata.chunked_context.padded_local_chunk_seq_lens is not None
2401
- assert prefill_metadata.chunked_context.local_context_lens_allranks is not None
2402
- assert prefill_metadata.chunked_context.padded_local_cu_seq_lens is not None
2403
- assert prefill_metadata.chunked_context.padded_local_token_to_seq is not None
2404
- assert prefill_metadata.chunked_context.cu_seq_lens_lst is not None
2405
- assert prefill_metadata.chunked_context.chunk_size is not None
2599
+ chunked_context = prefill_metadata.chunked_context
2600
+ assert chunked_context is not None
2601
2602
use_fp8_prefill = prefill_metadata.q_data_type == current_platform.fp8_dtype()
2603
kv_b_proj_input_dtype = _get_kv_b_proj_input_dtype(
2604
self.kv_b_proj, use_fp8_prefill
2605
)
2606
output = None
2412
- merge_output = None
2413
- iters = len(prefill_metadata.chunked_context.seq_tot)
2414
- workspace = prefill_metadata.chunked_context.workspace
2415
-
2416
- for i in range(iters):
2417
- toks = prefill_metadata.chunked_context.seq_tot[i]
2418
- if toks == 0:
2419
- continue
2420
- padded_local_cu_seq_lens = (
2421
- prefill_metadata.chunked_context.padded_local_cu_seq_lens[i]
2422
- )
2607
+ output_lse = None
2608
+ workspace = chunked_context.workspace
2609
+
2610
+ for chunk in chunked_context.chunks:
2611
+ assert chunk.padded_local_seq_lens is not None
2612
+ assert chunk.local_context_lens_allranks is not None
2613
+ assert chunk.padded_local_cu_seq_lens is not None
2614
+ assert chunk.padded_local_token_to_seq is not None
2615
+ assert chunk.local_starts is not None
2616
+
2617
+ toks = chunk.num_local_context_tokens
2618
+ padded_local_cu_seq_lens = chunk.padded_local_cu_seq_lens
2619
+ block_table = prefill_metadata.block_table[chunk.request_slice]
2620
if self.kv_cache_dtype == "fp8_ds_mla":
2621
ops.cp_gather_and_upconvert_fp8_kv_cache(
2622
src_cache=kv_c_and_k_pe_cache,
2623
dst=workspace[:toks],
2427
- block_table=prefill_metadata.block_table,
2624
+ block_table=block_table,
2625
workspace_starts=padded_local_cu_seq_lens,
2429
- batch_size=attn_metadata.num_prefills,
2430
- seq_starts=prefill_metadata.chunked_context.starts[i],
2626
+ batch_size=chunk.num_requests,
2627
+ seq_starts=chunk.starts,
2628
)
2629
elif is_quantized_kv_cache(self.kv_cache_dtype):
2630
assert k_scale is not None
2631
ops.gather_and_maybe_dequant_cache(
2632
src_cache=kv_c_and_k_pe_cache,
2633
dst=workspace,
2437
- block_table=prefill_metadata.block_table,
2634
+ block_table=block_table,
2635
cu_seq_lens=padded_local_cu_seq_lens,
2439
- token_to_seq=prefill_metadata.chunked_context.padded_local_token_to_seq[
2440
- i
2441
- ],
2636
+ token_to_seq=chunk.padded_local_token_to_seq,
2637
num_tokens=toks,
2638
kv_cache_dtype=self.kv_cache_dtype,
2639
scale=k_scale,
2445
- seq_starts=prefill_metadata.chunked_context.starts[i],
2640
+ seq_starts=chunk.starts,
2641
)
2642
else:
2643
ops.cp_gather_cache(
2644
src_cache=kv_c_and_k_pe_cache,
2645
dst=workspace,
2451
- block_table=prefill_metadata.block_table,
2646
+ block_table=block_table,
2647
cu_seq_lens=padded_local_cu_seq_lens,
2453
- batch_size=attn_metadata.num_prefills,
2454
- seq_starts=prefill_metadata.chunked_context.starts[i],
2648
+ batch_size=chunk.num_requests,
2649
+ seq_starts=chunk.starts,
2650
)
2651
# workspace
2652
# |------- N tokens --------|--------- N*dcp_size tokens ----------|
@@ -2479,14 +2674,11 @@ class MLACommonBaseImpl(MLAAttentionImpl[A], Generic[A]):
2674
kv_c_normed, k_pe = reorg_kvcache(
2675
allgatered_kv_c_normed,
2676
allgatered_k_pe,
2482
- padded_local_chunk_seq_lens_lst=prefill_metadata.chunked_context.padded_local_chunk_seq_lens[
2483
- i
2484
- ],
2485
- local_context_lens_allranks=prefill_metadata.chunked_context.local_context_lens_allranks,
2486
- sum_seq_len=prefill_metadata.chunked_context.cu_seq_lens_lst[i][-1],
2487
- max_seq_len=prefill_metadata.chunked_context.max_seq_lens[i],
2488
- chunk_size=prefill_metadata.chunked_context.chunk_size,
2489
- chunk_idx=i,
2677
+ padded_local_chunk_seq_lens_lst=chunk.padded_local_seq_lens,
2678
+ local_context_lens_allranks=chunk.local_context_lens_allranks,
2679
+ local_starts=chunk.local_starts,
2680
+ sum_seq_len=chunk.num_context_tokens,
2681
+ max_seq_len=chunk.max_seq_len,
2682
toks=toks,
2683
)
2684
if kv_b_proj_input_dtype is not None:
@@ -2503,37 +2695,28 @@ class MLACommonBaseImpl(MLAAttentionImpl[A], Generic[A]):
2695
2696
attn_output, attn_softmax_lse = (
2697
prefill_metadata.prefill_backend.run_prefill_context_chunk(
2506
- chunk_idx=i,
2507
- q=q,
2698
+ chunk=chunk,
2699
+ q=q[chunk.token_slice],
2700
k=k,
2701
v=v,
2702
)
2703
)
2512
- if prefill_metadata.chunked_context.has_empty_context[i]:
2513
- mask_empty_context(
2514
- attn_softmax_lse,
2515
- attn_output,
2516
- prefill_metadata.query_start_loc,
2517
- prefill_metadata.chunked_context.cu_seq_lens[i],
2518
- )
2704
2705
if output is None:
2521
- output = attn_output
2522
- output_lse = attn_softmax_lse
2523
- else:
2524
- if merge_output is None:
2525
- merge_output = torch.empty_like(output)
2526
- merge_output_lse = torch.empty_like(output_lse)
2527
- merge_attn_states(
2528
- output=merge_output,
2529
- output_lse=merge_output_lse,
2530
- prefix_output=output,
2531
- prefix_lse=output_lse,
2532
- suffix_output=attn_output,
2533
- suffix_lse=attn_softmax_lse,
2706
+ if (
2707
+ len(chunked_context.chunks) == 1
2708
+ and not chunked_context.empty_token_slices
2709
+ ):
2710
+ return attn_output, attn_softmax_lse
2711
+ output, output_lse = init_mla_context_partial(
2712
+ chunked_context,
2713
+ attn_output,
2714
+ attn_softmax_lse,
2715
+ num_tokens=q.shape[0],
2716
)
2535
- output, merge_output = merge_output, output
2536
- output_lse, merge_output_lse = merge_output_lse, output_lse
2717
+ accumulate_mla_context_chunk(
2718
+ chunk, attn_output, attn_softmax_lse, output, output_lse
2719
+ )
2720
2721
return output, output_lse
2722
@@ -2615,7 +2798,6 @@ class MLACommonBaseImpl(MLAAttentionImpl[A], Generic[A]):
2798
prefix_lse=context_lse,
2799
suffix_output=suffix_output,
2800
suffix_lse=suffix_lse,
2618
- prefill_tokens_with_context=prefill_metadata.chunked_context.prefill_tokens_with_context,
2801
)
2802
elif output_scale is None:
2803
# With output_scale set, backend already wrote into `output` in place.
vllm/model_executor/layers/attention/sparse_mla_attention.py
+50
-54
@@ -19,8 +19,10 @@ from vllm.model_executor.layers.attention.mla_attention import (
19
MLACommonBaseImpl,
20
MLACommonMetadata,
21
MLACommonPrefillMetadata,
22
+ accumulate_mla_context_chunk,
23
build_mla_chunked_context_metadata,
24
get_mla_dims,
25
+ init_mla_context_partial,
26
)
27
from vllm.platforms import current_platform
28
from vllm.triton_utils import tl, triton
@@ -60,17 +62,14 @@ def _topk_mask_shape(
62
def _masked_mha_workspace_fits(
63
batch_size: int,
64
max_query_len: int,
63
- context_chunk_max_seq_lens: list[int] | None,
65
+ max_context_chunk_seq_len: int,
66
workspace_numel: int,
67
) -> bool:
68
"""Return whether the suffix and per-context-chunk masks fit the workspace.
69
70
The global mask is excluded: it always needs more, and has its own check.
71
"""
70
- max_key_len = max(
71
- max_query_len,
72
- max(context_chunk_max_seq_lens or (), default=0),
73
- )
72
+ max_key_len = max(max_query_len, max_context_chunk_seq_len)
73
needed = math.prod(_topk_mask_shape(batch_size, max_query_len, max_key_len))
74
return needed <= workspace_numel
75
@@ -185,10 +184,7 @@ class SparseMLACommonMetadataBuilder(AttentionMetadataBuilder[T]):
184
64 * 1024,
185
scheduler_config.max_num_seqs * topk_tokens,
186
)
188
- return max(
189
- workspace_size,
190
- scheduler_config.max_num_seqs * cache_config.block_size,
191
- )
187
+ return max(workspace_size, cache_config.block_size)
188
189
def _build_req_id_per_token(
190
self,
@@ -228,7 +224,6 @@ class SparseMLACommonMetadataBuilder(AttentionMetadataBuilder[T]):
224
return build_mla_chunked_context_metadata(
225
context_lens_cpu=context_lens_cpu,
226
prefill_query_start_loc_cpu=prefill_query_start_loc_cpu,
231
- num_prefills=num_prefills,
227
chunked_prefill_workspace=self.chunked_prefill_workspace,
228
chunked_prefill_workspace_size=self.chunked_prefill_workspace_size,
229
block_size=self.kv_cache_spec.block_size,
@@ -542,14 +537,15 @@ class SparseMLACommonImpl(MLACommonBaseImpl[T], Generic[T]):
537
workspace = prefill.topk_mask_workspace
538
if workspace is None or prefill.query_lens_cpu is None:
539
return False
540
+ max_context_chunk_seq_len = 0
541
+ if prefill.chunked_context is not None:
542
+ max_context_chunk_seq_len = max(
543
+ chunk.max_seq_len for chunk in prefill.chunked_context.chunks
544
+ )
545
fits = _masked_mha_workspace_fits(
546
batch_size=len(prefill.query_lens_cpu),
547
max_query_len=prefill.max_query_len,
548
- context_chunk_max_seq_lens=(
549
- None
550
- if prefill.chunked_context is None
551
- else prefill.chunked_context.max_seq_lens
552
- ),
548
+ max_context_chunk_seq_len=max_context_chunk_seq_len,
549
workspace_numel=workspace.numel(),
550
)
551
if not fits:
@@ -716,69 +712,70 @@ class SparseMLACommonImpl(MLACommonBaseImpl[T], Generic[T]):
712
713
chunked_context = prefill_metadata.chunked_context
714
assert chunked_context is not None
719
- use_global_mask = dense_mask is not None
715
output: torch.Tensor | None = None
716
output_lse: torch.Tensor | None = None
717
workspace = chunked_context.workspace
718
724
- for i, toks in enumerate(chunked_context.seq_tot):
725
- if toks == 0:
726
- continue
719
+ for chunk in chunked_context.chunks:
720
+ toks = chunk.num_context_tokens
721
+ requests = chunk.request_slice
722
ops.gather_and_maybe_dequant_cache(
723
src_cache=kv_c_and_k_pe_cache,
724
dst=workspace,
730
- block_table=prefill_metadata.block_table,
731
- cu_seq_lens=chunked_context.cu_seq_lens[i],
732
- token_to_seq=chunked_context.token_to_seq[i],
733
- num_tokens=chunked_context.chunk_total_token[i],
725
+ block_table=prefill_metadata.block_table[requests],
726
+ cu_seq_lens=chunk.cu_seq_lens,
727
+ token_to_seq=chunk.token_to_seq,
728
+ num_tokens=toks,
729
kv_cache_dtype=self.kv_cache_dtype,
730
scale=k_scale,
736
- seq_starts=chunked_context.starts[i],
731
+ seq_starts=chunk.starts,
732
)
733
734
chunk_kv_c = workspace[:toks, : self.kv_lora_rank]
735
chunk_k_pe = workspace[:toks, self.kv_lora_rank :].unsqueeze(1)
736
k, v = self._project_kv(chunk_kv_c, chunk_k_pe)
742
- chunk_lens = chunked_context.seq_lens[i].tolist()
743
- chunk_topk = (
744
- topk_per_req
745
- if use_global_mask
746
- else self._remap_topk_to_ranges(
747
- topk_per_req,
748
- chunked_context.starts[i],
749
- chunk_lens,
737
+ if dense_mask is not None:
738
+ chunk_mask: torch.Tensor | None = dense_mask[requests]
739
+ chunk_topk = topk_per_req[requests]
740
+ key_starts: torch.Tensor | None = chunk.starts
741
+ else:
742
+ chunk_mask = None
743
+ chunk_topk = self._remap_topk_to_ranges(
744
+ topk_per_req[requests],
745
+ chunk.starts,
746
+ chunk.seq_lens.tolist(),
747
)
751
- )
748
+ key_starts = None
749
attn_out, lse = self._run_masked_mha(
753
- q=q,
750
+ q=q[chunk.token_slice],
751
k=k,
752
v=v,
756
- cu_seqlens_q=prefill_metadata.query_start_loc,
757
- cu_seqlens_k=chunked_context.cu_seq_lens[i],
758
- max_seqlen_q=prefill_metadata.max_query_len,
759
- max_seqlen_k=chunked_context.max_seq_lens[i],
753
+ cu_seqlens_q=chunk.query_start_loc,
754
+ cu_seqlens_k=chunk.cu_seq_lens,
755
+ max_seqlen_q=chunk.max_query_len,
756
+ max_seqlen_k=chunk.max_seq_len,
757
topk_per_req=chunk_topk,
761
- q_lens=q_lens,
758
+ q_lens=q_lens[requests],
759
causal=False,
760
return_softmax_lse=True,
764
- dense_mask=dense_mask,
765
- key_starts=(chunked_context.starts[i] if use_global_mask else None),
761
+ dense_mask=chunk_mask,
762
+ key_starts=key_starts,
763
topk_mask_workspace=prefill_metadata.topk_mask_workspace,
764
)
765
766
if output is None:
770
- output = attn_out
771
- output_lse = lse
772
- else:
773
- assert output_lse is not None
774
- merge_attn_states(
775
- output=output,
776
- output_lse=output_lse,
777
- prefix_output=output,
778
- prefix_lse=output_lse,
779
- suffix_output=attn_out,
780
- suffix_lse=lse,
767
+ if (
768
+ len(chunked_context.chunks) == 1
769
+ and not chunked_context.empty_token_slices
770
+ ):
771
+ return attn_out, lse
772
+ output, output_lse = init_mla_context_partial(
773
+ chunked_context,
774
+ attn_out,
775
+ lse,
776
+ num_tokens=q.shape[0],
777
)
778
+ accumulate_mla_context_chunk(chunk, attn_out, lse, output, output_lse)
779
780
assert output is not None and output_lse is not None
781
return output, output_lse
@@ -844,7 +841,7 @@ class SparseMLACommonImpl(MLACommonBaseImpl[T], Generic[T]):
841
output.copy_(attn_out[..., : self.v_head_dim].flatten(start_dim=-2))
842
return
843
847
- context_lens = chunked_context.seq_lens.sum(dim=0).tolist()
844
+ context_lens = chunked_context.context_lens_list
845
dense_mask = self._try_build_global_mask(
846
topk_per_req,
847
q_lens,
@@ -889,5 +886,4 @@ class SparseMLACommonImpl(MLACommonBaseImpl[T], Generic[T]):
886
prefix_lse=context_lse,
887
suffix_output=suffix_output[..., : self.v_head_dim],
888
suffix_lse=suffix_lse,
892
- prefill_tokens_with_context=chunked_context.prefill_tokens_with_context,
889
)
vllm/models/kimi_k3/nvidia/mla.py
-1
@@ -763,7 +763,6 @@ class MultiHeadLatentAttention(nn.Module, AttentionLayerBase):
763
prefix_lse=context_lse,
764
suffix_output=suffix_output[..., : self.v_head_dim],
765
suffix_lse=suffix_lse,
766
- prefill_tokens_with_context=prefill.chunked_context.prefill_tokens_with_context,
766
)
767
elif not writes_out:
768
out.copy_(output_prefill[..., : self.v_head_dim].flatten(start_dim=-2))
vllm/v1/attention/backends/mla/prefill/aiter_flash_attn.py
+8
-7
@@ -17,6 +17,9 @@ from vllm.v1.attention.backends.mla.prefill.base import MLAPrefillBackend
17
18
if TYPE_CHECKING:
19
from vllm.config import VllmConfig
20
+ from vllm.model_executor.layers.attention.mla_attention import (
21
+ MLACommonPrefillMetadata,
22
+ )
23
from vllm.platforms.interface import DeviceCapability
24
25
@@ -99,21 +102,19 @@ class AiterFlashAttnPrefillBackend(MLAPrefillBackend):
102
103
def run_prefill_context_chunk(
104
self,
102
- chunk_idx: int,
105
+ chunk: "MLACommonPrefillMetadata.ContextChunk",
106
q: torch.Tensor,
107
k: torch.Tensor,
108
v: torch.Tensor,
109
) -> tuple[torch.Tensor, torch.Tensor]:
107
- assert self._prefill_metadata.chunked_context is not None
108
- chunked = self._prefill_metadata.chunked_context
110
out, lse = self.flash_attn_varlen_func(
111
q=q,
112
k=k,
113
v=v,
113
- cu_seqlens_q=self._prefill_metadata.query_start_loc,
114
- cu_seqlens_k=chunked.cu_seq_lens[chunk_idx],
115
- max_seqlen_q=self._prefill_metadata.max_query_len,
116
- max_seqlen_k=chunked.max_seq_lens[chunk_idx],
114
+ cu_seqlens_q=chunk.query_start_loc,
115
+ cu_seqlens_k=chunk.cu_seq_lens,
116
+ max_seqlen_q=chunk.max_query_len,
117
+ max_seqlen_k=chunk.max_seq_len,
118
softmax_scale=self.scale,
119
causal=False,
120
return_lse=True,
vllm/v1/attention/backends/mla/prefill/base.py
+1
-1
@@ -175,7 +175,7 @@ class MLAPrefillBackend(ABC):
175
@abstractmethod
176
def run_prefill_context_chunk(
177
self,
178
- chunk_idx: int,
178
+ chunk: "MLACommonPrefillMetadata.ContextChunk",
179
q: torch.Tensor,
180
k: torch.Tensor,
181
v: torch.Tensor,
vllm/v1/attention/backends/mla/prefill/flash_attn.py
+9
-7
@@ -27,7 +27,10 @@ from vllm.v1.attention.backends.mla.prefill.base import (
27
28
if TYPE_CHECKING:
29
from vllm.config import VllmConfig
30
- from vllm.model_executor.layers.attention.mla_attention import MLADims
30
+ from vllm.model_executor.layers.attention.mla_attention import (
31
+ MLACommonPrefillMetadata,
32
+ MLADims,
33
+ )
34
from vllm.model_executor.layers.quantization.utils.quant_utils import QuantKey
35
36
if is_flash_attn_varlen_func_available():
@@ -450,20 +453,19 @@ class FlashAttnPrefillBackend(MLAPrefillBackend):
453
454
def run_prefill_context_chunk(
455
self,
453
- chunk_idx: int,
456
+ chunk: "MLACommonPrefillMetadata.ContextChunk",
457
q: torch.Tensor,
458
k: torch.Tensor,
459
v: torch.Tensor,
460
) -> tuple[torch.Tensor, torch.Tensor]:
458
- assert self._prefill_metadata.chunked_context is not None
461
return self._flash_attn_varlen_diff_headdims(
462
q=q,
463
k=k,
464
v=v,
463
- cu_seqlens_q=self._prefill_metadata.query_start_loc,
464
- cu_seqlens_k=self._prefill_metadata.chunked_context.cu_seq_lens[chunk_idx],
465
- max_seqlen_q=self._prefill_metadata.max_query_len,
466
- max_seqlen_k=self._prefill_metadata.chunked_context.max_seq_lens[chunk_idx],
465
+ cu_seqlens_q=chunk.query_start_loc,
466
+ cu_seqlens_k=chunk.cu_seq_lens,
467
+ max_seqlen_q=chunk.max_query_len,
468
+ max_seqlen_k=chunk.max_seq_len,
469
softmax_scale=self.scale,
470
causal=False, # Context is unmasked
471
return_softmax_lse=True,
vllm/v1/attention/backends/mla/prefill/flashinfer.py
+7
-10
@@ -150,8 +150,7 @@ class FlashInferPrefillBackend(MLAPrefillBackend):
150
if has_context:
151
chunked_context = prefill_metadata.chunked_context
152
assert chunked_context is not None
153
- num_chunks = chunked_context.cu_seq_lens.shape[0]
154
- self._ensure_chunks(num_chunks, self._workspace_buffer)
153
+ self._ensure_chunks(len(chunked_context.chunks), self._workspace_buffer)
154
155
num_qo_heads = self.num_heads
156
num_kv_heads = num_qo_heads
@@ -179,12 +178,10 @@ class FlashInferPrefillBackend(MLAPrefillBackend):
178
if has_context:
179
chunked_context = prefill_metadata.chunked_context
180
assert chunked_context is not None
182
- for i in range(num_chunks):
183
- kv_indptr_chunk = chunked_context.cu_seq_lens[i]
184
-
185
- self._prefill_chunks[i].plan(
186
- qo_indptr=qo_indptr,
187
- kv_indptr=kv_indptr_chunk,
181
+ for chunk in chunked_context.chunks:
182
+ self._prefill_chunks[chunk.index].plan(
183
+ qo_indptr=chunk.query_start_loc,
184
+ kv_indptr=chunk.cu_seq_lens,
185
num_qo_heads=num_qo_heads,
186
num_kv_heads=num_kv_heads,
187
head_dim_qk=head_dim_qk,
@@ -227,12 +224,12 @@ class FlashInferPrefillBackend(MLAPrefillBackend):
224
225
def run_prefill_context_chunk(
226
self,
230
- chunk_idx: int,
227
+ chunk: "MLACommonPrefillMetadata.ContextChunk",
228
q: torch.Tensor,
229
k: torch.Tensor,
230
v: torch.Tensor,
231
) -> tuple[torch.Tensor, torch.Tensor]:
235
- attn_out, lse = self._prefill_chunks[chunk_idx].run(
232
+ attn_out, lse = self._prefill_chunks[chunk.index].run(
233
q=q,
234
k=k,
235
v=v,
vllm/v1/attention/backends/mla/prefill/tokenspeed_mla.py
+7
-10
@@ -162,16 +162,13 @@ class TokenspeedMLAPrefillBackend(MLAPrefillBackend):
162
163
def run_prefill_context_chunk(
164
self,
165
- chunk_idx: int,
165
+ chunk: "MLACommonPrefillMetadata.ContextChunk",
166
q: torch.Tensor,
167
k: torch.Tensor,
168
v: torch.Tensor,
169
) -> tuple[torch.Tensor, torch.Tensor]:
170
from tokenspeed_mla import tokenspeed_mla_prefill
171
172
- assert self._prefill_metadata.chunked_context is not None
173
- chunked = self._prefill_metadata.chunked_context
174
-
172
# See note in run_prefill_new_tokens — `v` is a split-view of `kv_nope`
173
# in `_compute_prefill_context` and arrives non-contiguous.
174
v = v.contiguous()
@@ -180,15 +177,15 @@ class TokenspeedMLAPrefillBackend(MLAPrefillBackend):
177
query=q,
178
key=k,
179
value=v,
183
- seq_lens=chunked.seq_lens[chunk_idx],
184
- cum_seq_lens=chunked.cu_seq_lens[chunk_idx],
185
- max_seq_len=chunked.max_seq_lens[chunk_idx],
186
- batch_size=chunked.seq_lens[chunk_idx].shape[0],
180
+ seq_lens=chunk.seq_lens,
181
+ cum_seq_lens=chunk.cu_seq_lens,
182
+ max_seq_len=chunk.max_seq_len,
183
+ batch_size=chunk.num_requests,
184
softmax_scale=self.scale,
185
is_causal=False,
186
return_lse=True,
190
- cum_seq_lens_q=self._prefill_metadata.query_start_loc,
191
- max_seq_len_q=self._prefill_metadata.max_query_len,
187
+ cum_seq_lens_q=chunk.query_start_loc,
188
+ max_seq_len_q=chunk.max_query_len,
189
enable_pdl=False,
190
)
191
vllm/v1/attention/backends/mla/prefill/trtllm_ragged.py
+7
-14
@@ -143,16 +143,13 @@ class TrtllmRaggedPrefillBackend(MLAPrefillBackend):
143
144
def run_prefill_context_chunk(
145
self,
146
- chunk_idx: int,
146
+ chunk: "MLACommonPrefillMetadata.ContextChunk",
147
q: torch.Tensor,
148
k: torch.Tensor,
149
v: torch.Tensor,
150
) -> tuple[torch.Tensor, torch.Tensor]:
151
from flashinfer.prefill import trtllm_ragged_attention_deepseek
152
153
- assert self._prefill_metadata.chunked_context is not None
154
- assert self._prefill_metadata.chunked_context.seq_lens[chunk_idx] is not None
155
-
153
out = torch.empty(
154
q.shape[0],
155
q.shape[1],
@@ -166,20 +163,16 @@ class TrtllmRaggedPrefillBackend(MLAPrefillBackend):
163
key=k,
164
value=v,
165
workspace_buffer=self._workspace_buffer,
169
- seq_lens=self._prefill_metadata.chunked_context.seq_lens[chunk_idx],
170
- max_q_len=self._prefill_metadata.max_query_len,
171
- max_kv_len=self._prefill_metadata.chunked_context.max_seq_lens[chunk_idx],
166
+ seq_lens=chunk.seq_lens,
167
+ max_q_len=chunk.max_query_len,
168
+ max_kv_len=chunk.max_seq_len,
169
bmm1_scale=self.scale,
170
bmm2_scale=1.0,
171
o_sf_scale=1.0,
175
- batch_size=self._prefill_metadata.chunked_context.seq_lens[chunk_idx].shape[
176
- 0
177
- ],
172
+ batch_size=chunk.num_requests,
173
window_left=-1,
179
- cum_seq_lens_q=self._prefill_metadata.query_start_loc,
180
- cum_seq_lens_kv=self._prefill_metadata.chunked_context.cu_seq_lens[
181
- chunk_idx
182
- ],
174
+ cum_seq_lens_q=chunk.query_start_loc,
175
+ cum_seq_lens_kv=chunk.cu_seq_lens,
176
enable_pdl=False,
177
is_causal=False,
178
return_lse=True,
vllm/v1/attention/ops/triton_merge_attn_states.py
+1
-114
@@ -9,118 +9,6 @@ from vllm.triton_utils import tl, triton
9
float8_info = torch.finfo(current_platform.fp8_dtype())
10
11
12
-def mask_empty_context(
13
- lse: torch.Tensor,
14
- output: torch.Tensor,
15
- query_start_loc: torch.Tensor,
16
- context_start_loc: torch.Tensor,
17
-) -> None:
18
- """Neutralize context chunks that cover no keys before merging.
19
-
20
- A prefill query whose context chunk is empty attended to no keys, so its
21
- partial attention is undefined: the backend leaves the output rows as
22
- uninitialized scratch (which may hold NaN/Inf) even when it reports an LSE
23
- of -inf. Sanitize both here so ``merge_attn_states`` can stay generic:
24
- force the LSE to -inf (zero softmax weight) and zero the undefined output
25
- rows (so a zero weight cannot combine with NaN/Inf). Emptiness is derived
26
- from the context offsets, not from the -inf LSE, so no merge kernel has to
27
- reason about undefined partials.
28
-
29
- Args:
30
- lse: Chunk log-sum-exp, shape [num_heads, num_tokens].
31
- output: Chunk attention output, shape [num_tokens, num_heads, ...].
32
- query_start_loc: Prefill query cumulative offsets, shape [num_reqs + 1].
33
- context_start_loc: Chunk context cumulative offsets,
34
- shape [num_reqs + 1]; an empty chunk has a zero-length span.
35
- """
36
- num_heads, num_tokens = lse.shape
37
- num_reqs = query_start_loc.shape[0] - 1
38
- block_size = 128
39
- # Reserve the worst-case number of request-local blocks.
40
- num_query_blocks = num_tokens // block_size + num_reqs
41
- is_empty = torch.zeros(num_tokens, dtype=torch.bool, device=lse.device)
42
- mask_empty_context_kernel[(num_query_blocks,)](
43
- lse,
44
- is_empty,
45
- query_start_loc,
46
- context_start_loc,
47
- lse.stride(0),
48
- lse.stride(1),
49
- num_reqs,
50
- NUM_HEADS=num_heads,
51
- BLOCK_SIZE=block_size,
52
- BLOCK_HEADS=8,
53
- num_warps=8,
54
- )
55
- output.masked_fill_(is_empty[:, None, None], 0.0)
56
-
57
-
58
-@triton.jit
59
-def mask_empty_context_kernel(
60
- lse,
61
- is_empty,
62
- query_start_loc,
63
- context_start_loc,
64
- lse_head_stride,
65
- lse_token_stride,
66
- num_reqs,
67
- NUM_HEADS: tl.constexpr,
68
- BLOCK_SIZE: tl.constexpr,
69
- BLOCK_HEADS: tl.constexpr,
70
-):
71
- query_block_idx = tl.program_id(0)
72
-
73
- lanes = tl.arange(0, 32)
74
- chunk_start = 0
75
- req_idx = 0
76
- req_idx_found = False
77
- while (chunk_start < num_reqs) & (not req_idx_found):
78
- req_offsets = chunk_start + lanes
79
- req_mask = req_offsets < num_reqs
80
- query_starts = tl.load(query_start_loc + req_offsets, mask=req_mask)
81
- # Assume the worst-case number of blocks for each request.
82
- req_block_starts = query_starts // BLOCK_SIZE + req_offsets
83
- matched_idx = tl.sum(
84
- (req_mask & (req_block_starts <= query_block_idx)).to(tl.int32)
85
- )
86
- # matched_idx == 32 means the match is past this warp chunk.
87
- req_idx = chunk_start + matched_idx - 1
88
- req_idx_found = matched_idx < 32
89
- chunk_start += 32
90
-
91
- query_start = tl.load(query_start_loc + req_idx)
92
- query_end = tl.load(query_start_loc + req_idx + 1)
93
- query_len = query_end - query_start
94
- req_first_block = query_start // BLOCK_SIZE + req_idx
95
- block_in_req = query_block_idx - req_first_block
96
- token_offset = block_in_req * BLOCK_SIZE
97
- if token_offset >= query_len:
98
- return
99
-
100
- context_start = tl.load(context_start_loc + req_idx)
101
- context_end = tl.load(context_start_loc + req_idx + 1)
102
- if context_start != context_end:
103
- return
104
-
105
- token_offsets = token_offset + tl.arange(0, BLOCK_SIZE)
106
- token_indices = query_start + token_offsets
107
- token_lse_offsets = token_indices * lse_token_stride
108
- valid_tokens = token_offsets < query_len
109
- tl.store(is_empty + token_indices, True, mask=valid_tokens)
110
- head_offsets = tl.arange(0, BLOCK_HEADS)
111
- for head_start in range(0, NUM_HEADS, BLOCK_HEADS):
112
- head_indices = head_start + head_offsets
113
- lse_ptrs = (
114
- lse + head_indices[:, None] * lse_head_stride + token_lse_offsets[None, :]
115
- )
116
- valid_heads = head_indices < NUM_HEADS
117
- tl.store(
118
- lse_ptrs,
119
- float("-inf"),
120
- mask=valid_heads[:, None] & valid_tokens[None, :],
121
- )
122
-
123
-
12
# Implements section 2.2 of https://www.arxiv.org/pdf/2501.01005
13
# can be used to combine partial attention results (in the split-KV case)
14
def merge_attn_states(
@@ -317,8 +205,7 @@ def merge_attn_states_kernel(
205
s_scale = s_se / out_se
206
out = p_out * p_scale + s_out * s_scale
207
# If both sides are empty (max_lse == -inf) the scales are 0/0 = NaN; emit
320
- # zeros rather than NaN. Callers with empty chunks (see mask_empty_context)
321
- # zero those inputs, so this only guards the fully-undefined corner.
208
+ # zeros rather than NaN.
209
out = tl.where(max_lse == float("-inf"), 0.0, out)
210
211
if USE_FP8: