A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 3, 19:00 UTC
[Bugfix][SimpleCPUOffloadBackend] Dedup in-flight CPU offload stores across scheduler steps (#41289)
Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Yifan Qiao committed
May 13, 2026 at 01:53 UTC
9ce74042d368d6f985741d048ad53d6d66cace6d
3 files changed
+126
-21
.buildkite/test_areas/misc.yaml
+2
@@ -79,6 +79,7 @@ steps:
79
- tests/v1/core
80
- tests/v1/executor
81
- tests/v1/kv_offload
82
+ - tests/v1/simple_kv_offload
83
- tests/v1/worker
84
- tests/v1/kv_connector/unit
85
- tests/v1/metrics
@@ -90,6 +91,7 @@ steps:
91
- pytest -v -s -m 'not cpu_test' v1/core
92
- pytest -v -s v1/executor
93
- pytest -v -s v1/kv_offload
94
+ - pytest -v -s v1/simple_kv_offload
95
- pytest -v -s v1/worker
96
- pytest -v -s -m 'not cpu_test' v1/kv_connector/unit
97
- pytest -v -s -m 'not cpu_test' v1/metrics
tests/v1/simple_kv_offload/test_scheduler.py
+116
-16
@@ -179,6 +179,7 @@ _req_counter = 0
179
def make_request(
180
num_blocks: int = 2,
181
request_id: str | None = None,
182
+ extra_tokens: int = 1,
183
) -> Request:
184
"""Create a Request with deterministic block hashes."""
185
global _req_counter
@@ -186,13 +187,7 @@ def make_request(
187
if request_id is None:
188
request_id = f"req-{_req_counter}"
189
189
- # Add one extra token beyond the last full block so that
190
- # ``max_cache_hit_length = num_tokens - 1`` (see
191
- # KVCacheManager.get_computed_blocks) does not truncate the final
192
- # full block: ``find_longest_cache_hit`` uses
193
- # ``max_length // block_size`` and would otherwise drop one block
194
- # when the prompt is an exact multiple of block_size.
195
- num_tokens = num_blocks * BLOCK_SIZE + 1
190
+ num_tokens = num_blocks * BLOCK_SIZE + extra_tokens
191
start = _req_counter * 10000
192
prompt_token_ids = list(range(start, start + num_tokens))
193
sampling_params = SamplingParams(max_tokens=1)
@@ -386,6 +381,8 @@ def test_eager_store_and_load_roundtrip() -> None:
381
block_hasher=req._block_hasher,
382
)
383
hit_tokens, is_async = sched.get_num_new_matched_tokens(req2, num_computed_tokens=0)
384
+ # make_request pads num_tokens by +1 beyond the last full block, so the
385
+ # manager's max_hit_len = num_tokens - 1 cap leaves all full blocks intact.
386
assert hit_tokens == num_blocks * BLOCK_SIZE
387
assert is_async is True
388
@@ -405,7 +402,44 @@ def test_eager_store_and_load_roundtrip() -> None:
402
403
404
# ---------------------------------------------------------------------------
408
-# Test 1b: Lazy store-and-load roundtrip
405
+# Test 1b: Boundary — max_hit_len cap drops the last full block when the
406
+# prompt is an exact multiple of BLOCK_SIZE.
407
+# ---------------------------------------------------------------------------
408
+def test_max_hit_len_cap_drops_last_full_block() -> None:
409
+ """When num_tokens is an exact multiple of BLOCK_SIZE, the manager's
410
+ ``max_hit_len = num_tokens - 1`` cap forces ``find_longest_cache_hit`` to
411
+ drop the final block (since ``max_length // block_size`` rounds down).
412
+ """
413
+ fix = make_scheduler(num_cpu_blocks=8, num_gpu_blocks=16, lazy=False)
414
+ sched = fix.scheduler
415
+
416
+ num_blocks = 2
417
+ req = make_request(num_blocks=num_blocks, extra_tokens=0)
418
+ assert req.num_tokens == num_blocks * BLOCK_SIZE
419
+
420
+ kv_blocks = _alloc_and_register(fix, req, num_blocks)
421
+ sched.update_state_after_alloc(req, kv_blocks, num_external_tokens=0)
422
+ sched_out = make_scheduler_output(
423
+ {req.request_id: num_blocks * BLOCK_SIZE},
424
+ new_reqs={req.request_id: kv_blocks.get_block_ids()},
425
+ )
426
+ meta = sched.build_connector_meta(sched_out)
427
+ simulate_store_completion(sched, meta.store_event)
428
+
429
+ req2 = Request(
430
+ request_id="req-cap-boundary",
431
+ prompt_token_ids=req.prompt_token_ids,
432
+ sampling_params=req.sampling_params,
433
+ pooling_params=None,
434
+ mm_features=None,
435
+ block_hasher=req._block_hasher,
436
+ )
437
+ hit_tokens, _ = sched.get_num_new_matched_tokens(req2, num_computed_tokens=0)
438
+ assert hit_tokens == (num_blocks - 1) * BLOCK_SIZE
439
+
440
+
441
+# ---------------------------------------------------------------------------
442
+# Test 1c: Lazy store-and-load roundtrip
443
# ---------------------------------------------------------------------------
444
def _flush_old_blocks_to_lru_head(
445
gpu_pool: BlockPool,
@@ -469,8 +503,11 @@ def test_lazy_store_and_load_roundtrip() -> None:
503
hit_tokens, is_async = sched.get_num_new_matched_tokens(
504
req_old2, num_computed_tokens=0
505
)
472
- assert hit_tokens == num_blocks * BLOCK_SIZE, (
473
- f"Expected {num_blocks * BLOCK_SIZE} hit tokens, got {hit_tokens}"
506
+ # make_request pads num_tokens by +1 beyond the last full block, so the
507
+ # manager's max_hit_len = num_tokens - 1 cap leaves all full blocks intact.
508
+ expected_hit = num_blocks * BLOCK_SIZE
509
+ assert hit_tokens == expected_hit, (
510
+ f"Expected {expected_hit} hit tokens, got {hit_tokens}"
511
)
512
assert is_async is True
513
@@ -537,7 +574,72 @@ def test_eager_duplicate_store_skipped() -> None:
574
575
576
# ---------------------------------------------------------------------------
540
-# Test 2b: Lazy duplicate store is skipped
577
+# Test 2b: Eager dedup of in-flight stores across consecutive steps
578
+# ---------------------------------------------------------------------------
579
+def test_eager_in_flight_store_dedup_across_steps() -> None:
580
+ """Eager: a second request sharing a prefix with an in-flight store
581
+ must not re-offload the same GPU blocks before completion lands.
582
+
583
+ Simulates a GPU prefix-cache hit by reusing the first request's
584
+ GPU block IDs in the second scheduler step, which is the path the
585
+ real scheduler takes when two requests share a prefix.
586
+ """
587
+ fix = make_scheduler(num_cpu_blocks=8, num_gpu_blocks=16, lazy=False)
588
+ sched = fix.scheduler
589
+
590
+ num_blocks = 2
591
+ req = make_request(num_blocks=num_blocks)
592
+
593
+ kv_blocks = _alloc_and_register(fix, req, num_blocks)
594
+ sched.update_state_after_alloc(req, kv_blocks, num_external_tokens=0)
595
+ block_ids = kv_blocks.get_block_ids()
596
+ sched_out = make_scheduler_output(
597
+ {req.request_id: num_blocks * BLOCK_SIZE},
598
+ new_reqs={req.request_id: block_ids},
599
+ )
600
+
601
+ meta1 = sched.build_connector_meta(sched_out)
602
+ assert meta1.store_event >= 0
603
+ assert len(meta1.store_cpu_blocks) == num_blocks
604
+ # In-flight set tracks the scheduled GPU blocks until completion.
605
+ assert sched._in_flight_store_gpu_blocks == set(meta1.store_gpu_blocks)
606
+ cpu_free_after_first = get_cpu_free_blocks(sched)
607
+
608
+ # Second request shares the prefix and reuses the same GPU block IDs
609
+ # (the real scheduler path: GPU prefix cache returns the same blocks).
610
+ # Do NOT simulate completion — the first store is still in-flight.
611
+ req2 = Request(
612
+ request_id="req-dup-eager-inflight",
613
+ prompt_token_ids=req.prompt_token_ids,
614
+ sampling_params=req.sampling_params,
615
+ pooling_params=None,
616
+ mm_features=None,
617
+ block_hasher=req._block_hasher,
618
+ )
619
+ req2.num_computed_tokens = num_blocks * BLOCK_SIZE
620
+ sched.update_state_after_alloc(req2, kv_blocks, num_external_tokens=0)
621
+ sched_out2 = make_scheduler_output(
622
+ {req2.request_id: num_blocks * BLOCK_SIZE},
623
+ new_reqs={req2.request_id: block_ids},
624
+ )
625
+
626
+ meta2 = sched.build_connector_meta(sched_out2)
627
+ if meta2.store_event >= 0:
628
+ assert len(meta2.store_cpu_blocks) == 0, (
629
+ "Expected no new CPU blocks for in-flight duplicate hashes"
630
+ )
631
+ assert get_cpu_free_blocks(sched) == cpu_free_after_first, (
632
+ "Second request should not consume CPU blocks while the first "
633
+ "store is still in-flight"
634
+ )
635
+
636
+ # After completion, the in-flight set is cleared.
637
+ simulate_store_completion(sched, meta1.store_event)
638
+ assert sched._in_flight_store_gpu_blocks == set()
639
+
640
+
641
+# ---------------------------------------------------------------------------
642
+# Test 2c: Lazy duplicate store is skipped
643
# ---------------------------------------------------------------------------
644
def test_lazy_duplicate_store_skipped() -> None:
645
"""Lazy: blocks already offloaded to CPU should not be offloaded again.
@@ -1092,11 +1194,9 @@ def test_partial_gpu_prefix_plus_cpu_load() -> None:
1194
hit_tokens, is_async = sched.get_num_new_matched_tokens(
1195
req2, num_computed_tokens=gpu_local_computed
1196
)
1095
- # CPU should hit blocks 2,3 (not 4,5 — those are beyond the CPU range).
1096
- num_cpu_hit_blocks = 2
1097
- # Actually CPU has all 6 stored; it returns hits starting from position 2.
1098
- # The number of CPU hit blocks = min(remaining request blocks, CPU cached).
1099
- # Here remaining = 6 - 2 = 4 blocks are in CPU, so hit = 4 * BLOCK_SIZE.
1197
+ # CPU has all 6 blocks stored. make_request pads num_tokens by +1, so
1198
+ # the manager's num_tokens - 1 cap leaves all full blocks intact:
1199
+ # remaining hashable range = 6 - 2 = 4 blocks, all hit.
1200
num_cpu_hit_blocks = 4
1201
assert hit_tokens == num_cpu_hit_blocks * BLOCK_SIZE, (
1202
f"Expected {num_cpu_hit_blocks * BLOCK_SIZE} CPU hit tokens, got {hit_tokens}"
vllm/v1/simple_kv_offload/manager.py
+8
-5
@@ -146,6 +146,7 @@ class SimpleCPUOffloadScheduler:
146
# Eager mode only
147
self._reqs_to_store: dict[str, StoreRequestState] = {}
148
self._store_event_to_reqs: dict[int, list[str]] = {}
149
+ self._in_flight_store_gpu_blocks: set[int] = set()
150
151
# Event counters
152
self._load_event_counter: int = 0
@@ -468,7 +469,8 @@ class SimpleCPUOffloadScheduler:
469
num_free = cpu_block_pool.get_num_free_blocks()
470
kv_cache_groups = self.cpu_kv_cache_config.kv_cache_groups
471
num_groups = len(kv_cache_groups)
471
- gpu_blocks_this_step: set[int] = set()
472
+ # Dedup against blocks already scheduled.
473
+ in_flight = self._in_flight_store_gpu_blocks
474
475
for req_id, new_block_id_groups, preempted in yield_req_data(scheduler_output):
476
state = self._reqs_to_store.get(req_id)
@@ -523,10 +525,9 @@ class SimpleCPUOffloadScheduler:
525
if bhash_with_group is None:
526
break
527
526
- # Check if this group's data is already scheduled for store
527
- # in this step or already cached in CPU.
528
+ # Skip if already scheduled for store or already cached in CPU.
529
if (
529
- gpu_block_id in gpu_blocks_this_step
530
+ gpu_block_id in in_flight
531
or cpu_block_pool.cached_block_hash_to_block.get_one_block(
532
bhash_with_group
533
)
@@ -561,7 +562,7 @@ class SimpleCPUOffloadScheduler:
562
req_ids.append(req_id)
563
merged_gpu_block_ids.extend(gpu_block_ids)
564
merged_cpu_block_ids.extend(cpu_block_ids)
564
- gpu_blocks_this_step.update(gpu_block_ids)
565
+ in_flight.update(gpu_block_ids)
566
567
# Touch GPU blocks to prevent freeing during async copy
568
gpu_block_pool.touch(
@@ -608,6 +609,8 @@ class SimpleCPUOffloadScheduler:
609
def _process_store_event(self, event_idx: int) -> None:
610
"""Process a fully-completed store event."""
611
transfer = self._store_event_to_blocks.pop(event_idx)
612
+ if not self._lazy_mode:
613
+ self._in_flight_store_gpu_blocks.difference_update(transfer.gpu_block_ids)
614
self._process_store_completion(transfer.gpu_block_ids, transfer.cpu_block_ids)
615
logger.debug(
616
"Store event %d completed: cached %d blocks to CPU",