Skip to content

gh-92041: Avoid repeated sys.modules scans in inspect.getmodule - #92042

Open
mdeck wants to merge 6 commits into
python:mainfrom
mdeck:fix_inspect
Open

gh-92041: Avoid repeated sys.modules scans in inspect.getmodule#92042
mdeck wants to merge 6 commits into
python:mainfrom
mdeck:fix_inspect

Conversation

@mdeck

@mdeck mdeck commented Apr 29, 2022

Copy link
Copy Markdown

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 in sys.modules. This has appeared at application scale: Sentry reported inspect.stack() increasing from 4% to 54% of execution time in one worker type, and Home Assistant reported 7,184,347 ismodule() 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() reads f_globals["__name__"], looks that name up in sys.modules, requires module.__dict__ is f_globals, and verifies that the code and module origins match. A matching registered frame is resolved directly without scanning sys.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 _imp query exposes a per-interpreter generation that is incremented when:

  • the canonical sys.modules dictionary is mutated;
  • a module's __file__, __name__, or __getattr__ metadata changes; or
  • a module's class changes, which can make that metadata dynamic.

Exact 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.modules mapping 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

  • The filename fallback and the reported zipimport/fresh-namespace getsource() behavior are preserved.
  • Same-key module replacement and in-place changes to relevant module metadata invalidate the cache.
  • Fileless modules and existing object fast paths retain their behavior.
  • The longstanding modulesbyfile and _filesbymodname dictionary objects and their value shapes are preserved as compatibility mirrors.
  • A registered frame's actual globals owner now wins if an unrelated module later reuses the same filename. This is intentional: globals identity establishes the executing module more precisely than a filename collision.
  • No public API or import semantics are changed; the added _imp helper is private.

Performance

Measured against the PR's fixed main base (09b63170e3) using the same optimized interpreter and 10,002 sys.modules entries:

Workload main PR Result
Existing function fast path 0.221 µs 0.242 µs no material change
Repeated registered frame 3.014 µs 0.872 µs 3.5× faster
Cold registered frame 1.664 ms 1.248 µs 1,333× faster
Repeated unregistered frame 1.469 ms 5.299 µs 277× faster
Repeated mismatched-origin frame 742.8 µs 7.664 µs 97× faster
Repeated bare-code miss 1.508 ms 5.243 µs 288× faster
inspect.stack(), depth 2 905.1 µs 176.9 µs 5.1× faster
inspect.stack(), depth 64 1.326 ms 580.4 µs 2.3× faster
Cached filename fallback hit 2.928 µs 3.582 µs +0.654 µs

Rebuilding 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:

  • a 16-file release-build matrix covering 3,234 tests;
  • a free-threaded matrix covering 945 tests;
  • a pydebug reference-leak run covering 528 tests;
  • targeted concurrent cache-publication, registry-mutation, dynamic-module, replacement-sys.modules, and subinterpreter tests;
  • make -j4, make regen-all, and git 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.

@ghost

ghost commented Apr 29, 2022

Copy link
Copy Markdown

All commit authors signed the Contributor License Agreement.
CLA signed

@bedevere-bot

Copy link
Copy Markdown

Every change to Python requires a NEWS entry.

Please, add it using the blurb_it Web app or the blurb command-line tool.

@mdeck mdeck changed the title Improve performance of inspect.getmodule gh-92041: Improve performance of inspect.getmodule Apr 29, 2022
@bedevere-bot

Copy link
Copy Markdown

Every change to Python requires a NEWS entry.

Please, add it using the blurb_it Web app or the blurb command-line tool.

@eendebakpt eendebakpt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. Is the new case also covered by tests?

Comment thread Lib/inspect.py Outdated
Comment thread Lib/inspect.py Outdated
@mdeck

mdeck commented Apr 30, 2022

Copy link
Copy Markdown
Author

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.

@mdeck
mdeck force-pushed the fix_inspect branch 6 times, most recently from 9bbf165 to 725a326 Compare April 30, 2022 18:26
@mdeck
mdeck marked this pull request as draft April 30, 2022 18:43
@mdeck
mdeck marked this pull request as ready for review April 30, 2022 20:49
@mdeck

