diff --git a/internal/metrics/cputemp_test.go b/internal/metrics/cputemp_test.go new file mode 100644 index 0000000..c7ffb2a --- /dev/null +++ b/internal/metrics/cputemp_test.go @@ -0,0 +1,16 @@ +package metrics + +import "testing" + +// a two-socket board: two coretemp chips, and the CPU's temperature is the hotter package +func TestCPUTempHottestPackage(t *testing.T) { + temps := []Temp{ + {Chip: "coretemp", Label: "Core 0", C: 31}, + {Chip: "coretemp", Label: "Core 0", C: 44, Pkg: 1}, + {Chip: "coretemp", Label: "Package id 0", C: 35}, + {Chip: "coretemp", Label: "Package id 1", C: 41, Pkg: 1}, + } + if got := cpuTemp(temps); got != 41 { + t.Fatalf("cpuTemp = %v, want 41", got) + } +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 872d2df..22725c8 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -60,6 +60,7 @@ type Temp struct { C float64 `json:"c"` Max float64 `json:"max,omitempty"` Crit float64 `json:"crit,omitempty"` + Pkg int `json:"pkg,omitempty"` // coretemp: the CPU package the chip belongs to — a two-socket board has two chips, both with a "Core 0" } type Fan struct { @@ -544,6 +545,19 @@ func hwmonDir(d string) (chip string, temps []Temp, fans []Fan) { fans = append(fans, Fan{Chip: chip, Label: label, RPM: int(raw)}) } } + // coretemp is one chip per CPU package, and only its "Package id N" says which + if chip == "coretemp" { + for _, t := range temps { + if n, ok := strings.CutPrefix(t.Label, "Package id "); ok { + if pkg, err := strconv.Atoi(n); err == nil { + for i := range temps { + temps[i].Pkg = pkg + } + } + break + } + } + } return } @@ -559,7 +573,10 @@ func hwmon() ([]Temp, []Fan) { if temps[i].Chip != temps[j].Chip { return temps[i].Chip < temps[j].Chip } - return temps[i].Label < temps[j].Label + if temps[i].Label != temps[j].Label { + return temps[i].Label < temps[j].Label + } + return temps[i].Pkg < temps[j].Pkg }) return temps, fans } @@ -1112,6 +1129,38 @@ func coreIDs(n int) []int { return coreIDsVal[:n] } +// corePkgs maps logical CPUs to the CPU package (socket) each is on, and is nil on a +// one-socket machine: there is nothing to tell apart. +var ( + corePkgsOnce sync.Once + corePkgsVal []int +) + +func corePkgs(n int) []int { + corePkgsOnce.Do(func() { + seen := map[int]bool{} + var out []int + for i := 0; ; i++ { + b, err := os.ReadFile("/sys/devices/system/cpu/cpu" + strconv.Itoa(i) + "/topology/physical_package_id") + if err != nil { + break + } + id, err := strconv.Atoi(strings.TrimSpace(string(b))) + if err != nil { + break + } + out, seen[id] = append(out, id), true + } + if len(seen) > 1 { + corePkgsVal = out + } + }) + if len(corePkgsVal) < n { + return nil + } + return corePkgsVal[:n] +} + // ProcTable is the Processes tab payload. type ProcTable struct { T int64 `json:"t"` @@ -1123,7 +1172,8 @@ type ProcTable struct { 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) + 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"` @@ -1182,18 +1232,24 @@ func cpuMHz() float64 { // cpuTemp picks the sensor that best represents the CPU package. func cpuTemp(temps []Temp) float64 { - best, max := 0.0, 0.0 + // the hottest package: a two-socket board has two, and the hotter one is the one to watch + pkg, max := 0.0, 0.0 for _, t := range temps { l := strings.ToLower(t.Label) if t.Chip == "coretemp" && strings.HasPrefix(l, "package") || t.Chip == "k10temp" && (l == "tctl" || l == "tdie") || t.Chip == "thermal" && strings.Contains(l, "cpu") || t.Chip == "cpu_thermal" || t.Chip == "soc_thermal" { - return t.C + if t.C > pkg { + pkg = t.C + } } if (t.Chip == "coretemp" || t.Chip == "k10temp") && t.C > max { - max, best = t.C, t.C + max = t.C } } - return best + if pkg > 0 { + return pkg + } + return max } var bootTime = func() int64 { @@ -1323,6 +1379,7 @@ func (c *Collector) Processes() ProcTable { 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.CoreIDs = coreIDs(len(last.Cores)) + t.CorePkgs = corePkgs(len(last.Cores)) from := n - 90 if from < 0 { from = 0 diff --git a/web/src/Hardware.tsx b/web/src/Hardware.tsx index beb003f..220b80c 100644 --- a/web/src/Hardware.tsx +++ b/web/src/Hardware.tsx @@ -158,6 +158,10 @@ export default function Hardware() { const capacity = drives.reduce((n, d) => n + d.size, 0) const threads = cpu.threads ?? 0 const cores = cpu.cores ?? 0 + // a two-socket board is two processors, not one with twice the cores: count and name each + const sockets = Math.max(1, cpu.sockets ?? 1) + const multi = sockets > 1 + const per = (n: number) => Math.round(n / sockets) return (
1 ? ` · ${cpu.threads_per_core} threads each` : ''}` : ''} /> + {multi &&
} +1 ? ` · ${cpu.threads_per_core} threads each` : ''}` : ''} /> + {multi && threads > 0 &&
} - + diff --git a/web/src/Top.tsx b/web/src/Top.tsx index 54f010b..470db38 100644 --- a/web/src/Top.tsx +++ b/web/src/Top.tsx @@ -3,7 +3,7 @@ // as they climb. The numbers are Ham Lab's own — the sensor stream for everything measured and // /api/procs for the table — so this works on any machine the console can see, whether or not // btop is installed on it. -import { createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { Fragment, createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { api, GPU, ProcTable, Proc, Sample } from './api' import { Catalog, MONITOR_SHAPE, Values, mergeValues, sampleOf, stale } from './sampleAdapter' import { fetchCatalog, fetchHistories, useSensorStream } from './sensors' @@ -360,7 +360,13 @@ export default function Top() { const model = (pt?.cpu_model ?? '').replace(/\(R\)|\(TM\)|CPU|Processor/g, '').replace(/@.*$/, '').replace(/^\d+th Gen\s*/i, '').replace(/\s+/g, ' ').trim() // the hottest thing that looks like a CPU sensor, and one per core where the chip names them; // a second thread of a core (CPU 8 on an 8-core chip with hyper-threading) wears its core's - const coreTemp = (i: number) => { const id = pt?.core_ids?.[i] ?? i; return s?.temps.find((t) => new RegExp(`core ${id}\\b`, 'i').test(t.label))?.c } + // On a two-socket board each CPU has its own sensor chip, both counting from "Core 0", so a + // thread's core is looked up on its own socket's chip. + const pkgs = pt?.core_pkgs?.length === cores.length ? pt.core_pkgs : undefined + const sockets = pkgs ? [...new Set(pkgs)].sort((a, b) => a - b) : [] + const coreTemp = (i: number) => { const id = pt?.core_ids?.[i] ?? i, pk = pkgs?.[i]; return s?.temps.find((t) => new RegExp(`core ${id}\\b`, 'i').test(t.label) && (pk === undefined || (t.pkg ?? 0) === pk))?.c } + const sockTemp = (pk: number) => s?.temps.find((t) => t.chip === 'coretemp' && new RegExp(`^package id ${pk}$`, 'i').test(t.label))?.c + const sockUse = (pk: number) => { const v = cores.filter((_, i) => pkgs?.[i] === pk); return v.length ? v.reduce((a, b) => a + b, 0) / v.length : 0 } const cpuTemp = pt?.cpu_temp ?? s?.temps.find((t) => /package|tctl|tdie|cpu/i.test(t.label))?.c ?? coreTemp(0) // the temperature columns are there for every row once any row has one, so the % column lines up const anyTemp = cpuTemp !== undefined || cores.some((_, i) => coreTemp(i) !== undefined) @@ -403,8 +409,8 @@ export default function Top() {