Part 5 · Advanced Python · Module 39

Performance & Profiling

Fast Python is mostly the result of a disciplined process, not clever tricks: measure, find the real bottleneck, fix the algorithm, and only then reach for micro-optimizations, caching, vectorization, or native code. This module teaches that process and the tools behind it, from timeit and cProfile to 3.15's sampling profiler, plus what the 3.14 interpreter itself does to make your code faster.

⏱ ~2.5 hoursPrerequisites: M19 Complexity, M38 asyncio

Measure first

Donald Knuth's line is usually quoted half-way. The full version: "We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%." The skill is finding that 3%. Programmers are famously bad at guessing where time goes; the slow part is rarely the line that looks slow.

  1. Define "fast enough": a target with a number ("the report must build in under 2 s for 100k rows"). Without one, optimization never ends.
  2. Make it correct and tested first. Tests let you optimize fearlessly; every speedup must keep them green.
  3. Benchmark the realistic workload, so you know the baseline.
  4. Profile to find where the time actually goes.
  5. Fix the biggest cost, preferably with a better algorithm or data structure.
  6. Re-measure, and stop when you hit the target.

Timing code correctly

Choose the right clock:

ClockMeasuresUse for
time.perf_counter()Highest-resolution monotonic wall timeBenchmarks and elapsed time
time.perf_counter_ns()Same, as integer nanoseconds (no float rounding)Very short intervals
time.process_time()CPU time of this process (excludes sleep/I/O waits)"Is it CPU or waiting?"
time.monotonic()Monotonic wall time, coarserTimeouts, rate limiting
time.time()Calendar time; can jump (NTP adjustments)Timestamps, never for measuring durations

Single measurements are noisy: other processes, CPU frequency scaling, caches, and the garbage collector all interfere. timeit handles the basics: it runs the statement many times per measurement (number), repeats the measurement (repeat), and disables the GC during timing. Report the minimum (or median) of the repeats: the minimum is the run with the least interference, the best estimate of the code's intrinsic cost.

pixi run python -m timeit -s "data = list(range(1000))" "sum(data)"
# 50000 loops, best of 5: 4.1 usec per loop

Profiling with cProfile and pstats

A profiler tells you where time goes across a whole program. cProfile is a deterministic (tracing) profiler: it records every function call and return, so call counts are exact. The price is overhead on every call, which inflates the cost of small, frequently called functions. Use it to find hotspots, not to measure absolute speed.

Reading the output

ColumnMeaning
ncallsNumber of calls. 12/1 means 12 total calls, 1 primitive (non-recursive) call: recursion.
tottimeTime spent in this function's own code, excluding functions it called. High tottime = the work is here.
percall (first)tottime / ncalls
cumtimeTime from entry to exit, including callees. High cumtime but low tottime = it's a coordinator; look at what it calls.
percall (second)cumtime / primitive calls
filename:lineno(function)Where the function is defined; {built-in method ...} entries are C functions.

Sort by tottime to find where the CPU actually burns; sort by cumtime to see which high-level operations are expensive. stats.print_callers("is_prime") shows who calls a function, and print_callees what it calls. In this example most time is in is_prime and its generator expression, and the list-membership test also shows up: two different fixes (memoize or sieve; use a set).

pixi run python -m cProfile -s cumtime app.py | head -25     # quick look
pixi run python -m cProfile -o app.prof app.py               # save for later analysis
pixi run python -m pstats app.prof                           # interactive browser: sort tottime, stats 10
pixi add snakeviz && pixi run snakeviz app.prof              # icicle/sunburst view in the browser

Preview: the profiling package in 3.15

pixi exec --spec "python=3.15" python -m profiling.sampling run app.py                     # summary table
pixi exec --spec "python=3.15" python -m profiling.sampling run --flamegraph -o flame.html app.py
pixi exec --spec "python=3.15" python -m profiling.sampling run --live app.py              # top-like live TUI
pixi exec --spec "python=3.15" python -m profiling.sampling attach --live 12345             # attach to a running PID
pixi exec --spec "python=3.15" python -m profiling.sampling run --mode cpu -r 10khz app.py  # CPU time only, 10 kHz
pixi exec --spec "python=3.15" python -m profiling.tracing -s tottime app.py               # cProfile under its new name

