From cc403b7ba88746d4b36af55f80810f3391e190b7 Mon Sep 17 00:00:00 2001 From: Daniel Harding Date: Tue, 19 Apr 2022 18:07:05 +0300 Subject: [PATCH 1/3] Remove get_stack() context argument get_stack() is never called with a value other than the default. Also remove the context argument from our local copy of getframeinfo(). However, keep it as a variable within the function to reduce the deviation from the original function in the Python stdlib. --- debug_toolbar/utils.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/debug_toolbar/utils.py b/debug_toolbar/utils.py index ae4d49168..24a649f68 100644 --- a/debug_toolbar/utils.py +++ b/debug_toolbar/utils.py @@ -167,19 +167,18 @@ def get_name_from_obj(obj): return name -def getframeinfo(frame, context=1): +def getframeinfo(frame): """ Get information about a frame or traceback object. A tuple of five things is returned: the filename, the line number of the current line, the function name, a list of lines of context from the source code, and the index of the current line within that list. - The optional second argument specifies the number of lines of context - to return, which are centered around the current line. This originally comes from ``inspect`` but is modified to handle issues with ``findsource()``. """ + context = 1 if inspect.istraceback(frame): lineno = frame.tb_lineno frame = frame.tb_frame @@ -232,7 +231,7 @@ def get_sorted_request_variable(variable): return [(k, variable.getlist(k)) for k in sorted(variable)] -def get_stack(context=1): +def get_stack(): """ Get a list of records for a frame and all higher (calling) frames. @@ -244,7 +243,7 @@ def get_stack(context=1): frame = sys._getframe(1) framelist = [] while frame: - framelist.append((frame,) + getframeinfo(frame, context)) + framelist.append((frame,) + getframeinfo(frame)) frame = frame.f_back return framelist From e55950d1682f84bdc31550a851c8231fba30503a Mon Sep 17 00:00:00 2001 From: Daniel Harding Date: Wed, 20 Apr 2022 17:00:29 +0300 Subject: [PATCH 2/3] Cache getframeinfo() results for get_stack() getframeinfo() is quite expensive as it hits the filesystem multiple times. Wrap with a custom least-recently-used cache to reduce this cost. It is not possible to use functools.lru_cache directly as using frame objects as cache keys would cause them (and their associated locals) to stay in memory indefinitely. The custom LRU cache takes a function which transforms the frame argument into a suitable cache key. --- debug_toolbar/utils.py | 56 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/debug_toolbar/utils.py b/debug_toolbar/utils.py index 24a649f68..a90a881fa 100644 --- a/debug_toolbar/utils.py +++ b/debug_toolbar/utils.py @@ -1,3 +1,4 @@ +import functools import inspect import os.path import re @@ -19,6 +20,54 @@ threading = None +# A lighter-weight (and somewhat less-featureful) alternative to the +# functools.lru_cache decorator. Does not support keyword arguments. Does not +# use any locking internally, as the worst result of any race condition would +# be a slight inaccuracy in the cache entry access count, possibly resulting in +# a more-recently-accessed entry being discarded if the cache is too full. Its +# main distinctive is that it supports an optional key function, which +# transforms the function arguments into a cache key if the original arguments +# cannot or should not be used as a cache key. +class LRUCache: + SENTINEL = object() + DEFAULT_MAX_SIZE = 256 + + class Entry: + def __init__(self, value): + self.value = value + self.access_count = 0 + + def __init__(self, key_func=None, max_size=None): + self.cache = {} + self.counter = 0 + self.key_func = key_func + if max_size is None: + max_size = self.DEFAULT_MAX_SIZE + self.max_size = max_size + + def __call__(self, f): + @functools.wraps(f) + def wrapper(*args): + if self.key_func is None: + cache_key = args + else: + cache_key = self.key_func(*args) + entry = self.cache.get(cache_key, self.SENTINEL) + if entry is self.SENTINEL: + entry = self.Entry(f(*args)) + while len(self.cache) >= self.max_size: + lru_key, _ = min( + self.cache.items(), key=lambda item: item[1].access_count + ) + self.cache.pop(lru_key, None) + self.cache[cache_key] = entry + self.counter += 1 + entry.access_count = self.counter + return entry.value + + return wrapper + + # Figure out some paths django_path = os.path.realpath(os.path.dirname(django.__file__)) @@ -167,6 +216,13 @@ def get_name_from_obj(obj): return name +# getframeinfo() is quite expensive as it hits the filesystem multiple +# times. Apply the LRUCache decorator to reduce this cost. Using the frame +# objects in cache keys directly is undesirable as it would cause the frames +# (and their associated locals) to stay in memory indefinitely. Instead use +# the filename and line number as the cache key (this has the happy benefit of +# improving the cache hit rate as well). +@LRUCache(key_func=lambda frame: (frame.f_code.co_filename, frame.f_lineno)) def getframeinfo(frame): """ Get information about a frame or traceback object. From 86e872f8bd55a601640bfda37ebdcef685542638 Mon Sep 17 00:00:00 2001 From: Daniel Harding Date: Tue, 19 Apr 2022 17:11:11 +0300 Subject: [PATCH 3/3] Improve tidy_stacktrace() performance Prior to this commit, tidy_stacktrace() called os.path.realpath() for each frame in the stack. This call is expensive because it has to hit the filesystem. Reduce this cost by moving the call to realpath() into omit_path() and applying an LRUCache() decorator to omit_path(). --- debug_toolbar/utils.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/debug_toolbar/utils.py b/debug_toolbar/utils.py index a90a881fa..2fd502db0 100644 --- a/debug_toolbar/utils.py +++ b/debug_toolbar/utils.py @@ -90,7 +90,12 @@ def get_module_path(module_name): ] +# os.path.realpath() is expensive since it has to hit the filesystem. Since +# omit_path() is called for each stack frame in tidy_stacktrace(), apply the +# LRUCache() decorator to reduce the cost. +@LRUCache() def omit_path(path): + path = os.path.realpath(path) return any(path.startswith(hidden_path) for hidden_path in hidden_paths) @@ -105,7 +110,7 @@ def tidy_stacktrace(stack): """ trace = [] for frame, path, line_no, func_name, text in (f[:5] for f in stack): - if omit_path(os.path.realpath(path)): + if omit_path(path): continue text = "".join(text).strip() if text else "" frame_locals = (