26 — Performance & Optimization
Profiling Workflow — Measure Before Optimizing
# ── Production profiling workflow: cProfile → identify hotspots → line_profiler → optimize ──
import cProfile
import pstats
import time
from io import StringIO
def find_primes_below(n: int) -> list[int]:
"""Sieve of Eratosthenes — a realistic CPU-bound function to profile."""
sieve = [True] * n
sieve[0] = sieve[1] = False
for i in range(2, int(n ** 0.5) + 1):
if sieve[i]:
for j in range(i * i, n, i):
sieve[j] = False
return [i for i, is_prime in enumerate(sieve) if is_prime]
def count_primes_in_ranges(ranges: list[tuple[int, int]]) -> dict[int, int]:
"""Count primes in multiple ranges — calls find_primes_below repeatedly."""
results = {}
for range_id, (lo, hi) in enumerate(ranges):
primes = find_primes_below(hi)
count = sum(1 for p in primes if p >= lo)
results[range_id] = count
return results
# ── Step 1: cProfile — identify WHICH function dominates ──
def profile_with_cprofile():
"""Run cProfile and print top functions by cumulative time."""
ranges = [(0, 100_000), (0, 200_000), (0, 50_000), (0, 300_000)]
profiler = cProfile.Profile()
profiler.enable()
result = count_primes_in_ranges(ranges)
profiler.disable()
# Sort by cumulative time — shows which functions dominate total runtime
stats = pstats.Stats(profiler)
stats.sort_stats("cumulative")
stats.print_stats(10) # top 10 functions
# Key columns:
# ncalls — how many times the function was called
# tottime — time IN this function alone (excluding sub-calls)
# cumtime — time including all sub-calls
# percall — tottime or cumtime / ncalls
profile_with_cprofile()
# Output reveals: find_primes_below dominates — called 4 times, most cumtime
# The inner loop `for j in range(i*i, n, i): sieve[j] = False` is the actual hotspot
# ── Memory profiling with tracemalloc — find allocation hotspots ──
import tracemalloc
def memory_inefficient(n: int) -> list[int]:
"""Builds many intermediate lists — each comprehension allocates a new list."""
result = []
for i in range(n):
squares = [x ** 2 for x in range(i)] # allocates a new list EACH iteration
if squares:
result.append(squares[-1])
return result
def memory_efficient(n: int) -> list[int]:
"""Avoids intermediate lists — computes directly, O(1) extra memory per iteration."""
return [(i - 1) ** 2 for i in range(1, n)] # one list, no intermediates
def profile_memory(func, *args):
"""Snapshot tracemalloc before/after, show top allocation sites."""
tracemalloc.start()
snapshot_before = tracemalloc.take_snapshot()
func(*args)
snapshot_after = tracemalloc.take_snapshot()
stats = snapshot_after.compare_to(snapshot_before, "lineno")
print(f"\\nMemory allocation diff for {func.__name__}:")
for stat in stats[:5]: # top 5 allocation sites
print(f" {stat}")
profile_memory(memory_inefficient, 1000)
profile_memory(memory_efficient, 1000)
# memory_inefficient shows many allocations from the inner comprehension line
# memory_efficient shows a single allocation from the outer comprehension
# ── numpy vectorization: the single biggest performance win in numeric Python ──
import numpy as np
import time
N = 10_000_000
# ANTI-PATTERN: element-wise Python loop over numpy array — defeats vectorization
def slow_square(arr: np.ndarray) -> np.ndarray:
result = np.empty_like(arr)
for i in range(len(arr)):
result[i] = arr[i] ** 2 # Python loop, per-element dispatch
return result
# CORRECT: vectorized operation — single C call over contiguous memory
def fast_square(arr: np.ndarray) -> np.ndarray:
return arr ** 2 # numpy dispatches to SIMD-optimized C, no Python loop
arr = np.arange(N, dtype=np.float64)
t0 = time.perf_counter()
_ = fast_square(arr)
vectorized = time.perf_counter() - t0
print(f"numpy vectorized: {vectorized:.4f}s ({N:,} elements, SIMD C)")
# Don't even try the slow version on 10M elements — it would take minutes
# On 100k elements for comparison:
small = np.arange(100_000, dtype=np.float64)
t0 = time.perf_counter()
_ = slow_square(small)
python_loop = time.perf_counter() - t0
t0 = time.perf_counter()
_ = fast_square(small)
numpy_fast = time.perf_counter() - t0
print(f"Python loop (100k): {python_loop:.4f}s vs numpy: {numpy_fast:.4f}s → {python_loop/numpy_fast:.0f}x faster")
The GIL, dis, and Why Python Loops Are Slow
import dis
def add_one(x):
return x + 1
dis.dis(add_one)
# 2 0 RESUME 0
# 2 LOAD_FAST 0 (x)
# 4 LOAD_CONST 1 (1)
# 6 BINARY_OP 0 (+)
# 10 RETURN_VALUE
Every Python-level operation — even x + 1 — compiles to multiple bytecode instructions, each dispatched through the interpreter's evaluation loop, each involving type checks and dynamic dispatch (__add__ lookup, etc.) that a compiled language resolves once at compile time. This is the fundamental reason a pure-Python for loop summing a million numbers is orders of magnitude slower than the equivalent C loop: not one slow operation, but millions of small dynamic-dispatch overheads compounding.
import time
data = list(range(10_000_000))
start = time.perf_counter()
total = 0
for x in data:
total += x
print(f"pure loop: {time.perf_counter() - start:.4f}s")
start = time.perf_counter()
total = sum(data) # sum() runs its loop in C, not in the bytecode interpreter
print(f"builtin sum(): {time.perf_counter() - start:.4f}s") # typically 5-10x faster
Best practice: prefer built-ins (sum, min, max, sorted, any, all) and comprehensions over hand-written Python loops for anything performance-sensitive — they execute their iteration in C, sidestepping per-iteration bytecode dispatch overhead entirely, often for a 5-10x speedup with no algorithmic change at all.
numpy: Vectorization Over Loops
import numpy as np
import time
size = 10_000_000
python_list = list(range(size))
numpy_array = np.arange(size)
start = time.perf_counter()
squared_list = [x * x for x in python_list]
print(f"list comprehension: {time.perf_counter() - start:.4f}s")
start = time.perf_counter()
squared_array = numpy_array ** 2
print(f"numpy vectorized: {time.perf_counter() - start:.4f}s") # typically 20-50x faster
numpy arrays are backed by contiguous, fixed-type C memory blocks, and operations like **, +, or np.sum() dispatch to compiled C (or SIMD-vectorized) loops operating on that raw memory directly — no per-element Python object boxing, no per-element type dispatch. Best practice: any numerical workload processing more than a few thousand elements — matrix math, signal processing, statistics, image data — belongs in numpy (or pandas, built on it), not a Python list with manual loops; this single change routinely accounts for the largest performance win available in data-heavy code.
prices = np.array([19.99, 5.50, 100.00, 0.99])
quantities = np.array([2, 10, 1, 50])
# WRONG mindset (works, but throws away numpy's advantage):
total = 0
for i in range(len(prices)):
total += prices[i] * quantities[i]
# RIGHT — vectorized, no Python-level loop at all:
total = (prices * quantities).sum()
Cython and C Extensions: When Pure Python Isn't Enough
# fib.pyx — Cython source, a superset of Python with optional static typing
def fib_cython(int n):
cdef int a = 0, b = 1, i
for i in range(n):
a, b = b, a + b
return a
[build-system]
requires = ["setuptools", "Cython"]
build-backend = "setuptools.build_meta"
cythonize -i fib.pyx # compiles fib.pyx into a native .so/.pyd extension module
Cython compiles Python-like syntax (with optional C-level type annotations via cdef) directly to C, then to a native shared library importable exactly like a normal Python module — the cdef int declarations let the generated C skip Python's dynamic type dispatch entirely for that variable, closing most of the gap to hand-written C. When to reach for it: after profiling identifies a specific, narrow hot loop that dominates runtime and can't be vectorized with numpy — Cython is a scalpel for that one function, not a wholesale rewrite strategy. Numba (@numba.jit) is a lighter-weight alternative for numerical code: it JIT-compiles ordinary Python/numpy functions at first call, no separate build step or .pyx file needed, at the cost of being more limited in what Python features it supports.
Memory Views: Avoiding Copies
data = bytearray(b"hello world" * 1000)
# WRONG for large data — slicing a bytearray/bytes COPIES the sliced portion
chunk = data[0:100] # allocates a brand new 100-byte object
# RIGHT — memoryview exposes a window into the SAME underlying buffer, zero-copy
view = memoryview(data)
chunk_view = view[0:100] # no copy — just a new view object referencing the same memory
chunk_view[0] = ord("H") # mutates the ORIGINAL bytearray through the view
print(data[:5]) # bytearray(b'Hello') — the underlying buffer changed
A memoryview implements Python's buffer protocol, letting code operate on a slice, reshape, or cast a large binary buffer (a bytearray, a numpy array, an array.array) without copying the underlying bytes — critical when processing large files or network buffers where naive slicing (data[a:b]) would otherwise allocate a new copy for every slice operation. Best practice: reach for memoryview when repeatedly slicing large bytes/bytearray objects in a hot path (binary protocol parsers, file-chunk processing) — the memory and time savings scale directly with how large the buffer and how frequent the slicing is.
def parse_header_wrong(packet: bytes):
magic = packet[0:4] # copies 4 bytes into a new bytes object
length = packet[4:8] # copies another 4 bytes
payload = packet[8:] # copies the ENTIRE remaining payload — the expensive one
return magic, length, payload
def parse_header_fast(packet: bytes):
view = memoryview(packet)
magic = view[0:4] # zero-copy view
length = view[4:8] # zero-copy view
payload = view[8:] # zero-copy view, regardless of payload size
return magic, length, payload
__slots__: Trading Flexibility for Memory
import sys
class PointDict:
def __init__(self, x, y):
self.x = x
self.y = y
class PointSlots:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
p1 = PointDict(1, 2)
p2 = PointSlots(1, 2)
print(sys.getsizeof(p1.__dict__)) # 296 bytes or more — every instance carries a full dict
print(sys.getsizeof(p2)) # dramatically smaller — no __dict__ at all
Without __slots__, every instance carries its own __dict__ for attribute storage — flexible (attributes can be added dynamically at any time) but memory-heavy, since a dict's hash table overhead dwarfs the space needed for just two floats. __slots__ = ("x", "y") tells Python to allocate fixed, dict-free storage for exactly those named attributes, cutting per-instance memory dramatically (commonly 40-50% for simple attribute-heavy classes) — the difference compounds fast when instantiating millions of objects (parsed rows, graph nodes, simulation particles).
class PointSlots:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
p = PointSlots(1, 2)
# p.z = 3 # AttributeError: 'PointSlots' object has no attribute 'z' — no __dict__ to fall back to
The trade-off: __slots__ classes cannot have arbitrary attributes added after the fact (no __dict__ exists to hold them, unless __dict__ is explicitly included in __slots__, which defeats much of the memory benefit), and multiple inheritance between two classes that each define non-empty, non-identical __slots__ raises TypeError: multiple bases have instance lay-out conflict. Best practice: use __slots__ for classes instantiated in bulk (thousands to millions of instances) with a fixed, known attribute set — data records, tree/graph nodes, particle-simulation entities — and skip it for classes where dynamic attribute flexibility matters more than the memory savings.
functools.lru_cache: Trading Memory for Speed
from functools import lru_cache
import time
@lru_cache(maxsize=None)
def expensive_lookup(user_id):
time.sleep(0.1) # simulates a slow database call
return f"user-{user_id}"
start = time.perf_counter()
expensive_lookup(42)
print(f"first call: {time.perf_counter() - start:.3f}s") # ~0.100s
start = time.perf_counter()
expensive_lookup(42) # cache hit — same arguments
print(f"second call: {time.perf_counter() - start:.3f}s") # ~0.000s
lru_cache memoizes a pure function's return value keyed by its arguments — a one-line decorator that converts a repeatedly-called, deterministic, side-effect-free function into an O(1) lookup after the first call. The gotcha: maxsize=None means the cache grows unboundedly, which is a memory leak in long-running processes (a web server handling millions of distinct user IDs will eventually cache all of them) — set an explicit maxsize (e.g., maxsize=1024) for anything long-running and unbounded in the input space, letting Python's LRU eviction cap memory growth.
Common Performance Pitfalls
# WRONG — string concatenation in a loop is O(n²): each += allocates
# an entirely new string, since str is IMMUTABLE in Python
result = ""
for word in ["a very", "long", "sequence", "of", "many", "words"] * 10000:
result += word + " "
# RIGHT — join() allocates the final string ONCE, O(n) total
words = ["a very", "long", "sequence", "of", "many", "words"] * 10000
result = " ".join(words)
Every result += word on a Python str creates a brand-new string object and copies the entire existing content into it (strings are immutable), turning what looks like a simple loop into quadratic behavior as result grows — for large inputs, this is a common, easy-to-miss source of surprisingly slow "obviously simple" code. "".join(list_of_strings) computes the total needed length once and allocates a single buffer, making it linear.
# WRONG — repeated membership testing against a list is O(n) PER CHECK
blocked_ids = [101, 204, 305, 512, 630] # imagine thousands of entries
for user_id in incoming_requests:
if user_id in blocked_ids: # linear scan every single time
reject(user_id)
# RIGHT — a set gives O(1) average-case membership testing
blocked_ids = {101, 204, 305, 512, 630}
for user_id in incoming_requests:
if user_id in blocked_ids: # O(1) hash lookup
reject(user_id)
A list's in operator is a linear scan; a set's (or dict's) in operator is a hash lookup, averaging O(1) regardless of collection size. Best practice: any collection used primarily for membership testing (x in collection), not order or duplicates, should be a set, not a list — a one-line type change with no other code changes needed, and a massive asymptotic improvement as the collection grows.
💡 Tips & Tricks
- Performance:
python -X importtime myscript.pyreports how long each import took at startup — useful for diagnosing slow CLI tool startup caused by an unexpectedly heavy transitive import (importing all ofpandasjust to use one small helper function, for example). - Debug:
timeit(python -m timeit -s "setup code" "statement to time") runs a snippet many times and reports the best/average time, automatically handling GC and warm-up noise far more reliably than manually wrappingtime.perf_counter()around a single run. - Idiom:
sys.intern()can deduplicate memory for many repeated identical strings (e.g., column names re-parsed from millions of CSV rows) — Python already auto-interns short identifier-like string literals, but explicit interning helps for strings built dynamically at runtime that happen to repeat frequently. - Performance: generator expressions (
sum(x*x for x in data)) avoid materializing an intermediate list entirely, unlike the equivalent list comprehension (sum([x*x for x in data])) — for a one-pass consumption likesum()/any()/all(), the generator form uses O(1) memory instead of O(n). - Debug:
tracemalloc(standard library) snapshots memory allocations and can diff two snapshots to show exactly which lines of code are responsible for growth between them — far more precise than watching overall process RSS climb and guessing at the cause.
⚠️ Edge Cases & Gotchas
cProfile's per-call overhead can itself distort results for functions with extremely high call counts and tiny bodies — a function called a million times with almost no work inside it can appear disproportionately expensive purely due to profiling instrumentation overhead, not real cost; cross-check suspiciously "hot" tiny functions withtimeiton a de-instrumented run.lru_cacheon a method (not a plain function) keeps the cache alive as long as the class holds a reference to the cached wrapper, which can keepself(and everything it references) alive far longer than expected — caching instance methods can silently create memory leaks by preventing garbage collection of otherwise-dead objects; prefer caching at the module level or explicitly scoping cache lifetime to the instance.__slots__inherited from a base class without__slots__onobjectitself provides no memory savings — if any class in the MRO omits__slots__(defaulting to a__dict__), instances of subclasses still get a__dict__alongside the slots, silently defeating the entire optimization while making the code less flexible for no benefit.numpyoperations on arrays of Python objects (dtype=object) get none of the vectorization speedup — creating anumpyarray from mixed-type or non-numeric Python objects falls back to storing pointers to ordinary Python objects, meaningarr * 2still dispatches through slow per-element Python-level multiplication; the speedup depends entirely on a genuine, uniform numericdtype.- Copying a large
numpyarray via slicing behaves the OPPOSITE of Python lists — basic slicing returns a VIEW, not a copy:sub = arr[10:20]shares memory witharr, so mutatingsubmutatesarrtoo — the inverse gotcha of Python's ownlistslicing (which always copies), easy to get backwards when moving between the two.
🧠 Spot the Bug
A function processes a large log file and is unexpectedly slow and memory-hungry in production despite looking like idiomatic Python. Find the bug.
def count_error_lines(filename):
with open(filename) as f:
lines = f.readlines()
error_lines = [line for line in lines if "ERROR" in line]
return len(error_lines)
Answer
f.readlines() reads the entire file into memory at once as a list of every line, before any filtering happens — for a multi-gigabyte log file, this allocates gigabytes of memory just to hold lines that will mostly be discarded a moment later, and the list comprehension then builds a second full list (error_lines) alongside it, doubling peak memory further. None of this is necessary: the function only needs a count, never the actual line contents held simultaneously.
The fix iterates the file lazily, one line at a time, and uses a generator expression with sum() instead of materializing any intermediate list:
def count_error_lines(filename):
with open(filename) as f:
return sum(1 for line in f if "ERROR" in line)
Iterating a file object directly (for line in f) reads one line at a time from disk via a small internal buffer, never holding the whole file in memory — combined with a generator expression (no intermediate list bracket), peak memory becomes O(1) relative to file size instead of O(n).
The lesson: readlines() (and any function that eagerly builds a full list from a large or unbounded source) trades memory for a superficially simpler-looking loop — for anything that can be processed one item at a time, iterate lazily (the file object itself, csv.reader, generator expressions) rather than materializing the entire dataset in memory first.
Key Takeaways
- Always profile before optimizing —
cProfilefor function-level hotspots,line_profilerfor line-level detail — since intuition about bottlenecks is frequently wrong and optimizing the wrong code wastes effort. - Prefer built-ins (
sum,sorted, comprehensions,join) over hand-written Python loops for hot paths — they execute their iteration in C, sidestepping per-iteration bytecode dispatch overhead. numpyvectorization is the single biggest lever for numerical workloads, routinely delivering 20-50x speedups by operating on contiguous C memory instead of boxed Python objects; Cython/Numba are the scalpel for narrow, non-vectorizable hot loops.memoryviewavoids copying large binary buffers on every slice operation;__slots__trades dynamic-attribute flexibility for significant per-instance memory savings at scale — both matter most when multiplied across large amounts of data or many instances.- Classic pitfalls —
strconcatenation in a loop (O(n²)),listmembership testing instead ofset(O(n) vs O(1)), and eagerly materializing large collections (readlines()) instead of iterating lazily — account for a large fraction of real-world "why is this simple code so slow" bugs. lru_cachetrades memory for speed on pure, deterministic functions — always set amaxsizein long-running processes to avoid unbounded cache growth becoming a memory leak.