|
| 1 | +# Benchmarks |
| 2 | + |
| 3 | +Measured 2026-08-18 at commit `8b36b91` (the 1.8.0 serving-path work, PR #572). |
| 4 | +This document exists to make one set of claims precisely, with the method and |
| 5 | +configurations needed to check them — not to advertise a bigger number than the |
| 6 | +method supports. |
| 7 | + |
| 8 | +**What these numbers are:** the cached-answer serving ceiling of each resolver |
| 9 | +on one machine — how fast the server itself can answer once the answer is in |
| 10 | +its cache. **What they are not:** a prediction of production throughput. Real |
| 11 | +traffic mixes hits with misses, and a miss is bound by upstream latency, not by |
| 12 | +the serving engine. What a resolver's engine controls is the hit path; that is |
| 13 | +what this measures. |
| 14 | + |
| 15 | +## Environment |
| 16 | + |
| 17 | +| | | |
| 18 | +|---|---| |
| 19 | +| Host | 2× Intel Xeon E5-2620 v4 @ 2.10 GHz (32 logical cores), 64 GB RAM | |
| 20 | +| OS | Ubuntu 26.04 LTS, stock kernel and sysctls — no network tuning | |
| 21 | +| Load generator | dnsperf 2.15.0, on the same host over loopback | |
| 22 | +| sdns build | Go 1.26.5, `go build`, no build flags | |
| 23 | + |
| 24 | +Client and server share the machine, so every number includes the load |
| 25 | +generator's own CPU cost, identically for every contender. |
| 26 | + |
| 27 | +## Method |
| 28 | + |
| 29 | +- **Corpus:** 2,349 names pre-verified to be served from cache (plus separate |
| 30 | + corpora: 1,143 names covered by cached negative answers, 63 names with |
| 31 | + cached SERVFAIL). Warm-hit corpora decay as TTLs expire, so every |
| 32 | + measurement is preceded by a fresh warm pass; numbers taken against a stale |
| 33 | + cache measure recursion, not serving, and come out far lower. |
| 34 | +- **Protocol per contender:** start fresh → warm the corpus (parallel `dig` |
| 35 | + pass) → one 10-second throwaway run → the measured runs, 20 seconds each. |
| 36 | + Median and best of the series are reported. |
| 37 | +- **UDP load shape:** `dnsperf -c 128 -T 8 -l 20`. The flow count matters: with |
| 38 | + only 20 flows (`-c 20`), kernel reuseport hashing leaves most of a 32-socket |
| 39 | + receiver idle and the results measure hash luck. 128 flows approximates real |
| 40 | + traffic, which carries thousands. (For reference, the 20-flow shape puts |
| 41 | + sdns and PowerDNS at parity around 370–400k and does not change the ordering |
| 42 | + of the others.) |
| 43 | +- **TCP load shape:** `dnsperf -m tcp -c 20 -T 4 -l 20`, pipelined persistent |
| 44 | + connections. |
| 45 | +- **DNSSEC validation enabled in all four resolvers** (AD flag spot-checked |
| 46 | + through each). IPv6 upstream disabled everywhere (the host has no v6 |
| 47 | + transit); irrelevant to cached serving. |
| 48 | + |
| 49 | +## Contenders |
| 50 | + |
| 51 | +| Resolver | Version | Serving configuration | |
| 52 | +|---|---|---| |
| 53 | +| sdns | 1.8.0 @ `8b36b91` | stock generated config (bind/API/paths only); full middleware chain runs per query | |
| 54 | +| PowerDNS Recursor | 5.4.1 | `threads=8`, `reuseport=yes`, `dnssec=validate`; warm hits served by the packet cache | |
| 55 | +| Unbound | 1.24.2 | `num-threads: 8`, `so-reuseport: yes`, cache slabs = 8, `msg-cache-size: 256m`, `rrset-cache-size: 512m`, `minimal-responses: yes` | |
| 56 | +| Knot Resolver | 6.2.0 | 8 `kresd` instances on one port (SO_REUSEPORT), shared 512 MB LMDB cache | |
| 57 | + |
| 58 | +Two fairness notes, one in each direction. PowerDNS's packet cache echoes a |
| 59 | +stored packet — deliberately less work per query than sdns's full chain, so |
| 60 | +its number represents its lightest possible path, as does ours. And each |
| 61 | +contender was given a reasonable performance configuration, not an exhaustive |
| 62 | +tuning pass; a specialist could likely move any of these numbers some percent. |
| 63 | + |
| 64 | +## Results |
| 65 | + |
| 66 | +### UDP, cached answers (`-c 128 -T 8`, 3×20 s) |
| 67 | + |
| 68 | +| Resolver | median qps | best qps | |
| 69 | +|---|---|---| |
| 70 | +| **sdns 1.8.0** | **424k** | **444k** | |
| 71 | +| PowerDNS Recursor 5.4.1 | 371k | 390k | |
| 72 | +| Unbound 1.24.2 | 343k | 346k | |
| 73 | +| Knot Resolver 6.2.0 | 191k | 192k | |
| 74 | + |
| 75 | +sdns with the untouched default configuration measures in the same band |
| 76 | +(median 406k over three runs) — the result does not depend on tuning knobs. |
| 77 | +Answer classes beyond plain hits, measured on sdns freshly warmed: negative |
| 78 | +answers (NXDOMAIN from cached denial) 409k, cached SERVFAIL 399k. |
| 79 | + |
| 80 | +### TCP, cached answers (`-c 20 -T 4`, 5×20 s) |
| 81 | + |
| 82 | +| Resolver | median qps | best qps | |
| 83 | +|---|---|---| |
| 84 | +| **sdns 1.8.0** | **226k** | **273k** | |
| 85 | +| Knot Resolver 6.2.0 | 142k | 146k | |
| 86 | +| Unbound 1.24.2 | 136k | 149k | |
| 87 | +| PowerDNS Recursor 5.4.1 | 56k | 57k | |
| 88 | + |
| 89 | +### Run-to-run spread |
| 90 | + |
| 91 | +20-second runs on a busy OS have real variance; the full series behind the |
| 92 | +medians spanned roughly ±7% for sdns UDP (423–444k), ±6% for PowerDNS |
| 93 | +(346–390k), ±3% for Unbound, ±2% for Knot, and ±15% for sdns TCP (195–273k). |
| 94 | +Single-run numbers from any resolver should be read with that in mind. |
| 95 | + |
| 96 | +## What changed in 1.8.0 |
| 97 | + |
| 98 | +The same harness, applied to sdns itself across the 1.8.0 serving-path work |
| 99 | +(each row A/B-measured against its predecessor at the time; early rows used |
| 100 | +the 20-flow shape, so rows are comparable to their neighbors, not across the |
| 101 | +whole column): |
| 102 | + |
| 103 | +| build | UDP cached answers | |
| 104 | +|---|---| |
| 105 | +| 1.8.0 baseline before PR #572 | 268k | |
| 106 | +| + sharded slab caches | 287k | |
| 107 | +| + fetch-add lease admission | 306k | |
| 108 | +| + batch-slot persistence | ~330k | |
| 109 | +| + inline wire-hit serving on the reader | 424k median / 444k best | |
| 110 | + |
| 111 | +TCP moved from ~100k to the 226k median above in the same PR, by removing a |
| 112 | +per-connection query budget that forced a reconnect storm under pipelining. |
| 113 | + |
| 114 | +## Reproducing |
| 115 | + |
| 116 | +```sh |
| 117 | +# corpus: one name per line; verify each serves from cache before trusting it |
| 118 | +dnsperf -s <addr> -p <port> -d hits.txt -c 128 -T 8 -l 20 # UDP |
| 119 | +dnsperf -s <addr> -p <port> -m tcp -d hits.txt -c 20 -T 4 -l 20 # TCP |
| 120 | +``` |
| 121 | + |
| 122 | +Warm first, discard a throwaway run, take at least three measurements, report |
| 123 | +the median, and state the flow count — it is the parameter that moves these |
| 124 | +numbers the most. |
0 commit comments