A flame graph draws every sampled stack as a tower of boxes: the x-axis is the share of samples (not time order), the y-axis is call depth, and wide boxes are where time goes. You read it top-down for "what is the CPU doing" and bottom-up for "which entry point is expensive". The --mode option chooses what a sample counts: wall-clock time (including waiting), CPU time, time holding the GIL, or time handling exceptions.

Tracing (cProfile / profiling.tracing)Sampling (profiling.sampling)
Call countsExactNone (only sample counts)
OverheadHigh for many small callsLow and roughly constant
Accuracy for tiny functionsDistorted by overheadStatistically fair
Attach to a running processNoYes
Best forDevelopment, "how many times is this called?"Production, realistic loads, long runs

Thinking at the line level

Function-level profiles tell you which function is hot. Inside a long function, you need to know which lines are. Options:

When you look at a hot line, ask: how many times does it run (move work out of loops), how much does each run cost (an O(n) operation like x in list inside an O(n) loop is O(n²)), and does it allocate (building temporary lists or strings)? dis.dis(func) shows the bytecode if you're curious what a line really does.

Memory: sys.getsizeof and tracemalloc

Memory is performance too: allocation costs time, big working sets fall out of CPU caches, and running out of RAM means swapping or crashing. sys.getsizeof(obj) returns the size of the object itself in bytes, shallowly: for a list, the list's header and pointer array, but not the objects it points to.

tracemalloc tracks every allocation made by Python, with the line that made it. Take snapshots before and after an operation and compare them to find what's eating memory. Its numbers include the full cost of the objects created, which is what you actually care about.

Algorithmic wins vs micro-optimizations

The biggest speedups come from doing less work: a better algorithm or data structure changes the growth rate, and no amount of micro-tuning competes with that at scale. Replacing an O(n²) inner search with an O(1) set lookup turns a 10-minute job into a 50 ms one. Drag the slider to feel the difference:

Once the algorithm is right, a set of well-known micro-optimizations can still give 1.2–3× in a hot loop. They work because they reduce the number of bytecode instructions, dictionary lookups, or Python-level calls. Use them only in code the profiler flagged; elsewhere, readability wins.

