internal/autogen/gpuset_test.go
Rama main · réplica de lectura del repositorio original.
package autogen
import (
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/quartermaster-labs/quartermaster/internal/perf"
)
// gpuSetFromStats is the eligibility rule the whole multi-GPU path is built on:
// newest sample per device id, anything under the inference floor dropped,
// Index order preserved.
func TestAutogen_gpuSetFromStats(t *testing.T) {
now := time.Now()
set := gpuSetFromStats([]perf.GpuStat{
{ID: 1, Name: "4070 Ti SUPER", MemTotalMB: 16384, MemUsedMB: 8192, Timestamp: now.Add(-time.Minute)},
{ID: 1, Name: "4070 Ti SUPER", MemTotalMB: 16384, MemUsedMB: 4096, Timestamp: now},
{ID: 0, Name: "3060", MemTotalMB: 12288, MemUsedMB: 1024, Timestamp: now},
{ID: 2, Name: "iGPU", MemTotalMB: 2048, MemUsedMB: 0, Timestamp: now},
{ID: 3, Name: "no telemetry", MemTotalMB: 0, MemUsedMB: 0, Timestamp: now},
}, GpuPolicy{})
if len(set) != 2 {
t.Fatalf("got %d devices %+v, want the two real cards", len(set), set)
}
if set[0].Index != 0 || set[1].Index != 1 {
t.Fatalf("devices out of Index order: %+v", set)
}
// The newest ID 1 sample wins: 16 GB total, 4 GB used -> 12 GB free.
if got := set[1].FreeGB; got != 12 {
t.Fatalf("ID 1 free = %.2f, want 12 (from the newest sample)", got)
}
if got := set.TotalGB(); got != 28 {
t.Fatalf("pooled total = %.2f, want 28", got)
}
if got := set.FreeGB(); got != 23 {
t.Fatalf("pooled free = %.2f, want 23", got)
}
if !set.Multi() {
t.Fatal("two cards did not report Multi")
}
// MainIndex follows FREE memory, not total: the split's fixed costs land on
// whichever card can actually absorb them.
if got := set.MainIndex(); got != 1 {
t.Fatalf("MainIndex = %d, want 1 (12 GB free vs 11)", got)
}
// A used reading above total (a driver hiccup) clamps to 0 free rather than
// going negative and poisoning the pooled sum.
one := gpuSetFromStats([]perf.GpuStat{
{ID: 0, MemTotalMB: 8192, MemUsedMB: 9000, Timestamp: now},
}, GpuPolicy{})
if len(one) != 1 || one[0].FreeGB != 0 {
t.Fatalf("over-used card = %+v, want free clamped to 0", one)
}
if one.Multi() || one.MainIndex() != 0 {
t.Fatalf("single card reported Multi=%v main=%d", one.Multi(), one.MainIndex())
}
if set := gpuSetFromStats(nil, GpuPolicy{}); len(set) != 0 {
t.Fatalf("empty stats produced %+v", set)
}
}
// TensorSplit charges the whole plan's fixed cost to the main device and each
// other device its own runtime context, then normalises the remainder. Under
// that ratio every per-device constraint binds at once, which is what makes the
// POOLED budget safe to size against.
func TestAutogen_TensorSplit(t *testing.T) {
// Non-CUDA on purpose: perDeviceFixedGB is computeVulkanCtxGB (0.4), the case
// that used to be charged 0 and hand a Vulkan/ROCm box a free extra device.
setCudaGPU(t, false)
set := GpuSet{
{Index: 0, TotalGB: 12, FreeGB: 11},
{Index: 1, TotalGB: 16, FreeGB: 15},
}
// 2 GB of fixed cost on the main device (ID 1, most free) and 0.4 on the
// other: 10.6 and 13 of 23.6.
split := set.TensorSplit(2)
if len(split) != 2 || split[0] != 0.45 || split[1] != 0.55 {
t.Fatalf("TensorSplit = %v, want [0.45 0.55]", split)
}
if got := FormatSplit(split); got != "0.45,0.55" {
t.Fatalf("FormatSplit = %q", got)
}
// A device with no room left after its fixed cost gets nothing, and the
// whole model goes to the card that can hold it.
tight := GpuSet{
{Index: 0, TotalGB: 12, FreeGB: 0.5},
{Index: 1, TotalGB: 16, FreeGB: 10},
}
if split := tight.TensorSplit(10); len(split) != 2 || split[0] != 1 || split[1] != 0 {
t.Fatalf("TensorSplit = %v, want everything on the card with room", split)
}
// Nothing to split: no flags, and the single-GPU path stands.
full := GpuSet{
{Index: 0, TotalGB: 12, FreeGB: 0},
{Index: 1, TotalGB: 16, FreeGB: 2},
}
if split := full.TensorSplit(4); split != nil {
t.Fatalf("TensorSplit with no room anywhere = %v, want nil", split)
}
if split := (GpuSet{{Index: 0, FreeGB: 12}}).TensorSplit(1); split != nil {
t.Fatalf("single-device TensorSplit = %v, want nil", split)
}
// One extra device, so one runtime context, at the backend's own constant.
if got := set.ExtraDeviceOverheadGB(); got != computeVulkanCtxGB {
t.Fatalf("non-CUDA extra-device overhead = %.2f, want %.2f", got, computeVulkanCtxGB)
}
setCudaGPU(t, true)
if got := set.ExtraDeviceOverheadGB(); got != computeCudaCtxGB {
t.Fatalf("CUDA extra-device overhead = %.2f, want one context for the second card", got)
}
if got := (GpuSet{{Index: 0}}).ExtraDeviceOverheadGB(); got != 0 {
t.Fatalf("single-device overhead = %.2f, want 0", got)
}
}
// PlanTensorSplit is the generate-time twin: same derivation, but over each
// card's stable capacity, and never nil for a set worth splitting. A config is
// a long-lived artifact planned off one cold sample, and spawn time can retune
// a baked ratio but cannot add one to an argv that has none, so a card busy for
// that single sample must not bake a plan without a split in it. See issue #4.
func TestAutogen_PlanTensorSplit(t *testing.T) {
// Non-CUDA, matching TestAutogen_TensorSplit: perDeviceFixedGB is 0.4.
setCudaGPU(t, false)
// Both cards idle: the plan and the live derivation agree, because the
// floor never raises a reading that is already above it.
idle := GpuSet{
{Index: 0, TotalGB: 12, FreeGB: 11},
{Index: 1, TotalGB: 16, FreeGB: 15},
}
if got := idle.PlanTensorSplit(2); len(got) != 2 || got[0] != 0.45 || got[1] != 0.55 {
t.Fatalf("idle PlanTensorSplit = %v, want the same [0.45 0.55] as the live split", got)
}
if got := idle.PlanMainIndex(); got != 1 {
t.Fatalf("idle PlanMainIndex = %d, want 1", got)
}
// The case the bake used to get wrong: the big card is mid-load at the one
// moment the generate pass samples. The live derivation writes it off (0.06
// of the layers) and hands the fixed costs to the small card; the plan
// floors it at 85% of its own VRAM, keeps it main, and gives it the larger
// share. Spawn time can walk that back, and could not have walked a
// missing split forward.
busy := GpuSet{
{Index: 0, TotalGB: 12, FreeGB: 11},
{Index: 1, TotalGB: 16, FreeGB: 1},
}
if got := busy.MainIndex(); got != 0 {
t.Fatalf("live MainIndex = %d, want the idle small card", got)
}
if got := busy.PlanMainIndex(); got != 1 {
t.Fatalf("PlanMainIndex = %d, want the momentarily busy big card", got)
}
if got := busy.PlanTensorSplit(2); len(got) != 2 || got[0] != 0.48 || got[1] != 0.52 {
t.Fatalf("busy PlanTensorSplit = %v, want [0.48 0.52]", got)
}
// No arrangement fits: the live split gives up and returns nil, the plan
// falls back to the physical ratio so the flags still exist for the retune
// to rewrite.
tiny := GpuSet{
{Index: 0, TotalGB: 0.3, FreeGB: 0},
{Index: 1, TotalGB: 0.3, FreeGB: 0},
}
if got := tiny.TensorSplit(5); got != nil {
t.Fatalf("live TensorSplit with no room = %v, want nil", got)
}
if got := tiny.PlanTensorSplit(5); len(got) != 2 || got[0] != 0.5 || got[1] != 0.5 {
t.Fatalf("PlanTensorSplit fallback = %v, want the physical ratio [0.5 0.5]", got)
}
// A single card is still no split at all.
if got := (GpuSet{{Index: 0, TotalGB: 12, FreeGB: 12}}).PlanTensorSplit(1); got != nil {
t.Fatalf("single-device PlanTensorSplit = %v, want nil", got)
}
}
// EligibleGpuStats is what the server's guard and VRAM tracker call. It has to
// apply the same floor as the sizer, and collapse to the sizer's main device
// when multiGpu is off.
func TestAutogen_EligibleGpuStats(t *testing.T) {
now := time.Now()
hist := []perf.GpuStat{
{ID: 0, MemTotalMB: 12288, MemUsedMB: 1024, Timestamp: now},
{ID: 1, MemTotalMB: 16384, MemUsedMB: 8192, Timestamp: now.Add(-time.Minute)},
{ID: 1, MemTotalMB: 16384, MemUsedMB: 4096, Timestamp: now},
{ID: 2, MemTotalMB: 2048, MemUsedMB: 0, Timestamp: now},
}
multi := EligibleGpuStats(hist, true, GpuPolicy{})
if len(multi) != 2 || multi[0].ID != 0 || multi[1].ID != 1 || multi[1].MemUsedMB != 4096 {
t.Fatalf("EligibleGpuStats(multi) = %+v, want both cards' newest samples", multi)
}
single := EligibleGpuStats(hist, false, GpuPolicy{})
if len(single) != 1 || single[0].ID != 1 {
t.Fatalf("EligibleGpuStats(single) = %+v, want only the main device", single)
}
if got := EligibleGpuStats(nil, true, GpuPolicy{}); got != nil {
t.Fatalf("empty stats produced %+v", got)
}
// LiveGpuSet is the un-smoothed set the spawn guard retunes the split from.
if live := LiveGpuSet(hist, true, GpuPolicy{}); len(live) != 2 || live[1].FreeGB != 12 {
t.Fatalf("LiveGpuSet = %+v, want raw per-device free", live)
}
}
// MultiGpuEnabled defaults ON, and turning it off has to hide the resolved set
// from every caller rather than needing each one to re-check the flag.
func TestAutogen_MultiGpuEnabled(t *testing.T) {
set := GpuSet{{Index: 0, FreeGB: 11}, {Index: 1, FreeGB: 15}}
s := Settings{Gpus: set}
if !s.MultiGpuEnabled() || len(s.GpuSetOrEmpty()) != 2 {
t.Fatal("nil multiGpu did not default to on")
}
off := false
s.MultiGpu = &off
if s.MultiGpuEnabled() || s.GpuSetOrEmpty() != nil {
t.Fatal("multiGpu:false still exposed the device set")
}
}
// setCudaGPU forces the CUDA flag for one test and restores it after, so the
// fixed-cost arithmetic doesn't depend on the machine running the suite.
func setCudaGPU(t *testing.T, on bool) {
t.Helper()
prev := cudaGPU.Load()
cudaGPU.Store(on)
t.Cleanup(func() { cudaGPU.Store(prev) })
}
// The split flags are emitted ONLY for a real multi-device plan, and never on a
// CPU-only load: -ngl 0 means there are no layers on any GPU to divide, and
// --tensor-split would then be a lie the runtime guard would later act on.
func TestAutogen_emitsTensorSplitOnlyWhenSplitting(t *testing.T) {
s := Settings{TargetVramGB: 24}
row := GgufRow{FullPath: "/m.gguf"}
prof := profile{Name: "solo", TensorSplit: []float64{0.46, 0.54}, MainGpu: 1}
got := strings.Join(buildCmdLines(s, Metadata{}, row, prof, 8192, 99, 0, "q8_0", "q8_0", false, nil), " ")
if !strings.Contains(got, "-sm layer --main-gpu 1") || !strings.Contains(got, "--tensor-split 0.46,0.54") {
t.Fatalf("multi-device plan emitted no split flags:\n%s", got)
}
// -ngl 0: everything is on the CPU, so nothing is split.
got = strings.Join(buildCmdLines(s, Metadata{}, row, prof, 8192, 0, 0, "q8_0", "q8_0", false, nil), " ")
if strings.Contains(got, "--tensor-split") || strings.Contains(got, "--main-gpu") {
t.Fatalf("CPU-only load still emitted split flags:\n%s", got)
}
// Single GPU: no TensorSplit on the profile, no flags.
solo := profile{Name: "solo", MainGpu: -1}
got = strings.Join(buildCmdLines(s, Metadata{}, row, solo, 8192, 99, 0, "q8_0", "q8_0", false, nil), " ")
if strings.Contains(got, "--tensor-split") || strings.Contains(got, "-sm ") {
t.Fatalf("single-GPU plan emitted split flags:\n%s", got)
}
}
// retuneTensorSplit is the spawn-time half: a baked ratio built from idle VRAM
// is re-derived against the cards as they look now, so a model landing while
// another one holds one card does not send layers to a device with no room.
func TestAutogen_retuneTensorSplit(t *testing.T) {
setCudaGPU(t, false)
args := []string{"llama-server", "-m", "/m.gguf", "-sm", "layer", "--main-gpu", "0", "--tensor-split", "0.5,0.5"}
// No device set (no telemetry): the baked ratio is the best guess we have.
if got := retuneTensorSplit(Settings{}, args, 1, nil); !reflect.DeepEqual(got, args) {
t.Fatalf("no-telemetry retune changed the argv: %v", got)
}
// Card 0 is now nearly full, card 1 is empty: the ratio and the main device
// both move to card 1.
s := Settings{Gpus: GpuSet{
{Index: 0, TotalGB: 12, FreeGB: 1},
{Index: 1, TotalGB: 16, FreeGB: 15},
}}
got := retuneTensorSplit(s, args, 5, nil)
// 1-0.4=0.6 on card 0 against 15-5=10 on the main card: 0.06 and 0.94.
if got[len(got)-1] != "0.06,0.94" {
t.Fatalf("--tensor-split = %q, want the live ratio 0.06,0.94", got[len(got)-1])
}
if got[6] != "1" {
t.Fatalf("--main-gpu = %q, want 1", got[5])
}
if args[len(args)-1] != "0.5,0.5" {
t.Fatal("retune mutated the caller's argv")
}
// An argv with no --tensor-split is left exactly as it is.
plain := []string{"llama-server", "-m", "/m.gguf"}
if got := retuneTensorSplit(s, plain, 1, nil); !reflect.DeepEqual(got, plain) {
t.Fatalf("single-GPU argv changed: %v", got)
}
}
// The extra devices' runtime context must never reach splitBy as part of the
// MAIN device's fixed cost: splitBy already charges perDeviceFixedGB to each
// non-main device, so folding ExtraDeviceOverheadGB in bills the secondary
// card's runtime twice, and both charges land on the main card's side of the
// ratio. Issue #4's box is the shape it hurts: a 16 GB main card beside a 12 GB
// one, where every point of ratio shifted off the main GPU lands on the card
// that ran out of memory.
func TestAutogen_TensorSplit_ExcludesExtraDeviceOverhead(t *testing.T) {
setCudaGPU(t, false)
set := GpuSet{
{Index: 0, TotalGB: 12, FreeGB: 11}, // RTX 3060
{Index: 1, TotalGB: 16, FreeGB: 15}, // RTX 4070 Ti SUPER, the main device
}
const overhead = 2.0
// Correct: 15-2 = 13 on main, 11-0.4 = 10.6 on the other, 23.6 total.
want := set.TensorSplit(overhead)
if len(want) != 2 || want[0] != 0.45 || want[1] != 0.55 {
t.Fatalf("TensorSplit(%.1f) = %v, want [0.45 0.55]", overhead, want)
}
// What the generate path used to pass. The second card's 0.4 GB is deducted
// from the main card as well as from itself, so the ratio tips toward the
// smaller card by exactly the amount that was double-counted.
doubled := set.TensorSplit(overhead + set.ExtraDeviceOverheadGB())
if doubled[0] <= want[0] {
t.Fatalf("double-charged split %v is not biased toward the secondary card vs %v; "+
"the regression this guards is no longer observable and the test needs new numbers",
doubled, want)
}
}
// Once generate pins the device list with --device, --main-gpu is a position in
// THAT list. The spawn-time retune rewrites --main-gpu, so it has to follow the
// same rule or it puts back the telemetry ordinal the naming was meant to
// replace: here main is telemetry index 3, which is position 1 of the pinned
// pair.
func TestAutogen_retuneTensorSplit_PinnedDeviceList(t *testing.T) {
setCudaGPU(t, false)
s := Settings{Gpus: GpuSet{
{Index: 0, TotalGB: 12, FreeGB: 1},
{Index: 3, TotalGB: 16, FreeGB: 15},
}}
// A pinned list whose order cannot be re-derived (no probeable backend exe
// here) is left completely alone. Rewriting --tensor-split in SET order
// against a list written in main-last order would hand each card the other's
// ratio, which is worse than the stale ratio it replaced.
named := []string{"llama-server", "-m", "/m.gguf", "--device", "Vulkan1,Vulkan0",
"-sm", "layer", "--main-gpu", "1", "--tensor-split", "0.5,0.5"}
got := retuneTensorSplit(s, named, 5, nil)
if got[10] != "0.5,0.5" || got[8] != "1" || got[4] != "Vulkan1,Vulkan0" {
t.Fatalf("retune rewrote an unmappable pinned launch: %v", got[4:])
}
// Same set, no --device: the telemetry index is still the best stand-in for
// the backend's own ordinal, and the split is already in set order.
unnamed := []string{"llama-server", "-m", "/m.gguf", "-sm", "layer", "--main-gpu", "0", "--tensor-split", "0.5,0.5"}
reg := retuneTensorSplit(s, unnamed, 5, nil)
if reg[6] != "3" {
t.Fatalf("unnamed --main-gpu = %q, want the telemetry index 3", reg[6])
}
if reg[8] == "0.5,0.5" {
t.Fatalf("unnamed --tensor-split was not retuned: %q", reg[8])
}
}
// A backend device id carries both halves of the pin: which enumeration to
// filter, and which ordinal in it. Getting the pairing wrong does not fail
// loudly, it silently runs the model on a card the sizer did not budget.
func TestAutogen_visibleDevicesEnvFor(t *testing.T) {
want := map[string][2]string{
"CUDA0": {"CUDA_VISIBLE_DEVICES", "0"},
"Vulkan1": {"GGML_VK_VISIBLE_DEVICES", "1"},
"ROCm2": {"HIP_VISIBLE_DEVICES", "2"},
}
for id, w := range want {
name, ord, ok := visibleDevicesEnvFor(id)
if !ok || name != w[0] || ord != w[1] {
t.Errorf("visibleDevicesEnvFor(%q) = %q,%q,%v; want %q,%q,true", id, name, ord, ok, w[0], w[1])
}
}
// An unrecognised backend, and an id with no ordinal at all, get nothing
// rather than a guess.
for _, id := range []string{"SYCL0", "Metal0", "Vulkan", "0"} {
if _, _, ok := visibleDevicesEnvFor(id); ok {
t.Errorf("visibleDevicesEnvFor(%q) claimed a pin", id)
}
}
}
// With no probe to go on, CUDA still pins from the telemetry index (PCI bus
// order makes the two agree) and everything else emits nothing, which is what
// shipped.
func TestAutogen_singleDeviceEnvFor_NoProbe(t *testing.T) {
set := GpuSet{
{Index: 0, TotalGB: 12, FreeGB: 11},
{Index: 1, TotalGB: 16, FreeGB: 15},
}
missing := filepath.Join(t.TempDir(), "not-a-backend")
setCudaGPU(t, true)
pin, isCuda := singleDeviceEnvFor(missing, set)
if pin != "CUDA_VISIBLE_DEVICES=1" || !isCuda {
t.Fatalf("CUDA fallback = %q,%v; want CUDA_VISIBLE_DEVICES=1,true", pin, isCuda)
}
setCudaGPU(t, false)
if pin, _ := singleDeviceEnvFor(missing, set); pin != "" {
t.Fatalf("non-CUDA with no probe = %q, want no pin", pin)
}
}
// An APU's "VRAM" is the BIOS carve-out; the pool the GPU actually allocates
// from is the shared one (AMD's GTT). Before issue #37 the carve-out was the
// whole reading, it sat under the 3GB floor, and the only GPU in the machine was
// dropped from every budget in the program.
func TestAutogen_IntegratedGpuCountsSharedPool(t *testing.T) {
now := time.Now()
set := gpuSetFromStats([]perf.GpuStat{{
ID: 0, Name: "AMD Radeon 780M card0 (gfx1103)",
MemTotalMB: 2048, MemUsedMB: 256,
SharedTotalMB: 16384, SharedUsedMB: 2048,
Timestamp: now,
}}, GpuPolicy{})
if len(set) != 1 {
t.Fatalf("got %+v, want the APU kept: a 2GB carve-out is under the floor, 2+16GB is not", set)
}
d := set[0]
if !d.Integrated {
t.Error("Integrated = false, want true")
}
if d.TotalGB != 18 || d.FreeGB != 15.75 {
t.Errorf("total/free = %.2f/%.2f, want 18/15.75 (carve-out + shared)", d.TotalGB, d.FreeGB)
}
if d.SharedFreeGB != 14 {
t.Errorf("SharedFreeGB = %.2f, want 14", d.SharedFreeGB)
}
}
// The same test from the other side, and the one that matters most: a discrete
// card reports a host aperture (GTT) too, and counting it would budget layers
// into PCIe memory. Its own 24GB is far above the shape test's bound, so only
// the dedicated pool counts.
func TestAutogen_DiscreteGpuIgnoresSharedPool(t *testing.T) {
now := time.Now()
set := gpuSetFromStats([]perf.GpuStat{{
ID: 0, Name: "AMD Radeon RX 7900 XTX",
MemTotalMB: 24576, MemUsedMB: 1024,
SharedTotalMB: 16384, SharedUsedMB: 512,
Timestamp: now,
}}, GpuPolicy{})
if len(set) != 1 {
t.Fatalf("got %+v, want one card", set)
}
d := set[0]
if d.Integrated {
t.Error("Integrated = true for a discrete card")
}
if d.TotalGB != 24 || d.SharedFreeGB != 0 {
t.Errorf("total/sharedFree = %.2f/%.2f, want 24/0 (aperture must not count)", d.TotalGB, d.SharedFreeGB)
}
if d.SharedTotalGB != 16 {
t.Errorf("SharedTotalGB = %.2f, want 16 reported (but not budgeted)", d.SharedTotalGB)
}
}
// A small discrete card in a big-RAM box reports the shape an APU does, minus
// the ratio: an 8GB card with a 16GB aperture is only 2x. It must stay a
// dedicated device, or the budget invents host memory for it.
func TestAutogen_SmallDiscreteCardIsNotCalledIntegrated(t *testing.T) {
set := gpuSetFromStats([]perf.GpuStat{{
ID: 0, Name: "AMD Radeon RX 6600",
MemTotalMB: 8192, MemUsedMB: 0,
SharedTotalMB: 16384, SharedUsedMB: 0,
}}, GpuPolicy{})
if len(set) != 1 || set[0].Integrated || set[0].TotalGB != 8 {
t.Fatalf("got %+v, want one 8GB dedicated device", set)
}
}
// The kernel's own verdict outranks every heuristic, and it is the only signal
// that catches a Strix Halo: a 16GB carve-out reads like a card's VRAM, the name
// matches no APU marker, and the aperture ratio is under 3x, so all three
// guesses say "discrete". KFD's local_mem_size says otherwise.
func TestAutogen_KernelFlagClassifiesOversizedApu(t *testing.T) {
apu := perf.GpuStat{
ID: 0, Name: "AMD Radeon 8060S Graphics",
MemTotalMB: 16384, MemUsedMB: 0,
SharedTotalMB: 32768, SharedUsedMB: 0,
Integrated: true,
}
set := gpuSetFromStats([]perf.GpuStat{apu}, GpuPolicy{})
if len(set) != 1 || !set[0].Integrated {
t.Fatalf("got %+v, want the kernel's verdict honoured", set)
}
if set[0].TotalGB != 48 {
t.Errorf("TotalGB = %.1f, want 48 (carve-out + pool)", set[0].TotalGB)
}
// The same readings without the flag are a discrete card. That is the
// heuristic's blind spot, not a regression: nothing else can tell them apart.
apu.Integrated = false
set = gpuSetFromStats([]perf.GpuStat{apu}, GpuPolicy{})
if len(set) != 1 || set[0].Integrated || set[0].TotalGB != 16 {
t.Fatalf("without the flag = %+v, want a 16GB dedicated device", set)
}
}
// An APU with no carve-out at all has zero dedicated memory, and the floor would
// drop it as "no GPU" if the pool were not folded first. The kernel flag is what
// says the pool is its memory.
func TestAutogen_KernelFlagCountsAnApuWithNoCarveout(t *testing.T) {
set := gpuSetFromStats([]perf.GpuStat{{
ID: 0, Name: "amdgpu [1002:15bf]", MemTotalMB: 0, MemUsedMB: 0,
SharedTotalMB: 16384, SharedUsedMB: 1024, Integrated: true,
}}, GpuPolicy{})
if len(set) != 1 {
t.Fatalf("got %+v, want the APU kept", set)
}
if set[0].TotalGB != 16 || set[0].FreeGB != 15 {
t.Errorf("total/free = %.2f/%.2f, want 16/15 (the pool is the whole GPU)", set[0].TotalGB, set[0].FreeGB)
}
}
// Name markers catch an APU whose carve-out is too large for the shape test
// (an 8GB UMA setting) without ever matching a card's name.
func TestAutogen_ApuNameMarker(t *testing.T) {
set := gpuSetFromStats([]perf.GpuStat{{
ID: 0, Name: "Ryzen 7 7840U w/ Radeon 780M card0 (gfx1103)",
MemTotalMB: 8192, MemUsedMB: 0,
SharedTotalMB: 16384, SharedUsedMB: 0,
}}, GpuPolicy{})
if len(set) != 1 || !set[0].Integrated || set[0].TotalGB != 24 {
t.Fatalf("got %+v, want the APU counted at 8+16GB", set)
}
}
// The two explicit modes exist because auto has to guess. off is the escape
// hatch that restores the pre-#37 reading exactly; on budgets the pool for a
// device auto refused, without also calling it integrated (topology is what the
// pooling rule reads).
func TestAutogen_SharedMemoryModes(t *testing.T) {
apu := perf.GpuStat{
ID: 0, Name: "AMD Radeon 780M card0 (gfx1103)",
MemTotalMB: 2048, MemUsedMB: 256,
SharedTotalMB: 16384, SharedUsedMB: 2048,
}
gpu := perf.GpuStat{
ID: 0, Name: "AMD Radeon RX 7900 XTX",
MemTotalMB: 24576, MemUsedMB: 1024,
SharedTotalMB: 16384, SharedUsedMB: 512,
}
if set := gpuSetFromStats([]perf.GpuStat{apu}, GpuPolicy{SharedMemory: SharedMemoryOff}); len(set) != 0 {
t.Fatalf("off = %+v, want the APU back under the floor (pre-#37 behaviour)", set)
}
if set := gpuSetFromStats([]perf.GpuStat{apu}, GpuPolicy{SharedMemory: SharedMemoryOn}); len(set) != 1 || set[0].TotalGB != 18 {
t.Fatalf("on = %+v, want the APU counted", set)
}
set := gpuSetFromStats([]perf.GpuStat{gpu}, GpuPolicy{SharedMemory: SharedMemoryOn})
if len(set) != 1 || set[0].TotalGB != 40 || set[0].Integrated {
t.Fatalf("on (discrete) = %+v, want 24+16GB budgeted but still a dedicated device", set)
}
}
// An APU beside a card is not a split target by default: real layers on an iGPU
// are slower than not splitting at all. It is a PAIRING rule, so the APU still
// gets the whole budget when it is the only GPU (see the tests above).
func TestAutogen_IntegratedPairingNeedsTheToggle(t *testing.T) {
now := time.Now()
hist := []perf.GpuStat{
{
ID: 0, Name: "AMD Radeon 780M card0 (gfx1103)",
MemTotalMB: 2048, MemUsedMB: 256, SharedTotalMB: 16384, SharedUsedMB: 2048, Timestamp: now,
},
{ID: 1, Name: "NVIDIA GeForce RTX 4070 Ti SUPER", MemTotalMB: 16384, MemUsedMB: 4096, Timestamp: now},
}
got := gpuSetFromStats(hist, GpuPolicy{})
if len(got) != 1 || got[0].Index != 1 {
t.Fatalf("set = %+v, want only the card: an iGPU is not a split target by default", got)
}
got = gpuSetFromStats(hist, GpuPolicy{PoolIntegrated: true})
if len(got) != 2 || got[0].Index != 0 || !got[0].Integrated {
t.Fatalf("set = %+v, want both devices with the APU first", got)
}
// The pooled budget is the sum of both, and the APU's share of it is the
// shared pool, which is what the sizer's own log line reports.
if free := got.FreeGB(); free != 15.75+12 {
t.Fatalf("pooled free = %.2f, want 27.75", free)
}
}
// DescribeGpuStats is what cmd/monitor-test prints, and the reason it exists is
// that the reporter of a device-specific bug has to be able to say which side of
// the policy their machine landed on.
func TestAutogen_DescribeGpuStats(t *testing.T) {
stats := []perf.GpuStat{
{
ID: 0, Name: "AMD Radeon 780M card0 (gfx1103)",
MemTotalMB: 2048, MemUsedMB: 256, SharedTotalMB: 16384, SharedUsedMB: 2048,
},
{ID: 1, Name: "AMD Radeon RX 6600", MemTotalMB: 8192, MemUsedMB: 0, SharedTotalMB: 16384},
{ID: 2, Name: "tiny", MemTotalMB: 1024, MemUsedMB: 0},
}
lines := DescribeGpuStats(stats, true, GpuPolicy{})
joined := strings.Join(lines, "\n")
for _, want := range []string{
"integrated", // the APU's topology is named
"counted", // ...and that its pool is part of the budget
"not counted", // ...while the card's aperture is not
"18.0 GB usable", // the fold is shown, so 2GB -> 18GB is visible
"8.0 GB usable", // the card keeps its own 8GB
"DROPPED (under the 3.0 GB floor)",
"EXCLUDED", // the APU is unpairable beside a card by default
"8.0 GB total", // so the summary holds the card alone
} {
if !strings.Contains(joined, want) {
t.Errorf("output %q missing %q", joined, want)
}
}
// The escape hatch has to be visible in the diagnostic too, or a user
// comparing a pre-#37 report against a post-#37 one sees nothing change.
off := strings.Join(DescribeGpuStats(stats[:1], true, GpuPolicy{SharedMemory: SharedMemoryOff}), "\n")
if !strings.Contains(off, "not counted") || !strings.Contains(off, "no GPU counts") {
t.Fatalf("sharedMemory=off output = %q, want the pool uncounted and no budget", off)
}
}