CPU und GPU zwischen Auslastung und Temperatur umschaltbar

Die M-Serie-Konvention gilt auch auf dem M5: Tp* sind die CPU-Kerne, Tg* die
GPU-Cluster. Gezeigt wird der wärmste Punkt, nicht der Durchschnitt —
gedrosselt wird nach dem heißesten Kern. Gemessen: CPU 76 °C, GPU 70 °C.

Die Fühlerliste wird zur Laufzeit gesucht statt fest verdrahtet: welche es
gibt, hängt am Modell. Auf diesem Mac sind es 285 Temperaturfühler.

Zwei Leistungsprobleme, gefunden bevor sie jemandem auffallen konnten:

Die Suche geht alle 3486 Keys durch und braucht knapp eine Sekunde. Auf dem
Hauptthread wäre das eine spürbare Startverzögerung — sie läuft jetzt im
Hintergrund, bis dahin zeigt die Oberfläche "—" statt einer erfundenen Zahl.

Fünfzig Fühler zu lesen kostete 34 ms je Abtastung, bei 1 Hz also 3,4 % CPU.
Zwei Maßnahmen: Typ und Größe je Key werden zwischengespeichert (sie stehen in
der Firmware fest, sie bei jedem Lesen zu erfragen verdoppelte die
IOKit-Aufrufe), und Temperaturen werden höchstens alle zwei Sekunden neu
gelesen — sie bewegen sich in einer Sekunde ohnehin kaum. Ergebnis: 6 ms
Grundlast, 18 ms alle zwei Sekunden, im Mittel gut 1 %.

Dabei noch eine Eigenheit gefunden: die Byte-Reihenfolge im SMC ist nicht
durchgängig. Messwerte wie B0AV kommen little-endian aus dem Akku-Baustein,
die Metadaten des SMC selbst dagegen big-endian. #KEY little-endian gelesen
ergibt 2 651 652 096 statt 3486 — die Fühlersuche brach damit still ab und
alle Temperaturen blieben leer.

Lüfterdrehzahlen stehen jetzt im Sensor-Popover, mit Balken zwischen Minimum
und Maximum der Firmware: "2321 U/min" allein sagt niemandem, ob das viel ist.
Die Steuerung selbst kommt mit Phase 6 — Schreibzugriff auf den SMC verlangt
root — und der Popover sagt das auch.

145 Tests grün.
This commit is contained in:
Guido Schmit
2026-08-10 22:05:38 +02:00
parent 594d7c422a
commit a4cc75ab6e
10 changed files with 914 additions and 520 deletions

View File

