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

Merge PR #108: Enhance CPU/GPU display for Apple Silicon

- Add P-core/E-core detection and display (8P+4E format)
- Add GPU core count from system_profiler
- Add real-time GPU usage via powermetrics
- Keep top 3 busiest cores display for clean UI
- Pre-compile regex patterns for better performance

Thanks to @bsisduck for the initial implementation
This commit is contained in:
Tw93
2025-12-08 19:10:03 +08:00
5 changed files with 140 additions and 17 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,35 @@ import (
const ( const (
systemProfilerTimeout = 4 * time.Second systemProfilerTimeout = 4 * time.Second
macGPUInfoTTL = 10 * time.Minute macGPUInfoTTL = 10 * time.Minute
powermetricsTimeout = 2 * time.Second
)
// Pre-compiled regex patterns for GPU usage parsing
var (
gpuActiveResidencyRe = regexp.MustCompile(`GPU HW active residency:\s+([\d.]+)%`)
gpuIdleResidencyRe = regexp.MustCompile(`GPU idle residency:\s+([\d.]+)%`)
) )
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 { if gpus, err := readMacGPUInfo(); err == nil && len(gpus) > 0 {
c.cachedGPU = gpus c.cachedGPU = gpus
c.lastGPUAt = now c.lastGPUAt = now
return gpus, nil }
}
// Get real-time GPU usage
if len(c.cachedGPU) > 0 {
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 +110,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,9 +133,11 @@ 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
CoreCount: coreCount,
Note: note, Note: note,
}) })
} }
@@ -129,3 +151,36 @@ 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%"
matches := gpuActiveResidencyRe.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
matchesIdle := gpuIdleResidencyRe.FindStringSubmatch(out)
if len(matchesIdle) >= 2 {
idle, err := strconv.ParseFloat(matchesIdle[1], 64)
if err == nil {
return 100.0 - idle
}
}
return -1
}

View File

@@ -163,8 +163,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,12 +186,18 @@ 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)))
if cpu.PCoreCount > 0 && cpu.ECoreCount > 0 {
lines = append(lines, subtleStyle.Render(fmt.Sprintf("Load %.2f / %.2f / %.2f (%dP+%dE)",
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
type coreUsage struct { type coreUsage struct {
idx int idx int
val float64 val float64
@@ -221,11 +227,16 @@ 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)
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) coreInfo := ""
if g.CoreCount > 0 {
coreInfo = fmt.Sprintf(" (%d cores)", g.CoreCount)
}
lines = append(lines, subtleStyle.Render(g.Name+coreInfo))
if g.Usage < 0 {
lines = append(lines, subtleStyle.Render("Run with sudo for usage metrics"))
} }
} }
} }