功耗(整机、电源输入、电池、GPU)与各类核心频率

This commit is contained in:
gentpan committed 2026-09-13 08:29:00 +05:00
1 parent 1e8c2f0773
commit 722b278832
14 files changed
+409 -5

No files matched your search

+1
View File
@@ -13,6 +13,7 @@
- 辅助工具版本检查:启动时核对正在运行的辅助工具版本;旧进程还在运行时先断开让它退出,由系统启动新版本,仍然旧时在风扇、合盖运行和辅助工具设置里提示“重新安装”,侧边栏对应项目显示提醒圆点;有新版本时“关于”旁同样显示圆点。
- 导出诊断信息(设置 · 关于):把版本、系统与辅助工具状态、主要设置、最近 3 天的运行日志、清理记录和最近的崩溃报告打包成 zip,反馈问题时附上;自动去掉用户目录名、IP 与硬件地址,不含序列号。应用与辅助工具的关键操作和错误写入系统日志。
- 系统通知(设置 · 通知):CPU 过热(持续 1 分钟高于设定温度)、内存压力严重(持续 30 秒)、磁盘空间不足(低于 10% 或 10 GB)、网络断开超过 20 秒(恢复后再提示)、电池最大容量低于 80%,逐项开关,默认关闭;同一状况持续期间只提醒一次并有冷却时间,点通知打开对应页面;可发送测试通知,通知被关闭时提示去系统设置打开。
- 功耗与频率:“温度与风扇”页和温度详情新增功耗,显示整机功耗走势、电源输入、电池充放电功率和 GPU 功耗(读 SMC 与系统能耗统计,不需要辅助工具);CPU 详情的核心分工显示各类核心工作时的平均频率。读取能耗统计有一定开销,只在这些界面打开时每 2 秒读一次。
#### 调整
+1 -1
View File
@@ -15,7 +15,7 @@ let package = Package(
],
targets: [
.target(name: "SMC"),
.target(name: "Metrics", dependencies: ["SMC"]),
.target(name: "Metrics", dependencies: ["SMC"], linkerSettings: [.linkedLibrary("IOReport")]),
.target(name: "HelperShared"),
.target(name: "Cleaner"),
.target(name: "Updates"),
@@ -65,6 +65,15 @@ public enum Format {
fahrenheit ? "\(Int((celsius * 9 / 5 + 32).rounded()))°F" : "\(Int(celsius.rounded()))°C"
}
public static func watts(_ value: Double) -> String {
value < 10 ? "\(value.formatted(.number.precision(.fractionLength(1)))) W" : "\(Int(value.rounded())) W"
}
/// 频率:MHz 输入,1000 以上显示为 GHz
public static func frequency(megahertz value: Double) -> String {
value >= 1000 ? "\((value / 1000).formatted(.number.precision(.fractionLength(2)))) GHz" : "\(Int(value.rounded())) MHz"
}
public static func rpm(_ value: Double) -> String {
"\(Int(value.rounded())) RPM"
}
@@ -0,0 +1,46 @@
import Foundation
// libIOReport 没有公开头文件,按 powermetrics / macmon 使用的签名声明。只读取统计,不需要 root。
typealias IOReportSubscriptionRef = OpaquePointer
@_silgen_name("IOReportCopyChannelsInGroup")
func IOReportCopyChannelsInGroup(_ group: CFString?, _ subgroup: CFString?, _ a: UInt64, _ b: UInt64, _ c: UInt64) -> Unmanaged<CFMutableDictionary>?
@_silgen_name("IOReportMergeChannels")
func IOReportMergeChannels(_ into: CFMutableDictionary, _ from: CFDictionary, _ unused: CFTypeRef?)
@_silgen_name("IOReportCreateSubscription")
func IOReportCreateSubscription(_ allocator: UnsafeRawPointer?, _ channels: CFMutableDictionary,
_ subscribed: UnsafeMutablePointer<Unmanaged<CFMutableDictionary>?>,
_ channelID: UInt64, _ options: CFTypeRef?) -> IOReportSubscriptionRef?
@_silgen_name("IOReportCreateSamples")
func IOReportCreateSamples(_ subscription: IOReportSubscriptionRef, _ channels: CFMutableDictionary, _ options: CFTypeRef?) -> Unmanaged<CFDictionary>?
@_silgen_name("IOReportCreateSamplesDelta")
func IOReportCreateSamplesDelta(_ previous: CFDictionary, _ current: CFDictionary, _ options: CFTypeRef?) -> Unmanaged<CFDictionary>?
@_silgen_name("IOReportChannelGetGroup")
func IOReportChannelGetGroup(_ channel: CFDictionary) -> Unmanaged<CFString>?
@_silgen_name("IOReportChannelGetSubGroup")
func IOReportChannelGetSubGroup(_ channel: CFDictionary) -> Unmanaged<CFString>?
@_silgen_name("IOReportChannelGetChannelName")
func IOReportChannelGetChannelName(_ channel: CFDictionary) -> Unmanaged<CFString>?
@_silgen_name("IOReportChannelGetUnitLabel")
func IOReportChannelGetUnitLabel(_ channel: CFDictionary) -> Unmanaged<CFString>?
@_silgen_name("IOReportSimpleGetIntegerValue")
func IOReportSimpleGetIntegerValue(_ channel: CFDictionary, _ unused: Int32) -> Int64
@_silgen_name("IOReportStateGetCount")
func IOReportStateGetCount(_ channel: CFDictionary) -> Int32
@_silgen_name("IOReportStateGetNameForIndex")
func IOReportStateGetNameForIndex(_ channel: CFDictionary, _ index: Int32) -> Unmanaged<CFString>?
@_silgen_name("IOReportStateGetResidency")
func IOReportStateGetResidency(_ channel: CFDictionary, _ index: Int32) -> Int64
@@ -14,6 +14,10 @@ public struct MetricsDemand: Sendable, Equatable {
public var systemProcesses = false
public var temperatures: Set<TemperatureGroup> = []
public var fans = false
/// 整机功耗与 GPU 功耗
public var power = false
/// 各类核心的频率
public var cpuFrequency = false
public init() {}
}
@@ -24,6 +28,7 @@ public actor MetricsHub {
private var network = NetworkSampler()
private var processes = ProcessSampler()
private let sensors = SensorSampler()
private let power = PowerSampler()
private var demand = MetricsDemand()
private var handler: (@MainActor @Sendable (MetricsSnapshot) -> Void)?
@@ -35,6 +40,7 @@ public actor MetricsHub {
private var lastDisk = Date.distantPast
private var lastBattery = Date.distantPast
private var lastInterface = Date.distantPast
private var lastPower = Date.distantPast
public init() {}
@@ -48,12 +54,16 @@ public actor MetricsHub {
let needsImmediateRefresh = newDemand.interval < demand.interval
|| (newDemand.processes && !demand.processes)
|| (newDemand.disk && !demand.disk)
if (newDemand.power && !demand.power) || (newDemand.cpuFrequency && !demand.cpuFrequency) {
lastPower = .distantPast
}
demand = newDemand
if needsImmediateRefresh {
lastDisk = .distantPast
lastBattery = .distantPast
lastInterface = .distantPast
}
restart()
}
@@ -81,6 +91,8 @@ public actor MetricsHub {
demand.processes = true
demand.temperatures = Set(TemperatureGroup.allCases)
demand.fans = true
demand.power = true
demand.cpuFrequency = true
return demand
}
@@ -133,6 +145,11 @@ public actor MetricsHub {
if !demand.temperatures.isEmpty || demand.fans {
snapshot.sensors = sensors.sample(groups: demand.temperatures, includeFans: demand.fans)
}
// IOReport 采样有一定开销,至少间隔 2 秒
if demand.power || demand.cpuFrequency, now.timeIntervalSince(lastPower) >= 1.9 {
snapshot.power = power.sample(gpu: demand.power, frequency: demand.cpuFrequency, now: now)
lastPower = now
}
return snapshot
}
}
@@ -192,6 +192,7 @@ public struct MetricsSnapshot: Sendable {
public var processes: [ProcessUsage]?
public var systemCounts: SystemCounts?
public var sensors: SensorReadings?
public var power: PowerReading?
public init() {}
}
@@ -0,0 +1,226 @@
import Foundation
import IOKit
import SMC
/// 功耗与 CPU 频率
public struct PowerReading: Sendable, Equatable {
/// 整机功耗(SMC PSTR)
public var system: Double?
/// 电源适配器输入(SMC PDTR),使用电池时为 nil
public var adapter: Double?
/// 电池充放电功率(SMC PPBR)
public var battery: Double?
/// GPU 功耗(IOReport Energy Model,两次采样之间的平均值)
public var gpu: Double?
/// 各类核心在工作时的平均频率(MHz),键为 perflevel,0 为最高性能档
public var clusterFrequency: [Int: Double] = [:]
public init(system: Double? = nil, adapter: Double? = nil, battery: Double? = nil, gpu: Double? = nil,
clusterFrequency: [Int: Double] = [:]) {
self.system = system
self.adapter = adapter
self.battery = battery
self.gpu = gpu
self.clusterFrequency = clusterFrequency
}
}
/// 功耗读 SMC,CPU 频率与 GPU 能耗读 IOReport,都不需要 root。
/// 注:新款芯片上 IOReport 的 CPU 能耗计数器(mJ)长时间不更新,因此不显示 CPU 功耗。
public final class PowerSampler: @unchecked Sendable {
private let smc = try? SMCConnection()
private var energy: Subscription?
private var cpuStates: Subscription?
private var lastEnergy: (date: Date, sample: CFDictionary)?
private var lastStates: (date: Date, sample: CFDictionary)?
private lazy var frequencyTables: [[Double]] = Self.readFrequencyTables()
public init() {}
/// SMC 读数几乎没有开销;IOReport 每次采样约 5 毫秒 CPU,GPU 功耗与 CPU 频率只在界面需要时读
public func sample(gpu includeGPU: Bool = true, frequency includeFrequency: Bool = true, now: Date = Date()) -> PowerReading {
var reading = PowerReading()
reading.system = positive(smc?.double(SMCKey("PSTR")))
reading.adapter = positive(smc?.double(SMCKey("PDTR")))
reading.battery = smc?.double(SMCKey("PPBR")).flatMap { $0.isFinite && abs($0) >= 0.01 ? $0 : nil }
if includeGPU { reading.gpu = sampleGPU(now: now) }
if includeFrequency { reading.clusterFrequency = sampleFrequencies(now: now) }
return reading
}
private func positive(_ value: Double?) -> Double? {
guard let value, value.isFinite, value > 0.05, value < 1000 else { return nil }
return value
}
// MARK: GPU
private func sampleGPU(now: Date) -> Double? {
if energy == nil { energy = Subscription(group: "Energy Model", subgroup: nil) }
guard let energy, let current = energy.sample() else { return nil }
defer { lastEnergy = (now, current) }
// 间隔太短噪声大;太长(界面关闭过一段时间)的平均值没有意义
guard let last = lastEnergy, (0.2...15).contains(now.timeIntervalSince(last.date)),
let delta = IOReportCreateSamplesDelta(last.sample, current, nil)?.takeRetainedValue() else {
return nil
}
let seconds = now.timeIntervalSince(last.date)
for channel in Subscription.channels(in: delta) where Subscription.name(channel) == "GPU Energy" {
let value = Double(IOReportSimpleGetIntegerValue(channel, 0))
let joules = value * Self.joulesPerUnit(Subscription.unit(channel))
return joules / seconds
}
return nil
}
static func joulesPerUnit(_ unit: String) -> Double {
switch unit {
case "nJ": 1e-9
case "uJ", "µJ": 1e-6
case "mJ": 1e-3
default: 1
}
}
// MARK: CPU 频率
private func sampleFrequencies(now: Date) -> [Int: Double] {
if cpuStates == nil { cpuStates = Subscription(group: "CPU Stats", subgroup: "CPU Complex Performance States") }
guard let cpuStates, let current = cpuStates.sample() else { return [:] }
defer { lastStates = (now, current) }
guard let last = lastStates, now.timeIntervalSince(last.date) <= 15,
let delta = IOReportCreateSamplesDelta(last.sample, current, nil)?.takeRetainedValue() else { return [:] }
var complexes: [ComplexResidency] = []
for channel in Subscription.channels(in: delta) {
let name = Subscription.name(channel)
guard let letter = Self.complexLetter(name) else { continue }
var states: [(String, Int64)] = []
for index in 0..<IOReportStateGetCount(channel) {
let stateName = IOReportStateGetNameForIndex(channel, index)?.takeUnretainedValue() as String? ?? ""
states.append((stateName, IOReportStateGetResidency(channel, index)))
}
complexes.append(ComplexResidency(letter: letter, states: states))
}
let levels = Sysctl.int("hw.nperflevels") ?? 1
return Self.clusterFrequencies(complexes, tables: frequencyTables, perfLevels: levels)
}
struct ComplexResidency {
let letter: Character
let states: [(name: String, residency: Int64)]
}
/// “ECPU”“PCPU”“MCPU0” 这类核心组通道;“PCPM”“MCPM0_IDLE” 是电源管理通道,不计入
static func complexLetter(_ name: String) -> Character? {
guard name.count >= 4, let first = name.first, first.isUppercase,
name.dropFirst().hasPrefix("CPU"), name.dropFirst(4).allSatisfy(\.isNumber) else { return nil }
return first
}
/// 按工作状态(V{n}P{m})的驻留时间加权平均频率。同一字母的核心组合并;
/// 各组按频率表的最高频率从高到低对应 perflevel 0、1、2
static func clusterFrequencies(_ complexes: [ComplexResidency], tables: [[Double]], perfLevels: Int) -> [Int: Double] {
var weighted: [Character: (sum: Double, time: Double, top: Double)] = [:]
for complex in complexes {
let active = complex.states.filter { $0.name.hasPrefix("V") && $0.name.contains("P") }
guard let table = table(forStateCount: active.count, letter: complex.letter, in: tables) else { continue }
var entry = weighted[complex.letter] ?? (0, 0, table.max() ?? 0)
for (index, state) in active.enumerated() where index < table.count {
entry.sum += table[index] * Double(state.residency)
entry.time += Double(state.residency)
}
weighted[complex.letter] = entry
}
let ordered = weighted.sorted { $0.value.top > $1.value.top }
guard ordered.count == perfLevels else { return [:] }
var result: [Int: Double] = [:]
for (level, item) in ordered.enumerated() where item.value.time > 0 {
result[level] = item.value.sum / item.value.time
}
return result
}
/// 状态数与频率表项数一致的表;有多张时能效核(E)取最低、其他取最高
static func table(forStateCount count: Int, letter: Character, in tables: [[Double]]) -> [Double]? {
let candidates = tables.filter { $0.count == count }
return letter == "E" ? candidates.min { ($0.max() ?? 0) < ($1.max() ?? 0) }
: candidates.max { ($0.max() ?? 0) < ($1.max() ?? 0) }
}
/// pmgr 的 voltage-states*-sram 属性:每项 8 字节(频率、电压,小端 UInt32)。
/// 旧芯片单位是 Hz,新芯片直接是 MHz;第一项为 0 的是 GPU 等其他模块的表
static func parseFrequencyTable(_ data: Data) -> [Double]? {
guard data.count >= 16, data.count % 8 == 0 else { return nil }
let values = stride(from: 0, to: data.count, by: 8).map { offset -> Double in
let raw = data.subdata(in: offset..<offset + 4).withUnsafeBytes { $0.loadUnaligned(as: UInt32.self) }
return Double(UInt32(littleEndian: raw))
}
guard let first = values.first, first > 0, let top = values.max() else { return nil }
let scale = top > 100_000_000 ? 1e-6 : top > 100_000 ? 1e-3 : 1
let megahertz = values.map { $0 * scale }
// 合理的 CPU 频率范围
guard (200...10_000).contains(megahertz.max() ?? 0) else { return nil }
return megahertz
}
private static func readFrequencyTables() -> [[Double]] {
var iterator: io_iterator_t = 0
guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("AppleARMIODevice"), &iterator) == KERN_SUCCESS else {
return []
}
defer { IOObjectRelease(iterator) }
var tables: [[Double]] = []
while case let entry = IOIteratorNext(iterator), entry != 0 {
defer { IOObjectRelease(entry) }
var name = [CChar](repeating: 0, count: 128)
guard IORegistryEntryGetName(entry, &name) == KERN_SUCCESS,
String(decoding: name.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, as: UTF8.self) == "pmgr" else { continue }
var properties: Unmanaged<CFMutableDictionary>?
guard IORegistryEntryCreateCFProperties(entry, &properties, kCFAllocatorDefault, 0) == KERN_SUCCESS,
let dictionary = properties?.takeRetainedValue() as? [String: Any] else { continue }
for (key, value) in dictionary where key.hasPrefix("voltage-states") && key.hasSuffix("-sram") {
if let data = value as? Data, let table = parseFrequencyTable(data), !tables.contains(table) {
tables.append(table)
}
}
}
return tables
}
}
/// IOReport 订阅:只订阅需要的分组,减少每次采样的开销
private final class Subscription {
private let channels: CFMutableDictionary
private let subscription: IOReportSubscriptionRef
init?(group: String, subgroup: String?) {
guard let channels = IOReportCopyChannelsInGroup(group as CFString, subgroup as CFString?, 0, 0, 0)?.takeRetainedValue() else {
return nil
}
var subscribed: Unmanaged<CFMutableDictionary>?
guard let subscription = IOReportCreateSubscription(nil, channels, &subscribed, 0, nil) else { return nil }
subscribed?.release()
self.channels = channels
self.subscription = subscription
}
func sample() -> CFDictionary? {
IOReportCreateSamples(subscription, channels, nil)?.takeRetainedValue()
}
static func channels(in sample: CFDictionary) -> [CFDictionary] {
let key = "IOReportChannels" as CFString
guard let raw = CFDictionaryGetValue(sample, Unmanaged.passUnretained(key).toOpaque()) else { return [] }
let array = unsafeBitCast(raw, to: CFArray.self)
return (0..<CFArrayGetCount(array)).map { unsafeBitCast(CFArrayGetValueAtIndex(array, $0), to: CFDictionary.self) }
}
static func name(_ channel: CFDictionary) -> String {
IOReportChannelGetChannelName(channel)?.takeUnretainedValue() as String? ?? ""
}
static func unit(_ channel: CFDictionary) -> String {
(IOReportChannelGetUnitLabel(channel)?.takeUnretainedValue() as String? ?? "").trimmingCharacters(in: .whitespaces)
}
}
@@ -12,6 +12,7 @@ struct ThermalPage: View {
FanCard().frame(maxWidth: .infinity)
}
.fixedSize(horizontal: false, vertical: true)
PowerCard()
FanSafetySettings()
}
}
@@ -67,6 +68,18 @@ private struct TemperatureRow: View {
}
}
private struct PowerCard: View {
@Environment(AppModel.self) private var model
var body: some View {
let power = model.store.power
Card {
CardHeader(icon: "bolt", title: "功耗", detail: power?.system.map { "整机 \(Format.watts($0))" } ?? "读取中")
PowerRows(power: power, history: model.store.powerHistory.elements)
}
}
}
private struct FanCard: View {
@Environment(AppModel.self) private var model
@@ -46,7 +46,8 @@ struct CPUPopover: View {
SectionCard(title: section.title) {
ForEach(store.topology.clusters) { cluster in
let average = CoreClusterBars.average(of: cluster, perCore: cpu?.perCore ?? []) ?? 0
ShareRow(label: "\(cluster.name) · \(cluster.coreIndices.count) 核", value: Format.percent(average),
let frequency = store.power?.clusterFrequency[cluster.id].map { " · \(Format.frequency(megahertz: $0))" } ?? ""
ShareRow(label: "\(cluster.name) · \(cluster.coreIndices.count) 核\(frequency)", value: Format.percent(average),
fraction: average, color: DS.Palette.cluster(cluster.id))
}
if let busiest = busiestCore(store.topology, cpu?.perCore ?? []) {
@@ -552,6 +553,10 @@ struct ThermalPopover: View {
}
}
}
case .thermalPower:
SectionCard(title: section.title, trailing: { Text(store.power?.system.map(Format.watts) ?? "—") }) {
PowerRows(power: store.power, history: store.powerHistory.elements, compact: true)
}
default:
EmptyView()
}
@@ -566,3 +571,32 @@ func fanStatusText(fans: [FanState], mode: FanController.Mode) -> String {
guard fans.contains(where: \.isManual) else { return "由 macOS 调节" }
return mode == .automatic ? "其他程序手动控制" : "OpenStats 控制中"
}
/// 功耗明细:整机、电源输入、电池、GPU,附整机功耗走势
struct PowerRows: View {
let power: PowerReading?
let history: [Double]
var compact = false
var body: some View {
if let power, power.system != nil || power.gpu != nil {
if history.count > 1 {
LineHistoryChart(values: history, maxValue: max(history.max() ?? 0, 10) * 1.2,
color: DS.Palette.warning, height: compact ? DS.Size.tileChart : DS.Size.chartHeight)
}
if let system = power.system { InfoRow(label: "整机", text: Format.watts(system)) }
if let adapter = power.adapter { InfoRow(label: "电源输入", text: Format.watts(adapter)) }
if let battery = power.battery {
// PPBR 为正表示电池在放电供电,接通电源时是充电功率
InfoRow(label: power.adapter == nil ? "电池放电" : "电池充电", text: Format.watts(abs(battery)))
}
if let gpu = power.gpu { InfoRow(label: "GPU", text: Format.watts(gpu)) }
Text("由 SMC 与系统能耗统计读取;新款芯片不提供可靠的 CPU 单独功耗")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
} else {
Text("这台 Mac 不提供功耗读数").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
}
@@ -29,6 +29,8 @@ enum SnapshotRenderer {
demand.processes = true
demand.temperatures = Set(TemperatureGroup.allCases)
demand.fans = true
demand.power = true
demand.cpuFrequency = true
await model.hub.update(demand)
await model.hub.start { snapshot in model.handle(snapshot) }
try? await Task.sleep(for: .seconds(12))
@@ -77,6 +77,8 @@ public final class AppModel {
if showing(.gpu, .gpu) { groups.insert(.gpu) }
if settings.enabledAlerts.contains(.cpuTemperature) { groups.insert(.cpu) }
demand.temperatures = groups
demand.power = (window && tab == .thermal) || thermalPopover
demand.cpuFrequency = showing(.cpu, .cpu)
demand.fans = (window && (tab == .thermal || tab == .overview)) || menu.contains(.fan)
|| fans.mode != .automatic || thermalPopover
return demand
@@ -59,8 +59,8 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
case .memory: [.memoryWaterline, .memoryCompression, .memoryApps]
case .network: [.networkHistory, .networkProbe, .networkInterface, .networkAddresses, .networkDNS, .networkProcesses]
case .gpu: [.gpuHistory, .gpuDetails]
case .temperature: [.thermalSensors, .thermalFans]
case .fan: [.thermalFans, .thermalSensors]
case .temperature: [.thermalSensors, .thermalFans, .thermalPower]
case .fan: [.thermalFans, .thermalSensors, .thermalPower]
}
}
@@ -85,7 +85,7 @@ public enum PopoverSection: String, CaseIterable, Identifiable, Sendable {
case memoryWaterline, memoryCompression, memoryApps
case networkHistory, networkProbe, networkInterface, networkAddresses, networkDNS, networkProcesses
case gpuHistory, gpuDetails
case thermalSensors, thermalFans
case thermalSensors, thermalFans, thermalPower
public var id: String { rawValue }
@@ -107,6 +107,7 @@ public enum PopoverSection: String, CaseIterable, Identifiable, Sendable {
case .gpuDetails: "显卡信息"
case .thermalSensors: "温度"
case .thermalFans: "风扇"
case .thermalPower: "功耗"
}
}
}
@@ -36,12 +36,18 @@ public final class MetricsStore {
public private(set) var processes: [ProcessUsage] = []
public private(set) var systemCounts: SystemCounts?
public private(set) var sensors: SensorReadings?
public private(set) var power: PowerReading?
public private(set) var powerHistory = History<Double>(capacity: historyCapacity)
public private(set) var lastUpdate: Date?
public init() {}
public func apply(_ snapshot: MetricsSnapshot) {
lastUpdate = snapshot.date
if let power = snapshot.power {
self.power = power
if let system = power.system { powerHistory.append(system) }
}
if let cpu = snapshot.cpu {
self.cpu = cpu
cpuTotal.append(cpu.total)
@@ -0,0 +1,46 @@
import Foundation
import Testing
@testable import Metrics
@Suite struct PowerSamplerTests {
private func table(_ values: [UInt32]) -> Data {
var data = Data()
for value in values {
withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) }
withUnsafeBytes(of: UInt32(800).littleEndian) { data.append(contentsOf: $0) }
}
return data
}
@Test func parsesTablesInHertzAndMegahertz() {
#expect(PowerSampler.parseFrequencyTable(table([600_000_000, 1_200_000_000, 3_200_000_000])) == [600, 1200, 3200])
#expect(PowerSampler.parseFrequencyTable(table([1308, 2292, 4608])) == [1308, 2292, 4608])
// 第一项为 0 的是 GPU 等模块的表
#expect(PowerSampler.parseFrequencyTable(table([0, 338_000, 1_620_000])) == nil)
#expect(PowerSampler.parseFrequencyTable(Data([1, 2, 3])) == nil)
}
@Test func recognizesComplexChannels() {
#expect(PowerSampler.complexLetter("ECPU") == "E")
#expect(PowerSampler.complexLetter("PCPU") == "P")
#expect(PowerSampler.complexLetter("MCPU1") == "M")
#expect(PowerSampler.complexLetter("PCPM") == nil)
#expect(PowerSampler.complexLetter("MCPM0_IDLE") == nil)
}
@Test func weightsFrequencyByResidencyAndOrdersClusters() {
let tables: [[Double]] = [[600, 1000, 2000], [1000, 2000, 3000, 4000]]
let efficiency = PowerSampler.ComplexResidency(letter: "E", states: [("IDLE", 900), ("V0P2", 50), ("V1P1", 0), ("V2P0", 50)])
let performance = PowerSampler.ComplexResidency(letter: "P", states: [("DOWN", 10), ("IDLE", 10), ("V0P3", 0), ("V1P2", 0), ("V2P1", 0), ("V3P0", 100)])
let result = PowerSampler.clusterFrequencies([efficiency, performance], tables: tables, perfLevels: 2)
#expect(result[0] == 4000)
#expect(result[1] == 1300)
// 核心组数与 perflevel 数对不上时不猜
#expect(PowerSampler.clusterFrequencies([efficiency], tables: tables, perfLevels: 2).isEmpty)
}
@Test func convertsEnergyUnits() {
#expect(PowerSampler.joulesPerUnit("nJ") == 1e-9)
#expect(PowerSampler.joulesPerUnit("mJ") == 1e-3)
}
}