main
py 284 lines 8.81 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import argparse
5 import itertools
6 import logging
7 import os
8 import uuid
9 from contextlib import asynccontextmanager
10
11 import httpx
12 from fastapi import FastAPI, Request
13 from fastapi.responses import StreamingResponse
14
15 logger = logging.getLogger(__name__)
16 logger.setLevel(logging.DEBUG)
17
18
19 @asynccontextmanager
20 async def lifespan(app: FastAPI):
21 """Lifespan context manager to handle startup and shutdown events."""
22 # Startup: Initialize client pools for prefiller and decoder services
23 app.state.prefill_clients = []
24 app.state.decode_clients = []
25
26 # Create prefill clients
27 for i, (host, port) in enumerate(global_args.prefiller_instances):
28 prefiller_base_url = f"http://{host}:{port}/v1"
29 app.state.prefill_clients.append(
30 {
31 "client": httpx.AsyncClient(
32 timeout=None,
33 base_url=prefiller_base_url,
34 limits=httpx.Limits(
35 max_connections=None,
36 max_keepalive_connections=None,
37 ),
38 ),
39 "host": host,
40 "port": port,
41 "id": i,
42 }
43 )
44
45 # Create decode clients
46 for i, (host, port) in enumerate(global_args.decoder_instances):
47 decoder_base_url = f"http://{host}:{port}/v1"
48 app.state.decode_clients.append(
49 {
50 "client": httpx.AsyncClient(
51 timeout=None,
52 base_url=decoder_base_url,
53 limits=httpx.Limits(
54 max_connections=None,
55 max_keepalive_connections=None,
56 ),
57 ),
58 "host": host,
59 "port": port,
60 "id": i,
61 }
62 )
63
64 # Initialize round-robin iterators
65 app.state.prefill_iterator = itertools.cycle(range(len(app.state.prefill_clients)))
66 app.state.decode_iterator = itertools.cycle(range(len(app.state.decode_clients)))
67
68 print(
69 f"Initialized {len(app.state.prefill_clients)} prefill clients "
70 f"and {len(app.state.decode_clients)} decode clients."
71 )
72
73 yield
74
75 # Shutdown: Close all clients
76 for client_info in app.state.prefill_clients:
77 await client_info["client"].aclose()
78
79 for client_info in app.state.decode_clients:
80 await client_info["client"].aclose()
81
82
83 # Update FastAPI app initialization to use lifespan
84 app = FastAPI(lifespan=lifespan)
85
86
87 def parse_args():
88 parser = argparse.ArgumentParser()
89
90 parser.add_argument("--port", type=int, default=8000)
91 # Always use 127.0.0.1 as localhost binds to IPv6 which is blocked on CI
92 parser.add_argument("--host", type=str, default="127.0.0.1")
93
94 # For prefiller instances
95 parser.add_argument(
96 "--prefiller-hosts",
97 "--prefiller-host",
98 type=str,
99 nargs="+",
100 default=["localhost"],
101 )
102 parser.add_argument(
103 "--prefiller-ports", "--prefiller-port", type=int, nargs="+", default=[8100]
104 )
105
106 # For decoder instances
107 parser.add_argument(
108 "--decoder-hosts", "--decoder-host", type=str, nargs="+", default=["localhost"]
109 )
110 parser.add_argument(
111 "--decoder-ports", "--decoder-port", type=int, nargs="+", default=[8200]
112 )
113
114 args = parser.parse_args()
115
116 # Validate and pair hosts with ports
117 if len(args.prefiller_hosts) != len(args.prefiller_ports):
118 raise ValueError(
119 "Number of prefiller hosts must match number of prefiller ports"
120 )
121
122 if len(args.decoder_hosts) != len(args.decoder_ports):
123 raise ValueError("Number of decoder hosts must match number of decoder ports")
124
125 # Create tuples of (host, port) for each service type
126 args.prefiller_instances = list(zip(args.prefiller_hosts, args.prefiller_ports))
127 args.decoder_instances = list(zip(args.decoder_hosts, args.decoder_ports))
128
129 return args
130
131
132 def get_next_client(app, service_type: str):
133 """Get the next client in round-robin fashion.
134
135 Args:
136 app: The FastAPI app instance
137 service_type: Either 'prefill' or 'decode'
138
139 Returns:
140 The next client to use
141
142 """
143 if service_type == "prefill":
144 client_idx = next(app.state.prefill_iterator)
145 return app.state.prefill_clients[client_idx]
146 elif service_type == "decode":
147 client_idx = next(app.state.decode_iterator)
148 return app.state.decode_clients[client_idx]
149 else:
150 raise ValueError(f"Unknown service type: {service_type}")
151
152
153 async def send_request_to_service(
154 client_info: dict, endpoint: str, req_data: dict, request_id: str
155 ):
156 """Send a request to a service using a client from the pool."""
157 req_data = req_data.copy()
158 req_data["kv_transfer_params"] = {
159 "do_remote_decode": True,
160 "do_remote_prefill": False,
161 "remote_engine_id": None,
162 "remote_block_ids": None,
163 "remote_host": None,
164 "remote_port": None,
165 }
166 req_data["stream"] = False
167 req_data["max_tokens"] = 1
168 if "max_completion_tokens" in req_data:
169 req_data["max_completion_tokens"] = 1
170 if "stream_options" in req_data:
171 del req_data["stream_options"]
172 # These args are not supported for P
173 min_tokens = req_data.pop("min_tokens", None)
174 min_completion_tokens = req_data.pop("min_completion_tokens", None)
175 headers = {
176 "Authorization": f"Bearer {os.environ.get('OPENAI_API_KEY')}",
177 "X-Request-Id": request_id,
178 }
179
180 response = await client_info["client"].post(
181 endpoint, json=req_data, headers=headers
182 )
183 response.raise_for_status()
184
185 # read/consume the response body to release the connection
186 # otherwise, it would http.ReadError
187 await response.aread()
188
189 # Add back the min_tokens and min_completion_tokens so D can use them
190 req_data["min_tokens"] = min_tokens
191 req_data["min_completion_tokens"] = min_completion_tokens
192
193 return response
194
195
196 async def stream_service_response(
197 client_info: dict, endpoint: str, req_data: dict, request_id: str
198 ):
199 """Asynchronously stream response from a service using a client from the pool."""
200 headers = {
201 "Authorization": f"Bearer {os.environ.get('OPENAI_API_KEY')}",
202 "X-Request-Id": request_id,
203 }
204
205 async with client_info["client"].stream(
206 "POST", endpoint, json=req_data, headers=headers
207 ) as response:
208 response.raise_for_status()
209 async for chunk in response.aiter_bytes():
210 yield chunk
211
212
213 async def _handle_completions(api: str, request: Request):
214 try:
215 req_data = await request.json()
216 request_id = str(uuid.uuid4())
217
218 # Get the next prefill client in round-robin fashion
219 prefill_client_info = get_next_client(request.app, "prefill")
220
221 # Send request to prefill service
222 response = await send_request_to_service(
223 prefill_client_info, api, req_data, request_id
224 )
225
226 # Extract the needed fields
227 response_json = response.json()
228 await response.aclose() # CRITICAL: Release connection back to pool
229 kv_transfer_params = response_json.get("kv_transfer_params", {})
230 if kv_transfer_params:
231 req_data["kv_transfer_params"] = kv_transfer_params
232
233 # Get the next decode client in round-robin fashion
234 decode_client_info = get_next_client(request.app, "decode")
235
236 logger.debug("Using %s %s", prefill_client_info, decode_client_info)
237
238 # Stream response from decode service
239 async def generate_stream():
240 async for chunk in stream_service_response(
241 decode_client_info, api, req_data, request_id=request_id
242 ):
243 yield chunk
244
245 return StreamingResponse(generate_stream(), media_type="application/json")
246
247 except Exception as e:
248 import sys
249 import traceback
250
251 exc_info = sys.exc_info()
252 print(f"Error occurred in disagg prefill proxy server - {api} endpoint")
253 print(e)
254 print("".join(traceback.format_exception(*exc_info)))
255 raise
256
257
258 @app.post("/v1/completions")
259 async def handle_completions(request: Request):
260 return await _handle_completions("/completions", request)
261
262
263 @app.post("/v1/chat/completions")
264 async def handle_chat_completions(request: Request):
265 return await _handle_completions("/chat/completions", request)
266
267
268 @app.get("/healthcheck")
269 async def healthcheck():
270 """Simple endpoint to check if the server is running."""
271 return {
272 "status": "ok",
273 "prefill_instances": len(app.state.prefill_clients),
274 "decode_instances": len(app.state.decode_clients),
275 }
276
277
278 if __name__ == "__main__":
279 global global_args
280 global_args = parse_args()
281
282 import uvicorn
283
284 uvicorn.run(app, host=global_args.host, port=global_args.port)