diff --git a/internal/metrics/cpupower.go b/internal/metrics/cpupower.go index f85edc3..6d1af30 100644 --- a/internal/metrics/cpupower.go +++ b/internal/metrics/cpupower.go @@ -95,30 +95,41 @@ func readRAPL(root string, dirs []string) ([]raplCount, bool) { // is carried across its range. One that went backwards with no known range, or a set of // counters that changed between readings, gives no answer rather than a wrong one. func raplWatts(prev, cur []raplCount, max map[string]uint64, dt float64) (float64, bool) { - if dt <= 0 || len(prev) == 0 || len(prev) != len(cur) { - return 0, false + each, ok := raplWattsEach(prev, cur, max, dt) + var w float64 + for _, x := range each { + w += x } - var uj float64 + return w, ok +} + +// raplWattsEach is raplWatts per counter: one package each. +func raplWattsEach(prev, cur []raplCount, max map[string]uint64, dt float64) ([]float64, bool) { + if dt <= 0 || len(prev) == 0 || len(prev) != len(cur) { + return nil, false + } + out := make([]float64, len(cur)) for i := range cur { if cur[i].Dir != prev[i].Dir { - return 0, false + return nil, false } if cur[i].UJ >= prev[i].UJ { - uj += float64(cur[i].UJ - prev[i].UJ) + out[i] = float64(cur[i].UJ-prev[i].UJ) / 1e6 / dt continue } m := max[cur[i].Dir] if m == 0 || prev[i].UJ > m { - return 0, false + return nil, false } - uj += float64(m - prev[i].UJ + cur[i].UJ) + out[i] = float64(m-prev[i].UJ+cur[i].UJ) / 1e6 / dt } - return uj / 1e6 / dt, true + return out, true } // cpuPower is the CPU packages' power since the previous sample, in watts; 0 when this -// machine has no counters or no way to read them. -func (c *Collector) cpuPower(now time.Time) float64 { +// machine has no counters or no way to read them. On a machine with more than one package +// it also gives each one's, indexed by package id (the socket). +func (c *Collector) cpuPower(now time.Time) (float64, []float64) { p := &c.power if !p.probed { p.probed = true @@ -131,7 +142,7 @@ func (c *Collector) cpuPower(now time.Time) float64 { } } if len(p.dirs) == 0 { - return 0 + return 0, nil } var cur []raplCount ok := false @@ -148,14 +159,40 @@ func (c *Collector) cpuPower(now time.Time) float64 { } if !ok { p.prev = nil - return 0 + return 0, nil } - w, good := raplWatts(p.prev, cur, p.max, now.Sub(p.prevAt).Seconds()) + each, good := raplWattsEach(p.prev, cur, p.max, now.Sub(p.prevAt).Seconds()) p.prev, p.prevAt = cur, now if !good { - return 0 + return 0, nil } - return round1(w) + var w float64 + for _, x := range each { + w += x + } + return round1(w), perPackage(cur, each) +} + +// perPackage puts each counter's watts at its package's id — intel-rapl:1 is "package-1" — +// and is nil for a single package, whose figure is the total. +func perPackage(cur []raplCount, each []float64) []float64 { + if len(cur) < 2 { + return nil + } + var out []float64 + for i, c := range cur { + id := i + if n, ok := strings.CutPrefix(strings.TrimSpace(readStr(filepath.Join(raplRoot, c.Dir, "name"))), "package-"); ok { + if v, err := strconv.Atoi(n); err == nil && v >= 0 && v < 64 { + id = v + } + } + for len(out) <= id { + out = append(out, 0) + } + out[id] = round1(each[i]) + } + return out } // energyHelper is the root child that answers each newline with the package counters, one diff --git a/internal/metrics/fold.go b/internal/metrics/fold.go index 2ba7aa8..3662342 100644 --- a/internal/metrics/fold.go +++ b/internal/metrics/fold.go @@ -43,6 +43,21 @@ func fold(b []Sample) Sample { if pn > 0 { out.CPUPower = round1(pw / pn) } + // and each package's, the same way + out.CPUPowerPkgs = nil + for _, s := range b { + for i, w := range s.CPUPowerPkgs { + for len(out.CPUPowerPkgs) <= i { + out.CPUPowerPkgs = append(out.CPUPowerPkgs, 0) + } + out.CPUPowerPkgs[i] += w + } + } + if pn > 0 { + for i := range out.CPUPowerPkgs { + out.CPUPowerPkgs[i] = round1(out.CPUPowerPkgs[i] / pn) + } + } out.Load[0] = sum(func(s Sample) float64 { return s.Load[0] }) out.Load[1] = sum(func(s Sample) float64 { return s.Load[1] }) out.Load[2] = sum(func(s Sample) float64 { return s.Load[2] }) diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 22725c8..285541b 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -136,24 +136,25 @@ type Proc struct { } type Sample struct { - T int64 `json:"t"` // unix ms - Interval int64 `json:"interval_ms"` // sampling interval in effect - CPU float64 `json:"cpu"` - Cores []float64 `json:"cores"` - CPUPower float64 `json:"cpu_power_w,omitempty"` // the CPU packages' own energy counters; 0 = no reading - Load [3]float64 `json:"load"` - Mem Mem `json:"mem"` - Swap Mem `json:"swap"` - Temps []Temp `json:"temps"` - Fans []Fan `json:"fans"` - Net []Net `json:"net"` - Disks []Disk `json:"disks"` - FS []FS `json:"fs"` - UPS *UPS `json:"ups"` - GPUs []GPU `json:"gpus"` - ARC *ARC `json:"arc"` // the ZFS read cache; nil without the module - ZFSWrite *ZFSWrites `json:"zfs_writes"` // the ZFS write path; nil without the module - Uptime float64 `json:"uptime"` + T int64 `json:"t"` // unix ms + Interval int64 `json:"interval_ms"` // sampling interval in effect + CPU float64 `json:"cpu"` + Cores []float64 `json:"cores"` + CPUPower float64 `json:"cpu_power_w,omitempty"` // the CPU packages' own energy counters; 0 = no reading + CPUPowerPkgs []float64 `json:"cpu_power_pkgs_w,omitempty"` // each package's share, by socket; only with more than one + Load [3]float64 `json:"load"` + Mem Mem `json:"mem"` + Swap Mem `json:"swap"` + Temps []Temp `json:"temps"` + Fans []Fan `json:"fans"` + Net []Net `json:"net"` + Disks []Disk `json:"disks"` + FS []FS `json:"fs"` + UPS *UPS `json:"ups"` + GPUs []GPU `json:"gpus"` + ARC *ARC `json:"arc"` // the ZFS read cache; nil without the module + ZFSWrite *ZFSWrites `json:"zfs_writes"` // the ZFS write path; nil without the module + Uptime float64 `json:"uptime"` fsT, upsT int64 // when the slow sections were really read; sensors report these, not T } @@ -350,7 +351,7 @@ func (c *Collector) sample() { s := Sample{T: now.UnixMilli(), Interval: c.Interval().Milliseconds(), Temps: []Temp{}, Fans: []Fan{}, Net: []Net{}, Disks: []Disk{}} s.CPU, s.Cores = c.cpu() - s.CPUPower = c.cpuPower(now) + s.CPUPower, s.CPUPowerPkgs = c.cpuPower(now) s.Load = loadavg() s.Mem, s.Swap = meminfo() s.Uptime = uptime() @@ -1163,26 +1164,27 @@ func corePkgs(n int) []int { // ProcTable is the Processes tab payload. type ProcTable struct { - T int64 `json:"t"` - CPU float64 `json:"cpu"` - Cores []float64 `json:"cores"` - CPUHist []float64 `json:"cpu_hist"` // recent total-CPU samples, oldest first - CPUModel string `json:"cpu_model,omitempty"` - CPUMHz float64 `json:"cpu_mhz,omitempty"` - CPUMaxMHz float64 `json:"cpu_max_mhz,omitempty"` - CPUPowerW float64 `json:"cpu_power_w,omitempty"` - CPUTemp float64 `json:"cpu_temp,omitempty"` - CoreIDs []int `json:"core_ids,omitempty"` // for each logical CPU, the physical core it is a thread of (what the temperature sensors are named by) - CorePkgs []int `json:"core_pkgs,omitempty"` // for each logical CPU, the socket it is on; only on a machine with more than one - Load [3]float64 `json:"load"` - Mem Mem `json:"mem"` - Swap Mem `json:"swap"` - Net []Net `json:"net"` - GPUs []GPU `json:"gpus"` - Uptime float64 `json:"uptime"` - Procs []Proc `json:"procs"` - Total int `json:"total"` - Running int `json:"running"` + T int64 `json:"t"` + CPU float64 `json:"cpu"` + Cores []float64 `json:"cores"` + CPUHist []float64 `json:"cpu_hist"` // recent total-CPU samples, oldest first + CPUModel string `json:"cpu_model,omitempty"` + CPUMHz float64 `json:"cpu_mhz,omitempty"` + CPUMaxMHz float64 `json:"cpu_max_mhz,omitempty"` + CPUPowerW float64 `json:"cpu_power_w,omitempty"` + CPUPowerPkgsW []float64 `json:"cpu_power_pkgs_w,omitempty"` + CPUTemp float64 `json:"cpu_temp,omitempty"` + CoreIDs []int `json:"core_ids,omitempty"` // for each logical CPU, the physical core it is a thread of (what the temperature sensors are named by) + CorePkgs []int `json:"core_pkgs,omitempty"` // for each logical CPU, the socket it is on; only on a machine with more than one + Load [3]float64 `json:"load"` + Mem Mem `json:"mem"` + Swap Mem `json:"swap"` + Net []Net `json:"net"` + GPUs []GPU `json:"gpus"` + Uptime float64 `json:"uptime"` + Procs []Proc `json:"procs"` + Total int `json:"total"` + Running int `json:"running"` } var cpuModel = func() string { @@ -1377,7 +1379,7 @@ func (c *Collector) Processes() ProcTable { t.Mem, t.Swap = meminfo() if n := len(c.ring); n > 0 { last := c.ring[n-1] - t.CPU, t.Cores, t.Net, t.CPUTemp, t.CPUPowerW = last.CPU, last.Cores, last.Net, cpuTemp(last.Temps), last.CPUPower + t.CPU, t.Cores, t.Net, t.CPUTemp, t.CPUPowerW, t.CPUPowerPkgsW = last.CPU, last.Cores, last.Net, cpuTemp(last.Temps), last.CPUPower, last.CPUPowerPkgs t.CoreIDs = coreIDs(len(last.Cores)) t.CorePkgs = corePkgs(len(last.Cores)) from := n - 90 diff --git a/internal/metrics/sensors.go b/internal/metrics/sensors.go index 2d338bd..dc905a3 100644 --- a/internal/metrics/sensors.go +++ b/internal/metrics/sensors.go @@ -65,6 +65,10 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor { if s.CPUPower > 0 { f.add("cpu.power", "CPU power", "power", "W", s.CPUPower, t, none) } + for i, w := range s.CPUPowerPkgs { + n := strconv.Itoa(i) + f.add("cpu.package."+n+".power", "CPU "+strconv.Itoa(i+1)+" power", "power", "W", w, t, none, "package", n) + } for i, w := range []string{"1", "5", "15"} { f.add("load."+w, "Load "+w+" min", "load", "", s.Load[i], t, none) } diff --git a/web/src/Top.tsx b/web/src/Top.tsx index 470db38..1a039a3 100644 --- a/web/src/Top.tsx +++ b/web/src/Top.tsx @@ -294,6 +294,13 @@ export default function Top() { // the process table, and the CPU model, come from the same place the Processes page reads, // on the sample's beat (above) const [pickedIf, setPickedIf] = useState(null) // which NIC the net box draws + // which sockets' threads are folded away on a two-socket machine; remembered in this browser + const [folded, setFolded] = useState(() => { try { return JSON.parse(localStorage.getItem('top.folded-cpus') ?? '[]') } catch { return [] } }) + const fold = (pk: number) => setFolded((f) => { + const n = f.includes(pk) ? f.filter((x) => x !== pk) : [...f, pk] + try { localStorage.setItem('top.folded-cpus', JSON.stringify(n)) } catch { /* private window */ } + return n + }) // Which pool each drive belongs to, asked once: a drive in a ZFS pool has no filesystem of // its own, and "nothing is on it" would be a lie about eight drives holding a NAS. const [pools, setPools] = useState>({}) @@ -421,10 +428,11 @@ export default function Top() { {(sockets.length > 1 ? sockets : [undefined]).map((pk) => ( {pk !== undefined && (() => { - const u = sockUse(pk), t = sockTemp(pk) + const u = sockUse(pk), t = sockTemp(pk), w = pt?.cpu_power_pkgs_w?.[pk], open = !folded.includes(pk) return ( -
- CPU{pk + 1} +
fold(pk)}> + {open ? '▾' : '▸'}CPU{pk + 1} + {w ? `${w < 10 ? w.toFixed(1) : Math.round(w)} W` : ''} {Math.round(u)}% {anyTemp && <>{t !== undefined && }{t !== undefined ? `${Math.round(t)}°C` : ''}} @@ -432,7 +440,7 @@ export default function Top() { ) })()} {cores.map((v, i) => { - if (pk !== undefined && pkgs?.[i] !== pk) return null + if (pk !== undefined && (pkgs?.[i] !== pk || folded.includes(pk))) return null const t = coreTemp(i) return (
diff --git a/web/src/api.ts b/web/src/api.ts index 077fcd1..e0a49e4 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -90,7 +90,7 @@ export interface GPU { mhz?: number; req_mhz?: number; max_mhz?: number; mem_used?: number; mem_total?: number clients: GPUClient[]; clients_ok: boolean; stalled?: boolean } -export interface ProcTable { t: number; cpu: number; cores: number[]; cpu_hist: number[]; cpu_model?: string; cpu_mhz?: number; cpu_max_mhz?: number; cpu_power_w?: number; cpu_temp?: number; core_ids?: number[]; core_pkgs?: number[]; load: [number, number, number]; mem: MemInfo; swap: MemInfo; net: NetIf[]; gpus?: GPU[]; uptime: number; procs: Proc[]; total: number; running: number } +export interface ProcTable { t: number; cpu: number; cores: number[]; cpu_hist: number[]; cpu_model?: string; cpu_mhz?: number; cpu_max_mhz?: number; cpu_power_w?: number; cpu_power_pkgs_w?: number[]; cpu_temp?: number; core_ids?: number[]; core_pkgs?: number[]; load: [number, number, number]; mem: MemInfo; swap: MemInfo; net: NetIf[]; gpus?: GPU[]; uptime: number; procs: Proc[]; total: number; running: number } export interface Sample { t: number; interval_ms: number; cpu: number; cores: number[]; load: [number, number, number] mem: { total: number; used: number; avail: number }; swap: { total: number; used: number; avail: number } diff --git a/web/src/styles.css b/web/src/styles.css index 95d57f3..64e0d0b 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -794,8 +794,11 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%, .t-cores-head b { color: var(--fg); font-weight: 400; overflow: hidden; text-overflow: ellipsis; } .t-core { display: flex; align-items: center; gap: 6px; } .t-core-n { width: 30px; } .t-core-p { width: 34px; text-align: right; } -.t-cores.multi .t-core-n { width: 38px; } /* "CPU2" — a two-socket board has a row per socket above its threads */ -.t-sock { color: var(--fg); margin-top: 2px; } +/* a two-socket board: a row per socket — arrow, name, watts — with its threads indented under it */ +.t-cores.multi .t-core-n { width: 50px; } .t-cores.multi .t-core:not(.t-sock) .t-core-n { padding-left: 14px; box-sizing: border-box; } +.t-sock { color: var(--fg); margin-top: 2px; cursor: pointer; user-select: none; } +.t-fold { display: inline-block; width: 12px; color: var(--muted); } +.t-core-w { width: 40px; flex-shrink: 0; text-align: right; color: var(--muted); } .t-core-g { flex: 1; color: var(--ok); letter-spacing: 0; overflow: hidden; white-space: nowrap; } .t-core .tm { flex: 1; } .t-core-t { flex: 0 0 62px; display: flex; } .t-core-c { width: 42px; text-align: right; color: var(--t-blue); } .t-load { display: flex; margin-top: 2px; }