mdeck commented May 1, 2022

Copy link
Copy Markdown
Author

Rebased past some bad upstream that broke CI.

You can view the weakref implementation I tested here..
I'm not sure why the performance degraded so much with weakref.

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) -- inspect.stack ends up calling getmodule on each stack frame. This is slower with interactive console frames, as they are moduleless and don't get cached.

@mdeck

mdeck commented May 1, 2022

Copy link
Copy Markdown
Author

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.

@mdeck

mdeck commented May 1, 2022

Copy link
Copy Markdown
Author

run_test shows indirect changes via inspect.stack. Here are numbers with getmodule directly. One change to test:
dur = timeit.timeit(lambda: inspect.getmodule(inspect.currentframe()), number=1)

Reminder, columns are stack depth, rows are len(sys.modules), numbers are milliseconds.

Without changes:

>>> run_test(sys)
           1      2      4      8     16     32     64
   89    2.5    0.2    0.2    0.2    0.2    0.2    0.2
  189    0.7    0.6    0.6    0.6    0.6    0.6    0.6
 1189    5.1    5.1    5.0    5.1    5.5    5.0    5.1
11189   49.0   49.3   50.0   49.3   50.6   49.5   49.7
>>> 

With this PR changes:

>>> run_test(sys)
           1      2      4      8     16     32     64
   89    2.5    0.0    0.0    0.0    0.0    0.0    0.0
  189    0.0    0.0    0.0    0.0    0.0    0.0    0.0
 1189    0.0    0.0    0.0    0.0    0.0    0.0    0.0
11189    0.0    0.0    0.0    0.0    0.0    0.0    0.0
>>> 

Also testing with weakref version:

>>> run_test(sys)
           1      2      4      8     16     32     64
   91    2.5    0.0    0.0    0.0    0.0    0.0    0.0
  191    0.0    0.0    0.0    0.0    0.0    0.0    0.0
 1191    0.0    0.0    0.0    0.0    0.0    0.0    0.0
11191    0.0    0.0    0.0    0.0    0.0    0.0    0.0
>>> 

Interesting! getmodule performance looks equivalent with weakref! So why did I see slower inspect.stack times when using weakref? Let's revisit the inspect.stack times, using original run_test code.

With this PR changes:

>>> run_test(sys)
           1      2      4      8     16     32     64
   89    3.8    0.4    0.6    0.6    0.8    1.3    2.2
  189    0.4    0.4    0.5    0.6    0.8    1.6    2.2
 1189    0.4    0.4    0.4    0.5    1.0    1.3    2.2
11189    0.4    0.5    0.5    0.5    1.0    1.3    2.2
>>> 

With weakref version:

>>> run_test(sys)
           1      2      4      8     16     32     64
   91    4.1    0.5    0.6    0.7    1.0    1.5    2.6
  191    0.7    0.7    1.0    1.0    1.2    1.8    2.8
 1191    2.9    3.0    3.5    3.1    3.5    3.9    5.0
11191   26.4   25.3   25.2   25.4   25.6   26.2   27.3
>>> 

I will take a closer look at what else inspect.stack is doing, to try and explain this.

@mdeck

mdeck commented May 1, 2022

Copy link
Copy Markdown
Author

Also the getmodule times were all 0.0; Here are those numbers in microseconds, rather than milliseonds.

With this PR changes:

>>> run_test(sys)
           1      2      4      8     16     32     64
   89 2447.6    6.2    5.5    6.1    7.1    4.4    7.3
  189    5.8    4.2    4.2    4.7    4.2    4.2    3.9
 1189    4.8    4.4    3.9    4.0    3.7    4.5    4.1
11189   15.3    4.6    4.1    4.0    4.2    4.4    4.1

With weakref impl:

>>> run_test(sys)
           1      2      4      8     16     32     64
   91 2546.2    9.6    8.0    7.3    8.7    6.8   10.8
  191    8.4    6.6    6.1    6.4    5.9    6.5    5.9
 1191    7.1    6.1    6.0    5.8    5.9    5.9   12.9
11191   22.6    7.4    6.6    6.2    5.9    6.2    6.0

