Files
onyx/Packages/OnyxKit/Sources/MetricsProvider/SystemMetrics.swift
Guido Schmit a4cc75ab6e 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.
2026-08-10 22:05:38 +02:00

370 lines
15 KiB
Swift

import Foundation
import IOKit
import IOKit.ps
import Darwin
/// Ein vollständiger Messwertsatz.
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] = []
public var fans: [FanReading] = []
public var asOf = Date()
}
public struct CPUReading: Equatable, Sendable {
/// Gesamtauslastung, 0 bis 1.
public var total: Double = 0
/// Auslastung je Kern, in der Reihenfolge des Systems.
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 {
public var used: UInt64 = 0
public var total: UInt64 = 0
public var compressed: UInt64 = 0
public var swapUsed: UInt64 = 0
public var pressure: MemoryPressure = .normal
public var usedFraction: Double {
total > 0 ? Double(used) / Double(total) : 0
}
}
public struct BatteryReading: Equatable, Sendable {
public var charge: Double = 0 // 0 bis 1
public var isCharging = false
public var isPluggedIn = false
public var cycleCount: Int?
public var health: Double? // 0 bis 1
public var watts: Double? // negativ = Entladung
public var timeRemaining: TimeInterval?
}
public struct SensorReading: Equatable, Sendable, Identifiable {
public let key: String
public let name: String
public let celsius: Double
public var id: String { key }
}
public struct FanReading: Equatable, Sendable, Identifiable {
public let index: Int
public let rpm: Double
public let minimum: Double
public let maximum: Double
public var id: Int { index }
/// Anteil zwischen Minimum und Maximum, 0 bis 1.
public var fraction: Double {
guard maximum > minimum else { return 0 }
return min(max((rpm - minimum) / (maximum - minimum), 0), 1)
}
}
/// Liest die Hardwarewerte des Systems.
///
/// Bewusst kein Singleton mit Timer: das Abtasten steuert `MetricsModel`, weil
/// nur dort bekannt ist, wie viele Konsumenten es gerade gibt und wie schnell
/// sie es brauchen.
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
var length = MemoryLayout<UInt64>.size
guard sysctlbyname("hw.pagesize", &size, &length, nil, 0) == 0, size > 0 else {
return 16384 // Apple Silicon
}
return size
}()
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()
snapshot.cpu = readCPU()
snapshot.gpu = readGPU()
snapshot.memory = readMemory()
snapshot.battery = readBattery()
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
}
// MARK: - CPU
private func readCPU() -> CPUReading {
var count: natural_t = 0
var info: processor_info_array_t?
var infoCount: mach_msg_type_number_t = 0
guard host_processor_info(mach_host_self(), PROCESSOR_CPU_LOAD_INFO,
&count, &info, &infoCount) == KERN_SUCCESS,
let info else { return CPUReading() }
defer {
vm_deallocate(mach_task_self_, vm_address_t(bitPattern: info),
vm_size_t(infoCount) * vm_size_t(MemoryLayout<integer_t>.stride))
}
var current: [CPUTicks] = []
current.reserveCapacity(Int(count))
for core in 0..<Int(count) {
let base = core * Int(CPU_STATE_MAX)
current.append(CPUTicks(
user: UInt32(bitPattern: info[base + Int(CPU_STATE_USER)]),
system: UInt32(bitPattern: info[base + Int(CPU_STATE_SYSTEM)]),
idle: UInt32(bitPattern: info[base + Int(CPU_STATE_IDLE)]),
nice: UInt32(bitPattern: info[base + Int(CPU_STATE_NICE)])))
}
// Die erste Abtastung hat keinen Vorgänger und kann deshalb keine
// Auslastung ergeben. Null zu zeigen ist ehrlicher als der Anteil seit
// dem Systemstart, den man sonst bekäme der liegt immer bei ein paar
// Prozent und sieht aus wie ein Messwert.
defer { previousCPUTicks = current }
guard previousCPUTicks.count == current.count else { return CPUReading() }
let perCore = zip(previousCPUTicks, current).map(CPUTicks.usage(from:to:))
return CPUReading(total: perCore.isEmpty ? 0 : perCore.reduce(0, +) / Double(perCore.count),
perCore: perCore,
watts: nil)
}
// MARK: - GPU
private func readGPU() -> Double? {
var iterator: io_iterator_t = 0
guard IOServiceGetMatchingServices(kIOMainPortDefault,
IOServiceMatching("IOAccelerator"),
&iterator) == kIOReturnSuccess else { return nil }
defer { IOObjectRelease(iterator) }
while case let entry = IOIteratorNext(iterator), entry != 0 {
defer { IOObjectRelease(entry) }
var unmanaged: Unmanaged<CFMutableDictionary>?
guard IORegistryEntryCreateCFProperties(entry, &unmanaged, kCFAllocatorDefault, 0)
== kIOReturnSuccess,
let properties = unmanaged?.takeRetainedValue() as? [String: Any],
let statistics = properties["PerformanceStatistics"] as? [String: Any]
else { continue }
// Der Schlüssel heißt je nach Gerät unterschiedlich; auf Apple
// Silicon ist es Device Utilization %".
for name in ["Device Utilization %", "GPU Activity(%)", "Renderer Utilization %"] {
if let value = statistics[name] as? Int { return Double(value) / 100 }
}
}
return nil
}
// MARK: - Arbeitsspeicher
private func readMemory() -> MemoryReading {
var statistics = vm_statistics64_data_t()
var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64_data_t>.size
/ MemoryLayout<integer_t>.size)
let result = withUnsafeMutablePointer(to: &statistics) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count)
}
}
guard result == KERN_SUCCESS else { return MemoryReading() }
// `vm_kernel_page_size` ist eine globale Variable und damit unter
// strenger Nebenläufigkeitsprüfung tabu. Der Wert steht seit dem Start
// fest, also einmal über sysctl holen.
let pageSize = Self.pageSize
let total = ProcessInfo.processInfo.physicalMemory
let compressed = UInt64(statistics.compressor_page_count) * pageSize
// Benutzt" wie im Aktivitätsmonitor: aktiv, verdrahtet und komprimiert.
// Der Dateicache (`external_page_count`) zählt nicht mit er wird bei
// Bedarf freigegeben, und ihn mitzuzählen ließe jeden Mac dauernd voll
// aussehen.
let used = UInt64(statistics.active_count) * pageSize
+ UInt64(statistics.wire_count) * pageSize
+ compressed
var swap = xsw_usage()
var swapSize = MemoryLayout<xsw_usage>.size
sysctlbyname("vm.swapusage", &swap, &swapSize, nil, 0)
let free = total > 0 ? 1 - Double(used) / Double(total) : 0
return MemoryReading(used: used, total: total, compressed: compressed,
swapUsed: swap.xsu_used,
pressure: MemoryPressure.classify(
free: free,
compressed: total > 0 ? Double(compressed) / Double(total) : 0,
swapUsed: swap.xsu_used))
}
// MARK: - Akku
private func readBattery() -> BatteryReading? {
guard let blob = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
let sources = IOPSCopyPowerSourcesList(blob)?.takeRetainedValue() as? [CFTypeRef],
let source = sources.first,
let description = IOPSGetPowerSourceDescription(blob, source)?
.takeUnretainedValue() as? [String: Any] else { return nil }
var reading = BatteryReading()
if let current = description[kIOPSCurrentCapacityKey] as? Int,
let maximum = description[kIOPSMaxCapacityKey] as? Int, maximum > 0 {
reading.charge = Double(current) / Double(maximum)
}
reading.isCharging = description[kIOPSIsChargingKey] as? Bool ?? false
reading.isPluggedIn = (description[kIOPSPowerSourceStateKey] as? String)
== kIOPSACPowerValue
if let seconds = description[kIOPSTimeToEmptyKey] as? Int, seconds > 0 {
reading.timeRemaining = TimeInterval(seconds * 60)
}
if let seconds = description[kIOPSTimeToFullChargeKey] as? Int, seconds > 0 {
reading.timeRemaining = TimeInterval(seconds * 60)
}
// Zyklen und Gesundheit stehen nicht in der Power-Source-Beschreibung,
// sondern in der Batterie selbst.
let service = IOServiceGetMatchingService(kIOMainPortDefault,
IOServiceMatching("AppleSmartBattery"))
if service != 0 {
defer { IOObjectRelease(service) }
var unmanaged: Unmanaged<CFMutableDictionary>?
if IORegistryEntryCreateCFProperties(service, &unmanaged, kCFAllocatorDefault, 0)
== kIOReturnSuccess,
let properties = unmanaged?.takeRetainedValue() as? [String: Any] {
reading.cycleCount = properties["CycleCount"] as? Int
if let maximum = properties["MaxCapacity"] as? Int, maximum > 0 {
reading.health = Double(maximum) / 100
}
}
}
// Leistung aus dem SMC: positiv beim Laden, negativ beim Entladen.
if let watts = smc?.float("PPBR") {
reading.watts = reading.isCharging ? watts : -watts
}
return reading
}
// MARK: - Sensoren und Lüfter
/// Die Temperaturfühler, die auf `Mac17,9` tatsächlich existieren.
///
/// Bewusst eine kurze Liste statt aller 3486 SMC-Keys: die meisten sind
/// unbenannt, und eine Wand aus Kürzeln ist keine Information. Was fehlt,
/// fällt beim Lesen einfach weg.
private static let temperatureKeys: [(String, String)] = [
("Ts0P", "Gehäuse vorn"),
("Ts1P", "Gehäuse hinten"),
("TB0T", "Akku"),
("TW0P", "WLAN"),
("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
guard let celsius = smc.float(key), celsius > 0, celsius < 150 else { return nil }
return SensorReading(key: key, name: name, celsius: celsius)
}
}
private func readFans() -> [FanReading] {
guard let smc, let count = smc.integer("FNum"), count > 0 else { return [] }
return (0..<Int(count)).compactMap { index in
guard let rpm = smc.float("F\(index)Ac") else { return nil }
return FanReading(index: index,
rpm: rpm,
minimum: smc.float("F\(index)Mn") ?? 0,
maximum: smc.float("F\(index)Mx") ?? 0)
}
}
}