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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ rex = runtime_harness.cli:main
@@ -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,5 @@
1
+ """Runtime experiment harness for vLLM / guidellm on NVLink GPU clusters."""
2
+
3
+ __version__ = "0.2.0"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,8 @@
1
+ """Allow `python -m runtime_harness` alongside the `rex` console script."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -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