[Bugfix][KV Offload] Track cache recency once per request (#51787)

Signed-off-by: mindungil <alswnsrlf12@naver.com> Co-authored-by: Or Ozeri <oro@il.ibm.com>

MINJUN GIL committed Sep 16, 2026 at 16:47 UTC f12fe10c5c377ccd401b66c39df3ffb394fa067c
10 files changed +918 -171
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py
+18 -53
@@ -258,6 +258,9 @@ def test_partial_tail_store_uses_attention_and_recurrent_cow_sources():
258 jobs = scheduler._build_partial_tail_store_jobs(output)
259
260 assert len(jobs) == 1
261 + offered_keys = scheduler.manager.prepare_store.call_args.args[0]
262 + req_context = scheduler._req_status["req"].req_context
263 + assert all(req_context.get_offload_key_position(key) == 28 for key in offered_keys)
264 [job_id] = jobs
265 src_spec = jobs[job_id].src_spec
266 assert isinstance(src_spec, GPULoadStoreSpec)
@@ -380,6 +383,13 @@ def test_normal_store_excludes_align_mode_mamba_sources():
383 req_status.group_states[0].block_ids[:] = [11]
384 req_status.group_states[1].block_ids[:] = [99]
385 req_status.update_offload_keys()
386 + for group_config, group_state in zip(
387 + scheduler.config.kv_group_configs, req_status.group_states
388 + ):
389 + assert (
390 + req_status.req_context.get_offload_key_position(group_state.offload_keys[0])
391 + == group_config.tokens_per_chunk
392 + )
393 scheduler.manager.prepare_store.side_effect = lambda keys, req_context: (
394 generate_store_output(keys)
395 )
@@ -921,7 +931,6 @@ def test_offloading_connector(request_runner, async_scheduling: bool):
931 runner.run(decoded_tokens=[0] * (tokens_per_chunk + 1))
932
933 # 1 more block (+ token for kicking off offloading)
924 - # now check touch was called with all 6 blocks
934 runner.manager.prepare_store.side_effect = lambda keys, req_context: (
935 generate_store_output(keys)
936 )
@@ -929,9 +938,6 @@ def test_offloading_connector(request_runner, async_scheduling: bool):
938 decoded_tokens=[0] * (tokens_per_chunk + 1),
939 expected_stored=(15, 16, 17),
940 )
932 - runner.manager.touch.assert_called()
933 - block_hashes1 = list(runner.manager.touch.call_args.args[0])
934 - assert len(block_hashes1) == 6
941
942 # terminate request
943 runner.run(decoded_tokens=[EOS_TOKEN_ID])
@@ -939,13 +945,7 @@ def test_offloading_connector(request_runner, async_scheduling: bool):
945 # create a new request differing only on the last token
946 runner.new_request(token_ids=[0] * (tokens_per_chunk * 6 - 1) + [1])
947 runner.run(decoded_tokens=[0])
942 - runner.manager.touch.assert_called()
943 - block_hashes2 = list(runner.manager.touch.call_args.args[0])
944 - assert len(block_hashes2) == 6
945 -
946 - # verify hashes are the same, except for the last block
947 - assert block_hashes1[:5] == block_hashes2[:5]
948 - assert block_hashes1[5] != block_hashes2[5]
948 + runner.manager.touch.assert_not_called()
949
950 # terminate request
951 runner.run(
@@ -1353,14 +1353,7 @@ def test_two_groups_full_and_sliding_window(request_runner, async_scheduling: bo
1353 generate_store_output(keys)
1354 )
1355 runner.run(decoded_tokens=[0])
1356 - # _touch called from get_num_new_matched_tokens (2 groups) and
1357 - # _get_reqs_to_store (2 groups) → 4 touch calls total.
1358 - touch_calls = runner.manager.touch.call_args_list
1359 - assert len(touch_calls) == 4
1360 - assert len(touch_calls[0].args[0]) == 3
1361 - assert len(touch_calls[1].args[0]) == 3
1362 - assert len(touch_calls[2].args[0]) == 3
1363 - assert len(touch_calls[3].args[0]) == 3
1356 + runner.manager.touch.assert_not_called()
1357
1358 # store 3 more block
1359 runner.manager.prepare_store.side_effect = lambda keys, req_context: (
@@ -1371,9 +1364,7 @@ def test_two_groups_full_and_sliding_window(request_runner, async_scheduling: bo
1364 expected_stored=(0, 1, 2, 3, 4, 5),
1365 )
1366
1374 - # touch called from _get_reqs_to_store * 3 blocks, once for each group
1375 - touch_calls = runner.manager.touch.call_args_list
1376 - assert len(touch_calls) == 6
1367 + runner.manager.touch.assert_not_called()
1368
1369 # EOS lands in the last slot of the 7th block (offset 6). No forward pass
1370 # writes that slot, so the finishing step declines the block.
@@ -1392,13 +1383,7 @@ def test_two_groups_full_and_sliding_window(request_runner, async_scheduling: bo
1383 expected_loaded=((0, 0), (0, 1), (0, 2), (1, 1), (1, 2)),
1384 )
1385
1395 - # 2 touch calls from get_num_new_matched_tokens (2 groups)
1396 - touch_calls = runner.manager.touch.call_args_list
1397 - assert len(touch_calls) == 2
1398 - # full attention group touched all 3 blocks
1399 - assert len(touch_calls[0].args[0]) == 3
1400 - # sliding window group touched just the last 2 blocks
1401 - assert len(touch_calls[1].args[0]) == 2
1386 + runner.manager.touch.assert_not_called()
1387
1388 # 3 blocks are hit on GPU [0, 1, 2]
1389 # 1 block loaded [3,]
@@ -1464,15 +1449,7 @@ def test_two_groups_different_block_sizes(request_runner, async_scheduling: bool
1449 generate_store_output(keys)
1450 )
1451 runner.run(decoded_tokens=[0])
1467 - # _touch called from get_num_new_matched_tokens (2 groups) and
1468 - # _get_reqs_to_store (2 groups) → 4 touch calls total.
1469 - # Group 0 has 2 offload keys, group 1 has 1.
1470 - touch_calls = runner.manager.touch.call_args_list
1471 - assert len(touch_calls) == 4
1472 - assert len(touch_calls[0].args[0]) == 2
1473 - assert len(touch_calls[1].args[0]) == 1
1474 - assert len(touch_calls[2].args[0]) == 2
1475 - assert len(touch_calls[3].args[0]) == 1
1452 + runner.manager.touch.assert_not_called()
1453
1454 # Get to 31 tokens
1455 # No further blocks offloaded
@@ -1482,11 +1459,7 @@ def test_two_groups_different_block_sizes(request_runner, async_scheduling: bool
1459 # Group 0 blocks: [0, 1], ending_token_offset = 24
1460 # Group 1 blocks: [0, 1], ending_token_offset = 32
1461 runner.run(decoded_tokens=[0])
1485 - # _get_reqs_to_store touch: only group 1 has a new block to store
1486 - touch_calls = runner.manager.touch.call_args_list
1487 - assert len(touch_calls) == 2
1488 - assert len(touch_calls[0].args[0]) == 2
1489 - assert len(touch_calls[1].args[0]) == 2
1462 + runner.manager.touch.assert_not_called()
1463
1464 # Get to 35 tokens
1465 # No further blocks offloaded
@@ -1496,11 +1469,7 @@ def test_two_groups_different_block_sizes(request_runner, async_scheduling: bool
1469 # Group 0 blocks: [0, 1, 2], ending_token_offset = 36
1470 # Group 1 blocks: [0, 1], ending_token_offset = 32
1471 runner.run(decoded_tokens=[0])
1499 - # _get_reqs_to_store touch: only group 0 has a new block to store
1500 - touch_calls = runner.manager.touch.call_args_list
1501 - assert len(touch_calls) == 2
1502 - assert len(touch_calls[0].args[0]) == 3
1503 - assert len(touch_calls[1].args[0]) == 2
1472 + runner.manager.touch.assert_not_called()
1473
1474 # Get to 47 tokens
1475 # No further blocks offloaded
@@ -1510,11 +1479,7 @@ def test_two_groups_different_block_sizes(request_runner, async_scheduling: bool
1479 # Group 0 blocks: [0, 1, 2, 3], ending_token_offset = 4
1480 # Group 1 blocks: [0, 1, 2], ending_token_offset = 48
1481 runner.run(decoded_tokens=[0])
1513 - # _get_reqs_to_store touch: both groups have a new block, each with 1 key
1514 - touch_calls = runner.manager.touch.call_args_list
1515 - assert len(touch_calls) == 2
1516 - assert len(touch_calls[0].args[0]) == 4
1517 - assert len(touch_calls[1].args[0]) == 3
1482 + runner.manager.touch.assert_not_called()
1483
1484 runner.run(decoded_tokens=[0], expected_stored=((0, 3), (1, 2)))
1485
tests/v1/kv_offload/cpu/test_manager.py
+428 -8
@@ -23,6 +23,7 @@ from vllm.v1.kv_offload.cpu.common import (
23 )
24 from vllm.v1.kv_offload.cpu.manager import CPUOffloadingManager
25 from vllm.v1.kv_offload.cpu.policies.arc import ARCCachePolicy
26 +from vllm.v1.kv_offload.cpu.policies.lru import LRUCachePolicy
27
28
29 def make_req_context(
@@ -430,31 +431,34 @@ def test_cpu_manager():
431 assert cpu_manager.lookup(to_key(0), _EMPTY_REQ_CTX) is LookupResult.MISS
432
433 # prepare load [2, 3]
433 - prepare_load_output = cpu_manager.prepare_load(to_keys([2, 3]), _EMPTY_REQ_CTX)
434 + load_ctx = make_req_context("load-2-3")
435 + prepare_load_output = cpu_manager.prepare_load(to_keys([2, 3]), load_ctx)
436 verify_load_output(prepare_load_output, [1, 2])
437
438 # prepare store with no space ([2, 3] is being loaded)
439 assert cpu_manager.prepare_store(to_keys([6, 7, 8]), _EMPTY_REQ_CTX) is None
440
441 # complete load [2, 3]. Load changes the eviction list, making 2, 3 recent.
440 - cpu_manager.complete_load(to_keys([2, 3]), _EMPTY_REQ_CTX)
442 + cpu_manager.complete_load(to_keys([2, 3]), load_ctx)
443 + cpu_manager.on_request_finished(load_ctx)
444
442 - # prepare store [6, 7, 8] -> evicts [4, 5, 2] (oldest)
445 + # prepare store [6, 7, 8] -> evicts [4, 5, 3] (oldest). Within
446 + # the accessed prefix [2, 3], the tail is less valuable than the head.
447 prepare_store_output = cpu_manager.prepare_store(to_keys([6, 7, 8]), _EMPTY_REQ_CTX)
448 verify_store_output(
449 prepare_store_output,
450 ExpectedPrepareStoreOutput(
451 keys_to_store=[6, 7, 8],
448 - store_chunk_ids=[1, 0, 3],
449 - evicted_keys=[4, 5, 2],
452 + store_chunk_ids=[2, 0, 3],
453 + evicted_keys=[4, 5, 3],
454 ),
455 )
456
457 # complete store [6, 7, 8]
458 cpu_manager.complete_store(to_keys([6, 7, 8]), _EMPTY_REQ_CTX)
459
456 - # touch [3, 6, 7] (move to end of LRU order)
457 - cpu_manager.touch(to_keys([3, 6, 7]), _EMPTY_REQ_CTX)
460 + # touch [2, 6, 7] (move to end of LRU order)
461 + cpu_manager.touch(to_keys([2, 6, 7]), _EMPTY_REQ_CTX)
462
463 # prepare store [7, 9] -> evicts [8] (oldest following previous touch)
464 prepare_store_output = cpu_manager.prepare_store(to_keys([9]), _EMPTY_REQ_CTX)
@@ -477,7 +481,7 @@ def test_cpu_manager():
481 verify_events(
482 cpu_manager.take_events(),
483 expected_stores=({3, 4, 5}, {6, 7, 8}),
480 - expected_evictions=({4, 5, 2}, {8}),
484 + expected_evictions=({4, 5, 3}, {8}),
485 )
486
487
@@ -518,6 +522,65 @@ def test_prepare_load_preserves_key_order():
522 manager.complete_load([key_a, key_b, key_c], _EMPTY_REQ_CTX) # order irrelevant
523
524
525 +def test_lru_batch_eviction_failure_is_atomic():
526 + manager = make_cpu_manager(num_chunks=4, cache_policy="lru")
527 + policy = manager._policy
528 + assert isinstance(policy, LRUCachePolicy)
529 + keys = to_keys([1, 2, 3, 4])
530 + assert manager.prepare_store(keys, _EMPTY_REQ_CTX) is not None
531 + manager.complete_store(keys, _EMPTY_REQ_CTX)
532 +
533 + protected = set(keys[1:])
534 + assert policy.evict(2, protected) is None
535 + assert all(manager.lookup(key, _EMPTY_REQ_CTX) is LookupResult.HIT for key in keys)
536 +
537 + evicted = policy.evict(1, protected)
538 + assert evicted is not None
539 + assert [key for key, _ in evicted] == [keys[0]]
540 +
541 +
542 +def test_lru_repeated_pin_cycles_compact_lazy_heap_entries():
543 + manager = make_cpu_manager(num_chunks=1, cache_policy="lru")
544 + policy = manager._policy
545 + assert isinstance(policy, LRUCachePolicy)
546 + key = to_key(1)
547 + assert manager.prepare_store([key], _EMPTY_REQ_CTX) is not None
548 + manager.complete_store([key], _EMPTY_REQ_CTX)
549 +
550 + for _ in range(128):
551 + manager.prepare_load([key], _EMPTY_REQ_CTX)
552 + manager.complete_load([key], _EMPTY_REQ_CTX)
553 +
554 + assert len(policy._heap) < 64
555 + evicted = policy.evict(1, set())
556 + assert evicted is not None
557 + assert [evicted_key for evicted_key, _ in evicted] == [key]
558 +
559 +
560 +def test_reset_discards_stale_request_access_classification():
561 + manager = make_cpu_manager(num_chunks=1, cache_policy="arc")
562 + policy = manager._policy
563 + assert isinstance(policy, ARCCachePolicy)
564 + key = to_key(1)
565 + resumed_ctx = make_req_context("resumed")
566 +
567 + assert manager.prepare_store([key], resumed_ctx) is not None
568 + manager.complete_store([key], resumed_ctx)
569 + manager.reset_cache()
570 +
571 + replacement_ctx = make_req_context("replacement")
572 + assert manager.prepare_store([key], replacement_ctx) is not None
573 + manager.complete_store([key], replacement_ctx)
574 + manager.on_request_finished(replacement_ctx)
575 + assert key in policy.t1
576 +
577 + manager.prepare_load([key], resumed_ctx)
578 + manager.complete_load([key], resumed_ctx)
579 + manager.on_request_finished(resumed_ctx)
580 +
581 + assert key in policy.t2
582 +
583 +
584 class TestARCPolicy:
585 """Unit tests for CPUOffloadingManager with ARC eviction policy."""
586
@@ -1042,6 +1105,48 @@ def test_filter_reused_manager_oversized_offer_makes_progress():
1105 assert stored_keys == set(keys)
1106
1107
1108 +def test_store_threshold_does_not_hide_ready_reuse():
1109 + manager = make_cpu_manager(
1110 + num_chunks=2,
1111 + cache_policy="lru",
1112 + store_threshold=2,
1113 + max_tracker_size=2,
1114 + )
1115 + head, tail, noise_1, noise_2, replacement = to_keys(list(range(5)))
1116 +
1117 + seed_ctx = make_req_context("seed")
1118 + output = manager.prepare_store([head, tail], seed_ctx)
1119 + assert output is not None
1120 + assert not output.keys_to_store
1121 + output = manager.prepare_store([head, tail], seed_ctx)
1122 + assert output is not None
1123 + assert output.keys_to_store == [head, tail]
1124 + manager.complete_store([head, tail], seed_ctx)
1125 + manager.on_request_finished(seed_ctx)
1126 +
1127 + # Age both resident keys out of the admission tracker without storing the
1128 + # noise keys. Reusing the tail recreates its counter at one, below the
1129 + # threshold, but must still refresh its cache recency.
1130 + output = manager.prepare_store([noise_1, noise_2], make_req_context("noise"))
1131 + assert output is not None
1132 + assert not output.keys_to_store
1133 + reuse_ctx = make_req_context("reuse")
1134 + skipped_before_reuse = manager.stores_skipped_in_current_batch
1135 + output = manager.prepare_store([tail], reuse_ctx)
1136 + assert output is not None
1137 + assert not output.keys_to_store
1138 + assert manager.stores_skipped_in_current_batch == skipped_before_reuse
1139 + manager.on_request_finished(reuse_ctx)
1140 +
1141 + replacement_ctx = make_req_context("replacement")
1142 + output = manager.prepare_store([replacement], replacement_ctx)
1143 + assert output is not None
1144 + assert not output.keys_to_store
1145 + output = manager.prepare_store([replacement], replacement_ctx)
1146 + assert output is not None
1147 + assert output.evicted_keys == [head]
1148 +
1149 +
1150 def test_evictable_cache_chunk_count():
1151 """
1152 Verifies _num_evictable_cache_chunks is maintained correctly through the
@@ -1156,3 +1261,318 @@ def test_touch_forwards_req_context_to_policy(monkeypatch):
1261 assert len(received) == 1
1262 assert received[0][0] == keys
1263 assert received[0][1] is ctx
1264 +
1265 +
1266 +@pytest.mark.parametrize("finish_before_completion", [False, True])
1267 +def test_request_finish_orders_lru_prefix_independent_of_store_completion(
1268 + finish_before_completion: bool,
1269 +):
1270 + """A transfer's completion order must not make the prefix head LRU."""
1271 + manager = make_cpu_manager(num_chunks=4, cache_policy="lru")
1272 + prefix = to_keys([1, 2, 3, 4])
1273 + ctx = make_req_context("prefix")
1274 +
1275 + output = manager.prepare_store(prefix, ctx)
1276 + assert output is not None
1277 + if finish_before_completion:
1278 + manager.on_request_finished(ctx)
1279 + manager.complete_store(set(prefix), ctx)
1280 + if not finish_before_completion:
1281 + manager.on_request_finished(ctx)
1282 +
1283 + output = manager.prepare_store(to_keys([5]), make_req_context("evict"))
1284 + assert output is not None
1285 + assert output.evicted_keys == to_keys([4])
1286 +
1287 +
1288 +@pytest.mark.parametrize("finish_before_completion", [False, True])
1289 +def test_request_finish_orders_lru_prefix_independent_of_load_completion(
1290 + finish_before_completion: bool,
1291 +):
1292 + manager = make_cpu_manager(num_chunks=4, cache_policy="lru")
1293 + prefix = to_keys([1, 2, 3, 4])
1294 +
1295 + seed_ctx = make_req_context("seed")
1296 + output = manager.prepare_store(prefix, seed_ctx)
1297 + assert output is not None
1298 + manager.complete_store(prefix, seed_ctx)
1299 +
1300 + reuse_ctx = make_req_context("reuse")
1301 + manager.prepare_load(prefix, reuse_ctx)
1302 + if finish_before_completion:
1303 + manager.on_request_finished(reuse_ctx)
1304 + manager.complete_load(set(prefix), reuse_ctx)
1305 + if not finish_before_completion:
1306 + manager.on_request_finished(reuse_ctx)
1307 +
1308 + output = manager.prepare_store(to_keys([5]), make_req_context("evict"))
1309 + assert output is not None
1310 + assert output.evicted_keys == to_keys([4])
1311 +
1312 +
1313 +def test_late_old_completion_does_not_override_newer_request_recency():
1314 + manager = make_cpu_manager(num_chunks=4, cache_policy="lru")
1315 + old_keys = to_keys([1, 2])
1316 + new_keys = to_keys([3, 4])
1317 +
1318 + old_ctx = make_req_context("old")
1319 + assert manager.prepare_store(old_keys, old_ctx) is not None
1320 + manager.on_request_finished(old_ctx)
1321 +
1322 + new_ctx = make_req_context("new")
1323 + assert manager.prepare_store(new_keys, new_ctx) is not None
1324 + manager.complete_store(new_keys, new_ctx)
1325 + manager.on_request_finished(new_ctx)
1326 +
1327 + # Physical completion is late, but the logical access is still older.
1328 + manager.complete_store(old_keys, old_ctx)
1329 +
1330 + output = manager.prepare_store(to_keys([5]), make_req_context("evict"))
1331 + assert output is not None
1332 + assert output.evicted_keys == [old_keys[-1]]
1333 +
1334 +
1335 +def test_arc_counts_reuse_once_and_keeps_insertions_in_t1():
1336 + manager = make_cpu_manager(num_chunks=4, cache_policy="arc")
1337 + policy = manager._policy
1338 + assert isinstance(policy, ARCCachePolicy)
1339 + prefix = to_keys([1, 2, 3, 4])
1340 +
1341 + seed_ctx = make_req_context("seed")
1342 + assert manager.prepare_store(prefix, seed_ctx) is not None
1343 + manager.complete_store(prefix, seed_ctx)
1344 + # Repeated store offers from the same request must not become ARC hits.
1345 + assert manager.prepare_store(prefix, seed_ctx) is not None
1346 + # Nor should an internal read used to cascade the new chunks to a tier.
1347 + manager.prepare_load(prefix, seed_ctx)
1348 + manager.complete_load(prefix, seed_ctx)
1349 + manager.on_request_finished(seed_ctx)
1350 +
1351 + assert list(policy.t1) == list(reversed(prefix))
1352 + assert not policy.t2
1353 +
1354 + reuse_ctx = make_req_context("reuse")
1355 + manager.prepare_load(prefix, reuse_ctx)
1356 + manager.complete_load(prefix, reuse_ctx)
1357 + # A duplicate internal pin, as used by tiering cascades, is deduplicated.
1358 + manager.prepare_load(prefix, reuse_ctx)
1359 + manager.complete_load(prefix, reuse_ctx)
1360 + manager.on_request_finished(reuse_ctx)
1361 +
1362 + assert not policy.t1
1363 + assert list(policy.t2) == list(reversed(prefix))
1364 +
1365 +
1366 +def test_arc_reuse_wins_if_same_request_later_reinserts_key():
1367 + manager = make_cpu_manager(num_chunks=1, cache_policy="arc")
1368 + policy = manager._policy
1369 + assert isinstance(policy, ARCCachePolicy)
1370 + key_1, key_2 = to_keys([1, 2])
1371 +
1372 + seed_ctx = make_req_context("seed")
1373 + assert manager.prepare_store([key_1], seed_ctx) is not None
1374 + manager.complete_store([key_1], seed_ctx)
1375 + manager.on_request_finished(seed_ctx)
1376 +
1377 + reuse_ctx = make_req_context("reuse-and-reinsert")
1378 + manager.prepare_load([key_1], reuse_ctx)
1379 + manager.complete_load([key_1], reuse_ctx)
1380 +
1381 + other_ctx = make_req_context("evict-reused-key")
1382 + assert manager.prepare_store([key_2], other_ctx) is not None
1383 + manager.complete_store([key_2], other_ctx)
1384 + manager.on_request_finished(other_ctx)
1385 +
1386 + assert manager.prepare_store([key_1], reuse_ctx) is not None
1387 + manager.complete_store([key_1], reuse_ctx)
1388 + manager.on_request_finished(reuse_ctx)
1389 +
1390 + assert key_1 not in policy.t1
1391 + assert list(policy.t2) == [key_1]
1392 +
1393 +
1394 +def test_request_finish_forwards_grouped_deduplicated_accesses(monkeypatch):
1395 + manager = make_cpu_manager(num_chunks=5)
1396 + existing = make_offload_key(b"existing", 0)
1397 + group_0_new = make_offload_key(b"group-0-new", 0)
1398 + group_1_head = make_offload_key(b"group-1-head", 1)
1399 + group_1_tail = make_offload_key(b"group-1-tail", 1)
1400 +
1401 + seed_ctx = make_req_context("seed")
1402 + assert manager.prepare_store([existing], seed_ctx) is not None
1403 + manager.complete_store([existing], seed_ctx)
1404 +
1405 + ctx = make_req_context("grouped")
1406 + # Group 1 is observed first, but policy finalization follows group index.
1407 + offered = [group_1_head, existing, group_0_new]
1408 + assert manager.prepare_store(offered, ctx) is not None
1409 + # Later offers extend a group's prefix and may repeat earlier keys.
1410 + assert (
1411 + manager.prepare_store([group_0_new, group_1_head, group_1_tail], ctx)
1412 + is not None
1413 + )
1414 +
1415 + received = []
1416 +
1417 + def on_request_finished(key_groups, insertion_only_keys, reused_keys, req_context):
1418 + received.append((key_groups, insertion_only_keys, reused_keys, req_context))
1419 +
1420 + monkeypatch.setattr(manager._policy, "on_request_finished", on_request_finished)
1421 + manager.on_request_finished(ctx)
1422 + manager.on_request_finished(ctx)
1423 +
1424 + assert received == [
1425 + (
1426 + ((existing, group_0_new), (group_1_head, group_1_tail)),
1427 + {group_0_new, group_1_head, group_1_tail},
1428 + {existing},
1429 + ctx,
1430 + )
1431 + ]
1432 +
1433 +
1434 +def test_failed_store_records_access_once_and_uses_store_miss_hook(monkeypatch):
1435 + manager = make_cpu_manager(num_chunks=1)
1436 + resident, candidate = to_keys([1, 2])
1437 +
1438 + seed_ctx = make_req_context("seed")
1439 + assert manager.prepare_store([resident], seed_ctx) is not None
1440 + manager.complete_store([resident], seed_ctx)
1441 +
1442 + pin_ctx = make_req_context("pin")
1443 + manager.prepare_load([resident], pin_ctx)
1444 +
1445 + record_calls = 0
1446 + original_record = manager._record_request_cache_access
1447 +
1448 + def record_access(*args, **kwargs):
1449 + nonlocal record_calls
1450 + record_calls += 1
1451 + return original_record(*args, **kwargs)
1452 +
1453 + store_miss_calls = []
1454 +
1455 + def on_store_miss(keys, req_context):
1456 + store_miss_calls.append((list(keys), req_context))
1457 +
1458 + finalized = []
1459 +
1460 + def on_request_finished(key_groups, insertion_only_keys, reused_keys, req_context):
1461 + finalized.append((key_groups, insertion_only_keys, reused_keys, req_context))
1462 +
1463 + monkeypatch.setattr(manager, "_record_request_cache_access", record_access)
1464 + monkeypatch.setattr(manager._policy, "on_store_miss", on_store_miss)
1465 + monkeypatch.setattr(manager._policy, "on_request_finished", on_request_finished)
1466 +
1467 + ctx = make_req_context("failed-store")
1468 + assert manager.prepare_store([candidate, resident], ctx) is None
1469 + assert record_calls == 1
1470 + assert store_miss_calls == [([candidate], ctx)]
1471 +
1472 + manager.on_request_finished(ctx)
1473 + assert finalized == [
1474 + (((candidate, resident),), set(), {resident}, ctx),
1475 + ]
1476 +
1477 + manager.complete_load([resident], pin_ctx)
1478 +
1479 +
1480 +def test_arc_ghost_hit_adapts_once_per_request_before_insertion():
1481 + manager = make_cpu_manager(num_chunks=2, cache_policy="arc")
1482 + policy = manager._policy
1483 + assert isinstance(policy, ARCCachePolicy)
1484 + keys = to_keys([1, 2, 3])
1485 +
1486 + seed_ctx = make_req_context("seed")
1487 + assert manager.prepare_store(keys[:2], seed_ctx) is not None
1488 + manager.complete_store(keys[:2], seed_ctx)
1489 + manager.on_request_finished(seed_ctx)
1490 +
1491 + evict_ctx = make_req_context("create-ghost")
1492 + output = manager.prepare_store([keys[2]], evict_ctx)
1493 + assert output is not None
1494 + [ghost_key] = output.evicted_keys
1495 + manager.complete_store([keys[2]], evict_ctx)
1496 + manager.on_request_finished(evict_ctx)
1497 + assert ghost_key in policy.b1
1498 +
1499 + resident_keys = [key for key in keys if key != ghost_key]
1500 + pin_ctx = make_req_context("pin-residents")
1501 + manager.prepare_load(resident_keys, pin_ctx)
1502 +
1503 + ghost_ctx = make_req_context("ghost-hit")
1504 + target_before = policy.target_t1_size
1505 + assert manager.prepare_store([ghost_key], ghost_ctx) is None
1506 + target_after_first_offer = policy.target_t1_size
1507 + assert target_after_first_offer > target_before
1508 + assert manager.prepare_store([ghost_key], ghost_ctx) is None
1509 + assert policy.target_t1_size == target_after_first_offer
1510 +
1511 + manager.complete_load(resident_keys, pin_ctx)
1512 + output = manager.prepare_store([ghost_key], ghost_ctx)
1513 + assert output is not None
1514 + assert policy.target_t1_size == target_after_first_offer
1515 + manager.complete_store([ghost_key], ghost_ctx)
1516 + manager.on_request_finished(ghost_ctx)
1517 + assert ghost_key in policy.t1
1518 + assert ghost_key not in policy.t2
1519 +
1520 +
1521 +def test_arc_pending_chunk_is_not_a_frequency_hit():
1522 + manager = make_cpu_manager(num_chunks=2, cache_policy="arc")
1523 + policy = manager._policy
1524 + assert isinstance(policy, ARCCachePolicy)
1525 + key = to_key(1)
1526 +
1527 + writer_ctx = make_req_context("writer")
1528 + assert manager.prepare_store([key], writer_ctx) is not None
1529 +
1530 + observer_ctx = make_req_context("pending-observer")
1531 + output = manager.prepare_store([key], observer_ctx)
1532 + assert output is not None and not output.keys_to_store
1533 + manager.on_request_finished(observer_ctx)
1534 +
1535 + chunk = policy.get(key)
1536 + assert chunk is not None and not chunk.is_ready
1537 + assert key in policy.t1
1538 + assert key not in policy.t2
1539 +
1540 +
1541 +def test_request_key_positions_override_store_observation_order():
1542 + manager = make_cpu_manager(num_chunks=2, cache_policy="lru")
1543 + head, tail = to_keys([1, 2])
1544 + ctx = make_req_context("out-of-order-store")
1545 + ctx.set_offload_key_position(head, 16)
1546 + ctx.set_offload_key_position(tail, 32)
1547 +
1548 + # A backfill or partial-tail path may offer a later key first.
1549 + assert manager.prepare_store([tail], ctx) is not None
1550 + assert manager.prepare_store([head], ctx) is not None
1551 + manager.on_request_finished(ctx)
1552 + manager.complete_store({head, tail}, ctx)
1553 +
1554 + output = manager.prepare_store([to_key(3)], make_req_context("evict-tail"))
1555 + assert output is not None
1556 + assert output.evicted_keys == [tail]
1557 +
1558 +
1559 +def test_request_key_positions_order_tails_across_kv_groups():
1560 + manager = make_cpu_manager(num_chunks=4, cache_policy="lru")
1561 + group_0_head = make_offload_key(b"group-0-head", 0)
1562 + group_0_tail = make_offload_key(b"group-0-tail", 0)
1563 + group_1_head = make_offload_key(b"group-1-head", 1)
1564 + group_1_tail = make_offload_key(b"group-1-tail", 1)
1565 + keys = [group_0_head, group_0_tail, group_1_head, group_1_tail]
1566 + ctx = make_req_context("hybrid-groups")
1567 + for key in (group_0_head, group_1_head):
1568 + ctx.set_offload_key_position(key, 16)
1569 + for key in (group_0_tail, group_1_tail):
1570 + ctx.set_offload_key_position(key, 32)
1571 +
1572 + assert manager.prepare_store(keys, ctx) is not None
1573 + manager.complete_store(keys, ctx)
1574 + manager.on_request_finished(ctx)
1575 +
1576 + output = manager.prepare_store(to_keys([10, 11]), make_req_context("evict"))
1577 + assert output is not None
1578 + assert set(output.evicted_keys) == {group_0_tail, group_1_tail}
tests/v1/kv_offload/tiering/test_tiering_offloading.py
+56 -5
@@ -40,6 +40,7 @@ from vllm.v1.kv_offload.base import (
40 TierMatcher,
41 make_offload_key,
42 )
43 +from vllm.v1.kv_offload.cpu.policies.lru import LRUCachePolicy
44 from vllm.v1.kv_offload.tiering.base import (
45 JobResult,
46 SecondaryTierManager,
@@ -753,14 +754,64 @@ class TestTieringOffloadingManager:
754 # Touch chunks
755 self.manager.touch(chunks, _CTX)
756
756 - # Verify touch was called on primary tier (check LRU order)
757 - primary_keys = list(self.primary_tier._policy.evictable_chunks.keys())
758 - assert primary_keys[-3:] == list(reversed(chunks))
757 + # Verify touch was called on the primary tier (head is most recent).
758 + policy = self.primary_tier._policy
759 + assert isinstance(policy, LRUCachePolicy)
760 + ranks = policy._ranks
761 + assert ranks[chunks[2]] < ranks[chunks[1]] < ranks[chunks[0]]
762
763 # Verify touch was propagated to all secondary tiers
764 self.secondary_tier1.touch.assert_called_once_with(chunks, _CTX)
765 self.secondary_tier2.touch.assert_called_once_with(chunks, _CTX)
766
767 + def test_request_order_survives_late_cascade_completion(self, manager_setup):
768 + """Cascade completion must not replace request order with I/O order."""
769 + chunks = to_keys(range(5))
770 + self._start_request()
771 + output = self.manager.prepare_store(chunks, _CTX)
772 + assert output is not None
773 + self.manager.complete_store(chunks, _CTX, success=True)
774 +
775 + # Finalize while both secondary-tier cascades still pin primary chunks.
776 + self.manager.on_request_finished(_CTX)
777 + self._simulate_on_schedule_end()
778 + self._simulate_on_schedule_end()
779 +
780 + evict_ctx = ReqContext(req_id="evict")
781 + self._start_request(evict_ctx)
782 + output = self.manager.prepare_store(to_keys([5]), evict_ctx)
783 + assert output is not None
784 + assert output.evicted_keys == [chunks[-1]]
785 +
786 + def test_late_old_store_submission_does_not_override_newer_request_order(
787 + self, manager_setup
788 + ):
789 + """Cascade submission after finish is not a new primary access."""
790 + old_blocks = to_keys(range(2))
791 + old_ctx = ReqContext(req_id="old")
792 + self._start_request(old_ctx)
793 + assert self.manager.prepare_store(old_blocks, old_ctx) is not None
794 + self.manager.on_request_finished(old_ctx)
795 +
796 + new_blocks = to_keys(range(2, 5))
797 + new_ctx = ReqContext(req_id="new")
798 + self._start_request(new_ctx)
799 + assert self.manager.prepare_store(new_blocks, new_ctx) is not None
800 + self.manager.complete_store(new_blocks, new_ctx, success=True)
801 + self.manager.on_request_finished(new_ctx)
802 +
803 + # The old GPU->primary write lands after the newer request finished.
804 + # Its cascade pins must not turn it into the more recent request.
805 + self.manager.complete_store(old_blocks, old_ctx, success=True)
806 + self._simulate_on_schedule_end()
807 + self._simulate_on_schedule_end()
808 +
809 + evict_ctx = ReqContext(req_id="evict")
810 + self._start_request(evict_ctx)
811 + output = self.manager.prepare_store(to_keys([5]), evict_ctx)
812 + assert output is not None
813 + assert output.evicted_keys == [old_blocks[-1]]
814 +
815 def test_failed_store_no_cascade(self, manager_setup):
816 """Test that failed GPU→primary store doesn't cascade."""
817 chunks = to_keys(range(3))
@@ -873,10 +924,10 @@ class TestTieringOffloadingManager:
924 job_metadata = self.secondary_tier1.submit_store.call_args.args[0]
925 assert job_metadata.req_context is ctx
926
876 - def test_on_request_finished_delays_secondary_until_store_submitted(
927 + def test_on_request_finished_delays_only_secondary_until_store_submitted(
928 self, manager_setup
929 ):
879 - """Manager hook is eager; secondary hooks wait for cascade submission."""
930 + """Primary order commits immediately; secondary cleanup waits."""
931 chunks = to_keys(range(2))
932 ctx = ReqContext(req_id="req_delayed_secondary")
933 calls: list[tuple[str, str]] = []
vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py
+27 -43
@@ -427,8 +427,11 @@ class RequestOffloadState:
427 None,
428 group_config.hashes_per_chunk,
429 ):
430 - group_state.offload_keys.append(
431 - make_offload_key(req_block_hash, group_config.group_idx)
430 + key = make_offload_key(req_block_hash, group_config.group_idx)
431 + group_state.offload_keys.append(key)
432 + self.req_context.set_offload_key_position(
433 + key,
434 + len(group_state.offload_keys) * group_config.tokens_per_chunk,
435 )
436
437 def update_block_id_groups(
@@ -742,37 +745,6 @@ class OffloadingConnectorScheduler:
745 )
746 return None if defer_lookup or pending_in_window else consecutive_hits
747
745 - def _touch(self, req_status: RequestOffloadState):
746 - for group_config, group_state in zip(
747 - self.config.kv_group_configs, req_status.group_states
748 - ):
749 - if group_config.sliding_window_size_in_chunks is None:
750 - self.manager.touch(group_state.offload_keys, req_status.req_context)
751 - else:
752 - # Keep only chunks needed to hit the original request, plus
753 - # decoded chunks.
754 - chunks_to_skip = max(
755 - 0,
756 - group_state.num_hit_chunks
757 - - group_config.sliding_window_size_in_chunks,
758 - )
759 - self.manager.touch(
760 - group_state.offload_keys[chunks_to_skip:],
761 - req_status.req_context,
762 - )
763 - if req_status.partial_tail_boundary is not None:
764 - self.manager.touch(
765 - tuple(
766 - self._make_boundary_key(
767 - req_status.req,
768 - group.group_idx,
769 - req_status.partial_tail_boundary,
770 - )
771 - for group in self.config.kv_group_configs
772 - ),
773 - req_status.req_context,
774 - )
775 -
748 def _lookup_complete_chunks(
749 self,
750 req_status: RequestOffloadState,
@@ -977,10 +949,16 @@ class OffloadingConnectorScheduler:
949 return num_hit_tokens
950
951 def _make_boundary_key(
980 - self, request: Request, group_idx: int, boundary_tokens: int
952 + self,
953 + request: Request,
954 + group_idx: int,
955 + boundary_tokens: int,
956 + req_context: ReqContext,
957 ) -> OffloadKey:
958 hash_idx = boundary_tokens // self.config.tokens_per_hash - 1
983 - return make_offload_key(request.block_hashes[hash_idx], group_idx)
959 + key = make_offload_key(request.block_hashes[hash_idx], group_idx)
960 + req_context.set_offload_key_position(key, boundary_tokens)
961 + return key
962
963 def _lookup(
964 self,
@@ -1015,7 +993,10 @@ class OffloadingConnectorScheduler:
993 boundary_keys = []
994 for group_config in self.config.kv_group_configs:
995 key = self._make_boundary_key(
1018 - req_status.req, group_config.group_idx, boundary
996 + req_status.req,
997 + group_config.group_idx,
998 + boundary,
999 + req_status.req_context,
1000 )
1001 boundary_keys.append(key)
1002 result = self.manager.lookup(key, req_status.req_context)
@@ -1108,8 +1089,6 @@ class OffloadingConnectorScheduler:
1089 self._maybe_observe_lookup_async_delay(req_status)
1090 req_status.update_num_hit_chunks(num_computed_tokens + (num_hit_tokens or 0))
1091
1111 - self._touch(req_status)
1112 -
1092 return num_hit_tokens, bool(num_hit_tokens)
1093
1094 def update_state_after_alloc(
@@ -1183,7 +1162,10 @@ class OffloadingConnectorScheduler:
1162 if partial_tail_boundary is not None:
1163 keys_to_load.append(
1164 self._make_boundary_key(
1186 - request, group_config.group_idx, partial_tail_boundary
1165 + request,
1166 + group_config.group_idx,
1167 + partial_tail_boundary,
1168 + req_status.req_context,
1169 )
1170 )
1171
@@ -1307,7 +1289,9 @@ class OffloadingConnectorScheduler:
1289 ):
1290 continue
1291
1310 - key = self._make_boundary_key(req, group_idx, boundary)
1292 + key = self._make_boundary_key(
1293 + req, group_idx, boundary, req_status.req_context
1294 + )
1295 store_output = self.manager.prepare_store([key], req_status.req_context)
1296 if store_output is None:
1297 self._connector_stats.increase_counter(
@@ -1402,7 +1386,9 @@ class OffloadingConnectorScheduler:
1386 ):
1387 continue
1388 keys = [
1405 - self._make_boundary_key(req, group.group_idx, boundary)
1389 + self._make_boundary_key(
1390 + req, group.group_idx, boundary, req_status.req_context
1391 + )
1392 for group in self.config.kv_group_configs
1393 ]
1394 block_ids = [
@@ -1689,8 +1675,6 @@ class OffloadingConnectorScheduler:
1675 req_status.advance_stored_idx(num_offloadable_tokens)
1676 continue
1677
1692 - self._touch(req_status)
1693 -
1678 keys_to_store = set(store_output.keys_to_store)
1679
1680 group_sizes: list[int] = []
vllm/v1/kv_offload/base.py
+12
@@ -96,6 +96,12 @@ class ReqContext:
96 # kv_transfer_params once (in on_new_request) and read the result back
97 # on later calls for the same request.
98 _state: dict[type, Any] = field(default_factory=dict, repr=False, init=False)
99 + # End-token position for each key in this request. The scheduler records
100 + # these positions so managers can recover prefix order even when store
101 + # calls arrive out of order (for example, SWA backfills).
102 + _offload_key_positions: dict[OffloadKey, int] = field(
103 + default_factory=dict, repr=False, init=False
104 + )
105
106 def set_state(self, val: Any) -> None:
107 self._state[type(val)] = val
@@ -103,6 +109,12 @@ class ReqContext:
109 def get_state(self, cls: type[_T]) -> _T | None:
110 return self._state.get(cls)
111
112 + def set_offload_key_position(self, key: OffloadKey, end_token: int) -> None:
113 + self._offload_key_positions[key] = end_token
114 +
115 + def get_offload_key_position(self, key: OffloadKey) -> int | None:
116 + return self._offload_key_positions.get(key)
117 +
118
119 class LookupResult(Enum):
120 """Result of OffloadingManager.lookup()."""
vllm/v1/kv_offload/cpu/manager.py
+141 -5
@@ -2,6 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 from collections import OrderedDict
4 from collections.abc import Collection, Iterable
5 +from dataclasses import dataclass, field
6
7 from typing_extensions import override
8
@@ -18,6 +19,7 @@ from vllm.v1.kv_offload.base import (
19 PrepareStoreOutput,
20 ReqContext,
21 RequestOffloadingContext,
22 + get_offload_group_idx,
23 )
24 from vllm.v1.kv_offload.cpu.common import (
25 CPULoadStoreSpec,
@@ -27,6 +29,20 @@ from vllm.v1.kv_offload.cpu.policies.base import CachePolicy, ChunkStatus
29 from vllm.v1.kv_offload.cpu.policies.factory import CachePolicyFactory
30
31
32 +@dataclass(slots=True)
33 +class _RequestCacheAccess:
34 + """Cache keys observed by one request, grouped in prefix order."""
35 +
36 + owner: object
37 + cache_generation: int
38 + key_groups: dict[int, list[OffloadKey]] = field(default_factory=dict)
39 + seen_keys: set[OffloadKey] = field(default_factory=set)
40 + inserted_keys: set[OffloadKey] = field(default_factory=set)
41 + reused_keys: set[OffloadKey] = field(default_factory=set)
42 + store_miss_keys: set[OffloadKey] = field(default_factory=set)
43 + finished: bool = False
44 +
45 +
46 class CPUOffloadingManager(OffloadingManager):
47 """
48 An OffloadingManager with a pluggable CachePolicy, resolved by name via
@@ -66,6 +82,7 @@ class CPUOffloadingManager(OffloadingManager):
82 self.max_tracker_size: int = max_tracker_size
83 self.stores_skipped_in_current_batch: int = 0
84 self.allocation_sizes_in_current_batch: list[int] = []
85 + self._cache_generation = 0
86
87 # Number of chunk references. It is ordered so can evict the LRU entry in O(1).
88 self.counts: OrderedDict[OffloadKey, int] | None = (
@@ -124,10 +141,48 @@ class CPUOffloadingManager(OffloadingManager):
141 num_unprotected -= 1
142 self.counts[key] = 1
143
144 + def _get_request_cache_access(self, req_context: ReqContext) -> _RequestCacheAccess:
145 + state = req_context.get_state(_RequestCacheAccess)
146 + if (
147 + state is None
148 + or state.owner is not self
149 + or state.cache_generation != self._cache_generation
150 + ):
151 + state = _RequestCacheAccess(
152 + owner=self, cache_generation=self._cache_generation
153 + )
154 + req_context.set_state(state)
155 + return state
156 +
157 + def _record_request_cache_access(
158 + self,
159 + keys: Iterable[OffloadKey],
160 + req_context: ReqContext,
161 + inserted_keys: Iterable[OffloadKey] = (),
162 + reused_keys: Iterable[OffloadKey] = (),
163 + ) -> None:
164 + state = self._get_request_cache_access(req_context)
165 + for key in keys:
166 + if key in state.seen_keys:
167 + continue
168 + assert not state.finished, (
169 + "New cache keys observed after request finalization"
170 + )
171 + group_idx = get_offload_group_idx(key)
172 + state.key_groups.setdefault(group_idx, []).append(key)
173 + state.seen_keys.add(key)
174 + state.inserted_keys.update(inserted_keys)
175 + # Re-reading a chunk inserted by this request is an internal transfer
176 + # (for example, a tiering cascade), not a second cache access.
177 + state.reused_keys.update(
178 + key for key in reused_keys if key not in state.inserted_keys
179 + )
180 +
181 # --- OffloadingManager interface ---
182
183 @override
184 def on_new_request(self, req_context: ReqContext) -> RequestOffloadingContext:
185 + self._get_request_cache_access(req_context)
186 return RequestOffloadingContext()
187
188 @override
@@ -144,6 +199,15 @@ class CPUOffloadingManager(OffloadingManager):
199 self,
200 keys: Collection[OffloadKey],
201 req_context: ReqContext,
202 + ) -> LoadStoreSpec:
203 + return self._prepare_load(keys, req_context, record_access=True)
204 +
205 + def _prepare_load(
206 + self,
207 + keys: Collection[OffloadKey],
208 + req_context: ReqContext,
209 + *,
210 + record_access: bool,
211 ) -> LoadStoreSpec:
212 chunks = []
213 for key in keys:
@@ -156,6 +220,8 @@ class CPUOffloadingManager(OffloadingManager):
220 assert self._num_evictable_cache_chunks >= 0
221 chunk.ref_cnt += 1
222 chunks.append(chunk)
223 + if record_access:
224 + self._record_request_cache_access(keys, req_context, reused_keys=keys)
225 return self._get_load_store_spec(keys, chunks)
226
227 @override
@@ -181,13 +247,52 @@ class CPUOffloadingManager(OffloadingManager):
247 keys: Collection[OffloadKey],
248 req_context: ReqContext,
249 ) -> PrepareStoreOutput | None:
250 + keys = list(keys)
251 if self.counts is not None:
185 - num_keys = len(keys)
252 self._record_accesses(keys)
187 - keys = [k for k in keys if self.counts.get(k, 0) >= self.store_threshold]
188 - self.stores_skipped_in_current_batch += num_keys - len(keys)
189 - # filter out chunks that are already stored
190 - keys_to_store = [k for k in keys if self._policy.get(k) is None]
253 + # Partition the original offer before admission filtering. Ready
254 + # resident chunks are request reuses even if their tracker entry was
255 + # aged out; the threshold applies only to new store candidates.
256 + keys_to_store: list[OffloadKey] = []
257 + ready_existing_keys: list[OffloadKey] = []
258 + for key in keys:
259 + chunk = self._policy.get(key)
260 + if chunk is None:
261 + keys_to_store.append(key)
262 + else:
263 + if chunk.is_ready:
264 + ready_existing_keys.append(key)
265 +
266 + if self.counts is not None:
267 + num_store_candidates = len(keys_to_store)
268 + keys_to_store = [
269 + key
270 + for key in keys_to_store
271 + if self.counts.get(key, 0) >= self.store_threshold
272 + ]
273 + self.stores_skipped_in_current_batch += num_store_candidates - len(
274 + keys_to_store
275 + )
276 +
277 + state = self._get_request_cache_access(req_context)
278 + new_store_misses = [
279 + key for key in keys_to_store if key not in state.store_miss_keys
280 + ]
281 + if new_store_misses:
282 + # ARC learns from B1/B2 before insert() removes the ghost entry.
283 + # Deduplication makes this one policy observation per request.
284 + self._policy.on_store_miss(new_store_misses, req_context)
285 + state.store_miss_keys.update(new_store_misses)
286 +
287 + recorded_keys = set(keys_to_store)
288 + recorded_keys.update(ready_existing_keys)
289 + # Record once before any allocation-related early return. Store
290 + # candidates are classified as insertions only after allocation succeeds.
291 + self._record_request_cache_access(
292 + (key for key in keys if key in recorded_keys),
293 + req_context,
294 + reused_keys=ready_existing_keys,
295 + )
296
297 if not keys_to_store:
298 return PrepareStoreOutput(
@@ -239,6 +344,7 @@ class CPUOffloadingManager(OffloadingManager):
344 for key, chunk in zip(keys_to_store, chunks):
345 self._policy.insert(key, chunk)
346 self._num_write_pending_chunks += len(keys_to_store)
347 + state.inserted_keys.update(keys_to_store)
348
349 # build store specs for allocated chunks
350 store_spec = self._get_load_store_spec(keys_to_store, chunks)
@@ -284,6 +390,35 @@ class CPUOffloadingManager(OffloadingManager):
390 )
391 )
392
393 + @override
394 + def on_request_finished(self, req_context: ReqContext) -> None:
395 + state = req_context.get_state(_RequestCacheAccess)
396 + if (
397 + state is None
398 + or state.owner is not self
399 + or state.cache_generation != self._cache_generation
400 + or state.finished
401 + ):
402 + return
403 + state.finished = True
404 + key_groups = []
405 + for group_idx in sorted(state.key_groups):
406 + keys = state.key_groups[group_idx]
407 + positions = {
408 + key: position
409 + for key in keys
410 + if (position := req_context.get_offload_key_position(key)) is not None
411 + }
412 + if len(positions) == len(keys):
413 + keys = sorted(keys, key=positions.__getitem__)
414 + key_groups.append(tuple(keys))
415 + self._policy.on_request_finished(
416 + tuple(key_groups),
417 + state.inserted_keys - state.reused_keys,
418 + state.reused_keys,
419 + req_context,
420 + )
421 +
422 @override
423 def reset_cache(self) -> None:
424 # Clear ALL chunks unconditionally. The scheduler's _stale_job_threshold
@@ -292,6 +427,7 @@ class CPUOffloadingManager(OffloadingManager):
427 # flushes in-flight load job IDs to the workers before any new stores
428 # can begin, preventing a cross-direction data race on reused offload chunk IDs.
429 self._policy.clear()
430 + self._cache_generation += 1
431 self._num_evictable_cache_chunks = 0
432 self._num_write_pending_chunks = 0
433
vllm/v1/kv_offload/cpu/policies/arc.py
+59 -22
@@ -1,12 +1,16 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 from collections import OrderedDict
4 -from collections.abc import Iterable, Iterator
4 +from collections.abc import Iterable, Iterator, Sequence
5
6 from typing_extensions import override
7
8 from vllm.v1.kv_offload.base import OffloadKey, ReqContext
9 -from vllm.v1.kv_offload.cpu.policies.base import CachePolicy, ChunkStatus
9 +from vllm.v1.kv_offload.cpu.policies.base import (
10 + CachePolicy,
11 + ChunkStatus,
12 + order_request_keys,
13 +)
14
15
16 class ARCCachePolicy(CachePolicy):
@@ -24,12 +28,11 @@ class ARCCachePolicy(CachePolicy):
28 Searches T1 and T2 for chunk hashes and counts consecutive hits
29 until a miss or non-ready chunk is encountered.
30
27 - 2. Cache touch (touch) - Adaptive Learning:
28 - For each key (in reverse order):
29 - - If in T1: Move to T2 (promotion from recent to frequent).
30 - - If in T2: Move to MRU position (end of queue).
31 - - If in B1 ghost list: Increase target_t1_size.
32 - - If in B2 ghost list: Decrease target_t1_size.
31 + 2. Request access - Adaptive Learning:
32 + - Ready chunks reused by a request move from T1 to T2 once, or
33 + move to the MRU end of T2.
34 + - B1/B2 misses adjust target_t1_size once per request before
35 + insertion removes their ghost entries.
36
37 3. Chunk eviction (evict) - Adaptive Replacement:
38 Determines eviction source based on adaptive target:
@@ -39,7 +42,7 @@ class ARCCachePolicy(CachePolicy):
42
43 4. Chunk insertion (insert):
44 New chunks are always inserted into T1 and removed from B1/B2 if
42 - present. Chunks may later be promoted to T2 during touch operations.
45 + present. A later request reuse may promote them to T2.
46
47 Adaptive Behavior:
48 The algorithm self-tunes the recency vs. frequency trade-off:
@@ -71,6 +74,26 @@ class ARCCachePolicy(CachePolicy):
74 if self.t1.pop(key, None) is None:
75 self.t2.pop(key, None)
76
77 + def _adapt_to_ghost_hit(self, key: OffloadKey) -> bool:
78 + if key in self.b1:
79 + delta = max(1, len(self.b2) / len(self.b1))
80 + self.target_t1_size = min(self.target_t1_size + delta, self.cache_capacity)
81 + self.b1.move_to_end(key)
82 + return True
83 + if key in self.b2:
84 + delta = max(1, len(self.b1) / len(self.b2))
85 + self.target_t1_size = max(self.target_t1_size - delta, 0)
86 + self.b2.move_to_end(key)
87 + return True
88 + return False
89 +
90 + @override
91 + def on_store_miss(
92 + self, keys: Iterable[OffloadKey], req_context: ReqContext
93 + ) -> None:
94 + for key in reversed(list(keys)):
95 + self._adapt_to_ghost_hit(key)
96 +
97 @override
98 def touch(self, keys: Iterable[OffloadKey], req_context: ReqContext) -> None:
99 for key in reversed(list(keys)):
@@ -86,19 +109,33 @@ class ARCCachePolicy(CachePolicy):
109 elif key in self.t2:
110 self.t2.move_to_end(key)
111
89 - elif key in self.b1:
90 - delta = max(1, len(self.b2) / len(self.b1))
91 - self.target_t1_size = min(
92 - self.target_t1_size + delta, self.cache_capacity
93 - )
94 - # move to MRU position (end) to keep it fresh in the ghost list
95 - self.b1.move_to_end(key)
96 -
97 - elif key in self.b2:
98 - delta = max(1, len(self.b1) / len(self.b2))
99 - self.target_t1_size = max(self.target_t1_size - delta, 0)
100 - # move to MRU position (end) to keep it fresh in the ghost list
101 - self.b2.move_to_end(key)
112 + else:
113 + self._adapt_to_ghost_hit(key)
114 +
115 + @override
116 + def on_request_finished(
117 + self,
118 + key_groups: Sequence[Sequence[OffloadKey]],
119 + insertion_only_keys: set[OffloadKey],
120 + reused_keys: set[OffloadKey],
121 + req_context: ReqContext,
122 + ) -> None:
123 + for key in reversed(order_request_keys(key_groups, req_context)):
124 + if key in insertion_only_keys:
125 + # A store is not a frequency hit. Preserve T1 membership
126 + # while still restoring tail-to-head recency.
127 + if key in self.t1:
128 + self.t1.move_to_end(key)
129 + elif key in self.t2:
130 + self.t2.move_to_end(key)
131 + continue
132 +
133 + # Ready chunks reused by this request count as one access.
134 + if key in reused_keys and key in self.t1:
135 + chunk = self.t1.pop(key)
136 + self.t2[key] = chunk
137 + elif key in reused_keys and key in self.t2:
138 + self.t2.move_to_end(key)
139
140 @override
141 def clear(self) -> None:
vllm/v1/kv_offload/cpu/policies/base.py
+51 -1
@@ -2,11 +2,26 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import ctypes
4 from abc import ABC, abstractmethod
5 -from collections.abc import Iterable
5 +from collections.abc import Iterable, Sequence
6
7 from vllm.v1.kv_offload.base import OffloadKey, ReqContext
8
9
10 +def order_request_keys(
11 + key_groups: Sequence[Sequence[OffloadKey]], req_context: ReqContext
12 +) -> list[OffloadKey]:
13 + """Return one head-to-tail order across all KV cache groups."""
14 + keys = [key for group in key_groups for key in group]
15 + positions = {
16 + key: position
17 + for key in keys
18 + if (position := req_context.get_offload_key_position(key)) is not None
19 + }
20 + if len(positions) == len(keys):
21 + keys.sort(key=positions.__getitem__)
22 + return keys
23 +
24 +
25 class ChunkStatus(ctypes.Structure):
26 """
27 Offloading status for a single chunk of KV data.
@@ -66,6 +81,41 @@ class CachePolicy(ABC):
81 req_context: Per-request context for the request touching these chunks.
82 """
83
84 + def on_store_miss(
85 + self, keys: Iterable[OffloadKey], req_context: ReqContext
86 + ) -> None:
87 + """Observe store misses before their cache entries are inserted.
88 +
89 + The default delegates to ``touch`` for compatibility with external
90 + policies. Policies may override this hook when a miss has distinct
91 + semantics, such as ARC adapting to a ghost-list hit.
92 + """
93 + self.touch(keys, req_context)
94 +
95 + def on_request_finished(
96 + self,
97 + key_groups: Sequence[Sequence[OffloadKey]],
98 + insertion_only_keys: set[OffloadKey],
99 + reused_keys: set[OffloadKey],
100 + req_context: ReqContext,
101 + ) -> None:
102 + """Apply one request-scoped cache access in prefix order.
103 +
104 + ``key_groups`` contains keys observed for each KV cache group in
105 + head-to-tail order. ``insertion_only_keys`` and ``reused_keys``
106 + distinguish chunks only created by this request from ready chunks it
107 + actually reused, which matters for policies such as ARC where reuse
108 + changes frequency but insertion and pending observations do not.
109 +
110 + The default forwards one touch per group, preserving compatibility
111 + for experimental out-of-tree policies while moving those touches to
112 + request finalization. Policies that distinguish insertion from reuse
113 + can override this hook and inspect the access classifications.
114 + """
115 + del insertion_only_keys, reused_keys
116 + for keys in key_groups:
117 + self.touch(keys, req_context)
118 +
119 @abstractmethod
120 def evict(
121 self, n: int, protected: set[OffloadKey]
vllm/v1/kv_offload/cpu/policies/lru.py
+113 -29
@@ -1,28 +1,74 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -from collections import OrderedDict
4 -from collections.abc import Iterable
3 +import heapq
4 +from collections.abc import Iterable, Sequence
5
6 from typing_extensions import override
7
8 from vllm.v1.kv_offload.base import OffloadKey, ReqContext
9 -from vllm.v1.kv_offload.cpu.policies.base import CachePolicy, ChunkStatus
9 +from vllm.v1.kv_offload.cpu.policies.base import (
10 + CachePolicy,
11 + ChunkStatus,
12 + order_request_keys,
13 +)
14
15
16 class LRUCachePolicy(CachePolicy):
17 """
14 - LRU Caching policy that keeps a dedicated evictable list for fast eviction.
18 + LRU caching policy with logical recency independent of transfer pinning.
19 +
20 + Evictable chunks live in a lazy-invalidating min-heap. A chunk's recency
21 + can therefore be updated while it is pinned; when it later becomes
22 + evictable, it enters the heap with the order assigned by the request
23 + rather than its transfer-completion time.
24 +
25 A use is indicated by,
26 - First time the key is added (store).
17 - - Load job completion
18 - - touch
27 + - A request-scoped access.
28 """
29
30 def __init__(self, cache_capacity: int):
31 super().__init__(cache_capacity)
23 - # Chunks with ref_cnt 0 (not participating in any loads/stores) ordered in LRU
24 - self.evictable_chunks: OrderedDict[OffloadKey, None] = OrderedDict()
32 self.chunks: dict[OffloadKey, ChunkStatus] = {}
33 + self._ranks: dict[OffloadKey, int] = {}
34 + self._evictable: set[OffloadKey] = set()
35 + self._heap: list[tuple[int, OffloadKey]] = []
36 + self._next_rank = 0
37 +
38 + def _assign_new_rank(self, key: OffloadKey) -> bool:
39 + if key not in self.chunks:
40 + return False
41 + self._next_rank += 1
42 + self._ranks[key] = self._next_rank
43 + return key in self._evictable
44 +
45 + def _push_evictable(self, key: OffloadKey) -> None:
46 + heapq.heappush(
47 + self._heap,
48 + (self._ranks[key], key),
49 + )
50 +
51 + def _is_current(self, entry: tuple[int, OffloadKey]) -> bool:
52 + rank, key = entry
53 + return key in self._evictable and self._ranks.get(key) == rank
54 +
55 + def _maybe_compact_heap(self) -> None:
56 + if len(self._heap) < max(64, 2 * len(self._evictable)):
57 + return
58 + self._heap = [(self._ranks[key], key) for key in self._evictable]
59 + heapq.heapify(self._heap)
60 +
61 + def _update_recency(self, keys: Iterable[OffloadKey]) -> None:
62 + updated_evictable = [key for key in keys if self._assign_new_rank(key)]
63 + # Rebuilding is linear and substantially cheaper than k heap pushes
64 + # for a long prefix. Small updates retain the incremental path.
65 + if len(updated_evictable) >= max(64, len(self._evictable) // 4):
66 + self._heap = [(self._ranks[key], key) for key in self._evictable]
67 + heapq.heapify(self._heap)
68 + else:
69 + for key in updated_evictable:
70 + self._push_evictable(key)
71 + self._maybe_compact_heap()
72
73 @override
74 def get(self, key: OffloadKey) -> ChunkStatus | None:
@@ -31,26 +77,40 @@ class LRUCachePolicy(CachePolicy):
77 @override
78 def insert(self, key: OffloadKey, chunk: ChunkStatus) -> None:
79 self.chunks[key] = chunk
80 + self._next_rank += 1
81 + self._ranks[key] = self._next_rank
82 if chunk.ref_cnt == 0:
35 - self.evictable_chunks[key] = None
83 + self._evictable.add(key)
84 + self._push_evictable(key)
85
86 @override
87 def remove(self, key: OffloadKey) -> None:
88 del self.chunks[key]
40 - self.evictable_chunks.pop(key, None)
89 + self._ranks.pop(key, None)
90 + self._evictable.discard(key)
91
92 @override
93 def touch(self, keys: Iterable[OffloadKey], req_context: ReqContext) -> None:
44 - for key in reversed(list(keys)):
45 - if key in self.evictable_chunks:
46 - self.evictable_chunks.move_to_end(key)
47 - # active chunks are untouched as they are non-evictable now. They
48 - # will eventually reach the end of evictable_chunks when they finish.
94 + self._update_recency(reversed(list(keys)))
95 +
96 + @override
97 + def on_request_finished(
98 + self,
99 + key_groups: Sequence[Sequence[OffloadKey]],
100 + insertion_only_keys: set[OffloadKey],
101 + reused_keys: set[OffloadKey],
102 + req_context: ReqContext,
103 + ) -> None:
104 + del insertion_only_keys, reused_keys
105 + self._update_recency(reversed(order_request_keys(key_groups, req_context)))
106
107 @override
108 def clear(self) -> None:
52 - self.evictable_chunks.clear()
109 self.chunks.clear()
110 + self._ranks.clear()
111 + self._evictable.clear()
112 + self._heap.clear()
113 + self._next_rank = 0
114
115 @override
116 def evict(
@@ -59,22 +119,44 @@ class LRUCachePolicy(CachePolicy):
119 if n == 0:
120 return []
121
62 - candidates: list[tuple[OffloadKey, ChunkStatus]] = []
63 - for key, _ in self.evictable_chunks.items():
122 + selected: list[tuple[tuple[int, OffloadKey], ChunkStatus]] = []
123 + selected_keys: set[OffloadKey] = set()
124 + deferred: list[tuple[int, OffloadKey]] = []
125 + while self._heap and len(selected) < n:
126 + entry = heapq.heappop(self._heap)
127 + if not self._is_current(entry):
128 + continue
129 + key = entry[1]
130 + # Re-pinning without a recency change can leave an equivalent
131 + # lazy entry behind. Select each cache key at most once.
132 + if key in selected_keys:
133 + continue
134 if key in protected:
135 + deferred.append(entry)
136 continue
66 -
137 chunk = self.chunks[key]
138 assert chunk.ref_cnt == 0
69 - candidates.append((key, chunk))
70 - if len(candidates) == n:
71 - break
72 -
73 - if len(candidates) < n:
139 + selected.append((entry, chunk))
140 + selected_keys.add(key)
141 +
142 + if len(selected) < n:
143 + for entry, _ in selected:
144 + heapq.heappush(self._heap, entry)
145 + for entry in deferred:
146 + heapq.heappush(self._heap, entry)
147 return None
75 - for key, _ in candidates:
76 - del self.evictable_chunks[key]
148 +
149 + for entry in deferred:
150 + heapq.heappush(self._heap, entry)
151 +
152 + candidates: list[tuple[OffloadKey, ChunkStatus]] = []
153 + for entry, chunk in selected:
154 + key = entry[1]
155 + self._evictable.remove(key)
156 del self.chunks[key]
157 + del self._ranks[key]
158 + candidates.append((key, chunk))
159 + self._maybe_compact_heap()
160 return candidates
161
162 @override
@@ -82,9 +164,11 @@ class LRUCachePolicy(CachePolicy):
164 # chunks can become evictable when,
165 # store completes - i.e. ref_cnt -1 -> 0 # not in evictable list
166 # all loads complete - i.e ref_cnt 1 -> 0 # not in evictable list
85 - self.evictable_chunks[key] = None
167 + self._evictable.add(key)
168 + self._push_evictable(key)
169 + self._maybe_compact_heap()
170
171 @override
172 def mark_non_evictable(self, key: OffloadKey) -> None:
89 - # key must have been in the evictable list.
90 - del self.evictable_chunks[key]
173 + # key must have been evictable before it was pinned.
174 + self._evictable.remove(key)
vllm/v1/kv_offload/tiering/manager.py
+13 -5
@@ -110,13 +110,22 @@ class CPUPrimaryTierOffloadingManager(CPUOffloadingManager):
110 # read/write is for CPU<->secondary transfers,
111 # load/store is for CPU<->GPU transfers.
112 # These aliases avoid calling prepare_load inside a store path.
113 - self.prepare_read = self.prepare_load
113 self.complete_read = self.complete_load
114 self.prepare_write = self.prepare_store
115 self.complete_write = self.complete_store
116
117 self._kv_memoryview = mmap_region.create_kv_memoryview()
118
119 + def prepare_read(
120 + self, keys: Collection[OffloadKey], req_context: ReqContext
121 + ) -> LoadStoreSpec:
122 + """Pin chunks for a CPU-to-secondary transfer.
123 +
124 + Cascade reads are implementation details of tiering, not additional
125 + request accesses, so they must not alter request-scoped recency.
126 + """
127 + return self._prepare_load(keys, req_context, record_access=False)
128 +
129 def get_kv_memoryview(self) -> memoryview:
130 """Return the memoryview over the primary tier's KV cache buffer.
131
@@ -781,11 +790,10 @@ class TieringOffloadingManager(OffloadingManager):
790 req_id: str,
791 exclude_tier_idx: int | None = None,
792 ) -> None:
784 - """Finalize secondary tiers once no more store cascades can be submitted.
793 + """Finalize secondary tiers once no more cascades can be submitted.
794
786 - Finalization means forwarding on_request_finished() to secondary tiers.
787 - It is delayed until pending GPU->primary stores finish, since their
788 - complete_store() callbacks may still submit primary->secondary stores.
795 + Their finalization is delayed until pending GPU->primary stores
796 + finish, since those callbacks may still submit secondary stores.
797 """
798 state = self._req_state[req_id]
799 if not state.is_finished: