runtime-experiment-harness 0.2.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.
- runtime_experiment_harness-0.2.0.dist-info/METADATA +152 -0
- runtime_experiment_harness-0.2.0.dist-info/RECORD +12 -0
- runtime_experiment_harness-0.2.0.dist-info/WHEEL +4 -0
- runtime_experiment_harness-0.2.0.dist-info/entry_points.txt +2 -0
- runtime_experiment_harness-0.2.0.dist-info/licenses/LICENSE +21 -0
- runtime_harness/__init__.py +5 -0
- runtime_harness/__main__.py +8 -0
- runtime_harness/banner.py +149 -0
- runtime_harness/checks.py +168 -0
- runtime_harness/cli.py +142 -0
- runtime_harness/data/experiments.yaml +48 -0
- runtime_harness/experiments.py +424 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runtime-experiment-harness
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Runtime experiment harness for vLLM / guidellm benchmarking on NVLink GPU clusters.
|
|
5
|
+
Project-URL: Homepage, https://github.com/GitM-Labs/runtime-experiment-harness
|
|
6
|
+
Project-URL: Repository, https://github.com/GitM-Labs/runtime-experiment-harness
|
|
7
|
+
Project-URL: Issues, https://github.com/GitM-Labs/runtime-experiment-harness
|
|
8
|
+
Author-email: Adit Chawdhary <adit@gitmachine.ai>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: benchmark,gpu,guidellm,inference,llm,nvlink,vllm
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: GPU :: NVIDIA CUDA
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Classifier: Topic :: System :: Benchmark
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: pyyaml>=6.0
|
|
23
|
+
Requires-Dist: rich>=13.0.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: twine; extra == 'dev'
|
|
28
|
+
Provides-Extra: gpu
|
|
29
|
+
Requires-Dist: guidellm; extra == 'gpu'
|
|
30
|
+
Requires-Dist: vllm; extra == 'gpu'
|
|
31
|
+
Provides-Extra: plot
|
|
32
|
+
Requires-Dist: plotly>=5.20.0; extra == 'plot'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# Runtime Experiment Harness
|
|
36
|
+
|
|
37
|
+
A Python experiment harness for benchmarking vLLM with guidellm on NVIDIA
|
|
38
|
+
H100 / NVLink clusters. Ships as the `rex` command.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install runtime-experiment-harness # harness only
|
|
44
|
+
pip install "runtime-experiment-harness[plot]" # + Plotly HTML result plots
|
|
45
|
+
pip install "runtime-experiment-harness[gpu]" # + vllm and guidellm (CUDA hosts only)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`vllm` and `guidellm` are intentionally optional. They are large, CUDA-specific,
|
|
49
|
+
and will not build on a machine without an NVIDIA toolchain — making them
|
|
50
|
+
required would break `pip install` on a laptop. The harness invokes both as
|
|
51
|
+
subprocesses, so install the `gpu` extra on the cluster (or let `rex run
|
|
52
|
+
--install` fetch them at runtime).
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
rex init # write a starter experiments.yaml
|
|
58
|
+
rex check # verify the host and provision it
|
|
59
|
+
rex run # provision, then run every experiment
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`rex check` prepares the box in five steps and stops at the first failure:
|
|
63
|
+
|
|
64
|
+
1. Verify CUDA >= 13.0 (vLLM dropped everything older)
|
|
65
|
+
2. Print GPU count and the `nvidia-smi` NVLink topology matrix
|
|
66
|
+
3. Install `torch`, `vllm`, `guidellm`, `huggingface-hub`, `plotly`
|
|
67
|
+
4. Create the HF cache, `experiments/`, and `/workspace/guidellm_reports`
|
|
68
|
+
5. Point `HF_HOME` at the cache
|
|
69
|
+
|
|
70
|
+
`rex run` performs the same five steps, then runs the sweep. Useful flags on
|
|
71
|
+
both:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
rex check --no-install # verify and set up, skip pip
|
|
75
|
+
rex check --hf-home /mnt/models # default is /workspace/hf_hub
|
|
76
|
+
rex run --config sweep.yaml --output-dir ./results
|
|
77
|
+
rex run --experiments-dir /workspace/experiments
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`python -m runtime_harness` works identically to `rex` if the console script is
|
|
81
|
+
not on your PATH.
|
|
82
|
+
|
|
83
|
+
## Results
|
|
84
|
+
|
|
85
|
+
Each guidellm run is written per-experiment as:
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
experiments/<experiment-id>/<timestamp>_<experiment-id>.json
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
where `<experiment-id>` is `<name>-gpu<N>` — so one manifest entry swept across
|
|
92
|
+
2, 4, and 8 GPUs produces three directories. Timestamps are UTC and
|
|
93
|
+
filename-safe (`20260803T142305Z`); colons are avoided because they are illegal
|
|
94
|
+
in filenames on macOS and Windows and need quoting in every shell.
|
|
95
|
+
|
|
96
|
+
guidellm's own JSON report is written to `/workspace/guidellm_reports` as
|
|
97
|
+
`<timestamp>_<experiment-id>_guidellm.json`, and a copy is placed beside the
|
|
98
|
+
harness record so each run directory is self-contained. A manifest that sets
|
|
99
|
+
`--output kind=json,path=...` explicitly on its `guidellm_command` keeps that
|
|
100
|
+
path — an explicit config value beats the default.
|
|
101
|
+
|
|
102
|
+
## vLLM startup
|
|
103
|
+
|
|
104
|
+
`vllm_args` from the manifest are passed through verbatim, with
|
|
105
|
+
`--tensor-parallel-size` and `--enable-expert-parallel` filled in from the
|
|
106
|
+
`tp`/`ep` fields when not already present. The harness then polls
|
|
107
|
+
`http://localhost:<port>/health` until the server actually accepts traffic
|
|
108
|
+
rather than sleeping a fixed interval — a cold HF cache can take many minutes to
|
|
109
|
+
load a large checkpoint. `--port` is read from `vllm_args` (default 8000). If
|
|
110
|
+
the server dies or fails to come up within 15 minutes, that experiment is
|
|
111
|
+
recorded as failed and the sweep moves on.
|
|
112
|
+
|
|
113
|
+
A combined `experiment_results.json` and a `plots/` folder also land in the
|
|
114
|
+
working directory (override with `--output-dir`).
|
|
115
|
+
|
|
116
|
+
### `HF_HOME` and your shell
|
|
117
|
+
|
|
118
|
+
`rex` exports `HF_HOME` into its own process, so every vLLM and guidellm
|
|
119
|
+
subprocess it launches inherits the cache location. A child process cannot
|
|
120
|
+
change its parent's environment, so this does **not** persist into your shell —
|
|
121
|
+
`rex` prints the matching `export` line if you want it there too.
|
|
122
|
+
|
|
123
|
+
## Requirements
|
|
124
|
+
|
|
125
|
+
- Python 3.10+
|
|
126
|
+
- CUDA 13.0 or newer — vLLM has dropped support for anything older, so `rex
|
|
127
|
+
check` and `rex run` both refuse to proceed below that and tell you what was
|
|
128
|
+
detected.
|
|
129
|
+
- NVIDIA drivers with `nvidia-smi` on `PATH`
|
|
130
|
+
|
|
131
|
+
## Config
|
|
132
|
+
|
|
133
|
+
`experiments.yaml` defines the sweep: `gpu_counts` to iterate over, then one
|
|
134
|
+
entry per experiment with model, prompt, and EP/DP/TP/PP parallelism settings.
|
|
135
|
+
`vllm_args` and `guidellm_args` are passed through verbatim, so any flag the
|
|
136
|
+
underlying tools accept works without changes here. `rex init` writes a
|
|
137
|
+
worked example to start from.
|
|
138
|
+
|
|
139
|
+
## Development
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
pip install -e ".[dev,plot]"
|
|
143
|
+
pytest
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Release
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
python -m build
|
|
150
|
+
python -m twine check dist/*
|
|
151
|
+
python -m twine upload dist/*
|
|
152
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
runtime_harness/__init__.py,sha256=3MibRUzONEGjlgt-VeUdg_8y8ApaR7fXmu52rg_Dg3M,127
|
|
2
|
+
runtime_harness/__main__.py,sha256=ho1jLf3h0btspnOT2K7MJ_qLwZfxuiOSUdkSBDlbxn0,160
|
|
3
|
+
runtime_harness/banner.py,sha256=5pNLuORAQaMQwgPViFSnGe9zNENROVl1f48xBJoyACs,4776
|
|
4
|
+
runtime_harness/checks.py,sha256=GfkH3IwCayL7CBwpoaB8goLeAaL7ZrxNWmbTtZ8sItQ,6256
|
|
5
|
+
runtime_harness/cli.py,sha256=6OJq3BBStoNXNn8b5urnZjVew7GDFdAr9_82HxmC2GQ,4418
|
|
6
|
+
runtime_harness/experiments.py,sha256=mLGaSc4T_7yaQzuuVo2KhBXPWxse1zT5DAujFW1s9cY,14875
|
|
7
|
+
runtime_harness/data/experiments.yaml,sha256=5wUnwTOWpf5D3cER-Q8PVCI1GCjDh1QdaUlFx8AJZRc,1364
|
|
8
|
+
runtime_experiment_harness-0.2.0.dist-info/METADATA,sha256=s5sfelF1buP7-zskSPRTVMkr__GYwyzCSCXf7_bZV0k,5800
|
|
9
|
+
runtime_experiment_harness-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
10
|
+
runtime_experiment_harness-0.2.0.dist-info/entry_points.txt,sha256=yvdKhAFQdEjd62ECc_fU9pKG9GfBPLWRtehRuJZ9ulQ,49
|
|
11
|
+
runtime_experiment_harness-0.2.0.dist-info/licenses/LICENSE,sha256=XDYIWg_2DNTrf0H3TfKpFzg77iPVhdJBuOkgQoLPnTk,1068
|
|
12
|
+
runtime_experiment_harness-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Git Machine
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Startup banner for runtime.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
from banner import banner
|
|
5
|
+
print(banner()) # auto-detects colour support
|
|
6
|
+
print(banner("runtime", subtitle="v0.4.1"))
|
|
7
|
+
|
|
8
|
+
Run directly to preview: python banner.py
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
|
|
17
|
+
# --- 5x5 block font -------------------------------------------------------
|
|
18
|
+
# '#' = filled cell, '.' = empty. Add glyphs here to support more names.
|
|
19
|
+
|
|
20
|
+
FONT: dict[str, tuple[str, ...]] = {
|
|
21
|
+
"R": ("####.", "#...#", "####.", "#..#.", "#...#"),
|
|
22
|
+
"U": ("#...#", "#...#", "#...#", "#...#", ".###."),
|
|
23
|
+
"N": ("#...#", "##..#", "#.#.#", "#..##", "#...#"),
|
|
24
|
+
"T": ("#####", "..#..", "..#..", "..#..", "..#.."),
|
|
25
|
+
"I": ("#####", "..#..", "..#..", "..#..", "#####"),
|
|
26
|
+
"M": ("#...#", "##.##", "#.#.#", "#...#", "#...#"),
|
|
27
|
+
"E": ("#####", "#....", "####.", "#....", "#####"),
|
|
28
|
+
"A": (".###.", "#...#", "#####", "#...#", "#...#"),
|
|
29
|
+
"C": (".####", "#....", "#....", "#....", ".####"),
|
|
30
|
+
"D": ("####.", "#...#", "#...#", "#...#", "####."),
|
|
31
|
+
"O": (".###.", "#...#", "#...#", "#...#", ".###."),
|
|
32
|
+
"S": (".####", "#....", ".###.", "....#", "####."),
|
|
33
|
+
"L": ("#....", "#....", "#....", "#....", "#####"),
|
|
34
|
+
"P": ("####.", "#...#", "####.", "#....", "#...."),
|
|
35
|
+
"Y": ("#...#", ".#.#.", "..#..", "..#..", "..#.."),
|
|
36
|
+
"X": ("#...#", ".#.#.", "..#..", ".#.#.", "#...#"),
|
|
37
|
+
"H": ("#...#", "#...#", "#####", "#...#", "#...#"),
|
|
38
|
+
"V": ("#...#", "#...#", "#...#", ".#.#.", "..#.."),
|
|
39
|
+
"G": (".####", "#....", "#..##", "#...#", ".###."),
|
|
40
|
+
"B": ("####.", "#...#", "####.", "#...#", "####."),
|
|
41
|
+
"F": ("#####", "#....", "####.", "#....", "#...."),
|
|
42
|
+
" ": (".....", ".....", ".....", ".....", "....."),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
BLOCK = "\u25a6" # ▦
|
|
46
|
+
HEIGHT = 5
|
|
47
|
+
|
|
48
|
+
# Vertical gradient, top row brightest. Truecolor RGB.
|
|
49
|
+
GRADIENT = [(0xE6, 0xF7, 0xFF), (0x9A, 0xD9, 0xF5),
|
|
50
|
+
(0x5C, 0xB3, 0xE8), (0x2E, 0x86, 0xC8), (0x1B, 0x5A, 0x9E)]
|
|
51
|
+
|
|
52
|
+
DIM = "\u001b[2m"
|
|
53
|
+
RESET = "\u001b[0m"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def supports_color(stream=None) -> bool:
|
|
57
|
+
"""True when it is safe to emit ANSI escapes."""
|
|
58
|
+
stream = stream or sys.stdout
|
|
59
|
+
if os.environ.get("NO_COLOR") is not None:
|
|
60
|
+
return False
|
|
61
|
+
if os.environ.get("FORCE_COLOR"):
|
|
62
|
+
return True
|
|
63
|
+
if not hasattr(stream, "isatty") or not stream.isatty():
|
|
64
|
+
return False
|
|
65
|
+
return os.environ.get("TERM", "") != "dumb"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _rows(text: str, cell: str, gap: int) -> list[str]:
|
|
69
|
+
glyphs = []
|
|
70
|
+
for ch in text.upper():
|
|
71
|
+
try:
|
|
72
|
+
glyphs.append(FONT[ch])
|
|
73
|
+
except KeyError:
|
|
74
|
+
raise ValueError(f"no glyph for {ch!r}; add one to FONT") from None
|
|
75
|
+
|
|
76
|
+
spacer = " " * gap
|
|
77
|
+
rows = []
|
|
78
|
+
for y in range(HEIGHT):
|
|
79
|
+
parts = ["".join(cell if c == "#" else " " * len(cell) for c in g[y])
|
|
80
|
+
for g in glyphs]
|
|
81
|
+
rows.append(spacer.join(parts).rstrip())
|
|
82
|
+
return rows
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def banner(
|
|
86
|
+
text: str = "runtime",
|
|
87
|
+
subtitle: str | Sequence[str] | None = None,
|
|
88
|
+
*,
|
|
89
|
+
color: bool | None = None,
|
|
90
|
+
cell: str = BLOCK * 2,
|
|
91
|
+
gap: int = 1,
|
|
92
|
+
indent: int = 2,
|
|
93
|
+
) -> str:
|
|
94
|
+
"""Render `text` as a block banner.
|
|
95
|
+
|
|
96
|
+
color=None auto-detects; pass True/False to force.
|
|
97
|
+
cell is the glyph used per filled pixel (two blocks keeps the
|
|
98
|
+
aspect ratio close to square in most terminals).
|
|
99
|
+
"""
|
|
100
|
+
rows = _rows(text, cell, gap)
|
|
101
|
+
pad = " " * indent
|
|
102
|
+
use_color = supports_color() if color is None else color
|
|
103
|
+
|
|
104
|
+
out = []
|
|
105
|
+
for i, row in enumerate(rows):
|
|
106
|
+
if use_color:
|
|
107
|
+
r, g, b = GRADIENT[i % len(GRADIENT)]
|
|
108
|
+
out.append(f"{pad}\u001b[38;2;{r};{g};{b}m{row}{RESET}")
|
|
109
|
+
else:
|
|
110
|
+
out.append(pad + row)
|
|
111
|
+
|
|
112
|
+
if subtitle:
|
|
113
|
+
lines = [subtitle] if isinstance(subtitle, str) else list(subtitle)
|
|
114
|
+
width = max(len(r) for r in rows)
|
|
115
|
+
out.append("")
|
|
116
|
+
for line in lines:
|
|
117
|
+
centered = line.center(width).rstrip()
|
|
118
|
+
out.append(f"{pad}{DIM}{centered}{RESET}" if use_color else pad + centered)
|
|
119
|
+
|
|
120
|
+
return "\n".join(out)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def harness_banner(
|
|
124
|
+
version: str = "",
|
|
125
|
+
model: str | None = None,
|
|
126
|
+
*,
|
|
127
|
+
color: bool | None = None,
|
|
128
|
+
) -> str:
|
|
129
|
+
"""The artwork shown when the harness starts.
|
|
130
|
+
|
|
131
|
+
Renders REX in the block font over a dim strapline. `model` is included only
|
|
132
|
+
when known, so `rex check` (which has no manifest) stays uncluttered.
|
|
133
|
+
"""
|
|
134
|
+
strapline = "runtime experiment harness"
|
|
135
|
+
if version:
|
|
136
|
+
strapline += f" \u00b7 v{version}"
|
|
137
|
+
# strapline += "vLLM \u00b7 guidellm \u00b7 NVLink fabric"
|
|
138
|
+
|
|
139
|
+
subtitles = [strapline]
|
|
140
|
+
if model:
|
|
141
|
+
subtitles.append(model)
|
|
142
|
+
|
|
143
|
+
return banner("RUNTIME", subtitle=subtitles, color=color)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
print()
|
|
148
|
+
print(harness_banner("0.2.0", color=True))
|
|
149
|
+
print()
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Preflight checks for the GPU host: CUDA version and NVLink topology."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
# vLLM dropped support for CUDA older than this.
|
|
18
|
+
MIN_CUDA_VERSION = (13, 0)
|
|
19
|
+
|
|
20
|
+
# Model weights are large; keep them on the roomy workspace volume rather than
|
|
21
|
+
# the default ~/.cache/huggingface, which is often a small root disk.
|
|
22
|
+
DEFAULT_HF_HOME = Path("/workspace/hf_hub")
|
|
23
|
+
DEFAULT_EXPERIMENTS_DIR = Path("experiments")
|
|
24
|
+
DEFAULT_REPORTS_DIR = Path("/workspace/guidellm_reports")
|
|
25
|
+
|
|
26
|
+
# torch is listed alongside vllm so pip resolves them together -- vllm pins an
|
|
27
|
+
# exact torch build, and installing torch first would just get it replaced.
|
|
28
|
+
RUNTIME_PACKAGES = ["torch", "vllm", "guidellm", "huggingface-hub", "plotly"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def format_version(version: tuple[int, int]) -> str:
|
|
32
|
+
return ".".join(str(part) for part in version)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_command(command, capture_output=False, check=True, env=None):
|
|
36
|
+
console.log(f"[blue]Running command:[/blue] {command}")
|
|
37
|
+
process = subprocess.run(
|
|
38
|
+
shlex.split(command),
|
|
39
|
+
capture_output=capture_output,
|
|
40
|
+
text=True,
|
|
41
|
+
check=check,
|
|
42
|
+
env=env,
|
|
43
|
+
)
|
|
44
|
+
return process.stdout if capture_output else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def ensure_dependencies(packages=None):
|
|
48
|
+
console.rule("Installing runtime dependencies")
|
|
49
|
+
packages = list(packages) if packages is not None else RUNTIME_PACKAGES
|
|
50
|
+
command = f"{sys.executable} -m pip install {' '.join(packages)}"
|
|
51
|
+
run_command(command)
|
|
52
|
+
console.log("[green]Dependencies installed successfully[/green]")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def prepare_workspace(hf_home=None, experiments_dir=None, reports_dir=None):
|
|
56
|
+
"""Create the HF cache, experiments, and report directories; point HF at the cache.
|
|
57
|
+
|
|
58
|
+
The environment variables are exported into this process, so every vLLM and
|
|
59
|
+
guidellm subprocess the harness launches inherits them. They do not leak back
|
|
60
|
+
into the calling shell -- see the printed export line for that.
|
|
61
|
+
"""
|
|
62
|
+
hf_home = Path(hf_home) if hf_home is not None else DEFAULT_HF_HOME
|
|
63
|
+
experiments_dir = Path(experiments_dir) if experiments_dir is not None else DEFAULT_EXPERIMENTS_DIR
|
|
64
|
+
reports_dir = Path(reports_dir) if reports_dir is not None else DEFAULT_REPORTS_DIR
|
|
65
|
+
|
|
66
|
+
console.rule("Preparing workspace")
|
|
67
|
+
|
|
68
|
+
flags = {"hf_home": "--hf-home", "experiments": "--experiments-dir", "reports": "--reports-dir"}
|
|
69
|
+
for label, directory in (
|
|
70
|
+
("hf_home", hf_home),
|
|
71
|
+
("experiments", experiments_dir),
|
|
72
|
+
("reports", reports_dir),
|
|
73
|
+
):
|
|
74
|
+
try:
|
|
75
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
except OSError as exc:
|
|
77
|
+
raise RuntimeError(
|
|
78
|
+
f"Could not create {directory}: {exc}. "
|
|
79
|
+
f"Pass {flags[label]} to use a writable location."
|
|
80
|
+
) from exc
|
|
81
|
+
|
|
82
|
+
os.environ["HF_HOME"] = str(hf_home)
|
|
83
|
+
os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
|
|
84
|
+
|
|
85
|
+
console.print(f"[bold]HF_HOME:[/bold] {hf_home}")
|
|
86
|
+
console.print(f"[bold]Experiments directory:[/bold] {experiments_dir.resolve()}")
|
|
87
|
+
console.print(f"[bold]guidellm reports:[/bold] {reports_dir}")
|
|
88
|
+
console.print(f"[dim]For your shell too: export HF_HOME={hf_home}[/dim]")
|
|
89
|
+
|
|
90
|
+
return hf_home, experiments_dir, reports_dir
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parse_cuda_version(smi_output: str):
|
|
94
|
+
"""Return the CUDA version reported by nvidia-smi as an (major, minor) tuple."""
|
|
95
|
+
match = re.search(r"CUDA Version:\s*([0-9]+)\.([0-9]+)", smi_output)
|
|
96
|
+
if match is None:
|
|
97
|
+
return None
|
|
98
|
+
return int(match.group(1)), int(match.group(2))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def ensure_cuda_supported():
|
|
102
|
+
"""Fail fast when the CUDA runtime is older than vLLM supports."""
|
|
103
|
+
if shutil.which("nvidia-smi") is None:
|
|
104
|
+
raise FileNotFoundError("nvidia-smi not found in PATH. NVIDIA drivers must be installed.")
|
|
105
|
+
|
|
106
|
+
smi_output = run_command("nvidia-smi", capture_output=True) or ""
|
|
107
|
+
version = parse_cuda_version(smi_output)
|
|
108
|
+
minimum = format_version(MIN_CUDA_VERSION)
|
|
109
|
+
|
|
110
|
+
if version is None:
|
|
111
|
+
raise RuntimeError(
|
|
112
|
+
f"Could not determine the CUDA version from nvidia-smi output. vLLM requires CUDA >= {minimum}."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
detected = format_version(version)
|
|
116
|
+
if version < MIN_CUDA_VERSION:
|
|
117
|
+
raise RuntimeError(
|
|
118
|
+
f"CUDA {detected} detected, but vLLM requires CUDA >= {minimum}. "
|
|
119
|
+
"Upgrade the NVIDIA driver / CUDA toolkit before running experiments."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
console.print(f"[bold]CUDA version:[/bold] {detected}")
|
|
123
|
+
return version
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def parse_gpu_topology():
|
|
127
|
+
if shutil.which("nvidia-smi") is None:
|
|
128
|
+
raise FileNotFoundError("nvidia-smi not found in PATH. NVIDIA drivers must be installed.")
|
|
129
|
+
|
|
130
|
+
topo_raw = run_command("nvidia-smi topo --matrix", capture_output=True) or ""
|
|
131
|
+
gpu_count = 0
|
|
132
|
+
nvlink_matrix = []
|
|
133
|
+
|
|
134
|
+
for line in topo_raw.splitlines():
|
|
135
|
+
if line.startswith("GPU") and "CPU" not in line:
|
|
136
|
+
parts = re.split(r"\s+", line.strip())
|
|
137
|
+
if parts:
|
|
138
|
+
gpu_count += 1
|
|
139
|
+
nvlink_matrix.append(parts[1:gpu_count + 1])
|
|
140
|
+
|
|
141
|
+
nvlink_available = any("NV" in cell.upper() for row in nvlink_matrix for cell in row)
|
|
142
|
+
return gpu_count, nvlink_available, topo_raw
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def run_preflight(install=True, hf_home=None, experiments_dir=None, reports_dir=None):
|
|
146
|
+
"""Verify the host and provision it for experiments.
|
|
147
|
+
|
|
148
|
+
1. CUDA >= 13.0 (vLLM dropped everything older)
|
|
149
|
+
2. GPU count and NVLink topology matrix
|
|
150
|
+
3. torch / vllm / guidellm / huggingface-hub installed
|
|
151
|
+
4. HF cache, experiments, and guidellm report directories created
|
|
152
|
+
5. HF_HOME pointed at the cache
|
|
153
|
+
|
|
154
|
+
Raises on the first unmet requirement.
|
|
155
|
+
"""
|
|
156
|
+
ensure_cuda_supported()
|
|
157
|
+
|
|
158
|
+
gpu_count, nvlink_available, topo_raw = parse_gpu_topology()
|
|
159
|
+
console.print(f"[bold]Detected GPUs:[/bold] {gpu_count}")
|
|
160
|
+
console.print(f"[bold]NVLink available:[/bold] {nvlink_available}")
|
|
161
|
+
console.print("[bold]Topology matrix:[/bold]\n" + topo_raw)
|
|
162
|
+
|
|
163
|
+
if install:
|
|
164
|
+
ensure_dependencies()
|
|
165
|
+
|
|
166
|
+
hf_home, experiments_dir, reports_dir = prepare_workspace(hf_home, experiments_dir, reports_dir)
|
|
167
|
+
|
|
168
|
+
return gpu_count, nvlink_available, topo_raw, hf_home, experiments_dir, reports_dir
|
runtime_harness/cli.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Command line entry point for the `rex` runtime experiment harness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .banner import harness_banner
|
|
15
|
+
from .checks import DEFAULT_EXPERIMENTS_DIR, DEFAULT_HF_HOME, DEFAULT_REPORTS_DIR, run_preflight
|
|
16
|
+
from .experiments import BUNDLED_CONFIG, DEFAULT_CONFIG_NAME, run_all_experiments
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cmd_check(args) -> int:
|
|
22
|
+
# Printed directly, not via rich: the banner already carries its own ANSI
|
|
23
|
+
# colour, and rich would re-parse those escapes as literal text.
|
|
24
|
+
print(harness_banner(__version__))
|
|
25
|
+
run_preflight(
|
|
26
|
+
install=not args.no_install,
|
|
27
|
+
hf_home=args.hf_home,
|
|
28
|
+
experiments_dir=args.experiments_dir,
|
|
29
|
+
reports_dir=args.reports_dir,
|
|
30
|
+
)
|
|
31
|
+
console.print("[bold green]Host ready.[/bold green] Run [bold]rex run[/bold] to start experiments.")
|
|
32
|
+
return 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cmd_run(args) -> int:
|
|
36
|
+
asyncio.run(
|
|
37
|
+
run_all_experiments(
|
|
38
|
+
args.config,
|
|
39
|
+
install=not args.no_install,
|
|
40
|
+
output_dir=args.output_dir,
|
|
41
|
+
hf_home=args.hf_home,
|
|
42
|
+
experiments_dir=args.experiments_dir,
|
|
43
|
+
reports_dir=args.reports_dir,
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_init(args) -> int:
|
|
50
|
+
destination = args.path
|
|
51
|
+
if destination.exists() and not args.force:
|
|
52
|
+
console.print(
|
|
53
|
+
f"[yellow]{destination} already exists. Pass --force to overwrite.[/yellow]"
|
|
54
|
+
)
|
|
55
|
+
return 1
|
|
56
|
+
shutil.copyfile(BUNDLED_CONFIG, destination)
|
|
57
|
+
console.print(f"[green]Wrote starter manifest to {destination}[/green]")
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
62
|
+
parser = argparse.ArgumentParser(
|
|
63
|
+
prog="rex",
|
|
64
|
+
description="Runtime experiment harness for vLLM / guidellm on NVLink GPU clusters.",
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
67
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
68
|
+
|
|
69
|
+
def add_setup_flags(sub):
|
|
70
|
+
sub.add_argument(
|
|
71
|
+
"--hf-home",
|
|
72
|
+
type=Path,
|
|
73
|
+
default=None,
|
|
74
|
+
help=f"Hugging Face cache directory (default: {DEFAULT_HF_HOME}).",
|
|
75
|
+
)
|
|
76
|
+
sub.add_argument(
|
|
77
|
+
"--experiments-dir",
|
|
78
|
+
type=Path,
|
|
79
|
+
default=None,
|
|
80
|
+
help=f"Directory for per-experiment results (default: ./{DEFAULT_EXPERIMENTS_DIR}).",
|
|
81
|
+
)
|
|
82
|
+
sub.add_argument(
|
|
83
|
+
"--reports-dir",
|
|
84
|
+
type=Path,
|
|
85
|
+
default=None,
|
|
86
|
+
help=f"Directory for guidellm JSON reports (default: {DEFAULT_REPORTS_DIR}).",
|
|
87
|
+
)
|
|
88
|
+
sub.add_argument(
|
|
89
|
+
"--no-install",
|
|
90
|
+
action="store_true",
|
|
91
|
+
help="Skip installing torch/vllm/guidellm/huggingface-hub; verify and set up only.",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
check = subparsers.add_parser(
|
|
95
|
+
"check",
|
|
96
|
+
help="Verify CUDA and topology, install runtime deps, and prepare the workspace.",
|
|
97
|
+
)
|
|
98
|
+
add_setup_flags(check)
|
|
99
|
+
check.set_defaults(func=cmd_check)
|
|
100
|
+
|
|
101
|
+
run = subparsers.add_parser("run", help="Run the experiment suite from a YAML manifest.")
|
|
102
|
+
run.add_argument(
|
|
103
|
+
"--config",
|
|
104
|
+
type=Path,
|
|
105
|
+
default=Path(DEFAULT_CONFIG_NAME),
|
|
106
|
+
help=f"Path to the experiments manifest (default: ./{DEFAULT_CONFIG_NAME}).",
|
|
107
|
+
)
|
|
108
|
+
run.add_argument(
|
|
109
|
+
"--output-dir",
|
|
110
|
+
type=Path,
|
|
111
|
+
default=None,
|
|
112
|
+
help="Directory for plots and experiment_results.json (default: current directory).",
|
|
113
|
+
)
|
|
114
|
+
add_setup_flags(run)
|
|
115
|
+
run.set_defaults(func=cmd_run)
|
|
116
|
+
|
|
117
|
+
init = subparsers.add_parser("init", help="Write a starter experiments.yaml.")
|
|
118
|
+
init.add_argument(
|
|
119
|
+
"path",
|
|
120
|
+
type=Path,
|
|
121
|
+
nargs="?",
|
|
122
|
+
default=Path(DEFAULT_CONFIG_NAME),
|
|
123
|
+
help=f"Where to write the manifest (default: ./{DEFAULT_CONFIG_NAME}).",
|
|
124
|
+
)
|
|
125
|
+
init.add_argument("--force", action="store_true", help="Overwrite an existing file.")
|
|
126
|
+
init.set_defaults(func=cmd_init)
|
|
127
|
+
|
|
128
|
+
return parser
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def main(argv=None) -> int:
|
|
132
|
+
parser = build_parser()
|
|
133
|
+
args = parser.parse_args(argv)
|
|
134
|
+
try:
|
|
135
|
+
return args.func(args)
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
console.print(f"[bold red]Experiment harness failed:[/bold red] {exc}")
|
|
138
|
+
return 1
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
sys.exit(main())
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
gpu_counts:
|
|
2
|
+
- 2
|
|
3
|
+
|
|
4
|
+
experiments:
|
|
5
|
+
- name: "qwen-3.6-35b-a3b-sxm"
|
|
6
|
+
model: "Qwen/Qwen3.6-35B-A3B-FP8"
|
|
7
|
+
prompt: "Summarize the following text: The NVIDIA H100 SXM platform with NVLink provides extremely high bandwidth for large-scale multi-GPU inference."
|
|
8
|
+
batch_size: 4
|
|
9
|
+
sequence_length: 2048
|
|
10
|
+
num_steps: 40
|
|
11
|
+
ep: 1
|
|
12
|
+
dp: 1
|
|
13
|
+
tp: 2
|
|
14
|
+
pp: 1
|
|
15
|
+
vllm_args:
|
|
16
|
+
- "--trust-remote-code"
|
|
17
|
+
- "--enable-expert-parallel"
|
|
18
|
+
- "--tensor-parallel-size"
|
|
19
|
+
- "2"
|
|
20
|
+
- "--gpu-memory-utilization"
|
|
21
|
+
- "0.95"
|
|
22
|
+
- "--max-num-batched-tokens"
|
|
23
|
+
- "8192"
|
|
24
|
+
- "--max-num-seqs"
|
|
25
|
+
- "256"
|
|
26
|
+
- "--max-model-len"
|
|
27
|
+
- "auto"
|
|
28
|
+
- "--enable-auto-tool-choice"
|
|
29
|
+
- "--tool-call-parser"
|
|
30
|
+
- "qwen3_xml"
|
|
31
|
+
- "--reasoning-parser"
|
|
32
|
+
- "qwen3"
|
|
33
|
+
- "--mm-encoder-tp-mode"
|
|
34
|
+
- "data"
|
|
35
|
+
guidellm_command:
|
|
36
|
+
- "run"
|
|
37
|
+
- "--backend"
|
|
38
|
+
- "kind=openai_http,target=http://localhost:8000/v1,model=Qwen/Qwen3.6-35B-A3B-FP8,max_tokens=1024"
|
|
39
|
+
- "--profile"
|
|
40
|
+
- "kind=concurrent,streams=16,warmup=0.1,cooldown=0.1"
|
|
41
|
+
- "--constraint"
|
|
42
|
+
- "kind=max_duration,seconds=300"
|
|
43
|
+
- "--constraint"
|
|
44
|
+
- "kind=over_saturation"
|
|
45
|
+
- "--data"
|
|
46
|
+
- "kind=synthetic_text,prompt_tokens=16384,output_tokens=1024"
|
|
47
|
+
- "--output"
|
|
48
|
+
- "kind=json,path=/workspace/tp2-ep-prof-run.json"
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""Experiment orchestration: launch vLLM, drive guidellm, collect results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import dataclasses
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shlex
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import urllib.request
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import plotly.graph_objects as go
|
|
20
|
+
except ModuleNotFoundError: # pragma: no cover - handled when optional plotting dependency is absent.
|
|
21
|
+
go = None
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
from rich.console import Console
|
|
25
|
+
|
|
26
|
+
from . import __version__
|
|
27
|
+
from .banner import harness_banner
|
|
28
|
+
from .checks import DEFAULT_EXPERIMENTS_DIR, run_preflight
|
|
29
|
+
|
|
30
|
+
console = Console()
|
|
31
|
+
|
|
32
|
+
# Config and outputs resolve against the invocation directory, never the install
|
|
33
|
+
# location -- site-packages is often read-only and is never the user's workspace.
|
|
34
|
+
DEFAULT_CONFIG_NAME = "experiments.yaml"
|
|
35
|
+
BUNDLED_CONFIG = Path(__file__).parent / "data" / DEFAULT_CONFIG_NAME
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def utc_timestamp() -> str:
|
|
39
|
+
"""Filename-safe UTC stamp: 20260803T142305Z.
|
|
40
|
+
|
|
41
|
+
Deliberately not literal `date:time` -- colons are illegal in filenames on
|
|
42
|
+
macOS and Windows and need quoting in every shell command that touches them.
|
|
43
|
+
"""
|
|
44
|
+
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def experiment_id(experiment: "Experiment", gpu_count: int) -> str:
|
|
48
|
+
"""Stable identifier for one (experiment, GPU count) pair in the sweep."""
|
|
49
|
+
return f"{experiment.name}-gpu{gpu_count}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def save_experiment_result(result: dict, experiments_dir: Path, exp_id: str, timestamp=None) -> Path:
|
|
53
|
+
"""Write one guidellm result to experiments/<id>/<timestamp>_<id>.json.
|
|
54
|
+
|
|
55
|
+
If guidellm was configured to emit its own JSON report (via
|
|
56
|
+
`--output kind=json,path=...`), that artifact is copied in alongside so the
|
|
57
|
+
run directory is self-contained.
|
|
58
|
+
"""
|
|
59
|
+
timestamp = timestamp or utc_timestamp()
|
|
60
|
+
run_dir = Path(experiments_dir) / exp_id
|
|
61
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
|
|
63
|
+
output_file = run_dir / f"{timestamp}_{exp_id}.json"
|
|
64
|
+
payload = dict(result)
|
|
65
|
+
payload["experiment_id"] = exp_id
|
|
66
|
+
payload["timestamp"] = timestamp
|
|
67
|
+
payload["latency"] = parse_latency_from_output(result.get("stdout", ""))
|
|
68
|
+
|
|
69
|
+
with output_file.open("w", encoding="utf-8") as handle:
|
|
70
|
+
json.dump(payload, handle, indent=2)
|
|
71
|
+
|
|
72
|
+
guidellm_report = result.get("guidellm_output_path")
|
|
73
|
+
if guidellm_report and Path(guidellm_report).is_file():
|
|
74
|
+
shutil.copyfile(guidellm_report, run_dir / f"{timestamp}_{exp_id}_guidellm.json")
|
|
75
|
+
|
|
76
|
+
console.log(f"[green]Saved {exp_id} result to {output_file}[/green]")
|
|
77
|
+
return output_file
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def guidellm_output_path(experiment: "Experiment"):
|
|
81
|
+
"""Extract the report path guidellm was told to write, if any."""
|
|
82
|
+
for arg in list(experiment.guidellm_command) + list(experiment.guidellm_args):
|
|
83
|
+
match = re.search(r"kind=json,\s*path=([^,\s]+)", arg)
|
|
84
|
+
if match:
|
|
85
|
+
return match.group(1)
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclasses.dataclass
|
|
90
|
+
class Experiment:
|
|
91
|
+
name: str
|
|
92
|
+
model: str
|
|
93
|
+
prompt: str
|
|
94
|
+
batch_size: int
|
|
95
|
+
sequence_length: int
|
|
96
|
+
num_steps: int
|
|
97
|
+
ep: int
|
|
98
|
+
dp: int
|
|
99
|
+
tp: int
|
|
100
|
+
pp: int
|
|
101
|
+
vllm_args: list[str] = dataclasses.field(default_factory=list)
|
|
102
|
+
guidellm_args: list[str] = dataclasses.field(default_factory=list)
|
|
103
|
+
guidellm_command: list[str] = dataclasses.field(default_factory=list)
|
|
104
|
+
metadata: dict = dataclasses.field(default_factory=dict)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def normalize_arg_list(raw):
|
|
108
|
+
if raw is None:
|
|
109
|
+
return []
|
|
110
|
+
if isinstance(raw, str):
|
|
111
|
+
return shlex.split(raw)
|
|
112
|
+
if isinstance(raw, list):
|
|
113
|
+
return [str(item) for item in raw]
|
|
114
|
+
raise ValueError("Argument lists must be strings or lists.")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def load_experiments(config_path: Path):
|
|
118
|
+
with config_path.open("r", encoding="utf-8") as handle:
|
|
119
|
+
config = yaml.safe_load(handle)
|
|
120
|
+
|
|
121
|
+
if not isinstance(config, dict):
|
|
122
|
+
raise ValueError("Configuration file must contain a YAML mapping.")
|
|
123
|
+
|
|
124
|
+
gpu_counts = config.get("gpu_counts", [2, 4, 8])
|
|
125
|
+
experiments = []
|
|
126
|
+
for raw in config.get("experiments", []):
|
|
127
|
+
experiment = Experiment(
|
|
128
|
+
name=raw["name"],
|
|
129
|
+
model=raw["model"],
|
|
130
|
+
prompt=raw["prompt"],
|
|
131
|
+
batch_size=int(raw.get("batch_size", 1)),
|
|
132
|
+
sequence_length=int(raw.get("sequence_length", 512)),
|
|
133
|
+
num_steps=int(raw.get("num_steps", 10)),
|
|
134
|
+
ep=int(raw.get("ep", 1)),
|
|
135
|
+
dp=int(raw.get("dp", 1)),
|
|
136
|
+
tp=int(raw.get("tp", 1)),
|
|
137
|
+
pp=int(raw.get("pp", 1)),
|
|
138
|
+
vllm_args=normalize_arg_list(raw.get("vllm_args")),
|
|
139
|
+
guidellm_args=normalize_arg_list(raw.get("guidellm_args")),
|
|
140
|
+
guidellm_command=normalize_arg_list(raw.get("guidellm_command")),
|
|
141
|
+
metadata=raw.get("metadata", {}),
|
|
142
|
+
)
|
|
143
|
+
experiments.append(experiment)
|
|
144
|
+
|
|
145
|
+
return gpu_counts, experiments
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def build_vllm_command(experiment: Experiment, gpu_count: int):
|
|
149
|
+
command = [sys.executable, "-m", "vllm", "serve", experiment.model]
|
|
150
|
+
|
|
151
|
+
if experiment.tp and not any(arg.startswith("--tensor-parallel-size") for arg in experiment.vllm_args):
|
|
152
|
+
command += ["--tensor-parallel-size", str(experiment.tp)]
|
|
153
|
+
|
|
154
|
+
if experiment.ep > 1 and "--enable-expert-parallel" not in experiment.vllm_args:
|
|
155
|
+
command.append("--enable-expert-parallel")
|
|
156
|
+
|
|
157
|
+
command += experiment.vllm_args
|
|
158
|
+
return command
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def vllm_port(experiment: Experiment, default: int = 8000) -> int:
|
|
162
|
+
"""Port the manifest asked vLLM to serve on, so readiness polls the right place."""
|
|
163
|
+
args = experiment.vllm_args
|
|
164
|
+
for index, arg in enumerate(args):
|
|
165
|
+
if arg == "--port" and index + 1 < len(args):
|
|
166
|
+
return int(args[index + 1])
|
|
167
|
+
if arg.startswith("--port="):
|
|
168
|
+
return int(arg.split("=", 1)[1])
|
|
169
|
+
return default
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def server_is_ready(port: int, timeout: float = 2.0) -> bool:
|
|
173
|
+
url = f"http://localhost:{port}/health"
|
|
174
|
+
try:
|
|
175
|
+
with urllib.request.urlopen(url, timeout=timeout) as response:
|
|
176
|
+
return 200 <= response.status < 300
|
|
177
|
+
except Exception:
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def build_guidellm_command(experiment: Experiment):
|
|
182
|
+
if experiment.guidellm_command:
|
|
183
|
+
return [sys.executable, "-m", "guidellm"] + experiment.guidellm_command
|
|
184
|
+
|
|
185
|
+
command = [
|
|
186
|
+
sys.executable,
|
|
187
|
+
"-m",
|
|
188
|
+
"guidellm",
|
|
189
|
+
"run",
|
|
190
|
+
"--name",
|
|
191
|
+
experiment.name,
|
|
192
|
+
"--model",
|
|
193
|
+
experiment.model,
|
|
194
|
+
"--prompt",
|
|
195
|
+
experiment.prompt,
|
|
196
|
+
"--batch-size",
|
|
197
|
+
str(experiment.batch_size),
|
|
198
|
+
"--sequence-length",
|
|
199
|
+
str(experiment.sequence_length),
|
|
200
|
+
"--num-steps",
|
|
201
|
+
str(experiment.num_steps),
|
|
202
|
+
]
|
|
203
|
+
command += experiment.guidellm_args
|
|
204
|
+
return command
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def start_vllm_server(experiment: Experiment, gpu_count: int, startup_timeout: float = 900.0):
|
|
208
|
+
"""Launch vLLM with the manifest's parameters and wait until it serves traffic.
|
|
209
|
+
|
|
210
|
+
Loading a large checkpoint can take many minutes on a cold HF cache, so this
|
|
211
|
+
polls /health until ready rather than assuming a fixed startup delay.
|
|
212
|
+
"""
|
|
213
|
+
console.rule(f"Starting vLLM server: {experiment.name} on {gpu_count} GPUs")
|
|
214
|
+
command = build_vllm_command(experiment, gpu_count)
|
|
215
|
+
port = vllm_port(experiment)
|
|
216
|
+
env = os.environ.copy()
|
|
217
|
+
env["CUDA_VISIBLE_DEVICES"] = ",".join(str(i) for i in range(gpu_count))
|
|
218
|
+
|
|
219
|
+
console.log(f"[blue]vLLM command:[/blue] {shlex.join(command)}")
|
|
220
|
+
|
|
221
|
+
process = subprocess.Popen(
|
|
222
|
+
command,
|
|
223
|
+
stdout=subprocess.DEVNULL,
|
|
224
|
+
stderr=subprocess.PIPE,
|
|
225
|
+
text=True,
|
|
226
|
+
env=env,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def failure(stderr: str):
|
|
230
|
+
return {
|
|
231
|
+
"name": experiment.name,
|
|
232
|
+
"engine": "vllm",
|
|
233
|
+
"gpu_count": gpu_count,
|
|
234
|
+
"port": port,
|
|
235
|
+
"success": False,
|
|
236
|
+
"stdout": "",
|
|
237
|
+
"stderr": stderr,
|
|
238
|
+
}, None
|
|
239
|
+
|
|
240
|
+
deadline = asyncio.get_running_loop().time() + startup_timeout
|
|
241
|
+
while True:
|
|
242
|
+
if process.poll() is not None:
|
|
243
|
+
stderr = process.stderr.read() if process.stderr else ""
|
|
244
|
+
console.log(f"[red]vLLM server exited during startup for {experiment.name}[/red]")
|
|
245
|
+
console.log(stderr)
|
|
246
|
+
return failure(stderr)
|
|
247
|
+
|
|
248
|
+
if await asyncio.to_thread(server_is_ready, port):
|
|
249
|
+
break
|
|
250
|
+
|
|
251
|
+
if asyncio.get_running_loop().time() >= deadline:
|
|
252
|
+
console.log(
|
|
253
|
+
f"[red]vLLM server did not become ready within {startup_timeout:.0f}s "
|
|
254
|
+
f"for {experiment.name}[/red]"
|
|
255
|
+
)
|
|
256
|
+
process.terminate()
|
|
257
|
+
return failure(f"Timed out waiting for http://localhost:{port}/health")
|
|
258
|
+
|
|
259
|
+
await asyncio.sleep(3)
|
|
260
|
+
|
|
261
|
+
console.log(f"[green]vLLM server ready for {experiment.name} on port {port}[/green]")
|
|
262
|
+
return {
|
|
263
|
+
"name": experiment.name,
|
|
264
|
+
"engine": "vllm",
|
|
265
|
+
"gpu_count": gpu_count,
|
|
266
|
+
"port": port,
|
|
267
|
+
"success": True,
|
|
268
|
+
"stdout": "",
|
|
269
|
+
"stderr": "",
|
|
270
|
+
}, process
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def run_guidellm(experiment: Experiment, reports_dir=None, exp_id=None, timestamp=None):
|
|
274
|
+
"""Run guidellm, directing its JSON report into the reports directory.
|
|
275
|
+
|
|
276
|
+
A manifest that already specifies `--output kind=json,path=...` is left
|
|
277
|
+
alone -- an explicit path in the config beats our default.
|
|
278
|
+
"""
|
|
279
|
+
console.rule(f"Running guidellm: {experiment.name}")
|
|
280
|
+
command = build_guidellm_command(experiment)
|
|
281
|
+
|
|
282
|
+
report_path = guidellm_output_path(experiment)
|
|
283
|
+
if report_path is None and reports_dir is not None:
|
|
284
|
+
exp_id = exp_id or experiment.name
|
|
285
|
+
timestamp = timestamp or utc_timestamp()
|
|
286
|
+
report_path = str(Path(reports_dir) / f"{timestamp}_{exp_id}_guidellm.json")
|
|
287
|
+
Path(report_path).parent.mkdir(parents=True, exist_ok=True)
|
|
288
|
+
command += ["--output", f"kind=json,path={report_path}"]
|
|
289
|
+
|
|
290
|
+
process = subprocess.run(command, capture_output=True, text=True)
|
|
291
|
+
success = process.returncode == 0
|
|
292
|
+
if not success:
|
|
293
|
+
console.log(f"[red]guidellm failed for {experiment.name}[/red]")
|
|
294
|
+
console.log(process.stderr)
|
|
295
|
+
return {
|
|
296
|
+
"name": experiment.name,
|
|
297
|
+
"engine": "guidellm",
|
|
298
|
+
"success": success,
|
|
299
|
+
"stdout": process.stdout,
|
|
300
|
+
"stderr": process.stderr,
|
|
301
|
+
"guidellm_output_path": report_path,
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def parse_latency_from_output(output: str):
|
|
306
|
+
match = re.search(r"latency[:=]\s*([0-9]+(?:\.[0-9]+)?)", output, re.IGNORECASE)
|
|
307
|
+
if match:
|
|
308
|
+
return float(match.group(1))
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
async def plot_results_async(results: list[dict], output_dir: Path):
|
|
313
|
+
if go is None:
|
|
314
|
+
console.log("[yellow]Plotly is not installed; skipping HTML result plotting.[/yellow]")
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
318
|
+
output_file = output_dir / f"results_{len(results)}.html"
|
|
319
|
+
|
|
320
|
+
fig = go.Figure()
|
|
321
|
+
for result in results:
|
|
322
|
+
latency = parse_latency_from_output(result.get("stdout", ""))
|
|
323
|
+
score = result.get("metadata", {}).get("score")
|
|
324
|
+
label = f"{result['name']}:{result['engine']}"
|
|
325
|
+
if latency is not None:
|
|
326
|
+
fig.add_trace(go.Bar(name=label, x=[label], y=[latency], marker_color="#1f77b4"))
|
|
327
|
+
elif score is not None:
|
|
328
|
+
fig.add_trace(go.Bar(name=label, x=[label], y=[score], marker_color="#ff7f0e"))
|
|
329
|
+
else:
|
|
330
|
+
fig.add_trace(go.Bar(name=label, x=[label], y=[0], marker_color="#2ca02c"))
|
|
331
|
+
|
|
332
|
+
fig.update_layout(
|
|
333
|
+
title="Experiment Results",
|
|
334
|
+
xaxis_title="Experiment",
|
|
335
|
+
yaxis_title="Metric",
|
|
336
|
+
barmode="group",
|
|
337
|
+
template="plotly_dark",
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
await asyncio.to_thread(fig.write_html, str(output_file), include_plotlyjs="cdn")
|
|
341
|
+
console.log(f"[green]Saved plot to {output_file}[/green]")
|
|
342
|
+
return output_file
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def render_runtime_banner(model: str | None = None, version: str | None = None) -> str:
|
|
346
|
+
"""Startup artwork. Delegates to the block-font renderer in banner.py."""
|
|
347
|
+
return harness_banner(version or __version__, model)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
async def run_all_experiments(
|
|
351
|
+
config_path: Path,
|
|
352
|
+
install: bool = True,
|
|
353
|
+
output_dir: Path | None = None,
|
|
354
|
+
hf_home: Path | None = None,
|
|
355
|
+
experiments_dir: Path | None = None,
|
|
356
|
+
reports_dir: Path | None = None,
|
|
357
|
+
):
|
|
358
|
+
output_dir = Path(output_dir) if output_dir is not None else Path.cwd()
|
|
359
|
+
|
|
360
|
+
# See cmd_check: the banner carries its own ANSI colour, so bypass rich.
|
|
361
|
+
print(render_runtime_banner())
|
|
362
|
+
|
|
363
|
+
if not config_path.exists():
|
|
364
|
+
raise FileNotFoundError(
|
|
365
|
+
f"No experiment manifest at {config_path}. Run 'rex init' to write a starter manifest."
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
gpu_count, _nvlink, _topo, _hf_home, experiments_dir, reports_dir = run_preflight(
|
|
369
|
+
install=install,
|
|
370
|
+
hf_home=hf_home,
|
|
371
|
+
experiments_dir=experiments_dir if experiments_dir is not None else output_dir / DEFAULT_EXPERIMENTS_DIR,
|
|
372
|
+
reports_dir=reports_dir,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
gpu_counts, experiments = load_experiments(config_path)
|
|
376
|
+
results = []
|
|
377
|
+
pending_plots = []
|
|
378
|
+
|
|
379
|
+
desired_gpu_counts = [count for count in gpu_counts if count <= gpu_count]
|
|
380
|
+
if not desired_gpu_counts:
|
|
381
|
+
raise ValueError(f"No supported GPU counts found for this machine. Detected {gpu_count}.")
|
|
382
|
+
|
|
383
|
+
for exp in experiments:
|
|
384
|
+
for count in desired_gpu_counts:
|
|
385
|
+
exp_id = experiment_id(exp, count)
|
|
386
|
+
timestamp = utc_timestamp()
|
|
387
|
+
vllm_result, server_process = await start_vllm_server(exp, count)
|
|
388
|
+
results.append(vllm_result)
|
|
389
|
+
|
|
390
|
+
if vllm_result["success"]:
|
|
391
|
+
guidellm_result = run_guidellm(exp, reports_dir, exp_id, timestamp)
|
|
392
|
+
results.append(guidellm_result)
|
|
393
|
+
else:
|
|
394
|
+
guidellm_result = {
|
|
395
|
+
"name": exp.name,
|
|
396
|
+
"engine": "guidellm",
|
|
397
|
+
"success": False,
|
|
398
|
+
"stdout": "",
|
|
399
|
+
"stderr": "vLLM server failed to start.",
|
|
400
|
+
}
|
|
401
|
+
results.append(guidellm_result)
|
|
402
|
+
|
|
403
|
+
guidellm_result["gpu_count"] = count
|
|
404
|
+
save_experiment_result(guidellm_result, experiments_dir, exp_id, timestamp)
|
|
405
|
+
|
|
406
|
+
if server_process is not None and server_process.poll() is None:
|
|
407
|
+
server_process.terminate()
|
|
408
|
+
try:
|
|
409
|
+
server_process.wait(timeout=10)
|
|
410
|
+
except subprocess.TimeoutExpired:
|
|
411
|
+
server_process.kill()
|
|
412
|
+
|
|
413
|
+
task = asyncio.create_task(plot_results_async(results.copy(), output_dir / "plots"))
|
|
414
|
+
pending_plots.append(task)
|
|
415
|
+
|
|
416
|
+
if pending_plots:
|
|
417
|
+
await asyncio.gather(*pending_plots)
|
|
418
|
+
|
|
419
|
+
output_json = output_dir / "experiment_results.json"
|
|
420
|
+
output_json.parent.mkdir(parents=True, exist_ok=True)
|
|
421
|
+
with output_json.open("w", encoding="utf-8") as handle:
|
|
422
|
+
json.dump(results, handle, indent=2)
|
|
423
|
+
console.log(f"[green]Saved results to {output_json}[/green]")
|
|
424
|
+
return results
|