@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 480fadab1b
Fork 0

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 2, 07:15 UTC

[BugFix][kv_offload]: Prevent offloading stale sliding window blocks (#42959)

Signed-off-by: Or Ozeri <oro@il.ibm.com>

Or Ozeri committed Jun 2, 2026 at 05:59 UTC 480fadab1b6d6b2ee9bc039ee89b57fb64d66f60
2 files changed +156 -40
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py
+76
@@ -1305,3 +1305,79 @@ def test_swa_alignment_skip(request_runner, async_scheduling: bool):
1305 (1, 7),
1306 ),
1307 )
1308 +
1309 +
1310 +@pytest.mark.parametrize("async_scheduling", [True, False])
1311 +def test_stale_sliding_window_block_after_prepare_store_failure(
1312 + request_runner, async_scheduling: bool
1313 +):
1314 + """Regression test: when prepare_store fails (returns None), offloading is
1315 + delayed. Meanwhile, sliding window blocks get freed and reallocated to the
1316 + same request. On retry, the stale block_id must be detected and skipped.
1317 +
1318 + Without the fix, the stale block_id would either:
1319 + - Cause a KeyError in _remove_pending_job (duplicate in
1320 + _block_id_to_pending_jobs)
1321 + - Silently offload wrong data under a wrong key
1322 + """
1323 + block_size = 4
1324 + # sliding_window = 8 -> window of 2 blocks
1325 + sliding_window = 8
1326 + # Use a tight GPU block budget so freed sliding window blocks are
1327 + # immediately reused by the same request's new allocations.
1328 + num_gpu_blocks = 4
1329 +
1330 + kv_cache_groups = [
1331 + KVCacheGroupSpec(
1332 + ["layer0"],
1333 + SlidingWindowSpec(
1334 + block_size=block_size,
1335 + num_kv_heads=1,
1336 + head_size=1,
1337 + dtype=torch.float32,
1338 + sliding_window=sliding_window,
1339 + ),
1340 + ),
1341 + ]
1342 +
1343 + runner = request_runner(
1344 + block_size=block_size,
1345 + num_gpu_blocks=num_gpu_blocks,
1346 + async_scheduling=async_scheduling,
1347 + kv_cache_groups=kv_cache_groups,
1348 + )
1349 +
1350 + # Request with 3 blocks of prompt. Window = 2 blocks, so block 0 is
1351 + # outside the window but won't be freed until the next allocate_slots.
1352 + runner.new_request(token_ids=[0] * block_size * 3)
1353 +
1354 + # First step: prepare_store FAILS -> offloading delayed.
1355 + # next_stored_block_idx stays at 0, block_ids[0] still holds the
1356 + # original block_id for position 0.
1357 + runner.manager.prepare_store.side_effect = lambda keys, req_context: None
1358 + runner.run(decoded_tokens=[0])
1359 + runner.manager.prepare_store.assert_called()
1360 +
1361 + # Second step: decode more tokens -> block 3 allocated.
1362 + # allocate_slots calls remove_skipped_blocks which frees block 0
1363 + # (it's now outside the sliding window). With num_gpu_blocks=4,
1364 + # the freed block is immediately reused for the new allocation.
1365 + # prepare_store still fails so offloading is still delayed.
1366 + runner.manager.prepare_store.side_effect = lambda keys, req_context: None
1367 + runner.run(decoded_tokens=[0] * block_size)
1368 +
1369 + # Now prepare_store succeeds.
1370 + # Without the fix, the request would try to offload the stale block_id
1371 + # at position 0 (now reused at position 3), causing a duplicate in
1372 + # sliding_window_block_ids and eventually a KeyError.
1373 + runner.manager.prepare_store.side_effect = lambda keys, req_context: (
1374 + generate_store_output(keys)
1375 + )
1376 + # block_ids=[0, ?, 3, 1]: positions 0 and 1 are zeroed (stale blocks that
1377 + # were freed by the sliding window and reallocated). Only blocks at
1378 + # positions 2 and 3 (request offsets 2, 3) are stored.
1379 + runner.run(
1380 + decoded_tokens=[EOS_TOKEN_ID],
1381 + expected_stored=(2, 3),
1382 + expected_flushed=(2, 3) if not async_scheduling else (),
1383 + )
vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py
+80 -40
@@ -286,6 +286,8 @@ class OffloadingConnectorScheduler:
286 self._req_status: dict[ReqId, RequestOffloadState] = {}
287 self._current_batch_load_jobs: dict[int, TransferJob] = {}
288 self._current_batch_jobs_to_flush: set[int] = set()
289 + # GPU block IDs allocated in the current engine step
290 + self._current_batch_allocated_block_ids: set[int] = set()
291 # if GPU prefix caching is enabled,
292 # track loaded blocks to avoid redundant loads
293 self._blocks_being_loaded: set[OffloadKey] | None = (
@@ -589,6 +591,10 @@ class OffloadingConnectorScheduler:
591 req_status.group_states,
592 blocks.blocks,
593 ):
594 + self._current_batch_allocated_block_ids.update(
595 + block.block_id for block in group_blocks if block.block_id != 0
596 + )
597 +
598 gpu_block_size = group_config.gpu_block_size
599 offloaded_block_size = group_config.offloaded_block_size
600 offload_keys = group_state.offload_keys
@@ -638,17 +644,6 @@ class OffloadingConnectorScheduler:
644 if req_status.offloading_context.policy == OffloadPolicy.BLOCK_LEVEL:
645 group_state.next_stored_block_idx = num_blocks
646
641 - # Fence dst blocks against finished-request pending stores.
642 - if (
643 - self._block_id_to_pending_jobs
644 - and not self._block_id_to_pending_jobs.keys().isdisjoint(dst_block_ids)
645 - ):
646 - self._current_batch_jobs_to_flush.update(
647 - jid
648 - for bid in dst_block_ids
649 - for jid in self._block_id_to_pending_jobs.get(bid, ())
650 - )
651 -
647 src_spec = self.manager.prepare_load(keys_to_load, req_status.req_context)
648 dst_spec = GPULoadStoreSpec(
649 dst_block_ids, group_sizes=group_sizes, block_indices=block_indices
@@ -672,37 +667,68 @@ class OffloadingConnectorScheduler:
667 if self._blocks_being_loaded is not None:
668 self._blocks_being_loaded.update(keys_to_load)
669
675 - def _build_store_jobs(
676 - self,
677 - scheduler_output: SchedulerOutput,
678 - ) -> dict[int, TransferJob]:
679 - block_size_factor = self.config.block_size_factor
680 - store_jobs: dict[int, TransferJob] = {}
681 - # iterate over both new and cached requests
670 + def _update_req_states(self, scheduler_output: SchedulerOutput) -> None:
671 + """
672 + Update request states from the Scheduler's output.
673 + """
674 +
675 + # new_block_ids_end[req_id][i] = end of pre-existing block_ids for
676 + # the i-th sliding window group (before this step's extend).
677 + # Used to detect sliding window blocks that got re-allocated.
678 + new_block_ids_end: dict[str, tuple[int, ...]] = {}
679 +
680 for req_id, new_block_id_groups, preempted in yield_req_data(scheduler_output):
681 req_status = self._req_status[req_id]
682 req_status.update_offload_keys()
685 - req = req_status.req
683
684 if preempted:
685 for group_state in req_status.group_states:
686 group_state.block_ids.clear()
687
688 if new_block_id_groups:
689 + if self._sliding_window_groups:
690 + new_block_ids_end[req_id] = tuple(
691 + len(req_status.group_states[grp_idx].block_ids)
692 + for grp_idx in self._sliding_window_groups
693 + )
694 req_status.update_block_id_groups(new_block_id_groups)
693 - # Fence new blocks against in-flight stores.
694 - if self._block_id_to_pending_jobs:
695 - new_blocks_flat = [
696 - bid for new_blocks in new_block_id_groups for bid in new_blocks
697 - ]
698 - if not self._block_id_to_pending_jobs.keys().isdisjoint(
699 - new_blocks_flat
700 - ):
701 - self._current_batch_jobs_to_flush.update(
702 - jid
703 - for bid in new_blocks_flat
704 - for jid in self._block_id_to_pending_jobs.get(bid, ())
705 - )
695 + for new_blocks in new_block_id_groups:
696 + for bid in new_blocks:
697 + if bid != 0:
698 + self._current_batch_allocated_block_ids.add(bid)
699 +
700 + # Zero out stale block_ids in sliding window groups' pending-store
701 + # positions. Only sliding window groups can have stale entries (blocks
702 + # freed by remove_skipped_blocks then reallocated). Only positions in
703 + # [next_stored_block_idx * bsf, end) need checking where end is the
704 + # pre-extend length: earlier positions were already offloaded, later
705 + # ones are fresh allocations from this step.
706 + if self._sliding_window_groups and self._current_batch_allocated_block_ids:
707 + block_size_factor = self.config.block_size_factor
708 + for req_id, req_status in self._req_status.items():
709 + ends = new_block_ids_end.get(req_id)
710 + for i, grp_idx in enumerate(self._sliding_window_groups):
711 + group_state = req_status.group_states[grp_idx]
712 + start = group_state.next_stored_block_idx * block_size_factor
713 + end = ends[i] if ends is not None else len(group_state.block_ids)
714 + for j in range(start, end):
715 + if (
716 + group_state.block_ids[j]
717 + in self._current_batch_allocated_block_ids
718 + ):
719 + group_state.block_ids[j] = 0
720 +
721 + def _build_store_jobs(
722 + self,
723 + scheduler_output: SchedulerOutput,
724 + ) -> dict[int, TransferJob]:
725 + block_size_factor = self.config.block_size_factor
726 + store_jobs: dict[int, TransferJob] = {}
727 + for req_id in scheduler_output.num_scheduled_tokens:
728 + req_status = self._req_status.get(req_id)
729 + if req_status is None:
730 + continue
731 + req = req_status.req
732
733 num_scheduled_tokens = scheduler_output.num_scheduled_tokens[req_id]
734 num_tokens_after_batch = req.num_computed_tokens + num_scheduled_tokens
@@ -735,11 +761,8 @@ class OffloadingConnectorScheduler:
761 # For each block to offload, take the last corresponding GPU block.
762 # e.g. if block size factor is 3 and GPU block IDs are
763 # 1 5 6 7 2 4 9 3 8 then we'll take blocks 6 4 8.
738 - # We will use these GPU blocks to determine if the block needs
739 - # offloading, or (if the GPU block ID is 0) this block should
740 - # be skipped due to sliding window attention / SSM.
741 - # We know that if a block is skipped, then all the previous blocks
742 - # are skipped as well. This is why we take the last of each block.
764 + # A block_id of 0 means either a sliding window / SSM skip
765 + # or a stale entry that was zeroed out — skip it either way.
766 offload_block_ids = group_state.block_ids[
767 start_block_idx * block_size_factor
768 + block_size_factor
@@ -814,10 +837,8 @@ class OffloadingConnectorScheduler:
837 for i in range(block_size_factor):
838 block_id = block_ids[gpu_block_idx + i]
839 if block_id == 0:
817 - # skipped blocks cannot appear after non-skipped blocks
818 - assert start_gpu_block_idx is None
840 continue
820 - elif start_gpu_block_idx is None:
841 + if start_gpu_block_idx is None:
842 start_gpu_block_idx = gpu_block_idx + i
843 src_block_ids.append(block_id)
844 num_group_blocks += 1
@@ -875,6 +896,9 @@ class OffloadingConnectorScheduler:
896 def build_connector_meta(
897 self, scheduler_output: SchedulerOutput
898 ) -> KVConnectorMetadata:
899 + self._update_req_states(scheduler_output)
900 +
901 + # Flush jobs for preempted requests.
902 for req_id in scheduler_output.preempted_req_ids or ():
903 req_status = self._req_status.get(req_id)
904 if req_status is None or not req_status.transfer_jobs:
@@ -883,6 +907,20 @@ class OffloadingConnectorScheduler:
907 assert self._jobs[any_jid].is_store
908 self._current_batch_jobs_to_flush.update(req_status.transfer_jobs)
909
910 + # Flush jobs that contain re-allocated blocks.
911 + if (
912 + self._block_id_to_pending_jobs
913 + and not self._block_id_to_pending_jobs.keys().isdisjoint(
914 + self._current_batch_allocated_block_ids
915 + )
916 + ):
917 + self._current_batch_jobs_to_flush.update(
918 + jid
919 + for bid in self._current_batch_allocated_block_ids
920 + if bid in self._block_id_to_pending_jobs
921 + for jid in self._block_id_to_pending_jobs[bid]
922 + )
923 +
924 # If all tracked requests are finished, flush all pending jobs
925 # (both store and load) - there might not be a future scheduler
926 # step to trigger their completion.
@@ -898,6 +936,7 @@ class OffloadingConnectorScheduler:
936 )
937 self._current_batch_load_jobs = {}
938 self._current_batch_jobs_to_flush = set()
939 + self._current_batch_allocated_block_ids = set()
940 return meta
941
942 def update_connector_output(self, connector_output: KVConnectorOutput):
@@ -1013,6 +1052,7 @@ class OffloadingConnectorScheduler:
1052 # reset_cache cannot be called in the middle of a schedule step
1053 assert not self._current_batch_load_jobs
1054 assert not self._current_batch_jobs_to_flush
1055 + assert not self._current_batch_allocated_block_ids
1056
1057 # Flush all in-flight jobs
1058 self._current_batch_jobs_to_flush.update(self._jobs.keys())