main
py 1,445 lines 60.5 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Scheduler-side manager for SimpleCPUOffloadConnector."""
4
5 import contextlib
6 from collections.abc import Iterable
7 from dataclasses import dataclass, field, fields, replace
8 from enum import Enum, auto
9 from typing import TYPE_CHECKING, Any
10
11 from vllm.config import VllmConfig
12 from vllm.distributed.kv_events import (
13 MEDIUM_CPU,
14 MEDIUM_STORAGE,
15 BlockRemoved,
16 BlockStored,
17 KVCacheEvent,
18 )
19 from vllm.distributed.kv_transfer.kv_connector.utils import yield_req_data
20 from vllm.logger import init_logger
21 from vllm.utils.math_utils import cdiv
22 from vllm.v1.core.block_pool import BlockPool
23 from vllm.v1.core.kv_cache_coordinator import (
24 KVCacheCoordinator,
25 get_kv_cache_coordinator,
26 )
27 from vllm.v1.core.kv_cache_utils import (
28 BlockHashWithGroupId,
29 ExternalBlockHash,
30 generate_block_hash_extra_keys,
31 get_block_hash,
32 get_group_id,
33 make_block_hash_with_group_id,
34 maybe_convert_block_hash,
35 resolve_block_hashes,
36 resolve_dcp_kv_block_size,
37 to_event_extra_keys,
38 )
39 from vllm.v1.core.sched.output import SchedulerOutput
40 from vllm.v1.kv_cache_interface import (
41 FullAttentionSpec,
42 MambaSpec,
43 SlidingWindowSpec,
44 get_kv_cache_spec_kind,
45 get_kv_cache_spec_sliding_window,
46 )
47 from vllm.v1.outputs import KVConnectorOutput
48 from vllm.v1.simple_kv_offload.metadata import (
49 SimpleCPUOffloadMetadata,
50 SimpleCPUOffloadWorkerMetadata,
51 )
52 from vllm.v1.simple_kv_offload.metrics import (
53 OUTCOME_TO_FIELD,
54 MetricName,
55 SimpleCPUOffloadStats,
56 )
57
58 if TYPE_CHECKING:
59 from vllm.v1.core.kv_cache_manager import KVCacheBlocks
60 from vllm.v1.core.kv_cache_utils import BlockHashList, KVCacheBlock
61 from vllm.v1.kv_cache_interface import KVCacheConfig
62 from vllm.v1.request import Request
63
64 logger = init_logger(__name__)
65
66
67 @dataclass
68 class BlockStoreMeta:
69 """Per-block metadata snapshot for BlockStored event emission.
70
71 Captured at store-prep time (when the Request is available) and carried
72 through TransferMeta to async completion, where the Request is gone.
73 """
74
75 token_ids: list[int]
76 parent_block_hash: ExternalBlockHash | None
77 lora_id: int | None
78 lora_name: str | None
79 extra_keys: tuple[Any, ...] | None
80 block_size: int | None = None
81
82
83 @dataclass
84 class TransferMeta:
85 gpu_block_ids: list[int]
86 cpu_block_ids: list[int]
87 block_meta: list[dict[BlockHashWithGroupId, BlockStoreMeta]] | None = None
88
89
90 @dataclass
91 class LoadRequestState:
92 request: "Request"
93 transfer_meta: TransferMeta
94 load_event: int | None = None
95 finished: bool = False
96
97
98 # NOTE: This per-request state is only used in eager mode.
99 @dataclass
100 class StoreRequestState:
101 request: "Request"
102 # Accumulated block IDs from scheduler_output via yield_req_data.
103 block_ids: tuple[list[int], ...]
104 # Per-group cursors tracking how many blocks have been stored/skipped.
105 num_stored_blocks: list[int]
106 store_events: set[int] = field(default_factory=set)
107 finished: bool = False
108
109
110 class _StoreAdmission(Enum):
111 READY = auto()
112 NULL_BLOCK = auto()
113 NOT_HASHED = auto()
114 IN_FLIGHT = auto()
115 ALREADY_CACHED = auto()
116
117
118 @dataclass
119 class BoundaryStoreStats:
120 """Cumulative counters for boundary hand-off stores.
121
122 Every branch that declines a hand-off is silent by design: the boundary is
123 simply not offloaded and a later request misses. Without counters the only
124 symptom is a lower cache hit rate with nothing in the logs, so each decline
125 reason is counted and exposed through
126 ``SimpleCPUOffloadConnector.get_boundary_store_stats()``. Reset by
127 ``reset()``, which carries any not-yet-drained deltas; not otherwise
128 cleared.
129 """
130
131 published: int = 0
132 stored: int = 0
133 dropped_cpu_full: int = 0
134 dropped_request_gone: int = 0
135 skipped_already_cached: int = 0
136 skipped_in_flight: int = 0
137 dropped_null_block: int = 0
138 dropped_not_hashed: int = 0
139
140
141 class SimpleCPUOffloadScheduler:
142 """Scheduler-side manager for CPU offloading."""
143
144 def __init__(
145 self,
146 vllm_config: VllmConfig,
147 kv_cache_config: "KVCacheConfig | None",
148 cpu_capacity_bytes: int,
149 scheduler_block_size: int,
150 hash_block_size: int,
151 lazy_offload: bool = False,
152 disk_capacity_bytes: int = 0,
153 use_page_cache: bool = False,
154 ):
155 self.vllm_config = vllm_config
156 self.kv_cache_config = kv_cache_config
157 # When disk mode is active, the offload pool size is disk-based.
158 offload_capacity = (
159 disk_capacity_bytes if disk_capacity_bytes > 0 else cpu_capacity_bytes
160 )
161 self.enable_kv_cache_events = (
162 vllm_config.kv_events_config is not None
163 and vllm_config.kv_events_config.enable_kv_cache_events
164 )
165 dcp_world_size = vllm_config.parallel_config.decode_context_parallel_size
166 self.cp_world_size = dcp_world_size
167 self.block_size = scheduler_block_size
168 self.hash_block_size = hash_block_size
169 assert self.block_size % self.hash_block_size == 0
170 # Derive a CPU KVCacheConfig from the GPU config and build a coordinator
171 assert kv_cache_config is not None
172 self.cpu_kv_cache_config = self._derive_cpu_config(
173 kv_cache_config, offload_capacity
174 )
175 self.num_cpu_blocks = self.cpu_kv_cache_config.num_blocks
176 self.prefix_cacheable_group_ids = (
177 self.cpu_kv_cache_config.prefix_cacheable_group_ids
178 )
179 self.kv_event_medium = MEDIUM_STORAGE if disk_capacity_bytes > 0 else MEDIUM_CPU
180 # Must track kv_event_medium.
181 self._info_labelvalues: tuple[str, ...] = (
182 "disk" if disk_capacity_bytes > 0 else "cpu",
183 str(use_page_cache).lower(),
184 str(lazy_offload).lower(),
185 str(self.num_cpu_blocks),
186 )
187 # Find the full attention kv group for prefix cache matching.
188 self.fa_gidx = -1
189 for g_idx, g in enumerate(self.cpu_kv_cache_config.kv_cache_groups):
190 if isinstance(g.kv_cache_spec, FullAttentionSpec):
191 self.fa_gidx = g_idx
192 break
193 assert 0 <= self.fa_gidx < len(self.cpu_kv_cache_config.kv_cache_groups)
194 logger.info(
195 "SimpleCPUOffloadScheduler: Allocating %d offload blocks "
196 "(%.2f GB, mode=%s, backend=%s)",
197 self.num_cpu_blocks,
198 offload_capacity / (1024**3),
199 "lazy" if lazy_offload else "eager",
200 "disk" if disk_capacity_bytes > 0 else "cpu",
201 )
202
203 spec_config = vllm_config.speculative_config
204 use_eagle_block_drop = (
205 spec_config is not None and spec_config.use_eagle_block_drop()
206 )
207 self.cpu_coordinator: KVCacheCoordinator = get_kv_cache_coordinator(
208 kv_cache_config=self.cpu_kv_cache_config,
209 max_model_len=vllm_config.model_config.max_model_len,
210 max_in_flight_tokens=vllm_config.max_in_flight_tokens,
211 use_eagle=use_eagle_block_drop,
212 enable_caching=True,
213 enable_kv_cache_events=self.enable_kv_cache_events,
214 dcp_world_size=dcp_world_size,
215 pcp_world_size=1,
216 scheduler_block_size=self.block_size,
217 hash_block_size=self.hash_block_size,
218 allow_partial_hash_hits=not lazy_offload,
219 )
220 self.group_block_sizes = self.cpu_coordinator.group_block_sizes
221 # FA group's own resolved block_size; divides scheduler_block_size (the
222 # LCM) but is NOT assumed to equal it.
223 self.fa_block_size: int = self.group_block_sizes[self.fa_gidx]
224 assert self.block_size % self.fa_block_size == 0
225 self.cpu_block_pool: BlockPool = self.cpu_coordinator.block_pool
226 # GPU block pool reference - bound after scheduler builds kv_cache_manager
227 self._gpu_block_pool: BlockPool | None = None
228
229 # Load metadata
230 self._reqs_to_load: dict[str, LoadRequestState] = {}
231 # Inverse map: load_event_idx -> req_ids. Keyed by load_event_idx because
232 # the worker reports completions by event index, not request id.
233 self._load_event_to_reqs: dict[int, list[str]] = {}
234
235 # Pending (cpu_hit_blocks, hit_length, num_computed_tokens) tuples from
236 # find_longest_cache_hit, kept pinned via touch() while awaiting
237 # update_state_after_alloc(). ``num_computed_tokens`` is the local
238 # prefix the hit was resolved against; the load path needs it to place
239 # external blocks and must not re-derive it from block metadata.
240 self._pending_cpu_hits: dict[
241 str, tuple[tuple[list[KVCacheBlock], ...], int, int]
242 ] = {}
243
244 # Store metadata
245 self._lazy_mode = lazy_offload
246 # Lazy mode: use a cursor to track the last scanned block in the GPU free queue.
247 self._cursor: KVCacheBlock | None = None
248 if self._lazy_mode:
249 self._target_free = self._estimate_lazy_target_blocks(
250 kv_cache_config,
251 vllm_config.scheduler_config.max_num_batched_tokens,
252 self.cp_world_size,
253 )
254 else:
255 self._target_free = 0
256 self._store_event_to_blocks: dict[int, TransferMeta] = {}
257 self._abandoned_store_event_to_blocks: dict[int, TransferMeta] = {}
258 # Eager mode only
259 self._reqs_to_store: dict[str, StoreRequestState] = {}
260 self._store_event_to_reqs: dict[int, list[str]] = {}
261 self._in_flight_store_gpu_blocks: set[int] = set()
262 self._pending_finished_stores: list[TransferMeta] = []
263 self._abandoned_reqs_to_load: dict[str, LoadRequestState] = {}
264
265 # Event counters
266 self._load_event_counter: int = 0
267 self._store_event_counter: int = 0
268 self.boundary_store_stats = BoundaryStoreStats()
269 # Interval stats state drained by get_stats()
270 self._boundary_stats_snapshot = BoundaryStoreStats()
271 self._interval_load_blocks_completed = 0
272
273 # For TP/PP: track partial store completions across steps.
274 # Events must be reported by all world_size workers before considered complete.
275 self._expected_worker_count = vllm_config.parallel_config.world_size
276 self._store_event_pending_counts: dict[int, int] = {}
277
278 @staticmethod
279 def _derive_cpu_config(
280 gpu_config: "KVCacheConfig", cpu_capacity_bytes: int
281 ) -> "KVCacheConfig":
282 """Derive a CPU KVCacheConfig from the GPU config.
283 Same kv_cache_groups, num_blocks scaled by CPU/GPU memory ratio."""
284 # Import here to avoid potential circular imports
285 from vllm.v1.kv_cache_interface import KVCacheTensor
286
287 assert len(gpu_config.kv_cache_tensors) > 0
288
289 # Every KVCacheTensor describes placement within the same backing allocation,
290 # so its size is the total GPU KV cache size.
291 gpu_total_bytes = gpu_config.kv_cache_tensors[0].size
292 num_gpu_blocks = gpu_config.num_blocks
293 num_cpu_blocks = max(1, num_gpu_blocks * cpu_capacity_bytes // gpu_total_bytes)
294 # Create CPU kv_cache_tensors mirroring GPU by scaling size proportionally.
295 cpu_tensors = [
296 KVCacheTensor(
297 size=t.size // num_gpu_blocks * num_cpu_blocks,
298 layers=list(t.layers),
299 layer_stride=t.layer_stride,
300 block_stride=t.block_stride,
301 offset=t.offset,
302 )
303 for t in gpu_config.kv_cache_tensors
304 ]
305
306 return replace(
307 gpu_config,
308 num_blocks=num_cpu_blocks,
309 kv_cache_tensors=cpu_tensors,
310 )
311
312 @staticmethod
313 def _estimate_lazy_target_blocks(
314 kv_cache_config: "KVCacheConfig",
315 max_num_batched_tokens: int,
316 cp_world_size: int = 1,
317 ) -> int:
318 """GPU blocks to keep available (free/offloaded) per step in lazy mode."""
319 WATERMARK_RATIO = 1.0 # Reserve larger space to avoid running out of GPU blocks
320 target = 0
321 for g in kv_cache_config.prefix_cacheable_groups:
322 spec = g.kv_cache_spec
323 block_size = resolve_dcp_kv_block_size(spec, cp_world_size)
324 if isinstance(spec, MambaSpec):
325 target += 2
326 elif isinstance(spec, SlidingWindowSpec):
327 target += cdiv(spec.sliding_window, block_size) + 1
328 else:
329 target += cdiv(max_num_batched_tokens, block_size)
330 return int(target * (1 + WATERMARK_RATIO))
331
332 def bind_gpu_block_pool(self, gpu_block_pool: BlockPool) -> None:
333 """Bind GPU block pool so that we can touch blocks during stores.
334 Called by Scheduler after kv_cache_manager is ready."""
335 self._gpu_block_pool = gpu_block_pool
336
337 def get_num_new_matched_tokens(
338 self, request: "Request", num_computed_tokens: int
339 ) -> tuple[int | None, bool]:
340 """Return (num_new_tokens, is_async) from consecutive CPU cache hits."""
341 # Pins found CPU blocks so they survive LRU eviction until
342 # update_state_after_alloc() consumes them. Any pin from an earlier
343 # call on the same request (e.g. retry after a failed allocate_slots)
344 # is dropped first.
345 if stale := self._pending_cpu_hits.pop(request.request_id, None):
346 self._free_pending_cpu_hit(stale)
347
348 if request.skip_reading_prefix_cache:
349 return 0, False
350
351 if num_computed_tokens % self.block_size != 0:
352 # Transfers are whole-block copies, so an external suffix cannot
353 # start in the middle of a destination block. When the GPU-side
354 # coordinator also serves fine-grained hits, the local prefix can
355 # land on a hash boundary that is not a scheduler-block boundary;
356 # such a request keeps its local hit and skips the external one.
357 logger.warning_once(
358 "SimpleCPU external lookup requires scheduler-block-aligned "
359 "local tokens, got %d tokens with scheduler block size %d.",
360 num_computed_tokens,
361 self.block_size,
362 )
363 return 0, False
364
365 num_skipped_hashes = num_computed_tokens // self.hash_block_size
366 remaining_hashes = request.block_hashes[num_skipped_hashes:]
367
368 if not remaining_hashes:
369 return 0, False
370 # Must recompute at least the last token, matching the logic in
371 # kv_cache_manager.get_computed_blocks().
372 max_hit_len = request.num_tokens - 1 - num_computed_tokens
373 if max_hit_len <= 0:
374 return 0, False
375 cpu_hit_blocks, hit_length, _ = self.cpu_coordinator.find_longest_cache_hit(
376 remaining_hashes, max_hit_len
377 )
378
379 if hit_length > 0:
380 pin_blocks = [
381 blk for grp in cpu_hit_blocks for blk in grp if not blk.is_null
382 ]
383 self.cpu_block_pool.touch(pin_blocks)
384 self._pending_cpu_hits[request.request_id] = (
385 cpu_hit_blocks,
386 hit_length,
387 num_computed_tokens,
388 )
389 return hit_length, True
390 return 0, False
391
392 # TODO(yifan): this API now only matches the suffix part of the prefix cache. A more
393 # general API should scan blocks in both GPU and CPU block pool in a single pass.
394 def update_state_after_alloc(
395 self,
396 request: "Request",
397 blocks: "KVCacheBlocks",
398 num_external_tokens: int,
399 ) -> None:
400 req_id = request.request_id
401 block_ids_by_group = blocks.get_block_ids()
402 num_groups = len(block_ids_by_group)
403
404 # Store tracking (eager mode only). Register the request;
405 # block IDs are accumulated from scheduler_output in
406 # _prepare_eager_store_specs via yield_req_data.
407 if not self._lazy_mode and req_id not in self._reqs_to_store:
408 self._reqs_to_store[req_id] = StoreRequestState(
409 request=request,
410 block_ids=tuple([] for _ in range(num_groups)),
411 num_stored_blocks=[0] * num_groups,
412 )
413
414 # Pop the CPU hit cached by get_num_new_matched_tokens(). The
415 # found blocks were pinned there to survive LRU eviction in the window
416 # between get_num_new_matched_tokens() and this matching call.
417 pending = self._pending_cpu_hits.pop(req_id, None)
418
419 if num_external_tokens == 0:
420 if pending is not None:
421 logger.warning(
422 "SimpleCPUOffloadScheduler: update_state_after_alloc "
423 "called for req_id=%s with no external tokens but "
424 "get_num_new_matched_tokens() unexpectedly recorded "
425 "a pending CPU hit; releasing the stale pin.",
426 req_id,
427 )
428 self._free_pending_cpu_hit(pending)
429 return
430
431 if pending is None:
432 logger.warning(
433 "SimpleCPUOffloadScheduler: update_state_after_alloc called "
434 "for req_id=%s with num_external_tokens=%d but no pending "
435 "CPU hit from get_num_new_matched_tokens(); skipping load.",
436 req_id,
437 num_external_tokens,
438 )
439 return
440
441 cpu_hit_blocks_full, _, num_computed_tokens = pending
442
443 assert num_computed_tokens % self.block_size == 0, (
444 "SimpleCPU external loads must start at a scheduler-block boundary"
445 )
446 # Fine-grained hits are hash-block aligned, not group-block aligned, so
447 # the per-group block count below rounds up instead of dividing exactly.
448 # Hash alignment is still required: it is what makes the accepted
449 # external suffix start on a boundary every group can address.
450 assert num_external_tokens % self.hash_block_size == 0, (
451 f"num_external_tokens={num_external_tokens} is not aligned to "
452 f"hash_block_size={self.hash_block_size}"
453 )
454
455 # The scheduler may have accepted fewer blocks than
456 # get_num_new_matched_tokens() reported.
457 # (e.g. due to token budget in test_partial_gpu_prefix_plus_cpu_load).
458 # Take only the leading N blocks per group matching num_external_tokens;
459 # the rest will be released along with the temp pin below.
460 cpu_hit_blocks: list[list[KVCacheBlock]] = []
461 for g in range(num_groups):
462 if g not in self.prefix_cacheable_group_ids:
463 cpu_hit_blocks.append([])
464 continue
465 g_block_size = self.group_block_sizes[g]
466 n_take_g = cdiv(num_external_tokens, g_block_size)
467 cpu_hit_blocks.append(cpu_hit_blocks_full[g][:n_take_g])
468
469 gpu_block_ids: list[int] = []
470 cpu_block_ids: list[int] = []
471 cpu_blocks_to_touch: list[KVCacheBlock] = []
472
473 for g in range(num_groups):
474 cpu_blocks_g = cpu_hit_blocks[g]
475 n_ext_g = len(cpu_blocks_g)
476 if n_ext_g == 0:
477 continue
478
479 g_block_size = self.group_block_sizes[g]
480 gpu_ext_start = num_computed_tokens // g_block_size
481 group_gpu_ids = block_ids_by_group[g]
482
483 for i, cpu_blk in enumerate(cpu_blocks_g):
484 # Skip null blocks (e.g. sliding window or mamba padding).
485 if cpu_blk.is_null:
486 continue
487 gpu_block_ids.append(group_gpu_ids[gpu_ext_start + i])
488 cpu_block_ids.append(cpu_blk.block_id)
489 cpu_blocks_to_touch.append(cpu_blk)
490
491 # Touch CPU blocks to prevent eviction during async load.
492 self.cpu_block_pool.touch(cpu_blocks_to_touch)
493 # Release the temporary pin held since get_num_new_matched_tokens().
494 self._free_pending_cpu_hit(pending)
495
496 # Touch GPU blocks to prevent freeing during async load
497 assert self._gpu_block_pool is not None
498 self._gpu_block_pool.touch(
499 [self._gpu_block_pool.blocks[bid] for bid in gpu_block_ids]
500 )
501
502 assert self._reqs_to_load.get(req_id) is None
503 self._reqs_to_load[req_id] = LoadRequestState(
504 request=request, transfer_meta=TransferMeta(gpu_block_ids, cpu_block_ids)
505 )
506
507 def build_connector_meta(
508 self,
509 scheduler_output: SchedulerOutput,
510 ) -> SimpleCPUOffloadMetadata:
511 # --- Stores ---
512 store_event = -1
513 store_gpu, store_cpu, store_req_ids, store_meta = self.prepare_store_specs(
514 scheduler_output
515 )
516 for transfer in self._pending_finished_stores:
517 store_gpu.extend(transfer.gpu_block_ids)
518 store_cpu.extend(transfer.cpu_block_ids)
519 if store_meta is not None:
520 assert transfer.block_meta is not None
521 store_meta.extend(transfer.block_meta)
522 self._pending_finished_stores.clear()
523
524 if store_gpu:
525 store_event = self._store_event_counter
526 self._store_event_counter += 1
527 self._store_event_to_blocks[store_event] = TransferMeta(
528 store_gpu, store_cpu, store_meta
529 )
530 if store_req_ids: # For eager mode only, track req->blocks mapping
531 self._store_event_to_reqs[store_event] = store_req_ids
532 for req_id in store_req_ids:
533 store_state = self._reqs_to_store.get(req_id)
534 if store_state is not None:
535 store_state.store_events.add(store_event)
536
537 # --- Loads ---
538 load_event = -1
539 load_gpu: list[int] = []
540 load_cpu: list[int] = []
541 load_req_ids: list[str] = []
542 for req_id, load_state in self._reqs_to_load.items():
543 if load_state.load_event is not None:
544 continue
545 assert load_state.transfer_meta is not None
546 load_gpu.extend(load_state.transfer_meta.gpu_block_ids)
547 load_cpu.extend(load_state.transfer_meta.cpu_block_ids)
548 load_req_ids.append(req_id)
549 if load_req_ids:
550 load_event = self._load_event_counter
551 self._load_event_counter += 1
552 for req_id in load_req_ids:
553 self._reqs_to_load[req_id].load_event = load_event
554 self._load_event_to_reqs[load_event] = load_req_ids
555
556 result = SimpleCPUOffloadMetadata(
557 load_event=load_event,
558 load_gpu_blocks=load_gpu,
559 load_cpu_blocks=load_cpu,
560 load_event_to_reqs={
561 event_idx: list(req_ids)
562 for event_idx, req_ids in self._load_event_to_reqs.items()
563 },
564 store_event=store_event,
565 store_gpu_blocks=store_gpu,
566 store_cpu_blocks=store_cpu,
567 need_flush=bool(scheduler_output.preempted_req_ids),
568 )
569 return result
570
571 def prepare_store_specs(
572 self, scheduler_output: SchedulerOutput
573 ) -> tuple[
574 list[int],
575 list[int],
576 list[str],
577 list[dict[BlockHashWithGroupId, BlockStoreMeta]] | None,
578 ]:
579 """Prepare store specs for the store event."""
580 if self._lazy_mode:
581 return self._prepare_lazy_store_specs()
582 else:
583 return self._prepare_eager_store_specs(scheduler_output)
584
585 def _prepare_lazy_store_specs(
586 self,
587 ) -> tuple[
588 list[int],
589 list[int],
590 list[str],
591 list[dict[BlockHashWithGroupId, BlockStoreMeta]] | None,
592 ]:
593 """Single-pass cursor walk: offload cached GPU blocks near eviction.
594
595 Walks the GPU free queue from the cursor, counting blocks that are
596 free-or-offloaded (safe for the allocator to evict). Stops when
597 target_free blocks are covered or CPU capacity is reached.
598 """
599 gpu_pool = self._gpu_block_pool
600 if gpu_pool is None or self._target_free <= 0:
601 return [], [], [], None
602
603 free_queue = gpu_pool.free_block_queue
604 cpu_pool = self.cpu_block_pool
605 num_cpu_free = cpu_pool.get_num_free_blocks()
606
607 # Validate cursor: stale if block was removed from free queue.
608 if self._cursor is not None and self._cursor.ref_cnt > 0:
609 self._cursor = None
610
611 gpu_ids: list[int] = []
612 last_visited = self._cursor
613
614 for covered, node in enumerate(free_queue.iter_blocks_after(self._cursor)):
615 if covered >= self._target_free or len(gpu_ids) >= num_cpu_free:
616 break
617
618 last_visited = node
619 bhash = node.block_hash
620
621 if (
622 bhash is not None
623 and not node.is_null
624 and cpu_pool.cached_block_hash_to_block.get_one_block(bhash) is None
625 ):
626 gpu_ids.append(node.block_id)
627
628 self._cursor = last_visited
629
630 # Batch-allocate CPU blocks.
631 if gpu_ids:
632 cpu_blocks = cpu_pool.get_new_blocks(len(gpu_ids))
633 cpu_ids = [blk.block_id for blk in cpu_blocks]
634 # Touch GPU blocks to prevent eviction during async copy.
635 gpu_pool.touch([gpu_pool.blocks[bid] for bid in gpu_ids])
636 else:
637 cpu_ids = []
638
639 return gpu_ids, cpu_ids, [], None
640
641 def _prepare_eager_store_specs(
642 self, scheduler_output: SchedulerOutput
643 ) -> tuple[
644 list[int],
645 list[int],
646 list[str],
647 list[dict[BlockHashWithGroupId, BlockStoreMeta]] | None,
648 ]:
649 """Identify newly computed blocks to offload from scheduler requests.
650
651 Only considers blocks whose KV data has been **confirmed computed** by
652 the GPU. Blocks from the current step are stored on a later step, or by
653 the finish-time flush if the request completes first.
654
655 Returns:
656 (gpu_block_ids, cpu_block_ids, req_ids, block_meta) for the store
657 event. ``block_meta`` is None when kv cache events are disabled.
658
659 """
660 merged_gpu_block_ids: list[int] = []
661 merged_cpu_block_ids: list[int] = []
662 req_ids: list[str] = []
663 merged_block_meta: list[dict[BlockHashWithGroupId, BlockStoreMeta]] | None = (
664 [] if self.enable_kv_cache_events else None
665 )
666
667 gpu_block_pool = self._gpu_block_pool
668 if gpu_block_pool is None:
669 return [], [], [], merged_block_meta
670 cpu_block_pool = self.cpu_block_pool
671 num_groups = len(self.cpu_kv_cache_config.kv_cache_groups)
672 # Dedup against blocks already scheduled.
673 in_flight = self._in_flight_store_gpu_blocks
674 num_free = cpu_block_pool.get_num_free_blocks()
675
676 block_state = scheduler_output.kv_connector_block_state
677 boundary_offloads = (
678 block_state.boundary_state_offloads if block_state is not None else {}
679 )
680 stats = self.boundary_store_stats
681 preempted_req_ids = scheduler_output.preempted_req_ids or ()
682 for req_id, entries in boundary_offloads.items():
683 # The scheduler drains handoffs without filtering by request
684 # liveness. A request that finished or was preempted in this step
685 # is giving up its blocks, which may already have been reallocated
686 # to another request; reading them would publish unrelated KV under
687 # a valid hash. Drop conservatively, as the mooncake store does.
688 store_state = self._reqs_to_store.get(req_id)
689 if (
690 store_state is None
691 or store_state.finished
692 or req_id in preempted_req_ids
693 or req_id in scheduler_output.finished_req_ids
694 ):
695 stats.published += len(entries)
696 stats.dropped_request_gone += len(entries)
697 continue
698 scheduled_for_req = False
699 # ``boundary_tokens`` is not needed here: the cache key comes from
700 # the handed-off block itself, not from the offered boundary.
701 for _, gpu_block_id, _ in entries:
702 stats.published += 1
703 gpu_block = gpu_block_pool.blocks[gpu_block_id]
704 admission = self._classify_store_candidate(gpu_block)
705 if admission is _StoreAdmission.NULL_BLOCK:
706 stats.dropped_null_block += 1
707 continue
708 if admission is _StoreAdmission.NOT_HASHED:
709 stats.dropped_not_hashed += 1
710 continue
711 if admission is _StoreAdmission.IN_FLIGHT:
712 stats.skipped_in_flight += 1
713 continue
714 if admission is _StoreAdmission.ALREADY_CACHED:
715 stats.skipped_already_cached += 1
716 continue
717 if num_free <= 0:
718 stats.dropped_cpu_full += 1
719 logger.warning_once(
720 "SimpleCPU dropped a boundary-state handoff because "
721 "the CPU block pool is full; this boundary will not be retried."
722 )
723 continue
724 cpu_block = cpu_block_pool.get_new_blocks(1)[0]
725 merged_gpu_block_ids.append(gpu_block_id)
726 merged_cpu_block_ids.append(cpu_block.block_id)
727 if merged_block_meta is not None:
728 # Keep the metadata list index-parallel with the block ids.
729 # The hand-off block's own hashes are resolved at completion,
730 # so there is nothing to capture here; emit the event without
731 # a payload, as the size-mismatch fallback already does.
732 merged_block_meta.append({})
733 in_flight.add(gpu_block_id)
734 gpu_block_pool.touch([gpu_block])
735 num_free -= 1
736 stats.stored += 1
737 scheduled_for_req = True
738 if scheduled_for_req:
739 req_ids.append(req_id)
740
741 for req_id, new_block_id_groups, preempted in yield_req_data(scheduler_output):
742 state = self._reqs_to_store.get(req_id)
743 if state is None or state.finished:
744 continue
745
746 if preempted:
747 state.block_ids = tuple([] for _ in range(num_groups))
748 state.num_stored_blocks = [0] * num_groups
749 if new_block_id_groups:
750 for g in range(min(num_groups, len(new_block_id_groups))):
751 if new_block_id_groups[g] is not None:
752 # Accumulate new block IDs.
753 state.block_ids[g].extend(new_block_id_groups[g])
754
755 num_new_tokens = scheduler_output.num_scheduled_tokens.get(req_id, 0)
756 if num_new_tokens == 0:
757 continue
758
759 block_ids_by_group = state.block_ids
760 if not block_ids_by_group:
761 continue
762
763 gpu_block_ids, advanced_per_group, block_meta = (
764 self._select_eager_blocks_to_store(state, block_ids_by_group)
765 )
766
767 # Batch allocate the CPU destinations.
768 n_to_alloc = len(gpu_block_ids)
769 if n_to_alloc > 0:
770 cpu_blocks_alloc = cpu_block_pool.get_new_blocks(n_to_alloc)
771 cpu_block_ids = [blk.block_id for blk in cpu_blocks_alloc]
772 else:
773 cpu_block_ids = []
774
775 if cpu_block_ids:
776 req_ids.append(req_id)
777 merged_gpu_block_ids.extend(gpu_block_ids)
778 merged_cpu_block_ids.extend(cpu_block_ids)
779 in_flight.update(gpu_block_ids)
780 if merged_block_meta is not None:
781 assert block_meta is not None
782 merged_block_meta.extend(block_meta)
783
784 # Touch GPU blocks to prevent freeing during async copy
785 gpu_block_pool.touch(
786 [gpu_block_pool.blocks[bid] for bid in gpu_block_ids]
787 )
788
789 logger.debug(
790 "Request %s: Scheduling store of %d blocks to CPU (%d groups)",
791 req_id,
792 len(cpu_block_ids),
793 num_groups,
794 )
795
796 # Advance per-group cursors (includes cached hits + newly stored)
797 for g in range(num_groups):
798 state.num_stored_blocks[g] += advanced_per_group[g]
799
800 # A request can contribute both boundary and positional blocks to the
801 # same event. Completion tracking is set-based, so keep one request ID.
802 req_ids = list(dict.fromkeys(req_ids))
803 return merged_gpu_block_ids, merged_cpu_block_ids, req_ids, merged_block_meta
804
805 def _cached_gpu_block(
806 self, resolved_hashes: "BlockHashList", block_idx: int, group_id: int
807 ) -> "KVCacheBlock | None":
808 """Return the GPU block still cached under this group's block hash."""
809 if block_idx >= len(resolved_hashes):
810 return None
811 assert self._gpu_block_pool is not None
812 blocks = self._gpu_block_pool.get_cached_block(
813 resolved_hashes[block_idx], [group_id]
814 )
815 return blocks[0] if blocks else None
816
817 def _select_eager_blocks_to_store(
818 self,
819 state: StoreRequestState,
820 block_ids_by_group: tuple[list[int], ...],
821 ) -> tuple[
822 list[int],
823 list[int],
824 list[dict[BlockHashWithGroupId, BlockStoreMeta]] | None,
825 ]:
826 """Return confirmed eager blocks, cursor advances, and event metadata."""
827 assert self._gpu_block_pool is not None
828 gpu_block_ids: list[int] = []
829 advanced_per_group = [0] * len(self.cpu_kv_cache_config.kv_cache_groups)
830 block_meta: list[dict[BlockHashWithGroupId, BlockStoreMeta]] | None = (
831 [] if self.enable_kv_cache_events else None
832 )
833 request = state.request
834 confirmed_tokens = request.num_computed_tokens - request.num_output_placeholders
835 # Truncate to the granularity a lookup can actually land on. With
836 # fine-grained hits that is hash_block_size; otherwise hits only land on
837 # the scheduler block (the group LCM). Using the LCM unconditionally
838 # would drop whole blocks that sit between the last LCM boundary and a
839 # reachable fine-grained boundary, so the other groups would hold that
840 # boundary and this one would not, and the joint hybrid lookup would
841 # reconcile to zero.
842 store_alignment = (
843 self.hash_block_size
844 if self.cpu_coordinator.enable_partial_hash_hits
845 else self.block_size
846 )
847 aligned_tokens = confirmed_tokens // store_alignment * store_alignment
848 num_free = self.cpu_block_pool.get_num_free_blocks()
849
850 for g, group_gpu_ids in enumerate(block_ids_by_group):
851 if g not in self.prefix_cacheable_group_ids:
852 continue
853 if len(gpu_block_ids) >= num_free:
854 break
855 group_manager = self.cpu_coordinator.single_type_managers[g]
856 if not group_manager.has_positionally_stable_blocks:
857 continue
858 # FIXME (yifan): handle CPU cache eviction, where
859 # num_stored_blocks can be stale and omit evicted blocks in
860 # the middle of the request.
861 group_size = self.group_block_sizes[g]
862 ready = min(len(group_gpu_ids), aligned_tokens // group_size)
863 resolved_hashes = resolve_block_hashes(
864 request.block_hashes, self.hash_block_size, group_size
865 )
866 curr_mm_idx = 0
867 secondary_mm_idx = 0
868 start = state.num_stored_blocks[g]
869 for i, gpu_block_id in enumerate(group_gpu_ids[start:ready], start=start):
870 gpu_block = self._gpu_block_pool.blocks[gpu_block_id]
871 if gpu_block.is_null:
872 # Sliding-window groups null pages that left the window
873 # before the connector sees the block table, but the
874 # retained prefix-cache tail stays hashed in the GPU free
875 # queue; store it from there.
876 recovered = self._cached_gpu_block(resolved_hashes, i, g)
877 if recovered is None:
878 advanced_per_group[g] += 1
879 continue
880 gpu_block = recovered
881 gpu_block_id = gpu_block.block_id
882 if (
883 self._classify_store_candidate(gpu_block)
884 is not _StoreAdmission.READY
885 ):
886 advanced_per_group[g] += 1
887 continue
888 if len(gpu_block_ids) >= num_free:
889 break
890 primary_block_hash = gpu_block.block_hash
891 assert primary_block_hash is not None
892 gpu_block_ids.append(gpu_block_id)
893 advanced_per_group[g] += 1
894 if block_meta is not None:
895 token_start = i * group_size
896 token_end = token_start + group_size
897 parent_hash = (
898 None
899 if i == 0
900 else maybe_convert_block_hash(resolved_hashes[i - 1])
901 )
902 lora_req = request.lora_request
903 extra_keys, curr_mm_idx = generate_block_hash_extra_keys(
904 request, token_start, token_end, curr_mm_idx
905 )
906 meta_by_hash = {
907 primary_block_hash: BlockStoreMeta(
908 token_ids=list(
909 request.all_token_ids[token_start:token_end]
910 ),
911 parent_block_hash=parent_hash,
912 lora_id=lora_req.adapter_id if lora_req else None,
913 lora_name=lora_req.name if lora_req else None,
914 extra_keys=extra_keys,
915 )
916 }
917 first_hash_idx = token_start // self.hash_block_size
918 last_hash_idx = token_end // self.hash_block_size
919 for hash_idx in range(first_hash_idx, last_hash_idx):
920 block_hash = make_block_hash_with_group_id(
921 request.block_hashes[hash_idx], g
922 )
923 if block_hash is None or block_hash == primary_block_hash:
924 continue
925 hash_start = hash_idx * self.hash_block_size
926 hash_end = hash_start + self.hash_block_size
927 secondary_extra_keys, secondary_mm_idx = (
928 generate_block_hash_extra_keys(
929 request,
930 hash_start,
931 hash_end,
932 secondary_mm_idx,
933 )
934 )
935 meta_by_hash[block_hash] = BlockStoreMeta(
936 token_ids=list(request.all_token_ids[hash_start:hash_end]),
937 parent_block_hash=(
938 None
939 if hash_idx == 0
940 else maybe_convert_block_hash(
941 request.block_hashes[hash_idx - 1]
942 )
943 ),
944 lora_id=lora_req.adapter_id if lora_req else None,
945 lora_name=lora_req.name if lora_req else None,
946 extra_keys=secondary_extra_keys,
947 block_size=self.hash_block_size,
948 )
949 block_meta.append(meta_by_hash)
950
951 return gpu_block_ids, advanced_per_group, block_meta
952
953 def _classify_store_candidate(self, gpu_block: "KVCacheBlock") -> _StoreAdmission:
954 if gpu_block.is_null:
955 return _StoreAdmission.NULL_BLOCK
956 if gpu_block.block_hash is None:
957 return _StoreAdmission.NOT_HASHED
958 if gpu_block.block_id in self._in_flight_store_gpu_blocks:
959 return _StoreAdmission.IN_FLIGHT
960 if (
961 self.cpu_block_pool.cached_block_hash_to_block.get_one_block(
962 gpu_block.block_hash
963 )
964 is not None
965 ):
966 return _StoreAdmission.ALREADY_CACHED
967 return _StoreAdmission.READY
968
969 def get_boundary_store_stats(self) -> BoundaryStoreStats:
970 return replace(self.boundary_store_stats)
971
972 def get_stats(self) -> SimpleCPUOffloadStats:
973 """Drain per-step stats for the connector's stats hooks."""
974 stats = SimpleCPUOffloadStats()
975
976 current = self.boundary_store_stats
977 for outcome, field_name in OUTCOME_TO_FIELD.items():
978 delta = getattr(current, field_name) - getattr(
979 self._boundary_stats_snapshot, field_name
980 )
981 if delta > 0:
982 stats.increase_counter(MetricName.SAVE_OUTCOMES, delta, (outcome,))
983 self._boundary_stats_snapshot = replace(current)
984
985 if self._interval_load_blocks_completed:
986 stats.increase_counter(
987 MetricName.LOAD_BLOCKS,
988 self._interval_load_blocks_completed,
989 )
990 self._interval_load_blocks_completed = 0
991
992 stats.set_gauge(
993 MetricName.USED_BLOCKS,
994 self.num_cpu_blocks - self.cpu_block_pool.get_num_free_blocks(),
995 )
996 pending = sum(
997 len(t.cpu_block_ids)
998 for t in (
999 *self._store_event_to_blocks.values(),
1000 *self._pending_finished_stores,
1001 *self._abandoned_store_event_to_blocks.values(),
1002 )
1003 )
1004 stats.set_gauge(MetricName.PENDING_STORE_BLOCKS, pending)
1005 stats.set_gauge(MetricName.INFO, 1, self._info_labelvalues)
1006 return stats
1007
1008 def update_connector_output(self, connector_output: KVConnectorOutput) -> None:
1009 """Handle async transfer completions from worker.
1010
1011 Load completions arrive via finished_recving (real req_ids).
1012 Store completions arrive via kv_connector_worker_meta as
1013 per-event worker counts. We accumulate across steps and process
1014 a store event only when all workers have reported completion.
1015 """
1016 # --- Load completions ---
1017 for req_id in list(connector_output.finished_recving or []):
1018 completed_blocks = self._cleanup_load_request(req_id)
1019 if completed_blocks:
1020 self._interval_load_blocks_completed += completed_blocks
1021
1022 # --- Store completions ---
1023 meta = connector_output.kv_connector_worker_meta
1024 if not isinstance(meta, SimpleCPUOffloadWorkerMetadata):
1025 return
1026 for event_idx, count in meta.completed_store_events.items():
1027 total = self._store_event_pending_counts.get(event_idx, 0) + count
1028 if total >= self._expected_worker_count:
1029 self._store_event_pending_counts.pop(event_idx, None)
1030 self._process_store_event(event_idx)
1031 else:
1032 self._store_event_pending_counts[event_idx] = total
1033
1034 def _process_store_event(self, event_idx: int) -> None:
1035 """Process a fully-completed store event."""
1036 transfer = self._store_event_to_blocks.pop(event_idx, None)
1037 if transfer is None:
1038 transfer = self._abandoned_store_event_to_blocks.pop(event_idx, None)
1039 if transfer is None:
1040 return # guard stale events from before a reset() call
1041 self._release_transfer_refs(transfer)
1042 return
1043
1044 if not self._lazy_mode:
1045 self._in_flight_store_gpu_blocks.difference_update(transfer.gpu_block_ids)
1046
1047 self._process_store_completion(
1048 transfer.gpu_block_ids,
1049 transfer.cpu_block_ids,
1050 transfer.block_meta,
1051 )
1052 logger.debug(
1053 "Store event %d completed: cached %d blocks to CPU",
1054 event_idx,
1055 len(transfer.cpu_block_ids),
1056 )
1057
1058 # Eager only: update per-req state
1059 if not self._lazy_mode:
1060 for req_id in self._store_event_to_reqs.pop(event_idx, []):
1061 state = self._reqs_to_store.get(req_id)
1062 if state is None:
1063 continue
1064 state.store_events.discard(event_idx)
1065 if state.finished and not state.store_events:
1066 self._cleanup_store_request(req_id)
1067
1068 def _process_store_completion(
1069 self,
1070 gpu_block_ids: list[int],
1071 cpu_block_ids: list[int],
1072 block_meta: list[dict[BlockHashWithGroupId, BlockStoreMeta]] | None = None,
1073 ) -> None:
1074 """Register copied blocks in the CPU prefix cache and release refs."""
1075 assert len(cpu_block_ids) == len(gpu_block_ids)
1076 assert block_meta is None or len(block_meta) == len(gpu_block_ids)
1077
1078 cpu_blocks = [self.cpu_block_pool.blocks[bid] for bid in cpu_block_ids]
1079
1080 assert self._gpu_block_pool is not None
1081 for i, (gpu_block_id, cpu_block) in enumerate(zip(gpu_block_ids, cpu_blocks)):
1082 gpu_block = self._gpu_block_pool.blocks[gpu_block_id]
1083 primary_hash = gpu_block.block_hash
1084 assert primary_hash is not None
1085 self.cpu_block_pool._insert_block_hash(
1086 primary_hash,
1087 cpu_block,
1088 num_tokens=gpu_block.block_hash_num_tokens,
1089 )
1090 secondary_hashes = self._gpu_block_pool.cached_block_hashes_by_block.get(
1091 gpu_block_id, ()
1092 )
1093 for block_hash in secondary_hashes:
1094 self.cpu_block_pool._insert_block_hash(
1095 block_hash, cpu_block, num_tokens=None
1096 )
1097 if self.enable_kv_cache_events:
1098 meta_by_hash = block_meta[i] if block_meta is not None else {}
1099 primary_group_idx = get_group_id(primary_hash)
1100 primary_block_size = self.group_block_sizes[primary_group_idx]
1101 events_to_emit: list[
1102 tuple[BlockHashWithGroupId, BlockStoreMeta | None, int]
1103 ] = [
1104 (
1105 primary_hash,
1106 meta_by_hash.get(primary_hash),
1107 primary_block_size,
1108 )
1109 ]
1110 for secondary_hash in secondary_hashes:
1111 secondary_meta = meta_by_hash.get(secondary_hash)
1112 events_to_emit.append(
1113 (
1114 secondary_hash,
1115 secondary_meta,
1116 secondary_meta.block_size
1117 if secondary_meta is not None
1118 and secondary_meta.block_size is not None
1119 else 0,
1120 )
1121 )
1122
1123 for block_hash, meta, event_block_size in events_to_emit:
1124 group_idx = get_group_id(block_hash)
1125 spec = self.cpu_kv_cache_config.kv_cache_groups[
1126 group_idx
1127 ].kv_cache_spec
1128 if meta is not None and len(meta.token_ids) != event_block_size:
1129 # token_ids were sliced with a different g_block_size
1130 # (e.g. Mamba+DCP where capture uses spec.block_size *
1131 # cp_world_size but the event emits spec.block_size * 1).
1132 # Emit empty metadata until #49962 fixes the sizing.
1133 token_ids = []
1134 parent_block_hash = None
1135 extra_keys = None
1136 else:
1137 token_ids = meta.token_ids if meta else []
1138 parent_block_hash = meta.parent_block_hash if meta else None
1139 extra_keys = meta.extra_keys if meta else None
1140 self.cpu_block_pool.kv_event_queue.append(
1141 BlockStored(
1142 block_hashes=[
1143 maybe_convert_block_hash(get_block_hash(block_hash))
1144 ],
1145 parent_block_hash=parent_block_hash,
1146 token_ids=token_ids,
1147 block_size=event_block_size,
1148 lora_id=meta.lora_id if meta else None,
1149 medium=self.kv_event_medium,
1150 lora_name=meta.lora_name if meta else None,
1151 extra_keys=to_event_extra_keys(extra_keys and [extra_keys]),
1152 group_idx=group_idx,
1153 kv_cache_spec_kind=get_kv_cache_spec_kind(spec).value,
1154 kv_cache_spec_sliding_window=(
1155 get_kv_cache_spec_sliding_window(spec)
1156 ),
1157 locality="LOCAL",
1158 )
1159 )
1160
1161 # Free CPU and GPU blocks' ref counts to turn them into prefix cache
1162 self.cpu_block_pool.free_blocks(cpu_blocks)
1163 self._gpu_block_pool.free_blocks(
1164 self._gpu_block_pool.blocks[bid] for bid in gpu_block_ids
1165 )
1166
1167 def _release_transfer_refs(self, transfer: TransferMeta) -> None:
1168 """Release transfer refs without making copied data cacheable."""
1169 cpu_blocks = [self.cpu_block_pool.blocks[bid] for bid in transfer.cpu_block_ids]
1170 self.cpu_block_pool.free_blocks(cpu_blocks)
1171 assert self._gpu_block_pool is not None
1172 self._gpu_block_pool.free_blocks(
1173 self._gpu_block_pool.blocks[bid] for bid in transfer.gpu_block_ids
1174 )
1175
1176 def has_pending_stores(self) -> bool:
1177 """Return True if a store transfer is queued or in flight."""
1178 return bool(
1179 self._pending_finished_stores
1180 or self._store_event_to_blocks
1181 or self._abandoned_store_event_to_blocks
1182 )
1183
1184 def request_finished(
1185 self,
1186 request: "Request",
1187 block_ids: list[int],
1188 ) -> tuple[bool, dict[str, Any] | None]:
1189 """Always returns (False, None). GPU blocks are protected by ref_cnt,
1190 so the scheduler can free blocks immediately."""
1191 req_id = request.request_id
1192
1193 # Release any temp CPU hit pin from get_num_new_matched_tokens()
1194 # if request is canceled or preempted before update_state_after_alloc()
1195 pending = self._pending_cpu_hits.pop(req_id, None)
1196 if pending is not None:
1197 self._free_pending_cpu_hit(pending)
1198
1199 # Handle load: defer cleanup if load is in-flight
1200 load_state = self._reqs_to_load.get(req_id)
1201 if load_state is not None:
1202 if load_state.load_event is not None:
1203 load_state.finished = True # Defer: load in-flight
1204 else:
1205 self._cleanup_load_request(req_id)
1206
1207 # Handle store (eager mode only): defer cleanup if stores in-flight
1208 if not self._lazy_mode:
1209 store_state = self._reqs_to_store.get(req_id)
1210 if store_state is not None:
1211 if store_state.store_events:
1212 store_state.finished = True # Defer: stores in-flight
1213 else:
1214 self._cleanup_store_request(req_id)
1215
1216 return False, None
1217
1218 def request_finished_all_groups(
1219 self,
1220 request: "Request",
1221 block_ids: tuple[list[int], ...],
1222 ) -> tuple[bool, dict[str, Any] | None]:
1223 self._queue_finished_eager_store(request, block_ids)
1224 return self.request_finished(request, block_ids=[])
1225
1226 def _queue_finished_eager_store(
1227 self, request: "Request", block_ids: tuple[list[int], ...]
1228 ) -> None:
1229 """Queue confirmed eager blocks omitted after a request enters decode."""
1230 state = self._reqs_to_store.get(request.request_id)
1231 gpu_pool = self._gpu_block_pool
1232 if state is None or gpu_pool is None:
1233 return
1234 gpu_ids, _, block_meta = self._select_eager_blocks_to_store(state, block_ids)
1235 partial_tail = self._find_fa_partial_tail_source(request, block_ids)
1236 if (
1237 partial_tail is not None
1238 # Decode tokens can fill the boundary block, in which case the
1239 # positional scan above already selected it. Appending again would
1240 # allocate two CPU blocks for one GPU block.
1241 and partial_tail not in gpu_ids
1242 and len(gpu_ids) < self.cpu_block_pool.get_num_free_blocks()
1243 ):
1244 gpu_ids.append(partial_tail)
1245 if block_meta is not None:
1246 # The block's own hashes are registered at completion, so no
1247 # capture is needed here; emit the event without a payload, as
1248 # the size-mismatch fallback above already does.
1249 block_meta.append({})
1250 if not gpu_ids:
1251 return
1252 cpu_blocks = self.cpu_block_pool.get_new_blocks(len(gpu_ids))
1253 self._pending_finished_stores.append(
1254 TransferMeta(gpu_ids, [b.block_id for b in cpu_blocks], block_meta)
1255 )
1256 self._in_flight_store_gpu_blocks.update(gpu_ids)
1257 gpu_pool.touch([gpu_pool.blocks[bid] for bid in gpu_ids])
1258
1259 def _find_fa_partial_tail_source(
1260 self, request: "Request", block_ids: tuple[list[int], ...]
1261 ) -> int | None:
1262 """Locate the full-attention prompt-tail block for a fine-grained hit.
1263
1264 Mirrors ``FullAttentionManager._cache_partial_tail_block``: only the
1265 final prompt hash boundary is eligible, and boundaries that land on a
1266 physical block edge are already covered by the positional scan.
1267
1268 Attention block tables are append-only, so the block is located
1269 positionally, as the mooncake store and offloading connector also do;
1270 no new core hand-off is required. The boundary key is registered at
1271 completion along with the block's other hashes, so nothing has to be
1272 captured here.
1273 """
1274 if not self.cpu_coordinator.enable_partial_hash_hits:
1275 return None
1276 assert self._gpu_block_pool is not None
1277 boundary_tokens = (
1278 request.num_prompt_tokens // self.hash_block_size * self.hash_block_size
1279 )
1280 if boundary_tokens == 0 or boundary_tokens > request.num_computed_tokens:
1281 return None
1282 if boundary_tokens % self.fa_block_size == 0:
1283 return None
1284 block_idx = boundary_tokens // self.fa_block_size
1285 fa_block_ids = block_ids[self.fa_gidx]
1286 if block_idx >= len(fa_block_ids):
1287 return None
1288 gpu_block_id = fa_block_ids[block_idx]
1289 gpu_block = self._gpu_block_pool.blocks[gpu_block_id]
1290 if gpu_block.is_null or gpu_block_id in self._in_flight_store_gpu_blocks:
1291 return None
1292 hash_idx = boundary_tokens // self.hash_block_size - 1
1293 if hash_idx >= len(request.block_hashes):
1294 return None
1295 block_hash = make_block_hash_with_group_id(
1296 request.block_hashes[hash_idx], self.fa_gidx
1297 )
1298 # Only worth copying when the boundary key is actually registered on
1299 # the source block; completion replays the block's own hashes.
1300 if (
1301 gpu_block.block_hash != block_hash
1302 and block_hash
1303 not in self._gpu_block_pool.cached_block_hashes_by_block.get(
1304 gpu_block_id, ()
1305 )
1306 ):
1307 return None
1308 if self.cpu_block_pool.cached_block_hash_to_block.get_one_block(block_hash):
1309 return None
1310 return gpu_block_id
1311
1312 def _free_pending_cpu_hit(
1313 self, pending: tuple[tuple[list["KVCacheBlock"], ...], int, int]
1314 ) -> None:
1315 """Release the temporary CPU block pin taken in get_num_new_matched_tokens()."""
1316 cpu_hit_blocks, _hit_length, _num_computed_tokens = pending
1317 blocks_to_free = [
1318 blk for grp in cpu_hit_blocks for blk in grp if not blk.is_null
1319 ]
1320 if blocks_to_free:
1321 self.cpu_block_pool.free_blocks(blocks_to_free)
1322
1323 def _cleanup_load_request(self, req_id: str) -> int:
1324 """Release all load resources for a request.
1325
1326 Shared between request_finished() and update_connector_output() paths.
1327 Removes the request from _reqs_to_load, cleans up event mappings,
1328 and frees CPU/GPU touch refs.
1329
1330 Returns the number of blocks in the load if it had been issued to
1331 the worker, else 0 (never-issued loads are not counted as completed
1332 by the caller).
1333 """
1334 state = self._reqs_to_load.pop(req_id, None)
1335 if state is None:
1336 state = self._abandoned_reqs_to_load.pop(req_id, None)
1337 if state is None:
1338 return 0
1339 # Remove from load event mapping (only this req, not whole event)
1340 if state.load_event is not None:
1341 reqs = self._load_event_to_reqs.get(state.load_event)
1342 if reqs is not None:
1343 with contextlib.suppress(ValueError):
1344 reqs.remove(req_id)
1345 if not reqs:
1346 self._load_event_to_reqs.pop(state.load_event, None)
1347
1348 if state.transfer_meta is not None:
1349 # Free CPU touch refs
1350 self.cpu_block_pool.free_blocks(
1351 self.cpu_block_pool.blocks[bid]
1352 for bid in state.transfer_meta.cpu_block_ids
1353 )
1354 # Free GPU touch refs
1355 assert self._gpu_block_pool is not None
1356 self._gpu_block_pool.free_blocks(
1357 self._gpu_block_pool.blocks[bid]
1358 for bid in state.transfer_meta.gpu_block_ids
1359 )
1360
1361 if state.load_event is None:
1362 return 0
1363 return len(state.transfer_meta.gpu_block_ids)
1364
1365 def _cleanup_store_request(self, req_id: str) -> None:
1366 """Release store metadata for a request.
1367
1368 Metadata-only cleanup but no block freeing. Job completion handles
1369 block caching and GPU ref freeing via _process_store_completion().
1370 """
1371 state = self._reqs_to_store.pop(req_id, None)
1372 if state is None:
1373 return
1374 for event_idx in list(state.store_events):
1375 if (reqs := self._store_event_to_reqs.get(event_idx)) is not None:
1376 with contextlib.suppress(ValueError):
1377 reqs.remove(req_id)
1378 if not reqs:
1379 self._store_event_to_reqs.pop(event_idx, None)
1380 state.store_events.clear()
1381
1382 def take_events(self) -> Iterable[KVCacheEvent]:
1383 events = self.cpu_block_pool.take_events()
1384 for event in events:
1385 if isinstance(event, BlockRemoved):
1386 event.medium = self.kv_event_medium
1387 event.locality = "LOCAL"
1388 return events
1389
1390 def reset(self) -> bool:
1391 """Abandon pending transfers and reset the CPU cache when safe.
1392
1393 Worker-side DMA may still be using blocks after reset is requested.
1394 Keep those block refs pinned until the existing completion path reports
1395 the transfer finished, then release refs without caching abandoned
1396 store results.
1397 """
1398 self._abandoned_store_event_to_blocks.update(self._store_event_to_blocks)
1399 for transfer in self._pending_finished_stores:
1400 self._release_transfer_refs(transfer)
1401 self._pending_finished_stores.clear()
1402
1403 self._store_event_to_blocks.clear()
1404 self._in_flight_store_gpu_blocks.clear()
1405
1406 # Loads that have not been sent to the worker cannot have running DMA.
1407 # In-flight loads stay pinned and are cleaned up on completion.
1408 for req_id in list(self._reqs_to_load):
1409 state = self._reqs_to_load.pop(req_id)
1410 if state.load_event is None:
1411 self._reqs_to_load[req_id] = state
1412 self._cleanup_load_request(req_id)
1413 else:
1414 self._abandoned_reqs_to_load[req_id] = state
1415
1416 self._reqs_to_store.clear()
1417 self._store_event_to_reqs.clear()
1418 self._store_event_pending_counts = {
1419 event_idx: count
1420 for event_idx, count in self._store_event_pending_counts.items()
1421 if event_idx in self._abandoned_store_event_to_blocks
1422 }
1423 self._cursor = None
1424 # Seed the fresh counters with outcomes since the last drain so a
1425 # mid-interval reset() does not drop them from the next report.
1426 undrained = BoundaryStoreStats(
1427 **{
1428 f.name: max(
1429 0,
1430 getattr(self.boundary_store_stats, f.name)
1431 - getattr(self._boundary_stats_snapshot, f.name),
1432 )
1433 for f in fields(BoundaryStoreStats)
1434 }
1435 )
1436 self.boundary_store_stats = undrained
1437 self._boundary_stats_snapshot = BoundaryStoreStats()
1438 # NOTE: _load_event_counter / _store_event_counter are not
1439 # reset as they are monotonic and must stay ahead of the workers
1440 # high-water marks to avoid event index collisions
1441
1442 if self._abandoned_store_event_to_blocks or self._abandoned_reqs_to_load:
1443 return False
1444
1445 return self.cpu_block_pool.reset_prefix_cache()