Fix: Convert intel gpu top from text to JSON (#2387)

Co-authored-by: hank <hank@henrygd.me>
This commit is contained in:
Jayden
2026-09-27 10:17:28 -04:00
committed by GitHub
co-authored by hank
parent 3f20ecae50
commit 739649a6db
2 changed files with 314 additions and 315 deletions
+122 -102
View File
@@ -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
}
+192 -213
View File
@@ -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")
}