perf: resolve each uid once per process_iter() call - #2957
Open
Dipet wants to merge 2 commits into
Open
Conversation
username() calls pwd.getpwuid() for every process. That goes through NSS, which on a host resolving users over a directory service costs a fraction of a millisecond, and a system has far fewer users than processes. Reuse is scoped to a single process_iter() call, so a bare username() still resolves the uid every time and cannot hand back a stale name.
pwd.getpwuid() raises KeyError for a uid with no entry behind it, and resolve() let that propagate without recording anything, so username() paid a full NSS lookup for every process owned by a deleted account. Moving the str(uid) fallback out of username() and into resolve() lets a miss be cached like a hit, and drops the try/except at the call site. Measured on a host with 21153 processes and 79 distinct uids, two of which resolve to nothing and are shared by 607 processes (NSS over the network, ~800us per lookup): getpwuid() calls per process_iter() drop from 688 to 79, worth ~486ms per scan. Add tests for both paths, and wrap the docstrings at 79 columns.
Dipet
force-pushed
the
perf/cache-username-within-process-iter
branch
from
August 7, 2026 15:39
72a62e5 to
abbe51a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Description
Process.username()callspwd.getpwuid()on every invocation. That goes through NSS, and on a host that resolves users over a directory service each call costs a fraction of a millisecond. A system has orders of magnitude fewer users than processes, so a fullprocess_iter(attrs=["username", ...])resolves the same handful of uids thousands of times.On a box with ~15 600 processes and 80 distinct uids:
process_iter(attrs=["username", "pid", "name"])4.33x. Resolved names are identical to the uncached ones for all 15 196 processes present in both runs.
Scope of the reuse
Reuse is deliberately limited to a single
process_iter()call rather than being process-wide. A snapshot is already a snapshot, so reusing an answer inside one is free of consequences; keeping it across calls would mean a long-running monitor reports a renamed user under the old name until restart. That is a behaviour change I did not want to make on your behalf.Outside
process_iter()a bareusername()resolves the uid every time, exactly as today. Nesting keeps the outer scope's cache, and concurrent use from several threads is safe: the mapping does not depend on the caller, so the worst case is that one scan resolves a uid another one had already looked up.If you would rather have a wider cache, this becomes a one-liner —
functools.lru_cacheon the resolver, plus acache_clearhook alongside the existingprocess_iter.cache_clear. I am happy to switch to that if you consider the staleness acceptable; I just did not want to decide it myself. Notelru_cacheevicts by recency rather than age, so hot uids would effectively never be refreshed.Update: uids the system cannot resolve
The first commit cached successful lookups only.
pwd.getpwuid()raisesKeyErrorfor a uid with no entry behind it, andresolve()let that propagate tousername(), which turned it intostr(uid)without recording anything — so every process owned by a deleted account still paid a full NSS round trip.Moving that fallback out of
username()and intoresolve()lets a miss be cached like a hit, and removes thetry/exceptat the call site.Measured on the same host, now at 21 153 processes / 79 distinct uids, two of which resolve to nothing and are shared by 607 processes (
passwd: files <network module>innsswitch.conf,nscdandsssdboth inactive, ~800 µs per failing lookup):getpwuid()calls perprocess_iter()The 609 avoidable calls are worth ~486 ms per scan. The
usernameattribute alone goes from 12 155 ms to 1 638 ms (7.4x) on this host.For a real consumer: a full
glancesrefresh that includes its cached attributes drops from 20.2 s to 8.6 s with this PR plus #2955.Tests
Two tests added to
TestProcessIter, both of which fail without the change:test_username_resolves_each_uid_once21089 <= 79❌689 <= 79❌test_username_resolves_each_unknown_uid_once21214 <= 79❌21234 <= 79❌The first guards the original claim; the second isolates the miss path by mocking every lookup to fail.
tests/test_system.py tests/test_process.py→ 151 passed, 9 skipped, 3 failed, plus 3 passed in theisolatedpass.The three failures —
test_long_name,test_memory_maps_lists_libandtest_users— fail identically on unmodifiedupstream/masterin this environment, verified by running exactly those three on both.test_pid_exists_2was deselected. A profile shows it spends its time in theexcept AssertionErrorbranch, where each process that died betweenpsutil.pids()andpid_exists()coststime.sleep(0.1)plus a full re-walk of/proc. This machine has ~21 000 processes and 88 logged-in users, so that branch fires constantly and the test runs for minutes — and since it is a single test it pins onexdistworker while the other 15 idle. Unrelated to this change, which does not touchpids(), but it looks worth fixing separately: comparing against one snapshot taken after the loop would do the same job.