Vllm recipes tool improve (#53325)
Vllm recipes tool improve (#53325) Signed-off-by: louie-tsai <louie.tsai@intel.com>
Louie Tsai committed
Aug 24, 2026 at 22:53 UTC
4af586e185b028acf08312a4dee381b5998a137e
9 files changed
+1930
-105
tools/recipes/README.md
+76
-104
@@ -1,87 +1,46 @@
1
# vLLM Recipes Tools
2
3
-Utilities for consuming deployment configurations from [vLLM Recipes](https://recipes.vllm.ai/) and converting them into files that can be used directly with vLLM.
4
-
5
-## `recipe_json_to_vllm_config.py`
6
-
7
-Converts a hardware-specific vLLM Recipes JSON rendering into:
3
+Convert a vLLM Recipes deployment rendering into files for `vllm serve`.
4
+
5
+## Optimized Deployment Flow
6
+
7
+```mermaid
8
+flowchart LR
9
+ R["vLLM Recipe"] --> C["Recipe Converter"]
10
+ H["Hardware Info (optional)"] --> C
11
+ W["Workload Info (optional)"] --> C
12
+ C --> F["config.yml + env.sh"]
13
+ C -.-> S["Sweep Tuning (optional)"]
14
+ S -.-> F
15
+ F --> D["vLLM Docker Image"]
16
+ D --> E["OpenAI Endpoint"]
17
+
18
+ style S stroke-dasharray: 5 5
19
+```
20
9
-- `config.yml` — native configuration for `vllm serve --config`
10
-- `env.sh` — environment variables required by the recipe
21
+The recipe is the baseline. Hardware and workload information can optionally
22
+refine the initial configuration. Sweep tuning is an optional validation step.
23
12
-The converter uses the Recipes JSON API as the source of truth. It does not reimplement model-variant, hardware, or strategy compatibility rules.
24
+## 1. vLLM Recipes Only
25
14
-## Install Dependency
26
+Use the converter directly when the recipe already contains the deployment
27
+settings you need. This path requires only PyYAML; the vLLM Python package is
28
+not required unless optional runtime tuning or sweep generation is requested.
29
30
```bash
31
pip install pyyaml
32
```
33
20
-## Recipe Selection Model
21
-
22
-Discovery follows the JSON links published by vLLM Recipes:
23
-
24
-```text
25
-/models.json
26
- -> /{hf_id}.json
27
- -> recommended_command.by_hardware[{hardware}]
28
- -> /{hf_id}/hw/{hardware}.json
29
- -> recommended strategy
30
- -> alternatives[{strategy}]
31
-```
32
-
33
-The per-hardware JSON is the recommended deployment rendering for that model and hardware. Alternative strategy renderings are referenced through the JSON's `alternatives` map.
34
-
35
-The converter follows those exact links instead of constructing strategy paths locally. This also handles the Recipes API's legacy default-hardware strategy path automatically.
34
+Choose whichever recipe-selection method fits the workflow:
35
37
-## Interactive Recipe Discovery
38
-
39
-If you do not know the recipe JSON URL, run the script without an input:
36
+**Interactive discovery** — search models, then choose hardware and strategy:
37
38
```bash
39
python3 tools/recipes/recipe_json_to_vllm_config.py
40
```
41
45
-The script will:
46
-
47
-1. Search the Recipes model index, including promoted variant model IDs.
48
-2. Ask which model to use.
49
-3. Show the hardware configurations available for that model.
50
-4. Show the recommended strategy and generated alternative strategies.
51
-5. Resolve the exact Recipes JSON endpoint.
52
-6. Generate `config.yml` and `env.sh`.
53
-
54
-Example interaction:
55
-
56
-```text
57
-No recipe JSON supplied; starting Recipes API discovery.
58
-Model search (for example: llama 3.1): llama 3.1 fp8
59
-
60
-Matching models:
61
- [1] nvidia/Llama-3.1-8B-Instruct-FP8 — Llama-3.1-8B-Instruct [Meta]
62
-Select model: 1
63
-
64
-Available hardware:
65
- [1] arc_pro_b70
66
- [2] b200
67
- [3] h100
68
-Select hardware: 1
69
-
70
-Available strategies:
71
- [1] single_node_tp (recommended)
72
- [2] multi_node_tp
73
-Select strategy: 1
74
-
75
-Resolved recipe:
76
- Model: nvidia/Llama-3.1-8B-Instruct-FP8
77
- Hardware: arc_pro_b70
78
- Strategy: single_node_tp
79
- JSON: https://recipes.vllm.ai/nvidia/Llama-3.1-8B-Instruct-FP8/hw/arc_pro_b70.json
80
-```
81
-
82
-## Non-Interactive Recipe Discovery
83
-
84
-For automation or CI, specify the model and hardware directly:
42
+**Non-interactive discovery** — provide model and hardware and use the
43
+Recipes-recommended strategy:
44
45
```bash
46
python3 tools/recipes/recipe_json_to_vllm_config.py \
@@ -89,68 +48,81 @@ python3 tools/recipes/recipe_json_to_vllm_config.py \
48
--hardware xeon6
49
```
50
92
-When `--strategy` is omitted with both `--model` and `--hardware`, the converter uses the Recipes-recommended strategy from the per-hardware JSON.
93
-
94
-To request a generated alternative strategy explicitly:
51
+**Direct JSON input** — use a Recipes JSON URL or a local JSON file:
52
53
```bash
54
python3 tools/recipes/recipe_json_to_vllm_config.py \
98
- --model nvidia/Llama-3.1-8B-Instruct-FP8 \
99
- --hardware arc_pro_b70 \
100
- --strategy multi_node_tp
55
+ https://recipes.vllm.ai/meta-llama/Llama-3.1-8B-Instruct/hw/xeon6.json
56
+
57
+python3 tools/recipes/recipe_json_to_vllm_config.py recipe.json
58
```
59
103
-The converter does not synthesize a URL such as `.../strategies/multi_node_tp.json`. It reads the selected hardware JSON and follows the exact `alternatives["multi_node_tp"]` link published by the Recipes API.
60
+All paths generate `config.yml` and `env.sh`. See
61
+[REFERENCE.md](REFERENCE.md) for recipe discovery, strategy selection, direct
62
+JSON input, custom output files, and deployment scope.
63
105
-## Direct Recipe JSON Input
64
+## 2. Hardware Information (Optional)
65
107
-If you already know the recipe JSON URL:
66
+Add `--detect-hardware` when the target host's effective CPU/NUMA/memory
67
+resources should refine deployment-sensitive values such as
68
+`tensor-parallel-size` and `gpu-memory-utilization`.
69
70
```bash
71
python3 tools/recipes/recipe_json_to_vllm_config.py \
111
- https://recipes.vllm.ai/meta-llama/Llama-3.1-8B-Instruct/hw/xeon6.json
112
-```
113
-
114
-A local JSON file is also supported:
115
-
116
-```bash
117
-python3 tools/recipes/recipe_json_to_vllm_config.py recipe.json
72
+ --model meta-llama/Llama-3.1-8B-Instruct \
73
+ --hardware xeon6 \
74
+ --detect-hardware
75
```
76
120
-When a positional JSON source is supplied, do not combine it with `--model`, `--hardware`, or `--strategy`.
77
+Hardware detection is optional and uses vLLM CPU resource utilities only when
78
+requested. See [RUNTIME_TUNING.md](RUNTIME_TUNING.md#hardware-information).
79
122
-## Start vLLM
80
+## 3. Workload Information (Optional)
81
124
-After generating the files:
82
+Workload hints can refine scheduler settings for one initial deployment
83
+suggestion. Inputs include token lengths, concurrency, and optional latency or
84
+capacity objectives.
85
86
```bash
127
-source env.sh
128
-vllm serve --config config.yml
87
+python3 tools/recipes/recipe_json_to_vllm_config.py \
88
+ --model meta-llama/Llama-3.1-8B-Instruct \
89
+ --hardware xeon6 \
90
+ --input-tokens 2048 \
91
+ --output-tokens 128 \
92
+ --concurrency 32 \
93
+ --ttft-sla-ms 3000 \
94
+ --tpot-sla-ms 100
95
```
96
131
-Example generated `config.yml`:
132
-
133
-```yaml
134
-model: meta-llama/Llama-3.1-8B-Instruct
135
-tensor-parallel-size: 1
136
-```
97
+Hardware detection and workload information are independent optional inputs;
98
+they can also be supplied together. See
99
+[RUNTIME_TUNING.md](RUNTIME_TUNING.md#workload-information) for the supported
100
+inputs and how runtime parameters are calculated.
101
138
-If the selected recipe does not require environment variables, `env.sh` will contain no additional exports.
102
+## 4. Sweep Tuning (Optional)
103
140
-## Custom Output Files
104
+Use `--generate-sweep` when the initial scheduler suggestion should be validated
105
+with `vllm bench sweep serve`. The sweep benchmarks nearby scheduler values and
106
+`recommend.py` produces one measured `recommended-config.yml` plus the
107
+selection evidence in `recommendation.json`.
108
109
```bash
110
python3 tools/recipes/recipe_json_to_vllm_config.py \
111
--model meta-llama/Llama-3.1-8B-Instruct \
112
--hardware xeon6 \
146
- --config-out llama31-xeon6.yml \
147
- --env-out llama31-xeon6-env.sh
113
+ --detect-hardware \
114
+ --input-tokens 2048 \
115
+ --output-tokens 128 \
116
+ --concurrency 32 \
117
+ --generate-sweep
118
```
119
150
-## Strategy and Deployment Scope
120
+See [SWEEP_TUNING.md](SWEEP_TUNING.md) for the benchmark, recommendation, and
121
+vLLM CPU Docker-shell workflow.
122
152
-Strategy discovery and config conversion are separate concerns.
153
-
154
-The converter can resolve any generated strategy JSON exposed by the Recipes API. It currently emits one `config.yml`, so the selected rendering must contain a single `vllm serve` `argv`.
123
+## Start vLLM
124
156
-Single-process renderings such as `single_node_tp` can be converted directly. Multi-node, disaggregated prefill/decode, or other multi-process renderings expose fields such as `head_argv`, `worker_argv`, `prefill`, or `decode`; the converter intentionally exits instead of generating an incomplete single-process config.
125
+```bash
126
+source env.sh
127
+vllm serve --config config.yml
128
+```
tools/recipes/REFERENCE.md
new
+177
@@ -0,0 +1,177 @@
1
+# vLLM Recipes Tools
2
+
3
+Utilities for consuming deployment configurations from [vLLM Recipes](https://recipes.vllm.ai/) and converting them into files that can be used directly with vLLM.
4
+
5
+## Optimized Deployment Flow
6
+
7
+The recipe provides the validated deployment baseline. Hardware and workload
8
+information are optional inputs that can refine deployment-sensitive values
9
+before the generated configuration is passed to vLLM.
10
+
11
+```mermaid
12
+flowchart LR
13
+ R["vLLM Recipe"] --> C["Recipe Converter"]
14
+ H["Hardware Info (optional)"] --> C
15
+ W["Workload Info (optional)"] --> C
16
+ C --> F["config.yml + env.sh"]
17
+ F --> D["vLLM Docker Image"]
18
+ D --> E["OpenAI Endpoint"]
19
+```
20
+
21
+## `recipe_json_to_vllm_config.py`
22
+
23
+Converts a hardware-specific vLLM Recipes JSON rendering into:
24
+
25
+- `config.yml` — native configuration for `vllm serve --config`
26
+- `env.sh` — environment variables required by the recipe
27
+
28
+The converter uses the Recipes JSON API as the source of truth. It does not reimplement model-variant, hardware, or strategy compatibility rules.
29
+
30
+## Install Dependency
31
+
32
+```bash
33
+pip install pyyaml
34
+```
35
+
36
+## Recipe Selection Model
37
+
38
+Discovery follows the JSON links published by vLLM Recipes:
39
+
40
+```text
41
+/models.json
42
+ -> /{hf_id}.json
43
+ -> recommended_command.by_hardware[{hardware}]
44
+ -> /{hf_id}/hw/{hardware}.json
45
+ -> recommended strategy
46
+ -> alternatives[{strategy}]
47
+```
48
+
49
+The per-hardware JSON is the recommended deployment rendering for that model and hardware. Alternative strategy renderings are referenced through the JSON's `alternatives` map.
50
+
51
+The converter follows those exact links instead of constructing strategy paths locally. This also handles the Recipes API's legacy default-hardware strategy path automatically.
52
+
53
+## Interactive Recipe Discovery
54
+
55
+If you do not know the recipe JSON URL, run the script without an input:
56
+
57
+```bash
58
+python3 tools/recipes/recipe_json_to_vllm_config.py
59
+```
60
+
61
+The script will:
62
+
63
+1. Search the Recipes model index, including promoted variant model IDs.
64
+2. Ask which model to use.
65
+3. Show the hardware configurations available for that model.
66
+4. Show the recommended strategy and generated alternative strategies.
67
+5. Resolve the exact Recipes JSON endpoint.
68
+6. Generate `config.yml` and `env.sh`.
69
+
70
+Example interaction:
71
+
72
+```text
73
+No recipe JSON supplied; starting Recipes API discovery.
74
+Model search (for example: llama 3.1): llama 3.1 fp8
75
+
76
+Matching models:
77
+ [1] nvidia/Llama-3.1-8B-Instruct-FP8 — Llama-3.1-8B-Instruct [Meta]
78
+Select model: 1
79
+
80
+Available hardware:
81
+ [1] arc_pro_b70
82
+ [2] b200
83
+ [3] h100
84
+Select hardware: 1
85
+
86
+Available strategies:
87
+ [1] single_node_tp (recommended)
88
+ [2] multi_node_tp
89
+Select strategy: 1
90
+
91
+Resolved recipe:
92
+ Model: nvidia/Llama-3.1-8B-Instruct-FP8
93
+ Hardware: arc_pro_b70
94
+ Strategy: single_node_tp
95
+ JSON: https://recipes.vllm.ai/nvidia/Llama-3.1-8B-Instruct-FP8/hw/arc_pro_b70.json
96
+```
97
+
98
+## Non-Interactive Recipe Discovery
99
+
100
+For automation or CI, specify the model and hardware directly:
101
+
102
+```bash
103
+python3 tools/recipes/recipe_json_to_vllm_config.py \
104
+ --model meta-llama/Llama-3.1-8B-Instruct \
105
+ --hardware xeon6
106
+```
107
+
108
+When `--strategy` is omitted with both `--model` and `--hardware`, the converter uses the Recipes-recommended strategy from the per-hardware JSON.
109
+
110
+To request a generated alternative strategy explicitly:
111
+
112
+```bash
113
+python3 tools/recipes/recipe_json_to_vllm_config.py \
114
+ --model nvidia/Llama-3.1-8B-Instruct-FP8 \
115
+ --hardware arc_pro_b70 \
116
+ --strategy multi_node_tp
117
+```
118
+
119
+The converter does not synthesize a URL such as `.../strategies/multi_node_tp.json`. It reads the selected hardware JSON and follows the exact `alternatives["multi_node_tp"]` link published by the Recipes API.
120
+
121
+## Direct Recipe JSON Input
122
+
123
+If you already know the recipe JSON URL:
124
+
125
+```bash
126
+python3 tools/recipes/recipe_json_to_vllm_config.py \
127
+ https://recipes.vllm.ai/meta-llama/Llama-3.1-8B-Instruct/hw/xeon6.json
128
+```
129
+
130
+A local JSON file is also supported:
131
+
132
+```bash
133
+python3 tools/recipes/recipe_json_to_vllm_config.py recipe.json
134
+```
135
+
136
+When a positional JSON source is supplied, do not combine it with `--model`, `--hardware`, or `--strategy`.
137
+
138
+## Start vLLM
139
+
140
+After generating the files:
141
+
142
+```bash
143
+source env.sh
144
+vllm serve --config config.yml
145
+```
146
+
147
+Example generated `config.yml`:
148
+
149
+```yaml
150
+model: meta-llama/Llama-3.1-8B-Instruct
151
+tensor-parallel-size: 1
152
+```
153
+
154
+If the selected recipe does not require environment variables, `env.sh` will contain no additional exports.
155
+
156
+## Custom Output Files
157
+
158
+```bash
159
+python3 tools/recipes/recipe_json_to_vllm_config.py \
160
+ --model meta-llama/Llama-3.1-8B-Instruct \
161
+ --hardware xeon6 \
162
+ --config-out llama31-xeon6.yml \
163
+ --env-out llama31-xeon6-env.sh
164
+```
165
+
166
+## Strategy and Deployment Scope
167
+
168
+Strategy discovery and config conversion are separate concerns.
169
+
170
+The converter can resolve any generated strategy JSON exposed by the Recipes API. It currently emits one `config.yml`, so the selected rendering must contain a single `vllm serve` `argv`.
171
+
172
+Single-process renderings such as `single_node_tp` can be converted directly. Multi-node, disaggregated prefill/decode, or other multi-process renderings expose fields such as `head_argv`, `worker_argv`, `prefill`, or `decode`; the converter intentionally exits instead of generating an incomplete single-process config.
173
+
174
+## Related Guides
175
+
176
+- [RUNTIME_TUNING.md](RUNTIME_TUNING.md) — optional hardware and workload inputs, runtime parameter policies, and calculations.
177
+- [SWEEP_TUNING.md](SWEEP_TUNING.md) — optional benchmark sweep and measured recommendation workflow.
tools/recipes/RUNTIME_TUNING.md
new
+292
@@ -0,0 +1,292 @@
1
+# Runtime Tuning
2
+
3
+The Recipes JSON remains the baseline. vLLM Recipes already provide validated
4
+model, hardware, strategy, environment variables, and serving arguments.
5
+Runtime tuning is optional and is intended for parameters whose best value can
6
+depend on the actual deployment resources or expected request workload.
7
+
8
+## Information Sources
9
+
10
+Runtime tuning combines up to three inputs:
11
+
12
+- **vLLM Recipe** — the required baseline from the Recipes JSON API or a direct
13
+ recipe JSON file.
14
+- **Hardware information (optional)** — detected with `--detect-hardware`,
15
+ including effective CPU/NUMA topology and memory availability.
16
+- **Workload information (optional)** — supplied through CLI hints such as input
17
+ and output token lengths, concurrency, TTFT/TPOT objectives, and target QPS.
18
+
19
+If no optional hardware or workload information is supplied, the converter keeps
20
+the normal Recipes conversion behavior.
21
+
22
+See [Deployment-Time Parameters](#deployment-time-parameters) for the mapping
23
+between these inputs and individual runtime parameters.
24
+
25
+## Hardware Information
26
+
27
+Hardware detection is enabled only with `--detect-hardware`. It reports the
28
+effective resources available to the process or container, including CPU/NUMA
29
+topology and memory information. The current policy can use those values to
30
+refine `tensor-parallel-size` and `gpu-memory-utilization`.
31
+
32
+```bash
33
+python3 tools/recipes/recipe_json_to_vllm_config.py \
34
+ --model meta-llama/Llama-3.1-8B-Instruct \
35
+ --hardware xeon6 \
36
+ --detect-hardware
37
+```
38
+
39
+Hardware detection is optional. Recipe hardware selects the tuning policy; host
40
+hardware detection provides resource information to that policy.
41
+
42
+## Workload Information
43
+
44
+Workload information is supplied explicitly because the converter runs before a
45
+vLLM server exists. Supported hints include:
46
+
47
+- `--input-tokens`
48
+- `--output-tokens`
49
+- `--concurrency`
50
+- `--ttft-sla-ms`
51
+- `--tpot-sla-ms`
52
+- `--target-qps`
53
+
54
+Example:
55
+
56
+```bash
57
+python3 tools/recipes/recipe_json_to_vllm_config.py \
58
+ --model meta-llama/Llama-3.1-8B-Instruct \
59
+ --hardware xeon6 \
60
+ --input-tokens 2048 \
61
+ --output-tokens 128 \
62
+ --concurrency 32 \
63
+ --ttft-sla-ms 3000 \
64
+ --tpot-sla-ms 100
65
+```
66
+
67
+`--output-tokens` is used to estimate steady-state request turnover for
68
+`max-num-batched-tokens`. When both `--target-qps` and `--tpot-sla-ms` are
69
+available, they are also used to estimate prompt arrival pressure per scheduler
70
+step. `--ttft-sla-ms` is collected but is not yet converted directly into a
71
+batch-size formula because the relationship between TTFT and scheduler budget is
72
+model- and hardware-dependent.
73
+
74
+## Deployment-Time Parameters
75
+
76
+The parameters below may already exist in a recipe. They are candidates for
77
+deployment-time refinement when the recipe value is missing, generic, or based
78
+on a validation environment that differs from the user's target deployment.
79
+
80
+| Runtime parameter | Why it may need deployment-time refinement | Main decision input | Current policy |
81
+| --- | --- | --- | --- |
82
+| `tensor-parallel-size` | The effective CPU/NUMA topology available to a container or pod can differ from the system used to validate the recipe. | Hardware topology | Use the largest power-of-two TP value that does not exceed the effective NUMA-node count. |
83
+| `gpu-memory-utilization` | Available memory can differ by machine size, container limits, and other memory use. The vLLM option name is also used by the CPU backend. | Hardware memory + recipe baseline | Calculate a conservative fraction from the most constrained NUMA node. |
84
+| `max-num-seqs` | The useful scheduler concurrency depends on the number of requests expected to be active at the same time. | Workload concurrency | Set `max-num-seqs` to `--concurrency` when supplied. |
85
+| `max-num-batched-tokens` | Each scheduler iteration must share its token budget between active decodes and incoming prefills. | Input/output token shape, concurrency, and optional QPS/TPOT | Calculate decode budget + expected prefill demand, with vLLM scheduler constraints as floors. |
86
+| `data-parallel-size` | The required replica count depends on the requested throughput and the capacity of one replica. | Capacity target | Keep the recipe value today because per-replica SLA capacity is not known. |
87
+
88
+## How Each Runtime Parameter Is Calculated
89
+
90
+### `tensor-parallel-size`
91
+
92
+TP is derived from the effective NUMA topology reported by hardware detection:
93
+
94
+```text
95
+TP = largest power of two <= effective NUMA-node count
96
+```
97
+
98
+For example, 2 effective NUMA nodes produce TP=2, while 6 nodes currently
99
+produce TP=4. This is a topology-based starting point and avoids automatically
100
+selecting unusual non-power-of-two TP sizes before they are validated.
101
+
102
+### `gpu-memory-utilization`
103
+
104
+For every effective NUMA memory node, the policy calculates:
105
+
106
+```text
107
+node_safe_fraction = available_memory / total_memory - 0.10
108
+safe_fraction = min(0.90, minimum node_safe_fraction)
109
+```
110
+
111
+The 10% reserve leaves memory for model/runtime overhead outside the vLLM cache
112
+budget. If the recipe already contains a smaller value, the policy preserves the
113
+smaller value. If it is absent, `0.80` is used as the initial ceiling:
114
+
115
+```text
116
+candidate = min(recipe value or 0.80, safe_fraction)
117
+```
118
+
119
+### `max-num-seqs`
120
+
121
+When `--concurrency` is supplied, it directly represents the requested maximum
122
+number of simultaneously active requests:
123
+
124
+```text
125
+max-num-seqs = concurrency
126
+```
127
+
128
+If concurrency is not supplied, the converter does not override this parameter.
129
+
130
+### `max-num-batched-tokens`
131
+
132
+The token budget is workload-derived and no longer assumes a fixed number of
133
+parallel prefills.
134
+
135
+First, the policy determines the active sequence count:
136
+
137
+```text
138
+active_sequences =
139
+ --concurrency
140
+ or recipe max-num-seqs
141
+ or vLLM default max_num_seqs (128)
142
+```
143
+
144
+Decode requests consume approximately one token per active sequence in a
145
+scheduler iteration:
146
+
147
+```text
148
+decode_budget = active_sequences
149
+```
150
+
151
+The policy then estimates how many new prompts need prefill work per scheduler
152
+step:
153
+
154
+```text
155
+prefills_per_step = max(
156
+ 1,
157
+ active_sequences / output_tokens,
158
+ target_qps * tpot_sla_ms / 1000
159
+)
160
+
161
+prefills_per_step = min(active_sequences, prefills_per_step)
162
+prefill_budget = ceil(input_tokens * prefills_per_step)
163
+```
164
+
165
+The final scheduler budget is:
166
+
167
+```text
168
+max-num-batched-tokens = max(
169
+ vLLM default max_num_batched_tokens (2048),
170
+ active_sequences,
171
+ decode_budget + prefill_budget
172
+)
173
+```
174
+
175
+For example, with 2048 input tokens, 128 output tokens, and concurrency 32:
176
+
177
+```text
178
+decode_budget = 32
179
+prefills_per_step = max(1, 32 / 128) = 1
180
+prefill_budget = 2048
181
+candidate = max(2048, 32, 32 + 2048) = 2080
182
+```
183
+
184
+If chunked prefill is explicitly disabled and `max-model-len` is available, the
185
+policy also ensures:
186
+
187
+```text
188
+max-num-batched-tokens >= max-model-len
189
+```
190
+
191
+### `data-parallel-size`
192
+
193
+`--target-qps` alone is not enough to choose DP safely. A correct capacity rule
194
+also needs measured per-replica throughput that still satisfies TTFT/TPOT:
195
+
196
+```text
197
+DP = ceil(target_qps / qps_per_replica_at_SLO)
198
+```
199
+
200
+Because the converter does not have that measured capacity yet, it intentionally
201
+keeps the recipe DP value rather than guessing.
202
+
203
+## Precedence
204
+
205
+```text
206
+vLLM defaults
207
+ -> vLLM Recipes baseline
208
+ -> hardware refinement (optional)
209
+ -> workload / SLO refinement (optional)
210
+ -> config.yml + env.sh
211
+```
212
+
213
+## Runtime-Tuning Hardware Scope
214
+
215
+Runtime tuning is selected from the resolved recipe JSON's `hardware` field,
216
+rather than by inspecting which physical devices happen to be present on the
217
+host. This matters because a GPU server also exposes its host CPU topology.
218
+
219
+The current runtime-tuning policy registry contains `xeon6`. A tuning request
220
+for unregistered recipe hardware, such as `b200`, fails before host hardware
221
+detection:
222
+
223
+```text
224
+ERROR: Runtime tuning is not supported for recipe hardware 'b200'.
225
+Currently supported: xeon6.
226
+```
227
+
228
+Plain recipe conversion remains available for all recipe hardware. The gate only
229
+applies when optional runtime-tuning inputs are requested.
230
+
231
+## vLLM CPU Docker Shell
232
+
233
+Run hardware detection and runtime tuning inside the target vLLM CPU container
234
+so detected CPU, NUMA, memory, and cgroup limits match the deployment
235
+environment.
236
+
237
+From the vLLM source tree:
238
+
239
+```bash
240
+mkdir -p recipe-output
241
+
242
+docker run --rm -it \
243
+ --entrypoint bash \
244
+ --security-opt seccomp=unconfined \
245
+ --cap-add SYS_NICE \
246
+ --shm-size=4g \
247
+ -p 8000:8000 \
248
+ -v "$PWD/tools/recipes:/recipes:ro" \
249
+ -v "$PWD/recipe-output:/output" \
250
+ -v "$HOME/.cache/huggingface:/root/.cache/huggingface" \
251
+ -w /output \
252
+ vllm/vllm-openai-cpu:latest-x86_64
253
+```
254
+
255
+`/output` is writable; `/recipes` remains read-only.
256
+
257
+Inside the container, generate one runtime-tuned initial configuration:
258
+
259
+```bash
260
+python3 /recipes/recipe_json_to_vllm_config.py \
261
+ --model meta-llama/Llama-3.1-8B-Instruct \
262
+ --hardware xeon6 \
263
+ --detect-hardware \
264
+ --input-tokens 2048 \
265
+ --output-tokens 128 \
266
+ --concurrency 32 \
267
+ --ttft-sla-ms 3000 \
268
+ --tpot-sla-ms 100 \
269
+ --config-out /output/config.yml \
270
+ --env-out /output/env.sh
271
+```
272
+
273
+Deploy the generated initial configuration:
274
+
275
+```bash
276
+source /output/env.sh
277
+vllm serve --config /output/config.yml
278
+```
279
+
280
+For optional benchmark-backed validation of the scheduler settings, continue
281
+with [SWEEP_TUNING.md](SWEEP_TUNING.md).
282
+
283
+## Implementation
284
+
285
+- `hardware_detection.py` collects effective CPU/NUMA/memory information.
286
+- `runtime_tuning.py` owns hardware-policy selection and independent parameter
287
+ tuning functions.
288
+- `recipe_json_to_vllm_config.py` resolves the recipe, collects optional inputs,
289
+ applies the selected policy, and generates `config.yml` and `env.sh`.
290
+
291
+For benchmark-backed validation of the initial scheduler suggestion, see
292
+[SWEEP_TUNING.md](SWEEP_TUNING.md).
tools/recipes/SWEEP_TUNING.md
new
+124
@@ -0,0 +1,124 @@
1
+# Sweep Tuning
2
+
3
+Sweep tuning is optional. The converter always creates one initial `config.yml`
4
+first. Users can deploy it immediately or benchmark nearby scheduler settings
5
+and generate one measured recommendation.
6
+
7
+## Generate
8
+
9
+```bash
10
+python3 tools/recipes/recipe_json_to_vllm_config.py \
11
+ --model meta-llama/Llama-3.1-8B-Instruct \
12
+ --hardware xeon6 \
13
+ --detect-hardware \
14
+ --input-tokens 2048 \
15
+ --output-tokens 128 \
16
+ --concurrency 32 \
17
+ --ttft-sla-ms 3000 \
18
+ --tpot-sla-ms 100 \
19
+ --generate-sweep
20
+```
21
+
22
+The optional package is:
23
+
24
+```text
25
+sweep/
26
+├── serve_params.json
27
+├── bench_params.json
28
+├── run_sweep.sh
29
+├── recommend.py
30
+└── SWEEP.md
31
+```
32
+
33
+The sweep currently varies only `max-num-seqs` and
34
+`max-num-batched-tokens`.
35
+
36
+## Run and Recommend
37
+
38
+```bash
39
+sweep/run_sweep.sh --dry-run
40
+sweep/run_sweep.sh
41
+sweep/recommend.py
42
+```
43
+
44
+Resume an interrupted sweep with:
45
+
46
+```bash
47
+sweep/run_sweep.sh --resume
48
+```
49
+
50
+`recommend.py` writes:
51
+
52
+```text
53
+sweep/recommended-config.yml
54
+sweep/recommendation.json
55
+```
56
+
57
+With TTFT/TPOT objectives, the benchmark uses vLLM `--goodput`, and the
58
+recommender selects highest mean request goodput across repeated runs. Without
59
+latency objectives it selects highest mean output-token throughput. Failed
60
+benchmark configurations are excluded.
61
+
62
+`recommended-config.yml` changes only the two swept scheduler parameters.
63
+`recommendation.json` records the measured evidence.
64
+
65
+Deploy:
66
+
67
+```bash
68
+source env.sh
69
+vllm serve --config sweep/recommended-config.yml
70
+```
71
+
72
+## vLLM CPU Docker Shell
73
+
74
+Use the common CPU container setup in
75
+[RUNTIME_TUNING.md](RUNTIME_TUNING.md#vllm-cpu-docker-shell). It ensures
76
+hardware detection runs against the same effective CPU, NUMA, memory, and
77
+cgroup limits used by the deployment.
78
+
79
+Inside that container, generate the initial suggestion plus sweep package:
80
+
81
+```bash
82
+python3 /recipes/recipe_json_to_vllm_config.py \
83
+ --model meta-llama/Llama-3.1-8B-Instruct \
84
+ --hardware xeon6 \
85
+ --detect-hardware \
86
+ --input-tokens 2048 \
87
+ --output-tokens 128 \
88
+ --concurrency 32 \
89
+ --ttft-sla-ms 3000 \
90
+ --tpot-sla-ms 100 \
91
+ --config-out /output/config.yml \
92
+ --env-out /output/env.sh \
93
+ --generate-sweep \
94
+ --sweep-out-dir /output/sweep
95
+```
96
+
97
+The initial configuration can be deployed directly:
98
+
99
+```bash
100
+source /output/env.sh
101
+vllm serve --config /output/config.yml
102
+```
103
+
104
+Stop that manually started server before running the sweep, because
105
+`run_sweep.sh` starts and stops its own vLLM servers.
106
+
107
+```bash
108
+/output/sweep/run_sweep.sh --dry-run
109
+/output/sweep/run_sweep.sh
110
+/output/sweep/recommend.py
111
+```
112
+
113
+Inspect:
114
+
115
+```bash
116
+cat /output/sweep/recommendation.json
117
+```
118
+
119
+Deploy the measured result:
120
+
121
+```bash
122
+source /output/env.sh
123
+vllm serve --config /output/sweep/recommended-config.yml
124
+```
tools/recipes/hardware_detection.py
new
+115
@@ -0,0 +1,115 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+"""CPU hardware discovery for optional vLLM Recipes runtime tuning.
5
+
6
+Keep this module focused on facts exposed to the current process/container.
7
+Tuning decisions belong in runtime_tuning.py.
8
+"""
9
+
10
+from __future__ import annotations
11
+
12
+import json
13
+import platform
14
+from dataclasses import asdict, dataclass
15
+from pathlib import Path
16
+
17
+from vllm.utils.cpu_resource_utils import (
18
+ get_allowed_cpu_list,
19
+ get_memory_affinity,
20
+ get_memory_node_info,
21
+)
22
+
23
+
24
+@dataclass(frozen=True)
25
+class NumaNodeInfo:
26
+ node_id: int
27
+ logical_cpu_ids: tuple[int, ...]
28
+ physical_core_count: int
29
+ total_memory_bytes: int
30
+ available_memory_bytes: int
31
+
32
+
33
+@dataclass(frozen=True)
34
+class HardwareInfo:
35
+ architecture: str
36
+ socket_count: int | None
37
+ numa_nodes: tuple[NumaNodeInfo, ...]
38
+ allowed_logical_cpu_count: int
39
+ physical_core_count: int
40
+
41
+ @property
42
+ def numa_node_count(self) -> int:
43
+ return len(self.numa_nodes)
44
+
45
+ def to_dict(self) -> dict[str, object]:
46
+ return asdict(self)
47
+
48
+
49
+def _read_socket_id(cpu_id: int) -> int | None:
50
+ path = Path(f"/sys/devices/system/cpu/cpu{cpu_id}/topology/physical_package_id")
51
+ try:
52
+ return int(path.read_text().strip())
53
+ except (OSError, ValueError):
54
+ return None
55
+
56
+
57
+def detect_hardware() -> HardwareInfo:
58
+ """Return CPU resources effectively available to this process.
59
+
60
+ vLLM's CPU helpers already honor process CPU affinity and cgroup-aware
61
+ memory limits. We intentionally use get_memory_affinity() rather than
62
+ get_visible_memory_node() so CPU_VISIBLE_MEMORY_NODES does not become an
63
+ input to the detector; auto binding remains owned by the CPU backend.
64
+ """
65
+ cpus = get_allowed_cpu_list()
66
+ if not cpus:
67
+ raise RuntimeError("vLLM reported no CPUs available to this process.")
68
+
69
+ cpu_numa_nodes = {cpu.numa_node for cpu in cpus if cpu.numa_node >= 0}
70
+ memory_numa_nodes = set(get_memory_affinity())
71
+ effective_numa_nodes = sorted(cpu_numa_nodes & memory_numa_nodes)
72
+ if not effective_numa_nodes:
73
+ effective_numa_nodes = sorted(cpu_numa_nodes)
74
+
75
+ physical_cores = {
76
+ (cpu.numa_node, cpu.physical_core) for cpu in cpus if cpu.physical_core >= 0
77
+ }
78
+
79
+ numa_nodes = []
80
+ for node_id in effective_numa_nodes:
81
+ node_cpus = [cpu for cpu in cpus if cpu.numa_node == node_id]
82
+ node_physical_cores = {
83
+ cpu.physical_core for cpu in node_cpus if cpu.physical_core >= 0
84
+ }
85
+ memory = get_memory_node_info(node_id)
86
+ numa_nodes.append(
87
+ NumaNodeInfo(
88
+ node_id=node_id,
89
+ logical_cpu_ids=tuple(sorted(cpu.id for cpu in node_cpus)),
90
+ physical_core_count=len(node_physical_cores),
91
+ total_memory_bytes=memory.total_memory,
92
+ available_memory_bytes=memory.available_memory,
93
+ )
94
+ )
95
+
96
+ socket_ids = {
97
+ socket_id for cpu in cpus if (socket_id := _read_socket_id(cpu.id)) is not None
98
+ }
99
+
100
+ return HardwareInfo(
101
+ architecture=platform.machine(),
102
+ socket_count=len(socket_ids) if socket_ids else None,
103
+ numa_nodes=tuple(numa_nodes),
104
+ allowed_logical_cpu_count=len(cpus),
105
+ physical_core_count=len(physical_cores),
106
+ )
107
+
108
+
109
+def main() -> int:
110
+ print(json.dumps(detect_hardware().to_dict(), indent=2))
111
+ return 0
112
+
113
+
114
+if __name__ == "__main__":
115
+ raise SystemExit(main())
tools/recipes/recipe_json_to_vllm_config.py
+162
-1
@@ -57,7 +57,6 @@ try:
57
except ImportError as exc:
58
raise SystemExit("PyYAML is required. Install it with: pip install pyyaml") from exc
59
60
-
60
DEFAULT_API_BASE = "https://recipes.vllm.ai"
61
62
SHORT_ALIASES = {
@@ -117,6 +116,71 @@ def parse_args() -> argparse.Namespace:
116
default="env.sh",
117
help="Output shell environment file (default: env.sh)",
118
)
119
+
120
+ tuning = p.add_argument_group(
121
+ "optional runtime tuning",
122
+ (
123
+ "Refine the recipe baseline only when additional information is supplied. "
124
+ "Runtime tuning is enabled only for recipe hardware with a registered "
125
+ "policy (currently: xeon6)."
126
+ ),
127
+ )
128
+ tuning.add_argument(
129
+ "--detect-hardware",
130
+ action="store_true",
131
+ help=(
132
+ "Detect effective CPU/NUMA/memory resources and allow the selected "
133
+ "recipe-hardware policy to override recipe runtime arguments."
134
+ ),
135
+ )
136
+ tuning.add_argument(
137
+ "--input-tokens",
138
+ type=int,
139
+ help="Expected input-token length. Optional workload hint.",
140
+ )
141
+ tuning.add_argument(
142
+ "--output-tokens",
143
+ type=int,
144
+ help="Expected output-token length. Optional workload hint.",
145
+ )
146
+ tuning.add_argument(
147
+ "--concurrency",
148
+ type=int,
149
+ help="Expected maximum concurrent requests. Optional workload hint.",
150
+ )
151
+ tuning.add_argument(
152
+ "--ttft-sla-ms",
153
+ type=float,
154
+ help="Optional time-to-first-token objective in milliseconds.",
155
+ )
156
+ tuning.add_argument(
157
+ "--tpot-sla-ms",
158
+ type=float,
159
+ help="Optional time-per-output-token objective in milliseconds.",
160
+ )
161
+ tuning.add_argument(
162
+ "--target-qps",
163
+ type=float,
164
+ help="Optional capacity target for future DP/capacity tuning.",
165
+ )
166
+
167
+ sweep = p.add_argument_group(
168
+ "optional performance sweep",
169
+ ("Generate benchmark files after creating one initial runtime suggestion."),
170
+ )
171
+ sweep.add_argument(
172
+ "--generate-sweep",
173
+ action="store_true",
174
+ help=(
175
+ "Generate an optional vllm bench sweep package around the single "
176
+ "initial runtime suggestion."
177
+ ),
178
+ )
179
+ sweep.add_argument(
180
+ "--sweep-out-dir",
181
+ default="sweep",
182
+ help="Output directory for optional sweep files (default: sweep).",
183
+ )
184
return p.parse_args()
185
186
@@ -642,18 +706,115 @@ def main() -> int:
706
707
argv = recipe_argv(recipe)
708
config = argv_to_config(argv)
709
+
710
+ tuning_requested = (
711
+ args.detect_hardware
712
+ or args.generate_sweep
713
+ or any(
714
+ value is not None
715
+ for value in (
716
+ args.input_tokens,
717
+ args.output_tokens,
718
+ args.concurrency,
719
+ args.ttft_sla_ms,
720
+ args.tpot_sla_ms,
721
+ args.target_qps,
722
+ )
723
+ )
724
+ )
725
+
726
+ tuning = None
727
+ workload = None
728
+ sweep_writer = None
729
+ if tuning_requested:
730
+ # Keep plain Recipes conversion lightweight. vLLM-specific modules
731
+ # are imported only for optional runtime tuning or sweep generation.
732
+ from runtime_tuning import (
733
+ WorkloadHints,
734
+ finetune_runtime_config,
735
+ get_runtime_tuning_policies,
736
+ )
737
+
738
+ workload = WorkloadHints(
739
+ input_tokens=args.input_tokens,
740
+ output_tokens=args.output_tokens,
741
+ concurrency=args.concurrency,
742
+ ttft_sla_ms=args.ttft_sla_ms,
743
+ tpot_sla_ms=args.tpot_sla_ms,
744
+ target_qps=args.target_qps,
745
+ )
746
+
747
+ if args.generate_sweep:
748
+ from sweep_generation import (
749
+ validate_sweep_workload,
750
+ write_sweep_files,
751
+ )
752
+
753
+ validate_sweep_workload(workload)
754
+ sweep_writer = write_sweep_files
755
+
756
+ recipe_hardware = recipe.get("hardware")
757
+ policies = get_runtime_tuning_policies(recipe_hardware)
758
+
759
+ hardware = None
760
+ if args.detect_hardware:
761
+ from hardware_detection import detect_hardware
762
+
763
+ hardware = detect_hardware()
764
+
765
+ tuning = finetune_runtime_config(
766
+ config,
767
+ hardware=hardware,
768
+ workload=workload,
769
+ policies=policies,
770
+ )
771
+ config.update(tuning.overrides)
772
+
773
write_config(args.config_out, source, recipe, config)
774
write_env(args.env_out, source, recipe)
775
+
776
+ sweep_files: list[Path] = []
777
+ if args.generate_sweep:
778
+ assert workload is not None
779
+ assert sweep_writer is not None
780
+ sweep_files = sweep_writer(
781
+ args.sweep_out_dir,
782
+ config_path=args.config_out,
783
+ env_path=args.env_out,
784
+ config=config,
785
+ workload=workload,
786
+ )
787
+
788
+ if tuning is not None:
789
+ if tuning.overrides:
790
+ print("Initial runtime suggestion:")
791
+ for key, value in tuning.overrides.items():
792
+ print(f" {key}: {value}")
793
+ for note in tuning.notes:
794
+ print(f" tuning: {note}")
795
except Exception as exc:
796
print(f"ERROR: {exc}", file=sys.stderr)
797
return 1
798
799
print(f"Wrote {args.config_out}")
800
print(f"Wrote {args.env_out}")
801
+ if sweep_files:
802
+ print(f"Wrote optional sweep package under {args.sweep_out_dir}/")
803
print()
804
print("Run:")
805
print(f" source {shlex.quote(args.env_out)}")
806
print(f" vllm serve --config {shlex.quote(args.config_out)}")
807
+ if sweep_files:
808
+ sweep_dir = Path(args.sweep_out_dir)
809
+ run_sweep = sweep_dir / "run_sweep.sh"
810
+ recommend = sweep_dir / "recommend.py"
811
+ print()
812
+ print("Optional performance sweep:")
813
+ print(f" {shlex.quote(str(run_sweep))} --dry-run")
814
+ print(f" {shlex.quote(str(run_sweep))}")
815
+ print()
816
+ print("After the sweep:")
817
+ print(f" {shlex.quote(str(recommend))}")
818
return 0
819
820
tools/recipes/runtime_tuning.py
new
+314
@@ -0,0 +1,314 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+"""Optional runtime refinement for configs produced from vLLM Recipes.
5
+
6
+The recipe remains the baseline. Each policy may return an override only when
7
+enough information is available. Keep policies independent so the tuning
8
+algorithms can evolve without changing the converter or hardware detector.
9
+
10
+The policies in this draft are intentionally simple heuristics, not benchmark-
11
+proven universal optima.
12
+"""
13
+
14
+from __future__ import annotations
15
+
16
+from collections.abc import Callable
17
+from dataclasses import dataclass, field
18
+from math import ceil
19
+from typing import Any
20
+
21
+from hardware_detection import HardwareInfo
22
+
23
+from vllm.config.scheduler import SchedulerConfig
24
+
25
+
26
+@dataclass(frozen=True)
27
+class WorkloadHints:
28
+ input_tokens: int | None = None
29
+ output_tokens: int | None = None
30
+ concurrency: int | None = None
31
+ ttft_sla_ms: float | None = None
32
+ tpot_sla_ms: float | None = None
33
+ target_qps: float | None = None
34
+
35
+ def validate(self) -> None:
36
+ for name in ("input_tokens", "output_tokens", "concurrency"):
37
+ value = getattr(self, name)
38
+ if value is not None and value <= 0:
39
+ raise ValueError(f"{name} must be greater than zero")
40
+
41
+ for name in ("ttft_sla_ms", "tpot_sla_ms", "target_qps"):
42
+ value = getattr(self, name)
43
+ if value is not None and value <= 0:
44
+ raise ValueError(f"{name} must be greater than zero")
45
+
46
+
47
+@dataclass
48
+class TuningResult:
49
+ overrides: dict[str, Any] = field(default_factory=dict)
50
+ notes: list[str] = field(default_factory=list)
51
+
52
+
53
+Policy = Callable[
54
+ [dict[str, Any], HardwareInfo | None, WorkloadHints, TuningResult],
55
+ None,
56
+]
57
+
58
+
59
+def _record_override(
60
+ result: TuningResult,
61
+ key: str,
62
+ value: Any,
63
+ reason: str,
64
+) -> None:
65
+ result.overrides[key] = value
66
+ result.notes.append(f"{key}={value!r}: {reason}")
67
+
68
+
69
+def _get_active_sequence_count(
70
+ config: dict[str, Any],
71
+ workload: WorkloadHints,
72
+) -> int:
73
+ """Return the sequence count used to size one scheduler iteration."""
74
+ if workload.concurrency is not None:
75
+ return workload.concurrency
76
+
77
+ configured = config.get("max-num-seqs")
78
+ if (
79
+ isinstance(configured, int)
80
+ and not isinstance(configured, bool)
81
+ and configured > 0
82
+ ):
83
+ return configured
84
+
85
+ return SchedulerConfig.DEFAULT_MAX_NUM_SEQS
86
+
87
+
88
+def _estimate_prefills_per_step(
89
+ workload: WorkloadHints,
90
+ active_sequences: int,
91
+) -> float:
92
+ """Estimate steady-state prompt arrivals per decode scheduler step."""
93
+ # Reserve enough prefill budget for at least one representative prompt.
94
+ prefills_per_step = 1.0
95
+
96
+ # In steady state, active_sequences / output_tokens approximates how many
97
+ # requests finish and are replaced by new prefills per decode step.
98
+ if workload.output_tokens is not None:
99
+ prefills_per_step = max(
100
+ prefills_per_step,
101
+ active_sequences / workload.output_tokens,
102
+ )
103
+
104
+ # With target QPS + TPOT, approximate how many prompts arrive during one
105
+ # decode scheduler-step interval.
106
+ if workload.target_qps is not None and workload.tpot_sla_ms is not None:
107
+ prefills_per_step = max(
108
+ prefills_per_step,
109
+ workload.target_qps * workload.tpot_sla_ms / 1000.0,
110
+ )
111
+
112
+ # A step cannot replace more requests than are active.
113
+ return min(float(active_sequences), prefills_per_step)
114
+
115
+
116
+def _resolve_tensor_parallel_size(
117
+ config: dict[str, Any],
118
+ hardware: HardwareInfo | None,
119
+ workload: WorkloadHints,
120
+ result: TuningResult,
121
+) -> None:
122
+ del config, workload
123
+ if hardware is None or hardware.numa_node_count < 1:
124
+ return
125
+
126
+ # Draft policy: use the largest power-of-two TP that does not exceed the
127
+ # effective NUMA-node count. This deliberately avoids hard-coding unusual
128
+ # non-power-of-two topologies into the converter.
129
+ candidate = 1 << (hardware.numa_node_count.bit_length() - 1)
130
+ _record_override(
131
+ result,
132
+ "tensor-parallel-size",
133
+ candidate,
134
+ "derived from the effective NUMA-node count",
135
+ )
136
+
137
+
138
+def _resolve_gpu_memory_utilization(
139
+ config: dict[str, Any],
140
+ hardware: HardwareInfo | None,
141
+ workload: WorkloadHints,
142
+ result: TuningResult,
143
+) -> None:
144
+ del workload
145
+ if hardware is None or not hardware.numa_nodes:
146
+ return
147
+
148
+ reserve_fraction = 0.10
149
+ safe_fractions = [
150
+ max(
151
+ 0.0,
152
+ node.available_memory_bytes / node.total_memory_bytes - reserve_fraction,
153
+ )
154
+ for node in hardware.numa_nodes
155
+ if node.total_memory_bytes > 0
156
+ ]
157
+ if not safe_fractions:
158
+ return
159
+
160
+ safe_fraction = round(min(0.90, min(safe_fractions)), 2)
161
+ if safe_fraction <= 0:
162
+ return
163
+
164
+ recipe_value = config.get("gpu-memory-utilization")
165
+ if isinstance(recipe_value, (int, float)):
166
+ candidate = min(float(recipe_value), safe_fraction)
167
+ else:
168
+ candidate = min(0.80, safe_fraction)
169
+
170
+ _record_override(
171
+ result,
172
+ "gpu-memory-utilization",
173
+ round(candidate, 2),
174
+ "constrained by effective per-NUMA available memory",
175
+ )
176
+
177
+
178
+def _resolve_max_num_seqs(
179
+ config: dict[str, Any],
180
+ hardware: HardwareInfo | None,
181
+ workload: WorkloadHints,
182
+ result: TuningResult,
183
+) -> None:
184
+ del config, hardware
185
+ if workload.concurrency is None:
186
+ return
187
+
188
+ _record_override(
189
+ result,
190
+ "max-num-seqs",
191
+ workload.concurrency,
192
+ "derived from the optional target concurrency",
193
+ )
194
+
195
+
196
+def _resolve_max_num_batched_tokens(
197
+ config: dict[str, Any],
198
+ hardware: HardwareInfo | None,
199
+ workload: WorkloadHints,
200
+ result: TuningResult,
201
+) -> None:
202
+ del hardware
203
+ if workload.input_tokens is None:
204
+ return
205
+
206
+ active_sequences = _get_active_sequence_count(config, workload)
207
+
208
+ # Decode requests consume roughly one token per active sequence in one
209
+ # scheduler iteration. Prefills share the remaining scheduler token budget.
210
+ decode_budget = active_sequences
211
+ prefills_per_step = _estimate_prefills_per_step(workload, active_sequences)
212
+ prefill_budget = ceil(workload.input_tokens * prefills_per_step)
213
+
214
+ # Use vLLM's scheduler defaults/constraints as floors rather than local
215
+ # recipe-specific magic constants.
216
+ candidate = max(
217
+ SchedulerConfig.DEFAULT_MAX_NUM_BATCHED_TOKENS,
218
+ active_sequences,
219
+ decode_budget + prefill_budget,
220
+ )
221
+
222
+ # Chunked prefill normally lets a prompt span scheduler iterations. If the
223
+ # recipe explicitly disables it, vLLM requires the token budget to cover
224
+ # max_model_len when max_model_len is known.
225
+ if config.get("enable-chunked-prefill") is False:
226
+ max_model_len = config.get("max-model-len")
227
+ if (
228
+ isinstance(max_model_len, int)
229
+ and not isinstance(max_model_len, bool)
230
+ and max_model_len > 0
231
+ ):
232
+ candidate = max(candidate, max_model_len)
233
+
234
+ _record_override(
235
+ result,
236
+ "max-num-batched-tokens",
237
+ candidate,
238
+ (
239
+ "workload-derived scheduler budget "
240
+ f"(active_sequences={active_sequences}, "
241
+ f"prefills_per_step={prefills_per_step:.2f}, "
242
+ f"decode_budget={decode_budget}, "
243
+ f"prefill_budget={prefill_budget})"
244
+ ),
245
+ )
246
+
247
+
248
+def _resolve_data_parallel_size(
249
+ config: dict[str, Any],
250
+ hardware: HardwareInfo | None,
251
+ workload: WorkloadHints,
252
+ result: TuningResult,
253
+) -> None:
254
+ del config, hardware
255
+ if workload.target_qps is None:
256
+ return
257
+
258
+ result.notes.append(
259
+ "data-parallel-size: target QPS supplied; kept recipe value because "
260
+ "per-replica capacity is not known yet"
261
+ )
262
+
263
+
264
+DEFAULT_POLICIES: tuple[Policy, ...] = (
265
+ _resolve_tensor_parallel_size,
266
+ _resolve_gpu_memory_utilization,
267
+ _resolve_max_num_seqs,
268
+ _resolve_max_num_batched_tokens,
269
+ _resolve_data_parallel_size,
270
+)
271
+
272
+# Select tuning behavior from the hardware requested by the Recipes rendering,
273
+# not from devices physically present in the host. A GPU server still exposes
274
+# its host CPU topology, so host inspection alone cannot determine deployment
275
+# intent.
276
+HARDWARE_TUNING_POLICIES: dict[str, tuple[Policy, ...]] = {
277
+ "xeon6": DEFAULT_POLICIES,
278
+}
279
+
280
+
281
+def get_runtime_tuning_policies(
282
+ recipe_hardware: object,
283
+) -> tuple[Policy, ...]:
284
+ if not isinstance(recipe_hardware, str) or not recipe_hardware:
285
+ raise ValueError(
286
+ "Runtime tuning requires the resolved Recipes JSON to declare "
287
+ "a non-empty `hardware` field."
288
+ )
289
+
290
+ policies = HARDWARE_TUNING_POLICIES.get(recipe_hardware)
291
+ if policies is None:
292
+ supported = ", ".join(sorted(HARDWARE_TUNING_POLICIES))
293
+ raise ValueError(
294
+ "Runtime tuning is not supported for recipe hardware "
295
+ f"{recipe_hardware!r}. Currently supported: {supported}."
296
+ )
297
+ return policies
298
+
299
+
300
+def finetune_runtime_config(
301
+ config: dict[str, Any],
302
+ *,
303
+ hardware: HardwareInfo | None = None,
304
+ workload: WorkloadHints | None = None,
305
+ policies: tuple[Policy, ...] = DEFAULT_POLICIES,
306
+) -> TuningResult:
307
+ """Return overrides; never mutate the recipe-derived config in place."""
308
+ hints = workload or WorkloadHints()
309
+ hints.validate()
310
+
311
+ result = TuningResult()
312
+ for policy in policies:
313
+ policy(config, hardware, hints, result)
314
+ return result
tools/recipes/sweep_generation.py
new
+341
@@ -0,0 +1,341 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+"""Generate optional vLLM benchmark sweep files from one initial suggestion."""
5
+
6
+from __future__ import annotations
7
+
8
+import json
9
+import os
10
+import shlex
11
+from pathlib import Path
12
+from typing import Any
13
+
14
+from runtime_tuning import WorkloadHints
15
+
16
+
17
+def _positive_int(config: dict[str, Any], key: str) -> int:
18
+ value = config.get(key)
19
+ if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
20
+ raise ValueError(f"Sweep generation requires a positive initial {key!r} value.")
21
+ return value
22
+
23
+
24
+def _strict_lower_power_of_two(value: int) -> int:
25
+ if value <= 1:
26
+ return 1
27
+ return 1 << ((value - 1).bit_length() - 1)
28
+
29
+
30
+def _strict_upper_power_of_two(value: int) -> int:
31
+ return 1 << value.bit_length()
32
+
33
+
34
+def validate_sweep_workload(workload: WorkloadHints) -> None:
35
+ required = {
36
+ "--input-tokens": workload.input_tokens,
37
+ "--output-tokens": workload.output_tokens,
38
+ "--concurrency": workload.concurrency,
39
+ }
40
+ missing = [name for name, value in required.items() if value is None]
41
+ if missing:
42
+ raise ValueError("--generate-sweep requires " + ", ".join(missing) + ".")
43
+
44
+
45
+def build_serve_params(config: dict[str, Any]) -> list[dict[str, Any]]:
46
+ """Build a small directed sweep around the single initial suggestion."""
47
+ initial_seqs = _positive_int(config, "max-num-seqs")
48
+ initial_batch = _positive_int(config, "max-num-batched-tokens")
49
+
50
+ minimum_batch = initial_seqs
51
+ if config.get("enable-chunked-prefill") is False:
52
+ max_model_len = config.get("max-model-len")
53
+ if (
54
+ isinstance(max_model_len, int)
55
+ and not isinstance(max_model_len, bool)
56
+ and max_model_len > 0
57
+ ):
58
+ minimum_batch = max(minimum_batch, max_model_len)
59
+
60
+ lower_seqs = max(1, (initial_seqs + 1) // 2)
61
+ lower_batch = max(
62
+ minimum_batch,
63
+ _strict_lower_power_of_two(initial_batch),
64
+ )
65
+ higher_batch = max(
66
+ minimum_batch,
67
+ _strict_upper_power_of_two(initial_batch),
68
+ )
69
+
70
+ candidates: list[dict[str, Any]] = []
71
+ seen: set[tuple[int, int]] = set()
72
+
73
+ def add(name: str, max_num_seqs: int, max_num_batched_tokens: int) -> None:
74
+ max_num_batched_tokens = max(
75
+ max_num_batched_tokens,
76
+ max_num_seqs,
77
+ )
78
+ signature = (max_num_seqs, max_num_batched_tokens)
79
+ if signature in seen:
80
+ return
81
+ seen.add(signature)
82
+ candidates.append(
83
+ {
84
+ "_benchmark_name": name,
85
+ "max_num_seqs": max_num_seqs,
86
+ "max_num_batched_tokens": max_num_batched_tokens,
87
+ }
88
+ )
89
+
90
+ # Keep the exact initial suggestion as the measured baseline. Additional
91
+ # values exist only in the optional sweep package.
92
+ add("initial", initial_seqs, initial_batch)
93
+ add("lower_scheduler_concurrency", lower_seqs, initial_batch)
94
+ add("lower_batch_budget", initial_seqs, lower_batch)
95
+ add("latency_focused", lower_seqs, lower_batch)
96
+ add("higher_batch_budget", initial_seqs, higher_batch)
97
+
98
+ return candidates
99
+
100
+
101
+def build_bench_params(workload: WorkloadHints) -> list[dict[str, Any]]:
102
+ validate_sweep_workload(workload)
103
+ assert workload.input_tokens is not None
104
+ assert workload.output_tokens is not None
105
+ assert workload.concurrency is not None
106
+
107
+ return [
108
+ {
109
+ "_benchmark_name": "user_workload",
110
+ "random_input_len": workload.input_tokens,
111
+ "random_output_len": workload.output_tokens,
112
+ "max_concurrency": workload.concurrency,
113
+ }
114
+ ]
115
+
116
+
117
+def _benchmark_models(config: dict[str, Any]) -> tuple[str, str]:
118
+ model = config.get("model")
119
+ if not isinstance(model, str) or not model:
120
+ raise ValueError("Sweep generation requires a model in config.yml.")
121
+
122
+ served_model_name = config.get("served-model-name")
123
+ if isinstance(served_model_name, str) and served_model_name:
124
+ return served_model_name, model
125
+ if (
126
+ isinstance(served_model_name, list)
127
+ and served_model_name
128
+ and isinstance(served_model_name[0], str)
129
+ ):
130
+ return served_model_name[0], model
131
+
132
+ return model, model
133
+
134
+
135
+def _relative_to(directory: Path, target: str) -> str:
136
+ return os.path.relpath(Path(target).resolve(), directory.resolve())
137
+
138
+
139
+def _write_json(path: Path, value: object) -> None:
140
+ path.write_text(
141
+ json.dumps(value, indent=2) + "\n",
142
+ encoding="utf-8",
143
+ )
144
+
145
+
146
+def _write_run_script(
147
+ path: Path,
148
+ *,
149
+ config_rel: str,
150
+ env_rel: str,
151
+ request_model: str,
152
+ tokenizer: str,
153
+ workload: WorkloadHints,
154
+) -> None:
155
+ bench_parts = [
156
+ "vllm bench serve",
157
+ "--backend vllm",
158
+ f"--model {shlex.quote(request_model)}",
159
+ f"--tokenizer {shlex.quote(tokenizer)}",
160
+ "--dataset-name random",
161
+ "--request-rate inf",
162
+ "--ignore-eos",
163
+ "--metric-percentiles 99",
164
+ ]
165
+
166
+ goodput_pairs: list[str] = []
167
+ if workload.ttft_sla_ms is not None:
168
+ goodput_pairs.append(f"ttft:{workload.ttft_sla_ms:g}")
169
+ if workload.tpot_sla_ms is not None:
170
+ goodput_pairs.append(f"tpot:{workload.tpot_sla_ms:g}")
171
+ if goodput_pairs:
172
+ bench_parts.append(
173
+ "--goodput " + " ".join(shlex.quote(pair) for pair in goodput_pairs)
174
+ )
175
+
176
+ bench_cmd = " ".join(bench_parts)
177
+ script = f"""#!/usr/bin/env bash
178
+set -euo pipefail
179
+
180
+SCRIPT_DIR="$(cd -- "$(dirname -- "${{BASH_SOURCE[0]}}")" && pwd)"
181
+CONFIG_PATH="${{SCRIPT_DIR}}/{config_rel}"
182
+ENV_PATH="${{SCRIPT_DIR}}/{env_rel}"
183
+
184
+source "${{ENV_PATH}}"
185
+
186
+vllm bench sweep serve \
187
+ --serve-cmd "vllm serve --config '${{CONFIG_PATH}}'" \
188
+ --bench-cmd "{bench_cmd}" \
189
+ --serve-params "${{SCRIPT_DIR}}/serve_params.json" \
190
+ --bench-params "${{SCRIPT_DIR}}/bench_params.json" \
191
+ --output-dir "${{SCRIPT_DIR}}/results" \
192
+ --experiment-name runtime-tuning \
193
+ "$@"
194
+"""
195
+ path.write_text(script, encoding="utf-8")
196
+ path.chmod(path.stat().st_mode | 0o111)
197
+
198
+
199
+def _write_recommend_script(
200
+ path: Path,
201
+ *,
202
+ config_rel: str,
203
+ env_rel: str,
204
+ workload: WorkloadHints,
205
+) -> None:
206
+ template_path = Path(__file__).with_name("sweep_recommendation.py")
207
+ source = template_path.read_text(encoding="utf-8")
208
+
209
+ replacements = {
210
+ "DEFAULT_CONFIG_PATH: str | None = None": (
211
+ f"DEFAULT_CONFIG_PATH: str | None = {config_rel!r}"
212
+ ),
213
+ "DEFAULT_ENV_PATH: str | None = None": (
214
+ f"DEFAULT_ENV_PATH: str | None = {env_rel!r}"
215
+ ),
216
+ "DEFAULT_TTFT_SLA_MS: float | None = None": (
217
+ f"DEFAULT_TTFT_SLA_MS: float | None = {workload.ttft_sla_ms!r}"
218
+ ),
219
+ "DEFAULT_TPOT_SLA_MS: float | None = None": (
220
+ f"DEFAULT_TPOT_SLA_MS: float | None = {workload.tpot_sla_ms!r}"
221
+ ),
222
+ }
223
+ for marker, replacement in replacements.items():
224
+ if marker not in source:
225
+ raise ValueError(f"Recommender template marker not found: {marker}")
226
+ source = source.replace(marker, replacement, 1)
227
+
228
+ path.write_text(source, encoding="utf-8")
229
+ path.chmod(path.stat().st_mode | 0o111)
230
+
231
+
232
+def _write_guide(path: Path, workload: WorkloadHints) -> None:
233
+ sla_lines: list[str] = []
234
+ if workload.ttft_sla_ms is not None:
235
+ sla_lines.append(f"- TTFT objective: `{workload.ttft_sla_ms:g} ms`")
236
+ if workload.tpot_sla_ms is not None:
237
+ sla_lines.append(f"- TPOT objective: `{workload.tpot_sla_ms:g} ms`")
238
+
239
+ sla_text = "\n".join(sla_lines)
240
+ if sla_text:
241
+ sla_text = (
242
+ "\n## Supplied latency objectives\n\n"
243
+ + sla_text
244
+ + "\n\nThe generated benchmark uses these values with vLLM "
245
+ "`--goodput`.\n"
246
+ )
247
+
248
+ content = f"""# Optional Runtime Tuning Sweep
249
+
250
+The generated `config.yml` is the **single initial suggestion** and can be
251
+deployed directly. The sweep benchmarks nearby values for:
252
+
253
+- `max-num-seqs`
254
+- `max-num-batched-tokens`
255
+
256
+## Run
257
+
258
+```bash
259
+./run_sweep.sh --dry-run
260
+./run_sweep.sh
261
+./recommend.py
262
+```
263
+
264
+For a quick one-run experiment:
265
+
266
+```bash
267
+rm -rf results/runtime-tuning
268
+./run_sweep.sh --num-runs 1
269
+./recommend.py
270
+```
271
+
272
+Resume an interrupted sweep:
273
+
274
+```bash
275
+./run_sweep.sh --resume
276
+```
277
+
278
+By default, vLLM benchmarks each parameter combination three times.
279
+{sla_text}
280
+## Outputs
281
+
282
+`recommend.py` writes:
283
+
284
+```text
285
+recommended-config.yml
286
+recommendation.json
287
+```
288
+
289
+With TTFT/TPOT objectives it selects highest mean request goodput across
290
+repeated runs. Without latency objectives it selects highest mean output-token
291
+throughput. Configurations with failed requests are excluded.
292
+
293
+`recommended-config.yml` copies the initial configuration and changes only
294
+`max-num-seqs` and `max-num-batched-tokens`.
295
+"""
296
+ path.write_text(content, encoding="utf-8")
297
+
298
+
299
+def write_sweep_files(
300
+ output_dir: str,
301
+ *,
302
+ config_path: str,
303
+ env_path: str,
304
+ config: dict[str, Any],
305
+ workload: WorkloadHints,
306
+) -> list[Path]:
307
+ """Write an optional sweep package around the initial config suggestion."""
308
+ validate_sweep_workload(workload)
309
+
310
+ directory = Path(output_dir)
311
+ directory.mkdir(parents=True, exist_ok=True)
312
+
313
+ serve_params = directory / "serve_params.json"
314
+ bench_params = directory / "bench_params.json"
315
+ run_script = directory / "run_sweep.sh"
316
+ recommend_script = directory / "recommend.py"
317
+ guide = directory / "SWEEP.md"
318
+
319
+ config_rel = _relative_to(directory, config_path)
320
+ env_rel = _relative_to(directory, env_path)
321
+
322
+ _write_json(serve_params, build_serve_params(config))
323
+ _write_json(bench_params, build_bench_params(workload))
324
+ request_model, tokenizer = _benchmark_models(config)
325
+ _write_run_script(
326
+ run_script,
327
+ config_rel=config_rel,
328
+ env_rel=env_rel,
329
+ request_model=request_model,
330
+ tokenizer=tokenizer,
331
+ workload=workload,
332
+ )
333
+ _write_recommend_script(
334
+ recommend_script,
335
+ config_rel=config_rel,
336
+ env_rel=env_rel,
337
+ workload=workload,
338
+ )
339
+ _write_guide(guide, workload)
340
+
341
+ return [serve_params, bench_params, run_script, recommend_script, guide]
tools/recipes/sweep_recommendation.py
new
+329
@@ -0,0 +1,329 @@
1
+#!/usr/bin/env python3
2
+# SPDX-License-Identifier: Apache-2.0
3
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
4
+
5
+"""Select one measured scheduler recommendation from vLLM sweep results."""
6
+
7
+from __future__ import annotations
8
+
9
+import argparse
10
+import json
11
+from collections import defaultdict
12
+from pathlib import Path
13
+from statistics import mean
14
+from typing import Any
15
+
16
+try:
17
+ import yaml
18
+except ImportError as exc:
19
+ raise SystemExit("PyYAML is required. Install it with: pip install pyyaml") from exc
20
+
21
+
22
+DEFAULT_CONFIG_PATH: str | None = None
23
+DEFAULT_ENV_PATH: str | None = None
24
+DEFAULT_TTFT_SLA_MS: float | None = None
25
+DEFAULT_TPOT_SLA_MS: float | None = None
26
+
27
+
28
+def _number(value: object) -> float | None:
29
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
30
+ return None
31
+ return float(value)
32
+
33
+
34
+def _mean(rows: list[dict[str, Any]], key: str) -> float | None:
35
+ values = [_number(row.get(key)) for row in rows]
36
+ numbers = [value for value in values if value is not None]
37
+ return mean(numbers) if numbers else None
38
+
39
+
40
+def _positive_int(row: dict[str, Any], key: str) -> int | None:
41
+ value = row.get(key)
42
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
43
+ return None
44
+ return value
45
+
46
+
47
+def _resolve(script_dir: Path, value: str | None) -> Path | None:
48
+ if value is None:
49
+ return None
50
+ path = Path(value)
51
+ return path if path.is_absolute() else script_dir / path
52
+
53
+
54
+def _load_runs(results_dir: Path) -> list[dict[str, Any]]:
55
+ rows: list[dict[str, Any]] = []
56
+ for path in sorted(results_dir.rglob("summary.json")):
57
+ data = json.loads(path.read_text(encoding="utf-8"))
58
+ if not isinstance(data, list):
59
+ continue
60
+ for row in data:
61
+ if isinstance(row, dict):
62
+ copied = dict(row)
63
+ copied["_summary_path"] = str(path)
64
+ rows.append(copied)
65
+ return rows
66
+
67
+
68
+def _aggregate_candidates(
69
+ rows: list[dict[str, Any]],
70
+ *,
71
+ use_goodput: bool,
72
+) -> list[dict[str, Any]]:
73
+ grouped: dict[tuple[int, int], list[dict[str, Any]]] = defaultdict(list)
74
+
75
+ for row in rows:
76
+ seqs = _positive_int(row, "max_num_seqs")
77
+ batch = _positive_int(row, "max_num_batched_tokens")
78
+ if seqs is None or batch is None:
79
+ continue
80
+ grouped[(seqs, batch)].append(row)
81
+
82
+ candidates: list[dict[str, Any]] = []
83
+ for (seqs, batch), runs in sorted(grouped.items()):
84
+ failed_requests = sum(int(_number(run.get("failed")) or 0) for run in runs)
85
+ output_throughput = _mean(runs, "output_throughput")
86
+ request_goodput = _mean(runs, "request_goodput")
87
+ p99_ttft_ms = _mean(runs, "p99_ttft_ms")
88
+ p99_tpot_ms = _mean(runs, "p99_tpot_ms")
89
+
90
+ valid = failed_requests == 0 and output_throughput is not None
91
+ reason = None
92
+ if failed_requests:
93
+ valid = False
94
+ reason = f"{failed_requests} failed request(s) across repeated runs"
95
+ elif use_goodput and request_goodput is None:
96
+ valid = False
97
+ reason = "request_goodput is missing; rerun sweep with SLO goodput enabled"
98
+
99
+ candidates.append(
100
+ {
101
+ "max_num_seqs": seqs,
102
+ "max_num_batched_tokens": batch,
103
+ "run_count": len(runs),
104
+ "failed_requests": failed_requests,
105
+ "mean_request_goodput": request_goodput,
106
+ "mean_output_throughput": output_throughput,
107
+ "mean_p99_ttft_ms": p99_ttft_ms,
108
+ "mean_p99_tpot_ms": p99_tpot_ms,
109
+ "valid": valid,
110
+ "invalid_reason": reason,
111
+ "summary_files": sorted({str(run["_summary_path"]) for run in runs}),
112
+ }
113
+ )
114
+
115
+ return candidates
116
+
117
+
118
+def _select_candidate(
119
+ candidates: list[dict[str, Any]],
120
+ *,
121
+ use_goodput: bool,
122
+) -> tuple[dict[str, Any], str]:
123
+ valid = [candidate for candidate in candidates if candidate["valid"]]
124
+ if not valid:
125
+ raise ValueError(
126
+ "No valid sweep configuration was found. Inspect the sweep results "
127
+ "for failed requests or missing metrics."
128
+ )
129
+
130
+ if use_goodput:
131
+ objective = "highest_mean_request_goodput"
132
+ winner = max(
133
+ valid,
134
+ key=lambda candidate: (
135
+ candidate["mean_request_goodput"],
136
+ candidate["mean_output_throughput"],
137
+ -candidate["max_num_batched_tokens"],
138
+ -candidate["max_num_seqs"],
139
+ ),
140
+ )
141
+ if winner["mean_request_goodput"] <= 0:
142
+ raise ValueError(
143
+ "No sweep configuration produced non-zero request goodput for "
144
+ "the supplied TTFT/TPOT objectives."
145
+ )
146
+ else:
147
+ objective = "highest_mean_output_throughput"
148
+ winner = max(
149
+ valid,
150
+ key=lambda candidate: (
151
+ candidate["mean_output_throughput"],
152
+ -candidate["max_num_batched_tokens"],
153
+ -candidate["max_num_seqs"],
154
+ ),
155
+ )
156
+
157
+ return winner, objective
158
+
159
+
160
+def _load_config(path: Path) -> dict[str, Any]:
161
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
162
+ if not isinstance(data, dict):
163
+ raise ValueError(f"{path} does not contain a YAML configuration object.")
164
+ return data
165
+
166
+
167
+def _write_config(
168
+ path: Path,
169
+ *,
170
+ source_path: Path,
171
+ config: dict[str, Any],
172
+ objective: str,
173
+) -> None:
174
+ body = yaml.safe_dump(
175
+ config,
176
+ sort_keys=False,
177
+ default_flow_style=False,
178
+ allow_unicode=True,
179
+ )
180
+ header = (
181
+ "# Generated from the initial recipe config after a vLLM parameter sweep.\n"
182
+ f"# Initial config: {source_path}\n"
183
+ f"# Selection objective: {objective}\n"
184
+ )
185
+ path.write_text(header + body, encoding="utf-8")
186
+
187
+
188
+def parse_args() -> argparse.Namespace:
189
+ parser = argparse.ArgumentParser(
190
+ description=(
191
+ "Select one recommended max-num-seqs/max-num-batched-tokens pair "
192
+ "from generated vLLM sweep results."
193
+ )
194
+ )
195
+ parser.add_argument(
196
+ "--results-dir",
197
+ default="results/runtime-tuning",
198
+ help="Sweep experiment directory (default: results/runtime-tuning).",
199
+ )
200
+ parser.add_argument(
201
+ "--config",
202
+ default=DEFAULT_CONFIG_PATH,
203
+ help="Initial config.yml used by the sweep.",
204
+ )
205
+ parser.add_argument(
206
+ "--env",
207
+ default=DEFAULT_ENV_PATH,
208
+ help="Environment file to show in the deployment instructions.",
209
+ )
210
+ parser.add_argument(
211
+ "--ttft-sla-ms",
212
+ type=float,
213
+ default=DEFAULT_TTFT_SLA_MS,
214
+ help="TTFT objective used to generate request goodput.",
215
+ )
216
+ parser.add_argument(
217
+ "--tpot-sla-ms",
218
+ type=float,
219
+ default=DEFAULT_TPOT_SLA_MS,
220
+ help="TPOT objective used to generate request goodput.",
221
+ )
222
+ parser.add_argument(
223
+ "--output-config",
224
+ default="recommended-config.yml",
225
+ help="Recommended config output path.",
226
+ )
227
+ parser.add_argument(
228
+ "--output-json",
229
+ default="recommendation.json",
230
+ help="Recommendation evidence output path.",
231
+ )
232
+ return parser.parse_args()
233
+
234
+
235
+def main() -> int:
236
+ args = parse_args()
237
+ script_dir = Path(__file__).resolve().parent
238
+
239
+ config_path = _resolve(script_dir, args.config)
240
+ env_path = _resolve(script_dir, args.env)
241
+ results_dir = _resolve(script_dir, args.results_dir)
242
+ output_config = _resolve(script_dir, args.output_config)
243
+ output_json = _resolve(script_dir, args.output_json)
244
+
245
+ if config_path is None:
246
+ raise ValueError("--config is required.")
247
+ assert results_dir is not None
248
+ assert output_config is not None
249
+ assert output_json is not None
250
+
251
+ if not results_dir.exists():
252
+ raise ValueError(
253
+ f"Sweep results were not found at {results_dir}. Run the sweep first."
254
+ )
255
+
256
+ rows = _load_runs(results_dir)
257
+ if not rows:
258
+ raise ValueError(f"No summary.json sweep results found under {results_dir}.")
259
+
260
+ use_goodput = args.ttft_sla_ms is not None or args.tpot_sla_ms is not None
261
+ candidates = _aggregate_candidates(rows, use_goodput=use_goodput)
262
+ winner, objective = _select_candidate(candidates, use_goodput=use_goodput)
263
+
264
+ initial_config = _load_config(config_path)
265
+ recommended_config = dict(initial_config)
266
+ recommended_config["max-num-seqs"] = winner["max_num_seqs"]
267
+ recommended_config["max-num-batched-tokens"] = winner["max_num_batched_tokens"]
268
+
269
+ _write_config(
270
+ output_config,
271
+ source_path=config_path,
272
+ config=recommended_config,
273
+ objective=objective,
274
+ )
275
+
276
+ recommendation = {
277
+ "selection_objective": objective,
278
+ "slo": {
279
+ "ttft_ms": args.ttft_sla_ms,
280
+ "tpot_ms": args.tpot_sla_ms,
281
+ },
282
+ "initial": {
283
+ "max_num_seqs": initial_config.get("max-num-seqs"),
284
+ "max_num_batched_tokens": initial_config.get("max-num-batched-tokens"),
285
+ },
286
+ "recommended": {
287
+ "max_num_seqs": winner["max_num_seqs"],
288
+ "max_num_batched_tokens": winner["max_num_batched_tokens"],
289
+ },
290
+ "measured": {
291
+ "run_count": winner["run_count"],
292
+ "mean_request_goodput": winner["mean_request_goodput"],
293
+ "mean_output_throughput": winner["mean_output_throughput"],
294
+ "mean_p99_ttft_ms": winner["mean_p99_ttft_ms"],
295
+ "mean_p99_tpot_ms": winner["mean_p99_tpot_ms"],
296
+ },
297
+ "candidates": candidates,
298
+ }
299
+ output_json.write_text(
300
+ json.dumps(recommendation, indent=2) + "\n",
301
+ encoding="utf-8",
302
+ )
303
+
304
+ print("Recommended runtime configuration")
305
+ print()
306
+ print(f" max-num-seqs: {winner['max_num_seqs']}")
307
+ print(f" max-num-batched-tokens: {winner['max_num_batched_tokens']}")
308
+ print()
309
+ print(f"Selection objective: {objective}")
310
+ if use_goodput:
311
+ print(f" mean request goodput: {winner['mean_request_goodput']:.2f} req/s")
312
+ print(f" mean output throughput: {winner['mean_output_throughput']:.2f} tok/s")
313
+ if winner["mean_p99_ttft_ms"] is not None:
314
+ print(f" mean P99 TTFT: {winner['mean_p99_ttft_ms']:.2f} ms")
315
+ if winner["mean_p99_tpot_ms"] is not None:
316
+ print(f" mean P99 TPOT: {winner['mean_p99_tpot_ms']:.2f} ms")
317
+ print()
318
+ print(f"Wrote {output_config}")
319
+ print(f"Wrote {output_json}")
320
+ print()
321
+ print("Deploy:")
322
+ if env_path is not None:
323
+ print(f" source {env_path}")
324
+ print(f" vllm serve --config {output_config}")
325
+ return 0
326
+
327
+
328
+if __name__ == "__main__":
329
+ raise SystemExit(main())