[PD] Introduce runtime role switching between prefill and decode (#28403)

Signed-off-by: huanglong <huanglong@linux.alibaba.com>
Signed-off-by: inkcherry <mingzhi.liu@amd.com>
Co-authored-by: huanglong <huanglong@linux.alibaba.com>
Co-authored-by: Shangming Cai <csmthu@gmail.com>
Co-authored-by: Huang Long <121648372+LLLL114@users.noreply.github.com>
This commit is contained in:
inkcherry
2026-09-18 01:45:12 +08:00
committed by GitHub
co-authored by huanglong Shangming Cai Huang Long
parent a98d921658
commit 1f60ddef5d
27 changed files with 1737 additions and 55 deletions
@@ -111,6 +111,42 @@ class MiniLoadBalancer:
self.decode_urls[didx],
)
def current_role_and_port(self, worker_url):
"""Return (role, bootstrap_port) of a registered server, or (None, None)
if it is not in either routing list."""
if worker_url in self.prefill_urls:
return (
"prefill",
self.prefill_bootstrap_ports[self.prefill_urls.index(worker_url)],
)
if worker_url in self.decode_urls:
return "decode", None
return None, None
def remove_worker(self, worker_url):
"""Drop a server from both routing lists so no new requests are sent to
it (used to quiesce it before a role switch)."""
if worker_url in self.decode_urls:
self.decode_urls.remove(worker_url)
if worker_url in self.prefill_urls:
idx = self.prefill_urls.index(worker_url)
self.prefill_urls.pop(idx)
self.prefill_bootstrap_ports.pop(idx)
def add_worker(self, worker_url, role, bootstrap_port=None):
"""Register a server under a role in the routing lists."""
if role == "prefill":
self.prefill_urls.append(worker_url)
self.prefill_bootstrap_ports.append(bootstrap_port or 8998)
elif role == "decode":
self.decode_urls.append(worker_url)
def apply_role_switch(self, worker_url, new_role, bootstrap_port=None):
"""Move a server between the prefill and decode routing lists after its
role has been switched on the backend. Idempotent."""
self.remove_worker(worker_url)
self.add_worker(worker_url, new_role, bootstrap_port)
async def generate(
self, modified_request, prefill_server, decode_server, endpoint
) -> ORJSONResponse:
@@ -255,6 +291,69 @@ async def health_generate():
return Response(status_code=200)
async def _post_role_switch(worker_url, body):
"""POST the role switch to a backend server; return (status, json)."""
try:
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=lb.timeout)
) as session:
async with session.post(f"{worker_url}/pd_role_switch", json=body) as resp:
return resp.status, await resp.json()
except Exception as e: # transport error -> report as a failure
return 502, {"success": False, "message": str(e)}
@app.post("/pd_role_switch")
async def pd_role_switch(request_data: dict):
"""Switch a running server's PD role (prefill<->decode) at runtime and
update the LB's routing lists. Body: {"worker_url", "new_role":
"prefill"|"decode", "bootstrap_port"?, "decode_cuda_graph_bs"?,
"decode_cuda_graph_memory_gb"?, "drain"?, "drain_timeout_secs"?}.
The backend rejects a switch unless the instance is idle. To make this
safe while serving, by default the LB first removes the server from its
routing lists (so no new requests arrive), then retries the switch while
the server drains its in-flight requests, and only then registers it
under the new role. A failed server is restored only when the backend
confirms that no role state changed."""
worker_url = request_data.get("worker_url")
new_role = request_data.get("new_role")
if worker_url is None:
raise HTTPException(status_code=400, detail="worker_url is required")
if new_role not in ("prefill", "decode"):
raise HTTPException(status_code=400, detail=f"invalid new_role={new_role!r}")
drain = request_data.get("drain", True)
drain_timeout = request_data.get("drain_timeout_secs", 300)
old_role, old_port = lb.current_role_and_port(worker_url)
body = {"new_role": new_role}
for field in ("decode_cuda_graph_bs", "decode_cuda_graph_memory_gb"):
if request_data.get(field) is not None:
body[field] = request_data[field]
# Stop routing new requests to this server so it can drain to idle.
if drain and old_role is not None:
lb.remove_worker(worker_url)
deadline = asyncio.get_event_loop().time() + drain_timeout
while True:
status, result = await _post_role_switch(worker_url, body)
if status == 200 and result.get("success", False):
break
# The backend rejects while not idle; keep retrying as it drains.
not_idle = "not idle" in (result.get("message", "") or "").lower()
if drain and not_idle and asyncio.get_event_loop().time() < deadline:
await asyncio.sleep(1.0)
continue
if drain and old_role is not None and result.get("safe_to_restore", False):
lb.add_worker(worker_url, old_role, old_port)
return ORJSONResponse(content=result, status_code=status)
lb.apply_role_switch(worker_url, new_role, request_data.get("bootstrap_port"))
return ORJSONResponse(content=result, status_code=200)
@app.post("/flush_cache")
async def flush_cache(timeout: Optional[float] = None):
# `timeout` must reach the workers. The scheduler treats a missing or