|
| 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