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/__init__.py
ADDED
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)
|