[Benchmark] Add sweep warmup and failure recovery (#57305)

Signed-off-by: louie-tsai <louie.tsai@intel.com>

Louie Tsai committed Sep 29, 2026 at 20:16 UTC 1b77cc3d67eddd98c7ecbad7024213a3eeaf7e93
4 files changed +300 -35
docs/benchmarking/sweeps.md
+6
@@ -88,6 +88,12 @@ vllm bench sweep serve \
88
89 By default, each parameter combination is benchmarked 3 times to make the results more reliable. You can adjust the number of runs by setting `--num-runs`.
90
91 +You can optionally run an unmeasured warmup before each parameter combination by setting `--warmup-num-prompts`. The warmup result is saved as `warmup.json` for inspection, but is excluded from the measured summary.
92 +
93 +You can use `--continue-on-error` to record a failed warmup, benchmark run, or server configuration and continue processing the remaining sweep combinations. Failure details are saved in `.failure.json` files.
94 +
95 +Both options are also supported by `vllm bench sweep serve_workload`.
96 +
97 !!! important
98 If both `--serve-params` and `--bench-params` are passed, the script will iterate over the Cartesian product between them.
99 You can use `--dry-run` to preview the commands to be run.
tests/benchmarks/sweep/test_param_sweep.py
+126
@@ -6,6 +6,7 @@ from pathlib import Path
6
7 import pytest
8
9 +from vllm.benchmarks.sweep import serve as sweep_serve
10 from vllm.benchmarks.sweep.param_sweep import ParameterSweep, ParameterSweepItem
11
12
@@ -247,3 +248,128 @@ class TestParameterSweepItemKeyNormalization:
248 # The prefix (compilation_config) gets converted to hyphens,
249 # but the suffix (some_nested_param) is preserved
250 assert any("compilation-config.some_nested_param" in arg for arg in cmd)
251 +
252 +
253 +def test_run_comb_excludes_warmup_from_measured_results(
254 + monkeypatch: pytest.MonkeyPatch, tmp_path: Path
255 +):
256 + calls: list[tuple[int, int, str]] = []
257 +
258 + def fake_run_benchmark(
259 + server,
260 + bench_cmd,
261 + *,
262 + serve_overrides,
263 + bench_overrides,
264 + run_number,
265 + output_path,
266 + dry_run,
267 + ):
268 + calls.append(
269 + (run_number, int(bench_overrides["num_prompts"]), output_path.name)
270 + )
271 + return {"run_number": run_number}
272 +
273 + monkeypatch.setattr(sweep_serve, "run_benchmark", fake_run_benchmark)
274 + base_path = tmp_path / "combination"
275 + base_path.mkdir()
276 +
277 + measured = sweep_serve.run_comb(
278 + None,
279 + [],
280 + serve_comb=ParameterSweepItem(),
281 + bench_comb=ParameterSweepItem({"num_prompts": 320}),
282 + link_vars=[],
283 + base_path=base_path,
284 + num_runs=2,
285 + warmup_num_prompts=32,
286 + dry_run=False,
287 + )
288 +
289 + assert calls == [
290 + (-1, 32, "warmup.json"),
291 + (0, 320, "run=0.json"),
292 + (1, 320, "run=1.json"),
293 + ]
294 + assert measured == [{"run_number": 0}, {"run_number": 1}]
295 +
296 +
297 +def test_run_comb_continue_on_error_keeps_later_runs(
298 + monkeypatch: pytest.MonkeyPatch, tmp_path: Path
299 +):
300 + calls: list[int] = []
301 +
302 + def fake_run_benchmark(
303 + server,
304 + bench_cmd,
305 + *,
306 + serve_overrides,
307 + bench_overrides,
308 + run_number,
309 + output_path,
310 + dry_run,
311 + ):
312 + calls.append(run_number)
313 + if run_number == 0:
314 + raise RuntimeError("synthetic run failure")
315 + return {"run_number": run_number}
316 +
317 + monkeypatch.setattr(sweep_serve, "run_benchmark", fake_run_benchmark)
318 + base_path = tmp_path / "combination"
319 + base_path.mkdir()
320 +
321 + measured = sweep_serve.run_comb(
322 + None,
323 + [],
324 + serve_comb=ParameterSweepItem(),
325 + bench_comb=ParameterSweepItem({"num_prompts": 100}),
326 + link_vars=[],
327 + base_path=base_path,
328 + num_runs=2,
329 + warmup_num_prompts=0,
330 + dry_run=False,
331 + continue_on_error=True,
332 + )
333 +
334 + assert calls == [0, 1]
335 + assert measured == [{"run_number": 1}]
336 + assert (base_path / "run=0.failure.json").exists()
337 + assert (base_path / "summary.json").exists()
338 +
339 +
340 +def test_run_comb_warmup_default_is_backward_compatible(
341 + monkeypatch: pytest.MonkeyPatch, tmp_path: Path
342 +):
343 + """Older callers can omit warmup_num_prompts."""
344 + calls: list[int] = []
345 +
346 + def fake_run_benchmark(
347 + server,
348 + bench_cmd,
349 + *,
350 + serve_overrides,
351 + bench_overrides,
352 + run_number,
353 + output_path,
354 + dry_run,
355 + ):
356 + calls.append(run_number)
357 + return {"run_number": run_number}
358 +
359 + monkeypatch.setattr(sweep_serve, "run_benchmark", fake_run_benchmark)
360 + base_path = tmp_path / "combination"
361 + base_path.mkdir()
362 +
363 + measured = sweep_serve.run_comb(
364 + None,
365 + [],
366 + serve_comb=ParameterSweepItem(),
367 + bench_comb=ParameterSweepItem({"num_prompts": 10}),
368 + link_vars=[],
369 + base_path=base_path,
370 + num_runs=1,
371 + dry_run=False,
372 + )
373 +
374 + assert calls == [0]
375 + assert measured == [{"run_number": 0}]
vllm/benchmarks/sweep/serve.py
+142 -31
@@ -221,29 +221,80 @@ def run_comb(
221 base_path: Path,
222 num_runs: int,
223 dry_run: bool,
224 + warmup_num_prompts: int = 0,
225 + continue_on_error: bool = False,
226 ):
227 if not _comb_is_valid(serve_comb, bench_comb, link_vars):
228 return None
229
230 comb_data = list[dict[str, object]]()
231
232 + def run_benchmark_with_error_handling(
233 + *,
234 + bench_overrides: ParameterSweepItem,
235 + run_number: int,
236 + output_path: Path,
237 + ):
238 + try:
239 + return run_benchmark(
240 + server,
241 + bench_cmd,
242 + serve_overrides=serve_comb,
243 + bench_overrides=bench_overrides,
244 + run_number=run_number,
245 + output_path=output_path,
246 + dry_run=dry_run,
247 + )
248 + except Exception as exc:
249 + if not continue_on_error:
250 + raise
251 + failure_path = output_path.with_name(output_path.stem + ".failure.json")
252 + failure_path.parent.mkdir(parents=True, exist_ok=True)
253 + failure_path.write_text(
254 + json.dumps(
255 + {
256 + "run_number": run_number,
257 + "error_type": type(exc).__name__,
258 + "error": str(exc),
259 + "serve_overrides": dict(serve_comb),
260 + "bench_overrides": dict(bench_overrides),
261 + },
262 + indent=2,
263 + )
264 + + "\n",
265 + encoding="utf-8",
266 + )
267 + print(f"[FAILED BENCHMARK] run={run_number}: {type(exc).__name__}: {exc}")
268 + print(f"Failure details: {failure_path}")
269 + if server is not None:
270 + with contextlib.suppress(Exception):
271 + server.after_bench()
272 + return None
273 +
274 + if warmup_num_prompts > 0:
275 + warmup_overrides = bench_comb | {"num_prompts": warmup_num_prompts}
276 + run_benchmark_with_error_handling(
277 + bench_overrides=warmup_overrides,
278 + run_number=-1,
279 + output_path=base_path / "warmup.json",
280 + )
281 +
282 for run_number in range(num_runs):
231 - run_data = run_benchmark(
232 - server,
233 - bench_cmd,
234 - serve_overrides=serve_comb,
283 + run_data = run_benchmark_with_error_handling(
284 bench_overrides=bench_comb,
285 run_number=run_number,
286 output_path=_get_comb_run_path(base_path, run_number),
238 - dry_run=dry_run,
287 )
240 -
288 if run_data is not None:
289 comb_data.append(run_data)
290
291 if dry_run:
292 return None
293
294 + if not comb_data:
295 + print("[NO SUCCESSFUL MEASURED RUNS] leaving combination retryable")
296 + return []
297 +
298 with _get_comb_run_path(base_path, run_number=None).open("w") as f:
299 json.dump(comb_data, f, indent=4)
300
@@ -262,36 +313,69 @@ def run_combs(
313 link_vars: list[tuple[str, str]],
314 experiment_dir: Path,
315 num_runs: int,
316 + warmup_num_prompts: int,
317 dry_run: bool,
318 + continue_on_error: bool = False,
319 ):
320 all_data = list[dict[str, object]]()
321 for serve_comb in serve_params:
269 - with server_ctx(
270 - serve_cmd,
271 - after_bench_cmd,
272 - show_stdout=show_stdout,
273 - serve_comb=serve_comb,
274 - bench_params=bench_params,
275 - experiment_dir=experiment_dir,
276 - dry_run=dry_run,
277 - server_ready_timeout=server_ready_timeout,
278 - ) as server:
279 - for bench_comb in bench_params:
280 - base_path = _get_comb_base_path(experiment_dir, serve_comb, bench_comb)
281 -
282 - comb_data = run_comb(
283 - server,
284 - bench_cmd,
285 - serve_comb=serve_comb,
286 - bench_comb=bench_comb,
287 - link_vars=link_vars,
288 - base_path=base_path,
289 - num_runs=num_runs,
290 - dry_run=dry_run,
322 + try:
323 + with server_ctx(
324 + serve_cmd,
325 + after_bench_cmd,
326 + show_stdout=show_stdout,
327 + serve_comb=serve_comb,
328 + bench_params=bench_params,
329 + experiment_dir=experiment_dir,
330 + dry_run=dry_run,
331 + server_ready_timeout=server_ready_timeout,
332 + ) as server:
333 + for bench_comb in bench_params:
334 + base_path = _get_comb_base_path(
335 + experiment_dir, serve_comb, bench_comb
336 + )
337 +
338 + comb_data = run_comb(
339 + server,
340 + bench_cmd,
341 + serve_comb=serve_comb,
342 + bench_comb=bench_comb,
343 + link_vars=link_vars,
344 + base_path=base_path,
345 + num_runs=num_runs,
346 + warmup_num_prompts=warmup_num_prompts,
347 + dry_run=dry_run,
348 + continue_on_error=continue_on_error,
349 + )
350 +
351 + if comb_data is not None:
352 + all_data.extend(comb_data)
353 + except Exception as exc:
354 + if not continue_on_error:
355 + raise
356 + failure_name = sanitize_filename(
357 + f"SERVE-{serve_comb.name or 'default'}-failure.json"
358 + )
359 + failure_path = experiment_dir / failure_name
360 + failure_path.parent.mkdir(parents=True, exist_ok=True)
361 + failure_path.write_text(
362 + json.dumps(
363 + {
364 + "error_type": type(exc).__name__,
365 + "error": str(exc),
366 + "serve_overrides": dict(serve_comb),
367 + },
368 + indent=2,
369 )
292 -
293 - if comb_data is not None:
294 - all_data.extend(comb_data)
370 + + "\n",
371 + encoding="utf-8",
372 + )
373 + print(
374 + "[FAILED SERVER COMBINATION] "
375 + f"{serve_comb.name or '(default)'}: "
376 + f"{type(exc).__name__}: {exc}"
377 + )
378 + print(f"Failure details: {failure_path}")
379
380 if dry_run:
381 return None
@@ -315,6 +399,8 @@ class SweepServeArgs:
399 output_dir: Path
400 experiment_name: str
401 num_runs: int
402 + warmup_num_prompts: int
403 + continue_on_error: bool
404 dry_run: bool
405 resume: bool
406
@@ -351,6 +437,9 @@ class SweepServeArgs:
437 num_runs = args.num_runs
438 if num_runs < 1:
439 raise ValueError("`num_runs` should be at least 1.")
440 + warmup_num_prompts = args.warmup_num_prompts
441 + if warmup_num_prompts < 0:
442 + raise ValueError("`warmup_num_prompts` should be at least 0.")
443
444 return cls(
445 serve_cmd=serve_cmd,
@@ -363,6 +452,8 @@ class SweepServeArgs:
452 output_dir=Path(args.output_dir),
453 experiment_name=experiment_name,
454 num_runs=num_runs,
455 + warmup_num_prompts=warmup_num_prompts,
456 + continue_on_error=args.continue_on_error,
457 dry_run=args.dry_run,
458 resume=args.resume,
459 server_ready_timeout=args.server_ready_timeout,
@@ -453,6 +544,24 @@ class SweepServeArgs:
544 default=3,
545 help="Number of runs per parameter combination.",
546 )
547 + parser.add_argument(
548 + "--warmup-num-prompts",
549 + type=int,
550 + default=0,
551 + help=(
552 + "Number of prompts in one unmeasured warmup run before each "
553 + "parameter combination. The warmup is saved as warmup.json but "
554 + "excluded from summary statistics."
555 + ),
556 + )
557 + parser.add_argument(
558 + "--continue-on-error",
559 + action="store_true",
560 + help=(
561 + "Record and skip a failed warmup or benchmark run instead of "
562 + "terminating the entire sweep."
563 + ),
564 + )
565 parser.add_argument(
566 "--dry-run",
567 action="store_true",
@@ -523,7 +632,9 @@ def run_main(args: SweepServeArgs):
632 bench_params=args.bench_params,
633 experiment_dir=experiment_dir,
634 num_runs=args.num_runs,
635 + warmup_num_prompts=args.warmup_num_prompts,
636 dry_run=args.dry_run,
637 + continue_on_error=args.continue_on_error,
638 )
639
640
vllm/benchmarks/sweep/serve_workload.py
+26 -4
@@ -63,6 +63,8 @@ def run_comb_workload(
63 experiment_dir: Path,
64 num_runs: int,
65 dry_run: bool,
66 + warmup_num_prompts: int,
67 + continue_on_error: bool,
68 workload_var: WorkloadVariable,
69 workload_value: int,
70 ) -> list[dict[str, object]] | None:
@@ -82,6 +84,8 @@ def run_comb_workload(
84 ),
85 num_runs=num_runs,
86 dry_run=dry_run,
87 + warmup_num_prompts=warmup_num_prompts,
88 + continue_on_error=continue_on_error,
89 )
90
91
@@ -97,6 +101,8 @@ def explore_comb_workloads(
101 experiment_dir: Path,
102 num_runs: int,
103 dry_run: bool,
104 + warmup_num_prompts: int,
105 + continue_on_error: bool,
106 ):
107 print("[WL START]")
108 print(f"Serve parameters: {serve_comb.as_text() or '(None)'}")
@@ -129,6 +135,8 @@ def explore_comb_workloads(
135 experiment_dir=experiment_dir,
136 num_runs=num_runs,
137 dry_run=dry_run,
138 + warmup_num_prompts=warmup_num_prompts,
139 + continue_on_error=continue_on_error,
140 workload_var=workload_var,
141 workload_value=1,
142 )
@@ -141,16 +149,22 @@ def explore_comb_workloads(
149 experiment_dir=experiment_dir,
150 num_runs=num_runs,
151 dry_run=dry_run,
152 + warmup_num_prompts=warmup_num_prompts,
153 + continue_on_error=continue_on_error,
154 workload_var=workload_var,
155 workload_value=dataset_size,
156 )
157
148 - if serial_workload_data is None or batch_workload_data is None:
158 + if not serial_workload_data or not batch_workload_data:
159 if dry_run:
160 print("Omitting intermediate Workload iterations.")
151 - print("[WL END]")
152 -
153 - return
161 + else:
162 + print(
163 + "Unable to establish both Workload Explorer endpoints; "
164 + "skipping intermediate workload levels."
165 + )
166 + print("[WL END]")
167 + return []
168
169 serial_workload_value = math.ceil(
170 _estimate_workload_avg(serial_workload_data, workload_var)
@@ -181,6 +195,8 @@ def explore_comb_workloads(
195 experiment_dir=experiment_dir,
196 num_runs=num_runs,
197 dry_run=dry_run,
198 + warmup_num_prompts=warmup_num_prompts,
199 + continue_on_error=continue_on_error,
200 workload_var=workload_var,
201 workload_value=inter_workload_value,
202 )
@@ -207,6 +223,8 @@ def explore_combs_workloads(
223 experiment_dir: Path,
224 num_runs: int,
225 dry_run: bool,
226 + warmup_num_prompts: int,
227 + continue_on_error: bool,
228 ):
229 if any(bench_comb.has_param(workload_var) for bench_comb in bench_params):
230 raise ValueError(
@@ -238,6 +256,8 @@ def explore_combs_workloads(
256 experiment_dir=experiment_dir,
257 num_runs=num_runs,
258 dry_run=dry_run,
259 + warmup_num_prompts=warmup_num_prompts,
260 + continue_on_error=continue_on_error,
261 )
262
263 if comb_data is not None:
@@ -315,6 +335,8 @@ def run_main(args: SweepServeWorkloadArgs):
335 experiment_dir=experiment_dir,
336 num_runs=args.num_runs,
337 dry_run=args.dry_run,
338 + warmup_num_prompts=args.warmup_num_prompts,
339 + continue_on_error=args.continue_on_error,
340 )
341
342