CI / test (push) Successful in 1m28s
Two things a cluster with many nodes and many GPUs needs that were missing, and they are the same thing seen from two distances. Idle tracking. The control plane knew what was placed and whether it was healthy, never whether anyone wanted it — so on a fleet with more models than cards there was no way to answer the first question anyone asks, which is what can I stop. The router is the only thing that knows, so it now reports it: a timestamp written in Acquire, where a request already meets an endpoint under a lock already held, and collected once a tick as a map. That shape is the point. Per-request notification would be a firehose carrying one bit — the newest timestamp wins — and this costs the same on four replicas as on four hundred. It travels through an interface in the direction opposite EndpointSink, for the same reason that one exists: the control plane decides what should run, the router knows whether anyone wants it, and neither should need to be in the other's process to say so. Unknown is kept distinct from idle throughout. A router that has just restarted knows nothing, and a zero timestamp would be the oldest idle replica in every comparison — so one restart would nominate an entire fleet for eviction. Absent from the map, "-" in the table, negative in the JSON, and IdleFor returns a second value rather than a duration anything could act on by mistake. ctl status. A per-node listing is right for four nodes and wrong for two hundred: how full am I, what is broken, what is holding cards nobody is using are aggregates, and reading them off a long table is a chore, which means it gets skipped. Capacity per GPU class with usage bars, free whole nodes per fabric — the number that decides whether a multi-node replica can land, since free cards on the wrong fabric are not capacity for a model that spans nodes — replica and model counts, models that cannot be placed, and what has gone unasked-for. Derived from the same state the tables show, so it cannot disagree with them. Unhealthy GPUs count as neither used nor free, because counting them free is how a cluster looks like it has room it does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
190 lines
5.7 KiB
Go
190 lines
5.7 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"`
|
|
// 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++
|
|
// 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++
|
|
}
|
|
}
|
|
}
|
|
for _, f := range byFabric {
|
|
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
|
|
}
|