@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 941fb50835
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 4, 10:00 UTC

[kv_offload+HMA][12/N]: Scheduler-side support for sliding window groups (#41228)

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

Or Ozeri committed May 1, 2026 at 06:59 UTC 941fb5083552516eee947fc5f6c4d2031af76ea4
1 file changed +280 -98
vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py
+280 -98
@@ -18,6 +18,12 @@ from vllm.logger import init_logger
18 from vllm.utils.math_utils import cdiv
19 from vllm.v1.core.kv_cache_manager import KVCacheBlocks
20 from vllm.v1.core.sched.output import SchedulerOutput
21 +from vllm.v1.kv_cache_interface import (
22 + FullAttentionSpec,
23 + KVCacheSpec,
24 + MambaSpec,
25 + SlidingWindowSpec,
26 +)
27 from vllm.v1.kv_offload.base import (
28 GPULoadStoreSpec,
29 OffloadingManager,
@@ -44,8 +50,12 @@ class TransferJobStatus:
50 # Offload keys this job covers; passed to manager.complete_*().
51 keys: set[OffloadKey]
52 is_store: bool
47 - # GPU blocks the fence tracks. Store src blocks; None for loads.
48 - gpu_block_ids: list[int] | None = None
53 + # Store src block IDs whose ref_cnt protects them while the request
54 + # runs. Only registered in _block_id_to_pending_jobs on request_finished.
55 + non_sliding_window_block_ids: list[int] | None = None
56 + # Store src block IDs that may be freed before the request finishes.
57 + # Registered in _block_id_to_pending_jobs at store creation time.
58 + sliding_window_block_ids: list[int] | None = None
59
60
61 class GroupOffloadConfig(NamedTuple):
@@ -53,6 +63,23 @@ class GroupOffloadConfig(NamedTuple):
63 gpu_block_size: int
64 offloaded_block_size: int
65 hash_block_size_factor: int
66 + # None below means full attention
67 + sliding_window_size_in_blocks: int | None
68 +
69 +
70 +def get_sliding_window_size_in_blocks(
71 + kv_cache_spec: KVCacheSpec, offloaded_block_size: int
72 +) -> int | None:
73 + if isinstance(kv_cache_spec, SlidingWindowSpec):
74 + assert kv_cache_spec.sliding_window > 0
75 + return cdiv(kv_cache_spec.sliding_window, offloaded_block_size)
76 +
77 + if isinstance(kv_cache_spec, MambaSpec):
78 + # Mamba depends on a single state
79 + return 1
80 +
81 + assert isinstance(kv_cache_spec, FullAttentionSpec)
82 + return None
83
84
85 class SchedulerOffloadConfig(NamedTuple):
@@ -73,6 +100,10 @@ class SchedulerOffloadConfig(NamedTuple):
100 (gpu_block_size * spec.block_size_factor)
101 // spec.hash_block_size
102 ),
103 + sliding_window_size_in_blocks=get_sliding_window_size_in_blocks(
104 + spec.kv_cache_config.kv_cache_groups[idx].kv_cache_spec,
105 + gpu_block_size * spec.block_size_factor,
106 + ),
107 )
108 for idx, gpu_block_size in enumerate(spec.gpu_block_size)
109 ),
@@ -86,6 +117,9 @@ class RequestGroupState:
117 block_ids: list[int] = field(default_factory=list)
118 # index of next block (of size offloaded_block_size) to offload
119 next_stored_block_idx: int = 0
120 + # number of offloaded blocks hit (including GPU prefix cache)
121 + # when the request first started
122 + num_hit_blocks: int = 0
123
124
125 @dataclass(slots=True)
@@ -139,6 +173,14 @@ class RequestOffloadState:
173 num_blocks = num_offloadable_tokens // group_config.offloaded_block_size
174 group_state.next_stored_block_idx = num_blocks
175
176 + def update_num_hit_blocks(self, num_cached_tokens: int) -> None:
177 + for group_config, group_state in zip(
178 + self.config.kv_group_configs, self.group_states
179 + ):
180 + group_state.num_hit_blocks = (
181 + num_cached_tokens // group_config.offloaded_block_size
182 + )
183 +
184
185 class OffloadingConnectorScheduler:
186 """Implementation of Scheduler side methods"""
@@ -147,12 +189,25 @@ class OffloadingConnectorScheduler:
189 self.config = SchedulerOffloadConfig.from_spec(spec)
190 self.manager: OffloadingManager = spec.get_manager()
191
150 - attention_groups: list[int] = []
151 - for idx, _ in enumerate(spec.kv_cache_config.kv_cache_groups):
152 - # currently treat all groups as full attention
153 - attention_groups.append(idx)
192 + full_attention_groups: list[int] = []
193 + sliding_window_groups: list[int] = []
194 + for group_config in self.config.kv_group_configs:
195 + if group_config.sliding_window_size_in_blocks is None:
196 + full_attention_groups.append(group_config.group_idx)
197 + else:
198 + sliding_window_groups.append(group_config.group_idx)
199 +
200 + # sort sliding window groups by window size in decreasing order
201 + def _sliding_window_sort_key(i: int) -> int:
202 + val = self.config.kv_group_configs[i].sliding_window_size_in_blocks
203 + assert val is not None
204 + return val
205 +
206 + sliding_window_groups.sort(key=_sliding_window_sort_key, reverse=True)
207
155 - self.lookup_groups = attention_groups
208 + # used by _lookup
209 + self._sliding_window_groups: tuple[int, ...] = tuple(sliding_window_groups)
210 + self._lookup_groups = tuple(full_attention_groups) + self._sliding_window_groups
211
212 self._req_status: dict[ReqId, RequestOffloadState] = {}
213 self._current_batch_load_jobs: dict[int, TransferJob] = {}
@@ -167,8 +222,11 @@ class OffloadingConnectorScheduler:
222 self._job_counter: int = 0
223 self._jobs: dict[int, TransferJobStatus] = {}
224
170 - # block_id -> pending store job_ids. Populated only for finished
171 - # requests (running-request blocks are protected by their ref_cnt).
225 + # block_id -> pending store job_ids. Used to track jobs that needs
226 + # flushing in case a block is re-allocated by the KV cache manager.
227 + # Populated only for finished requests (running-request blocks are
228 + # protected by their ref_cnt) and for sliding window blocks (which can
229 + # be freed before a request finishes).
230 self._block_id_to_pending_jobs: dict[int, set[int]] = {}
231
232 def _generate_job_id(self) -> int:
@@ -176,10 +234,18 @@ class OffloadingConnectorScheduler:
234 self._job_counter += 1
235 return job_id
236
237 + def _remove_pending_job(self, job_id: int, block_ids: list[int] | None) -> None:
238 + for bid in block_ids or ():
239 + pending = self._block_id_to_pending_jobs[bid]
240 + pending.remove(job_id)
241 + if not pending:
242 + del self._block_id_to_pending_jobs[bid]
243 +
244 def _maximal_prefix_lookup(
245 self, keys: Iterable[OffloadKey], req_context: ReqContext
246 ) -> int | None:
182 - """Find the length of the maximal prefix of offloaded blocks."""
247 + """Return the number of consecutive offloaded blocks from the start,
248 + or None if the backend deferred a lookup."""
249 hit_count = 0
250 defer_lookup = False
251 for key in keys:
@@ -200,8 +266,9 @@ class OffloadingConnectorScheduler:
266 sliding_window_size: int,
267 req_context: ReqContext,
268 ) -> int | None:
203 - """Find the maximal ending position of consecutive offloaded blocks
204 - within a sliding window."""
269 + """Return the end index (in `keys`) of the last run of
270 + `sliding_window_size` consecutive hits, scanning from the end.
271 + Returns 0 on miss, None if the backend deferred a lookup."""
272 defer_lookup = False
273 consecutive_hits = 0
274 for idx in range(len(keys) - 1, -1, -1):
@@ -219,6 +286,160 @@ class OffloadingConnectorScheduler:
286 return idx + sliding_window_size if not defer_lookup else None
287 return consecutive_hits if not defer_lookup else None
288
289 + def _touch(self, req_status: RequestOffloadState):
290 + for group_config, group_state in zip(
291 + self.config.kv_group_configs, req_status.group_states
292 + ):
293 + if group_config.sliding_window_size_in_blocks is None:
294 + self.manager.touch(group_state.offload_keys)
295 + else:
296 + # we aim to keep just blocks that are necessary to hit
297 + # the original request (+ decoded blocks)
298 + blocks_to_skip = max(
299 + 0,
300 + group_state.num_hit_blocks
301 + - group_config.sliding_window_size_in_blocks,
302 + )
303 + self.manager.touch(group_state.offload_keys[blocks_to_skip:])
304 +
305 + def _lookup(self, req_status: RequestOffloadState) -> int | None:
306 + """
307 + Find how many tokens beyond num_locally_computed_tokens can be loaded.
308 +
309 + Iterates full-attention groups first (prefix lookup), then sliding-window
310 + groups (suffix lookup). Each group may tighten max_hit_size_tokens, which
311 + can invalidate an earlier group's result, so the loop re-runs when that
312 + happens until num_hit_tokens converges.
313 + """
314 + num_computed_tokens = req_status.num_locally_computed_tokens
315 + max_hit_size_tokens: int = req_status.req.num_tokens
316 + if self._sliding_window_groups:
317 + # the last prompt token has to be recomputed to get the logprobs
318 + # for sliding window attention, we must reduce by 1 to make sure
319 + # we still have a hit after reduction
320 + max_hit_size_tokens -= 1
321 + num_hit_tokens: int = 0
322 + defer_lookup = False
323 + lookup_groups = self._lookup_groups
324 + while lookup_groups:
325 + looked_up_sliding_window: bool = False
326 + groups_iter = iter(lookup_groups)
327 + lookup_groups = ()
328 + for group_idx in groups_iter:
329 + group_config: GroupOffloadConfig = self.config.kv_group_configs[
330 + group_idx
331 + ]
332 + group_state: RequestGroupState = req_status.group_states[group_idx]
333 + offloaded_block_size = group_config.offloaded_block_size
334 + offload_keys = group_state.offload_keys
335 +
336 + assert (
337 + len(offload_keys)
338 + >= req_status.req.num_tokens // offloaded_block_size
339 + )
340 +
341 + # Constrain to block-aligned boundary for this group
342 + max_hit_size_tokens = min(
343 + max_hit_size_tokens, len(offload_keys) * offloaded_block_size
344 + )
345 + if max_hit_size_tokens - num_computed_tokens < offloaded_block_size:
346 + # we can only load less than a block, better skip
347 + return 0
348 +
349 + num_blocks = min(
350 + cdiv(max_hit_size_tokens, offloaded_block_size), len(offload_keys)
351 + )
352 + start_block_idx = num_computed_tokens // offloaded_block_size
353 + offload_keys = offload_keys[start_block_idx:num_blocks]
354 + sliding_window_size_in_blocks = (
355 + group_config.sliding_window_size_in_blocks
356 + )
357 +
358 + # end index (in the sliced offload_keys) up to which we
359 + # have backend-confirmed hits
360 + num_hit_blocks: int | None
361 + if sliding_window_size_in_blocks is None:
362 + num_hit_blocks = self._maximal_prefix_lookup(
363 + offload_keys, req_status.req_context
364 + )
365 + else:
366 + num_hit_blocks = self._sliding_window_lookup(
367 + offload_keys,
368 + sliding_window_size_in_blocks,
369 + req_status.req_context,
370 + )
371 + if num_hit_blocks == 0:
372 + return 0
373 +
374 + if num_hit_blocks is None:
375 + defer_lookup = True
376 + else:
377 + max_hit_size_tokens = min(
378 + max_hit_size_tokens,
379 + offloaded_block_size * (start_block_idx + num_hit_blocks),
380 + )
381 +
382 + new_num_hit_tokens = max_hit_size_tokens - num_computed_tokens
383 + if new_num_hit_tokens < offloaded_block_size:
384 + # we can only load less than a block, better skip
385 + return 0
386 +
387 + if new_num_hit_tokens < num_hit_tokens:
388 + if defer_lookup:
389 + # make another iteration on all groups to check
390 + # if we still need to defer lookup
391 + defer_lookup = False
392 + lookup_groups = self._lookup_groups
393 + elif looked_up_sliding_window and not lookup_groups:
394 + # we need another iteration to confirm previously looked up
395 + # sliding window works with the new_num_hit_tokens
396 + lookup_groups = self._sliding_window_groups
397 +
398 + looked_up_sliding_window |= sliding_window_size_in_blocks is not None
399 + num_hit_tokens = new_num_hit_tokens
400 +
401 + if defer_lookup:
402 + logger.debug(
403 + "Offloading manager delayed request %s as backend requested",
404 + req_status.req.request_id,
405 + )
406 + return None
407 +
408 + # possibly delay request if any of the hit blocks is already being loaded
409 + if self._blocks_being_loaded:
410 + for group_config, group_state in zip(
411 + self.config.kv_group_configs, req_status.group_states
412 + ):
413 + offloaded_block_size = group_config.offloaded_block_size
414 + sliding_window_size_in_blocks = (
415 + group_config.sliding_window_size_in_blocks
416 + )
417 + offload_keys = group_state.offload_keys
418 + num_blocks = cdiv(
419 + num_computed_tokens + num_hit_tokens, offloaded_block_size
420 + )
421 + start_block_idx = num_computed_tokens // offloaded_block_size
422 + offload_keys = offload_keys[start_block_idx:num_blocks]
423 + if sliding_window_size_in_blocks is not None:
424 + offload_keys = offload_keys[-sliding_window_size_in_blocks:]
425 + if any(key in self._blocks_being_loaded for key in offload_keys):
426 + # hit blocks are being loaded, delay request
427 + logger.debug(
428 + "Delaying request %s since some of its"
429 + " blocks are already being loaded",
430 + req_status.req.request_id,
431 + )
432 + return None
433 +
434 + logger.debug(
435 + "Request %s hit %s offloaded tokens after %s GPU hit tokens",
436 + req_status.req.request_id,
437 + num_hit_tokens,
438 + num_computed_tokens,
439 + )
440 +
441 + return num_hit_tokens
442 +
443 def get_num_new_matched_tokens(
444 self, request: Request, num_computed_tokens: int
445 ) -> tuple[int | None, bool]:
@@ -241,96 +462,28 @@ class OffloadingConnectorScheduler:
462 - `True` if tokens will be loaded asynchronously
463 (between scheduler steps).
464 """
465 + is_new_request = False
466 if req_status := self._req_status.get(request.request_id):
467 # make sure block IDs are cleared
468 for group_state in req_status.group_states:
469 group_state.block_ids.clear()
470 else:
471 + is_new_request = True
472 req_status = RequestOffloadState(config=self.config, req=request)
473 self._req_status[request.request_id] = req_status
474
475 req_status.update_offload_keys()
476 req_status.num_locally_computed_tokens = num_computed_tokens
477
255 - for gs in req_status.group_states:
256 - self.manager.touch(gs.offload_keys)
257 -
258 - # Start with the full request size as the maximum loadable
259 - max_hit_size_tokens: int = req_status.req.num_tokens
260 - num_hit_tokens: int = 0
261 - defer_lookup = False
262 - delay_request = False
263 - for group_idx in self.lookup_groups:
264 - group_config: GroupOffloadConfig = self.config.kv_group_configs[group_idx]
265 - offloaded_block_size = group_config.offloaded_block_size
266 - offload_keys = req_status.group_states[group_idx].offload_keys
267 -
268 - num_blocks = max_hit_size_tokens // offloaded_block_size
269 - assert len(offload_keys) >= num_blocks
270 -
271 - # Constrain to block-aligned boundary for this group
272 - max_hit_size_tokens = num_blocks * offloaded_block_size
273 - num_hit_tokens = max_hit_size_tokens - num_computed_tokens
274 - if num_hit_tokens < offloaded_block_size:
275 - # we can only load less than a block, better skip
276 - return 0, False
277 -
278 - start_block_idx = num_computed_tokens // offloaded_block_size
279 - offload_keys = offload_keys[start_block_idx:num_blocks]
280 - # Full attention relies on all previous KV cache blocks.
281 - # Thus, we search for a maximal prefix of KV cache which are all cached.
282 - block_hits = self._maximal_prefix_lookup(
283 - offload_keys, req_status.req_context
478 + num_hit_tokens = self._lookup(req_status)
479 + if is_new_request:
480 + req_status.update_num_hit_blocks(
481 + num_computed_tokens + (num_hit_tokens or 0)
482 )
285 - if block_hits == 0:
286 - return 0, False
483
288 - if block_hits is None:
289 - defer_lookup = True
290 - else:
291 - # Further constrain based on what's actually available by backend
292 - max_hit_size_tokens = offloaded_block_size * (
293 - start_block_idx + block_hits
294 - )
484 + self._touch(req_status)
485
296 - num_hit_tokens = max_hit_size_tokens - num_computed_tokens
297 - if num_hit_tokens < offloaded_block_size:
298 - # we can only load less than a block, better skip
299 - return 0, False
300 -
301 - if (
302 - block_hits
303 - and self._blocks_being_loaded
304 - and any(
305 - key in self._blocks_being_loaded
306 - for key in offload_keys[:block_hits]
307 - )
308 - ):
309 - # hit blocks are being loaded, delay request
310 - delay_request = True
311 -
312 - if defer_lookup:
313 - logger.debug(
314 - "Offloading manager delayed request %s as backend requested",
315 - req_status.req.request_id,
316 - )
317 - return None, False
318 -
319 - if delay_request:
320 - logger.debug(
321 - "Delaying request %s since some of its blocks are already being loaded",
322 - req_status.req.request_id,
323 - )
324 - return None, False
325 -
326 - logger.debug(
327 - "Request %s hit %s offloaded tokens after %s GPU hit tokens",
328 - request.request_id,
329 - num_hit_tokens,
330 - num_computed_tokens,
331 - )
332 -
333 - return num_hit_tokens, True
486 + return num_hit_tokens, bool(num_hit_tokens)
487
488 def update_state_after_alloc(
489 self, request: Request, blocks: KVCacheBlocks, num_external_tokens: int
@@ -375,6 +528,13 @@ class OffloadingConnectorScheduler:
528 )
529 num_pending_gpu_blocks = num_gpu_blocks - num_locally_computed_gpu_blocks
530
531 + if group_config.sliding_window_size_in_blocks is not None:
532 + assert (
533 + num_pending_gpu_blocks
534 + <= group_config.sliding_window_size_in_blocks
535 + * self.config.block_size_factor
536 + )
537 +
538 num_blocks = cdiv(num_cached_tokens, offloaded_block_size)
539 assert len(offload_keys) >= num_blocks
540 if num_pending_gpu_blocks:
@@ -513,17 +673,21 @@ class OffloadingConnectorScheduler:
673 req_status.advance_stored_idx(num_offloadable_tokens)
674 continue
675
516 - for group_state in req_status.group_states:
517 - self.manager.touch(group_state.offload_keys)
676 + self._touch(req_status)
677
678 keys_to_store = set(store_output.keys_to_store)
679
680 group_sizes: list[int] = []
681 block_indices: list[int] = []
682 src_block_ids: list[int] = []
683 + sliding_window_block_ids: list[int] = []
684 + non_sliding_window_block_ids: list[int] = []
685 for group_config, group_state in zip(
686 self.config.kv_group_configs, req_status.group_states
687 ):
688 + is_sliding_window = (
689 + group_config.sliding_window_size_in_blocks is not None
690 + )
691 num_blocks = num_offloadable_tokens // group_config.offloaded_block_size
692 start_block_idx = group_state.next_stored_block_idx
693 block_ids = group_state.block_ids
@@ -547,6 +711,11 @@ class OffloadingConnectorScheduler:
711 elif start_gpu_block_idx is None:
712 start_gpu_block_idx = gpu_block_idx + i
713 src_block_ids.append(block_id)
714 + if is_sliding_window:
715 + sliding_window_block_ids.append(block_id)
716 + else:
717 + non_sliding_window_block_ids.append(block_id)
718 +
719 group_sizes.append(num_group_blocks)
720 block_indices.append(start_gpu_block_idx or 0)
721 group_state.next_stored_block_idx = num_blocks
@@ -562,12 +731,21 @@ class OffloadingConnectorScheduler:
731 any_jid = next(iter(req_status.transfer_jobs))
732 assert self._jobs[any_jid].is_store
733 req_status.transfer_jobs.add(job_id)
734 +
735 + # Watch sliding window blocks as they may get evicted
736 + # before the request finishes
737 + for bid in sliding_window_block_ids or ():
738 + self._block_id_to_pending_jobs.setdefault(bid, set()).add(job_id)
739 +
740 + # the non-sliding window blocks will be watched only
741 + # when the request finishes
742 self._jobs[job_id] = TransferJobStatus(
743 req_id=req_id,
744 pending_count=self.config.num_workers,
745 keys=set(keys_to_store),
746 is_store=True,
570 - gpu_block_ids=src_block_ids,
747 + non_sliding_window_block_ids=non_sliding_window_block_ids,
748 + sliding_window_block_ids=sliding_window_block_ids or None,
749 )
750
751 store_jobs[job_id] = TransferJob(
@@ -632,12 +810,16 @@ class OffloadingConnectorScheduler:
810 self._blocks_being_loaded.difference_update(job_status.keys)
811
812 req_status = self._req_status[job_status.req_id]
635 - if self._block_id_to_pending_jobs and req_status.req.is_finished():
636 - for bid in job_status.gpu_block_ids or ():
637 - pending = self._block_id_to_pending_jobs[bid]
638 - pending.remove(job_id)
639 - if not pending:
640 - del self._block_id_to_pending_jobs[bid]
813 + if self._block_id_to_pending_jobs:
814 + # Sliding window blocks are tracked from store creation
815 + # and must be cleaned up unconditionally.
816 + self._remove_pending_job(job_id, job_status.sliding_window_block_ids)
817 + # Non-sliding-window blocks are only tracked after
818 + # request_finished, so only clean up for finished requests.
819 + if req_status.req.is_finished():
820 + self._remove_pending_job(
821 + job_id, job_status.non_sliding_window_block_ids
822 + )
823
824 del self._jobs[job_id]
825 req_status.transfer_jobs.remove(job_id)
@@ -671,7 +853,7 @@ class OffloadingConnectorScheduler:
853 # Register them so future block reuse triggers a flush.
854 for job_id in req_status.transfer_jobs:
855 job_status = self._jobs[job_id]
674 - for bid in job_status.gpu_block_ids or ():
856 + for bid in job_status.non_sliding_window_block_ids or ():
857 self._block_id_to_pending_jobs.setdefault(bid, set()).add(job_id)
858 return False, None
859