At higher module counts, either implementation uses less microseconds than the current implementation in milliseconds..

@mdeck

mdeck commented May 1, 2022

Copy link
Copy Markdown
Author

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 getmodule returning None was rare, so the LRU complexity was unnecessary. I am curious if that was a safe assumption - perhaps there are use cases that involve dynamic generated code objects being queried. I may explore that again to see the additional complexity & cost. Interested in any thoughts here.

@github-actions

Copy link
Copy Markdown

This PR is stale because it has been open for 30 days with no activity.

@github-actions github-actions Bot added the stale Stale PR or inactive for long period of time. label Apr 10, 2026
@bedevere-app

bedevere-app Bot commented Aug 14, 2026

Copy link
Copy Markdown

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 skip news label instead.

@github-actions github-actions Bot removed the stale Stale PR or inactive for long period of time. label Aug 14, 2026
@mdeck mdeck changed the title gh-92041: Improve performance of inspect.getmodule gh-92041: Avoid module scans for frames and tracebacks Aug 14, 2026

mdeck commented Aug 15, 2026

Copy link
Copy Markdown
Author

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.

mdeck commented Aug 16, 2026

Copy link
Copy Markdown
Author

Updated performance comparison using the PR’s exact base (09b63170e3) and head (3fbfadc521) on CPython 3.16.0a0.

The same compiled interpreter was used for both measurements, switching only between the base and PR Lib trees. The PR contains no C changes. Results are steady-state medians with 10,000 synthetic sys.modules entries.

Workload Base PR Result
Unregistered exec frame 2.504 ms 0.421 µs ~5,940× faster
Generated mismatched-origin frame 2.451 ms 25.34 µs ~97× faster
inspect.stack(), depth 2 3.721 ms 92.5 µs ~40× faster
inspect.stack(), depth 8 3.739 ms 149.3 µs ~25× faster
inspect.stack(), depth 64 4.364 ms 702.3 µs ~6.2× faster

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 sys.modules, at the cost of a small fixed overhead for an already-cached registered frame.

mdeck commented Aug 17, 2026

Copy link
Copy Markdown
Author

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.

@eendebakpt

eendebakpt commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

@mdeck Thanks for the updated PR. There might be a behavior change with this PR. A reproducer generated with Claude:

Reproducer
# Reproducer for the behavior change in python/cpython PR #92042 (gh-92041).
#
# Run on main and on the PR branch (or with PYTHONPATH pointing at a patched
# inspect.py) and compare the output.
#
# Scenario 1: a module imported from a zip archive; a plugin-style runner
#   execs the module's source in a fresh namespace (plain dict).  On main,
#   inspect.getmodule(frame) resolves the module via the filename cache and
#   inspect.getsource(frame) retrieves the source through the module's PEP 302
#   loader.  With the PR, getmodule returns None and getsource raises OSError.
#
# Scenario 2: the same exec-in-a-fresh-namespace pattern with an ordinary
#   on-disk stdlib module (no zip).  On main getmodule(frame) resolves the
#   module; with the PR it returns None (getsource still works here because
#   the file exists on disk).

import inspect
import os
import sys
import tempfile
import textwrap
import zipfile

SRC = textwrap.dedent("""\
    import inspect

    def capture():
        return inspect.currentframe()

    frame = capture()
""")


def scenario_zip():
    tmp = tempfile.mkdtemp()
    zpath = os.path.join(tmp, "app.zip")
    with zipfile.ZipFile(zpath, "w") as z:
        z.writestr("zmod.py", SRC)
    sys.path.insert(0, zpath)
    import zmod  # imported from the zip via zipimport

    # Plugin-style runner: execute the module's own source in a fresh
    # namespace.  The code object carries the zipped file's path.
    ns = {}
    exec(compile(SRC, zmod.__file__, "exec"), ns)
    frame = ns["frame"]

    print("1) zipimported module, source exec'd in a fresh namespace")
    print("   frame.f_code.co_filename:", frame.f_code.co_filename)
    print("   getmodule(frame):        ", inspect.getmodule(frame))
    try:
        source = inspect.getsource(frame)
        first = source.splitlines()[0]
        print(f"   getsource(frame):         OK ({len(source)} chars, first line {first!r})")
    except OSError as exc:
        print(f"   getsource(frame):         OSError: {exc}")


