@@ -190,6 +190,91 @@ def _tail(path: Path, n: int = 40) -> str:
190190 return ""
191191
192192
193+ def _serve (model_path : str , port : int , continuous : bool , log_path : Path ) -> subprocess .Popen :
194+ argv = ["vllm-mlx" , "serve" , model_path , "--host" , "127.0.0.1" ,
195+ "--port" , str (port ), "--max-request-tokens" , "32768" ]
196+ if continuous :
197+ argv += ["--continuous-batching" , "--use-paged-cache" ]
198+ _log (("continuous" if continuous else "simple" ) + " serve: " + " " .join (argv ))
199+ with open (log_path , "wb" ) as logf :
200+ return subprocess .Popen (argv , stdout = logf , stderr = subprocess .STDOUT )
201+
202+
203+ def _run_phase (
204+ model_path : str , continuous : bool , sessions : int , haystack_lines : int ,
205+ max_new_tokens : int , server_timeout : float , req_timeout : float ,
206+ ) -> Dict [str , Any ]:
207+ """Launch one vLLM-MLX server (simple or continuous-batching) and run a
208+ sessions-way concurrent NIAH against it. Returns a result dict."""
209+ phase : Dict [str , Any ] = {
210+ "continuous_batching" : continuous , "sessions" : sessions , "status" : "init" ,
211+ }
212+ port = _free_port ()
213+ base_url = f"http://127.0.0.1:{ port } "
214+ log_path = Path (tempfile .gettempdir ()) / f"vllm_mlx_serve_{ port } .log"
215+ proc : Optional [subprocess .Popen ] = None
216+ try :
217+ proc = _serve (model_path , port , continuous , log_path )
218+ ready , detail = _wait_for_server (base_url , proc , server_timeout )
219+ if not ready :
220+ phase .update (status = "server_failed" , error = detail ,
221+ server_log_tail = _tail (log_path ))
222+ return phase
223+ model_id = _get_model_id (base_url ) or "default"
224+ items = _build_niah_items (sessions , haystack_lines )
225+ _post_generate (base_url , model_id , "Reply with the word ready." , 8 , req_timeout )
226+
227+ results : List [Any ] = [None ] * len (items )
228+ t0 = time .time ()
229+ with ThreadPoolExecutor (max_workers = max (1 , len (items ))) as ex :
230+ futs = {ex .submit (_post_generate , base_url , model_id , it ["prompt" ],
231+ max_new_tokens , req_timeout ): k
232+ for k , it in enumerate (items )}
233+ for fut in futs :
234+ k = futs [fut ]
235+ try :
236+ results [k ] = fut .result ()
237+ except Exception as exc : # noqa: BLE001
238+ results [k ] = (False , "" , 0 , 0.0 , f"{ type (exc ).__name__ } : { exc } " , "none" )
239+ wall = max (time .time () - t0 , 1e-6 )
240+
241+ per_session , hits , total_ctoks , n_ok = [], 0 , 0 , 0
242+ endpoint = "none"
243+ for it , res in zip (items , results ):
244+ ok , text , ctoks , lat , err , ep = res
245+ endpoint = ep if ep != "none" else endpoint
246+ found = ok and (it ["code" ] in (text or "" ))
247+ hits += 1 if found else 0
248+ total_ctoks += ctoks if ok else 0
249+ n_ok += 1 if ok else 0
250+ per_session .append ({
251+ "session" : it ["session" ], "ok" : ok , "needle_found" : found ,
252+ "expected_code" : it ["code" ], "answer_excerpt" : (text or "" )[:80 ],
253+ "completion_tokens" : ctoks , "latency_s" : round (lat , 3 ), "error" : err ,
254+ })
255+ phase .update (
256+ status = "ok" , endpoint_used = endpoint ,
257+ recall = round (hits / len (items ), 4 ) if items else 0.0 ,
258+ sessions_ok = n_ok , total_completion_tokens = total_ctoks ,
259+ aggregate_decode_tps = round (total_ctoks / wall , 2 ),
260+ wall_s = round (wall , 3 ), per_session = per_session ,
261+ server_log_tail = _tail (log_path , 12 ),
262+ )
263+ return phase
264+ except Exception as exc : # noqa: BLE001
265+ phase .update (status = "error" , error = f"{ type (exc ).__name__ } : { exc } " ,
266+ server_log_tail = _tail (log_path ))
267+ return phase
268+ finally :
269+ if proc is not None and proc .poll () is None :
270+ proc .terminate ()
271+ try :
272+ proc .wait (timeout = 20 )
273+ except Exception :
274+ proc .kill ()
275+ time .sleep (2 ) # let the port/Metal context release before the next phase
276+
277+
193278def main () -> int :
194279 ap = argparse .ArgumentParser (description = "vLLM-MLX parallel NIAH probe" )
195280 ap .add_argument ("--model-path" , required = True ,
@@ -232,138 +317,37 @@ def _flush(status: str, **extra: Any) -> None:
232317 return 0
233318 _log (f"vllm-mlx version: { report ['vllm_mlx_version' ]} " )
234319
235- port = _free_port ()
236- base_url = f"http://127.0.0.1:{ port } "
237- log_path = Path (tempfile .gettempdir ()) / f"vllm_mlx_serve_{ port } .log"
238- serve_argv = [
239- "vllm-mlx" , "serve" , args .model_path ,
240- "--host" , "127.0.0.1" , "--port" , str (port ),
241- "--continuous-batching" , "--use-paged-cache" ,
242- "--max-request-tokens" , "32768" ,
243- ]
244- _log ("launching: " + " " .join (serve_argv ))
245- proc : Optional [subprocess .Popen ] = None
246- try :
247- with open (log_path , "wb" ) as logf :
248- proc = subprocess .Popen (serve_argv , stdout = logf , stderr = subprocess .STDOUT )
249-
250- ready , detail = _wait_for_server (base_url , proc , args .server_timeout )
251- if not ready :
252- _flush ("server_failed" ,
253- error = f"server not ready: { detail } " ,
254- server_log_tail = _tail (log_path ))
255- return 0
256- _log (f"server ready ({ detail } )" )
257-
258- model_id = _get_model_id (base_url ) or "default"
259- report ["served_model_id" ] = model_id
260- _log (f"served model id: { model_id } " )
261-
262- # DEBUG: capture the raw server response for a simple control prompt, so
263- # we can see finish_reason / structure (diagnose the empty-answer issue).
264- for dbgname , dbgbody in (
265- ("debug_simple_chat" , {
266- "model" : model_id ,
267- "messages" : [{"role" : "user" ,
268- "content" : "What is the capital of France? Answer in one short sentence." }],
269- "max_tokens" : 32 , "temperature" : 0.0 }),
270- ("debug_simple_completion" , {
271- "model" : model_id ,
272- "prompt" : "<start_of_turn>user\n What is the capital of France?<end_of_turn>\n <start_of_turn>model\n " ,
273- "max_tokens" : 32 , "temperature" : 0.0 , "stop" : ["<end_of_turn>" ]}),
274- ):
275- path = ("/v1/chat/completions" if "chat" in dbgname
276- else "/v1/completions" )
277- try :
278- dbg = _post (base_url , path , dbgbody , args .req_timeout )
279- report [dbgname ] = json .dumps (dbg )[:900 ]
280- except urllib .error .HTTPError as exc :
281- report [dbgname ] = f"HTTP { exc .code } : { exc .read ().decode ('utf-8' ,'replace' )[:300 ]} "
282- except Exception as exc : # noqa: BLE001
283- report [dbgname ] = f"{ type (exc ).__name__ } : { exc } "
284- _log (f"{ dbgname } : { report [dbgname ][:300 ]} " )
285-
286- items = _build_niah_items (args .sessions , args .haystack_lines )
287-
288- # Warmup (lazy MLX graph compile) — not measured.
289- _post_generate (base_url , model_id , "Reply with the word ready." ,
290- 8 , args .req_timeout )
291-
292- # N=1 baseline (single request decode tok/s).
293- ok0 , text0 , ct0 , lat0 , err0 , ep0 = _post_generate (
294- base_url , model_id , items [0 ]["prompt" ], args .max_new_tokens ,
295- args .req_timeout )
296- n1_tps = (ct0 / lat0 ) if (ok0 and lat0 > 0 ) else 0.0
297- report ["endpoint_used" ] = ep0
298- if not ok0 :
299- report ["n1_error" ] = err0
300-
301- # N concurrent (the parallel path — continuous batching).
302- results : List [Optional [Tuple [bool , str , int , float , str , str ]]] = [None ] * len (items )
303- t0 = time .time ()
304- with ThreadPoolExecutor (max_workers = len (items )) as ex :
305- futs = {
306- ex .submit (_post_generate , base_url , model_id , it ["prompt" ],
307- args .max_new_tokens , args .req_timeout ): k
308- for k , it in enumerate (items )
309- }
310- for fut in futs :
311- k = futs [fut ]
312- try :
313- results [k ] = fut .result ()
314- except Exception as exc : # noqa: BLE001
315- results [k ] = (False , "" , 0 , 0.0 , f"{ type (exc ).__name__ } : { exc } " , "none" )
316- wall = max (time .time () - t0 , 1e-6 )
317-
318- per_session : List [Dict [str , Any ]] = []
319- hits = 0
320- total_ctoks = 0
321- n_ok = 0
322- for k , (it , res ) in enumerate (zip (items , results )):
323- ok , text , ctoks , lat , err , _ep = res # type: ignore[misc]
324- found = ok and (it ["code" ] in (text or "" ))
325- hits += 1 if found else 0
326- total_ctoks += ctoks if ok else 0
327- n_ok += 1 if ok else 0
328- per_session .append ({
329- "session" : it ["session" ], "ok" : ok , "needle_found" : found ,
330- "expected_code" : it ["code" ],
331- "answer_excerpt" : (text or "" )[:80 ], "completion_tokens" : ctoks ,
332- "latency_s" : round (lat , 3 ), "error" : err ,
333- })
334-
335- recall = hits / len (items ) if items else 0.0
336- agg_tps = total_ctoks / wall
337- _flush (
338- "ok" ,
339- recall = round (recall , 4 ),
340- sessions_ok = n_ok ,
341- n1_decode_tps = round (n1_tps , 2 ),
342- aggregate_decode_tps = round (agg_tps , 2 ),
343- parallel_speedup_vs_n1 = round (agg_tps / n1_tps , 3 ) if n1_tps > 0 else None ,
344- concurrent_wall_s = round (wall , 3 ),
345- total_completion_tokens = total_ctoks ,
346- per_session = per_session ,
347- server_log_tail = _tail (log_path , 20 ),
348- )
349- verdict = (
350- f"recall={ recall :.3f} ({ hits } /{ len (items )} ), "
351- f"agg_decode={ agg_tps :.1f} tok/s, N=1={ n1_tps :.1f} tok/s, "
352- f"parallel={ 'YES' if agg_tps > n1_tps else 'no-gain' } "
353- )
354- _log ("VERDICT: " + verdict )
355- return 0
356- except Exception as exc : # noqa: BLE001
357- _flush ("error" , error = f"{ type (exc ).__name__ } : { exc } " ,
358- server_log_tail = _tail (log_path ))
359- return 0
360- finally :
361- if proc is not None and proc .poll () is None :
362- proc .terminate ()
363- try :
364- proc .wait (timeout = 20 )
365- except Exception :
366- proc .kill ()
320+ # Phase A — SIMPLE mode (no continuous batching): single-stream control. If
321+ # gemma-4 generates here but fails under batching, the failure is isolated to
322+ # vLLM-MLX's continuous-batching adapter, not model loading.
323+ _log ("=== Phase A: simple mode (single-stream control, N=1) ===" )
324+ simple = _run_phase (
325+ args .model_path , continuous = False , sessions = 1 ,
326+ haystack_lines = args .haystack_lines , max_new_tokens = args .max_new_tokens ,
327+ server_timeout = args .server_timeout , req_timeout = args .req_timeout )
328+ report ["simple_mode" ] = simple
329+
330+ # Phase B — CONTINUOUS BATCHING (the parallel path under test): N sessions.
331+ _log (f"=== Phase B: continuous batching, N={ args .sessions } ===" )
332+ batched = _run_phase (
333+ args .model_path , continuous = True , sessions = args .sessions ,
334+ haystack_lines = args .haystack_lines , max_new_tokens = args .max_new_tokens ,
335+ server_timeout = args .server_timeout , req_timeout = args .req_timeout )
336+ report ["continuous_batching_mode" ] = batched
337+
338+ # Verdict: parallel AND recall-preserving on our config?
339+ simple_gen = simple .get ("status" ) == "ok" and simple .get ("total_completion_tokens" , 0 ) > 0
340+ batched_recall = batched .get ("recall" , 0.0 ) if batched .get ("status" ) == "ok" else 0.0
341+ batched_gen = batched .get ("status" ) == "ok" and batched .get ("total_completion_tokens" , 0 ) > 0
342+ report ["verdict" ] = {
343+ "single_stream_generates" : bool (simple_gen ),
344+ "batched_generates" : bool (batched_gen ),
345+ "batched_recall" : batched_recall ,
346+ "parallel_and_recall_preserving" : bool (batched_gen and batched_recall >= 0.99 ),
347+ }
348+ _flush ("ok" )
349+ _log (f"VERDICT: { json .dumps (report ['verdict' ])} " )
350+ return 0
367351
368352
369353if __name__ == "__main__" :
0 commit comments