@@ -7,6 +7,8 @@ import Darwin
public struct MetricsSnapshot: Equatable, Sendable {
public var cpu = CPUReading()
public var gpu: Double?
/// Wärmster Punkt der GPU in Grad Celsius.
public var gpuTemperature: Double?
public var memory = MemoryReading()
public var battery: BatteryReading?
public var sensors: [SensorReading] = []
@@ -21,6 +23,12 @@ public struct CPUReading: Equatable, Sendable {
public var perCore: [Double] = []
/// Leistungsaufnahme in Watt, falls messbar.
public var watts: Double?
/// Der wärmste Kern in Grad Celsius.
///
/// Apple Silicon hat pro Kern einen Fühler auf dem M5 Pro sind es
/// über zwanzig. Der Höchstwert ist der aussagekräftige: gedrosselt wird
/// nach dem heißesten Punkt, nicht nach dem Durchschnitt.
public var temperature: Double?
}
public struct MemoryReading: Equatable, Sendable {
@@ -76,6 +84,26 @@ public final class SystemMetrics: @unchecked Sendable {
private let smc = SMCReader()
private var previousCPUTicks: [CPUTicks] = []
/// Welche Kern- und GPU-Fühler dieses Modell hat. `Tp*` sind die
/// CPU-Kerne, `Tg*` die GPU-Cluster die Konvention gilt für die ganze
/// M-Serie.
///
/// Die Suche läuft im Hintergrund: sie geht alle 3486 Keys durch und
/// braucht dafür knapp eine Sekunde. Auf dem Hauptthread wäre das eine
/// spürbare Startverzögerung für etwas, das nach dem ersten Messwert
/// niemandem auffällt.
private var temperatureKeys: (cpu: [String], gpu: [String]) = ([], [])
private let keysLock = NSLock()
/// Zwischenspeicher für die Temperaturen.
///
/// Fünfzig Fühler zu lesen kostet gut 15 ms. Bei 1 Hz wären das anderthalb
/// Prozent CPU für Werte, die sich in einer Sekunde kaum bewegen deshalb
/// höchstens alle zwei Sekunden, unabhängig von der Abtastrate ringsum.
private var cachedTemperatures: (cpu: Double?, gpu: Double?) = (nil, nil)
private var temperaturesReadAt = Date.distantPast
private static let temperatureInterval: TimeInterval = 2
/// Seit dem Start unveränderlich einmal ermitteln reicht.
private static let pageSize: UInt64 = {
var size: UInt64 = 0
@@ -86,7 +114,21 @@ public final class SystemMetrics: @unchecked Sendable {
return size
}()
public init() {}
public init() {
// Nicht im Initialisierer blockieren: der läuft beim App-Start.
// `DispatchQueue` statt `Task.detached`: die Suche ist blockierende
// IOKit-Arbeit ohne Suspendierungspunkt, sie gehört nicht in den
// Nebenläufigkeitspool von Swift und `NSLock` ist aus einem
// asynchronen Kontext ohnehin nicht erlaubt.
DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self, let smc else { return }
let cpu = smc.keys(matching: ["Tp"])
let gpu = smc.keys(matching: ["Tg"])
keysLock.lock()
temperatureKeys = (cpu, gpu)
keysLock.unlock()
}
}
public func sample() -> MetricsSnapshot {
var snapshot = MetricsSnapshot()
@@ -97,6 +139,9 @@ public final class SystemMetrics: @unchecked Sendable {
snapshot.sensors = readSensors()
snapshot.fans = readFans()
snapshot.cpu.watts = smc?.float("PSTR")
let temperatures = readTemperatures()
snapshot.cpu.temperature = temperatures.cpu
snapshot.gpuTemperature = temperatures.gpu
return snapshot
}
@@ -273,6 +318,36 @@ public final class SystemMetrics: @unchecked Sendable {
("TH0x", "SSD"),
]
private func readTemperatures() -> (cpu: Double?, gpu: Double?) {
guard Date().timeIntervalSince(temperaturesReadAt) >= Self.temperatureInterval else {
return cachedTemperatures
}
keysLock.lock()
let keys = temperatureKeys
keysLock.unlock()
// Solange die Suche im Hintergrund läuft, bleiben die Werte leer die
// Oberfläche zeigt dann " statt einer erfundenen Zahl.
guard !keys.cpu.isEmpty || !keys.gpu.isEmpty else { return (nil, nil) }
cachedTemperatures = (hottest(of: keys.cpu), hottest(of: keys.gpu))
temperaturesReadAt = Date()
return cachedTemperatures
}
/// Der höchste plausible Messwert einer Fühlergruppe.
///
/// Die Plausibilitätsgrenze ist nötig: einzelne Fühler liefern gelegentlich
/// 0 oder Werte jenseits von 200 °C, und ein einziger Ausreißer würde die
/// Anzeige übernehmen, weil hier das Maximum gebildet wird.
private func hottest(of keys: [String]) -> Double? {
guard let smc, !keys.isEmpty else { return nil }
return keys.compactMap { key -> Double? in
guard let value = smc.float(key), value > 5, value < 150 else { return nil }
return value
}.max()
}
private func readSensors() -> [SensorReading] {
guard let smc else { return [] }
return Self.temperatureKeys.compactMap { key, name in