Skip to content

Commit 08414d5

Browse files
authored
Support Heterogenous Node Groups (#4094)
* Support Heterogenous Node Groups * Review Comments Resolved Co-authored-by: Cursor <cursoragent@cursor.com> * Fix ResourcesSpec cpu type in hetero node group test * Pass for_offers_only in get_job_plans tests * Minor Updates * Apply resource defaults and image validation to node groups * Add docs for heterogenous node groups * Fix hetero tests for CPU arch defaults and replica provisioning gate * Remove node groups docs from PR --------- Co-authored-by: Bihan Rana
1 parent 19527e9 commit 08414d5

32 files changed

Lines changed: 2237 additions & 129 deletions

File tree

runner/internal/runner/executor/executor.go

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,15 @@ func (ex *RunExecutor) execJob(ctx context.Context, jobLogFile io.Writer) error
473473
nodeRank := ex.jobSpec.JobNum
474474
nodesNum := ex.jobSpec.JobsPerReplica
475475
gpusPerNodeNum := ex.clusterInfo.GPUSPerJob
476-
gpusNum := nodesNum * gpusPerNodeNum
476+
gpusNum := 0
477+
if len(ex.clusterInfo.GPUSPerNode) > 0 {
478+
for _, n := range ex.clusterInfo.GPUSPerNode {
479+
gpusNum += n
480+
}
481+
} else {
482+
// Old servers omit gpus_per_node; fall back to homogeneous math.
483+
gpusNum = nodesNum * gpusPerNodeNum
484+
}
477485

478486
mpiHostfilePath := filepath.Join(ex.dstackDir, "mpi/hostfile")
479487

@@ -544,7 +552,15 @@ func (ex *RunExecutor) execJob(ctx context.Context, jobLogFile io.Writer) error
544552
log.Warning(ctx, "failed to include dstack_profile", "path", profilePath, "err", err)
545553
}
546554

