davanstrien HF Staff commited on
Commit
503e20a
·
verified ·
1 Parent(s): 08c3123

Upload folder using huggingface_hub

Browse files
Files changed (4) hide show
  1. README.md +19 -7
  2. app.py +153 -0
  3. control_plane.py +244 -0
  4. requirements.txt +2 -0
README.md CHANGED
@@ -1,13 +1,25 @@
1
  ---
2
- title: Embedding Fleet Dashboard
3
- emoji: 😻
4
- colorFrom: red
5
- colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.20.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Embedding Fleet
3
+ emoji: 🛰️
4
+ colorFrom: blue
5
+ colorTo: gray
6
  sdk: gradio
7
+ sdk_version: "5.49.1"
 
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ # Embedding Fleet Jobs control plane
13
+
14
+ Run-level dashboard for a fan-out embedding run launched with
15
+ [`launch-embedding-fleet.py`](https://huggingface.co/datasets/uv-scripts/embeddings/blob/main/launch-embedding-fleet.py)
16
+ from [uv-scripts/embeddings](https://huggingface.co/datasets/uv-scripts/embeddings):
17
+ documents-processed progress, tokens, live ~$ cost vs the hard timeout ceiling, ETA,
18
+ GPU utilization, replica health, and a per-worker table.
19
+
20
+ Data sources: the run bucket (manifest + worker heartbeats, polled) and the Jobs API
21
+ (stages, durations, one metrics sample per running job per tick).
22
+
23
+ Config (Space variables): `FLEET_BUCKET` (default `davanstrien/embedding-runs`),
24
+ `FLEET_NAMESPACE`, `FLEET_POLL_SECS`. Secret: `HF_TOKEN` with read access to jobs +
25
+ the bucket. Deep-link a run with `?run=<run-id>`.
app.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Embedding-fleet control plane — run-level view of a fan-out embedding run.
2
+
3
+ Renders one run of launch-embedding-fleet.py (uv-scripts/embeddings): documents-processed
4
+ progress, tokens, live ~$ cost vs the hard ceiling, ETA, GPU utilization, replica health,
5
+ and a per-worker table. Polls the run bucket + Jobs API every few seconds.
6
+ """
7
+
8
+ import os
9
+
10
+ import gradio as gr
11
+
12
+ from control_plane import RunView, list_runs, load_run
13
+
14
+ BUCKET = os.environ.get("FLEET_BUCKET", "davanstrien/embedding-runs")
15
+ NAMESPACE = os.environ.get("FLEET_NAMESPACE") or BUCKET.split("/")[0]
16
+ POLL_SECS = float(os.environ.get("FLEET_POLL_SECS", "5"))
17
+
18
+ CSS = """
19
+ :root {
20
+ --ink: #1a1a1a; --ink-2: #555; --ink-3: #999;
21
+ --surface: #fffef9; --card: #ffffff; --line: #e4e2da;
22
+ --accent: #3d6ea5; --ok: #2e7d43; --bad: #b3382c;
23
+ }
24
+ @media (prefers-color-scheme: dark) {
25
+ :root { --ink: #ececec; --ink-2: #b0b0b0; --ink-3: #7a7a7a;
26
+ --surface: #131313; --card: #1c1c1c; --line: #333;
27
+ --accent: #7ba7d4; --ok: #6fbf85; --bad: #e07a6e; }
28
+ }
29
+ .gradio-container { max-width: 1080px !important; }
30
+ #fleet-html h2 { font-weight: 600; margin: 0 0 2px; }
31
+ .fleet-head { color: var(--ink-2); font-size: 0.92rem; margin-bottom: 14px; }
32
+ .fleet-head a { color: var(--accent); text-decoration: none; }
33
+ .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
34
+ gap: 10px; margin: 12px 0 6px; }
35
+ .tile { background: var(--card); border: 1px solid var(--line); border-radius: 6px;
36
+ padding: 10px 14px; }
37
+ .tile .k { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em;
38
+ color: var(--ink-3); }
39
+ .tile .v { font-size: 1.45rem; font-variant-numeric: tabular-nums; color: var(--ink); }
40
+ .tile .s { font-size: 0.78rem; color: var(--ink-2); }
41
+ .tile .v.ok { color: var(--ok); } .tile .v.bad { color: var(--bad); }
42
+ .bar-wrap { margin: 10px 0 2px; }
43
+ .bar-label { display: flex; justify-content: space-between; font-size: 0.85rem;
44
+ color: var(--ink-2); margin-bottom: 4px; font-variant-numeric: tabular-nums; }
45
+ .bar { height: 10px; background: var(--line); border-radius: 5px; overflow: hidden; }
46
+ .bar > div { height: 100%; background: var(--accent); border-radius: 5px 0 0 5px;
47
+ transition: width 0.6s ease; }
48
+ """
49
+
50
+
51
+ def fmt_int(n):
52
+ return f"{n:,}" if n is not None else "—"
53
+
54
+
55
+ def fmt_secs(s):
56
+ if s is None:
57
+ return "—"
58
+ if s < 90:
59
+ return f"{s:.0f}s"
60
+ if s < 5400:
61
+ return f"{s / 60:.0f} min"
62
+ return f"{s / 3600:.1f} h"
63
+
64
+
65
+ def render_run(view: RunView) -> str:
66
+ m = view.manifest
67
+ pct = 100.0 * view.rows_done / view.rows_total if view.rows_total else 0.0
68
+ in_url = f"https://huggingface.co/datasets/{m['input_dataset']}"
69
+ out_url = f"https://huggingface.co/datasets/{m['output_dataset']}"
70
+ ceiling = f"ceiling ≤ ${view.cost_ceiling_usd:,.2f}" if view.cost_ceiling_usd else "est."
71
+ gpu = f"{view.gpu_util:.0f}%" if view.gpu_util is not None else "—"
72
+ health_cls = "bad" if view.errored else "ok"
73
+ eta = "done" if view.eta_secs == 0 else fmt_secs(view.eta_secs)
74
+ return f"""
75
+ <h2>{m["output_dataset"].split("/")[-1]}</h2>
76
+ <div class="fleet-head">
77
+ <a href="{in_url}">{m["input_dataset"]}</a> → <a href="{out_url}">{m["output_dataset"]}</a>
78
+ · <code>{m["model"].split("/")[-1]}</code>
79
+ · {view.num_shards} × {m["flavor"]} · run <code>{view.run_id}</code>
80
+ </div>
81
+ <div class="bar-wrap">
82
+ <div class="bar-label">
83
+ <span>{fmt_int(view.rows_done)} of {fmt_int(view.rows_total)} documents</span>
84
+ <span>{pct:.1f}%</span>
85
+ </div>
86
+ <div class="bar"><div style="width:{min(pct, 100):.2f}%"></div></div>
87
+ </div>
88
+ <div class="tiles">
89
+ <div class="tile"><div class="k">Tokens (est.)</div>
90
+ <div class="v">{fmt_int(view.tokens_done_est)}</div></div>
91
+ <div class="tile"><div class="k">Cost</div>
92
+ <div class="v">~${view.cost_usd:,.2f}</div><div class="s">{ceiling}</div></div>
93
+ <div class="tile"><div class="k">ETA</div>
94
+ <div class="v">{eta}</div></div>
95
+ <div class="tile"><div class="k">GPU util</div>
96
+ <div class="v">{gpu}</div><div class="s">mean of running</div></div>
97
+ <div class="tile"><div class="k">Replicas healthy</div>
98
+ <div class="v {health_cls}">{view.healthy}/{view.num_shards}</div>
99
+ <div class="s">{view.done} done · {view.errored} error</div></div>
100
+ </div>
101
+ """
102
+
103
+
104
+ def worker_table(view: RunView):
105
+ rows = []
106
+ for w in view.workers:
107
+ rows.append([
108
+ w.rank,
109
+ w.stage + (f" ({w.state})" if w.state and w.state != "running" else ""),
110
+ f"{w.rows_done:,}" + (f" / {w.rows_total:,}" if w.rows_total else ""),
111
+ f"{w.rows_per_sec:,.0f}" if w.rows_per_sec else "—",
112
+ f"{w.gpu_util:.0f}%" if w.gpu_util is not None else "—",
113
+ f"~${w.cost_usd:.3f}" if w.cost_usd is not None else "—",
114
+ w.job_id or "—",
115
+ ])
116
+ return rows
117
+
118
+
119
+ def refresh(run_id):
120
+ if not run_id:
121
+ return "<p>No runs found in the bucket yet.</p>", []
122
+ view = load_run(BUCKET, run_id, namespace=NAMESPACE)
123
+ if view is None:
124
+ return f"<p>Run <code>{run_id}</code> has no manifest.</p>", []
125
+ return render_run(view), worker_table(view)
126
+
127
+
128
+ def init(request: gr.Request):
129
+ runs = list_runs(BUCKET)
130
+ wanted = dict(request.query_params).get("run")
131
+ selected = wanted if wanted in runs else (runs[0] if runs else None)
132
+ html, table = refresh(selected)
133
+ return gr.Dropdown(choices=runs, value=selected), html, table
134
+
135
+
136
+ with gr.Blocks(css=CSS, title="Embedding Fleet") as demo:
137
+ with gr.Row():
138
+ run_dd = gr.Dropdown(label="Run", choices=[], scale=3)
139
+ reload_btn = gr.Button("Reload runs", scale=1)
140
+ html = gr.HTML(elem_id="fleet-html")
141
+ table = gr.Dataframe(
142
+ headers=["rank", "stage", "rows", "rows/s", "gpu", "~$", "job"],
143
+ interactive=False, label="Workers",
144
+ )
145
+ timer = gr.Timer(POLL_SECS)
146
+
147
+ demo.load(init, inputs=None, outputs=[run_dd, html, table])
148
+ timer.tick(refresh, inputs=run_dd, outputs=[html, table])
149
+ run_dd.change(refresh, inputs=run_dd, outputs=[html, table])
150
+ reload_btn.click(lambda: gr.Dropdown(choices=list_runs(BUCKET)), outputs=run_dd)
151
+
152
+ if __name__ == "__main__":
153
+ demo.launch()
control_plane.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data layer for the embedding-fleet control plane.
2
+
3
+ One poll tick = bucket reads (run manifest + worker heartbeats) + Jobs API reads
4
+ (stage, durations, one metrics sample per running job). All aggregation to the
5
+ run level happens here; app.py only renders.
6
+
7
+ Cost figures are client-side estimates (flavor unit price x running time), NOT
8
+ billing — always presented as "~$".
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import tempfile
15
+ import time
16
+ from concurrent.futures import ThreadPoolExecutor
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+
20
+ from huggingface_hub import (
21
+ download_bucket_files,
22
+ fetch_job_metrics,
23
+ list_bucket_tree,
24
+ list_jobs,
25
+ list_jobs_hardware,
26
+ )
27
+
28
+ TERMINAL_OK = {"COMPLETED"}
29
+ TERMINAL_BAD = {"ERROR", "CANCELED", "DELETED"}
30
+
31
+
32
+ def _stage_name(job) -> str:
33
+ stage = job.status.stage if job.status else None
34
+ return getattr(stage, "value", None) or str(stage or "UNKNOWN")
35
+
36
+
37
+ @dataclass
38
+ class WorkerRow:
39
+ rank: int
40
+ job_id: str | None = None
41
+ stage: str = "UNKNOWN"
42
+ rows_done: int = 0
43
+ rows_total: int | None = None
44
+ rows_per_sec: float = 0.0
45
+ tokens_done_est: int | None = None
46
+ gpu_util: float | None = None
47
+ cost_usd: float | None = None
48
+ state: str | None = None # worker-reported: running/done/error
49
+
50
+
51
+ @dataclass
52
+ class RunView:
53
+ run_id: str
54
+ manifest: dict
55
+ workers: list[WorkerRow] = field(default_factory=list)
56
+
57
+ # run-level aggregates
58
+ rows_done: int = 0
59
+ rows_total: int = 0
60
+ tokens_done_est: int = 0
61
+ cost_usd: float = 0.0
62
+ cost_ceiling_usd: float | None = None
63
+ eta_secs: float | None = None
64
+ gpu_util: float | None = None
65
+ healthy: int = 0
66
+ errored: int = 0
67
+ done: int = 0
68
+ num_shards: int = 0
69
+
70
+
71
+ _PRICING: dict | None = None
72
+
73
+
74
+ def pricing() -> dict:
75
+ global _PRICING
76
+ if _PRICING is None:
77
+ _PRICING = {hw.name: hw for hw in list_jobs_hardware()}
78
+ return _PRICING
79
+
80
+
81
+ def parse_timeout_secs(timeout) -> float | None:
82
+ """'20m' / '1h' / '90s' / plain seconds -> seconds."""
83
+ if timeout is None:
84
+ return None
85
+ s = str(timeout).strip().lower()
86
+ try:
87
+ mult = {"s": 1, "m": 60, "h": 3600, "d": 86400}.get(s[-1])
88
+ return float(s[:-1]) * mult if mult else float(s)
89
+ except (ValueError, IndexError):
90
+ return None
91
+
92
+
93
+ def list_runs(bucket: str) -> list[str]:
94
+ """Run ids under runs/, newest first (ids are timestamp-prefixed)."""
95
+ try:
96
+ ids = [Path(e.path.rstrip("/")).name
97
+ for e in list_bucket_tree(bucket, prefix="runs/", recursive=False)
98
+ if e.__class__.__name__ == "BucketFolder"]
99
+ # Timestamp-prefixed ids first (newest first), ad-hoc ids after.
100
+ return sorted(set(ids), key=lambda r: (r[:8].isdigit(), r), reverse=True) if ids else []
101
+ except Exception:
102
+ return []
103
+
104
+
105
+ def _read_bucket_json(bucket: str, paths: list[str]) -> dict[str, dict]:
106
+ """Fetch small JSON files from the bucket; missing files are skipped."""
107
+ out: dict[str, dict] = {}
108
+ if not paths:
109
+ return out
110
+ with tempfile.TemporaryDirectory() as td:
111
+ pairs = [(p, Path(td) / p.replace("/", "__")) for p in paths]
112
+ try:
113
+ download_bucket_files(bucket, pairs, raise_on_missing_files=False)
114
+ except Exception:
115
+ return out
116
+ for src, dst in pairs:
117
+ if dst.exists():
118
+ try:
119
+ out[src] = json.loads(dst.read_text())
120
+ except (json.JSONDecodeError, OSError):
121
+ pass
122
+ return out
123
+
124
+
125
+ def _sample_gpu_util(job_id: str, timeout: float = 3.0) -> float | None:
126
+ """One metrics sample -> mean GPU utilization. Never blocks past `timeout`."""
127
+
128
+ def _one():
129
+ gen = iter(fetch_job_metrics(job_id=job_id))
130
+ try:
131
+ raw = next(gen)
132
+ finally:
133
+ getattr(gen, "close", lambda: None)()
134
+ gpus = raw.get("gpus") or {}
135
+ utils = [float(g.get("utilization") or 0) for g in gpus.values()]
136
+ return sum(utils) / len(utils) if utils else None
137
+
138
+ with ThreadPoolExecutor(max_workers=1) as pool:
139
+ fut = pool.submit(_one)
140
+ try:
141
+ return fut.result(timeout=timeout)
142
+ except Exception:
143
+ return None
144
+
145
+
146
+ def _accrued_cost(job, hw_pricing: dict) -> float | None:
147
+ flavor = getattr(job.flavor, "value", None) or (str(job.flavor) if job.flavor else None)
148
+ hw = hw_pricing.get(flavor)
149
+ if not hw:
150
+ return None
151
+ secs = job.durations.running_secs if job.durations else None
152
+ if not secs and job.started_at and _stage_name(job) == "RUNNING":
153
+ secs = time.time() - job.started_at.timestamp()
154
+ if not secs:
155
+ return None
156
+ return secs / 60.0 * hw.unit_cost_usd
157
+
158
+
159
+ def load_run(bucket: str, run_id: str, namespace: str | None = None) -> RunView | None:
160
+ """One full poll tick: manifest + heartbeats + job stages + metrics samples -> RunView."""
161
+ manifest = _read_bucket_json(bucket, [f"runs/{run_id}/run.json"]).get(f"runs/{run_id}/run.json")
162
+ if not manifest:
163
+ return None
164
+ n = manifest["num_shards"]
165
+ view = RunView(run_id=run_id, manifest=manifest, num_shards=n,
166
+ rows_total=manifest.get("rows_total") or 0)
167
+
168
+ status_paths = [f"runs/{run_id}/status/{i:05d}.json" for i in range(n)]
169
+ statuses = _read_bucket_json(bucket, status_paths)
170
+
171
+ # Jobs by label (server-side filter); fall back to manifest job_ids via list comprehension.
172
+ jobs_by_id = {}
173
+ try:
174
+ for j in list_jobs(labels={"embedding-fleet-run": run_id}, namespace=namespace):
175
+ jobs_by_id[j.id] = j
176
+ except Exception:
177
+ pass
178
+ manifest_job_ids = manifest.get("job_ids") or []
179
+
180
+ hw_pricing = pricing()
181
+ workers: list[WorkerRow] = []
182
+ running_job_ids: list[str] = []
183
+ for rank in range(n):
184
+ row = WorkerRow(rank=rank)
185
+ st = statuses.get(f"runs/{run_id}/status/{rank:05d}.json")
186
+ if st:
187
+ row.state = st.get("state")
188
+ row.rows_done = st.get("rows_done") or 0
189
+ row.rows_total = st.get("rows_total")
190
+ row.rows_per_sec = st.get("rows_per_sec") or 0.0
191
+ row.tokens_done_est = st.get("tokens_done_est")
192
+ row.job_id = st.get("job_id")
193
+ if row.job_id is None and rank < len(manifest_job_ids):
194
+ row.job_id = manifest_job_ids[rank]
195
+ job = jobs_by_id.get(row.job_id)
196
+ if job is None and str(rank) in {j.labels.get("rank") for j in jobs_by_id.values() if j.labels}:
197
+ job = next(j for j in jobs_by_id.values() if (j.labels or {}).get("rank") == str(rank))
198
+ if job is not None:
199
+ row.stage = _stage_name(job)
200
+ row.cost_usd = _accrued_cost(job, hw_pricing)
201
+ if row.stage == "RUNNING":
202
+ running_job_ids.append(row.job_id)
203
+ workers.append(row)
204
+
205
+ # One GPU sample per running job, in parallel, bounded.
206
+ if running_job_ids:
207
+ with ThreadPoolExecutor(max_workers=min(8, len(running_job_ids))) as pool:
208
+ samples = dict(zip(running_job_ids,
209
+ pool.map(_sample_gpu_util, running_job_ids)))
210
+ for row in workers:
211
+ if row.job_id in samples:
212
+ row.gpu_util = samples[row.job_id]
213
+
214
+ # Consolidator cost (labeled role=consolidate) counts toward the run.
215
+ consolidator_cost = sum(
216
+ _accrued_cost(j, hw_pricing) or 0.0
217
+ for j in jobs_by_id.values()
218
+ if (j.labels or {}).get("role") == "consolidate"
219
+ )
220
+
221
+ # ---- aggregate ----
222
+ view.workers = workers
223
+ view.rows_done = sum(w.rows_done for w in workers)
224
+ view.tokens_done_est = sum(w.tokens_done_est or 0 for w in workers)
225
+ view.cost_usd = sum(w.cost_usd or 0.0 for w in workers) + consolidator_cost
226
+ view.done = sum(1 for w in workers if w.state == "done" or w.stage in TERMINAL_OK)
227
+ view.errored = sum(1 for w in workers if w.state == "error" or w.stage in TERMINAL_BAD)
228
+ view.healthy = n - view.errored
229
+ gpu_vals = [w.gpu_util for w in workers if w.gpu_util is not None]
230
+ view.gpu_util = sum(gpu_vals) / len(gpu_vals) if gpu_vals else None
231
+
232
+ timeout_secs = parse_timeout_secs(manifest.get("timeout"))
233
+ hw = hw_pricing.get(manifest.get("flavor"))
234
+ if timeout_secs and hw:
235
+ view.cost_ceiling_usd = n * timeout_secs / 60.0 * hw.unit_cost_usd
236
+
237
+ active_rps = sum(w.rows_per_sec for w in workers
238
+ if w.state == "running" and w.stage not in TERMINAL_BAD)
239
+ remaining = max((view.rows_total or 0) - view.rows_done, 0)
240
+ if active_rps > 0 and remaining > 0:
241
+ view.eta_secs = remaining / active_rps
242
+ elif remaining == 0 and view.rows_total:
243
+ view.eta_secs = 0.0
244
+ return view
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ gradio>=5.0
2
+ huggingface-hub>=1.12