File size: 11,386 Bytes
bb02ad0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# Example config for `cascade-provisioner` β€” the per-round GPU pod renter.
#
#   cascade-provisioner --config provision.toml --chain-toml chain.toml \
#       --work-root /root/cascade/_train_work [--dry-run] [--once]
#
# This file is OPERATOR-LOCAL: keep it out of git (it names your SSH key and
# hosts file) and OFF the public chain.toml. Round shape (epoch cadence, final
# training hours, the image-digest pin) is read from chain.toml β€” this file
# only holds the RENTAL policy: what to rent, where, and how much to spend.
#
# The provisioner holds NO wallet β€” only provider API keys (env:
# SHADEFORM_API_KEY / LIUM_API_KEY, see deploy/cascade-provisioner.service)
# and the orchestrator's SSH keypair. Signing/publishing stays on the trainer.

[provisioner]
# Where the trainer's --remote-hosts points. The provisioner writes this file
# atomically per round (heat + final entries), re-renders it final-only when
# the heat settles, and EMPTIES it when it can't rent β€” an empty file is the
# trainer's explicit "train locally this round" signal.
hosts_path = "/root/cascade/hosts.toml"

# Crash-safe rental ledger (default: <work-root>/provisioner_state.json).
# Restarts resume teardown from here; delete it only if you have verified by
# hand that no cascade-* pod is still running on any provider.
state_path = "/root/cascade/_train_work/provisioner_state.json"

# Digest-pinned worker image (see docs/DEPLOY_PODS.md Β§1). MUST be @sha256 β€”
# a moving tag breaks the expected_gpu / train_image_digest contract.
image = "ghcr.io/tensorlink-ai/cascade-worker@sha256:0000000000000000000000000000000000000000000000000000000000000000"

# ── OR: bootstrap mode (no worker image published yet) ───────────────────────
# With bootstrap_script set, pods rent BARE (image becomes the provider's
# template to boot, digest pin not required) and the script β€” run ON the
# orchestrator with POD_IP/POD_PORT/POD_USER/POD_KEY/POD_STAGE/POD_WORKDIR in
# env β€” provisions them over SSH (rsync source + `uv sync --frozen`; see
# deploy/pod_bootstrap.sh). The health gate then verifies what it built.
# bootstrap_script    = "/root/cascade/deploy/pod_bootstrap.sh"
# bootstrap_timeout_s = 1800
# pod_user            = "root"
# Shadeform in bootstrap mode needs VM-mode launches: the account's registered
# SSH key id (GET /v1/sshkeys) β€” pods boot as bare VMs, user "shadeform".
# shadeform_ssh_key_id = "00000000-0000-0000-0000-000000000000"

# Operator's static [[host]] entries (a long-lived hand-rented pod, e.g. the
# final box) β€” validated at startup with the trainer's own loader, appended
# verbatim to EVERY hosts.toml publish, and NEVER dropped by provisioner
# activity (teardown re-renders, no-capacity rounds, budget refusals).
# static_hosts = "/root/cascade/static_hosts.toml"

# ── elastic validator eval pod (optional; see [provisioner.eval] below) ──────
# Where the VALIDATOR's --eval-hosts points β€” a SEPARATE file from hosts_path
# (the two consumers have different lifecycles; the provisioner refuses equal
# paths). Published when a round's manifest appears, cleared on teardown; the
# validator re-reads it lazily at each offloaded eval, so clearing mid-round
# is safe (its next eval just runs locally). Unset = eval stage off.
# eval_hosts_path = "/root/cascade/eval_hosts.toml"
# The eval pod's primary teardown signal: this deployment's validator
# publishes round receipts at receipts/<validator_hotkey>/round-<id>.json β€”
# when the served round's receipt appears the pod is torn down. A newer
# manifest (round superseded) and the TTL below also end it. Unset = only
# those two backstops apply.
# receipt_prefix = "receipts/5ValidatorHotkey.../"

