[Bugfix][KV Offload] Skip non-prefix-cacheable groups in SimpleCPUOffload (GLM-5.3-Flash kpool tail and QSA) (#56810)
Signed-off-by: Jared Wen <jaredwen@inferact.ai> Signed-off-by: Jared Wen <w13431838023@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Zhewen Li <zhewenli@inferact.ai>
Jared Wen committed
Sep 20, 2026 at 18:58 UTC
f648eed23dc48fcc8ba64be0c4182f8e775b5bfa
3 files changed
+267
-2
tests/v1/simple_kv_offload/test_kv_events.py
+36
@@ -19,8 +19,10 @@ from tests.v1.simple_kv_offload.test_scheduler import (
19
SchedulerFixture,
20
_alloc_and_register,
21
_allocate_cp_gpu_blocks,
22
+ _allocate_gpu_blocks,
23
_make_cp_request,
24
_make_kv_cache_config,
25
+ _make_scratch_kv_cache_config,
26
_make_vllm_config,
27
make_request,
28
make_scheduler_output,
@@ -757,3 +759,37 @@ def test_mamba_align_skips_positional_event_metadata() -> None:
759
get_block_hash(make_block_hash_with_group_id(req.block_hashes[idx], 0))
760
)
761
assert ev.block_hashes == [expected_hash]
762
+
763
+
764
+def test_scratch_group_store_emits_events_for_cacheable_groups_only() -> None:
765
+ """Event metadata is resolved only for prefix-cacheable groups.
766
+
767
+ A scratch group (GLM-5.3-Flash kpool tail) has a block size that does not
768
+ divide the hash block size, so viewing the request hashes at its block
769
+ size is impossible; the store must skip it and still emit BlockStored
770
+ for the attention blocks.
771
+ """
772
+ fix = make_events_scheduler(kv_cache_config=_make_scratch_kv_cache_config(16))
773
+ sched = fix.scheduler
774
+ gpu_pool = fix.gpu_block_pool
775
+ num_blocks = 2
776
+
777
+ req = make_request(num_blocks=num_blocks)
778
+ fa_blocks = _allocate_gpu_blocks(gpu_pool, req, num_blocks, group_id=0)
779
+ scratch_block = gpu_pool.get_new_blocks(1)
780
+ kv_blocks = KVCacheBlocks(blocks=(fa_blocks, scratch_block))
781
+ req.num_computed_tokens = num_blocks * BLOCK_SIZE
782
+ sched.update_state_after_alloc(req, kv_blocks, num_external_tokens=0)
783
+ sched_out = make_scheduler_output(
784
+ {req.request_id: num_blocks * BLOCK_SIZE},
785
+ new_reqs={req.request_id: kv_blocks.get_block_ids()},
786
+ )
787
+ meta = sched.build_connector_meta(sched_out)
788
+ assert sorted(meta.store_gpu_blocks) == sorted(b.block_id for b in fa_blocks)
789
+ simulate_store_completion(sched, meta.store_event)
790
+
791
+ events = list(sched.take_events())
792
+ assert len(events) == num_blocks
793
+ assert all(isinstance(e, BlockStored) for e in events)
794
+ assert {e.group_idx for e in events} == {0}
795
+ assert all(len(e.token_ids) == BLOCK_SIZE for e in events)
tests/v1/simple_kv_offload/test_scheduler.py
+222
-1
@@ -41,7 +41,9 @@ from vllm.v1.core.single_type_kv_cache_manager import (
41
register_all_kvcache_specs,
42
)
43
from vllm.v1.kv_cache_interface import (
44
+ CircularBufferSpec,
45
FullAttentionSpec,
46
+ KpoolTailSpec,
47
KVCacheConfig,
48
KVCacheGroupSpec,
49
KVCacheTensor,
@@ -125,6 +127,45 @@ def _make_kv_cache_config(
127
)
128
129
130
+def _make_scratch_kv_cache_config(
131
+ num_blocks: int, scratch_block_size: int = 4
132
+) -> KVCacheConfig:
133
+ """FullAttention group plus a non-prefix-cacheable scratch group.
134
+
135
+ Mirrors GLM-5.3-Flash, whose kpool-tail group holds one per-request
136
+ scratch block of ``index_kpool`` tokens: it is excluded from prefix
137
+ caching, so its block size neither divides nor is divided by the
138
+ hash block size in general.
139
+ """
140
+ register_all_kvcache_specs(vllm_config=None)
141
+ fa_config = _make_kv_cache_config(num_blocks, num_groups=1)
142
+ scratch_layers = ["layer_scratch"]
143
+ scratch_spec = KpoolTailSpec(
144
+ block_size=scratch_block_size,
145
+ num_kv_heads=2,
146
+ head_size=HEAD_SIZE,
147
+ head_size_v=0,
148
+ dtype=DTYPE,
149
+ sliding_window=scratch_block_size,
150
+ )
151
+ assert not scratch_spec.prefix_cacheable
152
+ scratch_bytes = scratch_spec.page_size_bytes * num_blocks
153
+ return KVCacheConfig(
154
+ num_blocks=num_blocks,
155
+ kv_cache_tensors=fa_config.kv_cache_tensors
156
+ + [
157
+ KVCacheTensor(
158
+ size=scratch_bytes,
159
+ layers=scratch_layers,
160
+ layer_stride=scratch_bytes,
161
+ block_stride=scratch_spec.page_size_bytes,
162
+ )
163
+ ],
164
+ kv_cache_groups=fa_config.kv_cache_groups
165
+ + [KVCacheGroupSpec(scratch_layers, scratch_spec)],
166
+ )
167
+
168
+
169
def _make_vllm_config(block_size: int = BLOCK_SIZE) -> VllmConfig:
170
"""Minimal VllmConfig for scheduler tests (no GPU)."""
171
model_config = ModelConfig(
@@ -174,9 +215,13 @@ def make_scheduler(
215
num_gpu_blocks: int = 16,
216
num_groups: int = 1,
217
lazy: bool = False,
218
+ kv_cache_config: KVCacheConfig | None = None,
219
) -> SchedulerFixture:
220
"""Build a SimpleCPUOffloadScheduler with small block pools."""
179
- kv_cache_config = _make_kv_cache_config(num_gpu_blocks, num_groups)
221
+ if kv_cache_config is None:
222
+ kv_cache_config = _make_kv_cache_config(num_gpu_blocks, num_groups)
223
+ else:
224
+ num_groups = len(kv_cache_config.kv_cache_groups)
225
vllm_config = _make_vllm_config()
226
cpu_capacity_bytes = _BYTES_PER_BLOCK * num_cpu_blocks * num_groups
227
@@ -1392,6 +1437,63 @@ def test_multi_group_null_blocks_skipped() -> None:
1437
)
1438
1439
1440
+# ---------------------------------------------------------------------------
1441
+# Test 8b: Non-prefix-cacheable scratch groups take no part in store or load
1442
+# ---------------------------------------------------------------------------
1443
+def test_scratch_group_excluded_from_store_and_load() -> None:
1444
+ """A scratch group (GLM-5.3-Flash kpool tail) is never stored or loaded.
1445
+
1446
+ Its single per-request block carries no hash, so the store must offload
1447
+ only the attention blocks and the load must pair only attention blocks.
1448
+ """
1449
+ fix = make_scheduler(
1450
+ num_cpu_blocks=8,
1451
+ num_gpu_blocks=16,
1452
+ kv_cache_config=_make_scratch_kv_cache_config(16),
1453
+ )
1454
+ sched = fix.scheduler
1455
+ gpu_pool = fix.gpu_block_pool
1456
+ num_blocks = 2
1457
+
1458
+ req = make_request(num_blocks=num_blocks)
1459
+ fa_blocks = _allocate_gpu_blocks(gpu_pool, req, num_blocks, group_id=0)
1460
+ scratch_block = gpu_pool.get_new_blocks(1)
1461
+ kv_blocks = KVCacheBlocks(blocks=(fa_blocks, scratch_block))
1462
+ req.num_computed_tokens = num_blocks * BLOCK_SIZE
1463
+ sched.update_state_after_alloc(req, kv_blocks, num_external_tokens=0)
1464
+ sched_out = make_scheduler_output(
1465
+ {req.request_id: num_blocks * BLOCK_SIZE},
1466
+ new_reqs={req.request_id: kv_blocks.get_block_ids()},
1467
+ )
1468
+ meta = sched.build_connector_meta(sched_out)
1469
+ assert sorted(meta.store_gpu_blocks) == sorted(b.block_id for b in fa_blocks)
1470
+ simulate_store_completion(sched, meta.store_event)
1471
+
1472
+ req2 = Request(
1473
+ request_id="req-scratch-load",
1474
+ prompt_token_ids=req.prompt_token_ids,
1475
+ sampling_params=req.sampling_params,
1476
+ pooling_params=None,
1477
+ mm_features=None,
1478
+ block_hasher=req._block_hasher,
1479
+ )
1480
+ hit_tokens, is_async = sched.get_num_new_matched_tokens(req2, num_computed_tokens=0)
1481
+ assert hit_tokens == num_blocks * BLOCK_SIZE
1482
+ assert is_async is True
1483
+
1484
+ fa_blocks2 = gpu_pool.get_new_blocks(num_blocks)
1485
+ scratch_block2 = gpu_pool.get_new_blocks(1)
1486
+ kv_blocks2 = KVCacheBlocks(blocks=(fa_blocks2, scratch_block2))
1487
+ sched.update_state_after_alloc(req2, kv_blocks2, num_external_tokens=hit_tokens)
1488
+ sched_out2 = make_scheduler_output(
1489
+ {req2.request_id: 1},
1490
+ new_reqs={req2.request_id: kv_blocks2.get_block_ids()},
1491
+ )
1492
+ meta2 = sched.build_connector_meta(sched_out2)
1493
+ assert sorted(meta2.load_gpu_blocks) == sorted(b.block_id for b in fa_blocks2)
1494
+ assert len(meta2.load_cpu_blocks) == num_blocks
1495
+
1496
+
1497
# ---------------------------------------------------------------------------
1498
# Test 9: Chunked prefill accumulates block_ids across steps
1499
# ---------------------------------------------------------------------------
@@ -2844,3 +2946,122 @@ def test_finished_eager_store_recovers_nulled_window_tail() -> None:
2946
hit_tokens, is_async = sched.get_num_new_matched_tokens(req2, num_computed_tokens=0)
2947
assert hit_tokens == num_blocks * BLOCK_SIZE
2948
assert is_async is True
2949
+
2950
+
2951
+QSA_RING_BLOCK_SIZE = 4
2952
+
2953
+
2954
+def _make_qsa_hybrid_kv_cache_config(num_blocks: int) -> KVCacheConfig:
2955
+ register_all_kvcache_specs(vllm_config=None)
2956
+ full_spec = FullAttentionSpec(
2957
+ block_size=BLOCK_SIZE,
2958
+ num_kv_heads=NUM_KV_HEADS,
2959
+ head_size=HEAD_SIZE,
2960
+ dtype=DTYPE,
2961
+ )
2962
+ ring_spec = CircularBufferSpec(
2963
+ block_size=QSA_RING_BLOCK_SIZE,
2964
+ num_kv_heads=NUM_KV_HEADS,
2965
+ head_size=HEAD_SIZE,
2966
+ head_size_v=0,
2967
+ dtype=DTYPE,
2968
+ )
2969
+ groups = [
2970
+ KVCacheGroupSpec(["layer_0"], full_spec),
2971
+ KVCacheGroupSpec(["qsa_ring"], ring_spec),
2972
+ ]
2973
+ tensors = [
2974
+ KVCacheTensor(
2975
+ size=group.kv_cache_spec.page_size_bytes * num_blocks,
2976
+ layers=list(group.layer_names),
2977
+ layer_stride=group.kv_cache_spec.page_size_bytes * num_blocks,
2978
+ block_stride=group.kv_cache_spec.page_size_bytes,
2979
+ )
2980
+ for group in groups
2981
+ ]
2982
+ return KVCacheConfig(
2983
+ num_blocks=num_blocks,
2984
+ kv_cache_tensors=tensors,
2985
+ kv_cache_groups=groups,
2986
+ )
2987
+
2988
+
2989
+def _make_qsa_scheduler(
2990
+ lazy: bool = False,
2991
+) -> tuple[SimpleCPUOffloadScheduler, BlockPool]:
2992
+ kv_cache_config = _make_qsa_hybrid_kv_cache_config(num_blocks=16)
2993
+ sched = SimpleCPUOffloadScheduler(
2994
+ vllm_config=_make_vllm_config(),
2995
+ kv_cache_config=kv_cache_config,
2996
+ cpu_capacity_bytes=_BYTES_PER_BLOCK * 8 * 2,
2997
+ scheduler_block_size=BLOCK_SIZE,
2998
+ hash_block_size=BLOCK_SIZE,
2999
+ lazy_offload=lazy,
3000
+ )
3001
+ gpu_pool = BlockPool(
3002
+ num_gpu_blocks=16, enable_caching=True, hash_block_size=BLOCK_SIZE
3003
+ )
3004
+ sched.bind_gpu_block_pool(gpu_pool)
3005
+ return sched, gpu_pool
3006
+
3007
+
3008
+def test_qsa_ring_group_is_never_stored_or_loaded() -> None:
3009
+ sched, gpu_pool = _make_qsa_scheduler()
3010
+ assert sched.prefix_cacheable_group_ids == (0,)
3011
+
3012
+ num_blocks = 2
3013
+ req = make_request(num_blocks=num_blocks)
3014
+ fa_blocks = _allocate_gpu_blocks(gpu_pool, req, num_blocks, group_id=0)
3015
+ ring_block = gpu_pool.get_new_blocks(1)[0]
3016
+ kv_blocks = KVCacheBlocks(blocks=(fa_blocks, [ring_block]))
3017
+ req.num_computed_tokens = num_blocks * BLOCK_SIZE
3018
+ sched.update_state_after_alloc(req, kv_blocks, num_external_tokens=0)
3019
+
3020
+ sched_out = make_scheduler_output(
3021
+ {req.request_id: num_blocks * BLOCK_SIZE},
3022
+ new_reqs={req.request_id: kv_blocks.get_block_ids()},
3023
+ )
3024
+ meta = sched.build_connector_meta(sched_out)
3025
+ assert set(meta.store_gpu_blocks) == {block.block_id for block in fa_blocks}
3026
+ assert ring_block.block_id not in meta.store_gpu_blocks
3027
+ assert meta.store_event >= 0
3028
+ simulate_store_completion(sched, meta.store_event)
3029
+
3030
+ req2 = Request(
3031
+ request_id="req-qsa-load",
3032
+ prompt_token_ids=req.prompt_token_ids,
3033
+ sampling_params=req.sampling_params,
3034
+ pooling_params=None,
3035
+ mm_features=None,
3036
+ block_hasher=req._block_hasher,
3037
+ )
3038
+ hit_tokens, is_async = sched.get_num_new_matched_tokens(req2, num_computed_tokens=0)
3039
+ assert hit_tokens == num_blocks * BLOCK_SIZE
3040
+ assert is_async is True
3041
+
3042
+ fa_blocks2 = gpu_pool.get_new_blocks(num_blocks)
3043
+ ring_block2 = gpu_pool.get_new_blocks(1)[0]
3044
+ kv_blocks2 = KVCacheBlocks(blocks=(fa_blocks2, [ring_block2]))
3045
+ sched.update_state_after_alloc(req2, kv_blocks2, num_external_tokens=hit_tokens)
3046
+
3047
+ meta2 = sched.build_connector_meta(make_scheduler_output({req2.request_id: 1}))
3048
+ assert set(meta2.load_gpu_blocks) == {block.block_id for block in fa_blocks2}
3049
+ assert ring_block2.block_id not in meta2.load_gpu_blocks
3050
+ assert len(meta2.load_cpu_blocks) == num_blocks
3051
+
3052
+
3053
+def test_lazy_target_blocks_ignore_non_prefix_cacheable_groups() -> None:
3054
+ with_ring = _make_qsa_hybrid_kv_cache_config(num_blocks=16)
3055
+ without_ring = KVCacheConfig(
3056
+ num_blocks=16,
3057
+ kv_cache_tensors=with_ring.kv_cache_tensors[:1],
3058
+ kv_cache_groups=with_ring.kv_cache_groups[:1],
3059
+ )
3060
+ max_batched = 64
3061
+ target_with = SimpleCPUOffloadScheduler._estimate_lazy_target_blocks(
3062
+ with_ring, max_batched
3063
+ )
3064
+ target_without = SimpleCPUOffloadScheduler._estimate_lazy_target_blocks(
3065
+ without_ring, max_batched
3066
+ )
3067
+ assert target_with == target_without
vllm/v1/simple_kv_offload/manager.py
+9
-1
@@ -165,6 +165,9 @@ class SimpleCPUOffloadScheduler:
165
kv_cache_config, offload_capacity
166
)
167
self.num_cpu_blocks = self.cpu_kv_cache_config.num_blocks
168
+ self.prefix_cacheable_group_ids = (
169
+ self.cpu_kv_cache_config.prefix_cacheable_group_ids
170
+ )
171
self.kv_event_medium = MEDIUM_STORAGE if disk_capacity_bytes > 0 else MEDIUM_CPU
172
# Find the full attention kv group for prefix cache matching.
173
self.fa_gidx = -1
@@ -297,7 +300,7 @@ class SimpleCPUOffloadScheduler:
300
"""GPU blocks to keep available (free/offloaded) per step in lazy mode."""
301
WATERMARK_RATIO = 1.0 # Reserve larger space to avoid running out of GPU blocks
302
target = 0
300
- for g in kv_cache_config.kv_cache_groups:
303
+ for g in kv_cache_config.prefix_cacheable_groups:
304
spec = g.kv_cache_spec
305
# Only full attention is sharded across DCP ranks; replicated specs
306
# (mamba, sliding window, chunked-local) keep their own block size.
@@ -442,6 +445,9 @@ class SimpleCPUOffloadScheduler:
445
# the rest will be released along with the temp pin below.
446
cpu_hit_blocks: list[list[KVCacheBlock]] = []
447
for g in range(num_groups):
448
+ if g not in self.prefix_cacheable_group_ids:
449
+ cpu_hit_blocks.append([])
450
+ continue
451
g_block_size = self.group_block_sizes[g]
452
n_take_g = cdiv(num_external_tokens, g_block_size)
453
cpu_hit_blocks.append(cpu_hit_blocks_full[g][:n_take_g])
@@ -828,6 +834,8 @@ class SimpleCPUOffloadScheduler:
834
num_free = self.cpu_block_pool.get_num_free_blocks()
835
836
for g, group_gpu_ids in enumerate(block_ids_by_group):
837
+ if g not in self.prefix_cacheable_group_ids:
838
+ continue
839
if len(gpu_block_ids) >= num_free:
840
break
841
group_manager = self.cpu_coordinator.single_type_managers[g]