From 739649a6db777387ec8d551fd7298ac25c33a59e Mon Sep 17 00:00:00 2001 From: Jayden <5250189+lukyjay@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:17:28 +0800 Subject: [PATCH] Fix: Convert intel gpu top from text to JSON (#2387) Co-authored-by: hank --- agent/gpu_intel.go | 224 +++++++++++++------------ agent/gpu_test.go | 405 +++++++++++++++++++++------------------------ 2 files changed, 314 insertions(+), 315 deletions(-) diff --git a/agent/gpu_intel.go b/agent/gpu_intel.go index fd73ed84..b0813ffa 100644 --- a/agent/gpu_intel.go +++ b/agent/gpu_intel.go @@ -2,7 +2,9 @@ package agent import ( "bufio" + "encoding/json" "io" + "log/slog" "os/exec" "strconv" "strings" @@ -49,10 +51,10 @@ func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool { return true } -// collectIntelStats executes intel_gpu_top in text mode (-l) and parses the output +// collectIntelStats executes intel_gpu_top in JSON mode (-J) and parses the output. func (gm *GPUManager) collectIntelStats() (err error) { // Build command arguments, optionally selecting a device via -d - args := []string{"-s", intelGpuStatsInterval, "-l"} + args := []string{"-s", intelGpuStatsInterval, "-J"} if dev, ok := utils.GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" { args = append(args, "-d", dev) } @@ -80,48 +82,64 @@ func (gm *GPUManager) collectIntelStats() (err error) { } }() - scanner := bufio.NewScanner(stdout) - var header1 string - var engineNames []string - var friendlyNames []string - var preEngineCols int - var powerIndex int + if err := gm.parseIntelJSONStream(stdout); err != nil { + return err + } + // The closing "]" is printed as the process exits, so read to EOF to let + // it finish instead of killing it. + _, _ = io.Copy(io.Discard, stdout) + return nil +} + +// parseIntelJSONStream decodes samples from intel_gpu_top -J output and +// aggregates them. Since v1.28 the samples are wrapped in an array ("[", then +// comma separated objects, and "]" only when the process exits). Older +// versions print the same comma separated objects without the opening "[", so +// it is added here to let both formats decode as an array. +func (gm *GPUManager) parseIntelJSONStream(r io.Reader) error { + er := &eofReader{r: r} + br := bufio.NewReader(er) + first, err := peekNonSpace(br) + if err != nil { + if err == io.EOF { + return errNoValidData + } + return err + } + var src io.Reader = br + if first != '[' { + src = io.MultiReader(strings.NewReader("["), br) + } + + dec := json.NewDecoder(src) + if _, err := dec.Token(); err != nil { // opening "[" + return err + } var hadDataRow bool // skip first data row because it sometimes has erroneous data var skippedFirstDataRow bool - - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue + // Decode reads one object and skips the commas between them. The array is + // usually never closed, so output ending mid-array or mid-sample (the + // process was killed) is the normal end of the stream rather than an error. + for dec.More() { + var sample intelGpuJSONSample + if err := dec.Decode(&sample); err != nil { + if er.eof { + break + } + return err } - - // first header line - if strings.HasPrefix(line, "Freq") { - header1 = line - continue - } - - // second header line - if strings.HasPrefix(line, "req") { - engineNames, friendlyNames, powerIndex, preEngineCols = gm.parseIntelHeaders(header1, line) - continue - } - - // Data row if !skippedFirstDataRow { skippedFirstDataRow = true continue } - sample, err := gm.parseIntelData(line, engineNames, friendlyNames, powerIndex, preEngineCols) - if err != nil { - return err + stats := parseIntelJSONSample(sample) + if !validIntelPower(stats.PowerGPU) || !validIntelPower(stats.PowerPkg) { + slog.Debug("Skipping intel_gpu_top sample with invalid power", "gpu", stats.PowerGPU, "pkg", stats.PowerPkg) + continue } hadDataRow = true - gm.updateIntelFromStats(&sample) - } - if scanErr := scanner.Err(); scanErr != nil { - return scanErr + gm.updateIntelFromStats(&stats) } if !hadDataRow { return errNoValidData @@ -129,80 +147,82 @@ func (gm *GPUManager) collectIntelStats() (err error) { return nil } -func (gm *GPUManager) parseIntelHeaders(header1 string, header2 string) (engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) { - // Build indexes - h1 := strings.Fields(header1) - h2 := strings.Fields(header2) - powerIndex = -1 // Initialize to -1, will be set to actual index if found - // Collect engine names from header1 - for _, col := range h1 { - key := strings.TrimRightFunc(col, func(r rune) bool { - return (r >= '0' && r <= '9') || r == '/' - }) - var friendly string - switch key { - case "RCS": - friendly = "Render/3D" - case "BCS": - friendly = "Blitter" - case "VCS": - friendly = "Video" - case "VECS": - friendly = "VideoEnhance" - case "CCS": - friendly = "Compute" - default: - continue - } - engineNames = append(engineNames, key) - friendlyNames = append(friendlyNames, friendly) - } - // find power gpu index among pre-engine columns - if n := len(engineNames); n > 0 { - preEngineCols = max(len(h2)-3*n, 0) - limit := min(len(h2), preEngineCols) - for i := range limit { - if strings.EqualFold(h2[i], "gpu") { - powerIndex = i - break - } - } - } - return engineNames, friendlyNames, powerIndex, preEngineCols +// eofReader records whether the underlying reader has returned io.EOF. The +// json decoder reports a stream ending mid-value as a syntax error, so this +// is how a truncated final sample is told apart from invalid output. +type eofReader struct { + r io.Reader + eof bool } -func (gm *GPUManager) parseIntelData(line string, engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) (sample intelGpuStats, err error) { - fields := strings.Fields(line) - if len(fields) == 0 { - return sample, errNoValidData +func (e *eofReader) Read(p []byte) (int, error) { + n, err := e.r.Read(p) + if err == io.EOF { + e.eof = true } - // Make sure row has enough columns for engines - if need := preEngineCols + 3*len(engineNames); len(fields) < need { - return sample, errNoValidData + return n, err +} + +// peekNonSpace discards leading JSON whitespace and returns the next byte without consuming it. +func peekNonSpace(br *bufio.Reader) (byte, error) { + for { + b, err := br.Peek(1) + if err != nil { + return 0, err + } + switch b[0] { + case ' ', '\t', '\n', '\r': + _, _ = br.ReadByte() + default: + return b[0], nil + } } - if powerIndex >= 0 && powerIndex < len(fields) { - if v, perr := strconv.ParseFloat(fields[powerIndex], 64); perr == nil { - sample.PowerGPU = v - } - if v, perr := strconv.ParseFloat(fields[powerIndex+1], 64); perr == nil { - sample.PowerPkg = v +} + +// intelGpuJSONSample is a single sample from intel_gpu_top -J output. Only the +// needed fields are mapped. +type intelGpuJSONSample struct { + Power *struct { + GPU float64 `json:"GPU"` + Package float64 `json:"Package"` + } `json:"power"` + Engines map[string]struct { + Busy float64 `json:"busy"` + } `json:"engines"` +} + +// validIntelPower reports whether a power reading from intel_gpu_top is plausible. +func validIntelPower(watts float64) bool { + // 5000 is well above any real GPU or package draw. intel_gpu_top + // computes power from unsigned energy counter deltas, so a counter that reads + // lower than the previous sample produces an enormous value for that period. + return watts >= 0 && watts <= 5000 +} + +// parseIntelJSONSample converts one intel_gpu_top JSON sample into intelGpuStats. +func parseIntelJSONSample(sample intelGpuJSONSample) (stats intelGpuStats) { + if sample.Power != nil { + stats.PowerGPU = sample.Power.GPU + stats.PowerPkg = sample.Power.Package + } + if len(sample.Engines) > 0 { + stats.Engines = make(map[string]float64, len(sample.Engines)) + for key, engine := range sample.Engines { + stats.Engines[intelEngineClass(key)] += engine.Busy } } - if len(engineNames) > 0 { - sample.Engines = make(map[string]float64, len(engineNames)) - for k := range engineNames { - base := preEngineCols + 3*k - if base < len(fields) { - busy := 0.0 - if v, e := strconv.ParseFloat(fields[base], 64); e == nil { - busy = v - } - cur := sample.Engines[friendlyNames[k]] - sample.Engines[friendlyNames[k]] = cur + busy - } else { - sample.Engines[friendlyNames[k]] = 0 - } + return stats +} + +// intelEngineClass returns the engine class name for an engine key. Keys are +// class names ("Render/3D", "Video") in class view, which JSON output uses by +// default since v1.28, and instance names ("Render/3D/0", "Video/1") in +// physical view, which older versions use. +func intelEngineClass(key string) string { + if i := strings.LastIndexByte(key, '/'); i >= 0 { + if _, err := strconv.ParseUint(key[i+1:], 10, 32); err == nil { + return key[:i] } } - return sample, nil + return key } diff --git a/agent/gpu_test.go b/agent/gpu_test.go index 889f6c26..82a83a73 100644 --- a/agent/gpu_test.go +++ b/agent/gpu_test.go @@ -3,9 +3,11 @@ package agent import ( + "encoding/json" "fmt" "os" "path/filepath" + "slices" "strings" "testing" "time" @@ -1431,12 +1433,10 @@ func TestNewGPUManagerPriorityMixedCollectors(t *testing.T) { t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "intel_gpu_top,rocm-smi") intelPath := filepath.Join(dir, "intel_gpu_top") - intelScript := `#!/bin/sh -echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS VCS" -echo " req act /s % gpu pkg rd wr % se wa % se wa" -echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0" -echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0" -` + intelScript := "#!/bin/sh\necho '" + intelJSONStream(true, + intelJSONSample(2, 2.69, map[string]float64{"Render/3D": 0, "Video": 0}), + intelJSONSample(1.8, 2.45, map[string]float64{"Render/3D": 8.5, "Video": 15}), + ) + "'\n" require.NoError(t, os.WriteFile(intelPath, []byte(intelScript), 0755)) rocmPath := filepath.Join(dir, "rocm-smi") @@ -1752,19 +1752,61 @@ func TestIntelUpdateFromStats(t *testing.T) { assert.Equal(t, float64(2), gpu.Count) } +// intelJSONSample returns one sample object formatted like intel_gpu_top -J output +func intelJSONSample(powerGPU, powerPkg float64, engines map[string]float64) string { + var sb strings.Builder + sb.WriteString("{\n\t\"period\": {\n\t\t\"duration\": 3300.123456,\n\t\t\"unit\": \"ms\"\n\t},\n") + sb.WriteString("\t\"frequency\": {\n\t\t\"requested\": 373.000000,\n\t\t\"actual\": 373.000000,\n\t\t\"unit\": \"MHz\"\n\t},\n") + fmt.Fprintf(&sb, "\t\"power\": {\n\t\t\"GPU\": %f,\n\t\t\"Package\": %f,\n\t\t\"unit\": \"W\"\n\t},\n", powerGPU, powerPkg) + sb.WriteString("\t\"engines\": {") + names := make([]string, 0, len(engines)) + for name := range engines { + names = append(names, name) + } + slices.Sort(names) + for i, name := range names { + if i > 0 { + sb.WriteString(",") + } + fmt.Fprintf(&sb, "\n\t\t%q: {\n\t\t\t\"busy\": %f,\n\t\t\t\"sema\": 0.000000,\n\t\t\t\"wait\": 0.000000,\n\t\t\t\"unit\": \"%%\"\n\t\t}", name, engines[name]) + } + sb.WriteString("\n\t}\n}") + return sb.String() +} + +// intelJSONStream joins samples as intel_gpu_top -J prints them. Since v1.28 +// the output starts with "[" (withArray); older versions omit it. +func intelJSONStream(withArray bool, samples ...string) string { + var sb strings.Builder + if withArray { + sb.WriteString("[\n") + } + for i, s := range samples { + if i > 0 { + sb.WriteString(",\n") + } + sb.WriteString(s) + } + return sb.String() +} + func TestIntelCollectorStreaming(t *testing.T) { dir := t.TempDir() t.Setenv("PATH", dir) - // Create a fake intel_gpu_top that prints -l format with four samples (first will be skipped) and exits + engines := func(render, blitter, video float64) map[string]float64 { + return map[string]float64{"Render/3D": render, "Blitter": blitter, "Video": video} + } + output := intelJSONStream(true, + intelJSONSample(1.5, 4.13, engines(12.34, 0, 5)), + intelJSONSample(2.0, 2.69, engines(0, 0, 0)), + intelJSONSample(1.8, 2.45, engines(8.5, 15, 22)), + intelJSONSample(2.2, 3.12, engines(5.75, 9.5, 12)), + ) + "\n]" + + // Create a fake intel_gpu_top that prints -J output with four samples (first will be skipped) and exits scriptPath := filepath.Join(dir, "intel_gpu_top") - script := `#!/bin/sh -echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS BCS VCS" -echo " req act /s % gpu pkg rd wr % se wa % se wa % se wa" -echo "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0" -echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0 0.00 0 0" -echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0 22.00 0 1" -echo "298 295 278 51 2.20 3.12 1675 942 5.75 1 2 9.50 3 1 12.00 1 0"` + script := "#!/bin/sh\necho '" + output + "'\n" if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { t.Fatal(err) } @@ -1781,229 +1823,168 @@ echo "298 295 278 51 2.20 3.12 1675 942 5.75 1 2 9.50 gpu := gm.GpuDataMap["i0"] require.NotNil(t, gpu) // Power should be sum of samples 2-4 (first is skipped): 2.0 + 1.8 + 2.2 = 6.0 - assert.EqualValues(t, 6.0, gpu.Power) + assert.InDelta(t, 6.0, gpu.Power, 0.001) assert.InDelta(t, 8.26, gpu.PowerPkg, 0.01) // Allow small floating point differences // Engines aggregated from samples 2-4 - assert.EqualValues(t, 14.25, gpu.Engines["Render/3D"]) // 0.00 + 8.50 + 5.75 - assert.EqualValues(t, 34.0, gpu.Engines["Video"]) // 0.00 + 22.00 + 12.00 - assert.EqualValues(t, 24.5, gpu.Engines["Blitter"]) // 0.00 + 15.00 + 9.50 + assert.InDelta(t, 14.25, gpu.Engines["Render/3D"], 0.001) // 0.00 + 8.50 + 5.75 + assert.InDelta(t, 34.0, gpu.Engines["Video"], 0.001) // 0.00 + 22.00 + 12.00 + assert.InDelta(t, 24.5, gpu.Engines["Blitter"], 0.001) // 0.00 + 15.00 + 9.50 // Count should be 3 samples (first is skipped) assert.Equal(t, float64(3), gpu.Count) } -func TestParseIntelHeaders(t *testing.T) { +func TestParseIntelJSONStream(t *testing.T) { + first := intelJSONSample(9, 9, map[string]float64{"Render/3D": 99, "Compute": 99}) + classView := []string{ + intelJSONSample(2, 3, map[string]float64{"Render/3D": 10, "Blitter": 1, "Video": 5, "VideoEnhance": 0, "Compute": 40}), + intelJSONSample(1, 2, map[string]float64{"Render/3D": 20, "Blitter": 0, "Video": 5, "VideoEnhance": 3, "Compute": 60}), + } + classViewWant := map[string]float64{"Render/3D": 30, "Blitter": 1, "Video": 10, "VideoEnhance": 3, "Compute": 100} + tests := []struct { - name string - header1 string - header2 string - wantEngineNames []string - wantFriendlyNames []string - wantPowerIndex int - wantPreEngineCols int + name string + input string + wantErr error + wantAnyErr bool + wantCount float64 + wantPower float64 + wantPkg float64 + wantEngines map[string]float64 }{ { - name: "basic headers with RCS BCS VCS", - header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS BCS VCS", - header2: " req act /s % gpu pkg rd wr % se wa % se wa % se wa", - wantEngineNames: []string{"RCS", "BCS", "VCS"}, - wantFriendlyNames: []string{"Render/3D", "Blitter", "Video"}, - wantPowerIndex: 4, // "gpu" is at index 4 - wantPreEngineCols: 8, // 17 total cols - 3*3 = 8 + name: "array still open while process runs", + input: intelJSONStream(true, first, classView[0], classView[1]), + wantCount: 2, + wantPower: 3, + wantPkg: 5, + wantEngines: classViewWant, }, { - name: "basic headers with RCS BCS VCS using index in name", - header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS/0 BCS/1 VCS/2", - header2: " req act /s % gpu pkg rd wr % se wa % se wa % se wa", - wantEngineNames: []string{"RCS", "BCS", "VCS"}, - wantFriendlyNames: []string{"Render/3D", "Blitter", "Video"}, - wantPowerIndex: 4, // "gpu" is at index 4 - wantPreEngineCols: 8, // 17 total cols - 3*3 = 8 + name: "closed array", + input: intelJSONStream(true, first, classView[0], classView[1]) + "\n]\n", + wantCount: 2, + wantPower: 3, + wantPkg: 5, + wantEngines: classViewWant, }, { - name: "headers with only RCS", - header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS", - header2: " req act /s % gpu pkg rd wr % se wa", - wantEngineNames: []string{"RCS"}, - wantFriendlyNames: []string{"Render/3D"}, - wantPowerIndex: 4, - wantPreEngineCols: 8, // 11 total - 3*1 = 8 + name: "truncated final sample", + input: intelJSONStream(true, first, classView[0], classView[1], `{"period": {"duration": 33`), + wantCount: 2, + wantPower: 3, + wantPkg: 5, + wantEngines: classViewWant, }, { - name: "headers with VECS and CCS", - header1: "Freq MHz IRQ RC6 Power W IMC MiB/s VECS CCS", - header2: " req act /s % gpu pkg rd wr % se wa % se wa", - wantEngineNames: []string{"VECS", "CCS"}, - wantFriendlyNames: []string{"VideoEnhance", "Compute"}, - wantPowerIndex: 4, - wantPreEngineCols: 8, // 14 total - 3*2 = 8 + // intel_gpu_top < 1.28 omits the opening "[" and uses physical engine names + name: "legacy output without array and with engine instances", + input: intelJSONStream(false, + intelJSONSample(9, 9, map[string]float64{"Render/3D/0": 99}), + intelJSONSample(1.5, 2.5, map[string]float64{"Render/3D/0": 12, "Blitter/0": 1, "Video/0": 4, "Video/1": 6, "VideoEnhance/0": 2}), + ), + wantCount: 1, + wantPower: 1.5, + wantPkg: 2.5, + wantEngines: map[string]float64{"Render/3D": 12, "Blitter": 1, "Video": 10, "VideoEnhance": 2}, }, { - name: "no engines", - header1: "Freq MHz IRQ RC6 Power W IMC MiB/s", - header2: " req act /s % gpu pkg rd wr", - wantEngineNames: nil, // no engines found, slices remain nil - wantFriendlyNames: nil, - wantPowerIndex: -1, // no engines, so no search - wantPreEngineCols: 0, + // energy counter read lower than the previous sample in intel_gpu_top + name: "sample with invalid power is skipped", + input: intelJSONStream(true, first, classView[0], + intelJSONSample(86_000_000, 3, map[string]float64{"Render/3D": 50}), + intelJSONSample(2, 90_000_000, map[string]float64{"Render/3D": 50}), + classView[1], + ), + wantCount: 2, + wantPower: 3, + wantPkg: 5, + wantEngines: classViewWant, }, { - name: "power index not found", - header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS", - header2: " req act /s % pkg cpu rd wr % se wa", // no "gpu" - wantEngineNames: []string{"RCS"}, - wantFriendlyNames: []string{"Render/3D"}, - wantPowerIndex: -1, // "gpu" not found - wantPreEngineCols: 8, // 11 total - 3*1 = 8 + name: "only samples with invalid power", + input: intelJSONStream(true, first, intelJSONSample(86_000_000, 3, map[string]float64{"Render/3D": 50})), + wantErr: errNoValidData, }, { - name: "empty headers", - header1: "", - header2: "", - wantEngineNames: nil, // empty input, slices remain nil - wantFriendlyNames: nil, - wantPowerIndex: -1, - wantPreEngineCols: 0, + name: "empty output", + input: "", + wantErr: errNoValidData, + }, + { + name: "only first sample, which is skipped", + input: intelJSONStream(true, first), + wantErr: errNoValidData, + }, + { + name: "invalid output", + input: "intel_gpu_top: command failed", + wantAnyErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gm := &GPUManager{} - engineNames, friendlyNames, powerIndex, preEngineCols := gm.parseIntelHeaders(tt.header1, tt.header2) + gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)} + err := gm.parseIntelJSONStream(strings.NewReader(tt.input)) + if tt.wantAnyErr { + assert.Error(t, err) + return + } + if tt.wantErr != nil { + assert.ErrorIs(t, err, tt.wantErr) + assert.Empty(t, gm.GpuDataMap) + return + } + require.NoError(t, err) - assert.Equal(t, tt.wantEngineNames, engineNames) - assert.Equal(t, tt.wantFriendlyNames, friendlyNames) - assert.Equal(t, tt.wantPowerIndex, powerIndex) - assert.Equal(t, tt.wantPreEngineCols, preEngineCols) + gpu := gm.GpuDataMap["i0"] + require.NotNil(t, gpu) + assert.Equal(t, tt.wantCount, gpu.Count) + assert.InDelta(t, tt.wantPower, gpu.Power, 0.001) + assert.InDelta(t, tt.wantPkg, gpu.PowerPkg, 0.001) + assert.Len(t, gpu.Engines, len(tt.wantEngines)) + for name, want := range tt.wantEngines { + assert.InDelta(t, want, gpu.Engines[name], 0.001, name) + } }) } } -func TestParseIntelData(t *testing.T) { - tests := []struct { - name string - line string - engineNames []string - friendlyNames []string - powerIndex int - preEngineCols int - wantPowerGPU float64 - wantEngines map[string]float64 - wantErr error - }{ - { - name: "basic data with power and engines", - line: "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0", - engineNames: []string{"RCS", "BCS", "VCS"}, - friendlyNames: []string{"Render/3D", "Blitter", "Video"}, - powerIndex: 4, - preEngineCols: 8, - wantPowerGPU: 1.50, - wantEngines: map[string]float64{ - "Render/3D": 12.34, - "Blitter": 0.00, - "Video": 5.00, - }, - }, - { - name: "data with zero power", - line: "226 223 338 58 0.00 2.69 1820 965 0.00 0 0 0.00 0 0 0.00 0 0", - engineNames: []string{"RCS", "BCS", "VCS"}, - friendlyNames: []string{"Render/3D", "Blitter", "Video"}, - powerIndex: 4, - preEngineCols: 8, - wantPowerGPU: 0.00, - wantEngines: map[string]float64{ - "Render/3D": 0.00, - "Blitter": 0.00, - "Video": 0.00, - }, - }, - { - name: "data with no power index", - line: "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0", - engineNames: []string{"RCS", "BCS", "VCS"}, - friendlyNames: []string{"Render/3D", "Blitter", "Video"}, - powerIndex: -1, - preEngineCols: 8, - wantPowerGPU: 0.0, // no power parsed - wantEngines: map[string]float64{ - "Render/3D": 12.34, - "Blitter": 0.00, - "Video": 5.00, - }, - }, - { - name: "data with insufficient columns", - line: "373 373 224 45 1.50", // too few columns - engineNames: []string{"RCS", "BCS", "VCS"}, - friendlyNames: []string{"Render/3D", "Blitter", "Video"}, - powerIndex: 4, - preEngineCols: 8, - wantPowerGPU: 0.0, - wantEngines: nil, // empty sample returned - wantErr: errNoValidData, - }, - { - name: "empty line", - line: "", - engineNames: []string{"RCS"}, - friendlyNames: []string{"Render/3D"}, - powerIndex: 4, - preEngineCols: 8, - wantPowerGPU: 0.0, - wantEngines: nil, - wantErr: errNoValidData, - }, - { - name: "data with invalid power value", - line: "373 373 224 45 N/A 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0", - engineNames: []string{"RCS", "BCS", "VCS"}, - friendlyNames: []string{"Render/3D", "Blitter", "Video"}, - powerIndex: 4, - preEngineCols: 8, - wantPowerGPU: 0.0, // N/A can't be parsed - wantEngines: map[string]float64{ - "Render/3D": 12.34, - "Blitter": 0.00, - "Video": 5.00, - }, - }, - { - name: "data with invalid engine value", - line: "373 373 224 45 1.50 4.13 2554 714 N/A 0 0 0.00 0 0 5.00 0 0", - engineNames: []string{"RCS", "BCS", "VCS"}, - friendlyNames: []string{"Render/3D", "Blitter", "Video"}, - powerIndex: 4, - preEngineCols: 8, - wantPowerGPU: 1.50, - wantEngines: map[string]float64{ - "Render/3D": 0.0, // N/A becomes 0 - "Blitter": 0.00, - "Video": 5.00, - }, - }, - { - name: "data with no engines", - line: "373 373 224 45 1.50 4.13 2554 714", - engineNames: []string{}, - friendlyNames: []string{}, - powerIndex: 4, - preEngineCols: 8, - wantPowerGPU: 1.50, - wantEngines: nil, - }, +func TestParseIntelJSONSample(t *testing.T) { + t.Run("without power", func(t *testing.T) { + var sample intelGpuJSONSample + require.NoError(t, json.Unmarshal([]byte(`{"engines": {"Render/3D": {"busy": 7.5, "unit": "%"}}}`), &sample)) + stats := parseIntelJSONSample(sample) + assert.Zero(t, stats.PowerGPU) + assert.Zero(t, stats.PowerPkg) + assert.Equal(t, map[string]float64{"Render/3D": 7.5}, stats.Engines) + }) + + t.Run("without engines", func(t *testing.T) { + var sample intelGpuJSONSample + require.NoError(t, json.Unmarshal([]byte(`{"power": {"GPU": 1.25, "Package": 4.5, "unit": "W"}}`), &sample)) + stats := parseIntelJSONSample(sample) + assert.Equal(t, 1.25, stats.PowerGPU) + assert.Equal(t, 4.5, stats.PowerPkg) + assert.Nil(t, stats.Engines) + }) +} + +func TestIntelEngineClass(t *testing.T) { + tests := map[string]string{ + "Render/3D": "Render/3D", + "Render/3D/0": "Render/3D", + "Blitter": "Blitter", + "Blitter/0": "Blitter", + "Video/1": "Video", + "VideoEnhance/0": "VideoEnhance", + "Compute/3": "Compute", + "[unknown]": "[unknown]", + "[unknown]/0": "[unknown]", + "Video/": "Video/", } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gm := &GPUManager{} - sample, err := gm.parseIntelData(tt.line, tt.engineNames, tt.friendlyNames, tt.powerIndex, tt.preEngineCols) - assert.Equal(t, tt.wantErr, err) - - assert.Equal(t, tt.wantPowerGPU, sample.PowerGPU) - assert.Equal(t, tt.wantEngines, sample.Engines) - }) + for key, want := range tests { + assert.Equal(t, want, intelEngineClass(key), key) } } @@ -2016,13 +1997,11 @@ func TestIntelCollectorDeviceEnv(t *testing.T) { // Create a fake intel_gpu_top that records its arguments and prints minimal valid output scriptPath := filepath.Join(dir, "intel_gpu_top") - script := fmt.Sprintf(`#!/bin/sh -echo "$@" > %s -echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS VCS" -echo " req act /s %% gpu pkg rd wr %% se wa %% se wa" -echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0" -echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0" -`, argsFile) + output := intelJSONStream(true, + intelJSONSample(2, 2.69, map[string]float64{"Render/3D": 0, "Video": 0}), + intelJSONSample(1.8, 2.45, map[string]float64{"Render/3D": 8.5, "Video": 15}), + ) + script := fmt.Sprintf("#!/bin/sh\necho \"$@\" > %s\necho '%s'\n", argsFile, output) if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { t.Fatal(err) } @@ -2043,5 +2022,5 @@ echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 argsStr := strings.TrimSpace(string(data)) require.Contains(t, argsStr, "-d sriov") require.Contains(t, argsStr, "-s ") - require.Contains(t, argsStr, "-l") + require.Contains(t, argsStr, "-J") }