Compare commits
206
Commits
No files matched your search
@@ -34,7 +34,11 @@ jobs:
|
||||
run: go vet ./...
|
||||
|
||||
- name: Test with the race detector
|
||||
run: go test -race ./...
|
||||
# Under the job's limit, so a package that hangs panics with the
|
||||
# test it was in and where every goroutine waited, rather than the
|
||||
# job ending at its deadline with nothing said: from 2026-10-15 every
|
||||
# run did, printing nothing after internal/release.
|
||||
run: go test -race -timeout 12m ./...
|
||||
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
|
||||
@@ -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
|
||||
@@ -9,3 +9,5 @@ coverage.out
|
||||
/terraform-provider-llmgw/terraform-provider-llmgw
|
||||
/terraform-provider-llmgw/examples/.terraform*
|
||||
/terraform-provider-llmgw/examples/terraform.tfstate*
|
||||
# What a development server run from the checkout writes beside it.
|
||||
/secrets/
|
||||
+897
-15
@@ -15,6 +15,65 @@ Conventions this codebase keeps, which every item here must keep too:
|
||||
- `go test ./...`, `go vet ./...` and `govulncheck ./...` pass. No release is
|
||||
cut and nothing is deployed to the PoC cluster without being told to.
|
||||
|
||||
## Open, in order (2026-10-20)
|
||||
|
||||
What is left, most needed first. The detail is in the item each names.
|
||||
|
||||
**Now: what goes wrong where it is installed.**
|
||||
1. ~~**J8**~~: done 2026-10-20. `ctl guard judge check`, and init times the judge. Left of **J3**: its load test
|
||||
against judges on the control plane, which was stopped half way. Judges on a 4-core node of their own refused
|
||||
0/60 at 2 at once, 13/60 at 4 and 42/60 at 10.
|
||||
2. ~~**I2 P2**~~: done 2026-10-20: the guardrails both ways, a rate per caller and server, a breaker. A portal page
|
||||
for the servers is left, with P3 (stdio, per-person OAuth).
|
||||
3. **K**: fixes from the fabric test the operator runs with docs/large-models.md.
|
||||
|
||||
**Soon: the customer's experience, and HA.**
|
||||
4. ~~**C24**~~: done 2026-10-21. The store's, catalog's and mirror's errors, percent signs, footer times and stale
|
||||
forms are in the page's language. Left: the /docs documents, and a language kept per person (cookie only today).
|
||||
5. ~~**J4**~~: done 2026-10-29. The done-criterion held on the PoC: with mirror 146 stopped, `ctl model serve` of a
|
||||
new model was fetched by the second mirror (VM 167, 10.0.0.152), placed and answered; a node without a file
|
||||
146's address names was given 152's; what was added meanwhile reached 146 when it came back; a removal was
|
||||
carried both ways. Found and fixed there: add-peer on a mirror machine of its own, files without checksums never
|
||||
copied, and `mirror remove` neither recorded nor seen by the running mirror.
|
||||
6. **F5**: large imports. Done 2026-10-29: the gateway embeds what `llmgw-kb` sends as text; the slots and
|
||||
halfvec were done before. Left: a larger store machine before a million chunks (the operator's), and the
|
||||
embedding model on a GPU for a real import.
|
||||
|
||||
**From Kong** (section L): L1–L5 and L9 done (v0.15.0); left of L4 is routing by cost between outside
|
||||
providers, of L5 cache-write and audio prices. L6 and L7 are llmgw-80's; L8 (A2A) when asked.
|
||||
|
||||
**Models we can trust** (section M, 2026-10-25): M1 done (pickles refused; the explorer lists only what this
|
||||
cluster's hardware holds, with CPU and GPU tok/s). Next M2 (every download pinned and checked against Hugging
|
||||
Face, 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.
|
||||
8. **Audit before and after**: what a change changed, and changes made with the CLI recorded too.
|
||||
|
||||
**When there is room.**
|
||||
9. ~~**I1**~~: done 2026-10-25 (in memory, not pgvector); left: the 40-rewordings run on the PoC.
|
||||
10. **H1**: tried on the PoC.
|
||||
11. **C4**: Parquet, and a scheduled push for usage export.
|
||||
|
||||
**When needed.**
|
||||
12. **H5**: licences in video and audio. 13. **H3**: replicas shared, the catalog side. 14. **J7**: nginx in front
|
||||
of several control planes. 15. **J6**: rate limits per process (a note).
|
||||
|
||||
**Done since 2026-10-16:**
|
||||
- J1, J5, I3, I4, J2.
|
||||
- Active Directory and Entra ID, tried against a Samba DC, with the console's Sign-in screen.
|
||||
- J3's code.
|
||||
- K's code and docs/large-models.md.
|
||||
- I2 P1 and the cluster's own read-only MCP server (llmgw:admin).
|
||||
- The node console's Overview; a node's page showing its version and the models on each GPU.
|
||||
|
||||
**Not code, the operator's to decide or do:**
|
||||
- Where Alertmanager sends (its receiver is "none"): with a judge down, nobody hears.
|
||||
- The fabric test for K.
|
||||
- The PoC runs a dev build of main (v0.15.0-7-ga583359) on its control planes and nodes, the mirror v0.15.0.
|
||||
|
||||
---
|
||||
|
||||
## A. Security — do first
|
||||
@@ -386,10 +445,9 @@ portal is not done — the page shows; the console and ctl change.
|
||||
tenant-admin / billing; `ctl audit`. Depends on A5 for a real actor.
|
||||
2. **Done (branch `features-c`):** OIDC sign-in (`auth.oidc`, PKCE,
|
||||
verified email, roles and memberships from groups, `no_passwords`) and
|
||||
SCIM 2.0 (`/scim/v2` Users and Groups). Left: the sign-in cookie is
|
||||
signed with a per-process key, so with two control planes behind a
|
||||
balancer the callback must reach the one that started it (sticky
|
||||
sessions) until the key comes from the store. **OIDC/SSO login (M), then SCIM (M).** `internal/account/service.go`,
|
||||
SCIM 2.0 (`/scim/v2` Users and Groups). The sign-in cookie's key comes
|
||||
from the store (2026-10-10), so a callback may reach either control
|
||||
plane behind a balancer. **OIDC/SSO login (M), then SCIM (M).** `internal/account/service.go`,
|
||||
`internal/web/account.go`; map IdP groups to org/team/admin.
|
||||
3. **Done (branch `features-c`):** key lifetime limits (gateway, org, team,
|
||||
project), `keys rotate --overlap`, kinds and expiry in `keys list`.
|
||||
@@ -405,8 +463,9 @@ portal is not done — the page shows; the console and ctl change.
|
||||
lost, budget spent, update available; signed JSON or Slack; retries,
|
||||
hourly de-duplication), `ctl notify test`, Alertmanager receiver
|
||||
templates. Left: `server init` and the console do not yet ask for a
|
||||
receiver — the templates are filled in by hand; each control plane sends
|
||||
budget events on its own, so two behind a balancer may both send one.
|
||||
receiver — the templates are filled in by hand. Events are claimed in
|
||||
the store before they are sent (2026-10-10), so two control planes send
|
||||
each once.
|
||||
**Alerting out of the box (S).** `deploy/monitoring/alertmanager.yml`
|
||||
ships `receiver: none`; add Slack/email/webhook/PagerDuty receiver
|
||||
templates driven by `server init` or the console; llmgw-native webhooks
|
||||
@@ -1073,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.
|
||||
@@ -1091,19 +1156,836 @@ at a third of its CPUs.
|
||||
FlashAttention and ubatch 1–2k; f16 no better. vLLM fp16 (`--runner
|
||||
pooling`) 6,200 direct, 4,700–4,900 through the gateway in the import,
|
||||
and its vectors match llama.cpp's (cosine 0.9995). The 103,579 chunks
|
||||
took 3 h. Wanted: an embedding model's GPU profile on vLLM and CPU
|
||||
profile on llama.cpp, which needs a source per profile (the catalog has
|
||||
one per model; the import swapped the entry by hand), and an admission
|
||||
queue limit that suits bulk imports (30 s turned some away).
|
||||
took 3 h. Done (2026-10-10, branch `admission-bulk`): a source per
|
||||
profile, so an embedding model's GPU profile runs vLLM and its CPU
|
||||
profile llama.cpp; a band's own `queue_ttl` for bulk imports, and
|
||||
`Retry-After` on a busy refusal, which is what had turned passages away
|
||||
(llmgw-kb retries only what says when to).
|
||||
- Retrieval on 40 questions written from the corpus, document in the top
|
||||
5: bge-m3 31/40 (Turkish questions 13/21), Qwen3-Embedding-4B 37/40
|
||||
(18/21), with its query prefix 36/40 and first place 31/40 instead of
|
||||
28 (MRR 0.738 → 0.789 → 0.838). Next: translating the question with the
|
||||
chat model, and a reranker.
|
||||
|
||||
## G. Jobs: 3D generation through ComfyUI (2026-10-10)
|
||||
|
||||
A request for a 3D model takes minutes on the one 24 GB card, and the card has to be
|
||||
emptied of the chat model first. That is not a request to hold a socket for
|
||||
— `internal/router/activate.go:46` already says so — it is a job: queued,
|
||||
answered with an id, asked after, its result downloaded. The job machinery
|
||||
is built first and tested without a GPU; the ComfyUI generalisation and the
|
||||
custom-node install come after. Decided 2026-10-10: a `jobs` table of its
|
||||
own rather than a one-line batch (the JSONL shape would be artificial and
|
||||
has nowhere for progress), and progress is shown, not just a status.
|
||||
|
||||
Which models (decided 2026-10-10): the assets go into a game sold on
|
||||
Steam, worldwide, the EU included, so the licence of the output decides
|
||||
before the quality does. Hunyuan3D (every version) is out: Tencent's
|
||||
licence excludes the EU, UK and South Korea, and whether a game shipped
|
||||
there with its outputs is "use in the Territory" is not a question to
|
||||
leave to a court. In, in this order: **TRELLIS.2** shape (MIT; the best
|
||||
geometry; its own texture baking goes through nvdiffrec and nvdiffrast,
|
||||
both under NVIDIA's non-commercial source licence, so that stage is not
|
||||
run), **Step1X-3D** (Apache 2.0, StepFun; shape and texture in one, the
|
||||
EU-safe textured path — its ComfyUI node and VRAM need are to be
|
||||
checked), **Direct3D-S2** (MIT, DreamTech; high-detail shape, has a
|
||||
ComfyUI node) if TRELLIS.2's detail falls short. Not in: TripoSR and
|
||||
InstantMesh (permissive but superseded), Stable Fast 3D (Stability's
|
||||
community licence, revenue-capped; served all the same since 2026-10-10
|
||||
at the user's request, see G3), SAM 3D Objects (Meta's own licence,
|
||||
to be read before it is considered). Steam's disclosure form (changed
|
||||
2026-01-16) lists pre-generated 3D models as content that must be
|
||||
declared on the store page: the licence notice G3 writes into the GLB is
|
||||
what the studio answers that form from.
|
||||
|
||||
### G1. Jobs: store, worker, endpoints (M) — done 2026-10-10 (branch `jobs-g1`)
|
||||
Built as written, with two choices: a job found running after its gateway
|
||||
died is run again from the start (the prompt id column is there for G2 to
|
||||
follow the same work instead), and the request is validated at the door
|
||||
only as far as costs nothing — key, model, allowlist, the request's shape
|
||||
— with limits, guardrails and admission applied when the worker replays it,
|
||||
said as the job's error. The result file's `content_type` is the job's; the
|
||||
files endpoint still serves every file as an octet stream.
|
||||
- Store: `jobs` (id, owner key/principal, kind, model, status, input
|
||||
(JSON), progress (JSON: queue position, percent, step), result file id,
|
||||
error, created/started/finished, lease owner/until), migrations for
|
||||
sqlite and postgres beside `internal/store/batches.go`, whose
|
||||
claim/renew/release lease pattern is copied, not shared: a job is one
|
||||
unit of work, a batch is many lines.
|
||||
- Worker: `internal/router/jobworker.go` after `batchworker.go`. Any
|
||||
gateway on the store claims a queued job, asks for the model with
|
||||
`activator.Want` and waits as long as it takes (no `ActivationWait`
|
||||
bound: nobody is holding a connection), runs it, stores the output
|
||||
through the files store, writes usage and price, marks it done. A
|
||||
gateway that dies mid-job loses its lease and another one picks it up;
|
||||
the ComfyUI prompt id is in the job row so the second gateway asks
|
||||
after the same work rather than queueing it again.
|
||||
- Endpoints: `POST /v1/3d/generations` validates like any inference
|
||||
request (key, allowlist, limits, admission, guardrails on the prompt),
|
||||
writes the job, answers 202 with `{id, status: "queued"}`;
|
||||
`GET /v1/3d/generations/{id}` returns status, progress and, when done,
|
||||
`output_file_id` and the usage; `POST .../{id}/cancel`; the file comes
|
||||
from `GET /v1/files/{id}/content`. Owner-scoped like batches. Results
|
||||
are kept for the batch files' retention and deleted with them: a
|
||||
departure from `images.go`'s "kept nowhere", because a mesh is a
|
||||
download, not a JSON field; the retention is the bound.
|
||||
- Done when: a job survives the gateway that took it being killed, a
|
||||
cancelled job leaves ComfyUI's queue (`comfy.Client.cancel`), two
|
||||
gateways never both run one job (test with the lease), and the usage
|
||||
row carries the job's price.
|
||||
|
||||
### G2. Progress from ComfyUI (S, after G1) — done 2026-10-10 (branch `jobs-3d`)
|
||||
Built as written: the gate's websocket under its own client id, the
|
||||
client_id of every /prompt rewritten, /progress/{prompt_id} on the gate,
|
||||
the router reading it and /queue into the job (only into a job of the same
|
||||
key). Left: a node that reports no steps (TripoSR's) shows its name and no
|
||||
percent; a vanished replica is said by the job failing, not by progress.
|
||||
The gate (`internal/comfy/gate.go`) passes seven paths and no websocket.
|
||||
Rather than open `/ws`, the gate subscribes to ComfyUI's own `/ws` once,
|
||||
with `golang.org/x/net/websocket` (already a dependency), under a client
|
||||
id of its own, and rewrites the `client_id` of every `/prompt` it
|
||||
forwards to that one. It keeps the last `executing`, `progress`
|
||||
(value/max/node) and `execution_error` message per prompt id and serves
|
||||
them at `GET /progress/{prompt_id}` on the gate; the router polls that
|
||||
beside `/history` and `/queue` and writes queue position, percent and the
|
||||
current node's class into the job's progress. The gate's buffer is
|
||||
bounded (the last N prompts) and the websocket reconnects. Done when a
|
||||
3D job shows "queued, 2 ahead" then "running 40%, step 12/30" in
|
||||
`ctl jobs` and on the account page, and a job whose replica vanished
|
||||
says so within one poll.
|
||||
|
||||
### G3. ComfyUI beyond pictures: 3D models and custom nodes (M, after G1) — done 2026-10-10 (branch `jobs-3d`), with TripoSR
|
||||
Built: output kinds, image upload through the gate, every output list
|
||||
fetched, OBJ to GLB, licence in /v1/models, the job and the GLB, per_mesh
|
||||
and usage.meshes (migration 0045), custom nodes cloned at their commit by
|
||||
the gate with packages beside the replica (a node's named packages at its
|
||||
range, the rest at ComfyUI's versions). Not as written: the model. On the
|
||||
PoC's Turing card TRELLIS.2 cannot run — it needs Meta's gated DINOv3,
|
||||
its published background remover (RMBG-2.0) is non-commercial, and its
|
||||
sparse attention and Direct3D-S2's need flash-attention (Ampere and
|
||||
newer). TripoSR (MIT) proves the chain on cuda01: a .glb from a picture
|
||||
in about 30 s. Left: background removal before TripoSR (BiRefNet, MIT,
|
||||
as a node), TRELLIS.2 or Direct3D-S2 on an Ampere card with an ungated
|
||||
encoder, a missing node refusing the replica by name.
|
||||
Added 2026-10-10 at the user's request, beside the order above: **Stable
|
||||
Fast 3D** as `sf3d` (Stability AI Community License: free under US$1 M
|
||||
annual revenue, an enterprise licence above, "Powered by Stability AI"),
|
||||
the textured mesh that runs on Turing: about 1 s a mesh warm in fp16,
|
||||
UV-mapped GLB with its texture, 18 GB of the card at peak. Its weights are
|
||||
gated, so they were fetched once with the user's token into
|
||||
/srv/llmgw-comfy/stable-fast-3d and linked into the engine user's
|
||||
Hugging Face cache, which its loader node reads by repository name; no
|
||||
token is on the machine at runtime. Its own save node returns the GLB as
|
||||
base64, which the gate cannot fetch, so `LLMGWSaveMesh` writes the file;
|
||||
`LLMGWPrepareForMesh` now also gives the object's mask. Left: its two
|
||||
CUDA extensions (texture_baker, uv_unwrapper) are not on PyPI, so they
|
||||
were built on cuda01 and installed into the engine's venv by hand; a
|
||||
custom node's requirements need a way to name a local build, or the
|
||||
engine install must carry them, before a customer site gets this. And
|
||||
rembg's default session now fetches BRIA RMBG-2.0 (non-commercial):
|
||||
every workflow must name its rembg model (ours say u2net).
|
||||
- Catalog: a model makes `pictures` or `meshes` (`MakesImages` becomes an
|
||||
output kind; `ServesComfyUI` stays). 3D weight folders in the gate's
|
||||
`modelFolders`; the workflow's placeholders gain `image` (the input
|
||||
picture, base64 in the request, written to the replica's `input/` by
|
||||
the gate through a new `POST /upload/image` the gate allows — or a
|
||||
base64-loading node, whichever TRELLIS.2's wrapper wants).
|
||||
- `comfy.Client.fetch` reads every output key, not `images` only, and
|
||||
fetches each file named by `/view`; `Image` becomes `Artifact` with its
|
||||
content type (model/gltf-binary, application/octet-stream for .obj).
|
||||
- Custom nodes: a model (or the engine config) names git repositories
|
||||
and a pip requirements list; the agent's provision step clones them
|
||||
into the replica's `custom_nodes/` (the gate's `--base-directory` is
|
||||
where ComfyUI looks, not the install directory) and installs the
|
||||
requirements into the engine's venv with the same PyTorch index and
|
||||
lock discipline as A8. A replica with a missing node refuses to start
|
||||
with the node's name in the error rather than ComfyUI's traceback.
|
||||
- Licence of the output (decided 2026-10-10: it matters). A catalog model
|
||||
carries `license: {name, url, notice}`; a job's answer, `/v1/models`
|
||||
and the portal page show it beside the result, and the mesh's GLB
|
||||
`asset.extras` carries the notice too, since the file travels without
|
||||
the API. What was checked on 2026-10-10: TRELLIS.2 weights are MIT and
|
||||
its outputs free, but its texture baking depends on nvdiffrast, whose
|
||||
NVIDIA Source Code License is non-commercial — a commercial service
|
||||
must render textures another way or skip that stage. Hunyuan3D 2.0 and
|
||||
2.1 claim no rights in outputs, but the licence excludes the EU, UK and
|
||||
South Korea, needs Tencent's permission above 1 M monthly active users,
|
||||
forbids using outputs to train other models, and asks for "Powered by
|
||||
Tencent Hunyuan"; the notice ships with any distribution. TripoSR is
|
||||
MIT, InstantMesh Apache 2.0. Every one of them trained on Objaverse,
|
||||
some of whose objects are CC-BY-NC: a provenance question no licence
|
||||
text settles, to be said in the notice rather than hidden.
|
||||
- Pricing: `per_image` stays; `per_mesh` beside it in `accounting.Price`,
|
||||
`Outcome.Meshes`, the usage sums and exports (as eaea671 did for
|
||||
pictures).
|
||||
- Done when: TRELLIS.2 on cuda01 with `scaling: {min: 0,
|
||||
idle_timeout: 10m}` makes a .glb from a picture through G1's endpoint
|
||||
with nvdiffrast and nvdiffrec not installed in its venv (the licence
|
||||
is kept by the install, not by a note), the GLB carries the MIT
|
||||
notice,
|
||||
the card is free again after the idle timeout, and the same request
|
||||
against flux-schnell is refused as "makes pictures, not meshes".
|
||||
|
||||
### G4. Portal and console (S, after G2) — done 2026-10-10 (branch `jobs-3d`)
|
||||
Built: /3d with model-viewer served from the gateway, progress, download
|
||||
and licence; `ctl 3d list|get|cancel` over /api/v1/client-jobs (ctl jobs
|
||||
was taken by the control plane's own work). Left: the console's Jobs view.
|
||||
A 3D page beside `/images` (upload a picture, watch progress, download
|
||||
the .glb, a viewer with `<model-viewer>` from the allowed CDNs), `ctl
|
||||
jobs list|get|cancel`, the console's Jobs view. The page sends down the
|
||||
same path as the API, like `imagesSend` does.
|
||||
|
||||
## H. ComfyUI as a platform (written 2026-10-11)
|
||||
|
||||
G made one model at a time work: flux for pictures, TripoSR and SF3D for
|
||||
meshes, each with its own endpoint, its own request shape and its own
|
||||
replica. The user's direction (2026-10-11): llmgw is a generic platform,
|
||||
and should serve whatever ComfyUI can run — pictures, edits, video,
|
||||
audio, meshes, upscaling, segmentation — as data in the catalog rather
|
||||
than code per model. Two decisions shape everything below:
|
||||
|
||||
- **The licence is metadata, not a filter.** llmgw does not leave a
|
||||
model out because of its licence. The licence travels with the model
|
||||
and with every file it makes; what a site will not serve is that
|
||||
site's policy (H6). The game studio's EU constraint is one such policy,
|
||||
not a decision the code makes for everyone.
|
||||
- **The install fits the hardware.** The PoC has one Turing card; other
|
||||
sites will have Ampere, Ada and Hopper. A catalog entry must be
|
||||
writable once and placed sensibly anywhere: fp16 on Turing, bf16 or
|
||||
fp8 where the card has them, flash-attention models only where it
|
||||
runs, CUDA extensions built for the card that runs them (H2, H4).
|
||||
|
||||
The first set of models proves the structure at its corners rather than
|
||||
by licence: Qwen-Image-Edit (several weight files, two pictures in),
|
||||
Wan 2.2 small (video out, a long job), ACE-Step (audio out), SF3D (a
|
||||
package not on PyPI, gated weights), Hunyuan3D (a licence that excludes
|
||||
the EU, which H6 must be able to say no to).
|
||||
|
||||
### H1. A workflow says what it takes and makes (M) — built 2026-10-11, not yet tried on the PoC
|
||||
Built as written, with three choices: a run answers with one file, the
|
||||
first of its output kind (several outputs as one zip is left); the
|
||||
workflow's placeholders are checked against the inputs when a run is
|
||||
served, since the catalog never reads the workflow file; and video,
|
||||
audio and files count as nothing in usage until a per-run price exists
|
||||
(per_image and per_mesh still count pictures and meshes).
|
||||
- Catalog: `comfyui.inputs` — a list of `{name, type, required, default,
|
||||
min, max, choices, max_mb, doc}`, type one of text, int, float, bool,
|
||||
choice, image, mask, audio, video, file. Each is a placeholder of the
|
||||
same name in the workflow. The template's own placeholders are checked
|
||||
against the list when the catalog is built: one the workflow uses that
|
||||
no input or built-in names is refused there, not at the first request.
|
||||
- `comfyui.output` gains `video`, `audio` and `files` beside `pictures`
|
||||
and `meshes`; `files` takes whatever the workflow saves. `comfy.Image`
|
||||
becomes `comfy.File` and `typeByName` knows mp4, webm, gif, wav, flac,
|
||||
mp3, ogg.
|
||||
- Endpoint: `POST /v1/workflows/runs` with `{model, inputs: {...}}`, a
|
||||
job like G1's (`GET /v1/workflows/runs[/{id}]`, `.../cancel`). A file
|
||||
input is base64, a data URL, or a `file-…` id from `/v1/files` for
|
||||
what is too big to send inline (video, long audio). Files go into the
|
||||
replica's input directory through the gate's `/upload/image`, which
|
||||
ComfyUI uses for any file. `/v1/images/generations` and
|
||||
`/v1/3d/generations` stay as they are, as fixed shapes over the same
|
||||
machinery.
|
||||
- The models list (`/v1/models`) gives each ComfyUI model its inputs and
|
||||
output kind, so a client can build a form without reading the catalog.
|
||||
- Done when: the existing flux, triposr and sf3d entries work unchanged,
|
||||
and one model with a picture and a text input (Qwen-Image-Edit) runs
|
||||
through `/v1/workflows/runs` with a 400 for a missing required input
|
||||
before any job is written.
|
||||
|
||||
### H2. Profiles that fit the hardware (M) — built 2026-10-11
|
||||
Built as written. A profile by need is left out of placement until the
|
||||
cluster's cards are known: with no class, placement would have read it as
|
||||
a CPU profile. A profile's own `source` is loaded under its own file name
|
||||
by the workflow's `{{checkpoint}}`.
|
||||
- A profile may say `gpu: {min_compute_capability, min_memory_gb}` in
|
||||
place of `gpu_class`. When the reconciler places a model, such a
|
||||
profile stands for every GPU class in the cluster that meets it, at
|
||||
the profile's priority: the catalog function that turns profiles into
|
||||
placement's view takes the cluster's classes (class, compute
|
||||
capability, memory) and returns one concrete profile per class that
|
||||
fits, named `<profile>@<class>`. Placement itself is unchanged: it
|
||||
still sees exact classes, and a tensor-parallel group still never
|
||||
mixes them.
|
||||
- A ComfyUI profile may carry its own `comfyui` block (workflow, nodes,
|
||||
checkpoint) over the model's: the fp8 workflow on compute 8.9 and up,
|
||||
the fp16 one on 7.5. A Turing card is not an error, it is the profile
|
||||
that fits it.
|
||||
- Done when: one catalog entry with an "ampere+" profile (min 8.0) and a
|
||||
"turing" fallback places the fallback on cuda01, and the same entry,
|
||||
in a test with an A100 in the inventory, places the other.
|
||||
|
||||
### H3. Environments: which models can share a replica (S, the catalog side)
|
||||
- `comfyui.environment: <name>` puts a model in a named environment.
|
||||
Models in one environment must agree on every custom node they both
|
||||
name (same repository, same commit) and their requirements must
|
||||
resolve together; the catalog refuses an environment that cannot, with
|
||||
both models' names. Models with no environment are each their own, as
|
||||
today.
|
||||
- `Catalog.Environments()` returns each environment with its models and
|
||||
the union of their nodes: what one replica of it must install.
|
||||
- Placement and eviction of a replica that serves every model of its
|
||||
environment (so flux, SF3D and Qwen-Image-Edit stop handing the card to
|
||||
each other with a 30 s cold start each time) is the other session's
|
||||
work (llmgw-b7), built on this.
|
||||
|
||||
### H4. Custom nodes with packages that are built, not downloaded (S) — built 2026-10-11
|
||||
Built as written, with `uv build --no-build-isolation` against ComfyUI's
|
||||
Python, the card's capability asked of torch in the replica (so
|
||||
CUDA_VISIBLE_DEVICES is the card it will run on), and wheels kept under
|
||||
the engines' cache by commit, `sm<arch>` and directory. Also
|
||||
`comfyui.huggingface_repo`: a node that loads its weights by repository
|
||||
name (SF3D's) finds them in the engines' Hugging Face cache, linked by the
|
||||
gate, so no token is on the node.
|
||||
- `nodes[].build: [subdir, ...]`: directories of the node's repository
|
||||
that are Python packages with C++/CUDA extensions (SF3D's texture_baker
|
||||
and uv_unwrapper). The gate builds each as a wheel with the engine's
|
||||
Python, `TORCH_CUDA_ARCH_LIST` set from the compute capability of the
|
||||
card it runs on, and installs it into the replica's own `site` like
|
||||
any other requirement. Built wheels are kept under the engine's home by
|
||||
node commit, subdirectory and architecture, so the second replica on
|
||||
the same kind of card does not build again.
|
||||
- A node needing nvcc on a node without it refuses the replica with that
|
||||
said, not with a compiler's traceback.
|
||||
- Done when: SF3D runs on cuda01 with nothing installed into the engine's
|
||||
venv by hand (the hand install of 2026-10-10 removed first).
|
||||
|
||||
### H5. Licence on every output (S) — done 2026-10-14: the whole licence in GLB asset.extras and PNG iTXt; video and audio not yet
|
||||
- `License` gains `id` (SPDX where there is one, else a lowercase name:
|
||||
stability-community, tencent-hunyuan-community), `commercial` (unset is
|
||||
not said), `excluded_regions` (ISO 3166 codes, `EU` for the union),
|
||||
`revenue_cap_usd` and `attribution` ("Powered by Stability AI"). All
|
||||
optional but the name. Done 2026-10-11 in the catalog, so H6 can match
|
||||
on `id`.
|
||||
- Written into the file where the format has a place for it: glTF
|
||||
`asset.extras.license` (as now), PNG an `iTXt` chunk named `license`
|
||||
(the workflow's own text chunks are still stripped), MP4/WebM and the
|
||||
audio formats in their metadata where ffmpeg is at hand on the router,
|
||||
otherwise beside it in the job's answer only — said in the docs.
|
||||
|
||||
### H6. Licence choices (S) — done 2026-10-11 (4bbac08), live on the PoC
|
||||
The user asked (2026-10-11) for the choice to be a person's: on their
|
||||
profile they pick the licences they will not use, and their keys and
|
||||
portal session neither see those models in `/v1/models` nor may call
|
||||
them (403, saying which licence). Matched on `license.id`, and on
|
||||
`commercial` / `excluded_regions` where a choice is put that way. Its
|
||||
store table is migration 0046. A site-wide or team-wide default can sit
|
||||
on top of it later.
|
||||
|
||||
### H7. A job worker per model, not one for all (S) — done 2026-10-14: one job per model at a time, eight in all
|
||||
The worker runs one job at a time: a job waiting for the card holds up a
|
||||
job for another model that could run now (seen on the PoC, 2026-10-10).
|
||||
Jobs are claimed and run concurrently up to a limit per model (or per
|
||||
environment, after H3) and a cluster-wide cap, with the lease and
|
||||
leader-only rules of G1 kept.
|
||||
|
||||
### H8. Presets that add themselves (S) — done 2026-10-13 (v0.14.5)
|
||||
flux-schnell and triposr come with llmgw (`ctl model add <preset>`), and
|
||||
the leader adds them on demand where a node fits one, the cluster installs
|
||||
ComfyUI and the catalog makes nothing of that kind (`presets.auto`); a
|
||||
preset removed is declined and not added back. The cluster page and the
|
||||
console show and add them.
|
||||
|
||||
### H9. Small things learned on the PoC (2026-10-14)
|
||||
- Done: `albedo_match` on sf3d (its delighting made dark objects light);
|
||||
`on_demand.evict_after_idle` / `min_lifetime` as live settings; an
|
||||
upgrade that leaves nodes behind says which; `ctl model serve` serves an
|
||||
embedding model for embedding; refreshes wait while a form is filled in.
|
||||
- Done 2026-10-15: llmgw's `/mcp` with picture, 3D, VLM and workflow tools,
|
||||
so a client needs no server of its own (asked by the game team); the
|
||||
shared ComfyUI replica (H3's placement side).
|
||||
- Done 2026-10-16 (v0.14.6, v0.14.7): setup lists every part with what it
|
||||
is and its settings; three machines each run the control plane, with a
|
||||
floating address (VRRP id from the address); a lost store primary costs no
|
||||
request (3 s connect per member, keepalive and TCP_USER_TIMEOUT, Patroni
|
||||
ttl 20, a standby leaves the lease alone a round after the store returns);
|
||||
a new leader waits for its judge within the budget; a judge is asked again
|
||||
on another machine at half its budget; init sizes the judge (2 GB, no
|
||||
prompt cache, two cores, one judge on up to four cores on one machine).
|
||||
|
||||
## I. What other gateways do that llmgw does not (written 2026-10-16)
|
||||
|
||||
From a comparison with Bifrost (maximhq/bifrost, Apache 2.0, Go), with
|
||||
LiteLLM and Portkey in mind. Bifrost routes to 23+ cloud providers and runs
|
||||
no model; llmgw runs the models, so most of Bifrost is either in llmgw
|
||||
already (keys, budgets, fallbacks, guardrails, tracing, SSO, HA — the last
|
||||
three paid in Bifrost) or does not apply. These four are worth taking for
|
||||
a site that runs its own models. Left until a customer asks: native
|
||||
Azure/Anthropic/Bedrock/Vertex adapters, request hooks a customer plugs in,
|
||||
routing by what a request holds. Not taken: a realtime (WebSocket audio)
|
||||
API, prompt management, an `npx` install.
|
||||
|
||||
### I1. Answers to similar questions, not only the same one (M) — done 2026-10-25
|
||||
`keys cache --similar`, `router.cache.embedding_model`, `internal/router/similar.go`. Changed
|
||||
from the plan: the vectors are in the gateway's memory beside the exact answers, not a pgvector
|
||||
table. The exact cache keeps answer text out of the store on purpose (data.md), and a table of
|
||||
answers would undo that; it also works on SQLite. The usage row marks a similar hit as a hit
|
||||
(no column of its own). Open: the 40-rewordings and eval-suite runs on the PoC.
|
||||
The original item follows.
|
||||
The answer cache (`internal/router/cache.go`, C17) answers a request whose
|
||||
body is the same byte for byte. A help desk asked "how do I reset my
|
||||
password" in forty wordings asks the model forty times.
|
||||
- Per project, off by default, beside the exact cache: `llmgw keys cache
|
||||
--project P --similar 0.95` (the cosine similarity a question must reach;
|
||||
higher is stricter). The exact cache is asked first.
|
||||
- The question embedded by a model the operator names
|
||||
(`router.cache.embedding_model`, an embedding model in the catalog), into a
|
||||
pgvector table per project with the answer, the model and the TTL; the
|
||||
store's halfvec and text index work from F applies. SQLite stores keep the
|
||||
exact cache only.
|
||||
- What counts as the question: the last user turn, with the system prompt
|
||||
and the earlier turns hashed and required to match exactly — a similar
|
||||
question in another conversation is not the same question. Tools,
|
||||
`response_format` and the sampling settings also exact.
|
||||
- Said: `x-llmgw-cache: similar` with the similarity, and the usage row
|
||||
says so; guardrails judge the request before a hit, as for the exact
|
||||
cache. A project with `no_store` gets neither.
|
||||
- Done when: forty rewordings of one question on the PoC cost one model call
|
||||
and 39 lookups, and an eval suite run with the cache on scores within a
|
||||
point of the run with it off.
|
||||
|
||||
### I2. MCP gateway: the customer's tool servers behind llmgw (L)
|
||||
P1 done 2026-10-20: HTTP servers registered (`ctl mcp`, `/api/v1/mcp/servers`,
|
||||
store `mcp_servers`), tools as `server.tool` in `/mcp`, `allowed_tools`
|
||||
grants in the tree (deny by default), credentials in the store or by
|
||||
reference, the caller named in `X-Llmgw-*` headers, time and size bounds,
|
||||
progress passed on, audit (Source `mcp`) and metrics. Left: P2, the
|
||||
guardrails both ways, a per-tool rate limit, a breaker, and a portal page;
|
||||
P3, stdio servers and per-person OAuth. The "done when" with Claude Code
|
||||
against a real server is still to try.
|
||||
`/mcp` serves llmgw's own tools. An agent inside a company also wants Jira,
|
||||
the file server, a database — each an MCP server, each today configured
|
||||
in every client with its own credentials and nobody seeing what was called.
|
||||
- Servers registered once, by an administrator: `llmgw ctl mcp add jira
|
||||
--url https://… --header-file /etc/llmgw/secrets/jira` (HTTP transport;
|
||||
stdio servers run on the control plane under their own user, like an
|
||||
engine). Their credentials stay on the control plane, never in a client.
|
||||
- `/mcp` lists every server's tools, prefixed by its name (`jira.search`),
|
||||
filtered by what the key may use: an allowlist per org, team, project or
|
||||
key, by server or by tool, in the tenancy tree as model allowlists are.
|
||||
- Each call goes to the audit log — who, which tool, the arguments' size,
|
||||
the outcome and how long — and through the guardrails both ways (a tool's
|
||||
answer is prompt input). A per-tool rate limit.
|
||||
- Later, if it earns it: Bifrost's "code mode" (the tools as one typed API
|
||||
the model writes code against), which cuts the tokens of a long tool list.
|
||||
- Done when: Claude Code given only llmgw's `/mcp` reaches a test MCP server
|
||||
through it, a key without the grant does not see the server's tools, and
|
||||
every call is in `ctl audit`.
|
||||
|
||||
### I3. What llmgw adds to a request, measured (S) — done 2026-10-16
|
||||
`llmgw ctl bench overhead` (`internal/cli/bench_overhead.go`): an instant
|
||||
engine and the gateway in one process, four modes (direct, bare, auth, auth
|
||||
and guard patterns) at rising rates. On an Apple M5 the gateway added
|
||||
0.05–0.42 ms at p50 and at most 0.49 ms at p99, in the README.
|
||||
`TestTheGatewaysOverheadIsMeasured` runs a second of it in CI with no bound
|
||||
asserted. Left: the CPU figure is rough with everything in one process; a
|
||||
gateway in a process of its own would measure it properly. The original
|
||||
item follows.
|
||||
|
||||
Bifrost's headline is "11 µs at 5k RPS" (its own figure, and its own pages
|
||||
disagree with it). llmgw has none, so any question about its overhead is
|
||||
answered with a guess.
|
||||
- `llmgw ctl bench --overhead`: the same load through the gateway and straight
|
||||
at a `sim-engine` (`internal/cli/simengine.go`) that answers at once, at
|
||||
rising request rates, with auth, accounting and the guardrails' pattern
|
||||
checks on and off. Reports the added latency at p50/p99 and the gateway's
|
||||
CPU per thousand requests.
|
||||
- On the PoC the CPU is oversubscribed (pve1, 28 threads for ~125 vCPUs):
|
||||
report it as a ratio and record the host's load beside it, and repeat on
|
||||
an idle machine before a figure goes into the README.
|
||||
- Done when: the README states the overhead, with how it was measured, and
|
||||
CI runs the short version so a change that doubles it is seen.
|
||||
|
||||
### I4. Several keys for one outside provider (S) — done 2026-10-17
|
||||
`external.api_key_files` (`catalog.ExternalKey`: file, weight), one endpoint
|
||||
each in `control.externalEndpoints`, 401 retried on another key and
|
||||
`external_key_refused` notified (`router.keyRefused`). With one key a 401 is
|
||||
still the client's. `TestAKeyAtItsLimitOrRefusedIsPassedOverForAnother`. The
|
||||
original item follows.
|
||||
|
||||
An external model has one `api_key_file` (`internal/control/control.go`,
|
||||
where external endpoints are made). A provider's limit is per key; one key
|
||||
answering 429 makes the whole fallback answer 429.
|
||||
- `api_key_files: [a, b, c]`, each with an optional `weight`: one endpoint
|
||||
per key (`external:<model>#<n>`), so the router's scoring, retries and
|
||||
breakers work per key as they already do per replica. A key answering 429
|
||||
or 401 is passed over while its breaker is open; a 401 also notifies
|
||||
(`external_key_refused`).
|
||||
- The usage row says which key answered (by its index, never the key).
|
||||
- Done when: with two keys and one returning 429, every request is answered
|
||||
by the other, and the console shows the one breaker open.
|
||||
|
||||
## J. Found testing HA and single-machine installs (written 2026-10-16)
|
||||
|
||||
Three VMs installed as HA from the setup screens, a fourth as a judge node,
|
||||
a fifth as a single 4-core, 8 GB machine; machines powered off under load.
|
||||
|
||||
### J1. A restart refused for room another restart was giving back (S) — done 2026-10-16
|
||||
Not a wait for ever, as first written: on the 4-core VM the judge's restart
|
||||
was *refused* ("fits on no node even with this replica stopped") because the
|
||||
starter, restarted at the same time to reserve fewer cores, still held its
|
||||
old ones. Once refused, nothing tried again, so the judges kept their old
|
||||
figures. Two fixes in `internal/control/reconciler`:
|
||||
- a restart whose replacement fits once other restarts under way have gone
|
||||
(queued, retiring or draining) waits for them, up to DrainGrace, instead
|
||||
of being refused (`fitsAfterRestarts`, restart.go);
|
||||
- a drained replica counts a retiring one that still answers as serving, so
|
||||
one restart after another no longer leaves the first's drained replica,
|
||||
and the room it holds, waiting out its grace (reconciler.go).
|
||||
`TestARestartWaitsForRoomAnotherRestartWillGiveBack` is the 4-core case.
|
||||
|
||||
### J2. A judge the leader adds is sized as init's is (S)
|
||||
When a policy names a judge the catalog lacks, the leader serves it itself
|
||||
(`internal/cli/judgekeeper.go` via `knownJudge.serveRequest` in
|
||||
`judges_known.go`, through modelserve). That judge reserves 3 GB (the
|
||||
weights, an 8192-token KV cache and a fixed gigabyte), no cores, and runs
|
||||
with llama.cpp's prompt cache, which grew one to 3.2 GB. init's judge
|
||||
(`guardModel` in `internal/cli/serverinit_cluster.go`) reserves 2 GB and
|
||||
its cores and runs with `--cache-ram 0`.
|
||||
- `modelserve` gives a guard model `--cache-ram 0`, and the keeper's
|
||||
request the same memory and cores rule as `cpuPlan`.
|
||||
- Done when: a judge removed from the catalog comes back with the same
|
||||
figures init would have given it.
|
||||
|
||||
### J3. HA setup puts the judges on nodes of their own (S)
|
||||
Under load on a 4-core control plane that was also a node, 38 of 60
|
||||
requests were refused for want of a judgment. With the judges on a node of
|
||||
their own (4 cores, 32 GB, two cores each) the same test refused 2.
|
||||
- The HA setup offers a judge node or two: machines given like the store's,
|
||||
added as nodes with a label (`llmgw.role: judge`), and the judge's
|
||||
profile selects that label.
|
||||
- Done when: the setup installs the judges on the machines given for them,
|
||||
and the load test above refuses no more than it did by hand.
|
||||
|
||||
### J4. The model mirror on more than one machine (M) — done 2026-10-29, tried on the PoC
|
||||
In an HA install the mirror stays on the first machine. While it is down,
|
||||
models the nodes already have keep serving and new ones wait for it.
|
||||
- A second mirror kept in step (each machine of the store, or one more), the
|
||||
control planes and nodes given both, the first that answers used.
|
||||
- Done when: with the first machine powered off, a model added then is
|
||||
fetched and served.
|
||||
|
||||
### J5. An upgrade corrects an earlier install's judge (S) — done 2026-10-16
|
||||
Built in the judge keeper (`internal/cli/judgekeeper.go`, `refit`), on the
|
||||
leader: a judge whose entry is exactly what init before v0.14.7 wrote is
|
||||
given 2 GB, `--cache-ram 0` and cores by init's rule (`refitCores`: up to
|
||||
four for one judge, two each for more), through `modelsvc.SetProfiles`,
|
||||
and its ready replicas are restarted (after J1). Its replica count is left
|
||||
as it is. One that differs is left alone and said once in the log. The
|
||||
original item follows.
|
||||
|
||||
v0.14.6 and earlier installed two judges reserving no cores or one, with
|
||||
2 or 4 GB and the prompt cache on. On fewer than eight cores every judgment
|
||||
missed its 5 s budget and every request was refused. v0.14.7's notes say
|
||||
how to fix it by hand (`ctl model resources`, then `ctl restart`).
|
||||
- On upgrade, or on `server init` run again, a judge whose profile is still
|
||||
exactly what an earlier init wrote is moved to today's figures and
|
||||
restarted (after J1). One changed by hand is left alone and named.
|
||||
- Done when: a v0.14.6 single-machine install upgraded to it judges within
|
||||
budget without anything typed.
|
||||
|
||||
### J6. Rate limits per process (note)
|
||||
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`.
|
||||
nginx's open-source build has no active health checks, so its `upstream`
|
||||
block would send to standbys and to a lost leader alike.
|
||||
- nginx.conf: the floating address as its one upstream, and why; or the
|
||||
`max_fails` passive approach with what it costs.
|
||||
|
||||
### J8. A judge budget that fits the machine (S)
|
||||
A judgment takes 2.3 s on four cores and 4.2 on two, against the 5 s
|
||||
default: two cores are close to it, and a busy machine passes it.
|
||||
- At install, after the judge is up, time three judgments. If the slowest is
|
||||
more than half the budget, say so in the plan's closing text with the
|
||||
choices (more cores, a GPU judge, a longer `latency_budget`, `fail:
|
||||
open`). The budget itself is not changed without being asked.
|
||||
- Done when: an install on a machine too slow for its judge says so before
|
||||
its first refused request.
|
||||
|
||||
## L. Taken from Kong's AI Gateway (written 2026-10-25)
|
||||
|
||||
From a look at Kong AI Gateway 3.9–3.16 and 2.x. Kong's open source stops at
|
||||
3.9, and only six of its AI plugins are open (proxy, prompt template,
|
||||
decorator, regex prompt guard, request and response transformer); the rest
|
||||
is Enterprise or Konnect. So nothing of Kong is embedded: the two meet over
|
||||
plain OpenAI/Anthropic HTTP, and these are the ideas worth taking. Not taken:
|
||||
prompt compression (Kong ships it as a private LLMLingua sidecar, its benefit
|
||||
unshown), prompt templates and decorators (no one has asked), Konnect's
|
||||
entity model. MCP items are llmgw-80's (I2); they are listed for it.
|
||||
|
||||
### L1. Traces in the OpenTelemetry GenAI conventions (S) — done 2026-10-25
|
||||
`gen_ai.*` on the `inference` and `upstream` spans (`internal/router/trace.go`):
|
||||
operation, request and response model, max_tokens, temperature, top_p, the
|
||||
session as conversation id, input/output/cached tokens, the engine as
|
||||
provider. Never prompt or answer text (`TestTracesSpeakTheGenAIConventionsAndHoldNoText`).
|
||||
Prometheus already counted tokens by direction, cached apart. Left: the MCP
|
||||
calls' spans as `gen_ai.tool.*`, once /mcp is traced at all.
|
||||
|
||||
### L2. Redacted personal data put back in the answer (S) — done 2026-10-25
|
||||
`restore: true` (`guard.Rules.Restore`), `Verdict.Restore`, `internal/router/restore.go`
|
||||
ahead of the output filter. The original item follows.
|
||||
The guardrails rewrite personal data as `[KIND_n]` (`internal/guard/gate.go`,
|
||||
`placeholders`), the same value always the same placeholder. A model that
|
||||
answers "Dear [PERSON_1]" leaves it so. Kong's sanitizer restores.
|
||||
- Per policy, `restore: true`: the placeholders of one request kept in memory
|
||||
for its life, and swapped back in the answer, streamed too (a placeholder
|
||||
split across two chunks held until it is whole).
|
||||
- Only into the answer to the request that held them; never stored, never in
|
||||
usage rows or traces.
|
||||
- Done when: "write to Ayşe Yılmaz at ayse@example.com" through an enforce
|
||||
policy reaches the model as placeholders and the client reads the real name
|
||||
and address back.
|
||||
|
||||
### L3. A guardrail that is someone else's service (S) — done 2026-10-25
|
||||
`guard.Service` (`internal/guard/service.go`), policy `services`, asked
|
||||
before the judge on input and once on the whole answer. The original item follows.
|
||||
Customers may be required to use one: Azure Content Safety, Lakera, NeMo
|
||||
Guardrails, an in-house classifier. Kong's AI Custom Guardrail is a
|
||||
templated HTTP call with an expression for "block".
|
||||
- A detector kind `http`: URL, headers from secret files, a body template
|
||||
with the text, a JSON path that says blocked and one that says why;
|
||||
timeout and `fail` as the judge's. Checked on input, output or both.
|
||||
- Done when: a policy with an `http` detector against a test service blocks
|
||||
what it says to, and a service that does not answer is treated as `fail`
|
||||
says.
|
||||
|
||||
### L4. Routing by latency and by cost, and failover by priority (M) — latency done 2026-10-25
|
||||
`endpoint.Registry.ObserveTTFT`, `scheduler.Latency()` in `chat`. Failover in tiers is
|
||||
what `fallbacks` already does (local, then an outside model when no room). Left:
|
||||
routing by cost between several outside providers. The original item follows.
|
||||
The scorers (`internal/router/scheduler/scorers.go`) know sessions, queues
|
||||
and KV cache. Kong also balances by measured latency, by tokens or cost
|
||||
used, and in priority tiers.
|
||||
- A `latency` scorer: a moving average of time to first token per endpoint.
|
||||
- Fallbacks in tiers: local replicas first, then an outside provider, with
|
||||
the tier said in the usage row.
|
||||
- Done when: with two replicas of one model, one on a slower card, `latency`
|
||||
sends most of the traffic to the faster and the slower still gets some.
|
||||
|
||||
### L5. Prices by kind and by cache (S–M) — done 2026-10-25
|
||||
`cached_per_1k`, charged on the answer's `cached_tokens`. A budget in money was already
|
||||
there (`keys limits --budget`). Not done: cache-write and audio/video token prices —
|
||||
no engine llmgw runs reports them, and no provider it reaches is billed by them yet.
|
||||
The original item follows.
|
||||
`catalog` prices are per million input/output tokens and per picture/mesh.
|
||||
Kong prices cached reads and cache writes apart, and audio, image and video
|
||||
tokens apart, and limits by cost.
|
||||
- Price fields for cached input and by modality; the usage row's cost from
|
||||
them; a budget limit in money as well as tokens (`keys limits --cost`).
|
||||
- Done when: a request with 90% cached prompt costs what the cached rate says.
|
||||
|
||||
### L6. REST APIs as MCP tools (M) — for llmgw-80, beside I2
|
||||
Kong turns OpenAPI operations into MCP tools. A company with an internal
|
||||
REST API and no MCP server could offer it to agents through `/mcp`:
|
||||
`llmgw ctl mcp add hr --openapi https://hr.internal/openapi.json`, each
|
||||
operation a tool, under the same grants, audit, guardrails and rate as I2.
|
||||
|
||||
### L7. MCP sign-in by the MCP spec (M) — for llmgw-80, part of I2 P3
|
||||
Kong's MCP OAuth2: llmgw's `/mcp` as an OAuth 2.1 resource server (protected
|
||||
resource metadata, tokens checked by JWKS against the company's identity
|
||||
provider), and a token exchanged for a short-lived one per upstream server
|
||||
rather than one long-lived secret.
|
||||
|
||||
### L8. A2A (S, when asked)
|
||||
Agent-to-agent traffic through the gateway: agent cards' URLs rewritten to
|
||||
llmgw's, calls audited and traced. The standard is young; wait for a
|
||||
customer.
|
||||
|
||||
### L9. llmgw behind Kong, written down (S) — done 2026-10-25
|
||||
docs/kong.md, tried with kong:3.9: plain route and ai-proxy, keys passed on;
|
||||
Kong 3.9 counts no completion tokens of a stream. The original item follows.
|
||||
A customer that already runs Kong puts llmgw behind it: `ai-proxy` with
|
||||
`provider: openai` and `upstream_url` at llmgw (3.9, open source), or a plain
|
||||
route. A page in docs/ with the configuration, tried against Kong 3.9 in a
|
||||
container, and what Kong's token accounting sees.
|
||||
|
||||
## M. Models from Hugging Face we can trust (written 2026-10-25)
|
||||
|
||||
The user asked how we know a model fetched from Hugging Face is safe. Two
|
||||
questions: whether its files can run code on our machines, which we can
|
||||
close, and whether the model itself was trained to misbehave, which no scan
|
||||
proves — only who published it, an eval before it serves, and the
|
||||
guardrails narrow that. What existed: `--trust-remote-code` refused, no
|
||||
repository Python fetched, ComfyUI nodes pinned to a commit, engines
|
||||
hash-pinned and run as `llmgw-engine`, mirror versions pinned to a commit
|
||||
and never overwritten. What did not: everything below.
|
||||
|
||||
### M1. Weights in a pickle format refused (S) — done 2026-10-25
|
||||
`.bin`, `.pt`, `.pth`, `.ckpt`, `.pkl` run code as they are loaded, which is
|
||||
how the malicious models found on Hugging Face work. The node refuses such a
|
||||
source, or a mirror set holding one, before fetching anything; a model being
|
||||
added or given such a source is refused; vLLM and SGLang downloading a model
|
||||
by its id are given `--load-format safetensors`. The explorer hides a repository with neither GGUF nor safetensors
|
||||
(under "runs here", ticked by default) and does not offer a repository's
|
||||
pickle files. `allow_pickle: true` on a model lets it through, on purpose. On the
|
||||
PoC, `triposr` (`hf:stabilityai/TripoSR/model.ckpt`) is the one model that
|
||||
needs it.
|
||||
|
||||
### 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
|
||||
catalog's origin, and fetch that commit only (`--revision` for vLLM).
|
||||
- The sha256 Hugging Face publishes for each LFS file compared on every
|
||||
path, and a file that differs removed: `mirror add`, the manager, the
|
||||
node's `hf:` and http sources, mirror set files with no sha recorded.
|
||||
- Done when: a file changed on Hugging Face after a model was added is
|
||||
refused by the node with both checksums named.
|
||||
|
||||
### M3. Hugging Face's own scan (S)
|
||||
Hugging Face scans every file (pickle imports, ClamAV, partners' scanners)
|
||||
and marks a file unsafe. Ask for it with the repository listing; refuse an
|
||||
unsafe file, show the result on the mirror page and in the catalog's origin.
|
||||
The API field has to be confirmed first.
|
||||
|
||||
### M4. Publishers we trust, and the rest asked about (S)
|
||||
An allow-list of organisations in the cluster's settings (Qwen, meta-llama,
|
||||
mistralai, google, microsoft, …). A model from anyone else is added only by
|
||||
an admin who says so, and who said it is kept in the origin. The PoC already
|
||||
serves four community models whose training nobody here checked.
|
||||
|
||||
### M5. Engines without the internet (M)
|
||||
The firewall is inbound only, so an engine that was made to run code can
|
||||
send what it reads anywhere. With weights from the mirror the engines need
|
||||
no egress: an nft output rule for the engine user (allow the mirror, the
|
||||
cluster, loopback), systemd hardening of the agent's engines
|
||||
(`NoNewPrivileges`, `ProtectSystem`, private /tmp). Agent itself still root.
|
||||
|
||||
### M6. An eval before a new model serves (S)
|
||||
`eval` and `canary` exist; make a new model from an untrusted publisher
|
||||
(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`, no auth, `ctlClient` in `internal/cli/ctl.go`.
|
||||
- Admin API: `127.0.0.1:9090`, behind the token in
|
||||
`/var/lib/llmgw/admin-token` (A5), `ctlClient` in `internal/cli/ctl.go`.
|
||||
Metrics on `:9091` with `/prometheus/targets` beside them.
|
||||
- Store: `internal/store`, migrations under `internal/store/migrations/{sqlite,postgres}`;
|
||||
the PoC runs PostgreSQL under Patroni.
|
||||
|
||||
+1202
-3
File diff suppressed because it is too large.
Load diff
@@ -30,7 +30,7 @@ Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
|
||||
| v0.1.0 | 0 | Skeleton and sim-engine | released |
|
||||
| v0.2.0 | 1 | Static router (filter/score/pick, streaming proxy) | released |
|
||||
| v0.3.0 | 1.5 | Admission control (priority bands) | released |
|
||||
| v0.4.0 | 2 | Agent: GPU/IB discovery, engine supervision | released |
|
||||
| v0.4.0 | 2 | Agent: GPU/IB discovery, engine supervision | released as part of v0.5.0 |
|
||||
| v0.5.0 | 3 | Control plane: catalog, placement, reconciler, ctl | released |
|
||||
| v0.6.0 | 4 | Multi-node placement and fabric awareness | released |
|
||||
| v0.7.0 | — | Conversations, agent memory, disk management | released |
|
||||
@@ -40,8 +40,10 @@ Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
|
||||
| v0.11.0 | — | High availability from setup, the setup and console screens, usage sums and per-tenant alerts | released |
|
||||
| v0.12.0 | — | The cluster's settings in the store, in force when changed; the firewall switch; wipe | released |
|
||||
| v0.13.0 | — | The whole system stopped and started from one command; Qwen3Guard judges, on a GPU and CPUs together | released |
|
||||
| v0.14.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released (v0.14.1: adapters, the tree over the API, the firewall on every machine) |
|
||||
| v1.0.0 | 5 | Tenancy, prefix affinity, benchmarking | planned |
|
||||
| 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) |
|
||||
| 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
|
||||
control plane places replicas — including ones larger than a single machine —
|
||||
@@ -50,6 +52,46 @@ budgets and rate limits decide who may use any of it.
|
||||
|
||||
Since v0.11.0 (each release's detail is in [CHANGELOG.md](CHANGELOG.md)):
|
||||
|
||||
- **Setup that explains, and high availability that is whole** (v0.14.6).
|
||||
`sudo llmgw` lists the parts of an install, each with what it is for and
|
||||
what is missing without it, and every one can be turned off or set: the
|
||||
starter model and its context, the mirror's directory, the judge's
|
||||
categories and what it does when it does not answer, limits, the
|
||||
portal's language, pictures and 3D. With three machines each runs the
|
||||
control plane, with a floating address on the leader, and any one can
|
||||
be lost: on three VMs, cutting the store primary's power lost no
|
||||
request. One ComfyUI serves an environment's models, pictures, 3D and
|
||||
workflows are MCP tools, and `/v1/messages` gives a model's reasoning as
|
||||
thinking blocks.
|
||||
- **Picture and 3D models that add themselves** (v0.14.5). Where a node's
|
||||
card fits flux-schnell or triposr and the catalog makes nothing of that
|
||||
kind, the leader adds it on demand, so a new cluster's portal has its
|
||||
Images and 3D pages; the cluster page and the console show each preset
|
||||
and add it by hand, and `presets.auto` turns it off. Stable Fast 3D's
|
||||
colours can be brought to the picture's (`albedo_match`), and every mesh
|
||||
carries its whole licence.
|
||||
- **Any ComfyUI workflow, and models by name** (v0.14.4). `POST
|
||||
/v1/workflows/runs` runs a ComfyUI model's workflow as a job, its inputs
|
||||
(text, numbers, choices, images, masks, audio, video, files) declared in
|
||||
the catalog and listed in `/v1/models`. `llmgw ctl model add triposr` or
|
||||
`flux-schnell` adds a tested model with no YAML; a profile may name the
|
||||
card it needs instead of a class, and ComfyUI now installs on V100s. A
|
||||
licence carries what a machine can match, and a person can leave
|
||||
licences out on the profile page so no key of theirs reaches such a
|
||||
model. A 3D job waiting for the one card says what holds it and for
|
||||
about how long.
|
||||
- **3D models as jobs** (v0.14.3). `POST /v1/3d/generations` answers with a
|
||||
job, asked after until its mesh is ready to download. ComfyUI makes it on
|
||||
the cluster's own cards, TripoSR being the tested model. The job says how
|
||||
far along it is, the model's licence is written into the GLB, and
|
||||
`per_mesh` prices it. People make them at `/3d` on the portal; `llmgw ctl
|
||||
3d` shows every owner's jobs. A model profile may also load weights of
|
||||
its own, and an admission band may wait longer than the rest, for bulk
|
||||
work.
|
||||
- **Pictures from ComfyUI** (v0.14.2). `POST /v1/images/generations` is made
|
||||
by ComfyUI, behind a gate that demands the replica's key, as an engine the
|
||||
agents install from its source checked against a digest. `per_image`
|
||||
prices it, and people make pictures at `/images` on the portal.
|
||||
- **Adapters, and the tenancy tree over the API** (v0.14.1). `llmgw ctl
|
||||
kb dataset` and `llmgw ctl train` make a LoRA adapter from a collection
|
||||
on a node's spare GPU memory, kept on the mirror and seen only by its
|
||||
@@ -143,6 +185,8 @@ Every page is also in the binary: `llmgw docs`, or **Manual** in the console.
|
||||
cluster that exists
|
||||
- [docs/kubernetes.md](docs/kubernetes.md) — the control plane on
|
||||
Kubernetes with the Helm chart, and why GPU nodes stay outside it
|
||||
- [docs/kong.md](docs/kong.md) — llmgw behind Kong Gateway, or Kong behind
|
||||
llmgw: the configuration, what Kong counts, and which to choose
|
||||
- [docs/fabrics.md](docs/fabrics.md) — InfiniBand fabrics and NVLink: what
|
||||
llmgw needs of them, checking a node with `llmgw doctor`, and what to look at
|
||||
when a replica spanning nodes will not run
|
||||
@@ -217,6 +261,33 @@ Add `--json` to any command for machine-readable output.
|
||||
says nothing about which of six constraints bit, so it prints every candidate
|
||||
considered and the reason it was rejected.
|
||||
|
||||
## What llmgw adds to a request
|
||||
|
||||
Measured with `llmgw ctl bench overhead`. It starts an engine that answers
|
||||
at once and a gateway in front of it, in one process on loopback. It sends
|
||||
the same load straight to the engine and through the gateway, and reports
|
||||
what the gateway added. Run on an Apple M5 (10 CPUs, load about 2) on
|
||||
2026-10-16, 10 s a step, 72,000 requests with none failed:
|
||||
|
||||
| Through the gateway | added at p50 | added at p99 |
|
||||
|---|---|---|
|
||||
| bare | 0.06–0.24 ms | 0.10–0.33 ms |
|
||||
| with authentication and usage accounting | 0.05–0.32 ms | 0.09–0.37 ms |
|
||||
| with the guardrails' pattern checks as well | 0.10–0.42 ms | 0.15–0.49 ms |
|
||||
|
||||
The range is across 100 to 2,000 requests a second. The overhead was
|
||||
*lowest* at 2,000, most likely because a busier processor keeps its cores
|
||||
awake. The guardrails' judge model is not in these figures: a judgment
|
||||
takes seconds on CPUs (see [install.md](docs/install.md)) and is a model's
|
||||
time, not the gateway's.
|
||||
|
||||
The command also prints the gateway's CPU per thousand requests. Gateway,
|
||||
engine and load share one process there, so that figure is rough (0.04 to
|
||||
0.65 s per thousand in the run above); the latency is the one to quote.
|
||||
Figures from a VM on a busy host move with the host: run it where you will
|
||||
deploy, and keep the load it prints beside the result. CI runs a one-second
|
||||
version on every change (`TestTheGatewaysOverheadIsMeasured`).
|
||||
|
||||
## Running the router
|
||||
|
||||
```sh
|
||||
@@ -244,8 +315,9 @@ within 1.24x of idle while 500 concurrent batch requests run, against 58x with
|
||||
admission off.
|
||||
|
||||
Routing profiles select how endpoints are scored: `chat` favours session
|
||||
affinity, `batch` favours the shortest queue, `long-context` favours free KV
|
||||
cache. Pick one per model in the config, or per request with the
|
||||
affinity and the replica that has been answering soonest (its time to first
|
||||
token, smoothed), `batch` favours the shortest queue, `long-context` favours
|
||||
free KV cache. Pick one per model in the config, or per request with the
|
||||
`x-llmgw-profile` header.
|
||||
|
||||
## sim-engine
|
||||
|
||||
+189
-26
@@ -1206,9 +1206,12 @@ type Host struct {
|
||||
CpuFlags []string `protobuf:"bytes,5,rep,name=cpu_flags,json=cpuFlags,proto3" json:"cpu_flags,omitempty"`
|
||||
// nvidia_driver is the driver version, empty when there is none. A card
|
||||
// without a driver is not a GPU anything can use.
|
||||
NvidiaDriver string `protobuf:"bytes,6,opt,name=nvidia_driver,json=nvidiaDriver,proto3" json:"nvidia_driver,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
NvidiaDriver string `protobuf:"bytes,6,opt,name=nvidia_driver,json=nvidiaDriver,proto3" json:"nvidia_driver,omitempty"`
|
||||
// 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
|
||||
}
|
||||
|
||||
func (x *Host) Reset() {
|
||||
@@ -1283,6 +1286,13 @@ func (x *Host) GetNvidiaDriver() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Host) GetMemBandwidthGbs() float64 {
|
||||
if x != nil {
|
||||
return x.MemBandwidthGbs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Storage is one filesystem a node keeps weights on.
|
||||
//
|
||||
// Per filesystem rather than per configured directory: free space belongs to
|
||||
@@ -1579,7 +1589,14 @@ type GPU struct {
|
||||
// is then 1000 + 100 × card + device, uuid the instance's (MIG-…), and
|
||||
// memory the instance's share; utilization_percent stays zero, because no
|
||||
// driver measures it per instance.
|
||||
Mig *MIGDevice `protobuf:"bytes,22,opt,name=mig,proto3" json:"mig,omitempty"`
|
||||
Mig *MIGDevice `protobuf:"bytes,22,opt,name=mig,proto3" json:"mig,omitempty"`
|
||||
// How the card is linked to its host: the fastest generation card and
|
||||
// slot allow together, the card's own fastest, and the lanes in use and
|
||||
// the card's most. Zero when the driver did not say.
|
||||
PcieGen int32 `protobuf:"varint,23,opt,name=pcie_gen,json=pcieGen,proto3" json:"pcie_gen,omitempty"`
|
||||
PcieGpuGen int32 `protobuf:"varint,24,opt,name=pcie_gpu_gen,json=pcieGpuGen,proto3" json:"pcie_gpu_gen,omitempty"`
|
||||
PcieWidth int32 `protobuf:"varint,25,opt,name=pcie_width,json=pcieWidth,proto3" json:"pcie_width,omitempty"`
|
||||
PcieWidthMax int32 `protobuf:"varint,26,opt,name=pcie_width_max,json=pcieWidthMax,proto3" json:"pcie_width_max,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -1768,6 +1785,34 @@ func (x *GPU) GetMig() *MIGDevice {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *GPU) GetPcieGen() int32 {
|
||||
if x != nil {
|
||||
return x.PcieGen
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *GPU) GetPcieGpuGen() int32 {
|
||||
if x != nil {
|
||||
return x.PcieGpuGen
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *GPU) GetPcieWidth() int32 {
|
||||
if x != nil {
|
||||
return x.PcieWidth
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *GPU) GetPcieWidthMax() int32 {
|
||||
if x != nil {
|
||||
return x.PcieWidthMax
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// MIGDevice is which card a MIG instance is carved from, and how.
|
||||
type MIGDevice struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@@ -2174,12 +2219,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() {
|
||||
@@ -2408,6 +2468,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"`
|
||||
@@ -2943,8 +3045,12 @@ type Heartbeat struct {
|
||||
// is not handed out. Empty from an agent that does not measure it, or from
|
||||
// one that could not this time.
|
||||
GpuForeignMemoryMb []int32 `protobuf:"varint,19,rep,packed,name=gpu_foreign_memory_mb,json=gpuForeignMemoryMb,proto3" json:"gpu_foreign_memory_mb,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
// mem_bandwidth_gbs is how fast the node's memory reads, in GB/s, once
|
||||
// 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
|
||||
}
|
||||
|
||||
func (x *Heartbeat) Reset() {
|
||||
@@ -3110,6 +3216,13 @@ func (x *Heartbeat) GetGpuForeignMemoryMb() []int32 {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Heartbeat) GetMemBandwidthGbs() float64 {
|
||||
if x != nil {
|
||||
return x.MemBandwidthGbs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Provision is the software a node should have: which engines, at which
|
||||
// versions. Nothing more.
|
||||
//
|
||||
@@ -3530,7 +3643,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
|
||||
}
|
||||
@@ -3586,6 +3702,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 {
|
||||
@@ -3790,7 +3913,14 @@ type EngineProfile struct {
|
||||
Adapters []*LoraAdapter `protobuf:"bytes,12,rep,name=adapters,proto3" json:"adapters,omitempty"`
|
||||
// kv_events has a vLLM engine publish its KV cache events to the agent, for
|
||||
// the router's precise index.
|
||||
KvEvents bool `protobuf:"varint,13,opt,name=kv_events,json=kvEvents,proto3" json:"kv_events,omitempty"`
|
||||
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"`
|
||||
// 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
|
||||
}
|
||||
@@ -3916,6 +4046,20 @@ func (x *EngineProfile) GetKvEvents() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *EngineProfile) GetAllowPickle() bool {
|
||||
if x != nil {
|
||||
return x.AllowPickle
|
||||
}
|
||||
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
|
||||
@@ -5180,7 +5324,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\"\xb1\x01\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xdd\x01\n" +
|
||||
"\x04Host\x12\x13\n" +
|
||||
"\x05os_id\x18\x01 \x01(\tR\x04osId\x12\x1d\n" +
|
||||
"\n" +
|
||||
@@ -5189,7 +5333,8 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||
"osCodename\x12\x12\n" +
|
||||
"\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\"u\n" +
|
||||
"\rnvidia_driver\x18\x06 \x01(\tR\fnvidiaDriver\x12*\n" +
|
||||
"\x11mem_bandwidth_gbs\x18\a \x01(\x01R\x0fmemBandwidthGbs\"u\n" +
|
||||
"\aStorage\x12\x12\n" +
|
||||
"\x04path\x18\x01 \x01(\tR\x04path\x12\x1d\n" +
|
||||
"\n" +
|
||||
@@ -5219,7 +5364,7 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||
"leaderPort\x12\x1d\n" +
|
||||
"\n" +
|
||||
"profile_fp\x18\x0f \x01(\tR\tprofileFp\x12+\n" +
|
||||
"\acompute\x18\x10 \x01(\v2\x11.llmgw.v1.ComputeR\acompute\"\xca\x06\n" +
|
||||
"\acompute\x18\x10 \x01(\v2\x11.llmgw.v1.ComputeR\acompute\"\xcc\a\n" +
|
||||
"\x03GPU\x12\x14\n" +
|
||||
"\x05index\x18\x01 \x01(\x05R\x05index\x12\x12\n" +
|
||||
"\x04uuid\x18\x02 \x01(\tR\x04uuid\x12\x12\n" +
|
||||
@@ -5245,7 +5390,13 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||
"\fcluster_uuid\x18\x13 \x01(\tR\vclusterUuid\x12\x1b\n" +
|
||||
"\tclique_id\x18\x14 \x01(\tR\bcliqueId\x12)\n" +
|
||||
"\x10fabric_bandwidth\x18\x15 \x01(\tR\x0ffabricBandwidth\x12%\n" +
|
||||
"\x03mig\x18\x16 \x01(\v2\x13.llmgw.v1.MIGDeviceR\x03mig\x1a;\n" +
|
||||
"\x03mig\x18\x16 \x01(\v2\x13.llmgw.v1.MIGDeviceR\x03mig\x12\x19\n" +
|
||||
"\bpcie_gen\x18\x17 \x01(\x05R\apcieGen\x12 \n" +
|
||||
"\fpcie_gpu_gen\x18\x18 \x01(\x05R\n" +
|
||||
"pcieGpuGen\x12\x1d\n" +
|
||||
"\n" +
|
||||
"pcie_width\x18\x19 \x01(\x05R\tpcieWidth\x12$\n" +
|
||||
"\x0epcie_width_max\x18\x1a \x01(\x05R\fpcieWidthMax\x1a;\n" +
|
||||
"\rNicPathsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x84\x02\n" +
|
||||
@@ -5283,7 +5434,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" +
|
||||
@@ -5315,7 +5467,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" +
|
||||
@@ -5361,7 +5520,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\"\xa9\a\n" +
|
||||
"\tbandwidth\x18\x06 \x01(\tR\tbandwidth\"\xd5\a\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" +
|
||||
@@ -5383,7 +5542,8 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||
"gpu_fabric\x18\x10 \x03(\v2\x18.llmgw.v1.GPUFabricStateR\tgpuFabric\x12\"\n" +
|
||||
"\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\x1aA\n" +
|
||||
"\x15gpu_foreign_memory_mb\x18\x13 \x03(\x05R\x12gpuForeignMemoryMb\x12*\n" +
|
||||
"\x11mem_bandwidth_gbs\x18\x14 \x01(\x01R\x0fmemBandwidthGbs\x1aA\n" +
|
||||
"\x13EngineVersionsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\";\n" +
|
||||
@@ -5430,13 +5590,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" +
|
||||
@@ -5462,7 +5623,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\"\xcd\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" +
|
||||
@@ -5478,7 +5639,9 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||
" \x01(\tR\tmodelPath\x12*\n" +
|
||||
"\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\x1a6\n" +
|
||||
"\tkv_events\x18\r \x01(\bR\bkvEvents\x12!\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" +
|
||||
|
||||
@@ -238,6 +238,9 @@ message Host {
|
||||
// nvidia_driver is the driver version, empty when there is none. A card
|
||||
// without a driver is not a GPU anything can use.
|
||||
string nvidia_driver = 6;
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Storage is one filesystem a node keeps weights on.
|
||||
@@ -334,6 +337,13 @@ message GPU {
|
||||
// memory the instance's share; utilization_percent stays zero, because no
|
||||
// driver measures it per instance.
|
||||
MIGDevice mig = 22;
|
||||
// How the card is linked to its host: the fastest generation card and
|
||||
// slot allow together, the card's own fastest, and the lanes in use and
|
||||
// the card's most. Zero when the driver did not say.
|
||||
int32 pcie_gen = 23;
|
||||
int32 pcie_gpu_gen = 24;
|
||||
int32 pcie_width = 25;
|
||||
int32 pcie_width_max = 26;
|
||||
}
|
||||
|
||||
// MIGDevice is which card a MIG instance is carved from, and how.
|
||||
@@ -438,6 +448,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.
|
||||
@@ -588,6 +613,11 @@ message Heartbeat {
|
||||
// is not handed out. Empty from an agent that does not measure it, or from
|
||||
// one that could not this time.
|
||||
repeated int32 gpu_foreign_memory_mb = 19;
|
||||
|
||||
// mem_bandwidth_gbs is how fast the node's memory reads, in GB/s, once
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Provision is the software a node should have: which engines, at which
|
||||
@@ -690,6 +720,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
|
||||
@@ -749,6 +782,13 @@ message EngineProfile {
|
||||
// kv_events has a vLLM engine publish its KV cache events to the agent, for
|
||||
// the router's precise index.
|
||||
bool kv_events = 13;
|
||||
// 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")
|
||||
|
||||
+37
-12
@@ -1,4 +1,4 @@
|
||||
# Two control planes
|
||||
# More than one control plane
|
||||
|
||||
llmgw handles the hard half of this itself: only one control plane places work,
|
||||
and agents find whichever one is leading. What it does not do is move client
|
||||
@@ -37,7 +37,7 @@ nothing in front of the two had moved.
|
||||
So something has to. The options, in the order most people should consider
|
||||
them:
|
||||
|
||||
**A load balancer you already run.** Health-check `/ready` on each control
|
||||
**A load balancer you already run.** Health-check `/leader` on each control
|
||||
plane and send traffic to whichever answers. This is the right answer if you
|
||||
have one, and it needs nothing else from this directory.
|
||||
|
||||
@@ -50,25 +50,50 @@ in whatever your resolvers actually honour rather than what you set, which on
|
||||
a bad day is minutes. Fine for a cluster people use interactively, wrong for
|
||||
one an agent is driving.
|
||||
|
||||
## Check `/ready`, not `/health`
|
||||
## Check `/leader`, not `/health`
|
||||
|
||||
This is the one thing to get right, and the obvious choice is the wrong one.
|
||||
|
||||
`/health` says the process is up. It says nothing about whether it can serve,
|
||||
`/health` says the process is up. It says nothing about whether it leads,
|
||||
and a standby is exactly that case: alive, answering, and holding no endpoints
|
||||
at all — its reconciler placed nothing and it has no agent streams to adopt
|
||||
from. A load balancer checking `/health` would send it half the traffic, and
|
||||
half the traffic would be a 503.
|
||||
from. A standby passes client requests on to the leader, so traffic sent to
|
||||
one still works — until the leader is the machine that was lost, and there
|
||||
is nobody to pass them to.
|
||||
|
||||
`/leader` is 200 on the control plane that leads and 503 on a standby. It is
|
||||
what keepalived follows for the floating address, and what a load balancer
|
||||
should send traffic by: to the one machine that answers 200.
|
||||
|
||||
`/ready` is narrower: 200 only when this process has somewhere to send a
|
||||
request, so 503 on a standby and also on a leader with no model running yet.
|
||||
Checking it takes the portal and the admin pages away with the models — on a
|
||||
new cluster with no node yet, nobody can sign in to add one. Use it to stop
|
||||
sending inference only, if your load balancer can tell the paths apart.
|
||||
|
||||
`/ready` says this process has somewhere to send a request. It is 503 on a
|
||||
standby, 503 on a leader whose replicas have all failed, and 200 when there is
|
||||
something to serve — which is the question a load balancer is actually asking.
|
||||
Both are open, because a load balancer has no key.
|
||||
|
||||
During a failover neither passes for about the length of the lease handover:
|
||||
During a failover nothing passes for about the length of the lease handover:
|
||||
three seconds when the leader is stopped cleanly, up to the lease TTL when it
|
||||
is not. That gap is real and there is no way to have it both ways — traffic
|
||||
cannot go to a control plane that has nothing to route to.
|
||||
is not. A proxy that waits that long for a healthy machine rather than
|
||||
failing at once (Caddy's `lb_try_duration`) hides it from clients.
|
||||
|
||||
**Caddy**, in front of three control planes — the end of
|
||||
[deploy/proxy/Caddyfile](../proxy/Caddyfile) has it with the rest of the
|
||||
settings:
|
||||
|
||||
```
|
||||
reverse_proxy 10.0.0.157:8080 10.0.0.158:8080 10.0.0.159:8080 {
|
||||
lb_policy first
|
||||
health_uri /leader
|
||||
health_interval 2s
|
||||
lb_try_duration 15s
|
||||
flush_interval -1
|
||||
}
|
||||
```
|
||||
|
||||
With a floating address the address already follows the leader: give Caddy
|
||||
that one address instead.
|
||||
|
||||
Not the admin port, for a different reason: it serves pprof, and pprof can
|
||||
dump the process heap, where API keys sit in plaintext.
|
||||
|
||||
@@ -41,7 +41,9 @@ vrrp_instance llmgw {
|
||||
# MASTER on the machine you would rather serve; BACKUP on the other.
|
||||
state MASTER
|
||||
interface eth0
|
||||
# Any number both machines agree on and nothing else on the segment uses.
|
||||
# Any number both machines agree on and nothing else on the segment uses:
|
||||
# another cluster's keepalived with the same one reads as an invalid
|
||||
# password. `llmgw server add --vip` derives one from the address.
|
||||
virtual_router_id 51
|
||||
# The higher one wins while both are healthy. The weight above is enough
|
||||
# to invert this when the preferred machine's gateway is not answering.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -21,11 +21,18 @@ scrape_configs:
|
||||
# The control plane's dedicated metrics port. Not its admin listener: that one
|
||||
# is loopback because it also serves pprof, and pprof can dump the heap where
|
||||
# API keys sit in plaintext.
|
||||
#
|
||||
# Every control plane, one line each: the leader moves on restarts and the
|
||||
# router's metrics are the leading one's, so scraping one alone leaves gaps
|
||||
# — and alerts on them — whenever another leads.
|
||||
- job_name: llmgw
|
||||
static_configs:
|
||||
- targets: ['10.0.0.140:9091']
|
||||
labels:
|
||||
instance: control-plane
|
||||
instance: control-plane-140
|
||||
# - targets: ['10.0.0.150:9091']
|
||||
# labels:
|
||||
# instance: control-plane-150
|
||||
|
||||
# Every machine of the cluster, for its node exporter: network and disk
|
||||
# traffic, filesystems, CPU time. The control plane lists them — nodes,
|
||||
@@ -33,10 +40,15 @@ scrape_configs:
|
||||
# nothing edited here, and each carries its llmgw id as the node label.
|
||||
# `llmgw node add` installs the exporter; on a machine added before that,
|
||||
# apt-get install prometheus-node-exporter.
|
||||
# Asked of every control plane too: a standby lists only the control
|
||||
# planes and the store, and they label every machine alike, so the same
|
||||
# target from two of them is scraped once.
|
||||
- job_name: nodes
|
||||
http_sd_configs:
|
||||
- url: http://10.0.0.140:9091/prometheus/targets
|
||||
refresh_interval: 1m
|
||||
# - url: http://10.0.0.150:9091/prometheus/targets
|
||||
# refresh_interval: 1m
|
||||
|
||||
- job_name: prometheus
|
||||
static_configs:
|
||||
|
||||
@@ -14,6 +14,7 @@ llm.example.com {
|
||||
max_size 32MB
|
||||
}
|
||||
|
||||
# One control plane. With more than one, see the end of this file.
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
# Tokens are written the moment they are generated. Without this a
|
||||
# streamed reply can arrive in bursts, and a client measuring time to
|
||||
@@ -39,3 +40,36 @@ llm.example.com {
|
||||
# Access log to stderr, which under systemd is the journal.
|
||||
log
|
||||
}
|
||||
|
||||
# More than one control plane (server init --standbys, server add): one of
|
||||
# them leads, the others stand by. Name them all and send to the one that
|
||||
# leads — /leader is 200 there and 503 on a standby. Not /ready: that is 503
|
||||
# on a leader with no model running yet, and would take the portal away with
|
||||
# it. A standby would pass a request on to the leader anyway, but not while
|
||||
# there is none, and the hop is for nothing.
|
||||
#
|
||||
# With a floating address (--vip) the address already follows the leader:
|
||||
# give reverse_proxy that one address instead, and keep the rest as above.
|
||||
#
|
||||
# llm.example.com {
|
||||
# request_body {
|
||||
# max_size 32MB
|
||||
# }
|
||||
# reverse_proxy 10.0.0.157:8080 10.0.0.158:8080 10.0.0.159:8080 {
|
||||
# # The first that is healthy: the leader, whichever it is.
|
||||
# lb_policy first
|
||||
# health_uri /leader
|
||||
# health_interval 2s
|
||||
# health_timeout 2s
|
||||
# # While leadership moves — a few seconds when a leader is lost —
|
||||
# # none is healthy; a request waits that long for one rather than
|
||||
# # failing at once.
|
||||
# lb_try_duration 15s
|
||||
# lb_try_interval 250ms
|
||||
# flush_interval -1
|
||||
# transport http {
|
||||
# response_header_timeout 15m
|
||||
# }
|
||||
# }
|
||||
# log
|
||||
# }
|
||||
@@ -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
|
||||
|
||||
@@ -96,6 +96,12 @@ func titleOf(src, path string) string {
|
||||
//go:embed docs/admin-api.openapi.json
|
||||
var AdminOpenAPI []byte
|
||||
|
||||
// ClientOpenAPI is the client API described in OpenAPI 3.1, generated from
|
||||
// internal/clientapi's route table and served at /v1/openapi.json.
|
||||
//
|
||||
//go:embed docs/client-api.openapi.json
|
||||
var ClientOpenAPI []byte
|
||||
|
||||
// EvalSuites are the example quality suites under deploy/eval, which the
|
||||
// control plane runs by name when its eval directory has none of the name.
|
||||
//
|
||||
|
||||
+2004
-2
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+78
-2
@@ -38,6 +38,14 @@ export ANTHROPIC_BASE_URL=https://llm.example.com
|
||||
export ANTHROPIC_API_KEY=$LLMGW_API_KEY
|
||||
```
|
||||
|
||||
### The API described
|
||||
|
||||
Every endpoint is described in OpenAPI 3.1 at `/v1/openapi.json`. It needs
|
||||
no key, so a client generator can be pointed at it. `/docs/api` shows it in
|
||||
Swagger UI, where *Authorize* takes a key and *Try it out* sends a request to
|
||||
the gateway. People with a part in running the gateway also find the admin
|
||||
API's document there.
|
||||
|
||||
## A first request
|
||||
|
||||
### curl
|
||||
@@ -526,6 +534,73 @@ curl https://llm.example.com/mcp \
|
||||
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'
|
||||
```
|
||||
|
||||
**Pictures, 3D models, vision and workflows.** Where the gateway has the
|
||||
models for them, the same server offers more tools — only those it has a
|
||||
model for:
|
||||
|
||||
| Tool | Does |
|
||||
|---|---|
|
||||
| `generate_image` | A picture from a prompt. `style` adds what a picture for a mesh wants: `concept` (one object, three-quarter view, grey background), `side` (an exact side view), `texture` (a seamless tile) or `none`. Answers the PNG's `file_id`, `url` and `seed`, with a 512 px preview to look at. |
|
||||
| `generate_mesh` | A GLB from a picture — `image` is a `file-…` id (a `generate_image` result, say), a data URL or base64 — or from a `prompt`, for which a concept picture is made first. `seed`, `resolution`, `texture_resolution`, and any other input the model's workflow declares in `inputs`. Answers the GLB's `file_id`, `url` and licence. |
|
||||
| `review_images` | Asks a vision model about one to four pictures (each shrunk to 1280 px on its long side); `json_schema` makes it answer that JSON. |
|
||||
| `run_workflow` | Runs a ComfyUI workflow from the inputs its catalog entry declares, and answers its result file. |
|
||||
| `job_status` | Where a mesh or a workflow run stands, and its file when done; `wait_seconds` waits for it. |
|
||||
| `gateway_status` | The picture, 3D, vision and workflow models, whether each is loaded on a GPU now, a workflow's inputs, and your last jobs. |
|
||||
|
||||
Each tool makes its request of the gateway under your key, so your
|
||||
allowlist, limits, left-out licences and budget apply, and it is charged
|
||||
and listed like any other request. What it makes is kept as your file for
|
||||
as long as job results are: fetch it from `url` with the same key (`curl -H
|
||||
"Authorization: Bearer $LLMGW_API_KEY" -o rock.glb <url>`). A tool server
|
||||
over HTTP cannot write to your disk, so an agent that needs the file
|
||||
locally — to import it in Blender — fetches it, or goes through a small
|
||||
local bridge that does.
|
||||
|
||||
A picture can be uploaded once and named by its id after: `POST /v1/files`
|
||||
with `purpose=vision` (or `user_data`) keeps it, for as long as job results
|
||||
are kept, as a file of yours that `generate_mesh`, `review_images` and a
|
||||
workflow's file inputs take as `file-…`. A call is at most 64 MB.
|
||||
|
||||
A model that is not loaded is waited for: a picture or a look at one whose
|
||||
model is starting, or waiting for another to give up the GPU, waits as a
|
||||
mesh does, saying so, rather than failing with "model loading".
|
||||
|
||||
A mesh takes from half a minute to a few minutes. A client that takes the
|
||||
answer as a stream (`Accept: text/event-stream`, as Claude Code does) is
|
||||
answered that way: `notifications/progress` for the call's
|
||||
`progressToken` while it runs — queued, waiting for the GPU, the node and
|
||||
its percent — then the result, and the call waits up to ten minutes. One
|
||||
answered in one piece waits 50 seconds; a job not done by then is answered
|
||||
with its `job_id`, to ask after with `job_status`. `wait_seconds` (up to
|
||||
900) sets either.
|
||||
|
||||
```
|
||||
claude mcp add --transport http llmgw https://llm.example.com/mcp \
|
||||
--header "Authorization: Bearer $LLMGW_API_KEY"
|
||||
```
|
||||
|
||||
**Your company's tools.** Where an administrator has registered the
|
||||
company's own MCP servers (Jira, a file server, a database) and your key is
|
||||
granted their tools, the same `/mcp` lists them too, each as `server.tool`
|
||||
(`jira.search`). Give your agent only llmgw's `/mcp` and your key: the
|
||||
servers' own credentials stay with llmgw. A tool your key is not granted is
|
||||
not listed, and calling it by name is refused. Each call is in the audit
|
||||
log under your key: which tool, the size of what was sent, how it ended and
|
||||
how long it took, never what it said.
|
||||
|
||||
### Licences you leave out
|
||||
|
||||
A model's licence is in `/v1/models` (`license`: its `id`, `name`, whether
|
||||
it allows commercial use and the regions it grants nothing in). On your
|
||||
profile page, under **Licences**, you can leave licences out: one by one,
|
||||
every licence that forbids commercial use, or every licence that grants
|
||||
nothing in a region you name (`EU` for the union). The rules cover models
|
||||
added later too. From then on those models are not listed to your keys and
|
||||
a request for one is refused with 403 and the code `licence_excluded`,
|
||||
naming the choice that left it out — on every key you own and on the
|
||||
portal's pages, whatever a key's own allowlist says. A change reaches keys
|
||||
already in use within a minute.
|
||||
|
||||
### Headers llmgw sends back
|
||||
|
||||
| Header | Says |
|
||||
@@ -545,9 +620,10 @@ its usual exception with the gateway's message in it. The ones worth knowing:
|
||||
|---|---|
|
||||
| 400 | Besides a malformed request: a prompt too long for the model, or a guardrail policy refused it (the code `content_policy_violation`). |
|
||||
| 401 | No key, a mistyped one, or one that was revoked or has expired. |
|
||||
| 403 | The key may not use that model. |
|
||||
| 403 | The key may not use that model; with the code `licence_excluded`, its licence is one you left out on your profile page. |
|
||||
| 429 | A rate limit or budget of the key, its project or its team was reached. `Retry-After` says when to try again, and is absent when waiting will not help — a spent monthly budget. |
|
||||
| 503 | No model of that name is served (the message lists the ones that are), the model is still loading, or every replica is busy and the request waited as long as it may. The SDKs retry by themselves; a name that is not served will not start to be. |
|
||||
| 503 | No model of that name is served (the message lists the ones that are), the model is still loading, or every replica is busy and the queue is full. The SDKs retry by themselves; a name that is not served will not start to be. |
|
||||
| 504 | Every replica was busy for as long as the key's band may wait. `Retry-After` says when to send it again. |
|
||||
|
||||
## Priority
|
||||
|
||||
|
||||
+56
-4
@@ -2,8 +2,8 @@
|
||||
|
||||
`sudo llmgw`, with nothing after it, on a machine llmgw runs on. On a machine
|
||||
with nothing installed it opens the setup screens; on a control plane, the
|
||||
console described here; on a node, the node's own console, which edits
|
||||
`agent.yaml`. Everything on these screens can also be done by command — each
|
||||
console described here; on a node, the node's own console, which shows
|
||||
what runs on that machine and edits `agent.yaml`. Everything on these screens can also be done by command — each
|
||||
screen says which — so a script never needs them and a person never needs
|
||||
to remember the commands.
|
||||
|
||||
@@ -173,6 +173,23 @@ search…** as an administrator, or **Delete…** it. People
|
||||
fill collections from their own machines with `llmgw-kb`
|
||||
([knowledge.md](knowledge.md)). Off on a SQLite store, and says why.
|
||||
|
||||
**MCP servers** lists the company's own MCP servers that agents reach through
|
||||
the gateway's `/mcp`, their tools named `server.tool`: whether each is on,
|
||||
who has been granted its tools, its rate per caller, the names of the
|
||||
credentials it is sent (never their values) and its address. **A** adds one:
|
||||
its name, which is its tools' prefix, its address, and a bearer token or
|
||||
another header, kept sealed in the store. On a row: **Test, and list its
|
||||
tools**, with its credential, as a call would; **Grant its tools to…** a
|
||||
team, project, organization, person or key — all of them, `jira.*`, or some
|
||||
by name — beside whatever else that level is granted, and saying when a
|
||||
level above keeps them out of reach; **Revoke** a grant;
|
||||
**Change…** its address, timeout or rate; **Replace its credential…**;
|
||||
**Turn off** (its tools are listed nowhere and every call refused) or on;
|
||||
**Remove…**. A server's tools are seen nowhere until granted. The screen
|
||||
also says how a person points an agent at the gateway, with a key of their
|
||||
own from `/profile/keys`. `llmgw ctl mcp` does the same from a shell, and
|
||||
the web page **MCP servers** (`/admin/mcp`, administrators only) in a browser.
|
||||
|
||||
**Mirror** is what the model mirror holds, one row for each version of each
|
||||
model — a file, or the set of files a model of several is: shards, a vision
|
||||
projector, a config and tokenizer — with how far its download has got and
|
||||
@@ -246,6 +263,28 @@ long a key made there may live, and is the expiry of one made without any
|
||||
Limits are requests and tokens a minute, a budget per day or month, and the
|
||||
models allowed. Each level only narrows what is above it.
|
||||
|
||||
**Sign-in** — how people get in besides a password kept here: single
|
||||
sign-on, Active Directory and provisioning, each on or off, with the
|
||||
directory's last sync (how many people, accounts kept in step, suspended,
|
||||
reinstated).
|
||||
- **E** sets up Entra ID. Give the tenant ID, the client ID and secret,
|
||||
and the groups for administrators and operators. A provider other than
|
||||
Entra takes its issuer URL in place of the tenant. The form shows the
|
||||
redirect address to register.
|
||||
- **D** sets up Active Directory: the servers, where people are, the
|
||||
service account and its password, the domain's CA certificate as a PEM
|
||||
file on this machine, the group whose members may sign in, and groups
|
||||
for roles and teams.
|
||||
- Before it is kept, the directory is tried from the control plane: each
|
||||
server reached and bound, how many people may sign in, how many are in
|
||||
each group. If any step fails, nothing is kept and the steps are shown.
|
||||
**T** tries the settings in force.
|
||||
- On the provisioning row, Enter makes a SCIM token, shown once.
|
||||
- The password, the client secret and the token are kept in the store,
|
||||
sealed by the master key when one is set. The settings name them as
|
||||
`store:auth/…`, so each control plane has them with no file put on it.
|
||||
The CA is written into the settings themselves.
|
||||
|
||||
**Guardrails…** on a row lists the guardrail policies server.yaml defines.
|
||||
Each policy is marked in one of three ways:
|
||||
|
||||
@@ -335,5 +374,18 @@ to restart it.
|
||||
|
||||
## The node's console
|
||||
|
||||
On a node, `sudo llmgw` opens `agent.yaml` in the same settings view, and the
|
||||
manual. The cluster is watched and changed from a control plane.
|
||||
On a node, `sudo llmgw` opens on **Overview**: what this machine is doing, read
|
||||
on the machine itself, so it works without an admin token and while the control
|
||||
plane is out of reach. Refreshed every three seconds, it shows
|
||||
|
||||
- llmgw-agent's state and since when, and the version of llmgw;
|
||||
- each control plane the agent dials, from `agent.yaml` and the list the
|
||||
leader last sent, and whether it can be reached;
|
||||
- each GPU: how busy, its memory, its heat, and the models on it;
|
||||
- the engines running here, from the agent's own records: replica, model,
|
||||
engine, GPUs, port, whether the process is still there, and for how long;
|
||||
- the free space where weights and engines are kept;
|
||||
- the agent's warnings and errors of the last hour.
|
||||
|
||||
Below it, `agent.yaml` in the same settings view, and the manual. The cluster
|
||||
is watched and changed from a control plane.
|
||||
+3
-1
@@ -72,7 +72,9 @@ each answer that may be reused, as the client got it, and a hash of the
|
||||
request it answers, in the gateway process's memory — never in the store,
|
||||
never on disk — for the project's TTL or until pushed out by newer ones
|
||||
(`router.cache.max_bytes`), and gone with a restart. Never under a `no_store`
|
||||
policy. Forgetting a person does not reach into it; the answers age out by
|
||||
policy. A project that reuses answers to similar questions (`--similar`)
|
||||
also has each question's last user message embedded, and the vector kept
|
||||
beside its answer, in the same memory and for as long. Forgetting a person does not reach into it; the answers age out by
|
||||
the TTL. The store keeps only the setting, and a usage record's mark that it
|
||||
was answered from the cache.
|
||||
|
||||
|
||||
@@ -91,6 +91,21 @@ guard:
|
||||
value is the same placeholder throughout a request).
|
||||
- **`block`** stops it.
|
||||
|
||||
**Putting them back.** With `restore: true` in a policy's `input`, the
|
||||
values a request had redacted are put back in its answer: the model reads
|
||||
"write to [PERSON_1] at [EMAIL_1]", the client reads the name and address
|
||||
it sent. Streamed answers too — a placeholder split across two events is
|
||||
held until it is whole. Only that request's own values, only into its
|
||||
answer; the map is kept in memory for the request and nowhere else. The
|
||||
output rules then read the answer as the client will, so a policy that
|
||||
redacts personal data in answers still does.
|
||||
|
||||
```yaml
|
||||
input:
|
||||
pii: {email: redact, phone: redact}
|
||||
restore: true
|
||||
```
|
||||
|
||||
**Shapes.**
|
||||
|
||||
- `system_prompt` is put first.
|
||||
@@ -258,6 +273,47 @@ themselves.
|
||||
context rather than overflowing it and going unjudged.
|
||||
- **Cache:** the same text is not asked about twice within five minutes.
|
||||
|
||||
## An outside service
|
||||
|
||||
A site required to use a particular guardrail — Azure Content Safety,
|
||||
Lakera Guard, NeMo Guardrails, a classifier of its own — names it in a
|
||||
policy's `services`. It is asked over HTTP beside the patterns and before
|
||||
the judge; its "unsafe" blocks or flags like theirs, and a service that does
|
||||
not answer is what the policy's `fail` says.
|
||||
|
||||
```yaml
|
||||
policies:
|
||||
strict:
|
||||
fail: closed
|
||||
services:
|
||||
- name: lakera
|
||||
url: https://api.lakera.ai/v2/guard
|
||||
header_files: {Authorization: /etc/llmgw/secrets/lakera} # "Bearer …", read each call
|
||||
body: '{"messages": [{"role": "user", "content": {{json .Text}}}]}'
|
||||
blocked: flagged # where the answer says unsafe
|
||||
reason: breakdown.0.detector_type
|
||||
on: both # input, output or both
|
||||
action: block # or flag
|
||||
timeout: 2s
|
||||
```
|
||||
|
||||
`body` is a template given `.Text` (`{{json .Text}}` writes it as a JSON
|
||||
string; empty sends `{"input": …}`). `blocked` and `reason` are dotted paths
|
||||
into the JSON answer, numbers for list items; true, a number above zero, a
|
||||
non-empty list or a string other than "false" is unsafe. On input the
|
||||
service reads the latest user turn as the model will, with what `redact`
|
||||
took out still out; on output the whole answer, once, at its end, as the
|
||||
client reads it — with `restore`, the values put back. Being at the end, a
|
||||
service can stop a streamed answer only where it is held back
|
||||
(`stream: buffer_all`): released a sentence at a time, the client has read
|
||||
it by then, and what the service finds ends the stream with
|
||||
`content_filter`, flags it and is recorded. The rule recorded is
|
||||
`service:<name>:<reason>`. The service is told the text only, never who
|
||||
sent it. Under `fail: open`, the default, a service that cannot be asked
|
||||
lets the text through — and a caller can make it fail, sending what it
|
||||
refuses to read; give `fail: closed` where the service must decide. Of an
|
||||
answer asked for with `n` above 1, the service reads the first.
|
||||
|
||||
## Where a policy applies
|
||||
|
||||
`guard.default` applies everywhere. Others are attached to a part of the
|
||||
|
||||
+971
-40
File diff suppressed because it is too large.
Load diff
+30
-10
@@ -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
|
||||
|
||||
@@ -146,6 +146,26 @@ Rules that apply:
|
||||
- Every document added or removed is in the audit log (`llmgw ctl audit`),
|
||||
by name. The text is never recorded.
|
||||
|
||||
### A large import
|
||||
|
||||
A few thousand documents ask the embedding model for far more than it can
|
||||
answer at once. Give the key that sends them a band of its own, below the
|
||||
people's, that may wait for room for minutes rather than the 30 seconds the
|
||||
others wait:
|
||||
|
||||
```sh
|
||||
llmgw ctl config set router.admission.bands '[{name: gold, priority: 300, saturation_ceiling: 0.85},
|
||||
{name: bronze, priority: 100, saturation_ceiling: 0.3}, {name: bulk, priority: 50, saturation_ceiling: 0.3, queue_ttl: 5m}]'
|
||||
llmgw keys create import --project Acme/docs --band bulk
|
||||
```
|
||||
|
||||
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`
|
||||
|
||||
### Get it
|
||||
@@ -207,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.
|
||||
@@ -241,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.
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# llmgw and Kong
|
||||
|
||||
For a site that already runs Kong. The two meet over plain HTTP: Kong in
|
||||
front of llmgw, as one more API it governs, or Kong behind llmgw, as an
|
||||
OpenAI-compatible provider. Nothing of either is built into the other.
|
||||
|
||||
Tried with Kong Gateway 3.9 (`kong:3.9`, the last open-source release; 3.10
|
||||
and later need a Kong licence) in DB-less mode, in front of llmgw.
|
||||
|
||||
## Kong in front of llmgw
|
||||
|
||||
Two ways, both tried.
|
||||
|
||||
**A plain route.** Kong forwards the request as it came. Tried with chat
|
||||
completions, streamed and not; llmgw's other APIs — Anthropic's
|
||||
`/v1/messages`, `/v1/responses`, embeddings, pictures — go the same way,
|
||||
since Kong does not read them. Kong adds its own authentication, rate limits
|
||||
and logging; llmgw still checks the key, the budget and the guardrails.
|
||||
|
||||
```yaml
|
||||
_format_version: "3.0"
|
||||
services:
|
||||
- name: llmgw
|
||||
url: https://llm.example.com # llmgw's client port
|
||||
routes:
|
||||
- name: llmgw
|
||||
paths: [/llmgw] # /llmgw/v1/chat/completions → /v1/chat/completions
|
||||
```
|
||||
|
||||
**Kong's `ai-proxy` plugin**, for Kong's own token statistics on a chat
|
||||
route. llmgw is an OpenAI provider whose `upstream_url` is llmgw:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
- name: llmgw-chat
|
||||
url: https://llm.example.com
|
||||
routes:
|
||||
- name: chat
|
||||
paths: [/chat]
|
||||
plugins:
|
||||
- name: ai-proxy
|
||||
config:
|
||||
route_type: llm/v1/chat
|
||||
logging: {log_statistics: true, log_payloads: false}
|
||||
model:
|
||||
provider: openai
|
||||
name: qwen-coder # a model llmgw serves
|
||||
options:
|
||||
upstream_url: https://llm.example.com/v1/chat/completions
|
||||
```
|
||||
|
||||
**Keys.** Without an `auth` block, `ai-proxy` passes the client's
|
||||
`Authorization` header on: each team keeps its own llmgw key, and llmgw's
|
||||
budgets, limits, guardrails and usage stay per team. With
|
||||
`auth: {header_name: Authorization, header_value: "Bearer sk-llmgw-…"}` Kong
|
||||
sends one key of its own instead, and llmgw sees every caller as that key —
|
||||
then budgets per team are Kong's to keep.
|
||||
|
||||
**What Kong counts.** For a whole answer Kong reads llmgw's `usage` and
|
||||
records prompt and completion tokens as llmgw did. For a streamed answer
|
||||
Kong 3.9 records an estimate of the prompt tokens and no completion tokens,
|
||||
even with `stream_options.include_usage` set — the usage chunk reaches it
|
||||
and is not read. llmgw's own accounting counts both; use its usage export
|
||||
(`llmgw ctl usage export`) or `/admin/usage` for figures to bill by.
|
||||
|
||||
**Timeouts.** A long answer from a model still loading outlasts Kong's
|
||||
60-second defaults: set the service's `read_timeout` to the time a model may
|
||||
take to start, as for nginx in [install.md](install.md).
|
||||
|
||||
## Kong behind llmgw
|
||||
|
||||
Not tried here. Kong's AI routes speak OpenAI's API, so llmgw reaches models Kong fronts as
|
||||
it reaches any provider: a model whose source is `external`.
|
||||
|
||||
```yaml
|
||||
- name: kong-gpt
|
||||
source: external
|
||||
external:
|
||||
url: https://kong.example.com/openai # Kong's route, without /v1
|
||||
model: gpt-4o-mini
|
||||
api_key_file: /etc/llmgw/secrets/kong-consumer.key
|
||||
```
|
||||
|
||||
The key is the one Kong's own authentication asks of a consumer. As a
|
||||
fallback (`fallbacks: [kong-gpt]`) it is used only when the cluster has no
|
||||
room.
|
||||
|
||||
## Which to choose
|
||||
|
||||
Kong in front when the site's API policy says every API goes through Kong:
|
||||
llmgw is then one more upstream, and its per-team accounting still holds
|
||||
when each team keeps its own key. Kong behind when the site already reaches
|
||||
outside providers through Kong and llmgw should too. llmgw does not need
|
||||
Kong for anything it does itself — keys, budgets, guardrails, routing to
|
||||
its own replicas.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Large models: several cards, several nodes
|
||||
|
||||
A model too big for one card runs on several. llmgw chooses how from the
|
||||
model's own figures and the cluster as it is; this page is the rules it
|
||||
follows, the commands, and what to look at when a first one is brought up
|
||||
on new hardware.
|
||||
|
||||
## How a model is split
|
||||
|
||||
In order, the first that fits:
|
||||
|
||||
1. **One card.** Nothing to split.
|
||||
2. **Several cards of one node, tensor parallel.** Every layer is cut
|
||||
across the cards, so they talk for every token: they must be on one
|
||||
NVLink domain. The agent reads `nvidia-smi topo -m` and gives a replica
|
||||
the cards with the most NVLink between them
|
||||
([install.md](install.md#nvlink-if-your-gpus-have-it)).
|
||||
3. **Several nodes, pipeline parallel between them.** Each node holds a
|
||||
run of layers on all its cards (tensor parallel inside it), and only a
|
||||
layer's output crosses to the next node. The nodes must share one
|
||||
InfiniBand or RoCE fabric: a replica never spans two, and never a node
|
||||
with none. Any number of nodes, not only two or four.
|
||||
|
||||
The plan takes the fewest cards, then the fewest nodes, then the better
|
||||
precision up to eight bits.
|
||||
|
||||
**Tensor width and the KV heads.** The cards split the model's KV heads
|
||||
among them, or past their number each card holds a copy of one. So the
|
||||
width divides the KV heads or is a multiple of them: a model with 8 KV
|
||||
heads runs 2, 4, 8 or 16 wide, not 12. Qwen3-235B has 4 and runs on 8.
|
||||
|
||||
**Mixture of experts.** A model of experts (Qwen3-235B-A22B, DeepSeek-V3,
|
||||
Llama 4, Mixtral) on more than one card is served with vLLM's
|
||||
`--enable-expert-parallel`: each card holds whole experts rather than a
|
||||
slice of every one, which is less traffic per token. It is read from
|
||||
`config.json` (`num_experts`, `n_routed_experts`, `num_local_experts`).
|
||||
|
||||
**When it does not fit.** A smaller precision first: an FP8 or AWQ
|
||||
repository of the same model, or a GGUF quantisation on llama.cpp if one
|
||||
node's cards hold it. Then more nodes. `llmgw ctl model plan REPO
|
||||
--precision bf16,fp8` shows each precision's fit from the model's config.
|
||||
|
||||
## What a way needs
|
||||
|
||||
Each way the plan offers says what a replica needs of each card, part by
|
||||
part, and of all its cards together:
|
||||
|
||||
```
|
||||
$ llmgw ctl model plan meta-llama/Llama-3.3-70B-Instruct --format safetensors --context 32768
|
||||
FILE SIZE BEST WAY A CARD OF ALL CARDS NOW
|
||||
original 131 GB 4 × h100-80g 37.4 GB 72 GB 150 GB free
|
||||
|
||||
Best: original on 4 × h100-80g: 37.4 GB a card (weights 32.9 + context 2.5 + engine 2.0) of 72 usable; 150 GB on 4 cards.
|
||||
The context costs 0.31 GB per 1000 tokens, the whole model (--context to plan another).
|
||||
```
|
||||
|
||||
- **weights:** the files, divided over the cards.
|
||||
- **context:** the KV cache for the context planned, divided likewise.
|
||||
- **engine:** the engine's own: 2 GB and a fiftieth of the weights for vLLM.
|
||||
- **usable:** 90% of a card; the rest is the driver's and fragmentation's.
|
||||
|
||||
The console's **Serve a model** shows the same for every way a file runs.
|
||||
A context longer than planned is checked against it before anything is
|
||||
fetched.
|
||||
|
||||
## Serving one
|
||||
|
||||
Without flags the plan's best way is taken:
|
||||
|
||||
```
|
||||
llmgw ctl model serve Qwen/Qwen3-235B-A22B-Instruct-2507-FP8 --format safetensors --context-length 32768
|
||||
```
|
||||
|
||||
Or a way given by hand, for instance two nodes of eight:
|
||||
|
||||
```
|
||||
llmgw ctl model serve meta-llama/Llama-3.3-70B-Instruct --format safetensors \
|
||||
--gpu-class h100-80g --gpus 8 --nodes 2 --env NCCL_DEBUG=INFO
|
||||
```
|
||||
|
||||
`--gpus` is the cards on each node and `--nodes` the nodes. The pipeline
|
||||
is the nodes and the tensor width the cards of each.
|
||||
|
||||
`--env KEY=VALUE` goes into the engine's environment on every node:
|
||||
`NCCL_DEBUG=INFO` while bringing a cluster up, `NCCL_IB_*` to steer it.
|
||||
Only the variables engines are tuned with are taken. llmgw already sets
|
||||
these:
|
||||
- `NCCL_IB_HCA` to the fabric's adapters;
|
||||
- `NCCL_IB_GID_INDEX` on RoCE;
|
||||
- `NCCL_SOCKET_IFNAME` to the management network;
|
||||
- for an NVLink domain spanning nodes, `NCCL_MNNVL_ENABLE`.
|
||||
|
||||
The job's first step says what it needs of each card. The weights then
|
||||
come to the mirror once and from it to each node. The startup timeout (20
|
||||
minutes unless the profile's `startup_timeout` says otherwise) runs from
|
||||
when a node's download last moved. So a model of hundreds of gigabytes is
|
||||
not failed while it is still arriving, and one whose download stops is
|
||||
failed that long after.
|
||||
|
||||
## Watching it come up
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| The replica and its shards, one per node, each with its download | `llmgw ctl replicas` |
|
||||
| Why it is not placed | `llmgw ctl explain-placement MODEL` |
|
||||
| A node's engine output (NCCL's lines with `NCCL_DEBUG=INFO`) | `llmgw ctl logs NODE --source SHARD` |
|
||||
| The bandwidth between two nodes, rail by rail | `llmgw ctl fabric-test NODE NODE` |
|
||||
| Each node's GPUs, NVLink and adapters | `llmgw doctor` on the node; its page under /admin |
|
||||
| Every step of a start, across nodes | the `replica start` trace ([install.md](install.md)) |
|
||||
|
||||
With `NCCL_DEBUG=INFO`, NCCL says which transport it took between nodes:
|
||||
`NET/IB` is the fabric. `NET/Socket` means it fell back to TCP, and the
|
||||
model will run, slowly.
|
||||
|
||||
## Bringing it up on new hardware
|
||||
|
||||
A first run on a fabric, in order. Each step is worth its output if
|
||||
something goes wrong.
|
||||
|
||||
1. **The nodes.** Add each with its fabric:
|
||||
`llmgw node add host --fabric ib-a:mlx5_0,mlx5_1`. Then
|
||||
`llmgw ctl nodes` shows every node ready, on `ib-a`.
|
||||
2. **The fabric.** `llmgw ctl fabric-test node1 node2`: each rail at its
|
||||
rated speed (a 400 Gb/s rail near 45 GB/s).
|
||||
3. **One node, several cards.** A dense 70B at bf16 on four or eight
|
||||
cards of one node, by the plan:
|
||||
`llmgw ctl model serve meta-llama/Llama-3.3-70B-Instruct --format safetensors`.
|
||||
Ready, then a question answered.
|
||||
4. **Two nodes.** The same model forced over two:
|
||||
`--gpu-class CLASS --gpus 8 --nodes 2 --env NCCL_DEBUG=INFO`, under
|
||||
another `--name`. Ready, answering, and `NET/IB` in both shards' logs.
|
||||
5. **Experts.** A mixture-of-experts model on one node's eight cards,
|
||||
for instance `Qwen/Qwen3-235B-A22B-Instruct-2507-FP8`: its profile
|
||||
carries `--enable-expert-parallel` (`llmgw ctl models`, or its
|
||||
definition from the admin API).
|
||||
6. **A node lost.** With the two-node replica serving, stop one node's
|
||||
agent (`systemctl stop llmgw-agent`): the replica fails and is placed
|
||||
again once the node is back or another has room. Nothing is left
|
||||
holding cards.
|
||||
|
||||
For each, worth keeping:
|
||||
- the time from serve to ready;
|
||||
- tokens a second from `llmgw ctl bench`;
|
||||
- the plan's per-card figure against what `nvidia-smi` shows once loaded;
|
||||
- anything it said that was wrong.
|
||||
+13
-3
@@ -54,10 +54,20 @@ on the page that creates it and never again.
|
||||
|
||||
### Connecting an editor
|
||||
|
||||
The account page's **Connect your editor** makes a key for Continue, Codex or
|
||||
Cline and shows what the tool needs with that key, this gateway's address and a
|
||||
model already in it:
|
||||
The account page's **Connect your editor** makes a key for VS Code, Continue,
|
||||
Codex or Cline and shows what the tool needs with that key, this gateway's
|
||||
address and a model already in it:
|
||||
|
||||
- **VS Code** — llmgw's own extension, downloaded from the gateway at
|
||||
`/setup/vscode/llmgw.vsix` with this gateway's address already in it. It
|
||||
offers every chat model the key may use to VS Code itself: the chat's model
|
||||
picker, agent mode, and any extension that asks VS Code for a language model
|
||||
(Cline's "VS Code LM API" provider among them), with no setup of their own.
|
||||
**llmgw: Sign In** in VS Code opens this page with VS Code chosen; the page's
|
||||
**Open in VS Code** link hands the key back, and the extension keeps it in
|
||||
VS Code's secret storage. A link the extension did not ask for is confirmed
|
||||
first, with the gateway's address shown, since it decides where code is sent.
|
||||
**llmgw: Enter API Key** takes a pasted one instead.
|
||||
- **Continue** — an assistant file of its own, `~/.continue/assistants/llmgw.yaml`,
|
||||
so their `config.yaml` is not touched.
|
||||
- **Codex** — a provider and a profile in `~/.codex/config.toml`, run with
|
||||
|
||||
+30
-2
@@ -8,7 +8,7 @@ explains every choice.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **A control plane** | One machine, or three for high availability (the store replicated, either one able to take over). Linux, x86-64 or ARM64: Debian 12, Ubuntu 22.04+ or RHEL 10. No GPU needed; 2 GB RAM, 20 GB disk each — 8 GB RAM when it is also a node running the starter model and the two judges. |
|
||||
| **A control plane** | One machine, or three for high availability (the store replicated, either one able to take over). Linux, x86-64 or ARM64: Debian 12, Ubuntu 22.04+ or RHEL 10. No GPU needed; 2 GB RAM, 20 GB disk each — 12 GB RAM when it is also a node running the starter model and the two judges, which setup checks before it installs anything; four cores or more, or the judges on machines of their own, keep a judgment within its wait. |
|
||||
| **GPU nodes** (optional to start) | The same Linux, with the NVIDIA driver installed. vLLM needs driver 525 or newer and a Turing GPU or newer; a V100 gets an older vLLM. |
|
||||
| **root** | on the control plane. On the other two of a three-machine setup, `sudo` (its password is asked for if it needs one); on nodes, `sudo` without a password. |
|
||||
| **SSH** | from the control plane to the other machines. The setup screens take a key, the ssh agent or a password; `node add` uses ssh keys. A machine nothing may log into joins with a code instead (`llmgw join`). |
|
||||
@@ -146,7 +146,35 @@ agent is stopped.
|
||||
## 4. Add a model
|
||||
|
||||
Open `/admin/mirror` and choose **Explore models**. It lists what is popular on
|
||||
Hugging Face, showing only what your nodes can run.
|
||||
Hugging Face, showing only what your nodes can run: judged by the cluster as it
|
||||
is now — the largest card and its compute capability, the most memory on one
|
||||
node — so a model too big for them, in a form those cards lack (FP8 needs 8.9),
|
||||
or in neither GGUF nor safetensors is left out, and the page says how many.
|
||||
Without GPUs it lists GGUF only. Untick **only what these nodes can run** to
|
||||
see everything. On a model's page a file too big for the cluster is shown
|
||||
without a **Fetch** button, with what it would need.
|
||||
|
||||
The **CPU tok/s** column is about how fast one reply would come on the CPU of
|
||||
the fastest node whose memory holds the model at Q4_K_M; a model's page gives
|
||||
it for each GGUF file by its own size. Every node's agent measures how fast its
|
||||
memory reads when it starts (it is in the agent's log as `memory_gbs`), and a
|
||||
token on a CPU reads every weight once: the estimate is that speed over the
|
||||
model's size — for a model of experts named like `30B-A3B`, its active part.
|
||||
On the test cluster it came within 15% of what llama.cpp measured. Several
|
||||
replies at once share the reads and each is slower.
|
||||
|
||||
**GPU tok/s** is the same on the cards, for the layout this cluster would
|
||||
give the model — the fewest cards that hold it, of each class, the fastest of
|
||||
those; its tooltip says which ("2× h100-80g, NVLink, vLLM", "2 nodes × 1×
|
||||
a100-80g over ib0 (InfiniBand 200 Gb/s), vLLM"). A card's memory bandwidth is
|
||||
known from its model name (an H100 SXM and an H100 PCIe differ by half); a card
|
||||
not in the table gets no figure. Tensor parallel across a node's cards divides
|
||||
what each reads, and adds two all-reduces a layer, short over NVLink and
|
||||
longer over PCIe; llama.cpp across cards splits by layer, so the cards take
|
||||
turns and a reply is no faster than on one; a pipeline across nodes takes turns
|
||||
too, and adds a hop over the fabric for each node. One card's figures were
|
||||
measured on the test cluster's RTX 6000; tensor parallel and the fabric are
|
||||
from what NCCL is known to take, not measured here.
|
||||
|
||||
1. Click a model to read its card.
|
||||
2. Pick one of its files, usually a `q4_k_m` for a first try, and click
|
||||
|
||||
+861
-1
File diff suppressed because it is too large.
Load diff
@@ -12,6 +12,7 @@ require (
|
||||
github.com/charmbracelet/x/ansi v0.11.6
|
||||
github.com/coreos/go-oidc/v3 v3.21.0
|
||||
github.com/go-jose/go-jose/v4 v4.1.4
|
||||
github.com/go-ldap/ldap/v3 v3.4.15
|
||||
github.com/go-zeromq/zmq4 v0.17.0
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/knadh/koanf/parsers/yaml v1.1.1
|
||||
@@ -35,6 +36,7 @@ require (
|
||||
golang.org/x/net v0.59.0
|
||||
golang.org/x/oauth2 v0.37.0
|
||||
golang.org/x/sync v0.23.0
|
||||
golang.org/x/sys v0.48.0
|
||||
golang.org/x/term v0.46.0
|
||||
golang.org/x/text v0.42.0
|
||||
google.golang.org/protobuf v1.36.12
|
||||
@@ -44,6 +46,7 @@ require (
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
filippo.io/hpke v0.4.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
@@ -56,6 +59,7 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
@@ -88,7 +92,6 @@ require (
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.46.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
|
||||
google.golang.org/grpc v1.83.1 // indirect
|
||||
|
||||
@@ -8,8 +8,12 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
|
||||
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
|
||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/NVIDIA/go-nvml v0.13.4-0 h1:o3jp9u2x1R9ShFE3v+Aesp55XOSIQFMJz/VGNUcJaNE=
|
||||
github.com/NVIDIA/go-nvml v0.13.4-0/go.mod h1:id63qwpoDWpFXwnwM6psDCSqW4BmNu6mWpr4YeQtPGo=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -46,8 +50,12 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-ldap/ldap/v3 v3.4.15 h1:eRB4f4Ecn1POL1mO86+uUDlecmyWsdiJwh60bAUS4UY=
|
||||
github.com/go-ldap/ldap/v3 v3.4.15/go.mod h1:jeD0c98EdZQCKKKKe4kTWs5aCf6y+j8KuYsYLznZSQk=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -69,6 +77,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
@@ -81,6 +91,18 @@ github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
type fakeDirectory struct {
|
||||
asked []string
|
||||
u store.User
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDirectory) SignIn(_ context.Context, login, _ string) (store.User, error) {
|
||||
f.asked = append(f.asked, login)
|
||||
return f.u, f.err
|
||||
}
|
||||
|
||||
// With a directory, a name the store has no password for is the
|
||||
// directory's to check; an account with a password here — the
|
||||
// administrator init made — is still checked here, so a directory that is
|
||||
// down locks nobody out.
|
||||
func TestPasswordsKeptElsewhereAreTheDirectorysToCheck(t *testing.T) {
|
||||
svc, st := newService(t)
|
||||
ctx := t.Context()
|
||||
hash, _ := HashPassword("local-secret")
|
||||
admin := store.User{ID: "usr-admin", Email: "admin@example.com", Name: "a", PasswordHash: hash,
|
||||
State: store.UserActive, CreatedAt: time.Now()}
|
||||
ada := store.User{ID: "usr-ada", Email: "ada@corp.example", Name: "Ada", PasswordHash: "!ldap",
|
||||
State: store.UserActive, CreatedAt: time.Now()}
|
||||
for _, u := range []store.User{admin, ada} {
|
||||
if err := st.CreateUser(ctx, u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
dir := &fakeDirectory{u: ada}
|
||||
svc.SetDirectory(dir)
|
||||
|
||||
if _, _, err := svc.SignIn(ctx, "admin@example.com", "local-secret"); err != nil || len(dir.asked) != 0 {
|
||||
t.Fatalf("the local administrator: %v, directory asked %v", err, dir.asked)
|
||||
}
|
||||
// A name, not an address, and an account kept by the directory.
|
||||
for _, login := range []string{`CORP\ada`, "ada@corp.example"} {
|
||||
if tok, _, err := svc.SignIn(ctx, login, "ad-secret"); err != nil || tok == "" {
|
||||
t.Errorf("%s: %v", login, err)
|
||||
}
|
||||
}
|
||||
if len(dir.asked) != 2 || dir.asked[0] != `CORP\ada` {
|
||||
t.Errorf("the directory was asked %v", dir.asked)
|
||||
}
|
||||
dir.err = ErrBadCredentials
|
||||
if _, _, err := svc.SignIn(ctx, "ada", "wrong"); !errors.Is(err, ErrBadCredentials) {
|
||||
t.Errorf("a password the directory refused: %v", err)
|
||||
}
|
||||
// A reset link would give a directory's account a password here.
|
||||
if _, _, err := svc.RequestReset(ctx, st, "ada@corp.example"); !errors.Is(err, ErrNoReset) {
|
||||
t.Errorf("a directory's account was sent a reset link: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -80,8 +80,26 @@ type Service struct {
|
||||
|
||||
mu sync.Mutex
|
||||
attempts map[string]*attemptWindow
|
||||
|
||||
// dir checks the passwords of accounts this store keeps none for.
|
||||
dir Directory
|
||||
}
|
||||
|
||||
// Directory checks a password against an outside directory — Active
|
||||
// Directory over LDAP — and answers the account to sign in: made or kept in
|
||||
// step with the directory. Its errors are this package's ErrBadCredentials
|
||||
// and ErrSuspended for the person's own, and anything else for the
|
||||
// directory's.
|
||||
type Directory interface {
|
||||
SignIn(ctx context.Context, login, password string) (store.User, error)
|
||||
}
|
||||
|
||||
// SetDirectory has passwords for accounts with none here, and names that are
|
||||
// not addresses, checked by the directory. An account with a password here
|
||||
// — the administrator init made — is still checked here, so a directory that
|
||||
// is down locks nobody out.
|
||||
func (s *Service) SetDirectory(d Directory) { s.dir = d }
|
||||
|
||||
type attemptWindow struct {
|
||||
count int
|
||||
since time.Time
|
||||
@@ -137,6 +155,7 @@ func (s *Service) SignUp(ctx context.Context, email, name, password string) erro
|
||||
// SignIn checks a password and opens a session, returning the session token and
|
||||
// the CSRF token that must accompany the forms in it.
|
||||
func (s *Service) SignIn(ctx context.Context, email, password string) (token, csrf string, err error) {
|
||||
login := strings.TrimSpace(email)
|
||||
email = store.NormalizeEmail(email)
|
||||
// Counted per address and the client trying it, so somebody guessing
|
||||
// from elsewhere cannot lock the owner out of their own account; and per
|
||||
@@ -151,6 +170,15 @@ func (s *Service) SignIn(ctx context.Context, email, password string) (token, cs
|
||||
}
|
||||
|
||||
u, err := s.store.UserByEmail(ctx, email)
|
||||
// No password kept here — the directory's, or nobody's — and a
|
||||
// directory to ask: it decides.
|
||||
if s.dir != nil && (errors.Is(err, store.ErrNotFound) || (err == nil && strings.HasPrefix(u.PasswordHash, "!"))) {
|
||||
if u, err = s.dir.SignIn(ctx, login, password); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
s.clearAttempts(byClient)
|
||||
return s.open(ctx, u)
|
||||
}
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
// Verify anyway, against a hash that cannot match. Returning early here
|
||||
// would let a stopwatch enumerate every account on the system.
|
||||
@@ -180,7 +208,11 @@ func (s *Service) SignIn(ctx context.Context, email, password string) (token, cs
|
||||
case store.UserSuspended:
|
||||
return "", "", ErrSuspended
|
||||
}
|
||||
return s.open(ctx, u)
|
||||
}
|
||||
|
||||
// open makes a session for a person whose password was right.
|
||||
func (s *Service) open(ctx context.Context, u store.User) (token, csrf string, err error) {
|
||||
if token, err = store.NewSessionToken(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -402,7 +434,10 @@ func (s *Service) RequestReset(ctx context.Context, rs ResetStore, email string)
|
||||
return "", store.User{}, ErrTooManyTries
|
||||
}
|
||||
u, err := rs.UserByEmail(ctx, email)
|
||||
if err != nil || u.PasswordHash == "" || u.State != store.UserActive {
|
||||
// An account whose password is kept elsewhere — "!oidc", "!scim",
|
||||
// "!ldap" — gets no link: setting one here would give it a second way
|
||||
// in that its directory knows nothing of, and outlives its leaving.
|
||||
if err != nil || u.PasswordHash == "" || strings.HasPrefix(u.PasswordHash, "!") || u.State != store.UserActive {
|
||||
return "", store.User{}, ErrNoReset
|
||||
}
|
||||
token, err := rs.CreatePasswordReset(ctx, u.ID, ResetLinkTTL)
|
||||
|
||||
@@ -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
|
||||
@@ -323,7 +351,7 @@ func (a *Accountant) Record(res Reservation, u store.Usage) {
|
||||
if u.ServedModel != "" && u.ServedModel != u.Model && a.pricing != nil {
|
||||
price = a.pricing.For(u.ServedModel)
|
||||
}
|
||||
u.CostMicros = price.Cost(u.PromptTokens, u.CompletionTokens)
|
||||
u.CostMicros = price.Cost(u.PromptTokens, u.CachedTokens, u.CompletionTokens, u.Images, u.Meshes)
|
||||
for _, b := range res.Budgets {
|
||||
a.budgets.Add(b.Counter, b.Period, u.CostMicros)
|
||||
}
|
||||
|
||||
@@ -39,17 +39,44 @@ func TestCostRoundsUpSoSmallRequestsAreNotFree(t *testing.T) {
|
||||
// Rounding down would make a stream of tiny requests cost nothing, which is
|
||||
// exactly the traffic shape a quota exists to bound.
|
||||
p := Price{PromptPer1k: 1000, CompletionPer1k: 3000}
|
||||
if got := p.Cost(1, 0); got != 1 {
|
||||
if got := p.Cost(1, 0, 0, 0, 0); got != 1 {
|
||||
t.Errorf("one prompt token cost %d", got)
|
||||
}
|
||||
if got := p.Cost(1000, 1000); got != 4000 {
|
||||
if got := p.Cost(1000, 0, 1000, 0, 0); got != 4000 {
|
||||
t.Errorf("a thousand of each cost %d, want 4000", got)
|
||||
}
|
||||
if got := p.Cost(0, 0); got != 0 {
|
||||
if got := p.Cost(0, 0, 0, 0, 0); got != 0 {
|
||||
t.Errorf("an empty request cost %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACachedPromptCostsTheCachedRate(t *testing.T) {
|
||||
p := Price{PromptPer1k: 1000, CachedPer1k: 100, CompletionPer1k: 3000}
|
||||
// 10,000 prompt tokens of which 9,000 were read from the cache: 1,000 at
|
||||
// the prompt rate and 9,000 at the cached one.
|
||||
if got, want := p.Cost(10000, 9000, 0, 0, 0), int64(1000+900); got != want {
|
||||
t.Errorf("a 90%% cached prompt cost %d, want %d", got, want)
|
||||
}
|
||||
// More cached than prompt is an engine's miscount, not a refund.
|
||||
if got := p.Cost(100, 500, 0, 0, 0); got != 10 {
|
||||
t.Errorf("cached beyond the prompt cost %d, want 10", got)
|
||||
}
|
||||
// No cached rate: cached tokens cost what any prompt token does.
|
||||
if got := (Price{PromptPer1k: 1000}).Cost(10000, 9000, 0, 0, 0); got != 10000 {
|
||||
t.Errorf("without a cached rate a cached prompt cost %d, want 10000", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPictureCostsItsPriceOnTopOfItsPrompt(t *testing.T) {
|
||||
p := Price{PromptPer1k: 1000, PerImage: 50000}
|
||||
if got := p.Cost(20, 0, 0, 3, 0); got != 20+150000 {
|
||||
t.Errorf("three pictures from a 20-token prompt cost %d", got)
|
||||
}
|
||||
if (Price{PerImage: 1}).Zero() {
|
||||
t.Error("a model priced per picture reads as free, and would pass a spent budget")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroPricedModelsStayAvailableWhenTheBudgetIsSpent(t *testing.T) {
|
||||
// This is what makes budget fallback work: a key with nothing left can
|
||||
// still be served something that costs nothing.
|
||||
@@ -189,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 {
|
||||
|
||||
@@ -18,6 +18,18 @@ import (
|
||||
type Price struct {
|
||||
PromptPer1k int64 `koanf:"prompt_per_1k"`
|
||||
CompletionPer1k int64 `koanf:"completion_per_1k"`
|
||||
// CachedPer1k is what a prompt token costs that the engine or provider
|
||||
// read from its cache instead of computing: the part of prompt_tokens
|
||||
// the answer counts as cached_tokens. Zero charges those at
|
||||
// PromptPer1k, as before there was a rate for them.
|
||||
CachedPer1k int64 `koanf:"cached_per_1k"`
|
||||
// PerImage is what one picture costs, for a model that makes them: a
|
||||
// picture's cost is the card's time, which its prompt's few tokens do
|
||||
// not measure.
|
||||
PerImage int64 `koanf:"per_image"`
|
||||
// PerMesh is what one 3D model costs: minutes of a card, which no
|
||||
// token count measures.
|
||||
PerMesh int64 `koanf:"per_mesh"`
|
||||
}
|
||||
|
||||
// Zero reports whether this model spends nothing.
|
||||
@@ -25,14 +37,26 @@ type Price struct {
|
||||
// It matters beyond arithmetic: a zero-cost model stays available to a key
|
||||
// whose budget is spent, which is what makes the plan's budget fallback work
|
||||
// (§7.7).
|
||||
func (p Price) Zero() bool { return p.PromptPer1k == 0 && p.CompletionPer1k == 0 }
|
||||
func (p Price) Zero() bool {
|
||||
return p.PromptPer1k == 0 && p.CompletionPer1k == 0 && p.CachedPer1k == 0 && p.PerImage == 0 && p.PerMesh == 0
|
||||
}
|
||||
|
||||
// Cost computes what a request spent, rounding up.
|
||||
//
|
||||
// Up rather than to nearest: rounding down would make a stream of tiny requests
|
||||
// free, which is exactly the shape of traffic a quota exists to bound.
|
||||
func (p Price) Cost(promptTokens, completionTokens int64) int64 {
|
||||
return divUp(promptTokens*p.PromptPer1k, 1000) + divUp(completionTokens*p.CompletionPer1k, 1000)
|
||||
//
|
||||
// cachedTokens are counted within promptTokens, as OpenAI's usage counts
|
||||
// them, and are charged at the cached rate instead of the prompt rate.
|
||||
func (p Price) Cost(promptTokens, cachedTokens, completionTokens, images, meshes int64) int64 {
|
||||
cachedTokens = min(max(cachedTokens, 0), max(promptTokens, 0))
|
||||
cachedRate := p.CachedPer1k
|
||||
if cachedRate == 0 {
|
||||
cachedRate = p.PromptPer1k
|
||||
}
|
||||
return divUp((promptTokens-cachedTokens)*p.PromptPer1k+cachedTokens*cachedRate, 1000) +
|
||||
divUp(completionTokens*p.CompletionPer1k, 1000) +
|
||||
max(images, 0)*p.PerImage + max(meshes, 0)*p.PerMesh
|
||||
}
|
||||
|
||||
func divUp(n, d int64) 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)
|
||||
}
|
||||
}
|
||||
+103
-14
@@ -6,11 +6,13 @@ import (
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -45,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
|
||||
@@ -183,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(),
|
||||
@@ -391,8 +395,11 @@ func (a *Agent) settleAdopted(sh *supervisor.Shard, o supervisor.Orphan) {
|
||||
_ = a.sup.Stop(ctx, o.ShardID)
|
||||
return
|
||||
}
|
||||
// What it computes on, for one started before the agent kept that.
|
||||
if a.sup.ComputeOf(o.ShardID) == nil {
|
||||
// What it computes on, for one started before the agent kept that — or
|
||||
// kept as the CPU for a GPU placement by an agent that did not follow
|
||||
// an engine into a process group of its own, which the check above has
|
||||
// just found on its GPUs.
|
||||
if c := a.sup.ComputeOf(o.ShardID); c == nil || (len(o.Spec.GPUIndices) > 0 && c.Backend == "CPU") {
|
||||
if c, ok := a.gpuCompute(ctx, o.Spec.GPUIndices, sh.Status().PID); ok {
|
||||
a.sup.SetCompute(o.ShardID, c)
|
||||
}
|
||||
@@ -512,6 +519,7 @@ 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()
|
||||
a.mu.Lock()
|
||||
a.inv = inv
|
||||
a.prov.Host = host
|
||||
@@ -568,6 +576,9 @@ func (a *Agent) session(ctx context.Context, server string) error {
|
||||
reg.Host = &llmgwv1.Host{
|
||||
OsId: h.OSID, OsVersion: h.OSVersion, OsCodename: h.OSCodename,
|
||||
Arch: h.Arch, CpuFlags: h.CPUFlags, NvidiaDriver: h.NVIDIADriver,
|
||||
// What is measured by now, not what the startup scan found before
|
||||
// the measurement ended: a reconnect would otherwise say 0.
|
||||
MemBandwidthGbs: a.memBandwidth(),
|
||||
}
|
||||
|
||||
if err := stream.Send(&llmgwv1.AgentMessage{
|
||||
@@ -665,6 +676,7 @@ func (a *Agent) heartbeat() *llmgwv1.AgentMessage {
|
||||
// without anybody restarting anything.
|
||||
hb.MemoryTotalMb = int64(discovery.MemoryTotalMB())
|
||||
hb.CpuCount = int32(runtime.NumCPU())
|
||||
hb.MemBandwidthGbs = a.memBandwidth()
|
||||
|
||||
a.reprobeEngines()
|
||||
a.mu.Lock()
|
||||
@@ -786,6 +798,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 =
|
||||
@@ -868,6 +885,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)
|
||||
}
|
||||
}
|
||||
@@ -1119,11 +1137,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); 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
|
||||
@@ -1131,11 +1149,37 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<
|
||||
stop()
|
||||
spec.Profile.ModelPath = path
|
||||
}
|
||||
// A shared ComfyUI replica's members' weights, each fetched like the
|
||||
// model's, and handed to the gate where they landed.
|
||||
if raw := spec.Profile.Env[engines.SharedModelsEnv]; raw != "" {
|
||||
var ms []map[string]string
|
||||
if err := json.Unmarshal([]byte(raw), &ms); err != nil {
|
||||
report(supervisor.Failed, "the shared replica's models are not readable: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
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,
|
||||
Phase: p.Phase}})
|
||||
})
|
||||
path, err := a.weights().Ensure(ctx, m["source"], m["allow_pickle"] == "true")
|
||||
stop()
|
||||
if err != nil {
|
||||
report(supervisor.Failed, fmt.Sprintf("model %s: %v", m["name"], err), nil)
|
||||
return
|
||||
}
|
||||
ms[i]["path"] = path
|
||||
}
|
||||
b, _ := json.Marshal(ms)
|
||||
spec.Profile.Env = maps.Clone(spec.Profile.Env)
|
||||
spec.Profile.Env[engines.SharedModelsEnv] = string(b)
|
||||
}
|
||||
// Adapters are fetched like the model, and before the engine starts: it
|
||||
// loads them at start, and one missing is a replica that answers for the
|
||||
// base model under the adapter's name.
|
||||
for i, ad := range spec.Profile.Adapters {
|
||||
path, err := a.weights().Ensure(ctx, ad.Path)
|
||||
path, err := a.weights().Ensure(ctx, ad.Path, spec.Profile.AllowPickle)
|
||||
if err != nil {
|
||||
report(supervisor.Failed, fmt.Sprintf("adapter %s: %v", ad.Name, err), nil)
|
||||
return
|
||||
@@ -1391,11 +1435,14 @@ func Client(cfg tlsconf.Config, timeout time.Duration) (*http.Client, error) {
|
||||
// off — is never closed: nothing sends the FIN, and the agent's heartbeats sit
|
||||
// in the socket's buffer until the kernel gives up retransmitting, fifteen
|
||||
// minutes later. Found by switching off a leader: the standby took over in 14
|
||||
// seconds and the node stayed with the dead one. An HTTP/2 ping after ten
|
||||
// quiet seconds, given five to answer, finds it in a quarter of a minute.
|
||||
// seconds and the node stayed with the dead one. An HTTP/2 ping after five
|
||||
// quiet seconds, given three to answer, finds it in eight: by the time the
|
||||
// standby leads, as it does within the lease's ten seconds, the node is on
|
||||
// its way to it. At fifteen, with the lease at fifteen too, the new leader
|
||||
// went without its nodes, and so its judges, for half a minute.
|
||||
const (
|
||||
streamIdle = 10 * time.Second
|
||||
streamPing = 5 * time.Second
|
||||
streamIdle = 5 * time.Second
|
||||
streamPing = 3 * time.Second
|
||||
)
|
||||
|
||||
// localizeSpec fills in what only this node knows.
|
||||
@@ -1476,18 +1523,25 @@ func ResolveManagement(c Config) (addr, iface string) {
|
||||
// shard computes on as soon as that is known: an adopted engine's is found
|
||||
// after registration, which carried what was known then.
|
||||
func (a *Agent) reportCompute(ctx context.Context, out chan<- *llmgwv1.AgentMessage) {
|
||||
sent := map[string]bool{}
|
||||
// What was sent for each shard, so a compute that changes is sent again:
|
||||
// an adopted shard's kept one is sent at once, and adopting it may then
|
||||
// find it was kept wrong.
|
||||
sent := map[string]string{}
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
for range 60 {
|
||||
for _, st := range a.sup.Statuses() {
|
||||
if st.State.Terminal() || sent[st.ID] {
|
||||
if st.State.Terminal() {
|
||||
continue
|
||||
}
|
||||
c := a.sup.ComputeOf(st.ID)
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
said := fmt.Sprint(*c)
|
||||
if sent[st.ID] == said {
|
||||
continue
|
||||
}
|
||||
sh, err := a.sup.Get(st.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -1498,7 +1552,7 @@ func (a *Agent) reportCompute(ctx context.Context, out chan<- *llmgwv1.AgentMess
|
||||
}}}
|
||||
select {
|
||||
case out <- msg:
|
||||
sent[st.ID] = true
|
||||
sent[st.ID] = said
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -1510,3 +1564,38 @@ func (a *Agent) reportCompute(ctx context.Context, out chan<- *llmgwv1.AgentMess
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// memBandwidth is the node's memory read rate in GB/s, 0 until measured.
|
||||
// Measured in the background, the first time it is asked for: it reads half
|
||||
// a gigabyte, which must not hold up an agent reaching its control plane,
|
||||
// and the heartbeat carries it when it is known. Not at once but after a
|
||||
// minute or so, chosen at random, and again every hour, keeping the best:
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
time.Sleep(time.Hour)
|
||||
}
|
||||
}()
|
||||
})
|
||||
return math.Float64frombits(bwBits.Load())
|
||||
}
|
||||
|
||||
// bandwidthDelay is how long an agent waits before measuring, so agents
|
||||
// started together measure apart.
|
||||
var bandwidthDelay = func() time.Duration { return 10*time.Second + rand.N(110*time.Second) }
|
||||
|
||||
var (
|
||||
bwOnce sync.Once
|
||||
bwBits 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})
|
||||
@@ -671,7 +671,7 @@ func TestAShardWithMissingWeightsFailsBeforeLaunching(t *testing.T) {
|
||||
// nobody is watching — so it is caught here, where the message can say
|
||||
// what is actually wrong.
|
||||
a := &Agent{cfg: Config{WeightsCache: t.TempDir()}, log: slog.New(slog.DiscardHandler)}
|
||||
_, err := a.weights().Ensure(t.Context(), filepath.Join(t.TempDir(), "not-there.gguf"))
|
||||
_, err := a.weights().Ensure(t.Context(), filepath.Join(t.TempDir(), "not-there.gguf"), false)
|
||||
if err == nil {
|
||||
t.Fatal("a missing weights file was accepted")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -52,6 +52,8 @@ type GPU struct {
|
||||
// memory the instance's share. Utilisation stays unreported, because no
|
||||
// driver measures it per instance.
|
||||
MIG *MIGDevice `json:"mig,omitempty"`
|
||||
// PCIe is how the card is linked to its host, zero when not read.
|
||||
PCIe PCIeLink `json:"pcie,omitempty"`
|
||||
}
|
||||
|
||||
// GPUHealth records conditions that make a GPU unsafe to schedule onto.
|
||||
|
||||
@@ -67,10 +67,23 @@ func DiscoverGPUsNVML(_ context.Context) ([]GPU, error) {
|
||||
if major, minor, ret := dev.GetCudaComputeCapability(); ret == nvml.SUCCESS {
|
||||
g.ComputeCapability = fmt.Sprintf("%d.%d", major, minor)
|
||||
}
|
||||
if v, ret := dev.GetMaxPcieLinkGeneration(); ret == nvml.SUCCESS {
|
||||
g.PCIe.Gen = v
|
||||
}
|
||||
if v, ret := dev.GetGpuMaxPcieLinkGeneration(); ret == nvml.SUCCESS {
|
||||
g.PCIe.GPUGen = v
|
||||
}
|
||||
if v, ret := dev.GetCurrPcieLinkWidth(); ret == nvml.SUCCESS {
|
||||
g.PCIe.Width = v
|
||||
}
|
||||
if v, ret := dev.GetMaxPcieLinkWidth(); ret == nvml.SUCCESS {
|
||||
g.PCIe.WidthMax = v
|
||||
}
|
||||
if pci, ret := dev.GetPciInfo(); ret == nvml.SUCCESS {
|
||||
g.PCIBusID = busIDString(pci.BusId[:])
|
||||
byBusID[normalizeBusID(g.PCIBusID)] = i
|
||||
}
|
||||
g.PCIe = withSysfs(g.PCIe, "/sys/bus/pci/devices", g.PCIBusID)
|
||||
if t, ret := dev.GetTemperature(nvml.TEMPERATURE_GPU); ret == nvml.SUCCESS {
|
||||
g.Health.TemperatureC = int(t)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ func DiscoverGPUsSMI(ctx context.Context, binary string) ([]GPU, error) {
|
||||
if err != nil || len(cards) == 0 {
|
||||
return cards, err
|
||||
}
|
||||
readPCIe(ctx, binary, cards)
|
||||
// The query lists whole cards, MIG mode or not. A card in MIG mode is
|
||||
// replaced by its instances, which only -L names and only the full
|
||||
// report sizes; asked only when some card could be in MIG mode, since
|
||||
|
||||
@@ -3,6 +3,7 @@ package discovery
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -30,6 +31,10 @@ type Host struct {
|
||||
// MinComputeCapability is the oldest GPU's, 0 when there are none or it
|
||||
// is not known. Not read here but from the GPU inventory, by the agent.
|
||||
MinComputeCapability float64 `json:"min_compute_capability,omitempty"`
|
||||
// MemBandwidthGBs is how fast the memory reads, in GB/s, 0 when not
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// Family is the packaging family: "deb", "rpm", or "" when neither is known.
|
||||
@@ -142,17 +147,29 @@ func cpuFlagsFrom(path string) []string {
|
||||
// nvidiaDriverFrom reads the loaded kernel module's version, which exists
|
||||
// exactly when a driver is loaded — unlike nvidia-smi, which can be installed
|
||||
// on a machine whose module failed to load.
|
||||
var driverVersion = regexp.MustCompile(`^[0-9]+\.[0-9]+(\.[0-9]+)?$`)
|
||||
|
||||
func nvidiaDriverFrom(path string) string {
|
||||
b, err := os.ReadFile(path) //nolint:gosec // a fixed system path
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
// "NVRM version: NVIDIA UNIX x86_64 Kernel Module 570.86.15 Thu ..."
|
||||
// "NVRM version: NVIDIA UNIX x86_64 Kernel Module 570.86.15 Thu ...", or
|
||||
// for the open module, the default since 560, "NVIDIA UNIX Open Kernel
|
||||
// Module for x86_64 580.82.07 Release Build ...": the first version
|
||||
// after "Module", not the word after it. Read as "for", the driver was
|
||||
// unknown, and every build was tried whatever driver it needs.
|
||||
fields := strings.Fields(string(b))
|
||||
for i, f := range fields {
|
||||
if f == "Module" && i+1 < len(fields) {
|
||||
return fields[i+1]
|
||||
if f != "Module" {
|
||||
continue
|
||||
}
|
||||
for _, v := range fields[i+1:] {
|
||||
if driverVersion.MatchString(v) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNVIDIADriverFrom(t *testing.T) {
|
||||
for _, tc := range []struct{ text, want string }{
|
||||
{"NVRM version: NVIDIA UNIX x86_64 Kernel Module 570.86.15 Thu Jan 23 21:30:38 UTC 2025\nGCC version: gcc version 12.2.0\n", "570.86.15"},
|
||||
{"NVRM version: NVIDIA UNIX Open Kernel Module for x86_64 580.82.07 Release Build (dvs-builder@U16-I3-B03-4-3) Wed Aug 27 00:51:12 UTC 2025\n", "580.82.07"},
|
||||
{"NVRM version: NVIDIA UNIX Open Kernel Module for aarch64 580.95.05 Release Build\n", "580.95.05"},
|
||||
{"NVRM version: NVIDIA UNIX x86_64 Kernel Module 535.54 Mon\n", "535.54"},
|
||||
{"something else\n", ""},
|
||||
} {
|
||||
p := filepath.Join(t.TempDir(), "version")
|
||||
if err := os.WriteFile(p, []byte(tc.text), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := nvidiaDriverFrom(p); got != tc.want {
|
||||
t.Errorf("%q: got %q, want %q", tc.text, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// How fast this machine reads its memory, which on a CPU is what decides how
|
||||
// fast a model answers: every token of a reply reads all the weights it uses
|
||||
// once, so a node that reads 40 GB/s writes a 4 GB model's tokens about ten
|
||||
// times a second, whatever its core count. Measured once when the agent
|
||||
// starts, in a fraction of a second, by every CPU summing a share of a buffer
|
||||
// too large for any cache — a VM sharing its host's memory with others gets
|
||||
// its share, which is the number that matters for it.
|
||||
|
||||
// bandwidthBuffer is what is read at most; the least is eight times the
|
||||
// processor's last cache, and never under minBuffer. Smaller, it would be
|
||||
// the cache that was measured — some hold a gigabyte — and the figure kept
|
||||
// would be too high.
|
||||
const (
|
||||
bandwidthBuffer = 2 << 30
|
||||
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 {
|
||||
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
|
||||
// for a buffer past the cache, none is measured: the hourly retry
|
||||
// measures it when there is.
|
||||
if avail := memAvailable("/proc/meminfo"); avail > 0 {
|
||||
size = min(size, avail/4)
|
||||
}
|
||||
if size < max(minBuffer, 8*lastCacheBytes("/sys/devices/system/cpu")) {
|
||||
return 0
|
||||
}
|
||||
buf := make([]uint64, size/8)
|
||||
for i := range buf {
|
||||
buf[i] = uint64(i)
|
||||
}
|
||||
workers := runtime.NumCPU()
|
||||
chunk := (len(buf) + workers - 1) / workers
|
||||
best := 0.0
|
||||
for pass := 0; pass < 4; pass++ {
|
||||
var wg sync.WaitGroup
|
||||
start := time.Now()
|
||||
for w := 0; w < workers; w++ {
|
||||
lo, hi := w*chunk, min((w+1)*chunk, len(buf))
|
||||
if lo >= hi {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(part []uint64) {
|
||||
defer wg.Done()
|
||||
sink(sum(part))
|
||||
}(buf[lo:hi])
|
||||
}
|
||||
wg.Wait()
|
||||
if s := time.Since(start).Seconds(); s > 0 {
|
||||
best = max(best, float64(size)/s/1e9)
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// sum reads every word, eight at a time so the loop is not what limits it.
|
||||
func sum(p []uint64) uint64 {
|
||||
var a, b, c, d, e, f, g, h uint64
|
||||
i := 0
|
||||
for ; i+8 <= len(p); i += 8 {
|
||||
a += p[i]
|
||||
b += p[i+1]
|
||||
c += p[i+2]
|
||||
d += p[i+3]
|
||||
e += p[i+4]
|
||||
f += p[i+5]
|
||||
g += p[i+6]
|
||||
h += p[i+7]
|
||||
}
|
||||
for ; i < len(p); i++ {
|
||||
a += p[i]
|
||||
}
|
||||
return a + b + c + d + e + f + g + h
|
||||
}
|
||||
|
||||
// sinkValue keeps the sums alive, so the reads are not optimised away.
|
||||
var sinkValue struct {
|
||||
sync.Mutex
|
||||
v uint64
|
||||
}
|
||||
|
||||
func sink(v uint64) {
|
||||
sinkValue.Lock()
|
||||
sinkValue.v += v
|
||||
sinkValue.Unlock()
|
||||
}
|
||||
|
||||
// memAvailable is MemAvailable in bytes, 0 when unknown.
|
||||
func memAvailable(path string) int64 {
|
||||
f, err := os.Open(path) //nolint:gosec // a fixed system path
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) >= 2 && fields[0] == "MemAvailable:" {
|
||||
kb, _ := strconv.ParseInt(fields[1], 10, 64)
|
||||
return kb << 10
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// lastCacheBytes is the largest cache any CPU reports, 0 when unknown.
|
||||
func lastCacheBytes(root string) int64 {
|
||||
paths, _ := filepath.Glob(filepath.Join(root, "cpu*", "cache", "index*", "size"))
|
||||
var most int64
|
||||
for _, p := range paths {
|
||||
b, err := os.ReadFile(p) //nolint:gosec // under /sys
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
v := strings.TrimSpace(string(b))
|
||||
mult := int64(1)
|
||||
switch {
|
||||
case strings.HasSuffix(v, "K"):
|
||||
mult, v = 1<<10, strings.TrimSuffix(v, "K")
|
||||
case strings.HasSuffix(v, "M"):
|
||||
mult, v = 1<<20, strings.TrimSuffix(v, "M")
|
||||
}
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
most = max(most, n*mult)
|
||||
}
|
||||
}
|
||||
return most
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMemoryBandwidthIsMeasured(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("reads half a gigabyte")
|
||||
}
|
||||
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)
|
||||
}
|
||||
t.Logf("%.1f GB/s", bw)
|
||||
}
|
||||
|
||||
func TestTheLastCacheIsReadFromSys(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for path, size := range map[string]string{"cpu0/cache/index0/size": "48K", "cpu0/cache/index3/size": "96M"} {
|
||||
p := filepath.Join(root, path)
|
||||
_ = os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
_ = os.WriteFile(p, []byte(size+"\n"), 0o644)
|
||||
}
|
||||
if got := lastCacheBytes(root); got != 96<<20 {
|
||||
t.Errorf("%d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A card in the wrong slot: an x16 card in a slot wired x8 or x4, behind a
|
||||
// riser, or in a slot of an older PCIe generation than the card's, links
|
||||
// slower than it was built to, and nothing says so. A model on the card
|
||||
// still decodes at the card's own memory speed, but it loads slower, a
|
||||
// tensor-parallel replica over PCIe is slower, and so is anything that
|
||||
// spills to the host. The driver says how the link stands and what the
|
||||
// card could do; the difference is reported where the node's warnings are.
|
||||
|
||||
// PCIeLink is how a card is linked to its host.
|
||||
type PCIeLink struct {
|
||||
// Gen is the fastest generation the card and the slot together allow —
|
||||
// not the current one, which drops while the card idles, to save power.
|
||||
// GPUGen is the card's own fastest, 0 when the driver does not say.
|
||||
Gen int `json:"gen,omitempty"`
|
||||
GPUGen int `json:"gpu_gen,omitempty"`
|
||||
// Width is the lanes in use now, WidthMax the card's most.
|
||||
Width int `json:"width,omitempty"`
|
||||
WidthMax int `json:"width_max,omitempty"`
|
||||
}
|
||||
|
||||
// laneGBs is one lane's usable rate in GB/s, by generation.
|
||||
var laneGBs = map[int]float64{1: 0.25, 2: 0.5, 3: 0.985, 4: 1.969, 5: 3.938, 6: 7.877}
|
||||
|
||||
// GBs is what a link of gen and width carries, in GB/s each way.
|
||||
func pcieGBs(gen, width int) float64 { return laneGBs[gen] * float64(width) }
|
||||
|
||||
// Problem says how a card's link falls short of what the card could do,
|
||||
// empty when it does not or the driver did not say.
|
||||
func (p PCIeLink) Problem() string {
|
||||
gen := p.Gen
|
||||
if p.GPUGen > 0 && gen == 0 {
|
||||
gen = p.GPUGen
|
||||
}
|
||||
slowGen := p.GPUGen > 0 && p.Gen > 0 && p.Gen < p.GPUGen
|
||||
narrow := p.WidthMax > 0 && p.Width > 0 && p.Width < p.WidthMax
|
||||
if !slowGen && !narrow {
|
||||
return ""
|
||||
}
|
||||
could := pcieGBs(max(p.GPUGen, gen), p.WidthMax)
|
||||
has := pcieGBs(gen, p.Width)
|
||||
var why []string
|
||||
if narrow {
|
||||
why = append(why, fmt.Sprintf("x%d of its x%d lanes", p.Width, p.WidthMax))
|
||||
}
|
||||
if slowGen {
|
||||
why = append(why, fmt.Sprintf("PCIe Gen%d of its Gen%d", p.Gen, p.GPUGen))
|
||||
}
|
||||
return fmt.Sprintf("linked at %s: %.1f GB/s where it could have %.1f. "+
|
||||
"Check the slot (wired fewer lanes, or an older generation), a riser, or the BIOS's PCIe setting",
|
||||
strings.Join(why, " and "), has, could)
|
||||
}
|
||||
|
||||
// pcieFields are asked apart from the rest, and the card's own fastest
|
||||
// generation apart again: a driver that does not know a field refuses the
|
||||
// whole query, and these must not cost a node its cards.
|
||||
var pcieFields = [][]string{
|
||||
{"index", "pcie.link.gen.max", "pcie.link.width.current", "pcie.link.width.max", "pcie.link.gen.gpumax"},
|
||||
{"index", "pcie.link.gen.max", "pcie.link.width.current", "pcie.link.width.max"},
|
||||
}
|
||||
|
||||
// readPCIe fills each card's link from nvidia-smi, leaving it empty when
|
||||
// the driver says nothing of it.
|
||||
func readPCIe(ctx context.Context, binary string, cards []GPU) {
|
||||
for _, fields := range pcieFields {
|
||||
out, err := exec.CommandContext(ctx, binary, "--query-gpu="+strings.Join(fields, ","),
|
||||
"--format=csv,noheader,nounits").Output()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
links := parsePCIe(string(out))
|
||||
for i := range cards {
|
||||
if l, ok := links[cards[i].Index]; ok && cards[i].MIG == nil {
|
||||
cards[i].PCIe = l
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
for i := range cards {
|
||||
if cards[i].MIG == nil {
|
||||
cards[i].PCIe = withSysfs(cards[i].PCIe, "/sys/bus/pci/devices", cards[i].PCIBusID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withSysfs takes the card's own lane count, and its own fastest
|
||||
// generation, from the kernel: the driver's width.max is what card and slot
|
||||
// allow together, so a x16 card in a slot wired x8 reads x8 of x8 there,
|
||||
// while the device's max_link_width is the card's, 16.
|
||||
func withSysfs(l PCIeLink, root, busID string) PCIeLink {
|
||||
dev := sysfsBusID(busID)
|
||||
if dev == "" {
|
||||
return l
|
||||
}
|
||||
read := func(name string) string {
|
||||
b, err := os.ReadFile(filepath.Join(root, dev, name)) //nolint:gosec // under /sys
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
if n, err := strconv.Atoi(read("max_link_width")); err == nil && n > 0 {
|
||||
l.WidthMax = n
|
||||
}
|
||||
if n, err := strconv.Atoi(read("current_link_width")); err == nil && n > 0 {
|
||||
l.Width = n
|
||||
}
|
||||
if g := genOfSpeed(read("max_link_speed")); g > 0 && l.GPUGen == 0 {
|
||||
l.GPUGen = g
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// sysfsBusID is nvidia-smi's 00000000:3B:00.0 as the kernel names it,
|
||||
// 0000:3b:00.0.
|
||||
func sysfsBusID(id string) string {
|
||||
id = strings.ToLower(strings.TrimSpace(id))
|
||||
dom, rest, ok := strings.Cut(id, ":")
|
||||
if !ok || len(dom) < 4 {
|
||||
return ""
|
||||
}
|
||||
return dom[len(dom)-4:] + ":" + rest
|
||||
}
|
||||
|
||||
// genOfSpeed is the PCIe generation of a link speed as sysfs writes it.
|
||||
func genOfSpeed(s string) int {
|
||||
switch {
|
||||
case strings.HasPrefix(s, "2.5 GT/s"):
|
||||
return 1
|
||||
case strings.HasPrefix(s, "5.0 GT/s"), strings.HasPrefix(s, "5 GT/s"):
|
||||
return 2
|
||||
case strings.HasPrefix(s, "8.0 GT/s"), strings.HasPrefix(s, "8 GT/s"):
|
||||
return 3
|
||||
case strings.HasPrefix(s, "16.0 GT/s"), strings.HasPrefix(s, "16 GT/s"):
|
||||
return 4
|
||||
case strings.HasPrefix(s, "32.0 GT/s"), strings.HasPrefix(s, "32 GT/s"):
|
||||
return 5
|
||||
case strings.HasPrefix(s, "64.0 GT/s"), strings.HasPrefix(s, "64 GT/s"):
|
||||
return 6
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parsePCIe(out string) map[int]PCIeLink {
|
||||
r := csv.NewReader(strings.NewReader(out))
|
||||
r.TrimLeadingSpace = true
|
||||
r.FieldsPerRecord = -1
|
||||
rows, _ := r.ReadAll()
|
||||
num := func(s string) int {
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(s))
|
||||
return n
|
||||
}
|
||||
links := map[int]PCIeLink{}
|
||||
for _, row := range rows {
|
||||
if len(row) < 4 {
|
||||
continue
|
||||
}
|
||||
idx, err := strconv.Atoi(strings.TrimSpace(row[0]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
l := PCIeLink{Gen: num(row[1]), Width: num(row[2]), WidthMax: num(row[3])}
|
||||
if len(row) > 4 {
|
||||
l.GPUGen = num(row[4])
|
||||
}
|
||||
links[idx] = l
|
||||
}
|
||||
return links
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestACardInTheWrongSlotIsSaidSo(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
link PCIeLink
|
||||
want string // empty: nothing to say
|
||||
}{
|
||||
// The PoC's Quadro RTX 6000, idle: current Gen1, but its fastest is 3,
|
||||
// as the card's is, on all 16 lanes. Nothing wrong.
|
||||
{PCIeLink{Gen: 3, GPUGen: 3, Width: 16, WidthMax: 16}, ""},
|
||||
// An x16 card in a slot wired x8.
|
||||
{PCIeLink{Gen: 4, GPUGen: 4, Width: 8, WidthMax: 16}, "x8 of its x16 lanes: 15.8 GB/s where it could have 31.5"},
|
||||
// A Gen5 card in a Gen4 slot.
|
||||
{PCIeLink{Gen: 4, GPUGen: 5, Width: 16, WidthMax: 16}, "PCIe Gen4 of its Gen5: 31.5 GB/s where it could have 63.0"},
|
||||
// Both, behind a riser.
|
||||
{PCIeLink{Gen: 3, GPUGen: 4, Width: 4, WidthMax: 16}, "x4 of its x16 lanes and PCIe Gen3 of its Gen4"},
|
||||
// A driver that says nothing.
|
||||
{PCIeLink{}, ""},
|
||||
} {
|
||||
got := c.link.Problem()
|
||||
if (c.want == "") != (got == "") || !strings.Contains(got, c.want) {
|
||||
t.Errorf("%+v: %q, want %q", c.link, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheLinkIsReadFromNvidiaSMI(t *testing.T) {
|
||||
links := parsePCIe("0, 3, 16, 16, 3\n1, 4, 8, 16, [N/A]\n")
|
||||
if links[0] != (PCIeLink{Gen: 3, GPUGen: 3, Width: 16, WidthMax: 16}) || links[1] != (PCIeLink{Gen: 4, Width: 8, WidthMax: 16}) {
|
||||
t.Errorf("%+v", links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCardsOwnLanesComeFromTheKernel(t *testing.T) {
|
||||
// The driver says x8 of x8 for a x16 card in a slot wired x8; the
|
||||
// device's own max_link_width says 16.
|
||||
root := t.TempDir()
|
||||
dev := filepath.Join(root, "0000:3b:00.0")
|
||||
_ = os.MkdirAll(dev, 0o755)
|
||||
for f, v := range map[string]string{"max_link_width": "16", "current_link_width": "8", "max_link_speed": "16.0 GT/s PCIe"} {
|
||||
_ = os.WriteFile(filepath.Join(dev, f), []byte(v+"\n"), 0o644)
|
||||
}
|
||||
l := withSysfs(PCIeLink{Gen: 4, Width: 8, WidthMax: 8}, root, "00000000:3B:00.0")
|
||||
if l.WidthMax != 16 || l.Width != 8 || l.GPUGen != 4 || !strings.Contains(l.Problem(), "x8 of its x16") {
|
||||
t.Errorf("%+v: %q", l, l.Problem())
|
||||
}
|
||||
}
|
||||
@@ -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,19 @@ 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.
|
||||
func SampleGPUs(ctx context.Context) []GPUSensor {
|
||||
return (&SensorSampler{smi: runSMI}).gpus(ctx)
|
||||
}
|
||||
|
||||
func runSMI(ctx context.Context, fields string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -94,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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package engines
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// ComfyUIConfig locates a ComfyUI installation.
|
||||
type ComfyUIConfig struct {
|
||||
// Dir is ComfyUI's directory, where main.py is. Readable by the user
|
||||
// engines run as.
|
||||
Dir string `koanf:"dir"`
|
||||
// Python is the interpreter of its environment; Dir's .venv when unset.
|
||||
Python string `koanf:"python"`
|
||||
}
|
||||
|
||||
// ComfyUI runs ComfyUI as an engine, behind llmgw's gate (llmgw
|
||||
// comfyui-gate): ComfyUI asks for no key, so it listens on the loopback and
|
||||
// the gate, which does, on the replica's port. Its replicas make pictures
|
||||
// for /v1/images/generations.
|
||||
type ComfyUI struct {
|
||||
cfg ComfyUIConfig
|
||||
installed atomic.Pointer[string]
|
||||
}
|
||||
|
||||
// Name identifies the engine in the catalog.
|
||||
func (*ComfyUI) Name() string { return "comfyui" }
|
||||
|
||||
// HealthPath is the gate's, which answers once ComfyUI does.
|
||||
func (*ComfyUI) HealthPath() string { return "/health" }
|
||||
|
||||
// MetricsPath is empty: ComfyUI publishes no metrics, and the router reads
|
||||
// none from it.
|
||||
func (*ComfyUI) MetricsPath() string { return "" }
|
||||
|
||||
// ServesHTTP is true: a ComfyUI replica is one process on one node.
|
||||
func (*ComfyUI) ServesHTTP(ShardSpec) bool { return true }
|
||||
|
||||
// UseInstalled points the engine at the environment the agent installed:
|
||||
// its interpreter, with ComfyUI's code beside its bin, in comfyui/.
|
||||
func (c *ComfyUI) UseInstalled(python string) { c.installed.Store(&python) }
|
||||
|
||||
// Configured reports whether the configuration names a ComfyUI.
|
||||
func (c *ComfyUI) Configured() bool { return c.cfg.Dir != "" }
|
||||
|
||||
func (c *ComfyUI) dir() string {
|
||||
if p := c.installed.Load(); p != nil {
|
||||
return filepath.Join(filepath.Dir(filepath.Dir(*p)), "comfyui")
|
||||
}
|
||||
return c.cfg.Dir
|
||||
}
|
||||
|
||||
func (c *ComfyUI) python() string {
|
||||
if p := c.installed.Load(); p != nil {
|
||||
return *p
|
||||
}
|
||||
if c.cfg.Python != "" {
|
||||
return c.cfg.Python
|
||||
}
|
||||
return filepath.Join(c.cfg.Dir, ".venv", "bin", "python")
|
||||
}
|
||||
|
||||
var comfyVersion = regexp.MustCompile(`__version__\s*=\s*"([^"]+)"`)
|
||||
|
||||
// Probe reports ComfyUI's version, from the file its build writes it to:
|
||||
// importing ComfyUI to ask would load torch and look for cards.
|
||||
func (c *ComfyUI) Probe(context.Context) (string, error) {
|
||||
dir := c.dir()
|
||||
if dir == "" {
|
||||
return "", fmt.Errorf("no ComfyUI directory is configured")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "main.py")); err != nil {
|
||||
return "", fmt.Errorf("%s is not ComfyUI: %w", dir, err)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, "comfyui_version.py"))
|
||||
if err != nil {
|
||||
return Unknown, nil //nolint:nilerr // installed, of a version it does not say
|
||||
}
|
||||
if m := comfyVersion.FindSubmatch(b); m != nil {
|
||||
return string(m[1]), nil
|
||||
}
|
||||
return Unknown, nil
|
||||
}
|
||||
|
||||
// Validate checks the profile: one card, on one node. ComfyUI runs a
|
||||
// workflow on one device and has no tensor parallelism to spread it by.
|
||||
func (*ComfyUI) Validate(p Profile, gpus []GPUInfo) error {
|
||||
if p.Nodes > 1 || p.TP > 1 || p.PP > 1 || p.GPUsPerNode > 1 {
|
||||
return fmt.Errorf("profile %q: ComfyUI runs on one GPU of one node", p.Name)
|
||||
}
|
||||
return validateCommon(p, gpus)
|
||||
}
|
||||
|
||||
// Commands is the gate, which starts ComfyUI itself.
|
||||
func (c *ComfyUI) Commands(s ShardSpec) ([]Command, error) {
|
||||
dir := c.dir()
|
||||
if dir == "" {
|
||||
return nil, fmt.Errorf("comfyui: no ComfyUI directory is configured (engines.comfyui.dir)")
|
||||
}
|
||||
bin, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := s.Profile
|
||||
args := []string{"comfyui-gate",
|
||||
"--listen", net.JoinHostPort(s.Host(), strconv.Itoa(s.HTTPPort)),
|
||||
"--model", p.ServedModelName,
|
||||
"--weights", p.ModelPath,
|
||||
// Relative to where engines run — their user's home — so it is
|
||||
// theirs to write.
|
||||
"--state", filepath.Join("comfyui", s.ReplicaID),
|
||||
"--python", c.python(),
|
||||
"--comfyui", dir,
|
||||
"--",
|
||||
}
|
||||
args = append(args, p.Args...)
|
||||
env := baseEnv(s)
|
||||
if s.APIKey != "" {
|
||||
env["LLMGW_ENGINE_KEY"] = s.APIKey
|
||||
}
|
||||
// A model's custom nodes and the repository a node loads its weights by,
|
||||
// which only the catalog sets — a profile's env may not name them — and
|
||||
// so taken before baseEnv drops them. Without the repository the gate
|
||||
// links no Hugging Face cache, and sf3d's node went to the network for
|
||||
// its gated repository and was refused with a 401.
|
||||
for _, k := range []string{comfyNodesEnv, comfyHFRepoEnv, SharedModelsEnv} {
|
||||
if v := p.Env[k]; v != "" {
|
||||
env[k] = v
|
||||
}
|
||||
}
|
||||
return []Command{{Name: "comfyui", Path: bin, Args: args, Env: materializeEnv(env)}}, nil
|
||||
}
|
||||
|
||||
// comfyNodesEnv is the custom nodes the catalog gives a ComfyUI model, as
|
||||
// JSON; catalog.NodesEnv and comfy.NodesEnv by the same name.
|
||||
const comfyNodesEnv = "LLMGW_COMFYUI_NODES"
|
||||
|
||||
// comfyHFRepoEnv is the repository a custom node loads the weights by name
|
||||
// from; catalog.HFRepoEnv and comfy.HFRepoEnv by the same name.
|
||||
const comfyHFRepoEnv = "LLMGW_COMFYUI_HF_REPO"
|
||||
|
||||
// SharedModelsEnv is a shared replica's members — name, source, and the
|
||||
// path the agent fetched it to — as JSON; catalog.SharedModelsEnv and
|
||||
// comfy.SharedModelsEnv by the same name.
|
||||
const SharedModelsEnv = "LLMGW_COMFYUI_MODELS"
|
||||
@@ -0,0 +1,99 @@
|
||||
package engines
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestComfyUIRunsBehindTheGateWithItsKeyOutOfSight(t *testing.T) {
|
||||
c := &ComfyUI{cfg: ComfyUIConfig{Dir: "/opt/comfyui"}}
|
||||
cmds, err := c.Commands(ShardSpec{
|
||||
ReplicaID: "r-flux-1", HTTPPort: 8100, BindAddr: "10.0.0.147", APIKey: "sekret", GPUIndices: []int{0},
|
||||
Profile: Profile{Name: "p", Engine: "comfyui", ModelPath: "/w/flux.safetensors", ServedModelName: "flux",
|
||||
Args: []string{"--disable-smart-memory"}},
|
||||
})
|
||||
if err != nil || len(cmds) != 1 {
|
||||
t.Fatalf("%v %v", cmds, err)
|
||||
}
|
||||
args := strings.Join(cmds[0].Args, " ")
|
||||
for _, want := range []string{"comfyui-gate", "--listen 10.0.0.147:8100", "--model flux", "--weights /w/flux.safetensors",
|
||||
"--state comfyui/r-flux-1", "--python /opt/comfyui/.venv/bin/python", "--comfyui /opt/comfyui", "-- --disable-smart-memory"} {
|
||||
if !strings.Contains(args, want) {
|
||||
t.Errorf("%q not in %q", want, args)
|
||||
}
|
||||
}
|
||||
if strings.Contains(args, "sekret") {
|
||||
t.Error("the key is on the command line, where every user can read it")
|
||||
}
|
||||
if !slices.Contains(cmds[0].Env, "LLMGW_ENGINE_KEY=sekret") || !slices.Contains(cmds[0].Env, "CUDA_VISIBLE_DEVICES=0") {
|
||||
t.Error("the key or the card is not in the environment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheGateIsToldTheNodesAndTheRepositoryTheCatalogGives(t *testing.T) {
|
||||
// The repository was dropped on the way: sf3d's node went to the network
|
||||
// for its gated repository, with no cache linked, and got a 401.
|
||||
c := &ComfyUI{cfg: ComfyUIConfig{Dir: "/opt/comfyui"}}
|
||||
cmds, err := c.Commands(ShardSpec{
|
||||
ReplicaID: "r-sf3d-1", HTTPPort: 8100, BindAddr: "10.0.0.147", GPUIndices: []int{0},
|
||||
Profile: Profile{Name: "p", Engine: "comfyui", ModelPath: "/w/sf3d/", ServedModelName: "sf3d",
|
||||
Env: map[string]string{comfyNodesEnv: `[{"repo":"x"}]`, comfyHFRepoEnv: "stabilityai/stable-fast-3d"}},
|
||||
})
|
||||
if err != nil || len(cmds) != 1 {
|
||||
t.Fatalf("%v %v", cmds, err)
|
||||
}
|
||||
for _, want := range []string{comfyNodesEnv + `=[{"repo":"x"}]`, comfyHFRepoEnv + "=stabilityai/stable-fast-3d"} {
|
||||
if !slices.Contains(cmds[0].Env, want) {
|
||||
t.Errorf("%q not in the gate's environment", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComfyUIRunsOnOneCard(t *testing.T) {
|
||||
c := &ComfyUI{}
|
||||
if err := c.Validate(Profile{Name: "p", ModelPath: "/w", TP: 2}, h100(2)); err == nil {
|
||||
t.Error("a two-card ComfyUI profile was accepted")
|
||||
}
|
||||
if err := c.Validate(Profile{Name: "p", ModelPath: "/w", TP: 1}, h100(1)); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if _, err := c.Commands(ShardSpec{Profile: Profile{ModelPath: "/w"}}); err == nil {
|
||||
t.Error("commands without a ComfyUI directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComfyUIIsOfferedByTheVersionItsBuildWrote(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := &ComfyUI{cfg: ComfyUIConfig{Dir: dir}}
|
||||
if _, err := c.Probe(t.Context()); err == nil {
|
||||
t.Error("a directory without main.py passed for ComfyUI")
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(dir, "main.py"), nil, 0o644)
|
||||
_ = os.WriteFile(filepath.Join(dir, "comfyui_version.py"), []byte("__version__ = \"0.39.2\"\n"), 0o644)
|
||||
if v, err := c.Probe(t.Context()); err != nil || v != "0.39.2" {
|
||||
t.Errorf("%q %v", v, err)
|
||||
}
|
||||
if got := NewRegistry(Config{ComfyUI: ComfyUIConfig{Dir: dir}}).ProbeConfigured(t.Context()); got["comfyui"] != "0.39.2" {
|
||||
t.Errorf("a configured ComfyUI is reported as %v", got)
|
||||
}
|
||||
if err := UnsafeArgs("comfyui", []string{"--enable-manager"}); err == nil {
|
||||
t.Error("a profile may install ComfyUI nodes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInstalledComfyUIIsFoundBesideItsInterpreter(t *testing.T) {
|
||||
c := &ComfyUI{cfg: ComfyUIConfig{Dir: "/opt/by-hand"}}
|
||||
c.UseInstalled("/opt/llmgw/engines/comfyui/0.39.2-cu13/bin/python")
|
||||
cmds, err := c.Commands(ShardSpec{ReplicaID: "r", HTTPPort: 1, Profile: Profile{ModelPath: "/w", ServedModelName: "m"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
args := strings.Join(cmds[0].Args, " ")
|
||||
if !strings.Contains(args, "--python /opt/llmgw/engines/comfyui/0.39.2-cu13/bin/python") ||
|
||||
!strings.Contains(args, "--comfyui /opt/llmgw/engines/comfyui/0.39.2-cu13/comfyui") {
|
||||
t.Errorf("the installed ComfyUI is not what runs: %s", args)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,13 @@ type Profile struct {
|
||||
// KVEvents has the engine publish its KV cache events to the agent,
|
||||
// for the router's precise index. vLLM only.
|
||||
KVEvents bool `json:"kv_events,omitempty"`
|
||||
// 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.
|
||||
@@ -214,6 +221,9 @@ func NewRegistry(cfg Config) *Registry {
|
||||
r.Add(&LlamaCpp{cfg: cfg.LlamaCpp})
|
||||
r.Add(&TRTLLM{cfg: cfg.TRTLLM})
|
||||
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
|
||||
}
|
||||
|
||||
@@ -262,6 +272,15 @@ type Config struct {
|
||||
// offers it only when its interpreter is named here.
|
||||
TRTLLM TRTLLMConfig `koanf:"trtllm"`
|
||||
Sim SimConfig `koanf:"sim"`
|
||||
// ComfyUI makes pictures; offered by a node whose ComfyUI is named here.
|
||||
ComfyUI ComfyUIConfig `koanf:"comfyui"`
|
||||
// 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,14 +477,14 @@ func TestValidateRequiresModelPath(t *testing.T) {
|
||||
|
||||
func TestRegistry(t *testing.T) {
|
||||
r := NewRegistry(Config{})
|
||||
if got := r.Names(); !slices.Equal(got, []string{"llamacpp", "sglang", "sim", "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 {
|
||||
t.Errorf("vllm missing: %v", err)
|
||||
}
|
||||
err := func() error { _, err := r.Get("nope"); return err }()
|
||||
if err == nil || !strings.Contains(err.Error(), "llamacpp, sglang, sim, trtllm, vllm") {
|
||||
if err == nil || !strings.Contains(err.Error(), "llamacpp, sglang, sim, strata, trtllm, vllm") {
|
||||
t.Errorf("an unknown engine should list what is available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package engines
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAModelDownloadedByTheEngineIsReadAsSafetensorsOnly(t *testing.T) {
|
||||
for path, want := range map[string]bool{
|
||||
"Qwen/Qwen2.5-7B-Instruct": true,
|
||||
"/var/lib/llmgw/weights/abc/": false, "./local": false,
|
||||
"http://m/q/v/": false, "q.gguf": false,
|
||||
} {
|
||||
got := slices.Contains(safetensorsOnly(Profile{ModelPath: path}), "safetensors")
|
||||
if got != want {
|
||||
t.Errorf("%q: safetensors only = %v, want %v", path, got, want)
|
||||
}
|
||||
}
|
||||
if len(safetensorsOnly(Profile{ModelPath: "Qwen/Qwen2.5-7B-Instruct", AllowPickle: true})) != 0 {
|
||||
t.Error("allow_pickle still forced safetensors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAForbiddenFlagIsKnownHoweverItIsSpelled(t *testing.T) {
|
||||
for engine, args := range map[string][]string{
|
||||
"vllm": {"--trust_remote_code"},
|
||||
"sglang": {"--load-format", "pt"},
|
||||
} {
|
||||
if UnsafeArgs(engine, args) == nil {
|
||||
t.Errorf("%s %v was let through", engine, args)
|
||||
}
|
||||
}
|
||||
for _, a := range [][]string{{"--load_format", "pt"}, {"--trust-remote"}, {"--load-form=pt"}} {
|
||||
if UnsafeArgs("vllm", a) == nil {
|
||||
t.Errorf("vllm %v was let through", a)
|
||||
}
|
||||
}
|
||||
// What is not one of them, nor the start of one, still passes.
|
||||
for _, a := range [][]string{{"--max-model-len", "8192"}, {"--tokenizer-mode", "mistral"}, {"--gpu-memory-utilization=0.9"}} {
|
||||
if err := UnsafeArgs("vllm", a); err != nil {
|
||||
t.Errorf("vllm %v was refused: %v", a, err)
|
||||
}
|
||||
}
|
||||
// Short flags are matched as written; llama.cpp takes no abbreviations,
|
||||
// so its --chat-template is not taken for --chat-template-file.
|
||||
for _, a := range [][]string{{"-m", "x"}, {"-hf", "o/r"}, {"-mu", "http://x"}} {
|
||||
if UnsafeArgs("llamacpp", a) == nil {
|
||||
t.Errorf("llamacpp %v was let through", a)
|
||||
}
|
||||
}
|
||||
if err := UnsafeArgs("llamacpp", []string{"--chat-template", "chatml"}); err != nil {
|
||||
t.Errorf("llama.cpp's own --chat-template was refused: %v", err)
|
||||
}
|
||||
// ComfyUI's argparse reads --li as --listen.
|
||||
if UnsafeArgs("comfyui", []string{"--li", "0.0.0.0"}) == nil {
|
||||
t.Error("comfyui --li was let through")
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,7 @@ func (g *SGLang) serverCommand(s ShardSpec) (Command, error) {
|
||||
if p.ServedModelName != "" {
|
||||
args = append(args, "--served-model-name", p.ServedModelName)
|
||||
}
|
||||
args = append(args, safetensorsOnly(p)...)
|
||||
if s.NNodes > 1 {
|
||||
args = append(args,
|
||||
"--nnodes", strconv.Itoa(s.NNodes),
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
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.
|
||||
type StrataConfig struct {
|
||||
// Dir is Strata's source directory, where setup.py and serve/ are.
|
||||
Dir string `koanf:"dir"`
|
||||
// Python is the interpreter of its environment; Dir's .venv when unset.
|
||||
Python string `koanf:"python"`
|
||||
// Engine is the directory of its prebuilt engine, where the strata
|
||||
// binary and the CUDA libraries it ships are; Dir's engine/ when unset.
|
||||
Engine string `koanf:"engine"`
|
||||
}
|
||||
|
||||
// Strata runs Strata (github.com/Niko1221/Strata): a mixture-of-experts
|
||||
// engine that keeps a model's experts in RAM and the ones used most in VRAM,
|
||||
// so a 125B model serves from one 24 GB card. It runs a few model families
|
||||
// only, each prepared from its GGUF files by Strata's own setup, so the agent
|
||||
// starts llmgw strata-serve, which prepares the model and then becomes
|
||||
// Strata's server.
|
||||
type Strata struct {
|
||||
cfg StrataConfig
|
||||
installed atomic.Pointer[string]
|
||||
}
|
||||
|
||||
// Name identifies the engine in the catalog.
|
||||
func (*Strata) Name() string { return "strata" }
|
||||
|
||||
// HealthPath answers 200 only once the model is loaded.
|
||||
func (*Strata) HealthPath() string { return "/health" }
|
||||
|
||||
// MetricsPath is Strata's Prometheus text, in vLLM's names; its /metrics is
|
||||
// JSON for its own monitor unless asked for this format.
|
||||
func (*Strata) MetricsPath() string { return "/metrics?format=prometheus" }
|
||||
|
||||
// ServesHTTP is true: a Strata replica is one process on one node.
|
||||
func (*Strata) ServesHTTP(ShardSpec) bool { return true }
|
||||
|
||||
// UseInstalled points the engine at the environment the agent installed:
|
||||
// its interpreter, with Strata's code beside its bin in strata/ and the
|
||||
// engine in strata-engine/.
|
||||
func (s *Strata) UseInstalled(python string) { s.installed.Store(&python) }
|
||||
|
||||
// Configured reports whether the configuration names a Strata.
|
||||
func (s *Strata) Configured() bool { return s.cfg.Dir != "" }
|
||||
|
||||
// StrataSourceDir and StrataEngineDir are where an installed environment
|
||||
// keeps Strata's code and its prebuilt engine, beside bin/.
|
||||
const (
|
||||
StrataSourceDir = "strata"
|
||||
StrataEngineDir = "strata-engine"
|
||||
)
|
||||
|
||||
func (s *Strata) env() string {
|
||||
if p := s.installed.Load(); p != nil {
|
||||
return filepath.Dir(filepath.Dir(*p))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Strata) dir() string {
|
||||
if e := s.env(); e != "" {
|
||||
return filepath.Join(e, StrataSourceDir)
|
||||
}
|
||||
return s.cfg.Dir
|
||||
}
|
||||
|
||||
func (s *Strata) engineDir() string {
|
||||
if e := s.env(); e != "" {
|
||||
return filepath.Join(e, StrataEngineDir)
|
||||
}
|
||||
if s.cfg.Engine != "" {
|
||||
return s.cfg.Engine
|
||||
}
|
||||
return filepath.Join(s.cfg.Dir, "engine")
|
||||
}
|
||||
|
||||
func (s *Strata) python() string {
|
||||
if p := s.installed.Load(); p != nil {
|
||||
return *p
|
||||
}
|
||||
if s.cfg.Python != "" {
|
||||
return s.cfg.Python
|
||||
}
|
||||
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)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Commands is llmgw strata-serve, which prepares the model with Strata's
|
||||
// setup and then execs Strata's server.
|
||||
func (s *Strata) Commands(sh ShardSpec) ([]Command, error) {
|
||||
dir := s.dir()
|
||||
if dir == "" {
|
||||
return nil, fmt.Errorf("strata: no Strata directory is configured (engines.strata.dir)")
|
||||
}
|
||||
bin, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := sh.Profile
|
||||
args := []string{"strata-serve",
|
||||
"--listen", net.JoinHostPort(sh.Host(), strconv.Itoa(sh.HTTPPort)),
|
||||
"--model", p.ServedModelName,
|
||||
"--weights", p.ModelPath,
|
||||
// Relative to where engines run — their user's home — so they are
|
||||
// theirs to write. The data is shared by the node's replicas: a
|
||||
// prepared model and its draft layer are gigabytes, the same for
|
||||
// every replica of it.
|
||||
"--state", filepath.Join("strata", sh.ReplicaID),
|
||||
"--data", filepath.Join("strata", "data"),
|
||||
"--python", s.python(),
|
||||
"--strata", dir,
|
||||
"--engine", s.engineDir(),
|
||||
"--",
|
||||
}
|
||||
args = append(args, p.Args...)
|
||||
env := baseEnv(sh)
|
||||
if sh.APIKey != "" {
|
||||
// Strata's server reads its key from here, and strata-serve passes
|
||||
// it on in the environment: not on a command line every user on the
|
||||
// node can read.
|
||||
env["STRATA_API_KEY"] = sh.APIKey
|
||||
}
|
||||
return []Command{{Name: "strata", Path: bin, Args: args, Env: materializeEnv(env)}}, nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package engines
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStrataStartsStrataServeWithTheKeyInTheEnvironment(t *testing.T) {
|
||||
s := &Strata{}
|
||||
s.UseInstalled("/opt/llmgw/engines/strata/0.2.7-cuda13/bin/python")
|
||||
spec := ShardSpec{
|
||||
ReplicaID: "r1", HTTPPort: 18090, BindAddr: "10.0.0.147", GPUIndices: []int{0},
|
||||
APIKey: "secret-key",
|
||||
Profile: Profile{Name: "p", Engine: "strata", TP: 1, ModelPath: "/var/lib/llmgw/models/q38",
|
||||
ServedModelName: "qwen3.8-flash-next", Args: []string{"--family", "qwen", "--size", "IQ2_XS"}},
|
||||
}
|
||||
cmds, err := s.Commands(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := cmds[0]
|
||||
args := strings.Join(c.Args, " ")
|
||||
env := "/opt/llmgw/engines/strata/0.2.7-cuda13"
|
||||
for _, want := range []string{
|
||||
"strata-serve", "--listen 10.0.0.147:18090", "--model qwen3.8-flash-next",
|
||||
"--weights /var/lib/llmgw/models/q38", "--state " + filepath.Join("strata", "r1"),
|
||||
"--python " + env + "/bin/python", "--strata " + env + "/strata", "--engine " + env + "/strata-engine",
|
||||
"-- --family qwen --size IQ2_XS",
|
||||
} {
|
||||
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")
|
||||
}
|
||||
if !slices.Contains(c.Env, "STRATA_API_KEY=secret-key") {
|
||||
t.Error("the key is not in the environment, where Strata's server reads it")
|
||||
}
|
||||
if !slices.Contains(c.Env, "CUDA_VISIBLE_DEVICES=0") {
|
||||
t.Error("the shard is not pinned to its GPU")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataRunsOnOneCard(t *testing.T) {
|
||||
s := &Strata{}
|
||||
p := Profile{Name: "p", Engine: "strata", TP: 1, GPUsPerNode: 2, ModelPath: "/m"}
|
||||
if err := s.Validate(p, nil); err == nil {
|
||||
t.Error("a two-card profile was accepted")
|
||||
}
|
||||
p.GPUsPerNode = 1
|
||||
if err := s.Validate(p, []GPUInfo{{Name: "Quadro RTX 6000", ComputeCapability: "7.5"}}); err != nil {
|
||||
t.Errorf("one Turing card was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataSetupFlagsThatMoveFilesAreRefused(t *testing.T) {
|
||||
for _, bad := range []string{"--gguf-dir", "--gguf", "--data-dir=/etc", "--model", "--mod", "--feed", "--host", "--gpus"} {
|
||||
if err := UnsafeArgs("strata", []string{bad, "x"}); err == nil {
|
||||
t.Errorf("%s was accepted", bad)
|
||||
}
|
||||
}
|
||||
if err := UnsafeArgs("strata", []string{"--family", "qwen", "--size", "IQ2_XS", "--context", "32768",
|
||||
"--parallel", "2", "--vram-reserve-mib", "2048", "--vision", "no"}); err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package engines
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -24,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.
|
||||
@@ -32,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{
|
||||
@@ -58,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 " +
|
||||
@@ -101,6 +113,13 @@ var flagsNever = map[string]map[string]string{
|
||||
"--hf-token": "is a credential", "--alias": "is set by the agent",
|
||||
"--model-draft": "loads a file", "-md": "loads a file",
|
||||
"--prompt-cache": "writes a file", "--logdir": "writes files",
|
||||
// The short spellings of the same.
|
||||
"-mm": "loads a file; the projector is fetched with the model", "--mmproj-url": "downloads a file",
|
||||
"-mmu": "downloads a file", "-hfr": "downloads a model", "-hff": "downloads a model",
|
||||
"--hf-repo-draft": "downloads a model", "-hfd": "downloads a model", "-hfrd": "downloads a model",
|
||||
"--hf-repo-v": "downloads a model", "-hfv": "downloads a model", "--hf-file-v": "downloads a model",
|
||||
"-hffv": "downloads a model", "-hft": "is a credential", "-jf": "reads a file", "-a": "is set by the agent",
|
||||
"--model-vocoder": "loads a file", "-mv": "loads a file",
|
||||
},
|
||||
"vllm": {
|
||||
"--download-dir": "writes files", "--tokenizer": "reads another repository or path",
|
||||
@@ -118,6 +137,46 @@ var flagsNever = map[string]map[string]string{
|
||||
"--file-storage-path": "writes files", "--log-requests": "logs prompts",
|
||||
"--model-path": "is the model's source, set by the agent", "--served-model-name": "is set by the agent",
|
||||
"--lora-paths": "loads files", "--download-dir": "writes files",
|
||||
"--load-format": "changes how weights are loaded, some ways running code",
|
||||
},
|
||||
// ComfyUI's main.py, behind the gate that sets where it listens and
|
||||
// where its files are.
|
||||
"comfyui": {
|
||||
"--listen": "is set by the agent", "--base-directory": "is set by the agent",
|
||||
"--extra-model-paths-config": "is set by the agent",
|
||||
"--output-directory": "writes files", "--temp-directory": "writes files",
|
||||
"--input-directory": "reads files", "--user-directory": "writes files",
|
||||
"--database-url": "writes a database elsewhere", "--front-end-root": "serves a directory",
|
||||
"--enable-cors-header": "lets other sites' pages call it",
|
||||
"--enable-manager": "installs nodes, which run Python",
|
||||
"--tls-keyfile": "reads a file", "--tls-certfile": "reads a file",
|
||||
"--comfy-api-base": "sends work to another service",
|
||||
},
|
||||
// Strata's setup.py, which a profile's arguments go to: llmgw
|
||||
// strata-serve sets where the model is, where its files go and where
|
||||
// the server listens, and the engine is the one the agent installed.
|
||||
// Its own --model, the size, is spelt --size in a profile, since
|
||||
// --model is the source everywhere else.
|
||||
"strata": {
|
||||
"--gguf-dir": "is the model's source, set by the agent", "--models-dir": "is set by the agent",
|
||||
"--data-dir": "is set by the agent", "--move-data": "moves files", "--set-default-data-dir": "writes settings",
|
||||
"--feed": "downloads an engine", "--prebuilt": "loads an engine", "--build": "compiles an engine",
|
||||
"--update": "downloads an engine", "--rollback-engine": "changes the engine",
|
||||
"--dsv41-package": "loads a file", "--inspect": "reads another repository or path",
|
||||
"--source": "downloads from elsewhere", "--setup": "is not for a server",
|
||||
"--browser": "opens a browser", "--gpu": "is set by the agent", "--gpus": "is set by the agent",
|
||||
"--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.
|
||||
@@ -150,14 +209,43 @@ var flagsNever = map[string]map[string]string{
|
||||
// unsafeFlag is why an engine may not be given a flag, or "".
|
||||
func unsafeFlag(engine, flag, _ string) string {
|
||||
name, _, _ := strings.Cut(flag, "=")
|
||||
if !strings.HasPrefix(name, "-") {
|
||||
return ""
|
||||
}
|
||||
// Written as the engine would read it: vLLM takes --load_format for
|
||||
// --load-format, TensorRT-LLM the other way round. Short flags (-m,
|
||||
// -hf) are matched as written.
|
||||
norm := func(f string) string {
|
||||
if strings.HasPrefix(f, "--") {
|
||||
return "--" + strings.ReplaceAll(f[2:], "_", "-")
|
||||
}
|
||||
return f
|
||||
}
|
||||
given := norm(name)
|
||||
// argparse also takes any unambiguous beginning of a long flag for the
|
||||
// flag — --trust-remote is --trust-remote-code, --li is ComfyUI's
|
||||
// --listen — so for the engines that parse with it, a beginning of a
|
||||
// refused flag is refused. llama.cpp and TensorRT-LLM take no such
|
||||
// thing, and --chat-template there is a flag of its own.
|
||||
abbreviates := argparseEngines[engine] && strings.HasPrefix(given, "--") && len(given) >= 4
|
||||
for _, set := range []string{"", engine} {
|
||||
if why, ok := flagsNever[set][name]; ok && why != "" {
|
||||
return why
|
||||
for never, why := range flagsNever[set] {
|
||||
if why == "" {
|
||||
continue
|
||||
}
|
||||
n := norm(never)
|
||||
if n == given || (abbreviates && strings.HasPrefix(n, "--") && strings.HasPrefix(n, given)) {
|
||||
return why
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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, "freetoken": true}
|
||||
|
||||
// UnsafeArgs refuses the first of a profile's arguments an engine may not be
|
||||
// given, saying why.
|
||||
func UnsafeArgs(engine string, args []string) error {
|
||||
@@ -172,3 +260,29 @@ func UnsafeArgs(engine string, args []string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// safetensorsOnly has vLLM or SGLang read only safetensors from a model it
|
||||
// downloads itself, by its Hugging Face id: left to choose, either takes
|
||||
// the repository's pickle files (pytorch_model.bin) when it has no
|
||||
// safetensors, and loading those runs whatever they hold. A path was
|
||||
// fetched by the agent, which checked it, or put there by the operator.
|
||||
func safetensorsOnly(p Profile) []string {
|
||||
if p.AllowPickle || !hubID(p.ModelPath) {
|
||||
return nil
|
||||
}
|
||||
return []string{"--load-format", "safetensors"}
|
||||
}
|
||||
|
||||
// hubID reports whether a model path is a Hugging Face id, org/name, rather
|
||||
// than a file or directory.
|
||||
func hubID(s string) bool {
|
||||
if s == "" || strings.HasPrefix(s, "/") || strings.HasPrefix(s, ".") || strings.Contains(s, "://") {
|
||||
return false
|
||||
}
|
||||
// org/name:Q4_K_M is a GGUF in a repository, which vLLM reads as one.
|
||||
if strings.Count(s, "/") != 1 || strings.Contains(s, ":") {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(s)
|
||||
return err != nil
|
||||
}
|
||||
@@ -119,6 +119,7 @@ func (v *VLLM) serverCommand(s ShardSpec) (Command, error) {
|
||||
if p.ServedModelName != "" {
|
||||
args = append(args, "--served-model-name", p.ServedModelName)
|
||||
}
|
||||
args = append(args, safetensorsOnly(p)...)
|
||||
|
||||
env := baseEnv(s)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ var (
|
||||
gpuProcesses = discovery.GPUProcesses
|
||||
gpuMemory = discovery.GPUMemoryByProcess
|
||||
processGroup = pgidOf
|
||||
parentOf = ppidOf
|
||||
// gpuCheckWait is how long a shard has, once it answers, to show up on
|
||||
// its GPUs: the weights are on them by then, but nvidia-smi can lag.
|
||||
gpuCheckWait = 15 * time.Second
|
||||
@@ -116,19 +117,44 @@ func (a *Agent) foreignGPUMemory(ctx context.Context, shards []int) (foreign, us
|
||||
return foreign, used
|
||||
}
|
||||
|
||||
// holds reports whether any of pids is the shard's process or in its group:
|
||||
// an engine that computes in a child process is still the shard.
|
||||
// holds reports whether any of pids is the shard's process, in its group,
|
||||
// or a process it started: an engine that computes in a child process is
|
||||
// still the shard, and Strata's server starts its engine in a group of the
|
||||
// engine's own.
|
||||
func holds(pids []int, pid, group int) bool {
|
||||
for _, p := range pids {
|
||||
if p == pid || (group > 0 && processGroup(p) == group) {
|
||||
if p == pid || (group > 0 && processGroup(p) == group) || descends(p, pid) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// descends reports whether process p was started, at any remove, by pid.
|
||||
func descends(p, pid int) bool {
|
||||
// Bounded: a chain this deep is no engine's, and a /proc read racing an
|
||||
// exit must not loop.
|
||||
for range 16 {
|
||||
p = parentOf(p)
|
||||
switch {
|
||||
case p == pid:
|
||||
return true
|
||||
case p <= 1:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pgidOf reads a process's group from /proc; zero when it cannot.
|
||||
func pgidOf(pid int) int {
|
||||
func pgidOf(pid int) int { return statField(pid, 2) }
|
||||
|
||||
// ppidOf reads a process's parent from /proc; zero when it cannot.
|
||||
func ppidOf(pid int) int { return statField(pid, 1) }
|
||||
|
||||
// statField is one of the numbers after the command name in /proc's stat
|
||||
// line: 1 is the parent, 2 the group.
|
||||
func statField(pid, field int) int {
|
||||
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
|
||||
if err != nil {
|
||||
return 0
|
||||
@@ -141,10 +167,10 @@ func pgidOf(pid int) int {
|
||||
return 0
|
||||
}
|
||||
f := strings.Fields(s[i+1:])
|
||||
if len(f) < 3 {
|
||||
if len(f) <= field {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(f[2])
|
||||
n, _ := strconv.Atoi(f[field])
|
||||
return n
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,30 @@ func TestForeignGPUMemoryLeavesOutTheAgentsOwnShards(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEngineStartedInAGroupOfItsOwnIsStillTheShards(t *testing.T) {
|
||||
// cuda01: Strata's server (259709) starts its engine (261072) in a
|
||||
// group of the engine's own, and the engine holds the card. It read as
|
||||
// a shard on the CPU, and as 22 GB of somebody else's memory.
|
||||
oldMem, oldGroup, oldParent := gpuMemory, processGroup, parentOf
|
||||
t.Cleanup(func() { gpuMemory, processGroup, parentOf = oldMem, oldGroup, oldParent })
|
||||
processGroup = func(pid int) int { return pid }
|
||||
parentOf = func(pid int) int {
|
||||
return map[int]int{261072: 259709, 259709: 258734, 258734: 1, 999: 1}[pid]
|
||||
}
|
||||
gpuMemory = func(context.Context, string) ([]discovery.GPUMemory, error) {
|
||||
return []discovery.GPUMemory{{Index: 0, UsedMB: 23000,
|
||||
Procs: []discovery.GPUProcess{{PID: 261072, UsedMB: 22226}, {PID: 999, UsedMB: 700}}}}, nil
|
||||
}
|
||||
a := &Agent{log: slog.Default()}
|
||||
if c, ok := a.gpuCompute(context.Background(), []int{0}, 259709); !ok || c.Backend != "CUDA" ||
|
||||
len(c.Devices) != 1 || c.Devices[0] != "GPU 0: 21.7 GB" {
|
||||
t.Errorf("the engine's child on GPU 0: %+v %v", c, ok)
|
||||
}
|
||||
if foreign, _ := a.foreignGPUMemory(context.Background(), []int{259709}); !slices.Equal(foreign, []int32{774}) {
|
||||
t.Errorf("foreign %v; want only what the other process and nobody holds, [774]", foreign)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLlamaCppsOwnWordOnTheCPUIsEnoughToRefuse(t *testing.T) {
|
||||
eng := fallbackEngine{}
|
||||
// Without nvidia-smi to ask, the engine's own output says it.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
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 }
|
||||
bandwidthDelay = func() time.Duration { return 0 }
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
)
|
||||
|
||||
// comfyuiSource is a ComfyUI release archive as GitHub makes one, and the
|
||||
// recipe pointed at it.
|
||||
func comfyuiSource(t *testing.T, in *Installer, rel *release, digest string) {
|
||||
t.Helper()
|
||||
archive := tarGz(t, map[string]string{
|
||||
"ComfyUI-0.39.2/main.py": "print('comfy')\n",
|
||||
"ComfyUI-0.39.2/comfyui_version.py": "__version__ = \"0.39.2\"\n",
|
||||
"ComfyUI-0.39.2/requirements.txt": "comfyui-frontend-package==1.53.10\ntorch\ntorchvision\n" +
|
||||
"numpy>=1.25.0\n\n#non essential dependencies:\nkornia>=0.7.1\n",
|
||||
}, nil)
|
||||
rel.assets["v0.39.2.tar.gz"] = archive
|
||||
sum := sha256.Sum256(archive)
|
||||
if digest == "" {
|
||||
digest = hex.EncodeToString(sum[:])
|
||||
}
|
||||
old := comfyuiReleases
|
||||
comfyuiReleases = map[string]struct {
|
||||
sha256 string
|
||||
published string
|
||||
}{"0.39.2": {digest, "2026-10-07T21:58:07Z"}}
|
||||
t.Cleanup(func() { comfyuiReleases = old })
|
||||
}
|
||||
|
||||
var turingNode = discovery.Host{OSID: "ubuntu", OSVersion: "24.04", Arch: "amd64",
|
||||
NVIDIADriver: "580.82.07", MinComputeCapability: 7.5}
|
||||
|
||||
func TestComfyUIIsInstalledFromItsSourceCheckedAgainstTheRecipe(t *testing.T) {
|
||||
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
|
||||
in, rel := pythonInstaller(t, turingNode, sys)
|
||||
comfyuiSource(t, in, rel, "")
|
||||
|
||||
got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.39.2"}})
|
||||
if got[0].State != Ready {
|
||||
t.Fatalf("not ready: %+v", got[0])
|
||||
}
|
||||
dir := filepath.Join(in.Root, "comfyui", "0.39.2-cu13")
|
||||
if got[0].Binary != filepath.Join(dir, "bin", "python") {
|
||||
t.Errorf("binary %s, want the environment's interpreter", got[0].Binary)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "comfyui", "main.py")); err != nil {
|
||||
t.Errorf("ComfyUI's code is not beside the interpreter: %v", err)
|
||||
}
|
||||
all := strings.Join(sys.commands, "\n")
|
||||
for _, want := range []string{
|
||||
// Not PyPI's date, which ComfyUI has none of: the recipe's, a day on.
|
||||
"--exclude-newer 2026-10-08T21:58:07Z",
|
||||
"comfyui-frontend-package==1.53.10", "numpy>=1.25.0", "kornia>=0.7.1", "torch",
|
||||
"--require-hashes",
|
||||
} {
|
||||
if !strings.Contains(all, want) {
|
||||
t.Errorf("no %q in:\n%s", want, all)
|
||||
}
|
||||
}
|
||||
if strings.Contains(all, "/pypi/") {
|
||||
t.Error("PyPI was asked when ComfyUI was published")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComfyUIOnAnOlderDriverTakesPyTorchsCUDA12Torch(t *testing.T) {
|
||||
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
|
||||
host := turingNode
|
||||
host.NVIDIADriver = "550.163.01"
|
||||
in, rel := pythonInstaller(t, host, sys)
|
||||
comfyuiSource(t, in, rel, "")
|
||||
got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.39.2"}})
|
||||
if got[0].State != Ready || got[0].Variant != "cu129" {
|
||||
t.Fatalf("got %+v", got[0])
|
||||
}
|
||||
var project string
|
||||
for _, c := range sys.commands {
|
||||
if strings.HasPrefix(c, "pyproject: ") {
|
||||
project = c
|
||||
}
|
||||
}
|
||||
// torch from PyTorch's index alone, named once, as the build names it.
|
||||
if !strings.Contains(project, `torch = { index = "extra" }`) || strings.Count(project, `"torch"`) != 1 {
|
||||
t.Errorf("torch is not from PyTorch's index alone:\n%s", project)
|
||||
}
|
||||
}
|
||||
|
||||
// A V100 on driver 580: CUDA 13 runs on the driver, but neither its torch
|
||||
// nor CUDA 12.9's has kernels for Volta. Tried in turn, each was installed,
|
||||
// 10 GB at a time, and failed on the card.
|
||||
func TestComfyUIOnAVoltaTakesPyTorchsCUDA126Torch(t *testing.T) {
|
||||
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
|
||||
host := turingNode
|
||||
host.MinComputeCapability = 7.0
|
||||
in, rel := pythonInstaller(t, host, sys)
|
||||
comfyuiSource(t, in, rel, "")
|
||||
got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.39.2"}})
|
||||
if got[0].State != Ready || got[0].Variant != "cu126" {
|
||||
t.Fatalf("got %+v", got[0])
|
||||
}
|
||||
all := strings.Join(sys.commands, "\n")
|
||||
if !strings.Contains(all, pytorchCU126) || strings.Contains(all, pytorchCU129) {
|
||||
t.Errorf("torch is not CUDA 12.6's:\n%s", all)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComfyUIWhoseSourceIsNotTheOneTheRecipeNamesIsNotInstalled(t *testing.T) {
|
||||
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
|
||||
in, rel := pythonInstaller(t, turingNode, sys)
|
||||
comfyuiSource(t, in, rel, strings.Repeat("ab", 32))
|
||||
got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.39.2"}})
|
||||
if got[0].State != Failed || !strings.Contains(got[0].Detail, "does not match") {
|
||||
t.Fatalf("got %s %q", got[0].State, got[0].Detail)
|
||||
}
|
||||
for _, c := range sys.commands {
|
||||
if strings.Contains(c, "pip install") {
|
||||
t.Errorf("installed from a source that did not match: %s", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComfyUIIsInstalledOnlyWhereItCanRun(t *testing.T) {
|
||||
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
|
||||
in, _ := pythonInstaller(t, ubuntu, sys)
|
||||
if got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.39.2"}}); got[0].State != Skipped {
|
||||
t.Errorf("a node with no GPU: %+v", got[0])
|
||||
}
|
||||
in, _ = pythonInstaller(t, turingNode, sys)
|
||||
got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.1.0"}})
|
||||
if got[0].State != Unsupported || !strings.Contains(got[0].Detail, "knows the source digest of ComfyUI 0.39.2") {
|
||||
t.Errorf("a version without a digest: %s %q", got[0].State, got[0].Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARequirementsFileIsReadForPackagesAndNothingElse(t *testing.T) {
|
||||
got, err := requirementsIn("torch\ntorchvision>=0.20\nPillow\n# a comment\naiohttp>=3.11 # inline\n", pytorchPackages)
|
||||
if err != nil || strings.Join(got, ",") != "Pillow,aiohttp>=3.11" {
|
||||
t.Errorf("%v %v", got, err)
|
||||
}
|
||||
for _, bad := range []string{"-r other.txt", "--index-url https://evil", "x @ https://evil/x.whl", "-e ."} {
|
||||
if _, err := requirementsIn(bad+"\n", nil); err == nil {
|
||||
t.Errorf("%q was accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A card every build runs on keeps the order it had: a Turing node's
|
||||
// installed cu13 is still the first found, and is not installed again
|
||||
// because a build for older cards was added after it.
|
||||
func TestComfyUIBuildsAreTriedBestFirst(t *testing.T) {
|
||||
variants := func(h discovery.Host) string {
|
||||
builds, err := comfyuiCandidates("0.39.2", "", h)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
var v []string
|
||||
for _, b := range builds {
|
||||
v = append(v, b.Variant)
|
||||
}
|
||||
return strings.Join(v, ",")
|
||||
}
|
||||
volta := turingNode
|
||||
volta.MinComputeCapability = 7.0
|
||||
older := turingNode
|
||||
older.NVIDIADriver = "550.163.01"
|
||||
for h, want := range map[*discovery.Host]string{&turingNode: "cu13,cu129,cu126", &volta: "cu126", &older: "cu129,cu126"} {
|
||||
if got := variants(*h); got != want {
|
||||
t.Errorf("cc %.1f driver %s: %s, want %s", h.MinComputeCapability, h.NVIDIADriver, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -174,7 +174,7 @@ func (in *Installer) one(ctx context.Context, w Want) Status {
|
||||
}
|
||||
return st
|
||||
}
|
||||
if w.Offline != nil && recipe.Repo == "" {
|
||||
if w.Offline != nil && (recipe.Repo == "" || builds[0].Python != "") {
|
||||
// Said, not attempted: uv would spend minutes failing to reach PyPI,
|
||||
// and the failure would read like a network fault rather than the
|
||||
// design of an offline site.
|
||||
|
||||
@@ -66,12 +66,17 @@ func (in *Installer) installPython(ctx context.Context, r Recipe, w Want, b Buil
|
||||
return fmt.Errorf("installing uv, which installs %s: %s", w.Engine, uv.Detail)
|
||||
}
|
||||
|
||||
in.progress(st, "asking PyPI when %s %s was published", r.PyPI, w.Version)
|
||||
published, err := in.published(ctx, r.PyPI, w.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
var asOf string
|
||||
if !b.AsOf.IsZero() {
|
||||
asOf = b.AsOf.UTC().Format(time.RFC3339)
|
||||
} else {
|
||||
in.progress(st, "asking PyPI when %s %s was published", r.PyPI, w.Version)
|
||||
published, err := in.published(ctx, r.PyPI, w.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
asOf = published.Add(24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
}
|
||||
asOf := published.Add(24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
|
||||
cache := filepath.Join(in.root(), ".uv-cache")
|
||||
env := []string{
|
||||
@@ -96,7 +101,19 @@ func (in *Installer) installPython(ctx context.Context, r Recipe, w Want, b Buil
|
||||
}
|
||||
python := filepath.Join(tmp, "bin", "python")
|
||||
|
||||
reqs := b.Requirements
|
||||
reqs := slices.Clone(b.Requirements)
|
||||
if b.Source != nil {
|
||||
more, err := in.unpackSource(ctx, w, b, tmp, st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reqs = append(reqs, more...)
|
||||
}
|
||||
if len(b.Assets) > 0 {
|
||||
if err := in.unpackAssets(ctx, r, w, b, tmp, st); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if b.Wheel != nil {
|
||||
// The engine from its GitHub release, pinned to the digest GitHub
|
||||
// publishes for the file: the same trust as an archive, and uv
|
||||
@@ -320,3 +337,110 @@ func (in *Installer) gpuCheck(ctx context.Context, python string, w Want, b Buil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unpackSource fetches an engine's source archive, checks it against the
|
||||
// digest the recipe holds, puts its code in the environment, and returns the
|
||||
// requirements its requirements file names — less the packages a second
|
||||
// index gives, which the build names itself.
|
||||
func (in *Installer) unpackSource(ctx context.Context, w Want, b Build, env string, st *Status) ([]string, error) {
|
||||
src := b.Source
|
||||
url := src.URL
|
||||
if strings.HasPrefix(url, "/") {
|
||||
url = strings.TrimSuffix(in.downloadBase(), "/") + url
|
||||
}
|
||||
scratch := env + ".source"
|
||||
_ = os.RemoveAll(scratch)
|
||||
if err := os.MkdirAll(scratch, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(scratch) //nolint:errcheck // scratch
|
||||
archive := filepath.Join(scratch, "source.tar.gz")
|
||||
name := w.Engine + " " + w.Version + "'s source"
|
||||
if err := in.download(ctx, url, name, archive, src.SHA256, st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.progress(st, "unpacking %s", name)
|
||||
if err := untarGz(archive, scratch); err != nil {
|
||||
return nil, fmt.Errorf("unpacking %s: %w", name, err)
|
||||
}
|
||||
dst := filepath.Join(env, src.Dir)
|
||||
if err := os.Rename(filepath.Join(scratch, src.Top), dst); err != nil {
|
||||
return nil, fmt.Errorf("%s has no %s at its top: %w", name, src.Top, err)
|
||||
}
|
||||
if src.Requirements == "" {
|
||||
return nil, nil
|
||||
}
|
||||
f, err := os.ReadFile(filepath.Join(dst, src.Requirements))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return requirementsIn(string(f), b.IndexPackages)
|
||||
}
|
||||
|
||||
// unpackAssets fetches a Python engine's release binaries, each checked
|
||||
// against the digest GitHub publishes for it, and unpacks them into the
|
||||
// environment's AssetDir.
|
||||
func (in *Installer) unpackAssets(ctx context.Context, r Recipe, w Want, b Build, env string, st *Status) error {
|
||||
tag := b.AssetTag
|
||||
if tag == "" {
|
||||
tag = w.Version
|
||||
}
|
||||
in.progress(st, "asking GitHub for the checksums of %s %s", r.Repo, tag)
|
||||
sums, err := in.checksums(ctx, r.Repo, tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dst := filepath.Join(env, b.AssetDir)
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, asset := range b.Assets {
|
||||
want, ok := sums[asset]
|
||||
if !ok {
|
||||
// Refused rather than installed unchecked: this is a binary that
|
||||
// will run on every node.
|
||||
return fmt.Errorf("%s %s publishes no checksum for %s, so it cannot be verified", r.Repo, tag, asset)
|
||||
}
|
||||
url := strings.TrimSuffix(in.downloadBase(), "/") + "/" + r.Repo + "/releases/download/" + tag + "/" + asset
|
||||
archive := filepath.Join(env+".source", asset)
|
||||
if err := os.MkdirAll(filepath.Dir(archive), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := in.download(ctx, url, asset, archive, want, st); err != nil {
|
||||
return err
|
||||
}
|
||||
in.progress(st, "unpacking %s", asset)
|
||||
err := untarGz(archive, dst)
|
||||
_ = os.RemoveAll(filepath.Dir(archive))
|
||||
if err != nil {
|
||||
return fmt.Errorf("unpacking %s: %w", asset, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requirementsIn reads a requirements file's lines. Options (-r, -e, --index-url
|
||||
// and the like) are refused: they would fetch from somewhere other than PyPI,
|
||||
// or install what the lock never saw.
|
||||
func requirementsIn(text string, skip []string) ([]string, error) {
|
||||
var out []string
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line, _, _ = strings.Cut(line, "#")
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "-") || strings.Contains(line, "://") || strings.Contains(line, " @ ") {
|
||||
return nil, fmt.Errorf("the requirements name %q, which is not a package from PyPI", line)
|
||||
}
|
||||
name := strings.ToLower(line)
|
||||
if i := strings.IndexAny(name, "<>=!~;[ "); i >= 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
if slices.Contains(skip, name) {
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -53,6 +53,7 @@ func (f *fakePython) run(ctx context.Context, env []string, name string, args ..
|
||||
return []byte("error: Hash mismatch for torch\n"), errExit
|
||||
}
|
||||
venv := filepath.Dir(filepath.Dir(args[slices.Index(args, "--python")+1]))
|
||||
_ = os.WriteFile(filepath.Join(venv, "bin", "python"), []byte("#!/bin/sh\n"), 0o755)
|
||||
return nil, os.WriteFile(filepath.Join(venv, "bin", "vllm"), []byte("#!/bin/sh\n"), 0o755)
|
||||
case strings.HasSuffix(name, "/bin/vllm") && args[0] == "--version":
|
||||
return []byte("0.30.0\n"), nil
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
)
|
||||
@@ -68,12 +69,36 @@ type Build struct {
|
||||
// Note says why this build, when it is not the plain one: shown beside
|
||||
// it wherever the installation is.
|
||||
Note string
|
||||
// Source, for a Python engine that is not on PyPI, is its own code: an
|
||||
// archive pinned by its digest, unpacked into the environment, whose
|
||||
// requirements file is installed with Requirements. AsOf is then the
|
||||
// date its dependencies are resolved as of — the day after its version
|
||||
// was published, which PyPI cannot be asked for.
|
||||
Source *Source
|
||||
AsOf time.Time
|
||||
// AssetTag and AssetDir, for a Python engine that also ships a binary
|
||||
// in its releases, are the release tag Assets are taken from, checked
|
||||
// against the digests GitHub publishes, and the directory in the
|
||||
// environment they are unpacked into.
|
||||
AssetTag, AssetDir string
|
||||
// FreeGB is the disk space the installation needs before it starts: a
|
||||
// Python engine is several gigabytes, and one that fills the disk halfway
|
||||
// leaves the node worse off than one that never began.
|
||||
FreeGB int
|
||||
}
|
||||
|
||||
// Source is an engine's code as an archive.
|
||||
type Source struct {
|
||||
// URL is fetched as is; SHA256 is what it must hash to, written into
|
||||
// the recipe because a project's source archives come with no published
|
||||
// digest of their own.
|
||||
URL, SHA256 string
|
||||
// Top is the archive's one top directory, put in the environment as Dir.
|
||||
Top, Dir string
|
||||
// Requirements is the requirements file, in Dir.
|
||||
Requirements string
|
||||
}
|
||||
|
||||
// Wheel is a package published as a GitHub release asset: the asset whose
|
||||
// name starts with Prefix and ends with Suffix.
|
||||
type Wheel struct {
|
||||
@@ -101,10 +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},
|
||||
"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
|
||||
@@ -151,6 +179,9 @@ func Builds(engine, version, arch string) ([]Build, error) {
|
||||
continue
|
||||
}
|
||||
for _, b := range bs {
|
||||
if b.Python != "" {
|
||||
return nil, fmt.Errorf("%s is a Python environment from PyPI, which an offline bundle does not carry", engine)
|
||||
}
|
||||
if !seen[b.Variant] {
|
||||
seen[b.Variant] = true
|
||||
out = append(out, b)
|
||||
@@ -556,3 +587,228 @@ func orUnknown(s string) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// --- ComfyUI ---
|
||||
|
||||
// comfyuiReleases are the ComfyUI versions this agent installs: each source
|
||||
// archive's digest, taken when it was added here and confirmed from a second
|
||||
// machine, and when the version was published. GitHub publishes no digest
|
||||
// for a tag's archive, so a version not listed is refused rather than
|
||||
// installed unchecked.
|
||||
var comfyuiReleases = map[string]struct {
|
||||
sha256 string
|
||||
published string
|
||||
}{
|
||||
"0.39.2": {"1d655e22cf546700fc1d6d2536e27701695c5832a51f63bb16c41866ac09be0b", "2026-10-07T21:58:07Z"},
|
||||
}
|
||||
|
||||
// ComfyUIVersion is the newest ComfyUI this agent installs: what a cluster
|
||||
// is set to when a model first needs it.
|
||||
func ComfyUIVersion() string {
|
||||
newest := ""
|
||||
for v := range comfyuiReleases {
|
||||
if newest == "" || versionBefore(newest, v) {
|
||||
newest = v
|
||||
}
|
||||
}
|
||||
return newest
|
||||
}
|
||||
|
||||
// ComfyUIRepo is where ComfyUI's source is published.
|
||||
const ComfyUIRepo = "Comfy-Org/ComfyUI"
|
||||
|
||||
// comfyuiDir is where ComfyUI's code is put in its environment: main.py is
|
||||
// <environment>/comfyui/main.py, beside bin/python.
|
||||
const comfyuiDir = "comfyui"
|
||||
|
||||
// pytorchCU126 is PyTorch's CUDA 12.6 build: the last that carries kernels
|
||||
// for Volta and Pascal. From torch 2.8 its CUDA 12.8 and 12.9 builds start
|
||||
// at Turing (7.5), as CUDA 13 does, so a V100 has no torch but this one.
|
||||
const pytorchCU126 = "https://download.pytorch.org/whl/cu126"
|
||||
|
||||
// comfyuiCandidates are the ComfyUI builds for a machine: its source, in a
|
||||
// Python environment with the torch for the node's driver and cards —
|
||||
// PyPI's, built for CUDA 13; PyTorch's CUDA 12.9 build for a driver CUDA 13
|
||||
// does not run on; and its CUDA 12.6 build for cards older than Turing.
|
||||
func comfyuiCandidates(version, variant string, h discovery.Host) ([]Build, error) {
|
||||
if _, err := gpuArch("ComfyUI", h); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rel, ok := comfyuiReleases[version]
|
||||
if !ok {
|
||||
known := make([]string, 0, len(comfyuiReleases))
|
||||
for v := range comfyuiReleases {
|
||||
known = append(known, v)
|
||||
}
|
||||
slices.Sort(known)
|
||||
return nil, fmt.Errorf("this agent knows the source digest of ComfyUI %s, not of %s; "+
|
||||
"install that one by hand and name it in agent.yaml (engines.comfyui.dir)",
|
||||
strings.Join(known, ", "), version)
|
||||
}
|
||||
published, err := time.Parse(time.RFC3339, rel.published)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
build := func(variant string) Build {
|
||||
return Build{
|
||||
Variant: variant,
|
||||
Python: pythonForEngines,
|
||||
Source: &Source{
|
||||
URL: "/" + ComfyUIRepo + "/archive/refs/tags/v" + version + ".tar.gz",
|
||||
SHA256: rel.sha256, Top: "ComfyUI-" + version, Dir: comfyuiDir,
|
||||
Requirements: "requirements.txt",
|
||||
},
|
||||
AsOf: published.Add(24 * time.Hour),
|
||||
// ComfyUI is started as a script, so what the agent is given is
|
||||
// the environment's interpreter; the proof is importing what it
|
||||
// imports first, from where its code is.
|
||||
Binary: "python",
|
||||
VersionArgs: []string{"-c", `import os, sys, torch, aiohttp, safetensors
|
||||
d = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(sys.executable))), "` + comfyuiDir + `")
|
||||
assert os.path.isfile(os.path.join(d, "main.py")), d + " has no main.py"
|
||||
print("comfyui version", "` + version + `", "on torch", torch.__version__)`},
|
||||
Packages: compilerPackages,
|
||||
FreeGB: 15,
|
||||
GPUCheck: true,
|
||||
}
|
||||
}
|
||||
cu129 := build("cu129")
|
||||
cu129.ExtraIndex, cu129.IndexPackages = pytorchCU129, pytorchPackages
|
||||
cu126 := build("cu126")
|
||||
cu126.ExtraIndex, cu126.IndexPackages = pytorchCU126, pytorchPackages
|
||||
return pick("ComfyUI "+version, variant, []candidate{
|
||||
{build("cu13"), gpuNeeds{minDriverForCUDA13, 7.5}, "ComfyUI " + version + " on CUDA 13's torch"},
|
||||
{cu129, gpuNeeds{minDriverForCUDA12, 7.5}, "ComfyUI " + version + " on CUDA 12.9's torch"},
|
||||
{cu126, gpuNeeds{minDriverForCUDA12, 5.0}, "ComfyUI " + version + " on CUDA 12.6's torch"},
|
||||
}, h)
|
||||
}
|
||||
|
||||
// --- Strata ---
|
||||
|
||||
// StrataRepo is where Strata is published: its source, and in each release
|
||||
// the prebuilt engine for each CUDA.
|
||||
const StrataRepo = "Niko1221/Strata"
|
||||
|
||||
// strataReleases are the Strata versions this agent installs: the source
|
||||
// archive's digest, taken when it was added here and confirmed from a second
|
||||
// machine, when the version was published, and the engine its release
|
||||
// carries, whose files GitHub publishes digests for.
|
||||
var strataReleases = map[string]struct {
|
||||
sha256 string
|
||||
published string
|
||||
engine string
|
||||
}{
|
||||
"0.2.7": {"41ae861478f5e5d9506864f97dfb5dd7cfe6b83193300e2b0d4f04c0380e3af9", "2026-10-27T14:54:42Z", "0.1.52"},
|
||||
}
|
||||
|
||||
// strataCandidates are the Strata builds for a machine: its source, in a
|
||||
// Python environment, with the prebuilt engine for the node's driver and
|
||||
// cards — built for CUDA 13, Turing to Blackwell; or for CUDA 12.9, Pascal
|
||||
// to Ada, which is what a V100, or a driver older than 580, gets.
|
||||
func strataCandidates(version, variant string, h discovery.Host) ([]Build, error) {
|
||||
if _, err := gpuArch("Strata", h); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.Arch != "amd64" {
|
||||
return nil, NotHere{"Strata publishes its Linux CUDA engine for x86-64 only"}
|
||||
}
|
||||
rel, ok := strataReleases[version]
|
||||
if !ok {
|
||||
known := make([]string, 0, len(strataReleases))
|
||||
for v := range strataReleases {
|
||||
known = append(known, v)
|
||||
}
|
||||
slices.Sort(known)
|
||||
return nil, fmt.Errorf("this agent knows the source digest of Strata %s, not of %s; "+
|
||||
"install that one by hand and name it in agent.yaml (engines.strata.dir)",
|
||||
strings.Join(known, ", "), version)
|
||||
}
|
||||
published, err := time.Parse(time.RFC3339, rel.published)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
build := func(cuda string) Build {
|
||||
return Build{
|
||||
Variant: cuda,
|
||||
Python: pythonForEngines,
|
||||
Source: &Source{
|
||||
URL: "/" + StrataRepo + "/archive/refs/tags/v" + version + ".tar.gz",
|
||||
SHA256: rel.sha256, Top: "Strata-" + version, Dir: "strata",
|
||||
Requirements: "requirements.txt",
|
||||
},
|
||||
AsOf: published.Add(24 * time.Hour),
|
||||
Assets: []string{"strata-engine-" + rel.engine + "-linux-x86_64-cuda-" + cuda + ".tar.gz"},
|
||||
AssetTag: "v" + version,
|
||||
AssetDir: "strata-engine",
|
||||
// Strata is started as a script, so what the agent is given is
|
||||
// the environment's interpreter; the proof is importing what its
|
||||
// server needs, and finding its engine beside it.
|
||||
Binary: "python",
|
||||
VersionArgs: []string{"-c", `import os, sys, numpy, jinja2, regex
|
||||
e = os.path.dirname(os.path.dirname(os.path.abspath(sys.executable)))
|
||||
for f in ("strata/setup.py", "strata/serve/server.py", "strata-engine/strata"):
|
||||
assert os.path.isfile(os.path.join(e, f)), f + " is missing"
|
||||
print("strata version", "` + version + `", "engine", "` + rel.engine + `")`},
|
||||
FreeGB: 5,
|
||||
}
|
||||
}
|
||||
return pick("Strata "+version, variant, []candidate{
|
||||
{build("cuda13"), gpuNeeds{minDriverForCUDA13, 7.5}, "Strata " + version + " built for CUDA 13"},
|
||||
// 12.9 rather than 12: its machine code is for 6.0 to 8.9 and its
|
||||
// PTX was compiled by 12.9, so the driver must be 575.
|
||||
{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)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
)
|
||||
|
||||
// strataRelease is Strata's source archive as GitHub makes one, and its
|
||||
// release's engine for CUDA 13, with the recipe pointed at the archive.
|
||||
func strataRelease(t *testing.T, rel *release) {
|
||||
t.Helper()
|
||||
archive := tarGz(t, map[string]string{
|
||||
"Strata-0.2.7/setup.py": "print('setup')\n",
|
||||
"Strata-0.2.7/serve/server.py": "print('server')\n",
|
||||
"Strata-0.2.7/requirements.txt": "numpy==2.5.3; python_version >= \"3.12\"\njinja2==3.1.6\n",
|
||||
}, nil)
|
||||
rel.assets["v0.2.7.tar.gz"] = archive
|
||||
sum := sha256.Sum256(archive)
|
||||
engine := tarGz(t, map[string]string{"strata": "\x7fELF", "libcudart.so.13": "lib"}, nil)
|
||||
name := "strata-engine-0.1.52-linux-x86_64-cuda-cuda13.tar.gz"
|
||||
rel.assets[name] = engine
|
||||
esum := sha256.Sum256(engine)
|
||||
rel.digests[name] = "sha256:" + hex.EncodeToString(esum[:])
|
||||
|
||||
old := strataReleases
|
||||
strataReleases = map[string]struct {
|
||||
sha256 string
|
||||
published string
|
||||
engine string
|
||||
}{"0.2.7": {hex.EncodeToString(sum[:]), "2026-10-27T14:54:42Z", "0.1.52"}}
|
||||
t.Cleanup(func() { strataReleases = old })
|
||||
}
|
||||
|
||||
func TestStrataIsInstalledWithItsEngineBesideItsCode(t *testing.T) {
|
||||
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
|
||||
in, rel := pythonInstaller(t, turingNode, sys)
|
||||
strataRelease(t, rel)
|
||||
|
||||
got := in.Converge(t.Context(), []Want{{Engine: "strata", Version: "0.2.7"}})
|
||||
if got[0].State != Ready {
|
||||
t.Fatalf("not ready: %+v", got[0])
|
||||
}
|
||||
dir := filepath.Join(in.Root, "strata", "0.2.7-cuda13")
|
||||
if got[0].Binary != filepath.Join(dir, "bin", "python") {
|
||||
t.Errorf("binary %s, want the environment's interpreter", got[0].Binary)
|
||||
}
|
||||
for _, f := range []string{"strata/setup.py", "strata/serve/server.py", "strata-engine/strata", "strata-engine/libcudart.so.13"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
|
||||
t.Errorf("%s is not in the environment: %v", f, err)
|
||||
}
|
||||
}
|
||||
all := strings.Join(sys.commands, "\n")
|
||||
for _, want := range []string{"--exclude-newer 2026-10-28T14:54:42Z", "jinja2==3.1.6"} {
|
||||
if !strings.Contains(all, want) {
|
||||
t.Errorf("the installation never used %q:\n%s", want, all)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataRefusesAnEngineWhoseDigestDoesNotMatch(t *testing.T) {
|
||||
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
|
||||
in, rel := pythonInstaller(t, turingNode, sys)
|
||||
strataRelease(t, rel)
|
||||
rel.digests["strata-engine-0.1.52-linux-x86_64-cuda-cuda13.tar.gz"] = "sha256:" + strings.Repeat("0", 64)
|
||||
|
||||
got := in.Converge(t.Context(), []Want{{Engine: "strata", Version: "0.2.7", Variant: "cuda13"}})
|
||||
if got[0].State != Failed || !strings.Contains(got[0].Detail, "does not match the checksum") {
|
||||
t.Fatalf("an engine that does not match its digest was not refused: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataPicksTheEngineForTheCardsAndDriver(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
host discovery.Host
|
||||
want []string
|
||||
err string
|
||||
}{
|
||||
{"turing on 580", turingNode, []string{"cuda13", "cuda12"}, ""},
|
||||
{"volta", discovery.Host{Arch: "amd64", NVIDIADriver: "580.82.07", MinComputeCapability: 7.0}, []string{"cuda12"}, ""},
|
||||
{"driver 575", discovery.Host{Arch: "amd64", NVIDIADriver: "575.51.03", MinComputeCapability: 8.6}, []string{"cuda12"}, ""},
|
||||
{"driver 570", discovery.Host{Arch: "amd64", NVIDIADriver: "570.86.10", MinComputeCapability: 8.6}, nil, "575"},
|
||||
{"no gpu", discovery.Host{Arch: "amd64"}, nil, "no"},
|
||||
{"arm", discovery.Host{Arch: "arm64", NVIDIADriver: "580.82.07", MinComputeCapability: 9.0}, nil, "x86-64"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
bs, err := strataCandidates("0.2.7", "", tc.host)
|
||||
if tc.err != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.err) {
|
||||
t.Fatalf("got %v, %v; want an error saying %q", bs, err, tc.err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for _, b := range bs {
|
||||
got = append(got, b.Variant)
|
||||
}
|
||||
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
|
||||
t.Errorf("builds %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataIsNotInAnOfflineBundle(t *testing.T) {
|
||||
if _, err := Builds("strata", "0.2.7", "amd64"); err == nil {
|
||||
t.Fatal("an offline bundle would carry Strata, which needs PyPI")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package supervisor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// An engine's output goes to a file of its own, not to a pipe of the
|
||||
// agent's. A pipe has one reader, and that reader is the agent that
|
||||
// started the engine: when that agent restarts, the engine it left running
|
||||
// — and adopts a moment later — writes to a pipe nobody reads, and its next
|
||||
// write past the pipe's buffer fails with EPIPE. A Python engine's progress
|
||||
// bar then raises, and takes the request it was reporting on with it. A
|
||||
// file has no reader to lose: the engine appends to it, every agent reads
|
||||
// it, and the output from before a restart is there for the one after.
|
||||
|
||||
// maxOutputBytes bounds one process's file: past it the file is emptied and
|
||||
// the process goes on appending to it. A progress bar rewriting itself for
|
||||
// an hour would otherwise fill the disk.
|
||||
const maxOutputBytes = 64 << 20
|
||||
|
||||
// outputPath is where a shard's process writes.
|
||||
func (s *Supervisor) outputPath(shardID, name string) string {
|
||||
if name != "" {
|
||||
return filepath.Join(s.cfg.WorkDir, "shard-"+shardID+"."+name+".log")
|
||||
}
|
||||
return filepath.Join(s.cfg.WorkDir, "shard-"+shardID+".log")
|
||||
}
|
||||
|
||||
// openOutput is the file a process is started with: new, for a process
|
||||
// that is new.
|
||||
func (s *Supervisor) openOutput(shardID, name string) (*os.File, error) {
|
||||
return os.OpenFile(s.outputPath(shardID, name), os.O_CREATE|os.O_WRONLY|os.O_APPEND|os.O_TRUNC, 0o600) //nolint:gosec // the agent's own directory
|
||||
}
|
||||
|
||||
// follow reads a process's file into the shard's log as it is written,
|
||||
// until the shard's output is stopped and the file has been read to its
|
||||
// end. A file an earlier agent began is read from the start, so the output
|
||||
// from before a restart is in the log too.
|
||||
func (s *Supervisor) follow(sh *Shard, path, name string) {
|
||||
defer sh.outDone.Done()
|
||||
f, err := os.Open(path) //nolint:gosec // the agent's own directory
|
||||
if err != nil {
|
||||
sh.logs.add("[" + name + "] output unavailable: " + err.Error())
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
var offset int64
|
||||
var rest []byte
|
||||
buf := make([]byte, 64<<10)
|
||||
read := func() {
|
||||
for {
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if st.Size() < offset {
|
||||
// Emptied, by this agent or another: begin again.
|
||||
offset, rest = 0, nil
|
||||
}
|
||||
if st.Size() > maxOutputBytes {
|
||||
_ = os.Truncate(path, 0)
|
||||
offset, rest = 0, nil
|
||||
continue
|
||||
}
|
||||
n, err := f.ReadAt(buf, offset)
|
||||
if n > 0 {
|
||||
offset += int64(n)
|
||||
rest = append(rest, buf[:n]...)
|
||||
for {
|
||||
i := bytes.IndexByte(rest, '\n')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
s.addLine(sh, name, rest[:i])
|
||||
rest = rest[i+1:]
|
||||
}
|
||||
// A progress bar is a line rewritten with \r and never ended:
|
||||
// its latest state is what is wanted, not a line a megabyte
|
||||
// long. Kept, as llama.cpp and tqdm write them.
|
||||
if len(rest) > 1<<20 {
|
||||
s.addLine(sh, name, rest)
|
||||
rest = nil
|
||||
}
|
||||
}
|
||||
if err != nil || n == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
t := time.NewTicker(200 * time.Millisecond)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-sh.outStop:
|
||||
read()
|
||||
if len(rest) > 0 {
|
||||
s.addLine(sh, name, rest)
|
||||
}
|
||||
return
|
||||
case <-t.C:
|
||||
read()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Supervisor) addLine(sh *Shard, name string, b []byte) {
|
||||
// The last state of a line rewritten with \r, as a terminal would show.
|
||||
if i := bytes.LastIndexByte(b, '\r'); i >= 0 {
|
||||
b = b[i+1:]
|
||||
}
|
||||
line := string(b)
|
||||
if name != "" {
|
||||
// Prefixed so the two processes of a Ray shard stay distinguishable
|
||||
// in a single log tail.
|
||||
line = "[" + name + "] " + line
|
||||
}
|
||||
sh.logs.add(line)
|
||||
s.log.Debug("engine", "shard", sh.ID, "line", line)
|
||||
}
|
||||
|
||||
// removeOutput drops a shard's files once nothing writes to them.
|
||||
func (s *Supervisor) removeOutput(shardID string) {
|
||||
matches, _ := filepath.Glob(filepath.Join(s.cfg.WorkDir, "shard-"+shardID+".*log"))
|
||||
for _, m := range matches {
|
||||
_ = os.Remove(m)
|
||||
}
|
||||
_ = os.Remove(s.outputPath(shardID, ""))
|
||||
}
|
||||
|
||||
// stopOutput has every follower read what is left and stop. Called once
|
||||
// the process has ended, before the shard is done, so what it wrote last
|
||||
// is in the log by the time anyone reads the log for why it ended.
|
||||
func (s *Supervisor) stopOutput(sh *Shard) {
|
||||
sh.outOnce.Do(func() { close(sh.outStop) })
|
||||
sh.outDone.Wait()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
package supervisor
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
@@ -104,6 +102,12 @@ type Shard struct {
|
||||
ID string
|
||||
Spec engines.ShardSpec
|
||||
|
||||
// outStop ends the followers of the processes' output files, and
|
||||
// outDone is their having read to the end.
|
||||
outStop chan struct{}
|
||||
outOnce sync.Once
|
||||
outDone sync.WaitGroup
|
||||
|
||||
mu sync.Mutex
|
||||
state State
|
||||
pid int
|
||||
@@ -248,6 +252,7 @@ func (s *Supervisor) Start(ctx context.Context, id string, spec engines.ShardSpe
|
||||
logs: newRingBuffer(s.cfg.LogLines),
|
||||
started: time.Now(),
|
||||
done: make(chan struct{}),
|
||||
outStop: make(chan struct{}),
|
||||
}
|
||||
s.shards[id] = sh
|
||||
s.mu.Unlock()
|
||||
@@ -315,14 +320,15 @@ func (s *Supervisor) launch(ctx context.Context, sh *Shard, cmds []engines.Comma
|
||||
// CommandContext kills only the leader, so replace that with a group kill.
|
||||
c.Cancel = func() error { return killGroup(c.Process.Pid, syscall.SIGKILL) }
|
||||
|
||||
stdout, err := c.StdoutPipe()
|
||||
out, err := s.openOutput(sh.ID, "")
|
||||
if err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
c.Stderr = c.Stdout.(io.Writer) // interleave, as the engine writes them
|
||||
|
||||
if err := c.Start(); err != nil {
|
||||
c.Stdout, c.Stderr = out, out // interleaved, as the engine writes them
|
||||
err = c.Start()
|
||||
out.Close() // the process holds its own
|
||||
if err != nil {
|
||||
cancel()
|
||||
return fmt.Errorf("starting %s: %w", cmd.Path, err)
|
||||
}
|
||||
@@ -339,7 +345,8 @@ func (s *Supervisor) launch(ctx context.Context, sh *Shard, cmds []engines.Comma
|
||||
"shard", sh.ID, "err", err)
|
||||
}
|
||||
|
||||
go s.pump(sh, stdout, cmd.Name)
|
||||
sh.outDone.Add(1)
|
||||
go s.follow(sh, s.outputPath(sh.ID, ""), cmd.Name)
|
||||
go s.wait(sh)
|
||||
return nil
|
||||
}
|
||||
@@ -353,16 +360,18 @@ func (s *Supervisor) spawn(ctx context.Context, sh *Shard, c engines.Command) (*
|
||||
s.dropTo(proc)
|
||||
proc.Cancel = func() error { return killGroup(proc.Process.Pid, syscall.SIGKILL) }
|
||||
|
||||
stdout, err := proc.StdoutPipe()
|
||||
out, err := s.openOutput(sh.ID, c.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
proc.Stderr = proc.Stdout.(io.Writer)
|
||||
|
||||
if err := proc.Start(); err != nil {
|
||||
proc.Stdout, proc.Stderr = out, out
|
||||
err = proc.Start()
|
||||
out.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("starting %s: %w", c.Path, err)
|
||||
}
|
||||
go s.pump(sh, stdout, c.Name)
|
||||
sh.outDone.Add(1)
|
||||
go s.follow(sh, s.outputPath(sh.ID, c.Name), c.Name)
|
||||
// Reaped so it does not become a zombie; its exit does not end the shard.
|
||||
go func() { _ = proc.Wait() }()
|
||||
return proc, nil
|
||||
@@ -395,24 +404,6 @@ func waitForPort(ctx context.Context, addr string, timeout time.Duration) (err e
|
||||
}
|
||||
|
||||
// pump copies a process's output into the ring buffer and the log.
|
||||
func (s *Supervisor) pump(sh *Shard, r io.Reader, name string) {
|
||||
sc := bufio.NewScanner(r)
|
||||
// Engine tracebacks and progress bars produce long lines; the default
|
||||
// 64 KiB limit truncates them exactly when they matter.
|
||||
sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
|
||||
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if name != "" {
|
||||
// Prefixed so the two processes of a Ray shard stay distinguishable
|
||||
// in a single log tail.
|
||||
line = "[" + name + "] " + line
|
||||
}
|
||||
sh.logs.add(line)
|
||||
s.log.Debug("engine", "shard", sh.ID, "line", line)
|
||||
}
|
||||
}
|
||||
|
||||
// wait reaps the process and records why it ended.
|
||||
func (s *Supervisor) wait(sh *Shard) {
|
||||
defer close(sh.done)
|
||||
@@ -440,6 +431,7 @@ func (s *Supervisor) wait(sh *Shard) {
|
||||
final, code := sh.state, sh.exitCode
|
||||
sh.mu.Unlock()
|
||||
|
||||
s.stopOutput(sh)
|
||||
s.removeState(sh)
|
||||
if final == Failed {
|
||||
s.log.Error("shard failed", "shard", sh.ID, "exit_code", code, "err", err,
|
||||
@@ -590,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.
|
||||
@@ -606,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 {
|
||||
@@ -760,6 +767,7 @@ func (s *Supervisor) writeState(sh *Shard) error {
|
||||
|
||||
func (s *Supervisor) removeState(sh *Shard) {
|
||||
_ = os.Remove(s.statePath(sh.ID))
|
||||
s.removeOutput(sh.ID)
|
||||
}
|
||||
|
||||
// Orphan is an engine process left behind by a previous agent.
|
||||
@@ -838,15 +846,18 @@ func (s *Supervisor) Adopt(o Orphan) (*Shard, error) {
|
||||
logs: newRingBuffer(s.cfg.LogLines),
|
||||
started: o.StartedAt,
|
||||
done: make(chan struct{}),
|
||||
outStop: make(chan struct{}),
|
||||
adopted: true,
|
||||
cmdline: o.Cmdline,
|
||||
}
|
||||
s.shards[o.ShardID] = sh
|
||||
s.mu.Unlock()
|
||||
|
||||
// Output is gone with the previous agent; say so rather than presenting an
|
||||
// empty log as if the engine had been silent.
|
||||
sh.logs.add("[adopted from a previous agent; earlier output is not available]")
|
||||
// Its output file is read from the start: what it wrote under the
|
||||
// previous agent is there, and what it writes now goes on arriving.
|
||||
sh.logs.add("[adopted from a previous agent]")
|
||||
sh.outDone.Add(1)
|
||||
go s.follow(sh, s.outputPath(sh.ID, ""), "")
|
||||
|
||||
go s.poll(sh)
|
||||
return sh, nil
|
||||
@@ -874,6 +885,7 @@ func (s *Supervisor) poll(sh *Shard) {
|
||||
}
|
||||
sh.mu.Unlock()
|
||||
|
||||
s.stopOutput(sh)
|
||||
s.removeState(sh)
|
||||
s.log.Info("adopted shard exited", "shard", sh.ID, "expected", expected)
|
||||
return
|
||||
@@ -900,6 +912,7 @@ func (s *Supervisor) KillOrphan(o Orphan) error {
|
||||
}
|
||||
// An already-missing state file is success: the previous agent may have
|
||||
// reaped the process itself between the scan and this call.
|
||||
s.removeOutput(o.ShardID)
|
||||
if err := os.Remove(s.statePath(o.ShardID)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package supervisor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -905,3 +907,113 @@ func TestAnAdoptedShardsComputeIsKeptWithoutCrashing(t *testing.T) {
|
||||
t.Fatalf("lost: %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAdoptedEngineGoesOnWritingAndItsEarlierOutputIsKept(t *testing.T) {
|
||||
// An engine's output went to a pipe of the agent's. The agent that
|
||||
// restarted and adopted the engine left that pipe with no reader, and
|
||||
// the engine's next write past the pipe's buffer failed with EPIPE —
|
||||
// which, in a Python engine, raised inside whatever was being reported
|
||||
// on and failed the request. Output goes to a file now: a reader is not
|
||||
// something a file can lose, and the lines from before the restart are
|
||||
// there for the agent after it.
|
||||
s, _ := newSupervisor(t)
|
||||
// 256 KiB at once outgrows a pipe's buffer; a reader gone means EPIPE.
|
||||
sh, err := s.Start(t.Context(), "a", engines.ShardSpec{ReplicaID: "r"},
|
||||
shellCommand(`echo before-restart; sleep 1; head -c 262144 /dev/zero | tr '\0' x; echo; echo after-restart-done; sleep 30`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventually(t, 3*time.Second, "the first line was never captured", func() bool {
|
||||
return strings.Contains(strings.Join(sh.Logs(10), "\n"), "before-restart")
|
||||
})
|
||||
// The agent goes away without stopping the engine: nothing reads the
|
||||
// output between here and the adoption.
|
||||
pid := sh.Status().PID
|
||||
sh.outOnce.Do(func() { close(sh.outStop) })
|
||||
sh.outDone.Wait()
|
||||
|
||||
s2, err := New(Config{WorkDir: s.cfg.WorkDir, StopTimeout: 3 * time.Second, LogLines: 64},
|
||||
&scriptedHealth{}, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
orphans, _ := s2.FindOrphans()
|
||||
adopted, err := s2.Adopt(orphans[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventually(t, 5*time.Second, "the engine's output after the restart never arrived: "+strings.Join(adopted.Logs(5), " | "), func() bool {
|
||||
return strings.Contains(strings.Join(adopted.Logs(64), "\n"), "after-restart-done")
|
||||
})
|
||||
if !strings.Contains(strings.Join(adopted.Logs(64), "\n"), "before-restart") {
|
||||
t.Error("the output from before the restart is not in the adopted log")
|
||||
}
|
||||
if !processAlive(pid) {
|
||||
t.Fatal("the engine died writing output nobody was reading")
|
||||
}
|
||||
if err := s2.Stop(t.Context(), "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(s2.outputPath("a", "")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("the output file was not removed with the shard: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOutputFileIsEmptiedPastItsBound(t *testing.T) {
|
||||
s, _ := newSupervisor(t)
|
||||
path := s.outputPath("a", "")
|
||||
// A file already past the bound, as a long-running engine leaves one.
|
||||
big, _ := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
_, _ = big.Write(bytes.Repeat([]byte("x"), maxOutputBytes+1))
|
||||
big.Close()
|
||||
sh := &Shard{ID: "a", logs: newRingBuffer(8), done: make(chan struct{}), outStop: make(chan struct{})}
|
||||
sh.outDone.Add(1)
|
||||
go s.follow(sh, path, "")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
f, _ := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
_, _ = f.WriteString("still here\n")
|
||||
f.Close()
|
||||
s.stopOutput(sh)
|
||||
st, _ := os.Stat(path)
|
||||
if st.Size() > 1024 {
|
||||
t.Errorf("the file was not emptied: %d bytes", st.Size())
|
||||
}
|
||||
if !strings.Contains(strings.Join(sh.Logs(8), "\n"), "still here") {
|
||||
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
|
||||
+137
-15
@@ -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 {
|
||||
@@ -90,14 +96,27 @@ var ErrNotLocal = errors.New("the weights are not on this node and the source sa
|
||||
// already cached, downloaded, or downloaded by another shard this one waited
|
||||
// for — since that is most of the difference between a replica that starts
|
||||
// in a minute and one that takes an hour.
|
||||
func (f *Fetcher) Ensure(ctx context.Context, source string) (path string, err error) {
|
||||
//
|
||||
// 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() {
|
||||
span.SetAttributes(attribute.String("llmgw.weights.path", path))
|
||||
obs.End(span, err)
|
||||
}()
|
||||
return f.ensure(ctx, source)
|
||||
if !allowPickle && !IsSet(source) && Pickled(source) {
|
||||
return "", refusePickle(source)
|
||||
}
|
||||
return f.ensure(ctx, source, allowPickle, strings.ToLower(sha256))
|
||||
}
|
||||
|
||||
// tracerName names the spans this package opens.
|
||||
@@ -119,7 +138,7 @@ func redact(source string) string {
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func (f *Fetcher) ensure(ctx context.Context, source string) (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")
|
||||
}
|
||||
@@ -127,7 +146,11 @@ func (f *Fetcher) ensure(ctx context.Context, source string) (string, error) {
|
||||
// file, and a set has to be handled before any of it.
|
||||
if IsSet(source) {
|
||||
outcome(ctx, "set")
|
||||
return f.ensureSet(ctx, source)
|
||||
// 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) {
|
||||
@@ -150,10 +173,24 @@ func (f *Fetcher) ensure(ctx context.Context, source string) (string, error) {
|
||||
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{}
|
||||
@@ -172,8 +209,7 @@ func (f *Fetcher) ensure(ctx context.Context, source string) (string, error) {
|
||||
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()
|
||||
@@ -184,9 +220,83 @@ func (f *Fetcher) ensure(ctx context.Context, source string) (string, error) {
|
||||
|
||||
// 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
|
||||
@@ -195,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))))
|
||||
@@ -259,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),
|
||||
@@ -284,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()
|
||||
@@ -302,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 {
|
||||
@@ -316,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"
|
||||
@@ -37,7 +39,7 @@ func TestALocalPathIsUsedNeverCopied(t *testing.T) {
|
||||
}
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
|
||||
got, err := f.Ensure(t.Context(), path)
|
||||
got, err := f.Ensure(t.Context(), path, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -53,7 +55,7 @@ func TestAMissingLocalPathSaysWhatIsWrong(t *testing.T) {
|
||||
// The commonest setup mistake, and "no such file" from an engine twenty
|
||||
// minutes into a load is a poor way to learn it.
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
_, err := f.Ensure(t.Context(), filepath.Join(t.TempDir(), "absent.gguf"))
|
||||
_, err := f.Ensure(t.Context(), filepath.Join(t.TempDir(), "absent.gguf"), false)
|
||||
if !errors.Is(err, ErrNotLocal) {
|
||||
t.Fatalf("err = %v, want ErrNotLocal", err)
|
||||
}
|
||||
@@ -67,7 +69,7 @@ func TestARemoteFileIsFetchedOnceAndReused(t *testing.T) {
|
||||
srv := serveBytes(t, []byte("the weights"), &hits)
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
|
||||
first, err := f.Ensure(t.Context(), srv.URL+"/model.gguf")
|
||||
first, err := f.Ensure(t.Context(), srv.URL+"/model.gguf", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -76,7 +78,7 @@ func TestARemoteFileIsFetchedOnceAndReused(t *testing.T) {
|
||||
t.Fatalf("fetched content = %q, err %v", body, err)
|
||||
}
|
||||
|
||||
second, err := f.Ensure(t.Context(), srv.URL+"/model.gguf")
|
||||
second, err := f.Ensure(t.Context(), srv.URL+"/model.gguf", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -103,7 +105,7 @@ func TestConcurrentRequestsProduceOneDownload(t *testing.T) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
paths[i], errs[i] = f.Ensure(t.Context(), srv.URL+"/model.gguf")
|
||||
paths[i], errs[i] = f.Ensure(t.Context(), srv.URL+"/model.gguf", false)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -139,7 +141,7 @@ func TestAPartialDownloadIsNeverVisibleAsTheModel(t *testing.T) {
|
||||
|
||||
cache := t.TempDir()
|
||||
f := &Fetcher{CacheDir: cache}
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/model.gguf"); err == nil {
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/model.gguf", false); err == nil {
|
||||
t.Fatal("a truncated download was reported as success")
|
||||
}
|
||||
entries, _ := os.ReadDir(cache)
|
||||
@@ -169,7 +171,7 @@ func TestAnInterruptedDownloadResumes(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := f.Ensure(t.Context(), srv.URL+"/model.gguf")
|
||||
got, err := f.Ensure(t.Context(), srv.URL+"/model.gguf", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -232,7 +234,7 @@ func TestProgressIsReportedWithAPercentage(t *testing.T) {
|
||||
CacheDir: t.TempDir(),
|
||||
OnProgress: func(p Progress) { seen = append(seen, p) },
|
||||
}
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/model.gguf"); err != nil {
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/model.gguf", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
@@ -254,7 +256,7 @@ func TestPercentIsUnknownRatherThanZeroWithoutATotal(t *testing.T) {
|
||||
|
||||
func TestNoCacheDirectoryIsAClearRefusal(t *testing.T) {
|
||||
f := &Fetcher{}
|
||||
_, err := f.Ensure(t.Context(), "https://example.invalid/model.gguf")
|
||||
_, err := f.Ensure(t.Context(), "https://example.invalid/model.gguf", false)
|
||||
if err == nil || !strings.Contains(err.Error(), "cache directory") {
|
||||
t.Errorf("err = %v, want a complaint about the cache directory", err)
|
||||
}
|
||||
@@ -269,7 +271,7 @@ func TestABareModelIdIsLeftToTheEngine(t *testing.T) {
|
||||
"Qwen/Qwen2.5-7B-Instruct",
|
||||
"meta-llama/Llama-3.3-70B-Instruct",
|
||||
} {
|
||||
got, err := f.Ensure(t.Context(), id)
|
||||
got, err := f.Ensure(t.Context(), id, false)
|
||||
if err != nil {
|
||||
t.Errorf("%s was refused: %v", id, err)
|
||||
}
|
||||
@@ -283,7 +285,79 @@ func TestAnAbsolutePathThatIsMissingIsStillAnError(t *testing.T) {
|
||||
// The other half: an operator writing a path means a file, and a missing
|
||||
// one should be said plainly rather than handed to an engine.
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
if _, err := f.Ensure(t.Context(), "/models/definitely-not-here.gguf"); !errors.Is(err, ErrNotLocal) {
|
||||
if _, err := f.Ensure(t.Context(), "/models/definitely-not-here.gguf", false); !errors.Is(err, ErrNotLocal) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package weights
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Weights in Python's pickle format are a program as much as a file: loading
|
||||
// one runs whatever it says, as the engine's user, on the node. That is how
|
||||
// the malicious models found on Hugging Face work, and nothing in the file's
|
||||
// name or size says so. safetensors and GGUF are only tensors and are read,
|
||||
// never run, so those are what a node fetches unless the catalog says
|
||||
// otherwise for a model, with allow_pickle, on purpose.
|
||||
|
||||
// pickleExts are the extensions of PyTorch's pickle formats, and of pickle
|
||||
// itself. A .bin beside safetensors is the same model in pickle form.
|
||||
var pickleExts = []string{".bin", ".pt", ".pt2", ".pth", ".ckpt", ".pkl", ".pickle"}
|
||||
|
||||
// Pickled reports whether a file name or URL is in a pickle format.
|
||||
func Pickled(name string) bool {
|
||||
// A query or a fragment is not the file's name, on a URL or an hf:
|
||||
// source alike: model.bin?download=true is still model.bin.
|
||||
if i := strings.IndexAny(name, "?#"); i >= 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
ext := strings.ToLower(path.Ext(name))
|
||||
for _, e := range pickleExts {
|
||||
if ext == e {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ErrPickle is a source refused for being in a pickle format.
|
||||
var ErrPickle = errors.New("weights in a pickle format run code when they are loaded")
|
||||
|
||||
func refusePickle(name string) error {
|
||||
return fmt.Errorf("%w: %s is refused; use the model's safetensors or GGUF, or set allow_pickle: true "+
|
||||
"on the model if it was checked and has no other form", ErrPickle, redact(name))
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package weights
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPickleFormatsAreKnownByTheirNames(t *testing.T) {
|
||||
for name, want := range map[string]bool{
|
||||
"pytorch_model.bin": true, "model.ckpt": true, "x.PT": true, "w.pth": true, "a.pkl": true,
|
||||
"https://h/r/resolve/main/model.ckpt?download=true": true,
|
||||
"hf:o/r/model.bin?download=true": true, "hf:o/r/model.ckpt#x": true, "m.pt2": true,
|
||||
"model.safetensors": false, "m-Q4_K_M.gguf": false, "Qwen/Qwen2.5-7B-Instruct": false,
|
||||
"config.json": false, "": false,
|
||||
} {
|
||||
if got := Pickled(name); got != want {
|
||||
t.Errorf("Pickled(%q) = %v, want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPickleFileIsRefusedBeforeItIsFetched(t *testing.T) {
|
||||
var hits atomic.Int64
|
||||
srv := serveBytes(t, []byte("not really a pickle"), &hits)
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
|
||||
_, err := f.Ensure(t.Context(), srv.URL+"/model.ckpt", false)
|
||||
if !errors.Is(err, ErrPickle) {
|
||||
t.Fatalf("a .ckpt was not refused: %v", err)
|
||||
}
|
||||
if hits.Load() != 0 {
|
||||
t.Errorf("the refused file was fetched %d times", hits.Load())
|
||||
}
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/model.ckpt", true); err != nil {
|
||||
t.Errorf("allowed, the .ckpt was still refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASetWithAPickleFileIsRefusedWhole(t *testing.T) {
|
||||
f, srv, hits := mirrorOf(t, map[string]string{
|
||||
"pytorch_model.bin": "pickle",
|
||||
"config.json": "{}",
|
||||
})
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); !errors.Is(err, ErrPickle) {
|
||||
t.Fatalf("a set holding pytorch_model.bin was not refused: %v", err)
|
||||
}
|
||||
if *hits != 0 {
|
||||
t.Errorf("%d files of the refused set were fetched", *hits)
|
||||
}
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", true); err != nil {
|
||||
t.Errorf("allowed, the set was still refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASetAlreadyHereIsLookedAtAgain(t *testing.T) {
|
||||
// Fetched once for a model that allowed pickles, or before they were
|
||||
// refused: another asking for it without leave is refused all the same.
|
||||
f, srv, _ := mirrorOf(t, map[string]string{"pytorch_model.bin": "pickle", "config.json": "{}"})
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); !errors.Is(err, ErrPickle) {
|
||||
t.Errorf("a set already on the node was not looked at: %v", err)
|
||||
}
|
||||
}
|
||||
+133
-16
@@ -5,12 +5,16 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IsSet reports whether a source names a set of files rather than one.
|
||||
@@ -46,7 +50,7 @@ type manifest struct {
|
||||
// The whole set or nothing. A directory with three of four shards in it loads
|
||||
// as a broken model rather than failing to load, so a partial set is left
|
||||
// unfinished under its temporary name and the next attempt starts again.
|
||||
func (f *Fetcher) ensureSet(ctx context.Context, source string) (string, error) {
|
||||
func (f *Fetcher) ensureSet(ctx context.Context, source string, allowPickle bool) (string, error) {
|
||||
dir, err := f.cachePath(strings.TrimSuffix(source, "/"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -56,6 +60,16 @@ func (f *Fetcher) ensureSet(ctx context.Context, source string) (string, error)
|
||||
// exactly the failure the marker exists to prevent.
|
||||
done := filepath.Join(dir, ".complete")
|
||||
if _, err := os.Stat(done); err == nil {
|
||||
// Already here, maybe fetched for a model that allowed pickles, or
|
||||
// before they were refused: looked at again, not trusted.
|
||||
if !allowPickle {
|
||||
entries, _ := os.ReadDir(dir)
|
||||
for _, e := range entries {
|
||||
if Pickled(e.Name()) {
|
||||
return "", refusePickle(source + e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
@@ -66,10 +80,25 @@ func (f *Fetcher) ensureSet(ctx context.Context, source string) (string, error)
|
||||
if len(m.Files) == 0 {
|
||||
return "", fmt.Errorf("%s holds no files", source)
|
||||
}
|
||||
// Refused whole rather than skipped: an engine given the directory
|
||||
// loads the pickle when there is no safetensors beside it.
|
||||
if !allowPickle {
|
||||
for _, file := range m.Files {
|
||||
if Pickled(file.Name) {
|
||||
return "", refusePickle(source + file.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -79,34 +108,24 @@ func (f *Fetcher) ensureSet(ctx context.Context, source string) (string, error)
|
||||
}
|
||||
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 {
|
||||
@@ -168,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.
|
||||
@@ -53,7 +56,7 @@ func TestASetIsFetchedIntoOneDirectory(t *testing.T) {
|
||||
"config.json": "{}",
|
||||
})
|
||||
|
||||
got, err := f.Ensure(t.Context(), srv.URL+"/m/v/")
|
||||
got, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -74,11 +77,11 @@ func TestASetIsFetchedIntoOneDirectory(t *testing.T) {
|
||||
func TestAFinishedSetIsNotFetchedAgain(t *testing.T) {
|
||||
f, srv, hits := mirrorOf(t, map[string]string{"a.gguf": "aaaa", "b.gguf": "bb"})
|
||||
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/"); err != nil {
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := *hits
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/"); err != nil {
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *hits != after {
|
||||
@@ -100,7 +103,7 @@ func TestAHalfFetchedSetIsNotTreatedAsFinished(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := f.Ensure(t.Context(), srv.URL+"/m/v/")
|
||||
got, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -117,7 +120,7 @@ func TestAFileAlreadyThereAtTheRightSizeIsNotRefetched(t *testing.T) {
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
_ = os.WriteFile(filepath.Join(dir, "a.gguf"), []byte("aaaa"), 0o644)
|
||||
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/"); err != nil {
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Resuming a set means not paying for the shards that already landed.
|
||||
@@ -133,7 +136,7 @@ func TestAFilenameThatEscapesTheDirectoryIsRefused(t *testing.T) {
|
||||
defer srv.Close()
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
|
||||
_, err := f.Ensure(t.Context(), srv.URL+"/m/v/")
|
||||
_, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false)
|
||||
// The mirror is a place an operator copies files into by hand, so what it
|
||||
// reports is not automatically safe to join onto a path.
|
||||
if err == nil || !strings.Contains(err.Error(), "offered a file named") {
|
||||
@@ -148,7 +151,7 @@ func TestADirectoryListingIsNotAManifest(t *testing.T) {
|
||||
defer srv.Close()
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
|
||||
_, err := f.Ensure(t.Context(), srv.URL+"/m/v/")
|
||||
_, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false)
|
||||
// "invalid character '<'" sends somebody looking at the wrong thing.
|
||||
if err == nil || !strings.Contains(err.Error(), "has to be a mirror version") {
|
||||
t.Errorf("the reason should name the cause, got %v", err)
|
||||
@@ -182,12 +185,12 @@ func TestASetFileThatDoesNotMatchTheMirrorsChecksumIsRefused(t *testing.T) {
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/"); err == nil || !strings.Contains(err.Error(), "sha256") {
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); err == nil || !strings.Contains(err.Error(), "sha256") {
|
||||
t.Fatalf("a damaged shard was accepted: %v", err)
|
||||
}
|
||||
served = good
|
||||
// The size differs, so it is fetched again; this time it matches.
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/"); err != nil {
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); err != nil {
|
||||
t.Fatalf("the shard as mirrored: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -198,9 +201,109 @@ func TestASetStillDownloadingOnTheMirrorSaysSo(t *testing.T) {
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
_, err := f.Ensure(t.Context(), srv.URL+"/m/v/")
|
||||
_, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false)
|
||||
if err == nil || !strings.Contains(err.Error(), "not complete on the mirror yet") ||
|
||||
!strings.Contains(err.Error(), "model-00002-of-00002") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ func TestAFetchSaysInItsTraceWhetherItDownloadedAndHowMuch(t *testing.T) {
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
src := strings.Replace(srv.URL, "http://", "http://user:secret@", 1) + "/m/model.gguf?token=hunter2"
|
||||
|
||||
if _, err := f.Ensure(t.Context(), src); err != nil {
|
||||
if _, err := f.Ensure(t.Context(), src, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fetch := rec.One(t, [16]byte{}, "fetch weights")
|
||||
@@ -30,7 +30,7 @@ func TestAFetchSaysInItsTraceWhetherItDownloadedAndHowMuch(t *testing.T) {
|
||||
}
|
||||
|
||||
// The second start on this node finds it here.
|
||||
if _, err := f.Ensure(t.Context(), src); err != nil {
|
||||
if _, err := f.Ensure(t.Context(), src, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again := rec.Named("fetch weights")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func newAnswerCacheCmd(open opener) *cobra.Command {
|
||||
@@ -12,6 +14,7 @@ func newAnswerCacheCmd(open opener) *cobra.Command {
|
||||
project string
|
||||
ttl time.Duration
|
||||
anyAnswer bool
|
||||
similar float64
|
||||
off bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
@@ -28,15 +31,25 @@ func newAnswerCacheCmd(open opener) *cobra.Command {
|
||||
"a request continuing a stored conversation or under a guardrails no_store\n" +
|
||||
"policy. The guardrails judge a reused answer as they judge any other.\n\n" +
|
||||
"Off for every project until this is run. Without --ttl or --off, says how\n" +
|
||||
"it stands. The answers are held in memory, router.cache bounds how much.",
|
||||
"it stands. The answers are held in memory, router.cache bounds how much.\n\n" +
|
||||
"--similar also answers a chat question that is like one already answered:\n" +
|
||||
"its last user message embedded by router.cache.embedding_model, and given\n" +
|
||||
"the answer whose question reaches that cosine similarity (0.95 is strict,\n" +
|
||||
"0.9 looser), as x-llmgw-cache: similar. The system prompt, the earlier\n" +
|
||||
"turns, tools and the sampling settings must still be the same.",
|
||||
Example: " llmgw keys cache --project Acme/ml/train --ttl 1h\n" +
|
||||
" llmgw keys cache --project Acme/ml/eval --ttl 24h --any\n" +
|
||||
" llmgw keys cache --project Acme/support/desk --ttl 24h --similar 0.95\n" +
|
||||
" llmgw keys cache --project Acme/ml/train --off",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
switch {
|
||||
case off && (ttl != 0 || anyAnswer):
|
||||
return fmt.Errorf("--off, or --ttl with --any: not both")
|
||||
case off && (ttl != 0 || anyAnswer || similar != 0):
|
||||
return fmt.Errorf("--off, or --ttl with --any or --similar: not both")
|
||||
case similar < 0 || similar > 1:
|
||||
return fmt.Errorf("--similar is a cosine similarity between 0 and 1, like 0.95")
|
||||
case similar != 0 && ttl == 0:
|
||||
return fmt.Errorf("--similar goes with --ttl: say how long answers are reused")
|
||||
case ttl < 0:
|
||||
return fmt.Errorf("--ttl is how long an answer is reused, like 1h")
|
||||
case ttl > 0 && ttl < time.Second:
|
||||
@@ -54,7 +67,7 @@ func newAnswerCacheCmd(open opener) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
if off || ttl > 0 {
|
||||
if err := s.SetAnswerCache(cmd.Context(), p.ID, ttl, anyAnswer); err != nil {
|
||||
if err := s.SetAnswerCache(cmd.Context(), p.ID, store.AnswerCache{TTL: ttl, Any: anyAnswer, Similar: similar}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -71,6 +84,9 @@ func newAnswerCacheCmd(open opener) *cobra.Command {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s reuses answers for %s: those asked at temperature 0 or with a seed, "+
|
||||
"and embeddings, rerank and score.\n", p.Path, c.TTL)
|
||||
}
|
||||
if c.TTL > 0 && c.Similar > 0 {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Chat questions reaching %.2f similarity to one answered get its answer too.\n", c.Similar)
|
||||
}
|
||||
if off || ttl > 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "Gateways see the change within 30 seconds.")
|
||||
}
|
||||
@@ -80,6 +96,7 @@ func newAnswerCacheCmd(open opener) *cobra.Command {
|
||||
cmd.Flags().StringVar(&project, "project", "", "project: id, path or unique name")
|
||||
cmd.Flags().DurationVar(&ttl, "ttl", 0, "how long an answer is reused, like 1h")
|
||||
cmd.Flags().BoolVar(&anyAnswer, "any", false, "reuse sampled answers too, not only reproducible ones")
|
||||
cmd.Flags().Float64Var(&similar, "similar", 0, "also answer chat questions this similar to one answered, like 0.95")
|
||||
cmd.Flags().BoolVar(&off, "off", false, "stop reusing answers for this project")
|
||||
return cmd
|
||||
}
|
||||
@@ -150,6 +150,7 @@ func newBenchCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
f.DurationVar(&cfg.SLO.TPOTp99, "tpot-p99", cfg.SLO.TPOTp99, "per-output-token target")
|
||||
f.BoolVar(&save, "save", false,
|
||||
"record the capacity against the model and profile, so the number outlives this terminal")
|
||||
cmd.AddCommand(newBenchOverheadCmd(asJSON))
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 582 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user