Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ This project follows a Keep a Changelog-style format and uses version tags for r

## [Unreleased]

## [1.6.4] - 2026-08-09

### Fixed
- **Process CPU% was understated ~41.7x on every Apple Silicon machine.** The
two CPU-time fields in `proc_pidinfo` (`pti_total_user` / `pti_total_system`)
are mach absolute-clock ticks, not nanoseconds — on Apple Silicon the
timebase is 125/3 (1 tick = 41.667 ns). `get_native_processes()` returned
those raw ticks as `cpu_time_ns`, so every process's CPU% read ~2.4% of its
real value (e.g. 1.2 s of burned CPU appeared as 0.03 s). `proc_pidinfo`'
actually reports nanoseconds only by coincidence on Intel (timebase 1/1),
which is why the offset-verified offsets never surfaced the unit. The module
now reads `mach_timebase_info` once at import and converts via integer math
(`_mach_ticks_to_ns`). Per-process `cpu_time_share` and watt attribution are
ratio-based, so they were never affected. See Apple openradar FB9546856.

## [1.6.3] - 2026-08-08

### Added
Expand Down
46 changes: 41 additions & 5 deletions actop/native_sys.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@
]
_host_statistics64.restype = ctypes.c_int

_mach_timebase_info = _libc.mach_timebase_info
_mach_timebase_info.argtypes = [ctypes.c_void_p]
_mach_timebase_info.restype = ctypes.c_int

_proc_listpids = _libc.proc_listpids
_proc_listpids.argtypes = [
ctypes.c_uint32,
Expand Down Expand Up @@ -199,6 +203,36 @@

_THERMAL_STATES = {0: "Nominal", 1: "Fair", 2: "Serious", 3: "Critical"}

# mach_timebase_info ratio: on Apple Silicon numer/denom = 125/3, so one mach
# tick is 41.667 ns; on Intel it is 1/1. `proc_pidinfo` reports per-process
# CPU time (pti_total_user/pti_total_system) in these mach ticks, NOT
# nanoseconds -- converting raw values with the ratio is required to get ns.
# See Apple openradar FB9546856 ("incorrect value for values reported by
# libproc") and htop's Platform_machTicksToNanoseconds. The ratio is read once
# at import; it never changes for a running kernel.
if _DARWIN:

class _Timebase(ctypes.Structure):
_fields_ = [("numer", ctypes.c_uint32), ("denom", ctypes.c_uint32)]

_timebase = _Timebase()
_mach_timebase_info(ctypes.byref(_timebase))
_TIMEBASE_NUMER = _timebase.numer or 1
_TIMEBASE_DENOM = _timebase.denom or 1
else:
_TIMEBASE_NUMER = 1
_TIMEBASE_DENOM = 1


def _mach_ticks_to_ns(ticks):
"""Convert proc_pidinfo mach-tick CPU times to nanoseconds.

Integer math, so the conversion is exact for any value a real process
accumulates (tens of milliseconds to tens of hours of CPU time stay well
inside u64). No float rounding.
"""
return ticks * _TIMEBASE_NUMER // _TIMEBASE_DENOM


# ---------------------------------------------------------------------------
# Thermal pressure (NSProcessInfo.thermalState)
Expand Down Expand Up @@ -583,14 +617,16 @@ def get_process_cmdline(pid: int) -> str:


# proc_taskallinfo byte layout (proc_bsdinfo + proc_taskinfo), arm64.
# Verified on macOS Sonoma (14) / Sequoia (15). Version-sensitive: a kernel
# struct change shifts these and must be re-verified, not assumed.
# Verified on macOS Sonoma (14) / Sequoia (15) / Tahoe (26). Version-sensitive:
# a kernel struct change shifts these and must be re-verified, not assumed.
# The two CPU-time fields (pti_total_user/pti_total_system at 152/160) are
# mach ticks, not nanoseconds -- convert via _mach_ticks_to_ns.
_PROC_PIDTASKALLINFO = 2
_PTAI_SIZE = 232 # sizeof(struct proc_taskallinfo)
_OFF_COMM = 48 # char p_comm[16] (fallback name)
_OFF_NAME = 64 # char proc_name[32]
_OFF_START_TVSEC = 120 # uint64 pbi_start_tvsec (process start, seconds)
_OFF_PROC_METRICS = 136 # uint64 x4: vms, rss, user_time, sys_time
_OFF_PROC_METRICS = 136 # uint64 x4: vms, rss, user_time(tick), sys_time(tick)
_OFF_THREADS = 220 # uint32 pti_threadnum


Expand Down Expand Up @@ -633,7 +669,7 @@ def get_native_processes() -> list:
.strip()
)