# Orchestrator SSH keypair: the PUBLIC key is injected into each pod
# ($SSH_PUBKEY); the PRIVATE key path is written into hosts.toml for the
# trainer's dispatches (and used by the provisioner's own health checks).
ssh_pubkey   = "~/.ssh/cascade_ed25519.pub"     # inline "ssh-…" also accepted
ssh_key_path = "~/.ssh/cascade_ed25519"

# Rent inside the last N blocks of the epoch (~12s each; 25 β‰ˆ 5 min). Must sit
# within the trainer's reveal margin β€” the field is only countable once timed
# reveals have landed β€” and be < epoch_blocks. Pods finish booting AFTER the
# round starts; the trainer's --hosts-wait-seconds covers that gap.
trigger_margin_blocks = 25

# HARD per-round spend ceiling in USD, projected at WORST case (every pod
# billed for the full TTL). If the projection exceeds this, the round rents
# NOTHING and the trainer trains locally β€” a runaway field can never surprise
# you on the bill.
max_spend_per_round = 25.0

# Pod time-to-live in epochs β€” the no-cooperation teardown backstop. Even if
# the heat marker, the manifest, and the ledger are ALL lost, every pod dies
# this many epochs after rent.
ttl_epochs = 1

# Rules of escalation. A stage whose rung delivers NO healthy pod (the launch
# call failed, or every pod and its one replacement was a dud) re-enters the
# SKU ladder at the next (candidate Γ— provider) rung β€” capacity re-probed at
# escalation time, each rung re-checked against max_spend_per_round β€” for at
# most escalate_deadline_s of wall clock per attempt. Renting runs in a
# worker thread (the poll loop keeps tearing down / heartbeating); the
# deadline bounds the single worker so the JIT final, the retries, and the
# next round's trigger are never starved (0 = a failed rung is final).
# A PARTIAL fleet below min_viable_fleet of its slot demand gets ONE
# same-candidate top-up batch β€” never a different SKU (0 = never top up).
escalate_deadline_s = 1800
min_viable_fleet    = 0.5

# Within-round retry: a stage that rented NOTHING (no capacity anywhere, over
# budget, or every pod a dud past escalation) re-attempts the full
# pick→budget→rent pipeline on this cadence, for as long as the stage can
# still matter β€” the heat while one serial screening wave fits in its
# remaining window (the fleet re-sizes to that window), the final while its
# full training hours + boot margin remain. 0 = one attempt per round.
rent_retry_cooldown_s = 900

# When to rent the FINAL fleet. "margin": with the heat at the epoch boundary
# (the default). "heat_complete": just-in-time at the trainer's
# heat_complete.json marker β€” the fleet is sized off the marker's ACTUAL
# finalist list and the pinned-SKU pods stop idle-billing through the heat.
# Requires a trainer new enough to re-read hosts before the duel (it waits
# --hosts-wait-seconds for final-tagged entries; boot is 10-15 min). If the
# primary final rung probes scarce at the margin, the final rents early via
# the ladder anyway β€” the thin-market exception.
final_rent_on = "margin"

# Health-gate knobs (see cascade/provision/health.py for the full seven).
min_disk_gb = 20.0

# Pod-side layout (matches deploy/Dockerfile).
remote_python = "/root/cascade/.venv/bin/python"
workdir       = "/root/cascade"
# chain_toml  = "/root/cascade/chain.testnet.toml"   # only if non-default on the pod
poll_seconds  = 30

