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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user