Repository navigation
feat(vllm): support CPU KV offloading on vLLM 0.29 and 0.30 - #598
Conversation
From vLLM 0.29 OffloadingConnector allocates its KV through kvcached, but it copies blocks after vLLM has freed them. The store that a request's last step needs is submitted at the start of the next step, after the request finished and its blocks went back; whatever a finished request still has to store is built in the next build_connector_meta; and a sliding-window group keeps freed block ids and may store them any number of builds later. vLLM waits for such a copy only when it reallocates the block, so kvcached could unmap the page first and the copy would read whatever was mapped there next. kvcached therefore refused --kv-offloading-size at startup. With that refusal removed, on vLLM 0.30 (Model Runner V2, --no-async-scheduling), Qwen2.5-0.5B on an A100 with KVCACHED_MAX_CACHED_TOKENS=0 and KVCACHED_MAX_RESERVED_PAGES=0, two of 60 requests served from the CPU cache returned '5' and '0' where native vLLM returns ' River'. Both are requests that generate one token, so they finish in the step that writes their KV. Each transfer job now pins its pages in the kvcached manager from the build that returns it until every worker has reported it, and a page left empty by a free waits for one more build. Pages of sliding-window blocks (and of Mamba groups outside "align" mode) stay pinned from allocation until their request's last build; the pins keep pages mapped, and vLLM still reuses the blocks. Mamba "align" states are stored in the pass that hands them off, so the one-build wait covers them. All of this is driven from the connector's public build, completion and pending-work methods; vLLM is unchanged. Offloading is accepted on vLLM 0.29 and 0.30 with Model Runner V2, TP=1 and PP=1, and refused at startup otherwise.
There was a problem hiding this comment.
Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.
An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.
|
/ci run |
Basic correctness · 2026-10-26 · PASSRun · kvcached 2ae013c (merged into main at 3170a0a) All 22 cases passed. Setup of every caseOn request in a pull request (gpu-pr.yml) on 2x NVIDIA L4 (23034 MiB, driver 580.178.04); the SGLang and vLLM cases run at the same time, one GPU each. kvcached is installed from the pull request merged into main into each engine's official image; each case starts one server in it.
CasesEach kvcached case is compared with the native case of the same engine and model.
Each case:
Elastic tests
Limit tests
Logs: artifact, kept 30 days: serve.log and cmd.txt per case, summary.json with every request's tokens, setup.json, run.log, GPU state at the end. |
|
/ci run weekly |
Weekly correctness · 2026-10-26 · PASSRun · kvcached 2ae013c (merged into main at 3170a0a) All 26 cases passed. These cases pass, though some prompts' greedy tokens differ from native: fewer than half of each phase's prompts, which is most likely numerical. Greedy decoding flips between near-tied tokens, and the engines also do this between their own runs.
Setup of every caseOn request in a pull request (gpu-pr.yml) on 2x NVIDIA L4 (23034 MiB, driver 580.178.04). kvcached is installed from the pull request merged into main into each engine's official image; each case starts one server in it.
CasesEach kvcached case is compared with the native case of the same engine and model.
Each case:
Elastic tests
Logs: artifact, kept 30 days: serve.log and cmd.txt per case, summary.json with every request's tokens, setup.json, run.log, GPU state at the end. |
shipiyouniao
left a comment
There was a problem hiding this comment.
[P2] Drop obsolete window holds before resumed admission
Can we release a preempted request's window holds when the connector discards its old block list, before allocating the resumed blocks? Currently the holds are only dropped after successful resumption or finish.
With offloading and sliding-window groups, lowering the memory limit below the held pages and then resetting the prefix cache with reset_running_requests=True can leave those pages pinned and retired. If the waiting request needs more blocks than the remaining unretired capacity, it cannot resume while shrink is pending, so it never reaches the code that drops its holds. Resize retries cannot complete while those held pages exceed the target.
The native connector already clears the old block IDs in get_num_new_matched_tokens, before admission. Could we mirror that lifetime boundary while retaining in-flight transfer-job pins, and cover this shrink/preemption interaction?
kvcached/kvcached/integration/vllm/offload_pins.py
Lines 153 to 174 in 2ae013c
kvcached/kvcached/kv_cache_manager.py
Lines 1455 to 1476 in 2ae013c
https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py#L1041-L1050
|
/ci run weekly |
|
GPU CI (weekly) on 62fffb9, requested by @shipiyouniao: run |
Weekly correctness · 2026-10-27 · PASSRun · kvcached 62fffb9 (merged into main at 3170a0a) All 26 cases passed. These cases pass, though some prompts' greedy tokens differ from native: fewer than half of each phase's prompts, which is most likely numerical. Greedy decoding flips between near-tied tokens, and the engines also do this between their own runs.
Setup of every caseOn request in a pull request (gpu-pr.yml) on 2x NVIDIA L4 (23034 MiB, driver 580.178.04). kvcached is installed from the pull request merged into main into each engine's official image; each case starts one server in it.
CasesEach kvcached case is compared with the native case of the same engine and model.
Each case:
Elastic tests
Logs: artifact, kept 30 days: serve.log and cmd.txt per case, summary.json with every request's tokens, setup.json, run.log, GPU state at the end. |
shipiyouniao
left a comment
There was a problem hiding this comment.
The obsolete window holds are released when native matching drops the old block lists, while in-flight transfer pins remain protected. Final-head CPU/static checks, weekly CI, and native-versus-patched vLLM 0.30 offload/reload validation passed.
Problem
From vLLM 0.29 the KV cache that
OffloadingConnectorcopies is allocated through kvcached, and kvcached may unmap a page once all of its blocks are free. In three cases a block is freed, kvcached can unmap its page, and only then does the connector copy the block:build_connector_meta, but the worker submits it only at the start of the next step. The request frees its blocks in between, so the page can be unmapped before the store is submitted.build_connector_meta(request_finished: "so _build_store_jobs can create store jobs for the last block(s) on the next schedule step"), so the page can be unmapped before that build.vLLM waits for such a copy only when it reallocates the block (
jobs_to_flush), and an unmap is not a reallocation. main refuses offloading at startup (#351):With that check removed, on vLLM 0.30 with
--no-async-scheduling, Qwen2.5-0.5B,KVCACHED_MAX_CACHED_TOKENS=0andKVCACHED_MAX_RESERVED_PAGES=0, 2 of 60 requests served from the CPU cache returned'5'and'0'where native vLLM returns' River'. Both generate one token, so they finish in the step that writes their KV.Change
build_connector_meta,update_connector_outputandhas_pending_push_work; vLLM is unchanged.OffloadingConnectoras the only connector, and refused at startup otherwise. Before 0.29 nothing changes (Error when vllm-v0.16.0 uses --kv-offloading-size #267).#269 and #361 add offloading for vLLM 0.16's cross-layer allocation, which 0.29 replaced.
Validation
tests/test_offload_copy_pins.py(24 tests, fail on main). The CPU suite goes from 1537 to 1563 passed.vllm/vllm-openai:v0.30.0andv0.29.0, kvcached8d026c4with this change (the branch was then rebased onto main with fix(controller): preserve latest activity after out-of-order requests #596, which touches only the controller),KVCACHED_MAX_CACHED_TOKENS=0,KVCACHED_MAX_RESERVED_PAGES=0. Each run sends the same prompts (4.5k-7.5k tokens, 1-24 output tokens) twice with a GPU prefix-cache reset in between, so the second pass loads its prefix from the CPU. Outputs are compared by greedy tokens and top-5 logprobs against native vLLM with the same flags. The block-size andKVCACHED_CONTIGUOUS_LAYOUT=falseruns below used this branch (2ae013c).--kv-offloading-sizeon 0.29 / 0.30'5','0')** main with the startup check from #351 removed, so that offloading runs.
With this change, sync and async scheduling:
--block-size 1024(0.30 sync, either KV layout)†; equal to the first pass in the other runs (16 prompts, 14 GB CPU cache)KVCACHED_CONTIGUOUS_LAYOUT=false; with the default layout 19 of 40 prompts hit 16 tokens more than native, and 6 of them change a near-tie token‡† kvcached selects 1024-token blocks for this model (#522); with native's default of 528 even the first pass differs from kvcached at 4 of 16 prompts.
‡ For the default contiguous layout kvcached requires a block-outermost KV layout (BLNHC), for which vLLM groups this model in 16-token blocks instead of native's 32 and 16, so its hits are multiples of 16 instead of 32 tokens. Native vLLM 0.30 cannot run that grouping: with
VLLM_KV_CACHE_LAYOUT=BLNHCit fails at startup with aKeyErroron layer 15, Gemma-4's first KV-shared layer.With
KVCACHED_CONTIGUOUS_LAYOUT=falseon 0.30, Qwen2.5-0.5B (sync, 60 prompts) and Gemma-4-E2B (sync and async, 40 prompts) match native in both passes, including per-request hits and top-5 logprobs.On 0.29 gpt-oss gets no CPU hits even natively, so only its stores ran. A counter of jobs that read freed blocks stayed at 0 for gpt-oss with and without the sliding-window pins, also with 4096-token offload chunks: kvcached reallocates freed window blocks right away, and the connector then drops their ids. The window pins follow the connector's contract rather than a reproduced failure.