mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-30 05:17:49 +02:00
Fix: Convert intel gpu top from text to JSON (#2387)
Co-authored-by: hank <hank@henrygd.me>
This commit is contained in:
@@ -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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
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 sample, nil
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
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 key
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user