[KV Offload] Add per-request `max_load_tokens control` (#55885)

Signed-off-by: Alberto Perdomo <aperdomo@redhat.com>

alberto committed Sep 16, 2026 at 04:57 UTC 75dc5882699be65eaee144cb7d0b06c1f86d3cec
3 files changed +151 -1
docs/features/kv_offloading_usage.md
+29
@@ -22,6 +22,35 @@ flowchart LR
22 CPU <--> SN["..."]
23 ```
24
25 +## Per-request load control
26 +
27 +Individual requests can cap how many tokens are loaded from offloaded storage
28 +by setting `max_load_tokens` in `kv_transfer_params`. The cap applies to tokens
29 +beyond those already available in the GPU prefix cache. Set it to `0` to
30 +disable external loading for the request:
31 +
32 +```json
33 +{
34 + "kv_transfer_params": {
35 + "max_load_tokens": 0
36 + }
37 +}
38 +```
39 +
40 +GPU prefix-cache reuse remains enabled, and tokens beyond the aligned load cap
41 +are recomputed. Omitting the field leaves loading uncapped. The value must be a
42 +non-negative integer; invalid values are ignored. Positive caps are rounded
43 +down to a boundary supported by the configured KV cache groups. Offloading
44 +remains enabled unless it is controlled separately with `max_offload_tokens`.
45 +
46 +!!! warning
47 + `max_load_tokens` is experimental and subject to change.
48 +
49 +`kv_load_tiers` continues to select secondary tiers. CPU is always included:
50 +it can satisfy a resident hit directly and is the required staging tier for
51 +secondary-to-GPU loads. Consequently, an empty tier list allows CPU hits but
52 +does not query any secondary tier.
53 +
54 ## Terminology: Chunks
55
56 The unit of operation is a **chunk** — a fixed-size piece of KV data covering a group of tokens. By default, a chunk maps to a single accelerator block. A configurable `blocks_per_chunk` parameter allows larger chunks, yielding larger I/Os to the host and secondary tiers.
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py
+92 -1
@@ -1,6 +1,7 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 from types import SimpleNamespace
4 +from typing import Any
5 from unittest.mock import MagicMock, call
6
7 import pytest
@@ -195,15 +196,17 @@ def _make_partial_tail_scheduler() -> OffloadingConnectorScheduler:
196
197 def _make_partial_tail_request(
198 scheduler: OffloadingConnectorScheduler,
199 + kv_transfer_params: dict[str, Any] | None = None,
200 ) -> MagicMock:
201 request = MagicMock()
202 request.request_id = "req"
201 - request.kv_transfer_params = None
203 + request.kv_transfer_params = kv_transfer_params
204 request.num_prompt_tokens = 30
205 request.num_tokens = 30
206 request.block_hashes = [BlockHash(f"h{i}".encode()) for i in range(7)]
207 request.all_token_ids = list(range(30))
208 request.lora_request = None
209 + request.skip_reading_prefix_cache = False
210 request.is_finished.return_value = False
211 scheduler.on_new_request(request)
212 return request
@@ -427,6 +430,53 @@ def test_lookup_cap_stops_at_authoritative_prefix_boundary():
430 assert scheduler._req_status["req"].partial_tail_boundary == 20
431
432
433 +def test_max_load_tokens_zero_skips_partial_tail_lookup():
434 + scheduler = _make_partial_tail_scheduler()
435 + request = _make_partial_tail_request(
436 + scheduler, kv_transfer_params={"max_load_tokens": 0}
437 + )
438 + scheduler.manager.lookup.return_value = LookupResult.HIT
439 +
440 + assert scheduler.get_num_new_matched_tokens(request, 16) == (0, False)
441 + scheduler.manager.lookup.assert_not_called()
442 + assert all(
443 + state.num_hit_chunks == 1 for state in scheduler._req_status["req"].group_states
444 + )
445 +
446 +
447 +def test_max_load_tokens_caps_tokens_beyond_gpu_prefix():
448 + scheduler = _make_partial_tail_scheduler()
449 + request = _make_partial_tail_request(
450 + scheduler, kv_transfer_params={"max_load_tokens": 8}
451 + )
452 + scheduler.manager.lookup.return_value = LookupResult.HIT
453 +
454 + assert scheduler.get_num_new_matched_tokens(request, 16) == (8, True)
455 + assert scheduler._req_status["req"].partial_tail_boundary == 24
456 +
457 +
458 +def test_max_load_tokens_rounds_down_without_partial_tail():
459 + scheduler = _make_partial_tail_scheduler()
460 + scheduler.config = scheduler.config._replace(supports_partial_tail=False)
461 + request = _make_partial_tail_request(
462 + scheduler, kv_transfer_params={"max_load_tokens": 20}
463 + )
464 + scheduler.manager.lookup.return_value = LookupResult.HIT
465 +
466 + assert scheduler.get_num_new_matched_tokens(request, 0) == (16, True)
467 +
468 +
469 +@pytest.mark.parametrize("value", ["8", 8.5, -1, True])
470 +def test_invalid_max_load_tokens_is_ignored(value):
471 + scheduler = _make_partial_tail_scheduler()
472 + request = _make_partial_tail_request(
473 + scheduler, kv_transfer_params={"max_load_tokens": value}
474 + )
475 + scheduler.manager.lookup.return_value = LookupResult.HIT
476 +
477 + assert scheduler.get_num_new_matched_tokens(request, 0) == (28, True)
478 +
479 +
480 def test_recurrent_group_unhashed_block_does_not_truncate_load_boundary():
481 """A recurrent group may hold unhashed blocks inside the computed prefix.
482
@@ -2964,6 +3014,47 @@ def test_skip_reading_prefix_cache(request_runner, async_scheduling: bool):
3014 runner.manager.lookup.assert_not_called()
3015
3016
3017 +@pytest.mark.parametrize("async_scheduling", [True, False])
3018 +def test_max_load_tokens_limits_external_load(request_runner, async_scheduling: bool):
3019 + """The load cap limits external reads without changing the store path."""
3020 + block_size = 4
3021 + blocks_per_chunk = 3
3022 + tokens_per_chunk = block_size * blocks_per_chunk
3023 +
3024 + runner = request_runner(
3025 + block_size=block_size,
3026 + num_gpu_blocks=100,
3027 + async_scheduling=async_scheduling,
3028 + blocks_per_chunk=blocks_per_chunk,
3029 + )
3030 +
3031 + runner.new_request(token_ids=[0] * tokens_per_chunk)
3032 + runner.manager.prepare_store.side_effect = lambda keys, req_context: (
3033 + generate_store_output(keys)
3034 + )
3035 + runner.run(
3036 + decoded_tokens=[EOS_TOKEN_ID],
3037 + expected_stored=(0, 1, 2),
3038 + )
3039 +
3040 + runner.scheduler.reset_prefix_cache()
3041 + runner.manager.lookup.reset_mock()
3042 + runner.new_request(
3043 + token_ids=[0] * tokens_per_chunk,
3044 + kv_transfer_params={"max_load_tokens": 0},
3045 + )
3046 + runner.manager.prepare_store.side_effect = lambda keys, req_context: (
3047 + generate_store_output(keys)
3048 + )
3049 + runner.run(
3050 + decoded_tokens=[EOS_TOKEN_ID],
3051 + expected_loaded=(),
3052 + expected_stored=(0, 1, 2),
3053 + )
3054 +
3055 + runner.manager.lookup.assert_not_called()
3056 +
3057 +
3058 # ---------------------------------------------------------------------------
3059 # Eagle/MTP test class
3060 # ---------------------------------------------------------------------------
vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py
+30
@@ -354,6 +354,8 @@ class RequestOffloadState:
354 req_context: ReqContext
355 offloading_context: RequestOffloadingContext
356 group_states: tuple[RequestGroupState, ...] = field(init=False)
357 + # upper bound on tokens to load for this request; None means no cap
358 + max_load_tokens: int | None = None
359 # upper bound on tokens to offload for this request; None means no cap
360 max_offload_tokens: int | None = None
361 # number of hits in the GPU cache
@@ -376,6 +378,20 @@ class RequestOffloadState:
378 )
379 params = self.req.kv_transfer_params
380
381 + # NOTE: This field is experimental and subject to change in the future.
382 + raw = params.get("max_load_tokens") if params else None
383 + if type(raw) is int and raw >= 0:
384 + self.max_load_tokens = raw
385 + logger.debug(
386 + "Request %s: max_load_tokens set to %d",
387 + self.req.request_id,
388 + raw,
389 + )
390 + elif raw is not None:
391 + logger.warning(
392 + "max_load_tokens must be a non-negative int, got %r; ignoring", raw
393 + )
394 +
395 # NOTE: This field is experimental and subject to change in the future.
396 raw = params.get("max_offload_tokens") if params else None
397 if type(raw) is int and raw >= 0:
@@ -767,6 +783,11 @@ class OffloadingConnectorScheduler:
783 max_hit_size_tokens = min(
784 max_hit_size_tokens, num_computed_tokens + max_num_new_tokens
785 )
786 + if req_status.max_load_tokens is not None:
787 + max_hit_size_tokens = min(
788 + max_hit_size_tokens,
789 + num_computed_tokens + req_status.max_load_tokens,
790 + )
791 if self._sliding_window_groups:
792 # the last prompt token has to be recomputed to get the logprobs
793 # for sliding window attention, we must reduce by 1 to make sure
@@ -810,6 +831,10 @@ class OffloadingConnectorScheduler:
831 max_hit_size_tokens = min(
832 max_hit_size_tokens, len(offload_keys) * tokens_per_chunk
833 )
834 + if req_status.max_load_tokens is not None:
835 + max_hit_size_tokens = round_down(
836 + max_hit_size_tokens, tokens_per_chunk
837 + )
838 if max_hit_size_tokens - num_computed_tokens < tokens_per_chunk:
839 # We can only load less than a chunk, so skip.
840 return 0
@@ -965,6 +990,11 @@ class OffloadingConnectorScheduler:
990 max_boundary = min(req_status.req.num_prompt_tokens - 1, block_end - 1)
991 if max_num_new_tokens is not None:
992 max_boundary = min(max_boundary, local_tokens + max_num_new_tokens)
993 + if req_status.max_load_tokens is not None:
994 + max_boundary = min(
995 + max_boundary,
996 + local_tokens + req_status.max_load_tokens,
997 + )
998 max_boundary = round_down(max_boundary, tokens_per_hash)
999 if max_boundary <= complete_boundary:
1000 return complete_hit