main
py 6,282 lines 229 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Compare the with and without prefix caching."""
4
5 import copy
6 from collections.abc import Callable
7 from dataclasses import replace
8 from math import lcm
9 from types import MethodType, SimpleNamespace
10 from unittest.mock import MagicMock
11
12 import pytest
13 import torch
14
15 import vllm.v1.core.kv_cache_manager as kv_cache_manager
16 import vllm.v1.core.kv_cache_utils as kv_cache_utils
17 from vllm.distributed.kv_events import (
18 MEDIUM_CPU,
19 MEDIUM_GPU,
20 AllBlocksCleared,
21 BlockRemoved,
22 BlockStored,
23 )
24 from vllm.distributed.kv_transfer.kv_connector.v1.hisparse.connector import (
25 HiSparseConnector,
26 HiSparseConnectorScheduler,
27 HiSparseConnectorWorkerMetadata,
28 )
29 from vllm.distributed.kv_transfer.kv_connector.v1.multi_connector import MultiConnector
30 from vllm.distributed.kv_transfer.kv_connector.v1.offloading_connector import (
31 OffloadingConnector,
32 )
33 from vllm.lora.request import LoRARequest
34 from vllm.multimodal.inputs import (
35 MultiModalFeatureSpec,
36 MultiModalKwargsItem,
37 PlaceholderRange,
38 )
39 from vllm.sampling_params import SamplingParams
40 from vllm.utils.hashing import sha256, sha256_cbor
41 from vllm.v1.core.block_pool import BlockHashToBlockMap, BlockPool
42 from vllm.v1.core.kv_cache_manager import KVCacheBlocks, KVCacheManager, Request
43 from vllm.v1.core.kv_cache_utils import (
44 BlockHash,
45 BlockHashWithGroupId,
46 KVCacheBlock,
47 get_block_hash,
48 get_group_id,
49 get_request_block_hasher,
50 hash_block_tokens,
51 init_none_hash,
52 make_block_hash_with_group_id,
53 )
54 from vllm.v1.core.sched.output import SchedulerOutput
55 from vllm.v1.core.sched.scheduler import Scheduler
56 from vllm.v1.hisparse.coordinator import get_hisparse_coordinator
57 from vllm.v1.kv_cache_interface import (
58 FullAttentionSpec,
59 HiSparseHotSpec,
60 HiSparseResidentSpec,
61 KVCacheConfig,
62 KVCacheGroupRole,
63 KVCacheGroupSpec,
64 KVCacheSpecKind,
65 MambaSpec,
66 MLAAttentionSpec,
67 SlidingWindowSpec,
68 )
69 from vllm.v1.outputs import KVConnectorOutput
70 from vllm.v1.request import RequestStatus
71
72 pytestmark = pytest.mark.cpu_test
73
74
75 @pytest.fixture(autouse=True)
76 def _auto_init_hash_fn(request):
77 hash_fn: Callable
78 if "hash_fn" in request.fixturenames:
79 hash_fn = request.getfixturevalue("hash_fn")
80 else:
81 hash_fn = sha256
82 init_none_hash(hash_fn)
83
84
85 def make_request(
86 request_id: str,
87 prompt_token_ids: list[int],
88 block_size: int,
89 hash_fn: Callable,
90 mm_positions: list[PlaceholderRange] | None = None,
91 mm_hashes: list[str] | None = None,
92 prompt_logprobs: int | None = None,
93 cache_salt: str | None = None,
94 lora_request: LoRARequest | None = None,
95 session_id: str | None = None,
96 ):
97 mm_features = []
98 if mm_positions is not None:
99 for j, position in enumerate(mm_positions):
100 identifier = mm_hashes[j] if mm_hashes else f"hash_{j}"
101 mm_feature = MultiModalFeatureSpec(
102 data=MultiModalKwargsItem.dummy(),
103 mm_position=position,
104 identifier=identifier,
105 modality="image",
106 )
107 mm_features.append(mm_feature)
108
109 sampling_params = SamplingParams(max_tokens=17, prompt_logprobs=prompt_logprobs)
110 sampling_params.update_from_generation_config({}, eos_token_id=100)
111
112 return Request(
113 request_id=request_id,
114 prompt_token_ids=prompt_token_ids,
115 mm_features=mm_features if mm_features else None,
116 sampling_params=sampling_params,
117 pooling_params=None,
118 lora_request=lora_request,
119 cache_salt=cache_salt,
120 block_hasher=get_request_block_hasher(block_size, hash_fn),
121 session_id=session_id,
122 )
123
124
125 def make_kv_cache_manager(kv_cache_config: KVCacheConfig, **kwargs) -> KVCacheManager:
126 """Build a ``KVCacheManager``, deriving ``scheduler_block_size`` from the
127 config (LCM of group block sizes) unless explicitly provided. This mirrors
128 ``resolve_kv_cache_block_sizes`` for the non-context-parallel case used by
129 these tests, so callers don't have to pass it at every site."""
130 kwargs.setdefault(
131 "scheduler_block_size",
132 lcm(*(g.kv_cache_spec.block_size for g in kv_cache_config.kv_cache_groups)),
133 )
134 if "retention_interval" in kwargs:
135 kv_cache_config = replace(
136 kv_cache_config,
137 prefix_cache_retention_interval=kwargs.pop("retention_interval"),
138 )
139 manager = KVCacheManager(kv_cache_config, **kwargs)
140 if kv_cache_config.hisparse_host_num_blocks is not None:
141 get_hisparse_coordinator(manager)
142 return manager
143
144
145 def make_kv_cache_config(block_size: int, num_blocks: int) -> KVCacheConfig:
146 return KVCacheConfig(
147 num_blocks=num_blocks,
148 kv_cache_tensors=[],
149 kv_cache_groups=[
150 KVCacheGroupSpec(
151 ["layer"],
152 FullAttentionSpec(
153 block_size=block_size,
154 num_kv_heads=1,
155 head_size=1,
156 dtype=torch.float32,
157 ),
158 )
159 ],
160 )
161
162
163 HISPARSE_BLOCK_SIZE = 16
164
165
166 def make_hisparse_kv_cache_config(
167 num_blocks: int,
168 host_num_blocks: int,
169 *,
170 transfer_device_cache: bool = False,
171 num_resident_groups: int = 1,
172 ) -> KVCacheConfig:
173 source_spec = FullAttentionSpec(
174 block_size=HISPARSE_BLOCK_SIZE,
175 num_kv_heads=1,
176 head_size=1,
177 dtype=torch.float32,
178 )
179 groups = [
180 KVCacheGroupSpec(
181 ["source"],
182 source_spec,
183 role=KVCacheGroupRole.HISPARSE_SOURCE,
184 host_resident=True,
185 ),
186 KVCacheGroupSpec(
187 ["indexer"],
188 source_spec,
189 enable_kv_transfer=transfer_device_cache,
190 role=KVCacheGroupRole.HISPARSE_INDEXER,
191 ),
192 ]
193 groups.append(
194 KVCacheGroupSpec(
195 ["resident"],
196 HiSparseResidentSpec(
197 block_size=HISPARSE_BLOCK_SIZE,
198 page_size=HISPARSE_BLOCK_SIZE * 4,
199 ),
200 enable_kv_transfer=transfer_device_cache,
201 )
202 )
203 groups.append(
204 KVCacheGroupSpec(
205 ["hot"],
206 HiSparseHotSpec(
207 block_size=HISPARSE_BLOCK_SIZE,
208 page_size=HISPARSE_BLOCK_SIZE * 4,
209 blocks_per_request=2,
210 ),
211 enable_kv_transfer=False,
212 )
213 )
214 for group_idx in range(1, num_resident_groups):
215 groups.extend(
216 replace(
217 group, layer_names=[f"{name}_{group_idx}" for name in group.layer_names]
218 )
219 for group in groups[2:4]
220 )
221 return KVCacheConfig(
222 num_blocks=num_blocks,
223 hisparse_host_num_blocks=host_num_blocks,
224 kv_cache_tensors=[],
225 kv_cache_groups=groups,
226 )
227
228
229 def make_hisparse_kv_cache_manager(
230 num_blocks: int,
231 host_num_blocks: int,
232 *,
233 max_model_len: int = 128,
234 max_in_flight_tokens: int | None = None,
235 enable_caching: bool = False,
236 num_prefill_lookahead: int = 0,
237 **config_kwargs,
238 ) -> KVCacheManager:
239 config = make_hisparse_kv_cache_config(num_blocks, host_num_blocks, **config_kwargs)
240 return make_kv_cache_manager(
241 config,
242 max_model_len=max_model_len,
243 max_in_flight_tokens=max_in_flight_tokens,
244 enable_caching=enable_caching,
245 hash_block_size=HISPARSE_BLOCK_SIZE,
246 num_prefill_lookahead=num_prefill_lookahead,
247 )
248
249
250 def _allocate_scheduled(
251 manager: KVCacheManager,
252 request: Request,
253 num_new_tokens: int,
254 *,
255 num_new_computed_tokens: int = 0,
256 num_external_computed_tokens: int = 0,
257 **kwargs,
258 ) -> KVCacheBlocks | None:
259 """``allocate_slots`` plus the per-step residency work the connector runs."""
260 blocks = manager.allocate_slots(
261 request,
262 num_new_tokens,
263 num_new_computed_tokens=num_new_computed_tokens,
264 num_external_computed_tokens=num_external_computed_tokens,
265 **kwargs,
266 )
267 if blocks is None or kwargs.get("delay_cache_blocks"):
268 return blocks
269 num_computed_tokens = min(
270 request.num_computed_tokens
271 + num_new_computed_tokens
272 + num_external_computed_tokens,
273 manager.max_model_len,
274 )
275 num_tokens = min(num_computed_tokens + num_new_tokens, request.num_tokens)
276 get_hisparse_coordinator(manager).advance_scheduled(
277 [(request.request_id, num_tokens)]
278 )
279 return blocks
280
281
282 def test_hisparse_does_not_write_back_reprefillable_tokens():
283 """Multi-module MTP re-prefills the last tokens, so they are not final."""
284 manager = make_hisparse_kv_cache_manager(32, 16, num_prefill_lookahead=3)
285 request = make_request(
286 "request", list(range(2 * HISPARSE_BLOCK_SIZE)), HISPARSE_BLOCK_SIZE, sha256
287 )
288 assert _allocate_scheduled(manager, request, 2 * HISPARSE_BLOCK_SIZE)
289 transfers = get_hisparse_coordinator(manager).build_offload_command().page_transfers
290 assert len(transfers) == 1
291
292
293 def test_hisparse_builds_dma_row_mirrors_across_pages():
294 manager = make_hisparse_kv_cache_manager(32, 16)
295 request = make_request(
296 "request",
297 list(range(2 * HISPARSE_BLOCK_SIZE)),
298 HISPARSE_BLOCK_SIZE,
299 sha256,
300 )
301 assert _allocate_scheduled(manager, request, num_new_tokens=32) is not None
302 coordinator = get_hisparse_coordinator(manager)
303 resident_blocks = coordinator.resident_managers[0].req_to_blocks[request.request_id]
304 host_blocks = coordinator.host_manager.req_to_blocks[request.request_id]
305
306 mirrors = coordinator.build_row_mirrors([(request.request_id, 14, 4)])
307
308 assert [mirror.source_starts for mirror in mirrors] == [
309 (resident_blocks[0].block_id * HISPARSE_BLOCK_SIZE + 14,),
310 (resident_blocks[1].block_id * HISPARSE_BLOCK_SIZE,),
311 ]
312 assert [mirror.destination_start for mirror in mirrors] == [
313 host_blocks[0].block_id * HISPARSE_BLOCK_SIZE + 14,
314 host_blocks[1].block_id * HISPARSE_BLOCK_SIZE,
315 ]
316 assert [mirror.num_rows for mirror in mirrors] == [2, 2]
317
318
319 def test_hisparse_async_speculation_mirrors_uncertain_position_range():
320 """Unresolved drafts must not leave gaps in the eager host mirror."""
321 coordinator = MagicMock(host_group_id=0)
322 coordinator.take_block_table_updates.return_value = {}
323 coordinator.build_offload_command.return_value = None
324 coordinator.build_row_mirrors.return_value = ()
325 scheduler = HiSparseConnectorScheduler(
326 async_speculative=True,
327 draft_kv_lookahead=4,
328 )
329 scheduler.bind_coordinator(coordinator)
330 request = SimpleNamespace(request_id="request", num_output_placeholders=3)
331 connector = object.__new__(HiSparseConnector)
332 connector.connector_scheduler = scheduler
333 connector.update_state_after_alloc(request, None, 0)
334 scheduler_output = SimpleNamespace(
335 block_table_updates=None,
336 kv_cache_block_copies=None,
337 scheduled_new_reqs=[],
338 scheduled_cached_reqs=SimpleNamespace(
339 new_block_ids=[],
340 req_ids=["request"],
341 num_computed_tokens=[103],
342 ),
343 num_scheduled_tokens={"request": 4},
344 )
345
346 scheduler.build_connector_meta(scheduler_output)
347
348 coordinator.build_row_mirrors.assert_called_once_with((("request", 100, 11),))
349
350 # Acceptance arrives between steps; the connector must read the live count.
351 request.num_output_placeholders = 1
352 scheduler.build_connector_meta(scheduler_output)
353 coordinator.build_row_mirrors.assert_called_with((("request", 102, 9),))
354
355 connector.request_finished_all_groups(request, ())
356 assert not scheduler.requests
357
358
359 def test_hisparse_reports_when_context_is_fully_resident():
360 """A page the pool reuses under a host-reading request stops being resident."""
361 manager = make_hisparse_kv_cache_manager(32, 16)
362 tokens = list(range(4 * HISPARSE_BLOCK_SIZE))
363 request = make_request("request", tokens, HISPARSE_BLOCK_SIZE, sha256)
364 assert _allocate_scheduled(manager, request, num_new_tokens=len(tokens)) is not None
365 coordinator = get_hisparse_coordinator(manager)
366 scheduled = ((request.request_id, len(tokens) - 1, 1),)
367 assert coordinator.all_context_pages_resident(scheduled)
368
369 for hot_manager in coordinator.hot_managers:
370 hot_manager.require_hot(request.request_id)
371 assert _allocate_scheduled(manager, request, num_new_tokens=len(tokens)) is not None
372 _publish_hisparse_pages(manager)
373 pool = manager.block_pool
374 pool.get_new_blocks(pool.get_num_free_blocks())
375
376 assert not coordinator.all_context_pages_resident(scheduled)
377 assert coordinator.take_block_table_updates().keys() == {request.request_id}
378
379
380 def test_hisparse_host_prefix_can_be_completed_by_indexer_offload():
381 """Restore the final host page when completing an indexer-only import."""
382 manager = make_hisparse_kv_cache_manager(
383 32,
384 16,
385 enable_caching=True,
386 )
387 tokens = list(range(4 * HISPARSE_BLOCK_SIZE))
388 original = make_request("original", tokens, HISPARSE_BLOCK_SIZE, sha256)
389 assert (
390 _allocate_scheduled(manager, original, num_new_tokens=len(tokens)) is not None
391 )
392 spills = get_hisparse_coordinator(manager).build_offload_command().page_transfers
393 spill_counts = {spill.transfer_id: 1 for spill in spills}
394 get_hisparse_coordinator(manager).update_spills(spill_counts, spill_counts)
395 _, indexer_blocks, _, _ = manager.get_blocks(original.request_id).blocks
396 evicted_indexer_id = indexer_blocks[2].block_id
397 manager.free(original)
398 manager.block_pool.evict_blocks({evicted_indexer_id})
399
400 resumed = make_request("resumed", tokens, HISPARSE_BLOCK_SIZE, sha256)
401 blocks, num_local, _ = manager.get_computed_blocks(resumed)
402 max_completion = HISPARSE_BLOCK_SIZE
403 assert num_local == 2 * HISPARSE_BLOCK_SIZE
404 assert [len(group_blocks) for group_blocks in blocks.blocks] == [3, 2, 0, 0]
405
406 allocated = _allocate_scheduled(
407 manager,
408 resumed,
409 num_new_tokens=1,
410 num_new_computed_tokens=num_local,
411 new_computed_blocks=blocks,
412 num_external_computed_tokens=max_completion,
413 )
414
415 assert allocated is not None
416 source, indexer, resident, hot = manager.get_blocks(resumed.request_id).blocks
417 assert len(source) == len(indexer) == len(resident) == 4
418 assert len(hot) == 2
419 assert not any(block.is_null for block in resident[:2])
420 assert not resident[2].is_null
421 assert not resident[3].is_null
422 coordinator = get_hisparse_coordinator(manager)
423 assert not coordinator.build_offload_command().page_transfers
424 coordinator.finish_host_import(resumed.request_id, failed=False)
425 transfers = coordinator.build_offload_command().page_transfers
426 assert len(transfers) == 1 and transfers[0].restore
427 assert transfers[0].resident_block_ids == (resident[2].block_id,)
428
429
430 def test_hisparse_indexer_offload_is_capped_by_missing_host_prefix():
431 manager = make_hisparse_kv_cache_manager(
432 32,
433 16,
434 enable_caching=True,
435 )
436 tokens = list(range(4 * HISPARSE_BLOCK_SIZE))
437 original = make_request("original", tokens, HISPARSE_BLOCK_SIZE, sha256)
438 assert (
439 _allocate_scheduled(manager, original, num_new_tokens=len(tokens)) is not None
440 )
441 spills = get_hisparse_coordinator(manager).build_offload_command().page_transfers
442 spill_counts = {spill.transfer_id: 1 for spill in spills}
443 get_hisparse_coordinator(manager).update_spills(spill_counts, spill_counts)
444 host_blocks, _, _, _ = manager.get_blocks(original.request_id).blocks
445 evicted_host_id = host_blocks[0].block_id
446 manager.free(original)
447 get_hisparse_coordinator(manager).host_manager.block_pool.evict_blocks(
448 {evicted_host_id}
449 )
450
451 resumed = make_request("resumed", tokens, HISPARSE_BLOCK_SIZE, sha256)
452 _, num_local, _ = manager.get_computed_blocks(resumed)
453 connector = object.__new__(OffloadingConnector)
454 connector._kv_cache_config = manager.kv_cache_config
455 connector.bind_kv_cache_manager(manager)
456 assert num_local == 0
457 assert connector._max_loadable_tokens(resumed, num_local) == 0
458
459
460 @pytest.mark.parametrize("nested", [False, True])
461 @pytest.mark.parametrize(
462 "available_tokens,imported_tokens,host_blocks",
463 [(0, 0, 2), (16, 16, 2), (64, 32, 3)],
464 )
465 def test_connector_completes_partial_prefix_without_importing_missing_host_kv(
466 nested, available_tokens, imported_tokens, host_blocks
467 ):
468 manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
469 tokens = list(range(64))
470 original = make_request("original", tokens, HISPARSE_BLOCK_SIZE, sha256)
471 assert _allocate_scheduled(manager, original, num_new_tokens=64) is not None
472 _publish_hisparse_pages(manager)
473 host, indexer, _, _ = manager.get_blocks(original.request_id).blocks
474 missing_indexer = indexer[1].block_id
475 missing_host = host[0].block_id
476 manager.free(original)
477 manager.block_pool.evict_blocks({missing_indexer})
478
479 defer = True
480
481 def lookup(request, num_computed_tokens, max_num_new_tokens=None):
482 nonlocal defer
483 if defer:
484 defer = False
485 return None, False
486 available = available_tokens
487 if max_num_new_tokens is not None:
488 available = min(available, max_num_new_tokens)
489 return available, available > 0
490
491 connector = object.__new__(OffloadingConnector)
492 connector._kv_cache_config = manager.kv_cache_config
493 connector.connector_scheduler = SimpleNamespace(get_num_new_matched_tokens=lookup)
494 if nested:
495 for _ in range(2):
496 outer = object.__new__(MultiConnector)
497 outer._connectors = (connector,)
498 outer._requests_to_connector = {}
499 connector = outer
500 connector.bind_kv_cache_manager(manager)
501 scheduler = SimpleNamespace(connector=connector, kv_cache_manager=manager)
502 resumed = make_request("resumed", tokens, HISPARSE_BLOCK_SIZE, sha256)
503
504 blocks, local, _, diverged = Scheduler._get_local_prefix_cache_hit(
505 scheduler, resumed
506 )
507 assert local == 16 and not diverged
508 assert connector.get_num_new_matched_tokens(resumed, local) == (None, False)
509
510 blocks, local, _, diverged = Scheduler._get_local_prefix_cache_hit(
511 scheduler, resumed
512 )
513 external, asynchronous = connector.get_num_new_matched_tokens(resumed, local)
514 assert external == imported_tokens
515 assert asynchronous == (external > 0)
516 assert [len(group) for group in blocks.blocks] == [3, 1, 0, 0]
517 assert (
518 _allocate_scheduled(
519 manager,
520 resumed,
521 int(external == 0),
522 delay_cache_blocks=True,
523 num_new_computed_tokens=local,
524 new_computed_blocks=blocks,
525 num_external_computed_tokens=external,
526 )
527 is not None
528 )
529 allocated = manager.get_blocks(resumed.request_id).blocks
530 assert len(allocated[0]) == host_blocks
531 assert allocated[2][1].is_null == (host_blocks > 2)
532 assert not allocated[2][-1].is_null
533 manager.free(resumed)
534
535 # A surviving indexer offload must not make absent host KV look computed.
536 get_hisparse_coordinator(manager).host_manager.block_pool.evict_blocks(
537 {missing_host}
538 )
539 retry = make_request("retry", tokens, HISPARSE_BLOCK_SIZE, sha256)
540 _, local, _, _ = Scheduler._get_local_prefix_cache_hit(scheduler, retry)
541 assert local == 0
542 assert connector.get_num_new_matched_tokens(retry, local) == (0, False)
543
544
545 def allocate_external_prefix(
546 manager: KVCacheManager, request: Request, num_tokens: int
547 ) -> KVCacheBlocks | None:
548 return _allocate_scheduled(
549 manager,
550 request,
551 num_new_tokens=0,
552 num_external_computed_tokens=num_tokens,
553 delay_cache_blocks=True,
554 full_sequence_must_fit=True,
555 )
556
557
558 @pytest.mark.parametrize("num_tokens", [1, 15, 16, 17, 31, 32, 33, 127])
559 def test_nixl_hisparse_full_block_import_keeps_a_writable_tail(num_tokens):
560 """Import every real token and restore the page used by last-token replay."""
561 from tests.v1.kv_connector.unit.utils import create_vllm_config
562 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.pull_scheduler import (
563 NixlPullConnectorScheduler,
564 )
565
566 manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
567 config = create_vllm_config(block_size=HISPARSE_BLOCK_SIZE)
568 connector = NixlPullConnectorScheduler(config, "test", manager.kv_cache_config)
569 request = make_request(
570 "import", list(range(num_tokens)), HISPARSE_BLOCK_SIZE, sha256
571 )
572 request.kv_transfer_params = {"do_remote_prefill": True}
573 count, is_async = connector.get_num_new_matched_tokens(request, 0)
574 assert count == num_tokens and is_async
575 assert allocate_external_prefix(manager, request, count) is not None
576 source, _, resident, _ = manager.get_blocks(request.request_id).blocks
577 assert len(source) == len(resident) == (num_tokens + 15) // 16
578 assert all(block.is_null for block in resident[:-1])
579 assert not resident[-1].is_null
580 coordinator = get_hisparse_coordinator(manager)
581 assert not coordinator.build_offload_command().page_transfers
582
583 coordinator.finish_host_import(request.request_id, failed=False)
584 transfers = coordinator.build_offload_command().page_transfers
585 assert len(transfers) == 1
586 restore = transfers[0]
587 assert restore.restore and not restore.after_forward
588 assert restore.host_block_id == source[-1].block_id
589 assert restore.resident_block_ids == (resident[-1].block_id,)
590 request.num_computed_tokens = count
591 scheduler = SimpleNamespace(
592 connector=connector,
593 kv_cache_manager=manager,
594 failed_recving_kv_req_ids=set(),
595 finished_recving_kv_req_ids={request.request_id},
596 prefix_replay_tokens=0,
597 )
598 scheduler._mark_prefix_replay = MethodType(Scheduler._mark_prefix_replay, scheduler)
599 Scheduler._update_waiting_for_remote_kv(scheduler, request)
600 assert request.num_tokens - request.num_computed_tokens == 1
601 assert _allocate_scheduled(manager, request, num_new_tokens=1) is not None
602 assert coordinator.build_row_mirrors([(request.request_id, num_tokens - 1, 1)])
603 # A pending restore must not expose uninitialized GPU copies to prefix hits.
604 assert all(resident[-1] not in copies for copies in coordinator.copies.values())
605
606 coordinator.update_spills({restore.transfer_id: 2}, {restore.transfer_id: 1})
607 assert resident[-1].ref_cnt == 2
608 assert all(resident[-1] not in copies for copies in coordinator.copies.values())
609 coordinator.update_spills({}, {restore.transfer_id: 1})
610 assert resident[-1].ref_cnt == 1
611 assert coordinator.request_states[request.request_id].valid_pages == set(
612 range(num_tokens // HISPARSE_BLOCK_SIZE)
613 )
614 if num_tokens % HISPARSE_BLOCK_SIZE:
615 assert source[-1].block_hash is None
616 else:
617 assert coordinator.copies[source[-1].block_hash] == (resident[-1],)
618
619
620 def test_hisparse_aborted_tail_restore_retains_both_endpoints():
621 """An abort cannot recycle storage still used by a queued tail restore."""
622 manager = make_hisparse_kv_cache_manager(16, 16)
623 request = make_request("import", list(range(17)), HISPARSE_BLOCK_SIZE, sha256)
624 assert allocate_external_prefix(manager, request, 17) is not None
625 source, _, resident, _ = manager.get_blocks(request.request_id).blocks
626 host_tail, gpu_tail = source[-1], resident[-1]
627 coordinator = get_hisparse_coordinator(manager)
628 coordinator.finish_host_import(request.request_id, failed=False)
629 restore = coordinator.build_offload_command().page_transfers[0]
630 manager.free(request)
631 assert host_tail.ref_cnt == gpu_tail.ref_cnt == 1
632 coordinator.update_spills({restore.transfer_id: 2}, {restore.transfer_id: 1})
633 assert host_tail.ref_cnt == gpu_tail.ref_cnt == 1
634 coordinator.update_spills({}, {restore.transfer_id: 1})
635 assert host_tail.ref_cnt == gpu_tail.ref_cnt == 0
636 assert not coordinator.has_pending_work()
637
638
639 @pytest.mark.parametrize("enable_caching", [False, True])
640 def test_hisparse_keeps_resident_pages_until_hot_buffer_is_allocated(enable_caching):
641 """Later admissions must not remove a scheduled request's only readable tier."""
642 manager = make_hisparse_kv_cache_manager(
643 32, 32, max_model_len=256, enable_caching=enable_caching
644 )
645 first = make_request("first", list(range(160)), HISPARSE_BLOCK_SIZE, sha256)
646 assert _allocate_scheduled(manager, first, num_new_tokens=128) is not None
647 _publish_hisparse_pages(manager)
648 first.num_computed_tokens = 128
649 pool = manager.block_pool
650 held = pool.get_new_blocks(pool.get_num_free_blocks() - 3)
651
652 assert _allocate_scheduled(manager, first, num_new_tokens=1) is not None
653 second = make_request("second", [1000] * 16, HISPARSE_BLOCK_SIZE, sha256)
654 _allocate_scheduled(manager, second, num_new_tokens=16)
655 coordinator = get_hisparse_coordinator(manager)
656 scheduled = (("first", 128, 1),)
657 assert coordinator.all_context_pages_resident(scheduled)
658 assert all(not m.has_hot("first") for m in coordinator.hot_managers)
659
660 # Once another allocation finishes, A can acquire its hot buffer and
661 # safely release its clean pages for other requests to reuse.
662 pool.free_blocks(held)
663 assert _allocate_scheduled(manager, first, num_new_tokens=1) is not None
664 assert all(m.has_hot("first") for m in coordinator.hot_managers)
665 pool.get_new_blocks(pool.get_num_free_blocks())
666 assert not coordinator.all_context_pages_resident(scheduled)
667 assert "first" in coordinator.take_block_table_updates()
668
669
670 def test_hisparse_full_pool_keeps_pages_pinned_until_preemption():
671 """Without room for a hot buffer, admission must defer instead of losing KV."""
672 manager = make_hisparse_kv_cache_manager(18, 18, max_model_len=160)
673 first = make_request("first", list(range(128)), HISPARSE_BLOCK_SIZE, sha256)
674 assert _allocate_scheduled(manager, first, num_new_tokens=128) is not None
675 _publish_hisparse_pages(manager)
676 assert manager.block_pool.get_num_free_blocks() == 1
677 first.num_computed_tokens = 128
678 assert _allocate_scheduled(manager, first, num_new_tokens=1) is None
679 assert get_hisparse_coordinator(manager).all_context_pages_resident(
680 (("first", 127, 1),)
681 )
682
683 manager.free(first)
684 second = make_request("second", [1000] * 16, HISPARSE_BLOCK_SIZE, sha256)
685 assert _allocate_scheduled(manager, second, num_new_tokens=16) is not None
686
687
688 @pytest.mark.parametrize("num_resident_groups", [1, 3])
689 def test_hisparse_materializes_prefix_without_allocating_hot_blocks(
690 num_resident_groups,
691 ):
692 """A host prefix becomes visible only when every page is durable.
693
694 Completing a later page first must not expose a prefix with a hole.
695 """
696 manager = make_hisparse_kv_cache_manager(
697 32,
698 16,
699 enable_caching=True,
700 num_resident_groups=num_resident_groups,
701 )
702 tokens = list(range(2 * HISPARSE_BLOCK_SIZE))
703 request = make_request("resident", tokens, HISPARSE_BLOCK_SIZE, sha256)
704 assert _allocate_scheduled(manager, request, num_new_tokens=len(tokens)) is not None
705 blocks = manager.get_block_ids(request.request_id)
706 assert len(blocks[2]) == 2
707 assert blocks[3] == []
708
709 spills = get_hisparse_coordinator(manager).build_offload_command().page_transfers
710 assert len(spills) == 2
711 assert all(spill.after_forward for spill in spills)
712 resident_blocks = manager.get_blocks(request.request_id).blocks[2]
713 assert [block.ref_cnt for block in resident_blocks] == [2, 2]
714 spill_counts = {spill.transfer_id: 1 for spill in spills}
715 duplicate = make_request(
716 "duplicate",
717 list(range(3 * HISPARSE_BLOCK_SIZE)),
718 HISPARSE_BLOCK_SIZE,
719 sha256,
720 )
721 _, num_computed, _ = manager.get_computed_blocks(duplicate)
722 assert num_computed == 0
723
724 get_hisparse_coordinator(manager).update_spills(spill_counts, {})
725 assert [block.ref_cnt for block in resident_blocks] == [2, 2]
726 _, num_computed, _ = manager.get_computed_blocks(duplicate)
727 assert num_computed == 0
728
729 get_hisparse_coordinator(manager).update_spills({}, {spills[1].transfer_id: 1})
730 assert [block.ref_cnt for block in resident_blocks] == [2, 1]
731 _, num_computed, _ = manager.get_computed_blocks(duplicate)
732 assert num_computed == 0
733
734 get_hisparse_coordinator(manager).update_spills({}, {spills[0].transfer_id: 1})
735 _, num_computed, _ = manager.get_computed_blocks(duplicate)
736 assert num_computed == 2 * HISPARSE_BLOCK_SIZE
737 blocks = manager.get_block_ids(request.request_id)
738 assert len(blocks[2]) == 2
739 assert blocks[3] == []
740
741
742 @pytest.mark.parametrize("num_resident_groups", [1, 3])
743 def test_hisparse_materialization_respects_per_step_spill_budget(num_resident_groups):
744 """Prefix publication must not bypass the configured spill batch limit."""
745 manager = make_hisparse_kv_cache_manager(
746 32,
747 16,
748 enable_caching=True,
749 num_resident_groups=num_resident_groups,
750 )
751 coordinator = get_hisparse_coordinator(manager)
752 coordinator.max_spill_pages = 1
753 tokens = list(range(2 * HISPARSE_BLOCK_SIZE))
754 request = make_request("bounded", tokens, HISPARSE_BLOCK_SIZE, sha256)
755
756 assert _allocate_scheduled(manager, request, num_new_tokens=len(tokens)) is not None
757 first = coordinator.build_offload_command().page_transfers
758 assert len(first) == 1
759
760 coordinator.plan_prefix_materialization(request.request_id, len(tokens))
761 second = coordinator.build_offload_command().page_transfers
762 assert len(second) == 1
763 assert first[0].transfer_id != second[0].transfer_id
764
765
766 def test_hisparse_deferred_free_retains_host_blocks_until_fence():
767 """Detaching a request must not make in-flight host pages reusable."""
768 manager = make_hisparse_kv_cache_manager(16, 16)
769 host_pool = get_hisparse_coordinator(manager).get_host_block_pool()
770 assert host_pool is not None
771 host_free = host_pool.get_num_free_blocks()
772 device_free = manager.block_pool.get_num_free_blocks()
773 request = make_request("free", list(range(16)), HISPARSE_BLOCK_SIZE, sha256)
774 assert (
775 _allocate_scheduled(manager, request, 16, delay_cache_blocks=True) is not None
776 )
777 blocks = manager.pop_blocks_for_free(request)
778 assert host_pool.get_num_free_blocks() < host_free
779 assert blocks and any(host_pool.blocks[b.block_id] is b for b in blocks)
780 manager.block_pool.free_blocks(reversed(blocks))
781 assert host_pool.get_num_free_blocks() == host_free
782 assert manager.block_pool.get_num_free_blocks() == device_free
783
784
785 def test_hisparse_host_cow_copy_is_drained_without_a_gpu_pool():
786 """Host-only copy-on-write work must reach the worker copy queue."""
787 manager = make_hisparse_kv_cache_manager(16, 16)
788 source_manager = manager.coordinator.single_type_managers[0]
789 source_block = source_manager.block_pool.get_new_blocks(1)[0]
790 source_manager.req_to_blocks["cow"] = [source_block]
791 source_manager._partial_hit_reqs["cow"] = (0, source_block)
792
793 new_blocks = source_manager.allocate_new_blocks(
794 "cow", HISPARSE_BLOCK_SIZE, HISPARSE_BLOCK_SIZE
795 )
796 new_block_ids = manager.take_new_block_ids()
797 coordinator = get_hisparse_coordinator(manager)
798 assert manager.take_kv_cache_block_copies() == ([], [])
799 connector = HiSparseConnectorScheduler(async_speculative=False)
800 connector.bind_coordinator(coordinator)
801 output = SchedulerOutput.make_empty()
802 copies = connector.build_connector_meta(output).host_block_copies
803 assert output.has_sync_kv_loads
804
805 assert new_blocks and new_block_ids == []
806 assert len(copies) == 1
807 assert copies[0].src_block_id == source_block.block_id
808 assert copies[0].dst_block_id == new_blocks[0].block_id
809 source_manager.free("cow")
810 assert source_block.ref_cnt > 0 and new_blocks[0].ref_cnt > 0
811 assert coordinator.has_pending_work()
812 connector.update_connector_output(
813 KVConnectorOutput(
814 kv_connector_worker_meta=HiSparseConnectorWorkerMetadata(
815 {}, {}, completed_host_copy_dst_ids=(copies[0].dst_block_id,)
816 )
817 )
818 )
819 assert source_block.ref_cnt == new_blocks[0].ref_cnt == 0
820 assert not coordinator.has_pending_work()
821
822
823 def test_hisparse_inflight_host_import_reserves_remaining_gpu_pages():
824 """An in-flight host import must reserve its unwritten resident pages."""
825 manager = make_hisparse_kv_cache_manager(
826 12,
827 16,
828 transfer_device_cache=True,
829 )
830 request = make_request(
831 "partial-import",
832 list(range(6 * HISPARSE_BLOCK_SIZE)),
833 HISPARSE_BLOCK_SIZE,
834 sha256,
835 )
836 imported_tokens = 4 * HISPARSE_BLOCK_SIZE
837
838 assert allocate_external_prefix(manager, request, imported_tokens) is not None
839 required = manager.coordinator.get_num_blocks_to_allocate(
840 request_id=request.request_id,
841 num_tokens=request.num_tokens,
842 new_computed_blocks=manager.empty_kv_cache_blocks.blocks,
843 num_encoder_tokens=0,
844 total_computed_tokens=imported_tokens,
845 num_local_computed_tokens=0,
846 num_tokens_main_model=request.num_tokens,
847 apply_admission_cap=True,
848 )
849
850 assert required == 4
851
852
853 @pytest.mark.parametrize("host_num_blocks,admitted", [(5, False), (6, True)])
854 def test_hisparse_async_admission_requires_full_prompt_host_pages(
855 host_num_blocks, admitted, tmp_path
856 ):
857 """An async load is admitted only if its whole prompt's host pages fit.
858
859 The in-flight prefill's host page is not free, so the waiting request's
860 four prompt pages need six host blocks including the null block.
861 """
862 from .utils import create_scheduler, mock_kv
863
864 (tmp_path / "config.json").write_text(
865 '{"architectures": ["OPTForCausalLM"], "model_type": "opt"}'
866 )
867 scheduler = create_scheduler(
868 model=str(tmp_path),
869 skip_tokenizer_init=True,
870 max_model_len=128,
871 use_kv_connector=mock_kv(matched_tokens=HISPARSE_BLOCK_SIZE, is_async=True),
872 )
873 manager = make_hisparse_kv_cache_manager(32, host_num_blocks)
874 scheduler.kv_cache_manager = manager
875 scheduler.kv_cache_config = manager.kv_cache_config
876 assert scheduler.scheduler_reserve_full_isl
877 inflight = make_request(
878 "inflight", list(range(3 * HISPARSE_BLOCK_SIZE)), HISPARSE_BLOCK_SIZE, sha256
879 )
880 scheduler.add_request(inflight)
881 scheduler.schedule()
882 assert inflight in scheduler._inflight_prefills
883
884 request = make_request(
885 "waiting", list(range(4 * HISPARSE_BLOCK_SIZE)), HISPARSE_BLOCK_SIZE, sha256
886 )
887 scheduler.add_request(request)
888 scheduler.schedule()
889
890 assert (request in scheduler._inflight_prefills) == admitted
891 assert bool(manager.get_blocks(request.request_id).blocks[0]) == admitted
892
893
894 def test_hisparse_admitted_async_loads_can_finish_with_nothing_running(tmp_path):
895 """Admitted async loads must not strand each other on host capacity.
896
897 Waiting requests keep their blocks and are never preempted, so two imports
898 that each hold a host page while together needing more than the pool has
899 left would never be scheduled again. HiSparse relies on full-ISL admission:
900 a load is admitted only if its whole prompt's host pages fit, so the newest
901 admitted load can always finish and free host blocks for the others.
902 """
903 from .utils import create_scheduler, mock_kv
904
905 (tmp_path / "config.json").write_text(
906 '{"architectures": ["OPTForCausalLM"], "model_type": "opt"}'
907 )
908 scheduler = create_scheduler(
909 model=str(tmp_path),
910 skip_tokenizer_init=True,
911 max_model_len=128,
912 use_kv_connector=mock_kv(matched_tokens=HISPARSE_BLOCK_SIZE, is_async=True),
913 )
914 # One null block plus three usable host blocks.
915 manager = make_hisparse_kv_cache_manager(32, 4)
916 scheduler.kv_cache_manager = manager
917 scheduler.kv_cache_config = manager.kv_cache_config
918 assert scheduler.scheduler_reserve_full_isl
919 first = make_request(
920 "first", list(range(3 * HISPARSE_BLOCK_SIZE)), HISPARSE_BLOCK_SIZE, sha256
921 )
922 second = make_request(
923 "second",
924 list(range(1000, 1000 + 3 * HISPARSE_BLOCK_SIZE)),
925 HISPARSE_BLOCK_SIZE,
926 sha256,
927 )
928 for request in (first, second):
929 scheduler.add_request(request)
930 scheduler.schedule()
931 admitted = {r.request_id for r in scheduler._inflight_prefills}
932
933 scheduler.finished_recving_kv_req_ids.update(admitted)
934 num_scheduled_tokens = 0
935 for _ in range(4):
936 num_scheduled_tokens += scheduler.schedule().total_num_scheduled_tokens
937 assert num_scheduled_tokens > 0
938
939
940 @pytest.mark.parametrize(
941 "evictable,local_tokens,free_blocks,admitted",
942 [
943 (False, HISPARSE_BLOCK_SIZE, 1, True),
944 (True, HISPARSE_BLOCK_SIZE, 1, False),
945 (False, HISPARSE_BLOCK_SIZE - 1, 1, False),
946 (False, HISPARSE_BLOCK_SIZE - 1, 2, True),
947 ],
948 )
949 def test_hisparse_import_capacity_includes_hits_and_cow(
950 evictable, local_tokens, free_blocks, admitted
951 ):
952 """Touching an evictable hit and copying a partial hit consume capacity."""
953 manager = make_hisparse_kv_cache_manager(32, free_blocks + 2 - int(evictable))
954 coordinator = get_hisparse_coordinator(manager)
955 host_pool = coordinator.get_host_block_pool()
956 assert host_pool is not None
957 hit = host_pool.get_new_blocks(1)[0]
958 if evictable:
959 host_pool.free_blocks([hit])
960 assert host_pool.get_num_free_blocks() == free_blocks
961 assert (
962 coordinator.host_manager.get_num_blocks_to_allocate(
963 "waiting", 32, [hit], 32, local_tokens, 32
964 )
965 <= host_pool.get_num_free_blocks()
966 ) == admitted
967
968
969 @pytest.mark.parametrize("enable_caching", [False, True])
970 def test_hisparse_host_exhaustion_defers_allocation(enable_caching):
971 """Pages are only allocated with host backing, so every one can be reclaimed."""
972 manager = make_hisparse_kv_cache_manager(32, 5, enable_caching=enable_caching)
973 donor = make_request("donor", [99] * 16, HISPARSE_BLOCK_SIZE, sha256)
974 assert _allocate_scheduled(manager, donor, 16) is not None
975 request = make_request("resident", list(range(64)), HISPARSE_BLOCK_SIZE, sha256)
976 free_gpu = manager.block_pool.get_num_free_blocks()
977 assert manager.allocate_slots(request, 64) is None
978 assert manager.block_pool.get_num_free_blocks() == free_gpu
979
980 # The donor's in-flight write-back holds its host block until acked.
981 manager.free(donor)
982 coordinator = get_hisparse_coordinator(manager)
983 assert coordinator.has_pending_reclamation()
984 assert manager.allocate_slots(request, 64) is None
985 _publish_hisparse_pages(manager)
986 assert not coordinator.has_pending_reclamation()
987 assert _allocate_scheduled(manager, request, 64) is not None
988 host = coordinator.host_manager
989 assert host is not None
990 assert not any(b.is_null for b in host.req_to_blocks[request.request_id])
991 _publish_hisparse_pages(manager)
992 for hot in coordinator.hot_managers:
993 hot.require_hot(request.request_id)
994 hot.allocate_new_blocks(request.request_id, 64, 64)
995 coordinator.update_residency(request.request_id)
996 assert coordinator.request_states[request.request_id].unpinned_pages == {0, 1}
997 for resident in coordinator.resident_managers:
998 assert [b.ref_cnt for b in resident.req_to_blocks[request.request_id]] == [
999 0,
1000 0,
1001 1,
1002 1,
1003 ]
1004
1005
1006 @pytest.mark.parametrize("failed", [False, True])
1007 def test_host_receive_completion_without_spill_metadata(failed):
1008 """Only successful external receives make host pages readable."""
1009 manager = make_hisparse_kv_cache_manager(32, 16)
1010 request = make_request("import", list(range(32)), HISPARSE_BLOCK_SIZE, sha256)
1011 assert allocate_external_prefix(manager, request, 32) is not None
1012 request.num_computed_tokens = 32
1013 request.status = RequestStatus.WAITING_FOR_REMOTE_KVS
1014 scheduler = HiSparseConnectorScheduler(async_speculative=False)
1015 scheduler.bind_coordinator(get_hisparse_coordinator(manager))
1016 scheduler.requests[request.request_id] = request
1017 connector = object.__new__(HiSparseConnector)
1018 connector.connector_scheduler = scheduler
1019 composite = object.__new__(MultiConnector)
1020 composite._connectors = (connector,)
1021 composite.update_connector_output(
1022 KVConnectorOutput(
1023 finished_recving={request.request_id},
1024 failed_recving={request.request_id} if failed else set(),
1025 )
1026 )
1027 state = get_hisparse_coordinator(manager).request_states.get(request.request_id)
1028 if failed:
1029 assert state is None
1030 else:
1031 assert state is not None and state.valid_pages == {0, 1}
1032
1033
1034 def test_hisparse_recomputation_does_not_complete_failed_host_import():
1035 """Allocating recomputation is not evidence that imported host KV is valid."""
1036 manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
1037 request = make_request(
1038 "partial-failure", list(range(48)), HISPARSE_BLOCK_SIZE, sha256
1039 )
1040 assert allocate_external_prefix(manager, request, 32) is not None
1041 # A partial load failure leaves only the first page computed. The next
1042 # allocation reaches the original import boundary before the worker runs.
1043 request.num_computed_tokens = 16
1044 manager.cache_blocks(request, 16)
1045 assert _allocate_scheduled(manager, request, 16) is not None
1046 state = get_hisparse_coordinator(manager).request_states.get(request.request_id)
1047 assert state is None or 1 not in state.valid_pages
1048
1049
1050 def test_hisparse_host_import_ignores_unsealed_tail():
1051 """A partial imported page must not become readable from stale host data."""
1052 manager = make_hisparse_kv_cache_manager(16, 16)
1053 coordinator = get_hisparse_coordinator(manager)
1054
1055 coordinator.complete_host_import("partial", HISPARSE_BLOCK_SIZE + 1)
1056
1057 state = coordinator.request_states["partial"]
1058 assert state.valid_pages == {0}
1059 assert state.ready_prefix_pages == 1
1060
1061
1062 def test_hisparse_resident_request_can_grow_without_hot_capacity():
1063 """Running resident history must not be mistaken for a new host-prefix hit."""
1064 manager = make_hisparse_kv_cache_manager(8, 16)
1065 request = make_request("resident", list(range(48)), HISPARSE_BLOCK_SIZE, sha256)
1066 assert _allocate_scheduled(manager, request, num_new_tokens=32) is not None
1067 request.num_computed_tokens = 32
1068
1069 # Three free blocks fit the next indexer/resident pages, but not a hot region.
1070 assert manager.block_pool.get_num_free_blocks() == 3
1071 assert _allocate_scheduled(manager, request, num_new_tokens=1) is not None
1072 assert manager.get_block_ids(request.request_id)[3] == []
1073
1074
1075 def test_hisparse_capacity_query_does_not_require_hot_blocks():
1076 """A read-only capacity query must not mutate hot-block requirements."""
1077 manager = make_hisparse_kv_cache_manager(16, 16)
1078 coordinator = get_hisparse_coordinator(manager)
1079 host_pool = coordinator.get_host_block_pool()
1080 assert host_pool is not None
1081 computed: tuple[list[KVCacheBlock], ...] = (
1082 [host_pool.blocks[1]],
1083 [],
1084 [],
1085 [],
1086 )
1087
1088 manager.coordinator.get_num_blocks_to_allocate(
1089 request_id="query-only",
1090 num_tokens=HISPARSE_BLOCK_SIZE,
1091 new_computed_blocks=computed,
1092 num_encoder_tokens=0,
1093 total_computed_tokens=HISPARSE_BLOCK_SIZE,
1094 num_local_computed_tokens=HISPARSE_BLOCK_SIZE,
1095 num_tokens_main_model=HISPARSE_BLOCK_SIZE,
1096 )
1097
1098 assert all(
1099 "query-only" not in hot_manager.hot_required
1100 for hot_manager in coordinator.hot_managers
1101 )
1102
1103
1104 def _publish_hisparse_pages(manager: KVCacheManager) -> None:
1105 """Ack all planned spills so host pages publish to the prefix cache."""
1106 command = get_hisparse_coordinator(manager).build_offload_command()
1107 counts = {spill.transfer_id: 1 for spill in command.page_transfers}
1108 get_hisparse_coordinator(manager).update_spills(counts, counts)
1109
1110
1111 def test_hisparse_events_report_host_and_device_placement():
1112 """Host publication and eviction must not advertise GPU-resident KV."""
1113 manager = make_kv_cache_manager(
1114 make_hisparse_kv_cache_config(32, 16),
1115 max_model_len=128,
1116 enable_caching=True,
1117 hash_block_size=HISPARSE_BLOCK_SIZE,
1118 enable_kv_cache_events=True,
1119 )
1120 request = make_request("request", list(range(64)), HISPARSE_BLOCK_SIZE, sha256)
1121 assert _allocate_scheduled(manager, request, 64) is not None
1122 _publish_hisparse_pages(manager)
1123
1124 stored = [
1125 event for event in manager.take_events() if isinstance(event, BlockStored)
1126 ]
1127 assert {(event.group_idx, event.medium) for event in stored} == {
1128 (0, MEDIUM_CPU),
1129 (1, MEDIUM_GPU),
1130 }
1131
1132 host_block = manager.get_blocks(request.request_id).blocks[0][0]
1133 get_hisparse_coordinator(manager).host_manager.block_pool.evict_blocks(
1134 {host_block.block_id}
1135 )
1136 [removed] = manager.take_events()
1137 assert isinstance(removed, BlockRemoved)
1138 assert removed.medium == MEDIUM_CPU
1139 assert removed.group_idx == 0
1140
1141
1142 def test_mamba_boundary_handoffs_do_not_pin_obsolete_blocks():
1143 """Draining unused connector offers must not retain old recurrent states."""
1144 manager = make_kv_cache_manager(
1145 KVCacheConfig(
1146 num_blocks=16,
1147 kv_cache_tensors=[],
1148 kv_cache_groups=[
1149 KVCacheGroupSpec(
1150 ["mamba"],
1151 MambaSpec(
1152 block_size=16,
1153 shapes=((1,),),
1154 dtypes=(torch.float32,),
1155 mamba_cache_mode="align",
1156 ),
1157 )
1158 ],
1159 ),
1160 max_model_len=128,
1161 enable_caching=True,
1162 hash_block_size=16,
1163 )
1164 request = make_request("request", list(range(64)), 16, sha256)
1165 old_blocks = []
1166 for end in (16, 32, 48):
1167 assert manager.allocate_slots(request, 16) is not None
1168 request.num_computed_tokens = end
1169 offers = manager.take_boundary_state_offloads()
1170 [(_, block_id, boundary)] = offers[request.request_id]
1171 assert boundary == end
1172 old_blocks.append(manager.block_pool.blocks[block_id])
1173 manager.remove_skipped_blocks(request.request_id, end, 64)
1174 manager.new_step_starts()
1175
1176 assert all(block.ref_cnt == 0 for block in old_blocks[:-1])
1177
1178
1179 def test_hisparse_prefix_hit_adopts_gpu_shadow_pages():
1180 """A host prefix hit must come back GPU-resident while shadows survive."""
1181 manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
1182 tokens = list(range(4 * HISPARSE_BLOCK_SIZE))
1183 original = make_request("original", tokens, HISPARSE_BLOCK_SIZE, sha256)
1184 assert (
1185 _allocate_scheduled(manager, original, num_new_tokens=len(tokens)) is not None
1186 )
1187 _publish_hisparse_pages(manager)
1188 original_resident_ids = [
1189 block.block_id for block in manager.get_blocks("original").blocks[2]
1190 ]
1191 manager.free(original)
1192
1193 resumed = make_request("resumed", tokens, HISPARSE_BLOCK_SIZE, sha256)
1194 computed, num_computed, _ = manager.get_computed_blocks(resumed)
1195 assert num_computed == 3 * HISPARSE_BLOCK_SIZE
1196 assert _allocate_scheduled(
1197 manager,
1198 resumed,
1199 num_new_tokens=len(tokens) - num_computed,
1200 num_new_computed_tokens=num_computed,
1201 new_computed_blocks=computed,
1202 )
1203 resident_blocks = manager.get_blocks("resumed").blocks[2]
1204 assert [block.block_id for block in resident_blocks[:3]] == (
1205 original_resident_ids[:3]
1206 )
1207 assert not any(block.is_null for block in resident_blocks[:3])
1208 assert get_hisparse_coordinator(manager).all_context_pages_resident(
1209 ((resumed.request_id, num_computed, len(tokens) - num_computed),)
1210 )
1211
1212
1213 @pytest.mark.parametrize("free_blocks,adopted_pages", [(7, []), (10, [0, 1])])
1214 def test_hisparse_prefix_hit_under_pressure_adopts_surviving_copies(
1215 free_blocks, adopted_pages
1216 ):
1217 """A host prefix hit must not starve its own allocation of free blocks.
1218
1219 Admission counts free GPU copies as free, but adopting a copy pins it. When
1220 adoption ran before the hit's allocation, it took blocks the allocation was
1221 promised and the pool ran dry (``Cannot get N free blocks``). The allocation
1222 now goes first, evicting a prefix's copies tail first; the copies it leaves
1223 are adopted.
1224 """
1225 manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
1226 tokens = list(range(4 * HISPARSE_BLOCK_SIZE))
1227 original = make_request("original", tokens, HISPARSE_BLOCK_SIZE, sha256)
1228 assert (
1229 _allocate_scheduled(manager, original, num_new_tokens=len(tokens)) is not None
1230 )
1231 _publish_hisparse_pages(manager)
1232 copy_ids = [block.block_id for block in manager.get_blocks("original").blocks[2]]
1233 manager.free(original)
1234 pool = manager.block_pool
1235 pool.get_new_blocks(pool.get_num_free_blocks() - free_blocks)
1236
1237 resumed = make_request("resumed", tokens, HISPARSE_BLOCK_SIZE, sha256)
1238 computed, num_computed, _ = manager.get_computed_blocks(resumed)
1239 assert num_computed == 3 * HISPARSE_BLOCK_SIZE
1240 assert _allocate_scheduled(
1241 manager,
1242 resumed,
1243 num_new_tokens=len(tokens) - num_computed,
1244 num_new_computed_tokens=num_computed,
1245 new_computed_blocks=computed,
1246 )
1247
1248 resumed_blocks = manager.get_blocks("resumed").blocks
1249 assert [block.block_id for block in resumed_blocks[2][:3]] == [
1250 copy_ids[page] if page in adopted_pages else pool.null_block.block_id
1251 for page in range(3)
1252 ]
1253 gpu_ids = [
1254 block.block_id
1255 for group in resumed_blocks[1:]
1256 for block in group
1257 if not block.is_null
1258 ]
1259 assert len(gpu_ids) == len(set(gpu_ids))
1260
1261
1262 def test_hisparse_host_backed_request_accepts_local_prefix_hit():
1263 """Local group-completion hits need no external host import allocation."""
1264 manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
1265 tokens = list(range(4 * HISPARSE_BLOCK_SIZE))
1266 original = make_request("original", tokens, HISPARSE_BLOCK_SIZE, sha256)
1267 assert (
1268 _allocate_scheduled(manager, original, num_new_tokens=len(tokens)) is not None
1269 )
1270 _publish_hisparse_pages(manager)
1271 manager.free(original)
1272
1273 resumed = make_request("resumed", tokens, HISPARSE_BLOCK_SIZE, sha256)
1274 computed, num_computed, _ = manager.get_computed_blocks(resumed)
1275 assert num_computed == 3 * HISPARSE_BLOCK_SIZE
1276 assert _allocate_scheduled(
1277 manager,
1278 resumed,
1279 num_new_tokens=len(tokens) - num_computed,
1280 num_new_computed_tokens=num_computed,
1281 new_computed_blocks=computed,
1282 num_external_computed_tokens=0,
1283 )
1284
1285
1286 def test_hisparse_finished_request_leaves_free_copies():
1287 """Clean pages of a finished request are free-queue blocks, not pins."""
1288 manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
1289 tokens = list(range(4 * HISPARSE_BLOCK_SIZE))
1290 original = make_request("original", tokens, HISPARSE_BLOCK_SIZE, sha256)
1291 assert (
1292 _allocate_scheduled(manager, original, num_new_tokens=len(tokens)) is not None
1293 )
1294 _publish_hisparse_pages(manager)
1295 manager.free(original)
1296
1297 coordinator = get_hisparse_coordinator(manager)
1298 assert coordinator.copies
1299 pool = manager.block_pool
1300 assert pool.get_num_free_blocks() == pool.num_gpu_blocks - 1
1301 pool.get_new_blocks(pool.get_num_free_blocks())
1302 assert not coordinator.copies
1303 assert not coordinator.spills_to_send
1304
1305
1306 @pytest.mark.parametrize(
1307 "status,expected_cached_pages",
1308 [
1309 (RequestStatus.FINISHED_STOPPED, 2),
1310 (RequestStatus.FINISHED_ABORTED, 2),
1311 (RequestStatus.RUNNING, 0),
1312 ],
1313 ids=["stopped", "aborted", "running"],
1314 )
1315 def test_hisparse_cleanup_publishes_only_finalized_terminal_pages(
1316 status, expected_cached_pages
1317 ):
1318 """Only terminal cleanup may publish completed, finalized pages."""
1319 manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
1320 coordinator = get_hisparse_coordinator(manager)
1321 request = make_request("terminal", list(range(33)), HISPARSE_BLOCK_SIZE, sha256)
1322 request.spec_token_ids = list(range(33, 49))
1323 assert (
1324 _allocate_scheduled(manager, request, 49, num_lookahead_tokens=16) is not None
1325 )
1326 host_blocks = list(manager.get_blocks(request.request_id).blocks[0])
1327 command = coordinator.build_offload_command()
1328 counts = {transfer.transfer_id: 1 for transfer in command.page_transfers}
1329 assert len(counts) == 2
1330 request.status = status
1331 manager.free(request)
1332 coordinator.update_spills(counts, counts)
1333 assert all(
1334 block.block_hash is not None for block in host_blocks[:expected_cached_pages]
1335 )
1336 assert all(
1337 block.block_hash is None for block in host_blocks[expected_cached_pages:]
1338 )
1339 assert all(block.ref_cnt == 0 for block in host_blocks)
1340 assert not coordinator.has_pending_work()
1341
1342
1343 @pytest.mark.parametrize("num_pages,reads_host", [(4, True), (3, False)])
1344 def test_hisparse_prefill_reads_host_once_it_fills_admission_window(
1345 num_pages, reads_host
1346 ):
1347 """A prefill switches to host reads once it fills its admission window.
1348
1349 Admission caps each request's resident pages at the in-flight window,
1350 assuming older pages move to host, but pinned pages were released only
1351 once free blocks fell below a fixed watermark. A chunked prefill larger
1352 than the watermark outgrew the pool first and was preempted.
1353 """
1354 window_pages = 4
1355 manager = make_hisparse_kv_cache_manager(
1356 32,
1357 16,
1358 enable_caching=True,
1359 max_in_flight_tokens=window_pages * HISPARSE_BLOCK_SIZE,
1360 )
1361 coordinator = get_hisparse_coordinator(manager)
1362 tokens = list(range(2 * window_pages * HISPARSE_BLOCK_SIZE))
1363 request = make_request("prefill", tokens, HISPARSE_BLOCK_SIZE, sha256)
1364 assert (
1365 _allocate_scheduled(manager, request, num_pages * HISPARSE_BLOCK_SIZE)
1366 is not None
1367 )
1368 pool = coordinator.gpu_pool
1369 assert pool is not None
1370 assert pool.get_num_free_blocks() >= coordinator.transition_watermark
1371
1372 hot_manager = coordinator.hot_managers[0]
1373 assert (request.request_id in hot_manager.hot_required) == reads_host
1374
1375
1376 def test_hisparse_preempted_prefill_resumes_from_durable_prefix():
1377 """A prefill preempted mid-way must resume from the pages already on host.
1378
1379 Host writes trail a chunked prefill by a chunk, and host pages were only
1380 published once the whole computed prefix was durable. Each chunk moved
1381 that target past the finished writes, so nothing was published before the
1382 prefill ended: a prefill preempted each time it outgrew the GPU pool
1383 recomputed from the start forever.
1384 """
1385 manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
1386 coordinator = get_hisparse_coordinator(manager)
1387 chunk = 2 * HISPARSE_BLOCK_SIZE
1388 tokens = list(range(3 * chunk))
1389 request = make_request("long", tokens, HISPARSE_BLOCK_SIZE, sha256)
1390 assert _allocate_scheduled(manager, request, chunk) is not None
1391 request.num_computed_tokens = chunk
1392 first_chunk_writes = coordinator.build_offload_command().page_transfers
1393 assert _allocate_scheduled(manager, request, chunk) is not None
1394 request.num_computed_tokens = 2 * chunk
1395 acks = {transfer.transfer_id: 1 for transfer in first_chunk_writes}
1396 coordinator.update_spills(acks, acks)
1397 manager.free(request)
1398
1399 resumed = make_request("resumed", tokens, HISPARSE_BLOCK_SIZE, sha256)
1400 assert manager.get_computed_blocks(resumed)[1] == chunk
1401
1402
1403 def test_hisparse_terminal_prefix_waits_for_all_workers_and_preserves_identity():
1404 """Old completions retain their pages through pressure and request-ID reuse."""
1405 manager = make_hisparse_kv_cache_manager(32, 8, enable_caching=True)
1406 coordinator = get_hisparse_coordinator(manager)
1407 host_pool = coordinator.get_host_block_pool()
1408 tokens = list(range(2 * HISPARSE_BLOCK_SIZE + 1))
1409 request = make_request("reused", tokens, HISPARSE_BLOCK_SIZE, sha256)
1410 assert _allocate_scheduled(manager, request, len(tokens)) is not None
1411 old_blocks = list(manager.get_blocks(request.request_id).blocks[0])
1412 command = coordinator.build_offload_command()
1413 counts = {transfer.transfer_id: 2 for transfer in command.page_transfers}
1414 partial = dict.fromkeys(counts, 1)
1415 first_completions = partial.copy()
1416 first_completions[next(iter(counts))] = 2
1417 coordinator.update_spills(counts, first_completions)
1418 request.status = RequestStatus.FINISHED_LENGTH_CAPPED
1419 manager.free(request)
1420 replacement = make_request(
1421 "reused", list(range(100, 117)), HISPARSE_BLOCK_SIZE, sha256
1422 )
1423 assert _allocate_scheduled(manager, replacement, 17) is not None
1424 new_blocks = list(manager.get_blocks(replacement.request_id).blocks[0])
1425 pressure = host_pool.get_new_blocks(host_pool.get_num_free_blocks())
1426 assert not {block.block_id for block in old_blocks[:2]} & {
1427 block.block_id for block in pressure + new_blocks
1428 }
1429 # Only the page every worker finished writing is published.
1430 assert old_blocks[0].block_hash is not None
1431 assert all(block.block_hash is None for block in old_blocks[1:])
1432 coordinator.update_spills({}, partial)
1433 repeated = make_request("probe", tokens, HISPARSE_BLOCK_SIZE, sha256)
1434 assert manager.get_computed_blocks(repeated)[1] == 2 * HISPARSE_BLOCK_SIZE
1435 assert all(block.block_hash is None for block in new_blocks)
1436 assert all(block.ref_cnt == 0 for block in old_blocks[:2])
1437 coordinator.update_spills({}, partial)
1438 assert all(block.ref_cnt == 0 for block in old_blocks[:2])
1439 host_pool.free_blocks(pressure)
1440 manager.free(replacement)
1441 _publish_hisparse_pages(manager)
1442 assert not coordinator.has_pending_work()
1443
1444
1445 def test_hisparse_reset_prefix_cache_drops_copies():
1446 """Reset must forget GPU copies along with the host hashes they mirror."""
1447 manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
1448 tokens = list(range(4 * HISPARSE_BLOCK_SIZE))
1449 request = make_request("request", tokens, HISPARSE_BLOCK_SIZE, sha256)
1450 assert _allocate_scheduled(manager, request, num_new_tokens=len(tokens)) is not None
1451 _publish_hisparse_pages(manager)
1452 manager.free(request)
1453
1454 coordinator = get_hisparse_coordinator(manager)
1455 assert coordinator.copies
1456 assert manager.reset_prefix_cache()
1457 assert not coordinator.copies
1458
1459
1460 def test_hisparse_reused_copy_is_not_adopted():
1461 """A host prefix hit must not adopt a GPU copy the pool has handed out."""
1462 manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
1463 tokens = list(range(4 * HISPARSE_BLOCK_SIZE))
1464 original = make_request("original", tokens, HISPARSE_BLOCK_SIZE, sha256)
1465 assert (
1466 _allocate_scheduled(manager, original, num_new_tokens=len(tokens)) is not None
1467 )
1468 _publish_hisparse_pages(manager)
1469 indexer_blocks = list(manager.get_blocks("original").blocks[1])
1470 manager.free(original)
1471 pool = manager.block_pool
1472 pool.touch(indexer_blocks)
1473 reused = pool.get_new_blocks(pool.get_num_free_blocks())
1474 pool.free_blocks(reused)
1475 pool.free_blocks(indexer_blocks)
1476
1477 resumed = make_request("resumed", tokens, HISPARSE_BLOCK_SIZE, sha256)
1478 computed, num_computed, _ = manager.get_computed_blocks(resumed)
1479 assert num_computed == 3 * HISPARSE_BLOCK_SIZE
1480 assert _allocate_scheduled(
1481 manager,
1482 resumed,
1483 num_new_tokens=len(tokens) - num_computed,
1484 num_new_computed_tokens=num_computed,
1485 new_computed_blocks=computed,
1486 )
1487 resident_blocks = manager.get_blocks("resumed").blocks[2]
1488 assert all(block.is_null for block in resident_blocks[:3])
1489
1490
1491 def test_hisparse_external_import_uses_hard_gpu_footprint():
1492 """An external prefix larger than resident capacity must remain admissible.
1493
1494 This guards the admission boundary where indexer + one writable resident
1495 page + the fixed hot allocation fit, but indexer + full resident history do
1496 not. Requiring one more resident page would silently strand long P/D inputs.
1497 """
1498 num_prompt_blocks = 4
1499 num_prompt_tokens = num_prompt_blocks * HISPARSE_BLOCK_SIZE
1500 manager = make_hisparse_kv_cache_manager(
1501 8,
1502 9,
1503 transfer_device_cache=True,
1504 )
1505 request = make_request(
1506 "host-import",
1507 list(range(num_prompt_tokens)),
1508 HISPARSE_BLOCK_SIZE,
1509 sha256,
1510 )
1511
1512 allocated = allocate_external_prefix(manager, request, num_prompt_tokens)
1513
1514 assert allocated is not None
1515 source, indexer, resident, hot = manager.get_blocks(request.request_id).blocks
1516 assert len(source) == num_prompt_blocks
1517 assert len(indexer) == num_prompt_blocks
1518 assert len(resident) == num_prompt_blocks
1519 assert all(block.is_null for block in resident[:-1])
1520 assert not resident[-1].is_null
1521 assert len(hot) == 2
1522 assert manager.block_pool.get_num_free_blocks() == 0
1523
1524
1525 def test_hisparse_external_import_survives_capacity_retry():
1526 manager = make_hisparse_kv_cache_manager(
1527 9,
1528 7,
1529 transfer_device_cache=True,
1530 )
1531 tokens = list(range(4 * HISPARSE_BLOCK_SIZE))
1532 first = make_request("first", tokens, HISPARSE_BLOCK_SIZE, sha256)
1533 second = make_request("second", tokens, HISPARSE_BLOCK_SIZE, sha256)
1534
1535 assert allocate_external_prefix(manager, first, len(tokens)) is not None
1536
1537 assert allocate_external_prefix(manager, second, len(tokens)) is None
1538
1539 manager.free(first)
1540 assert allocate_external_prefix(manager, second, len(tokens)) is not None
1541 _, _, resident, hot = manager.get_blocks(second.request_id).blocks
1542 assert all(block.is_null for block in resident[:-1])
1543 assert not resident[-1].is_null
1544 assert len(hot) == 2
1545
1546
1547 def make_kv_cache_config_hybrid_model(
1548 block_size: int,
1549 num_blocks: int,
1550 sliding_window_blocks: int,
1551 second_spec_type: str = "sliding_window",
1552 ) -> KVCacheConfig:
1553 if second_spec_type == "sliding_window":
1554 second_spec = SlidingWindowSpec(
1555 block_size=block_size,
1556 num_kv_heads=1,
1557 head_size=1,
1558 dtype=torch.float32,
1559 sliding_window=sliding_window_blocks * block_size,
1560 )
1561 elif second_spec_type == "mamba":
1562 second_spec = MambaSpec(
1563 block_size=block_size,
1564 shapes=((1, 1),),
1565 dtypes=(torch.float32,),
1566 )
1567
1568 return KVCacheConfig(
1569 num_blocks=num_blocks,
1570 kv_cache_tensors=[],
1571 kv_cache_groups=[
1572 KVCacheGroupSpec(
1573 ["layer1"],
1574 FullAttentionSpec(
1575 block_size=block_size,
1576 num_kv_heads=1,
1577 head_size=1,
1578 dtype=torch.float32,
1579 ),
1580 ),
1581 KVCacheGroupSpec(
1582 ["layer2"],
1583 second_spec,
1584 ),
1585 KVCacheGroupSpec(
1586 ["layer3"],
1587 second_spec,
1588 ),
1589 ],
1590 )
1591
1592
1593 def make_kv_cache_config_three_types(
1594 block_size: int, num_blocks: int, third_spec_type: str = "mamba"
1595 ) -> KVCacheConfig:
1596 if third_spec_type == "mamba":
1597 third_spec = MambaSpec(
1598 block_size=block_size,
1599 shapes=((1, 1),),
1600 dtypes=(torch.float32,),
1601 )
1602 elif third_spec_type == "sliding_window":
1603 third_spec = SlidingWindowSpec(
1604 block_size=block_size,
1605 num_kv_heads=1,
1606 head_size=1,
1607 dtype=torch.float32,
1608 sliding_window=4 * block_size,
1609 )
1610
1611 return KVCacheConfig(
1612 num_blocks=num_blocks,
1613 kv_cache_tensors=[],
1614 kv_cache_groups=[
1615 KVCacheGroupSpec(
1616 ["layer1"],
1617 FullAttentionSpec(
1618 block_size=block_size,
1619 num_kv_heads=1,
1620 head_size=1,
1621 dtype=torch.float32,
1622 ),
1623 ),
1624 KVCacheGroupSpec(
1625 ["layer2"],
1626 SlidingWindowSpec(
1627 block_size=block_size,
1628 num_kv_heads=1,
1629 head_size=1,
1630 dtype=torch.float32,
1631 sliding_window=2 * block_size,
1632 ),
1633 ),
1634 KVCacheGroupSpec(
1635 ["layer3"],
1636 third_spec,
1637 ),
1638 ],
1639 )
1640
1641
1642 @pytest.mark.parametrize("draft_sharded", [True, False])
1643 def test_prefix_cache_hit_uses_per_group_dcp_geometry(draft_sharded):
1644 """Prefix lookup must use each group's DCP size, not the process-wide one.
1645
1646 Target MLA is sharded; draft MLA can opt out and Mamba stays replicated.
1647 Hits must align to the target block in either case.
1648 """
1649 block_size = 16
1650 dcp = 8
1651 sharded_block = block_size * dcp
1652 kv_cache_config = KVCacheConfig(
1653 num_blocks=64,
1654 kv_cache_tensors=[],
1655 kv_cache_groups=[
1656 KVCacheGroupSpec(
1657 ["target_mla"],
1658 FullAttentionSpec(
1659 block_size=block_size,
1660 num_kv_heads=1,
1661 head_size=1,
1662 dtype=torch.float32,
1663 ),
1664 ),
1665 KVCacheGroupSpec(
1666 ["draft_mla"],
1667 MLAAttentionSpec(
1668 dcp_sharded=draft_sharded,
1669 block_size=block_size,
1670 num_kv_heads=1,
1671 head_size=1,
1672 dtype=torch.float32,
1673 ),
1674 ),
1675 KVCacheGroupSpec(
1676 ["mamba"],
1677 MambaSpec(
1678 block_size=block_size,
1679 shapes=(1, 1),
1680 dtypes=(torch.float32,),
1681 ),
1682 ),
1683 ],
1684 )
1685 manager = KVCacheManager(
1686 kv_cache_config,
1687 max_model_len=8192,
1688 enable_caching=True,
1689 hash_block_size=block_size,
1690 scheduler_block_size=sharded_block,
1691 dcp_world_size=dcp,
1692 )
1693 target_mgr, draft_mgr, mamba_mgr = manager.coordinator.single_type_managers
1694 assert target_mgr.dcp_world_size == dcp
1695 assert draft_mgr.dcp_world_size == (dcp if draft_sharded else 1)
1696 assert mamba_mgr.dcp_world_size == 1
1697 assert target_mgr.block_size == sharded_block
1698 assert draft_mgr.block_size == (sharded_block if draft_sharded else block_size)
1699 assert mamba_mgr.block_size == block_size
1700
1701 hash_fn = sha256
1702 common_token_ids = [i for i in range(2) for _ in range(sharded_block)]
1703 req0 = make_request("0", common_token_ids + [99] * 7, block_size, hash_fn)
1704 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
1705 assert num_computed_tokens == 0
1706 blocks = manager.allocate_slots(
1707 req0, len(req0.prompt_token_ids), 0, computed_blocks
1708 )
1709 assert blocks is not None
1710
1711 req1 = make_request("1", common_token_ids + [100] * 5, block_size, hash_fn)
1712 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
1713 assert num_computed_tokens == 2 * sharded_block
1714 assert [len(group) for group in computed_blocks.blocks] == [
1715 2,
1716 2 if draft_sharded else 16,
1717 16,
1718 ]
1719
1720 manager.free(req0)
1721 manager.free(req1)
1722
1723
1724 @pytest.mark.parametrize("hash_fn", [sha256, sha256_cbor])
1725 def test_prefill(hash_fn):
1726 block_size = 16
1727 manager = make_kv_cache_manager(
1728 make_kv_cache_config(block_size, 11),
1729 max_model_len=8192,
1730 enable_caching=True,
1731 hash_block_size=block_size,
1732 )
1733
1734 # Complete 3 blocks (48 tokens)
1735 common_token_ids = [i for i in range(3) for _ in range(16)]
1736
1737 # Fully cache miss
1738 # Incomplete 1 block (7 tokens)
1739 unique_token_ids = [3] * 7
1740 all_token_ids = common_token_ids + unique_token_ids
1741 req0 = make_request("0", all_token_ids, block_size, hash_fn)
1742 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
1743 assert len(req0.block_hashes) == 3
1744 assert not computed_blocks.blocks[0]
1745 assert num_computed_tokens == 0
1746 blocks = manager.allocate_slots(
1747 req0, 55, len(computed_blocks.blocks[0]) * 16, computed_blocks
1748 )
1749 assert blocks is not None and blocks.get_block_ids() == ([1, 2, 3, 4],)
1750
1751 # Check full block metadata
1752 parent_block_hash = None
1753 for block_id in (1, 2, 3):
1754 block_tokens = tuple(all_token_ids[(block_id - 1) * 16 : block_id * 16])
1755 block_hash = hash_block_tokens(hash_fn, parent_block_hash, block_tokens)
1756 blk_hash = manager.block_pool.blocks[block_id].block_hash
1757 assert blk_hash is not None
1758 assert get_block_hash(blk_hash) == block_hash
1759 assert get_group_id(blk_hash) == 0
1760 assert manager.block_pool.blocks[block_id].ref_cnt == 1
1761 parent_block_hash = block_hash
1762
1763 # Check partial block metadata
1764 for block_id in (4,):
1765 assert manager.block_pool.blocks[block_id].block_hash is None
1766 assert manager.block_pool.blocks[block_id].ref_cnt == 1
1767
1768 # Cache hit in the common prefix when the original block is still in use.
1769 # Incomplete 1 block (5 tokens)
1770 unique_token_ids = [3] * 5
1771 req1 = make_request("1", common_token_ids + unique_token_ids, block_size, hash_fn)
1772 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
1773 assert len(req1.block_hashes) == 3
1774 assert computed_blocks.get_block_ids() == ([1, 2, 3],)
1775 assert num_computed_tokens == 3 * 16
1776 num_new_tokens = 53 - 3 * 16
1777 blocks = manager.allocate_slots(
1778 req1, num_new_tokens, len(computed_blocks.blocks[0]) * 16, computed_blocks
1779 )
1780 assert blocks is not None and blocks.get_block_ids() == ([5],)
1781 for block in computed_blocks.blocks[0]:
1782 assert block.ref_cnt == 2
1783
1784 # At this point, we should have 5 free blocks left.
1785 free_block_queue = manager.block_pool.free_block_queue
1786 assert free_block_queue.num_free_blocks == 5
1787
1788 manager.free(req0)
1789 manager.free(req1)
1790
1791 # All blocks should be available.
1792 assert free_block_queue.num_free_blocks == 10
1793 # The order should be
1794 # [partial without hashes from req1 and req0 (5, 4) - prepended for immediate reuse]
1795 # [unallocated (6, 7, 8, 9, 10)]
1796 # [common (3, 2, 1)]
1797 assert [
1798 b.block_id for b in manager.block_pool.free_block_queue.get_all_free_blocks()
1799 ] == [5, 4, 6, 7, 8, 9, 10, 3, 2, 1]
1800
1801 # Cache hit in the common prefix when the original block is already free.
1802 # Incomplete 1 block (6 tokens)
1803 unique_token_ids = [3] * 6
1804 req2 = make_request("2", common_token_ids + unique_token_ids, block_size, hash_fn)
1805 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req2)
1806 assert len(req2.block_hashes) == 3
1807 assert computed_blocks.get_block_ids() == ([1, 2, 3],)
1808 assert num_computed_tokens == 3 * 16
1809 num_new_tokens = 53 - 3 * 16
1810 blocks = manager.allocate_slots(
1811 req2, num_new_tokens, len(computed_blocks.blocks[0]) * 16, computed_blocks
1812 )
1813 assert blocks is not None and blocks.get_block_ids() == ([5],) # reuse partial [5]
1814
1815 # Although we only have 6 free blocks, we have 8 blocks in
1816 # the free block queue due to lazy removal.
1817 assert free_block_queue.num_free_blocks == 6
1818 assert all([b.ref_cnt == 0 for b in free_block_queue.get_all_free_blocks()])
1819 assert len([b for b in free_block_queue.get_all_free_blocks()]) == 6
1820
1821 manager.free(req2)
1822
1823 # Cache miss and eviction.
1824 req3 = make_request("3", [99] * (16 * 10), block_size, hash_fn)
1825 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req3)
1826 assert not computed_blocks.blocks[0]
1827 assert num_computed_tokens == 0
1828 blocks = manager.allocate_slots(
1829 req3, 16 * 10, len(computed_blocks.blocks[0]) * 16, computed_blocks
1830 )
1831 # This block ID order also checks the eviction order.
1832 assert blocks is not None and blocks.get_block_ids() == (
1833 [5, 4, 6, 7, 8, 9, 10, 3, 2, 1],
1834 )
1835
1836 assert free_block_queue.num_free_blocks == 0
1837 assert (
1838 free_block_queue.fake_free_list_head.next_free_block
1839 is free_block_queue.fake_free_list_tail
1840 )
1841 assert (
1842 free_block_queue.fake_free_list_tail.prev_free_block
1843 is free_block_queue.fake_free_list_head
1844 )
1845
1846
1847 def test_prefill_hybrid_model():
1848 block_size = 16
1849 manager = make_kv_cache_manager(
1850 make_kv_cache_config_hybrid_model(block_size, 21, 2),
1851 max_model_len=8192,
1852 enable_caching=True,
1853 hash_block_size=block_size,
1854 )
1855
1856 hash_fn = sha256
1857
1858 # Complete 3 blocks (48 tokens)
1859 num_full_blocks = 3
1860 common_token_ids = [i for i in range(num_full_blocks) for _ in range(block_size)]
1861
1862 # Fully cache miss
1863 # Incomplete 1 block (7 tokens)
1864 unique_token_ids = [3] * 7
1865 all_token_ids = common_token_ids + unique_token_ids
1866 req0 = make_request("0", all_token_ids, block_size, hash_fn)
1867 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
1868 assert len(req0.block_hashes) == 3
1869 assert not computed_blocks.blocks[0]
1870 assert num_computed_tokens == 0
1871 blocks = manager.allocate_slots(
1872 req0, 55, len(computed_blocks.blocks[0]) * 16, computed_blocks
1873 )
1874 assert blocks is not None and blocks.get_block_ids() == (
1875 [1, 2, 3, 4],
1876 [5, 6, 7, 8],
1877 [9, 10, 11, 12],
1878 )
1879
1880 # Check full block metadata
1881 parent_block_hash = None
1882 for length, block_ids in zip((1, 2, 3), ((1, 5, 9), (2, 6, 10), (3, 7, 11))):
1883 block_tokens = tuple(all_token_ids[(length - 1) * 16 : length * 16])
1884 block_hash = hash_block_tokens(hash_fn, parent_block_hash, block_tokens)
1885 for group_id, block_id in enumerate(block_ids):
1886 blk_hash = manager.block_pool.blocks[block_id].block_hash
1887 assert blk_hash is not None
1888 assert get_block_hash(blk_hash) == block_hash
1889 assert get_group_id(blk_hash) == group_id
1890 assert manager.block_pool.blocks[block_id].ref_cnt == 1
1891 parent_block_hash = block_hash
1892
1893 # Check partial block metadata
1894 for block_id in (4, 8, 12):
1895 assert manager.block_pool.blocks[block_id].block_hash is None
1896 assert manager.block_pool.blocks[block_id].ref_cnt == 1
1897
1898 # Cache hit in the common prefix
1899 # Incomplete 1 block (5 tokens)
1900 unique_token_ids = [3] * 5
1901 all_token_ids = common_token_ids + unique_token_ids
1902 req1 = make_request("1", common_token_ids + unique_token_ids, block_size, hash_fn)
1903 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
1904 assert len(req1.block_hashes) == 3
1905 assert computed_blocks.get_block_ids() == ([1, 2, 3], [0, 6, 7], [0, 10, 11])
1906 assert num_computed_tokens == 3 * 16
1907 num_new_tokens = 53 - 3 * 16
1908 blocks = manager.allocate_slots(
1909 req1, num_new_tokens, len(computed_blocks.blocks[0]) * 16, computed_blocks
1910 )
1911 assert blocks is not None and blocks.get_block_ids() == ([13], [14], [15])
1912 for block_per_group in computed_blocks.blocks:
1913 for block in block_per_group:
1914 if block != manager.block_pool.null_block:
1915 assert block.ref_cnt == 2
1916
1917 block_hashes = req1.block_hashes
1918 manager.free(req0)
1919 manager.free(req1)
1920
1921 # Evict the blocks outside sliding window, does not affect the hit length.
1922 _test_partial_request_hit(
1923 manager,
1924 block_size,
1925 num_full_blocks,
1926 "2",
1927 all_token_ids,
1928 [
1929 make_block_hash_with_group_id(block_hashes[0], 1),
1930 make_block_hash_with_group_id(block_hashes[0], 2),
1931 ],
1932 3,
1933 )
1934
1935 # Evict the first block of full attention, makes total cache miss.
1936 _test_partial_request_hit(
1937 manager,
1938 block_size,
1939 num_full_blocks,
1940 "3",
1941 all_token_ids,
1942 [make_block_hash_with_group_id(block_hashes[0], 0)],
1943 0,
1944 )
1945
1946 # Evict the last block of all layers, reduces the hit length to 2.
1947 _test_partial_request_hit(
1948 manager,
1949 block_size,
1950 num_full_blocks,
1951 "4",
1952 all_token_ids,
1953 [
1954 make_block_hash_with_group_id(block_hashes[2], 0),
1955 make_block_hash_with_group_id(block_hashes[2], 1),
1956 make_block_hash_with_group_id(block_hashes[2], 2),
1957 ],
1958 2,
1959 )
1960
1961 # Evict the last block of full attention, reduces the hit length to 2.
1962 _test_partial_request_hit(
1963 manager,
1964 block_size,
1965 num_full_blocks,
1966 "5",
1967 all_token_ids,
1968 [make_block_hash_with_group_id(block_hashes[2], 0)],
1969 2,
1970 )
1971
1972 # Evict the last block of sliding window, reduces the hit length to 2.
1973 _test_partial_request_hit(
1974 manager,
1975 block_size,
1976 num_full_blocks,
1977 "6",
1978 all_token_ids,
1979 [make_block_hash_with_group_id(block_hashes[2], 1)],
1980 2,
1981 )
1982
1983 # Evict the last block of sliding window, reduces the hit length to 2.
1984 _test_partial_request_hit(
1985 manager,
1986 block_size,
1987 num_full_blocks,
1988 "7",
1989 all_token_ids,
1990 [make_block_hash_with_group_id(block_hashes[2], 2)],
1991 2,
1992 )
1993
1994 # Evict different set of blocks for full attention and sliding window makes
1995 # total cache miss.
1996 # The cache hit length of full attention is 1 * block_size.
1997 # The cache hit length of sliding window is 2 * block_size.
1998 # Then it is cache miss as the two type of layers
1999 # have different hit length.
2000 _test_partial_request_hit(
2001 manager,
2002 block_size,
2003 num_full_blocks,
2004 "8",
2005 all_token_ids,
2006 [
2007 make_block_hash_with_group_id(block_hashes[2], 0),
2008 make_block_hash_with_group_id(block_hashes[0], 1),
2009 make_block_hash_with_group_id(block_hashes[0], 2),
2010 ],
2011 0,
2012 )
2013
2014
2015 def test_prefill_hybrid_model_eagle():
2016 block_size = 16
2017 kv_cache_config = make_kv_cache_config_hybrid_model(block_size, 31, 3)
2018 manager = make_kv_cache_manager(
2019 kv_cache_config,
2020 max_model_len=8192,
2021 enable_caching=True,
2022 hash_block_size=block_size,
2023 use_eagle=True,
2024 )
2025
2026 hash_fn = sha256
2027
2028 # Complete 6 blocks (96 tokens)
2029 num_full_blocks = 6
2030 common_token_ids = [i for i in range(num_full_blocks) for _ in range(block_size)]
2031
2032 # Fully cache miss
2033 # Incomplete 1 block (7 tokens)
2034 unique_token_ids = [6] * 7
2035 all_token_ids = common_token_ids + unique_token_ids
2036 req0 = make_request("0", all_token_ids, block_size, hash_fn)
2037 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
2038 assert len(req0.block_hashes) == len(all_token_ids) // block_size
2039 assert not computed_blocks.blocks[0]
2040 assert num_computed_tokens == 0
2041 blocks = manager.allocate_slots(
2042 req0, len(all_token_ids), num_computed_tokens, computed_blocks
2043 )
2044 block_ids = (
2045 [1, 2, 3, 4, 5, 6, 7],
2046 [8, 9, 10, 11, 12, 13, 14],
2047 [15, 16, 17, 18, 19, 20, 21],
2048 )
2049 assert blocks is not None and blocks.get_block_ids() == block_ids
2050
2051 # Check full block metadata
2052 parent_block_hash = None
2053 for i, full_block_ids in enumerate(zip(*(row[:-1] for row in block_ids))):
2054 block_tokens = tuple(all_token_ids[i * block_size : (i + 1) * block_size])
2055 block_hash = hash_block_tokens(hash_fn, parent_block_hash, block_tokens)
2056 for group_id, block_id in enumerate(full_block_ids):
2057 blk_hash = manager.block_pool.blocks[block_id].block_hash
2058 assert blk_hash is not None
2059 assert get_block_hash(blk_hash) == block_hash
2060 assert get_group_id(blk_hash) == group_id
2061 assert manager.block_pool.blocks[block_id].ref_cnt == 1
2062 parent_block_hash = block_hash
2063
2064 # Check partial block metadata
2065 for partial_block_id in (row[-1] for row in block_ids):
2066 assert manager.block_pool.blocks[partial_block_id].block_hash is None
2067 assert manager.block_pool.blocks[partial_block_id].ref_cnt == 1
2068
2069 # Cache hit in the common prefix
2070 # Incomplete 1 block (5 tokens)
2071 unique_token_ids = [6] * 5
2072 all_token_ids = common_token_ids + unique_token_ids
2073 req1 = make_request("1", all_token_ids, block_size, hash_fn)
2074 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
2075 assert len(req1.block_hashes) == num_full_blocks
2076 assert computed_blocks.get_block_ids() == (
2077 [1, 2, 3, 4, 5],
2078 [0, 0, 10, 11, 12],
2079 [0, 0, 17, 18, 19],
2080 )
2081 assert num_computed_tokens == 5 * block_size
2082 num_new_tokens = len(all_token_ids) - num_computed_tokens
2083 blocks = manager.allocate_slots(
2084 req1, num_new_tokens, num_computed_tokens, computed_blocks
2085 )
2086 assert blocks is not None and blocks.get_block_ids() == (
2087 [22, 23],
2088 [24, 25],
2089 [26, 27],
2090 )
2091 for block_per_group in computed_blocks.blocks:
2092 for block in block_per_group:
2093 if block != manager.block_pool.null_block:
2094 assert block.ref_cnt == 2
2095
2096 block_hashes = req1.block_hashes
2097 manager.free(req0)
2098 manager.free(req1)
2099
2100 # Evict the blocks outside sliding window, does not affect the hit length.
2101 _test_partial_request_hit(
2102 manager,
2103 block_size,
2104 num_full_blocks,
2105 "2",
2106 all_token_ids,
2107 [
2108 make_block_hash_with_group_id(block_hashes[0], 1),
2109 make_block_hash_with_group_id(block_hashes[0], 2),
2110 ],
2111 5,
2112 )
2113
2114 # Evict the first block of full attention, makes total cache miss.
2115 _test_partial_request_hit(
2116 manager,
2117 block_size,
2118 num_full_blocks,
2119 "3",
2120 all_token_ids,
2121 [make_block_hash_with_group_id(block_hashes[0], 0)],
2122 0,
2123 )
2124
2125 # Evict the last block of all layers, reduces the hit length to 4.
2126 _test_partial_request_hit(
2127 manager,
2128 block_size,
2129 num_full_blocks,
2130 "4",
2131 all_token_ids,
2132 [
2133 make_block_hash_with_group_id(block_hashes[-1], 0),
2134 make_block_hash_with_group_id(block_hashes[-1], 1),
2135 make_block_hash_with_group_id(block_hashes[-1], 2),
2136 ],
2137 4,
2138 )
2139
2140 # Evict the last block of full attention, reduces the hit length to 4.
2141 _test_partial_request_hit(
2142 manager,
2143 block_size,
2144 num_full_blocks,
2145 "5",
2146 all_token_ids,
2147 [make_block_hash_with_group_id(block_hashes[-1], 0)],
2148 4,
2149 )
2150
2151 # Since the last block of full attention is dropped for eagle, evict
2152 # the second last block of sliding window, reduces the hit length to 3.
2153 _test_partial_request_hit(
2154 manager,
2155 block_size,
2156 num_full_blocks,
2157 "6",
2158 all_token_ids,
2159 [make_block_hash_with_group_id(block_hashes[-2], 1)],
2160 3,
2161 )
2162
2163 # Since the last block of full attention is dropped for eagle, evict
2164 # the second last block of sliding window, reduces the hit length to 3.
2165 _test_partial_request_hit(
2166 manager,
2167 block_size,
2168 num_full_blocks,
2169 "7",
2170 all_token_ids,
2171 [make_block_hash_with_group_id(block_hashes[-2], 2)],
2172 3,
2173 )
2174
2175 # Evict different set of blocks for full attention and sliding window.
2176 # Full loses its last block so it drops to 4 full blocks after the eagle
2177 # pop; SWA lost block 0 (outside the sliding window of the final hit),
2178 # which is not required for the K+1 anchor at position 4. Coordinated
2179 # single-drop aligns both groups at hit=4.
2180 _test_partial_request_hit(
2181 manager,
2182 block_size,
2183 num_full_blocks,
2184 "8",
2185 all_token_ids,
2186 [
2187 make_block_hash_with_group_id(block_hashes[-1], 0),
2188 make_block_hash_with_group_id(block_hashes[0], 1),
2189 make_block_hash_with_group_id(block_hashes[0], 2),
2190 ],
2191 4,
2192 )
2193
2194
2195 def _test_partial_request_hit(
2196 manager: KVCacheManager,
2197 block_size: int,
2198 num_full_blocks,
2199 request_id: str,
2200 prompt_token_ids: list[int],
2201 hash_to_evict: list[BlockHashWithGroupId],
2202 expect_hit_length: int,
2203 ):
2204 cached_block_hash_to_block_bak = copy.copy(
2205 manager.block_pool.cached_block_hash_to_block._cache
2206 )
2207 req = make_request(request_id, prompt_token_ids, block_size, sha256)
2208 for hash_with_group_id in hash_to_evict:
2209 manager.block_pool.cached_block_hash_to_block._cache.pop(hash_with_group_id)
2210 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req)
2211 assert len(req.block_hashes) == num_full_blocks
2212 assert num_computed_tokens == expect_hit_length * block_size
2213 for block_per_group in computed_blocks.blocks:
2214 assert len(block_per_group) == num_computed_tokens // block_size
2215 for hash_with_group_id in hash_to_evict:
2216 manager.block_pool.cached_block_hash_to_block._cache[hash_with_group_id] = (
2217 cached_block_hash_to_block_bak[hash_with_group_id]
2218 )
2219 manager.free(req)
2220
2221
2222 def _make_hybrid_kv_cache_config(
2223 block_size: int, num_blocks: int, spec_types: list[str]
2224 ) -> KVCacheConfig:
2225 """Create a KVCacheConfig with the specified spec types.
2226
2227 Args:
2228 block_size: The block size for KV cache.
2229 num_blocks: The number of blocks in the KV cache.
2230 spec_types: List of spec type strings. Supported types:
2231 - "full": FullAttentionSpec
2232 - "sliding_window": SlidingWindowSpec with window=2*block_size
2233 - "sliding_window_large": SlidingWindowSpec with window=4*block_size
2234 - "mamba": MambaSpec
2235
2236 """
2237 spec_map = {
2238 "full": lambda: FullAttentionSpec(
2239 block_size=block_size,
2240 num_kv_heads=1,
2241 head_size=1,
2242 dtype=torch.float32,
2243 ),
2244 "sliding_window": lambda: SlidingWindowSpec(
2245 block_size=block_size,
2246 num_kv_heads=1,
2247 head_size=1,
2248 dtype=torch.float32,
2249 sliding_window=2 * block_size,
2250 ),
2251 "sliding_window_large": lambda: SlidingWindowSpec(
2252 block_size=block_size,
2253 num_kv_heads=1,
2254 head_size=1,
2255 dtype=torch.float32,
2256 sliding_window=4 * block_size,
2257 ),
2258 "mamba": lambda: MambaSpec(
2259 block_size=block_size,
2260 shapes=((1, 1),),
2261 dtypes=(torch.float32,),
2262 ),
2263 "mamba_align": lambda: MambaSpec(
2264 block_size=block_size,
2265 shapes=((1, 1),),
2266 dtypes=(torch.float32,),
2267 mamba_cache_mode="align",
2268 ),
2269 }
2270
2271 kv_cache_groups = [
2272 KVCacheGroupSpec([f"layer{i}"], spec_map[spec_type]())
2273 for i, spec_type in enumerate(spec_types)
2274 ]
2275
2276 return KVCacheConfig(
2277 num_blocks=num_blocks,
2278 kv_cache_tensors=[],
2279 kv_cache_groups=kv_cache_groups,
2280 )
2281
2282
2283 # Test cases covering various combinations of KV cache spec types:
2284 # - Varying number of groups (2, 3, or 4)
2285 # - 0, 1, or 2 full attention groups
2286 # - Sliding window with different window sizes
2287 # - Interleaved group IDs (full attn and other types mixed)
2288 # - Mamba spec combinations
2289 _HYBRID_MODEL_TEST_CASES = [
2290 # 2 groups: 1 full + 1 other
2291 pytest.param(["full", "sliding_window"], id="2g-full+sw"),
2292 pytest.param(["full", "mamba"], id="2g-full+mamba"),
2293 # 2 groups: 0 full (all other types)
2294 pytest.param(["sliding_window", "mamba"], id="2g-sw+mamba"),
2295 pytest.param(["sliding_window", "sliding_window_large"], id="2g-sw+sw_large"),
2296 # 3 groups: 1 full + 2 others (same type)
2297 pytest.param(["full", "sliding_window", "sliding_window"], id="3g-full+2sw"),
2298 pytest.param(["full", "mamba", "mamba"], id="3g-full+2mamba"),
2299 # 3 groups: 1 full + 2 others (different types)
2300 pytest.param(["full", "sliding_window", "mamba"], id="3g-full+sw+mamba"),
2301 pytest.param(
2302 ["full", "sliding_window", "sliding_window_large"],
2303 id="3g-full+sw+sw_large",
2304 ),
2305 # 3 groups: 2 full + 1 other
2306 pytest.param(["full", "full", "sliding_window"], id="3g-2full+sw"),
2307 pytest.param(["full", "full", "mamba"], id="3g-2full+mamba"),
2308 # 4 groups: interleaved (full, other, full, other)
2309 pytest.param(
2310 ["full", "sliding_window", "full", "sliding_window_large"],
2311 id="4g-interleaved-full+sw+sw_large",
2312 ),
2313 pytest.param(
2314 ["full", "mamba", "full", "mamba"],
2315 id="4g-interleaved-full+mamba",
2316 ),
2317 # 4 groups: interleaved with different sliding windows
2318 pytest.param(
2319 ["full", "sliding_window", "full", "sliding_window_large"],
2320 id="4g-interleaved-full+sw_mixed",
2321 ),
2322 # 4 groups: 0 full (all other types)
2323 pytest.param(
2324 ["sliding_window", "mamba", "sliding_window_large", "mamba"],
2325 id="4g-sw+mamba+sw_large+mamba",
2326 ),
2327 # 4 groups: 2 full + 2 others (grouped)
2328 pytest.param(
2329 ["full", "full", "sliding_window", "mamba"],
2330 id="4g-2full+sw+mamba",
2331 ),
2332 ]
2333
2334
2335 @pytest.mark.parametrize("spec_types", _HYBRID_MODEL_TEST_CASES)
2336 def test_prefill_hybrid_model_combinations(spec_types: list[str]):
2337 """Test prefix caching with hybrid models containing various combinations of
2338 KV cache spec types.
2339
2340 This unified test covers:
2341 - Various combinations (full attn + other attn types)
2342 - Varying number of groups (2, 3, or 4)
2343 - 0, 1, or 2 full attention groups in the combination
2344 - Two sliding_window attn groups with different window sizes
2345 - Interleaved group IDs (full attn and other types alternating)
2346 - Mamba spec with other attention types
2347 """
2348 block_size = 16
2349 num_groups = len(spec_types)
2350 # Allocate enough blocks for all groups
2351 num_blocks = 10 * num_groups
2352
2353 kv_cache_config = _make_hybrid_kv_cache_config(block_size, num_blocks, spec_types)
2354 manager = make_kv_cache_manager(
2355 kv_cache_config,
2356 max_model_len=8192,
2357 enable_caching=True,
2358 hash_block_size=block_size,
2359 )
2360
2361 hash_fn = sha256
2362
2363 # Complete 3 blocks (48 tokens)
2364 common_token_ids = [i for i in range(3) for _ in range(block_size)]
2365 unique_token_ids = [3] * 7
2366 all_token_ids = common_token_ids + unique_token_ids
2367
2368 # First request: no cache hit initially
2369 req0 = make_request("0", all_token_ids, block_size, hash_fn)
2370 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
2371
2372 assert len(req0.block_hashes) == 3
2373 assert not computed_blocks.blocks[0] # No cache hit initially
2374 assert num_computed_tokens == 0
2375
2376 blocks = manager.allocate_slots(
2377 req0, 55, len(computed_blocks.blocks[0]) * block_size, computed_blocks
2378 )
2379 assert blocks is not None
2380 # Should have blocks for all groups
2381 assert len(blocks.get_block_ids()) == num_groups
2382
2383 manager.new_step_starts()
2384
2385 # Second request: should hit cached blocks for common prefix
2386 req1 = make_request("1", common_token_ids + [4] * 5, block_size, hash_fn)
2387 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
2388
2389 # Should hit cached blocks for all groups
2390 assert num_computed_tokens == 3 * block_size
2391 assert len(computed_blocks.blocks) == num_groups
2392
2393 # Allocate and verify blocks for second request
2394 blocks = manager.allocate_slots(
2395 req1,
2396 len(common_token_ids) + 5 - num_computed_tokens,
2397 num_computed_tokens,
2398 computed_blocks,
2399 )
2400 assert blocks is not None
2401 assert len(blocks.get_block_ids()) == num_groups
2402
2403 manager.free(req0)
2404 manager.free(req1)
2405
2406
2407 # Test cases with eagle enabled: Only test a single simple case for now.
2408 # - 2 groups: 1 full + 1 other
2409 _EAGLE_HYBRID_MODEL_TEST_CASES = [
2410 # 2 groups: 1 full + 1 other
2411 pytest.param(["full", "sliding_window"], 3, id="2g-full+sw"),
2412 ]
2413
2414
2415 @pytest.mark.parametrize("spec_types,expect_hit_length", _EAGLE_HYBRID_MODEL_TEST_CASES)
2416 def test_prefill_hybrid_model_combinations_eagle(
2417 spec_types: list[str], expect_hit_length: int
2418 ):
2419 """Test prefix caching with hybrid models (1 full attn + 1 other) with EAGLE.
2420 More complex hybrid models with EAGLE are not yet supported (see issue #32802).
2421 """
2422 block_size = 16
2423 num_groups = len(spec_types)
2424 # Allocate enough blocks for all groups
2425 num_blocks = 10 * num_groups
2426
2427 kv_cache_config = _make_hybrid_kv_cache_config(block_size, num_blocks, spec_types)
2428 manager = make_kv_cache_manager(
2429 kv_cache_config,
2430 max_model_len=8192,
2431 enable_caching=True,
2432 hash_block_size=block_size,
2433 use_eagle=True,
2434 )
2435
2436 hash_fn = sha256
2437
2438 # Complete 3 blocks (48 tokens)
2439 num_full_blocks = 4
2440 common_token_ids = [i for i in range(num_full_blocks) for _ in range(block_size)]
2441 unique_token_ids = [4] * 7
2442 all_token_ids = common_token_ids + unique_token_ids
2443
2444 # First request: no cache hit initially
2445 req0 = make_request("0", all_token_ids, block_size, hash_fn)
2446 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
2447
2448 assert len(req0.block_hashes) == num_full_blocks
2449 assert not computed_blocks.blocks[0] # No cache hit initially
2450 assert num_computed_tokens == 0
2451
2452 blocks = manager.allocate_slots(
2453 req0, len(all_token_ids), num_computed_tokens, computed_blocks
2454 )
2455 assert blocks is not None
2456 # Should have blocks for all groups
2457 assert len(blocks.get_block_ids()) == num_groups
2458
2459 # Second request: should hit cached blocks for common prefix
2460 all_token_ids = common_token_ids + [6] * 5
2461 req1 = make_request("1", all_token_ids, block_size, hash_fn)
2462 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
2463
2464 # Should hit cached blocks for all groups
2465 assert num_computed_tokens == expect_hit_length * block_size
2466 assert len(computed_blocks.blocks) == num_groups
2467 # Verify each group has the correct number of computed blocks
2468 for block_per_group in computed_blocks.blocks:
2469 assert len(block_per_group) == expect_hit_length
2470
2471 # Allocate and verify blocks for second request
2472 blocks = manager.allocate_slots(
2473 req1,
2474 len(all_token_ids) - num_computed_tokens,
2475 num_computed_tokens,
2476 computed_blocks,
2477 )
2478 assert blocks is not None
2479 assert len(blocks.get_block_ids()) == num_groups
2480
2481 manager.free(req0)
2482 manager.free(req1)
2483
2484
2485 def test_prefill_hybrid_model_mamba_align():
2486 """Test that MambaManager.cache_blocks() handles null blocks in align mode.
2487
2488 Regression test for https://github.com/vllm-project/vllm/issues/34361.
2489 In mamba_cache_mode="align", allocate_new_blocks() pads req_to_blocks with
2490 null blocks. cache_full_blocks() correctly skips them, but
2491 MambaManager.cache_blocks() must also skip null blocks when tracking
2492 cached_blocks_this_step.
2493 """
2494 block_size = 16
2495 num_blocks = 30
2496
2497 kv_cache_config = _make_hybrid_kv_cache_config(
2498 block_size, num_blocks, ["full", "mamba_align"]
2499 )
2500 manager = make_kv_cache_manager(
2501 kv_cache_config,
2502 max_model_len=8192,
2503 enable_caching=True,
2504 hash_block_size=block_size,
2505 )
2506
2507 hash_fn = sha256
2508
2509 # 3 full blocks (48 tokens) + 7 partial tokens = 55 tokens total
2510 all_token_ids = [i for i in range(3) for _ in range(block_size)] + [3] * 7
2511
2512 # First request: allocate_slots should not crash with the assertion error
2513 # in MambaManager.cache_blocks() when null blocks are present.
2514 req0 = make_request("0", all_token_ids, block_size, hash_fn)
2515 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
2516 assert num_computed_tokens == 0
2517
2518 blocks = manager.allocate_slots(req0, 55, num_computed_tokens, computed_blocks)
2519 assert blocks is not None
2520 assert len(blocks.get_block_ids()) == 2 # full_attn + mamba groups
2521
2522 manager.free(req0)
2523
2524
2525 def test_hybrid_cache_mamba_align_shared_prefix_detection():
2526 """Test shared prefix detection heuristic for mamba align cache mode
2527
2528 HybridKVCacheCoordinator returns num_uncached_common > 0 when a shared
2529 uncached prefix is detected. With mamba_align cache, _mamba_block_aligned_split
2530 enforces scheduling aligned with the common prefix.
2531 """
2532 block_size = 16
2533 manager = make_kv_cache_manager(
2534 _make_hybrid_kv_cache_config(block_size, 30, ["full", "mamba_align"]),
2535 max_model_len=8192,
2536 enable_caching=True,
2537 hash_block_size=block_size,
2538 )
2539 hash_fn = sha256
2540
2541 # Request: 3 blocks
2542 prefix = [i for i in range(3) for _ in range(block_size)]
2543 req_0 = make_request("0", prefix, block_size, hash_fn)
2544 computed_blocks, num_computed, shared_prefix_boundary = manager.get_computed_blocks(
2545 req_0
2546 )
2547 assert num_computed == 0 # nothing cached yet
2548 assert shared_prefix_boundary == 0
2549 manager.allocate_slots(req_0, 3 * block_size, 0, computed_blocks)
2550
2551 # Request: 3 blocks (shared with above) + 7 different tokens
2552 req_1 = make_request("1", prefix + [100] * 7, block_size, hash_fn)
2553 computed_blocks, num_computed, shared_prefix_boundary = manager.get_computed_blocks(
2554 req_1
2555 )
2556 assert num_computed == 3 * block_size # we should observe a 3-block cache hit
2557 assert shared_prefix_boundary == 0
2558 manager.allocate_slots(req_1, 7, 3 * block_size, computed_blocks)
2559
2560 # Request: 3 blocks, but only 2 blocks shared (replace the last token in 3rd block):
2561 req_2 = make_request("2", prefix[:-1] + [101], block_size, hash_fn)
2562 computed_blocks, num_computed, shared_prefix_boundary = manager.get_computed_blocks(
2563 req_2
2564 )
2565 assert num_computed == 0 # mamba_align doesn't cache intermediate blocks
2566 # boundary == junction: 2 shared blocks (num_computed 0 + 2 uncached blocks).
2567 assert shared_prefix_boundary == 2 * block_size
2568
2569 # Next, validate the align-mode Marconi split. It takes the shared-prefix
2570 # junction (absolute) and stops the chunk there (req_2 has num_computed 0).
2571 # Create minimal mock with just the needed attributes
2572 mock = SimpleNamespace(
2573 block_size=block_size,
2574 cache_config=SimpleNamespace(block_size=block_size),
2575 max_num_scheduled_tokens=3 * block_size,
2576 scheduler_config=SimpleNamespace(long_prefill_token_threshold=0),
2577 use_eagle_block_drop=False,
2578 hash_block_size=block_size,
2579 mamba_partial_cache_hit=False,
2580 mamba_shared_prefix_checkpoint=False,
2581 mamba_has_prefill_checkpoint_blocks=False,
2582 )
2583 req_2.shared_prefix_boundary = shared_prefix_boundary
2584 num_new_tokens_adjusted = Scheduler._mamba_block_aligned_split(
2585 self=mock,
2586 request=req_2,
2587 num_new_tokens=3 * block_size,
2588 )
2589 assert num_new_tokens_adjusted == 2 * block_size # adjust to the common prefix
2590
2591 manager.allocate_slots(req_2, 3 * block_size, 0, computed_blocks)
2592 # Cleanup
2593 manager.free(req_0)
2594 manager.free(req_1)
2595 manager.free(req_2)
2596
2597
2598 def test_hybrid_model_mamba_align_with_dynamic_draft_tokens():
2599 """Regression test for https://github.com/vllm-project/vllm/issues/39271.
2600
2601 With suffix decoding enabled, the number of proposed draft token may
2602 change dynamically each round, causing the MambaManager to crash during
2603 allocate_slots() as it originally assumes the `num_blocks` to increase.
2604 """
2605 block_size = 16
2606 num_blocks = 30
2607
2608 kv_cache_config = _make_hybrid_kv_cache_config(
2609 block_size, num_blocks, ["full", "mamba_align"]
2610 )
2611 manager = KVCacheManager(
2612 kv_cache_config,
2613 max_model_len=8192,
2614 enable_caching=True,
2615 hash_block_size=block_size,
2616 scheduler_block_size=block_size,
2617 )
2618
2619 # the default hash function is sha256
2620 hash_fn = sha256
2621
2622 all_token_ids = [i for i in range(3) for _ in range(block_size)] + [3] * 7
2623 req0 = make_request("0", all_token_ids, block_size, hash_fn)
2624 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
2625 assert num_computed_tokens == 0
2626 blocks = manager.allocate_slots(
2627 req0, len(all_token_ids), num_computed_tokens, computed_blocks
2628 )
2629 assert blocks is not None
2630
2631 # prefill forward finished
2632 req0.append_output_token_ids([1])
2633 req0.num_computed_tokens = len(all_token_ids)
2634
2635 # Round1: propose 16 draft tokens, accept only one
2636 req0.spec_token_ids = [4] * 16
2637 blocks = manager.allocate_slots(req0, num_new_tokens=16, num_new_computed_tokens=0)
2638 assert blocks is not None
2639 req0.append_output_token_ids([4])
2640 req0.num_computed_tokens += 1
2641
2642 # Round2: propose only one token, allocate should not crash
2643 req0.spec_token_ids = [5] * 1
2644 blocks = manager.allocate_slots(req0, num_new_tokens=1, num_new_computed_tokens=0)
2645 assert blocks is not None and all(len(group) == 0 for group in blocks.blocks)
2646
2647 manager.free(req0)
2648
2649
2650 def test_prefill_plp():
2651 """Test prefill with APC and some prompt logprobs (plp) requests.
2652
2653 1. Schedule plp request and validate APC block allocation
2654 2. Schedule non-plp request and validate blocks
2655 3. Schedule plp request; no hit should occur; validate blocks
2656 """
2657 block_size = 16
2658 manager = make_kv_cache_manager(
2659 make_kv_cache_config(block_size, 11),
2660 max_model_len=8192,
2661 enable_caching=True,
2662 hash_block_size=block_size,
2663 )
2664 # the default hash function is sha256
2665 hash_fn = sha256
2666
2667 # Complete 3 blocks (48 tokens)
2668 common_token_ids = [i for i in range(3) for _ in range(16)]
2669
2670 # Request #0 is a prompt logprobs request
2671 # Fully cache miss
2672 # Incomplete 1 block (7 tokens)
2673 unique_token_ids = [3] * 7
2674 all_token_ids = common_token_ids + unique_token_ids
2675 req0 = make_request("0", all_token_ids, block_size, hash_fn, prompt_logprobs=5)
2676 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
2677 assert len(req0.block_hashes) == 3
2678 assert not computed_blocks.blocks[0]
2679 assert num_computed_tokens == 0
2680 blocks = manager.allocate_slots(
2681 req0, 55, len(computed_blocks.blocks[0]) * 16, computed_blocks
2682 )
2683 assert blocks is not None and blocks.get_block_ids() == ([1, 2, 3, 4],)
2684 req0_block_hashes = [b.block_hash for b in blocks.blocks[0]]
2685
2686 # Check full block metadata
2687 parent_block_hash = None
2688 for block_id in (1, 2, 3):
2689 block_tokens = tuple(all_token_ids[(block_id - 1) * 16 : block_id * 16])
2690 block_hash = hash_block_tokens(hash_fn, parent_block_hash, block_tokens)
2691 blk_hash = manager.block_pool.blocks[block_id].block_hash
2692 assert blk_hash is not None
2693 assert get_block_hash(blk_hash) == block_hash
2694 assert get_group_id(blk_hash) == 0
2695 assert manager.block_pool.blocks[block_id].ref_cnt == 1
2696 parent_block_hash = block_hash
2697
2698 # Check partial block metadata
2699 for block_id in (4,):
2700 assert manager.block_pool.blocks[block_id].block_hash is None
2701 assert manager.block_pool.blocks[block_id].ref_cnt == 1
2702
2703 # Request #1 is a non-prompt-logprobs request:
2704 # Cache hit in the common prefix when the original block is still in use.
2705 # Incomplete 1 block (5 tokens)
2706 unique_token_ids = [3] * 5
2707 req1 = make_request("1", common_token_ids + unique_token_ids, block_size, hash_fn)
2708 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
2709 assert len(req1.block_hashes) == 3
2710 assert computed_blocks.get_block_ids() == ([1, 2, 3],)
2711 assert num_computed_tokens == 3 * 16
2712 num_new_tokens = 53 - 3 * 16
2713 blocks = manager.allocate_slots(
2714 req1, num_new_tokens, len(computed_blocks.blocks[0]) * 16, computed_blocks
2715 )
2716 assert blocks is not None and blocks.get_block_ids() == ([5],)
2717 for block in computed_blocks.blocks[0]:
2718 assert block.ref_cnt == 2
2719
2720 # At this point, we should have 5 free blocks left.
2721 assert manager.block_pool.free_block_queue.num_free_blocks == 5
2722
2723 manager.free(req0)
2724 manager.free(req1)
2725
2726 # All blocks should be available.
2727 assert manager.block_pool.free_block_queue.num_free_blocks == 10
2728 # The order should be
2729 # [partial without hashes from req1 and req0 (5, 4) - prepended for immediate reuse]
2730 # [unallocated (6, 7, 8, 9, 10)]
2731 # [common (3, 2, 1)]
2732 assert [
2733 b.block_id for b in manager.block_pool.free_block_queue.get_all_free_blocks()
2734 ] == [5, 4, 6, 7, 8, 9, 10, 3, 2, 1]
2735
2736 # Request #2 is a prompt-logprobs request:
2737 # NO cache hit in the common prefix; duplicates request #0 cached blocks
2738 unique_token_ids = [3] * 6
2739 req2 = make_request(
2740 "2", common_token_ids + unique_token_ids, block_size, hash_fn, prompt_logprobs=5
2741 )
2742 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req2)
2743 assert len(req2.block_hashes) == 3
2744 assert not computed_blocks.blocks[0]
2745 assert num_computed_tokens == 0
2746 blocks = manager.allocate_slots(
2747 req2, 55, len(computed_blocks.blocks[0]) * 16, computed_blocks
2748 )
2749 assert blocks is not None
2750 block_ids = blocks.get_block_ids()
2751 # Duplicate cached blocks have different ids but same hashes vs request #0
2752 assert [b.block_hash for b in blocks.blocks[0]] == req0_block_hashes
2753 assert block_ids != ([1, 2, 3, 4],)
2754
2755 # Request #2 block hashes are valid since request #0 hashes are.
2756 # Check block reference counts.
2757 for block_id in block_ids[0]:
2758 assert manager.block_pool.blocks[block_id].ref_cnt == 1
2759
2760 manager.free(req2)
2761
2762
2763 def test_decode():
2764 block_size = 16
2765 manager = make_kv_cache_manager(
2766 make_kv_cache_config(block_size, 11),
2767 max_model_len=8192,
2768 enable_caching=True,
2769 hash_block_size=block_size,
2770 )
2771
2772 # Complete 3 blocks (48 tokens)
2773 common_token_ids = [i for i in range(3) for _ in range(16)]
2774
2775 # Fully cache miss
2776 # Incomplete 1 block (7 tokens)
2777 unique_token_ids = [3] * 7
2778 req0 = make_request("0", common_token_ids + unique_token_ids, block_size, sha256)
2779 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
2780 assert not computed_blocks.blocks[0]
2781 assert num_computed_tokens == 0
2782 blocks = manager.allocate_slots(
2783 req0, 55, len(computed_blocks.blocks[0]) * 16, computed_blocks
2784 )
2785 assert blocks is not None and blocks.get_block_ids() == ([1, 2, 3, 4],)
2786
2787 # Append slots without allocating a new block.
2788 req0.num_computed_tokens = 55
2789 for _ in range(4):
2790 req0.append_output_token_ids(8)
2791 new_blocks = manager.allocate_slots(
2792 req0, 4, len(computed_blocks.blocks[0]) * 16, computed_blocks
2793 )
2794 assert new_blocks is not None and len(new_blocks.blocks[0]) == 0
2795 assert (
2796 manager.coordinator.single_type_managers[0]
2797 .req_to_blocks[req0.request_id][-1]
2798 .block_hash
2799 is None
2800 )
2801
2802 # Append slots with allocating a new block.
2803 req0.num_computed_tokens = 59
2804 # 9 tokens to fill the previous block, and 10 tokens to fill
2805 # the preallocated block.
2806 for _ in range(9 + 10):
2807 req0.append_output_token_ids(7)
2808 new_blocks = manager.allocate_slots(
2809 req0, 19, len(computed_blocks.blocks[0]) * 16, computed_blocks
2810 )
2811 assert new_blocks is not None and len(new_blocks.blocks[0]) == 1
2812 assert (
2813 manager.coordinator.single_type_managers[0]
2814 .req_to_blocks[req0.request_id][-2]
2815 .block_hash
2816 is not None
2817 )
2818 assert (
2819 manager.coordinator.single_type_managers[0]
2820 .req_to_blocks[req0.request_id][-1]
2821 .block_hash
2822 is None
2823 )
2824
2825
2826 def test_evict():
2827 block_size = 16
2828 manager = make_kv_cache_manager(
2829 make_kv_cache_config(block_size, 11),
2830 max_model_len=8192,
2831 enable_caching=True,
2832 hash_block_size=block_size,
2833 )
2834
2835 last_token_id = 5 * 16 + 7
2836 req0 = make_request("0", list(range(last_token_id)), block_size, sha256)
2837 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
2838 assert not computed_blocks.blocks[0]
2839 assert num_computed_tokens == 0
2840 blocks = manager.allocate_slots(
2841 req0, 5 * 16 + 7, len(computed_blocks.blocks[0]) * 16, computed_blocks
2842 )
2843 # 5 full + 1 partial
2844 assert blocks is not None and len(blocks.blocks[0]) == 6
2845
2846 # 3 blocks.
2847 req1 = make_request(
2848 "1", list(range(last_token_id, last_token_id + 3 * 16)), block_size, sha256
2849 )
2850 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
2851 assert not computed_blocks.blocks[0]
2852 assert num_computed_tokens == 0
2853 blocks = manager.allocate_slots(
2854 req1, 3 * 16, len(computed_blocks.blocks[0]) * 16, computed_blocks
2855 )
2856 assert blocks is not None and len(blocks.blocks[0]) == 3 # 3 full blocks
2857 last_token_id += 3 * 16
2858
2859 # 10 - (6 + 3) == 1
2860 assert manager.block_pool.free_block_queue.num_free_blocks == 1
2861
2862 manager.free(req0)
2863 # partial blocks (without hash) at head, other at tail (LRU policy):
2864 assert [
2865 b.block_id for b in manager.block_pool.free_block_queue.get_all_free_blocks()
2866 ] == [6, 10, 5, 4, 3, 2, 1]
2867 manager.free(req1)
2868 assert manager.block_pool.free_block_queue.num_free_blocks == 10
2869 assert [
2870 b.block_id for b in manager.block_pool.free_block_queue.get_all_free_blocks()
2871 ] == [6, 10, 5, 4, 3, 2, 1, 9, 8, 7]
2872
2873 # Touch the first 2 blocks.
2874 req2 = make_request("2", list(range(2 * 16 + 3)), block_size, sha256)
2875 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req2)
2876 assert computed_blocks.get_block_ids() == ([1, 2],)
2877 assert num_computed_tokens == 2 * 16
2878 blocks = manager.allocate_slots(
2879 req2, 3, len(computed_blocks.blocks[0]) * 16, computed_blocks
2880 )
2881 assert blocks is not None and blocks.get_block_ids() == ([6],)
2882 assert manager.block_pool.free_block_queue.num_free_blocks == 7
2883
2884
2885 def test_hash_block_correct_reuse():
2886 """This tests when a previously cached block is reused as a new block,
2887 its hash metadata should be correctly reset.
2888 """
2889 block_size = 16
2890 manager = make_kv_cache_manager(
2891 make_kv_cache_config(16, 2),
2892 max_model_len=8192,
2893 enable_caching=True,
2894 hash_block_size=block_size,
2895 )
2896
2897 # Allocate 1 block and cache it.
2898 num_tokens = block_size * 1
2899 req = make_request("0", list(range(num_tokens)), block_size, sha256)
2900 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req)
2901 assert not computed_blocks.blocks[0]
2902 assert num_computed_tokens == 0
2903 blocks = manager.allocate_slots(
2904 req, num_tokens, len(computed_blocks.blocks[0]) * 16, computed_blocks
2905 )
2906 assert blocks is not None and len(blocks.blocks[0]) == 1
2907
2908 # Deallocate the block.
2909 manager.free(req)
2910
2911 # Allocate a new block that's not full, make sure hash info on the
2912 # block is cleared.
2913 req = make_request("1", list(range(num_tokens - 1)), block_size, sha256)
2914 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req)
2915 assert not computed_blocks.blocks[0]
2916 assert num_computed_tokens == 0
2917 blocks = manager.allocate_slots(
2918 req, num_tokens - 1, len(computed_blocks.blocks[0]) * 16, computed_blocks
2919 )
2920 assert blocks is not None and len(blocks.blocks[0]) == 1
2921
2922 assert manager.block_pool.blocks[blocks.blocks[0][0].block_id].block_hash is None
2923
2924
2925 def test_computed_blocks_not_evicted():
2926 """Test that the computed blocks are not evicted when getting new blocks
2927 for a request if there are any other free blocks.
2928 """
2929 block_size = 16
2930 manager = make_kv_cache_manager(
2931 make_kv_cache_config(block_size, 3),
2932 max_model_len=8192,
2933 enable_caching=True,
2934 hash_block_size=block_size,
2935 )
2936
2937 # Allocate a block and cache it.
2938 num_tokens = block_size * 1
2939 req0 = make_request("0", list(range(num_tokens)), block_size, sha256)
2940 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
2941 assert not computed_blocks.blocks[0]
2942 assert num_computed_tokens == 0
2943 blocks = manager.allocate_slots(
2944 req0, num_tokens, len(computed_blocks.blocks[0]) * 16, computed_blocks
2945 )
2946 assert blocks is not None and len(blocks.blocks[0]) == 1
2947 assert blocks.blocks[0][0].block_id == 1
2948
2949 # Allocate another block.
2950 req1 = make_request(
2951 "1", list(range(num_tokens, num_tokens * 2)), block_size, sha256
2952 )
2953 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
2954 assert not computed_blocks.blocks[0]
2955 assert num_computed_tokens == 0
2956 blocks = manager.allocate_slots(
2957 req1, num_tokens, len(computed_blocks.blocks[0]) * 16, computed_blocks
2958 )
2959 assert blocks is not None and len(blocks.blocks[0]) == 1
2960 assert blocks.blocks[0][0].block_id == 2
2961
2962 # Free the blocks.
2963 manager.free(req0)
2964 manager.free(req1)
2965
2966 # Now if we have a cache hit on the first block, we should evict the second
2967 # cached block rather than the first one.
2968 req2 = make_request("2", list(range(num_tokens * 2)), block_size, sha256)
2969 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req2)
2970 assert len(computed_blocks.blocks[0]) == 1
2971 assert computed_blocks.blocks[0][0].block_id == 1
2972 assert num_computed_tokens == block_size
2973
2974 blocks = manager.allocate_slots(
2975 req2,
2976 num_tokens * 2 - num_tokens,
2977 len(computed_blocks.blocks[0]) * 16,
2978 computed_blocks,
2979 )
2980 assert blocks is not None and len(blocks.blocks[0]) == 1
2981 assert blocks.blocks[0][0].block_id == 2
2982
2983
2984 def test_basic_prefix_caching_disabled():
2985 """This tests that the prefix caching is disabled."""
2986 block_size = 4
2987 manager = make_kv_cache_manager(
2988 make_kv_cache_config(block_size, 5),
2989 max_model_len=8192,
2990 enable_caching=False,
2991 hash_block_size=block_size,
2992 )
2993
2994 req1 = make_request(
2995 "1", list(range(10)), block_size, sha256
2996 ) # 2 blocks and some more
2997
2998 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
2999 assert not computed_blocks.blocks[0]
3000 assert num_computed_tokens == 0
3001 blocks = manager.allocate_slots(
3002 req1, 10, len(computed_blocks.blocks[0]) * 16, computed_blocks
3003 )
3004 assert blocks is not None and len(blocks.blocks[0]) == 3
3005
3006 # Free the blocks.
3007 manager.free(req1)
3008
3009 # No caching.
3010 req2 = make_request("2", list(range(16)), block_size, sha256) # shared prefix
3011 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req2)
3012 assert not computed_blocks.blocks[0]
3013 assert num_computed_tokens == 0
3014 blocks = manager.allocate_slots(
3015 req2, 16, len(computed_blocks.blocks[0]) * 16, computed_blocks
3016 )
3017 assert blocks is not None and len(blocks.blocks[0]) == 4
3018
3019 # New requests should not have any blocks.
3020 req3 = make_request("3", list(range(4)), block_size, sha256)
3021 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req3)
3022 assert not computed_blocks.blocks[0]
3023 assert num_computed_tokens == 0
3024 blocks = manager.allocate_slots(
3025 req3, 4, len(computed_blocks.blocks[0]) * 16, computed_blocks
3026 )
3027 assert not blocks
3028
3029
3030 @pytest.mark.parametrize("hash_fn", [sha256, sha256_cbor])
3031 def test_cache_blocks(hash_fn):
3032 """This is a unit test that tests the correctness of the _cache_full_blocks
3033 function of KVCacheManager.
3034 """
3035 block_size = 4
3036 block_pool = BlockPool(
3037 num_gpu_blocks=5,
3038 enable_caching=True,
3039 hash_block_size=block_size,
3040 )
3041 # Req:
3042 # Block 0: [0, 1, 2, 3]
3043 # Block 1: [4, 5, 6, 7]
3044 # Block 2: [8, 9, 10, 11]
3045 # Block 3: [12, 13]
3046 req = make_request("0", list(range(14)), block_size, hash_fn)
3047
3048 # Test that blocks are cached correctly for 2 full blocks from the start.
3049 blocks = [KVCacheBlock(block_id=i) for i in range(2)]
3050
3051 block_pool.cache_full_blocks(
3052 request=req,
3053 blocks=blocks,
3054 num_cached_blocks=0,
3055 num_full_blocks=2,
3056 block_size=block_size,
3057 kv_cache_group_id=0,
3058 )
3059
3060 assert len(block_pool.cached_block_hash_to_block) == 2
3061 assert all([block.block_hash is not None for block in blocks])
3062
3063 # Test that blocks that don't start from the beginning are cached
3064 # correctly.
3065 blocks += [KVCacheBlock(block_id=2)]
3066 block_pool.cache_full_blocks(
3067 request=req,
3068 blocks=blocks,
3069 num_cached_blocks=2,
3070 num_full_blocks=3,
3071 block_size=block_size,
3072 kv_cache_group_id=0,
3073 )
3074 assert len(block_pool.cached_block_hash_to_block) == 3
3075 assert blocks[0].block_hash is not None
3076
3077
3078 def test_cache_blocks_multi_group():
3079 """This tests that blocks are cached correctly for different kv cache groups."""
3080 block_size = 4
3081 block_pool = BlockPool(
3082 num_gpu_blocks=10, enable_caching=True, hash_block_size=block_size
3083 )
3084
3085 # Req:
3086 # Block 0/4: [0, 1, 2, 3]
3087 # Block 1/5: [4, 5, 6, 7]
3088 # Block 2/6: [8, 9, 10, 11]
3089 # Block 3/7: [12, 13]
3090 req = make_request("0", list(range(14)), block_size, sha256)
3091
3092 # Cache the blocks for group 0.
3093 blocks = [KVCacheBlock(block_id=i) for i in range(2)]
3094 block_pool.cache_full_blocks(
3095 request=req,
3096 blocks=blocks,
3097 num_cached_blocks=0,
3098 num_full_blocks=2,
3099 block_size=block_size,
3100 kv_cache_group_id=0,
3101 )
3102 assert len(block_pool.cached_block_hash_to_block) == 2
3103 assert len(req.block_hashes) == 3
3104 assert all([block.block_hash is not None for block in blocks])
3105
3106 # Cache the blocks for group 1.
3107 blocks = [KVCacheBlock(block_id=i) for i in range(3)]
3108 block_pool.cache_full_blocks(
3109 request=req,
3110 blocks=blocks,
3111 num_cached_blocks=0,
3112 num_full_blocks=3,
3113 block_size=block_size,
3114 kv_cache_group_id=1,
3115 )
3116 assert len(block_pool.cached_block_hash_to_block) == 5
3117 assert len(req.block_hashes) == 3
3118 assert all([block.block_hash is not None for block in blocks])
3119
3120 # Block hash 0: hit for group 0 and 1
3121 # Block hash 1: hit for group 0 and 1
3122 # Block hash 2: hit for group 1
3123
3124 assert (
3125 block_pool.get_cached_block(req.block_hashes[0], kv_cache_group_ids=[0])
3126 is not None
3127 )
3128 assert (
3129 block_pool.get_cached_block(req.block_hashes[1], kv_cache_group_ids=[0])
3130 is not None
3131 )
3132 assert (
3133 block_pool.get_cached_block(req.block_hashes[2], kv_cache_group_ids=[0]) is None
3134 )
3135 assert (
3136 block_pool.get_cached_block(req.block_hashes[0], kv_cache_group_ids=[1])
3137 is not None
3138 )
3139 assert (
3140 block_pool.get_cached_block(req.block_hashes[1], kv_cache_group_ids=[1])
3141 is not None
3142 )
3143 assert (
3144 block_pool.get_cached_block(req.block_hashes[2], kv_cache_group_ids=[1])
3145 is not None
3146 )
3147 assert (
3148 block_pool.get_cached_block(req.block_hashes[0], kv_cache_group_ids=[0, 1])
3149 is not None
3150 )
3151 assert (
3152 block_pool.get_cached_block(req.block_hashes[1], kv_cache_group_ids=[0, 1])
3153 is not None
3154 )
3155 assert (
3156 block_pool.get_cached_block(req.block_hashes[2], kv_cache_group_ids=[0, 1])
3157 is None
3158 )
3159
3160
3161 def test_mm_prefix_caching():
3162 """This tests that the multi-modal prefix caching is correct."""
3163 block_size = 16
3164 manager = make_kv_cache_manager(
3165 make_kv_cache_config(block_size, 11),
3166 max_model_len=8192,
3167 enable_caching=True,
3168 hash_block_size=block_size,
3169 )
3170
3171 # Common prompt tokens (T is text tokens and P is image placeholder tokens)
3172 # [T,...,T, P0,...,P0], [P0,...,P0,T,...,T,P1,...,P1], [P1,...,P1]
3173 common_token_ids = list(range(10)) + [-1] * 6
3174 common_token_ids += [-1] * 4 + list(range(10, 20)) + [-1] * 2
3175 common_token_ids += [-1] * 16
3176
3177 common_mm_positions = [
3178 PlaceholderRange(offset=11, length=10),
3179 PlaceholderRange(offset=30, length=18),
3180 ]
3181 common_mm_hashes = ["aaa", "bbb"]
3182
3183 # A unique image plus some text tokens.
3184 unique_token_ids = [-1] * 7 + [100] * 4
3185 all_token_ids = common_token_ids + unique_token_ids
3186 mm_positions = common_mm_positions + [PlaceholderRange(offset=48, length=7)]
3187 mm_hashes = common_mm_hashes + ["ccc"]
3188 req0 = make_request(
3189 "0",
3190 all_token_ids,
3191 block_size,
3192 sha256,
3193 mm_positions=mm_positions,
3194 mm_hashes=mm_hashes,
3195 )
3196 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
3197
3198 # Completed block should have hashes
3199 assert not computed_blocks.blocks[0]
3200 assert num_computed_tokens == 0
3201 block_hashes = req0.block_hashes
3202 assert len(block_hashes) == 3
3203 assert block_hashes[0] == sha256(
3204 (
3205 kv_cache_utils.NONE_HASH,
3206 tuple(all_token_ids[:block_size]),
3207 (("mm", "aaa", 11),),
3208 )
3209 )
3210 assert block_hashes[1] == sha256(
3211 (
3212 block_hashes[0],
3213 tuple(all_token_ids[block_size : block_size * 2]),
3214 (("mm", "aaa", -5), ("mm", "bbb", 14)),
3215 )
3216 )
3217 assert block_hashes[2] == sha256(
3218 (
3219 block_hashes[1],
3220 tuple(all_token_ids[block_size * 2 : block_size * 3]),
3221 (("mm", "bbb", -2),),
3222 )
3223 )
3224
3225 blocks = manager.allocate_slots(
3226 req0, 59, len(computed_blocks.blocks[0]) * 16, computed_blocks
3227 )
3228 assert blocks is not None
3229 assert blocks.get_block_ids() == ([1, 2, 3, 4],)
3230 req0.num_computed_tokens = 59
3231
3232 # Append slots without allocating a new block.
3233 for _ in range(5):
3234 req0.append_output_token_ids(8)
3235 new_blocks = manager.allocate_slots(
3236 req0, 5, len(computed_blocks.blocks[0]) * 16, computed_blocks
3237 )
3238 assert new_blocks is not None and len(new_blocks.blocks[0]) == 0
3239 assert len(block_hashes) == 4
3240 assert block_hashes[3] == sha256(
3241 (
3242 block_hashes[2],
3243 tuple(all_token_ids[3 * block_size :] + [8] * 5),
3244 (("mm", "ccc", 0),),
3245 )
3246 )
3247
3248 # Cache hit.
3249 unique_token_ids = [-1] * 7 + [200] * 5
3250 all_token_ids = common_token_ids + unique_token_ids
3251 mm_positions = common_mm_positions + [PlaceholderRange(offset=48, length=7)]
3252 mm_hashes = common_mm_hashes + ["ccc"]
3253 req1 = make_request(
3254 "1",
3255 all_token_ids,
3256 block_size,
3257 sha256,
3258 mm_positions=mm_positions,
3259 mm_hashes=mm_hashes,
3260 )
3261 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
3262 assert len(computed_blocks.blocks[0]) == 3
3263 assert num_computed_tokens == 3 * 16
3264
3265
3266 def test_cache_key_salting():
3267 """This tests that cache salts are applied during hashing and the cache
3268 is separated cache as expected.
3269 """
3270 block_size = 16
3271 manager = make_kv_cache_manager(
3272 make_kv_cache_config(block_size, 11),
3273 max_model_len=8192,
3274 enable_caching=True,
3275 hash_block_size=block_size,
3276 )
3277
3278 # 3 complete blocks and an incomplete block with 11 tokens.
3279 common_token_ids = [i for i in range(3) for _ in range(block_size)]
3280 token_ids = common_token_ids + [3] * 11
3281 req0 = make_request("0", token_ids, block_size, sha256, cache_salt="salt1")
3282 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
3283
3284 # Completed block should have hashes
3285 assert not computed_blocks.blocks[0]
3286 assert num_computed_tokens == 0
3287 block_hashes = req0.block_hashes
3288 assert len(block_hashes) == 3
3289 assert block_hashes[0] == sha256(
3290 (
3291 kv_cache_utils.NONE_HASH,
3292 tuple(token_ids[:block_size]),
3293 (("cache_salt", "salt1"),),
3294 )
3295 )
3296 assert block_hashes[1] == sha256(
3297 (block_hashes[0], tuple(token_ids[block_size : block_size * 2]), None)
3298 )
3299 assert block_hashes[2] == sha256(
3300 (block_hashes[1], tuple(token_ids[block_size * 2 : block_size * 3]), None)
3301 )
3302
3303 blocks = manager.allocate_slots(
3304 req0, 59, len(computed_blocks.blocks[0]) * 16, computed_blocks
3305 )
3306 assert blocks is not None
3307 assert blocks.get_block_ids() == ([1, 2, 3, 4],)
3308 req0.num_computed_tokens = 59
3309
3310 # Append slots without allocating a new block.
3311 for _ in range(5):
3312 req0.append_output_token_ids(8)
3313 new_blocks = manager.allocate_slots(
3314 req0, 5, len(computed_blocks.blocks[0]) * 16, computed_blocks
3315 )
3316 assert new_blocks is not None and len(new_blocks.blocks[0]) == 0
3317 assert len(block_hashes) == 4
3318 assert block_hashes[3] == sha256(
3319 (block_hashes[2], tuple(token_ids[3 * block_size :] + [8] * 5), None)
3320 )
3321
3322 # Test cache hit with a new request that has the same salt.
3323 token_ids = common_token_ids + [4] * 11
3324 req1 = make_request("1", token_ids, block_size, sha256, cache_salt="salt1")
3325 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
3326 # Should match only a prefix of 3 blocks.
3327 assert len(computed_blocks.blocks[0]) == 3
3328 assert num_computed_tokens == 3 * block_size
3329
3330 # Test cache miss with same content but different salt.
3331 token_ids = common_token_ids + [4] * 11
3332 req2 = make_request("2", token_ids, block_size, sha256, cache_salt="salt2")
3333 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req2)
3334 assert len(computed_blocks.blocks[0]) == 0
3335 assert num_computed_tokens == 0
3336 block_hashes = req2.block_hashes
3337 assert len(block_hashes) == 3
3338 assert block_hashes[0] == sha256(
3339 (
3340 kv_cache_utils.NONE_HASH,
3341 tuple(token_ids[:block_size]),
3342 (("cache_salt", "salt2"),),
3343 )
3344 )
3345 assert block_hashes[1] == sha256(
3346 (block_hashes[0], tuple(token_ids[block_size : block_size * 2]), None)
3347 )
3348 assert block_hashes[2] == sha256(
3349 (block_hashes[1], tuple(token_ids[block_size * 2 : block_size * 3]), None)
3350 )
3351
3352
3353 def test_prefill_not_enough_free_blocks_with_computed_blocks():
3354 """This is a unit test that tests the correctness of the allocate_slots
3355 when there is not enough free blocks. Specifically, when a request
3356 has computed blocks but cannot be allocated due to not enough free blocks,
3357 the computed blocks should not be touched.
3358 """
3359 block_size = 16
3360 manager = make_kv_cache_manager(
3361 make_kv_cache_config(block_size, 11),
3362 max_model_len=8192,
3363 enable_caching=True,
3364 hash_block_size=block_size,
3365 )
3366 # Complete 3 blocks (48 tokens)
3367 # | Common-0 | Common-1 | Common-2 | ... |
3368 common_token_ids = [i for i in range(3) for _ in range(16)]
3369 req0 = make_request("0", common_token_ids, block_size, sha256)
3370 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
3371 assert not computed_blocks.blocks[0]
3372 assert num_computed_tokens == 0
3373 manager.allocate_slots(
3374 req0, 48, len(computed_blocks.blocks[0]) * 16, computed_blocks
3375 )
3376 block_part0 = manager.coordinator.single_type_managers[0].req_to_blocks[
3377 req0.request_id
3378 ]
3379
3380 # | Common-0 | Common-1 | Common-2 | Req1-3 | Req1-4 | Req1-5 | ... |
3381 req1 = make_request("1", common_token_ids * 2, block_size, sha256)
3382 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
3383 assert computed_blocks.blocks[0] == block_part0
3384 assert num_computed_tokens == 3 * 16
3385 manager.allocate_slots(
3386 req1, 48, len(computed_blocks.blocks[0]) * 16, computed_blocks
3387 )
3388 block_part1 = manager.coordinator.single_type_managers[0].req_to_blocks[
3389 req1.request_id
3390 ]
3391 # | Common-0 | Common-1 | Common-2 | Req1-3 (F) | Req1-4 (F) |
3392 # | Req1-5(F)| ... |
3393 manager.free(req1)
3394 assert {block.ref_cnt for block in block_part1[:3]} == {1}
3395 assert {block.ref_cnt for block in block_part1[3:]} == {0}
3396
3397 # | Common-0 | Common-1 | Common-2 | Req1-3 (F) | Req1-4 (F) |
3398 # | Req1-5(F)| Req2-0 | Req2-1 | ... |
3399 req2 = make_request("2", [7] * block_size * 2, block_size, sha256)
3400 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req2)
3401 assert not computed_blocks.blocks[0]
3402 assert num_computed_tokens == 0
3403 manager.allocate_slots(
3404 req2,
3405 block_size * 2,
3406 len(computed_blocks.blocks[0]) * block_size,
3407 computed_blocks,
3408 )
3409
3410 # Req3 is Req2 + 3 new blocks, so the first 6 blocks are computed,
3411 # but it cannot be allocated due to insufficient free blocks (2).
3412 # In this case, the ref_cnt of the computed blocks should not be changed.
3413 assert manager.block_pool.free_block_queue.num_free_blocks == 5
3414 req3 = make_request("3", common_token_ids * 3, block_size, sha256)
3415 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req3)
3416 assert computed_blocks.blocks[0] == block_part1
3417 assert num_computed_tokens == 6 * 16
3418 # Req3 cannot be allocated.
3419 assert (
3420 manager.allocate_slots(
3421 req3, 48, len(computed_blocks.blocks[0]) * 16, computed_blocks
3422 )
3423 is None
3424 )
3425 # Block 0-2 are used by Req 1.
3426 assert {block.ref_cnt for block in block_part1[:3]} == {1}
3427 # Block 3-5 are free.
3428 assert {block.ref_cnt for block in block_part1[3:]} == {0}
3429
3430
3431 def test_reset_prefix_cache():
3432 block_size = 16
3433 manager = make_kv_cache_manager(
3434 make_kv_cache_config(block_size, 11),
3435 max_model_len=8192,
3436 enable_caching=True,
3437 hash_block_size=block_size,
3438 )
3439
3440 full_block_token_ids = [i for i in range(3) for _ in range(16)]
3441 unique_token_ids = [3] * 7
3442 all_token_ids = full_block_token_ids + unique_token_ids
3443 req0 = make_request("0", all_token_ids, block_size, sha256)
3444 blocks = manager.allocate_slots(req0, 55)
3445 assert blocks is not None and blocks.get_block_ids() == ([1, 2, 3, 4],)
3446
3447 unique_token_ids = [4] * 7
3448 all_token_ids = full_block_token_ids + unique_token_ids
3449 req1 = make_request("1", all_token_ids, block_size, sha256)
3450 computed_blocks, _, _ = manager.get_computed_blocks(req1)
3451 assert len(req1.block_hashes) == 3
3452 assert len(computed_blocks.blocks[0]) == 3
3453 blocks = manager.allocate_slots(
3454 req1, 7, len(computed_blocks.blocks[0]) * 16, computed_blocks
3455 )
3456 assert blocks is not None and blocks.get_block_ids() == ([5],)
3457
3458 # Failed to reset prefix cache because some blocks are not freed yet.
3459 assert not manager.reset_prefix_cache()
3460 assert manager.block_pool.cached_block_hash_to_block
3461
3462 # Free the blocks.
3463 manager.free(req0)
3464 manager.free(req1)
3465
3466 assert manager.reset_prefix_cache()
3467 assert not manager.block_pool.cached_block_hash_to_block
3468 assert all([blk.block_hash is None for blk in manager.block_pool.blocks])
3469
3470
3471 def test_prefix_cache_stats_disabled():
3472 """Test that prefix_cache_stats is None when log_stats is False."""
3473 block_size = 16
3474 manager = make_kv_cache_manager(
3475 make_kv_cache_config(block_size, 11),
3476 max_model_len=8192,
3477 enable_caching=True,
3478 hash_block_size=block_size,
3479 log_stats=False, # Disable logging stats
3480 )
3481 assert manager.prefix_cache_stats is None
3482
3483 # Call all functions that check whether log_stats is disabled.
3484 req = make_request("0", list(range(16)), block_size, sha256)
3485 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req)
3486 assert not computed_blocks.blocks[0]
3487 assert num_computed_tokens == 0
3488 manager.allocate_slots(
3489 req, 16, len(computed_blocks.blocks[0]) * 16, computed_blocks
3490 )
3491 manager.reset_prefix_cache()
3492
3493 # Ensure prefix_cache_stats remains None
3494 assert manager.prefix_cache_stats is None
3495
3496
3497 def test_maybe_evict_cached_block():
3498 pool = BlockPool(num_gpu_blocks=4, enable_caching=True, hash_block_size=16)
3499 block_hash0 = make_block_hash_with_group_id(BlockHash(b"10"), 1000)
3500 block_hash1 = make_block_hash_with_group_id(BlockHash(b"20"), 2000)
3501 block_hash2 = make_block_hash_with_group_id(BlockHash(b"30"), 3000)
3502 block_hashes = [
3503 block_hash0,
3504 block_hash1,
3505 block_hash2,
3506 # block3 had the exact same block_hash as the first block
3507 block_hash0,
3508 ]
3509 assert len(pool.blocks) == len(block_hashes)
3510 # Manually add all blocks to cached_blocks
3511 for block, block_hash in zip(pool.blocks, block_hashes):
3512 block.set_block_hash(block_hash)
3513 pool.cached_block_hash_to_block.insert(block_hash, block)
3514
3515 block0, block1, block2, block3 = pool.blocks
3516 assert pool.cached_block_hash_to_block._cache == {
3517 block_hash0: {
3518 block0.block_id: block0,
3519 block3.block_id: block3,
3520 },
3521 block_hash1: block1,
3522 block_hash2: block2,
3523 }
3524 # Evict block1
3525 pool._maybe_evict_cached_block(block1)
3526 assert pool.cached_block_hash_to_block._cache == {
3527 block_hash0: {block0.block_id: block0, block3.block_id: block3},
3528 block_hash2: block2,
3529 }
3530 # Evict block0: block_hash0 entry should NOT be removed, as block3
3531 # also use the same hash
3532 pool._maybe_evict_cached_block(block0)
3533 assert pool.cached_block_hash_to_block._cache == {
3534 block_hash0: {block3.block_id: block3},
3535 block_hash2: block2,
3536 }
3537 # Evict block2
3538 pool._maybe_evict_cached_block(block2)
3539 assert pool.cached_block_hash_to_block._cache == {block_hash0: {3: block3}}
3540 # Evict block3
3541 pool._maybe_evict_cached_block(block3)
3542 assert pool.cached_block_hash_to_block._cache == {}
3543
3544
3545 @pytest.mark.parametrize("blocks_to_cache", [2, 3, 10])
3546 def test_kv_cache_events(blocks_to_cache: int):
3547 block_size = 16
3548 num_blocks = blocks_to_cache + 1
3549
3550 # Allocate Blocks
3551 # Should see a single block stored event with a blocks_to_cache number of
3552 # block hashes
3553 # take_events should reset the kv_event_queue
3554 manager = make_kv_cache_manager(
3555 make_kv_cache_config(block_size, num_blocks),
3556 max_model_len=8192,
3557 enable_caching=True,
3558 enable_kv_cache_events=True,
3559 hash_block_size=block_size,
3560 )
3561
3562 num_tokens = block_size * blocks_to_cache
3563 req0 = make_request("0", list(range(num_tokens)), block_size, sha256)
3564 _ = manager.allocate_slots(req0, num_tokens)
3565 events = manager.take_events()
3566
3567 block = events[-1]
3568 assert (
3569 len(block.block_hashes)
3570 == blocks_to_cache
3571 == len(manager.block_pool.cached_block_hash_to_block)
3572 )
3573 assert len(block.token_ids) == block.block_size * len(block.block_hashes)
3574 assert block.kv_cache_spec_kind == KVCacheSpecKind.FULL_ATTENTION.value
3575 assert len(manager.block_pool.kv_event_queue) == 0
3576
3577 stored_block_hash = block.block_hashes
3578
3579 # Remove blocks and send another request
3580 # Should see block_to_cache number of removed block events and a new block
3581 # stored event
3582 manager.free(req0)
3583 req1 = make_request("1", list(range(num_tokens)), block_size, sha256)
3584 _ = manager.allocate_slots(req1, num_tokens)
3585 events = manager.take_events()
3586
3587 for blocks in events[:-1]:
3588 assert isinstance(blocks, BlockRemoved)
3589 assert blocks.block_hashes[0] in stored_block_hash
3590 assert len(events) == blocks_to_cache + 1
3591 assert isinstance(events[-2], BlockRemoved)
3592 assert (
3593 len(events[-1].block_hashes)
3594 == blocks_to_cache
3595 == len(manager.block_pool.cached_block_hash_to_block)
3596 )
3597
3598 # All Blocks Cleared
3599 # Should see a single all blocks cleared event
3600 manager.free(req1)
3601 manager.reset_prefix_cache()
3602 events = manager.take_events()
3603
3604 assert isinstance(events[-1], AllBlocksCleared)
3605 assert len(manager.block_pool.cached_block_hash_to_block) == 0
3606
3607
3608 def test_null_parent_block_hash():
3609 block_size = 1
3610 num_cached_blocks = 2
3611 num_full_blocks = 4
3612 kv_cache_group_id = 0
3613
3614 pool = BlockPool(
3615 num_gpu_blocks=8,
3616 enable_caching=True,
3617 hash_block_size=block_size,
3618 enable_kv_cache_events=True,
3619 )
3620
3621 req = make_request(
3622 "req_null_parent",
3623 prompt_token_ids=[10, 11, 12, 13],
3624 block_size=block_size,
3625 hash_fn=sha256,
3626 )
3627 assert len(req.block_hashes) == num_full_blocks
3628
3629 # Physical parent is `null_block` (no hash), while the logical parent hash
3630 # still exists in `request.block_hashes[num_cached_blocks - 1]`.
3631 assert pool.null_block.block_hash is None
3632 new_blocks = pool.get_new_blocks(num_full_blocks - 1)
3633 blocks = [
3634 new_blocks[: num_cached_blocks - 1],
3635 pool.null_block, # physical parent
3636 *new_blocks[num_cached_blocks - 1 :],
3637 ]
3638
3639 pool.cache_full_blocks(
3640 request=req,
3641 blocks=blocks,
3642 num_cached_blocks=num_cached_blocks,
3643 num_full_blocks=num_full_blocks,
3644 block_size=block_size,
3645 kv_cache_group_id=kv_cache_group_id,
3646 )
3647
3648 events = pool.take_events()
3649 assert len(events) == 1
3650 event = events[0]
3651 assert isinstance(event, BlockStored)
3652
3653 expected_parent = kv_cache_utils.maybe_convert_block_hash(
3654 req.block_hashes[num_cached_blocks - 1]
3655 )
3656 assert event.parent_block_hash == expected_parent
3657 assert event.parent_block_hash is not None
3658
3659 expected_new_hashes = [
3660 kv_cache_utils.maybe_convert_block_hash(h)
3661 for h in req.block_hashes[num_cached_blocks:num_full_blocks]
3662 ]
3663 assert event.block_hashes == expected_new_hashes
3664 assert event.group_idx == kv_cache_group_id
3665 assert event.kv_cache_spec_kind is None
3666 assert event.kv_cache_spec_sliding_window is None
3667
3668 # Ensure we didn't accidentally assign a hash to the null block.
3669 assert pool.null_block.block_hash is None
3670 # Sanity check: newly cached physical blocks should have hashes assigned.
3671 assert blocks[num_cached_blocks].block_hash is not None
3672 assert blocks[num_full_blocks - 1].block_hash is not None
3673
3674
3675 @pytest.mark.parametrize("blocks_to_cache", [2, 3, 10])
3676 def test_kv_cache_events_with_lora(blocks_to_cache: int):
3677 """Test BlockStored events contain correct lora_id when using LoRA requests."""
3678 block_size = 16
3679 num_blocks = blocks_to_cache + 1
3680
3681 # Create KVCacheManager with events enabled
3682 manager = make_kv_cache_manager(
3683 make_kv_cache_config(block_size, num_blocks),
3684 max_model_len=8192,
3685 enable_caching=True,
3686 enable_kv_cache_events=True,
3687 hash_block_size=block_size,
3688 )
3689
3690 # Test with LoRA request
3691 lora_request = LoRARequest(
3692 lora_name="test_lora", lora_int_id=42, lora_path="/test/path"
3693 )
3694
3695 num_tokens = block_size * blocks_to_cache
3696 req_with_lora = make_request(
3697 "lora_req",
3698 list(range(num_tokens)),
3699 block_size,
3700 sha256,
3701 lora_request=lora_request,
3702 )
3703
3704 # Allocate slots and get events
3705 _ = manager.allocate_slots(req_with_lora, num_tokens)
3706 events = manager.take_events()
3707
3708 # Verify BlockStored event contains correct lora_id
3709 block_stored_event = events[-1]
3710 assert isinstance(block_stored_event, BlockStored)
3711 assert block_stored_event.lora_id == 42 # Should match lora_request.adapter_id
3712 assert len(block_stored_event.block_hashes) == blocks_to_cache
3713 assert block_stored_event.block_size == block_size
3714
3715 # Clean up
3716 manager.free(req_with_lora)
3717
3718 # Test without LoRA request (should have lora_id=None)
3719 req_without_lora = make_request(
3720 "no_lora_req", list(range(num_tokens)), block_size, sha256
3721 )
3722
3723 _ = manager.allocate_slots(req_without_lora, num_tokens)
3724 events = manager.take_events()
3725
3726 block_stored_event = events[-1]
3727 assert isinstance(block_stored_event, BlockStored)
3728 assert block_stored_event.lora_id is None # Should be None when no LoRA request
3729 assert len(block_stored_event.block_hashes) == blocks_to_cache
3730 assert block_stored_event.block_size == block_size
3731
3732
3733 @pytest.mark.parametrize("group_id", [0, 1, 2])
3734 def test_block_stored_event_group_idx(group_id: int):
3735 """Test BlockStored events emitted by cache_full_blocks carry the correct
3736 group_idx."""
3737 block_size = 4
3738 num_tokens = block_size * 2
3739
3740 manager = make_kv_cache_manager(
3741 make_kv_cache_config_three_types(block_size, num_blocks=5),
3742 max_model_len=8192,
3743 enable_caching=True,
3744 enable_kv_cache_events=True,
3745 hash_block_size=block_size,
3746 )
3747 pool = manager.block_pool
3748
3749 req = make_request(
3750 "req_grp_idx",
3751 prompt_token_ids=list(range(num_tokens)),
3752 block_size=block_size,
3753 hash_fn=sha256,
3754 )
3755
3756 blocks = pool.get_new_blocks(2)
3757 pool.cache_full_blocks(
3758 request=req,
3759 blocks=blocks,
3760 num_cached_blocks=0,
3761 num_full_blocks=2,
3762 block_size=block_size,
3763 kv_cache_group_id=group_id,
3764 )
3765
3766 events = manager.take_events()
3767 assert len(events) == 1
3768 assert isinstance(events[0], BlockStored)
3769 assert events[0].group_idx == group_id
3770 assert (
3771 events[0].kv_cache_spec_kind
3772 == [
3773 KVCacheSpecKind.FULL_ATTENTION.value,
3774 KVCacheSpecKind.SLIDING_WINDOW.value,
3775 KVCacheSpecKind.MAMBA.value,
3776 ][group_id]
3777 )
3778 assert (
3779 events[0].kv_cache_spec_sliding_window
3780 == [
3781 None,
3782 2 * block_size,
3783 None,
3784 ][group_id]
3785 )
3786
3787
3788 def test_block_stored_event_session_id():
3789 block_size = 4
3790 session_id = "agent-session-1"
3791 manager = make_kv_cache_manager(
3792 make_kv_cache_config(block_size, num_blocks=5),
3793 max_model_len=8192,
3794 enable_caching=True,
3795 enable_kv_cache_events=True,
3796 hash_block_size=block_size,
3797 )
3798 req = make_request(
3799 "req_session_id",
3800 prompt_token_ids=list(range(block_size * 2)),
3801 block_size=block_size,
3802 hash_fn=sha256,
3803 session_id=session_id,
3804 )
3805
3806 manager.allocate_slots(req, req.num_tokens)
3807 events = manager.take_events()
3808
3809 assert len(events) == 1
3810 assert isinstance(events[0], BlockStored)
3811 assert events[0].session_id == session_id
3812
3813
3814 def test_session_id_does_not_affect_prefix_cache_identity():
3815 block_size = 4
3816 prompt_token_ids = list(range(block_size * 2 + 1))
3817 manager = make_kv_cache_manager(
3818 make_kv_cache_config(block_size, num_blocks=8),
3819 max_model_len=8192,
3820 enable_caching=True,
3821 hash_block_size=block_size,
3822 )
3823 first_req = make_request(
3824 "req_session_a",
3825 prompt_token_ids,
3826 block_size,
3827 sha256,
3828 session_id="session-a",
3829 )
3830 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(first_req)
3831 allocated_blocks = manager.allocate_slots(
3832 first_req,
3833 first_req.num_tokens,
3834 num_computed_tokens,
3835 computed_blocks,
3836 )
3837 assert allocated_blocks is not None
3838
3839 second_req = make_request(
3840 "req_session_b",
3841 prompt_token_ids,
3842 block_size,
3843 sha256,
3844 session_id="session-b",
3845 )
3846 assert second_req.block_hashes == first_req.block_hashes
3847
3848 cached_blocks, num_cached_tokens, _ = manager.get_computed_blocks(second_req)
3849 assert num_cached_tokens == block_size * 2
3850 assert cached_blocks.get_block_ids() == (allocated_blocks.get_block_ids()[0][:2],)
3851
3852
3853 def test_block_stored_event_group_idx_multiple_groups():
3854 """Test BlockStored events for separate HMA groups that each carry the
3855 correct group_idx.
3856
3857 Simulates the HMA scenario where full-attention blocks (group 0) and
3858 sliding-window blocks (group 1) are cached independently and must be
3859 distinguishable by consumers doing HMA-aware prefix-cache routing.
3860 """
3861 block_size = 4
3862 num_tokens = block_size * 2
3863
3864 manager = make_kv_cache_manager(
3865 KVCacheConfig(
3866 num_blocks=5,
3867 kv_cache_tensors=[],
3868 kv_cache_groups=[
3869 KVCacheGroupSpec(
3870 ["layer1"],
3871 FullAttentionSpec(
3872 block_size=block_size,
3873 num_kv_heads=1,
3874 head_size=1,
3875 dtype=torch.float32,
3876 ),
3877 ),
3878 KVCacheGroupSpec(
3879 ["layer2"],
3880 SlidingWindowSpec(
3881 block_size=block_size,
3882 num_kv_heads=1,
3883 head_size=1,
3884 dtype=torch.float32,
3885 sliding_window=128,
3886 ),
3887 ),
3888 ],
3889 ),
3890 max_model_len=8192,
3891 enable_caching=True,
3892 enable_kv_cache_events=True,
3893 hash_block_size=block_size,
3894 )
3895 pool = manager.block_pool
3896
3897 req = make_request(
3898 "req_multi_grp",
3899 prompt_token_ids=list(range(num_tokens)),
3900 block_size=block_size,
3901 hash_fn=sha256,
3902 )
3903
3904 # Cache blocks for group 0 (full-attention)
3905 blocks_grp0 = pool.get_new_blocks(2)
3906 pool.cache_full_blocks(
3907 request=req,
3908 blocks=blocks_grp0,
3909 num_cached_blocks=0,
3910 num_full_blocks=2,
3911 block_size=block_size,
3912 kv_cache_group_id=0,
3913 )
3914
3915 # Cache blocks for group 1 (sliding-window)
3916 blocks_grp1 = pool.get_new_blocks(2)
3917 pool.cache_full_blocks(
3918 request=req,
3919 blocks=blocks_grp1,
3920 num_cached_blocks=0,
3921 num_full_blocks=2,
3922 block_size=block_size,
3923 kv_cache_group_id=1,
3924 )
3925
3926 events = manager.take_events()
3927 assert len(events) == 2
3928 assert isinstance(events[0], BlockStored)
3929 assert events[0].group_idx == 0
3930 assert events[0].kv_cache_spec_kind == KVCacheSpecKind.FULL_ATTENTION.value
3931 assert events[0].kv_cache_spec_sliding_window is None
3932 assert isinstance(events[1], BlockStored)
3933 assert events[1].group_idx == 1
3934 assert events[1].kv_cache_spec_kind == KVCacheSpecKind.SLIDING_WINDOW.value
3935 assert events[1].kv_cache_spec_sliding_window == 128
3936
3937
3938 def test_block_stored_event_group_idx_out_of_bounds(monkeypatch):
3939 """Out-of-range group_idx events are returned without metadata annotation."""
3940 block_size = 4
3941 manager = make_kv_cache_manager(
3942 make_kv_cache_config(block_size, num_blocks=5),
3943 max_model_len=8192,
3944 enable_caching=True,
3945 enable_kv_cache_events=True,
3946 hash_block_size=block_size,
3947 )
3948 event = BlockStored(
3949 block_hashes=[1],
3950 parent_block_hash=None,
3951 token_ids=list(range(block_size)),
3952 block_size=block_size,
3953 lora_id=None,
3954 medium=None,
3955 lora_name=None,
3956 group_idx=1,
3957 )
3958 manager.block_pool.kv_event_queue.append(event)
3959 warnings = []
3960
3961 def collect_warning(message, *args, **kwargs):
3962 del kwargs
3963 warnings.append(message % args if args else message)
3964
3965 monkeypatch.setattr(kv_cache_manager.logger, "warning", collect_warning)
3966 events = manager.take_events()
3967
3968 assert events == [event]
3969 assert event.kv_cache_spec_kind is None
3970 assert event.kv_cache_spec_sliding_window is None
3971 assert warnings == ["Group index `1` not in KV cache metadata"]
3972
3973
3974 @pytest.mark.parametrize("group_id", [0, 1, 2])
3975 def test_block_removed_event_group_idx(group_id: int):
3976 """Test BlockRemoved events emitted on eviction carry the group_idx extracted
3977 from the evicted block's BlockHashWithGroupId via get_group_id().
3978 """
3979 block_size = 4
3980 num_tokens = block_size * 2
3981
3982 # null block + 4 usable; allocate all 4, cache 2, free all, re-allocate
3983 # all 4 so the 2 cached blocks are forced through _maybe_evict_cached_block.
3984 pool = BlockPool(
3985 num_gpu_blocks=5,
3986 enable_caching=True,
3987 hash_block_size=block_size,
3988 enable_kv_cache_events=True,
3989 )
3990
3991 req = make_request(
3992 "req_evict_grp",
3993 prompt_token_ids=list(range(num_tokens)),
3994 block_size=block_size,
3995 hash_fn=sha256,
3996 )
3997
3998 # Allocate all usable blocks and cache the first two for the target group.
3999 all_blocks = pool.get_new_blocks(4)
4000 pool.cache_full_blocks(
4001 request=req,
4002 blocks=all_blocks,
4003 num_cached_blocks=0,
4004 num_full_blocks=2,
4005 block_size=block_size,
4006 kv_cache_group_id=group_id,
4007 )
4008
4009 # Drain the BlockStored events so only eviction events remain later.
4010 pool.take_events()
4011
4012 # Return all blocks to the free queue so they become eviction candidates.
4013 pool.free_blocks(all_blocks)
4014
4015 # Re-allocate all blocks; the two with hashes trigger BlockRemoved events.
4016 pool.get_new_blocks(4)
4017
4018 events = pool.take_events()
4019 removed_events = [e for e in events if isinstance(e, BlockRemoved)]
4020
4021 assert len(removed_events) == 2
4022 for event in removed_events:
4023 assert event.group_idx == group_id
4024
4025
4026 def test_emit_cached_block_events():
4027 """emit_cached_block_events emits one BlockStored for already-cached
4028 (reused) prefix blocks, carrying the correct group_idx /
4029 parent_block_hash / token_ids, and without mutating block state."""
4030 block_size = 4
4031 num_cached_blocks = 3
4032 kv_cache_group_id = 1
4033 num_tokens = block_size * 4 # 4 full blocks; reuse the first 3
4034
4035 pool = BlockPool(
4036 num_gpu_blocks=8,
4037 enable_caching=True,
4038 hash_block_size=block_size,
4039 enable_kv_cache_events=True,
4040 )
4041
4042 req = make_request(
4043 "req_emit_cached",
4044 prompt_token_ids=list(range(num_tokens)),
4045 block_size=block_size,
4046 hash_fn=sha256,
4047 session_id="agent-session-reuse",
4048 )
4049 assert len(req.block_hashes) >= num_cached_blocks
4050
4051 # Snapshot block state to prove emit_cached_block_events does not mutate it.
4052 free_before = pool.get_num_free_blocks()
4053 assert len(pool.cached_block_hash_to_block) == 0
4054
4055 pool.emit_cached_block_events(
4056 request=req,
4057 num_cached_blocks=num_cached_blocks,
4058 block_size=block_size,
4059 kv_cache_group_id=kv_cache_group_id,
4060 )
4061
4062 # No block-state mutation: nothing allocated, nothing inserted into the
4063 # prefix-cache map.
4064 assert pool.get_num_free_blocks() == free_before
4065 assert len(pool.cached_block_hash_to_block) == 0
4066
4067 events = pool.take_events()
4068 assert len(events) == 1
4069 event = events[0]
4070 assert isinstance(event, BlockStored)
4071
4072 expected_hashes = [
4073 kv_cache_utils.maybe_convert_block_hash(req.block_hashes[i])
4074 for i in range(num_cached_blocks)
4075 ]
4076 assert event.block_hashes == expected_hashes
4077 # Reused blocks start from block 0, so there is no parent block hash.
4078 assert event.parent_block_hash is None
4079 assert event.token_ids == list(req.all_token_ids[: num_cached_blocks * block_size])
4080 assert event.group_idx == kv_cache_group_id
4081 assert event.block_size == block_size
4082 assert event.medium == MEDIUM_GPU
4083 assert event.lora_id is None
4084 assert event.lora_name is None
4085 assert event.session_id == "agent-session-reuse"
4086
4087
4088 def test_emit_cached_block_events_disabled():
4089 """No events are emitted when enable_kv_cache_events is False."""
4090 block_size = 4
4091 pool = BlockPool(
4092 num_gpu_blocks=8,
4093 enable_caching=True,
4094 hash_block_size=block_size,
4095 enable_kv_cache_events=False,
4096 )
4097 req = make_request(
4098 "req_emit_disabled",
4099 prompt_token_ids=list(range(block_size * 4)),
4100 block_size=block_size,
4101 hash_fn=sha256,
4102 )
4103
4104 pool.emit_cached_block_events(
4105 request=req,
4106 num_cached_blocks=3,
4107 block_size=block_size,
4108 kv_cache_group_id=0,
4109 )
4110
4111 assert pool.take_events() == []
4112
4113
4114 def test_emit_cached_block_events_zero_cached():
4115 """No events are emitted when num_cached_blocks == 0."""
4116 block_size = 4
4117 pool = BlockPool(
4118 num_gpu_blocks=8,
4119 enable_caching=True,
4120 hash_block_size=block_size,
4121 enable_kv_cache_events=True,
4122 )
4123 req = make_request(
4124 "req_emit_zero",
4125 prompt_token_ids=list(range(block_size * 4)),
4126 block_size=block_size,
4127 hash_fn=sha256,
4128 )
4129
4130 pool.emit_cached_block_events(
4131 request=req,
4132 num_cached_blocks=0,
4133 block_size=block_size,
4134 kv_cache_group_id=0,
4135 )
4136
4137 assert pool.take_events() == []
4138
4139
4140 def test_eagle_enabled_removes_last_block():
4141 """Verify Eagle does NOT remove blocks when request
4142 length is divisible by block size."""
4143 block_size = 16
4144 manager = make_kv_cache_manager(
4145 make_kv_cache_config(block_size, num_blocks=10),
4146 max_model_len=8192,
4147 enable_caching=True,
4148 use_eagle=True,
4149 hash_block_size=block_size,
4150 )
4151
4152 # Request with 3 full blocks (48 tokens)
4153 token_ids = [0] * (3 * block_size)
4154 req = make_request("divisible_request", token_ids, block_size, sha256)
4155
4156 # Prime the cache
4157 computed_blocks, _, _ = manager.get_computed_blocks(req)
4158 manager.allocate_slots(
4159 req, len(token_ids), len(computed_blocks.blocks[0]) * 16, computed_blocks
4160 )
4161 manager.free(req)
4162
4163 # New request with same tokens + Eagle enabled
4164 req_eagle = make_request("eagle_divisible", token_ids, block_size, sha256)
4165 computed_blocks, num_tokens, _ = manager.get_computed_blocks(req_eagle)
4166
4167 # Should retain 1 block:
4168 # 1. Original 3 blocks → pop last hash → 2 matched blocks
4169 # 2. drop last matched block → 1 remaining block
4170 assert len(computed_blocks.blocks[0]) == 1
4171 assert num_tokens == 1 * block_size # 16 tokens
4172
4173
4174 def test_eagle_with_partial_blocks():
4175 """Test Eagle behavior with requests containing partial blocks."""
4176 block_size = 16
4177 manager = make_kv_cache_manager(
4178 make_kv_cache_config(block_size, num_blocks=10),
4179 max_model_len=8192,
4180 enable_caching=True,
4181 use_eagle=True,
4182 hash_block_size=block_size,
4183 )
4184 # 2 full blocks + 5 tokens (non-divisible length)
4185 token_ids = [0] * (2 * block_size + 5)
4186 req = make_request("partial_block_test", token_ids, block_size, sha256)
4187
4188 # Prime the cache
4189 computed_blocks, _, _ = manager.get_computed_blocks(req)
4190 manager.allocate_slots(
4191 req, len(token_ids), len(computed_blocks.blocks[0]) * 16, computed_blocks
4192 )
4193 manager.free(req)
4194
4195 # New request with Eagle enabled
4196 req_eagle = make_request("partial_eagle", token_ids, block_size, sha256)
4197 computed_blocks, num_tokens, _ = manager.get_computed_blocks(req_eagle)
4198 # Original match: 2 full blocks → Eagle removes 1 → 1 remaining
4199 assert len(computed_blocks.blocks[0]) == 1
4200 assert num_tokens == 1 * block_size
4201
4202
4203 def test_eagle_with_sliding_window():
4204 """Test Eagle behavior with sliding window."""
4205 block_size = 16
4206 sliding_window_spec = SlidingWindowSpec(
4207 block_size=block_size,
4208 num_kv_heads=1,
4209 head_size=1,
4210 dtype=torch.float32,
4211 sliding_window=block_size,
4212 )
4213 manager = make_kv_cache_manager(
4214 KVCacheConfig(
4215 num_blocks=10,
4216 kv_cache_tensors=[],
4217 kv_cache_groups=[KVCacheGroupSpec(["layer"], sliding_window_spec)],
4218 ),
4219 max_model_len=8192,
4220 enable_caching=True,
4221 use_eagle=True,
4222 hash_block_size=block_size,
4223 )
4224
4225 # 2 full blocks + 5 tokens (non-divisible length)
4226 token_ids = [0] * (2 * block_size + 5)
4227 req = make_request("partial_block_test", token_ids, block_size, sha256)
4228
4229 # Prime the cache
4230 computed_blocks, _, _ = manager.get_computed_blocks(req)
4231 manager.allocate_slots(
4232 req, len(token_ids), len(computed_blocks.blocks[0]) * 16, computed_blocks
4233 )
4234 # record the block hash of the first block in the request for later use
4235 block_hash_first_block = req.block_hashes[0]
4236 assert block_hash_first_block is not None
4237 manager.free(req)
4238
4239 # New request with Eagle enabled
4240 req_eagle = make_request("partial_eagle", token_ids, block_size, sha256)
4241 computed_blocks, num_tokens, _ = manager.get_computed_blocks(req_eagle)
4242 # Original match: 2 full blocks → Eagle removes 1 → 1 remaining
4243 assert len(computed_blocks.blocks[0]) == 1
4244 assert num_tokens == 1 * block_size
4245
4246 # Evict the first block in the request
4247 assert (
4248 manager.block_pool.get_cached_block(
4249 block_hash_first_block, kv_cache_group_ids=[0]
4250 )
4251 is not None
4252 )
4253 manager.block_pool.cached_block_hash_to_block._cache.pop(
4254 make_block_hash_with_group_id(block_hash_first_block, 0)
4255 )
4256
4257 # New request
4258 req_after_evict = make_request(
4259 "partial_eagle_after_evict", token_ids, block_size, sha256
4260 )
4261 computed_blocks, num_tokens, _ = manager.get_computed_blocks(req_after_evict)
4262 # Cache miss. The only hit prefix is [NULL_BLOCK, BLOCK_2] if eagle is
4263 # not considered. But after dropping the last matched block due to eagle,
4264 # there will be no matched prefix.
4265 assert len(computed_blocks.blocks[0]) == 0
4266 assert num_tokens == 0
4267
4268
4269 def test_eagle_swa_alignment_caches_extra_block():
4270 """Regression: SWA + EAGLE with `sliding_window <= alignment_tokens`.
4271
4272 When the cache-hit alignment (lcm of per-group block sizes) is larger than
4273 the SWA window, the SWA mask only kept the last block of each aligned
4274 segment. EAGLE/MTP lookup needs ``tail + 1`` contiguous cached blocks and
4275 that +1 block lives at the next segment's first position, which was left
4276 uncached. The fix caches that extra block when ``use_eagle=True``.
4277 """
4278 block_size = 8
4279 # Full group uses 4 * block_size, so lcm/alignment is 4 * block_size.
4280 # SWA group has sliding_window = block_size (i.e., tail = 1 block).
4281 # Without the fix, the second cached block needed for the EAGLE 2-block
4282 # match never exists -> EAGLE cache hit fails entirely.
4283 kv_cache_config = KVCacheConfig(
4284 num_blocks=100,
4285 kv_cache_tensors=[],
4286 kv_cache_groups=[
4287 KVCacheGroupSpec(
4288 ["full"],
4289 FullAttentionSpec(
4290 block_size=4 * block_size,
4291 num_kv_heads=1,
4292 head_size=1,
4293 dtype=torch.float16,
4294 ),
4295 ),
4296 KVCacheGroupSpec(
4297 ["swa_mtp"],
4298 SlidingWindowSpec(
4299 block_size=block_size,
4300 num_kv_heads=1,
4301 head_size=1,
4302 dtype=torch.float32,
4303 sliding_window=block_size,
4304 ),
4305 is_eagle_group=True,
4306 ),
4307 ],
4308 )
4309 manager = make_kv_cache_manager(
4310 kv_cache_config=kv_cache_config,
4311 max_model_len=8192,
4312 enable_caching=True,
4313 hash_block_size=block_size,
4314 use_eagle=True,
4315 )
4316
4317 # Prime the cache with a long prompt (16 swa blocks = 4 aligned segments).
4318 token_ids = [i for i in range(16) for _ in range(block_size)]
4319 req0 = make_request("0", token_ids, block_size, sha256)
4320 computed_blocks, _, _ = manager.get_computed_blocks(req0)
4321 blocks = manager.allocate_slots(
4322 req0,
4323 len(token_ids),
4324 len(computed_blocks.blocks[0]) * block_size,
4325 computed_blocks,
4326 )
4327 assert blocks is not None
4328 manager.free(req0)
4329
4330 # Second request with identical prompt should find an EAGLE cache hit.
4331 # Without the fix, ``num_computed_tokens`` is 0; with the fix, it lands at
4332 # an alignment boundary (multiple of 32 tokens, minus the EAGLE drop).
4333 req1 = make_request("1", token_ids, block_size, sha256)
4334 _, num_computed_tokens, _ = manager.get_computed_blocks(req1)
4335 assert num_computed_tokens > 0, (
4336 "EAGLE + SWA with sliding_window <= alignment failed to find any "
4337 "cache hit; the +1 block past each segment boundary must be cached."
4338 )
4339 # Each aligned segment contributes 4 * block_size = 32 tokens; EAGLE drops
4340 # the last block (block_size tokens) from the hit.
4341 assert num_computed_tokens % (4 * block_size) == 0
4342
4343
4344 def test_eagle_swa_boundary_caches_post_boundary_block():
4345 """EAGLE + SWA must cache the first block after an alignment boundary.
4346
4347 A 40-token computed prefix with 8-token SWA blocks and 32-token hybrid
4348 alignment needs SWA blocks 3 and 4 cached to reuse a 32-token prefix:
4349 block 3 is the segment tail, and block 4 is the EAGLE lookahead block
4350 that gets dropped after lookup.
4351 """
4352 block_size = 8
4353 kv_cache_config = KVCacheConfig(
4354 num_blocks=100,
4355 kv_cache_tensors=[],
4356 kv_cache_groups=[
4357 KVCacheGroupSpec(
4358 ["full"],
4359 FullAttentionSpec(
4360 block_size=4 * block_size,
4361 num_kv_heads=1,
4362 head_size=1,
4363 dtype=torch.float16,
4364 ),
4365 ),
4366 KVCacheGroupSpec(
4367 ["swa_mtp"],
4368 SlidingWindowSpec(
4369 block_size=block_size,
4370 num_kv_heads=1,
4371 head_size=1,
4372 dtype=torch.float32,
4373 sliding_window=block_size,
4374 ),
4375 is_eagle_group=True,
4376 ),
4377 ],
4378 )
4379 manager = make_kv_cache_manager(
4380 kv_cache_config=kv_cache_config,
4381 max_model_len=8192,
4382 enable_caching=True,
4383 hash_block_size=block_size,
4384 use_eagle=True,
4385 )
4386
4387 token_ids = [i for i in range(5) for _ in range(block_size)]
4388 req0 = make_request("0", token_ids, block_size, sha256)
4389 computed_blocks, _, _ = manager.get_computed_blocks(req0)
4390 blocks = manager.allocate_slots(
4391 req0,
4392 len(token_ids),
4393 len(computed_blocks.blocks[0]) * block_size,
4394 computed_blocks,
4395 )
4396 assert blocks is not None
4397
4398 pool = manager.block_pool
4399 assert pool.get_cached_block(req0.block_hashes[3], kv_cache_group_ids=[1])
4400 assert pool.get_cached_block(req0.block_hashes[4], kv_cache_group_ids=[1])
4401 manager.free(req0)
4402
4403 req1 = make_request("1", token_ids + [999], block_size, sha256)
4404 _, num_computed_tokens, _ = manager.get_computed_blocks(req1)
4405 assert num_computed_tokens == 4 * block_size
4406
4407
4408 def test_eagle_grouped_swa_siblings_use_same_cache_mask():
4409 """Grouped SWA siblings must cache the EAGLE lookahead block together."""
4410 block_size = 8
4411 swa_spec = SlidingWindowSpec(
4412 block_size=block_size,
4413 num_kv_heads=1,
4414 head_size=1,
4415 dtype=torch.float32,
4416 sliding_window=block_size,
4417 )
4418 kv_cache_config = KVCacheConfig(
4419 num_blocks=100,
4420 kv_cache_tensors=[],
4421 kv_cache_groups=[
4422 KVCacheGroupSpec(
4423 ["full"],
4424 FullAttentionSpec(
4425 block_size=4 * block_size,
4426 num_kv_heads=1,
4427 head_size=1,
4428 dtype=torch.float16,
4429 ),
4430 ),
4431 KVCacheGroupSpec(["swa_main"], swa_spec),
4432 KVCacheGroupSpec(["swa_mtp"], swa_spec, is_eagle_group=True),
4433 ],
4434 )
4435 manager = make_kv_cache_manager(
4436 kv_cache_config=kv_cache_config,
4437 max_model_len=8192,
4438 enable_caching=True,
4439 hash_block_size=block_size,
4440 use_eagle=True,
4441 )
4442
4443 token_ids = [i for i in range(9) for _ in range(block_size)]
4444 req0 = make_request("0", token_ids, block_size, sha256)
4445 computed_blocks, _, _ = manager.get_computed_blocks(req0)
4446 blocks = manager.allocate_slots(
4447 req0,
4448 len(token_ids),
4449 len(computed_blocks.blocks[0]) * block_size,
4450 computed_blocks,
4451 )
4452 assert blocks is not None
4453
4454 pool = manager.block_pool
4455 assert pool.get_cached_block(req0.block_hashes[4], kv_cache_group_ids=[1, 2])
4456 assert pool.get_cached_block(req0.block_hashes[8], kv_cache_group_ids=[1, 2])
4457 manager.free(req0)
4458
4459 req1 = make_request("1", token_ids + [999], block_size, sha256)
4460 _, num_computed_tokens, _ = manager.get_computed_blocks(req1)
4461 assert num_computed_tokens == 8 * block_size
4462
4463
4464 def test_different_block_size():
4465 block_size = 16
4466 # full attention and sliding window attention layers have the same page size:
4467 # (32 tokens/block * float16 token, vs. 16 tokens/block * float32 token)
4468 kv_cache_config = KVCacheConfig(
4469 num_blocks=100,
4470 kv_cache_tensors=[],
4471 kv_cache_groups=[
4472 KVCacheGroupSpec(
4473 ["layer1"],
4474 FullAttentionSpec(
4475 block_size=block_size * 2,
4476 num_kv_heads=1,
4477 head_size=1,
4478 dtype=torch.float16,
4479 ),
4480 ),
4481 KVCacheGroupSpec(
4482 ["layer2"],
4483 SlidingWindowSpec(
4484 block_size=block_size,
4485 num_kv_heads=1,
4486 head_size=1,
4487 dtype=torch.float32,
4488 sliding_window=2 * block_size,
4489 ),
4490 ),
4491 ],
4492 )
4493 manager = make_kv_cache_manager(
4494 kv_cache_config=kv_cache_config,
4495 max_model_len=8192,
4496 enable_caching=True,
4497 hash_block_size=block_size,
4498 )
4499
4500 # 10 blocks of 16 tokens each. Token ids are not strictly aligned for each block.
4501 common_token_ids = [i for i in range(10) for _ in range(block_size)]
4502
4503 req0 = make_request("0", common_token_ids, block_size, sha256)
4504 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
4505 assert not computed_blocks.blocks[0]
4506 assert not computed_blocks.blocks[1]
4507 assert num_computed_tokens == 0
4508 blocks = manager.allocate_slots(
4509 req0, 7 * block_size, len(computed_blocks.blocks[0]) * 16, computed_blocks
4510 )
4511 assert blocks.get_block_ids() == ([1, 2, 3, 4], [5, 6, 7, 8, 9, 10, 11])
4512 req1 = make_request("1", common_token_ids[: 7 * block_size + 1], block_size, sha256)
4513 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
4514 assert len(computed_blocks.blocks[0]) == 3
4515 assert len(computed_blocks.blocks[1]) == 6
4516 assert num_computed_tokens == 6 * 16
4517
4518 req2 = make_request("2", common_token_ids[: 6 * block_size + 1], block_size, sha256)
4519 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req2)
4520 assert len(computed_blocks.blocks[0]) == 3
4521 assert len(computed_blocks.blocks[1]) == 6
4522 assert num_computed_tokens == 6 * 16
4523
4524 # Evict some blocks to make sliding window cache hit length 5*16
4525 # But should return 4 * 16 because full attention cache hit length must be
4526 # a multiple of 32
4527 manager.block_pool.cached_block_hash_to_block.pop(
4528 make_block_hash_with_group_id(req1.block_hashes[6], 1), 11
4529 )
4530 manager.block_pool.cached_block_hash_to_block.pop(
4531 make_block_hash_with_group_id(req1.block_hashes[5], 1), 10
4532 )
4533 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
4534 assert len(computed_blocks.blocks[0]) == 2
4535 assert len(computed_blocks.blocks[1]) == 4
4536 assert num_computed_tokens == 4 * 16
4537
4538
4539 def test_hybrid_cache_blocks_swa_tail_window_only():
4540 """Within each lcm-aligned segment, SWA's ``find_longest_cache_hit`` only
4541 returns the trailing ``ceil((sliding_window - 1) / block_size)`` blocks
4542 (its right-to-left scan stops once a contiguous match is found). Blocks
4543 earlier in the segment can never serve a hit, so
4544 ``HybridKVCacheCoordinator.cache_blocks`` should skip them rather than
4545 polluting the prefix-cache hash map."""
4546 block_size = 8
4547 # Full attn block_size=32, SWA block_size=8, sw=8 -> lcm=32.
4548 # tail = ceil(7/8) = 1; per_segment = 32/8 = 4.
4549 # Per-segment template = [F, F, F, T]; only the last SWA block in each
4550 # 32-token segment ends up in the prefix-cache hash map.
4551 kv_cache_config = KVCacheConfig(
4552 num_blocks=100,
4553 kv_cache_tensors=[],
4554 kv_cache_groups=[
4555 KVCacheGroupSpec(
4556 ["layer1"],
4557 FullAttentionSpec(
4558 block_size=4 * block_size,
4559 num_kv_heads=1,
4560 head_size=1,
4561 dtype=torch.float16,
4562 ),
4563 ),
4564 KVCacheGroupSpec(
4565 ["layer2"],
4566 SlidingWindowSpec(
4567 block_size=block_size,
4568 num_kv_heads=1,
4569 head_size=1,
4570 dtype=torch.float32,
4571 sliding_window=block_size,
4572 ),
4573 ),
4574 ],
4575 )
4576 manager = make_kv_cache_manager(
4577 kv_cache_config=kv_cache_config,
4578 max_model_len=8192,
4579 enable_caching=True,
4580 hash_block_size=block_size,
4581 )
4582
4583 # 8 hash-blocks of 8 tokens (64 tokens, two lcm-aligned segments).
4584 token_ids = [i for i in range(8) for _ in range(block_size)]
4585 req = make_request("0", token_ids, block_size, sha256)
4586 computed_blocks, _, _ = manager.get_computed_blocks(req)
4587 blocks = manager.allocate_slots(
4588 req,
4589 8 * block_size,
4590 len(computed_blocks.blocks[0]) * block_size,
4591 computed_blocks,
4592 )
4593 assert blocks is not None
4594 assert len(req.block_hashes) == 8
4595
4596 pool = manager.block_pool
4597 # SWA group_id=1: only hash 3 and hash 7 (the last block of each
4598 # 32-token segment) should be cached. Hashes 0,1,2,4,5,6 cannot serve
4599 # a hit at any lcm-aligned length, so they must NOT be cached.
4600 expected_cached = {3, 7}
4601 for i in range(8):
4602 cached = pool.get_cached_block(req.block_hashes[i], kv_cache_group_ids=[1])
4603 if i in expected_cached:
4604 assert cached is not None, f"SWA hash {i} should be cached"
4605 else:
4606 assert cached is None, (
4607 f"SWA hash {i} cannot serve any lcm-aligned hit; should not be cached"
4608 )
4609
4610
4611 def test_hybrid_cache_blocks_clamped_to_lcm():
4612 """HybridKVCacheCoordinator.cache_blocks() clamps to scheduler_block_size.
4613 Chunks past the last lcm-aligned boundary can never participate in a
4614 cache hit (find_longest_cache_hit always returns lcm-aligned hits), so
4615 caching them only pollutes the prefix-cache hash map and keeps blocks
4616 on the LRU list that could otherwise return to the free pool."""
4617 block_size = 16
4618 # Full attn block_size=32, SWA block_size=16 -> lcm=32.
4619 kv_cache_config = KVCacheConfig(
4620 num_blocks=100,
4621 kv_cache_tensors=[],
4622 kv_cache_groups=[
4623 KVCacheGroupSpec(
4624 ["layer1"],
4625 FullAttentionSpec(
4626 block_size=block_size * 2,
4627 num_kv_heads=1,
4628 head_size=1,
4629 dtype=torch.float16,
4630 ),
4631 ),
4632 KVCacheGroupSpec(
4633 ["layer2"],
4634 SlidingWindowSpec(
4635 block_size=block_size,
4636 num_kv_heads=1,
4637 head_size=1,
4638 dtype=torch.float32,
4639 sliding_window=2 * block_size,
4640 ),
4641 ),
4642 ],
4643 )
4644 manager = make_kv_cache_manager(
4645 kv_cache_config=kv_cache_config,
4646 max_model_len=8192,
4647 enable_caching=True,
4648 hash_block_size=block_size,
4649 )
4650
4651 # 7 hash-blocks of 16 tokens (112 tokens). With lcm=32 the clamp truncates
4652 # to 96 tokens — SWA caches 6 hashes, full-attn caches 3.
4653 token_ids = [i for i in range(7) for _ in range(block_size)]
4654 req = make_request("0", token_ids, block_size, sha256)
4655 computed_blocks, _, _ = manager.get_computed_blocks(req)
4656 blocks = manager.allocate_slots(
4657 req,
4658 7 * block_size,
4659 len(computed_blocks.blocks[0]) * block_size,
4660 computed_blocks,
4661 )
4662 assert blocks is not None
4663 assert len(req.block_hashes) == 7
4664
4665 pool = manager.block_pool
4666 # SWA group_id=1: hashes 0..5 cached (6 blocks * 16 tokens = 96), hash 6
4667 # spans tokens [96, 112) past the lcm boundary and must NOT be cached.
4668 for i in range(6):
4669 assert (
4670 pool.get_cached_block(req.block_hashes[i], kv_cache_group_ids=[1])
4671 is not None
4672 ), f"SWA hash {i} should be cached"
4673 assert pool.get_cached_block(req.block_hashes[6], kv_cache_group_ids=[1]) is None, (
4674 "SWA hash 6 spans tokens past the lcm boundary; should not be cached"
4675 )
4676
4677
4678 def test_hybrid_local_kv_retention_interval_aligns_in_manager():
4679 """Verify fixed intervals retain sparse tails plus the latest replay tail."""
4680 block_size = 8
4681 kv_cache_config = KVCacheConfig(
4682 num_blocks=100,
4683 kv_cache_tensors=[],
4684 kv_cache_groups=[
4685 KVCacheGroupSpec(
4686 ["layer1"],
4687 FullAttentionSpec(
4688 block_size=4 * block_size,
4689 num_kv_heads=1,
4690 head_size=1,
4691 dtype=torch.float16,
4692 ),
4693 ),
4694 KVCacheGroupSpec(
4695 ["layer2"],
4696 SlidingWindowSpec(
4697 block_size=block_size,
4698 num_kv_heads=1,
4699 head_size=1,
4700 dtype=torch.float32,
4701 sliding_window=block_size,
4702 ),
4703 ),
4704 ],
4705 )
4706 manager = make_kv_cache_manager(
4707 kv_cache_config=kv_cache_config,
4708 max_model_len=8192,
4709 enable_caching=True,
4710 hash_block_size=block_size,
4711 retention_interval=64,
4712 )
4713
4714 # The SWA manager uses the configured 64-token interval (a multiple of the
4715 # 32-token lcm_block_size) as its retention segment. For this 128-token
4716 # prompt, the retained SWA tails are the 64-token interval boundary, the
4717 # 96-token replay boundary, and the 128-token interval boundary.
4718 token_ids = [i for i in range(16) for _ in range(block_size)]
4719 req = make_request("0", token_ids, block_size, sha256)
4720 computed_blocks, _, _ = manager.get_computed_blocks(req)
4721 blocks = manager.allocate_slots(
4722 req,
4723 len(token_ids),
4724 len(computed_blocks.blocks[0]) * block_size,
4725 computed_blocks,
4726 )
4727 assert blocks is not None
4728
4729 pool = manager.block_pool
4730 expected_swa_cached = {7, 11, 15}
4731 for i in range(16):
4732 cached = pool.get_cached_block(req.block_hashes[i], kv_cache_group_ids=[1])
4733 if i in expected_swa_cached:
4734 assert cached is not None, f"SWA hash {i} should be cached"
4735 else:
4736 assert cached is None, f"SWA hash {i} should not be cached"
4737
4738
4739 @pytest.mark.parametrize(
4740 "interval, expected_match",
4741 [
4742 # scheduler_block_size is 32 (= lcm(4*8, 8)); 33 is not a multiple of it.
4743 (33, "multiple of scheduler_block_size"),
4744 # A negative multiple (-32 % 32 == 0) must still be rejected explicitly,
4745 # otherwise it would pass the modulo check and silently degrade to dense.
4746 (-32, "non-negative"),
4747 ],
4748 )
4749 def test_hybrid_local_kv_retention_interval_rejects_invalid(interval, expected_match):
4750 """A retention interval that is negative or not a multiple of
4751 scheduler_block_size errors out at construction time."""
4752 block_size = 8
4753 kv_cache_config = KVCacheConfig(
4754 num_blocks=100,
4755 kv_cache_tensors=[],
4756 kv_cache_groups=[
4757 KVCacheGroupSpec(
4758 ["layer1"],
4759 FullAttentionSpec(
4760 block_size=4 * block_size,
4761 num_kv_heads=1,
4762 head_size=1,
4763 dtype=torch.float16,
4764 ),
4765 ),
4766 KVCacheGroupSpec(
4767 ["layer2"],
4768 SlidingWindowSpec(
4769 block_size=block_size,
4770 num_kv_heads=1,
4771 head_size=1,
4772 dtype=torch.float32,
4773 sliding_window=block_size,
4774 ),
4775 ),
4776 ],
4777 )
4778 with pytest.raises(ValueError, match=expected_match):
4779 make_kv_cache_manager(
4780 kv_cache_config=kv_cache_config,
4781 max_model_len=8192,
4782 enable_caching=True,
4783 hash_block_size=block_size,
4784 retention_interval=interval,
4785 )
4786
4787
4788 def test_zero_retention_is_ignored_for_full_attention():
4789 kv_cache_config = make_kv_cache_config(block_size=16, num_blocks=10)
4790 manager = make_kv_cache_manager(
4791 kv_cache_config,
4792 max_model_len=8192,
4793 enable_caching=True,
4794 hash_block_size=16,
4795 retention_interval=0,
4796 )
4797 assert manager.coordinator.retention_interval == 0
4798
4799
4800 def test_positive_retention_rejects_full_attention():
4801 kv_cache_config = make_kv_cache_config(block_size=16, num_blocks=10)
4802 with pytest.raises(ValueError, match="no sliding-window or Mamba"):
4803 make_kv_cache_manager(
4804 kv_cache_config,
4805 max_model_len=8192,
4806 enable_caching=True,
4807 hash_block_size=16,
4808 retention_interval=16,
4809 )
4810
4811
4812 def test_hybrid_local_kv_retention_interval_survives_recycling():
4813 """Verify retained local checkpoints are reused after block recycling."""
4814 hash_block_size = 4
4815 kv_cache_config = KVCacheConfig(
4816 num_blocks=800,
4817 kv_cache_tensors=[],
4818 kv_cache_groups=[
4819 KVCacheGroupSpec(
4820 ["full"],
4821 MLAAttentionSpec(
4822 block_size=64 * hash_block_size,
4823 num_kv_heads=1,
4824 head_size=1,
4825 dtype=torch.uint8,
4826 tokens_per_state=4,
4827 ),
4828 ),
4829 KVCacheGroupSpec(
4830 ["swa"],
4831 SlidingWindowSpec(
4832 block_size=16 * hash_block_size,
4833 num_kv_heads=1,
4834 head_size=1,
4835 dtype=torch.float32,
4836 sliding_window=512,
4837 ),
4838 ),
4839 KVCacheGroupSpec(
4840 ["c128"],
4841 SlidingWindowSpec(
4842 block_size=2 * hash_block_size,
4843 num_kv_heads=1,
4844 head_size=1,
4845 dtype=torch.float32,
4846 sliding_window=128,
4847 ),
4848 ),
4849 KVCacheGroupSpec(
4850 ["c4"],
4851 SlidingWindowSpec(
4852 block_size=hash_block_size,
4853 num_kv_heads=1,
4854 head_size=1,
4855 dtype=torch.float32,
4856 sliding_window=8,
4857 ),
4858 ),
4859 ],
4860 )
4861 manager = make_kv_cache_manager(
4862 kv_cache_config=kv_cache_config,
4863 max_model_len=4096,
4864 enable_caching=True,
4865 hash_block_size=hash_block_size,
4866 retention_interval=1024,
4867 )
4868
4869 def fill_request(request_id: str, token_offset: int) -> list[int]:
4870 token_ids = [
4871 token_offset + i for i in range(1024) for _ in range(hash_block_size)
4872 ]
4873 fill_req = make_request(request_id, token_ids, hash_block_size, sha256)
4874 while fill_req.num_computed_tokens < len(token_ids):
4875 num_new_tokens = min(512, len(token_ids) - fill_req.num_computed_tokens)
4876 blocks = manager.allocate_slots(fill_req, num_new_tokens)
4877 assert blocks is not None
4878 fill_req.num_computed_tokens += num_new_tokens
4879 manager.free(fill_req)
4880 return token_ids
4881
4882 token_ids = fill_request("fill_0", 0)
4883 replay_req = make_request("replay", token_ids[:1800], hash_block_size, sha256)
4884 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(replay_req)
4885 assert num_computed_tokens == 1024
4886 assert [len(blocks) for blocks in computed_blocks.blocks] == [4, 16, 128, 256]
4887
4888 fill_request("fill_1", 100_000)
4889 replay_req = make_request("replay_again", token_ids[:1800], hash_block_size, sha256)
4890 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(replay_req)
4891 assert num_computed_tokens == 1024
4892 assert [len(blocks) for blocks in computed_blocks.blocks] == [4, 16, 128, 256]
4893
4894
4895 def test_hybrid_local_kv_retention_latest_only_reuses_replay_boundary():
4896 """Verify latest-only retention reuses only the replayable prompt boundary."""
4897 block_size = 8
4898 kv_cache_config = KVCacheConfig(
4899 num_blocks=100,
4900 kv_cache_tensors=[],
4901 kv_cache_groups=[
4902 KVCacheGroupSpec(
4903 ["layer1"],
4904 FullAttentionSpec(
4905 block_size=4 * block_size,
4906 num_kv_heads=1,
4907 head_size=1,
4908 dtype=torch.float16,
4909 ),
4910 ),
4911 KVCacheGroupSpec(
4912 ["layer2"],
4913 SlidingWindowSpec(
4914 block_size=block_size,
4915 num_kv_heads=1,
4916 head_size=1,
4917 dtype=torch.float32,
4918 sliding_window=block_size,
4919 ),
4920 ),
4921 ],
4922 )
4923 manager = make_kv_cache_manager(
4924 kv_cache_config=kv_cache_config,
4925 max_model_len=8192,
4926 enable_caching=True,
4927 hash_block_size=block_size,
4928 retention_interval=0,
4929 )
4930
4931 token_ids = [i for i in range(16) for _ in range(block_size)]
4932 req0 = make_request("0", token_ids, block_size, sha256)
4933 computed_blocks, _, _ = manager.get_computed_blocks(req0)
4934 blocks = manager.allocate_slots(
4935 req0,
4936 len(token_ids),
4937 len(computed_blocks.blocks[0]) * block_size,
4938 computed_blocks,
4939 )
4940 assert blocks is not None
4941
4942 pool = manager.block_pool
4943 expected_swa_cached = {11}
4944 for i in range(16):
4945 cached = pool.get_cached_block(req0.block_hashes[i], kv_cache_group_ids=[1])
4946 if i in expected_swa_cached:
4947 assert cached is not None, f"SWA hash {i} should be cached"
4948 else:
4949 assert cached is None, f"SWA hash {i} should not be cached"
4950
4951 manager.free(req0)
4952 retained_swa_block = pool.get_cached_block(req0.block_hashes[11], [1])
4953 assert retained_swa_block is not None
4954 assert retained_swa_block[0].ref_cnt == 0
4955
4956 req1 = make_request("1", token_ids, block_size, sha256)
4957 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
4958 # Full prompt hits intentionally recompute the final block for logits, so
4959 # the longest usable hit is the previous LCM boundary: 96 tokens.
4960 assert num_computed_tokens == 12 * block_size
4961 assert len(computed_blocks.blocks[1]) == 12
4962
4963 shorter_req = make_request("2", token_ids[: 12 * block_size], block_size, sha256)
4964 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(shorter_req)
4965 assert num_computed_tokens == 0
4966 assert len(computed_blocks.blocks[1]) == 0
4967
4968
4969 def test_hybrid_local_kv_retention_mtp_reuses_latest_boundary():
4970 """Verify MTP/EAGLE SWA retention keeps the extra proof block.
4971
4972 EAGLE/MTP lookup matches one additional local block after the returned
4973 prefix and then drops it. Sparse retention must therefore cache the normal
4974 local tail at the latest replay boundary plus one extra SWA block.
4975 """
4976 block_size = 8
4977 kv_cache_config = KVCacheConfig(
4978 num_blocks=100,
4979 kv_cache_tensors=[],
4980 kv_cache_groups=[
4981 KVCacheGroupSpec(
4982 ["full"],
4983 FullAttentionSpec(
4984 block_size=4 * block_size,
4985 num_kv_heads=1,
4986 head_size=1,
4987 dtype=torch.float16,
4988 ),
4989 ),
4990 KVCacheGroupSpec(
4991 ["swa_mtp"],
4992 SlidingWindowSpec(
4993 block_size=block_size,
4994 num_kv_heads=1,
4995 head_size=1,
4996 dtype=torch.float32,
4997 sliding_window=block_size,
4998 ),
4999 is_eagle_group=True,
5000 ),
Showing first 5,000 of 6,282 lines. View raw