ContentView declared an @EnvironmentObject it never read, so every
SystemMonitor publish (3 s by default) re-evaluated its body. On macOS
the AppKit tab bridge permanently retains the AnyView(Label(...)) behind
each .tabItem, leaking ~6 KB per tick — 519 MB after three days of
uptime, >90% of the process footprint.
Keep the TabView shells free of observable dependencies:
- ContentView: drop the unused @EnvironmentObject, so the body is
evaluated exactly once.
- SettingsView: split the shell from its two tabs, which now own the
AppSettings observation (dragging a threshold slider leaked 2 labels
per step).
- PerformanceView: drop the write-only selection binding (3 labels per
sub-tab switch).
Two smaller wins while in here:
- GPUMonitor leaked one io_object per GPU poll: leaving a
`defer { release; IOIteratorNext }` loop via break/return hands back an
object nobody releases. Replaced with firstMatch(in:), and removed a
never-released iterator in getAppleSiliconChipName.
- MetricsStore re-encoded the whole 20k-sample history to JSON every
30 s; saveEvery 10 -> 40 (~2 min). flush() on quit and on background
is unchanged.
Measured over 10 minutes (~200 refresh cycles): footprint 86-123 MB with
no upward trend, leaked Label views pinned at 4 — the tab items
themselves. Was 519 MB and climbing ~170 MB/day. 55/55 unit tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
254 sor
9.3 KiB
Swift
254 sor
9.3 KiB
Swift
import Foundation
|
|
import IOKit
|
|
import Metal
|
|
|
|
final class GPUMonitor {
|
|
private let isAppleSilicon: Bool
|
|
private let chipName: String
|
|
private let gpuCoreCount: Int?
|
|
|
|
init() {
|
|
var sysinfo = utsname()
|
|
uname(&sysinfo)
|
|
let machine = withUnsafePointer(to: &sysinfo.machine) {
|
|
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
|
|
String(cString: $0)
|
|
}
|
|
}
|
|
isAppleSilicon = machine.hasPrefix("arm64")
|
|
chipName = GPUMonitor.getChipName()
|
|
gpuCoreCount = GPUMonitor.getGPUCoreCount()
|
|
}
|
|
|
|
private static func getGPUCoreCount() -> Int? {
|
|
// Try to get GPU core count from Metal
|
|
guard let device = MTLCreateSystemDefaultDevice() else { return nil }
|
|
|
|
// For Apple Silicon, we can infer from the chip name or use Metal properties
|
|
// Metal doesn't directly expose core count, but we can check recommended working set size
|
|
// as a proxy or use the chip name to look up known values
|
|
|
|
// Try IOKit for more detailed info
|
|
let matchingDict = IOServiceMatching("AGXAccelerator")
|
|
var iterator: io_iterator_t = 0
|
|
|
|
if IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &iterator) == KERN_SUCCESS {
|
|
defer { IOObjectRelease(iterator) }
|
|
|
|
let service = IOIteratorNext(iterator)
|
|
if service != 0 {
|
|
defer { IOObjectRelease(service) }
|
|
|
|
if let props = IORegistryEntryCreateCFProperty(
|
|
service,
|
|
"gpu-core-count" as CFString,
|
|
kCFAllocatorDefault,
|
|
0
|
|
)?.takeRetainedValue() as? Int {
|
|
return props
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: infer from device name
|
|
let name = device.name.lowercased()
|
|
if name.contains("m3 max") { return 40 }
|
|
if name.contains("m3 pro") { return 18 }
|
|
if name.contains("m3") { return 10 }
|
|
if name.contains("m2 ultra") { return 76 }
|
|
if name.contains("m2 max") { return 38 }
|
|
if name.contains("m2 pro") { return 19 }
|
|
if name.contains("m2") { return 10 }
|
|
if name.contains("m1 ultra") { return 64 }
|
|
if name.contains("m1 max") { return 32 }
|
|
if name.contains("m1 pro") { return 16 }
|
|
if name.contains("m1") { return 8 }
|
|
|
|
return nil
|
|
}
|
|
|
|
private static func getChipName() -> String {
|
|
var size: size_t = 0
|
|
sysctlbyname("machdep.cpu.brand_string", nil, &size, nil, 0)
|
|
|
|
var brand = [CChar](repeating: 0, count: size)
|
|
sysctlbyname("machdep.cpu.brand_string", &brand, &size, nil, 0)
|
|
|
|
let brandString = String(cString: brand)
|
|
|
|
// For Apple Silicon, this returns "Apple M1", "Apple M2", etc.
|
|
if brandString.hasPrefix("Apple") {
|
|
return brandString
|
|
}
|
|
|
|
// Fallback: try to get chip from IOKit
|
|
return getAppleSiliconChipName() ?? "Apple GPU"
|
|
}
|
|
|
|
private static func getAppleSiliconChipName() -> String? {
|
|
let platformExpert = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice"))
|
|
guard platformExpert != 0 else { return nil }
|
|
defer { IOObjectRelease(platformExpert) }
|
|
|
|
if let modelData = IORegistryEntryCreateCFProperty(
|
|
platformExpert,
|
|
"model" as CFString,
|
|
kCFAllocatorDefault,
|
|
0
|
|
)?.takeRetainedValue() as? Data {
|
|
let model = String(data: modelData, encoding: .utf8)?.trimmingCharacters(in: .controlCharacters) ?? ""
|
|
|
|
// Map model identifiers to chip names
|
|
if model.contains("Mac14") || model.contains("Mac15") {
|
|
return "Apple M2/M3"
|
|
} else if model.contains("Mac13") {
|
|
return "Apple M2"
|
|
} else if model.contains("Mac12") || model.contains("MacBookAir10") || model.contains("MacBookPro17") || model.contains("MacBookPro18") || model.contains("Macmini9") || model.contains("iMac21") {
|
|
return "Apple M1"
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func fetchGPUInfo() -> GPUInfo? {
|
|
if isAppleSilicon {
|
|
return fetchAppleSiliconGPUInfo()
|
|
} else {
|
|
return fetchDiscreteGPUInfo()
|
|
}
|
|
}
|
|
|
|
/// Walks an `io_iterator_t` and returns the first non-`nil` value `body`
|
|
/// produces, releasing **every** object it pulled out of the iterator.
|
|
///
|
|
/// The obvious `while` + `defer { release; next() }` shape leaks one
|
|
/// `io_object_t` whenever the loop is left with `break`/`return`, because
|
|
/// the deferred `IOIteratorNext` hands back an object nobody releases. At a
|
|
/// GPU poll every few seconds that leaks tens of thousands of mach port
|
|
/// rights per day.
|
|
private static func firstMatch<T>(in iterator: io_iterator_t, _ body: (io_service_t) -> T?) -> T? {
|
|
var result: T?
|
|
while result == nil {
|
|
let service = IOIteratorNext(iterator)
|
|
guard service != 0 else { break }
|
|
result = body(service)
|
|
IOObjectRelease(service)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func fetchAppleSiliconGPUInfo() -> GPUInfo? {
|
|
// Apple Silicon uses unified memory - get memory pressure instead
|
|
let totalMemory = ProcessInfo.processInfo.physicalMemory
|
|
|
|
// Try to get GPU utilization from IOKit
|
|
var utilizationPercent: Double? = nil
|
|
var inUseSystemMemory: UInt64 = 0
|
|
|
|
let matchingDict = IOServiceMatching("IOAccelerator")
|
|
var iterator: io_iterator_t = 0
|
|
|
|
if IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &iterator) == KERN_SUCCESS {
|
|
defer { IOObjectRelease(iterator) }
|
|
|
|
_ = Self.firstMatch(in: iterator) { service -> Bool? in
|
|
guard let props = IORegistryEntryCreateCFProperty(
|
|
service,
|
|
"PerformanceStatistics" as CFString,
|
|
kCFAllocatorDefault,
|
|
0
|
|
)?.takeRetainedValue() as? [String: Any] else { return nil }
|
|
|
|
// Try various keys for GPU memory on Apple Silicon
|
|
if let inUse = props["In use system memory"] as? UInt64 {
|
|
inUseSystemMemory = inUse
|
|
} else if let inUse = props["Alloc system memory"] as? UInt64 {
|
|
inUseSystemMemory = inUse
|
|
} else if let inUse = props["inUseSystemMemory"] as? UInt64 {
|
|
inUseSystemMemory = inUse
|
|
}
|
|
|
|
// GPU utilization
|
|
if let util = props["Device Utilization %"] as? Int {
|
|
utilizationPercent = Double(util)
|
|
} else if let util = props["GPU Activity(%)"] as? Int {
|
|
utilizationPercent = Double(util)
|
|
}
|
|
|
|
return true
|
|
}
|
|
}
|
|
|
|
// For Apple Silicon, show unified memory usage
|
|
return GPUInfo(
|
|
name: chipName + " GPU",
|
|
vramUsed: inUseSystemMemory,
|
|
vramTotal: inUseSystemMemory > 0 ? totalMemory : nil,
|
|
utilizationPercent: utilizationPercent,
|
|
isUnifiedMemory: true,
|
|
coreCount: gpuCoreCount
|
|
)
|
|
}
|
|
|
|
private func fetchDiscreteGPUInfo() -> GPUInfo? {
|
|
let matchingDict = IOServiceMatching("IOAccelerator")
|
|
|
|
var iterator: io_iterator_t = 0
|
|
guard IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &iterator) == KERN_SUCCESS else {
|
|
return nil
|
|
}
|
|
defer { IOObjectRelease(iterator) }
|
|
|
|
return Self.firstMatch(in: iterator) { service -> GPUInfo? in
|
|
var name = "GPU"
|
|
if let modelData = IORegistryEntryCreateCFProperty(
|
|
service,
|
|
"model" as CFString,
|
|
kCFAllocatorDefault,
|
|
0
|
|
)?.takeRetainedValue() as? Data {
|
|
name = String(data: modelData, encoding: .utf8)?
|
|
.trimmingCharacters(in: .controlCharacters) ?? "GPU"
|
|
}
|
|
|
|
if let props = IORegistryEntryCreateCFProperty(
|
|
service,
|
|
"PerformanceStatistics" as CFString,
|
|
kCFAllocatorDefault,
|
|
0
|
|
)?.takeRetainedValue() as? [String: Any] {
|
|
|
|
let vramUsed = (props["vramUsedBytes"] as? UInt64)
|
|
?? (props["VRAM,totalMB"] as? UInt64).map { $0 * 1024 * 1024 }
|
|
?? (props["inUseVidMemoryBytes"] as? UInt64)
|
|
?? 0
|
|
|
|
let vramFree = props["vramFreeBytes"] as? UInt64
|
|
let vramTotal: UInt64?
|
|
|
|
if let free = vramFree {
|
|
vramTotal = vramUsed + free
|
|
} else if let totalMB = props["VRAM,totalMB"] as? UInt64 {
|
|
vramTotal = totalMB * 1024 * 1024
|
|
} else {
|
|
vramTotal = nil
|
|
}
|
|
|
|
let utilization = props["Device Utilization %"] as? Double
|
|
?? props["GPU Core Utilization"] as? Double
|
|
|
|
return GPUInfo(
|
|
name: name,
|
|
vramUsed: vramUsed,
|
|
vramTotal: vramTotal,
|
|
utilizationPercent: utilization,
|
|
isUnifiedMemory: false
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
}
|