Skip to content

Commit 74b87ed

Browse files
authored
Kubernetes: Rework offers and resource handling (#4021)
* Switch the backend from ComputeWithFilteredOffersCached to ComputeWithAllOffersCached. Kubernetes node offers don't depend on run requirements, so all node offers are now cached once and adjusted/filtered per requirements via get_offers_modifiers(), instead of re-querying the cluster for every distinct requirements set. * Emit a Kubernetes request of 0 when a resource range has no lower bound (e.g. `cpu: ..4`). Previously no request was emitted, and since Kubernetes defaults the request to the limit, `cpu: ..4` silently behaved like `cpu: 4`. An explicit 0 request is less surprising and matches how ranges behave elsewhere in dstack. * Introduce ResourceRequests/ResourceLimits dataclasses that centralize the translation between dstack ResourcesSpec and Kubernetes resource maps, and back (from_kubernetes_map). Previously this logic was duplicated and built ad hoc as dicts in _create_job_pod() and get_instance_offers(). * Add adjust_resources_by_resource_requests() to cap an offer's advertised resources to what was requested (used as the offer modifier), and to reflect the actual pod requests on the provisioned instance in run_job().
1 parent 22ac385 commit 74b87ed

4 files changed

Lines changed: 302 additions & 100 deletions

File tree

mkdocs/docs/concepts/backends.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1247,9 +1247,9 @@ projects:
12471247
If you use ranges with [`resources`](../concepts/tasks.md#resources) (e.g. `gpu: 1..8` or `memory: 64GB..`) in fleet or run configurations, other backends collect and try all offers that satisfy the range.
12481248

12491249
The `kubernetes` backend handles it differently.
1250-
1250+
12511251
* For `gpu`, if you specify a range (e.g. `gpu: 4..8`), the `kubernetes` backend only provisions pods with the GPU count equal to the lower limit (`4`). The upper limit of the GPU range is always ignored.
1252-
* For other resources such as `cpu`, `memory`, and `disk`, the `kubernetes` backend passes the lower and upper limits of the range as Kubernetes [requests and limits](https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/#requests-and-limits) respectively. If the upper limit is not set, the Kubernetes limit is also not set.
1252+
* For other resources such as `cpu`, `memory`, and `disk`, the `kubernetes` backend passes the lower and upper limits of the range as Kubernetes [requests and limits](https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/#requests-and-limits) respectively. If the lower limit is not set, the Kubernetes request is set to `0`, unlike Kubernetes, where the request defaults to the limit if not set. If the upper limit is not set, the Kubernetes limit is also not set.
12531253

12541254
Example:
12551255

@@ -1260,7 +1260,7 @@ projects:
12601260
ide: vscode
12611261
12621262
resources:
1263-
cpu: 32..64
1263+
cpu: ..64
12641264
memory: 1024GB
12651265
disk: 100GB..
12661266
gpu: nvidia:4..8
@@ -1272,7 +1272,7 @@ projects:
12721272

12731273
| Resource | Request | Limit |
12741274
|---------------------|----------|-----------|
1275-
| `cpu` | `32` | `64` |
1275+
| `cpu` | `0` | `64` |
12761276
| `memory` | `1024Gi` | `1024Gi` |
12771277
| `ephemeral-storage` | `100Gi` | _not set_ |
12781278
| `nvidia.com/gpu` | `4` | `4` |

src/dstack/_internal/core/backends/kubernetes/compute.py

Lines changed: 48 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from contextlib import ExitStack
88
from decimal import Decimal
99
from enum import Enum
10+
from functools import partial
1011
from typing import List, Optional
1112

1213
from gpuhunt import AcceleratorVendor
@@ -15,7 +16,7 @@
1516

1617
from dstack._internal.core.backends.base.compute import (
1718
Compute,
18-
ComputeWithFilteredOffersCached,
19+
ComputeWithAllOffersCached,
1920
ComputeWithGatewaySupport,
2021
ComputeWithInstanceVolumesSupport,
2122
ComputeWithMultinodeSupport,
@@ -29,27 +30,33 @@
2930
get_dstack_gateway_commands,
3031
merge_tags,
3132
)
32-
from dstack._internal.core.backends.base.offers import RegionalSkipOfferCache, gpu_matches_gpu_spec
33+
from dstack._internal.core.backends.base.offers import (
34+
OfferModifier,
35+
RegionalSkipOfferCache,
36+
gpu_matches_gpu_spec,
37+
)
3338
from dstack._internal.core.backends.kubernetes.api_client import API_CLIENT_EXCEPTIONS
3439
from dstack._internal.core.backends.kubernetes.models import KubernetesConfig
3540
from dstack._internal.core.backends.kubernetes.resources import (
3641
AMD_GPU_DEVICE_ID_LABEL_PREFIX,
3742
AMD_GPU_NAME_TO_DEVICE_IDS,
3843
AMD_GPU_NODE_TAINT,
39-
AMD_GPU_RESOURCE,
4044
LABEL_VALUE_MAX_LENGTH,
4145
NVIDIA_GPU_NODE_TAINT,
4246
NVIDIA_GPU_PRODUCT_LABEL,
43-
NVIDIA_GPU_RESOURCE,
4447
OBJECT_NAME_MAX_LENGTH,
48+
AnyKubernetesGPUResource,
49+
KubernetesResource,
4550
PodPhase,
51+
ResourceLimits,
52+
ResourceRequests,
4653
TaintEffect,
54+
adjust_resources_by_resource_requests,
4755
build_base_labels,
4856
build_dockerconfigjson,
4957
filter_invalid_labels,
5058
format_memory,
5159
get_amd_gpu_from_node_labels,
52-
get_gpu_request_from_gpu_spec,
5360
get_instance_offer_from_node,
5461
get_instance_offers,
5562
get_node_labels,
@@ -80,7 +87,7 @@
8087
SSHConnectionParams,
8188
)
8289
from dstack._internal.core.models.placement import PlacementGroup
83-
from dstack._internal.core.models.resources import CPUSpec, GPUSpec
90+
from dstack._internal.core.models.resources import GPUSpec
8491
from dstack._internal.core.models.routers import AnyGatewayRouterConfig
8592
from dstack._internal.core.models.runs import (
8693
Job,
@@ -125,7 +132,7 @@ def load(cls, raw: str) -> Self:
125132

126133

127134
class KubernetesCompute(
128-
ComputeWithFilteredOffersCached,
135+
ComputeWithAllOffersCached,
129136
ComputeWithPrivilegedSupport,
130137
ComputeWithInstanceVolumesSupport,
131138
ComputeWithVolumeSupport,
@@ -138,17 +145,15 @@ def __init__(self, config: KubernetesConfig):
138145
self.region_cluster_map = {c.region: c for c in get_clusters_from_backend_config(config)}
139146
self.skip_offer_cache = RegionalSkipOfferCache(ttl=60)
140147

141-
def get_offers_by_requirements(
142-
self, requirements: Requirements
143-
) -> list[InstanceOfferWithAvailability]:
148+
def get_all_offers_with_availability(self) -> list[InstanceOfferWithAvailability]:
144149
offers: list[InstanceOfferWithAvailability] = []
145150
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
146151
future_cluster_map: dict[
147152
concurrent.futures.Future[list[InstanceOfferWithAvailability]], Cluster
148153
] = {}
149154
for region, cluster in self.region_cluster_map.items():
150155
api = client.CoreV1Api(cluster.api_client)
151-
future = executor.submit(get_instance_offers, api, region, requirements)
156+
future = executor.submit(get_instance_offers, api, region)
152157
future_cluster_map[future] = cluster
153158
for future in concurrent.futures.as_completed(future_cluster_map):
154159
try:
@@ -164,6 +169,10 @@ def get_offers_by_requirements(
164169
offers.extend(cluster_offers)
165170
return offers
166171

172+
def get_offers_modifiers(self, requirements: Requirements) -> list[OfferModifier]:
173+
resource_requests = ResourceRequests.from_resources_spec(requirements.resources)
174+
return [partial(_offer_modifier, resource_requests)]
175+
167176
def run_job(
168177
self,
169178
run: Run,
@@ -385,18 +394,15 @@ def update_provisioning_data(
385394
provisioning_data.hostname = get_or_error(service_spec.cluster_ip)
386395
pod_spec = get_or_error(pod.spec)
387396
node = api.read_node(name=get_or_error(pod_spec.node_name))
388-
# In the original offer, the resources have already been adjusted according to
389-
# the run configuration resource requirements, see get_offers_by_requirements()
390-
original_resources = provisioning_data.instance_type.resources
391-
instance_offer = get_instance_offer_from_node(
392-
node=node,
393-
region=cluster.region,
394-
cpu_request=original_resources.cpus,
395-
memory_mib_request=original_resources.memory_mib,
396-
gpu_request=len(original_resources.gpus),
397-
disk_mib_request=original_resources.disk.size_mib,
398-
)
397+
instance_offer = get_instance_offer_from_node(node=node, region=cluster.region)
399398
if instance_offer is not None:
399+
resource_requirements = get_or_error(pod_spec.containers[0].resources)
400+
resource_requests = ResourceRequests.from_kubernetes_map(
401+
resource_requirements.requests or {}
402+
)
403+
adjust_resources_by_resource_requests(
404+
instance_offer.instance.resources, resource_requests, force=True
405+
)
400406
provisioning_data.instance_type = instance_offer.instance
401407
provisioning_data.price = instance_offer.price
402408

@@ -730,18 +736,18 @@ def delete_volume(self, volume: Volume):
730736

731737
def _get_pod_spec_parameters_for_gpu(
732738
api: client.CoreV1Api, gpu_spec: GPUSpec
733-
) -> tuple[str, client.V1NodeAffinity, str]:
739+
) -> tuple[AnyKubernetesGPUResource, client.V1NodeAffinity, str]:
734740
nodes = api.list_node().items
735741
gpu_vendor = gpu_spec.vendor
736742
# If no vendor specified, we assume it's NVIDIA. Technically, it's possible to request either
737743
# NVIDIA or AMD in the run configuration using only GPU names (e.g.,`gpu: H100,MI300X:8`),
738744
# but we ignore such configurations as it's hard to translate them to K8s request.
739745
if gpu_vendor is None or gpu_vendor == AcceleratorVendor.NVIDIA:
740746
node_affinity = _get_nvidia_gpu_node_affinity(gpu_spec, nodes)
741-
return NVIDIA_GPU_RESOURCE, node_affinity, NVIDIA_GPU_NODE_TAINT
747+
return KubernetesResource.NVIDIA_GPU, node_affinity, NVIDIA_GPU_NODE_TAINT
742748
if gpu_vendor == AcceleratorVendor.AMD:
743749
node_affinity = _get_amd_gpu_node_affinity(gpu_spec, nodes)
744-
return AMD_GPU_RESOURCE, node_affinity, AMD_GPU_NODE_TAINT
750+
return KubernetesResource.AMD_GPU, node_affinity, AMD_GPU_NODE_TAINT
745751
raise ComputeError(f"Unsupported GPU vendor: {gpu_vendor}")
746752

747753

@@ -804,6 +810,14 @@ def _get_amd_gpu_node_affinity(
804810
)
805811

806812

813+
def _offer_modifier(
814+
resource_requests: ResourceRequests, offer: InstanceOfferWithAvailability
815+
) -> InstanceOfferWithAvailability:
816+
offer_copy = offer.copy(deep=True)
817+
adjust_resources_by_resource_requests(offer_copy.instance.resources, resource_requests)
818+
return offer_copy
819+
820+
807821
def _create_jump_pod_service_if_not_exists(
808822
api: client.CoreV1Api,
809823
namespace: str,
@@ -1103,27 +1117,21 @@ def _create_job_pod(
11031117
requirements: Requirements,
11041118
authorized_keys: list[str],
11051119
) -> None:
1106-
resources_requests: dict[str, str] = {}
1107-
resources_limits: dict[str, str] = {}
11081120
node_affinity: Optional[client.V1NodeAffinity] = None
11091121
tolerations: list[client.V1Toleration] = []
11101122
volumes_: list[client.V1Volume] = []
11111123
volume_mounts: list[client.V1VolumeMount] = []
11121124
env_vars: list[client.V1EnvVar] = []
11131125

11141126
resources_spec = requirements.resources
1115-
assert isinstance(resources_spec.cpu, CPUSpec)
1116-
if (cpu_min := resources_spec.cpu.count.min) is not None:
1117-
resources_requests["cpu"] = str(cpu_min)
1118-
if (cpu_max := resources_spec.cpu.count.max) is not None:
1119-
resources_limits["cpu"] = str(cpu_max)
1120-
gpu_spec = resources_spec.gpu
1121-
if gpu_spec is not None and (gpu_request := get_gpu_request_from_gpu_spec(gpu_spec)) > 0:
1127+
resource_requests = ResourceRequests.from_resources_spec(resources_spec)
1128+
resource_limits = ResourceLimits.from_resources_spec(resources_spec)
1129+
gpu_resource: Optional[AnyKubernetesGPUResource] = None
1130+
if resource_requests.gpu > 0:
1131+
gpu_spec = resources_spec.gpu
1132+
assert gpu_spec is not None
11221133
gpu_resource, node_affinity, node_taint = _get_pod_spec_parameters_for_gpu(api, gpu_spec)
1123-
logger.debug("Requesting GPU resource: %s=%d", gpu_resource, gpu_request)
1124-
resources_requests[gpu_resource] = str(gpu_request)
1125-
# Limit must be set (GPU resources cannot be overcommitted) and must be equal to request.
1126-
resources_limits[gpu_resource] = str(gpu_request)
1134+
logger.debug("Requesting GPU resource: %s=%d", gpu_resource, resource_requests.gpu)
11271135
# It should be NoSchedule, but we also add NoExecute toleration just in case.
11281136
for effect in [TaintEffect.NO_SCHEDULE, TaintEffect.NO_EXECUTE]:
11291137
tolerations.append(
@@ -1134,15 +1142,6 @@ def _create_job_pod(
11341142
# into the image (NVIDIA images and images based on them including dstackai/base)
11351143
# See https://github.com/NVIDIA/k8s-device-plugin/issues/61
11361144
env_vars.append(client.V1EnvVar(name="NVIDIA_VISIBLE_DEVICES", value="void"))
1137-
if (memory_min := resources_spec.memory.min) is not None:
1138-
resources_requests["memory"] = format_memory(memory_min)
1139-
if (memory_max := resources_spec.memory.max) is not None:
1140-
resources_limits["memory"] = format_memory(memory_max)
1141-
if (disk_spec := resources_spec.disk) is not None:
1142-
if (disk_min := disk_spec.size.min) is not None:
1143-
resources_requests["ephemeral-storage"] = format_memory(disk_min)
1144-
if (disk_max := disk_spec.size.max) is not None:
1145-
resources_limits["ephemeral-storage"] = format_memory(disk_max)
11461145
if (shm_size := resources_spec.shm_size) is not None:
11471146
shm_volume_name = "dev-shm"
11481147
volumes_.append(
@@ -1249,8 +1248,8 @@ def _create_job_pod(
12491248
),
12501249
),
12511250
resources=client.V1ResourceRequirements(
1252-
requests=resources_requests,
1253-
limits=resources_limits,
1251+
requests=resource_requests.to_kubernetes_map(gpu_resource),
1252+
limits=resource_limits.to_kubernetes_map(gpu_resource),
12541253
),
12551254
volume_mounts=volume_mounts,
12561255
env=env_vars,

0 commit comments

Comments
 (0)