[Feature][EPD] Support dynamic EPD (EC-connector) proxy (#54176)

Signed-off-by: Tianyu Guo <guoty@inferact.ai> Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: OpenAI Codex <noreply@openai.com>

Tianyu Guo committed Sep 20, 2026 at 12:24 UTC 70fc7bd695e6c9161ec7f3ba67b73e243189d571
8 files changed +1169 -73
examples/disaggregated/disaggregated_encoder/README.md
+77 -1
@@ -106,6 +106,82 @@ If you enable prefill instance (`--prefill-servers-urls` not disabled), you will
106 | `--decode-servers-urls` | Comma-separated list of decode endpoints. Non-stream and stream paths both round-robin over this list. |
107 | `--host`, `--port` | Bind address for the proxy itself (defaults: `0.0.0.0:8000`). |
108
109 +### Dynamic registration
110 +
111 +Alternatively, let the external launcher register ready instances over HTTP.
112 +No vLLM configuration changes or worker registration threads are required.
113 +Set `ADMIN_API_KEY` on the proxy and supply it as `X-API-Key` for registration
114 +and removal. These are trusted control-plane APIs; do not expose them publicly.
115 +
116 +```bash
117 +export ADMIN_API_KEY="your-admin-key"
118 +python disagg_epd_proxy.py --port 8000 --dynamic-registration
119 +```
120 +
121 +Omit the static server URL flags and register every stage. The roles determine
122 +the topology: `encode` + `prefill_decode` gives E+PD; `encode` + `prefill` +
123 +`decode` gives E+P+D. Standalone `decode` always requires an available `prefill`,
124 +even when D registers first or all P instances go offline; otherwise requests
125 +return `503`. The proxy rejects mixing combined PD with standalone P/D,
126 +including unhealthy instances still in the registry. Explicitly remove the
127 +old topology's P/D or PD registrations before switching topologies.
128 +
129 +After each instance is ready, the launcher registers its reachable HTTP URL:
130 +
131 +```bash
132 +curl --fail-with-body http://proxy-host:8000/instances \
133 + -H "X-API-Key: $ADMIN_API_KEY" -H 'Content-Type: application/json' \
134 + -d '{"role":"encode","url":"http://e-host:8001"}'
135 +curl --fail-with-body http://proxy-host:8000/instances \
136 + -H "X-API-Key: $ADMIN_API_KEY" -H 'Content-Type: application/json' \
137 + -d '{"role":"prefill_decode","url":"http://pd-host:8002"}'
138 +```
139 +
140 +For E+P+D, register P with `role: "prefill"` and D with `role: "decode"`.
141 +Example and NIXL EC connectors need no additional registration fields.
142 +For Mooncake, register `ec_zmq_addrs` on the **EC consumer** (`prefill_decode`
143 +or `prefill`), using its configured `ec_ip` and `ec_port`. Keep the fixed-port
144 +layout: supply the TP-rank-0 address for each DP replica, in DP-rank order;
145 +replica `r` uses `ec_port + r * tensor_parallel_size`. The connector discovers
146 +the remaining TP ranks itself. For example, DP=2, TP=2, `ec_port=19019`:
147 +
148 +```json
149 +{
150 + "role": "prefill_decode",
151 + "url": "http://pd-host:8002",
152 + "dp_size": 2,
153 + "ec_zmq_addrs": ["tcp://pd-host:19019", "tcp://pd-host:19021"]
154 +}
155 +```
156 +
157 +The proxy selects one consumer replica and uses it for both the encoder push
158 +and the consumer HTTP request. Standalone D does not need EC control addresses.
159 +Port allocation and avoiding collisions remain the launcher's responsibility.
160 +
161 +Inspect or remove instances without restarting the proxy:
162 +
163 +```bash
164 +curl http://proxy-host:8000/instances
165 +curl --fail-with-body -X DELETE \
166 + 'http://proxy-host:8000/instances?url=http://e-host:8001' \
167 + -H "X-API-Key: $ADMIN_API_KEY"
168 +```
169 +
170 +Registration is idempotent. The proxy probes registered instances every
171 +`--probe-interval` seconds (default 5), with `--probe-timeout` seconds per probe
172 +(default 2). After `--fail-threshold` consecutive failures (default 3), it stops
173 +sending new requests to that instance. Healthy instances rejoin automatically;
174 +unreachable ones are forgotten after `--evicted-ttl` seconds (default 900;
175 +`0` retains them indefinitely). Removal stops new routing; already routed
176 +requests retain their selected endpoints, so drain requests before stopping
177 +the instance. The launcher must re-register instances after a proxy restart.
178 +
179 +The example launch scripts also support this flow: export `ADMIN_API_KEY` and
180 +set `DYNAMIC_REGISTRATION=1` when running `disagg_1e1pd_example.sh` or
181 +`disagg_1e1p1d_example.sh`. Their default remains static routing.
182 +
183 +### Static configuration
184 +
185 The proxy batches images from the same user request assigned to the same encoder.
186 Set the proxy environment variable `ENCODER_MAX_BATCH_SIZE` to limit the number
187 of images per encoder subrequest. It defaults to `0` (unlimited); `1` sends each
@@ -137,6 +213,6 @@ For E + P + D setup:
213 ```bash
214 $ python disagg_encoder_proxy.py \
215 --encode-servers-urls "http://e1:8001,http://e2:8001" \
140 - --prefill-servers-urls "http://p1:8003,http://p2:8004" \
216 + --prefill-servers-urls "http://p1:8003,http://p2:8004" \
217 --decode-servers-urls "http://d1:8005,http://d2:8006"
218 ```
examples/disaggregated/disaggregated_encoder/disagg_1e1p1d_example.sh
+21 -4
@@ -189,17 +189,34 @@ wait_for_server "$DECODE_PORT"
189 ###############################################################################
190 # Proxy
191 ###############################################################################
192 +PROXY_ARGS=()
193 +if [[ "${DYNAMIC_REGISTRATION:-0}" == "1" ]]; then
194 + : "${ADMIN_API_KEY:?Set ADMIN_API_KEY for dynamic registration}"
195 + export ADMIN_API_KEY
196 + PROXY_ARGS=(--dynamic-registration)
197 +else
198 + PROXY_ARGS=(
199 + --encode-servers-urls "http://localhost:$ENCODE_PORT"
200 + --prefill-servers-urls "http://localhost:$PREFILL_PORT"
201 + --decode-servers-urls "http://localhost:$DECODE_PORT"
202 + )
203 +fi
204 python disagg_epd_proxy.py \
205 --host "0.0.0.0" \
206 --port "$PROXY_PORT" \
195 - --encode-servers-urls "http://localhost:$ENCODE_PORT" \
196 - --prefill-servers-urls "http://localhost:$PREFILL_PORT" \
197 - --decode-servers-urls "http://localhost:$DECODE_PORT" \
207 + "${PROXY_ARGS[@]}" \
208 >"${PROXY_LOG}" 2>&1 &
209
210 PIDS+=($!)
211
212 wait_for_server "$PROXY_PORT"
213 +if [[ "${DYNAMIC_REGISTRATION:-0}" == "1" ]]; then
214 + for endpoint in "encode:$ENCODE_PORT" "prefill:$PREFILL_PORT" "decode:$DECODE_PORT"; do
215 + curl --fail-with-body "http://127.0.0.1:$PROXY_PORT/instances" \
216 + -H "X-API-Key: $ADMIN_API_KEY" -H 'Content-Type: application/json' \
217 + -d "{\"role\":\"${endpoint%%:*}\",\"url\":\"http://127.0.0.1:${endpoint#*:}\"}"
218 + done
219 +fi
220 echo "All services are up!"
221
222 ###############################################################################
@@ -238,4 +255,4 @@ curl http://127.0.0.1:"${PROXY_PORT}"/v1/chat/completions \
255
256 # cleanup
257 echo "cleanup..."
241 -cleanup
\ No newline at end of file
258 +cleanup
examples/disaggregated/disaggregated_encoder/disagg_1e1pd_example.sh
+20 -4
@@ -152,17 +152,33 @@ wait_for_server "$PREFILL_DECODE_PORT"
152 ###############################################################################
153 # Proxy
154 ###############################################################################
155 +PROXY_ARGS=()
156 +if [[ "${DYNAMIC_REGISTRATION:-0}" == "1" ]]; then
157 + : "${ADMIN_API_KEY:?Set ADMIN_API_KEY for dynamic registration}"
158 + export ADMIN_API_KEY
159 + PROXY_ARGS=(--dynamic-registration)
160 +else
161 + PROXY_ARGS=(
162 + --encode-servers-urls "http://localhost:$ENCODE_PORT"
163 + --decode-servers-urls "http://localhost:$PREFILL_DECODE_PORT"
164 + )
165 +fi
166 python disagg_epd_proxy.py \
167 --host "0.0.0.0" \
168 --port "$PROXY_PORT" \
158 - --encode-servers-urls "http://localhost:$ENCODE_PORT" \
159 - --prefill-servers-urls "disable" \
160 - --decode-servers-urls "http://localhost:$PREFILL_DECODE_PORT" \
169 + "${PROXY_ARGS[@]}" \
170 >"${PROXY_LOG}" 2>&1 &
171
172 PIDS+=($!)
173
174 wait_for_server "$PROXY_PORT"
175 +if [[ "${DYNAMIC_REGISTRATION:-0}" == "1" ]]; then
176 + for endpoint in "encode:$ENCODE_PORT" "prefill_decode:$PREFILL_DECODE_PORT"; do
177 + curl --fail-with-body "http://127.0.0.1:$PROXY_PORT/instances" \
178 + -H "X-API-Key: $ADMIN_API_KEY" -H 'Content-Type: application/json' \
179 + -d "{\"role\":\"${endpoint%%:*}\",\"url\":\"http://127.0.0.1:${endpoint#*:}\"}"
180 + done
181 +fi
182 echo "All services are up!"
183
184 ###############################################################################
@@ -201,4 +217,4 @@ curl http://127.0.0.1:"${PROXY_PORT}"/v1/chat/completions \
217
218 # cleanup
219 echo "cleanup..."
204 -cleanup
\ No newline at end of file
220 +cleanup
examples/disaggregated/disaggregated_encoder/disagg_epd_proxy.py
+571 -60
@@ -1,12 +1,13 @@
1 #!/usr/bin/env python3
2 # SPDX-License-Identifier: Apache-2.0
3 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
4 -"""disagg_encoder_proxy.py
4 +"""OpenAI-compatible proxy for E+PD and E+P+D disaggregation.
5
6 -Proxy that routes OpenAI-compatible “/v1/chat/completions” requests to two
7 -clusters:
8 - • encode (multimodal feature extraction)
9 - • decode (language-model inference)
6 +Use static server URLs, or --dynamic-registration for launcher-managed HTTP
7 +registration of E, P/PD and D instances. Both modes route OpenAI-compatible
8 +``/v1/chat/completions`` requests through encoder and inference clusters and
9 +share encoder fan-out, metadata-only rewriting, prefill, and retry/streaming
10 +forwarding.
11
12 For MM input we:
13 1. Extract *every* image/audio/video item.
@@ -20,22 +21,27 @@ from __future__ import annotations
21
22 import argparse
23 import asyncio
24 +import enum
25 import hashlib
26 import itertools
27 import json
28 import logging
29 import os
30 import random
31 +import secrets
32 import time
33 import uuid
34 from collections.abc import AsyncIterator
35 +from contextlib import asynccontextmanager, suppress
36 +from dataclasses import dataclass, field
37 from typing import Any
38
39 import aiohttp
40 import msgspec
41 import uvicorn
37 -from fastapi import FastAPI, HTTPException, Request
42 +from fastapi import Depends, FastAPI, Header, HTTPException, Request
43 from fastapi.responses import JSONResponse, Response, StreamingResponse
44 +from pydantic import AnyHttpUrl, BaseModel, Field, model_validator
45
46 ###############################################################################
47 # FastAPI app & global state
@@ -44,6 +50,340 @@ from fastapi.responses import JSONResponse, Response, StreamingResponse
50 logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
51 logger = logging.getLogger("proxy")
52
53 +DEFAULT_PROBE_INTERVAL = 5.0
54 +DEFAULT_PROBE_TIMEOUT = 2.0
55 +DEFAULT_FAIL_THRESHOLD = 3
56 +# Stop probing an instance that has been down this long. 0 probes forever,
57 +# which is what a cluster that restarts instances in place wants.
58 +DEFAULT_EVICTED_TTL = 900.0
59 +
60 +
61 +class InstanceRole(str, enum.Enum):
62 + ENCODE = "encode"
63 + PREFILL = "prefill"
64 + DECODE = "decode"
65 + PREFILL_DECODE = "prefill_decode"
66 +
67 +
68 +@dataclass
69 +class InstanceRecord:
70 + """One registered instance.
71 +
72 + Attributes:
73 + role: Which stage this instance serves.
74 + url: Base OpenAI-compatible URL, e.g. ``http://host:8000``.
75 + ec_zmq_addrs: Mooncake TP-rank-0 control addresses, one per DP replica,
76 + supplied by the launcher from the consumer's fixed port config.
77 + dp_size: Data-parallel replicas behind `url`, so the proxy can pick
78 + a replica and name the same one to both halves of a request.
79 +
80 + """
81 +
82 + role: InstanceRole
83 + url: str
84 + ec_zmq_addrs: list[str] = field(default_factory=list)
85 + dp_size: int = 1
86 +
87 +
88 +class InstanceRegistry:
89 + def __init__(
90 + self,
91 + probe_interval: float = DEFAULT_PROBE_INTERVAL,
92 + probe_timeout: float = DEFAULT_PROBE_TIMEOUT,
93 + fail_threshold: int = DEFAULT_FAIL_THRESHOLD,
94 + evicted_ttl: float = DEFAULT_EVICTED_TTL,
95 + ):
96 + self._probe_interval = probe_interval
97 + self._probe_timeout = probe_timeout
98 + self._fail_threshold = fail_threshold
99 + self._evicted_ttl = evicted_ttl
100 +
101 + self._live: dict[str, InstanceRecord] = {}
102 + self._evicted: dict[str, InstanceRecord] = {}
103 + self._evicted_since: dict[str, float] = {}
104 + self._fail_counts: dict[str, int] = {}
105 + # One cursor per role, only ever incremented. Rebuilding it whenever
106 + # the roster changes -- what an `itertools.cycle` over a mutable list
107 + # forces -- restarts every fan-out at the first instance and hot-spots
108 + # it after each registration.
109 + self._cursors: dict[InstanceRole, int] = {role: 0 for role in InstanceRole}
110 + self._replica_cursors: dict[str, int] = {}
111 + self._probe_task: asyncio.Task | None = None
112 +
113 + def register(self, record: InstanceRecord) -> bool:
114 + """Add or refresh an instance without overriding its health status."""
115 + roles = {
116 + other.role
117 + for other in itertools.chain(self._live.values(), self._evicted.values())
118 + }
119 + split_roles = {InstanceRole.PREFILL, InstanceRole.DECODE}
120 + if (record.role is InstanceRole.PREFILL_DECODE and roles & split_roles) or (
121 + record.role in split_roles and InstanceRole.PREFILL_DECODE in roles
122 + ):
123 + raise ValueError("Cannot mix prefill_decode with standalone prefill/decode")
124 + key = record.url
125 + previous = self._live.get(key) or self._evicted.get(key)
126 + if previous is not None:
127 + if previous == record:
128 + return False
129 + if previous.role is not record.role:
130 + raise ValueError("Unregister the instance before changing its role")
131 + target = self._live if key in self._live else self._evicted
132 + target[key] = record
133 + return False
134 + self._live[key] = record
135 + logger.info("Registered instance %s: %s", record.role.value, record.url)
136 + return True
137 +
138 + def unregister(self, url: str) -> bool:
139 + """Drop an instance for good, so a probe cannot bring it back."""
140 + key = url
141 + if key not in self._live and key not in self._evicted:
142 + return False
143 + self._live.pop(key, None)
144 + self._evicted.pop(key, None)
145 + self._evicted_since.pop(key, None)
146 + self._fail_counts.pop(key, None)
147 + self._replica_cursors.pop(key, None)
148 + logger.info("Unregistered instance: %s", url)
149 + return True
150 +
151 + def instances(self, role: InstanceRole) -> list[InstanceRecord]:
152 + return [record for record in self._live.values() if record.role is role]
153 +
154 + def urls(self, role: InstanceRole) -> list[str]:
155 + return [record.url for record in self.instances(role)]
156 +
157 + def pick(self, role: InstanceRole) -> InstanceRecord | None:
158 + """Take the next instance of `role` in round-robin order."""
159 + picked = self.pick_many(role, 1)
160 + return picked[0] if picked else None
161 +
162 + def pick_many(self, role: InstanceRole, count: int) -> list[InstanceRecord]:
163 + """Take `count` instances, continuing the rotation across calls.
164 +
165 + A multimodal request fans out one encoder request per item, so the
166 + assignment has to be contiguous with the previous request's rather
167 + than restart at the first instance every time.
168 + """
169 + alive = self.instances(role)
170 + if not alive or count <= 0:
171 + return []
172 + start = self._cursors[role]
173 + self._cursors[role] = start + count
174 + return [alive[(start + offset) % len(alive)] for offset in range(count)]
175 +
176 + def next_replica(self, record: InstanceRecord) -> int:
177 + """Take the next data-parallel replica of `record`, round-robin.
178 +
179 + The encoder pushes to one replica's receive channel, so the request
180 + has to run on that same replica; the caller names the rank to both
181 + halves.
182 + """
183 + if record.dp_size <= 1:
184 + return 0
185 + cursor = self._replica_cursors.get(record.url, 0)
186 + self._replica_cursors[record.url] = cursor + 1
187 + return cursor % record.dp_size
188 +
189 + def status(self) -> dict[str, Any]:
190 + return {
191 + role.value: {
192 + "live": [record.url for record in self.instances(role)],
193 + "evicted": [
194 + record.url
195 + for record in self._evicted.values()
196 + if record.role is role
197 + ],
198 + }
199 + for role in InstanceRole
200 + }
201 +
202 + def start_probing(self) -> None:
203 + if self._probe_task is None and self._probe_interval > 0:
204 + self._probe_task = asyncio.create_task(self._probe_loop())
205 +
206 + async def stop_probing(self) -> None:
207 + if self._probe_task is None:
208 + return
209 + self._probe_task.cancel()
210 + with suppress(asyncio.CancelledError):
211 + await self._probe_task
212 + self._probe_task = None
213 +
214 + async def _probe_loop(self) -> None:
215 + timeout = aiohttp.ClientTimeout(total=self._probe_timeout)
216 + async with aiohttp.ClientSession(timeout=timeout) as session:
217 + while True:
218 + await asyncio.sleep(self._probe_interval)
219 + try:
220 + await self._probe_once(session)
221 + except Exception:
222 + logger.exception("EPD registry probe round failed")
223 +
224 + async def _probe_once(self, session: aiohttp.ClientSession) -> None:
225 + targets = list(self._live.values()) + list(self._evicted.values())
226 + if not targets:
227 + return
228 + results = await asyncio.gather(
229 + *(self._probe(session, record.url) for record in targets),
230 + return_exceptions=True,
231 + )
232 + now = time.monotonic()
233 + for record, healthy in zip(targets, results):
234 + current = self._live.get(record.url) or self._evicted.get(record.url)
235 + if current is not record:
236 + continue
237 + if healthy is True:
238 + self._on_probe_success(record)
239 + else:
240 + self._on_probe_failure(record, now)
241 + self._drop_expired(now)
242 +
243 + async def _probe(self, session: aiohttp.ClientSession, url: str) -> bool:
244 + async with session.get(f"{url}/health") as resp:
245 + return resp.status == 200
246 +
247 + def _on_probe_success(self, record: InstanceRecord) -> None:
248 + key = record.url
249 + self._fail_counts.pop(key, None)
250 + if key in self._evicted:
251 + self._evicted.pop(key, None)
252 + self._evicted_since.pop(key, None)
253 + self._live[key] = record
254 + logger.info(
255 + "Instance %s (%s) is healthy again; routing resumed",
256 + record.url,
257 + record.role.value,
258 + )
259 +
260 + def _on_probe_failure(self, record: InstanceRecord, now: float) -> None:
261 + key = record.url
262 + if key not in self._live:
263 + # Either already evicted, or unregistered while this probe was in
264 + # flight. A round snapshots its targets and then awaits, so a
265 + # removal inside that window would otherwise be undone by a result
266 + # describing a registry that no longer exists.
267 + return
268 + failures = self._fail_counts.get(key, 0) + 1
269 + self._fail_counts[key] = failures
270 + if failures < self._fail_threshold:
271 + return
272 + self._live.pop(key, None)
273 + self._evicted[key] = record
274 + self._evicted_since[key] = now
275 + logger.warning(
276 + "Instance %s (%s) failed %d consecutive probes; stopped routing "
277 + "to it. It rejoins on its own once it responds again.",
278 + record.url,
279 + record.role.value,
280 + failures,
281 + )
282 +
283 + def _drop_expired(self, now: float) -> None:
284 + if self._evicted_ttl <= 0:
285 + return
286 + for key, since in list(self._evicted_since.items()):
287 + if now - since < self._evicted_ttl:
288 + continue
289 + record = self._evicted.pop(key, None)
290 + self._evicted_since.pop(key, None)
291 + self._fail_counts.pop(key, None)
292 + self._replica_cursors.pop(key, None)
293 + if record is not None:
294 + logger.warning("Instance %s stayed down; forgetting it", record.url)
295 +
296 +
297 +class InstanceRegistration(BaseModel):
298 + role: InstanceRole
299 + url: AnyHttpUrl
300 + ec_zmq_addrs: list[str] = Field(default_factory=list)
301 + dp_size: int = Field(default=1, ge=1)
302 +
303 + @model_validator(mode="after")
304 + def validate_consumer_addresses(self):
305 + if self.ec_zmq_addrs and len(self.ec_zmq_addrs) != self.dp_size:
306 + raise ValueError("Provide one Mooncake control address per DP replica")
307 + return self
308 +
309 +
310 +def require_admin_key(x_api_key: str = Header(default="")) -> None:
311 + expected = os.getenv("ADMIN_API_KEY", "")
312 + if not expected or not secrets.compare_digest(x_api_key, expected):
313 + raise HTTPException(403, "Invalid admin API key")
314 +
315 +
316 +@dataclass
317 +class EPDProxyConfig:
318 + probe_interval: float = 5.0
319 + probe_timeout: float = 2.0
320 + fail_threshold: int = 3
321 + evicted_ttl: float = 900.0
322 +
323 +
324 +@dataclass
325 +class _Route:
326 + """The instances one request was assigned to.
327 +
328 + Attributes:
329 + consumer: The stage that receives the embedding, if any stage does.
330 + consumer_zmq: The receive address named to the encoders.
331 + dp_rank: Which replica of `consumer` was named, so the request can be
332 + pinned to it.
333 +
334 + """
335 +
336 + encoder_urls: list[str]
337 + prefill: InstanceRecord | None
338 + decode: InstanceRecord
339 + consumer: InstanceRecord | None = None
340 + consumer_zmq: str | None = None
341 + dp_rank: int | None = None
342 +
343 +
344 +class EPDProxy:
345 + def __init__(self, registry: InstanceRegistry):
346 + self.registry = registry
347 +
348 + # ---------------------------------------------------------------- #
349 + # Routing #
350 + # ---------------------------------------------------------------- #
351 + def route(self, num_items: int) -> _Route:
352 + decode = self.registry.pick(InstanceRole.PREFILL_DECODE) or self.registry.pick(
353 + InstanceRole.DECODE
354 + )
355 + if decode is None:
356 + raise HTTPException(
357 + status_code=503, detail="No decode instance is registered"
358 + )
359 + prefill = (
360 + self.registry.pick(InstanceRole.PREFILL)
361 + if decode.role is InstanceRole.DECODE
362 + else None
363 + )
364 + if decode.role is InstanceRole.DECODE and prefill is None:
365 + raise HTTPException(
366 + status_code=503, detail="No prefill instance is registered"
367 + )
368 + encoders = self.registry.urls(InstanceRole.ENCODE)
369 + if num_items and not encoders:
370 + raise HTTPException(
371 + status_code=503, detail="No encode instance is registered"
372 + )
373 + route = _Route(encoder_urls=encoders, prefill=prefill, decode=decode)
374 + self._name_consumer(route)
375 + return route
376 +
377 + def _name_consumer(self, route: _Route) -> None:
378 + """Pin the EC consumer replica and, for push connectors, its endpoint."""
379 + candidate = route.prefill or route.decode
380 + route.consumer = candidate
381 + rank = self.registry.next_replica(candidate)
382 + route.dp_rank = rank if candidate.dp_size > 1 else None
383 + if candidate.ec_zmq_addrs:
384 + route.consumer_zmq = candidate.ec_zmq_addrs[rank]
385 +
386 +
387 app = FastAPI()
388 encode_session: aiohttp.ClientSession | None = None
389 prefill_session: aiohttp.ClientSession | None = None
@@ -448,6 +788,7 @@ async def maybe_prefill(
788 req_data: dict,
789 p_url: str,
790 req_id: str,
791 + dp_rank: int | None = None,
792 ) -> dict:
793 """- Do prefill-only task if p_url exist;
794 - Return a new body carrying kv transfer params (for nixl connector)
@@ -459,7 +800,7 @@ async def maybe_prefill(
800 if p_url:
801 logger.info("[%s] Processing through prefill: %s", req_id, p_url)
802
462 - prefill_response = await process_prefill_stage(req_data, p_url, req_id)
803 + prefill_response = await process_prefill_stage(req_data, p_url, req_id, dp_rank)
804 # for nixl connector to facilitate kv transfer...
805 prefill_response_json = msgspec.json.decode(await prefill_response.read())
806 kv_transfer_params = prefill_response_json.get("kv_transfer_params", {})
@@ -473,6 +814,7 @@ async def process_prefill_stage(
814 req_data: dict,
815 p_url: str,
816 req_id: str,
817 + dp_rank: int | None = None,
818 ) -> dict:
819 """Process request through Prefill stage and return kv_transfer_params"""
820 logger.info("[%s] Sending prefill request to: %s", req_id, p_url)
@@ -494,6 +836,8 @@ async def process_prefill_stage(
836 del prefill_request["stream_options"]
837
838 headers = {"x-request-id": req_id, "Content-Type": "application/json"}
839 + if dp_rank is not None:
840 + headers["X-data-parallel-rank"] = str(dp_rank)
841 try:
842 prefill_response = await prefill_session.post(
843 f"{p_url}/v1/chat/completions",
@@ -594,9 +938,7 @@ async def on_startup() -> None:
938 ),
939 )
940 encode_session = aiohttp.ClientSession(timeout=timeout, connector=connector)
597 - if app.state.p_urls:
598 - # only setup if prefill instance(s) exist
599 - prefill_session = aiohttp.ClientSession(timeout=timeout, connector=connector)
941 + prefill_session = aiohttp.ClientSession(timeout=timeout, connector=connector)
942 decode_session = aiohttp.ClientSession(timeout=timeout, connector=connector)
943
944
@@ -622,6 +964,7 @@ async def prepare_for_decode(
964 e_urls: list[str],
965 p_url: str,
966 consumer_zmq: str | None,
967 + prefill_dp_rank: int | None = None,
968 ) -> tuple[dict, float, float]:
969 """Encode, rewrite and prefill, returning the body to send to decode.
970
@@ -642,7 +985,7 @@ async def prepare_for_decode(
985 handles.update(ec_params)
986 prepared = {**prepared, "ec_transfer_params": handles}
987 _t2 = time.perf_counter()
645 - prepared = await maybe_prefill(prepared, p_url, req_id)
988 + prepared = await maybe_prefill(prepared, p_url, req_id, prefill_dp_rank)
989 return prepared, _t1 - _t0, _t2 - _t1
990
991
@@ -654,12 +997,13 @@ async def forward_non_stream(
997 d_url: str,
998 consumer_zmq: str | None,
999 dp_rank: int | None = None,
1000 + prefill_dp_rank: int | None = None,
1001 ) -> Response:
1002 try:
1003 for attempt in range(DECODE_RETRIES + 1):
1004 _t0 = time.perf_counter()
1005 prepared, encode_s, rewrite_s = await prepare_for_decode(
662 - req_data, req_id, e_urls, p_url, consumer_zmq
1006 + req_data, req_id, e_urls, p_url, consumer_zmq, prefill_dp_rank
1007 )
1008 _t2 = time.perf_counter()
1009
@@ -733,12 +1077,13 @@ async def forward_stream(
1077 d_url: str,
1078 consumer_zmq: str | None,
1079 dp_rank: int | None = None,
1080 + prefill_dp_rank: int | None = None,
1081 ) -> AsyncIterator[bytes]:
1082 try:
1083 for attempt in range(DECODE_RETRIES + 1):
1084 _t0 = time.perf_counter()
1085 prepared, encode_s, rewrite_s = await prepare_for_decode(
741 - req_data, req_id, e_urls, p_url, consumer_zmq
1086 + req_data, req_id, e_urls, p_url, consumer_zmq, prefill_dp_rank
1087 )
1088 _t2 = time.perf_counter()
1089
@@ -976,10 +1321,147 @@ async def stop_profile(request: Request):
1321 return await _profile_cmd("stop", body, e_url, p_url, d_url)
1322
1323
1324 +def build_app(config: EPDProxyConfig | None = None) -> FastAPI:
1325 + config = config or EPDProxyConfig()
1326 + registry = InstanceRegistry(
1327 + probe_interval=config.probe_interval,
1328 + probe_timeout=config.probe_timeout,
1329 + fail_threshold=config.fail_threshold,
1330 + evicted_ttl=config.evicted_ttl,
1331 + )
1332 + proxy = EPDProxy(registry)
1333 +
1334 + @asynccontextmanager
1335 + async def lifespan(app: FastAPI):
1336 + await on_startup()
1337 + try:
1338 + registry.start_probing()
1339 + yield
1340 + finally:
1341 + await registry.stop_probing()
1342 + await on_shutdown()
1343 +
1344 + app = FastAPI(lifespan=lifespan)
1345 + app.state.proxy = proxy
1346 + app.state.registry = registry
1347 +
1348 + @app.get("/instances")
1349 + async def list_instances():
1350 + return registry.status()
1351 +
1352 + @app.post("/instances", dependencies=[Depends(require_admin_key)])
1353 + async def register_instance(body: InstanceRegistration):
1354 + if body.ec_zmq_addrs and body.role not in (
1355 + InstanceRole.PREFILL,
1356 + InstanceRole.PREFILL_DECODE,
1357 + ):
1358 + raise HTTPException(400, "Only EC consumers accept Mooncake addresses")
1359 + try:
1360 + created = registry.register(
1361 + InstanceRecord(
1362 + body.role,
1363 + str(body.url).rstrip("/"),
1364 + body.ec_zmq_addrs,
1365 + body.dp_size,
1366 + )
1367 + )
1368 + except ValueError as error:
1369 + raise HTTPException(409, str(error)) from error
1370 + return {"registered": created}
1371 +
1372 + @app.delete("/instances", dependencies=[Depends(require_admin_key)])
1373 + async def unregister_instance(url: AnyHttpUrl):
1374 + return {"removed": registry.unregister(str(url).rstrip("/"))}
1375 +
1376 + @app.post("/v1/chat/completions")
1377 + async def chat_completions(request: Request):
1378 + req_data = msgspec.json.decode(await request.body())
1379 + req_id = request.headers.get("x-request-id", str(uuid.uuid4()))
1380 + route = proxy.route(len(extract_mm_items(req_data)))
1381 + args = (
1382 + req_data,
1383 + req_id,
1384 + route.encoder_urls,
1385 + route.prefill.url if route.prefill else None,
1386 + route.decode.url,
1387 + route.consumer_zmq,
1388 + )
1389 + ranks = {
1390 + "dp_rank": route.dp_rank if route.prefill is None else None,
1391 + "prefill_dp_rank": route.dp_rank if route.prefill is not None else None,
1392 + }
1393 + if req_data.get("stream", False):
1394 + return StreamingResponse(
1395 + forward_stream(*args, **ranks), media_type="text/event-stream"
1396 + )
1397 + return await forward_non_stream(*args, **ranks)
1398 +
1399 + @app.get("/v1/models")
1400 + async def list_models():
1401 + decode = registry.pick(InstanceRole.PREFILL_DECODE) or registry.pick(
1402 + InstanceRole.DECODE
1403 + )
1404 + if decode is None:
1405 + raise HTTPException(
1406 + status_code=503, detail="No decode instance is registered"
1407 + )
1408 + async with decode_session.get(f"{decode.url}/v1/models") as resp:
1409 + resp.raise_for_status()
1410 + return await resp.json()
1411 +
1412 + @app.get("/health")
1413 + async def health():
1414 + status = registry.status()
1415 + # An empty roster is not unhealthy: the proxy is meant to come up
1416 + # before anything registers with it.
1417 + return JSONResponse({"proxy": "healthy", "instances": status})
1418 +
1419 + @app.post("/start_profile")
1420 + async def start_profile(request: Request):
1421 + return await _profile(registry, "start", await request.json())
1422 +
1423 + @app.post("/stop_profile")
1424 + async def stop_profile(request: Request):
1425 + return await _profile(registry, "stop", await request.json())
1426 +
1427 + return app
1428 +
1429 +
1430 +async def _profile(registry: InstanceRegistry, cmd: str, payload: dict) -> dict:
1431 + headers = {"Authorization": f"Bearer {os.getenv('OPENAI_API_KEY', '')}"}
1432 + targets = {role.value: registry.pick(role) for role in InstanceRole}
1433 + results = await asyncio.gather(
1434 + *(
1435 + _post_if_available(
1436 + decode_session, f"{record.url}/{cmd}_profile", payload, headers
1437 + )
1438 + for record in targets.values()
1439 + if record is not None
1440 + )
1441 + )
1442 + reachable = [result for result in results if result is not None]
1443 + if not reachable:
1444 + raise HTTPException(
1445 + status_code=503,
1446 + detail="Profiling endpoints are disabled on every instance",
1447 + )
1448 + live = [name for name, record in targets.items() if record is not None]
1449 + return dict(zip(live, results))
1450 +
1451 +
1452 if __name__ == "__main__":
1453 parser = argparse.ArgumentParser()
1454 parser.add_argument("--host", default="0.0.0.0")
1455 parser.add_argument("--port", type=int, default=8000)
1456 + parser.add_argument(
1457 + "--dynamic-registration",
1458 + action="store_true",
1459 + help="Enable launcher-managed HTTP registration for E+PD or E+P+D.",
1460 + )
1461 + parser.add_argument("--probe-interval", type=float, default=DEFAULT_PROBE_INTERVAL)
1462 + parser.add_argument("--probe-timeout", type=float, default=DEFAULT_PROBE_TIMEOUT)
1463 + parser.add_argument("--fail-threshold", type=int, default=DEFAULT_FAIL_THRESHOLD)
1464 + parser.add_argument("--evicted-ttl", type=float, default=DEFAULT_EVICTED_TTL)
1465 parser.add_argument(
1466 "--log-requests",
1467 action="store_true",
@@ -995,12 +1477,12 @@ if __name__ == "__main__":
1477 )
1478 parser.add_argument(
1479 "--encode-servers-urls",
998 - required=True,
1480 + default="",
1481 help='Comma-separated encode URLs ("http://e1:8001,http://e2:8001")',
1482 )
1483 parser.add_argument(
1484 "--prefill-servers-urls",
1003 - required=True,
1485 + default="none",
1486 help=(
1487 'Comma-separated prefill URLs ("http://p1:8003,http://p2:8004") '
1488 'to enable E->P->D, set "disable" or "none" to enable E->PD'
@@ -1008,7 +1490,7 @@ if __name__ == "__main__":
1490 )
1491 parser.add_argument(
1492 "--decode-servers-urls",
1011 - required=True,
1493 + default="",
1494 help='Comma-separated decode URLs ("http://d1:8005,http://d2:8006")',
1495 )
1496 parser.add_argument(
@@ -1043,58 +1525,87 @@ if __name__ == "__main__":
1525 )
1526
1527 args = parser.parse_args()
1528 + if args.fail_threshold < 1:
1529 + parser.error("--fail-threshold must be at least 1")
1530 if args.log_requests:
1531 logging.getLogger().setLevel(logging.DEBUG)
1532 app.middleware("http")(log_requests)
1533 NO_REWRITE = args.no_rewrite
1534 DECODE_RETRIES = max(0, args.decode_retries)
1051 - app.state.e_urls = [
1052 - u.strip() for u in args.encode_servers_urls.split(",") if u.strip()
1053 - ]
1054 - app.state.d_urls = [
1055 - u.strip() for u in args.decode_servers_urls.split(",") if u.strip()
1056 - ]
1057 - app.state.d_ec_urls = [
1058 - u.strip() for u in args.ec_consumer_zmq_addrs.split(",") if u.strip()
1059 - ]
1060 - if args.ec_consumer_dp_size < 1:
1061 - parser.error("--ec-consumer-dp-size must be at least 1")
1062 - app.state.ec_consumer_dp_size = args.ec_consumer_dp_size
1063 - app.state.replica_counter = itertools.count()
1064 - expected = len(app.state.d_urls) * args.ec_consumer_dp_size
1065 - if app.state.d_ec_urls and len(app.state.d_ec_urls) != expected:
1066 - parser.error(
1067 - "--ec-consumer-zmq-addrs must contain one address per consumer "
1068 - f"replica: expected {expected} "
1069 - f"({len(app.state.d_urls)} servers x {args.ec_consumer_dp_size} replicas), "
1070 - f"got {len(app.state.d_ec_urls)}"
1071 - )
1072 - # handle prefill instances
1073 - if args.prefill_servers_urls.lower() in ("disable", "none", ""):
1074 - app.state.p_urls = []
1075 - logger.info(
1076 - "Disaggregated prefill phase explicitly disabled by user. Running E + PD..."
1535 + if args.dynamic_registration:
1536 + if not os.getenv("ADMIN_API_KEY"):
1537 + parser.error("--dynamic-registration requires ADMIN_API_KEY")
1538 + if (
1539 + args.encode_servers_urls
1540 + or args.prefill_servers_urls.lower() not in ("disable", "none", "")
1541 + or args.decode_servers_urls
1542 + or args.ec_consumer_zmq_addrs
1543 + ):
1544 + parser.error(
1545 + "With --dynamic-registration, instances register through /instances"
1546 + )
1547 + app = build_app(
1548 + EPDProxyConfig(
1549 + probe_interval=args.probe_interval,
1550 + probe_timeout=args.probe_timeout,
1551 + fail_threshold=args.fail_threshold,
1552 + evicted_ttl=args.evicted_ttl,
1553 + )
1554 )
1555 + if args.log_requests:
1556 + app.middleware("http")(log_requests)
1557 else:
1079 - app.state.p_urls = [
1080 - u.strip() for u in args.prefill_servers_urls.split(",") if u.strip()
1558 + if not args.encode_servers_urls or not args.decode_servers_urls:
1559 + parser.error(
1560 + "Static routing requires --encode-servers-urls "
1561 + "and --decode-servers-urls"
1562 + )
1563 + app.state.e_urls = [
1564 + u.strip() for u in args.encode_servers_urls.split(",") if u.strip()
1565 ]
1082 - logger.info("Disaggregated prefill phase is enabled. Running E + P + D...")
1083 - try:
1084 - validate_ec_consumer_routing(app.state.p_urls, app.state.d_ec_urls)
1085 - except ValueError as exc:
1086 - parser.error(str(exc))
1087 -
1088 - logger.info("Proxy listening on %s:%s", args.host, args.port)
1089 - logger.info("Encode servers: %s", app.state.e_urls)
1090 - logger.info("Prefill instances %s", app.state.p_urls)
1091 - logger.info("Decode servers: %s", app.state.d_urls)
1092 - if app.state.ec_consumer_dp_size > 1:
1093 - logger.info(
1094 - "EC consumer replicas per server: %d (control addresses: %s)",
1095 - app.state.ec_consumer_dp_size,
1096 - app.state.d_ec_urls,
1097 - )
1566 + app.state.d_urls = [
1567 + u.strip() for u in args.decode_servers_urls.split(",") if u.strip()
1568 + ]
1569 + app.state.d_ec_urls = [
1570 + u.strip() for u in args.ec_consumer_zmq_addrs.split(",") if u.strip()
1571 + ]
1572 + if args.ec_consumer_dp_size < 1:
1573 + parser.error("--ec-consumer-dp-size must be at least 1")
1574 + app.state.ec_consumer_dp_size = args.ec_consumer_dp_size
1575 + app.state.replica_counter = itertools.count()
1576 + expected = len(app.state.d_urls) * args.ec_consumer_dp_size
1577 + if app.state.d_ec_urls and len(app.state.d_ec_urls) != expected:
1578 + parser.error(
1579 + "--ec-consumer-zmq-addrs must contain one address per consumer "
1580 + f"replica: expected {expected} "
1581 + f"({len(app.state.d_urls)} servers x "
1582 + f"{args.ec_consumer_dp_size} replicas), "
1583 + f"got {len(app.state.d_ec_urls)}"
1584 + )
1585 + # handle prefill instances
1586 + if args.prefill_servers_urls.lower() in ("disable", "none", ""):
1587 + app.state.p_urls = []
1588 + logger.info("Disaggregated prefill disabled. Running E + PD...")
1589 + else:
1590 + app.state.p_urls = [
1591 + u.strip() for u in args.prefill_servers_urls.split(",") if u.strip()
1592 + ]
1593 + logger.info("Disaggregated prefill phase is enabled. Running E + P + D...")
1594 + try:
1595 + validate_ec_consumer_routing(app.state.p_urls, app.state.d_ec_urls)
1596 + except ValueError as exc:
1597 + parser.error(str(exc))
1598 +
1599 + logger.info("Proxy listening on %s:%s", args.host, args.port)
1600 + logger.info("Encode servers: %s", app.state.e_urls)
1601 + logger.info("Prefill instances %s", app.state.p_urls)
1602 + logger.info("Decode servers: %s", app.state.d_urls)
1603 + if app.state.ec_consumer_dp_size > 1:
1604 + logger.info(
1605 + "EC consumer replicas per server: %d (control addresses: %s)",
1606 + app.state.ec_consumer_dp_size,
1607 + app.state.d_ec_urls,
1608 + )
1609
1610 uvicorn.run(
1611 app,
tests/v1/ec_connector/unit/test_epd_proxy.py new
+279
@@ -0,0 +1,279 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Routing and registration behaviour of the EPD proxy."""
4 +
5 +import asyncio
6 +from typing import Any
7 +from unittest.mock import AsyncMock, Mock
8 +
9 +import msgspec
10 +import pytest
11 +from fastapi import HTTPException
12 +from fastapi.testclient import TestClient
13 +
14 +from examples.disaggregated.disaggregated_encoder import (
15 + disagg_epd_proxy as proxy_module,
16 +)
17 +from examples.disaggregated.disaggregated_encoder.disagg_epd_proxy import (
18 + EPDProxy,
19 + EPDProxyConfig,
20 + InstanceRecord,
21 + InstanceRegistry,
22 + InstanceRole,
23 + build_app,
24 + extract_mm_items,
25 +)
26 +
27 +ENCODE = InstanceRole.ENCODE
28 +PREFILL = InstanceRole.PREFILL
29 +DECODE = InstanceRole.DECODE
30 +PD = InstanceRole.PREFILL_DECODE
31 +
32 +IMAGE_ITEM = {"type": "image_url", "image_url": {"url": "http://img/0.png"}}
33 +
34 +
35 +@pytest.fixture
36 +def proxy():
37 + registry = InstanceRegistry(probe_interval=0)
38 + return EPDProxy(registry)
39 +
40 +
41 +class TestRouting:
42 + def test_no_decode_instance_is_service_unavailable(self, proxy):
43 + """The proxy comes up before anything registers, so this is normal."""
44 + with pytest.raises(HTTPException) as excinfo:
45 + proxy.route(num_items=0)
46 + assert excinfo.value.status_code == 503
47 +
48 + def test_media_without_an_encoder_is_service_unavailable(self, proxy):
49 + proxy.registry.register(InstanceRecord(PD, "http://d0:8000"))
50 + assert proxy.route(num_items=0).decode.url == "http://d0:8000"
51 + with pytest.raises(HTTPException) as excinfo:
52 + proxy.route(num_items=1)
53 + assert excinfo.value.status_code == 503
54 +
55 + def test_prefill_is_optional(self, proxy):
56 + """An E+PD deployment registers no prefill instance at all."""
57 + proxy.registry.register(InstanceRecord(PD, "http://d0:8000"))
58 + assert proxy.route(num_items=0).prefill is None
59 +
60 + def test_encoder_roster_tracks_new_instances(self, proxy):
61 + proxy.registry.register(InstanceRecord(PD, "http://d0:8000"))
62 + for index in range(2):
63 + proxy.registry.register(InstanceRecord(ENCODE, f"http://e{index}:8000"))
64 + assert proxy.route(num_items=3).encoder_urls == [
65 + "http://e0:8000",
66 + "http://e1:8000",
67 + ]
68 + proxy.registry.unregister("http://e0:8000")
69 + assert proxy.route(num_items=3).encoder_urls == ["http://e1:8000"]
70 +
71 + @pytest.mark.parametrize("unregister", [False, True])
72 + def test_standalone_decode_always_requires_healthy_prefill(self, unregister):
73 + app = build_app()
74 + proxy = app.state.proxy
75 + proxy.registry.register(InstanceRecord(DECODE, "http://d0:8000"))
76 + with pytest.raises(HTTPException) as excinfo:
77 + proxy.route(num_items=0)
78 + assert excinfo.value.status_code == 503
79 + proxy.registry.register(InstanceRecord(PREFILL, "http://p0:8000"))
80 + route = proxy.route(num_items=0)
81 + assert route.decode.url == "http://d0:8000"
82 + assert route.consumer.url == "http://p0:8000"
83 + if unregister:
84 + proxy.registry.unregister("http://p0:8000")
85 + else:
86 + for _ in range(proxy.registry._fail_threshold):
87 + proxy.registry._on_probe_failure(route.prefill, 0)
88 + with pytest.raises(HTTPException) as excinfo:
89 + proxy.route(num_items=0)
90 + assert excinfo.value.status_code == 503
91 +
92 +
93 +class TestConsumerAddress:
94 + """Which stage receives the embedding depends on the topology."""
95 +
96 + def test_shared_storage_connectors_name_no_target(self, proxy):
97 + """Rotate replicas without requiring a push endpoint."""
98 + proxy.registry.register(InstanceRecord(PD, "http://d0:8000", dp_size=2))
99 + route = proxy.route(num_items=0)
100 + assert route.consumer_zmq is None
101 + assert route.dp_rank == 0
102 + assert proxy.route(num_items=0).dp_rank == 1
103 +
104 + def test_decode_is_the_consumer_when_prefill_is_not_split_out(self, proxy):
105 + proxy.registry.register(
106 + InstanceRecord(PD, "http://d0:8000", ec_zmq_addrs=["tcp://d0:20001"])
107 + )
108 + assert proxy.route(num_items=0).consumer_zmq == "tcp://d0:20001"
109 +
110 + def test_prefill_is_the_consumer_when_it_is_split_out(self, proxy):
111 + """In E+P+D the prefill instance consumes the embedding, not decode.
112 +
113 + Reading the address out of whichever list it came from -- rather than
114 + from the record that reported one -- sends the push to the wrong
115 + instance in this topology.
116 + """
117 + proxy.registry.register(
118 + InstanceRecord(PREFILL, "http://p0:8000", ec_zmq_addrs=["tcp://p0:20001"])
119 + )
120 + proxy.registry.register(InstanceRecord(DECODE, "http://d0:8000"))
121 + assert proxy.route(num_items=0).consumer_zmq == "tcp://p0:20001"
122 +
123 +
124 +class TestProxyApi:
125 + @pytest.fixture
126 + def client(self, monkeypatch):
127 + monkeypatch.setenv("ADMIN_API_KEY", "test-key")
128 + config = EPDProxyConfig(probe_interval=0)
129 + with TestClient(build_app(config)) as client:
130 + client.headers["X-API-Key"] = "test-key"
131 + yield client
132 +
133 + def test_proxy_serves_before_anything_registers(self, client):
134 + assert client.get("/health").status_code == 200
135 + assert client.get("/instances").json()["encode"]["live"] == []
136 +
137 + def test_requests_are_refused_until_a_decoder_registers(self, client):
138 + response = client.post(
139 + "/v1/chat/completions", json={"model": "m", "messages": []}
140 + )
141 + assert response.status_code == 503
142 + assert client.get("/v1/models").status_code == 503
143 +
144 + def test_http_registration_keeps_consumer_replica_and_address_together(
145 + self, client
146 + ):
147 + payload: dict[str, Any] = {
148 + "role": "prefill_decode",
149 + "url": "http://d0:8000/",
150 + "dp_size": 2,
151 + "ec_zmq_addrs": ["tcp://d0:20001", "tcp://d0:20003"],
152 + }
153 + assert client.post("/instances", json=payload).json() == {"registered": True}
154 + assert client.post("/instances", json=payload).json() == {"registered": False}
155 + proxy = client.app.state.proxy
156 + for rank in [0, 1, 0]:
157 + route = proxy.route(0)
158 + assert route.dp_rank == rank
159 + assert route.consumer_zmq == payload["ec_zmq_addrs"][rank]
160 + assert client.delete("/instances", params={"url": payload["url"]}).json() == {
161 + "removed": True
162 + }
163 + assert proxy.registry.pick(PD) is None
164 +
165 + def test_registration_rejects_invalid_topology(self, client):
166 + cases: list[tuple[dict[str, Any], int]] = [
167 + ({"dp_size": 0}, 422),
168 + ({"dp_size": 2, "ec_zmq_addrs": ["tcp://d0:20001"]}, 422),
169 + ({"role": "decode", "ec_zmq_addrs": ["tcp://d0:20001"]}, 400),
170 + ({"role": "encode", "ec_zmq_addrs": ["tcp://d0:20001"]}, 400),
171 + ]
172 + for fields, status in cases:
173 + response = client.post(
174 + "/instances",
175 + json={"url": "http://d0:8000", "role": "prefill_decode", **fields},
176 + )
177 + assert response.status_code == status
178 +
179 + def test_registration_requires_admin_key(self, client):
180 + client.headers.pop("X-API-Key")
181 + assert (
182 + client.post(
183 + "/instances", json={"role": "encode", "url": "http://e0:8000"}
184 + ).status_code
185 + == 403
186 + )
187 + assert (
188 + client.delete("/instances", params={"url": "http://e0:8000"}).status_code
189 + == 403
190 + )
191 +
192 + @pytest.mark.parametrize("split_role", ["prefill", "decode"])
193 + @pytest.mark.parametrize("combined_first", [False, True])
194 + def test_registration_rejects_mixed_topologies(
195 + self, client, split_role, combined_first
196 + ):
197 + roles = ["prefill_decode", split_role]
198 + if not combined_first:
199 + roles.reverse()
200 + first = {"role": roles[0], "url": "http://first:8000"}
201 + second = {"role": roles[1], "url": "http://second:8000"}
202 + assert client.post("/instances", json=first).status_code == 200
203 + assert client.post("/instances", json=second).status_code == 409
204 + # Health eviction must not silently permit a topology switch.
205 + registry = client.app.state.registry
206 + record = registry._live[first["url"]]
207 + for _ in range(registry._fail_threshold):
208 + registry._on_probe_failure(record, 0)
209 + assert client.post("/instances", json=second).status_code == 409
210 + assert client.delete("/instances", params={"url": first["url"]}).json() == {
211 + "removed": True
212 + }
213 + assert client.post("/instances", json=second).status_code == 200
214 +
215 +
216 +def test_extract_mm_items_finds_media_across_messages():
217 + req = {
218 + "messages": [
219 + {"role": "user", "content": "plain text"},
220 + {"role": "user", "content": [IMAGE_ITEM, {"type": "text", "text": "hi"}]},
221 + {"role": "user", "content": [IMAGE_ITEM]},
222 + ]
223 + }
224 + assert extract_mm_items(req) == [IMAGE_ITEM, IMAGE_ITEM]
225 +
226 +
227 +@pytest.mark.parametrize("push", [False, True])
228 +def test_encoder_handles_and_json_metadata_survive_rewrite(proxy, push, monkeypatch):
229 + """Keep main's hash-keyed handles and per-item push IDs after the proxy move."""
230 + proxy.registry.register(InstanceRecord(ENCODE, "http://encoder"))
231 + proxy.registry.register(
232 + InstanceRecord(
233 + PD,
234 + "http://decode",
235 + ec_zmq_addrs=["tcp://decode:14579"] if push else [],
236 + )
237 + )
238 + handle = {
239 + "metadata": {"image_grid_thw": [[1, 2, 3]]},
240 + "item_indices": [0, 1],
241 + "peer_port": 5601,
242 + }
243 + response = Mock(
244 + status=200,
245 + read=AsyncMock(
246 + return_value=msgspec.json.encode(
247 + {"ec_transfer_params": {"engine-hash": handle}}
248 + )
249 + ),
250 + )
251 + session = Mock(post=AsyncMock(return_value=response))
252 + monkeypatch.setattr(proxy_module, "encode_session", session)
253 + original = {"messages": [{"role": "user", "content": [IMAGE_ITEM, IMAGE_ITEM]}]}
254 + route = proxy.route(2)
255 + prepared, _, _ = asyncio.run(
256 + proxy_module.prepare_for_decode(
257 + original, "request", route.encoder_urls, None, route.consumer_zmq
258 + )
259 + )
260 + assert "ec_transfer_params" not in original
261 + assert prepared["ec_transfer_params"]["engine-hash"] == handle
262 + items = prepared["ec_transfer_params"]["ec_items"]
263 + assert [item["mm_hash"] for item in items] == ["engine-hash", "engine-hash"]
264 + assert items[0]["transfer_id"] != items[1]["transfer_id"]
265 + assert session.post.call_count == 1
266 + sent = msgspec.json.decode(session.post.call_args.kwargs["data"]).get(
267 + "ec_transfer_params"
268 + )
269 + if push:
270 + assert sent == {
271 + "consumer_zmq": "tcp://decode:14579",
272 + "ec_items": [{"transfer_id": item["transfer_id"]} for item in items],
273 + }
274 + else:
275 + assert sent is None
276 + for index in range(2):
277 + assert prepared["messages"][0]["content"][index]["image_embeds"] == {
278 + "image_grid_thw": [1, 2, 3]
279 + }
tests/v1/ec_connector/unit/test_epd_proxy_retry.py
+43 -4
@@ -8,6 +8,7 @@ change to them is what these tests catch.
8
9 import asyncio
10 import importlib.util
11 +import sys
12 from pathlib import Path
13 from types import SimpleNamespace
14 from typing import Any
@@ -27,6 +28,7 @@ def proxy():
28 spec = importlib.util.spec_from_file_location("disagg_epd_proxy_retry", path)
29 assert spec is not None and spec.loader is not None
30 module = importlib.util.module_from_spec(spec)
31 + sys.modules[spec.name] = module
32 spec.loader.exec_module(module)
33 return module
34
@@ -48,7 +50,7 @@ def test_maybe_prefill_leaves_the_caller_body_untouched(proxy, monkeypatch):
50 """
51 served = [{"remote_block_ids": [1, 2]}, {}]
52
51 - async def _stage(req_data, p_url, req_id):
53 + async def _stage(req_data, p_url, req_id, dp_rank=None):
54 assert "kv_transfer_params" not in req_data
55 return _Response(served.pop(0))
56
@@ -222,7 +224,7 @@ def test_a_decode_retry_does_not_inherit_the_previous_handles(proxy, monkeypatch
224 _EncoderSession([{"encoder-side-hash": handle}, {}]),
225 )
226
225 - async def _no_prefill(req_data, p_url, req_id):
227 + async def _no_prefill(req_data, p_url, req_id, dp_rank=None):
228 return req_data
229
230 monkeypatch.setattr(proxy, "maybe_prefill", _no_prefill)
@@ -280,6 +282,7 @@ def test_raw_media_keeps_encoder_transfer_identity(proxy, monkeypatch):
282 @pytest.mark.asyncio
283 @pytest.mark.parametrize("stream", [False, True])
284 @pytest.mark.parametrize("prefill", [False, True])
285 +@pytest.mark.parametrize("dynamic", [False, True])
286 @pytest.mark.parametrize(
287 "request_options",
288 [
@@ -293,7 +296,7 @@ def test_raw_media_keeps_encoder_transfer_identity(proxy, monkeypatch):
296 ],
297 )
298 async def test_http_roundtrip_preserves_payload_and_response_bytes(
296 - proxy, monkeypatch, stream, prefill, request_options
299 + proxy, monkeypatch, stream, prefill, dynamic, request_options
300 ):
301 """Preserve media and scheduling context across HTTP hops and retries."""
302 seen: dict[str, list[dict]] = {"encode": [], "prefill": [], "decode": []}
@@ -307,6 +310,10 @@ async def test_http_roundtrip_preserves_payload_and_response_bytes(
310
311 async def backend(request):
312 stage = request.match_info["stage"]
313 + if dynamic:
314 + consumer = "prefill" if prefill else "decode"
315 + expected_rank = "0" if stage == consumer else None
316 + assert request.headers.get("X-data-parallel-rank") == expected_rank
317 assert request.content_type == "application/json"
318 body = await request.json()
319 seen[stage].append(body)
@@ -354,6 +361,10 @@ async def test_http_roundtrip_preserves_payload_and_response_bytes(
361 monkeypatch.setattr(proxy.app.state, "ec_consumer_dp_size", 1, raising=False)
362 monkeypatch.setattr(proxy, "DECODE_RETRIES", 1)
363 monkeypatch.setattr(proxy, "NO_REWRITE", False)
364 + target_app = proxy.app
365 + if dynamic:
366 + monkeypatch.setenv("ADMIN_API_KEY", "test-key")
367 + target_app = proxy.build_app(proxy.EPDProxyConfig(probe_interval=0))
368 item = {"type": "image_url", "image_url": {"url": "data:image/png;base64,YWJj"}}
369 body = {
370 "model": "test",
@@ -377,9 +388,37 @@ async def test_http_roundtrip_preserves_payload_and_response_bytes(
388 await proxy.on_startup()
389 try:
390 async with httpx.AsyncClient(
380 - transport=httpx.ASGITransport(app=proxy.app), base_url="http://proxy"
391 + transport=httpx.ASGITransport(app=target_app), base_url="http://proxy"
392 ) as client:
393 + if dynamic:
394 + registrations = [("encode", "encode")]
395 + registrations += (
396 + [("prefill", "prefill"), ("decode", "decode")]
397 + if prefill
398 + else [("prefill_decode", "decode")]
399 + )
400 + for role, stage in registrations:
401 + consumer = "prefill" if prefill else "decode"
402 + registered = await client.post(
403 + "/instances",
404 + headers={"X-API-Key": "test-key"},
405 + json={
406 + "role": role,
407 + "url": str(server.make_url(f"/{stage}")),
408 + "dp_size": 2 if stage == consumer else 1,
409 + },
410 + )
411 + assert registered.status_code == 200
412 response = await client.post("/v1/chat/completions", json=body)
413 + if dynamic:
414 + removed = await client.delete(
415 + "/instances",
416 + headers={"X-API-Key": "test-key"},
417 + params={"url": str(server.make_url("/decode"))},
418 + )
419 + assert removed.json() == {"removed": True}
420 + unavailable = await client.post("/v1/chat/completions", json=body)
421 + assert unavailable.status_code == 503
422 assert response.status_code == 200
423 assert response.content == expected
424 assert response.headers["content-type"].startswith(
tests/v1/ec_connector/unit/test_epd_proxy_round_robin.py
+2
@@ -14,6 +14,7 @@ across calls so consecutive requests keep advancing through every URL.
14 """
15
16 import importlib.util
17 +import sys
18 from collections import Counter
19 from pathlib import Path
20
@@ -27,6 +28,7 @@ def _load_proxy_module():
28 spec = importlib.util.spec_from_file_location("disagg_epd_proxy_under_test", path)
29 assert spec is not None and spec.loader is not None
30 module = importlib.util.module_from_spec(spec)
31 + sys.modules[spec.name] = module
32 spec.loader.exec_module(module)
33 return module
34
tests/v1/ec_connector/unit/test_epd_registry.py new
+156
@@ -0,0 +1,156 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Dynamic registration and liveness for the EPD proxy's instance registry."""
4 +
5 +from unittest.mock import patch
6 +
7 +import pytest
8 +
9 +from examples.disaggregated.disaggregated_encoder.disagg_epd_proxy import (
10 + InstanceRecord,
11 + InstanceRegistry,
12 + InstanceRole,
13 +)
14 +
15 +ENCODE = InstanceRole.ENCODE
16 +DECODE = InstanceRole.DECODE
17 +
18 +
19 +@pytest.fixture
20 +def registry():
21 + return InstanceRegistry(probe_interval=0, fail_threshold=3, evicted_ttl=60.0)
22 +
23 +
24 +def _fill(registry, role, count, prefix="http://h"):
25 + for index in range(count):
26 + registry.register(InstanceRecord(role, f"{prefix}{index}:8000"))
27 +
28 +
29 +async def _probe_round(registry, healthy: set[str], now: float = 0.0):
30 + """Run one probe pass where only `healthy` URLs answer."""
31 +
32 + async def fake_probe(self, session, url):
33 + return url in healthy
34 +
35 + with (
36 + patch.object(InstanceRegistry, "_probe", fake_probe),
37 + patch(
38 + "examples.disaggregated.disaggregated_encoder.disagg_epd_proxy.time.monotonic",
39 + return_value=now,
40 + ),
41 + ):
42 + await registry._probe_once(session=None)
43 +
44 +
45 +class TestRegistration:
46 + def test_registering_is_idempotent_and_reports_novelty(self, registry):
47 + record = InstanceRecord(ENCODE, "http://e0:8000")
48 + assert registry.register(record) is True
49 + assert registry.register(record) is False
50 + assert registry.urls(ENCODE) == ["http://e0:8000"]
51 +
52 + def test_transfer_addresses_stay_with_the_registered_consumer(self, registry):
53 + """Roster updates must not shift a consumer's transfer addresses."""
54 + registry.register(InstanceRecord(DECODE, "http://d0:8000"))
55 + registry.register(
56 + InstanceRecord(
57 + DECODE,
58 + "http://d0:8000",
59 + ec_zmq_addrs=["tcp://d0:20001", "tcp://d0:20002"],
60 + dp_size=2,
61 + )
62 + )
63 + picked = registry.pick(DECODE)
64 + assert picked.ec_zmq_addrs == ["tcp://d0:20001", "tcp://d0:20002"]
65 + assert picked.dp_size == 2
66 +
67 + def test_unregister_is_terminal(self, registry):
68 + registry.register(InstanceRecord(ENCODE, "http://e0:8000"))
69 + assert registry.unregister("http://e0:8000") is True
70 + assert registry.unregister("http://e0:8000") is False
71 + assert registry.pick(ENCODE) is None
72 +
73 + def test_round_robin_survives_registration(self, registry):
74 + _fill(registry, ENCODE, 2)
75 + assert [record.url for record in registry.pick_many(ENCODE, 2)] == [
76 + "http://h0:8000",
77 + "http://h1:8000",
78 + ]
79 + registry.register(InstanceRecord(ENCODE, "http://h2:8000"))
80 + assert registry.pick(ENCODE).url == "http://h2:8000"
81 +
82 +
83 +class TestLiveness:
84 + @pytest.mark.asyncio
85 + async def test_identical_reregistration_preserves_inflight_probe_results(
86 + self, registry
87 + ):
88 + """Repeated registrations must not suppress eviction or recovery."""
89 + url = "http://e0:8000"
90 + registry.register(InstanceRecord(ENCODE, url))
91 + healthy = False
92 +
93 + async def probe(self, session, url):
94 + registry.register(InstanceRecord(ENCODE, url))
95 + return healthy
96 +
97 + with patch.object(InstanceRegistry, "_probe", probe):
98 + for _ in range(registry._fail_threshold):
99 + await registry._probe_once(None)
100 + assert registry.status()["encode"] == {"live": [], "evicted": [url]}
101 +
102 + healthy = True
103 + await registry._probe_once(None)
104 + assert registry.status()["encode"] == {"live": [url], "evicted": []}
105 +
106 + @pytest.mark.asyncio
107 + async def test_consecutive_failures_stop_routing(self, registry):
108 + registry.register(InstanceRecord(ENCODE, "http://e0:8000"))
109 + registry.register(InstanceRecord(ENCODE, "http://e1:8000"))
110 + for _ in range(registry._fail_threshold):
111 + await _probe_round(registry, healthy={"http://e1:8000"})
112 + assert registry.urls(ENCODE) == ["http://e1:8000"]
113 + assert registry.status()["encode"]["evicted"] == ["http://e0:8000"]
114 +
115 + @pytest.mark.asyncio
116 + @pytest.mark.parametrize("role", [ENCODE, DECODE])
117 + async def test_a_recovered_instance_rejoins_without_re_registering(
118 + self, registry, role
119 + ):
120 + """Restarting every encoder to recover from a blip is not acceptable."""
121 + registry.register(InstanceRecord(role, "http://e0:8000"))
122 + for _ in range(registry._fail_threshold):
123 + await _probe_round(registry, healthy=set())
124 + assert registry.urls(role) == []
125 +
126 + await _probe_round(registry, healthy={"http://e0:8000"}, now=1)
127 + assert registry.urls(role) == ["http://e0:8000"]
128 +
129 + @pytest.mark.asyncio
130 + async def test_re_registration_does_not_override_failed_health_checks(
131 + self, registry
132 + ):
133 + record = InstanceRecord(ENCODE, "http://e0:8000")
134 + registry.register(record)
135 + for _ in range(registry._fail_threshold):
136 + await _probe_round(registry, healthy=set())
137 + registry.register(record)
138 + assert registry.urls(ENCODE) == []
139 +
140 + @pytest.mark.asyncio
141 + @pytest.mark.parametrize("healthy", [False, True])
142 + async def test_inflight_probe_cannot_restore_unregistered_instance(
143 + self, registry, healthy
144 + ):
145 + record = InstanceRecord(ENCODE, "http://e0:8000")
146 + registry.register(record)
147 + for _ in range(registry._fail_threshold):
148 + await _probe_round(registry, healthy=set())
149 +
150 + async def probe(self, session, url):
151 + registry.unregister(url)
152 + return healthy
153 +
154 + with patch.object(InstanceRegistry, "_probe", probe):
155 + await registry._probe_once(None)
156 + assert registry.status()["encode"] == {"live": [], "evicted": []}