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/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"]
@@ -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