Graphs stepped once a history point (1 s at a 5 min window) while the numbers changed every tick; they now slide a fraction of a column each tick. The process table (and the GPU boxes, MHz and watts it carries) was polled on its own timer; it is now read per sample and committed with it. GPU clients are walked every sample, and the 1–2 s floors on the gallery, Network, Docker and ZFS ARC live views are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
220 lines
8.5 KiB
Go
220 lines
8.5 KiB
Go
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)
|
|
}
|
|
}
|