[Bugfix][KV Offloading] Fix eager SimpleCPUOffload cache registration and final flush (#53532)

Signed-off-by: maithilijoshi20 <maithilijoshi2003@gmail.com> Signed-off-by: maithilijoshi20 <97733343+maithilijoshi20@users.noreply.github.com> Co-authored-by: maithilijoshi20 <maithilijoshi2003@gmail.com> Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: Yifan Qiao <yifanqiao@inferact.ai>

maithilijoshi20 committed Sep 2, 2026 at 12:23 UTC 12b9573c98c32932e83fdb22e22c152b248c8c88
3 files changed +354 -94
tests/v1/simple_kv_offload/test_scheduler.py
+248 -6
@@ -18,6 +18,9 @@ from vllm.config import (
18 SchedulerConfig,
19 VllmConfig,
20 )
21 +from vllm.distributed.kv_transfer.kv_connector.v1.simple_cpu_offload_connector import (
22 + SimpleCPUOffloadConnector,
23 +)
24 from vllm.utils.hashing import sha256
25 from vllm.v1.core.block_pool import BlockPool
26 from vllm.v1.core.kv_cache_manager import KVCacheBlocks
@@ -39,12 +42,16 @@ from vllm.v1.kv_cache_interface import (
42 KVCacheConfig,
43 KVCacheGroupSpec,
44 KVCacheTensor,
45 + SlidingWindowSpec,
46 )
47 from vllm.v1.outputs import KVConnectorOutput
48 from vllm.v1.request import Request
49 from vllm.v1.simple_kv_offload.manager import SimpleCPUOffloadScheduler
50 from vllm.v1.simple_kv_offload.metadata import SimpleCPUOffloadWorkerMetadata
51
52 +pytestmark = pytest.mark.skip_global_cleanup
53 +
54 +
55 # ---------------------------------------------------------------------------
56 # Constants
57 # ---------------------------------------------------------------------------
@@ -77,15 +84,26 @@ def _make_kv_cache_config(
84 ) # Ensure specs are registered for tests
85 for g in range(num_groups):
86 layer_names = [f"layer_{g}"]
87 + spec = (
88 + FullAttentionSpec(
89 + block_size=BLOCK_SIZE,
90 + num_kv_heads=NUM_KV_HEADS,
91 + head_size=HEAD_SIZE,
92 + dtype=DTYPE,
93 + )
94 + if g == 0
95 + else SlidingWindowSpec(
96 + block_size=BLOCK_SIZE,
97 + num_kv_heads=NUM_KV_HEADS,
98 + head_size=HEAD_SIZE,
99 + dtype=DTYPE,
100 + sliding_window=BLOCK_SIZE * 4,
101 + )
102 + )
103 groups.append(
104 KVCacheGroupSpec(
105 layer_names,
83 - FullAttentionSpec(
84 - block_size=BLOCK_SIZE,
85 - num_kv_heads=NUM_KV_HEADS,
86 - head_size=HEAD_SIZE,
87 - dtype=DTYPE,
88 - ),
106 + spec,
107 )
108 )
109 tensors.append(
@@ -412,6 +430,230 @@ def test_eager_store_and_load_roundtrip() -> None:
430 assert len(meta2.load_cpu_blocks) == len(meta2.load_gpu_blocks)
431
432
433 +def test_eager_store_preserves_secondary_block_hashes() -> None:
434 + """CPU copies retain fine-grained hashes owned by the GPU block."""
435 + fix = make_scheduler(num_cpu_blocks=8, num_gpu_blocks=16, lazy=False)
436 + sched = fix.scheduler
437 + req = make_request(num_blocks=1)
438 + kv_blocks = _alloc_and_register(fix, req, num_blocks=1)
439 + gpu_block = kv_blocks.blocks[0][0]
440 + primary_hash = gpu_block.block_hash
441 + primary_num_tokens = gpu_block.block_hash_num_tokens
442 + assert primary_hash is not None
443 +
444 + fine_grained_req = Request(
445 + request_id="req-fine-grained-hash",
446 + prompt_token_ids=req.prompt_token_ids,
447 + sampling_params=req.sampling_params,
448 + pooling_params=None,
449 + mm_features=None,
450 + block_hasher=get_request_block_hasher(BLOCK_SIZE // 2, sha256),
451 + )
452 + secondary_hash = make_block_hash_with_group_id(fine_grained_req.block_hashes[0], 0)
453 + fix.gpu_block_pool._insert_block_hash(
454 + secondary_hash, gpu_block, num_tokens=BLOCK_SIZE // 2
455 + )
456 + assert (
457 + secondary_hash
458 + in fix.gpu_block_pool.cached_block_hashes_by_block[gpu_block.block_id]
459 + )
460 +
461 + sched.update_state_after_alloc(req, kv_blocks, num_external_tokens=0)
462 + block_ids = kv_blocks.get_block_ids()
463 + meta = sched.build_connector_meta(
464 + make_scheduler_output(
465 + {req.request_id: BLOCK_SIZE},
466 + new_reqs={req.request_id: block_ids},
467 + )
468 + )
469 + simulate_store_completion(sched, meta.store_event)
470 +
471 + cpu_block = sched.cpu_block_pool.cached_block_hash_to_block.get_one_block(
472 + secondary_hash
473 + )
474 + assert cpu_block is not None
475 + assert cpu_block.block_hash == primary_hash
476 + assert cpu_block.block_hash_num_tokens == primary_num_tokens
477 + assert (
478 + secondary_hash
479 + in sched.cpu_block_pool.cached_block_hashes_by_block[cpu_block.block_id]
480 + )
481 +
482 +
483 +# ---------------------------------------------------------------------------
484 +# Test 1b: Finished eager stores
485 +# ---------------------------------------------------------------------------
486 +def test_finished_eager_stores_are_batched() -> None:
487 + """Finished requests in one step retain, complete, and cache every store."""
488 + fix = make_scheduler(num_cpu_blocks=8, num_gpu_blocks=16, lazy=False)
489 + sched = fix.scheduler
490 +
491 + requests = [make_request(num_blocks=2) for _ in range(2)]
492 + block_ids = []
493 + for request in requests:
494 + kv_blocks = _alloc_and_register(fix, request, num_blocks=2)
495 + sched.update_state_after_alloc(request, kv_blocks, num_external_tokens=0)
496 + block_ids.append(kv_blocks.get_block_ids())
497 +
498 + for request, request_block_ids in zip(requests, block_ids):
499 + sched.request_finished_all_groups(request, request_block_ids)
500 +
501 + meta = sched.build_connector_meta(make_scheduler_output({}))
502 + assert len(meta.store_gpu_blocks) == 4
503 + assert len(meta.store_cpu_blocks) == 4
504 + simulate_store_completion(sched, meta.store_event)
505 +
506 + for request in requests:
507 + for block_hash in request.block_hashes[:2]:
508 + cached = sched.cpu_block_pool.cached_block_hash_to_block.get_one_block(
509 + make_block_hash_with_group_id(block_hash, 0)
510 + )
511 + assert cached is not None
512 +
513 + request = requests[0]
514 + cached_request = Request(
515 + request_id="req-finished-eager-load",
516 + prompt_token_ids=request.prompt_token_ids,
517 + sampling_params=request.sampling_params,
518 + pooling_params=None,
519 + mm_features=None,
520 + block_hasher=request._block_hasher,
521 + )
522 + hit_tokens, is_async = sched.get_num_new_matched_tokens(
523 + cached_request, num_computed_tokens=0
524 + )
525 + assert hit_tokens == 2 * BLOCK_SIZE
526 + assert is_async is True
527 +
528 + cached_gpu_blocks = fix.gpu_block_pool.get_new_blocks(2)
529 + sched.update_state_after_alloc(
530 + cached_request,
531 + KVCacheBlocks(blocks=(cached_gpu_blocks,)),
532 + num_external_tokens=hit_tokens,
533 + )
534 + load_meta = sched.build_connector_meta(
535 + make_scheduler_output(
536 + {cached_request.request_id: 1},
537 + new_reqs={
538 + cached_request.request_id: (
539 + [block.block_id for block in cached_gpu_blocks],
540 + )
541 + },
542 + )
543 + )
544 + assert load_meta.load_event >= 0
545 + assert len(load_meta.load_gpu_blocks) == 2
546 + assert len(load_meta.load_cpu_blocks) == 2
547 +
548 +
549 +def test_finished_eager_store_skips_existing_cpu_cache_entries() -> None:
550 + """A final flush must not consume CPU capacity for an existing hash."""
551 + fix = make_scheduler(num_cpu_blocks=8, num_gpu_blocks=16, lazy=False)
552 + sched = fix.scheduler
553 +
554 + first = make_request(num_blocks=2)
555 + first_blocks = _alloc_and_register(fix, first, num_blocks=2)
556 + sched.update_state_after_alloc(first, first_blocks, num_external_tokens=0)
557 + sched.request_finished_all_groups(first, first_blocks.get_block_ids())
558 + first_meta = sched.build_connector_meta(make_scheduler_output({}))
559 + simulate_store_completion(sched, first_meta.store_event)
560 + free_blocks = get_cpu_free_blocks(sched)
561 +
562 + duplicate = Request(
563 + request_id="req-finished-eager-duplicate",
564 + prompt_token_ids=first.prompt_token_ids,
565 + sampling_params=first.sampling_params,
566 + pooling_params=None,
567 + mm_features=None,
568 + block_hasher=first._block_hasher,
569 + )
570 + duplicate_blocks = _alloc_and_register(fix, duplicate, num_blocks=2)
571 + sched.update_state_after_alloc(duplicate, duplicate_blocks, num_external_tokens=0)
572 + sched.request_finished_all_groups(duplicate, duplicate_blocks.get_block_ids())
573 +
574 + duplicate_meta = sched.build_connector_meta(make_scheduler_output({}))
575 + assert duplicate_meta.store_event == -1
576 + assert get_cpu_free_blocks(sched) == free_blocks
577 +
578 +
579 +def test_finished_eager_store_is_reported_pending_before_metadata_build() -> None:
580 + """Queued finished stores must keep the connector pending until submitted."""
581 + fix = make_scheduler(num_cpu_blocks=8, num_gpu_blocks=16, lazy=False)
582 + sched = fix.scheduler
583 + request = make_request(num_blocks=2)
584 + kv_blocks = _alloc_and_register(fix, request, num_blocks=2)
585 + connector = SimpleCPUOffloadConnector.__new__(SimpleCPUOffloadConnector)
586 + connector.scheduler_manager = sched
587 + sched.update_state_after_alloc(request, kv_blocks, num_external_tokens=0)
588 +
589 + sched.request_finished_all_groups(request, kv_blocks.get_block_ids())
590 +
591 + assert sched.has_pending_stores()
592 + assert connector.has_pending_push_work()
593 + meta = sched.build_connector_meta(make_scheduler_output({}))
594 + assert meta.store_event >= 0
595 + assert not sched._pending_finished_stores
596 + assert connector.has_pending_push_work()
597 + simulate_store_completion(sched, meta.store_event)
598 + assert not sched.has_pending_stores()
599 + assert not connector.has_pending_push_work()
600 +
601 +
602 +def test_finished_eager_store_caches_all_groups() -> None:
603 + """The final flush retains completed blocks from every KV cache group."""
604 + fix = make_scheduler(num_cpu_blocks=8, num_gpu_blocks=16, num_groups=2, lazy=False)
605 + sched = fix.scheduler
606 + request = make_request(num_blocks=2)
607 + group_blocks = tuple(
608 + _allocate_gpu_blocks(fix.gpu_block_pool, request, 2, group_id=group_id)
609 + for group_id in range(2)
610 + )
611 + request.num_computed_tokens = 2 * BLOCK_SIZE
612 + kv_blocks = KVCacheBlocks(blocks=group_blocks)
613 + sched.update_state_after_alloc(request, kv_blocks, num_external_tokens=0)
614 +
615 + sched.request_finished_all_groups(request, kv_blocks.get_block_ids())
616 + meta = sched.build_connector_meta(make_scheduler_output({}))
617 + assert len(meta.store_gpu_blocks) == 4
618 + simulate_store_completion(sched, meta.store_event)
619 +
620 + for group_id in range(2):
621 + for block_hash in request.block_hashes[:2]:
622 + cached = sched.cpu_block_pool.cached_block_hash_to_block.get_one_block(
623 + make_block_hash_with_group_id(block_hash, group_id)
624 + )
625 + assert cached is not None
626 +
627 +
628 +def test_finished_eager_store_respects_cpu_capacity() -> None:
629 + """A finished request skips excess blocks when the CPU cache is full."""
630 + fix = make_scheduler(num_cpu_blocks=3, num_gpu_blocks=16, lazy=False)
631 + sched = fix.scheduler
632 + request = make_request(num_blocks=3)
633 + kv_blocks = _alloc_and_register(fix, request, num_blocks=3)
634 + sched.update_state_after_alloc(request, kv_blocks, num_external_tokens=0)
635 + available = get_cpu_free_blocks(sched)
636 +
637 + sched.request_finished_all_groups(request, kv_blocks.get_block_ids())
638 + meta = sched.build_connector_meta(make_scheduler_output({}))
639 +
640 + assert len(meta.store_gpu_blocks) == available
641 + assert len(meta.store_cpu_blocks) == available
642 +
643 +
644 +def test_reset_releases_unsubmitted_finished_stores() -> None:
645 + """Reset releases finished-store refs that were not sent to a worker."""
646 + fix = make_scheduler(num_cpu_blocks=8, num_gpu_blocks=16, lazy=False)
647 + sched = fix.scheduler
648 + request = make_request(num_blocks=2)
649 + kv_blocks = _alloc_and_register(fix, request, num_blocks=2)
650 + sched.update_state_after_alloc(request, kv_blocks, num_external_tokens=0)
651 +
652 + sched.request_finished_all_groups(request, kv_blocks.get_block_ids())
653 +
654 + assert sched.reset()
655 +
656 +
657 # ---------------------------------------------------------------------------
658 # Test 1b: Boundary — max_hit_len cap drops the last full block when the
659 # prompt is an exact multiple of BLOCK_SIZE.
vllm/distributed/kv_transfer/kv_connector/v1/simple_cpu_offload_connector.py
+1 -2
@@ -291,8 +291,7 @@ class SimpleCPUOffloadConnector(KVConnectorBase_V1, SupportsHMA):
291 )
292 return False, None
293
294 - # NOTE: New API only for SimpleCPUOffloadConnector.
295 - def has_pending_transfers(self) -> bool:
294 + def has_pending_push_work(self) -> bool:
295 if self.scheduler_manager is not None:
296 return self.scheduler_manager.has_pending_stores()
297 return False
vllm/v1/simple_kv_offload/manager.py
+105 -86
@@ -174,6 +174,7 @@ class SimpleCPUOffloadScheduler:
174 self._reqs_to_store: dict[str, StoreRequestState] = {}
175 self._store_event_to_reqs: dict[int, list[str]] = {}
176 self._in_flight_store_gpu_blocks: set[int] = set()
177 + self._pending_finished_stores: list[TransferMeta] = []
178 self._abandoned_reqs_to_load: dict[str, LoadRequestState] = {}
179
180 # Event counters
@@ -414,6 +415,11 @@ class SimpleCPUOffloadScheduler:
415 # --- Stores ---
416 store_event = -1
417 store_gpu, store_cpu, store_req_ids = self.prepare_store_specs(scheduler_output)
418 + for transfer in self._pending_finished_stores:
419 + store_gpu.extend(transfer.gpu_block_ids)
420 + store_cpu.extend(transfer.cpu_block_ids)
421 + self._pending_finished_stores.clear()
422 +
423 if store_gpu:
424 store_event = self._store_event_counter
425 self._store_event_counter += 1
@@ -492,7 +498,6 @@ class SimpleCPUOffloadScheduler:
498 self._cursor = None
499
500 gpu_ids: list[int] = []
495 - block_hashes: list[bytes] = []
501 last_visited = self._cursor
502
503 for covered, node in enumerate(free_queue.iter_blocks_after(self._cursor)):
@@ -508,16 +513,13 @@ class SimpleCPUOffloadScheduler:
513 and cpu_pool.cached_block_hash_to_block.get_one_block(bhash) is None
514 ):
515 gpu_ids.append(node.block_id)
511 - block_hashes.append(bhash)
516
517 self._cursor = last_visited
518
515 - # Batch-allocate CPU blocks and stamp hashes.
519 + # Batch-allocate CPU blocks.
520 if gpu_ids:
521 cpu_blocks = cpu_pool.get_new_blocks(len(gpu_ids))
522 cpu_ids = [blk.block_id for blk in cpu_blocks]
519 - for cpu_blk, bhash in zip(cpu_blocks, block_hashes): # type: ignore[assignment]
520 - cpu_blk._block_hash = bhash # type: ignore[assignment]
523 # Touch GPU blocks to prevent eviction during async copy.
524 gpu_pool.touch([gpu_pool.blocks[bid] for bid in gpu_ids])
525 else:
@@ -531,9 +533,8 @@ class SimpleCPUOffloadScheduler:
533 """Identify newly computed blocks to offload from scheduler requests.
534
535 Only considers blocks whose KV data has been **confirmed computed** by
534 - the GPU. This means blocks from the current step are NOT stored until the
535 - next step. If a request finishes in the same step as its last full block,
536 - that block may be missed. (TODO: flush on finish.)
536 + the GPU. Blocks from the current step are stored on a later step, or by
537 + the finish-time flush if the request completes first.
538
539 Returns:
540 (gpu_block_ids, cpu_block_ids, req_ids) for the store event.
@@ -547,7 +548,6 @@ class SimpleCPUOffloadScheduler:
548 if gpu_block_pool is None:
549 return [], [], []
550 cpu_block_pool = self.cpu_block_pool
550 - num_free = cpu_block_pool.get_num_free_blocks()
551 kv_cache_groups = self.cpu_kv_cache_config.kv_cache_groups
552 num_groups = len(kv_cache_groups)
553 # Dedup against blocks already scheduled.
@@ -575,73 +575,15 @@ class SimpleCPUOffloadScheduler:
575 if not block_ids_by_group:
576 continue
577
578 - # --- Phase 1: Scan blocks, classify as cached vs to-store ---
579 - gpu_block_ids: list[int] = []
580 - block_hashes_to_store: list[bytes] = []
581 - advanced_per_group: list[int] = [0] * num_groups
582 - out_of_space = False
583 - # Confirmed tokens: KV data written and visible to all streams.
584 - req = state.request
585 - confirmed_tokens = req.num_computed_tokens - req.num_output_placeholders
586 - # Cap to blocks with confirmed KV data.
587 - aligned_tokens = confirmed_tokens // self.block_size * self.block_size
588 -
589 - for g in range(num_groups):
590 - # FIXME (yifan): handle CPU cache eviction, where
591 - # num_stored_blocks can be stale and omit evicted blocks in
592 - # the middle of the request.
593 - already_stored_g = state.num_stored_blocks[g]
594 - group_gpu_ids = block_ids_by_group[g]
595 -
596 - g_block_size = (
597 - kv_cache_groups[g].kv_cache_spec.block_size * self.cp_world_size
598 - )
599 - ready_blocks_g = aligned_tokens // g_block_size
600 - scannable = group_gpu_ids[already_stored_g:ready_blocks_g]
601 -
602 - for gpu_block_id in scannable:
603 - gpu_block = gpu_block_pool.blocks[gpu_block_id]
604 - if gpu_block.is_null:
605 - advanced_per_group[g] += 1
606 - continue
607 -
608 - bhash_with_group = gpu_block.block_hash
609 - if bhash_with_group is None:
610 - # Masked-out SWA position the coordinator chose not to
611 - # hash; it can never serve a prefix-cache hit, so skip.
612 - advanced_per_group[g] += 1
613 - continue
614 -
615 - # Skip if already scheduled for store or already cached in CPU.
616 - if (
617 - gpu_block_id in in_flight
618 - or cpu_block_pool.cached_block_hash_to_block.get_one_block(
619 - bhash_with_group
620 - )
621 - is not None
622 - ):
623 - advanced_per_group[g] += 1
624 - continue
625 -
626 - if num_free <= 0:
627 - out_of_space = True
628 - break
629 - num_free -= 1
630 -
631 - gpu_block_ids.append(gpu_block_id)
632 - block_hashes_to_store.append(bhash_with_group)
633 - advanced_per_group[g] += 1
634 -
635 - if out_of_space:
636 - break
578 + gpu_block_ids, advanced_per_group = self._select_eager_blocks_to_store(
579 + state, block_ids_by_group
580 + )
581
638 - # --- Phase 2: Batch allocate CPU blocks and stamp hashes ---
582 + # --- Phase 2: Batch allocate CPU blocks ---
583 n_to_alloc = len(gpu_block_ids)
584 if n_to_alloc > 0:
585 cpu_blocks_alloc = cpu_block_pool.get_new_blocks(n_to_alloc)
586 cpu_block_ids = [blk.block_id for blk in cpu_blocks_alloc]
643 - for cpu_blk, bhash in zip(cpu_blocks_alloc, block_hashes_to_store):
644 - cpu_blk._block_hash = bhash # type: ignore[assignment]
587 else:
588 cpu_block_ids = []
589
@@ -669,6 +611,53 @@ class SimpleCPUOffloadScheduler:
611
612 return merged_gpu_block_ids, merged_cpu_block_ids, req_ids
613
614 + def _select_eager_blocks_to_store(
615 + self,
616 + state: StoreRequestState,
617 + block_ids_by_group: tuple[list[int], ...],
618 + ) -> tuple[list[int], list[int]]:
619 + """Return confirmed, uncached eager blocks and per-group cursor advances."""
620 + assert self._gpu_block_pool is not None
621 + gpu_block_ids: list[int] = []
622 + advanced_per_group = [0] * len(self.cpu_kv_cache_config.kv_cache_groups)
623 + confirmed_tokens = (
624 + state.request.num_computed_tokens - state.request.num_output_placeholders
625 + )
626 + aligned_tokens = confirmed_tokens // self.block_size * self.block_size
627 + num_free = self.cpu_block_pool.get_num_free_blocks()
628 +
629 + for g, group_gpu_ids in enumerate(block_ids_by_group):
630 + if len(gpu_block_ids) >= num_free:
631 + break
632 + # FIXME (yifan): handle CPU cache eviction, where
633 + # num_stored_blocks can be stale and omit evicted blocks in
634 + # the middle of the request.
635 + group_size = (
636 + self.cpu_kv_cache_config.kv_cache_groups[g].kv_cache_spec.block_size
637 + * self.cp_world_size
638 + )
639 + ready = min(len(group_gpu_ids), aligned_tokens // group_size)
640 + for gpu_block_id in group_gpu_ids[state.num_stored_blocks[g] : ready]:
641 + gpu_block = self._gpu_block_pool.blocks[gpu_block_id]
642 + if gpu_block.is_null or gpu_block.block_hash is None:
643 + advanced_per_group[g] += 1
644 + continue
645 + if (
646 + gpu_block_id in self._in_flight_store_gpu_blocks
647 + or self.cpu_block_pool.cached_block_hash_to_block.get_one_block(
648 + gpu_block.block_hash
649 + )
650 + is not None
651 + ):
652 + advanced_per_group[g] += 1
653 + continue
654 + if len(gpu_block_ids) >= num_free:
655 + break
656 + gpu_block_ids.append(gpu_block_id)
657 + advanced_per_group[g] += 1
658 +
659 + return gpu_block_ids, advanced_per_group
660 +
661 def update_connector_output(self, connector_output: KVConnectorOutput) -> None:
662 """Handle async transfer completions from worker.
663
@@ -726,24 +715,31 @@ class SimpleCPUOffloadScheduler:
715 def _process_store_completion(
716 self, gpu_block_ids: list[int], cpu_block_ids: list[int]
717 ) -> None:
729 - """Cache CPU blocks per-group and release GPU refs.
730 -
731 - Block hashes were stamped on CPU blocks at allocation time (in
732 - ``_prepare_*_store_specs``). Here we just register them in the
733 - cache map so they become discoverable by the load path.
734 - """
718 + """Register copied blocks in the CPU prefix cache and release refs."""
719 assert len(cpu_block_ids) == len(gpu_block_ids)
720
721 cpu_blocks = [self.cpu_block_pool.blocks[bid] for bid in cpu_block_ids]
722
739 - for cpu_block in cpu_blocks:
740 - bhash = cpu_block.block_hash
741 - assert bhash is not None
742 - self.cpu_block_pool.cached_block_hash_to_block.insert(bhash, cpu_block)
723 + assert self._gpu_block_pool is not None
724 + for gpu_block_id, cpu_block in zip(gpu_block_ids, cpu_blocks):
725 + gpu_block = self._gpu_block_pool.blocks[gpu_block_id]
726 + primary_hash = gpu_block.block_hash
727 + assert primary_hash is not None
728 + self.cpu_block_pool._insert_block_hash(
729 + primary_hash,
730 + cpu_block,
731 + num_tokens=gpu_block.block_hash_num_tokens,
732 + )
733 + secondary_hashes = self._gpu_block_pool.cached_block_hashes_by_block.get(
734 + gpu_block_id, ()
735 + )
736 + for block_hash in secondary_hashes:
737 + self.cpu_block_pool._insert_block_hash(
738 + block_hash, cpu_block, num_tokens=None
739 + )
740
741 # Free CPU and GPU blocks' ref counts to turn them into prefix cache
742 self.cpu_block_pool.free_blocks(cpu_blocks)
746 - assert self._gpu_block_pool is not None
743 self._gpu_block_pool.free_blocks(
744 self._gpu_block_pool.blocks[bid] for bid in gpu_block_ids
745 )
@@ -751,8 +747,6 @@ class SimpleCPUOffloadScheduler:
747 def _release_transfer_refs(self, transfer: TransferMeta) -> None:
748 """Release transfer refs without making copied data cacheable."""
749 cpu_blocks = [self.cpu_block_pool.blocks[bid] for bid in transfer.cpu_block_ids]
754 - for cpu_block in cpu_blocks:
755 - cpu_block.reset_hash()
750 self.cpu_block_pool.free_blocks(cpu_blocks)
751 assert self._gpu_block_pool is not None
752 self._gpu_block_pool.free_blocks(
@@ -760,9 +754,11 @@ class SimpleCPUOffloadScheduler:
754 )
755
756 def has_pending_stores(self) -> bool:
763 - """Return True if there are in-flight store transfers."""
757 + """Return True if a store transfer is queued or in flight."""
758 return bool(
765 - self._store_event_to_blocks or self._abandoned_store_event_to_blocks
759 + self._pending_finished_stores
760 + or self._store_event_to_blocks
761 + or self._abandoned_store_event_to_blocks
762 )
763
764 def request_finished(
@@ -804,8 +800,27 @@ class SimpleCPUOffloadScheduler:
800 request: "Request",
801 block_ids: tuple[list[int], ...],
802 ) -> tuple[bool, dict[str, Any] | None]:
803 + self._queue_finished_eager_store(request, block_ids)
804 return self.request_finished(request, block_ids=[])
805
806 + def _queue_finished_eager_store(
807 + self, request: "Request", block_ids: tuple[list[int], ...]
808 + ) -> None:
809 + """Queue confirmed eager blocks omitted after a request enters decode."""
810 + state = self._reqs_to_store.get(request.request_id)
811 + gpu_pool = self._gpu_block_pool
812 + if state is None or gpu_pool is None:
813 + return
814 + gpu_ids, _ = self._select_eager_blocks_to_store(state, block_ids)
815 + if not gpu_ids:
816 + return
817 + cpu_blocks = self.cpu_block_pool.get_new_blocks(len(gpu_ids))
818 + self._pending_finished_stores.append(
819 + TransferMeta(gpu_ids, [b.block_id for b in cpu_blocks])
820 + )
821 + self._in_flight_store_gpu_blocks.update(gpu_ids)
822 + gpu_pool.touch([gpu_pool.blocks[bid] for bid in gpu_ids])
823 +
824 def _free_pending_cpu_hit(self, pending: tuple) -> None:
825 """Release the temporary CPU block pin taken in get_num_new_matched_tokens()."""
826 cpu_hit_blocks, _ = pending
@@ -879,6 +894,10 @@ class SimpleCPUOffloadScheduler:
894 """
895
896 self._abandoned_store_event_to_blocks.update(self._store_event_to_blocks)
897 + for transfer in self._pending_finished_stores:
898 + self._release_transfer_refs(transfer)
899 + self._pending_finished_stores.clear()
900 +
901 self._store_event_to_blocks.clear()
902 self._in_flight_store_gpu_blocks.clear()
903