package metrics import ( "encoding/json" "os" "strings" "testing" "time" ) const fdinfoA = `pos: 0 flags: 02100002 drm-driver: i915 drm-client-id: 544 drm-pdev: 0000:00:02.0 drm-total-system0: 52624 KiB drm-resident-system0: 50140 KiB drm-resident-stolen-system0: 32 KiB drm-engine-render: 1000000000 ns drm-engine-copy: 0 ns drm-engine-video: 2000000000 ns drm-engine-capacity-video: 2 drm-engine-video-enhance: 0 ns ` func TestParseFdinfo(t *testing.T) { fi := parseFdinfo(fdinfoA) if fi.driver != "i915" || fi.pdev != "0000:00:02.0" || fi.client != "544" { t.Fatalf("identity: %+v", fi) } if fi.engines["video"] != 2000000000 || fi.capacity["video"] != 2 { t.Errorf("video engine: %+v", fi) } if fi.mem != (50140+32)<<10 { t.Errorf("resident = %d", fi.mem) } } func TestLspciName(t *testing.T) { for in, want := range map[string]string{ "Intel Corporation DG2 [Arc A380] (rev 05)": "Intel Arc A380", "Intel Corporation RocketLake-S GT1 [UHD Graphics 750] (rev 04)": "Intel UHD Graphics 750", "Intel Corporation Alder Lake-N [UHD Graphics]": "Intel UHD Graphics", "Advanced Micro Devices, Inc. [AMD/ATI] Navi 23 [Radeon RX 6600/6600 XT/6600M] (rev c7)": "AMD Radeon RX 6600/6600 XT/6600M", "NVIDIA Corporation GA106 [GeForce RTX 3060 Lite Hash Rate] (rev a1)": "NVIDIA GeForce RTX 3060 Lite Hash Rate", "Moore Threads Technology Co.,Ltd MTT S80": "Moore Threads Technology Co.,Ltd MTT S80", } { if got := lspciName(in); got != want { t.Errorf("%q → %q, want %q", in, got, want) } } } // Two scans a second apart: a client that ran its render engine for half of it and both // video engines flat out reads 50% render, 100% video, and the device's engines follow. func TestGPUClientsShare(t *testing.T) { c := NewCollector() d := &gpuDev{card: "/nonexistent/card9", dev: "/nonexistent", key: "0000:00:02.0", bus: "0000:00:02.0", driver: "i915", name: "Test GPU", integrated: true} c.gpu.devs = []*gpuDev{d} c.gpu.listedAt = time.Now().Add(time.Hour) // keep the fake list c.gpu.clients = map[string]clientPrev{} t0 := time.Now() c.gpu.last = []drmFD{{PID: 7, Comm: "ffmpeg", Info: fdinfoA}} c.gpu.scan = func() ([]drmFD, bool) { return c.gpu.last, true } // no walk: the fake list stands first := c.gpus(t0, 40) if len(first) != 1 || len(first[0].Clients) != 1 || first[0].Clients[0].Usage != 0 { t.Fatalf("first pass: %+v", first) } if first[0].Temp != 40 || first[0].TempSrc != "package" { t.Errorf("an integrated GPU wears the package temperature: %+v", first[0]) } later := fdinfoA later = replace(later, "drm-engine-render: 1000000000 ns", "drm-engine-render: 1500000000 ns") later = replace(later, "drm-engine-video: 2000000000 ns", "drm-engine-video: 4000000000 ns") c.gpu.last = []drmFD{{PID: 7, Comm: "ffmpeg", Info: later}} got := c.gpus(t0.Add(time.Second), 40) cl := got[0].Clients[0] if cl.Engines["render"] != 50 || cl.Engines["video"] != 100 || cl.Usage != 100 { t.Errorf("client share: %+v", cl) } if got[0].Engines["video"] != 100 || got[0].Usage != 100 || got[0].UsageSrc != "clients" { t.Errorf("device from clients: %+v", got[0]) } if got[0].MemUsed != cl.Mem { t.Errorf("resident memory should be the clients' sum: %d vs %d", got[0].MemUsed, cl.Mem) } // the Processes table joins the client to its row c.ring = []Sample{{T: 1, GPUs: got}} c.prevProc = nil table := c.Processes() if len(table.GPUs) != 1 { t.Errorf("table carries the GPUs: %+v", table.GPUs) } } func TestFlattenGPU(t *testing.T) { s := Sample{T: 5, GPUs: []GPU{{Name: "Intel Arc A380", Driver: "i915", Bus: "0000:03:00.0", Usage: 12, UsageSrc: "idle", Engines: map[string]float64{"video": 30}, PowerW: 9.5, PowerCapW: 55, MHz: 2450, MaxMHz: 2450, Clients: []GPUClient{{PID: 1}}}}} got := map[string]any{} for _, x := range flatten(s, nil) { got[x.ID] = x.Value } for id, want := range map[string]any{"gpu.intel_arc_a380.usage": 12.0, "gpu.intel_arc_a380.video": 30.0, "gpu.intel_arc_a380.power": 9.5, "gpu.intel_arc_a380.freq": 2450.0, "gpu.intel_arc_a380.clients": 1.0} { if got[id] != want { t.Errorf("%s = %v, want %v", id, got[id], want) } } if _, ok := got["gpu.intel_arc_a380.temp"]; ok { t.Error("no temperature sensor, no temperature") } } func replace(s, old, new string) string { for i := 0; i+len(old) <= len(s); i++ { if s[i:i+len(old)] == old { return s[:i] + new + s[i+len(old):] } } return s } // TestLiveGPU reads this machine's GPUs for real: HAMLAB_LIVE=1 go test -run TestLiveGPU -v ./internal/metrics func TestLiveGPU(t *testing.T) { if os.Getenv("HAMLAB_LIVE") == "" { t.Skip("set HAMLAB_LIVE=1 to read the real hardware") } c := NewCollector() c.sample() time.Sleep(1100 * time.Millisecond) c.sample() s, _ := c.Latest() b, _ := json.MarshalIndent(s.GPUs, "", " ") t.Logf("%s", b) for _, x := range flatten(s, func(id string) bool { return len(id) > 4 && id[:4] == "gpu." }) { t.Logf("%s = %v %s", x.ID, x.Value, x.Unit) } } // TestLiveARC: HAMLAB_LIVE=1 go test -run TestLiveARC -v ./internal/metrics func TestLiveARC(t *testing.T) { if os.Getenv("HAMLAB_LIVE") == "" { t.Skip("set HAMLAB_LIVE=1 to read the real machine") } c := NewCollector() c.sample() time.Sleep(1100 * time.Millisecond) c.sample() s, _ := c.Latest() b, _ := json.MarshalIndent(s.ARC, "", " ") t.Logf("%s", b) for _, x := range flatten(s, func(id string) bool { return strings.HasPrefix(id, "zfs.arc.") }) { t.Logf("%s = %v %s", x.ID, x.Value, x.Unit) } } // The embedded table names the lab's four GPUs the way lspci would, so a machine without // pciutils (the router) agrees with one that has it. func TestPCIName(t *testing.T) { for id, want := range map[string]string{"8086:3185": "Intel UHD Graphics 600", "8086:56a5": "Intel Arc A380", "8086:46d1": "Intel UHD Graphics", "8086:4c8a": "Intel UHD Graphics 750", "10de:2684": "NVIDIA GeForce RTX 4090"} { v, d := pciName(id[:4], id[5:]) if d == "" { t.Errorf("%s: not in the table", id) continue } if got := lspciName(v + " " + d); got != want { t.Errorf("%s → %q, want %q", id, got, want) } } if _, d := pciName("8086", "ffff"); d != "" { t.Errorf("an unknown device should be empty, got %q", d) } } func TestParseNvidiaQuery(t *testing.T) { var g GPU parseNvidiaQuery(&g, "31, 0, 12, 1488, 2048, 949, 1097, 57, 23.41, [N/A]\n") if g.Usage != 31 || g.UsageSrc != "busy" || g.Engines["render"] != 31 || g.Engines["video"] != 12 { t.Errorf("usage: %+v", g) } if g.MemUsed != 1488<<20 || g.MemTotal != 2048<<20 || g.MHz != 949 || g.MaxMHz != 1097 { t.Errorf("mem/clock: %+v", g) } if g.Temp != 57 || g.TempSrc != "gpu" || g.PowerW != 23.4 || g.PowerCapW != 0 { t.Errorf("temp/power (cap must stay 0 for [N/A]): %+v", g) } var short GPU parseNvidiaQuery(&short, "garbage") if short.UsageSrc != "" { t.Errorf("a short line must leave the GPU untouched: %+v", short) } } const pmonA = `# gpu pid type sm mem enc dec jpg ofa fb ccpm command # Idx # C/G % % % % % % MB MB name 0 1063 G - - - - - - 701 0 Xorg 0 1480 G 27 22 - - - - 270 0 cinnamon 0 13318 G 9 7 40 - - - 454 0 firefox-esr ` func TestParseNvidiaPmon(t *testing.T) { cl := parseNvidiaPmon(pmonA) if len(cl) != 3 { t.Fatalf("want 3 clients, got %+v", cl) } // busiest first: firefox's encoder share (40) beats cinnamon's sm (27) if cl[0].PID != 13318 || cl[0].Name != "firefox-esr" || cl[0].Usage != 40 || cl[0].Engines["render"] != 9 || cl[0].Engines["video"] != 40 || cl[0].Mem != 454<<20 { t.Errorf("firefox: %+v", cl[0]) } if cl[1].PID != 1480 || cl[1].Usage != 27 || cl[1].Engines["render"] != 27 { t.Errorf("cinnamon: %+v", cl[1]) } if cl[2].PID != 1063 || cl[2].Usage != 0 || cl[2].Engines != nil || cl[2].Mem != 701<<20 { t.Errorf("xorg (no shares, only memory): %+v", cl[2]) } // an older driver without the jpg/ofa/ccpm columns old := "# gpu pid type sm mem enc dec fb command\n# Idx # C/G % % % % MB name\n 0 42 C 55 10 - - 100 python3\n" if cl := parseNvidiaPmon(old); len(cl) != 1 || cl[0].PID != 42 || cl[0].Usage != 55 || cl[0].Mem != 100<<20 || cl[0].Name != "python3" { t.Errorf("old layout: %+v", cl) } }