Codec benchmark¶
How fast pysnmp encodes and decodes SNMP messages, how that is measured, and what the measurement said the first time it was run.
Why there is one¶
In March 2017 a reporter polling thousands of devices moved the workload onto pysnmp and found encode/decode was the bottleneck, measuring it at roughly six times the cost of libsnmp (etingof/pysnmp#44). The report was never answered. It was raised again here as #290, and the honest answer at the time was that nobody could say whether it still described this fork: the numbers were taken against pysnmp 4.x on Python 2 with a pyasn1 several majors behind the current one, and there was no benchmark in the repository to ask.
tools/benchmark_codec.py is that benchmark. It is a measuring instrument,
not a gate: it asserts no threshold, nothing fails when a number moves, and
what it produces is a table plus a statement of where the time went.
Running it¶
python tools/benchmark_codec.py # the table
python tools/benchmark_codec.py --profile # and where the time goes
python tools/benchmark_codec.py --quick # a fast sanity run
python tools/benchmark_codec.py --json out.json --markdown out.md
The C baseline needs libnetsnmp, which is libsnmp-dev on Debian and
Ubuntu and brew install net-snmp on macOS. Where it is not loadable the
benchmark says so in a note and reports the rest; --require-netsnmp turns
that into an error for a run that means to compare. PYSNMP_BENCH_NETSNMP_LIB
names the library outright when it is somewhere the loader does not look, which
is the normal case for a keg-only Homebrew install.
In CI it is Codec Benchmark,
which runs on demand only – dispatched by hand against any branch, or by
labelling a pull request ci:benchmark, which posts the combined table back
on the pull request. It is not on the push or pull-request path: the matrix
costs minutes of runner time and produces a measurement, not a verdict, so
there is nothing for a push to break. The matrix covers Linux, macOS and
Windows on the oldest and newest supported CPython, plus a free-threaded build
and PyPy.
What it measures¶
Three implementations, on the same octets:
pysnmpThe real path: pyasn1’s BER codec driven by the
asn1Specthatpysnmp.proto.rfc1901andpysnmp.proto.rfc1905define. This is what an engine runs for every datagram.pyasn1The same message declared from bare
pyasn1.type.univtypes – same tags, same choices, same nesting, but none of pysnmp’s subtype machinery: no value-range or size constraints, no named values, nopysnmp.proto.rfc1902subclasses. What separates this column from the one above is what pysnmp’s own type layer costs; what is left is pyasn1’s.netsnmplibnetsnmp’ssnmp_pdu_parseandsnmp_pdu_buildthroughctypes– the modern stand-in for the reporter’s libsnmp, and the floor a C implementation puts under the same work. Thectypescall overhead is counted against it, which is fair: it is what driving C from Python actually costs.
Two layers. The PDU body alone is the comparison, because all three
implementations do exactly that. The whole message – version and community
included – is pysnmp and pyasn1 only: net-snmp’s message parse wants a
populated netsnmp_session and its own security processing, which is a
different amount of work and would not be the same measurement.
Three workloads, which is how the per-message and per-binding costs come apart: one binding, ten (an interface row), and fifty (a GETBULK response). The values cycle through the syntaxes a real poll returns – counters, gauges, tick counts, display strings, addresses, object identifiers – so that no single decoder path stands in for all of them.
Findings¶
From run 35447776986, the first with every leg of the matrix reporting: CPython 3.14.7 on GitHub’s ubuntu runner, net-snmp 5.9.4, pysnmp 6.0.0-rc.15, pyasn1 2.0.3.
Read the ratios, not the microseconds. A shared runner is not a measuring bench: the same ten-binding decode on the same leg read 429 us in the run before this one and 622 us in this one, a 45% spread from nothing but the machine, and the matrix’s slowest platform is twice its fastest. What holds steady across runs is the shape – which column is bigger than which, and by how much.
workload |
bindings |
pysnmp |
pyasn1 |
netsnmp |
pysnmp / netsnmp |
|---|---|---|---|---|---|
decode, single |
1 |
110 |
106 |
2.0 |
56x |
decode, poll |
10 |
622 |
590 |
3.4 |
186x |
decode, bulk |
50 |
2839 |
2750 |
8.9 |
321x |
encode, single |
1 |
40 |
39 |
1.3 |
31x |
encode, poll |
10 |
209 |
208 |
1.9 |
112x |
encode, bulk |
50 |
940 |
933 |
4.4 |
215x |
pysnmp’s own type layer is not where the time is. The bare-pyasn1 mirror of
the same message decodes within a couple of per cent of pysnmp’s spec, and the
profile puts 0.4% of decode self time and none of encode in pysnmp’s package.
The constraints, the named values and the pysnmp.proto.rfc1902
subclasses – the obvious suspects – cost close to nothing at codec time.
The codec is pyasn1, which takes 84.8% of decode self time and 88.8% of
encode – and 86.9% and 92.5% on PyPy, so this is not an artefact of one
interpreter. Of what remains, roughly a tenth is interpreter builtins (dictionary
updates, mostly) and 3-5% is logging.Logger.isEnabledFor: pyasn1’s debug
hooks ask whether debugging is on 577 times per ten-binding decode, and the
answer is always no.
The cost is per binding, not per message. Across the three workloads decode
fits 54 us + 56 us per binding and encode 21 us + 18 us per binding to
within a couple of per cent. Anything that would matter has to make a binding
cheaper; there is no per-message overhead worth attacking.
Against a C codec the gap is two orders of magnitude, not six times. That is not the reporter’s comparison restated – what “libsnmp” named in 2017 is ambiguous, and a ~6x ratio is what one would expect between two Python implementations rather than against net-snmp’s C library, which is what this measures. Read the netsnmp column as the floor, and the pysnmp-versus-pyasn1 column as the only one that says anything about this repository’s own code.
The interpreter moves the number far more than anything in this repository does. The ten-binding decode, on one Linux runner, one run:
interpreter |
decode (us) |
encode (us) |
vs 3.10 |
|---|---|---|---|
CPython 3.10 |
960 |
383 |
– |
CPython 3.14 |
622 |
209 |
1.5x |
CPython 3.14t (free-threaded) |
549 |
212 |
1.7x |
PyPy 3.11 |
124 |
34 |
7.7x |
PyPy is where the answer to the original report is. It decodes five times faster than the newest CPython and nearly eight times faster than the oldest one this package supports; on encode the same comparison is six and eleven times. The free-threaded build is a little quicker than the build with the GIL on a single thread. Nothing in this repository buys anything close to any of it.
The PyPy line also needs its netsnmp column read carefully. That column
costs 8.5 to 14.5 us there against 2 to 3.4 us on CPython, for exactly the same
C calls: PyPy’s ctypes bridge is the slow part, not libnetsnmp. So the
gap narrowing to 12x on PyPy is pysnmp getting faster and the yardstick
getting slower, and the two cannot be separated with this instrument.
One more thing the matrix says and a single machine could not: on Windows the 3.10-to-3.14 comparison is much flatter than elsewhere – 988 us against 911 us – where Linux and macOS both gain substantially.
Running PyPy at all was the first thing this found. Before the matrix
existed nobody had tried: import pysnmp.proto.rfc1902 ended in a
RecursionError there. pysnmp-pyasn1’s NamedType subclasses a
namedtuple() and overrode __getitem__ to return
self.asn1Object, and PyPy builds namedtuple field accessors out of
__getitem__ – so the accessor called the override which called the
accessor. CPython’s accessors read the tuple slot directly and never enter the
override, which is why the same code had always worked there and why five years
of CI had nothing to say about it.
Fixed in pysnmp/pyasn1#185 and
released as pysnmp-pyasn1 2.0.3, which is why pyproject.toml excludes
2.0.2: it is not importable on PyPy, and on CPython the two are the same code.
The floor itself has since moved above 2.0.3 for unrelated reasons that
pyproject.toml sets out, so every version resolvable here carries the fix.
The leg is an ordinary part of the matrix now.
One thing to know when reading it: PyPy measures a warm interpreter or it
measures the JIT. The harness sizes its own iteration counts and reports the
fastest of several samples, which gives the JIT somewhere to warm up, but a
short run – --quick, or a low --min-time – reports compilation there
rather than the codec.
What it does not measure¶
Codec only. No USM authentication or privacy, no MIB resolution, no transport, no dispatcher – a v3 exchange does all of those and they are not in these numbers. The cProfile figures inflate every call they measure, so the split between packages is what to read there, never the totals. And a CI runner is a shared machine: each figure is the fastest of several samples for that reason, and a difference of a few per cent between two runs is the runner, not the code.