gipper 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gipper/__init__.py +2 -0
- gipper/billing.py +85 -0
- gipper/bootstrap.sh +25 -0
- gipper/cli.py +216 -0
- gipper/config.py +91 -0
- gipper/costs.py +125 -0
- gipper/cuda_probe.py +78 -0
- gipper/history.py +232 -0
- gipper/http.py +40 -0
- gipper/job.py +153 -0
- gipper/latency.py +50 -0
- gipper/ledger.py +68 -0
- gipper/lifecycle.py +60 -0
- gipper/market.py +179 -0
- gipper/network_probe.py +78 -0
- gipper/placement.py +211 -0
- gipper/providers/__init__.py +50 -0
- gipper/providers/runpod.py +225 -0
- gipper/providers/shadeform.py +143 -0
- gipper/providers/vast.py +170 -0
- gipper/reap.py +95 -0
- gipper/run.py +358 -0
- gipper/runpod/__init__.py +2 -0
- gipper/runpod/api.py +10 -0
- gipper/runpod/run.py +24 -0
- gipper/ssh.py +82 -0
- gipper-0.3.0.dist-info/METADATA +275 -0
- gipper-0.3.0.dist-info/RECORD +32 -0
- gipper-0.3.0.dist-info/WHEEL +5 -0
- gipper-0.3.0.dist-info/entry_points.txt +2 -0
- gipper-0.3.0.dist-info/licenses/LICENSE +21 -0
- gipper-0.3.0.dist-info/top_level.txt +1 -0
gipper/market.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Shopping across providers: every market answers with the same Offer, and one
|
|
2
|
+
ranking decides: the job's GPU preference first, then the whole-run score from
|
|
3
|
+
gipper.costs (expected dollars, the chance of a bad host, time), then known stock.
|
|
4
|
+
|
|
5
|
+
GPU names differ by provider ("NVIDIA GeForce RTX 4090", "RTX 4090", "RTX4090"),
|
|
6
|
+
so preferences match on a normalised name. Regions are ISO country codes ("US",
|
|
7
|
+
"CA"); each provider maps its own locations onto them.
|
|
8
|
+
"""
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from gipper.config import settings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Offer:
|
|
17
|
+
provider: str
|
|
18
|
+
gpu: str # the provider's own name for the GPU
|
|
19
|
+
mem_gb: float
|
|
20
|
+
usd_h: float
|
|
21
|
+
where: str = "" # the provider's location ("EUR-IS-2", "Michigan, US", "desmoines-usa-1")
|
|
22
|
+
country: str | None = None
|
|
23
|
+
stock: str | None = None # High/Medium/Low, "yes" for a specific machine, None if unknown
|
|
24
|
+
udp: bool = False # can expose UDP ports
|
|
25
|
+
ref: dict = field(default_factory=dict) # what the provider needs to rent it
|
|
26
|
+
metrics: dict = field(default_factory=dict) # what the provider advertises (speeds, fees, CUDA); missing stays missing
|
|
27
|
+
estimate: dict = field(default_factory=dict) # gipper.costs.estimate for the job being shopped
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def label(self):
|
|
31
|
+
return f"{self.provider}:{self.gpu}"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def placement_estimate(self):
|
|
35
|
+
"""0.1's name for `estimate`."""
|
|
36
|
+
return self.estimate
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_NOISE = re.compile(r"nvidia|geforce|generation|pcie|sxm\d?|[^a-z0-9]")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def norm(name):
|
|
43
|
+
"""'NVIDIA GeForce RTX 4090' -> 'rtx4090' -> '4090'; 'RTX6000Ada' -> '6000ada'."""
|
|
44
|
+
n = re.sub(r"(\d)gb\b", r"\1g", name.lower()) # "A100 80GB" and "A100_80G" are one card
|
|
45
|
+
n = _NOISE.sub("", n)
|
|
46
|
+
return n[3:] if n.startswith("rtx") and len(n) > 3 else n
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def matches(pref, gpu):
|
|
50
|
+
"""Same card, or the same card with a memory size ("A100" matches "A100 80GB");
|
|
51
|
+
a variant is another card ("4090" does not match "4090D", "L40" not "L40S")."""
|
|
52
|
+
p, g = norm(pref), norm(gpu)
|
|
53
|
+
return bool(p) and (g == p or (g.startswith(p) and g[len(p)].isdigit()))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def wants_udp(ports):
|
|
57
|
+
return any(p.lower().endswith("/udp") for p in ports)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def admit(offers, *, min_mem_gb=0, regions=(), ports=(), placement=None):
|
|
61
|
+
"""Drop what can't serve the job: too little memory, outside its regions, no UDP,
|
|
62
|
+
or below a [placement] floor the provider advertises. A floor on a figure the
|
|
63
|
+
provider doesn't advertise passes here and is checked on the machine itself."""
|
|
64
|
+
regions = {r.upper() for r in regions}
|
|
65
|
+
from gipper.placement import distance_km, rtt_ms
|
|
66
|
+
floors = [] if placement is None else [
|
|
67
|
+
("host_download_mbps", placement.min_host_download_mbps), ("host_upload_mbps", placement.min_host_upload_mbps),
|
|
68
|
+
("cuda_max_good", placement.min_cuda), ("compute_capability", placement.min_compute_capability),
|
|
69
|
+
("dlperf", placement.min_dlperf)]
|
|
70
|
+
out = []
|
|
71
|
+
for o in offers:
|
|
72
|
+
if o.mem_gb < min_mem_gb:
|
|
73
|
+
continue
|
|
74
|
+
if regions and (o.country or "").upper() not in regions:
|
|
75
|
+
continue
|
|
76
|
+
if wants_udp(ports) and not o.udp:
|
|
77
|
+
continue
|
|
78
|
+
if any(floor is not None and o.metrics.get(k) is not None and o.metrics[k] < floor for k, floor in floors):
|
|
79
|
+
continue
|
|
80
|
+
if placement is not None and placement.max_rtt_ms is not None: # a limit on what wasn't measured fails
|
|
81
|
+
rtt = rtt_ms(o, placement)
|
|
82
|
+
if rtt is None or rtt > placement.max_rtt_ms:
|
|
83
|
+
continue
|
|
84
|
+
if placement is not None and placement.max_distance_km is not None:
|
|
85
|
+
km = distance_km(o, placement)
|
|
86
|
+
if km is None or km > placement.max_distance_km:
|
|
87
|
+
continue
|
|
88
|
+
out.append(o)
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def rank(offers, prefer=(), providers=(), *, job=None, history=None, throughput=None):
|
|
93
|
+
"""Preferred GPUs first (in the job's order), then offers known to be in stock
|
|
94
|
+
(Runpod's types without stock mostly fail to rent), then the lowest whole-run
|
|
95
|
+
score (needs the job), then the provider order in settings."""
|
|
96
|
+
def pref_rank(o):
|
|
97
|
+
return next((i for i, p in enumerate(prefer) if matches(p, o.gpu)), len(prefer))
|
|
98
|
+
prov = {p: i for i, p in enumerate(providers)}
|
|
99
|
+
if job is not None:
|
|
100
|
+
from gipper.costs import estimate
|
|
101
|
+
from gipper.history import History
|
|
102
|
+
history = history or History.load()
|
|
103
|
+
for o in offers:
|
|
104
|
+
o.estimate = estimate(job, o, history, throughput=throughput)
|
|
105
|
+
if job is not None and job.placement.objective == "latency":
|
|
106
|
+
from gipper.placement import distance_km
|
|
107
|
+
p = job.placement
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def near(o): # best round-trip figure, then preferred country, then distance
|
|
111
|
+
rtt = o.estimate.get("rtt_ms")
|
|
112
|
+
country = p.preferred_countries.index(o.country) if o.country in p.preferred_countries else len(p.preferred_countries)
|
|
113
|
+
km = distance_km(o, p)
|
|
114
|
+
return (rtt is None, rtt if rtt is not None else 0, country, km if km is not None else float("inf"))
|
|
115
|
+
return sorted(offers, key=lambda o: (pref_rank(o), o.stock is None, *near(o), o.estimate.get("score", o.usd_h),
|
|
116
|
+
prov.get(o.provider, len(prov))))
|
|
117
|
+
return sorted(offers, key=lambda o: (pref_rank(o), o.stock is None, o.estimate.get("score", o.usd_h),
|
|
118
|
+
prov.get(o.provider, len(prov))))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def ceiling(offers, job_max=None, prefer=()):
|
|
122
|
+
"""0.1's hourly ceiling (2x the cheapest stocked preferred offer); kept for callers
|
|
123
|
+
that print it. Ranking no longer needs it: the score already prices the run."""
|
|
124
|
+
if job_max:
|
|
125
|
+
return job_max
|
|
126
|
+
pool = [o for o in offers if any(matches(p, o.gpu) for p in prefer)] or offers
|
|
127
|
+
stocked = [o.usd_h for o in pool if o.stock] or [o.usd_h for o in pool]
|
|
128
|
+
return 2 * min(stocked) if stocked else 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def shop(job, *, names=None, keyed_only=True, history=None, throughput=None):
|
|
132
|
+
"""Offers that can serve the job from every usable provider, ranked, plus notes on
|
|
133
|
+
providers that were skipped or failed (one market being down never stops another)."""
|
|
134
|
+
from gipper.providers import load
|
|
135
|
+
s = settings()
|
|
136
|
+
names = names or job.providers or s["providers"]
|
|
137
|
+
if job.volume: # network volumes are Runpod's
|
|
138
|
+
names = [n for n in names if n == "runpod"]
|
|
139
|
+
offers, notes = [], []
|
|
140
|
+
for name in names:
|
|
141
|
+
try:
|
|
142
|
+
p = load(name)
|
|
143
|
+
except KeyError as e:
|
|
144
|
+
notes.append(str(e))
|
|
145
|
+
continue
|
|
146
|
+
if keyed_only and not p.has_key():
|
|
147
|
+
notes.append(f"{name}: no {p.key_name}; skipped")
|
|
148
|
+
continue
|
|
149
|
+
if wants_udp(job.ports) and not p.udp:
|
|
150
|
+
notes.append(f"{name}: no UDP; skipped")
|
|
151
|
+
continue
|
|
152
|
+
try:
|
|
153
|
+
offers += p.offers(job)
|
|
154
|
+
except (Exception, SystemExit) as e: # one market's outage (or missing key) must not stop the others
|
|
155
|
+
notes.append(f"{name}: {e}")
|
|
156
|
+
if job.placement.probe_rtt:
|
|
157
|
+
notes += probe_rtt(admit(offers, min_mem_gb=job.min_mem_gb, regions=job.regions, ports=job.ports))
|
|
158
|
+
offers = admit(offers, min_mem_gb=job.min_mem_gb, regions=job.regions, ports=job.ports, placement=job.placement)
|
|
159
|
+
return rank(offers, job.prefer, names, job=job, history=history, throughput=throughput), notes
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
PROBE_LIMIT = 48 # hosts pinged per shop, cheapest first
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def probe_rtt(offers):
|
|
166
|
+
"""Ping the cheapest offers' hosts from here before renting, into
|
|
167
|
+
metrics["rtt_ms_here"], and remember the readings (a `market.rtt` receipt)."""
|
|
168
|
+
from gipper import ledger
|
|
169
|
+
from gipper.latency import HERE, ping_all
|
|
170
|
+
pick = sorted((o for o in offers if o.metrics.get("public_ip")), key=lambda o: o.usd_h)[:PROBE_LIMIT]
|
|
171
|
+
if not pick:
|
|
172
|
+
return []
|
|
173
|
+
found = ping_all(o.metrics["public_ip"] for o in pick)
|
|
174
|
+
for o in pick:
|
|
175
|
+
o.metrics["rtt_ms_here"] = found.get(o.metrics["public_ip"])
|
|
176
|
+
samples = [{"provider": o.provider, "machine": o.ref.get("machine"), "country": o.country,
|
|
177
|
+
"ms": o.metrics["rtt_ms_here"]} for o in pick if o.metrics["rtt_ms_here"] is not None]
|
|
178
|
+
ledger.write("market.rtt", {"origin": HERE, "samples": samples})
|
|
179
|
+
return [f"pinged {len(pick)} host(s) from {HERE}: {len(samples)} answered"]
|
gipper/network_probe.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Small opt-in private SSH transfer probe; no public speed-test endpoint.
|
|
2
|
+
|
|
3
|
+
subprocess.run owns, kills and waits for its SSH child on timeout. No pipeline
|
|
4
|
+
children are created. Timings include SSH setup, encryption and local disk I/O;
|
|
5
|
+
these are path goodput observations, never RTT measurements.
|
|
6
|
+
"""
|
|
7
|
+
import hashlib
|
|
8
|
+
import os
|
|
9
|
+
import shlex
|
|
10
|
+
import socket
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def probe(remote, profile):
|
|
19
|
+
size = int(profile.probe_transfer_mb * 1024**2) # configuration MB is explicitly MiB
|
|
20
|
+
start = time.monotonic()
|
|
21
|
+
deadline = start + profile.probe_timeout_s
|
|
22
|
+
cleanup_reserve = min(3.0, profile.probe_timeout_s / 4)
|
|
23
|
+
remote_path = '/tmp/gipper-network-probe-' + uuid.uuid4().hex
|
|
24
|
+
quoted = shlex.quote(remote_path)
|
|
25
|
+
receipt = {'origin_hostname': socket.gethostname(), 'destination_host': remote.host,
|
|
26
|
+
'destination_port': remote.port, 'bytes_per_direction': size,
|
|
27
|
+
'basis': 'random private bytes over Remote SSH; wall goodput includes SSH overhead, not RTT',
|
|
28
|
+
'status': 'error', 'upload_mbps': None, 'download_mbps': None,
|
|
29
|
+
'min_upload_mbps': profile.min_measured_upload_mbps,
|
|
30
|
+
'min_download_mbps': profile.min_measured_download_mbps}
|
|
31
|
+
def remaining():
|
|
32
|
+
value = deadline - time.monotonic() - cleanup_reserve
|
|
33
|
+
if value <= 0:
|
|
34
|
+
raise subprocess.TimeoutExpired('network probe', profile.probe_timeout_s)
|
|
35
|
+
return value
|
|
36
|
+
try:
|
|
37
|
+
with tempfile.TemporaryDirectory(prefix='gipper-network-probe-') as directory:
|
|
38
|
+
native, returned = Path(directory)/'random', Path(directory)/'returned'
|
|
39
|
+
native.write_bytes(os.urandom(size))
|
|
40
|
+
with native.open('rb') as src:
|
|
41
|
+
t = time.monotonic()
|
|
42
|
+
result = subprocess.run(remote._base() + [f"umask 077; trap 'rm -f {quoted}' HUP INT TERM; cat > {quoted}"],
|
|
43
|
+
stdin=src, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=remaining())
|
|
44
|
+
elapsed = time.monotonic() - t
|
|
45
|
+
if result.returncode:
|
|
46
|
+
raise RuntimeError('probe upload SSH failed')
|
|
47
|
+
receipt.update(upload_s=elapsed, upload_mbps=size*8/1e6/max(elapsed,1e-9))
|
|
48
|
+
with returned.open('wb') as dst:
|
|
49
|
+
t = time.monotonic()
|
|
50
|
+
result = subprocess.run(remote._base() + [f"trap 'rm -f {quoted}' EXIT HUP INT TERM; cat {quoted}"],
|
|
51
|
+
stdout=dst, stderr=subprocess.PIPE, timeout=remaining())
|
|
52
|
+
elapsed = time.monotonic() - t
|
|
53
|
+
if result.returncode:
|
|
54
|
+
raise RuntimeError('probe download SSH failed')
|
|
55
|
+
if returned.stat().st_size != size or hashlib.sha256(native.read_bytes()).digest() != hashlib.sha256(returned.read_bytes()).digest():
|
|
56
|
+
raise RuntimeError('probe roundtrip bytes failed integrity check')
|
|
57
|
+
receipt.update(download_s=elapsed, download_mbps=size*8/1e6/max(elapsed,1e-9))
|
|
58
|
+
failures = [f'{direction} below configured minimum' for direction in ('upload','download')
|
|
59
|
+
if (getattr(profile, f'min_measured_{direction}_mbps') is not None
|
|
60
|
+
and receipt[f'{direction}_mbps'] < getattr(profile, f'min_measured_{direction}_mbps'))]
|
|
61
|
+
receipt['status'] = 'rejected' if failures else 'ok'
|
|
62
|
+
if failures:
|
|
63
|
+
receipt['error'] = '; '.join(failures)
|
|
64
|
+
except (OSError, subprocess.SubprocessError, RuntimeError) as error:
|
|
65
|
+
receipt['error'] = f'{type(error).__name__}: {error}'
|
|
66
|
+
finally:
|
|
67
|
+
# Best effort within the SAME total deadline; rental teardown is separate.
|
|
68
|
+
budget = deadline - time.monotonic()
|
|
69
|
+
if budget > 0:
|
|
70
|
+
try:
|
|
71
|
+
result = remote.run(f'rm -f {quoted}', check=False, timeout=budget)
|
|
72
|
+
receipt['remote_cleanup_confirmed'] = result.returncode == 0
|
|
73
|
+
except (OSError, subprocess.SubprocessError, RuntimeError):
|
|
74
|
+
receipt['remote_cleanup_confirmed'] = False
|
|
75
|
+
else:
|
|
76
|
+
receipt['remote_cleanup_confirmed'] = False
|
|
77
|
+
receipt['wall_s'] = time.monotonic() - start
|
|
78
|
+
return receipt
|
gipper/placement.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""What a job tells gipper about itself so offers can be judged on the whole run,
|
|
2
|
+
not the hourly price: `[placement]` in the job file. Everything is optional;
|
|
3
|
+
whatever is missing comes from memory (gipper.history) or a stated default.
|
|
4
|
+
|
|
5
|
+
[placement]
|
|
6
|
+
objective = "auto" # auto | cost | time (see docs/placement.md)
|
|
7
|
+
compute_s = 550 # expected compute seconds once inputs are up
|
|
8
|
+
compute_s_by_gpu = { A40 = 250 } # per GPU, where known
|
|
9
|
+
setup_s = 120 # the job's own setup, excluding the downloads below
|
|
10
|
+
setup_download_gb = 8.5 # what the job fetches itself (weights, wheels)
|
|
11
|
+
upload_gb = 0.1 # default: the size of [[inputs]]
|
|
12
|
+
download_gb = 0.2 # outputs coming back
|
|
13
|
+
transfer_efficiency = 0.5 # share of a host's advertised speed to expect
|
|
14
|
+
min_host_download_mbps = 500 # hard filters on what the provider advertises
|
|
15
|
+
min_host_upload_mbps = 100
|
|
16
|
+
min_cuda = 12.8
|
|
17
|
+
min_compute_capability = 8.0
|
|
18
|
+
probe_transfer_mb = 2 # measure the ssh path before uploading (MiB each way)
|
|
19
|
+
probe_timeout_s = 120
|
|
20
|
+
min_measured_upload_mbps = 1
|
|
21
|
+
min_measured_download_mbps = 1
|
|
22
|
+
min_dlperf = 30 # Vast's generic DLPerf score: a filter, never a speed
|
|
23
|
+
client_upload_mbps = 40 # this end's link, if it's the bottleneck
|
|
24
|
+
client_download_mbps = 300
|
|
25
|
+
fallback_mbps = 100 # a link nobody has advertised or measured
|
|
26
|
+
|
|
27
|
+
Real-time work (`profile = "interactive"`, objective "latency") ranks by measured
|
|
28
|
+
round trip to where the user is, then preferred country, then distance, then the
|
|
29
|
+
usual score:
|
|
30
|
+
|
|
31
|
+
profile = "interactive"
|
|
32
|
+
latency_target = "interactive-client" # names the client the RTTs were measured from;
|
|
33
|
+
# "here" means this machine, so its pings count
|
|
34
|
+
measured_rtt_ms_by_machine = { "vast:machine:12345" = 28 }
|
|
35
|
+
max_rtt_ms = 50 # needs a measurement: unmeasured offers fail it
|
|
36
|
+
probe_rtt = true # ping candidates before renting (default for interactive)
|
|
37
|
+
preferred_countries = ["CA", "US"]
|
|
38
|
+
near_latitude = 43.65 # with max_distance_km: offers without
|
|
39
|
+
near_longitude = -79.38 # coordinates fail it
|
|
40
|
+
max_distance_km = 1500
|
|
41
|
+
"""
|
|
42
|
+
import fnmatch
|
|
43
|
+
import math
|
|
44
|
+
from dataclasses import dataclass, field, fields
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
OBJECTIVES = ("auto", "cost", "time", "latency")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Placement:
|
|
52
|
+
objective: str = "auto"
|
|
53
|
+
profile: str = "batch" # batch | interactive
|
|
54
|
+
compute_s: float | None = None
|
|
55
|
+
compute_s_by_gpu: dict = field(default_factory=dict)
|
|
56
|
+
setup_s: float | None = None
|
|
57
|
+
setup_download_gb: float = 0.0
|
|
58
|
+
upload_gb: float | None = None
|
|
59
|
+
download_gb: float = 0.0
|
|
60
|
+
transfer_efficiency: float = 0.5
|
|
61
|
+
min_host_download_mbps: float | None = None
|
|
62
|
+
min_host_upload_mbps: float | None = None
|
|
63
|
+
min_cuda: float | None = None
|
|
64
|
+
min_compute_capability: float | None = None
|
|
65
|
+
probe_transfer_mb: float = 0.0
|
|
66
|
+
probe_timeout_s: float = 120.0
|
|
67
|
+
min_measured_upload_mbps: float | None = None
|
|
68
|
+
min_measured_download_mbps: float | None = None
|
|
69
|
+
min_dlperf: float | None = None
|
|
70
|
+
client_upload_mbps: float | None = None
|
|
71
|
+
client_download_mbps: float | None = None
|
|
72
|
+
fallback_mbps: float = 100.0
|
|
73
|
+
preferred_countries: list = field(default_factory=list)
|
|
74
|
+
near_latitude: float | None = None
|
|
75
|
+
near_longitude: float | None = None
|
|
76
|
+
max_distance_km: float | None = None
|
|
77
|
+
max_rtt_ms: float | None = None
|
|
78
|
+
latency_target: str = ""
|
|
79
|
+
measured_rtt_ms_by_machine: dict = field(default_factory=dict)
|
|
80
|
+
probe_rtt: bool | None = None # default: on for interactive jobs
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _number(v):
|
|
84
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load_placement(data, *, error=ValueError):
|
|
88
|
+
"""The job's [placement] table (absent: all defaults), validated."""
|
|
89
|
+
data = dict(data or {})
|
|
90
|
+
unknown = set(data) - {f.name for f in fields(Placement)}
|
|
91
|
+
if unknown:
|
|
92
|
+
raise error(f"[placement] has unknown fields: {', '.join(sorted(unknown))} (see gipper/placement.py)")
|
|
93
|
+
if data.get("profile") == "interactive":
|
|
94
|
+
data.setdefault("objective", "latency")
|
|
95
|
+
data.setdefault("probe_rtt", True)
|
|
96
|
+
p = Placement(**data)
|
|
97
|
+
if p.objective not in OBJECTIVES or p.profile not in ("batch", "interactive"):
|
|
98
|
+
raise error(f"placement.profile is batch or interactive; objective one of {', '.join(OBJECTIVES)}")
|
|
99
|
+
if (p.objective == "latency") != (p.profile == "interactive"):
|
|
100
|
+
raise error('placement: objective "latency" goes with profile "interactive", and only with it')
|
|
101
|
+
for name in ("compute_s", "setup_s", "setup_download_gb", "upload_gb", "download_gb", "min_host_download_mbps",
|
|
102
|
+
"min_host_upload_mbps", "min_cuda", "min_compute_capability", "probe_transfer_mb", "probe_timeout_s",
|
|
103
|
+
"min_measured_upload_mbps", "min_measured_download_mbps", "min_dlperf", "client_upload_mbps",
|
|
104
|
+
"client_download_mbps", "fallback_mbps", "max_distance_km", "max_rtt_ms"):
|
|
105
|
+
v = getattr(p, name)
|
|
106
|
+
if v is not None and (not _number(v) or v < 0):
|
|
107
|
+
raise error(f"placement.{name} must be a non-negative number")
|
|
108
|
+
if not (_number(p.transfer_efficiency) and 0 < p.transfer_efficiency <= 1):
|
|
109
|
+
raise error("placement.transfer_efficiency must be in (0, 1]")
|
|
110
|
+
if not isinstance(p.compute_s_by_gpu, dict) or not all(isinstance(k, str) and _number(v) and v > 0
|
|
111
|
+
for k, v in p.compute_s_by_gpu.items()):
|
|
112
|
+
raise error("placement.compute_s_by_gpu maps GPU names to seconds")
|
|
113
|
+
if p.probe_transfer_mb and not 1 <= p.probe_transfer_mb <= 64 or p.probe_timeout_s > 300:
|
|
114
|
+
raise error("placement.probe_transfer_mb is 0 (off) or 1-64 MiB; probe_timeout_s at most 300")
|
|
115
|
+
if (p.min_measured_upload_mbps or p.min_measured_download_mbps) and not p.probe_transfer_mb:
|
|
116
|
+
raise error("placement.min_measured_*_mbps need probe_transfer_mb")
|
|
117
|
+
if not p.fallback_mbps:
|
|
118
|
+
raise error("placement.fallback_mbps must be positive")
|
|
119
|
+
if not isinstance(p.measured_rtt_ms_by_machine, dict) or not all(
|
|
120
|
+
isinstance(k, str) and _number(v) and v > 0 for k, v in p.measured_rtt_ms_by_machine.items()):
|
|
121
|
+
raise error('placement.measured_rtt_ms_by_machine maps "provider:machine:id" to milliseconds')
|
|
122
|
+
if not isinstance(p.preferred_countries, list) or not all(isinstance(c, str) and len(c) == 2
|
|
123
|
+
for c in p.preferred_countries):
|
|
124
|
+
raise error("placement.preferred_countries is a list of ISO country codes")
|
|
125
|
+
p.preferred_countries = [c.upper() for c in p.preferred_countries]
|
|
126
|
+
coords = (p.near_latitude, p.near_longitude)
|
|
127
|
+
if any(v is not None for v in coords) and not (all(_number(v) for v in coords)
|
|
128
|
+
and abs(coords[0]) <= 90 and abs(coords[1]) <= 180):
|
|
129
|
+
raise error("placement needs a valid near_latitude and near_longitude pair")
|
|
130
|
+
if p.max_distance_km is not None and p.near_latitude is None:
|
|
131
|
+
raise error("placement.max_distance_km needs near_latitude and near_longitude")
|
|
132
|
+
if (p.max_rtt_ms is not None or p.measured_rtt_ms_by_machine) and not p.latency_target:
|
|
133
|
+
raise error("placement: RTT measurements need latency_target, naming where they were measured from")
|
|
134
|
+
return p
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def distance_km(offer, p):
|
|
138
|
+
"""Great-circle distance from the job's near_* point to the offer, if both are known."""
|
|
139
|
+
lat, lon = offer.metrics.get("latitude"), offer.metrics.get("longitude")
|
|
140
|
+
if p.near_latitude is None or not (_number(lat) and _number(lon)):
|
|
141
|
+
return None
|
|
142
|
+
a, b, c, d = map(math.radians, (p.near_latitude, p.near_longitude, lat, lon))
|
|
143
|
+
h = math.sin((c - a) / 2) ** 2 + math.cos(a) * math.cos(c) * math.sin((d - b) / 2) ** 2
|
|
144
|
+
return 6371.0088 * 2 * math.asin(min(1, math.sqrt(h)))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def target_is_here(p):
|
|
148
|
+
from gipper.latency import HERE
|
|
149
|
+
return p.latency_target in ("here", HERE)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def rtt_ms(offer, p):
|
|
153
|
+
"""A round trip that counts as measured from the job's latency_target: the job's
|
|
154
|
+
own measurement, or a ping from here when the target is here."""
|
|
155
|
+
machine = offer.ref.get("machine")
|
|
156
|
+
own = p.measured_rtt_ms_by_machine.get(f"{offer.provider}:machine:{machine}") if machine is not None else None
|
|
157
|
+
if own is not None:
|
|
158
|
+
return own
|
|
159
|
+
return offer.metrics.get("rtt_ms_here") if target_is_here(p) else None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def best_rtt(offer, p, history=None):
|
|
163
|
+
"""(ms, basis): the best round-trip figure there is for ranking. Measured from the
|
|
164
|
+
target first, then pinged from here just now, then memory of this machine, then
|
|
165
|
+
the median for its country, all from here."""
|
|
166
|
+
from gipper.latency import HERE
|
|
167
|
+
own = rtt_ms(offer, p)
|
|
168
|
+
if own is not None:
|
|
169
|
+
return own, "measured" if not target_is_here(p) or offer.metrics.get("rtt_ms_here") != own else "pinged from here"
|
|
170
|
+
if offer.metrics.get("rtt_ms_here") is not None:
|
|
171
|
+
return offer.metrics["rtt_ms_here"], "pinged from here (proxy)"
|
|
172
|
+
if history is not None:
|
|
173
|
+
v = history.rtt(offer.provider, offer.ref.get("machine"), HERE)
|
|
174
|
+
if v is not None:
|
|
175
|
+
return v, "memory of this machine"
|
|
176
|
+
v = history.country_rtt(offer.country, HERE)
|
|
177
|
+
if v is not None:
|
|
178
|
+
return v, f"memory of {offer.country}"
|
|
179
|
+
return None, "unknown"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def estimate_offer(job, offer, *, upload_gb=None):
|
|
183
|
+
"""0.1's name for the whole-run estimate (gipper.costs.estimate)."""
|
|
184
|
+
from gipper.costs import estimate
|
|
185
|
+
from gipper.history import History
|
|
186
|
+
if upload_gb is not None:
|
|
187
|
+
job.placement.upload_gb = upload_gb
|
|
188
|
+
return estimate(job, offer, History.load())
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def rank_for_job(offers, job, *, tie_key=None):
|
|
192
|
+
"""0.1's name for filtering and ranking offers for a job."""
|
|
193
|
+
from gipper.market import admit, rank
|
|
194
|
+
offers = admit(offers, min_mem_gb=job.min_mem_gb, regions=job.regions, ports=job.ports, placement=job.placement)
|
|
195
|
+
return rank(offers, job.prefer, job=job)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def input_gb(job):
|
|
199
|
+
"""Bytes the [[inputs]] will upload (decimal GB), honouring their excludes."""
|
|
200
|
+
size = 0
|
|
201
|
+
for t in job.inputs:
|
|
202
|
+
root = Path(t.src)
|
|
203
|
+
for path in (root.rglob("*") if root.is_dir() else [root]):
|
|
204
|
+
if path.is_symlink() or not path.is_file():
|
|
205
|
+
continue
|
|
206
|
+
rel = path.relative_to(root) if root.is_dir() else Path(path.name)
|
|
207
|
+
if any(fnmatch.fnmatch(str(rel), pat) or any(fnmatch.fnmatch(part, pat) for part in rel.parts)
|
|
208
|
+
for pat in t.exclude):
|
|
209
|
+
continue
|
|
210
|
+
size += path.stat().st_size
|
|
211
|
+
return size / 1e9
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""GPU markets gipper can rent from. Each provider answers the same questions:
|
|
2
|
+
|
|
3
|
+
offers(job) -> [Offer] that fit (memory, regions, ports)
|
|
4
|
+
create(offer, job, pubkey, name) -> {"id", "usd_h", "gpu"}; raises Unavailable if
|
|
5
|
+
the offer is gone, so the next one is tried
|
|
6
|
+
wait_ready(id, job) -> (Remote, {"machine", "gpu", "ports"}); raises
|
|
7
|
+
BadHost if the machine will never come up
|
|
8
|
+
terminate(id)
|
|
9
|
+
list_ours() -> [{"id", "name", "started"}] for names gipper-*
|
|
10
|
+
account() -> one line on balance and spend
|
|
11
|
+
billed(since) -> {id: (usd, detail)} actually charged, or None if
|
|
12
|
+
the provider can't say
|
|
13
|
+
|
|
14
|
+
Everything else (caps, upload, the detached run, checkpoints, teardown, receipts)
|
|
15
|
+
is the same for every provider and lives in gipper.run.
|
|
16
|
+
"""
|
|
17
|
+
from importlib import import_module
|
|
18
|
+
|
|
19
|
+
from gipper.config import secret
|
|
20
|
+
|
|
21
|
+
NAMES = ("runpod", "vast", "shadeform")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Unavailable(RuntimeError):
|
|
25
|
+
"""The offer was taken or the market has nothing free: try the next offer."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BadHost(RuntimeError):
|
|
29
|
+
"""The machine is busy, broken or never came up: end it and rent another."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Provider:
|
|
33
|
+
name = ""
|
|
34
|
+
key_name = ""
|
|
35
|
+
udp = False
|
|
36
|
+
|
|
37
|
+
def has_key(self):
|
|
38
|
+
return bool(secret(self.key_name, required=False))
|
|
39
|
+
|
|
40
|
+
def key(self):
|
|
41
|
+
return secret(self.key_name)
|
|
42
|
+
|
|
43
|
+
def billed(self, since):
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load(name):
|
|
48
|
+
if name not in NAMES:
|
|
49
|
+
raise KeyError(f"{name}: not a provider gipper knows ({', '.join(NAMES)})")
|
|
50
|
+
return import_module(f"gipper.providers.{name}").PROVIDER
|