Skip to content

Python optimizations

These variants test how far you can get before PyObject overhead dominates.

flowchart TD
    B[baseline t = f s] --> I[inline / unroll]
    I --> C[Cython / PyPy]
    I -.->|L1 misses ~1.2M| W[still heap PyObjects]
    C --> N[native loop, fewer bytecodes]

    style W fill:#4e342e,color:#fff
    style N fill:#1b5e20,color:#fff

Pure Python tweaks buy 10-40% on instructions, sometimes more on wall time. L1 miss count barely moves because the objects stay scattered on the heap.

Results

ms speedup instr (M)
Baseline 296 1.0× 8,944
Inline 107 2.8× 2,914
Loop unroll 35 8.6× 898
Combined 36 8.2× 898
Cython 14 21× 104
PyPy3 30 10× 230

Cython drops instructions from 8,944M to 104M, which is mostly the interpreter going away. The leftover ~14 ms is still refcount and memory traffic. cdef str is still a PyUnicodeObject.

Wall time (ms) · hyperfine

Speedup vs baseline

Instructions (M) · cachegrind

L1 data misses · cachegrind

CPU cycles (M) · perf stat

IPC · perf stat

NumPy variant omitted from charts: it runs a vectorized integer add, not this string loop.

Inlining removes the function call (~3× faster) but L1 misses stay flat: the PyObject still lives in the same heap slot. Cython drops instructions ~86× by eliminating bytecode; the remaining cost is refcount traffic.

Escape paths

quadrantChart
    title Speedup vs changes data layout
    x-axis "same PyObject layout" --> "different memory"
    y-axis "low speedup" --> "high speedup"
    quadrant-1 "rewrite in C"
    quadrant-2 "different problem"
    "inline/unroll": [0.25, 0.35]
    "Cython": [0.3, 0.85]
    "PyPy": [0.35, 0.75]
    "NumPy": [0.9, 0.6]

The NumPy row is a different benchmark (vector add). It is not comparable to the string loop.

Source

Baseline

python_optimized/bench_01_baseline.py
"""
Baseline: Original Python implementation
This is the reference implementation with no optimizations.
"""
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()

Inline

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

Loop unroll (10× inside each iteration)

python_optimized/bench_04_loop_unroll.py
"""
Optimization 3: Loop unrolling
Reduce loop overhead by processing multiple iterations per loop cycle.
This reduces the number of branch instructions and loop counter updates.
Expected improvement: ~15-25%
"""
import time

def main():
    s = "Lorem ipsum " * 1_000_000
    print(f"String length: {len(s)}")

    start = time.perf_counter()
    # Unroll the loop 10x - do 10 assignments per iteration
    for _ in range(1_000_000):
        t = s
        t = s
        t = s
        t = s
        t = s
        t = s
        t = s
        t = s
        t = s
        t = s
    end = time.perf_counter()
    print(f"Time: {end - start:.6f} s")

if __name__ == "__main__":
    main()

Combined

python_optimized/bench_10_combined.py
"""
Optimization 10: Combined optimizations
Combine multiple optimization techniques for maximum performance.
Expected improvement: ~30-40%
"""
import time

def main():
    # Use local variable
    s = "Lorem ipsum " * 1_000_000
    print(f"String length: {len(s)}")

    # Cache local reference
    local_s = s

    start = time.perf_counter()
    # Loop unrolling + inline + local cache
    for _ in range(1_000_000):
        _ = local_s
        _ = local_s
        _ = local_s
        _ = local_s
        _ = local_s
        _ = local_s
        _ = local_s
        _ = local_s
        _ = local_s
        _ = local_s
    end = time.perf_counter()
    print(f"Time: {end - start:.6f} s")

if __name__ == "__main__":
    main()

Cython

python_optimized/bench_06_cython.pyx
# cython: language_level=3
# cython: boundscheck=False
# cython: wraparound=False
"""
Cython optimized version with type annotations.
This compiles to C and eliminates PyObject overhead for typed variables.
"""
import time

cdef str f(str x):
    return x

def main():
    cdef str s = "Lorem ipsum " * 1_000_000
    cdef double start, end
    cdef long i
    cdef str t

    print(f"String length: {len(s)}")

    start = time.perf_counter()
    for i in range(10_000_000):
        t = f(s)
    end = time.perf_counter()
    print(f"Time: {end - start:.6f} s")

if __name__ == "__main__":
    main()

Runner

