KV Offloading Usage Guide¶
This guide covers configuration of the OffloadingConnector, which extends the prefix cache by offloading completed KV blocks to slower but larger tiers (CPU host memory, plus optional secondary tiers) as they are produced. Hits in the offload tiers are promoted back to GPU on demand. Transfers between GPU and CPU use DMA (cudaMemcpyAsync) and run asynchronously alongside model computation, so offloading adds minimal CPU- and GPU-core overhead.
Note
The OffloadingConnector currently supports CUDA, ROCm, and XPU only.
Overview¶
Two specs are available, selected by the spec_name key in kv_connector_extra_config:
CPUOffloadingSpec(default): single CPU tier. Completed GPU blocks are copied into pinned host memory.TieringOffloadingSpec: multi-tier. A CPU primary tier plus one or more secondary tiers.
Only the CPU primary tier has direct GPU access. Secondary tiers cannot read from or write to GPU memory; all GPU↔secondary transfers are staged through the CPU primary tier.
flowchart LR
GPU <--> CPU["CPU primary tier"]
CPU <--> S0["Secondary tier 0"]
CPU <--> S1["Secondary tier 1"]
CPU <--> SN["..."] Single-Tier Setup (CPU Only)¶
vllm serve <model> \
--kv-transfer-config '{
"kv_connector": "OffloadingConnector",
"kv_role": "kv_both",
"kv_connector_extra_config": {
"block_size": 64,
"cpu_bytes_to_use": 1000000000
}
}'
Multi-Tier Setup¶
Set spec_name to "TieringOffloadingSpec" and supply a secondary_tiers list. Each entry is a dict with a required type key plus tier-specific fields. The list is ordered: tier 0 is consulted before tier 1, and so on. See Secondary Tiers for tier-specific keys.
vllm serve <model> \
--kv-transfer-config '{
"kv_connector": "OffloadingConnector",
"kv_role": "kv_both",
"kv_connector_extra_config": {
"spec_name": "TieringOffloadingSpec",
"cpu_bytes_to_use": 10737418240,
"block_size": 16,
"eviction_policy": "lru",
"secondary_tiers": [
{
"type": "fs",
"root_dir": "/mnt/kv_cache",
"n_read_threads": 32,
"n_write_threads": 16
}
]
}
}'
kv_connector_extra_config Reference¶
| Key | Required | Default | Scope | Notes |
|---|---|---|---|---|
spec_name | no | CPUOffloadingSpec | both | Set to TieringOffloadingSpec for multi-tier. |
cpu_bytes_to_use | yes | — | both | Total bytes of host memory reserved for the CPU tier across all workers (not per-worker). |
block_size | no | GPU block size | both | Offloaded block size in tokens; must be a multiple of the GPU block size. Mutually exclusive with blocks_per_chunk. |
blocks_per_chunk | no | 1 | both | Offloaded chunk size in GPU blocks; must be > 0. Alternative to block_size for models whose KV cache groups have different block sizes. |
eviction_policy | no | lru | both | Primary tier policy: lru or arc. |
store_threshold | no | 0 | single-tier | Min lookups before a block is offloaded. Values ≥ 2 are rejected by TieringOffloadingSpec. |
max_tracker_size | no | 64000 | single-tier | Max entries in the lookup tracker. |
secondary_tiers | no | [] | multi-tier | List of secondary tier configs (see below). |
offload_prompt_only | no | true | both | If true, only prompt (prefill) blocks are offloaded; decode blocks are skipped. |
self_describing_kv_events | no | false | both | Opt-in. When true and KV cache events are enabled (--kv-events-config with enable_kv_cache_events), the connector emits self-describing block-granular BlockStored/BlockRemoved payloads (constituent block hashes, whole-chunk token_ids, per-block block_size, parent hash, LoRA + group/cache-spec metadata) instead of the placeholder fallback, so external KV-event consumers can index offloaded blocks. Inert unless events are enabled. With TieringOffloadingSpec, a CPU promotion is self-describing when a local request observes its primary-tier HIT before event translation; otherwise its stored event may retain the placeholder, while a later HIT can backfill metadata for removal. Pending-removal/re-promotion races and externally initiated promotions may also produce placeholders, and consumers must ignore removals for unknown hashes. Full-attention groups only; sliding-window/SSM groups keep the placeholder fallback. In chunk mode (block_size > GPU block size, or blocks_per_chunk > 1), overlapping chunks re-announce shared per-block hashes, so consumers must reference-count (deduplicate) repeated store/remove announcements. |
spec_module_path | no | — | both | Python import path for a custom OffloadingSpec not in the built-in registry. Required only when spec_name is not built-in (advanced). |
Secondary Tiers¶
Each entry in secondary_tiers is a dict with a required type field plus tier-specific fields.
The filesystem and object-store tiers can publish hash-only BlockStored KV events for blocks they successfully store, tagged with a stable per-tier medium (FS for the filesystem tier, OBJ for the object-store tier). Set enable_kv_events: true in the tier's entry to opt in; events are published only when KV cache events are also enabled globally via --kv-events-config.
Set the optional locality tier field to LOCAL or REMOTE to describe the tier's storage location relative to the publishing vLLM instance. LOCAL marks storage local to that instance, while REMOTE marks storage that is not local to it. When the setting is omitted, locality is unspecified. vLLM does not infer it from the tier type, so an OBJ tier is not implicitly REMOTE. A KV event includes locality only when the tier explicitly configures it. This metadata describes the tier property without implying that a consumer can already route requests to its blocks.
Filesystem (FS)¶
The filesystem tier (type: "fs") writes blocks to a filesystem directory.
| Key | Required | Default | Notes |
|---|---|---|---|
type | yes | — | Must be fs. |
root_dir | yes | — | Base directory; vLLM creates subdirectories beneath it (see On-Disk Layout). |
n_read_threads | no | 16 | Read-priority I/O threads (load path). |
n_write_threads | no | 16 | Write-priority I/O threads (store path). |
enable_kv_events | no | false | Publish BlockStored KV events (medium FS) for successfully stored blocks. Requires KV cache events to be enabled globally. |
locality | no | unspecified | LOCAL or REMOTE relative to the publishing vLLM instance. Included in the tier's KV events only when explicitly configured. |
Each thread group prefers its own queue but pulls from the other when its primary queue is empty, so a write-heavy or read-heavy burst won't leave the off-priority queue waiting. Size the totals to your storage's effective concurrency.
On-Disk Layout¶
Under root_dir, vLLM creates a subdirectory <model>_<digest>, where <model> is the model name with / replaced by _ (so HuggingFace IDs like meta-llama/Llama-3-8B don't nest), and <digest> is a short SHA256 prefix derived from the run configuration (model, block size, parallelism, dtype, etc.). Runs with the same configuration share the same subdirectory; runs with different configurations live side-by-side under the same root_dir without colliding.
Inside that subdirectory, blocks are sharded across hash-prefix subdirectories to limit directory fan-out:
<root_dir>/
<model>_<digest>/
config.json
<model>_<digest>_r<rank>/
<hhh>/ # first 3 hex chars of the block hash
<hh>_g<group_idx>/ # next 2 hex chars + KV cache group index
<hash_hex>.bin # full block hash (in hex)
config.json records the run (block size, number of KV groups, etc.) and is written on first start. Each rank writes blocks under its own _r<rank> sibling directory, so multiple ranks can safely share the same root_dir.
Cross-Process Sharing¶
To enable KV cache sharing between multiple vLLM instances using the same root_dir (e.g., via a shared PVC), the PYTHONHASHSEED environment variable must be set to the same fixed value (e.g., "0") on every instance. Without this, each process initializes NONE_HASH (the chain-hash seed for block content hashes) with random bytes, producing different block filenames for identical token content.
Object Store (OBJ)¶
The object-store tier (type: "obj") offloads blocks to an S3-compatible object store through the NIXL OBJ backend.
| Key | Required | Default | Notes |
|---|---|---|---|
type | yes | — | Must be obj. |
store_config | yes | — | Object store connection parameters (see below). |
prefix | no | "" | Key prefix prepended to all object keys. |
io_threads | no | 4 | Number of NIXL OBJ backend I/O threads. |
enable_kv_events | no | false | Publish BlockStored KV events (medium OBJ) for successfully stored blocks. Requires KV cache events to be enabled globally. |
locality | no | unspecified | LOCAL or REMOTE relative to the publishing vLLM instance. Included in the tier's KV events only when explicitly configured; OBJ does not imply REMOTE. |
store_config fields:
| Key | Required | Default | Notes |
|---|---|---|---|
bucket | yes | — | Bucket name. |
endpoint_override | yes | — | Object store endpoint host; the URL scheme is set separately via scheme. |
scheme | no | http | http or https. |
access_key, secret_key, session_token | no | "" | Explicit credentials. When left empty, the NIXL OBJ plugin falls back to the AWS SDK default credential provider chain (IAM roles, environment variables, credential files), which enables workload-identity auth on Kubernetes. |
region | no | "" | Bucket region, if the endpoint requires one. |
ca_bundle | no | "" | CA bundle path for TLS verification. |
Object keys follow the same run-configuration digest scheme as the filesystem tier (see On-Disk Layout) and are stored under the optional prefix. The Cross-Process Sharing requirement (PYTHONHASHSEED) applies to shared buckets as well, so instances sharing a bucket produce identical keys for identical content. At startup the tier probes object store connectivity and fails fast with a configuration error if the bucket is unreachable.
P2P (Including P/D)¶
The P2P tier (type: "p2p") shares completed KV blocks between vLLM instances over RDMA via NIXL. Each instance binds a control socket on host:port and exchanges blocks directly with peers — no shared filesystem required.
The PYTHONHASHSEED environment variable must be set to the same fixed value (e.g. "0") on all nodes so that block content hashes match across instances (see Cross-Process Sharing). This is enforced: a P2P instance started without PYTHONHASHSEED set fails at startup, and each peer's value is verified during the connect handshake — a peer advertising a different PYTHONHASHSEED is rejected.
| Key | Required | Default | Notes |
|---|---|---|---|
type | yes | — | Must be p2p. |
host | no | $VLLM_P2P_SIDE_CHANNEL_HOST (localhost) | Address the control socket binds to, used verbatim as the identity peers dial back. When omitted, resolves from the env var below. The localhost default binds loopback only — for cross-host P2P you must set it to the node's routable IP (see below). |
port | no | $VLLM_P2P_SIDE_CHANNEL_PORT (5710) | Base port for the control socket. Must be reachable from peers. The bound port is base + data_parallel_index (one socket per DP replica). When omitted, the base resolves from the env var below. |
backends | no | ["UCX"] | NIXL transport backends. See NixlConnector Usage Guide for available backends and selection guidance. |
num_threads | no | 4 | NIXL agent worker threads. Only used when backends is UCX-only; ignored when any non-UCX backend is requested. |
The backends and num_threads options mirror the conditional logic used by NixlConnector: when any non-UCX backend is configured, NIXL is initialised with backends=...; otherwise it falls back to a UCX-only agent with the configured num_threads. This lets the P2P tier use a different transport (e.g. MOONCAKE, GDS_MT, LIBFABRIC) than the main NixlConnector running in the same process.
Environment Variables¶
Rather than embedding host/port in each secondary_tiers entry, set them once at deploy time via environment variables (mirroring VLLM_NIXL_SIDE_CHANNEL_HOST/VLLM_NIXL_SIDE_CHANNEL_PORT). Explicit host/port config keys, when present, take precedence.
VLLM_P2P_SIDE_CHANNEL_HOST(defaultlocalhost): address the P2P control socket binds to. It is used verbatim as both the bind address and the identity peers dial back — there is no auto-detection (this mirrorsVLLM_NIXL_SIDE_CHANNEL_HOST). The default binds the loopback interface only, so peers on another host cannot reach it. For any cross-host P2P deployment you must set this explicitly to the node's routable IP (e.g. the pod IP) before launchingvllm serve— otherwise remote peers will fail to connect. The NIXL agent name is a separate per-process identifier, so peers sharing ahost:portnever collide.VLLM_P2P_SIDE_CHANNEL_PORT(default5710): base port for the P2P control socket. The port actually bound isVLLM_P2P_SIDE_CHANNEL_PORT + data_parallel_index— one socket per DP replica, matching NIXL (for DP=1 the offset is 0). The peer's port is passed asremote_portinkv_transfer_params; the router/EPP that selects the DP rank (e.g. via theX-data-parallel-rankheader) computesremote_port = base + rank. The DP-index offset separates replicas within one deployment; two co-located deployments (a prefiller and a decoder on the same host) still need distinct base ports (e.g. decoder base5711) to avoid a bind collision.
Orchestration-Layer Protocol¶
The P2P tier does not decide which peer to pull from — that is the orchestration layer's job (the router/EPP and its scheduler). The orchestrator drives every transfer through a request's kv_transfer_params dict: it picks the request's role, allocates a unique transaction ID, and supplies the remote peer's address. All block lookup, hash matching, and NIXL transfer happen at the tier level below; the orchestrator only sets the correct role keys and enforces the allowed combinations.
Every vLLM instance is a symmetric peer. Per request it acts as a consumer (pulls KV blocks from a remote peer's CPU cache instead of computing locally) or a producer (serves blocks from its own CPU cache to remote consumers) — or both, on the same session, for different requests. Roles are chosen per request by the keys below; there are no fixed prefiller/decoder processes.
Three role keys are defined, each mapping to a sub-dict. All are optional; a request with none of them uses the tier only as a local CPU cache.
Each key names the remote counterpart this peer transfers with (not this peer's own role), so the name reads as "the remote ___ I transfer with".
| Key | Set on | Value fields | Meaning |
|---|---|---|---|
remote_decoder | prefill producer request | kv_request_id | Peer computes KV and keeps it available in CPU cache for the remote decoder to pull. |
remote_prefiller | decode consumer request | kv_request_id, remote_host, remote_port | Peer pulls KV from the remote prefiller at the given address (classic P/D disaggregation). |
remote_kv_source | P2P consumer request | kv_request_id, remote_host, remote_port | Peer looks up and pulls whatever blocks the remote source currently holds in CPU cache. |
Field semantics:
kv_request_id(str): unique transaction ID allocated by the orchestrator and pushed to every peer involved in the transfer; used to correlate the lookup, fetch, and transfer-done messages. The producer is implicit — it serves whatever block hashes it currently holds in its CPU cache for that ID.remote_host(str): IP/hostname of the remote peer's control socket to query. Must be the peer's routable node IP (see Environment Variables).remote_port(int): the peer's bound control-socket port, i.e.base + data_parallel_indexfor the selected DP rank.
Allowed and forbidden combinations:
remote_decoder+remote_kv_sourceis the only legal multi-key combination: a prefill producer may also act as a P2P consumer for the same request — skipping prefix prefill by pulling cached blocks from a source while still keeping its own computed blocks available for a downstream decoder.- Forbidden:
remote_prefiller+remote_decoder(contradictory roles),remote_prefiller+remote_kv_source(two competing fetch sources), and all three together.
Minimal examples (values that would appear in the request's kv_transfer_params):
# Prefill producer — compute and keep KV for a remote decoder to pull
kv_transfer_params = {"remote_decoder": {"kv_request_id": "<unique-transfer-id>"}}
# Decode consumer — pull KV from a specific prefiller (classic P/D)
kv_transfer_params = {
"remote_prefiller": {
"kv_request_id": "<unique-transfer-id>",
"remote_host": "<prefiller-node-ip>",
"remote_port": 5710,
}
}
# P2P consumer — pull whatever the source already has cached
kv_transfer_params = {
"remote_kv_source": {
"kv_request_id": "<unique-transfer-id>",
"remote_host": "<source-node-ip>",
"remote_port": 5710,
}
}
Runtime handshake for a P2P (or P/D) pull, once the orchestrator has set the keys above:
- Both peers already have listener threads on their control sockets (see Environment Variables).
- Lookup. The consumer's tiering manager does per-block lookups; in P2P mode the tier returns
Noneand registers the key. Aton_schedule_endthe consumer sends oneLookupMsg(kv_request_id+ block hashes) to the peer, per request, per step. - The producer matches those hashes against its local CPU cache and replies with a
LookupRespMsgcarrying the hit block hashes. - Resolve. Retried lookups now return hit / miss / in-flight. The consumer calls
submit_loadfor hits only, allocating CPU slots only for hits. - The consumer sends a
FetchMsg(kv_request_id, block hashes, destination block indexes). - The producer performs the NIXL WRITE transfer and sends
TransferDonewith a success status. - On
get_finished, hits are loaded into GPU as ordinary cache hits; misses are recomputed by the engine.
In classic P/D mode (remote_prefiller set, no remote_kv_source), the lookup phase (steps 2–4) is skipped: the decode consumer assumes the prefiller holds all of the request's blocks, so every block lookup() returns an immediate hit and the consumer jumps straight to the FetchMsg in step 5. The LookupMsg/LookupRespMsg round-trip only happens in P2P mode, where the consumer does not know in advance which blocks the peer has cached.
Tuning Tips¶
cpu_bytes_to_use: a bigger CPU tier means fewer trips to slower secondary tiers and a higher hit rate. The value is total across all workers, not per-worker. Leave headroom for the rest of the host workload.- For single-tier (CPU-only) setups, set
cpu_bytes_to_uselarger than the aggregate GPU KV cache. Because offloading is immediate, a smaller CPU tier just mirrors what the GPU already holds and adds no hit rate. block_size/blocks_per_chunk: larger offloaded chunks reduce per-block bookkeeping overhead but increase the granularity of lookups.- FS thread counts: tune
n_read_threadsandn_write_threadsto the parallelism your storage can sustain. Reads are latency-sensitive on the prefill path, so prefer more read threads when prefill hit rates are high. - Sharing
root_diracross runs: runs with the same model,block_size, parallelism layout, and dtype share files under the same<digest>subdirectory. Changing any of these produces a new subdirectory; old ones are orphaned but harmless. Delete them to reclaim disk.
Per-Request Selective Offload¶
Individual requests can cap how many of their tokens are eligible for offload by setting max_offload_tokens in the request's kv_transfer_params. Only the first max_offload_tokens tokens of the request are offloaded; blocks beyond that point are skipped on the store path. This is useful when a known prefix (e.g., a system prompt or shared context) is worth caching but later request-specific tokens are not.
| Key | Type | Notes |
|---|---|---|
max_offload_tokens | non-negative int | Upper bound on tokens to offload for this request. 0 disables offload for the request entirely; omit the key (or set to None) for no cap. Non-int, negative, or bool values are rejected with a warning and treated as no cap. |
Note
max_offload_tokens is experimental and subject to change.
Example (OpenAI-compatible completions request):
Further Reading¶
- vLLM blog: KV Offloading Connector — motivation, architecture (DMA-based async transfer), and benchmarks (TTFT and throughput).