Skip to content

Commit ff4dedd

Browse files
committed
feat(topview): label per-core bars by core type, split Apple cluster power
The per-core grid was anonymous — on a 12P+4E M3 Max you couldn't tell if the pinned cores were P or E. New cpu.topology metric carries the core-type layout, read unprivileged everywhere it exists: sysctl hw.perflevel on Apple Silicon (E-cluster owns the low ids, verified live), cpu_core/cpu_atom sysfs on Intel hybrid Linux, EfficiencyClass on Windows. Uniform CPUs and older daemons keep the plain grid. Also stop throwing away the SMC P/E rail split on Pro/Max — we read those keys anyway to build power.cpu. They now ship as power.cpu.pcluster/ecluster and the POWER panel shows a 'cpu clusters' line. Rails that don't read emit nothing, never a fake 0. Story 0028, closes #5.
1 parent 267e8c0 commit ff4dedd

27 files changed

Lines changed: 2375 additions & 43 deletions

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
77

88
## [Unreleased]
99

10+
## [2.6.0] - 2026-07-02
11+
12+
### Added
13+
- **Top view: per-core grid labelled by core type.** On hybrid silicon the
14+
per-core bars are grouped under `P-cores (N):` / `E-cores (N):` headers with
15+
their real core ids — you can now see *which* cores are pinned. Sources: new
16+
unprivileged `cpu.topology` metric — sysctl `hw.perflevel*` on Apple Silicon
17+
(E-cluster owns the low ids, verified live on an M3 Max),
18+
`/sys/devices/cpu_core|cpu_atom` on Intel hybrid Linux, and
19+
`GetLogicalProcessorInformationEx` EfficiencyClass on Windows. Uniform CPUs
20+
(AMD, non-hybrid Intel, ARM) and older daemons keep the plain grid — new
21+
metric names only, nothing existing changed shape. NARROW tier collapses to
22+
one aggregate per type.
23+
- **Per-cluster CPU power on Apple Pro/Max.** The SMC P/E rails that already
24+
fed `power.cpu` are now surfaced as `power.cpu.pcluster` /
25+
`power.cpu.ecluster`, and the top view's POWER panel shows
26+
`cpu clusters: P … W · E … W` under the CPU figure. Hosts without cluster
27+
rails (base dies, Linux RAPL, Windows) simply don't emit them — a rail that
28+
didn't read is never a fabricated 0. (story 0028, issue #5)
29+
1030
## [2.5.2] - 2026-07-02
1131

1232
### Fixed

app/cmd/daemon/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,12 @@ func formatMetric(m domain.Metric) string {
465465
return fmt.Sprintf("%s=%s", m.Name, m.Status)
466466
}
467467
if m.Kind == domain.KindPerCore && len(m.PerCore) > 0 {
468+
// cpu.topology carries type ids, not percentages — an avg/max summary
469+
// is meaningless there; the detail is the human rendering, compacted
470+
// ("12P+4E") because the print line is space-separated.
471+
if m.Name == "cpu.topology" && m.Detail != "" {
472+
return fmt.Sprintf("%s=%s", m.Name, strings.ReplaceAll(m.Detail, " ", ""))
473+
}
468474
return fmt.Sprintf("%s=%s", m.Name, perCoreValue(m))
469475
}
470476
value, ok := unitValue[m.Unit]

app/internal/adapters/cpu.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import (
1515
type CPU struct{}
1616

1717
func (CPU) Describe() domain.AdapterInfo {
18-
return domain.AdapterInfo{ID: "cpu", Metrics: []string{"cpu.util", "cpu.cores"}}
18+
return domain.AdapterInfo{ID: "cpu", Metrics: []string{"cpu.util", "cpu.cores", "cpu.topology"}}
1919
}
2020

2121
func (CPU) Collect(ctx context.Context) ([]domain.Metric, error) {
@@ -38,6 +38,12 @@ func (CPU) Collect(ctx context.Context) ([]domain.Metric, error) {
3838
Name: "cpu.cores", Unit: "percent", Status: domain.StatusOK,
3939
Kind: domain.KindPerCore, Gauge: avg, PerCore: per,
4040
})
41+
// Core-type layout (P/E on Apple Silicon and Intel hybrid; uniform
42+
// elsewhere) so the top view can label the per-core grid. Omitted when
43+
// no probe answers — the renderer falls back to the unlabelled grid.
44+
if topo, ok := coreTopologyMetric(len(per)); ok {
45+
out = append(out, topo)
46+
}
4147
}
4248
return out, nil
4349
}

