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.
Files changed (91) hide show
  1. vllm_optimizer/__init__.py +7 -0
  2. vllm_optimizer/benchmarks/__init__.py +13 -0
  3. vllm_optimizer/benchmarks/configuration.py +84 -0
  4. vllm_optimizer/benchmarks/guidellm.py +126 -0
  5. vllm_optimizer/benchmarks/metrics.py +122 -0
  6. vllm_optimizer/benchmarks/timing.py +77 -0
  7. vllm_optimizer/benchmarks/vllm.py +92 -0
  8. vllm_optimizer/cli.py +117 -0
  9. vllm_optimizer/cli_options.py +46 -0
  10. vllm_optimizer/config/__init__.py +6 -0
  11. vllm_optimizer/config/errors.py +17 -0
  12. vllm_optimizer/config/loader.py +145 -0
  13. vllm_optimizer/config/models.py +31 -0
  14. vllm_optimizer/config/preflight.py +95 -0
  15. vllm_optimizer/config/runtime.py +69 -0
  16. vllm_optimizer/domain/__init__.py +25 -0
  17. vllm_optimizer/domain/attempt_report.py +18 -0
  18. vllm_optimizer/domain/benchmark.py +52 -0
  19. vllm_optimizer/domain/models.py +69 -0
  20. vllm_optimizer/domain/results.py +59 -0
  21. vllm_optimizer/domain/states.py +31 -0
  22. vllm_optimizer/domain/trial_report.py +73 -0
  23. vllm_optimizer/execution/__init__.py +10 -0
  24. vllm_optimizer/execution/finalist_validation.py +46 -0
  25. vllm_optimizer/execution/scheduler.py +93 -0
  26. vllm_optimizer/execution/slots.py +118 -0
  27. vllm_optimizer/execution/trial_executor.py +97 -0
  28. vllm_optimizer/lifecycle/__init__.py +5 -0
  29. vllm_optimizer/lifecycle/integrity.py +102 -0
  30. vllm_optimizer/lifecycle/retry.py +105 -0
  31. vllm_optimizer/managers/__init__.py +9 -0
  32. vllm_optimizer/managers/results.py +68 -0
  33. vllm_optimizer/managers/run_results.py +143 -0
  34. vllm_optimizer/managers/run_session.py +90 -0
  35. vllm_optimizer/managers/scoring.py +123 -0
  36. vllm_optimizer/managers/trial.py +106 -0
  37. vllm_optimizer/measurement.py +78 -0
  38. vllm_optimizer/orchestrator.py +199 -0
  39. vllm_optimizer/py.typed +1 -0
  40. vllm_optimizer/reporting/__init__.py +6 -0
  41. vllm_optimizer/reporting/analysis.py +149 -0
  42. vllm_optimizer/reporting/benchmark_details.py +59 -0
  43. vllm_optimizer/reporting/charts.py +97 -0
  44. vllm_optimizer/reporting/context.py +23 -0
  45. vllm_optimizer/reporting/dashboard.py +140 -0
  46. vllm_optimizer/reporting/importance.py +23 -0
  47. vllm_optimizer/reporting/llm_summary.py +123 -0
  48. vllm_optimizer/reporting/measurement.py +67 -0
  49. vllm_optimizer/reporting/methodology.py +16 -0
  50. vllm_optimizer/reporting/offline.py +179 -0
  51. vllm_optimizer/reporting/reporter.py +67 -0
  52. vllm_optimizer/reporting/styles.py +11 -0
  53. vllm_optimizer/reporting/tables.py +122 -0
  54. vllm_optimizer/reporting/validation.py +58 -0
  55. vllm_optimizer/reproduction/__init__.py +1 -0
  56. vllm_optimizer/reproduction/display.py +63 -0
  57. vllm_optimizer/reproduction/export.py +13 -0
  58. vllm_optimizer/reproduction/manifest.py +77 -0
  59. vllm_optimizer/reproduction/metadata.py +66 -0
  60. vllm_optimizer/reproduction/models.py +36 -0
  61. vllm_optimizer/reproduction/reader.py +48 -0
  62. vllm_optimizer/reproduction/redaction.py +40 -0
  63. vllm_optimizer/search/__init__.py +8 -0
  64. vllm_optimizer/search/factory.py +44 -0
  65. vllm_optimizer/search/fixed_session.py +26 -0
  66. vllm_optimizer/search/grid.py +65 -0
  67. vllm_optimizer/search/grid_session.py +29 -0
  68. vllm_optimizer/search/optuna_session.py +121 -0
  69. vllm_optimizer/search/strategy.py +18 -0
  70. vllm_optimizer/terminal.py +140 -0
  71. vllm_optimizer/terminal_style.py +30 -0
  72. vllm_optimizer/workers/__init__.py +23 -0
  73. vllm_optimizer/workers/attempts.py +15 -0
  74. vllm_optimizer/workers/base.py +33 -0
  75. vllm_optimizer/workers/benchmark.py +140 -0
  76. vllm_optimizer/workers/completion.py +96 -0
  77. vllm_optimizer/workers/configuration.py +115 -0
  78. vllm_optimizer/workers/drain.py +139 -0
  79. vllm_optimizer/workers/factory.py +75 -0
  80. vllm_optimizer/workers/failure_details.py +50 -0
  81. vllm_optimizer/workers/process.py +147 -0
  82. vllm_optimizer/workers/readiness.py +119 -0
  83. vllm_optimizer/workers/vllm.py +73 -0
  84. vllm_optimizer/workers/vllm_benchmark.py +127 -0
  85. vllm_optimizer-0.1.0a11.dist-info/METADATA +224 -0
  86. vllm_optimizer-0.1.0a11.dist-info/RECORD +91 -0
  87. vllm_optimizer-0.1.0a11.dist-info/WHEEL +5 -0
  88. vllm_optimizer-0.1.0a11.dist-info/entry_points.txt +3 -0
  89. vllm_optimizer-0.1.0a11.dist-info/licenses/LICENSE +21 -0
  90. vllm_optimizer-0.1.0a11.dist-info/top_level.txt +2 -0
  91. vtune/__init__.py +11 -0
@@ -0,0 +1,7 @@
1
+ """vLLM Optimizer package."""
2
+
3
+ __version__ = "0.1.0a11"
4
+
5
+ from .orchestrator import Orchestrator, RunOutcome
6
+
7
+ __all__ = ["Orchestrator", "RunOutcome"]
@@ -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())