Skip to content

Commit 01becce

Browse files
authored
Update Vast.ai spot instances support (#4036)
- Detect and clean up stale spot instances that failed to start due to insufficient bid. - Update to gpuhunt 0.1.27, which includes better ordering and more precise pricing of spot offers. - Use backend-specific `min_bid` offer parameter introduced in gpuhunt 0.1.27.
1 parent bae409d commit 01becce

4 files changed

Lines changed: 113 additions & 26 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ dependencies = [
3333
"python-multipart>=0.0.16",
3434
"filelock",
3535
"psutil",
36-
"gpuhunt==0.1.26",
36+
"gpuhunt==0.1.27",
3737
"argcomplete>=3.5.0",
3838
"ignore-python>=0.2.0",
3939
"orjson",

src/dstack/_internal/core/backends/vastai/api_client.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,31 @@
44

55
import dstack._internal.utils.docker as docker
66
from dstack._internal.core.consts import DSTACK_RUNNER_SSH_PORT
7-
from dstack._internal.core.errors import ComputeError, NoCapacityError
7+
from dstack._internal.core.errors import ComputeError
88
from dstack._internal.core.models.common import RegistryAuth
99

1010

1111
class VastAIRateLimitError(Exception):
1212
pass
1313

1414

15+
class VastAICreateInstanceError(Exception):
16+
"""
17+
Attributes:
18+
resp: Full API response.
19+
created_instance_id: ID of the instance that was created despite the error. For example,
20+
Vast.ai creates spot instances in the stopped state and returns an error if the bid is
21+
insufficient.
22+
"""
23+
24+
def __init__(
25+
self, *args, resp: str, created_instance_id: Optional[int] = None, **kwargs
26+
) -> None:
27+
super().__init__(*args, **kwargs)
28+
self.resp = resp
29+
self.created_instance_id = created_instance_id
30+
31+
1532
class VastAIAPIClient:
1633
def __init__(self, api_key: str):
1734
self.api_url = "https://console.vast.ai/api"
@@ -27,7 +44,7 @@ def create_instance(
2744
disk_size: int,
2845
registry_auth: Optional[RegistryAuth] = None,
2946
bid: Optional[float] = None,
30-
) -> dict:
47+
) -> int:
3148
"""
3249
Args:
3350
instance_name: instance label
@@ -39,10 +56,10 @@ def create_instance(
3956
(spot) instance is created; if None, an on-demand instance.
4057
4158
Raises:
42-
NoCapacityError: if instance cannot be created
59+
VastAICreateInstanceError: if instance cannot be created
4360
4461
Returns:
45-
create instance response
62+
Instance ID
4663
"""
4764
image_login = None
4865
if registry_auth:
@@ -70,12 +87,20 @@ def create_instance(
7087
"force": False,
7188
}
7289
resp = self.s.put(self._url(f"/v0/asks/{bundle_id}/"), json=payload)
73-
if resp.status_code != 200 or not (data := resp.json())["success"]:
74-
raise NoCapacityError(resp.text)
75-
return data
90+
try:
91+
data = resp.json()
92+
except requests.exceptions.JSONDecodeError:
93+
raise VastAICreateInstanceError(resp=resp.text)
94+
if resp.status_code != 200 or not data["success"]:
95+
raise VastAICreateInstanceError(
96+
resp=resp.text, created_instance_id=data.get("new_contract")
97+
)
98+
return data["new_contract"]
7699

77100
def destroy_instance(self, instance_id: Union[str, int]) -> None:
78101
resp = self.s.delete(self._url(f"/v0/instances/{instance_id}/"))
102+
if resp.status_code == 429:
103+
raise VastAIRateLimitError()
79104
try:
80105
data = resp.json()
81106
except requests.exceptions.JSONDecodeError:

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

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import time
12
from typing import List, Optional
23

34
import gpuhunt
@@ -12,7 +13,11 @@
1213
)
1314
from dstack._internal.core.backends.base.offers import get_catalog_offers
1415
from dstack._internal.core.backends.base.profile_options import get_backend_profile_options
15-
from dstack._internal.core.backends.vastai.api_client import VastAIAPIClient, VastAIRateLimitError
16+
from dstack._internal.core.backends.vastai.api_client import (
17+
VastAIAPIClient,
18+
VastAICreateInstanceError,
19+
VastAIRateLimitError,
20+
)
1621
from dstack._internal.core.backends.vastai.models import VastAIConfig
1722
from dstack._internal.core.backends.vastai.profile_options import (
1823
VASTAI_DEFAULT_MIN_RELIABILITY,
@@ -21,8 +26,9 @@
2126
VastAIProfileOptions,
2227
)
2328
from dstack._internal.core.consts import DSTACK_RUNNER_SSH_PORT
24-
from dstack._internal.core.errors import ProvisioningError
29+
from dstack._internal.core.errors import ComputeError, NoCapacityError, ProvisioningError
2530
from dstack._internal.core.models.backends.base import BackendType
31+
from dstack._internal.core.models.common import CoreModel
2632
from dstack._internal.core.models.instances import (
2733
InstanceAvailability,
2834
InstanceOfferWithAvailability,
@@ -125,23 +131,39 @@ def run_job(
125131
commands = get_docker_commands(
126132
[run.run_spec.ssh_key_pub.strip(), project_ssh_public_key.strip()]
127133
)
134+
offer_backend_data: VastAIOfferBackendData = VastAIOfferBackendData.__response__.parse_obj(
135+
instance_offer.backend_data
136+
)
128137
bid = None
129138
if instance_offer.instance.resources.spot:
130-
bid = instance_offer.price
131-
resp = self.api_client.create_instance(
132-
instance_name=instance_name,
133-
bundle_id=instance_offer.instance.name,
134-
image_name=job.job_spec.image_name,
135-
onstart=" && ".join(commands),
136-
disk_size=round(instance_offer.instance.resources.disk.size_mib / 1024),
137-
registry_auth=job.job_spec.registry_auth,
138-
bid=bid,
139-
)
140-
instance_id = resp["new_contract"]
139+
if offer_backend_data.min_bid is None:
140+
raise ComputeError(
141+
"VastAIOfferBackendData.min_bid is unexpectedly missing for a spot offer"
142+
)
143+
bid = offer_backend_data.min_bid
144+
try:
145+
instance_id = self.api_client.create_instance(
146+
instance_name=instance_name,
147+
bundle_id=instance_offer.instance.name,
148+
image_name=job.job_spec.image_name,
149+
onstart=" && ".join(commands),
150+
disk_size=round(instance_offer.instance.resources.disk.size_mib / 1024),
151+
registry_auth=job.job_spec.registry_auth,
152+
bid=bid,
153+
)
154+
except VastAICreateInstanceError as e:
155+
if e.created_instance_id is not None:
156+
_terminate_instance_with_rate_limit_retry(
157+
self.api_client, e.created_instance_id, retries=4
158+
)
159+
logger.debug(
160+
"Terminated Vast.ai instance %s that failed to start", e.created_instance_id
161+
)
162+
raise NoCapacityError(e.resp) from e
141163
return JobProvisioningData(
142164
backend=instance_offer.backend,
143165
instance_type=instance_offer.instance,
144-
instance_id=instance_id,
166+
instance_id=str(instance_id),
145167
hostname=None,
146168
internal_ip=None,
147169
region=instance_offer.region,
@@ -183,3 +205,38 @@ def update_provisioning_data(
183205
and ": OCI runtime create failed:" in resp["status_msg"]
184206
):
185207
raise ProvisioningError(resp["status_msg"])
208+
209+
210+
class VastAIOfferBackendData(CoreModel):
211+
min_bid: float | None = None
212+
213+
214+
def _terminate_instance_with_rate_limit_retry(
215+
client: VastAIAPIClient, instance_id: int, retries: int
216+
) -> bool:
217+
for attempt in range(retries):
218+
try:
219+
client.destroy_instance(instance_id)
220+
return True
221+
except VastAIRateLimitError:
222+
if attempt + 1 < retries:
223+
logger.warning(
224+
"Hit rate limit when terminating Vast.ai instance %s. Attempt %s/%s",
225+
instance_id,
226+
attempt + 1,
227+
retries,
228+
)
229+
time.sleep(attempt + 1)
230+
except BaseException as e:
231+
logger.error(
232+
"Failed to terminate Vast.ai instance %s. Terminate it manually to avoid accumulating charges. Error: %r",
233+
instance_id,
234+
e,
235+
)
236+
raise
237+
logger.error(
238+
"Failed to terminate Vast.ai instance %s after %s attempts hit rate limits. Terminate it manually to avoid accumulating charges",
239+
instance_id,
240+
retries,
241+
)
242+
return False

src/tests/_internal/core/backends/vastai/test_compute.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ def _requirements() -> Requirements:
2323
return Requirements(resources=ResourcesSpec())
2424

2525

26-
def _offer(*, spot: bool, price: float = 0.5) -> InstanceOfferWithAvailability:
26+
def _offer(
27+
*, spot: bool, price: float = 0.5, min_bid: float | None = None
28+
) -> InstanceOfferWithAvailability:
2729
return InstanceOfferWithAvailability(
2830
backend=BackendType.VASTAI,
2931
instance=InstanceType(
@@ -39,6 +41,9 @@ def _offer(*, spot: bool, price: float = 0.5) -> InstanceOfferWithAvailability:
3941
region="Hong Kong, HK",
4042
price=price,
4143
availability=InstanceAvailability.AVAILABLE,
44+
backend_data={
45+
**({"min_bid": min_bid} if min_bid is not None else {}),
46+
},
4247
)
4348

4449

@@ -115,17 +120,17 @@ def test_vastai_compute_can_disable_community_cloud():
115120
def test_vastai_run_job_bids_on_spot_offer():
116121
compute = VastAICompute(_config())
117122
compute.api_client = MagicMock()
118-
compute.api_client.create_instance.return_value = {"new_contract": 123}
123+
compute.api_client.create_instance.return_value = 123
119124

120-
_run_job(compute, _offer(spot=True, price=0.1244444))
125+
_run_job(compute, _offer(spot=True, price=0.14, min_bid=0.1244444))
121126

122127
assert compute.api_client.create_instance.call_args.kwargs["bid"] == 0.1244444
123128

124129

125130
def test_vastai_run_job_does_not_bid_on_ondemand_offer():
126131
compute = VastAICompute(_config())
127132
compute.api_client = MagicMock()
128-
compute.api_client.create_instance.return_value = {"new_contract": 123}
133+
compute.api_client.create_instance.return_value = 123
129134

130135
_run_job(compute, _offer(spot=False, price=0.24))
131136

0 commit comments

Comments
 (0)