gpuhedge 0.1.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.
- gpuhedge/__init__.py +32 -0
- gpuhedge/__main__.py +8 -0
- gpuhedge/auth.py +100 -0
- gpuhedge/backends/__init__.py +66 -0
- gpuhedge/backends/base.py +183 -0
- gpuhedge/backends/cerebrium_backend.py +415 -0
- gpuhedge/backends/http_backend.py +284 -0
- gpuhedge/backends/modal_backend.py +168 -0
- gpuhedge/backends/runpod_backend.py +281 -0
- gpuhedge/backends/sim_backend.py +197 -0
- gpuhedge/benchmark/__init__.py +7 -0
- gpuhedge/benchmark/cancel_audit.py +156 -0
- gpuhedge/benchmark/cascade.py +263 -0
- gpuhedge/benchmark/controller.py +145 -0
- gpuhedge/benchmark/demo.py +216 -0
- gpuhedge/benchmark/live_hedge.py +154 -0
- gpuhedge/benchmark/live_stage.py +148 -0
- gpuhedge/benchmark/qualify.py +164 -0
- gpuhedge/benchmark/replay.py +382 -0
- gpuhedge/benchmark/report.py +170 -0
- gpuhedge/benchmark/round.py +169 -0
- gpuhedge/benchmark/state_aware.py +240 -0
- gpuhedge/benchmark/validation.py +199 -0
- gpuhedge/cli.py +466 -0
- gpuhedge/config/benchmark.example.yaml +157 -0
- gpuhedge/config/benchmark.yaml +153 -0
- gpuhedge/config/demo.yaml +99 -0
- gpuhedge/config.py +154 -0
- gpuhedge/policies/__init__.py +153 -0
- gpuhedge/router.py +112 -0
- gpuhedge/telemetry/__init__.py +18 -0
- gpuhedge/telemetry/costs.py +205 -0
- gpuhedge/telemetry/ledger.py +191 -0
- gpuhedge/telemetry/trace.py +55 -0
- gpuhedge/validators/__init__.py +21 -0
- gpuhedge/validators/audio.py +87 -0
- gpuhedge/validators/registry.py +90 -0
- gpuhedge-0.1.0.dist-info/METADATA +275 -0
- gpuhedge-0.1.0.dist-info/RECORD +43 -0
- gpuhedge-0.1.0.dist-info/WHEEL +5 -0
- gpuhedge-0.1.0.dist-info/entry_points.txt +2 -0
- gpuhedge-0.1.0.dist-info/licenses/LICENSE +179 -0
- gpuhedge-0.1.0.dist-info/top_level.txt +1 -0
gpuhedge/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""GPUHedge — speculative execution across serverless GPU clouds.
|
|
2
|
+
|
|
3
|
+
First valid result wins. Loser cancellation is audited.
|
|
4
|
+
|
|
5
|
+
The public surface is intentionally small; the benchmark harness lives under
|
|
6
|
+
``gpuhedge.benchmark`` and provider adapters under ``gpuhedge.backends``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from gpuhedge.config import BenchmarkConfig, default_config_path, load_config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def __getattr__(name: str):
|
|
15
|
+
# Lazy: Router pulls in telemetry/policy modules; keep bare import light.
|
|
16
|
+
if name in ("Router", "RouterOutcome"):
|
|
17
|
+
from gpuhedge import router
|
|
18
|
+
|
|
19
|
+
return getattr(router, name)
|
|
20
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"__version__",
|
|
27
|
+
"BenchmarkConfig",
|
|
28
|
+
"load_config",
|
|
29
|
+
"default_config_path",
|
|
30
|
+
"Router",
|
|
31
|
+
"RouterOutcome",
|
|
32
|
+
]
|
gpuhedge/__main__.py
ADDED
gpuhedge/auth.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Provider login verification, reused by ``gpuhedge login-check``.
|
|
2
|
+
|
|
3
|
+
Each check does a real authenticated round-trip (not just "is a token file
|
|
4
|
+
present"): Modal lists apps, RunPod calls ``get_user``, Cerebrium lists
|
|
5
|
+
projects. Everything is timeboxed and degrades to a clear "not logged in"
|
|
6
|
+
rather than raising, so the CLI can render a status table.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import pathlib
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class AuthStatus:
|
|
19
|
+
provider: str
|
|
20
|
+
logged_in: bool
|
|
21
|
+
identity: str | None = None # profile / user id / project
|
|
22
|
+
detail: str = ""
|
|
23
|
+
config_path: str | None = None
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def mark(self) -> str:
|
|
27
|
+
return "OK" if self.logged_in else "NOT LOGGED IN"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _run(cmd: list[str], timeout: float) -> tuple[int, str]:
|
|
31
|
+
try:
|
|
32
|
+
proc = subprocess.run(
|
|
33
|
+
cmd, capture_output=True, text=True, timeout=timeout, check=False
|
|
34
|
+
)
|
|
35
|
+
return proc.returncode, (proc.stdout + proc.stderr)
|
|
36
|
+
except FileNotFoundError:
|
|
37
|
+
return 127, "command not found"
|
|
38
|
+
except subprocess.TimeoutExpired:
|
|
39
|
+
return 124, f"timed out after {timeout}s"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def check_modal(timeout: float = 45.0) -> AuthStatus:
|
|
43
|
+
cfg = pathlib.Path.home() / ".modal.toml"
|
|
44
|
+
if shutil.which("modal") is None:
|
|
45
|
+
return AuthStatus("modal", False, detail="modal CLI not installed")
|
|
46
|
+
code, out = _run(["modal", "profile", "current"], timeout)
|
|
47
|
+
profile = out.strip().splitlines()[-1].strip() if code == 0 and out.strip() else None
|
|
48
|
+
if code != 0:
|
|
49
|
+
return AuthStatus("modal", False, detail=out.strip()[:200], config_path=str(cfg))
|
|
50
|
+
# Live auth: listing apps requires a valid token.
|
|
51
|
+
code2, out2 = _run(["modal", "app", "list"], timeout)
|
|
52
|
+
ok = code2 == 0
|
|
53
|
+
return AuthStatus(
|
|
54
|
+
"modal", ok, identity=profile,
|
|
55
|
+
detail="apps listed" if ok else out2.strip()[:200],
|
|
56
|
+
config_path=str(cfg),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def check_runpod(timeout: float = 45.0) -> AuthStatus:
|
|
61
|
+
cfg = pathlib.Path.home() / ".runpod" / "config.toml"
|
|
62
|
+
try:
|
|
63
|
+
from gpuhedge.backends.runpod_backend import load_runpod_api_key
|
|
64
|
+
|
|
65
|
+
api_key = load_runpod_api_key()
|
|
66
|
+
except Exception as exc: # noqa: BLE001
|
|
67
|
+
return AuthStatus("runpod", False, detail=str(exc)[:200], config_path=str(cfg))
|
|
68
|
+
try:
|
|
69
|
+
import runpod
|
|
70
|
+
|
|
71
|
+
runpod.api_key = api_key
|
|
72
|
+
user = runpod.get_user()
|
|
73
|
+
uid = user.get("id") if isinstance(user, dict) else None
|
|
74
|
+
return AuthStatus("runpod", True, identity=uid, detail="get_user ok",
|
|
75
|
+
config_path=str(cfg))
|
|
76
|
+
except Exception as exc: # noqa: BLE001
|
|
77
|
+
return AuthStatus("runpod", False, detail=str(exc)[:200], config_path=str(cfg))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def check_cerebrium(timeout: float = 45.0) -> AuthStatus:
|
|
81
|
+
cfg = pathlib.Path.home() / ".cerebrium" / "config.yaml"
|
|
82
|
+
if shutil.which("cerebrium") is None:
|
|
83
|
+
return AuthStatus("cerebrium", False, detail="cerebrium CLI not installed",
|
|
84
|
+
config_path=str(cfg))
|
|
85
|
+
code, out = _run(["cerebrium", "projects", "list", "--no-color"], timeout)
|
|
86
|
+
if code != 0:
|
|
87
|
+
return AuthStatus("cerebrium", False, detail=out.strip()[:200], config_path=str(cfg))
|
|
88
|
+
# Grab the current project context if available.
|
|
89
|
+
code2, out2 = _run(["cerebrium", "projects", "current", "--no-color"], timeout)
|
|
90
|
+
project = None
|
|
91
|
+
if code2 == 0:
|
|
92
|
+
for line in out2.splitlines():
|
|
93
|
+
if "projectId" in line:
|
|
94
|
+
project = line.split(":", 1)[-1].strip()
|
|
95
|
+
return AuthStatus("cerebrium", True, identity=project, detail="projects listed",
|
|
96
|
+
config_path=str(cfg))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def check_all(timeout: float = 45.0) -> list[AuthStatus]:
|
|
100
|
+
return [check_modal(timeout), check_runpod(timeout), check_cerebrium(timeout)]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Provider adapters and the factory that builds one from config.
|
|
2
|
+
|
|
3
|
+
Adapters are imported lazily so that ``gpuhedge`` (and CI) can import the
|
|
4
|
+
package without every cloud SDK installed — ``pip install gpuhedge[runpod]``
|
|
5
|
+
etc. pulls only what a given provider needs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from gpuhedge.backends.base import (
|
|
13
|
+
Backend,
|
|
14
|
+
BackendError,
|
|
15
|
+
CancellationEvidence,
|
|
16
|
+
CancellationReceipt,
|
|
17
|
+
JobHandle,
|
|
18
|
+
JobState,
|
|
19
|
+
LifecycleEvent,
|
|
20
|
+
NotDeployedError,
|
|
21
|
+
ProviderResult,
|
|
22
|
+
)
|
|
23
|
+
from gpuhedge.config import Provider
|
|
24
|
+
|
|
25
|
+
_REGISTRY = {
|
|
26
|
+
"runpod": ("gpuhedge.backends.runpod_backend", "RunPodBackend"),
|
|
27
|
+
"modal": ("gpuhedge.backends.modal_backend", "ModalBackend"),
|
|
28
|
+
"cerebrium": ("gpuhedge.backends.cerebrium_backend", "CerebriumBackend"),
|
|
29
|
+
"sim": ("gpuhedge.backends.sim_backend", "SimBackend"),
|
|
30
|
+
"http": ("gpuhedge.backends.http_backend", "HttpBackend"),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_backend(provider: Provider, request: dict[str, Any]) -> Backend:
|
|
35
|
+
"""Construct the adapter for the provider.
|
|
36
|
+
|
|
37
|
+
The adapter is selected by ``extra.adapter`` when present (``sim``,
|
|
38
|
+
``http``, or any registry key), else by the provider key itself — so a
|
|
39
|
+
config can declare arbitrarily-named providers backed by the generic
|
|
40
|
+
adapters."""
|
|
41
|
+
|
|
42
|
+
import importlib
|
|
43
|
+
|
|
44
|
+
adapter = provider.extra.get("adapter", provider.key)
|
|
45
|
+
if adapter not in _REGISTRY:
|
|
46
|
+
raise BackendError(
|
|
47
|
+
f"no adapter {adapter!r} for provider {provider.key!r} "
|
|
48
|
+
f"(known: {sorted(_REGISTRY)})"
|
|
49
|
+
)
|
|
50
|
+
module_name, cls_name = _REGISTRY[adapter]
|
|
51
|
+
module = importlib.import_module(module_name)
|
|
52
|
+
return getattr(module, cls_name)(provider, request)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"Backend",
|
|
57
|
+
"BackendError",
|
|
58
|
+
"NotDeployedError",
|
|
59
|
+
"JobHandle",
|
|
60
|
+
"JobState",
|
|
61
|
+
"LifecycleEvent",
|
|
62
|
+
"ProviderResult",
|
|
63
|
+
"CancellationEvidence",
|
|
64
|
+
"CancellationReceipt",
|
|
65
|
+
"build_backend",
|
|
66
|
+
]
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Uniform provider abstraction: submit -> result / status / cancel, plus the
|
|
2
|
+
structured cancellation receipt that is GPUHedge's core differentiator.
|
|
3
|
+
|
|
4
|
+
Every adapter implements the same small surface so the benchmark controller and
|
|
5
|
+
the (future) production router treat RunPod, Modal, and Cerebrium identically.
|
|
6
|
+
Provider capability differences (e.g. fine-grained lifecycle events) are exposed
|
|
7
|
+
honestly via ``LifecycleEvent`` lists that may be coarse for some providers.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from gpuhedge.config import Provider
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class JobState(str, Enum):
|
|
21
|
+
PENDING = "PENDING"
|
|
22
|
+
QUEUED = "IN_QUEUE"
|
|
23
|
+
IN_PROGRESS = "IN_PROGRESS"
|
|
24
|
+
COMPLETED = "COMPLETED"
|
|
25
|
+
FAILED = "FAILED"
|
|
26
|
+
CANCELLED = "CANCELLED"
|
|
27
|
+
TIMEOUT = "TIMEOUT" # right-censored: exceeded the per-model cap
|
|
28
|
+
UNKNOWN = "UNKNOWN"
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def terminal(self) -> bool:
|
|
32
|
+
return self in {
|
|
33
|
+
JobState.COMPLETED,
|
|
34
|
+
JobState.FAILED,
|
|
35
|
+
JobState.CANCELLED,
|
|
36
|
+
JobState.TIMEOUT,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def now_ms() -> float:
|
|
41
|
+
"""Monotonic milliseconds — safe for durations, never wall-clock arithmetic."""
|
|
42
|
+
|
|
43
|
+
return time.monotonic() * 1000.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class LifecycleEvent:
|
|
48
|
+
"""One observed transition, ms relative to submit (round-relative if shared)."""
|
|
49
|
+
|
|
50
|
+
t_ms: float
|
|
51
|
+
stage: str # submitted|queued|in_progress|model_load|generating|result|...
|
|
52
|
+
detail: str = ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ProviderResult:
|
|
57
|
+
provider: str
|
|
58
|
+
state: JobState
|
|
59
|
+
wall_s: float # submit -> valid result / terminal
|
|
60
|
+
audio: bytes | None = None # decoded WAV bytes (winner only)
|
|
61
|
+
sample_rate: int | None = None
|
|
62
|
+
provider_metrics: dict[str, Any] = field(default_factory=dict) # load_seconds, gpu, ...
|
|
63
|
+
events: list[LifecycleEvent] = field(default_factory=list)
|
|
64
|
+
error: str | None = None
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def ok(self) -> bool:
|
|
68
|
+
return self.state is JobState.COMPLETED and self.audio is not None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CancellationEvidence(str, Enum):
|
|
72
|
+
"""How much proof the adapter obtained that the loser actually stopped.
|
|
73
|
+
|
|
74
|
+
Ordered strongest to weakest. An adapter must EARN a level: acknowledgment
|
|
75
|
+
of the cancel call and terminal-state confirmation are distinct events,
|
|
76
|
+
and an unconfirmed cancellation never defaults to success
|
|
77
|
+
(docs/cancellation-semantics.md).
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
CONFIRMED_TERMINAL = "confirmed_terminal" # polled to a terminal state
|
|
81
|
+
PROVIDER_ACK = "provider_ack" # cancel call acknowledged only
|
|
82
|
+
REQUEST_CHANNEL_CLOSED = "request_channel_closed" # in-flight request broke
|
|
83
|
+
NO_EVIDENCE = "no_evidence" # nothing observable
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class CancellationReceipt:
|
|
88
|
+
"""Audit record for a loser cancellation — the evidence trail that
|
|
89
|
+
separates GPUHedge from an ``asyncio.wait(FIRST_COMPLETED)`` demo
|
|
90
|
+
(docs/cancellation-semantics.md).
|
|
91
|
+
|
|
92
|
+
Conservative by default: ``evidence`` starts at NO_EVIDENCE and ``leaked``
|
|
93
|
+
starts True. Adapters flip them only when they observe proof."""
|
|
94
|
+
|
|
95
|
+
provider: str
|
|
96
|
+
job_id: str | None
|
|
97
|
+
was_running: bool # cancelled IN_PROGRESS vs merely IN_QUEUE
|
|
98
|
+
cancel_sent_ms: float
|
|
99
|
+
cancel_ack_ms: float | None = None # provider acknowledged the cancel call
|
|
100
|
+
terminal_ms: float | None = None # job reached a terminal state on poll
|
|
101
|
+
terminal_status: str | None = None # set ONLY from an observed terminal state
|
|
102
|
+
last_gpu_activity_ms: float | None = None
|
|
103
|
+
execution_ms_before_cancel: float | None = None
|
|
104
|
+
estimated_cost_usd: float | None = None
|
|
105
|
+
reconciled_cost_usd: float | None = None
|
|
106
|
+
evidence: str = CancellationEvidence.NO_EVIDENCE.value
|
|
107
|
+
cancel_scope: str = "unknown" # queued_job | request | container | unknown
|
|
108
|
+
confirmed_terminal: bool = False # terminal state observed, not assumed
|
|
109
|
+
billing_stop_confirmed: bool = False # provider-reported final billing captured
|
|
110
|
+
leaked: bool = True # True until stopping is evidenced
|
|
111
|
+
note: str = ""
|
|
112
|
+
|
|
113
|
+
def ack_latency_ms(self) -> float | None:
|
|
114
|
+
if self.cancel_ack_ms is None:
|
|
115
|
+
return None
|
|
116
|
+
return self.cancel_ack_ms - self.cancel_sent_ms
|
|
117
|
+
|
|
118
|
+
def cancel_to_terminal_ms(self) -> float | None:
|
|
119
|
+
if self.terminal_ms is None:
|
|
120
|
+
return None
|
|
121
|
+
return self.terminal_ms - self.cancel_sent_ms
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class BackendError(RuntimeError):
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class NotDeployedError(BackendError):
|
|
129
|
+
"""Raised when a provider's endpoint has not been deployed yet
|
|
130
|
+
(``deployed: false`` in the config — e.g. Cerebrium before Stage 1)."""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class JobHandle:
|
|
134
|
+
"""A submitted job on one provider. Adapters subclass and implement the
|
|
135
|
+
async methods; base tracks submit time so wall/relative timings are uniform."""
|
|
136
|
+
|
|
137
|
+
provider: str
|
|
138
|
+
|
|
139
|
+
def __init__(self, provider: str, *, submit_ms: float) -> None:
|
|
140
|
+
self.provider = provider
|
|
141
|
+
self.key = provider # adapters build ProviderResult(provider=self.key)
|
|
142
|
+
self.submit_ms = submit_ms
|
|
143
|
+
self.events: list[LifecycleEvent] = [LifecycleEvent(0.0, "submitted")]
|
|
144
|
+
|
|
145
|
+
def _record(self, stage: str, detail: str = "") -> None:
|
|
146
|
+
self.events.append(LifecycleEvent(now_ms() - self.submit_ms, stage, detail))
|
|
147
|
+
|
|
148
|
+
def job_id(self) -> str | None: # pragma: no cover - overridden
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
async def result(self, timeout_s: float) -> ProviderResult: # pragma: no cover
|
|
152
|
+
raise NotImplementedError
|
|
153
|
+
|
|
154
|
+
async def status(self) -> JobState: # pragma: no cover
|
|
155
|
+
raise NotImplementedError
|
|
156
|
+
|
|
157
|
+
async def cancel( # pragma: no cover
|
|
158
|
+
self, *, reason: str = "lost the race"
|
|
159
|
+
) -> CancellationReceipt:
|
|
160
|
+
raise NotImplementedError
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Backend:
|
|
164
|
+
"""Factory + provider-level operations (submit, force cold)."""
|
|
165
|
+
|
|
166
|
+
key: str
|
|
167
|
+
|
|
168
|
+
def __init__(self, provider: Provider, request: dict[str, Any]) -> None:
|
|
169
|
+
self.provider = provider
|
|
170
|
+
self.key = provider.key
|
|
171
|
+
self.request = request
|
|
172
|
+
|
|
173
|
+
def available(self) -> bool:
|
|
174
|
+
return self.provider.deployed
|
|
175
|
+
|
|
176
|
+
async def submit(self) -> JobHandle: # pragma: no cover - overridden
|
|
177
|
+
raise NotImplementedError
|
|
178
|
+
|
|
179
|
+
async def scale_to_zero(self) -> None:
|
|
180
|
+
"""Best-effort force the endpoint into its normal cold state before a
|
|
181
|
+
round. Default no-op; adapters override where the platform allows it."""
|
|
182
|
+
|
|
183
|
+
return None
|