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>
112 lines
3.3 KiB
Go
112 lines
3.3 KiB
Go
package adminapi
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
|
|
)
|
|
|
|
func TestSummaryCountsCapacityByClass(t *testing.T) {
|
|
// The question a per-node table answers badly at two hundred nodes: how
|
|
// full am I.
|
|
a, _ := setup(t, testModels(), gpuNode("n1", 8), gpuNode("n2", 8))
|
|
s := a.Summary()
|
|
|
|
if s.Nodes.Total != 2 || s.Nodes.Ready != 2 {
|
|
t.Errorf("nodes = %+v", s.Nodes)
|
|
}
|
|
if len(s.GPUs) != 1 {
|
|
t.Fatalf("expected one GPU class, got %+v", s.GPUs)
|
|
}
|
|
if g := s.GPUs[0]; g.Total != 16 || g.Free() != 16 {
|
|
t.Errorf("class totals wrong: %+v", g)
|
|
}
|
|
}
|
|
|
|
func TestUnhealthyGPUsAreNotCountedAsFree(t *testing.T) {
|
|
// Counting them free is how a cluster looks like it has room it does not,
|
|
// which is worse than showing less capacity than exists.
|
|
n := gpuNode("n1", 4)
|
|
n.GPUs[0].Health.Problems = []string{"3 uncorrected ECC errors"}
|
|
a, _ := setup(t, testModels(), n)
|
|
|
|
g := a.Summary().GPUs[0]
|
|
if g.Unhealthy != 1 {
|
|
t.Errorf("unhealthy = %d", g.Unhealthy)
|
|
}
|
|
if g.Free() != 3 {
|
|
t.Errorf("free = %d; a faulty card was counted as available", g.Free())
|
|
}
|
|
}
|
|
|
|
func TestFabricFreeBlocksCountWholeNodesOnly(t *testing.T) {
|
|
// A multi-node replica may never span fabrics, and a node with one card in
|
|
// use cannot contribute a full-width shard. Free cards scattered across
|
|
// partly-used nodes are not capacity for the model that needs them.
|
|
free := gpuNode("free", 8, "ib-a")
|
|
partial := gpuNode("partial", 8, "ib-a")
|
|
a, ctl := setup(t, testModels(), free, partial)
|
|
// Take one GPU on the second node.
|
|
ctl.Inventory().Allocate("partial", inventory.Allocation{
|
|
ShardID: "s", ReplicaID: "r", GPUIndices: []int{0},
|
|
})
|
|
|
|
s := a.Summary()
|
|
if len(s.Fabrics) != 1 {
|
|
t.Fatalf("fabrics = %+v", s.Fabrics)
|
|
}
|
|
if got := s.Fabrics[0].LargestFreeBlock; got != 1 {
|
|
t.Errorf("fully free nodes = %d, want 1: a partly used node is not one", got)
|
|
}
|
|
}
|
|
|
|
func TestANodeWithNoGPUsDoesNotInventAClass(t *testing.T) {
|
|
// The CPU-only node this fleet acquired. It has no class, and a summary
|
|
// that invents one for it would report capacity nobody has.
|
|
a, _ := setup(t, testModels(), inventory.Node{ID: "cpu1", State: inventory.Ready})
|
|
s := a.Summary()
|
|
for _, g := range s.GPUs {
|
|
if g.Class == "" {
|
|
t.Errorf("an empty class was reported: %+v", g)
|
|
}
|
|
}
|
|
if s.Nodes.Total != 1 {
|
|
t.Errorf("the node was not counted: %+v", s.Nodes)
|
|
}
|
|
}
|
|
|
|
func TestIdleReplicasNeedRealIdleness(t *testing.T) {
|
|
// A replica the router has said nothing about carries -1, and must not be
|
|
// read as idle since the epoch — otherwise a router restart nominates the
|
|
// whole fleet for eviction at once.
|
|
for _, tc := range []struct {
|
|
name string
|
|
idle int64
|
|
want bool
|
|
}{
|
|
{"never reported", -1, false},
|
|
{"just used", 5, false},
|
|
{"idle for an hour", int64(time.Hour.Seconds()), true},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := tc.idle >= int64(idleThreshold.Seconds())
|
|
if got != tc.want {
|
|
t.Errorf("idle=%d counted as idle=%v, want %v", tc.idle, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSummaryNamesModelsThatCannotBePlaced(t *testing.T) {
|
|
// The line that turns "something is wrong" into a place to look.
|
|
a, _ := setup(t, testModels()) // no nodes at all
|
|
s := a.Summary()
|
|
if len(s.Unplaceable) == 0 {
|
|
t.Fatal("a model with no hardware to run on was not named")
|
|
}
|
|
if s.Models.Short == 0 {
|
|
t.Error("no model was counted short")
|
|
}
|
|
}
|