[Bugfix][HiSparse] Fix MTP acceptance collapse under FULL graphs with a saturated GPU pool (#59309)
Signed-off-by: Lucas Wilkinson <lwilkinson@neuralmagic.com> Signed-off-by: Matthew Bonanni <mbonanni@redhat.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Matthew Bonanni <mbonanni@redhat.com>
Lucas Wilkinson committed
Oct 2, 2026 at 02:04 UTC
4056c8ac1f8a7e8fb50cf1c56ff96649fcadccc6
11 files changed
+426
-122
docs/design/hisparse.md
+4
-1
@@ -133,7 +133,10 @@ duplicate mapping.
133
Speculative decoding resolves all verification rows of a request in one pass:
134
one block resolves the union of the rows' top-k against the request's hot-cache
135
state, so rows that select the same host row share its hot row and no row
136
-evicts a hot row another row of the step still reads.
136
+evicts a hot row another row of the step still reads. Draft layers write their
137
+rows after the target forward, so their host mirror runs at the start of the
138
+next step, after the drafter, and the step's page transfers are submitted
139
+behind it.
140
141
## P/D import target
142
tests/v1/attention/test_sparse_mla_backends.py
+102
-35
@@ -1943,6 +1943,10 @@ def _make_hisparse_cache_handle(
1943
return HiSparseCacheHandle(runtime)
1944
1945
1946
+def _valid_rows(num_rows: int) -> torch.Tensor:
1947
+ return torch.tensor([num_rows], dtype=torch.int32, device=DEVICE_TYPE)
1948
+
1949
+
1950
@requires_hisparse_ops
1951
def test_hisparse_uses_graph_stable_request_state_mapping():
1952
device = torch.device(DEVICE_TYPE)
@@ -1969,6 +1973,7 @@ def test_hisparse_uses_graph_stable_request_state_mapping():
1973
block_table=torch.tensor([[1]], dtype=torch.int32, device=device),
1974
logical_topk_indices=torch.tensor([[0]], dtype=torch.int32, device=device),
1975
block_size=block_size,
1976
+ num_valid_rows=_valid_rows(1),
1977
)
1978
torch.accelerator.synchronize()
1979
@@ -2011,6 +2016,7 @@ def test_hisparse_maps_speculative_rows_through_request_state():
2016
block_table=torch.zeros((1, 1), dtype=torch.int32, device=device),
2017
logical_topk_indices=torch.zeros((4, 1), dtype=torch.int32, device=device),
2018
block_size=block_size,
2019
+ num_valid_rows=_valid_rows(4),
2020
)
2021
torch.accelerator.synchronize()
2022
@@ -2072,6 +2078,7 @@ def test_hisparse_speculative_rows_resolve_host_misses_consistently(
2078
block_table=block_table,
2079
logical_topk_indices=topk.clone(),
2080
block_size=block_size,
2081
+ num_valid_rows=_valid_rows(num_rows),
2082
)
2083
torch.accelerator.synchronize()
2084
@@ -2115,12 +2122,99 @@ def test_hisparse_speculative_rows_count_a_shared_load_once():
2122
block_table=block_table,
2123
logical_topk_indices=topk,
2124
block_size=block_size,
2125
+ num_valid_rows=_valid_rows(2),
2126
)
2127
torch.accelerator.synchronize()
2128
2129
assert runtime.index_group.swap_stats.cpu().tolist() == [top_k, top_k]
2130
2131
2132
+@requires_hisparse_ops
2133
+def test_hisparse_resolver_reads_current_request_ids():
2134
+ """The resolver must see this call's request ids, not the previous call's.
2135
+
2136
+ It runs on the copy stream after waiting only on ``logical_topk_ready``,
2137
+ which the indexer records before attention writes the ids. An MTP drafter
2138
+ alternates four rows per request with one, so stale ids resolve rows
2139
+ against other requests' KV. Stalling the compute stream exposes the race.
2140
+ """
2141
+ device = torch.device(DEVICE_TYPE)
2142
+ block_size, row_width, top_k, blocks_per_req, num_reqs = 64, 16, 64, 4, 2
2143
+ kv_pool = torch.randn(
2144
+ (num_reqs * blocks_per_req + 1, block_size, row_width), dtype=torch.float32
2145
+ ).pin_memory()
2146
+ runtime = _make_hisparse_runtime(
2147
+ top_k=top_k,
2148
+ device_buffer_size=4 * top_k,
2149
+ max_num_reqs=num_reqs,
2150
+ row_width=row_width,
2151
+ block_size=block_size,
2152
+ max_swap_rows=4 * num_reqs,
2153
+ )
2154
+ runtime.bind_source_cache(kv_pool)
2155
+ ready = torch.Event()
2156
+ runtime.index_group.logical_topk_ready = ready
2157
+ cache = HiSparseCacheHandle(runtime)
2158
+ cache.all_context_pages_resident = False
2159
+ cache.source_block_table = torch.arange(
2160
+ 1, num_reqs * blocks_per_req + 1, dtype=torch.int32, device=device
2161
+ ).view(num_reqs, blocks_per_req)
2162
+
2163
+ index_group = object.__new__(HiSparseMLAIndexGroup)
2164
+ index_group.caches = [cache]
2165
+ index_group.physical_topk_indices = torch.empty(
2166
+ (4 * num_reqs + 1, top_k), dtype=torch.int32, device=device
2167
+ )
2168
+
2169
+ # Four rows per request, then one per request plus a CUDA-graph padding
2170
+ # row, which maps to request 0 and must be skipped.
2171
+ for rows_per_req, num_padding in ((4, 0), (1, 1)):
2172
+ num_rows = num_reqs * rows_per_req
2173
+ req_ids = torch.arange(num_reqs, dtype=torch.int32, device=device)
2174
+ req_ids = torch.cat(
2175
+ [
2176
+ req_ids.repeat_interleave(rows_per_req),
2177
+ torch.zeros(num_padding, dtype=torch.int32, device=device),
2178
+ ]
2179
+ )
2180
+ topk = torch.stack(
2181
+ [
2182
+ torch.randperm(blocks_per_req * block_size, device=device)[:top_k]
2183
+ for _ in range(req_ids.numel())
2184
+ ]
2185
+ ).to(torch.int32)
2186
+ query_start_loc = torch.arange(
2187
+ 0, num_rows + 1, rows_per_req, dtype=torch.int32, device=device
2188
+ )
2189
+ attn_metadata = SimpleNamespace(
2190
+ req_id_per_token=req_ids,
2191
+ query_start_loc=torch.cat([query_start_loc, query_start_loc[-1:]]),
2192
+ block_size=block_size,
2193
+ )
2194
+ runtime.begin_forward()
2195
+ ready.record(current_stream())
2196
+ torch.cuda._sleep(200_000_000)
2197
+ hot_indices = index_group.convert_logical_to_physical_topk(
2198
+ 0, topk, attn_metadata, block_stride_rows=None, return_valid_counts=False
2199
+ )
2200
+ torch.accelerator.synchronize()
2201
+
2202
+ assert (hot_indices[num_rows:] == -1).all()
2203
+ global_ref = _triton_convert_reference_impl(
2204
+ req_ids[:num_rows],
2205
+ cache.source_block_table,
2206
+ topk[:num_rows],
2207
+ block_size,
2208
+ top_k,
2209
+ )
2210
+ gathered = runtime.hot.attention_cache.reshape(-1, row_width)[
2211
+ hot_indices[:num_rows].to(torch.long)
2212
+ ].cpu()
2213
+ torch.testing.assert_close(
2214
+ gathered, kv_pool.reshape(-1, row_width)[global_ref.cpu().to(torch.long)]
2215
+ )
2216
+
2217
+
2218
@requires_hisparse_ops
2219
def test_hisparse_union_table_scales_with_union_bound():
2220
"""Shared memory follows the per-request union bound, not the hot buffer.
@@ -2157,6 +2251,7 @@ def test_hisparse_union_table_scales_with_union_bound():
2251
block_table=block_table,
2252
logical_topk_indices=topk,
2253
block_size=block_size,
2254
+ num_valid_rows=_valid_rows(req_ids.numel()),
2255
)
2256
torch.accelerator.synchronize()
2257
@@ -2217,6 +2312,7 @@ def test_hisparse_resident_rows_bypass_hot_lru():
2312
block_table=source_table,
2313
logical_topk_indices=topk,
2314
block_size=block_size,
2315
+ num_valid_rows=_valid_rows(request_ids.numel()),
2316
return_valid_counts=True,
2317
)
2318
torch.accelerator.synchronize()
@@ -2280,6 +2376,7 @@ def test_hisparse_swap_in_preserves_rows_across_eviction():
2376
block_table=block_table,
2377
logical_topk_indices=topk.clone(),
2378
block_size=block_size,
2379
+ num_valid_rows=_valid_rows(req_ids.numel()),
2380
return_valid_counts=True,
2381
)
2382
torch.accelerator.synchronize()
@@ -2361,6 +2458,7 @@ def test_hisparse_multi_step_swaps_match_independent():
2458
req_id_per_token=req_ids,
2459
block_table=block_table,
2460
block_size=block_size,
2461
+ num_valid_rows=_valid_rows(req_ids.numel()),
2462
)
2463
producer_indices = [
2464
producer.swap_in(logical_topk_indices=topk.clone(), **kw) for topk in topks
@@ -2435,6 +2533,7 @@ def test_hisparse_multi_step_writes_request_major_output():
2533
block_table,
2534
logical[:, step],
2535
block_size=block_size,
2536
+ num_valid_rows=_valid_rows(request_ids.numel()),
2537
return_valid_counts=True,
2538
attention_indices_out=physical[:, step],
2539
valid_counts_out=valid_counts[:, step],
@@ -2619,6 +2718,7 @@ def test_hisparse_remaps_strided_hma_rows_for_attention():
2718
[[0, 1, 2, 3]], dtype=torch.int32, device=device
2719
),
2720
block_size=block_size,
2721
+ num_valid_rows=_valid_rows(1),
2722
)
2723
2724
assert runtime.hot.attention_cache is not None
@@ -2752,6 +2852,7 @@ def test_hisparse_newest_write_and_recycled_slot_invalidation():
2852
block_table=block_table,
2853
logical_topk_indices=topk,
2854
block_size=block_size,
2855
+ num_valid_rows=_valid_rows(req_ids.numel()),
2856
)
2857
torch.accelerator.synchronize()
2858
stale_hot_slot = hot_indices[0, 0].item()
@@ -2767,6 +2868,7 @@ def test_hisparse_newest_write_and_recycled_slot_invalidation():
2868
block_table=block_table,
2869
logical_topk_indices=topk,
2870
block_size=block_size,
2871
+ num_valid_rows=_valid_rows(req_ids.numel()),
2872
)
2873
torch.accelerator.synchronize()
2874
idx = hot_indices.cpu().tolist()[0][0]
@@ -3200,8 +3302,6 @@ def test_hisparse_fp8_decode_resolves_rows_once_then_runs_batched_attention():
3302
index_group.physical_topk_indices = torch.empty(
3303
(num_tokens + 1, 4), dtype=torch.int32, device=device
3304
)
3203
- index_group.row_indices = torch.arange(num_tokens, dtype=torch.int32, device=device)
3204
- index_group.request_ids = torch.empty_like(index_group.row_indices)
3305
impl = SimpleNamespace(
3306
kv_lora_rank=1,
3307
index_group=index_group,
@@ -3255,39 +3355,6 @@ def test_hisparse_keeps_speculative_rows_as_decodes():
3355
assert builder.reorder_batch_threshold == 4
3356
3357
3258
-def test_hisparse_decode_skips_padding_rows():
3259
- """Padding rows are masked, and the ids stay in the group's persistent buffer.
3260
-
3261
- The resolver reads them on the HiSparse copy stream, where a compute-stream
3262
- temporary can already be recycled (CUDA graphs do), scrambling rows' requests.
3263
- """
3264
- topk = torch.arange(20, dtype=torch.int32).view(10, 2)
3265
- cache = SimpleNamespace(
3266
- source_block_table=torch.zeros((2, 1), dtype=torch.int32),
3267
- swap_in=MagicMock(return_value=topk),
3268
- )
3269
- index_group = object.__new__(HiSparseMLAIndexGroup)
3270
- index_group.caches = [cache]
3271
- index_group.physical_topk_indices = torch.empty((11, 2), dtype=torch.int32)
3272
- index_group.row_indices = torch.arange(10, dtype=torch.int32)
3273
- index_group.request_ids = torch.empty_like(index_group.row_indices)
3274
- metadata = SimpleNamespace(
3275
- query_start_loc=torch.tensor([0, 8, 9], dtype=torch.int32),
3276
- req_id_per_token=torch.tensor([0] * 8 + [1, 0], dtype=torch.int32),
3277
- block_size=64,
3278
- )
3279
-
3280
- index_group.convert_logical_to_physical_topk(
3281
- 0, topk, metadata, block_stride_rows=None, return_valid_counts=False
3282
- )
3283
-
3284
- request_ids = cache.swap_in.call_args.args[0]
3285
- assert request_ids.data_ptr() == index_group.request_ids.data_ptr()
3286
- torch.testing.assert_close(
3287
- request_ids, torch.tensor([0] * 8 + [1, -1], dtype=torch.int32)
3288
- )
3289
-
3290
-
3358
def test_flashinfer_hisparse_decode_runs_batched_attention():
3359
device = torch.device("cpu")
3360
num_tokens = 6
tests/v1/e2e/general/test_hisparse.py
+50
-27
@@ -95,6 +95,8 @@ def test_hisparse_spill_and_prefix_restore(
95
96
A regression omitted attention metadata before FULL graph replay, so decode
97
completed normally while its newly written KV rows were never copied to host.
98
+ MTP layers write after the target forward; their rows used to be mirrored
99
+ before the drafter wrote them, so the drafter later read stale host rows.
100
"""
101
capability = current_platform.get_device_capability()
102
if capability is None or capability.major < 9:
@@ -171,49 +173,70 @@ def test_hisparse_spill_and_prefix_restore(
173
model_runner.cudagraph_manager, "run_fullgraph", record_fullgraph
174
)
175
176
+ # The MTP layer writes its rows after the target forward, so it is
177
+ # mirrored at the start of the next step instead of in finish_forward.
178
+ draft_layers = set(worker._draft_layers)
179
+ assert draft_layers
180
original_finish_forward = worker.finish_forward
175
- verified_full_replay_rows = 0
181
+ original_finish_previous_step = worker._finish_previous_step
182
+ verified_rows = {"target": 0, "draft": 0}
183
copied_nonzero_kv = False
184
+ draft_check_pending = False
185
178
- def finish_forward():
179
- nonlocal full_replay_pending, verified_full_replay_rows
186
+ def check_host_rows(layer_indices: set[int], kind: str) -> None:
187
nonlocal copied_nonzero_kv
188
+ # Synchronize only the test's inspection, not the worker itself.
189
+ with gpu_sync_allowed():
190
+ torch.accelerator.synchronize()
191
+ assert worker._row_mirrors
192
+ for layer_index in layer_indices:
193
+ cache = worker.cache_handles[layer_index]
194
+ source_index = cache.runtime.resident_source_index
195
+ for mirror in worker._row_mirrors:
196
+ source_row = mirror.source_starts[source_index]
197
+ destination_row = mirror.destination_start
198
+ num_rows = mirror.num_rows
199
+ source_block, source_offset = divmod(
200
+ source_row, worker.kernel_block_size
201
+ )
202
+ with gpu_sync_allowed():
203
+ gpu_rows = worker.resident_caches[layer_index][
204
+ source_block, source_offset : source_offset + num_rows
205
+ ].cpu()
206
+ host_rows = worker.host_caches[layer_index][
207
+ destination_row : destination_row + num_rows
208
+ ]
209
+ assert torch.equal(host_rows, gpu_rows)
210
+ copied_nonzero_kv |= bool(torch.count_nonzero(gpu_rows).item())
211
+ verified_rows[kind] += num_rows
212
+
213
+ def finish_forward():
214
+ nonlocal full_replay_pending, draft_check_pending
215
try:
216
original_finish_forward()
217
if not full_replay_pending:
218
return
185
- # Synchronize only the test's inspection, not finish_forward itself.
186
- with gpu_sync_allowed():
187
- torch.accelerator.synchronize()
188
- assert worker._row_mirrors
189
- for layer_index, cache in enumerate(worker.cache_handles):
190
- source_index = cache.runtime.resident_source_index
191
- for mirror in worker._row_mirrors:
192
- source_row = mirror.source_starts[source_index]
193
- destination_row = mirror.destination_start
194
- num_rows = mirror.num_rows
195
- source_block, source_offset = divmod(
196
- source_row, worker.kernel_block_size
197
- )
198
- with gpu_sync_allowed():
199
- gpu_rows = worker.resident_caches[layer_index][
200
- source_block, source_offset : source_offset + num_rows
201
- ].cpu()
202
- host_rows = worker.host_caches[layer_index][
203
- destination_row : destination_row + num_rows
204
- ]
205
- assert torch.equal(host_rows, gpu_rows)
206
- copied_nonzero_kv |= bool(torch.count_nonzero(gpu_rows).item())
207
- verified_full_replay_rows += num_rows
219
+ target_layers = set(range(len(worker.cache_handles))) - draft_layers
220
+ check_host_rows(target_layers, "target")
221
+ draft_check_pending = True
222
finally:
223
full_replay_pending = False
224
225
+ def finish_previous_step():
226
+ nonlocal draft_check_pending
227
+ original_finish_previous_step()
228
+ if draft_check_pending:
229
+ draft_check_pending = False
230
+ check_host_rows(draft_layers, "draft")
231
+
232
monkeypatch.setattr(worker, "finish_forward", finish_forward)
233
+ monkeypatch.setattr(worker, "_finish_previous_step", finish_previous_step)
234
235
expected = runner.generate_greedy([target], max_tokens=8)
236
237
assert full_graph_calls > 0
216
- assert verified_full_replay_rows > 0
238
+ assert verified_rows["target"] > 0
239
+ assert verified_rows["draft"] > 0
240
assert copied_nonzero_kv
241
242
runner.generate_greedy(pressure, max_tokens=8)
tests/v1/kv_connector/unit/test_hisparse_connector.py
+202
@@ -1,15 +1,21 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+import contextlib
4
+import ctypes
5
from types import SimpleNamespace
6
from unittest.mock import MagicMock
7
8
import pytest
9
import torch
10
11
+from vllm.config.compilation import CUDAGraphMode
12
from vllm.distributed.kv_transfer.kv_connector.v1.base import KVConnectorRole
13
from vllm.distributed.kv_transfer.kv_connector.v1.example_connector import (
14
ExampleConnector,
15
)
16
+from vllm.distributed.kv_transfer.kv_connector.v1.hisparse import (
17
+ worker as worker_module,
18
+)
19
from vllm.distributed.kv_transfer.kv_connector.v1.hisparse.connector import (
20
HiSparseConnector,
21
HiSparseConnectorMetadata,
@@ -19,8 +25,14 @@ from vllm.distributed.kv_transfer.kv_connector.v1.hisparse.worker import (
25
HiSparseConnectorWorker,
26
)
27
from vllm.distributed.kv_transfer.kv_connector.v1.multi_connector import MultiConnector
28
+from vllm.v1.hisparse import runtime as runtime_module
29
from vllm.v1.hisparse.coordinator import get_hisparse_coordinator
30
from vllm.v1.hisparse.runtime import HiSparseCacheHandle
31
+from vllm.v1.hisparse.types import (
32
+ SparseKVOffloadCommand,
33
+ SparseKVPageTransfer,
34
+ SparseKVRowMirror,
35
+)
36
from vllm.v1.worker.gpu.kv_connector import ActiveKVConnector
37
38
@@ -83,6 +95,7 @@ def test_full_graph_step_prepares_host_mirror_outside_model():
95
worker._group_leaders = (("layer", handle),)
96
worker._per_layer_mirrored = set()
97
worker._submitted_mirror_layers = set()
98
+ worker._draft_layers = ()
99
worker.is_host_writer = True
100
worker._enqueue_row_dma = MagicMock()
101
worker.start_step = MagicMock(
@@ -116,6 +129,195 @@ def test_full_graph_step_prepares_host_mirror_outside_model():
129
runtime.invalidate_written_slots.assert_called_once()
130
131
132
+class _FakeEvent:
133
+ log: list[tuple[str, object]] = []
134
+
135
+ def __init__(self, *args, **kwargs) -> None:
136
+ pass
137
+
138
+ def record(self, stream=None) -> None:
139
+ self.log.append(("event", self))
140
+
141
+ def query(self) -> bool:
142
+ return True
143
+
144
+ def synchronize(self) -> None:
145
+ pass
146
+
147
+
148
+@pytest.mark.parametrize("cg_mode", [CUDAGraphMode.FULL, CUDAGraphMode.NONE])
149
+def test_draft_layer_rows_mirrored_after_drafter(monkeypatch, cg_mode):
150
+ """Draft-layer host rows must hold the drafter's writes before page release.
151
+
152
+ The drafter writes its layers' rows after the target forward. Mirroring
153
+ them at the end of the target forward (the only mirror a FULL graph gets)
154
+ leaves pre-draft rows on the host, and the page is still reported clean.
155
+ """
156
+ block_size, width = 4, 8
157
+ resident_block, host_block, transfer_id = 2, 1, 7
158
+ log: list[tuple[str, object]] = []
159
+ _FakeEvent.log = log
160
+
161
+ def swap_blocks_batch(src, dst, sizes):
162
+ for src_ptr, dst_ptr, size in zip(src.tolist(), dst.tolist(), sizes.tolist()):
163
+ ctypes.memmove(dst_ptr, src_ptr, size)
164
+ log.append(("dma", dst_ptr))
165
+
166
+ monkeypatch.setattr(torch, "Event", _FakeEvent)
167
+ monkeypatch.setattr(torch, "Stream", lambda *args, **kwargs: MagicMock())
168
+ monkeypatch.setattr(torch.cuda, "Stream", lambda *args, **kwargs: MagicMock())
169
+ monkeypatch.setattr(torch.cuda, "stream", lambda _: contextlib.nullcontext())
170
+ compute_stream = MagicMock()
171
+ monkeypatch.setattr(worker_module, "current_stream", lambda: compute_stream)
172
+ monkeypatch.setattr(worker_module.ops, "swap_blocks_batch", swap_blocks_batch)
173
+ monkeypatch.setattr(
174
+ runtime_module,
175
+ "get_forward_context",
176
+ lambda: SimpleNamespace(cudagraph_runtime_mode=cg_mode),
177
+ )
178
+
179
+ slots = torch.arange(block_size) + resident_block * block_size
180
+ request_state_indices = torch.full((1,), -1, dtype=torch.int32)
181
+ handles = []
182
+ for draft_layer in (False, True):
183
+ runtime = SimpleNamespace(
184
+ is_group_leader=True,
185
+ eager_host_mirror=True,
186
+ resident_source_index=0,
187
+ host_cache=torch.zeros(4 * block_size, width),
188
+ request_state_indices=request_state_indices,
189
+ begin_forward=MagicMock(),
190
+ invalidate_written_slots=MagicMock(),
191
+ )
192
+ handle = HiSparseCacheHandle(runtime)
193
+ handle.view = SimpleNamespace(
194
+ block_size=block_size, cache=torch.zeros(4, block_size, width)
195
+ )
196
+ handle.slot_mapping = slots
197
+ handle.mirror_slot_mapping = slots
198
+ handle.draft_layer = draft_layer
199
+ handles.append(handle)
200
+ target, draft = handles
201
+ # Rows the draft layer held before this step's drafter ran.
202
+ draft.view.cache[resident_block] = -1.0
203
+
204
+ worker = HiSparseConnectorWorker(
205
+ SimpleNamespace(
206
+ scheduler_config=SimpleNamespace(max_num_batched_tokens=16),
207
+ num_lookahead_tokens=3,
208
+ ),
209
+ MagicMock(),
210
+ )
211
+ worker.initialize(
212
+ handles,
213
+ ["target", "draft"],
214
+ MagicMock(),
215
+ max_num_reqs=1,
216
+ host_num_blocks=4,
217
+ device=torch.device("cpu"),
218
+ pinned_host_pools=[],
219
+ )
220
+ connector = object.__new__(HiSparseConnector)
221
+ connector.connector_worker = worker
222
+ connector._get_connector_metadata = MagicMock(
223
+ return_value=HiSparseConnectorMetadata(
224
+ SparseKVOffloadCommand(
225
+ [
226
+ SparseKVPageTransfer(
227
+ transfer_id=transfer_id,
228
+ host_block_id=host_block,
229
+ resident_block_ids=(resident_block,),
230
+ after_forward=True,
231
+ )
232
+ ]
233
+ ),
234
+ (),
235
+ (),
236
+ {
237
+ "req": (
238
+ SparseKVRowMirror(
239
+ source_starts=(resident_block * block_size,),
240
+ destination_start=host_block * block_size,
241
+ num_rows=block_size,
242
+ ),
243
+ )
244
+ },
245
+ True,
246
+ )
247
+ )
248
+ # A verification step: four query tokens of one request fill the page.
249
+ attn_metadata = SimpleNamespace(
250
+ num_actual_tokens=block_size,
251
+ num_decode_tokens=0,
252
+ num_reqs=1,
253
+ max_query_len=block_size,
254
+ req_id_per_token=torch.zeros(block_size, dtype=torch.int32),
255
+ )
256
+
257
+ connector.start_load_kv(
258
+ SimpleNamespace(),
259
+ request_state_indices=None,
260
+ request_ids=["req"],
261
+ num_tokens=block_size,
262
+ attn_metadata={"target": attn_metadata, "draft": attn_metadata},
263
+ )
264
+ target.view.cache[resident_block] = 1.0
265
+ if cg_mode != CUDAGraphMode.FULL:
266
+ target.finish_kv_update()
267
+ connector.finish_forward()
268
+ enqueued_before_draft = list(worker._enqueued_transfer_ids)
269
+
270
+ # The drafter writes the draft layer's rows for the same positions.
271
+ if cg_mode != CUDAGraphMode.FULL:
272
+ draft.prepare_group_for_batch(attn_metadata)
273
+ draft.view.cache[resident_block] = 2.0
274
+ if cg_mode != CUDAGraphMode.FULL:
275
+ draft.finish_kv_update()
276
+ # The next step's start mirrors the drafter's rows, then hands the page over.
277
+ compute_stream.wait_event.reset_mock()
278
+ connector._get_connector_metadata.return_value = HiSparseConnectorMetadata(
279
+ None, (), (), {}, True
280
+ )
281
+ connector.start_load_kv(
282
+ SimpleNamespace(),
283
+ request_state_indices=None,
284
+ request_ids=[],
285
+ num_tokens=0,
286
+ attn_metadata={},
287
+ )
288
+ ((completion_event, _),) = worker._pending_transfer_events
289
+ worker_meta = connector.build_connector_worker_meta()
290
+
291
+ host_rows = slice(host_block * block_size, (host_block + 1) * block_size)
292
+ torch.testing.assert_close(target.runtime.host_cache[host_rows], torch.ones(4, 8))
293
+ torch.testing.assert_close(
294
+ draft.runtime.host_cache[host_rows], torch.full((4, 8), 2.0)
295
+ )
296
+ draft_host = draft.runtime.host_cache
297
+ draft_host_range = range(
298
+ draft_host.data_ptr(),
299
+ draft_host.data_ptr() + draft_host.numel() * draft_host.element_size(),
300
+ )
301
+ draft_copies = [
302
+ index
303
+ for index, (kind, ptr) in enumerate(log)
304
+ if kind == "dma" and ptr in draft_host_range
305
+ ]
306
+ # One draft-layer copy, and the page is not handed back before it.
307
+ assert len(draft_copies) == 1
308
+ assert enqueued_before_draft == []
309
+ assert log.index(("event", completion_event)) > draft_copies[0]
310
+ # The next forward waits for the draft copy: the scheduler may already
311
+ # have handed the blocks it reads and writes to another request.
312
+ draft_copy_event = next(
313
+ entry[1] for entry in log[draft_copies[0] :] if entry[0] == "event"
314
+ )
315
+ assert compute_stream.wait_event.call_args_list[0].args == (draft_copy_event,)
316
+ assert worker_meta is not None
317
+ assert worker_meta.enqueued_transfer_counts == {transfer_id: 1}
318
+ assert worker_meta.completed_transfer_counts == {transfer_id: 1}
319
+
320
+
321
def test_scheduled_prefix_hit_publishes_adopted_copies():
322
"""Copies adopted after scheduling must reach the worker's block table."""
323
from tests.v1.core.test_prefix_caching import (
tests/v1/worker/test_utils.py
+2
-24
@@ -44,6 +44,8 @@ def _make_hisparse_worker() -> HiSparseConnectorWorker:
44
worker._row_mirror_num_rows = 0
45
worker._per_layer_mirrored = set()
46
worker._submitted_mirror_layers = set()
47
+ worker._draft_layers = ()
48
+ worker._draft_mirror_pending = False
49
worker._pending_dma_descriptors = deque()
50
worker._dma_free_descriptors = []
51
worker.host_write_events = (MagicMock(), MagicMock())
@@ -785,30 +787,6 @@ def test_hisparse_finish_forward_does_not_repeat_per_layer_mirrors():
787
worker._enqueue_row_dma.assert_not_called()
788
789
788
-def test_hisparse_finish_forward_submits_lazy_post_forward_transfer(monkeypatch):
789
- runtime = SimpleNamespace(eager_host_mirror=False)
790
- worker = _make_hisparse_worker()
791
- worker.is_host_writer = True
792
- worker.cache_handles = [SimpleNamespace(runtime=runtime, num_actual_tokens=0)]
793
- transfer = SparseKVPageTransfer(7, 2, (1,), after_forward=True)
794
- worker._post_forward_transfers = [transfer]
795
- worker._forward_ready_event = MagicMock()
796
- worker._enqueue_host_mirror = MagicMock()
797
- worker._submit_transfers = MagicMock()
798
- worker._dma_submitted = False
799
- worker._submitted_mirror_layers = set()
800
- worker._finish_mirror_phase = MagicMock()
801
- worker._release_completed_dma_descriptors = MagicMock()
802
- stream = MagicMock()
803
- monkeypatch.setattr(hisparse_worker_module, "current_stream", lambda: stream)
804
-
805
- worker.finish_forward()
806
-
807
- worker._finish_mirror_phase.assert_called_once_with(worker._forward_ready_event)
808
- worker._submit_transfers.assert_called_once_with([transfer])
809
- worker.host_write_event.record.assert_called_once_with(stream)
810
-
811
-
790
def test_hisparse_prefill_mirrors_source_groups_and_flushes_partial_group():
791
slots = torch.tensor([7, 8], dtype=torch.int64)
792
source_indices = [0, 0, 1, 1, 1, 1, 2]
vllm/distributed/kv_transfer/kv_connector/v1/hisparse/worker.py
+35
-8
@@ -301,6 +301,10 @@ class HiSparseConnectorWorker:
301
self._submitted_mirror_layers: set[int] = set()
302
self._layer_ready_events = tuple(torch.Event() for _ in cache_handles)
303
self._forward_ready_event = torch.Event()
304
+ self._draft_layers = tuple(
305
+ index for index, cache in enumerate(cache_handles) if cache.draft_layer
306
+ )
307
+ self._draft_mirror_pending = False
308
self._set_row_mirrors(())
309
self.cache_layer_names = cache_layer_names
310
self._group_leaders = tuple(
@@ -344,9 +348,11 @@ class HiSparseConnectorWorker:
348
self._init_dma()
349
if self.is_host_writer:
350
for layer_index, handle in enumerate(cache_handles):
347
- handle.submit_layer_mirror = partial(
348
- self._enqueue_layer_mirror, layer_index
349
- )
351
+ # Draft layers are mirrored once in the next start_step instead.
352
+ if not handle.draft_layer:
353
+ handle.submit_layer_mirror = partial(
354
+ self._enqueue_layer_mirror, layer_index
355
+ )
356
self._initialized = True
357
358
def set_request_state_indices(self, indices: torch.Tensor) -> None:
@@ -397,16 +403,17 @@ class HiSparseConnectorWorker:
403
num_tokens: int = 0,
404
) -> None:
405
self._stage_row_mirror_mapping(num_tokens)
406
+ self._finish_previous_step()
407
previous_host_write_event = self.host_write_event
408
self.host_write_event = self.host_write_events[self._next_host_write_event]
409
self._next_host_write_event ^= 1
410
current_stream().wait_event(previous_host_write_event)
411
self._release_completed_dma_descriptors()
412
+ self._dma_submitted = False
413
mirrors = _flatten_row_mirrors(metadata.row_mirrors, request_ids)
414
if self._slot_mapping_staging is not None:
415
self._slot_mapping_staging.candidates = mirrors
416
self._set_row_mirrors(mirrors)
409
- self._dma_submitted = False
417
self._clear_forward_mirror_state()
418
for handle in self.cache_handles:
419
handle.all_context_pages_resident = metadata.all_context_pages_resident
@@ -849,7 +856,12 @@ class HiSparseConnectorWorker:
856
if num_rows == 0:
857
return
858
if self.is_host_writer:
852
- expected_layers = {index for index, _ in active}
859
+ # The drafter has not written its layers' rows yet; the next
860
+ # start_step mirrors them after it runs.
861
+ self._draft_mirror_pending = bool(self._draft_layers)
862
+ expected_layers = {index for index, _ in active}.difference(
863
+ self._draft_layers
864
+ )
865
if self._per_layer_mirrored and self._per_layer_mirrored != expected_layers:
866
raise RuntimeError(
867
"HiSparse per-layer DMA did not mirror every active layer: "
@@ -884,9 +896,6 @@ class HiSparseConnectorWorker:
896
compute_stream = current_stream()
897
self._forward_ready_event.record()
898
self._finish_mirror_phase(self._forward_ready_event)
887
- transfers = self._post_forward_transfers
888
- self._post_forward_transfers = []
889
- self._submit_transfers(transfers)
899
if self.is_host_writer:
900
if self._dma_submitted:
901
compute_stream.wait_event(self.host_write_event)
@@ -895,6 +904,24 @@ class HiSparseConnectorWorker:
904
self.host_write_event.record(compute_stream)
905
self._release_completed_dma_descriptors()
906
907
+ def _finish_previous_step(self) -> None:
908
+ """Mirror the previous step's draft-layer rows, then hand its pages over.
909
+
910
+ Runs before this step's work, so after the previous step's drafter.
911
+ Post-forward transfers are ordered behind the draft mirror, so a page is
912
+ never reported clean, and its resident block never released, before
913
+ its draft-layer rows reach the host. Both land in the previous step's
914
+ host write event, which this step waits on before its forward and its
915
+ host copies, since the scheduler may already have reused the
916
+ finished requests' blocks they read and write.
917
+ """
918
+ if self._draft_mirror_pending:
919
+ self._draft_mirror_pending = False
920
+ self._enqueue_row_dma(self._draft_layers)
921
+ transfers = self._post_forward_transfers
922
+ self._post_forward_transfers = []
923
+ self._submit_transfers(transfers)
924
+
925
def take_completed_host_copies(self) -> list[int]:
926
"""Drain host copies this worker has enqueued for this step."""
927
completed = self._completed_host_copy_dst_ids
vllm/model_executor/layers/attention_layer_base.py
+2
@@ -21,6 +21,8 @@ class AttentionLayerBase(ABC):
21
22
impl: "AttentionImpl"
23
supports_dcp: bool = True
24
+ # Set by the speculator on its draft model's layers.
25
+ is_draft_layer: bool = False
26
27
def bind_kv_cache(self, kv_cache: torch.Tensor) -> None:
28
"""Bind the allocated KV cache tensor to this layer.
vllm/v1/attention/backends/mla/index_group.py
+2
-20
@@ -39,8 +39,6 @@ class SparseMLAIndexGroup:
39
logical_topk_indices: torch.Tensor
40
physical_topk_indices: torch.Tensor
41
valid_topk_counts: torch.Tensor
42
- row_indices: torch.Tensor
43
- request_ids: torch.Tensor
42
side_stream: torch.Stream
43
logical_topk_ready: torch.Event
44
physical_topk_ready: torch.Event
@@ -252,19 +250,13 @@ class HiSparseMLAIndexGroup(SparseMLAIndexGroup):
250
)
251
source_block_table = cache.source_block_table
252
assert source_block_table is not None
255
- # CUDA-graph padding rows past the batch's tokens map to request 0;
256
- # mark them -1 so residency resolution skips them.
257
- request_ids = self.request_ids[:num_tokens]
258
- request_ids.copy_(req_id_per_token)
259
- request_ids.masked_fill_(
260
- self.row_indices[:num_tokens] >= attn_metadata.query_start_loc[-1], -1
261
- )
253
return cache.swap_in(
263
- request_ids,
254
+ req_id_per_token,
255
block_table=source_block_table,
256
logical_topk_indices=logical_topk_indices,
257
block_size=attn_metadata.block_size,
258
return_valid_counts=return_valid_counts,
259
+ num_valid_rows=attn_metadata.query_start_loc[-1:],
260
)
261
262
def stage_prefill_rows(
@@ -384,16 +376,6 @@ class SparseMLAIndexGroupBuilder:
376
dtype=torch.int32,
377
device=self.logical_topk_indices.device,
378
),
387
- row_indices=torch.arange(
388
- workspace_rows,
389
- dtype=torch.int32,
390
- device=self.logical_topk_indices.device,
391
- ),
392
- request_ids=torch.empty(
393
- workspace_rows,
394
- dtype=torch.int32,
395
- device=self.logical_topk_indices.device,
396
- ),
379
side_stream=_create_side_stream(self.logical_topk_indices.device),
380
logical_topk_ready=_create_event(),
381
physical_topk_ready=_create_event(),
vllm/v1/hisparse/binding.py
+1
@@ -284,6 +284,7 @@ def bind_hisparse_kv_caches(
284
assert source_cache.untyped_storage().data_ptr() == (
285
host_pool.registered.untyped_storage().data_ptr()
286
)
287
+ cache_handle.draft_layer = forward_context[layer_name].is_draft_layer
288
cache_handle.runtime.shared_host_region = host_pool.shared_region
289
cache_handle.runtime.bind_source_cache(
290
source_cache,
vllm/v1/hisparse/runtime.py
+20
-1
@@ -662,6 +662,10 @@ class HiSparseIndexGroup:
662
self.lru_init = torch.arange(region_stride, dtype=torch.int16, device=device)
663
self.lru_slots = self.lru_init.repeat(max_num_reqs, 1).contiguous()
664
self.shared_topk = _create_shared_topk_state(device, max_swap_rows, top_k)
665
+ # Request ids the resolver reads, built on the copy stream: a
666
+ # compute-stream write would race with its logical_topk_ready wait.
667
+ self.row_indices = torch.arange(max_swap_rows, dtype=torch.int32, device=device)
668
+ self.request_ids = torch.empty_like(self.row_indices)
669
self.copy_stream = (
670
copy_stream if copy_stream is not None else _create_copy_stream(device)
671
)
@@ -997,6 +1001,7 @@ class HiSparseRuntime:
1001
shared_rows: slice,
1002
attention_indices_out: torch.Tensor | None,
1003
valid_counts_out: torch.Tensor | None,
1004
+ num_valid_rows: torch.Tensor,
1005
) -> None:
1006
group = self.index_group
1007
compute_stream = current_stream()
@@ -1005,9 +1010,17 @@ class HiSparseRuntime:
1010
else:
1011
group.copy_stream.wait_stream(compute_stream)
1012
with group.copy_stream:
1013
+ # CUDA-graph padding rows past the batch's tokens map to request 0;
1014
+ # mark them -1 so residency resolution skips them.
1015
+ num_tokens = logical_topk_indices.shape[0]
1016
+ request_ids = group.request_ids[:num_tokens]
1017
+ request_ids.copy_(req_id_per_token)
1018
+ request_ids.masked_fill_(
1019
+ group.row_indices[:num_tokens] >= num_valid_rows, -1
1020
+ )
1021
self._resolve_residency(
1022
resident=resident,
1010
- req_id_per_token=req_id_per_token,
1023
+ req_id_per_token=request_ids,
1024
block_table=block_table,
1025
topk_indices=logical_topk_indices,
1026
block_size=block_size,
@@ -1051,6 +1064,7 @@ class HiSparseRuntime:
1064
block_table: torch.Tensor,
1065
logical_topk_indices: torch.Tensor,
1066
block_size: int,
1067
+ num_valid_rows: torch.Tensor,
1068
return_valid_counts: bool = False,
1069
attention_indices_out: torch.Tensor | None = None,
1070
valid_counts_out: torch.Tensor | None = None,
@@ -1074,6 +1088,7 @@ class HiSparseRuntime:
1088
shared_rows=shared_rows,
1089
attention_indices_out=attention_indices_out,
1090
valid_counts_out=valid_counts_out,
1091
+ num_valid_rows=num_valid_rows,
1092
)
1093
1094
if not self._swap_staged:
@@ -1110,6 +1125,8 @@ class HiSparseCacheHandle:
1125
self.mirror_staging_cache: torch.Tensor | None = None
1126
self.mirror_staging_slots: torch.Tensor | None = None
1127
self.submit_layer_mirror: Callable[[], None] | None = None
1128
+ # Speculator layers write their rows after the target forward.
1129
+ self.draft_layer = False
1130
self.index_group_caches: list[HiSparseCacheHandle] = [self]
1131
1132
def prepare_group_for_batch(self, attn_metadata: Any | None) -> None:
@@ -1203,6 +1220,7 @@ class HiSparseCacheHandle:
1220
logical_topk_indices: torch.Tensor,
1221
*,
1222
block_size: int,
1223
+ num_valid_rows: torch.Tensor,
1224
return_valid_counts: bool = False,
1225
attention_indices_out: torch.Tensor | None = None,
1226
valid_counts_out: torch.Tensor | None = None,
@@ -1216,6 +1234,7 @@ class HiSparseCacheHandle:
1234
return_valid_counts=return_valid_counts,
1235
attention_indices_out=attention_indices_out,
1236
valid_counts_out=valid_counts_out,
1237
+ num_valid_rows=num_valid_rows,
1238
)
1239
1240
vllm/v1/worker/gpu/spec_decode/speculator.py
+6
-6
@@ -224,13 +224,13 @@ class DraftModelSpeculator(BaseSpeculator):
224
self.model = self.load_draft_model(target_model, target_attn_layer_names)
225
self._validate_local_argmax_reduction()
226
227
- all_attn_layers = set[str](
228
- get_layers_from_vllm_config(
229
- self.vllm_config,
230
- AttentionLayerBase, # type: ignore[type-abstract]
231
- ).keys()
227
+ all_attn_layers = get_layers_from_vllm_config(
228
+ self.vllm_config,
229
+ AttentionLayerBase, # type: ignore[type-abstract]
230
)
233
- self.draft_attn_layer_names = all_attn_layers - target_attn_layer_names
231
+ self.draft_attn_layer_names = set(all_attn_layers) - target_attn_layer_names
232
+ for layer_name in self.draft_attn_layer_names:
233
+ all_attn_layers[layer_name].is_draft_layer = True
234
235
target_supports_mm = self.vllm_config.model_config.supports_multimodal_inputs
236
draft_supports_mm = supports_multimodal_embeddings(self.model)