Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/src/pages/Offers/List/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const getRequestParams = ({
env: {},
resources: {
// cpu/memory/disk should match ResourcesSpec.unconstrained() used by `dstack offer` CLI command
cpu: { min: 1 },
cpu: { count: { min: 1 } },
memory: { min: 0.0 },
disk: null,
gpu: {
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/types/gpu.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,13 @@ declare interface IGPUSpecRequest {
compute_capability?: any[];
}

declare interface ICPUSpecRequest {
arch?: 'x86' | 'arm' | null;
count?: TRange | number | string;
}

declare interface IResourcesSpecRequest {
cpu?: TRange | number | string;
cpu?: ICPUSpecRequest | number | string;
memory?: TRange | number | string;
shm_size?: number | string;
gpu?: IGPUSpecRequest | number | string;
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/types/run.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ declare type TRange = { min?: number; max?: number };

declare type TResourceRequest = {
gpu?: TGPUResources | string | number;
cpu?: string | number | TRange;
cpu?: string | number | ICPUSpecRequest;
memory?: string | number | TRange;
shm_size?: string | number;
disk?:
Expand Down
4 changes: 2 additions & 2 deletions src/dstack/_internal/cli/models/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from dstack._internal.core.models.common import CoreModel
from dstack._internal.core.models.configurations import ServiceConfiguration
from dstack._internal.core.models.profiles import ProfileParams
from dstack._internal.core.models.resources import CPUSpec, ResourcesSpec
from dstack._internal.core.models.resources import ResourcesSpec


class PresetBenchmarkWorkload(CoreModel):
Expand Down Expand Up @@ -159,7 +159,7 @@ class PresetListOutput(CoreModel):


def _validate_exact_resources(resources: ResourcesSpec) -> None:
cpu = CPUSpec.model_validate(resources.cpu)
cpu = resources.cpu
if not _is_exact(cpu.count) or not _is_exact(resources.memory):
raise ValueError("preset validation resources must be exact")
if resources.disk is None or not _is_exact(resources.disk.size):
Expand Down
4 changes: 1 addition & 3 deletions src/dstack/_internal/cli/services/configurators/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@
from dstack._internal.core.models.repos import RepoHeadWithCreds
from dstack._internal.core.models.repos.base import Repo
from dstack._internal.core.models.repos.remote import RemoteRepo, RemoteRepoCreds
from dstack._internal.core.models.resources import CPUSpec
from dstack._internal.core.models.runs import JobStatus, JobSubmission, RunPlan, RunSpec, RunStatus
from dstack._internal.core.services.diff import diff_models
from dstack._internal.core.services.repos import get_repo_creds_and_default_branch
Expand Down Expand Up @@ -533,8 +532,7 @@ def validate_cpu_arch_and_image(self, conf: RunConfigurationT) -> None:
"""
Infers `resources.cpu.arch` if not set, requires `image` if the architecture is ARM.
"""
# TODO: Remove in 0.20. Use conf.resources.cpu directly
cpu_spec = CPUSpec.model_validate(conf.resources.cpu)
cpu_spec = conf.resources.cpu
arch = cpu_spec.arch
if arch is None:
gpu_spec = conf.resources.gpu
Expand Down
5 changes: 2 additions & 3 deletions src/dstack/_internal/core/backends/base/offers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
InstanceType,
Resources,
)
from dstack._internal.core.models.resources import DEFAULT_DISK, CPUSpec, GPUSpec, Memory, Range
from dstack._internal.core.models.resources import DEFAULT_DISK, GPUSpec, Memory, Range
from dstack._internal.core.models.runs import Job, Requirements, Run
from dstack._internal.utils.common import get_or_error

Expand Down Expand Up @@ -170,8 +170,7 @@ def requirements_to_query_filter(req: Optional[Requirements]) -> gpuhunt.QueryFi

res = req.resources
if res.cpu:
# TODO: Remove in 0.20. Use res.cpu directly
cpu = CPUSpec.model_validate(res.cpu)
cpu = res.cpu
q.cpu_arch = cpu.arch
q.min_cpu = cpu.count.min
q.max_cpu = cpu.count.max
Expand Down
5 changes: 1 addition & 4 deletions src/dstack/_internal/core/backends/kubernetes/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
InstanceType,
Resources,
)
from dstack._internal.core.models.resources import CPUSpec, Memory, ResourcesSpec
from dstack._internal.core.models.resources import Memory, ResourcesSpec
from dstack._internal.utils import docker as docker_utils
from dstack._internal.utils.common import get_or_error
from dstack._internal.utils.logging import get_logger
Expand Down Expand Up @@ -179,7 +179,6 @@ class ResourceRequests(ResourceRequestsLimits):

@classmethod
def from_resources_spec(cls, spec: ResourcesSpec) -> Self:
assert isinstance(spec.cpu, CPUSpec)
cpu = spec.cpu.count.min or 0
memory_mib: int = 0
if spec.memory.min is not None:
Expand Down Expand Up @@ -223,7 +222,6 @@ def from_kubernetes_map(cls, map_: Mapping[str, str]) -> Self:
class ResourceLimits(ResourceRequestsLimits):
@classmethod
def from_resources_spec(cls, spec: ResourcesSpec) -> Self:
assert isinstance(spec.cpu, CPUSpec)
cpu = spec.cpu.count.max
memory_mib: Optional[int] = None
if spec.memory.max is not None:
Expand All @@ -236,7 +234,6 @@ def from_resources_spec(cls, spec: ResourcesSpec) -> Self:
if spec.gpu is not None:
# GPU resources cannot be overcommitted, limit must be equal to request
gpu = spec.gpu.count.min or 0
assert isinstance(spec.cpu, CPUSpec)
return cls(
cpu=cpu,
memory_mib=memory_mib,
Expand Down
2 changes: 0 additions & 2 deletions src/dstack/_internal/core/backends/slurm/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from dstack._internal.core.models.instances import Gpu
from dstack._internal.core.models.resources import (
DEFAULT_MEMORY_SIZE,
CPUSpec,
Memory,
ResourcesSpec,
)
Expand Down Expand Up @@ -118,7 +117,6 @@ class RequestedResources:


def get_requested_resources_from_resources_spec(spec: ResourcesSpec) -> RequestedResources:
assert isinstance(spec.cpu, CPUSpec)
# 1 is the default value of --cpus-per-task
cpu_count = spec.cpu.count.min or 1

Expand Down
44 changes: 5 additions & 39 deletions src/dstack/_internal/core/models/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@
Field,
GetCoreSchemaHandler,
GetJsonSchemaHandler,
SerializerFunctionWrapHandler,
Tag,
field_validator,
model_serializer,
model_validator,
)
from pydantic.json_schema import JsonSchemaValue
Expand Down Expand Up @@ -227,11 +224,9 @@ def parse(cls, v: Any) -> Any:
# Range and min/max dict - for backward compatibility
if isinstance(v, Range):
return {"arch": None, "count": v}
# A subset rather than exactly {"min", "max"}: `ResourcesSpec` serializes `cpu` down to its
# count for old clients, and under `exclude_none=True` that leaves just `{"min": ...}`.
# Requiring both keys made the round trip land on the `Range[int]` arm of `ResourcesSpec.cpu`
# instead of coming back as a `CPUSpec`. `arch`/`count` are the only `CPUSpec` fields, so a
# mapping of min/max is unambiguously a range.
# `arch` and `count` are the only `CPUSpec` fields, so a mapping of `min`/`max` is
# unambiguously a count range. A subset rather than exactly `{"min", "max"}`, because a
# half-open range may omit the other key.
if isinstance(v, Mapping) and v and v.keys() <= {"min", "max"}:
return {"arch": None, "count": v}
return v
Expand Down Expand Up @@ -395,20 +390,7 @@ def _parse(cls, v: Any) -> Any:


class ResourcesSpec(CoreModel):
# TODO: remove `Range[int]` in 0.20. It is kept only for backward compatibility.
cpu: Annotated[
Union[
# `Tag` only names the arm in validation errors. Without it the `loc` of a bad `cpu`
# spells out the whole wrapped schema —
# `cpu.function-before[parse(), function-before[parse(), ... CPUSpec]].count` — which
# is what `dstack apply` shows the user.
Annotated[CPUSpec, Tag("CPUSpec")],
Annotated[Range[int], Tag("Range[int]")],
],
# `CPUSpec` and `Range[int]` both accept a bare int/str, so the arm has to be picked by
# declaration order rather than by pydantic v2's "smart" union resolution.
Field(description="The CPU requirements", union_mode="left_to_right"),
] = CPUSpec()
cpu: Annotated[CPUSpec, Field(description="The CPU requirements")] = CPUSpec()
memory: Annotated[Range[Memory], Field(description="The RAM size (e.g., `8GB`)")] = (
DEFAULT_MEMORY_SIZE
)
Expand All @@ -435,8 +417,7 @@ def unconstrained(cls) -> "ResourcesSpec":
)

def pretty_format(self) -> str:
# TODO: Remove in 0.20. Use self.cpu directly
cpu = CPUSpec.model_validate(self.cpu)
cpu = self.cpu
resources: Dict[str, Any] = dict(cpu_arch=cpu.arch, cpus=cpu.count, memory=self.memory)
if self.gpu:
gpu = self.gpu
Expand All @@ -452,18 +433,3 @@ def pretty_format(self) -> str:
resources.update(disk_size=self.disk.size)
res = pretty_resources(**resources)
return res

@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Dict[str, Any]:
res = handler(self)
self._update_serialized_cpu(res)
return res

# TODO: Remove in 0.20. Added for backward compatibility.
def _update_serialized_cpu(self, values: Dict):
cpu = values.get("cpu")
if cpu:
arch = cpu.get("arch")
count = cpu.get("count")
if count and arch in [None, gpuhunt.CPUArchitecture.X86.value]:
values["cpu"] = count
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ def _combine_tags(value1: dict[str, str], value2: dict[str, str]) -> dict[str, s

def _combine_resources(value1: ResourcesSpec, value2: ResourcesSpec) -> ResourcesSpec:
return ResourcesSpec(
cpu=_combine_cpu(value1.cpu, value2.cpu), # type: ignore[attr-defined]
cpu=_combine_cpu(value1.cpu, value2.cpu),
memory=_combine_memory(value1.memory, value2.memory),
shm_size=_combine_shm_size_optional(value1.shm_size, value2.shm_size),
gpu=_combine_gpu_optional(value1.gpu, value2.gpu),
Expand Down
6 changes: 2 additions & 4 deletions src/dstack/_internal/server/services/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@

import gpuhunt

from dstack._internal.core.models.resources import CPUSpec, ResourcesSpec
from dstack._internal.core.models.resources import ResourcesSpec


def set_resources_defaults(resources: ResourcesSpec) -> None:
# TODO: Remove in 0.20. Use resources.cpu directly
cpu = CPUSpec.model_validate(resources.cpu)
cpu = resources.cpu
if cpu.arch is None:
gpu = resources.gpu
if (
Expand All @@ -19,7 +18,6 @@ def set_resources_defaults(resources: ResourcesSpec) -> None:
cpu.arch = gpuhunt.CPUArchitecture.ARM
else:
cpu.arch = gpuhunt.CPUArchitecture.X86
resources.cpu = cpu


def set_gpu_vendor_default(
Expand Down
27 changes: 23 additions & 4 deletions src/tests/_internal/core/models/test_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,20 +158,39 @@ def test_range_object(self):
"count": {"min": 1, "max": 2},
}

def test_range_dict(self):
assert CPUSpec.model_validate({"min": 1, "max": 2}).model_dump() == {
@pytest.mark.parametrize(
["value", "expected_min", "expected_max"],
[
pytest.param({"min": 1, "max": 2}, 1, 2, id="closed"),
pytest.param({"min": 1}, 1, None, id="min-only"),
pytest.param({"max": 2}, None, 2, id="max-only"),
# An empty mapping is not a range: it falls through to the `CPUSpec` defaults instead
# of an empty `count` range, which `Range` rejects.
pytest.param({}, DEFAULT_CPU_COUNT.min, DEFAULT_CPU_COUNT.max, id="empty"),
],
)
def test_range_dict(
self, value: dict, expected_min: Optional[int], expected_max: Optional[int]
):
assert CPUSpec.model_validate(value).model_dump() == {
"arch": None,
"count": {"min": 1, "max": 2},
"count": {"min": expected_min, "max": expected_max},
}

def test_valid_dict(self):
def test_valid_dict_with_all_fields(self):
assert CPUSpec.model_validate(
{"arch": "ARM", "count": {"min": 1, "max": 2}}
).model_dump() == {
"arch": CPUArchitecture.ARM,
"count": {"min": 1, "max": 2},
}

def test_valid_dict_no_arch_half_open_count_range(self):
assert CPUSpec.model_validate({"count": {"max": 2}}).model_dump() == {
"arch": None,
"count": {"min": None, "max": 2},
}

def test_invalid_dict(self):
with pytest.raises(ValidationError):
CPUSpec.model_validate({"arch": "x86", "min": 1, "max": 2})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,11 @@
"reservation": null,
"resources": {
"cpu": {
"max": null,
"min": 2
"arch": null,
"count": {
"max": null,
"min": 2
}
},
"disk": {
"size": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,11 @@
"reservation": null,
"resources": {
"cpu": {
"max": null,
"min": 2
"arch": null,
"count": {
"max": null,
"min": 2
}
},
"disk": {
"size": {
Expand Down Expand Up @@ -151,8 +154,11 @@
"reservation": null,
"resources": {
"cpu": {
"max": null,
"min": 2
"arch": null,
"count": {
"max": null,
"min": 2
}
},
"disk": {
"size": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,11 @@
"reservation": null,
"resources": {
"cpu": {
"max": null,
"min": 2
"arch": null,
"count": {
"max": null,
"min": 2
}
},
"disk": {
"size": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@
"reservation": null,
"resources": {
"cpu": {
"max": null,
"min": 2
"arch": null,
"count": {
"max": null,
"min": 2
}
},
"disk": {
"size": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,11 @@
"reservation": null,
"resources": {
"cpu": {
"max": null,
"min": 2
"arch": null,
"count": {
"max": null,
"min": 2
}
},
"disk": {
"size": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,11 @@
"reservation": null,
"resources": {
"cpu": {
"max": 8,
"min": 2
"arch": null,
"count": {
"max": 8,
"min": 2
}
},
"disk": {
"size": {
Expand Down
Loading
Loading