[2/N][Core] support partial prefix cache hit for hybrid model (#46384)
Signed-off-by: zjy0516 <riverclouds.zhu@qq.com> Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai> Co-authored-by: Yifan Qiao <yifanqiao@inferact.ai>
Jiangyun Zhu committed
Jul 12, 2026 at 13:37 UTC
481e481be786c1ca3229e26aa34c15ffd22375af
21 files changed
+1651
-175
tests/v1/core/prefix_cache/test_partial_prefix_cache_hits.py
new
+816
@@ -0,0 +1,816 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Fine-grained partial prefix-cache hits for hybrid (full attention + mamba
4
+"align") models: scheduler chunk splitting, partial tail registration, CoW
5
+on partial hits, and same-step deferral."""
6
+
7
+from types import SimpleNamespace
8
+
9
+import pytest
10
+import torch
11
+
12
+from tests.v1.core.test_prefix_caching import make_kv_cache_manager, make_request
13
+from vllm.utils.hashing import sha256
14
+from vllm.v1.core.kv_cache_utils import (
15
+ KVCacheBlockCopy,
16
+ get_block_hash,
17
+ get_group_id,
18
+ init_none_hash,
19
+)
20
+from vllm.v1.core.sched.scheduler import Scheduler
21
+from vllm.v1.kv_cache_interface import (
22
+ FullAttentionSpec,
23
+ KVCacheConfig,
24
+ KVCacheGroupSpec,
25
+ MambaSpec,
26
+)
27
+
28
+
29
+@pytest.fixture(autouse=True)
30
+def _auto_init_hash_fn():
31
+ init_none_hash(sha256)
32
+
33
+
34
+def test_mamba_align_split_partial_tail_schedule():
35
+ """Chunk ends with partial hits on: block-aligned chunks, one extra stop
36
+ at the prompt's last hash boundary (registering the partial tail), then
37
+ the remaining tokens. block=512, hash=32, prompt=10000, budget=8192:
38
+ 0 -> 8192 -> 9728 -> 9984 -> 10000."""
39
+ block_size = 512
40
+ hash_block_size = 32
41
+ mock = SimpleNamespace(
42
+ cache_config=SimpleNamespace(block_size=block_size),
43
+ use_eagle=False,
44
+ hash_block_size=hash_block_size,
45
+ mamba_partial_cache_hit=True,
46
+ )
47
+ split = Scheduler._mamba_block_aligned_split
48
+
49
+ req = make_request("0", [0] * 10000, hash_block_size, sha256)
50
+ req.num_computed_tokens = 0
51
+ assert split(self=mock, request=req, num_new_tokens=8192) == 8192
52
+ req.num_computed_tokens = 8192
53
+ # Stop at the last block boundary (9728).
54
+ assert split(self=mock, request=req, num_new_tokens=1808) == 1536
55
+ req.num_computed_tokens = 9728
56
+ # Extra stop at the prompt's last hash boundary (9984).
57
+ assert split(self=mock, request=req, num_new_tokens=272) == 256
58
+ req.num_computed_tokens = 9984
59
+ # Final 16 tokens run unchanged (no mid-block-resume stop: the next
60
+ # block boundary is past the last block boundary).
61
+ assert split(self=mock, request=req, num_new_tokens=16) == 16
62
+
63
+ # Partial hits off: no extra stop, the tail runs in one chunk.
64
+ mock.mamba_partial_cache_hit = False
65
+ req.num_computed_tokens = 9728
66
+ assert split(self=mock, request=req, num_new_tokens=272) == 272
67
+ mock.mamba_partial_cache_hit = True
68
+
69
+ # A request resumed mid-block (partial hash hit at 9984): the first chunk
70
+ # stops at the next block boundary (10240), later chunk ends re-align.
71
+ req2 = make_request("1", [0] * 12000, hash_block_size, sha256)
72
+ req2.num_computed_tokens = 9984
73
+ assert split(self=mock, request=req2, num_new_tokens=2016) == 256
74
+ req2.num_computed_tokens = 10240
75
+ assert split(self=mock, request=req2, num_new_tokens=1000) == 512
76
+
77
+
78
+def test_hybrid_mamba_align_partial_hash_hit():
79
+ hash_block_size = 2
80
+ mamba_block_size = 2 * hash_block_size
81
+ kv_cache_config = KVCacheConfig(
82
+ num_blocks=20,
83
+ kv_cache_tensors=[],
84
+ kv_cache_groups=[
85
+ KVCacheGroupSpec(
86
+ ["full"],
87
+ FullAttentionSpec(
88
+ block_size=hash_block_size,
89
+ num_kv_heads=1,
90
+ head_size=1,
91
+ dtype=torch.float32,
92
+ ),
93
+ ),
94
+ KVCacheGroupSpec(
95
+ ["mamba"],
96
+ MambaSpec(
97
+ block_size=mamba_block_size,
98
+ shapes=(1, 1),
99
+ dtypes=(torch.float32,),
100
+ mamba_cache_mode="align",
101
+ ),
102
+ ),
103
+ ],
104
+ )
105
+ manager = make_kv_cache_manager(
106
+ kv_cache_config=kv_cache_config,
107
+ max_model_len=8192,
108
+ enable_caching=True,
109
+ hash_block_size=hash_block_size,
110
+ )
111
+
112
+ req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
113
+ computed_blocks, num_computed = manager.get_computed_blocks(req0)
114
+ assert num_computed == 0
115
+ blocks = manager.allocate_slots(req0, 6, num_computed, computed_blocks)
116
+ assert blocks is not None
117
+ manager.free(req0)
118
+ manager.new_step_starts()
119
+
120
+ partial_mamba_hash = req0.block_hashes[6 // hash_block_size - 1]
121
+ partial_mamba_block = manager.block_pool.get_cached_block(
122
+ partial_mamba_hash, kv_cache_group_ids=[1]
123
+ )
124
+ assert partial_mamba_block is not None
125
+ assert partial_mamba_block[0].block_hash_num_tokens == 6
126
+
127
+ req1 = make_request("1", [0, 0, 1, 1, 2, 2, 3, 3], hash_block_size, sha256)
128
+ computed_blocks, num_computed = manager.get_computed_blocks(req1)
129
+ assert num_computed == 6
130
+ assert [len(group) for group in computed_blocks.blocks] == [3, 2]
131
+
132
+ new_blocks = manager.allocate_slots(req1, 2, num_computed, computed_blocks)
133
+ assert new_blocks is not None
134
+ mamba_new_block_ids = new_blocks.get_block_ids()[1]
135
+ assert len(mamba_new_block_ids) == 1
136
+ assert mamba_new_block_ids[0] != partial_mamba_block[0].block_id
137
+ assert manager.get_blocks("1").get_block_ids()[1][1] == mamba_new_block_ids[0]
138
+ assert partial_mamba_block[0].block_hash is not None
139
+ assert get_block_hash(partial_mamba_block[0].block_hash) == partial_mamba_hash
140
+ assert get_group_id(partial_mamba_block[0].block_hash) == 1
141
+ assert partial_mamba_block[0].block_hash_num_tokens == 6
142
+ copies, _ = manager.take_kv_cache_block_copies()
143
+ assert (
144
+ KVCacheBlockCopy(
145
+ src_block_id=partial_mamba_block[0].block_id,
146
+ dst_block_id=mamba_new_block_ids[0],
147
+ )
148
+ in copies
149
+ )
150
+ assert manager.get_blocks("1").blocks[1][1].block_hash_num_tokens == 8
151
+
152
+
153
+def test_hybrid_mamba_partial_tail_owner_uses_cow_on_continue():
154
+ hash_block_size = 2
155
+ block_size = 2 * hash_block_size
156
+ kv_cache_config = KVCacheConfig(
157
+ num_blocks=24,
158
+ kv_cache_tensors=[],
159
+ kv_cache_groups=[
160
+ KVCacheGroupSpec(
161
+ ["full"],
162
+ FullAttentionSpec(
163
+ block_size=hash_block_size,
164
+ num_kv_heads=1,
165
+ head_size=1,
166
+ dtype=torch.float32,
167
+ ),
168
+ ),
169
+ KVCacheGroupSpec(
170
+ ["mamba"],
171
+ MambaSpec(
172
+ block_size=block_size,
173
+ shapes=(1, 1),
174
+ dtypes=(torch.float32,),
175
+ mamba_cache_mode="align",
176
+ ),
177
+ ),
178
+ ],
179
+ )
180
+ manager = make_kv_cache_manager(
181
+ kv_cache_config=kv_cache_config,
182
+ max_model_len=8192,
183
+ enable_caching=True,
184
+ hash_block_size=hash_block_size,
185
+ )
186
+
187
+ req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
188
+ computed_blocks, num_computed = manager.get_computed_blocks(req0)
189
+ assert num_computed == 0
190
+ assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
191
+
192
+ partial_mamba_hash = req0.block_hashes[6 // hash_block_size - 1]
193
+ partial_mamba_block = manager.block_pool.get_cached_block(
194
+ partial_mamba_hash, kv_cache_group_ids=[1]
195
+ )
196
+ assert partial_mamba_block is not None
197
+ partial_mamba_block_id = partial_mamba_block[0].block_id
198
+ assert manager.get_blocks("0").get_block_ids()[1][1] == partial_mamba_block_id
199
+
200
+ req0.num_computed_tokens = 6
201
+ req0.append_output_token_ids([3])
202
+ new_blocks = manager.allocate_slots(req0, 1)
203
+ assert new_blocks is not None
204
+
205
+ # Reversed CoW for the owning request: it keeps its own block (the
206
+ # worker's block table is append-only), and no new mamba block is handed
207
+ # to the worker. The prefix-cache entry is moved to a private copy that
208
+ # the queued block copy fills before the next forward.
209
+ assert new_blocks.get_block_ids()[1] == []
210
+ assert manager.get_blocks("0").get_block_ids()[1][1] == partial_mamba_block_id
211
+ copies, _ = manager.take_kv_cache_block_copies()
212
+ cow_copy = next(c for c in copies if c.src_block_id == partial_mamba_block_id)
213
+ assert cow_copy.dst_block_id != partial_mamba_block_id
214
+ # The source block gave up the hash; the copy target now owns the entry.
215
+ assert partial_mamba_block[0].block_hash is None
216
+ moved = manager.block_pool.get_cached_block(
217
+ partial_mamba_hash, kv_cache_group_ids=[1]
218
+ )
219
+ assert moved is not None
220
+ assert moved[0].block_id == cow_copy.dst_block_id
221
+ assert get_block_hash(moved[0].block_hash) == partial_mamba_hash
222
+ assert get_group_id(moved[0].block_hash) == 1
223
+ assert moved[0].block_hash_num_tokens == 6
224
+
225
+
226
+def test_hybrid_mamba_partial_tail_owner_continue_preserves_later_hit():
227
+ hash_block_size = 2
228
+ block_size = 2 * hash_block_size
229
+ kv_cache_config = KVCacheConfig(
230
+ num_blocks=32,
231
+ kv_cache_tensors=[],
232
+ kv_cache_groups=[
233
+ KVCacheGroupSpec(
234
+ ["full"],
235
+ FullAttentionSpec(
236
+ block_size=hash_block_size,
237
+ num_kv_heads=1,
238
+ head_size=1,
239
+ dtype=torch.float32,
240
+ ),
241
+ ),
242
+ KVCacheGroupSpec(
243
+ ["mamba"],
244
+ MambaSpec(
245
+ block_size=block_size,
246
+ shapes=(1, 1),
247
+ dtypes=(torch.float32,),
248
+ mamba_cache_mode="align",
249
+ ),
250
+ ),
251
+ ],
252
+ )
253
+ manager = make_kv_cache_manager(
254
+ kv_cache_config=kv_cache_config,
255
+ max_model_len=8192,
256
+ enable_caching=True,
257
+ hash_block_size=hash_block_size,
258
+ )
259
+
260
+ req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
261
+ computed_blocks, num_computed = manager.get_computed_blocks(req0)
262
+ assert num_computed == 0
263
+ assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
264
+
265
+ partial_mamba_hash = req0.block_hashes[6 // hash_block_size - 1]
266
+ partial_mamba_block = manager.block_pool.get_cached_block(
267
+ partial_mamba_hash, kv_cache_group_ids=[1]
268
+ )
269
+ assert partial_mamba_block is not None
270
+ partial_mamba_block_id = partial_mamba_block[0].block_id
271
+
272
+ req0.num_computed_tokens = 6
273
+ req0.append_output_token_ids([3])
274
+ assert manager.allocate_slots(req0, 1) is not None
275
+ # The owner moved the prefix-cache entry to a private copy; capture its id.
276
+ owner_copies, _ = manager.take_kv_cache_block_copies()
277
+ cow_copy = next(c for c in owner_copies if c.src_block_id == partial_mamba_block_id)
278
+ moved_block_id = cow_copy.dst_block_id
279
+ manager.new_step_starts()
280
+
281
+ req1 = make_request("1", [0, 0, 1, 1, 2, 2, 4, 4], hash_block_size, sha256)
282
+ computed_blocks, num_computed = manager.get_computed_blocks(req1)
283
+ assert num_computed == 6
284
+ # The later request hits the moved (private-copy) entry, not the source.
285
+ assert computed_blocks.get_block_ids()[1][1] == moved_block_id
286
+
287
+ new_blocks = manager.allocate_slots(req1, 2, num_computed, computed_blocks)
288
+ assert new_blocks is not None
289
+ mamba_new_block_ids = new_blocks.get_block_ids()[1]
290
+ assert len(mamba_new_block_ids) == 1
291
+ assert mamba_new_block_ids[0] != moved_block_id
292
+ # The hitting request CoWs from the moved entry into its own private block.
293
+ copies, _ = manager.take_kv_cache_block_copies()
294
+ assert (
295
+ KVCacheBlockCopy(
296
+ src_block_id=moved_block_id,
297
+ dst_block_id=mamba_new_block_ids[0],
298
+ )
299
+ in copies
300
+ )
301
+
302
+
303
+def test_hybrid_mamba_moved_partial_entry_defers_same_step_hit():
304
+ """The owner's move re-arms the same-step guard: the moved entry is
305
+ filled by this step's copy, and chained same-step copies read stale
306
+ sources, so a request hitting it in the move step must be deferred."""
307
+ hash_block_size = 2
308
+ block_size = 2 * hash_block_size
309
+ kv_cache_config = KVCacheConfig(
310
+ num_blocks=32,
311
+ kv_cache_tensors=[],
312
+ kv_cache_groups=[
313
+ KVCacheGroupSpec(
314
+ ["full"],
315
+ FullAttentionSpec(
316
+ block_size=hash_block_size,
317
+ num_kv_heads=1,
318
+ head_size=1,
319
+ dtype=torch.float32,
320
+ ),
321
+ ),
322
+ KVCacheGroupSpec(
323
+ ["mamba"],
324
+ MambaSpec(
325
+ block_size=block_size,
326
+ shapes=(1, 1),
327
+ dtypes=(torch.float32,),
328
+ mamba_cache_mode="align",
329
+ ),
330
+ ),
331
+ ],
332
+ )
333
+ manager = make_kv_cache_manager(
334
+ kv_cache_config=kv_cache_config,
335
+ max_model_len=8192,
336
+ enable_caching=True,
337
+ hash_block_size=hash_block_size,
338
+ )
339
+
340
+ req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
341
+ computed_blocks, num_computed = manager.get_computed_blocks(req0)
342
+ assert num_computed == 0
343
+ assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
344
+ manager.new_step_starts()
345
+
346
+ # The owning request continues decoding: the partial entry moves to a
347
+ # private copy in this step.
348
+ req0.num_computed_tokens = 6
349
+ req0.append_output_token_ids([3])
350
+ assert manager.allocate_slots(req0, 1) is not None
351
+
352
+ # A request hitting the moved entry in the SAME step must be deferred.
353
+ req1 = make_request("1", [0, 0, 1, 1, 2, 2, 4, 4], hash_block_size, sha256)
354
+ computed_blocks, num_computed = manager.get_computed_blocks(req1)
355
+ assert num_computed == 6
356
+ assert manager.allocate_slots(req1, 2, num_computed, computed_blocks) is None
357
+
358
+ # Next step the moved entry is consumable.
359
+ manager.new_step_starts()
360
+ computed_blocks, num_computed = manager.get_computed_blocks(req1)
361
+ assert num_computed == 6
362
+ assert manager.allocate_slots(req1, 2, num_computed, computed_blocks) is not None
363
+
364
+
365
+def test_hybrid_full_attention_partial_hash_hit_uses_cow():
366
+ hash_block_size = 2
367
+ block_size = 2 * hash_block_size
368
+ kv_cache_config = KVCacheConfig(
369
+ num_blocks=24,
370
+ kv_cache_tensors=[],
371
+ kv_cache_groups=[
372
+ KVCacheGroupSpec(
373
+ ["full"],
374
+ FullAttentionSpec(
375
+ block_size=block_size,
376
+ num_kv_heads=1,
377
+ head_size=1,
378
+ dtype=torch.float32,
379
+ ),
380
+ ),
381
+ KVCacheGroupSpec(
382
+ ["mamba"],
383
+ MambaSpec(
384
+ block_size=block_size,
385
+ shapes=(1, 1),
386
+ dtypes=(torch.float32,),
387
+ mamba_cache_mode="align",
388
+ ),
389
+ ),
390
+ ],
391
+ )
392
+ manager = make_kv_cache_manager(
393
+ kv_cache_config=kv_cache_config,
394
+ max_model_len=8192,
395
+ enable_caching=True,
396
+ hash_block_size=hash_block_size,
397
+ )
398
+
399
+ req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
400
+ computed_blocks, num_computed = manager.get_computed_blocks(req0)
401
+ assert num_computed == 0
402
+ assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
403
+ manager.free(req0)
404
+ manager.new_step_starts()
405
+
406
+ partial_full_hash = req0.block_hashes[6 // hash_block_size - 1]
407
+ partial_full_block = manager.block_pool.get_cached_block(
408
+ partial_full_hash, kv_cache_group_ids=[0]
409
+ )
410
+ assert partial_full_block is not None
411
+
412
+ req1 = make_request("1", [0, 0, 1, 1, 2, 2, 3, 3], hash_block_size, sha256)
413
+ computed_blocks, num_computed = manager.get_computed_blocks(req1)
414
+ assert num_computed == 6
415
+ assert [len(group) for group in computed_blocks.blocks] == [2, 2]
416
+
417
+ new_blocks = manager.allocate_slots(req1, 2, num_computed, computed_blocks)
418
+ assert new_blocks is not None
419
+ full_new_block_ids = new_blocks.get_block_ids()[0]
420
+ assert len(full_new_block_ids) == 1
421
+ assert full_new_block_ids[0] != partial_full_block[0].block_id
422
+ assert partial_full_block[0].block_hash is not None
423
+ assert get_block_hash(partial_full_block[0].block_hash) == partial_full_hash
424
+ assert get_group_id(partial_full_block[0].block_hash) == 0
425
+ assert partial_full_block[0].block_hash_num_tokens == 6
426
+ copies, retained = manager.take_kv_cache_block_copies()
427
+ assert (
428
+ KVCacheBlockCopy(
429
+ src_block_id=partial_full_block[0].block_id,
430
+ dst_block_id=full_new_block_ids[0],
431
+ )
432
+ in copies
433
+ )
434
+ assert partial_full_block[0].ref_cnt == 1
435
+ manager.block_pool.free_blocks(retained)
436
+ assert partial_full_block[0].ref_cnt == 0
437
+
438
+
439
+def test_hybrid_partial_hit_cow_target_starts_uncached():
440
+ hash_block_size = 2
441
+ block_size = 2 * hash_block_size
442
+ kv_cache_config = KVCacheConfig(
443
+ num_blocks=32,
444
+ kv_cache_tensors=[],
445
+ kv_cache_groups=[
446
+ KVCacheGroupSpec(
447
+ ["full"],
448
+ FullAttentionSpec(
449
+ block_size=block_size,
450
+ num_kv_heads=1,
451
+ head_size=1,
452
+ dtype=torch.float32,
453
+ ),
454
+ ),
455
+ KVCacheGroupSpec(
456
+ ["mamba"],
457
+ MambaSpec(
458
+ block_size=block_size,
459
+ shapes=(1, 1),
460
+ dtypes=(torch.float32,),
461
+ mamba_cache_mode="align",
462
+ ),
463
+ ),
464
+ ],
465
+ )
466
+ manager = make_kv_cache_manager(
467
+ kv_cache_config=kv_cache_config,
468
+ max_model_len=8192,
469
+ enable_caching=True,
470
+ hash_block_size=hash_block_size,
471
+ )
472
+
473
+ req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
474
+ computed_blocks, num_computed = manager.get_computed_blocks(req0)
475
+ assert num_computed == 0
476
+ assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
477
+ manager.free(req0)
478
+ manager.new_step_starts()
479
+
480
+ partial_hash = req0.block_hashes[6 // hash_block_size - 1]
481
+ partial_full_block = manager.block_pool.get_cached_block(
482
+ partial_hash, kv_cache_group_ids=[0]
483
+ )
484
+ partial_mamba_block = manager.block_pool.get_cached_block(
485
+ partial_hash, kv_cache_group_ids=[1]
486
+ )
487
+ assert partial_full_block is not None
488
+ assert partial_mamba_block is not None
489
+
490
+ req1 = make_request("1", [0, 0, 1, 1, 2, 2, 3, 3], hash_block_size, sha256)
491
+ computed_blocks, num_computed = manager.get_computed_blocks(req1)
492
+ assert num_computed == 6
493
+
494
+ new_blocks = manager.allocate_slots(
495
+ req1,
496
+ 2,
497
+ num_computed,
498
+ computed_blocks,
499
+ delay_cache_blocks=True,
500
+ )
501
+ assert new_blocks is not None
502
+
503
+ full_cow_block = manager.get_blocks("1").blocks[0][1]
504
+ mamba_cow_block = manager.get_blocks("1").blocks[1][1]
505
+ assert full_cow_block.block_id != partial_full_block[0].block_id
506
+ assert mamba_cow_block.block_id != partial_mamba_block[0].block_id
507
+ assert full_cow_block.block_hash is None
508
+ assert full_cow_block.block_hash_num_tokens is None
509
+ assert mamba_cow_block.block_hash is None
510
+ assert mamba_cow_block.block_hash_num_tokens is None
511
+
512
+ assert partial_full_block[0].block_hash is not None
513
+ assert get_block_hash(partial_full_block[0].block_hash) == partial_hash
514
+ assert get_group_id(partial_full_block[0].block_hash) == 0
515
+ assert partial_full_block[0].block_hash_num_tokens == 6
516
+ assert partial_mamba_block[0].block_hash is not None
517
+ assert get_block_hash(partial_mamba_block[0].block_hash) == partial_hash
518
+ assert get_group_id(partial_mamba_block[0].block_hash) == 1
519
+ assert partial_mamba_block[0].block_hash_num_tokens == 6
520
+
521
+
522
+def test_hybrid_partial_hash_truncates_full_attention_hit_length():
523
+ hash_block_size = 2
524
+ block_size = 2 * hash_block_size
525
+ kv_cache_config = KVCacheConfig(
526
+ num_blocks=24,
527
+ kv_cache_tensors=[],
528
+ kv_cache_groups=[
529
+ KVCacheGroupSpec(
530
+ ["full"],
531
+ FullAttentionSpec(
532
+ block_size=block_size,
533
+ num_kv_heads=1,
534
+ head_size=1,
535
+ dtype=torch.float32,
536
+ ),
537
+ ),
538
+ KVCacheGroupSpec(
539
+ ["mamba"],
540
+ MambaSpec(
541
+ block_size=block_size,
542
+ shapes=(1, 1),
543
+ dtypes=(torch.float32,),
544
+ mamba_cache_mode="align",
545
+ ),
546
+ ),
547
+ ],
548
+ )
549
+ manager = make_kv_cache_manager(
550
+ kv_cache_config=kv_cache_config,
551
+ max_model_len=8192,
552
+ enable_caching=True,
553
+ hash_block_size=hash_block_size,
554
+ )
555
+ pool = manager.block_pool
556
+ req = make_request(
557
+ "0",
558
+ [0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5],
559
+ hash_block_size,
560
+ sha256,
561
+ )
562
+
563
+ full_blocks = pool.get_new_blocks(3)
564
+ pool.cache_full_blocks(
565
+ request=req,
566
+ blocks=full_blocks,
567
+ num_cached_blocks=0,
568
+ num_full_blocks=2,
569
+ block_size=block_size,
570
+ kv_cache_group_id=0,
571
+ )
572
+ pool.cache_partial_block(
573
+ request=req,
574
+ block=full_blocks[2],
575
+ num_tokens=10,
576
+ kv_cache_group_id=0,
577
+ block_size=block_size,
578
+ )
579
+
580
+ mamba_block = pool.get_new_blocks(1)[0]
581
+ pool.cache_partial_block(
582
+ request=req,
583
+ block=mamba_block,
584
+ num_tokens=6,
585
+ kv_cache_group_id=1,
586
+ block_size=block_size,
587
+ )
588
+
589
+ computed_blocks, num_computed = manager.get_computed_blocks(req)
590
+ assert num_computed == 6
591
+ assert [len(group) for group in computed_blocks.blocks] == [2, 2]
592
+
593
+
594
+def test_cow_retained_blocks_returned_for_release():
595
+ """new_step_starts returns the CoW copy retentions instead of freeing
596
+ them; the scheduler owns releasing them once the copy has run."""
597
+ hash_block_size = 2
598
+ block_size = 2 * hash_block_size
599
+ kv_cache_config = KVCacheConfig(
600
+ num_blocks=24,
601
+ kv_cache_tensors=[],
602
+ kv_cache_groups=[
603
+ KVCacheGroupSpec(
604
+ ["full"],
605
+ FullAttentionSpec(
606
+ block_size=hash_block_size,
607
+ num_kv_heads=1,
608
+ head_size=1,
609
+ dtype=torch.float32,
610
+ ),
611
+ ),
612
+ KVCacheGroupSpec(
613
+ ["mamba"],
614
+ MambaSpec(
615
+ block_size=block_size,
616
+ shapes=(1, 1),
617
+ dtypes=(torch.float32,),
618
+ mamba_cache_mode="align",
619
+ ),
620
+ ),
621
+ ],
622
+ )
623
+ manager = make_kv_cache_manager(
624
+ kv_cache_config=kv_cache_config,
625
+ max_model_len=8192,
626
+ enable_caching=True,
627
+ hash_block_size=hash_block_size,
628
+ )
629
+ req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
630
+ computed_blocks, num_computed = manager.get_computed_blocks(req0)
631
+ assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
632
+
633
+ # The owner's move queues a copy and retains both endpoints.
634
+ req0.num_computed_tokens = 6
635
+ req0.append_output_token_ids([3])
636
+ assert manager.allocate_slots(req0, 1) is not None
637
+ (cow_copy,), retained = manager.take_kv_cache_block_copies()
638
+ assert {b.block_id for b in retained} == {
639
+ cow_copy.src_block_id,
640
+ cow_copy.dst_block_id,
641
+ }
642
+ # Not freed yet: the retention refs are still held.
643
+ assert all(b.ref_cnt > 0 for b in retained)
644
+ manager.block_pool.free_blocks(retained)
645
+
646
+
647
+def test_free_cow_retained_blocks_defers_until_copy_step_processed():
648
+ """Scheduler releases CoW retentions immediately when the copy's step has
649
+ been processed (or deferral is off), and defers them otherwise."""
650
+ from collections import deque
651
+
652
+ freed: list = []
653
+ blocks = [SimpleNamespace(block_id=7), SimpleNamespace(block_id=9)]
654
+ mock = SimpleNamespace(
655
+ kv_cache_manager=SimpleNamespace(
656
+ block_pool=SimpleNamespace(free_blocks=freed.extend)
657
+ ),
658
+ deferred_frees=deque(),
659
+ defer_block_free=True,
660
+ processed_step_seq=2,
661
+ )
662
+ free = Scheduler._free_cow_retained_blocks
663
+
664
+ # Copy step still in flight: deferred with its fence.
665
+ free(mock, list(blocks), fence_seq=3)
666
+ assert not freed
667
+ assert mock.deferred_frees == deque([(3, blocks[::-1])])
668
+
669
+ # Copy step processed: freed immediately.
670
+ mock.processed_step_seq = 3
671
+ free(mock, list(blocks), fence_seq=3)
672
+ assert freed == blocks
673
+
674
+ # Deferral disabled: freed immediately regardless of the fence.
675
+ freed.clear()
676
+ mock.deferred_frees.clear()
677
+ mock.defer_block_free = False
678
+ mock.processed_step_seq = 0
679
+ free(mock, list(blocks), fence_seq=3)
680
+ assert freed == blocks
681
+
682
+
683
+def test_full_attention_eagle_drops_one_hash_unit():
684
+ """With fine-grained partial hits, eagle rewinds the hit by one hash unit
685
+ instead of a whole cache block: the tail block's KV is append-only, so it
686
+ still covers the reduced length and stays in the hit as a partial block."""
687
+ from vllm.v1.core.block_pool import BlockPool
688
+ from vllm.v1.core.single_type_kv_cache_manager import FullAttentionManager
689
+
690
+ hash_block_size = 2
691
+ block_size = 4
692
+ pool = BlockPool(
693
+ num_gpu_blocks=10, enable_caching=True, hash_block_size=hash_block_size
694
+ )
695
+ spec = FullAttentionSpec(
696
+ block_size=block_size, num_kv_heads=1, head_size=1, dtype=torch.float32
697
+ )
698
+ req = make_request("0", [0, 0, 1, 1, 2, 2, 3, 3], hash_block_size, sha256)
699
+
700
+ def find(drop_eagle_block):
701
+ return FullAttentionManager.find_longest_cache_hit(
702
+ block_hashes=req.block_hashes,
703
+ max_length=8,
704
+ kv_cache_group_ids=[0],
705
+ block_pool=pool,
706
+ kv_cache_spec=spec,
707
+ drop_eagle_block=drop_eagle_block,
708
+ alignment_tokens=hash_block_size,
709
+ )
710
+
711
+ # Two full cached blocks (hit 8): eagle rewinds to 6, keeping the last
712
+ # block as a partial hit instead of dropping it to 4.
713
+ blocks = pool.get_new_blocks(2)
714
+ pool.cache_full_blocks(
715
+ request=req,
716
+ blocks=blocks,
717
+ num_cached_blocks=0,
718
+ num_full_blocks=2,
719
+ block_size=block_size,
720
+ kv_cache_group_id=0,
721
+ )
722
+ hit_blocks, hit_length = find(drop_eagle_block=False)
723
+ assert (hit_length, len(hit_blocks[0])) == (8, 2)
724
+ hit_blocks, hit_length = find(drop_eagle_block=True)
725
+ assert (hit_length, len(hit_blocks[0])) == (6, 2)
726
+
727
+ # A partial tail at 6 (block 1 not fully cached): eagle rewinds to the
728
+ # block boundary and trims the tail block.
729
+ pool2 = BlockPool(
730
+ num_gpu_blocks=10, enable_caching=True, hash_block_size=hash_block_size
731
+ )
732
+ pool = pool2
733
+ blocks = pool.get_new_blocks(2)
734
+ pool.cache_full_blocks(
735
+ request=req,
736
+ blocks=blocks[:1],
737
+ num_cached_blocks=0,
738
+ num_full_blocks=1,
739
+ block_size=block_size,
740
+ kv_cache_group_id=0,
741
+ )
742
+ assert (
743
+ pool.cache_partial_block(
744
+ request=req,
745
+ block=blocks[1],
746
+ num_tokens=6,
747
+ kv_cache_group_id=0,
748
+ block_size=block_size,
749
+ )
750
+ is not None
751
+ )
752
+ hit_blocks, hit_length = find(drop_eagle_block=False)
753
+ assert (hit_length, len(hit_blocks[0])) == (6, 2)
754
+ hit_blocks, hit_length = find(drop_eagle_block=True)
755
+ assert (hit_length, len(hit_blocks[0])) == (4, 1)
756
+
757
+
758
+def test_hybrid_partial_hit_with_eagle_stays_within_group_blocks():
759
+ """Regression: with eagle, the mamba group must not receive the eagle
760
+ lookup margin — its finder never applies the drop, so it could return a
761
+ hit past the blocks the (dropped) full-attention group covers, crashing
762
+ the consumer's CoW with block_idx >= len(req_blocks)."""
763
+ hash_block_size = 2
764
+ block_size = 2 * hash_block_size
765
+ kv_cache_config = KVCacheConfig(
766
+ num_blocks=32,
767
+ kv_cache_tensors=[],
768
+ kv_cache_groups=[
769
+ KVCacheGroupSpec(
770
+ ["full"],
771
+ FullAttentionSpec(
772
+ block_size=block_size,
773
+ num_kv_heads=1,
774
+ head_size=1,
775
+ dtype=torch.float32,
776
+ ),
777
+ ),
778
+ KVCacheGroupSpec(
779
+ ["mamba"],
780
+ MambaSpec(
781
+ block_size=block_size,
782
+ shapes=(1, 1),
783
+ dtypes=(torch.float32,),
784
+ mamba_cache_mode="align",
785
+ ),
786
+ ),
787
+ ],
788
+ )
789
+ manager = make_kv_cache_manager(
790
+ kv_cache_config=kv_cache_config,
791
+ max_model_len=8192,
792
+ enable_caching=True,
793
+ hash_block_size=hash_block_size,
794
+ use_eagle=True,
795
+ )
796
+
797
+ # The owner prefills in scheduler-split style: stop at the block boundary
798
+ # (4), then at the prompt's last hash boundary (6, partial entries).
799
+ req0 = make_request("0", [7] * 6, hash_block_size, sha256)
800
+ computed_blocks, num_computed = manager.get_computed_blocks(req0)
801
+ assert manager.allocate_slots(req0, 4, num_computed, computed_blocks) is not None
802
+ req0.num_computed_tokens = 4
803
+ manager.new_step_starts()
804
+ assert manager.allocate_slots(req0, 2) is not None
805
+ req0.num_computed_tokens = 6
806
+ manager.new_step_starts()
807
+
808
+ # A longer request with eagle: full attention drops the partial tail, so
809
+ # the joint hit must fall back to the block boundary the FA blocks cover.
810
+ req1 = make_request("1", [7] * 6 + [9] * 2, hash_block_size, sha256)
811
+ computed_blocks, num_computed = manager.get_computed_blocks(req1)
812
+ assert num_computed == 4
813
+ assert all(
814
+ len(group) * block_size >= num_computed for group in computed_blocks.blocks
815
+ )
816
+ assert manager.allocate_slots(req1, 4, num_computed, computed_blocks) is not None
tests/v1/core/test_deferred_block_free.py
+62
@@ -18,6 +18,7 @@ from unittest.mock import PropertyMock, patch
18
import pytest
19
20
from vllm.config import VllmConfig
21
+from vllm.v1.core.kv_cache_utils import KVCacheBlockCopy
22
from vllm.v1.core.sched.output import SchedulerOutput
23
from vllm.v1.outputs import ModelRunnerOutput
24
from vllm.v1.request import RequestStatus
@@ -412,3 +413,64 @@ def test_non_async_abort_defers_via_last_sched_seq():
413
scheduler.update_from_output(out0, _make_model_runner_output(out0))
414
assert not scheduler.deferred_frees
415
assert pool.get_num_free_blocks() == num_free_initially
416
+
417
+
418
+def test_cow_retentions_deferred_until_copy_step_processed():
419
+ """The endpoints of a queued KV block copy must stay out of the free
420
+ pool until the step that runs the copy has been processed. Freed
421
+ earlier, an endpoint can be reallocated (e.g. as a PD KV-load
422
+ destination) and overwritten by a transfer that is not ordered against
423
+ the copy still pending in the in-flight step.
424
+ """
425
+ scheduler = _create_deferring_scheduler()
426
+ pool = scheduler.kv_cache_manager.block_pool
427
+ manager = scheduler.kv_cache_manager.coordinator.single_type_managers[0]
428
+
429
+ request = create_requests(
430
+ num_requests=1,
431
+ num_tokens=NUM_PROMPT_TOKENS,
432
+ max_tokens=5,
433
+ stop_token_ids=[STOP_TOKEN_ID],
434
+ )[0]
435
+ scheduler.add_request(request)
436
+
437
+ # Simulate a partial-hit CoW performed while scheduling step 1, whose
438
+ # hitting request was freed within the same step: the copy rides out0
439
+ # and each endpoint stays alive only through its copy retention.
440
+ src_block, dst_block = pool.get_new_blocks(2)
441
+ block_copy = KVCacheBlockCopy(
442
+ src_block_id=src_block.block_id, dst_block_id=dst_block.block_id
443
+ )
444
+ manager._pending_cow_copies.append((src_block, dst_block))
445
+ out0 = scheduler.schedule()
446
+ assert out0.kv_cache_block_copies == [block_copy]
447
+
448
+ # Exhaust the rest of the pool so the copy endpoints are the only blocks
449
+ # a new request could receive, then add one that fits exactly in them.
450
+ pool.get_new_blocks(pool.get_num_free_blocks())
451
+ late_request = create_requests(
452
+ num_requests=1,
453
+ num_tokens=2 * scheduler.block_size,
454
+ max_tokens=5,
455
+ req_ids=["late"],
456
+ )[0]
457
+ scheduler.add_request(late_request)
458
+
459
+ # Step 2 is scheduled while step 1 (which runs the copy) is still in
460
+ # flight: the retentions are released against the copy's fence, so the
461
+ # endpoints must not reach the free pool -- the late request must not be
462
+ # scheduled onto them.
463
+ out1 = scheduler.schedule()
464
+ assert src_block.ref_cnt == 1
465
+ assert dst_block.ref_cnt == 1
466
+ assert scheduler.deferred_frees
467
+ assert not out1.scheduled_new_reqs
468
+
469
+ # Step 1's output is processed: the copy has run, endpoints return to
470
+ # the pool and the late request can be scheduled onto them safely.
471
+ scheduler.update_from_output(out0, _make_model_runner_output(out0))
472
+ assert src_block.ref_cnt == 0
473
+ assert dst_block.ref_cnt == 0
474
+ assert not scheduler.deferred_frees
475
+ out2 = scheduler.schedule()
476
+ assert [r.req_id for r in out2.scheduled_new_reqs] == ["late"]
tests/v1/core/test_kv_cache_utils.py
+60
@@ -2597,3 +2597,63 @@ def test_hma_not_disabled_when_kv_events_enabled():
2597
assert vllm_config.scheduler_config.disable_hybrid_kv_cache_manager is False, (
2598
"kv_events_config must not force-disable the hybrid KV cache manager."
2599
)
2600
+
2601
+
2602
+def test_resolve_block_hashes_gate():
2603
+ # Resolve symbols through the module so they stay consistent with each other
2604
+ # even after other tests reload ``kv_cache_utils``.
2605
+ resolve_block_hashes = kv_cache_utils.resolve_block_hashes
2606
+ BlockHashListWithBlockSize = kv_cache_utils.BlockHashListWithBlockSize
2607
+ # Raw, hash_block_size-granularity hashes (contents are opaque here).
2608
+ raw = [BlockHash(bytes([i])) for i in range(8)]
2609
+
2610
+ # block_size == hash_block_size: always reuse the raw hashes.
2611
+ assert resolve_block_hashes(raw, 2, 2, alignment_tokens=2) is raw
2612
+ assert (
2613
+ resolve_block_hashes(
2614
+ raw, 2, 2, supports_fine_grained_hash_lookup=True, alignment_tokens=2
2615
+ )
2616
+ is raw
2617
+ )
2618
+
2619
+ # Fine-grained manager, partial hits ON (alignment_tokens == hash_block_size
2620
+ # < block_size): keep raw hashes so the manager can scan at hash granularity.
2621
+ assert (
2622
+ resolve_block_hashes(
2623
+ raw, 2, 4, supports_fine_grained_hash_lookup=True, alignment_tokens=2
2624
+ )
2625
+ is raw
2626
+ )
2627
+
2628
+ # Fine-grained manager, partial hits OFF (alignment_tokens ==
2629
+ # scheduler_block_size >= block_size): must fall back to a block-size view,
2630
+ # exactly like the pre-refactor coordinator did.
2631
+ off = resolve_block_hashes(
2632
+ raw, 2, 4, supports_fine_grained_hash_lookup=True, alignment_tokens=4
2633
+ )
2634
+ assert isinstance(off, BlockHashListWithBlockSize)
2635
+ assert off.scale_factor == 2
2636
+
2637
+ # Non-fine-grained manager (e.g. sliding window): always a block-size view
2638
+ # when block_size != hash_block_size, regardless of alignment_tokens.
2639
+ swa = resolve_block_hashes(
2640
+ raw, 2, 4, supports_fine_grained_hash_lookup=False, alignment_tokens=2
2641
+ )
2642
+ assert isinstance(swa, BlockHashListWithBlockSize)
2643
+ assert swa.scale_factor == 2
2644
+
2645
+
2646
+def test_resolve_block_hashes_rejects_mismatched_view():
2647
+ resolve_block_hashes = kv_cache_utils.resolve_block_hashes
2648
+ BlockHashListWithBlockSize = kv_cache_utils.BlockHashListWithBlockSize
2649
+ raw = [BlockHash(bytes([i])) for i in range(8)]
2650
+
2651
+ # A view built at this block_size is returned as-is (idempotent).
2652
+ view = BlockHashListWithBlockSize(raw, 2, 4)
2653
+ assert resolve_block_hashes(view, 2, 4) is view
2654
+
2655
+ # A view built at a different block_size must fail loudly rather than be
2656
+ # silently reinterpreted at the wrong granularity.
2657
+ mismatched = BlockHashListWithBlockSize(raw, 2, 8)
2658
+ with pytest.raises(AssertionError):
2659
+ resolve_block_hashes(mismatched, 2, 4)
tests/v1/core/test_prefix_caching.py
+4
-1
@@ -1072,7 +1072,10 @@ def test_hybrid_cache_mamba_align_shared_prefix_detection():
1072
# Next, validate scheduler logic for num_uncached_common_prefix_tokens > 0
1073
# Create minimal mock with just the needed attributes
1074
mock = SimpleNamespace(
1075
- cache_config=SimpleNamespace(block_size=block_size), use_eagle=False
1075
+ cache_config=SimpleNamespace(block_size=block_size),
1076
+ use_eagle=False,
1077
+ hash_block_size=block_size,
1078
+ mamba_partial_cache_hit=False,
1079
)
1080
num_new_tokens_adjusted = Scheduler._mamba_block_aligned_split(
1081
self=mock,
tests/v1/core/test_single_type_kv_cache_manager.py
+8
-6
@@ -93,7 +93,7 @@ def test_chunked_local_attention_possible_cached_prefix():
93
kv_cache_spec=chunked_local_attention_spec,
94
drop_eagle_block=False,
95
alignment_tokens=block_size,
96
- )[0]
96
+ )[0][0]
97
assert len(computed_blocks) == expect_length
98
99
assert all(
@@ -164,7 +164,7 @@ def test_sliding_window_possible_cached_prefix():
164
kv_cache_spec=sliding_window_spec,
165
drop_eagle_block=False,
166
alignment_tokens=block_size,
167
- )[0]
167
+ )[0][0]
168
assert len(computed_blocks) == expect_length
169
170
assert all(
@@ -398,13 +398,13 @@ def test_get_num_blocks_to_allocate():
398
399
assert (
400
manager.get_num_blocks_to_allocate(
401
- "1", 20 * block_size, cached_blocks_1, 0, 20 * block_size
401
+ "1", 20 * block_size, cached_blocks_1, 0, 0, 20 * block_size
402
)
403
== 20
404
)
405
assert (
406
manager.get_num_blocks_to_allocate(
407
- "2", 20 * block_size, cached_blocks_2, 0, 20 * block_size
407
+ "2", 20 * block_size, cached_blocks_2, 0, 0, 20 * block_size
408
)
409
== 15
410
)
@@ -434,6 +434,7 @@ def test_evictable_cached_blocks_not_double_allocated():
434
num_tokens=2 * block_size,
435
new_computed_blocks=[evictable_block],
436
total_computed_tokens=block_size,
437
+ num_local_computed_tokens=block_size,
438
num_tokens_main_model=2 * block_size,
439
)
440
# Free capacity check should count evictable cached blocks, but allocation
@@ -474,13 +475,13 @@ def test_chunked_local_attention_get_num_blocks_to_allocate():
475
476
assert (
477
manager.get_num_blocks_to_allocate(
477
- "1", 20 * block_size, cached_blocks_1, 0, 20 * block_size
478
+ "1", 20 * block_size, cached_blocks_1, 0, 0, 20 * block_size
479
)
480
== 20
481
)
482
assert (
483
manager.get_num_blocks_to_allocate(
483
- "2", 20 * block_size, cached_blocks_2, 0, 20 * block_size
484
+ "2", 20 * block_size, cached_blocks_2, 0, 0, 20 * block_size
485
)
486
== 15
487
)
@@ -524,6 +525,7 @@ def test_predictor_matches_allocator_blocks_calculation_with_admission_cap():
525
num_tokens=num_tokens,
526
new_computed_blocks=[],
527
total_computed_tokens=total_computed,
528
+ num_local_computed_tokens=0,
529
num_tokens_main_model=num_tokens,
530
)
531
new_blocks = manager.allocate_new_blocks(
tests/v1/kv_connector/unit/test_mooncake_store_coordinator.py
+15
-15
@@ -37,7 +37,7 @@ def _make_coord(groups, hash_block_size, use_eagle=False, retention_interval=Non
37
38
39
def test_external_cached_block_pool_tautological_returns_present_for_any_hash():
40
- cmap = ExternalCachedBlockPool()
40
+ cmap = ExternalCachedBlockPool(16)
41
h = BlockHash(b"\xaa" * 4)
42
res = cmap.get_cached_block(h, [0, 1])
43
assert res is not None
@@ -48,7 +48,7 @@ def test_external_cached_block_pool_tautological_returns_present_for_any_hash():
48
49
def test_external_cached_block_pool_hit_all_groups():
50
h = BlockHash(b"\x11\x22\x33\x44")
51
- cmap = ExternalCachedBlockPool({(0, bytes(h)), (1, bytes(h))})
51
+ cmap = ExternalCachedBlockPool(16, {(0, bytes(h)), (1, bytes(h))})
52
res = cmap.get_cached_block(h, [0, 1])
53
assert res is not None
54
assert len(res) == 2
@@ -58,14 +58,14 @@ def test_external_cached_block_pool_hit_all_groups():
58
59
def test_external_cached_block_pool_miss_one_group():
60
h = BlockHash(b"\x11\x22\x33\x44")
61
- cmap = ExternalCachedBlockPool({(0, bytes(h))})
61
+ cmap = ExternalCachedBlockPool(16, {(0, bytes(h))})
62
assert cmap.get_cached_block(h, [0, 1]) is None
63
64
65
def test_external_cached_block_pool_unknown_hash():
66
h_known = BlockHash(b"\x01" * 4)
67
h_unknown = BlockHash(b"\x02" * 4)
68
- cmap = ExternalCachedBlockPool({(0, bytes(h_known))})
68
+ cmap = ExternalCachedBlockPool(16, {(0, bytes(h_known))})
69
assert cmap.get_cached_block(h_unknown, [0]) is None
70
71
@@ -103,7 +103,7 @@ def test_coordinator_single_full_attention_all_hits():
103
groups = [KVCacheGroupSpec(["L0"], _full(16))]
104
coord = _make_coord(groups, hash_block_size=16)
105
hs = _hashes(4)
106
- cmap = ExternalCachedBlockPool({(0, bytes(h)) for h in hs})
106
+ cmap = ExternalCachedBlockPool(16, {(0, bytes(h)) for h in hs})
107
masks, hit = coord.find_longest_cache_hit(hs, max_length=64, cached_block_pool=cmap)
108
assert hit == 64
109
assert masks[0] == [True, True, True, True]
@@ -113,7 +113,7 @@ def test_coordinator_single_full_attention_partial_prefix():
113
groups = [KVCacheGroupSpec(["L0"], _full(16))]
114
coord = _make_coord(groups, hash_block_size=16)
115
hs = _hashes(4)
116
- cmap = ExternalCachedBlockPool({(0, bytes(hs[0])), (0, bytes(hs[1]))})
116
+ cmap = ExternalCachedBlockPool(16, {(0, bytes(hs[0])), (0, bytes(hs[1]))})
117
masks, hit = coord.find_longest_cache_hit(hs, max_length=64, cached_block_pool=cmap)
118
assert hit == 32
119
assert masks[0] == [True, True]
@@ -123,7 +123,7 @@ def test_coordinator_single_full_attention_no_hits():
123
groups = [KVCacheGroupSpec(["L0"], _full(16))]
124
coord = _make_coord(groups, hash_block_size=16)
125
hs = _hashes(4)
126
- cmap = ExternalCachedBlockPool(set())
126
+ cmap = ExternalCachedBlockPool(16, set())
127
masks, hit = coord.find_longest_cache_hit(hs, max_length=64, cached_block_pool=cmap)
128
assert hit == 0
129
assert masks[0] == []
@@ -135,7 +135,7 @@ def test_coordinator_single_swa_tautological_pool_masks_pre_window():
135
groups = [KVCacheGroupSpec(["L0"], _swa(block_size=16, sliding_window=32))]
136
coord = _make_coord(groups, hash_block_size=16)
137
hs = _hashes(4) # 4 chunks * 16 tokens
138
- cmap = ExternalCachedBlockPool()
138
+ cmap = ExternalCachedBlockPool(16)
139
masks, hit = coord.find_longest_cache_hit(hs, max_length=64, cached_block_pool=cmap)
140
assert hit == 64
141
# ceil((sw-1)/block_size) = ceil(31/16) = 2 tail blocks.
@@ -153,7 +153,7 @@ def test_coordinator_hybrid_full_plus_swa_all_hit():
153
]
154
coord = _make_coord(groups, hash_block_size=16)
155
hs = _hashes(4)
156
- cmap = ExternalCachedBlockPool({(g, bytes(h)) for g in (0, 1) for h in hs})
156
+ cmap = ExternalCachedBlockPool(16, {(g, bytes(h)) for g in (0, 1) for h in hs})
157
_masks, hit = coord.find_longest_cache_hit(
158
hs, max_length=64, cached_block_pool=cmap
159
)
@@ -169,7 +169,7 @@ def test_coordinator_hybrid_hole_in_full_clips_both():
169
hs = _hashes(4)
170
exists = {(0, bytes(hs[0])), (0, bytes(hs[2])), (0, bytes(hs[3]))}
171
exists |= {(1, bytes(h)) for h in hs}
172
- cmap = ExternalCachedBlockPool(exists)
172
+ cmap = ExternalCachedBlockPool(16, exists)
173
_masks, hit = coord.find_longest_cache_hit(
174
hs, max_length=64, cached_block_pool=cmap
175
)
@@ -188,7 +188,7 @@ def test_coordinator_group_block_size_double_hash():
188
big_hashes = list(chunk_hashes_for_block_size(hs, 16, 32))
189
exists = {(0, bytes(h)) for h in hs}
190
exists |= {(1, bytes(bh)) for bh in big_hashes}
191
- cmap = ExternalCachedBlockPool(exists)
191
+ cmap = ExternalCachedBlockPool(16, exists)
192
_masks, hit = coord.find_longest_cache_hit(
193
hs, max_length=64, cached_block_pool=cmap
194
)
@@ -405,7 +405,7 @@ def test_lookup_with_eagle_pops_last_full_attention_block():
405
groups = [KVCacheGroupSpec(["L0"], _full(16))]
406
coord = _make_coord(groups, hash_block_size=16, use_eagle=True)
407
hs = _hashes(4)
408
- cmap = ExternalCachedBlockPool({(0, bytes(h)) for h in hs})
408
+ cmap = ExternalCachedBlockPool(16, {(0, bytes(h)) for h in hs})
409
_masks, hit = coord.find_longest_cache_hit(
410
hs, max_length=64, cached_block_pool=cmap
411
)
@@ -426,7 +426,7 @@ def test_load_mask_with_eagle_does_not_double_prune_full_attention():
426
groups = [KVCacheGroupSpec(["L0"], _full(16))]
427
coord = _make_coord(groups, hash_block_size=16, use_eagle=True)
428
hs = _hashes(4)
429
- cmap = ExternalCachedBlockPool({(0, bytes(h)) for h in hs})
429
+ cmap = ExternalCachedBlockPool(16, {(0, bytes(h)) for h in hs})
430
_masks, hit = coord.find_longest_cache_hit(
431
hs, max_length=64, cached_block_pool=cmap
432
)
@@ -450,7 +450,7 @@ def test_load_mask_with_eagle_hybrid_full_plus_swa():
450
coord = _make_coord(groups, hash_block_size=16, use_eagle=True)
451
hs = _hashes(4)
452
exists = {(g, bytes(h)) for g in (0, 1) for h in hs}
453
- cmap = ExternalCachedBlockPool(exists)
453
+ cmap = ExternalCachedBlockPool(16, exists)
454
_masks, hit = coord.find_longest_cache_hit(
455
hs, max_length=64, cached_block_pool=cmap
456
)
@@ -469,7 +469,7 @@ def test_load_mask_without_eagle_unchanged():
469
groups = [KVCacheGroupSpec(["L0"], _full(16))]
470
coord = _make_coord(groups, hash_block_size=16, use_eagle=False)
471
hs = _hashes(4)
472
- cmap = ExternalCachedBlockPool({(0, bytes(h)) for h in hs})
472
+ cmap = ExternalCachedBlockPool(16, {(0, bytes(h)) for h in hs})
473
_masks, hit = coord.find_longest_cache_hit(
474
hs, max_length=64, cached_block_pool=cmap
475
)
tests/v1/kv_connector/unit/test_mooncake_store_hma_e2e.py
+1
@@ -65,6 +65,7 @@ def _minimal_vllm_config(cache_block_size=16):
65
cfg.cache_config.block_size = cache_block_size
66
cfg.cache_config.num_gpu_blocks = 4
67
cfg.cache_config.hash_block_size = None
68
+ cfg.cache_config.prefix_match_unit = None
69
cfg.cache_config.enable_prefix_caching = True
70
cfg.parallel_config.prefill_context_parallel_size = 1
71
cfg.parallel_config.decode_context_parallel_size = 1
vllm/config/cache.py
+10
-10
@@ -53,17 +53,17 @@ class CacheConfig:
53
"""Whether block_size was explicitly provided. Derived automatically."""
54
user_specified_mamba_block_size: bool = field(default=False, init=False)
55
"""Whether mamba_block_size was explicitly provided. Derived automatically."""
56
- hash_block_size: int | None = Field(default=None, gt=0)
57
- """Block size (in tokens) used for computing Request's block_hashes.
56
+ prefix_match_unit: int | None = Field(default=None, gt=0)
57
+ """The finest token boundary (in tokens) a prefix-cache hit can land on.
58
59
- This can be set to a finer granularity than the physical KV cache block
60
- sizes (e.g. 8) as long as every KV cache group's `block_size` is divisible
61
- by it. This enables prefix-caching keys to be computed at the finest common
62
- granularity and then merged for larger physical block sizes.
59
+ Prefix-cache keys are computed every `prefix_match_unit` tokens. It can
60
+ be set finer than the physical KV cache block sizes (e.g. 32 vs a
61
+ 1024-token hybrid-model block) as long as every KV cache group's
62
+ `block_size` is divisible by it, enabling cache hits at boundaries
63
+ inside a physical block. It controls matching granularity only, not how
64
+ often states are stored.
65
64
- This config is not static default. If left unspecified, vLLM will choose a
65
- default based on the resolved KV cache groups (typically the smallest KV
66
- cache block size when there are multiple groups).
66
+ This equals to the `hash_block_size` used throughout the KV cache code.
67
"""
68
gpu_memory_utilization: float = Field(default=0.92, gt=0, le=1)
69
"""The fraction of GPU memory to be used for the model executor, which can
@@ -209,7 +209,7 @@ class CacheConfig:
209
"enable_prefix_caching",
210
"prefix_caching_hash_algo",
211
# Prefix-caching implementation detail (doesn't affect compiled graph).
212
- "hash_block_size",
212
+ "prefix_match_unit",
213
"mamba_page_size_padded",
214
"skip_page_size_padded",
215
"user_specified_block_size",
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/coordinator.py
+17
-9
@@ -29,10 +29,15 @@ from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry
29
class ExternalCachedBlockPool:
30
"""Duck-typed BlockPool backed by a ``(group_id, hash)`` exists set."""
31
32
- def __init__(self, exists: set[tuple[int, bytes]] | None = None) -> None:
32
+ def __init__(
33
+ self,
34
+ hash_block_size: int,
35
+ exists: set[tuple[int, bytes]] | None = None,
36
+ ) -> None:
37
# ``exists=None`` is used on the recv side where hit_length is already
38
# determined and we just want each spec's manager to apply its own mask.
39
self._exists = exists
40
+ self.hash_block_size = hash_block_size
41
self.null_block = KVCacheBlock(block_id=0)
42
# Dummy ID 1 for present block for duck-typing.
43
self._present_block = KVCacheBlock(block_id=1)
@@ -166,7 +171,7 @@ class MooncakeStoreCoordinator:
171
masks, _ = self.find_longest_cache_hit(
172
block_hashes,
173
token_len,
169
- ExternalCachedBlockPool(),
174
+ ExternalCachedBlockPool(self.hash_block_size),
175
apply_eagle=False,
176
)
177
return masks
@@ -270,7 +275,7 @@ class MooncakeStoreCoordinator:
275
if len(self.attention_groups) == 1:
276
spec, group_ids, manager_cls = self.attention_groups[0]
277
hashes = self.block_hashes_for_spec(block_hashes, spec)
273
- hit_blocks = manager_cls.find_longest_cache_hit(
278
+ hit_blocks, hit_length = manager_cls.find_longest_cache_hit(
279
block_hashes=hashes, # type: ignore[arg-type]
280
max_length=max_length,
281
kv_cache_group_ids=group_ids,
@@ -283,11 +288,12 @@ class MooncakeStoreCoordinator:
288
blocks_by_group: list[list[KVCacheBlock]] = [[] for _ in range(num_groups)]
289
for gid, blks in zip(group_ids, hit_blocks, strict=True):
290
blocks_by_group[gid] = blks
286
- return tuple(blocks_by_group), len(hit_blocks[0]) * spec.block_size
291
+ return tuple(blocks_by_group), hit_length
292
293
num_groups = len(self.kv_cache_groups)
294
hit_length = max_length
295
hit_blocks_by_group: list[list[KVCacheBlock] | None] = [None] * num_groups
296
+ hit_length_by_group: list[int] = [0] * num_groups
297
298
is_simple_hybrid = len(self.attention_groups) == 2 and isinstance(
299
self.attention_groups[0][0], FullAttentionSpec
@@ -298,10 +304,11 @@ class MooncakeStoreCoordinator:
304
curr_hit_length = hit_length
305
306
for idx, (spec, group_ids, manager_cls) in enumerate(self.attention_groups):
301
- cached = hit_blocks_by_group[group_ids[0]]
307
+ first_group_id = group_ids[0]
308
+ cached = hit_blocks_by_group[first_group_id]
309
if isinstance(spec, FullAttentionSpec) and cached is not None:
303
- curr_hit_length = (
304
- curr_hit_length // spec.block_size * spec.block_size
310
+ curr_hit_length = min(
311
+ curr_hit_length, hit_length_by_group[first_group_id]
312
)
313
continue
314
@@ -310,7 +317,7 @@ class MooncakeStoreCoordinator:
317
if drop_eagle_block:
318
_max_length = min(curr_hit_length + spec.block_size, max_length)
319
hashes = self.block_hashes_for_spec(block_hashes, spec)
313
- hit_blocks = manager_cls.find_longest_cache_hit(
320
+ hit_blocks, _new_hit_length = manager_cls.find_longest_cache_hit(
321
block_hashes=hashes, # type: ignore[arg-type]
322
max_length=_max_length,
323
kv_cache_group_ids=group_ids,
@@ -319,7 +326,6 @@ class MooncakeStoreCoordinator:
326
drop_eagle_block=drop_eagle_block,
327
alignment_tokens=self.lcm_block_size,
328
)
322
- _new_hit_length = len(hit_blocks[0]) * spec.block_size
329
if drop_eagle_block:
330
eagle_verified.add(idx)
331
elif _new_hit_length < curr_hit_length:
@@ -327,6 +333,7 @@ class MooncakeStoreCoordinator:
333
curr_hit_length = _new_hit_length
334
for gid, blocks in zip(group_ids, hit_blocks, strict=True):
335
hit_blocks_by_group[gid] = blocks
336
+ hit_length_by_group[gid] = _new_hit_length
337
338
if curr_hit_length >= hit_length:
339
break
@@ -343,6 +350,7 @@ class MooncakeStoreCoordinator:
350
full_blks = hit_blocks_by_group[gid]
351
assert full_blks is not None
352
del full_blks[num_blocks:]
353
+ hit_length_by_group[gid] = hit_length
354
355
return (
356
tuple(blks if blks is not None else [] for blks in hit_blocks_by_group),
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/worker.py
+3
-1
@@ -1523,7 +1523,9 @@ class MooncakeStoreWorker:
1523
}
1524
1525
_masks, hit_length = self.coord.find_longest_cache_hit(
1526
- block_hashes, token_len, ExternalCachedBlockPool(exists_set)
1526
+ block_hashes,
1527
+ token_len,
1528
+ ExternalCachedBlockPool(self.hash_block_size, exists_set),
1529
)
1530
return hit_length
1531
vllm/engine/arg_utils.py
+5
@@ -693,6 +693,7 @@ class EngineArgs:
693
mamba_cache_dtype: MambaDType = CacheConfig.mamba_cache_dtype
694
mamba_ssm_cache_dtype: MambaDType = CacheConfig.mamba_ssm_cache_dtype
695
mamba_block_size: int | None = get_field(CacheConfig, "mamba_block_size")
696
+ prefix_match_unit: int | None = get_field(CacheConfig, "prefix_match_unit")
697
mamba_cache_mode: MambaCacheMode = CacheConfig.mamba_cache_mode
698
699
mamba_backend: MambaBackendEnum = MambaBackendEnum.TRITON
@@ -1194,6 +1195,9 @@ class EngineArgs:
1195
cache_group.add_argument(
1196
"--mamba-block-size", **cache_kwargs["mamba_block_size"]
1197
)
1198
+ cache_group.add_argument(
1199
+ "--prefix-match-unit", **cache_kwargs["prefix_match_unit"]
1200
+ )
1201
cache_group.add_argument(
1202
"--mamba-cache-mode", **cache_kwargs["mamba_cache_mode"]
1203
)
@@ -1899,6 +1903,7 @@ class EngineArgs:
1903
mamba_cache_dtype=self.mamba_cache_dtype,
1904
mamba_ssm_cache_dtype=self.mamba_ssm_cache_dtype,
1905
mamba_block_size=self.mamba_block_size,
1906
+ prefix_match_unit=self.prefix_match_unit,
1907
mamba_cache_mode=self.mamba_cache_mode,
1908
kv_offloading_size=self.kv_offloading_size,
1909
kv_offloading_backend=self.kv_offloading_backend,
vllm/v1/core/block_pool.py
+24
-2
@@ -260,7 +260,9 @@ class BlockPool:
260
return
261
new_full_blocks = blocks[num_cached_blocks:num_full_blocks]
262
assert block_mask is None or len(block_mask) == len(new_full_blocks)
263
- block_hashes = resolve_block_hashes(request, self.hash_block_size, block_size)
263
+ block_hashes = resolve_block_hashes(
264
+ request.block_hashes, self.hash_block_size, block_size
265
+ )
266
267
new_block_hashes = block_hashes[num_cached_blocks:]
268
new_hashes: list[ExternalBlockHash] | None = (
@@ -390,7 +392,9 @@ class BlockPool:
392
if not self.enable_kv_cache_events or num_cached_blocks == 0:
393
return
394
393
- block_hashes = resolve_block_hashes(request, self.hash_block_size, block_size)
395
+ block_hashes = resolve_block_hashes(
396
+ request.block_hashes, self.hash_block_size, block_size
397
+ )
398
399
# Collect external hashes and extra_keys for cached blocks.
400
cached_hashes: list[ExternalBlockHash] = []
@@ -622,6 +626,24 @@ class BlockPool:
626
)
627
self.cached_block_hash_to_block.insert(block_hash_with_group_id, block)
628
629
+ def move_block_hashes(
630
+ self,
631
+ src_block: KVCacheBlock,
632
+ dst_block: KVCacheBlock,
633
+ ) -> None:
634
+ """Re-point ``src_block``'s prefix-cache entries to ``dst_block``.
635
+
636
+ Used when the request owning ``src_block`` keeps writing into it
637
+ : the prefix cache holds a private copy (``dst_block``)
638
+ under the same hashes instead. Entries stay live; no events emitted.
639
+ """
640
+ assert dst_block.block_hash is None
641
+ assert dst_block.block_id not in self.cached_block_hashes_by_block
642
+ num_tokens = src_block.block_hash_num_tokens
643
+ for block_hash in self._remove_cached_block_hashes(src_block):
644
+ # `num_tokens` only applies to the first (primary) insertion.
645
+ self._insert_block_hash(block_hash, dst_block, num_tokens=num_tokens)
646
+
647
def get_new_blocks(self, num_blocks: int) -> list[KVCacheBlock]:
648
"""Get new blocks from the free block pool.
649
vllm/v1/core/kv_cache_coordinator.py
+71
-43
@@ -5,12 +5,11 @@ from collections.abc import Sequence
5
from typing import NamedTuple
6
7
from vllm import envs
8
+from vllm.utils.math_utils import cdiv
9
from vllm.v1.core.block_pool import BlockPool
10
from vllm.v1.core.kv_cache_metrics import KVCacheMetricsCollector
11
from vllm.v1.core.kv_cache_utils import (
12
BlockHash,
12
- BlockHashList,
13
- BlockHashListWithBlockSize,
13
KVCacheBlock,
14
)
15
from vllm.v1.core.single_type_kv_cache_manager import (
@@ -135,6 +134,7 @@ class KVCacheCoordinator(ABC):
134
new_computed_blocks: tuple[Sequence[KVCacheBlock], ...],
135
num_encoder_tokens: int,
136
total_computed_tokens: int,
137
+ num_local_computed_tokens: int,
138
num_tokens_main_model: int,
139
apply_admission_cap: bool = False,
140
) -> int:
@@ -150,6 +150,8 @@ class KVCacheCoordinator(ABC):
150
num_encoder_tokens: The number of encoder tokens for allocating
151
blocks for cross-attention.
152
total_computed_tokens: Include both local and external tokens.
153
+ num_local_computed_tokens: The number of local prefix-cache computed
154
+ tokens.
155
num_tokens_main_model: The number of tokens for the main model (aka target
156
model in spec decode). w/o spec decode, it is num_tokens;
157
with spec decode, it is num_tokens - num_lookahead_tokens.
@@ -171,6 +173,7 @@ class KVCacheCoordinator(ABC):
173
num_encoder_tokens,
174
[],
175
0,
176
+ 0,
177
num_encoder_tokens,
178
apply_admission_cap=apply_admission_cap,
179
)
@@ -180,6 +183,7 @@ class KVCacheCoordinator(ABC):
183
num_tokens,
184
new_computed_blocks[i],
185
total_computed_tokens,
186
+ num_local_computed_tokens,
187
num_tokens_main_model,
188
apply_admission_cap=apply_admission_cap,
189
)
@@ -370,7 +374,7 @@ class KVCacheCoordinator(ABC):
374
pass
375
376
def new_step_starts(self) -> None:
373
- """Called when a new step is started."""
377
+ """Notify each manager that a new step is starting."""
378
for manager in self.single_type_managers:
379
manager.new_step_starts()
380
@@ -483,7 +487,7 @@ class UnitaryKVCacheCoordinator(KVCacheCoordinator):
487
block_hashes: list[BlockHash],
488
max_cache_hit_length: int,
489
) -> tuple[tuple[list[KVCacheBlock], ...], int]:
486
- hit_blocks = self.single_type_managers[0].find_longest_cache_hit(
490
+ hit_blocks, hit_length = self.single_type_managers[0].find_longest_cache_hit(
491
block_hashes=block_hashes,
492
max_length=max_cache_hit_length,
493
kv_cache_group_ids=[0],
@@ -494,7 +498,7 @@ class UnitaryKVCacheCoordinator(KVCacheCoordinator):
498
dcp_world_size=self.dcp_world_size,
499
pcp_world_size=self.pcp_world_size,
500
)
497
- return hit_blocks, len(hit_blocks[0]) * self.block_size
501
+ return hit_blocks, hit_length
502
503
504
class SpecGroup(NamedTuple):
@@ -572,8 +576,26 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
576
"full-attention and Mamba groups, got: "
577
f"{type(g.kv_cache_spec).__name__}."
578
)
579
+ # Partial hash hits are limited to full-attention + mamba ("align")
580
+ # without context parallelism.
581
+ self.enable_partial_hash_hits = dcp_world_size == 1 and any(
582
+ isinstance(g.kv_cache_spec, MambaSpec)
583
+ and g.kv_cache_spec.mamba_cache_mode == "align"
584
+ and g.kv_cache_spec.block_size > hash_block_size
585
+ for g in kv_cache_config.kv_cache_groups
586
+ )
587
self.verify_and_split_kv_cache_groups()
588
589
+ @property
590
+ def _cache_hit_alignment_tokens(self) -> int:
591
+ # Fine-grained partial hits may return hash-block-aligned lengths;
592
+ # otherwise it must stay scheduler-block-aligned.
593
+ return (
594
+ self.hash_block_size
595
+ if self.enable_partial_hash_hits
596
+ else self.scheduler_block_size
597
+ )
598
+
599
def verify_and_split_kv_cache_groups(self) -> None:
600
"""
601
Groups KV cache groups by their spec type for efficient batch processing
@@ -617,14 +639,19 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
639
self.single_type_managers[gid].use_eagle = True
640
641
def cache_blocks(self, request: Request, num_computed_tokens: int) -> None:
620
- # Cache hits in this coordinator are always a multiple of
621
- # ``scheduler_block_size`` tokens (see ``find_longest_cache_hit``).
622
- # Within an aligned region, SWA groups may only consult a subset of blocks
623
- # per ``scheduler_block_size``-segment so the unused blocks also stay
624
- # out of the prefix-cache hash map.
625
- aligned_num_computed_tokens = (
626
- num_computed_tokens // self.scheduler_block_size * self.scheduler_block_size
627
- )
642
+ if self.enable_partial_hash_hits:
643
+ aligned_num_computed_tokens = num_computed_tokens
644
+ else:
645
+ # Cache hits in this coordinator are always a multiple of
646
+ # ``scheduler_block_size`` tokens (see ``find_longest_cache_hit``).
647
+ # Within an aligned region, SWA groups may only consult a subset of
648
+ # blocks per ``scheduler_block_size``-segment so the unused blocks
649
+ # also stay out of the prefix-cache hash map.
650
+ aligned_num_computed_tokens = (
651
+ num_computed_tokens
652
+ // self.scheduler_block_size
653
+ * self.scheduler_block_size
654
+ )
655
for manager in self.single_type_managers:
656
num_tokens_to_cache = aligned_num_computed_tokens
657
# EAGLE groups match one block past each aligned boundary and drop
@@ -667,17 +694,11 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
694
- The number of tokens of the longest cache hit.
695
"""
696
670
- def _get_block_hashes(block_size: int) -> BlockHashList:
671
- if block_size == self.hash_block_size:
672
- return block_hashes
673
- return BlockHashListWithBlockSize(
674
- block_hashes, self.hash_block_size, block_size
675
- )
676
-
697
num_groups = len(self.kv_cache_config.kv_cache_groups)
698
hit_length = max_cache_hit_length
699
longest_hit_length = 0
700
hit_blocks_by_group: list[list[KVCacheBlock] | None] = [None] * num_groups
701
+ hit_length_by_group: list[int] = [0] * num_groups
702
703
# Simple hybrid (1 full attn + 1 other): one iteration suffices.
704
# Full attn is always first if it exists.
@@ -696,40 +717,53 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
717
for idx, (spec, group_ids, manager_cls, use_eagle) in enumerate(
718
self.attention_groups
719
):
699
- group_block_size = self.single_type_managers[group_ids[0]].block_size
700
- cached_blocks = hit_blocks_by_group[group_ids[0]]
720
+ first_group_id = group_ids[0]
721
+ # DCP/PCP shard each block's KV across ranks, so the manager's
722
+ # effective block size may exceed the spec's.
723
+ group_block_size = self.single_type_managers[first_group_id].block_size
724
+ cached_blocks = hit_blocks_by_group[first_group_id]
725
if isinstance(spec, FullAttentionSpec) and cached_blocks is not None:
726
# Full attention is downward-closed: we only need to look
727
# up cached blocks once; on subsequent iterations just trim
728
# to the (reduced) current hit length.
705
- curr_hit_length = (
706
- curr_hit_length // group_block_size * group_block_size
729
+ curr_hit_length = min(
730
+ curr_hit_length, hit_length_by_group[first_group_id]
731
)
732
continue
733
734
drop_eagle_block = use_eagle and idx not in eagle_verified
735
736
_max_length = curr_hit_length
713
- if drop_eagle_block:
714
- # Eagle needs to match one more block and then pop the last.
737
+ # Eagle matches one extra drop unit (one hash unit for
738
+ # fine-grained managers, else one cache block) and then drops
739
+ # it, landing back at the candidate length. No margin for
740
+ # mamba: its finder never drops (draft models have no mamba
741
+ # layers), so the hit would grow past the candidate.
742
+ if drop_eagle_block and not isinstance(spec, MambaSpec):
743
+ eagle_margin = (
744
+ self.hash_block_size
745
+ if self.enable_partial_hash_hits
746
+ and manager_cls.supports_fine_grained_hash_lookup
747
+ and group_block_size > self.hash_block_size
748
+ else group_block_size
749
+ )
750
_max_length = min(
716
- curr_hit_length + group_block_size, max_cache_hit_length
751
+ curr_hit_length + eagle_margin, max_cache_hit_length
752
)
718
- hit_blocks = manager_cls.find_longest_cache_hit(
719
- block_hashes=_get_block_hashes(group_block_size),
753
+ hit_blocks, _new_hit_length = manager_cls.find_longest_cache_hit(
754
+ block_hashes=block_hashes,
755
max_length=_max_length,
756
kv_cache_group_ids=group_ids,
757
block_pool=self.block_pool,
758
kv_cache_spec=spec,
759
drop_eagle_block=drop_eagle_block,
725
- alignment_tokens=self.scheduler_block_size,
760
+ alignment_tokens=self._cache_hit_alignment_tokens,
761
dcp_world_size=(
762
self.dcp_world_size
763
if isinstance(spec, FullAttentionSpec)
764
else 1
765
),
766
)
732
- _new_hit_length = len(hit_blocks[0]) * group_block_size
767
if drop_eagle_block:
768
eagle_verified.add(idx)
769
elif _new_hit_length < curr_hit_length:
@@ -738,6 +772,7 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
772
curr_hit_length = _new_hit_length
773
for group_id, blocks in zip(group_ids, hit_blocks):
774
hit_blocks_by_group[group_id] = blocks
775
+ hit_length_by_group[group_id] = _new_hit_length
776
777
longest_hit_length = max(longest_hit_length, curr_hit_length)
778
@@ -753,10 +788,11 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
788
group_block_size = self.single_type_managers[
789
first_group.group_ids[0]
790
].block_size
756
- num_blocks = hit_length // group_block_size
791
+ num_blocks = cdiv(hit_length, group_block_size)
792
for group_id in first_group.group_ids:
793
if (blks := hit_blocks_by_group[group_id]) is not None:
794
del blks[num_blocks:]
795
+ hit_length_by_group[group_id] = hit_length
796
797
# Uncached shared prefix detection: If any attn. group cached a longer prefix
798
# than the current prefix, it is an uncached common prefix across requests:
@@ -776,28 +812,20 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
812
(blocks_per_group, hit_lengths_per_group)
813
"""
814
779
- def _get_block_hashes(kv_cache_spec: KVCacheSpec) -> BlockHashList:
780
- if kv_cache_spec.block_size == self.hash_block_size:
781
- return block_hashes
782
- return BlockHashListWithBlockSize(
783
- block_hashes, self.hash_block_size, kv_cache_spec.block_size
784
- )
785
-
815
num_groups = len(self.kv_cache_config.kv_cache_groups)
816
hit_blocks: list[list[KVCacheBlock]] = [[] for _ in range(num_groups)]
817
hit_lengths: list[int] = [0] * num_groups
818
819
for spec, group_ids, manager_cls, use_eagle in self.attention_groups:
791
- blocks = manager_cls.find_longest_cache_hit(
792
- block_hashes=_get_block_hashes(spec),
820
+ blocks, group_hit = manager_cls.find_longest_cache_hit(
821
+ block_hashes=block_hashes,
822
max_length=max_cache_hit_length,
823
kv_cache_group_ids=group_ids,
824
block_pool=self.block_pool,
825
kv_cache_spec=spec,
826
drop_eagle_block=use_eagle,
798
- alignment_tokens=self.scheduler_block_size,
827
+ alignment_tokens=self._cache_hit_alignment_tokens,
828
)
800
- group_hit = len(blocks[0]) * spec.block_size
829
for gid, blks in zip(group_ids, blocks):
830
hit_blocks[gid] = blks
831
hit_lengths[gid] = group_hit
vllm/v1/core/kv_cache_manager.py
+21
-2
@@ -11,7 +11,7 @@ from vllm.logger import init_logger
11
from vllm.utils.math_utils import cdiv
12
from vllm.v1.core.kv_cache_coordinator import get_kv_cache_coordinator
13
from vllm.v1.core.kv_cache_metrics import KVCacheMetricsCollector
14
-from vllm.v1.core.kv_cache_utils import KVCacheBlock
14
+from vllm.v1.core.kv_cache_utils import KVCacheBlock, KVCacheBlockCopy
15
from vllm.v1.kv_cache_interface import (
16
AttentionSpec,
17
CrossAttentionSpec,
@@ -404,6 +404,7 @@ class KVCacheManager:
404
new_computed_blocks=new_computed_block_list,
405
num_encoder_tokens=num_encoder_tokens,
406
total_computed_tokens=total_computed_tokens,
407
+ num_local_computed_tokens=num_local_computed_tokens,
408
num_tokens_main_model=full_num_tokens,
409
apply_admission_cap=True,
410
)
@@ -438,6 +439,7 @@ class KVCacheManager:
439
num_encoder_tokens=num_encoder_tokens,
440
total_computed_tokens=num_local_computed_tokens
441
+ num_external_computed_tokens,
442
+ num_local_computed_tokens=num_local_computed_tokens,
443
num_tokens_main_model=num_tokens_main_model,
444
)
445
@@ -665,6 +667,23 @@ class KVCacheManager:
667
ids.extend(mgr.take_new_block_ids())
668
return ids
669
670
+ def take_kv_cache_block_copies(
671
+ self,
672
+ ) -> tuple[list[KVCacheBlockCopy], list[KVCacheBlock]]:
673
+ """Drain pending copies and return their retained endpoints."""
674
+ pending_copies: list[tuple[KVCacheBlock, KVCacheBlock]] = []
675
+ for mgr in self.coordinator.single_type_managers:
676
+ pending_copies.extend(mgr.take_pending_cow_copies())
677
+ copies = [
678
+ KVCacheBlockCopy(
679
+ src_block_id=source_block.block_id,
680
+ dst_block_id=cow_block.block_id,
681
+ )
682
+ for source_block, cow_block in pending_copies
683
+ ]
684
+ retained_blocks = [block for pair in pending_copies for block in pair]
685
+ return copies, retained_blocks
686
+
687
def new_step_starts(self) -> None:
669
- """Called when a new step is started."""
688
+ """Notify the coordinator that a new step is starting."""
689
self.coordinator.new_step_starts()
vllm/v1/core/kv_cache_utils.py
+38
-19
@@ -10,7 +10,7 @@ from collections import defaultdict
10
from collections.abc import Callable, Iterable, Iterator, Sequence
11
from dataclasses import dataclass, replace
12
from functools import partial
13
-from typing import Any, NewType, TypeAlias, cast, overload
13
+from typing import Any, NamedTuple, NewType, TypeAlias, cast, overload
14
15
from vllm import envs
16
from vllm.config import VllmConfig
@@ -126,7 +126,7 @@ class KVCacheBlock:
126
# when the block is full and cached.
127
_block_hash: BlockHashWithGroupId | None = None
128
# Number of prefix tokens covered by _block_hash. For full blocks this is
129
- # the full block boundary; partial aliases can end inside a cache block.
129
+ # the full block boundary; partial entries can end inside a cache block.
130
_block_hash_num_tokens: int | None = None
131
132
# Used to construct a doubly linked list for free blocks.
@@ -176,6 +176,11 @@ class KVCacheBlock:
176
)
177
178
179
+class KVCacheBlockCopy(NamedTuple):
180
+ src_block_id: int
181
+ dst_block_id: int
182
+
183
+
184
class FreeKVCacheBlockQueue:
185
"""This class organizes a list of KVCacheBlock objects to a doubly linked
186
list of free blocks. We implement this class instead of using Python
@@ -631,11 +636,11 @@ def resolve_kv_cache_block_sizes(
636
Mamba groups keep their full per-rank state and are not scaled.
637
- ``hash_block_size`` is the granularity at which ``Request.block_hashes``
638
is computed. Single group: equals scheduler block size. Multiple groups:
634
- ``cache_config.hash_block_size`` override if set, else the GCD of group
635
- block sizes; every group's block size must be divisible by it. Returns
636
- the scheduler block size (i.e. disables finer hashing) if block hashing
637
- is inactive or a mamba group's block size diverges from the cache
638
- block size (mamba_cache_mode != "align").
639
+ ``cache_config.prefix_match_unit`` override if set, else the GCD of
640
+ group block sizes; every group's block size must be divisible by it.
641
+ Returns the scheduler block size (i.e. disables finer hashing) if block
642
+ hashing is inactive or a mamba group's block size diverges from the
643
+ cache block size (mamba_cache_mode != "align").
644
"""
645
cache_config = vllm_config.cache_config
646
dcp = vllm_config.parallel_config.decode_context_parallel_size
@@ -671,14 +676,14 @@ def resolve_kv_cache_block_sizes(
676
):
677
return scheduler_block_size, scheduler_block_size
678
674
- requested = cache_config.hash_block_size
679
+ requested = cache_config.prefix_match_unit
680
hash_block_size = (
681
requested if requested is not None else math.gcd(*group_block_sizes)
682
)
683
if any(bs % hash_block_size != 0 for bs in group_block_sizes):
684
raise ValueError(
680
- f"Invalid hash_block_size={hash_block_size}; all KV cache group "
681
- f"block sizes must be divisible by hash_block_size. "
685
+ f"Invalid prefix_match_unit={hash_block_size}; all KV cache group "
686
+ f"block sizes must be divisible by prefix_match_unit. "
687
f"Got group block sizes={group_block_sizes}."
688
)
689
return scheduler_block_size, hash_block_size
@@ -2252,19 +2257,33 @@ BlockHashList = list[BlockHash] | BlockHashListWithBlockSize
2257
2258
2259
def resolve_block_hashes(
2255
- request: Request,
2260
+ block_hashes: BlockHashList,
2261
hash_block_size: int,
2262
block_size: int,
2263
+ *,
2264
+ supports_fine_grained_hash_lookup: bool = False,
2265
+ alignment_tokens: int | None = None,
2266
) -> BlockHashList:
2259
- """Resolve the block-hash view for ``request`` at ``block_size``.
2267
+ """Resolve the block-hash view at ``block_size``.
2268
2261
- When ``block_size`` equals ``hash_block_size``, reuse the request's
2262
- precomputed ``block_hashes`` directly; otherwise recalculate at
2263
- ``block_size`` granularity (``block_size`` must be a multiple of
2264
- ``hash_block_size``, which happens when KV cache groups differ in
2265
- block size).
2269
+ When ``block_size`` equals ``hash_block_size``, reuse the precomputed block
2270
+ hashes directly; otherwise view them at ``block_size`` granularity.
2271
+ Fine-grained lookup keeps the original hashes for partial cache hits.
2272
"""
2273
if block_size == hash_block_size:
2268
- return request.block_hashes
2274
+ return block_hashes
2275
+ if isinstance(block_hashes, BlockHashListWithBlockSize):
2276
+ # Already a block-size view
2277
+ assert block_hashes.scale_factor == block_size // hash_block_size
2278
+ return block_hashes
2279
+ # Fine-grained partial hits keep the raw hashes. The caller passes
2280
+ # alignment_tokens = hash_block_size to enable them, else >= block_size.
2281
+ if (
2282
+ supports_fine_grained_hash_lookup
2283
+ and alignment_tokens is not None
2284
+ and alignment_tokens < block_size
2285
+ and block_size % alignment_tokens == 0
2286
+ ):
2287
+ return block_hashes
2288
assert block_size % hash_block_size == 0
2270
- return BlockHashListWithBlockSize(request.block_hashes, hash_block_size, block_size)
2289
+ return BlockHashListWithBlockSize(block_hashes, hash_block_size, block_size)
vllm/v1/core/sched/output.py
+5
@@ -16,10 +16,12 @@ if TYPE_CHECKING:
16
from vllm.multimodal.inputs import MultiModalFeatureSpec
17
from vllm.pooling_params import PoolingParams
18
from vllm.sampling_params import SamplingParams
19
+ from vllm.v1.core.kv_cache_utils import KVCacheBlockCopy
20
from vllm.v1.request import Request
21
else:
22
ECConnectorMetadata = object
23
KVConnectorMetadata = object
24
+ KVCacheBlockCopy = object
25
LoRARequest = object
26
MultiModalFeatureSpec = object
27
PoolingParams = object
@@ -240,6 +242,9 @@ class SchedulerOutput:
242
# preventing stale NaN/data from corrupting attention or SSM computation.
243
new_block_ids_to_zero: list[int] | None = None
244
245
+ # CoW copies to apply after zeroing new blocks and before forward.
246
+ kv_cache_block_copies: list[KVCacheBlockCopy] | None = None
247
+
248
# Dynamic speculative decoding: optimal K chosen by scheduler.
249
# Number of spec tokens to schedule for the next step.
250
num_spec_tokens_to_schedule: int = 0
vllm/v1/core/sched/scheduler.py
+68
-8
@@ -259,6 +259,7 @@ class Scheduler(SchedulerInterface):
259
# Create the KV cache manager.
260
if hash_block_size is None:
261
hash_block_size = block_size
262
+ self.hash_block_size = hash_block_size
263
self.kv_cache_manager = KVCacheManager(
264
kv_cache_config=kv_cache_config,
265
max_model_len=self.max_model_len,
@@ -297,6 +298,13 @@ class Scheduler(SchedulerInterface):
298
self.need_mamba_block_aligned_split = (
299
self.has_mamba_layers and self.cache_config.mamba_cache_mode == "align"
300
)
301
+ # A finer prefix_match_unit is configured: a mamba partial tail entry
302
+ # can only be registered by a step ending exactly at the prompt's last
303
+ # hash boundary, so the split adds that stop.
304
+ self.mamba_partial_cache_hit = (
305
+ self.need_mamba_block_aligned_split
306
+ and self.hash_block_size < self.block_size
307
+ )
308
309
# Counts of non-empty steps scheduled / processed. update_from_output
310
# is called once per scheduled step in FIFO order, so these stay in sync.
@@ -368,9 +376,23 @@ class Scheduler(SchedulerInterface):
376
if self.use_eagle:
377
last_cache_position = max(last_cache_position - block_size, 0)
378
num_computed_tokens_after_sched = num_computed_tokens + num_new_tokens
371
- if num_computed_tokens_after_sched < last_cache_position:
372
- # align to block_size
373
- num_new_tokens = num_new_tokens // block_size * block_size
379
+ next_boundary = (num_computed_tokens // block_size + 1) * block_size
380
+ if (
381
+ num_computed_tokens % block_size != 0
382
+ and next_boundary <= last_cache_position
383
+ and num_computed_tokens_after_sched > next_boundary
384
+ ):
385
+ # Resumed mid-block (partial hash hit): stop the first chunk
386
+ # at the next block boundary so chunk ends re-align to blocks
387
+ # and the boundary state is materialized before running past.
388
+ num_new_tokens = next_boundary - num_computed_tokens
389
+ elif num_computed_tokens_after_sched < last_cache_position:
390
+ # Align the chunk END (not its length) to block_size;
391
+ # identical to flooring the length when the start is
392
+ # block-aligned. May yield 0 (insufficient budget to reach
393
+ # the next boundary); the caller then skips the request.
394
+ aligned_end = num_computed_tokens_after_sched // block_size * block_size
395
+ num_new_tokens = max(aligned_end - num_computed_tokens, 0)
396
elif (
397
num_computed_tokens
398
< last_cache_position
@@ -378,9 +400,23 @@ class Scheduler(SchedulerInterface):
400
):
401
# force to cache the last chunk
402
num_new_tokens = last_cache_position - num_computed_tokens
381
- else:
382
- # prefill the last few tokens
383
- pass
403
+ elif self.mamba_partial_cache_hit:
404
+ # Prefill of the final partial block: stop once at the
405
+ # prompt's last hash boundary so the mamba partial tail entry
406
+ # can be registered.
407
+ tail_boundary = (
408
+ request.num_prompt_tokens
409
+ // self.hash_block_size
410
+ * self.hash_block_size
411
+ )
412
+ if (
413
+ num_computed_tokens
414
+ < tail_boundary
415
+ < num_computed_tokens_after_sched
416
+ and tail_boundary < request.num_prompt_tokens
417
+ and tail_boundary > last_cache_position
418
+ ):
419
+ num_new_tokens = tail_boundary - num_computed_tokens
420
421
# Marconi cache admission optimization:
422
# cache common prefixes by scheduling num_new_tokens = common prefix length
@@ -1084,6 +1120,16 @@ class Scheduler(SchedulerInterface):
1120
new_block_ids_to_zero = (
1121
(new_attn_block_ids or None) if self.needs_kv_cache_zeroing else None
1122
)
1123
+ kv_cache_block_copies, cow_retained_blocks = (
1124
+ self.kv_cache_manager.take_kv_cache_block_copies()
1125
+ )
1126
+ if kv_cache_block_copies:
1127
+ # The copies run with this step's execution; the first non-empty
1128
+ # step at or after it gets seq `sched_step_seq + 1` (0-token steps
1129
+ # do not advance the seq), and its completion implies the copies
1130
+ # have run.
1131
+ self._free_cow_retained_blocks(cow_retained_blocks, self.sched_step_seq + 1)
1132
+ pending_kv_cache_block_copies = kv_cache_block_copies or None
1133
1134
# Dynamic speculative decoding: compute optimal K
1135
num_spec_tokens_to_schedule = self.num_spec_tokens
@@ -1108,6 +1154,7 @@ class Scheduler(SchedulerInterface):
1154
finished_req_ids=self.finished_req_ids,
1155
free_encoder_mm_hashes=self.encoder_cache_manager.get_freed_mm_hashes(),
1156
new_block_ids_to_zero=new_block_ids_to_zero,
1157
+ kv_cache_block_copies=pending_kv_cache_block_copies,
1158
num_spec_tokens_to_schedule=num_spec_tokens_to_schedule,
1159
)
1160
@@ -2148,11 +2195,23 @@ class Scheduler(SchedulerInterface):
2195
if blocks:
2196
self.deferred_frees.append((self.sched_step_seq, blocks))
2197
2198
+ def _free_cow_retained_blocks(
2199
+ self, blocks: list[KVCacheBlock], fence_seq: int
2200
+ ) -> None:
2201
+ """Release CoW copy retentions, deferring their return to the block
2202
+ pool while the step that runs the copy may still be in flight.
2203
+ """
2204
+ if not self.defer_block_free or fence_seq <= self.processed_step_seq:
2205
+ self.kv_cache_manager.block_pool.free_blocks(blocks)
2206
+ return
2207
+ self.deferred_frees.append((fence_seq, blocks[::-1]))
2208
+
2209
def _drain_deferred_frees(self):
2210
"""Return deferred blocks whose fence step has completed.
2211
2154
- Entries are appended with monotonically non-decreasing fences, so
2155
- stop at the first one that is still pending.
2212
+ Fences are appended in near-monotonic order (a CoW retention fence
2213
+ can lead request-free fences by one step), so stop at the first
2214
+ pending one; any satisfied entry behind it is merely freed later.
2215
"""
2216
while self.deferred_frees:
2217
fence, _ = self.deferred_frees[0]
@@ -2401,6 +2460,7 @@ class Scheduler(SchedulerInterface):
2460
new_computed_blocks=self.kv_cache_manager.empty_kv_cache_blocks.blocks,
2461
num_encoder_tokens=0,
2462
total_computed_tokens=request.num_computed_tokens,
2463
+ num_local_computed_tokens=request.num_computed_tokens,
2464
num_tokens_main_model=full_num_tokens,
2465
apply_admission_cap=True,
2466
)
vllm/v1/core/single_type_kv_cache_manager.py
+363
-57
@@ -4,13 +4,16 @@ import itertools
4
from abc import ABC, abstractmethod
5
from collections import defaultdict
6
from collections.abc import Sequence
7
+from typing import ClassVar
8
9
from vllm.utils.math_utils import cdiv
10
from vllm.v1.core.block_pool import BlockPool
11
from vllm.v1.core.kv_cache_utils import (
12
BlockHashList,
13
+ BlockHashListWithBlockSize,
14
BlockHashWithGroupId,
15
KVCacheBlock,
16
+ resolve_block_hashes,
17
)
18
from vllm.v1.kv_cache_interface import (
19
ChunkedLocalAttentionSpec,
@@ -36,6 +39,8 @@ class SingleTypeKVCacheManager(ABC):
39
logic of one specific type of attention layer.
40
"""
41
42
+ supports_fine_grained_hash_lookup: ClassVar[bool] = False
43
+
44
def __init__(
45
self,
46
kv_cache_spec: KVCacheSpec,
@@ -106,16 +111,34 @@ class SingleTypeKVCacheManager(ABC):
111
# determining the attention groups.
112
self.use_eagle = False
113
114
+ # Partial-hit copy-on-write bookkeeping. Populated only by fine-grained
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
+
119
@classmethod
120
def _get_num_evictable_blocks(cls, blocks: Sequence[KVCacheBlock]):
121
return sum(blk.ref_cnt == 0 and not blk.is_null for blk in blocks)
122
123
+ def _has_partial_local_hit(
124
+ self,
125
+ new_computed_blocks: Sequence[KVCacheBlock],
126
+ num_local_computed_tokens: int,
127
+ ) -> bool:
128
+ # The local prefix-cache hit ends inside one of this manager's
129
+ # blocks: the shared tail block needs CoW.
130
+ return (
131
+ len(new_computed_blocks) > 0
132
+ and num_local_computed_tokens % self.block_size != 0
133
+ )
134
+
135
def get_num_blocks_to_allocate(
136
self,
137
request_id: str,
138
num_tokens: int,
139
new_computed_blocks: Sequence[KVCacheBlock],
140
total_computed_tokens: int,
141
+ num_local_computed_tokens: int,
142
num_tokens_main_model: int,
143
apply_admission_cap: bool = False,
144
) -> int:
@@ -130,6 +153,8 @@ class SingleTypeKVCacheManager(ABC):
153
prefix caching.
154
total_computed_tokens: Include both local and external computed
155
tokens.
156
+ num_local_computed_tokens: The number of local prefix-cache computed
157
+ tokens.
158
num_tokens_main_model: The number of tokens for the main model (aka target
159
model in spec decode). w/o spec decode, it is num_tokens;
160
with spec decode, it is num_tokens - num_lookahead_tokens.
@@ -189,6 +214,10 @@ class SingleTypeKVCacheManager(ABC):
214
num_evictable_blocks = self._get_num_evictable_blocks(
215
new_computed_blocks[num_skipped_new_computed_blocks:]
216
)
217
+ if self._has_partial_local_hit(new_computed_blocks, num_local_computed_tokens):
218
+ # Reserve the extra block that allocate_new_blocks pulls for the
219
+ # partial-hit CoW redirect.
220
+ num_new_blocks += 1
221
return num_new_blocks + num_evictable_blocks
222
223
def add_local_computed_blocks(
@@ -242,6 +271,13 @@ class SingleTypeKVCacheManager(ABC):
271
# them so cache_blocks() will not try to re-cache blocks that already
272
# have a block_hash set.
273
self.num_cached_block[request_id] = len(req_blocks)
274
+ if self._has_partial_local_hit(new_computed_blocks, num_local_computed_tokens):
275
+ # Record the partial tail for the CoW redirect in
276
+ # allocate_new_blocks; cap the cached count at the full blocks so
277
+ # cache_blocks() re-caches the private copy once full.
278
+ block_idx = num_local_computed_tokens // self.block_size
279
+ self._partial_hit_reqs[request_id] = (block_idx, new_computed_blocks[-1])
280
+ self.num_cached_block[request_id] = block_idx
281
282
def allocate_external_computed_blocks(
283
self,
@@ -299,17 +335,29 @@ class SingleTypeKVCacheManager(ABC):
335
Returns:
336
The new allocated blocks.
337
"""
338
+ cow_blocks: list[KVCacheBlock] = []
339
+ if request_id in self._partial_hit_reqs:
340
+ # Partial hit: redirect the shared tail to a private CoW block.
341
+ # Replacing in place keeps the length-based allocation below
342
+ # correct; the extra block was reserved by
343
+ # get_num_blocks_to_allocate.
344
+ block_idx, source_block = self._partial_hit_reqs.pop(request_id)
345
+ cow_block = self.block_pool.get_new_blocks(1)[0]
346
+ self._apply_cow(request_id, block_idx, source_block, cow_block)
347
+ self.new_block_ids.append(cow_block.block_id)
348
+ cow_blocks.append(cow_block)
349
+
350
req_blocks = self.req_to_blocks[request_id]
351
num_required_blocks = cdiv(num_tokens, self.block_size)
352
num_new_blocks = num_required_blocks - len(req_blocks)
353
if num_new_blocks <= 0:
306
- return []
354
+ return cow_blocks
355
else:
356
new_blocks = self.block_pool.get_new_blocks(num_new_blocks)
357
req_blocks.extend(new_blocks)
358
if self._record_new_block_ids:
359
self.new_block_ids.extend(b.block_id for b in new_blocks)
312
- return new_blocks
360
+ return cow_blocks + new_blocks
361
362
def take_new_block_ids(self) -> list[int]:
363
"""Drain and return block IDs allocated since the last call."""
@@ -317,6 +365,36 @@ class SingleTypeKVCacheManager(ABC):
365
self.new_block_ids = []
366
return ids
367
368
+ def take_pending_cow_copies(
369
+ self,
370
+ ) -> list[tuple[KVCacheBlock, KVCacheBlock]]:
371
+ """Drain pending CoW source and destination block pairs."""
372
+ pending_copies = self._pending_cow_copies
373
+ self._pending_cow_copies = []
374
+ return pending_copies
375
+
376
+ def _apply_cow(
377
+ self,
378
+ request_id: str,
379
+ block_idx: int,
380
+ source_block: KVCacheBlock,
381
+ cow_block: KVCacheBlock,
382
+ ) -> None:
383
+ """Redirect a partial prefix-cache hit to a private CoW block.
384
+
385
+ Both copy endpoints stay retained until the copy has run on the worker,
386
+ so a same-step free cannot recycle them: ``source_block`` keeps its
387
+ hit-ref, ``cow_block`` takes an extra ref beyond the one handed to the
388
+ request.
389
+ """
390
+ req_blocks = self.req_to_blocks[request_id]
391
+ assert block_idx < len(req_blocks)
392
+ assert req_blocks[block_idx] is source_block
393
+ assert not source_block.is_null and source_block.ref_cnt > 0
394
+ req_blocks[block_idx] = cow_block
395
+ self._pending_cow_copies.append((source_block, cow_block))
396
+ cow_block.ref_cnt += 1
397
+
398
def cache_blocks(
399
self,
400
request: Request,
@@ -398,6 +476,7 @@ class SingleTypeKVCacheManager(ABC):
476
# Default to [] in case a request is freed (aborted) before alloc.
477
req_blocks = self.req_to_blocks.pop(request_id, [])
478
self.num_cached_block.pop(request_id, None)
479
+ self._partial_hit_reqs.pop(request_id, None)
480
return req_blocks
481
482
def free(self, request_id: str) -> None:
@@ -439,7 +518,7 @@ class SingleTypeKVCacheManager(ABC):
518
alignment_tokens: int,
519
dcp_world_size: int = 1,
520
pcp_world_size: int = 1,
442
- ) -> tuple[list[KVCacheBlock], ...]:
521
+ ) -> tuple[tuple[list[KVCacheBlock], ...], int]:
522
"""
523
Get the longest cache hit prefix of the blocks that is not longer than
524
`max_length`. The prefix should be a common prefix hit for all the
@@ -466,11 +545,9 @@ class SingleTypeKVCacheManager(ABC):
545
pcp_world_size: The world size of prefill context parallelism.
546
547
Returns:
469
- A list of cached blocks with skipped blocks replaced by null block
470
- for each kv cache group in `kv_cache_group_ids`.
471
- Return a list of length `len(kv_cache_group_ids)`, where the i-th
472
- element is a list of cached blocks for the i-th kv cache group
473
- in `kv_cache_group_ids`.
548
+ A tuple containing cached blocks and the exact cache-hit length in
549
+ tokens. The cached block tuple has skipped blocks replaced by null
550
+ blocks for each kv cache group in `kv_cache_group_ids`.
551
For example, sliding window manager should return a list like
552
([NULL, NULL, KVCacheBlock(7), KVCacheBlock(8)]) for block size 4
553
and sliding window 8 and len(kv_cache_group_ids) = 1.
@@ -558,11 +635,12 @@ class SingleTypeKVCacheManager(ABC):
635
return 0
636
637
def new_step_starts(self) -> None:
561
- # do nothing by default
638
return None
639
640
641
class FullAttentionManager(SingleTypeKVCacheManager):
642
+ supports_fine_grained_hash_lookup: ClassVar[bool] = True
643
+
644
@classmethod
645
def find_longest_cache_hit(
646
cls,
@@ -575,42 +653,131 @@ class FullAttentionManager(SingleTypeKVCacheManager):
653
alignment_tokens: int,
654
dcp_world_size: int = 1,
655
pcp_world_size: int = 1,
578
- ) -> tuple[list[KVCacheBlock], ...]:
656
+ ) -> tuple[tuple[list[KVCacheBlock], ...], int]:
657
assert isinstance(
658
kv_cache_spec, FullAttentionSpec | ChunkedLocalAttentionSpec
659
), (
660
"FullAttentionManager can only be used for full attention "
661
"and chunked local attention groups"
662
)
585
- computed_blocks: tuple[list[KVCacheBlock], ...] = tuple(
586
- [] for _ in range(len(kv_cache_group_ids))
587
- )
663
block_size = kv_cache_spec.block_size
664
if dcp_world_size * pcp_world_size > 1:
665
+ # DCP/PCP shard each block's KV across ranks; hashes must be
666
+ # viewed at the sharded (scaled) block size.
667
block_size *= dcp_world_size * pcp_world_size
591
- max_num_blocks = max_length // block_size
592
- for block_hash in itertools.islice(block_hashes, max_num_blocks):
593
- # block_hashes is a chain of block hashes. If a block hash is not
594
- # in the cached_block_hash_to_id, the following block hashes are
595
- # not computed yet for sure.
596
- if cached_block := block_pool.get_cached_block(
597
- block_hash, kv_cache_group_ids
598
- ):
599
- for computed, cached in zip(computed_blocks, cached_block):
668
+ block_hashes = resolve_block_hashes(
669
+ block_hashes,
670
+ block_pool.hash_block_size,
671
+ block_size,
672
+ supports_fine_grained_hash_lookup=cls.supports_fine_grained_hash_lookup,
673
+ alignment_tokens=alignment_tokens,
674
+ )
675
+
676
+ # Fine-grained mode (alignment_tokens == hash_block_size <
677
+ # block_size): resolve_block_hashes kept the raw hash-granularity
678
+ # list so interior boundaries can be probed.
679
+ fine_grained = (
680
+ alignment_tokens < block_size and block_size % alignment_tokens == 0
681
+ )
682
+ if fine_grained:
683
+ assert isinstance(block_hashes, list)
684
+ full_block_hashes: BlockHashList = BlockHashListWithBlockSize(
685
+ block_hashes, alignment_tokens, block_size
686
+ )
687
+ else:
688
+ full_block_hashes = block_hashes
689
+
690
+ computed_blocks: tuple[list[KVCacheBlock], ...] = tuple(
691
+ [] for _ in range(len(kv_cache_group_ids))
692
+ )
693
+ # Phase 1: longest run of cached full blocks from the start. A missing
694
+ # block implies every later block misses too (chained hashes).
695
+ for block_hash in itertools.islice(full_block_hashes, max_length // block_size):
696
+ cached_block = block_pool.get_cached_block(block_hash, kv_cache_group_ids)
697
+ if not cached_block:
698
+ break
699
+ for computed, cached in zip(computed_blocks, cached_block):
700
+ computed.append(cached)
701
+ hit_length = len(computed_blocks[0]) * block_size
702
+
703
+ # Phase 2 (fine-grained only): extend into the first non-full block by
704
+ # probing its interior hash boundaries high-to-low (longest hit first).
705
+ if fine_grained:
706
+ assert isinstance(block_hashes, list)
707
+ scale_factor = block_size // alignment_tokens
708
+ first_partial_idx = len(computed_blocks[0]) * scale_factor
709
+ max_partial_idx = min(
710
+ first_partial_idx + scale_factor - 1,
711
+ max_length // alignment_tokens,
712
+ len(block_hashes),
713
+ )
714
+ for fine_idx in range(max_partial_idx - 1, first_partial_idx - 1, -1):
715
+ cached_tail = block_pool.get_cached_block(
716
+ block_hashes[fine_idx], kv_cache_group_ids
717
+ )
718
+ if not cached_tail:
719
+ continue
720
+ for computed, cached in zip(computed_blocks, cached_tail):
721
computed.append(cached)
601
- else:
722
+ hit_length = (fine_idx + 1) * alignment_tokens
723
break
603
- if drop_eagle_block and computed_blocks[0]:
604
- # Need to drop the last matched block if eagle is enabled.
605
- for computed in computed_blocks:
606
- computed.pop()
607
- while (
608
- block_size != alignment_tokens # Faster for common case.
609
- and len(computed_blocks[0]) * block_size % alignment_tokens != 0
610
- ):
611
- for computed in computed_blocks:
612
- computed.pop()
613
- return computed_blocks
724
+
725
+ # Eagle needs the tokens right before the generation point recomputed:
726
+ # drop one hash unit when fine-grained (the tail block's KV is
727
+ # append-only, so it still covers the reduced length), else one cache
728
+ # block.
729
+ if drop_eagle_block and hit_length > 0:
730
+ hit_length -= min(alignment_tokens, block_size)
731
+ # Round down to the alignment; a no-op when fine-grained (hits land on
732
+ # hash boundaries by construction) and when alignment_tokens ==
733
+ # block_size. Then trim blocks past the new tail.
734
+ hit_length -= hit_length % alignment_tokens
735
+ num_blocks = cdiv(hit_length, block_size)
736
+ for computed in computed_blocks:
737
+ del computed[num_blocks:]
738
+ return computed_blocks, hit_length
739
+
740
+ def cache_blocks(
741
+ self,
742
+ request: Request,
743
+ num_tokens: int,
744
+ retention_interval: int | None = None,
745
+ ) -> None:
746
+ super().cache_blocks(request, num_tokens, retention_interval=retention_interval)
747
+ hash_block_size = self.block_pool.hash_block_size
748
+ if self.block_size == hash_block_size:
749
+ return
750
+ self._cache_partial_tail_block(request, num_tokens)
751
+
752
+ def _cache_partial_tail_block(
753
+ self,
754
+ request: Request,
755
+ num_tokens: int,
756
+ ) -> None:
757
+ """Cache the prompt tail when it ends inside a cache block.
758
+
759
+ Only the final prompt hash boundary is registered as a partial
760
+ prefix-cache entry; intermediate hash boundaries inside the same cache
761
+ block are intentionally skipped.
762
+ """
763
+ hash_block_size = self.block_pool.hash_block_size
764
+ boundary_tokens = request.num_prompt_tokens // hash_block_size * hash_block_size
765
+ if boundary_tokens == 0 or boundary_tokens > num_tokens:
766
+ return
767
+ if boundary_tokens % self.block_size == 0:
768
+ return
769
+
770
+ blocks = self.req_to_blocks[request.request_id]
771
+ block_idx = boundary_tokens // self.block_size
772
+ if block_idx >= len(blocks):
773
+ return
774
+ self.block_pool.cache_partial_block(
775
+ request=request,
776
+ block=blocks[block_idx],
777
+ num_tokens=boundary_tokens,
778
+ kv_cache_group_id=self.kv_cache_group_id,
779
+ block_size=self.block_size,
780
+ )
781
782
def get_num_common_prefix_blocks(self, running_request_id: str) -> int:
783
blocks = self.req_to_blocks[running_request_id]
@@ -699,12 +866,23 @@ class SlidingWindowManager(SingleTypeKVCacheManager):
866
alignment_tokens: int,
867
dcp_world_size: int = 1,
868
pcp_world_size: int = 1,
702
- ) -> tuple[list[KVCacheBlock], ...]:
869
+ ) -> tuple[tuple[list[KVCacheBlock], ...], int]:
870
assert isinstance(kv_cache_spec, SlidingWindowSpec), (
871
"SlidingWindowManager can only be used for sliding window groups"
872
)
873
assert dcp_world_size == 1, "DCP not support sliding window attn now."
874
assert pcp_world_size == 1, "PCP not support sliding window attn now."
875
+ # Fine-grained partial hits are not supported for sliding window now
876
+ assert alignment_tokens % kv_cache_spec.block_size == 0, (
877
+ "SlidingWindowManager does not support fine-grained (partial) cache hits"
878
+ )
879
+ block_hashes = resolve_block_hashes(
880
+ block_hashes,
881
+ block_pool.hash_block_size,
882
+ kv_cache_spec.block_size,
883
+ supports_fine_grained_hash_lookup=cls.supports_fine_grained_hash_lookup,
884
+ alignment_tokens=alignment_tokens,
885
+ )
886
887
# The number of contiguous blocks needed for a prefix cache hit.
888
sliding_window_contiguous_blocks = cls._contiguous_blocks_for_hit(
@@ -717,7 +895,7 @@ class SlidingWindowManager(SingleTypeKVCacheManager):
895
# sliding_window_contiguous_blocks),
896
# which is good for low cache hit rate scenarios.
897
max_num_blocks = max_length // kv_cache_spec.block_size
720
- computed_blocks = tuple(
898
+ computed_blocks: tuple[list[KVCacheBlock], ...] = tuple(
899
[block_pool.null_block] * max_num_blocks
900
for _ in range(len(kv_cache_group_ids))
901
)
@@ -772,7 +950,8 @@ class SlidingWindowManager(SingleTypeKVCacheManager):
950
):
951
for computed in computed_blocks:
952
computed.pop()
775
- return computed_blocks
953
+ hit_length = len(computed_blocks[0]) * block_size
954
+ return computed_blocks, hit_length
955
956
@classmethod
957
def reachable_block_mask(
@@ -893,7 +1072,7 @@ class ChunkedLocalAttentionManager(SingleTypeKVCacheManager):
1072
alignment_tokens: int,
1073
dcp_world_size: int = 1,
1074
pcp_world_size: int = 1,
896
- ) -> tuple[list[KVCacheBlock], ...]:
1075
+ ) -> tuple[tuple[list[KVCacheBlock], ...], int]:
1076
"""
1077
For chunked local attention, we need to find the longest cache hit
1078
prefix of the blocks that is not longer than `max_length`. The prefix
@@ -942,6 +1121,13 @@ class ChunkedLocalAttentionManager(SingleTypeKVCacheManager):
1121
"KV cache groups with different block sizes are not compatible with "
1122
"chunked local attention now"
1123
)
1124
+ block_hashes = resolve_block_hashes(
1125
+ block_hashes,
1126
+ block_pool.hash_block_size,
1127
+ kv_cache_spec.block_size,
1128
+ supports_fine_grained_hash_lookup=cls.supports_fine_grained_hash_lookup,
1129
+ alignment_tokens=alignment_tokens,
1130
+ )
1131
max_num_blocks = max_length // kv_cache_spec.block_size
1132
if max_length > 0:
1133
local_attention_start_idx = (
@@ -971,7 +1157,8 @@ class ChunkedLocalAttentionManager(SingleTypeKVCacheManager):
1157
computed.append(cached)
1158
else:
1159
break
974
- return computed_blocks
1160
+ hit_length = len(computed_blocks[0]) * kv_cache_spec.block_size
1161
+ return computed_blocks, hit_length
1162
1163
def get_num_skipped_tokens(self, num_computed_tokens: int) -> int:
1164
"""
@@ -1027,6 +1214,8 @@ class ChunkedLocalAttentionManager(SingleTypeKVCacheManager):
1214
1215
1216
class MambaManager(SingleTypeKVCacheManager):
1217
+ supports_fine_grained_hash_lookup: ClassVar[bool] = True
1218
+
1219
def __init__(
1220
self, kv_cache_spec: MambaSpec, block_pool: BlockPool, **kwargs
1221
) -> None:
@@ -1035,9 +1224,9 @@ class MambaManager(SingleTypeKVCacheManager):
1224
# recurrent state. Undo the DCP/PCP block_size scaling that the base
1225
# class applies for attention groups whose KV cache is partitioned.
1226
self.block_size = kv_cache_spec.block_size
1038
- self.cached_blocks_this_step: set[BlockHashWithGroupId] = set()
1227
self.mamba_cache_mode = kv_cache_spec.mamba_cache_mode
1228
self.num_speculative_blocks: int = kv_cache_spec.num_speculative_blocks
1229
+ self.cached_blocks_this_step: set[BlockHashWithGroupId] = set()
1230
if self.mamba_cache_mode == "align":
1231
# Mapping from request ID to the index of the block
1232
# allocated in the previous step
@@ -1057,17 +1246,46 @@ class MambaManager(SingleTypeKVCacheManager):
1246
alignment_tokens: int,
1247
dcp_world_size: int = 1,
1248
pcp_world_size: int = 1,
1060
- ) -> tuple[list[KVCacheBlock], ...]:
1249
+ ) -> tuple[tuple[list[KVCacheBlock], ...], int]:
1250
assert isinstance(kv_cache_spec, MambaSpec), (
1251
"MambaManager can only be used for mamba groups"
1252
)
1253
assert dcp_world_size == 1, "DCP not support mamba now."
1254
assert pcp_world_size == 1, "PCP not support mamba now."
1255
+ block_hashes = resolve_block_hashes(
1256
+ block_hashes,
1257
+ block_pool.hash_block_size,
1258
+ kv_cache_spec.block_size,
1259
+ supports_fine_grained_hash_lookup=cls.supports_fine_grained_hash_lookup,
1260
+ alignment_tokens=alignment_tokens,
1261
+ )
1262
computed_blocks: tuple[list[KVCacheBlock], ...] = tuple(
1263
[] for _ in range(len(kv_cache_group_ids))
1264
)
1265
+ hit_length = 0
1266
1267
block_size = kv_cache_spec.block_size
1268
+ if alignment_tokens < block_size and block_size % alignment_tokens == 0:
1269
+ assert isinstance(block_hashes, list)
1270
+ hash_block_size = alignment_tokens
1271
+ scale_factor = block_size // hash_block_size
1272
+ max_num_partial_units = min(
1273
+ max_length // hash_block_size, len(block_hashes)
1274
+ )
1275
+ for fine_idx in range(max_num_partial_units - 1, -1, -1):
1276
+ num_tokens = (fine_idx + 1) * hash_block_size
1277
+ block_hash = block_hashes[fine_idx]
1278
+ if cached_block := block_pool.get_cached_block(
1279
+ block_hash, kv_cache_group_ids
1280
+ ):
1281
+ block_idx = fine_idx // scale_factor
1282
+ for computed, cached in zip(computed_blocks, cached_block):
1283
+ computed.extend([block_pool.null_block] * block_idx)
1284
+ computed.append(cached)
1285
+ hit_length = num_tokens
1286
+ break
1287
+ return computed_blocks, hit_length
1288
+
1289
max_num_blocks = max_length // block_size
1290
# Search from right to left and early stop when a match is found.
1291
for i in range(max_num_blocks - 1, -1, -1):
@@ -1089,9 +1307,10 @@ class MambaManager(SingleTypeKVCacheManager):
1307
# so we insert dummy blocks at the beginning:
1308
computed.extend([block_pool.null_block] * i)
1309
computed.append(cached)
1310
+ hit_length = (i + 1) * block_size
1311
break # we just need the last match - early stopping
1312
1094
- return computed_blocks
1313
+ return computed_blocks, hit_length
1314
1315
@classmethod
1316
def reachable_block_mask(
@@ -1189,6 +1408,7 @@ class MambaManager(SingleTypeKVCacheManager):
1408
num_tokens: int,
1409
new_computed_blocks: Sequence[KVCacheBlock],
1410
total_computed_tokens: int,
1411
+ num_local_computed_tokens: int,
1412
num_tokens_main_model: int,
1413
apply_admission_cap: bool = False,
1414
) -> int:
@@ -1214,6 +1434,7 @@ class MambaManager(SingleTypeKVCacheManager):
1434
num_tokens,
1435
new_computed_blocks,
1436
total_computed_tokens,
1437
+ num_local_computed_tokens,
1438
num_tokens_main_model,
1439
apply_admission_cap=apply_admission_cap,
1440
)
@@ -1235,15 +1456,26 @@ class MambaManager(SingleTypeKVCacheManager):
1456
- len(new_computed_blocks)
1457
- len(self.req_to_blocks[request_id])
1458
)
1459
+ has_partial_hit = (
1460
+ self._has_partial_local_hit(
1461
+ new_computed_blocks, num_local_computed_tokens
1462
+ )
1463
+ or request_id in self._partial_hit_reqs
1464
+ )
1465
+ if has_partial_hit:
1466
+ num_new_blocks = max(num_new_blocks, 0) + 1
1467
if num_new_blocks > 0:
1468
if request_id in self._allocated_block_reqs:
1469
# Old request. Needs at most 1 more blocks as we can reuse the
1470
# speculative blocks in previous step.
1242
- num_new_blocks = 1
1471
+ num_new_blocks = 1 + int(has_partial_hit)
1472
else:
1244
- # First prefill. Allocate 1 block for running state and the
1245
- # speculative blocks.
1246
- num_new_blocks = 1 + self.num_speculative_blocks
1473
+ # First prefill. Allocate 1 block for running state, the
1474
+ # speculative blocks, and one extra block if a partial cache
1475
+ # hit must be copy-on-written before the new tokens run.
1476
+ num_new_blocks = (
1477
+ 1 + self.num_speculative_blocks + int(has_partial_hit)
1478
+ )
1479
1480
num_evictable_computed_blocks = self._get_num_evictable_blocks(
1481
new_computed_blocks
@@ -1275,9 +1507,11 @@ class MambaManager(SingleTypeKVCacheManager):
1507
num_required_blocks = (
1508
cdiv(num_tokens, self.block_size) + self.num_speculative_blocks
1509
)
1510
+ partial_hit = self._partial_hit_reqs.get(request_id)
1511
+ has_partial_hit = partial_hit is not None
1512
# `num_required_blocks` might be less than `len(req_blocks)` if blocks are
1513
# over-allocated at last round.
1280
- if num_required_blocks <= len(req_blocks):
1514
+ if num_required_blocks <= len(req_blocks) and not has_partial_hit:
1515
return []
1516
else:
1517
prev_block_len = len(req_blocks)
@@ -1317,14 +1551,42 @@ class MambaManager(SingleTypeKVCacheManager):
1551
else:
1552
break
1553
num_new_blocks = num_required_blocks - len(req_blocks)
1554
+ if has_partial_hit:
1555
+ num_new_blocks = max(num_new_blocks, 0) + 1
1556
if blocks_allocated:
1321
- assert num_new_blocks <= 1
1557
+ assert num_new_blocks <= 1 + int(has_partial_hit)
1558
else:
1323
- assert num_new_blocks <= self.num_speculative_blocks + 1
1559
+ assert num_new_blocks <= self.num_speculative_blocks + 1 + int(
1560
+ has_partial_hit
1561
+ )
1562
new_blocks = self.block_pool.get_new_blocks(num_new_blocks)
1563
+ returned_blocks = req_blocks[prev_block_len:]
1564
+ if partial_hit is not None:
1565
+ block_idx, source_block = partial_hit
1566
+ cow_block = new_blocks[0]
1567
+ new_blocks = new_blocks[1:]
1568
+ if blocks_allocated:
1569
+ # The worker block table of a running request is
1570
+ # append-only, so the request must stay on
1571
+ # source_block. Move the cache entry to cow_block
1572
+ # instead; the queued copy fills it before forward
1573
+ # overwrites source_block.
1574
+ assert req_blocks[block_idx] is source_block
1575
+ self.block_pool.move_block_hashes(source_block, cow_block)
1576
+ self._pending_cow_copies.append((source_block, cow_block))
1577
+ source_block.ref_cnt += 1
1578
+ if cow_block.block_hash is not None:
1579
+ # The moved entry is only filled by this step's
1580
+ # copy, so defer same-step hits on it.
1581
+ self.cached_blocks_this_step.add(cow_block.block_hash)
1582
+ else:
1583
+ self._apply_cow(request_id, block_idx, source_block, cow_block)
1584
+ returned_blocks = [cow_block] + returned_blocks
1585
req_blocks.extend(new_blocks)
1586
self._allocated_block_reqs.add(request_id)
1327
- return req_blocks[prev_block_len:]
1587
+ self._partial_hit_reqs.pop(request_id, None)
1588
+ returned_blocks.extend(new_blocks)
1589
+ return returned_blocks
1590
1591
def pop_blocks_for_free(self, request_id: str) -> list[KVCacheBlock]:
1592
if self.mamba_cache_mode == "align":
@@ -1349,6 +1611,10 @@ class MambaManager(SingleTypeKVCacheManager):
1611
num_cached_blocks_before = self.num_cached_block.get(request.request_id, 0)
1612
super().cache_blocks(request, num_tokens, retention_interval=retention_interval)
1613
num_cached_blocks_after = self.num_cached_block.get(request.request_id, 0)
1614
+ if self.mamba_cache_mode == "align":
1615
+ partial_hash = self._cache_partial_tail_block(request, num_tokens)
1616
+ if partial_hash is not None:
1617
+ self.cached_blocks_this_step.add(partial_hash)
1618
if num_cached_blocks_after > num_cached_blocks_before:
1619
for block in self.req_to_blocks[request.request_id][
1620
num_cached_blocks_before:num_cached_blocks_after
@@ -1364,6 +1630,44 @@ class MambaManager(SingleTypeKVCacheManager):
1630
def new_step_starts(self) -> None:
1631
self.cached_blocks_this_step.clear()
1632
1633
+ def _cache_partial_tail_block(
1634
+ self,
1635
+ request: Request,
1636
+ num_tokens: int,
1637
+ ) -> BlockHashWithGroupId | None:
1638
+ hash_block_size = self.block_pool.hash_block_size
1639
+ if self.block_size == hash_block_size:
1640
+ return None
1641
+ if num_tokens % self.block_size == 0:
1642
+ return None
1643
+ if num_tokens % hash_block_size != 0:
1644
+ return None
1645
+ latest_prompt_hash_boundary = (
1646
+ request.num_prompt_tokens // hash_block_size
1647
+ ) * hash_block_size
1648
+ if num_tokens != latest_prompt_hash_boundary:
1649
+ return None
1650
+
1651
+ block_idx = num_tokens // self.block_size
1652
+ blocks = self.req_to_blocks[request.request_id]
1653
+ if block_idx >= len(blocks):
1654
+ return None
1655
+ source_block = blocks[block_idx]
1656
+ if source_block.is_null:
1657
+ return None
1658
+
1659
+ partial_hash = self.block_pool.cache_partial_block(
1660
+ request=request,
1661
+ block=source_block,
1662
+ num_tokens=num_tokens,
1663
+ kv_cache_group_id=self.kv_cache_group_id,
1664
+ block_size=self.block_size,
1665
+ )
1666
+ if partial_hash is not None:
1667
+ self._partial_hit_reqs[request.request_id] = (block_idx, source_block)
1668
+ self.num_cached_block[request.request_id] = block_idx
1669
+ return partial_hash
1670
+
1671
1672
class CrossAttentionManager(SingleTypeKVCacheManager):
1673
"""Manager for cross-attention KV cache in encoder-decoder models."""
@@ -1415,7 +1719,7 @@ class CrossAttentionManager(SingleTypeKVCacheManager):
1719
alignment_tokens: int,
1720
dcp_world_size: int = 1,
1721
pcp_world_size: int = 1,
1418
- ) -> tuple[list[KVCacheBlock], ...]:
1722
+ ) -> tuple[tuple[list[KVCacheBlock], ...], int]:
1723
assert isinstance(kv_cache_spec, CrossAttentionSpec), (
1724
"CrossAttentionManager can only be used for cross-attention groups"
1725
)
@@ -1435,16 +1739,18 @@ class SinkFullAttentionManager(FullAttentionManager):
1739
block_pool: BlockPool,
1740
enable_caching: bool,
1741
kv_cache_group_id: int,
1742
+ scheduler_block_size: int,
1743
dcp_world_size: int = 1,
1744
pcp_world_size: int = 1,
1745
):
1746
super().__init__(
1442
- kv_cache_spec,
1443
- block_pool,
1444
- enable_caching,
1445
- kv_cache_group_id,
1446
- dcp_world_size,
1447
- pcp_world_size,
1747
+ kv_cache_spec=kv_cache_spec,
1748
+ block_pool=block_pool,
1749
+ enable_caching=enable_caching,
1750
+ kv_cache_group_id=kv_cache_group_id,
1751
+ scheduler_block_size=scheduler_block_size,
1752
+ dcp_world_size=dcp_world_size,
1753
+ pcp_world_size=pcp_world_size,
1754
)
1755
sink_len = kv_cache_spec.sink_len
1756
assert sink_len is not None and sink_len > 0 and sink_len % self.block_size == 0
vllm/v1/worker/gpu/model_runner.py
+10
-1
@@ -112,7 +112,7 @@ from vllm.v1.worker.gpu.spec_decode.utils import DraftTokensHandler
112
from vllm.v1.worker.gpu.states import RequestState
113
from vllm.v1.worker.gpu.structured_outputs import StructuredOutputsWorker
114
from vllm.v1.worker.lora_model_runner_mixin import LoRAModelRunnerMixin
115
-from vllm.v1.worker.utils import KVBlockZeroer
115
+from vllm.v1.worker.utils import KVBlockZeroer, copy_kv_cache_blocks_inplace
116
117
logger = init_logger(__name__)
118
@@ -838,6 +838,15 @@ class GPUModelRunner(LoRAModelRunnerMixin):
838
assert self.kv_block_zeroer is not None
839
self.kv_block_zeroer.zero_block_ids(scheduler_output.new_block_ids_to_zero)
840
841
+ # Apply copy-on-write block copies for partial prefix-cache hits, after
842
+ # zeroing new blocks and before the forward pass reads them.
843
+ if scheduler_output.kv_cache_block_copies:
844
+ copy_kv_cache_blocks_inplace(
845
+ self.kv_caches,
846
+ self.kv_cache_config.num_blocks,
847
+ scheduler_output.kv_cache_block_copies,
848
+ )
849
+
850
def prepare_inputs(
851
self, scheduler_output: SchedulerOutput, batch_desc: BatchExecutionDescriptor
852
) -> InputBatch:
vllm/v1/worker/gpu_model_runner.py
+7
@@ -230,6 +230,7 @@ from .utils import (
230
KVBlockZeroer,
231
add_kv_sharing_layers_to_kv_cache_groups,
232
bind_kv_cache,
233
+ copy_kv_cache_blocks_inplace,
234
prepare_kernel_block_sizes,
235
sanity_check_mm_encoder_outputs,
236
)
@@ -1188,6 +1189,12 @@ class GPUModelRunner(
1189
# stale NaN/data from corrupting attention or SSM computation.
1190
if scheduler_output.new_block_ids_to_zero:
1191
self._zero_block_ids(scheduler_output.new_block_ids_to_zero)
1192
+ if scheduler_output.kv_cache_block_copies:
1193
+ copy_kv_cache_blocks_inplace(
1194
+ self.kv_caches,
1195
+ self.kv_cache_config.num_blocks,
1196
+ scheduler_output.kv_cache_block_copies,
1197
+ )
1198
1199
# Free the cached encoder outputs.
1200
for mm_hash in scheduler_output.free_encoder_mm_hashes:
vllm/v1/worker/utils.py
+43
-1
@@ -2,11 +2,12 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
import math
4
from collections import defaultdict
5
-from collections.abc import Iterable
5
+from collections.abc import Iterable, Sequence
6
from dataclasses import dataclass, field
7
from itertools import product as iprod
8
from typing import Any
9
10
+import numpy as np
11
import torch
12
13
from vllm.config import CacheConfig, VllmConfig
@@ -18,11 +19,13 @@ from vllm.platforms import current_platform
19
from vllm.triton_utils import tl, triton
20
from vllm.utils.math_utils import largest_power_of_2_divisor
21
from vllm.utils.mem_utils import MemorySnapshot, format_gib
22
+from vllm.utils.torch_utils import async_tensor_h2d
23
from vllm.v1.attention.backend import (
24
AttentionBackend,
25
AttentionMetadataBuilder,
26
MultipleOf,
27
)
28
+from vllm.v1.core.kv_cache_utils import KVCacheBlockCopy
29
from vllm.v1.kv_cache_interface import (
30
AttentionSpec,
31
EncoderOnlyAttentionSpec,
@@ -535,6 +538,45 @@ def bind_kv_cache(
538
forward_context[layer_name].kv_cache = kv_cache
539
540
541
+def copy_kv_cache_blocks_inplace(
542
+ kv_caches: Iterable[torch.Tensor | list[torch.Tensor]],
543
+ num_blocks: int,
544
+ kv_cache_block_copies: Sequence[KVCacheBlockCopy],
545
+) -> None:
546
+ if not kv_cache_block_copies:
547
+ return
548
+
549
+ storage_tensors: list[torch.Tensor] = []
550
+ seen_storage: set[int] = set()
551
+ for entry in kv_caches:
552
+ # Mamba layers hold a list of state tensors; attention layers a single
553
+ # tensor. Both alias the shared block-major backing storage.
554
+ tensors = entry if isinstance(entry, (list, tuple)) else (entry,)
555
+ for tensor in tensors:
556
+ ptr = tensor.untyped_storage().data_ptr()
557
+ if ptr in seen_storage:
558
+ continue
559
+ seen_storage.add(ptr)
560
+ storage_tensors.append(tensor)
561
+
562
+ if not storage_tensors:
563
+ return
564
+ device = storage_tensors[0].device
565
+ indices_np = np.array(kv_cache_block_copies, dtype=np.int64)
566
+ indices = async_tensor_h2d(indices_np, device=device)
567
+ src_indices, dst_indices = indices.unbind(dim=1)
568
+
569
+ for tensor in storage_tensors:
570
+ assert tensor.device == device
571
+ blocks = torch.empty(0, dtype=torch.uint8, device=device)
572
+ blocks.set_(tensor.untyped_storage())
573
+ # Block-major backing storage: block i owns the contiguous byte range
574
+ # [i * page_size, (i + 1) * page_size).
575
+ assert blocks.numel() % num_blocks == 0
576
+ blocks = blocks.view(num_blocks, -1)
577
+ blocks[dst_indices] = blocks[src_indices]
578
+
579
+
580
def is_residual_scattered_for_sp(
581
vllm_config: VllmConfig, num_input_tokens: int
582
) -> bool: