Skip to content

Commit 26dffe1

Browse files
Merge branch 'main' into fix/per-prompt-mm-features
2 parents fbec75c + 31be30a commit 26dffe1

12 files changed

Lines changed: 463 additions & 19 deletions

File tree

config/charts/routerlib/templates/_deployment.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ spec:
2525
labels:
2626
{{- include "llm-d-router.selectorLabels" . | nindent 8 }}
2727
{{- include "llm-d-router.modeLabels" . | nindent 8 }}
28+
{{- with .Values.router.epp.podAnnotations }}
29+
annotations:
30+
{{- toYaml . | nindent 8 }}
31+
{{- end }}
2832
spec:
2933
{{- $proxy := include "llm-d-router.proxy" . | fromYaml | default dict }}
3034
{{- $proxyType := include "llm-d-router.proxyType" . | trim }}

config/charts/routerlib/values.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ epp:
6767
affinity: {}
6868
tolerations: []
6969

70+
# Annotations added to the EPP pod template. Useful for clusters that rely on
71+
# annotation-based Prometheus scraping (prometheus.io/{scrape,port,path}) or
72+
# Istio metrics-merging, rather than a ServiceMonitor or GMP.
73+
podAnnotations: {}
74+
7075
# By default no proxy is configured
7176
proxy:
7277
enabled: false
@@ -123,7 +128,8 @@ monitoring:
123128
prometheus:
124129
enabled: false
125130
auth:
126-
# To allow unauthenticated /metrics access (e.g., for debugging with curl), set to false
131+
# Set to false to allow unauthenticated /metrics access. Required for annotation-based
132+
# Prometheus scraping and Istio metrics-merging, which do not send Bearer tokens.
127133
enabled: true
128134
# Service account token secret for authentication
129135
secretName: inference-gateway-sa-metrics-reader-secret

docs/disaggregation.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,27 @@ Specifies which KV transfer protocol the sidecar uses to coordinate prefill/deco
529529
| `shared-storage` | `SharedStorageConnector` | KV transfer via shared filesystem |
530530
| `sglang` | — | SGLang disaggregation protocol |
531531
| `mooncake` | `MooncakeConnector` | [Mooncake](https://github.com/kvcache-ai/Mooncake) KV transfer using RDMA |
532+
| `offloading` | `OffloadingConnector` | KV transfer over the vLLM CPU offloading tier. The decoder pulls KV from the prefiller via the `p2p` secondary tier. |
533+
534+
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`.
535+
536+
Both prefill and decode pods require the following `--kv-transfer-config`:
537+
538+
```json
539+
{
540+
"kv_connector": "OffloadingConnector",
541+
"kv_role": "kv_both",
542+
"kv_connector_extra_config": {
543+
"spec_name": "TieringOffloadingSpec",
544+
"cpu_bytes_to_use": <bytes>,
545+
"secondary_tiers": [{"type": "p2p", "host": "<POD_IP>", "port": <p2p-connector-port>}]
546+
}
547+
}
548+
```
549+
550+
`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)).
551+
552+
**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.
532553

533554
### General Sidecar Flags
534555

@@ -545,6 +566,7 @@ Specifies which KV transfer protocol the sidecar uses to coordinate prefill/deco
545566
|---|---|---|---|---|
546567
| `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`. |
547568
| `sglang` | — | `SGLANG_BOOTSTRAP_PORT` | `8998` | Port used for the SGLang bootstrap endpoint on prefill pods. |
569+
| `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. |
548570

549571
---
550572

@@ -555,3 +577,4 @@ Specifies which KV transfer protocol the sidecar uses to coordinate prefill/deco
555577
- vLLM: [[RFC]: Prototype Separating Vision Encoder to Its Own Worker](https://github.com/vllm-project/vllm/issues/20799)
556578
- vLLM: [Encoder Disaggregation for Scalable Multimodal Model Serving](https://vllm.ai/blog/vllm-epd)
557579
- Mooncake: [MooncakeConnector Usage Guide](https://github.com/vllm-project/vllm/blob/main/docs/features/mooncake_connector_usage.md)
580+
- vLLM: [OffloadingConnector / P2P secondary tier (PR #42285)](https://github.com/vllm-project/vllm/pull/42285)

pkg/sidecar/constants/constants.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ const (
2929
// KVConnectorMooncake enables mooncake the P/D KV disaggregation protocol
3030
KVConnectorMooncake = "mooncake"
3131

32+
// KVConnectorOffloading enables the OffloadingConnector P/D KV disaggregation protocol
33+
KVConnectorOffloading = "offloading"
34+
3235
// ECExampleConnector enables the Encoder disaggregation protocol (E/PD, E/P/D)
3336
ECExampleConnector = "ec-example"
3437

pkg/sidecar/proxy/connector_mooncake_test.go

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,7 @@ var _ = Describe("Mooncake Connector", func() {
6363
It("should send concurrent requests with correct mooncake kv_transfer_params", func() {
6464
proxyBaseAddr := testInfo.startProxy()
6565

66-
body := `{
67-
"model": "Qwen/Qwen2-0.5B",
68-
"messages": [
69-
{"role": "user", "content": "Hello"}
70-
],
71-
"max_tokens": 50,
72-
"max_completion_tokens": 100
73-
}`
66+
body := chatCompletionsRequestBodyWithMaxCompletionTokens
7467
req, err := http.NewRequest(http.MethodPost, proxyBaseAddr+ChatCompletionsPath, bytes.NewReader([]byte(body)))
7568
Expect(err).ToNot(HaveOccurred())
7669

pkg/sidecar/proxy/connector_p2p.go

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/*
2+
Copyright 2026 The llm-d Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package proxy
18+
19+
import (
20+
"context"
21+
"encoding/json"
22+
"fmt"
23+
"io"
24+
"net/http"
25+
"time"
26+
27+
"go.opentelemetry.io/otel/attribute"
28+
"go.opentelemetry.io/otel/codes"
29+
"go.opentelemetry.io/otel/trace"
30+
31+
logging "github.com/llm-d/llm-d-router/pkg/common/observability/logging"
32+
"github.com/llm-d/llm-d-router/pkg/common/observability/tracing"
33+
)
34+
35+
// handleP2P implements the vLLM OffloadingConnector P2P orchestration contract. The
36+
// prefiller stores KV under a kv_request_id with no peer address; the decoder
37+
// pulls it using the prefiller's OffloadingConnector P2P tier host/port. Both legs are
38+
// dispatched concurrently: the connector parks any KV blocks stored before the
39+
// decoder's fetch binds the session, so ordering between the legs is safe.
40+
func (s *Server) handleP2P(w http.ResponseWriter, r *http.Request, prefillPodHostPort string) {
41+
body, err := io.ReadAll(r.Body)
42+
if err != nil {
43+
if err := errorJSONInvalid(fmt.Errorf("failed to read request body: %w", err), w); err != nil {
44+
s.logger.Error(err, "failed to send error response to client")
45+
}
46+
return
47+
}
48+
49+
var requestData map[string]any
50+
if err := json.Unmarshal(body, &requestData); err != nil {
51+
if err := errorJSONInvalid(err, w); err != nil {
52+
s.logger.Error(err, "failed to send error response to client")
53+
}
54+
return
55+
}
56+
57+
kvRequestID := newUUID()
58+
s.logger.Info("running P2P protocol",
59+
"prefill_host", extractHost(prefillPodHostPort),
60+
"kv_request_id", kvRequestID,
61+
"p2p_connector_port", s.config.P2PConnectorPort)
62+
63+
// Prefill leg: store KV under kv_request_id, no peer address. Capped to a
64+
// single output token so the prefiller returns as soon as KV is stored.
65+
prefillData := make(map[string]any, len(requestData)+1)
66+
for k, v := range requestData {
67+
prefillData[k] = v
68+
}
69+
prefillData[requestFieldKVTransferParams] = map[string]any{
70+
requestFieldP2PDecodeParams: map[string]any{
71+
requestFieldKVRequestID: kvRequestID,
72+
},
73+
}
74+
prefillData[requestFieldStream] = false
75+
delete(prefillData, requestFieldStreamOptions)
76+
prefillData[requestFieldMaxTokens] = 1
77+
if _, ok := prefillData[requestFieldMaxCompletionTokens]; ok {
78+
prefillData[requestFieldMaxCompletionTokens] = 1
79+
}
80+
81+
prefillBody, err := json.Marshal(prefillData)
82+
if err != nil {
83+
if err := errorJSONInvalid(err, w); err != nil {
84+
s.logger.Error(err, "failed to send error response to client")
85+
}
86+
return
87+
}
88+
if v := s.logger.V(logging.TRACE); v.Enabled() {
89+
v.Info("prefill request body", "body", string(prefillBody))
90+
}
91+
92+
// Decode leg: pull KV from the prefiller's OffloadingConnector P2P tier. Original body
93+
// (streaming, token limits) is preserved.
94+
decodeData := make(map[string]any, len(requestData)+1)
95+
for k, v := range requestData {
96+
decodeData[k] = v
97+
}
98+
decodeData[requestFieldKVTransferParams] = map[string]any{
99+
requestFieldP2PPrefillParams: map[string]any{
100+
requestFieldKVRequestID: kvRequestID,
101+
requestFieldRemoteHost: extractHost(prefillPodHostPort),
102+
requestFieldRemotePort: s.config.P2PConnectorPort,
103+
},
104+
}
105+
106+
decodeBody, err := json.Marshal(decodeData)
107+
if err != nil {
108+
if err := errorJSONInvalid(err, w); err != nil {
109+
s.logger.Error(err, "failed to send error response to client")
110+
}
111+
return
112+
}
113+
if v := s.logger.V(logging.TRACE); v.Enabled() {
114+
v.Info("decode request body", "body", string(decodeBody))
115+
}
116+
117+
s.handleP2PConcurrentRequests(w, r, prefillBody, decodeBody, prefillPodHostPort)
118+
}
119+
120+
func (s *Server) handleP2PConcurrentRequests(w http.ResponseWriter, r *http.Request, prefillBody, decodeBody []byte, prefillHost string) {
121+
tracer := tracing.Tracer(tracerScope)
122+
ctx := r.Context()
123+
124+
// WithoutCancel for prefill so it isn't aborted when the decode response finishes first.
125+
prefillReq := cloneRequestWithBody(context.WithoutCancel(ctx), r, prefillBody)
126+
decodeReq := cloneRequestWithBody(ctx, r, decodeBody)
127+
128+
// Prefill runs in a goroutine: only stores KV, response is discarded.
129+
// Decode runs on the main thread: writes the actual response back via w.
130+
ctx, prefillSpan := tracer.Start(ctx, "llm_d.pd_proxy.prefill",
131+
trace.WithSpanKind(trace.SpanKindInternal),
132+
)
133+
prefillSpan.SetAttributes(
134+
attribute.String("llm_d.pd_proxy.prefill_target", prefillHost),
135+
attribute.String("llm_d.pd_proxy.connector", KVConnectorOffloading),
136+
attribute.Bool("llm_d.pd_proxy.prefill.async", true),
137+
)
138+
prefillStart := time.Now()
139+
140+
prefillHandler, err := s.prefillerProxyHandler(prefillHost)
141+
if err != nil {
142+
prefillSpan.SetStatus(codes.Error, "failed to create prefill handler")
143+
prefillSpan.End()
144+
if err := errorBadGateway(err, w); err != nil {
145+
s.logger.Error(err, "failed to send error response to client")
146+
}
147+
return
148+
}
149+
150+
go func() {
151+
defer prefillSpan.End()
152+
defer func() {
153+
if rec := recover(); rec != nil && rec != http.ErrAbortHandler {
154+
s.logger.Error(fmt.Errorf("panic: %v", rec), "panic in prefill request")
155+
}
156+
}()
157+
pw := &bufferedResponseWriter{}
158+
prefillHandler.ServeHTTP(pw, prefillReq)
159+
prefillDuration := time.Since(prefillStart)
160+
prefillSpan.SetAttributes(
161+
attribute.Int("llm_d.pd_proxy.prefill.status_code", pw.statusCode),
162+
attribute.Float64("llm_d.pd_proxy.prefill.duration_ms", float64(prefillDuration.Milliseconds())),
163+
)
164+
if isHTTPError(pw.statusCode) {
165+
prefillSpan.SetStatus(codes.Error, "prefill request failed")
166+
}
167+
s.logger.V(logging.DEBUG).Info("p2p prefill request completed", "status", pw.statusCode)
168+
}()
169+
170+
// Decode Stage
171+
ctx, decodeSpan := tracer.Start(ctx, "llm_d.pd_proxy.decode",
172+
trace.WithSpanKind(trace.SpanKindInternal),
173+
)
174+
defer decodeSpan.End()
175+
176+
decodeSpan.SetAttributes(
177+
attribute.String("llm_d.pd_proxy.connector", KVConnectorOffloading),
178+
attribute.Bool("llm_d.pd_proxy.decode.concurrent_with_prefill", true),
179+
)
180+
decodeStart := time.Now()
181+
182+
decodeReq = decodeReq.WithContext(ctx)
183+
s.decoderProxy.ServeHTTP(w, decodeReq)
184+
185+
decodeDuration := time.Since(decodeStart)
186+
decodeSpan.SetAttributes(
187+
attribute.Float64("llm_d.pd_proxy.decode.duration_ms", float64(decodeDuration.Milliseconds())),
188+
attribute.String("llm_d.pd_proxy.decode.target", s.config.DecoderURL.Host),
189+
)
190+
191+
// End-to-end P/D timing. True TTFT captures time from gateway request start
192+
// to decode start; prefill duration is tracked in the async prefill span.
193+
if currentSpan := trace.SpanFromContext(ctx); currentSpan.SpanContext().IsValid() {
194+
var totalDuration time.Duration
195+
var trueTTFT time.Duration
196+
if requestStartValue := ctx.Value(requestStartTimeKey); requestStartValue != nil {
197+
if requestStart, ok := requestStartValue.(time.Time); ok {
198+
totalDuration = time.Since(requestStart)
199+
trueTTFT = decodeStart.Sub(requestStart)
200+
}
201+
}
202+
203+
currentSpan.SetAttributes(
204+
attribute.Float64("llm_d.pd_proxy.total_duration_ms", float64(totalDuration.Milliseconds())),
205+
attribute.Float64("llm_d.pd_proxy.true_ttft_ms", float64(trueTTFT.Milliseconds())),
206+
attribute.Float64("llm_d.pd_proxy.decode_duration_ms", float64(decodeDuration.Milliseconds())),
207+
attribute.Bool("llm_d.pd_proxy.concurrent_pd", true),
208+
)
209+
}
210+
}

0 commit comments

Comments
 (0)