Compare commits
No files matched your search
@@ -0,0 +1,20 @@
|
||||
# Fails when an engine llmgw follows release by release (FreeToken) has
|
||||
# published a version newer than the one DefaultEngines installs: the failed
|
||||
# run is the notice to check the new version and move to it. See
|
||||
# scripts/engine_releases.py.
|
||||
name: Engine releases
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Mondays 06:00 UTC.
|
||||
- cron: "0 6 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Compare with PyPI
|
||||
run: python3 scripts/engine_releases.py
|
||||
+312
-24
@@ -30,27 +30,27 @@ What is left, most needed first. The detail is in the item each names.
|
||||
**Soon: the customer's experience, and HA.**
|
||||
4. ~~**C24**~~: done 2026-10-21. The store's, catalog's and mirror's errors, percent signs, footer times and stale
|
||||
forms are in the page's language. Left: the /docs documents, and a language kept per person (cookie only today).
|
||||
5. **J4**: the model mirror on more than one machine. Code done 2026-10-29: mirrors kept in step (`mirror serve
|
||||
--peer`, removals carried), `mirror.peers` and failover in the control plane, nodes given the file on a mirror that
|
||||
answers, `mirror add-peer` and the setup's second mirror. Tried 2026-10-29 on two VMs:
|
||||
- `add-peer` set the second up over ssh;
|
||||
- a file came across in ~30 s, checksum matching;
|
||||
- a version added on the second while the first was off reached the first when it came back;
|
||||
- one removed on the first while the second was off was removed there when it came back, not copied back.
|
||||
|
||||
Left: the same with a control plane and a node, a model served while the first mirror is off. That part (failover
|
||||
in the control plane, the node given the other mirror's file) is so far tried only in tests.
|
||||
6. **F5**: large imports limited by the embedding engine's slots. (M)
|
||||
5. ~~**J4**~~: done 2026-10-29. The done-criterion held on the PoC: with mirror 146 stopped, `ctl model serve` of a
|
||||
new model was fetched by the second mirror (VM 167, 10.0.0.152), placed and answered; a node without a file
|
||||
146's address names was given 152's; what was added meanwhile reached 146 when it came back; a removal was
|
||||
carried both ways. Found and fixed there: add-peer on a mirror machine of its own, files without checksums never
|
||||
copied, and `mirror remove` neither recorded nor seen by the running mirror.
|
||||
6. **F5**: large imports. Done 2026-10-29: the gateway embeds what `llmgw-kb` sends as text; the slots and
|
||||
halfvec were done before. Left: a larger store machine before a million chunks (the operator's), and the
|
||||
embedding model on a GPU for a real import.
|
||||
|
||||
**From Kong** (section L): L1–L5 and L9 done (v0.15.0); left of L4 is routing by cost between outside
|
||||
providers, of L5 cache-write and audio prices. L6 and L7 are llmgw-80's; L8 (A2A) when asked.
|
||||
|
||||
**Models we can trust** (section M, 2026-10-25): M1 done (pickles refused; the explorer lists only what this
|
||||
cluster's hardware holds, with CPU and GPU tok/s). Next M2 (every download pinned and checked against Hugging
|
||||
Face), then M5 (engines without the internet), M3, M4, M6.
|
||||
Face, done 2026-10-30), then M5 (engines without the internet), M3, M4, M6.
|
||||
|
||||
**Scale.** O: more than one control plane answering clients; O1 (measuring one router's ceiling) first.
|
||||
|
||||
**Governance.**
|
||||
7. **C1**: a tenant-admin role, and forms hidden from viewers.
|
||||
7. ~~**C1**~~: done 2026-11-08. An organization's admin (`--role admin` on an org membership) changes its teams,
|
||||
projects, people and keys from /org; viewers are drawn no forms; forms made compact.
|
||||
8. **Audit before and after**: what a change changed, and changes made with the CLI recorded too.
|
||||
|
||||
**When there is room.**
|
||||
@@ -436,9 +436,9 @@ portal is not done — the page shows; the console and ctl change.
|
||||
API middleware and the /admin pages (with each change's result), `ctl
|
||||
audit`, the console's Audit view; staff roles viewer / operator /
|
||||
billing / admin. Left: before/after values (the request's fields are
|
||||
recorded, not the state they replaced); a tenant-admin role that changes
|
||||
its own organization (managers still only view it); hiding the forms a
|
||||
viewer cannot submit. **Audit log and roles (M).** Today "audit" is slog lines
|
||||
recorded, not the state they replaced). Done 2026-11-08: an organization's
|
||||
admin who changes its own organization from /org (managers still only view
|
||||
it), and the forms a viewer cannot submit no longer drawn. **Audit log and roles (M).** Today "audit" is slog lines
|
||||
(`internal/web/admin_users.go:403-480`) and roles are the `IsAdmin` flag
|
||||
plus an org "manager" who can view. Add `audit_events` (who, what,
|
||||
before/after, source) written by the `adminAction`/`tenancyAction`
|
||||
@@ -463,8 +463,9 @@ portal is not done — the page shows; the console and ctl change.
|
||||
5. **Done (branch `features-c`):** `notify.webhooks` (model parked, node
|
||||
lost, budget spent, update available; signed JSON or Slack; retries,
|
||||
hourly de-duplication), `ctl notify test`, Alertmanager receiver
|
||||
templates. Left: `server init` and the console do not yet ask for a
|
||||
receiver — the templates are filled in by hand. Events are claimed in
|
||||
templates. `server init` and both setup screens ask for webhooks and test
|
||||
each (d1e9186, 2026-10-14); with none set, `ctl status` and the console's
|
||||
Overview say the alerts go nowhere (2026-11-12). Events are claimed in
|
||||
the store before they are sent (2026-10-10), so two control planes send
|
||||
each once.
|
||||
**Alerting out of the box (S).** `deploy/monitoring/alertmanager.yml`
|
||||
@@ -1133,13 +1134,19 @@ four texts per pass, while llmgw-kb sends 32 a request from 6 workers. The
|
||||
usage rows show 4.6 s a request on average, 1.8 s of it in the gateway's
|
||||
queue and 0.04 s in the guard. The GPU sat at 18–50% and the store machine
|
||||
at a third of its CPUs.
|
||||
- An embedding model's profile with many slots by default (`--parallel`
|
||||
- Done for `ctl model serve` (32 slots of 1024 tokens; `ctl model context
|
||||
MODEL 1024 --slots 32` resizes one served before): an embedding model's profile with many slots by default (`--parallel`
|
||||
32–64, `--ctx-size` to match, `--batch-size`/`--ubatch-size` at least the
|
||||
longest chunk), or `ctl model serve` choosing that for a model with
|
||||
`capabilities.embeddings`. With the default 512, one 516-token chunk failed.
|
||||
- Embedding on the gateway's side of a document PUT, so the client sends
|
||||
text only: today an embeddings answer is ~21 KB of JSON a chunk, and
|
||||
the text and vector go up again (~11 KB).
|
||||
- Done (2026-10-29): embedding on the gateway's side of a document PUT.
|
||||
`llmgw-kb` sends text only to a gateway that says `server_embeds`; the
|
||||
gateway embeds through its own `/v1/embeddings` as the caller, 32 a batch,
|
||||
four batches at once, waiting out a busy engine. On 40 DTIC files the
|
||||
upload went from 25.7 to 3.5 MiB, and the ~21 KB a chunk that came down
|
||||
is gone; on 6 files both ways gave the same 32 chunks and the same search
|
||||
answer. Speed is the engine's either way: the PoC's bge-m3 on node02's two
|
||||
CPU cores embeds ~0.2 chunks/s, so a real import still wants it on a GPU.
|
||||
- The store next: HNSW inserts took half of one of pg01's two cores; at a
|
||||
million chunks the vectors alone outgrow its 4 GB. halfvec (pgvector ≥
|
||||
0.7) and a larger store machine.
|
||||
@@ -1652,7 +1659,7 @@ their own (4 cores, 32 GB, two cores each) the same test refused 2.
|
||||
- Done when: the setup installs the judges on the machines given for them,
|
||||
and the load test above refuses no more than it did by hand.
|
||||
|
||||
### J4. The model mirror on more than one machine (M)
|
||||
### J4. The model mirror on more than one machine (M) — done 2026-10-29, tried on the PoC
|
||||
In an HA install the mirror stays on the first machine. While it is down,
|
||||
models the nodes already have keep serving and new ones wait for it.
|
||||
- A second mirror kept in step (each machine of the store, or one more), the
|
||||
@@ -1683,6 +1690,8 @@ how to fix it by hand (`ctl model resources`, then `ctl restart`).
|
||||
Rate limits are counted in each control plane's memory. A failover starts
|
||||
every key's minute afresh. Documented in install.md; worth a shared count in
|
||||
the store only if a customer's limits must hold across a failover.
|
||||
With `router.active_active` (O2, 2026-11-01) the counts are shared every 2 s,
|
||||
so a standby taking over keeps the minute; without it, as above.
|
||||
|
||||
### J7. nginx in front of several control planes (S)
|
||||
`deploy/proxy/Caddyfile` sends to whichever control plane answers `/leader`.
|
||||
@@ -1820,7 +1829,14 @@ pickle files. `allow_pickle: true` on a model lets it through, on purpose. On th
|
||||
PoC, `triposr` (`hf:stabilityai/TripoSR/model.ckpt`) is the one model that
|
||||
needs it.
|
||||
|
||||
### M2. Every download pinned and checked against Hugging Face (M)
|
||||
### M2. Every download pinned and checked against Hugging Face (M) — done 2026-10-30
|
||||
Done: what is added from Hugging Face is pinned (the file's commit URL, or
|
||||
`--revision` for vLLM/SGLang) with each file's sha256 in the origin; the node
|
||||
refuses and deletes a copy that is not it, naming both, and keeps the
|
||||
checksum beside a cached file; the mirror checks what `ctl model serve`
|
||||
fetches, `mirror add` a Hugging Face URL. Left: models declared in the
|
||||
settings are not pinned, the admin page's and the console's mirror fetches
|
||||
send no sha256, and adapters by hub id are not pinned.
|
||||
- `hf:org/repo/file` follows `main` and is checked by nothing; a bare
|
||||
`org/repo` given to vLLM is downloaded by vLLM, of whatever `main` is.
|
||||
Both: resolve to the commit when the model is added, keep it in the
|
||||
@@ -1855,6 +1871,278 @@ cluster, loopback), systemd hardening of the agent's engines
|
||||
(M4) serve only after its eval suite passes, so a model trained to answer
|
||||
wrongly on a trigger has at least been asked the suite.
|
||||
|
||||
## N. Strata, all the way up (written 2026-10-30)
|
||||
|
||||
Strata (opt-in engine since 2026-10-29) is wired at the agent: adapter, `llmgw strata-serve`, install pinned by
|
||||
digest, metrics in vLLM's names, GPU memory of child processes. Above the agent it is invisible: a Strata model is
|
||||
served only from a definition written by hand. In order:
|
||||
|
||||
**A. What is broken.**
|
||||
- N1. A Strata named by hand (`engines.strata.dir`) was never reported: no Probe. Done 2026-10-30.
|
||||
- N2. Its engine runs in a session of its own and survived a stop, holding the card and the RAM: the supervisor
|
||||
now finds what left the group before signalling it. Done 2026-10-30.
|
||||
- N3. Host memory: the experts live in RAM (40 GB for Qwen3.8-Flash-Next IQ2_XS, 72–136 GB for Unsloth's) and
|
||||
nothing reserved it. A Strata profile added now reserves what its size takes, from the table (N5), unless it says
|
||||
`min_memory_gb`. Done 2026-10-30.
|
||||
- N4. Pascal and Volta: installed (CUDA 12.9 build) and then refused (7.0 floor). Floor now 6.0. Done 2026-10-30.
|
||||
|
||||
**B. Offered where models are chosen.**
|
||||
- N5. A table of Strata's families and sizes (qwen, coder, swift, unsloth, gyro, nvfp4, qwen36, deepseek41) with
|
||||
download and RAM, from Strata 0.2.7's setup (internal/strata/models.go); the catalog refuses an unknown family or
|
||||
size, and requires `--size`. Done 2026-10-30. Speeds measured: Qwen3.8-Flash-Next IQ2_XS 70–87 tok/s on an RTX 6000.
|
||||
- N6. `modelplan`, `ctl model plan` and `ctl model serve` offer "Strata on one card + N GB RAM" for a model it runs;
|
||||
a large MoE that fits no card is not "fits nowhere". Done 2026-10-30, without a speed (one measurement so far).
|
||||
- N7. The web serve page, the explorer's fit, the console's serve form: the same choice. Done 2026-10-30.
|
||||
- N8. `--context`/`--parallel` tied to `context_length`/`max_output`; `ctl model context` takes Strata.
|
||||
`ctl model context` takes Strata (setup's `--context`), and `--max-output` changes the longest answer alone,
|
||||
in place, with no restart: done 2026-11-01 (31f440a). Open: a Strata model served without `--context` still runs
|
||||
at what Strata's setup picks (4096 on cuda01) while the catalog says the model's trained context (32768), so
|
||||
serve should write `--context` from `context_length`; and `--parallel`.
|
||||
|
||||
- N7b. The mirror page's Strata tab: every family and size, fetch (GGUF) or serve (packs Strata fetches itself:
|
||||
NVFP4, DeepSeek V4.1). Done 2026-10-30. Community `strata-ple` packs Strata 0.2.7 does not list stay unservable;
|
||||
running them would mean starting the engine past Strata's setup (`--pack`, `--native`, `--ple-gguf`).
|
||||
|
||||
**C. Operating it.**
|
||||
- N9. `ctl engine-check strata`: the flags of setup.py and server.py, which drift between versions.
|
||||
- N10. `ctl engine-build NODE strata cuda12|cuda13`, as vLLM has.
|
||||
- N11a. Strata's per-replica state directories (engine-home/strata/<replica>) are left behind when a replica stops;
|
||||
remove them with it.
|
||||
- N11. Disk for the prepared pack and draft layer (and DeepSeek V4.1's 240–280 GB), cleaned with the weights; a
|
||||
first start's preparation within the startup timeout.
|
||||
- N12. docs/install.md: Strata in the engines table, a section with a profile, its memory and first-run time;
|
||||
`doctor` and `server init` checking or offering it.
|
||||
|
||||
**D. What Strata can do that llmgw does not ask of it yet.**
|
||||
- N13. Several cards of one node (`--gpus`, `--gpu-roles`): larger packs on a customer's multi-GPU machines.
|
||||
- N14. `--calibrate` once per node and model, kept.
|
||||
- N15. Vision, KV types, experts from SSD when RAM is short (`--low-ram`) in the plan and forms.
|
||||
- N16. Versions apart from llmgw's releases (Strata's signed update feed, `--rollback-engine`); offline bundles.
|
||||
- N17. `cache_salt` for KB isolation if Strata caches prefixes; `ctl bench` with its metrics path.
|
||||
|
||||
## O. More than one place answering clients (written 2026-10-30)
|
||||
|
||||
Every client request goes through the leader's router today: the floating address is on the leader, and a standby
|
||||
passes `/v1` on to it (a standby's router sees an empty cluster). That is failover, not scale: the cluster serves what
|
||||
one control plane's router can. In order:
|
||||
|
||||
- O1. Measured 2026-10-30/31 on the PoC: the leader (2 vCPU, 2 GB, pve1 at load 6–10 of 28 threads) in front of
|
||||
a fake engine on node06 (`sim-engine`, 500 tok/s a stream, no batch slowdown, as an external model), 64-token
|
||||
streams from mirror 146, the default `observe` guardrails and usage written to PostgreSQL:
|
||||
|
||||
| at once | req/s | tok/s | first token p50/p95 | leader llmgw CPU |
|
||||
|---|---|---|---|---|
|
||||
| 64 | 465 | 29,752 | 9 / 34 ms | 159% |
|
||||
| 128 | 572 | 36,607 | 91 / 167 ms | 181% |
|
||||
| 256 | 577 | 36,900 | 310 / 392 ms | 182% |
|
||||
| 512 | 551 | 35,240 | 793 / 883 ms | 181% |
|
||||
|
||||
The ceiling is the router's CPU: ~575 requests/s, ~37k streamed tokens/s on two cores, no errors; past it the
|
||||
first token waits. Found on the way and fixed: an external model held to 3–4 at once by admission; a valid key
|
||||
throttled as "unrecognised" when its cache entry expired under load.
|
||||
|
||||
Profiled under 128 streams (2026-10-31): a third of the CPU was one write a token to the client; joining the
|
||||
events already in hand into one write took the ceiling to ~700 req/s and ~45k tok/s, first token p50 45 ms at 128.
|
||||
Now the largest part is the output guardrail of the default `observe` policy, ~30%: decoding each event (~20%;
|
||||
a token-level reader with encoding/json/jsontext was measured slower than json.Unmarshal, 1.66 vs 1.27 µs) and
|
||||
the PII patterns (~12%). Ways on, a guardrail decision: judge shadow output in larger windows, or a sample of it.
|
||||
Next: the same run on a machine the size of a customer's control plane, then O2.
|
||||
- O2. Every control plane answers `/v1` itself (active-active):
|
||||
- [x] the leader's replica table followed live by the standbys, so their routers route (2026-11-01, behind
|
||||
`router.active_active`, off by default). Every 2 s a standby posts what its router used to the leader's
|
||||
`POST /api/v1/routing/activity` over the agent port with its control-plane certificate, and is answered with
|
||||
the Ready replicas: no keys travel, each control plane derives a replica's key from the cluster secret. The
|
||||
leader takes the last uses in (else a replica busy behind a standby is evicted as idle) and the on-demand models
|
||||
asked for; a catalog changed on the leader is read again from the store. Unheard from the leader for a minute,
|
||||
a standby routes nothing and passes `/v1` on as before. The portal, `/admin` and `/mcp` still go to the leader.
|
||||
- [x] budgets: each router raises its counters to the store's every 30 s, so another's spend is seen within that.
|
||||
- [x] rate limits cluster-wide (2026-11-01): each router's per-counter request and token counts, this minute's
|
||||
and the last as absolute figures, ride the same 2 s exchange; the leader answers each standby with everyone
|
||||
else's sum, its own included, and puts the standbys' sum in force on its own router. Each limiter adds the
|
||||
others' counts to its sliding window. Overshoot is bounded by what N routers allow in 2 s; a standby quiet for
|
||||
10 s stops counting. (J6's single-process case was never the problem.)
|
||||
- [ ] a session's requests to one router and one replica: the session ring already picks the same replica on
|
||||
every router for the same replica set; what is left is the same router, which is the balancer's (source hash).
|
||||
- [ ] in front: IPVS on the keepalived the setup already installs, DNS round-robin, or the customer's balancer
|
||||
(J7, L9). Until then the standbys' own addresses are what spread the load.
|
||||
- [x] tried on the PoC (2026-11-01, v0.16.1-14-g8b7eefe, setting on since): 140 leads, 150 follows. A request
|
||||
through 150 is answered and logged by 150; a model added on the leader is routable on 150 within a tick; a
|
||||
CPU replica used only through 150 shows idle 6 s on the leader. Load from 146, 64-token streams, a sim engine on
|
||||
node06 at 5,000 tok/s a stream (2,300 req/s on its own; at 500 tok/s it was the ceiling, ~490 req/s, routers or
|
||||
not), pve1 at load 14 of 28 threads:
|
||||
|
||||
| streams | leader alone | split over 140 + 150 | first token p50, alone / split |
|
||||
|---|---|---|---|
|
||||
| 128 | 971 req/s (140 at 176%) | 832 + 692 = 1,524 req/s (178% / 166%) | 115 / 59–75 ms |
|
||||
| 256 | 990 req/s (177%) | 816 + 677 = 1,493 req/s (177% / 164%) | 243 / 135–166 ms |
|
||||
|
||||
1.5× with two routers on an oversubscribed host, no errors. Not 2×: both routers' VMs and the load generator
|
||||
share pve1's cores.
|
||||
- Known and accepted for now, per router: the precise KV index is empty on a standby (agents stream to the leader
|
||||
only), a revoked key works on a standby until `auth.cache_ttl`, canary promotion sees the leader's share only,
|
||||
the answer cache and prefix tree are split, an MCP caller's rate is per router.
|
||||
- O3. Only if O2 is not enough: stateless gateway machines (`llmgw server --role gateway`) in front of the leader's
|
||||
control plane, as many as the traffic needs.
|
||||
|
||||
## P. Training beyond one card (written 2026-11-11) — decision open
|
||||
|
||||
F4 trains a LoRA on one card of a node the caller names (`ctl train start --node`), one job per node, no resume.
|
||||
Larger work (full fine-tuning, a LoRA on a 70B, several GPUs or machines) needs more. Two ways:
|
||||
|
||||
**A. In llmgw.** Add, in order: a job that takes N whole GPUs on one node (torchrun, FSDP/DeepSpeed in the agent's
|
||||
venv); placement by the control plane instead of `--node`, freeing GPUs from replicas by the scaling schedule or by
|
||||
draining; checkpoints to shared storage and resume when an agent or node goes; datasets uploaded or read from
|
||||
storage, not only KB pairs; then gang placement of N nodes with a rendezvous on the first, the NCCL environment from
|
||||
the fabric discovery (IB/NVLink, `llmgw doctor`); a console screen with logs and loss. Size: L for one node, XL for
|
||||
several. For: one product, one install, one set of users and grants (KB rules carry over), GPUs shared with
|
||||
inference without a second scheduler. Against: it is Slurm's gang scheduling, requeue and accounting written again;
|
||||
multi-node NCCL cannot be tried on the PoC (one GPU node, no IB); every customer's training code would have to fit
|
||||
our job format.
|
||||
|
||||
**B. Slurm beside llmgw.** The TUI installs Slurm on the nodes the customer picks (munge, slurmctld on the control
|
||||
planes, slurmd, users from the same LDAP/AD), with pyxis/enroot for containers. A node is handed between the two:
|
||||
llmgw drains its replicas and `scontrol update state=resume`, and back. llmgw's part is what comes out: a checkpoint
|
||||
or adapter from a Slurm job published to the mirror (`ctl train import`), served through canary and the eval. Size:
|
||||
M. For: what research and university customers already run (`sbatch`, `srun torchrun`), any training code, mature
|
||||
multi-node and requeue; we write only the install and the handover. Against: a second system with its own failures,
|
||||
upgrades and users to keep in step; GPUs split by hand or by the handover, not by demand; access rules on training
|
||||
data are Slurm's and the file system's, not the KB's.
|
||||
|
||||
Both need shared storage (NFS, BeeGFS, Lustre) the customer provides; neither changes F4.
|
||||
|
||||
Recommendation: keep F4 in llmgw for small single-card adapters tied to KB grants; take B for anything larger;
|
||||
build only A's first step (N GPUs on one node, placed by the control plane, with resume) if customers ask for
|
||||
mid-size jobs without wanting Slurm.
|
||||
|
||||
**B taken (2026-11-11).** Design: the cluster's settings say it (`slurm: enabled, cluster_name, controller, nodes,
|
||||
lent, partition`); the leader tells each named node's agent (root) what to be, the agents install and run it.
|
||||
- The controller is an llmgw node (its agent runs slurmctld); `nodes` run slurmd configless
|
||||
(`--conf-server`), so slurm.conf, gres.conf and cgroup.conf live only on the controller.
|
||||
- The munge key is derived from the cluster secret (HKDF), so nothing new is stored or copied by hand.
|
||||
- Each node reports `slurmd -C`; the leader writes slurm.conf from those lines, its mgmt address and its GPUs
|
||||
(`Gres=gpu:<type>:N`, gres.conf with the device files), and sends it to the controller again when one changes.
|
||||
- A node in `nodes` belongs to llmgw until it is in `lent`: drained in Slurm (reason "llmgw: serving models").
|
||||
Lent: llmgw drains it as `ctl drain` does (kept in force each tick, as facility holds are, so a new leader
|
||||
keeps it) and, once no replica is left, its agent resumes it in Slurm. Taken out of `lent`: the agent drains
|
||||
it in Slurm, reports the jobs left, and when none are the leader makes it ready for llmgw again.
|
||||
- Phases: P1 the `slurm` package (conf, key, parsers) and settings; P2 the agent side (proto, install, munge,
|
||||
slurmd/slurmctld, status); P3 the leader side (conf from reports, lend/reclaim); P4 `ctl slurm`
|
||||
(status, lend, reclaim) and a TUI screen; P5 `ctl train import` of a Slurm job's output into the mirror.
|
||||
Later: a backup slurmctld on a second node, accounting (slurmdbd; it takes MySQL/MariaDB only, not the Patroni PG), users from LDAP/AD.
|
||||
- Tried on the PoC 2026-11-11 (dev build v0.16.3-17-g849760c-slurm on 140, 150, node05, node06; Ubuntu
|
||||
24.04, Slurm 23.11.4 from the archive): `ctl slurm setup --controller llmgw-node06 --nodes llmgw-node05`
|
||||
installed munge, slurmctld and slurmd, node05 entered drained with its slurmd -C hardware (8 CPUs,
|
||||
31132 MiB after the 3%). `lend`: its 2 replicas moved off, then IDLE in Slurm; srun and sbatch ran there.
|
||||
`reclaim` with a 90 s job running: MIXED+DRAIN, llmgw kept off, ready again within a minute of the job
|
||||
ending. Found and fixed on the way: a client command needs its files even for --version (version now
|
||||
from the daemon); the packaged unit is Type=notify (slurmd needs --systemd); slurmd's own "slurmstepd
|
||||
infinity" was counted as a job; without accounting storage 23.11's main scheduler marks batch jobs
|
||||
InvalidAccount and backfill starts them, one a pass (bf_interval=10: 6-10 s each). So many small jobs
|
||||
want slurmdbd. — Wrong cause, found 2026-11-11: a job asking no memory got the whole node's (ReqTRES
|
||||
mem=all), so a node ran one job at a time and each next one waited for backfill's pass. Fixed: the
|
||||
partition's DefMemPerCPU is the leanest node's share; slurmdbd is not needed for this. InvalidAccount is real too:
|
||||
23.11 dropped accounting_storage/none, and without slurmdbd the main scheduler passes every job by;
|
||||
backfill starts it within bf_interval (10 s). priority/basic did not change that (fair share needs
|
||||
slurmdbd anyway). Starting on submission needs slurmdbd, on MySQL/MariaDB.
|
||||
- slurmdbd, done 2026-11-12 (748f777): the controller's agent installs MariaDB (bound to 127.0.0.1; a
|
||||
MariaDB that was there keeps its settings), makes `slurm_acct_db` and its user (password from the munge
|
||||
key, through a 0600 file, never argv), runs slurmdbd (6819, in the firewall for the cluster) and adds
|
||||
the cluster, before slurmctld. slurm.conf: AccountingStorageType=slurmdbd, jobacct_gather/cgroup,
|
||||
AccountingStorageTRES=gres/gpu, priority/multifactor. Tried on the PoC: five jobs started on
|
||||
submission, `sacct -a` has them with their TRES. Not tried: a fresh `server init --slurm` (needs VMs);
|
||||
a Red Hat controller.
|
||||
- Fresh install tried 2026-11-12 on VMs (3 control planes with Patroni, 2 CPU nodes, `server init
|
||||
--slurm`): Slurm, slurmdbd and MariaDB came up with the install; two-node srun/sbatch on the lent nodes,
|
||||
`sacct` has them; leader failover kept serving. Open from it: `--slurm` needs the first machine to be a
|
||||
node (the controller is its node), so a site wanting control planes without nodes chooses the
|
||||
controller later with `ctl slurm setup`; batch output lands on the job's first node until there is
|
||||
shared storage; on shared CPUs the judge refuses the first request after a placement change (fail
|
||||
closed, ~10 s), and its advice repeats `--cores 4` to a judge that has 4. Tried again on the idle
|
||||
VMs: one judge restarted in place, then both at once, requests every second: 48 of 48 served; the
|
||||
first after a cold start took 7.6–8.9 s end to end against ~3 s, within the 5 s judge budget. So the
|
||||
refusal was the install's load on the CPUs; if it is seen again, warm a new judge with one judgment
|
||||
before it takes requests.
|
||||
- Shared files and users, built 2026-11-12: `slurm.shared` {path, nfs | server} — the site's export
|
||||
mounted (a systemd mount unit, hard, nconnect=4), an llmgw node exporting the path itself
|
||||
(/etc/exports.d, to the Slurm machines' addresses, root_squash; 2049 in the firewall), or the site's
|
||||
own mount only looked for; `slurm.users: llmgw` — every active account a Linux user everywhere
|
||||
(`slurm_users`: login from the e-mail, UID from 200000, never reused; homes made on the exporting
|
||||
machine, else the controller; keys in /etc/llmgw/ssh-keys/%u through an sshd drop-in), SSH keys
|
||||
from the portal's Profile or `ctl slurm users keys`. A user already on a machine with another UID
|
||||
is left alone and said. The site has AD (not yet live): users from it, through sssd, are the next
|
||||
step (`slurm.users` empty today leaves them to the site).
|
||||
- Logging in to a node, done 2026-11-14: llmgw's users reach the controller freely, another Slurm
|
||||
machine only while a job of theirs runs there (pam_slurm_adopt, installed by the agent; PrologFlags=
|
||||
Contain). Only the users listed in the agent's users file are held: root and the machine's own users
|
||||
log in as before, so a wrong line never locks out who would fix it. pam_systemd would move the session
|
||||
out of the job, so sshd's stack takes the session file in place of its include, with pam_systemd
|
||||
skipped for the listed users alone; where that cannot be done safely (nested includes, jumps across
|
||||
it) they are held to having a job, not to its cores and cards, and the agent says so. Tried on the
|
||||
VMs: ayse without a job refused ("no active jobs on this node"), with one in
|
||||
job_N/step_extern (nproc 1), cut off when it ended; mehmet refused; admin's session unchanged.
|
||||
Left: sites whose users come from AD (sssd) are not held yet; a group of theirs would be the list.
|
||||
- A GPU node, tried 2026-11-14 on the PoC (cuda01, Quadro RTX 6000, Slurm 23.11.4): gres.conf
|
||||
`Type=rtx6000 File=/dev/nvidia0`, slurm.conf `Gres=gpu:rtx6000:1`. `srun --gres=gpu:1`:
|
||||
CUDA_VISIBLE_DEVICES=0, the one card; torch found it and ran 14.1 TFLOPS (fp32 matmul). Without
|
||||
--gres: "No devices found", torch none (ConstrainDevices holds). `--gres=gpu:2`: refused. sacct has
|
||||
`gres/gpu=1` in AllocTRES. Found: lending a node whose GPU model has nowhere else to go waits out
|
||||
the drain grace (5 min) before the replica stops, and `ctl slurm status` says only "1 replica(s)
|
||||
still to move off"; it should say it waits for a replacement that cannot be placed, and until when.
|
||||
Done (c3a75dc): it says the time.
|
||||
- Jobs in containers, done 2026-11-14: `slurm.containers` (`ctl slurm containers on`). Each agent
|
||||
installs enroot 4.2.1 (NVIDIA's .deb/.rpm, sha256 pinned) and builds pyxis 0.24.0 (source tarball,
|
||||
sha256 pinned) against its own Slurm (gcc, make, libslurm-dev); on a machine with nvidia-smi,
|
||||
nvidia-container-cli from NVIDIA's repository, its key's digest pinned. enroot.conf keeps each
|
||||
user's runtime, cache and data under /run/enroot and /var/lib/enroot (no login session needed), the
|
||||
shared path bind-mounted in every container. The leader writes plugstack.conf ("required") only
|
||||
once every machine reports both, since a machine without the plugin would fail every job. Tried on
|
||||
the VMs: on in ~30 s; ayse's srun --container-image=ubuntu:24.04 on n1 ran as uid 200001 with
|
||||
/shared inside and nproc 1; a two-node sbatch ran a rank on each. Seen once: a node's pull timed out
|
||||
reaching auth.docker.io and the job failed (network, not pyxis). Not tried: GPUs in a container
|
||||
(cuda01's agent is an older build; needs the peer protocol), Red Hat.
|
||||
- AD end to end, tried 2026-11-14 (decided: one AD per install, the existing auth.ldap group mapping, no
|
||||
tree from AD, no sssd unless a site asks): the VMs' auth.ldap against Samba DC VM 165 (ldaps by IP,
|
||||
CA in the settings, ml-team -> team:Test/arge). ada signed in on the portal with her AD password: an
|
||||
account, in Test/arge, Slurm user ada UID 200003; her key added in the portal's Profile, ssh to the
|
||||
controller ~20 s later, sbatch ran on n1 as ada. Disabled in AD: suspended in 56 s, the Linux user
|
||||
gone in 89 s, ssh refused, home kept. Enabled: back in 32 s, same UID, key kept. Taken out of
|
||||
ml-team: out of the team in 11 s; put back: in again in 56 s.
|
||||
- P6, Slurm's jobs in llmgw's accounting (written 2026-11-12, not started). The tenancy tree to Slurm:
|
||||
each project an account (`sacctmgr add account`, parent its team and organization), each member a user
|
||||
of it, kept by the leader from the store; `sbatch --account=PROJECT`. The records back: the leader reads
|
||||
`sacct --json` for jobs ended since the last read (through the controller's agent) into a
|
||||
`batch_usage` table (job, user, project, start, end, GPU-seconds by class, CPU-seconds, memory), priced
|
||||
per GPU-hour and core-hour into cost_micros, summed with the requests' usage on the usage pages and in
|
||||
budgets. Budgets enforced: a project's remaining budget as GPU-minutes in its account's GrpTRESMins, so
|
||||
Slurm holds its new jobs once spent. Needs the same users on every node (open: LDAP/sssd or
|
||||
llmgw-made). Also found: slurm.conf
|
||||
wrote every node State=DRAIN, so each slurmctld start (on any change of the files) drained a lent node
|
||||
until its agent resumed it, up to a minute; a lent node is now written without it. Not tried yet: a GPU node (gres).
|
||||
- Failover while lent, tried 2026-11-11 (v0.16.3-23): the first try killed the job (NODE_FAIL). A new
|
||||
leader had heard from no node yet and sent the controller a slurm.conf without them; slurmctld,
|
||||
restarted on it, dropped node05 and its job. Fixed (141a1f4, 0774e90): slurm.conf's nodes and the
|
||||
controller's address are kept in cluster_state (`slurm-nodes`) and a new leader starts from them; with
|
||||
nothing kept (an upgraded cluster) or the store unread, the controller's files wait up to 2 min for the
|
||||
nodes; a node is told nothing until its controller's address is known (no part stops its slurmd). Tried
|
||||
again: a 5-minute job on lent node05, leader 140 stopped, 150 took over; slurmctld not restarted, the job
|
||||
ran to its end; reclaimed during it, node05 was llmgw's within seconds of its end. Also 3387f97: srun in
|
||||
SrunPortRange=60001-63000, and Slurm's ports in llmgw's firewall for the cluster's networks.
|
||||
- Built 2026-11-11: P1 (`internal/slurm`, `slurm.*` settings), P2
|
||||
(`internal/agent/slurmnode`, proto Slurm/SlurmStatus), P3 (`internal/control/slurmsvc`, wired in the
|
||||
leader; `GET /api/v1/slurm`), P4's commands (`ctl slurm status|setup|lend|reclaim|off`). Decisions on the
|
||||
way: a node not heard from or with Slurm jobs is cordoned, not drained (nothing of llmgw's to move, and a
|
||||
new leader must not move models off every Slurm node); jobs are counted from the node's own slurmstepd,
|
||||
so a controller that is down neither hides them nor, with none, keeps the node from llmgw; a drain whose
|
||||
reason does not start with "llmgw" is a Slurm administrator's and never resumed. Left: ~~the TUI screen~~
|
||||
(done 2026-11-11: console **Slurm**, lend/take back on a row), P5,
|
||||
a try on the PoC (needs agents built from this; slurm-wlm 23.11 on Ubuntu 24.04), GPU minor numbers are
|
||||
taken to be nvidia-smi's index.
|
||||
- In the first install too (2026-11-11): `llmgw server init --slurm`, and a Slurm part on the choices
|
||||
screen, write `slurm: {enabled, controller: <this hostname>, all_nodes: true}` into server.yaml.
|
||||
`slurm.all_nodes` (also `ctl slurm setup --all-nodes`) runs slurmd on every node in the leader's
|
||||
inventory, those that join later too; none is lent. Refused with `--no-node`, or a hostname Slurm does
|
||||
not take.
|
||||
|
||||
## D. Facts worth knowing before starting
|
||||
|
||||
- Admin API: `127.0.0.1:9090`, behind the token in
|
||||
|
||||
+383
-1
@@ -19,12 +19,394 @@ is only released once its **exit criterion** is met, not when its code lands.
|
||||
| v0.12.0 | — | The cluster's settings in the store, in force when changed; the firewall switch; wipe | released |
|
||||
| v0.13.0 | — | The whole system stopped and started from one command; Qwen3Guard judges, on a GPU and CPUs together | released |
|
||||
| v0.14.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released (v0.14.1: adapters, the tree over the API, the firewall on every machine; v0.14.2: pictures from ComfyUI; v0.14.3: 3D models as jobs; v0.14.4: any ComfyUI workflow as a job, presets, licences; v0.14.5: picture and 3D models that add themselves; v0.14.6: setup that explains every part and lets it be chosen, the gateway on all three HA machines; v0.14.7: the judge sized for its machine; v0.14.8: sign-in with Active Directory, several keys for one outside provider; v0.14.9: sign-in set up from the console, a node's console that shows the machine) |
|
||||
| v0.15.0 | — | The company's MCP tools and VS Code through the gateway; guardrails that ask outside services and put redacted values back; answers to similar questions | released (v0.15.1: Strata, a 125B model of experts on one card; a second mirror kept in step; pickled weights refused; a served model's context changed) |
|
||||
| v0.15.0 | — | The company's MCP tools and VS Code through the gateway; guardrails that ask outside services and put redacted values back; answers to similar questions | released (v0.15.1: Strata, a 125B model of experts on one card; a second mirror kept in step; pickled weights refused; a served model's context changed; v0.15.2: a served model moved to more cards; v0.15.3: a model stopped and kept, or removed with its files) |
|
||||
| v0.16.0 | — | Weights pinned to a commit and checked against what Hugging Face published; documents embedded by the gateway; Strata offered where models are chosen; cluster upgrades that say how far they are | released (v0.16.1: the mirror's Strata tab, Strata's ready-made packs; v0.16.2: standbys that answer clients, FreeToken, a model fetched once per node; v0.16.3: replicas per profile, a served model's settings on the mirror page, the CPU's speed before serving) |
|
||||
| v1.0.0 | 5 | What phase 5 has left: precise KV routing proven on vLLM, the block cost picker, defragmentation across NVLink domains, prefill/decode disaggregation | planned |
|
||||
|
||||
Until v1.0.0 the minor version is the breaking one: configuration formats and Go
|
||||
APIs change between minors without a deprecation period.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- **An organization's admin.** Besides member and manager, a membership of
|
||||
an organization may now be `admin` (`llmgw users join … --org Acme --role
|
||||
admin`, the role chosen on a membership on the people page, the console,
|
||||
the admin API). An admin sees the organization's page as a manager does and
|
||||
also changes the organization from it: adds, renames and removes its teams
|
||||
and projects, adds people with an approved account by their address and
|
||||
takes them out, makes its members managers or admins, and revokes keys spent
|
||||
in its projects. Limits, approving and suspending accounts, the
|
||||
organization's name and the rest of the cluster stay with the cluster's
|
||||
administrators; an admin cannot take away their own role. Every change is in
|
||||
the audit log.
|
||||
- **Slurm beside llmgw, for training and batch work.** `llmgw ctl slurm setup
|
||||
--controller NODE --nodes NODE,...` (or `--all-nodes`, or `server init
|
||||
--slurm`) has the agents install munge and Slurm from the machines' package
|
||||
sources: slurmctld on one node, slurmd configless on the others, slurm.conf
|
||||
and gres.conf written from what each node reports. Every node stays llmgw's,
|
||||
drained in Slurm, until `ctl slurm lend NODE`: its replicas move off, then
|
||||
Slurm runs jobs there. `ctl slurm reclaim NODE` takes it back once its jobs
|
||||
end. `ctl slurm status` and the console's **Slurm** show each node. The
|
||||
controller also keeps the jobs' records: slurmdbd, in a MariaDB on that
|
||||
machine alone (Slurm takes MySQL or MariaDB only). Slurm's ports
|
||||
(6817–6819, srun's 60001–63000) answer the cluster's networks only, as
|
||||
llmgw's own do.
|
||||
- **Slurm's shared files and users.** `slurm.shared` (`ctl slurm shared`) is
|
||||
one path on every Slurm machine: the site's NFS export mounted, an llmgw
|
||||
node exporting it over NFS (what `server init --slurm` chooses, `/shared`
|
||||
from the first machine), or a file system the site mounts. `slurm.users:
|
||||
llmgw` (`ctl slurm users on`) makes each active account a Linux user on
|
||||
every Slurm machine, the same login and UID (from 200000, never reused)
|
||||
everywhere, its home on the shared path; people add SSH keys in the
|
||||
portal's Profile (`ctl slurm users keys` for an operator). They log in to
|
||||
the controller; to another Slurm machine only while a job of theirs runs
|
||||
there, their session put in the job (pam_slurm_adopt), so nobody uses a
|
||||
node's cards outside Slurm, nor on a node llmgw serves from. Root and the
|
||||
machines' own users are not held to it.
|
||||
- **Slurm's jobs in containers.** `llmgw ctl slurm containers on`
|
||||
(`slurm.containers`) installs enroot and pyxis on every Slurm machine, at
|
||||
pinned versions checked against their digests, and nvidia-container-cli on
|
||||
GPU machines; Slurm loads pyxis once every machine has it, and then `srun
|
||||
--container-image=...` runs a job in that image, as its user, with the
|
||||
shared path inside.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Forms only for those who may send them.** A viewer sees the people,
|
||||
limits, mirror and cluster pages without their forms, an operator the
|
||||
mirror's and a node's engines but not the rest; before, they were drawn and
|
||||
answered "not found".
|
||||
- **Smaller forms.** A label sits close to its field, a field is as wide as
|
||||
what goes in it rather than the page, buttons are smaller, and the short
|
||||
forms — a new key, a password, connecting an editor, a capacity or a limit —
|
||||
sit on one line or two instead of a screen.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The serve forms start at the longest context that fits.** The mirror
|
||||
page's form began every GGUF model at 4096 tokens, and the console's at
|
||||
4096 or the plan's figure, whatever the model was trained for. Both now
|
||||
start at what it was trained for, as long as its KV cache fits where the
|
||||
form places it beside the weights, else at the most that does, in whole
|
||||
thousands; the mirror page's llama.cpp slots are as many of that, up to
|
||||
four, as the room left holds, and its memory figure is for that pool. A
|
||||
plan says what the model was trained for (`trained`). Strata stays at
|
||||
its 32768; vLLM's form is unchanged.
|
||||
- **A model that failed to start was started in the same place again.**
|
||||
Placement given the same inputs chooses the same node and card, so an
|
||||
on-demand model whose replica failed to load was retried there, past each
|
||||
backoff, until it was parked, with another card or node free all along.
|
||||
The control plane now remembers where a model's replicas failed (node and
|
||||
cards, up to eight places) and places the next one elsewhere when anywhere
|
||||
else fits; with nowhere else it is tried there as before. Coming up, a
|
||||
change to its definition or `ctl retry` forgets the places. The log says
|
||||
`placing a model away from where it failed to start`, with both places.
|
||||
- **Deleted model files came back after a control plane restart.** An agent
|
||||
registers with the files its startup scan found, and sends the list again
|
||||
only when it changes; a file deleted after the agent started was listed
|
||||
again by a control plane that restarted, until the next change. A new
|
||||
stream now gets the list on its first heartbeat.
|
||||
- **`ctl weights` offered models in use for deletion.** A model of several
|
||||
files (a set, its address ending in `/`) is fetched into a directory named
|
||||
from the address without the slash; the sweep named it from the address
|
||||
with it, so every set in the catalog looked like nobody's. On the PoC that
|
||||
was 330 GB of six catalog models, the qwen3.8-flash-next Q8_0 among them;
|
||||
`--collect`, and `ctl model remove --purge` of a set, would have deleted
|
||||
them. Both now compute the name the fetch does.
|
||||
- **A sign-in took an organization's manager role away.** A group from single
|
||||
sign-on, SCIM or Active Directory that puts someone in an organization wrote
|
||||
the membership again at every sign-in or sync, as a plain member: a manager
|
||||
made one on the people page was one only until they next signed in. A
|
||||
membership the directory names is now added if missing and otherwise left
|
||||
as it is.
|
||||
- **The limits form on the people page's panel** was laid out on one line, its
|
||||
title squeezed into a column.
|
||||
- **Deleting the model files nothing uses said what it was asked to free,
|
||||
not what it freed.** The console's "Delete model files nothing uses" and
|
||||
`ctl weights --collect` added up every file the nodes were asked to
|
||||
delete as soon as the request was sent, and a model of several files —
|
||||
fetched into a directory of its own — was refused by the node as "not
|
||||
empty", so a sweep said tens of gigabytes freed and nothing went. The
|
||||
node now deletes such a directory whole (a link only as a link), and the
|
||||
sweep waits for the nodes to list their files again: freed is what they
|
||||
no longer list, and what they kept is said beside, with where to read
|
||||
why. Removing a model with its files reports the same way.
|
||||
|
||||
## v0.16.3 — 2026-11-03
|
||||
|
||||
Replicas counted per profile — seven on cards and four on CPUs under one name, the cards filled first — and every
|
||||
setting of a served model from the mirror page; how fast a model answers on the CPU, said before it is served, by
|
||||
the threads it is given rather than the whole machine's memory.
|
||||
|
||||
**Upgrading.**
|
||||
- No migrations. `llmgw ctl upgrade v0.16.3`: the control planes carry the replicas per profile, the model card and
|
||||
the speed in the serve form and in `ctl model plan`; the agents carry the measurement of one core's memory rate,
|
||||
without which the speed is reckoned the old way, from the whole machine's.
|
||||
|
||||
### Added
|
||||
|
||||
- **Replicas counted per profile: seven on cards and four on CPUs, under one
|
||||
name.** `llmgw ctl model replicas <model> gpu=7 cpu=4`, the console's
|
||||
Replicas per profile… and the mirror page's model card set how many of a
|
||||
model's replicas run on each of its profiles, rather than every replica
|
||||
on the best profile that fits. For a GGUF model with no profile on CPUs
|
||||
one is added (`--cpu-memory`), preferred below the cards, so the router
|
||||
fills the cards first and sends to the CPUs once they are full. A change
|
||||
starts the replicas short before stopping those over: the model never
|
||||
runs fewer. `ctl models` shows the counts; a scaling of another total
|
||||
ends them.
|
||||
- **Every setting of a served model from the mirror page.** A served
|
||||
model's row has a Settings link, and its card now changes the context
|
||||
and longest answer, the cards a replica takes, the replicas on each
|
||||
profile (with the CPU's speed beside), stops and starts it, restarts its
|
||||
replicas, and takes it out of the catalog with its files or without —
|
||||
beside the replicas, class, owner, memory and adapters it held before.
|
||||
Stopped on the row is now the model stopped and kept, started again as
|
||||
it was, rather than its scaling set to nothing.
|
||||
- **How fast a model answers on the CPU, before it is served.** The serve
|
||||
form says, as the cores are changed, about how many tokens a second one
|
||||
reply gets on the CPU and on one card of the fleet's fastest class, and
|
||||
warns when it is slow (under 15) or very slow (under 5, a reply of 500
|
||||
tokens taking minutes). Very slow on the CPU is refused until "run on the
|
||||
CPU anyway" is ticked, GPUs free or not. `llmgw ctl model plan` and the
|
||||
console's Hugging Face screen show the same figure for every way a model
|
||||
can run, and `ctl model serve --cpu` says it in its log.
|
||||
- **The speed follows the threads a replica is given.** Each agent now
|
||||
measures what one core reads beside what all of them read: a replica of
|
||||
four threads on a large server reads at about four cores' rate, not the
|
||||
server's, which the estimate used to assume and overstated several times.
|
||||
Calibrated on the PoC by llama-bench at 1, 2, 4 and 8 threads (within 15%),
|
||||
and a model of experts reads its experts' share, from the GGUF header.
|
||||
Agents need upgrading to report one core's rate; until then the old
|
||||
estimate is used.
|
||||
|
||||
## v0.16.2 — 2026-11-02
|
||||
|
||||
Standby control planes that answer clients themselves, with rate limits and budgets kept across them; FreeToken, an
|
||||
engine for models larger than the card; a served model's longest answer changed in place; and fixes found putting
|
||||
the router under load and fetching large models: a multi-file model fetched once per node however many replicas
|
||||
start there, downloads that carry on where they were cut off, and a router that does twice the work on two cores.
|
||||
|
||||
**Upgrading.**
|
||||
- No migrations. `llmgw ctl upgrade v0.16.2`: the control planes carry active-active routing (off until
|
||||
`llmgw ctl config set router.active_active true`) and the router fixes; the agents carry the set fetched once
|
||||
per node and the set's progress and checking; the mirror carries the resumed downloads.
|
||||
|
||||
### Added
|
||||
|
||||
- **Standbys that answer the client API themselves.** With
|
||||
`router.active_active: true` in the cluster's settings, a standby control
|
||||
plane routes `/v1` to the leader's replicas instead of passing every
|
||||
request to the leader, so more machines carry the traffic. It hears the
|
||||
replicas from the leader every two seconds over the agent port (no engine
|
||||
keys travel) and reports back what it used, so the leader does not evict
|
||||
a replica busy behind a standby, and the on-demand models it was asked
|
||||
for. Rate limits are the cluster's: each router's request and token
|
||||
counts reach the others through the leader every two seconds, so a key
|
||||
spread over several control planes gets its limit, not that many times
|
||||
it. Budgets are brought up to the store's every 30 seconds. Off by
|
||||
default; the portal, `/admin` and `/mcp` still go to the leader.
|
||||
- **A served model's longest answer, changed in place.** `llmgw ctl model
|
||||
context <model> --max-output N` (and `max_output` on `POST
|
||||
/api/v1/models/{name}/context`, and the console's Context length… form)
|
||||
sets the longest answer on its own: the catalog's limit, in force at once,
|
||||
with no replica started again. With a context it is set with it. Strata's
|
||||
context can now be changed too (its setup's `--context`), so neither
|
||||
needs the model removed and added again.
|
||||
- **FreeToken, an engine for models larger than the card.** Mixture-of-experts
|
||||
models several times a card's size, from one card, with the experts in RAM
|
||||
and streamed over PCIe — read from Hugging Face safetensors checkpoints as
|
||||
published (DeepSeek V4 Flash, GLM-5.x, Qwen3.6/3.8, gpt-oss, Gemma 4,
|
||||
MiniMax), with batching. Installed like vLLM on every node it runs on: a
|
||||
card of compute capability 8.0 or newer, driver 580+, x86-64, and the CUDA 13
|
||||
toolkit it compiles kernels with; older cards are passed over (tried on a
|
||||
Turing card, where 0.2.1 has no kernels). Profiles are one card
|
||||
(`engine: freetoken`); the key goes in its environment, and flags that read
|
||||
files or fetch are refused. Readiness now also waits for an engine whose
|
||||
`/health` answers 200 while saying `loading` in its body. FreeToken is
|
||||
followed release by release: a weekly workflow fails when PyPI has a version
|
||||
newer than `DefaultEngines` installs.
|
||||
- **How much of each GPU works, beside whether anything does.** The busy
|
||||
figure is nvidia-smi's `utilization.gpu` — the share of time any kernel
|
||||
ran, which a kernel on one SM fills — and it is now said to be that: on
|
||||
the dashboard, the node page, the metric's help and Grafana. Beside it,
|
||||
each card's draw against its power limit (the efficiency table, the fleet
|
||||
and node charts, the console's GPU work chart), and where the card's
|
||||
profiling counters can be read, its SMs, SM occupancy, tensor cores and
|
||||
memory interface: `llmgw_gpu_sm_active_percent`,
|
||||
`llmgw_gpu_sm_occupancy_percent`, `llmgw_gpu_tensor_active_percent`,
|
||||
`llmgw_gpu_dram_active_percent`. The agent reads them from NVML's GPM on
|
||||
Hopper and later, and from a dcgm-exporter on the node elsewhere
|
||||
(`discovery.dcgm_exporter`, default `http://127.0.0.1:9400/metrics`, `off`
|
||||
for none). The GPU map colours by this "work" by default — SMs active
|
||||
where read, power against the limit otherwise — and keeps busy as a mode.
|
||||
On cuda01 decode read 92% busy with the SMs at work 69% of the time.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Several replicas of a multi-file model on one node fetched it once
|
||||
each, over one another.** Four replicas placed on a node's four cards
|
||||
wanted the same safetensors set at the same moment, each wrote the same
|
||||
`.part` files, and all but one failed to start ("rename … no such file")
|
||||
until a retry found the set finished. A set is now fetched once per node
|
||||
and every replica waiting on it gets it, as single files always were.
|
||||
- **A model of several files showed one file's download as the whole.** A node fetching a set reported the part it
|
||||
was on — "59% of 46.1 GB" for a 188 GB model — instead of the set: it now reports the set's bytes against its
|
||||
whole size.
|
||||
- **A set's files checked with nothing said, one at a time.** After the last byte arrived a node checked each file
|
||||
against the mirror's checksum, 195 GB on one core for eight minutes, while the replica still read "fetching 100%".
|
||||
It now checks four at once and says so: `checking 40% of 195 GB` in `ctl replicas`, "Checking the weights" in the
|
||||
chat page; and a check that takes long is not taken for a stuck download.
|
||||
- **A mirror download the registry cut off started again from nothing, or not at all.** Hugging Face's CDN ended a
|
||||
60 GB transfer at 23 GB (`stream error … CANCEL`), the mirror deleted what had arrived, and `ctl model serve`
|
||||
failed. The mirror now tries again up to six times, carrying on from where it got to (`Range`), keeps what arrived
|
||||
when it gives up, and carries on from it after a restart too.
|
||||
- **A model from an outside provider took three or four requests at once.** Admission read every request in flight to
|
||||
an endpoint it does not measure as a queue, so an external model (OpenAI, another llmgw cluster) was held to what a
|
||||
saturation of 0.85 allows of "in flight / 4"; the rest waited and timed out with 504. An outside provider queues for
|
||||
itself and says 429 when full, which its breaker already hears: it no longer counts towards saturation.
|
||||
- **A valid key told to slow down under load, every thirty seconds.** When a busy key's cached entry expired, every
|
||||
request in flight missed the cache together and each took one of the address's allowance for unknown keys: past a
|
||||
hundred or so streams at once, a valid key got 429 "too many unrecognised api keys". Requests missing the same key
|
||||
at once now share one read and one allowance.
|
||||
- **A streamed answer was one write to the client a token.** Each event was flushed alone, a system call each: a
|
||||
third of a busy router's CPU. Events already received from the engine now go out in one write, with no token
|
||||
waiting for another; on the PoC's two-core leader the ceiling rose from ~575 to ~700 streams' requests a second,
|
||||
and the first token at 128 at once from 91 to 45 ms.
|
||||
|
||||
## v0.16.1 — 2026-10-30
|
||||
|
||||
The mirror page's Strata tab, Strata's ready-made packs served with Strata fetching them, and two fixes found
|
||||
serving Strata's models: repositories that keep each size in a directory, and DeepSeek on cards its engine does not
|
||||
run on.
|
||||
|
||||
**Upgrading.**
|
||||
- No migrations. The control planes carry the tab, the plan and the serving; the agents carry the refusal of a
|
||||
Strata profile its card cannot run (`llmgw ctl upgrade v0.16.1`). Mirrors need nothing new.
|
||||
|
||||
### Added
|
||||
|
||||
- **The mirror page's Strata tab.** Every family and size Strata runs, with its download, the RAM it keeps and where
|
||||
it would run here; a GGUF size fetched into the mirror in one press (checked against Hugging Face's checksums),
|
||||
and the packs Strata fetches itself — NVFP4, DeepSeek V4.1 — served at once, their repository the source.
|
||||
`ctl model plan` and `ctl model serve` take those repositories too, and the explorer names a `strata-ple`
|
||||
repository for what it is: part of a pack Strata knows, or one Strata 0.2.7 does not.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A repository that keeps each quantisation in a directory of its own** (ISTA-DASLab's `IQ2_XS/`, `IQ3_S/`) listed
|
||||
no weights: its file tree is now read recursively, by the explorer, the plan and `ctl model serve`.
|
||||
- **DeepSeek V4.1 offered on a card its engine was not built for.** Strata's DeepSeek engine runs on RTX 30 (compute
|
||||
8.6) and newer; on a Turing card the tab and the plan offered it, and Strata's setup refused it once placed. Now
|
||||
neither offers it there, the tab says why, and the agent refuses such a profile before starting it.
|
||||
|
||||
## v0.16.0 — 2026-10-30
|
||||
|
||||
Weights pinned to the commit they were added at and checked, by the nodes and the mirror, against the sha256
|
||||
Hugging Face published; the documents `llmgw-kb` sends embedded by the gateway; Strata offered by the plan, `ctl
|
||||
model serve`, the explorer and the serve forms; and cluster upgrades that tell the nodes together, say how far they
|
||||
are, and do not fail a node that came back on the version.
|
||||
|
||||
**Upgrading.**
|
||||
- No migrations. Upgrade the control planes, then the nodes (`llmgw ctl upgrade v0.16.0`), then each mirror's host
|
||||
(`sudo llmgw upgrade v0.16.0 --service llmgw-mirror`).
|
||||
- A node checks what it fetches against the catalog's checksums only once it runs this version; until then it
|
||||
fetches as before.
|
||||
- A model added from Hugging Face is now pinned when it is added, which needs the registry reachable from the
|
||||
control plane; one it cannot be looked up in is refused, saying why. Models already in the catalog are left as
|
||||
they are.
|
||||
- A Strata profile must say `--size` as well as `--family`, and both must be ones Strata runs.
|
||||
- `llmgw-kb` sends text alone to a gateway of this version; an older `llmgw-kb` keeps working, sending vectors.
|
||||
|
||||
### Added
|
||||
|
||||
- **Strata offered where models are chosen.** For a repository of a family Strata runs, `ctl model plan` offers "one
|
||||
card with Strata, experts in RAM" with the RAM it takes, `ctl model serve --engine strata` (or the plan's choice)
|
||||
builds its profile, the Hugging Face explorer lists it as running here, and the mirror's serve form serves such a
|
||||
version on Strata. Strata's families and sizes are known: the catalog refuses one it does not run and reserves the
|
||||
RAM a size keeps its experts in.
|
||||
- **The gateway embeds the documents `llmgw-kb` sends.** A document may be sent as its passages' text alone; the
|
||||
gateway embeds them through its own `/v1/embeddings` as the caller, 32 at a time and four batches at once,
|
||||
waiting out a busy engine rather than failing the document. About a kilobyte a passage leaves the machine instead
|
||||
of a vector down and up again, some thirty times more. `llmgw-kb` does so with a gateway that says it can
|
||||
(`server_embeds` in a collection's answer); `--embed-here` keeps the old way.
|
||||
- **Weights pinned to a commit, and checked against what Hugging Face published.** A model added from Hugging Face
|
||||
(`hf:`, a resolve URL on a branch, or a repository vLLM or SGLang downloads) is pinned to the commit it is at
|
||||
then — the file's URL at that commit, or `--revision` — and each file's published sha256 goes in its origin. A node
|
||||
refuses and deletes a copy that is not it, naming both checksums; the mirror does the same for what `ctl model
|
||||
serve` fetches, and `llmgw mirror add` for a Hugging Face URL (`--sha256` for others). Nodes need this version to
|
||||
check; older ones fetch as before.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A cluster upgrade that sat silent, or stopped on a node that had upgraded.**
|
||||
- The nodes of a wave are told together, each saying when it is done, and what is still awaited is said every 30
|
||||
seconds; before, each was told in turn, up to five minutes each, with nothing on the screen.
|
||||
- The control plane takes the node registering again on the version as done: the agent's answer, sent a moment
|
||||
before it exits, could be lost, and a node that had upgraded was failed as "older than v0.10.4".
|
||||
- The agent's download no longer ends with its connection (a control plane restarting, leadership moving), and the
|
||||
control plane waits longer than the download may take; a second request while one runs is refused.
|
||||
- A cordoned or draining node upgraded is done; a node whose idle model stopped meanwhile is done once nothing on it
|
||||
is starting; a node not connected is passed over and named at the end rather than stopping every node after it.
|
||||
- The console's node upgrade waits as long as the control plane, and offers no development version.
|
||||
- systemd never gives up restarting the agent (`StartLimitIntervalSec=0`), in units written from now on.
|
||||
|
||||
- **Strata.** One named by hand in `agent.yaml` was never reported, so never placed on; its engine, which leaves the
|
||||
server's process group, is now stopped with it; Pascal and Volta, which its CUDA 12 build runs, were refused.
|
||||
- **A name with a slash in it, asked through a standby control plane.** `llmgw ctl kb delete Demo/hr` on a standby
|
||||
was answered 405: the standby passed the request on to the leader with `Demo%2Fhr` unescaped, two segments instead
|
||||
of one. The path now reaches the leader as it was sent.
|
||||
|
||||
## v0.15.3 — 2026-10-29
|
||||
|
||||
A model stopped and kept, or removed with its files; the second mirror fixed where it was tried.
|
||||
|
||||
**Upgrading.**
|
||||
- No migrations. The control planes carry stop, start and purge; the nodes need nothing new.
|
||||
- The mirrors carry the fixes: on each mirror's machine, `sudo llmgw upgrade v0.15.3 --service llmgw-mirror`.
|
||||
A mirror with a peer then works out the checksums its older files lack, once, which reads every such file.
|
||||
|
||||
### Added
|
||||
|
||||
- **A model stopped and kept, and a model removed with its files.** `llmgw ctl model stop|start MODEL`: none of its
|
||||
replicas run, it stays in the catalog with its files, and start runs it again scaled as it was; requests are told an
|
||||
administrator stopped it. `llmgw ctl model remove MODEL --purge` (`DELETE /api/v1/models/{name}?purge=true`) also
|
||||
deletes every version of it on the mirror, a second mirror following, and the nodes' copies once its replicas have
|
||||
stopped; what another model uses stays. Both are in the console's model menu.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`llmgw mirror add-peer` on a mirror with a machine of its own.** It set the second mirror up and then
|
||||
failed for want of the control plane's server.yaml; now it prints the `llmgw ctl config set mirror.peers`
|
||||
to run on a control plane.
|
||||
- **Models fetched before checksums were kept reach the second mirror.** A mirror kept in step copies only what it
|
||||
can check, and those files had no sha256; a mirror with peers now works theirs out and records them, once.
|
||||
- **A file removed with `llmgw mirror remove` is gone from the mirror's answers too.** The running mirror kept
|
||||
reporting a file it had fetched as present for a week after it was removed, so a control plane could place a
|
||||
model on a file that was not there; and the removal was not recorded, so a mirror kept in step copied it back.
|
||||
|
||||
## v0.15.2 — 2026-10-29
|
||||
|
||||
A served model moved to more cards, or fewer, while it is served.
|
||||
|
||||
**Upgrading.**
|
||||
- No migrations, and nothing changes until the command is used. The control
|
||||
planes carry it; the nodes need nothing new.
|
||||
|
||||
### Added
|
||||
|
||||
- **A served model moved to more cards, or fewer.** `llmgw ctl model gpus
|
||||
MODEL N [--profile P] [--in-place]`, `POST /api/v1/models/{name}/gpus`, and
|
||||
GPUs… in the console's model menu. How many cards of one node each replica
|
||||
uses: tensor parallel for vLLM, SGLang and TensorRT-LLM, checked against
|
||||
the model's KV heads, and the layers split for llama.cpp. Refused for a
|
||||
count no node has, or whose cards would not hold the model and its
|
||||
context, with the figures; its replicas are then started again on it, one
|
||||
by one, beside the old — or, with `--in-place` when there is no room
|
||||
beside them, each stopped first.
|
||||
|
||||
## v0.15.1 — 2026-10-29
|
||||
|
||||
A 125B model on one card, a second mirror, and weights that run code
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Functional Source License, Version 1.1, ALv2 Future License
|
||||
|
||||
## Abbreviation
|
||||
|
||||
FSL-1.1-ALv2
|
||||
|
||||
## Notice
|
||||
|
||||
Copyright 2026 Ilker Manap
|
||||
|
||||
## Terms and Conditions
|
||||
|
||||
### Licensor ("We")
|
||||
|
||||
The party offering the Software under these Terms and Conditions.
|
||||
|
||||
### The Software
|
||||
|
||||
The "Software" is each version of the software that we make available under
|
||||
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
||||
Conditions with the Software.
|
||||
|
||||
### License Grant
|
||||
|
||||
Subject to your compliance with this License Grant and the Patents,
|
||||
Redistribution and Trademark clauses below, we hereby grant you the right to
|
||||
use, copy, modify, create derivative works, publicly perform, publicly display
|
||||
and redistribute the Software for any Permitted Purpose identified below.
|
||||
|
||||
### Permitted Purpose
|
||||
|
||||
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
||||
means making the Software available to others in a commercial product or
|
||||
service that:
|
||||
|
||||
1. substitutes for the Software;
|
||||
|
||||
2. substitutes for any other product or service we offer using the Software
|
||||
that exists as of the date we make the Software available; or
|
||||
|
||||
3. offers the same or substantially similar functionality as the Software.
|
||||
|
||||
Permitted Purposes specifically include using the Software:
|
||||
|
||||
1. for your internal use and access;
|
||||
|
||||
2. for non-commercial education;
|
||||
|
||||
3. for non-commercial research; and
|
||||
|
||||
4. in connection with professional services that you provide to a licensee
|
||||
using the Software in accordance with these Terms and Conditions.
|
||||
|
||||
### Patents
|
||||
|
||||
To the extent your use for a Permitted Purpose would necessarily infringe our
|
||||
patents, the license grant above includes a license under our patents. If you
|
||||
make a claim against any party that the Software infringes or contributes to
|
||||
the infringement of any patent, then your patent license to the Software ends
|
||||
immediately.
|
||||
|
||||
### Redistribution
|
||||
|
||||
The Terms and Conditions apply to all copies, modifications and derivatives of
|
||||
the Software.
|
||||
|
||||
If you redistribute any copies, modifications or derivatives of the Software,
|
||||
you must include a copy of or a link to these Terms and Conditions and not
|
||||
remove any copyright notices provided in or with the Software.
|
||||
|
||||
### Disclaimer
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
||||
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
||||
|
||||
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
||||
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
||||
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
||||
|
||||
### Trademarks
|
||||
|
||||
Except for displaying the License Details and identifying us as the origin of
|
||||
the Software, you have no right under these Terms and Conditions to use our
|
||||
trademarks, trade names, service marks or product names.
|
||||
|
||||
## Grant of Future License
|
||||
|
||||
We hereby irrevocably grant you an additional license to use the Software under
|
||||
the Apache License, Version 2.0 that is effective on the second anniversary of
|
||||
the date we make the Software available. On or after that date, you may use the
|
||||
Software under the Apache License, Version 2.0, in which case the following
|
||||
will apply:
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
|
||||
this file except in compliance with the License.
|
||||
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software distributed
|
||||
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
||||
CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations under the License.
|
||||
@@ -41,7 +41,8 @@ Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
|
||||
| v0.12.0 | — | The cluster's settings in the store, in force when changed; the firewall switch; wipe | released |
|
||||
| v0.13.0 | — | The whole system stopped and started from one command; Qwen3Guard judges, on a GPU and CPUs together | released |
|
||||
| v0.14.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released (v0.14.1: adapters, the tree over the API, the firewall on every machine; v0.14.2: pictures from ComfyUI; v0.14.3: 3D models as jobs; v0.14.4: any ComfyUI workflow as a job, presets, licences; v0.14.5: picture and 3D models that add themselves; v0.14.6: setup that explains every part and lets it be chosen, the gateway on all three HA machines; v0.14.7: the judge sized for its machine; v0.14.8: sign-in with Active Directory, several keys for one outside provider; v0.14.9: sign-in set up from the console, a node's console that shows the machine) |
|
||||
| v0.15.0 | — | The company's MCP tools and VS Code through the gateway; guardrails that ask outside services and put redacted values back; answers to similar questions | released (v0.15.1: Strata, a 125B model of experts on one card; a second mirror kept in step; pickled weights refused; a served model's context changed) |
|
||||
| v0.15.0 | — | The company's MCP tools and VS Code through the gateway; guardrails that ask outside services and put redacted values back; answers to similar questions | released (v0.15.1: Strata, a 125B model of experts on one card; a second mirror kept in step; pickled weights refused; a served model's context changed; v0.15.2: a served model moved to more cards; v0.15.3: a model stopped and kept, or removed with its files) |
|
||||
| v0.16.0 | — | Weights pinned to a commit and checked against what Hugging Face published; documents embedded by the gateway; Strata offered where models are chosen; cluster upgrades that say how far they are | released (v0.16.1: the mirror's Strata tab, Strata's ready-made packs; v0.16.2: standbys that answer clients, FreeToken, a model fetched once per node; v0.16.3: replicas per profile, a served model's settings on the mirror page, the CPU's speed before serving) |
|
||||
| v1.0.0 | 5 | What phase 5 has left: precise KV routing proven on vLLM, the block cost picker, defragmentation across NVLink domains, prefill/decode disaggregation | planned |
|
||||
|
||||
As of v0.6.0 a cluster runs unattended: agents report their hardware, the
|
||||
@@ -371,3 +372,10 @@ make rename NEW=git.example.com/team/llmgw
|
||||
## Language
|
||||
|
||||
Code, comments, tests, commit messages and documentation are in English.
|
||||
|
||||
## Licence
|
||||
|
||||
Functional Source License 1.1 with an Apache 2.0 future licence
|
||||
(FSL-1.1-ALv2); see [LICENSE](LICENSE). Each release may be used, read and
|
||||
changed for anything except offering it as a competing product or service,
|
||||
and becomes Apache 2.0 two years after it is published.
|
||||
+769
-95
File diff suppressed because it is too large.
Load diff
@@ -30,6 +30,7 @@ message AgentMessage {
|
||||
KVEvents kv_events = 8;
|
||||
TrainStatus train_status = 9;
|
||||
TrainArtifact train_artifact = 10;
|
||||
SlurmStatus slurm_status = 11;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +79,7 @@ message ControlMessage {
|
||||
AgentSettings agent_settings = 15;
|
||||
TrainStart train_start = 16;
|
||||
TrainCancel train_cancel = 17;
|
||||
Slurm slurm = 18;
|
||||
}
|
||||
// trace is the W3C trace context (traceparent, and tracestate when there
|
||||
// is one) of the control plane's span that sent this command, so what the
|
||||
@@ -241,6 +243,10 @@ message Host {
|
||||
// mem_bandwidth_gbs is how fast the memory reads, in GB/s, measured when
|
||||
// the agent started: what decides a model's speed on the CPU. 0 unknown.
|
||||
double mem_bandwidth_gbs = 7;
|
||||
// mem_bandwidth_core_gbs is what one core reads alone, in GB/s: a replica
|
||||
// given a few threads of a large machine reads at a few times this, not at
|
||||
// the whole machine's rate. 0 unknown.
|
||||
double mem_bandwidth_core_gbs = 8;
|
||||
}
|
||||
|
||||
// Storage is one filesystem a node keeps weights on.
|
||||
@@ -448,6 +454,21 @@ message GPUSensor {
|
||||
int32 last_xid = 26;
|
||||
string last_xid_text = 27;
|
||||
int64 last_xid_unix = 28;
|
||||
// How much of the chip the work on it uses, which utilization_percent
|
||||
// does not say: that is the share of time any kernel ran, and a kernel on
|
||||
// one SM of a hundred counts in full. has_profile says these are present;
|
||||
// -1 is not reported. Read from NVML's GPM on Hopper and later, or from a
|
||||
// dcgm-exporter on the node; profile_source says which ("gpm", "dcgm").
|
||||
bool has_profile = 29;
|
||||
// sm_active_percent is the share of time the SMs had work, averaged over
|
||||
// all of them; sm_occupancy_percent how full those SMs were with warps.
|
||||
double sm_active_percent = 30;
|
||||
double sm_occupancy_percent = 31;
|
||||
// tensor_active_percent is the tensor cores; dram_active_percent the
|
||||
// memory interface, which decode is bound by.
|
||||
double tensor_active_percent = 32;
|
||||
double dram_active_percent = 33;
|
||||
string profile_source = 34;
|
||||
}
|
||||
|
||||
// ShardLoad is one engine process group's use.
|
||||
@@ -603,6 +624,8 @@ message Heartbeat {
|
||||
// the agent has measured it — in the background after it starts, so a
|
||||
// registration usually has none yet. 0 means not measured.
|
||||
double mem_bandwidth_gbs = 20;
|
||||
// mem_bandwidth_core_gbs is one core's read rate, as in Host.
|
||||
double mem_bandwidth_core_gbs = 21;
|
||||
}
|
||||
|
||||
// Provision is the software a node should have: which engines, at which
|
||||
@@ -705,6 +728,9 @@ message FetchProgress {
|
||||
// is a real answer: a percentage would then be invented, and an invented
|
||||
// percentage that stops moving is worse than a byte count that keeps rising.
|
||||
int64 bytes_total = 3;
|
||||
// phase is what the bytes are: empty while they arrive, "checking" while the
|
||||
// node checks a set's files against the mirror's checksums.
|
||||
string phase = 4;
|
||||
}
|
||||
|
||||
// StartShard launches one shard. It is idempotent by shard_id: the control
|
||||
@@ -767,6 +793,10 @@ message EngineProfile {
|
||||
// allow_pickle lets the node fetch weights in a pickle format, which run
|
||||
// code when loaded: the catalog's allow_pickle, set on purpose.
|
||||
bool allow_pickle = 14;
|
||||
// model_sha256 is the sha256 the registry published for the file
|
||||
// model_path names, from the catalog's origin: the node refuses a copy
|
||||
// that is not it. Empty when nothing is known to check against.
|
||||
string model_sha256 = 15;
|
||||
}
|
||||
|
||||
// RemoveWeights deletes cached model files a node no longer needs.
|
||||
@@ -958,3 +988,99 @@ message TrainArtifact {
|
||||
bytes data = 4;
|
||||
bool last = 5;
|
||||
}
|
||||
|
||||
// Slurm is what this node is in the Slurm cluster run beside llmgw
|
||||
// (BACKLOG P): its controller, one of its nodes, both or neither. Sent when
|
||||
// an agent registers and whenever it changes; the agent installs what is
|
||||
// missing and keeps the daemons running as it says.
|
||||
message Slurm {
|
||||
// controller runs slurmctld here, on files.
|
||||
bool controller = 1;
|
||||
// node runs slurmd here, configless, fetching its files from conf_server.
|
||||
bool node = 2;
|
||||
// name is this node's NodeName in slurm.conf.
|
||||
string name = 3;
|
||||
// conf_server is slurmctld's host:port.
|
||||
string conf_server = 4;
|
||||
// munge_key is the key every machine of the cluster shares.
|
||||
bytes munge_key = 5;
|
||||
// files are the controller's, by name in /etc/slurm; empty for a node
|
||||
// that is not the controller.
|
||||
map<string, string> files = 6;
|
||||
// lent says Slurm may run jobs here now: resume the node if llmgw drained
|
||||
// it. Otherwise it is drained, its jobs left to finish.
|
||||
bool lent = 7;
|
||||
// shared is the file system every machine of the Slurm cluster sees at
|
||||
// the same path; unset or with no path, none.
|
||||
SlurmShared shared = 8;
|
||||
// manage_users has llmgw keep the Linux users below on this machine,
|
||||
// and take away those it made that are no longer among them.
|
||||
bool manage_users = 9;
|
||||
repeated SlurmUser users = 10;
|
||||
// make_homes has this machine make the users' home directories: the one
|
||||
// that exports the shared path, else the controller.
|
||||
bool make_homes = 11;
|
||||
// containers has the machine install pyxis and enroot, so jobs may run
|
||||
// in containers (srun --container-image), and set them up.
|
||||
bool containers = 12;
|
||||
}
|
||||
|
||||
// SlurmShared is where the Slurm cluster's shared files come from.
|
||||
message SlurmShared {
|
||||
// path is where every machine sees them, as /shared.
|
||||
string path = 1;
|
||||
// nfs is an export, host:/path, to mount at path; empty when this
|
||||
// machine exports path itself (serve) or the site mounts it.
|
||||
string nfs = 2;
|
||||
// serve has this machine export path over NFS to clients.
|
||||
bool serve = 3;
|
||||
// clients are the addresses the export answers.
|
||||
repeated string clients = 4;
|
||||
}
|
||||
|
||||
// SlurmUser is a Linux user that runs jobs, the same on every machine.
|
||||
message SlurmUser {
|
||||
string login = 1;
|
||||
// uid is also the user's own group's gid.
|
||||
uint32 uid = 2;
|
||||
// name is the account's, for the GECOS field.
|
||||
string name = 3;
|
||||
string home = 4;
|
||||
// ssh_keys are OpenSSH public keys, one a line of authorized_keys.
|
||||
repeated string ssh_keys = 5;
|
||||
}
|
||||
|
||||
// SlurmStatus is what this node's Slurm daemons are doing, sent when it
|
||||
// changes and every few minutes while the node has a part in Slurm.
|
||||
message SlurmStatus {
|
||||
// version is Slurm's, as "23.11.4"; empty when it is not installed.
|
||||
string version = 1;
|
||||
// hardware is `slurmd -C` on a node, which slurm.conf must match.
|
||||
string hardware = 2;
|
||||
bool slurmd_running = 3;
|
||||
bool slurmctld_running = 4;
|
||||
// state and reason are the controller's for this node, from scontrol.
|
||||
string state = 5;
|
||||
string reason = 6;
|
||||
// jobs is how many job steps run on this node now, counted from their
|
||||
// slurmstepd so a controller that is down does not hide them; -1 when the
|
||||
// agent cannot tell.
|
||||
int32 jobs = 7;
|
||||
// error is the last thing that went wrong keeping Slurm, empty when
|
||||
// nothing did.
|
||||
string error = 8;
|
||||
// files_sha256 is of the controller's files as written, so the leader
|
||||
// knows they arrived.
|
||||
string files_sha256 = 9;
|
||||
// slurmdbd_running is the accounting daemon on the controller, which keeps
|
||||
// the jobs' records in a MariaDB beside it.
|
||||
bool slurmdbd_running = 10;
|
||||
// shared is the shared path as this machine has it: "mounted",
|
||||
// "exported", "missing", or empty when there is none.
|
||||
string shared = 11;
|
||||
// users is how many of llmgw's users this machine has.
|
||||
int32 users = 12;
|
||||
// containers is what this machine has for jobs in containers, as
|
||||
// "pyxis 0.24.0, enroot 4.2.1"; empty while it has not got both.
|
||||
string containers = 13;
|
||||
}
|
||||
@@ -331,6 +331,8 @@ hidden files, version control folders and Office lock files are not.`,
|
||||
f.BoolVar(&o.Delete, "delete", false, "remove documents this machine sent whose files are gone")
|
||||
f.BoolVar(&o.Retry, "retry", false, "read again files skipped before")
|
||||
f.IntVar(&o.Workers, "workers", 4, "files read and sent at once")
|
||||
f.BoolVar(&o.EmbedHere, "embed-here", false,
|
||||
"embed on this machine and send the vectors, even to a gateway that embeds text it is sent")
|
||||
f.StringVar(&ocr, "ocr-languages", "tur+eng", "Tesseract's languages for images and scanned pages")
|
||||
f.Int64Var(&maxMiB, "max-size", 256, "skip files larger than this many MiB")
|
||||
f.BoolVarP(&verbose, "verbose", "v", false, "list unchanged files too")
|
||||
|
||||
@@ -504,7 +504,7 @@
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "GPU busy, by card",
|
||||
"description": "Share of each sample period a kernel was running, sampled every heartbeat.",
|
||||
"description": "Share of each sample period in which any kernel ran (nvidia-smi's utilization.gpu), sampled every heartbeat. Not how much of the chip: one SM busy reads 100%. The SMs panel and power against its limit say how much.",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
@@ -908,6 +908,51 @@
|
||||
"mode": "multi"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "GPU SMs and tensor cores active, by card",
|
||||
"description": "From the cards' profiling counters: NVML's GPM on Hopper and later, or a dcgm-exporter on the node. SMs active is the share of time the SMs had work, averaged over all of them; the gap to busy is chip a running kernel leaves idle. Tensor cores stay low in decode at batch one; prefill and batches are where they rise. Empty for cards nothing can profile.",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 73,
|
||||
"w": 24,
|
||||
"h": 7
|
||||
},
|
||||
"id": 25,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "llmgw_gpu_sm_active_percent",
|
||||
"legendFormat": "{{node}} gpu {{gpu}} SMs",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "llmgw_gpu_tensor_active_percent",
|
||||
"legendFormat": "{{node}} gpu {{gpu}} tensor",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"min": 0,
|
||||
"max": 100
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
Description=llmgw node agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
# Never given up on: a new release that fails its first starts, or an upgrade
|
||||
# that restarts it twice in a minute, would otherwise leave the node off.
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/llmgw agent --config /etc/llmgw/agent.yaml
|
||||
|
||||
+862
-4
File diff suppressed because it is too large.
Load diff
@@ -2890,6 +2890,10 @@
|
||||
"writable": {
|
||||
"type": "boolean",
|
||||
"description": "Writable is whether this caller may add and remove documents."
|
||||
},
|
||||
"server_embeds": {
|
||||
"type": "boolean",
|
||||
"description": "ServerEmbeds says a document may be sent as text alone: the gateway embeds its chunks, as the caller, with the collection's model."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+17
-2
@@ -67,7 +67,8 @@ or with the mirror unticked, is said in the form and nothing starts.
|
||||
## Watch
|
||||
|
||||
**Overview** is `llmgw ctl status`: nodes, GPUs by class, fabrics, replicas,
|
||||
models, the store's members, and anything parked, unplaced or idle.
|
||||
models, the store's members, and anything parked, unplaced or idle. With no
|
||||
webhook in the cluster's settings it says the alerts go nowhere.
|
||||
|
||||
**Monitor** draws the cluster the way btop draws a machine: CPU, memory,
|
||||
GPU busy and GPU memory, requests and tokens a second, time to first token,
|
||||
@@ -207,6 +208,19 @@ starting — and whether it ended done, failed, or waiting for room on busy
|
||||
cards. The entry shows how many are running. Enter on one shows its steps.
|
||||
`llmgw ctl jobs` is the same, and `llmgw ctl jobs ID` follows one.
|
||||
|
||||
**Slurm** is the Slurm cluster run beside llmgw, when there is one: its
|
||||
controller and whether slurmctld runs, and each node that runs slurmd —
|
||||
whose it is now (llmgw's, Slurm's, or on its way between them), its state in
|
||||
Slurm, the jobs running there. On a row, **Lend to Slurm…** moves the node's
|
||||
replicas off and then lets Slurm run jobs there; **Take back from Slurm…**
|
||||
starts no new job there and gives it back to llmgw when its jobs have ended.
|
||||
Both change `slurm.lent` in the cluster's settings, as `llmgw ctl slurm lend`
|
||||
and `reclaim` do. Turning Slurm on and choosing its nodes is `llmgw ctl slurm
|
||||
setup`, or the Slurm part of the first install. Above the nodes it says where
|
||||
the shared files come from and how many users llmgw made; the SHARED column
|
||||
is each node's (mounted, exported, missing). `llmgw ctl slurm shared` and
|
||||
`llmgw ctl slurm users` change them.
|
||||
|
||||
**Logs** shows a node's agent log, newest at the bottom, errors and warnings
|
||||
coloured.
|
||||
|
||||
@@ -220,7 +234,8 @@ and change it with the same commands as `llmgw users`, `llmgw keys` and
|
||||
dashboard, and what they belong to. **A** creates an account, already
|
||||
approved. On a row: approve one waiting, suspend or reinstate, give or take
|
||||
the cluster dashboard, add to or take out of an organization, team or
|
||||
project, their limits, a new password (their sessions close), delete, and
|
||||
project (on an organization as a member, its manager, who sees its page, or
|
||||
its admin, who also changes it), their limits, a new password (their sessions close), delete, and
|
||||
**Forget them…** for a request to be forgotten (`llmgw users forget`).
|
||||
|
||||
**API keys** — every key, its project, owner, state and last use. **A**
|
||||
|
||||
+291
-16
@@ -166,13 +166,74 @@ is a flag too, and the plan shows the command with them: `--starter qwen3-8b`
|
||||
`--judge-fail open`, `--judge-categories`, `--judge-replicas`,
|
||||
`--judge-budget`, `--language tr`, `--signup-open`, `--default-rpm`,
|
||||
`--default-tpm`, `--max-key-ttl`, `--media` (pictures and 3D: ComfyUI on the
|
||||
nodes, and `presets.auto`), `--evict-after-idle` and `--min-lifetime`. They
|
||||
nodes, and `presets.auto`), `--slurm` (see below), `--evict-after-idle` and `--min-lifetime`. They
|
||||
go into `server.yaml` as init writes it, and become the cluster's first
|
||||
settings when it starts; a `server.yaml` already there is never overwritten,
|
||||
so run again they change nothing — the settings page does. An NVIDIA card with no working
|
||||
driver is reported rather than silently ignored; install the driver and run
|
||||
init again to serve on it.
|
||||
|
||||
**Slurm, for training and batch jobs.** `--slurm`, or the Slurm part on the
|
||||
choices screen, runs a Slurm cluster beside llmgw: slurmctld on this
|
||||
machine's node, slurmd on every node, those that join later too
|
||||
(`slurm.all_nodes`). The agents install munge and Slurm (22.05 or newer)
|
||||
from the machines' own package sources, and on this machine slurmdbd, which
|
||||
keeps the jobs' records (`sacct`), in a MariaDB installed beside it that
|
||||
answers this machine alone. Slurm keeps its records in MySQL or MariaDB
|
||||
only, not in the cluster's PostgreSQL; the database's password is made from
|
||||
the cluster's munge key, so nothing new is stored. A MariaDB the machine
|
||||
already had keeps its settings and only gets the database `slurm_acct_db`
|
||||
and its user. Back it up with the machine (`mariadb-dump slurm_acct_db`);
|
||||
records older than a year are purged. No node is Slurm's at first: each is
|
||||
drained in Slurm and serves models until `llmgw ctl slurm lend NODE` gives it
|
||||
to Slurm — its models move off first — and `llmgw ctl slurm reclaim NODE`
|
||||
takes it back once its jobs end. Jobs are submitted on this machine with
|
||||
`sbatch` and `srun`; `llmgw ctl slurm status` shows where each node is. It
|
||||
needs this machine to be a node, and its hostname to be a name Slurm takes.
|
||||
On a cluster installed without it, `llmgw ctl slurm setup --controller NODE
|
||||
--all-nodes` does the same later. Slurm's ports (6817–6819, and srun's 60001–63000) answer the cluster's
|
||||
networks only, as llmgw's own do; a firewall of the site's own between the
|
||||
nodes must let them through.
|
||||
|
||||
**Slurm's shared files and users.** Jobs need the same files on every
|
||||
machine: homes, datasets, output, checkpoints. With `--slurm` this machine
|
||||
exports `/shared` over NFS to every Slurm machine and they mount it
|
||||
(`slurm.shared`); NFS's port 2049 answers the cluster's networks only, the
|
||||
export the Slurm machines alone. That is one machine, not highly available
|
||||
and no faster than its disk and network: enough for a few nodes. A site with
|
||||
storage of its own uses it instead — `--slurm-shared HOST:/EXPORT` at
|
||||
install, or `llmgw ctl slurm shared --path /shared --nfs HOST:/EXPORT`
|
||||
later; Lustre, BeeGFS or GPFS the site mounts itself, and `llmgw ctl slurm
|
||||
shared --path /lustre/hpc` only has llmgw look that it is there. Every
|
||||
active llmgw account is a Linux user on every Slurm machine
|
||||
(`slurm.users: llmgw`): its login from its e-mail address, a UID from
|
||||
200000 up that is never given to anyone else, its home under
|
||||
`/shared/home`. People add their SSH public keys in the portal's
|
||||
**Profile** and log in to this machine to run `sbatch`; an account suspended
|
||||
or deleted loses its user, its files stay. On the other Slurm machines they
|
||||
are let in only while a job of theirs runs there, and their ssh session is
|
||||
put in that job, held to its cores and cards (pam_slurm_adopt, which the
|
||||
agents install); root and the machines' own users log in as before. A site whose users come from its
|
||||
directory (sssd against Active Directory or LDAP) passes `--slurm-users
|
||||
site`, or later `llmgw ctl slurm users off`.
|
||||
|
||||
Jobs may run in containers: `llmgw ctl slurm containers on` has the agents
|
||||
install enroot and pyxis on every Slurm machine (NVIDIA's releases, at
|
||||
versions llmgw pins and checks; pyxis is built against each machine's own
|
||||
Slurm) and, on GPU machines, nvidia-container-cli from NVIDIA's repository.
|
||||
Once every machine has them Slurm loads pyxis, and `ctl slurm status` says
|
||||
`containers: on`:
|
||||
|
||||
```sh
|
||||
srun --gres=gpu:1 --container-image=nvcr.io#nvidia/pytorch:25.01-py3 python train.py
|
||||
```
|
||||
|
||||
The shared path is in every container where it is on the host, and the job
|
||||
runs as its user, held to its cores and cards. Each node pulls the image
|
||||
from its registry for each job; for a large image used often, import it
|
||||
once (`enroot import -o /shared/images/pytorch.sqsh docker://nvcr.io#...`)
|
||||
and give that file to `--container-image`.
|
||||
|
||||
**Secure by default.** What init writes is the hardened configuration, not a
|
||||
demonstration one:
|
||||
|
||||
@@ -703,6 +764,8 @@ Nothing to do: the agent installs them once the node joins, under
|
||||
| vLLM | nodes with an NVIDIA GPU it can run on | a Python environment built by uv from PyPI, about 8 GB |
|
||||
| SGLang | only when you ask for it | the same way as vLLM |
|
||||
| TensorRT-LLM | only nodes where you installed it | not installed by llmgw: see [TensorRT-LLM, which you bring](#tensorrt-llm-which-you-bring) |
|
||||
| FreeToken | nodes with a GPU of compute capability 8.0 or newer (RTX 30, A100 and later), driver 580+, x86-64 and the CUDA 13 toolkit | a Python environment built by uv from PyPI (`freetoken[accel]`); see [FreeToken](#freetoken-models-larger-than-the-card) |
|
||||
| Strata | only when you ask for it (`engines: {strata: "0.2.7"}`) | its source, pinned by digest, in a Python environment, and its prebuilt engine for CUDA 13 (driver 580+) or CUDA 12.9 (Pascal to Ada, driver 575+); see [Strata](#strata-a-large-model-of-experts-on-one-card) |
|
||||
|
||||
vLLM is resolved as PyPI stood a day after that vLLM was published, so every
|
||||
node gets the same dependencies whenever it is built, and installed with
|
||||
@@ -745,6 +808,100 @@ you installed yourself on a node, name its path in that node's `agent.yaml`
|
||||
(`engines.llamacpp.bin`, `engines.vllm.bin`, `engines.sglang.python`,
|
||||
`engines.trtllm.python`): the agent then installs none of its own beside it.
|
||||
|
||||
#### Strata: a large model of experts on one card
|
||||
|
||||
Strata runs a few mixture-of-experts models — Qwen3.8-Flash-Next (125B) and its
|
||||
Swift, Coder, Unsloth, Gyro and NVFP4 versions, Qwen3.6-35B-A3B, DeepSeek
|
||||
V4.1-Flash — with the experts in the node's RAM and the rest on one card of 12
|
||||
GB or more. Where it is installed, the plan offers it for a repository of one
|
||||
of those families:
|
||||
|
||||
```
|
||||
llmgw ctl model plan ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF
|
||||
llmgw ctl model serve ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF \
|
||||
--file Qwen3.8-Flash-Next-GSQ-RCO-IQ2_XS-00001-of-00002.gguf --engine strata
|
||||
```
|
||||
|
||||
The Hugging Face explorer shows the same ("1× … with Strata, N GB of RAM"),
|
||||
and the mirror's serve form serves such a version on Strata.
|
||||
|
||||
The mirror page's **Strata** tab lists every family and size Strata runs, with
|
||||
what each downloads and keeps in RAM and where it would run here:
|
||||
|
||||
- **Fetch into the mirror**, for the GGUF families: the size's files, checked
|
||||
against Hugging Face's checksums; the version's serve form then serves it on
|
||||
Strata.
|
||||
- **Serve**, for the packs Strata fetches itself — the NVFP4 packs
|
||||
(`Maximilian228/Qwen3.8-Flash-Next-NVFP4-GPTQ-Strata` and its NVIDIA
|
||||
sibling, NVIDIA's FP4-Local) and DeepSeek V4.1: the model is added with the
|
||||
repository as its source, and its first start fetches tens of gigabytes onto
|
||||
the node, each file pinned and checked by Strata, before preparing it.
|
||||
`ctl model serve` does the same for these repositories.
|
||||
|
||||
Repositories of architecture `strata-ple` that Strata 0.2.7 does not list —
|
||||
community packs such as `…-Q8_0-Strata` — are named as such in the explorer:
|
||||
Strata's setup refuses them, and llama.cpp cannot read them. A Strata profile
|
||||
says its family and size (`--family qwen --size IQ2_XS`), which the catalog
|
||||
checks against what Strata runs, and reserves the RAM the size keeps its experts
|
||||
in (48 GB and the engine's 4 for IQ2_XS) unless it says `min_memory_gb`. Its
|
||||
first start prepares the model on the node, which takes minutes, and fetches a
|
||||
draft layer of about 5 GB from the internet.
|
||||
|
||||
#### FreeToken: models larger than the card
|
||||
|
||||
[FreeToken](https://github.com/FlashML-org/FreeToken) serves mixture-of-experts
|
||||
models several times a card's size from one card: the experts stay in the
|
||||
node's RAM, the ones used most are cached in VRAM, and the rest stream over
|
||||
PCIe. Unlike Strata it reads Hugging Face safetensors checkpoints as they are
|
||||
published — DeepSeek V4 Flash, GLM-5.x, Qwen3.6/3.8 (Flash-Next among them),
|
||||
gpt-oss, Gemma 4, MiniMax M2.5/M3 — in FP8, NVFP4, MXFP4 or BF16, and batches
|
||||
several requests (`--max-running-requests`, 4 by default).
|
||||
|
||||
Every node that can run it installs it, as with vLLM: an NVIDIA GPU of compute
|
||||
capability 8.0 or newer, driver 580 or newer, x86-64. Older cards are passed
|
||||
over without a word — on a Quadro RTX 6000 (7.5) version 0.2.1 had no kernel
|
||||
image for the card's first prefill, and in fp16 its first request ended in an
|
||||
illegal memory access. Its FP8 checkpoints need 8.9 (Ada, Hopper) and its NVFP4
|
||||
ones 12.0 (RTX 50, RTX PRO 6000 Blackwell). It compiles some kernels the first
|
||||
time a model loads, so the node also needs the **CUDA 13 toolkit** (`nvcc` in
|
||||
`CUDA_HOME`, `/usr/local/cuda` or `/usr/local/cuda-13.*`); a node of new cards
|
||||
without one says so on the cluster page instead of installing an engine that
|
||||
fails on its first model.
|
||||
|
||||
A FreeToken profile runs on one card of one node (`llmgw ctl model add -f`):
|
||||
|
||||
```yaml
|
||||
name: glm-5.2
|
||||
source: /var/lib/llmgw-weights/GLM-5.2-NVFP4 # a safetensors checkpoint's directory
|
||||
profiles:
|
||||
- name: one-card
|
||||
engine: freetoken
|
||||
gpu_class: rtx-pro-6000-96g
|
||||
min_memory_gb: 200 # the experts live in RAM: say how much
|
||||
args: ["--max-running-requests", "8"]
|
||||
```
|
||||
|
||||
A Hugging Face id as the source works too — FreeToken downloads the
|
||||
repository itself — but it is not pinned to a commit or checked against
|
||||
Hugging Face's checksums as vLLM's are, FreeToken having no `--revision`; a
|
||||
directory the mirror fetched, or one put on the node, is.
|
||||
|
||||
The agent gives it the model, its address, its card (`--gpu`) and the key (in
|
||||
`FREETOKEN_API_KEY`, not on the command line); `--mmproj`, media paths and
|
||||
domains, `--hf-overrides` and `--moe-cache-split` are refused as they read
|
||||
files or fetch. `ctl model context` sets `--max-seq-len-override`. Readiness
|
||||
waits for its `/health` to stop saying `loading`, since it answers 200 from the
|
||||
moment its HTTP server is up. It serves no Prometheus metrics, so the router
|
||||
counts its requests itself.
|
||||
|
||||
llmgw follows FreeToken release by release: the version is
|
||||
`DefaultEngines["freetoken"]`, and a weekly workflow
|
||||
(`.gitea/workflows/engine-releases.yml`) fails when PyPI has a newer one, the
|
||||
notice to check it and move to it. A version of your own is named under
|
||||
`engines` like any other (`freetoken: "0.2.2"`), and `freetoken: ""` installs
|
||||
none; one node is turned off with its build set to *none* (console, the node's
|
||||
menu).
|
||||
|
||||
#### Choosing a node's vLLM build
|
||||
|
||||
Which build a GPU node installs is chosen on the cluster page, in the **vLLM
|
||||
@@ -897,6 +1054,24 @@ the node — an adapter given by its id, a TensorRT-LLM model's — is refused
|
||||
when the model is added, unless it says `allow_pickle`: mirror its
|
||||
safetensors and give that instead.
|
||||
|
||||
**Weights are pinned to a commit and checked.** A model added with
|
||||
`ctl model add`, the API or the console, whose weights come from Hugging Face,
|
||||
is pinned to the commit the repository is at when it is added:
|
||||
|
||||
- `hf:org/repo/file` and a `…/resolve/main/…` URL become that commit's URL;
|
||||
- a repository vLLM or SGLang downloads itself is given `--revision`;
|
||||
- the sha256 Hugging Face published for each file goes in the model's origin
|
||||
(its definition, `GET /api/v1/models/NAME`, shows it).
|
||||
|
||||
A node then refuses a copy of the file that is not it, naming both checksums,
|
||||
and deletes it; a cached copy is checked once and its checksum kept beside it.
|
||||
The mirror does the same for a model `ctl model serve` fetches, and
|
||||
`llmgw mirror add` for a Hugging Face URL (`--sha256` for any other). A new
|
||||
upload to the repository reaches the cluster only through `ctl model update`,
|
||||
which says what changed. Not pinned: models declared in the cluster's settings,
|
||||
which are not added this way, and a vLLM model whose arguments already say
|
||||
`--revision` keeps that one.
|
||||
|
||||
Put them where the node can read them. `scp`, a shared filesystem, or the
|
||||
engine's own downloader are all valid, and which you want depends on how many
|
||||
nodes you have.
|
||||
@@ -1629,25 +1804,93 @@ Not yet: models declared in server.yaml cannot name a peer's model as a
|
||||
fallback (the catalog in the file is checked before the peers are read), and
|
||||
a peer behind a private CA needs that CA in the system's trust store.
|
||||
|
||||
### Changing a served model's context
|
||||
|
||||
How much one request may use is set when a model is served. To change it
|
||||
afterwards:
|
||||
### Stopping a model, and removing one with its files
|
||||
|
||||
```
|
||||
llmgw ctl model context qwen-7b 32768 # tokens a request
|
||||
llmgw ctl model context qwen-7b 32768 --slots 4 # llama.cpp: four at once
|
||||
llmgw ctl model stop qwen-7b # none of its replicas run; it is kept
|
||||
llmgw ctl model start qwen-7b # runs again, scaled as it was
|
||||
llmgw ctl model remove qwen-7b # leaves the catalog; its files stay
|
||||
llmgw ctl model remove qwen-7b --purge # its files on the mirror and the nodes too
|
||||
```
|
||||
|
||||
The catalog, the engine's arguments (`--ctx-size` and `--parallel` for
|
||||
llama.cpp, `--max-model-len` for vLLM, `--context-length` for SGLang) and
|
||||
the memory its KV cache takes change together. A context longer than the
|
||||
- **Stop** keeps the model in the catalog with its files and what it was
|
||||
scaled to; a request for it is told an administrator stopped it. `ctl
|
||||
models` shows it as stopped. A `ctl scale` while it is stopped starts it at
|
||||
that number.
|
||||
- **Remove** leaves its files on the mirror and the nodes, for adding it
|
||||
again without fetching it. `ctl weights` lists such files on the nodes.
|
||||
- **Remove --purge** deletes every version of it on the mirror (a second
|
||||
mirror follows), its adapters and the weights its last update replaced,
|
||||
and the nodes' copies once its replicas have stopped. What another model
|
||||
uses — a canary's weights, a shared adapter — stays. It cannot be undone:
|
||||
adding it again fetches it again.
|
||||
|
||||
The console has the same in a model's menu: Stop… or Start, Remove…, and
|
||||
Remove with its files….
|
||||
|
||||
### Changing a served model's context and longest answer
|
||||
|
||||
How much one request may use, and how long an answer may be, are set when a
|
||||
model is served. To change them afterwards, in place:
|
||||
|
||||
```
|
||||
llmgw ctl model context qwen-7b 32768 # tokens a request
|
||||
llmgw ctl model context qwen-7b 32768 --slots 4 # llama.cpp: four at once
|
||||
llmgw ctl model context qwen-7b 32768 --max-output 8192 # and the longest answer
|
||||
llmgw ctl model context qwen-7b --max-output 4096 # the longest answer alone
|
||||
```
|
||||
|
||||
The longest answer is the gateway's: a request asking for more `max_tokens`
|
||||
is refused, `/v1/models` lists it as `max_output_tokens` for clients to read,
|
||||
and the account page's chat asks for no more. Changed alone it is only
|
||||
the catalog's, in force at once, and no replica is started again. With a
|
||||
context it is set with it; left out, it grows and shrinks with the context
|
||||
as before, unless it was set to something else. It never exceeds the context.
|
||||
|
||||
For a context, the catalog, the engine's arguments (`--ctx-size` and
|
||||
`--parallel` for llama.cpp, `--max-model-len` for vLLM, `--context-length` for
|
||||
SGLang, `--context` for Strata's setup, `--max-seq-len-override` for
|
||||
FreeToken) and the memory its KV cache takes change together. Strata and
|
||||
FreeToken size their caches to the card themselves, so their cards are not
|
||||
checked here. A context longer than the
|
||||
model was trained for is refused, and so is one its cards cannot hold — with
|
||||
the figures, so you know how far to go. Its replicas are then started again on
|
||||
it, one by one, a new one ready before the old one stops. The console has the
|
||||
same under a model's menu, Context length…. A model declared in the cluster's
|
||||
same under a model's menu, Context length…, with the longest answer beside
|
||||
it. The admin API is `POST /api/v1/models/{name}/context` with `tokens`,
|
||||
`slots` and `max_output`, any of them left out. A model declared in the cluster's
|
||||
settings is changed there (`llmgw ctl config edit`).
|
||||
|
||||
### Moving a served model to more cards
|
||||
|
||||
How many cards of one node each replica uses can be changed while the model
|
||||
is served:
|
||||
|
||||
```
|
||||
llmgw ctl model gpus qwen3-coder-30b 2 # two cards a replica
|
||||
llmgw ctl model gpus llama-70b 4 --profile h100 # the model has several profiles on cards
|
||||
llmgw ctl model gpus qwen3-coder-30b 2 --in-place # no room beside the running replica
|
||||
```
|
||||
|
||||
vLLM, SGLang and TensorRT-LLM spread the model by tensor parallel (`tp`),
|
||||
which needs the count to divide the model's KV heads or be divided by them:
|
||||
a model with 8 splits 2, 4, 8 or 16 ways, not 3 or 12. llama.cpp has no
|
||||
tensor parallel; it splits the layers over the cards (`gpus_per_node`), and
|
||||
a `--tensor-split` written for the old cards is dropped. ComfyUI and Strata
|
||||
run on one card. A count no node has is refused, and so is one whose cards
|
||||
would not hold the weights and the context — with the figures; fewer cards
|
||||
with a long context may need `llmgw ctl model context` first.
|
||||
|
||||
Its replicas are then started again on it, each new one ready beside the old
|
||||
before the old stops. That needs the cards free beside it: on a node of two
|
||||
cards with the model on one, two do not fit until it is gone, and the command
|
||||
says so. `--in-place` stops each replica first and starts its replacement on
|
||||
the cards it held and the free ones, and the model does not answer while the
|
||||
new one loads — a model with several replicas keeps answering on the others.
|
||||
A replica spread over several nodes is laid out by hand, with a fabric
|
||||
(`nodes`, `gpus_per_node` in the profile). The console has the same under a
|
||||
model's menu, GPUs….
|
||||
|
||||
### Trying new weights on some of the traffic first
|
||||
|
||||
When a model's repository publishes new weights, `llmgw ctl model update`
|
||||
@@ -1907,12 +2150,31 @@ dashboard, is in [deploy/monitoring/](../deploy/monitoring/) — including where
|
||||
to run it, which is not the control plane: monitoring that dies with the thing
|
||||
it monitors tells you nothing at the moment you need it.
|
||||
|
||||
Two figures repay knowing about. `llmgw_gpu_utilization_percent` is absent
|
||||
Three figures repay knowing about. `llmgw_gpu_utilization_percent` is absent
|
||||
rather than zero when the driver does not report it — a flat zero across a
|
||||
fleet means the measurement is missing, not that the fleet is idle. And
|
||||
`llmgw_gpu_nvlink_peers` at zero everywhere means the topology is unknown,
|
||||
which is what a binary built without the nvml tag reports.
|
||||
|
||||
The third is what "busy" means. `llmgw_gpu_utilization_percent` is
|
||||
nvidia-smi's `utilization.gpu`: the share of time *any* kernel ran, however
|
||||
little of the chip it ran on — a kernel on one SM of 142 reads 100%. A Quadro
|
||||
RTX 6000 decoding read 92% busy while its SMs had work 69% of the time and
|
||||
issued an instruction on 18% of their cycles. How much of the chip is at work
|
||||
is in `llmgw_gpu_sm_active_percent`, `llmgw_gpu_sm_occupancy_percent`,
|
||||
`llmgw_gpu_tensor_active_percent` and `llmgw_gpu_dram_active_percent`, from
|
||||
the card's profiling counters. The agent reads them from NVML's GPM on
|
||||
Hopper and later, with nothing to install; on older cards it reads them from
|
||||
a [dcgm-exporter](https://github.com/NVIDIA/dcgm-exporter) on the node, at
|
||||
`http://127.0.0.1:9400/metrics` unless `discovery.dcgm_exporter` in
|
||||
agent.yaml says otherwise (`off` reads none). Where neither is there, the
|
||||
dashboard's GPU map and the console colour cards by their draw against the
|
||||
power limit (`llmgw_gpu_power_watts / llmgw_gpu_power_limit_watts`), which
|
||||
rises with how much of the chip works rather than with whether anything
|
||||
does. Low tensor-core activity in decode is not waste: a quantised
|
||||
matrix-vector product at batch one runs on the integer units, and it is
|
||||
prefill and batches that use the tensor cores.
|
||||
|
||||
### Token by token, and SLOs
|
||||
|
||||
Beside time to the first token, every streamed answer is timed per token:
|
||||
@@ -2553,6 +2815,12 @@ is down, and new ones wait for it. With a second, the cluster carries on.
|
||||
```
|
||||
sudo llmgw mirror add-peer ubuntu@10.0.0.7 --control-plane 10.0.0.5 --control-plane 10.0.0.6 --control-plane 10.0.0.7
|
||||
```
|
||||
|
||||
It logs in with root's ssh key on the mirror's machine, so that key must
|
||||
be accepted for the user named on the second machine. When the mirror has
|
||||
a machine of its own, without the control plane, the command cannot change
|
||||
the cluster's settings itself: it prints the `llmgw ctl config set
|
||||
mirror.peers …` to run on a control plane.
|
||||
- **Kept in step:** each mirror reads the other every thirty seconds and
|
||||
copies what it lacks, keeping a copy only if its checksum matches.
|
||||
- A model removed from one is removed from the other, even if that one
|
||||
@@ -2614,9 +2882,16 @@ Caddy doing that for three control planes, and
|
||||
`server add` checks, and says what to add to the mirror's unit if it
|
||||
refuses the new control plane — which would otherwise find out the first
|
||||
time it leads and fetches a model.
|
||||
- **Limits are per process.** Rate limits are counted in memory, and clients
|
||||
reach the leader through the floating address, so a failover starts each
|
||||
key's minute afresh.
|
||||
- **Standbys can answer clients too.** With `router.active_active: true`
|
||||
(`llmgw ctl config set router.active_active true`) a standby answers `/v1`
|
||||
itself, routing to the leader's replicas, instead of passing it on: more
|
||||
machines carry the traffic. Point clients at every control plane — DNS,
|
||||
or your own load balancer checking `/ready` — since the floating address
|
||||
stays on the leader. Rate limits stay the cluster's: each router's counts
|
||||
reach the others every two seconds, and a standby that becomes the leader
|
||||
keeps the minute it was counting. Without the setting, rate limits are
|
||||
counted in the leader's memory and a failover starts each key's minute
|
||||
afresh.
|
||||
|
||||
[deploy/ha/](../deploy/ha/) describes what each half does, for anyone putting
|
||||
their own load balancer in front.
|
||||
@@ -2931,7 +3206,7 @@ loaded at boot by `llmgw-firewall.service`):
|
||||
| Machine | Ports, for the cluster only |
|
||||
| --- | --- |
|
||||
| control plane | 7443 (agents), metrics when not on loopback, 8081 (its mirror), engines and 9100 when it is a node, 2379/2380 when it holds the store's third etcd vote |
|
||||
| node | engine ports (`control.port_range`, 20000–20999), 9100 |
|
||||
| node | engine ports (`control.port_range`, 20000–20999), 9100; with a part in Slurm also 6817–6819 (slurmctld, slurmd, slurmdbd) and 60001–63000 (srun's `SrunPortRange`); exporting Slurm's shared files, 2049 (NFS) |
|
||||
| store member | 5432, 8008, 2379, 2380, 9100 |
|
||||
| mirror | 8081, 8082, 9100 |
|
||||
|
||||
|
||||
+16
-14
@@ -3,9 +3,9 @@
|
||||
A knowledge base is a collection of a company's documents that the cluster
|
||||
can answer from. People put their documents into it from their own machines
|
||||
with `llmgw-kb`. The tool reads the files on that machine, cuts them into
|
||||
passages ("chunks"), has the gateway embed each passage with the person's own
|
||||
key, and sends the text and the vectors. The files themselves never leave
|
||||
the machine.
|
||||
passages ("chunks") and sends their text; the gateway embeds each passage
|
||||
with the person's own key and keeps the text and the vectors. The files
|
||||
themselves never leave the machine.
|
||||
|
||||
## Why retrieval, and not training
|
||||
|
||||
@@ -159,10 +159,12 @@ llmgw ctl config set router.admission.bands '[{name: gold, priority: 300, satura
|
||||
llmgw keys create import --project Acme/docs --band bulk
|
||||
```
|
||||
|
||||
The people's questions then go first, and the import waits behind them. A
|
||||
request that waits as long as its band allows is answered 504 with
|
||||
`Retry-After`, and `llmgw-kb` sends it again rather than counting the
|
||||
passage as failed. It gives up after 15 minutes.
|
||||
The people's questions then go first, and the import waits behind them. The
|
||||
gateway embeds a document's passages 32 at a time, four batches at once,
|
||||
enough to fill an embedding replica's slots; a batch told to wait (`Retry-After`)
|
||||
waits up to five minutes before the document is answered the same, and
|
||||
`llmgw-kb` sends it again rather than counting it as failed. It gives up
|
||||
after 15 minutes.
|
||||
|
||||
## For everyone: `llmgw-kb`
|
||||
|
||||
@@ -225,6 +227,7 @@ not changed.
|
||||
| `--include`, `--exclude` | Glob patterns. `*.pdf` matches a file name; `reports/**/*.xlsx` matches a path. |
|
||||
| `--hidden` | Reads hidden files and folders too (left out by default). |
|
||||
| `-v` | Lists unchanged files too. |
|
||||
| `--embed-here` | Embeds the passages from this machine and sends the vectors. With a gateway that does not embed them itself it does so anyway. |
|
||||
|
||||
Documents are named by their path under the folder, with forward slashes,
|
||||
on every system. With several folders, each folder's own name comes first.
|
||||
@@ -259,13 +262,12 @@ scanned page with no OCR.
|
||||
|
||||
### What leaves your machine
|
||||
|
||||
For each new or changed document, two things leave the machine, both over
|
||||
the gateway's TLS connection:
|
||||
|
||||
- **The passages, to be embedded.** They go through `/v1/embeddings` like
|
||||
any request of yours. Your key's limits, budget and guardrails apply.
|
||||
- **The document itself.** Its name, its file's SHA-256 digest and size,
|
||||
and the passages with their vectors.
|
||||
For each new or changed document, one request leaves the machine, over the
|
||||
gateway's TLS connection: its name, its file's SHA-256 digest and size, and
|
||||
its passages' text. The gateway embeds the passages through `/v1/embeddings`
|
||||
as you, so your key's limits, budget and guardrails apply as if you had
|
||||
asked. That is about a kilobyte a passage; embedding here (`--embed-here`)
|
||||
brings each vector down and sends it up again, some thirty times more.
|
||||
|
||||
The file itself is never sent. `--dry-run` shows exactly what would be.
|
||||
|
||||
|
||||
Binary file not shown.
+32
-11
@@ -192,22 +192,41 @@ auth:
|
||||
A personal key has no project, so it keeps no conversations or agent notes;
|
||||
those are kept per project.
|
||||
|
||||
### Organization managers
|
||||
### Organization managers and admins
|
||||
|
||||
A member of an organization can be made its manager:
|
||||
A member of an organization can be made its manager, who sees it, or its
|
||||
admin, who also changes it:
|
||||
|
||||
```
|
||||
llmgw users join boss@acme.com --org Acme --role manager
|
||||
llmgw users join lead@acme.com --org Acme --role admin
|
||||
```
|
||||
|
||||
or with the star on their organization membership on `/admin/users`. A manager
|
||||
gets an **Organization** link and a page at `/org`: usage over time by team,
|
||||
or with the role chosen on their organization membership on `/admin/users`.
|
||||
Both get an **Organization** link and a page at `/org`: usage over time by team,
|
||||
project or person, the teams and projects with their limits and spend, and every
|
||||
member. Only what was spent in that organization's projects is on it; what its
|
||||
people do elsewhere is not. The page is read-only — a manager is not an
|
||||
administrator of the cluster, and limits and memberships stay with those who
|
||||
are. An administrator following `/org` lands on the same organization's panel
|
||||
on the people page instead, where all of that can be changed.
|
||||
people do elsewhere is not.
|
||||
|
||||
A manager's page is read-only. An organization's admin also changes the
|
||||
organization from it:
|
||||
|
||||
- adds and renames its teams and projects, and removes an empty one;
|
||||
- adds a person by their sign-in address to the organization, a team or a
|
||||
project, and takes them out (their keys there are revoked with it);
|
||||
- makes a member of the organization a manager or an admin, or neither;
|
||||
- revokes a key spent in its projects.
|
||||
|
||||
What reaches past the organization stays with the cluster's administrators:
|
||||
its limits and budgets, approving or suspending an account (someone not yet
|
||||
approved cannot be added), the organization's own name, and anything else on
|
||||
the cluster. An admin cannot take away their own role. A group from single
|
||||
sign-on or Active Directory that puts someone in the organization does not, by
|
||||
saying so again at the next sign-in, take a manager's or admin's role away.
|
||||
|
||||
An administrator of the cluster following `/org` lands on the same
|
||||
organization's panel on the people page instead, where all of that can be
|
||||
changed.
|
||||
|
||||
`llmgw keys init` still works for provisioning scripts and is idempotent, but it
|
||||
will no longer move a team or project that already exists under another parent;
|
||||
@@ -517,8 +536,10 @@ included: `llmgw ctl audit`, or **Audit** in the console.
|
||||
suspends accounts from it; see [install.md](install.md#single-sign-on).
|
||||
Without them accounts are local, with local passwords.
|
||||
- **Staff roles are coarse.** `viewer`, `operator`, `billing` and `admin`
|
||||
(`llmgw users role`) decide what someone may see and do under /admin; an
|
||||
organization's manager can view it but not change it. Every change is in
|
||||
the audit log (`llmgw ctl audit`).
|
||||
(`llmgw users role`) decide what someone may see and do under /admin, and a
|
||||
page draws only the forms that person may send: a viewer sees no forms. An
|
||||
organization's manager can view it; its admin also changes its teams,
|
||||
projects, people and keys. Every change is in the audit log (`llmgw ctl
|
||||
audit`).
|
||||
- **Rate limits are per gateway instance.** With two instances each counts its own,
|
||||
so the effective limit doubles. v1 is single-instance by design.
|
||||
@@ -126,6 +126,7 @@ rather than repeated.
|
||||
| `name` | `string` | [Federation](#federation) |
|
||||
| `interval` | `time.Duration` | [Federation](#federation) |
|
||||
| `enabled` | `bool` | [FirewallSettings](#firewallsettings) |
|
||||
| `binary` | `string` | [FreeTokenConfig](#freetokenconfig) |
|
||||
| `min_compute_capability` | `float64` | [GPUNeed](#gpuneed) |
|
||||
| `min_memory_gb` | `int` | [GPUNeed](#gpuneed) |
|
||||
| `format` | `string` | [GuardModel](#guardmodel) |
|
||||
@@ -192,6 +193,7 @@ rather than repeated.
|
||||
| `allow_pickle` | `bool` | [Model](#model) |
|
||||
| `profiles` | `[]Profile` | [Model](#model) |
|
||||
| `scaling` | `Scaling` | [Model](#model) |
|
||||
| `stopped` | `*Scaling` | [Model](#model) |
|
||||
| `class` | `string` | [Model](#model) |
|
||||
| `owner` | `string` | [Model](#model) |
|
||||
| `external` | `*External` | [Model](#model) |
|
||||
@@ -233,6 +235,7 @@ rather than repeated.
|
||||
| `ib_root` | `string` | [Options](#options) |
|
||||
| `prefer_nvml` | `bool` | [Options](#options) |
|
||||
| `skip_gpus` | `bool` | [Options](#options) |
|
||||
| `dcgm_exporter` | `string` | [Options](#options) |
|
||||
| `repo` | `string` | [Origin](#origin) |
|
||||
| `revision` | `string` | [Origin](#origin) |
|
||||
| `files` | `[]OriginFile` | [Origin](#origin) |
|
||||
@@ -292,6 +295,7 @@ rather than repeated.
|
||||
| `keep_within` | `string` | [Profile](#profile) |
|
||||
| `weight` | `float64` | [Profile](#profile) |
|
||||
| `priority` | `int` | [Profile](#profile) |
|
||||
| `replicas` | `int` | [Profile](#profile) |
|
||||
| `startup_timeout` | `time.Duration` | [Profile](#profile) |
|
||||
| `kv_events` | `bool` | [Profile](#profile) |
|
||||
| `gpu` | `*GPUNeed` | [Profile](#profile) |
|
||||
@@ -317,6 +321,7 @@ rather than repeated.
|
||||
| `batch` | `Batch` | [Router](#router) |
|
||||
| `cache` | `Cache` | [Router](#router) |
|
||||
| `trusted_proxies` | `[]string` | [Router](#router) |
|
||||
| `active_active` | `bool` | [Router](#router) |
|
||||
| `timeouts` | `Timeouts` | [Router](#router) |
|
||||
| `scoring` | `Scoring` | [Router](#router) |
|
||||
| `admission` | `Admission` | [Router](#router) |
|
||||
@@ -371,6 +376,7 @@ rather than repeated.
|
||||
| `mail` | `mail.Config` | [Server](#server) |
|
||||
| `serving` | `ServingSettings` | [Server](#server) |
|
||||
| `firewall` | `FirewallSettings` | [Server](#server) |
|
||||
| `slurm` | `SlurmSettings` | [Server](#server) |
|
||||
| `presets` | `PresetSettings` | [Server](#server) |
|
||||
| `on_demand` | `OnDemandSettings` | [Server](#server) |
|
||||
| `agents` | `AgentSettings` | [Server](#server) |
|
||||
@@ -395,7 +401,20 @@ rather than repeated.
|
||||
| `action` | `string` | [Service](#service) |
|
||||
| `timeout` | `time.Duration` | [Service](#service) |
|
||||
| `stopped` | `bool` | [ServingSettings](#servingsettings) |
|
||||
| `path` | `string` | [SharedStorage](#sharedstorage) |
|
||||
| `nfs` | `string` | [SharedStorage](#sharedstorage) |
|
||||
| `server` | `string` | [SharedStorage](#sharedstorage) |
|
||||
| `bin` | `string` | [SimConfig](#simconfig) |
|
||||
| `enabled` | `bool` | [SlurmSettings](#slurmsettings) |
|
||||
| `cluster_name` | `string` | [SlurmSettings](#slurmsettings) |
|
||||
| `controller` | `string` | [SlurmSettings](#slurmsettings) |
|
||||
| `nodes` | `[]string` | [SlurmSettings](#slurmsettings) |
|
||||
| `all_nodes` | `bool` | [SlurmSettings](#slurmsettings) |
|
||||
| `lent` | `[]string` | [SlurmSettings](#slurmsettings) |
|
||||
| `partition` | `string` | [SlurmSettings](#slurmsettings) |
|
||||
| `shared` | `SharedStorage` | [SlurmSettings](#slurmsettings) |
|
||||
| `users` | `string` | [SlurmSettings](#slurmsettings) |
|
||||
| `containers` | `bool` | [SlurmSettings](#slurmsettings) |
|
||||
| `dir` | `string` | [StrataConfig](#strataconfig) |
|
||||
| `python` | `string` | [StrataConfig](#strataconfig) |
|
||||
| `engine` | `string` | [StrataConfig](#strataconfig) |
|
||||
@@ -480,6 +499,7 @@ rather than repeated.
|
||||
| `sim` | `SimConfig` | [engines.Config](#enginesconfig) |
|
||||
| `comfyui` | `ComfyUIConfig` | [engines.Config](#enginesconfig) |
|
||||
| `strata` | `StrataConfig` | [engines.Config](#enginesconfig) |
|
||||
| `freetoken` | `FreeTokenConfig` | [engines.Config](#enginesconfig) |
|
||||
| `enabled` | `*bool` | [guard.Config](#guardconfig) |
|
||||
| `default` | `[]string` | [guard.Config](#guardconfig) |
|
||||
| `policies` | `map[string]Policy` | [guard.Config](#guardconfig) |
|
||||
@@ -1315,6 +1335,16 @@ site whose own firewall does that job; the rules are kept, and true
|
||||
puts them back. Changed in the cluster's settings, it is in force on
|
||||
every node and control plane within seconds.
|
||||
|
||||
## FreeTokenConfig
|
||||
|
||||
### `binary`
|
||||
|
||||
`string`
|
||||
|
||||
Binary is its ft command, in the environment it was installed into.
|
||||
The agent installs one itself on a node whose GPUs it runs on; this
|
||||
is for one installed by hand.
|
||||
|
||||
## GPUNeed
|
||||
|
||||
### `min_compute_capability`
|
||||
@@ -1824,6 +1854,16 @@ no safetensors or GGUF form and whose files were checked.
|
||||
|
||||
Takes a [Scaling](#scaling) block.
|
||||
|
||||
### `stopped`
|
||||
|
||||
`*Scaling`
|
||||
|
||||
Stopped is the scaling a model had when an administrator stopped it:
|
||||
kept in the catalog with none running (Scaling 0/0) until started
|
||||
again with this. Nil for a model not stopped.
|
||||
|
||||
Takes a [Scaling](#scaling) block.
|
||||
|
||||
### `class`
|
||||
|
||||
`string`
|
||||
@@ -2171,6 +2211,15 @@ is used, is the more exact source of the peers.
|
||||
SkipGPUs omits GPU discovery. A node without nvidia-smi is already
|
||||
found to have none, so this is for one whose driver is broken.
|
||||
|
||||
### `dcgm_exporter`
|
||||
|
||||
`string`
|
||||
|
||||
DCGMExporter is the dcgm-exporter metrics URL read for how much of
|
||||
each card's chip is in use, on cards NVML's GPM does not cover (before
|
||||
Hopper). Empty is http://127.0.0.1:9400/metrics; "off" reads none. A
|
||||
node without one costs a refused connection every five minutes.
|
||||
|
||||
## Origin
|
||||
|
||||
### `repo`
|
||||
@@ -2577,6 +2626,16 @@ rather than a guess (plan §6.2.1).
|
||||
|
||||
Priority orders profiles during placement; highest that fits wins.
|
||||
|
||||
### `replicas`
|
||||
|
||||
`int`
|
||||
|
||||
Replicas, set on any of a model's profiles, is how many of its
|
||||
replicas run on this one: seven on cards and four on CPUs, all
|
||||
answering under the model's name, rather than each replica on the
|
||||
best profile that fits. The model's scaling is then the sum, min and
|
||||
max alike; a profile left at zero runs none.
|
||||
|
||||
### `startup_timeout`
|
||||
|
||||
`time.Duration`
|
||||
@@ -2777,6 +2836,22 @@ as addresses or CIDR prefixes. Their X-Forwarded-For, -Proto and -Host
|
||||
are believed; anyone else's are not, since they decide the address
|
||||
written into the setup scripts and whose attempts are counted where.
|
||||
|
||||
### `active_active`
|
||||
|
||||
`bool`
|
||||
|
||||
ActiveActive has standby control planes answer the client API
|
||||
themselves, routing to the leader's replicas, rather than passing
|
||||
every request to the leader. More machines carry the traffic, which
|
||||
is the point. Rate limits are the cluster's: each router's counts
|
||||
reach the others through the leader every two seconds, so a key
|
||||
spread over N control planes may pass its limit by what they allow
|
||||
in those two seconds. A budget is checked against the store every
|
||||
half minute; cached answers and prefix affinity are split between
|
||||
the routers. Off by
|
||||
default. Which machine a client reaches is the load balancer's or
|
||||
DNS's to say; the floating address stays on the leader.
|
||||
|
||||
### `timeouts`
|
||||
|
||||
`Timeouts`
|
||||
@@ -3218,6 +3293,15 @@ Firewall turns llmgw's firewall on every machine on or off.
|
||||
|
||||
Takes a [FirewallSettings](#firewallsettings) block.
|
||||
|
||||
### `slurm`
|
||||
|
||||
`SlurmSettings`
|
||||
|
||||
Slurm is a Slurm cluster run beside llmgw on some of its nodes, and
|
||||
which of them it may use now.
|
||||
|
||||
Takes a [SlurmSettings](#slurmsettings) block.
|
||||
|
||||
### `presets`
|
||||
|
||||
`PresetSettings`
|
||||
@@ -3410,6 +3494,30 @@ running and every model's definition as it is: llmgw stop sets it,
|
||||
llmgw start clears it, and the models come back as their catalog
|
||||
entries ask. In force on every control plane within seconds.
|
||||
|
||||
## SharedStorage
|
||||
|
||||
### `path`
|
||||
|
||||
`string`
|
||||
|
||||
Path is where every machine sees it, as /shared.
|
||||
|
||||
### `nfs`
|
||||
|
||||
`string`
|
||||
|
||||
NFS is the site's export, host:/path, mounted at Path on every
|
||||
machine of the Slurm cluster.
|
||||
|
||||
### `server`
|
||||
|
||||
`string`
|
||||
|
||||
Server is the llmgw node, the controller or one of the nodes, that
|
||||
exports Path over NFS to the others: for a site with no storage of
|
||||
its own. One machine: not highly available, and no faster than its
|
||||
disk and network.
|
||||
|
||||
## SimConfig
|
||||
|
||||
### `bin`
|
||||
@@ -3419,6 +3527,88 @@ entries ask. In force on every control plane within seconds.
|
||||
Bin is the llmgw binary itself; the engine is one of its subcommands.
|
||||
Empty means whatever started this agent, which is right almost always.
|
||||
|
||||
## SlurmSettings
|
||||
|
||||
### `enabled`
|
||||
|
||||
`bool`
|
||||
|
||||
Enabled runs Slurm on the nodes below. False stops llmgw keeping it;
|
||||
what was installed is left installed, and every node is llmgw's.
|
||||
|
||||
### `cluster_name`
|
||||
|
||||
`string`
|
||||
|
||||
ClusterName is Slurm's ClusterName. Empty is "llmgw".
|
||||
|
||||
### `controller`
|
||||
|
||||
`string`
|
||||
|
||||
Controller is the llmgw node whose agent runs slurmctld. It needs no
|
||||
GPU, and need not be one of Nodes.
|
||||
|
||||
### `nodes`
|
||||
|
||||
`[]string`
|
||||
|
||||
Nodes are the llmgw nodes that run slurmd. Each stays llmgw's, drained
|
||||
in Slurm, until it is also in Lent.
|
||||
|
||||
### `all_nodes`
|
||||
|
||||
`bool`
|
||||
|
||||
AllNodes runs slurmd on every llmgw node, those that join later too,
|
||||
besides any named in Nodes. Each is llmgw's until it is lent, as any.
|
||||
|
||||
### `lent`
|
||||
|
||||
`[]string`
|
||||
|
||||
Lent are the nodes of Nodes Slurm may run jobs on now. A node put here
|
||||
is drained of llmgw's replicas first, as `ctl drain` does, and resumed
|
||||
in Slurm once none is left; a node taken out is drained in Slurm and
|
||||
given back to llmgw when its last job ends.
|
||||
|
||||
### `partition`
|
||||
|
||||
`string`
|
||||
|
||||
Partition is the one partition every node is in. Empty is "gpu".
|
||||
|
||||
### `shared`
|
||||
|
||||
`SharedStorage`
|
||||
|
||||
Shared is the file system every machine of the Slurm cluster sees at
|
||||
the same path: home directories, datasets, the jobs' output and
|
||||
checkpoints. Empty path: none, and a batch job's output stays on the
|
||||
node it ran on.
|
||||
|
||||
Takes a [SharedStorage](#sharedstorage) block.
|
||||
|
||||
### `users`
|
||||
|
||||
`string`
|
||||
|
||||
Users is who keeps the Linux users that run jobs, the same on every
|
||||
machine of the Slurm cluster: "llmgw" makes one for each active llmgw
|
||||
account (its login from the e-mail address, a UID of llmgw's from
|
||||
200000 up, its SSH keys from the portal), let in to the controller,
|
||||
and to a node only while a job of theirs runs there (pam_slurm_adopt);
|
||||
empty leaves users to the site (a directory through sssd, or by hand).
|
||||
|
||||
### `containers`
|
||||
|
||||
`bool`
|
||||
|
||||
Containers lets jobs run in containers: `srun --container-image
|
||||
nvcr.io#nvidia/pytorch:25.01-py3` (pyxis, with enroot under it),
|
||||
the GPUs and the shared path inside. The agents install both on
|
||||
every Slurm machine; jobs take it once every machine has them.
|
||||
|
||||
## StrataConfig
|
||||
|
||||
### `dir`
|
||||
@@ -4021,6 +4211,16 @@ offered by a node whose Strata is named here, or that installed it.
|
||||
|
||||
Takes a [StrataConfig](#strataconfig) block.
|
||||
|
||||
### `freetoken`
|
||||
|
||||
`FreeTokenConfig`
|
||||
|
||||
FreeToken serves mixture-of-experts models larger than a card from
|
||||
RAM and one card of 8.0 or newer; installed by the agent where the
|
||||
cards allow it, or named here.
|
||||
|
||||
Takes a [FreeTokenConfig](#freetokenconfig) block.
|
||||
|
||||
## guard.Config
|
||||
|
||||
### `enabled`
|
||||
|
||||
@@ -78,10 +78,41 @@ func (a *Accountant) RestoreBudgets(ctx context.Context, s store.Store, period s
|
||||
if start.IsZero() {
|
||||
return nil
|
||||
}
|
||||
rows, err := s.SpendSinceByScope(ctx, start)
|
||||
spend, keys, err := storedSpend(ctx, s, start)
|
||||
if err != nil {
|
||||
return fmt.Errorf("restoring budgets: %w", err)
|
||||
}
|
||||
a.budgets.Restore(period, spend)
|
||||
a.log.Info("restored budgets from the store",
|
||||
"keys", keys, "counters", len(spend), "since", start)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CatchUpBudgets raises each counter to what the store has recorded, where
|
||||
// that is more. Run every so often by a router that is one of several taking
|
||||
// requests: the others' spend reaches it only through the store. Never
|
||||
// lowers a counter, which leads the store by whatever this process has not
|
||||
// written yet.
|
||||
func (a *Accountant) CatchUpBudgets(ctx context.Context, s store.Store, period store.Period) error {
|
||||
start := PeriodStart(period, time.Now())
|
||||
if start.IsZero() {
|
||||
return nil
|
||||
}
|
||||
spend, _, err := storedSpend(ctx, s, start)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading budgets: %w", err)
|
||||
}
|
||||
a.budgets.Raise(period, spend)
|
||||
return nil
|
||||
}
|
||||
|
||||
// storedSpend is the spend since start the store has recorded, by counter,
|
||||
// and how many keys it came from.
|
||||
func storedSpend(ctx context.Context, s store.Store, start time.Time) (map[string]int64, int, error) {
|
||||
rows, err := s.SpendSinceByScope(ctx, start)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
// Every scope a key sits under, not just the key. A budget owned by a team
|
||||
// is one counter fed by every key beneath it, and restoring only per-key
|
||||
// totals would bring the control plane back up believing the team had spent
|
||||
@@ -113,10 +144,7 @@ func (a *Accountant) RestoreBudgets(ctx context.Context, s store.Store, period s
|
||||
}
|
||||
}
|
||||
}
|
||||
a.budgets.Restore(period, spend)
|
||||
a.log.Info("restored budgets from the store",
|
||||
"keys", len(rows), "counters", len(spend), "since", start)
|
||||
return nil
|
||||
return spend, len(rows), nil
|
||||
}
|
||||
|
||||
// Reservation is what Check returns: the estimate it charged, so Record can
|
||||
|
||||
@@ -216,6 +216,22 @@ func TestBudgetSurvivesARestart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatchingUpRaisesButNeverLowers(t *testing.T) {
|
||||
// Another router's spend arrives through the store; this one's own, not
|
||||
// written yet, must not be wiped by a store that is behind.
|
||||
b := NewBudgets()
|
||||
b.Add("mine", store.PeriodMonth, 900)
|
||||
b.Add("shared", store.PeriodMonth, 100)
|
||||
|
||||
b.Raise(store.PeriodMonth, map[string]int64{"mine": 400, "shared": 700, "theirs": 50})
|
||||
|
||||
for key, want := range map[string]int64{"mine": 900, "shared": 700, "theirs": 50} {
|
||||
if got := b.Spent(key, store.PeriodMonth); got != want {
|
||||
t.Errorf("%s: spent %d, want %d", key, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBudgetsResetAtThePeriodBoundary(t *testing.T) {
|
||||
b := NewBudgets()
|
||||
now := time.Date(2026, 1, 31, 23, 59, 0, 0, time.UTC)
|
||||
|
||||
@@ -61,6 +61,19 @@ func (b *Budgets) Restore(period store.Period, spend map[string]int64) {
|
||||
}
|
||||
}
|
||||
|
||||
// Raise lifts counters to the spend given, where it is more.
|
||||
func (b *Budgets) Raise(period store.Period, spend map[string]int64) {
|
||||
start := PeriodStart(period, b.now())
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for key, micros := range spend {
|
||||
if c := b.currentLocked(key, start); micros > c.micros {
|
||||
c.micros = micros
|
||||
b.spend[key] = c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spent returns a key's spend in the current period, resetting it if the period
|
||||
// has rolled over.
|
||||
func (b *Budgets) Spent(keyID string, period store.Period) int64 {
|
||||
|
||||
@@ -25,7 +25,11 @@ type Limiter struct {
|
||||
type window struct {
|
||||
// current and previous are counts in this minute and the one before.
|
||||
current, previous int64
|
||||
start time.Time
|
||||
// othersCurrent and othersPrevious are the same counts on the other
|
||||
// control planes taking requests, as last heard: what makes a limit the
|
||||
// cluster's rather than each process's.
|
||||
othersCurrent, othersPrevious int64
|
||||
start time.Time
|
||||
}
|
||||
|
||||
// NewLimiter builds an empty limiter.
|
||||
@@ -104,21 +108,24 @@ func (w *window) roll(now time.Time) {
|
||||
return
|
||||
case start.Sub(w.start) == windowLen:
|
||||
w.previous, w.current = w.current, 0
|
||||
w.othersPrevious, w.othersCurrent = w.othersCurrent, 0
|
||||
default:
|
||||
// More than one window has passed with no traffic.
|
||||
w.previous, w.current = 0, 0
|
||||
w.othersPrevious, w.othersCurrent = 0, 0
|
||||
}
|
||||
w.start = start
|
||||
}
|
||||
|
||||
// rate weights the previous window by how much of it is still in view.
|
||||
func (w *window) rate(now time.Time) float64 {
|
||||
cur, prev := w.current+w.othersCurrent, w.previous+w.othersPrevious
|
||||
elapsed := now.Sub(w.start)
|
||||
if elapsed >= windowLen {
|
||||
return float64(w.current)
|
||||
return float64(cur)
|
||||
}
|
||||
remaining := 1 - float64(elapsed)/float64(windowLen)
|
||||
return float64(w.previous)*remaining + float64(w.current)
|
||||
return float64(prev)*remaining + float64(cur)
|
||||
}
|
||||
|
||||
// Forget drops a key's window. Used when a key is revoked; keeping it would
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package accounting
|
||||
|
||||
import "time"
|
||||
|
||||
// Counts are one limiter's counts by counter: this minute's and the one
|
||||
// before, as absolute figures, so reporting them twice changes nothing.
|
||||
type Counts struct {
|
||||
Minute time.Time `json:"minute"`
|
||||
Current map[string]int64 `json:"current,omitempty"`
|
||||
Previous map[string]int64 `json:"previous,omitempty"`
|
||||
}
|
||||
|
||||
// Add sums other into c, aligned to c's minute: counts of a minute that is
|
||||
// c's previous one land there, older ones are dropped. Clocks a second apart
|
||||
// at a minute's turn are why.
|
||||
func (c *Counts) Add(other Counts) {
|
||||
switch {
|
||||
case other.Minute.Equal(c.Minute):
|
||||
c.Current = addInto(c.Current, other.Current)
|
||||
c.Previous = addInto(c.Previous, other.Previous)
|
||||
case other.Minute.Add(windowLen).Equal(c.Minute):
|
||||
c.Previous = addInto(c.Previous, other.Current)
|
||||
}
|
||||
}
|
||||
|
||||
func addInto(dst, src map[string]int64) map[string]int64 {
|
||||
if len(src) == 0 {
|
||||
return dst
|
||||
}
|
||||
if dst == nil {
|
||||
dst = make(map[string]int64, len(src))
|
||||
}
|
||||
for k, v := range src {
|
||||
dst[k] += v
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// Counts reads this limiter's own counts, without the others'.
|
||||
func (l *Limiter) Counts() Counts {
|
||||
now := l.now()
|
||||
out := Counts{Minute: now.Truncate(windowLen), Current: map[string]int64{}, Previous: map[string]int64{}}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
for k, w := range l.keys {
|
||||
w.roll(now)
|
||||
if w.current > 0 {
|
||||
out.Current[k] = w.current
|
||||
}
|
||||
if w.previous > 0 {
|
||||
out.Previous[k] = w.previous
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetOthers replaces what the other control planes have counted. A counter
|
||||
// they did not mention has nothing from them.
|
||||
func (l *Limiter) SetOthers(c Counts) {
|
||||
now := l.now()
|
||||
aligned := Counts{Minute: now.Truncate(windowLen)}
|
||||
aligned.Add(c)
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
for k, w := range l.keys {
|
||||
w.roll(now)
|
||||
w.othersCurrent, w.othersPrevious = aligned.Current[k], aligned.Previous[k]
|
||||
delete(aligned.Current, k)
|
||||
delete(aligned.Previous, k)
|
||||
}
|
||||
// Counters only others have used yet: a key spread over the cluster is
|
||||
// limited here from its first request.
|
||||
for k := range aligned.Current {
|
||||
w := l.windowLocked(k, now)
|
||||
w.othersCurrent, w.othersPrevious = aligned.Current[k], aligned.Previous[k]
|
||||
delete(aligned.Previous, k)
|
||||
}
|
||||
for k, v := range aligned.Previous {
|
||||
l.windowLocked(k, now).othersPrevious = v
|
||||
}
|
||||
}
|
||||
|
||||
// Rates are an accountant's two limiters' counts.
|
||||
type Rates struct {
|
||||
Requests Counts `json:"requests"`
|
||||
Tokens Counts `json:"tokens"`
|
||||
}
|
||||
|
||||
// Add sums other into r.
|
||||
func (r *Rates) Add(other Rates) {
|
||||
r.Requests.Add(other.Requests)
|
||||
r.Tokens.Add(other.Tokens)
|
||||
}
|
||||
|
||||
// NewRates is an empty sum for the minute now is in.
|
||||
func NewRates(now time.Time) Rates {
|
||||
m := now.Truncate(windowLen)
|
||||
return Rates{Requests: Counts{Minute: m}, Tokens: Counts{Minute: m}}
|
||||
}
|
||||
|
||||
// Rates reads this process's own request and token counts.
|
||||
func (a *Accountant) Rates() Rates {
|
||||
return Rates{Requests: a.requests.Counts(), Tokens: a.tokens.Counts()}
|
||||
}
|
||||
|
||||
// SetOtherRates puts the other control planes' counts in force here.
|
||||
func (a *Accountant) SetOtherRates(r Rates) {
|
||||
a.requests.SetOthers(r.Requests)
|
||||
a.tokens.SetOthers(r.Tokens)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package accounting
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Two control planes each allowing a key six of its ten a minute: once each
|
||||
// has heard the other's count, neither allows another.
|
||||
func TestALimitIsTheClustersOnceTheCountsAreShared(t *testing.T) {
|
||||
now := time.Date(2026, 11, 1, 12, 0, 10, 0, time.UTC)
|
||||
a, b := NewLimiter(), NewLimiter()
|
||||
a.now, b.now = func() time.Time { return now }, func() time.Time { return now }
|
||||
for range 6 {
|
||||
if !a.Allow("k", 10, 1) || !b.Allow("k", 10, 1) {
|
||||
t.Fatal("refused within the limit")
|
||||
}
|
||||
}
|
||||
a.SetOthers(b.Counts())
|
||||
b.SetOthers(a.Counts())
|
||||
if a.Allow("k", 10, 1) || b.Allow("k", 10, 1) {
|
||||
t.Error("twelve of ten allowed once both knew")
|
||||
}
|
||||
// Reporting the same counts again changes nothing: they are absolute.
|
||||
a.SetOthers(b.Counts())
|
||||
if got := a.Rate("k"); got != 12 {
|
||||
t.Errorf("rate %v after the same counts twice", got)
|
||||
}
|
||||
// What the others counted is never charged back to them.
|
||||
if c := a.Counts(); c.Current["k"] != 6 {
|
||||
t.Errorf("own count %d", c.Current["k"])
|
||||
}
|
||||
}
|
||||
|
||||
// A key only the others have used is limited here from its first request,
|
||||
// and their counts move into the previous minute with the clock.
|
||||
func TestOthersCountsFollowTheMinute(t *testing.T) {
|
||||
now := time.Date(2026, 11, 1, 12, 0, 50, 0, time.UTC)
|
||||
a, b := NewLimiter(), NewLimiter()
|
||||
a.now, b.now = func() time.Time { return now }, func() time.Time { return now }
|
||||
for range 10 {
|
||||
b.Allow("k", 10, 1)
|
||||
}
|
||||
a.SetOthers(b.Counts())
|
||||
if a.Allow("k", 10, 1) {
|
||||
t.Error("a key the cluster has used up was allowed where it had not been seen")
|
||||
}
|
||||
now = now.Add(20 * time.Second) // 12:01:10, a sixth into the next minute
|
||||
if got := a.Rate("k"); got < 8 || got > 8.5 {
|
||||
t.Errorf("rate %v: the others' last minute should weigh five sixths", got)
|
||||
}
|
||||
// Counts of a minute ago, sent late, land in the previous minute.
|
||||
old := b.Counts()
|
||||
old.Minute = now.Truncate(time.Minute).Add(-time.Minute)
|
||||
old.Current, old.Previous = map[string]int64{"k": 3}, nil
|
||||
a.SetOthers(old)
|
||||
if got := a.Rate("k"); got < 2 || got > 3 {
|
||||
t.Errorf("rate %v from late counts", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRatesAddAlignsMinutes(t *testing.T) {
|
||||
m := time.Date(2026, 11, 1, 12, 1, 0, 0, time.UTC)
|
||||
sum := NewRates(m)
|
||||
sum.Add(Rates{Requests: Counts{Minute: m, Current: map[string]int64{"k": 2}, Previous: map[string]int64{"k": 1}}})
|
||||
sum.Add(Rates{Requests: Counts{Minute: m.Add(-time.Minute), Current: map[string]int64{"k": 5}}})
|
||||
sum.Add(Rates{Requests: Counts{Minute: m.Add(-2 * time.Minute), Current: map[string]int64{"k": 100}}})
|
||||
if sum.Requests.Current["k"] != 2 || sum.Requests.Previous["k"] != 6 {
|
||||
t.Errorf("sum %+v", sum.Requests)
|
||||
}
|
||||
}
|
||||
+86
-21
@@ -35,6 +35,7 @@ import (
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/slurmnode"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/train"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/weights"
|
||||
@@ -47,7 +48,9 @@ import (
|
||||
|
||||
// Agent runs on a GPU node.
|
||||
type Agent struct {
|
||||
cfg Config
|
||||
// upgrading is true while an upgrade downloads.
|
||||
upgrading atomic.Bool
|
||||
cfg Config
|
||||
// answered says the current session heard from its control plane, which
|
||||
// starts the reconnect backoff again when it ends.
|
||||
answered atomic.Bool
|
||||
@@ -95,7 +98,10 @@ type Agent struct {
|
||||
// (nil: this machine's shell).
|
||||
fwMu sync.Mutex
|
||||
fwRunner firewall.Runner
|
||||
inv discovery.Inventory
|
||||
// fwLast is the last firewall message, applied again when the node
|
||||
// is given a part in Slurm. Under fwMu.
|
||||
fwLast *llmgwv1.Firewall
|
||||
inv discovery.Inventory
|
||||
// shardReplica remembers which replica a shard belongs to, so a status
|
||||
// report can name both. The control plane keys on the replica.
|
||||
shardReplica map[string]string
|
||||
@@ -146,6 +152,10 @@ type Agent struct {
|
||||
|
||||
// trainer runs LoRA training jobs, one at a time (BACKLOG F4).
|
||||
trainer *train.Runner
|
||||
// slurm keeps the node's part in Slurm (BACKLOG P); slurmOut is where
|
||||
// its status goes, under mu.
|
||||
slurm *slurmnode.Keeper
|
||||
slurmOut chan<- *llmgwv1.AgentMessage
|
||||
}
|
||||
|
||||
// connectHTTPClient is the HTTP client connect uses; an interface so tests can
|
||||
@@ -185,7 +195,7 @@ func New(cfg Config, httpClient connectHTTPClient, log *slog.Logger) (*Agent, er
|
||||
logRing: ring,
|
||||
provKick: make(chan struct{}, 1),
|
||||
load: discovery.NewLoadSampler(),
|
||||
sensors: discovery.NewSensorSampler(),
|
||||
sensors: discovery.NewSensorSampler().WithProfile(discovery.NewProfSampler(cfg.Discovery.DCGMExporter)),
|
||||
wear: discovery.NewHealthSampler(),
|
||||
serverList: loadControlPlanes(cfg.Servers, cfg.TLS.Enabled()),
|
||||
kv: kvevents.NewMirror(),
|
||||
@@ -204,6 +214,7 @@ func New(cfg Config, httpClient connectHTTPClient, log *slog.Logger) (*Agent, er
|
||||
}
|
||||
a.trainer = train.New(train.Env{EnginesDir: enginesDir, WorkDir: filepath.Join(cfg.Superv.WorkDir, "trainer"),
|
||||
RunAs: runAs, Log: log})
|
||||
a.slurm = slurmnode.New(slurmnode.Local{}, a.prov, slurmMarker, log)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -230,6 +241,7 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
}
|
||||
go a.provisionLoop(ctx)
|
||||
go a.ensureDiags(ctx)
|
||||
go a.slurm.Run(ctx, a.sendSlurm)
|
||||
|
||||
attempt := 0
|
||||
next := 0
|
||||
@@ -517,7 +529,8 @@ func (a *Agent) scan(ctx context.Context) error {
|
||||
inv, err := discovery.Discover(ctx, opts, a.log)
|
||||
host := discovery.DiscoverHost()
|
||||
host.MinComputeCapability = discovery.MinComputeCapability(inv.GPUs)
|
||||
host.MemBandwidthGBs = a.memBandwidth()
|
||||
bw := a.memBandwidth()
|
||||
host.MemBandwidthGBs, host.MemBandwidthCoreGBs = bw.All, bw.Core
|
||||
a.mu.Lock()
|
||||
a.inv = inv
|
||||
a.prov.Host = host
|
||||
@@ -527,6 +540,18 @@ func (a *Agent) scan(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// forgetSent is a new stream: a control plane that knows nothing of where
|
||||
// installs stand, nor which files are here beyond what registration says —
|
||||
// and registration says what the startup scan found. So the next heartbeat
|
||||
// tells it both again. Without it, files deleted after the agent started came
|
||||
// back on a control plane that restarted, and stayed until the next change.
|
||||
func (a *Agent) forgetSent() {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.provSent = ""
|
||||
a.lastWeights = ""
|
||||
}
|
||||
|
||||
// session runs one connection until it breaks.
|
||||
func (a *Agent) session(ctx context.Context, server string) error {
|
||||
client := llmgwv1connect.NewAgentServiceClient(a.http, server)
|
||||
@@ -563,20 +588,19 @@ func (a *Agent) session(ctx context.Context, server string) error {
|
||||
reg.EngineVersions = a.engines(parent)
|
||||
reg.EnginesProbed = true
|
||||
reg.InstallableEngines = slices.DeleteFunc(provision.Installable(), a.disabled)
|
||||
reg.Features = []string{FeatureFabricTest}
|
||||
reg.Features = []string{FeatureFabricTest, FeatureSlurm}
|
||||
reg.RunningShards = a.runningShards()
|
||||
a.mu.Lock()
|
||||
h := a.prov.Host
|
||||
// A new stream is a control plane that knows nothing of where installs
|
||||
// stand, so the next heartbeat tells it again.
|
||||
a.provSent = ""
|
||||
a.mu.Unlock()
|
||||
a.forgetSent()
|
||||
reg.Host = &llmgwv1.Host{
|
||||
OsId: h.OSID, OsVersion: h.OSVersion, OsCodename: h.OSCodename,
|
||||
Arch: h.Arch, CpuFlags: h.CPUFlags, NvidiaDriver: h.NVIDIADriver,
|
||||
// What is measured by now, not what the startup scan found before
|
||||
// the measurement ended: a reconnect would otherwise say 0.
|
||||
MemBandwidthGbs: a.memBandwidth(),
|
||||
MemBandwidthGbs: a.memBandwidth().All,
|
||||
MemBandwidthCoreGbs: a.memBandwidth().Core,
|
||||
}
|
||||
|
||||
if err := stream.Send(&llmgwv1.AgentMessage{
|
||||
@@ -614,6 +638,8 @@ func (a *Agent) session(ctx context.Context, server string) error {
|
||||
go a.watchExits(ctx, out, unready(reg.RunningShards))
|
||||
go a.forwardKV(ctx, out)
|
||||
go a.reportCompute(ctx, out)
|
||||
a.setSlurmOut(out)
|
||||
defer a.setSlurmOut(nil)
|
||||
|
||||
for {
|
||||
msg, err := stream.Receive()
|
||||
@@ -674,7 +700,8 @@ func (a *Agent) heartbeat() *llmgwv1.AgentMessage {
|
||||
// without anybody restarting anything.
|
||||
hb.MemoryTotalMb = int64(discovery.MemoryTotalMB())
|
||||
hb.CpuCount = int32(runtime.NumCPU())
|
||||
hb.MemBandwidthGbs = a.memBandwidth()
|
||||
bw := a.memBandwidth()
|
||||
hb.MemBandwidthGbs, hb.MemBandwidthCoreGbs = bw.All, bw.Core
|
||||
|
||||
a.reprobeEngines()
|
||||
a.mu.Lock()
|
||||
@@ -796,6 +823,11 @@ func (a *Agent) sampleLoad() *llmgwv1.NodeLoad {
|
||||
MemoryUsedMb: g.MemoryUsedMB, MemoryTotalMb: g.MemoryTotalMB,
|
||||
SmClockMhz: g.SMClockMHz, EccUncorrected: g.ECCUncorrected,
|
||||
}
|
||||
if p := g.Profile; p != nil {
|
||||
gs.HasProfile, gs.ProfileSource = true, p.Source
|
||||
gs.SmActivePercent, gs.SmOccupancyPercent = p.SMActive, p.SMOccupancy
|
||||
gs.TensorActivePercent, gs.DramActivePercent = p.TensorActive, p.DRAMActive
|
||||
}
|
||||
if w, ok := wear[g.Index]; ok {
|
||||
gs.HasWear = true
|
||||
gs.PcieReplays, gs.RetiredPagesSbe, gs.RetiredPagesDbe, gs.RetirementPending =
|
||||
@@ -874,10 +906,27 @@ func (a *Agent) removeWeights(paths []string) {
|
||||
a.log.Warn("refused to remove a file llmgw did not fetch", "path", p)
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
|
||||
// A set — every file of one version, fetched into a directory of its
|
||||
// own — goes whole: os.Remove refused it as not empty, and nothing
|
||||
// was freed of what a model of several files left. Lstat, so a link
|
||||
// is taken away and never what it points at.
|
||||
info, err := os.Lstat(abs)
|
||||
switch {
|
||||
case os.IsNotExist(err):
|
||||
continue
|
||||
case err != nil:
|
||||
a.log.Warn("could not look at cached weights", "path", p, "err", err)
|
||||
continue
|
||||
case info.IsDir():
|
||||
err = os.RemoveAll(abs)
|
||||
default:
|
||||
err = os.Remove(abs)
|
||||
}
|
||||
if err != nil {
|
||||
a.log.Warn("could not remove cached weights", "path", p, "err", err)
|
||||
continue
|
||||
}
|
||||
_ = os.Remove(weights.ChecksumPath(abs))
|
||||
a.log.Info("removed cached weights no model asks for", "path", p)
|
||||
}
|
||||
}
|
||||
@@ -914,6 +963,8 @@ func (a *Agent) handle(ctx context.Context, msg *llmgwv1.ControlMessage, out cha
|
||||
go a.applySettings(m.AgentSettings)
|
||||
case *llmgwv1.ControlMessage_TrainStart:
|
||||
go a.trainJob(ctx, m.TrainStart, out)
|
||||
case *llmgwv1.ControlMessage_Slurm:
|
||||
a.slurmMessage(m.Slurm)
|
||||
case *llmgwv1.ControlMessage_TrainCancel:
|
||||
a.trainer.Cancel(m.TrainCancel.GetJobId())
|
||||
case *llmgwv1.ControlMessage_RestartAgent:
|
||||
@@ -1129,11 +1180,11 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<
|
||||
send(&llmgwv1.ShardStatus{
|
||||
State: agentsvc.StateToWire(supervisor.Starting),
|
||||
Fetch: &llmgwv1.FetchProgress{
|
||||
Source: p.Source, BytesDone: p.BytesDone, BytesTotal: p.BytesTotal,
|
||||
Source: p.Source, BytesDone: p.BytesDone, BytesTotal: p.BytesTotal, Phase: p.Phase,
|
||||
},
|
||||
})
|
||||
})
|
||||
if path, err := a.weights().Ensure(ctx, spec.Profile.ModelPath, spec.Profile.AllowPickle); err != nil {
|
||||
if path, err := a.weights().EnsureChecked(ctx, spec.Profile.ModelPath, spec.Profile.AllowPickle, spec.Profile.ModelSHA256); err != nil {
|
||||
stop()
|
||||
report(supervisor.Failed, err.Error(), nil)
|
||||
return
|
||||
@@ -1152,7 +1203,8 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<
|
||||
for i, m := range ms {
|
||||
stop := a.watchFetch(m["source"], func(p weights.Progress) {
|
||||
send(&llmgwv1.ShardStatus{State: agentsvc.StateToWire(supervisor.Starting),
|
||||
Fetch: &llmgwv1.FetchProgress{Source: p.Source, BytesDone: p.BytesDone, BytesTotal: p.BytesTotal}})
|
||||
Fetch: &llmgwv1.FetchProgress{Source: p.Source, BytesDone: p.BytesDone, BytesTotal: p.BytesTotal,
|
||||
Phase: p.Phase}})
|
||||
})
|
||||
path, err := a.weights().Ensure(ctx, m["source"], m["allow_pickle"] == "true")
|
||||
stop()
|
||||
@@ -1564,20 +1616,32 @@ func (a *Agent) reportCompute(ctx context.Context, out chan<- *llmgwv1.AgentMess
|
||||
// VMs on one host that all restart together measure each other, and read a
|
||||
// third less than any of them gets alone. Once a process rather than an
|
||||
// agent, so tests running many agents in one read it once.
|
||||
func (a *Agent) memBandwidth() float64 {
|
||||
func (a *Agent) memBandwidth() discovery.Bandwidth {
|
||||
bwOnce.Do(func() {
|
||||
go func() {
|
||||
time.Sleep(bandwidthDelay())
|
||||
for {
|
||||
if bw := measureBandwidth(); bw > math.Float64frombits(bwBits.Load()) {
|
||||
bwBits.Store(math.Float64bits(bw))
|
||||
a.log.Info("this node's memory", "read_gbs", math.Round(bw*10)/10)
|
||||
// The best of each seen so far: an hourly pass on a node
|
||||
// busy serving reads less, and that is not the memory.
|
||||
bw := measureBandwidth()
|
||||
cur := a.memBandwidthNow()
|
||||
if bw.All > cur.All || bw.Core > cur.Core {
|
||||
bwBits.Store(math.Float64bits(max(bw.All, cur.All)))
|
||||
coreBits.Store(math.Float64bits(max(bw.Core, cur.Core)))
|
||||
a.log.Info("this node's memory", "read_gbs", math.Round(bw.All*10)/10,
|
||||
"one_core_gbs", math.Round(bw.Core*10)/10)
|
||||
}
|
||||
time.Sleep(time.Hour)
|
||||
}
|
||||
}()
|
||||
})
|
||||
return math.Float64frombits(bwBits.Load())
|
||||
return a.memBandwidthNow()
|
||||
}
|
||||
|
||||
// memBandwidthNow is what has been measured so far, without starting a
|
||||
// measurement.
|
||||
func (a *Agent) memBandwidthNow() discovery.Bandwidth {
|
||||
return discovery.Bandwidth{All: math.Float64frombits(bwBits.Load()), Core: math.Float64frombits(coreBits.Load())}
|
||||
}
|
||||
|
||||
// bandwidthDelay is how long an agent waits before measuring, so agents
|
||||
@@ -1585,8 +1649,9 @@ func (a *Agent) memBandwidth() float64 {
|
||||
var bandwidthDelay = func() time.Duration { return 10*time.Second + rand.N(110*time.Second) }
|
||||
|
||||
var (
|
||||
bwOnce sync.Once
|
||||
bwBits atomic.Uint64
|
||||
bwOnce sync.Once
|
||||
bwBits atomic.Uint64
|
||||
coreBits atomic.Uint64
|
||||
// measureBandwidth is replaced in tests, which have no use for it.
|
||||
measureBandwidth = discovery.MeasureMemoryBandwidth
|
||||
)
|
||||
@@ -68,7 +68,7 @@ func (r *statusRecorder) OnShardCompute(replicaID, shardID string, c reconciler.
|
||||
r.computes = append(r.computes, c)
|
||||
}
|
||||
|
||||
func (r *statusRecorder) OnFetchProgress(replicaID, shardID, source string, done, total int64) {
|
||||
func (r *statusRecorder) OnFetchProgress(replicaID, shardID, source, phase string, done, total int64) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.fetches = append(r.fetches, fetchReport{replicaID, shardID, source, done, total})
|
||||
@@ -925,3 +925,23 @@ func TestABandwidthTestIsRunOnTheAdapterAskedFor(t *testing.T) {
|
||||
t.Errorf("reply %+v, ran %+v", r, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewStreamIsToldTheFilesAgain(t *testing.T) {
|
||||
// Registration carries what the startup scan found; a file deleted since
|
||||
// came back on a control plane that restarted, because the next
|
||||
// heartbeat saw the list unchanged since it was last sent.
|
||||
dir := t.TempDir()
|
||||
h := start(t, func(c *Config) { c.ModelDirs = []string{dir} })
|
||||
if err := os.WriteFile(filepath.Join(dir, "0123456789abcdef-m.gguf"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.ag.heartbeat()
|
||||
if hb := h.ag.heartbeat().GetHeartbeat(); hb.GetWeightsChanged() {
|
||||
t.Fatal("an unchanged list should not be sent again on the same stream")
|
||||
}
|
||||
h.ag.forgetSent()
|
||||
hb := h.ag.heartbeat().GetHeartbeat()
|
||||
if !hb.GetWeightsChanged() || len(hb.GetWeights()) != 1 {
|
||||
t.Errorf("a new stream should be told the files: changed=%v %v", hb.GetWeightsChanged(), hb.GetWeights())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CUDAToolkit is the root of an installed CUDA toolkit of this major version
|
||||
// — the directory whose bin holds nvcc — or "" when there is none. Engines
|
||||
// that compile their kernels where they run (FreeToken, ComfyUI's compiled
|
||||
// nodes) need one matching the CUDA their torch was built for, and a
|
||||
// toolkit of another major version compiles code that torch cannot load.
|
||||
//
|
||||
// CUDA_HOME is looked at first, then /usr/local/cuda, then every
|
||||
// /usr/local/cuda-<version>, newest first.
|
||||
func CUDAToolkit(major int) string {
|
||||
return cudaToolkitIn(major, os.Getenv("CUDA_HOME"), "/usr/local")
|
||||
}
|
||||
|
||||
func cudaToolkitIn(major int, home, root string) string {
|
||||
var dirs []string
|
||||
if home != "" {
|
||||
dirs = append(dirs, home)
|
||||
}
|
||||
dirs = append(dirs, filepath.Join(root, "cuda"))
|
||||
versioned, _ := filepath.Glob(filepath.Join(root, "cuda-*"))
|
||||
slices.SortFunc(versioned, func(a, b string) int { return versionCompare(b, a) })
|
||||
dirs = append(dirs, versioned...)
|
||||
for _, d := range dirs {
|
||||
if _, err := os.Stat(filepath.Join(d, "bin", "nvcc")); err != nil {
|
||||
continue
|
||||
}
|
||||
if toolkitMajor(d) == major {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// toolkitMajor is a toolkit's major version, from the version.json every
|
||||
// toolkit since 11.1 carries, or from nvcc where it has none; 0 when neither
|
||||
// says.
|
||||
func toolkitMajor(dir string) int {
|
||||
if b, err := os.ReadFile(filepath.Join(dir, "version.json")); err == nil { //nolint:gosec // a toolkit's own file
|
||||
var v struct {
|
||||
CUDA struct {
|
||||
Version string `json:"version"`
|
||||
} `json:"cuda"`
|
||||
}
|
||||
if json.Unmarshal(b, &v) == nil && v.CUDA.Version != "" {
|
||||
n, _ := strconv.Atoi(strings.SplitN(v.CUDA.Version, ".", 2)[0])
|
||||
return n
|
||||
}
|
||||
}
|
||||
//nolint:gosec // nvcc of a toolkit found on disk
|
||||
out, err := exec.Command(filepath.Join(dir, "bin", "nvcc"), "--version").Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
if m := nvccRelease.FindSubmatch(out); m != nil {
|
||||
n, _ := strconv.Atoi(string(m[1]))
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var nvccRelease = regexp.MustCompile(`release (\d+)\.`)
|
||||
|
||||
// versionCompare orders two paths ending in dotted versions: cuda-13.10 is
|
||||
// newer than cuda-13.4, which comparing them as text gets wrong.
|
||||
func versionCompare(a, b string) int {
|
||||
pa := strings.Split(strings.TrimPrefix(filepath.Base(a), "cuda-"), ".")
|
||||
pb := strings.Split(strings.TrimPrefix(filepath.Base(b), "cuda-"), ".")
|
||||
for i := range max(len(pa), len(pb)) {
|
||||
var x, y int
|
||||
if i < len(pa) {
|
||||
x, _ = strconv.Atoi(pa[i])
|
||||
}
|
||||
if i < len(pb) {
|
||||
y, _ = strconv.Atoi(pb[i])
|
||||
}
|
||||
if x != y {
|
||||
return x - y
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func toolkit(t *testing.T, root, name, version string) string {
|
||||
t.Helper()
|
||||
d := filepath.Join(root, name)
|
||||
put(t, d, "bin/nvcc", "#!/bin/sh")
|
||||
if version != "" {
|
||||
put(t, d, "version.json", `{"cuda":{"name":"CUDA SDK","version":"`+version+`"}}`)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestTheNewestToolkitOfTheMajorVersionAskedForIsFound(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
toolkit(t, root, "cuda-12.8", "12.8.1")
|
||||
toolkit(t, root, "cuda-13.4", "13.4.0")
|
||||
want := toolkit(t, root, "cuda-13.10", "13.10.0")
|
||||
// /usr/local/cuda is a 12: a toolkit of the wrong major version is not
|
||||
// one torch built for 13 can load what it compiles.
|
||||
toolkit(t, root, "cuda", "12.8.1")
|
||||
if got := cudaToolkitIn(13, "", root); got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
if got := cudaToolkitIn(12, "", root); got != filepath.Join(root, "cuda") {
|
||||
t.Errorf("12: got %q", got)
|
||||
}
|
||||
// CUDA_HOME first, when it is one.
|
||||
home := toolkit(t, t.TempDir(), "mine", "13.0.2")
|
||||
if got := cudaToolkitIn(13, home, root); got != home {
|
||||
t.Errorf("CUDA_HOME: got %q", got)
|
||||
}
|
||||
if got := cudaToolkitIn(14, "", root); got != "" {
|
||||
t.Errorf("14: got %q", got)
|
||||
}
|
||||
// A directory without nvcc is not a toolkit.
|
||||
if err := os.MkdirAll(filepath.Join(root, "cuda-13.99"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := cudaToolkitIn(13, "", root); got != want {
|
||||
t.Errorf("with an empty cuda-13.99: got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,11 @@ type Options struct {
|
||||
// SkipGPUs omits GPU discovery. A node without nvidia-smi is already
|
||||
// found to have none, so this is for one whose driver is broken.
|
||||
SkipGPUs bool `koanf:"skip_gpus"`
|
||||
// DCGMExporter is the dcgm-exporter metrics URL read for how much of
|
||||
// each card's chip is in use, on cards NVML's GPM does not cover (before
|
||||
// Hopper). Empty is http://127.0.0.1:9400/metrics; "off" reads none. A
|
||||
// node without one costs a refused connection every five minutes.
|
||||
DCGMExporter string `koanf:"dcgm_exporter"`
|
||||
// ModelDirs are scanned for weights. Empty means do not look.
|
||||
ModelDirs []string `koanf:"-"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
//go:build !nvml
|
||||
|
||||
package discovery
|
||||
|
||||
// newGPM has nothing to read GPM with in a build without the nvml tag.
|
||||
func newGPM() gpmSource { return nil }
|
||||
@@ -0,0 +1,124 @@
|
||||
//go:build nvml
|
||||
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/NVIDIA/go-nvml/pkg/nvml"
|
||||
)
|
||||
|
||||
// nvmlGPM reads GPM, NVML's own profiling counters, on the cards that have
|
||||
// them: Hopper and later. Each read takes a sample and works out the metrics
|
||||
// against the one before, so NVML stays initialised between reads.
|
||||
type nvmlGPM struct {
|
||||
mu sync.Mutex
|
||||
opened bool
|
||||
failed bool
|
||||
cards map[int]*gpmCard
|
||||
}
|
||||
|
||||
type gpmCard struct {
|
||||
dev nvml.Device
|
||||
prev, cur nvml.GpmSample
|
||||
havePrevious bool
|
||||
}
|
||||
|
||||
func newGPM() gpmSource { return &nvmlGPM{} }
|
||||
|
||||
// The GPM metrics read, in the order they are asked for.
|
||||
var gpmMetrics = []struct {
|
||||
id nvml.GpmMetricId
|
||||
set func(*GPUProfile, float64)
|
||||
}{
|
||||
{nvml.GPM_METRIC_SM_UTIL, func(p *GPUProfile, v float64) { p.SMActive = v }},
|
||||
{nvml.GPM_METRIC_SM_OCCUPANCY, func(p *GPUProfile, v float64) { p.SMOccupancy = v }},
|
||||
{nvml.GPM_METRIC_ANY_TENSOR_UTIL, func(p *GPUProfile, v float64) { p.TensorActive = v }},
|
||||
{nvml.GPM_METRIC_DRAM_BW_UTIL, func(p *GPUProfile, v float64) { p.DRAMActive = v }},
|
||||
}
|
||||
|
||||
func (g *nvmlGPM) read() map[int]GPUProfile {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.failed {
|
||||
return nil
|
||||
}
|
||||
if !g.opened && !g.open() {
|
||||
g.failed = true
|
||||
return nil
|
||||
}
|
||||
out := map[int]GPUProfile{}
|
||||
for idx, c := range g.cards {
|
||||
if c.cur.Get(c.dev) != nvml.SUCCESS {
|
||||
// A card in MIG mode, or one that has gone: no sample, and the
|
||||
// next must not be measured against an old one.
|
||||
c.havePrevious = false
|
||||
continue
|
||||
}
|
||||
if c.havePrevious {
|
||||
if p, ok := gpmBetween(c.prev, c.cur); ok {
|
||||
out[idx] = p
|
||||
}
|
||||
}
|
||||
c.prev, c.cur = c.cur, c.prev
|
||||
c.havePrevious = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// open finds the cards with GPM. False is NVML not starting, or no card
|
||||
// having it, and the reader then never tries again.
|
||||
func (g *nvmlGPM) open() bool {
|
||||
if nvml.Init() != nvml.SUCCESS {
|
||||
return false
|
||||
}
|
||||
g.cards = map[int]*gpmCard{}
|
||||
n, ret := nvml.DeviceGetCount()
|
||||
if ret != nvml.SUCCESS {
|
||||
n = 0
|
||||
}
|
||||
for i := range n {
|
||||
dev, ret := nvml.DeviceGetHandleByIndex(i)
|
||||
if ret != nvml.SUCCESS {
|
||||
continue
|
||||
}
|
||||
sup, ret := dev.GpmQueryDeviceSupport()
|
||||
if ret != nvml.SUCCESS || sup.IsSupportedDevice == 0 {
|
||||
continue
|
||||
}
|
||||
a, ret := nvml.GpmSampleAlloc()
|
||||
if ret != nvml.SUCCESS {
|
||||
continue
|
||||
}
|
||||
b, ret := nvml.GpmSampleAlloc()
|
||||
if ret != nvml.SUCCESS {
|
||||
_ = a.Free()
|
||||
continue
|
||||
}
|
||||
g.cards[i] = &gpmCard{dev: dev, prev: a, cur: b}
|
||||
}
|
||||
if len(g.cards) == 0 {
|
||||
_ = nvml.Shutdown()
|
||||
return false
|
||||
}
|
||||
g.opened = true
|
||||
return true
|
||||
}
|
||||
|
||||
// gpmBetween is the profile over the time between two samples.
|
||||
func gpmBetween(earlier, later nvml.GpmSample) (GPUProfile, bool) {
|
||||
q := nvml.GpmMetricsGetType{NumMetrics: uint32(len(gpmMetrics)), Sample1: earlier, Sample2: later}
|
||||
for i, m := range gpmMetrics {
|
||||
q.Metrics[i].MetricId = uint32(m.id)
|
||||
}
|
||||
if nvml.GpmMetricsGet(&q) != nvml.SUCCESS {
|
||||
return GPUProfile{}, false
|
||||
}
|
||||
p := GPUProfile{SMActive: -1, SMOccupancy: -1, TensorActive: -1, DRAMActive: -1, Source: "gpm"}
|
||||
for i, m := range gpmMetrics {
|
||||
if r := q.Metrics[i]; r.NvmlReturn == uint32(nvml.SUCCESS) && r.Value >= 0 {
|
||||
m.set(&p, r.Value)
|
||||
}
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
@@ -35,6 +35,10 @@ type Host struct {
|
||||
// measured: what decides a model's speed on the CPU. Not read here but
|
||||
// measured by the agent when it starts (MeasureMemoryBandwidth).
|
||||
MemBandwidthGBs float64 `json:"mem_bandwidth_gbs,omitempty"`
|
||||
// MemBandwidthCoreGBs is what one core reads alone, in GB/s, 0 when not
|
||||
// measured: a replica given n threads reads at about n times this, up to
|
||||
// MemBandwidthGBs.
|
||||
MemBandwidthCoreGBs float64 `json:"mem_bandwidth_core_gbs,omitempty"`
|
||||
}
|
||||
|
||||
// Family is the packaging family: "deb", "rpm", or "" when neither is known.
|
||||
|
||||
@@ -28,9 +28,22 @@ const (
|
||||
minBuffer = 256 << 20
|
||||
)
|
||||
|
||||
// MeasureMemoryBandwidth is the memory's read rate in GB/s, the best of a
|
||||
// few passes; 0 when the machine has too little free memory to measure.
|
||||
func MeasureMemoryBandwidth() float64 {
|
||||
// Bandwidth is how fast this machine's memory reads, in GB/s: with every
|
||||
// CPU reading, and with one alone. A replica given a few threads of a large
|
||||
// machine reads at a few times the second, not at the first: on a server of
|
||||
// many memory channels one core cannot keep more than a fraction of them
|
||||
// busy.
|
||||
type Bandwidth struct {
|
||||
All, Core float64
|
||||
}
|
||||
|
||||
// coreBuffer is what the one-core passes read: past any cache, and short
|
||||
// enough that one core reads it in a fraction of a second.
|
||||
const coreBuffer = 512 << 20
|
||||
|
||||
// MeasureMemoryBandwidth is the memory's read rate, the best of a few passes
|
||||
// each way; zero when the machine has too little free memory to measure.
|
||||
func MeasureMemoryBandwidth() Bandwidth {
|
||||
size := int64(bandwidthBuffer)
|
||||
// Never more than a quarter of what is free: a node already holding
|
||||
// models must not be pushed into swap by a measurement. Without room
|
||||
@@ -40,7 +53,7 @@ func MeasureMemoryBandwidth() float64 {
|
||||
size = min(size, avail/4)
|
||||
}
|
||||
if size < max(minBuffer, 8*lastCacheBytes("/sys/devices/system/cpu")) {
|
||||
return 0
|
||||
return Bandwidth{}
|
||||
}
|
||||
buf := make([]uint64, size/8)
|
||||
for i := range buf {
|
||||
@@ -68,7 +81,17 @@ func MeasureMemoryBandwidth() float64 {
|
||||
best = max(best, float64(size)/s/1e9)
|
||||
}
|
||||
}
|
||||
return best
|
||||
// And one core alone, over a part of the same buffer.
|
||||
part := buf[:min(len(buf), coreBuffer/8)]
|
||||
core := 0.0
|
||||
for pass := 0; pass < 3; pass++ {
|
||||
start := time.Now()
|
||||
sink(sum(part))
|
||||
if s := time.Since(start).Seconds(); s > 0 {
|
||||
core = max(core, float64(len(part)*8)/s/1e9)
|
||||
}
|
||||
}
|
||||
return Bandwidth{All: best, Core: min(core, best)}
|
||||
}
|
||||
|
||||
// sum reads every word, eight at a time so the loop is not what limits it.
|
||||
|
||||
@@ -12,11 +12,11 @@ func TestMemoryBandwidthIsMeasured(t *testing.T) {
|
||||
}
|
||||
bw := MeasureMemoryBandwidth()
|
||||
// Any machine running this reads more than a gigabyte a second and less
|
||||
// than a few terabytes.
|
||||
if bw < 1 || bw > 5000 {
|
||||
t.Errorf("measured %.1f GB/s", bw)
|
||||
// than a few terabytes, and one core no more than all of them.
|
||||
if bw.All < 1 || bw.All > 5000 || bw.Core < 0.5 || bw.Core > bw.All {
|
||||
t.Errorf("measured %.1f GB/s, %.1f on one core", bw.All, bw.Core)
|
||||
}
|
||||
t.Logf("%.1f GB/s", bw)
|
||||
t.Logf("%.1f GB/s, %.1f on one core", bw.All, bw.Core)
|
||||
}
|
||||
|
||||
func TestTheLastCacheIsReadFromSys(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GPUProfile is how much of a card's chip the work on it uses. nvidia-smi's
|
||||
// utilization.gpu cannot say: it is the share of time any kernel ran, and a
|
||||
// kernel keeping one SM of a hundred busy reads 100%. Measured on a Quadro
|
||||
// RTX 6000 under decode, it read 92% while the SMs had work 69% of the time
|
||||
// and issued an instruction on 18% of their cycles.
|
||||
//
|
||||
// These come from the card's profiling counters, which only two things read
|
||||
// without stopping the work: NVML's GPM, on Hopper and later, and DCGM, which
|
||||
// a site may run as dcgm-exporter. Percentages; -1 is not reported.
|
||||
type GPUProfile struct {
|
||||
// SMActive is the share of time the SMs had work, averaged over all of
|
||||
// them; SMOccupancy how full those SMs were with warps.
|
||||
SMActive, SMOccupancy float64
|
||||
// TensorActive is the tensor cores. Decode at batch one hardly uses them
|
||||
// even when nothing is wrong: a quantised matrix-vector product runs on
|
||||
// the integer units. Prefill and batches are where they show.
|
||||
TensorActive float64
|
||||
// DRAMActive is the memory interface, which decode is bound by.
|
||||
DRAMActive float64
|
||||
// Source is "gpm" or "dcgm".
|
||||
Source string
|
||||
}
|
||||
|
||||
// DefaultDCGMExporter is where dcgm-exporter listens unless told otherwise.
|
||||
const DefaultDCGMExporter = "http://127.0.0.1:9400/metrics"
|
||||
|
||||
// dcgmRetry is how long a dcgm-exporter that did not answer is left alone.
|
||||
// Most nodes run none, and asking every ten seconds would be asking nobody.
|
||||
const dcgmRetry = 5 * time.Minute
|
||||
|
||||
// gpmSource reads GPM, where this build and the card have it.
|
||||
type gpmSource interface {
|
||||
read() map[int]GPUProfile
|
||||
}
|
||||
|
||||
// ProfSampler reads each card's GPUProfile: from GPM where the card has it,
|
||||
// otherwise from a dcgm-exporter on the node.
|
||||
type ProfSampler struct {
|
||||
gpm gpmSource
|
||||
dcgm string
|
||||
get func(ctx context.Context, url string) ([]byte, error)
|
||||
now func() time.Time
|
||||
|
||||
dcgmAfter time.Time
|
||||
}
|
||||
|
||||
// NewProfSampler reads this machine. dcgmURL is the dcgm-exporter to ask:
|
||||
// empty is DefaultDCGMExporter, "off" asks none.
|
||||
func NewProfSampler(dcgmURL string) *ProfSampler {
|
||||
switch dcgmURL {
|
||||
case "":
|
||||
dcgmURL = DefaultDCGMExporter
|
||||
case "off":
|
||||
dcgmURL = ""
|
||||
}
|
||||
return &ProfSampler{gpm: newGPM(), dcgm: dcgmURL, get: httpGet, now: time.Now}
|
||||
}
|
||||
|
||||
// Sample is each card's profile by index; a card missing has none. GPM needs
|
||||
// two samples, so its cards appear from the second call.
|
||||
func (p *ProfSampler) Sample(ctx context.Context) map[int]GPUProfile {
|
||||
out := map[int]GPUProfile{}
|
||||
if p.gpm != nil {
|
||||
for i, v := range p.gpm.read() {
|
||||
out[i] = v
|
||||
}
|
||||
}
|
||||
if p.dcgm == "" || p.now().Before(p.dcgmAfter) {
|
||||
return out
|
||||
}
|
||||
body, err := p.get(ctx, p.dcgm)
|
||||
if err != nil {
|
||||
p.dcgmAfter = p.now().Add(dcgmRetry)
|
||||
return out
|
||||
}
|
||||
for i, v := range parseDCGM(body) {
|
||||
if _, ok := out[i]; !ok {
|
||||
out[i] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func httpGet(ctx context.Context, url string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
}
|
||||
|
||||
// The dcgm-exporter fields read, as ratios of one. Its default field list
|
||||
// has them on every card DCGM can profile.
|
||||
var dcgmFields = map[string]func(*GPUProfile, float64){
|
||||
"DCGM_FI_PROF_SM_ACTIVE": func(p *GPUProfile, v float64) { p.SMActive = v },
|
||||
"DCGM_FI_PROF_SM_OCCUPANCY": func(p *GPUProfile, v float64) { p.SMOccupancy = v },
|
||||
"DCGM_FI_PROF_PIPE_TENSOR_ACTIVE": func(p *GPUProfile, v float64) { p.TensorActive = v },
|
||||
"DCGM_FI_PROF_DRAM_ACTIVE": func(p *GPUProfile, v float64) { p.DRAMActive = v },
|
||||
}
|
||||
|
||||
// parseDCGM reads dcgm-exporter's text exposition: a line per field and
|
||||
// card, the card in the gpu label. A card with none of the fields — DCGM
|
||||
// cannot profile it — is left out. A MIG instance's lines carry a
|
||||
// GPU_I_ID and are left out too: these are whole cards.
|
||||
func parseDCGM(body []byte) map[int]GPUProfile {
|
||||
out := map[int]GPUProfile{}
|
||||
for _, line := range strings.Split(string(body), "\n") {
|
||||
if line == "" || line[0] == '#' {
|
||||
continue
|
||||
}
|
||||
open := strings.IndexByte(line, '{')
|
||||
end := strings.LastIndexByte(line, '}')
|
||||
if open < 0 || end < open {
|
||||
continue
|
||||
}
|
||||
set, ok := dcgmFields[line[:open]]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
labels := line[open+1 : end]
|
||||
if strings.Contains(labels, `GPU_I_ID="`) && !strings.Contains(labels, `GPU_I_ID=""`) {
|
||||
continue
|
||||
}
|
||||
idx, err := strconv.Atoi(labelValue(labels, "gpu"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
val := strings.Fields(line[end+1:])
|
||||
if len(val) == 0 {
|
||||
continue
|
||||
}
|
||||
v, err := strconv.ParseFloat(val[0], 64)
|
||||
if err != nil || v < 0 {
|
||||
continue
|
||||
}
|
||||
p, seen := out[idx]
|
||||
if !seen {
|
||||
p = GPUProfile{SMActive: -1, SMOccupancy: -1, TensorActive: -1, DRAMActive: -1, Source: "dcgm"}
|
||||
}
|
||||
set(&p, 100*v)
|
||||
out[idx] = p
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// labelValue is one label's value from a Prometheus label set.
|
||||
func labelValue(labels, name string) string {
|
||||
for _, kv := range strings.Split(labels, ",") {
|
||||
k, v, ok := strings.Cut(strings.TrimSpace(kv), "=")
|
||||
if ok && k == name {
|
||||
return strings.Trim(v, `"`)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// dcgmBody is dcgm-exporter's output for two cards and a MIG instance:
|
||||
// card 0 profiled, card 1 a model DCGM cannot profile, which has the device
|
||||
// fields and none of the PROF ones.
|
||||
const dcgmBody = `# HELP DCGM_FI_DEV_GPU_UTIL GPU utilization (in %).
|
||||
# TYPE DCGM_FI_DEV_GPU_UTIL gauge
|
||||
DCGM_FI_DEV_GPU_UTIL{gpu="0",UUID="GPU-a",device="nvidia0",modelName="NVIDIA L40S",Hostname="n1"} 100
|
||||
DCGM_FI_DEV_GPU_UTIL{gpu="1",UUID="GPU-b",device="nvidia1",modelName="Quadro RTX 6000",Hostname="n1"} 92
|
||||
# HELP DCGM_FI_PROF_SM_ACTIVE The ratio of cycles an SM has at least 1 warp assigned.
|
||||
# TYPE DCGM_FI_PROF_SM_ACTIVE gauge
|
||||
DCGM_FI_PROF_SM_ACTIVE{gpu="0",UUID="GPU-a",device="nvidia0",modelName="NVIDIA L40S",Hostname="n1"} 0.007042
|
||||
DCGM_FI_PROF_SM_ACTIVE{gpu="2",UUID="GPU-c",device="nvidia2",modelName="NVIDIA A100",GPU_I_PROFILE="3g.40gb",GPU_I_ID="1",Hostname="n1"} 0.5
|
||||
DCGM_FI_PROF_SM_OCCUPANCY{gpu="0",UUID="GPU-a",device="nvidia0",modelName="NVIDIA L40S",Hostname="n1"} 0.25
|
||||
DCGM_FI_PROF_PIPE_TENSOR_ACTIVE{gpu="0",UUID="GPU-a",device="nvidia0",modelName="NVIDIA L40S",Hostname="n1"} 0
|
||||
`
|
||||
|
||||
func TestDCGMExporterSaysHowMuchOfEachChipIsInUse(t *testing.T) {
|
||||
got := parseDCGM([]byte(dcgmBody))
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("want card 0 alone (card 1 unprofiled, card 2 a MIG instance): %+v", got)
|
||||
}
|
||||
p := got[0]
|
||||
// The LinkedIn L40S: 100% busy, one SM of 142 at work.
|
||||
if p.SMActive < 0.70 || p.SMActive > 0.71 || p.SMOccupancy != 25 || p.TensorActive != 0 || p.Source != "dcgm" {
|
||||
t.Errorf("card 0 %+v", p)
|
||||
}
|
||||
// A field the exporter does not send is unknown, not zero.
|
||||
if p.DRAMActive != -1 {
|
||||
t.Errorf("DRAM active not sent, read as %v", p.DRAMActive)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeGPM map[int]GPUProfile
|
||||
|
||||
func (f fakeGPM) read() map[int]GPUProfile { return f }
|
||||
|
||||
func TestGPMIsPreferredAndDCGMFillsTheCardsItDoesNotCover(t *testing.T) {
|
||||
calls := 0
|
||||
p := &ProfSampler{
|
||||
gpm: fakeGPM{1: {SMActive: 40, Source: "gpm"}},
|
||||
dcgm: DefaultDCGMExporter,
|
||||
get: func(context.Context, string) ([]byte, error) {
|
||||
calls++
|
||||
return []byte(`DCGM_FI_PROF_SM_ACTIVE{gpu="0"} 0.5` + "\n" + `DCGM_FI_PROF_SM_ACTIVE{gpu="1"} 0.9` + "\n"), nil
|
||||
},
|
||||
now: time.Now,
|
||||
}
|
||||
got := p.Sample(t.Context())
|
||||
if got[0].Source != "dcgm" || got[0].SMActive != 50 {
|
||||
t.Errorf("card 0 %+v", got[0])
|
||||
}
|
||||
if got[1].Source != "gpm" || got[1].SMActive != 40 {
|
||||
t.Errorf("GPM's card was taken from DCGM: %+v", got[1])
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("%d calls", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodeWithoutDCGMIsNotAskedEverySample(t *testing.T) {
|
||||
now := time.Now()
|
||||
calls := 0
|
||||
p := &ProfSampler{dcgm: DefaultDCGMExporter, now: func() time.Time { return now },
|
||||
get: func(context.Context, string) ([]byte, error) { calls++; return nil, errors.New("connection refused") }}
|
||||
for range 5 {
|
||||
if got := p.Sample(t.Context()); len(got) != 0 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
now = now.Add(10 * time.Second)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("asked %d times in 50 s", calls)
|
||||
}
|
||||
now = now.Add(dcgmRetry)
|
||||
p.Sample(t.Context())
|
||||
if calls != 2 {
|
||||
t.Errorf("not asked again after %v", dcgmRetry)
|
||||
}
|
||||
if off := NewProfSampler("off"); off.dcgm != "" {
|
||||
t.Errorf("off still asks %q", off.dcgm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachSampleCarriesTheProfileOfTheCardsThatHaveOne(t *testing.T) {
|
||||
smi := func(context.Context, string) ([]byte, error) {
|
||||
return []byte("0, 71, 239, 260, 0x0, 92, 30, 23000, 24576, 1878, 0, 0\n" +
|
||||
"1, 30, 20, 260, 0x0, 0, 0, 512, 24576, 300, 0, 0\n"), nil
|
||||
}
|
||||
now := time.Now()
|
||||
s := sampler(t.TempDir(), smi, &now).WithProfile(&ProfSampler{
|
||||
gpm: fakeGPM{0: {SMActive: 69, SMOccupancy: 45, TensorActive: 1, DRAMActive: 33, Source: "gpm"}}, now: time.Now})
|
||||
got := s.Sample(t.Context()).GPUs
|
||||
if got[0].Profile == nil || got[0].Profile.SMActive != 69 || got[0].UtilPercent != 92 {
|
||||
t.Errorf("card 0 %+v", got[0])
|
||||
}
|
||||
if got[1].Profile != nil {
|
||||
t.Errorf("card 1 has a profile nobody read: %+v", got[1].Profile)
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,9 @@ type GPUSensor struct {
|
||||
MemoryUsedMB, MemoryTotalMB float64
|
||||
SMClockMHz float64
|
||||
ECCUncorrected float64
|
||||
// Profile is how much of the chip the work uses, where something on the
|
||||
// node can say: nil on most cards. UtilPercent is not that.
|
||||
Profile *GPUProfile
|
||||
}
|
||||
|
||||
// sensorEvery bounds how often nvidia-smi is run. Heartbeats come more often
|
||||
@@ -61,6 +64,8 @@ type SensorSampler struct {
|
||||
sys string // /sys, or a test's copy of it
|
||||
smi func(ctx context.Context, fields string) ([]byte, error)
|
||||
now func() time.Time
|
||||
// prof reads the cards' profiling counters; nil reads none.
|
||||
prof *ProfSampler
|
||||
|
||||
mu sync.Mutex
|
||||
last Sensors
|
||||
@@ -74,6 +79,12 @@ func NewSensorSampler() *SensorSampler {
|
||||
return &SensorSampler{sys: "/sys", smi: runSMI, now: time.Now}
|
||||
}
|
||||
|
||||
// WithProfile has each sample carry the cards' profiles as well.
|
||||
func (s *SensorSampler) WithProfile(p *ProfSampler) *SensorSampler {
|
||||
s.prof = p
|
||||
return s
|
||||
}
|
||||
|
||||
// SampleGPUs reads every card's heat, power and how busy it is now, without
|
||||
// a sampler's ten seconds of memory: for a screen that asks every few seconds
|
||||
// and wants what it asked for. Nil on a machine without nvidia-smi or a card.
|
||||
@@ -101,6 +112,14 @@ func (s *SensorSampler) Sample(ctx context.Context) Sensors {
|
||||
out.CPUTempC, out.InletTempC = s.hwmon()
|
||||
out.CPUPowerW = s.rapl(now)
|
||||
out.GPUs = s.gpus(ctx)
|
||||
if s.prof != nil {
|
||||
profs := s.prof.Sample(ctx)
|
||||
for i := range out.GPUs {
|
||||
if p, ok := profs[out.GPUs[i].Index]; ok {
|
||||
out.GPUs[i].Profile = &p
|
||||
}
|
||||
}
|
||||
}
|
||||
s.last, s.lastAt = out, now
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ type Profile struct {
|
||||
// AllowPickle lets the node fetch weights in a pickle format, which run
|
||||
// code when loaded; the catalog's allow_pickle.
|
||||
AllowPickle bool `json:"allow_pickle,omitempty"`
|
||||
// ModelSHA256 is the sha256 the registry published for the file
|
||||
// ModelPath names; the node refuses a copy that is not it. Empty when
|
||||
// nothing is known to check against.
|
||||
ModelSHA256 string `json:"model_sha256,omitempty"`
|
||||
}
|
||||
|
||||
// ShardSpec is one shard of one replica on this node.
|
||||
@@ -219,6 +223,7 @@ func NewRegistry(cfg Config) *Registry {
|
||||
r.Add(&Sim{cfg: cfg.Sim})
|
||||
r.Add(&ComfyUI{cfg: cfg.ComfyUI})
|
||||
r.Add(&Strata{cfg: cfg.Strata})
|
||||
r.Add(&FreeToken{cfg: cfg.FreeToken})
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -272,6 +277,10 @@ type Config struct {
|
||||
// Strata serves a few mixture-of-experts models from RAM and one card;
|
||||
// offered by a node whose Strata is named here, or that installed it.
|
||||
Strata StrataConfig `koanf:"strata"`
|
||||
// FreeToken serves mixture-of-experts models larger than a card from
|
||||
// RAM and one card of 8.0 or newer; installed by the agent where the
|
||||
// cards allow it, or named here.
|
||||
FreeToken FreeTokenConfig `koanf:"freetoken"`
|
||||
}
|
||||
|
||||
// --- shared helpers ---
|
||||
|
||||
@@ -477,7 +477,7 @@ func TestValidateRequiresModelPath(t *testing.T) {
|
||||
|
||||
func TestRegistry(t *testing.T) {
|
||||
r := NewRegistry(Config{})
|
||||
if got := r.Names(); !slices.Equal(got, []string{"comfyui", "llamacpp", "sglang", "sim", "strata", "trtllm", "vllm"}) {
|
||||
if got := r.Names(); !slices.Equal(got, []string{"comfyui", "freetoken", "llamacpp", "sglang", "sim", "strata", "trtllm", "vllm"}) {
|
||||
t.Errorf("registry contents: %v", got)
|
||||
}
|
||||
if _, err := r.Get("vllm"); err != nil {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package engines
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
)
|
||||
|
||||
// FreeTokenConfig locates a FreeToken installation.
|
||||
type FreeTokenConfig struct {
|
||||
// Binary is its ft command, in the environment it was installed into.
|
||||
// The agent installs one itself on a node whose GPUs it runs on; this
|
||||
// is for one installed by hand.
|
||||
Binary string `koanf:"binary"`
|
||||
}
|
||||
|
||||
// freetokenToolkit finds the CUDA 13 toolkit; a variable for tests.
|
||||
var freetokenToolkit = func() string { return discovery.CUDAToolkit(13) }
|
||||
|
||||
// FreeToken runs FreeToken (github.com/FlashML-org/FreeToken): a
|
||||
// mixture-of-experts engine that keeps a model's experts in host RAM, the
|
||||
// ones used most in VRAM, and streams the rest over PCIe, so a model several
|
||||
// times a card's size serves from one card. It reads Hugging Face
|
||||
// safetensors checkpoints as they are published (FP8, NVFP4, MXFP4, BF16)
|
||||
// and serves the OpenAI and Anthropic APIs. One card of compute capability
|
||||
// 8.0 or newer; it compiles some of its kernels on first start, with the
|
||||
// CUDA 13 toolkit.
|
||||
type FreeToken struct {
|
||||
cfg FreeTokenConfig
|
||||
installed atomic.Pointer[string]
|
||||
}
|
||||
|
||||
// Name identifies the engine in the catalog.
|
||||
func (*FreeToken) Name() string { return "freetoken" }
|
||||
|
||||
// HealthPath answers before the model is loaded too, with its progress in
|
||||
// the body; the supervisor waits for the status code, so it counts as ready
|
||||
// only once FreeToken's own startup — loading, warm-up, CUDA graphs — is
|
||||
// over and the API server is accepting.
|
||||
func (*FreeToken) HealthPath() string { return "/health" }
|
||||
|
||||
// MetricsPath is empty: FreeToken serves its numbers as JSON (/v1/stats),
|
||||
// not in Prometheus' text, so the router counts its requests itself.
|
||||
func (*FreeToken) MetricsPath() string { return "" }
|
||||
|
||||
// ServesHTTP is true: a FreeToken replica is one process on one node.
|
||||
func (*FreeToken) ServesHTTP(ShardSpec) bool { return true }
|
||||
|
||||
// UseInstalled points the engine at the ft the agent installed.
|
||||
func (f *FreeToken) UseInstalled(path string) { f.installed.Store(&path) }
|
||||
|
||||
// Configured reports whether the configuration names a FreeToken.
|
||||
func (f *FreeToken) Configured() bool { return f.cfg.Binary != "" }
|
||||
|
||||
func (f *FreeToken) binary() string {
|
||||
if p := f.installed.Load(); p != nil {
|
||||
return *p
|
||||
}
|
||||
return f.cfg.Binary
|
||||
}
|
||||
|
||||
// HelpCommand is ft serve's own help.
|
||||
func (f *FreeToken) HelpCommand(Command) (string, []string) {
|
||||
return f.binary(), []string{"serve", "--help"}
|
||||
}
|
||||
|
||||
// Probe reports the FreeToken a configuration names, by what it says its
|
||||
// version is.
|
||||
func (f *FreeToken) Probe(ctx context.Context) (string, error) {
|
||||
bin := f.binary()
|
||||
if bin == "" {
|
||||
return "", fmt.Errorf("no FreeToken is configured")
|
||||
}
|
||||
out, err := exec.CommandContext(ctx, bin, "--version").Output() //nolint:gosec // the configured engine
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s does not run: %w", bin, err)
|
||||
}
|
||||
// "freetoken version 0.2.1"
|
||||
if fs := strings.Fields(string(out)); len(fs) > 0 {
|
||||
return fs[len(fs)-1], nil
|
||||
}
|
||||
return Unknown, nil
|
||||
}
|
||||
|
||||
// Validate checks the profile: one card of 8.0 or newer, on one node.
|
||||
// FreeToken has no tensor parallel; it picks one device.
|
||||
func (*FreeToken) Validate(p Profile, gpus []GPUInfo) error {
|
||||
if p.Nodes > 1 || p.TP > 1 || p.PP > 1 || p.GPUsPerNode > 1 {
|
||||
return fmt.Errorf("profile %q: freetoken runs on one GPU of one node", p.Name)
|
||||
}
|
||||
if err := requireComputeCapability(p, gpus, 8.0, "freetoken"); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateCommon(p, gpus)
|
||||
}
|
||||
|
||||
// Commands is ft serve. The card is the shard's, by CUDA_VISIBLE_DEVICES,
|
||||
// so it is device 0 to FreeToken; its key goes in the environment, where it
|
||||
// reads it from, rather than on a command line every user can read.
|
||||
func (f *FreeToken) Commands(sh ShardSpec) ([]Command, error) {
|
||||
bin := f.binary()
|
||||
if bin == "" {
|
||||
return nil, fmt.Errorf("freetoken: no FreeToken is installed or configured (engines.freetoken.binary)")
|
||||
}
|
||||
toolkit := freetokenToolkit()
|
||||
if toolkit == "" {
|
||||
return nil, fmt.Errorf("freetoken compiles its kernels on first start and this node has no CUDA 13 toolkit " +
|
||||
"(nvcc in CUDA_HOME, /usr/local/cuda or /usr/local/cuda-13.*)")
|
||||
}
|
||||
p := sh.Profile
|
||||
args := []string{"serve",
|
||||
"--model", p.ModelPath,
|
||||
"--host", sh.Host(),
|
||||
"--port", strconv.Itoa(sh.HTTPPort),
|
||||
"--gpu", "0",
|
||||
}
|
||||
if p.ServedModelName != "" {
|
||||
args = append(args, "--served-model-name", p.ServedModelName)
|
||||
}
|
||||
args = append(args, p.Args...)
|
||||
env := baseEnv(sh)
|
||||
env["CUDA_HOME"] = toolkit
|
||||
// nvcc for the kernels it compiles, and the environment's own bin
|
||||
// first, where ninja is.
|
||||
env["PATH"] = strings.Join([]string{filepath.Dir(bin), filepath.Join(toolkit, "bin"), os.Getenv("PATH")}, ":")
|
||||
if sh.APIKey != "" {
|
||||
env["FREETOKEN_API_KEY"] = sh.APIKey
|
||||
}
|
||||
return []Command{{Name: "freetoken", Path: bin, Args: args, Env: materializeEnv(env)}}, nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package engines
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func withToolkit(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
old := freetokenToolkit
|
||||
freetokenToolkit = func() string { return dir }
|
||||
t.Cleanup(func() { freetokenToolkit = old })
|
||||
}
|
||||
|
||||
func TestFreeTokenServesOnTheShardsCardWithTheKeyInTheEnvironment(t *testing.T) {
|
||||
withToolkit(t, "/usr/local/cuda-13.4")
|
||||
f := &FreeToken{}
|
||||
f.UseInstalled("/opt/llmgw/engines/freetoken/0.2.1-cu13/bin/ft")
|
||||
spec := ShardSpec{
|
||||
ReplicaID: "r1", HTTPPort: 18091, BindAddr: "10.0.0.160", GPUIndices: []int{3},
|
||||
APIKey: "secret-key",
|
||||
Profile: Profile{Name: "p", Engine: "freetoken", TP: 1, ModelPath: "/var/lib/llmgw-weights/glm-5.2-nvfp4",
|
||||
ServedModelName: "glm-5.2", Args: []string{"--max-running-requests", "8"}},
|
||||
}
|
||||
cmds, err := f.Commands(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := cmds[0]
|
||||
if c.Path != "/opt/llmgw/engines/freetoken/0.2.1-cu13/bin/ft" {
|
||||
t.Errorf("path %s", c.Path)
|
||||
}
|
||||
args := strings.Join(c.Args, " ")
|
||||
for _, want := range []string{
|
||||
"serve --model /var/lib/llmgw-weights/glm-5.2-nvfp4", "--host 10.0.0.160", "--port 18091",
|
||||
// The shard's card is device 0 once CUDA_VISIBLE_DEVICES pins it.
|
||||
"--gpu 0", "--served-model-name glm-5.2", "--max-running-requests 8",
|
||||
} {
|
||||
if !strings.Contains(args, want) {
|
||||
t.Errorf("args lack %q: %s", want, args)
|
||||
}
|
||||
}
|
||||
if strings.Contains(args, "secret-key") {
|
||||
t.Error("the key is on the command line, which every user on the node can read")
|
||||
}
|
||||
for _, want := range []string{"FREETOKEN_API_KEY=secret-key", "CUDA_VISIBLE_DEVICES=3", "CUDA_HOME=/usr/local/cuda-13.4"} {
|
||||
if !slices.Contains(c.Env, want) {
|
||||
t.Errorf("env lacks %s", want)
|
||||
}
|
||||
}
|
||||
var path string
|
||||
for _, e := range c.Env {
|
||||
if p, ok := strings.CutPrefix(e, "PATH="); ok {
|
||||
path = p
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(path, "/opt/llmgw/engines/freetoken/0.2.1-cu13/bin:/usr/local/cuda-13.4/bin:") {
|
||||
t.Errorf("nvcc and the environment's tools are not first on PATH: %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeTokenWithoutTheToolkitSaysSoInsteadOfFailingOnItsFirstModel(t *testing.T) {
|
||||
withToolkit(t, "")
|
||||
f := &FreeToken{cfg: FreeTokenConfig{Binary: "/x/bin/ft"}}
|
||||
_, err := f.Commands(ShardSpec{HTTPPort: 1, Profile: Profile{Name: "p", ModelPath: "/m"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "CUDA 13 toolkit") {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeTokenRunsOnOneAmpereOrNewerCard(t *testing.T) {
|
||||
f := &FreeToken{}
|
||||
p := Profile{Name: "p", Engine: "freetoken", TP: 1, ModelPath: "/m"}
|
||||
if err := f.Validate(p, []GPUInfo{{Name: "NVIDIA RTX A6000", ComputeCapability: "8.6", MemoryTotalMB: 49140}}); err != nil {
|
||||
t.Errorf("an Ampere card was refused: %v", err)
|
||||
}
|
||||
// Tried on cuda01: no kernel image for the card, then an illegal memory
|
||||
// access in fp16.
|
||||
if err := f.Validate(p, []GPUInfo{{Name: "Quadro RTX 6000", ComputeCapability: "7.5", MemoryTotalMB: 24576}}); err == nil {
|
||||
t.Error("a Turing card was accepted")
|
||||
}
|
||||
p.TP = 2
|
||||
if err := f.Validate(p, h100(2)); err == nil {
|
||||
t.Error("a two-card profile was accepted; FreeToken has no tensor parallel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeTokenFlagsThatReadFilesOrMoveWhatTheAgentSetsAreRefused(t *testing.T) {
|
||||
for _, a := range []string{
|
||||
"--gpu", "--served-model-name=x", "--mmproj", "--allowed-local-media-path",
|
||||
// argparse takes a beginning for the whole flag.
|
||||
"--allowed-local", "--api-key", "--port", "--hf-overrides",
|
||||
} {
|
||||
if UnsafeArgs("freetoken", []string{a, "v"}) == nil {
|
||||
t.Errorf("%s was allowed", a)
|
||||
}
|
||||
}
|
||||
for _, a := range []string{"--max-running-requests", "--moe-strategy", "--dtype", "--max-seq-len-override", "--moe-cache-auto"} {
|
||||
if err := UnsafeArgs("freetoken", []string{a, "1"}); err != nil {
|
||||
t.Errorf("%s was refused: %v", a, err)
|
||||
}
|
||||
}
|
||||
for k, refuse := range map[string]bool{
|
||||
"FREETOKEN_PIN_BUDGET_GB": false, "FREETOKEN_API_KEY": true, "FREETOKEN_API_LOG_DIR": true,
|
||||
"FREETOKEN_MMPROJ": true, "FREETOKEN_HOST": true,
|
||||
} {
|
||||
if got, why := UnsafeEnv(k); got != refuse {
|
||||
t.Errorf("%s: refused %v (%s), want %v", k, got, why, refuse)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
package engines
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/strata"
|
||||
)
|
||||
|
||||
// StrataConfig locates a Strata installation.
|
||||
@@ -93,15 +97,51 @@ func (s *Strata) python() string {
|
||||
return filepath.Join(s.cfg.Dir, ".venv", "bin", "python")
|
||||
}
|
||||
|
||||
var strataVersion = regexp.MustCompile(`PRODUCT_VERSION\s*=\s*"([^"]+)"`)
|
||||
|
||||
// Probe reports the Strata a configuration names, by the version its source
|
||||
// states: a directory with its setup and server, an interpreter, and the
|
||||
// engine binary. Without it a Strata named by hand was never reported, and
|
||||
// so never placed on.
|
||||
func (s *Strata) Probe(context.Context) (string, error) {
|
||||
dir := s.dir()
|
||||
if dir == "" {
|
||||
return "", fmt.Errorf("no Strata directory is configured")
|
||||
}
|
||||
for _, f := range []string{filepath.Join(dir, "setup.py"), filepath.Join(dir, "serve", "server.py"),
|
||||
s.python(), filepath.Join(s.engineDir(), "strata")} {
|
||||
if _, err := os.Stat(f); err != nil {
|
||||
return "", fmt.Errorf("%s is not a whole Strata: %w", dir, err)
|
||||
}
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, "strata_cli", "__init__.py"))
|
||||
if err != nil {
|
||||
return Unknown, nil //nolint:nilerr // whole, of a version it does not say
|
||||
}
|
||||
if m := strataVersion.FindSubmatch(b); m != nil {
|
||||
return string(m[1]), nil
|
||||
}
|
||||
return Unknown, nil
|
||||
}
|
||||
|
||||
// Validate checks the profile: one card, on one node. Strata splits a model
|
||||
// over a node's cards by layer, but that is not driven from here yet.
|
||||
func (*Strata) Validate(p Profile, gpus []GPUInfo) error {
|
||||
if p.Nodes > 1 || p.TP > 1 || p.PP > 1 || p.GPUsPerNode > 1 {
|
||||
return fmt.Errorf("profile %q: strata runs on one GPU of one node", p.Name)
|
||||
}
|
||||
if err := requireComputeCapability(p, gpus, 7.0, "strata"); err != nil {
|
||||
// Pascal and Volta run its CUDA 12 build, which the install picks for
|
||||
// them.
|
||||
if err := requireComputeCapability(p, gpus, 6.0, "strata"); err != nil {
|
||||
return err
|
||||
}
|
||||
// A family whose engine needs more: said now, rather than by Strata's
|
||||
// setup after the replica was placed here.
|
||||
if fam, ok := strata.FamilyNamed(strata.ArgValue(p.Args, "--family")); ok && fam.MinCC > 0 {
|
||||
if err := requireComputeCapability(p, gpus, fam.MinCC, "strata's "+fam.Title+" engine"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return validateCommon(p, gpus)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package engines
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -67,3 +68,46 @@ func TestStrataSetupFlagsThatMoveFilesAreRefused(t *testing.T) {
|
||||
t.Errorf("an ordinary profile was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStrataNamedByHandIsReportedWithItsVersion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, f := range []string{"setup.py", "serve/server.py", ".venv/bin/python", "engine/strata"} {
|
||||
p := filepath.Join(dir, f)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, nil, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
s := &Strata{cfg: StrataConfig{Dir: dir}}
|
||||
if v, err := s.Probe(t.Context()); err != nil || v != Unknown {
|
||||
t.Fatalf("%q %v", v, err)
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Join(dir, "strata_cli"), 0o755)
|
||||
_ = os.WriteFile(filepath.Join(dir, "strata_cli", "__init__.py"), []byte("PRODUCT_VERSION = \"0.2.7\"\n"), 0o644)
|
||||
if v, _ := s.Probe(t.Context()); v != "0.2.7" {
|
||||
t.Errorf("version %q", v)
|
||||
}
|
||||
r := NewRegistry(Config{Strata: StrataConfig{Dir: dir}})
|
||||
if got := r.ProbeConfigured(t.Context()); got["strata"] != "0.2.7" {
|
||||
t.Errorf("not reported: %v", got)
|
||||
}
|
||||
_ = os.Remove(filepath.Join(dir, "engine", "strata"))
|
||||
if _, err := s.Probe(t.Context()); err == nil {
|
||||
t.Error("reported without its engine")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataRefusesDeepSeekOnACardItsEngineWasNotBuiltFor(t *testing.T) {
|
||||
s := &Strata{}
|
||||
p := Profile{Name: "p", Engine: "strata", ModelPath: "/m", TP: 1, GPUsPerNode: 1, Args: []string{"--family", "deepseek41", "--size", "IST-DAS-1.75bpw"}}
|
||||
gpus := []GPUInfo{{Index: 0, Name: "Quadro RTX 6000", ComputeCapability: "7.5"}}
|
||||
if err := s.Validate(p, gpus); err == nil || !strings.Contains(err.Error(), "8.6") {
|
||||
t.Errorf("DeepSeek on 7.5: %v", err)
|
||||
}
|
||||
p.Args = []string{"--family", "qwen", "--size", "IQ2_XS"}
|
||||
if err := s.Validate(p, gpus); err != nil {
|
||||
t.Errorf("Qwen on 7.5: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ var envFamilies = []string{
|
||||
"CUDA_", "NCCL_", "NVIDIA_", "NVTE_", "GLOO_", "UCX_", "TORCH_", "PYTORCH_",
|
||||
"VLLM_", "SGLANG_", "GGML_", "LLAMA_", "OMP_", "MKL_", "OPENBLAS_", "KMP_",
|
||||
"HF_", "TRANSFORMERS_", "TOKENIZERS_", "TRITON_", "FLASHINFER_",
|
||||
"TRTLLM_", "TLLM_",
|
||||
"TRTLLM_", "TLLM_", "FREETOKEN_",
|
||||
}
|
||||
|
||||
// envExact are single variables that are tuning too.
|
||||
@@ -33,6 +33,14 @@ var envExact = map[string]bool{
|
||||
"PYTHONUNBUFFERED": true, "PYTHONHASHSEED": true, "TZ": true,
|
||||
}
|
||||
|
||||
// envNeverExact are variables in an allowed family that no profile may set,
|
||||
// whose names the patterns below do not catch: what the agent sets itself,
|
||||
// and FreeToken's way of loading a projector file.
|
||||
var envNeverExact = map[string]string{
|
||||
"FREETOKEN_API_KEY": "is set by the agent", "FREETOKEN_HOST": "is set by the agent",
|
||||
"FREETOKEN_MMPROJ": "loads a file",
|
||||
}
|
||||
|
||||
// envNever are names in an allowed family that load code or move where the
|
||||
// engine reads and writes.
|
||||
var envNever = []string{
|
||||
@@ -59,6 +67,9 @@ func UnsafeEnv(key string) (bool, string) {
|
||||
return true, "only the variables engines are tuned with are allowed (" +
|
||||
strings.Join(envFamilies, "*, ") + "*)"
|
||||
}
|
||||
if why, ok := envNeverExact[k]; ok {
|
||||
return true, "it " + why
|
||||
}
|
||||
for _, n := range envNever {
|
||||
if strings.Contains(k, n) && !envExact[k] {
|
||||
return true, "it names a path, a plugin, a library or an address, which is a way to make the engine " +
|
||||
@@ -157,6 +168,16 @@ var flagsNever = map[string]map[string]string{
|
||||
"--gpu-roles": "is set by the agent", "--backend": "is set by the agent",
|
||||
"--experimental-speed-projection": "changes what the model refuses",
|
||||
},
|
||||
// ft serve: where it listens, its key, its model and its card are the
|
||||
// agent's, and what reads files or fetches is refused.
|
||||
"freetoken": {
|
||||
"--gpu": "is set by the agent", "--served-model-name": "is set by the agent",
|
||||
"--mmproj": "loads a file", "--mm-encoder-weights": "loads a file",
|
||||
"--allowed-local-media-path": "serves local files to requests",
|
||||
"--allowed-media-domains": "lets requests have the engine fetch from those sites",
|
||||
"--moe-cache-split": "reads a profile file",
|
||||
"--hf-overrides": "rewrites the checkpoint's configuration, which can name code to load",
|
||||
},
|
||||
// trtllm-serve spells its flags with underscores, so the shared list's
|
||||
// hyphenated names do not reach them.
|
||||
"trtllm": {
|
||||
@@ -223,7 +244,7 @@ func unsafeFlag(engine, flag, _ string) string {
|
||||
|
||||
// argparseEngines read their flags with Python's argparse, which takes a
|
||||
// long flag's beginning for the flag.
|
||||
var argparseEngines = map[string]bool{"vllm": true, "sglang": true, "comfyui": true, "strata": true}
|
||||
var argparseEngines = map[string]bool{"vllm": true, "sglang": true, "comfyui": true, "strata": true, "freetoken": true}
|
||||
|
||||
// UnsafeArgs refuses the first of a profile's arguments an engine may not be
|
||||
// given, saying why.
|
||||
|
||||
@@ -10,8 +10,20 @@ import (
|
||||
|
||||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/firewall"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/slurm"
|
||||
)
|
||||
|
||||
// slurmPorts are Slurm's on a node that runs any of it: slurmctld's and
|
||||
// slurmd's, and srun's range for the steps it starts.
|
||||
var slurmPorts = []firewall.Port{
|
||||
{From: slurm.CtldPort, To: slurm.DbdPort, What: "slurm: slurmctld, slurmd and slurmdbd"},
|
||||
{From: slurm.SrunPortsFrom, To: slurm.SrunPortsTo, What: "slurm: srun, for the steps it starts"},
|
||||
}
|
||||
|
||||
// nfsPort is NFSv4's, on the machine that exports the Slurm cluster's
|
||||
// shared files.
|
||||
var nfsPort = firewall.Port{From: slurm.NFSPort, To: slurm.NFSPort, What: "nfs: the Slurm cluster's shared files"}
|
||||
|
||||
// applyFirewall keeps llmgw's firewall on this node as the cluster's
|
||||
// settings say: the engines' ports and the node exporter's for the networks
|
||||
// the node reaches its control planes on, and those named, or no table at
|
||||
@@ -19,6 +31,7 @@ import (
|
||||
func (a *Agent) applyFirewall(m *llmgwv1.Firewall) {
|
||||
a.fwMu.Lock()
|
||||
defer a.fwMu.Unlock()
|
||||
a.fwLast = m
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
r := a.fwRunner
|
||||
@@ -55,6 +68,14 @@ func (a *Agent) applyFirewall(m *llmgwv1.Firewall) {
|
||||
if from > 0 && to >= from {
|
||||
ports = append(ports, firewall.Engines(from, to))
|
||||
}
|
||||
// Once there, they stay: the ruleset keeps every port it held, so a
|
||||
// node that leaves Slurm keeps them closed to everyone but the cluster.
|
||||
if a.slurm != nil && a.slurm.HasPart() {
|
||||
ports = append(ports, slurmPorts...)
|
||||
}
|
||||
if a.slurm != nil && a.slurm.Serves() {
|
||||
ports = append(ports, nfsPort)
|
||||
}
|
||||
changed, err := firewall.Install(ctx, r, firewall.Spec{Ports: ports, Allow: nets})
|
||||
switch {
|
||||
case err != nil:
|
||||
@@ -64,6 +85,23 @@ func (a *Agent) applyFirewall(m *llmgwv1.Firewall) {
|
||||
}
|
||||
}
|
||||
|
||||
// slurmMessage is the leader's word on this node's part in Slurm. A node
|
||||
// that gets one, or starts or stops exporting the shared files, has the
|
||||
// firewall applied again, with Slurm's ports and NFS's.
|
||||
func (a *Agent) slurmMessage(m *llmgwv1.Slurm) {
|
||||
had, served := a.slurm.HasPart(), a.slurm.Serves()
|
||||
a.slurm.Apply(m)
|
||||
if (had || !a.slurm.HasPart()) && served == a.slurm.Serves() {
|
||||
return
|
||||
}
|
||||
a.fwMu.Lock()
|
||||
last := a.fwLast
|
||||
a.fwMu.Unlock()
|
||||
if last != nil && last.GetEnabled() {
|
||||
go a.applyFirewall(last)
|
||||
}
|
||||
}
|
||||
|
||||
// controlPlaneIPs are the addresses of the control planes an agent dials.
|
||||
func controlPlaneIPs(ctx context.Context, servers []string) []string {
|
||||
var out []string
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/slurmnode"
|
||||
)
|
||||
|
||||
// fakeShell answers the firewall package's scripts and records what was
|
||||
@@ -55,3 +57,28 @@ func TestANodeKeepsOrDropsItsFirewallAsTheClusterSays(t *testing.T) {
|
||||
t.Error("turned off, the table stayed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodeGivenAPartInSlurmLetsSlurmInFromTheClusterOnly(t *testing.T) {
|
||||
sh := &fakeShell{}
|
||||
a := &Agent{log: slog.New(slog.DiscardHandler), fwRunner: sh, serverList: []string{"https://10.0.0.140:7443"},
|
||||
slurm: slurmnode.New(nil, nil, "", slog.New(slog.DiscardHandler))}
|
||||
a.applyFirewall(&llmgwv1.Firewall{Enabled: true, EnginePortsFrom: 20000, EnginePortsTo: 20999})
|
||||
if strings.Contains(sh.installed, "6817") {
|
||||
t.Fatalf("Slurm's ports before the node has a part in it:\n%s", sh.installed)
|
||||
}
|
||||
|
||||
a.slurmMessage(&llmgwv1.Slurm{Name: "node05", Node: true, ConfServer: "10.0.0.148:6817"})
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
a.fwMu.Lock()
|
||||
got := sh.installed
|
||||
a.fwMu.Unlock()
|
||||
if strings.Contains(got, "6817-6819") && strings.Contains(got, "60001-63000") {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("given a part in Slurm, the rules still lack its ports:\n%s", got)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Half a gigabyte read per test binary, and contending with the tests
|
||||
// for the CPUs: these never look at the figure.
|
||||
measureBandwidth = func() float64 { return 0 }
|
||||
measureBandwidth = func() discovery.Bandwidth { return discovery.Bandwidth{} }
|
||||
bandwidthDelay = func() time.Duration { return 0 }
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -164,6 +164,13 @@ func (a *Agent) provisionLoop(ctx context.Context) {
|
||||
|
||||
// provisionStatus records a change in one engine's installation.
|
||||
func (a *Agent) provisionStatus(st provision.Status) {
|
||||
if st == (provision.Status{}) {
|
||||
// A package install that was no engine's has ended.
|
||||
a.mu.Lock()
|
||||
delete(a.provStatus, "")
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
prev, had := a.provStatus[st.Engine]
|
||||
a.provStatus[st.Engine] = st
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
)
|
||||
|
||||
func TestFreeTokenIsInstalledOnlyWhereItsKernelsRun(t *testing.T) {
|
||||
old := freetokenToolkit
|
||||
toolkit := "/usr/local/cuda-13.4"
|
||||
freetokenToolkit = func() string { return toolkit }
|
||||
t.Cleanup(func() { freetokenToolkit = old })
|
||||
|
||||
ampere := discovery.Host{Arch: "amd64", NVIDIADriver: "580.95.05", MinComputeCapability: 8.6}
|
||||
got, err := freetokenCandidates("0.2.1", "", ampere)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := got[0]
|
||||
if b.Binary != "ft" || !slices.Equal(b.Requirements, []string{"freetoken[accel]==0.2.1"}) || !b.GPUCheck || b.Python == "" {
|
||||
t.Errorf("build %+v", b)
|
||||
}
|
||||
|
||||
// Not meant for these, and not reported as a fault: no GPU, a Turing
|
||||
// card (cuda01, where 0.2.1 had no kernel image for it), an Arm node.
|
||||
for name, h := range map[string]discovery.Host{
|
||||
"no gpu": {Arch: "amd64"},
|
||||
"turing": {Arch: "amd64", NVIDIADriver: "615.71.09", MinComputeCapability: 7.5},
|
||||
"aarch64": {Arch: "arm64", NVIDIADriver: "580.95.05", MinComputeCapability: 9.0},
|
||||
} {
|
||||
_, err := freetokenCandidates("0.2.1", "", h)
|
||||
var nh NotHere
|
||||
if !errors.As(err, &nh) {
|
||||
t.Errorf("%s: %v, want NotHere", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Meant for these, and said why not.
|
||||
if _, err := freetokenCandidates("0.2.1", "", discovery.Host{Arch: "amd64", NVIDIADriver: "570.1", MinComputeCapability: 9.0}); err == nil ||
|
||||
!strings.Contains(err.Error(), "driver 580") {
|
||||
t.Errorf("old driver: %v", err)
|
||||
}
|
||||
toolkit = ""
|
||||
_, err = freetokenCandidates("0.2.1", "", ampere)
|
||||
var nh NotHere
|
||||
if err == nil || errors.As(err, &nh) || !strings.Contains(err.Error(), "CUDA 13 toolkit") {
|
||||
t.Errorf("no toolkit: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -483,7 +483,8 @@ func (in *Installer) packages(ctx context.Context, byFamily map[string][]string,
|
||||
return fmt.Errorf("%s is missing and no package manager was found to install it",
|
||||
strings.Join(missing, ", "))
|
||||
}
|
||||
in.progress(st, "installing %s with %s", strings.Join(missing, ", "), tool)
|
||||
// The state already says installing.
|
||||
in.progress(st, "%s, with %s", strings.Join(missing, ", "), tool)
|
||||
for _, c := range cmds {
|
||||
if err := in.runWaitingForLock(ctx, pm.env, c, st); err != nil {
|
||||
return err
|
||||
@@ -497,7 +498,13 @@ func (in *Installer) packages(ctx context.Context, byFamily map[string][]string,
|
||||
// byFamily is keyed by discovery.Host.Family: "deb" or "rpm".
|
||||
func (in *Installer) InstallPackages(ctx context.Context, byFamily map[string][]string) error {
|
||||
var st Status
|
||||
return in.packages(ctx, byFamily, &st)
|
||||
err := in.packages(ctx, byFamily, &st)
|
||||
if st.State == Installing {
|
||||
// Its progress was no engine's: an empty status takes it off the
|
||||
// node's page again, done or failed (the caller says which).
|
||||
in.report(Status{})
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// lockWait bounds how long a package command waits for another to finish.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
)
|
||||
|
||||
// A package install that is no engine's says it is done, so its progress
|
||||
// does not stay on the node's page (found on the PoC installing Slurm).
|
||||
func TestInstallPackagesClearsItsProgress(t *testing.T) {
|
||||
var got []Status
|
||||
in := &Installer{Host: discovery.Host{OSID: "ubuntu"}, OnStatus: func(s Status) { got = append(got, s) },
|
||||
Run: func(_ context.Context, _ []string, name string, _ ...string) ([]byte, error) {
|
||||
if name == "sh" { // the query: not installed
|
||||
return nil, context.Canceled
|
||||
}
|
||||
return nil, nil
|
||||
}}
|
||||
if err := in.InstallPackages(context.Background(), map[string][]string{"deb": {"munge"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) < 2 || got[0].State != Installing || got[len(got)-1] != (Status{}) {
|
||||
t.Fatalf("statuses %+v", got)
|
||||
}
|
||||
|
||||
got = nil
|
||||
in.Run = func(context.Context, []string, string, ...string) ([]byte, error) { return nil, nil }
|
||||
if err := in.InstallPackages(context.Background(), map[string][]string{"deb": {"munge"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("installed already, yet said %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -126,12 +126,13 @@ type Recipe struct {
|
||||
// from PyPI by uv, which is itself installed like llama.cpp: a GitHub release
|
||||
// checked against its published checksum.
|
||||
var Recipes = map[string]Recipe{
|
||||
"llamacpp": {Repo: "ggml-org/llama.cpp", Candidates: llamaCppCandidates},
|
||||
"vllm": {PyPI: "vllm", Candidates: vllmCandidates},
|
||||
"sglang": {PyPI: "sglang", Candidates: sglangCandidates},
|
||||
"comfyui": {Candidates: comfyuiCandidates},
|
||||
"strata": {Repo: StrataRepo, Candidates: strataCandidates},
|
||||
"uv": {Repo: "astral-sh/uv", Candidates: uvCandidates},
|
||||
"llamacpp": {Repo: "ggml-org/llama.cpp", Candidates: llamaCppCandidates},
|
||||
"vllm": {PyPI: "vllm", Candidates: vllmCandidates},
|
||||
"sglang": {PyPI: "sglang", Candidates: sglangCandidates},
|
||||
"comfyui": {Candidates: comfyuiCandidates},
|
||||
"strata": {Repo: StrataRepo, Candidates: strataCandidates},
|
||||
"freetoken": {PyPI: "freetoken", Candidates: freetokenCandidates},
|
||||
"uv": {Repo: "astral-sh/uv", Candidates: uvCandidates},
|
||||
}
|
||||
|
||||
// Installable are the engines this agent can install, as it tells the
|
||||
@@ -758,3 +759,56 @@ print("strata version", "` + version + `", "engine", "` + rel.engine + `")`},
|
||||
{build("cuda12"), gpuNeeds{575, 6.0}, "Strata " + version + " built for CUDA 12.9"},
|
||||
}, h)
|
||||
}
|
||||
|
||||
// --- FreeToken ---
|
||||
|
||||
// FreeTokenRepo is where FreeToken is developed; its builds are on PyPI.
|
||||
const FreeTokenRepo = "FlashML-org/FreeToken"
|
||||
|
||||
// freetokenMinCompute is the oldest GPU FreeToken runs on. It says RTX 30
|
||||
// and newer, and its kernels agree: on a Quadro RTX 6000 (7.5) version 0.2.1
|
||||
// loaded gpt-oss-20b and failed its first prefill with no kernel image for
|
||||
// the card, and in fp16 with fused experts its first request hit an illegal
|
||||
// memory access. Its FP8 kernels need 8.9 and its NVFP4 ones 12.0, which
|
||||
// the model's quantisation decides.
|
||||
const freetokenMinCompute = 8.0
|
||||
|
||||
// freetokenToolkit finds the CUDA toolkit FreeToken compiles its kernels
|
||||
// with; a variable for tests.
|
||||
var freetokenToolkit = func() string { return discovery.CUDAToolkit(13) }
|
||||
|
||||
// freetokenCandidates are the FreeToken builds for a machine: its PyPI
|
||||
// wheels with the kernel extras (FlashInfer, SGLang's kernels), CUDA 13,
|
||||
// x86-64 only. A node of older cards is not one it is meant for, and is not
|
||||
// reported as a fault; one of new cards without the toolkit is.
|
||||
func freetokenCandidates(version, variant string, h discovery.Host) ([]Build, error) {
|
||||
if _, err := gpuArch("FreeToken", h); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.Arch != "amd64" {
|
||||
return nil, NotHere{"FreeToken publishes wheels for x86-64 only; this node is " + h.Arch}
|
||||
}
|
||||
if cc := h.MinComputeCapability; cc > 0 && cc < freetokenMinCompute {
|
||||
return nil, NotHere{fmt.Sprintf("FreeToken runs on GPUs of compute capability %.1f and newer "+
|
||||
"(RTX 30, A100 and later); this node has %.1f", freetokenMinCompute, cc)}
|
||||
}
|
||||
// It compiles kernels the first time a model loads, with nvcc, as its
|
||||
// own install guide says; without a toolkit the installation looks
|
||||
// finished and the first replica fails.
|
||||
if freetokenToolkit() == "" {
|
||||
return nil, fmt.Errorf("FreeToken compiles its kernels where it runs and needs the CUDA 13 toolkit's nvcc " +
|
||||
"(in CUDA_HOME, /usr/local/cuda or /usr/local/cuda-13.*); install cuda-toolkit-13 from NVIDIA's repository")
|
||||
}
|
||||
b := Build{
|
||||
Variant: "cu13",
|
||||
Python: pythonForEngines,
|
||||
Requirements: []string{"freetoken[accel]==" + version},
|
||||
Binary: "ft",
|
||||
Packages: compilerPackages,
|
||||
FreeGB: 20,
|
||||
GPUCheck: true,
|
||||
}
|
||||
return pick("FreeToken "+version, variant, []candidate{
|
||||
{b, gpuNeeds{minDriverForCUDA13, freetokenMinCompute}, "FreeToken " + version},
|
||||
}, h)
|
||||
}
|
||||
@@ -94,3 +94,35 @@ func TestWithNoCacheConfiguredNothingIsDeleted(t *testing.T) {
|
||||
t.Errorf("an agent with no cache directory deleted something: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A model of several files is fetched into a directory of its own, and an
|
||||
// unused one goes whole: it was refused as not empty, and nothing was freed.
|
||||
func TestAnUnusedSetIsRemovedWhole(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := agentWithCache(t, dir)
|
||||
set := weights.CachePathFor(dir, "https://mirror/coder/v1")
|
||||
if err := os.MkdirAll(set, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write(t, filepath.Join(set, "model-00001-of-00002.gguf"))
|
||||
write(t, filepath.Join(set, ".complete"))
|
||||
// A link in the cache is taken away, not what it points at.
|
||||
target := t.TempDir()
|
||||
write(t, filepath.Join(target, "keep.gguf"))
|
||||
link := weights.CachePathFor(dir, "https://mirror/linked/v1")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a.removeWeights([]string{set, link})
|
||||
|
||||
if _, err := os.Stat(set); !os.IsNotExist(err) {
|
||||
t.Errorf("the set is still there: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(link); !os.IsNotExist(err) {
|
||||
t.Errorf("the link is still there: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(target, "keep.gguf")); err != nil {
|
||||
t.Errorf("what the link pointed at was deleted: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||
)
|
||||
|
||||
// FeatureSlurm is the agent keeping its node's part in Slurm (BACKLOG P).
|
||||
const FeatureSlurm = "slurm"
|
||||
|
||||
// slurmMarker says llmgw started Slurm's daemons on this machine.
|
||||
var slurmMarker = "/var/lib/llmgw-agent/slurm"
|
||||
|
||||
// setSlurmOut is where Slurm's status goes now: this session's queue, or
|
||||
// nowhere between sessions.
|
||||
func (a *Agent) setSlurmOut(out chan<- *llmgwv1.AgentMessage) {
|
||||
a.mu.Lock()
|
||||
a.slurmOut = out
|
||||
a.mu.Unlock()
|
||||
if out != nil {
|
||||
a.slurm.Resend()
|
||||
}
|
||||
}
|
||||
|
||||
// sendSlurm queues a status without waiting: a full queue or no session
|
||||
// drops it, and the next session is sent it again.
|
||||
func (a *Agent) sendSlurm(st *llmgwv1.SlurmStatus) {
|
||||
a.mu.Lock()
|
||||
out := a.slurmOut
|
||||
a.mu.Unlock()
|
||||
if out == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case out <- &llmgwv1.AgentMessage{Msg: &llmgwv1.AgentMessage_SlurmStatus{SlurmStatus: st}}:
|
||||
default:
|
||||
a.log.Warn("Slurm's status not sent: the queue to the control plane is full")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package slurmnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/slurm"
|
||||
)
|
||||
|
||||
// cacheDir holds what is fetched for Slurm: enroot's packages, pyxis's
|
||||
// source and its build.
|
||||
const cacheDir = "/var/cache/llmgw-agent/slurm"
|
||||
|
||||
// pyxisMarker is the pyxis version built here, against this Slurm.
|
||||
func (k *Keeper) pyxisMarker() string { return k.marker + "-pyxis" }
|
||||
|
||||
// containers has enroot and pyxis here, set up, when want says so; what
|
||||
// is there is left when it no longer does (Slurm stops loading pyxis once
|
||||
// the controller's plugstack.conf goes). Answers the status line, empty
|
||||
// while either is missing.
|
||||
func (k *Keeper) containers(ctx context.Context, want *llmgwv1.Slurm) (string, error) {
|
||||
if !want.GetContainers() {
|
||||
return "", nil
|
||||
}
|
||||
family := k.family()
|
||||
if family == "" {
|
||||
return "", fmt.Errorf("neither dpkg nor rpm here: enroot and pyxis come as deb or rpm")
|
||||
}
|
||||
if err := k.enroot(ctx, family); err != nil {
|
||||
return "", fmt.Errorf("enroot: %w", err)
|
||||
}
|
||||
if err := k.enrootConf(ctx, want.GetShared().GetPath()); err != nil {
|
||||
return "", fmt.Errorf("enroot's settings: %w", err)
|
||||
}
|
||||
if _, err := k.host.LookPath("nvidia-smi"); err == nil {
|
||||
if err := k.nvidiaContainer(ctx, family); err != nil {
|
||||
return "", fmt.Errorf("the GPUs in containers (nvidia-container-cli): %w", err)
|
||||
}
|
||||
}
|
||||
if err := k.pyxis(ctx, family); err != nil {
|
||||
return "", fmt.Errorf("pyxis: %w", err)
|
||||
}
|
||||
return slurm.ContainersStatus(), nil
|
||||
}
|
||||
|
||||
func (k *Keeper) family() string {
|
||||
if _, err := k.host.LookPath("dpkg"); err == nil {
|
||||
return "deb"
|
||||
}
|
||||
if _, err := k.host.LookPath("rpm"); err == nil {
|
||||
return "rpm"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// enroot installs the pinned release when another, or none, is here.
|
||||
func (k *Keeper) enroot(ctx context.Context, family string) error {
|
||||
if k.installed["enroot"] {
|
||||
return nil
|
||||
}
|
||||
if out, err := k.host.Run(ctx, nil, "enroot", "version"); err == nil && strings.TrimSpace(string(out)) == slurm.EnrootVersion {
|
||||
k.installed["enroot"] = true
|
||||
return nil
|
||||
}
|
||||
pkgs, err := slurm.EnrootPackages(family, runtime.GOARCH)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if k.inst == nil {
|
||||
return fmt.Errorf("enroot missing, and this agent installs nothing")
|
||||
}
|
||||
var files []string
|
||||
for _, p := range pkgs {
|
||||
f := filepath.Join(cacheDir, p.Name)
|
||||
if err := k.host.Download(ctx, p.URL(), f, p.SHA256); err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
k.log.Info("installing enroot, for Slurm's jobs in containers", "version", slurm.EnrootVersion)
|
||||
if err := k.inst.InstallPackages(ctx, map[string][]string{family: files}); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := k.host.Run(ctx, nil, "enroot", "version")
|
||||
if v := strings.TrimSpace(string(out)); err != nil || v != slurm.EnrootVersion {
|
||||
return fmt.Errorf("enroot says %q after installing %s: %v", v, slurm.EnrootVersion, err)
|
||||
}
|
||||
k.installed["enroot"] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// enrootConf writes enroot's settings and makes its directories.
|
||||
func (k *Keeper) enrootConf(ctx context.Context, shared string) error {
|
||||
if _, err := k.write(slurm.EnrootConfPath, []byte(slurm.EnrootConf), 0o644, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
changed, err := k.write(slurm.EnrootTmpfiles, []byte(slurm.EnrootTmpfilesConf), 0o644, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
if out, err := k.host.Run(ctx, nil, "systemd-tmpfiles", "--create", slurm.EnrootTmpfiles); err != nil {
|
||||
return fmt.Errorf("systemd-tmpfiles: %v: %s", err, lastLine(out))
|
||||
}
|
||||
}
|
||||
for _, d := range []string{slurm.EnrootLib + "/cache", slurm.EnrootLib + "/data"} {
|
||||
if _, err := k.host.Run(ctx, nil, "test", "-d", d); err == nil {
|
||||
continue
|
||||
}
|
||||
// Each user makes their own beneath, as in /tmp.
|
||||
if out, err := k.host.Run(ctx, nil, "install", "-d", "-m", "1777", d); err != nil {
|
||||
return fmt.Errorf("making %s: %v: %s", d, err, lastLine(out))
|
||||
}
|
||||
}
|
||||
if shared == "" {
|
||||
return k.host.Remove(slurm.EnrootMounts)
|
||||
}
|
||||
_, err = k.write(slurm.EnrootMounts, []byte(slurm.EnrootSharedMount(shared)), 0o644, "")
|
||||
return err
|
||||
}
|
||||
|
||||
// nvidiaContainer installs nvidia-container-cli, which enroot's hook uses
|
||||
// to give a container the job's GPUs, from NVIDIA's repository.
|
||||
func (k *Keeper) nvidiaContainer(ctx context.Context, family string) error {
|
||||
if k.installed["nvidia-container-cli"] {
|
||||
return nil
|
||||
}
|
||||
if _, err := k.host.LookPath("nvidia-container-cli"); err == nil {
|
||||
k.installed["nvidia-container-cli"] = true
|
||||
return nil
|
||||
}
|
||||
if k.inst == nil {
|
||||
return fmt.Errorf("nvidia-container-cli missing, and this agent installs nothing")
|
||||
}
|
||||
switch family {
|
||||
case "deb":
|
||||
if err := k.host.Download(ctx, slurm.NvidiaContainerKeyURL, slurm.NvidiaContainerKeyPath,
|
||||
slurm.NvidiaContainerKeySHA256); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := k.write(slurm.NvidiaContainerAptList, []byte(slurm.NvidiaContainerApt), 0o644, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
case "rpm":
|
||||
if _, err := k.write(slurm.NvidiaContainerYumRepo, []byte(slurm.NvidiaContainerYum), 0o644, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
k.log.Info("installing nvidia-container-cli, so containers get the job's GPUs")
|
||||
if err := k.inst.InstallPackages(ctx, slurm.NvidiaContainerPackages); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := k.host.LookPath("nvidia-container-cli"); err != nil {
|
||||
return fmt.Errorf("still missing after installing libnvidia-container-tools")
|
||||
}
|
||||
k.installed["nvidia-container-cli"] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// pyxis builds the pinned pyxis against this machine's Slurm and installs
|
||||
// it, when it has not been, or was for another version.
|
||||
func (k *Keeper) pyxis(ctx context.Context, family string) error {
|
||||
if b, err := k.host.ReadFile(k.pyxisMarker()); err == nil && strings.TrimSpace(string(b)) == slurm.PyxisVersion {
|
||||
if _, err := k.host.Run(ctx, nil, "test", "-f", slurm.PyxisPlugin); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if k.inst == nil {
|
||||
return fmt.Errorf("pyxis missing, and this agent installs nothing")
|
||||
}
|
||||
if err := k.inst.InstallPackages(ctx, map[string][]string{family: slurm.PyxisBuildPackages[family]}); err != nil {
|
||||
return fmt.Errorf("installing what builds it: %w", err)
|
||||
}
|
||||
tarball := filepath.Join(cacheDir, "pyxis-"+slurm.PyxisVersion+".tar.gz")
|
||||
if err := k.host.Download(ctx, slurm.PyxisSource, tarball, slurm.PyxisSHA256); err != nil {
|
||||
return err
|
||||
}
|
||||
src := filepath.Join(cacheDir, "pyxis-"+slurm.PyxisVersion)
|
||||
k.log.Info("building pyxis against this machine's Slurm", "version", slurm.PyxisVersion)
|
||||
for _, c := range [][]string{
|
||||
{"rm", "-rf", src},
|
||||
{"tar", "-xzf", tarball, "-C", cacheDir},
|
||||
{"make", "-C", src, "install"},
|
||||
} {
|
||||
if out, err := k.host.Run(ctx, nil, c[0], c[1:]...); err != nil {
|
||||
return fmt.Errorf("%s: %v: %s", strings.Join(c, " "), err, lastLine(out))
|
||||
}
|
||||
}
|
||||
if _, err := k.host.Run(ctx, nil, "test", "-f", slurm.PyxisPlugin); err != nil {
|
||||
return fmt.Errorf("built, yet %s is not there", slurm.PyxisPlugin)
|
||||
}
|
||||
_, err := k.write(k.pyxisMarker(), []byte(slurm.PyxisVersion+"\n"), 0o644, "")
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package slurmnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/slurm"
|
||||
)
|
||||
|
||||
func TestContainersInstalledFromPinnedReleases(t *testing.T) {
|
||||
if _, err := slurm.EnrootPackages("deb", runtime.GOARCH); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
h := newFake()
|
||||
h.bins["dpkg"], h.bins["nvidia-smi"] = true, true
|
||||
inst := &fakeInst{host: h}
|
||||
k := New(h, inst, "/var/lib/llmgw-agent/slurm", nil)
|
||||
// enroot is not here until installed, and then says its version.
|
||||
h.fail["enroot version"] = true
|
||||
h.fail["test -d "+slurm.EnrootLib+"/cache"] = true
|
||||
h.fail["test -f "+slurm.PyxisPlugin] = true
|
||||
m := nodeMsg(true)
|
||||
m.Containers = true
|
||||
m.Shared = &llmgwv1.SlurmShared{Path: "/shared", Nfs: "10.0.0.11:/shared"}
|
||||
|
||||
st := k.Reconcile(context.Background(), m)
|
||||
if st.GetContainers() != "" || !strings.Contains(st.GetError(), "containers: enroot") {
|
||||
t.Fatalf("ready with enroot saying nothing: %v", st)
|
||||
}
|
||||
pkgs, _ := slurm.EnrootPackages("deb", runtime.GOARCH)
|
||||
for _, p := range pkgs {
|
||||
if !slices.Contains(h.ran, "download "+p.URL()+" "+cacheDir+"/"+p.Name+" "+p.SHA256) {
|
||||
t.Errorf("%s not fetched with its digest:\n%s", p.Name, strings.Join(h.ran, "\n"))
|
||||
}
|
||||
}
|
||||
if len(inst.got["deb"]) != 2 || !strings.HasPrefix(inst.got["deb"][0], cacheDir) {
|
||||
t.Errorf("installed %v", inst.got)
|
||||
}
|
||||
|
||||
delete(h.fail, "enroot version")
|
||||
h.outputs["enroot version"] = slurm.EnrootVersion + "\n"
|
||||
h.bins["nvidia-container-cli"] = false
|
||||
st = k.Reconcile(context.Background(), m)
|
||||
if !strings.Contains(st.GetError(), "nvidia-container-cli") {
|
||||
t.Errorf("a GPU machine without nvidia-container-cli is ready: %v", st)
|
||||
}
|
||||
if string(h.files[slurm.NvidiaContainerAptList]) != slurm.NvidiaContainerApt ||
|
||||
!slices.Contains(h.ran, "download "+slurm.NvidiaContainerKeyURL+" "+slurm.NvidiaContainerKeyPath+" "+slurm.NvidiaContainerKeySHA256) {
|
||||
t.Errorf("NVIDIA's repository not added with its key checked")
|
||||
}
|
||||
|
||||
h.bins["nvidia-container-cli"] = true
|
||||
st = k.Reconcile(context.Background(), m)
|
||||
// Built, but the plugin is still not there.
|
||||
if st.GetContainers() != "" || !strings.Contains(st.GetError(), "pyxis") {
|
||||
t.Fatalf("status %v", st)
|
||||
}
|
||||
for _, want := range []string{"tar -xzf " + cacheDir + "/pyxis-" + slurm.PyxisVersion + ".tar.gz -C " + cacheDir,
|
||||
"make -C " + cacheDir + "/pyxis-" + slurm.PyxisVersion + " install", "install -d -m 1777 " + slurm.EnrootLib + "/cache"} {
|
||||
if !slices.Contains(h.ran, want) {
|
||||
t.Errorf("did not run %q", want)
|
||||
}
|
||||
}
|
||||
delete(h.fail, "test -f "+slurm.PyxisPlugin)
|
||||
st = k.Reconcile(context.Background(), m)
|
||||
if st.GetContainers() != slurm.ContainersStatus() || st.GetError() != "" {
|
||||
t.Fatalf("status %v", st)
|
||||
}
|
||||
if string(h.files[slurm.EnrootConfPath]) != slurm.EnrootConf ||
|
||||
!strings.Contains(string(h.files[slurm.EnrootMounts]), "/shared /shared none") {
|
||||
t.Errorf("enroot's settings not written")
|
||||
}
|
||||
|
||||
// Done: nothing fetched or built again.
|
||||
h.ran = nil
|
||||
k.Reconcile(context.Background(), m)
|
||||
for _, c := range h.ran {
|
||||
if strings.HasPrefix(c, "download") || strings.HasPrefix(c, "make") {
|
||||
t.Errorf("ran %q again", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Off: nothing said, nothing taken away.
|
||||
m.Containers = false
|
||||
if st := k.Reconcile(context.Background(), m); st.GetContainers() != "" {
|
||||
t.Errorf("status %v", st)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package slurmnode
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/slurm"
|
||||
)
|
||||
|
||||
// mariaDBConfDirs are where each family's MariaDB reads its settings: Debian
|
||||
// and Ubuntu's, then Red Hat's.
|
||||
var mariaDBConfDirs = []string{"/etc/mysql/mariadb.conf.d", "/etc/my.cnf.d"}
|
||||
|
||||
const mariaDBConfName = "90-llmgw-slurm.cnf"
|
||||
|
||||
// acctSQLPath is where the database's statements are put for the client to
|
||||
// read, never on its command line: they hold the password.
|
||||
const acctSQLPath = "/run/llmgw-slurm-acct.sql"
|
||||
|
||||
// accounting keeps slurmdbd on the controller: MariaDB on this machine
|
||||
// alone, the database and its user, slurmdbd on them, and the cluster in it.
|
||||
// Before slurmctld, which reports to it from its first start.
|
||||
func (k *Keeper) accounting(ctx context.Context, key []byte, files map[string]string) error {
|
||||
password := slurm.DbdPassword(key)
|
||||
changed, err := k.mariaDBSettings(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case changed:
|
||||
k.log.Info("MariaDB, for Slurm's accounting, starts on this machine alone")
|
||||
if err := k.systemctl(ctx, "enable", "--now", "mariadb", "&&restart"); err != nil {
|
||||
return err
|
||||
}
|
||||
case !k.active(ctx, "mariadb"):
|
||||
if err := k.systemctl(ctx, "enable", "--now", "mariadb"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
client, err := k.mariaDBClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := k.waitFor(ctx, "MariaDB", client, "-e", "SELECT 1"); err != nil {
|
||||
return err
|
||||
}
|
||||
// The database and its user, once each time the agent starts or the
|
||||
// password is new: cheap, and a database restored from elsewhere gets
|
||||
// its user back.
|
||||
sum := sha256.Sum256([]byte(password))
|
||||
if mark := hex.EncodeToString(sum[:]); k.acctDone != mark {
|
||||
if err := k.host.WriteFile(acctSQLPath, []byte(slurm.AcctSQL(password)), 0o600, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := k.host.Run(ctx, nil, client, "-e", "source "+acctSQLPath)
|
||||
_ = k.host.Remove(acctSQLPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("making Slurm's database: %v: %s", err, lastLine(out))
|
||||
}
|
||||
k.acctDone = mark
|
||||
}
|
||||
|
||||
out, err := k.host.Run(ctx, nil, "hostname", "-s")
|
||||
host := strings.TrimSpace(string(out))
|
||||
if err != nil || host == "" {
|
||||
return fmt.Errorf("this machine's short hostname: %v: %s", err, lastLine(out))
|
||||
}
|
||||
changed, err = k.write(slurm.DbdConfPath, []byte(slurm.DbdConf(host, password)), 0o600, "slurm")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case changed:
|
||||
k.log.Info("slurmdbd starts again on new settings")
|
||||
if err := k.systemctl(ctx, "enable", "--now", "slurmdbd", "&&restart"); err != nil {
|
||||
return err
|
||||
}
|
||||
case !k.active(ctx, "slurmdbd"):
|
||||
if err := k.systemctl(ctx, "enable", "--now", "slurmdbd"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := k.waitFor(ctx, "slurmdbd", "sacctmgr", "-n", "-P", "show", "cluster", "format=cluster"); err != nil {
|
||||
return err
|
||||
}
|
||||
name := clusterName(files["slurm.conf"])
|
||||
if name == "" || k.clusterAdded == name {
|
||||
return nil
|
||||
}
|
||||
out, err = k.host.Run(ctx, nil, "sacctmgr", "-n", "-P", "show", "cluster", "format=cluster")
|
||||
if err != nil {
|
||||
return fmt.Errorf("sacctmgr show cluster: %v: %s", err, lastLine(out))
|
||||
}
|
||||
for _, l := range strings.Split(string(out), "\n") {
|
||||
if strings.TrimSpace(l) == name {
|
||||
k.clusterAdded = name
|
||||
return nil
|
||||
}
|
||||
}
|
||||
k.log.Info("the cluster is added to Slurm's accounting", "cluster", name)
|
||||
if out, err := k.host.Run(ctx, nil, "sacctmgr", "-i", "add", "cluster", name); err != nil {
|
||||
return fmt.Errorf("sacctmgr add cluster %s: %v: %s", name, err, lastLine(out))
|
||||
}
|
||||
k.clusterAdded = name
|
||||
return nil
|
||||
}
|
||||
|
||||
// mariaDBSettings keeps MariaDB to this machine, when llmgw installed it;
|
||||
// one that was here before llmgw keeps its own settings, and only gets the
|
||||
// database and its user.
|
||||
func (k *Keeper) mariaDBSettings(ctx context.Context) (bool, error) {
|
||||
if _, err := k.host.ReadFile(k.mariaDBMarker()); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
for _, d := range mariaDBConfDirs {
|
||||
if _, err := k.host.Run(ctx, nil, "test", "-d", d); err == nil {
|
||||
return k.write(d+"/"+mariaDBConfName, []byte(slurm.MariaDBConf), 0o644, "")
|
||||
}
|
||||
}
|
||||
return false, fmt.Errorf("MariaDB has none of %s to put its settings in", strings.Join(mariaDBConfDirs, ", "))
|
||||
}
|
||||
|
||||
// mariaDBMarker is there when llmgw installed MariaDB on this machine.
|
||||
func (k *Keeper) mariaDBMarker() string { return k.marker + "-mariadb" }
|
||||
|
||||
// mariaDBClient is MariaDB's command-line client, under either of its
|
||||
// names.
|
||||
func (k *Keeper) mariaDBClient() (string, error) {
|
||||
for _, c := range []string{"mariadb", "mysql"} {
|
||||
if _, err := k.host.LookPath(c); err == nil {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("MariaDB's client (mariadb or mysql) is not installed")
|
||||
}
|
||||
|
||||
// waitFor runs a command until it succeeds, half a minute at most.
|
||||
func (k *Keeper) waitFor(ctx context.Context, what, name string, args ...string) error {
|
||||
var last []byte
|
||||
for range 30 {
|
||||
out, err := k.host.Run(ctx, nil, name, args...)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
last = out
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(pingWait):
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s did not answer after starting: %s", what, lastLine(last))
|
||||
}
|
||||
|
||||
// clusterName is ClusterName in slurm.conf.
|
||||
func clusterName(conf string) string {
|
||||
sc := bufio.NewScanner(strings.NewReader(conf))
|
||||
for sc.Scan() {
|
||||
if v, ok := strings.CutPrefix(strings.TrimSpace(sc.Text()), "ClusterName="); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package slurmnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/slurm"
|
||||
)
|
||||
|
||||
// What a machine says of the shared path.
|
||||
const (
|
||||
SharedMounted = "mounted"
|
||||
SharedExported = "exported"
|
||||
SharedMissing = "missing"
|
||||
)
|
||||
|
||||
// shared keeps the shared path as want says: exported from here, mounted
|
||||
// from an export, or looked for where the site mounts it. Answers how it
|
||||
// is.
|
||||
func (k *Keeper) shared(ctx context.Context, want *llmgwv1.Slurm) (string, error) {
|
||||
sh := want.GetShared()
|
||||
path := sh.GetPath()
|
||||
k.unshare(ctx, path, sh.GetServe(), sh.GetNfs())
|
||||
if path == "" {
|
||||
return "", nil
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") || strings.Contains(path, "..") || strings.ContainsAny(path, " \n\t\"'\\$`") {
|
||||
return SharedMissing, fmt.Errorf("the leader sent a path %q", path)
|
||||
}
|
||||
switch {
|
||||
case sh.GetServe():
|
||||
if err := k.host.MkdirAll(path, 0o755, ""); err != nil {
|
||||
return SharedMissing, err
|
||||
}
|
||||
changed, err := k.write(slurm.ExportsPath, []byte(slurm.Exports(path, sh.GetClients())), 0o644, "")
|
||||
if err != nil {
|
||||
return SharedMissing, err
|
||||
}
|
||||
if !k.active(ctx, "nfs-server") {
|
||||
k.log.Info("NFS starts on this machine, exporting the Slurm cluster's shared files", "path", path)
|
||||
if err := k.systemctl(ctx, "enable", "--now", "nfs-server"); err != nil {
|
||||
return SharedMissing, err
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
if out, err := k.host.Run(ctx, nil, "exportfs", "-ra"); err != nil {
|
||||
return SharedMissing, fmt.Errorf("exportfs -ra: %v: %s", err, lastLine(out))
|
||||
}
|
||||
}
|
||||
return SharedExported, nil
|
||||
case sh.GetNfs() != "":
|
||||
nfs := sh.GetNfs()
|
||||
if strings.ContainsAny(nfs, " \n\t\"'\\$`") || !strings.Contains(nfs, ":/") {
|
||||
return SharedMissing, fmt.Errorf("the leader sent an export %q", nfs)
|
||||
}
|
||||
if err := k.host.MkdirAll(path, 0o755, ""); err != nil {
|
||||
return SharedMissing, err
|
||||
}
|
||||
unit := slurm.MountUnitName(path)
|
||||
changed, err := k.write(filepath.Join(slurm.MountUnitDir, unit), []byte(slurm.MountUnit(nfs, path)), 0o644, "")
|
||||
if err != nil {
|
||||
return SharedMissing, err
|
||||
}
|
||||
if changed {
|
||||
k.log.Info("the Slurm cluster's shared files are mounted here", "from", nfs, "at", path)
|
||||
if err := k.systemctl(ctx, "daemon-reload"); err != nil {
|
||||
return SharedMissing, err
|
||||
}
|
||||
if err := k.systemctl(ctx, "enable", unit); err != nil {
|
||||
return SharedMissing, err
|
||||
}
|
||||
// From another export: a restart mounts the new one, and fails
|
||||
// while a job still has files open on the old.
|
||||
if err := k.systemctl(ctx, "restart", unit); err != nil {
|
||||
return SharedMissing, err
|
||||
}
|
||||
} else if !k.active(ctx, unit) {
|
||||
if err := k.systemctl(ctx, "start", unit); err != nil {
|
||||
return SharedMissing, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := k.host.Run(ctx, nil, "mountpoint", "-q", path); err != nil {
|
||||
if sh.GetNfs() == "" {
|
||||
return SharedMissing, fmt.Errorf("%s is not mounted here: the site mounts it (slurm.shared names no export)", path)
|
||||
}
|
||||
return SharedMissing, fmt.Errorf("%s is not mounted from %s", path, sh.GetNfs())
|
||||
}
|
||||
return SharedMounted, nil
|
||||
}
|
||||
|
||||
// unshare takes away the export or the mount llmgw kept here that want no
|
||||
// longer has: path is where the files are wanted now, serve and nfs how.
|
||||
func (k *Keeper) unshare(ctx context.Context, path string, serve bool, nfs string) {
|
||||
if _, err := k.host.ReadFile(slurm.ExportsPath); err == nil && (!serve || path == "") {
|
||||
k.log.Info("this machine no longer exports the Slurm cluster's shared files")
|
||||
_ = k.host.Remove(slurm.ExportsPath)
|
||||
_, _ = k.host.Run(ctx, nil, "exportfs", "-ra")
|
||||
}
|
||||
out, err := k.host.Run(ctx, nil, "systemctl", "list-units", "--all", "--plain", "--no-legend", "--type=mount")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
keep := ""
|
||||
if path != "" && nfs != "" && !serve {
|
||||
keep = slurm.MountUnitName(path)
|
||||
}
|
||||
for _, l := range strings.Split(string(out), "\n") {
|
||||
f := strings.Fields(l)
|
||||
if len(f) == 0 || f[0] == keep {
|
||||
continue
|
||||
}
|
||||
file := filepath.Join(slurm.MountUnitDir, f[0])
|
||||
b, err := k.host.ReadFile(file)
|
||||
if err != nil || !strings.HasPrefix(string(b), "# Written by llmgw (BACKLOG P): the Slurm cluster's shared files.") {
|
||||
continue
|
||||
}
|
||||
k.log.Info("the Slurm cluster's old shared mount is taken away", "unit", f[0])
|
||||
if err := k.systemctl(ctx, "disable", "--now", f[0]); err != nil {
|
||||
k.log.Warn("could not unmount the old shared files", "unit", f[0], "err", err)
|
||||
continue
|
||||
}
|
||||
_ = k.host.Remove(file)
|
||||
_ = k.systemctl(ctx, "daemon-reload")
|
||||
}
|
||||
}
|
||||
|
||||
// sharedPackages are what the shared path needs here, by the command that
|
||||
// shows each is installed.
|
||||
func sharedPackages(want *llmgwv1.Slurm, need map[string][2]string) {
|
||||
switch sh := want.GetShared(); {
|
||||
case sh.GetPath() == "":
|
||||
case sh.GetServe():
|
||||
need["exportfs"] = [2]string{"nfs-kernel-server", "nfs-utils"}
|
||||
case sh.GetNfs() != "":
|
||||
need["mount.nfs"] = [2]string{"nfs-common", "nfs-utils"}
|
||||
}
|
||||
}
|
||||
|
||||
// Serves is whether this machine exports the shared files, so NFS's port
|
||||
// is kept for the cluster's networks.
|
||||
func (k *Keeper) Serves() bool {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
return k.want.GetShared().GetServe() && k.want.GetShared().GetPath() != ""
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package slurmnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/slurm"
|
||||
)
|
||||
|
||||
func ayse() *llmgwv1.SlurmUser {
|
||||
return &llmgwv1.SlurmUser{Login: "ayse", Uid: 200000, Name: "Ayşe Y.", Home: "/shared/home/ayse",
|
||||
SshKeys: []string{"ssh-ed25519 AAAA ayse@laptop"}}
|
||||
}
|
||||
|
||||
func TestTheServerExportsAndMakesUsersWithHomes(t *testing.T) {
|
||||
h := newFake()
|
||||
h.active["ssh"] = true
|
||||
inst := &fakeInst{host: h}
|
||||
k := New(h, inst, "/var/lib/llmgw-agent/slurm", nil)
|
||||
m := nodeMsg(false)
|
||||
m.Shared = &llmgwv1.SlurmShared{Path: "/shared", Serve: true, Clients: []string{"10.0.0.22", "10.0.0.21"}}
|
||||
m.ManageUsers, m.MakeHomes, m.Users = true, true, []*llmgwv1.SlurmUser{ayse()}
|
||||
h.fail["test -d /shared/home/ayse"] = true
|
||||
h.files[slurm.PAMSSHD] = []byte(pamStack)
|
||||
st := k.Reconcile(context.Background(), m)
|
||||
if st.GetError() != "" {
|
||||
t.Fatal(st.GetError())
|
||||
}
|
||||
if st.GetShared() != SharedExported || st.GetUsers() != 1 {
|
||||
t.Fatalf("status %v", st)
|
||||
}
|
||||
if !slices.Contains(inst.got["deb"], "nfs-kernel-server") {
|
||||
t.Errorf("packages %v", inst.got)
|
||||
}
|
||||
if e := string(h.files[slurm.ExportsPath]); !strings.Contains(e, `"/shared" 10.0.0.21(`) {
|
||||
t.Errorf("exports:\n%s", e)
|
||||
}
|
||||
for _, want := range []string{"systemctl enable --now nfs-server", "exportfs -ra", "groupadd -g 200000 ayse",
|
||||
"useradd -u 200000 -g 200000 -M -d /shared/home/ayse -s /bin/bash -c Ayşe Y. ayse",
|
||||
"install -d -m 0700 -o 200000 -g 200000 /shared/home/ayse", "sshd -t", "systemctl reload ssh"} {
|
||||
if !slices.Contains(h.ran, want) {
|
||||
t.Errorf("did not run %q:\n%s", want, strings.Join(h.ran, "\n"))
|
||||
}
|
||||
}
|
||||
if string(h.files[slurm.AuthorizedKeysDir+"/ayse"]) != "ssh-ed25519 AAAA ayse@laptop\n" ||
|
||||
string(h.files[k.usersMarker()]) != "ayse\n" || h.files[slurm.SSHDropIn] == nil {
|
||||
t.Errorf("files: keys %q, marker %q", h.files[slurm.AuthorizedKeysDir+"/ayse"], h.files[k.usersMarker()])
|
||||
}
|
||||
|
||||
// Again, made: nothing is run again.
|
||||
h.outputs["getent passwd ayse"] = "ayse:x:200000:200000:Ayşe Y.:/shared/home/ayse:/bin/bash\n"
|
||||
delete(h.fail, "test -d /shared/home/ayse")
|
||||
h.ran = nil
|
||||
k.Reconcile(context.Background(), m)
|
||||
for _, c := range h.ran {
|
||||
if strings.HasPrefix(c, "useradd") || strings.HasPrefix(c, "usermod") || strings.HasPrefix(c, "exportfs") ||
|
||||
strings.HasPrefix(c, "install") || c == "systemctl reload ssh" {
|
||||
t.Errorf("ran %q again", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Gone from the accounts: taken away, its home left.
|
||||
m.Users = nil
|
||||
k.Reconcile(context.Background(), m)
|
||||
if !slices.Contains(h.ran, "userdel ayse") || h.files[k.usersMarker()] != nil || h.files[slurm.SSHDropIn] != nil ||
|
||||
h.files[slurm.AuthorizedKeysDir+"/ayse"] != nil {
|
||||
t.Errorf("not taken away:\n%s", strings.Join(h.ran, "\n"))
|
||||
}
|
||||
if slices.ContainsFunc(h.ran, func(c string) bool { return strings.HasPrefix(c, "rm") || strings.Contains(c, "userdel -r") }) {
|
||||
t.Error("a home was removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAClientMountsAndLeavesOthersUsersAlone(t *testing.T) {
|
||||
h := newFake()
|
||||
inst := &fakeInst{host: h}
|
||||
k := New(h, inst, "/var/lib/llmgw-agent/slurm", nil)
|
||||
m := nodeMsg(false)
|
||||
m.Shared = &llmgwv1.SlurmShared{Path: "/shared", Nfs: "10.0.0.11:/shared"}
|
||||
bob := &llmgwv1.SlurmUser{Login: "bob", Uid: 200001, Home: "/shared/home/bob"}
|
||||
m.ManageUsers, m.Users = true, []*llmgwv1.SlurmUser{ayse(), bob}
|
||||
// The site's own ayse, with its own UID.
|
||||
h.outputs["getent passwd ayse"] = "ayse:x:1001:1001::/home/ayse:/bin/bash\n"
|
||||
st := k.Reconcile(context.Background(), m)
|
||||
if st.GetShared() != SharedMounted || st.GetUsers() != 1 {
|
||||
t.Fatalf("status %v", st)
|
||||
}
|
||||
if !strings.Contains(st.GetError(), "ayse is already a user here with UID 1001") {
|
||||
t.Errorf("error %q", st.GetError())
|
||||
}
|
||||
if u := string(h.files["/etc/systemd/system/shared.mount"]); !strings.Contains(u, "What=10.0.0.11:/shared") {
|
||||
t.Fatalf("unit:\n%s", u)
|
||||
}
|
||||
for _, want := range []string{"systemctl enable shared.mount", "systemctl restart shared.mount", "mountpoint -q /shared",
|
||||
"useradd -u 200001 -g 200001 -M -d /shared/home/bob -s /bin/bash -c bob"} {
|
||||
if !slices.Contains(h.ran, want) {
|
||||
t.Errorf("did not run %q:\n%s", want, strings.Join(h.ran, "\n"))
|
||||
}
|
||||
}
|
||||
if slices.ContainsFunc(h.ran, func(c string) bool { return strings.Contains(c, "ayse") && !strings.HasPrefix(c, "getent") }) ||
|
||||
slices.ContainsFunc(h.ran, func(c string) bool { return strings.HasPrefix(c, "install -d") }) {
|
||||
t.Errorf("touched the site's ayse, or made a home:\n%s", strings.Join(h.ran, "\n"))
|
||||
}
|
||||
if string(h.files[k.usersMarker()]) != "bob\n" {
|
||||
t.Errorf("marker %q", h.files[k.usersMarker()])
|
||||
}
|
||||
|
||||
// Not mounted: said.
|
||||
h.fail["mountpoint -q /shared"] = true
|
||||
if st := k.Reconcile(context.Background(), m); st.GetShared() != SharedMissing ||
|
||||
!strings.Contains(st.GetError(), "not mounted from 10.0.0.11:/shared") {
|
||||
t.Errorf("status %v", st)
|
||||
}
|
||||
|
||||
// No shared path any more: the mount is taken away.
|
||||
h.outputs["systemctl list-units --all --plain --no-legend --type=mount"] = "shared.mount loaded active mounted llmgw\n"
|
||||
m.Shared = nil
|
||||
k.Reconcile(context.Background(), m)
|
||||
if !slices.Contains(h.ran, "systemctl disable --now shared.mount") || h.files["/etc/systemd/system/shared.mount"] != nil {
|
||||
t.Errorf("mount kept:\n%s", strings.Join(h.ran, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
const pamStack = "@include common-auth\naccount required pam_nologin.so\n@include common-account\n@include common-session\n"
|
||||
|
||||
func TestUsersReachANodeOnlyThroughTheirJobs(t *testing.T) {
|
||||
h := newFake()
|
||||
inst := &fakeInst{host: h}
|
||||
k := New(h, inst, "/var/lib/llmgw-agent/slurm", nil)
|
||||
h.files[slurm.PAMSSHD] = []byte(pamStack)
|
||||
// The module is not there until its package is installed.
|
||||
for _, d := range slurm.ModuleDirs {
|
||||
h.fail["test -f "+d+"/"+slurm.AdoptModule] = true
|
||||
}
|
||||
m := nodeMsg(true)
|
||||
m.ManageUsers, m.Users = true, []*llmgwv1.SlurmUser{ayse()}
|
||||
k.Reconcile(context.Background(), m)
|
||||
if !slices.Contains(inst.got["deb"], "libpam-slurm-adopt") {
|
||||
t.Fatalf("not installed: %v", inst.got)
|
||||
}
|
||||
if got := string(h.files[slurm.PAMSSHD]); got != pamStack {
|
||||
t.Fatalf("lines put in with the module still missing:\n%s", got)
|
||||
}
|
||||
delete(h.fail, "test -f "+slurm.ModuleDirs[0]+"/"+slurm.AdoptModule)
|
||||
st := k.Reconcile(context.Background(), m)
|
||||
got := string(h.files[slurm.PAMSSHD])
|
||||
if !strings.Contains(got, "pam_listfile.so item=user sense=allow file="+k.usersMarker()) ||
|
||||
!strings.Contains(got, "account required "+slurm.AdoptModule) {
|
||||
t.Fatalf("not held to their jobs (%s):\n%s", st.GetError(), got)
|
||||
}
|
||||
|
||||
// The controller is where they log in: not held there.
|
||||
m.Controller = true
|
||||
m.Files = map[string]string{"slurm.conf": "x"}
|
||||
k.Reconcile(context.Background(), m)
|
||||
if string(h.files[slurm.PAMSSHD]) != pamStack {
|
||||
t.Errorf("held on the controller:\n%s", h.files[slurm.PAMSSHD])
|
||||
}
|
||||
|
||||
// No users of llmgw's any more: the lines go.
|
||||
m.Controller, m.Files = false, nil
|
||||
k.Reconcile(context.Background(), m)
|
||||
m.Users = nil
|
||||
k.Reconcile(context.Background(), m)
|
||||
if string(h.files[slurm.PAMSSHD]) != pamStack {
|
||||
t.Errorf("lines left with no users:\n%s", h.files[slurm.PAMSSHD])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
// Package slurmnode keeps this machine's part in the Slurm cluster run beside
|
||||
// llmgw (BACKLOG P), as the leader says: slurmctld on the controller, slurmd
|
||||
// on a node, munge on both, installed from the machine's own package sources.
|
||||
//
|
||||
// What the leader sends is wanted state, kept by Keeper's loop: sent again,
|
||||
// or checked again a minute later, it changes nothing that is already so.
|
||||
package slurmnode
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/slurm"
|
||||
)
|
||||
|
||||
// Host is what Keeper does to the machine; Local in the agent, a fake in
|
||||
// tests.
|
||||
type Host interface {
|
||||
Run(ctx context.Context, env []string, name string, args ...string) ([]byte, error)
|
||||
LookPath(name string) (string, error)
|
||||
ReadFile(path string) ([]byte, error)
|
||||
// WriteFile writes b at path with mode, owned by owner ("" for root),
|
||||
// making its directory.
|
||||
WriteFile(path string, b []byte, mode fs.FileMode, owner string) error
|
||||
// MkdirAll makes path, owned by owner.
|
||||
MkdirAll(path string, mode fs.FileMode, owner string) error
|
||||
Remove(path string) error
|
||||
// Download fetches url to path, kept only if its SHA-256 is sha; a file
|
||||
// already there with it is not fetched again.
|
||||
Download(ctx context.Context, url, path, sha string) error
|
||||
}
|
||||
|
||||
// Installer installs packages by family, "deb" and "rpm".
|
||||
type Installer interface {
|
||||
InstallPackages(ctx context.Context, byFamily map[string][]string) error
|
||||
}
|
||||
|
||||
// Reasons llmgw drains a node with; one drained for another reason, by a
|
||||
// Slurm administrator, is never resumed by llmgw.
|
||||
const (
|
||||
ReasonServing = "llmgw: serving models"
|
||||
reasonPrefix = "llmgw"
|
||||
)
|
||||
|
||||
// DropIn is the unit drop-in that starts slurmd configless.
|
||||
const DropIn = "/etc/systemd/system/slurmd.service.d/llmgw.conf"
|
||||
|
||||
// Keeper keeps the machine as the last Slurm message says.
|
||||
type Keeper struct {
|
||||
host Host
|
||||
inst Installer
|
||||
log *slog.Logger
|
||||
marker string
|
||||
|
||||
mu sync.Mutex
|
||||
want *llmgwv1.Slurm
|
||||
kick chan struct{}
|
||||
// resend has the next status sent even if unchanged: a new stream's
|
||||
// control plane may never have had it.
|
||||
resend bool
|
||||
// installed is true once the packages were found or put there, so a
|
||||
// minute's check does not look again.
|
||||
installed map[string]bool
|
||||
files string
|
||||
// acctDone is the digest of the password the database's user was last
|
||||
// given; clusterAdded the cluster last found in the accounting.
|
||||
acctDone string
|
||||
clusterAdded string
|
||||
}
|
||||
|
||||
// New is a Keeper; marker is a file it keeps while it runs Slurm here, so
|
||||
// an agent started again knows to stop what it started when told it has no
|
||||
// part any more.
|
||||
func New(host Host, inst Installer, marker string, log *slog.Logger) *Keeper {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Keeper{host: host, inst: inst, log: log, marker: marker,
|
||||
kick: make(chan struct{}, 1), installed: map[string]bool{}}
|
||||
}
|
||||
|
||||
// Apply is a new message from the leader: kept from now on.
|
||||
func (k *Keeper) Apply(m *llmgwv1.Slurm) {
|
||||
k.mu.Lock()
|
||||
k.want = proto.CloneOf(m)
|
||||
k.mu.Unlock()
|
||||
select {
|
||||
case k.kick <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// HasPart is whether the last message gives this machine a part in Slurm.
|
||||
func (k *Keeper) HasPart() bool {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
return k.want.GetController() || k.want.GetNode()
|
||||
}
|
||||
|
||||
// Resend has the status sent again on the next pass, which it starts now.
|
||||
func (k *Keeper) Resend() {
|
||||
k.mu.Lock()
|
||||
k.resend = true
|
||||
k.mu.Unlock()
|
||||
select {
|
||||
case k.kick <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Every is how often the machine is checked again without a new message.
|
||||
var Every = time.Minute
|
||||
|
||||
// resendAfter is how often an unchanged status is sent anyway.
|
||||
const resendAfter = 5 * time.Minute
|
||||
|
||||
// Run keeps the machine until ctx ends, sending its status when it changes
|
||||
// and every few minutes while it has a part.
|
||||
func (k *Keeper) Run(ctx context.Context, send func(*llmgwv1.SlurmStatus)) {
|
||||
t := time.NewTicker(Every)
|
||||
defer t.Stop()
|
||||
var last *llmgwv1.SlurmStatus
|
||||
var sent time.Time
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-k.kick:
|
||||
case <-t.C:
|
||||
}
|
||||
k.mu.Lock()
|
||||
want, again := k.want, k.resend
|
||||
k.resend = false
|
||||
k.mu.Unlock()
|
||||
if want == nil {
|
||||
continue
|
||||
}
|
||||
st := k.Reconcile(ctx, want)
|
||||
if st == nil {
|
||||
last = nil
|
||||
continue
|
||||
}
|
||||
if again || !proto.Equal(st, last) || time.Since(sent) > resendAfter {
|
||||
send(st)
|
||||
last, sent = st, time.Now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile makes the machine what want says once, and answers its status;
|
||||
// nil when it has no part in Slurm.
|
||||
func (k *Keeper) Reconcile(ctx context.Context, want *llmgwv1.Slurm) *llmgwv1.SlurmStatus {
|
||||
ctx, cancel := context.WithTimeout(ctx, 20*time.Minute)
|
||||
defer cancel()
|
||||
if !want.GetController() && !want.GetNode() {
|
||||
k.stop(ctx)
|
||||
return nil
|
||||
}
|
||||
st := &llmgwv1.SlurmStatus{}
|
||||
fail := func(what string, err error) *llmgwv1.SlurmStatus {
|
||||
st.Error = what + ": " + err.Error()
|
||||
k.log.Warn("keeping Slurm on this node: "+what, "err", err)
|
||||
return st
|
||||
}
|
||||
if err := k.ensurePackages(ctx, want); err != nil {
|
||||
return fail("installing Slurm", err)
|
||||
}
|
||||
// From the daemon, not scontrol: a client command looks for its files
|
||||
// even to say its version, and a configless node has none before slurmd
|
||||
// has fetched them.
|
||||
daemon := "slurmd"
|
||||
if !want.GetNode() {
|
||||
daemon = "slurmctld"
|
||||
}
|
||||
out, err := k.host.Run(ctx, nil, daemon, "-V")
|
||||
if err != nil {
|
||||
return fail("reading Slurm's version", fmt.Errorf("%v: %s", err, lastLine(out)))
|
||||
}
|
||||
v, err := slurm.ParseVersion(string(out))
|
||||
if err != nil {
|
||||
return fail("reading Slurm's version", err)
|
||||
}
|
||||
st.Version = strings.TrimSpace(string(out))
|
||||
if i := strings.LastIndexByte(st.Version, ' '); i >= 0 {
|
||||
st.Version = st.Version[i+1:]
|
||||
}
|
||||
if slurm.TooOld(v) {
|
||||
return fail("Slurm", fmt.Errorf("%d.%02d is older than %d.%02d, the first to run jobs in cgroup v2; "+
|
||||
"a newer release of the distribution, or Slurm's own packages, has one", v[0], v[1],
|
||||
slurm.MinVersion[0], slurm.MinVersion[1]))
|
||||
}
|
||||
if err := k.host.WriteFile(k.marker, []byte("llmgw runs Slurm here (BACKLOG P)\n"), 0o644, ""); err != nil {
|
||||
return fail("keeping its marker", err)
|
||||
}
|
||||
if err := k.munge(ctx, want.GetMungeKey()); err != nil {
|
||||
return fail("munge", err)
|
||||
}
|
||||
// The shared files, then the users whose homes are on them. Neither
|
||||
// keeps Slurm from running: what goes wrong is said, and Slurm kept.
|
||||
var shErr, usErr error
|
||||
st.Shared, shErr = k.shared(ctx, want)
|
||||
if shErr != nil {
|
||||
k.log.Warn("keeping the Slurm cluster's shared files on this node", "err", shErr)
|
||||
}
|
||||
if shErr == nil || !want.GetMakeHomes() || want.GetShared().GetPath() == "" {
|
||||
st.Users, usErr = k.users(ctx, want)
|
||||
} else {
|
||||
// Homes made now would be made beside the shared files, not on them.
|
||||
usErr = errors.New("waiting for the shared files, where the homes are")
|
||||
}
|
||||
if usErr != nil {
|
||||
k.log.Warn("keeping the Slurm users on this node", "err", usErr)
|
||||
}
|
||||
// Jobs in containers; Slurm runs without them, and is kept anyway.
|
||||
var ctErr error
|
||||
if st.Containers, ctErr = k.containers(ctx, want); ctErr != nil {
|
||||
k.log.Warn("setting up jobs in containers on this node", "err", ctErr)
|
||||
}
|
||||
defer func() {
|
||||
switch {
|
||||
case st.Error != "":
|
||||
case shErr != nil:
|
||||
st.Error = "shared files: " + shErr.Error()
|
||||
case usErr != nil:
|
||||
st.Error = "users: " + usErr.Error()
|
||||
case ctErr != nil:
|
||||
st.Error = "containers: " + ctErr.Error()
|
||||
}
|
||||
}()
|
||||
if want.GetController() {
|
||||
// Accounting first: slurmctld reports to it from its first start.
|
||||
// Without it slurmctld still runs jobs, a pass of backfill late
|
||||
// each; so a failure here is said, and slurmctld is kept anyway.
|
||||
var acctErr error
|
||||
if acctErr = k.accounting(ctx, want.GetMungeKey(), want.GetFiles()); acctErr != nil {
|
||||
k.log.Warn("keeping Slurm's accounting on this node", "err", acctErr)
|
||||
}
|
||||
st.SlurmdbdRunning = k.active(ctx, "slurmdbd")
|
||||
defer func() {
|
||||
if acctErr != nil && st.Error == "" {
|
||||
st.Error = "slurmdbd: " + acctErr.Error()
|
||||
}
|
||||
}()
|
||||
sum, err := k.controller(ctx, want.GetFiles())
|
||||
st.FilesSha256 = sum
|
||||
if err != nil {
|
||||
return fail("slurmctld", err)
|
||||
}
|
||||
st.SlurmctldRunning = k.active(ctx, "slurmctld")
|
||||
}
|
||||
if want.GetNode() {
|
||||
if err := k.node(ctx, want, st); err != nil {
|
||||
return fail("slurmd", err)
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func (k *Keeper) ensurePackages(ctx context.Context, want *llmgwv1.Slurm) error {
|
||||
need := map[string][2]string{
|
||||
"munged": {"munge", "munge"},
|
||||
"scontrol": {"slurm-client", "slurm"},
|
||||
}
|
||||
if want.GetController() {
|
||||
need["slurmctld"] = [2]string{"slurmctld", "slurm-slurmctld"}
|
||||
need["slurmdbd"] = [2]string{"slurmdbd", "slurm-slurmdbd"}
|
||||
need["mariadbd"] = [2]string{"mariadb-server", "mariadb-server"}
|
||||
}
|
||||
if want.GetNode() {
|
||||
need["slurmd"] = [2]string{"slurmd", "slurm-slurmd"}
|
||||
}
|
||||
sharedPackages(want, need)
|
||||
pkgs := map[string][]string{}
|
||||
for bin, p := range need {
|
||||
if k.installed[bin] {
|
||||
continue
|
||||
}
|
||||
if _, err := k.host.LookPath(bin); err == nil {
|
||||
k.installed[bin] = true
|
||||
continue
|
||||
}
|
||||
pkgs["deb"] = append(pkgs["deb"], p[0])
|
||||
pkgs["rpm"] = append(pkgs["rpm"], p[1])
|
||||
}
|
||||
if len(pkgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
slices.Sort(pkgs["deb"])
|
||||
slices.Sort(pkgs["rpm"])
|
||||
if k.inst == nil {
|
||||
return fmt.Errorf("%s missing, and this agent installs nothing", strings.Join(pkgs["deb"], ", "))
|
||||
}
|
||||
k.log.Info("installing Slurm, as the cluster's settings say", "packages", pkgs["deb"])
|
||||
if err := k.inst.InstallPackages(ctx, pkgs); err != nil {
|
||||
return err
|
||||
}
|
||||
if slices.Contains(pkgs["deb"], "mariadb-server") {
|
||||
// Installed by llmgw: its settings are llmgw's to keep. One that was
|
||||
// here before is another's, and is left as it is.
|
||||
if err := k.host.WriteFile(k.mariaDBMarker(), []byte("llmgw installed MariaDB here, for Slurm's accounting\n"),
|
||||
0o644, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for bin := range need {
|
||||
if _, err := k.host.LookPath(bin); err != nil {
|
||||
return fmt.Errorf("%s is still missing after installing %s", bin, strings.Join(pkgs["deb"], ", "))
|
||||
}
|
||||
k.installed[bin] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// munge writes the cluster's key and keeps munged running on it.
|
||||
func (k *Keeper) munge(ctx context.Context, key []byte) error {
|
||||
if len(key) < 32 {
|
||||
return errors.New("the leader sent no key")
|
||||
}
|
||||
changed, err := k.write(slurm.MungeKeyPath, key, 0o400, "munge")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
k.log.Info("munge key written; munge starts again on it")
|
||||
return k.systemctl(ctx, "enable", "--now", "munge", "&&restart")
|
||||
}
|
||||
if !k.active(ctx, "munge") {
|
||||
return k.systemctl(ctx, "enable", "--now", "munge")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// controller writes the files and keeps slurmctld running on them,
|
||||
// answering their digest.
|
||||
func (k *Keeper) controller(ctx context.Context, files map[string]string) (string, error) {
|
||||
if files["slurm.conf"] == "" {
|
||||
return "", errors.New("the leader sent no slurm.conf")
|
||||
}
|
||||
for _, d := range []string{slurm.StateDir, slurm.LogDir} {
|
||||
if err := k.host.MkdirAll(d, 0o755, "slurm"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(files))
|
||||
for n := range files {
|
||||
names = append(names, n)
|
||||
}
|
||||
slices.Sort(names)
|
||||
h := sha256.New()
|
||||
changed := false
|
||||
for _, n := range names {
|
||||
if strings.ContainsAny(n, "/\\") || n == "" || n == "." || n == ".." {
|
||||
return "", fmt.Errorf("the leader sent a file named %q", n)
|
||||
}
|
||||
fmt.Fprintf(h, "%s\x00%s\x00", n, files[n])
|
||||
c, err := k.write(filepath.Join(slurm.ConfDir, n), []byte(files[n]), 0o644, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
changed = changed || c
|
||||
}
|
||||
sum := hex.EncodeToString(h.Sum(nil))
|
||||
if changed || sum != k.files {
|
||||
if err := k.systemctl(ctx, "enable", "slurmctld"); err != nil {
|
||||
return sum, err
|
||||
}
|
||||
// Nodes added or taken away need a restart before 23.11 and a
|
||||
// reconfigure after it; a restart does both, and running jobs live
|
||||
// through it.
|
||||
k.log.Info("slurmctld starts again on new files")
|
||||
if err := k.systemctl(ctx, "restart", "slurmctld"); err != nil {
|
||||
return sum, err
|
||||
}
|
||||
if err := k.waitPing(ctx); err != nil {
|
||||
return sum, err
|
||||
}
|
||||
// Hands the configless slurmd the new files.
|
||||
if out, err := k.host.Run(ctx, nil, "scontrol", "reconfigure"); err != nil {
|
||||
return sum, fmt.Errorf("scontrol reconfigure: %v: %s", err, lastLine(out))
|
||||
}
|
||||
k.files = sum
|
||||
return sum, nil
|
||||
}
|
||||
if !k.active(ctx, "slurmctld") {
|
||||
return sum, k.systemctl(ctx, "enable", "--now", "slurmctld")
|
||||
}
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
func (k *Keeper) waitPing(ctx context.Context) error {
|
||||
var last []byte
|
||||
for range 30 {
|
||||
out, err := k.host.Run(ctx, nil, "scontrol", "ping")
|
||||
if err == nil && bytes.Contains(out, []byte("UP")) {
|
||||
return nil
|
||||
}
|
||||
last = out
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(pingWait):
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("slurmctld did not answer after starting: %s", lastLine(last))
|
||||
}
|
||||
|
||||
var pingWait = time.Second
|
||||
|
||||
// node keeps slurmd running configless and puts the node in or out of the
|
||||
// controller's hands as lent says.
|
||||
func (k *Keeper) node(ctx context.Context, want *llmgwv1.Slurm, st *llmgwv1.SlurmStatus) error {
|
||||
name, server := want.GetName(), want.GetConfServer()
|
||||
if name == "" || server == "" {
|
||||
return errors.New("the leader sent no name or controller")
|
||||
}
|
||||
if strings.ContainsAny(name+server, " \n\t\"'\\$`") {
|
||||
return fmt.Errorf("the leader sent a name or controller with spaces or quotes in it")
|
||||
}
|
||||
for _, d := range []string{slurm.SpoolDir, slurm.LogDir} {
|
||||
if err := k.host.MkdirAll(d, 0o755, "slurm"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
bin, err := k.host.LookPath("slurmd")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unit := "# Written by llmgw (BACKLOG P): slurmd fetches its files from slurmctld.\n" +
|
||||
"[Unit]\nConditionPathExists=\nStartLimitIntervalSec=0\n" +
|
||||
// --systemd as the packaged unit has it (Type=notify, since 22.05):
|
||||
// without it systemd waits for a readiness that never comes.
|
||||
"[Service]\nExecStart=\nExecStart=" + bin + " --systemd --conf-server " + server + " -N " + name + "\n" +
|
||||
// A node new to the controller is refused until the leader has
|
||||
// written it into slurm.conf; tried again until it is.
|
||||
"Restart=on-failure\nRestartSec=15\n"
|
||||
changed, err := k.write(DropIn, []byte(unit), 0o644, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out, err := k.host.Run(ctx, nil, "slurmd", "-C"); err == nil {
|
||||
st.Hardware = strings.TrimSpace(string(out))
|
||||
}
|
||||
if changed {
|
||||
if err := k.systemctl(ctx, "daemon-reload"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := k.systemctl(ctx, "enable", "slurmd"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := k.systemctl(ctx, "restart", "slurmd"); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !k.active(ctx, "slurmd") {
|
||||
if err := k.systemctl(ctx, "enable", "--now", "slurmd"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
st.SlurmdRunning = k.active(ctx, "slurmd")
|
||||
|
||||
st.Jobs = k.localJobs(ctx)
|
||||
env := k.clientEnv()
|
||||
out, err := k.host.Run(ctx, env, "scontrol", "show", "node", "-o", name)
|
||||
if err != nil {
|
||||
// Not in slurm.conf yet: the leader writes it once this node's
|
||||
// hardware is in.
|
||||
return nil
|
||||
}
|
||||
ns, err := slurm.ParseNodeState(string(out))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ours := ns.Reason == "" || strings.HasPrefix(ns.Reason, reasonPrefix)
|
||||
switch {
|
||||
case want.GetLent() && ns.Drained() && ours:
|
||||
k.log.Info("this node is lent to Slurm: resumed there")
|
||||
if out, err := k.host.Run(ctx, env, "scontrol", "update", "NodeName="+name, "State=RESUME"); err != nil {
|
||||
return fmt.Errorf("resuming the node: %v: %s", err, lastLine(out))
|
||||
}
|
||||
case !want.GetLent() && !strings.Contains(ns.State, "DRAIN"):
|
||||
k.log.Info("this node is llmgw's: drained in Slurm, its jobs left to finish")
|
||||
if out, err := k.host.Run(ctx, env, "scontrol", "update", "NodeName="+name, "State=DRAIN",
|
||||
"Reason="+ReasonServing); err != nil {
|
||||
return fmt.Errorf("draining the node: %v: %s", err, lastLine(out))
|
||||
}
|
||||
}
|
||||
if out, err := k.host.Run(ctx, env, "scontrol", "show", "node", "-o", name); err == nil {
|
||||
if ns2, err := slurm.ParseNodeState(string(out)); err == nil {
|
||||
ns = ns2
|
||||
}
|
||||
}
|
||||
st.State, st.Reason = ns.State, ns.Reason
|
||||
return nil
|
||||
}
|
||||
|
||||
// localJobs counts the job steps running here, from their slurmstepd, not
|
||||
// from the controller: a job outlives a controller that is down, and is
|
||||
// what decides whether llmgw may have the cards back. The one slurmstepd
|
||||
// that is no job's, "slurmstepd infinity" holding slurmd's cgroup v2 scope,
|
||||
// is not counted. -1 when it cannot tell.
|
||||
func (k *Keeper) localJobs(ctx context.Context) int32 {
|
||||
// pgrep exits 1 with no output when there is none.
|
||||
out, err := k.host.Run(ctx, nil, "pgrep", "-a", "-x", "slurmstepd")
|
||||
if err != nil && len(bytes.TrimSpace(out)) > 0 {
|
||||
return -1
|
||||
}
|
||||
n := int32(0)
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 2 || f[len(f)-1] == "infinity" {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// clientEnv points scontrol at slurmd's copy of the files on a node that is
|
||||
// not the controller, which has none of its own.
|
||||
func (k *Keeper) clientEnv() []string {
|
||||
if _, err := k.host.ReadFile(filepath.Join(slurm.ConfDir, "slurm.conf")); err == nil {
|
||||
return nil
|
||||
}
|
||||
return []string{"SLURM_CONF=" + filepath.Join(slurm.SpoolDir, "conf-cache", "slurm.conf")}
|
||||
}
|
||||
|
||||
// stop stops what llmgw started here, if it did.
|
||||
func (k *Keeper) stop(ctx context.Context) {
|
||||
if _, err := k.host.ReadFile(k.marker); err != nil {
|
||||
return
|
||||
}
|
||||
k.log.Info("this node has no part in Slurm any more: slurmd, slurmctld and slurmdbd stopped; the packages and MariaDB stay")
|
||||
// MariaDB stays: it may have been the machine's before, and holds the
|
||||
// jobs' records.
|
||||
for _, u := range []string{"slurmd", "slurmctld", "slurmdbd"} {
|
||||
_ = k.systemctl(ctx, "disable", "--now", u)
|
||||
}
|
||||
_ = k.host.Remove(DropIn)
|
||||
_ = k.systemctl(ctx, "daemon-reload")
|
||||
k.unshare(ctx, "", false, "")
|
||||
if err := k.removeUsers(ctx, k.madeUsers(), nil); err != nil {
|
||||
k.log.Warn("could not take away every Slurm user llmgw made", "err", err)
|
||||
}
|
||||
_ = k.host.Remove(k.marker)
|
||||
k.files, k.acctDone, k.clusterAdded = "", "", ""
|
||||
}
|
||||
|
||||
// write writes a file when it differs, saying whether it did.
|
||||
func (k *Keeper) write(path string, b []byte, mode fs.FileMode, owner string) (bool, error) {
|
||||
if old, err := k.host.ReadFile(path); err == nil && bytes.Equal(old, b) {
|
||||
return false, nil
|
||||
}
|
||||
return true, k.host.WriteFile(path, b, mode, owner)
|
||||
}
|
||||
|
||||
// systemctl runs systemctl; a last argument "&&restart" restarts the unit
|
||||
// after.
|
||||
func (k *Keeper) systemctl(ctx context.Context, args ...string) error {
|
||||
restart := false
|
||||
if n := len(args); n > 0 && args[n-1] == "&&restart" {
|
||||
args, restart = args[:n-1], true
|
||||
}
|
||||
if out, err := k.host.Run(ctx, nil, "systemctl", args...); err != nil {
|
||||
return fmt.Errorf("systemctl %s: %v: %s", strings.Join(args, " "), err, lastLine(out))
|
||||
}
|
||||
if restart {
|
||||
u := args[len(args)-1]
|
||||
if out, err := k.host.Run(ctx, nil, "systemctl", "restart", u); err != nil {
|
||||
return fmt.Errorf("systemctl restart %s: %v: %s", u, err, lastLine(out))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *Keeper) active(ctx context.Context, unit string) bool {
|
||||
out, err := k.host.Run(ctx, nil, "systemctl", "is-active", unit)
|
||||
return err == nil && strings.TrimSpace(string(out)) == "active"
|
||||
}
|
||||
|
||||
func lastLine(b []byte) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if i := strings.LastIndexByte(s, '\n'); i >= 0 {
|
||||
s = s[i+1:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Local is the machine the agent runs on.
|
||||
type Local struct{}
|
||||
|
||||
// Run runs a command, with env added to the agent's.
|
||||
func (Local) Run(ctx context.Context, env []string, name string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
if len(env) > 0 {
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
}
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
// LookPath finds a command, in the sbin directories too: an agent started
|
||||
// by systemd has them, one started by hand may not.
|
||||
func (Local) LookPath(name string) (string, error) {
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
// /usr/libexec: Red Hat's mariadbd.
|
||||
for _, d := range []string{"/usr/sbin", "/sbin", "/usr/local/sbin", "/usr/libexec"} {
|
||||
p := filepath.Join(d, name)
|
||||
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%s is not installed", name)
|
||||
}
|
||||
|
||||
// ReadFile reads a file.
|
||||
func (Local) ReadFile(path string) ([]byte, error) { return os.ReadFile(path) } //nolint:gosec // our paths
|
||||
|
||||
// WriteFile writes a file whole, through a temporary one beside it.
|
||||
func (Local) WriteFile(path string, b []byte, mode fs.FileMode, owner string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { //nolint:gosec // system directories
|
||||
return err
|
||||
}
|
||||
tmp := path + ".llmgw-new"
|
||||
if err := os.WriteFile(tmp, b, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmp, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := chown(tmp, owner); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// Download fetches url to path through a temporary file beside it, kept
|
||||
// only when its SHA-256 is sha.
|
||||
func (Local) Download(ctx context.Context, url, path, sha string) error {
|
||||
if f, err := os.Open(path); err == nil { //nolint:gosec // our paths
|
||||
h := sha256.New()
|
||||
_, cerr := io.Copy(h, f)
|
||||
_ = f.Close()
|
||||
if cerr == nil && hex.EncodeToString(h.Sum(nil)) == sha {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { //nolint:gosec // system directories
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching %s: %w", url, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("fetching %s: %s", url, resp.Status)
|
||||
}
|
||||
tmp := path + ".llmgw-new"
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) //nolint:gosec // our paths
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h := sha256.New()
|
||||
_, err = io.Copy(io.MultiWriter(f, h), io.LimitReader(resp.Body, 1<<30))
|
||||
if cerr := f.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("fetching %s: %w", url, err)
|
||||
}
|
||||
if got := hex.EncodeToString(h.Sum(nil)); got != sha {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("%s has SHA-256 %s, not the %s llmgw was built with: not used", url, got, sha)
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// MkdirAll makes a directory owned by owner.
|
||||
func (Local) MkdirAll(path string, mode fs.FileMode, owner string) error {
|
||||
if err := os.MkdirAll(path, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
return chown(path, owner)
|
||||
}
|
||||
|
||||
// Remove removes a file, and is content when there is none.
|
||||
func (Local) Remove(path string) error {
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func chown(path, owner string) error {
|
||||
if owner == "" {
|
||||
return nil
|
||||
}
|
||||
u, err := user.Lookup(owner)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user %s: %w (installed with its package)", owner, err)
|
||||
}
|
||||
uid, _ := strconv.Atoi(u.Uid)
|
||||
gid, _ := strconv.Atoi(u.Gid)
|
||||
return os.Chown(path, uid, gid)
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package slurmnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/slurm"
|
||||
)
|
||||
|
||||
type fakeHost struct {
|
||||
files map[string][]byte
|
||||
owners map[string]string
|
||||
bins map[string]bool
|
||||
active map[string]bool
|
||||
ran []string
|
||||
outputs map[string]string
|
||||
fail map[string]bool
|
||||
state string
|
||||
reason string
|
||||
}
|
||||
|
||||
func newFake() *fakeHost {
|
||||
return &fakeHost{files: map[string][]byte{}, owners: map[string]string{}, bins: map[string]bool{},
|
||||
active: map[string]bool{}, outputs: map[string]string{
|
||||
"slurmd -V": "slurm-wlm 23.11.4\n",
|
||||
"slurmctld -V": "slurm-wlm 23.11.4\n",
|
||||
"scontrol ping": "Slurmctld(primary) at node06 is UP\n",
|
||||
"slurmd -C": "NodeName=cuda01 CPUs=16 RealMemory=64000\nUpTime=1-00:00:00\n",
|
||||
"hostname -s": "node06\n",
|
||||
}, fail: map[string]bool{}, state: "IDLE+DRAIN", reason: slurm.ReasonNew}
|
||||
}
|
||||
|
||||
func (f *fakeHost) Run(_ context.Context, env []string, name string, args ...string) ([]byte, error) {
|
||||
c := strings.Join(append([]string{name}, args...), " ")
|
||||
f.ran = append(f.ran, c)
|
||||
if f.fail[c] {
|
||||
return []byte("it failed"), errors.New("exit status 1")
|
||||
}
|
||||
switch {
|
||||
case name == "systemctl" && len(args) >= 1:
|
||||
u := args[len(args)-1]
|
||||
switch args[0] {
|
||||
case "is-active":
|
||||
if f.active[u] {
|
||||
return []byte("active\n"), nil
|
||||
}
|
||||
return []byte("inactive\n"), errors.New("exit status 3")
|
||||
case "restart", "start":
|
||||
f.active[u] = true
|
||||
case "enable":
|
||||
if slices.Contains(args, "--now") {
|
||||
f.active[u] = true
|
||||
}
|
||||
case "disable":
|
||||
f.active[u] = false
|
||||
}
|
||||
return []byte(f.outputs[c]), nil
|
||||
case strings.HasPrefix(c, "scontrol show node"):
|
||||
return []byte("NodeName=cuda01 Arch=x86_64 State=" + f.state + " Partitions=gpu Reason=" + f.reason + " [root@now]\n"), nil
|
||||
case strings.HasPrefix(c, "scontrol update"):
|
||||
if slices.Contains(args, "State=RESUME") {
|
||||
f.state, f.reason = "IDLE", ""
|
||||
} else {
|
||||
f.state, f.reason = "IDLE+DRAIN", ReasonServing
|
||||
}
|
||||
return nil, nil
|
||||
case strings.HasPrefix(c, "pgrep"):
|
||||
out := "1800358 /usr/sbin/slurmstepd infinity\n" + f.outputs["jobs"]
|
||||
return []byte(out), nil
|
||||
}
|
||||
return []byte(f.outputs[c]), nil
|
||||
}
|
||||
|
||||
func (f *fakeHost) LookPath(name string) (string, error) {
|
||||
if f.bins[name] {
|
||||
return "/usr/sbin/" + name, nil
|
||||
}
|
||||
return "", errors.New("not found")
|
||||
}
|
||||
|
||||
func (f *fakeHost) ReadFile(path string) ([]byte, error) {
|
||||
b, ok := f.files[path]
|
||||
if !ok {
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (f *fakeHost) WriteFile(path string, b []byte, _ fs.FileMode, owner string) error {
|
||||
f.files[path] = append([]byte(nil), b...)
|
||||
f.owners[path] = owner
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeHost) MkdirAll(string, fs.FileMode, string) error { return nil }
|
||||
|
||||
func (f *fakeHost) Remove(path string) error { delete(f.files, path); return nil }
|
||||
|
||||
func (f *fakeHost) Download(_ context.Context, url, path, sha string) error {
|
||||
c := "download " + url + " " + path + " " + sha
|
||||
f.ran = append(f.ran, c)
|
||||
if f.fail[c] || f.fail["download "+url] {
|
||||
return errors.New("fetch failed")
|
||||
}
|
||||
f.files[path] = []byte("fetched " + url)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeHost) count(prefix string) int {
|
||||
n := 0
|
||||
for _, c := range f.ran {
|
||||
if strings.HasPrefix(c, prefix) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
type fakeInst struct {
|
||||
host *fakeHost
|
||||
got map[string][]string
|
||||
}
|
||||
|
||||
func (i *fakeInst) InstallPackages(_ context.Context, by map[string][]string) error {
|
||||
i.got = by
|
||||
for _, b := range []string{"munged", "scontrol", "slurmd", "slurmctld", "slurmdbd", "mariadbd", "mariadb", "exportfs", "mount.nfs"} {
|
||||
i.host.bins[b] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var key = []byte(strings.Repeat("k", 1024))
|
||||
|
||||
func nodeMsg(lent bool) *llmgwv1.Slurm {
|
||||
return &llmgwv1.Slurm{Node: true, Name: "cuda01", ConfServer: "10.0.0.146:6817", MungeKey: key, Lent: lent}
|
||||
}
|
||||
|
||||
func TestNodeInstallsAndStaysDrained(t *testing.T) {
|
||||
h := newFake()
|
||||
inst := &fakeInst{host: h}
|
||||
k := New(h, inst, "/var/lib/llmgw-agent/slurm", nil)
|
||||
st := k.Reconcile(context.Background(), nodeMsg(false))
|
||||
if st.GetError() != "" {
|
||||
t.Fatal(st.GetError())
|
||||
}
|
||||
if !slices.Equal(inst.got["deb"], []string{"munge", "slurm-client", "slurmd"}) ||
|
||||
!slices.Equal(inst.got["rpm"], []string{"munge", "slurm", "slurm-slurmd"}) {
|
||||
t.Fatalf("installed %v", inst.got)
|
||||
}
|
||||
if string(h.files[slurm.MungeKeyPath]) != string(key) || h.owners[slurm.MungeKeyPath] != "munge" {
|
||||
t.Fatal("the munge key was not written for munge")
|
||||
}
|
||||
unit := string(h.files[DropIn])
|
||||
if !strings.Contains(unit, "ExecStart=/usr/sbin/slurmd --systemd --conf-server 10.0.0.146:6817 -N cuda01\n") ||
|
||||
!strings.Contains(unit, "ConditionPathExists=\n") {
|
||||
t.Fatalf("drop-in:\n%s", unit)
|
||||
}
|
||||
if !st.GetSlurmdRunning() || st.GetVersion() != "23.11.4" || !strings.HasPrefix(st.GetHardware(), "NodeName=cuda01 CPUs=16") {
|
||||
t.Fatalf("status %v", st)
|
||||
}
|
||||
// Not lent: left drained, with llmgw's first reason.
|
||||
if h.count("scontrol update") != 0 || st.GetState() != "IDLE+DRAIN" {
|
||||
t.Fatalf("a node not lent was changed: %v, %v", h.ran, st)
|
||||
}
|
||||
if _, ok := h.files["/var/lib/llmgw-agent/slurm"]; !ok {
|
||||
t.Fatal("no marker")
|
||||
}
|
||||
|
||||
// Again, nothing changed: no install, no restart.
|
||||
h.ran = nil
|
||||
inst.got = nil
|
||||
k.Reconcile(context.Background(), nodeMsg(false))
|
||||
if inst.got != nil || h.count("systemctl restart") != 0 || h.count("systemctl enable") != 0 {
|
||||
t.Fatalf("a second pass did work: %v", h.ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLendAndReclaim(t *testing.T) {
|
||||
h := newFake()
|
||||
k := New(h, &fakeInst{host: h}, "/m", nil)
|
||||
st := k.Reconcile(context.Background(), nodeMsg(true))
|
||||
if h.count("scontrol update NodeName=cuda01 State=RESUME") != 1 || st.GetState() != "IDLE" {
|
||||
t.Fatalf("a lent node was not resumed: %v %v", h.ran, st)
|
||||
}
|
||||
h.outputs["jobs"] = "2001 slurmstepd: [101.batch]\n2002 slurmstepd: [102.0]\n"
|
||||
st = k.Reconcile(context.Background(), nodeMsg(false))
|
||||
if h.count("scontrol update NodeName=cuda01 State=DRAIN Reason="+ReasonServing) != 1 {
|
||||
t.Fatalf("a node taken back was not drained: %v", h.ran)
|
||||
}
|
||||
if st.GetJobs() != 2 || !strings.Contains(st.GetState(), "DRAIN") {
|
||||
t.Fatalf("status %v", st)
|
||||
}
|
||||
// Drained already: not drained again.
|
||||
h.ran = nil
|
||||
k.Reconcile(context.Background(), nodeMsg(false))
|
||||
if h.count("scontrol update") != 0 {
|
||||
t.Fatalf("drained twice: %v", h.ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminsDrainIsLeft(t *testing.T) {
|
||||
h := newFake()
|
||||
h.reason = "bad DIMM, ticket 42"
|
||||
k := New(h, &fakeInst{host: h}, "/m", nil)
|
||||
k.Reconcile(context.Background(), nodeMsg(true))
|
||||
if h.count("scontrol update") != 0 {
|
||||
t.Fatalf("llmgw resumed a node a Slurm administrator drained: %v", h.ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientEnvOnANodeWithoutFiles(t *testing.T) {
|
||||
h := newFake()
|
||||
k := New(h, &fakeInst{host: h}, "/m", nil)
|
||||
if env := k.clientEnv(); len(env) != 1 || !strings.HasSuffix(env[0], "conf-cache/slurm.conf") {
|
||||
t.Fatalf("env %v", env)
|
||||
}
|
||||
h.files[filepath.Join(slurm.ConfDir, "slurm.conf")] = []byte("x")
|
||||
if env := k.clientEnv(); env != nil {
|
||||
t.Fatalf("env %v on the controller", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestController(t *testing.T) {
|
||||
h := newFake()
|
||||
inst := &fakeInst{host: h}
|
||||
k := New(h, inst, "/m", nil)
|
||||
files := map[string]string{"slurm.conf": "ClusterName=llmgw\n", "gres.conf": "#\n", "cgroup.conf": "#\n"}
|
||||
m := &llmgwv1.Slurm{Controller: true, MungeKey: key, Files: files}
|
||||
st := k.Reconcile(context.Background(), m)
|
||||
if st.GetError() != "" {
|
||||
t.Fatal(st.GetError())
|
||||
}
|
||||
if !slices.Contains(inst.got["deb"], "slurmctld") || slices.Contains(inst.got["deb"], "slurmd") {
|
||||
t.Fatalf("installed %v", inst.got)
|
||||
}
|
||||
if string(h.files["/etc/slurm/slurm.conf"]) != "ClusterName=llmgw\n" {
|
||||
t.Fatal("slurm.conf not written")
|
||||
}
|
||||
if h.count("systemctl restart slurmctld") != 1 || h.count("scontrol reconfigure") != 1 {
|
||||
t.Fatalf("slurmctld not started on its files: %v", h.ran)
|
||||
}
|
||||
if !st.GetSlurmctldRunning() || len(st.GetFilesSha256()) != 64 {
|
||||
t.Fatalf("status %v", st)
|
||||
}
|
||||
// Accounting: MariaDB on this machine alone, the database, slurmdbd on
|
||||
// it, the cluster added, all before slurmctld.
|
||||
if !slices.Contains(inst.got["deb"], "slurmdbd") || !slices.Contains(inst.got["deb"], "mariadb-server") {
|
||||
t.Fatalf("installed %v", inst.got)
|
||||
}
|
||||
if !strings.Contains(string(h.files["/etc/mysql/mariadb.conf.d/90-llmgw-slurm.cnf"]), "bind-address=127.0.0.1") {
|
||||
t.Fatal("MariaDB, installed by llmgw, not kept to this machine")
|
||||
}
|
||||
dbd := string(h.files[slurm.DbdConfPath])
|
||||
if !strings.Contains(dbd, "DbdHost=node06\n") || !strings.Contains(dbd, "StoragePass="+slurm.DbdPassword(key)+"\n") ||
|
||||
h.owners[slurm.DbdConfPath] != "slurm" {
|
||||
t.Fatalf("slurmdbd.conf for %q:\n%s", h.owners[slurm.DbdConfPath], dbd)
|
||||
}
|
||||
if _, ok := h.files[acctSQLPath]; ok {
|
||||
t.Fatal("the database's statements, password and all, were left on disk")
|
||||
}
|
||||
if strings.Contains(strings.Join(h.ran, "\n"), slurm.DbdPassword(key)) {
|
||||
t.Fatal("the password was on a command line")
|
||||
}
|
||||
order := strings.Join(h.ran, "\n")
|
||||
if !strings.Contains(order, "sacctmgr -i add cluster llmgw") ||
|
||||
strings.Index(order, "slurmdbd") > strings.Index(order, "systemctl restart slurmctld") {
|
||||
t.Fatalf("accounting not before slurmctld: %v", h.ran)
|
||||
}
|
||||
if !st.GetSlurmdbdRunning() {
|
||||
t.Fatalf("status %v", st)
|
||||
}
|
||||
sum := st.GetFilesSha256()
|
||||
|
||||
h.ran = nil
|
||||
k.Reconcile(context.Background(), m)
|
||||
if h.count("systemctl restart") != 0 {
|
||||
t.Fatalf("restarted on the same files: %v", h.ran)
|
||||
}
|
||||
|
||||
files["gres.conf"] = "NodeName=cuda01 Name=gpu File=/dev/nvidia0\n"
|
||||
h.ran = nil
|
||||
st = k.Reconcile(context.Background(), m)
|
||||
if h.count("systemctl restart slurmctld") != 1 || st.GetFilesSha256() == sum {
|
||||
t.Fatalf("a changed file did not restart slurmctld: %v", h.ran)
|
||||
}
|
||||
|
||||
if st := k.Reconcile(context.Background(), &llmgwv1.Slurm{Controller: true, MungeKey: key,
|
||||
Files: map[string]string{"slurm.conf": "x", "../evil": "y"}}); !strings.Contains(st.GetError(), "named") {
|
||||
t.Fatalf("a path in a file name was taken: %v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTooOld(t *testing.T) {
|
||||
h := newFake()
|
||||
h.outputs["slurmd -V"] = "slurm-wlm 21.08.5\n"
|
||||
k := New(h, &fakeInst{host: h}, "/m", nil)
|
||||
st := k.Reconcile(context.Background(), nodeMsg(false))
|
||||
if !strings.Contains(st.GetError(), "older than 22.05") {
|
||||
t.Fatalf("status %v", st)
|
||||
}
|
||||
if _, ok := h.files[slurm.MungeKeyPath]; ok {
|
||||
t.Fatal("went on with a Slurm too old")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStop(t *testing.T) {
|
||||
h := newFake()
|
||||
k := New(h, &fakeInst{host: h}, "/m", nil)
|
||||
k.Reconcile(context.Background(), nodeMsg(false))
|
||||
h.ran = nil
|
||||
if st := k.Reconcile(context.Background(), &llmgwv1.Slurm{}); st != nil {
|
||||
t.Fatalf("status %v for no part", st)
|
||||
}
|
||||
if !slices.Contains(h.ran, "systemctl disable --now slurmd") {
|
||||
t.Fatalf("slurmd not stopped: %v", h.ran)
|
||||
}
|
||||
if _, ok := h.files[DropIn]; ok {
|
||||
t.Fatal("drop-in left")
|
||||
}
|
||||
// Never started here: nothing touched.
|
||||
h2 := newFake()
|
||||
k2 := New(h2, &fakeInst{host: h2}, "/m", nil)
|
||||
k2.Reconcile(context.Background(), &llmgwv1.Slurm{})
|
||||
if len(h2.ran) != 0 {
|
||||
t.Fatalf("a machine llmgw never ran Slurm on was touched: %v", h2.ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadNames(t *testing.T) {
|
||||
h := newFake()
|
||||
k := New(h, &fakeInst{host: h}, "/m", nil)
|
||||
m := nodeMsg(false)
|
||||
m.Name = "cuda01 -c /etc/evil"
|
||||
if st := k.Reconcile(context.Background(), m); !strings.Contains(st.GetError(), "spaces") {
|
||||
t.Fatalf("status %v", st)
|
||||
}
|
||||
if st := k.Reconcile(context.Background(), &llmgwv1.Slurm{Node: true, Name: "a", ConfServer: "b"}); !strings.Contains(st.GetError(), "no key") {
|
||||
t.Fatalf("status %v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMariaDBThatWasThereKeepsItsSettings(t *testing.T) {
|
||||
h := newFake()
|
||||
for _, b := range []string{"munged", "scontrol", "slurmctld", "slurmdbd", "mariadbd", "mariadb", "exportfs", "mount.nfs"} {
|
||||
h.bins[b] = true
|
||||
}
|
||||
k := New(h, &fakeInst{host: h}, "/m", nil)
|
||||
st := k.Reconcile(context.Background(), &llmgwv1.Slurm{Controller: true, MungeKey: key,
|
||||
Files: map[string]string{"slurm.conf": "ClusterName=llmgw\n"}})
|
||||
if st.GetError() != "" {
|
||||
t.Fatal(st.GetError())
|
||||
}
|
||||
for p := range h.files {
|
||||
if strings.HasSuffix(p, ".cnf") {
|
||||
t.Fatalf("a MariaDB llmgw did not install had its settings changed: %s", p)
|
||||
}
|
||||
}
|
||||
if h.count("mariadb -e source") != 1 || !st.GetSlurmdbdRunning() {
|
||||
t.Fatalf("the database was not made in the MariaDB that was there: %v", h.ran)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package slurmnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/slurm"
|
||||
)
|
||||
|
||||
// usersMarker lists, a line each, the logins llmgw made here: the ones it
|
||||
// may change and take away. A user that was here before is never touched.
|
||||
func (k *Keeper) usersMarker() string { return k.marker + "-users" }
|
||||
|
||||
// users keeps the Linux users want has, the same login and UID as on every
|
||||
// other machine, their keys where sshd reads them, and their homes when
|
||||
// this machine makes them; and takes away those llmgw made that are gone.
|
||||
// Answers how many it has. A user that cannot be made is said, the others
|
||||
// are made anyway.
|
||||
func (k *Keeper) users(ctx context.Context, want *llmgwv1.Slurm) (int32, error) {
|
||||
made := k.madeUsers()
|
||||
if !want.GetManageUsers() {
|
||||
return 0, k.removeUsers(ctx, made, nil)
|
||||
}
|
||||
var errs []error
|
||||
var keep []string
|
||||
n := int32(0)
|
||||
for _, u := range want.GetUsers() {
|
||||
err := k.user(ctx, u, slices.Contains(made, u.GetLogin()), want.GetMakeHomes())
|
||||
if errors.Is(err, errNotMade) {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
keep = append(keep, u.GetLogin())
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
if err := k.keepMade(keep); err != nil {
|
||||
return n, err
|
||||
}
|
||||
if err := k.removeUsers(ctx, made, keep); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if err := k.sshConf(ctx, len(keep) > 0); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
// The controller is where they log in and submit from; a node only
|
||||
// while a job of theirs runs there.
|
||||
if err := k.nodeLogin(ctx, len(keep) > 0 && want.GetNode() && !want.GetController()); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return n, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// nodeLogin holds the users llmgw made here to their jobs when they log in
|
||||
// (pam_slurm_adopt), or no longer. Only they are: root and the site's
|
||||
// administrators log in as before, so a mistake here never locks out the
|
||||
// people who would put it right.
|
||||
func (k *Keeper) nodeLogin(ctx context.Context, on bool) error {
|
||||
old, err := k.host.ReadFile(slurm.PAMSSHD)
|
||||
if err != nil {
|
||||
if !on {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("reading %s: %w", slurm.PAMSSHD, err)
|
||||
}
|
||||
if on {
|
||||
if err := k.adoptModule(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
read := func(name string) (string, error) {
|
||||
b, err := k.host.ReadFile(filepath.Join(filepath.Dir(slurm.PAMSSHD), name))
|
||||
return string(b), err
|
||||
}
|
||||
stack, contained, err := slurm.PAMAdopt(string(old), k.usersMarker(), read, on)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stack == string(old) {
|
||||
return nil
|
||||
}
|
||||
if on {
|
||||
k.log.Info("llmgw's Slurm users log in to this node only while a job of theirs runs here",
|
||||
"in_the_job", contained)
|
||||
if !contained {
|
||||
k.log.Warn("pam_systemd could not be skipped for them safely here: their ssh sessions are not held " +
|
||||
"to their job's cores and cards, only to having one")
|
||||
}
|
||||
} else {
|
||||
k.log.Info("llmgw's Slurm users are no longer held to their jobs when they log in here")
|
||||
}
|
||||
// Read by sshd for each login: nothing to reload.
|
||||
_, err = k.write(slurm.PAMSSHD, []byte(stack), 0o644, "")
|
||||
return err
|
||||
}
|
||||
|
||||
// adoptModule is there, or is installed.
|
||||
func (k *Keeper) adoptModule(ctx context.Context) error {
|
||||
if k.installed[slurm.AdoptModule] {
|
||||
return nil
|
||||
}
|
||||
find := func() bool {
|
||||
for _, d := range slurm.ModuleDirs {
|
||||
if _, err := k.host.Run(ctx, nil, "test", "-f", filepath.Join(d, slurm.AdoptModule)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !find() {
|
||||
if k.inst == nil {
|
||||
return fmt.Errorf("%s missing, and this agent installs nothing", slurm.AdoptModule)
|
||||
}
|
||||
k.log.Info("installing pam_slurm_adopt, which holds llmgw's Slurm users to their jobs here")
|
||||
if err := k.inst.InstallPackages(ctx, slurm.AdoptPackages); err != nil {
|
||||
return err
|
||||
}
|
||||
if !find() {
|
||||
return fmt.Errorf("%s is still missing after installing %s", slurm.AdoptModule,
|
||||
strings.Join(slurm.AdoptPackages["deb"], ", "))
|
||||
}
|
||||
}
|
||||
k.installed[slurm.AdoptModule] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// errNotMade is a user llmgw could not make here, nor took for its own.
|
||||
var errNotMade = errors.New("not made")
|
||||
|
||||
type notMade struct{ msg string }
|
||||
|
||||
func (e notMade) Error() string { return e.msg }
|
||||
func (e notMade) Is(target error) bool { return target == errNotMade }
|
||||
|
||||
// user keeps one user; ours is whether llmgw made it here before.
|
||||
func (k *Keeper) user(ctx context.Context, u *llmgwv1.SlurmUser, ours, makeHome bool) error {
|
||||
login, uid, home := u.GetLogin(), u.GetUid(), u.GetHome()
|
||||
if !slurm.ValidLogin(login) || uid < slurm.MinUID || !filepath.IsAbs(home) || strings.Contains(home, "..") {
|
||||
return notMade{fmt.Sprintf("the leader sent a user %q, UID %d, home %q", login, uid, home)}
|
||||
}
|
||||
id := strconv.FormatUint(uint64(uid), 10)
|
||||
gecos := strings.Map(func(r rune) rune {
|
||||
if r == ':' || r == ',' || r == '\n' || r < ' ' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, u.GetName())
|
||||
byName, okName := k.passwd(ctx, login)
|
||||
byID, okID := k.passwd(ctx, id)
|
||||
switch {
|
||||
case okName && byName[2] != id:
|
||||
return notMade{fmt.Sprintf("%s is already a user here with UID %s, not llmgw's %s", login, byName[2], id)}
|
||||
case !okName && okID:
|
||||
return notMade{fmt.Sprintf("UID %s is already %s's here, so %s is not made", id, byID[0], login)}
|
||||
case !okName:
|
||||
if _, ok := k.group(ctx, login); !ok {
|
||||
if out, err := k.host.Run(ctx, nil, "groupadd", "-g", id, login); err != nil {
|
||||
return notMade{fmt.Sprintf("groupadd %s: %v: %s", login, err, lastLine(out))}
|
||||
}
|
||||
}
|
||||
k.log.Info("a Slurm user is made here, as llmgw's accounts say", "login", login, "uid", uid)
|
||||
if out, err := k.host.Run(ctx, nil, "useradd", "-u", id, "-g", id, "-M", "-d", home, "-s", "/bin/bash",
|
||||
"-c", gecos, login); err != nil {
|
||||
return notMade{fmt.Sprintf("useradd %s: %v: %s", login, err, lastLine(out))}
|
||||
}
|
||||
case ours && (byName[4] != gecos || byName[5] != home):
|
||||
if out, err := k.host.Run(ctx, nil, "usermod", "-c", gecos, "-d", home, login); err != nil {
|
||||
return fmt.Errorf("usermod %s: %v: %s", login, err, lastLine(out))
|
||||
}
|
||||
}
|
||||
keys := strings.Join(u.GetSshKeys(), "\n")
|
||||
file := filepath.Join(slurm.AuthorizedKeysDir, login)
|
||||
if keys == "" {
|
||||
_ = k.host.Remove(file)
|
||||
} else if _, err := k.write(file, []byte(keys+"\n"), 0o644, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
if makeHome {
|
||||
return k.home(ctx, login, id, home)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// home makes a user's home directory, from /etc/skel, when it is not there.
|
||||
func (k *Keeper) home(ctx context.Context, login, id, home string) error {
|
||||
if _, err := k.host.Run(ctx, nil, "test", "-d", home); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := k.host.MkdirAll(filepath.Dir(home), 0o755, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
k.log.Info("a Slurm user's home is made", "login", login, "home", home)
|
||||
for _, c := range [][]string{
|
||||
{"install", "-d", "-m", "0700", "-o", id, "-g", id, home},
|
||||
{"cp", "-rT", "/etc/skel", home},
|
||||
{"chown", "-R", id + ":" + id, home},
|
||||
} {
|
||||
if out, err := k.host.Run(ctx, nil, c[0], c[1:]...); err != nil {
|
||||
return fmt.Errorf("making %s's home: %s: %v: %s", login, c[0], err, lastLine(out))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeUsers takes away the users llmgw made here that are not in keep.
|
||||
// Their homes and files stay; their UID is never given to another.
|
||||
func (k *Keeper) removeUsers(ctx context.Context, made, keep []string) error {
|
||||
var errs []error
|
||||
left := slices.Clone(keep)
|
||||
for _, login := range made {
|
||||
if slices.Contains(keep, login) {
|
||||
continue
|
||||
}
|
||||
k.log.Info("a Slurm user llmgw made is taken away; its home stays", "login", login)
|
||||
_ = k.host.Remove(filepath.Join(slurm.AuthorizedKeysDir, login))
|
||||
if _, ok := k.passwd(ctx, login); ok {
|
||||
if out, err := k.host.Run(ctx, nil, "userdel", login); err != nil {
|
||||
// Still logged in, or running something: tried again.
|
||||
errs = append(errs, fmt.Errorf("userdel %s: %v: %s", login, err, lastLine(out)))
|
||||
left = append(left, login)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if !slices.Equal(left, made) {
|
||||
if err := k.keepMade(left); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if len(left) == 0 {
|
||||
if err := k.sshConf(ctx, false); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if err := k.nodeLogin(ctx, false); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (k *Keeper) madeUsers() []string {
|
||||
b, err := k.host.ReadFile(k.usersMarker())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return strings.Fields(string(b))
|
||||
}
|
||||
|
||||
func (k *Keeper) keepMade(logins []string) error {
|
||||
s := slices.Clone(logins)
|
||||
slices.Sort(s)
|
||||
s = slices.Compact(s)
|
||||
if len(s) == 0 {
|
||||
return k.host.Remove(k.usersMarker())
|
||||
}
|
||||
_, err := k.write(k.usersMarker(), []byte(strings.Join(s, "\n")+"\n"), 0o644, "")
|
||||
return err
|
||||
}
|
||||
|
||||
// sshConf has sshd read the users' keys from AuthorizedKeysDir, or no
|
||||
// longer.
|
||||
func (k *Keeper) sshConf(ctx context.Context, on bool) error {
|
||||
if !on {
|
||||
if _, err := k.host.ReadFile(slurm.SSHDropIn); err != nil {
|
||||
return nil
|
||||
}
|
||||
_ = k.host.Remove(slurm.SSHDropIn)
|
||||
return k.reloadSSH(ctx)
|
||||
}
|
||||
changed, err := k.write(slurm.SSHDropIn, []byte(slurm.SSHConf), 0o644, "")
|
||||
if err != nil || !changed {
|
||||
return err
|
||||
}
|
||||
if out, err := k.host.Run(ctx, nil, "sshd", "-t"); err != nil {
|
||||
_ = k.host.Remove(slurm.SSHDropIn)
|
||||
return fmt.Errorf("sshd refuses llmgw's setting for the users' keys, so it is not kept: %v: %s", err, lastLine(out))
|
||||
}
|
||||
k.log.Info("sshd reads the Slurm users' keys from llmgw too", "dir", slurm.AuthorizedKeysDir)
|
||||
return k.reloadSSH(ctx)
|
||||
}
|
||||
|
||||
// reloadSSH has sshd read its settings again, under Debian's name for it
|
||||
// or Red Hat's; one that is not running reads them when it starts.
|
||||
func (k *Keeper) reloadSSH(ctx context.Context) error {
|
||||
for _, u := range []string{"ssh", "sshd"} {
|
||||
if k.active(ctx, u) {
|
||||
return k.systemctl(ctx, "reload", u)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// passwd is getent's line for a user, by login or UID, in its fields.
|
||||
func (k *Keeper) passwd(ctx context.Context, key string) ([]string, bool) {
|
||||
out, err := k.host.Run(ctx, nil, "getent", "passwd", key)
|
||||
f := strings.Split(strings.TrimSpace(string(out)), ":")
|
||||
if err != nil || len(f) < 7 {
|
||||
return nil, false
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
|
||||
func (k *Keeper) group(ctx context.Context, key string) ([]string, bool) {
|
||||
out, err := k.host.Run(ctx, nil, "getent", "group", key)
|
||||
f := strings.Split(strings.TrimSpace(string(out)), ":")
|
||||
if err != nil || len(f) < 3 {
|
||||
return nil, false
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
@@ -54,9 +54,13 @@ func NewHTTPHealth(timeout time.Duration, opts ...HTTPHealthOption) *HTTPHealth
|
||||
|
||||
// Ready reports whether the engine at baseURL is serving model.
|
||||
func (h *HTTPHealth) Ready(ctx context.Context, baseURL, model, apiKey string) error {
|
||||
if err := h.get(ctx, baseURL+h.healthPath, apiKey, nil); err != nil {
|
||||
var body healthBody
|
||||
if err := h.get(ctx, baseURL+h.healthPath, apiKey, &body); err != nil {
|
||||
return fmt.Errorf("health: %w", err)
|
||||
}
|
||||
if body.notReady() {
|
||||
return fmt.Errorf("health: the engine says %q", body.Status)
|
||||
}
|
||||
if !h.requireName || model == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -98,5 +102,29 @@ func (h *HTTPHealth) get(ctx context.Context, url, apiKey string, out any) error
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if hb, ok := out.(*healthBody); ok {
|
||||
// Most engines answer /health with nothing, or with text: only a
|
||||
// JSON status says anything, and a body that is not one is not a
|
||||
// failure.
|
||||
_ = json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(hb)
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// healthBody is what an engine's /health may say in its body. Some answer
|
||||
// 200 from the moment their HTTP server is up and say in the body that the
|
||||
// model is still loading — FreeToken's {"status":"loading","phase":...} —
|
||||
// and a model that lists itself in /v1/models meanwhile would be routed to.
|
||||
type healthBody struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// notReady is a body that says the engine cannot serve yet, or has failed.
|
||||
func (b healthBody) notReady() bool {
|
||||
switch b.Status {
|
||||
case "loading", "starting", "initializing", "warming_up", "error", "unhealthy":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -103,3 +103,29 @@ func TestTheHealthCheckSendsTheEnginesKey(t *testing.T) {
|
||||
t.Error("without the key the engine refused, and that read as ready")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotReadyWhileTheHealthBodySaysLoading(t *testing.T) {
|
||||
// FreeToken answers 200 from the moment its HTTP server is up and lists
|
||||
// its model meanwhile; only the body says the weights are still loading.
|
||||
body := `{"status":"loading","phase":"weights","progress":{"done_bytes":1,"total_bytes":9},"model":"glm"}`
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { io.WriteString(w, body) })
|
||||
mux.HandleFunc("GET /v1/models", func(w http.ResponseWriter, _ *http.Request) {
|
||||
io.WriteString(w, `{"data":[{"id":"glm"}]}`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
h := NewHTTPHealth(time.Second)
|
||||
if err := h.Ready(t.Context(), srv.URL, "glm", ""); err == nil || !strings.Contains(err.Error(), "loading") {
|
||||
t.Errorf("a loading engine was reported ready, or not said to be loading: %v", err)
|
||||
}
|
||||
body = `{"status":"ok","model":"glm","uptime_s":3}`
|
||||
if err := h.Ready(t.Context(), srv.URL, "glm", ""); err != nil {
|
||||
t.Errorf("a serving engine was reported unready: %v", err)
|
||||
}
|
||||
// Text, as some engines answer: not a status, and not a failure.
|
||||
body = "OK"
|
||||
if err := h.Ready(t.Context(), srv.URL, "glm", ""); err != nil {
|
||||
t.Errorf("a plain-text health answer was taken for a failure: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
@@ -63,3 +64,71 @@ func isZombie(pid int) bool {
|
||||
}
|
||||
return b[i+2] == 'Z'
|
||||
}
|
||||
|
||||
// strays are a process's descendants that left its group, as an engine that
|
||||
// starts its worker in a session of its own does (Strata): signalling the
|
||||
// group misses them, and one left behind holds the card and the RAM the next
|
||||
// replica needs. Found before the group is signalled, while they are still
|
||||
// its descendants: after, they belong to init. Linux only; elsewhere none.
|
||||
func strays(pid int) []int {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
parent := map[int]int{}
|
||||
group := map[int]int{}
|
||||
for _, e := range entries {
|
||||
p, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if pp, pg, ok := parentAndGroup(p); ok {
|
||||
parent[p], group[p] = pp, pg
|
||||
}
|
||||
}
|
||||
var out []int
|
||||
for p := range parent {
|
||||
if group[p] == pid || p == pid {
|
||||
continue
|
||||
}
|
||||
// Bounded: no engine's tree is this deep, and a pid reused while
|
||||
// /proc was read must not loop.
|
||||
for q, n := parent[p], 0; q > 1 && n < 32; q, n = parent[q], n+1 {
|
||||
if q == pid {
|
||||
out = append(out, p)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parentAndGroup reads a process's parent and group from /proc.
|
||||
func parentAndGroup(pid int) (int, int, bool) {
|
||||
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
i := bytes.LastIndexByte(b, ')')
|
||||
if i < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
f := strings.Fields(string(b[i+1:]))
|
||||
if len(f) < 3 {
|
||||
return 0, 0, false
|
||||
}
|
||||
pp, err1 := strconv.Atoi(f[1])
|
||||
pg, err2 := strconv.Atoi(f[2])
|
||||
return pp, pg, err1 == nil && err2 == nil
|
||||
}
|
||||
|
||||
// signalStrays signals each stray, and its own group when it leads one.
|
||||
func signalStrays(pids []int, sig syscall.Signal) {
|
||||
for _, p := range pids {
|
||||
if _, pg, ok := parentAndGroup(p); ok && pg == p {
|
||||
_ = syscall.Kill(-p, sig)
|
||||
continue
|
||||
}
|
||||
_ = syscall.Kill(p, sig)
|
||||
}
|
||||
}
|
||||
@@ -582,10 +582,24 @@ func (s *Supervisor) Stop(ctx context.Context, id string) (err error) {
|
||||
}
|
||||
|
||||
// Signal the group, not the process: engines fork workers that hold GPU
|
||||
// memory, and those outlive a signal sent only to the leader.
|
||||
// memory, and those outlive a signal sent only to the leader. And what
|
||||
// left the group, found while it is still the leader's.
|
||||
left := strays(pid)
|
||||
if err := killGroup(pid, syscall.SIGTERM); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
s.log.Warn("SIGTERM failed", "shard", id, "err", err)
|
||||
}
|
||||
signalStrays(left, syscall.SIGTERM)
|
||||
defer func() {
|
||||
// Given as long as the group to go; then killed.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) && slices.ContainsFunc(left, processAlive) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if slices.ContainsFunc(left, processAlive) {
|
||||
s.log.Warn("killing an engine's processes that left its group", "shard", id, "pids", left)
|
||||
signalStrays(left, syscall.SIGKILL)
|
||||
}
|
||||
}()
|
||||
|
||||
// force kills the group outright. exec.Cmd's cancel does this for shards we
|
||||
// started; an adopted shard has none, so signal its group directly.
|
||||
@@ -598,6 +612,7 @@ func (s *Supervisor) Stop(ctx context.Context, id string) (err error) {
|
||||
if err := killGroup(pid, syscall.SIGKILL); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
s.log.Warn("SIGKILL failed", "shard", id, "err", err)
|
||||
}
|
||||
signalStrays(left, syscall.SIGKILL)
|
||||
}
|
||||
|
||||
select {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -981,3 +982,38 @@ func TestAnOutputFileIsEmptiedPastItsBound(t *testing.T) {
|
||||
t.Errorf("a line written after the file was emptied was lost: %v", sh.Logs(8))
|
||||
}
|
||||
}
|
||||
|
||||
// An engine's worker in a session of its own, as Strata's is, is not in the
|
||||
// group a stop signals: it is found and stopped as well.
|
||||
func TestStopKillsAWorkerThatLeftTheGroup(t *testing.T) {
|
||||
if _, err := os.Stat("/proc/self/stat"); err != nil {
|
||||
t.Skip("needs /proc")
|
||||
}
|
||||
if _, err := exec.LookPath("setsid"); err != nil {
|
||||
t.Skip("needs setsid")
|
||||
}
|
||||
s, _ := newSupervisor(t)
|
||||
marker := filepath.Join(t.TempDir(), "worker.pid")
|
||||
script := fmt.Sprintf(`setsid sh -c 'echo $$ > %s; sleep 30' & sleep 30`, marker)
|
||||
if _, err := s.Start(t.Context(), "a", engines.ShardSpec{}, shellCommand(script)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var worker int
|
||||
for deadline := time.Now().Add(3 * time.Second); time.Now().Before(deadline) && worker == 0; time.Sleep(10 * time.Millisecond) {
|
||||
if b, err := os.ReadFile(marker); err == nil {
|
||||
_, _ = fmt.Sscanf(strings.TrimSpace(string(b)), "%d", &worker)
|
||||
}
|
||||
}
|
||||
if worker == 0 {
|
||||
t.Fatal("the worker never started")
|
||||
}
|
||||
if err := s.Stop(t.Context(), "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for deadline := time.Now().Add(8 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||
if !processAlive(worker) {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("the worker %d that left the group survived the stop", worker)
|
||||
}
|
||||
@@ -35,12 +35,25 @@ func (a *Agent) upgrade(ctx context.Context, m *llmgwv1.UpgradeAgent, out chan<-
|
||||
select {
|
||||
case out <- &llmgwv1.AgentMessage{Msg: &llmgwv1.AgentMessage_Upgrade{Upgrade: reply}}:
|
||||
case <-ctx.Done():
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
// One at a time: a second request while one downloads would race it to
|
||||
// the same file.
|
||||
if !a.upgrading.CompareAndSwap(false, true) {
|
||||
reply.Error = "an upgrade is already under way on this node"
|
||||
send()
|
||||
return
|
||||
}
|
||||
defer a.upgrading.Store(false)
|
||||
a.log.Info("upgrading", "to", m.GetVersion(), "from", a.cfg.Version)
|
||||
self, err := upgradeTarget()
|
||||
if err == nil {
|
||||
dctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||
// Not the stream's context: a stream that breaks while the release
|
||||
// downloads — a control plane restarting, leadership moving — cancelled
|
||||
// the upgrade with nobody told. The node registering again on the
|
||||
// version is what the control plane watches for.
|
||||
dctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), upgradeDownload)
|
||||
_, err = release.Install(dctx, nil, m.GetReleases(), m.GetVersion(), runtime.GOOS, runtime.GOARCH, self)
|
||||
cancel()
|
||||
}
|
||||
@@ -53,6 +66,13 @@ func (a *Agent) upgrade(ctx context.Context, m *llmgwv1.UpgradeAgent, out chan<-
|
||||
send()
|
||||
a.log.Info("upgraded; restarting into the new version", "to", m.GetVersion())
|
||||
// A moment for the reply to leave before the stream goes.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
time.Sleep(upgradeLinger)
|
||||
exitForUpgrade()
|
||||
}
|
||||
|
||||
// upgradeDownload bounds an upgrade's download; the control plane waits
|
||||
// longer (agentsvc.UpgradeWait). upgradeLinger is the moment its answer has
|
||||
// to leave before the agent exits; a variable so tests need not wait it.
|
||||
const upgradeDownload = 10 * time.Minute
|
||||
|
||||
var upgradeLinger = 2 * time.Second
|
||||
+130
-14
@@ -38,8 +38,14 @@ type Progress struct {
|
||||
Source string
|
||||
BytesDone int64
|
||||
BytesTotal int64
|
||||
// Phase is empty while the bytes arrive, PhaseChecking while a set's
|
||||
// files are checked against the mirror's checksums.
|
||||
Phase string
|
||||
}
|
||||
|
||||
// PhaseChecking is a set's files being checked, after they arrived.
|
||||
const PhaseChecking = "checking"
|
||||
|
||||
// Percent is how far along, or -1 when the total is unknown.
|
||||
func (p Progress) Percent() int {
|
||||
if p.BytesTotal <= 0 {
|
||||
@@ -94,6 +100,13 @@ var ErrNotLocal = errors.New("the weights are not on this node and the source sa
|
||||
// A source in a pickle format, or a set with such a file in it, is refused
|
||||
// before anything is fetched unless allowPickle; see Pickled.
|
||||
func (f *Fetcher) Ensure(ctx context.Context, source string, allowPickle bool) (path string, err error) {
|
||||
return f.EnsureChecked(ctx, source, allowPickle, "")
|
||||
}
|
||||
|
||||
// EnsureChecked is Ensure for a file whose sha256 the registry published: a
|
||||
// copy that is not it, downloaded or cached, is refused rather than served.
|
||||
// An empty sha256 checks nothing.
|
||||
func (f *Fetcher) EnsureChecked(ctx context.Context, source string, allowPickle bool, sha256 string) (path string, err error) {
|
||||
ctx, span := obs.Tracer(tracerName).Start(ctx, "fetch weights",
|
||||
trace.WithAttributes(attribute.String("llmgw.weights.source", redact(source))))
|
||||
defer func() {
|
||||
@@ -103,7 +116,7 @@ func (f *Fetcher) Ensure(ctx context.Context, source string, allowPickle bool) (
|
||||
if !allowPickle && !IsSet(source) && Pickled(source) {
|
||||
return "", refusePickle(source)
|
||||
}
|
||||
return f.ensure(ctx, source, allowPickle)
|
||||
return f.ensure(ctx, source, allowPickle, strings.ToLower(sha256))
|
||||
}
|
||||
|
||||
// tracerName names the spans this package opens.
|
||||
@@ -125,7 +138,7 @@ func redact(source string) string {
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func (f *Fetcher) ensure(ctx context.Context, source string, allowPickle bool) (string, error) {
|
||||
func (f *Fetcher) ensure(ctx context.Context, source string, allowPickle bool, want string) (string, error) {
|
||||
if source == "" {
|
||||
return "", fmt.Errorf("no source given")
|
||||
}
|
||||
@@ -133,7 +146,11 @@ func (f *Fetcher) ensure(ctx context.Context, source string, allowPickle bool) (
|
||||
// file, and a set has to be handled before any of it.
|
||||
if IsSet(source) {
|
||||
outcome(ctx, "set")
|
||||
return f.ensureSet(ctx, source, allowPickle)
|
||||
// Shared like a single file: four replicas placed on one node's
|
||||
// four cards want the same set at the same instant, and each
|
||||
// fetching it wrote the same .part files over one another — three
|
||||
// failed renaming a file another had already renamed.
|
||||
return f.shared(ctx, source, func() (string, error) { return f.ensureSet(ctx, source, allowPickle) })
|
||||
}
|
||||
if !isRemote(source) {
|
||||
if !isPath(source) {
|
||||
@@ -156,10 +173,24 @@ func (f *Fetcher) ensure(ctx context.Context, source string, allowPickle bool) (
|
||||
return "", err
|
||||
}
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
outcome(ctx, "cached")
|
||||
return target, nil
|
||||
if ok, err := f.cachedIs(target, want); err != nil {
|
||||
return "", err
|
||||
} else if ok {
|
||||
outcome(ctx, "cached")
|
||||
return target, nil
|
||||
}
|
||||
// Not the file the catalog names: fetched again below, and checked.
|
||||
}
|
||||
|
||||
return f.shared(ctx, source, func() (string, error) {
|
||||
outcome(ctx, "downloaded")
|
||||
return f.fetchChecked(ctx, source, target, want)
|
||||
})
|
||||
}
|
||||
|
||||
// shared runs fetch once for every caller asking for source at the same
|
||||
// time; the others wait for its answer.
|
||||
func (f *Fetcher) shared(ctx context.Context, source string, fetch func() (string, error)) (string, error) {
|
||||
f.mu.Lock()
|
||||
if f.inflight == nil {
|
||||
f.inflight = map[string]*fetchCall{}
|
||||
@@ -178,8 +209,7 @@ func (f *Fetcher) ensure(ctx context.Context, source string, allowPickle bool) (
|
||||
f.inflight[source] = call
|
||||
f.mu.Unlock()
|
||||
|
||||
outcome(ctx, "downloaded")
|
||||
call.path, call.err = f.fetch(ctx, source, target)
|
||||
call.path, call.err = fetch()
|
||||
close(call.done)
|
||||
|
||||
f.mu.Lock()
|
||||
@@ -190,9 +220,83 @@ func (f *Fetcher) ensure(ctx context.Context, source string, allowPickle bool) (
|
||||
|
||||
// fetch downloads a source to a target, resuming and verifying.
|
||||
func (f *Fetcher) fetch(ctx context.Context, source, target string) (string, error) {
|
||||
return f.fetchAs(ctx, source, source, target)
|
||||
return f.fetchAs(ctx, source, source, target, setShare{})
|
||||
}
|
||||
|
||||
// fetchChecked is fetch for a file whose sha256 is known: a download that
|
||||
// is not it is deleted and refused, naming both, and one that is has its
|
||||
// checksum kept beside it so a cached copy is not read again at each start.
|
||||
func (f *Fetcher) fetchChecked(ctx context.Context, source, target, want string) (string, error) {
|
||||
got, err := f.fetch(ctx, source, target)
|
||||
if err != nil || want == "" {
|
||||
return got, err
|
||||
}
|
||||
sum, err := fileSHA256(target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if sum != want {
|
||||
_ = os.Remove(target)
|
||||
return "", fmt.Errorf("%s arrived with sha256 %s, and the registry published %s for it: refused and deleted",
|
||||
redact(source), sum, want)
|
||||
}
|
||||
_ = writeChecksum(target, sum)
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// cachedIs says whether a cached file is the one whose sha256 is want: by
|
||||
// the checksum kept beside it, or worked out once and then kept. One that
|
||||
// is not is deleted, to be fetched again. Any file is, when want is empty.
|
||||
func (f *Fetcher) cachedIs(target, want string) (bool, error) {
|
||||
kept := readChecksum(target)
|
||||
if want == "" || kept == want {
|
||||
return true, nil
|
||||
}
|
||||
if kept != "" {
|
||||
// Checked when it was fetched, as another file: the same address
|
||||
// gives the same bytes again, and another model may be serving
|
||||
// these. Refused, and left.
|
||||
return false, fmt.Errorf("the copy of this file here has sha256 %s, as fetched, and the catalog names %s: refused",
|
||||
kept, want)
|
||||
}
|
||||
sum, err := fileSHA256(target)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if sum == want {
|
||||
_ = writeChecksum(target, sum)
|
||||
return true, nil
|
||||
}
|
||||
if f.Log != nil {
|
||||
f.Log.Warn("a cached file is not the one the catalog names; fetching it again",
|
||||
"path", target, "sha256", sum, "want", want)
|
||||
}
|
||||
_ = os.Remove(checksumPath(target))
|
||||
return false, os.Remove(target)
|
||||
}
|
||||
|
||||
// checksumPath is where a cached file's sha256 is kept: beside it, hidden,
|
||||
// so no scan takes it for weights.
|
||||
func checksumPath(file string) string {
|
||||
return filepath.Join(filepath.Dir(file), "."+filepath.Base(file)+".sha256")
|
||||
}
|
||||
|
||||
func writeChecksum(file, sum string) error {
|
||||
return os.WriteFile(checksumPath(file), []byte(sum+"\n"), 0o644) //nolint:gosec // a checksum, for anyone to read
|
||||
}
|
||||
|
||||
func readChecksum(file string) string {
|
||||
b, err := os.ReadFile(checksumPath(file)) //nolint:gosec // beside a file this fetcher wrote
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// ChecksumPath is where the sha256 of a cached file is kept, for whoever
|
||||
// deletes the file to delete with it.
|
||||
func ChecksumPath(file string) string { return checksumPath(file) }
|
||||
|
||||
// fetchAs downloads one URL but reports progress under another name.
|
||||
//
|
||||
// The two differ for a set: the shards waiting on it asked for the version, not
|
||||
@@ -201,7 +305,7 @@ func (f *Fetcher) fetch(ctx context.Context, source, target string) (string, err
|
||||
//
|
||||
// Each file is a "download" span, with how many bytes it took and where a
|
||||
// resumed one started from.
|
||||
func (f *Fetcher) fetchAs(ctx context.Context, source, reportAs, target string) (_ string, err error) {
|
||||
func (f *Fetcher) fetchAs(ctx context.Context, source, reportAs, target string, share setShare) (_ string, err error) {
|
||||
ctx, span := obs.Tracer(tracerName).Start(ctx, "download",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(attribute.String("llmgw.weights.source", redact(source))))
|
||||
@@ -265,8 +369,8 @@ func (f *Fetcher) fetchAs(ctx context.Context, source, reportAs, target string)
|
||||
}
|
||||
|
||||
total := existing + resp.ContentLength
|
||||
f.report(Progress{Source: reportAs, BytesDone: existing, BytesTotal: total})
|
||||
written, err := f.copyWithProgress(ctx, out, resp.Body, reportAs, existing, total)
|
||||
f.report(share.of(Progress{Source: reportAs, BytesDone: existing, BytesTotal: total}))
|
||||
written, err := f.copyWithProgress(ctx, out, resp.Body, reportAs, existing, total, share)
|
||||
span.SetAttributes(
|
||||
attribute.Int64("llmgw.weights.resumed_from", existing),
|
||||
attribute.Int64("llmgw.weights.bytes", written),
|
||||
@@ -290,7 +394,7 @@ func (f *Fetcher) fetchAs(ctx context.Context, source, reportAs, target string)
|
||||
}
|
||||
|
||||
func (f *Fetcher) copyWithProgress(ctx context.Context, dst io.Writer, src io.Reader,
|
||||
source string, done, total int64,
|
||||
source string, done, total int64, share setShare,
|
||||
) (int64, error) {
|
||||
buf := make([]byte, 4<<20)
|
||||
last := time.Now()
|
||||
@@ -308,12 +412,12 @@ func (f *Fetcher) copyWithProgress(ctx context.Context, dst io.Writer, src io.Re
|
||||
// hundred gigabytes is twenty-five thousand callbacks, and nobody
|
||||
// needs to be told that often.
|
||||
if time.Since(last) > 2*time.Second {
|
||||
f.report(Progress{Source: source, BytesDone: done, BytesTotal: total})
|
||||
f.report(share.of(Progress{Source: source, BytesDone: done, BytesTotal: total}))
|
||||
last = time.Now()
|
||||
}
|
||||
}
|
||||
if errors.Is(err, io.EOF) {
|
||||
f.report(Progress{Source: source, BytesDone: done, BytesTotal: total})
|
||||
f.report(share.of(Progress{Source: source, BytesDone: done, BytesTotal: total}))
|
||||
return done, nil
|
||||
}
|
||||
if err != nil {
|
||||
@@ -322,6 +426,18 @@ func (f *Fetcher) copyWithProgress(ctx context.Context, dst io.Writer, src io.Re
|
||||
}
|
||||
}
|
||||
|
||||
// setShare places one file's download within its set's: the bytes of the
|
||||
// files before it, and the whole set's. Zero for a file on its own.
|
||||
type setShare struct{ before, total int64 }
|
||||
|
||||
// of is a file's progress as the set's.
|
||||
func (s setShare) of(p Progress) Progress {
|
||||
if s.total > 0 {
|
||||
p.BytesDone, p.BytesTotal = s.before+p.BytesDone, s.total
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (f *Fetcher) report(p Progress) {
|
||||
if f.OnProgress != nil {
|
||||
f.OnProgress(p)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package weights
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -287,3 +289,75 @@ func TestAnAbsolutePathThatIsMissingIsStillAnError(t *testing.T) {
|
||||
t.Errorf("err = %v, want ErrNotLocal", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A file whose sha256 the registry published is served only as that file:
|
||||
// changed upstream after the model was added, it is refused with both
|
||||
// checksums named, and a cached copy that is not it is fetched again.
|
||||
func TestAFileThatIsNotWhatTheRegistryPublishedIsRefused(t *testing.T) {
|
||||
body := "the weights as published"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
sum := sha256.Sum256([]byte(body))
|
||||
want := hex.EncodeToString(sum[:])
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
src := srv.URL + "/model.gguf"
|
||||
|
||||
path, err := f.EnsureChecked(t.Context(), src, false, strings.ToUpper(want))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if readChecksum(path) != want {
|
||||
t.Errorf("the checksum was not kept beside it: %q", readChecksum(path))
|
||||
}
|
||||
|
||||
// The cached copy changed under it: fetched again, and the right one kept.
|
||||
if err := os.WriteFile(path, []byte("tampered"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Remove(checksumPath(path))
|
||||
if _, err := f.EnsureChecked(t.Context(), src, false, want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b, _ := os.ReadFile(path); string(b) != body {
|
||||
t.Errorf("the cached copy that was not it was served: %q", b)
|
||||
}
|
||||
|
||||
// Upstream changed after the model was added.
|
||||
body = "weights someone replaced"
|
||||
f2 := &Fetcher{CacheDir: t.TempDir()}
|
||||
_, err = f2.EnsureChecked(t.Context(), src, false, want)
|
||||
if err == nil || !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), "sha256") {
|
||||
t.Fatalf("not refused, or without both checksums: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(CachePathFor(f2.CacheDir, src)); !os.IsNotExist(err) {
|
||||
t.Error("the file that was not it was kept")
|
||||
}
|
||||
// With nothing to check against, it is fetched as before.
|
||||
if _, err := f2.Ensure(t.Context(), src, false); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// A cached file checked when it was fetched, that another model names with
|
||||
// another checksum, is refused and left for the model it is.
|
||||
func TestACheckedFileAnotherChecksumNamesIsRefusedAndLeft(t *testing.T) {
|
||||
body := "the weights"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }))
|
||||
defer srv.Close()
|
||||
sum := sha256.Sum256([]byte(body))
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
src := srv.URL + "/m.gguf"
|
||||
path, err := f.EnsureChecked(t.Context(), src, false, hex.EncodeToString(sum[:]))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrong := strings.Repeat("0", 64)
|
||||
if _, err := f.EnsureChecked(t.Context(), src, false, wrong); err == nil || !strings.Contains(err.Error(), wrong) {
|
||||
t.Fatalf("not refused: %v", err)
|
||||
}
|
||||
if b, err := os.ReadFile(path); err != nil || string(b) != body {
|
||||
t.Errorf("the checked file was not left: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,17 @@ func CachePathFor(dir, source string) string {
|
||||
return filepath.Join(dir, hex.EncodeToString(sum[:8])+"-"+name)
|
||||
}
|
||||
|
||||
// cacheSource is the source a cache name is computed from: a set's address
|
||||
// without its trailing slash, as its directory was named when it was fetched.
|
||||
// Computed from the slash-ended address, every set in the catalog looked like
|
||||
// nobody's, and a sweep offered 330 GB of models still in use for deletion.
|
||||
func cacheSource(source string) string {
|
||||
if IsSet(source) {
|
||||
return strings.TrimSuffix(source, "/")
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
// Owned reports whether a path is one this package created.
|
||||
//
|
||||
// The dividing line of the whole feature. A file without the cache name was
|
||||
@@ -65,7 +76,7 @@ func Orphans(have []string, wanted []string) []string {
|
||||
// By base name rather than full path: the control plane does not know
|
||||
// each node's cache directory, and the name is what identifies the
|
||||
// file wherever the node keeps it.
|
||||
keep[filepath.Base(CachePathFor("", src))] = struct{}{}
|
||||
keep[filepath.Base(CachePathFor("", cacheSource(src)))] = struct{}{}
|
||||
}
|
||||
|
||||
var out []string
|
||||
|
||||
+114
-16
@@ -5,12 +5,16 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IsSet reports whether a source names a set of files rather than one.
|
||||
@@ -47,7 +51,7 @@ type manifest struct {
|
||||
// as a broken model rather than failing to load, so a partial set is left
|
||||
// unfinished under its temporary name and the next attempt starts again.
|
||||
func (f *Fetcher) ensureSet(ctx context.Context, source string, allowPickle bool) (string, error) {
|
||||
dir, err := f.cachePath(strings.TrimSuffix(source, "/"))
|
||||
dir, err := f.cachePath(cacheSource(source))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -89,6 +93,12 @@ func (f *Fetcher) ensureSet(ctx context.Context, source string, allowPickle bool
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// One number for the whole set, as the shards waiting on it see it: the
|
||||
// file being fetched was shown alone, a 46 GB part of 188.
|
||||
var setTotal, before int64
|
||||
for _, file := range m.Files {
|
||||
setTotal += file.Bytes
|
||||
}
|
||||
for _, file := range m.Files {
|
||||
// Rejected rather than joined: a name with a separator in it would
|
||||
// write outside the directory the mirror said it was in, and the
|
||||
@@ -98,34 +108,24 @@ func (f *Fetcher) ensureSet(ctx context.Context, source string, allowPickle bool
|
||||
}
|
||||
target := filepath.Join(dir, file.Name)
|
||||
if st, err := os.Stat(target); err == nil && st.Size() == file.Bytes {
|
||||
before += file.Bytes
|
||||
continue
|
||||
}
|
||||
// Reported under the set's own address, because that is what the
|
||||
// shards are waiting on. Reusing fetch rather than writing a second
|
||||
// downloader keeps resume, the .part rename and the progress callback
|
||||
// in one place.
|
||||
if _, err := f.fetchAs(ctx, source+file.Name, source, target); err != nil {
|
||||
if _, err := f.fetchAs(ctx, source+file.Name, source, target, setShare{before: before, total: setTotal}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
before += file.Bytes
|
||||
}
|
||||
|
||||
// Each file against the checksum the mirror took when it fetched it,
|
||||
// before the set is called complete: a shard damaged on the way loads as
|
||||
// a model that answers nonsense, not as an error.
|
||||
for _, file := range m.Files {
|
||||
if file.SHA256 == "" {
|
||||
continue
|
||||
}
|
||||
target := filepath.Join(dir, file.Name)
|
||||
got, err := fileSHA256(target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !strings.EqualFold(got, file.SHA256) {
|
||||
_ = os.Remove(target)
|
||||
return "", fmt.Errorf("%s%s: sha256 %s, the mirror has %s; removed, to be fetched again",
|
||||
source, file.Name, got, file.SHA256)
|
||||
}
|
||||
if err := f.checkSet(ctx, source, dir, m); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(done, []byte(source+"\n"), 0o644); err != nil {
|
||||
@@ -187,3 +187,101 @@ func fileSHA256(path string) (string, error) {
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// checkSetAtOnce is how many of a set's files are checked at once: a set of
|
||||
// six 50 GB files took eight minutes on one core, with nothing said.
|
||||
const checkSetAtOnce = 4
|
||||
|
||||
// checkSet checks each file of a set against the checksum the mirror took
|
||||
// when it fetched it, several at once, saying how far it has got.
|
||||
func (f *Fetcher) checkSet(ctx context.Context, source, dir string, m manifest) error {
|
||||
var total int64
|
||||
for _, file := range m.Files {
|
||||
if file.SHA256 != "" {
|
||||
total += file.Bytes
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return nil // nothing the mirror took a checksum of
|
||||
}
|
||||
var done atomic.Int64
|
||||
stop := make(chan struct{})
|
||||
defer close(stop)
|
||||
go func() {
|
||||
t := time.NewTicker(2 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
f.report(Progress{Source: source, BytesDone: done.Load(), BytesTotal: total, Phase: PhaseChecking})
|
||||
}
|
||||
}
|
||||
}()
|
||||
f.report(Progress{Source: source, BytesTotal: total, Phase: PhaseChecking})
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
first error
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
slots := make(chan struct{}, checkSetAtOnce)
|
||||
for _, file := range m.Files {
|
||||
if file.SHA256 == "" {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
slots <- struct{}{}
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-slots }()
|
||||
target := filepath.Join(dir, file.Name)
|
||||
got, err := countedSHA256(ctx, target, &done)
|
||||
if err == nil && !strings.EqualFold(got, file.SHA256) {
|
||||
_ = os.Remove(target)
|
||||
err = fmt.Errorf("%s%s: sha256 %s, the mirror has %s; removed, to be fetched again",
|
||||
source, file.Name, got, file.SHA256)
|
||||
}
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
if first == nil {
|
||||
first = err
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if first == nil {
|
||||
f.report(Progress{Source: source, BytesDone: total, BytesTotal: total, Phase: PhaseChecking})
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
// countedSHA256 is a file's checksum, adding what it has read to done.
|
||||
func countedSHA256(ctx context.Context, path string, done *atomic.Int64) (string, error) {
|
||||
file, err := os.Open(path) //nolint:gosec // a file this fetcher wrote
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
h := sha256.New()
|
||||
buf := make([]byte, 4<<20)
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
n, err := file.Read(buf)
|
||||
if n > 0 {
|
||||
h.Write(buf[:n])
|
||||
done.Add(int64(n))
|
||||
}
|
||||
if errors.Is(err, io.EOF) {
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,10 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mirrorOf serves a manifest at "/m/v/" and the files beneath it.
|
||||
@@ -71,6 +74,23 @@ func TestASetIsFetchedIntoOneDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestASetInTheCatalogIsNotAnOrphan(t *testing.T) {
|
||||
// The catalog names a set by its address with the trailing slash; the
|
||||
// fetch names the directory without it. A sweep that computed the name
|
||||
// from the catalog's spelling offered every set in use for deletion.
|
||||
f, srv, _ := mirrorOf(t, map[string]string{"model.safetensors": "aaaa", "config.json": "{}"})
|
||||
dir, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := Orphans([]string{dir}, []string{srv.URL + "/m/v/"}); len(got) != 0 {
|
||||
t.Errorf("a set the catalog still names was called an orphan: %v", got)
|
||||
}
|
||||
if got := Orphans([]string{dir}, nil); len(got) != 1 {
|
||||
t.Errorf("a set nothing names was not found: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFinishedSetIsNotFetchedAgain(t *testing.T) {
|
||||
f, srv, hits := mirrorOf(t, map[string]string{"a.gguf": "aaaa", "b.gguf": "bb"})
|
||||
|
||||
@@ -204,3 +224,103 @@ func TestASetStillDownloadingOnTheMirrorSaysSo(t *testing.T) {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A set's download is one number climbing against the whole set, not each
|
||||
// part's own: a 46 GB part of 188 was shown as the download.
|
||||
func TestASetsProgressIsTheWholeSets(t *testing.T) {
|
||||
f, srv, _ := mirrorOf(t, map[string]string{
|
||||
"m-00001-of-00002.gguf": strings.Repeat("a", 600),
|
||||
"m-00002-of-00002.gguf": strings.Repeat("b", 400),
|
||||
})
|
||||
var seen []Progress
|
||||
f.OnProgress = func(p Progress) { seen = append(seen, p) }
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
t.Fatal("no progress reported")
|
||||
}
|
||||
last := int64(-1)
|
||||
for _, p := range seen {
|
||||
if p.BytesTotal != 1000 {
|
||||
t.Fatalf("reported against %d, not the set's 1000: %+v", p.BytesTotal, seen)
|
||||
}
|
||||
if p.BytesDone < last {
|
||||
t.Fatalf("went back: %+v", seen)
|
||||
}
|
||||
last = p.BytesDone
|
||||
}
|
||||
if last != 1000 {
|
||||
t.Errorf("ended at %d", last)
|
||||
}
|
||||
}
|
||||
|
||||
// A set's files checked after they arrive say so, as far as they have got,
|
||||
// rather than leaving the download at 100% for minutes.
|
||||
func TestASetBeingCheckedSaysSo(t *testing.T) {
|
||||
parts := map[string]string{"a.gguf": strings.Repeat("a", 300), "b.gguf": strings.Repeat("b", 200)}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/") {
|
||||
var files []string
|
||||
for n, body := range parts {
|
||||
sum := sha256.Sum256([]byte(body))
|
||||
files = append(files, fmt.Sprintf(`{"name":%q,"bytes":%d,"sha256":%q}`, n, len(body), hex.EncodeToString(sum[:])))
|
||||
}
|
||||
fmt.Fprintf(w, `{"model":"m","version":"v","files":[%s]}`, strings.Join(files, ","))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(parts[strings.TrimPrefix(r.URL.Path, "/m/v/")]))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
var mu sync.Mutex
|
||||
var checking []Progress
|
||||
f := &Fetcher{CacheDir: t.TempDir(), OnProgress: func(p Progress) {
|
||||
if p.Phase == PhaseChecking {
|
||||
mu.Lock()
|
||||
checking = append(checking, p)
|
||||
mu.Unlock()
|
||||
}
|
||||
}}
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(checking) < 2 || checking[0].BytesDone != 0 || checking[len(checking)-1].BytesDone != 500 ||
|
||||
checking[len(checking)-1].BytesTotal != 500 {
|
||||
t.Errorf("%+v", checking)
|
||||
}
|
||||
}
|
||||
|
||||
// Four replicas on one node's four cards start together and want the same
|
||||
// set: it is fetched once, and every one of them gets it. Each fetching it
|
||||
// alone wrote the same .part files over one another, and three failed.
|
||||
func TestASetWantedByFourReplicasAtOnceIsFetchedOnce(t *testing.T) {
|
||||
body := strings.Repeat("a", 1<<20)
|
||||
var hits atomic.Int64
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/") {
|
||||
fmt.Fprintf(w, `{"model":"m","version":"v","files":[{"name":"a.safetensors","bytes":%d},{"name":"b.safetensors","bytes":%d}]}`,
|
||||
len(body), len(body))
|
||||
return
|
||||
}
|
||||
hits.Add(1)
|
||||
time.Sleep(100 * time.Millisecond) // long enough for all four to ask
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, 4)
|
||||
for i := range errs {
|
||||
wg.Go(func() { _, errs[i] = f.Ensure(t.Context(), srv.URL+"/m/v/", false) })
|
||||
}
|
||||
wg.Wait()
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Errorf("replica %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if n := hits.Load(); n != 2 {
|
||||
t.Errorf("%d downloads of a set of two files", n)
|
||||
}
|
||||
}
|
||||
@@ -112,8 +112,9 @@ func (w *statusWriter) Flush() {
|
||||
}
|
||||
|
||||
// notChanges are POSTs that change nothing and carry text that must not be
|
||||
// kept: asking a judge about a sentence.
|
||||
var notChanges = []string{"/api/v1/guard/judges/ask"}
|
||||
// kept: asking a judge about a sentence. And a standby's report of what its
|
||||
// router sent where, every two seconds: activity, not an operator's change.
|
||||
var notChanges = []string{"/api/v1/guard/judges/ask", "/api/v1/routing/activity"}
|
||||
|
||||
// API records every change to the admin API: a request that is not a GET or
|
||||
// HEAD, under /api/. actor says who made it.
|
||||
|
||||
+35
-17
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
// Principal is who a request is, once authenticated.
|
||||
@@ -118,6 +119,12 @@ type Authenticator struct {
|
||||
// lookups rations the database reads of keys not in the cache, per
|
||||
// client address.
|
||||
lookups *buckets
|
||||
|
||||
// resolving shares one database read between the requests that miss the
|
||||
// cache for the same key at once: when a busy key's entry expired, every
|
||||
// request in flight missed together, each took from the client's ration,
|
||||
// and a valid key was told to slow down.
|
||||
resolving singleflight.Group
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
@@ -166,26 +173,37 @@ func (a *Authenticator) AuthenticateFrom(ctx context.Context, presented, client
|
||||
return e.principal, e.err
|
||||
}
|
||||
|
||||
if client != "" && !a.lookups.allow(client, a.now()) {
|
||||
return Principal{}, ErrThrottled
|
||||
}
|
||||
p, err := a.resolve(ctx, hash)
|
||||
if err != nil && !isRejection(err) {
|
||||
// A database problem is not an authentication answer, so it must not be
|
||||
// cached as one. Failing the request is right — the alternative is
|
||||
// serving traffic whose authorization is unknown.
|
||||
// One read for every request missing this key at once, rationed once.
|
||||
v, err, _ := a.resolving.Do(id, func() (any, error) {
|
||||
if e, ok := a.lookup(id); ok { // filled while this waited its turn
|
||||
return e, nil
|
||||
}
|
||||
if client != "" && !a.lookups.allow(client, a.now()) {
|
||||
return nil, ErrThrottled
|
||||
}
|
||||
p, err := a.resolve(ctx, hash)
|
||||
if err != nil && !isRejection(err) {
|
||||
// A database problem is not an authentication answer, so it must
|
||||
// not be cached as one. Failing the request is right — the
|
||||
// alternative is serving traffic whose authorization is unknown.
|
||||
return nil, err
|
||||
}
|
||||
ttl := a.cfg.TTL
|
||||
if err != nil {
|
||||
ttl = a.cfg.NegativeTTL
|
||||
}
|
||||
e := entry{principal: p, err: err, expires: a.now().Add(ttl)}
|
||||
a.store_(id, e)
|
||||
return e, nil
|
||||
})
|
||||
if err != nil {
|
||||
return Principal{}, err
|
||||
}
|
||||
|
||||
ttl := a.cfg.TTL
|
||||
if err != nil {
|
||||
ttl = a.cfg.NegativeTTL
|
||||
e := v.(entry)
|
||||
if e.err == nil {
|
||||
a.markUsed(e.principal.KeyID)
|
||||
}
|
||||
a.store_(id, entry{principal: p, err: err, expires: a.now().Add(ttl)})
|
||||
if err == nil {
|
||||
a.markUsed(p.KeyID)
|
||||
}
|
||||
return p, err
|
||||
return e.principal, e.err
|
||||
}
|
||||
|
||||
func (a *Authenticator) resolve(ctx context.Context, hash []byte) (Principal, error) {
|
||||
|
||||
@@ -478,3 +478,41 @@ func TestTheCacheDoesNotGrowWithoutBound(t *testing.T) {
|
||||
t.Errorf("%d entries", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A busy key whose entry has expired: every request in flight misses at once,
|
||||
// and they share one read and one of the address's allowance. Each taking
|
||||
// its own told a valid key to slow down, every thirty seconds, under load.
|
||||
func TestABusyKeyMissingTheCacheTogetherIsReadOnceAndNotThrottled(t *testing.T) {
|
||||
s, secret, _ := fixture(t)
|
||||
c := &slowStore{countingStore: countingStore{Store: s}, delay: 50 * time.Millisecond}
|
||||
a := New(c, DefaultConfig())
|
||||
var wg sync.WaitGroup
|
||||
var throttled atomic.Int64
|
||||
for range 200 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := a.AuthenticateFrom(context.Background(), secret, "203.0.113.9"); errors.Is(err, ErrThrottled) {
|
||||
throttled.Add(1)
|
||||
} else if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if throttled.Load() != 0 || c.lookups.Load() != 1 {
|
||||
t.Errorf("%d throttled, %d database reads", throttled.Load(), c.lookups.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// slowStore is a countingStore whose reads take a while, so the requests
|
||||
// missing together are still waiting when the first one asks.
|
||||
type slowStore struct {
|
||||
countingStore
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (s *slowStore) KeyByHash(ctx context.Context, hash []byte) (store.Key, error) {
|
||||
time.Sleep(s.delay)
|
||||
return s.countingStore.KeyByHash(ctx, hash)
|
||||
}
|
||||
@@ -247,6 +247,8 @@ var liveSettings = []string{
|
||||
"presets",
|
||||
// Handed to the reconciler as they change.
|
||||
"on_demand",
|
||||
// Read by the Slurm service each pass, kicked when they change.
|
||||
"slurm",
|
||||
// Read by server init, node add and backups, not by the server.
|
||||
"backup_recipients", "backup_copy_to",
|
||||
}
|
||||
|
||||
+23
-3
@@ -189,6 +189,7 @@ func newCtlCmd() *cobra.Command {
|
||||
newMCPCmd(client, &asJSON),
|
||||
newKBCmd(client, &asJSON),
|
||||
newTrainCmd(client, &asJSON),
|
||||
newSlurmCmd(client, &asJSON),
|
||||
newConfigCmd(client, &asJSON),
|
||||
newAuditCmd(client, &asJSON),
|
||||
newNotifyCmd(client, &asJSON),
|
||||
@@ -321,6 +322,8 @@ func locationColumn(n adminapi.NodeView) string {
|
||||
// actually served, never what is merely catalogued.
|
||||
func modelStatus(m adminapi.ModelView) string {
|
||||
switch {
|
||||
case m.Stopped:
|
||||
return "stopped (llmgw ctl model start)"
|
||||
case m.Parked:
|
||||
return "parked"
|
||||
case m.External != "":
|
||||
@@ -416,7 +419,7 @@ func newModelsCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%d/%d\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
name, serving, m.Ready, m.Min, modelStatus(m), m.Class, reserves(m),
|
||||
join(m.Aliases), join(m.Profiles), join(m.Fallbacks))
|
||||
join(m.Aliases), join(profilesWithCounts(m)), join(m.Fallbacks))
|
||||
for _, x := range m.Warnings {
|
||||
warnings = append(warnings, m.Name+": "+x)
|
||||
}
|
||||
@@ -497,8 +500,12 @@ func newReplicasCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
if s.Fetch == nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, " %s: fetching %s\t\t\t\t\t\t\t\t\n",
|
||||
s.Node, fetchColumn(s.Fetch))
|
||||
what := "fetching"
|
||||
if s.Fetch.Phase != "" {
|
||||
what = s.Fetch.Phase
|
||||
}
|
||||
fmt.Fprintf(w, " %s: %s %s\t\t\t\t\t\t\t\t\n",
|
||||
s.Node, what, fetchColumn(s.Fetch))
|
||||
}
|
||||
}
|
||||
_ = w.Flush()
|
||||
@@ -690,3 +697,16 @@ func reserves(m adminapi.ModelView) string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// profilesWithCounts are a model's profiles, each with its replicas when
|
||||
// they are counted per profile: gpu=7, cpu=4.
|
||||
func profilesWithCounts(m adminapi.ModelView) []string {
|
||||
if len(m.ProfileReplicas) == 0 {
|
||||
return m.Profiles
|
||||
}
|
||||
out := make([]string, len(m.Profiles))
|
||||
for i, p := range m.Profiles {
|
||||
out[i] = fmt.Sprintf("%s=%d", p, m.ProfileReplicas[p])
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/slurmsvc"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
|
||||
)
|
||||
|
||||
// newSlurmCmd is the Slurm cluster run beside llmgw (BACKLOG P): set up on
|
||||
// nodes llmgw already has, and nodes handed between the two.
|
||||
func newSlurmCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "slurm",
|
||||
Short: "A Slurm cluster beside llmgw, for training and other batch work",
|
||||
Long: "Slurm runs on llmgw's own nodes, installed and kept by their agents: slurmctld on one node,\n" +
|
||||
"slurmd on those named. A node is llmgw's, drained in Slurm, until it is lent; lent, its\n" +
|
||||
"replicas move off first, then Slurm may run jobs there. Taken back, Slurm drains it and\n" +
|
||||
"llmgw has it again when its last job ends. Jobs are submitted on the controller node\n" +
|
||||
"(sbatch, srun) by its own users, as on any Slurm cluster.",
|
||||
}
|
||||
cmd.AddCommand(newSlurmStatusCmd(client, asJSON), newSlurmSetupCmd(client), newSlurmOffCmd(client),
|
||||
newSlurmListCmd(client, "lend", "Let Slurm run jobs on nodes; llmgw's replicas move off first", true),
|
||||
newSlurmListCmd(client, "reclaim", "Take nodes back from Slurm; llmgw has them when their jobs end", false),
|
||||
newSlurmSharedCmd(client), newSlurmUsersCmd(client, asJSON), newSlurmContainersCmd(client))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newSlurmContainersCmd(client func() *ctlClient) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "containers",
|
||||
Short: "Jobs in containers: srun --container-image (pyxis and enroot)",
|
||||
Long: "On, the agents install enroot and pyxis on every machine of the Slurm cluster (from NVIDIA's\n" +
|
||||
"releases, at versions llmgw pins and checks) and, on GPU machines, nvidia-container-cli; once\n" +
|
||||
"every machine has them, Slurm loads pyxis and jobs may run in a container:\n\n" +
|
||||
" srun --gres=gpu:1 --container-image=nvcr.io#nvidia/pytorch:25.01-py3 python train.py\n\n" +
|
||||
"The shared path is in every container where it is on the host. Off, Slurm stops loading pyxis;\n" +
|
||||
"what was installed stays.",
|
||||
}
|
||||
var note string
|
||||
toggle := func(use, short string, on bool) *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return changeSettings(cmd, client, note, func(doc *settings.Doc) error {
|
||||
if !on {
|
||||
if doc.Node([]string{"slurm", "containers"}) != nil {
|
||||
doc.Unset([]string{"slurm", "containers"})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return doc.SetYAML([]string{"slurm", "containers"}, "true")
|
||||
})
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(¬e, "note", "", "why, kept with the settings' version")
|
||||
return c
|
||||
}
|
||||
cmd.AddCommand(toggle("on", "Install pyxis and enroot everywhere, and let jobs run in containers", true),
|
||||
toggle("off", "Slurm stops loading pyxis; what was installed stays", false))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newSlurmSharedCmd(client func() *ctlClient) *cobra.Command {
|
||||
var path, server, nfs, note string
|
||||
var off bool
|
||||
c := &cobra.Command{
|
||||
Use: "shared --path DIR (--server NODE | --nfs HOST:/EXPORT) | --off",
|
||||
Short: "Files every machine of the Slurm cluster sees at one path",
|
||||
Long: "Home directories, datasets, the jobs' output and checkpoints, at the same path on the controller\n" +
|
||||
"and every node. Three ways:\n" +
|
||||
" --nfs HOST:/EXPORT the site's own NFS export, mounted at --path on every machine;\n" +
|
||||
" --server NODE an llmgw node (the controller or a node) exports --path over NFS to the\n" +
|
||||
" others, for a site with no storage of its own: one machine, not highly\n" +
|
||||
" available, no faster than its disk and network;\n" +
|
||||
" neither the site mounts --path itself (Lustre, BeeGFS, GPFS); llmgw only looks\n" +
|
||||
" that it is there.\n" +
|
||||
"The export answers the Slurm machines alone, NFS's port the cluster's networks. A machine\n" +
|
||||
"that gives up its mount does so when no job holds files on it.",
|
||||
Example: " llmgw ctl slurm shared --path /shared --server node06\n" +
|
||||
" llmgw ctl slurm shared --path /shared --nfs filer.site:/export/hpc\n" +
|
||||
" llmgw ctl slurm shared --path /lustre/hpc\n" +
|
||||
" llmgw ctl slurm shared --off",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if off == (path != "") {
|
||||
return fmt.Errorf("give --path, or --off")
|
||||
}
|
||||
return changeSettings(cmd, client, note, func(doc *settings.Doc) error {
|
||||
if off {
|
||||
if doc.Node([]string{"slurm", "shared"}) != nil {
|
||||
doc.Unset([]string{"slurm", "shared"})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
v := map[string]string{"path": path}
|
||||
if server != "" {
|
||||
v["server"] = server
|
||||
}
|
||||
if nfs != "" {
|
||||
v["nfs"] = nfs
|
||||
}
|
||||
b, _ := json.Marshal(v)
|
||||
return doc.SetYAML([]string{"slurm", "shared"}, string(b))
|
||||
})
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(&path, "path", "", "where every machine sees the files, as /shared")
|
||||
c.Flags().StringVar(&server, "server", "", "the llmgw node that exports them over NFS")
|
||||
c.Flags().StringVar(&nfs, "nfs", "", "the site's NFS export, HOST:/EXPORT")
|
||||
c.Flags().BoolVar(&off, "off", false, "no shared files; mounts and exports llmgw made are taken away")
|
||||
c.Flags().StringVar(¬e, "note", "", "why, kept with the settings' version")
|
||||
return c
|
||||
}
|
||||
|
||||
func newSlurmUsersCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "users",
|
||||
Short: "The Linux users that run jobs, made from llmgw's accounts",
|
||||
Long: "With slurm.users \"llmgw\" every active account is a Linux user on every machine of the Slurm\n" +
|
||||
"cluster: its login from its e-mail address, a UID of llmgw's from 200000 up (never given to\n" +
|
||||
"another, even when the account goes), its home under the shared path. It logs in over SSH\n" +
|
||||
"with the keys given in the portal (Profile) or here. An account suspended or deleted loses\n" +
|
||||
"its user; its home and files stay. Off, users are the site's: from its directory (sssd), or\n" +
|
||||
"made by hand.",
|
||||
}
|
||||
list := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "Every user, with its login, UID and how many keys it has",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
var us []slurmsvc.User
|
||||
if err := client().get(cmd.Context(), "/api/v1/slurm/users", &us); err != nil {
|
||||
return err
|
||||
}
|
||||
w := cmd.OutOrStdout()
|
||||
if *asJSON {
|
||||
return json.NewEncoder(w).Encode(us)
|
||||
}
|
||||
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "LOGIN\tUID\tACCOUNT\tSSH KEYS")
|
||||
for _, u := range us {
|
||||
fmt.Fprintf(tw, "%s\t%d\t%s\t%d\n", u.Login, u.UID, u.Email, len(u.SSHKeys))
|
||||
}
|
||||
return tw.Flush()
|
||||
},
|
||||
}
|
||||
var note string
|
||||
toggle := func(use, short, value string) *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return changeSettings(cmd, client, note, func(doc *settings.Doc) error {
|
||||
if value == "" {
|
||||
if doc.Node([]string{"slurm", "users"}) != nil {
|
||||
doc.Unset([]string{"slurm", "users"})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return doc.SetYAML([]string{"slurm", "users"}, yamlString(value))
|
||||
})
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(¬e, "note", "", "why, kept with the settings' version")
|
||||
return c
|
||||
}
|
||||
keys := &cobra.Command{
|
||||
Use: "keys ACCOUNT FILE",
|
||||
Short: "Replace an account's SSH public keys, from a file (- for standard input)",
|
||||
Long: "FILE holds OpenSSH public keys, one a line, as ~/.ssh/id_ed25519.pub; an empty one takes them all away.",
|
||||
Example: " llmgw ctl slurm users keys ayse@site.example ayse.pub\n" +
|
||||
" ssh-add -L | llmgw ctl slurm users keys ayse@site.example -",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var b []byte
|
||||
var err error
|
||||
if args[1] == "-" {
|
||||
b, err = io.ReadAll(io.LimitReader(cmd.InOrStdin(), 64<<10))
|
||||
} else {
|
||||
b, err = os.ReadFile(args[1])
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"keys": string(b)})
|
||||
var u slurmsvc.User
|
||||
if err := client().put(cmd.Context(), "/api/v1/slurm/users/"+url.PathEscape(args[0])+"/ssh-keys", body, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s (UID %d) has %d SSH key(s); the machines have them within a minute\n",
|
||||
u.Login, u.UID, len(u.SSHKeys))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(list,
|
||||
toggle("on", "Make a Linux user for every active account, on every machine of the Slurm cluster", "llmgw"),
|
||||
toggle("off", "Leave users to the site; those llmgw made are taken away, their homes left", ""),
|
||||
keys)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newSlurmStatusCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "The Slurm cluster, and where each of its nodes is",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
var st slurmsvc.Status
|
||||
if err := client().get(cmd.Context(), "/api/v1/slurm", &st); err != nil {
|
||||
return err
|
||||
}
|
||||
w := cmd.OutOrStdout()
|
||||
if *asJSON {
|
||||
return json.NewEncoder(w).Encode(st)
|
||||
}
|
||||
if !st.Enabled {
|
||||
fmt.Fprintln(w, "Slurm is off. `llmgw ctl slurm setup --controller NODE --nodes NODE,...` turns it on.")
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(w, "cluster %s, partition %s, controller %s", st.Cluster, st.Partition, st.Controller)
|
||||
switch c := st.Ctld; {
|
||||
case c == nil:
|
||||
fmt.Fprintln(w, ": not heard from yet")
|
||||
case c.Error != "":
|
||||
fmt.Fprintf(w, ": %s\n", c.Error)
|
||||
case c.SlurmctldRunning && c.SlurmdbdRunning:
|
||||
fmt.Fprintf(w, ": slurmctld %s and slurmdbd running\n", c.Version)
|
||||
case c.SlurmctldRunning:
|
||||
fmt.Fprintf(w, ": slurmctld %s running, slurmdbd not (the jobs' records)\n", c.Version)
|
||||
default:
|
||||
fmt.Fprintln(w, ": slurmctld not running")
|
||||
}
|
||||
fmt.Fprintf(w, "shared files: %s\n", sharedLine(st.Shared))
|
||||
if st.Users != "" {
|
||||
fmt.Fprintf(w, "users: %d, made from llmgw's accounts\n", st.UserCount)
|
||||
}
|
||||
if line := containersLine(st); line != "" {
|
||||
fmt.Fprintf(w, "containers: %s\n", line)
|
||||
}
|
||||
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "NODE\tPHASE\tSLURM\tJOBS\tSLURMD\tSHARED\tNOTE")
|
||||
for _, n := range st.Nodes {
|
||||
state, jobs, slurmd, shared, note := "-", "-", "-", "-", n.Describe()
|
||||
if r := n.Status; r != nil {
|
||||
state = or(r.State, "-")
|
||||
jobs = fmt.Sprint(r.Jobs)
|
||||
slurmd = map[bool]string{true: "running", false: "stopped"}[r.SlurmdRunning]
|
||||
shared = or(r.Shared, "-")
|
||||
if r.Error != "" {
|
||||
note = r.Error
|
||||
}
|
||||
}
|
||||
if !n.Connected {
|
||||
note = "agent not connected; " + note
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", n.Node, n.Phase, state, jobs, slurmd, shared, note)
|
||||
}
|
||||
if err := tw.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(st.Waiting) > 0 {
|
||||
fmt.Fprintf(w, "not in slurm.conf until they report their hardware: %s\n", strings.Join(st.Waiting, ", "))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newSlurmSetupCmd(client func() *ctlClient) *cobra.Command {
|
||||
var controller, cluster, partition, note string
|
||||
var nodes []string
|
||||
var all bool
|
||||
c := &cobra.Command{
|
||||
Use: "setup --controller NODE --nodes NODE,... | --all-nodes",
|
||||
Short: "Run Slurm on llmgw's nodes: slurmctld on one, slurmd on those named",
|
||||
Long: "Turns slurm.enabled on and names the nodes, or with --all-nodes every node. Their agents install munge and Slurm (22.05 or\n" +
|
||||
"newer) from the machines' own package sources; the controller also slurmdbd, the jobs' records, in a\n" +
|
||||
"MariaDB on that machine alone (one that was there keeps its settings). Every node starts drained in Slurm and stays\n" +
|
||||
"llmgw's until `llmgw ctl slurm lend` gives it to Slurm.",
|
||||
Example: " llmgw ctl slurm setup --controller node06 --nodes cuda01,cuda02\n" +
|
||||
" llmgw ctl slurm setup --controller node06 --all-nodes",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if controller == "" || (len(nodes) == 0 && !all) {
|
||||
return fmt.Errorf("give --controller, and --nodes or --all-nodes")
|
||||
}
|
||||
return changeSettings(cmd, client, note, func(doc *settings.Doc) error {
|
||||
if err := doc.SetYAML([]string{"slurm", "enabled"}, "true"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := doc.SetYAML([]string{"slurm", "controller"}, yamlString(controller)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := setList(doc, []string{"slurm", "nodes"}, nodes); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := doc.SetYAML([]string{"slurm", "all_nodes"}, fmt.Sprint(all)); err != nil {
|
||||
return err
|
||||
}
|
||||
// Lent nodes no longer named are not lent.
|
||||
lent := slices.DeleteFunc(docList(doc, []string{"slurm", "lent"}),
|
||||
func(n string) bool { return !all && !slices.Contains(nodes, n) })
|
||||
if err := setList(doc, []string{"slurm", "lent"}, lent); err != nil {
|
||||
return err
|
||||
}
|
||||
for k, v := range map[string]string{"cluster_name": cluster, "partition": partition} {
|
||||
if v != "" {
|
||||
if err := doc.SetYAML([]string{"slurm", k}, yamlString(v)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(&controller, "controller", "", "the node that runs slurmctld")
|
||||
c.Flags().StringSliceVar(&nodes, "nodes", nil, "the nodes that run slurmd")
|
||||
c.Flags().BoolVar(&all, "all-nodes", false, "every node runs slurmd, those that join later too")
|
||||
c.Flags().StringVar(&cluster, "cluster-name", "", "Slurm's ClusterName (llmgw)")
|
||||
c.Flags().StringVar(&partition, "partition", "", "the partition every node is in (gpu)")
|
||||
c.Flags().StringVar(¬e, "note", "", "why, kept with the settings' version")
|
||||
return c
|
||||
}
|
||||
|
||||
func newSlurmOffCmd(client func() *ctlClient) *cobra.Command {
|
||||
var note string
|
||||
var force bool
|
||||
c := &cobra.Command{
|
||||
Use: "off",
|
||||
Short: "Stop running Slurm; every node is llmgw's again",
|
||||
Long: "slurmd and slurmctld are stopped where llmgw started them; the packages stay. A node still\n" +
|
||||
"lent is refused unless --force: take it back first, so its jobs end before the daemons do.",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return changeSettings(cmd, client, note, func(doc *settings.Doc) error {
|
||||
if lent := docList(doc, []string{"slurm", "lent"}); len(lent) > 0 && !force {
|
||||
return fmt.Errorf("%s still lent: `llmgw ctl slurm reclaim` them first, or --force",
|
||||
strings.Join(lent, ", "))
|
||||
}
|
||||
setList(doc, []string{"slurm", "lent"}, nil) //nolint:errcheck // an empty list
|
||||
return doc.SetYAML([]string{"slurm", "enabled"}, "false")
|
||||
})
|
||||
},
|
||||
}
|
||||
c.Flags().BoolVar(&force, "force", false, "even with nodes still lent")
|
||||
c.Flags().StringVar(¬e, "note", "", "why, kept with the settings' version")
|
||||
return c
|
||||
}
|
||||
|
||||
// containersLine says whether jobs may run in containers, and which
|
||||
// machines are not ready yet; empty when containers are off.
|
||||
func containersLine(st slurmsvc.Status) string {
|
||||
switch st.Containers {
|
||||
case "on":
|
||||
return "on (srun --container-image)"
|
||||
case "":
|
||||
return ""
|
||||
}
|
||||
var waiting []string
|
||||
for _, n := range st.Nodes {
|
||||
if n.Status == nil || n.Status.Containers == "" {
|
||||
waiting = append(waiting, n.Node)
|
||||
}
|
||||
}
|
||||
if st.Ctld == nil || st.Ctld.Containers == "" {
|
||||
if !slices.Contains(waiting, st.Controller) {
|
||||
waiting = append(waiting, st.Controller)
|
||||
}
|
||||
}
|
||||
return "installing pyxis and enroot; jobs take them once every machine has them (waiting: " +
|
||||
strings.Join(waiting, ", ") + ")"
|
||||
}
|
||||
|
||||
// sharedLine says where the shared files are and come from.
|
||||
func sharedLine(sh config.SharedStorage) string {
|
||||
switch {
|
||||
case sh.Path == "":
|
||||
return "none (`llmgw ctl slurm shared` adds them; a batch job's output stays on its node)"
|
||||
case sh.Server != "":
|
||||
return fmt.Sprintf("%s, exported over NFS by %s", sh.Path, sh.Server)
|
||||
case sh.NFS != "":
|
||||
return fmt.Sprintf("%s, mounted from %s", sh.Path, sh.NFS)
|
||||
}
|
||||
return sh.Path + ", mounted by the site"
|
||||
}
|
||||
|
||||
// newSlurmListCmd adds nodes to slurm.lent, or takes them out.
|
||||
func newSlurmListCmd(client func() *ctlClient, use, short string, add bool) *cobra.Command {
|
||||
var note string
|
||||
c := &cobra.Command{
|
||||
Use: use + " NODE...",
|
||||
Short: short,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return changeSettings(cmd, client, note, func(doc *settings.Doc) error {
|
||||
return lendChange(doc, args, add)
|
||||
})
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(¬e, "note", "", "why, kept with the settings' version")
|
||||
return c
|
||||
}
|
||||
|
||||
// lendChange adds nodes to slurm.lent, or with add false takes them out.
|
||||
func lendChange(doc *settings.Doc, names []string, add bool) error {
|
||||
nodes := docList(doc, []string{"slurm", "nodes"})
|
||||
all := docBool(doc, []string{"slurm", "all_nodes"})
|
||||
lent := docList(doc, []string{"slurm", "lent"})
|
||||
for _, n := range names {
|
||||
switch {
|
||||
case add && !all && !slices.Contains(nodes, n):
|
||||
return fmt.Errorf("%s does not run slurmd: add it with `llmgw ctl slurm setup --nodes`", n)
|
||||
case add && !slices.Contains(lent, n):
|
||||
lent = append(lent, n)
|
||||
case !add:
|
||||
lent = slices.DeleteFunc(lent, func(l string) bool { return l == n })
|
||||
}
|
||||
}
|
||||
return setList(doc, []string{"slurm", "lent"}, lent)
|
||||
}
|
||||
|
||||
// docList reads a list of strings from the settings, empty when unset.
|
||||
func docList(doc *settings.Doc, path []string) []string {
|
||||
n := doc.Node(path)
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
if err := n.Decode(&out); err != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// docBool reads a boolean from the settings, false when unset.
|
||||
func docBool(doc *settings.Doc, path []string) bool {
|
||||
var b bool
|
||||
if n := doc.Node(path); n != nil {
|
||||
_ = n.Decode(&b)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func setList(doc *settings.Doc, path []string, list []string) error {
|
||||
if len(list) == 0 {
|
||||
if doc.Node(path) != nil {
|
||||
doc.Unset(path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return doc.SetYAML(path, yamlList(list))
|
||||
}
|
||||
|
||||
func yamlString(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func yamlList(list []string) string {
|
||||
b, _ := json.Marshal(list)
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
|
||||
)
|
||||
|
||||
func TestSlurmDocLists(t *testing.T) {
|
||||
doc, err := settings.FromBytes("settings", []byte("slurm:\n enabled: true\n nodes: [cuda01, site2]\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := docList(doc, []string{"slurm", "nodes"}); !slices.Equal(got, []string{"cuda01", "site2"}) {
|
||||
t.Fatalf("nodes %v", got)
|
||||
}
|
||||
if got := docList(doc, []string{"slurm", "lent"}); got != nil {
|
||||
t.Fatalf("lent %v", got)
|
||||
}
|
||||
if err := setList(doc, []string{"slurm", "lent"}, []string{"cuda01"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := docList(doc, []string{"slurm", "lent"}); !slices.Equal(got, []string{"cuda01"}) {
|
||||
t.Fatalf("lent %v", got)
|
||||
}
|
||||
if err := setList(doc, []string{"slurm", "lent"}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := doc.Bytes()
|
||||
if strings.Contains(string(b), "lent") {
|
||||
t.Fatalf("an empty list left:\n%s", b)
|
||||
}
|
||||
}
|
||||
@@ -84,3 +84,19 @@ func TestTheSummaryIsWrittenWhereItIsToldNotToTheTerminal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusSaysWhenAlertsGoNowhere(t *testing.T) {
|
||||
render := func(s adminapi.Summary) string {
|
||||
cmd := &cobra.Command{}
|
||||
var b bytes.Buffer
|
||||
cmd.SetOut(&b)
|
||||
printSummary(cmd, s)
|
||||
return b.String()
|
||||
}
|
||||
if out := render(adminapi.Summary{NoAlerts: true}); !strings.Contains(out, "ALERTS nowhere") {
|
||||
t.Errorf("no webhook was not called out:\n%s", out)
|
||||
}
|
||||
if out := render(adminapi.Summary{}); strings.Contains(out, "ALERTS") {
|
||||
t.Errorf("an alerts line with webhooks set:\n%s", out)
|
||||
}
|
||||
}
|
||||
+98
-19
@@ -11,11 +11,13 @@ import (
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/onboard"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/release"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
|
||||
@@ -244,6 +246,7 @@ func upgradeNodes(ctx context.Context, out io.Writer, client func() *ctlClient,
|
||||
}
|
||||
slices.SortFunc(nodes, func(a, b adminapi.NodeView) int { return strings.Compare(a.ID, b.ID) })
|
||||
var todo []adminapi.NodeView
|
||||
var away []string
|
||||
skipped := 0
|
||||
for _, n := range nodes {
|
||||
if len(only) > 0 && !slices.Contains(only, n.ID) {
|
||||
@@ -253,8 +256,17 @@ func upgradeNodes(ctx context.Context, out io.Writer, client func() *ctlClient,
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
// A node that is not connected cannot be told, and one tried first
|
||||
// stopped every run at it: passed over, and named at the end.
|
||||
if n.State == "lost" || n.State == "unreachable" {
|
||||
away = append(away, n.ID)
|
||||
continue
|
||||
}
|
||||
todo = append(todo, n)
|
||||
}
|
||||
if len(away) > 0 {
|
||||
fmt.Fprintf(out, "Passed over, not connected: %s\n", strings.Join(away, ", "))
|
||||
}
|
||||
parallel = max(parallel, 1)
|
||||
start, size, done := time.Now(), 1, 0
|
||||
for len(todo) > 0 {
|
||||
@@ -296,7 +308,10 @@ func upgradeNodes(ctx context.Context, out io.Writer, client func() *ctlClient,
|
||||
}
|
||||
|
||||
// upgradeWave tells every node in the wave at once, and waits until each is
|
||||
// back on v, ready, with as many ready replicas as it had.
|
||||
// back on v and serving what it served.
|
||||
//
|
||||
// Told together, not one after another: each answer can take minutes, and a
|
||||
// wave of twenty waited for them in turn with nothing on the screen.
|
||||
func upgradeWave(ctx context.Context, out io.Writer, client func() *ctlClient, v string,
|
||||
wave []adminapi.NodeView, perNode time.Duration,
|
||||
) error {
|
||||
@@ -308,35 +323,61 @@ func upgradeWave(ctx context.Context, out io.Writer, client func() *ctlClient, v
|
||||
}
|
||||
fmt.Fprintf(out, "== %d node(s): %s\n", len(wave), abbreviate(ids, 8))
|
||||
body, _ := json.Marshal(adminapi.UpgradeRequest{Version: v})
|
||||
var failed []string
|
||||
var (
|
||||
mu sync.Mutex
|
||||
failed []string
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
for _, n := range wave {
|
||||
// The control plane waits for the agent to fetch and check the
|
||||
// release, up to five minutes; fifteen seconds gave up on a node
|
||||
// that was upgrading.
|
||||
if err := client().patient(6*time.Minute).post(ctx, "/api/v1/nodes/"+n.ID+"/upgrade", body, nil); err != nil {
|
||||
failed = append(failed, fmt.Sprintf("%s: %v", n.ID, err))
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// The control plane waits for the agent's answer, or for it to
|
||||
// come back on v, longer than the agent gives its download.
|
||||
err := client().patient(agentsvc.UpgradeWait+time.Minute).post(ctx, "/api/v1/nodes/"+n.ID+"/upgrade", body, nil)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
failed = append(failed, fmt.Sprintf("%s: %v", n.ID, err))
|
||||
fmt.Fprintf(out, " %s: not upgraded: %v\n", n.ID, err)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, " %s: on %s, coming back\n", n.ID, v)
|
||||
}()
|
||||
}
|
||||
if len(failed) > 0 {
|
||||
return fmt.Errorf("not told to upgrade:\n %s\n`llmgw ctl logs NODE` says more", strings.Join(failed, "\n "))
|
||||
}
|
||||
waiting := slices.Clone(ids)
|
||||
wg.Wait()
|
||||
waiting := slices.DeleteFunc(slices.Clone(ids), func(id string) bool {
|
||||
return slices.ContainsFunc(failed, func(f string) bool { return strings.HasPrefix(f, id+": ") })
|
||||
})
|
||||
deadline := time.Now().Add(perNode)
|
||||
for {
|
||||
backAt := map[string]time.Time{}
|
||||
said := time.Now()
|
||||
for len(waiting) > 0 {
|
||||
var now []adminapi.NodeView
|
||||
_ = client().get(ctx, "/api/v1/nodes", &now)
|
||||
after := readyReplicasByNode(ctx, client)
|
||||
var reps []adminapi.ReplicaView
|
||||
_ = client().get(ctx, "/api/v1/replicas", &reps)
|
||||
after, starting := replicasByNode(reps)
|
||||
state := map[string]adminapi.NodeView{}
|
||||
for _, x := range now {
|
||||
state[x.ID] = x
|
||||
}
|
||||
waiting = slices.DeleteFunc(waiting, func(id string) bool {
|
||||
cur := state[id]
|
||||
return cur.AgentVersion == v && cur.State == "ready" && after[id] >= before[id]
|
||||
// Connected and on v: ready, or cordoned or draining as it
|
||||
// was, which an upgrade does not undo.
|
||||
if cur.AgentVersion != v || (cur.State != "ready" && cur.State != "cordoned" && cur.State != "draining") {
|
||||
return false
|
||||
}
|
||||
if _, ok := backAt[id]; !ok {
|
||||
backAt[id] = time.Now()
|
||||
}
|
||||
// Serving what it served; or, a while on, nothing on it still
|
||||
// starting: a model that went idle meanwhile is not one lost.
|
||||
return after[id] >= before[id] || (time.Since(backAt[id]) > 90*time.Second && starting[id] == 0)
|
||||
})
|
||||
if len(waiting) == 0 {
|
||||
fmt.Fprintf(out, " done in %s\n", time.Since(start).Round(time.Second))
|
||||
return nil
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
var why []string
|
||||
@@ -345,11 +386,49 @@ func upgradeWave(ctx context.Context, out io.Writer, client func() *ctlClient, v
|
||||
why = append(why, fmt.Sprintf("%s (version %q, state %q, replicas %d/%d)",
|
||||
id, cur.AgentVersion, cur.State, after[id], before[id]))
|
||||
}
|
||||
return fmt.Errorf("not back within %s:\n %s\n`llmgw ctl logs NODE` says why, and each keeps its "+
|
||||
"previous binary beside the new one", perNode, strings.Join(why, "\n "))
|
||||
failed = append(failed, fmt.Sprintf("not back within %s: %s", perNode, strings.Join(why, ", ")))
|
||||
break
|
||||
}
|
||||
if time.Since(said) >= 30*time.Second {
|
||||
var w []string
|
||||
for _, id := range waiting {
|
||||
cur := state[id]
|
||||
w = append(w, fmt.Sprintf("%s (%s, %s, replicas %d/%d)", id, orUnknown(cur.AgentVersion), cur.State,
|
||||
after[id], before[id]))
|
||||
}
|
||||
fmt.Fprintf(out, " waiting for %s\n", abbreviate(w, 8))
|
||||
said = time.Now()
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
if len(failed) > 0 {
|
||||
return fmt.Errorf("not upgraded:\n %s\n`llmgw ctl logs NODE` says why; each keeps its previous binary beside the new one",
|
||||
strings.Join(failed, "\n "))
|
||||
}
|
||||
fmt.Fprintf(out, " done in %s\n", time.Since(start).Round(time.Second))
|
||||
return nil
|
||||
}
|
||||
|
||||
// replicasByNode counts each node's ready replicas, and those still placing
|
||||
// or starting.
|
||||
func replicasByNode(reps []adminapi.ReplicaView) (ready, starting map[string]int) {
|
||||
ready, starting = map[string]int{}, map[string]int{}
|
||||
for _, r := range reps {
|
||||
seen := map[string]bool{}
|
||||
for _, sh := range r.Shards {
|
||||
if seen[sh.Node] {
|
||||
continue
|
||||
}
|
||||
seen[sh.Node] = true
|
||||
switch r.State {
|
||||
case "ready":
|
||||
ready[sh.Node]++
|
||||
case "placing", "starting":
|
||||
starting[sh.Node]++
|
||||
}
|
||||
}
|
||||
}
|
||||
return ready, starting
|
||||
}
|
||||
|
||||
// abbreviate lists up to n names, and says how many more.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -21,6 +22,8 @@ type fakeFleet struct {
|
||||
versions map[string]string
|
||||
stuck map[string]bool
|
||||
told []string
|
||||
// states are nodes' states other than ready.
|
||||
states map[string]string
|
||||
}
|
||||
|
||||
func (f *fakeFleet) server(t *testing.T) *httptest.Server {
|
||||
@@ -31,7 +34,11 @@ func (f *fakeFleet) server(t *testing.T) *httptest.Server {
|
||||
case r.URL.Path == "/api/v1/nodes":
|
||||
var out []adminapi.NodeView
|
||||
for id, v := range f.versions {
|
||||
out = append(out, adminapi.NodeView{ID: id, State: "ready", AgentVersion: v})
|
||||
st := "ready"
|
||||
if f.states[id] != "" {
|
||||
st = f.states[id]
|
||||
}
|
||||
out = append(out, adminapi.NodeView{ID: id, State: st, AgentVersion: v})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
case r.URL.Path == "/api/v1/replicas":
|
||||
@@ -201,3 +208,23 @@ func TestUpgradingAControlPlaneAloneSaysItsNodesWereNot(t *testing.T) {
|
||||
t.Errorf("said something with every node on the version: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A cordoned node upgraded is done, as it was; a node not connected is passed
|
||||
// over rather than stopping every node after it.
|
||||
func TestAnUpgradeTakesACordonedNodeAndPassesOverOneAway(t *testing.T) {
|
||||
f := &fakeFleet{versions: map[string]string{"a": "v1.0.0", "b": "v1.0.0", "c": "v1.0.0"},
|
||||
states: map[string]string{"a": "lost", "b": "cordoned"}}
|
||||
srv := f.server(t)
|
||||
var out bytes.Buffer
|
||||
err := upgradeNodes(t.Context(), &out, func() *ctlClient { return newCtlClient(srv.URL) }, "v1.0.1", nil, 5*time.Second, 2)
|
||||
if slices.Contains(f.told, "a") || !slices.Contains(f.told, "b") || !slices.Contains(f.told, "c") {
|
||||
t.Errorf("told %v", f.told)
|
||||
}
|
||||
if !strings.Contains(out.String(), "Passed over, not connected: a") {
|
||||
t.Errorf("output:\n%s", out.String())
|
||||
}
|
||||
// The one away is named at the end, behind.
|
||||
if err == nil || !strings.Contains(err.Error(), "a (v1.0.0, lost)") {
|
||||
t.Errorf("err %v", err)
|
||||
}
|
||||
}
|
||||
+19
-15
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -87,13 +88,10 @@ func collectOrphans(cmd *cobra.Command, c *ctlClient, node string, asJSON bool)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var results []struct {
|
||||
Node string `json:"node"`
|
||||
Files int `json:"files"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
if err := c.post(cmd.Context(), "/api/v1/weights/collect", body, &results); err != nil {
|
||||
var results []adminapi.CollectResult
|
||||
// The control plane waits for the nodes to list their files without the
|
||||
// deleted ones: half a minute at most.
|
||||
if err := c.patient(time.Minute).post(cmd.Context(), "/api/v1/weights/collect", body, &results); err != nil {
|
||||
return err
|
||||
}
|
||||
if asJSON {
|
||||
@@ -103,7 +101,7 @@ func collectOrphans(cmd *cobra.Command, c *ctlClient, node string, asJSON bool)
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "Nothing to reclaim.")
|
||||
return nil
|
||||
}
|
||||
var freed int64
|
||||
var freed, left int64
|
||||
for _, r := range results {
|
||||
if r.Error != "" {
|
||||
// Reported rather than fatal: the other nodes' space is still worth
|
||||
@@ -111,14 +109,20 @@ func collectOrphans(cmd *cobra.Command, c *ctlClient, node string, asJSON bool)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", r.Node, r.Error)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s: asked to remove %d files, %s\n",
|
||||
r.Node, r.Files, fileSize(r.Bytes))
|
||||
freed += r.Bytes
|
||||
line := fmt.Sprintf("%s: removed %d files, %s", r.Node, r.Files, fileSize(r.Bytes))
|
||||
if r.Left > 0 {
|
||||
line += fmt.Sprintf("; %d still there, %s", r.Left, fileSize(r.LeftBytes))
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), line)
|
||||
freed, left = freed+r.Bytes, left+r.LeftBytes
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "\n%s freed.\n", fileSize(freed))
|
||||
if left > 0 {
|
||||
// Each node decides: one that keeps a path it cannot prove it
|
||||
// fetched is the feature working. Its agent's log names each.
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s the nodes kept; each node's agent log says why "+
|
||||
"(journalctl -u llmgw-agent).\n", fileSize(left))
|
||||
}
|
||||
// "asked", not "removed": each node decides, and one that refuses a path it
|
||||
// cannot prove it fetched is the feature working rather than failing.
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "\n%s requested. The nodes verify each path before deleting it.\n",
|
||||
fileSize(freed))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,8 @@ func newEngineCheckCmd() *cobra.Command {
|
||||
cfg.VLLM.Bin = bin
|
||||
case "sglang":
|
||||
cfg.SGLang.Python = bin
|
||||
case "freetoken":
|
||||
cfg.FreeToken.Binary = bin
|
||||
case "trtllm":
|
||||
cfg.TRTLLM.Python = bin
|
||||
case "sim":
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
)
|
||||
|
||||
func TestAPackageInstallThatIsNoEnginesIsNamed(t *testing.T) {
|
||||
if got := provisionLine("", "", "installing", "mariadb-server, munge, with apt-get"); got !=
|
||||
"system packages: installing: mariadb-server, munge, with apt-get" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := provisionLine("llamacpp", "b10991", "ready", ""); got != "llamacpp b10991: ready" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoresAloneKeepTheMemory(t *testing.T) {
|
||||
m := catalog.Model{Name: "judge", Profiles: []catalog.Profile{{Name: "cpu", MinMemoryGB: 2}, {Name: "cpu-big", MinMemoryGB: 2}}}
|
||||
if got, err := keptMemory(m, ""); err != nil || got != 2 {
|
||||
t.Fatalf("got %d, %v", got, err)
|
||||
}
|
||||
m.Profiles[1].MinMemoryGB = 4
|
||||
if _, err := keptMemory(m, ""); err == nil {
|
||||
t.Fatal("profiles that differ were given one memory")
|
||||
}
|
||||
if got, err := keptMemory(m, "cpu-big"); err != nil || got != 4 {
|
||||
t.Fatalf("got %d, %v", got, err)
|
||||
}
|
||||
if _, err := keptMemory(m, "gpu"); err == nil {
|
||||
t.Fatal("a profile the model has not was taken")
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,9 @@ type leaderForward struct {
|
||||
self string
|
||||
tls tlsconf.Config
|
||||
log *slog.Logger
|
||||
// routes, with router.active_active, is the leader's replicas this
|
||||
// standby routes to; nil when every request goes to the leader.
|
||||
routes *standbyRoutes
|
||||
}
|
||||
|
||||
// standby wraps the admin handler: /api/ goes to the leader while this
|
||||
@@ -73,8 +76,10 @@ func (f *leaderForward) standby(next http.Handler) http.Handler {
|
||||
p := &httputil.ReverseProxy{
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
pr.Out.URL.Scheme, pr.Out.URL.Host = "https", target.Host
|
||||
// Escaped as it came: a name with a slash in it, Demo/hr
|
||||
// as Demo%2Fhr, is one segment, not two.
|
||||
pr.Out.URL.Path = forwardPrefix + pr.In.URL.Path
|
||||
pr.Out.URL.RawPath = ""
|
||||
pr.Out.URL.RawPath = forwardPrefix + pr.In.URL.EscapedPath()
|
||||
pr.Out.Host = target.Host
|
||||
// The leader's own token is not this one's, and the
|
||||
// certificate is what it believes.
|
||||
@@ -115,7 +120,8 @@ func (f *leaderForward) public(next http.Handler, port string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Once only: a request another control plane forwarded is answered
|
||||
// here, leading or not, rather than sent on while the lease moves.
|
||||
if publicStaysHere(r.URL.Path) || f.lead.IsLeader() || r.Header.Get(hdrForwardedBy) != "" {
|
||||
if publicStaysHere(r.URL.Path) || f.lead.IsLeader() || r.Header.Get(hdrForwardedBy) != "" ||
|
||||
f.answersHere(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -152,6 +158,15 @@ func (f *leaderForward) public(next http.Handler, port string) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// answersHere is whether a standby answers this client request itself: the
|
||||
// API under /v1, with router.active_active, while it has the leader's
|
||||
// replicas to send it to. Not the portal, its admin pages or /mcp, whose
|
||||
// llmgw:admin tools read the cluster from memory only the leader has; and
|
||||
// not while the leader has gone unheard, when it is passed on as before.
|
||||
func (f *leaderForward) answersHere(path string) bool {
|
||||
return f.routes != nil && strings.HasPrefix(path, "/v1/") && f.routes.serving()
|
||||
}
|
||||
|
||||
// trustControlPlanes keeps the client port trusting the other control
|
||||
// planes' X-Forwarded-For, besides router.trusted_proxies: a standby passes
|
||||
// requests on, and the client is the address it was given, not the standby.
|
||||
@@ -221,7 +236,7 @@ func (f *leaderForward) leaderSide(admin http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL.Path, r2.URL.RawPath = path, ""
|
||||
r2.URL.Path, r2.URL.RawPath = path, strings.TrimPrefix(r.URL.EscapedPath(), forwardPrefix)
|
||||
w.Header().Set(hdrAnsweredBy, f.self)
|
||||
admin.ServeHTTP(w, r2)
|
||||
})
|
||||
|
||||
@@ -32,7 +32,11 @@ type forwardCluster struct {
|
||||
leaderURL string
|
||||
}
|
||||
|
||||
func newForwardCluster(t *testing.T) *forwardCluster {
|
||||
func newForwardCluster(t *testing.T) *forwardCluster { return newForwardClusterWith(t, nil) }
|
||||
|
||||
// newForwardClusterWith answers forwarded requests on the leader with admin,
|
||||
// or, given nil, with one that says what it was asked.
|
||||
func newForwardClusterWith(t *testing.T, leaderAdmin http.Handler) *forwardCluster {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
ca, err := tlsconf.NewCA("test CA")
|
||||
@@ -77,10 +81,13 @@ func newForwardCluster(t *testing.T) *forwardCluster {
|
||||
|
||||
// The leader's admin API: says whose it is, and who it was asked by.
|
||||
admin := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"path": r.URL.Path, "by": r.Header.Get(hdrForwardedBy),
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"path": r.URL.Path, "raw": r.URL.EscapedPath(), "by": r.Header.Get(hdrForwardedBy),
|
||||
"auth": r.Header.Get("Authorization"), "actor": r.Header.Get("X-Llmgw-Actor")})
|
||||
})
|
||||
mux := http.NewServeMux()
|
||||
if leaderAdmin != nil {
|
||||
admin = leaderAdmin.ServeHTTP
|
||||
}
|
||||
mux.Handle(forwardPrefix+"/", c.leader.leaderSide(admin))
|
||||
srv := httptest.NewUnstartedServer(mux)
|
||||
tc, err := cfgFor("ctl-a").ServerConfig()
|
||||
@@ -242,3 +249,13 @@ func TestAStandbysPortalIsTheLeaders(t *testing.T) {
|
||||
t.Errorf("a forwarded request was forwarded again: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A name with a slash in it, escaped, stays one segment on the leader: a
|
||||
// collection Demo/hr removed through a standby was a 405 there.
|
||||
func TestAnEscapedSlashReachesTheLeaderEscaped(t *testing.T) {
|
||||
c := newForwardCluster(t)
|
||||
resp, got := c.ask(t, "/api/v1/kb/collections/Demo%2Fhr")
|
||||
if resp.StatusCode != http.StatusOK || got["raw"] != "/api/v1/kb/collections/Demo%2Fhr" {
|
||||
t.Fatalf("%d %v", resp.StatusCode, got)
|
||||
}
|
||||
}
|
||||
+32
-2
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
|
||||
)
|
||||
@@ -57,7 +58,7 @@ func newMirrorCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
func newMirrorAddCmd(dir *string) *cobra.Command {
|
||||
var model, version, as string
|
||||
var model, version, as, sha, registry string
|
||||
|
||||
c := &cobra.Command{
|
||||
Use: "add <url>...",
|
||||
@@ -79,7 +80,7 @@ func newMirrorAddCmd(dir *string) *cobra.Command {
|
||||
if model == "" || version == "" {
|
||||
return fmt.Errorf("--model and --version are required: they are the layout")
|
||||
}
|
||||
if as != "" && len(args) > 1 {
|
||||
if (as != "" || sha != "") && len(args) > 1 {
|
||||
return fmt.Errorf("--as names one file; with %d URLs each keeps its own name", len(args))
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
@@ -90,6 +91,21 @@ func newMirrorAddCmd(dir *string) *cobra.Command {
|
||||
file = mirror.LastPathElement(source)
|
||||
}
|
||||
e := mirror.Entry{Model: model, Version: version, File: file}
|
||||
// From the registry: what it published for the file, to
|
||||
// compare with what arrives.
|
||||
want := strings.ToLower(sha)
|
||||
if id, rev, path, ok := hf.FileSource(source, registry); ok && want == "" {
|
||||
repo, err := hf.New(registry, 30*time.Second).RepoAt(cmd.Context(), id, rev)
|
||||
if err != nil {
|
||||
return fmt.Errorf("looking up %s in the registry, for the sha256 it published: %w "+
|
||||
"(--sha256 gives it instead)", id, err)
|
||||
}
|
||||
for _, f := range repo.Files {
|
||||
if f.Path == path {
|
||||
want = f.SHA256
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(out, "fetching %s\n", source)
|
||||
added, err := mirror.Add(cmd.Context(), *dir, source, e, func(done, total int64) {
|
||||
if total > 0 {
|
||||
@@ -102,6 +118,14 @@ func newMirrorAddCmd(dir *string) *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if want != "" && added.SHA256 != want {
|
||||
_ = mirror.Remove(*dir, e)
|
||||
return fmt.Errorf("%s arrived with sha256 %s, and %s was published for it: deleted",
|
||||
source, added.SHA256, want)
|
||||
}
|
||||
if want != "" {
|
||||
fmt.Fprintln(out, " sha256 matches the one published")
|
||||
}
|
||||
fmt.Fprintf(out, "%s/%s/%s %s\n sha256 %s\n",
|
||||
added.Entry.Model, added.Entry.Version, added.Entry.File,
|
||||
fileSize(added.Entry.Bytes), added.SHA256)
|
||||
@@ -123,6 +147,8 @@ func newMirrorAddCmd(dir *string) *cobra.Command {
|
||||
c.Flags().StringVar(&model, "model", "", "the model's name, one directory level")
|
||||
c.Flags().StringVar(&version, "version", "", "the version, one directory level; a date works well")
|
||||
c.Flags().StringVar(&as, "as", "", "the filename to save as, for one URL; taken from the URL when omitted")
|
||||
c.Flags().StringVar(&sha, "sha256", "", "the sha256 the file must have, for one URL; looked up for a Hugging Face URL")
|
||||
c.Flags().StringVar(®istry, "registry", hf.DefaultEndpoint, "the registry a resolve URL is looked up in")
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -238,6 +264,10 @@ func newMirrorRemoveCmd(dir *string) *cobra.Command {
|
||||
if err := mirror.Remove(*dir, e); err != nil {
|
||||
return err
|
||||
}
|
||||
// Kept for a mirror kept in step, which otherwise copies it back.
|
||||
if err := mirror.RecordRemoval(*dir, model, version); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "removed %s/%s/%s\n", model, version, file)
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -34,6 +34,8 @@ step with this one: each copies what the other holds, checksum checked, and
|
||||
a model removed from one is removed from the other. This mirror is told of
|
||||
it, and the cluster's settings name it (mirror.peers), so while either
|
||||
machine is down the control planes use the other and nodes fetch from it.
|
||||
On a mirror machine without the control plane's settings file, it prints
|
||||
the llmgw ctl config set to run on a control plane instead.
|
||||
|
||||
HOST needs room for every model this mirror holds: it copies them all.`,
|
||||
Example: " sudo llmgw mirror add-peer ubuntu@10.0.0.12 --control-plane 10.0.0.10 --control-plane 10.0.0.12",
|
||||
@@ -123,6 +125,19 @@ func addMirrorPeer(ctx context.Context, out io.Writer, remote onboard.Remote, ho
|
||||
fmt.Fprintf(out, " ok this mirror keeps in step with %s\n", peerAPI)
|
||||
|
||||
// The control planes told of both.
|
||||
return nameMirrorPeer(out, configPath, peerAPI)
|
||||
}
|
||||
|
||||
// nameMirrorPeer names a peer in the cluster's settings. A mirror on a
|
||||
// machine of its own has neither the settings file nor the store: it says
|
||||
// what to run on a control plane.
|
||||
func nameMirrorPeer(out io.Writer, configPath, peerAPI string) error {
|
||||
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
|
||||
fmt.Fprintf(out, " !! this machine has no %s, so the cluster's settings are not changed here. On a control plane:\n"+
|
||||
" sudo llmgw ctl config set mirror.peers '[%s]'\n"+
|
||||
" (with any peers already named, which llmgw ctl config get mirror.peers shows)\n", configPath, peerAPI)
|
||||
return nil
|
||||
}
|
||||
if err := addMirrorPeerSetting(configPath, peerAPI); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -72,3 +73,13 @@ func TestTheSetupOffersASecondMirrorOnAStoreMachine(t *testing.T) {
|
||||
t.Errorf("without standbys: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMirrorOnAMachineOfItsOwnSaysWhatToRunOnAControlPlane(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
if err := nameMirrorPeer(&out, filepath.Join(t.TempDir(), "server.yaml"), "http://10.0.0.12:8082"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "llmgw ctl config set mirror.peers '[http://10.0.0.12:8082]'") {
|
||||
t.Errorf("%s", out.String())
|
||||
}
|
||||
}
|
||||
+255
-20
@@ -19,6 +19,7 @@ import (
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelsvc"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/sourcecheck"
|
||||
)
|
||||
|
||||
@@ -34,7 +35,7 @@ func newModelCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
Use: "model",
|
||||
Short: "Add or remove models while the cluster is running",
|
||||
}
|
||||
c.AddCommand(newModelContextCmd(client), newModelAddCmd(client), newModelPresetsCmd(client, asJSON), newModelRemoveCmd(client), newModelPlanCmd(client),
|
||||
c.AddCommand(newModelStopCmd(client), newModelStartCmd(client), newModelReplicasCmd(client), newModelContextCmd(client), newModelGPUsCmd(client), newModelAddCmd(client), newModelPresetsCmd(client, asJSON), newModelRemoveCmd(client), newModelPlanCmd(client),
|
||||
newResourcesCmd(client), newClassCmd(client), newOwnerCmd(client),
|
||||
newTargetCmd(client, asJSON), newAdaptersCmd(client),
|
||||
newModelServeCmd(client), newModelUpdatesCmd(client, asJSON), newModelUpdateCmd(client),
|
||||
@@ -163,21 +164,104 @@ func readModelFile(path string) (catalog.Model, error) {
|
||||
}
|
||||
|
||||
func newModelRemoveCmd(client func() *ctlClient) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
var purge bool
|
||||
c := &cobra.Command{
|
||||
Use: "remove <name>",
|
||||
Short: "Remove a model that was added at runtime",
|
||||
Short: "Remove a model that was added at runtime, and with --purge its files",
|
||||
Long: "Only models added at runtime. One declared in server.yaml would come\n" +
|
||||
"back on the next restart, so removing it here would leave the cluster\n" +
|
||||
"disagreeing with its own configuration until then.\n\n" +
|
||||
"Its replicas are stopped on the next pass, after the endpoint has been\n" +
|
||||
"withdrawn, so traffic stops before the engine does.",
|
||||
"withdrawn, so traffic stops before the engine does.\n\n" +
|
||||
"Its files stay on the mirror and the nodes, for adding it again. With\n" +
|
||||
"--purge they go too: every version of it on the mirror (a mirror kept in\n" +
|
||||
"step follows), and the nodes' copies once its replicas have stopped.\n" +
|
||||
"What another model uses stays. To take a model out of service and keep\n" +
|
||||
"it, use llmgw ctl model stop.",
|
||||
Example: " llmgw ctl model remove qwen-7b\n" +
|
||||
" llmgw ctl model remove qwen-7b --purge # its files on the mirror and the nodes too",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var res map[string]string
|
||||
if err := client().delete(cmd.Context(), "/api/v1/models/"+args[0], &res); err != nil {
|
||||
out := cmd.OutOrStdout()
|
||||
if !purge {
|
||||
var res map[string]string
|
||||
if err := client().delete(cmd.Context(), "/api/v1/models/"+args[0], &res); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(out, "%s removed. Its files stay on the mirror and the nodes; --purge deletes them.\n", args[0])
|
||||
return nil
|
||||
}
|
||||
var v adminapi.PurgeView
|
||||
if err := client().patient(3*time.Minute).delete(cmd.Context(), "/api/v1/models/"+args[0]+"?purge=true", &v); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s removed.\n", args[0])
|
||||
fmt.Fprintf(out, "%s removed.\n", v.Removed)
|
||||
for _, m := range v.Mirror {
|
||||
fmt.Fprintf(out, " mirror %s deleted\n", m)
|
||||
}
|
||||
for _, m := range v.Kept {
|
||||
fmt.Fprintf(out, " mirror %s kept: another model uses it\n", m)
|
||||
}
|
||||
var freed int64
|
||||
for _, n := range v.Nodes {
|
||||
if n.Error != "" {
|
||||
fmt.Fprintf(out, " node %s: %s\n", n.Node, n.Error)
|
||||
continue
|
||||
}
|
||||
freed += n.Bytes
|
||||
fmt.Fprintf(out, " node %s: %d file(s), %s\n", n.Node, n.Files, humanBytes(n.Bytes))
|
||||
}
|
||||
if len(v.Mirror) == 0 && len(v.Nodes) == 0 && v.Note == "" {
|
||||
fmt.Fprintln(out, " it had no files of its own on the mirror or the nodes")
|
||||
}
|
||||
if freed > 0 {
|
||||
fmt.Fprintf(out, " %s freed on the nodes\n", humanBytes(freed))
|
||||
}
|
||||
if v.Note != "" {
|
||||
fmt.Fprintln(out, " "+v.Note)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().BoolVar(&purge, "purge", false, "delete its files from the mirror and the nodes as well")
|
||||
return c
|
||||
}
|
||||
|
||||
func newModelStopCmd(client func() *ctlClient) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "stop <model>",
|
||||
Short: "Take a model out of service and keep it: none of its replicas run until it is started",
|
||||
Long: "Its replicas stop; it stays in the catalog with its files, and a request\n" +
|
||||
"for it is told an administrator stopped it. What it was scaled to is kept:\n" +
|
||||
"llmgw ctl model start runs it again as it was.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var v adminapi.ScaleResponse
|
||||
if err := client().post(cmd.Context(), "/api/v1/models/"+args[0]+"/stop", nil, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s stopped: its replicas stop on the next pass. It is kept; "+
|
||||
"llmgw ctl model start %s runs it again.\n", v.Model, v.Model)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newModelStartCmd(client func() *ctlClient) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "start <model>",
|
||||
Short: "Run a stopped model again, scaled as it was when it was stopped",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var v adminapi.ScaleResponse
|
||||
if err := client().post(cmd.Context(), "/api/v1/models/"+args[0]+"/start", nil, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
how := fmt.Sprintf("%d replica(s)", v.Min)
|
||||
if v.OnDemand {
|
||||
how = "on demand"
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s started: %s, max %d. Watch it with llmgw ctl models.\n", v.Model, how, v.Max)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -254,7 +338,7 @@ func newResourcesCmd(client func() *ctlClient) *cobra.Command {
|
||||
var memory, cores int
|
||||
var profile string
|
||||
c := &cobra.Command{
|
||||
Use: "resources <model> --memory GB [--cores N]",
|
||||
Use: "resources <model> [--memory GB] [--cores N]",
|
||||
Short: "Set what a model reserves of a node: memory, and CPU cores",
|
||||
Long: "--memory is the host RAM a replica needs: for a model with no GPU, its\n" +
|
||||
"weights, KV cache and engine together. Placement reserves it on the node,\n" +
|
||||
@@ -267,8 +351,20 @@ func newResourcesCmd(client func() *ctlClient) *cobra.Command {
|
||||
"--model M` moves them to the new figures.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !cmd.Flags().Changed("memory") && !cmd.Flags().Changed("cores") {
|
||||
return fmt.Errorf("give --memory, --cores or both")
|
||||
}
|
||||
if !cmd.Flags().Changed("memory") {
|
||||
return fmt.Errorf("--memory is required")
|
||||
// Cores alone: the memory stays what it is.
|
||||
var cur catalog.Model
|
||||
if err := client().get(cmd.Context(), "/api/v1/models/"+args[0], &cur); err != nil {
|
||||
return err
|
||||
}
|
||||
kept, err := keptMemory(cur, profile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
memory = kept
|
||||
}
|
||||
body, err := json.Marshal(adminapi.ResourcesRequest{Profile: profile, MemoryGB: memory, CPUCores: cores})
|
||||
if err != nil {
|
||||
@@ -285,12 +381,31 @@ func newResourcesCmd(client func() *ctlClient) *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().IntVar(&memory, "memory", 0, "host RAM a replica needs, in GB")
|
||||
c.Flags().IntVar(&memory, "memory", 0, "host RAM a replica needs, in GB; kept as it is when left out")
|
||||
c.Flags().IntVar(&cores, "cores", 0, "CPU cores a replica with no GPU computes on; 0 reserves none")
|
||||
c.Flags().StringVar(&profile, "profile", "", "one profile; every profile when left out")
|
||||
return c
|
||||
}
|
||||
|
||||
// keptMemory is the memory a model's profiles reserve now, for a change of
|
||||
// cores alone: one profile's, or every profile's when they agree.
|
||||
func keptMemory(m catalog.Model, profile string) (int, error) {
|
||||
memory := -1
|
||||
for _, p := range m.Profiles {
|
||||
if profile != "" && p.Name != profile {
|
||||
continue
|
||||
}
|
||||
if memory >= 0 && p.MinMemoryGB != memory {
|
||||
return 0, fmt.Errorf("%s's profiles reserve different memory: give --memory, or --profile", m.Name)
|
||||
}
|
||||
memory = p.MinMemoryGB
|
||||
}
|
||||
if memory < 0 {
|
||||
return 0, fmt.Errorf("%s has no profile %q", m.Name, profile)
|
||||
}
|
||||
return memory, nil
|
||||
}
|
||||
|
||||
func newClassCmd(client func() *ctlClient) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "class <model> critical|standard|opportunistic|default",
|
||||
@@ -389,10 +504,10 @@ func newAdaptersCmd(client func() *ctlClient) *cobra.Command {
|
||||
}
|
||||
|
||||
func newModelContextCmd(client func() *ctlClient) *cobra.Command {
|
||||
var slots int
|
||||
var slots, maxOut int
|
||||
c := &cobra.Command{
|
||||
Use: "context <model> <tokens>",
|
||||
Short: "Change how much one request to a served model may use",
|
||||
Use: "context <model> [tokens]",
|
||||
Short: "Change how much one request to a served model may use, and how long its answers may be",
|
||||
Long: "The context lives in three places: the catalog (what a request may use, and\n" +
|
||||
"what it may answer), the engine's arguments (llama.cpp's --ctx-size and\n" +
|
||||
"--parallel, vLLM's --max-model-len), and the memory the KV cache takes. This\n" +
|
||||
@@ -400,21 +515,39 @@ func newModelContextCmd(client func() *ctlClient) *cobra.Command {
|
||||
"and a context its cards would not hold, saying what each would need. Its\n" +
|
||||
"replicas are then started again on it, each new one before the old stops.\n\n" +
|
||||
"--slots is how many requests a llama.cpp replica answers at once; its pool is\n" +
|
||||
"slots × tokens. Left out, it keeps what it has.",
|
||||
"slots × tokens. Left out, it keeps what it has.\n\n" +
|
||||
"--max-output is the longest answer the gateway lets a request ask for. With a\n" +
|
||||
"context it is set with it; left out, it follows the context as it did. Alone,\n" +
|
||||
"without tokens, it changes only that, and no replica is started again.\n\n" +
|
||||
"Strata's context is its setup's --context, and FreeToken's\n" +
|
||||
"--max-seq-len-override; for both the card is not checked, since each sizes its\n" +
|
||||
"caches to it.",
|
||||
Example: " llmgw ctl model context qwen3-coder-30b 65536\n" +
|
||||
" llmgw ctl model context qwen2.5-7b 16384 --slots 4",
|
||||
Args: cobra.ExactArgs(2),
|
||||
" llmgw ctl model context qwen2.5-7b 16384 --slots 4\n" +
|
||||
" llmgw ctl model context qwen3.8-flash-next-iq2_xs 32768 --max-output 8192\n" +
|
||||
" llmgw ctl model context qwen3.8-flash-next-iq2_xs --max-output 4096",
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
tokens, err := strconv.Atoi(args[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("the context is a whole number of tokens, not %q", args[1])
|
||||
tokens := 0
|
||||
if len(args) == 2 {
|
||||
var err error
|
||||
if tokens, err = strconv.Atoi(args[1]); err != nil {
|
||||
return fmt.Errorf("the context is a whole number of tokens, not %q", args[1])
|
||||
}
|
||||
} else if maxOut == 0 {
|
||||
return fmt.Errorf("say the context's tokens, or --max-output, or both")
|
||||
}
|
||||
body, _ := json.Marshal(modelserve.ContextRequest{Tokens: tokens, Slots: slots})
|
||||
body, _ := json.Marshal(modelserve.ContextRequest{Tokens: tokens, Slots: slots, MaxOutput: maxOut})
|
||||
var v modelserve.ContextChange
|
||||
if err := client().patient(time.Minute).post(cmd.Context(), "/api/v1/models/"+args[0]+"/context", body, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
if v.OutputOnly {
|
||||
fmt.Fprintf(out, "%s: answers up to %d tokens (was %d); its context stays %d, and no replica was started again\n",
|
||||
v.Model, v.MaxOutput, v.MaxOutputBefore, v.After)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(out, "%s: %d → %d tokens a request, answers up to %d", v.Model, v.Before, v.After, v.MaxOutput)
|
||||
if v.Trained > 0 {
|
||||
fmt.Fprintf(out, " (trained for %d)", v.Trained)
|
||||
@@ -440,5 +573,107 @@ func newModelContextCmd(client func() *ctlClient) *cobra.Command {
|
||||
},
|
||||
}
|
||||
c.Flags().IntVar(&slots, "slots", 0, "llama.cpp: requests a replica answers at once (default: as it is)")
|
||||
c.Flags().IntVar(&maxOut, "max-output", 0, "the longest answer, in tokens (default: follows the context)")
|
||||
return c
|
||||
}
|
||||
|
||||
func newModelGPUsCmd(client func() *ctlClient) *cobra.Command {
|
||||
var profile string
|
||||
var inPlace bool
|
||||
c := &cobra.Command{
|
||||
Use: "gpus <model> <cards>",
|
||||
Short: "Spread a served model over more cards, or fewer",
|
||||
Long: "How many cards of one node each replica uses. vLLM, SGLang and TensorRT-LLM\n" +
|
||||
"spread the model by tensor parallel, which needs the count to divide the\n" +
|
||||
"model's KV heads or be divided by them; llama.cpp splits its layers over the\n" +
|
||||
"cards. It refuses a count a node does not have, and one whose cards would not\n" +
|
||||
"hold the model and its context, saying what each would need. Its replicas are\n" +
|
||||
"then started again on it, each new one beside the old before it stops.\n\n" +
|
||||
"With no node free enough beside the running replicas — a node of two cards,\n" +
|
||||
"the model on one — --in-place stops each replica first: the model does not\n" +
|
||||
"answer while the new one loads.\n\n" +
|
||||
"--profile names the profile for a model with more than one on cards.",
|
||||
Example: " llmgw ctl model gpus qwen3-coder-30b 2\n" +
|
||||
" llmgw ctl model gpus llama-70b 4 --profile h100 --in-place",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
n, err := strconv.Atoi(args[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("the cards are a whole number, not %q", args[1])
|
||||
}
|
||||
body, _ := json.Marshal(modelserve.GPUsRequest{GPUs: n, Profile: profile, InPlace: inPlace})
|
||||
var v modelserve.GPUsChange
|
||||
if err := client().patient(time.Minute).post(cmd.Context(), "/api/v1/models/"+args[0]+"/gpus", body, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
fmt.Fprintf(out, "%s: %d → %d %s card(s) a replica (%s, %s)\n", v.Model, v.Before, v.After, v.Class, v.Profile, v.Engine)
|
||||
if v.PerCard != "" {
|
||||
fmt.Fprintln(out, " "+v.PerCard)
|
||||
}
|
||||
if len(v.Args) > 0 {
|
||||
fmt.Fprintln(out, " args: "+strings.Join(v.Args, " "))
|
||||
}
|
||||
if v.Note != "" {
|
||||
fmt.Fprintln(out, " note: "+v.Note)
|
||||
}
|
||||
switch {
|
||||
case v.Restarted > 0 && v.InPlace:
|
||||
fmt.Fprintf(out, "%d replica(s) stopped and started again on it, one by one: llmgw ctl replicas --model %s\n", v.Restarted, v.Model)
|
||||
case v.Restarted > 0:
|
||||
fmt.Fprintf(out, "%d replica(s) started again on it, each new one before the old stops: llmgw ctl replicas --model %s\n", v.Restarted, v.Model)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(&profile, "profile", "", "the profile to change, for a model with several on cards")
|
||||
c.Flags().BoolVar(&inPlace, "in-place", false, "stop each replica before its replacement starts, when there is no room beside it")
|
||||
return c
|
||||
}
|
||||
|
||||
func newModelReplicasCmd(client func() *ctlClient) *cobra.Command {
|
||||
var cpuMemory, cpuCores int
|
||||
c := &cobra.Command{
|
||||
Use: "replicas <model> <profile>=<n>...",
|
||||
Short: "How many of a model's replicas run on each of its profiles: gpu=7 cpu=4",
|
||||
Long: "Each replica runs on the best profile that fits unless counted here: seven on\n" +
|
||||
"cards and four on CPUs, all answering under the model's name, the router filling\n" +
|
||||
"the cards before the CPUs. A profile not named keeps its count. For a GGUF model\n" +
|
||||
"with no profile on CPUs, --cpu-memory adds one named cpu. Its scaling becomes the\n" +
|
||||
"sum; llmgw ctl model scale with another total ends the count per profile.",
|
||||
Example: " llmgw ctl model replicas qwen-coder gpu-1xl40s-48g=7 cpu=4 --cpu-memory 24 --cpu-cores 8",
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
req := adminapi.ProfileReplicasRequest{Profiles: map[string]int{}}
|
||||
for _, a := range args[1:] {
|
||||
name, v, ok := strings.Cut(a, "=")
|
||||
n, err := strconv.Atoi(v)
|
||||
if !ok || err != nil || name == "" {
|
||||
return fmt.Errorf("%q: a profile and a count, as gpu=7", a)
|
||||
}
|
||||
req.Profiles[name] = n
|
||||
}
|
||||
if cpuMemory > 0 {
|
||||
req.CPU = &modelsvc.CPUProfile{MemoryGB: cpuMemory, CPUCores: cpuCores}
|
||||
}
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var m catalog.Model
|
||||
if err := client().post(cmd.Context(), "/api/v1/models/"+args[0]+"/replicas", body, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
var parts []string
|
||||
for _, p := range m.Profiles {
|
||||
parts = append(parts, fmt.Sprintf("%d on %s", p.Replicas, p.Name))
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s runs %s: %d in all. Those short start first, then those over stop.\n",
|
||||
m.Name, strings.Join(parts, ", "), m.SplitTotal())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().IntVar(&cpuMemory, "cpu-memory", 0, "add a profile on CPUs named cpu, reserving this many GB a replica")
|
||||
c.Flags().IntVar(&cpuCores, "cpu-cores", 0, "the cores a replica on CPUs computes on (default: half the node's)")
|
||||
return c
|
||||
}
|
||||
@@ -64,6 +64,8 @@ func newModelServeCmd(client func() *ctlClient) *cobra.Command {
|
||||
"--embedding, 32 slots of 1024 tokens unless --ctx-pool or --context-length says otherwise")
|
||||
f.StringVar(&req.Format, "format", "gguf", "gguf, run by llama.cpp, or safetensors: the original weights, run by vLLM on GPUs")
|
||||
f.BoolVar(&req.CPU, "cpu", false, "run it on the CPU, whatever the plan says")
|
||||
f.StringVar(&req.Engine, "engine", "", "strata, for a family Strata runs: one card and the experts in RAM; "+
|
||||
"llamacpp to keep it off Strata (default: the plan's best)")
|
||||
f.StringVar(&req.GPUClass, "gpu-class", "", "run it on GPUs of this class, whatever the plan says")
|
||||
f.IntVar(&req.GPUsPerNode, "gpus", 0, "with --gpu-class: the cards one replica takes on each node (default 1)")
|
||||
f.IntVar(&req.Nodes, "nodes", 0, "with --gpu-class: the nodes one replica spans, over a fabric (vLLM only)")
|
||||
|
||||
@@ -310,6 +310,9 @@ func planFiles(cmd *cobra.Command, client func() *ctlClient, repo, format string
|
||||
if b.CPU {
|
||||
holds, all = fmt.Sprintf("%.1f GB of RAM", b.NeedGB), "-"
|
||||
}
|
||||
if b.Engine == "strata" {
|
||||
holds, all = fmt.Sprintf("%.0f GB of RAM", b.NeedGB), "1 card"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%.0f GB\t%s\t%s\n", name, bytesShort(a.Bytes), b.Label(), holds, b.HaveGB, all, now)
|
||||
}
|
||||
_ = w.Flush()
|
||||
@@ -318,7 +321,10 @@ func planFiles(cmd *cobra.Command, client func() *ctlClient, repo, format string
|
||||
fmt.Fprintln(out, "\nNo file fits this cluster as it is: a smaller context (--context), or more memory.")
|
||||
return errDoesNotFit
|
||||
}
|
||||
fmt.Fprintf(out, "\nBest: %s on %s: %s.\n", rec.Artifact, rec.Label(), rec.MemoryText())
|
||||
fmt.Fprintf(out, "\nBest: %s on %s: %s%s.\n", rec.Artifact, rec.Label(), rec.MemoryText(), speedNote(rec))
|
||||
if w := slowWarning(rec); w != "" {
|
||||
fmt.Fprintln(out, w)
|
||||
}
|
||||
if p.KVBytesPerToken > 0 {
|
||||
fmt.Fprintf(out, "The context costs %.2f GB per 1000 tokens, the whole model (--context to plan another).\n",
|
||||
modelplan.GB(p.KVBytesPerToken*1000))
|
||||
@@ -331,7 +337,8 @@ func planFiles(cmd *cobra.Command, client func() *ctlClient, repo, format string
|
||||
if o.FreeNow {
|
||||
now = "free"
|
||||
}
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%d host(s)\t%s\n", o.Artifact, o.Label(), o.MemoryText(), o.Hosts, now)
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%d host(s)\t%s\t%s\n", o.Artifact, o.Label(), o.MemoryText(), o.Hosts, now,
|
||||
speedCell(o.TokensPerSecond))
|
||||
}
|
||||
_ = w.Flush()
|
||||
}
|
||||
@@ -339,6 +346,44 @@ func planFiles(cmd *cobra.Command, client func() *ctlClient, repo, format string
|
||||
if p.Format == "safetensors" {
|
||||
fmt.Fprint(out, " --format safetensors")
|
||||
}
|
||||
if rec.Engine == "strata" {
|
||||
fmt.Fprintf(out, " --file %s --engine strata\n", rec.Artifact)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(out, " --fallbacks")
|
||||
return nil
|
||||
}
|
||||
|
||||
// speedCell is a way's speed in a table: about how many tokens a second one
|
||||
// reply gets, and whether that is slow.
|
||||
func speedCell(tps float64) string {
|
||||
switch {
|
||||
case tps <= 0:
|
||||
return ""
|
||||
case tps < modelplan.CPUTooSlowTPS:
|
||||
return fmt.Sprintf("~%.1f tok/s, very slow", tps)
|
||||
case tps < modelplan.CPUSlowTPS:
|
||||
return fmt.Sprintf("~%.0f tok/s, slow", tps)
|
||||
}
|
||||
return fmt.Sprintf("~%.0f tok/s", tps)
|
||||
}
|
||||
|
||||
// speedNote is the same for a sentence, empty when not known.
|
||||
func speedNote(o modelplan.Option) string {
|
||||
if o.TokensPerSecond <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(", about %s tokens a second for one reply", strconv.FormatFloat(o.TokensPerSecond, 'f', 1, 64))
|
||||
}
|
||||
|
||||
// slowWarning says what a slow way means for somebody waiting on a reply.
|
||||
func slowWarning(o modelplan.Option) string {
|
||||
tps := o.TokensPerSecond
|
||||
switch {
|
||||
case tps <= 0 || tps >= modelplan.CPUSlowTPS:
|
||||
return ""
|
||||
case tps < modelplan.CPUTooSlowTPS:
|
||||
return fmt.Sprintf("Very slow: a reply of 500 tokens takes about %.0f minutes.", max(1, 500/tps/60))
|
||||
}
|
||||
return fmt.Sprintf("Slow: a reply of 500 tokens takes about %.0f seconds.", 500/tps)
|
||||
}
|
||||
+15
-4
@@ -114,7 +114,7 @@ engines are kept.`,
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&na.opts.Node, "node", "", "node id (default: the machine's hostname)")
|
||||
f.StringVar(&na.opts.User, "ssh-user", "", "user to log in as (default: the one before @ in the address, else whoever ran sudo, else you)")
|
||||
f.StringVar(&na.opts.User, "ssh-user", "", "user to log in as (default: the one before @ in the address, else the ssh config's for the machine, else whoever ran sudo, else you)")
|
||||
f.StringArrayVar(&na.servers, "server", nil, "control plane address the agent dials, https://host:7443; repeatable for failover (default: this machine)")
|
||||
f.StringArrayVar(&na.fabrics, "fabric", nil, "InfiniBand or RoCE fabric and its adapters, name:mlx5_0,mlx5_1; repeatable")
|
||||
f.StringVar(&na.opts.MgmtAddr, "mgmt-addr", "", "address engines are reached at (default: found by the agent)")
|
||||
@@ -228,8 +228,9 @@ func (na *nodeAdd) target(addr string) onboard.Options {
|
||||
if o.User == "" {
|
||||
// Under sudo, the person who ran it — their account is usually the
|
||||
// one that exists on the nodes too. root's own keys still do the
|
||||
// logging in, since that is who is running ssh.
|
||||
o.User = os.Getenv("SUDO_USER")
|
||||
// logging in, since that is who is running ssh. Only a fallback: a
|
||||
// User in the ssh config's block for the machine comes first.
|
||||
o.FallbackUser = os.Getenv("SUDO_USER")
|
||||
}
|
||||
return o
|
||||
}
|
||||
@@ -495,7 +496,17 @@ func engineEvent(engine, version, state, detail string) onboard.Event {
|
||||
level = onboard.Warn
|
||||
}
|
||||
return onboard.Event{Step: onboard.StepEngines, Level: level,
|
||||
Detail: fmt.Sprintf("%s %s: %s%s", engine, version, state, prefixed(": ", detail))}
|
||||
Detail: provisionLine(engine, version, state, detail)}
|
||||
}
|
||||
|
||||
// provisionLine is one line of what a node is installing. A package
|
||||
// install that is no engine's, Slurm's or MariaDB's, has no name or version.
|
||||
func provisionLine(engine, version, state, detail string) string {
|
||||
what := strings.TrimSpace(engine + " " + version)
|
||||
if what == "" {
|
||||
what = "system packages"
|
||||
}
|
||||
return fmt.Sprintf("%s: %s%s", what, state, prefixed(": ", detail))
|
||||
}
|
||||
|
||||
func prefixed(p, s string) string {
|
||||
|
||||
+57
-1
@@ -49,6 +49,7 @@ import (
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/fleetmetrics"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelsvc"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/slurmsvc"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/train"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
|
||||
@@ -59,6 +60,7 @@ import (
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/promq"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/router"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/secrets"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/slurm"
|
||||
storepkg "gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
|
||||
@@ -254,6 +256,7 @@ func newServerCmd() *cobra.Command {
|
||||
// The settings in force, for what reads them at each use.
|
||||
var current atomic.Pointer[config.Server]
|
||||
current.Store(&cfg)
|
||||
rt.SetSlurm(func() config.SlurmSettings { return current.Load().Slurm })
|
||||
var agentSvc *agentsvc.Service
|
||||
var agentHandler http.Handler
|
||||
var agentPath string
|
||||
@@ -261,6 +264,8 @@ func newServerCmd() *cobra.Command {
|
||||
// What the settings say of the firewall, for the mirror and the
|
||||
// store's members; nil without a control plane.
|
||||
var fwPub *firewallPublisher
|
||||
// The Slurm cluster beside llmgw, when there is a store (BACKLOG P).
|
||||
var slurmSvc *slurmsvc.Service
|
||||
updateMetrics := &modelserve.UpdateCollector{}
|
||||
// The store's cluster, when it is one; read by the metrics and
|
||||
// by `ctl status`.
|
||||
@@ -412,6 +417,7 @@ func newServerCmd() *cobra.Command {
|
||||
adminAPI = api
|
||||
api.SetReleases(cfg.Releases)
|
||||
api.SetNotifier(notifier)
|
||||
api.SetWebhooks(func() int { return len(current.Load().Notify.Webhooks) })
|
||||
api.SetReplayer(rt.Replay)
|
||||
// Quality suites, asked of a model the way a replay is.
|
||||
evals = newEvalService(cfg, rt, admin, log)
|
||||
@@ -436,6 +442,31 @@ func newServerCmd() *cobra.Command {
|
||||
agentSvc.SetTrain(trainer)
|
||||
go trainer.Run(ctx)
|
||||
api.SetTrain(&adminapi.TrainAdmin{Store: st, Service: trainer})
|
||||
// Slurm on the nodes the settings name, its munge key
|
||||
// made from a cluster secret.
|
||||
slurmSvc = slurmsvc.New(func() config.SlurmSettings { return current.Load().Slurm },
|
||||
admin.Inventory(), agentSvc, func(ctx context.Context) ([]byte, error) {
|
||||
secret, err := st.ClusterSecret(ctx, "slurm-munge")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return slurm.MungeKey(secret)
|
||||
}, log)
|
||||
// What slurm.conf held, for the leader that comes next.
|
||||
slurmSvc.SetMemory(slurmsvc.StoreMemory(st))
|
||||
// slurm.users "llmgw": a Linux user for each active account.
|
||||
slurmSvc.SetUsers(func(ctx context.Context) ([]slurmsvc.User, error) {
|
||||
us, err := st.SlurmUsers(ctx)
|
||||
out := make([]slurmsvc.User, 0, len(us))
|
||||
for _, u := range us {
|
||||
out = append(out, adminapi.SlurmUserView(u))
|
||||
}
|
||||
return out, err
|
||||
})
|
||||
api.SetSlurmUsers(st)
|
||||
agentSvc.SetSlurm(slurmSvc)
|
||||
go slurmSvc.Run(ctx)
|
||||
api.SetSlurm(slurmSvc)
|
||||
}
|
||||
// The cluster's settings, each change checked against this
|
||||
// machine's own keys before it is kept.
|
||||
@@ -453,6 +484,9 @@ func newServerCmd() *cobra.Command {
|
||||
if st := rt.Store(); st != nil {
|
||||
svc := modelsvc.New(cfg.Models, st, admin, log)
|
||||
svc.SetCheck(servableCheck(cfg.WantedEngines()["llamacpp"]))
|
||||
// What is added from the registry is pinned to the
|
||||
// commit it is at, with each file's sha256 for the nodes.
|
||||
svc.SetRegistry(hf.New(cfg.Mirror.Registry, 30*time.Second))
|
||||
// Other llmgw clusters' models, offered as PEER/MODEL:
|
||||
// read before the catalog is built, so a model that falls
|
||||
// back to one is whole at startup.
|
||||
@@ -583,6 +617,10 @@ func newServerCmd() *cobra.Command {
|
||||
agentsvc.SetSourceRewrite(src.Rewrite)
|
||||
}
|
||||
rt.SetMirror(mc)
|
||||
if adminAPI != nil {
|
||||
// A model removed with its files is removed from it too.
|
||||
adminAPI.SetPurgeMirror(mc)
|
||||
}
|
||||
if fwPub != nil {
|
||||
fwPub.setMirror(mc)
|
||||
}
|
||||
@@ -607,7 +645,7 @@ func newServerCmd() *cobra.Command {
|
||||
// Updates: the catalog in force, the service that
|
||||
// changes it, and what replaces replicas.
|
||||
Current: func() []catalog.Model { return admin.Catalog().Models() },
|
||||
Models: catalogSvc, Restart: admin.Reconciler().Restart,
|
||||
Models: catalogSvc, Restart: admin.Reconciler().Restart, RestartInPlace: admin.Reconciler().RestartInPlace,
|
||||
Pool: adminAPI.PoolNodes, Notify: notifier,
|
||||
// Canaries: how the router saw each side serve.
|
||||
CanaryStats: rt.CanaryStats, ResetCanary: rt.ResetCanary,
|
||||
@@ -684,6 +722,21 @@ func newServerCmd() *cobra.Command {
|
||||
if admin != nil && admin.Leadership() != nil && rt.Store() != nil && cfg.AgentTLS.Enabled() {
|
||||
self, _ := os.Hostname()
|
||||
forward = &leaderForward{st: rt.Store(), lead: admin.Leadership(), self: self, tls: cfg.AgentTLS, log: log}
|
||||
// And with router.active_active, a standby answers the client
|
||||
// API itself, to the leader's replicas.
|
||||
if cfg.Router.ActiveActive {
|
||||
var refresh func(context.Context) error
|
||||
if catalogSvc != nil {
|
||||
refresh = catalogSvc.Refresh
|
||||
}
|
||||
routes := newStandbyRoutes(forward, rt, refresh, admin.Publish, log)
|
||||
forward.routes = routes
|
||||
admin.SetReplicaFeed(routes)
|
||||
rt.SetActivator(standbyActivator{Reconciler: admin.Reconciler(), routes: routes})
|
||||
go routes.run(ctx)
|
||||
go rt.ShareBudgets(ctx)
|
||||
log.Info("standbys answer the client API themselves (router.active_active)")
|
||||
}
|
||||
}
|
||||
served := adminHandler
|
||||
if forward != nil {
|
||||
@@ -819,6 +872,9 @@ func newServerCmd() *cobra.Command {
|
||||
fwPub.Kick()
|
||||
}
|
||||
}
|
||||
if slices.ContainsFunc(changed, func(c string) bool { return strings.HasPrefix(c, "slurm") }) && slurmSvc != nil {
|
||||
slurmSvc.Kick()
|
||||
}
|
||||
if slices.ContainsFunc(changed, func(c string) bool { return strings.HasPrefix(c, "on_demand") }) && admin != nil {
|
||||
admin.Reconciler().SetEvictionTimes(next.OnDemand.EvictAfterIdle, next.OnDemand.MinLifetime)
|
||||
}
|
||||
|
||||
@@ -443,7 +443,7 @@ func (sa *serverAdd) storeAccess(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
sa.done("nodes will be told of both control planes within half a minute")
|
||||
sa.done("nodes will be told of every control plane within half a minute")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,13 @@ type serverInit struct {
|
||||
// media has the nodes install ComfyUI and the leader add a picture model
|
||||
// and a 3D model once a card fits one.
|
||||
media bool
|
||||
// slurm runs Slurm beside llmgw: slurmctld on this machine's node,
|
||||
// slurmd on every node, each llmgw's until it is lent.
|
||||
slurm bool
|
||||
// slurmShared is where Slurm's shared files come from: "here" (this
|
||||
// machine exports /shared), the site's export as HOST:/EXPORT, or
|
||||
// "none". slurmUsers is "llmgw" (users from the accounts) or "site".
|
||||
slurmShared, slurmUsers string
|
||||
// evictAfterIdle and minLifetime are on_demand's; zero leaves its own.
|
||||
evictAfterIdle, minLifetime time.Duration
|
||||
// standbys makes each machine of the replicated store a control plane
|
||||
@@ -258,6 +265,12 @@ func newServerInitCmd() *cobra.Command {
|
||||
f.DurationVar(&in.maxKeyTTL, "max-key-ttl", defaultMaxKeyTTL, "the longest an API key lives")
|
||||
f.BoolVar(&in.media, "media", false,
|
||||
"pictures and 3D: the nodes install ComfyUI, and a picture model and a 3D model are added once a card fits one")
|
||||
f.BoolVar(&in.slurm, "slurm", false,
|
||||
"Slurm for training and batch jobs: slurmctld and slurmdbd (with a MariaDB) on this machine, slurmd on every node, each llmgw's until 'llmgw ctl slurm lend'")
|
||||
f.StringVar(&in.slurmShared, "slurm-shared", "here",
|
||||
"with --slurm, the jobs' shared files at /shared: here (this machine exports them over NFS), the site's HOST:/EXPORT, or none")
|
||||
f.StringVar(&in.slurmUsers, "slurm-users", config.UsersLlmgw,
|
||||
"with --slurm, who makes the Linux users that run jobs: llmgw (one for each account, SSH keys in the portal) or site")
|
||||
f.DurationVar(&in.evictAfterIdle, "evict-after-idle", 0,
|
||||
"how long a model loaded on demand goes unasked-for before another may have its card (0: 2m)")
|
||||
f.DurationVar(&in.minLifetime, "min-lifetime", 0,
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/pgha"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/preflight"
|
||||
)
|
||||
@@ -132,9 +133,25 @@ func (in *serverInit) checkChoices() error {
|
||||
return fmt.Errorf("max key ttl %s: an hour at least", in.maxKeyTTL)
|
||||
case in.evictAfterIdle < 0 || in.minLifetime < 0:
|
||||
return fmt.Errorf("on-demand times cannot be negative")
|
||||
case in.slurm && in.noNode:
|
||||
return fmt.Errorf("slurm runs slurmctld on this machine's node: it needs the node (no --no-node)")
|
||||
case in.vip != "" && !in.standbys:
|
||||
return fmt.Errorf("a floating address moves between control planes: it needs the standbys")
|
||||
}
|
||||
if in.slurm {
|
||||
// The controller is this machine's node, named by its hostname: a
|
||||
// name Slurm does not take would keep the server from starting.
|
||||
if err := (config.SlurmSettings{Enabled: true, Controller: localNodeID()}).Check(); err != nil {
|
||||
return fmt.Errorf("slurm: this machine's hostname: %w", err)
|
||||
}
|
||||
set := config.SlurmSettings{Enabled: true, Controller: localNodeID(), Shared: slurmShared(in.slurmShared)}
|
||||
if err := set.Check(); err != nil {
|
||||
return fmt.Errorf("--slurm-shared %q: here, none, or the site's HOST:/EXPORT: %w", in.slurmShared, err)
|
||||
}
|
||||
if u := in.slurmUsers; u != "" && u != config.UsersLlmgw && u != "site" {
|
||||
return fmt.Errorf("--slurm-users %q: llmgw or site", u)
|
||||
}
|
||||
}
|
||||
if in.vip != "" {
|
||||
ip, _, err := net.ParseCIDR(in.vip)
|
||||
if err != nil || ip.To4() == nil {
|
||||
@@ -318,6 +335,9 @@ func (in *serverInit) tuneConfig(body string) string {
|
||||
"# one ('llmgw ctl model presets auto off' stops it).\n"+
|
||||
"engines:\n comfyui: "+provision.ComfyUIVersion()+"\npresets:\n auto: true\n")
|
||||
}
|
||||
if in.slurm {
|
||||
extra = append(extra, slurmSection(localNodeID(), slurmShared(in.slurmShared), in.slurmUsers != "site"))
|
||||
}
|
||||
if in.evictAfterIdle > 0 || in.minLifetime > 0 {
|
||||
b := "# How models that load when asked for take turns on a card, chosen at\n# install.\non_demand:\n"
|
||||
if in.evictAfterIdle > 0 {
|
||||
@@ -334,6 +354,53 @@ func (in *serverInit) tuneConfig(body string) string {
|
||||
return body
|
||||
}
|
||||
|
||||
// slurmShared is --slurm-shared as settings: "here" is /shared exported
|
||||
// by this machine's node, "none" nothing, anything else the site's export.
|
||||
func slurmShared(v string) config.SharedStorage {
|
||||
switch v {
|
||||
case "", "none":
|
||||
return config.SharedStorage{}
|
||||
case "here":
|
||||
return config.SharedStorage{Path: "/shared", Server: localNodeID()}
|
||||
}
|
||||
return config.SharedStorage{Path: "/shared", NFS: v}
|
||||
}
|
||||
|
||||
// slurmSection is server.yaml's slurm block as an install chooses it:
|
||||
// slurmctld on this machine's node, slurmd on every node, the shared files
|
||||
// and the users as chosen.
|
||||
func slurmSection(controller string, shared config.SharedStorage, users bool) string {
|
||||
b := "# Slurm beside llmgw, chosen at install: slurmctld, and slurmdbd with a MariaDB\n" +
|
||||
"# for the jobs' records, on this machine; slurmd on\n" +
|
||||
"# every node, those that join later too. Each node is llmgw's, drained in\n" +
|
||||
"# Slurm, until 'llmgw ctl slurm lend NODE' gives it to Slurm; 'llmgw ctl slurm\n" +
|
||||
"# reclaim NODE' takes it back once its jobs end.\n" +
|
||||
"slurm:\n enabled: true\n controller: " + yamlString(controller) + "\n all_nodes: true\n"
|
||||
switch {
|
||||
case shared.Server != "":
|
||||
b += " # /shared on every Slurm machine, exported over NFS from this one\n" +
|
||||
" # ('llmgw ctl slurm shared' points it at the site's storage instead).\n" +
|
||||
" shared:\n path: " + yamlString(shared.Path) + "\n server: " + yamlString(shared.Server) + "\n"
|
||||
case shared.NFS != "":
|
||||
b += " # /shared on every Slurm machine, mounted from the site's export.\n" +
|
||||
" shared:\n path: " + yamlString(shared.Path) + "\n nfs: " + yamlString(shared.NFS) + "\n"
|
||||
}
|
||||
if users {
|
||||
b += " # A Linux user for each llmgw account, the same UID everywhere; SSH keys\n" +
|
||||
" # in the portal's Profile. Empty leaves users to the site (sssd, a directory).\n" +
|
||||
" users: llmgw\n"
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// localNodeID is the node id this machine's agent takes: its hostname.
|
||||
func localNodeID() string {
|
||||
if h, err := os.Hostname(); err == nil && h != "" {
|
||||
return h
|
||||
}
|
||||
return "localhost"
|
||||
}
|
||||
|
||||
// durationYAML is a duration as a person writes it: 2h, 90m, 1m30s.
|
||||
func durationYAML(d time.Duration) string {
|
||||
switch {
|
||||
@@ -467,6 +534,15 @@ func (in *serverInit) initFlags() []string {
|
||||
if in.media {
|
||||
add("--media")
|
||||
}
|
||||
if in.slurm {
|
||||
add("--slurm")
|
||||
if in.slurmShared != "" && in.slurmShared != "here" {
|
||||
add("--slurm-shared", in.slurmShared)
|
||||
}
|
||||
if in.slurmUsers == "site" {
|
||||
add("--slurm-users", "site")
|
||||
}
|
||||
}
|
||||
if in.evictAfterIdle > 0 {
|
||||
add("--evict-after-idle", durationYAML(in.evictAfterIdle))
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
func TestEveryChoiceIsInTheConfigurationWrittenAndItLoads(t *testing.T) {
|
||||
in := &serverInit{
|
||||
language: "tr", signupOpen: true, defaultRPM: 120, defaultTPM: 50000, maxKeyTTL: 30 * 24 * time.Hour,
|
||||
media: true, evictAfterIdle: time.Minute, minLifetime: 90 * time.Second,
|
||||
media: true, slurm: true, slurmShared: "here", slurmUsers: "llmgw", evictAfterIdle: time.Minute, minLifetime: 90 * time.Second,
|
||||
judge: judgeSpec{Fail: "open", Categories: []string{"Violent", "PII"}, Replicas: 3, Budget: 3 * time.Second},
|
||||
}
|
||||
j := in.judgeFor()
|
||||
@@ -41,6 +41,10 @@ func TestEveryChoiceIsInTheConfigurationWrittenAndItLoads(t *testing.T) {
|
||||
t.Errorf("limits %d %d %s", cfg.Auth.DefaultRPM, cfg.Auth.DefaultTPM, cfg.Auth.MaxKeyTTL)
|
||||
case !cfg.Presets.Auto || cfg.Engines["comfyui"] == "":
|
||||
t.Errorf("pictures and 3D: presets %v comfyui %q", cfg.Presets.Auto, cfg.Engines["comfyui"])
|
||||
case !cfg.Slurm.Enabled || !cfg.Slurm.AllNodes || cfg.Slurm.Controller != localNodeID():
|
||||
t.Errorf("slurm %+v", cfg.Slurm)
|
||||
case cfg.Slurm.Shared.Path != "/shared" || cfg.Slurm.Shared.Server != localNodeID() || cfg.Slurm.Users != "llmgw":
|
||||
t.Errorf("slurm's shared files and users %+v", cfg.Slurm)
|
||||
case cfg.OnDemand.EvictAfterIdle != time.Minute || cfg.OnDemand.MinLifetime != 90*time.Second:
|
||||
t.Errorf("on demand %+v", cfg.OnDemand)
|
||||
}
|
||||
@@ -95,14 +99,14 @@ func TestAStarterIsChosenByNameOrByItsFile(t *testing.T) {
|
||||
|
||||
func TestTheChoicesAreTheirFlagsAgain(t *testing.T) {
|
||||
in := &serverInit{admin: "ops@example.com", hosts: []string{"llm.example.com"}, postgres: true,
|
||||
mirrorPath: "/data/models", noNode: true, language: "tr", media: true,
|
||||
mirrorPath: "/data/models", language: "tr", media: true, slurm: true,
|
||||
webhooks: []string{"https://hooks.slack.com/services/T/B/x, json:https://a.example.com/in"},
|
||||
judge: judgeSpec{Categories: []string{"Violent", "Jailbreak"}}, maxKeyTTL: 7 * 24 * time.Hour}
|
||||
in.starter, _ = starterFrom("qwen3-4b")
|
||||
got := strings.Join(in.initFlags(), " ")
|
||||
want := "--admin ops@example.com --host llm.example.com --language tr --max-key-ttl 168h --postgres " +
|
||||
"--mirror-dir /data/models --no-node --starter qwen3-4b --judge-categories Violent,Jailbreak " +
|
||||
"--webhook https://hooks.slack.com/services/T/B/x --webhook json:https://a.example.com/in --media"
|
||||
"--mirror-dir /data/models --starter qwen3-4b --judge-categories Violent,Jailbreak " +
|
||||
"--webhook https://hooks.slack.com/services/T/B/x --webhook json:https://a.example.com/in --media --slurm"
|
||||
if got != want {
|
||||
t.Errorf("flags\n got %s\nwant %s", got, want)
|
||||
}
|
||||
@@ -124,6 +128,7 @@ func TestChoicesThatCannotBeInstalledAreRefused(t *testing.T) {
|
||||
{maxKeyTTL: time.Minute},
|
||||
{vip: "10.0.0.160/24"},
|
||||
{standbys: true, vip: "10.0.0.160"},
|
||||
{slurm: true, noNode: true},
|
||||
} {
|
||||
if err := in.checkChoices(); err == nil {
|
||||
t.Errorf("taken: %+v", in)
|
||||
|
||||
Loaded 100 of 290 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user