vllm-benchmark 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vllm_bench/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Script-driven vLLM benchmarking."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,126 @@
1
+ """Number result directories and save the exact experiment inputs."""
2
+
3
+ import hashlib
4
+ import os
5
+ import re
6
+ import subprocess
7
+ import time
8
+ import zipfile
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+
13
+ from . import __version__
14
+ from .contracts import Trial
15
+ from .json_io import write_json
16
+ from .progress import log
17
+
18
+ EXCLUDED = {
19
+ ".git",
20
+ ".venv",
21
+ "venv",
22
+ "env",
23
+ "node_modules",
24
+ "results",
25
+ ".vllm-bench",
26
+ "__pycache__",
27
+ ".cache",
28
+ ".pytest_cache",
29
+ ".ruff_cache",
30
+ "build",
31
+ "dist",
32
+ ".wheels",
33
+ }
34
+
35
+
36
+ def project_files(root):
37
+ try:
38
+ names = (
39
+ subprocess.check_output(
40
+ ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
41
+ cwd=root,
42
+ stderr=subprocess.DEVNULL,
43
+ )
44
+ .decode()
45
+ .split("\0")
46
+ )
47
+ except subprocess.CalledProcessError:
48
+ names = []
49
+ for folder, directories, files in os.walk(root):
50
+ directories[:] = [name for name in directories if name not in EXCLUDED]
51
+ names.extend(str((Path(folder) / name).relative_to(root)) for name in files)
52
+ for name in sorted(set(names)):
53
+ path = root / name
54
+ if name == ".vllm_bench/state.json":
55
+ continue
56
+ if not name or not path.is_file() or path.is_symlink():
57
+ continue
58
+ if any(
59
+ part in EXCLUDED or part.startswith(".env") or part.endswith(".egg-info")
60
+ for part in Path(name).parts
61
+ ):
62
+ continue
63
+ if any((root / parent).is_symlink() for parent in Path(name).parents):
64
+ continue
65
+ if path.suffix not in {".pyc", ".so", ".dylib", ".whl"}:
66
+ yield path, name
67
+
68
+
69
+ def trials(config):
70
+ return [
71
+ Trial(benchmark=name, scenario=scenario, repeat=repeat)
72
+ for name, benchmark in config.benchmarks.items()
73
+ for scenario in benchmark.scenarios
74
+ for repeat in range(1, config.runs + 1)
75
+ ]
76
+
77
+
78
+ def prepare_run(config, root):
79
+ parent = root / "results" / f"{config.provider}-{config.name}"
80
+ parent.mkdir(parents=True, exist_ok=True)
81
+ last = max(
82
+ (
83
+ int(match[1])
84
+ for path in parent.iterdir()
85
+ if path.is_dir() and (match := re.fullmatch(r"run(\d+)", path.name))
86
+ ),
87
+ default=0,
88
+ )
89
+ output = parent / f"run{last + 1:03d}"
90
+ output.mkdir()
91
+ log(f"Result directory: {output}")
92
+ (output / "config.yaml").write_text(
93
+ yaml.safe_dump(config.model_dump(exclude_none=True), sort_keys=False)
94
+ )
95
+ if not config.skip_volume_mount:
96
+ archive = output / "project.zip"
97
+ log("Saving project snapshot (excluding results, environments, and caches)...")
98
+ count = 0
99
+ last_update = time.monotonic()
100
+ with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as bundle:
101
+ for path, name in project_files(root):
102
+ bundle.write(path, name)
103
+ count += 1
104
+ if time.monotonic() - last_update >= 5:
105
+ log(f"Project snapshot: {count} files archived so far...")
106
+ last_update = time.monotonic()
107
+ with archive.open("rb") as stream:
108
+ digest = hashlib.file_digest(stream, "sha256").hexdigest()
109
+ (output / "project.sha256").write_text(f"{digest} project.zip\n")
110
+ log(
111
+ f"Snapshot saved: {count} files, {archive.stat().st_size / 1024**2:.1f} MiB"
112
+ )
113
+ else:
114
+ log("Project snapshot skipped: skip_volume_mount is enabled.")
115
+ write_json(
116
+ output / "run.json",
117
+ {
118
+ "schema_version": 1,
119
+ "cli_version": __version__,
120
+ "provider": config.provider,
121
+ "image": config.base_image,
122
+ "trials": [trial.model_dump() for trial in trials(config)],
123
+ },
124
+ )
125
+ log("Saved effective config and run manifest.")
126
+ return output
@@ -0,0 +1,124 @@
1
+ """The small public interface used by startup and benchmark scripts."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ from functools import lru_cache
7
+ from pathlib import Path
8
+
9
+
10
+ @lru_cache(maxsize=1)
11
+ def _context():
12
+ return json.loads(Path(os.environ["VLLM_BENCH_CONTEXT"]).read_text())
13
+
14
+
15
+ def get_global_args():
16
+ return dict(_context()["global_args"])
17
+
18
+
19
+ def get_service_args(name=None):
20
+ """Global arguments with overrides for the current or named service group."""
21
+ if name is None and (service := get_service()):
22
+ name = service["group"]
23
+ overrides = _context()["service_args"][name] if name is not None else {}
24
+ return {**get_global_args(), **overrides}
25
+
26
+
27
+ def get_global_arg(name, default=None):
28
+ return _context()["global_args"].get(name, default)
29
+
30
+
31
+ def get_scenario_arg(name, default=None):
32
+ return _context()["scenario_args"].get(name, default)
33
+
34
+
35
+ def get_benchmark_arg(name, default=None):
36
+ return _context()["benchmark_args"].get(name, default)
37
+
38
+
39
+ def get_output_dir():
40
+ path = Path(_context()["output_dir"])
41
+ path.mkdir(parents=True, exist_ok=True)
42
+ return path
43
+
44
+
45
+ def get_project_dir():
46
+ return Path(_context()["project_dir"])
47
+
48
+
49
+ def get_service():
50
+ """Name, role, and replica of the calling startup script (None on the client)."""
51
+ return _context().get("service")
52
+
53
+
54
+ def get_service_url(name, *, internal=False):
55
+ services = _context()["internal_services" if internal else "services"]
56
+ matches = [
57
+ item for item in services if item["group"] == name or item["name"] == name
58
+ ]
59
+ if len(matches) != 1:
60
+ raise ValueError(f"Expected one service named {name}; found {len(matches)}")
61
+ return matches[0]["url"]
62
+
63
+
64
+ def get_leader_url():
65
+ return next(
66
+ item["url"] for item in _context()["services"] if item["role"] == "leader"
67
+ )
68
+
69
+
70
+ def get_worker_urls():
71
+ return [item["url"] for item in _context()["services"] if item["role"] == "worker"]
72
+
73
+
74
+ def get_service_path(path):
75
+ """Translate a client project/trial path into the path visible to services."""
76
+ path = Path(path).resolve()
77
+ context = _context()
78
+ if context.get("external_services"):
79
+ # External services may share the client's filesystem; no upload is implied.
80
+ return str(path)
81
+ for local, remote in [
82
+ (context["trial_dir"], "/run/vllm-bench/trial"),
83
+ (context["project_dir"], context["service_project_dir"]),
84
+ ]:
85
+ local = Path(local).resolve()
86
+ if path.is_relative_to(local):
87
+ destination = str(Path(remote) / path.relative_to(local))
88
+ if sandbox_id := context.get("modal_sandbox_id"):
89
+ from .providers.modal import upload_path
90
+
91
+ upload_path(sandbox_id, path, destination)
92
+ return destination
93
+ raise ValueError(f"Path must be inside the project or trial directory: {path}")
94
+
95
+
96
+ @lru_cache(maxsize=1)
97
+ def get_gpu_count():
98
+ try:
99
+ result = subprocess.run(
100
+ ["nvidia-smi", "--query-gpu=uuid", "--format=csv,noheader"],
101
+ capture_output=True,
102
+ text=True,
103
+ check=True,
104
+ timeout=5,
105
+ )
106
+ return len(result.stdout.strip().splitlines())
107
+ except (OSError, subprocess.SubprocessError):
108
+ return 0
109
+
110
+
111
+ def is_gpu_available():
112
+ return get_gpu_count() > 0
113
+
114
+
115
+ def is_cpu():
116
+ return not is_gpu_available()
117
+
118
+
119
+ def __getattr__(name):
120
+ if name == "current_run":
121
+ return _context()["repeat"]
122
+ if name == "current_benchmark":
123
+ return _context()["benchmark"]
124
+ raise AttributeError(name)
vllm_bench/cli.py ADDED
@@ -0,0 +1,336 @@
1
+ """The user's machine owns scheduling, benchmark scripts, metrics, and reports."""
2
+
3
+ import argparse
4
+ import json
5
+ import shlex
6
+ import signal
7
+ import sys
8
+ import time
9
+ from contextlib import nullcontext
10
+ from pathlib import Path
11
+
12
+ from . import __version__
13
+ from .artifacts import prepare_run, trials
14
+ from .config import load_config, resolve_config, select_benchmarks
15
+ from .external import prepare_context, service_endpoints
16
+ from .progress import log
17
+ from .providers import get_provider, stop_saved
18
+ from .runner import run_only, run_trial, start_environment, wait_healthy
19
+
20
+
21
+ def interrupt(signum, frame):
22
+ raise KeyboardInterrupt
23
+
24
+
25
+ def run_only_command(config, config_path, root, trial, endpoints):
26
+ command = [
27
+ "vllm_bench",
28
+ "run_only",
29
+ "--current-dir",
30
+ str(root),
31
+ "--config",
32
+ str(config_path),
33
+ "--benchmark",
34
+ trial.benchmark,
35
+ "--scenario",
36
+ trial.scenario,
37
+ ]
38
+ urls = {}
39
+ for item in endpoints:
40
+ urls.setdefault(item["group"], item["url"])
41
+ for name, url in urls.items():
42
+ command.extend(["--service-url", f"{name}={url}"])
43
+ if dataset := config.benchmarks[trial.benchmark].script_args.get("dataset_path"):
44
+ command.extend(["--dataset-path", str(dataset)])
45
+ return shlex.join(command)
46
+
47
+
48
+ def show_environment(
49
+ provider, endpoints, directory, timeout, started, detach, run_command
50
+ ):
51
+ ready = False
52
+ try:
53
+ logs = nullcontext() if detach else provider.follow_logs()
54
+ with logs as process:
55
+ wait_healthy(provider, endpoints, timeout)
56
+ log(f"Environment ready in {time.monotonic() - started:.1f}s.")
57
+ log("Run the benchmark from another terminal:")
58
+ print(run_command, flush=True)
59
+ for item in endpoints:
60
+ log(f"{item['name']} ({item['role']}): {item['url']}")
61
+ log(f"Service output: {directory / 'services'}")
62
+ ready = True
63
+ if detach:
64
+ log("Containers are running. Stop with: vllm_bench down")
65
+ return 0
66
+ log("Following container logs. Press Ctrl+C to stop the environment.")
67
+ while process.poll() is None:
68
+ provider.check_running()
69
+ time.sleep(1)
70
+ if process.returncode:
71
+ raise RuntimeError(
72
+ f"Container log stream exited with {process.returncode}"
73
+ )
74
+ return 0
75
+ finally:
76
+ if not detach or not ready:
77
+ log("Stopping the environment and saving container logs...")
78
+ provider.stop()
79
+
80
+
81
+ def main():
82
+ parser = argparse.ArgumentParser(description="Run script-defined vLLM benchmarks.")
83
+ parser.add_argument("--version", action="version", version=__version__)
84
+ parser.add_argument("--config", type=Path, default=Path("local_cpu.yaml"))
85
+ parser.add_argument(
86
+ "--current-dir",
87
+ type=Path,
88
+ default=Path.cwd(),
89
+ help="Project root containing .vllm_bench",
90
+ )
91
+ commands = parser.add_subparsers(dest="command", required=True)
92
+ up = commands.add_parser("up", help="Start services and follow their logs")
93
+ up.add_argument(
94
+ "-d",
95
+ "--detach",
96
+ action="store_true",
97
+ help="Leave services running in the background",
98
+ )
99
+ down = commands.add_parser(
100
+ "down", help="Stop the active environment and collect output"
101
+ )
102
+ run = commands.add_parser(
103
+ "run_scenarios",
104
+ help="Run selected trials with fresh managed services each time",
105
+ )
106
+ single = commands.add_parser(
107
+ "run_only", help="Run one benchmark/scenario once against existing service URLs"
108
+ )
109
+ single.add_argument("--benchmark", required=True)
110
+ single.add_argument("--scenario", required=True)
111
+ single.add_argument(
112
+ "--service-url",
113
+ action="append",
114
+ required=True,
115
+ metavar="NAME=URL",
116
+ help="Existing service base URL; repeat for each required service",
117
+ )
118
+ single.add_argument(
119
+ "--dataset-path",
120
+ help="Existing dataset path visible to the services (passed to the benchmark)",
121
+ )
122
+ report = commands.add_parser("report", help="Regenerate reports from saved files")
123
+ report.add_argument("directory", type=Path)
124
+ for command in [up, down, run, single, report]:
125
+ command.add_argument(
126
+ "--current-dir",
127
+ type=Path,
128
+ default=argparse.SUPPRESS,
129
+ help="Project root containing .vllm_bench",
130
+ )
131
+ for command in [up, run, single]:
132
+ command.add_argument(
133
+ "--config",
134
+ type=Path,
135
+ default=argparse.SUPPRESS,
136
+ help="Config name or path, merged over base.yaml (e.g. modal)",
137
+ )
138
+ for command in [up, run]:
139
+ command.add_argument(
140
+ "--benchmark",
141
+ "--benchmarks",
142
+ dest="benchmarks",
143
+ nargs="+",
144
+ required=True,
145
+ help="Benchmark names, or '*' for all benchmarks",
146
+ )
147
+ command.add_argument(
148
+ "--scenario",
149
+ "--scenarios",
150
+ dest="scenarios",
151
+ nargs="+",
152
+ required=True,
153
+ help="Scenario names, or '*' for every scenario in each selected benchmark",
154
+ )
155
+ args = parser.parse_args()
156
+ signal.signal(signal.SIGTERM, interrupt)
157
+ root = args.current_dir.resolve()
158
+ config_path = resolve_config(root / ".vllm_bench/configs" / args.config)
159
+ state_path = root / ".vllm_bench/state.json"
160
+ try:
161
+ if args.command == "report":
162
+ from .reports import generate_reports
163
+
164
+ generate_reports((root / args.directory).resolve())
165
+ return 0
166
+ if not (root / ".vllm_bench").is_dir():
167
+ raise FileNotFoundError(f"Project must contain .vllm_bench: {root}")
168
+ if args.command == "down":
169
+ stop_saved(state_path)
170
+ return 0
171
+ started = time.monotonic()
172
+ if args.command == "up":
173
+ log(f"Reading config: {config_path}")
174
+ external = args.command == "run_only"
175
+ config = select_benchmarks(
176
+ load_config(config_path),
177
+ [args.benchmark] if external else args.benchmarks,
178
+ [args.scenario] if external else args.scenarios,
179
+ )
180
+ if external:
181
+ config = config.model_copy(
182
+ update={
183
+ "provider": "external",
184
+ "runs": 1,
185
+ "skip_volume_mount": True,
186
+ }
187
+ )
188
+ if args.dataset_path:
189
+ benchmark = config.benchmarks[args.benchmark]
190
+ benchmark.script_args = {
191
+ **benchmark.script_args,
192
+ "dataset_path": args.dataset_path,
193
+ }
194
+ experiment = trials(config)
195
+ first = experiment[0]
196
+ if args.command in {"up", "run_only"}:
197
+ pairs = sum(
198
+ len(benchmark.scenarios) for benchmark in config.benchmarks.values()
199
+ )
200
+ if pairs != 1:
201
+ raise ValueError(
202
+ f"{args.command} requires exactly one benchmark and one scenario"
203
+ )
204
+ if external:
205
+ endpoints = service_endpoints(config, first, args.service_url)
206
+ if args.command == "up":
207
+ log(f"Configuration valid. Provider: {config.provider}")
208
+ log(f"Starting benchmark '{first.benchmark}', scenario '{first.scenario}'.")
209
+ if model := config.global_args.get("model"):
210
+ log(f"Model: {model}")
211
+ scripts = [benchmark.script for benchmark in config.benchmarks.values()]
212
+ if not external:
213
+ if config.provider == "external":
214
+ raise ValueError("Use run_only for external services")
215
+ if not config.base_image:
216
+ raise ValueError(f"{args.command} requires base_image in the config")
217
+ groups = dict.fromkeys(
218
+ name
219
+ for benchmark_name, benchmark in config.benchmarks.items()
220
+ for scenario in benchmark.scenarios
221
+ for name in config.get_scenario(benchmark_name, scenario).services
222
+ )
223
+ for name in groups:
224
+ script = config.services[name].startup_script
225
+ if not script:
226
+ raise ValueError(
227
+ f"Managed service '{name}' requires startup_script"
228
+ )
229
+ if not config.skip_volume_mount:
230
+ scripts.append(script)
231
+ for script in scripts:
232
+ if not (root / script).is_file():
233
+ raise FileNotFoundError(f"Script not found: {root / script}")
234
+ if external:
235
+ from .reports import generate_reports
236
+
237
+ output = prepare_run(config, root)
238
+ directory = output / first.directory
239
+ context = prepare_context(config, first, root, directory, endpoints)
240
+ try:
241
+ result = run_only(config, root, first, directory, context, endpoints)
242
+ if result.status == "cancelled":
243
+ return 130
244
+ return 0 if result.status == "ok" else 1
245
+ finally:
246
+ generate_reports(output)
247
+ if state_path.exists():
248
+ if args.command == "up":
249
+ state = json.loads(state_path.read_text())
250
+ directory = Path(state["directory"])
251
+ active = json.loads((directory / ".provider/client.json").read_text())
252
+ if (
253
+ state.get("provider", "local") != config.provider
254
+ or state.get("image") != config.base_image
255
+ or (active["benchmark"], active["scenario"])
256
+ != (first.benchmark, first.scenario)
257
+ or active["global_args"] != config.global_args
258
+ or active.get("service_args", {})
259
+ != {
260
+ group: config.services[group].service_args
261
+ for group in config.get_scenario(
262
+ first.benchmark, first.scenario
263
+ ).services
264
+ }
265
+ or active["scenario_args"]
266
+ != config.get_scenario(first.benchmark, first.scenario).args
267
+ ):
268
+ raise ValueError(
269
+ f"An environment for {active['benchmark']}/"
270
+ f"{active['scenario']} "
271
+ "is already active with different selection "
272
+ "or environment settings. "
273
+ "Run 'vllm_bench down' before switching."
274
+ )
275
+ log(f"Attaching to {first.benchmark}/{first.scenario}: {directory}")
276
+ endpoints = json.loads((directory / "endpoints.json").read_text())
277
+ return show_environment(
278
+ get_provider(config, root),
279
+ endpoints,
280
+ directory,
281
+ config.startup_timeout,
282
+ started,
283
+ args.detach,
284
+ run_only_command(config, config_path, root, first, endpoints),
285
+ )
286
+ stop_saved(state_path)
287
+ output = prepare_run(config, root)
288
+ provider = get_provider(config, root)
289
+ if args.command == "up":
290
+ trial = experiment[0]
291
+ directory = output / trial.directory
292
+ directory.mkdir(parents=True)
293
+ try:
294
+ _, endpoints = start_environment(
295
+ config, provider, trial, directory, wait=False
296
+ )
297
+ except BaseException:
298
+ log("Startup did not complete. Stopping containers and saving logs...")
299
+ provider.stop()
300
+ log(f"Startup output: {directory}")
301
+ raise
302
+ return show_environment(
303
+ provider,
304
+ endpoints,
305
+ directory,
306
+ config.startup_timeout,
307
+ started,
308
+ args.detach,
309
+ run_only_command(config, config_path, root, first, endpoints),
310
+ )
311
+ from .reports import generate_reports
312
+
313
+ failed = False
314
+ try:
315
+ for trial in experiment:
316
+ result = run_trial(
317
+ config, root, provider, trial, output / trial.directory
318
+ )
319
+ if result.status == "cancelled":
320
+ return 130
321
+ failed = failed or result.status != "ok"
322
+ if state_path.exists():
323
+ # Preserve failed cleanup state for recovery; stop scheduling.
324
+ return 1
325
+ return 1 if failed else 0
326
+ finally:
327
+ generate_reports(output)
328
+ except KeyboardInterrupt:
329
+ return 130
330
+ except Exception as exc:
331
+ print(f"Error: {exc}", file=sys.stderr)
332
+ return 1
333
+
334
+
335
+ if __name__ == "__main__":
336
+ raise SystemExit(main())