Benchmarks¶
Introduction¶
multidict is measured in two different ways, and the difference matters
when you read the numbers below or produce your own.
Callgrind counts the instructions an operation executes. The count does not depend on scheduling, CPU frequency, or how busy the machine is, so a regression shows up as a changed integer instead of a distribution you have to argue about. Every number published on this page is an instruction count.
Python pyperf module measures elapsed time. That is what users actually feel, but it needs a tuned machine to be reproducible.
Property |
Callgrind instruction counts |
|
|---|---|---|
Reproducible |
Yes, bit for bit |
Only on a tuned machine |
Needs a quiet machine |
No |
Yes |
Measures |
Instructions executed |
Elapsed time |
Blind to |
Cache misses, branch prediction, memory latency, lock contention |
Nothing, but it is noisy |
Use it for |
The tables below, and for spotting a regression |
Confirming that an instruction-count win is a real time win |
Instruction counts are a proxy. A change that removes instructions but worsens
locality still looks like a win, so confirm anything significant with
pyperf on a tuned machine before claiming it.
What is measured¶
The rows are the operations dict and MultiDict
both have. Each is measured on a mapping of 200 str keys whose values
equal their keys, except d.update(other), which merges a 100-item
dict whose keys are all already present, so it measures replacement
rather than insertion.
Three rows use a different size, because what they cost is dominated by an
allocation rather than by the entries: cls() builds an empty mapping, and
cls(items), 20 items and d.copy(), 20 items use a request’s worth of
headers. An allocation is a fixed cost, so at 200 entries it is divided across
them and all but disappears; d.items() and iter(d) are listed for the
same reason, since they allocate one object each however large the mapping is.
Operations that destroy the mapping, such as d.pop(key) and d.clear(),
rebuild it before each measured round. The rebuild happens outside the
instrumented region, so it is not counted.
Every operation is also measured a second time with the operation itself
removed from the loop, and that baseline is subtracted. Without the
subtraction, the driving for loop, worth a few hundred instructions
per iteration, would compress every ratio toward 1. The subtraction does not
remove the attribute lookup and call of method-based operations such as
d.get(key), because a caller pays those too.
Operations dict has no counterpart for, including add(),
getall() and everything keyed by istr, are excluded
from the tables. They are still available to both runners; pass
--include-multidict-only to the Callgrind driver, or use
benchmarks/benchmark.py directly.
Results¶
multidict 6.9.2.dev0 (6e6ac72)
CPython 3.14.7, GIL and free-threaded builds
valgrind 3.26.0, callgrind, client-request bracketing
CPU Intel(R) Core(TM) Ultra 7 155H
platform Linux-6.17.0-29-generic-x86_64-with-glibc2.43
collected 2026-09-24
Operation |
|
|
|
|
|---|---|---|---|---|
|
91,427 |
44,450 |
62,530 |
0.68x |
|
569 |
609 |
749 |
1.32x |
|
9,257 |
4,163 |
5,983 |
0.65x |
|
9,810 |
25,462 |
25,859 |
2.64x |
|
1,452 |
1,903 |
2,300 |
1.58x |
|
184 |
148 |
238 |
1.29x |
|
358 |
314 |
582 |
1.62x |
|
174 |
134 |
225 |
1.29x |
|
252 |
314 |
404 |
1.60x |
|
388 |
388 |
478 |
1.23x |
|
585 |
505 |
784 |
1.34x |
|
295 |
236 |
326 |
1.10x |
|
476 |
388 |
672 |
1.41x |
|
447 |
1,092 |
2,395 |
5.36x |
|
30,716 |
37,529 |
46,393 |
1.51x |
|
4,821 |
7,212 |
7,469 |
1.55x |
|
53 |
62 |
65 |
1.22x |
|
88 |
378 |
382 |
4.35x |
|
538 |
326 |
583 |
1.08x |
|
727 |
496 |
496 |
0.68x |
Operation |
|
|
|
|
|---|---|---|---|---|
|
114,378 |
66,632 |
83,861 |
0.73x |
|
604 |
852 |
941 |
1.56x |
|
11,674 |
6,855 |
8,544 |
0.73x |
|
12,756 |
29,819 |
30,160 |
2.36x |
|
1,830 |
2,757 |
3,097 |
1.69x |
|
224 |
291 |
378 |
1.69x |
|
392 |
409 |
660 |
1.69x |
|
235 |
253 |
340 |
1.45x |
|
344 |
515 |
602 |
1.75x |
|
525 |
612 |
699 |
1.33x |
|
730 |
707 |
970 |
1.33x |
|
404 |
396 |
483 |
1.19x |
|
587 |
512 |
775 |
1.32x |
|
482 |
1,245 |
2,577 |
5.34x |
|
38,319 |
48,951 |
57,823 |
1.51x |
|
6,732 |
9,749 |
10,001 |
1.49x |
|
55 |
100 |
102 |
1.88x |
|
114 |
450 |
453 |
3.99x |
|
446 |
518 |
769 |
1.73x |
|
647 |
782 |
782 |
1.21x |
Class |
Median |
Best |
Worst |
Worst operation |
|---|---|---|---|---|
|
1.25x |
0.83x |
1.40x |
|
|
1.47x |
1.14x |
1.96x |
|
|
1.34x |
1.08x |
1.59x |
|
|
1.22x |
0.94x |
2.96x |
|
|
1.21x |
0.94x |
2.09x |
|
Operation |
|
|
|
|---|---|---|---|
|
2,488,662 |
2,729,937 |
55.99x |
|
64,605 |
64,946 |
106.08x |
|
330,310 |
353,961 |
79.34x |
|
450,224 |
456,750 |
17.68x |
|
75,510 |
82,026 |
39.68x |
|
7,483 |
8,737 |
50.46x |
|
7,741 |
8,977 |
24.68x |
|
7,762 |
9,016 |
57.89x |
|
26,228 |
27,539 |
83.40x |
|
27,120 |
28,153 |
69.88x |
|
34,833 |
37,029 |
69.03x |
|
19,684 |
20,897 |
83.23x |
|
15,495 |
16,732 |
39.96x |
|
11,915 |
13,851 |
10.91x |
|
2,050,778 |
2,167,967 |
54.65x |
|
114,191 |
114,183 |
15.83x |
|
2,190 |
4,066 |
35.27x |
|
2,510 |
4,445 |
6.64x |
|
2,494 |
2,494 |
7.65x |
|
9,143 |
9,152 |
18.43x |
How to read these numbers¶
They are instructions, not time. Two operations with the same count can differ in wall clock if one of them misses cache more often.
They transfer between x86-64 machines running the same builds, because the count depends on the compiled code and not on the hardware executing it. They do not transfer between CPython versions: an interpreter change moves every row at once.
The free-threading table includes dict as a control. Part of the
overhead on the free-threaded build is CPython’s own, not multidict’s, and
the dict row is how you tell the two apart.
Running the benchmarks¶
Use a virtualenv per interpreter build¶
Getting this wrong produces plausible wrong numbers rather than an error, so do it first.
Create one virtualenv per build, from the resolved interpreter:
$ ~/.pyenv/versions/3.14.7/bin/python -m venv .venv-gil
$ ~/.pyenv/versions/3.14.7t/bin/python -m venv .venv-ft
$ for venv in .venv-gil .venv-ft; do
> $venv/bin/python -m pip install -e . -r requirements/dev.txt
> done
Three things to watch:
Do not share a virtualenv between checkouts. Two working trees installing into the same environment swap which extension module resolves, and the benchmark then measures the other checkout.
Do not hand Valgrind a wrapper script. Valgrind does not follow child processes by default, so it measures the wrapper and detaches at its
exec. The failure signature is a plausible constant instruction count that does not change when the round count does. A virtualenv’sbin/pythonis a symbolic link to a real binary and is fine; apyenvshim is not. The driver refuses a wrapper rather than reporting a number.Use the same patch release for both builds. Otherwise the free-threading table attributes unrelated interpreter changes to free-threading.
render_tables.pyrefuses to build it when the two runs disagree.
Confirm each environment picked up the extension you expect:
$ .venv-gil/bin/python -c "import multidict._multidict as m; print(m.__file__)"
Instruction counts with Callgrind¶
Valgrind must be installed. Run the driver with the interpreter you want to measure:
$ .venv-gil/bin/python benchmarks/callgrind_driver.py -o gil.json
$ .venv-ft/bin/python benchmarks/callgrind_driver.py -o ft.json
Each cell is four Valgrind child processes, {run, noop} at two round
counts, reduced with:
ir_per_op = ((run[r2] - run[r1]) - (noop[r2] - noop[r1])) / ((r2 - r1) * inner)
Taking the difference between two non-zero round counts cancels interpreter
startup exactly; subtracting the second arm cancels the driving loop. The
children set PYTHONHASHSEED=0, because string hash randomization changes
the hash table’s probe sequences from process to process and moves the counts
by about 4%, which looks exactly like a real regression.
Because instruction counts do not depend on scheduling, the children run in
parallel by default and the result is correct even on a busy machine. Use
-j1 to run them one at a time.
The driver checks itself before reporting: it refuses a wrapper script, it
requires the counts to grow with the round count, and it re-measures one cell
and requires a bit-identical result. --self-check runs every operation once
without Valgrind and asserts the resulting state, which is the quick way to
check a new operation.
Wall-clock measurements with pyperf¶
requirements/dev.txt should be installed first. Please also make sure that
you have configured your OS to have reliable results;
see Preparing a stable environment below.
$ python benchmarks/benchmark.py
This runs both classes (MultiDict and
CIMultiDict) of both implementations (pure-Python and C),
plus dict for comparison. --shared-only limits it to the
operations dict also has.
To run benchmarks for a specific class of specific implementation please use
the --impl option:
$ python benchmarks/benchmark.py --impl multidict_c
Please use --help to see all available options. Most of the options are
described at pyperf’s Runner documentation.
Reported times are per operation and exclude the rebuild that destructive operations need, but unlike the tables above they still include the driving loop, so they are larger than the instruction counts imply and the two are not directly comparable.
Comparing implementations¶
The --impl option combined with the compare_to
command of Python pyperf module gives a good picture of how an implementation
performs:
$ python benchmarks/benchmark.py --impl multidict_c -o multidict_c.json
$ python benchmarks/benchmark.py --impl multidict_py -o multidict_py.json
$ python -m pyperf compare_to multidict_c.json multidict_py.json
Preparing a stable environment¶
None of this is needed for the instruction counts; it applies to pyperf
wall-clock runs only.
Start with the tuning pyperf can do itself, which covers the CPU frequency
governor, turbo, address space randomization and the perf event rate:
$ sudo python -m pyperf system tune
$ python -m pyperf system show
$ sudo python -m pyperf system reset # afterwards
Beyond that:
Isolate cores at boot. Add
isolcpus=,nohz_full=andrcu_nocbs=to the kernel command line for the cores you will measure on.nohz_fullmatters because the scheduler tick otherwise lands inside individual measurements.Pin the workers with
taskset -corpyperf’s--affinityoption.On a hybrid CPU, pin to performance cores only, one thread per physical core. A run that migrates between a performance core and an efficiency core produces a bimodal distribution that
pyperfreports as an enormous standard deviation, and hardware counters are multiplexed across the two core types. List them withlscpu -e=CPU,CORE,MAXMHZor read/sys/devices/cpu_core/cpus.Disable simultaneous multithreading, or at least leave the sibling thread idle.
Pin the clock.
cpupower frequency-set -g performanceand disabling turbo through/sys/devices/system/cpu/intel_pstate/no_turbostop the frequency drifting as the package heats up over a long run.Set
PYTHONHASHSEED=0. Note that-Iand-Emake CPython ignore everyPYTHON*variable, so adding either silently drops it.
On a shared, frequency-scaled laptop none of this is enough: within-run spread of 10% to 30% is normal there, which makes anything smaller than a 30% change too small to measure. Use the instruction counts instead.
Regenerating the tables¶
Collect both builds, then render:
$ .venv-gil/bin/python benchmarks/callgrind_driver.py -o gil.json
$ .venv-ft/bin/python benchmarks/callgrind_driver.py -o ft.json
$ python benchmarks/render_tables.py gil.json ft.json
That prints the reStructuredText so the numbers can be read before they are
committed. Add --write docs/benchmark.rst to replace the block between the
two marker comments in this file.
The tables are regenerated once per release rather than in every pull request
that moves performance, since each run shifts every row a little and the churn
buries the rows that actually changed. A pull request reports the operations it
moved in its own description; the published tables are refreshed as a step of
the release procedure, documented in RELEASE.md.