How the native OffloadingConnector was rebuilt into a hybrid-aware, multi-tier KV cache subsystem.
Feature authors: Or Ozeri, Danny Harnik, Ronen Schaffer (IBM Research) · co-maintained with the vLLM KV-connector group
A vLLM Lab source-code analysis based on reading the latest main, not release notes.
A serving engine keeps, for every running request, a key–value tensor for each transformer layer — the KV cache.
The bottleneck this subsystem targets is GPU high-bandwidth memory (HBM).
As an illustration of that waste, consider a request whose 8,000-token prompt has just been processed when concurrency forces the scheduler to preempt it.
The cost of a cache miss is a full prefill of the missed prefix.
KV offloading addresses the problem directly: blocks are copied to CPU DRAM before eviction and loaded back instead of recomputed.
The naive remedy is to copy evicted KV blocks to CPU memory and copy them back on a hit.
The rewritten design meets each of these constraints in turn.
vLLM already had a connector framework for external KV stores; the native OffloadingConnector fills a different gap.
With the pressure, the strawman and its resolution, and the positioning all established, the quantitative stakes can be stated compactly.
The flat v0.16 offloading directory was reorganised over five releases into a layered package whose shape encodes the new capabilities.
Each row maps one concern to the file that held it in the flat v0.16 layout (middle) and the file or subpackage that owns it now (right).
| Concern | Old (v0.16) | New (current) |
|---|---|---|
| Core abstractions | abstract.py | base.py |
| CPU tier | cpu.pybackends/cpu.pymediums.py | cpu/spec.pycpu/manager.pycpu/common.pycpu/gpu_worker.pycpu/shared_offload_region.pycpu/swap_blocks_triton.py |
| Eviction | lru_manager.pyarc_manager.py | cpu/policies/base.pycpu/policies/lru.pycpu/policies/arc.py (pluggable) |
| Storage tiers | — | tiering/base.pytiering/manager.pytiering/spec.pytiering/factory.pytiering/async_lookup.pytiering/fs/tiering/obj/tiering/example/ |
| Worker transfer | worker/cpu_gpu.py | cpu/gpu_worker.pyworker/worker.py |
The diagram below groups the current vllm/v1/kv_offload package by responsibility before the class table narrows to individual APIs.
vllm/v1/kv_offload/base.py — shared contracts: keys, request context, manager interfaces, and policy enums
vllm/v1/kv_offload/cpu/ — CPU primary tier, block pool, GPU transfer worker, and the pluggable LRU/ARC policies
vllm/v1/kv_offload/tiering/ — secondary-tier contract, async lookup, and the filesystem, object-store, and example tiers
vllm/v1/kv_offload/worker/ — connector-side integration with the vLLM worker lifecycle
The map below ties each class to its file under vllm/v1/kv_offload/ and its purpose; these six classes recur throughout this page.
| Class / API | File | Purpose |
|---|---|---|
OffloadKey | base.py | Block hash plus group index; keeps hybrid-model blocks from different groups distinct. |
ReqContext | base.py | Threads request id and kv_transfer_params through every manager call. |
CPUOffloadingManager | cpu/manager.py | Primary CPU tier; owns ref-counting and the block pool, delegates eviction. |
LRUCachePolicy | cpu/policies/lru.py | Baseline recency-ordered eviction with atomic semantics. |
ARCCachePolicy | cpu/policies/arc.py | Adaptive policy self-tuning between recency and frequency via ghost lists. |
SecondaryTierManager | tiering/base.py | Contract for every storage tier: scheduler-side, non-blocking, no GPU access. |
A common recollection is that the rewrite landed in v0.19.0. That description is incomplete.
The package is built on a few abstract classes in base.py; five changes distinguish the new design.
A key identifies one offloaded block; the new OffloadKey packs the KV-cache group index into the key.
# vllm/v1/kv_offload/base.py:31-48 — key = block_hash + 4-byte big-endian group index
OffloadKey = NewType("OffloadKey", bytes)
def make_offload_key(block_hash: bytes, group_idx: int) -> OffloadKey:
return OffloadKey(block_hash + group_idx.to_bytes(4, "big", signed=False))
def get_offload_block_hash(key: OffloadKey) -> bytes: return key[:-4]
def get_offload_group_idx(key: OffloadKey) -> int: return int.from_bytes(key[-4:], "big")
Encoding the group index as appended bytes avoids per-key tuple allocation on the scheduler hot path.
Lookup now takes one key and returns True / False / None, where None means a transfer is in flight.
# vllm/v1/kv_offload/base.py:151 (abstract) · cpu/manager.py:115 (CPU impl)
def lookup(self, key: OffloadKey, req_context: ReqContext) -> bool | None:
block = self._policy.get(key)
if block is None: return False # absent
if not block.is_ready: return None # write in-flight; caller should retry
return True
The retry state is intentionally conservative: returning None preserves the cache hit without letting the scheduler consume a CPU slot that is still being filled. It also prevents a secondary-tier promotion from racing a second miss path for the same key.
Every manager method now receives a ReqContext, and on_new_request declares a per-request OffloadPolicy.
# vllm/v1/kv_offload/base.py:51-68
@dataclass
class ReqContext:
req_id: str
kv_transfer_params: dict | None = None
class OffloadPolicy(Enum):
BLOCK_LEVEL = "block_level" # offload only newly-computed blocks
REQUEST_LEVEL = "request_level" # offload the request's complete context
CanonicalKVCaches presents every KV cache as int8 (num_blocks, page_size_bytes) tensors, so the worker is architecture-agnostic.
A spec declares its metrics via build_metric_definitions; full Prometheus migration (RFC #44008) is the one part still finishing.
The CPU tier's manager owns shared logic and delegates the eviction decision to a pluggable CachePolicy.
A block's whole readiness/pinning protocol is one integer: -1 not-ready, 0 evictable, >0 pinned.
LRU keeps blocks in one ordered dict and evicts the least-recently-used; its eviction is atomic.
# vllm/v1/kv_offload/cpu/policies/lru.py:42-57 (condensed)
def evict(self, n: int, protected: set) -> list | None:
candidates = []
for key, block in self.blocks.items(): # oldest first
if block.ref_cnt == 0 and key not in protected:
candidates.append((key, block))
if len(candidates) == n: break
if len(candidates) < n: return None # atomic: nothing mutated
for key, _ in candidates: del self.blocks[key]
return candidates
The important edge case is failed eviction: the method returns None before mutating the ordered dictionary, so a partially available candidate set cannot corrupt policy state. Protected and pinned blocks therefore remain stable across the whole scheduler step.
ARC tracks frequency as well as recency via T1/T2 lists and B1/B2 ghost lists, self-tuning a target split.
Concretely, the policy maintains a target size for T1 and adjusts it on every ghost-list hit:
Here $p$ is the target size of $T_1$ and $c$ the capacity; the step adapts to the relative ghost-list lengths.
The adaptation rule appears in the source as a small branch on each ghost-list hit; the excerpt below condenses the hit handler to that branch.
# vllm/v1/kv_offload/cpu/policies/arc.py:88-104 (condensed)
def _on_ghost_hit(self, key: OffloadKey) -> None:
if key in self.b1: # recency under-weighted
delta = max(1, len(self.b2) // max(1, len(self.b1)))
self.p = min(self.p + delta, self.capacity)
else: # key in b2: frequency under-weighted
delta = max(1, len(self.b1) // max(1, len(self.b2)))
self.p = max(self.p - delta, 0)
# ... omitted: move the key into T2 and trim a real list to honour p ...
The code makes the clamping explicit: p stays in [0, c] on every update, and the denominators are floored at one; eviction itself remains the atomic LRU-style scan, applied within whichever list p now favours.
Transfers use swap_blocks_batch (cuMemcpyBatchAsync); GPU→CPU stores wait on the compute stream to avoid reading unfinished KV.
Multi-tier offloading rests on one rule: a secondary tier may never touch GPU memory; all transfers stage through CPU.
That rule turns every store into a cascade and every load into a promotion, both staged through CPU:
A secondary tier implements lookup / submit_store / submit_load / get_finished_jobs / on_new_request / drain_jobs, all non-blocking, all scheduler-side.
Three concrete tiers are registered in tiering/factory.py.
The reference tier a contributor copies; a synchronous in-memory dict.
One block per file via O_DIRECT into the CPU memoryview, dual-priority thread pool.
S3-compatible, over NVIDIA NIXL's OBJ backend (not boto3).
Real tiers compose an AsyncLookupManager and implement only batch_lookup; the non-blocking machinery is inherited.
The table pins each headline change to a PR, author, and release; the rewrite runs v0.19.0→v0.23.0.
| Change | PR | Author | Release |
|---|---|---|---|
| Original OffloadingConnector framework | #22595 | orozery | v0.11.0 |
| cpu/ package split + pluggable LRU/ARC | #37874 | ronensc | v0.19.0 |
| OffloadKey / single-block lookup / ReqContext | #39185 + | orozery | v0.20.0 |
| abstract.py→base.py + CanonicalKVCaches (HMA) | #38453 + | orozery | v0.21.0 |
| Multi-tier framework (SecondaryTierManager) | #40020 | ronensc | v0.22.0 |
| Filesystem secondary tier | #41735 | rshavitt | v0.22.0 |
| Object-store (S3 / NIXL) secondary tier | #41968 | effi-ofer | v0.23.0 |
| Per-request offloading policy (on_new_request) | #43205 | ronensc | v0.23.0 |
| HMA on by default for capable connectors | #41847 | chfeng-cs | v0.23.0 |
| Metadata-driven metrics (cleanup) | #45905 | Alex-ai-future | post-v0.23 |
The rewrite closed older data-safety bugs but opened higher-level state-machine and contract problems.
The columns are the issue number, a one-line problem statement, and the current status with a suitability judgement (marked by a coloured dot).
| # | Problem | Status / suitability |
|---|---|---|
| #45405 | RFC: forward ReqContext to CachePolicy; LRU/ARC recover ~no prefixes in multi-turn. | ● Open RFC, additive; high research fit. |
| #44888 | Tiering startup fails with mmap madvise OSError on kernels < 5.14. | ● Self-contained; fix PR #44913 open. |
| #46027 | on_request_finished has inconsistent completion-vs-submission semantics across layers. | ● Well-specified; good entry point. |
| #41515 | kv_offload + HMA crashes on the second chat turn (hybrid model, TP=2). | ● Scheduler-side; delicate. |
| #45268 | Sleep mode + native offload mutually exclusive (CUDA-context teardown). | ● Lifecycle work; no fix yet. |
No issue carries a good-first-issue label, but #46027/#44888/#45405 are contributor-ready.
The rewrite established well-isolated extension points; this lab includes two scaffolds built against current main.
The columns name the extension being added, the interface to implement, and the registration hook that makes it discoverable.
| To add a… | Implement | Register at |
|---|---|---|
| Storage tier | SecondaryTierManager (6 methods) | SecondaryTierFactory.register_tier |
| Eviction policy | CachePolicy (get/insert/remove/touch/evict/clear) | _CACHE_POLICIES dict in cpu/manager.py |
| Offloading spec | OffloadingSpec (get_manager, get_handlers) | OffloadingSpecFactory.register_spec |
The scaffold adds a gds tier mirroring the fs tier, routing block I/O through cuFile when enabled, with an O_DIRECT fallback.
The scaffold is a SessionAwareLRUCachePolicy that protects blocks of recently-active sessions, keyed by session_id.
AGENTS.md mandates gh duplicate-checks, human accountability, AI disclosure, uv+venv, pre-commit, 88-char lines; >500 LOC needs an RFC.