Skip to content

Commit abfd0cb

Browse files
authored
perf: use lru_cache for caching (#14459)
All the Python versions that we support offer lru_cache from the standard library. We can use this instead of our own implementation because it is generally implemented in native and offers better performance, even though it is not specialised to the single argument case of our internal implementation. A simple test shows a 6x overhead reduction when opting for the stdlib solution. ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [ ] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)
1 parent 941cf9c commit abfd0cb

File tree

10 files changed

+27
-101
lines changed

10 files changed

+27
-101
lines changed

benchmarks/django_simple/scenario.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,17 +69,17 @@ def _(loops):
6969
from ddtrace.contrib.internal.django import database
7070

7171
try:
72-
database.get_conn_config.invalidate()
72+
database.get_conn_config.cache_clear()
7373
except Exception:
7474
pass
7575

7676
try:
77-
database.get_service_name.invalidate()
77+
database.get_service_name.cache_clear()
7878
except Exception:
7979
pass
8080

8181
try:
82-
database.get_conn_service_name.invalidate()
82+
database.get_conn_service_name.cache_clear()
8383
except Exception:
8484
pass
8585
except Exception:
@@ -90,12 +90,12 @@ def _(loops):
9090
from ddtrace.contrib.internal.django import cache
9191

9292
try:
93-
cache.get_service_name.invalidate()
93+
cache.get_service_name.cache_clear()
9494
except Exception:
9595
pass
9696

9797
try:
98-
cache.func_cache_operation.invalidate()
98+
cache.func_cache_operation.cache_clear()
9999
except Exception:
100100
pass
101101
except Exception:

ddtrace/internal/utils/cache.py

Lines changed: 7 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1+
from functools import lru_cache
12
from functools import wraps
23
from inspect import FullArgSpec
34
from inspect import getfullargspec
45
from inspect import isgeneratorfunction
5-
from threading import RLock
66
from typing import Any # noqa:F401
77
from typing import Callable # noqa:F401
88
from typing import Optional # noqa:F401
99
from typing import Type # noqa:F401
10-
from typing import TypeVar # noqa:F401
10+
from typing import TypeVar
1111

1212

1313
miss = object()
@@ -17,78 +17,14 @@
1717
M = Callable[[Any, T], Any]
1818

1919

20-
class LFUCache(dict):
21-
"""Simple LFU cache implementation.
20+
def cached(maxsize: int = 256) -> Callable[[Callable], Callable]:
21+
def _(f: Callable) -> Callable:
22+
return lru_cache(maxsize)(f)
2223

23-
This cache is designed for memoizing functions with a single hashable
24-
argument. The eviction policy is LFU, i.e. the least frequently used values
25-
are evicted when the cache is full. The amortized cost of shrinking the
26-
cache when it grows beyond the requested size is O(log(size)).
27-
"""
28-
29-
def __init__(self, maxsize=256):
30-
# type: (int) -> None
31-
self.maxsize = maxsize
32-
self.lock = RLock()
33-
self.count_lock = RLock()
34-
35-
def get(self, key, f): # type: ignore[override]
36-
# type: (T, F) -> Any
37-
"""Get a value from the cache.
38-
39-
If the value with the given key is not in the cache, the expensive
40-
function ``f`` is called on the key to generate it. The return value is
41-
then stored in the cache and returned to the caller.
42-
"""
43-
44-
_ = super(LFUCache, self).get(key, miss)
45-
if _ is not miss:
46-
with self.count_lock:
47-
value, count = _
48-
self[key] = (value, count + 1)
49-
return value
50-
51-
with self.lock:
52-
_ = super(LFUCache, self).get(key, miss)
53-
if _ is not miss:
54-
with self.count_lock:
55-
value, count = _
56-
self[key] = (value, count + 1)
57-
return value
58-
59-
# Cache miss: ensure that we have enough space in the cache
60-
# by evicting half of the entries when we go over the threshold
61-
while len(self) >= self.maxsize:
62-
for h in sorted(self, key=lambda h: self[h][1])[: self.maxsize >> 1]:
63-
del self[h]
64-
65-
value = f(key)
66-
67-
self[key] = (value, 1)
68-
69-
return value
70-
71-
72-
def cached(maxsize=256):
73-
# type: (int) -> Callable[[F], F]
74-
"""Decorator for memoizing functions of a single argument (LFU policy)."""
75-
76-
def cached_wrapper(f):
77-
# type: (F) -> F
78-
cache = LFUCache(maxsize)
79-
80-
def cached_f(key):
81-
# type: (T) -> Any
82-
return cache.get(key, f)
83-
84-
cached_f.invalidate = cache.clear # type: ignore[attr-defined]
85-
86-
return cached_f
87-
88-
return cached_wrapper
24+
return _
8925

9026

91-
class CachedMethodDescriptor(object):
27+
class CachedMethodDescriptor:
9228
def __init__(self, method, maxsize):
9329
# type: (M, int) -> None
9430
self._method = method

