Skip to content

Commit 5c25a0b

Browse files
committed
feat(cicd): codspeed benchmarks
1 parent 9ccc404 commit 5c25a0b

File tree

4 files changed

+352
-0
lines changed

4 files changed

+352
-0
lines changed

.github/workflows/codspeed.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: CodSpeed Benchmarks
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
- '[0-9].[0-9]+'
8+
pull_request:
9+
branches:
10+
- master
11+
- '[0-9].[0-9]+'
12+
13+
jobs:
14+
benchmark:
15+
name: Run CodSpeed Benchmarks (Python ${{ matrix.python-version }})
16+
runs-on: ubuntu-latest
17+
steps:
18+
- name: Checkout code
19+
uses: actions/checkout@v4
20+
21+
- name: Set up Python
22+
uses: actions/setup-python@v5
23+
with:
24+
python-version: '3.13'
25+
cache: 'pip'
26+
cache-dependency-path: '**/requirements*.txt'
27+
28+
- name: Install dependencies
29+
run: |
30+
pip install -r requirements-dev.txt
31+
pip install .
32+
33+
- name: Uninstall coverage utils
34+
# these pollute the codspeed flamegraphs
35+
run: pip uninstall -y coverage pytest-cov
36+
37+
- name: Create empty pytest config
38+
run: echo "[pytest]" > .empty-pytest.ini
39+
40+
- name: Run the benchmarks
41+
uses: CodSpeedHQ/action@v4
42+
with:
43+
mode: instrumentation
44+
run: pytest -c .empty-pytest.ini --codspeed benchmark.py --timeout=0
45+
token: ${{ secrets.CODSPEED_TOKEN }}

README.rst

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,32 @@ The library supports explicit invalidation for specific function call by
9292
The method returns `True` if corresponding arguments set was cached already, `False`
9393
otherwise.
9494

95+
Benchmarks
96+
----------
97+
98+
async-lru uses `CodSpeed <https://codspeed.io/>`_ for performance regression testing.
99+
100+
To run the benchmarks locally:
101+
102+
.. code-block:: shell
103+
104+
pip install -r requirements-dev.txt
105+
pytest --codspeed benchmark.py
106+
107+
The benchmark suite covers both bounded (with maxsize) and unbounded (no maxsize) cache configurations. Scenarios include:
108+
109+
- Cache hit
110+
- Cache miss
111+
- Cache fill/eviction (cycling through more keys than maxsize)
112+
- Cache clear
113+
- TTL expiry
114+
- Cache invalidation
115+
- Cache info retrieval
116+
- Concurrent cache hits
117+
- Baseline (uncached async function)
118+
119+
On CI, benchmarks are run automatically via GitHub Actions on Python 3.13, and results are uploaded to CodSpeed (if a `CODSPEED_TOKEN` is configured). You can view performance history and detect regressions on the CodSpeed dashboard.
120+
95121
Thanks
96122
------
97123

