KV Cache Management#
Motivation#
In multi-turn agentic RL, each trajectory may accumulate 10,000+ tokens across multiple turns. Each turn involves:
Concatenating the full conversation history (prompt + all prior turns).
Sending the full sequence to the rollout instance for the next generation step.
Without KV cache reuse, each turn requires full re-prefill of all prior tokens, a massive waste of GPU compute that grows quadratically with conversation length. For a 10-turn trajectory with 1k tokens per turn, this means re-computing ~55k tokens of prefill across the trajectory, when only ~10k tokens of new computation are actually needed.
PSRL integrates with LMCache to solve this problem, enabling KV cache offloading, prefix reuse, and cross-instance transfer.
LMCache Integration#
Config: psrl.lmcache.*
LMCache operates as a secondary KV cache backend alongside vLLM’s GPU-resident cache. When enabled:
Offloading: After prefill, KV blocks are copied to the offload backend (CPU memory by default). This frees GPU memory for new requests while preserving computed attention state.
Hash-based chunk indexing: KV blocks are indexed by token content hashes in fixed-size chunks (default: 256 tokens). This means matching is content-based, not position-based: if two requests share the same prefix tokens, they share KV cache regardless of when they were computed.
Prefix retrieval: On subsequent turns, the system checks the offload backend for matching prefix KV. Matching blocks are loaded back to GPU, and only the new (unmatched) tokens require fresh prefill computation.
How It Works#
sequenceDiagram
participant AW as Agent Worker
participant RI as Rollout Instance
participant LMC as LMCache (CPU)
Note over AW,LMC: Turn 1
AW->>RI: generate(prompt, turn_1_tokens)
RI->>RI: Full prefill (no cache)
RI->>LMC: offload(KV blocks, hashes)
RI-->>AW: response_1
Note over AW,LMC: Turn 2
AW->>RI: generate(prompt + response_1 + turn_2_tokens)
RI->>LMC: lookup(prefix_hashes)
LMC-->>RI: cached KV (prefix match)
RI->>RI: Partial prefill (new tokens only)
RI->>LMC: offload(new KV blocks)
RI-->>AW: response_2
Configuration#
Field |
Type |
Default |
Description |
|---|---|---|---|
|
bool |
|
Master switch for LMCache integration |
|
float |
|
Total CPU memory budget for KV offloading (divided across TP ranks) |
|
int |
|
Token chunk size for hash-based indexing |
|
bool |
|
Also cache KV from decode steps (helps multi-turn reuse) |
|
bool |
|
Invalidate the whole cache after model weight sync |
|
bool |
|
Tag entries with the model version so stale-weight KV is skipped instead of cleared |
|
bool |
|
Overlap KV retrieval with prefill computation (known bug, keep disabled) |
|
str |
|
Cache eviction: |
|
str |
|
Storage backend: |
psrl:
lmcache:
enable: true
offload_size_gb: 40.0
chunk_size: 256
save_decode_cache: true
clear_on_weight_update: false
multi_version_kv: true
enable_async_loading: false
cache_policy: LRU
backend: cpu
Key Considerations#
clear_on_weight_update
When the rollout instance syncs to a new model version, all cached KV becomes stale: it was computed with the old weights. Serving subsequent turns from stale-weight KV causes accuracy degradation that compounds on top of the staleness bound itself, so stale entries must not be reused.
PSRL offers two mechanisms for this, and you should run exactly one of them:
multi_version_kv: true(the shipped default) tags each cached entry with the model version that produced it, so a lookup at version N simply misses entries from earlier versions. Nothing is thrown away, which means KV from other still-valid versions survives the sync.clear_on_weight_update: trueinvalidates the entire cache on every sync. Correct, but coarse and expensive for multi-turn trajectories that span a version boundary.
P2P transfer forces the choice: enable_p2p: true requires multi_version_kv: true and clear_on_weight_update: false, because the P2P backend does not implement clear-on-weight-sync.
Tip
Memory sizing: A rule of thumb for offload_size_gb is:
The factor of 2×2 accounts for key+value and fp16 storage. For a 7B model with 32 layers, 4096 hidden dim, 64 concurrent sequences at 4k average length: ~64 GB across all TP ranks.
P2P Cross-Instance Transfer#
Config: psrl.lmcache.enable_p2p
When the Router moves a request to a different rollout instance (due to load balancing, migration, or sync-triggered re-routing), the accumulated KV cache for that request exists on the source instance. Without P2P transfer, the target instance must re-prefill from scratch.
Architecture#
LMCache Controller: A shared process started by the Rollout Coordinator before vLLM initialization. It maintains worker registration and answers peer-lookup queries (
/query_worker_info) so instances learn each other’s transfer endpoints. It is not on the KV data path.LMCache workers: Source and destination workers perform the hot-path data move directly, avoiding a controller bottleneck.
Transport: Transfer uses the NIXL library (same as PS weight transfer):
nixl: UCX transport: auto-selects shared memory (same node), IPC (same machine), or RDMA (cross-node).tcp: Fallback TCP transport for environments without UCX/RDMA.
Transfer Flow#
A move is initiated on the source instance (triggered by the router/coordinator
when a request is re-routed). The source GenWorker calls transfer_direct(), which
sends a MoveWorkerMsg over a ZMQ REQ socket to its local LMCache worker, and the worker
pushes the KV blocks to the destination’s endpoint (new_position) over NIXL/TCP and
replies with a MoveWorkerRetMsg carrying the number of tokens moved. The Controller
is consulted only beforehand, to resolve the destination’s peer endpoint.
sequenceDiagram
participant RC as Router / Coordinator
participant Src as Source GenWorker
participant SrcW as Source LMCache worker
participant Dst as Dest LMCache worker
RC->>Src: transfer_direct(tokens, src, dst)
Src->>SrcW: MoveWorkerMsg (ZMQ REQ, new_position=dst endpoint)
SrcW->>Dst: push KV blocks (NIXL / TCP)
Dst-->>SrcW: blocks received
SrcW-->>Src: MoveWorkerRetMsg(num_tokens)
Src-->>RC: transfer result (bool)
Configuration#
Field |
Type |
Default |
Description |
|---|---|---|---|
|
bool |
|
Enable cross-instance KV transfer |
|
str |
|
Transport backend: |
|
int |
|
Base HTTP port for the LMCache Controller |
|
int |
|
ZMQ registration/heartbeat port |
|
int |
|
ZMQ task-dispatch port |
psrl:
lmcache:
enable: true
enable_p2p: true
p2p_transfer_channel: nixl
controller_base_port: 9000
Integration with Routing#
P2P KV transfer integrates with the psrl.rollout_coordination.routing_strategy.kv_transfer configuration (see Flexible Rollout Coordination). The Router decides whether to transfer, LMCache handles how the data moves.
|
Behavior |
Best For |
|---|---|---|
|
Start transfer, begin generation immediately (re-prefill if KV arrives late) |
Latency-sensitive, short prefixes |
|
Wait for transfer, then begin generation (no re-prefill) |
Long prefixes where re-prefill is expensive |
|
Pin source KV + wait + unpin |
Maximum reliability, highest overhead |
Active Development
The P2P KV cache migration feature is under active development. API and configuration may change.