CI / test (push) Successful in 1m32s
Storage will be different at every installation — Lustre, NFS over RDMA, an image exported with SRP or NVMe-oF, local NVMe, an rsync — so llmgw observes it rather than managing it. An agent told which directories to look in reports what it can see; how the files got there is nobody's business here, and a node told nothing reports nothing and behaves exactly as every node did before this existed. That fact buys two things. Placement prefers a node that already holds the model over one that must first move tens of gigabytes, as a preference and never a filter: fetching them is a valid answer, and refusing to place would turn a slow start into no start at all. And ctl status shows what each fabric holds. Per fabric because fabrics are not connected to one another. Weights in ib-a are not weights in ib-b — the only path between them is the management network — so a model seeded into one fabric leaves the rest of the cluster unable to serve it without a slow copy, and nothing said so before now. One shared filer counts the same as twenty local copies, because "can this fabric serve it" is the question being asked. Three distinctions the tests hold to, because each is a way to get this quietly wrong. A node that was never asked to look must rank with one that looked and has nothing, or enabling the feature on a single node would drain every other. An empty directory is not weights, because a half-finished download looks exactly like one and would send placement to a node whose engine then fails to load. And local storage is never mistaken for shared: a filer read as local costs an unnecessary copy, while local read as shared would have the cluster believe a model is everywhere when it is on one node. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
218 lines
6.8 KiB
Go
218 lines
6.8 KiB
Go
package adminapi
|
|
|
|
import (
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// Summary is the whole fleet in one answer.
|
|
//
|
|
// A per-node listing is the right tool for four nodes and the wrong one for two
|
|
// hundred: the questions that matter at that size — how full am I, what is
|
|
// broken, what is holding cards it is not using — are aggregates, and reading
|
|
// them off a long table is a chore that gets skipped. Everything here is
|
|
// derived from the same state the tables show, so it can never disagree with
|
|
// them; it is a different question asked of the same facts.
|
|
type Summary struct {
|
|
Nodes NodeTotals `json:"nodes"`
|
|
GPUs []ClassUse `json:"gpus"`
|
|
// Fabrics matter because a multi-node replica may never span two of them,
|
|
// so free capacity in the wrong fabric is not capacity for the model that
|
|
// needs it.
|
|
Fabrics []FabricUse `json:"fabrics,omitempty"`
|
|
Replicas ReplicaTotal `json:"replicas"`
|
|
Models ModelTotals `json:"models"`
|
|
// Unplaceable names models the scheduler currently cannot satisfy. This is
|
|
// the line that turns "something is wrong" into a place to look.
|
|
Unplaceable []string `json:"unplaceable,omitempty"`
|
|
// IdleReplicas are serving replicas nothing has asked for in a while, which
|
|
// on a fleet with more models than GPUs is the first place to find room.
|
|
IdleReplicas []IdleReplica `json:"idle_replicas,omitempty"`
|
|
}
|
|
|
|
// NodeTotals counts nodes by the states that need different responses.
|
|
type NodeTotals struct {
|
|
Total int `json:"total"`
|
|
Ready int `json:"ready"`
|
|
Cordoned int `json:"cordoned"`
|
|
Draining int `json:"draining"`
|
|
Unreachable int `json:"unreachable"`
|
|
}
|
|
|
|
// ClassUse is how much of one kind of card is in use.
|
|
type ClassUse struct {
|
|
Class string `json:"class"`
|
|
Total int `json:"total"`
|
|
Used int `json:"used"`
|
|
// Unhealthy GPUs are neither used nor available, and counting them as free
|
|
// is how a cluster looks like it has room it does not have.
|
|
Unhealthy int `json:"unhealthy"`
|
|
}
|
|
|
|
// Free is what can still be allocated.
|
|
func (c ClassUse) Free() int { return c.Total - c.Used - c.Unhealthy }
|
|
|
|
// FabricUse is capacity within one fabric.
|
|
type FabricUse struct {
|
|
Fabric string `json:"fabric"`
|
|
Nodes int `json:"nodes"`
|
|
// ModelsPresent is how many nodes here hold weights at all, and Models how
|
|
// many distinct models they hold between them.
|
|
//
|
|
// Per fabric because that is the unit at which the answer is actionable.
|
|
// Fabrics are not connected to one another, so weights in one are not
|
|
// weights in another: a model seeded into a single fabric leaves the rest
|
|
// of the cluster unable to serve it without first moving tens of gigabytes
|
|
// over the management network, and nothing said so.
|
|
NodesWithWeights int `json:"nodes_with_weights"`
|
|
Models int `json:"models_present"`
|
|
// LargestFreeBlock is the most nodes on this fabric that are entirely free,
|
|
// which is the number that decides whether a multi-node replica can land.
|
|
LargestFreeBlock int `json:"largest_free_block"`
|
|
}
|
|
|
|
// ReplicaTotal counts replicas by state.
|
|
type ReplicaTotal struct {
|
|
Ready int `json:"ready"`
|
|
Starting int `json:"starting"`
|
|
Draining int `json:"draining"`
|
|
Failed int `json:"failed"`
|
|
}
|
|
|
|
// ModelTotals counts models by whether they have what they asked for.
|
|
type ModelTotals struct {
|
|
Total int `json:"total"`
|
|
Satisfied int `json:"satisfied"`
|
|
Short int `json:"short"`
|
|
}
|
|
|
|
// IdleReplica is a replica holding hardware nothing is asking for.
|
|
type IdleReplica struct {
|
|
ID string `json:"id"`
|
|
Model string `json:"model"`
|
|
IdleSec int64 `json:"idle_seconds"`
|
|
GPUs int `json:"gpus"`
|
|
}
|
|
|
|
// idleThreshold is how long a replica must go unasked-for to be worth naming.
|
|
//
|
|
// Minutes rather than seconds, because a replica that flaps in and out of this
|
|
// list is noise, and because unloading one throws away its KV cache — the thing
|
|
// prefix routing spent its effort filling.
|
|
const idleThreshold = 10 * time.Minute
|
|
|
|
// Summary builds the fleet summary.
|
|
func (a *API) Summary() Summary {
|
|
var s Summary
|
|
|
|
nodes := a.Nodes()
|
|
byClass := map[string]*ClassUse{}
|
|
s.Nodes.Total = len(nodes)
|
|
for _, n := range nodes {
|
|
switch n.State {
|
|
case "ready":
|
|
s.Nodes.Ready++
|
|
case "cordoned":
|
|
s.Nodes.Cordoned++
|
|
case "draining":
|
|
s.Nodes.Draining++
|
|
default:
|
|
s.Nodes.Unreachable++
|
|
}
|
|
for _, c := range n.Classes {
|
|
if byClass[c] == nil {
|
|
byClass[c] = &ClassUse{Class: c}
|
|
}
|
|
}
|
|
// A node reports one class in practice; attribute its cards to it.
|
|
if len(n.Classes) == 1 {
|
|
u := byClass[n.Classes[0]]
|
|
u.Total += n.GPUTotal
|
|
u.Unhealthy += len(n.Unhealthy)
|
|
u.Used += n.GPUTotal - n.GPUFree - len(n.Unhealthy)
|
|
}
|
|
}
|
|
for _, u := range byClass {
|
|
s.GPUs = append(s.GPUs, *u)
|
|
}
|
|
sort.Slice(s.GPUs, func(i, j int) bool { return s.GPUs[i].Class < s.GPUs[j].Class })
|
|
|
|
byFabric := map[string]*FabricUse{}
|
|
for _, n := range nodes {
|
|
for _, f := range n.Fabrics {
|
|
if byFabric[f] == nil {
|
|
byFabric[f] = &FabricUse{Fabric: f}
|
|
}
|
|
byFabric[f].Nodes++
|
|
if len(n.Models) > 0 {
|
|
byFabric[f].NodesWithWeights++
|
|
}
|
|
// Entirely free, which is what a multi-node replica needs: a node
|
|
// with one card in use cannot contribute a full-width shard.
|
|
if n.State == "ready" && n.GPUFree == n.GPUTotal && n.GPUTotal > 0 {
|
|
byFabric[f].LargestFreeBlock++
|
|
}
|
|
}
|
|
}
|
|
// Distinct models per fabric, counted across its nodes: one copy on a
|
|
// shared filer and twenty local copies are the same answer to "can this
|
|
// fabric serve it".
|
|
perFabricModels := map[string]map[string]struct{}{}
|
|
for _, n := range nodes {
|
|
for _, f := range n.Fabrics {
|
|
if perFabricModels[f] == nil {
|
|
perFabricModels[f] = map[string]struct{}{}
|
|
}
|
|
for _, m := range n.Models {
|
|
perFabricModels[f][m] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
for name, f := range byFabric {
|
|
f.Models = len(perFabricModels[name])
|
|
s.Fabrics = append(s.Fabrics, *f)
|
|
}
|
|
sort.Slice(s.Fabrics, func(i, j int) bool { return s.Fabrics[i].Fabric < s.Fabrics[j].Fabric })
|
|
|
|
for _, r := range a.Replicas("") {
|
|
switch r.State {
|
|
case "ready":
|
|
s.Replicas.Ready++
|
|
case "draining":
|
|
s.Replicas.Draining++
|
|
case "failed":
|
|
s.Replicas.Failed++
|
|
default:
|
|
s.Replicas.Starting++
|
|
}
|
|
if r.State == "ready" && r.IdleSec >= int64(idleThreshold.Seconds()) {
|
|
gpus := 0
|
|
for _, sh := range r.Shards {
|
|
gpus += len(sh.GPUs)
|
|
}
|
|
s.IdleReplicas = append(s.IdleReplicas, IdleReplica{
|
|
ID: r.ID, Model: r.Model, IdleSec: r.IdleSec, GPUs: gpus,
|
|
})
|
|
}
|
|
}
|
|
sort.Slice(s.IdleReplicas, func(i, j int) bool {
|
|
return s.IdleReplicas[i].IdleSec > s.IdleReplicas[j].IdleSec
|
|
})
|
|
|
|
for _, m := range a.Models() {
|
|
s.Models.Total++
|
|
if m.Ready >= m.Min {
|
|
s.Models.Satisfied++
|
|
continue
|
|
}
|
|
s.Models.Short++
|
|
// Short of replicas and unable to place more is the actionable case;
|
|
// short and still starting is just a cluster doing its job.
|
|
if m.Total <= m.Ready {
|
|
s.Unplaceable = append(s.Unplaceable, m.Name)
|
|
}
|
|
}
|
|
sort.Strings(s.Unplaceable)
|
|
return s
|
|
}
|