Skip to content

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

const auto& t = f(s);  // f returns const string&
  1. Load address of s from stack (8 B read)
  2. Store address in t (8 B write, stack only)
  3. Zero heap writes
t = f(s)   # def f(x): return x
  1. Bytecode: set up call frame, push s
  2. Run f: return s (INCREF on heap object)
  3. Assign to t: DECREF old t, INCREF new value
  4. Each INCREF/DECREF is a read-modify-write on ob_refcnt
  5. That field shares a 64 B cache line with ob_type and 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

python/bench.py
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++

cpp/bench.cpp
#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

rust/bench.rs
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

python_optimized/bench_02_inline.py
"""
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

Run

make bench_languages
# or:
python3 python/bench.py && ./cpp/bench && ./rust/bench