Files
llmgw/internal/control/placement/cpu_test.go
T
Ilker ManapandClaude Opus 5.5 990d82d801
Release / build (push) Successful in 1m5s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m47s
Reserve CPU cores and require memory for CPU models
A profile's cpu_cores is reserved out of its node's CPUs by placement, as
memory is, and becomes the engine's thread count. Agents report their CPU
count in registration and heartbeats. New CPU models must declare memory;
model views show reserved against measured memory with warnings, and
ctl model resources sets both for a stored model.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 17:43:13 +02:00

139 lines
4.4 KiB
Go

package placement
import (
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
)
// cpuProfile is what the catalog produces for a llama.cpp model: no GPU class,
// because there is no card to ask for.
func cpuProfile(name string) Profile {
return Profile{Name: name, Engine: "llamacpp"}
}
func TestCPUProfilePlacesOnANodeWithNoGPUs(t *testing.T) {
// The whole point: a machine with no cards is useless to every other engine
// and perfectly fine for this one.
s := New(store(node("n1", "", 0)))
res := s.Place([]Profile{cpuProfile("cpu")}, nil)
if res.Placement == nil {
t.Fatalf("no placement:\n%s", res.Explain())
}
if len(res.Placement.Nodes) != 1 || res.Placement.Nodes[0].NodeID != "n1" {
t.Fatalf("placed on %+v", res.Placement.Nodes)
}
if len(res.Placement.Nodes[0].GPUIndices) != 0 {
t.Errorf("a CPU replica was given GPUs: %v", res.Placement.Nodes[0].GPUIndices)
}
if res.Placement.Pool != "none/cpu" {
t.Errorf("pool %q", res.Placement.Pool)
}
}
func TestCPUReplicasSpreadRatherThanStack(t *testing.T) {
// There is no allocation to stop two CPU replicas sharing a machine's
// cores, so placement has to do it. Bin packing, which is right for GPUs,
// would put every replica on the same node.
st := store(node("n1", "", 0), node("n2", "", 0), node("n3", "", 0))
s := New(st)
seen := map[string]int{}
for i := range 3 {
res := s.Place([]Profile{cpuProfile("cpu")}, nil)
if res.Placement == nil {
t.Fatalf("iteration %d:\n%s", i, res.Explain())
}
id := res.Placement.Nodes[0].NodeID
seen[id]++
// Record it the way the reconciler would, so the next decision sees it.
st.Allocate(id, inventory.Allocation{ShardID: id + "-sh", ReplicaID: id + "-rep"})
}
if len(seen) != 3 {
t.Errorf("three replicas landed on %d nodes: %v", len(seen), seen)
}
}
func TestCPUProfileIgnoresGPUsItDoesNotNeed(t *testing.T) {
// A GPU node is still a valid host, and taking it must not consume cards
// that a GPU profile would need.
st := store(node("gpu", "h100-80g", 8))
s := New(st)
res := s.Place([]Profile{cpuProfile("cpu")}, nil)
if res.Placement == nil {
t.Fatalf("no placement:\n%s", res.Explain())
}
if n := len(res.Placement.Nodes[0].GPUIndices); n != 0 {
t.Errorf("a CPU replica claimed %d GPUs", n)
}
if free := st.FreeGPUs("gpu"); len(free) != 8 {
t.Errorf("GPUs were consumed: %d free", len(free))
}
}
func TestGPUProfileStillNeedsItsClass(t *testing.T) {
// The CPU path must not become a way for a GPU profile to be placed on a
// machine that cannot run it.
s := New(store(node("n1", "", 0)))
res := s.Place([]Profile{{Name: "tp1", GPUClass: "h100-80g", GPUsPerNode: 1}}, nil)
if res.Placement != nil {
t.Fatal("a GPU profile was placed on a node with no GPUs")
}
if len(res.Rejections) == 0 {
t.Error("the rejection should say why")
}
}
func TestCPUProfileRespectsAvoid(t *testing.T) {
// Spreading replicas of one model across failure domains matters the same
// way it does for GPU replicas.
s := New(store(node("n1", "", 0), node("n2", "", 0)))
res := s.Place([]Profile{cpuProfile("cpu")}, []string{"n1"})
if res.Placement == nil {
t.Fatalf("no placement:\n%s", res.Explain())
}
if got := res.Placement.Nodes[0].NodeID; got != "n2" {
t.Errorf("placed on %q despite being told to avoid it", got)
}
}
func TestCPUCoresAreReservedAsMemoryIs(t *testing.T) {
n := node("n1", "", 0)
n.CPUCount = 8
st := store(n)
s := New(st)
p := cpuProfile("cpu")
p.CPUCores = 3
for i := range 2 {
res := s.Place([]Profile{p}, nil)
if res.Placement == nil {
t.Fatalf("replica %d did not fit in 8 cores:\n%s", i+1, res.Explain())
}
st.Allocate("n1", inventory.Allocation{ShardID: string(rune('a' + i)), CPUCores: 3})
}
// Six of eight given away: a third would need three.
res := s.Place([]Profile{p}, nil)
if res.Placement != nil {
t.Fatal("a third replica was given cores the node does not have")
}
if got := res.Explain(); !strings.Contains(got, "has 8 CPUs with 2 free, needs 3") {
t.Errorf("the refusal should say how many cores are left:\n%s", got)
}
// Unknown counts constrain nothing: an older agent, or a profile that
// reserves no cores.
old := node("n2", "", 0)
if res := New(store(old)).Place([]Profile{p}, nil); res.Placement == nil {
t.Error("a node that does not report its CPUs was excluded")
}
if res := s.Place([]Profile{cpuProfile("cpu")}, nil); res.Placement == nil {
t.Error("a profile reserving no cores was refused")
}
}