python_optimized/benchmark_python.py
#!/usr/bin/env python3
"""
benchmark_python.py

Consolidated benchmark runner for Python optimization experiments.
Combines:
1. Compilation (Cython)
2. Timing execution (Hyperfine)
3. Hardware counter analysis (Valgrind & Perf)

Generates: PYTHON_OPTIMIZATION_REPORT.md
"""

import subprocess
import os
import re
import sys
import json
from pathlib import Path

# --- Configuration ---
BENCHMARKS = [
    ("01 Baseline", "python3 bench_01_baseline.py"),
    ("02 Inline", "python3 bench_02_inline.py"),
    ("03 LocalCache", "python3 bench_03_local_cache.py"),
    ("04 LoopUnroll", "python3 bench_04_loop_unroll.py"),
    ("05 PyPyScript (CPython)", "python3 bench_05_pypy.py"),
    ("06 Cython Native", "./bench_06_cython"),
    ("07 NumPy", "python3 bench_07_numpy.py"),
    ("08 RefcountAware", "python3 bench_08_refcount_aware.py"),
    ("09 ctypes Demo", "python3 bench_09_ctypes.py"),
    ("10 Combined", "python3 bench_10_combined.py"),
]

# Check for PyPy3 availability
if subprocess.run("which pypy3", shell=True, capture_output=True).returncode == 0:
    BENCHMARKS.append(("PyPy3 JIT", "pypy3 bench_01_baseline.py"))
else:
    print("[WARNING] pypy3 not found, skipping PyPy benchmark.")

OUTPUT_REPORT = "PYTHON_OPTIMIZATION_REPORT.md"
HF_JSON = "hf_results.json"

def run_cmd(cmd, capture=True):
    """Run a command and return stdout, stderr, and success status."""
    print(f"[RUNNING] {cmd}")
    try:
        if capture:
            process = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=300)
            return process.stdout, process.stderr, process.returncode == 0
        else:
            process = subprocess.run(cmd, shell=True, timeout=300)
            return "", "", process.returncode == 0
    except Exception as e:
        print(f"[ERROR] {e}")
        return "", str(e), False

def compile_cython():
    """Compiles the Cython benchmark if needed."""
    if not Path("./bench_06_cython.pyx").exists():
        print("[ERROR] bench_06_cython.pyx not found.")
        return False

    print("--- Compiling Cython ---")
    # Clean previous build
    if Path("./bench_06_cython.c").exists():
        os.remove("./bench_06_cython.c")

    cmd = "cython3 --embed -o bench_06_cython.c bench_06_cython.pyx && gcc -O3 -I/usr/include/python3.10 -o bench_06_cython bench_06_cython.c -lpython3.10"
    _, stderr, ok = run_cmd(cmd)
    if not ok:
        print(f"[FAIL] Cython compilation failed:\n{stderr}")
        return False
    return True

def parse_valgrind(stderr):
    """Extract metrics from Valgrind Cachegrind output."""
    metrics = {'instructions': 0, 'd1_misses': 0, 'll_misses': 0}

    pat_i = re.search(r"I\s+refs:\s+([\d,.]+)", stderr)
    pat_d1 = re.search(r"D1\s+misses:\s+([\d,.]+)", stderr)
    pat_ll = re.search(r"LL\s+misses:\s+([\d,.]+)", stderr)

    if pat_i: metrics['instructions'] = int(pat_i.group(1).replace(",", "").replace(".", ""))
    if pat_d1: metrics['d1_misses'] = int(pat_d1.group(1).replace(",", "").replace(".", ""))
    if pat_ll: metrics['ll_misses'] = int(pat_ll.group(1).replace(",", "").replace(".", ""))

    return metrics

def parse_perf(stderr):
    """Extract metrics from Perf Stat output."""
    metrics = {'cycles': 0, 'cache_misses': 0, 'l1_misses': 0}

    # Remove ANSI codes
    stderr = re.sub(r'\x1b\[[0-9;]*m', '', stderr)

    pat_c = re.search(r"([\d,.]+)\s+cycles", stderr)
    pat_cm = re.search(r"([\d,.]+)\s+cache-misses", stderr)
    pat_l1 = re.search(r"([\d,.]+)\s+L1-dcache-load-misses", stderr)

    if pat_c: metrics['cycles'] = int(pat_c.group(1).replace(",", "").replace(".", ""))
    if pat_cm: metrics['cache_misses'] = int(pat_cm.group(1).replace(",", "").replace(".", ""))
    if pat_l1: metrics['l1_misses'] = int(pat_l1.group(1).replace(",", "").replace(".", ""))

    return metrics