_vms_bytes, rss_bytes, user_ns, sys_ns = struct.unpack_from(
_vms_bytes, rss_bytes, user_ticks, sys_ticks = struct.unpack_from(
"<QQQQ", raw, _OFF_PROC_METRICS
)
(threads_count,) = struct.unpack_from("<I", raw, _OFF_THREADS)
Expand All @@ -647,7 +683,7 @@ def get_native_processes() -> list:
"name": name,
"rss_bytes": rss_bytes,
"num_threads": threads_count,
"cpu_time_ns": user_ns + sys_ns,
"cpu_time_ns": _mach_ticks_to_ns(user_ticks + sys_ticks),
"start_tvsec": start_tvsec,
}
)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "actop"
version = "1.6.3"
version = "1.6.4"
description = "Apple Silicon (M1–M4) power, GPU, ANE & memory-bandwidth monitor — sudoless TUI + Python API for profiling local LLM / MLX / CoreML inference"
readme = "README.md"
license = { text = "MIT" }
Expand Down
48 changes: 48 additions & 0 deletions tests/test_native_sys.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,54 @@ def test_native_processes_include_current_process():
assert me["num_threads"] >= 1


def test_cpu_time_ns_is_nanoseconds_not_mach_ticks():
# proc_pidinfo reports per-process CPU time in mach ticks, not nanoseconds
# (Apple Silicon timebase 125/3, so ticks are ~41.7x smaller -- see Apple
# openradar FB9546856). A unit regression reads ~1/41.7 of real CPU time
# here, which the share-based test in test_per_process_power.py cannot see
# (its numerator and denominator shrink together). Burning CPU and
# comparing the raw cpu_time_ns delta against getrusage is the only way
# the unit surfaces.
import resource
import time

def native_cpu_ns():
procs = get_native_processes()
me = next(p for p in procs if p["pid"] == os.getpid())
return me["cpu_time_ns"]

def getrusage_ns():
ru = resource.getrusage(resource.RUSAGE_SELF)
ut = (
ru.ru_utime
if isinstance(ru.ru_utime, float)
else (ru.ru_utime[0] + ru.ru_utime[1] / 1e6)
)
st = (
ru.ru_stime
if isinstance(ru.ru_stime, float)
else (ru.ru_stime[0] + ru.ru_stime[1] / 1e6)
)
return int((ut + st) * 1e9)

get_native_processes() # warm the code path
base_native, base_ru = native_cpu_ns(), getrusage_ns()
end = time.time() + 1.2
x = 0
while time.time() < end:
x += 1
measured = native_cpu_ns() - base_native
real = getrusage_ns() - base_ru

assert real > 0
# Mach ticks are ~2.4% of ns on Apple Silicon, so a unit regression puts
# measured at ~2.4% of real. Requiring 60% tolerates scheduling noise.
assert measured >= real * 0.6, (
f"cpu_time_ns grew {measured / 1e9:.3f}s vs {real / 1e9:.3f}s of real CPU "
f"({measured / real * 100:.1f}%) -- mach-tick unit regression in get_native_processes"
)


def test_dvfs_tables_classify_into_plausible_buckets():
tables = get_dvfs_tables_native()

Expand Down