Skip to content

Commit 068c3f6

Browse files
authored
test: add codspeed benchmarks for listener duplicate-packet dedup (#1744)
1 parent 7f0c476 commit 068c3f6

1 file changed

Lines changed: 128 additions & 0 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Benchmarks for the listener duplicate-packet suppression hot path.
2+
3+
These pin the cost of ``AsyncListener._process_datagram_at_time`` under
4+
three packet-stream shapes that exercise the dedup branch differently:
5+
6+
- ``test_dedup_hit_same_payload`` — N copies of one payload (steady-state
7+
dedup hit).
8+
- ``test_alternating_payloads`` — A, B, A, B, ... The single-slot
9+
remembered-last-packet dedup misses on every packet because each one
10+
differs from its immediate predecessor; a bounded recency window
11+
dedups after the second packet. This is the flood shape from
12+
issue #1724.
13+
- ``test_unique_payloads`` — N distinct payloads (no dedup hit possible
14+
on either implementation). Measures the store/evict overhead on the
15+
miss path.
16+
17+
Downstream work is held constant across implementations by overriding
18+
``handle_query_or_defer`` on a subclass with a no-op, so the only
19+
remaining variable is the dedup decision itself.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import pytest
25+
from pytest_codspeed import BenchmarkFixture
26+
27+
from zeroconf import DNSOutgoing, DNSQuestion, const
28+
from zeroconf._listener import AsyncListener
29+
from zeroconf._utils.time import current_time_millis
30+
from zeroconf.asyncio import AsyncZeroconf
31+
32+
33+
class _InertListener(AsyncListener):
34+
"""AsyncListener that skips response generation.
35+
36+
The dedup branch is the only piece that diverges between the
37+
single-slot and bounded-window implementations. Stubbing query
38+
handling keeps the per-packet cost outside the dedup branch
39+
constant so the benchmark isolates the change under test.
40+
"""
41+
42+
def handle_query_or_defer(self, *args: object, **kwargs: object) -> None: # type: ignore[override]
43+
return None
44+
45+
46+
def _make_query_packet(name: str) -> bytes:
47+
out = DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True)
48+
out.add_question(DNSQuestion(name, const._TYPE_PTR, const._CLASS_IN))
49+
return out.packets()[0]
50+
51+
52+
_ITERATIONS = 200
53+
_ADDRS: tuple[str, int] = ("192.0.2.1", 5353)
54+
55+
56+
def _build_listener(aiozc: AsyncZeroconf) -> _InertListener:
57+
zc = aiozc.zeroconf
58+
# A non-empty registry keeps the realistic code path live (the early
59+
# ``has_entries`` exit would otherwise bypass the per-packet work we
60+
# want to measure). Toggling the flag directly avoids the event-loop
61+
# round-trip that ``async_register_service`` would impose.
62+
zc.registry.has_entries = True
63+
listener = _InertListener(zc)
64+
listener.transport = object() # type: ignore[assignment]
65+
return listener
66+
67+
68+
@pytest.mark.asyncio
69+
async def test_dedup_hit_same_payload(benchmark: BenchmarkFixture) -> None:
70+
"""Steady-state dedup hit: same payload repeated."""
71+
aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
72+
await aiozc.zeroconf.async_wait_for_start()
73+
listener = _build_listener(aiozc)
74+
packet = _make_query_packet("a._http._tcp.local.")
75+
data_len = len(packet)
76+
# Prime the dedup state so the first iteration is already a hit.
77+
listener._process_datagram_at_time(False, data_len, current_time_millis(), packet, _ADDRS)
78+
79+
@benchmark
80+
def _run() -> None:
81+
# Single fresh timestamp keeps every call inside the
82+
# suppression interval so each one is a dedup hit.
83+
t = current_time_millis()
84+
for _ in range(_ITERATIONS):
85+
listener._process_datagram_at_time(False, data_len, t, packet, _ADDRS)
86+
87+
await aiozc.async_close()
88+
89+
90+
@pytest.mark.asyncio
91+
async def test_alternating_payloads(benchmark: BenchmarkFixture) -> None:
92+
"""Flood shape from issue #1724: A, B, A, B, ..."""
93+
aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
94+
await aiozc.zeroconf.async_wait_for_start()
95+
listener = _build_listener(aiozc)
96+
packet_a = _make_query_packet("a._http._tcp.local.")
97+
packet_b = _make_query_packet("b._http._tcp.local.")
98+
len_a = len(packet_a)
99+
len_b = len(packet_b)
100+
101+
@benchmark
102+
def _run() -> None:
103+
t = current_time_millis()
104+
for i in range(_ITERATIONS):
105+
if i & 1:
106+
listener._process_datagram_at_time(False, len_b, t, packet_b, _ADDRS)
107+
else:
108+
listener._process_datagram_at_time(False, len_a, t, packet_a, _ADDRS)
109+
110+
await aiozc.async_close()
111+
112+
113+
@pytest.mark.asyncio
114+
async def test_unique_payloads(benchmark: BenchmarkFixture) -> None:
115+
"""Stream of distinct payloads — no dedup hit on either implementation."""
116+
aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
117+
await aiozc.zeroconf.async_wait_for_start()
118+
listener = _build_listener(aiozc)
119+
packets = [_make_query_packet(f"x{i}._http._tcp.local.") for i in range(_ITERATIONS)]
120+
lengths = [len(p) for p in packets]
121+
122+
@benchmark
123+
def _run() -> None:
124+
t = current_time_millis()
125+
for packet, data_len in zip(packets, lengths, strict=True):
126+
listener._process_datagram_at_time(False, data_len, t, packet, _ADDRS)
127+
128+
await aiozc.async_close()

0 commit comments

Comments
 (0)