def main():
    # Ensure we are in the script's directory
    os.chdir(Path(__file__).parent)

    if not compile_cython():
        sys.exit(1)

    # 1. Hyperfine Timing
    print("\n--- Phase 1: Timing Analysis (Hyperfine) ---")
    commands = " ".join([f"'{b[1]}'" for b in BENCHMARKS])
    run_cmd(f"hyperfine --warmup 3 --export-json {HF_JSON} {commands}", capture=False)

    timing_data = {}
    if Path(HF_JSON).exists():
        with open(HF_JSON, "r") as f:
            raw = json.load(f)
            for res in raw['results']:
                timing_data[res['command']] = res

    # 2. Hardware Analysis
    print("\n--- Phase 2: Hardware Analysis (Valgrind & Perf) ---")
    results = []

    for name, cmd in BENCHMARKS:
        print(f"Analyzing: {name}")

        # Valgrind
        _, vg_stderr, ok_vg = run_cmd(f"valgrind --tool=cachegrind --cachegrind-out-file=/tmp/cg.out {cmd}")
        vg_metrics = parse_valgrind(vg_stderr) if ok_vg else {}

        # Perf (Try-catch essentially, as it might fail on some systems)
        _, perf_stderr, ok_perf = run_cmd(f"perf stat -e cycles,cache-misses,L1-dcache-load-misses {cmd}")
        perf_metrics = parse_perf(perf_stderr) if ok_perf else {}

        results.append({
            'name': name,
            'cmd': cmd,
            'timing': timing_data.get(cmd, {}),
            'valgrind': vg_metrics,
            'perf': perf_metrics
        })

    # 3. Generate Report
    generate_report(results)
    print(f"\n[DONE] Report generated at {OUTPUT_REPORT}")

def generate_report(results):
    baseline_time = 1.0
    for r in results:
        if "Baseline" in r['name'] and "PyPy" not in r['name']:
            baseline_time = r['timing'].get('mean', 1.0)
            break

    with open(OUTPUT_REPORT, "w") as f:
        f.write("# Python Optimization Analysis Report\n\n")
        f.write("I have analyzed the performance of various Python optimization techniques using a combination of `hyperfine` (timing), `cachegrind` (instruction/cache simulation), and `perf` (hardware counters).\n\n")

        f.write("## 1. Performance Summary\n\n")
        f.write("| Technique | Time (ms) | Speedup | Instructions (M) | L1 Cache Misses |\n")
        f.write("|:---|---:|---:|---:|---:|\n")

        for r in results:
            t_mean = r['timing'].get('mean', 0)
            time_ms = t_mean * 1000
            if t_mean > 0:
                speedup = baseline_time / t_mean
            else:
                speedup = 0.0

            instr = r['valgrind'].get('instructions', 0) / 1e6
            l1_miss = r['valgrind'].get('d1_misses', 0)

            f.write(f"| **{r['name']}** | {time_ms:.2f} | **{speedup:.2f}x** | {instr:.1f} | {l1_miss:,} |\n")

        f.write("\n## 2. Key Observations\n\n")

        # Determine best performer
        best = min(results, key=lambda x: x['timing'].get('mean', 9999))
        f.write(f"### Most Performant: {best['name']}\n")

        cython_res = next((r for r in results if "Cython" in r['name']), None)
        if cython_res:
             c_instr = cython_res['valgrind'].get('instructions', 0) / 1e6
             f.write(f"- Cython achieves near-native performance by eliminating interpreter overhead. It executed only **{c_instr:.1f}M** instructions compared to the baseline.\n")

        pypy_res = next((r for r in results if "PyPy" in r['name']), None)
        if pypy_res:
            f.write(f"- PyPy demonstrates the power of JIT, offering massive speedups for long-running loops, though its memory access patterns (L1 misses) can sometimes be higher due to JIT overhead.\n")

        f.write("\n### The 'Python' Ceiling\n")
        f.write("- Pure Python optimizations (Inlining, Loop Unrolling) offer measurable but limited gains (10-40%).\n")
        f.write("- The fundamental bottleneck remains the `PyObject` model and reference counting, which keeps the L1 cache pressure constant regardless of how we restructure the Python code.\n")

if __name__ == "__main__":
    main()

Run

make bench_python

Needs hyperfine, valgrind, cython3. pypy3 optional. Writes python_optimized/PYTHON_OPTIMIZATION_REPORT.md.