macOS · Apple Silicon

Reading Apple Silicon temperatures, watts and clocks without sudo

The data powermetrics prints is reachable from a normal user process. The catch is that two of the three doors are private APIs.

Cover illustration: Reading Apple Silicon temperatures, watts and clocks without sudo

Run powermetrics on a Mac and the first thing it tells you is powermetrics must be invoked as the superuser. For a one-off check that’s fine. For a menu bar app that wants to show CPU temperature and package watts all day, asking for root is a non-starter: no one should hand a system monitor a privileged daemon just to read a thermometer.

While building Stat Space I wanted temperatures, power per rail, CPU and GPU clocks, and fan speeds, all as a normal user process. It’s possible. Here’s what each number actually comes from, how I load the private parts safely, and the things that differ between M1 and M4 that will bite you.

Three sources, two of them private

WhatWhere it comes fromPrivate?Root?
TemperaturesIOHIDEventSystemClient (HID temperature services)YesNo
CPU, GPU, ANE, DRAM wattsIOReport “Energy Model” groupYesNo
CPU cluster and GPU clocksIOReport performance-state residency + pmgr tables in the IORegistryYes (IOReport)No
Fans, voltages, system powerAppleSMC via IOKitNoRead: no. Write: yes
GPU utilizationIOAccelerator “PerformanceStatistics”NoNo

Diagram of the three no-root sensor sources on Apple Silicon: IOHIDEventSystemClient for temperatures, IOReport for CPU, GPU, ANE and DRAM watts and clocks, and AppleSMC for fans and whole-Mac power, all feeding one Snapshot

Three doors, no root: two private frameworks loaded with dlopen, and the public AppleSMC user client.

None of this is new territory. Open-source tools like Stats and macmon use these routes too, and the macmon project in particular showed that IOReport gives you powermetrics-style power numbers without sudo. What I want to share is the shape of a version that’s safe to ship and cheap to run.

Rule one: dlopen everything private

Private APIs can change or disappear in any macOS update. If you link them directly and a symbol goes away, your app doesn’t launch. So nothing private is linked. Each source resolves its symbols at runtime with dlopen / dlsym, and its initializer is failable:

init?() {
    guard let h = dlopen("/usr/lib/libIOReport.dylib", RTLD_NOW) else { return nil }
    func sym<T>(_ name: String, _: T.Type) -> T? {
        guard let p = dlsym(h, name) else { return nil }
        return unsafeBitCast(p, to: T.self)
    }
    guard let copyChannels = sym("IOReportCopyChannelsInGroup", CopyChannelsFn.self),
          let createSamples = sym("IOReportCreateSamples", CreateSamplesFn.self)
          /* … */ else { return nil }
    // …
}

If any symbol is missing, init returns nil, the sampler reports isAvailable == false, the scheduler never runs it, and the snapshot’s capability flags tell the UI to hide that feature. A user on some future macOS where IOReport broke would see no power readings, not a crash. Sensors that don’t resolve are hidden too, never shown as 0, because a 0 °C CPU is worse than no reading.

The function pointers are declared as @convention(c) typealiases using raw pointers for every CF object. That keeps the signatures trivially representable, and ownership is handled explicitly with Unmanaged (takeRetainedValue for Copy/Create functions, takeUnretainedValue for Get functions).

Temperatures: IOHIDEventSystemClient

Apple Silicon exposes its thermal sensors as HID services. The symbols live in IOKit but aren’t in the public headers, so they’re looked up the same way. The flow is:

  1. IOHIDEventSystemClientCreate, then IOHIDEventSystemClientSetMatching with PrimaryUsagePage: 0xff00 (Apple vendor) and PrimaryUsage: 5 (temperature sensor).
  2. IOHIDEventSystemClientCopyServices once, and read each service’s Product property for its name.
  3. Per read: IOHIDServiceClientCopyEvent with event type 15 (temperature), then IOHIDEventGetFloatValue with field 15 << 16.
func temperature(at i: Int) -> Double? {
    guard let svc = CFArrayGetValueAtIndex(services, i),
          let ev = copyEvent(svc, 15, 0, 0) else { return nil }
    let v = getFloat(ev, 15 << 16)
    Unmanaged<AnyObject>.fromOpaque(ev).release()
    guard v.isFinite, v > 0, v < 150 else { return nil }
    return v
}

