1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-16 05:26:11 +00:00

Enhance CPU/GPU display for Apple Silicon

- Show all CPU cores instead of top 3 busiest
- Add P-CPU/E-CPU core grouping (Performance/Efficiency)
- Display GPU core count from system_profiler
- Add GPU usage meter via powermetrics (requires sudo)
- Show hint to run with sudo for GPU metrics

Tested on M2 Pro (6P + 4E cores, 16 GPU cores)
This commit is contained in:
bsisduck
2025-12-07 17:12:48 +01:00
parent e7fd73302d
commit 32ac98f06a
5 changed files with 157 additions and 35 deletions

Binary file not shown.

View File

@@ -63,6 +63,8 @@ type CPUStatus struct {
Load15 float64 Load15 float64
CoreCount int CoreCount int
LogicalCPU int LogicalCPU int
PCoreCount int // Performance cores (Apple Silicon)
ECoreCount int // Efficiency cores (Apple Silicon)
} }
type GPUStatus struct { type GPUStatus struct {
@@ -70,6 +72,7 @@ type GPUStatus struct {
Usage float64 Usage float64
MemoryUsed float64 MemoryUsed float64
MemoryTotal float64 MemoryTotal float64
CoreCount int
Note string Note string
} }

View File

@@ -63,6 +63,9 @@ func collectCPU() (CPUStatus, error) {
} }
} }
// Get P-core and E-core counts for Apple Silicon
pCores, eCores := getCoreTopology()
return CPUStatus{ return CPUStatus{
Usage: totalPercent, Usage: totalPercent,
PerCore: percents, PerCore: percents,
@@ -72,6 +75,8 @@ func collectCPU() (CPUStatus, error) {
Load15: loadAvg.Load15, Load15: loadAvg.Load15,
CoreCount: counts, CoreCount: counts,
LogicalCPU: logical, LogicalCPU: logical,
PCoreCount: pCores,
ECoreCount: eCores,
}, nil }, nil
} }
@@ -79,6 +84,55 @@ func isZeroLoad(avg load.AvgStat) bool {
return avg.Load1 == 0 && avg.Load5 == 0 && avg.Load15 == 0 return avg.Load1 == 0 && avg.Load5 == 0 && avg.Load15 == 0
} }
// getCoreTopology returns P-core and E-core counts on Apple Silicon.
// Returns (0, 0) on non-Apple Silicon or if detection fails.
func getCoreTopology() (pCores, eCores int) {
if runtime.GOOS != "darwin" {
return 0, 0
}
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
// Get performance level info from sysctl
out, err := runCmd(ctx, "sysctl", "-n",
"hw.perflevel0.logicalcpu",
"hw.perflevel0.name",
"hw.perflevel1.logicalcpu",
"hw.perflevel1.name")
if err != nil {
return 0, 0
}
lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) < 4 {
return 0, 0
}
// Parse perflevel0
level0Count, _ := strconv.Atoi(strings.TrimSpace(lines[0]))
level0Name := strings.ToLower(strings.TrimSpace(lines[1]))
// Parse perflevel1
level1Count, _ := strconv.Atoi(strings.TrimSpace(lines[2]))
level1Name := strings.ToLower(strings.TrimSpace(lines[3]))
// Assign based on name (Performance vs Efficiency)
if strings.Contains(level0Name, "performance") {
pCores = level0Count
} else if strings.Contains(level0Name, "efficiency") {
eCores = level0Count
}
if strings.Contains(level1Name, "performance") {
pCores = level1Count
} else if strings.Contains(level1Name, "efficiency") {
eCores = level1Count
}
return pCores, eCores
}
func fallbackLoadAvgFromUptime() (load.AvgStat, error) { func fallbackLoadAvgFromUptime() (load.AvgStat, error) {
if !commandExists("uptime") { if !commandExists("uptime") {
return load.AvgStat{}, errors.New("uptime command unavailable") return load.AvgStat{}, errors.New("uptime command unavailable")

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"regexp"
"runtime" "runtime"
"strconv" "strconv"
"strings" "strings"
@@ -13,17 +14,29 @@ import (
const ( const (
systemProfilerTimeout = 4 * time.Second systemProfilerTimeout = 4 * time.Second
macGPUInfoTTL = 10 * time.Minute macGPUInfoTTL = 10 * time.Minute
powermetricsTimeout = 2 * time.Second
) )
func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) { func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) {
if runtime.GOOS == "darwin" { if runtime.GOOS == "darwin" {
if len(c.cachedGPU) > 0 && !c.lastGPUAt.IsZero() && now.Sub(c.lastGPUAt) < macGPUInfoTTL { // Get static GPU info (cached for 10 min)
return c.cachedGPU, nil if len(c.cachedGPU) == 0 || c.lastGPUAt.IsZero() || now.Sub(c.lastGPUAt) >= macGPUInfoTTL {
if gpus, err := readMacGPUInfo(); err == nil && len(gpus) > 0 {
c.cachedGPU = gpus
c.lastGPUAt = now
}
} }
if gpus, err := readMacGPUInfo(); err == nil && len(gpus) > 0 {
c.cachedGPU = gpus // Get real-time GPU usage
c.lastGPUAt = now if len(c.cachedGPU) > 0 {
return gpus, nil usage := getMacGPUUsage()
result := make([]GPUStatus, len(c.cachedGPU))
copy(result, c.cachedGPU)
// Apply usage to first GPU (Apple Silicon has one integrated GPU)
if len(result) > 0 {
result[0].Usage = usage
}
return result, nil
} }
} }
@@ -91,6 +104,7 @@ func readMacGPUInfo() ([]GPUStatus, error) {
VRAM string `json:"spdisplays_vram"` VRAM string `json:"spdisplays_vram"`
Vendor string `json:"spdisplays_vendor"` Vendor string `json:"spdisplays_vendor"`
Metal string `json:"spdisplays_metal"` Metal string `json:"spdisplays_metal"`
Cores string `json:"sppci_cores"`
} `json:"SPDisplaysDataType"` } `json:"SPDisplaysDataType"`
} }
if err := json.Unmarshal([]byte(out), &data); err != nil { if err := json.Unmarshal([]byte(out), &data); err != nil {
@@ -113,10 +127,12 @@ func readMacGPUInfo() ([]GPUStatus, error) {
noteParts = append(noteParts, d.Vendor) noteParts = append(noteParts, d.Vendor)
} }
note := strings.Join(noteParts, " · ") note := strings.Join(noteParts, " · ")
coreCount, _ := strconv.Atoi(d.Cores)
gpus = append(gpus, GPUStatus{ gpus = append(gpus, GPUStatus{
Name: d.Name, Name: d.Name,
Usage: -1, Usage: -1, // Will be updated with real-time data
Note: note, CoreCount: coreCount,
Note: note,
}) })
} }
@@ -129,3 +145,38 @@ func readMacGPUInfo() ([]GPUStatus, error) {
return gpus, nil return gpus, nil
} }
// getMacGPUUsage gets GPU active residency from powermetrics.
// Returns -1 if unavailable (e.g., not running as root).
func getMacGPUUsage() float64 {
ctx, cancel := context.WithTimeout(context.Background(), powermetricsTimeout)
defer cancel()
// powermetrics requires root, but we try anyway - some systems may have it enabled
out, err := runCmd(ctx, "powermetrics", "--samplers", "gpu_power", "-i", "500", "-n", "1")
if err != nil {
return -1
}
// Parse "GPU HW active residency: X.XX%"
re := regexp.MustCompile(`GPU HW active residency:\s+([\d.]+)%`)
matches := re.FindStringSubmatch(out)
if len(matches) >= 2 {
usage, err := strconv.ParseFloat(matches[1], 64)
if err == nil {
return usage
}
}
// Fallback: parse "GPU idle residency: X.XX%" and calculate active
reIdle := regexp.MustCompile(`GPU idle residency:\s+([\d.]+)%`)
matchesIdle := reIdle.FindStringSubmatch(out)
if len(matchesIdle) >= 2 {
idle, err := strconv.ParseFloat(matchesIdle[1], 64)
if err == nil {
return 100.0 - idle
}
}
return -1
}

