Skip to content
45 changes: 33 additions & 12 deletions runner/internal/runner/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,15 @@ func (ex *RunExecutor) execJob(ctx context.Context, jobLogFile io.Writer) error
nodeRank := ex.jobSpec.JobNum
nodesNum := ex.jobSpec.JobsPerReplica
gpusPerNodeNum := ex.clusterInfo.GPUSPerJob
gpusNum := nodesNum * gpusPerNodeNum
gpusNum := 0
if len(ex.clusterInfo.GPUSPerNode) > 0 {
for _, n := range ex.clusterInfo.GPUSPerNode {
gpusNum += n
}
} else {
// Old servers omit gpus_per_node; fall back to homogeneous math.
gpusNum = nodesNum * gpusPerNodeNum
}

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

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

if err := writeMpiHostfile(ctx, ex.clusterInfo.JobIPs, gpusPerNodeNum, mpiHostfilePath); err != nil {
slots := ex.clusterInfo.GPUSPerNode
if len(slots) == 0 {
// Old servers omit gpus_per_node; fall back to homogeneous per-node GPU count.
slots = make([]int, len(ex.clusterInfo.JobIPs))
for i := range slots {
slots[i] = gpusPerNodeNum
}
}
if err := writeMpiHostfile(ctx, ex.clusterInfo.JobIPs, slots, mpiHostfilePath); err != nil {
return fmt.Errorf("write MPI hostfile: %w", err)
}

Expand Down Expand Up @@ -759,7 +775,7 @@ func prepareUserSshDir(user *linuxuser.User) (string, error) {
return sshDir, nil
}

func writeMpiHostfile(ctx context.Context, ips []string, gpusPerNode int, path string) error {
func writeMpiHostfile(ctx context.Context, ips []string, slots []int, path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create MPI hostfile directory: %w", err)
}
Expand All @@ -775,16 +791,21 @@ func writeMpiHostfile(ctx context.Context, ips []string, gpusPerNode int, path s
}
}
if len(nonEmptyIps) == len(ips) {
var template string
if gpusPerNode == 0 {
// CPU node: the number of slots defaults to the number of processor cores on that host
// See: https://docs.open-mpi.org/en/main/launching-apps/scheduling.html#calculating-the-number-of-slots
template = "%s\n"
} else {
template = fmt.Sprintf("%%s slots=%d\n", gpusPerNode)
if len(slots) != len(ips) {
return fmt.Errorf(
"gpus_per_node length %d != job_ips length %d",
len(slots), len(ips),
)
}
for _, ip := range nonEmptyIps {
if _, err = fmt.Fprintf(file, template, ip); err != nil {
for i, ip := range nonEmptyIps {
if slots[i] == 0 {
// CPU node: the number of slots defaults to the number of processor cores on that host
// See: https://docs.open-mpi.org/en/main/launching-apps/scheduling.html#calculating-the-number-of-slots
_, err = fmt.Fprintf(file, "%s\n", ip)
} else {
_, err = fmt.Fprintf(file, "%s slots=%d\n", ip, slots[i])
}
if err != nil {
return fmt.Errorf("write MPI hostfile line: %w", err)
}
}
Expand Down
58 changes: 58 additions & 0 deletions runner/internal/runner/executor/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,64 @@ func TestWriteDstackProfile(t *testing.T) {
}
}

func TestWriteMpiHostfile(t *testing.T) {
tmp := t.TempDir()

t.Run("heterogeneous_slots", func(t *testing.T) {
path := filepath.Join(tmp, "hostfile_hetero")
err := writeMpiHostfile(
t.Context(),
[]string{"10.0.0.1", "10.0.0.2", "10.0.0.3"},
[]int{8, 4, 0},
path,
)
require.NoError(t, err)
content, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, "10.0.0.1 slots=8\n10.0.0.2 slots=4\n10.0.0.3\n", string(content))
})

t.Run("homogeneous_slots", func(t *testing.T) {
path := filepath.Join(tmp, "hostfile_homo")
err := writeMpiHostfile(
t.Context(),
[]string{"10.0.0.1", "10.0.0.2"},
[]int{4, 4},
path,
)
require.NoError(t, err)
content, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, "10.0.0.1 slots=4\n10.0.0.2 slots=4\n", string(content))
})

t.Run("slots_length_mismatch", func(t *testing.T) {
path := filepath.Join(tmp, "hostfile_mismatch")
err := writeMpiHostfile(
t.Context(),
[]string{"10.0.0.1", "10.0.0.2"},
[]int{8},
path,
)
require.Error(t, err)
assert.Contains(t, err.Error(), "gpus_per_node length 1 != job_ips length 2")
})

t.Run("empty_ip_writes_empty_hostfile", func(t *testing.T) {
path := filepath.Join(tmp, "hostfile_empty_ip")
err := writeMpiHostfile(
t.Context(),
[]string{"10.0.0.1", ""},
[]int{8, 4},
path,
)
require.NoError(t, err)
content, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, "", string(content))
})
}

func TestExecutor_Logs(t *testing.T) {
var b bytes.Buffer
ex := makeTestExecutor(t)
Expand Down
1 change: 1 addition & 0 deletions runner/internal/runner/schemas/schemas.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ type ClusterInfo struct {
JobIPs []string `json:"job_ips"`
MasterJobIP string `json:"master_job_ip"`
GPUSPerJob int `json:"gpus_per_job"`
GPUSPerNode []int `json:"gpus_per_node"`
}

type SSHKey struct {
Expand Down
26 changes: 19 additions & 7 deletions src/dstack/_internal/cli/services/configurators/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
from dstack._internal.utils.interpolator import InterpolatorError, VariablesInterpolator
from dstack._internal.utils.logging import get_logger
from dstack._internal.utils.nested_list import NestedList, NestedListItem
from dstack._internal.utils.nodes_interpolator import is_valid_groups_ip_ref
from dstack._internal.utils.path import is_absolute_posix_path
from dstack.api._public.runs import Run

Expand Down Expand Up @@ -585,20 +586,28 @@ def register_commands_args(cls, parser: argparse.ArgumentParser):
metavar="RUN_ARGS",
)

def apply_commands_args(
self,
conf: ConfigurationWithCommandsParams,
args: argparse.Namespace,
):
commands = conf.commands
def _interpolate_commands(self, commands: list[str], args: argparse.Namespace) -> None:
run_args = shlex.join(args.run_args)
interpolator = VariablesInterpolator({"run": {"args": run_args}}, skip=["secrets"])
interpolator = VariablesInterpolator(
{"run": {"args": run_args}},
skip={
"secrets": VariablesInterpolator.validate_name,
"groups": is_valid_groups_ip_ref,
},
)
try:
for i, command in enumerate(commands):
commands[i] = interpolator.interpolate_or_error(command)
except InterpolatorError as e:
raise ConfigurationError(e.args[0])

def apply_commands_args(
self,
conf: ConfigurationWithCommandsParams,
args: argparse.Namespace,
):
self._interpolate_commands(conf.commands, args)


class TaskConfigurator(
RunWithPortsConfiguratorMixin, RunWithCommandsConfiguratorMixin, BaseRunConfigurator
Expand All @@ -615,6 +624,9 @@ def apply_args(self, conf: TaskConfiguration, args: argparse.Namespace):
super().apply_args(conf, args)
self.apply_ports_args(conf, args)
self.apply_commands_args(conf, args)
if conf.groups is not None:
for group in conf.groups:
self._interpolate_commands(group.commands, args)


class DevEnvironmentConfigurator(RunWithPortsConfiguratorMixin, BaseRunConfigurator):
Expand Down
2 changes: 1 addition & 1 deletion src/dstack/_internal/cli/utils/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ def th(s: str) -> str:
props.add_row(th("User"), run_plan.user)
configuration_type = run_spec.configuration.type
if run_spec.configuration.type == "task":
configuration_type += f" (nodes={run_spec.configuration.nodes})"
configuration_type += f" (nodes={run_spec.configuration.nodes_num})"
props.add_row(th("Type"), configuration_type)
props.add_row(th("Resources"), pretty_req)
props.add_row(th("Spot policy"), spot_policy)
Expand Down
7 changes: 6 additions & 1 deletion src/dstack/_internal/core/backends/slurm/compute.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,15 @@ def run_job(
placement_group: Optional[PlacementGroup],
requirements: Requirements,
) -> JobProvisioningData:
# run_job provisions a single dstack job → one Slurm node. Do not fall
# back to jobs_per_replica (total across hetero groups).
compute_provisioning_data = self._run_slurm_job(
run=run,
job=job,
instance_offer=instance_offer,
project_ssh_public_key=project_ssh_public_key,
requirements=requirements,
node_count=1,
)
return compute_provisioning_data.job_provisioning_datas[0]

Expand All @@ -164,6 +167,7 @@ def run_jobs(
instance_offer=instance_offer,
project_ssh_public_key=project_ssh_public_key,
requirements=requirements,
node_count=len(job_configurations),
)

def terminate_instance(
Expand All @@ -186,6 +190,7 @@ def _run_slurm_job(
instance_offer: InstanceOfferWithAvailability,
project_ssh_public_key: str,
requirements: Requirements,
node_count: int,
) -> ComputeGroupProvisioningData:
if job.job_spec.registry_auth is not None:
self._skip_offer_cache.add(run, job, instance_offer)
Expand All @@ -209,7 +214,7 @@ def _run_slurm_job(
assert run.run_spec.ssh_key_pub is not None
authorized_keys = [project_ssh_public_key.strip(), run.run_spec.ssh_key_pub.strip()]

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

Expand Down
16 changes: 16 additions & 0 deletions src/dstack/_internal/core/compatibility/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
IncludeExcludeDictType,
IncludeExcludeSetType,
)
from dstack._internal.core.models.configurations import TaskConfiguration
from dstack._internal.core.models.runs import (
DEFAULT_REPLICA_GROUP_NAME,
ApplyRunPlanInput,
JobSpec,
JobSubmission,
Expand Down Expand Up @@ -80,6 +82,14 @@ def get_run_spec_excludes(run_spec: RunSpec) -> IncludeExcludeDictType:
profile_excludes = get_profile_excludes(run_spec.profile)
for field in get_profile_excludes(run_spec.configuration):
configuration_excludes[field] = True

if isinstance(run_spec.configuration, TaskConfiguration):
if run_spec.configuration.groups is None:
configuration_excludes["groups"] = True
if run_spec.configuration.nodes is None:
# Omit nodes when unset so old servers never see null (pre-hetero nodes was int=1).
configuration_excludes["nodes"] = True

if configuration_excludes:
spec_excludes["configuration"] = configuration_excludes
if profile_excludes:
Expand All @@ -94,6 +104,12 @@ def get_job_spec_excludes(job_specs: list[JobSpec]) -> IncludeExcludeDictType:
clients backward-compatibility with older servers.
"""
spec_excludes: IncludeExcludeDictType = {}
if all(s.node_group_index == 0 for s in job_specs):
spec_excludes["node_group_index"] = True
if all(s.node_group_name == DEFAULT_REPLICA_GROUP_NAME for s in job_specs):
spec_excludes["node_group_name"] = True
if all(s.node_group_job_index == 0 for s in job_specs):
spec_excludes["node_group_job_index"] = True
return spec_excludes


Expand Down
Loading
Loading