Summary
- OS: Linux (Ubuntu 22.04, x86_64, 128 cores, ~15 600 processes)
- Type: performance
Description
On a busy machine process_iter(attrs=[...]) is slow, and I wanted to find out where the time actually goes before proposing anything. The answer surprised me, so I am opening this before writing a real patch.
Setup: psutil master, Python 3.12, ~15 600 processes, 12 attributes (the set Glances asks for):
cpu_percent, cpu_times, memory_percent, name, status, num_threads, io_counters, memory_info, nice, pid, gids, cpu_num.
Where the time goes
In steady state psutil opens 4 files per process — stat, status, statm, io — and all four are genuinely needed for those attributes. There is no redundant read to remove. (On the first process_iter() call stat is read twice per process, because Process.__init__ calls create_time() outside the oneshot() window, but that is a one-off.)
So I measured the ceiling of "make I/O free": I preloaded every one of those files into a dict and monkeypatched open_binary/bcat to serve from memory, so the measured run does literally zero file I/O.
psutil as-is min=3036.6ms 1.00x
psutil with ALL file I/O removed min=2269.6ms 1.34x
Even with I/O costing nothing, it is only 1.34x. Roughly three quarters of the time is the per-process Python machinery: creating a Process, as_dict(), the wrap_exceptions / memoize_when_activated decorator chain, and the namedtuple constructors.
That rules out the obvious fix (reading the files more cheaply, in C or otherwise) — it cannot pay off. The only way to a large win is to not build the per-process Python objects at all.
Proof of concept
I put a draft together to check that the ceiling is real:
https://github.com/Dipet/psutil/tree/perf/batch-process-scan-draft
It adds psutil/arch/linux/proc_scan.c, which reads stat/status/statm/io for every pid in one call with the GIL released, and a batch_scan() in _pslinux.py that process_iter() uses to prefill Process._prefetch. It falls back to the existing per-process path whenever the requested attrs are not fully covered, or for a process that appeared after the scan. Because the scan holds no GIL, it splits across a few threads (min(4, num_pids // 4000)).
process_iter(12 attrs), 15 623 procs, 4 interleaved pairs
per-process path median=3407.0ms
batch path median=1198.6ms
speedup: 2.84x
Of the remaining 1.2s, 59% is the C scan and 41% is still Python. Two obvious leftovers: posix.listdir costs 0.19s because pids() walks /proc again after the scan already enumerated it, and 0.17s goes into namedtuple constructors.
I compared the resulting dicts against the per-process path for every process: the key sets are identical and the only differing values are ones that genuinely move between two samples (cpu_times off by one tick, cpu_num, kworkers renaming themselves). As a control, two consecutive process_iter() runs of unmodified psutil disagree with each other on those same fields at the same rate or higher.
The part I cannot decide myself
241 tests pass, but 8 in TestProcessIter fail, and they are not sloppiness on my side — they are a genuine collision:
test_attrs, test_emulate_access_denied, test_emulate_nsp,
test_prefetch_ad_value, test_prefetch_ad_value_is_not_none,
test_prefetch_derived_methods, test_prefetch_derived_username,
test_prefetch_memory_percent
They mock psutil._psplatform.Process.<method> to raise AccessDenied and assert that process_iter() substitutes ad_value. A batch path does not call those methods, so the mocks cannot take effect. Making them pass would mean routing through the platform methods again, which is exactly the cost being removed.
There is also a real gap underneath the test artifact: as_dict(ad_value=...) substitutes on AccessDenied, and the batch has no such layer. Today it returns None for an unreadable /proc/<pid>/io, which coincides with the default ad_value but is wrong for any other value. That part I can fix — the scan already distinguishes "could not read" from "read".
So before I invest in a mergeable patch: is a batch path that bypasses _psplatform.Process something you would consider at all, given it needs TestProcessIter to be reworked? If not, I would rather know now. If yes, I am happy to do the work — including whatever shape you prefer for the API (internal-only, opt-in, or transparent as in the draft).
Happy to provide the profiles or rerun anything on this machine.
Summary
Description
On a busy machine
process_iter(attrs=[...])is slow, and I wanted to find out where the time actually goes before proposing anything. The answer surprised me, so I am opening this before writing a real patch.Setup: psutil master, Python 3.12, ~15 600 processes, 12 attributes (the set Glances asks for):
cpu_percent, cpu_times, memory_percent, name, status, num_threads, io_counters, memory_info, nice, pid, gids, cpu_num.Where the time goes
In steady state psutil opens 4 files per process —
stat,status,statm,io— and all four are genuinely needed for those attributes. There is no redundant read to remove. (On the firstprocess_iter()callstatis read twice per process, becauseProcess.__init__callscreate_time()outside theoneshot()window, but that is a one-off.)So I measured the ceiling of "make I/O free": I preloaded every one of those files into a dict and monkeypatched
open_binary/bcatto serve from memory, so the measured run does literally zero file I/O.Even with I/O costing nothing, it is only 1.34x. Roughly three quarters of the time is the per-process Python machinery: creating a
Process,as_dict(), thewrap_exceptions/memoize_when_activateddecorator chain, and the namedtuple constructors.That rules out the obvious fix (reading the files more cheaply, in C or otherwise) — it cannot pay off. The only way to a large win is to not build the per-process Python objects at all.
Proof of concept
I put a draft together to check that the ceiling is real:
https://github.com/Dipet/psutil/tree/perf/batch-process-scan-draft
It adds
psutil/arch/linux/proc_scan.c, which readsstat/status/statm/iofor every pid in one call with the GIL released, and abatch_scan()in_pslinux.pythatprocess_iter()uses to prefillProcess._prefetch. It falls back to the existing per-process path whenever the requested attrs are not fully covered, or for a process that appeared after the scan. Because the scan holds no GIL, it splits across a few threads (min(4, num_pids // 4000)).Of the remaining 1.2s, 59% is the C scan and 41% is still Python. Two obvious leftovers:
posix.listdircosts 0.19s becausepids()walks/procagain after the scan already enumerated it, and 0.17s goes into namedtuple constructors.I compared the resulting dicts against the per-process path for every process: the key sets are identical and the only differing values are ones that genuinely move between two samples (
cpu_timesoff by one tick,cpu_num, kworkers renaming themselves). As a control, two consecutiveprocess_iter()runs of unmodified psutil disagree with each other on those same fields at the same rate or higher.The part I cannot decide myself
241tests pass, but8inTestProcessIterfail, and they are not sloppiness on my side — they are a genuine collision:They mock
psutil._psplatform.Process.<method>to raiseAccessDeniedand assert thatprocess_iter()substitutesad_value. A batch path does not call those methods, so the mocks cannot take effect. Making them pass would mean routing through the platform methods again, which is exactly the cost being removed.There is also a real gap underneath the test artifact:
as_dict(ad_value=...)substitutes onAccessDenied, and the batch has no such layer. Today it returnsNonefor an unreadable/proc/<pid>/io, which coincides with the defaultad_valuebut is wrong for any other value. That part I can fix — the scan already distinguishes "could not read" from "read".So before I invest in a mergeable patch: is a batch path that bypasses
_psplatform.Processsomething you would consider at all, given it needsTestProcessIterto be reworked? If not, I would rather know now. If yes, I am happy to do the work — including whatever shape you prefer for the API (internal-only, opt-in, or transparent as in the draft).Happy to provide the profiles or rerun anything on this machine.