The sanity bound matters. You will occasionally get garbage, and throwing out anything non-finite, non-positive or over 150 °C is cheaper than explaining a 4,000 °C spike in a chart.

Sensor names are the hard part

What comes back is a list of product names like pACC MTR Temp Sensor0, eACC MTR Temp Sensor3, GPU MTR Temp Sensor1, PMU tdie, NAND CH0 temp, gas gauge battery. Which ones exist, and how many, depends on the chip. There’s no “CPU temperature” sensor. You decide what “CPU temperature” means.

Stat Space maps names with a prefix table. Each entry has a group, a display name, an optional role (cpu, gpu, ssd, battery) and a priority:

{ "prefix": "pACC MTR Temp Sensor", "group": "CPU", "name": "CPU P-core", "role": "cpu", "priority": 1 },
{ "prefix": "PMU tdie", "group": "SoC", "name": "SoC die", "role": "cpu", "priority": 2 },
{ "prefix": "GPU MTR Temp Sensor", "group": "GPU", "name": "GPU", "role": "gpu", "priority": 1 }

The CPU summary is the mean of the best-priority sensors present: the P-core and E-core cluster sensors if the chip has them, the PMU die sensors only as a fallback. The longest matching prefix wins. Because the table will be wrong on some chip I haven’t seen, it ships embedded but can be overridden by a JSON file in Application Support, so fixing a new Mac is a data change, not a release.

If you’re comparing numbers between apps, this is why they disagree: different apps average different sensors. Neither is lying. There’s a guide on what the readings mean at https://statspace.app/guides/apple-silicon-normal-temperature.

Power: IOReport energy deltas

IOReport is the kernel’s reporting framework, and it’s where powermetrics-style numbers come from. Power isn’t reported directly. Energy is. The steps:

  1. IOReportCopyChannelsInGroup("Energy Model") once.
  2. IOReportCreateSubscription once. The subscription lives as long as the process.
  3. Every sample: IOReportCreateSamples, then IOReportCreateSamplesDelta(previous, current).
  4. Walk the delta’s channels, read each with IOReportSimpleGetIntegerValue, convert to joules, divide by elapsed seconds.

The first call only primes the baseline and returns nothing. That also means after a suspend (nothing on screen needs power), the baseline is dropped and the next sample primes again, rather than dividing a ten-minute energy delta by a two-second interval.

Channels are classified by name once, and only again if the channel count changes:

  • *CPU Energy is CPU. The suffix match covers Ultra chips, which report one per die as DIE_n_CPU Energy.
  • GPU Energy is GPU. Some chips have only a channel called GPU, so that’s a fallback, used only when GPU Energy is absent.
  • ANE* is the Neural Engine, DRAM* is memory.

Units come from IOReportChannelGetUnitLabel (mJ, µJ, nJ). When the label is missing, macOS reports mJ on M1 through M4, so that’s the default. Package power is CPU + GPU + ANE + DRAM.

IOReport Energy Model example: a priming sample, a second sample 2 seconds later, and per-channel energy deltas for CPU, GPU, ANE and DRAM converted to watts, summing to 6.4 W package power

Watts are energy deltas divided by elapsed time; the first sample only primes the baseline.

Whole-Mac power is a different number

IOReport gives you the SoC’s rails. The whole Mac (display, SSD, everything) is a separate SMC key, PSTR. Stat Space 1.4.0 added it as its own menu bar item after a user asked for it, since the existing item showed processor power only. PSTR isn’t readable on every Mac, so the app probes it once at startup and hides the option where it fails.

Clocks: residency times a frequency table

There’s no “current CPU MHz” value either. IOReport’s “CPU Stats / CPU Complex Performance States” channels report how long each cluster spent in each state: IDLE, OFF, then a list of DVFS (frequency/voltage) states. The GPU has the same under “GPU Stats / GPU Performance States”.

The frequencies for those states live in the IORegistry, on the pmgr service: voltage-states1-sram for the E-cluster, voltage-states5-sram for the P-cluster, voltage-states9 for the GPU. They’re arrays of (frequency, voltage) pairs. The average active clock over an interval is:

MHz = Σ (residency in state i × frequency of state i) / Σ residency in active states

