Skip to content

Commit ae1c7cc

Browse files
BihanAndrey Cheptsovclaude
authored
[Presets] Add PD Disaggregation Support (#4227)
* [Presets] Allow PD disaggregation (1s iteration) * Update skills and system prompt * [Presets] Use the groups syntax for grouped services in exported and stored YAML * [Presets] Document PD disaggregation across skills and the preset system prompt Cover node groups (tasks) and replica groups (services) in the dstack and dstack-prototyping skills and the preset system prompt: replica/job targeting for logs/attach/ssh, SSH alias naming, cluster placement for PD, per-group sleep-infinity for prototyping, and the groups-based trial.json format. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * [Presets] Key trial.json format on node groups and fix replica/job default wording Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Drop groups serializer and keep groups --------- Co-authored-by: Bihan Rana Co-authored-by: Andrey Cheptsov <andrey.cheptsov@github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 356c59d commit ae1c7cc

9 files changed

Lines changed: 205 additions & 18 deletions

File tree

mkdocs/docs/concepts/presets.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,6 @@ At the same time, it's recommended to create presets using your own agent — ei
362362
## Limitations
363363

364364
* Currently, the agent doesn't upload compiled binaries anywhere; patches compile at runtime
365-
* Doesn't support PD disaggregation (coming soon)
366365
* The registry doesn't support public presets (coming soon)
367366
* Doesn't support ranges for `concurrency`
368367

skills/dstack-prototyping/SKILL.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ Pick the offer whose hardware best fits the goal at hand. Only when several offe
2727
Fetch `https://dstack.ai/docs/concepts/backends.md` and classify backends
2828
from the fetched document, not from memory.
2929

30+
If the intention is to use PD disaggregation, the fleet must use `placement: cluster`. Since PD disaggregation implies running a router, unlike workers that must run on GPUs, the router normally should run on a CPU instance. Use `dstack fleet` to see existing fleets and `dstack fleet get <fleet name> --json` to inspect a specific fleet.
31+
3032
## Check Serving Sources
3133

3234
Check serving-framework sources early enough to choose the image, command,
@@ -98,3 +100,20 @@ If service verification fails because the image, install, model download,
98100
command, resources, cache, or model behavior needs to change, go back to a task.
99101
If the tested serving setup is still right and only the dstack service
100102
configuration is wrong, fix the configuration and submit the service again.
103+
104+
## PD disaggregation
105+
106+
If the intention is to use PD disaggregation:
107+
108+
- Use node groups for the task and replica groups for the service: tasks' node
109+
groups are the equivalent of services' replica groups.
110+
- In both cases, you run a router and prefill/decode workers separately, and
111+
you need to use a fleet with an interconnect (`placement: cluster`).
112+
- With tasks, still use `sleep infinity` even when using `groups` (set it in
113+
each group's `commands`; top-level `commands` is not allowed with `groups`),
114+
and run the actual commands on each node interactively over SSH.
115+
- When testing inference, call the router endpoint, not the workers directly
116+
(unless you want to test if they are alive).
117+
- Look for "Node groups" and "PD disaggregation" in
118+
`https://dstack.ai/docs/concepts/tasks.md` and "Replica groups" and
119+
"PD disaggregation" in `https://dstack.ai/docs/concepts/services.md`.

skills/dstack/SKILL.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,14 @@ If background attach fails in the sandbox (permissions writing `~/.dstack` or `~
148148

149149
**"Connect to" or "open" a dev environment:** If a dev environment is already running, use `dstack attach <run name> --logs` (agent runs it in the background by default) to surface the IDE URL (`cursor://`, `vscode://`, etc.) and SSH alias. If sandboxed attach fails, request escalation or ask the user to run attach locally and share the link.
150150

151+
### Distributed tasks and multi-replica services
152+
153+
Unless you use **Distributed tasks** (see `### 2. Tasks`) or **Multi-replica services** (see `### 3. Services`), both tasks and services run on a single node. That's why `dstack logs <run name>`, `dstack attach <run name>`, and `ssh <run name>` default to the first replica/job.
154+
155+
- In a distributed task, each node runs its own job, numbered from 0 in order across node groups. Target a node via `dstack logs <run name> --job 1` or `dstack attach <run name> --job 1`.
156+
- In a multi-replica service, replicas are numbered from 0 in order across replica groups. Target a replica via `dstack logs <run name> --replica 1` or `dstack attach <run name> --replica 1`.
157+
- Attaching with a non-zero `--job` or `--replica` creates the SSH alias `ssh <run name>-<job num>-<replica num>`.
158+
151159
## Configuration types
152160

153161
`dstack` supports run configurations (dev environments, tasks, and services) and infrastructure configurations (fleets, volumes, and gateways). Configuration files can be named `<name>.dstack.yml` or simply `.dstack.yml`.
@@ -222,7 +230,7 @@ resources:
222230
223231
**Port forwarding:** When you specify `ports`, `dstack apply` forwards them to `localhost` while attached. Use `dstack attach <run name>` to reconnect and restore port forwarding. The run name becomes an SSH alias (e.g., `ssh <run name>`) for direct access.
224232

225-
**Distributed training:** Multi-node tasks are supported (e.g., via `nodes`) and require fleets that support inter-node communication (see `placement: cluster` in fleets).
233+
**Distributed tasks:** Set `nodes` to run a task across multiple nodes, or use `groups` to define node groups, each with its own `nodes` count, `resources`, `commands`, and `ports` (`groups` and top-level `nodes` are mutually exclusive). Requires a fleet that supports inter-node communication (see `placement: cluster` in fleets).
226234

227235
[Concept documentation](https://dstack.ai/docs/concepts/tasks.md) | [Configuration reference](https://dstack.ai/docs/reference/dstack.yml/task.md)
228236

@@ -262,6 +270,8 @@ resources:
262270
-d '{"model":"<model name>","messages":[{"role":"user","content":"Hello"}],"max_tokens":64}'
263271
```
264272

273+
**Multi-replica services:** Set `replicas` to run multiple replicas, or use `groups` to define replica groups, each with its own `replicas` count, `resources`, and `commands` (`groups` and top-level `replicas` are mutually exclusive). If replicas require an interconnect (e.g., PD disaggregation), the service must run on a fleet with `placement: cluster`.
274+
265275
[Concept documentation](https://dstack.ai/docs/concepts/services.md) | [Configuration reference](https://dstack.ai/docs/reference/dstack.yml/service.md)
266276

267277
### 4. Fleets

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

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,6 @@ the model variant (only if `model` has `base`), the serving framework, the
6161
Docker image and dependencies, the serving framework parameters, patch the
6262
serving framework source code, generate custom kernels, and patch drivers.
6363

64-
<!--?if prompt-->
65-
Do not use P/D disaggregation setups,
66-
unless `## Additional instructions` explicitly allows it.
67-
<!--?else-->
68-
Do not use P/D disaggregation setups.
69-
<!--?end-->
70-
<!--!TODO: allow P/D disaggregation and multi-node once tasks support node
71-
groups.-->
72-
7364
<!--?if prompt-->
7465
## Additional instructions
7566

@@ -260,12 +251,23 @@ also failed when its benchmark does not meet the constraints (see
260251
`# Constraints`). When a trial that changed several things fails, be
261252
mindful of which specific change was the root cause.
262253

263-
`trials/<n>/trial.json` is one JSON object with these fields and no others:
254+
`trials/<n>/trial.json` is a JSON object.
255+
256+
1. In case the task is not using node groups, the fields are these and no
257+
others:
264258

265259
```
266260
{"resources": {...}, "context_length": ..., "benchmark": {...}, "learned": ..., "failed": ...}
267261
```
268262

263+
2. In case the task is using node groups (e.g. for PD disaggregation),
264+
instead of a single `resources` it includes `groups`, and the fields are
265+
these and no others:
266+
267+
```
268+
{"groups": [[{...}], [{...}, {...}], [{...}]], "context_length": ..., "benchmark": {...}, "learned": ..., "failed": ...}
269+
```
270+
269271
- `resources`: the exact resources of the instance the task ran on, in
270272
`dstack` resources syntax, e.g. `{"cpu": "9", "memory": "50GB", "disk":
271273
"200GB", "gpu": {"name": "A40", "memory": "48GB", "count": 1}}`. Read the
@@ -274,6 +276,23 @@ mindful of which specific change was the root cause.
274276
`dstack run get <run name> --json`, converting MiB values to GB and the
275277
`gpus` list into one `gpu` object with the GPU `name`, per-GPU `memory`,
276278
and `count`.
279+
- `groups`: the exact resources of each instance the task ran on, per node
280+
group; groups are in the order they appear in the task configuration, and
281+
each group lists the resources of its instances, in the same format as
282+
`resources`. Read the actual values from each job's latest submission's
283+
`job_runtime_data.offer.instance.resources` in
284+
`dstack run get <run name> --json`, converting them as for `resources`.
285+
E.g. a one-node router group, a two-node prefill group, and a one-node
286+
decode group record:
287+
288+
```
289+
[
290+
[{"cpu": "16", "memory": "64GB", "disk": "100GB"}],
291+
[{"cpu": "192", "memory": "2048GB", "disk": "1000GB", "gpu": {"name": "H200", "memory": "141GB", "count": 8}},
292+
{"cpu": "192", "memory": "2048GB", "disk": "1000GB", "gpu": {"name": "H200", "memory": "141GB", "count": 8}}],
293+
[{"cpu": "192", "memory": "2048GB", "disk": "1000GB", "gpu": {"name": "H200", "memory": "141GB", "count": 8}}]
294+
]
295+
```
277296
- `context_length`: the largest context the trial's configuration handles,
278297
found as described in `## Benchmark`; `null` only when the benchmark couldn't
279298
be done at all.
@@ -301,6 +320,11 @@ During trials, run benchmarks via SSH inside the task, directly against the
301320
serving engine: use <!--?if dataset-->`dataset` and `concurrency`<!--?else-->`concurrency`, `input_tokens`, `output_tokens`, and
302321
`shared_prefix_tokens`<!--?end--> from `constraints.json` and measure all trials the same
303322
way so that their results are comparable with each other.
323+
324+
In case the task is using PD disaggregation, run benchmarks via SSH inside
325+
the router node, directly against the router engine. Never benchmark prefill
326+
or decode workers — each handles only part of a request.
327+
304328
<!--?if dataset-->
305329
Before any benchmark, reset the serving engine's prefix cache, or restart the
306330
engine, so it does not reuse what a previous benchmark cached. Do not vary
@@ -444,7 +468,8 @@ patches are correct (and will exactly replicate the result).
444468
# Task Usage
445469

446470
Trials are done entirely using `dstack` tasks. For maximum efficiency, it is a
447-
requirement that you always set the task `commands` to `sleep infinity` and
471+
requirement that you always set the task `commands` to `sleep infinity` (for
472+
a task with `groups`, in each group's `commands`) and
448473
run commands inside the task interactively, via SSH. It is important that
449474
you follow the `/dstack-prototyping` skill when working with tasks.
450475

@@ -540,6 +565,10 @@ trial benchmarks so that the results are comparable with each other. Attach to
540565
the service with `dstack attach <run name>`, which enables `ssh <run name>`
541566
into the replica.
542567

568+
In case the service is using PD disaggregation, run the final benchmark
569+
inside the router replica, directly against the router engine. Attach to it
570+
via `dstack attach <run name> --replica <replica num> --job <job num>`.
571+
543572
If the service or its benchmark cannot be completed, stop that service,
544573
pick the next-best trial, and repeat, until a service is verified or there
545574
are no unverified trials left. Report the result accordingly (see

src/dstack/_internal/cli/services/presets/session.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,7 @@ def _read_last_session_verification(path: Path) -> Optional[dict[str, Any]]:
562562

563563

564564
def _summarize_session_trials(path: Path) -> Optional[dict[str, Any]]:
565+
# TODO: Refactor this crap - must be explicit what is this and where is it used; also dicts are prohibited in dstack repo
565566
"""A trial directory without `trial.json` is still in flight and is not
566567
counted."""
567568
records = []
@@ -642,8 +643,44 @@ def _trial_entry(
642643

643644

644645
def _format_trial_gpu(record: dict[str, Any]) -> Optional[str]:
646+
"""A trial records its hardware in one of two formats, as described in
647+
`system_prompt.md`: `{"resources": {...}}` without node groups, and
648+
`{"groups": [[...], ...]}` with them.
649+
650+
Without node groups it returns that one instance's GPU, e.g. `H200:141GB:1`.
651+
With node groups it returns the GPUs of every node, e.g. `H200:141GB:1 x5`
652+
for a router plus 2 prefill and 3 decode nodes, or
653+
`H200:141GB:1 x2 + H100:80GB:1 x3` when the GPU models differ.
654+
655+
The value fills the `RESOURCES` column of a session row in
656+
`dstack preset list -v`.
657+
"""
658+
counts: dict[str, int] = {}
659+
for node in _trial_nodes(record):
660+
spec = _format_gpu(node.get("gpu"))
661+
if spec:
662+
# Insertion order is group order, so the roles read in the order they ran.
663+
counts[spec] = counts.get(spec, 0) + 1
664+
if not counts:
665+
return None
666+
return " + ".join(spec if n == 1 else f"{spec} x{n}" for spec, n in counts.items())
667+
668+
669+
def _trial_nodes(record: dict[str, Any]) -> list[dict[str, Any]]:
670+
groups = record.get("groups")
671+
if isinstance(groups, list):
672+
return [
673+
node
674+
for group in groups
675+
if isinstance(group, list)
676+
for node in group
677+
if isinstance(node, dict)
678+
]
645679
resources = record.get("resources")
646-
gpu = resources.get("gpu") if isinstance(resources, dict) else None
680+
return [resources] if isinstance(resources, dict) else []
681+
682+
683+
def _format_gpu(gpu: Any) -> Optional[str]:
647684
if not isinstance(gpu, dict) or not gpu.get("name"):
648685
return None
649686
text = str(gpu["name"])

src/tests/_internal/cli/services/presets/test_export.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,30 @@ def test_exports_a_deployable_service_configuration_with_its_files(self, tmp_pat
7070
)
7171
assert ServiceConfiguration.model_validate(data).model is not None
7272

73+
def test_exports_replica_groups_in_the_groups_syntax(self, tmp_path: Path):
74+
store = PresetStore(tmp_path / "presets")
75+
preset = get_preset()
76+
preset.service = ServiceConfiguration.model_validate(
77+
{
78+
"port": 8000,
79+
"model": "meta-llama/Llama-3.2-3B-Instruct",
80+
"groups": [
81+
{"replicas": 1, "commands": ["smg launch"]},
82+
{"replicas": 1, "commands": ["python -m sglang.launch_server"]},
83+
],
84+
}
85+
)
86+
preset_dir = store.save(preset).parent
87+
destination = tmp_path / "llama.dstack.yml"
88+
89+
export_preset(preset, preset_dir=preset_dir, destination=destination, force=False)
90+
91+
data = yaml.safe_load(destination.read_text())
92+
assert "groups" in data
93+
assert data.get("replicas") is None
94+
assert "replicas" in data["groups"][0]
95+
assert "count" not in data["groups"][0]
96+
7397
def test_names_the_service_after_the_preset(self, tmp_path: Path):
7498
store = PresetStore(tmp_path / "presets")
7599
preset = get_preset().model_copy(update={"name": "qwen-fast"})

src/tests/_internal/cli/services/presets/test_output.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,53 @@ def test_reports_the_gpu_when_no_trial_produced_a_benchmark(self, tmp_path):
415415
assert summary["best_failed"] is None
416416
assert summary["gpu"] == "MI300X:192GB:1"
417417

418+
def test_reports_the_gpu_of_a_single_node_group(self, tmp_path):
419+
from dstack._internal.cli.services.presets.session import _summarize_session_trials
420+
421+
trials_dir = _write_trials(
422+
tmp_path,
423+
[{"groups": [[{"gpu": {"name": "MI300X", "memory": "192GB", "count": 1}}]]}],
424+
)
425+
426+
summary = _summarize_session_trials(trials_dir)
427+
428+
assert summary["gpu"] == "MI300X:192GB:1"
429+
430+
def test_counts_the_worker_nodes_of_a_disaggregated_trial(self, tmp_path):
431+
from dstack._internal.cli.services.presets.session import _summarize_session_trials
432+
433+
# The CPU router has no GPU: it must neither blank the column nor split it.
434+
h200 = {"gpu": {"name": "H200", "memory": "141GB", "count": 8}}
435+
trials_dir = _write_trials(
436+
tmp_path,
437+
[{"groups": [[{"cpu": "16"}], [h200], [h200, h200]]}],
438+
)
439+
440+
summary = _summarize_session_trials(trials_dir)
441+
442+
assert summary["gpu"] == "H200:141GB:8 x3"
443+
444+
def test_shows_the_split_when_roles_ran_different_gpus(self, tmp_path):
445+
from dstack._internal.cli.services.presets.session import _summarize_session_trials
446+
447+
h100 = {"gpu": {"name": "H100", "memory": "80GB", "count": 8}}
448+
trials_dir = _write_trials(
449+
tmp_path,
450+
[
451+
{
452+
"groups": [
453+
[{"gpu": {"name": "H200", "memory": "141GB", "count": 8}}],
454+
[h100, h100],
455+
]
456+
}
457+
],
458+
)
459+
460+
summary = _summarize_session_trials(trials_dir)
461+
462+
# Group order, not sorted: the roles read in the order they ran.
463+
assert summary["gpu"] == "H200:141GB:8 + H100:80GB:8 x2"
464+
418465
def test_the_fastest_failed_trial_is_kept_when_nothing_passed(self, tmp_path):
419466
from dstack._internal.cli.services.presets.session import _summarize_session_trials
420467

src/tests/_internal/cli/services/presets/test_prompt.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,18 @@ def test_stays_byte_identical_without_user_prompt(self):
2727
assert "TODO" not in text
2828
assert "{prompt}" not in text
2929

30-
def test_injects_user_prompt_with_escape_clause(self):
30+
def test_injects_user_prompt(self):
3131
text = get_preset_agent_system_prompt(
3232
user_prompt="Optimize for RAG traffic.",
3333
baseline=False,
3434
previous=(),
3535
custom_dataset=False,
3636
)
3737

38-
clause_at = text.index("unless `## Additional instructions` explicitly allows it.")
3938
section_at = text.index(
4039
"## Additional instructions\n\n```\nOptimize for RAG traffic.\n```"
4140
)
42-
assert clause_at < section_at < text.index("## CLI And Skills")
41+
assert section_at < text.index("## CLI And Skills")
4342
assert "<!--?" not in text
4443

4544
def test_renders_only_the_custom_dataset_branch(self):

src/tests/_internal/cli/services/presets/test_store.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from dstack._internal.cli.services.presets.store import PresetStore
1212
from dstack._internal.compat import IS_WINDOWS
1313
from dstack._internal.core.errors import CLIError, ConfigurationError
14-
from dstack._internal.core.models.configurations import PresetConfiguration
14+
from dstack._internal.core.models.configurations import PresetConfiguration, ServiceConfiguration
1515
from dstack._internal.core.models.envs import EnvSentinel
1616
from dstack._internal.core.models.files import FilePathMapping
1717
from dstack._internal.core.models.presets import PortablePreset
@@ -49,6 +49,29 @@ def test_saves_and_lists_self_contained_preset(self, tmp_path: Path):
4949
assert store.get(preset.id) == preset
5050
assert not list(path.parent.glob("*.tmp"))
5151

52+
def test_saves_replica_groups_in_the_groups_syntax(self, tmp_path: Path):
53+
store = PresetStore(tmp_path / "presets")
54+
preset = get_preset()
55+
preset.service = ServiceConfiguration.model_validate(
56+
{
57+
"port": 8000,
58+
"model": "meta-llama/Llama-3.2-3B-Instruct",
59+
"groups": [
60+
{"replicas": 1, "commands": ["smg launch"]},
61+
{"replicas": 1, "commands": ["python -m sglang.launch_server"]},
62+
],
63+
}
64+
)
65+
66+
path = store.save(preset)
67+
68+
data = yaml.safe_load(path.read_text())
69+
service = data["service"]
70+
assert "groups" in service
71+
assert service.get("replicas") is None
72+
assert "replicas" in service["groups"][0]
73+
assert "count" not in service["groups"][0]
74+
5275
def test_a_verified_document_loads_as_a_verified_preset(self, tmp_path: Path):
5376
store = PresetStore(tmp_path / "presets")
5477
store.save(get_preset())

0 commit comments

Comments
 (0)