vllm-optimizer 0.1.0a11__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_optimizer/__init__.py +7 -0
- vllm_optimizer/benchmarks/__init__.py +13 -0
- vllm_optimizer/benchmarks/configuration.py +84 -0
- vllm_optimizer/benchmarks/guidellm.py +126 -0
- vllm_optimizer/benchmarks/metrics.py +122 -0
- vllm_optimizer/benchmarks/timing.py +77 -0
- vllm_optimizer/benchmarks/vllm.py +92 -0
- vllm_optimizer/cli.py +117 -0
- vllm_optimizer/cli_options.py +46 -0
- vllm_optimizer/config/__init__.py +6 -0
- vllm_optimizer/config/errors.py +17 -0
- vllm_optimizer/config/loader.py +145 -0
- vllm_optimizer/config/models.py +31 -0
- vllm_optimizer/config/preflight.py +95 -0
- vllm_optimizer/config/runtime.py +69 -0
- vllm_optimizer/domain/__init__.py +25 -0
- vllm_optimizer/domain/attempt_report.py +18 -0
- vllm_optimizer/domain/benchmark.py +52 -0
- vllm_optimizer/domain/models.py +69 -0
- vllm_optimizer/domain/results.py +59 -0
- vllm_optimizer/domain/states.py +31 -0
- vllm_optimizer/domain/trial_report.py +73 -0
- vllm_optimizer/execution/__init__.py +10 -0
- vllm_optimizer/execution/finalist_validation.py +46 -0
- vllm_optimizer/execution/scheduler.py +93 -0
- vllm_optimizer/execution/slots.py +118 -0
- vllm_optimizer/execution/trial_executor.py +97 -0
- vllm_optimizer/lifecycle/__init__.py +5 -0
- vllm_optimizer/lifecycle/integrity.py +102 -0
- vllm_optimizer/lifecycle/retry.py +105 -0
- vllm_optimizer/managers/__init__.py +9 -0
- vllm_optimizer/managers/results.py +68 -0
- vllm_optimizer/managers/run_results.py +143 -0
- vllm_optimizer/managers/run_session.py +90 -0
- vllm_optimizer/managers/scoring.py +123 -0
- vllm_optimizer/managers/trial.py +106 -0
- vllm_optimizer/measurement.py +78 -0
- vllm_optimizer/orchestrator.py +199 -0
- vllm_optimizer/py.typed +1 -0
- vllm_optimizer/reporting/__init__.py +6 -0
- vllm_optimizer/reporting/analysis.py +149 -0
- vllm_optimizer/reporting/benchmark_details.py +59 -0
- vllm_optimizer/reporting/charts.py +97 -0
- vllm_optimizer/reporting/context.py +23 -0
- vllm_optimizer/reporting/dashboard.py +140 -0
- vllm_optimizer/reporting/importance.py +23 -0
- vllm_optimizer/reporting/llm_summary.py +123 -0
- vllm_optimizer/reporting/measurement.py +67 -0
- vllm_optimizer/reporting/methodology.py +16 -0
- vllm_optimizer/reporting/offline.py +179 -0
- vllm_optimizer/reporting/reporter.py +67 -0
- vllm_optimizer/reporting/styles.py +11 -0
- vllm_optimizer/reporting/tables.py +122 -0
- vllm_optimizer/reporting/validation.py +58 -0
- vllm_optimizer/reproduction/__init__.py +1 -0
- vllm_optimizer/reproduction/display.py +63 -0
- vllm_optimizer/reproduction/export.py +13 -0
- vllm_optimizer/reproduction/manifest.py +77 -0
- vllm_optimizer/reproduction/metadata.py +66 -0
- vllm_optimizer/reproduction/models.py +36 -0
- vllm_optimizer/reproduction/reader.py +48 -0
- vllm_optimizer/reproduction/redaction.py +40 -0
- vllm_optimizer/search/__init__.py +8 -0
- vllm_optimizer/search/factory.py +44 -0
- vllm_optimizer/search/fixed_session.py +26 -0
- vllm_optimizer/search/grid.py +65 -0
- vllm_optimizer/search/grid_session.py +29 -0
- vllm_optimizer/search/optuna_session.py +121 -0
- vllm_optimizer/search/strategy.py +18 -0
- vllm_optimizer/terminal.py +140 -0
- vllm_optimizer/terminal_style.py +30 -0
- vllm_optimizer/workers/__init__.py +23 -0
- vllm_optimizer/workers/attempts.py +15 -0
- vllm_optimizer/workers/base.py +33 -0
- vllm_optimizer/workers/benchmark.py +140 -0
- vllm_optimizer/workers/completion.py +96 -0
- vllm_optimizer/workers/configuration.py +115 -0
- vllm_optimizer/workers/drain.py +139 -0
- vllm_optimizer/workers/factory.py +75 -0
- vllm_optimizer/workers/failure_details.py +50 -0
- vllm_optimizer/workers/process.py +147 -0
- vllm_optimizer/workers/readiness.py +119 -0
- vllm_optimizer/workers/vllm.py +73 -0
- vllm_optimizer/workers/vllm_benchmark.py +127 -0
- vllm_optimizer-0.1.0a11.dist-info/METADATA +224 -0
- vllm_optimizer-0.1.0a11.dist-info/RECORD +91 -0
- vllm_optimizer-0.1.0a11.dist-info/WHEEL +5 -0
- vllm_optimizer-0.1.0a11.dist-info/entry_points.txt +3 -0
- vllm_optimizer-0.1.0a11.dist-info/licenses/LICENSE +21 -0
- vllm_optimizer-0.1.0a11.dist-info/top_level.txt +2 -0
- vtune/__init__.py +11 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Benchmark backend adapters."""
|
|
2
|
+
|
|
3
|
+
from .configuration import (
|
|
4
|
+
configured_engine, configured_min_repeats, configured_repeats,
|
|
5
|
+
configured_runs, configured_warmup_repeats,
|
|
6
|
+
)
|
|
7
|
+
from .guidellm import GuideLLMPlan, build_plan, parse_result
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"GuideLLMPlan", "build_plan", "configured_engine", "configured_min_repeats",
|
|
11
|
+
"configured_repeats", "configured_runs", "configured_warmup_repeats",
|
|
12
|
+
"parse_result",
|
|
13
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Shared benchmark configuration validation."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from vllm_optimizer.config.models import VTuneConfig
|
|
7
|
+
|
|
8
|
+
_RUN_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
|
|
9
|
+
_ENGINES = {"guidellm", "vllm"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def configured_engine(config: VTuneConfig) -> str:
|
|
13
|
+
value = config.benchmark.get("engine", "guidellm")
|
|
14
|
+
if not isinstance(value, str) or value not in _ENGINES:
|
|
15
|
+
raise ValueError("benchmark.engine must be 'guidellm' or 'vllm'")
|
|
16
|
+
return value
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def configured_repeats(config: VTuneConfig) -> int:
|
|
20
|
+
value = config.benchmark.get("repeats", 1)
|
|
21
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
22
|
+
raise ValueError("benchmark.repeats must be a positive integer")
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def configured_warmup_repeats(config: VTuneConfig) -> int:
|
|
27
|
+
value = config.benchmark.get("warmup_repeats", 0)
|
|
28
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
29
|
+
raise ValueError("benchmark.warmup_repeats must be a non-negative integer")
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def configured_min_repeats(config: VTuneConfig) -> int:
|
|
34
|
+
value = config.benchmark.get("min_repeats", 1)
|
|
35
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
36
|
+
raise ValueError("benchmark.min_repeats must be a positive integer")
|
|
37
|
+
if value > configured_repeats(config):
|
|
38
|
+
raise ValueError("benchmark.min_repeats cannot exceed benchmark.repeats")
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def configured_runs(config: VTuneConfig) -> tuple[Mapping[str, object], ...]:
|
|
43
|
+
unknown = set(config.benchmark) - {
|
|
44
|
+
"engine", "runs", "repeats", "warmup_repeats", "min_repeats",
|
|
45
|
+
}
|
|
46
|
+
if unknown:
|
|
47
|
+
raise ValueError(f"Unsupported benchmark setting(s): {', '.join(sorted(unknown))}")
|
|
48
|
+
values = config.benchmark.get("runs")
|
|
49
|
+
if not isinstance(values, list) or not values:
|
|
50
|
+
raise ValueError("'benchmark.runs' must be a non-empty list")
|
|
51
|
+
engine, names = configured_engine(config), set()
|
|
52
|
+
runs: list[Mapping[str, object]] = []
|
|
53
|
+
for index, value in enumerate(values):
|
|
54
|
+
run = _mapping(value, f"run {index}")
|
|
55
|
+
name = run.get("name")
|
|
56
|
+
if not isinstance(name, str) or not _RUN_NAME.fullmatch(name):
|
|
57
|
+
raise ValueError("benchmark run names must use letters, numbers, '_' or '-'")
|
|
58
|
+
if name in names:
|
|
59
|
+
raise ValueError(f"duplicate benchmark run name: {name}")
|
|
60
|
+
names.add(name)
|
|
61
|
+
_validate_run(engine, run, index, name)
|
|
62
|
+
runs.append(run)
|
|
63
|
+
return tuple(runs)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _validate_run(engine: str, run: Mapping[str, object], index: int, name: str) -> None:
|
|
67
|
+
allowed = ({"name", "request_format", "profile", "constraints", "data"}
|
|
68
|
+
if engine == "guidellm" else {"name", "args"})
|
|
69
|
+
if unknown := set(run) - allowed:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"Unsupported setting(s) in benchmark run {index}: {', '.join(sorted(unknown))}"
|
|
72
|
+
)
|
|
73
|
+
if engine == "guidellm":
|
|
74
|
+
data = run.get("data")
|
|
75
|
+
if not isinstance(data, list) or len(data) != 1:
|
|
76
|
+
raise ValueError(f"benchmark run '{name}' must configure exactly one dataset")
|
|
77
|
+
else:
|
|
78
|
+
_mapping(run.get("args", {}), f"vllm benchmark run '{name}' args")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _mapping(value: object, label: str) -> Mapping[str, object]:
|
|
82
|
+
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
|
83
|
+
raise ValueError(f"{label} must be an object")
|
|
84
|
+
return value
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""GuideLLM configuration, command construction, and result normalization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from vllm_optimizer.config.models import VTuneConfig
|
|
11
|
+
from vllm_optimizer.config.runtime import model_path
|
|
12
|
+
from vllm_optimizer.domain.benchmark import BenchmarkResult, WorkloadResult
|
|
13
|
+
from vllm_optimizer.benchmarks.timing import normalize_durations
|
|
14
|
+
from vllm_optimizer.benchmarks.metrics import normalize_guidellm_metrics
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class GuideLLMPlan:
|
|
18
|
+
run_name: str
|
|
19
|
+
argv: tuple[str, ...]
|
|
20
|
+
directory: Path
|
|
21
|
+
json_path: Path
|
|
22
|
+
log_path: Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def configured_runs(config: VTuneConfig) -> tuple[Mapping[str, object], ...]:
|
|
26
|
+
from vllm_optimizer.benchmarks.configuration import configured_runs as validate
|
|
27
|
+
return validate(config)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def configured_repeats(config: VTuneConfig) -> int:
|
|
31
|
+
from vllm_optimizer.benchmarks.configuration import configured_repeats as validate
|
|
32
|
+
return validate(config)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_plan(
|
|
36
|
+
config: VTuneConfig, run: Mapping[str, object], endpoint: str, artifacts: Path,
|
|
37
|
+
) -> GuideLLMPlan:
|
|
38
|
+
name = run["name"]
|
|
39
|
+
request_format = run.get("request_format", "/v1/completions")
|
|
40
|
+
if not isinstance(request_format, str) or not request_format.strip():
|
|
41
|
+
raise ValueError("benchmark request_format must be a non-empty string")
|
|
42
|
+
directory = Path(artifacts) / str(name)
|
|
43
|
+
json_path = directory / "results.json"
|
|
44
|
+
argv = [
|
|
45
|
+
"guidellm", "run", "--backend",
|
|
46
|
+
_serialize({"kind": "openai_http", "target": endpoint,
|
|
47
|
+
"model": model_path(config), "request_format": request_format}),
|
|
48
|
+
"--profile", _serialize(_mapping(run.get("profile"), "profile")),
|
|
49
|
+
]
|
|
50
|
+
for option, label in (("constraints", "constraint"), ("data", "data")):
|
|
51
|
+
values = run.get(option, [])
|
|
52
|
+
if not isinstance(values, list):
|
|
53
|
+
raise ValueError(f"benchmark run '{option}' must be a list")
|
|
54
|
+
for value in values:
|
|
55
|
+
if label == "constraint" and isinstance(value, dict) and value.get("kind") == "max_duration":
|
|
56
|
+
value = {**value, "seconds": normalize_durations(value.get("seconds"))}
|
|
57
|
+
argv.extend((f"--{label}", _serialize(_mapping(value, label))))
|
|
58
|
+
argv.extend(("--output", f"kind=json,path={json_path}"))
|
|
59
|
+
return GuideLLMPlan(str(name), tuple(argv), directory, json_path,
|
|
60
|
+
directory / "benchmark.log")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_result(path: Path, run_name: str) -> BenchmarkResult:
|
|
64
|
+
source = Path(path)
|
|
65
|
+
try:
|
|
66
|
+
root = _mapping(json.loads(source.read_text(encoding="utf-8")), "result")
|
|
67
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
68
|
+
raise ValueError(f"Cannot read GuideLLM JSON result: {error}") from error
|
|
69
|
+
metadata = _mapping(root.get("metadata"), "metadata")
|
|
70
|
+
version = metadata.get("guidellm_version")
|
|
71
|
+
if not isinstance(version, str) or not version.strip():
|
|
72
|
+
raise ValueError("GuideLLM result is missing its version")
|
|
73
|
+
benchmarks = root.get("benchmarks")
|
|
74
|
+
if not isinstance(benchmarks, list) or not benchmarks:
|
|
75
|
+
raise ValueError("GuideLLM result must contain benchmarks")
|
|
76
|
+
workloads = tuple(
|
|
77
|
+
WorkloadResult(
|
|
78
|
+
index, _mapping(item.get("config"), f"benchmark {index} config"),
|
|
79
|
+
_normalized_metrics(_mapping(item.get("metrics"), f"benchmark {index} metrics")),
|
|
80
|
+
)
|
|
81
|
+
for index, value in enumerate(benchmarks)
|
|
82
|
+
for item in (_mapping(value, f"benchmark {index}"),)
|
|
83
|
+
)
|
|
84
|
+
return BenchmarkResult(run_name, "guidellm", version, workloads, source)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _normalized_metrics(raw: Mapping[str, object]) -> dict[str, object]:
|
|
88
|
+
metrics = normalize_guidellm_metrics(raw)
|
|
89
|
+
totals = raw.get("request_totals")
|
|
90
|
+
if (isinstance(totals, Mapping) and isinstance(totals.get("total"), int)
|
|
91
|
+
and not isinstance(totals.get("total"), bool)):
|
|
92
|
+
metrics["request_total"] = totals["total"]
|
|
93
|
+
return metrics
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _serialize(values: Mapping[str, object]) -> str:
|
|
97
|
+
kind = values.get("kind")
|
|
98
|
+
if not isinstance(kind, str) or not kind.strip():
|
|
99
|
+
raise ValueError("GuideLLM options require a non-empty 'kind'")
|
|
100
|
+
return ",".join(pair for key, value in values.items() for pair in _pairs(key, value))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _pairs(path: str, value: object) -> tuple[str, ...]:
|
|
104
|
+
if isinstance(value, Mapping):
|
|
105
|
+
return tuple(pair for key, item in value.items()
|
|
106
|
+
for pair in _pairs(f"{path}.{key}", item))
|
|
107
|
+
if isinstance(value, list | tuple):
|
|
108
|
+
return tuple(pair for index, item in enumerate(value)
|
|
109
|
+
for pair in _pairs(f"{path}[{index}]", item))
|
|
110
|
+
if isinstance(value, bool):
|
|
111
|
+
rendered = str(value).lower()
|
|
112
|
+
if isinstance(value, str | int | float) and not isinstance(value, bool):
|
|
113
|
+
rendered = str(value)
|
|
114
|
+
elif value is None:
|
|
115
|
+
rendered = "null"
|
|
116
|
+
elif not isinstance(value, bool):
|
|
117
|
+
raise ValueError(f"Unsupported GuideLLM value at '{path}'")
|
|
118
|
+
if "," in rendered:
|
|
119
|
+
raise ValueError(f"GuideLLM string values cannot contain commas: '{path}'")
|
|
120
|
+
return (f"{path}={rendered}",)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _mapping(value: object, label: str) -> Mapping[str, object]:
|
|
124
|
+
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
|
125
|
+
raise ValueError(f"GuideLLM {label} must be an object")
|
|
126
|
+
return value
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Canonical metric normalization shared by benchmark adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_GUIDELLM_METRICS = {
|
|
9
|
+
"requests_per_second": "requests_per_second",
|
|
10
|
+
"output_tokens_per_second": "output_tokens_per_second",
|
|
11
|
+
"total_tokens_per_second": "tokens_per_second",
|
|
12
|
+
"time_to_first_token_ms": "time_to_first_token_ms",
|
|
13
|
+
"time_per_output_token_ms": "time_per_output_token_ms",
|
|
14
|
+
"inter_token_latency_ms": "inter_token_latency_ms",
|
|
15
|
+
}
|
|
16
|
+
_VLLM_THROUGHPUT = {
|
|
17
|
+
"requests_per_second": ("requests_per_second", "request_throughput"),
|
|
18
|
+
"output_tokens_per_second": ("output_tokens_per_second", "output_throughput"),
|
|
19
|
+
"total_tokens_per_second": ("total_tokens_per_second", "total_token_throughput"),
|
|
20
|
+
}
|
|
21
|
+
_VLLM_LATENCY = {
|
|
22
|
+
"time_to_first_token_ms": "ttft",
|
|
23
|
+
"time_per_output_token_ms": "tpot",
|
|
24
|
+
"inter_token_latency_ms": "itl",
|
|
25
|
+
"end_to_end_latency_ms": "e2el",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalize_guidellm_metrics(raw: Mapping[str, object]) -> dict[str, object]:
|
|
30
|
+
"""Return GuideLLM metrics with backend-neutral canonical fields."""
|
|
31
|
+
result = dict(raw)
|
|
32
|
+
for canonical, source in _GUIDELLM_METRICS.items():
|
|
33
|
+
if summary := metric_summary(raw.get(source)):
|
|
34
|
+
result[canonical] = summary
|
|
35
|
+
if summary := metric_summary(raw.get("request_latency"), scale=1000.0):
|
|
36
|
+
result["end_to_end_latency_ms"] = summary
|
|
37
|
+
result["request_totals"] = request_totals(raw.get("request_totals"))
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def normalize_vllm_metrics(raw: Mapping[str, object]) -> dict[str, object]:
|
|
42
|
+
"""Return vLLM Bench Serve metrics with the same canonical fields."""
|
|
43
|
+
result = dict(raw)
|
|
44
|
+
for canonical, aliases in _VLLM_THROUGHPUT.items():
|
|
45
|
+
value = next((raw[name] for name in aliases if name in raw), None)
|
|
46
|
+
if summary := metric_summary(value):
|
|
47
|
+
result[canonical] = summary
|
|
48
|
+
for canonical, stem in _VLLM_LATENCY.items():
|
|
49
|
+
if summary := _vllm_latency(raw, stem):
|
|
50
|
+
result[canonical] = summary
|
|
51
|
+
result["request_totals"] = request_totals(
|
|
52
|
+
raw.get("request_totals"), completed=raw.get("completed"),
|
|
53
|
+
failed=raw.get("failed"), requested=raw.get("num_prompts"),
|
|
54
|
+
)
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def metric_summary(value: object, scale: float = 1.0) -> dict[str, float]:
|
|
59
|
+
"""Extract average, median, and P99 without fabricating absent values."""
|
|
60
|
+
if (number := _number(value)) is not None:
|
|
61
|
+
return {"average": number * scale}
|
|
62
|
+
if not isinstance(value, Mapping):
|
|
63
|
+
return {}
|
|
64
|
+
successful = value.get("successful")
|
|
65
|
+
source = successful if isinstance(successful, Mapping) else value
|
|
66
|
+
result: dict[str, float] = {}
|
|
67
|
+
for target, names in {
|
|
68
|
+
"average": ("average", "mean"),
|
|
69
|
+
"median": ("median", "p50"),
|
|
70
|
+
"p99": ("p99",),
|
|
71
|
+
}.items():
|
|
72
|
+
number = next((_number(source.get(name)) for name in names
|
|
73
|
+
if _number(source.get(name)) is not None), None)
|
|
74
|
+
if number is not None:
|
|
75
|
+
result[target] = number * scale
|
|
76
|
+
percentiles = source.get("percentiles")
|
|
77
|
+
if isinstance(percentiles, Mapping):
|
|
78
|
+
for target, name in (("median", "p50"), ("p99", "p99")):
|
|
79
|
+
if target not in result and (number := _number(percentiles.get(name))) is not None:
|
|
80
|
+
result[target] = number * scale
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def request_totals(
|
|
85
|
+
value: object, *, completed: object = None, failed: object = None,
|
|
86
|
+
requested: object = None,
|
|
87
|
+
) -> dict[str, int]:
|
|
88
|
+
source = value if isinstance(value, Mapping) else {}
|
|
89
|
+
successful = _count(source.get("successful", completed))
|
|
90
|
+
errored = _count(source.get("errored", source.get("failed", failed)))
|
|
91
|
+
incomplete = _count(source.get("incomplete"))
|
|
92
|
+
result = {
|
|
93
|
+
"successful": successful,
|
|
94
|
+
"errored": errored,
|
|
95
|
+
"incomplete": incomplete,
|
|
96
|
+
}
|
|
97
|
+
total = _optional_count(source.get("total", requested))
|
|
98
|
+
if total is not None:
|
|
99
|
+
result["incomplete"] = max(incomplete, total - successful - errored)
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _vllm_latency(raw: Mapping[str, object], stem: str) -> dict[str, float]:
|
|
104
|
+
values = {
|
|
105
|
+
"average": raw.get(f"mean_{stem}_ms"),
|
|
106
|
+
"median": raw.get(f"median_{stem}_ms", raw.get(f"p50_{stem}_ms")),
|
|
107
|
+
"p99": raw.get(f"p99_{stem}_ms"),
|
|
108
|
+
}
|
|
109
|
+
return {name: number for name, value in values.items()
|
|
110
|
+
if (number := _number(value)) is not None}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _number(value: object) -> float | None:
|
|
114
|
+
return float(value) if isinstance(value, int | float) and not isinstance(value, bool) else None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _count(value: object) -> int:
|
|
118
|
+
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _optional_count(value: object) -> int | None:
|
|
122
|
+
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Duration parsing and GuideLLM timeout estimation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
_DURATION = re.compile(r"^(\d+(?:\.\d+)?)\s*([smh]?)$")
|
|
9
|
+
_UNITS = {"": 1, "s": 1, "m": 60, "h": 3600}
|
|
10
|
+
_REQUEST_COUNT_TIMEOUT = 3600.0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_duration(value: object, label: str = "duration") -> float:
|
|
14
|
+
if isinstance(value, bool):
|
|
15
|
+
raise ValueError(f"'{label}' must be a positive duration")
|
|
16
|
+
if isinstance(value, int | float):
|
|
17
|
+
seconds = float(value)
|
|
18
|
+
elif isinstance(value, str) and (match := _DURATION.fullmatch(value.strip().lower())):
|
|
19
|
+
seconds = float(match.group(1)) * _UNITS[match.group(2)]
|
|
20
|
+
else:
|
|
21
|
+
raise ValueError(f"'{label}' must use seconds, '30s', '2m', or '1h'")
|
|
22
|
+
if seconds <= 0:
|
|
23
|
+
raise ValueError(f"'{label}' must be positive")
|
|
24
|
+
return seconds
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def timeout_for_run(run: Mapping[str, object], configured: object = None) -> float:
|
|
28
|
+
"""Return an explicit timeout or estimate a configured duration safely."""
|
|
29
|
+
if configured is not None:
|
|
30
|
+
return parse_duration(configured, "timeouts.benchmark")
|
|
31
|
+
if _has_request_constraint(run) and _duration_constraint(run) is None:
|
|
32
|
+
return _REQUEST_COUNT_TIMEOUT
|
|
33
|
+
duration = _duration_constraint(run)
|
|
34
|
+
if duration is None:
|
|
35
|
+
return 180.0
|
|
36
|
+
expected = duration * _strategy_count(run.get("profile"))
|
|
37
|
+
return expected + max(30.0, expected * 0.25)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def normalize_durations(value: object) -> object:
|
|
41
|
+
"""Convert duration strings recursively before passing values to GuideLLM."""
|
|
42
|
+
if isinstance(value, list):
|
|
43
|
+
return [normalize_durations(item) for item in value]
|
|
44
|
+
if isinstance(value, str):
|
|
45
|
+
return parse_duration(value)
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _duration_constraint(run: Mapping[str, object]) -> float | None:
|
|
50
|
+
constraints = run.get("constraints", [])
|
|
51
|
+
if not isinstance(constraints, list):
|
|
52
|
+
return None
|
|
53
|
+
for constraint in constraints:
|
|
54
|
+
if isinstance(constraint, Mapping) and constraint.get("kind") == "max_duration":
|
|
55
|
+
seconds = constraint.get("seconds")
|
|
56
|
+
if isinstance(seconds, list):
|
|
57
|
+
return sum(parse_duration(item, "max_duration.seconds") for item in seconds)
|
|
58
|
+
return parse_duration(seconds, "max_duration.seconds")
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _has_request_constraint(run: Mapping[str, object]) -> bool:
|
|
63
|
+
constraints = run.get("constraints", [])
|
|
64
|
+
return (isinstance(constraints, list)
|
|
65
|
+
and any(isinstance(item, Mapping) and item.get("kind") == "max_requests"
|
|
66
|
+
for item in constraints))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _strategy_count(profile: object) -> int:
|
|
70
|
+
if not isinstance(profile, Mapping):
|
|
71
|
+
return 1
|
|
72
|
+
for key in ("streams", "rates"):
|
|
73
|
+
value = profile.get(key)
|
|
74
|
+
if isinstance(value, list):
|
|
75
|
+
return max(1, len(value))
|
|
76
|
+
size = profile.get("sweep_size")
|
|
77
|
+
return size if isinstance(size, int) and size > 0 else 1
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""vLLM Bench Serve command construction and result normalization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
from vllm_optimizer.config.models import VTuneConfig
|
|
13
|
+
from vllm_optimizer.config.runtime import model_path
|
|
14
|
+
from vllm_optimizer.domain.benchmark import BenchmarkResult, WorkloadResult
|
|
15
|
+
from vllm_optimizer.benchmarks.metrics import normalize_vllm_metrics
|
|
16
|
+
|
|
17
|
+
_RESERVED = {"backend", "model", "host", "port", "base-url", "save-result", "append-result",
|
|
18
|
+
"result-dir", "result-filename"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class VLLMBenchPlan:
|
|
23
|
+
run_name: str
|
|
24
|
+
argv: tuple[str, ...]
|
|
25
|
+
directory: Path
|
|
26
|
+
json_path: Path
|
|
27
|
+
log_path: Path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_plan(config: VTuneConfig, run: Mapping[str, object], endpoint: str,
|
|
31
|
+
artifacts: Path) -> VLLMBenchPlan:
|
|
32
|
+
name = str(run["name"])
|
|
33
|
+
args = _mapping(run.get("args", {}), f"vllm benchmark run '{name}' args")
|
|
34
|
+
normalized = {key.replace("_", "-"): value for key, value in args.items()}
|
|
35
|
+
if protected := _RESERVED.intersection(normalized):
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"vLLM Optimizer controls vLLM benchmark argument(s): "
|
|
38
|
+
f"{', '.join(sorted(protected))}"
|
|
39
|
+
)
|
|
40
|
+
parsed = urlsplit(endpoint)
|
|
41
|
+
directory = Path(artifacts) / name
|
|
42
|
+
result = directory / "results.json"
|
|
43
|
+
argv = ["vllm", "bench", "serve", "--backend", "vllm",
|
|
44
|
+
"--model", model_path(config),
|
|
45
|
+
"--host", parsed.hostname or "127.0.0.1", "--port", str(parsed.port or 8000)]
|
|
46
|
+
for key, value in normalized.items():
|
|
47
|
+
argv.extend(_argument(key, value))
|
|
48
|
+
argv.extend(("--save-result", "--result-dir", str(directory),
|
|
49
|
+
"--result-filename", result.name, "--disable-tqdm"))
|
|
50
|
+
return VLLMBenchPlan(name, tuple(argv), directory, result,
|
|
51
|
+
directory / "benchmark.log")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_result(path: Path, run_name: str) -> BenchmarkResult:
|
|
55
|
+
source = Path(path)
|
|
56
|
+
try:
|
|
57
|
+
document = _mapping(json.loads(source.read_text(encoding="utf-8")), "result")
|
|
58
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
59
|
+
raise ValueError(f"Cannot read vLLM benchmark JSON result: {error}") from error
|
|
60
|
+
metrics = normalize_vllm_metrics(document)
|
|
61
|
+
if isinstance(document.get("num_prompts"), int) and not isinstance(document.get("num_prompts"), bool):
|
|
62
|
+
metrics["request_total"] = document["num_prompts"]
|
|
63
|
+
configuration = {key: document[key] for key in (
|
|
64
|
+
"backend", "model_id", "num_prompts", "request_rate", "burstiness",
|
|
65
|
+
"max_concurrency", "dataset_name",
|
|
66
|
+
) if key in document}
|
|
67
|
+
return BenchmarkResult(run_name, "vllm", _version(),
|
|
68
|
+
(WorkloadResult(0, configuration, metrics),), source)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _argument(name: str, value: object) -> tuple[str, ...]:
|
|
72
|
+
flag = f"--{name}"
|
|
73
|
+
if isinstance(value, bool):
|
|
74
|
+
return (flag,) if value else ()
|
|
75
|
+
if isinstance(value, list):
|
|
76
|
+
return tuple(item for entry in value for item in (flag, str(entry)))
|
|
77
|
+
if value is None or not isinstance(value, str | int | float):
|
|
78
|
+
raise ValueError(f"Unsupported vLLM benchmark value for '{name}'")
|
|
79
|
+
return flag, str(value)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _version() -> str:
|
|
83
|
+
try:
|
|
84
|
+
return version("vllm")
|
|
85
|
+
except PackageNotFoundError:
|
|
86
|
+
return "unknown"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _mapping(value: object, label: str) -> Mapping[str, object]:
|
|
90
|
+
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
|
91
|
+
raise ValueError(f"{label} must be an object")
|
|
92
|
+
return value
|
vllm_optimizer/cli.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Command-line entry points for vLLM Optimizer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from vllm_optimizer.config.errors import ConfigError
|
|
12
|
+
from vllm_optimizer.config.loader import load_config
|
|
13
|
+
from vllm_optimizer.config.runtime import model_path
|
|
14
|
+
from vllm_optimizer.cli_options import CLIUsageError, validate_cli_options
|
|
15
|
+
from vllm_optimizer.lifecycle import load_retry_plan
|
|
16
|
+
from vllm_optimizer.orchestrator import Orchestrator
|
|
17
|
+
from vllm_optimizer.reporting.offline import regenerate_report
|
|
18
|
+
from vllm_optimizer.reproduction.display import reproduce_trial
|
|
19
|
+
from vllm_optimizer.reproduction.export import export_vllm_command
|
|
20
|
+
from vllm_optimizer.terminal import with_debug_logging
|
|
21
|
+
from vllm_optimizer.terminal_style import styled
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_parser(prog: str = "vllm-opt") -> argparse.ArgumentParser:
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog=prog, description="Benchmark and optimize vLLM serving configurations.",
|
|
27
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
28
|
+
epilog=f"""Commands:
|
|
29
|
+
{prog} --config experiment.yaml Start an experiment
|
|
30
|
+
{prog} validate --config experiment.yaml Validate configuration only
|
|
31
|
+
{prog} retry --run RUN --trial ID [--trial ID] Retry selected trial(s)
|
|
32
|
+
{prog} reproduce --run RUN --trial ID Print a saved trial command
|
|
33
|
+
{prog} export --run RUN --trial ID Export a vLLM serve command
|
|
34
|
+
{prog} report --run RUN [--output DIRECTORY] Regenerate an HTML report""",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"action", nargs="?", choices=("validate", "export", "reproduce", "retry", "report"),
|
|
38
|
+
help="Post-run action; omit to start a new experiment.")
|
|
39
|
+
parser.add_argument("--config", "-c", metavar="YAML",
|
|
40
|
+
help="Experiment YAML for a new run or validation.")
|
|
41
|
+
parser.add_argument("--run", type=Path, metavar="DIRECTORY",
|
|
42
|
+
help="Existing immutable run directory.")
|
|
43
|
+
parser.add_argument("--trial", action="append", metavar="ID",
|
|
44
|
+
help="Trial ID; repeat the option when retrying several trials.")
|
|
45
|
+
parser.add_argument("--output", type=Path, metavar="DIRECTORY",
|
|
46
|
+
help="Destination for an offline regenerated report.")
|
|
47
|
+
parser.add_argument("--verbose", action="store_true",
|
|
48
|
+
help="Override logging.level with DEBUG and stream child logs.")
|
|
49
|
+
return parser
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
53
|
+
return _run(argv, "vllm-opt")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def legacy_main(argv: Sequence[str] | None = None) -> int:
|
|
57
|
+
print(
|
|
58
|
+
"The 'vtune' command is deprecated; use 'vllm-opt' instead.",
|
|
59
|
+
file=sys.stderr,
|
|
60
|
+
)
|
|
61
|
+
return _run(argv, "vtune")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _run(argv: Sequence[str] | None, prog: str) -> int:
|
|
65
|
+
args = build_parser(prog).parse_args(argv)
|
|
66
|
+
try:
|
|
67
|
+
validate_cli_options(args)
|
|
68
|
+
if args.action == "export":
|
|
69
|
+
print(export_vllm_command(args.run, args.trial[0]))
|
|
70
|
+
return 0
|
|
71
|
+
if args.action == "reproduce":
|
|
72
|
+
print(reproduce_trial(args.run, args.trial[0]))
|
|
73
|
+
return 0
|
|
74
|
+
if args.action == "retry":
|
|
75
|
+
plan = load_retry_plan(args.run, args.trial)
|
|
76
|
+
for warning in plan.warnings:
|
|
77
|
+
print(f"Integrity warning: {warning}", file=sys.stderr)
|
|
78
|
+
retry_config = with_debug_logging(plan.config) if args.verbose else plan.config
|
|
79
|
+
outcome = asyncio.run(Orchestrator(
|
|
80
|
+
retry_config, plan.trials, plan.source_run_id, plan.sources,
|
|
81
|
+
).run())
|
|
82
|
+
elif args.action == "report":
|
|
83
|
+
generated = regenerate_report(args.run, args.output)
|
|
84
|
+
print(f"Offline report regenerated: {generated.directory}")
|
|
85
|
+
for warning in generated.warnings:
|
|
86
|
+
print(f"Integrity warning: {warning}", file=sys.stderr)
|
|
87
|
+
return 0
|
|
88
|
+
else:
|
|
89
|
+
config = load_config(args.config)
|
|
90
|
+
config = with_debug_logging(config) if args.verbose else config
|
|
91
|
+
if args.action == "validate":
|
|
92
|
+
Orchestrator(config).validate()
|
|
93
|
+
print(f"Configuration valid: {config.experiment.name}")
|
|
94
|
+
print(f"Model: {model_path(config)}")
|
|
95
|
+
return 0
|
|
96
|
+
outcome = asyncio.run(Orchestrator(config).run())
|
|
97
|
+
except CLIUsageError as error:
|
|
98
|
+
print(styled(f"Command error: {error}", "red", sys.stderr), file=sys.stderr)
|
|
99
|
+
return 2
|
|
100
|
+
except ConfigError as error:
|
|
101
|
+
print(styled(f"Configuration error: {error}", "red", sys.stderr), file=sys.stderr)
|
|
102
|
+
return 2
|
|
103
|
+
except (OSError, TypeError, ValueError) as error:
|
|
104
|
+
print(styled(f"Experiment error: {error}", "red", sys.stderr), file=sys.stderr)
|
|
105
|
+
return 1
|
|
106
|
+
except KeyboardInterrupt:
|
|
107
|
+
print("Experiment interrupted", file=sys.stderr)
|
|
108
|
+
return 130
|
|
109
|
+
print(outcome.summary)
|
|
110
|
+
if outcome.status == "interrupted":
|
|
111
|
+
return 130
|
|
112
|
+
completed = any(report.status.value == "completed" for report in outcome.trials)
|
|
113
|
+
return 0 if completed else 1
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
raise SystemExit(main())
|