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

@@ -48,11 +48,18 @@ public final class SMCReader: @unchecked Sendable {
private static let handleYPCEvent: UInt32 = 2
private static let readKey: UInt8 = 5
private static let getKeyFromIndex: UInt8 = 8
private static let getKeyInfo: UInt8 = 9
private var connection: io_connect_t = 0
private let lock = NSLock()
/// Typ und Größe je Key. Beides steht in der Firmware fest und ändert sich
/// zur Laufzeit nicht es bei jedem Lesen erneut zu erfragen verdoppelt
/// die Zahl der IOKit-Aufrufe. Bei fünfzig Temperaturfühlern im Sekundentakt
/// ist das der Unterschied zwischen spürbar und unmerklich.
private var keyInfoCache: [String: KeyInfo] = [:]
public init?() {
let service = IOServiceGetMatchingService(kIOMainPortDefault,
IOServiceMatching("AppleSMC"))
@@ -92,26 +99,75 @@ public final class SMCReader: @unchecked Sendable {
public func exists(_ key: String) -> Bool { read(key) != nil }
/// Alle Keys, deren Name mit einem der Präfixe beginnt.
///
/// Wird einmal beim Start aufgerufen. Fest verdrahtete Listen wären hier
/// falsch: welche Fühler es gibt, hängt am Modell auf dem M5 sind es
/// 285 Temperaturfühler, auf einem anderen Mac ganz andere.
public func keys(matching prefixes: [String]) -> [String] {
guard let total = keyCount() else { return [] }
var found: [String] = []
for index in 0..<UInt32(total) {
guard let name = key(atIndex: index) else { continue }
if prefixes.contains(where: name.hasPrefix) { found.append(name) }
}
return found
}
/// Die Anzahl der Keys **big-endian**.
///
/// Die Byte-Reihenfolge ist im SMC nicht durchgängig, und das ist keine
/// Nachlässigkeit dieser Klasse, sondern der Hardware: 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 Suche brach damit still ab und alle Temperaturen
/// blieben leer.
private func keyCount() -> UInt32? {
guard let value = read("#KEY"), value.bytes.count >= 4 else { return nil }
let count = UInt32(value.bytes[0]) << 24 | UInt32(value.bytes[1]) << 16
| UInt32(value.bytes[2]) << 8 | UInt32(value.bytes[3])
return (0..<100_000).contains(count) ? count : nil
}
private func key(atIndex index: UInt32) -> String? {
lock.lock()
defer { lock.unlock() }
var command = Param()
command.data8 = Self.getKeyFromIndex
command.data32 = index
guard let output = call(command) else { return nil }
return Self.fourCCString(output.key)
}
private struct Value { let type: String; let bytes: [UInt8] }
private func read(_ key: String) -> Value? {
lock.lock()
defer { lock.unlock() }
var info = Param()
info.key = Self.fourCC(key)
info.data8 = Self.getKeyInfo
guard let described = call(info) else { return nil }
let described: KeyInfo
if let cached = keyInfoCache[key] {
described = cached
} else {
var info = Param()
info.key = Self.fourCC(key)
info.data8 = Self.getKeyInfo
guard let result = call(info) else { return nil }
described = result.keyInfo
keyInfoCache[key] = described
}
var command = Param()
command.key = Self.fourCC(key)
command.keyInfo = described.keyInfo
command.keyInfo = described
command.data8 = Self.readKey
guard let output = call(command) else { return nil }
let size = Int(min(described.keyInfo.dataSize, 32))
let size = Int(min(described.dataSize, 32))
let all = withUnsafeBytes(of: output.bytes) { Array($0) }
return Value(type: Self.fourCCString(described.keyInfo.dataType),
return Value(type: Self.fourCCString(described.dataType),
bytes: Array(all.prefix(size)))
}