jambu-gpu 0.2.0__tar.gz → 0.3.1__tar.gz
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.
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/.gitignore +1 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/CONTRIBUTING.md +2 -2
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/PKG-INFO +2 -2
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/README.md +1 -1
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/docs/ACCEPTANCE.md +2 -2
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/docs/CONFIG.md +2 -1
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/docs/INTEGRATION.md +6 -5
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/docs/LIFECYCLE.md +1 -1
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/docs/USAGE.md +5 -5
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/typescript-client/README.md +1 -1
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/__init__.py +1 -1
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/_common.py +1 -1
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/account.py +2 -2
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/main.py +1 -1
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/timer.py +6 -4
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/config.py +7 -2
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/credentials.py +4 -2
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/engine.py +13 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/events.py +1 -1
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/hosts.py +1 -1
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/models.py +8 -0
- jambu_gpu-0.3.1/jambu_gpu/core/paths.py +42 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/state.py +2 -2
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/sweep.py +6 -6
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/providers/vast/mapper.py +18 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/runtime/base.py +10 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/runtime/vllm.py +10 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/pyproject.toml +2 -2
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/conftest.py +4 -3
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_config.py +1 -1
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_engine.py +12 -0
- jambu_gpu-0.3.1/tests/test_paths.py +50 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_runtime_vllm.py +13 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_state.py +1 -1
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_sweep.py +5 -5
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_vast_mapper.py +35 -3
- jambu_gpu-0.3.1/uv.lock +870 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/.env.example +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/LICENSE +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/docs/ADDING_A_PROVIDER.md +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/docs/CHOOSING_A_MODEL.md +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/docs/assets/jambu-logo.jpg +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/experiment.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/jambu.yaml +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/mastra-agent/README.md +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/mastra-agent/package-lock.json +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/mastra-agent/package.json +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/mastra-agent/src/agent.ts +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/mastra-agent/src/index.ts +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/mastra-agent/src/tools/weather-tool.ts +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/mastra-agent/tsconfig.json +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/typescript-client/package-lock.json +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/typescript-client/package.json +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/typescript-client/src/index.ts +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/typescript-client/src/jambuGpu.ts +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/typescript-client/tsconfig.json +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu.example.yaml +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu.models.yaml +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu.yaml +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/__main__.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/__init__.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/inspect.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/lifecycle.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/providers.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/run.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/setup.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/status.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/validate.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/__init__.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/beacon.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/durations.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/errors.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/execution.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/health.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/model_inspect.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/policy.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/tool_parsers.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/providers/__init__.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/providers/base.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/providers/registry.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/providers/vast/__init__.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/providers/vast/client.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/providers/vast/config.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/providers/vast/provider.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/runtime/__init__.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/watchdog/__init__.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/watchdog/agent_body.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/watchdog/builder.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/fakes.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_cli.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_engine_sessions.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_integration.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_model_inspect.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_policy.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_vast_provider.py +0 -0
- {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_watchdog.py +0 -0
|
@@ -62,9 +62,9 @@ job queues, a web dashboard. See [docs/ACCEPTANCE.md](docs/ACCEPTANCE.md).
|
|
|
62
62
|
|
|
63
63
|
## Secrets
|
|
64
64
|
|
|
65
|
-
Never commit credentials, `.env`, `.
|
|
65
|
+
Never commit credentials, `.env`, `.gpu/state.json`, or provider instance
|
|
66
66
|
ids from a real run. Keys are read from the environment, a gitignored `.env`,
|
|
67
|
-
or `~/.
|
|
67
|
+
or `~/.gpu/credentials`. `.env.example` ships with empty values only.
|
|
68
68
|
|
|
69
69
|
## Pull requests
|
|
70
70
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: jambu-gpu
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Provider-agnostic CLI for provisioning and managing temporary GPU runtimes
|
|
5
5
|
Project-URL: Homepage, https://github.com/jambuai/jambu-gpu
|
|
6
6
|
Project-URL: Repository, https://github.com/jambuai/jambu-gpu
|
|
@@ -49,7 +49,7 @@ From an application in any language: `gpu --json setup` returns the OpenAI-compa
|
|
|
49
49
|
|
|
50
50
|
As a last line of defense, `gpu ls` shows every jambu instance in the account and `gpu sweep --install-timer` sets up a systemd user timer that guards every project on the machine by its own rules and cleans up the orphans no project tracks. See [Keeping the account clean](docs/USAGE.md#keeping-the-account-clean).
|
|
51
51
|
|
|
52
|
-
The API key is read from the environment, a project `.env`, or `~/.
|
|
52
|
+
The API key is read from the environment, a project `.env`, or `~/.gpu/credentials`. It never goes in the manifest.
|
|
53
53
|
|
|
54
54
|
## Declare
|
|
55
55
|
|
|
@@ -26,7 +26,7 @@ From an application in any language: `gpu --json setup` returns the OpenAI-compa
|
|
|
26
26
|
|
|
27
27
|
As a last line of defense, `gpu ls` shows every jambu instance in the account and `gpu sweep --install-timer` sets up a systemd user timer that guards every project on the machine by its own rules and cleans up the orphans no project tracks. See [Keeping the account clean](docs/USAGE.md#keeping-the-account-clean).
|
|
28
28
|
|
|
29
|
-
The API key is read from the environment, a project `.env`, or `~/.
|
|
29
|
+
The API key is read from the environment, a project `.env`, or `~/.gpu/credentials`. It never goes in the manifest.
|
|
30
30
|
|
|
31
31
|
## Declare
|
|
32
32
|
|
|
@@ -4,7 +4,7 @@ Eighteen guarantees, mapped to the code and the test that holds each one.
|
|
|
4
4
|
|
|
5
5
|
| # | Guarantee | Implementation | Test |
|
|
6
6
|
| --- | --- | --- | --- |
|
|
7
|
-
| 1 | Repeated `setup` does not duplicate infrastructure | `core/engine.py::Engine.setup` reconciles first, compares `config.fingerprint()`, and holds an exclusive `.
|
|
7
|
+
| 1 | Repeated `setup` does not duplicate infrastructure | `core/engine.py::Engine.setup` reconciles first, compares `config.fingerprint()`, and holds an exclusive `.gpu/runtime.lock` | `test_engine.py::test_setup_twice_does_not_create_two_instances`, `::test_setup_refuses_to_reuse_an_incompatible_instance` |
|
|
8
8
|
| 2 | Provider APIs do not leak into core orchestration | `providers/base.py` contract; all Vast wire format confined to `providers/vast/{client,mapper}.py` | `test_engine.py::test_the_core_never_branches_on_a_provider_name` (greps `core/`) |
|
|
9
9
|
| 3 | Missing credentials fail before provisioning | `VastProvider.validate` returns before any API call; `CredentialResolver` never reads jambu.yaml | `test_vast_provider.py::test_validate_fails_before_provisioning_without_a_key` (asserts zero HTTP calls) |
|
|
10
10
|
| 4 | Failed provisioning cleans up | `Engine.setup` persists state *before* the long wait, then destroys on failure when `cleanup_on_setup_failure` | `test_engine.py::test_failed_setup_cleans_up_partially_created_resources` |
|
|
@@ -19,7 +19,7 @@ Eighteen guarantees, mapped to the code and the test that holds each one.
|
|
|
19
19
|
| 13 | A restarted instance stays guarded | `Engine._reconcile` takes running/stopped from the provider; the watchdog's `terminated` flag only counts when the provider cannot tell. `RuntimeState.session_started_at` restarts on every start, and `max_lifetime` and the session budget are measured from it | `test_engine_sessions.py::test_reconcile_trusts_the_provider_over_a_stale_terminated_flag`, `::test_a_stale_terminated_flag_does_not_disable_the_guard`, `::test_restarting_a_stopped_instance_starts_a_new_session`, `::test_guard_budget_resets_with_a_new_session`; watchdog-side session tests in `test_policy.py` / `test_watchdog.py` |
|
|
20
20
|
| 14 | Traffic on the model endpoint is activity | `VllmRuntime.activity_probe` hands the watchdog vLLM's `/metrics` gauges and counters; `lifecycle.traffic_counts_as_activity` turns it off | `test_engine_sessions.py::test_the_watchdog_is_told_how_to_read_vllm_traffic`, `::test_traffic_can_be_excluded_from_activity`, `test_runtime_vllm.py::test_activity_probe_reads_vllm_metrics_on_loopback`; the watchdog's metrics-reading tests in `test_watchdog.py` |
|
|
21
21
|
| 15 | A stopped instance does not bill storage forever | `lifecycle.stopped_ttl` -> `PolicyConfig.stopped_ttl_s`; `RuntimeState.stopped_since`; `Engine.guard_tick` destroys with reason `stopped_ttl`; `validate` warns when nothing would remove it | `test_engine_sessions.py::test_guard_destroys_an_instance_stopped_longer_than_stopped_ttl`, `::test_validate_warns_when_nothing_removes_a_billing_stopped_instance`; the stopped-TTL policy tests in `test_policy.py` |
|
|
22
|
-
| 16 | Orphans are found account-wide, each project keeps its own rules, and nothing else is touched | `GPUProvider.list_instances` (capability `list_instances`) returns only `jambu-` labels; `gpu setup` registers the project in `~/.
|
|
22
|
+
| 16 | Orphans are found account-wide, each project keeps its own rules, and nothing else is touched | `GPUProvider.list_instances` (capability `list_instances`) returns only `jambu-` labels; `gpu setup` registers the project in `~/.gpu/projects.json`; `core/sweep.py::guard_projects` runs each registered project's own `Engine.guard_tick`; only untracked instances are orphans: running past `--orphan-max-age` is stopped (never destroyed), stopped past `--stopped-ttl` (from the sweep's own first sighting in `~/.gpu/sweep.json`) is destroyed; `gpu sweep --install-timer` schedules one working-directory-independent unit and refuses a key that is only in the shell | `test_sweep.py::test_vast_lists_only_jambu_labelled_instances`, `::test_non_jambu_instances_are_never_touched`, `::test_another_projects_restarted_and_locked_instance_is_not_destroyed`, `::test_each_project_is_judged_by_its_own_max_lifetime`, `::test_a_running_orphan_past_orphan_max_age_is_stopped_never_destroyed`, `::test_a_stopped_orphan_is_destroyed_once_seen_stopped_past_stopped_ttl`, `::test_install_timer_writes_one_unit_independent_of_the_project`, `::test_install_timer_refuses_a_key_only_in_the_shell_environment` |
|
|
23
23
|
| 17 | The CLI guard never stops a serving endpoint it cannot see | `Engine._idle_blind`: with the watchdog enabled and installed but unreachable, `guard_tick` evaluates without `idle_timeout` and records `idle_skipped`; `max_lifetime`, budget and `stopped_ttl` still apply | `test_engine_sessions.py::test_guard_leaves_idle_to_an_unreachable_watchdog`, `::test_max_lifetime_still_applies_when_the_watchdog_is_unreachable` |
|
|
24
24
|
| 18 | A fresh stop or a short API outage never costs the disk | `RuntimeState.mark_stopped` restarts `stopped_since` on evidence the instance ran since; the watchdog escalates a failing stop to destroy only after `escalate_after_s` (30m) of continuous failure and resets on renewed use | `test_engine_sessions.py::test_a_stale_stopped_since_does_not_destroy_a_fresh_stop`, `test_watchdog.py::test_an_api_blip_does_not_destroy_the_disk`, `::test_failures_reset_when_the_instance_is_back_in_use` |
|
|
25
25
|
|
|
@@ -86,10 +86,11 @@ each number comes from.
|
|
|
86
86
|
| `gpu.count` | `1` | Number of GPUs on one machine. |
|
|
87
87
|
| `gpu.name_filter` | – | Substring match, e.g. `A100`, `RTX 4090`. |
|
|
88
88
|
| `gpu.cuda_min` | – | Minimum host CUDA version. `gpu` raises it to what the runtime image needs, read from its tag (`-cu129` means 12.9, vLLM's bare `v0.32+` and `latest` mean 13.0), so an image never lands on a driver too old to run it. |
|
|
89
|
+
| `gpu.compute_cap_min` | – | Minimum GPU **compute capability** (architecture), e.g. `8.0` for Ampere. Distinct from `cuda_min`: a Pascal (GTX 10xx, 6.1) or Volta (V100, 7.0) card can sit behind a modern CUDA-13 driver — clearing `cuda_min` — yet still be too old for a runtime image whose kernels are built for newer architectures, which then crashes at engine startup. `gpu` raises this to what the runtime needs: a CUDA-13 vLLM image floors it at `8.0` (those wheels drop Volta), a CUDA-12 vLLM image at `7.0`. The cheapest Vast offers are often exactly these old cards, so without the floor the search keeps picking a GPU the image cannot run. |
|
|
89
90
|
| `disk_gb` | `80` | Disk. Must fit the model weights plus the container. |
|
|
90
91
|
| `region` | – | Comma-separated geolocations. |
|
|
91
92
|
| `spot` | `false` | Use interruptible/bid capacity where supported. |
|
|
92
|
-
| `host_attempts` | `3` | How many machines `setup` tries. A host that fails to boot (the provider reports it failed, it vanishes, or it is not running within `health.boot_timeout`) is destroyed, replaced from fresh offers (each held to the budget), and remembered in `$
|
|
93
|
+
| `host_attempts` | `3` | How many machines `setup` tries. A host that fails to boot (the provider reports it failed, it vanishes, or it is not running within `health.boot_timeout`) is destroyed, replaced from fresh offers (each held to the budget), and remembered in `$GPU_HOME/bad_hosts.json` so no setup rents it again for 24h. A model server that dies once its container runs fails at once, since the same config would fail on any machine, unless its log shows the host's network failed it (a dropped connection or timeout while downloading weights): that counts as a host failure too. Needs `lifecycle.cleanup_on_setup_failure`; without it the first failed host is kept for inspection. |
|
|
93
94
|
| `image` | – | Override the runtime's container image. vLLM's default is `vllm/vllm-openai:v0.32.0`. Pin a tag: `validate` warns on `latest` or an untagged image, because the CUDA it needs can change under you. On Vast, `vastai/vllm:v0.32.0-cuda-13.0` (the same vLLM, packaged by Vast) is often cached on the host and boots in minutes where a 12 GB Docker Hub pull can stall. |
|
|
94
95
|
|
|
95
96
|
---
|
|
@@ -15,16 +15,17 @@ The app never has to stop the GPU. The watchdog on the machine stops it once not
|
|
|
15
15
|
pipx install jambu-gpu # or: uv tool install jambu-gpu / pip install jambu-gpu
|
|
16
16
|
cd /srv/my-app # the directory the app runs gpu from
|
|
17
17
|
gpu init --model Qwen/Qwen2.5-7B-Instruct
|
|
18
|
-
echo "VAST_API_KEY=..." >> .env # or export it, or ~/.
|
|
18
|
+
echo "VAST_API_KEY=..." >> .env # or export it, or ~/.gpu/credentials
|
|
19
19
|
gpu validate # fails here, not in production, on a bad config
|
|
20
20
|
gpu sweep --install-timer # optional safety net, see USAGE.md
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
Requirements:
|
|
24
24
|
|
|
25
|
-
- `jambu-gpu` 0.
|
|
26
|
-
- The directory holding `jambu.yaml` must be **persistent**. `gpu` keeps what it rented in `.
|
|
27
|
-
-
|
|
25
|
+
- `jambu-gpu` 0.3.0 or later, on Python 3.10+. The app can be in any language.
|
|
26
|
+
- The directory holding `jambu.yaml` must be **persistent**. `gpu` keeps what it rented in `.gpu/state.json` next to it. In a container, mount it as a volume: a container that loses `.gpu/` forgets its instance, rents a second one, and leaves the first to the watchdog and the sweep.
|
|
27
|
+
- Before 0.3.0 these directories were `.jambu/` and `~/.jambu`. The first command after an upgrade renames them, so an instance already rented is kept, not rented again.
|
|
28
|
+
- `.gpu/state.json` holds the endpoint key and the watchdog token. It is written `0600`. Add `.gpu/` and `.env` to `.gitignore` and keep them out of images.
|
|
28
29
|
|
|
29
30
|
Tune the lifecycle in `jambu.yaml` for the app. For a service with bursty traffic, a longer idle timeout avoids paying a restart on every burst:
|
|
30
31
|
|
|
@@ -218,7 +219,7 @@ Run `gpu --json setup` as a subprocess, parse the JSON, and point the language's
|
|
|
218
219
|
- **The endpoint requires a key.** Unless `runtime.api_key` is set, `setup` generates one per machine (43 random URL-safe characters) and starts vLLM with it. Requests without it get `401`. `/health` and `/metrics` stay open: they reveal no data and the watchdog reads `/metrics`. Instances provisioned before this existed run without a key until they are recreated.
|
|
219
220
|
- **The endpoint is plain HTTP** on a public IP and port. The key and the prompts travel unencrypted. For sensitive data, put the app and the GPU on a private network or a tunnel, or front the endpoint with a TLS proxy.
|
|
220
221
|
- **The watchdog holds the provider key** on the instance so it can stop its own machine. A restricted key is the safer choice; see [LIFECYCLE.md](LIFECYCLE.md).
|
|
221
|
-
- `jambu.yaml` never holds credentials. `.env` and `.
|
|
222
|
+
- `jambu.yaml` never holds credentials. `.env` and `.gpu/` do not belong in version control.
|
|
222
223
|
|
|
223
224
|
## 6. Troubleshooting
|
|
224
225
|
|
|
@@ -105,7 +105,7 @@ gpu
|
|
|
105
105
|
│
|
|
106
106
|
├── Lifecycle core/policy.py the only shutdown rule
|
|
107
107
|
├── Runtime runtime/vllm.py how the model runs
|
|
108
|
-
└── State core/state.py .
|
|
108
|
+
└── State core/state.py .gpu/state.json
|
|
109
109
|
│
|
|
110
110
|
Provider contract providers/base.py
|
|
111
111
|
│
|
|
@@ -111,9 +111,9 @@ Vast.ai instances
|
|
|
111
111
|
|
|
112
112
|
### Every project by its own rules
|
|
113
113
|
|
|
114
|
-
`gpu setup` (and `gpu run`, which can provision) registers the project's absolute config path in `~/.
|
|
114
|
+
`gpu setup` (and `gpu run`, which can provision) registers the project's absolute config path in `~/.gpu/projects.json` (`$GPU_HOME` overrides `~/.gpu`; a `~/.jambu` from before 0.3.0 is renamed to `~/.gpu` on first use, and `$JAMBU_HOME` is still read). `gpu sweep` then works in two steps:
|
|
115
115
|
|
|
116
|
-
1. **Tracked instances.** For every registered project whose config still loads, it runs that project's own `gpu guard`: its own `jambu.yaml` and `.
|
|
116
|
+
1. **Tracked instances.** For every registered project whose config still loads, it runs that project's own `gpu guard`: its own `jambu.yaml` and `.gpu/state.json`, so its own `max_lifetime`, lock, session start, `stopped_ttl` and budget. The project's events land in its own log. A registered project whose config file is gone is dropped from the registry. The project in the current directory, or the one given with `--config`, is guarded too for that run.
|
|
117
117
|
2. **Orphans.** An instance no project tracks has no known policy and nobody knows whether it is in use, so the rules are blunt but never destroy a running machine:
|
|
118
118
|
|
|
119
119
|
| Orphan | Rule | Action |
|
|
@@ -134,7 +134,7 @@ $ gpu sweep
|
|
|
134
134
|
! 1 action(s) pending; re-run with --apply
|
|
135
135
|
```
|
|
136
136
|
|
|
137
|
-
Providers do not report when an instance stopped, so the sweep keeps the time it first saw each orphan stopped in `~/.
|
|
137
|
+
Providers do not report when an instance stopped, so the sweep keeps the time it first saw each orphan stopped in `~/.gpu/sweep.json`. An entry goes away when the instance runs again, disappears, or is tracked by a project. `stopped_ttl` for an orphan therefore counts from the first sweep that saw it stopped, never from a project's state. Every orphan action is recorded as a `sweep_action` event with its reason in `~/.gpu/logs/events.jsonl`. Credentials for the orphan step resolve the usual way (the environment, `.env` in the working directory, `~/.gpu/credentials`), then from the `.env` of registered projects.
|
|
138
138
|
|
|
139
139
|
### Run it on a timer
|
|
140
140
|
|
|
@@ -143,9 +143,9 @@ gpu sweep --install-timer # every 5m; --every 15m to change
|
|
|
143
143
|
systemctl --user daemon-reload && systemctl --user enable --now jambu-gpu-sweep.timer
|
|
144
144
|
```
|
|
145
145
|
|
|
146
|
-
`--install-timer` writes `~/.config/systemd/user/jambu-gpu-sweep.service` and `.timer`, and registers the current project if there is one. The service is a single `ExecStart=<python> -m jambu_gpu sweep --apply` with the interpreter that installed it, run from `~`: which projects get guarded comes from the registry, not from the working directory, so installing from a second project changes nothing in the unit. The oneshot has a 10 minute start timeout, a failing sweep shows as a failed unit, and `
|
|
146
|
+
`--install-timer` writes `~/.config/systemd/user/jambu-gpu-sweep.service` and `.timer`, and registers the current project if there is one. The service is a single `ExecStart=<python> -m jambu_gpu sweep --apply` with the interpreter that installed it, run from `~`: which projects get guarded comes from the registry, not from the working directory, so installing from a second project changes nothing in the unit. The oneshot has a 10 minute start timeout, a failing sweep shows as a failed unit, and `GPU_HOME` is passed through when it is set. A unit written by an older version, with one `guard` line per project, is rewritten and those projects are registered.
|
|
147
147
|
|
|
148
|
-
A user unit does not inherit your shell's environment. `--install-timer` therefore refuses unless the provider key is in a file: the project `.env` or `~/.
|
|
148
|
+
A user unit does not inherit your shell's environment. `--install-timer` therefore refuses unless the provider key is in a file: the project `.env` or `~/.gpu/credentials`. A key that is only exported in your shell is not enough. A user timer needs no root, but it stops at logout unless lingering is on (`loginctl enable-linger $USER`).
|
|
149
149
|
|
|
150
150
|
```bash
|
|
151
151
|
gpu sweep --uninstall-timer
|
|
@@ -6,7 +6,7 @@ Any Node/TypeScript application using a GPU that `gpu` rents and guards, through
|
|
|
6
6
|
|
|
7
7
|
## Run it
|
|
8
8
|
|
|
9
|
-
From the directory that holds `jambu.yaml` and `VAST_API_KEY` (in `.env` or the environment), with `jambu-gpu` 0.
|
|
9
|
+
From the directory that holds `jambu.yaml` and `VAST_API_KEY` (in `.env` or the environment), with `jambu-gpu` 0.3.0 or later on `PATH`:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npm --prefix examples/typescript-client install
|
|
@@ -118,7 +118,7 @@ def fmt_money(value: Optional[float], suffix: str = "") -> Optional[str]:
|
|
|
118
118
|
|
|
119
119
|
|
|
120
120
|
def register_project(context: Context) -> None:
|
|
121
|
-
"""Put this project in $
|
|
121
|
+
"""Put this project in $GPU_HOME/projects.json so `gpu sweep` guards it.
|
|
122
122
|
|
|
123
123
|
Done before provisioning: a setup that dies halfway must still leave an
|
|
124
124
|
instance the sweep judges by this project's policy, not as an orphan.
|
|
@@ -57,7 +57,7 @@ class Account:
|
|
|
57
57
|
|
|
58
58
|
|
|
59
59
|
def account_credentials(project_dir: Path, profile: str = "default") -> CredentialResolver:
|
|
60
|
-
"""The environment, ./.env, ~/.
|
|
60
|
+
"""The environment, ./.env, ~/.gpu/credentials, then registered projects' .env.
|
|
61
61
|
|
|
62
62
|
The timer runs from ~ without the shell's environment; a key kept only in
|
|
63
63
|
a project's .env must still reach the account-wide orphan sweep.
|
|
@@ -196,7 +196,7 @@ def _require_file_credentials(context: Context) -> None:
|
|
|
196
196
|
raise ConfigError(
|
|
197
197
|
f"{name} is {where}, so the timer could not use it for {who}: a systemd "
|
|
198
198
|
f"user unit does not inherit your shell. Put `{name}=...` in "
|
|
199
|
-
"~/.
|
|
199
|
+
"~/.gpu/credentials (chmod 600), then re-run --install-timer."
|
|
200
200
|
)
|
|
201
201
|
|
|
202
202
|
|
|
@@ -19,7 +19,7 @@ app = typer.Typer(
|
|
|
19
19
|
name="gpu",
|
|
20
20
|
help=(
|
|
21
21
|
"Provider-agnostic CLI for provisioning and managing temporary GPU runtimes.\n\n"
|
|
22
|
-
"jambu.yaml is the desired-state source of truth; .
|
|
22
|
+
"jambu.yaml is the desired-state source of truth; .gpu/state.json is what "
|
|
23
23
|
"was last observed."
|
|
24
24
|
),
|
|
25
25
|
no_args_is_help=True,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"""systemd *user* units that run `gpu sweep --apply` on a timer.
|
|
2
2
|
|
|
3
3
|
One ``ExecStart`` covers every project: the sweep guards each project in
|
|
4
|
-
``$
|
|
4
|
+
``$GPU_HOME/projects.json`` with its own config, so the unit does not depend
|
|
5
5
|
on its working directory. A user unit needs no root, but it does not inherit
|
|
6
|
-
the shell's environment: credentials must live in ``~/.
|
|
6
|
+
the shell's environment: credentials must live in ``~/.gpu/credentials`` or
|
|
7
7
|
the project ``.env``. Writing the files is all this module does; enabling them
|
|
8
8
|
is left to the printed ``systemctl`` command.
|
|
9
9
|
"""
|
|
@@ -64,7 +64,7 @@ def render_units(
|
|
|
64
64
|
f"TimeoutStartSec={START_TIMEOUT}",
|
|
65
65
|
]
|
|
66
66
|
if jambu_home:
|
|
67
|
-
lines.append(_environment("
|
|
67
|
+
lines.append(_environment("GPU_HOME", jambu_home))
|
|
68
68
|
# No "-" prefix: a failing sweep must show up as a failed unit.
|
|
69
69
|
lines.append("ExecStart=" + " ".join(_quote(a) for a in [python, "-m", "jambu_gpu", "sweep", "--apply"]))
|
|
70
70
|
lines.append("")
|
|
@@ -115,7 +115,9 @@ def install_timer(every_s: float, python: Optional[str] = None) -> dict:
|
|
|
115
115
|
service_path, timer_path = unit_paths()
|
|
116
116
|
legacy = legacy_guard_configs(service_path)
|
|
117
117
|
service, timer = render_units(
|
|
118
|
-
every_s=every_s,
|
|
118
|
+
every_s=every_s,
|
|
119
|
+
python=python,
|
|
120
|
+
jambu_home=os.environ.get("GPU_HOME") or os.environ.get("JAMBU_HOME") or None,
|
|
119
121
|
)
|
|
120
122
|
service_path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
123
|
service_path.write_text(service)
|
|
@@ -33,12 +33,12 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
|
|
|
33
33
|
|
|
34
34
|
from .durations import parse_duration
|
|
35
35
|
from .errors import ConfigError
|
|
36
|
+
from .paths import project_state_dir
|
|
36
37
|
|
|
37
38
|
# jambu.yaml (namespaced, shared with other Jambu Lab tools) is searched
|
|
38
39
|
# first; config.yml (flat, gpu-runtime-only) is the legacy fallback.
|
|
39
40
|
CONFIG_FILENAMES = ("jambu.yaml", "jambu.yml", "config.yml", "config.yaml")
|
|
40
41
|
GPU_RUNTIME_KEY = "gpu_runtime"
|
|
41
|
-
STATE_DIRNAME = ".jambu"
|
|
42
42
|
|
|
43
43
|
# The model catalog (spec: "Choosing a model" doc) - a project's jambu.yaml
|
|
44
44
|
# references one entry by name instead of redeclaring the whole model/compute/
|
|
@@ -79,6 +79,11 @@ class GpuConfig(_Base):
|
|
|
79
79
|
count: int = 1
|
|
80
80
|
name_filter: Optional[str] = None
|
|
81
81
|
cuda_min: Optional[str] = None
|
|
82
|
+
# Minimum GPU compute capability (architecture), e.g. 8.0 to require
|
|
83
|
+
# Ampere or newer. Raised to what the runtime needs; see
|
|
84
|
+
# ModelRuntime.required_compute_cap. Leave unset to accept the runtime's
|
|
85
|
+
# own floor.
|
|
86
|
+
compute_cap_min: Optional[float] = None
|
|
82
87
|
|
|
83
88
|
@field_validator("count")
|
|
84
89
|
@classmethod
|
|
@@ -319,7 +324,7 @@ class RuntimeConfig(_Base):
|
|
|
319
324
|
|
|
320
325
|
@property
|
|
321
326
|
def state_dir(self) -> Path:
|
|
322
|
-
return self.project_dir
|
|
327
|
+
return project_state_dir(self.project_dir)
|
|
323
328
|
|
|
324
329
|
def fingerprint(self) -> str:
|
|
325
330
|
"""Identity of the *desired* runtime.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Credential resolution (spec section 5).
|
|
2
2
|
|
|
3
3
|
Credentials never live in jambu.yaml. They are resolved from, in order:
|
|
4
|
-
environment variables, a project-local ``.env``, then ``~/.
|
|
4
|
+
environment variables, a project-local ``.env``, then ``~/.gpu/credentials``.
|
|
5
5
|
The resolution strategy can grow (keychain, secret managers, CI secrets)
|
|
6
6
|
without touching jambu.yaml or any adapter.
|
|
7
7
|
"""
|
|
@@ -12,6 +12,8 @@ import os
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Optional
|
|
14
14
|
|
|
15
|
+
from .paths import gpu_home
|
|
16
|
+
|
|
15
17
|
_DOTENV_CACHE: dict[Path, dict[str, str]] = {}
|
|
16
18
|
|
|
17
19
|
|
|
@@ -46,7 +48,7 @@ class CredentialResolver:
|
|
|
46
48
|
self.profile = profile
|
|
47
49
|
self.sources: list[tuple[str, Path]] = [
|
|
48
50
|
("project .env", self.project_dir / ".env"),
|
|
49
|
-
("user credentials",
|
|
51
|
+
("user credentials", gpu_home() / "credentials"),
|
|
50
52
|
*(extra_sources or []),
|
|
51
53
|
]
|
|
52
54
|
|
|
@@ -366,6 +366,7 @@ class Engine:
|
|
|
366
366
|
count=cfg.compute.gpu.count,
|
|
367
367
|
name_filter=cfg.compute.gpu.name_filter,
|
|
368
368
|
cuda_min=self.cuda_min(),
|
|
369
|
+
compute_cap_min=self.compute_cap_min(),
|
|
369
370
|
),
|
|
370
371
|
disk_gb=cfg.compute.disk_gb,
|
|
371
372
|
image=self.runtime.image(),
|
|
@@ -389,6 +390,18 @@ class Engine:
|
|
|
389
390
|
return configured
|
|
390
391
|
return f"{needed:.1f}"
|
|
391
392
|
|
|
393
|
+
def compute_cap_min(self) -> Optional[float]:
|
|
394
|
+
"""The stricter of compute.gpu.compute_cap_min and the runtime's floor."""
|
|
395
|
+
candidates = [
|
|
396
|
+
c
|
|
397
|
+
for c in (
|
|
398
|
+
self.config.compute.gpu.compute_cap_min,
|
|
399
|
+
self.runtime.required_compute_cap(),
|
|
400
|
+
)
|
|
401
|
+
if c is not None
|
|
402
|
+
]
|
|
403
|
+
return max(candidates) if candidates else None
|
|
404
|
+
|
|
392
405
|
def _ports(self) -> list[int]:
|
|
393
406
|
ports = list(self.runtime.ports())
|
|
394
407
|
if self.config.lifecycle.watchdog.enabled:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Structured lifecycle events (spec section 24).
|
|
2
2
|
|
|
3
|
-
Every lifecycle action appends one JSON object to ``.
|
|
3
|
+
Every lifecycle action appends one JSON object to ``.gpu/logs/events.jsonl``
|
|
4
4
|
so unexpected GPU cost can always be traced back to a decision and a reason.
|
|
5
5
|
"""
|
|
6
6
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A host that could not pull the image or reach the network fails the next
|
|
4
4
|
setup the same way, and the cheapest-offer search keeps picking it. Each
|
|
5
|
-
failure is kept for ``BAD_HOST_TTL_S`` in ``$
|
|
5
|
+
failure is kept for ``BAD_HOST_TTL_S`` in ``$GPU_HOME/bad_hosts.json``.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
@@ -91,6 +91,11 @@ class GpuSpec:
|
|
|
91
91
|
count: int = 1
|
|
92
92
|
name_filter: Optional[str] = None
|
|
93
93
|
cuda_min: Optional[str] = None
|
|
94
|
+
# Minimum GPU compute capability (architecture), e.g. 8.0 for Ampere. The
|
|
95
|
+
# host driver's CUDA (``cuda_min``) is not the same thing: a Pascal/Volta
|
|
96
|
+
# card can sit behind a CUDA-13 driver yet still be too old for a runtime
|
|
97
|
+
# image built for newer architectures, which then dies at engine startup.
|
|
98
|
+
compute_cap_min: Optional[float] = None
|
|
94
99
|
|
|
95
100
|
|
|
96
101
|
@dataclass
|
|
@@ -127,6 +132,9 @@ class Offer:
|
|
|
127
132
|
score: Optional[float] = None
|
|
128
133
|
# Highest CUDA version the host's driver supports, when the provider says.
|
|
129
134
|
cuda_max: Optional[float] = None
|
|
135
|
+
# The GPU's compute capability (architecture), e.g. 8.6 for an RTX 3090,
|
|
136
|
+
# when the provider says. Distinct from cuda_max (the driver's CUDA).
|
|
137
|
+
compute_cap: Optional[float] = None
|
|
130
138
|
# The physical machine behind the offer, when the provider says. Offers
|
|
131
139
|
# on one machine share its network, disk and Docker daemon.
|
|
132
140
|
host_id: Optional[str] = None
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Where gpu keeps its files: ``.gpu/`` in a project, ``~/.gpu`` per user.
|
|
2
|
+
|
|
3
|
+
Both were named ``.jambu`` before 0.3.0. A legacy directory is renamed in
|
|
4
|
+
place the first time it is needed, so an instance a project already tracks
|
|
5
|
+
is never forgotten (and never rented twice) after an upgrade.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
STATE_DIRNAME = ".gpu"
|
|
14
|
+
LEGACY_STATE_DIRNAME = ".jambu"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def adopt_legacy(path: Path, legacy: Path) -> Path:
|
|
18
|
+
"""``path``, after moving ``legacy`` there when only the legacy one exists."""
|
|
19
|
+
if not path.exists() and legacy.is_dir():
|
|
20
|
+
try:
|
|
21
|
+
legacy.rename(path)
|
|
22
|
+
except OSError:
|
|
23
|
+
# Read-only or racing another gpu process: keep using the old one.
|
|
24
|
+
return path if path.exists() else legacy
|
|
25
|
+
return path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def project_state_dir(project_dir: Path) -> Path:
|
|
29
|
+
project_dir = Path(project_dir)
|
|
30
|
+
return adopt_legacy(project_dir / STATE_DIRNAME, project_dir / LEGACY_STATE_DIRNAME)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def gpu_home() -> Path:
|
|
34
|
+
"""Per-user files: the project registry, sweep sightings, bad hosts, credentials.
|
|
35
|
+
|
|
36
|
+
``$GPU_HOME`` overrides it (``$JAMBU_HOME`` is still honored).
|
|
37
|
+
"""
|
|
38
|
+
override = os.environ.get("GPU_HOME") or os.environ.get("JAMBU_HOME")
|
|
39
|
+
if override:
|
|
40
|
+
return Path(override).expanduser()
|
|
41
|
+
home = Path.home()
|
|
42
|
+
return adopt_legacy(home / STATE_DIRNAME, home / LEGACY_STATE_DIRNAME)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Observed runtime state (spec section 9).
|
|
2
2
|
|
|
3
|
-
jambu.yaml is the desired-state SoT; ``.
|
|
3
|
+
jambu.yaml is the desired-state SoT; ``.gpu/state.json`` is what we last
|
|
4
4
|
observed. This module owns reading/writing it atomically, plus the on-disk
|
|
5
5
|
process lock that keeps two CLI invocations from provisioning twice.
|
|
6
6
|
"""
|
|
@@ -312,7 +312,7 @@ class RuntimeState:
|
|
|
312
312
|
|
|
313
313
|
|
|
314
314
|
class StateStore:
|
|
315
|
-
"""Atomic reader/writer for ``.
|
|
315
|
+
"""Atomic reader/writer for ``.gpu/state.json``."""
|
|
316
316
|
|
|
317
317
|
def __init__(self, state_dir: Path) -> None:
|
|
318
318
|
self._depth = 0 # flock is per-open-file-description: keep it reentrant
|
|
@@ -4,7 +4,7 @@ The watchdog guards one instance from the inside and `gpu guard` guards the
|
|
|
4
4
|
one instance a project tracks. The sweep covers every project on this machine
|
|
5
5
|
and whatever is left over:
|
|
6
6
|
|
|
7
|
-
- every project registered in ``$
|
|
7
|
+
- every project registered in ``$GPU_HOME/projects.json`` (by `gpu setup`)
|
|
8
8
|
gets its own ``Engine.guard_tick``, with its own config and state: its own
|
|
9
9
|
max_lifetime, lock, session and stopped_ttl. Those instances are *tracked*;
|
|
10
10
|
- an instance no project tracks is an *orphan*. Nobody knows its policy or
|
|
@@ -14,7 +14,7 @@ and whatever is left over:
|
|
|
14
14
|
ago is destroyed.
|
|
15
15
|
|
|
16
16
|
Providers do not report when an instance stopped, so the first time the sweep
|
|
17
|
-
saw each orphan stopped is kept in ``$
|
|
17
|
+
saw each orphan stopped is kept in ``$GPU_HOME/sweep.json``.
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
20
|
from __future__ import annotations
|
|
@@ -30,7 +30,8 @@ from typing import Any, Callable, Iterator, Optional
|
|
|
30
30
|
|
|
31
31
|
from . import events as ev
|
|
32
32
|
from . import policy
|
|
33
|
-
from .config import
|
|
33
|
+
from .config import load_config
|
|
34
|
+
from .paths import gpu_home, project_state_dir
|
|
34
35
|
from .errors import JambuError, ProviderError
|
|
35
36
|
from .events import EventLog
|
|
36
37
|
from .models import Instance, InstanceState, is_jambu_label
|
|
@@ -46,8 +47,7 @@ SWEEP_ACTION = "sweep_action"
|
|
|
46
47
|
|
|
47
48
|
|
|
48
49
|
def jambu_home() -> Path:
|
|
49
|
-
|
|
50
|
-
return Path(override).expanduser() if override else Path.home() / ".jambu"
|
|
50
|
+
return gpu_home()
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
class SweepRegistry:
|
|
@@ -198,7 +198,7 @@ class ProjectGuard:
|
|
|
198
198
|
|
|
199
199
|
def _state_beside(config_path: Path) -> Optional[RuntimeState]:
|
|
200
200
|
"""The project's state read straight from disk (no API call)."""
|
|
201
|
-
store = StateStore(Path(config_path).parent
|
|
201
|
+
store = StateStore(project_state_dir(Path(config_path).parent))
|
|
202
202
|
try:
|
|
203
203
|
return store.load() if store.exists() else None
|
|
204
204
|
except JambuError:
|
|
@@ -82,6 +82,12 @@ def _float_or_none(value: Any) -> Optional[float]:
|
|
|
82
82
|
return None
|
|
83
83
|
|
|
84
84
|
|
|
85
|
+
def _compute_cap(value: Any) -> Optional[float]:
|
|
86
|
+
"""Vast's ``compute_cap`` is capability ×100 (860 → 8.6); normalize it."""
|
|
87
|
+
raw = _float_or_none(value)
|
|
88
|
+
return raw / 100.0 if raw else None
|
|
89
|
+
|
|
90
|
+
|
|
85
91
|
def effective_min_cuda(spec: ComputeSpec, options: VastOptions) -> Optional[float]:
|
|
86
92
|
"""The stricter of the adapter's floor and what the spec (image) needs."""
|
|
87
93
|
known = [
|
|
@@ -110,6 +116,8 @@ def map_offer(raw: dict[str, Any]) -> Offer:
|
|
|
110
116
|
score=float(raw.get("score") or 0.0) if raw.get("score") is not None else None,
|
|
111
117
|
# Highest CUDA the host driver runs; an image needing more dies at boot.
|
|
112
118
|
cuda_max=_float_or_none(raw.get("cuda_max_good")),
|
|
119
|
+
# Vast reports compute capability ×100 (860 = 8.6); normalize to 8.6.
|
|
120
|
+
compute_cap=_compute_cap(raw.get("compute_cap")),
|
|
113
121
|
host_id=host_id(raw),
|
|
114
122
|
raw=raw,
|
|
115
123
|
)
|
|
@@ -193,6 +201,12 @@ def build_search_query(
|
|
|
193
201
|
min_cuda = effective_min_cuda(spec, options)
|
|
194
202
|
if min_cuda:
|
|
195
203
|
query["cuda_max_good"] = {"gte": min_cuda}
|
|
204
|
+
# Floor the GPU architecture, not just the driver's CUDA: Vast reports
|
|
205
|
+
# compute capability ×100, so 8.0 -> 800. Without this the cheapest offers
|
|
206
|
+
# (old Volta/Pascal cards behind modern drivers) pass cuda_max_good but
|
|
207
|
+
# crash a newer runtime image at engine startup.
|
|
208
|
+
if spec.gpu.compute_cap_min:
|
|
209
|
+
query["compute_cap"] = {"gte": int(round(float(spec.gpu.compute_cap_min) * 100))}
|
|
196
210
|
# gpu_name is deliberately NOT sent server-side: Vast's field only
|
|
197
211
|
# accepts an exact match ({"eq": ...}), but name_filter is documented and
|
|
198
212
|
# used everywhere else (offer_matches below) as a case-insensitive
|
|
@@ -258,6 +272,10 @@ def offer_matches(offer: Offer, spec: ComputeSpec, min_cuda: Optional[float] = N
|
|
|
258
272
|
# 1e-6: 12.9 must match a host reporting exactly 12.9.
|
|
259
273
|
if min_cuda and offer.cuda_max is not None and offer.cuda_max + 1e-6 < min_cuda:
|
|
260
274
|
return False
|
|
275
|
+
# Vast's compute_cap filter is advisory; verify the architecture floor too.
|
|
276
|
+
min_cc = spec.gpu.compute_cap_min
|
|
277
|
+
if min_cc and offer.compute_cap is not None and offer.compute_cap + 1e-6 < float(min_cc):
|
|
278
|
+
return False
|
|
261
279
|
if offer.gpu_count < spec.gpu.count:
|
|
262
280
|
return False
|
|
263
281
|
if spec.gpu.min_vram_gb and offer.gpu_vram_gb + 0.5 < float(spec.gpu.min_vram_gb):
|
|
@@ -116,6 +116,16 @@ class ModelRuntime(ABC):
|
|
|
116
116
|
"""
|
|
117
117
|
return cuda_from_tag(image_tag(self.image()))
|
|
118
118
|
|
|
119
|
+
def required_compute_cap(self) -> Optional[float]:
|
|
120
|
+
"""Minimum GPU compute capability (architecture) the runtime needs.
|
|
121
|
+
|
|
122
|
+
None if unknown. The offer filter is raised to this, so an image whose
|
|
123
|
+
kernels are built for newer architectures is never placed on a GPU too
|
|
124
|
+
old to run them (which the host driver's CUDA alone does not catch: a
|
|
125
|
+
Volta card behind a CUDA-13 driver still fails at engine startup).
|
|
126
|
+
"""
|
|
127
|
+
return None
|
|
128
|
+
|
|
119
129
|
# -- on-instance liveness -------------------------------------------------
|
|
120
130
|
|
|
121
131
|
def runtime_pid_path(self) -> Optional[str]:
|
|
@@ -123,6 +123,16 @@ class VllmRuntime(ModelRuntime):
|
|
|
123
123
|
def required_cuda(self) -> Optional[float]:
|
|
124
124
|
return vllm_image_cuda(self.image())
|
|
125
125
|
|
|
126
|
+
def required_compute_cap(self) -> Optional[float]:
|
|
127
|
+
# vLLM wheels built for CUDA 13+ drop Volta (sm_70) and need Ampere
|
|
128
|
+
# (8.0) or newer; those crash at engine-core init on a V100 or older.
|
|
129
|
+
# CUDA 12.x wheels still run Volta. Pascal (GTX 10xx, 6.1) is below
|
|
130
|
+
# vLLM's floor on any build.
|
|
131
|
+
cuda = self.required_cuda()
|
|
132
|
+
if cuda is None:
|
|
133
|
+
return None
|
|
134
|
+
return 8.0 if cuda >= 13.0 else 7.0
|
|
135
|
+
|
|
126
136
|
def runtime_pid_path(self) -> str:
|
|
127
137
|
return PID_PATH
|
|
128
138
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "jambu-gpu"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Provider-agnostic CLI for provisioning and managing temporary GPU runtimes"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -36,7 +36,7 @@ gpu = "jambu_gpu.cli.main:main"
|
|
|
36
36
|
packages = ["jambu_gpu"]
|
|
37
37
|
|
|
38
38
|
[tool.hatch.build.targets.sdist]
|
|
39
|
-
exclude = ["/.env", "/.venv", "/.jambu"]
|
|
39
|
+
exclude = ["/.env", "/.venv", "/.gpu", "/.jambu"]
|
|
40
40
|
|
|
41
41
|
[tool.pytest.ini_options]
|
|
42
42
|
testpaths = ["tests"]
|
|
@@ -29,9 +29,10 @@ BASE_CONFIG = {
|
|
|
29
29
|
|
|
30
30
|
@pytest.fixture(autouse=True)
|
|
31
31
|
def jambu_home(tmp_path, monkeypatch):
|
|
32
|
-
"""Keep the project registry and sweep state out of the real ~/.
|
|
33
|
-
home = tmp_path / "
|
|
34
|
-
monkeypatch.setenv("
|
|
32
|
+
"""Keep the project registry and sweep state out of the real ~/.gpu."""
|
|
33
|
+
home = tmp_path / "gpu-home"
|
|
34
|
+
monkeypatch.setenv("GPU_HOME", str(home))
|
|
35
|
+
monkeypatch.delenv("JAMBU_HOME", raising=False)
|
|
35
36
|
return home
|
|
36
37
|
|
|
37
38
|
|
|
@@ -100,7 +100,7 @@ def test_fingerprint_tracks_runtime_identity(write_config):
|
|
|
100
100
|
|
|
101
101
|
|
|
102
102
|
def test_state_dir_sits_next_to_the_config(config):
|
|
103
|
-
assert config.state_dir == config.source_path.parent / ".
|
|
103
|
+
assert config.state_dir == config.source_path.parent / ".gpu"
|
|
104
104
|
|
|
105
105
|
|
|
106
106
|
# -- jambu.yaml shared-manifest namespacing ----------------------------------
|