Same loop, three languages¶
We run t = f(s) ten million times on a 12 MB string in Python, C++, and Rust.
Python spends most of its time on refcount writes and interpreter dispatch, not on copying the string bytes. The numbers are from measured runs on one machine. Use them to learn mechanisms, not to pick a language.
Results¶
Wall-clock from hyperfine (3 warmup runs). Instruction counts from cachegrind simulation. Ratios matter, not absolute valgrind time.
Where the gap closes
Each bar shows how many times slower Python is than the fastest native language on that benchmark. Red and orange are wall-clock time (hyperfine). Purple and blue are simulated instruction counts (cachegrind).
Reference passing¶
One 12 MB string is built once. The loop only reassigns a reference: t = f(s) where f returns the same string. Nothing new is allocated, so the cost is mostly interpreter work and refcount traffic.
Wall clock · 12 MB string · 10M loops
Rust and C++ are close enough to call a tie; Python pays interpreter and refcount cost on every iteration.
Instructions executed (simulated)
About 111× more instructions, mostly bytecode and refcount traffic rather than copying string bytes.
Allocation¶
Each iteration builds a fresh short string ("prefix_" + str(i)). Everyone hits the allocator; Python's extra PyObject header still hurts, but the gap is much smaller than ref-pass.
Wall clock · new string each iter · 1M loops
The gap shrinks to about 7× once pymalloc and C++ SSO do real allocation work.
Instructions executed (simulated)
The instruction ratio falls from about 111× to about 7× when allocation dominates.
Why Python loses on this loop¶
- C++/Rust:
t = f(s)copies an 8-byte stack pointer. No heap write. - Python: every assignment INCREF/DECREF the
PyUnicodeObject, a read-modify-write onob_refcntthat dirties a cache line shared with type metadata. - The 12 MB string is on purpose: same heap layout in all three languages (too big for C++ small-string optimization), so the test measures refcount and dispatch, not copy cost.
Python variants¶
Same 12 MB loop with source-level tricks (inline, unroll, Cython, PyPy). Green = CPython. Purple = Cython. Yellow = PyPy.
Wall time by variant
Inlining removes the call (about 3×). Cython cuts bytecode overhead (about 21×). L1 misses stay flat because PyObjects still live on the heap.
Reproduce¶
| Topic | Page |
|---|---|
| Reference passing code | benchmarks/reference-passing.md |
| Allocation code | benchmarks/allocation.md |
| Python optimizations | python-optimizations.md |
| Measurement pipeline | methodology.md |
| Written analysis | PERFORMANCE_ANALYSIS.md |
Charts load from docs/data/benchmarks.json, rebuilt by make results-json.