ddtrace/settings/_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ def error_statuses(self, value):
411411
self._error_statuses = value
412412
self._error_ranges = get_error_ranges(value)
413413
# Mypy can't catch cached method's invalidate()
414-
self.is_error_code.invalidate() # type: ignore[attr-defined]
414+
self.is_error_code.cache_clear() # type: ignore[attr-defined]
415415

416416
@property
417417
def error_ranges(self):

ddtrace/settings/http.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def __init__(self, header_tags=None):
2424

2525
def _reset(self):
2626
self._header_tags = {}
27-
self._header_tag_name.invalidate()
27+
self._header_tag_name.cache_clear()
2828

2929
@cachedmethod()
3030
def _header_tag_name(self, header_name):
@@ -63,7 +63,7 @@ def trace_headers(self, whitelist):
6363
self._header_tags.setdefault(normalized_header_name, "")
6464

6565
# Mypy can't catch cached method's invalidate()
66-
self._header_tag_name.invalidate() # type: ignore[attr-defined]
66+
self._header_tag_name.cache_clear() # type: ignore[attr-defined]
6767

6868
return self
6969

tests/cache/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
Ensure that cached functions are invalidated between test runs.
33
"""
4+
45
import pytest
56

67
from ddtrace.internal.utils import cache
@@ -26,6 +27,6 @@ def wrapped_cached_f(f):
2627
@pytest.hookimpl(hookwrapper=True)
2728
def pytest_runtest_teardown(item, nextitem):
2829
for f in _CACHED_FUNCTIONS:
29-
f.invalidate()
30+
f.cache_clear()
3031

3132
yield

tests/contrib/django/conftest.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,11 @@ def clear_django_caches():
3333
from ddtrace.contrib.internal.django import cache
3434
from ddtrace.contrib.internal.django import database
3535

36-
cache.get_service_name.invalidate()
37-
cache.func_cache_operation.invalidate()
38-
database.get_conn_config.invalidate()
39-
database.get_conn_service_name.invalidate()
40-
database.get_traced_cursor_cls.invalidate()
36+
cache.get_service_name.cache_clear()
37+
cache.func_cache_operation.cache_clear()
38+
database.get_conn_config.cache_clear()
39+
database.get_conn_service_name.cache_clear()
40+
database.get_traced_cursor_cls.cache_clear()
4141

4242

4343
@pytest.fixture

tests/debugging/mocking.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ def _debugger(config_to_override: DDConfig, config_overrides: Any) -> Generator[
198198
config_to_override.__dict__ = old_config
199199
# Reset any test changes to the redaction config or cached calls.
200200
redaction_config.__dict__ = old_config
201-
redact.invalidate()
201+
redact.cache_clear()
202202
finally:
203203
atexit.register = atexit_register
204204

tests/internal/test_packages.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def packages():
2626
for f in _p.__dict__.values():
2727
try:
2828
if f.__code__ is _cached_sentinel.__code__:
29-
f.invalidate()
29+
f.cache_clear()
3030
except AttributeError:
3131
pass
3232

tests/internal/test_settings.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -576,7 +576,7 @@ def test_remoteconfig_header_tags(ddtrace_run_python_code_in_subprocess):
576576
assert span.get_tag("env_set_tag_name") == "helloworld"
577577
578578
config._http._reset()
579-
config._header_tag_name.invalidate()
579+
config._header_tag_name.cache_clear()
580580
call_apm_tracing_rc(_base_rc_config({"tracing_header_tags":
581581
[{"header": "X-Header-Tag-420", "tag_name":"header_tag_420"}]}), config)
582582
@@ -588,7 +588,7 @@ def test_remoteconfig_header_tags(ddtrace_run_python_code_in_subprocess):
588588
assert span2.get_tag("env_set_tag_name") is None
589589
590590
config._http._reset()
591-
config._header_tag_name.invalidate()
591+
config._header_tag_name.cache_clear()
592592
call_apm_tracing_rc(_base_rc_config({}), config)
593593
594594
with tracer.trace("test") as span3:

tests/tracer/test_utils.py

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ def cached_test_recipe(expensive, cheap, witness, cache_size):
278278
witness.assert_called_with("Foo")
279279
assert witness.call_count == 1
280280

281-
cheap.invalidate()
281+
cheap.cache_clear()
282282

283283
for i in range(cache_size >> 1):
284284
cheap("Foo%d" % i)
@@ -290,17 +290,6 @@ def cached_test_recipe(expensive, cheap, witness, cache_size):
290290

291291
assert witness.call_count == 1 + cache_size
292292

293-
MAX_FOO = "Foo%d" % (cache_size - 1)
294-
295-
cheap("last drop") # Forces least frequent elements out of the cache
296-
assert witness.call_count == 2 + cache_size
297-
298-
cheap(MAX_FOO) # Check MAX_FOO was dropped
299-
assert witness.call_count == 3 + cache_size
300-
301-
cheap("last drop") # Check last drop was retained
302-
assert witness.call_count == 3 + cache_size
303-
304293

305294
def test_cached():
306295
witness = mock.Mock()

0 commit comments

Comments
 (0)