From e843f45fed07cbe2b3c8c18618420516a00f4706 Mon Sep 17 00:00:00 2001 From: Dmitry Meyer Date: Fri, 24 Jul 2026 11:15:12 +0000 Subject: [PATCH] Add support for offers with unknown disk size If `InstanceOffer.instance.resources.disk.size_mib` is set to `0`, it's now treated as "disk size is unknown", and such offers are not filtered against `Requirements.resources.disk.size` (as if it's set to `None`); in other words, the `DiskSpec` requirement is simply ignored. The only backend that uses this new feature is Slurm -- there is no notion of persistent disk storage as a consumable resource (unless expressed via GRES, which we don't support), and previously we adjusted offers with fake disk size from the lower limit of the `DiskSpec.size` range, making a false promise that such an amount of storage is available. Now, disk size is always `0` -> isn't displayed to the end user at all, but offers are still not skipped due to disk size min/max requirements. --- .../_internal/core/backends/base/offers.py | 7 +- .../_internal/core/backends/slurm/compute.py | 1 - .../core/backends/slurm/resources.py | 6 -- src/dstack/_internal/core/models/instances.py | 1 + .../core/backends/base/test_offers.py | 66 ++++++++++++++++++- .../core/backends/slurm/test_compute.py | 6 +- 6 files changed, 73 insertions(+), 14 deletions(-) diff --git a/src/dstack/_internal/core/backends/base/offers.py b/src/dstack/_internal/core/backends/base/offers.py index 90d463234c..681b6f9557 100644 --- a/src/dstack/_internal/core/backends/base/offers.py +++ b/src/dstack/_internal/core/backends/base/offers.py @@ -139,6 +139,11 @@ def offer_to_catalog_item(offer: InstanceOffer) -> gpuhunt.CatalogItem: gpu_vendor = gpu.vendor gpu_name = gpu.name gpu_memory = gpu.memory_mib / 1024 + # Resources.disk.size_mib = 0 -> gpuhunt.CatalogItem.disk_size = None -> gpuhunt.matches() + # doesn't check disk size (gpuhunt.QueryFilter.{min_disk_size,max_disk_size} are ignored) + disk_size: Optional[float] = None + if (disk_size_mib := offer.instance.resources.disk.size_mib) != 0: + disk_size = disk_size_mib / 1024 return gpuhunt.CatalogItem( provider=offer.backend.value, instance_name=offer.instance.name, @@ -152,7 +157,7 @@ def offer_to_catalog_item(offer: InstanceOffer) -> gpuhunt.CatalogItem: gpu_name=gpu_name, gpu_memory=gpu_memory, spot=offer.instance.resources.spot, - disk_size=offer.instance.resources.disk.size_mib / 1024, + disk_size=disk_size, ) diff --git a/src/dstack/_internal/core/backends/slurm/compute.py b/src/dstack/_internal/core/backends/slurm/compute.py index 2739b2e75c..4592727e3a 100644 --- a/src/dstack/_internal/core/backends/slurm/compute.py +++ b/src/dstack/_internal/core/backends/slurm/compute.py @@ -449,7 +449,6 @@ def _adjust_resources(resources: Resources, requested_resources: RequestedResour resources.cpus = requested_resources.cpu_count resources.memory_mib = requested_resources.memory_mib resources.gpus = resources.gpus[: requested_resources.gpu_count] - resources.disk = Disk(size_mib=requested_resources.disk_mib) def _build_image_uri(image_name: str) -> str: diff --git a/src/dstack/_internal/core/backends/slurm/resources.py b/src/dstack/_internal/core/backends/slurm/resources.py index d93a7b9c65..ea7ae51401 100644 --- a/src/dstack/_internal/core/backends/slurm/resources.py +++ b/src/dstack/_internal/core/backends/slurm/resources.py @@ -115,7 +115,6 @@ class RequestedResources: cpu_count: int memory_mib: int gpu_count: int - disk_mib: int def get_requested_resources_from_resources_spec(spec: ResourcesSpec) -> RequestedResources: @@ -136,13 +135,8 @@ def get_requested_resources_from_resources_spec(spec: ResourcesSpec) -> Requeste if spec.gpu is not None: gpu_count = spec.gpu.count.min or 0 - disk_mib: int = 0 - if spec.disk is not None: - disk_mib = round((spec.disk.size.min or 0) * 1024) - return RequestedResources( cpu_count=cpu_count, memory_mib=memory_mib, gpu_count=gpu_count, - disk_mib=disk_mib, ) diff --git a/src/dstack/_internal/core/models/instances.py b/src/dstack/_internal/core/models/instances.py index dfce209c32..a7623bd27a 100644 --- a/src/dstack/_internal/core/models/instances.py +++ b/src/dstack/_internal/core/models/instances.py @@ -48,6 +48,7 @@ def validate_name_and_vendor(cls, values): class Disk(CoreModel): size_mib: int + """`size_mib=0` has a special meaning -- size is unknown""" class Resources(CoreModel): diff --git a/src/tests/_internal/core/backends/base/test_offers.py b/src/tests/_internal/core/backends/base/test_offers.py index 8f3383c642..ba3829a40f 100644 --- a/src/tests/_internal/core/backends/base/test_offers.py +++ b/src/tests/_internal/core/backends/base/test_offers.py @@ -1,9 +1,20 @@ import gpuhunt import pytest -from dstack._internal.core.backends.base.offers import gpu_matches_gpu_spec -from dstack._internal.core.models.instances import Gpu -from dstack._internal.core.models.resources import GPUSpec +from dstack._internal.core.backends.base.offers import ( + filter_offers_by_requirements, + gpu_matches_gpu_spec, +) +from dstack._internal.core.models.backends.base import BackendType +from dstack._internal.core.models.instances import ( + Disk, + Gpu, + InstanceOffer, + InstanceType, + Resources, +) +from dstack._internal.core.models.resources import GPUSpec, ResourcesSpec +from dstack._internal.core.models.runs import Requirements NVIDIA = gpuhunt.AcceleratorVendor.NVIDIA AMD = gpuhunt.AcceleratorVendor.AMD @@ -92,3 +103,52 @@ def test_single_failing_constraint_rejects_gpu(self): # Everything matches except memory, which is below the minimum. spec = GPUSpec(vendor="nvidia", name="A100", memory="40GB..80GB") assert not gpu_matches_gpu_spec(make_gpu(name="A100", memory_gib=24), spec) + + +class TestFilterOffersByRequirementsDiskSize: + """ + An offer with `disk.size_mib == 0` has an unknown disk size and must not be + filtered out by the disk size requirement. This exercises both the + `offer_to_catalog_item` mapping (`size_mib=0` -> `disk_size=None`) and + `gpuhunt.matches` (ignores disk bounds when `disk_size is None`). + """ + + def make_offer(self, disk_size_mib: int) -> InstanceOffer: + # cpus/memory are chosen to satisfy the default requirements so that only the + # disk size decides whether the offer is filtered out. + return InstanceOffer( + backend=BackendType.SLURM, + instance=InstanceType( + name="test-instance", + resources=Resources( + cpus=8, + memory_mib=64 * 1024, + gpus=[], + spot=False, + disk=Disk(size_mib=disk_size_mib), + ), + ), + region="test-region", + price=1.0, + ) + + def test_unknown_disk_size_ignores_disk_requirement(self): + # An offer with a real 0 GB disk would satisfy neither the min nor the max + # bound, so a match here proves the disk requirement is ignored entirely. + offer = self.make_offer(disk_size_mib=0) + requirements = Requirements(resources=ResourcesSpec(disk="100GB..200GB")) + + assert list(filter_offers_by_requirements([offer], requirements)) == [offer] + + def test_known_disk_size_below_min_is_filtered_out(self): + # A non-zero disk size is still checked, so the requirement stays effective. + offer = self.make_offer(disk_size_mib=50 * 1024) + requirements = Requirements(resources=ResourcesSpec(disk="100GB..")) + + assert list(filter_offers_by_requirements([offer], requirements)) == [] + + def test_known_disk_size_within_range_matches(self): + offer = self.make_offer(disk_size_mib=150 * 1024) + requirements = Requirements(resources=ResourcesSpec(disk="100GB..200GB")) + + assert list(filter_offers_by_requirements([offer], requirements)) == [offer] diff --git a/src/tests/_internal/core/backends/slurm/test_compute.py b/src/tests/_internal/core/backends/slurm/test_compute.py index d7b39c9273..c1c223db45 100644 --- a/src/tests/_internal/core/backends/slurm/test_compute.py +++ b/src/tests/_internal/core/backends/slurm/test_compute.py @@ -33,7 +33,7 @@ def _node_offer() -> InstanceOfferWithAvailability: memory_mib=128 * 1024, gpus=[Gpu(name="H100", memory_mib=80 * 1024) for _ in range(8)], spot=False, - disk=Disk(size_mib=500 * 1024), + disk=Disk(size_mib=0), ), ), region="cluster1", @@ -66,7 +66,7 @@ def test_get_offers_with_full_offers_keeps_full_node_resources(): assert full_resources.cpus == 16 assert full_resources.memory_mib == 128 * 1024 assert len(full_resources.gpus) == 8 - assert full_resources.disk.size_mib == 500 * 1024 + assert full_resources.disk.size_mib == 0 def test_get_offers_without_full_offers_adjusts_to_requested_slice(): @@ -89,5 +89,5 @@ def test_get_offers_without_full_offers_adjusts_to_requested_slice(): assert adjusted_resources.cpus == 2 assert adjusted_resources.memory_mib == 8 * 1024 assert len(adjusted_resources.gpus) == 1 - assert adjusted_resources.disk.size_mib == 100 * 1024 + assert adjusted_resources.disk.size_mib == 0 assert adjusted_offers[0].availability_zones == ["partition1"]