🟠 Python Internals

Performance Engineering: Profiling, __slots__, C Extensions & Free-Threaded Python

📖 Lesson 49 ⏱ 70 min 🧪 5 questions 💻 3 exercises

🎯 Learning Objectives

  • Profile Python code with cProfile, line_profiler, memory_profiler, and py-spy to find real bottlenecks
  • Apply micro-optimisations: __slots__, local variable caching, avoiding globals in hot loops, functools.lru_cache
  • Use timeit and perf_counter correctly for benchmarking
  • Accelerate numeric code with NumPy vectorisation and functools.reduce vs loops
  • Write a C extension module using the CPython C API (PyArg_ParseTuple, PyLong_FromLong)
  • Use Cython for zero-boilerplate C-speed Python functions
  • Understand Python 3.13's free-threaded (no-GIL) mode and what it means for CPU-bound parallelism

Section 1 — The Performance Mindset: Measure First

Golden rule: never optimise without profiling first. The bottleneck is almost never where you think it is. Developers routinely spend hours optimising code that accounts for 0.1% of total runtime while the real culprit — an O(n²) loop buried in a helper function — goes untouched.

The performance hierarchy, ordered from highest-impact to lowest:

  1. Algorithm improvement (O(n²) → O(n log n)) — always try first
  2. Data structure choice (list lookup O(n) → set lookup O(1))
  3. Vectorisation (NumPy instead of Python loops)
  4. Micro-optimisations (slots, local vars, avoiding globals)
  5. C extensions / Cython (for truly hot numeric loops)
  6. Parallelism (multiprocessing, free-threaded Python 3.13)

The full profiling toolkit — cProfile gives you call-count and time per function with minimal overhead:

import cProfile
import pstats
import io

# ── cProfile: call-count + time per function ──
def slow_function():
    total = 0
    for i in range(100_000):
        total += i * i
    return total

# Method 1: enable/disable
pr = cProfile.Profile()
pr.enable()
result = slow_function()
pr.disable()

stream = io.StringIO()
ps = pstats.Stats(pr, stream=stream).sort_stats("cumulative")
ps.print_stats(10)   # top 10 functions by cumulative time
print(stream.getvalue())

# Method 2: context manager
with cProfile.Profile() as pr:
    slow_function()
pstats.Stats(pr).sort_stats("tottime").print_stats(5)

# Method 3: command line (most convenient)
# python -m cProfile -s cumulative my_script.py
# python -m cProfile -o output.prof my_script.py
# python -m pstats output.prof   → interactive browser
profiling_basics.py

Visualise profiling data with snakeviz — it renders a sunburst diagram in your browser showing where time is spent:

pip install snakeviz
python -m cProfile -o profile.prof my_script.py
snakeviz profile.prof   # opens a sunburst diagram in the browser
terminal
Concept: cProfile uses C-level hooks with very low overhead. It measures wall-clock time per function call. For finding which function is slow it is perfect. For finding which line inside a function is slow, use line_profiler.

Section 2 — Line-Level & Memory Profiling

When cProfile tells you a function is slow, the next step is finding which lines inside it are the bottleneck. line_profiler times every line individually. memory_profiler shows memory allocations per line — essential for debugging memory leaks and reducing peak RSS.

pip install line_profiler memory_profiler
terminal
# ── line_profiler: time per line ──
# Run: kernprof -l -v script.py  (from the command line)
# The @profile decorator is injected automatically by kernprof

@profile   # noqa — injected by kernprof
def process_data(data: list[int]) -> int:
    result = 0
    for item in data:          # line 1
        if item % 2 == 0:      # line 2
            result += item     # line 3
        else:
            result -= item     # line 4
    return result

data = list(range(1_000_000))
process_data(data)
line_profiler_demo.py
# ── memory_profiler: memory per line ──
from memory_profiler import profile as mprofile

@mprofile
def build_large_dict(n: int) -> dict:
    d = {}
    for i in range(n):
        d[i] = f"value_{i}"
    return d

build_large_dict(100_000)
# Output shows memory increment per line
memory_profiler_demo.py
# ── tracemalloc: built-in allocation tracking ──
import tracemalloc

