Skip to content

Commit de44daa

Browse files
Merge branch 'main' into fix/per-prompt-mm-features
2 parents ee25953 + f56f3bd commit de44daa

10 files changed

Lines changed: 243 additions & 69 deletions

File tree

docs/disaggregation.md

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -539,9 +539,30 @@ Specifies which KV transfer protocol the sidecar uses to coordinate prefill/deco
539539
| `mooncake` | `MooncakeConnector` | [Mooncake](https://github.com/kvcache-ai/Mooncake) KV transfer using RDMA |
540540
| `offloading` | `OffloadingConnector` | KV transfer over the vLLM CPU offloading tier. The decoder pulls KV from the prefiller via the `p2p` secondary tier. |
541541

542-
With `offloading`, the sidecar dispatches prefill and decode concurrently. It injects role-keyed `kv_transfer_params`: the prefiller receives `{"decode": {"kv_request_id": <id>}}` (no peer address), and the decoder receives `{"prefill": {"kv_request_id": <id>, "remote_host": <prefiller host>, "remote_port": <p2p-connector-port>}}` so it can pull KV from the prefiller. The prefiller host comes from the `x-prefiller-host-port` header; the port is `--p2p-connector-port`.
543-
544-
When the request also carries the `x-kv-cache-source-host-port` header (set by the EPP `p2p-source-producer` to a peer holding more cached prefix than the pod computing the prefix), the sidecar injects an additional `p2p` key so vLLM pulls that cached prefix over the P2P tier instead of recomputing it. Under disaggregation the prefiller leg carries `{"decode": {...}, "p2p": {"kv_request_id": <own id>, "remote_host": <source host>, "remote_port": <p2p-connector-port>}}` (the only supported multi-key combination); without a prefiller the decoder-only request carries `{"p2p": {...}}` alone. A malformed or disallowed source header is ignored and the request proceeds unchanged, as is any source header on a connector that cannot pull over the P2P tier: only `offloading`, or NIXLv2 with `--enable-p2p-pull`, honors it. For the pulled blocks to be servable, the source pod must offload its generated (decode-phase) KV: set `offload_prompt_only: false` in its `kv_connector_extra_config` (the default `true` offloads only prefill blocks).
542+
With `offloading`, the sidecar dispatches prefill and decode concurrently. It
543+
injects role-keyed `kv_transfer_params`, each key named for the remote party it
544+
describes: the prefiller receives `{"remote_decoder": {"kv_request_id": <id>}}`
545+
(no peer address), and the decoder receives `{"remote_prefiller":
546+
{"kv_request_id": <id>, "remote_host": <prefiller host>, "remote_port":
547+
<p2p-connector-port>}}` so it can pull KV from the prefiller. The prefiller host
548+
comes from the `x-prefiller-host-port` header; the port is
549+
`--p2p-connector-port`.
550+
551+
When the request also carries the `x-kv-cache-source-host-port` header (set by
552+
the EPP `p2p-source-producer` to a peer holding more cached prefix than the pod
553+
computing the prefix), the sidecar injects an additional `remote_kv_source` key
554+
so vLLM pulls that cached prefix over the P2P tier instead of recomputing it.
555+
Under disaggregation the prefiller leg carries `{"remote_decoder": {...},
556+
"remote_kv_source": {"kv_request_id": <own id>, "remote_host": <source host>,
557+
"remote_port": <p2p-connector-port>}}` (the only supported multi-key
558+
combination); without a prefiller the decoder-only request carries
559+
`{"remote_kv_source": {...}}` alone. A malformed or disallowed source header is
560+
ignored and the request proceeds unchanged, as is any source header on a
561+
connector that cannot pull over the P2P tier: only `offloading`, or NIXLv2 with
562+
`--enable-p2p-pull`, honors it. For the pulled blocks to be servable, the source
563+
pod must offload its generated (decode-phase) KV: set `offload_prompt_only:
564+
false` in its `kv_connector_extra_config` (the default `true` offloads only
565+
prefill blocks).
545566

546567
Both prefill and decode pods require the following `--kv-transfer-config`:
547568

@@ -559,7 +580,28 @@ Both prefill and decode pods require the following `--kv-transfer-config`:
559580

560581
`host` must be the pod's own IP at runtime (use the Kubernetes downward API env var `status.podIP`). `port` must match `--p2p-connector-port` (default `7777`). `cpu_bytes_to_use` controls the CPU KV offload buffer size; size it to hold the KV for the expected concurrent in-flight transfers. `OffloadingConnector` is available in vLLM nightly builds from 2026-06-30 onward (commit `bec232a`, [PR #42285](https://github.com/vllm-project/vllm/pull/42285)).
561582

562-
**Restriction:** `--kv-connector=offloading` requires `--data-parallel-size=1`. Wide-EP pods (DP > 1) are rejected at startup: every DP rank would bind the same `POD_IP:<p2p-connector-port>`. DP-aware support is not yet implemented.
583+
**Data parallelism:** the P2P tier supports `--data-parallel-size` N > 1 when
584+
each pod is a complete DP group (the per-pod DP deployment).
585+
586+
- vLLM gives each DP replica its own P2P listener and offload region:
587+
replica `i` serves on `<p2p-connector-port>+i`, where `i` is the
588+
**global** `data_parallel_index`
589+
([PR #47636](https://github.com/vllm-project/vllm/pull/47636),
590+
[PR #47987](https://github.com/vllm-project/vllm/pull/47987)). Engines
591+
without those changes bind every replica to the same
592+
`POD_IP:<p2p-connector-port>`, and DP > 1 fails at engine startup.
593+
- The sidecar serves rank `r` on its own port + `r`, so the routed
594+
endpoint's port names the target rank. The sidecar injects
595+
`remote_port` = `--p2p-connector-port` + `r`; a port outside the rank
596+
range falls back to rank 0.
597+
- The endpoint port encodes the pod-local rank, which matches the global
598+
index only when the pod is a whole DP group. In multi-pod DP groups (for
599+
example LWS wide-EP, where pod `k`'s replicas hold global indices
600+
`k*N..k*N+N-1` behind the same serving ports), the global index cannot
601+
be read from the port and would have to be supplied with the request, so
602+
those deployments are not covered.
603+
- Every replica maps its own offload region, so the pod's `/dev/shm` must
604+
exceed N x `cpu_bytes_to_use`.
563605

564606
### General Sidecar Flags
565607

@@ -576,7 +618,7 @@ Both prefill and decode pods require the following `--kv-transfer-config`:
576618
|---|---|---|---|---|
577619
| `mooncake` | `--mooncake-bootstrap-port` | `MOONCAKE_BOOTSTRAP_PORT` | `8998` | Port used to query the Mooncake bootstrap endpoint on prefill pods. Corresponds to vLLM's `VLLM_MOONCAKE_BOOTSTRAP_PORT`. |
578620
| `sglang` | — | `SGLANG_BOOTSTRAP_PORT` | `8998` | Port used for the SGLang bootstrap endpoint on prefill pods. |
579-
| `offloading` | `--p2p-connector-port` | `P2P_CONNECTOR_PORT` | `7777` | Prefiller's OffloadingConnector P2P tier listening port, injected as `remote_port` on the decode leg so the decoder can pull KV. |
621+
| `offloading` | `--p2p-connector-port` | `P2P_CONNECTOR_PORT` | `7777` | Prefiller's OffloadingConnector P2P tier listening port (rank-0 port under data parallelism), injected as `remote_port` on the decode leg so the decoder can pull KV. |
580622
| `nixlv2` | `--enable-p2p-pull` | — | `false` | Declare the OffloadingConnector P2P tier available for cached-prefix pulls when the PD connector is NIXLv2, i.e. the engines run `MultiConnector(NixlConnector + OffloadingConnector)`. NIXL moves KV prefill to decode while the OffloadingConnector pulls the cached prefix named by `x-kv-cache-source-host-port`. Rejected at startup with any other connector; `offloading` provides the tier natively and needs no flag. |
581623

582624
---

pkg/sidecar/proxy/connector_nixlv2_p2p_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,8 @@ var _ = Describe("NIXL Connector with P2P pull", func() {
103103
// NIXL fields still drive the NixlConnector under MultiConnector.
104104
Expect(kv).To(HaveKeyWithValue(requestFieldDoRemoteDecode, true))
105105
Expect(kv).To(HaveKeyWithValue(requestFieldDoRemotePrefill, false))
106-
// The p2p block drives the OffloadingConnector's cached-prefix pull.
107-
p2p, ok := kv[requestFieldP2PParams].(map[string]any)
106+
// The remote_kv_source block drives the OffloadingConnector's cached-prefix pull.
107+
p2p, ok := kv[requestFieldRemoteKVSource].(map[string]any)
108108
Expect(ok).To(BeTrue())
109109
Expect(p2p[requestFieldKVRequestID]).ToNot(BeEmpty())
110110
Expect(p2p[requestFieldRemoteHost]).To(Equal("10.9.9.9"))
@@ -120,7 +120,7 @@ var _ = Describe("NIXL Connector with P2P pull", func() {
120120
routing.KVCacheSourceHeader: kvCacheSource,
121121
})
122122

123-
Expect(prefillKV()).ToNot(HaveKey(requestFieldP2PParams))
123+
Expect(prefillKV()).ToNot(HaveKey(requestFieldRemoteKVSource))
124124
})
125125

126126
It("does not compose a p2p pull when the source is the prefiller itself", func() {
@@ -133,7 +133,7 @@ var _ = Describe("NIXL Connector with P2P pull", func() {
133133
routing.KVCacheSourceHeader: prefillHostPort,
134134
})
135135

136-
Expect(prefillKV()).ToNot(HaveKey(requestFieldP2PParams))
136+
Expect(prefillKV()).ToNot(HaveKey(requestFieldRemoteKVSource))
137137
})
138138

139139
// The parallel-dispatch (MoRI-IO WRITE) path builds the prefill leg in a
@@ -157,10 +157,10 @@ var _ = Describe("NIXL Connector with P2P pull", func() {
157157
body, _ := io.ReadAll(resp.Body) //nolint:errcheck
158158
Expect(resp.StatusCode).To(Equal(http.StatusOK), string(body))
159159

160-
// Prefill leg keeps the NIXL WRITE fields and gains the composed p2p block.
160+
// Prefill leg keeps the NIXL WRITE fields and gains the composed remote_kv_source block.
161161
pkv := kvParams(env.prefillHandler, 0)
162162
Expect(pkv).To(HaveKeyWithValue(requestFieldDoRemoteDecode, true))
163-
p2p, ok := pkv[requestFieldP2PParams].(map[string]any)
163+
p2p, ok := pkv[requestFieldRemoteKVSource].(map[string]any)
164164
Expect(ok).To(BeTrue())
165165
Expect(p2p[requestFieldRemoteHost]).To(Equal("10.9.9.9"))
166166
Expect(p2p[requestFieldRemotePort]).To(BeNumerically("==", p2pConnectorPort))

pkg/sidecar/proxy/connector_p2p.go

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ import (
2121
"encoding/json"
2222
"fmt"
2323
"io"
24+
"net"
2425
"net/http"
2526
"os"
27+
"strconv"
28+
"strings"
2629
"time"
2730

2831
"go.opentelemetry.io/otel/attribute"
@@ -57,10 +60,11 @@ func (s *Server) handleP2P(w http.ResponseWriter, r *http.Request, prefillPodHos
5760
}
5861

5962
kvRequestID := newUUID()
63+
prefillP2PPort := s.p2pPortFor(prefillPodHostPort)
6064
s.logger.Info("running P2P protocol",
6165
"prefill_host", extractHost(prefillPodHostPort),
6266
"kv_request_id", kvRequestID,
63-
"p2p_connector_port", s.config.P2PConnectorPort)
67+
"p2p_connector_port", prefillP2PPort)
6468

6569
// Prefill leg: store KV under kv_request_id, no peer address. Capped to a
6670
// single output token so the prefiller returns as soon as KV is stored.
@@ -69,7 +73,7 @@ func (s *Server) handleP2P(w http.ResponseWriter, r *http.Request, prefillPodHos
6973
prefillData[k] = v
7074
}
7175
prefillKVParams := map[string]any{
72-
requestFieldP2PDecodeParams: map[string]any{
76+
requestFieldRemoteDecoder: map[string]any{
7377
requestFieldKVRequestID: kvRequestID,
7478
},
7579
}
@@ -95,10 +99,10 @@ func (s *Server) handleP2P(w http.ResponseWriter, r *http.Request, prefillPodHos
9599
decodeData[k] = v
96100
}
97101
decodeData[requestFieldKVTransferParams] = map[string]any{
98-
requestFieldP2PPrefillParams: map[string]any{
102+
requestFieldRemotePrefiller: map[string]any{
99103
requestFieldKVRequestID: kvRequestID,
100104
requestFieldRemoteHost: extractHost(prefillPodHostPort),
101-
requestFieldRemotePort: s.config.P2PConnectorPort,
105+
requestFieldRemotePort: prefillP2PPort,
102106
},
103107
}
104108

@@ -213,38 +217,77 @@ func (s *Server) handleP2PConcurrentRequests(w http.ResponseWriter, r *http.Requ
213217
// KVConnector is offloading, or is composed alongside NIXL via MultiConnector
214218
// (declared with --enable-p2p-pull) when the PD connector is NIXLv2. On any
215219
// other connector --enable-p2p-pull has no effect, since no MultiConnector
216-
// routes the p2p params to an OffloadingConnector.
220+
// routes the remote_kv_source params to an OffloadingConnector.
217221
func (s *Server) p2pPullAvailable() bool {
218222
return s.config.KVConnector == KVConnectorOffloading ||
219223
(s.config.EnableP2PPull && s.config.KVConnector == KVConnectorNIXLV2)
220224
}
221225

222-
// addP2PPullToPrefill adds the OffloadingConnector p2p pull block to a prefill
226+
// addP2PPullToPrefill adds the OffloadingConnector P2P pull block to a prefill
223227
// leg's kv_transfer_params so the prefiller pulls cached prefix from
224228
// kvCacheSource while keeping its own computed blocks available for the
225229
// decoder. It is a no-op when no source is set or the source resolves to the
226-
// prefiller itself, since there is nothing to pull from oneself. The p2p key
227-
// composes with NIXL params: vLLM's MultiConnector routes it to the
228-
// OffloadingConnector and the NIXL fields to the NixlConnector.
230+
// prefiller itself, since there is nothing to pull from oneself. The
231+
// remote_kv_source key composes with NIXL params: vLLM's MultiConnector
232+
// routes it to the OffloadingConnector and the NIXL fields to the
233+
// NixlConnector.
229234
func (s *Server) addP2PPullToPrefill(prefillKVParams map[string]any, kvCacheSource, prefillPodHostPort string) {
230235
if kvCacheSource != "" && extractHost(kvCacheSource) != extractHost(prefillPodHostPort) {
231-
prefillKVParams[requestFieldP2PParams] = s.p2pSourceParams(kvCacheSource)
236+
prefillKVParams[requestFieldRemoteKVSource] = s.p2pSourceParams(kvCacheSource)
232237
}
233238
}
234239

235-
// p2pSourceParams builds the kv_transfer_params.p2p block for a pull from
236-
// sourceHostPort's OffloadingConnector P2P tier. The kv_request_id is its
240+
// p2pSourceParams builds the kv_transfer_params.remote_kv_source block for a
241+
// pull from sourceHostPort's OffloadingConnector P2P tier. The kv_request_id is its
237242
// own fresh UUID: in P2P mode it is consumer-side only.
238243
func (s *Server) p2pSourceParams(sourceHostPort string) map[string]any {
239244
return map[string]any{
240245
requestFieldKVRequestID: newUUID(),
241246
requestFieldRemoteHost: extractHost(sourceHostPort),
242-
requestFieldRemotePort: s.config.P2PConnectorPort,
247+
requestFieldRemotePort: s.p2pPortFor(sourceHostPort),
243248
}
244249
}
245250

251+
// p2pPortFor resolves the P2P tier control port on the target endpoint. The
252+
// sidecar serves rank r on <base port>+r (data_parallel.go), so the routed
253+
// endpoint's port encodes the pod-local rank. vLLM binds the tier at
254+
// <p2p-connector-port> + the global data_parallel_index, so the mapping is
255+
// correct when each pod is its own DP group (local rank == global index).
256+
// Multi-pod DP groups (e.g. LWS wide-EP) need the source's global rank
257+
// supplied per request instead; this derivation is the per-pod fallback. A
258+
// port outside the rank range (or unparsable) falls back to the base P2P
259+
// port, which is rank 0's tier.
260+
func (s *Server) p2pPortFor(targetHostPort string) int {
261+
base := s.config.P2PConnectorPort
262+
if s.config.DataParallelSize <= 1 || s.dpBasePort == 0 {
263+
return base
264+
}
265+
// Backward compatible behavior: trim `http:` prefix (see createProxyHandler).
266+
targetHostPort, _ = strings.CutPrefix(targetHostPort, "http://")
267+
_, portStr, err := net.SplitHostPort(targetHostPort)
268+
if err != nil {
269+
s.logger.V(logging.DEBUG).Info("P2P target has no parsable port, using base P2P port",
270+
"target", targetHostPort, "error", err)
271+
return base
272+
}
273+
port, err := strconv.Atoi(portStr)
274+
if err != nil {
275+
s.logger.V(logging.DEBUG).Info("P2P target port is not numeric, using base P2P port",
276+
"target", targetHostPort, "error", err)
277+
return base
278+
}
279+
rank := port - s.dpBasePort
280+
if rank < 0 || rank >= s.config.DataParallelSize {
281+
s.logger.V(logging.DEBUG).Info("P2P target port outside the DP rank range, using base P2P port",
282+
"target", targetHostPort, "dpBasePort", s.dpBasePort,
283+
"dataParallelSize", s.config.DataParallelSize)
284+
return base
285+
}
286+
return base + rank
287+
}
288+
246289
// decodeWithP2PSource serves a decoder-only request through the local vLLM
247-
// with kv_transfer_params.p2p injected, so the engine looks up and pulls
290+
// with kv_transfer_params.remote_kv_source injected, so the engine looks up and pulls
248291
// cached prefix blocks from the peer at sourceHostPort instead of recomputing
249292
// them. It replaces any client-supplied kv_transfer_params (the sidecar owns
250293
// that field) and routes through dispatchDecode so chunked decode still
@@ -266,12 +309,12 @@ func (s *Server) decodeWithP2PSource(w http.ResponseWriter, r *http.Request, sou
266309
p2pParams := s.p2pSourceParams(sourceHostPort)
267310
// Rebuild kv_transfer_params from scratch: the sidecar owns this field, so
268311
// client-supplied keys are dropped rather than forwarded to vLLM.
269-
requestData[requestFieldKVTransferParams] = map[string]any{requestFieldP2PParams: p2pParams}
312+
requestData[requestFieldKVTransferParams] = map[string]any{requestFieldRemoteKVSource: p2pParams}
270313

271314
s.logger.Info("running P2P source protocol",
272315
"source_host", extractHost(sourceHostPort),
273316
"kv_request_id", p2pParams[requestFieldKVRequestID],
274-
"p2p_connector_port", s.config.P2PConnectorPort)
317+
"p2p_connector_port", p2pParams[requestFieldRemotePort])
275318

276319
newBody, err := json.Marshal(requestData)
277320
if err != nil {

0 commit comments

Comments
 (0)