Worked example of the P-cluster clock: IDLE and OFF residency dropped, active DVFS states at 600, 1260, 2064 and 3228 MHz weighted by milliseconds of residency, averaging about 3,074 MHz

The average active clock weights each DVFS state’s frequency by how long the cluster spent in it.

Pitfalls I hit here:

  • Skip the idle states. Leading IDLE, OFF and DOWN states are dropped before indexing into the frequency table, otherwise every index is off by one or two.
  • Units changed on M4. The pmgr tables are in Hz on earlier chips and kHz on the M4 family. The code checks the magnitude of the largest entry to decide.
  • Duplicate views. ECPM / PCPM channels are the clusters’ power-manager views of the same thing. Count them and you double the weight. Only ECPU*, PCPU* and GPUPH are used.
  • Idle means no clock. If a cluster was idle the whole interval there’s no meaningful average, so it’s nil, not 0.

Fans and the SMC: public, but fiddly

The SMC isn’t private in the dlopen sense. It’s an IOKit user client you open with IOServiceOpen on AppleSMC, then call with a parameter block that has to match the C SMCKeyData_t exactly: 80 bytes. Swift lays out nested structs by size, not stride, so I needed explicit padding to put the result field at the right offset, and an assertion that the struct is 80 bytes.

Each key is four characters packed into a big-endian UInt32 (F0Ac is fan 0 actual RPM, F0Mn / F0Mx its min and max). On Apple Silicon, flt values are little-endian Float32, while integer and fixed-point types are big-endian. Mix those up and you get nonsense values that look like real numbers.

Reading works as a normal user. Writing doesn’t: forcing a fan speed needs root, and on Apple Silicon also needs a test-mode key set to take manual control from the system’s thermal daemon. That’s why fan control is the one root component in Stat Space, an opt-in helper installed only when you turn it on (https://statspace.app/features/fan-control). Fanless Macs simply have no fan keys, and the fan UI hides.

Sampling cost: the sensor list is not free

The surprise for me was how expensive temperatures are. Each HID temperature read is a kernel round trip of roughly a millisecond, and a chip can expose dozens of sensors. A full pass costs tens of milliseconds, every time. At 1 s sampling that alone would blow a 0.3% idle CPU budget.

So sensors have a lite mode, used whenever nothing on screen shows the full sensor list (for example, only the menu bar temperature or a background alert needs it):

  • Keep only the best-priority CPU and GPU sensors, the ones that feed the summary values.
  • Read at most four of them per tick, in rotation. The rest keep their last value, so the mean stays whole.
  • Read the fans, skip the SMC voltage list.

Sensors and power also run slower than CPU: every 2 s with the popover open, 5 s for a menu bar item, 10 s if only a background feature needs them, and off otherwise. Temperatures don’t change fast enough to justify more.

What I’d pass on

  • powermetrics needs root; IOReport, HID sensors and SMC reads don’t.
  • dlopen every private symbol and make each feature disappear cleanly when loading fails.
  • Treat sensor names as data. Ship a table, let it be updated without a release.
  • Power is energy over time. Prime the baseline, and re-prime after any pause.
  • Check units every chip generation. M4 changed the frequency tables.
  • Budget the reads. A thermometer that costs a percent of CPU is heating the thing it measures.

Stat Space is the menu bar monitor where I put all of this together: a free, lean system monitor for Apple Silicon with a one-time Pro upgrade. If you just want to know how hot your Mac is right now, the guide at https://statspace.app/guides/check-mac-temperature covers that, and the app is at https://statspace.app.

Questions

Does reading Mac temperatures need root?

No. On Apple Silicon, temperature sensors are readable through IOHIDEventSystemClient as a normal user. powermetrics needs root, but it isn't the only way in.

How do tools show CPU and GPU watts without sudo?

They read the IOReport "Energy Model" counters, take two samples, and divide the energy difference by the elapsed time. That is the approach macmon popularized.

Can fan speed be changed without root?

Reading fan speed from the SMC works as a normal user. Writing to the SMC to force a fan speed needs root, which is why Stat Space's fan control is an opt-in helper.

Get started

Know what your Mac is doing, without adding to it.

Free, no account. Pro is $9.99 once for the first 100 buyers.