tracemalloc.start()
data = [{"id": i, "val": str(i)} for i in range(10_000)]
snapshot = tracemalloc.take_snapshot()
for stat in snapshot.statistics("lineno")[:5]:
    print(stat)
tracemalloc.stop()
tracemalloc_demo.py

For production profiling of running services, py-spy attaches to a live process without restarting it — zero code changes needed:

# Record a flamegraph of a running process
py-spy record --pid $(pgrep -f my_server.py) -o flamegraph.svg --duration 30
terminal

Section 3 — Micro-Optimisations

Once you have profiling data pointing at a specific function, these micro-level techniques can squeeze out 2–10× speedups without leaving pure Python. The key insight: CPython's bytecode interpreter has different costs for LOAD_FAST (local), LOAD_GLOBAL (module-level), and LOAD_ATTR (attribute access). Minimising the expensive opcodes in tight loops pays off.

import timeit

# ── 1. Cache globals as locals in hot loops ──
import math

def slow_sqrt_sum(n: int) -> float:
    total = 0.0
    for i in range(n):
        total += math.sqrt(i)   # LOAD_GLOBAL math, LOAD_ATTR sqrt every iteration
    return total

def fast_sqrt_sum(n: int) -> float:
    total = 0.0
    sqrt = math.sqrt            # cached as local — LOAD_FAST is faster than LOAD_GLOBAL
    for i in range(n):
        total += sqrt(i)
    return total

n = 1_000_000
print(timeit.timeit(lambda: slow_sqrt_sum(n), number=3))
print(timeit.timeit(lambda: fast_sqrt_sum(n), number=3))

# ── 2. __slots__ (covered in Lesson 45 but benchmarked here) ──
import sys

class WithDict:
    def __init__(self, x, y, z): self.x, self.y, self.z = x, y, z

class WithSlots:
    __slots__ = ("x", "y", "z")
    def __init__(self, x, y, z): self.x, self.y, self.z = x, y, z

print(f"WithDict : {sys.getsizeof(WithDict(1,2,3))} B")
print(f"WithSlots: {sys.getsizeof(WithSlots(1,2,3))} B")

# ── 3. List vs set membership ──
needle = 999_999
large_list = list(range(1_000_000))
large_set  = set(large_list)

t_list = timeit.timeit(lambda: needle in large_list, number=100)
t_set  = timeit.timeit(lambda: needle in large_set,  number=100)
print(f"list: {t_list:.4f}s  set: {t_set:.6f}s  speedup: {t_list/t_set:.0f}x")

# ── 4. String joining ──
parts = ["a"] * 10_000

slow = timeit.timeit(lambda: "".join(str(p) for p in parts), number=1000)
fast = timeit.timeit(lambda: "".join(parts), number=1000)
print(f"generator join: {slow:.3f}s  list join: {fast:.3f}s")

# ── 5. functools.lru_cache for memoisation ──
from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n: int) -> int:
    if n < 2: return n
    return fib(n - 1) + fib(n - 2)

print(fib(50))   # instantaneous; without cache: 2^50 calls

# ── 6. Avoid repeated attribute lookup ──
class Processor:
    def run_slow(self, data):
        result = []
        for x in data:
            result.append(x * 2)   # result.append looked up N times
        return result

    def run_fast(self, data):
        append = list.append       # cache the method
        result = []
        for x in data:
            append(result, x * 2)
        return result
micro_optimisations.py

Section 4 — NumPy Vectorisation vs Pure Python

For numeric workloads, the single most impactful optimisation is replacing Python loops with NumPy vectorised operations. The performance gap is typically 50–200× for array operations because NumPy operates on contiguous C memory with SIMD instructions and avoids per-element Python object overhead.

import numpy as np
import timeit

N = 1_000_000

# ── Dot product ──
a_list = list(range(N))
b_list = list(range(N))
a_arr  = np.arange(N, dtype=np.float64)
b_arr  = np.arange(N, dtype=np.float64)

t_py  = timeit.timeit(lambda: sum(x*y for x, y in zip(a_list, b_list)), number=3)
t_np  = timeit.timeit(lambda: np.dot(a_arr, b_arr), number=3)
print(f"Python dot: {t_py:.3f}s  NumPy dot: {t_np:.5f}s  speedup: {t_py/t_np:.0f}x")

