gh-92041: Avoid repeated sys.modules scans in inspect.getmodule - #92042
gh-92041: Avoid repeated sys.modules scans in inspect.getmodule#92042mdeck wants to merge 6 commits into
Conversation
|
Every change to Python requires a NEWS entry. Please, add it using the blurb_it Web app or the blurb command-line tool. |
|
Every change to Python requires a NEWS entry. Please, add it using the blurb_it Web app or the blurb command-line tool. |
eendebakpt
left a comment
There was a problem hiding this comment.
Looks good. Is the new case also covered by tests?
|
The recursion-break test suggested code objects should be distinguished by object instance rather than by their hash alone. I'm using id & hash together as best effort to uniquely identify code objects that have no module. I assume identity comparison with weakref would be the ideal solution, but the performance is O(N) rather than O(1), with N = len(sys.modules). The identity approximation seemed a reasonable trade-off to me. |
9bbf165 to
725a326
Compare
|
Rebased past some bad upstream that broke CI. You can view the weakref implementation I tested here.. Also worth noting, the tests I've shown were run by pasting into interactive console. With same test code in a module that is executed, it will run faster (though still slower than with this fix) -- |
|
Also it seems as though the CI has stalled since last update. Not sure if I need to make some random change and force a re-run, or if it's not running because I'm a "first-time contributor" -- the description is a bit vague. 4 expected checks, 2 workflows awaiting approval.. not sure what a 'workflow' vs a 'check' is. |
|
Reminder, columns are stack depth, rows are len(sys.modules), numbers are milliseconds. Without changes: With this PR changes: Also testing with weakref version: Interesting! With this PR changes: With weakref version: I will take a closer look at what else |
|
Also the With this PR changes: With weakref impl: At higher module counts, either implementation uses less microseconds than the current implementation in milliseconds.. |
|
The docs indicate weakref doesn't work with code objects. They appear to work with simple tests in interactive console. However, debugging the weakref implementation I linked earlier, the _moduleless cache fills with dead weakrefs indexed by the same code object id(). The current use of id ^ hash seems optimal. I have previously explored defining _moduleless as an LRU cache. When testing I got the sense |
|
This PR is stale because it has been open for 30 days with no activity. |
|
Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool. If this change has little impact on Python users, wait for a maintainer to apply the |
|
This PR has been substantially reworked to use an O(1) frame-globals lookup without a persistent cache. The expanded semantic and regression coverage and full CPython CI are now green. @berkerpeksag @lysnikolaou, this should be ready for a fresh review when you have a chance. |
|
Updated performance comparison using the PR’s exact base ( The same compiled interpreter was used for both measurements, switching only between the base and PR
A repeated registered-frame lookup changed from 3.07 µs to 3.40 µs, adding approximately 0.33 µs. Its first uncached lookup improved from 2.24 ms to 28.8 µs. The existing function fast path showed no meaningful change (0.186 versus 0.189 µs). Bare code objects retain the existing filename-based path and also showed no meaningful change. The targeted frame and traceback workloads therefore no longer scale with the size of |
|
I've updated the PR description with a standalone summary of the current implementation, behavior, application impact, exact base-versus-head performance, and verification. It supersedes the earlier cache-based design discussion and old benchmarks in this thread; the current patch can be reviewed from the description and diff without reconstructing that history. |
|
@mdeck Thanks for the updated PR. There might be a behavior change with this PR. A reproducer generated with Claude: ReproducerAn alternative suggested by Claude: main...eendebakpt:cpython:inspect_getmodule_skip_rescan (I have not reviewed it myself yet) |
Restore filename-based module resolution when direct frame-globals lookup cannot prove ownership. Avoid repeated full sys.modules scans while its module names are unchanged.
|
@eendebakpt Thanks for catching this — the reproducer exposed a real compatibility regression in the direct frame path. I’ve pushed 0afefebe. Registered frames still use the direct frame-globals lookup, but when that lookup cannot establish module ownership, I also adapted the suggested snapshot approach for the filename fallback. A miss compares The updated tests cover the exact zipimport/fresh-namespace case, ordinary unregistered frames, tracebacks and bare code objects, a registered namespace whose source belongs to another module, key changes, replacement Against current The focused/dependent suite, optimized mode, pydebug refleak run, free-threaded build, concurrent mutation stress, |
|
The caching is still not good enough, as one could del a module and add a different one under the same name. We could try: |
Track sys.modules and relevant module metadata changes with a per-interpreter generation so filename-based lookups can be reused without returning stale results. Preserve direct registered-frame resolution and the existing filename fallback, including dynamic modules and replacement registries.
|
@eendebakpt Agreed — the names-only snapshot could return a stale module after a same-key replacement, and an ID-based snapshot would still leave reuse and metadata-mutation cases unresolved. I replaced that design in 33ccc7b. The canonical This avoids weak-reference or object-ID heuristics while retaining the steady-state benefit for unregistered frames and bare code objects. It is deliberately more expensive when the registry changes, with representative break-even at roughly three stable misses per mutation; the updated description calls that tradeoff out explicitly. The PR description has been rewritten as a standalone explanation of the current proposal, including current-vs- The final tree passed the 3,234-test release matrix, 945-test free-threaded matrix, and 528-test pydebug/refleak run, plus regeneration and whitespace checks. Thanks for identifying the stale-cache hole. |
|
@mdeck The PR by now has gone through several different approaches. For really new approaches please open new PRs, so we can more easily compare (and avoid force pushes, the original approach I approved I cannot reach any more). The performance gain here is good, but there is also quite some complexity involved and if possible I would like to reduce that. It is possible to split the PR into two parts: 1) the frame check calling |
A frame whose filename belongs to a zipimported module must resolve through the filename fallback so getsource() can use the module's loader. Test adapted from PR python#92042. Co-authored-by: Mike Decker <mrd999@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
This PR speeds up
inspect.getmodule()for frame, traceback, and bare-code lookups without removing its filename-based fallback.A common affected flow is:
inspect.stack()→ frame/source resolution →inspect.getmodule(frame)On
main, a filename-cache miss scans every value insys.modules. This has appeared at application scale: Sentry reportedinspect.stack()increasing from 4% to 54% of execution time in one worker type, and Home Assistant reported 7,184,347ismodule()calls from this path during startup.Generated/private execution globals are used by libraries such as Jinja and attrs. Such frames can remain in the caller chain when logging, validation, or error reporting captures a stack.
Design
The implementation has two complementary paths.
Registered frames and tracebacks
For a frame or traceback,
getmodule()readsf_globals["__name__"], looks that name up insys.modules, requiresmodule.__dict__ is f_globals, and verifies that the code and module origins match. A matching registered frame is resolved directly without scanningsys.modules.If ownership cannot be established, lookup continues through the existing filename-based path. This preserves unregistered execution globals, mismatched registered globals, ordinary files, and loader-backed sources such as zipimport.
Bare code and filename fallback
The filename map is cached while the canonical module registry remains unchanged. A private
_impquery exposes a per-interpreter generation that is incremented when:sys.modulesdictionary is mutated;__file__,__name__, or__getattr__metadata changes; orExact modules use the generation fast path. Module subclasses and modules whose metadata is supplied dynamically are recorded and revalidated individually, so one dynamic module does not disable caching for the rest of the registry.
A replacement
sys.modulesmapping cannot be watched and is therefore scanned on each miss. Cache hits and rebuilt maps are validated against the mapping identity and generation; a scan that overlaps mutation is retried once and otherwise discarded. The cache is published as one tuple so concurrent and free-threaded readers cannot observe mismatched versions and maps.Compatibility and behavior
getsource()behavior are preserved.modulesbyfileand_filesbymodnamedictionary objects and their value shapes are preserved as compatibility mirrors._imphelper is private.Performance
Measured against the PR's fixed
mainbase (09b63170e3) using the same optimized interpreter and 10,002sys.modulesentries:maininspect.stack(), depth 2inspect.stack(), depth 64Rebuilding after a relevant registry mutation remains O(number of modules) and is more expensive than the existing scan because the new cache records enough metadata to validate later lookups. Representative measurements put the break-even at roughly three stable misses per mutation. The optimization therefore targets stack/source inspection after imports and module metadata have settled, rather than workloads that mutate the registry between nearly every lookup.
Verification
The final tree was verified with:
sys.modules, and subinterpreter tests;make -j4,make regen-all, andgit diff --check.Coverage includes registered, unregistered, mismatched-origin, fileless, traceback, bare-code, zipimport, same-name replacement, metadata mutation, dynamic metadata, overlapping scans, cache publication, and module-lifetime cases.