Skip to content

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.

Reference passing · Allocation · How we measured

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

  • Wall, ref-pass: 10M× t = f(s) on one 12 MB string; Python vs Rust
  • Wall, alloc: 1M× build a new short string; Python vs C++
  • Instr, ref-pass: same loop, instruction count under cachegrind
  • Instr, alloc: same loop, instruction count under 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 on ob_refcnt that 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.

Full walkthrough with memory diagrams →

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

make results && make docs-serve
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.