Reference passing¶
One 12 MB string; f returns it unchanged, ten million times. No new strings are allocated.
Results¶
| Language | Mean | vs Rust |
|---|---|---|
| Rust | 4.5 ms | 1× |
| C++ | 6.7 ms | 1.5× |
| Python | 293 ms | 65× |
hyperfine, 3 warmup runs. Raw: results_bench.md.
Wall clock
What one assignment costs¶
In C++/Rust, t = f(s) is a stack pointer copy with no heap write. In Python, every assignment INCREF/DECREF the PyUnicodeObject on the heap.
- Load address of
sfrom stack (8 B read) - Store address in
t(8 B write, stack only) - Zero heap writes
- Bytecode: set up call frame, push
s - Run
f: returns(INCREF on heap object) - Assign to
t: DECREF oldt, INCREF new value - Each INCREF/DECREF is a read-modify-write on
ob_refcnt - That field shares a 64 B cache line with
ob_typeand metadata, so the cache line gets dirtied even if you only wanted to read the string bytes
Memory layout¶
The name s is an 8-byte pointer in all three languages. In Python it points at a heap struct that gets written on every assign.
graph TB
subgraph stack["Your stack frame"]
direction TB
NS["C++ / Rust: &s (8 bytes)"]
NP["Python: s → PyObject* (8 bytes)"]
end
subgraph heap_cpp["Heap · C++ std::string"]
CD["12 MB char buffer"]
end
subgraph heap_py["Heap · PyUnicodeObject"]
direction TB
H1["ob_refcnt (8 B) ← written every assign"]
H2["ob_type (8 B)"]
H3["hash, len, flags…"]
H4["UTF-8 data (12 MB)"]
H1 --- H2 --- H3 --- H4
end
NS -->|"read only"| CD
NP -->|"pointer chase"| H1
style H1 fill:#ffebee,stroke:#c62828,color:#b71c1c,stroke-width:1px
style heap_py fill:#f5f5f5,stroke:#e0e0e0,color:#212121
sequenceDiagram
box Stack
participant V as variable t
end
box Heap PyObject
participant R as ob_refcnt
participant D as string data
end
Note over V,D: Python: t = f(s)
V->>R: INCREF (write)
V->>R: DECREF old t (write)
Note over V,D: C++: const auto& t = f(s)
V->>D: read via pointer (no heap write)
The 12 MB string is on purpose: it lives on the heap in all three languages (too big for C++ small-string optimization), so this test measures refcount and dispatch, not copy cost.
Where the time goes¶
pie title Python ~290 ms (approx)
"function call" : 190
"for loop" : 65
"refcount assign" : 35
Inlining to t = s drops the function-call slice. See bench_02_inline.py in Source below.
Cachegrind (ref-pass)¶
From valgrind cachegrind (make profile). Python runs ~100× slower under valgrind; cross-language ratios are what matter.
| C++ | Python | ratio | |
|---|---|---|---|
| Instructions | 79M | 8,774M | 111× |
| Data refs | 22M | 3,912M | 180× |
| L1 misses | 203K | 1.2M | 5.9× |
Instructions (millions)
Memory data references (millions)
L1 data cache misses
Last-level cache misses
Python divided by C++
Overhead split (estimated)
Source¶
Python¶
import time
def f(x):
return x
def main():
s = "Lorem ipsum " * 1_000_000
print(f"String length: {len(s)}")
start = time.perf_counter()
for _ in range(10_000_000):
t = f(s)
end = time.perf_counter()
print(f"Time: {end - start:.6f} s")
if __name__ == "__main__":
main()
C++¶
#include <string>
#include <iostream>
#include <chrono>
inline const std::string& f(const std::string& x) {
return x;
}
int main() {
std::string s;
s.reserve(12000000);
for (int k = 0; k < 1000000; ++k) {
s.append("Lorem ipsum ");
}
std::cout << "String length: " << s.length() << std::endl;
auto start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < 10000000; ++i) {
const auto& t = f(s);
// Prevent optimization of the loop body removal
asm volatile("" :: "r"(&t) : "memory");
}
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> diff = end - start;
std::cout << "Time: " << diff.count() << " s" << std::endl;
return 0;
}
Rust¶
use std::time::Instant;
use std::hint::black_box;
#[inline]
fn f(x: &String) -> &String {
x
}
fn main() {
let s = "Lorem ipsum ".repeat(1_000_000);
println!("String length: {}", s.len());
let start = Instant::now();
for _ in 0..10_000_000 {
let t = f(&s);
black_box(t);
}
let duration = start.elapsed();
println!("Time: {:.6} s", duration.as_secs_f64());
}
Python without the call¶
"""
Optimization 1: Eliminate function call overhead
By inlining the function, we avoid the function call overhead and frame creation.
Expected improvement: ~10-15%
"""
import time
def main():
s = "Lorem ipsum " * 1_000_000
print(f"String length: {len(s)}")
start = time.perf_counter()
for _ in range(10_000_000):
t = s # Inline the function - direct assignment
end = time.perf_counter()
print(f"Time: {end - start:.6f} s")
if __name__ == "__main__":
main()
| variant | ~time |
|---|---|
t = f(s) |
290 ms |
t = s |
100 ms |
| C++ / Rust | 2-7 ms |