Files
Ilker ManapandClaude Opus 5 501db3d3e8 Add the llama.cpp engine, and let a profile ask for no GPU
llama.cpp is what makes a cluster with no cards useful: it serves the same
OpenAI API on CPU, so the whole path — placement, supervision, scraping,
scheduling, admission — can be exercised end to end on ordinary machines.

Three things had to give way for it.

A profile may now omit its GPU class, and that is how it says it runs on the
CPU. Only engines that can actually serve without a card may omit it; for
vLLM an omitted class stays an error, because otherwise a typo becomes a
replica placed on a machine with no hardware and diagnosed minutes later
during model loading. Placement spreads CPU replicas instead of bin packing
them, since unlike GPUs there is no allocation to stop two of them sharing a
machine's cores.

Metric mappings are now per engine rather than per cluster. llama.cpp calls
the same three numbers requests_processing, requests_deferred and
kv_cache_usage_ratio, and a single global mapping would leave half a mixed
fleet unmeasurable. An empty mapping still defaults to vLLM's names; one that
names its own Running metric is taken as a complete statement, so the fields
it leaves blank stay blank rather than being filled with names that engine
cannot have.

The adapter refuses multi-node and tensor-parallel profiles. llama.cpp splits
across local GPUs by a different mechanism than this adapter drives, and
accepting either would produce a replica that starts and then serves from one
machine while the catalog believes it has several.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:14:05 +02:00

103 lines
3.3 KiB
Go

package placement
import (
"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)
}
}