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
|
||||
+139
-7
@@ -35,14 +35,18 @@ What is left, most needed first. The detail is in the item each names.
|
||||
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 limited by the embedding engine's slots. (M)
|
||||
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.
|
||||
@@ -1128,13 +1132,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.
|
||||
@@ -1678,6 +1688,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`.
|
||||
@@ -1815,7 +1827,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
|
||||
@@ -1850,6 +1869,119 @@ 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.
|
||||
|
||||
## D. Facts worth knowing before starting
|
||||
|
||||
- Admin API: `127.0.0.1:9090`, behind the token in
|
||||
|
||||
+226
@@ -20,11 +20,237 @@ is only released once its **exit criterion** is met, not when its code lands.
|
||||
| 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.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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -42,6 +42,7 @@ Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
|
||||
| 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.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
|
||||
|
||||
+132
-22
@@ -1210,8 +1210,12 @@ type Host struct {
|
||||
// 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.
|
||||
MemBandwidthGbs float64 `protobuf:"fixed64,7,opt,name=mem_bandwidth_gbs,json=memBandwidthGbs,proto3" json:"mem_bandwidth_gbs,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
// 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.
|
||||
MemBandwidthCoreGbs float64 `protobuf:"fixed64,8,opt,name=mem_bandwidth_core_gbs,json=memBandwidthCoreGbs,proto3" json:"mem_bandwidth_core_gbs,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *Host) Reset() {
|
||||
@@ -1293,6 +1297,13 @@ func (x *Host) GetMemBandwidthGbs() float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *Host) GetMemBandwidthCoreGbs() float64 {
|
||||
if x != nil {
|
||||
return x.MemBandwidthCoreGbs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Storage is one filesystem a node keeps weights on.
|
||||
//
|
||||
// Per filesystem rather than per configured directory: free space belongs to
|
||||
@@ -2219,12 +2230,27 @@ type GPUSensor struct {
|
||||
NvlinkReplayErrors int64 `protobuf:"varint,23,opt,name=nvlink_replay_errors,json=nvlinkReplayErrors,proto3" json:"nvlink_replay_errors,omitempty"`
|
||||
NvlinkRecoveryErrors int64 `protobuf:"varint,24,opt,name=nvlink_recovery_errors,json=nvlinkRecoveryErrors,proto3" json:"nvlink_recovery_errors,omitempty"`
|
||||
// xid_errors are the driver's error events since the agent started.
|
||||
XidErrors int64 `protobuf:"varint,25,opt,name=xid_errors,json=xidErrors,proto3" json:"xid_errors,omitempty"`
|
||||
LastXid int32 `protobuf:"varint,26,opt,name=last_xid,json=lastXid,proto3" json:"last_xid,omitempty"`
|
||||
LastXidText string `protobuf:"bytes,27,opt,name=last_xid_text,json=lastXidText,proto3" json:"last_xid_text,omitempty"`
|
||||
LastXidUnix int64 `protobuf:"varint,28,opt,name=last_xid_unix,json=lastXidUnix,proto3" json:"last_xid_unix,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
XidErrors int64 `protobuf:"varint,25,opt,name=xid_errors,json=xidErrors,proto3" json:"xid_errors,omitempty"`
|
||||
LastXid int32 `protobuf:"varint,26,opt,name=last_xid,json=lastXid,proto3" json:"last_xid,omitempty"`
|
||||
LastXidText string `protobuf:"bytes,27,opt,name=last_xid_text,json=lastXidText,proto3" json:"last_xid_text,omitempty"`
|
||||
LastXidUnix int64 `protobuf:"varint,28,opt,name=last_xid_unix,json=lastXidUnix,proto3" json:"last_xid_unix,omitempty"`
|
||||
// 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").
|
||||
HasProfile bool `protobuf:"varint,29,opt,name=has_profile,json=hasProfile,proto3" json:"has_profile,omitempty"`
|
||||
// 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.
|
||||
SmActivePercent float64 `protobuf:"fixed64,30,opt,name=sm_active_percent,json=smActivePercent,proto3" json:"sm_active_percent,omitempty"`
|
||||
SmOccupancyPercent float64 `protobuf:"fixed64,31,opt,name=sm_occupancy_percent,json=smOccupancyPercent,proto3" json:"sm_occupancy_percent,omitempty"`
|
||||
// tensor_active_percent is the tensor cores; dram_active_percent the
|
||||
// memory interface, which decode is bound by.
|
||||
TensorActivePercent float64 `protobuf:"fixed64,32,opt,name=tensor_active_percent,json=tensorActivePercent,proto3" json:"tensor_active_percent,omitempty"`
|
||||
DramActivePercent float64 `protobuf:"fixed64,33,opt,name=dram_active_percent,json=dramActivePercent,proto3" json:"dram_active_percent,omitempty"`
|
||||
ProfileSource string `protobuf:"bytes,34,opt,name=profile_source,json=profileSource,proto3" json:"profile_source,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *GPUSensor) Reset() {
|
||||
@@ -2453,6 +2479,48 @@ func (x *GPUSensor) GetLastXidUnix() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *GPUSensor) GetHasProfile() bool {
|
||||
if x != nil {
|
||||
return x.HasProfile
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *GPUSensor) GetSmActivePercent() float64 {
|
||||
if x != nil {
|
||||
return x.SmActivePercent
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *GPUSensor) GetSmOccupancyPercent() float64 {
|
||||
if x != nil {
|
||||
return x.SmOccupancyPercent
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *GPUSensor) GetTensorActivePercent() float64 {
|
||||
if x != nil {
|
||||
return x.TensorActivePercent
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *GPUSensor) GetDramActivePercent() float64 {
|
||||
if x != nil {
|
||||
return x.DramActivePercent
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *GPUSensor) GetProfileSource() string {
|
||||
if x != nil {
|
||||
return x.ProfileSource
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ShardLoad is one engine process group's use.
|
||||
type ShardLoad struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@@ -2992,8 +3060,10 @@ type Heartbeat struct {
|
||||
// the agent has measured it — in the background after it starts, so a
|
||||
// registration usually has none yet. 0 means not measured.
|
||||
MemBandwidthGbs float64 `protobuf:"fixed64,20,opt,name=mem_bandwidth_gbs,json=memBandwidthGbs,proto3" json:"mem_bandwidth_gbs,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
// mem_bandwidth_core_gbs is one core's read rate, as in Host.
|
||||
MemBandwidthCoreGbs float64 `protobuf:"fixed64,21,opt,name=mem_bandwidth_core_gbs,json=memBandwidthCoreGbs,proto3" json:"mem_bandwidth_core_gbs,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *Heartbeat) Reset() {
|
||||
@@ -3166,6 +3236,13 @@ func (x *Heartbeat) GetMemBandwidthGbs() float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *Heartbeat) GetMemBandwidthCoreGbs() float64 {
|
||||
if x != nil {
|
||||
return x.MemBandwidthCoreGbs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Provision is the software a node should have: which engines, at which
|
||||
// versions. Nothing more.
|
||||
//
|
||||
@@ -3586,7 +3663,10 @@ type FetchProgress struct {
|
||||
// bytes_total is 0 when the server did not say how large the file is, which
|
||||
// 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.
|
||||
BytesTotal int64 `protobuf:"varint,3,opt,name=bytes_total,json=bytesTotal,proto3" json:"bytes_total,omitempty"`
|
||||
BytesTotal int64 `protobuf:"varint,3,opt,name=bytes_total,json=bytesTotal,proto3" json:"bytes_total,omitempty"`
|
||||
// phase is what the bytes are: empty while they arrive, "checking" while the
|
||||
// node checks a set's files against the mirror's checksums.
|
||||
Phase string `protobuf:"bytes,4,opt,name=phase,proto3" json:"phase,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -3642,6 +3722,13 @@ func (x *FetchProgress) GetBytesTotal() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *FetchProgress) GetPhase() string {
|
||||
if x != nil {
|
||||
return x.Phase
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// StartShard launches one shard. It is idempotent by shard_id: the control
|
||||
// plane retries, and a repeat must not produce a second process.
|
||||
type StartShard struct {
|
||||
@@ -3849,7 +3936,11 @@ type EngineProfile struct {
|
||||
KvEvents bool `protobuf:"varint,13,opt,name=kv_events,json=kvEvents,proto3" json:"kv_events,omitempty"`
|
||||
// allow_pickle lets the node fetch weights in a pickle format, which run
|
||||
// code when loaded: the catalog's allow_pickle, set on purpose.
|
||||
AllowPickle bool `protobuf:"varint,14,opt,name=allow_pickle,json=allowPickle,proto3" json:"allow_pickle,omitempty"`
|
||||
AllowPickle bool `protobuf:"varint,14,opt,name=allow_pickle,json=allowPickle,proto3" json:"allow_pickle,omitempty"`
|
||||
// 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.
|
||||
ModelSha256 string `protobuf:"bytes,15,opt,name=model_sha256,json=modelSha256,proto3" json:"model_sha256,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -3982,6 +4073,13 @@ func (x *EngineProfile) GetAllowPickle() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *EngineProfile) GetModelSha256() string {
|
||||
if x != nil {
|
||||
return x.ModelSha256
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RemoveWeights deletes cached model files a node no longer needs.
|
||||
//
|
||||
// The paths are computed by the control plane from the catalog, but the agent
|
||||
@@ -5246,7 +5344,7 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1aA\n" +
|
||||
"\x13EngineVersionsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xdd\x01\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x92\x02\n" +
|
||||
"\x04Host\x12\x13\n" +
|
||||
"\x05os_id\x18\x01 \x01(\tR\x04osId\x12\x1d\n" +
|
||||
"\n" +
|
||||
@@ -5256,7 +5354,8 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||
"\x04arch\x18\x04 \x01(\tR\x04arch\x12\x1b\n" +
|
||||
"\tcpu_flags\x18\x05 \x03(\tR\bcpuFlags\x12#\n" +
|
||||
"\rnvidia_driver\x18\x06 \x01(\tR\fnvidiaDriver\x12*\n" +
|
||||
"\x11mem_bandwidth_gbs\x18\a \x01(\x01R\x0fmemBandwidthGbs\"u\n" +
|
||||
"\x11mem_bandwidth_gbs\x18\a \x01(\x01R\x0fmemBandwidthGbs\x123\n" +
|
||||
"\x16mem_bandwidth_core_gbs\x18\b \x01(\x01R\x13memBandwidthCoreGbs\"u\n" +
|
||||
"\aStorage\x12\x12\n" +
|
||||
"\x04path\x18\x01 \x01(\tR\x04path\x12\x1d\n" +
|
||||
"\n" +
|
||||
@@ -5356,7 +5455,8 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||
" \x01(\x01R\tcpuPowerW\x12 \n" +
|
||||
"\finlet_temp_c\x18\v \x01(\x01R\n" +
|
||||
"inletTempC\x12'\n" +
|
||||
"\x04gpus\x18\f \x03(\v2\x13.llmgw.v1.GPUSensorR\x04gpus\"\xde\b\n" +
|
||||
"\x04gpus\x18\f \x03(\v2\x13.llmgw.v1.GPUSensorR\x04gpus\"\xe8\n" +
|
||||
"\n" +
|
||||
"\tGPUSensor\x12\x14\n" +
|
||||
"\x05index\x18\x01 \x01(\x05R\x05index\x12\x15\n" +
|
||||
"\x06temp_c\x18\x02 \x01(\x01R\x05tempC\x12\"\n" +
|
||||
@@ -5388,7 +5488,14 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||
"xid_errors\x18\x19 \x01(\x03R\txidErrors\x12\x19\n" +
|
||||
"\blast_xid\x18\x1a \x01(\x05R\alastXid\x12\"\n" +
|
||||
"\rlast_xid_text\x18\x1b \x01(\tR\vlastXidText\x12\"\n" +
|
||||
"\rlast_xid_unix\x18\x1c \x01(\x03R\vlastXidUnix\"m\n" +
|
||||
"\rlast_xid_unix\x18\x1c \x01(\x03R\vlastXidUnix\x12\x1f\n" +
|
||||
"\vhas_profile\x18\x1d \x01(\bR\n" +
|
||||
"hasProfile\x12*\n" +
|
||||
"\x11sm_active_percent\x18\x1e \x01(\x01R\x0fsmActivePercent\x120\n" +
|
||||
"\x14sm_occupancy_percent\x18\x1f \x01(\x01R\x12smOccupancyPercent\x122\n" +
|
||||
"\x15tensor_active_percent\x18 \x01(\x01R\x13tensorActivePercent\x12.\n" +
|
||||
"\x13dram_active_percent\x18! \x01(\x01R\x11dramActivePercent\x12%\n" +
|
||||
"\x0eprofile_source\x18\" \x01(\tR\rprofileSource\"m\n" +
|
||||
"\tShardLoad\x12\x19\n" +
|
||||
"\bshard_id\x18\x01 \x01(\tR\ashardId\x12\x1b\n" +
|
||||
"\tcpu_cores\x18\x02 \x01(\x01R\bcpuCores\x12(\n" +
|
||||
@@ -5434,7 +5541,7 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||
"\x06status\x18\x03 \x01(\tR\x06status\x12!\n" +
|
||||
"\fcluster_uuid\x18\x04 \x01(\tR\vclusterUuid\x12\x1b\n" +
|
||||
"\tclique_id\x18\x05 \x01(\tR\bcliqueId\x12\x1c\n" +
|
||||
"\tbandwidth\x18\x06 \x01(\tR\tbandwidth\"\xd5\a\n" +
|
||||
"\tbandwidth\x18\x06 \x01(\tR\tbandwidth\"\x8a\b\n" +
|
||||
"\tHeartbeat\x12\x17\n" +
|
||||
"\anode_id\x18\x01 \x01(\tR\x06nodeId\x12+\n" +
|
||||
"\x12gpu_memory_used_mb\x18\x02 \x03(\x05R\x0fgpuMemoryUsedMb\x12!\n" +
|
||||
@@ -5457,7 +5564,8 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||
"\x04imex\x18\x11 \x01(\v2\x0e.llmgw.v1.IMEXR\x04imex\x12\x1b\n" +
|
||||
"\tcpu_count\x18\x12 \x01(\x05R\bcpuCount\x121\n" +
|
||||
"\x15gpu_foreign_memory_mb\x18\x13 \x03(\x05R\x12gpuForeignMemoryMb\x12*\n" +
|
||||
"\x11mem_bandwidth_gbs\x18\x14 \x01(\x01R\x0fmemBandwidthGbs\x1aA\n" +
|
||||
"\x11mem_bandwidth_gbs\x18\x14 \x01(\x01R\x0fmemBandwidthGbs\x123\n" +
|
||||
"\x16mem_bandwidth_core_gbs\x18\x15 \x01(\x01R\x13memBandwidthCoreGbs\x1aA\n" +
|
||||
"\x13EngineVersionsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\";\n" +
|
||||
@@ -5504,13 +5612,14 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||
"\vSTATE_READY\x10\x04\x12\x12\n" +
|
||||
"\x0eSTATE_DRAINING\x10\x05\x12\x11\n" +
|
||||
"\rSTATE_STOPPED\x10\x06\x12\x10\n" +
|
||||
"\fSTATE_FAILED\x10\a\"g\n" +
|
||||
"\fSTATE_FAILED\x10\a\"}\n" +
|
||||
"\rFetchProgress\x12\x16\n" +
|
||||
"\x06source\x18\x01 \x01(\tR\x06source\x12\x1d\n" +
|
||||
"\n" +
|
||||
"bytes_done\x18\x02 \x01(\x03R\tbytesDone\x12\x1f\n" +
|
||||
"\vbytes_total\x18\x03 \x01(\x03R\n" +
|
||||
"bytesTotal\"\x93\x04\n" +
|
||||
"bytesTotal\x12\x14\n" +
|
||||
"\x05phase\x18\x04 \x01(\tR\x05phase\"\x93\x04\n" +
|
||||
"\n" +
|
||||
"StartShard\x12\x1d\n" +
|
||||
"\n" +
|
||||
@@ -5536,7 +5645,7 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||
"profile_fp\x18\x0f \x01(\tR\tprofileFp\x12\x17\n" +
|
||||
"\aapi_key\x18\x10 \x01(\tR\x06apiKey\x12\x1f\n" +
|
||||
"\vcpu_threads\x18\x11 \x01(\x05R\n" +
|
||||
"cpuThreads\"\xf0\x03\n" +
|
||||
"cpuThreads\"\x93\x04\n" +
|
||||
"\rEngineProfile\x12\x12\n" +
|
||||
"\x04name\x18\x01 \x01(\tR\x04name\x12\x16\n" +
|
||||
"\x06engine\x18\x02 \x01(\tR\x06engine\x12\x1b\n" +
|
||||
@@ -5553,7 +5662,8 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||
"\x11served_model_name\x18\v \x01(\tR\x0fservedModelName\x121\n" +
|
||||
"\badapters\x18\f \x03(\v2\x15.llmgw.v1.LoraAdapterR\badapters\x12\x1b\n" +
|
||||
"\tkv_events\x18\r \x01(\bR\bkvEvents\x12!\n" +
|
||||
"\fallow_pickle\x18\x0e \x01(\bR\vallowPickle\x1a6\n" +
|
||||
"\fallow_pickle\x18\x0e \x01(\bR\vallowPickle\x12!\n" +
|
||||
"\fmodel_sha256\x18\x0f \x01(\tR\vmodelSha256\x1a6\n" +
|
||||
"\bEnvEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"%\n" +
|
||||
|
||||
@@ -241,6 +241,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 +452,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 +622,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 +726,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 +791,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+337
-2
@@ -110,6 +110,9 @@
|
||||
{
|
||||
"name": "restart"
|
||||
},
|
||||
{
|
||||
"name": "routing"
|
||||
},
|
||||
{
|
||||
"name": "settings"
|
||||
},
|
||||
@@ -2730,7 +2733,7 @@
|
||||
"/api/v1/models/{name}/context": {
|
||||
"post": {
|
||||
"operationId": "postModelsNameContext",
|
||||
"summary": "Change a served model's context: the catalog, its engines' arguments and memory, then its replicas restarted",
|
||||
"summary": "Change a served model's context and longest answer: the catalog, its engines' arguments and memory, then its replicas restarted; max_output alone changes only the catalog and restarts nothing",
|
||||
"tags": [
|
||||
"models"
|
||||
],
|
||||
@@ -3224,6 +3227,58 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/models/{name}/replicas": {
|
||||
"post": {
|
||||
"operationId": "postModelsNameReplicas",
|
||||
"summary": "How many replicas run on each profile",
|
||||
"description": "Seven on cards and four on CPUs, under the model's name. A profile not named keeps its count; cpu adds a profile on CPUs to a llama.cpp model that has none. Its scaling becomes the sum.",
|
||||
"tags": [
|
||||
"models"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "The model's name in the catalog.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/Actor"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProfileReplicasRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/catalog.Model"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"default": {
|
||||
"$ref": "#/components/responses/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/models/{name}/resources": {
|
||||
"post": {
|
||||
"operationId": "postModelsNameResources",
|
||||
@@ -4793,6 +4848,77 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/routing": {
|
||||
"get": {
|
||||
"operationId": "getRouting",
|
||||
"summary": "The Ready replicas a standby routes to",
|
||||
"description": "Read by a standby control plane when router.active_active is on: each replica's address, model, profile, pool and weight, without its key, and which catalog is in force.",
|
||||
"tags": [
|
||||
"routing"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/control.Routing"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"default": {
|
||||
"$ref": "#/components/responses/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/routing/activity": {
|
||||
"post": {
|
||||
"operationId": "postRoutingActivity",
|
||||
"summary": "A standby's router reports what it used",
|
||||
"description": "When it last sent each replica a request, and the on-demand models it was asked for, so the leader does not evict a replica busy behind a standby. Answered with the routing, so one request a tick does both.",
|
||||
"tags": [
|
||||
"routing"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/Actor"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/control.Activity"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/control.Routing"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"default": {
|
||||
"$ref": "#/components/responses/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/settings": {
|
||||
"get": {
|
||||
"operationId": "getSettings",
|
||||
@@ -6485,6 +6611,10 @@
|
||||
"percent": {
|
||||
"type": "integer",
|
||||
"description": "Percent is absent rather than zero when the total is unknown: an invented percentage that stops moving is worse than a byte count that keeps rising."
|
||||
},
|
||||
"phase": {
|
||||
"type": "string",
|
||||
"description": "Phase is empty while the bytes arrive, \"checking\" while a set's files are checked against the mirror's checksums."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -8025,6 +8155,13 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"profile_replicas": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "ProfileReplicas is how many replicas run on each profile, for a model whose replicas are counted per profile; absent otherwise."
|
||||
},
|
||||
"memory_gb": {
|
||||
"type": "integer",
|
||||
"description": "MemoryGB and CPUCores are what one replica reserves, from its first profile; MeasuredMemoryGB is the most one of its replicas uses now, 0 when none is measured."
|
||||
@@ -8227,6 +8364,10 @@
|
||||
"type": "number",
|
||||
"description": "MemBandwidthGBs is how fast its memory reads, in GB/s, measured when its agent started; 0 when not reported. A model's speed on the CPU."
|
||||
},
|
||||
"mem_bandwidth_core_gbs": {
|
||||
"type": "number",
|
||||
"description": "MemBandwidthCoreGBs is what one core reads alone, in GB/s; 0 when not reported. A CPU replica's threads read at about this each."
|
||||
},
|
||||
"engines": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -8583,6 +8724,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProfileReplicasRequest": {
|
||||
"type": "object",
|
||||
"description": "ProfileReplicasRequest counts a model's replicas per profile: profile name to replicas, and CPU, for a llama.cpp model with no profile on CPUs, the one to add.",
|
||||
"properties": {
|
||||
"profiles": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"cpu": {
|
||||
"$ref": "#/components/schemas/modelsvc.CPUProfile"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProjectView": {
|
||||
"type": "object",
|
||||
"description": "ProjectView is a project, with how many people and keys are in it.",
|
||||
@@ -9692,6 +9848,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"accounting.Counts": {
|
||||
"type": "object",
|
||||
"description": "Counts are one limiter's counts by counter: this minute's and the one before, as absolute figures, so reporting them twice changes nothing.",
|
||||
"properties": {
|
||||
"minute": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"current": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
"previous": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"accounting.Rates": {
|
||||
"type": "object",
|
||||
"description": "Rates are an accountant's two limiters' counts.",
|
||||
"properties": {
|
||||
"requests": {
|
||||
"$ref": "#/components/schemas/accounting.Counts"
|
||||
},
|
||||
"tokens": {
|
||||
"$ref": "#/components/schemas/accounting.Counts"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bench.Capacity": {
|
||||
"type": "object",
|
||||
"description": "Capacity is what one replica was measured to serve, kept so the measurement outlives the terminal it was printed in.\n\nThe conclusion of a run, not the run. The ramp itself — every concurrency level, its latencies, its errors — is worth reading once, while deciding whether the run was sound; it is not worth carrying around afterwards, and storing it would grow without bound as a model is re-benchmarked. What a later reader needs is the number and enough provenance to judge whether it is still true.",
|
||||
@@ -10392,6 +10584,10 @@
|
||||
"type": "integer",
|
||||
"description": "Priority orders profiles during placement; highest that fits wins."
|
||||
},
|
||||
"replicas": {
|
||||
"type": "integer",
|
||||
"description": "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": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
@@ -10550,6 +10746,77 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"control.Activity": {
|
||||
"type": "object",
|
||||
"description": "Activity is what a standby's router reports back: when it last sent each replica a request, and the on-demand models it was asked for. The leader evicts and loads by these, and without them a replica busy behind a standby looks idle to the one machine that decides.",
|
||||
"properties": {
|
||||
"from": {
|
||||
"type": "string",
|
||||
"description": "From names the standby, so its counts replace its own last ones and are not given back to it."
|
||||
},
|
||||
"last_used": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
"want": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"rates": {
|
||||
"$ref": "#/components/schemas/accounting.Rates",
|
||||
"description": "Rates are its router's own counts against each limit."
|
||||
}
|
||||
}
|
||||
},
|
||||
"control.RoutedReplica": {
|
||||
"type": "object",
|
||||
"description": "RoutedReplica is a Ready replica as a router needs it: where it is, what it serves and how much it may take. Not its key — every control plane derives that from the replica's id and the cluster's secret, so none travels.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"profile": {
|
||||
"type": "string"
|
||||
},
|
||||
"pool": {
|
||||
"type": "string"
|
||||
},
|
||||
"endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"control.Routing": {
|
||||
"type": "object",
|
||||
"description": "Routing is what a standby that answers requests itself reads from the leader: the replicas it may send to, and which catalog they belong to.",
|
||||
"properties": {
|
||||
"replicas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/control.RoutedReplica"
|
||||
}
|
||||
},
|
||||
"catalog": {
|
||||
"type": "string",
|
||||
"description": "Catalog changes whenever the leader puts a catalog in force. Only compared with the last one a standby saw, never with its own: a model added, scaled or given a canary is in the store, and the standby reads it again from there."
|
||||
},
|
||||
"rates": {
|
||||
"$ref": "#/components/schemas/accounting.Rates",
|
||||
"description": "Rates are what every other control plane's router has counted against each limit, the leader's among them: added to the asker's own, they make a key's limits the cluster's."
|
||||
}
|
||||
}
|
||||
},
|
||||
"eval.Live": {
|
||||
"type": "object",
|
||||
"description": "Live is a run as it stands: one running, with how many of its cases are done, or one finished.",
|
||||
@@ -11015,6 +11282,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"inventory.GPUProfile": {
|
||||
"type": "object",
|
||||
"description": "GPUProfile is a card's profiling counters, in percent; -1 is not reported. UtilPercent is the share of time any kernel ran, and a kernel on one SM of a hundred counts in full; these say how much of the chip it ran on.",
|
||||
"properties": {
|
||||
"sm_active": {
|
||||
"type": "number",
|
||||
"description": "SMActive is the share of time the SMs had work, averaged over all of them; SMOccupancy how full those SMs were with warps."
|
||||
},
|
||||
"sm_occupancy": {
|
||||
"type": "number"
|
||||
},
|
||||
"tensor_active": {
|
||||
"type": "number",
|
||||
"description": "TensorActive is the tensor cores, DRAMActive the memory interface."
|
||||
},
|
||||
"dram_active": {
|
||||
"type": "number"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Source is what read them: \"gpm\" (NVML, Hopper and later) or \"dcgm\"."
|
||||
}
|
||||
}
|
||||
},
|
||||
"inventory.GPUSensor": {
|
||||
"type": "object",
|
||||
"description": "GPUSensor is one card's heat and power, and what is throttling it.",
|
||||
@@ -11062,6 +11353,10 @@
|
||||
"wear": {
|
||||
"$ref": "#/components/schemas/inventory.GPUWear",
|
||||
"description": "Wear is what wears the card out, read every minute; nil from an agent that does not read it."
|
||||
},
|
||||
"profile": {
|
||||
"$ref": "#/components/schemas/inventory.GPUProfile",
|
||||
"description": "Profile is how much of the chip the work uses, which UtilPercent does not say; nil where nothing on the node can read it."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -11186,6 +11481,17 @@
|
||||
"expert_parallel": {
|
||||
"type": "boolean",
|
||||
"description": "ExpertParallel spreads a mixture-of-experts model's experts over the cards, whole, rather than slicing each across them: vLLM's --enable-expert-parallel, for a model of experts on more than one card."
|
||||
},
|
||||
"strata_family": {
|
||||
"type": "string",
|
||||
"description": "StrataFamily and StrataSize are Strata's --family and --size, for an option on Strata: one card, and the experts in the node's RAM, which NeedGB and HaveGB are then about."
|
||||
},
|
||||
"strata_size": {
|
||||
"type": "string"
|
||||
},
|
||||
"tokens_per_second": {
|
||||
"type": "number",
|
||||
"description": "TokensPerSecond is about how many tokens a second one reply gets this way, on the fastest host it fits; 0 when not known. On the CPU with the threads a replica is given by default: half the node's."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -11234,6 +11540,13 @@
|
||||
"max_output": {
|
||||
"type": "integer"
|
||||
},
|
||||
"output_only": {
|
||||
"type": "boolean",
|
||||
"description": "OutputOnly is a change of the longest answer alone, MaxOutputBefore what it was (0 no limit); nothing was started again."
|
||||
},
|
||||
"max_output_before": {
|
||||
"type": "integer"
|
||||
},
|
||||
"trained": {
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -11278,13 +11591,16 @@
|
||||
},
|
||||
"modelserve.ContextRequest": {
|
||||
"type": "object",
|
||||
"description": "ContextRequest is the context to change to: tokens one request may use, and for llama.cpp how many at once (its pool is the two multiplied).",
|
||||
"description": "ContextRequest is the context to change to: tokens one request may use, and for llama.cpp how many at once (its pool is the two multiplied). MaxOutput is the longest answer, which may be changed with it or alone: Tokens 0 with a MaxOutput changes only that, in the catalog, which the router reads it from — nothing is started again.",
|
||||
"properties": {
|
||||
"tokens": {
|
||||
"type": "integer"
|
||||
},
|
||||
"slots": {
|
||||
"type": "integer"
|
||||
},
|
||||
"max_output": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -11536,6 +11852,10 @@
|
||||
"type": "boolean",
|
||||
"description": "Fallbacks adds the next best ways, lower in priority, so a model whose first choice is busy runs on another rather than waiting."
|
||||
},
|
||||
"engine": {
|
||||
"type": "string",
|
||||
"description": "Engine is the engine the file runs on: \"strata\" for a family Strata runs, \"llamacpp\" to keep it off Strata; empty, the plan's best."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name is the catalog name; the repository suggests one otherwise."
|
||||
@@ -11664,6 +11984,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"modelsvc.CPUProfile": {
|
||||
"type": "object",
|
||||
"description": "CPUProfile is a profile on CPUs to add to a llama.cpp model, for replicas counted per profile: what a replica of it reserves.",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"memory_gb": {
|
||||
"type": "integer"
|
||||
},
|
||||
"cpu_cores": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notify.Result": {
|
||||
"type": "object",
|
||||
"description": "Result is how one webhook answered a test.",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+167
-13
@@ -703,6 +703,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 +747,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 +993,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.
|
||||
@@ -1653,23 +1767,37 @@ llmgw ctl model remove qwen-7b --purge # its files on the mirror and the nodes
|
||||
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
|
||||
### Changing a served model's context and longest answer
|
||||
|
||||
How much one request may use is set when a model is served. To change it
|
||||
afterwards:
|
||||
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 # 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 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
|
||||
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
|
||||
@@ -1961,12 +2089,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:
|
||||
@@ -2674,9 +2821,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.
|
||||
|
||||
+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.
|
||||
|
||||
|
||||
@@ -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) |
|
||||
@@ -234,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) |
|
||||
@@ -293,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) |
|
||||
@@ -318,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) |
|
||||
@@ -481,6 +485,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) |
|
||||
@@ -1316,6 +1321,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`
|
||||
@@ -2182,6 +2197,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`
|
||||
@@ -2588,6 +2612,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`
|
||||
@@ -2788,6 +2822,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`
|
||||
@@ -4032,6 +4082,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)
|
||||
}
|
||||
}
|
||||
+40
-15
@@ -47,7 +47,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
|
||||
@@ -185,7 +187,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(),
|
||||
@@ -517,7 +519,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
|
||||
@@ -576,7 +579,8 @@ func (a *Agent) session(ctx context.Context, server string) error {
|
||||
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{
|
||||
@@ -674,7 +678,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 +801,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 =
|
||||
@@ -878,6 +888,7 @@ func (a *Agent) removeWeights(paths []string) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1129,11 +1140,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 +1163,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 +1576,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 +1609,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})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+113
-15
@@ -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.
|
||||
@@ -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.
|
||||
@@ -204,3 +207,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)
|
||||
}
|
||||
+20
-3
@@ -418,7 +418,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)
|
||||
}
|
||||
@@ -499,8 +499,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()
|
||||
@@ -692,3 +696,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
|
||||
}
|
||||
+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)
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+28
-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
|
||||
}
|
||||
|
||||
|
||||
+78
-11
@@ -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(newModelStopCmd(client), newModelStartCmd(client), newModelContextCmd(client), newModelGPUsCmd(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),
|
||||
@@ -472,10 +473,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" +
|
||||
@@ -483,21 +484,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)
|
||||
@@ -523,6 +542,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -579,3 +599,50 @@ func newModelGPUsCmd(client func() *ctlClient) *cobra.Command {
|
||||
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)
|
||||
}
|
||||
@@ -453,6 +453,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.
|
||||
@@ -688,6 +691,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 {
|
||||
|
||||
@@ -64,6 +64,9 @@ const agentUnit = `[Unit]
|
||||
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
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/router"
|
||||
)
|
||||
|
||||
// How often a standby answering requests itself hears from the leader, and
|
||||
// how long it keeps routing to the replicas it last heard of when it does
|
||||
// not. Long enough to ride out the leader changing hands — the engines are
|
||||
// still where they were, and a dead one opens its breaker — short enough
|
||||
// that a standby cut off from the cluster stops sending to a list it cannot
|
||||
// check.
|
||||
const (
|
||||
standbyPoll = 2 * time.Second
|
||||
standbyTrust = time.Minute
|
||||
)
|
||||
|
||||
// The router is what counts against limits, and shares the counts.
|
||||
var _ control.RateSharer = (*router.Router)(nil)
|
||||
|
||||
// standbyRoutes is a standby's copy of the leader's Ready replicas, when
|
||||
// standbys answer requests themselves (router.active_active). Each tick it
|
||||
// reports what its router used and is answered with the replicas, in one
|
||||
// request over the agent port with its control-plane certificate.
|
||||
type standbyRoutes struct {
|
||||
fwd *leaderForward
|
||||
usage control.UsageSource
|
||||
refresh func(context.Context) error // reads the catalog again from the store
|
||||
publish func(context.Context) // hands the router the new list now
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
reps []control.RoutedReplica
|
||||
heard time.Time
|
||||
catalog string // the leader's catalog revision last seen
|
||||
sent map[string]time.Time // the last use already reported, by replica
|
||||
want map[string]bool // on-demand models asked for since the last report
|
||||
kick chan struct{}
|
||||
|
||||
// The connection to the leader, kept between ticks: one per leader
|
||||
// address, the previous one's closed when the lease moves.
|
||||
client *http.Client
|
||||
clientHost string
|
||||
}
|
||||
|
||||
func newStandbyRoutes(fwd *leaderForward, usage control.UsageSource, refresh func(context.Context) error,
|
||||
publish func(context.Context), log *slog.Logger,
|
||||
) *standbyRoutes {
|
||||
return &standbyRoutes{fwd: fwd, usage: usage, refresh: refresh, publish: publish, log: log, now: time.Now,
|
||||
sent: map[string]time.Time{}, want: map[string]bool{}, kick: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
// Ready satisfies control.ReplicaFeed.
|
||||
func (s *standbyRoutes) Ready() ([]control.RoutedReplica, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.heard.IsZero() || s.now().Sub(s.heard) > standbyTrust {
|
||||
return nil, false
|
||||
}
|
||||
return s.reps, true
|
||||
}
|
||||
|
||||
// serving says whether this standby has replicas to send a request to, which
|
||||
// is when it keeps the client API rather than passing it to the leader.
|
||||
func (s *standbyRoutes) serving() bool {
|
||||
reps, ok := s.Ready()
|
||||
return ok && len(reps) > 0
|
||||
}
|
||||
|
||||
// wanted queues an on-demand model for the leader, and reports at once
|
||||
// rather than on the next tick: someone is waiting on it.
|
||||
func (s *standbyRoutes) wanted(model string) {
|
||||
s.mu.Lock()
|
||||
s.want[model] = true
|
||||
s.mu.Unlock()
|
||||
select {
|
||||
case s.kick <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *standbyRoutes) run(ctx context.Context) {
|
||||
t := time.NewTicker(standbyPoll)
|
||||
defer t.Stop()
|
||||
for {
|
||||
if s.fwd.lead.IsLeader() {
|
||||
// Its own reconciler's replicas now. What it heard as a standby
|
||||
// is forgotten, so becoming one again starts afresh.
|
||||
s.mu.Lock()
|
||||
s.reps, s.heard, s.catalog = nil, time.Time{}, ""
|
||||
clear(s.sent)
|
||||
clear(s.want)
|
||||
s.mu.Unlock()
|
||||
} else if err := s.exchange(ctx); err != nil {
|
||||
s.log.Debug("could not hear from the leader", "err", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
case <-s.kick:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// exchange reports activity and takes the replicas in return.
|
||||
func (s *standbyRoutes) exchange(ctx context.Context) error {
|
||||
act, sent := s.activity()
|
||||
body, err := json.Marshal(act)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, name, err := s.fwd.leader(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := s.clientFor(target.Host, target.Hostname())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
"https://"+target.Host+forwardPrefix+"/api/v1/routing/activity", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set(hdrForwardedBy, s.fwd.self)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("%s answered %s: %s", name, resp.Status, bytes.TrimSpace(b))
|
||||
}
|
||||
var rt control.Routing
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rt); err != nil {
|
||||
return fmt.Errorf("%s's routing: %w", name, err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.reps, s.heard = rt.Replicas, s.now()
|
||||
stale := rt.Catalog != s.catalog
|
||||
s.catalog = rt.Catalog
|
||||
for id, at := range sent {
|
||||
s.sent[id] = at
|
||||
}
|
||||
for _, m := range act.Want {
|
||||
delete(s.want, m)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// What the leader and the other standbys counted, so this router's
|
||||
// limits are the cluster's.
|
||||
if rs, ok := s.usage.(control.RateSharer); ok && rt.Rates != nil {
|
||||
rs.SetOtherRates(*rt.Rates)
|
||||
}
|
||||
|
||||
// A model added or changed through the leader is in the store: the
|
||||
// catalog first, so the replicas published next resolve against it.
|
||||
if stale && s.refresh != nil {
|
||||
if err := s.refresh(ctx); err != nil {
|
||||
s.log.Warn("could not read the catalog the leader changed", "err", err)
|
||||
s.mu.Lock()
|
||||
s.catalog = "" // tried again next tick
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
if s.publish != nil {
|
||||
s.publish(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clientFor is the client for the leader at host, made once per leader.
|
||||
// Only the run loop calls it.
|
||||
func (s *standbyRoutes) clientFor(host, name string) (*http.Client, error) {
|
||||
if s.client != nil && s.clientHost == host {
|
||||
return s.client, nil
|
||||
}
|
||||
tc, err := s.fwd.tls.ClientConfig(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.client != nil {
|
||||
s.client.CloseIdleConnections()
|
||||
}
|
||||
s.client = &http.Client{Transport: &http2.Transport{TLSClientConfig: tc, ReadIdleTimeout: 30 * time.Second}}
|
||||
s.clientHost = host
|
||||
return s.client, nil
|
||||
}
|
||||
|
||||
// activity is what to report: uses newer than the last reported, and the
|
||||
// models asked for. sent is what to record as reported once the leader has
|
||||
// it.
|
||||
func (s *standbyRoutes) activity() (control.Activity, map[string]time.Time) {
|
||||
var used map[string]time.Time
|
||||
if s.usage != nil {
|
||||
used = s.usage.LastUsed()
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
act := control.Activity{From: s.fwd.self, LastUsed: map[string]time.Time{}}
|
||||
if rs, ok := s.usage.(control.RateSharer); ok {
|
||||
r := rs.Rates()
|
||||
act.Rates = &r
|
||||
}
|
||||
for id, at := range used {
|
||||
if at.After(s.sent[id]) {
|
||||
act.LastUsed[id] = at
|
||||
}
|
||||
}
|
||||
for m := range s.want {
|
||||
act.Want = append(act.Want, m)
|
||||
}
|
||||
return act, act.LastUsed
|
||||
}
|
||||
|
||||
// standbyActivator asks the leader for an on-demand model when a request for
|
||||
// it reaches a standby: the standby's own reconciler places nothing. Which
|
||||
// models load on demand, and why one cannot, are still the reconciler's to
|
||||
// answer from the catalog.
|
||||
type standbyActivator struct {
|
||||
*reconciler.Reconciler
|
||||
routes *standbyRoutes
|
||||
}
|
||||
|
||||
func (a standbyActivator) Want(model string) bool {
|
||||
if !a.Reconciler.Want(model) {
|
||||
return false
|
||||
}
|
||||
if !a.routes.fwd.lead.IsLeader() {
|
||||
a.routes.wanted(model)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/accounting"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control"
|
||||
)
|
||||
|
||||
// fakeLeaderRouting is the leader's routing API: what it was told, and the
|
||||
// replicas and catalog it answers with.
|
||||
type fakeLeaderRouting struct {
|
||||
mu sync.Mutex
|
||||
got []control.Activity
|
||||
routing control.Routing
|
||||
}
|
||||
|
||||
func (f *fakeLeaderRouting) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/routing/activity" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var act control.Activity
|
||||
if err := json.NewDecoder(r.Body).Decode(&act); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.got = append(f.got, act)
|
||||
_ = json.NewEncoder(w).Encode(f.routing)
|
||||
}
|
||||
|
||||
func (f *fakeLeaderRouting) last() control.Activity {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.got[len(f.got)-1]
|
||||
}
|
||||
|
||||
type fixedUsage map[string]time.Time
|
||||
|
||||
func (u fixedUsage) LastUsed() map[string]time.Time { return u }
|
||||
|
||||
// sharingUsage is a router whose limits are counted with the others'.
|
||||
type sharingUsage struct {
|
||||
fixedUsage
|
||||
own accounting.Rates
|
||||
others *accounting.Rates
|
||||
}
|
||||
|
||||
func (u *sharingUsage) Rates() accounting.Rates { return u.own }
|
||||
func (u *sharingUsage) SetOtherRates(r accounting.Rates) { u.others = &r }
|
||||
|
||||
// A standby reports its counts under its name and puts what the leader says
|
||||
// the rest of the cluster counted in force on its router.
|
||||
func TestAStandbysLimitsAreTheClusters(t *testing.T) {
|
||||
theirs := accounting.NewRates(time.Now())
|
||||
theirs.Tokens.Current = map[string]int64{"key-1": 900}
|
||||
leader := &fakeLeaderRouting{routing: control.Routing{Rates: &theirs}}
|
||||
c := newForwardClusterWith(t, leader)
|
||||
u := &sharingUsage{own: accounting.NewRates(time.Now())}
|
||||
u.own.Requests.Current = map[string]int64{"key-1": 4}
|
||||
s := newStandbyRoutes(c.standby, u, nil, nil, slog.New(slog.DiscardHandler))
|
||||
if err := s.exchange(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
act := leader.last()
|
||||
if act.From != "ctl-b" || act.Rates == nil || act.Rates.Requests.Current["key-1"] != 4 {
|
||||
t.Errorf("reported %+v", act)
|
||||
}
|
||||
if u.others == nil || u.others.Tokens.Current["key-1"] != 900 {
|
||||
t.Errorf("the router was told %+v", u.others)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStandbyFollowsTheLeadersReplicas(t *testing.T) {
|
||||
leader := &fakeLeaderRouting{routing: control.Routing{Catalog: "a/1",
|
||||
Replicas: []control.RoutedReplica{{ID: "llama-r0", Model: "llama", Endpoint: "http://10.0.0.5:20001"}}}}
|
||||
c := newForwardClusterWith(t, leader)
|
||||
used := time.Date(2026, 11, 1, 12, 0, 0, 0, time.UTC)
|
||||
refreshed := 0
|
||||
s := newStandbyRoutes(c.standby, fixedUsage{"llama-r0": used}, func(_ context.Context) error {
|
||||
refreshed++
|
||||
return nil
|
||||
}, nil, slog.New(slog.DiscardHandler))
|
||||
|
||||
if _, ok := s.Ready(); ok {
|
||||
t.Fatal("ready before hearing from the leader")
|
||||
}
|
||||
if err := s.exchange(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reps, ok := s.Ready(); !ok || len(reps) != 1 || reps[0].ID != "llama-r0" {
|
||||
t.Fatalf("replicas %+v, %v", reps, ok)
|
||||
}
|
||||
if got := leader.last().LastUsed["llama-r0"]; !got.Equal(used) {
|
||||
t.Errorf("the leader was told the replica was last used %v", got)
|
||||
}
|
||||
if refreshed != 1 {
|
||||
t.Errorf("the catalog was read %d times on first hearing from the leader", refreshed)
|
||||
}
|
||||
|
||||
// Only what is new is reported, and the catalog is read again only when
|
||||
// the leader's changed.
|
||||
s.wanted("flux")
|
||||
if err := s.exchange(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if act := leader.last(); len(act.LastUsed) != 0 || !slices.Equal(act.Want, []string{"flux"}) {
|
||||
t.Errorf("second report %+v", act)
|
||||
}
|
||||
if refreshed != 1 {
|
||||
t.Errorf("an unchanged catalog was read again")
|
||||
}
|
||||
leader.mu.Lock()
|
||||
leader.routing.Catalog = "a/2"
|
||||
leader.mu.Unlock()
|
||||
if err := s.exchange(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if act := leader.last(); len(act.Want) != 0 {
|
||||
t.Errorf("an on-demand model reported twice: %+v", act)
|
||||
}
|
||||
if refreshed != 2 {
|
||||
t.Errorf("the changed catalog was not read: %d", refreshed)
|
||||
}
|
||||
|
||||
// Unheard for longer than it is trusted, it routes nothing, and requests
|
||||
// go to the leader again.
|
||||
c.standby.routes = s
|
||||
if !c.standby.answersHere("/v1/chat/completions") {
|
||||
t.Error("a standby with replicas passed the API on")
|
||||
}
|
||||
s.now = func() time.Time { return time.Now().Add(standbyTrust + time.Second) }
|
||||
if _, ok := s.Ready(); ok || c.standby.answersHere("/v1/chat/completions") {
|
||||
t.Error("a standby the leader has not answered for a minute still routes")
|
||||
}
|
||||
}
|
||||
|
||||
// The client API stays on a standby with replicas; the portal, its admin
|
||||
// pages and /mcp still go to the leader.
|
||||
func TestAStandbyAnswersOnlyTheAPIItself(t *testing.T) {
|
||||
leader := &fakeLeaderRouting{routing: control.Routing{
|
||||
Replicas: []control.RoutedReplica{{ID: "llama-r0", Model: "llama", Endpoint: "http://10.0.0.5:20001"}}}}
|
||||
c := newForwardClusterWith(t, leader)
|
||||
s := newStandbyRoutes(c.standby, nil, nil, nil, slog.New(slog.DiscardHandler))
|
||||
if err := s.exchange(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.standby.routes = s
|
||||
|
||||
port := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("leader " + r.URL.Path))
|
||||
}))
|
||||
defer port.Close()
|
||||
_, p, _ := net.SplitHostPort(strings.TrimPrefix(port.URL, "http://"))
|
||||
local := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("standby " + r.URL.Path)) })
|
||||
srv := httptest.NewServer(c.standby.public(local, p))
|
||||
defer srv.Close()
|
||||
|
||||
for path, want := range map[string]string{
|
||||
"/v1/chat/completions": "standby", "/v1/models": "standby",
|
||||
"/admin/replicas": "leader", "/mcp": "leader", "/": "leader",
|
||||
} {
|
||||
resp, err := http.Get(srv.URL + path) //nolint:noctx // a test
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if got, _, _ := strings.Cut(string(b), " "); got != want {
|
||||
t.Errorf("%s answered by %q, want the %s", path, b, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,11 @@ import (
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/facility"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelsvc"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/release"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
|
||||
)
|
||||
@@ -345,10 +347,12 @@ func (m consoleModel) modelMenu(md adminapi.ModelView) *menuDialog {
|
||||
}},
|
||||
{"Context length…", func(m *consoleModel) tea.Cmd {
|
||||
m.openForm(&formDialog{title: "Context of " + md.Name,
|
||||
note: "What one request may use. The engine's arguments and the memory change with it, checked against " +
|
||||
"what the cards hold; its replicas are then started again on it, one by one.",
|
||||
note: "What one request may use, and the longest answer. A changed context changes the engine's arguments " +
|
||||
"and memory, checked against what the cards hold, and starts its replicas again one by one; the " +
|
||||
"longest answer alone changes only the gateway's limit, at once.",
|
||||
fields: []formField{
|
||||
textF("tokens", strconv.Itoa(md.ContextLength), "a request's prompt and answer together"),
|
||||
textF("longest answer", strconv.Itoa(md.MaxOutput), "tokens; empty follows the context"),
|
||||
textF("slots", "", "llama.cpp: requests at once; empty keeps it"),
|
||||
},
|
||||
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
||||
@@ -356,14 +360,25 @@ func (m consoleModel) modelMenu(md adminapi.ModelView) *menuDialog {
|
||||
if err != nil || tokens < 256 {
|
||||
return nil, fmt.Errorf("the context is a whole number of tokens, at least 256")
|
||||
}
|
||||
slots := 0
|
||||
maxOut := 0
|
||||
if v[1] != "" {
|
||||
if slots, err = strconv.Atoi(v[1]); err != nil || slots < 1 {
|
||||
if maxOut, err = strconv.Atoi(v[1]); err != nil || maxOut < 1 {
|
||||
return nil, fmt.Errorf("the longest answer is a whole number of tokens")
|
||||
}
|
||||
}
|
||||
slots := 0
|
||||
if v[2] != "" {
|
||||
if slots, err = strconv.Atoi(v[2]); err != nil || slots < 1 {
|
||||
return nil, fmt.Errorf("slots is a whole number from 1")
|
||||
}
|
||||
}
|
||||
return m.call("Context of "+md.Name, http.MethodPost, path+"/context",
|
||||
modelserve.ContextRequest{Tokens: tokens, Slots: slots}, contextSays), nil
|
||||
req := modelserve.ContextRequest{Tokens: tokens, Slots: slots, MaxOutput: maxOut}
|
||||
if tokens == md.ContextLength && slots == 0 && maxOut > 0 {
|
||||
// The context as it is: only the longest answer, and
|
||||
// no replica started again for it.
|
||||
req.Tokens = 0
|
||||
}
|
||||
return m.call("Context of "+md.Name, http.MethodPost, path+"/context", req, contextSays), nil
|
||||
}})
|
||||
return nil
|
||||
}},
|
||||
@@ -392,6 +407,48 @@ func (m consoleModel) modelMenu(md adminapi.ModelView) *menuDialog {
|
||||
}})
|
||||
return nil
|
||||
}},
|
||||
{"Replicas per profile…", func(m *consoleModel) tea.Cmd {
|
||||
var now []string
|
||||
for _, p := range md.Profiles {
|
||||
now = append(now, fmt.Sprintf("%s=%d", p, md.ProfileReplicas[p]))
|
||||
}
|
||||
m.openForm(&formDialog{title: "Replicas per profile of " + md.Name,
|
||||
note: "How many replicas run on each profile, all answering under the model's name: seven on cards and " +
|
||||
"four on CPUs, the router filling the cards first. A profile not named keeps its count; cpu=N with " +
|
||||
"the memory below adds a profile on CPUs to a GGUF model that has none. Those short start first, " +
|
||||
"then those over stop.",
|
||||
fields: []formField{
|
||||
textF("replicas", strings.Join(now, " "), "profile=count, separated by spaces"),
|
||||
textF("CPU memory GB", "", "only to add a profile on CPUs: what a replica reserves"),
|
||||
textF("CPU cores", "", "of a replica on CPUs; empty is half the node's"),
|
||||
},
|
||||
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
||||
req := adminapi.ProfileReplicasRequest{Profiles: map[string]int{}}
|
||||
for _, f := range strings.Fields(v[0]) {
|
||||
name, c, ok := strings.Cut(f, "=")
|
||||
n, err := strconv.Atoi(c)
|
||||
if !ok || err != nil || n < 0 || name == "" {
|
||||
return nil, fmt.Errorf("%q: a profile and a count, as gpu=7", f)
|
||||
}
|
||||
req.Profiles[name] = n
|
||||
}
|
||||
if v[1] != "" {
|
||||
mem, err := strconv.Atoi(v[1])
|
||||
if err != nil || mem < 1 {
|
||||
return nil, fmt.Errorf("the CPU memory is a whole number of GB")
|
||||
}
|
||||
cores := 0
|
||||
if v[2] != "" {
|
||||
if cores, err = strconv.Atoi(v[2]); err != nil || cores < 1 {
|
||||
return nil, fmt.Errorf("the CPU cores are a whole number from 1")
|
||||
}
|
||||
}
|
||||
req.CPU = &modelsvc.CPUProfile{MemoryGB: mem, CPUCores: cores}
|
||||
}
|
||||
return m.call("Replicas of "+md.Name, http.MethodPost, path+"/replicas", req, nil), nil
|
||||
}})
|
||||
return nil
|
||||
}},
|
||||
{"Resources…", func(m *consoleModel) tea.Cmd {
|
||||
profiles := md.Profiles
|
||||
if len(profiles) == 0 {
|
||||
@@ -554,7 +611,7 @@ func (m consoleModel) nodeMenu(n adminapi.NodeView) *menuDialog {
|
||||
}
|
||||
items = append(items, m.locationItems(n)...)
|
||||
if n.GPUTotal > 0 {
|
||||
for _, engine := range []string{"vllm", "sglang"} {
|
||||
for _, engine := range []string{"vllm", "sglang", "freetoken"} {
|
||||
items = append(items, menuItem{engine + " build…", func(m *consoleModel) tea.Cmd {
|
||||
m.openForm(&formDialog{title: engine + " on " + n.ID,
|
||||
note: "Which build of " + engine + " this node installs: auto follows its hardware, none installs none.",
|
||||
@@ -571,9 +628,15 @@ func (m consoleModel) nodeMenu(n adminapi.NodeView) *menuDialog {
|
||||
menuItem{"Upgrade its agent…", func(m *consoleModel) tea.Cmd {
|
||||
m.openForm(&formDialog{title: "Upgrade " + n.ID,
|
||||
note: "It fetches the release for its platform, checks it, swaps it in and restarts; engines keep serving.",
|
||||
fields: []formField{textF("version", version.Version, "a release, like v0.11.1")},
|
||||
fields: []formField{textF("version", releaseOrEmpty(version.Version), "a release, like v0.11.1")},
|
||||
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
||||
return m.call("Upgrade "+n.ID, http.MethodPost, path+"/upgrade", adminapi.UpgradeRequest{Version: v[0]}, nil), nil
|
||||
if !release.Tag.MatchString(v[0]) {
|
||||
return nil, fmt.Errorf("a release is vMAJOR.MINOR.PATCH, like v0.15.3")
|
||||
}
|
||||
// As long as the control plane waits: the download, the
|
||||
// restart, the node back on the version.
|
||||
return m.callWaiting("Upgrade "+n.ID, http.MethodPost, path+"/upgrade", adminapi.UpgradeRequest{Version: v[0]},
|
||||
func(json.RawMessage) string { return n.ID + " is on " + v[0] + "." }, agentsvc.UpgradeWait+time.Minute), nil
|
||||
}})
|
||||
return nil
|
||||
}},
|
||||
@@ -992,6 +1055,9 @@ func contextSays(raw json.RawMessage) string {
|
||||
if json.Unmarshal(raw, &v) != nil {
|
||||
return "Done."
|
||||
}
|
||||
if v.OutputOnly {
|
||||
return fmt.Sprintf("%s: answers up to %d tokens (was %d); no replica was started again.", v.Model, v.MaxOutput, v.MaxOutputBefore)
|
||||
}
|
||||
s := fmt.Sprintf("%s: %d → %d tokens a request, answers up to %d.", v.Model, v.Before, v.After, v.MaxOutput)
|
||||
for _, p := range v.Profiles {
|
||||
if p.After != "" {
|
||||
@@ -1056,3 +1122,12 @@ func purgeSays(raw json.RawMessage) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// releaseOrEmpty is this build's version when it is a release, else empty:
|
||||
// a development build's is not one a node can fetch.
|
||||
func releaseOrEmpty(v string) string {
|
||||
if release.Tag.MatchString(v) {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1497,6 +1497,10 @@ func (m consoleModel) gpuRows() [][]string {
|
||||
}
|
||||
if !mig {
|
||||
busy = fmt.Sprintf("%.0f%%", s.UtilPercent)
|
||||
if p := s.Profile; p != nil && p.SMActive >= 0 {
|
||||
// How much of the chip, beside whether anything ran.
|
||||
busy += fmt.Sprintf(" · SM %.0f%%", p.SMActive)
|
||||
}
|
||||
if s.MemoryTotalMB > 0 {
|
||||
mem = fmt.Sprintf("%.1f/%.0f GB", s.MemoryUsedMB/1024, s.MemoryTotalMB/1024)
|
||||
}
|
||||
|
||||
+24
-4
@@ -107,7 +107,15 @@ func optionLabel(o modelplan.Option) string {
|
||||
if o.FreeNow {
|
||||
now = sOK.Render("free")
|
||||
}
|
||||
return fmt.Sprintf("%s · %s · %s", o.Label(), o.MemoryText(), now)
|
||||
label := fmt.Sprintf("%s · %s · %s", o.Label(), o.MemoryText(), now)
|
||||
if c := speedCell(o.TokensPerSecond); c != "" {
|
||||
style := sDim
|
||||
if o.TokensPerSecond < modelplan.CPUTooSlowTPS {
|
||||
style = sWarn
|
||||
}
|
||||
label += " · " + style.Render(c)
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
// repoMenu offers a repository's files, each with the best way it runs here;
|
||||
@@ -196,6 +204,9 @@ func (m *consoleModel) serveForm(p modelserve.Plan, a modelserve.PlannedArtifact
|
||||
note += fmt.Sprintf("\nMemory for a %d-token context, each way it runs:", p.Context)
|
||||
for _, o := range opts {
|
||||
note += "\n " + o.Label() + ": " + o.MemoryText()
|
||||
if w := slowWarning(o); w != "" && o.CPU {
|
||||
note += "\n " + sWarn.Render(w)
|
||||
}
|
||||
}
|
||||
if p.KVBytesPerToken > 0 {
|
||||
note += fmt.Sprintf("\nThe context costs %.2f GB per 1000 tokens, the whole model; a longer one is checked\n"+
|
||||
@@ -206,6 +217,9 @@ func (m *consoleModel) serveForm(p modelserve.Plan, a modelserve.PlannedArtifact
|
||||
if a.Format == "safetensors" {
|
||||
ctxDefault = strconv.Itoa(p.Context)
|
||||
}
|
||||
if len(opts) > 0 && opts[0].Engine == "strata" {
|
||||
ctxDefault = "32768"
|
||||
}
|
||||
fields := []formField{
|
||||
textF("name", hf.ModelName(p.Repo), "what clients ask for"),
|
||||
textF("replicas", "1", "0 runs none until it is first asked for"),
|
||||
@@ -241,12 +255,18 @@ func (m *consoleModel) serveForm(p modelserve.Plan, a modelserve.PlannedArtifact
|
||||
if sized := o.WithContext(p.KVBytesPerToken, ctxLen); !sized.Fits() {
|
||||
return nil, fmt.Errorf("with a %d-token context it needs %s: a shorter context, or another way", ctxLen, sized.MemoryText())
|
||||
}
|
||||
if o.CPU {
|
||||
switch {
|
||||
case o.Engine == "strata":
|
||||
// One card, the experts in RAM; Strata reads the pictures
|
||||
// with a reader of its own, not this projector.
|
||||
req.Engine, req.GPUClass, proj = "strata", o.Class, -1
|
||||
case o.CPU:
|
||||
req.CPU = true
|
||||
} else {
|
||||
default:
|
||||
req.Engine = "llamacpp"
|
||||
req.GPUClass, req.GPUsPerNode, req.Nodes = o.Class, o.GPUsPerNode, max(o.Nodes, 1)
|
||||
}
|
||||
req.Fallbacks = strings.HasPrefix(v[5], "the next") && len(opts) > 1
|
||||
req.Fallbacks = strings.HasPrefix(v[5], "the next") && len(opts) > 1 && o.Engine != "strata"
|
||||
if proj >= 0 && !strings.HasPrefix(v[proj], "none") {
|
||||
req.Projector = v[proj]
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/promq"
|
||||
)
|
||||
|
||||
@@ -80,6 +81,9 @@ func (s *monitorState) sample(d consoleData) {
|
||||
for _, g := range l.GPUs {
|
||||
card := nd.ID + ":" + strconv.Itoa(g.Index)
|
||||
add("gpu/"+card, g.UtilPercent)
|
||||
if w := gpuWork(g); w >= 0 {
|
||||
add("gpuwork/"+card, w)
|
||||
}
|
||||
if g.MemoryTotalMB > 0 {
|
||||
add("vram/"+card, 100*g.MemoryUsedMB/g.MemoryTotalMB)
|
||||
}
|
||||
@@ -142,6 +146,7 @@ func monitorQueries(window time.Duration, points int) (time.Duration, []adminapi
|
||||
{ID: "cpu", Expr: `avg(llmgw_node_cpu_utilization_percent)`},
|
||||
{ID: "mem", Expr: `100 * sum(llmgw_node_memory_used_bytes) / sum(llmgw_node_memory_bytes)`},
|
||||
{ID: "gpu", Expr: card(`llmgw_gpu_utilization_percent`), By: "card"},
|
||||
{ID: "gpuwork", Expr: card(gpuWorkExpr("")), By: "card"},
|
||||
{ID: "vram", Expr: card(`100 * llmgw_gpu_memory_used_bytes / llmgw_gpu_memory_total_bytes`), By: "card"},
|
||||
{ID: "gputemp", Expr: card(`llmgw_gpu_temperature_celsius`), By: "card"},
|
||||
{ID: "gpupow", Expr: card(`llmgw_gpu_power_watts`), By: "card"},
|
||||
@@ -314,7 +319,8 @@ func (m consoleModel) monitorPanel(pw int) (string, string, string) {
|
||||
b.WriteString(indent(side(w, box("CPU", pct("cpu"), m.series("cpu"), 100),
|
||||
box("Memory", pct("mem"), m.series("mem"), 100)), " ") + "\n\n")
|
||||
if g := m.series("gpu"); len(g) > 0 || len(m.data.sum.GPUs) > 0 {
|
||||
b.WriteString(indent(side(w, box("GPU busy", "", g, 100), box("GPU memory", "", m.series("vram"), 100)), " ") + "\n\n")
|
||||
b.WriteString(indent(side(w, box("GPU busy", "", g, 100), box("GPU work", "", m.series("gpuwork"), 100)), " ") + "\n\n")
|
||||
b.WriteString(indent(side(w, box("GPU memory", "", m.series("vram"), 100), " "+sDim.Render(gpuWorkNote)), " ") + "\n\n")
|
||||
}
|
||||
if m.mon.res.Configured {
|
||||
rps := m.series("rps")
|
||||
@@ -356,10 +362,12 @@ func (m consoleModel) monitorPanel(pw int) (string, string, string) {
|
||||
// Each card: busy, memory, heat and power.
|
||||
if gpus := m.series("gpu"); len(gpus) > 0 {
|
||||
vram, temp, pow := byName(m.series("vram")), byName(m.series("gputemp")), byName(m.series("gpupow"))
|
||||
work := byName(m.series("gpuwork"))
|
||||
b.WriteString("\n " + mdBold.Render("GPUs") + "\n")
|
||||
for _, g := range gpus {
|
||||
fmt.Fprintf(&b, " %-16s busy %s %4.0f%% memory %s %4.0f%% %3.0f°C %s %4.0f W\n", truncate(g.name, 16),
|
||||
sparkSeries(g, sw/2, 100), last(g.values), sparkSeries(vram[g.name], sw/2, 100), last(vram[g.name].values),
|
||||
fmt.Fprintf(&b, " %-16s busy %s %4.0f%% work %s %4.0f%% memory %s %4.0f%% %3.0f°C %s %4.0f W\n", truncate(g.name, 16),
|
||||
sparkSeries(g, sw/3, 100), last(g.values), sparkSeries(work[g.name], sw/3, 100), last(work[g.name].values),
|
||||
sparkSeries(vram[g.name], sw/3, 100), last(vram[g.name].values),
|
||||
last(temp[g.name].values), sparkSeries(temp[g.name], 8, 100), last(pow[g.name].values))
|
||||
}
|
||||
}
|
||||
@@ -373,6 +381,29 @@ func (m consoleModel) monitorPanel(pw int) (string, string, string) {
|
||||
return "Monitor", head.String() + b.String(), keys
|
||||
}
|
||||
|
||||
// GPU busy is the driver's figure: the share of time any kernel ran, which
|
||||
// a kernel on one SM of a hundred fills. GPU work is how much of the chip:
|
||||
// the SMs' share of time with work where the card's profiling counters are
|
||||
// read (GPM, DCGM), and its draw against its power limit elsewhere.
|
||||
const gpuWorkNote = "busy: some kernel ran. work: how much of the chip —\nSMs active where the counters are read, power/limit elsewhere."
|
||||
|
||||
// gpuWorkExpr is GPU work in PromQL, for the series matching sel.
|
||||
func gpuWorkExpr(sel string) string {
|
||||
return `llmgw_gpu_sm_active_percent` + sel + ` or (100 * llmgw_gpu_power_watts` + sel +
|
||||
` / llmgw_gpu_power_limit_watts` + sel + `)`
|
||||
}
|
||||
|
||||
// gpuWork is GPU work from a sample, or -1 without the figures for it.
|
||||
func gpuWork(g inventory.GPUSensor) float64 {
|
||||
if p := g.Profile; p != nil && p.SMActive >= 0 {
|
||||
return p.SMActive
|
||||
}
|
||||
if l := g.PowerLoad(); l >= 0 {
|
||||
return 100 * l
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func named(s []chartSeries, name string) []chartSeries {
|
||||
for i := range s {
|
||||
s[i].name = name
|
||||
@@ -410,6 +441,7 @@ func detailQueries(kind, id string, window time.Duration, points int) (time.Dura
|
||||
{ID: "cpu", Expr: `llmgw_node_cpu_utilization_percent{node=` + q + `}`},
|
||||
{ID: "mem", Expr: `100 * llmgw_node_memory_used_bytes{node=` + q + `} / llmgw_node_memory_bytes{node=` + q + `}`},
|
||||
{ID: "gpu", Expr: card(`llmgw_gpu_utilization_percent{node=` + q + `}`), By: "card"},
|
||||
{ID: "gpuwork", Expr: card(gpuWorkExpr(`{node=` + q + `}`)), By: "card"},
|
||||
{ID: "vram", Expr: card(`100 * llmgw_gpu_memory_used_bytes{node=` + q + `} / llmgw_gpu_memory_total_bytes{node=` + q + `}`), By: "card"},
|
||||
{ID: "netin", Expr: `sum(rate(node_network_receive_bytes_total{` + netDevs + `,node=` + q + `}[` + rw + `]))`},
|
||||
{ID: "netout", Expr: `sum(rate(node_network_transmit_bytes_total{` + netDevs + `,node=` + q + `}[` + rw + `]))`},
|
||||
@@ -520,7 +552,8 @@ func (m consoleModel) detailCharts(pw int) string {
|
||||
b.WriteString(indent(side(w, box("CPU", fmt.Sprintf("%.0f%%", lastOf(cpu)), cpu, 100),
|
||||
box("Memory", fmt.Sprintf("%.0f%%", lastOf(mem)), mem, 100)), " "))
|
||||
if g := get("gpu"); len(g) > 0 {
|
||||
b.WriteString("\n\n" + indent(side(w, box("GPU busy", "", g, 100), box("GPU memory", "", get("vram"), 100)), " "))
|
||||
b.WriteString("\n\n" + indent(side(w, box("GPU busy", "", g, 100), box("GPU work", "", get("gpuwork"), 100)), " "))
|
||||
b.WriteString("\n\n" + indent(side(w, box("GPU memory", "", get("vram"), 100), " "+sDim.Render(gpuWorkNote)), " "))
|
||||
}
|
||||
if m.det.res.Configured {
|
||||
net := append(named(get("netin"), "in"), named(get("netout"), "out")...)
|
||||
|
||||
@@ -641,6 +641,19 @@ type Router struct {
|
||||
// written into the setup scripts and whose attempts are counted where.
|
||||
TrustedProxies []string `koanf:"trusted_proxies"`
|
||||
|
||||
// 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.
|
||||
ActiveActive bool `koanf:"active_active"`
|
||||
|
||||
Timeouts Timeouts `koanf:"timeouts"`
|
||||
Scoring Scoring `koanf:"scoring"`
|
||||
Admission Admission `koanf:"admission"`
|
||||
@@ -1069,12 +1082,18 @@ func (s Server) ControlPlaneMode() bool {
|
||||
// DefaultEngines are the engine versions this release was checked against:
|
||||
// every flag llmgw builds for them is one they take.
|
||||
//
|
||||
// vLLM is installed on every node with an NVIDIA GPU it can run on and
|
||||
// skipped elsewhere. SGLang is not here: it is opt-in, by naming a version
|
||||
// under engines.
|
||||
// vLLM and FreeToken are installed on every node with an NVIDIA GPU they
|
||||
// can run on and skipped elsewhere — FreeToken on cards of 8.0 and newer
|
||||
// with the CUDA 13 toolkit — so a node that can serve a model too large for
|
||||
// its cards has the engine for it before anyone asks. SGLang is not here:
|
||||
// it is opt-in, by naming a version under engines.
|
||||
//
|
||||
// FreeToken is followed release by release: a new one on PyPI is moved to
|
||||
// here once ft serve has been checked to take the flags llmgw gives it.
|
||||
var DefaultEngines = map[string]string{
|
||||
"llamacpp": "b10991",
|
||||
"vllm": "0.30.0",
|
||||
"llamacpp": "b10991",
|
||||
"vllm": "0.30.0",
|
||||
"freetoken": "0.2.1",
|
||||
}
|
||||
|
||||
// EngineOverride gives the nodes it picks different engines. Every selector
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelsvc"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/gguf"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/modelplan"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/release"
|
||||
@@ -130,6 +131,7 @@ func (a *API) Register(mux Mux) {
|
||||
mux.HandleFunc("POST /api/v1/models/{name}/start", a.startModel)
|
||||
mux.HandleFunc("POST /api/v1/models/{name}/scale", a.scaleModel)
|
||||
mux.HandleFunc("POST /api/v1/models/{name}/resources", a.setResources)
|
||||
mux.HandleFunc("POST /api/v1/models/{name}/replicas", a.setProfileReplicas)
|
||||
a.registerQuotas(mux)
|
||||
a.registerKB(mux)
|
||||
a.registerTenancy(mux)
|
||||
@@ -182,6 +184,7 @@ func (a *API) Register(mux Mux) {
|
||||
a.registerPresets(mux)
|
||||
mux.HandleFunc("GET /api/v1/models", a.listModels)
|
||||
mux.HandleFunc("GET /api/v1/replicas", a.listReplicas)
|
||||
a.registerRouting(mux)
|
||||
// Listing is a read and deleting is a separate verb, so a mistyped
|
||||
// command cannot remove anything.
|
||||
mux.HandleFunc("GET /api/v1/weights/orphans", a.listOrphans)
|
||||
@@ -258,6 +261,9 @@ type NodeView struct {
|
||||
// MemBandwidthGBs is how fast its memory reads, in GB/s, measured when
|
||||
// its agent started; 0 when not reported. A model's speed on the CPU.
|
||||
MemBandwidthGBs float64 `json:"mem_bandwidth_gbs,omitempty"`
|
||||
// MemBandwidthCoreGBs is what one core reads alone, in GB/s; 0 when not
|
||||
// reported. A CPU replica's threads read at about this each.
|
||||
MemBandwidthCoreGBs float64 `json:"mem_bandwidth_core_gbs,omitempty"`
|
||||
// Engines are the inference engines installed here, as the agent found
|
||||
// them. Absent means the agent is older than the probe and said nothing,
|
||||
// which is not the same as having none — placement treats it that way too,
|
||||
@@ -488,6 +494,9 @@ type ModelView struct {
|
||||
Unplaceable bool `json:"unplaceable,omitempty"`
|
||||
Fallbacks []string `json:"fallbacks,omitempty"`
|
||||
Profiles []string `json:"profiles"`
|
||||
// ProfileReplicas is how many replicas run on each profile, for a model
|
||||
// whose replicas are counted per profile; absent otherwise.
|
||||
ProfileReplicas map[string]int `json:"profile_replicas,omitempty"`
|
||||
// MemoryGB and CPUCores are what one replica reserves, from its first
|
||||
// profile; MeasuredMemoryGB is the most one of its replicas uses now, 0
|
||||
// when none is measured.
|
||||
@@ -608,6 +617,9 @@ type FetchView struct {
|
||||
// invented percentage that stops moving is worse than a byte count that
|
||||
// keeps rising.
|
||||
Percent *int `json:"percent,omitempty"`
|
||||
// Phase is empty while the bytes arrive, "checking" while a set's files
|
||||
// are checked against the mirror's checksums.
|
||||
Phase string `json:"phase,omitempty"`
|
||||
}
|
||||
|
||||
// ExplainView is why a model could not be placed.
|
||||
@@ -719,7 +731,7 @@ func (a *API) Nodes() []NodeView {
|
||||
v.EngineBuilds[e] = b
|
||||
}
|
||||
}
|
||||
v.MemBandwidthGBs = n.Host.MemBandwidthGBs
|
||||
v.MemBandwidthGBs, v.MemBandwidthCoreGBs = n.Host.MemBandwidthGBs, n.Host.MemBandwidthCoreGBs
|
||||
for _, g := range n.GPUs {
|
||||
if g.MIG != nil {
|
||||
continue // said of the card it is carved from
|
||||
@@ -861,6 +873,12 @@ func (a *API) Models() []ModelView {
|
||||
Canary: m.Canary, CanaryOf: a.ctl.Catalog().CanaryOf(m.Name), Eval: m.Eval,
|
||||
Role: m.Role,
|
||||
}
|
||||
if m.Split() {
|
||||
v.ProfileReplicas = map[string]int{}
|
||||
for _, p := range m.Profiles {
|
||||
v.ProfileReplicas[p.Name] = p.Replicas
|
||||
}
|
||||
}
|
||||
for _, p := range m.Profiles {
|
||||
v.Profiles = append(v.Profiles, p.Name)
|
||||
}
|
||||
@@ -922,7 +940,7 @@ func (a *API) Replicas(model string) []ReplicaView {
|
||||
}
|
||||
if f := s.Fetch; f != nil {
|
||||
sv.Fetch = &FetchView{
|
||||
Source: f.Source, BytesDone: f.BytesDone, BytesTotal: f.BytesTotal,
|
||||
Source: f.Source, BytesDone: f.BytesDone, BytesTotal: f.BytesTotal, Phase: f.Phase,
|
||||
}
|
||||
if p, ok := f.Percent(); ok {
|
||||
sv.Fetch.Percent = &p
|
||||
@@ -958,6 +976,15 @@ type Models interface {
|
||||
SetClass(ctx context.Context, name, class, by string) (catalog.Model, error)
|
||||
SetOwner(ctx context.Context, name, owner, by string) (catalog.Model, error)
|
||||
SetAdapters(ctx context.Context, name string, adapters []catalog.Adapter, by string) (catalog.Model, error)
|
||||
SetProfileReplicas(ctx context.Context, name string, counts map[string]int, cpu *modelsvc.CPUProfile, by string) (catalog.Model, error)
|
||||
}
|
||||
|
||||
// ProfileReplicasRequest counts a model's replicas per profile: profile
|
||||
// name to replicas, and CPU, for a llama.cpp model with no profile on CPUs,
|
||||
// the one to add.
|
||||
type ProfileReplicasRequest struct {
|
||||
Profiles map[string]int `json:"profiles"`
|
||||
CPU *modelsvc.CPUProfile `json:"cpu,omitempty"`
|
||||
}
|
||||
|
||||
// ResourcesRequest sets what a model's profiles reserve of a node.
|
||||
@@ -1284,6 +1311,25 @@ func (a *API) setResources(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, m)
|
||||
}
|
||||
|
||||
// setProfileReplicas counts a model's replicas per profile.
|
||||
func (a *API) setProfileReplicas(w http.ResponseWriter, r *http.Request) {
|
||||
if a.models == nil {
|
||||
writeError(w, http.StatusNotImplemented, "this control plane has no store")
|
||||
return
|
||||
}
|
||||
var req ProfileReplicasRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
m, err := a.models.SetProfileReplicas(r.Context(), r.PathValue("name"), req.Profiles, req.CPU, actorOf(r))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, m)
|
||||
}
|
||||
|
||||
func (a *API) removeModel(w http.ResponseWriter, r *http.Request) {
|
||||
if a.models == nil {
|
||||
writeError(w, http.StatusNotImplemented, "this control plane has no store")
|
||||
@@ -1939,7 +1985,7 @@ func PoolNodesOf(nodes []NodeView) []modelplan.PoolNode {
|
||||
continue
|
||||
}
|
||||
p := modelplan.PoolNode{ID: n.ID, GPUs: n.GPUTotal, FreeGPUs: n.GPUFree, HostMemGB: n.MemoryGB,
|
||||
HostFreeGB: n.MemoryGB}
|
||||
HostFreeGB: n.MemoryGB, Engines: n.Engines}
|
||||
if len(n.Classes) > 0 {
|
||||
p.Class = n.Classes[0]
|
||||
p.GPUMemGB = modelplan.MemFromClass(p.Class)
|
||||
@@ -1954,9 +2000,23 @@ func PoolNodesOf(nodes []NodeView) []modelplan.PoolNode {
|
||||
if len(n.Fabrics) > 0 {
|
||||
p.Fabric = n.Fabrics[0]
|
||||
}
|
||||
for _, c := range n.ComputeCapabilities {
|
||||
if v, err := strconv.ParseFloat(c, 64); err == nil && v > p.CC {
|
||||
p.CC = v
|
||||
}
|
||||
}
|
||||
if l := n.Load; l != nil && l.MemTotal > 0 {
|
||||
p.HostFreeGB = int((l.MemTotal - l.MemUsed) >> 30)
|
||||
}
|
||||
p.CPUs, p.MemBWGBs, p.MemBWCoreGBs = n.CPUCount, n.MemBandwidthGBs, n.MemBandwidthCoreGBs
|
||||
for _, g := range n.GPUs {
|
||||
if bw := modelplan.GPUBandwidthGBs(g.Name); bw > 0 {
|
||||
if p.CardBWGBs == nil {
|
||||
p.CardBWGBs = map[string]float64{}
|
||||
}
|
||||
p.CardBWGBs[g.Class] = bw
|
||||
}
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelsvc"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/router/endpoint"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
@@ -263,6 +264,16 @@ func (f *fakeModels) SetAdapters(_ context.Context, name string, as []catalog.Ad
|
||||
return catalog.Model{Name: name, Adapters: as}, f.err
|
||||
}
|
||||
|
||||
func (f *fakeModels) SetProfileReplicas(_ context.Context, name string, counts map[string]int, _ *modelsvc.CPUProfile,
|
||||
_ string,
|
||||
) (catalog.Model, error) {
|
||||
m := catalog.Model{Name: name}
|
||||
for p, n := range counts {
|
||||
m.Profiles = append(m.Profiles, catalog.Profile{Name: p, Replicas: n})
|
||||
}
|
||||
return m, f.err
|
||||
}
|
||||
|
||||
func (f *fakeModels) SetOwner(_ context.Context, name, owner, _ string) (catalog.Model, error) {
|
||||
return catalog.Model{Name: name, Owner: owner}, f.err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -48,10 +50,29 @@ type PurgeView struct {
|
||||
// purgeModel removes a model from the catalog and its files from the mirror
|
||||
// and the nodes.
|
||||
func (a *API) purgeModel(w http.ResponseWriter, r *http.Request, name string) {
|
||||
out, err := a.Purge(r.Context(), name, actorOf(r))
|
||||
switch {
|
||||
case errors.Is(err, errNoSuchModel):
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
case err != nil:
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// errNoSuchModel is a model the catalog does not have.
|
||||
var errNoSuchModel = errors.New("no such model in the catalog")
|
||||
|
||||
// Purge removes a model from the catalog and its files from the mirror and
|
||||
// the nodes, waiting a while for its replicas to stop before the nodes'.
|
||||
func (a *API) Purge(ctx context.Context, name, by string) (PurgeView, error) {
|
||||
if a.models == nil {
|
||||
return PurgeView{}, errors.New("this control plane has no store")
|
||||
}
|
||||
m, ok := a.ctl.Catalog().Resolve(name)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "no model "+name+" in the catalog")
|
||||
return
|
||||
return PurgeView{}, fmt.Errorf("%w: %s", errNoSuchModel, name)
|
||||
}
|
||||
name = m.Name
|
||||
others := []catalog.Model{}
|
||||
@@ -60,37 +81,35 @@ func (a *API) purgeModel(w http.ResponseWriter, r *http.Request, name string) {
|
||||
others = append(others, o)
|
||||
}
|
||||
}
|
||||
if err := a.models.Remove(r.Context(), name); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
if err := a.models.Remove(ctx, name); err != nil {
|
||||
return PurgeView{}, err
|
||||
}
|
||||
a.presetDeclined(r.Context(), name, actorOf(r))
|
||||
a.presetDeclined(ctx, name, by)
|
||||
out := PurgeView{Removed: name}
|
||||
|
||||
sources := purgeSources(m)
|
||||
if a.purgeMirror == nil {
|
||||
out.Note = "this control plane has no mirror; its files there, if any, are left"
|
||||
} else {
|
||||
out.Mirror, out.Kept, out.Note = purgeFromMirror(r.Context(), a.purgeMirror, sources, others)
|
||||
out.Mirror, out.Kept, out.Note = purgeFromMirror(ctx, a.purgeMirror, sources, others)
|
||||
}
|
||||
|
||||
// The nodes' copies, once nothing runs on them.
|
||||
deadline := time.Now().Add(purgeWait)
|
||||
for a.runs(name) && time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return out, ctx.Err()
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
if a.runs(name) {
|
||||
out.Note = strings.TrimSpace(out.Note + " Its replicas had not stopped yet: its copies on the nodes are " +
|
||||
"left, and llmgw ctl weights --collect deletes them.")
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return
|
||||
return out, nil
|
||||
}
|
||||
out.Nodes = a.purgeFromNodes(r.Context(), sources, others)
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
out.Nodes = a.purgeFromNodes(ctx, sources, others)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// runs says whether any replica of a model is still on a node.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
llmgw "gitea.mynodes.xyz/ilker/llmgw"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/bench"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/fabrics"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/federation"
|
||||
@@ -167,6 +168,10 @@ var Routes = []Route{
|
||||
Response: ScaleResponse{}},
|
||||
{Method: "POST", Path: "/api/v1/models/{name}/scale", Summary: "Set how many replicas a model runs",
|
||||
Request: ScaleRequest{}, Response: ScaleResponse{}},
|
||||
{Method: "POST", Path: "/api/v1/models/{name}/replicas", Summary: "How many replicas run on each profile",
|
||||
Description: "Seven on cards and four on CPUs, under the model's name. A profile not named keeps its count; " +
|
||||
"cpu adds a profile on CPUs to a llama.cpp model that has none. Its scaling becomes the sum.",
|
||||
Request: ProfileReplicasRequest{}, Response: catalog.Model{}},
|
||||
{Method: "POST", Path: "/api/v1/models/{name}/resources", Summary: "Set what a model's profiles reserve of a node",
|
||||
Request: ResourcesRequest{}, Response: catalog.Model{}},
|
||||
{Method: "POST", Path: "/api/v1/models/{name}/class", Summary: "Set a model's capacity class",
|
||||
@@ -194,6 +199,15 @@ var Routes = []Route{
|
||||
{Method: "GET", Path: "/api/v1/replicas", Summary: "Every replica, with its shards",
|
||||
Query: []Param{{Name: "model", Description: "Only this model's replicas."}},
|
||||
Response: []ReplicaView{}},
|
||||
{Method: "GET", Path: "/api/v1/routing", Summary: "The Ready replicas a standby routes to",
|
||||
Description: "Read by a standby control plane when router.active_active is on: each replica's address, " +
|
||||
"model, profile, pool and weight, without its key, and which catalog is in force.",
|
||||
Response: control.Routing{}},
|
||||
{Method: "POST", Path: "/api/v1/routing/activity", Summary: "A standby's router reports what it used",
|
||||
Description: "When it last sent each replica a request, and the on-demand models it was asked for, " +
|
||||
"so the leader does not evict a replica busy behind a standby. Answered with the routing, " +
|
||||
"so one request a tick does both.",
|
||||
Request: control.Activity{}, Response: control.Routing{}},
|
||||
{Method: "GET", Path: "/api/v1/explain-placement", Summary: "Why a model could not be placed",
|
||||
Query: []Param{{Name: "model", Required: true, Description: "The model's name or an alias."}},
|
||||
Response: ExplainView{}},
|
||||
@@ -221,7 +235,7 @@ var Routes = []Route{
|
||||
{Method: "POST", Path: "/api/v1/models/{name}/update", Summary: "Move a model to its repository's newer weights",
|
||||
Description: "With a quality suite, or the model's standing gate, the new weights are asked it first as a hidden canary.",
|
||||
Request: modelserve.Change{}, OptionalBody: true, Response: modelserve.Job{}, Status: http.StatusAccepted},
|
||||
{Method: "POST", Path: "/api/v1/models/{name}/context", Summary: "Change a served model's context: the catalog, its engines' arguments and memory, then its replicas restarted",
|
||||
{Method: "POST", Path: "/api/v1/models/{name}/context", Summary: "Change a served model's context and longest answer: the catalog, its engines' arguments and memory, then its replicas restarted; max_output alone changes only the catalog and restarts nothing",
|
||||
Request: modelserve.ContextRequest{}, Response: modelserve.ContextChange{}},
|
||||
{Method: "POST", Path: "/api/v1/models/{name}/gpus", Summary: "Spread a served model over more cards or fewer: its profile, checked against its KV heads, its cards' memory and the nodes, then its replicas restarted",
|
||||
Description: "Tensor parallel for vLLM, SGLang and TensorRT-LLM, a layer split for llama.cpp, on one node. With no room beside the running replicas, in_place stops each first.",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control"
|
||||
)
|
||||
|
||||
// registerRouting is what a standby answering requests itself reads from the
|
||||
// leader, and reports back to it. Reached by the standby through the agent
|
||||
// port with its control-plane certificate, like the rest of the forwarded API.
|
||||
func (a *API) registerRouting(mux Mux) {
|
||||
mux.HandleFunc("GET /api/v1/routing", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, a.ctl.Routing())
|
||||
})
|
||||
mux.HandleFunc("POST /api/v1/routing/activity", func(w http.ResponseWriter, r *http.Request) {
|
||||
var act control.Activity
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&act); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
a.ctl.RecordActivity(act)
|
||||
writeJSON(w, http.StatusOK, a.ctl.RoutingFor(act.From))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
|
||||
)
|
||||
|
||||
// What the portal changes of a served model beside what the catalog holds:
|
||||
// the same as the API's routes, called in the process.
|
||||
|
||||
// SetModelContext changes a served model's context and longest answer.
|
||||
func (a *API) SetModelContext(ctx context.Context, name string, req modelserve.ContextRequest, by string) (modelserve.ContextChange, error) {
|
||||
if a.serve == nil {
|
||||
return modelserve.ContextChange{}, errors.New("this control plane cannot change a model's context")
|
||||
}
|
||||
return a.serve.SetContext(ctx, name, req, by)
|
||||
}
|
||||
|
||||
// SetModelGPUs spreads a served model over more cards a replica, or fewer.
|
||||
func (a *API) SetModelGPUs(ctx context.Context, name string, req modelserve.GPUsRequest, by string) (modelserve.GPUsChange, error) {
|
||||
if a.serve == nil {
|
||||
return modelserve.GPUsChange{}, errors.New("this control plane cannot change a model's cards")
|
||||
}
|
||||
return a.serve.SetGPUs(ctx, name, req, by)
|
||||
}
|
||||
|
||||
// RestartModel starts each of a model's ready replicas again, one at a time,
|
||||
// and says how many.
|
||||
func (a *API) RestartModel(name string) (int, error) {
|
||||
var ids []string
|
||||
for _, rep := range a.ctl.Reconciler().ReadyReplicas() {
|
||||
if rep.Model == name {
|
||||
ids = append(ids, rep.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return 0, errors.New("none of its replicas is ready to restart")
|
||||
}
|
||||
return len(ids), a.ctl.Reconciler().RestartPaced(reconciler.RestartPace{}, false, ids...)
|
||||
}
|
||||
|
||||
// RemoveModel takes a model out of the catalog, with its files on the mirror
|
||||
// and the nodes when purge.
|
||||
func (a *API) RemoveModel(ctx context.Context, name string, purge bool, by string) (PurgeView, error) {
|
||||
if purge {
|
||||
return a.Purge(ctx, name, by)
|
||||
}
|
||||
if a.models == nil {
|
||||
return PurgeView{}, errors.New("this control plane has no store")
|
||||
}
|
||||
if err := a.models.Remove(ctx, name); err != nil {
|
||||
return PurgeView{}, err
|
||||
}
|
||||
a.presetDeclined(ctx, name, by)
|
||||
return PurgeView{Removed: name}, nil
|
||||
}
|
||||
@@ -36,7 +36,7 @@ func NodeFromRegister(r *llmgwv1.Register) inventory.Node {
|
||||
n.Host = discovery.Host{
|
||||
OSID: h.GetOsId(), OSVersion: h.GetOsVersion(), OSCodename: h.GetOsCodename(),
|
||||
Arch: h.GetArch(), CPUFlags: h.GetCpuFlags(), NVIDIADriver: h.GetNvidiaDriver(),
|
||||
MemBandwidthGBs: h.GetMemBandwidthGbs(),
|
||||
MemBandwidthGBs: h.GetMemBandwidthGbs(), MemBandwidthCoreGBs: h.GetMemBandwidthCoreGbs(),
|
||||
}
|
||||
}
|
||||
for _, w := range r.GetWeights() {
|
||||
@@ -236,6 +236,7 @@ func StartShardFromCommand(cmd reconciler.ShardCommand) *llmgwv1.StartShard {
|
||||
Adapters: adaptersToWire(p.Adapters),
|
||||
KvEvents: p.KVEvents,
|
||||
AllowPickle: p.AllowPickle,
|
||||
ModelSha256: p.ModelSHA256,
|
||||
},
|
||||
StartupTimeoutMs: cmd.StartupTimeout.Milliseconds(),
|
||||
Pool: cmd.Pool,
|
||||
@@ -274,6 +275,7 @@ func ShardSpecFromStart(m *llmgwv1.StartShard) (engines.ShardSpec, time.Duration
|
||||
Adapters: adaptersFromWire(p.GetAdapters()),
|
||||
KVEvents: p.GetKvEvents(),
|
||||
AllowPickle: p.GetAllowPickle(),
|
||||
ModelSHA256: p.GetModelSha256(),
|
||||
},
|
||||
}
|
||||
return spec, time.Duration(m.GetStartupTimeoutMs()) * time.Millisecond
|
||||
|
||||
@@ -3,6 +3,7 @@ package agentsvc
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
@@ -284,6 +285,28 @@ func TestAnUpgradeWaitsForTheAgentsAnswer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The agent's answer lost — it exits a moment after sending it — and the node
|
||||
// back on the version: the upgrade is done, not five minutes of waiting and a
|
||||
// failure blaming an old agent.
|
||||
func TestAnUpgradeIsDoneWhenTheNodeComesBackOnTheVersion(t *testing.T) {
|
||||
s := newTestService()
|
||||
nodeWith(s, inventory.Node{ID: "n1", State: inventory.Ready, AgentVersion: "v1.2.2"})
|
||||
c := &conn{nodeID: "n1", send: make(chan *llmgwv1.ControlMessage, 4), closed: make(chan struct{})}
|
||||
s.conns["n1"] = c
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- s.Upgrade(t.Context(), "n1", "v1.2.3", "https://r") }()
|
||||
<-c.send
|
||||
nodeWith(s, inventory.Node{ID: "n1", State: inventory.Ready, AgentVersion: "v1.2.3"})
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("still waiting for an answer the node will never send")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnotherTenantOnACardIsJudgedEveryHeartbeat(t *testing.T) {
|
||||
// Found on cuda01: ComfyUI held 5 GB when the agent started, so the card
|
||||
// was kept out of placement. ComfyUI let go of it and the card stayed
|
||||
|
||||
@@ -35,7 +35,7 @@ type StatusSink interface {
|
||||
// OnFetchProgress is separate because it is not a transition: the shard is
|
||||
// still starting, and treating each report as a state change would reset
|
||||
// the timers that decide when a slow start has become a failed one.
|
||||
OnFetchProgress(replicaID, shardID, source string, done, total int64)
|
||||
OnFetchProgress(replicaID, shardID, source, phase string, done, total int64)
|
||||
// OnShardCompute records where a shard's engine said it runs the model.
|
||||
OnShardCompute(replicaID, shardID string, c reconciler.Compute)
|
||||
Adopt(nodeID string, shards []reconciler.AdoptedShard) (unclaimed []reconciler.AdoptedShard)
|
||||
@@ -461,7 +461,7 @@ func (s *Service) handle(nodeID string, msg *llmgwv1.AgentMessage) {
|
||||
// download that takes an hour would keep a start timer at zero.
|
||||
if f := m.Shard.GetFetch(); f != nil {
|
||||
s.status.OnFetchProgress(m.Shard.GetReplicaId(), m.Shard.GetShardId(),
|
||||
f.GetSource(), f.GetBytesDone(), f.GetBytesTotal())
|
||||
f.GetSource(), f.GetPhase(), f.GetBytesDone(), f.GetBytesTotal())
|
||||
return
|
||||
}
|
||||
if c := m.Shard.GetCompute(); c != nil {
|
||||
@@ -547,7 +547,7 @@ func (s *Service) applyHeartbeat(nodeID string, hb *llmgwv1.Heartbeat) {
|
||||
storage := hb.GetStorage()
|
||||
memMB := int(hb.GetMemoryTotalMb())
|
||||
cpus := int(hb.GetCpuCount())
|
||||
bw := hb.GetMemBandwidthGbs()
|
||||
bw, coreBW := hb.GetMemBandwidthGbs(), hb.GetMemBandwidthCoreGbs()
|
||||
if len(used) == 0 && len(foreign) == 0 && len(problems) == 0 && len(storage) == 0 &&
|
||||
!hb.GetWeightsChanged() && memMB == 0 && cpus == 0 && !hb.GetEnginesChanged() &&
|
||||
!hb.GetProvisionChanged() && hb.GetLoad() == nil && len(hb.GetIbPorts()) == 0 &&
|
||||
@@ -626,6 +626,9 @@ func (s *Service) applyHeartbeat(nodeID string, hb *llmgwv1.Heartbeat) {
|
||||
if bw > 0 {
|
||||
n.Host.MemBandwidthGBs = bw
|
||||
}
|
||||
if coreBW > 0 {
|
||||
n.Host.MemBandwidthCoreGBs = coreBW
|
||||
}
|
||||
if cpus > 0 && cpus != n.CPUCount {
|
||||
if n.CPUCount > 0 {
|
||||
s.log.Info("node CPUs changed", "node", nodeID, "from", n.CPUCount, "to", cpus)
|
||||
@@ -772,12 +775,25 @@ func loadFrom(l *llmgwv1.NodeLoad, at time.Time) *inventory.Load {
|
||||
UtilPercent: g.GetUtilizationPercent(), MemUtilPercent: g.GetMemoryUtilizationPercent(),
|
||||
MemoryUsedMB: g.GetMemoryUsedMb(), MemoryTotalMB: g.GetMemoryTotalMb(),
|
||||
SMClockMHz: g.GetSmClockMhz(), ECCUncorrected: g.GetEccUncorrected(),
|
||||
Wear: wearFrom(g),
|
||||
Wear: wearFrom(g), Profile: profileFrom(g),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// profileFrom is a card's profiling counters, or nil from an agent that
|
||||
// sent none.
|
||||
func profileFrom(g *llmgwv1.GPUSensor) *inventory.GPUProfile {
|
||||
if !g.GetHasProfile() {
|
||||
return nil
|
||||
}
|
||||
return &inventory.GPUProfile{
|
||||
SMActive: g.GetSmActivePercent(), SMOccupancy: g.GetSmOccupancyPercent(),
|
||||
TensorActive: g.GetTensorActivePercent(), DRAMActive: g.GetDramActivePercent(),
|
||||
Source: g.GetProfileSource(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) attach(ctx context.Context, nodeID string) *conn {
|
||||
c := &conn{
|
||||
nodeID: nodeID,
|
||||
@@ -1158,21 +1174,45 @@ func (s *Service) Upgrade(ctx context.Context, nodeID, version, releases string)
|
||||
return err
|
||||
}
|
||||
s.log.Info("told a node to upgrade", "node", nodeID, "version", version)
|
||||
// A download of some tens of megabytes on a slow link, and no longer: an
|
||||
// agent too old to know this message never answers.
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
// Longer than the agent gives its download, so its own answer — done, or
|
||||
// why not — is what is heard. Its answer can still be lost: it exits a
|
||||
// moment after sending it, and a stream that breaks takes it along. The
|
||||
// node registering again on the version says the same, and is watched for.
|
||||
ctx, cancel := context.WithTimeout(ctx, UpgradeWait)
|
||||
defer cancel()
|
||||
select {
|
||||
case r := <-ch:
|
||||
if r.GetError() != "" {
|
||||
return errors.New(r.GetError())
|
||||
tick := time.NewTicker(2 * time.Second)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case r := <-ch:
|
||||
if r.GetError() != "" {
|
||||
return errors.New(r.GetError())
|
||||
}
|
||||
return nil
|
||||
case <-tick.C:
|
||||
if n, ok := s.inv.Get(nodeID); ok && n.AgentVersion == version {
|
||||
return nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
n, ok := s.inv.Get(nodeID)
|
||||
switch {
|
||||
case !slices.Contains(s.ConnectedNodes(), nodeID):
|
||||
return fmt.Errorf("node %s went away during the upgrade and has not come back: on it, "+
|
||||
"`systemctl status llmgw-agent` and `journalctl -u llmgw-agent` say why", nodeID)
|
||||
case ok && n.AgentVersion != "":
|
||||
return fmt.Errorf("node %s did not answer the upgrade and still runs %s: its download is slow or stuck, "+
|
||||
"or its agent is older than v0.10.4, which cannot upgrade itself", nodeID, n.AgentVersion)
|
||||
default:
|
||||
return fmt.Errorf("node %s did not answer the upgrade; an agent older than v0.10.4 cannot upgrade itself", nodeID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("node %s did not answer the upgrade; an agent older than v0.10.4 cannot upgrade itself", nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
// UpgradeWait is how long the control plane waits for an agent to upgrade:
|
||||
// longer than the agent gives its download (agent.upgradeDownload).
|
||||
const UpgradeWait = 11 * time.Minute
|
||||
|
||||
// wearFrom is a card's wear, when the agent read it.
|
||||
func wearFrom(g *llmgwv1.GPUSensor) *inventory.GPUWear {
|
||||
if !g.GetHasWear() {
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/facility"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/placement"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/strata"
|
||||
)
|
||||
|
||||
// ErrExists is a model added only if absent that was there already.
|
||||
@@ -266,6 +267,12 @@ type Profile struct {
|
||||
Weight float64 `json:"weight,omitempty" koanf:"weight"`
|
||||
// Priority orders profiles during placement; highest that fits wins.
|
||||
Priority int `json:"priority,omitempty" koanf:"priority"`
|
||||
// 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.
|
||||
Replicas int `json:"replicas,omitempty" koanf:"replicas"`
|
||||
// StartupTimeout bounds model loading. A 70B model across two nodes takes
|
||||
// tens of minutes, so this is per profile rather than global.
|
||||
StartupTimeout time.Duration `json:"startup_timeout,omitempty" koanf:"startup_timeout"`
|
||||
@@ -1023,10 +1030,57 @@ func (m Model) normalize() Model {
|
||||
return m
|
||||
}
|
||||
|
||||
// Split says whether the model's replicas are counted per profile: any of
|
||||
// its profiles sets replicas.
|
||||
func (m Model) Split() bool {
|
||||
return slices.ContainsFunc(m.Profiles, func(p Profile) bool { return p.Replicas > 0 })
|
||||
}
|
||||
|
||||
// SplitTotal is the replicas its profiles ask for together.
|
||||
func (m Model) SplitTotal() int {
|
||||
n := 0
|
||||
for _, p := range m.Profiles {
|
||||
n += max(p.Replicas, 0)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ProfileBase is the catalog profile a placement profile stands for: one
|
||||
// named by the card it needs is placed as name@class.
|
||||
func ProfileBase(name string) string {
|
||||
base, _, _ := strings.Cut(name, "@")
|
||||
return base
|
||||
}
|
||||
|
||||
// validateSplit refuses replicas per profile that the model's scaling does
|
||||
// not add up to.
|
||||
func (m Model) validateSplit() error {
|
||||
for _, p := range m.Profiles {
|
||||
if p.Replicas < 0 {
|
||||
return fmt.Errorf("model %q profile %q: replicas %d is negative", m.Name, p.Name, p.Replicas)
|
||||
}
|
||||
}
|
||||
if !m.Split() || m.Stopped != nil {
|
||||
return nil
|
||||
}
|
||||
n := m.SplitTotal()
|
||||
if m.Scaling.Min != n || m.Scaling.Max != n {
|
||||
return fmt.Errorf("model %q: its profiles' replicas add up to %d, so its scaling is min %d, max %d, not %d and %d",
|
||||
m.Name, n, n, n, m.Scaling.Min, m.Scaling.Max)
|
||||
}
|
||||
if len(m.Scaling.Schedule) > 0 {
|
||||
return fmt.Errorf("model %q: replicas per profile and a schedule are one or the other", m.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) validate() error {
|
||||
if m.Name == "" {
|
||||
return fmt.Errorf("a model has no name")
|
||||
}
|
||||
if err := m.validateSplit(); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Source == "" {
|
||||
return fmt.Errorf("model %q: source is required", m.Name)
|
||||
}
|
||||
@@ -1201,10 +1255,14 @@ func (m Model) validate() error {
|
||||
if p.Nodes > 1 || p.TP > 1 || p.PP > 1 || p.GPUsPerNode > 1 {
|
||||
return fmt.Errorf("model %q profile %q: strata runs a replica on one GPU of one node", m.Name, p.Name)
|
||||
}
|
||||
if !slices.ContainsFunc(p.Args, func(a string) bool { return a == "--family" || strings.HasPrefix(a, "--family=") }) {
|
||||
family, size := strata.ArgValue(p.Args, "--family"), strata.ArgValue(p.Args, "--size")
|
||||
if family == "" || size == "" {
|
||||
return fmt.Errorf("model %q profile %q: strata needs the model's family and size in args "+
|
||||
"(--family qwen --size IQ2_XS)", m.Name, p.Name)
|
||||
}
|
||||
if _, err := strata.SizeOf(family, size); err != nil {
|
||||
return fmt.Errorf("model %q profile %q: %w", m.Name, p.Name, err)
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(strings.TrimSuffix(m.SourceOf(p), "/")), ".gguf") {
|
||||
return fmt.Errorf("model %q profile %q: strata prepares a model from all of its GGUF files; "+
|
||||
"give the directory they are in as the source", m.Name, p.Name)
|
||||
@@ -1327,6 +1385,26 @@ func (m Model) SourceOf(p Profile) string {
|
||||
return m.Source
|
||||
}
|
||||
|
||||
// OriginSHA256 is the sha256 the registry published for the one file a
|
||||
// source names, from the model's origin: "" for a set, a repository, or a
|
||||
// file the origin does not list.
|
||||
func (m Model) OriginSHA256(source string) string {
|
||||
if m.Origin == nil || strings.HasSuffix(source, "/") {
|
||||
return ""
|
||||
}
|
||||
p := source
|
||||
if u, err := url.Parse(source); err == nil && u.Path != "" {
|
||||
p = u.Path
|
||||
}
|
||||
name := path.Base(p)
|
||||
for _, f := range m.Origin.Files {
|
||||
if f.SHA256 != "" && path.Base(f.Path) == name {
|
||||
return f.SHA256
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WeightSources are every source a model's replicas may load: the model's
|
||||
// and each profile's of its own, once each. What a node must not have
|
||||
// collected from under it.
|
||||
@@ -1402,6 +1480,7 @@ func (m Model) EngineProfile(name string) (engines.Profile, bool) {
|
||||
Adapters: m.engineAdapters(),
|
||||
KVEvents: p.KVEvents,
|
||||
AllowPickle: m.AllowPickle,
|
||||
ModelSHA256: m.OriginSHA256(m.SourceOf(p)),
|
||||
}.WithRuntimeLoRA(m.MaxLoadedAdapters), true
|
||||
}
|
||||
return engines.Profile{}, false
|
||||
@@ -1484,6 +1563,9 @@ func (m Model) Simulated() bool {
|
||||
// happened, and the answer to "why is this one on the wrong node" was in
|
||||
// somebody's memory of what they edited an hour ago.
|
||||
func (p Profile) Fingerprint() string {
|
||||
// How many replicas run on it is not how each runs: changing the count
|
||||
// must not mark the running ones stale.
|
||||
p.Replicas = 0
|
||||
b, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
// A profile that will not marshal cannot be compared, and saying so by
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package catalog
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTheFileASourceNamesIsGivenItsPublishedChecksum(t *testing.T) {
|
||||
m := Model{Name: "q", Source: "http://10.0.0.146:8081/q/abc/q4.gguf", Origin: &Origin{Repo: "o/r", Revision: "abc",
|
||||
Files: []OriginFile{{Path: "sub/q4.gguf", SHA256: "aaaa"}, {Path: "config.json"}}}}
|
||||
for src, want := range map[string]string{
|
||||
m.Source: "aaaa",
|
||||
"https://huggingface.co/o/r/resolve/abc/sub/q4.gguf": "aaaa",
|
||||
"http://10.0.0.146:8081/q/abc/": "", // a set
|
||||
"o/r": "",
|
||||
"http://10.0.0.146:8081/q/abc/config.json": "", // no checksum published
|
||||
} {
|
||||
if got := m.OriginSHA256(src); got != want {
|
||||
t.Errorf("%s: %q, want %q", src, got, want)
|
||||
}
|
||||
}
|
||||
m.Profiles = []Profile{{Name: "cpu", Engine: "llamacpp"}}
|
||||
if ep, ok := m.EngineProfile("cpu"); !ok || ep.ModelSHA256 != "aaaa" {
|
||||
t.Errorf("the node is not told it: %+v", ep)
|
||||
}
|
||||
m.Origin = nil
|
||||
if m.OriginSHA256(m.Source) != "" {
|
||||
t.Error("a checksum with no origin")
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,17 @@ func TestAStrataProfileNamesItsFamilyAndRunsOnOneCard(t *testing.T) {
|
||||
"family and size": {Name: "x", Source: "http://mirror:8081/x/1/", Profiles: []Profile{{
|
||||
Name: "p", Engine: "strata", GPUClass: "6000-24g", Args: []string{"--size", "IQ2_XS"}}}},
|
||||
"all of its GGUF files": {Name: "x", Source: "http://mirror:8081/x/1/x-00001-of-00002.gguf", Profiles: []Profile{{
|
||||
Name: "p", Engine: "strata", GPUClass: "6000-24g", Args: []string{"--family", "qwen", "--size", "IQ2_XS"}}}},
|
||||
"no family \"llama\"": {Name: "x", Source: "http://mirror:8081/x/1/", Profiles: []Profile{{
|
||||
Name: "p", Engine: "strata", GPUClass: "6000-24g", Args: []string{"--family", "llama", "--size", "IQ2_XS"}}}},
|
||||
"comes in IQ1_M": {Name: "x", Source: "http://mirror:8081/x/1/", Profiles: []Profile{{
|
||||
Name: "p", Engine: "strata", GPUClass: "6000-24g", Args: []string{"--family", "coder", "--size", "IQ2_XS"}}}},
|
||||
"family and size ": {Name: "x", Source: "http://mirror:8081/x/1/", Profiles: []Profile{{
|
||||
Name: "p", Engine: "strata", GPUClass: "6000-24g", Args: []string{"--family", "qwen"}}}},
|
||||
"--gguf-dir": {Name: "x", Source: "http://mirror:8081/x/1/", Profiles: []Profile{{
|
||||
Name: "p", Engine: "strata", GPUClass: "6000-24g", Args: []string{"--family", "qwen", "--gguf-dir", "/etc"}}}},
|
||||
} {
|
||||
if _, err := New([]Model{bad}); err == nil || !strings.Contains(err.Error(), want) {
|
||||
if _, err := New([]Model{bad}); err == nil || !strings.Contains(err.Error(), strings.TrimSpace(want)) {
|
||||
t.Errorf("expected %q, got %v", want, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
@@ -95,6 +96,15 @@ type Controller struct {
|
||||
mu sync.Mutex
|
||||
last string // fingerprint of the last published list
|
||||
|
||||
// feed is where a standby's router learns the leader's Ready replicas,
|
||||
// when standbys answer requests themselves; nil when they do not.
|
||||
feed ReplicaFeed
|
||||
// catRev changes with every catalog put in force here, so a standby
|
||||
// following this one knows to read the catalog again.
|
||||
catRev atomic.Uint64
|
||||
// rates are the standbys' counts against each limit, while this leads.
|
||||
rates sharedRates
|
||||
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
@@ -193,6 +203,7 @@ type LoadSource interface {
|
||||
// killed.
|
||||
func (c *Controller) ReplaceCatalog(ctx context.Context, next *catalog.Catalog) {
|
||||
c.cat.Store(next)
|
||||
c.catRev.Add(1)
|
||||
if cs, ok := c.sink.(CatalogSink); ok {
|
||||
cs.SetCatalog(next)
|
||||
}
|
||||
@@ -306,6 +317,7 @@ func (c *Controller) Start(ctx context.Context) {
|
||||
// Before publishing, so a replica's idleness is as fresh as
|
||||
// the endpoint list it travels beside.
|
||||
c.collectUsage()
|
||||
c.shareRates()
|
||||
c.Publish(ctx)
|
||||
}
|
||||
}
|
||||
@@ -345,7 +357,7 @@ func (c *Controller) Publish(ctx context.Context) {
|
||||
|
||||
// endpoints converts Ready replicas into the router's view.
|
||||
func (c *Controller) endpoints() []endpoint.Info {
|
||||
ready := c.rec.ReadyReplicas()
|
||||
ready := c.routable()
|
||||
out := make([]endpoint.Info, 0, len(ready))
|
||||
|
||||
for _, rep := range ready {
|
||||
|
||||
@@ -96,7 +96,7 @@ func EngineBuilds(engine string) []string {
|
||||
switch engine {
|
||||
case "vllm":
|
||||
return append(append([]string{"auto"}, provision.VLLMVariants...), "none")
|
||||
case "sglang":
|
||||
case "sglang", "freetoken":
|
||||
return []string{"auto", "none"}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -84,6 +84,10 @@ type Collector struct {
|
||||
gpuXID *prometheus.Desc
|
||||
gpuLastXID *prometheus.Desc
|
||||
gpuMemUtil *prometheus.Desc
|
||||
gpuSMActive *prometheus.Desc
|
||||
gpuSMOccupancy *prometheus.Desc
|
||||
gpuTensor *prometheus.Desc
|
||||
gpuDRAMActive *prometheus.Desc
|
||||
gpuSMClock *prometheus.Desc
|
||||
gpuTemp *prometheus.Desc
|
||||
gpuMemTemp *prometheus.Desc
|
||||
@@ -158,9 +162,22 @@ func New(src Source) *Collector {
|
||||
gpuMemoryUsed: prometheus.NewDesc("llmgw_gpu_memory_used_bytes",
|
||||
"A GPU's framebuffer in use, including processes llmgw did not start.", gpu, nil),
|
||||
gpuUtil: prometheus.NewDesc("llmgw_gpu_utilization_percent",
|
||||
"How busy a GPU was over the last sample period. Absent when the driver does not "+
|
||||
"report it, which is not the same as idle. Memory used says a model is loaded; "+
|
||||
"this says whether it is doing anything.", gpu, nil),
|
||||
"The share of the last sample period in which any kernel ran on a GPU (nvidia-smi's "+
|
||||
"utilization.gpu). Not how much of the chip: a kernel on one SM reads 100%. Absent "+
|
||||
"when the driver does not report it, which is not the same as idle. Memory used says "+
|
||||
"a model is loaded; this says whether it is doing anything, and "+
|
||||
"llmgw_gpu_sm_active_percent how much.", gpu, nil),
|
||||
gpuSMActive: prometheus.NewDesc("llmgw_gpu_sm_active_percent",
|
||||
"The share of time a GPU's SMs had work, averaged over all of them: how much of the chip "+
|
||||
"the busy figure is using. From NVML's GPM (Hopper and later) or a dcgm-exporter on the node; "+
|
||||
"absent elsewhere.", gpu, nil),
|
||||
gpuSMOccupancy: prometheus.NewDesc("llmgw_gpu_sm_occupancy_percent",
|
||||
"How full a GPU's active SMs were with warps, of the most they can hold.", gpu, nil),
|
||||
gpuTensor: prometheus.NewDesc("llmgw_gpu_tensor_active_percent",
|
||||
"The share of time a GPU's tensor cores were working. Low in decode at batch one is normal; "+
|
||||
"prefill and batches are where it should rise.", gpu, nil),
|
||||
gpuDRAMActive: prometheus.NewDesc("llmgw_gpu_dram_active_percent",
|
||||
"The share of time a GPU's memory interface was moving data, from its profiling counters.", gpu, nil),
|
||||
gpuPCIeReplay: prometheus.NewDesc("llmgw_gpu_pcie_replays",
|
||||
"PCIe link retries on a GPU's link since the driver loaded; one that keeps climbing is a riser, slot or cable.", gpu, nil),
|
||||
gpuRetired: prometheus.NewDesc("llmgw_gpu_retired_pages",
|
||||
@@ -310,7 +327,8 @@ func (c *Collector) Describe(ch chan<- *prometheus.Desc) {
|
||||
c.nodeCPU, c.nodeCPUs, c.nodeLoad, c.nodeMemUsed, c.nodeMemAvail, c.shardCPU, c.shardMemory,
|
||||
c.ibActive, c.ibRate, c.ibCounter, c.fabricSMs, c.fabricMaster, c.fabricProblems,
|
||||
c.gpuNVLJoined, c.imexUp,
|
||||
c.gpuMemoryTotal, c.gpuMemoryUsed, c.gpuUtil, c.gpuMemUtil, c.gpuSMClock, c.gpuAssigned, c.gpuTemp, c.gpuHealthy,
|
||||
c.gpuMemoryTotal, c.gpuMemoryUsed, c.gpuUtil, c.gpuMemUtil, c.gpuSMClock,
|
||||
c.gpuSMActive, c.gpuSMOccupancy, c.gpuTensor, c.gpuDRAMActive, c.gpuAssigned, c.gpuTemp, c.gpuHealthy,
|
||||
c.gpuPCIeReplay, c.gpuRetired, c.gpuRetirePend, c.gpuRemapped, c.gpuRemapPend, c.gpuRemapFail,
|
||||
c.gpuNVLinkErr, c.gpuXID, c.gpuLastXID,
|
||||
c.gpuMemTemp, c.gpuPower, c.gpuPowerLimit, c.gpuThrottled, c.nodeCPUTemp, c.nodeCPUPower, c.nodeInletTemp,
|
||||
@@ -653,6 +671,19 @@ func (c *Collector) gpuBusy(ch chan<- prometheus.Metric, s inventory.GPUSensor,
|
||||
if s.SMClockMHz > 0 {
|
||||
ch <- prometheus.MustNewConstMetric(c.gpuSMClock, prometheus.GaugeValue, s.SMClockMHz, labels...)
|
||||
}
|
||||
// The card's profile, here rather than with the sensors because it is
|
||||
// the whole card's: a MIG instance must not be published as having it.
|
||||
if p := s.Profile; p != nil {
|
||||
for _, v := range []struct {
|
||||
d *prometheus.Desc
|
||||
val float64
|
||||
}{{c.gpuSMActive, p.SMActive}, {c.gpuSMOccupancy, p.SMOccupancy}, {c.gpuTensor, p.TensorActive}, {c.gpuDRAMActive, p.DRAMActive}} {
|
||||
// Zero is an idle chip; only -1, not reported, is left out.
|
||||
if v.val >= 0 {
|
||||
ch <- prometheus.MustNewConstMetric(v.d, prometheus.GaugeValue, v.val, labels...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) gpuSensor(ch chan<- prometheus.Metric, s inventory.GPUSensor, labels []string) {
|
||||
|
||||
@@ -16,7 +16,8 @@ func TestAMIGInstanceHasItsOwnMemoryItsCardsHeatAndNoUtilisation(t *testing.T) {
|
||||
MIG: &discovery.MIGDevice{ParentIndex: 0, ParentMemoryTotalMB: 81920, Device: 1, Profile: "3g.40gb"}}
|
||||
n := inventory.Node{ID: "g1", State: inventory.Ready, GPUs: []discovery.GPU{inst},
|
||||
Load: &inventory.Load{At: time.Now(), GPUs: []inventory.GPUSensor{{Index: 0, TempC: 61, PowerW: 250,
|
||||
UtilPercent: 0, MemoryUsedMB: 60000, MemoryTotalMB: 81920}}}}
|
||||
UtilPercent: 0, MemoryUsedMB: 60000, MemoryTotalMB: 81920,
|
||||
Profile: &inventory.GPUProfile{SMActive: 50, SMOccupancy: 30, TensorActive: 10, DRAMActive: 20}}}}}
|
||||
out := gather(t, fakeSource{nodes: []inventory.Node{n}})
|
||||
labels := "class=a100-80g-mig-3g.40gb gpu=1001 node=g1"
|
||||
for _, want := range []string{
|
||||
@@ -33,4 +34,8 @@ func TestAMIGInstanceHasItsOwnMemoryItsCardsHeatAndNoUtilisation(t *testing.T) {
|
||||
if strings.Contains(out, "llmgw_gpu_utilization_percent "+labels) {
|
||||
t.Errorf("a utilisation was published for a MIG instance:\n%s", out)
|
||||
}
|
||||
// The card's profile is the whole card's, not this instance's.
|
||||
if strings.Contains(out, "llmgw_gpu_sm_active_percent "+labels) {
|
||||
t.Errorf("the card's SM activity was published for a MIG instance:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -127,3 +127,27 @@ func TestAGPUsWearIsPublishedAndWhatItDoesNotKeepIsLeftOut(t *testing.T) {
|
||||
t.Errorf("a count the card does not keep was published:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHowMuchOfTheChipIsPublishedBesideHowBusy(t *testing.T) {
|
||||
// The Quadro RTX 6000 in decode: 92% busy, its SMs at work 69% of the
|
||||
// time. The DRAM figure was not read, and is left out rather than zero.
|
||||
n := inventory.Node{ID: "g1", State: inventory.Ready,
|
||||
GPUs: []discovery.GPU{{Index: 0, Name: "NVIDIA H100 80GB HBM3", MemoryTotalMB: 81559}},
|
||||
Load: &inventory.Load{At: time.Now(), GPUs: []inventory.GPUSensor{{Index: 0, UtilPercent: 92, MemoryTotalMB: 81559,
|
||||
Profile: &inventory.GPUProfile{SMActive: 69, SMOccupancy: 45, TensorActive: 0, DRAMActive: -1, Source: "gpm"}}}}}
|
||||
out := gather(t, fakeSource{nodes: []inventory.Node{n}})
|
||||
for _, want := range []string{
|
||||
"llmgw_gpu_utilization_percent class=h100-80g gpu=0 node=g1 -> 92\n",
|
||||
"llmgw_gpu_sm_active_percent class=h100-80g gpu=0 node=g1 -> 69\n",
|
||||
"llmgw_gpu_sm_occupancy_percent class=h100-80g gpu=0 node=g1 -> 45\n",
|
||||
// Idle tensor cores are a reading.
|
||||
"llmgw_gpu_tensor_active_percent class=h100-80g gpu=0 node=g1 -> 0\n",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing %s in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "llmgw_gpu_dram_active_percent") {
|
||||
t.Errorf("a figure not read was published:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -340,6 +340,36 @@ type GPUSensor struct {
|
||||
// Wear is what wears the card out, read every minute; nil from an agent
|
||||
// that does not read it.
|
||||
Wear *GPUWear `json:"wear,omitempty"`
|
||||
// Profile is how much of the chip the work uses, which UtilPercent does
|
||||
// not say; nil where nothing on the node can read it.
|
||||
Profile *GPUProfile `json:"profile,omitempty"`
|
||||
}
|
||||
|
||||
// GPUProfile is a card's profiling counters, in percent; -1 is not reported.
|
||||
// UtilPercent is the share of time any kernel ran, and a kernel on one SM of
|
||||
// a hundred counts in full; these say how much of the chip it ran on.
|
||||
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 float64 `json:"sm_active"`
|
||||
SMOccupancy float64 `json:"sm_occupancy"`
|
||||
// TensorActive is the tensor cores, DRAMActive the memory interface.
|
||||
TensorActive float64 `json:"tensor_active"`
|
||||
DRAMActive float64 `json:"dram_active"`
|
||||
// Source is what read them: "gpm" (NVML, Hopper and later) or "dcgm".
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// PowerLoad is the card's draw as a share of its power limit, from 0 to 1,
|
||||
// or -1 without both figures. Unlike UtilPercent it rises with how much of
|
||||
// the chip is working, not only with whether anything is: a card reading
|
||||
// 92% busy drew 239 W of 260 W in decode and 112 W when the busy figure was
|
||||
// a kernel or two between requests.
|
||||
func (g GPUSensor) PowerLoad() float64 {
|
||||
if g.PowerW <= 0 || g.PowerLimitW <= 0 {
|
||||
return -1
|
||||
}
|
||||
return min(g.PowerW/g.PowerLimitW, 1)
|
||||
}
|
||||
|
||||
// GPUWear is a card's wear and failures. Counts of -1 are not reported for
|
||||
|
||||
@@ -21,9 +21,13 @@ import (
|
||||
|
||||
// ContextRequest is the context to change to: tokens one request may use,
|
||||
// and for llama.cpp how many at once (its pool is the two multiplied).
|
||||
// MaxOutput is the longest answer, which may be changed with it or alone:
|
||||
// Tokens 0 with a MaxOutput changes only that, in the catalog, which the
|
||||
// router reads it from — nothing is started again.
|
||||
type ContextRequest struct {
|
||||
Tokens int `json:"tokens"`
|
||||
Slots int `json:"slots,omitempty"`
|
||||
Tokens int `json:"tokens,omitempty"`
|
||||
Slots int `json:"slots,omitempty"`
|
||||
MaxOutput int `json:"max_output,omitempty"`
|
||||
}
|
||||
|
||||
// ContextProfile is what one profile needs before and after.
|
||||
@@ -37,13 +41,17 @@ type ContextProfile struct {
|
||||
|
||||
// ContextChange is what was changed, and the replicas started again on it.
|
||||
type ContextChange struct {
|
||||
Model string `json:"model"`
|
||||
Before int `json:"before"`
|
||||
After int `json:"after"`
|
||||
MaxOutput int `json:"max_output"`
|
||||
Trained int `json:"trained,omitempty"`
|
||||
Profiles []ContextProfile `json:"profiles"`
|
||||
Restarted int `json:"restarted"`
|
||||
Model string `json:"model"`
|
||||
Before int `json:"before"`
|
||||
After int `json:"after"`
|
||||
MaxOutput int `json:"max_output"`
|
||||
// OutputOnly is a change of the longest answer alone, MaxOutputBefore
|
||||
// what it was (0 no limit); nothing was started again.
|
||||
OutputOnly bool `json:"output_only,omitempty"`
|
||||
MaxOutputBefore int `json:"max_output_before,omitempty"`
|
||||
Trained int `json:"trained,omitempty"`
|
||||
Profiles []ContextProfile `json:"profiles"`
|
||||
Restarted int `json:"restarted"`
|
||||
// Note is what could not be checked, said once.
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
@@ -75,8 +83,14 @@ func (s *Server) SetContext(ctx context.Context, name string, req ContextRequest
|
||||
return ContextChange{}, fmt.Errorf("no model %q in the catalog", name)
|
||||
case s.deps.Models.Declared(name):
|
||||
return ContextChange{}, fmt.Errorf("model %q is declared in the cluster's settings: change its context there (llmgw ctl config edit)", name)
|
||||
case req.MaxOutput < 0:
|
||||
return ContextChange{}, errors.New("the longest answer is a number of tokens")
|
||||
case req.Tokens == 0 && req.MaxOutput > 0:
|
||||
return s.setMaxOutput(ctx, m, req.MaxOutput, by)
|
||||
case req.Tokens < 256:
|
||||
return ContextChange{}, errors.New("a context of at least 256 tokens")
|
||||
case req.MaxOutput > req.Tokens:
|
||||
return ContextChange{}, fmt.Errorf("an answer of %d tokens does not fit a context of %d", req.MaxOutput, req.Tokens)
|
||||
case req.Slots < 0 || req.Slots > 64:
|
||||
return ContextChange{}, errors.New("slots are 1 to 64")
|
||||
}
|
||||
@@ -100,6 +114,9 @@ func (s *Server) SetContext(ctx context.Context, name string, req ContextRequest
|
||||
out.MaxOutput = maxOutput(req.Tokens)
|
||||
}
|
||||
out.MaxOutput = min(out.MaxOutput, req.Tokens)
|
||||
if req.MaxOutput > 0 {
|
||||
out.MaxOutput = req.MaxOutput
|
||||
}
|
||||
if _, err := s.deps.Models.SetContext(ctx, name, req.Tokens, out.MaxOutput, profiles, by); err != nil {
|
||||
return ContextChange{}, err
|
||||
}
|
||||
@@ -121,6 +138,21 @@ func (s *Server) SetContext(ctx context.Context, name string, req ContextRequest
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// setMaxOutput changes only the longest answer: the catalog's, which the
|
||||
// router holds a request's max_tokens to. No engine reads it, so no replica
|
||||
// is started again.
|
||||
func (s *Server) setMaxOutput(ctx context.Context, m catalog.Model, maxOut int, by string) (ContextChange, error) {
|
||||
if m.ContextLength > 0 && maxOut > m.ContextLength {
|
||||
return ContextChange{}, fmt.Errorf("an answer of %d tokens does not fit %s's context of %d: change the context with it",
|
||||
maxOut, m.Name, m.ContextLength)
|
||||
}
|
||||
if _, err := s.deps.Models.SetContext(ctx, m.Name, m.ContextLength, maxOut, m.Profiles, by); err != nil {
|
||||
return ContextChange{}, err
|
||||
}
|
||||
return ContextChange{Model: m.Name, Before: m.ContextLength, After: m.ContextLength, MaxOutput: maxOut,
|
||||
OutputOnly: true, MaxOutputBefore: m.MaxOutput}, nil
|
||||
}
|
||||
|
||||
// shapeOf reads what sizes a model's context: a GGUF file's header from the
|
||||
// mirror, else the original weights' config from their repository.
|
||||
func (s *Server) shapeOf(ctx context.Context, m catalog.Model) (shape, string) {
|
||||
@@ -174,6 +206,20 @@ func resizeProfile(p catalog.Profile, req ContextRequest, sh shape) (catalog.Pro
|
||||
case "sglang":
|
||||
pool = req.Tokens
|
||||
p.Args = setArg(p.Args, "--context-length", strconv.Itoa(pool))
|
||||
case "strata":
|
||||
// Strata's setup takes the context, and writes the engine's start
|
||||
// script with it at every start; it sizes its KV cache and expert
|
||||
// cache to the card itself, so there is no card check to make here.
|
||||
p.Args = setArg(p.Args, "--context", strconv.Itoa(req.Tokens))
|
||||
change.Args = p.Args
|
||||
return p, change, nil
|
||||
case "freetoken":
|
||||
// Its experts live in RAM and stream to the card, so the weights
|
||||
// are not what has to fit there, and the card check below would
|
||||
// refuse what it serves.
|
||||
p.Args = setArg(p.Args, "--max-seq-len-override", strconv.Itoa(req.Tokens))
|
||||
change.Args = p.Args
|
||||
return p, change, nil
|
||||
default:
|
||||
return p, change, fmt.Errorf("a %s profile (%s) does not take a context this way", p.Engine, p.Name)
|
||||
}
|
||||
|
||||
@@ -88,3 +88,57 @@ func TestEngineArgumentsAreSetInEitherSpelling(t *testing.T) {
|
||||
t.Error("a comfyui profile took a context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheLongestAnswerAloneChangesOnlyTheCatalogAndStartsNothing(t *testing.T) {
|
||||
s, _, models, _, fleet := updSetup(t)
|
||||
m := models.models["qwen"]
|
||||
m.ContextLength, m.MaxOutput = 4096, 1024
|
||||
m.Profiles[0].Args = []string{"--ctx-size", "4096"}
|
||||
models.models["qwen"] = m
|
||||
before := fleet.Replicas()
|
||||
|
||||
got, err := s.SetContext(context.Background(), "qwen", ContextRequest{MaxOutput: 3000}, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.OutputOnly || got.MaxOutput != 3000 || got.MaxOutputBefore != 1024 || got.Restarted != 0 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
after := models.models["qwen"]
|
||||
if after.ContextLength != 4096 || after.MaxOutput != 3000 || !slices.Equal(after.Profiles[0].Args, []string{"--ctx-size", "4096"}) {
|
||||
t.Errorf("catalog %d/%d, args %v", after.ContextLength, after.MaxOutput, after.Profiles[0].Args)
|
||||
}
|
||||
for i, r := range fleet.Replicas() {
|
||||
if r.ID != before[i].ID {
|
||||
t.Errorf("%s was started again for a limit no engine reads", before[i].ID)
|
||||
}
|
||||
}
|
||||
// An answer longer than the context is refused, alone or with one.
|
||||
if _, err := s.SetContext(context.Background(), "qwen", ContextRequest{MaxOutput: 5000}, "admin"); err == nil {
|
||||
t.Error("an answer longer than the context was accepted")
|
||||
}
|
||||
if _, err := s.SetContext(context.Background(), "qwen", ContextRequest{Tokens: 8192, MaxOutput: 9000}, "admin"); err == nil {
|
||||
t.Error("an answer longer than the new context was accepted")
|
||||
}
|
||||
// With a context, the one asked for wins over the one that follows it.
|
||||
got, err = s.SetContext(context.Background(), "qwen", ContextRequest{Tokens: 8192, MaxOutput: 6000}, "admin")
|
||||
if err != nil || got.MaxOutput != 6000 || models.models["qwen"].MaxOutput != 6000 {
|
||||
t.Errorf("%+v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataTakesItsContextInItsSetupArguments(t *testing.T) {
|
||||
p := catalog.Profile{Name: "one-card", Engine: "strata", GPUClass: "6000-24g",
|
||||
Args: []string{"--family", "qwen", "--size", "IQ2_XS"}}
|
||||
got, change, err := resizeProfile(p, ContextRequest{Tokens: 32768}, shape{kvPerToken: 1 << 20, weights: 40 << 30})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Not refused for weights larger than the card: its experts live in RAM.
|
||||
if !slices.Equal(got.Args, []string{"--family", "qwen", "--size", "IQ2_XS", "--context", "32768"}) || !slices.Equal(change.Args, got.Args) {
|
||||
t.Errorf("args %v", got.Args)
|
||||
}
|
||||
if n := contextOf(catalog.Model{ContextLength: 4096}, got); n != 32768 {
|
||||
t.Errorf("contextOf %d", n)
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,14 @@ func contextOf(m catalog.Model, p catalog.Profile) int {
|
||||
if n := argInt(p.Args, "--context-length"); n > 0 {
|
||||
return n
|
||||
}
|
||||
case "strata":
|
||||
if n := argInt(p.Args, "--context"); n > 0 {
|
||||
return n
|
||||
}
|
||||
case "freetoken":
|
||||
if n := argInt(p.Args, "--max-seq-len-override"); n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return m.ContextLength
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/modelplan"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/notify"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/strata"
|
||||
)
|
||||
|
||||
// Registry reads a repository.
|
||||
@@ -134,6 +135,9 @@ type Request struct {
|
||||
// Fallbacks adds the next best ways, lower in priority, so a model whose
|
||||
// first choice is busy runs on another rather than waiting.
|
||||
Fallbacks bool `json:"fallbacks,omitempty"`
|
||||
// Engine is the engine the file runs on: "strata" for a family Strata
|
||||
// runs, "llamacpp" to keep it off Strata; empty, the plan's best.
|
||||
Engine string `json:"engine,omitempty"`
|
||||
// Name is the catalog name; the repository suggests one otherwise.
|
||||
Name string `json:"name,omitempty"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
@@ -409,6 +413,40 @@ func (s *Server) run(j *Job) {
|
||||
if req.TP > 0 && req.GPUsPerNode == 0 {
|
||||
req.GPUsPerNode = req.TP
|
||||
}
|
||||
// A pack Strata fetches itself: no mirror; its repository is the source.
|
||||
if _, size, ok := strata.ForFetchedRepo(repo.ID); ok {
|
||||
plan, err := s.plan(ctx, repo, PlanRequest{Repo: repo.ID})
|
||||
if err != nil {
|
||||
s.fail(j, "registry", err)
|
||||
return
|
||||
}
|
||||
set := hf.FileSet{Name: size.Name}
|
||||
layout, err := chooseLayout(Request{Format: "gguf", Engine: "strata", GPUClass: req.GPUClass}, plan, true, set)
|
||||
if err != nil {
|
||||
s.fail(j, "registry", err)
|
||||
return
|
||||
}
|
||||
name := req.Name
|
||||
if name == "" {
|
||||
name = hf.ModelName(repo.ID)
|
||||
}
|
||||
s.mu.Lock()
|
||||
j.Model = name
|
||||
s.mu.Unlock()
|
||||
if !req.sized {
|
||||
req.ContextLength = strataContext
|
||||
}
|
||||
s.step(j, "registry", Done, fmt.Sprintf("Strata's ready-made %s pack, revision %s; runs on %s",
|
||||
size.Name, repo.ShortRevision(), layout[0].Label()))
|
||||
s.step(j, "mirror", Done, fmt.Sprintf("not through the mirror: Strata fetches about %.0f GB onto the node itself", size.DownloadGB))
|
||||
profiles := profilesFor(layout, req, 0, 0)
|
||||
profiles[0].StartupTimeout = 4 * time.Hour
|
||||
s.mu.Lock()
|
||||
j.Request = req
|
||||
s.mu.Unlock()
|
||||
s.addAndStart(ctx, j, repo, set, name, repo.ID, profiles, hardwareExists(layout))
|
||||
return
|
||||
}
|
||||
// The plan: which file suits the hardware, and how it spreads over it.
|
||||
planCtx := req.CtxPool
|
||||
if req.Format == "safetensors" {
|
||||
@@ -488,6 +526,23 @@ func (s *Server) run(j *Job) {
|
||||
}
|
||||
|
||||
// 3. Whether the nodes' engine loads it, and how much memory it needs.
|
||||
if layout[0].Engine == "strata" {
|
||||
// Strata prepares it on the node from the directory of its files,
|
||||
// and keeps the experts in the RAM the plan counted.
|
||||
if !strings.HasSuffix(source, "/") {
|
||||
source = strings.TrimSuffix(source, mirror.LastPathElement(source))
|
||||
}
|
||||
if !req.sized {
|
||||
req.ContextLength = strataContext
|
||||
}
|
||||
s.step(j, "check", Done, fmt.Sprintf("Strata's %s %s, prepared on the node; %.0f GB of RAM for its experts",
|
||||
layout[0].StrataFamily, layout[0].StrataSize, layout[0].NeedGB))
|
||||
s.mu.Lock()
|
||||
j.Request = req
|
||||
s.mu.Unlock()
|
||||
s.addAndStart(ctx, j, repo, set, name, source, profilesFor(layout, req, 0, 0), hardwareExists(layout))
|
||||
return
|
||||
}
|
||||
if req.Format == "safetensors" {
|
||||
// vLLM reads the model's shape from its config.json, which came
|
||||
// with the weights.
|
||||
@@ -517,6 +572,14 @@ func (s *Server) run(j *Job) {
|
||||
// On one card, the weights and the context's cache have to fit it: a
|
||||
// 70B with a 65536-token pool on a 24 GB card loaded its weights and then
|
||||
// failed to make its context, every time, as did an 8B with 131072.
|
||||
// Asked for the CPU, and slow there: said, not refused — the request
|
||||
// named it — so the job's log says why its replies take minutes.
|
||||
if len(layout) > 0 && layout[0].CPU && layout[0].TokensPerSecond > 0 &&
|
||||
layout[0].TokensPerSecond < modelplan.CPUSlowTPS {
|
||||
tps := layout[0].TokensPerSecond
|
||||
s.step(j, "check", Running, fmt.Sprintf("on the CPU about %.1f tokens a second for one reply: "+
|
||||
"a reply of 500 tokens takes about %.0f seconds; a card would be many times faster", tps, 500/tps))
|
||||
}
|
||||
if len(layout) > 0 && layout[0].Class != "" && !layout[0].CPU {
|
||||
fitted, why, err := fitPoolToCard(req, header, set.Bytes, layout[0].Class, max(layout[0].GPUsPerNode, 1))
|
||||
if err != nil {
|
||||
@@ -566,10 +629,21 @@ func chooseLayout(req Request, plan Plan, planned bool, set hf.FileSet) ([]model
|
||||
}
|
||||
var opts []modelplan.Option
|
||||
for _, o := range plan.Options {
|
||||
if o.Artifact == artifact {
|
||||
onStrata := o.Engine == "strata"
|
||||
if o.Artifact == artifact && (req.Engine == "" || (req.Engine == "strata") == onStrata) {
|
||||
opts = append(opts, o)
|
||||
}
|
||||
}
|
||||
if req.Engine == "strata" {
|
||||
// Strata's way only, on the class asked for if one was.
|
||||
i := slices.IndexFunc(opts, func(o modelplan.Option) bool { return req.GPUClass == "" || o.Class == req.GPUClass })
|
||||
if i < 0 {
|
||||
return nil, fmt.Errorf("Strata cannot run %s here: it runs a few families only (`llmgw ctl model plan %s` "+
|
||||
"says whether this is one), on a node with Strata installed, a card of 12 GB or more and the RAM for its experts",
|
||||
set.Name, plan.Repo)
|
||||
}
|
||||
return []modelplan.Option{opts[i]}, nil
|
||||
}
|
||||
var out []modelplan.Option
|
||||
switch {
|
||||
case req.CPU || req.GPUClass != "":
|
||||
@@ -600,7 +674,7 @@ func chooseLayout(req Request, plan Plan, planned bool, set hf.FileSet) ([]model
|
||||
default:
|
||||
out = append(out, modelplan.Option{Artifact: artifact, Format: req.Format, Engine: engine, CPU: true})
|
||||
}
|
||||
if req.Fallbacks {
|
||||
if req.Fallbacks && out[0].Engine != "strata" {
|
||||
shape := func(o modelplan.Option) string {
|
||||
return fmt.Sprintf("%v/%s/%d/%d", o.CPU, o.Class, o.GPUsPerNode, o.Nodes)
|
||||
}
|
||||
@@ -625,6 +699,12 @@ func profilesFor(layout []modelplan.Option, req Request, memGB, cores int) []cat
|
||||
for i, o := range layout {
|
||||
p := catalog.Profile{Engine: o.Engine, Weight: 1, Priority: 100 - 10*i, Env: req.Env}
|
||||
switch {
|
||||
case o.Engine == "strata":
|
||||
p.Name = "strata-" + o.Class
|
||||
p.GPUClass, p.GPUsPerNode = o.Class, 1
|
||||
p.Args = []string{"--family", o.StrataFamily, "--size", o.StrataSize,
|
||||
"--context", strconv.Itoa(req.ContextLength), "--vision", "no"}
|
||||
p.MinMemoryGB = int(o.NeedGB)
|
||||
case o.CPU:
|
||||
p.Name = "cpu"
|
||||
p.Args = llamaArgs(req)
|
||||
@@ -674,6 +754,10 @@ func llamaArgs(req Request) []string {
|
||||
return args
|
||||
}
|
||||
|
||||
// strataContext is a Strata model's context when its caller did not say:
|
||||
// Strata's own default.
|
||||
const strataContext = 32768
|
||||
|
||||
// An embedding model's slots, when its caller did not size them: passages
|
||||
// of up to a thousand tokens, thirty-two at a time.
|
||||
const (
|
||||
@@ -848,6 +932,9 @@ func (s *Server) mirror(ctx context.Context, j *Job, repo hf.Repo, set hf.FileSe
|
||||
}
|
||||
resp, err := s.deps.Mirror.Fetch(ctx, mirror.FetchRequest{
|
||||
Source: repo.DownloadURL("", f.Path), Model: name, Version: version, File: file,
|
||||
// The mirror deletes a copy that is not what the registry
|
||||
// published, rather than holding it for the nodes.
|
||||
SHA256: f.SHA256,
|
||||
})
|
||||
// Already there is not a failure: the same commit fetched before.
|
||||
if err != nil && !strings.Contains(err.Error(), "already") {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/gguf"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/modelplan"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/strata"
|
||||
)
|
||||
|
||||
// Planning: which of a repository's files suits the cluster's hardware, and
|
||||
@@ -97,6 +98,23 @@ func (s *Server) Plan(ctx context.Context, req PlanRequest) (Plan, error) {
|
||||
}
|
||||
|
||||
func (s *Server) plan(ctx context.Context, repo hf.Repo, req PlanRequest) (Plan, error) {
|
||||
// A pack Strata fetches itself: one way, Strata's, whatever its files.
|
||||
if fam, size, ok := strata.ForFetchedRepo(repo.ID); ok {
|
||||
art := modelplan.Artifact{Name: size.Name, Format: "strata", Bytes: int64(size.DownloadGB * 1e9), Quant: size.Name}
|
||||
p := Plan{Repo: repo.ID, Revision: repo.Revision, Format: "strata", Arch: repo.Architecture, License: repo.License,
|
||||
Context: strataContext, Servable: true, Note: "Strata fetches this pack onto the node itself on its first start"}
|
||||
p.Options = modelplan.StrataOptions(fam.Name, []modelplan.Artifact{art}, s.deps.Pool())
|
||||
if len(p.Options) == 0 && fam.MinCC > 0 {
|
||||
p.Note = fmt.Sprintf("Strata's %s engine needs an NVIDIA card of compute capability %.1f or newer (RTX 30 on); "+
|
||||
"a node with Strata and such a card, and %d GB of RAM, runs it", fam.Title, fam.MinCC, size.MemoryGB())
|
||||
}
|
||||
pa := PlannedArtifact{Artifact: art}
|
||||
if len(p.Options) > 0 {
|
||||
pa.Best = &p.Options[0]
|
||||
}
|
||||
p.Artifacts = []PlannedArtifact{pa}
|
||||
return p, nil
|
||||
}
|
||||
p := Plan{Repo: repo.ID, Revision: repo.Revision, Format: req.Format, Arch: repo.Architecture,
|
||||
License: repo.License, Params: repo.Params, Context: req.Context, Gated: repo.Gated.Is(), Servable: true,
|
||||
Projectors: Projectors(repo)}
|
||||
@@ -132,6 +150,7 @@ func (s *Server) plan(ctx context.Context, repo hf.Repo, req PlanRequest) (Plan,
|
||||
}
|
||||
shape.KVBytesPerToken = v.Header.KVBytesPerToken()
|
||||
shape.KVHeads = int(v.Header.HeadCountKV) //nolint:gosec // a header's head count
|
||||
shape.ActiveShare = v.Header.ActiveShare()
|
||||
if p.Arch == "" {
|
||||
p.Arch = v.Header.Architecture
|
||||
}
|
||||
@@ -163,11 +182,26 @@ func (s *Server) plan(ctx context.Context, repo hf.Repo, req PlanRequest) (Plan,
|
||||
p.Note = "the model's shape was not readable, so the context's memory is not counted"
|
||||
}
|
||||
shape.Params = p.Params
|
||||
if shape.ActiveShare == 0 || shape.ActiveShare == 1 {
|
||||
shape.ActiveShare = modelplan.ActiveShare(repo.ID, p.Params)
|
||||
}
|
||||
p.KVBytesPerToken, p.Experts = shape.KVBytesPerToken, shape.Experts
|
||||
p.Options = modelplan.Options(arts, shape, s.deps.Pool(), p.Context)
|
||||
if !p.Servable {
|
||||
p.Options = nil
|
||||
}
|
||||
// A family Strata runs: its files on one card with the experts in RAM.
|
||||
// Strata reads them itself, whether or not llama.cpp could.
|
||||
if fam, ok := strata.ForRepo(repo.ID); ok && p.Format == "gguf" {
|
||||
if so := modelplan.StrataOptions(fam.Name, arts, s.deps.Pool()); len(so) > 0 {
|
||||
p.Options = append(p.Options, so...)
|
||||
modelplan.Rank(p.Options)
|
||||
if !p.Servable {
|
||||
p.Note = strings.TrimSpace(p.Note + " llama.cpp cannot run it (" + p.Reason + "); Strata can.")
|
||||
p.Servable, p.Reason = true, ""
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, a := range arts {
|
||||
pa := PlannedArtifact{Artifact: a}
|
||||
for i := range p.Options {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package modelserve
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/modelplan"
|
||||
)
|
||||
|
||||
func TestAStrataWayIsChosenAndMadeAProfile(t *testing.T) {
|
||||
set := hf.FileSet{Name: "Qwen3.8-Flash-Next-GSQ-RCO-IQ2_XS-00001-of-00002.gguf"}
|
||||
strataWay := modelplan.Option{Artifact: set.Name, Format: "gguf", Engine: "strata", Class: "6000-24g",
|
||||
GPUsPerNode: 1, Nodes: 1, NeedGB: 52, HaveGB: 213, Hosts: 1, StrataFamily: "qwen", StrataSize: "IQ2_XS"}
|
||||
cpuWay := modelplan.Option{Artifact: set.Name, Format: "gguf", Engine: "llamacpp", CPU: true, NeedGB: 75, HaveGB: 213, Hosts: 1}
|
||||
plan := Plan{Repo: "ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF", Options: []modelplan.Option{strataWay, cpuWay}}
|
||||
|
||||
got, err := chooseLayout(Request{Format: "gguf", Fallbacks: true}, plan, true, set)
|
||||
if err != nil || len(got) != 1 || got[0].Engine != "strata" {
|
||||
t.Fatalf("the plan's best, without llama.cpp fallbacks: %+v %v", got, err)
|
||||
}
|
||||
if got, _ := chooseLayout(Request{Format: "gguf", Engine: "llamacpp"}, plan, true, set); len(got) != 1 || !got[0].CPU {
|
||||
t.Errorf("kept off Strata: %+v", got)
|
||||
}
|
||||
if _, err := chooseLayout(Request{Format: "gguf", Engine: "strata", GPUClass: "h100-80g"}, plan, true, set); err == nil {
|
||||
t.Error("Strata on a class it was not planned for")
|
||||
}
|
||||
|
||||
ps := profilesFor(got, Request{ContextLength: 32768}, 0, 0)
|
||||
p := ps[0]
|
||||
if p.Engine != "strata" || p.GPUClass != "6000-24g" || p.GPUsPerNode != 1 || p.MinMemoryGB != 52 ||
|
||||
!slices.Equal(p.Args, []string{"--family", "qwen", "--size", "IQ2_XS", "--context", "32768", "--vision", "no"}) ||
|
||||
!strings.HasPrefix(p.Name, "strata-") {
|
||||
t.Errorf("%+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReadyMadePackIsPlannedOnStrataOnly(t *testing.T) {
|
||||
s := New(Deps{Pool: func() []modelplan.PoolNode {
|
||||
return []modelplan.PoolNode{{ID: "cuda01", Class: "6000-24g", GPUMemGB: 24, GPUs: 1, FreeGPUs: 1,
|
||||
HostMemGB: 251, HostFreeGB: 200, Engines: []string{"strata"}}}
|
||||
}})
|
||||
p, err := s.plan(t.Context(), hf.Repo{ID: "Maximilian228/Qwen3.8-Flash-Next-NVIDIA-NVFP4-Strata", Revision: "abc",
|
||||
Architecture: "strata-ple"}, PlanRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !p.Servable || len(p.Options) != 1 || p.Options[0].StrataFamily != "nvfp4" || p.Options[0].StrataSize != "NVFP4-NVIDIA" {
|
||||
t.Errorf("%+v", p)
|
||||
}
|
||||
}
|
||||
@@ -22,11 +22,13 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/strata"
|
||||
)
|
||||
|
||||
// Store is the persistence this needs.
|
||||
@@ -51,6 +53,8 @@ type Service struct {
|
||||
// check refuses a model that cannot run before it is added; see
|
||||
// SetCheck.
|
||||
check func(context.Context, catalog.Model) error
|
||||
// registry pins what is added from it to a commit; nil pins nothing.
|
||||
registry Registry
|
||||
|
||||
// federated are the models federated clusters offer; see SetFederated.
|
||||
federated func() []catalog.Model
|
||||
@@ -127,12 +131,31 @@ func (s *Service) add(ctx context.Context, m catalog.Model, by string, onlyNew b
|
||||
// fits everywhere as far as placement can tell — however many of it
|
||||
// there already are. Required of what is added now; models stored before
|
||||
// this still load, and say what they are missing.
|
||||
m.Profiles = slices.Clone(m.Profiles)
|
||||
for i := range m.Profiles {
|
||||
// Strata keeps a model's experts in the host's memory, tens of
|
||||
// gigabytes of it: placed as if it took none, two replicas, or one
|
||||
// beside a CPU model, filled a node until one was killed. What its
|
||||
// size takes, from Strata's table, unless the model says.
|
||||
if p := &m.Profiles[i]; p.Engine == "strata" && p.MinMemoryGB <= 0 {
|
||||
size, err := strata.SizeOf(strata.ArgValue(p.Args, "--family"), strata.ArgValue(p.Args, "--size"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("model %q profile %q: %w", m.Name, p.Name, err)
|
||||
}
|
||||
p.MinMemoryGB = size.MemoryGB()
|
||||
}
|
||||
}
|
||||
for _, p := range m.Profiles {
|
||||
if p.CPUOnly() && p.MinMemoryGB <= 0 {
|
||||
return fmt.Errorf("model %q profile %q runs without a GPU and declares no memory (min_memory_gb): "+
|
||||
"placement could not tell when a node is full", m.Name, p.Name)
|
||||
}
|
||||
}
|
||||
if s.registry != nil {
|
||||
if err := pin(ctx, s.registry, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if s.check != nil {
|
||||
if err := s.check(ctx, m); err != nil {
|
||||
return err
|
||||
@@ -227,6 +250,13 @@ func (s *Service) Remove(ctx context.Context, name string) error {
|
||||
// Scale changes how many replicas a model added at runtime runs.
|
||||
func (s *Service) Scale(ctx context.Context, name string, sc catalog.Scaling, by string) (catalog.Model, error) {
|
||||
final, err := s.update(ctx, name, by, "change its scaling", func(m *catalog.Model) error {
|
||||
// Replicas counted per profile stay so only for the same total, one
|
||||
// way: any other scaling is the model's again, on whatever fits.
|
||||
if m.Split() && (sc.Min != sc.Max || sc.Min != m.SplitTotal()) {
|
||||
for i := range m.Profiles {
|
||||
m.Profiles[i].Replicas = 0
|
||||
}
|
||||
}
|
||||
m.Scaling = sc
|
||||
// A number set is what it runs now: a stopped model is started by it.
|
||||
m.Stopped = nil
|
||||
@@ -239,6 +269,98 @@ func (s *Service) Scale(ctx context.Context, name string, sc catalog.Scaling, by
|
||||
return final, err
|
||||
}
|
||||
|
||||
// CPUProfile is a profile on CPUs to add to a llama.cpp model, for replicas
|
||||
// counted per profile: what a replica of it reserves.
|
||||
type CPUProfile struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
MemoryGB int `json:"memory_gb"`
|
||||
CPUCores int `json:"cpu_cores,omitempty"`
|
||||
}
|
||||
|
||||
// SetProfileReplicas sets how many of a model's replicas run on each of its
|
||||
// profiles: seven on cards and four on CPUs, under the one name. A profile
|
||||
// not named keeps its count. cpu, for a llama.cpp model with no profile on
|
||||
// CPUs, adds one — its card profile without the card, preferred below every
|
||||
// other, so the router fills the cards first and the CPUs after. Running
|
||||
// replicas carry on; those over a count stop once the ones short have
|
||||
// started.
|
||||
func (s *Service) SetProfileReplicas(ctx context.Context, name string, counts map[string]int, cpu *CPUProfile,
|
||||
by string,
|
||||
) (catalog.Model, error) {
|
||||
final, err := s.update(ctx, name, by, "count its replicas per profile", func(m *catalog.Model) error {
|
||||
if cpu != nil {
|
||||
if cpu.Name == "" {
|
||||
cpu.Name = "cpu"
|
||||
}
|
||||
if !slices.ContainsFunc(m.Profiles, func(p catalog.Profile) bool { return p.Name == cpu.Name }) {
|
||||
p, err := cpuProfileOf(*m, *cpu)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Profiles = append(m.Profiles, p)
|
||||
}
|
||||
}
|
||||
for prof, n := range counts {
|
||||
i := slices.IndexFunc(m.Profiles, func(p catalog.Profile) bool { return p.Name == prof })
|
||||
if i < 0 {
|
||||
return fmt.Errorf("model %q has no profile %q", name, prof)
|
||||
}
|
||||
if n < 0 {
|
||||
return fmt.Errorf("profile %q: %d replicas", prof, n)
|
||||
}
|
||||
m.Profiles[i].Replicas = n
|
||||
}
|
||||
n := m.SplitTotal()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("none of %q's profiles would run a replica; llmgw ctl model stop %s keeps it without", name, name)
|
||||
}
|
||||
sc := catalog.Scaling{Min: n, Max: n, IdleTimeout: m.Scaling.IdleTimeout}
|
||||
if m.Stopped != nil {
|
||||
sc.IdleTimeout = m.Stopped.IdleTimeout
|
||||
m.Stopped = &sc
|
||||
} else {
|
||||
m.Scaling = sc
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
s.log.Info("replicas counted per profile", "model", name, "counts", counts, "by", by)
|
||||
}
|
||||
return final, err
|
||||
}
|
||||
|
||||
// cpuProfileOf is a model's llama.cpp profile on CPUs: the same engine and
|
||||
// arguments, without the card or what spreads layers over cards.
|
||||
func cpuProfileOf(m catalog.Model, cpu CPUProfile) (catalog.Profile, error) {
|
||||
i := slices.IndexFunc(m.Profiles, func(p catalog.Profile) bool { return p.Engine == "llamacpp" })
|
||||
if i < 0 {
|
||||
return catalog.Profile{}, fmt.Errorf("model %q is not served by llama.cpp; only a GGUF model runs on CPUs", m.Name)
|
||||
}
|
||||
if cpu.MemoryGB <= 0 {
|
||||
return catalog.Profile{}, errors.New("a profile on CPUs needs the memory a replica reserves")
|
||||
}
|
||||
p := m.Profiles[i]
|
||||
lowest := p.Priority
|
||||
for _, o := range m.Profiles {
|
||||
lowest = min(lowest, o.Priority)
|
||||
}
|
||||
out := catalog.Profile{Name: cpu.Name, Engine: p.Engine, EngineVersion: p.EngineVersion, Source: p.Source,
|
||||
Env: p.Env, NodeSelector: p.NodeSelector, StartupTimeout: p.StartupTimeout, Weight: p.Weight,
|
||||
Priority: lowest - 10, MinMemoryGB: cpu.MemoryGB, CPUCores: cpu.CPUCores}
|
||||
for j := 0; j < len(p.Args); j++ {
|
||||
switch a := p.Args[j]; {
|
||||
case slices.Contains([]string{"--n-gpu-layers", "-ngl", "--split-mode", "-sm", "--tensor-split", "-ts",
|
||||
"--main-gpu", "-mg"}, a):
|
||||
j++ // and its value
|
||||
case strings.HasPrefix(a, "--n-gpu-layers=") || strings.HasPrefix(a, "--split-mode=") ||
|
||||
strings.HasPrefix(a, "--tensor-split=") || strings.HasPrefix(a, "--main-gpu="):
|
||||
default:
|
||||
out.Args = append(out.Args, a)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Stop takes a model out of service and keeps it: none of its replicas run,
|
||||
// and what it was scaled to is kept for Start.
|
||||
func (s *Service) Stop(ctx context.Context, name, by string) (catalog.Model, error) {
|
||||
|
||||
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user