main
py 472 lines 16 KB
Raw
1 #!/usr/bin/env python3
2 # SPDX-License-Identifier: Apache-2.0
3 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
4
5 """Select one measured scheduler recommendation from vLLM sweep results."""
6
7 from __future__ import annotations
8
9 import argparse
10 import json
11 from collections import defaultdict
12 from pathlib import Path
13 from statistics import mean, median
14 from typing import Any
15
16 try:
17 import yaml
18 except ImportError as exc:
19 raise SystemExit("PyYAML is required. Install it with: pip install pyyaml") from exc
20
21
22 DEFAULT_CONFIG_PATH: str | None = None
23 DEFAULT_ENV_PATH: str | None = None
24 DEFAULT_TTFT_SLA_MS: float | None = None
25 DEFAULT_TPOT_SLA_MS: float | None = None
26 DEFAULT_MINIMUM_COMPLIANCE: float = 0.99
27
28
29 def _number(value: object) -> float | None:
30 if isinstance(value, bool) or not isinstance(value, (int, float)):
31 return None
32 return float(value)
33
34
35 def _mean(rows: list[dict[str, Any]], key: str) -> float | None:
36 values = [_number(row.get(key)) for row in rows]
37 numbers = [value for value in values if value is not None]
38 return mean(numbers) if numbers else None
39
40
41 def _percentile_summary(
42 rows: list[dict[str, Any]], key: str
43 ) -> tuple[float | None, float | None, float | None]:
44 values = [_number(row.get(key)) for row in rows]
45 numbers = [value for value in values if value is not None]
46 if not numbers:
47 return None, None, None
48 return mean(numbers), median(numbers), max(numbers)
49
50
51 def _positive_int(row: dict[str, Any], key: str) -> int | None:
52 value = row.get(key)
53 if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
54 return None
55 return value
56
57
58 def _resolve(script_dir: Path, value: str | None) -> Path | None:
59 if value is None:
60 return None
61 path = Path(value)
62 return path if path.is_absolute() else script_dir / path
63
64
65 def _load_runs(results_dir: Path) -> list[dict[str, Any]]:
66 rows: list[dict[str, Any]] = []
67 for path in sorted(results_dir.rglob("summary.json")):
68 data = json.loads(path.read_text(encoding="utf-8"))
69 if not isinstance(data, list):
70 continue
71 for row in data:
72 if isinstance(row, dict):
73 copied = dict(row)
74 copied["_summary_path"] = str(path)
75 rows.append(copied)
76 return rows
77
78
79 def _aggregate_candidates(
80 rows: list[dict[str, Any]],
81 *,
82 use_goodput: bool,
83 ttft_sla_ms: float | None = None,
84 tpot_sla_ms: float | None = None,
85 minimum_compliance: float = DEFAULT_MINIMUM_COMPLIANCE,
86 ) -> list[dict[str, Any]]:
87 grouped: dict[tuple[int, int], list[dict[str, Any]]] = defaultdict(list)
88
89 for row in rows:
90 seqs = _positive_int(row, "max_num_seqs")
91 batch = _positive_int(row, "max_num_batched_tokens")
92 if seqs is None or batch is None:
93 continue
94 grouped[(seqs, batch)].append(row)
95
96 candidates: list[dict[str, Any]] = []
97 for (seqs, batch), runs in sorted(grouped.items()):
98 failed_requests = sum(int(_number(run.get("failed")) or 0) for run in runs)
99 output_throughput = _mean(runs, "output_throughput")
100 request_throughput = _mean(runs, "request_throughput")
101 request_goodput = _mean(runs, "request_goodput")
102 mean_p99_ttft_ms, median_p99_ttft_ms, worst_p99_ttft_ms = _percentile_summary(
103 runs, "p99_ttft_ms"
104 )
105 mean_p99_tpot_ms, median_p99_tpot_ms, worst_p99_tpot_ms = _percentile_summary(
106 runs, "p99_tpot_ms"
107 )
108
109 completed_requests = sum(
110 int(_number(run.get("completed")) or 0) for run in runs
111 )
112 estimated_compliant_requests = sum(
113 (_number(run.get("request_goodput")) or 0.0)
114 * (_number(run.get("duration")) or 0.0)
115 for run in runs
116 )
117 combined_compliance_ratio = (
118 estimated_compliant_requests / completed_requests
119 if completed_requests > 0
120 else None
121 )
122
123 valid = failed_requests == 0 and output_throughput is not None
124 reason = None
125 if failed_requests:
126 valid = False
127 reason = f"{failed_requests} failed request(s) across repeated runs"
128 elif use_goodput and request_goodput is None:
129 valid = False
130 reason = "request_goodput is missing; rerun sweep with SLO goodput enabled"
131
132 p99_sla_eligible = valid
133 if ttft_sla_ms is not None:
134 p99_sla_eligible = bool(
135 p99_sla_eligible
136 and median_p99_ttft_ms is not None
137 and median_p99_ttft_ms <= ttft_sla_ms
138 )
139 if tpot_sla_ms is not None:
140 p99_sla_eligible = bool(
141 p99_sla_eligible
142 and median_p99_tpot_ms is not None
143 and median_p99_tpot_ms <= tpot_sla_ms
144 )
145 compliance_eligible = bool(
146 valid
147 and combined_compliance_ratio is not None
148 and combined_compliance_ratio >= minimum_compliance
149 )
150
151 candidates.append(
152 {
153 "max_num_seqs": seqs,
154 "max_num_batched_tokens": batch,
155 "run_count": len(runs),
156 "failed_requests": failed_requests,
157 "mean_request_goodput": request_goodput,
158 "mean_request_throughput": request_throughput,
159 "mean_output_throughput": output_throughput,
160 "mean_p99_ttft_ms": mean_p99_ttft_ms,
161 "median_p99_ttft_ms": median_p99_ttft_ms,
162 "worst_p99_ttft_ms": worst_p99_ttft_ms,
163 "mean_p99_tpot_ms": mean_p99_tpot_ms,
164 "median_p99_tpot_ms": median_p99_tpot_ms,
165 "worst_p99_tpot_ms": worst_p99_tpot_ms,
166 "estimated_compliant_requests": estimated_compliant_requests,
167 "completed_requests": completed_requests,
168 "combined_compliance_ratio": combined_compliance_ratio,
169 "combined_compliance_percent": (
170 combined_compliance_ratio * 100
171 if combined_compliance_ratio is not None
172 else None
173 ),
174 "benchmark_valid": valid,
175 "p99_sla_eligible": p99_sla_eligible,
176 "compliance_eligible": compliance_eligible,
177 "sla_eligible": p99_sla_eligible and compliance_eligible,
178 "valid": valid,
179 "invalid_reason": reason,
180 "summary_files": sorted({str(run["_summary_path"]) for run in runs}),
181 }
182 )
183
184 return candidates
185
186
187 def _select_candidate(
188 candidates: list[dict[str, Any]],
189 *,
190 use_goodput: bool,
191 ) -> tuple[dict[str, Any] | None, dict[str, Any], str]:
192 valid = [candidate for candidate in candidates if candidate["valid"]]
193 if not valid:
194 raise ValueError(
195 "No valid sweep configuration was found. Inspect the sweep results "
196 "for failed requests or missing metrics."
197 )
198
199 if use_goodput:
200 objective = "p99_constrained_throughput"
201 best_effort = max(
202 valid,
203 key=lambda candidate: (
204 candidate["mean_request_goodput"],
205 candidate["combined_compliance_ratio"] or 0.0,
206 candidate["mean_output_throughput"],
207 -candidate["max_num_batched_tokens"],
208 -candidate["max_num_seqs"],
209 ),
210 )
211 if best_effort["mean_request_goodput"] <= 0:
212 raise ValueError(
213 "No sweep configuration produced non-zero request goodput for "
214 "the supplied TTFT/TPOT objectives."
215 )
216 eligible = [candidate for candidate in valid if candidate["sla_eligible"]]
217 winner = (
218 max(
219 eligible,
220 key=lambda candidate: (
221 candidate["mean_output_throughput"],
222 candidate["combined_compliance_ratio"],
223 candidate["mean_request_goodput"],
224 -candidate["max_num_batched_tokens"],
225 -candidate["max_num_seqs"],
226 ),
227 )
228 if eligible
229 else None
230 )
231 else:
232 objective = "highest_mean_output_throughput"
233 winner = max(
234 valid,
235 key=lambda candidate: (
236 candidate["mean_output_throughput"],
237 -candidate["max_num_batched_tokens"],
238 -candidate["max_num_seqs"],
239 ),
240 )
241 best_effort = winner
242
243 return winner, best_effort, objective
244
245
246 def _load_config(path: Path) -> dict[str, Any]:
247 data = yaml.safe_load(path.read_text(encoding="utf-8"))
248 if not isinstance(data, dict):
249 raise ValueError(f"{path} does not contain a YAML configuration object.")
250 return data
251
252
253 def _write_config(
254 path: Path,
255 *,
256 source_path: Path,
257 config: dict[str, Any],
258 objective: str,
259 ) -> None:
260 body = yaml.safe_dump(
261 config,
262 sort_keys=False,
263 default_flow_style=False,
264 allow_unicode=True,
265 )
266 header = (
267 "# Generated from the initial recipe config after a vLLM parameter sweep.\n"
268 f"# Initial config: {source_path}\n"
269 f"# Selection objective: {objective}\n"
270 )
271 path.write_text(header + body, encoding="utf-8")
272
273
274 def parse_args() -> argparse.Namespace:
275 parser = argparse.ArgumentParser(
276 description=(
277 "Select one recommended max-num-seqs/max-num-batched-tokens pair "
278 "from generated vLLM sweep results."
279 )
280 )
281 parser.add_argument(
282 "--results-dir",
283 default="results/runtime-tuning",
284 help="Sweep experiment directory (default: results/runtime-tuning).",
285 )
286 parser.add_argument(
287 "--config",
288 default=DEFAULT_CONFIG_PATH,
289 help="Initial config.yml used by the sweep.",
290 )
291 parser.add_argument(
292 "--env",
293 default=DEFAULT_ENV_PATH,
294 help="Environment file to show in the deployment instructions.",
295 )
296 parser.add_argument(
297 "--ttft-sla-ms",
298 type=float,
299 default=DEFAULT_TTFT_SLA_MS,
300 help="TTFT objective used to generate request goodput.",
301 )
302 parser.add_argument(
303 "--tpot-sla-ms",
304 type=float,
305 default=DEFAULT_TPOT_SLA_MS,
306 help="TPOT objective used to generate request goodput.",
307 )
308 parser.add_argument(
309 "--minimum-compliance",
310 type=float,
311 default=DEFAULT_MINIMUM_COMPLIANCE,
312 help=(
313 "Minimum duration-weighted fraction of completed requests that must "
314 "meet all supplied objectives (default: 0.99)."
315 ),
316 )
317 parser.add_argument(
318 "--output-config",
319 default="recommended-config.yml",
320 help="Recommended config output path.",
321 )
322 parser.add_argument(
323 "--output-json",
324 default="recommendation.json",
325 help="Recommendation evidence output path.",
326 )
327 return parser.parse_args()
328
329
330 def main() -> int:
331 args = parse_args()
332 if not 0.0 < args.minimum_compliance <= 1.0:
333 raise ValueError("--minimum-compliance must be greater than 0 and at most 1.")
334 script_dir = Path(__file__).resolve().parent
335
336 config_path = _resolve(script_dir, args.config)
337 env_path = _resolve(script_dir, args.env)
338 results_dir = _resolve(script_dir, args.results_dir)
339 output_config = _resolve(script_dir, args.output_config)
340 output_json = _resolve(script_dir, args.output_json)
341
342 if config_path is None:
343 raise ValueError("--config is required.")
344 assert results_dir is not None
345 assert output_config is not None
346 assert output_json is not None
347
348 if not results_dir.exists():
349 raise ValueError(
350 f"Sweep results were not found at {results_dir}. Run the sweep first."
351 )
352
353 rows = _load_runs(results_dir)
354 if not rows:
355 raise ValueError(f"No summary.json sweep results found under {results_dir}.")
356
357 use_goodput = args.ttft_sla_ms is not None or args.tpot_sla_ms is not None
358 candidates = _aggregate_candidates(
359 rows,
360 use_goodput=use_goodput,
361 ttft_sla_ms=args.ttft_sla_ms,
362 tpot_sla_ms=args.tpot_sla_ms,
363 minimum_compliance=args.minimum_compliance,
364 )
365 winner, best_effort, objective = _select_candidate(
366 candidates, use_goodput=use_goodput
367 )
368
369 initial_config = _load_config(config_path)
370 if winner is not None:
371 recommended_config = dict(initial_config)
372 recommended_config["max-num-seqs"] = winner["max_num_seqs"]
373 recommended_config["max-num-batched-tokens"] = winner["max_num_batched_tokens"]
374 _write_config(
375 output_config,
376 source_path=config_path,
377 config=recommended_config,
378 objective=objective,
379 )
380 elif output_config.exists():
381 output_config.unlink()
382
383 measured = None
384 recommended = None
385 if winner is not None:
386 recommended = {
387 "max_num_seqs": winner["max_num_seqs"],
388 "max_num_batched_tokens": winner["max_num_batched_tokens"],
389 }
390 measured = {
391 key: winner[key]
392 for key in (
393 "run_count",
394 "mean_request_throughput",
395 "mean_request_goodput",
396 "mean_output_throughput",
397 "combined_compliance_ratio",
398 "combined_compliance_percent",
399 "estimated_compliant_requests",
400 "completed_requests",
401 "mean_p99_ttft_ms",
402 "median_p99_ttft_ms",
403 "worst_p99_ttft_ms",
404 "mean_p99_tpot_ms",
405 "median_p99_tpot_ms",
406 "worst_p99_tpot_ms",
407 )
408 }
409
410 recommendation = {
411 "status": (
412 "sla_feasible" if winner is not None else "no_sla_feasible_configuration"
413 ),
414 "selection_objective": objective,
415 "minimum_compliance_ratio": args.minimum_compliance,
416 "slo": {
417 "ttft_ms": args.ttft_sla_ms,
418 "tpot_ms": args.tpot_sla_ms,
419 },
420 "initial": {
421 "max_num_seqs": initial_config.get("max-num-seqs"),
422 "max_num_batched_tokens": initial_config.get("max-num-batched-tokens"),
423 },
424 "recommended": recommended,
425 "measured": measured,
426 "best_effort": {
427 "max_num_seqs": best_effort["max_num_seqs"],
428 "max_num_batched_tokens": best_effort["max_num_batched_tokens"],
429 "mean_request_goodput": best_effort["mean_request_goodput"],
430 "combined_compliance_ratio": best_effort["combined_compliance_ratio"],
431 "p99_sla_eligible": best_effort["p99_sla_eligible"],
432 },
433 "candidates": candidates,
434 }
435 output_json.write_text(
436 json.dumps(recommendation, indent=2) + "\n",
437 encoding="utf-8",
438 )
439
440 if winner is None:
441 print("No SLA-feasible runtime configuration was found.")
442 print(f"Wrote diagnostic evidence to {output_json}")
443 print("No recommended config was written.")
444 return 2
445
446 print("Recommended runtime configuration")
447 print()
448 print(f" max-num-seqs: {winner['max_num_seqs']}")
449 print(f" max-num-batched-tokens: {winner['max_num_batched_tokens']}")
450 print()
451 print(f"Selection objective: {objective}")
452 if use_goodput:
453 print(f" mean request goodput: {winner['mean_request_goodput']:.2f} req/s")
454 print(f" combined compliance: {winner['combined_compliance_percent']:.2f}%")
455 print(f" mean output throughput: {winner['mean_output_throughput']:.2f} tok/s")
456 if winner["median_p99_ttft_ms"] is not None:
457 print(f" median P99 TTFT: {winner['median_p99_ttft_ms']:.2f} ms")
458 if winner["median_p99_tpot_ms"] is not None:
459 print(f" median P99 TPOT: {winner['median_p99_tpot_ms']:.2f} ms")
460 print()
461 print(f"Wrote {output_config}")
462 print(f"Wrote {output_json}")
463 print()
464 print("Deploy:")
465 if env_path is not None:
466 print(f" source {env_path}")
467 print(f" vllm serve --config {output_config}")
468 return 0
469
470
471 if __name__ == "__main__":
472 raise SystemExit(main())