diff --git a/src/dstack/_internal/core/models/fleets.py b/src/dstack/_internal/core/models/fleets.py index 0326ec584..dbf78ca25 100644 --- a/src/dstack/_internal/core/models/fleets.py +++ b/src/dstack/_internal/core/models/fleets.py @@ -248,7 +248,7 @@ class InstanceGroupParams(CoreModel): resources: Annotated[ Optional[ResourcesSpec], Field(description="The resources requirements"), - ] = ResourcesSpec() + ] = None blocks: Annotated[ Union[Literal["auto"], int], diff --git a/src/dstack/_internal/core/models/instances.py b/src/dstack/_internal/core/models/instances.py index 8820e0bc1..a7e615902 100644 --- a/src/dstack/_internal/core/models/instances.py +++ b/src/dstack/_internal/core/models/instances.py @@ -14,7 +14,7 @@ from dstack._internal.core.models.envs import Env from dstack._internal.core.models.health import HealthStatus from dstack._internal.core.models.volumes import Volume -from dstack._internal.utils.common import pretty_resources +from dstack._internal.utils.common import format_mib_as_gb, pretty_resources from dstack._internal.utils.logging import get_logger logger = get_logger(__name__) @@ -135,7 +135,7 @@ def _pretty_format( "gpu_count": len(gpus), } if gpu.memory_mib > 0: - gpu_resources["gpu_memory"] = f"{gpu.memory_mib / 1024:.0f}GB" + gpu_resources["gpu_memory"] = format_mib_as_gb(gpu.memory_mib) output = pretty_resources(**gpu_resources) if include_spot and spot: output += " (spot)" @@ -146,15 +146,15 @@ def _pretty_format( resources["cpus"] = cpus resources["cpu_arch"] = cpu_arch if memory_mib > 0: - resources["memory"] = f"{memory_mib / 1024:.0f}GB" + resources["memory"] = format_mib_as_gb(memory_mib) if disk_size_mib > 0: - resources["disk_size"] = f"{disk_size_mib / 1024:.0f}GB" + resources["disk_size"] = format_mib_as_gb(disk_size_mib) if gpus: gpu = gpus[0] resources["gpu_name"] = gpu.name resources["gpu_count"] = len(gpus) if gpu.memory_mib > 0: - resources["gpu_memory"] = f"{gpu.memory_mib / 1024:.0f}GB" + resources["gpu_memory"] = format_mib_as_gb(gpu.memory_mib) output = pretty_resources(**resources) if include_spot and spot: output += " (spot)" diff --git a/src/dstack/_internal/core/models/resources.py b/src/dstack/_internal/core/models/resources.py index 81230afcf..ff2a173a5 100644 --- a/src/dstack/_internal/core/models/resources.py +++ b/src/dstack/_internal/core/models/resources.py @@ -394,6 +394,16 @@ class ResourcesSpec(generate_dual_core_model(ResourcesSpecConfig)): """`gpu` is optional for backward compatibility.""" disk: Annotated[Optional[DiskSpec], Field(description="The disk resources")] = DEFAULT_DISK + @classmethod + def unconstrained(cls) -> "ResourcesSpec": + """ResourcesSpec with no meaningful minimum constraints.""" + return cls( + cpu=CPUSpec(count=Range[int](min=1, max=None)), + memory=Range[Memory](min=Memory.parse("0"), max=None), + gpu=DEFAULT_GPU_SPEC, + disk=None, + ) + def pretty_format(self) -> str: # TODO: Remove in 0.20. Use self.cpu directly cpu = parse_obj_as(CPUSpec, self.cpu) diff --git a/src/dstack/_internal/server/services/fleets.py b/src/dstack/_internal/server/services/fleets.py index 5d6a551ce..13f383fc0 100644 --- a/src/dstack/_internal/server/services/fleets.py +++ b/src/dstack/_internal/server/services/fleets.py @@ -937,8 +937,11 @@ def is_cloud_cluster(fleet_model: FleetModel) -> bool: def get_fleet_requirements(fleet_spec: FleetSpec) -> Requirements: profile = fleet_spec.merged_profile + resources = fleet_spec.configuration.resources + if resources is None: + resources = ResourcesSpec.unconstrained() requirements = Requirements( - resources=fleet_spec.configuration.resources or ResourcesSpec(), + resources=resources, max_price=profile.max_price, spot=get_policy_map(profile.spot_policy, default=SpotPolicy.ONDEMAND), reservation=fleet_spec.configuration.reservation, diff --git a/src/dstack/_internal/utils/common.py b/src/dstack/_internal/utils/common.py index c761bfcc2..f0c30b2a2 100644 --- a/src/dstack/_internal/utils/common.py +++ b/src/dstack/_internal/utils/common.py @@ -112,6 +112,11 @@ def pretty_date(time: datetime) -> str: return str(years) + " years ago" +def format_mib_as_gb(mib: int) -> str: + """Format a MiB value as a human-readable GB string, e.g. 512 → '0.5GB', 8192 → '8GB'.""" + return f"{round(mib / 1024, 1):g}GB" + + def pretty_resources( *, cpu_arch: Optional[Any] = None, diff --git a/src/tests/_internal/server/routers/test_fleets.py b/src/tests/_internal/server/routers/test_fleets.py index 5c5cef8c6..8fe17ba62 100644 --- a/src/tests/_internal/server/routers/test_fleets.py +++ b/src/tests/_internal/server/routers/test_fleets.py @@ -936,20 +936,7 @@ async def test_creates_fleet(self, test_db, session: AsyncSession, client: Async "placement": None, "env": {}, "ssh_config": None, - "resources": { - "cpu": {"min": 2, "max": None}, - "memory": {"min": 8.0, "max": None}, - "shm_size": None, - "gpu": { - "vendor": None, - "name": None, - "count": {"min": 0, "max": None}, - "memory": None, - "total_memory": None, - "compute_capability": None, - }, - "disk": {"size": {"min": 100.0, "max": None}}, - }, + "resources": None, "backends": None, "regions": None, "availability_zones": None, @@ -1067,20 +1054,7 @@ async def test_creates_ssh_fleet(self, test_db, session: AsyncSession, client: A }, "nodes": None, "placement": None, - "resources": { - "cpu": {"min": 2, "max": None}, - "memory": {"min": 8.0, "max": None}, - "shm_size": None, - "gpu": { - "vendor": None, - "name": None, - "count": {"min": 0, "max": None}, - "memory": None, - "total_memory": None, - "compute_capability": None, - }, - "disk": {"size": {"min": 100.0, "max": None}}, - }, + "resources": None, "backends": None, "regions": None, "availability_zones": None, @@ -1297,20 +1271,7 @@ async def test_updates_ssh_fleet(self, test_db, session: AsyncSession, client: A }, "nodes": None, "placement": None, - "resources": { - "cpu": {"min": 2, "max": None}, - "memory": {"min": 8.0, "max": None}, - "shm_size": None, - "gpu": { - "vendor": None, - "name": None, - "count": {"min": 0, "max": None}, - "memory": None, - "total_memory": None, - "compute_capability": None, - }, - "disk": {"size": {"min": 100.0, "max": None}}, - }, + "resources": None, "backends": None, "regions": None, "availability_zones": None, diff --git a/src/tests/_internal/server/services/requirements/test_combine.py b/src/tests/_internal/server/services/requirements/test_combine.py index d134664b1..294ed3f43 100644 --- a/src/tests/_internal/server/services/requirements/test_combine.py +++ b/src/tests/_internal/server/services/requirements/test_combine.py @@ -164,6 +164,29 @@ def test_combines_requirements( == expected_requirements ) + def test_unconstrained_fleet_resources_pass_through_run_requirements(self): + unconstrained_fleet = Requirements( + resources=ResourcesSpec.unconstrained(), + ) + run = Requirements( + resources=ResourcesSpec( + cpu=CPUSpec(count=Range(min=2, max=None)), + memory=Range(min=Memory.parse("2GB"), max=None), + gpu=GPUSpec(count=Range(min=1, max=None)), + disk=DiskSpec(size=Range(min=Memory.parse("50GB"), max=None)), + ), + ) + result = combine_fleet_and_run_requirements(unconstrained_fleet, run) + assert result is not None + combined_cpu = result.resources.cpu + assert isinstance(combined_cpu, CPUSpec) + assert combined_cpu.count.min == 2 + assert result.resources.memory.min == Memory.parse("2GB") + assert result.resources.gpu is not None + assert result.resources.gpu.count.min == 1 + assert result.resources.disk is not None + assert result.resources.disk.size.min == Memory.parse("50GB") + class TestIntersectLists: def test_both_none_returns_none(self):