package perf import ( "encoding/json" "fmt" "math" "regexp" "strconv" "strings" "time" ) // ParseNvidiaSmiLine parses a single line from nvidia-smi CSV output. // Format: index,name,uuid,temperature.gpu,utilization.gpu,memory.used,memory.total,fan.speed,power.draw func ParseNvidiaSmiLine(line string) *GpuStat { fields := strings.Split(line, ",") if len(fields) < 9 { return nil } id, _ := strconv.Atoi(strings.TrimSpace(fields[0])) name := strings.TrimSpace(fields[1]) uuid := strings.TrimSpace(fields[2]) tempC, _ := strconv.Atoi(strings.TrimSpace(fields[3])) gpuUtil, _ := strconv.ParseFloat(strings.TrimSpace(fields[4]), 64) memUsed, _ := strconv.Atoi(strings.TrimSpace(fields[5])) memTotal, _ := strconv.Atoi(strings.TrimSpace(fields[6])) fanSpeed, _ := strconv.ParseFloat(strings.TrimSpace(fields[7]), 64) powerDraw, _ := strconv.ParseFloat(strings.TrimSpace(fields[8]), 64) var memUtil float64 if memTotal > 0 { memUtil = float64(memUsed) / float64(memTotal) * 100 } return &GpuStat{ Timestamp: time.Now(), ID: id, Name: name, UUID: uuid, TempC: tempC, GpuUtilPct: gpuUtil, MemUtilPct: memUtil, MemUsedMB: memUsed, MemTotalMB: memTotal, FanSpeedPct: fanSpeed, PowerDrawW: powerDraw, } } // mactopOutput maps the subset of mactop's headless JSON output that is // relevant to GpuStat. Note that mactop's memory object is whole-system memory, // not GPU-attributed; the darwin monitor overlays ioreg's GPU-attributed // unified memory (see overlayIoregMem) so both backends report consistent // memory figures. type mactopOutput struct { SocMetrics struct { GPUPower float64 `json:"gpu_power"` GPUFreq int `json:"gpu_freq_mhz"` GPUTemp float64 `json:"gpu_temp"` } `json:"soc_metrics"` Memory struct { Total uint64 `json:"total"` Used uint64 `json:"used"` } `json:"memory"` GPUUsage float64 `json:"gpu_usage"` SystemInfo struct { Name string `json:"name"` GPUCoreCount int `json:"gpu_core_count"` } `json:"system_info"` Fans []struct { RPM int `json:"rpm"` MinRPM int `json:"min_rpm"` MaxRPM int `json:"max_rpm"` } `json:"fans"` Temperatures []struct { Group string `json:"group"` Avg float64 `json:"avg_celsius"` } `json:"temperatures"` } // ioreg output uses ` = ` (with spaces) for top-level device properties and // `=` (no spaces) for values inside nested dictionaries such as // PerformanceStatistics. var ( reIoregModel = regexp.MustCompile(`"model"\s*=\s*"([^"]+)"`) reIoregCoreCount = regexp.MustCompile(`"gpu-core-count"\s*=\s*(\d+)`) reIoregUtil = regexp.MustCompile(`"Device Utilization %"=(\d+)`) reIoregMemUsed = regexp.MustCompile(`"In use system memory"=(\d+)`) ) // ParseIoregOutput parses `ioreg -r -c IOGPU -d 1 -f` output into a GpuStat for // the Apple Silicon integrated GPU. This is a fallback for when mactop is not // installed: utilization and used memory are available, but power, temperature, // and fan speed are not exposed by ioreg. memTotalMB is the unified memory size // supplied by the caller, since Apple Silicon shares memory between CPU and GPU. // Returns nil if no GPU device is found in the output. func ParseIoregOutput(out []byte, memTotalMB int) *GpuStat { utilMatch := reIoregUtil.FindSubmatch(out) memMatch := reIoregMemUsed.FindSubmatch(out) if utilMatch == nil && memMatch == nil { return nil } var gpuUtil float64 if utilMatch != nil { gpuUtil, _ = strconv.ParseFloat(string(utilMatch[1]), 64) } const toMB = 1024 * 1024 var memUsedMB int if memMatch != nil { memUsedBytes, _ := strconv.ParseInt(string(memMatch[1]), 10, 64) memUsedMB = int(memUsedBytes / toMB) } var memUtil float64 if memTotalMB > 0 { memUtil = float64(memUsedMB) / float64(memTotalMB) * 100 } name := "Apple GPU" if m := reIoregModel.FindSubmatch(out); m != nil { name = string(m[1]) } if m := reIoregCoreCount.FindSubmatch(out); m != nil { if cores, err := strconv.Atoi(string(m[1])); err == nil && cores > 0 { name = fmt.Sprintf("%s (%d-core GPU)", name, cores) } } return &GpuStat{ Timestamp: time.Now(), ID: 0, Name: name, GpuUtilPct: gpuUtil, MemUtilPct: memUtil, MemUsedMB: memUsedMB, MemTotalMB: memTotalMB, } } // ParseMactopLine parses a single line of mactop headless JSON output into a // GpuStat for the Apple Silicon integrated GPU. Returns nil if the line cannot // be parsed. func ParseMactopLine(line string) *GpuStat { line = strings.TrimSpace(line) if line == "" { return nil } var out mactopOutput if err := json.Unmarshal([]byte(line), &out); err != nil { return nil } const toMB = 1024 * 1024 memUsedMB := int(out.Memory.Used / toMB) memTotalMB := int(out.Memory.Total / toMB) var memUtil float64 if memTotalMB > 0 { memUtil = float64(memUsedMB) / float64(memTotalMB) * 100 } name := out.SystemInfo.Name if name == "" { name = "Apple GPU" } if out.SystemInfo.GPUCoreCount > 0 { name = fmt.Sprintf("%s (%d-core GPU)", name, out.SystemInfo.GPUCoreCount) } // Unified memory has no dedicated VRAM sensor; use the memory temperature // group when mactop exposes it. var vramTempC int for _, t := range out.Temperatures { if strings.EqualFold(t.Group, "Memory") { vramTempC = int(math.Round(t.Avg)) break } } // Average fan load across all fans as a percentage of their RPM range. var fanSpeed float64 var fanCount int for _, f := range out.Fans { if f.MaxRPM > f.MinRPM { pct := float64(f.RPM-f.MinRPM) / float64(f.MaxRPM-f.MinRPM) * 100 if pct < 0 { pct = 0 } fanSpeed += pct fanCount++ } } if fanCount > 0 { fanSpeed /= float64(fanCount) } return &GpuStat{ Timestamp: time.Now(), ID: 0, Name: name, TempC: int(math.Round(out.SocMetrics.GPUTemp)), VramTempC: vramTempC, GpuUtilPct: out.GPUUsage, MemUtilPct: memUtil, MemUsedMB: memUsedMB, MemTotalMB: memTotalMB, FanSpeedPct: fanSpeed, PowerDrawW: out.SocMetrics.GPUPower, } }