app/internal/adapters/cputopo.go

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// Copyright (C) 2026 Kinn Coelho Juliao <kinncj@gmail.com>
3+
4+
package adapters
5+
6+
import (
7+
"fmt"
8+
"strconv"
9+
"strings"
10+
"sync"
11+
12+
"heimdall/app/internal/domain"
13+
)
14+
15+
// Core-type ids carried in cpu.topology's PerCore slice. The renderer maps them
16+
// to labels; lower id = higher-performance tier so 0 is always "P".
17+
const (
18+
corePerf = 0 // performance (P) core
19+
coreEff = 1 // efficiency (E) core
20+
coreLP = 2 // low-power efficiency core (e.g. Intel LP E-cores)
21+
)
22+
23+
var coreTypeLabels = []string{"P", "E", "LP"}
24+
25+
// topoOnce caches the probe: core topology cannot change while the daemon runs,
26+
// and the sysctl/sysfs/syscall reads don't need repeating every collect.
27+
var (
28+
topoOnce sync.Once
29+
topoTypes []int
30+
topoOK bool
31+
)
32+
33+
// coreTopologyMetric returns the cpu.topology metric for a host with `total`
34+
// logical cores, or ok=false when no probe applies (the adapter then omits the
35+
// metric entirely — the renderer falls back to the unlabelled grid).
36+
func coreTopologyMetric(total int) (domain.Metric, bool) {
37+
topoOnce.Do(func() {
38+
topoTypes, topoOK = coreTypes(total)
39+
})
40+
if !topoOK || len(topoTypes) != total {
41+
return domain.Metric{}, false
42+
}
43+
return topologyMetric(topoTypes), true
44+
}
45+
46+
// topologyMetric encodes a core-type slice as the cpu.topology metric:
47+
// PerCore[i] is the type id of logical core i, Gauge is the distinct type
48+
// count, Detail is the human summary ("12P + 4E", "16 cores (uniform)").
49+
func topologyMetric(types []int) domain.Metric {
50+
counts := map[int]int{}
51+
for _, t := range types {
52+
counts[t]++
53+
}
54+
per := make([]float64, len(types))
55+
for i, t := range types {
56+
per[i] = float64(t)
57+
}
58+
detail := ""
59+
if len(counts) <= 1 {
60+
detail = fmt.Sprintf("%d cores (uniform)", len(types))
61+
} else {
62+
var parts []string
63+
for id, label := range coreTypeLabels {
64+
if counts[id] > 0 {
65+
parts = append(parts, fmt.Sprintf("%d%s", counts[id], label))
66+
}
67+
}
68+
detail = strings.Join(parts, " + ")
69+
}
70+
return domain.Metric{
71+
Name: "cpu.topology", Unit: "type", Status: domain.StatusOK,
72+
Kind: domain.KindPerCore, Gauge: float64(len(counts)), PerCore: per,
73+
Detail: detail,
74+
}
75+
}
76+
77+
// typesFromCounts builds a type slice from per-tier core counts (the macOS
78+
// sysctl shape). eFirst says whether the efficiency cluster owns the low
79+
// logical ids (true on Apple Silicon — verified live on an M3 Max: background-
80+
// QoS load lands on c0–c3). Counts that don't add up are refused.
81+
func typesFromCounts(p, e, total int, eFirst bool) ([]int, bool) {
82+
if total <= 0 || p < 0 || e < 0 || p+e != total {
83+
return nil, false
84+
}
85+
out := make([]int, 0, total)
86+
first, second, firstType, secondType := p, e, corePerf, coreEff
87+
if eFirst {
88+
first, second, firstType, secondType = e, p, coreEff, corePerf
89+
}
90+
for i := 0; i < first; i++ {
91+
out = append(out, firstType)
92+
}
93+
for i := 0; i < second; i++ {
94+
out = append(out, secondType)
95+
}
96+
return out, true
97+
}
98+
99+
// typesFromRanges builds a type slice from index-range lists (the Linux
100+
// /sys/devices/cpu_core/cpus + cpu_atom/cpus shape, e.g. "0-15" and "16-23",
101+
// possibly comma-separated). Every core must be claimed by exactly one list and
102+
// stay in bounds, else the probe is refused.
103+
func typesFromRanges(perf, eff string, total int) ([]int, bool) {
104+
if total <= 0 {
105+
return nil, false
106+
}
107+
out := make([]int, total)
108+
for i := range out {
109+
out[i] = -1
110+
}
111+
assign := func(spec string, t int) bool {
112+
idx, ok := parseIndexRanges(spec)
113+
if !ok || len(idx) == 0 {
114+
return false
115+
}
116+
for _, i := range idx {
117+
if i < 0 || i >= total || out[i] != -1 {
118+
return false
119+
}
120+
out[i] = t
121+
}
122+
return true
123+
}
124+
if !assign(perf, corePerf) || !assign(eff, coreEff) {
125+
return nil, false
126+
}
127+
for _, t := range out {
128+
if t == -1 {
129+
return nil, false
130+
}
131+
}
132+
return out, true
133+
}
134+
135+
// parseIndexRanges parses a sysfs cpu list ("0-15", "0-3,8-11", "7").
136+
func parseIndexRanges(spec string) ([]int, bool) {
137+
spec = strings.TrimSpace(spec)
138+
if spec == "" {
139+
return nil, false
140+
}
141+
var out []int
142+
for _, part := range strings.Split(spec, ",") {
143+
part = strings.TrimSpace(part)
144+
lo, hi := part, part
145+
if a, b, found := strings.Cut(part, "-"); found {
146+
lo, hi = a, b
147+
}
148+
l, err1 := strconv.Atoi(lo)
149+
h, err2 := strconv.Atoi(hi)
150+
if err1 != nil || err2 != nil || h < l {
151+
return nil, false
152+
}
153+
for i := l; i <= h; i++ {
154+
out = append(out, i)
155+
}
156+
}
157+
return out, true
158+
}
159+
160+
// typesFromEfficiencyClasses maps Windows per-core EfficiencyClass values
161+
// (ordered by logical core index; a core's class repeats for each of its
162+
// logical CPUs) to type ids. Windows counts efficiency class upward with
163+
// performance — the highest class is the P tier — while our ids count downward
164+
// from P=0, so the mapping inverts.
165+
func typesFromEfficiencyClasses(classes []int) ([]int, bool) {
166+
if len(classes) == 0 {
167+
return nil, false
168+
}
169+
max := classes[0]
170+
for _, c := range classes {
171+
if c < 0 {
172+
return nil, false
173+
}
174+
if c > max {
175+
max = c
176+
}
177+
}
178+
out := make([]int, len(classes))
179+
for i, c := range classes {
180+
t := max - c
181+
if t >= len(coreTypeLabels) {
182+
// more tiers than we can label — refuse rather than mislabel
183+
return nil, false
184+
}
185+
out[i] = t
186+
}
187+
return out, true
188+
}
189+
190+
// uniformTypes is the no-probe fallback: every core the same (performance) type.
191+
func uniformTypes(total int) []int {
192+
out := make([]int, total)
193+
return out
194+
}
195+
196+
// relationProcessorCore is the RelationProcessorCore record tag in Windows'
197+
// GetLogicalProcessorInformationEx output. The parser lives here (pure bytes,
198+
// no syscalls) so it tests on every platform; only the syscall is build-tagged.
199+
const relationProcessorCore = 0
200+
201+
// efficiencyClassesFromBuffer walks SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX
202+
// records (RelationProcessorCore) and maps each logical CPU (group-0 mask bit)
203+
// to its core's EfficiencyClass. Record layout: Relationship u32, Size u32,
204+
// then PROCESSOR_RELATIONSHIP{Flags u8, EfficiencyClass u8, Reserved [20]u8,
205+
// GroupCount u16, GroupMask []GROUP_AFFINITY{Mask u64, Group u16, ...}}.
206+
// Multi-group machines (>64 logical CPUs) are refused — they don't ship hybrid
207+
// silicon, and an unlabelled grid beats a mislabelled one.
208+
func efficiencyClassesFromBuffer(buf []byte, total int) ([]int, bool) {
209+
if total <= 0 || total > 64 {
210+
return nil, false
211+
}
212+
classes := make([]int, total)
213+
for i := range classes {
214+
classes[i] = -1
215+
}
216+
le := func(b []byte) uint64 {
217+
var v uint64
218+
for i := len(b) - 1; i >= 0; i-- {
219+
v = v<<8 | uint64(b[i])
220+
}
221+
return v
222+
}
223+
for off := 0; off+32 <= len(buf); {
224+
rel := uint32(le(buf[off : off+4]))
225+
size := int(le(buf[off+4 : off+8]))
226+
if size <= 0 || off+size > len(buf) {
227+
return nil, false
228+
}
229+
if rel == relationProcessorCore {
230+
effClass := int(buf[off+9])
231+
groupCount := int(le(buf[off+30 : off+32]))
232+
maskOff := off + 32
233+
if groupCount != 1 || maskOff+16 > off+size {
234+
return nil, false
235+
}
236+
mask := le(buf[maskOff : maskOff+8])
237+
group := int(le(buf[maskOff+8 : maskOff+10]))
238+
if group != 0 {
239+
return nil, false
240+
}
241+
for bit := 0; bit < 64; bit++ {
242+
if mask&(1<<bit) == 0 {
243+
continue
244+
}
245+
if bit >= total || classes[bit] != -1 {
246+
return nil, false
247+
}
248+
classes[bit] = effClass
249+
}
250+
}
251+
off += size
252+
}
253+
for _, c := range classes {
254+
if c == -1 {
255+
return nil, false
256+
}
257+
}
258+
return classes, true
259+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// Copyright (C) 2026 Kinn Coelho Juliao <kinncj@gmail.com>
3+
4+
//go:build darwin
5+
6+
package adapters
7+
8+
import "golang.org/x/sys/unix"
9+
10+
// coreTypes probes Apple Silicon's perf-level sysctls (no root, no cgo):
11+
// hw.perflevel0 is the Performance tier, hw.perflevel1 the Efficiency tier.
12+
// The E-cluster owns the low logical core ids on Apple Silicon (cluster 0 is
13+
// the E-cluster in XNU; verified live on an M3 Max — background-QoS load lands
14+
// on c0–c3), so eFirst is true. Intel Macs have no perflevel sysctls and fall
15+
// back to a uniform topology.
16+
func coreTypes(total int) ([]int, bool) {
17+
p, errP := unix.SysctlUint32("hw.perflevel0.logicalcpu")
18+
e, errE := unix.SysctlUint32("hw.perflevel1.logicalcpu")
19+
if errP != nil || errE != nil || e == 0 {
20+
return uniformTypes(total), true
21+
}
22+
return typesFromCounts(int(p), int(e), total, true)
23+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// Copyright (C) 2026 Kinn Coelho Juliao <kinncj@gmail.com>
3+
4+
//go:build linux
5+
6+
package adapters
7+
8+
import "os"
9+
10+
// coreTypes probes Intel hybrid's sysfs core lists (unprivileged):
11+
// /sys/devices/cpu_core/cpus are the P cores, /sys/devices/cpu_atom/cpus the
12+
// E cores, both as index ranges ("0-15", "16-23"). Hosts without the hybrid
13+
// interface (AMD, older Intel, ARM) are uniform.
14+
func coreTypes(total int) ([]int, bool) {
15+
perf, errP := os.ReadFile("/sys/devices/cpu_core/cpus")
16+
eff, errE := os.ReadFile("/sys/devices/cpu_atom/cpus")
17+
if errP != nil || errE != nil {
18+
return uniformTypes(total), true
19+
}
20+
if types, ok := typesFromRanges(string(perf), string(eff), total); ok {
21+
return types, true
22+
}
23+
// hybrid interface present but inconsistent with the visible core count
24+
// (offline cores, torn read) — an unlabelled grid beats a mislabelled one.
25+
return nil, false
26+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// Copyright (C) 2026 Kinn Coelho Juliao <kinncj@gmail.com>
3+
4+
//go:build !darwin && !linux && !windows
5+
6+
package adapters
7+
8+
// coreTypes has no hybrid probe on this platform: uniform topology.
9+
func coreTypes(total int) ([]int, bool) {
10+
return uniformTypes(total), true
11+
}

0 commit comments

Comments
 (0)