gh-92041: Avoid module scans for frames and tracebacks - #92042
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 |
|
Hmm, looks like I need a maintainer to approve running tests. I can't seem to trigger them despite pressing all the buttons. |
@mdeck could you merge/rebase to current main? Perhaps that will trigger the tests to build |
|
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 |
|
Here, sys._getframe() is moduleless, and is cached:
# test3.py
import gc
import inspect
import random
import time
import sys
def run_test(module):
def print_line(h, *vals):
if not h:
print("%15s" % h, *["%9i" % v for v in vals], " <- stack depth")
else:
print("%15s" % h, *["%6.0f us" % v for v in vals])
#
len_sys_modules = [100, 1_000, 10_000]
def add_modules(n):
while len(sys.modules) < n:
sys.modules[f"foo_{random.randint(0,2**64)}"] = module
#
depths = [2, 8, 64]
def nest(depth):
if depth > 0:
return nest(depth-1)
t = time.time()
_ = inspect.stack()
dur = time.time() - t
return dur * 1000 * 1000
#
gc.collect()
if hasattr(inspect, "_moduleless"):
print(f"len moduleless: {len(inspect._moduleless)}")
inspect._moduleless.clear()
#
orig_modules = sys.modules.copy()
print_line("", *depths)
for len_modules in len_sys_modules:
add_modules(n=len_modules)
times = [nest(depth) for depth in depths]
print_line(len(sys.modules), *times)
sys.modules = orig_modules.copy()
print("len sys.modules")
run_test(random)
run_test(sys)
import resource
print(resource.getrusage(resource.RUSAGE_SELF)) |
|
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. |

Current implementation
This PR has been substantially reworked; the earlier cache-based approaches
discussed in the thread are obsolete.
Problem and impact
The relevant flow is:
inspect.stack()→ frame information/source resolution →inspect.getmodule(frame)On the base revision, when a frame cannot be resolved through the filename
cache,
getmodule()scanssys.modules. Consequently, one generated,fileless, or unregistered frame can make stack inspection scale with the
number of imported modules.
This path has appeared at application scale: Sentry reported
inspect.stack()increasing from 4% to 54% of execution time in one workertype, and Home Assistant reported
7,184,347
ismodule()calls from this function during startup. Neither reportidentified the responsible frame types.
The case is not limited to synthetic
exec()calls. Jinjaand
attrs,for example, execute generated functions with private globals. Such a frame
remains in the caller chain when a template helper, validator, logging hook,
or error reporter captures the stack.
Approach
For frame and traceback objects, the patch now:
frame.f_globals["__name__"];sys.modules;module.__dict__ is frame.f_globals; andThis provides an O(1) answer without a persistent cache, so there is no
invalidation problem and changes to
sys.modulesare observed immediately.Matching registered frames return their module. For the normal lookup,
unregistered, mismatched-origin, and fileless frames return
Nonewithout amodule scan. Fileless frames already returned
None; the intentional semanticcorrection is that an unregistered namespace is no longer associated with an
unrelated module merely because it uses the same filename.
Functions, methods, classes, modules, bare code objects, and an explicit
differing
_filenameoverride retain their existing lookup paths.Performance
Measured on CPython 3.16.0a0 using the PR's exact base (
09b63170e3) and head(
3fbfadc521), the same compiled interpreter, and 10,000 syntheticsys.modulesentries:inspect.stack(), depth 2 / 8 / 64The existing function fast path and bare-code path showed no meaningful
change.
Verification
Tests cover registered and unregistered frames and tracebacks, mismatched and
fileless origins, invalid or replaced
sys.modulesentries, bare code objects,and explicit filename overrides. The current head's CPython Tests, Lint, and
required status checks are green.