A big model's download no longer runs out its startup timeout
CI / test (push) Successful in 5m18s

The startup timeout runs from when a node's download last moved, so a
model of hundreds of gigabytes on a slow mirror is not failed while it
arrives. ctl model serve --env sets engine variables (NCCL_DEBUG=INFO)
on every node, and a layout given by hand keeps expert parallelism for a
model of experts. docs/large-models.md: how a model is split, what each
way needs of a card, and a checklist for bringing one up on a fabric.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-20 09:45:31 +02:00
1 parent 4fe9b46451
commit 8ffde2b80c
12 files changed
+259 -4

No files matched your search

+6
View File
@@ -47,6 +47,12 @@ What is left, most needed first. The detail is in the item each names.
licences in video and audio (H5); nginx in front of several control
planes (J7).
14. **K** — large models on several cards and nodes (2026-10-20): code
done (expert parallelism, any number of nodes, memory per card, the KV
head rule, the startup timeout from the last download), and
docs/large-models.md. Left for the operator: trying it on a fabric with
the checklist there. Then fixes from what it shows.
Not code, the operator's to decide or do: the PoC on v0.14.7 (it runs a
v0.14.5 dev build; no hurry); where Alertmanager sends (its receiver is
"none"). Settled 2026-10-16: Apertus 8B is on demand (min 0); the HA test
+15
View File
@@ -28,6 +28,14 @@ APIs change between minors without a deprecation period.
### Added
- **Large models across cards and nodes.** For a model of experts on more than one card, the plan now adds vLLM's
`--enable-expert-parallel`. It reads the expert count from `config.json` under any of its names, and sizes a model
that also reads pictures by its `text_config`. Several nodes are offered in any number, not only powers of two. Each
way now says what it needs of a card, part by part (weights, context, engine), and of all its cards together. This
shows in `ctl model plan`, in the console's Serve a model, and in a serve job's first step. The serve form checks that
a longer context still fits. `ctl model serve --env KEY=VALUE` puts engine variables such as `NCCL_DEBUG=INFO` on
every node. docs/large-models.md has the rules, the commands, and a checklist for bringing a model up on a fabric.
- **Machines of the judges' own in a three-machine install.** The setup screen asks for up to two judge machines after
the store's two; `server init` takes `--judge-node user@host` for each. Each is added as a node labelled
`llmgw.role: judge`, and the judge's profile selects it, so the judges run there alone. Under load on a 4-core
@@ -37,6 +45,13 @@ APIs change between minors without a deprecation period.
### Fixed
- **The plan refused tensor parallelism past a model's KV heads.** Qwen3-235B, with four KV heads, had no way to run
on eight cards, though vLLM gives each card a copy of a head. A width that is a multiple of the KV heads is now
offered, as is one that divides them.
- **A big model on a slow mirror was failed while it downloaded.** The startup timeout counted the download, so
hundreds of gigabytes at a few hundred MB/s ran past twenty minutes and the replica was started again from nothing.
The timeout now runs from when a node's download last moved.
- **The judge init installs is no longer replaced by the leader's own.** Just after the control plane started, the
leader saw a policy naming a judge that init was still fetching, and served one itself. Its entry replaced init's, so
a second judge ran with the leader's sizing, and with judge machines it ran off them. The leader now waits ten minutes
+11
View File
@@ -10639,6 +10639,10 @@
"format": "int64",
"description": "KVBytesPerToken is one token of context, the whole model: what an option needs for another context is worked out from it. 0 is unknown."
},
"experts": {
"type": "integer",
"description": "Experts is a mixture-of-experts model's routed experts; 0 for dense."
},
"gated": {
"type": "boolean"
},
@@ -10789,6 +10793,13 @@
"type": "integer",
"description": "CPUCores is what a replica computes on; zero takes half the smallest node it fits on."
},
"env": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"description": "Env is set in the engine's environment, on every node of a replica: NCCL_DEBUG=INFO to see how its cards talk, NCCL_IB_* to steer them. Only the variables engines are tuned with; the catalog refuses others."
},
"memory_gb": {
"type": "integer",
"description": "MemoryGB is what a replica on CPUs reserves, in place of the figure worked out from its weights and context: a judge's, whose own sizing is known."
+2
View File
@@ -3219,6 +3219,8 @@ requests. On a slow model, set `timeouts.generation` higher.
- `docs/choosing-models.md` — which models to run and how many, if the answer
to "we have GPUs, now what" is not already obvious
- `docs/large-models.md` — a model on several cards or several nodes: how it
is split, what each way needs of a card, and bringing one up on a fabric
- `docs/data.md` — what is stored and what leaves the machine, for the review
that happens before this is allowed near anyone's documents
- `docs/reference.md` — every configuration key, generated from the source, so
+145
View File
@@ -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.
+2
View File
@@ -69,6 +69,8 @@ func newModelServeCmd(client func() *ctlClient) *cobra.Command {
f.IntVar(&req.Nodes, "nodes", 0, "with --gpu-class: the nodes one replica spans, over a fabric (vLLM only)")
f.IntVar(&req.TP, "tp", 0, "the older name of --gpus")
_ = f.MarkHidden("tp")
f.StringToStringVar(&req.Env, "env", nil,
"KEY=VALUE in the engine's environment on every node, e.g. NCCL_DEBUG=INFO; repeatable")
f.BoolVar(&req.Fallbacks, "fallbacks", false, "also add the next best ways to run it, used when the first is busy")
f.BoolVar(&detach, "detach", false, "start it and return: it runs on the control plane; `llmgw ctl jobs` follows it")
f.StringVar(&req.Name, "name", "", "the catalog name (default: from the repository)")
+7 -1
View File
@@ -163,6 +163,10 @@ type Request struct {
// CPUCores is what a replica computes on; zero takes half the smallest
// node it fits on.
CPUCores int `json:"cpu_cores,omitempty"`
// Env is set in the engine's environment, on every node of a replica:
// NCCL_DEBUG=INFO to see how its cards talk, NCCL_IB_* to steer them.
// Only the variables engines are tuned with; the catalog refuses others.
Env map[string]string `json:"env,omitempty"`
// IfAbsent adds the model only if nobody has meanwhile: the leader's own
// serving of a judge a policy names gives way to the one init or an
// operator added while it fetched, rather than replacing it.
@@ -580,6 +584,8 @@ func chooseLayout(req Request, plan Plan, planned bool, set hf.FileSet) ([]model
o = p
}
}
// A way the plan did not offer is still a model of experts.
o.ExpertParallel = engine == "vllm" && plan.Experts > 1 && o.GPUs() > 1
out = append(out, o)
case len(opts) > 0:
out = append(out, opts[0])
@@ -614,7 +620,7 @@ func chooseLayout(req Request, plan Plan, planned bool, set hf.FileSet) ([]model
func profilesFor(layout []modelplan.Option, req Request, memGB, cores int) []catalog.Profile {
var out []catalog.Profile
for i, o := range layout {
p := catalog.Profile{Engine: o.Engine, Weight: 1, Priority: 100 - 10*i}
p := catalog.Profile{Engine: o.Engine, Weight: 1, Priority: 100 - 10*i, Env: req.Env}
switch {
case o.CPU:
p.Name = "cpu"
@@ -475,3 +475,26 @@ func TestAJobAddingOnlyIfAbsentGivesWayToAModelAddedMeanwhile(t *testing.T) {
t.Errorf("job %s, added %d: %+v", j.State, len(taken.added), j.Steps)
}
}
// A model of experts given its cards by hand spreads them still, and the
// environment asked for reaches every profile.
func TestAHandGivenLayoutOfExpertsKeepsExpertParallelAndTheEnvironment(t *testing.T) {
plan := Plan{Repo: "Qwen/Qwen3-235B-A22B", Experts: 128}
req := Request{Format: "safetensors", GPUClass: "h100-80g", GPUsPerNode: 8, Nodes: 2, ContextLength: 8192,
Env: map[string]string{"NCCL_DEBUG": "INFO"}}
layout, err := chooseLayout(req, plan, true, hf.FileSet{Name: "original"})
if err != nil {
t.Fatal(err)
}
profs := profilesFor(layout, req, 0, 0)
p := profs[0]
if p.TP != 8 || p.PP != 2 || p.Nodes != 2 || !slices.Contains(p.Args, "--enable-expert-parallel") || p.Env["NCCL_DEBUG"] != "INFO" {
t.Errorf("profile %+v", p)
}
// A dense model given the same cards does not.
plan.Experts = 0
layout, _ = chooseLayout(req, plan, true, hf.FileSet{Name: "original"})
if slices.Contains(profilesFor(layout, req, 0, 0)[0].Args, "--enable-expert-parallel") {
t.Error("a dense model was given expert parallelism")
}
}
+4 -2
View File
@@ -45,7 +45,9 @@ type Plan struct {
// KVBytesPerToken is one token of context, the whole model: what an
// option needs for another context is worked out from it. 0 is unknown.
KVBytesPerToken int64 `json:"kv_bytes_per_token,omitempty"`
Gated bool `json:"gated,omitempty"`
// Experts is a mixture-of-experts model's routed experts; 0 for dense.
Experts int `json:"experts,omitempty"`
Gated bool `json:"gated,omitempty"`
// Servable is false when the nodes' engine cannot run the model at all,
// and Reason says why.
Servable bool `json:"servable"`
@@ -161,7 +163,7 @@ func (s *Server) plan(ctx context.Context, repo hf.Repo, req PlanRequest) (Plan,
p.Note = "the model's shape was not readable, so the context's memory is not counted"
}
shape.Params = p.Params
p.KVBytesPerToken = shape.KVBytesPerToken
p.KVBytesPerToken, p.Experts = shape.KVBytesPerToken, shape.Experts
p.Options = modelplan.Options(arts, shape, s.deps.Pool(), p.Context)
if !p.Servable {
p.Options = nil
+8 -1
View File
@@ -582,7 +582,11 @@ func (r *Reconciler) expireStuckReplicas() {
if rep.State != Starting && rep.State != Placing {
continue
}
if now.Sub(rep.CreatedAt) < r.startupTimeout(rep) {
since := rep.CreatedAt
if rep.fetchedAt.After(since) {
since = rep.fetchedAt
}
if now.Sub(since) < r.startupTimeout(rep) {
continue
}
rep.State = Failed
@@ -1328,6 +1332,9 @@ func (r *Reconciler) OnFetchProgress(replicaID, shardID, source string, done, to
if rep.Shards[i].ID != shardID {
continue
}
if prev := rep.Shards[i].Fetch; prev == nil || done > prev.BytesDone {
rep.fetchedAt = r.now()
}
rep.Shards[i].Fetch = &FetchProgress{
Source: source, BytesDone: done, BytesTotal: total,
}
+5
View File
@@ -160,6 +160,11 @@ type Replica struct {
AdoptedAt time.Time
CreatedAt time.Time
// fetchedAt is when a shard's download last moved: the startup timeout
// runs from then, so a model of hundreds of gigabytes on a slow mirror
// is not failed while it is still arriving, and one whose download
// stalls is failed a timeout after it stopped.
fetchedAt time.Time
ReadyAt time.Time
// LastUsedAt is when a request was last routed to this replica, zero if
// none has been since the router started.
+31
View File
@@ -406,3 +406,34 @@ func TestAMultiNodeReplicaKnowsHowManyShardsItWants(t *testing.T) {
t.Errorf("WantShards = %d, want 2", reps[0].WantShards)
}
}
// A model of hundreds of gigabytes on a slow mirror takes longer to arrive
// than to load: the startup timeout runs from when its download last moved,
// and a download that stops is failed a timeout after it stopped.
func TestAReplicaStillDownloadingIsNotFailed(t *testing.T) {
m := testModel(1, catalog.Profile{
Name: "tp4", GPUClass: "h100-80g", TP: 4, StartupTimeout: 20 * time.Minute,
})
h := newHarness(t, m, gpuNode("1", 8))
h.r.Reconcile(context.Background())
rep := h.r.Replicas()[0]
shard := rep.Shards[0].ID
// Forty minutes of download, moving all the while.
for i := int64(1); i <= 8; i++ {
h.advance(5 * time.Minute)
h.r.OnFetchProgress(rep.ID, shard, "mirror", i<<35, 400<<30)
h.r.expireStuckReplicas()
}
if got := h.r.Replicas()[0].State; got == Failed {
t.Fatal("a replica whose download kept moving was failed")
}
// The same figure again is not progress: stalled, it fails a timeout on.
h.advance(10 * time.Minute)
h.r.OnFetchProgress(rep.ID, shard, "mirror", 8<<35, 400<<30)
h.advance(11 * time.Minute)
h.r.expireStuckReplicas()
if got := h.r.Replicas()[0].State; got != Failed {
t.Errorf("a stalled download was never failed: %s", got)
}
}