A high-throughput and memory-efficient inference and serving engine for LLMs
[SimpleCPUOffloadConnector]: Add support for reset_cache() (#39726)
Signed-off-by: Jonathan Chen <chenleejonathan@gmail.com> Signed-off-by: Jonathan <chenleejonathan@gmail.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Jonathan Chen committed
Jun 17, 2026 at 22:47 UTC
d57888efa41b317c34b912c21ca36bc20bdd8da1
3 files changed
+251
-9
tests/v1/simple_kv_offload/test_scheduler.py
+174
@@ -1354,3 +1354,177 @@ def test_toctou_cpu_hit_evicted_between_phases_no_crash() -> None:
1354
)
1355
assert len(meta_b.load_gpu_blocks) == 2
1356
assert len(meta_b.load_cpu_blocks) == 2
1357
+
1358
+
1359
+# ---------------------------------------------------------------------------
1360
+# Test 12: Reset with pending eager stores waits for completion
1361
+# ---------------------------------------------------------------------------
1362
+def test_reset_pending_eager_stores() -> None:
1363
+ """Eager mode: reset() abandons in-flight stores until they complete."""
1364
+ fix = make_scheduler(num_cpu_blocks=8, num_gpu_blocks=16, lazy=False)
1365
+ sched = fix.scheduler
1366
+ gpu_pool = fix.gpu_block_pool
1367
+
1368
+ num_blocks = 2
1369
+ req = make_request(num_blocks=num_blocks)
1370
+
1371
+ kv_blocks = _alloc_and_register(fix, req, num_blocks)
1372
+ sched.update_state_after_alloc(req, kv_blocks, num_external_tokens=0)
1373
+ block_ids = kv_blocks.get_block_ids()
1374
+ sched_out = make_scheduler_output(
1375
+ {req.request_id: num_blocks * BLOCK_SIZE},
1376
+ new_reqs={req.request_id: block_ids},
1377
+ )
1378
+
1379
+ meta = sched.build_connector_meta(sched_out)
1380
+ assert meta.store_event >= 0
1381
+ assert len(sched._store_event_to_blocks) > 0
1382
+
1383
+ # GPU blocks should have elevated ref_cnt from touch()
1384
+ for bid in meta.store_gpu_blocks:
1385
+ assert gpu_pool.blocks[bid].ref_cnt > 0
1386
+
1387
+ # Free the request's own block refs (simulates preemption)
1388
+ gpu_pool.free_blocks(gpu_pool.blocks[bid] for bid in block_ids[0])
1389
+
1390
+ # Reset should keep DMA refs pinned until the worker reports completion.
1391
+ assert sched.reset() is False
1392
+ assert len(sched._store_event_to_blocks) == 0
1393
+ assert len(sched._abandoned_store_event_to_blocks) == 1
1394
+ assert len(sched._reqs_to_store) == 0
1395
+ assert len(sched._store_event_to_reqs) == 0
1396
+
1397
+ num_used = gpu_pool.num_gpu_blocks - gpu_pool.get_num_free_blocks()
1398
+ assert num_used > 1
1399
+
1400
+ simulate_store_completion(sched, meta.store_event)
1401
+ assert len(sched._abandoned_store_event_to_blocks) == 0
1402
+
1403
+ # All GPU blocks should now be free (ref_cnt == 0) except null block
1404
+ num_used = gpu_pool.num_gpu_blocks - gpu_pool.get_num_free_blocks()
1405
+ assert num_used == 1, f"Expected only null block in use, got {num_used}"
1406
+
1407
+ # GPU prefix cache reset should now succeed
1408
+ assert gpu_pool.reset_prefix_cache() is True
1409
+ assert sched.reset() is True
1410
+
1411
+
1412
+# ---------------------------------------------------------------------------
1413
+# Test 13: Reset with pending lazy stores waits for completion
1414
+# ---------------------------------------------------------------------------
1415
+def test_reset_pending_lazy_stores() -> None:
1416
+ """Lazy mode: reset() abandons in-flight stores until they complete."""
1417
+ fix = make_scheduler(num_cpu_blocks=8, num_gpu_blocks=8, lazy=True)
1418
+ sched = fix.scheduler
1419
+ gpu_pool = fix.gpu_block_pool
1420
+
1421
+ num_blocks = 2
1422
+ req = make_request(num_blocks=num_blocks)
1423
+
1424
+ # Allocate, hash, and free — blocks move to free queue with hashes
1425
+ gpu_blocks = _allocate_gpu_blocks(gpu_pool, req, num_blocks, group_id=0)
1426
+ gpu_pool.free_blocks(gpu_blocks)
1427
+
1428
+ # Push hashed blocks to LRU head
1429
+ fillers = _flush_old_blocks_to_lru_head(gpu_pool, num_filler_blocks=5)
1430
+
1431
+ # Lazy scanner offloads old hashed blocks
1432
+ sched_out = make_scheduler_output({})
1433
+ meta = sched.build_connector_meta(sched_out)
1434
+ assert meta.store_event >= 0
1435
+ assert len(sched._store_event_to_blocks) > 0
1436
+
1437
+ gpu_pool.free_blocks(fillers)
1438
+
1439
+ # Reset should keep DMA refs pinned until the worker reports completion.
1440
+ assert sched.reset() is False
1441
+ assert len(sched._store_event_to_blocks) == 0
1442
+ assert len(sched._abandoned_store_event_to_blocks) == 1
1443
+ assert sched._cursor is None
1444
+
1445
+ simulate_store_completion(sched, meta.store_event)
1446
+ assert len(sched._abandoned_store_event_to_blocks) == 0
1447
+ assert sched.reset() is True
1448
+
1449
+ # No CPU cache hits after reset
1450
+ req2 = Request(
1451
+ request_id="req-after-lazy-reset",
1452
+ prompt_token_ids=req.prompt_token_ids,
1453
+ sampling_params=req.sampling_params,
1454
+ pooling_params=None,
1455
+ mm_features=None,
1456
+ block_hasher=req._block_hasher,
1457
+ )
1458
+ hit_tokens, _ = sched.get_num_new_matched_tokens(req2, num_computed_tokens=0)
1459
+ assert hit_tokens == 0, "CPU cache should be empty after reset"
1460
+
1461
+
1462
+# ---------------------------------------------------------------------------
1463
+# Test 14: Reset with pending loads waits for completion
1464
+# ---------------------------------------------------------------------------
1465
+def test_reset_pending_loads() -> None:
1466
+ """reset() abandons in-flight loads until they complete."""
1467
+ fix = make_scheduler(num_cpu_blocks=8, num_gpu_blocks=16, lazy=False)
1468
+ sched = fix.scheduler
1469
+ gpu_pool = fix.gpu_block_pool
1470
+
1471
+ num_blocks = 2
1472
+
1473
+ # First store blocks to CPU
1474
+ req = make_request(num_blocks=num_blocks)
1475
+ kv_blocks = _alloc_and_register(fix, req, num_blocks)
1476
+ sched.update_state_after_alloc(req, kv_blocks, num_external_tokens=0)
1477
+ block_ids = kv_blocks.get_block_ids()
1478
+ sched_out = make_scheduler_output(
1479
+ {req.request_id: num_blocks * BLOCK_SIZE},
1480
+ new_reqs={req.request_id: block_ids},
1481
+ )
1482
+ meta = sched.build_connector_meta(sched_out)
1483
+ simulate_store_completion(sched, meta.store_event)
1484
+
1485
+ # Start a load — CPU cache hit
1486
+ req2 = Request(
1487
+ request_id="req-load-reset",
1488
+ prompt_token_ids=req.prompt_token_ids,
1489
+ sampling_params=req.sampling_params,
1490
+ pooling_params=None,
1491
+ mm_features=None,
1492
+ block_hasher=req._block_hasher,
1493
+ )
1494
+ hit_tokens, is_async = sched.get_num_new_matched_tokens(req2, num_computed_tokens=0)
1495
+ assert hit_tokens > 0
1496
+
1497
+ gpu_blocks2 = gpu_pool.get_new_blocks(num_blocks)
1498
+ kv_blocks2 = KVCacheBlocks(blocks=(gpu_blocks2,))
1499
+ sched.update_state_after_alloc(req2, kv_blocks2, num_external_tokens=hit_tokens)
1500
+
1501
+ block_ids2 = kv_blocks2.get_block_ids()
1502
+ sched_out2 = make_scheduler_output(
1503
+ {req2.request_id: 1},
1504
+ new_reqs={req2.request_id: block_ids2},
1505
+ )
1506
+ meta2 = sched.build_connector_meta(sched_out2)
1507
+ assert meta2.load_event >= 0
1508
+ assert req2.request_id in sched._reqs_to_load
1509
+
1510
+ # Free request block refs (simulates preemption)
1511
+ gpu_pool.free_blocks(gpu_pool.blocks[bid] for bid in block_ids[0])
1512
+ gpu_pool.free_blocks(gpu_pool.blocks[bid] for bid in block_ids2[0])
1513
+
1514
+ # Reset should keep load touch refs until the worker reports completion.
1515
+ assert sched.reset() is False
1516
+ assert len(sched._reqs_to_load) == 0
1517
+ assert len(sched._abandoned_reqs_to_load) == 1
1518
+ assert len(sched._load_event_to_reqs) == 1
1519
+
1520
+ num_used = gpu_pool.num_gpu_blocks - gpu_pool.get_num_free_blocks()
1521
+ assert num_used > 1
1522
+
1523
+ simulate_load_completion(sched, {req2.request_id})
1524
+ assert len(sched._abandoned_reqs_to_load) == 0
1525
+ assert len(sched._load_event_to_reqs) == 0
1526
+ assert sched.reset() is True
1527
+
1528
+ # All GPU blocks free
1529
+ num_used = gpu_pool.num_gpu_blocks - gpu_pool.get_num_free_blocks()
1530
+ assert num_used == 1, f"Expected only null block in use, got {num_used}"
vllm/distributed/kv_transfer/kv_connector/v1/simple_cpu_offload_connector.py
+6
-6
@@ -245,10 +245,10 @@ class SimpleCPUOffloadConnector(KVConnectorBase_V1, SupportsHMA):
245
return self.scheduler_manager.take_events()
246
return []
247
248
+ # NOTE: Workers are not contacted. In-flight transfers drain naturally,
249
+ # and stale completions are ignored by the guarded
250
+ # SimpleCPUOffloadScheduler._process_store_event().
251
def reset_cache(self) -> bool | None:
249
- raise NotImplementedError(
250
- "SimpleCPUOffloadConnector does not support reset_cache(). "
251
- "reset_prefix_cache() requires synchronizing all pending "
252
- "CPU offload transfers before clearing GPU prefix cache blocks, "
253
- "which is not yet implemented."
254
- )
252
+ if self.scheduler_manager is not None:
253
+ return self.scheduler_manager.reset()
254
+ return None
vllm/v1/simple_kv_offload/manager.py
+71
-3
@@ -159,10 +159,12 @@ class SimpleCPUOffloadScheduler:
159
else:
160
self._target_free = 0
161
self._store_event_to_blocks: dict[int, TransferMeta] = {}
162
+ self._abandoned_store_event_to_blocks: dict[int, TransferMeta] = {}
163
# Eager mode only
164
self._reqs_to_store: dict[str, StoreRequestState] = {}
165
self._store_event_to_reqs: dict[int, list[str]] = {}
166
self._in_flight_store_gpu_blocks: set[int] = set()
167
+ self._abandoned_reqs_to_load: dict[str, LoadRequestState] = {}
168
169
# Event counters
170
self._load_event_counter: int = 0
@@ -427,7 +429,10 @@ class SimpleCPUOffloadScheduler:
429
load_event=load_event,
430
load_gpu_blocks=load_gpu,
431
load_cpu_blocks=load_cpu,
430
- load_event_to_reqs=self._load_event_to_reqs,
432
+ load_event_to_reqs={
433
+ event_idx: list(req_ids)
434
+ for event_idx, req_ids in self._load_event_to_reqs.items()
435
+ },
436
store_event=store_event,
437
store_gpu_blocks=store_gpu,
438
store_cpu_blocks=store_cpu,
@@ -680,9 +685,17 @@ class SimpleCPUOffloadScheduler:
685
686
def _process_store_event(self, event_idx: int) -> None:
687
"""Process a fully-completed store event."""
683
- transfer = self._store_event_to_blocks.pop(event_idx)
688
+ transfer = self._store_event_to_blocks.pop(event_idx, None)
689
+ if transfer is None:
690
+ transfer = self._abandoned_store_event_to_blocks.pop(event_idx, None)
691
+ if transfer is None:
692
+ return # guard stale events from before a reset() call
693
+ self._release_transfer_refs(transfer)
694
+ return
695
+
696
if not self._lazy_mode:
697
self._in_flight_store_gpu_blocks.difference_update(transfer.gpu_block_ids)
698
+
699
self._process_store_completion(transfer.gpu_block_ids, transfer.cpu_block_ids)
700
logger.debug(
701
"Store event %d completed: cached %d blocks to CPU",
@@ -725,9 +738,22 @@ class SimpleCPUOffloadScheduler:
738
self._gpu_block_pool.blocks[bid] for bid in gpu_block_ids
739
)
740
741
+ def _release_transfer_refs(self, transfer: TransferMeta) -> None:
742
+ """Release transfer refs without making copied data cacheable."""
743
+ cpu_blocks = [self.cpu_block_pool.blocks[bid] for bid in transfer.cpu_block_ids]
744
+ for cpu_block in cpu_blocks:
745
+ cpu_block.reset_hash()
746
+ self.cpu_block_pool.free_blocks(cpu_blocks)
747
+ assert self._gpu_block_pool is not None
748
+ self._gpu_block_pool.free_blocks(
749
+ self._gpu_block_pool.blocks[bid] for bid in transfer.gpu_block_ids
750
+ )
751
+
752
def has_pending_stores(self) -> bool:
753
"""Return True if there are in-flight store transfers."""
730
- return bool(self._store_event_to_blocks)
754
+ return bool(
755
+ self._store_event_to_blocks or self._abandoned_store_event_to_blocks
756
+ )
757
758
def request_finished(
759
self,
@@ -787,6 +813,8 @@ class SimpleCPUOffloadScheduler:
813
and frees CPU/GPU touch refs.
814
"""
815
state = self._reqs_to_load.pop(req_id, None)
816
+ if state is None:
817
+ state = self._abandoned_reqs_to_load.pop(req_id, None)
818
if state is None:
819
return
820
# Remove from load event mapping (only this req, not whole event)
@@ -830,3 +858,43 @@ class SimpleCPUOffloadScheduler:
858
859
def take_events(self) -> Iterable[KVCacheEvent]:
860
return self.cpu_block_pool.take_events()
861
+
862
+ def reset(self) -> bool:
863
+ """Abandon pending transfers and reset the CPU cache when safe.
864
+
865
+ Worker-side DMA may still be using blocks after reset is requested.
866
+ Keep those block refs pinned until the existing completion path reports
867
+ the transfer finished, then release refs without caching abandoned
868
+ store results.
869
+ """
870
+
871
+ self._abandoned_store_event_to_blocks.update(self._store_event_to_blocks)
872
+ self._store_event_to_blocks.clear()
873
+ self._in_flight_store_gpu_blocks.clear()
874
+
875
+ # Loads that have not been sent to the worker cannot have running DMA.
876
+ # In-flight loads stay pinned and are cleaned up on completion.
877
+ for req_id in list(self._reqs_to_load):
878
+ state = self._reqs_to_load.pop(req_id)
879
+ if state.load_event is None:
880
+ self._reqs_to_load[req_id] = state
881
+ self._cleanup_load_request(req_id)
882
+ else:
883
+ self._abandoned_reqs_to_load[req_id] = state
884
+
885
+ self._reqs_to_store.clear()
886
+ self._store_event_to_reqs.clear()
887
+ self._store_event_pending_counts = {
888
+ event_idx: count
889
+ for event_idx, count in self._store_event_pending_counts.items()
890
+ if event_idx in self._abandoned_store_event_to_blocks
891
+ }
892
+ self._cursor = None
893
+ # NOTE: _load_event_counter / _store_event_counter are not
894
+ # reset as they are monotonic and must stay ahead of the workers
895
+ # high-water marks to avoid event index collisions
896
+
897
+ if self._abandoned_store_event_to_blocks or self._abandoned_reqs_to_load:
898
+ return False
899
+
900
+ return self.cpu_block_pool.reset_prefix_cache()