Prefill-Decode Disaggregation with ModelBooster (vLLM, LMCache & NIXL)
This page describes how to deploy prefill-decode disaggregated inference with a
single ModelBooster resource on a GPU cluster, using either KV connector
supported by vLLM:
lmcache— KV reuse through a shared store (e.g. Redis).nixl— in-band, point-to-point KV transfer between prefill and decode.
How the KV connector type is selected
Kthena chooses the ModelServer's spec.kvConnector.type purely from the
kv_connector name inside each worker's kv-transfer-config — the image name has
no effect. An image built with ...-nixl-...-lmcache-... can run either
connector; what matters is the JSON:
kv_connector in kv-transfer-config | Generated kvConnector.type |
|---|---|
LMCacheConnectorV1 | lmcache |
NixlConnector | nixl |
MooncakeConnector / MooncakeConnectorV1 | mooncake |
The two connectors reuse KV cache in fundamentally different ways:
| Connector | KV transfer mechanism | Shared backend required | Router connector |
|---|---|---|---|
nixl | In-band kv_transfer_params handshake (P2P) | No | NIXL connector (parses prefill response, forwards kv_transfer_params to decode) |
lmcache | Shared store (Redis / lmcache-server / RWX PVC) | Yes | Generic HTTP connector (does not forward kv_transfer_params) |
For the lmcache type, KV reuse happens below the request layer through the
shared backend. Without a shared backend (LMCACHE_REMOTE_URL or a shared local
disk), each pod's LMCache writes only to its own local CPU memory, so no cross-pod
KV reuse is possible — the deployment behaves like request-level prefill/decode
routing only. This is the root cause reported in
issue #1069. The
lmcache type is intended for the shared-store mode; the P2P-over-LMCache path is
not currently supported. Use the nixl connector when you want in-band P2P transfer.
Prerequisites
- Kubernetes cluster with Kthena installed
- NVIDIA GPU-enabled nodes with the appropriate device plugin configured
- A vLLM image whose connector Python dependencies are fully installed:
- LMCache: use an image where LMCache is fully bundled, e.g. the official
lmcache/vllm-openai:latest. Theghcr.io/volcano-sh/vllm-openai:...-lmcache-0.3.2image ships the LMCache package but not all of its runtime dependencies (nvtx,sortedcontainers,redis, ...), so loadingLMCacheConnectorV1from it crash-loops withModuleNotFoundError. - NIXL: the
ghcr.io/volcano-sh/vllm-openai:v0.10.0-cu128-nixl-v0.4.1-lmcache-0.3.2image already bundles a compatible NIXL (v0.4.1).
- LMCache: use an image where LMCache is fully bundled, e.g. the official
- The target model accessible from the cluster
- For the
lmcacheconnector only: a standalone Redis instance reachable from the cluster to act as the shared LMCache backend
When using the ModelBooster approach, the ModelServer and ModelRoute resources
are created and managed automatically — you do not need to deploy them manually.
ModelBooster creates a ModelServing named {modelbooster-name}-{backend-name},
and a ModelRoute whose modelName equals the ModelBooster name. Send inference
requests with "model": "<modelbooster-name>", not the vLLM served-model-name.
Option A: LMCache connector (shared store)
1. Deploy a shared Redis backend
The lmcache connector needs a shared store for cross-pod KV reuse. Deploy the
provided Redis example (adjust the namespace as needed):
kubectl apply -f https://raw.githubusercontent.com/volcano-sh/kthena/main/examples/redis/redis-standalone.yaml
This creates a redis-server Service. The ModelBooster below points
LMCACHE_REMOTE_URL at redis://redis-server.default.svc.cluster.local:6379 —
update the host/namespace if you deploy Redis elsewhere.
2. Deploy the ModelBooster
Deploy the LMCache ModelBooster configuration for LMCache-backed prefill-decode disaggregation:
kubectl apply -f https://raw.githubusercontent.com/volcano-sh/kthena/main/examples/model-booster/lmcache-pd-disaggregation.yaml
Key parts of the configuration:
kv-transfer-config: Both workers declareLMCacheConnectorV1; the prefill worker useskv_role: kv_producerand the decode worker useskv_role: kv_consumer. This is what makes Kthena emitkvConnector.type: lmcache.- Shared-store env vars (applied to both workers via
spec.backend.env):LMCACHE_REMOTE_URL— the shared Redis backend used to exchange KV cache.LMCACHE_REMOTE_SERDE— serialization format for the remote backend (naive).LMCACHE_CHUNK_SIZE— LMCache chunk size. Prompts shorter than this never reach the shared backend, so test with prompts longer than this value.LMCACHE_LOCAL_CPU/LMCACHE_MAX_LOCAL_CPU_SIZE— LMCache hard-requires aLocalCPUBackend. KeepLOCAL_CPU=Truewith a small size even when relying on the remote backend;LOCAL_CPU=FalsewithMAX_LOCAL_CPU_SIZE=0triggersKeyError: 'LocalCPUBackend'.
3. Verify the LMCache deployment
Confirm the ModelServer was generated with the lmcache connector type:
kubectl get modelserver -n default -o yaml | grep -A2 kvConnector
Expected output:
kvConnector:
type: lmcache
Verify that both prefill and decode pods are running:
kubectl get pod -owide -l modelserving.volcano.sh/name=qwen-lmcache-pd-qwen-lmcache -n default
Expected output:
NAME READY STATUS RESTARTS AGE
qwen-lmcache-pd-qwen-lmcache-0-decode-0-0 2/2 Running 0 3m
qwen-lmcache-pd-qwen-lmcache-0-prefill-0-0 2/2 Running 0 3m
Send a chat completion request through the Kthena router. The model field must be
the ModelBooster name (qwen-lmcache-pd), which is the ModelRoute modelName.
Use a prompt longer than LMCACHE_CHUNK_SIZE tokens so the KV cache actually
reaches Redis:
curl -X POST http://<ROUTER_IP>:80/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"qwen-lmcache-pd","messages":[{"role":"user","content":"<long prompt of 300+ tokens>"}],"max_tokens":20}'
Confirm real KV reuse
Because the lmcache connector reuses KV through the shared backend rather than
in-band kv_transfer_params, verify reuse by inspecting Redis and the vLLM/LMCache
logs rather than the router logs.
-
Send the same long prompt twice (or to prefill first, then decode).
-
Check that Redis is populated:
kubectl exec -it deploy/redis-server -n default -- redis-cli DBSIZE -
Inspect the decode pod logs for an external prefix cache hit:
LMCache INFO: Reqid: ..., LMCache hit tokens: 512, need to load: 496
Prefix cache hit rate: 2.9%, External prefix cache hit rate: 70.8%
A non-zero External prefix cache hit rate on the decode side confirms that KV cache produced by prefill was reused via the shared Redis backend.
Option B: NIXL connector (in-band P2P)
The nixl connector transfers KV cache directly between prefill and decode via a
kv_transfer_params handshake, so no shared Redis/LMCache backend is required.
The same LMCache-enabled image can run NIXL — only the kv_connector name changes.
Deploy the NIXL ModelBooster configuration:
kubectl apply -f https://raw.githubusercontent.com/volcano-sh/kthena/main/examples/model-booster/nixl-pd-disaggregation.yaml
Key parts of the configuration:
kv-transfer-config: Both workers declareNixlConnector(prefillkv_producer, decodekv_consumer). This makes Kthena emitkvConnector.type: nixl, and the router's NIXL connector parses the prefill response and forwardskv_transfer_paramsto decode.KTHENA_SKIP_ENGINE_DEPENDENCY_INSTALL: "1": For the NIXL connector Kthena otherwise runspip install -U nixlat container startup, which upgrades the image's bundled NIXL to a version that is incompatible with the image's vLLM and crash-loops the engine. Since the recommended image already bundles a compatible NIXL, skip that install.- NIXL env vars (applied to both workers via
spec.backend.env):VLLM_NIXL_SIDE_CHANNEL_HOST/VLLM_NIXL_SIDE_CHANNEL_PORTfor the side channel, plusNCCL_IB_*/UCX_TLSsettings that match your cluster network. Do not setGLOO_SOCKET_IFNAME/TP_SOCKET_IFNAME/HCCL_SOCKET_IFNAMEhere — Kthena already injects those into the decode worker, and Kubernetes rejects duplicate env var names (the ModelServing would fail validation).
Verify the NIXL deployment
Confirm the connector type and pod status:
kubectl get modelserver -n default -o yaml | grep -A2 kvConnector
Expected output:
kvConnector:
type: nixl
kubectl get pod -owide -l modelserving.volcano.sh/name=qwen-nixl-pd-qwen-nixl -n default
Send a test request through the router:
curl -X POST http://<ROUTER_IP>:80/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"qwen-nixl-pd","messages":[{"role":"user","content":"Hello"}],"max_tokens":20}'
With NIXL, KV transfer is confirmed by the router log showing prefill and decode
routed within the same PD group, and by a successful response. Unlike lmcache, no
shared backend or minimum prompt length is required for KV transfer to occur.
NIXL performs GPU-to-GPU KV transfer over its side channel (UCX). This requires a
cluster network/transport that NIXL can use between the prefill and decode pods
(e.g. RDMA/InfiniBand, or a correctly configured UCX_TLS). On environments
without a suitable transport, the pods start and requests are routed, but the
decode engine can fail during the KV read (nixl_connector.py _read_blocks).
Tune UCX_TLS, NCCL_IB_DISABLE, and NCCL_IB_GID_INDEX to match your network,
or use the lmcache shared-store option, which does not need a P2P transport.
Troubleshooting
ModuleNotFoundError(nvtx,sortedcontainers,redis, ...) on the vLLM container withlmcache: The image ships LMCache but not all of its runtime dependencies. Use an image where LMCache is fully installed, e.g.lmcache/vllm-openai:latest.- NIXL vLLM container crash-loops right after a
pip install ... nixllog line: The startup dependency install upgraded NIXL to a version incompatible with the image's vLLM. SetKTHENA_SKIP_ENGINE_DEPENDENCY_INSTALL: "1"so the bundled NIXL is used. - ModelServing rejected with
Duplicate value: {"name":"GLOO_SOCKET_IFNAME"}: Kthena auto-injectsGLOO_SOCKET_IFNAME/TP_SOCKET_IFNAME/HCCL_SOCKET_IFNAMEinto the decode worker. Remove those env vars fromspec.backend.env. - Router returns
no decode pod found/ request fails: A prefill or decode pod is not Ready (often crash-looping). Check pod status and the vLLM container logs for the underlying error. LMCache hit tokens: 0/External prefix cache hit rate: 0.0%: Usually means no shared backend is configured, or the prompt is shorter thanLMCACHE_CHUNK_SIZE(withdiscard_partial_chunks: true, partial chunks are never stored). VerifyLMCACHE_REMOTE_URLis reachable and use a longer prompt.KeyError: 'LocalCPUBackend': SetLMCACHE_LOCAL_CPU=Truewith a smallLMCACHE_MAX_LOCAL_CPU_SIZE(e.g.1) even when relying on the remote backend.kvConnector.typeis not what you expected: The type comes from thekv_connectorname inkv-transfer-config, not the image. UseLMCacheConnectorV1forlmcacheandNixlConnectorfornixl.- NIXL decode errors / no transfer: Ensure
VLLM_NIXL_SIDE_CHANNEL_HOSTandVLLM_NIXL_SIDE_CHANNEL_PORTare set and theNCCL_IB_*/UCX_TLSvalues match your cluster network interfaces and transport.
For the ModelServing-based approach (manually creating ModelServing,
ModelServer, and ModelRoute), see
Prefill-Decode Disaggregation with ModelServing (vLLM, NIXL & LMCache).