From 4c7d4c47df237b3b204fac4071b79d0f993b9b25 Mon Sep 17 00:00:00 2001 From: yqc Date: Tue, 13 Oct 2026 17:10:53 +0800 Subject: [PATCH] perf(smc): skip absent key probes in long-lived polling --- Sources/ThermalForgeApp/AppState.swift | 6 ++ Sources/ThermalForgeCore/Daemon.swift | 4 ++ Sources/ThermalForgeCore/FanControl.swift | 7 +++ Sources/ThermalForgeCore/SMCConnection.swift | 27 +++++++- .../ThermalForgeCore/SMCKeyInventory.swift | 28 +++++++++ Sources/ThermalForgeCore/ThermalMonitor.swift | 6 ++ .../SMCKeyInventoryTests.swift | 61 +++++++++++++++++++ Tools/benchmark-smc-status.swift | 31 ++++++++++ 8 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 Sources/ThermalForgeCore/SMCKeyInventory.swift create mode 100644 Tests/ThermalForgeTests/SMCKeyInventoryTests.swift create mode 100644 Tools/benchmark-smc-status.swift diff --git a/Sources/ThermalForgeApp/AppState.swift b/Sources/ThermalForgeApp/AppState.swift index cd15b5c4..52154d6b 100644 --- a/Sources/ThermalForgeApp/AppState.swift +++ b/Sources/ThermalForgeApp/AppState.swift @@ -48,6 +48,7 @@ final class AppState: ObservableObject { @Published var availableUpdate: AvailableUpdate? private var monitor: ThermalMonitor? + private var wakeObserver: NSObjectProtocol? private let executor = PrivilegedExecutor() private var heartbeatTimer: DispatchSourceTimer? /// Consecutive failed heartbeats, for debouncing `daemonUnreachable`. @@ -178,6 +179,7 @@ final class AppState: ObservableObject { } deinit { + if let wakeObserver { NSWorkspace.shared.notificationCenter.removeObserver(wakeObserver) } heartbeatTimer?.cancel() } @@ -372,6 +374,10 @@ final class AppState: ObservableObject { } monitor.start() self.monitor = monitor + if let wakeObserver { NSWorkspace.shared.notificationCenter.removeObserver(wakeObserver) } + wakeObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didWakeNotification, object: nil, queue: .main + ) { [weak monitor] _ in monitor?.invalidateHardwareInventory() } } // MARK: - Actions diff --git a/Sources/ThermalForgeCore/Daemon.swift b/Sources/ThermalForgeCore/Daemon.swift index 6f01f6c8..830cf4dd 100644 --- a/Sources/ThermalForgeCore/Daemon.swift +++ b/Sources/ThermalForgeCore/Daemon.swift @@ -462,6 +462,7 @@ public final class DaemonServer { // unified log with the daemon's own prefix. fanControl.logSink = { DaemonLog.notice("ThermalForge daemon: \($0)") } self.fanControl = fanControl + fanControl.enableKeyInventoryCaching() self.ownerUID = ownerUID // Cache fan RPM limits once (fixed hardware constants). Empty on a read failure // → clamp becomes a no-op and FanControl's own range check stays the backstop. @@ -865,6 +866,9 @@ public final class DaemonServer { } private func handleWake() { + smcLock.lock() + fanControl.invalidateSMCKeyInventory() + smcLock.unlock() stateLock.lock() let heldCommand = hold.command stateLock.unlock() diff --git a/Sources/ThermalForgeCore/FanControl.swift b/Sources/ThermalForgeCore/FanControl.swift index 762dd336..7c88e188 100644 --- a/Sources/ThermalForgeCore/FanControl.swift +++ b/Sources/ThermalForgeCore/FanControl.swift @@ -112,6 +112,13 @@ public final class FanControl { } } + /// Opt into per-connection inventory caching for repeated polling. Discovery + /// is deferred to the next read; one-shot CLI callers retain their old path. + public func enableKeyInventoryCaching() { smc.enableKeyInventoryCaching() } + + /// Invalidate discovery after wake, on the caller's serialized SMC queue. + func invalidateSMCKeyInventory() { smc.invalidateKeyInventory() } + // MARK: - Fan Count public func fanCount() throws -> Int { diff --git a/Sources/ThermalForgeCore/SMCConnection.swift b/Sources/ThermalForgeCore/SMCConnection.swift index 5611b708..bae8cdae 100644 --- a/Sources/ThermalForgeCore/SMCConnection.swift +++ b/Sources/ThermalForgeCore/SMCConnection.swift @@ -76,6 +76,29 @@ struct SMCParamStruct { public final class SMCConnection { private let connection: io_connect_t + private var inventory = SMCKeyInventory() + private var usesKeyInventory = false + private var preparingInventory = false + private var inventoryRetryAt: TimeInterval = 0 + + /// Opt in only for long-lived callers; one-shot CLI reads avoid enumeration. + func enableKeyInventoryCaching() { usesKeyInventory = true } + + /// SMC access is externally serialized (monitor queue / daemon smcLock). + func invalidateKeyInventory() { + inventory = SMCKeyInventory() + inventoryRetryAt = 0 + } + + private func prepareKeyInventory() { + guard !inventory.isComplete, !preparingInventory, + ProcessInfo.processInfo.systemUptime >= inventoryRetryAt else { return } + preparingInventory = true // getKeyCount() reads #KEY through readKey(). + defer { preparingInventory = false } + if !inventory.rebuild(count: getKeyCount(), keyAtIndex: getKeyAtIndex) { + inventoryRetryAt = ProcessInfo.processInfo.systemUptime + 5 + } + } public init?() { var iterator: io_iterator_t = 0 @@ -108,6 +131,8 @@ public final class SMCConnection { /// Read raw bytes from an SMC key public func readKey(_ key: String) -> (success: Bool, bytes: [UInt8], size: UInt32) { + if usesKeyInventory { prepareKeyInventory() } + guard inventory.mayContain(key) else { return (false, [], 0) } var input = SMCParamStruct() var output = SMCParamStruct() @@ -176,7 +201,7 @@ public final class SMCConnection { input.data8 = SMCCommand.getKeyFromIndex.rawValue input.data32 = index - guard callSMC(&input, &output) == kIOReturnSuccess else { + guard callSMC(&input, &output) == kIOReturnSuccess, output.result == 0 else { return nil } diff --git a/Sources/ThermalForgeCore/SMCKeyInventory.swift b/Sources/ThermalForgeCore/SMCKeyInventory.swift new file mode 100644 index 00000000..54b57c60 --- /dev/null +++ b/Sources/ThermalForgeCore/SMCKeyInventory.swift @@ -0,0 +1,28 @@ +import Foundation + +/// A connection-local, firmware-enumerated inventory. Missing keys are skipped +/// only after every index was read successfully; an incomplete enumeration is +/// fail-open. No per-read I/O failure is ever recorded as an absent sensor. +struct SMCKeyInventory { + private var keys: Set? + var isComplete: Bool { keys != nil } + + func mayContain(_ key: String) -> Bool { + // #KEY bootstraps the inventory and must remain readable. + key == "#KEY" || (keys?.contains(key) ?? true) + } + + @discardableResult + mutating func rebuild(count: UInt32, keyAtIndex: (UInt32) -> String?) -> Bool { + keys = nil + guard count > 0, count < 100_000 else { return false } + var discovered = Set() + for index in 0..= 0x20 && $0 <= 0x7e }), + discovered.insert(key).inserted else { return false } + } + keys = discovered + return true + } +} diff --git a/Sources/ThermalForgeCore/ThermalMonitor.swift b/Sources/ThermalForgeCore/ThermalMonitor.swift index 39b0d947..1ffcb983 100644 --- a/Sources/ThermalForgeCore/ThermalMonitor.swift +++ b/Sources/ThermalForgeCore/ThermalMonitor.swift @@ -122,6 +122,7 @@ public final class ThermalMonitor { public func start(interval: TimeInterval = 0.1) { stop() + queue.async { [self] in fanControl.enableKeyInventoryCaching() } let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now(), repeating: interval) @@ -137,6 +138,11 @@ public final class ThermalMonitor { timer = nil } + /// Rebuild the connection's sensor inventory after wake without racing reads. + public func invalidateHardwareInventory() { + queue.async { [self] in fanControl.invalidateSMCKeyInventory() } + } + /// Update the active profile. public func switchProfile(_ profile: FanProfile) { queue.async { [self] in diff --git a/Tests/ThermalForgeTests/SMCKeyInventoryTests.swift b/Tests/ThermalForgeTests/SMCKeyInventoryTests.swift new file mode 100644 index 00000000..be673378 --- /dev/null +++ b/Tests/ThermalForgeTests/SMCKeyInventoryTests.swift @@ -0,0 +1,61 @@ +import Testing +@testable import ThermalForgeCore + +@Suite("SMC key inventory") +struct SMCKeyInventoryTests { + @Test("A complete inventory skips absent keys without probing them again") + func missingKeys() { + var inventory = SMCKeyInventory() + #expect(inventory.mayContain("Tp01")) + #expect(inventory.rebuild(count: 2) { ["TCHP", "TG0H"][Int($0)] }) + #expect(inventory.isComplete) + #expect(inventory.mayContain("TCHP")) + #expect(inventory.mayContain("TG0H")) + #expect(!inventory.mayContain("Tp01")) + #expect(inventory.mayContain("#KEY")) + } + @Test("Each connection uses its own hardware inventory") + func differentMachines() { + var first = SMCKeyInventory() + var second = SMCKeyInventory() + first.rebuild(count: 1) { _ in "TCHP" } + second.rebuild(count: 1) { _ in "Tp01" } + #expect(!first.mayContain("Tp01")) + #expect(second.mayContain("Tp01")) + #expect(!second.mayContain("TCHP")) + } + @Test("A failed or partial enumeration excludes no sensors and can be retried") + func transientFailures() { + var inventory = SMCKeyInventory() + #expect(!inventory.rebuild(count: 2) { $0 == 0 ? "TCHP" : nil }) + #expect(!inventory.isComplete) + #expect(inventory.mayContain("TG0H")) + #expect(inventory.rebuild(count: 2) { ["TCHP", "TG0H"][Int($0)] }) + #expect(inventory.mayContain("TG0H")) + } + @Test("Invalid counts, malformed keys and duplicate indices fail open") + func corruptInventory() { + var inventory = SMCKeyInventory() + for count: UInt32 in [0, 100_000] { + #expect(!inventory.rebuild(count: count) { _ in "TCHP" }) + #expect(inventory.mayContain("Tp01")) + } + for invalid in ["TC", "TCHPX", "\0\0\0\0", "éé"] { + #expect(!inventory.rebuild(count: 1) { _ in invalid }) + #expect(inventory.mayContain("Tp01")) + } + #expect(!inventory.rebuild(count: 2) { _ in "TCHP" }) + #expect(inventory.mayContain("Tp01")) + } + @Test("Wake/reconnect invalidation removes exclusions and discovers new keys") + func invalidation() { + var inventory = SMCKeyInventory() + inventory.rebuild(count: 1) { _ in "TCHP" } + #expect(!inventory.mayContain("Tp01")) + inventory = SMCKeyInventory() + #expect(inventory.mayContain("Tp01")) + inventory.rebuild(count: 1) { _ in "Tp01" } + #expect(inventory.mayContain("Tp01")) + #expect(!inventory.mayContain("TCHP")) + } +} diff --git a/Tools/benchmark-smc-status.swift b/Tools/benchmark-smc-status.swift new file mode 100644 index 00000000..fe280f1c --- /dev/null +++ b/Tools/benchmark-smc-status.swift @@ -0,0 +1,31 @@ +// Read-only benchmark: no fan writes or thermal workload. +// Build: swift build -c release --target ThermalForgeCore +// swiftc -O -I .build/release/Modules Tools/benchmark-smc-status.swift \ +// .build/release/ThermalForgeCore.build/*.o -framework Metal -framework IOKit \ +// -o /tmp/thermalforge-status-benchmark +// Run without caching, then with caching: /tmp/thermalforge-status-benchmark [--inventory] +import Foundation +import ThermalForgeCore +let initializationStart = ProcessInfo.processInfo.systemUptime +let control = try FanControl() +let enabled = CommandLine.arguments.contains("--inventory") +if enabled { control.enableKeyInventoryCaching() } +let initializationMS = (ProcessInfo.processInfo.systemUptime - initializationStart) * 1000 +var readings: [Double] = [] +var sensorKeys: [String] = [] +var firstStatusMS: Double = 0 +for iteration in 0..<205 { + let start = ProcessInfo.processInfo.systemUptime + let status = try control.status() + let elapsed = ProcessInfo.processInfo.systemUptime - start + if iteration == 0 { firstStatusMS = elapsed * 1000 } + if iteration >= 5 { readings.append(elapsed) } + sensorKeys = status.temperatures.keys.sorted() +} +let ordered = readings.sorted() +let result: [String: Any] = ["inventory_enabled": enabled, "initialization_ms": initializationMS, "first_status_ms": firstStatusMS, "reads": readings.count, + "mean_ms": readings.reduce(0,+) / Double(readings.count) * 1000, + "median_ms": ordered[ordered.count / 2] * 1000, + "p95_ms": ordered[Int(Double(ordered.count - 1) * 0.95)] * 1000, + "sensor_keys": sensorKeys] +print(String(data: try JSONSerialization.data(withJSONObject: result, options: .sortedKeys), encoding: .utf8)!)