Skip to content

Commit 44eb6fc

Browse files
authored
feat: --gpu flag to steer offer search toward specific hardware (#2)
* feat: --gpu flag to steer offer search toward specific hardware Add a repeatable, case-insensitive substring filter on GPU name to `aiod spin` and `aiod estimate`, e.g. `--gpu rtx6000` to target RTX 6000Ada / A6000 cards. - vast: fetch a wider offer pool, filter by normalised name substring, trim to limit (vast's gpu_name API filter is exact-match only). - runpod: filter gpu types by displayName substring. - spin: "no offer found matching --gpu ..." when the filter empties results. * fix: show real rented GPU in estimate table + smarter --gpu token matching The estimate table showed the sizing tier (e.g. 'A100 80GB') instead of the actual matched offer, so --gpu looked broken even when it matched. Show offer.gpu_name/num_gpus instead. Matching is now token-AND within a query (whitespace-split): --gpu 'rtx 6000' matches 'RTX PRO 6000 WS'. Shared gpu_name_matches() used by vast + runpod.
1 parent f15520a commit 44eb6fc

3 files changed

Lines changed: 48 additions & 11 deletions

File tree

aiod/cli.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,9 @@ def estimate(
233233
context: int = typer.Option(None, "--context", help="Context length for the KV estimate"),
234234
concurrency: int = typer.Option(4, "--concurrency", help="Concurrent sequences for KV estimate"),
235235
max_price: float = typer.Option(None, "--max-price", help="Cap $/hr in the offer search"),
236+
gpu: list[str] = typer.Option(
237+
None, "--gpu", help="Only consider GPUs whose name contains this (repeatable), e.g. --gpu 'rtx 6000'"
238+
),
236239
):
237240
"""Size a model from its HuggingFace link and show live provider cost ($0)."""
238241
provider = provider.lower()
@@ -274,10 +277,11 @@ def estimate(
274277
with console.status(f"Querying live {provider} offers..."):
275278
for p in sizing.plans:
276279
disk = recommend_disk_gb(p.weights_gb)
277-
priced = client.price_plan(p, disk, max_price=max_p)
280+
priced = client.price_plan(p, disk, max_price=max_p, gpu_match=gpu or None)
278281
best = _pick_cheapest(priced)
279282
if best:
280-
fit = f"{best.option.num_gpus}x {best.option.tier.name}"
283+
# Show the real rented card, not the sizing tier proxy.
284+
fit = f"{best.offer.num_gpus}x {best.offer.gpu_name}"
281285
hr = f"${best.offer.dph_total:.2f}"
282286
four = f"${best.offer.dph_total * 4:.2f}"
283287
else:
@@ -306,6 +310,9 @@ def spin(
306310
None, "--quant", "-q", help="vLLM: bf16/fp8/awq-int4 · GGUF: a repo quant tag"
307311
),
308312
max_price: float = typer.Option(None, "--max-price", help="Hard cap $/hr (default from .env)"),
313+
gpu: list[str] = typer.Option(
314+
None, "--gpu", help="Only rent GPUs whose name contains this (repeatable), e.g. --gpu 'rtx 6000'"
315+
),
309316
ttl: float = typer.Option(None, "--ttl", help="Auto-destroy reminder window, hours"),
310317
idle: int = typer.Option(
311318
None, "--idle", help="Auto-shutdown after N idle minutes (starts a local watcher)"
@@ -388,12 +395,13 @@ def spin(
388395

389396
with client_cm as client:
390397
with console.status("Finding the cheapest GPU that fits..."):
391-
priced = client.price_plan(plan, disk, max_price=max_p)
398+
priced = client.price_plan(plan, disk, max_price=max_p, gpu_match=gpu or None)
392399
best = _pick_cheapest(priced)
393400
if not best:
401+
gpu_note = f" matching --gpu {' '.join(gpu)}" if gpu else ""
394402
console.print(
395403
f"[red]No {provider} offer found[/] under ${max_p:.2f}/hr for {m.repo_id} "
396-
f"({quant}). Try a higher --max-price or a smaller quant."
404+
f"({quant}){gpu_note}. Try a higher --max-price or a smaller quant."
397405
)
398406
raise typer.Exit(1)
399407

aiod/runpod.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
from .bootstrap import ServerConfig
2727
from .sizing import QuantPlan
28-
from .vast import PricedOption
28+
from .vast import PricedOption, gpu_name_matches
2929

3030
REST = "https://rest.runpod.io/v1"
3131
GRAPHQL = "https://api.runpod.io/graphql"
@@ -94,13 +94,20 @@ def _fetch_gpu_types(self) -> list[dict]:
9494
self._gpu_types = data.get("data", {}).get("gpuTypes", []) or []
9595
return self._gpu_types
9696

97-
def _cheapest_gpu_for(self, vram_gb: float) -> dict | None:
97+
def _cheapest_gpu_for(
98+
self, vram_gb: float, gpu_match: list[str] | None = None
99+
) -> dict | None:
98100
"""Cheapest SECURE gpu type in the same VRAM class as `vram_gb`."""
101+
queries = [g for g in (gpu_match or []) if g.strip()]
99102
cands: list[tuple[float, dict]] = []
100103
for g in self._fetch_gpu_types():
101104
mem, price = g.get("memoryInGb"), g.get("securePrice")
102105
if not mem or not price or not g.get("secureCloud"):
103106
continue
107+
if queries:
108+
name = str(g.get("displayName", g.get("id", "")))
109+
if not gpu_name_matches(name, queries):
110+
continue
104111
if vram_gb * 0.95 <= mem <= vram_gb * 1.6:
105112
cands.append((price, g))
106113
if not cands:
@@ -109,14 +116,14 @@ def _cheapest_gpu_for(self, vram_gb: float) -> dict | None:
109116

110117
def price_plan(
111118
self, plan: QuantPlan, min_disk_gb: float, max_price: float | None = None,
112-
max_candidates: int = 3,
119+
max_candidates: int = 3, gpu_match: list[str] | None = None,
113120
) -> list[PricedOption]:
114121
"""Price the few least-wasteful configs that fit (mirrors the vast backend;
115122
all pricing comes from one cached GraphQL call, so this is cheap)."""
116123
opts = sorted((o for o in plan.options if o.fits), key=lambda o: o.total_vram_gb)
117124
priced: list[PricedOption] = []
118125
for opt in opts[:max_candidates]:
119-
g = self._cheapest_gpu_for(opt.tier.vram_gb)
126+
g = self._cheapest_gpu_for(opt.tier.vram_gb, gpu_match=gpu_match)
120127
offer = None
121128
if g:
122129
total = g["securePrice"] * opt.num_gpus

aiod/vast.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@ class VastError(Exception):
3636
pass
3737

3838

39+
def gpu_name_matches(name: str, queries: list[str]) -> bool:
40+
"""True if `name` matches any --gpu query. A query matches when ALL its
41+
whitespace-separated tokens appear (case-insensitively) in the GPU name, so
42+
`rtx 6000` matches "RTX PRO 6000 WS" and `a6000` matches "RTX A6000". A single
43+
glued token like `rtx6000` only matches a contiguous run, so prefer spaces."""
44+
n = name.lower()
45+
for q in queries:
46+
toks = q.lower().split()
47+
if toks and all(t in n for t in toks):
48+
return True
49+
return False
50+
51+
3952
@dataclass
4053
class Offer:
4154
id: int
@@ -115,6 +128,7 @@ def search_offers(
115128
min_compute_cap: int = 800,
116129
min_inet_mbps: int = 400,
117130
gpu_names: list[str] | None = None,
131+
gpu_match: list[str] | None = None,
118132
limit: int = 30,
119133
) -> list[Offer]:
120134
query: dict = {
@@ -137,16 +151,22 @@ def search_offers(
137151
"inet_down": {"gte": min_inet_mbps},
138152
"type": "ondemand",
139153
"order": [["dph_total", "asc"]],
140-
"limit": limit,
154+
# When substring-filtering by GPU name we fetch a wider pool and trim
155+
# client-side, since vast's gpu_name filter only does exact matches.
156+
"limit": max(limit, 50) if gpu_match else limit,
141157
}
142158
if max_price is not None:
143159
query["dph_total"] = {"lte": float(max_price)}
144160
if gpu_names:
145161
query["gpu_name"] = {"in": gpu_names}
146162

147163
data = self._post("/api/v0/bundles/", query)
148-
offers = data.get("offers", []) or []
149-
return [self._parse_offer(o) for o in offers]
164+
offers = [self._parse_offer(o) for o in (data.get("offers", []) or [])]
165+
if gpu_match:
166+
queries = [g for g in gpu_match if g.strip()]
167+
offers = [o for o in offers if gpu_name_matches(o.gpu_name, queries)]
168+
offers = offers[:limit]
169+
return offers
150170

151171
@staticmethod
152172
def _parse_offer(o: dict) -> Offer:
@@ -271,6 +291,7 @@ def price_plan(
271291
min_disk_gb: float,
272292
max_price: float | None = None,
273293
max_candidates: int = 3,
294+
gpu_match: list[str] | None = None,
274295
) -> list[PricedOption]:
275296
"""Price the few least-wasteful GPU configs that fit and return them all
276297
(the caller picks the cheapest by actual price). We sort fitting configs by
@@ -298,6 +319,7 @@ def price_plan(
298319
max_price=max_price,
299320
min_compute_cap=min_cc,
300321
min_inet_mbps=min_inet,
322+
gpu_match=gpu_match,
301323
limit=10,
302324
)
303325
priced.append(PricedOption(option=opt, offer=offers[0] if offers else None))

0 commit comments

Comments
 (0)