# ── Avoiding temporary arrays with in-place ops ──
rng  = np.random.default_rng(42)
data = rng.random((1000, 1000))

# Creates 3 intermediate arrays:
slow_result = (data * 2 + 1) ** 2

# No intermediate arrays — modify in-place:
fast_result = data.copy()
fast_result *= 2
fast_result += 1
fast_result **= 2

# ── numexpr for multi-array expressions ──
# pip install numexpr
try:
    import numexpr as ne
    expr = "(data * 2 + 1) ** 2"
    ne_result = ne.evaluate(expr)   # compiles to C, uses multiple threads
except ImportError:
    pass

# ── When NOT to use NumPy ──
# Small arrays (< ~100 elements): Python overhead > NumPy benefit
# Non-numeric data: strings, objects, mixed types
# Single-element operations: scalar math is faster in pure Python
numpy_vectorisation.py
Tip: The NumPy speedup comes from: (1) contiguous C memory (no pointer chasing), (2) BLAS/LAPACK optimised routines (SIMD, multi-core), (3) avoiding Python's per-element ob_refcnt / ob_type overhead. The crossover point where NumPy is faster than Python is roughly N > 100 elements.

Section 5 — Writing a C Extension

C extensions let you write performance-critical code in C and call it from Python. The CPython C API is the standard interface — every built-in module (math, json, re) is written this way. When NumPy vectorisation isn't applicable (custom algorithms, branching logic, pointer manipulation), a C extension gives you bare-metal speed.

/* fast_math.c — a simple C extension */
#define PY_SSIZE_T_CLEAN
#include <Python.h>
#include <math.h>

/* C function: sum of squares */
static PyObject *
sum_of_squares(PyObject *self, PyObject *args)
{
    long n;
    if (!PyArg_ParseTuple(args, "l", &n))   /* parse one long integer arg */
        return NULL;

    double total = 0.0;
    for (long i = 0; i < n; i++)
        total += (double)i * (double)i;

    return PyFloat_FromDouble(total);
}

/* Module method table */
static PyMethodDef FastMathMethods[] = {
    {"sum_of_squares", sum_of_squares, METH_VARARGS,
     "Compute sum of squares 0..n-1 (fast C implementation)."},
    {NULL, NULL, 0, NULL}   /* sentinel */
};

/* Module definition */
static struct PyModuleDef fastmathmodule = {
    PyModuleDef_HEAD_INIT,
    "fast_math",           /* module name */
    NULL,                  /* module docstring */
    -1,                    /* per-interpreter state (-1 = global) */
    FastMathMethods
};

/* Module init function — must be named PyInit_<modulename> */
PyMODINIT_FUNC
PyInit_fast_math(void)
{
    return PyModule_Create(&fastmathmodule);
}
fast_math.c

Build it with setuptools:

# setup.py
from setuptools import setup, Extension

module = Extension(
    "fast_math",
    sources=["fast_math.c"],
    extra_compile_args=["-O3", "-march=native"],
)

setup(
    name="fast_math",
    ext_modules=[module],
)
setup.py
python setup.py build_ext --inplace
python -c "import fast_math; print(fast_math.sum_of_squares(1_000_000))"
terminal

Modern alternative using pyproject.toml:

[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.backends.legacy:build"

[project]
name = "fast_math"
pyproject.toml
Concept: The CPython C API is powerful but verbose. For each operation you must: parse arguments with PyArg_ParseTuple, check for NULL return (error indicator), convert results to Python objects with PyLong_FromLong / PyFloat_FromDouble / PyUnicode_FromString, and manage reference counts with Py_INCREF / Py_DECREF. Libraries like cffi, ctypes, and pybind11 (for C++) provide higher-level alternatives.

Section 6 — Cython: Python → C at Zero Boilerplate

Cython compiles a superset of Python (with optional C type annotations) to C, then to a .so / .pyd extension module. You get C-level performance without writing C directly — just annotate your Python with types and Cython handles memory management, error checking, and the module boilerplate.

pip install cython
terminal
# fast_sum.pyx — Cython source
# Type annotations compile to C stack variables (no PyObject overhead)
def sum_squares_py(int n) -> float:
    """Pure Python fallback — no type annotations."""
    total = 0.0
    for i in range(n):
        total += i * i
    return total

def sum_squares_typed(int n) -> double:
    """Typed Cython — compiles to efficient C loop."""
    cdef int i
    cdef double total = 0.0
    for i in range(n):
        total += <double>i * i
    return total

# ── Cython with NumPy arrays (typed memoryviews) ──
import numpy as np
cimport numpy as cnp

def dot_product(cnp.ndarray[double, ndim=1] a,
                cnp.ndarray[double, ndim=1] b) -> double:
    cdef int n = a.shape[0]
    cdef int i
    cdef double result = 0.0
    for i in range(n):
        result += a[i] * b[i]
    return result
fast_sum.pyx
# setup_cython.py
from setuptools import setup
from Cython.Build import cythonize
import numpy as np

setup(
    ext_modules=cythonize("fast_sum.pyx", annotate=True),
    include_dirs=[np.get_include()],
)
setup_cython.py
python setup_cython.py build_ext --inplace
python -c "import fast_sum; print(fast_sum.sum_squares_typed(1_000_000))"

# Generate annotation HTML to see Python vs C lines:
cython -a fast_sum.pyx
terminal
Tip: The annotate=True flag in cythonize generates an HTML report. Lines highlighted in yellow still call the Python C API; the goal is to make hot loops entirely white (pure C). A cdef double variable becomes a C double on the stack — zero Python overhead.

Section 7 — Free-Threaded Python (3.13 No-GIL Mode)

Python 3.13 ships an experimental free-threaded build (no-GIL) as an opt-in. This is the most significant change to CPython's architecture since the GIL was introduced in 1992 — it allows CPU-bound Python threads to run truly in parallel on multiple cores.

# Install free-threaded Python (e.g. via pyenv)
pyenv install 3.13t   # 't' suffix = free-threaded

# Verify GIL status
python -c "import sys; print(sys._is_gil_enabled())"   # False in free-threaded build
terminal
# Traditional GIL limitation — threads cannot run CPU-bound code in parallel
import threading, time

def cpu_work(n: int) -> int:
    total = 0
    for i in range(n): total += i
    return total

N = 5_000_000

# Single thread
t0 = time.perf_counter()
cpu_work(N); cpu_work(N)
print(f"Sequential:  {time.perf_counter()-t0:.3f}s")

# Two threads (with GIL: no speedup for CPU-bound)
t0 = time.perf_counter()
t1 = threading.Thread(target=cpu_work, args=(N,))
t2 = threading.Thread(target=cpu_work, args=(N,))
t1.start(); t2.start(); t1.join(); t2.join()
print(f"2 threads (GIL):    {time.perf_counter()-t0:.3f}s")  # roughly same as sequential

# In free-threaded Python 3.13 (no GIL):
# The two threads run on separate CPU cores simultaneously
# → ~2x speedup for CPU-bound workloads
free_threaded_demo.py

Key architectural changes in free-threaded Python:

  • Per-object locking replaces the GIL — each object has its own lock
  • Atomic reference counting (using C11 atomics) — no GIL needed for refcount
  • Thread-safe dict, list, and set operations
  • C extensions compiled for the GIL build still work but are single-threaded (compatibility shim)
  • Extensions must be recompiled with the free-threaded build to get full parallelism

Performance model comparison:

Workload GIL (3.12) Free-threaded (3.13)
CPU-bound, threads No speedup Near-linear scaling
I/O-bound, threads ✅ Full speedup ✅ Full speedup
CPU-bound, multiprocessing ✅ Full speedup ✅ Full speedup
Single-threaded Baseline ~5% slower (per-object lock overhead)
Concept: Free-threaded Python does not remove the need for thread-safe programming — race conditions in Python code are still possible. What changes: CPU-bound threads can now use multiple cores without multiprocessing overhead. You still need locks, queues, or atomics to protect shared mutable state from data races.

cffi & ctypes: Calling C Without Compiling

When you need to call an existing C library — or prototype a fast routine — ctypes (stdlib) and cffi (third-party) let you do it without writing a full C extension.

import ctypes, os

# ── ctypes: load a shared library and call C functions ──
# Load libc (Linux/macOS)
libc = ctypes.CDLL("libc.so.6" if os.name != "nt" else "msvcrt.dll")

# Call strlen — tell ctypes the argument and return types
libc.strlen.argtypes = [ctypes.c_char_p]
libc.strlen.restype  = ctypes.c_size_t
print(libc.strlen(b"hello"))   # 5

# Create a C array and sum it
ArrayType = ctypes.c_double * 5
arr = ArrayType(1.0, 2.0, 3.0, 4.0, 5.0)
print(sum(arr))   # 15.0

# ── ctypes with a custom shared library ──
# Compile: gcc -O3 -shared -fPIC -o libfast.so fast_math.c
# lib = ctypes.CDLL("./libfast.so")
# lib.sum_of_squares.argtypes = [ctypes.c_long]
# lib.sum_of_squares.restype  = ctypes.c_double
# print(lib.sum_of_squares(1_000_000))
ctypes_demo.py
# ── cffi (C Foreign Function Interface) — more Pythonic than ctypes ──
# pip install cffi
from cffi import FFI

ffi = FFI()

# Declare the C function signature
ffi.cdef("""
    double sum_squares(long n);
    size_t strlen(const char *s);
""")

# Load the library (ABI mode — no compilation needed for existing .so)
lib = ffi.dlopen("libc.so.6")   # or your custom .so

# Call strlen
s = ffi.new("char[]", b"hello world")
print(lib.strlen(s))   # 11

# ── cffi in-line mode: embed C code directly in Python ──
ffi2 = FFI()
ffi2.cdef("int add(int a, int b);")

lib2 = ffi2.verify("""
    int add(int a, int b) { return a + b; }
""")
print(lib2.add(3, 4))   # 7  — compiled and loaded on the fly
cffi_demo.py
When to use what:
  • ctypes — stdlib, no install, good for simple calls to existing C libraries
  • cffi — cleaner API, better ABI mode, recommended for new code
  • C extension — full control, best performance, but most boilerplate
  • Cython — best for accelerating existing Python code; generates C extension automatically
  • pybind11 — C++ bindings with minimal boilerplate; integrates with CMake

Numba: JIT Compilation for Numeric Python

Numba compiles Python functions to native machine code using LLVM at first call. No C code, no compilation step — just a decorator.

pip install numba
terminal
from numba import njit, prange
import numpy as np
import timeit

# ── @njit: compile to native machine code (no Python overhead) ──
@njit
def sum_squares_numba(n: int) -> float:
    total = 0.0
    for i in range(n):
        total += i * i
    return total

# First call triggers compilation (~0.5s)
sum_squares_numba(10)

N = 1_000_000
t_py    = timeit.timeit(lambda: sum(i*i for i in range(N)), number=5)
t_numba = timeit.timeit(lambda: sum_squares_numba(N), number=5)
print(f"Python: {t_py:.3f}s   Numba: {t_numba:.5f}s   speedup: {t_py/t_numba:.0f}x")

# ── Parallel loops with prange ──
@njit(parallel=True)
def parallel_sum(arr: np.ndarray) -> float:
    total = 0.0
    for i in prange(len(arr)):   # prange → OpenMP parallel loop
        total += arr[i] * arr[i]
    return total

data = np.arange(10_000_000, dtype=np.float64)
print(parallel_sum(data))

# ── GPU acceleration with @cuda.jit ──
# from numba import cuda
# @cuda.jit
# def gpu_kernel(arr, out): ...   # runs on NVIDIA GPU

# ── What Numba can and cannot do ──
# ✅ Works with: NumPy arrays, numeric Python (int, float), loops, conditionals
# ❌ Fails with: arbitrary Python objects, dicts (mostly), strings, class instances
numba_demo.py
Numba's @njit ("no Python" JIT) compiles the function to native code that bypasses the CPython interpreter entirely. Unlike Cython, you do not write C — Numba infers types from the first call's arguments and generates LLVM IR. The compiled code is cached on disk so the compilation overhead only occurs once. Use Numba when your bottleneck is a pure numeric loop that NumPy cannot vectorise.

Performance Cookbook

Anti-Patterns (Things That Silently Slow You Down)

import dis, timeit

# ❌ Anti-pattern 1: Repeated attribute lookup in hot loops
class Processor:
    results = []
    def slow(self, data):
        for x in data:
            self.results.append(x)   # self.results AND .append looked up every iteration

    def fast(self, data):
        append = self.results.append  # cache the bound method
        for x in data:
            append(x)

# ❌ Anti-pattern 2: Building a string by concatenation
def slow_join(parts):
    result = ""
    for p in parts:
        result += p   # creates a NEW string object every iteration O(n²)
    return result

def fast_join(parts):
    return "".join(parts)   # single allocation

# ❌ Anti-pattern 3: Using a list where a deque is needed for popleft
from collections import deque
data = list(range(100_000))
dq   = deque(range(100_000))

t_list  = timeit.timeit(lambda: data.pop(0), number=1000)   # O(n) shift
t_deque = timeit.timeit(lambda: dq.popleft(), number=1000)  # O(1)
print(f"list.pop(0): {t_list:.4f}s  deque.popleft: {t_deque:.6f}s")

# ❌ Anti-pattern 4: Checking type with type() instead of isinstance()
def slow_check(x):
    return type(x) == int   # does not handle subclasses; slightly slower

def fast_check(x):
    return isinstance(x, int)   # handles subclasses; JIT-friendly

# ❌ Anti-pattern 5: Creating lambdas / partials in a tight loop
import functools
fn = functools.partial(pow, 2)   # create ONCE outside the loop
for _ in range(1_000_000):
    fn(10)   # reuse the same partial object
anti_patterns.py

Quick-Reference Performance Decision Table

ProblemSolutionTypical speedup
Slow algorithm (O(n²))Better algorithm / data structure10–1000×
List membership checkConvert to set100–10000×
Python loop over numbersNumPy vectorisation50–200×
Repeated expensive callfunctools.lru_cache10–1000×
Millions of small objects__slots__2–3× memory, 10–20% speed
Hot numeric loopNumba @njit50–200×
Calling C libraryctypes / cffiNear-C speed
Accelerating Python moduleCython typed annotations10–100×
CPU-bound multi-coremultiprocessing or Python 3.13 free-threadedN×cores
I/O-bound concurrentasyncio or threadsN×connections

Best Practices

  • Profile before optimising — always. Use cProfile to find the slow function, then line_profiler to find the slow line. Optimising the wrong place wastes time and adds complexity.
  • Fix the algorithm first — a 100× algorithmic improvement beats a 2× micro-optimisation. Replace O(n²) with O(n log n) before reaching for NumPy or C.
  • Benchmark with timeit, not wall-clock timetimeit runs the code many times and accounts for JIT warm-up and system noise. A single perf_counter call is unreliable for short-running code.
  • Cache global lookups as locals in hot loopsLOAD_FAST is faster than LOAD_GLOBAL + LOAD_ATTR. Assign sqrt = math.sqrt before a tight loop.
  • Use __slots__ for classes with millions of instances — eliminates per-instance __dict__, saving 40–60% memory and improving attribute access speed.
  • Prefer in-place NumPy operations for large arraysa += b instead of a = a + b avoids allocating a temporary array, halving peak memory usage.
  • Use Numba for loops NumPy can't vectorise — recursive algorithms, variable-length inner loops, custom reduction logic. Add @njit(cache=True) to persist compiled code across runs.
  • Treat free-threaded Python 3.13 as experimental for now — ecosystem packages (NumPy, pandas) are still adding free-threaded support. Test thoroughly before deploying GIL-free builds in production.

Exercises

Exercise 1 — Profile & Optimise a Data Pipeline

Given the following slow data processing function, use cProfile and timeit to identify the bottleneck and then fix it:

import random

def generate_data(n=100_000):
    return [{"id": i, "value": random.random(), "tag": str(i % 10)} for i in range(n)]

def process_slow(records):
    # Find records where tag is "5" and value > 0.5, return sorted by value desc
    result = []
    for r in records:
        if r["tag"] == "5":
            if r["value"] > 0.5:
                result.append(r)
    result = sorted(result, key=lambda x: x["value"], reverse=True)
    return result
pipeline.py
  • Profile process_slow with cProfile; identify top time consumers.
  • Rewrite process_fast using: list comprehension, direct dict access, and sorted with a key function.
  • Benchmark both with timeit on 100k records; print the speedup ratio.
  • As a stretch goal: implement process_numpy that converts the records to NumPy structured arrays and filters/sorts in NumPy.
💡 Hint
def process_fast(records):
    return sorted(
        (r for r in records if r["tag"] == "5" and r["value"] > 0.5),
        key=lambda x: x["value"],
        reverse=True,
    )

# Benchmark
import timeit
data = generate_data()
t_slow = timeit.timeit(lambda: process_slow(data), number=10)
t_fast = timeit.timeit(lambda: process_fast(data), number=10)
print(f"slow={t_slow:.3f}s  fast={t_fast:.3f}s  speedup={t_slow/t_fast:.1f}x")

Exercise 2 — Benchmark __slots__ at Scale

Quantify the real-world impact of __slots__ on a class that models a financial tick (high-frequency trading data):

  • Define TickDict (no slots) and TickSlots (__slots__) both with: symbol: str, price: float, volume: int, timestamp: float.
  • Create 5 000 000 instances of each; measure: peak memory with tracemalloc, construction time with timeit, attribute read time (t.price × 10M reads).
  • Print a formatted comparison table.
  • Confirm that TickSlots.__dict__["price"] is a member_descriptor.
💡 Hint
import sys, tracemalloc, timeit

class TickDict:
    def __init__(self, sym, price, vol, ts):
        self.symbol, self.price, self.volume, self.timestamp = sym, price, vol, ts

class TickSlots:
    __slots__ = ("symbol", "price", "volume", "timestamp")
    def __init__(self, sym, price, vol, ts):
        self.symbol, self.price, self.volume, self.timestamp = sym, price, vol, ts

N = 5_000_000
for cls in (TickDict, TickSlots):
    tracemalloc.start()
    objs = [cls("AAPL", 175.0 + i*0.0001, 100, 1700000000.0 + i) for i in range(N)]
    _, peak = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    read_t = timeit.timeit(lambda: objs[0].price, number=10_000_000)
    print(f"{cls.__name__:12s}  size={sys.getsizeof(objs[0])}B  "
          f"peak={peak/1e9:.2f}GB  read={read_t:.3f}s")

Exercise 3 — Write and Benchmark a C Extension

Write a C extension that implements a fast prime sieve and benchmark it against a pure-Python equivalent:

  • Implement the Sieve of Eratosthenes in C (sieve.c): sieve(int n) returns a Python list of all primes up to n.
  • Write setup.py to compile it as a Python extension module.
  • Implement the same algorithm in pure Python (sieve_py(n)).
  • Benchmark both for n=1_000_000 using timeit; print the speedup.
  • Stretch: also implement in NumPy (sieve_numpy(n)) using boolean array masking.
💡 Hint — sieve.c skeleton
#include <Python.h>
#include <stdlib.h>
#include <string.h>

static PyObject *sieve(PyObject *self, PyObject *args) {
    int n;
    if (!PyArg_ParseTuple(args, "i", &n)) return NULL;

    char *composite = calloc(n + 1, 1);  /* 0 = prime, 1 = composite */
    composite[0] = composite[1] = 1;
    for (int i = 2; (long)i*i <= n; i++) {
        if (!composite[i]) {
            for (int j = i*i; j <= n; j += i)
                composite[j] = 1;
        }
    }
    PyObject *list = PyList_New(0);
    for (int i = 2; i <= n; i++) {
        if (!composite[i])
            PyList_Append(list, PyLong_FromLong(i));
    }
    free(composite);
    return list;
}

static PyMethodDef SieveMethods[] = {
    {"sieve", sieve, METH_VARARGS, "Sieve of Eratosthenes"},
    {NULL, NULL, 0, NULL}
};
static struct PyModuleDef sievermodule = {
    PyModuleDef_HEAD_INIT, "sieve_c", NULL, -1, SieveMethods
};
PyMODINIT_FUNC PyInit_sieve_c(void) { return PyModule_Create(&sievermodule); }