# ── heat fleet: cheap SKU, sized off the revealed field ──────────────────────
# Heat checkpoints are trainer-internal (screened and discarded, never
# validated), so heats run on whatever is cheap β€” here 8Γ—A6000 clusters. The
# fleet is SLOT-sized: ceil(field Γ— heat_hours Γ— slot_overhead / window) GPU
# slots, then ceil(slots / gpus_per_pod) pods, capped at max_pods. sku is the
# EXACT `nvidia-smi --query-gpu=name` string β€” the health gate byte-compares
# every GPU of every pod against it.
# max_pods caps PODS ACROSS ALL SHAPES; pod count is always slot-derived, so a
# high cap lets small shapes scale out without over-renting big ones. max_pods
# = 0 marks the stage UNMANAGED (served entirely by static_hosts entries).
# market_sku is the marketplace's alias when it differs from nvidia-smi's
# device string ("A6000" on lium vs "NVIDIA RTX A6000" on the pod).
[provisioner.heat]
sku           = "NVIDIA GeForce RTX 4090"
market_sku    = "RTX4090"
gpus_per_pod  = 4            # 4x cluster: one hosts.toml entry per GPU
max_pods      = 8
providers     = ["lium", "shadeform"]   # priority order; a stage never splits providers
max_price_hr  = 2.60         # USD per POD-hour; dearer offers are skipped
slot_overhead = 1.3          # pads for image pull / corpus build / one flaky retry

# Ordered SKU/shape fallbacks: the round takes the FIRST candidate Γ— provider
# with capacity for the whole heat fleet (pod count re-derived per shape). A
# stage never mixes candidates, so within-round fairness holds by construction;
# heat scores only rank within a round, so cross-round SKU variance is free.
[[provisioner.heat.candidate]]
sku          = "NVIDIA RTX A6000"
market_sku   = "A6000"
gpus_per_pod = 4
max_price_hr = 2.40

[[provisioner.heat.candidate]]           # bigger cluster if that's what's stocked
sku          = "NVIDIA GeForce RTX 4090"
market_sku   = "RTX4090"
gpus_per_pod = 8
max_price_hr = 5.00

[[provisioner.heat.candidate]]           # last resort: 2x is the floor β€” 1x
sku          = "NVIDIA GeForce RTX 4090"  # singles pay a full bootstrap per
market_sku   = "RTX4090"                  # lane and rarely beat the trainer's
gpus_per_pod = 2                          # local fallback
max_price_hr = 1.30

# ── final fleet: the pinned SKU, king + finalists on ONE pod ─────────────────
# Default shape is a single pod with gpus_per_pod >= 1 + [round] finalists:
# every final run reports the same physical box's GPU, so the validator's
# expected_gpu pairing holds by construction. One 2Γ—L40S pod = king + 1
# finalist. (gpus_per_pod = 1 falls back to one pod per run.)
[provisioner.final]
sku          = "NVIDIA L40S"
market_sku   = "L40S"
gpus_per_pod = 2
max_pods     = 2             # 0 = unmanaged: a static hand-rented pod serves finals
providers    = ["lium", "shadeform"]
max_price_hr = 3.0

# ── eval pod: the validator's per-round offload box (OPTIONAL) ───────────────
# Manifest-triggered, unlike heat/final: rented when a round's manifest
# publishes (that is when the validator's GIFT-Eval gate + cascade bench need
# GPU β€” exactly as the trainer fleet is torn down), torn down when the round's
# receipt appears under receipt_prefix, a newer manifest supersedes it, or the
# TTL fires. One pod (max_pods = 1), one GPU is plenty. Absent table (or
# max_pods = 0, or eval_hosts_path unset) = feature off: validators eval
# locally on their --device, the pre-elastic behaviour. [[provisioner.eval.
# candidate]] SKU fallbacks work exactly like the heat's.
# [provisioner.eval]
# sku          = "NVIDIA L40S"
# market_sku   = "L40S"
# gpus_per_pod = 1
# max_pods     = 1
# providers    = ["lium", "shadeform"]
# max_price_hr = 1.20

# ── per-provider pod paths (bootstrap mode) ──────────────────────────────────
# Providers boot different base users: lium pods are root, shadeform VMs land
# as the "shadeform" user. Absent providers use the global defaults above.
# [provisioner.pods.shadeform]
# user          = "shadeform"
# workdir       = "/home/shadeform/cascade"
# remote_python = "/home/shadeform/cascade/.venv/bin/python"