benchmark.py

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import asyncio
2+
from typing import Any, Callable
3+
4+
import pytest
5+
6+
from async_lru import _LRUCacheWrapper, alru_cache
7+
8+
9+
try:
10+
from pytest_codspeed import BenchmarkFixture
11+
except ImportError: # pragma: no branch # only hit in cibuildwheel
12+
pytestmark = pytest.mark.skip("pytest-codspeed needs to be installed")
13+
else:
14+
pytestmark = pytest.mark.benchmark
15+
16+
17+
@pytest.fixture
18+
def loop():
19+
loop = asyncio.new_event_loop()
20+
asyncio.set_event_loop(loop)
21+
yield loop
22+
loop.close()
23+
24+
25+
@pytest.fixture
26+
def run_loop(loop):
27+
async def _get_coro(awaitable):
28+
"""A helper function that turns an awaitable into a coroutine."""
29+
return await awaitable
30+
31+
def run_the_loop(fn, *args, **kwargs):
32+
awaitable = fn(*args, **kwargs)
33+
coro = awaitable if asyncio.iscoroutine(awaitable) else _get_coro(awaitable)
34+
return loop.run_until_complete(coro)
35+
36+
return run_the_loop
37+
38+
39+
# Bounded cache (LRU)
40+
@alru_cache(maxsize=128)
41+
async def cached_func(x):
42+
return x
43+
44+
45+
@alru_cache(maxsize=16, ttl=0.01)
46+
async def cached_func_ttl(x):
47+
return x
48+
49+
50+
# Unbounded cache (no maxsize)
51+
@alru_cache()
52+
async def cached_func_unbounded(x):
53+
return x
54+
55+
56+
@alru_cache(ttl=0.01)
57+
async def cached_func_unbounded_ttl(x):
58+
return x
59+
60+
61+
async def uncached_func(x):
62+
return x
63+
64+
65+
ids = ["bounded", "unbounded"]
66+
funcs = [cached_func, cached_func_unbounded]
67+
funcs_ttl = [cached_func_ttl, cached_func_unbounded_ttl]
68+
69+
70+
@pytest.mark.parametrize("func", funcs, ids=ids)
71+
def test_cache_hit_benchmark(
72+
benchmark: BenchmarkFixture,
73+
run_loop: Callable[..., Any],
74+
func: _LRUCacheWrapper[Any],
75+
) -> None:
76+
# Populate cache
77+
keys = list(range(10))
78+
for key in keys:
79+
run_loop(func, key)
80+
81+
async def run() -> None:
82+
for _ in range(100):
83+
for key in keys:
84+
await func(key)
85+
86+
benchmark(run_loop, run)
87+
88+
89+
@pytest.mark.parametrize("func", funcs, ids=ids)
90+
def test_cache_miss_benchmark(
91+
benchmark: BenchmarkFixture,
92+
run_loop: Callable[..., Any],
93+
func: _LRUCacheWrapper[Any],
94+
) -> None:
95+
unique_objects = [object() for _ in range(128)]
96+
func.cache_clear()
97+
98+
async def run() -> None:
99+
for obj in unique_objects:
100+
await func(obj)
101+
102+
benchmark(run_loop, run)
103+
104+
105+
@pytest.mark.parametrize("func", funcs, ids=ids)
106+
def test_cache_clear_benchmark(
107+
benchmark: BenchmarkFixture,
108+
run_loop: Callable[..., Any],
109+
func: _LRUCacheWrapper[Any],
110+
) -> None:
111+
for i in range(100):
112+
run_loop(func, i)
113+
114+
benchmark(func.cache_clear)
115+
116+
117+
@pytest.mark.parametrize("func_ttl", funcs_ttl, ids=ids)
118+
def test_cache_ttl_expiry_benchmark(
119+
benchmark: BenchmarkFixture,
120+
run_loop: Callable[..., Any],
121+
func_ttl: _LRUCacheWrapper[Any],
122+
) -> None:
123+
run_loop(func_ttl, 99)
124+
run_loop(asyncio.sleep, 0.02)
125+
126+
benchmark(run_loop, func_ttl, 99)
127+
128+
129+
@pytest.mark.parametrize("func", funcs, ids=ids)
130+
def test_cache_invalidate_benchmark(
131+
benchmark: BenchmarkFixture,
132+
run_loop: Callable[..., Any],
133+
func: _LRUCacheWrapper[Any],
134+
) -> None:
135+
# Populate cache
136+
keys = list(range(123, 321))
137+
for i in keys:
138+
run_loop(func, i)
139+
140+
invalidate = func.cache_invalidate
141+
142+
@benchmark
143+
def run() -> None:
144+
for i in keys:
145+
invalidate(i)
146+
147+
148+
@pytest.mark.parametrize("func", funcs, ids=ids)
149+
def test_cache_info_benchmark(
150+
benchmark: BenchmarkFixture,
151+
run_loop: Callable[..., Any],
152+
func: _LRUCacheWrapper[Any],
153+
) -> None:
154+
# Populate cache
155+
keys = list(range(1000))
156+
for i in keys:
157+
run_loop(func, i)
158+
159+
cache_info = func.cache_info
160+
161+
@benchmark
162+
def run() -> None:
163+
for _ in keys:
164+
cache_info()
165+
166+
167+
@pytest.mark.parametrize("func", funcs, ids=ids)
168+
def test_concurrent_cache_hit_benchmark(
169+
benchmark: BenchmarkFixture,
170+
run_loop: Callable[..., Any],
171+
func: _LRUCacheWrapper[Any],
172+
) -> None:
173+
# Populate cache
174+
keys = list(range(600, 700))
175+
for key in keys:
176+
run_loop(func, key)
177+
178+
async def gather_coros():
179+
gather = asyncio.gather
180+
for _ in range(10):
181+
return await gather(*map(func, keys))
182+
183+
benchmark(run_loop, gather_coros)
184+
185+
186+
def test_cache_fill_eviction_benchmark(
187+
benchmark: BenchmarkFixture, run_loop: Callable[..., Any]
188+
) -> None:
189+
# Populate cache
190+
for i in range(-128, 0):
191+
run_loop(cached_func, i)
192+
193+
keys = list(range(5000))
194+
195+
async def fill():
196+
for k in keys:
197+
await cached_func(k)
198+
199+
benchmark(run_loop, fill)
200+
201+
202+
# ===========================
203+
# Internal Microbenchmarks
204+
# ===========================
205+
# These benchmarks directly exercise internal (sync) methods and data structures
206+
# not covered by the async public API benchmarks above.
207+
208+
209+
@pytest.mark.parametrize("func", funcs, ids=ids)
210+
def test_internal_cache_hit_microbenchmark(
211+
benchmark: BenchmarkFixture,
212+
run_loop: Callable[..., Any],
213+
func: _LRUCacheWrapper[Any],
214+
) -> None:
215+
"""Directly benchmark _cache_hit (internal, sync) using parameterized funcs."""
216+
cache_hit = func._cache_hit
217+
218+
# Populate cache
219+
keys = list(range(128))
220+
for i in keys:
221+
run_loop(func, i)
222+
223+
@benchmark
224+
def run() -> None:
225+
for i in keys:
226+
cache_hit(i)
227+
228+
229+
@pytest.mark.parametrize("func", funcs, ids=ids)
230+
def test_internal_cache_miss_microbenchmark(
231+
benchmark: BenchmarkFixture, func: _LRUCacheWrapper[Any]
232+
) -> None:
233+
"""Directly benchmark _cache_miss (internal, sync) using parameterized funcs."""
234+
cache_miss = func._cache_miss
235+
236+
@benchmark
237+
def run() -> None:
238+
for i in range(128):
239+
cache_miss(i)
240+
241+
242+
@pytest.mark.parametrize("func", funcs, ids=ids)
243+
@pytest.mark.parametrize("task_state", ["finished", "cancelled", "exception"])
244+
def test_internal_task_done_callback_microbenchmark(
245+
benchmark: BenchmarkFixture,
246+
loop: asyncio.BaseEventLoop,
247+
func: _LRUCacheWrapper[Any],
248+
task_state: str,
249+
) -> None:
250+
"""Directly benchmark _task_done_callback (internal, sync) using parameterized funcs and task states."""
251+
252+
# Create a dummy coroutine and task
253+
async def dummy_coro():
254+
if task_state == "exception":
255+
raise ValueError("test exception")
256+
return 123
257+
258+
task = loop.create_task(dummy_coro())
259+
if task_state == "finished":
260+
loop.run_until_complete(task)
261+
elif task_state == "cancelled":
262+
task.cancel()
263+
try:
264+
loop.run_until_complete(task)
265+
except asyncio.CancelledError:
266+
pass
267+
elif task_state == "exception":
268+
try:
269+
loop.run_until_complete(task)
270+
except Exception:
271+
pass
272+
273+
iterations = range(1000)
274+
create_future = loop.create_future
275+
callback = func._task_done_callback
276+
277+
@benchmark
278+
def run() -> None:
279+
for i in iterations:
280+
callback(create_future(), i, task)

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
coverage==7.10.6
44
pytest==8.4.2
55
pytest-asyncio==1.2.0
6+
pytest-codspeed==4.0.0
67
pytest-cov==7.0.0
78
pytest-timeout==2.4.0

0 commit comments

Comments
 (0)