Skip to content

Commit f56f3bd

Browse files
authored
feat(sidecar): support data parallelism with the offloading P2P tier (llm-d#2075)
* feat(sidecar): support data parallelism with the offloading P2P tier vLLM binds DP rank r's P2P tier on <p2p-connector-port>+r and gives each replica a distinct shared-memory offload region (vllm-project/vllm#47636, vllm-project/vllm#47987), so ranks in one pod no longer collide and the startup rejection of --kv-connector=offloading with --data-parallel-size > 1 guards a configuration the engine now supports. Lift it and derive the injected remote_port from the routed endpoint's port: rank r is served on <base port>+r, so the matching P2P port is <p2p-connector-port>+r. Ports outside the rank range fall back to rank 0, so a stale or foreign header degrades to a lookup miss instead of an error. Assumes pods in the pool share the sidecar port layout and DP size. Fixes llm-d#2074 Signed-off-by: nilig <nili.ifergan@gmail.com> * fix(sidecar): bound rank ports and accept scheme-prefixed P2P targets The injected port is p2p-connector-port plus the target's DP rank, so the highest rank must stay within 65535 - validate the range, not just the base. Routed endpoint values may carry a http:// prefix (the proxy trims it when building handlers); trim it in rank derivation and in extractHost too, so a scheme-prefixed header yields the correct remote_host and remote_port instead of a garbage host with a rank-0 port. Signed-off-by: nilig <nili.ifergan@gmail.com> * docs(sidecar): scope P2P rank derivation to per-pod DP groups vLLM binds the tier at p2p-connector-port plus the global data_parallel_index (tiering/p2p/manager.py), while the endpoint-port offset yields the pod-local rank. The two agree only when each pod is its own DP group, so state that boundary: multi-pod DP groups (LWS wide-EP) need the source's global index supplied per request, and this derivation is the per-pod fallback. Signed-off-by: nilig <nili.ifergan@gmail.com> * chore(sidecar): log P2P rank-derivation fallbacks at debug The fallbacks in p2pPortFor silently return the base P2P port, so a misrouted or unparsable target is invisible in the logs; a debug line per fallback explains which guard fired. Signed-off-by: nilig <nili.ifergan@gmail.com> * docs(sidecar): restructure the DP section into plain-language bullets One paragraph carried seven chained clauses; a lead sentence with one fact per bullet reads without unpacking. Signed-off-by: nilig <nili.ifergan@gmail.com> --------- Signed-off-by: nilig <nili.ifergan@gmail.com>
1 parent b25dd66 commit f56f3bd

7 files changed

Lines changed: 165 additions & 16 deletions

File tree

docs/disaggregation.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,28 @@ Both prefill and decode pods require the following `--kv-transfer-config`:
580580

581581
`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)).
582582

583-
**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`.
584605

585606
### General Sidecar Flags
586607

@@ -597,7 +618,7 @@ Both prefill and decode pods require the following `--kv-transfer-config`:
597618
|---|---|---|---|---|
598619
| `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`. |
599620
| `sglang` | — | `SGLANG_BOOTSTRAP_PORT` | `8998` | Port used for the SGLang bootstrap endpoint on prefill pods. |
600-
| `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. |
601622
| `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. |
602623

603624
---

pkg/sidecar/proxy/connector_p2p.go

Lines changed: 46 additions & 4 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.
@@ -98,7 +102,7 @@ func (s *Server) handleP2P(w http.ResponseWriter, r *http.Request, prefillPodHos
98102
requestFieldRemotePrefiller: map[string]any{
99103
requestFieldKVRequestID: kvRequestID,
100104
requestFieldRemoteHost: extractHost(prefillPodHostPort),
101-
requestFieldRemotePort: s.config.P2PConnectorPort,
105+
requestFieldRemotePort: prefillP2PPort,
102106
},
103107
}
104108

@@ -240,10 +244,48 @@ func (s *Server) p2pSourceParams(sourceHostPort string) map[string]any {
240244
return map[string]any{
241245
requestFieldKVRequestID: newUUID(),
242246
requestFieldRemoteHost: extractHost(sourceHostPort),
243-
requestFieldRemotePort: s.config.P2PConnectorPort,
247+
requestFieldRemotePort: s.p2pPortFor(sourceHostPort),
244248
}
245249
}
246250

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+
247289
// decodeWithP2PSource serves a decoder-only request through the local vLLM
248290
// with kv_transfer_params.remote_kv_source injected, so the engine looks up and pulls
249291
// cached prefix blocks from the peer at sourceHostPort instead of recomputing
@@ -272,7 +314,7 @@ func (s *Server) decodeWithP2PSource(w http.ResponseWriter, r *http.Request, sou
272314
s.logger.Info("running P2P source protocol",
273315
"source_host", extractHost(sourceHostPort),
274316
"kv_request_id", p2pParams[requestFieldKVRequestID],
275-
"p2p_connector_port", s.config.P2PConnectorPort)
317+
"p2p_connector_port", p2pParams[requestFieldRemotePort])
276318

277319
newBody, err := json.Marshal(requestData)
278320
if err != nil {

pkg/sidecar/proxy/connector_p2p_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,55 @@ var _ = DescribeTable("p2pPullAvailable",
147147
Entry("the flag has no effect on sglang", KVConnectorSGLang, true, false),
148148
Entry("the flag has no effect on shared-storage", KVConnectorSharedStorage, true, false),
149149
)
150+
151+
var _ = DescribeTable("p2pPortFor",
152+
func(dpSize, dpBasePort int, target string, want int) {
153+
s := &Server{
154+
dpBasePort: dpBasePort,
155+
config: Config{P2PConnectorPort: 7777, DataParallelSize: dpSize},
156+
}
157+
Expect(s.p2pPortFor(target)).To(Equal(want))
158+
},
159+
Entry("single DP uses the base port regardless of target", 1, 8000, "10.0.0.5:8003", 7777),
160+
Entry("rank 0 target uses the base port", 4, 8000, "10.0.0.5:8000", 7777),
161+
Entry("rank 2 target offsets by 2", 4, 8000, "10.0.0.5:8002", 7779),
162+
Entry("last rank target offsets by dpSize-1", 4, 8000, "10.0.0.5:8003", 7780),
163+
Entry("port below the base falls back to the base port", 4, 8000, "10.0.0.5:7999", 7777),
164+
Entry("port beyond the rank range falls back to the base port", 4, 8000, "10.0.0.5:8004", 7777),
165+
Entry("target without a port falls back to the base port", 4, 8000, "10.0.0.5", 7777),
166+
Entry("unparsable port falls back to the base port", 4, 8000, "10.0.0.5:http", 7777),
167+
Entry("zero base port disables derivation", 4, 0, "10.0.0.5:8002", 7777),
168+
Entry("scheme-prefixed target derives the rank", 4, 8000, "http://10.0.0.5:8002", 7779),
169+
)
170+
171+
var _ = Describe("p2pSourceParams", func() {
172+
It("offsets remote_port by the source endpoint's DP rank", func() {
173+
s := &Server{
174+
dpBasePort: 8000,
175+
config: Config{P2PConnectorPort: 7777, DataParallelSize: 4},
176+
}
177+
params := s.p2pSourceParams("10.0.0.9:8002")
178+
Expect(params[requestFieldRemoteHost]).To(Equal("10.0.0.9"))
179+
Expect(params[requestFieldRemotePort]).To(Equal(7779))
180+
Expect(params[requestFieldKVRequestID]).ToNot(BeEmpty())
181+
})
182+
183+
It("keeps rank derivation on Clone, which rank servers rely on", func() {
184+
s := &Server{
185+
dpBasePort: 8000,
186+
config: Config{P2PConnectorPort: 7777, DataParallelSize: 4},
187+
}
188+
params := s.Clone().p2pSourceParams("10.0.0.9:8002")
189+
Expect(params[requestFieldRemotePort]).To(Equal(7779))
190+
})
191+
192+
It("derives both host and port from a scheme-prefixed source", func() {
193+
s := &Server{
194+
dpBasePort: 8000,
195+
config: Config{P2PConnectorPort: 7777, DataParallelSize: 4},
196+
}
197+
params := s.p2pSourceParams("http://10.0.0.9:8002")
198+
Expect(params[requestFieldRemoteHost]).To(Equal("10.0.0.9"))
199+
Expect(params[requestFieldRemotePort]).To(Equal(7779))
200+
})
201+
})

pkg/sidecar/proxy/options.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ func (opts *Options) AddFlags(fs *pflag.FlagSet) {
252252
fs.IntVar(&opts.MooncakeBootstrapPort, mooncakeBootstrapPortFlag, opts.MooncakeBootstrapPort,
253253
"the port used to query the Mooncake bootstrap endpoint on prefill pods (only used with --kv-connector=mooncake)")
254254
fs.IntVar(&opts.P2PConnectorPort, p2pConnectorPortFlag, opts.P2PConnectorPort,
255-
"the prefiller's OffloadingConnector P2P tier listening port, injected as remote_port on the decode leg (used with --kv-connector=offloading or --enable-p2p-pull)")
255+
"the prefiller's OffloadingConnector P2P tier listening port, injected as remote_port on the decode leg; with --data-parallel-size > 1 this is the rank-0 port and rank r uses port+r (used with --kv-connector=offloading or --enable-p2p-pull)")
256256
fs.BoolVar(&opts.EnableP2PPull, enableP2PPull, opts.EnableP2PPull,
257257
"declare the OffloadingConnector P2P tier available for cached-prefix pulls when the PD connector is NIXL, i.e. engines run MultiConnector(NixlConnector + OffloadingConnector). Rejected with any other --kv-connector; offloading provides the tier natively without this flag.")
258258
fs.BoolVar(&opts.SecureServing, secureServing, opts.SecureServing, "Enables secure proxy. Defaults to true.")
@@ -532,11 +532,11 @@ func (opts *Options) Validate() error {
532532
if opts.P2PConnectorPort < 1 || opts.P2PConnectorPort > 65535 {
533533
return fmt.Errorf("--p2p-connector-port must be between 1 and 65535, got %d", opts.P2PConnectorPort)
534534
}
535-
536-
// offloading does not support wide-EP: every DP rank would bind the same
537-
// POD_IP:<p2p-connector-port>. DP-aware support is not yet implemented.
538-
if opts.KVConnector == KVConnectorOffloading && opts.DataParallelSize > 1 {
539-
return fmt.Errorf("--kv-connector=offloading does not support --data-parallel-size > 1 (got %d)", opts.DataParallelSize)
535+
// The injected port is offset by the target's DP rank, so the highest
536+
// rank's port must stay in range too.
537+
if opts.DataParallelSize > 1 && opts.P2PConnectorPort+opts.DataParallelSize-1 > 65535 {
538+
return fmt.Errorf("--p2p-connector-port %d plus data-parallel rank %d exceeds 65535",
539+
opts.P2PConnectorPort, opts.DataParallelSize-1)
540540
}
541541

542542
// --enable-p2p-pull composes the OffloadingConnector P2P tier alongside NIXL

pkg/sidecar/proxy/options_test.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -626,12 +626,12 @@ func TestP2PConnectorPort(t *testing.T) {
626626
}
627627

628628
func TestValidateOffloadingDP(t *testing.T) {
629-
t.Run("rejects offloading with data-parallel-size > 1", func(t *testing.T) {
629+
t.Run("allows offloading with data-parallel-size > 1", func(t *testing.T) {
630630
opts := NewOptions()
631631
opts.KVConnector = KVConnectorOffloading
632632
opts.DataParallelSize = 2
633633
require.NoError(t, opts.Complete())
634-
require.ErrorContains(t, opts.Validate(), "--kv-connector=offloading does not support --data-parallel-size > 1")
634+
require.NoError(t, opts.Validate())
635635
})
636636

637637
t.Run("allows offloading with data-parallel-size 1", func(t *testing.T) {
@@ -641,6 +641,24 @@ func TestValidateOffloadingDP(t *testing.T) {
641641
require.NoError(t, opts.Complete())
642642
require.NoError(t, opts.Validate())
643643
})
644+
645+
t.Run("rejects a rank port beyond 65535", func(t *testing.T) {
646+
opts := NewOptions()
647+
opts.KVConnector = KVConnectorOffloading
648+
opts.DataParallelSize = 4
649+
opts.P2PConnectorPort = 65533
650+
require.NoError(t, opts.Complete())
651+
require.ErrorContains(t, opts.Validate(), "exceeds 65535")
652+
})
653+
654+
t.Run("allows the highest rank port at 65535", func(t *testing.T) {
655+
opts := NewOptions()
656+
opts.KVConnector = KVConnectorOffloading
657+
opts.DataParallelSize = 4
658+
opts.P2PConnectorPort = 65532
659+
require.NoError(t, opts.Complete())
660+
require.NoError(t, opts.Validate())
661+
})
644662
}
645663

646664
func TestValidateEnableP2PPull(t *testing.T) {

pkg/sidecar/proxy/proxy.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"net/http"
2727
"net/http/httputil"
2828
"net/url"
29+
"strconv"
2930
"strings"
3031
"time"
3132

@@ -210,6 +211,8 @@ type Config struct {
210211

211212
// P2PConnectorPort is the prefiller's OffloadingConnector P2P tier listening port,
212213
// injected as remote_port on the decode leg so the decoder can pull KV from it.
214+
// With data parallelism it is the rank-0 port: rank r's tier listens on
215+
// P2PConnectorPort+r and the injected port is offset by the target's rank.
213216
// Meaningful with --kv-connector=offloading or --enable-p2p-pull.
214217
P2PConnectorPort int
215218

@@ -332,6 +335,11 @@ type Server struct {
332335

333336
prefillSamplerFn func(n int) int // allow test override
334337

338+
// dpBasePort is the rank-0 proxy port. Rank clones override config.Port
339+
// (data_parallel.go), so rank derivation from a routed endpoint's port
340+
// needs the pre-clone base. 0 disables derivation.
341+
dpBasePort int
342+
335343
config Config
336344
}
337345

@@ -353,6 +361,9 @@ func NewProxy(config Config) *Server {
353361
forwardDataParallel: true,
354362
prefillSamplerFn: rand.IntN,
355363
}
364+
if basePort, err := strconv.Atoi(config.Port); err == nil {
365+
server.dpBasePort = basePort
366+
}
356367

357368
server.setKVConnector()
358369
if config.UseTLSForPrefiller {
@@ -421,6 +432,7 @@ func (s *Server) Clone() *Server {
421432
dataParallelProxies: s.dataParallelProxies,
422433
forwardDataParallel: s.forwardDataParallel,
423434
prefillSamplerFn: s.prefillSamplerFn,
435+
dpBasePort: s.dpBasePort,
424436
config: s.config,
425437
}
426438
}

pkg/sidecar/proxy/proxy_helpers.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"net/http"
1313
"net/http/httputil"
1414
"net/url"
15+
"strings"
1516
"syscall"
1617
"time"
1718

@@ -196,9 +197,12 @@ func cloneRequestWithBody(ctx context.Context, r *http.Request, body []byte) *ht
196197
return cloned
197198
}
198199

199-
// extractHost returns the host part of a host:port string. If parsing
200-
// fails (e.g. no port), the input is returned as-is.
200+
// extractHost returns the host part of a host:port string. A `http://`
201+
// prefix is trimmed first, matching createProxyHandler's tolerance for
202+
// routed endpoint values. If parsing fails (e.g. no port), the input is
203+
// returned as-is.
201204
func extractHost(hostWithPort string) string {
205+
hostWithPort, _ = strings.CutPrefix(hostWithPort, "http://")
202206
host, _, err := net.SplitHostPort(hostWithPort)
203207
if err != nil {
204208
return hostWithPort

0 commit comments

Comments
 (0)