Skip to content

Commit b6afefa

Browse files
feat(vllm-mlx-niah): two-phase A/B — simple single-stream control vs continuous-batching N=8
Run 4 evidence: vllm-mlx 0.3.0 errors under continuous batching on gemma-4 (patch_gemma4_attention_for_batching got unexpected kwarg 'shared_kv' → finish_reason=error, 0 tokens). Now run BOTH phases in one go: Phase A simple mode (no continuous batching, N=1) isolates whether gemma-4 generates at all, Phase B continuous batching N=8 is the parallel+recall test. Emits a verdict: single_stream_generates / batched_generates / batched_recall / parallel_and_recall_preserving. Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com>
1 parent ace720f commit b6afefa

1 file changed

Lines changed: 116 additions & 132 deletions

File tree

scripts/research/vllm_mlx_niah_bench.py

Lines changed: 116 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
193278
def 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\nWhat 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

369353
if __name__ == "__main__":

0 commit comments

Comments
 (0)