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.
Files changed (43) hide show
  1. gpuhedge/__init__.py +32 -0
  2. gpuhedge/__main__.py +8 -0
  3. gpuhedge/auth.py +100 -0
  4. gpuhedge/backends/__init__.py +66 -0
  5. gpuhedge/backends/base.py +183 -0
  6. gpuhedge/backends/cerebrium_backend.py +415 -0
  7. gpuhedge/backends/http_backend.py +284 -0
  8. gpuhedge/backends/modal_backend.py +168 -0
  9. gpuhedge/backends/runpod_backend.py +281 -0
  10. gpuhedge/backends/sim_backend.py +197 -0
  11. gpuhedge/benchmark/__init__.py +7 -0
  12. gpuhedge/benchmark/cancel_audit.py +156 -0
  13. gpuhedge/benchmark/cascade.py +263 -0
  14. gpuhedge/benchmark/controller.py +145 -0
  15. gpuhedge/benchmark/demo.py +216 -0
  16. gpuhedge/benchmark/live_hedge.py +154 -0
  17. gpuhedge/benchmark/live_stage.py +148 -0
  18. gpuhedge/benchmark/qualify.py +164 -0
  19. gpuhedge/benchmark/replay.py +382 -0
  20. gpuhedge/benchmark/report.py +170 -0
  21. gpuhedge/benchmark/round.py +169 -0
  22. gpuhedge/benchmark/state_aware.py +240 -0
  23. gpuhedge/benchmark/validation.py +199 -0
  24. gpuhedge/cli.py +466 -0
  25. gpuhedge/config/benchmark.example.yaml +157 -0
  26. gpuhedge/config/benchmark.yaml +153 -0
  27. gpuhedge/config/demo.yaml +99 -0
  28. gpuhedge/config.py +154 -0
  29. gpuhedge/policies/__init__.py +153 -0
  30. gpuhedge/router.py +112 -0
  31. gpuhedge/telemetry/__init__.py +18 -0
  32. gpuhedge/telemetry/costs.py +205 -0
  33. gpuhedge/telemetry/ledger.py +191 -0
  34. gpuhedge/telemetry/trace.py +55 -0
  35. gpuhedge/validators/__init__.py +21 -0
  36. gpuhedge/validators/audio.py +87 -0
  37. gpuhedge/validators/registry.py +90 -0
  38. gpuhedge-0.1.0.dist-info/METADATA +275 -0
  39. gpuhedge-0.1.0.dist-info/RECORD +43 -0
  40. gpuhedge-0.1.0.dist-info/WHEEL +5 -0
  41. gpuhedge-0.1.0.dist-info/entry_points.txt +2 -0
  42. gpuhedge-0.1.0.dist-info/licenses/LICENSE +179 -0
  43. 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
@@ -0,0 +1,8 @@
1
+ """Enable ``python -m gpuhedge`` as an alias for the ``gpuhedge`` console script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from gpuhedge.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
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