TechniqueInstead ofWhy it's faster
Set/dict membershipx in some_listHash lookup O(1) vs linear scan O(n)
"".join(parts)s += piece in a loopOne allocation vs repeated copying (CPython sometimes optimizes +=, but don't rely on it)
Comprehensionsfor loop with .appendSpecialized bytecode, no repeated append attribute lookup and call
Built-ins (sum, min, max, sorted, any, map)Hand-written loopsThe loop runs in C
Local variablesGlobals or module attributes in a hot loopLocals are array slots; globals and builtins are dict lookups (the gap is smaller with 3.11+ specialization)
Hoist attribute lookupsself.items.append(x) repeated millions of timesBind append = self.items.append once
Move invariant work out of loopsre.compile, len(), conversions inside the loopDo it once
collections.Counter/defaultdictManual if k in d countingC-implemented counting
Generators / itertoolsBuilding big intermediate listsLess memory, better cache use

Caching

The fastest computation is the one you don't repeat. Memoization stores results keyed by arguments. The standard library provides it as decorators:

Vectorization: a NumPy teaser

For numeric work, the biggest lever is moving the loop out of Python entirely. NumPy stores numbers in compact typed arrays and runs operations over whole arrays in optimized C (often SIMD) loops. The style is called vectorization: express the computation on arrays, not elements. Part 7 (M48) covers it in depth.

The 3.14 interpreter: what CPython does for you

Recent CPython releases made unchanged Python code substantially faster. Knowing how helps you write code the interpreter can optimize, and helps you interpret benchmarks.

PYTHON_JIT=1 pixi run python -c "import sys; print(sys._jit.is_enabled())"   # True only on a JIT-capable build
pixi run python -c "import sysconfig; print(sysconfig.get_config_var('CONFIG_ARGS'))"   # how was it built?

Native extensions and compilers: an overview

When profiling shows a hot, CPU-bound core that can't be vectorized, you can move it to compiled code. Each option trades build complexity for speed:

ToolWhat you writeTypical gain on hot loopsTrade-offsPixi
NumPy / vectorizationArray expressions10–100×Only for array-shaped problemspixi add numpy
NumbaPython + @njit decorator10–100× on numeric loopsSubset of Python; JIT warm-up; numeric focuspixi add numba
mypycYour existing type-annotated Python1.5–5×Needs precise types; some dynamic features unsupported; compiles whole modulespixi add mypy (ships mypyc)
CythonPython with C type declarations (.pyx or pure-Python mode)10–100× with typesBuild step; C knowledge helps; great for wrapping C librariespixi add cython
Rust + PyO3 (maturin)RustC-like speed, safe parallelismLearn Rust; build tooling; FFI boundary costspixi add maturin rust
C API / cffi / ctypesCC speedManual memory and refcounting (C API); most effortpixi add cffi

Two rules apply to all of them. First, minimize boundary crossings: calling a compiled function a million times with one number each wastes the gain on call overhead; pass whole arrays or batches. Second, a compiled extension is a distribution burden: wheels per platform, per Python version, and (for free-threading) per ABI (M37). That cost is why "pure Python plus NumPy" is the default until proven insufficient.

Startup time and 3.15 lazy imports

For command-line tools, the dominant cost is often startup: importing modules you may never use on this run. A CLI with ten subcommands that imports pandas at the top pays ~0.5 s even for --help. Measure import cost with -X importtime, which prints a tree of import times in microseconds (self and cumulative):

pixi run python -X importtime -c "import json" 2>&1 | tail -3
# import time:       213 |       3549 |   json.decoder
# import time:       177 |        177 |   json.encoder
# import time:       173 |       3898 | json

In 3.14 the remedies are manual: import inside the function that needs the module, or use importlib.util.LazyLoader. Both work, but scatter imports or require boilerplate.

Practice

Write best_of(func, repeat=5, number=10) that calls func() number times per measurement, takes repeat measurements with time.perf_counter, and returns the best per-call time in seconds (minimum measurement divided by number). Raise ValueError if repeat or number is less than 1.

Hint

For each repeat: start = time.perf_counter(), a loop of number calls, then append time.perf_counter() - start.

Show solution
import time

def best_of(func, repeat=5, number=10):
    if repeat < 1 or number < 1:
        raise ValueError("repeat and number must be >= 1")
    timings = []
    for _ in range(repeat):
        start = time.perf_counter()
        for _ in range(number):
            func()
        timings.append(time.perf_counter() - start)
    return min(timings) / number

This function returns the items that appear more than once, in order of their second occurrence. It's correct but quadratic. Rewrite it to run in linear time with the same output. The test runs it on 40,000 items with a time limit.

Hint

Keep two sets: seen (items met at least once) and reported (items already added to the result).

Show solution
def duplicates(items):
    seen, reported, result = set(), set(), []
    for x in items:
        if x in seen and x not in reported:
            reported.add(x)
            result.append(x)
        seen.add(x)
    return result

Write a decorator memoize that caches results by positional arguments and exposes wrapper.hits, wrapper.misses, and wrapper.clear() (which empties the cache and resets both counters). Use functools.wraps. Calls with unhashable arguments must still work (just don't cache them, and don't count them as hits or misses).

Hint

Store the counters as attributes on the wrapper function. Detect unhashable arguments with try: hash(args) except TypeError:.

Show solution
import functools

def memoize(func):
    cache = {}

    @functools.wraps(func)
    def wrapper(*args):
        try:
            hash(args)
        except TypeError:
            return func(*args)
        if args in cache:
            wrapper.hits += 1
            return cache[args]
        wrapper.misses += 1
        result = cache[args] = func(*args)
        return result

    def clear():
        cache.clear()
        wrapper.hits = wrapper.misses = 0

    wrapper.hits = wrapper.misses = 0
    wrapper.clear = clear
    return wrapper

sys.getsizeof is shallow. Write deep_sizeof(obj) that adds up sys.getsizeof for obj and everything reachable through lists, tuples, sets, frozensets, and dicts (both keys and values), counting each object once (by id) so shared references and cycles are handled.

Hint

Use an explicit stack and a seen set of ids. For a dict, push both d.keys() and d.values() items.

Show solution
import sys

def deep_sizeof(obj):
    seen, stack, total = set(), [obj], 0
    while stack:
        current = stack.pop()
        if id(current) in seen:
            continue
        seen.add(id(current))
        total += sys.getsizeof(current)
        if isinstance(current, dict):
            stack.extend(current.keys())
            stack.extend(current.values())
        elif isinstance(current, (list, tuple, set, frozenset)):
            stack.extend(current)
    return total

Write hottest(func) that runs func() under cProfile and returns the name (just the function name, like "slow_part") of the Python function with the largest tottime, ignoring built-ins (entries whose filename is "~") and func itself. Use pstats.Stats(profiler).stats, a dict mapping (filename, lineno, funcname) to (cc, nc, tottime, cumtime, callers).

Hint

Filter stats.stats.items() with filename != "~" and funcname != func.__name__, then take max by value[2] (tottime).

Show solution
import cProfile
import pstats

def hottest(func):
    profiler = cProfile.Profile()
    profiler.enable()
    try:
        func()
    finally:
        profiler.disable()
    stats = pstats.Stats(profiler).stats
    candidates = [
        (values[2], name)
        for (filename, _, name), values in stats.items()
        if filename != "~" and name != func.__name__
    ]
    return max(candidates)[1]

Mini-project: make a slow script 10× faster

Mini-projectspeedrun: profile-driven optimization

Below is a deliberately slow log-report script. Your job: make it at least 10× faster on a 200,000-line input while producing byte-identical output, and document every step with measurements.

pixi init speedrun
cd speedrun
pixi add python=3.14 pytest
pixi run python gen_logs.py 200000 > access.log      # write a generator with a fixed random seed
pixi run python -m cProfile -s tottime slow_report.py access.log | head -20
pixi run python -m pytest -q                            # golden-output test: new == old

The slow script

import re
import sys

def parse(line):
    pattern = re.compile(r'(\S+) \S+ \S+ \[(.*?)\] "(\S+) (\S+)[^"]*" (\d{3}) (\d+)')   # compiled per line
    m = pattern.match(line)
    return {"ip": m[1], "time": m[2], "method": m[3], "path": m[4], "status": int(m[5]), "size": int(m[6])}

def report(lines):
    records = []
    for line in lines:
        records = records + [parse(line)]                 # copies the list every time: O(n^2)
    ips = []
    for r in records:
        if r["ip"] not in ips:                            # list membership: O(n^2)
            ips.append(r["ip"])
    out = ""
    for ip in sorted(ips):
        count = len([r for r in records if r["ip"] == ip])            # rescans all records per IP
        total = sum([r["size"] for r in records if r["ip"] == ip])
        errors = len([r for r in records if r["ip"] == ip and r["status"] >= 500])
        out += f"{ip},{count},{total},{errors}\n"         # string concatenation in a loop
    return out

if __name__ == "__main__":
    with open(sys.argv[1]) as f:
        sys.stdout.write(report(f.readlines()))

Steps

  1. Write a golden test that saves the slow script's output for a small fixed input.
  2. Benchmark the baseline (median of 3 runs with perf_counter or pyperf).
  3. Profile; fix the single biggest cost; re-run the test and benchmark; record the result in a table.
  4. Repeat until 10× (expect the algorithmic fixes to give most of it), then try one micro-optimization and measure whether it was worth it.
  5. Check memory with tracemalloc: can you stream the file instead of readlines()?

Acceptance criteria

  • Golden-output test passes for both versions
  • Measured speedup ≥ 10× on 200,000 lines (baseline and final timings recorded)
  • A NOTES.md table lists each change with its before/after time and the profiler evidence
  • Peak memory reported before and after (tracemalloc)
  • The fast version is still readable (no unmeasured micro-optimizations)

Stretch goals

  • Profile with 3.15's sampling profiler and compare its picture with cProfile's: pixi exec --spec "python=3.15" python -m profiling.sampling run --flamegraph -o flame.html slow_report.py access.log.
  • Process the file in parallel chunks (M37) and explain why the gain is smaller than the core count.
  • Try the build on a JIT-enabled interpreter with PYTHON_JIT=1 and report the difference.

Quiz

Recap

Going further