def scenario_plain():
    import json.encoder as target
    with open(target.__file__) as f:
        src = f.read()
    ns = {}
    exec(compile(src, target.__file__, "exec"), ns)
    # Re-exec the module source and grab a frame executing in that namespace.
    probe = {}
    exec(compile("import inspect\nframe = inspect.currentframe()\n",
                 target.__file__, "exec"), probe)
    frame = probe["frame"]

    print("2) on-disk module source exec'd in a fresh namespace")
    print("   getmodule(frame):        ", inspect.getmodule(frame))


print(sys.version)
print("inspect loaded from:", inspect.__file__)
print()
scenario_zip()
print()
scenario_plain()

An 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.
@mdeck mdeck changed the title gh-92041: Avoid module scans for frames and tracebacks gh-92041: Avoid repeated sys.modules scans in inspect.getmodule Aug 24, 2026

mdeck commented Aug 24, 2026

Copy link
Copy Markdown
Author

@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, getmodule() now falls through to the existing filename-based resolution. Both examples in the reproducer therefore retain their main behavior, including loader-backed getsource() for the zipimport case.

I also adapted the suggested snapshot approach for the filename fallback. A miss compares (id(sys.modules), tuple(sys.modules)) with the previous snapshot; unchanged module names skip the full value scan, while a key change or replacement mapping forces a scan of a copy. The snapshot stores no module values, so it does not keep removed modules alive. I used a tuple rather than a frozenset because, with 10,000 keys on the same interpreter, tuple construction measured about 53 µs and 80 KB shallow size versus 363 µs and 525 KB for a frozenset; an order change simply causes a conservative rescan.

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 sys.modules mappings, mutation during snapshotting, and module lifetime. The updated PR description also calls out the deliberate names-only invalidation tradeoff.

Against current main (f897dbf2f36) with 10,002 module entries, unregistered frames improve from 2.719 ms to 0.185 ms, mismatched frames from 2.807 ms to 0.321 ms, bare code misses from 2.761 ms to 0.177 ms, and a depth-64 stack from 91.861 ms to 6.913 ms. Repeated registered-frame lookup adds about 0.40 µs, and the existing function fast path is unchanged.

The focused/dependent suite, optimized mode, pydebug refleak run, free-threaded build, concurrent mutation stress, patchcheck, and regen-all are clean locally. CI is now running on the new head. Thanks again for the detailed report and alternative direction.

@eendebakpt

Copy link
Copy Markdown
Contributor

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: snapshot = (tuple(modules), tuple(map(id, modules.values()))), but even that is not foolproof (if a module is gc'ed the id can be re-used). Maybe weakrefs will do the trick, but that sounds complex as well.

@brettcannon
brettcannon removed their request for review August 25, 2026 20:55
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.
@mdeck
mdeck requested a review from kumaraditya303 as a code owner August 26, 2026 20:29

mdeck commented Aug 26, 2026

Copy link
Copy Markdown
Author

@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 sys.modules dictionary now has a per-interpreter generation, incremented for registry mutations and changes to module metadata that affects filename lookup (__file__, __name__, __getattr__, and module class). Cache hits and rebuilt maps validate that generation; dynamic module metadata is rechecked only for the affected entries. Replacement sys.modules mappings remain conservative and are scanned on each miss.

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-main benchmarks, compatibility details, and the one intentional filename-collision clarification. Earlier snapshot-design discussion and benchmarks no longer describe the patch.

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.

@eendebakpt

Copy link
Copy Markdown
Contributor

@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 _getframemodule 2) the dict watcher to avoid rescans of the modules?

eendebakpt added a commit to eendebakpt/cpython that referenced this pull request Aug 30, 2026
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>
@eendebakpt

Copy link
Copy Markdown
Contributor

@mdeck Complementary to this PR I opened #156685. Does it help for your use case?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting core review performance Performance or resource usage stdlib Standard Library Python modules in the Lib/ directory

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants