Repository navigation
Conversation
…loggers) Manager._clear_cache() walked every logger in loggerDict on each setLevel() or disable() call, so configuring N loggers one at a time was quadratic. Track loggers that have populated their isEnabledFor cache in a WeakSet and clear only those.
|
As mentioned on the issue, is this a real-world problem? There are some issues with the current approach:
Both go away by clearing all loggers in the set unconditionally and then emptying the set: with _lock:
for logger in self._cached_loggers:
logger._cache.clear()
self._cached_loggers.clear()
self.root._cache.clear()Some more detailsClearing a superset of what `main` clears is safe, and as a bonus it fixes a stale cache for loggers not in `loggerDict` (`Logger("x").setLevel(ERROR)` currently leaves `isEnabledFor(DEBUG)` True). Please add a test for that and for `_cached_loggers` being empty after `setLevel()`.Reproducer for both: import logging, time
lg = logging.getLogger("one")
def us_per_setlevel():
t0 = time.perf_counter()
for _ in range(2000):
lg.setLevel(logging.INFO)
lg.isEnabledFor(logging.DEBUG)
return (time.perf_counter() - t0) / 2000 * 1e6
print(f"baseline: {us_per_setlevel():7.1f} us")
registered = [logging.getLogger(f"app.mod{i}") for i in range(4000)]
for l in registered:
l.isEnabledFor(logging.DEBUG)
lg.setLevel(logging.INFO)
print(f"after bulk invalidate: {us_per_setlevel():7.1f} us") # (2)
logging.root.manager.loggerDict.clear()
standalone = [logging.Logger(f"s{i}") for i in range(4000)]
for l in standalone:
l.isEnabledFor(logging.DEBUG)
print(f"4000 standalone alive: {us_per_setlevel():7.1f} us") # (1)Another approach would be to check the cache with a global counter. That might also help with gh-155106 (the PR now is neutral for contention) |
…ache Skipping loggers that are not in loggerDict kept them in the set forever, and per-item discard left the set's table large after a bulk clear, so later setLevel() calls stayed slower than on main. Clear every tracked logger and empty the set instead. This also fixes a stale isEnabledFor cache on loggers that are not registered with the manager.
|
Thanks for digging into this! Both slowdowns reproduce here and your version fixes them, so I've switched to it and added the two tests. Short answer on real-world use: I found this by reading the code, not from an app of my own that was hurting. But the slow path is an idiom people really use, looping over What makes it worth it to me is that it's now a tiny change that also fixes a real bug on main: a standalone logger keeps a stale The version counter looks neat, but it's a bigger change to |
|
Good idea, added before and after checks to both tests! |
| # Start from a known state: other tests may have cached results. | ||
| manager._clear_cache() |
There was a problem hiding this comment.
you shoudl also have some cleanup (or if it's not the case, you can add this in setUp() and tearDown()). We don't want the cache to be possibly polluted for the next test.
|
Good catch, both new tests now clean up after themselves! |
Logger.setLevel()andlogging.disable()callManager._clear_cache(), which walks every logger inloggerDictto clear itsisEnabledForcache, whether or not it has cached anything. Setting levels on N loggers one at a time is O(N^2):(debug build of main, script from the issue)
The manager now keeps a
WeakSetof loggers that have populated their cache.isEnabledForadds itself on the cache-miss path, inside the lock it already takes, so the hot cache-hit path is untouched._clear_cacheclears those loggers and empties the set. That also covers loggers that are not inloggerDict, sologging.Logger("x").setLevel(logging.ERROR)no longer leaves a staleisEnabledForresult behind.test_loggingpasses, with new tests for the stale cache and for the set being emptied.