1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-04 19:09:43 +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
CoreCount int
LogicalCPU int
PCoreCount int // Performance cores (Apple Silicon)
ECoreCount int // Efficiency cores (Apple Silicon)
}
type GPUStatus struct {
@@ -70,6 +72,7 @@ type GPUStatus struct {
Usage float64
MemoryUsed float64
MemoryTotal float64
CoreCount int
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{
Usage: totalPercent,
PerCore: percents,
@@ -72,6 +75,8 @@ func collectCPU() (CPUStatus, error) {
Load15: loadAvg.Load15,
CoreCount: counts,
LogicalCPU: logical,
PCoreCount: pCores,
ECoreCount: eCores,
}, nil
}
@@ -79,6 +84,55 @@ func isZeroLoad(avg load.AvgStat) bool {
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) {
if !commandExists("uptime") {
return load.AvgStat{}, errors.New("uptime command unavailable")

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"regexp"
"runtime"
"strconv"
"strings"
@@ -13,17 +14,35 @@ import (
const (
systemProfilerTimeout = 4 * time.Second
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) {
if runtime.GOOS == "darwin" {
if len(c.cachedGPU) > 0 && !c.lastGPUAt.IsZero() && now.Sub(c.lastGPUAt) < macGPUInfoTTL {
return c.cachedGPU, nil
// Get static GPU info (cached for 10 min)
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
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"`
Vendor string `json:"spdisplays_vendor"`
Metal string `json:"spdisplays_metal"`
Cores string `json:"sppci_cores"`
} `json:"SPDisplaysDataType"`
}
if err := json.Unmarshal([]byte(out), &data); err != nil {
@@ -113,10 +133,12 @@ func readMacGPUInfo() ([]GPUStatus, error) {
noteParts = append(noteParts, d.Vendor)
}
note := strings.Join(noteParts, " · ")
coreCount, _ := strconv.Atoi(d.Cores)
gpus = append(gpus, GPUStatus{
Name: d.Name,
Usage: -1,
Note: note,
Name: d.Name,
Usage: -1, // Will be updated with real-time data
CoreCount: coreCount,
Note: note,
})
}
@@ -129,3 +151,36 @@ func readMacGPUInfo() ([]GPUStatus, error) {
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),
renderNetworkCard(m.Network, m.Proxy),
}
// Only show GPU card if there are GPUs with usage data
if len(m.GPU) > 0 && m.GPU[0].Usage >= 0 {
// Only show GPU card if there are GPUs with usage data or core count
if len(m.GPU) > 0 && (m.GPU[0].Usage >= 0 || m.GPU[0].CoreCount > 0) {
cards = append(cards, renderGPUCard(m.GPU))
}
// Only show sensors if we have valid temperature readings
@@ -186,12 +186,18 @@ func hasSensorData(sensors []SensorReading) bool {
func renderCPUCard(cpu CPUStatus) cardData {
var lines []string
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 {
lines = append(lines, subtleStyle.Render("Per-core data unavailable (using averaged load)"))
} else if len(cpu.PerCore) > 0 {
// Show top 3 busiest cores
type coreUsage struct {
idx int
val float64
@@ -221,11 +227,16 @@ func renderGPUCard(gpus []GPUStatus) cardData {
lines = append(lines, subtleStyle.Render("No GPU detected"))
} else {
for _, g := range gpus {
name := shorten(g.Name, 12)
if g.Usage >= 0 {
lines = append(lines, fmt.Sprintf("%-12s %s %5.1f%%", name, progressBar(g.Usage), g.Usage))
} else {
lines = append(lines, name)
lines = append(lines, fmt.Sprintf("Total %s %5.1f%%", progressBar(g.Usage), g.Usage))
}
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"))
}
}
}