[Feature][SimpleCPU] Load fine-grained hybrid prefix hits (#54736)

Signed-off-by: Yichao Zhu <Yichao.Zhu@amd.com>

YukioZzz committed Sep 11, 2026 at 09:36 UTC 07b75534651a0dd584c6f3be75d8c62fb8426397
6 files changed +982 -110
tests/v1/simple_kv_offload/test_kv_events.py
+9 -40
@@ -714,17 +714,8 @@ def test_disk_mode_block_removed_relabeled_storage() -> None:
714 assert ev.medium == MEDIUM_STORAGE
715
716
717 -def test_mamba_dcp_metadata_guard() -> None:
718 - """Mamba+DCP: token_ids guard emits [] when capture and emission sizes differ.
719 -
720 - The metadata capture at _prepare_eager_store_specs uses
721 - g_block_size = spec.block_size * cp_world_size = 32 * 2 = 64 to slice
722 - tokens. The event emission uses block_size = spec.block_size * 1 = 32
723 - for Mamba (SSM state is not CP-sharded). The guard in
724 - _process_store_completion detects len(token_ids) != block_size and emits
725 - token_ids=[] instead of misleading data. When #49962 fixes the capture
726 - path, the guard stops triggering and correct token_ids flow through.
727 - """
717 +def test_mamba_align_skips_positional_event_metadata() -> None:
718 + """Mamba align emits events only from explicit boundary handoffs."""
719 vbs = BLOCK_SIZE * 2
720 kv_cfg = _make_mixed_kv_cache_config(
721 num_blocks=16, with_mamba=True, mamba_block_size=32
@@ -739,8 +730,6 @@ def test_mamba_dcp_metadata_guard() -> None:
730 sched = fix.scheduler
731 gpu_pool = fix.gpu_block_pool
732
742 - # Mamba g_block_size = 32 * 2 = 64, so 2 Mamba blocks need 128 computed
743 - # tokens to be "ready"; FA g_block_size = 16 * 2 = 32 needs only 64.
733 req = _make_cp_request(num_blocks=4, virtual_block_size=vbs)
734 fa_blocks = _allocate_cp_gpu_blocks(gpu_pool, req, 2, vbs, group_id=0)
735 mamba_blocks = _allocate_cp_gpu_blocks(gpu_pool, req, 2, vbs, group_id=1)
@@ -759,32 +748,12 @@ def test_mamba_dcp_metadata_guard() -> None:
748
749 events = list(sched.take_events())
750 stored = [e for e in events if isinstance(e, BlockStored)]
762 - assert len(stored) == 4, (
763 - f"expected 4 BlockStored (2 FA + 2 Mamba), got {len(stored)}"
764 - )
765 -
766 - by_group: dict[int, list[BlockStored]] = {}
767 - for ev in stored:
768 - by_group.setdefault(ev.group_idx, []).append(ev)
769 -
770 - # FA group: capture size (64) == emission size (64) → token_ids present.
771 - fa_events = by_group[0]
772 - assert len(fa_events) == 2
773 - for ev in fa_events:
751 + assert len(stored) == 2
752 + assert {ev.group_idx for ev in stored} == {0}
753 + for idx, ev in enumerate(stored):
754 assert ev.block_size == vbs
775 - assert len(ev.token_ids) == vbs, (
776 - f"FA token_ids should be {vbs}, got {len(ev.token_ids)}"
777 - )
778 -
779 - # Mamba group: capture size (64) != emission size (32) → guard emits [].
780 - mamba_events = by_group[1]
781 - assert len(mamba_events) == 2
782 - for ev in mamba_events:
783 - assert ev.block_size == 32
784 - assert ev.token_ids == [], (
785 - f"Mamba token_ids should be [] (guard), got {ev.token_ids}"
786 - )
787 - assert ev.parent_block_hash is None, (
788 - "Mamba parent_block_hash should be None (guard)"
755 + assert ev.token_ids == req.prompt_token_ids[idx * vbs : (idx + 1) * vbs]
756 + expected_hash = maybe_convert_block_hash(
757 + get_block_hash(make_block_hash_with_group_id(req.block_hashes[idx], 0))
758 )
790 - assert ev.extra_keys is None, "Mamba extra_keys should be None (guard)"
759 + assert ev.block_hashes == [expected_hash]
tests/v1/simple_kv_offload/test_scheduler.py
+672
@@ -4,6 +4,7 @@
4
5 from __future__ import annotations
6
7 +import logging
8 from dataclasses import dataclass
9
10 import pytest
@@ -18,6 +19,7 @@ from vllm.config import (
19 SchedulerConfig,
20 VllmConfig,
21 )
22 +from vllm.config.kv_events import KVEventsConfig
23 from vllm.distributed.kv_transfer.kv_connector.v1.simple_cpu_offload_connector import (
24 SimpleCPUOffloadConnector,
25 )
@@ -31,6 +33,7 @@ from vllm.v1.core.kv_cache_utils import (
33 )
34 from vllm.v1.core.sched.output import (
35 CachedRequestData,
36 + KVConnectorBlockState,
37 NewRequestData,
38 SchedulerOutput,
39 )
@@ -42,6 +45,7 @@ from vllm.v1.kv_cache_interface import (
45 KVCacheConfig,
46 KVCacheGroupSpec,
47 KVCacheTensor,
48 + MambaSpec,
49 SlidingWindowSpec,
50 )
51 from vllm.v1.outputs import KVConnectorOutput
@@ -2128,3 +2132,671 @@ def test_cp_lazy_target_blocks_scaling(cp_world_size: int) -> None:
2132 f"cp_world_size={cp_world_size}: target_cp={target_cp} should be "
2133 f"less than target_base={target_base}"
2134 )
2135 +
2136 +
2137 +def _make_hybrid_attention_mamba_scheduler(
2138 + *,
2139 + num_cpu_blocks: int = 8,
2140 + num_gpu_blocks: int = 16,
2141 + attention_block_size: int = BLOCK_SIZE,
2142 + block_size: int = 4 * BLOCK_SIZE,
2143 + scheduler_block_size: int | None = None,
2144 + hash_block_size: int | None = None,
2145 + dcp_world_size: int = 4,
2146 + lazy: bool = False,
2147 + mamba_cache_mode: str = "align",
2148 + enable_kv_cache_events: bool = False,
2149 +) -> SchedulerFixture:
2150 + """Build a scheduler for one attention group plus one Mamba group."""
2151 + scheduler_block_size = scheduler_block_size or block_size
2152 + hash_block_size = hash_block_size or block_size
2153 + attention_spec = FullAttentionSpec(
2154 + block_size=attention_block_size,
2155 + num_kv_heads=NUM_KV_HEADS,
2156 + head_size=HEAD_SIZE,
2157 + dtype=DTYPE,
2158 + )
2159 + mamba_spec = MambaSpec(
2160 + block_size=block_size,
2161 + shapes=((1, 1),),
2162 + dtypes=(torch.float32,),
2163 + mamba_cache_mode=mamba_cache_mode,
2164 + )
2165 + groups = [
2166 + KVCacheGroupSpec(["attention"], attention_spec),
2167 + KVCacheGroupSpec(["mamba"], mamba_spec),
2168 + ]
2169 + tensors = [
2170 + KVCacheTensor(
2171 + size=spec.page_size_bytes * num_gpu_blocks,
2172 + layers=group.layer_names,
2173 + layer_stride=spec.page_size_bytes * num_gpu_blocks,
2174 + block_stride=spec.page_size_bytes,
2175 + )
2176 + for group, spec in zip(groups, (attention_spec, mamba_spec))
2177 + ]
2178 + kv_cache_config = KVCacheConfig(
2179 + num_blocks=num_gpu_blocks,
2180 + kv_cache_tensors=tensors,
2181 + kv_cache_groups=groups,
2182 + )
2183 + vllm_config = _make_cp_vllm_config(dcp_world_size=dcp_world_size)
2184 + vllm_config.cache_config.prefix_cache_retention_interval = 0
2185 + vllm_config.cache_config.mamba_cache_mode = mamba_cache_mode
2186 + if enable_kv_cache_events:
2187 + vllm_config.kv_events_config = KVEventsConfig(enable_kv_cache_events=True)
2188 + # _derive_cpu_config() scales the requested capacity against
2189 + # kv_cache_tensors[0].size, so express it as a multiple of that tensor's
2190 + # per-block size to get exactly num_cpu_blocks.
2191 + cpu_capacity_bytes = tensors[0].size // num_gpu_blocks * num_cpu_blocks
2192 + sched = SimpleCPUOffloadScheduler(
2193 + vllm_config=vllm_config,
2194 + kv_cache_config=kv_cache_config,
2195 + cpu_capacity_bytes=cpu_capacity_bytes,
2196 + scheduler_block_size=scheduler_block_size,
2197 + hash_block_size=hash_block_size,
2198 + lazy_offload=lazy,
2199 + )
2200 + gpu_block_pool = BlockPool(
2201 + num_gpu_blocks=num_gpu_blocks,
2202 + enable_caching=True,
2203 + hash_block_size=hash_block_size,
2204 + )
2205 + sched.bind_gpu_block_pool(gpu_block_pool)
2206 + return SchedulerFixture(
2207 + scheduler=sched,
2208 + gpu_block_pool=gpu_block_pool,
2209 + vllm_config=vllm_config,
2210 + kv_cache_config=kv_cache_config,
2211 + )
2212 +
2213 +
2214 +def test_hybrid_store_uses_resolved_group_block_sizes() -> None:
2215 + """Replicated groups must not be scaled by the DCP world size.
2216 +
2217 + ``dcp_world_size_for_kv_cache_spec`` gives full attention the process DCP
2218 + size and every other spec 1. Reconstructing a group's geometry as
2219 + ``spec.block_size * cp_world_size`` over-scales the replicated groups, so
2220 + the eager store scan believes far fewer of their blocks are ready and
2221 + silently offloads only a fraction of them.
2222 + """
2223 + attention_block_size = BLOCK_SIZE
2224 + mamba_block_size = 4 * BLOCK_SIZE
2225 + dcp_world_size = 2
2226 + fix = _make_hybrid_attention_mamba_scheduler(
2227 + num_cpu_blocks=32,
2228 + num_gpu_blocks=32,
2229 + attention_block_size=attention_block_size,
2230 + block_size=mamba_block_size,
2231 + # Every prefix-cacheable group's resolved block size must be a multiple
2232 + # of the hash block, so it cannot exceed the smallest of them.
2233 + hash_block_size=BLOCK_SIZE,
2234 + dcp_world_size=dcp_world_size,
2235 + mamba_cache_mode="all",
2236 + )
2237 + sched = fix.scheduler
2238 + gpu_pool = fix.gpu_block_pool
2239 + attention_size, mamba_size = sched.group_block_sizes
2240 + assert attention_size == attention_block_size * dcp_world_size
2241 + assert mamba_size == mamba_block_size
2242 +
2243 + confirmed = 2 * sched.block_size
2244 + req = _make_cp_request(num_blocks=8, virtual_block_size=BLOCK_SIZE)
2245 + attention_blocks = _allocate_cp_gpu_blocks(
2246 + gpu_pool, req, confirmed // attention_size, attention_size, group_id=0
2247 + )
2248 + mamba_blocks = _allocate_cp_gpu_blocks(
2249 + gpu_pool, req, confirmed // mamba_size, mamba_size, group_id=1
2250 + )
2251 + kv_blocks = KVCacheBlocks(blocks=(attention_blocks, mamba_blocks))
2252 + sched.update_state_after_alloc(req, kv_blocks, num_external_tokens=0)
2253 + sched.build_connector_meta(
2254 + make_scheduler_output(
2255 + {req.request_id: confirmed},
2256 + new_reqs={req.request_id: kv_blocks.get_block_ids()},
2257 + )
2258 + )
2259 + req.num_computed_tokens = confirmed
2260 + meta = sched.build_connector_meta(
2261 + make_scheduler_output(
2262 + {req.request_id: 1},
2263 + cached_req_new_blocks={req.request_id: None},
2264 + )
2265 + )
2266 +
2267 + # Both groups are positionally stable here, so every confirmed block of
2268 + # both must be offloaded. Over-scaling the replicated group halves its
2269 + # count.
2270 + assert sched._reqs_to_store[req.request_id].num_stored_blocks == [
2271 + confirmed // attention_size,
2272 + confirmed // mamba_size,
2273 + ]
2274 + assert set(meta.store_gpu_blocks) == {
2275 + *(b.block_id for b in attention_blocks),
2276 + *(b.block_id for b in mamba_blocks),
2277 + }
2278 +
2279 +
2280 +def test_dcp_mixed_cache_loads_fine_grained_external_hit() -> None:
2281 + """Map a fine-grained external suffix after a nonzero local prefix."""
2282 + hash_block_size = BLOCK_SIZE
2283 + mamba_block_size = 4 * BLOCK_SIZE
2284 + local_tokens = 4 * BLOCK_SIZE
2285 + # Span two Mamba blocks and end partially in the second one. This exercises
2286 + # both cdiv(external_tokens, mamba_block_size) > 1 and the partial tail.
2287 + external_tokens = 6 * BLOCK_SIZE
2288 + total_cached_tokens = local_tokens + external_tokens
2289 + fix = _make_hybrid_attention_mamba_scheduler(
2290 + num_cpu_blocks=32,
2291 + num_gpu_blocks=32,
2292 + block_size=mamba_block_size,
2293 + hash_block_size=hash_block_size,
2294 + dcp_world_size=2,
2295 + )
2296 + sched = fix.scheduler
2297 + gpu_pool = fix.gpu_block_pool
2298 +
2299 + assert external_tokens % mamba_block_size != 0
2300 + assert sched.cpu_coordinator.enable_partial_hash_hits
2301 +
2302 + producer = _make_cp_request(num_blocks=10, virtual_block_size=hash_block_size)
2303 + attention_blocks = _allocate_cp_gpu_blocks(
2304 + gpu_pool,
2305 + producer,
2306 + num_blocks=5,
2307 + virtual_block_size=2 * BLOCK_SIZE,
2308 + group_id=0,
2309 + )
2310 + mamba_blocks = gpu_pool.get_new_blocks(3)
2311 + gpu_pool.cache_full_blocks(
2312 + request=producer,
2313 + blocks=mamba_blocks,
2314 + num_cached_blocks=0,
2315 + num_full_blocks=2,
2316 + block_size=mamba_block_size,
2317 + kv_cache_group_id=1,
2318 + )
2319 + gpu_pool.cache_partial_block(
2320 + request=producer,
2321 + block=mamba_blocks[2],
2322 + num_tokens=total_cached_tokens,
2323 + kv_cache_group_id=1,
2324 + block_size=mamba_block_size,
2325 + )
2326 + producer_blocks = KVCacheBlocks(blocks=(attention_blocks, mamba_blocks))
2327 + sched.update_state_after_alloc(producer, producer_blocks, num_external_tokens=0)
2328 +
2329 + # Positional scanning stores append-only FA blocks. Mamba align blocks use
2330 + # exact handoffs for every retained boundary, including the partial tail.
2331 + store_output = make_scheduler_output(
2332 + {producer.request_id: total_cached_tokens},
2333 + new_reqs={producer.request_id: producer_blocks.get_block_ids()},
2334 + )
2335 + store_output.kv_connector_block_state = KVConnectorBlockState(
2336 + req_ids=set(),
2337 + resolve_block_ids=lambda _: (),
2338 + boundary_state_offloads={
2339 + producer.request_id: [
2340 + (1, mamba_blocks[0].block_id, mamba_block_size),
2341 + (1, mamba_blocks[1].block_id, 2 * mamba_block_size),
2342 + (1, mamba_blocks[2].block_id, total_cached_tokens),
2343 + ]
2344 + },
2345 + )
2346 + store_meta = sched.build_connector_meta(store_output)
2347 + # The exact handoffs land in the step they arrive; the append-only FA scan
2348 + # picks its blocks up once the scheduler has committed the tokens.
2349 + assert set(store_meta.store_gpu_blocks) == {
2350 + block.block_id for block in mamba_blocks
2351 + }
2352 + simulate_store_completion(sched, store_meta.store_event)
2353 + producer.num_computed_tokens = total_cached_tokens
2354 + fa_meta = sched.build_connector_meta(
2355 + make_scheduler_output(
2356 + {producer.request_id: 1},
2357 + cached_req_new_blocks={producer.request_id: None},
2358 + )
2359 + )
2360 + assert set(fa_meta.store_gpu_blocks) == {
2361 + block.block_id for block in attention_blocks
2362 + }
2363 + simulate_store_completion(sched, fa_meta.store_event)
2364 +
2365 + consumer = Request(
2366 + request_id="req-dcp-mixed-fine-load",
2367 + prompt_token_ids=producer.prompt_token_ids,
2368 + sampling_params=producer.sampling_params,
2369 + pooling_params=None,
2370 + mm_features=None,
2371 + block_hasher=producer._block_hasher,
2372 + )
2373 + hit_tokens, is_async = sched.get_num_new_matched_tokens(
2374 + consumer, num_computed_tokens=local_tokens
2375 + )
2376 + assert hit_tokens == external_tokens
2377 + assert is_async is True
2378 + pending_blocks, pending_tokens, _ = sched._pending_cpu_hits[consumer.request_id]
2379 + assert pending_tokens == external_tokens
2380 + assert len(pending_blocks[1]) == 2
2381 + assert pending_blocks[1][0].is_null
2382 + assert not pending_blocks[1][1].is_null
2383 +
2384 + local_attention = attention_blocks[:2]
2385 + local_mamba = mamba_blocks[:1]
2386 + load_blocks = KVCacheBlocks(
2387 + blocks=(
2388 + local_attention + gpu_pool.get_new_blocks(3),
2389 + local_mamba + gpu_pool.get_new_blocks(2),
2390 + )
2391 + )
2392 + sched.update_state_after_alloc(
2393 + consumer,
2394 + load_blocks,
2395 + num_external_tokens=hit_tokens,
2396 + )
2397 + load_meta = sched.build_connector_meta(
2398 + make_scheduler_output(
2399 + {consumer.request_id: 1},
2400 + new_reqs={consumer.request_id: load_blocks.get_block_ids()},
2401 + )
2402 + )
2403 + assert load_meta.load_gpu_blocks == [
2404 + *(block.block_id for block in load_blocks.blocks[0][2:5]),
2405 + load_blocks.blocks[1][2].block_id,
2406 + ]
2407 + assert len(load_meta.load_cpu_blocks) == 4
2408 +
2409 +
2410 +def test_finished_eager_store_caches_fa_partial_tail_for_hybrid_hit() -> None:
2411 + """A finished non-LCM prompt needs the FA partial tail in CPU too."""
2412 + hash_block_size = BLOCK_SIZE
2413 + mamba_block_size = BLOCK_SIZE
2414 + attention_block_size = 4 * BLOCK_SIZE
2415 + dcp_world_size = 2
2416 + scheduler_block_size = attention_block_size * dcp_world_size
2417 + total_cached_tokens = 5 * hash_block_size
2418 + fix = _make_hybrid_attention_mamba_scheduler(
2419 + num_cpu_blocks=32,
2420 + num_gpu_blocks=32,
2421 + attention_block_size=attention_block_size,
2422 + block_size=mamba_block_size,
2423 + scheduler_block_size=scheduler_block_size,
2424 + hash_block_size=hash_block_size,
2425 + dcp_world_size=dcp_world_size,
2426 + )
2427 + sched = fix.scheduler
2428 + gpu_pool = fix.gpu_block_pool
2429 + assert sched.fa_block_size == scheduler_block_size
2430 + assert total_cached_tokens % sched.fa_block_size != 0
2431 +
2432 + producer = _make_cp_request(num_blocks=5, virtual_block_size=hash_block_size)
2433 + attention_blocks = gpu_pool.get_new_blocks(1)
2434 + gpu_pool.cache_partial_block(
2435 + request=producer,
2436 + block=attention_blocks[0],
2437 + num_tokens=total_cached_tokens,
2438 + kv_cache_group_id=0,
2439 + block_size=scheduler_block_size,
2440 + )
2441 + mamba_blocks = gpu_pool.get_new_blocks(5)
2442 + gpu_pool.cache_full_blocks(
2443 + request=producer,
2444 + blocks=mamba_blocks,
2445 + num_cached_blocks=0,
2446 + num_full_blocks=5,
2447 + kv_cache_group_id=1,
2448 + block_size=mamba_block_size,
2449 + )
2450 + producer_blocks = KVCacheBlocks(blocks=(attention_blocks, mamba_blocks))
2451 + sched.update_state_after_alloc(producer, producer_blocks, num_external_tokens=0)
2452 +
2453 + store_output = make_scheduler_output(
2454 + {producer.request_id: total_cached_tokens},
2455 + new_reqs={producer.request_id: producer_blocks.get_block_ids()},
2456 + )
2457 + store_output.kv_connector_block_state = KVConnectorBlockState(
2458 + req_ids=set(),
2459 + resolve_block_ids=lambda _: (),
2460 + boundary_state_offloads={
2461 + producer.request_id: [
2462 + (1, mamba_blocks[4].block_id, total_cached_tokens),
2463 + ]
2464 + },
2465 + )
2466 + store_meta = sched.build_connector_meta(store_output)
2467 + simulate_store_completion(sched, store_meta.store_event)
2468 +
2469 + producer.num_computed_tokens = total_cached_tokens
2470 + sched.request_finished_all_groups(producer, producer_blocks.get_block_ids())
2471 + finish_meta = sched.build_connector_meta(make_scheduler_output({}))
2472 + assert attention_blocks[0].block_id in finish_meta.store_gpu_blocks
2473 + simulate_store_completion(sched, finish_meta.store_event)
2474 +
2475 + consumer = Request(
2476 + request_id="req-finished-partial-tail-load",
2477 + prompt_token_ids=producer.prompt_token_ids,
2478 + sampling_params=producer.sampling_params,
2479 + pooling_params=None,
2480 + mm_features=None,
2481 + block_hasher=producer._block_hasher,
2482 + )
2483 + hit_tokens, is_async = sched.get_num_new_matched_tokens(
2484 + consumer, num_computed_tokens=0
2485 + )
2486 + assert hit_tokens == total_cached_tokens
2487 + assert is_async is True
2488 +
2489 +
2490 +def test_finished_eager_store_does_not_duplicate_completed_partial_tail() -> None:
2491 + """Decode can fill the boundary block; the scan then already covers it."""
2492 + hash_block_size = BLOCK_SIZE
2493 + mamba_block_size = BLOCK_SIZE
2494 + attention_block_size = 4 * BLOCK_SIZE
2495 + dcp_world_size = 2
2496 + scheduler_block_size = attention_block_size * dcp_world_size
2497 + prompt_tokens = 5 * hash_block_size
2498 + fix = _make_hybrid_attention_mamba_scheduler(
2499 + num_cpu_blocks=32,
2500 + num_gpu_blocks=32,
2501 + attention_block_size=attention_block_size,
2502 + block_size=mamba_block_size,
2503 + scheduler_block_size=scheduler_block_size,
2504 + hash_block_size=hash_block_size,
2505 + dcp_world_size=dcp_world_size,
2506 + )
2507 + sched = fix.scheduler
2508 + gpu_pool = fix.gpu_block_pool
2509 + assert prompt_tokens % sched.fa_block_size != 0
2510 +
2511 + producer = _make_cp_request(num_blocks=5, virtual_block_size=hash_block_size)
2512 + attention_blocks = gpu_pool.get_new_blocks(1)
2513 + gpu_pool.cache_partial_block(
2514 + request=producer,
2515 + block=attention_blocks[0],
2516 + num_tokens=prompt_tokens,
2517 + kv_cache_group_id=0,
2518 + block_size=scheduler_block_size,
2519 + )
2520 + mamba_blocks = gpu_pool.get_new_blocks(5)
2521 + gpu_pool.cache_full_blocks(
2522 + request=producer,
2523 + blocks=mamba_blocks,
2524 + num_cached_blocks=0,
2525 + num_full_blocks=5,
2526 + kv_cache_group_id=1,
2527 + block_size=mamba_block_size,
2528 + )
2529 + producer_blocks = KVCacheBlocks(blocks=(attention_blocks, mamba_blocks))
2530 + sched.update_state_after_alloc(producer, producer_blocks, num_external_tokens=0)
2531 +
2532 + # Decode carries the request past the physical attention block boundary, so
2533 + # the positional scan now considers the same block the partial tail names.
2534 + producer.num_computed_tokens = sched.fa_block_size
2535 + sched.request_finished_all_groups(producer, producer_blocks.get_block_ids())
2536 + finish_meta = sched.build_connector_meta(make_scheduler_output({}))
2537 +
2538 + stored = finish_meta.store_gpu_blocks
2539 + assert attention_blocks[0].block_id in stored
2540 + assert len(stored) == len(set(stored))
2541 + assert len(finish_meta.store_cpu_blocks) == len(stored)
2542 +
2543 +
2544 +def test_external_lookup_rejects_misaligned_local_prefix(caplog_vllm) -> None:
2545 + scheduler_block_size = 4 * BLOCK_SIZE
2546 + fix = _make_hybrid_attention_mamba_scheduler(block_size=scheduler_block_size)
2547 +
2548 + request = _make_cp_request(num_blocks=2, virtual_block_size=scheduler_block_size)
2549 + # The ``vllm`` logger sets propagate=False, so plain ``caplog`` can observe
2550 + # nothing; ``caplog_vllm`` re-enables propagation for the duration.
2551 + with caplog_vllm.at_level(logging.WARNING):
2552 + hit_tokens, is_async = fix.scheduler.get_num_new_matched_tokens(
2553 + request, num_computed_tokens=BLOCK_SIZE
2554 + )
2555 +
2556 + assert (hit_tokens, is_async) == (0, False)
2557 + assert "requires scheduler-block-aligned local tokens" in caplog_vllm.text
2558 +
2559 +
2560 +def test_fine_grained_external_hits_require_eager_offload() -> None:
2561 + hash_block_size = BLOCK_SIZE
2562 + scheduler_block_size = 4 * BLOCK_SIZE
2563 +
2564 + eager = _make_hybrid_attention_mamba_scheduler(
2565 + block_size=scheduler_block_size,
2566 + hash_block_size=hash_block_size,
2567 + )
2568 + lazy = _make_hybrid_attention_mamba_scheduler(
2569 + block_size=scheduler_block_size,
2570 + hash_block_size=hash_block_size,
2571 + lazy=True,
2572 + )
2573 +
2574 + assert eager.scheduler.cpu_coordinator.enable_partial_hash_hits
2575 + assert not lazy.scheduler.cpu_coordinator.enable_partial_hash_hits
2576 +
2577 +
2578 +def test_eager_store_does_not_scan_mamba_blocks_positionally() -> None:
2579 + """Mamba stores must use exact handoffs, not historical block positions."""
2580 + block_size = 4 * BLOCK_SIZE
2581 + fix = _make_hybrid_attention_mamba_scheduler(
2582 + num_cpu_blocks=16, num_gpu_blocks=24, block_size=block_size
2583 + )
2584 + sched = fix.scheduler
2585 + gpu_pool = fix.gpu_block_pool
2586 + attention_manager, mamba_manager = sched.cpu_coordinator.single_type_managers
2587 + assert attention_manager.has_positionally_stable_blocks
2588 + assert not mamba_manager.has_positionally_stable_blocks
2589 +
2590 + req = _make_cp_request(num_blocks=2, virtual_block_size=block_size)
2591 + attn_blocks = _allocate_cp_gpu_blocks(
2592 + gpu_pool, req, 2, virtual_block_size=block_size, group_id=0
2593 + )
2594 + gpu_blocks = gpu_pool.get_new_blocks(2)
2595 + gpu_blocks[1].set_block_hash(make_block_hash_with_group_id(req.block_hashes[1], 1))
2596 + kv_blocks = KVCacheBlocks(blocks=(attn_blocks, gpu_blocks))
2597 + req.num_computed_tokens = 0
2598 + sched.update_state_after_alloc(req, kv_blocks, num_external_tokens=0)
2599 +
2600 + output = make_scheduler_output(
2601 + {req.request_id: 2 * block_size},
2602 + new_reqs={req.request_id: kv_blocks.get_block_ids()},
2603 + )
2604 + output.kv_connector_block_state = KVConnectorBlockState(
2605 + req_ids=set(),
2606 + resolve_block_ids=lambda _: (),
2607 + boundary_state_offloads={
2608 + req.request_id: [(1, gpu_blocks[1].block_id, 2 * block_size)]
2609 + },
2610 + )
2611 + meta1 = sched.build_connector_meta(output)
2612 + assert meta1.store_event >= 0
2613 + # Only the exact handoff lands this step. build_connector_meta runs before
2614 + # the scheduler commits this step's tokens, so the positional scan has
2615 + # nothing confirmed yet.
2616 + assert set(meta1.store_gpu_blocks) == {gpu_blocks[1].block_id}
2617 + assert sched._reqs_to_store[req.request_id].num_stored_blocks == [0, 0]
2618 + simulate_store_completion(sched, meta1.store_event)
2619 +
2620 + # Next step the attention blocks are confirmed and scanned positionally. A
2621 + # later hash update still must not make the mamba align snapshot a valid
2622 + # source; the explicit boundary-handoff tests cover the supported path.
2623 + gpu_blocks[0].set_block_hash(make_block_hash_with_group_id(req.block_hashes[0], 1))
2624 + req.num_computed_tokens = 2 * block_size
2625 + meta2 = sched.build_connector_meta(
2626 + make_scheduler_output(
2627 + {req.request_id: 1},
2628 + cached_req_new_blocks={req.request_id: None},
2629 + )
2630 + )
2631 + assert set(meta2.store_gpu_blocks) == {block.block_id for block in attn_blocks}
2632 + assert sched._reqs_to_store[req.request_id].num_stored_blocks == [2, 0]
2633 + simulate_store_completion(sched, meta2.store_event)
2634 +
2635 + # Nothing further: mamba align is never picked up positionally.
2636 + meta3 = sched.build_connector_meta(
2637 + make_scheduler_output(
2638 + {req.request_id: 1},
2639 + cached_req_new_blocks={req.request_id: None},
2640 + )
2641 + )
2642 + assert meta3.store_event == -1
2643 + assert meta3.store_gpu_blocks == []
2644 + assert sched._reqs_to_store[req.request_id].num_stored_blocks == [2, 0]
2645 +
2646 +
2647 +def test_eager_store_scans_mamba_all_blocks_positionally() -> None:
2648 + """Mamba all-mode retains positional identity and needs no handoff."""
2649 + block_size = 4 * BLOCK_SIZE
2650 + fix = _make_hybrid_attention_mamba_scheduler(
2651 + num_cpu_blocks=16,
2652 + num_gpu_blocks=24,
2653 + block_size=block_size,
2654 + mamba_cache_mode="all",
2655 + )
2656 + sched = fix.scheduler
2657 + gpu_pool = fix.gpu_block_pool
2658 + attention_manager, mamba_manager = sched.cpu_coordinator.single_type_managers
2659 + assert attention_manager.has_positionally_stable_blocks
2660 + assert mamba_manager.has_positionally_stable_blocks
2661 +
2662 + req = _make_cp_request(num_blocks=2, virtual_block_size=block_size)
2663 + attention_blocks = _allocate_cp_gpu_blocks(
2664 + gpu_pool, req, 2, virtual_block_size=block_size, group_id=0
2665 + )
2666 + mamba_blocks = _allocate_cp_gpu_blocks(
2667 + gpu_pool, req, 2, virtual_block_size=block_size, group_id=1
2668 + )
2669 + kv_blocks = KVCacheBlocks(blocks=(attention_blocks, mamba_blocks))
2670 + sched.update_state_after_alloc(req, kv_blocks, num_external_tokens=0)
2671 +
2672 + # build_connector_meta runs before the scheduler commits this step's
2673 + # tokens, so the positional scan picks the blocks up on the next step.
2674 + first = sched.build_connector_meta(
2675 + make_scheduler_output(
2676 + {req.request_id: 2 * block_size},
2677 + new_reqs={req.request_id: kv_blocks.get_block_ids()},
2678 + )
2679 + )
2680 + assert first.store_gpu_blocks == []
2681 + req.num_computed_tokens = 2 * block_size
2682 + meta = sched.build_connector_meta(
2683 + make_scheduler_output(
2684 + {req.request_id: 1},
2685 + cached_req_new_blocks={req.request_id: None},
2686 + )
2687 + )
2688 +
2689 + assert set(meta.store_gpu_blocks) == {
2690 + *(block.block_id for block in attention_blocks),
2691 + *(block.block_id for block in mamba_blocks),
2692 + }
2693 + assert sched._reqs_to_store[req.request_id].num_stored_blocks == [2, 2]
2694 +
2695 +
2696 +@pytest.mark.parametrize("gone_via", ["preempted", "finished", "unregistered"])
2697 +def test_boundary_handoff_dropped_for_departing_request(gone_via: str) -> None:
2698 + """Handoffs for a request leaving this step must not be read.
2699 +
2700 + The scheduler drains boundary handoffs without filtering by request
2701 + liveness, so a preempted or finished request can still offer block IDs.
2702 + Those blocks are being released and may already back another request, so
2703 + copying them would publish unrelated KV under a valid hash.
2704 + """
2705 + block_size = 4 * BLOCK_SIZE
2706 + fix = _make_hybrid_attention_mamba_scheduler(
2707 + num_cpu_blocks=16, num_gpu_blocks=24, block_size=block_size
2708 + )
2709 + sched = fix.scheduler
2710 + gpu_pool = fix.gpu_block_pool
2711 +
2712 + req = _make_cp_request(num_blocks=2, virtual_block_size=block_size)
2713 + attn_blocks = _allocate_cp_gpu_blocks(
2714 + gpu_pool, req, 2, virtual_block_size=block_size, group_id=0
2715 + )
2716 + mamba_blocks = gpu_pool.get_new_blocks(2)
2717 + mamba_blocks[1].set_block_hash(
2718 + make_block_hash_with_group_id(req.block_hashes[1], 1)
2719 + )
2720 + kv_blocks = KVCacheBlocks(blocks=(attn_blocks, mamba_blocks))
2721 + sched.update_state_after_alloc(req, kv_blocks, num_external_tokens=0)
2722 +
2723 + output = make_scheduler_output(
2724 + {req.request_id: 2 * block_size},
2725 + new_reqs={req.request_id: kv_blocks.get_block_ids()},
2726 + )
2727 + output.kv_connector_block_state = KVConnectorBlockState(
2728 + req_ids=set(),
2729 + resolve_block_ids=lambda _: (),
2730 + boundary_state_offloads={
2731 + req.request_id: [(1, mamba_blocks[1].block_id, 2 * block_size)]
2732 + },
2733 + )
2734 + if gone_via == "preempted":
2735 + output.preempted_req_ids = {req.request_id}
2736 + elif gone_via == "finished":
2737 + output.finished_req_ids = {req.request_id}
2738 + else:
2739 + sched._reqs_to_store.pop(req.request_id)
2740 +
2741 + meta = sched.build_connector_meta(output)
2742 +
2743 + # The exact-handoff block is never a store source for a departing request.
2744 + assert mamba_blocks[1].block_id not in meta.store_gpu_blocks
2745 + stats = sched.get_boundary_store_stats()
2746 + assert stats.dropped_request_gone == 1
2747 + assert stats.stored == 0
2748 +
2749 +
2750 +def test_boundary_handoff_keeps_block_meta_index_parallel() -> None:
2751 + """Boundary stores must extend block_meta alongside the block ids.
2752 +
2753 + ``_process_store_completion`` indexes ``block_meta`` by position, so a
2754 + boundary handoff that appends only block ids shortens the list, trips its
2755 + length assertion and shifts every following entry's metadata.
2756 + """
2757 + block_size = 4 * BLOCK_SIZE
2758 + fix = _make_hybrid_attention_mamba_scheduler(
2759 + num_cpu_blocks=16,
2760 + num_gpu_blocks=24,
2761 + block_size=block_size,
2762 + enable_kv_cache_events=True,
2763 + )
2764 + sched = fix.scheduler
2765 + gpu_pool = fix.gpu_block_pool
2766 + assert sched.enable_kv_cache_events
2767 +
2768 + req = _make_cp_request(num_blocks=2, virtual_block_size=block_size)
2769 + attn_blocks = _allocate_cp_gpu_blocks(
2770 + gpu_pool, req, 2, virtual_block_size=block_size, group_id=0
2771 + )
2772 + mamba_blocks = gpu_pool.get_new_blocks(2)
2773 + mamba_blocks[1].set_block_hash(
2774 + make_block_hash_with_group_id(req.block_hashes[1], 1)
2775 + )
2776 + kv_blocks = KVCacheBlocks(blocks=(attn_blocks, mamba_blocks))
2777 + sched.update_state_after_alloc(req, kv_blocks, num_external_tokens=0)
2778 + # Make the FA blocks positionally eligible in the same event as the
2779 + # explicit Mamba boundary handoff.
2780 + req.num_computed_tokens = 2 * block_size
2781 +
2782 + output = make_scheduler_output(
2783 + {req.request_id: 2 * block_size},
2784 + new_reqs={req.request_id: kv_blocks.get_block_ids()},
2785 + )
2786 + output.kv_connector_block_state = KVConnectorBlockState(
2787 + req_ids=set(),
2788 + resolve_block_ids=lambda _: (),
2789 + boundary_state_offloads={
2790 + req.request_id: [(1, mamba_blocks[1].block_id, 2 * block_size)]
2791 + },
2792 + )
2793 +
2794 + gpu_ids, cpu_ids, req_ids, block_meta = sched.prepare_store_specs(output)
2795 + assert mamba_blocks[1].block_id in gpu_ids
2796 + assert set(gpu_ids) == {
2797 + *(block.block_id for block in attn_blocks),
2798 + mamba_blocks[1].block_id,
2799 + }
2800 + assert req_ids == [req.request_id]
2801 + assert block_meta is not None
2802 + assert len(block_meta) == len(gpu_ids) == len(cpu_ids)
vllm/distributed/kv_transfer/kv_connector/v1/simple_cpu_offload_connector.py
+7
@@ -19,6 +19,7 @@ from vllm.logger import init_logger
19 from vllm.v1.core.sched.output import SchedulerOutput
20 from vllm.v1.outputs import KVConnectorOutput
21 from vllm.v1.simple_kv_offload.manager import (
22 + BoundaryStoreStats,
23 SimpleCPUOffloadScheduler,
24 )
25 from vllm.v1.simple_kv_offload.metadata import (
@@ -300,6 +301,12 @@ class SimpleCPUOffloadConnector(KVConnectorBase_V1, SupportsHMA):
301 return self.scheduler_manager.has_pending_stores()
302 return False
303
304 + def get_boundary_store_stats(self) -> BoundaryStoreStats | None:
305 + """Return cumulative boundary-handoff store diagnostics."""
306 + if self.scheduler_manager is not None:
307 + return self.scheduler_manager.get_boundary_store_stats()
308 + return None
309 +
310 def take_events(self) -> Iterable[KVCacheEvent]:
311 if self.scheduler_manager is not None:
312 return self.scheduler_manager.take_events()
vllm/v1/core/kv_cache_coordinator.py
+12 -4
@@ -155,6 +155,9 @@ class KVCacheCoordinator(ABC):
155 for manager in self.single_type_managers:
156 if isinstance(manager, MambaManager):
157 manager.drop_eagle_checkpoint_block = True
158 + self.group_block_sizes = tuple(
159 + manager.block_size for manager in self.single_type_managers
160 + )
161
162 # A positive retention interval must be a multiple of the base hit granularity
163 # (``scheduler_block_size``) to land on real cache-hit boundaries.
@@ -605,6 +608,7 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
608 hash_block_size: int,
609 metrics_collector: KVCacheMetricsCollector | None = None,
610 num_prefill_lookahead: int = 0,
611 + allow_partial_hash_hits: bool = True,
612 ):
613 super().__init__(
614 kv_cache_config,
@@ -629,7 +633,7 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
633 # Only groups that participate in prefix caching must satisfy the
634 # divisibility constraint; groups that opt out (e.g. GLM-5.3-Flash kpool
635 # tail, block_size=kpool) are scratch buffers and excluded.
632 - group_block_sizes = [
636 + cacheable_block_sizes = [
637 manager.block_size
638 for manager, group in zip(
639 self.single_type_managers, kv_cache_config.kv_cache_groups
@@ -637,10 +641,10 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
641 if group.kv_cache_spec.prefix_cacheable
642 ]
643 assert all(
640 - block_size % hash_block_size == 0 for block_size in group_block_sizes
644 + block_size % hash_block_size == 0 for block_size in cacheable_block_sizes
645 ), (
646 "Each KV cache group's real block_size must be divisible by "
643 - f"hash_block_size. block_sizes={group_block_sizes}, "
647 + f"hash_block_size. block_sizes={cacheable_block_sizes}, "
648 f"hash_block_size={hash_block_size}"
649 )
650 assert pcp_world_size == 1, "PCP not support hybrid attn now."
@@ -669,7 +673,9 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
673 )
674 for g in kv_cache_config.kv_cache_groups
675 )
672 - self.enable_partial_hash_hits = has_partial_mamba_group
676 + self.enable_partial_hash_hits = (
677 + allow_partial_hash_hits and has_partial_mamba_group
678 + )
679 if self.enable_partial_hash_hits:
680 unsupported_partial_hit_managers = {
681 type(manager).__name__
@@ -987,6 +993,7 @@ def get_kv_cache_coordinator(
993 hash_block_size: int,
994 metrics_collector: KVCacheMetricsCollector | None = None,
995 num_prefill_lookahead: int = 0,
996 + allow_partial_hash_hits: bool = True,
997 ) -> KVCacheCoordinator:
998 if not enable_caching:
999 return KVCacheCoordinatorNoPrefixCache(
@@ -1030,4 +1037,5 @@ def get_kv_cache_coordinator(
1037 hash_block_size=hash_block_size,
1038 metrics_collector=metrics_collector,
1039 num_prefill_lookahead=num_prefill_lookahead,
1040 + allow_partial_hash_hits=allow_partial_hash_hits,
1041 )
vllm/v1/core/single_type_kv_cache_manager.py
+16
@@ -48,6 +48,16 @@ class SingleTypeKVCacheManager(ABC):
48
49 supports_fine_grained_hash_lookup: ClassVar[bool] = False
50
51 + @property
52 + def has_positionally_stable_blocks(self) -> bool:
53 + """Whether positional offload scans can follow this block table.
54 +
55 + True means already-scanned indices will not be reused for a different
56 + token range. Managers may still null blocks that left their retention
57 + window, as long as the cursor's positional history remains valid.
58 + """
59 + return True
60 +
61 def __init__(
62 self,
63 kv_cache_spec: KVCacheSpec,
@@ -1423,6 +1433,12 @@ class ChunkedLocalAttentionManager(SingleTypeKVCacheManager):
1433 class MambaManager(SingleTypeKVCacheManager):
1434 supports_fine_grained_hash_lookup: ClassVar[bool] = True
1435
1436 + @property
1437 + def has_positionally_stable_blocks(self) -> bool:
1438 + # Align-mode Mamba can null interior states and relocate speculative
1439 + # blocks in place. Other modes retain positional identity.
1440 + return self.mamba_cache_mode != "align"
1441 +
1442 def __init__(
1443 self, kv_cache_spec: MambaSpec, block_pool: BlockPool, **kwargs
1444 ) -> None:
vllm/v1/simple_kv_offload/manager.py
+266 -66
@@ -5,6 +5,7 @@
5 import contextlib
6 from collections.abc import Iterable
7 from dataclasses import dataclass, field, replace
8 +from enum import Enum, auto
9 from typing import TYPE_CHECKING, Any
10
11 from vllm.config import VllmConfig
@@ -26,6 +27,7 @@ from vllm.v1.core.kv_cache_coordinator import (
27 from vllm.v1.core.kv_cache_utils import (
28 BlockHashWithGroupId,
29 ExternalBlockHash,
30 + dcp_world_size_for_kv_cache_spec,
31 generate_block_hash_extra_keys,
32 get_block_hash,
33 get_group_id,
@@ -99,6 +101,36 @@ class StoreRequestState:
101 finished: bool = False
102
103
104 +class _StoreAdmission(Enum):
105 + READY = auto()
106 + NULL_BLOCK = auto()
107 + NOT_HASHED = auto()
108 + IN_FLIGHT = auto()
109 + ALREADY_CACHED = auto()
110 +
111 +
112 +@dataclass
113 +class BoundaryStoreStats:
114 + """Cumulative counters for boundary hand-off stores.
115 +
116 + Every branch that declines a hand-off is silent by design: the boundary is
117 + simply not offloaded and a later request misses. Without counters the only
118 + symptom is a lower cache hit rate with nothing in the logs, so each decline
119 + reason is counted and exposed through
120 + ``SimpleCPUOffloadConnector.get_boundary_store_stats()``. Reset by
121 + ``reset()``; not otherwise cleared.
122 + """
123 +
124 + published: int = 0
125 + stored: int = 0
126 + dropped_cpu_full: int = 0
127 + dropped_request_gone: int = 0
128 + skipped_already_cached: int = 0
129 + skipped_in_flight: int = 0
130 + dropped_null_block: int = 0
131 + dropped_not_hashed: int = 0
132 +
133 +
134 class SimpleCPUOffloadScheduler:
135 """Scheduler-side manager for CPU offloading."""
136
@@ -141,16 +173,6 @@ class SimpleCPUOffloadScheduler:
173 self.fa_gidx = g_idx
174 break
175 assert 0 <= self.fa_gidx < len(self.cpu_kv_cache_config.kv_cache_groups)
144 - # FA group's own block_size; divides scheduler_block_size (the LCM)
145 - # but is NOT assumed to equal it.
146 - self.fa_block_size: int = (
147 - self.cpu_kv_cache_config.kv_cache_groups[
148 - self.fa_gidx
149 - ].kv_cache_spec.block_size
150 - * self.cp_world_size
151 - )
152 - assert self.block_size % self.fa_block_size == 0
153 -
176 logger.info(
177 "SimpleCPUOffloadScheduler: Allocating %d offload blocks "
178 "(%.2f GB, mode=%s, backend=%s)",
@@ -175,7 +197,13 @@ class SimpleCPUOffloadScheduler:
197 pcp_world_size=1,
198 scheduler_block_size=self.block_size,
199 hash_block_size=self.hash_block_size,
200 + allow_partial_hash_hits=not lazy_offload,
201 )
202 + self.group_block_sizes = self.cpu_coordinator.group_block_sizes
203 + # FA group's own resolved block_size; divides scheduler_block_size (the
204 + # LCM) but is NOT assumed to equal it.
205 + self.fa_block_size: int = self.group_block_sizes[self.fa_gidx]
206 + assert self.block_size % self.fa_block_size == 0
207 self.cpu_block_pool: BlockPool = self.cpu_coordinator.block_pool
208 # GPU block pool reference - bound after scheduler builds kv_cache_manager
209 self._gpu_block_pool: BlockPool | None = None
@@ -186,10 +214,13 @@ class SimpleCPUOffloadScheduler:
214 # the worker reports completions by event index, not request id.
215 self._load_event_to_reqs: dict[int, list[str]] = {}
216
189 - # Pending (cpu_hit_blocks, hit_length) tuples from find_longest_cache_hit,
190 - # kept pinned via touch() while awaiting update_state_after_alloc().
217 + # Pending (cpu_hit_blocks, hit_length, num_computed_tokens) tuples from
218 + # find_longest_cache_hit, kept pinned via touch() while awaiting
219 + # update_state_after_alloc(). ``num_computed_tokens`` is the local
220 + # prefix the hit was resolved against; the load path needs it to place
221 + # external blocks and must not re-derive it from block metadata.
222 self._pending_cpu_hits: dict[
192 - str, tuple[tuple[list[KVCacheBlock], ...], int]
223 + str, tuple[tuple[list[KVCacheBlock], ...], int, int]
224 ] = {}
225
226 # Store metadata
@@ -216,6 +247,7 @@ class SimpleCPUOffloadScheduler:
247 # Event counters
248 self._load_event_counter: int = 0
249 self._store_event_counter: int = 0
250 + self.boundary_store_stats = BoundaryStoreStats()
251
252 # For TP/PP: track partial store completions across steps.
253 # Events must be reported by all world_size workers before considered complete.
@@ -267,7 +299,11 @@ class SimpleCPUOffloadScheduler:
299 target = 0
300 for g in kv_cache_config.kv_cache_groups:
301 spec = g.kv_cache_spec
270 - block_size = spec.block_size * cp_world_size
302 + # Only full attention is sharded across DCP ranks; replicated specs
303 + # (mamba, sliding window, chunked-local) keep their own block size.
304 + block_size = spec.block_size * dcp_world_size_for_kv_cache_spec(
305 + spec, cp_world_size
306 + )
307 if isinstance(spec, MambaSpec):
308 target += 2
309 elif isinstance(spec, SlidingWindowSpec):
@@ -296,6 +332,20 @@ class SimpleCPUOffloadScheduler:
332 if request.skip_reading_prefix_cache:
333 return 0, False
334
335 + if num_computed_tokens % self.block_size != 0:
336 + # Transfers are whole-block copies, so an external suffix cannot
337 + # start in the middle of a destination block. When the GPU-side
338 + # coordinator also serves fine-grained hits, the local prefix can
339 + # land on a hash boundary that is not a scheduler-block boundary;
340 + # such a request keeps its local hit and skips the external one.
341 + logger.warning_once(
342 + "SimpleCPU external lookup requires scheduler-block-aligned "
343 + "local tokens, got %d tokens with scheduler block size %d.",
344 + num_computed_tokens,
345 + self.block_size,
346 + )
347 + return 0, False
348 +
349 num_skipped_hashes = num_computed_tokens // self.hash_block_size
350 remaining_hashes = request.block_hashes[num_skipped_hashes:]
351
@@ -318,6 +368,7 @@ class SimpleCPUOffloadScheduler:
368 self._pending_cpu_hits[request.request_id] = (
369 cpu_hit_blocks,
370 hit_length,
371 + num_computed_tokens,
372 )
373 return hit_length, True
374 return 0, False
@@ -371,20 +422,19 @@ class SimpleCPUOffloadScheduler:
422 )
423 return
424
374 - cpu_hit_blocks_full, _ = pending
425 + cpu_hit_blocks_full, _, num_computed_tokens = pending
426
376 - # ``num_external_tokens`` is LCM-aligned (checked per-group below),
377 - # so this counts whole scheduler-aligned chunks of incoming tokens.
378 - num_blocks_to_load = num_external_tokens // self.block_size
379 - assert num_blocks_to_load > 0
380 - num_cached_fa_blocks = sum(
381 - blk.block_hash is not None for blk in blocks.blocks[self.fa_gidx]
427 + assert num_computed_tokens % self.block_size == 0, (
428 + "SimpleCPU external loads must start at a scheduler-block boundary"
429 + )
430 + # Fine-grained hits are hash-block aligned, not group-block aligned, so
431 + # the per-group block count below rounds up instead of dividing exactly.
432 + # Hash alignment is still required: it is what makes the accepted
433 + # external suffix start on a boundary every group can address.
434 + assert num_external_tokens % self.hash_block_size == 0, (
435 + f"num_external_tokens={num_external_tokens} is not aligned to "
436 + f"hash_block_size={self.hash_block_size}"
437 )
383 - num_computed_tokens = num_cached_fa_blocks * self.fa_block_size
384 -
385 - # Build transfer pairs across all groups.
386 - total_computed_tokens = num_computed_tokens + num_external_tokens
387 - kv_cache_groups = self.cpu_kv_cache_config.kv_cache_groups
438
439 # The scheduler may have accepted fewer blocks than
440 # get_num_new_matched_tokens() reported.
@@ -393,14 +443,8 @@ class SimpleCPUOffloadScheduler:
443 # the rest will be released along with the temp pin below.
444 cpu_hit_blocks: list[list[KVCacheBlock]] = []
445 for g in range(num_groups):
396 - g_block_size = (
397 - kv_cache_groups[g].kv_cache_spec.block_size * self.cp_world_size
398 - )
399 - assert num_external_tokens % g_block_size == 0, (
400 - f"num_external_tokens={num_external_tokens} not aligned to "
401 - f"group {g} block_size={g_block_size}"
402 - )
403 - n_take_g = num_external_tokens // g_block_size
446 + g_block_size = self.group_block_sizes[g]
447 + n_take_g = cdiv(num_external_tokens, g_block_size)
448 cpu_hit_blocks.append(cpu_hit_blocks_full[g][:n_take_g])
449
450 gpu_block_ids: list[int] = []
@@ -413,14 +457,8 @@ class SimpleCPUOffloadScheduler:
457 if n_ext_g == 0:
458 continue
459
416 - # Number of blocks in the computed range for this group.
417 - g_block_size = (
418 - kv_cache_groups[g].kv_cache_spec.block_size * self.cp_world_size
419 - )
420 - n_computed_g = cdiv(total_computed_tokens, g_block_size)
421 -
422 - # Back-trace: ext blocks sit at the tail of the computed range.
423 - gpu_ext_start = n_computed_g - n_ext_g
460 + g_block_size = self.group_block_sizes[g]
461 + gpu_ext_start = num_computed_tokens // g_block_size
462 group_gpu_ids = block_ids_by_group[g]
463
464 for i, cpu_blk in enumerate(cpu_blocks_g):
@@ -614,6 +652,72 @@ class SimpleCPUOffloadScheduler:
652 num_groups = len(self.cpu_kv_cache_config.kv_cache_groups)
653 # Dedup against blocks already scheduled.
654 in_flight = self._in_flight_store_gpu_blocks
655 + num_free = cpu_block_pool.get_num_free_blocks()
656 +
657 + block_state = scheduler_output.kv_connector_block_state
658 + boundary_offloads = (
659 + block_state.boundary_state_offloads if block_state is not None else {}
660 + )
661 + stats = self.boundary_store_stats
662 + preempted_req_ids = scheduler_output.preempted_req_ids or ()
663 + for req_id, entries in boundary_offloads.items():
664 + # The scheduler drains handoffs without filtering by request
665 + # liveness. A request that finished or was preempted in this step
666 + # is giving up its blocks, which may already have been reallocated
667 + # to another request; reading them would publish unrelated KV under
668 + # a valid hash. Drop conservatively, as the mooncake store does.
669 + store_state = self._reqs_to_store.get(req_id)
670 + if (
671 + store_state is None
672 + or store_state.finished
673 + or req_id in preempted_req_ids
674 + or req_id in scheduler_output.finished_req_ids
675 + ):
676 + stats.published += len(entries)
677 + stats.dropped_request_gone += len(entries)
678 + continue
679 + scheduled_for_req = False
680 + # ``boundary_tokens`` is not needed here: the cache key comes from
681 + # the handed-off block itself, not from the offered boundary.
682 + for _, gpu_block_id, _ in entries:
683 + stats.published += 1
684 + gpu_block = gpu_block_pool.blocks[gpu_block_id]
685 + admission = self._classify_store_candidate(gpu_block)
686 + if admission is _StoreAdmission.NULL_BLOCK:
687 + stats.dropped_null_block += 1
688 + continue
689 + if admission is _StoreAdmission.NOT_HASHED:
690 + stats.dropped_not_hashed += 1
691 + continue
692 + if admission is _StoreAdmission.IN_FLIGHT:
693 + stats.skipped_in_flight += 1
694 + continue
695 + if admission is _StoreAdmission.ALREADY_CACHED:
696 + stats.skipped_already_cached += 1
697 + continue
698 + if num_free <= 0:
699 + stats.dropped_cpu_full += 1
700 + logger.warning_once(
701 + "SimpleCPU dropped a boundary-state handoff because "
702 + "the CPU block pool is full; this boundary will not be retried."
703 + )
704 + continue
705 + cpu_block = cpu_block_pool.get_new_blocks(1)[0]
706 + merged_gpu_block_ids.append(gpu_block_id)
707 + merged_cpu_block_ids.append(cpu_block.block_id)
708 + if merged_block_meta is not None:
709 + # Keep the metadata list index-parallel with the block ids.
710 + # The hand-off block's own hashes are resolved at completion,
711 + # so there is nothing to capture here; emit the event without
712 + # a payload, as the size-mismatch fallback already does.
713 + merged_block_meta.append({})
714 + in_flight.add(gpu_block_id)
715 + gpu_block_pool.touch([gpu_block])
716 + num_free -= 1
717 + stats.stored += 1
718 + scheduled_for_req = True
719 + if scheduled_for_req:
720 + req_ids.append(req_id)
721
722 for req_id, new_block_id_groups, preempted in yield_req_data(scheduler_output):
723 state = self._reqs_to_store.get(req_id)
@@ -641,7 +745,7 @@ class SimpleCPUOffloadScheduler:
745 self._select_eager_blocks_to_store(state, block_ids_by_group)
746 )
747
644 - # --- Phase 2: Batch allocate CPU blocks ---
748 + # Batch allocate the CPU destinations.
749 n_to_alloc = len(gpu_block_ids)
750 if n_to_alloc > 0:
751 cpu_blocks_alloc = cpu_block_pool.get_new_blocks(n_to_alloc)
@@ -674,6 +778,9 @@ class SimpleCPUOffloadScheduler:
778 for g in range(num_groups):
779 state.num_stored_blocks[g] += advanced_per_group[g]
780
781 + # A request can contribute both boundary and positional blocks to the
782 + # same event. Completion tracking is set-based, so keep one request ID.
783 + req_ids = list(dict.fromkeys(req_ids))
784 return merged_gpu_block_ids, merged_cpu_block_ids, req_ids, merged_block_meta
785
786 def _select_eager_blocks_to_store(
@@ -694,19 +801,31 @@ class SimpleCPUOffloadScheduler:
801 )
802 request = state.request
803 confirmed_tokens = request.num_computed_tokens - request.num_output_placeholders
697 - aligned_tokens = confirmed_tokens // self.block_size * self.block_size
804 + # Truncate to the granularity a lookup can actually land on. With
805 + # fine-grained hits that is hash_block_size; otherwise hits only land on
806 + # the scheduler block (the group LCM). Using the LCM unconditionally
807 + # would drop whole blocks that sit between the last LCM boundary and a
808 + # reachable fine-grained boundary, so the other groups would hold that
809 + # boundary and this one would not, and the joint hybrid lookup would
810 + # reconcile to zero.
811 + store_alignment = (
812 + self.hash_block_size
813 + if self.cpu_coordinator.enable_partial_hash_hits
814 + else self.block_size
815 + )
816 + aligned_tokens = confirmed_tokens // store_alignment * store_alignment
817 num_free = self.cpu_block_pool.get_num_free_blocks()
818
819 for g, group_gpu_ids in enumerate(block_ids_by_group):
820 if len(gpu_block_ids) >= num_free:
821 break
822 + group_manager = self.cpu_coordinator.single_type_managers[g]
823 + if not group_manager.has_positionally_stable_blocks:
824 + continue
825 # FIXME (yifan): handle CPU cache eviction, where
826 # num_stored_blocks can be stale and omit evicted blocks in
827 # the middle of the request.
706 - group_size = (
707 - self.cpu_kv_cache_config.kv_cache_groups[g].kv_cache_spec.block_size
708 - * self.cp_world_size
709 - )
828 + group_size = self.group_block_sizes[g]
829 ready = min(len(group_gpu_ids), aligned_tokens // group_size)
830 resolved_hashes = (
831 resolve_block_hashes(
@@ -720,20 +839,16 @@ class SimpleCPUOffloadScheduler:
839 start = state.num_stored_blocks[g]
840 for i, gpu_block_id in enumerate(group_gpu_ids[start:ready], start=start):
841 gpu_block = self._gpu_block_pool.blocks[gpu_block_id]
723 - if gpu_block.is_null or gpu_block.block_hash is None:
724 - advanced_per_group[g] += 1
725 - continue
842 if (
727 - gpu_block_id in self._in_flight_store_gpu_blocks
728 - or self.cpu_block_pool.cached_block_hash_to_block.get_one_block(
729 - gpu_block.block_hash
730 - )
731 - is not None
843 + self._classify_store_candidate(gpu_block)
844 + is not _StoreAdmission.READY
845 ):
846 advanced_per_group[g] += 1
847 continue
848 if len(gpu_block_ids) >= num_free:
849 break
850 + primary_block_hash = gpu_block.block_hash
851 + assert primary_block_hash is not None
852 gpu_block_ids.append(gpu_block_id)
853 advanced_per_group[g] += 1
854 if block_meta is not None:
@@ -750,7 +865,7 @@ class SimpleCPUOffloadScheduler:
865 request, token_start, token_end, curr_mm_idx
866 )
867 meta_by_hash = {
753 - gpu_block.block_hash: BlockStoreMeta(
868 + primary_block_hash: BlockStoreMeta(
869 token_ids=list(
870 request.all_token_ids[token_start:token_end]
871 ),
@@ -766,7 +881,7 @@ class SimpleCPUOffloadScheduler:
881 block_hash = make_block_hash_with_group_id(
882 request.block_hashes[hash_idx], g
883 )
769 - if block_hash == gpu_block.block_hash:
884 + if block_hash is None or block_hash == primary_block_hash:
885 continue
886 hash_start = hash_idx * self.hash_block_size
887 hash_end = hash_start + self.hash_block_size
@@ -796,6 +911,25 @@ class SimpleCPUOffloadScheduler:
911
912 return gpu_block_ids, advanced_per_group, block_meta
913
914 + def _classify_store_candidate(self, gpu_block: "KVCacheBlock") -> _StoreAdmission:
915 + if gpu_block.is_null:
916 + return _StoreAdmission.NULL_BLOCK
917 + if gpu_block.block_hash is None:
918 + return _StoreAdmission.NOT_HASHED
919 + if gpu_block.block_id in self._in_flight_store_gpu_blocks:
920 + return _StoreAdmission.IN_FLIGHT
921 + if (
922 + self.cpu_block_pool.cached_block_hash_to_block.get_one_block(
923 + gpu_block.block_hash
924 + )
925 + is not None
926 + ):
927 + return _StoreAdmission.ALREADY_CACHED
928 + return _StoreAdmission.READY
929 +
930 + def get_boundary_store_stats(self) -> BoundaryStoreStats:
931 + return replace(self.boundary_store_stats)
932 +
933 def update_connector_output(self, connector_output: KVConnectorOutput) -> None:
934 """Handle async transfer completions from worker.
935
@@ -886,12 +1020,7 @@ class SimpleCPUOffloadScheduler:
1020 if self.enable_kv_cache_events:
1021 meta_by_hash = block_meta[i] if block_meta is not None else {}
1022 primary_group_idx = get_group_id(primary_hash)
889 - primary_spec = self.cpu_kv_cache_config.kv_cache_groups[
890 - primary_group_idx
891 - ].kv_cache_spec
892 - primary_block_size = primary_spec.block_size * (
893 - self.cp_world_size if not isinstance(primary_spec, MambaSpec) else 1
894 - )
1023 + primary_block_size = self.group_block_sizes[primary_group_idx]
1024 events_to_emit: list[
1025 tuple[BlockHashWithGroupId, BlockStoreMeta | None, int]
1026 ] = [
@@ -1028,6 +1157,21 @@ class SimpleCPUOffloadScheduler:
1157 if state is None or gpu_pool is None:
1158 return
1159 gpu_ids, _, block_meta = self._select_eager_blocks_to_store(state, block_ids)
1160 + partial_tail = self._find_fa_partial_tail_source(request, block_ids)
1161 + if (
1162 + partial_tail is not None
1163 + # Decode tokens can fill the boundary block, in which case the
1164 + # positional scan above already selected it. Appending again would
1165 + # allocate two CPU blocks for one GPU block.
1166 + and partial_tail not in gpu_ids
1167 + and len(gpu_ids) < self.cpu_block_pool.get_num_free_blocks()
1168 + ):
1169 + gpu_ids.append(partial_tail)
1170 + if block_meta is not None:
1171 + # The block's own hashes are registered at completion, so no
1172 + # capture is needed here; emit the event without a payload, as
1173 + # the size-mismatch fallback above already does.
1174 + block_meta.append({})
1175 if not gpu_ids:
1176 return
1177 cpu_blocks = self.cpu_block_pool.get_new_blocks(len(gpu_ids))
@@ -1037,9 +1181,64 @@ class SimpleCPUOffloadScheduler:
1181 self._in_flight_store_gpu_blocks.update(gpu_ids)
1182 gpu_pool.touch([gpu_pool.blocks[bid] for bid in gpu_ids])
1183
1040 - def _free_pending_cpu_hit(self, pending: tuple) -> None:
1184 + def _find_fa_partial_tail_source(
1185 + self, request: "Request", block_ids: tuple[list[int], ...]
1186 + ) -> int | None:
1187 + """Locate the full-attention prompt-tail block for a fine-grained hit.
1188 +
1189 + Mirrors ``FullAttentionManager._cache_partial_tail_block``: only the
1190 + final prompt hash boundary is eligible, and boundaries that land on a
1191 + physical block edge are already covered by the positional scan.
1192 +
1193 + Attention block tables are append-only, so the block is located
1194 + positionally, as the mooncake store and offloading connector also do;
1195 + no new core hand-off is required. The boundary key is registered at
1196 + completion along with the block's other hashes, so nothing has to be
1197 + captured here.
1198 + """
1199 + if not self.cpu_coordinator.enable_partial_hash_hits:
1200 + return None
1201 + assert self._gpu_block_pool is not None
1202 + boundary_tokens = (
1203 + request.num_prompt_tokens // self.hash_block_size * self.hash_block_size
1204 + )
1205 + if boundary_tokens == 0 or boundary_tokens > request.num_computed_tokens:
1206 + return None
1207 + if boundary_tokens % self.fa_block_size == 0:
1208 + return None
1209 + block_idx = boundary_tokens // self.fa_block_size
1210 + fa_block_ids = block_ids[self.fa_gidx]
1211 + if block_idx >= len(fa_block_ids):
1212 + return None
1213 + gpu_block_id = fa_block_ids[block_idx]
1214 + gpu_block = self._gpu_block_pool.blocks[gpu_block_id]
1215 + if gpu_block.is_null or gpu_block_id in self._in_flight_store_gpu_blocks:
1216 + return None
1217 + hash_idx = boundary_tokens // self.hash_block_size - 1
1218 + if hash_idx >= len(request.block_hashes):
1219 + return None
1220 + block_hash = make_block_hash_with_group_id(
1221 + request.block_hashes[hash_idx], self.fa_gidx
1222 + )
1223 + # Only worth copying when the boundary key is actually registered on
1224 + # the source block; completion replays the block's own hashes.
1225 + if (
1226 + gpu_block.block_hash != block_hash
1227 + and block_hash
1228 + not in self._gpu_block_pool.cached_block_hashes_by_block.get(
1229 + gpu_block_id, ()
1230 + )
1231 + ):
1232 + return None
1233 + if self.cpu_block_pool.cached_block_hash_to_block.get_one_block(block_hash):
1234 + return None
1235 + return gpu_block_id
1236 +
1237 + def _free_pending_cpu_hit(
1238 + self, pending: tuple[tuple[list["KVCacheBlock"], ...], int, int]
1239 + ) -> None:
1240 """Release the temporary CPU block pin taken in get_num_new_matched_tokens()."""
1042 - cpu_hit_blocks, _ = pending
1241 + cpu_hit_blocks, _hit_length, _num_computed_tokens = pending
1242 blocks_to_free = [
1243 blk for grp in cpu_hit_blocks for blk in grp if not blk.is_null
1244 ]
@@ -1140,6 +1339,7 @@ class SimpleCPUOffloadScheduler:
1339 if event_idx in self._abandoned_store_event_to_blocks
1340 }
1341 self._cursor = None
1342 + self.boundary_store_stats = BoundaryStoreStats()
1343 # NOTE: _load_event_counter / _store_event_counter are not
1344 # reset as they are monotonic and must stay ahead of the workers
1345 # high-water marks to avoid event index collisions