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 ADDED
@@ -0,0 +1,2 @@
1
+ """gipper: GPU jobs across cloud markets."""
2
+ __version__ = "0.3.0"
gipper/billing.py ADDED
@@ -0,0 +1,85 @@
1
+ """What providers actually charged, beside what gipper computed.
2
+
3
+ gipper's own `spend` receipt is rate x lifetime, written at teardown. Bills differ:
4
+ Vast charges disk from creation and GPU only while running, rounds up short
5
+ rentals (a 47 s smoke run was billed 0.07 h) and charges bandwidth by the byte;
6
+ Runpod bills container disk alongside the GPU. `reconcile()` fetches each
7
+ provider's itemised charges for gipper's machines and records them as
8
+ `spend.billed`, which `spend_since()` (the daily cap) and gipper.history (the
9
+ billing factor used in estimates) then prefer.
10
+ """
11
+ import time
12
+ from collections import defaultdict
13
+ from datetime import datetime, timezone
14
+
15
+ from gipper import ledger
16
+ from gipper.config import settings
17
+ from gipper.providers import load
18
+
19
+
20
+ def _ours(since):
21
+ """(provider, id) -> job for every machine gipper created since `since`."""
22
+ return {((r["payload"].get("provider") or "runpod"), str(r["payload"]["pod"])): r["job"]
23
+ for r in ledger.read(since=since, kinds={"pod.create"}) if r["payload"].get("pod")}
24
+
25
+
26
+ def reconcile(days=7, providers=None):
27
+ """Write `spend.billed` for gipper's machines whose bill has grown. Returns
28
+ [(provider, id, usd)] written. Providers bill by the day, and a day's charges
29
+ grow until it closes, so the query starts at a UTC midnight with a day to
30
+ spare, and a bill is only recorded if it exceeds the last one: a window that
31
+ clipped a day can't replace a fuller bill. One provider's billing API failing
32
+ doesn't stop the others."""
33
+ since = (int(time.time() // 86400) - days - 1) * 86400
34
+ ours = _ours(since - 86400)
35
+ last = {}
36
+ for r in ledger.read(since=since - 31 * 86400, kinds={"spend.billed"}):
37
+ key = (r["payload"]["provider"], str(r["payload"]["pod"]))
38
+ last[key] = max(last.get(key, 0.0), r["payload"]["usd"])
39
+ written = []
40
+ for p in providers or [load(n) for n in settings()["providers"]]:
41
+ if not p.has_key():
42
+ continue
43
+ try:
44
+ bills = p.billed(since)
45
+ except Exception as e:
46
+ print(f"{p.name}: could not read bills: {e}")
47
+ continue
48
+ for iid, (usd, detail) in (bills or {}).items():
49
+ key = (p.name, str(iid))
50
+ if key not in ours or round(usd, 4) <= round(last.get(key, -1), 4):
51
+ continue
52
+ ledger.write("spend.billed", {"provider": p.name, "pod": str(iid), "usd": round(usd, 6), "detail": detail,
53
+ "basis": "provider charges"}, job=ours[key])
54
+ written.append((p.name, str(iid), usd))
55
+ return written
56
+
57
+
58
+ def spend_by_machine(since):
59
+ """(provider, id) -> usd for machines whose spend falls since `since`: the
60
+ largest bill where there is one, else gipper's computed spend. A machine's
61
+ spend is dated by its teardown (`spend` receipt), as the daily cap always
62
+ has been, or by its creation if it has no `spend` yet; a bill recorded days
63
+ later inherits that date."""
64
+ lookback = since - 31 * 86400 if since else None
65
+ created, ended, computed, billed = {}, {}, defaultdict(float), {}
66
+ for r in ledger.read(since=lookback, kinds={"pod.create", "spend", "spend.billed"}):
67
+ key = (r["payload"].get("provider") or "runpod", str(r["payload"].get("pod")))
68
+ if r["kind"] == "pod.create":
69
+ created[key] = r["ts"]
70
+ elif r["kind"] == "spend.billed":
71
+ billed[key] = max(billed.get(key, 0.0), r["payload"]["usd"])
72
+ else:
73
+ computed[key] += r["payload"].get("usd", 0.0)
74
+ ended[key] = r["ts"]
75
+ since_iso = datetime.fromtimestamp(since, timezone.utc).isoformat(timespec="seconds") if since else ""
76
+ out = {}
77
+ for key in set(computed) | set(billed):
78
+ when = ended.get(key) or created.get(key)
79
+ if when is not None and when >= since_iso:
80
+ out[key] = billed.get(key, computed.get(key, 0.0))
81
+ return out
82
+
83
+
84
+ def spend_since(since):
85
+ return sum(spend_by_machine(since).values())
gipper/bootstrap.sh ADDED
@@ -0,0 +1,25 @@
1
+ # Sourced at the start of every gipper job on a pod: tools jobs can rely on.
2
+ # Idempotent and quiet; anything heavier belongs in the job's own setup step.
3
+ export PATH="$HOME/.local/bin:$PATH"
4
+ export PIP_DISABLE_PIP_VERSION_CHECK=1
5
+ if ! command -v uv >/dev/null 2>&1; then
6
+ curl -LsSf https://astral.sh/uv/install.sh | sh >/dev/null 2>&1
7
+ fi
8
+ # Weights and environments persist on a network volume when one is attached.
9
+ if [ -d /workspace ] && [ -w /workspace ]; then
10
+ export HF_HOME="${HF_HOME:-/workspace/hf}" UV_CACHE_DIR="${UV_CACHE_DIR:-/workspace/uv-cache}"
11
+ fi
12
+ # The PyTorch wheel index that matches this host's *driver* (not what nvidia-smi
13
+ # reports as "CUDA Version": images with CUDA forward-compat libraries inflate
14
+ # that). Jobs install torch with: uv pip install torch --index-url "$GIPPER_TORCH_INDEX"
15
+ _drv=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -1 | cut -d. -f1)
16
+ _drv=${_drv:-0}
17
+ if [ "$_drv" -ge 580 ]; then _cuda=130
18
+ elif [ "$_drv" -ge 570 ]; then _cuda=128
19
+ elif [ "$_drv" -ge 560 ]; then _cuda=126
20
+ elif [ "$_drv" -ge 550 ]; then _cuda=124
21
+ else _cuda=121; fi
22
+ GIPPER_TORCH_INDEX=https://download.pytorch.org/whl/cu$_cuda
23
+ export GIPPER_TORCH_INDEX
24
+ echo "gipper: driver $_drv -> torch cu$_cuda ($GIPPER_TORCH_INDEX)"
25
+ nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader 2>/dev/null | sed 's/^/gipper: gpu /'
gipper/cli.py ADDED
@@ -0,0 +1,216 @@
1
+ """gipper: GPU jobs across cloud markets.
2
+
3
+ gipper pod gpus [--min-mem GB] [--region US,CA] [--udp] [--provider P] offers across markets
4
+ gipper pod estimate JOB.toml [--throughput U/S] [--provider P] what a run would take and cost
5
+ gipper pod run JOB.toml [--dry-run] [--gpu NAME] [--provider P] rent, run, collect, terminate
6
+ gipper pod hosts [--provider P] what gipper has learned about providers and machines
7
+ gipper pod ls gipper's machines now running, every provider
8
+ gipper pod reap [--dry-run] end machines gipper forgot
9
+ gipper pod reconcile [--days N] record what providers actually billed
10
+ gipper pod cost [--since YYYY-MM-DD] spend per job: billed where known, else computed
11
+ gipper pod account balance per provider
12
+ gipper ledger [--since YYYY-MM-DD] [--job ID] raw receipts
13
+
14
+ Providers: runpod, vast, shadeform (keys RUNPOD_API_KEY, VAST_API_KEY,
15
+ SHADEFORM_API_KEY in the environment or ~/.config/gipper/.env). Vast and Shadeform list offers without a key.
16
+ """
17
+ import argparse
18
+ import json
19
+ import signal
20
+ import sys
21
+ from collections import defaultdict
22
+
23
+ PROVIDERS = ["runpod", "vast", "shadeform"]
24
+
25
+
26
+ def _providers(name=None):
27
+ from gipper.config import settings
28
+ from gipper.providers import load
29
+ return [load(n) for n in ([name] if name else settings()["providers"])]
30
+
31
+
32
+ def _offer_line(o):
33
+ e = o.estimate
34
+ return (f"{o.provider:9s} {o.gpu[:28]:28s} {o.mem_gb:>4.0f} {o.usd_h:>6.2f} {e.get('wall_s', 0) / 60:>5.0f} "
35
+ f"{e.get('usd', 0):>7.3f} {e.get('p_fail', 0):>5.0%} {e.get('score', 0):>7.3f} {o.where}")
36
+
37
+
38
+ HEADER = f"{'provider':9s} {'gpu':28s} {'GB':>4s} {'$/h':>6s} {'min':>5s} {'$run':>7s} {'risk':>5s} {'score':>7s} where"
39
+
40
+
41
+ def _pod(args):
42
+ if args.cmd == "gpus":
43
+ from gipper import market
44
+ from gipper.job import Job
45
+ job = Job(name="gpus", workload="-", command="-", min_mem_gb=args.min_mem, cloud=args.cloud,
46
+ regions=[r.strip().upper() for r in (args.region or "").split(",") if r.strip()],
47
+ ports=["9/udp"] if args.udp else [])
48
+ offers, notes = market.shop(job, names=[args.provider] if args.provider else None, keyed_only=False)
49
+ keyed = {p.name for p in _providers() if p.has_key()}
50
+ print(HEADER + " (a 30-minute job)")
51
+ for o in offers[:args.limit]:
52
+ print(_offer_line(o) + ("" if o.provider in keyed else " (no key)"))
53
+ for n in notes:
54
+ print(f"note: {n}")
55
+ return
56
+ if args.cmd in ("estimate", "run"):
57
+ from gipper import run as runmod
58
+ from gipper.job import load
59
+ job = load(args.job)
60
+ if args.cmd == "run":
61
+ _, outcome = runmod.run(job, dry_run=args.dry_run, gpu_override=args.gpu, provider=args.provider,
62
+ throughput=args.throughput)
63
+ sys.exit(0 if outcome in ("succeeded", None) or args.dry_run else 1)
64
+ offers, _, notes = runmod.plan(job, provider=args.provider, throughput=args.throughput)
65
+ print(HEADER)
66
+ for o in offers[:8]:
67
+ print(_offer_line(o))
68
+ best = offers[0].estimate
69
+ print(f"\nbest: {offers[0].label}, objective {best['objective']}")
70
+ print(" seconds: " + ", ".join(f"{k} {v}" for k, v in best["phases_s"].items()))
71
+ print(f" dollars: rental {best['rental_usd']:.4f} + bandwidth {best['bandwidth_usd']:.4f}"
72
+ + ("" if best["bandwidth_known"] else " (fees not advertised)")
73
+ + (f", x{best['billing_factor']:.2f} billed vs computed" if best["billing_factor"] else ""))
74
+ print(f" host risk {best['p_fail']:.0%} -> ${best['risk_usd']:.3f}; waiting valued at ${best['hour_value_usd']}/h")
75
+ print(" from: " + ", ".join(f"{k} {v}" for k, v in best["basis"].items()))
76
+ for n in notes:
77
+ print(f"note: {n}")
78
+ return
79
+ if args.cmd == "hosts":
80
+ from gipper.history import History
81
+ h = History.load()
82
+ for name in [args.provider] if args.provider else PROVIDERS:
83
+ s = h.summary(name)
84
+ if not s["rentals"]:
85
+ continue
86
+ fmt = lambda v, f: f.format(v) if v is not None else "-"
87
+ print(f"{name}: {s['rentals']} rentals, {s['good']} good, {s['bad']} bad -> {s['failure']:.0%} risk for a new one; "
88
+ f"ready {fmt(s['ready_s'], '{:.0f}')} s; measured up {fmt(s['up_mbps'], '{:.0f}')} / "
89
+ f"down {fmt(s['down_mbps'], '{:.0f}')} Mb/s; billed {fmt(s['billing_factor'], 'x{:.2f}')} computed"
90
+ + (f"; {s['cleanup_pending']} cleanup(s) unconfirmed" if s["cleanup_pending"] else ""))
91
+ for why in s["reasons"]:
92
+ print(f" bad: {why[:110]}")
93
+ machines = defaultdict(list)
94
+ for r in h.rentals_of(name):
95
+ if r.machine:
96
+ machines[r.machine].append(r)
97
+ for m, rs in sorted(machines.items(), key=lambda kv: -len(kv[1])):
98
+ bad = sum(r.outcome == "bad" for r in rs)
99
+ if len(rs) > 1 or bad:
100
+ print(f" machine {m:>10s} {len(rs)} rentals, {bad} bad -> {h.failure(name, m):.0%} "
101
+ f"{rs[-1].gpu or ''}")
102
+ return
103
+ if args.cmd == "account":
104
+ for p in _providers():
105
+ if not p.has_key():
106
+ print(f"{p.name:10s} no {p.key_name}")
107
+ continue
108
+ try:
109
+ print(f"{p.name:10s} {p.account()}")
110
+ except Exception as e:
111
+ print(f"{p.name:10s} error: {e}")
112
+ return
113
+ if args.cmd == "ls":
114
+ for p in _providers():
115
+ if not p.has_key():
116
+ continue
117
+ try:
118
+ for m in p.list_ours():
119
+ print(f"{p.name:10s} {m['id']} {m['name']} {m.get('status')} ${m.get('usd_h')}/h")
120
+ except Exception as e:
121
+ print(f"{p.name:10s} error: {e}")
122
+ return
123
+ if args.cmd == "reap":
124
+ from gipper.reap import reap
125
+ acted = reap(dry_run=args.dry_run)
126
+ for prov, pid, name, why in acted:
127
+ print(f"{'would end' if args.dry_run else 'ended or retried'} {prov} {pid} {name}: {why}")
128
+ if not acted:
129
+ print("nothing to reap")
130
+ return
131
+ if args.cmd == "reconcile":
132
+ from gipper.billing import reconcile
133
+ written = reconcile(days=args.days)
134
+ for prov, iid, usd in written:
135
+ print(f"{prov} {iid}: billed ${usd:.4f}")
136
+ print(f"{len(written)} bill(s) recorded" if written else "bills already up to date")
137
+ return
138
+ if args.cmd == "cost":
139
+ from gipper import ledger
140
+ # a job belongs to the day it started; its bill may be recorded days later
141
+ per = defaultdict(lambda: {"computed": 0.0, "billed": {}, "s": 0, "name": "", "outcome": "", "first": None})
142
+ for r in ledger.read(kinds={"pod.create", "spend", "spend.billed", "run.end"}):
143
+ # some spend (e.g. a lease run outside `pod run`) is written without a job id
144
+ j = per[r["job"] or f"(no job) {r['refs'].get('name', '')}".strip()]
145
+ j["first"] = j["first"] or r["ts"]
146
+ if r["kind"] == "pod.create":
147
+ continue
148
+ j["name"] = r["refs"].get("name", j["name"])
149
+ if r["kind"] == "spend":
150
+ j["computed"] += r["payload"]["usd"]
151
+ j["s"] += r["payload"]["seconds"]
152
+ elif r["kind"] == "spend.billed":
153
+ j["billed"][(r["payload"]["provider"], r["payload"]["pod"])] = r["payload"]["usd"]
154
+ else:
155
+ j["outcome"] = r["payload"]["outcome"]
156
+ computed = billed_total = 0.0
157
+ print(f"{'job':16s} {'name':30s} {'min':>6s} {'computed':>9s} {'billed':>8s} outcome")
158
+ for jid, j in per.items():
159
+ if (not j["s"] and not j["billed"]) or (args.since and (j["first"] or "") < args.since):
160
+ continue
161
+ billed = sum(j["billed"].values()) if j["billed"] else None
162
+ shown = f"{billed:8.3f}" if billed is not None else f"{'-':>8s}"
163
+ print(f"{jid:16s} {j['name'][:30]:30s} {j['s'] / 60:6.1f} {j['computed']:9.3f} {shown} {j['outcome']}")
164
+ computed += j["computed"]
165
+ billed_total += billed if billed is not None else j["computed"]
166
+ print(f"total computed ${computed:.3f}; billed where known, else computed: ${billed_total:.3f}")
167
+
168
+
169
+ def main(argv=None):
170
+ sys.stdout.reconfigure(line_buffering=True) # live output when redirected to a file
171
+ signal.signal(signal.SIGPIPE, signal.SIG_DFL) # `gipper pod hosts | head` ends quietly
172
+ ap = argparse.ArgumentParser(prog="gipper", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
173
+ sub = ap.add_subparsers(dest="group")
174
+ pod = sub.add_parser("pod", help="rented GPUs (Runpod, Vast, Shadeform)")
175
+ ps = pod.add_subparsers(dest="cmd", required=True)
176
+ g = ps.add_parser("gpus")
177
+ g.add_argument("--min-mem", type=float, default=16)
178
+ g.add_argument("--region", help="ISO country codes, comma-separated (US,CA)")
179
+ g.add_argument("--udp", action="store_true", help="only providers that can expose UDP ports")
180
+ g.add_argument("--provider", choices=PROVIDERS)
181
+ g.add_argument("--cloud", default="COMMUNITY", choices=["COMMUNITY", "SECURE"], help="Runpod cloud to start from")
182
+ g.add_argument("--limit", type=int, default=30)
183
+ for name in ("estimate", "run"):
184
+ p = ps.add_parser(name)
185
+ p.add_argument("job")
186
+ p.add_argument("--throughput", type=float, help="units per second, if known")
187
+ p.add_argument("--provider", choices=PROVIDERS, help="shop only this provider")
188
+ if name == "run":
189
+ p.add_argument("--dry-run", action="store_true")
190
+ p.add_argument("--gpu", help="only this GPU (e.g. \"RTX 4090\")")
191
+ h = ps.add_parser("hosts")
192
+ h.add_argument("--provider", choices=PROVIDERS)
193
+ ps.add_parser("ls")
194
+ ps.add_parser("account")
195
+ r = ps.add_parser("reap")
196
+ r.add_argument("--dry-run", action="store_true")
197
+ rc = ps.add_parser("reconcile")
198
+ rc.add_argument("--days", type=int, default=7)
199
+ c = ps.add_parser("cost")
200
+ c.add_argument("--since")
201
+ lg = sub.add_parser("ledger", help="raw receipts")
202
+ lg.add_argument("--since")
203
+ lg.add_argument("--job")
204
+ a = ap.parse_args(argv)
205
+ if a.group == "pod":
206
+ return _pod(a)
207
+ if a.group == "ledger":
208
+ from gipper import ledger
209
+ for rec in ledger.read(since=a.since, job=a.job):
210
+ print(json.dumps(rec))
211
+ return
212
+ ap.print_help()
213
+
214
+
215
+ if __name__ == "__main__":
216
+ main()
gipper/config.py ADDED
@@ -0,0 +1,91 @@
1
+ """Paths, settings and the provider keys.
2
+
3
+ Each provider key (RUNPOD_API_KEY, VAST_API_KEY, SHADEFORM_API_KEY) comes from the
4
+ environment or a configured .env (mode 600, git-ignored) and only ever travels in a
5
+ request header: never logged, printed, written elsewhere or passed on a command
6
+ line. `redact()` scrubs every one of them from any text that might be shown or
7
+ recorded.
8
+ """
9
+ import os
10
+ import tomllib
11
+ from pathlib import Path
12
+
13
+ HOME = Path.home()
14
+ ROOT = Path(__file__).resolve().parents[1]
15
+ DATA = Path(os.environ.get("GIPPER_DATA", HOME / ".local/share/gipper"))
16
+ CONFIG_FILE = Path(os.environ.get("GIPPER_CONFIG", HOME / ".config/gipper/config.toml"))
17
+ KEY_DIR = Path(os.environ.get("GIPPER_KEYS", HOME / ".config/gipper"))
18
+
19
+ DEFAULTS = {
20
+ "daily_cap_usd": 5.0, # refuse a new pod if today's spend + estimate exceeds this
21
+ "default_job_cap_usd": 2.0, # a job without its own budget gets this
22
+ "default_max_hours": 3.0,
23
+ "image": "runpod/base:1.4.1-ubuntu2404", # Runpod's default; no CUDA in the image: torch wheels bring their own
24
+ "vast_image": "vastai/base-image:@vastai-automatic-tag", # Vast picks the tag that fits the host
25
+ "providers": ["runpod", "vast", "shadeform"], # markets to shop; those without a key are skipped
26
+ "regions": [], # ISO country codes a job may run in by default; empty = anywhere
27
+ "cuda_versions": [], # no host filter: it empties the market; bootstrap picks a matching torch build
28
+ "cloud": "COMMUNITY", # COMMUNITY or SECURE
29
+ "container_disk_gb": 40,
30
+ "checkpoint_every_s": 300,
31
+ # How offers are weighed (see docs/placement.md). An hour of waiting is worth
32
+ # this much; a failed host (time lost, attention, a re-run) costs this much on
33
+ # top of the rental it wasted. Short jobs end up chosen for reliability and
34
+ # speed, long ones for price.
35
+ "hour_value_usd": 3.0,
36
+ "failure_cost_usd": 1.0,
37
+ "history_days": 90, # how far back the ledger counts as memory
38
+ }
39
+
40
+
41
+ def settings():
42
+ s = dict(DEFAULTS)
43
+ if CONFIG_FILE.exists():
44
+ s.update(tomllib.loads(CONFIG_FILE.read_text()))
45
+ return s
46
+
47
+
48
+ def _dotenv(path):
49
+ out = {}
50
+ if path.exists():
51
+ for line in path.read_text().splitlines():
52
+ line = line.strip()
53
+ if line and not line.startswith("#") and "=" in line:
54
+ k, v = line.split("=", 1)
55
+ out[k.strip()] = v.strip().strip('"').strip("'")
56
+ return out
57
+
58
+
59
+ KEYS = ("RUNPOD_API_KEY", "VAST_API_KEY", "SHADEFORM_API_KEY")
60
+
61
+
62
+ def secret(name, required=True):
63
+ key = os.environ.get(name)
64
+ if key:
65
+ return key
66
+ # Explicit GIPPER_ENV is exclusive (including a missing file). Otherwise
67
+ # prefer user configuration while retaining editable installs' legacy .env.
68
+ if "GIPPER_ENV" in os.environ:
69
+ values = _dotenv(Path(os.environ["GIPPER_ENV"]))
70
+ else:
71
+ values = _dotenv(ROOT / ".env")
72
+ values.update(_dotenv(CONFIG_FILE.parent / ".env"))
73
+ key = values.get(name)
74
+ if not key and required:
75
+ raise SystemExit(f"{name} is not set (environment or configured .env; see GIPPER_ENV)")
76
+ return key or None
77
+
78
+
79
+ def runpod_key():
80
+ return secret("RUNPOD_API_KEY")
81
+
82
+
83
+ def redact(text):
84
+ """Remove every provider key from text before it is shown or recorded."""
85
+ if not isinstance(text, str):
86
+ return text
87
+ for name in KEYS:
88
+ key = secret(name, required=False)
89
+ if key:
90
+ text = text.replace(key, "<redacted>")
91
+ return text
gipper/costs.py ADDED
@@ -0,0 +1,125 @@
1
+ """The whole-run view of an offer: how long the job would take there, what it would
2
+ cost, how likely the host is to fail, and one number to rank offers by.
3
+
4
+ wall = ready + setup + upload + fetch + compute + download
5
+ expected = (rental for wall + bandwidth fees) x the provider's billing factor
6
+ score = expected + p_fail x (failure_cost + rental and time lost to a bad host)
7
+ + hour_value x wall
8
+
9
+ Every figure comes from the best source available, and the estimate says which:
10
+ the job's own [placement] hints, the ledger's memory (gipper.history), what the
11
+ provider advertises, or a stated default.
12
+
13
+ The score is what makes short and long jobs come out differently without a
14
+ switch: on a 15-minute job, $0.40/h against $0.80/h is ten cents, smaller than a
15
+ 26% chance of a bad host at $1 a failure, so reliability and speed win; on a
16
+ ten-hour job the same prices are four dollars apart, and price wins.
17
+
18
+ Objectives (placement.objective): "auto" values waiting at settings.hour_value_usd;
19
+ "cost" doesn't value waiting at all (failures still cost); "time" values it at
20
+ TIME_HOUR_VALUE; "latency" (interactive profile) is ranked by nearness first in
21
+ gipper.market, and scored like "auto".
22
+
23
+ The estimate also carries 0.1's field names (estimated_total_usd, setup_download_s,
24
+ assumptions, ...), which clients' reports and the estimate receipt still read.
25
+ """
26
+ from gipper.config import settings
27
+ from gipper.placement import input_gb
28
+
29
+ READY_S = {"runpod": 90, "vast": 120, "shadeform": 420} # until ssh, before memory has a median
30
+ SETUP_S = 120 # bootstrap and the job's own setup, if the job doesn't say
31
+ COMPUTE_S = 1800 # if neither the job nor memory says
32
+ PREFLIGHT_S = 60 # time a bad host costs beyond becoming ready
33
+ TIME_HOUR_VALUE = 50.0 # $/h of waiting under objective = "time"
34
+
35
+
36
+ def _mbps(measured, advertised, p, client_cap=None):
37
+ """Link speed and where it came from; a client-side cap limits whichever applies."""
38
+ if measured:
39
+ v, src = measured, "measured"
40
+ elif advertised and advertised > 0:
41
+ v, src = advertised * p.transfer_efficiency, "advertised"
42
+ else:
43
+ v, src = p.fallback_mbps * p.transfer_efficiency, "default"
44
+ if client_cap and client_cap * p.transfer_efficiency < v:
45
+ return client_cap * p.transfer_efficiency, "client cap"
46
+ return v, src
47
+
48
+
49
+ def estimate(job, offer, history, *, throughput=None):
50
+ """Phases (seconds), dollars, risk and score for running `job` on `offer`."""
51
+ from gipper.market import matches
52
+ s, p, m = settings(), job.placement, offer.metrics
53
+ machine = offer.ref.get("machine")
54
+ basis = {}
55
+
56
+ ready = history.ready_s(offer.provider, machine)
57
+ basis["ready"] = "memory" if ready else "default"
58
+ ready = ready or READY_S.get(offer.provider, 180)
59
+
60
+ setup = p.setup_s if p.setup_s is not None else SETUP_S
61
+ basis["setup"] = "job" if p.setup_s is not None else "default"
62
+
63
+ up_gb = p.upload_gb if p.upload_gb is not None else input_gb(job)
64
+ up_mbps, basis["upload"] = _mbps(history.mbps(offer.provider, machine, "up", offer.country),
65
+ m.get("host_download_mbps"), p, p.client_upload_mbps)
66
+ down_mbps, basis["download"] = _mbps(history.mbps(offer.provider, machine, "down", offer.country),
67
+ m.get("host_upload_mbps"), p, p.client_download_mbps)
68
+ fetch_mbps, basis["fetch"] = _mbps(None, m.get("host_download_mbps"), p) # host <- internet: no client cap
69
+ upload, download = up_gb * 8000 / up_mbps, p.download_gb * 8000 / down_mbps
70
+ fetch = p.setup_download_gb * 8000 / fetch_mbps
71
+
72
+ by_gpu = next((v for g, v in p.compute_s_by_gpu.items() if matches(g, offer.gpu)), None)
73
+ remembered = history.compute_s(job.workload, offer.gpu)
74
+ if by_gpu:
75
+ compute, basis["compute"] = by_gpu, "job (this GPU)"
76
+ elif throughput and job.units_count:
77
+ compute, basis["compute"] = job.units_count / throughput, "throughput"
78
+ elif p.compute_s is not None:
79
+ compute, basis["compute"] = p.compute_s, "job"
80
+ elif remembered:
81
+ compute, basis["compute"] = remembered, "memory"
82
+ else:
83
+ compute, basis["compute"] = min(COMPUTE_S, job.max_hours * 3600), "default"
84
+
85
+ wall = ready + setup + upload + fetch + compute + download
86
+ rental = wall / 3600 * offer.usd_h
87
+ fee_in, fee_out = m.get("host_download_usd_per_gb"), m.get("host_upload_usd_per_gb")
88
+ fees_known = offer.provider != "vast" or (fee_in is not None and fee_out is not None)
89
+ bandwidth = (up_gb + p.setup_download_gb) * (fee_in or 0) + p.download_gb * (fee_out or 0)
90
+ factor = history.billing_factor(offer.provider)
91
+ basis["billing"] = "memory" if factor else "rate"
92
+ expected = rental * (factor or 1.0) + bandwidth # bills already itemise bandwidth: the factor is the rest
93
+
94
+ p_fail = history.failure(offer.provider, machine)
95
+ hour_value = {"auto": s["hour_value_usd"], "latency": s["hour_value_usd"], "cost": 0.0,
96
+ "time": TIME_HOUR_VALUE}[p.objective]
97
+ lost_h = (ready + PREFLIGHT_S) / 3600
98
+ risk = p_fail * (s["failure_cost_usd"] + lost_h * (offer.usd_h + hour_value))
99
+ score = expected + risk + hour_value * wall / 3600
100
+ from gipper.placement import best_rtt, distance_km
101
+ rtt, rtt_basis = best_rtt(offer, p, history)
102
+ phases = {"ready": round(ready), "setup": round(setup), "upload": round(upload), "fetch": round(fetch),
103
+ "compute": round(compute), "download": round(download)}
104
+ legacy = { # 0.1's names, read by clients' reports
105
+ "profile": p.profile, "estimated_total_usd": round(expected, 4),
106
+ "estimated_bandwidth_usd": round(bandwidth, 4) if fees_known else None, "known_bandwidth_usd": round(bandwidth, 4),
107
+ "bandwidth_fees_known": fees_known, "cost_is_lower_bound": not fees_known,
108
+ "setup_s": phases["setup"], "setup_download_s": phases["fetch"], "upload_s": phases["upload"],
109
+ "download_s": phases["download"], "compute_s": phases["compute"],
110
+ "upload_gb": up_gb, "download_gb": p.download_gb, "setup_download_gb": p.setup_download_gb,
111
+ "host_download_mbps": m.get("host_download_mbps"), "host_upload_mbps": m.get("host_upload_mbps"),
112
+ "host_download_usd_per_gb": fee_in, "host_upload_usd_per_gb": fee_out,
113
+ "effective_upload_mbps": round(up_mbps, 1), "effective_download_mbps": round(down_mbps, 1),
114
+ "dlperf": m.get("dlperf"), "measured_rtt_ms": rtt if rtt_basis == "measured" else None,
115
+ "latency_target": p.latency_target or None,
116
+ "distance_km": round(distance_km(offer, p), 2) if distance_km(offer, p) is not None else None,
117
+ "assumptions": [f"{k} from {v}" for k, v in basis.items()]
118
+ + ([] if fees_known else ["bandwidth fees not advertised: total is a lower bound"]),
119
+ }
120
+ return {**legacy, "wall_s": round(wall), "usd": round(expected, 4), "score": round(score, 4),
121
+ "rtt_ms": rtt, "rtt_basis": rtt_basis,
122
+ "phases_s": phases,
123
+ "rental_usd": round(rental, 4), "bandwidth_usd": round(bandwidth, 4), "bandwidth_known": fees_known,
124
+ "billing_factor": factor, "p_fail": round(p_fail, 3), "risk_usd": round(risk, 4),
125
+ "objective": p.objective, "hour_value_usd": hour_value, "basis": basis}
gipper/cuda_probe.py ADDED
@@ -0,0 +1,78 @@
1
+ """Initialize the actual CUDA driver before any model/input upload.
2
+
3
+ nvidia-smi uses NVML and cannot prove cuInit succeeds. Only an 804 may trigger a
4
+ fresh-process retry excluding explicit CUDA forward-compatibility path entries.
5
+ No driver paths are guessed, libraries installed, or host configuration changed.
6
+ """
7
+ import json
8
+ import shlex
9
+ from pathlib import PurePosixPath
10
+ from gipper.providers import BadHost
11
+
12
+ SCRIPT = r'''
13
+ import ctypes,json,os
14
+ result={"ld_library_path":os.environ.get("LD_LIBRARY_PATH",""),"loaded_library_paths":[],"device_count":0}
15
+ try:
16
+ cuda=ctypes.CDLL("libcuda.so.1")
17
+ cuda.cuInit.argtypes=[ctypes.c_uint];cuda.cuInit.restype=ctypes.c_int
18
+ code=cuda.cuInit(0);result["cu_init"]=code
19
+ if code==0:
20
+ count=ctypes.c_int()
21
+ cuda.cuDeviceGetCount.argtypes=[ctypes.POINTER(ctypes.c_int)];cuda.cuDeviceGetCount.restype=ctypes.c_int
22
+ result["cu_device_get_count"]=cuda.cuDeviceGetCount(ctypes.byref(count));result["device_count"]=count.value
23
+ try:
24
+ result["loaded_library_paths"]=sorted({line.split()[-1] for line in open("/proc/self/maps") if "/libcuda.so" in line})
25
+ except OSError: pass
26
+ except Exception as error:
27
+ result["error"]=type(error).__name__+": "+str(error)
28
+ print(json.dumps(result))
29
+ '''
30
+
31
+
32
+ class CudaProbeError(BadHost):
33
+ def __init__(self, message, receipt):
34
+ super().__init__(message)
35
+ self.receipt=receipt
36
+
37
+
38
+ def compat_path(value):
39
+ parts=PurePosixPath(value).parts
40
+ return (len(parts)>1 and parts[-1]=='compat' and parts[-2].startswith('cuda')) or any(p.startswith('cuda-compat-') for p in parts)
41
+
42
+
43
+ def initialized(result):
44
+ return result.get('cu_init')==0 and result.get('cu_device_get_count')==0 and isinstance(result.get('device_count'),int) and result['device_count']>0
45
+
46
+
47
+ def check(remote, env=None):
48
+ receipt={'attempts':[], 'environment_override':{}, 'status':'rejected',
49
+ 'basis':'stdlib ctypes libcuda.so.1 cuInit(0) and cuDeviceGetCount; fresh Python per attempt'}
50
+ def attempt(ld=None):
51
+ prefix=('LD_LIBRARY_PATH='+shlex.quote(ld)+' ') if ld is not None else ''
52
+ response=remote.run(prefix+'python3 -c '+shlex.quote(SCRIPT),check=False,timeout=30)
53
+ if response.returncode:
54
+ raise RuntimeError('CUDA Python preflight exited '+str(response.returncode))
55
+ data=json.loads(response.stdout)
56
+ if not isinstance(data,dict):raise ValueError('invalid CUDA preflight response')
57
+ receipt['attempts'].append(data)
58
+ return data
59
+ try:
60
+ first=attempt((env or {}).get('LD_LIBRARY_PATH'))
61
+ if initialized(first):
62
+ receipt['status']='ok';return receipt
63
+ if first.get('cu_init')==804:
64
+ path=first.get('ld_library_path','')
65
+ if not isinstance(path,str): raise ValueError('invalid CUDA library path response')
66
+ entries=path.split(':')
67
+ removed=[p for p in entries if compat_path(p)]
68
+ if removed:
69
+ cleaned=':'.join(p for p in entries if not compat_path(p))
70
+ receipt['removed_compat_entries']=removed
71
+ second=attempt(cleaned) # new SSH/Python; never retry CDLL in loaded process
72
+ if initialized(second) and second.get("ld_library_path") == cleaned:
73
+ receipt.update(status='ok',environment_override={'LD_LIBRARY_PATH':cleaned})
74
+ return receipt
75
+ receipt['error']='CUDA initialization/device enumeration failed: '+json.dumps(receipt['attempts'][-1])
76
+ except Exception as error:
77
+ receipt['error']=f'{type(error).__name__}: {error}'
78
+ raise CudaProbeError(receipt['error'],receipt)