|
|
|
|
| package agent
|
|
|
| import (
|
| "bufio"
|
| "bytes"
|
| "encoding/json"
|
| "io"
|
| "log/slog"
|
| "os/exec"
|
| "strconv"
|
| "strings"
|
| "time"
|
|
|
| "github.com/henrygd/beszel/agent/utils"
|
| "github.com/henrygd/beszel/internal/entities/system"
|
| )
|
|
|
| const (
|
|
|
| powermetricsSampleIntervalMs = 500
|
|
|
| powermetricsPollInterval = 2 * time.Second
|
|
|
| macmonIntervalMs = 2500
|
| )
|
|
|
| const appleGPUID = "0"
|
|
|
|
|
|
|
| func (gm *GPUManager) startPowermetricsCollector() {
|
|
|
| if _, ok := gm.GpuDataMap[appleGPUID]; !ok {
|
| gm.GpuDataMap[appleGPUID] = &system.GPUData{Name: "Apple GPU"}
|
| }
|
|
|
| go func() {
|
| failures := 0
|
| for {
|
| if err := gm.collectPowermetrics(); err != nil {
|
| failures++
|
| if failures > maxFailureRetries {
|
| slog.Warn("powermetrics GPU collector failed repeatedly, stopping", "err", err)
|
| break
|
| }
|
| slog.Warn("Error collecting macOS GPU data via powermetrics (may require sudo)", "err", err)
|
| time.Sleep(retryWaitTime)
|
| continue
|
| }
|
| failures = 0
|
| time.Sleep(powermetricsPollInterval)
|
| }
|
| }()
|
| }
|
|
|
|
|
| func (gm *GPUManager) collectPowermetrics() error {
|
| interval := strconv.Itoa(powermetricsSampleIntervalMs)
|
| cmd := exec.Command(powermetricsCmd, "--samplers", "gpu_power", "-i", interval, "-n", "1")
|
| cmd.Stderr = nil
|
| out, err := cmd.Output()
|
| if err != nil {
|
| return err
|
| }
|
| if !gm.parsePowermetricsData(out) {
|
| return errNoValidData
|
| }
|
| return nil
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| func (gm *GPUManager) parsePowermetricsData(output []byte) bool {
|
| var idleResidency, powerMW float64
|
| var gotIdle, gotPower bool
|
|
|
| scanner := bufio.NewScanner(bytes.NewReader(output))
|
| for scanner.Scan() {
|
| line := strings.TrimSpace(scanner.Text())
|
| if strings.HasPrefix(line, "GPU idle residency:") {
|
|
|
| fields := strings.Fields(strings.TrimPrefix(line, "GPU idle residency:"))
|
| if len(fields) >= 1 {
|
| pct := strings.TrimSuffix(fields[0], "%")
|
| if v, err := strconv.ParseFloat(pct, 64); err == nil {
|
| idleResidency = v
|
| gotIdle = true
|
| }
|
| }
|
| } else if strings.HasPrefix(line, "GPU Power:") {
|
|
|
| fields := strings.Fields(strings.TrimPrefix(line, "GPU Power:"))
|
| if len(fields) >= 1 {
|
| if v, err := strconv.ParseFloat(fields[0], 64); err == nil {
|
| powerMW = v
|
| gotPower = true
|
| }
|
| }
|
| }
|
| }
|
| if err := scanner.Err(); err != nil {
|
| return false
|
| }
|
| if !gotIdle && !gotPower {
|
| return false
|
| }
|
|
|
| gm.Lock()
|
| defer gm.Unlock()
|
|
|
| if _, ok := gm.GpuDataMap[appleGPUID]; !ok {
|
| gm.GpuDataMap[appleGPUID] = &system.GPUData{Name: "Apple GPU"}
|
| }
|
| gpu := gm.GpuDataMap[appleGPUID]
|
|
|
| if gotIdle {
|
|
|
| gpu.Usage += 100 - idleResidency
|
| }
|
| if gotPower {
|
|
|
| gpu.Power += powerMW / milliwattsInAWatt
|
| }
|
| gpu.Count++
|
| return true
|
| }
|
|
|
|
|
|
|
| func (gm *GPUManager) startMacmonCollector() {
|
| if _, ok := gm.GpuDataMap[appleGPUID]; !ok {
|
| gm.GpuDataMap[appleGPUID] = &system.GPUData{Name: "Apple GPU"}
|
| }
|
|
|
| go func() {
|
| failures := 0
|
| for {
|
| if err := gm.collectMacmonPipe(); err != nil {
|
| failures++
|
| if failures > maxFailureRetries {
|
| slog.Warn("macmon GPU collector failed repeatedly, stopping", "err", err)
|
| break
|
| }
|
| slog.Warn("Error collecting macOS GPU data via macmon", "err", err)
|
| time.Sleep(retryWaitTime)
|
| continue
|
| }
|
| failures = 0
|
|
|
| time.Sleep(retryWaitTime)
|
| }
|
| }()
|
| }
|
|
|
| type macmonTemp struct {
|
| GPUTempAvg float64 `json:"gpu_temp_avg"`
|
| }
|
|
|
| type macmonSample struct {
|
| GPUPower float64 `json:"gpu_power"`
|
| GPURAMPower float64 `json:"gpu_ram_power"`
|
| GPUUsage []float64 `json:"gpu_usage"`
|
| Temp macmonTemp `json:"temp"`
|
| }
|
|
|
| func (gm *GPUManager) collectMacmonPipe() (err error) {
|
| macmonPath, err := utils.LookPathHomebrew(macmonCmd)
|
| if err != nil {
|
| return err
|
| }
|
| cmd := exec.Command(macmonPath, "pipe", "-i", strconv.Itoa(macmonIntervalMs))
|
|
|
| cmd.Stderr = io.Discard
|
| stdout, err := cmd.StdoutPipe()
|
| if err != nil {
|
| return err
|
| }
|
| if err := cmd.Start(); err != nil {
|
| return err
|
| }
|
|
|
|
|
|
|
| defer func() {
|
| _ = stdout.Close()
|
| if cmd.ProcessState == nil || !cmd.ProcessState.Exited() {
|
| _ = cmd.Process.Kill()
|
| }
|
| if waitErr := cmd.Wait(); err == nil && waitErr != nil {
|
| err = waitErr
|
| }
|
| }()
|
|
|
| scanner := bufio.NewScanner(stdout)
|
| var hadSample bool
|
| for scanner.Scan() {
|
| line := bytes.TrimSpace(scanner.Bytes())
|
| if len(line) == 0 {
|
| continue
|
| }
|
| if gm.parseMacmonLine(line) {
|
| hadSample = true
|
| }
|
| }
|
| if scanErr := scanner.Err(); scanErr != nil {
|
| return scanErr
|
| }
|
| if !hadSample {
|
| return errNoValidData
|
| }
|
| return nil
|
| }
|
|
|
|
|
| func (gm *GPUManager) parseMacmonLine(line []byte) bool {
|
| var sample macmonSample
|
| if err := json.Unmarshal(line, &sample); err != nil {
|
| return false
|
| }
|
|
|
| usage := 0.0
|
| if len(sample.GPUUsage) >= 2 {
|
| usage = sample.GPUUsage[1]
|
|
|
| if usage <= 1.0 {
|
| usage *= 100
|
| }
|
| }
|
|
|
|
|
| if usage == 0 && sample.GPUPower == 0 && sample.Temp.GPUTempAvg == 0 {
|
| return false
|
| }
|
|
|
| gm.Lock()
|
| defer gm.Unlock()
|
|
|
| gpu, ok := gm.GpuDataMap[appleGPUID]
|
| if !ok {
|
| gpu = &system.GPUData{Name: "Apple GPU"}
|
| gm.GpuDataMap[appleGPUID] = gpu
|
| }
|
| gpu.Temperature = sample.Temp.GPUTempAvg
|
| gpu.Usage += usage
|
|
|
| gpu.Power += sample.GPUPower + sample.GPURAMPower
|
| gpu.Count++
|
| return true
|
| }
|
|
|