[3/N][Core][KV Connector] Support reliable partial-tail KV offload for sub-block prompts (#49502)
Signed-off-by: Dao Le <Dao007forever@gmail.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Dao007forever committed
Jul 26, 2026 at 23:22 UTC
d74285661064541f12be14e49a16defa27de67c6
13 files changed
+1448
-49
tests/v1/core/prefix_cache/test_partial_prefix_cache_hits.py
+293
@@ -223,6 +223,299 @@ def test_hybrid_mamba_partial_tail_owner_uses_cow_on_continue():
223
assert moved[0].block_hash_num_tokens == 6
224
225
226
+def test_take_partial_tail_offloads_returns_cow_target():
227
+ """The connector offload hand-off exposes the mamba CoW *target* block Y
228
+ (the durable boundary state), not the overwritten source X, and only at
229
+ the CoW step."""
230
+ hash_block_size = 2
231
+ block_size = 2 * hash_block_size
232
+ kv_cache_config = KVCacheConfig(
233
+ num_blocks=24,
234
+ kv_cache_tensors=[],
235
+ kv_cache_groups=[
236
+ KVCacheGroupSpec(
237
+ ["full"],
238
+ FullAttentionSpec(
239
+ block_size=hash_block_size,
240
+ num_kv_heads=1,
241
+ head_size=1,
242
+ dtype=torch.float32,
243
+ ),
244
+ ),
245
+ KVCacheGroupSpec(
246
+ ["mamba"],
247
+ MambaSpec(
248
+ block_size=block_size,
249
+ shapes=(1, 1),
250
+ dtypes=(torch.float32,),
251
+ mamba_cache_mode="align",
252
+ ),
253
+ ),
254
+ ],
255
+ )
256
+ manager = make_kv_cache_manager(
257
+ kv_cache_config=kv_cache_config,
258
+ max_model_len=8192,
259
+ enable_caching=True,
260
+ hash_block_size=hash_block_size,
261
+ )
262
+
263
+ req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
264
+ computed_blocks, num_computed, _ = manager.get_computed_blocks(req0)
265
+ assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
266
+
267
+ # Step A registered the partial tail but has not CoW'd yet: no offload.
268
+ assert manager.take_partial_tail_offloads() == {}
269
+
270
+ partial_mamba_hash = req0.block_hashes[6 // hash_block_size - 1]
271
+ source_block = manager.block_pool.get_cached_block(
272
+ partial_mamba_hash, kv_cache_group_ids=[1]
273
+ )
274
+ assert source_block is not None
275
+ source_block_id = source_block[0].block_id
276
+
277
+ # Step B: the producer continues, triggering the CoW X->Y.
278
+ req0.num_computed_tokens = 6
279
+ req0.append_output_token_ids([3])
280
+ assert manager.allocate_slots(req0, 1) is not None
281
+
282
+ offloads = manager.take_partial_tail_offloads()
283
+ assert list(offloads.keys()) == ["0"]
284
+ assert len(offloads["0"]) == 1
285
+ group_id, block_id, boundary_tokens = offloads["0"][0]
286
+ assert group_id == 1 # the mamba group
287
+ assert boundary_tokens == 6
288
+ copies, _ = manager.take_kv_cache_block_copies()
289
+ cow_copy = next(c for c in copies if c.src_block_id == source_block_id)
290
+ # The offload points at the durable CoW target Y, not the overwritten X.
291
+ assert block_id == cow_copy.dst_block_id
292
+ assert block_id != source_block_id
293
+ # Draining clears it.
294
+ assert manager.take_partial_tail_offloads() == {}
295
+
296
+ # The hand-off pinned Y (its CoW retention is released after this step,
297
+ # and Y is off the request block table); freeing the request unpins it.
298
+ cow_block = manager.block_pool.blocks[block_id]
299
+ pinned_ref = cow_block.ref_cnt
300
+ assert pinned_ref >= 1
301
+ manager.free(req0)
302
+ assert cow_block.ref_cnt == pinned_ref - 1
303
+
304
+
305
+def test_partial_tail_pin_survives_released_cow_retention():
306
+ """If the CoW retention is released before the hand-off is drained
307
+ (immediate-free mode), the drain must rescue the cow block from the free
308
+ queue: a raw ref increment would leave a ref>0 block allocatable, and the
309
+ next allocation would pop it and assert."""
310
+ hash_block_size = 2
311
+ block_size = 2 * hash_block_size
312
+ kv_cache_config = KVCacheConfig(
313
+ num_blocks=24,
314
+ kv_cache_tensors=[],
315
+ kv_cache_groups=[
316
+ KVCacheGroupSpec(
317
+ ["full"],
318
+ FullAttentionSpec(
319
+ block_size=hash_block_size,
320
+ num_kv_heads=1,
321
+ head_size=1,
322
+ dtype=torch.float32,
323
+ ),
324
+ ),
325
+ KVCacheGroupSpec(
326
+ ["mamba"],
327
+ MambaSpec(
328
+ block_size=block_size,
329
+ shapes=(1, 1),
330
+ dtypes=(torch.float32,),
331
+ mamba_cache_mode="align",
332
+ ),
333
+ ),
334
+ ],
335
+ )
336
+ manager = make_kv_cache_manager(
337
+ kv_cache_config=kv_cache_config,
338
+ max_model_len=8192,
339
+ enable_caching=True,
340
+ hash_block_size=hash_block_size,
341
+ )
342
+ req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
343
+ computed_blocks, num_computed, _ = manager.get_computed_blocks(req0)
344
+ assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
345
+ req0.num_computed_tokens = 6
346
+ req0.append_output_token_ids([3])
347
+ assert manager.allocate_slots(req0, 1) is not None
348
+
349
+ # Retention released before the drain (defer_block_free=False ordering).
350
+ _copies, retained = manager.take_kv_cache_block_copies()
351
+ manager.block_pool.free_blocks(retained)
352
+
353
+ offloads = manager.take_partial_tail_offloads()
354
+ ((_group_id, block_id, boundary_tokens),) = offloads["0"]
355
+ assert boundary_tokens == 6
356
+ cow_block = manager.block_pool.blocks[block_id]
357
+ assert cow_block.ref_cnt == 1
358
+
359
+ # The pinned block is out of the free queue: draining every free block
360
+ # neither trips the allocator's ref_cnt assert nor hands it out.
361
+ new_blocks = manager.block_pool.get_new_blocks(
362
+ manager.block_pool.get_num_free_blocks()
363
+ )
364
+ assert block_id not in {b.block_id for b in new_blocks}
365
+
366
+
367
+def test_partial_tail_offload_dropped_when_request_freed_before_drain():
368
+ """A hand-off recorded in the same scheduling pass as the request's death
369
+ must not be drained: its release hook has already run, so draining would
370
+ leak a pinned block."""
371
+ hash_block_size = 2
372
+ block_size = 2 * hash_block_size
373
+ kv_cache_config = KVCacheConfig(
374
+ num_blocks=24,
375
+ kv_cache_tensors=[],
376
+ kv_cache_groups=[
377
+ KVCacheGroupSpec(
378
+ ["full"],
379
+ FullAttentionSpec(
380
+ block_size=hash_block_size,
381
+ num_kv_heads=1,
382
+ head_size=1,
383
+ dtype=torch.float32,
384
+ ),
385
+ ),
386
+ KVCacheGroupSpec(
387
+ ["mamba"],
388
+ MambaSpec(
389
+ block_size=block_size,
390
+ shapes=(1, 1),
391
+ dtypes=(torch.float32,),
392
+ mamba_cache_mode="align",
393
+ ),
394
+ ),
395
+ ],
396
+ )
397
+ manager = make_kv_cache_manager(
398
+ kv_cache_config=kv_cache_config,
399
+ max_model_len=8192,
400
+ enable_caching=True,
401
+ hash_block_size=hash_block_size,
402
+ )
403
+ req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
404
+ computed_blocks, num_computed, _ = manager.get_computed_blocks(req0)
405
+ assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
406
+ req0.num_computed_tokens = 6
407
+ req0.append_output_token_ids([3])
408
+ assert manager.allocate_slots(req0, 1) is not None
409
+
410
+ # The request dies (preempt/abort) before the scheduler drains.
411
+ manager.block_pool.free_blocks(manager.pop_blocks_for_free(req0))
412
+ assert manager.take_partial_tail_offloads() == {}
413
+
414
+
415
+def test_take_partial_tail_offloads_empty_without_partial_tail():
416
+ """A prompt ending on a block boundary registers no partial tail, so there
417
+ is nothing to offload."""
418
+ hash_block_size = 2
419
+ block_size = 2 * hash_block_size
420
+ kv_cache_config = KVCacheConfig(
421
+ num_blocks=24,
422
+ kv_cache_tensors=[],
423
+ kv_cache_groups=[
424
+ KVCacheGroupSpec(
425
+ ["full"],
426
+ FullAttentionSpec(
427
+ block_size=hash_block_size,
428
+ num_kv_heads=1,
429
+ head_size=1,
430
+ dtype=torch.float32,
431
+ ),
432
+ ),
433
+ KVCacheGroupSpec(
434
+ ["mamba"],
435
+ MambaSpec(
436
+ block_size=block_size,
437
+ shapes=(1, 1),
438
+ dtypes=(torch.float32,),
439
+ mamba_cache_mode="align",
440
+ ),
441
+ ),
442
+ ],
443
+ )
444
+ manager = make_kv_cache_manager(
445
+ kv_cache_config=kv_cache_config,
446
+ max_model_len=8192,
447
+ enable_caching=True,
448
+ hash_block_size=hash_block_size,
449
+ )
450
+
451
+ # 4-token prompt ends exactly on the mamba block boundary (block_size=4).
452
+ req0 = make_request("0", [0, 0, 1, 1], hash_block_size, sha256)
453
+ computed_blocks, num_computed, _ = manager.get_computed_blocks(req0)
454
+ assert manager.allocate_slots(req0, 4, num_computed, computed_blocks) is not None
455
+ assert manager.take_partial_tail_offloads() == {}
456
+
457
+ req0.num_computed_tokens = 4
458
+ req0.append_output_token_ids([2])
459
+ assert manager.allocate_slots(req0, 1) is not None
460
+ assert manager.take_partial_tail_offloads() == {}
461
+
462
+
463
+def test_truncate_computed_blocks_preserves_sparse_prefix_positions():
464
+ """truncate_computed_blocks slices each group by its own block size,
465
+ keeps null placeholders in the retained prefix, and leaves the original
466
+ lookup result untouched (pure view, no refcount changes)."""
467
+ hash_block_size = 2
468
+ kv_cache_config = KVCacheConfig(
469
+ num_blocks=24,
470
+ kv_cache_tensors=[],
471
+ kv_cache_groups=[
472
+ KVCacheGroupSpec(
473
+ ["full"],
474
+ FullAttentionSpec(
475
+ block_size=hash_block_size,
476
+ num_kv_heads=1,
477
+ head_size=1,
478
+ dtype=torch.float32,
479
+ ),
480
+ ),
481
+ KVCacheGroupSpec(
482
+ ["mamba"],
483
+ MambaSpec(
484
+ block_size=2 * hash_block_size,
485
+ shapes=(1, 1),
486
+ dtypes=(torch.float32,),
487
+ mamba_cache_mode="align",
488
+ ),
489
+ ),
490
+ ],
491
+ )
492
+ manager = make_kv_cache_manager(
493
+ kv_cache_config=kv_cache_config,
494
+ max_model_len=8192,
495
+ enable_caching=True,
496
+ hash_block_size=hash_block_size,
497
+ )
498
+ producer = make_request("producer", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
499
+ blocks, num_computed, _ = manager.get_computed_blocks(producer)
500
+ assert manager.allocate_slots(producer, 6, num_computed, blocks) is not None
501
+ manager.free(producer)
502
+ manager.new_step_starts()
503
+
504
+ consumer = make_request(
505
+ "consumer", [0, 0, 1, 1, 2, 2, 3, 3], hash_block_size, sha256
506
+ )
507
+ blocks, num_computed, _ = manager.get_computed_blocks(consumer)
508
+ assert num_computed == 6
509
+ assert [len(group) for group in blocks.blocks] == [3, 2]
510
+ assert blocks.blocks[1][0].is_null
511
+
512
+ truncated = manager.truncate_computed_blocks(blocks, 4)
513
+
514
+ assert [len(group) for group in truncated.blocks] == [2, 1]
515
+ assert truncated.blocks[1][0].is_null
516
+ assert [len(group) for group in blocks.blocks] == [3, 2]
517
+
518
+
519
def test_hybrid_mamba_partial_tail_owner_continue_preserves_later_hit():
520
hash_block_size = 2
521
block_size = 2 * hash_block_size
tests/v1/kv_connector/unit/test_mooncake_store_coordinator.py
+55
@@ -3,6 +3,8 @@
3
4
from math import lcm
5
6
+import torch
7
+
8
from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.coordinator import ( # noqa: E501
9
ExternalCachedBlockPool,
10
MooncakeStoreCoordinator,
@@ -14,10 +16,20 @@ from vllm.v1.core.kv_cache_utils import BlockHash
16
from vllm.v1.kv_cache_interface import (
17
FullAttentionSpec,
18
KVCacheGroupSpec,
19
+ MambaSpec,
20
SlidingWindowSpec,
21
)
22
23
24
+def _mamba_align(block_size=32):
25
+ return MambaSpec(
26
+ block_size=block_size,
27
+ shapes=((1, 1),),
28
+ dtypes=(torch.float32,),
29
+ mamba_cache_mode="align",
30
+ )
31
+
32
+
33
def _make_coord(groups, hash_block_size, use_eagle=False, retention_interval=None):
34
"""Construct a coordinator using the natural LCM of group block sizes as
35
the scheduler block size — mirrors ``resolve_kv_cache_block_sizes`` for
@@ -196,6 +208,49 @@ def test_coordinator_group_block_size_double_hash():
208
assert hit % 32 == 0
209
210
211
+# ----- Fine-grained partial hits (full attention + mamba "align") -----
212
+
213
+
214
+def test_coordinator_fine_grained_partial_tail_hit():
215
+ """K3 shape: FA + mamba-align, block_size=32 over hash_block_size=16. When
216
+ both groups have the sub-block boundary hash, the reconciled hit lands on
217
+ the hash boundary (48), not the block boundary (32)."""
218
+ groups = [
219
+ KVCacheGroupSpec(["L0"], _full(32)),
220
+ KVCacheGroupSpec(["L1"], _mamba_align(32)),
221
+ ]
222
+ coord = _make_coord(groups, hash_block_size=16)
223
+ assert coord.enable_partial_hash_hits
224
+ hs = _hashes(4) # 4 hash units of 16 = 64 tokens; block 0 = [0,32), etc.
225
+ # Both groups: full block 0 (key = last sub-hash hs[1]) + partial boundary
226
+ # at token 48 (key = hs[2]). No hs[3] -> block 1 is not full.
227
+ exists = {(g, bytes(h)) for g in (0, 1) for h in (hs[1], hs[2])}
228
+ cmap = ExternalCachedBlockPool(16, exists)
229
+ _masks, hit = coord.find_longest_cache_hit(
230
+ hs, max_length=64, cached_block_pool=cmap
231
+ )
232
+ assert hit == 48
233
+
234
+
235
+def test_coordinator_fine_grained_clips_when_one_group_missing_tail():
236
+ """If only one group has the sub-block boundary, min-convergence clips the
237
+ reconciled hit back to the block boundary (32)."""
238
+ groups = [
239
+ KVCacheGroupSpec(["L0"], _full(32)),
240
+ KVCacheGroupSpec(["L1"], _mamba_align(32)),
241
+ ]
242
+ coord = _make_coord(groups, hash_block_size=16)
243
+ hs = _hashes(4)
244
+ # Full block 0 for both; partial boundary hs[2] only for FA (group 0).
245
+ exists = {(g, bytes(hs[1])) for g in (0, 1)}
246
+ exists |= {(0, bytes(hs[2]))}
247
+ cmap = ExternalCachedBlockPool(16, exists)
248
+ _masks, hit = coord.find_longest_cache_hit(
249
+ hs, max_length=64, cached_block_pool=cmap
250
+ )
251
+ assert hit == 32
252
+
253
+
254
# ----- store_mask -----
255
256
tests/v1/kv_connector/unit/test_mooncake_store_hma_e2e.py
+244
-3
@@ -32,6 +32,7 @@ from vllm.v1.kv_cache_interface import (
32
KVCacheConfig,
33
KVCacheGroupSpec,
34
KVCacheTensor,
35
+ MambaSpec,
36
SlidingWindowSpec,
37
)
38
@@ -333,14 +334,14 @@ def test_recv_skips_swa_blocks_before_window():
334
335
def test_chunked_token_database_hash_block_size_smaller_than_block_size():
336
"""DSv4-style: hash_block_size=4, group block_size=16 — process_tokens
336
- keys each 16-token chunk by its last fine hash, keeping the Mooncake key
337
- at one digest instead of concatenating all 4 fine hashes."""
337
+ keys each chunk by its ending fine hash, including a partial tail."""
338
md = KeyMetadata("m", 0, 0, 0, 0, group_id=3)
339
db = ChunkedTokenDatabase(md, block_size=16, hash_block_size=4)
340
db.set_kv_caches_base_addr([0])
341
db.set_block_len([512])
342
- # 8 fine-grained hashes (32 tokens at hash_block_size=4) → 2 group chunks.
342
fine_hashes = [BlockHash(bytes([i + 1]) * 4) for i in range(8)]
343
+
344
+ # 8 fine-grained hashes (32 tokens at hash_block_size=4) → 2 group chunks.
345
out = list(db.process_tokens(token_len=32, block_hashes=fine_hashes))
346
assert len(out) == 2
347
assert out[0][0] == 0 and out[0][1] == 16
@@ -349,3 +350,243 @@ def test_chunked_token_database_hash_block_size_smaller_than_block_size():
350
# prior three.
351
assert out[0][2].hex() == fine_hashes[3].hex()
352
assert out[1][2].hex() == fine_hashes[7].hex()
353
+
354
+ # Sub-block hit: emit the partial chunk under its ending fine hash.
355
+ out = list(db.process_tokens(token_len=12, block_hashes=fine_hashes[:3]))
356
+ assert [(s, e) for s, e, _ in out] == [(0, 12)]
357
+ assert out[0][2].hex() == fine_hashes[2].hex()
358
+
359
+ # Cross-block hit: emit both the full chunk and its partial tail.
360
+ out = list(db.process_tokens(token_len=28, block_hashes=fine_hashes[:7]))
361
+ assert [(s, e) for s, e, _ in out] == [(0, 16), (16, 28)]
362
+ assert out[0][2].hex() == fine_hashes[3].hex()
363
+ assert out[1][2].hex() == fine_hashes[6].hex()
364
+
365
+
366
+def test_sub_block_partial_tail_offload_reads_cow_block():
367
+ """Sub-block prompt (the 900/128/1536 shape, scaled to 12/4/16): the
368
+ partial tail is offloaded for both groups under the boundary sub-hash. The
369
+ full-attention block is read from the request block table; the mamba block
370
+ is the core-provided CoW target, not block_ids."""
371
+ full = FullAttentionSpec(block_size=16, num_kv_heads=8, head_size=64, dtype=None)
372
+ mamba = MambaSpec(
373
+ block_size=16,
374
+ shapes=((1, 1),),
375
+ dtypes=(torch.float32,),
376
+ mamba_cache_mode="align",
377
+ )
378
+ groups = [
379
+ KVCacheGroupSpec(["L0"], full),
380
+ KVCacheGroupSpec(["L1"], mamba),
381
+ ]
382
+ coord = MooncakeStoreCoordinator(groups, scheduler_block_size=16, hash_block_size=4)
383
+ assert coord.enable_partial_hash_hits
384
+
385
+ class _RecordingStore(_DictStore):
386
+ def __init__(self):
387
+ super().__init__()
388
+ self.puts: dict[str, list[int]] = {}
389
+
390
+ def batch_put_from_multi_buffers(self, keys, addrs, sizes, *a, **k):
391
+ for key, addr in zip(keys, addrs):
392
+ self.puts[key] = addr
393
+ return super().batch_put_from_multi_buffers(keys, addrs, sizes, *a, **k)
394
+
395
+ store = _RecordingStore()
396
+ token_dbs = []
397
+ for g_idx in range(2):
398
+ db = ChunkedTokenDatabase(
399
+ KeyMetadata("m", 0, 0, 0, 0, group_id=g_idx),
400
+ block_size=16,
401
+ hash_block_size=4,
402
+ )
403
+ db.set_kv_caches_base_addr([g_idx * 10_000])
404
+ db.set_block_len([512])
405
+ token_dbs.append(db)
406
+
407
+ send = KVCacheStoreSendingThread(
408
+ store=store,
409
+ coord=coord,
410
+ token_databases=token_dbs,
411
+ block_size=16,
412
+ tp_rank=0,
413
+ group_put_steps=[1, 1],
414
+ kv_role="kv_both",
415
+ ready_event=threading.Event(),
416
+ replicate_config=MagicMock(),
417
+ )
418
+
419
+ # The surrounding metadata may describe a longer resumed replay, but the
420
+ # handoff identifies the exact state boundary to persist.
421
+ hs = [BlockHash(bytes([i + 1]) * 4) for i in range(5)]
422
+ mamba_cow_block = 7
423
+ req = ReqMeta(
424
+ req_id="r0",
425
+ token_len_chunk=0,
426
+ block_ids=([1], [2]),
427
+ block_hashes=hs,
428
+ can_save=True,
429
+ num_prompt_tokens=20,
430
+ partial_tail_offloads=[(1, mamba_cow_block, 12)],
431
+ )
432
+
433
+ send._maybe_offload_partial_tail(req)
434
+
435
+ # boundary = 12 // 4 * 4 = 12 -> keyed by hs[12 // 4 - 1] = hs[2].
436
+ partial_hash = hs[2]
437
+ fa_key = token_dbs[0].key_for(partial_hash)
438
+ mamba_key = token_dbs[1].key_for(partial_hash)
439
+ assert set(store.puts) == {fa_key, mamba_key}
440
+ # FA reads block_ids[0][0] = block 1: addr = base(0) + 1 * 512.
441
+ assert store.puts[fa_key] == [512]
442
+ # Mamba reads the CoW block 7, not block_ids[1][0]=2.
443
+ assert store.puts[mamba_key] == [10_000 + mamba_cow_block * 512]
444
+
445
+
446
+def test_offload_syncs_event_before_put():
447
+ """An offload-carrying meta synchronizes its CoW-fence event before the
448
+ store put reads the blocks, then completes in one pass and drains the
449
+ completion counter."""
450
+ full = FullAttentionSpec(block_size=16, num_kv_heads=8, head_size=64, dtype=None)
451
+ mamba = MambaSpec(
452
+ block_size=16,
453
+ shapes=((1, 1),),
454
+ dtypes=(torch.float32,),
455
+ mamba_cache_mode="align",
456
+ )
457
+ groups = [
458
+ KVCacheGroupSpec(["L0"], full),
459
+ KVCacheGroupSpec(["L1"], mamba),
460
+ ]
461
+ coord = MooncakeStoreCoordinator(groups, scheduler_block_size=16, hash_block_size=4)
462
+ event = MagicMock()
463
+
464
+ class _FencedStore(_DictStore):
465
+ def batch_put_from_multi_buffers(self, keys, addrs, sizes, *a, **k):
466
+ assert event.synchronize.called, "put must run after the event sync"
467
+ return super().batch_put_from_multi_buffers(keys, addrs, sizes, *a, **k)
468
+
469
+ store = _FencedStore()
470
+ token_dbs = []
471
+ for g_idx in range(2):
472
+ db = ChunkedTokenDatabase(
473
+ KeyMetadata("m", 0, 0, 0, 0, group_id=g_idx),
474
+ block_size=16,
475
+ hash_block_size=4,
476
+ )
477
+ db.set_kv_caches_base_addr([g_idx * 10_000])
478
+ db.set_block_len([512])
479
+ token_dbs.append(db)
480
+
481
+ send = KVCacheStoreSendingThread(
482
+ store=store,
483
+ coord=coord,
484
+ token_databases=token_dbs,
485
+ block_size=16,
486
+ tp_rank=0,
487
+ group_put_steps=[1, 1],
488
+ kv_role="kv_both",
489
+ ready_event=threading.Event(),
490
+ replicate_config=MagicMock(),
491
+ )
492
+
493
+ hs = [BlockHash(bytes([i + 1]) * 4) for i in range(3)]
494
+ req = ReqMeta(
495
+ req_id="r1",
496
+ token_len_chunk=0,
497
+ block_ids=([1], [2]),
498
+ block_hashes=hs,
499
+ can_save=True,
500
+ num_prompt_tokens=12,
501
+ partial_tail_offloads=[(1, 7, 12)],
502
+ )
503
+ req.current_event = event
504
+ send.add_stored_request("r1")
505
+
506
+ send.request_queue.put(req)
507
+ send._handle_request(send.request_queue.get())
508
+ assert send.request_queue.qsize() == 0
509
+ assert store._data
510
+ assert send.stored_requests["r1"] == 0
511
+ event.synchronize.assert_called_once()
512
+
513
+
514
+def test_sub_block_partial_tail_offload_covers_smaller_group_blocks():
515
+ """The K3-shaped 900/128/1536 scenario scaled to 12/4/16, with a
516
+ full-attention group whose block (4) is smaller than the lcm (16): the
517
+ offload must persist every FA block up to the boundary — the normal save
518
+ floors to the lcm, so those blocks are otherwise never written and the
519
+ consumer's per-group lookup would miss. The mamba boundary block still
520
+ reads the core-provided CoW target."""
521
+ full = FullAttentionSpec(block_size=4, num_kv_heads=8, head_size=64, dtype=None)
522
+ mamba = MambaSpec(
523
+ block_size=16,
524
+ shapes=((1, 1),),
525
+ dtypes=(torch.float32,),
526
+ mamba_cache_mode="align",
527
+ )
528
+ groups = [
529
+ KVCacheGroupSpec(["L0"], full),
530
+ KVCacheGroupSpec(["L1"], mamba),
531
+ ]
532
+ coord = MooncakeStoreCoordinator(groups, scheduler_block_size=16, hash_block_size=4)
533
+ assert coord.enable_partial_hash_hits
534
+
535
+ class _RecordingStore(_DictStore):
536
+ def __init__(self):
537
+ super().__init__()
538
+ self.puts: dict[str, list[int]] = {}
539
+
540
+ def batch_put_from_multi_buffers(self, keys, addrs, sizes, *a, **k):
541
+ for key, addr in zip(keys, addrs):
542
+ self.puts[key] = addr
543
+ return super().batch_put_from_multi_buffers(keys, addrs, sizes, *a, **k)
544
+
545
+ store = _RecordingStore()
546
+ token_dbs = []
547
+ for g_idx, block_size in enumerate([4, 16]):
548
+ db = ChunkedTokenDatabase(
549
+ KeyMetadata("m", 0, 0, 0, 0, group_id=g_idx),
550
+ block_size=block_size,
551
+ hash_block_size=4,
552
+ )
553
+ db.set_kv_caches_base_addr([g_idx * 10_000])
554
+ db.set_block_len([512])
555
+ token_dbs.append(db)
556
+
557
+ send = KVCacheStoreSendingThread(
558
+ store=store,
559
+ coord=coord,
560
+ token_databases=token_dbs,
561
+ block_size=16,
562
+ tp_rank=0,
563
+ group_put_steps=[1, 1],
564
+ kv_role="kv_both",
565
+ ready_event=threading.Event(),
566
+ replicate_config=MagicMock(),
567
+ )
568
+
569
+ hs = [BlockHash(bytes([i + 1]) * 4) for i in range(3)] # 3 hash units = 12 tok
570
+ mamba_cow_block = 7
571
+ req = ReqMeta(
572
+ req_id="r2",
573
+ token_len_chunk=0,
574
+ block_ids=([1, 2, 3], [4]),
575
+ block_hashes=hs,
576
+ can_save=True,
577
+ num_prompt_tokens=12,
578
+ partial_tail_offloads=[(1, mamba_cow_block, 12)],
579
+ )
580
+
581
+ send._maybe_offload_partial_tail(req)
582
+
583
+ # FA (block 4): full blocks ending at 4, 8 and 12, keyed by their normal
584
+ # block-end hashes; mamba (block 16): the partial boundary block under
585
+ # the boundary sub-hash, read from the CoW target.
586
+ expected = {
587
+ token_dbs[0].key_for(hs[0]): [1 * 512],
588
+ token_dbs[0].key_for(hs[1]): [2 * 512],
589
+ token_dbs[0].key_for(hs[2]): [3 * 512],
590
+ token_dbs[1].key_for(hs[2]): [10_000 + mamba_cow_block * 512],
591
+ }
592
+ assert store.puts == expected
tests/v1/kv_connector/unit/test_mooncake_store_scheduler.py
+231
-1
@@ -13,11 +13,15 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.scheduler impor
13
)
14
15
16
-def _make_bare_scheduler() -> MooncakeStoreScheduler:
16
+def _make_bare_scheduler(
17
+ *, hash_block_size: int = 16, enable_partial_hash_hits: bool = False
18
+) -> MooncakeStoreScheduler:
19
scheduler = object.__new__(MooncakeStoreScheduler)
20
scheduler.kv_role = "kv_both"
21
scheduler.lookup_async = False
22
scheduler._block_size = 16
23
+ scheduler._hash_block_size = hash_block_size
24
+ scheduler.enable_partial_hash_hits = enable_partial_hash_hits
25
scheduler.load_specs = {}
26
scheduler._unfinished_request_ids = {"req-0"}
27
scheduler._unfinished_requests = {}
@@ -136,6 +140,7 @@ def test_preemption_resets_tracker_before_request_finished():
140
block_hashes=[b"h0", b"h1"],
141
prefill_end_tokens=48,
142
)
143
+ scheduler._request_trackers["req-0"].has_pending_offload = True
144
145
scheduler.build_connector_meta(_make_preemption_scheduler_output())
146
@@ -144,6 +149,7 @@ def test_preemption_resets_tracker_before_request_finished():
149
assert tracker.allocated_block_ids == ()
150
assert tracker.num_saved_tokens == 0
151
assert tracker.token_ids is None
152
+ assert tracker.has_pending_offload is False
153
assert tracker.prefill_end_tokens == 0
154
request = SimpleNamespace(request_id="req-0")
155
assert scheduler.request_finished(request, ([0, 1],)) == (False, None)
@@ -536,3 +542,227 @@ def test_full_external_hit_with_full_local_hit_skips_load():
542
assert need_to_allocate == 0
543
assert load_async is False
544
assert "req-0" not in scheduler.load_specs
545
+
546
+
547
+def test_partial_hash_hit_block_aligned_local_loads_partial_tail():
548
+ # Fine-grained on (hash=4, block=16): a block-aligned local hit can pull a
549
+ # sub-block remote hit (24 = a hash boundary inside block 1). Loads [16, 24).
550
+ scheduler = _make_bare_scheduler(hash_block_size=4, enable_partial_hash_hits=True)
551
+ scheduler.load_async = True
552
+ scheduler.client = _StubLookupClient(hit_tokens=24)
553
+
554
+ request = SimpleNamespace(
555
+ request_id="req-0",
556
+ num_tokens=32,
557
+ block_hashes=[b"h0", b"h1", b"h2", b"h3", b"h4", b"h5", b"h6", b"h7"],
558
+ )
559
+
560
+ need_to_allocate, load_async = scheduler.get_num_new_matched_tokens(
561
+ request, num_computed_tokens=16
562
+ )
563
+
564
+ assert need_to_allocate == 8
565
+ assert load_async is True
566
+ load_spec = scheduler.load_specs["req-0"]
567
+ assert load_spec.vllm_cached_tokens == 16
568
+ assert load_spec.kvpool_cached_tokens == 24
569
+
570
+
571
+def test_partial_hash_hit_no_remote_gain_skips_load():
572
+ # Core always presents a block-aligned local hit (it floors a sub-block
573
+ # tail before calling the connector). When the remote hit does not exceed
574
+ # that block-aligned local hit, nothing is loaded.
575
+ scheduler = _make_bare_scheduler(hash_block_size=4, enable_partial_hash_hits=True)
576
+ scheduler.load_async = True
577
+ scheduler.client = _StubLookupClient(hit_tokens=16)
578
+
579
+ request = SimpleNamespace(
580
+ request_id="req-0",
581
+ num_tokens=32,
582
+ block_hashes=[b"h0", b"h1", b"h2", b"h3", b"h4", b"h5", b"h6", b"h7"],
583
+ )
584
+
585
+ need_to_allocate, load_async = scheduler.get_num_new_matched_tokens(
586
+ request, num_computed_tokens=16
587
+ )
588
+
589
+ assert need_to_allocate == 0
590
+ assert load_async is False
591
+ assert "req-0" not in scheduler.load_specs
592
+
593
+
594
+def test_sub_block_prompt_looks_up_with_fine_grained():
595
+ # A prompt smaller than one block (12 < block 16). With fine-grained partial
596
+ # hits the sub-block prefix is worth looking up (floor is the hash unit 4,
597
+ # not a full block), so a remote partial hit is loaded. Pre-change the
598
+ # block-size floor returned (0, False) for such prompts.
599
+ scheduler = _make_bare_scheduler(hash_block_size=4, enable_partial_hash_hits=True)
600
+ scheduler.load_async = True
601
+ scheduler.client = _StubLookupClient(hit_tokens=8)
602
+
603
+ request = SimpleNamespace(
604
+ request_id="req-0",
605
+ num_tokens=12,
606
+ block_hashes=[b"h0", b"h1", b"h2"],
607
+ )
608
+
609
+ need_to_allocate, load_async = scheduler.get_num_new_matched_tokens(
610
+ request, num_computed_tokens=0
611
+ )
612
+
613
+ assert need_to_allocate == 8
614
+ assert load_async is True
615
+ assert scheduler.load_specs["req-0"].kvpool_cached_tokens == 8
616
+
617
+
618
+def test_sub_block_prompt_not_looked_up_without_fine_grained():
619
+ # Without fine-grained partial hits, sub-block prompts still skip the lookup
620
+ # (there is no full block, and no sub-block key granularity).
621
+ scheduler = _make_bare_scheduler()
622
+ scheduler.client = _StubLookupClient(hit_tokens=8)
623
+
624
+ request = SimpleNamespace(
625
+ request_id="req-0",
626
+ num_tokens=12,
627
+ block_hashes=[b"h0", b"h1", b"h2"],
628
+ )
629
+
630
+ need_to_allocate, load_async = scheduler.get_num_new_matched_tokens(
631
+ request, num_computed_tokens=0
632
+ )
633
+
634
+ assert need_to_allocate == 0
635
+ assert load_async is False
636
+ assert "req-0" not in scheduler.load_specs
637
+
638
+
639
+def test_pending_partial_tail_emits_offload_only_reqmeta():
640
+ # A sub-block prompt never produces a block-aligned save, so the partial-
641
+ # tail offload arriving this step is emitted as an offload-only ReqMeta
642
+ # (can_save=True so it takes the normal enqueue path, token_len_chunk=0 so
643
+ # the worker skips the normal save). Pending-offload state delays the free
644
+ # without advancing the normal-save watermark before the put succeeds.
645
+ scheduler = _make_bare_scheduler(hash_block_size=4, enable_partial_hash_hits=True)
646
+ request = SimpleNamespace(
647
+ all_token_ids=list(range(12)),
648
+ block_hashes=[b"h0", b"h1", b"h2"],
649
+ num_output_placeholders=0,
650
+ num_prompt_tokens=12,
651
+ )
652
+ scheduler._unfinished_requests["req-0"] = (request, ([0],))
653
+ scheduler._request_trackers["req-0"] = RequestTracker(
654
+ req_id="req-0",
655
+ token_len=12,
656
+ allocated_block_ids=([0],),
657
+ num_saved_tokens=0,
658
+ token_ids=list(range(12)),
659
+ prefill_end_tokens=12,
660
+ )
661
+
662
+ out = SimpleNamespace(
663
+ finished_req_ids=set(),
664
+ preempted_req_ids=set(),
665
+ scheduled_new_reqs=[],
666
+ scheduled_cached_reqs=SimpleNamespace(
667
+ req_ids=[],
668
+ new_block_ids=[],
669
+ num_computed_tokens=[],
670
+ resumed_req_ids=set(),
671
+ ),
672
+ num_scheduled_tokens={},
673
+ scheduled_spec_decode_tokens={},
674
+ partial_tail_offloads={"req-0": [(1, 7, 12)]},
675
+ )
676
+
677
+ meta = scheduler.build_connector_meta(out)
678
+
679
+ assert len(meta.requests) == 1
680
+ req_meta = meta.requests[0]
681
+ assert req_meta.req_id == "req-0"
682
+ assert req_meta.can_save is True
683
+ assert req_meta.token_len_chunk == 0
684
+ assert req_meta.partial_tail_offloads == [(1, 7, 12)]
685
+ assert req_meta.num_prompt_tokens == 12
686
+ assert req_meta.block_ids == ([0],)
687
+ tracker = scheduler._request_trackers["req-0"]
688
+ assert tracker.num_saved_tokens == 0
689
+ assert tracker.has_pending_offload is True
690
+ request = SimpleNamespace(request_id="req-0")
691
+ assert scheduler.request_finished(request, ([0],)) == (True, None)
692
+
693
+
694
+def test_resumed_partial_tail_uses_handoff_boundary():
695
+ scheduler = _make_bare_scheduler(hash_block_size=4, enable_partial_hash_hits=True)
696
+ request = SimpleNamespace(
697
+ all_token_ids=list(range(20)),
698
+ block_hashes=[b"h0", b"h1", b"h2", b"h3", b"h4"],
699
+ num_output_placeholders=0,
700
+ num_prompt_tokens=12,
701
+ )
702
+ scheduler._unfinished_requests["req-0"] = (request, ([0, 1],))
703
+ scheduler._request_trackers["req-0"] = RequestTracker(
704
+ req_id="req-0",
705
+ token_len=20,
706
+ allocated_block_ids=([0, 1],),
707
+ num_saved_tokens=0,
708
+ token_ids=list(range(20)),
709
+ # Resumption replays prompt + previously generated tokens.
710
+ prefill_end_tokens=20,
711
+ )
712
+
713
+ out = SimpleNamespace(
714
+ finished_req_ids=set(),
715
+ preempted_req_ids=set(),
716
+ scheduled_new_reqs=[],
717
+ scheduled_cached_reqs=SimpleNamespace(
718
+ req_ids=[],
719
+ new_block_ids=[],
720
+ num_computed_tokens=[],
721
+ resumed_req_ids=set(),
722
+ ),
723
+ num_scheduled_tokens={},
724
+ scheduled_spec_decode_tokens={},
725
+ partial_tail_offloads={"req-0": [(1, 7, 12)]},
726
+ )
727
+
728
+ meta = scheduler.build_connector_meta(out)
729
+
730
+ assert len(meta.requests) == 1
731
+ assert meta.requests[0].partial_tail_offloads == [(1, 7, 12)]
732
+ # Ordinary metadata retains the full resumed prefill range.
733
+ assert meta.requests[0].num_prompt_tokens == 20
734
+ tracker = scheduler._request_trackers["req-0"]
735
+ assert tracker.num_saved_tokens == 0
736
+ assert tracker.has_pending_offload is True
737
+
738
+
739
+def test_resumed_partial_tail_attached_to_save_keeps_handoff_boundary():
740
+ scheduler = _make_bare_scheduler(hash_block_size=4, enable_partial_hash_hits=True)
741
+ request = SimpleNamespace(
742
+ all_token_ids=list(range(48)),
743
+ block_hashes=[b"h0", b"h1", b"h2"],
744
+ num_output_placeholders=0,
745
+ num_prompt_tokens=36,
746
+ )
747
+ scheduler._unfinished_requests["req-0"] = (request, ([0, 1],))
748
+ scheduler._request_trackers["req-0"] = RequestTracker(
749
+ req_id="req-0",
750
+ token_len=44,
751
+ allocated_block_ids=([0, 1],),
752
+ num_saved_tokens=32,
753
+ token_ids=list(range(44)),
754
+ prefill_end_tokens=48,
755
+ )
756
+ out = _make_scheduler_output(scheduled_spec_tokens=None)
757
+ out.partial_tail_offloads = {"req-0": [(0, 7, 36)]}
758
+
759
+ meta = scheduler.build_connector_meta(out)
760
+
761
+ assert len(meta.requests) == 1
762
+ assert meta.requests[0].can_save is True
763
+ assert meta.requests[0].partial_tail_offloads == [(0, 7, 36)]
764
+ assert meta.requests[0].num_prompt_tokens == 48
765
+ # Ordinary saving still covers the full resumed prefill range.
766
+ tracker = scheduler._request_trackers["req-0"]
767
+ assert tracker.num_saved_tokens == 48
768
+ assert tracker.has_pending_offload is True
tests/v1/kv_connector/unit/test_mooncake_store_worker.py
+122
@@ -599,6 +599,88 @@ def test_store_sending_thread_retries_skipped_range_after_pressure():
599
assert store.batch_put_from_multi_buffers.call_args.args[0] == keys
600
601
602
+def _make_partial_tail_send_thread(store):
603
+ coord = SimpleNamespace(
604
+ enable_partial_hash_hits=True,
605
+ hash_block_size=4,
606
+ lcm_block_size=16,
607
+ )
608
+ db = ChunkedTokenDatabase(
609
+ KeyMetadata("test-model", 0, 0, 0, 0),
610
+ block_size=4,
611
+ hash_block_size=4,
612
+ )
613
+ db.set_kv_caches_base_addr([0x1000])
614
+ db.set_block_len([256])
615
+ return _make_store_sending_thread(
616
+ store,
617
+ coord=coord,
618
+ token_databases=[db],
619
+ )
620
+
621
+
622
+def _make_partial_tail_req(block_ids: list[int]) -> ReqMeta:
623
+ return ReqMeta(
624
+ req_id="req-a",
625
+ token_len_chunk=0,
626
+ block_ids=(block_ids,),
627
+ block_hashes=[b"a0", b"a1", b"a2"],
628
+ can_save=True,
629
+ partial_tail_offloads=[(1, 7, 12)],
630
+ )
631
+
632
+
633
+def test_partial_tail_offload_skips_null_source_blocks():
634
+ store = MagicMock()
635
+ store.batch_is_exist.side_effect = lambda keys: [0] * len(keys)
636
+ store.batch_put_from_multi_buffers.return_value = [256, 256]
637
+ thread = _make_partial_tail_send_thread(store)
638
+
639
+ assert thread._maybe_offload_partial_tail(_make_partial_tail_req([0, 2, 3]))
640
+
641
+ keys, addrs, _sizes, _replicate_config = (
642
+ store.batch_put_from_multi_buffers.call_args.args
643
+ )
644
+ assert keys == [
645
+ "test-model@tp_rank:0@pcp0@dcp0@pp_rank:0@group:0@6131",
646
+ "test-model@tp_rank:0@pcp0@dcp0@pp_rank:0@group:0@6132",
647
+ ]
648
+ assert addrs == [[0x1000 + 2 * 256], [0x1000 + 3 * 256]]
649
+
650
+
651
+def test_partial_tail_offload_honors_active_pressure_gate():
652
+ store = MagicMock()
653
+ thread = _make_partial_tail_send_thread(store)
654
+ thread._store_pressure_active = True
655
+ thread._skip_store_requests.add("req-a")
656
+ thread.add_stored_request("req-a")
657
+
658
+ thread._handle_request(_make_partial_tail_req([1, 2, 3]))
659
+
660
+ store.batch_is_exist.assert_not_called()
661
+ store.batch_put_from_multi_buffers.assert_not_called()
662
+ assert thread.stored_requests["req-a"] == 0
663
+
664
+
665
+def test_partial_tail_put_failure_activates_pressure_gate():
666
+ store = MagicMock()
667
+ store.batch_is_exist.side_effect = lambda keys: [0] * len(keys)
668
+ store.batch_put_from_multi_buffers.return_value = [256, -200, 256]
669
+ thread = _make_partial_tail_send_thread(store)
670
+ thread.add_stored_request("req-a")
671
+
672
+ thread._handle_request(_make_partial_tail_req([1, 2, 3]))
673
+
674
+ assert thread._store_pressure_active is True
675
+ assert thread._skip_store_requests == {"req-a"}
676
+ assert thread._saved_offset.get("req-a", 0) == 0
677
+ assert thread.stored_requests["req-a"] == 0
678
+
679
+ thread.add_stored_request("req-a")
680
+ thread._handle_request(_make_partial_tail_req([1, 2, 3]))
681
+ assert store.batch_put_from_multi_buffers.call_count == 1
682
+
683
+
684
def test_store_sending_thread_delta_start_rank_saves_second_local_chunk():
685
store = MagicMock()
686
store.batch_is_exist.side_effect = lambda keys: [0] * len(keys)
@@ -1939,6 +2021,46 @@ def test_lookup_partial_prefix_returns_first_hit_length():
2021
assert worker.lookup(48, [b"a0", b"a1", b"a2"]) == 32
2022
2023
2024
+def test_lookup_partial_tail_uses_hash_alignment():
2025
+ """A stored sub-block tail can serve a request extending past it."""
2026
+ from vllm.v1.kv_cache_interface import (
2027
+ FullAttentionSpec,
2028
+ KVCacheGroupSpec,
2029
+ MambaSpec,
2030
+ )
2031
+
2032
+ worker = _make_bare_worker(block_size=16)
2033
+ full = FullAttentionSpec(block_size=16, num_kv_heads=8, head_size=64, dtype=None)
2034
+ mamba = MambaSpec(
2035
+ block_size=16,
2036
+ shapes=((1, 1),),
2037
+ dtypes=(torch.float32,),
2038
+ mamba_cache_mode="align",
2039
+ )
2040
+ worker._kv_cache_groups = [
2041
+ KVCacheGroupSpec(["full"], full),
2042
+ KVCacheGroupSpec(["mamba"], mamba),
2043
+ ]
2044
+ worker.hash_block_size = 4
2045
+ worker.token_dbs = [
2046
+ ChunkedTokenDatabase(
2047
+ KeyMetadata("test-model", 0, 0, 0, 0, group_id=group_id),
2048
+ block_size=16,
2049
+ hash_block_size=4,
2050
+ )
2051
+ for group_id in range(2)
2052
+ ]
2053
+ worker.coord = mooncake_store_worker.MooncakeStoreCoordinator(
2054
+ worker._kv_cache_groups,
2055
+ scheduler_block_size=16,
2056
+ hash_block_size=4,
2057
+ )
2058
+ _refresh_group_tp_replication_factors(worker)
2059
+ worker.store.batch_is_exist.return_value = [0, 0, 1, 0, 0, 1]
2060
+
2061
+ assert worker.lookup(13, [b"h0", b"h1", b"h2"]) == 12
2062
+
2063
+
2064
def test_lookup_full_hit_reuses_existing_boundary():
2065
"""A full hit is re-derived below the request end without another RPC."""
2066
worker = _make_bare_worker(block_size=16)
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/coordinator.py
+53
-13
@@ -21,6 +21,7 @@ from vllm.v1.kv_cache_interface import (
21
FullAttentionSpec,
22
KVCacheGroupSpec,
23
KVCacheSpec,
24
+ MambaSpec,
25
UniformTypeKVCacheSpecs,
26
)
27
from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry
@@ -83,6 +84,9 @@ class MooncakeStoreCoordinator:
84
self.kv_cache_groups = kv_cache_groups
85
self.hash_block_size = hash_block_size
86
self.lcm_block_size = scheduler_block_size
87
+ self.enable_partial_hash_hits = partial_hash_hits_enabled(
88
+ kv_cache_groups, hash_block_size
89
+ )
90
self.use_eagle = use_eagle
91
# Mirror vLLM core's KVCacheCoordinator.retention_interval.
92
self.retention_interval = retention_interval
@@ -94,7 +98,12 @@ class MooncakeStoreCoordinator:
98
self._verify_and_split_kv_cache_groups()
99
100
def align_lookup_length(self, length: int) -> int:
97
- return length // self.lcm_block_size * self.lcm_block_size
101
+ alignment = (
102
+ self.hash_block_size
103
+ if self.enable_partial_hash_hits
104
+ else self.lcm_block_size
105
+ )
106
+ return length // alignment * alignment
107
108
def _verify_and_split_kv_cache_groups(self) -> None:
109
"""Mirrors KVCacheCoordinator.verify_and_split_kv_cache_groups but
@@ -226,9 +235,14 @@ class MooncakeStoreCoordinator:
235
retention_interval: int | None,
236
num_prompt_tokens: int | None,
237
) -> tuple[list[bool] | None, ...]:
229
- assert aligned_token_len % self.lcm_block_size == 0, (
238
+ mask_alignment = (
239
+ self.hash_block_size
240
+ if self.enable_partial_hash_hits
241
+ else self.lcm_block_size
242
+ )
243
+ assert aligned_token_len % mask_alignment == 0, (
244
f"aligned_token_len ({aligned_token_len}) must be a multiple of "
231
- f"lcm_block_size ({self.lcm_block_size})"
245
+ f"{mask_alignment}"
246
)
247
masks: list[list[bool] | None] = []
248
for g_idx, g in enumerate(self.kv_cache_groups):
@@ -278,17 +292,21 @@ class MooncakeStoreCoordinator:
292
one already removed by the lookup.
293
"""
294
eagle_indices = self.eagle_attn_group_indices if apply_eagle else set()
295
+ alignment_tokens = (
296
+ self.hash_block_size
297
+ if self.enable_partial_hash_hits
298
+ else self.lcm_block_size
299
+ )
300
if len(self.attention_groups) == 1:
301
spec, group_ids, manager_cls = self.attention_groups[0]
283
- hashes = self.block_hashes_for_spec(block_hashes, spec)
302
hit_blocks, hit_length = manager_cls.find_longest_cache_hit(
285
- block_hashes=hashes, # type: ignore[arg-type]
303
+ block_hashes=block_hashes, # type: ignore[arg-type]
304
max_length=max_length,
305
kv_cache_group_ids=group_ids,
306
block_pool=cast(BlockPool, cached_block_pool),
307
kv_cache_spec=spec,
308
drop_eagle_block=(0 in eagle_indices),
291
- alignment_tokens=spec.block_size,
309
+ alignment_tokens=alignment_tokens,
310
)
311
num_groups = len(self.kv_cache_groups)
312
blocks_by_group: list[list[KVCacheBlock]] = [[] for _ in range(num_groups)]
@@ -320,17 +338,23 @@ class MooncakeStoreCoordinator:
338
339
drop_eagle_block = idx in eagle_indices and idx not in eagle_verified
340
_max_length = curr_hit_length
323
- if drop_eagle_block:
324
- _max_length = min(curr_hit_length + spec.block_size, max_length)
325
- hashes = self.block_hashes_for_spec(block_hashes, spec)
341
+ if drop_eagle_block and not isinstance(spec, MambaSpec):
342
+ eagle_margin = (
343
+ self.hash_block_size
344
+ if self.enable_partial_hash_hits
345
+ and manager_cls.supports_fine_grained_hash_lookup
346
+ and spec.block_size > self.hash_block_size
347
+ else spec.block_size
348
+ )
349
+ _max_length = min(curr_hit_length + eagle_margin, max_length)
350
hit_blocks, _new_hit_length = manager_cls.find_longest_cache_hit(
327
- block_hashes=hashes, # type: ignore[arg-type]
351
+ block_hashes=block_hashes, # type: ignore[arg-type]
352
max_length=_max_length,
353
kv_cache_group_ids=group_ids,
354
block_pool=cast(BlockPool, cached_block_pool),
355
kv_cache_spec=spec,
356
drop_eagle_block=drop_eagle_block,
333
- alignment_tokens=self.lcm_block_size,
357
+ alignment_tokens=alignment_tokens,
358
)
359
if drop_eagle_block:
360
eagle_verified.add(idx)
@@ -348,10 +372,11 @@ class MooncakeStoreCoordinator:
372
break
373
374
# Truncate full-attention hit_blocks to final converged length;
351
- # other specs already trim themselves inside their hit logic.
375
+ # other specs already trim themselves inside their hit logic. cdiv keeps
376
+ # the partial tail block when hit_length is not block-aligned.
377
spec0, group_ids0, _ = self.attention_groups[0]
378
if isinstance(spec0, FullAttentionSpec):
354
- num_blocks = hit_length // spec0.block_size
379
+ num_blocks = cdiv(hit_length, spec0.block_size)
380
for gid in group_ids0:
381
full_blks = hit_blocks_by_group[gid]
382
assert full_blks is not None
@@ -368,3 +393,18 @@ def _unwrap_spec(spec: KVCacheSpec) -> KVCacheSpec:
393
if isinstance(spec, UniformTypeKVCacheSpecs):
394
return next(iter(spec.kv_cache_specs.values()))
395
return spec
396
+
397
+
398
+def partial_hash_hits_enabled(
399
+ kv_cache_groups: list[KVCacheGroupSpec], hash_block_size: int
400
+) -> bool:
401
+ """Mirror of core's ``HybridKVCacheCoordinator.enable_partial_hash_hits``
402
+ (its dcp == 1 clause holds: the connector rejects hybrid + DCP/PCP > 1).
403
+ Single copy on purpose — scheduler and coordinator must not disagree.
404
+ """
405
+ return any(
406
+ isinstance(spec := _unwrap_spec(g.kv_cache_spec), MambaSpec)
407
+ and spec.mamba_cache_mode == "align"
408
+ and spec.block_size > hash_block_size
409
+ for g in kv_cache_groups
410
+ )
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/data.py
+27
-8
@@ -228,16 +228,28 @@ class ChunkedTokenDatabase:
228
n = len(chunks)
229
starts = np.fromiter((c[0] for c in chunks), dtype=np.int64, count=n)
230
spans = np.fromiter((c[1] for c in chunks), dtype=np.int64, count=n) - starts
231
- assert not (spans % self.block_size).any()
231
+ assert not (spans % self.hash_block_size).any()
232
bids = np.fromiter(
233
(block_ids[i] for i in (starts // self.block_size).tolist()),
234
dtype=np.int64,
235
count=n,
236
)
237
addrs = base[None, :] + bids[:, None] * blen[None, :]
238
- sizes = blen[None, :] * (spans // self.block_size)[:, None]
238
+ block_counts = (spans + self.block_size - 1) // self.block_size
239
+ sizes = blen[None, :] * block_counts[:, None]
240
return addrs.tolist(), sizes.tolist(), bids.tolist()
241
242
+ def prepare_value_for_block(self, block_id: int) -> tuple[list[int], list[int]]:
243
+ """Return addresses and sizes for one physical block slot."""
244
+ addr_list = []
245
+ size_list = []
246
+ length = len(self.block_len)
247
+ for index, base_addr in enumerate(self.kv_caches_base_addr):
248
+ addr = base_addr + block_id * self.block_len[index % length]
249
+ addr_list.append(addr)
250
+ size_list.append(self.block_len[index % length])
251
+ return addr_list, size_list
252
+
253
def process_tokens(
254
self,
255
token_len: int,
@@ -256,7 +268,8 @@ class ChunkedTokenDatabase:
268
rank regardless of where the processed suffix begins.
269
270
Args:
259
- token_len: Total number of tokens.
271
+ token_len: Total number of tokens. Must be hash-block aligned and
272
+ covered by ``block_hashes`` when hashes are present.
273
block_hashes: Block hashes computed at ``hash_block_size`` granularity.
274
When ``block_size > hash_block_size`` each group's ``block_size`` chunk
275
is keyed by its last sub-hash via ``chunk_hashes_for_block_size``.
@@ -269,11 +282,10 @@ class ChunkedTokenDatabase:
282
assert put_step > 0
283
if not block_hashes:
284
return
272
- chunk_hashes: Sequence[BlockHash] = chunk_hashes_for_block_size(
273
- block_hashes, self.hash_block_size, self.block_size
274
- )
285
+ assert token_len % self.hash_block_size == 0
286
+ assert token_len // self.hash_block_size <= len(block_hashes)
287
start_chunk = max(0, cdiv(mask_num, self.block_size))
276
- max_chunks = min(len(chunk_hashes), cdiv(token_len, self.block_size))
288
+ max_chunks = cdiv(token_len, self.block_size)
289
if chunk_mask is not None:
290
max_chunks = min(max_chunks, start_chunk + len(chunk_mask))
291
for chunk_id in range(start_chunk, max_chunks):
@@ -281,9 +293,9 @@ class ChunkedTokenDatabase:
293
continue
294
if chunk_id % put_step != put_step_rank:
295
continue
284
- h = chunk_hashes[chunk_id]
296
start_idx = chunk_id * self.block_size
297
end_idx = min(start_idx + self.block_size, token_len)
298
+ h = block_hashes[end_idx // self.hash_block_size - 1]
299
yield start_idx, end_idx, h
300
301
@@ -306,6 +318,7 @@ class RequestTracker:
318
allocated_block_ids: tuple[list[int], ...]
319
num_saved_tokens: int = 0
320
token_ids: list[int] | None = None
321
+ has_pending_offload: bool = False
322
# Snapshot of the prefill range length at tracker creation time.
323
# For a fresh request this is len(prompt). For a resumed-from-preemption
324
# request it includes previously-generated tokens, which are re-prefilled.
@@ -316,6 +329,7 @@ class RequestTracker:
329
self.allocated_block_ids = ()
330
self.num_saved_tokens = 0
331
self.token_ids = None
332
+ self.has_pending_offload = False
333
self.prefill_end_tokens = 0
334
335
def update(
@@ -352,6 +366,11 @@ class ReqMeta:
366
367
token_ids: list[int] | None = None
368
num_prompt_tokens: int | None = None
369
+ # Core-provided per-mamba-group
370
+ # (group_id, cow_block_id, boundary_tokens) for this request's partial tail.
371
+ # Present only on the producer's CoW step; drives the connector's offload
372
+ # (the FA group's block is derived from block_ids and boundary_tokens).
373
+ partial_tail_offloads: list[tuple[int, int, int]] | None = None
374
375
@staticmethod
376
def from_request_tracker(
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/scheduler.py
+53
-2
@@ -11,6 +11,9 @@ from vllm.config import VllmConfig
11
from vllm.distributed.kv_transfer.kv_connector.v1.base import (
12
KVConnectorMetadata,
13
)
14
+from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.coordinator import ( # noqa: E501
15
+ partial_hash_hits_enabled,
16
+)
17
from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.data import ( # noqa: E501
18
LoadSpec,
19
MooncakeStoreConnectorMetadata,
@@ -63,6 +66,9 @@ class MooncakeStoreScheduler:
66
self._block_size, self._hash_block_size = resolve_kv_cache_block_sizes(
67
kv_cache_config, vllm_config
68
)
69
+ self.enable_partial_hash_hits = partial_hash_hits_enabled(
70
+ kv_cache_config.kv_cache_groups, self._hash_block_size
71
+ )
72
73
# Per-request state
74
self.load_specs: dict[str, LoadSpec] = {} # to be loaded
@@ -80,7 +86,12 @@ class MooncakeStoreScheduler:
86
Returns ``(None, False)`` when an async lookup is still in flight,
87
signaling the scheduler to retry this request on a later step.
88
"""
83
- if request.num_tokens < self._block_size:
89
+ # Fine-grained hits may land on a hash boundary inside a block; without
90
+ # partial hits, prefixes shorter than one physical block are skipped.
91
+ align = (
92
+ self._hash_block_size if self.enable_partial_hash_hits else self._block_size
93
+ )
94
+ if request.num_tokens < align:
95
return 0, False
96
97
num_external_hit_tokens = self.client.lookup(
@@ -338,6 +349,44 @@ class MooncakeStoreScheduler:
349
if req_meta is not None:
350
meta.add_request(req_meta)
351
352
+ # Flush partial-tail offloads in the step they arrive: the CoW copy is
353
+ # enqueued before the connector event records, so this step's event
354
+ # fences the cow block. Ride the request's save meta when present, else
355
+ # emit an offload-only ReqMeta (token_len_chunk=0 skips the normal
356
+ # save; can_save=True takes the normal enqueue path).
357
+ step_partial_tails = getattr(scheduler_output, "partial_tail_offloads", None)
358
+ if step_partial_tails and not force_skip_save:
359
+ pending = dict(step_partial_tails)
360
+ for req_meta in meta.requests:
361
+ if req_meta.can_save:
362
+ groups = pending.pop(req_meta.req_id, None)
363
+ if groups:
364
+ req_meta.partial_tail_offloads = groups
365
+ tracker = self._request_trackers.get(req_meta.req_id)
366
+ if tracker is not None:
367
+ tracker.has_pending_offload = True
368
+ for req_id, groups in pending.items():
369
+ tracker = self._request_trackers.get(req_id)
370
+ req_tuple = self._unfinished_requests.get(req_id)
371
+ if tracker is None or req_tuple is None:
372
+ # Request finished/preempted within this step; its blocks
373
+ # are going away, so the offload is conservatively dropped.
374
+ logger.debug("Dropping partial-tail offload for request %s", req_id)
375
+ continue
376
+ assert len({boundary for _, _, boundary in groups}) == 1
377
+ tracker.has_pending_offload = True
378
+ meta.add_request(
379
+ ReqMeta(
380
+ req_id=req_id,
381
+ token_len_chunk=0,
382
+ block_ids=tracker.allocated_block_ids,
383
+ block_hashes=req_tuple[0].block_hashes,
384
+ can_save=True,
385
+ num_prompt_tokens=tracker.prefill_end_tokens,
386
+ partial_tail_offloads=groups,
387
+ )
388
+ )
389
+
390
return meta
391
392
def request_finished(
@@ -352,7 +401,9 @@ class MooncakeStoreScheduler:
401
# Missing tracker can happen when the request is aborted before the
402
# connector observes the normal finished lifecycle or is preempted
403
# before finishing.
355
- if tracker is None or tracker.num_saved_tokens <= 0:
404
+ if tracker is None or (
405
+ tracker.num_saved_tokens <= 0 and not tracker.has_pending_offload
406
+ ):
407
return False, None
408
total_blocks = sum(len(g) for g in block_ids)
409
delay_free_blocks = total_blocks > 0
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/worker.py
+193
-16
@@ -61,6 +61,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.protocol import
61
from vllm.logger import init_logger
62
from vllm.utils.math_utils import cdiv
63
from vllm.utils.network_utils import get_ip, make_zmq_socket
64
+from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
65
from vllm.v1.core.kv_cache_utils import (
66
BlockHash,
67
maybe_convert_block_hash,
@@ -531,6 +532,164 @@ class KVCacheStoreSendingThread(KVTransferThread):
532
self._skip_store_requests.clear()
533
return True
534
535
+ def _maybe_offload_partial_tail(self, req_meta: ReqMeta) -> bool:
536
+ """Offload the request's sub-block partial tail (its last prompt hash
537
+ boundary) so a later request can hit the sub-block prefix.
538
+
539
+ Covers every block from the normal save's lcm floor to the boundary:
540
+ the normal save floors to ``lcm_block_size``, so a smaller-block
541
+ group's full blocks in that gap are never persisted elsewhere, and
542
+ the consumer's lookup needs every group at every probed boundary.
543
+ Full blocks are keyed by their block-end hash, the partial boundary
544
+ block by the boundary sub-hash; the mamba "align" boundary block is
545
+ the core-provided CoW block. All keys are deduped against the store.
546
+
547
+ Returns:
548
+ True when no put is needed or every put succeeds, False otherwise.
549
+ """
550
+ if not self.coord.enable_partial_hash_hits or not req_meta.block_hashes:
551
+ return True
552
+ partial_tail_offloads = req_meta.partial_tail_offloads
553
+ if not partial_tail_offloads:
554
+ return True
555
+ hash_block_size = self.coord.hash_block_size
556
+ boundaries = {boundary for _, _, boundary in partial_tail_offloads}
557
+ if len(boundaries) != 1:
558
+ raise ValueError(
559
+ "Partial-tail offloads for one request must share a boundary"
560
+ )
561
+ boundary = boundaries.pop()
562
+ if boundary == 0:
563
+ return True
564
+ if boundary // hash_block_size - 1 >= len(req_meta.block_hashes):
565
+ return True
566
+ mamba_offloads = {
567
+ group_id: block_id for group_id, block_id, _ in partial_tail_offloads
568
+ }
569
+
570
+ keys: list[str] = []
571
+ addrs: list[list[int]] = []
572
+ sizes: list[list[int]] = []
573
+ saved = self._saved_offset.get(req_meta.req_id, 0)
574
+ for g_idx, db in enumerate(self.token_databases):
575
+ group_blocks = req_meta.block_ids[g_idx]
576
+ # Distribute across ranks by the same rule as normal chunks.
577
+ put_step = self.group_put_steps[g_idx]
578
+ put_step_rank = (self.tp_rank + g_idx) % put_step
579
+ # Always include the boundary block: its sub-hash key is written
580
+ # only here, even if normal saves already advanced past it.
581
+ last_block = cdiv(boundary, db.block_size) - 1
582
+ for block_idx in range(
583
+ min(saved // db.block_size, last_block), last_block + 1
584
+ ):
585
+ if block_idx % put_step != put_step_rank:
586
+ continue
587
+ valid_end = min((block_idx + 1) * db.block_size, boundary)
588
+ key_hash = req_meta.block_hashes[valid_end // hash_block_size - 1]
589
+ if (
590
+ g_idx in mamba_offloads
591
+ and valid_end == boundary
592
+ and boundary % db.block_size != 0
593
+ ):
594
+ block_id = mamba_offloads[g_idx]
595
+ else:
596
+ if block_idx >= len(group_blocks):
597
+ continue
598
+ block_id = group_blocks[block_idx]
599
+ if block_id == NULL_BLOCK_ID:
600
+ logger.debug(
601
+ "Skipping unavailable partial-tail source block "
602
+ "(req=%s, group=%d, block=%d)",
603
+ req_meta.req_id,
604
+ g_idx,
605
+ block_idx,
606
+ )
607
+ continue
608
+ addr, size = db.prepare_value_for_block(block_id)
609
+ keys.append(db.key_for(key_hash))
610
+ addrs.append(addr)
611
+ sizes.append(size)
612
+
613
+ if not keys:
614
+ return True
615
+ exists_start = time.perf_counter()
616
+ try:
617
+ exists = self.store.batch_is_exist(keys)
618
+ except Exception as e:
619
+ self._record_operation(
620
+ "save_exists",
621
+ exists_start,
622
+ len(keys),
623
+ status="error",
624
+ num_failed_keys=len(keys),
625
+ )
626
+ logger.error(
627
+ "Failed to check partial-tail keys for request %s: %s",
628
+ req_meta.req_id,
629
+ e,
630
+ )
631
+ return False
632
+ self._record_operation("save_exists", exists_start, len(keys))
633
+ missing = [i for i, e in enumerate(exists) if e != 1]
634
+ if not missing:
635
+ return True
636
+ keys = [keys[i] for i in missing]
637
+ addrs = [addrs[i] for i in missing]
638
+ sizes = [sizes[i] for i in missing]
639
+ if req_meta.current_event is not None:
640
+ # Fence the CoW block copy enqueued earlier this step.
641
+ req_meta.current_event.synchronize()
642
+ batch_bytes = _sum_batch_bytes(sizes)
643
+ put_start = time.perf_counter()
644
+ try:
645
+ res = self.store.batch_put_from_multi_buffers(
646
+ keys, addrs, sizes, self.replicate_config
647
+ )
648
+ except Exception as e:
649
+ self._record_operation(
650
+ "save_put",
651
+ put_start,
652
+ len(keys),
653
+ num_bytes=batch_bytes,
654
+ status="error",
655
+ num_failed_keys=len(keys),
656
+ )
657
+ logger.error(
658
+ "Failed to put partial-tail keys for request %s: %s",
659
+ req_meta.req_id,
660
+ e,
661
+ )
662
+ return False
663
+
664
+ failed = [i for i, value in enumerate(res) if value < 0]
665
+ self._record_operation(
666
+ "save_put",
667
+ put_start,
668
+ len(keys),
669
+ num_bytes=batch_bytes,
670
+ status="partial_failure" if failed else "ok",
671
+ num_failed_keys=len(failed),
672
+ )
673
+ if failed:
674
+ failed_codes = {res[i] for i in failed}
675
+ logger.warning(
676
+ "Partial-tail put failed for request %s: %d/%d keys failed (codes=%s)",
677
+ req_meta.req_id,
678
+ len(failed),
679
+ len(keys),
680
+ failed_codes,
681
+ )
682
+ if MOONCAKE_NO_AVAILABLE_HANDLE in failed_codes:
683
+ self._mark_request_skipped_for_pressure(req_meta.req_id)
684
+ return False
685
+
686
+ if self._clear_store_pressure():
687
+ logger.info(
688
+ "Mooncake CPU/disk offloading pressure cleared after a "
689
+ "successful partial-tail batch"
690
+ )
691
+ return True
692
+
693
def _handle_request(self, req_meta: ReqMeta):
694
# Cache hits are always a multiple of ``lcm_block_size`` tokens, which
695
# is also ``store_mask``'s precondition.
@@ -548,9 +707,6 @@ class KVCacheStoreSendingThread(KVTransferThread):
707
# so the scheduler can release the GPU blocks it pinned for this
708
# request (via `delay_free_blocks`) even when the store path raises.
709
try:
551
- if token_len == 0:
552
- return
553
-
710
if self._should_skip_request(req_id):
711
logger.debug(
712
"Skipping Mooncake store for request %s while CPU/disk "
@@ -559,6 +715,16 @@ class KVCacheStoreSendingThread(KVTransferThread):
715
)
716
return
717
718
+ # Offload the sub-block partial tail (independent of the normal
719
+ # block-aligned save, which may be skipped this step).
720
+ if req_meta.partial_tail_offloads is not None and not (
721
+ self._maybe_offload_partial_tail(req_meta)
722
+ ):
723
+ return
724
+
725
+ if token_len == 0:
726
+ return
727
+
728
# Resume from where this rank left off; only the new suffix is saved.
729
save_start = self._saved_offset.get(req_id, 0)
730
@@ -1385,7 +1551,7 @@ class MooncakeStoreWorker:
1551
self.recv_request_queue.put(request)
1552
1553
assert self.load_async, "load_async must be True for better performance."
1388
- # Issue stores with CUDA event synchronization
1554
+ # Issue stores with CUDA event synchronization.
1555
if self.kv_role in ["kv_producer", "kv_both"]:
1556
current_event = None
1557
for request in meta.requests:
@@ -1501,21 +1667,32 @@ class MooncakeStoreWorker:
1667
# candidate_meta stores the (group, hash_bytes) for key slice.
1668
candidate_keys: list[str] = []
1669
candidate_meta: list[tuple[int, bytes]] = []
1504
- lookup_masks = self.coord.lookup_mask(token_len)
1670
+ fine_grained = self.coord.enable_partial_hash_hits
1671
+ lookup_masks = None if fine_grained else self.coord.lookup_mask(token_len)
1672
for g_idx, db in enumerate(self.token_dbs):
1673
spec_block_size = db.block_size
1507
- lookup_mask = lookup_masks[g_idx]
1674
key_prefixes = self._lookup_key_prefixes[g_idx]
1509
- group_hashes = self.coord.block_hashes_for_spec(
1510
- block_hashes, self._kv_cache_groups[g_idx].kv_cache_spec
1511
- )
1512
- max_chunks = min(len(group_hashes), cdiv(token_len, spec_block_size))
1513
- mask_limit = (
1514
- max_chunks if lookup_mask is None else min(max_chunks, len(lookup_mask))
1515
- )
1516
- for chunk_id in range(mask_limit):
1517
- if lookup_mask is not None and not lookup_mask[chunk_id]:
1518
- continue
1675
+ if fine_grained:
1676
+ max_units = min(len(block_hashes), token_len // self.hash_block_size)
1677
+ unit_ids: range | list[int] = range(max_units)
1678
+ group_hashes: Sequence[BlockHash] = block_hashes
1679
+ else:
1680
+ lookup_mask = lookup_masks[g_idx] # type: ignore[index]
1681
+ group_hashes = self.coord.block_hashes_for_spec(
1682
+ block_hashes, self._kv_cache_groups[g_idx].kv_cache_spec
1683
+ )
1684
+ max_chunks = min(len(group_hashes), cdiv(token_len, spec_block_size))
1685
+ mask_limit = (
1686
+ max_chunks
1687
+ if lookup_mask is None
1688
+ else min(max_chunks, len(lookup_mask))
1689
+ )
1690
+ unit_ids = [
1691
+ chunk_id
1692
+ for chunk_id in range(mask_limit)
1693
+ if lookup_mask is None or lookup_mask[chunk_id]
1694
+ ]
1695
+ for chunk_id in unit_ids:
1696
h = group_hashes[chunk_id]
1697
hash_hex = h.hex()
1698
for key_prefix in key_prefixes:
vllm/v1/core/kv_cache_manager.py
+62
-1
@@ -186,6 +186,10 @@ class KVCacheManager:
186
tuple(() for _ in range(self.num_kv_cache_groups))
187
)
188
189
+ # Off-table cow blocks handed to a KV connector for partial-tail
190
+ # offload; pinned until the request's blocks are freed.
191
+ self._partial_tail_pins: dict[str, list[KVCacheBlock]] = {}
192
+
193
@property
194
def usage(self) -> float:
195
"""Get the KV cache usage.
@@ -568,6 +572,9 @@ class KVCacheManager:
572
Args:
573
request: The request to free the blocks.
574
"""
575
+ pins = self._partial_tail_pins.pop(request.request_id, None)
576
+ if pins:
577
+ self.block_pool.free_blocks(pins)
578
self.coordinator.free(request.request_id)
579
580
def remove_skipped_blocks(
@@ -600,7 +607,14 @@ class KVCacheManager:
607
Returns:
608
The request's blocks in allocation order.
609
"""
603
- return self.coordinator.pop_blocks_for_free(request.request_id)
610
+ blocks = self.coordinator.pop_blocks_for_free(request.request_id)
611
+ # Pins ride the same (possibly deferred) free as the request blocks.
612
+ # Preemption may release a pin under a still-queued offload — the same
613
+ # exposure normal saves of table blocks already have.
614
+ pins = self._partial_tail_pins.pop(request.request_id, None)
615
+ if pins:
616
+ blocks = pins + blocks
617
+ return blocks
618
619
def evict_blocks(self, block_ids: set[int]) -> None:
620
"""evict blocks from the prefix cache by their block IDs.
@@ -760,6 +774,25 @@ class KVCacheManager:
774
# Only create new KVCacheBlocks for non-empty blocks
775
return KVCacheBlocks(blocks) if any(blocks) else self.empty_kv_cache_blocks
776
777
+ def truncate_computed_blocks(
778
+ self, blocks: KVCacheBlocks, num_computed_tokens: int
779
+ ) -> KVCacheBlocks:
780
+ """Return a lookup-result view truncated at an aligned token endpoint.
781
+
782
+ Pure slicing: refcounts are untouched and ``blocks`` is not mutated.
783
+ """
784
+ truncated: list[list[KVCacheBlock]] = []
785
+ for group_blocks, manager in zip(
786
+ blocks.blocks,
787
+ self.coordinator.single_type_managers,
788
+ strict=True,
789
+ ):
790
+ assert num_computed_tokens % manager.block_size == 0
791
+ num_blocks = num_computed_tokens // manager.block_size
792
+ assert num_blocks <= len(group_blocks)
793
+ truncated.append(list(group_blocks[:num_blocks]))
794
+ return self.create_kv_cache_blocks(tuple(truncated))
795
+
796
def take_new_block_ids(self) -> list[int]:
797
"""Drain and return new attention block IDs for zeroing."""
798
ids: list[int] = []
@@ -812,6 +845,34 @@ class KVCacheManager:
845
retained_blocks = [block for pair in pending_copies for block in pair]
846
return copies, retained_blocks
847
848
+ def take_partial_tail_offloads(self) -> dict[str, list[tuple[int, int, int]]]:
849
+ """Drain producer partial-tail offload hand-offs per request.
850
+
851
+ Returns ``{request_id: [(group_id, block_id, boundary_tokens), ...]}``
852
+ for the durable boundary blocks of producers' last-prompt-boundary
853
+ partial tails. Only mamba "align" groups contribute; empty otherwise.
854
+ A KV connector reads the referenced blocks and offloads them so a later
855
+ request can hit the sub-block prefix.
856
+
857
+ Each handed-off block lives off the request block table, so it is
858
+ pinned here and unpinned when the request's blocks are freed — for a
859
+ producer with saved tokens, after the connector reports sends done.
860
+ """
861
+ offloads: dict[str, list[tuple[int, int, int]]] = {}
862
+ for mgr in self.coordinator.single_type_managers:
863
+ for (
864
+ req_id,
865
+ group_id,
866
+ block,
867
+ boundary_tokens,
868
+ ) in mgr.take_pending_partial_tail_offloads():
869
+ self.block_pool.touch((block,))
870
+ self._partial_tail_pins.setdefault(req_id, []).append(block)
871
+ offloads.setdefault(req_id, []).append(
872
+ (group_id, block.block_id, boundary_tokens)
873
+ )
874
+ return offloads
875
+
876
def new_step_starts(self) -> None:
877
"""Notify the coordinator that a new step is starting."""
878
self.coordinator.new_step_starts()
vllm/v1/core/sched/output.py
+6
@@ -258,6 +258,12 @@ class SchedulerOutput:
258
# CoW copies to apply after zeroing new blocks and before forward.
259
kv_cache_block_copies: list[KVCacheBlockCopy] | None = None
260
261
+ # Producer partial-tail offload hand-off for external KV connectors:
262
+ # {request_id: [(group_id, block_id, boundary_tokens), ...]} pointing at
263
+ # the durable boundary block of a producer's last-prompt-boundary partial
264
+ # tail (mamba "align" CoW target). None unless partial hash hits are active.
265
+ partial_tail_offloads: dict[str, list[tuple[int, int, int]]] | None = None
266
+
267
# Dynamic speculative decoding: optimal K chosen by scheduler.
268
# Number of spec tokens to schedule for the next step.
269
num_spec_tokens_to_schedule: int = 0
vllm/v1/core/sched/scheduler.py
+46
-2
@@ -740,9 +740,16 @@ class Scheduler(SchedulerInterface):
740
741
# Get externally-cached tokens if using a KVConnector.
742
if self.connector is not None:
743
+ # Present a block-aligned local hit to the connector so
744
+ # a strictly longer remote hit can supersede a local
745
+ # sub-block tail without racing its copy-on-write.
746
+ partial_tail = num_new_local_computed_tokens % self.block_size
747
+ block_aligned_local = (
748
+ num_new_local_computed_tokens - partial_tail
749
+ )
750
ext_tokens, load_kv_async = (
751
self.connector.get_num_new_matched_tokens(
745
- request, num_new_local_computed_tokens
752
+ request, block_aligned_local
753
)
754
)
755
@@ -754,7 +761,27 @@ class Scheduler(SchedulerInterface):
761
step_skipped_waiting.prepend_request(request)
762
continue
763
757
- num_external_computed_tokens = ext_tokens
764
+ if partial_tail and ext_tokens > partial_tail:
765
+ # Remote strictly exceeds the full local hit: drop the
766
+ # sub-block tail so no CoW is needed, and let the load
767
+ # cover it. Trim the partial block out of the local
768
+ # computed blocks so it is not adopted from the cache.
769
+ new_computed_blocks = (
770
+ self.kv_cache_manager.truncate_computed_blocks(
771
+ new_computed_blocks, block_aligned_local
772
+ )
773
+ )
774
+ num_new_local_computed_tokens = block_aligned_local
775
+ num_external_computed_tokens = ext_tokens
776
+ elif partial_tail:
777
+ # Remote does not exceed the full local hit: keep the
778
+ # local sub-block tail and load nothing external.
779
+ num_external_computed_tokens = 0
780
+ # Nothing to load remotely -> not an async-load step;
781
+ # clearing avoids the `load_kv_async` assert below.
782
+ load_kv_async = False
783
+ else:
784
+ num_external_computed_tokens = ext_tokens
785
786
if hit_diverged and num_external_computed_tokens == 0:
787
# No external tokens back the deeper local hit, so its
@@ -1108,6 +1135,22 @@ class Scheduler(SchedulerInterface):
1135
self.prev_step_scheduled_req_ids.clear()
1136
self.prev_step_scheduled_req_ids.update(num_scheduled_tokens.keys())
1137
1138
+ # Producer partial-tail hand-off for external KV connectors. Drained
1139
+ # before the CoW retentions are released below, so the pin lands while
1140
+ # the cow block still holds a retention ref. Without a producer-side
1141
+ # connector nothing consumes the hand-off, so skip the drain (and its
1142
+ # pin); the manager drops stale entries when the request's blocks are
1143
+ # popped for free.
1144
+ pending_partial_tail_offloads = None
1145
+ if (
1146
+ self.connector is not None
1147
+ and self.vllm_config.kv_transfer_config is not None
1148
+ and self.vllm_config.kv_transfer_config.is_kv_producer
1149
+ ):
1150
+ pending_partial_tail_offloads = (
1151
+ self.kv_cache_manager.take_partial_tail_offloads() or None
1152
+ )
1153
+
1154
kv_cache_block_copies, cow_retained_blocks = (
1155
self.kv_cache_manager.take_kv_cache_block_copies()
1156
)
@@ -1153,6 +1196,7 @@ class Scheduler(SchedulerInterface):
1196
free_encoder_mm_hashes=self.encoder_cache_manager.get_freed_mm_hashes(),
1197
new_block_ids_to_zero=self._get_new_block_ids_to_zero(),
1198
kv_cache_block_copies=pending_kv_cache_block_copies,
1199
+ partial_tail_offloads=pending_partial_tail_offloads,
1200
num_spec_tokens_to_schedule=num_spec_tokens_to_schedule,
1201
ec_manager_metadata=self.encoder_cache_manager.get_manager_metadata(),
1202
)
vllm/v1/core/single_type_kv_cache_manager.py
+63
-3
@@ -115,6 +115,15 @@ class SingleTypeKVCacheManager(ABC):
115
# managers (full attention, mamba "align"); harmlessly empty elsewhere.
116
self._partial_hit_reqs: dict[str, tuple[int, KVCacheBlock]] = {}
117
self._pending_cow_copies: list[tuple[KVCacheBlock, KVCacheBlock]] = []
118
+ # Partial-tail offload hand-off for external KV connectors: when a
119
+ # producer registers its last-prompt-boundary partial tail and the
120
+ # durable boundary block is not on the append-only request block table
121
+ # (mamba "align" CoW target), record the request, group, block, and
122
+ # exact token boundary so a connector can offload it under the right
123
+ # hash. Populated only by mamba "align".
124
+ self._pending_partial_tail_offloads: list[
125
+ tuple[str, int, KVCacheBlock, int]
126
+ ] = []
127
128
@classmethod
129
def _get_num_evictable_blocks(cls, blocks: Sequence[KVCacheBlock]):
@@ -378,6 +387,21 @@ class SingleTypeKVCacheManager(ABC):
387
self._pending_cow_copies = []
388
return pending_copies
389
390
+ def take_pending_partial_tail_offloads(
391
+ self,
392
+ ) -> list[tuple[str, int, KVCacheBlock, int]]:
393
+ """Drain producer partial-tail hand-offs.
394
+
395
+ Entries are ``(req_id, group_id, block, boundary_tokens)``.
396
+
397
+ Only mamba "align" populates this. The block lives off the request
398
+ block table, so the caller must pin it until the connector has read
399
+ it — nothing else keeps it alive once the CoW retention is released.
400
+ """
401
+ pending = self._pending_partial_tail_offloads
402
+ self._pending_partial_tail_offloads = []
403
+ return pending
404
+
405
def _apply_cow(
406
self,
407
request_id: str,
@@ -693,7 +717,8 @@ class FullAttentionManager(SingleTypeKVCacheManager):
717
alignment_tokens < block_size and block_size % alignment_tokens == 0
718
)
719
if fine_grained:
696
- assert isinstance(block_hashes, list)
720
+ # list or lazy BlobBlockHashes view
721
+ assert isinstance(block_hashes, Sequence)
722
full_block_hashes: BlockHashList = BlockHashListWithBlockSize(
723
block_hashes, alignment_tokens, block_size
724
)
@@ -716,7 +741,8 @@ class FullAttentionManager(SingleTypeKVCacheManager):
741
# Phase 2 (fine-grained only): extend into the first non-full block by
742
# probing its interior hash boundaries high-to-low (longest hit first).
743
if fine_grained:
719
- assert isinstance(block_hashes, list)
744
+ # list or lazy BlobBlockHashes view
745
+ assert isinstance(block_hashes, Sequence)
746
scale_factor = block_size // alignment_tokens
747
first_partial_idx = len(computed_blocks[0]) * scale_factor
748
max_partial_idx = min(
@@ -1244,6 +1270,11 @@ class MambaManager(SingleTypeKVCacheManager):
1270
self.last_state_block_idx: dict[str, int] = {}
1271
# The set of the requests that have been allocated blocks
1272
self._allocated_block_reqs: set[str] = set()
1273
+ # Requests that registered their own last-prompt-boundary partial
1274
+ # tail (producers). On the next step's CoW the boundary state moves
1275
+ # into a private cow_block; we record that block for connector
1276
+ # offload (see _pending_partial_tail_offloads).
1277
+ self._producer_partial_tail_reqs: dict[str, int] = {}
1278
1279
@classmethod
1280
def find_longest_cache_hit(
@@ -1277,7 +1308,8 @@ class MambaManager(SingleTypeKVCacheManager):
1308
1309
block_size = kv_cache_spec.block_size
1310
if alignment_tokens < block_size and block_size % alignment_tokens == 0:
1280
- assert isinstance(block_hashes, list)
1311
+ # list or lazy BlobBlockHashes view
1312
+ assert isinstance(block_hashes, Sequence)
1313
hash_block_size = alignment_tokens
1314
scale_factor = block_size // hash_block_size
1315
max_num_partial_units = min(
@@ -1590,6 +1622,21 @@ class MambaManager(SingleTypeKVCacheManager):
1622
self.block_pool.move_block_hashes(source_block, cow_block)
1623
self._pending_cow_copies.append((source_block, cow_block))
1624
source_block.ref_cnt += 1
1625
+ boundary_tokens = self._producer_partial_tail_reqs.pop(
1626
+ request_id, None
1627
+ )
1628
+ if boundary_tokens is not None:
1629
+ # This CoW preserved a producer's own boundary
1630
+ # state in cow_block; hand it to the connector for
1631
+ # partial-tail offload once the copy has run.
1632
+ self._pending_partial_tail_offloads.append(
1633
+ (
1634
+ request_id,
1635
+ self.kv_cache_group_id,
1636
+ cow_block,
1637
+ boundary_tokens,
1638
+ )
1639
+ )
1640
if cow_block.block_hash is not None:
1641
# The moved entry is only filled by this step's
1642
# copy, so defer same-step hits on it.
@@ -1607,6 +1654,14 @@ class MambaManager(SingleTypeKVCacheManager):
1654
if self.mamba_cache_mode == "align":
1655
self._allocated_block_reqs.discard(request_id)
1656
self.last_state_block_idx.pop(request_id, None)
1657
+ self._producer_partial_tail_reqs.pop(request_id, None)
1658
+ # A hand-off whose request died in this same scheduling pass must
1659
+ # not reach the connector: its unpin hook (free) has already run.
1660
+ self._pending_partial_tail_offloads = [
1661
+ entry
1662
+ for entry in self._pending_partial_tail_offloads
1663
+ if entry[0] != request_id
1664
+ ]
1665
return super().pop_blocks_for_free(request_id)
1666
1667
def get_num_skipped_tokens(self, num_computed_tokens: int) -> int:
@@ -1681,6 +1736,11 @@ class MambaManager(SingleTypeKVCacheManager):
1736
if partial_hash is not None:
1737
self._partial_hit_reqs[request.request_id] = (block_idx, source_block)
1738
self.num_cached_block[request.request_id] = block_idx
1739
+ # Producer of this partial tail: the boundary state currently lives
1740
+ # in ``source_block`` but the next step's forward overwrites it. The
1741
+ # upcoming CoW copies it into a durable cow_block; record the req so
1742
+ # allocate_new_blocks hands that block to the connector for offload.
1743
+ self._producer_partial_tail_reqs[request.request_id] = num_tokens
1744
return partial_hash
1745
1746