From fda166521a87931bf5f124ef8d804355feb2aa27 Mon Sep 17 00:00:00 2001 From: Bin Le Date: Sun, 9 Aug 2026 11:15:33 -0400 Subject: [PATCH] fix: convert proc_pidinfo CPU time from mach ticks to ns --- CHANGELOG.md | 15 +++++++++++++ actop/native_sys.py | 46 +++++++++++++++++++++++++++++++++----- pyproject.toml | 2 +- tests/test_native_sys.py | 48 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f937f4d..fad9b0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/actop/native_sys.py b/actop/native_sys.py index f1610e1..b485d84 100644 --- a/actop/native_sys.py +++ b/actop/native_sys.py @@ -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, @@ -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) @@ -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 @@ -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( " 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, } ) diff --git a/pyproject.toml b/pyproject.toml index ee5267f..b9b2a6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/tests/test_native_sys.py b/tests/test_native_sys.py index 33c3563..1818177 100644 --- a/tests/test_native_sys.py +++ b/tests/test_native_sys.py @@ -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()