Skip to content

Commit 5969f4d

Browse files
authored
Change CPU arch default value/validation (#4136)
* In the fleet spec, None/missing value now means "any arch", not "must be inferred and set" * In the run spec, None/missing value now means "any arch supported by the image", not "must be inferred and set" * The arch inferred from the image registry is set per-JobSpec (not RunSpec) via JobConfigurator. As a consequence, resources.cpu.arch must now be set in addition to commands and user to avoid contacting the image registry (e.g., when a local image not pushed to the registry is used) * As the arch is no longer inferred from GPU models, the image/cpu.arch field combination is only validated when cpu.arch is explicitly set to arm. A configuration that requests an NVIDIA ARM chip via gpu but sets neither image nor cpu.arch is valid now * Relaxing resources.cpu.arch from a specific value to null is treated as a compatible change, so re-applying an unchanged configuration after a server upgrade neither redeploys service replicas nor fails the in-place update * image: "scratch" now has a special meaning -- it forces the server to use some dummy defaults instead of contacting the image registry to extract optional field values from the image config. Used by `dstack offer` Fixes: #4056
1 parent bf182dd commit 5969f4d

21 files changed

Lines changed: 563 additions & 117 deletions

File tree

mkdocs/docs/concepts/dev-environments.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,8 @@ resources:
159159
</div>
160160

161161
The `cpu` property lets you set the architecture (`x86` or `arm`) and core count — e.g., `x86:16` (16 x86 cores), `arm:8..` (at least 8 ARM cores).
162-
If not set, `dstack` infers it from the GPU or defaults to `x86`.
162+
If the architecture is not set, `dstack` allows any architecture supported by the `image`, or `x86` if no `image` is set.
163+
Since the default `dstack` image only supports `x86`, requesting `arm` requires setting `image` and is not compatible with `docker: true`.
163164

164165
The `gpu` property lets you specify vendor, model, memory, and count — e.g., `nvidia` (one NVIDIA GPU), `A100` (one A100), `A10G,A100` (either), `A100:80GB` (one 80GB A100), `A100:2` (two A100), `24GB..40GB:2` (two GPUs with 24–40GB), `A100:40GB:2` (two 40GB A100s).
165166

mkdocs/docs/concepts/services.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -879,7 +879,8 @@ resources:
879879
</div>
880880

881881
The `cpu` property lets you set the architecture (`x86` or `arm`) and core count — e.g., `x86:16` (16 x86 cores), `arm:8..` (at least 8 ARM cores).
882-
If not set, `dstack` infers it from the GPU or defaults to `x86`.
882+
If the architecture is not set, `dstack` allows any architecture supported by the `image`, or `x86` if no `image` is set.
883+
Since the default `dstack` image only supports `x86`, requesting `arm` requires setting `image` and is not compatible with `docker: true`.
883884

884885
The `gpu` property lets you specify vendor, model, memory, and count — e.g., `nvidia` (one NVIDIA GPU), `A100` (one A100), `A10G,A100` (either), `A100:80GB` (one 80GB A100), `A100:2` (two A100), `24GB..40GB:2` (two GPUs with 24–40GB), `A100:40GB:2` (two 40GB A100s).
885886

mkdocs/docs/concepts/tasks.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,8 @@ resources:
220220
</div>
221221

222222
The `cpu` property lets you set the architecture (`x86` or `arm`) and core count — e.g., `x86:16` (16 x86 cores), `arm:8..` (at least 8 ARM cores).
223-
If not set, `dstack` infers it from the GPU or defaults to `x86`.
223+
If the architecture is not set, `dstack` allows any architecture supported by the `image`, or `x86` if no `image` is set.
224+
Since the default `dstack` image only supports `x86`, requesting `arm` requires setting `image` and is not compatible with `docker: true`.
224225

225226
The `gpu` property lets you specify vendor, model, memory, and count — e.g., `nvidia` (one NVIDIA GPU), `A100` (one A100), `A10G,A100` (either), `A100:80GB` (one 80GB A100), `A100:2` (two A100), `24GB..40GB:2` (two GPUs with 24–40GB), `A100:40GB:2` (two 40GB A100s).
226227

src/dstack/_internal/cli/commands/offer.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,13 @@ def _process_group_by_args(group_by_args: List[str]) -> List[str]:
154154

155155

156156
def _get_run_spec(args: argparse.Namespace) -> RunSpec:
157-
# Set image and user so that the server (a) does not default gpu.vendor
158-
# to nvidia — `dstack offer` should show all vendors, and (b) does not
159-
# attempt to pull image config from the Docker registry.
157+
# image="scratch" is a special value that forces the server to use some dummy default
158+
# values for optional fields that otherwise would be extracted from the image config
159+
# pulled from the image registry (commands/entrypoint, user, resources.cpu.arch).
160+
# Additionally, it disables the server code path that sets gpu.vendor to nvidia when
161+
# the image is not set.
162+
# We still set `commands` and `user` for compatibility with older servers that don't treat
163+
# "scratch" as a special "don't request the registry" value.
160164
conf = TaskConfiguration(
161165
resources=ResourcesSpec.unconstrained(),
162166
commands=[":"],

src/dstack/_internal/server/background/pipeline_tasks/runs/active.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
get_job_specs_from_run_spec,
3232
get_jobs_from_run_spec,
3333
group_jobs_by_replica_latest,
34+
job_spec_updatable_in_place,
3435
)
3536
from dstack._internal.server.services.runs import (
3637
create_job_model_for_new_submission,
@@ -524,7 +525,7 @@ async def _build_deployment_update_map(
524525
can_update_all_jobs = True
525526
for old_job_model, new_job_spec in zip(job_models, new_job_specs):
526527
old_job_spec = get_job_spec(old_job_model)
527-
if new_job_spec != old_job_spec:
528+
if not job_spec_updatable_in_place(old_job_spec, new_job_spec):
528529
can_update_all_jobs = False
529530
break
530531
if can_update_all_jobs:

src/dstack/_internal/server/services/docker.py

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import contextlib
12
import re
23
from dataclasses import dataclass
34
from typing import List, Optional
45

6+
import gpuhunt
57
import requests
68
from dxf import DXF
79
from dxf.exceptions import DXFError
@@ -18,7 +20,6 @@
1820
parse_image_name,
1921
)
2022

21-
DEFAULT_PLATFORM = "linux/amd64"
2223
MAX_CONFIG_OBJECT_SIZE = 2**22 # 4 MiB
2324
REGISTRY_REQUEST_TIMEOUT = 20
2425

@@ -50,6 +51,8 @@ def normalize_user(cls, v: Optional[str]) -> Optional[str]:
5051

5152

5253
class ImageConfigObject(CoreModel):
54+
architecture: str
55+
os: str
5356
config: ImageConfig = ImageConfig()
5457

5558
@field_validator("config", mode="before")
@@ -66,7 +69,9 @@ class ImageManifest(CoreModel):
6669
config: ImageManifestConfigField
6770

6871

69-
def get_image_config(image_name: str, registry_auth: Optional[RegistryAuth]) -> ImageConfigObject:
72+
def get_image_config_and_cpu_architectures(
73+
image_name: str, registry_auth: Optional[RegistryAuth]
74+
) -> tuple[ImageConfigObject, set[gpuhunt.CPUArchitecture]]:
7075
image = parse_image_name(image_name)
7176

7277
registry = image.registry
@@ -81,21 +86,54 @@ def get_image_config(image_name: str, registry_auth: Optional[RegistryAuth]) ->
8186
)
8287

8388
with registry_client:
89+
cpu_architectures: Optional[set[gpuhunt.CPUArchitecture]] = None
8490
try:
85-
manifest_resp = registry_client.get_manifest(
86-
alias=image.digest or image.tag, platform=DEFAULT_PLATFORM
87-
)
88-
assert isinstance(manifest_resp, str), (
89-
"get_manifest() returns the manifest JSON when `platform` is given"
90-
)
91-
manifest = validate_json_extra_ignore(ImageManifest, manifest_resp)
91+
# FIXME: get_manifest() makes N+1 requests when platform is not specified and alias
92+
# points to an image index, where N is a number of images in the index,
93+
# e.g., debian has 8 os/architecture[/variant] combinations
94+
manifest_resp = registry_client.get_manifest(alias=image.digest or image.tag)
95+
if isinstance(manifest_resp, dict):
96+
# Image index (OCI) aka Manifest list (Docker) -- multi os/arch higher-level object
97+
manifests: dict[gpuhunt.CPUArchitecture, ImageManifest] = {}
98+
for platform, manifest_raw in manifest_resp.items():
99+
# os/architecture[/variant]
100+
os_name, architecture, *_ = platform.split("/")
101+
if not _os_supported(os_name):
102+
continue
103+
cpu_arch = _cpu_arch_from_string(architecture)
104+
if cpu_arch is not None:
105+
manifests[cpu_arch] = validate_json_extra_ignore(
106+
ImageManifest, manifest_raw
107+
)
108+
# ImageConfigs (User/Cmd/Entrypoint) may be different for different images
109+
# within the same index; we assume that it's not the case but at least pick
110+
# the manifest deterministically
111+
for cpu_arch in [gpuhunt.CPUArchitecture.X86, gpuhunt.CPUArchitecture.ARM]:
112+
with contextlib.suppress(KeyError):
113+
manifest = manifests[cpu_arch]
114+
break
115+
else:
116+
raise _no_supported_platforms_error(image_name)
117+
cpu_architectures = set(manifests)
118+
else:
119+
# Image manifest -- one specific os/arch combination
120+
manifest = validate_json_extra_ignore(ImageManifest, manifest_resp)
121+
92122
config_stream = registry_client.pull_blob(manifest.config.digest)
93123
config_resp = join_byte_stream_checked(config_stream, MAX_CONFIG_OBJECT_SIZE) # type: ignore[arg-type]
94124
if config_resp is None:
95125
raise DockerRegistryError(
96126
f"Image config object exceeds the size limit of {MAX_CONFIG_OBJECT_SIZE} bytes"
97127
)
98-
return validate_json_extra_ignore(ImageConfigObject, config_resp)
128+
image_config = validate_json_extra_ignore(ImageConfigObject, config_resp)
129+
130+
if cpu_architectures is None:
131+
cpu_arch = _cpu_arch_from_string(image_config.architecture)
132+
if not _os_supported(image_config.os) or cpu_arch is None:
133+
raise _no_supported_platforms_error(image_name)
134+
cpu_architectures = {cpu_arch}
135+
136+
return image_config, cpu_architectures
99137

100138
except (DXFError, requests.RequestException, ValidationError) as e:
101139
raise DockerRegistryError(e)
@@ -130,3 +168,19 @@ def is_valid_docker_volume_target(path: str) -> bool:
130168
if path.endswith("/") and path != "/":
131169
return False
132170
return DOCKER_TARGET_PATH_PATTERN.match(path) is not None
171+
172+
173+
def _cpu_arch_from_string(architecture: str) -> Optional[gpuhunt.CPUArchitecture]:
174+
if architecture == "amd64":
175+
return gpuhunt.CPUArchitecture.X86
176+
if architecture == "arm64":
177+
return gpuhunt.CPUArchitecture.ARM
178+
return None
179+
180+
181+
def _os_supported(os_name: str) -> bool:
182+
return os_name == "linux"
183+
184+
185+
def _no_supported_platforms_error(image_name: str) -> DockerRegistryError:
186+
return DockerRegistryError(f"No supported OS/architectures found: {image_name!r}")

src/dstack/_internal/server/services/fleets.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,7 @@
8787
list_user_project_models,
8888
project_model_to_project,
8989
)
90-
from dstack._internal.server.services.resources import (
91-
set_default_cpu_spec_arch,
92-
set_default_gpu_spec,
93-
)
90+
from dstack._internal.server.services.resources import set_default_gpu_spec
9491
from dstack._internal.utils import random_names
9592
from dstack._internal.utils import ssh as ssh_utils
9693
from dstack._internal.utils.common import (
@@ -1429,8 +1426,7 @@ def _validate_fleet_configuration_subtype_specific_fields(conf: FleetConfigurati
14291426
def _set_fleet_spec_defaults(spec: FleetSpec):
14301427
resources_spec = spec.configuration.resources
14311428
if resources_spec is not None:
1432-
gpu_spec = set_default_gpu_spec(resources_spec)
1433-
set_default_cpu_spec_arch(resources_spec.cpu, gpu_spec)
1429+
set_default_gpu_spec(resources_spec)
14341430

14351431

14361432
def _validate_all_ssh_params_specified(ssh_config: SSHParams):

src/dstack/_internal/server/services/jobs/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,24 @@ def get_job_spec(job_model: JobModel) -> JobSpec:
298298
return validate_json_extra_ignore(JobSpec, job_model.job_spec_data)
299299

300300

301+
def job_spec_updatable_in_place(old_job_spec: JobSpec, new_job_spec: JobSpec) -> bool:
302+
"""
303+
Check if a job running with `old_job_spec` already satisfies `new_job_spec`, that is,
304+
the job can be marked as up-to-date without redeployment.
305+
"""
306+
if old_job_spec == new_job_spec:
307+
return True
308+
# Older servers always resolved `cpu.arch` to a specific value. Now an unset `arch` means
309+
# "any architecture supported by the image", so a specific value -> None change only widens
310+
# the requirements -- an already provisioned job still satisfies them. Without this check,
311+
# re-applying an unchanged configuration after a server upgrade would trigger redeployment.
312+
if new_job_spec.requirements.resources.cpu.arch is not None:
313+
return False
314+
new_job_spec = new_job_spec.model_copy(deep=True)
315+
new_job_spec.requirements.resources.cpu.arch = old_job_spec.requirements.resources.cpu.arch
316+
return old_job_spec == new_job_spec
317+
318+
301319
def delay_job_instance_termination(job_model: JobModel):
302320
job_model.remove_at = common.get_current_datetime() + timedelta(seconds=15)
303321

src/dstack/_internal/server/services/jobs/configurators/base.py

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from pathlib import PurePosixPath
77
from typing import Dict, List, Optional
88

9+
import gpuhunt
910
from cachetools import TTLCache, cached
1011

1112
from dstack._internal import settings
@@ -55,7 +56,7 @@
5556
from dstack._internal.server.services.docker import (
5657
ImageConfig,
5758
apply_server_docker_defaults,
58-
get_image_config,
59+
get_image_config_and_cpu_architectures,
5960
)
6061
from dstack._internal.utils import crypto
6162
from dstack._internal.utils.common import run_async
@@ -69,6 +70,19 @@
6970
DSTACK_DIR = "/dstack"
7071
DSTACK_PROFILE_PATH = f"{DSTACK_DIR}/profile"
7172

73+
# A non-existent image name used to signal that the image registry must never be requested
74+
# and some dummy defaults should be used instead.
75+
# As a job with such an image cannot be started, this special value only makes sense
76+
# when used for offer collection (via `/runs/get_plan` with `for_offers_only`), not
77+
# regular run planning/submission.
78+
# Specifying a single "magic" value is still hacky but better than requiring clients to set
79+
# an ever-growing list of optional configuration fields such as `commands`/`entrypoint`,
80+
# `user`, `resources.cpu.arch`.
81+
# In addition, it has a special effect on `resources.cpu.arch` -- unlike unset image,
82+
# which defaults the arch to x86-only (as the default dstack image doesn't support ARM),
83+
# this dummy image leaves the arch unset.
84+
DUMMY_IMAGE_NAME = "scratch"
85+
7286

7387
def get_default_python_verison() -> str:
7488
version_info = sys.version_info
@@ -98,6 +112,7 @@ class JobConfigurator(ABC):
98112
TYPE: RunConfigurationType
99113

100114
_image_config: Optional[ImageConfig] = None
115+
_image_cpu_architectures: Optional[set[gpuhunt.CPUArchitecture]] = None
101116
# JobSSHKey should be shared for all jobs in a replica for inter-node communication.
102117
_job_ssh_key: Optional[JobSSHKey] = None
103118

@@ -139,8 +154,17 @@ def _ports(self) -> List[PortMapping]:
139154
pass
140155

141156
async def _get_image_config(self) -> ImageConfig:
157+
image_config, _ = await self._get_image_config_and_cpu_architectures()
158+
return image_config
159+
160+
async def _get_image_config_and_cpu_architectures(
161+
self,
162+
) -> tuple[ImageConfig, set[gpuhunt.CPUArchitecture]]:
142163
if self._image_config is not None:
143-
return self._image_config
164+
assert self._image_cpu_architectures is not None
165+
return self._image_config, self._image_cpu_architectures
166+
image_name = self._image_name()
167+
assert image_name != DUMMY_IMAGE_NAME
144168
interpolate = VariablesInterpolator({"secrets": self.secrets}).interpolate_or_error
145169
registry_auth = self.run_spec.configuration.registry_auth
146170
if registry_auth is not None:
@@ -151,14 +175,15 @@ async def _get_image_config(self) -> ImageConfig:
151175
)
152176
except InterpolatorError as e:
153177
raise ServerClientError(e.args[0])
154-
image_name, registry_auth = apply_server_docker_defaults(self._image_name(), registry_auth)
155-
image_config = await run_async(
156-
_get_image_config,
178+
image_name, registry_auth = apply_server_docker_defaults(image_name, registry_auth)
179+
image_config, cpu_architectures = await run_async(
180+
_get_image_config_and_cpu_architectures,
157181
image_name,
158182
registry_auth,
159183
)
160184
self._image_config = image_config
161-
return image_config
185+
self._image_cpu_architectures = cpu_architectures
186+
return image_config, cpu_architectures
162187

163188
async def _get_job_spec(
164189
self,
@@ -184,7 +209,7 @@ async def _get_job_spec(
184209
stop_duration=self._stop_duration(),
185210
utilization_policy=self._utilization_policy(),
186211
registry_auth=self._registry_auth(),
187-
requirements=self._requirements(jobs_per_replica),
212+
requirements=await self._requirements(jobs_per_replica),
188213
retry=self._retry(),
189214
working_dir=self._working_dir(),
190215
volumes=self._volumes(job_num),
@@ -219,6 +244,9 @@ async def _commands(self) -> List[str]:
219244
entrypoint = [self._shell(), "-i", "-c"]
220245
dstack_image_commands = self._dstack_image_commands()
221246
commands = [_join_shell_commands(dstack_image_commands + shell_commands)]
247+
elif self._image_name() == DUMMY_IMAGE_NAME:
248+
entrypoint = []
249+
commands = [":"]
222250
else: # custom docker image without commands
223251
image_config = await self._get_image_config()
224252
entrypoint = image_config.entrypoint or []
@@ -299,6 +327,8 @@ def _image_name(self) -> str:
299327
async def _user(self) -> Optional[UnixUser]:
300328
user = self.run_spec.configuration.user
301329
if user is None and self.run_spec.configuration.image is not None:
330+
if self.run_spec.configuration.image == DUMMY_IMAGE_NAME:
331+
return None
302332
image_config = await self._get_image_config()
303333
user = image_config.user
304334
if user is None:
@@ -335,13 +365,29 @@ def _utilization_policy(self) -> Optional[UtilizationPolicy]:
335365
def _registry_auth(self) -> Optional[RegistryAuth]:
336366
return self.run_spec.configuration.registry_auth
337367

338-
def _requirements(self, jobs_per_replica: int) -> Requirements:
368+
async def _requirements(self, jobs_per_replica: int) -> Requirements:
339369
resources = self.run_spec.configuration.resources
370+
image = self.run_spec.configuration.image
340371
if self.run_spec.configuration.type == "service":
341372
for group in self.run_spec.configuration.replica_groups:
342373
if group.name == self.replica_group_name:
343374
resources = group.resources
375+
if group.image is not None:
376+
image = group.image
344377
break
378+
resources = resources.model_copy(deep=True)
379+
if resources.cpu.arch is None and image != DUMMY_IMAGE_NAME:
380+
if image is None:
381+
# dstackai/base or dstackai/dind image, both don't support ARM
382+
resources.cpu.arch = gpuhunt.CPUArchitecture.X86
383+
else:
384+
_, cpu_architectures = await self._get_image_config_and_cpu_architectures()
385+
if len(cpu_architectures) == 1:
386+
resources.cpu.arch = next(iter(cpu_architectures))
387+
# len(cpu_architectures) > 1 => multi-arch image, keep CPUSpec.arch unset.
388+
# In the requirements, unset arch means "any architecture supported by the
389+
# image", unlike the run configuration, where unset arch means "not specified,
390+
# resolve it here"
345391
spot_policy = self._spot_policy()
346392
return Requirements(
347393
resources=resources,
@@ -514,10 +560,15 @@ def _join_shell_commands(commands: List[str]) -> str:
514560
cache=TTLCache(maxsize=2048, ttl=80),
515561
lock=threading.Lock(),
516562
)
517-
def _get_image_config(image: str, registry_auth: Optional[RegistryAuth]) -> ImageConfig:
563+
def _get_image_config_and_cpu_architectures(
564+
image: str, registry_auth: Optional[RegistryAuth]
565+
) -> tuple[ImageConfig, set[gpuhunt.CPUArchitecture]]:
518566
try:
519-
return get_image_config(image, registry_auth).config
567+
image_config, cpu_architectures = get_image_config_and_cpu_architectures(
568+
image, registry_auth
569+
)
520570
except DockerRegistryError as e:
521571
raise ServerClientError(
522572
f"Error pulling configuration for image {image!r} from the docker registry: {e}"
523573
)
574+
return image_config.config, cpu_architectures

0 commit comments

Comments
 (0)