Skip to content

Commit 78a0618

Browse files
Andrey Cheptsovclaude
andcommitted
Support a benchmark dataset in preset creation
`dataset` selects what every benchmark in a preset session measures: the synthetic `random` prompts shaped by `input_tokens` and `output_tokens`, a dataset the benchmark tool supports, or a Hugging Face dataset ID. A custom dataset provides the requests, so the request-shape properties can't be set with it, and the preset records the measured means instead. The dataset is part of the contract: it is written to the session constraints, the agent reports it with the benchmark, the preset records it, and `dstack preset` shows it. A session that doesn't set it renders exactly as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 43e0bc2 commit 78a0618

15 files changed

Lines changed: 270 additions & 48 deletions

File tree

‎mkdocs/docs/concepts/presets.md‎

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -136,57 +136,67 @@ Alternatively, pass `--fleet` to `dstack preset create` or `dstack preset apply`
136136
repo: Qwen/Qwen2.5-7B-Instruct
137137
```
138138

139-
### Shared prefix
139+
### Previous sessions
140140

141-
By default every request is unique, so the cache hit rate is near zero. Set `shared_prefix_tokens` to control how much of each request the serving framework can serve from its prefix cache.
141+
Set `previous` to a list of preset IDs to give the agent the results of earlier creation sessions. It analyzes what they tried and how it worked, and aims to improve on them instead of rediscovering it.
142142

143143
<div editor-title="preset.dstack.yml">
144144

145145
```yaml
146-
input_tokens: 8192
147-
output_tokens: 1024
148-
149-
# Roughly 90% of prompt tokens can be served from cache
150-
shared_prefix_tokens: 7360
146+
previous:
147+
- c83375b4
151148
```
152149

153150
</div>
154151

155-
The `shared_prefix_tokens` value is the part of `input_tokens` that is identical across requests, such as a system prompt or conversation history, and must be less than `input_tokens`.
152+
Alternatively, pass `--previous` (repeatable) to `dstack preset create`.
156153

157154
### Prompt
158155

159-
The `prompt` property is optional. Set it to guide the agent with custom objectives, target metrics, or an experimentation approach. It accepts inline text or a file `path`.
156+
Set `prompt` to steer what the agent explores: which frameworks or model variants to try, or how deep to go before settling. It accepts inline text or a file `path`. Constraints such as `concurrency` and `max_ttft` can't be changed this way.
160157

161158
<div editor-title="preset.dstack.yml">
162159

163160
```yaml
164161
prompt: |
165-
Optimize for the lowest TTFT at concurrency 32. Consider FP8 quantization.
162+
Profile the engine before each trial and report how far it is from the
163+
memory-bandwidth roofline. While that gap is large, prefer patching the
164+
serving framework over tuning flags.
166165
```
167166

168167
</div>
169168

170-
### Baseline
169+
### Dataset
171170

172-
By default, the first trial is a baseline: the agent serves the model the way the chosen serving framework recommends, without tuning it for performance. Later trials are optimization attempts. Set `baseline: false` to make every trial an optimization attempt.
171+
The requests every benchmark measures.
173172

174-
### Previous sessions
173+
=== "Random"
175174

176-
Set `previous` to a list of preset IDs to give the agent the results of earlier creation sessions. It analyzes what they tried and how it worked, and aims to improve on them instead of rediscovering it.
175+
By default, benchmarks use synthetic prompts shaped by `input_tokens` and `output_tokens`. Set `shared_prefix_tokens` to make part of every request identical, such as a system prompt or conversation history, so the serving framework can serve it from its prefix cache. It must be less than `input_tokens`.
177176

178-
<div editor-title="preset.dstack.yml">
177+
```yaml
178+
input_tokens: 8192
179+
output_tokens: 1024
179180
180-
```yaml
181-
previous:
182-
- c83375b4
183-
```
181+
# Roughly 90% of prompt tokens can be served from cache
182+
shared_prefix_tokens: 7360
183+
```
184184

185-
</div>
185+
=== "Custom"
186186

187-
Alternatively, pass `--previous` (repeatable) to `dstack preset create`.
187+
Set `dataset` to benchmark on real text instead: a dataset the benchmark tool supports, or a Hugging Face dataset ID.
188+
189+
```yaml
190+
dataset: sharegpt
191+
```
192+
193+
The dataset provides the requests, so `input_tokens`, `output_tokens`, and `shared_prefix_tokens` can't be set with it, and the preset records the measured means. A gated dataset requires `HF_TOKEN` in `env`.
194+
195+
### Baseline
196+
197+
By default, the first trial is a baseline: the agent serves the model the way the chosen serving framework recommends, without tuning it for performance. Later trials are optimization attempts. Set `baseline: false` to make every trial an optimization attempt.
188198

189-
In this case, the baseline trial reproduces the best comparable previous result to confirm it still holds before optimizing further.
199+
When the session builds on `previous`, the baseline trial reproduces the best comparable previous result instead, to confirm it still holds before optimizing further.
190200

191201
!!! info "Reference"
192202
The `preset` configuration supports many more options. See the [`.dstack.yml` reference](../reference/dstack.yml/preset.md).
@@ -274,7 +284,6 @@ For command options and agent settings, see the [`dstack preset` CLI reference](
274284
* Currently, the agent doesn't upload compiled binaries anywhere; patches compile at runtime
275285
* Doesn't support PD disaggregation (coming soon)
276286
* Presets are saved locally (a preset registry is coming soon)
277-
* Doesn't allow a custom dataset; always uses `random`
278287
* Doesn't support ranges for `concurrency`
279288

280289
Report bugs and request features on [GitHub](https://github.com/dstackai/dstack/issues), and ask questions on [Discord](https://discord.gg/u8SmfwPpMd).

‎src/dstack/_internal/cli/models/configurations.py‎

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
DEFAULT_INPUT_TOKENS = 1024
1919
DEFAULT_OUTPUT_TOKENS = 1024
2020
DEFAULT_BASELINE = True
21+
DEFAULT_DATASET = "random"
2122

2223

2324
class PresetModelRepo(CoreModel):
@@ -199,6 +200,17 @@ class PresetConfiguration(
199200
)
200201
),
201202
] = None
203+
dataset: Annotated[
204+
Optional[str],
205+
Field(
206+
description=(
207+
"The benchmark dataset used during preset creation: `random` for synthetic"
208+
" prompts shaped by `input_tokens` and `output_tokens`, a benchmark tool's"
209+
" dataset name (e.g. `sharegpt`, `spec_bench`), or a Hugging Face dataset ID."
210+
" Defaults to `random`"
211+
)
212+
),
213+
] = None
202214
baseline: Annotated[
203215
Optional[bool],
204216
Field(
@@ -236,6 +248,38 @@ def effective_output_tokens(self) -> int:
236248
def effective_baseline(self) -> bool:
237249
return self.baseline if self.baseline is not None else DEFAULT_BASELINE
238250

251+
@property
252+
def effective_dataset(self) -> str:
253+
return self.dataset if self.dataset is not None else DEFAULT_DATASET
254+
255+
@field_validator("dataset")
256+
@classmethod
257+
def validate_dataset_name(cls, value: Optional[str]) -> Optional[str]:
258+
if value is None:
259+
return None
260+
# Stripped because the agent reports the dataset it actually loaded, and
261+
# the two are compared for equality when the preset is verified.
262+
value = value.strip()
263+
if not value:
264+
raise ValueError("dataset must be a non-empty string")
265+
return value
266+
267+
@model_validator(mode="after")
268+
def validate_dataset(self) -> Self:
269+
if self.dataset in (None, DEFAULT_DATASET):
270+
return self
271+
set_fields = [
272+
name
273+
for name in ("input_tokens", "output_tokens", "shared_prefix_tokens")
274+
if getattr(self, name) is not None
275+
]
276+
if set_fields:
277+
raise ValueError(
278+
f"{', '.join(set_fields)} can only be set with the `random` dataset;"
279+
" a custom dataset defines its own request shape"
280+
)
281+
return self
282+
239283
@model_validator(mode="after")
240284
def validate_shared_prefix_tokens(self) -> Self:
241285
# The prefix is carved out of the request, so something has to be left
@@ -294,9 +338,10 @@ class PresetConstraints(CoreModel):
294338
max_ttft: PositiveInt
295339
trials_num: PositiveInt
296340
concurrency: PositiveInt
297-
input_tokens: PositiveInt
298-
output_tokens: PositiveInt
299-
shared_prefix_tokens: int = 0
341+
input_tokens: Optional[PositiveInt] = None
342+
output_tokens: Optional[PositiveInt] = None
343+
shared_prefix_tokens: Optional[int] = None
344+
dataset: Optional[str] = None
300345
baseline: bool = False
301346
fleets: list[str] = Field(min_length=1)
302347
env: list[str] = []

‎src/dstack/_internal/cli/models/preset_agent.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,16 @@
3636
"output_tokens": {"type": "integer", "minimum": 2},
3737
"concurrency": {"type": "integer", "minimum": 1},
3838
"shared_prefix_tokens": {"type": "integer", "minimum": 0},
39+
"dataset": {"type": "string", "minLength": 1},
3940
},
41+
# `shared_prefix_tokens` and `dataset` are not required: one schema
42+
# serves both session modes, and each mode knows only its own field.
4043
"required": [
4144
"api",
4245
"num_requests",
4346
"input_tokens",
4447
"output_tokens",
4548
"concurrency",
46-
"shared_prefix_tokens",
4749
],
4850
"additionalProperties": False,
4951
},

‎src/dstack/_internal/cli/models/presets.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,16 @@
2020
class PresetBenchmarkWorkload(CoreModel):
2121
api: Literal["chat_completions", "completions"]
2222
num_requests: PositiveInt
23+
# With a dataset other than `random`, the measured means rather than the
24+
# configured request shape.
2325
input_tokens: PositiveInt
2426
output_tokens: Annotated[int, Field(ge=2)]
2527
concurrency: PositiveInt
26-
# Defaulted rather than required: presets saved before this field existed
27-
# must still load, and for them the benchmark was fully unique.
28-
shared_prefix_tokens: Annotated[int, Field(ge=0)] = 0
28+
# Absent for presets saved before the field existed, and with a dataset
29+
# other than `random`, where the dataset decides prefix sharing.
30+
shared_prefix_tokens: Annotated[Optional[int], Field(ge=0)] = None
31+
# Absent means the synthetic `random` dataset.
32+
dataset: Optional[str] = None
2933

3034

3135
class PresetBenchmarkLatency(CoreModel):

‎src/dstack/_internal/cli/services/presets/create.py‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from rich.text import Text
1616

1717
from dstack._internal.cli.models.configurations import (
18+
DEFAULT_DATASET,
1819
PresetConfiguration,
1920
PresetConstraints,
2021
)
@@ -589,6 +590,7 @@ async def _create_preset(
589590
user_prompt=setup.user_prompt,
590591
baseline=configuration.effective_baseline,
591592
previous=", ".join(setup.previous) if setup.previous else None,
593+
custom_dataset=configuration.effective_dataset != DEFAULT_DATASET,
592594
)
593595
if setup.write_constraints:
594596
if setup.user_prompt:
@@ -880,6 +882,7 @@ def _build_constraints(
880882
build_name: str,
881883
allowed_fleets: Sequence[str],
882884
) -> str:
885+
dataset = configuration.effective_dataset
883886
constraints = PresetConstraints.model_validate(
884887
{
885888
"run_name_prefix": build_name,
@@ -888,16 +891,21 @@ def _build_constraints(
888891
"max_ttft": configuration.max_ttft,
889892
"trials_num": configuration.trials,
890893
"concurrency": configuration.concurrency,
891-
"input_tokens": configuration.effective_input_tokens,
892-
"output_tokens": configuration.effective_output_tokens,
893-
"shared_prefix_tokens": configuration.shared_prefix_tokens or 0,
894+
**(
895+
{
896+
"input_tokens": configuration.effective_input_tokens,
897+
"output_tokens": configuration.effective_output_tokens,
898+
"shared_prefix_tokens": configuration.shared_prefix_tokens or 0,
899+
}
900+
if dataset == DEFAULT_DATASET
901+
else {"dataset": dataset}
902+
),
894903
"baseline": configuration.effective_baseline,
895904
"fleets": list(allowed_fleets),
896905
"env": list(configuration.env),
897906
}
898907
)
899-
# All fields are always present; unset optional constraints render as null.
900-
return json.dumps(json.loads(constraints.model_dump_json()), indent=2) + "\n"
908+
return json.dumps(json.loads(constraints.model_dump_json(exclude_none=True)), indent=2) + "\n"
901909

902910

903911
def _save_final_report_copy(

‎src/dstack/_internal/cli/services/presets/output.py‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,8 @@ def _add_session(table: Table, session: dict[str, Any], *, verbose: bool = False
192192
constraints = session.get("constraints") or {}
193193
parts = []
194194
objective = []
195+
if dataset := constraints.get("dataset"):
196+
objective.append(f"data={dataset}")
195197
if constraints.get("input_tokens") and constraints.get("output_tokens"):
196198
objective.append(
197199
f"io={_format_token_count(constraints['input_tokens'])}"
@@ -302,12 +304,16 @@ def format_preset_objective(
302304
verbose: bool = False,
303305
) -> str:
304306
workload = preset.validations[0].benchmark.workload
305-
parts = [
306-
f"io={_format_token_count(workload.input_tokens)}"
307-
f"/{_format_token_count(workload.output_tokens)}",
308-
]
309-
share = round(100 * workload.shared_prefix_tokens / workload.input_tokens)
310-
parts.append(f"prefix={share}%")
307+
parts = []
308+
if workload.dataset:
309+
parts.append(f"data={workload.dataset}")
310+
else:
311+
parts.append(
312+
f"io={_format_token_count(workload.input_tokens)}"
313+
f"/{_format_token_count(workload.output_tokens)}"
314+
)
315+
share = round(100 * (workload.shared_prefix_tokens or 0) / workload.input_tokens)
316+
parts.append(f"prefix={share}%")
311317
parts.append(f"conc={workload.concurrency}")
312318
# Absent for presets saved before the creation record was consulted.
313319
if verbose and min_context_length is not None:

‎src/dstack/_internal/cli/services/presets/prompt.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ def get_preset_agent_system_prompt(
174174
user_prompt: Optional[str] = None,
175175
baseline: bool = False,
176176
previous: Optional[str] = None,
177+
custom_dataset: bool = False,
177178
) -> str:
178179
text = _SYSTEM_PROMPT_PATH.read_text(encoding="utf-8").strip()
179180
variables = {
@@ -182,6 +183,8 @@ def get_preset_agent_system_prompt(
182183
"baseline": "on" if baseline else None,
183184
# A comma-separated list of the previous session IDs.
184185
"previous": previous.strip() if previous else None,
186+
# Rendered for its presence only; the dataset itself is in constraints.json.
187+
"dataset": "on" if custom_dataset else None,
185188
}
186189
applied: set[str] = set()
187190
rendered = _render_branch(_parse_directives(text, variables), variables, applied, dedent=False)

‎src/dstack/_internal/cli/services/presets/resources/system_prompt.md‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,15 @@ Field semantics:
3939
this session. It is fixed so that benchmark results are comparable.
4040
<!--!TODO: support a concurrency sweep, so that a trial is measured at several
4141
concurrencies instead of one.-->
42+
<!--?if dataset-->
43+
- `dataset`: the benchmark dataset for every benchmark in this session; see
44+
`## Benchmark`.
45+
<!--?else-->
4246
- `input_tokens`, `output_tokens`: the request shape for every benchmark in
4347
this session. They are fixed for the same reason.
4448
- `shared_prefix_tokens`: how many of `input_tokens` are identical in every
4549
request. `0` means every request is fully unique.
50+
<!--?end-->
4651
- `baseline`: whether the first trial must be a baseline rather than an
4752
optimization attempt; see `# Trials (Main Section)`.
4853
- `fleets`: use these existing `dstack` fleets only. Do not create, delete,
@@ -293,9 +298,31 @@ no trials remain. In that case, log the failure to `final_report.json` (see
293298
## Benchmark
294299

295300
During trials, run benchmarks via SSH inside the task, directly against the
296-
serving engine: use `concurrency`, `input_tokens`, `output_tokens`, and
297-
`shared_prefix_tokens` from `constraints.json` and measure all trials the same
301+
serving engine: use <!--?if dataset-->`dataset` and `concurrency`<!--?else-->`concurrency`, `input_tokens`, `output_tokens`, and
302+
`shared_prefix_tokens`<!--?end--> from `constraints.json` and measure all trials the same
298303
way so that their results are comparable with each other.
304+
<!--?if dataset-->
305+
Before any benchmark, reset the serving engine's prefix cache, or restart the
306+
engine, so it does not reuse what a previous benchmark cached. Do not vary
307+
which samples the dataset provides between benchmarks.
308+
309+
Use the `dataset` for every benchmark. Choose the benchmark tool's options
310+
that load exactly that dataset, and confirm from the tool's own
311+
documentation, for the version you run, how it loads the dataset. If the
312+
dataset fails to load, fix the loading; never fall back to another dataset or
313+
to synthetic prompts. Prefer the dataset's own output lengths; when the tool
314+
forces an output length instead, use the same value in every benchmark. For
315+
example, the dataset options are:
316+
317+
| tool | dataset options |
318+
| --- | --- |
319+
| `vllm bench serve` | `--dataset-name <dataset>` when `dataset` is the tool's own dataset name, or `--dataset-name hf --dataset-path <dataset>` when it is a Hugging Face dataset ID |
320+
| `sglang.benchmark.serving` | `--dataset-name <dataset>` when `dataset` is the tool's own dataset name; the tool has no Hugging Face dataset option |
321+
322+
The table is an example and not a full command: the remaining options still
323+
come from `concurrency`, option names and defaults differ between versions,
324+
and any other tool needs its own equivalent.
325+
<!--?else-->
299326
Before any benchmark, ensure it uses a different seed than the previous
300327
benchmark. Otherwise the benchmark will depend on what has been cached by the
301328
previous benchmark.
@@ -315,6 +342,7 @@ lengths. For example, the shared-prefix options are:
315342
The table is an example and not a full command: the remaining options still come
316343
from `concurrency` and `output_tokens`, option names and defaults differ between
317344
versions, and any other tool needs its own equivalent.
345+
<!--?end-->
318346

319347
Before any benchmark — a trial one or the final one — verify that the model
320348
works as expected: send real requests and check the responses, including
@@ -331,8 +359,9 @@ trial benchmarks in `trials/<n>/trial.json`, the final benchmark as
331359
{
332360
"tool": "vllm bench serve",
333361
"tool_version": "0.11.0",
334-
"command": "vllm bench serve ...",
335-
"workload": {"api": "chat_completions", "num_requests": 16, "input_tokens": 1024, "output_tokens": 128, "concurrency": 8, "shared_prefix_tokens": 768},
362+
"command": "vllm bench serve ...",<!--?if dataset-->
363+
"workload": {"api": "chat_completions", "num_requests": 16, "input_tokens": 1024, "output_tokens": 128, "concurrency": 8, "dataset": "sharegpt"},<!--?else-->
364+
"workload": {"api": "chat_completions", "num_requests": 16, "input_tokens": 1024, "output_tokens": 128, "concurrency": 8, "shared_prefix_tokens": 768},<!--?end-->
336365
"metrics": {
337366
"successful_requests": 16, "failed_requests": 0, "duration_seconds": 4.0,
338367
"total_input_tokens": 16384, "total_output_tokens": 2048,
@@ -343,6 +372,11 @@ trial benchmarks in `trials/<n>/trial.json`, the final benchmark as
343372
}
344373
```
345374

375+
<!--?if dataset-->
376+
Set `workload.dataset` to `dataset` from `constraints.json`, and compute
377+
`workload.input_tokens` and `workload.output_tokens` as the measured mean
378+
input and output token counts of the benchmark, rounded to whole tokens.
379+
<!--?end-->
346380
Compute `output_tok_per_s` as `total_output_tokens / duration_seconds` and
347381
`per_user_tok_per_s` as `output_tok_per_s / workload.concurrency`. These are
348382
the numbers used to compare trials (see `## Performance`).

0 commit comments

Comments
 (0)