547-
if err := writeMpiHostfile(ctx, ex.clusterInfo.JobIPs, gpusPerNodeNum, mpiHostfilePath); err != nil {
555+
slots := ex.clusterInfo.GPUSPerNode
556+
if len(slots) == 0 {
557+
// Old servers omit gpus_per_node; fall back to homogeneous per-node GPU count.
558+
slots = make([]int, len(ex.clusterInfo.JobIPs))
559+
for i := range slots {
560+
slots[i] = gpusPerNodeNum
561+
}
562+
}
563+
if err := writeMpiHostfile(ctx, ex.clusterInfo.JobIPs, slots, mpiHostfilePath); err != nil {
548564
return fmt.Errorf("write MPI hostfile: %w", err)
549565
}
550566

@@ -759,7 +775,7 @@ func prepareUserSshDir(user *linuxuser.User) (string, error) {
759775
return sshDir, nil
760776
}
761777

762-
func writeMpiHostfile(ctx context.Context, ips []string, gpusPerNode int, path string) error {
778+
func writeMpiHostfile(ctx context.Context, ips []string, slots []int, path string) error {
763779
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
764780
return fmt.Errorf("create MPI hostfile directory: %w", err)
765781
}
@@ -775,16 +791,21 @@ func writeMpiHostfile(ctx context.Context, ips []string, gpusPerNode int, path s
775791
}
776792
}
777793
if len(nonEmptyIps) == len(ips) {
778-
var template string
779-
if gpusPerNode == 0 {
780-
// CPU node: the number of slots defaults to the number of processor cores on that host
781-
// See: https://docs.open-mpi.org/en/main/launching-apps/scheduling.html#calculating-the-number-of-slots
782-
template = "%s\n"
783-
} else {
784-
template = fmt.Sprintf("%%s slots=%d\n", gpusPerNode)
794+
if len(slots) != len(ips) {
795+
return fmt.Errorf(
796+
"gpus_per_node length %d != job_ips length %d",
797+
len(slots), len(ips),
798+
)
785799
}
786-
for _, ip := range nonEmptyIps {
787-
if _, err = fmt.Fprintf(file, template, ip); err != nil {
800+
for i, ip := range nonEmptyIps {
801+
if slots[i] == 0 {
802+
// CPU node: the number of slots defaults to the number of processor cores on that host
803+
// See: https://docs.open-mpi.org/en/main/launching-apps/scheduling.html#calculating-the-number-of-slots
804+
_, err = fmt.Fprintf(file, "%s\n", ip)
805+
} else {
806+
_, err = fmt.Fprintf(file, "%s slots=%d\n", ip, slots[i])
807+
}
808+
if err != nil {
788809
return fmt.Errorf("write MPI hostfile line: %w", err)
789810
}
790811
}

runner/internal/runner/executor/executor_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,64 @@ func TestWriteDstackProfile(t *testing.T) {
287287
}
288288
}
289289

290+
func TestWriteMpiHostfile(t *testing.T) {
291+
tmp := t.TempDir()
292+
293+
t.Run("heterogeneous_slots", func(t *testing.T) {
294+
path := filepath.Join(tmp, "hostfile_hetero")
295+
err := writeMpiHostfile(
296+
t.Context(),
297+
[]string{"10.0.0.1", "10.0.0.2", "10.0.0.3"},
298+
[]int{8, 4, 0},
299+
path,
300+
)
301+
require.NoError(t, err)
302+
content, err := os.ReadFile(path)
303+
require.NoError(t, err)
304+
assert.Equal(t, "10.0.0.1 slots=8\n10.0.0.2 slots=4\n10.0.0.3\n", string(content))
305+
})
306+
307+
t.Run("homogeneous_slots", func(t *testing.T) {
308+
path := filepath.Join(tmp, "hostfile_homo")
309+
err := writeMpiHostfile(
310+
t.Context(),
311+
[]string{"10.0.0.1", "10.0.0.2"},
312+
[]int{4, 4},
313+
path,
314+
)
315+
require.NoError(t, err)
316+
content, err := os.ReadFile(path)
317+
require.NoError(t, err)
318+
assert.Equal(t, "10.0.0.1 slots=4\n10.0.0.2 slots=4\n", string(content))
319+
})
320+
321+
t.Run("slots_length_mismatch", func(t *testing.T) {
322+
path := filepath.Join(tmp, "hostfile_mismatch")
323+
err := writeMpiHostfile(
324+
t.Context(),
325+
[]string{"10.0.0.1", "10.0.0.2"},
326+
[]int{8},
327+
path,
328+
)
329+
require.Error(t, err)
330+
assert.Contains(t, err.Error(), "gpus_per_node length 1 != job_ips length 2")
331+
})
332+
333+
t.Run("empty_ip_writes_empty_hostfile", func(t *testing.T) {
334+
path := filepath.Join(tmp, "hostfile_empty_ip")
335+
err := writeMpiHostfile(
336+
t.Context(),
337+
[]string{"10.0.0.1", ""},
338+
[]int{8, 4},
339+
path,
340+
)
341+
require.NoError(t, err)
342+
content, err := os.ReadFile(path)
343+
require.NoError(t, err)
344+
assert.Equal(t, "", string(content))
345+
})
346+
}
347+
290348
func TestExecutor_Logs(t *testing.T) {
291349
var b bytes.Buffer
292350
ex := makeTestExecutor(t)

runner/internal/runner/schemas/schemas.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ type ClusterInfo struct {
9595
JobIPs []string `json:"job_ips"`
9696
MasterJobIP string `json:"master_job_ip"`
9797
GPUSPerJob int `json:"gpus_per_job"`
98+
GPUSPerNode []int `json:"gpus_per_node"`
9899
}
99100

100101
type SSHKey struct {

src/dstack/_internal/cli/services/configurators/run.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
from dstack._internal.utils.interpolator import InterpolatorError, VariablesInterpolator
7474
from dstack._internal.utils.logging import get_logger
7575
from dstack._internal.utils.nested_list import NestedList, NestedListItem
76+
from dstack._internal.utils.nodes_interpolator import is_valid_groups_ip_ref
7677
from dstack._internal.utils.path import is_absolute_posix_path
7778
from dstack.api._public.runs import Run
7879

@@ -585,20 +586,28 @@ def register_commands_args(cls, parser: argparse.ArgumentParser):
585586
metavar="RUN_ARGS",
586587
)
587588

588-
def apply_commands_args(
589-
self,
590-
conf: ConfigurationWithCommandsParams,
591-
args: argparse.Namespace,
592-
):
593-
commands = conf.commands
589+
def _interpolate_commands(self, commands: list[str], args: argparse.Namespace) -> None:
594590
run_args = shlex.join(args.run_args)
595-
interpolator = VariablesInterpolator({"run": {"args": run_args}}, skip=["secrets"])
591+
interpolator = VariablesInterpolator(
592+
{"run": {"args": run_args}},
593+
skip={
594+
"secrets": VariablesInterpolator.validate_name,
595+
"groups": is_valid_groups_ip_ref,
596+
},
597+
)
596598
try:
597599
for i, command in enumerate(commands):
598600
commands[i] = interpolator.interpolate_or_error(command)
599601
except InterpolatorError as e:
600602
raise ConfigurationError(e.args[0])
601603

604+
def apply_commands_args(
605+
self,
606+
conf: ConfigurationWithCommandsParams,
607+
args: argparse.Namespace,
608+
):
609+
self._interpolate_commands(conf.commands, args)
610+
602611

603612
class TaskConfigurator(
604613
RunWithPortsConfiguratorMixin, RunWithCommandsConfiguratorMixin, BaseRunConfigurator
@@ -615,6 +624,9 @@ def apply_args(self, conf: TaskConfiguration, args: argparse.Namespace):
615624
super().apply_args(conf, args)
616625
self.apply_ports_args(conf, args)
617626
self.apply_commands_args(conf, args)
627+
if conf.groups is not None:
628+
for group in conf.groups:
629+
self._interpolate_commands(group.commands, args)
618630

619631

620632
class DevEnvironmentConfigurator(RunWithPortsConfiguratorMixin, BaseRunConfigurator):

src/dstack/_internal/cli/utils/run.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ def th(s: str) -> str:
122122
props.add_row(th("User"), run_plan.user)
123123
configuration_type = run_spec.configuration.type
124124
if run_spec.configuration.type == "task":
125-
configuration_type += f" (nodes={run_spec.configuration.nodes})"
125+
configuration_type += f" (nodes={run_spec.configuration.nodes_num})"
126126
props.add_row(th("Type"), configuration_type)
127127
props.add_row(th("Resources"), pretty_req)
128128
props.add_row(th("Spot policy"), spot_policy)

src/dstack/_internal/core/backends/slurm/compute.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,15 @@ def run_job(
138138
placement_group: Optional[PlacementGroup],
139139
requirements: Requirements,
140140
) -> JobProvisioningData:
141+
# run_job provisions a single dstack job → one Slurm node. Do not fall
142+
# back to jobs_per_replica (total across hetero groups).
141143
compute_provisioning_data = self._run_slurm_job(
142144
run=run,
143145
job=job,
144146
instance_offer=instance_offer,
145147
project_ssh_public_key=project_ssh_public_key,
146148
requirements=requirements,
149+
node_count=1,
147150
)
148151
return compute_provisioning_data.job_provisioning_datas[0]
149152

@@ -164,6 +167,7 @@ def run_jobs(
164167
instance_offer=instance_offer,
165168
project_ssh_public_key=project_ssh_public_key,
166169
requirements=requirements,
170+
node_count=len(job_configurations),
167171
)
168172

169173
def terminate_instance(
@@ -186,6 +190,7 @@ def _run_slurm_job(
186190
instance_offer: InstanceOfferWithAvailability,
187191
project_ssh_public_key: str,
188192
requirements: Requirements,
193+
node_count: int,
189194
) -> ComputeGroupProvisioningData:
190195
if job.job_spec.registry_auth is not None:
191196
self._skip_offer_cache.add(run, job, instance_offer)
@@ -209,7 +214,7 @@ def _run_slurm_job(
209214
assert run.run_spec.ssh_key_pub is not None
210215
authorized_keys = [project_ssh_public_key.strip(), run.run_spec.ssh_key_pub.strip()]
211216

212-
node_count = job.job_spec.jobs_per_replica
217+
# Slurm --nodes for this call (1 from run_job, len(batch) from run_jobs).
213218
resources_spec = requirements.resources
214219
requested_resources = get_requested_resources_from_resources_spec(resources_spec)
215220

src/dstack/_internal/core/compatibility/runs.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
IncludeExcludeDictType,
66
IncludeExcludeSetType,
77
)
8+
from dstack._internal.core.models.configurations import TaskConfiguration
89
from dstack._internal.core.models.runs import (
10+
DEFAULT_REPLICA_GROUP_NAME,
911
ApplyRunPlanInput,
1012
JobSpec,
1113
JobSubmission,
@@ -80,6 +82,14 @@ def get_run_spec_excludes(run_spec: RunSpec) -> IncludeExcludeDictType:
8082
profile_excludes = get_profile_excludes(run_spec.profile)
8183
for field in get_profile_excludes(run_spec.configuration):
8284
configuration_excludes[field] = True
85+
86+
if isinstance(run_spec.configuration, TaskConfiguration):
87+
if run_spec.configuration.groups is None:
88+
configuration_excludes["groups"] = True
89+
if run_spec.configuration.nodes is None:
90+
# Omit nodes when unset so old servers never see null (pre-hetero nodes was int=1).
91+
configuration_excludes["nodes"] = True
92+
8393
if configuration_excludes:
8494
spec_excludes["configuration"] = configuration_excludes
8595
if profile_excludes:
@@ -94,6 +104,12 @@ def get_job_spec_excludes(job_specs: list[JobSpec]) -> IncludeExcludeDictType:
94104
clients backward-compatibility with older servers.
95105
"""
96106
spec_excludes: IncludeExcludeDictType = {}
107+
if all(s.node_group_index == 0 for s in job_specs):
108+
spec_excludes["node_group_index"] = True
109+
if all(s.node_group_name == DEFAULT_REPLICA_GROUP_NAME for s in job_specs):
110+
spec_excludes["node_group_name"] = True
111+
if all(s.node_group_job_index == 0 for s in job_specs):
112+
spec_excludes["node_group_job_index"] = True
97113
return spec_excludes
98114

99115

0 commit comments

Comments
 (0)