View File

@@ -2,7 +2,6 @@ package main
import ( import (
"fmt" "fmt"
"sort"
"strconv" "strconv"
"strings" "strings"
@@ -163,8 +162,8 @@ func buildCards(m MetricsSnapshot, _ int) []cardData {
renderProcessCard(m.TopProcesses), renderProcessCard(m.TopProcesses),
renderNetworkCard(m.Network, m.Proxy), renderNetworkCard(m.Network, m.Proxy),
} }
// Only show GPU card if there are GPUs with usage data // Only show GPU card if there are GPUs with usage data or core count
if len(m.GPU) > 0 && m.GPU[0].Usage >= 0 { if len(m.GPU) > 0 && (m.GPU[0].Usage >= 0 || m.GPU[0].CoreCount > 0) {
cards = append(cards, renderGPUCard(m.GPU)) cards = append(cards, renderGPUCard(m.GPU))
} }
// Only show sensors if we have valid temperature readings // Only show sensors if we have valid temperature readings
@@ -186,29 +185,36 @@ func hasSensorData(sensors []SensorReading) bool {
func renderCPUCard(cpu CPUStatus) cardData { func renderCPUCard(cpu CPUStatus) cardData {
var lines []string var lines []string
lines = append(lines, fmt.Sprintf("Total %s %5.1f%%", progressBar(cpu.Usage), cpu.Usage)) lines = append(lines, fmt.Sprintf("Total %s %5.1f%%", progressBar(cpu.Usage), cpu.Usage))
lines = append(lines, subtleStyle.Render(fmt.Sprintf("%.2f / %.2f / %.2f (%d cores)", cpu.Load1, cpu.Load5, cpu.Load15, cpu.LogicalCPU)))
// Show core topology info if available (Apple Silicon)
if cpu.PCoreCount > 0 && cpu.ECoreCount > 0 {
lines = append(lines, subtleStyle.Render(fmt.Sprintf("%.2f / %.2f / %.2f (%dP + %dE cores)",
cpu.Load1, cpu.Load5, cpu.Load15, cpu.PCoreCount, cpu.ECoreCount)))
} else {
lines = append(lines, subtleStyle.Render(fmt.Sprintf("%.2f / %.2f / %.2f (%d cores)",
cpu.Load1, cpu.Load5, cpu.Load15, cpu.LogicalCPU)))
}
if cpu.PerCoreEstimated { if cpu.PerCoreEstimated {
lines = append(lines, subtleStyle.Render("Per-core data unavailable (using averaged load)")) lines = append(lines, subtleStyle.Render("Per-core data unavailable (using averaged load)"))
} else if len(cpu.PerCore) > 0 { } else if len(cpu.PerCore) > 0 {
// Show top 3 busiest cores // Apple Silicon: Group cores into P-CPU and E-CPU
type coreUsage struct { if cpu.PCoreCount > 0 && cpu.ECoreCount > 0 {
idx int // P-cores (Performance) come first
val float64 lines = append(lines, titleStyle.Render("P-CPU"))
} for i := 0; i < cpu.PCoreCount && i < len(cpu.PerCore); i++ {
var cores []coreUsage lines = append(lines, fmt.Sprintf("Core%-2d %s %5.1f%%", i+1, progressBar(cpu.PerCore[i]), cpu.PerCore[i]))
for i, v := range cpu.PerCore { }
cores = append(cores, coreUsage{i, v}) // E-cores (Efficiency) come after P-cores
} lines = append(lines, titleStyle.Render("E-CPU"))
sort.Slice(cores, func(i, j int) bool { return cores[i].val > cores[j].val }) for i := cpu.PCoreCount; i < cpu.PCoreCount+cpu.ECoreCount && i < len(cpu.PerCore); i++ {
lines = append(lines, fmt.Sprintf("Core%-2d %s %5.1f%%", i+1, progressBar(cpu.PerCore[i]), cpu.PerCore[i]))
maxCores := 3 }
if len(cores) < maxCores { } else {
maxCores = len(cores) // Non-Apple Silicon: Show all cores without grouping
} for i, v := range cpu.PerCore {
for i := 0; i < maxCores; i++ { lines = append(lines, fmt.Sprintf("Core%-2d %s %5.1f%%", i+1, progressBar(v), v))
c := cores[i] }
lines = append(lines, fmt.Sprintf("Core%-2d %s %5.1f%%", c.idx+1, progressBar(c.val), c.val))
} }
} }
@@ -221,11 +227,19 @@ func renderGPUCard(gpus []GPUStatus) cardData {
lines = append(lines, subtleStyle.Render("No GPU detected")) lines = append(lines, subtleStyle.Render("No GPU detected"))
} else { } else {
for _, g := range gpus { for _, g := range gpus {
name := shorten(g.Name, 12) // Line 1: Usage bar (if available)
if g.Usage >= 0 { if g.Usage >= 0 {
lines = append(lines, fmt.Sprintf("%-12s %s %5.1f%%", name, progressBar(g.Usage), g.Usage)) lines = append(lines, fmt.Sprintf("Total %s %5.1f%%", progressBar(g.Usage), g.Usage))
} else { }
lines = append(lines, name) // Line 2: GPU name and core count
coreInfo := ""
if g.CoreCount > 0 {
coreInfo = fmt.Sprintf(" (%d cores)", g.CoreCount)
}
lines = append(lines, subtleStyle.Render(g.Name+coreInfo))
// Line 3: Hint for sudo if usage unavailable
if g.Usage < 0 {
lines = append(lines, subtleStyle.Render("Run with sudo for usage metrics"))
} }
} }
} }