◈ python mapVol 2 · Ch 29/30
Volume 2 Python, from the metal up · chapter 29

29Performance & profiling — measure before you cut

In Chapter 28 we made the program correct — the tests went green, the numbers came out right. This chapter makes it fast, and I want to go slow, because there's a trap here that swallows almost everyone. Your intuition about where a program spends its time is not a little off. It is reliably wrong, and it's wrong for a reason you can name. The source text tells you the truth about what your code does. It says nothing about what any of it costs. a + b can be one machine ADD or a heap allocation. x in seen can be one probe or a million compares, and the letters look identical. So here's the plan. First we prove your gut is a liar. Then we install real instruments between your mental model and the machine: timeit to time one snippet honestly, and cProfile to watch the whole program run and hand back a ranked list of suspects. And we learn to read what they say. The whole way through, we keep asking the one thing that actually matters — where is the time truly going, and how would you ever know without measuring? By the end you'll carry a discipline you can run on any slow program. Measure first. Fix only the one thing the measurement flagged. Then measure again. There's no magic in performance work — just the machine doing exactly what it does, once you stop guessing and actually look.

iolinked · chapter 29 — the checkpoints6 steps
$ sections covered in Performance & profiling — measure before you cut
01Measure first — your intuition is wrong
02timeit controls the noise
03A profiler attributes time to functions
04Algorithmic beats constant-factor
05Memory, allocation, and the cache
06Optimize the hot path, then remeasure

01Measure first — your intuition is wrong

Let's start with the mistake every one of us makes, because you have to feel it before the fix means anything. You closed Chapter 28 with a program that works. The numbers are right, the tests are green, and one problem remains: it is slow. So you do what everyone does. You read the code, your pulse rises at a particular line, and a voice says there — that triple-nested loop is the murderer. You spend an afternoon rewriting it into something clever. You run the program, and the stopwatch reads exactly what it read before. Not a little better. Identical. That loop was two percent of the runtime. The real cost was a config file being re-read and re-parsed on every iteration — a line so plain your eye slid right over it. You optimised the innocent and left the guilty untouched. And you paid for the privilege with new complexity and a fresh chance at a bug.

So what you actually need here is not a technique. It is a way to stop guessing. To earn that, you have to see why guessing fails so consistently, because it does not fail at random. It fails systematically, and a systematic error is one you can correct for. Here is the root cause, from the metal up. A CPU issues billions of instructions per second, and the cost of an operation is nowhere in its written form. Watch two lines that read as perfect equals:

looks_can_lie.pypython
# Two lines. They look equally cheap. They are not.
total = a + b                 # small ints: one C-level ADD, a few nanoseconds
found = key in seen        # seen is a list of 1,000,000: up to a million compares

Nothing in the second line looks expensive. There is no loop in the source. The loop is inside the in operator, hidden in C, invisible to your eye. This is the pattern behind every performance surprise: the real time-eaters are the operations with no syntactic weight. A helper called ten million times. An accidental O(n²) crouched behind x in some_list. A generator that quietly re-does its work each pass. An I/O wait where the core sits idle for milliseconds — an eternity — while a disk or a socket answers. None of these announce themselves in the text. And "thinking hard" about the text cannot find them, because thinking hard cannot see call counts or wait states.

There is a second reason reading fails, and it is subtler: cost is non-local. The line that looks expensive is usually not where the time is spent. The time is spent inside the functions that line calls, transitively, three and four levels down. total = summarize(data) is one short line. Say summarize calls normalize, which calls a square-root-heavy routine n² times. Then the cost lives at the bottom of a call chain you cannot see from the top. You could stare at summarize(data) for an hour and learn nothing.

Your hunch is not the hot spotWHAT YOU SUSPECTWHAT THE PROFILER MEASURESgut feelinner loop“FEELS slow”2% of runtimeload_config()71%parse() 14%render() 8%other 5%inner loop 2%what you'd fix ≠ what actually costs
Fig — Intuition guesses; the profiler measures — and they rarely point at the same line.

So we stop reasoning about the schematic and start measuring the running circuit. A profiler is an instrument, exactly like a multimeter clipped onto a live board: it does not argue about what should happen, it reports what does. This reframes the whole activity. The profile is ground truth. Your opinion about the slow part is a hypothesis — legitimate, but worthless until a measurement confirms it. And the shape of all performance work is one loop: measure → find the hot path → fix only the hot path → measure again. Everything else in this chapter is filling in that loop with real tools.

THE ONE IDEA TO CARRY FORWARD
You cannot see time in source code — only structure. Call counts, wait states, and the true cost buried three calls deep are invisible to reading. So the first move is never to edit; it is to measure. Treat every hunch as a hypothesis and the profiler as the experiment that settles it.

One last piece of vocabulary, because it splits every performance problem cleanly in two. Latency is the time for one operation, and throughput is operations per second. They are not reciprocals once work runs in parallel, so name which one you care about. More important is the split between wall-clock time and CPU time. Wall-clock time is what the user actually feels — a real clock on the wall, which includes I/O and every other program on the machine. CPU time is the cycles your code truly burned. When those two numbers agree, your program is CPU-bound: it is computing, and to go faster it must compute less. When wall-clock time towers over CPU time, your program is waiting — for a disk, a network, a lock — and no amount of cleverer arithmetic will help. The fix for each is completely different. Telling them apart is the first diagnosis you will make on any slow program.

SYNTAX · the stopwatch — time.perf_counter() around the thing you suspectthree clocks ship in the stdlib, and only one of them can see a millisecond on Windows
import time t0 = time.perf_counter() -- stamp. monotonic, ~100 ns resolution. result = do_the_thing() -- the ONLY thing between the two stamps dt = time.perf_counter() - t0 -- seconds, as a float time.perf_counter() -- the stopwatch. no date. only DIFFERENCES mean anything. time.time() -- the wall clock. answers "what time is it". NTP can move it. time.process_time() -- CPU burned by THIS process. sleeps and waits cost zero. time.perf_counter_ns() -- same clock, integer nanoseconds, no float rounding time.get_clock_info("time").resolution -- ask the machine. never assume. best = min(samples) -- N brackets, report the floor
perf_counter()Not a date. The origin is arbitrary, so only the difference between two stamps carries meaning.
monotonicIt never runs backward. A clock sync mid-measurement cannot hand you a negative duration.
time.time()The wall clock. It is the right tool for timestamps in a log, and the wrong tool for a stopwatch.
process_time()CPU charged to your process. A sleep, a socket read and a lock wait are all free here.
the bracketEverything between the stamps is measured. The print you forgot to move out is in your number.
resolutionThe smallest tick the clock can report. Below it, every answer is quantised to zero or one tick.
the minRun the bracket N times, keep the smallest. Interference only ever adds; it cannot give cycles back.
the warm-upCh 28 ran a warm-up map before its timed line. Same discipline: pay start-up costs outside the bracket.
you type
# ---------- stopwatch.py ----------
"""stopwatch.py -- the two clocks, and what each one is honest about."""
import time

def work():
    return sum(i * i for i in range(20_000))     # ~1 ms of real arithmetic

print("clock resolution on this machine")
for name in ("time", "perf_counter", "process_time"):
    ci = time.get_clock_info(name)
    print(f"  time.{name:<13} {ci.resolution:>10} s   {ci.implementation}")

print("\nfive timings with time.time()")
for _ in range(5):
    t0 = time.time();          work(); print(f"  {time.time() - t0:.6f} s")

print("\nfive timings with time.perf_counter()")
for _ in range(5):
    t0 = time.perf_counter();  work(); print(f"  {time.perf_counter() - t0:.6f} s")

print("\nwall clock vs CPU clock, while we sleep")
w0, c0 = time.perf_counter(), time.process_time()
time.sleep(0.5)
print(f"  perf_counter  {time.perf_counter() - w0:.4f} s   <- what the user feels")
print(f"  process_time  {time.process_time() - c0:.4f} s   <- what the CPU burned")


# ---------- the terminal ----------
$ python stopwatch.py
you see
$ python stopwatch.py
clock resolution on this machine
  time.time            0.015625 s   GetSystemTimeAsFileTime()
  time.perf_counter       1e-07 s   QueryPerformanceCounter()
  time.process_time       1e-07 s   GetProcessTimes()

five timings with time.time()
  0.000000 s
  0.000000 s
  0.000000 s
  0.000000 s
  0.006701 s

five timings with time.perf_counter()
  0.000881 s
  0.000901 s
  0.000922 s
  0.000941 s
  0.000942 s

wall clock vs CPU clock, while we sleep
  perf_counter  0.5008 s   <- what the user feels
  process_time  0.0000 s   <- what the CPU burned
where beginners trip
  • Look at the first block of five. time.time() reported 0.000000 four times for work that genuinely took 0.9 ms.
  • The fifth read 0.006701. Same work, same machine. It simply straddled a tick. That is quantisation, not variance.
  • 0.015625 s is 1/64 of a second, the classic Windows tick. On Linux the same call resolves to about a nanosecond, so run the resolution check on your box before trusting anyone's advice.
  • perf_counter has no absolute meaning. Print one on its own and you get a number about the machine's uptime.
  • The sleep row is the whole wall-versus-CPU split in two lines: 0.5008 s felt, 0.0000 s computed.
  • One bracket, one number, no repeat, is a sample, not a measurement. Loop it and take the minimum.
  • Put the t0 = stamp above the import and you have timed the import too. The bracket is exactly what it contains.

You know now to measure instead of guess. But your first attempt to measure — wrapping two snippets in a stopwatch — is about to betray you, because a single timing is almost pure noise. Next: how timeit subtracts the machine's chaos and hands you the one number that is really your code's. →

02timeit controls the noise

You have two ways to write the same function, and you want to know which is faster. Reasonable. So you reach for the obvious tool: wrap each in a clock and diff:

the_naive_way.pypython
import time
t0 = time.time(); build_a(); print(time.time() - t0)   # 0.0181
t0 = time.time(); build_b(); print(time.time() - t0)   # 0.0092  → B wins!

A wins. You run it again — B wins. Again — A by three times. The numbers are garbage, and you are one Enter away from making a permanent code decision on a coin flip. What you need is honest comparison: a way to tell which implementation is truly better that survives the fact that your machine is doing a thousand other things while it measures.

To fix this you have to see, from the metal up, why a single timing is meaningless. Your snippet does not run alone. The OS scheduler can preempt your process mid-measurement. Another program grabs the core, your clock keeps ticking, and you are charged for someone else's work. The CPU also changes its own clock frequency under you: turbo boost when it is cool, thermal throttling when it is hot. So the same instructions take different real time minute to minute. The first run has cold caches and untrained branch predictors. The second is warm and faster for reasons that have nothing to do with your code. And Python's own garbage collector can fire in the middle of your snippet and bill its pause to you. Layer on one more problem. A fast snippet may finish in nanoseconds, far below the resolution of a naive clock, so a single call measures the clock's own graininess, not your code.

Timings have a floor, not a centertime per run →the wall = mintrue cost, noise removedmean — dragged rightGC pause · context switch · throttleNoise can only push a sample slower — so read the floor, never the average.
Fig — Because interference only adds time, the minimum sample — not the mean — is the honest cost.

Now look at exactly what timeit does about each of these, because every design choice answers one of the problems above. First, it runs the snippet a chosen number of times in a tight compiled loop and divides. So nanosecond operations are multiplied up into a span the clock can actually resolve, and the per-call overhead is amortised away. Second, it uses time.perf_counter, the highest-resolution monotonic clock available. It never runs backward, and it is immune to the wall clock being nudged by NTP mid-measurement. Third — and this is the part people miss — it disables the garbage collector for the duration, so a gc pause cannot pollute the number. Fourth, it repeats the whole trial several times (repeat) and hands you a list, and you report the minimum, never the mean.

SYNTAX · timeit — the command you type and the function you callnumber amortises the clock, repeat fights the scheduler, and you report the minimum
# 1. THE TERMINAL FORM -- nothing to write, nothing to import python -m timeit "sum(range(100))" python -m timeit -s "SETUP" "STMT" -- setup runs once, untimed python -m timeit -n 200 -r 7 -s "SETUP" "STMT" -- pin loops and trials python -m timeit -s "SETUP" "line one" "line two" -- extra args = newlines # 2. THE CODE FORM -- when you want the numbers back as floats import timeit timeit.timeit(stmt, setup, number=100_000) -- ONE trial, TOTAL seconds timeit.repeat(stmt, setup, number=1000, repeat=7) -- a LIST of trial totals min(timeit.repeat(...)) / number -- seconds per operation t = timeit.Timer(stmt, setup) -- reuse one compiled snippet t.autorange() -- (loops, secs) it picked itself
number / -nHow many times the statement runs inside one trial. This is what lifts nanoseconds above the clock's grain.
repeat / -rHow many independent trials. Each one is a fresh chance for the scheduler to leave you alone.
setup / -sRuns once per trial and is not timed. Imports and input construction belong here, never in the statement.
the return valuetimeit() hands back one float: the total for all number runs. Divide it yourself for per-op.
min, not meanThe CLI already prints best of 5. In code nothing takes the minimum for you, so min() is your job.
the gctimeit disables the garbage collector for the timed span, so a collection cannot land inside your number.
auto-calibrationWith no -n, timeit grows the loop count until a trial takes roughly 0.2 s. Two runs can print different loop counts.
compilationThe statement string is compiled once, before timing starts. Compile cost is never in the result.
you type
# ---------- the classic duel: building one 38,890-character string ----------
$ python -m timeit -s "parts=[str(i) for i in range(10000)]" "s=''" "for p in parts: s += p"
$ python -m timeit -s "parts=[str(i) for i in range(10000)]" "s=''.join(parts)"

# ---------- pin the counts instead of letting timeit choose ----------
$ python -m timeit -n 200 -r 7 -s "parts=[str(i) for i in range(10000)]" "s=''.join(parts)"

# ---------- the constant-folding trap ----------
$ python -m timeit "2 ** 20"
$ python -m timeit -s "x = 2" "x ** 20"

# ---------- is += really quadratic? ask the machine, three sizes ----------
$ python -m timeit -s "parts=[str(i) for i in range(10000)]" "s=''" "for p in parts: s += p"
$ python -m timeit -s "parts=[str(i) for i in range(10000)]" "s=''" "for p in parts: keep = s; s += p"
you see
$ python -m timeit -s "parts=[str(i) for i in range(10000)]" "s=''" "for p in parts: s += p"
500 loops, best of 5: 831 usec per loop

$ python -m timeit -s "parts=[str(i) for i in range(10000)]" "s=''.join(parts)"
10000 loops, best of 5: 24.2 usec per loop

$ python -m timeit -n 200 -r 7 -s "parts=[str(i) for i in range(10000)]" "s=''.join(parts)"
200 loops, best of 7: 21.7 usec per loop

$ python -m timeit "2 ** 20"
50000000 loops, best of 5: 7.5 nsec per loop

$ python -m timeit -s "x = 2" "x ** 20"
5000000 loops, best of 5: 54.2 nsec per loop

# the three sizes, plain += :
n=10000  500 loops, best of 5: 818 usec per loop
n=20000  200 loops, best of 5: 1.31 msec per loop
n=40000  100 loops, best of 5: 2.97 msec per loop

# the three sizes, with `keep = s` added to the loop body:
n=10000  50 loops, best of 5: 4.64 msec per loop
n=20000  20 loops, best of 5: 18.9 msec per loop
n=40000   5 loops, best of 5: 78.6 msec per loop
where beginners trip
  • ''.join beat += by 34× for the identical 38,890-character result. That one is worth typing.
  • Now the honest correction to the folklore. The last two tables say s += p is not quadratic on CPython: 818 µs, 1.31 ms, 2.97 ms is roughly linear.
  • The reason is a real interpreter special case. When the target is a local holding the only reference, CPython grows the string's buffer in place instead of copying it.
  • Add one more name (keep = s) and the refcount check fails, the fast path is gone, and the same loop reads 4.64, 18.9, 78.6 ms. That is 4× per doubling: textbook n².
  • So the rule survives, with its real mechanism attached. Use join, because += is fast only while an optimisation you do not control keeps applying.
  • 2 ** 20 timed at 7.5 ns because the compiler folded it into the literal 1048576. You measured a constant load.
  • The same += command printed 831 µs in one batch and 818 µs in another. Absolute numbers are not portable; ratios measured back to back are.
  • timeit prints UserWarning: The test results are likely unreliable when the worst trial is 4× the best. Read it. The machine moved under you.
REPORT THE MIN, NOT THE MEAN — HERE'S WHY
Noise can only ever make a run slower. The scheduler can steal your cycles; it cannot hand you free ones. So the fastest run you observed is the one with the least interference subtracted — the closest you will get to your code's true cost. The mean measures how busy your machine was. The min measures your code. Read the wall.
honest_timing.pypython
import timeit

# setup runs ONCE per trial and is NOT timed — build the input here
setup = "data = list(range(10_000))"

loop  = # the thing we actually want to measure
loop  = "total = []\nfor x in data: total.append(x*x)"
comp  = "total = [x*x for x in data]"

best_loop = min(timeit.repeat(loop, setup, number=1000, repeat=5))
best_comp = min(timeit.repeat(comp, setup, number=1000, repeat=5))
print(best_loop, best_comp)   # comprehension wins, and it wins every run

Two details in that code are the whole craft. The setup / stmt split is not decoration. setup runs once per trial and is not timed, so you build your input there and measure only the operation. Fold the input construction into the timed statement and you are secretly timing list(range(...)) too — the same operation gives a different and dishonest verdict. And beware the compiler. timeit('2 ** 20') reports a time near zero because 2 ** 20 is a constant expression folded at compile time into the literal 1048576. You measured nothing. To force real work, the operand must be unknown to the compiler: pull x from setup and time x ** 20.

TYPE THIS · save it, then run ittimeit_duel.py — four micro-duels, run for real, and the one honest answer among them
"""timeit_duel.py -- four honest duels. Only ONE of them will ever matter."""
import timeit

REPEAT = 7


def duel(title, setup, a_stmt, b_stmt, number):
    """Interleave A and B trial by trial, then report each one's minimum."""
    ta = timeit.Timer(a_stmt, setup)
    tb = timeit.Timer(b_stmt, setup)
    best_a = best_b = float("inf")
    for _ in range(REPEAT):                       # A,B,A,B... so any drift
        best_a = min(best_a, ta.timeit(number))   # hits both candidates alike
        best_b = min(best_b, tb.timeit(number))
    a, b = best_a / number, best_b / number
    ratio = max(a, b) / min(a, b)
    print(title)
    print(f"   {a_stmt:<24} {a*1e6:10.3f} us")
    print(f"   {b_stmt:<24} {b*1e6:10.3f} us")
    if ratio < 1.05:                              # under 5% is not a result
        print(f"   -> dead heat ({ratio:.3f}x) -- same work, two spellings\n")
    else:
        print(f"   -> {a_stmt if a < b else b_stmt}  wins by {ratio:.2f}x\n")


duel("1. list-comp vs map        10,000 ints -> str",
     "d = list(range(10_000))",
     "[str(x) for x in d]", "list(map(str, d))", number=100)

duel("2. in-list vs in-set       100,000 items, needle absent",
     "big_list = list(range(100_000))\nbig_set = set(big_list)\nneedle = -1",
     "needle in big_list", "needle in big_set", number=100)

duel("3. f-string vs concat      two short strings",
     'a = "Blinding"; b = "Lights"',
     'f"{a} - {b}"', 'a + " - " + b', number=200_000)

duel("4. sorted(d) vs copy+sort  10,000 random floats",
     "import random; random.seed(29)\nd = [random.random() for _ in range(10_000)]",
     "sorted(d)", "t = d[:]; t.sort()", number=100)
$ python timeit_duel.py
1. list-comp vs map        10,000 ints -> str
   [str(x) for x in d]         484.143 us
   list(map(str, d))           595.555 us
   -> [str(x) for x in d]  wins by 1.23x

2. in-list vs in-set       100,000 items, needle absent
   needle in big_list          643.885 us
   needle in big_set             0.023 us
   -> needle in big_set  wins by 27995.14x

3. f-string vs concat      two short strings
   f"{a} - {b}"                  0.067 us
   a + " - " + b                 0.089 us
   -> f"{a} - {b}"  wins by 1.34x

4. sorted(d) vs copy+sort  10,000 random floats
   sorted(d)                  1077.487 us
   t = d[:]; t.sort()         1082.590 us
   -> dead heat (1.005x) -- same work, two spellings
Type it, run it, and read the four verdicts as one sentence: three of these duels do not matter. Duel 1 gives the comprehension a 1.23× edge, duel 3 gives the f-string 1.34×, and duel 4 has no winner at all. Duel 2 gives the set 27,995×. That is not a bigger version of the same kind of win. It is a different kind: duels 1 and 3 shaved the constant, duel 2 changed the curve from n to 1. Look at what duel 3 actually bought, in absolute terms. 0.089 µs became 0.067 µs, so you saved 22 nanoseconds per call. To recover one second of runtime you would need to run that line forty-five million times. Meanwhile a single word in duel 2 turned 644 µs into 23 nanoseconds for the same question. Now the part I want you to steal, which is the duel() function itself, not its results. My first version of this script timed A completely, then timed B completely, and it reported that sorted(d) was 3.5× faster than copy-then-sort. That result is impossible. CPython's sorted() is a copy followed by list.sort; the same code cannot beat itself by 3.5×. The bug was in my harness, not in Python. This machine drifts as a process runs, so whichever candidate is measured second is charged for the drift. Interleaving the trials, A,B,A,B, spreads that drift over both candidates, and the fake 3.5× collapses to the honest 1.005× you see above. That is why the 5% dead-heat rule is in the code. A difference smaller than your machine's own wander is not a result, it is a rounding error with an opinion. Two more things to try, both quick. First, run the script twice and compare. On my second run the same statement read 820 µs instead of 484 µs, while every ratio held to two digits, which tells you exactly which of the two numbers you are allowed to quote. Second, change duel 2's needle = -1 to needle = 0 so the list finds it on the first probe, and watch the 28,000× evaporate. The list was never slow. It was slow at this question, asked at this size. One honest caveat on duel 1: this is Python 3.12, where comprehensions are inlined into the enclosing frame, and on an older interpreter the ranking may flip. Measure on the version you actually ship.

timeit tells you which of two snippets is faster — but not which snippet to test. In a five-thousand-line program you have no idea where to point it. Next: an instrument that watches the whole program run and hands you a ranked list of suspects. →

03A profiler attributes time to functions

timeit is a scalpel, and a scalpel is useless until you know where to cut. In a real program of thousands of lines and hundreds of functions, the question is not "which of these two is faster." It is "which of these hundred is the problem." You need triage: something that watches the entire program run and returns a ranked list. Here are your functions, here is how many times each was called, here is how much time each ate. That instrument is a profiler, and CPython ships one built from a mechanism you already met in Chapter 25.

Recall the trace hook. CPython lets you register a callback that the interpreter invokes as your program runs. There are two flavours. The trace hook fires on every line. The profile hook, installed via sys.setprofile, fires only on function call and return events — far fewer events, far less overhead. cProfile installs such a hook written in C (the _lsprof module), so the per-event cost is small. The logic is simple and worth holding in your head. On a call event it pushes a frame record onto a stack and stamps a timer. On the matching return it stamps the timer again and folds the elapsed span into that function's running ledger. Do that for every call in the program, and per function you have three numbers that answer everything.

run_the_profiler.pypython
import cProfile, pstats

cProfile.run("main()", "out.prof")          # or: python -m cProfile -s cumtime prog.py
stats = pstats.Stats("out.prof")
stats.sort_stats("cumulative").print_stats(10)  # top 10 by inclusive time

The three numbers are these. ncalls is how many times the function was entered. tottime is time spent inside the function itself, excluding everything it called ("self time"). cumtime is total time from entry to return, including every callee ("inclusive time"). The distinction between the last two is not a detail. It is the entire skill of profile-reading, so hold it hard. A function with huge cumtime but tiny tottime is a conductor: it barely works itself, it just calls other expensive things, so chase its children. A function with tottime ≈ cumtime is a hot leaf: this is where cycles actually burn, and this is where you fix. Read the table sorted by cumtime to find the expensive subtree, then re-sort by tottime to find the leaf that is truly hot inside it.

SYNTAX · cProfile — three ways to start it, one table to readthe module flag for a whole script, the function for one call, the object for a region
# 1. THE WHOLE SCRIPT, FROM THE TERMINAL -- you edit nothing python -m cProfile -s cumtime prog.py -- sort by INCLUSIVE time python -m cProfile -s tottime prog.py -- sort by SELF time python -m cProfile -s ncalls prog.py -- sort by call count python -m cProfile -o out.prof prog.py -- save raw stats, print NOTHING python -m cProfile -s cumtime -m mypackage -- profile `python -m mypackage` # 2. ONE CALL, FROM INSIDE YOUR CODE import cProfile cProfile.run("main()") -- a STRING, exec'd in __main__ cProfile.run("main()", "out.prof") -- ...saved instead of printed cProfile.runctx("f(x)", globals(), locals()) -- when the names are LOCAL # 3. A REGION, WITH THE OBJECT API p = cProfile.Profile() p.enable(); hot_region(); p.disable() p.dump_stats("out.prof")
-m cProfileRuns your file as __main__, so the if __name__ guard still fires exactly as normal.
-sThe sort key. cumtime to find the expensive subtree, tottime to find the leaf inside it.
-oWrites a binary stats file and prints nothing at all. The silence means it worked; open it with pstats.
run("main()")Takes a string and execs it in __main__'s namespace. A local main is invisible to it.
runctxThe same, with explicit globals() and locals(). This is the fix for the invisible-local surprise.
enable/disableProfiles a region rather than a program. Useful when start-up cost would otherwise drown the part you care about.
the overheadA C hook fires on every call and return. Measured here: 2.1× slower for cProfile, 73× for the pure-Python profile.
you type
# ---------- shapes.py ----------
"""shapes.py -- half a second of computing, half a second of waiting."""
import time


def compute():
    return sum(i * i for i in range(2_000_000))


def fetch():
    time.sleep(0.5)              # stands in for a socket read
    return "payload"


def main():
    return compute(), fetch()


if __name__ == "__main__":
    main()


# ---------- the terminal ----------
$ python -m cProfile -s tottime shapes.py
$ python -c "import time, shapes; w=time.perf_counter(); c=time.process_time(); shapes.main(); print(f'wall {time.perf_counter()-w:.3f} s   cpu {time.process_time()-c:.3f} s')"
you see
$ python -m cProfile -s tottime shapes.py
         2000009 function calls in 0.771 seconds

   Ordered by: internal time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.501    0.501    0.501    0.501 {built-in method time.sleep}
        1    0.147    0.147    0.269    0.269 {built-in method builtins.sum}
  2000001    0.122    0.000    0.122    0.000 shapes.py:6(<genexpr>)
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}
        1    0.000    0.000    0.501    0.501 shapes.py:9(fetch)
        1    0.000    0.000    0.771    0.771 {built-in method builtins.exec}
        1    0.000    0.000    0.771    0.771 shapes.py:14(main)
        1    0.000    0.000    0.771    0.771 shapes.py:1(<module>)
        1    0.000    0.000    0.269    0.269 shapes.py:5(compute)

$ python -c "... perf_counter and process_time around shapes.main() ..."
wall 0.630 s   cpu 0.141 s
where beginners trip
  • The top row by tottime is {built-in method time.sleep} at 0.501 s. Nothing computed. The program stood still.
  • The cross-check settles it: 0.630 s of wall clock, 0.141 s of CPU. Half a second went to waiting, and no rewrite touches that.
  • So a fat row is not automatically work. Ask process_time whether the cycles were real before you plan a fix.
  • compute shows tottime 0.000 and cumtime 0.269. Its body is one return; the cost is in sum and the genexpr below it.
  • That genexpr row with 2,000,001 calls is the generator being resumed once per item. Comprehension frames show up as callable rows.
  • cProfile.run("main()") inside a function fails with NameError if main is a local. Reach for runctx, not for a global.
  • The {method 'disable' of '_lsprof.Profiler'} row is the profiler measuring its own shutdown. Every real profile has junk rows.
  • Add -o out.prof and you get no table. That is not a failure; you asked it to save instead of print.
Reading cProfile: tottime vs cumtimencallstottimepercallcumtimepercallfilename:lineno(function)10.0010.0014.8124.812app.py:10(run)10.0040.0043.9063.906config.py:8(build)500003.7800.0003.8020.000parse.py:22(tokenize)500000.0180.0000.0220.000parse.py:40(strip) CONDUCTOR: big cumtime, tiny tottime → chase its calleesHOT LEAF: tottime ≈ cumtime → cycles burn HERE, fix thishow the columns relatebuild cum 3.906tokenize cum 3.802strip cum 0.022parent cumtime = Σ children cumtime+ its own tottime
Fig — cumtime finds the delegator; tottime finds the culprit — optimize where they nearly coincide.
SYNTAX · pstats — asking the saved profile the next questionsort to convict, print_callees for the itemised bill, print_callers to find who ordered it
import pstats st = pstats.Stats("out.prof") st.strip_dirs() -- drop absolute paths, keep filenames st.sort_stats("cumtime").print_stats(10) -- the expensive SUBTREE, top 10 st.sort_stats("tottime").print_stats(10) -- the hot LEAF, top 10 st.sort_stats("ncalls").print_stats(10) -- who is called absurdly often st.print_stats("normalise") -- a str arg is a REGEX filter, not a name st.print_stats(0.1) -- a float is a FRACTION: the top tenth of rows st.print_callees("main") -- what main called, and what each one cost st.print_callers("<genexpr>") -- who is calling this 444,450 times st.sort_stats("tottime", "ncalls") -- ties broken by the second key st.dump_stats("merged.prof") -- st.add("other.prof") merges runs first
strip_dirs()Throws away directory prefixes so rows are readable. Do it once, before you sort, and never think about it again.
sort keyscumtime, tottime, ncalls, pcalls, nfl, filename. Pass two and the second breaks ties.
print_stats(int)An integer means rows. print_stats(10) is the top ten of whatever you just sorted by.
print_stats(float)A float means a fraction of the rows, not a fraction of the time. 0.1 is the top tenth of the list.
print_stats(str)A regular expression matched against the row text. "main" also matches domain_of, so anchor it if that bites.
print_calleesFor each match, the functions it called with their ncalls, tottime and cumtime. This is the itemised bill.
print_callersThe mirror image. Given a suspiciously hot leaf, it names every caller that put work into it.
the two percallsThe header prints percall twice. The first is tottime/ncalls; the second is cumtime/ncalls.
you type
# ---------- read_the_profile.py ----------
"""read_the_profile.py -- run the profiler from code, then interrogate the .prof"""
import cProfile, pstats, find_the_hog

cProfile.run("find_the_hog.main()", "hog.prof")

st = pstats.Stats("hog.prof")
st.strip_dirs()                              # drop the long paths

print("\n=== sorted by TOTTIME, top 5 -- where cycles actually burn ===")
st.sort_stats("tottime").print_stats(5)

print("=== who does main() call, and what did each cost? ===")
st.print_callees("main")


# ---------- the terminal ----------
$ python read_the_profile.py
$ python -c "import pstats; pstats.Stats('hog.prof').strip_dirs().print_callers('genexpr')"
you see
$ python read_the_profile.py

=== sorted by TOTTIME, top 5 -- where cycles actually burn ===
Sun Aug 16 08:06:37 2026    hog.prof

         1148906 function calls in 2.218 seconds

   Ordered by: internal time
   List reduced from 13 to 5 due to restriction <5>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    2.033    2.033    2.033    2.033 find_the_hog.py:17(unique)
   444450    0.055    0.000    0.089    0.000 find_the_hog.py:14(<genexpr>)
    50000    0.038    0.000    0.127    0.000 {method 'join' of 'str' objects}
   444450    0.034    0.000    0.034    0.000 {method 'isalnum' of 'str' objects}
    50000    0.027    0.000    0.165    0.000 find_the_hog.py:12(normalise)


=== who does main() call, and what did each cost? ===
   Ordered by: internal time
   List reduced from 13 to 1 due to restriction <'main'>

Function                  called...
                              ncalls  tottime  cumtime
find_the_hog.py:26(main)  ->       1    0.009    0.012  find_the_hog.py:4(build_words)
                               50000    0.027    0.165  find_the_hog.py:12(normalise)
                                   1    2.033    2.033  find_the_hog.py:17(unique)

$ python -c "... print_callers('genexpr') ..."
Function                       was called by...
                                   ncalls  tottime  cumtime
find_the_hog.py:14(<genexpr>)  <-  444450    0.055    0.089  {method 'join' of 'str' objects}
where beginners trip
  • Read the print_callees("main") table as an invoice. Three line items, and one of them is 2.033 of the 2.218 seconds.
  • unique has ncalls 1 and the largest tottime in the run. Call count is not the signal here; a hidden scan is.
  • normalise has 50,000 calls and 0.027 s of self time. High ncalls with low tottime is a busy, cheap function.
  • print_callers answered the natural next question. The 444,450 genexpr resumptions come from str.join pulling the generator.
  • List reduced from 13 to 5 is pstats telling you what it hid. Never quote a top-5 as if it were the whole run.
  • ncalls sometimes reads 1897/1. That is total calls over primitive calls, which is how recursion shows itself.
  • Skip strip_dirs() and every row carries a full path, so the function name lands off the right edge of your terminal.
  • The saved .prof records a timestamp and nothing about your machine. Write down the Python version yourself.
tottime FINDS THE FIX; cumtime FINDS THE PATH
Sort by cumtime to walk down the expensive branch of the call tree. Sort by tottime to land on the single function where the cycles are spent. High cumtime + low tottime means "not your problem — its child is." High tottime means "stop here."

Now the honest caveats, because an instrument that lies to you is worse than none. First, cProfile perturbs what it measures — the observer effect. Its hook samples a clock on every call. So a trivial function called ten million times has the hook's own overhead swamp its real cost, and the profiler over-blames tiny hot functions. This is why there are two families. Deterministic profilers like cProfile see every call exactly, but they slow the program roughly 2–5× and mis-charge micro-functions. Sampling profilers like py-spy take the opposite bet. They interrupt the program at a fixed frequency — say a thousand times a second — and record where it "usually is," attributing time statistically with near-zero overhead and no per-call distortion. For a tight CPU loop of tiny calls, reach for a sampler. For understanding call structure, cProfile.

The second caveat is the one that catches everyone, and it ties straight back to Section 1. cProfile measures the Python call structure — it fires on calls and returns. It does not fire while your program sits inside a socket read or a disk wait, because no Python function is being called during that wait. So an I/O-bound program produces a profile that blames almost nothing. Every function looks cheap, the totals are tiny, and yet the program took three real seconds. An empty profile is itself the diagnosis. It means your time went to waiting, not computing, and you should be reading wall-clock time, not a call profiler. The instrument that finds nothing has told you exactly where to look.

TYPE THIS · save it, then run itfind_the_hog.py — guess first, then profile, fix one word, and profile again
"""find_the_hog.py -- three functions. one of them is eating the program."""


def build_words(n):
    """Looks like the expensive one: it builds the whole dataset."""
    out = []
    for i in range(n):
        out.append(f"  Word{i % 10_000}!  ")
    return out


def normalise(word):
    """Called once per word -- 50,000 times. Surely this is the hog?"""
    return "".join(ch for ch in word.strip().lower() if ch.isalnum())


def unique(words):
    """Three lines. No nested loop. The innocent one."""
    seen = []
    for w in words:
        if w not in seen:
            seen.append(w)
    return seen


def main():
    raw = build_words(50_000)
    clean = [normalise(w) for w in raw]
    return unique(clean)


if __name__ == "__main__":
    print(len(main()), "unique words")


# ---------- the fix, once the profile has spoken ----------
def unique(words):                    # find_the_hog_fixed.py
    seen = set()                      # <-- hash-and-probe, not scan
    out = []
    for w in words:
        if w not in seen:
            seen.add(w)
            out.append(w)
    return out
$ python -m cProfile -s cumtime find_the_hog.py
10000 unique words
         1148916 function calls in 2.427 seconds

   Ordered by: cumulative time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    2.427    2.427 {built-in method builtins.exec}
        1    0.001    0.001    2.427    2.427 find_the_hog.py:1(<module>)
        1    0.007    0.007    2.426    2.426 find_the_hog.py:26(main)
        1    2.203    2.203    2.204    2.204 find_the_hog.py:17(unique)
    50000    0.032    0.000    0.199    0.000 find_the_hog.py:12(normalise)
    50000    0.046    0.000    0.153    0.000 {method 'join' of 'str' objects}
   444450    0.067    0.000    0.107    0.000 find_the_hog.py:14(<genexpr>)
   444450    0.040    0.000    0.040    0.000 {method 'isalnum' of 'str' objects}
        1    0.012    0.012    0.016    0.016 find_the_hog.py:4(build_words)
    50000    0.006    0.000    0.006    0.000 {method 'lower' of 'str' objects}
    50000    0.006    0.000    0.006    0.000 {method 'strip' of 'str' objects}
    60000    0.005    0.000    0.005    0.000 {method 'append' of 'list' objects}

$ python -m cProfile -s cumtime find_the_hog_fixed.py
10000 unique words
         1158916 function calls in 0.196 seconds

   Ordered by: cumulative time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.196    0.196 {built-in method builtins.exec}
        1    0.001    0.001    0.196    0.196 find_the_hog_fixed.py:1(<module>)
        1    0.007    0.007    0.195    0.195 find_the_hog_fixed.py:28(main)
    50000    0.027    0.000    0.170    0.000 find_the_hog_fixed.py:12(normalise)
    50000    0.040    0.000    0.132    0.000 {method 'join' of 'str' objects}
   444450    0.057    0.000    0.092    0.000 find_the_hog_fixed.py:14(<genexpr>)
   444450    0.035    0.000    0.035    0.000 {method 'isalnum' of 'str' objects}
        1    0.010    0.010    0.014    0.014 find_the_hog_fixed.py:4(build_words)
        1    0.003    0.003    0.005    0.005 find_the_hog_fixed.py:17(unique)
   10000    0.001    0.000    0.001    0.000 {method 'add' of 'set' objects}

$ python measure_hog.py            # perf_counter, no profiler attached
slow samples: [2.3876, 9.0066, 8.162, 8.1967, 8.1625]
fast samples: [0.1664, 0.1656, 0.1655, 0.1639, 0.1655]
slow best: 2.3876 s
fast best: 0.1639 s
speedup  : 14.6x

                  cumtime BEFORE   cumtime AFTER    change
  unique             2.204 s          0.005 s        441x
  normalise          0.199 s          0.170 s        1.2x
  build_words        0.016 s          0.014 s        1.1x
  ---------------------------------------------------------
  whole program      2.427 s          0.196 s       12.4x
Before you run anything, commit to a guess out loud. Almost everyone picks normalise, and the reasoning is sound: it is called 50,000 times, it builds a generator, it calls three string methods. unique looks like nothing. Three lines, one for, an if, an append. Now read the profile. unique: 2.204 s of 2.427 s, which is 91% of the run, from a single call. normalise, all fifty thousand invocations of it, is 8%. Here is what your eye could not see. w not in seen has no loop in it, so it reads as one operation, and it is a loop written in C. By the end seen holds 10,000 strings, so the average miss compares against several thousand of them. Fifty thousand words times a few thousand comparisons is a few hundred million string compares, hiding inside four characters of source. This is section 1's key in seen line, caught in the act. The fix is one word, and it is not a micro-optimisation. Swapping [] for set() changes what the membership test is: a scan becomes a hash and a probe, so f(n) goes from n² to n. unique's cumtime falls from 2.204 s to 0.005 s, a 441× cut, and the whole program drops 12.4×. Now look at the second profile again, because it contains the lesson of section 6 in miniature. The monster is gone, and the top row is now normalise at 0.170 of 0.196 s. It is 87% of the new total. It did not get slower; the denominator collapsed underneath it. Your first profile is a description of a program that no longer exists, which is why re-measuring is mandatory rather than tidy. Two honesty notes on the numbers themselves. First, the unprofiled samples read 2.39, 9.01, 8.16, 8.20, 8.16 s. That spread is real, and it is why we quote the minimum. This is a hybrid CPU, and a long-running thread can be moved onto a slower core partway through, so the same code takes three times as long for reasons that have nothing to do with the code. Second, compare 2.427 s profiled against 2.388 s unprofiled. cProfile barely taxed this program, because its hook fires per call and the hot work happens inside one frame. Give it a million tiny calls instead and the same profiler charges 2×. Then try this: change i % 10_000 to i % 100 so only a hundred distinct words exist, re-profile the slow version, and watch unique shrink to a rounding error. Same code, same call counts, a completely different verdict, because the cost was never in the function. It was in the size of the list it was scanning.

The profiler has named your hot function. Now the amateur and the professional part ways. The amateur shaves the inner loop; the professional asks a deeper question — is this slow because each step is expensive, or because there are too many steps? The answer decides everything. →

04Algorithmic beats constant-factor

The profiler flagged your hot function and you did the obvious thing. You rewrote its inner loop to be cleverer, hoisted a method into a local variable, unrolled it a little. You got fifteen percent. You felt good. Then the input doubled — a normal Tuesday for real data — and the function was slower than when you started. Something you did not touch scaled against you and ate your fifteen percent alive. The need now is to understand why some optimisations evaporate the instant the data grows while others compound. And you want to tell which kind of situation you are in before you spend a day fixing the wrong thing.

Here is the idea the entire chapter has been walking toward. A running time is, roughly, c × f(n). The constant factor c is how expensive each unit of work is — the thing micro-optimisation shrinks. The growth function f(n) is how the number of units scales with the input size, and that is set entirely by your algorithm. Micro-optimising changes c: it slides the whole curve down, uniformly, but it never changes the curve's shape. Choosing a better algorithm changes f(n) itself: it bends the curve. Slide versus bend. That is the difference between a junior's afternoon and a senior's five-minute rewrite, and at scale it is not close.

the_canonical_collision.pypython
# O(n²): 'in' on a list scans element by element, every time
def has_dup_list(items):
    seen = []
    for x in items:
        if x in seen: return True   # up to n compares, done n times → n²
        seen.append(x)
    return False

# O(n): a set membership test is one hash-and-probe (from the hash-table mechanism)
def has_dup_set(items):
    seen = set()
    for x in items:
        if x in seen: return True   # one probe, done n times → n
        seen.add(x)
    return False

The two functions differ by one word — [] became set() — and that word changes f(n) from n² to n, because a list must scan while a set does a single hash-and-probe. At n = 100 nobody notices. At n = 1,000,000 it is the difference between instant and never, and the arithmetic makes it visceral. O(n²) at a million is 10¹² operations. Even at a billion operations per second, that is about a thousand seconds — seventeen minutes. O(n) is a million operations, about a millisecond. No constant factor in the universe closes that gap. Make the quadratic inner loop a hundred times faster and you move a thousand seconds to ten seconds, while the linear version still sits at a millisecond. You cannot micro-optimise your way across a change in curve. That is the punchline of the whole chapter.

SYNTAX · the order you are allowed to work inmeasure, then the algorithm, then the data structure, then layout, and only then the micro-edit
0. IS IT CORRECT? -- tests green, or you are optimising a bug into a fast bug 1. MEASURE python -m cProfile -s cumtime prog.py -- no edit before this line. none. 2. ALGORITHM -- change f(n) itself: kill repeated or nested work n^2 -> n has_dup_list(items) -> has_dup_set(items) 2^n -> n def fib(n): ... -> @lru_cache on fib n -> 1 re-read the config -> read it once, cache it 3. DATA STRUCTURE -- make the operation you do MOST the cheap one x in list -> x in set membership lst.pop(0) -> deque.popleft() both ends scan for a key -> dict[key] by-id access 4. LAYOUT -- array/bytes over boxed lists; __slots__; fewer temporaries 5. MICRO -- hoist a lookup, localise a method, inline a helper ...and write down the readability you just spent 6. MEASURE AGAIN -- the bottleneck moved. it always moves.
step 0 firstA fast wrong answer is worthless. Ch 24's tests are what let you change the engine without changing the output.
step 1 is not optionalIn this chapter's own run, the 50,000-call helper was 8% and a three-line function was 91%.
step 2 pays in curvesMeasured here: set membership beat list membership by 27,995× on 100,000 items, for the same question.
step 3 is usually one wordIn the exercise below, a dict index cut lookup from 1.180 s to 0.006 s, and the program 15×.
what steps 2 and 3 shareBoth change how many operations happen. Steps 4 and 5 only change what each operation costs.
step 5's real sizeMeasured here: hoisting two lookups and inlining a helper bought 1.09×. Four edits, nine percent.
the price of step 5You pay it forever, on every future read and every future bug. A clean 1.8× beats an unreadable 2.0×.
the stop ruleYou stop when the tallest remaining bar cannot repay an afternoon, not when the code feels fast.
you type
# ---------- step 1: measure. slow_report.py builds a 30,000-line report ----------
$ python -m cProfile -s cumtime slow_report.py

# ---------- step 3: one index built once, and lookup() becomes a dict get ----------
BY_ID = {row[0]: row for row in CUSTOMERS}

def lookup(cid):
    return BY_ID.get(cid)

# ---------- step 6: measure again ----------
$ python -c "import time, slow_report, slow_report_fixed
for m in (slow_report, slow_report_fixed):
    ts = [ ... three perf_counter brackets around m.main() ... ]
    print(m.__name__, min(ts))"

# ---------- now try step 5 ON TOP: hoist BY_ID.get and out.append into locals,
#            and inline fmt() as an f-string. four edits. how much is it worth? ----------
$ python micro.py
you see
$ python -m cProfile -s cumtime slow_report.py
30000 lines rendered
         120019 function calls in 1.220 seconds
        1    0.012    0.012    1.205    1.205 slow_report.py:17(enrich)
    30000    1.180    0.000    1.180    0.000 slow_report.py:6(lookup)
    30000    0.011    0.000    0.011    0.000 slow_report.py:13(fmt)

$ python -c "... perf_counter, three samples each, minimum ..."
slow_report        best 1.1103 s   samples [1.111, 1.11, 3.225]
slow_report_fixed  best 0.0729 s   samples [0.075, 0.073, 0.075]

$ python micro.py
enrich       (dict index)        11.57 ms
enrich_micro (hoisted+inlined)   10.59 ms
the micro-edit bought          1.09x
where beginners trip
  • One line of step 3 bought 15.2×. Four lines of step 5, stacked on top of it, bought 1.09×.
  • That ratio is the whole argument for the order. Micro-editing first would have chased 9% while 93% sat untouched.
  • Ch 34 will show why list.append is O(1) amortised, and Ch 48 puts 51 ns on a set probe, roughly 11,000× a list scan.
  • Note what step 3 cost in readability: nothing. BY_ID.get(cid) reads better than the loop it replaced.
  • Note what step 5 cost: get = BY_ID.get and append = out.append are lines that exist purely to be fast.
  • The [1.111, 1.11, 3.225] sample is the machine wandering again, not a third version of the code.
  • Knuth's line is quoted to forbid optimising. The same sentence tells you to seize the critical 3%, once you have found it.
  • If a step-2 fix exists, a step-5 fix is almost never worth writing. Look for the repeated work before you look for the tighter loop.
A smaller constant delays the wall; the curve still winstimen (log scale)1010³10⁵10⁶10⁷O(n)O(n log n)O(n²)O(n²), 100× smaller constcrossoverPast the marker, the tuned quadratic is still overtaken by plain O(n) — only the curve matters at scale.
Fig — Constant-factor tuning shifts the crossover point; it never changes which curve wins in the end.
BIG-O IS ASYMPTOTIC — KNOW YOUR n
The rule is not "always pick the better Big-O." For small n, a worse-Big-O algorithm with a tiny constant can win — which is exactly why real sort implementations fall back to insertion sort (O(n²), but cache-friendly and low-overhead) under about 16 elements before switching to O(n log n). There is a crossover point. The honest rule: better Big-O wins at scale, and you must know the scale you actually run at.

So stock your head with the curves you will actually meet, in order of how they scale. O(1) is a hash lookup or dict access, the dream. O(log n) is binary search or a balanced tree, where doubling n adds one step. O(n) is a single scan. O(n log n) is a good sort, the ceiling for comparison sorting. O(n²) is the nested scan, the all-pairs comparison, the accidental disaster. And O(2ⁿ) is naive recursion over subsets, the fib-without-memoisation explosion that dies at n = 40. Almost every real optimisation is one of a small handful of moves that step you down this ladder. Replace a scan with a hash lookup. Replace all-pairs with sort-then-scan. Replace recomputation with memoisation of overlapping subproblems. And above all, pick the data structure whose cheap operation is the one you do most — a set for membership, a dict for lookup, a heap for "smallest so far," a deque for both ends. The right structure is not a tidiness choice. It is the choice of which curve you live on.

NOW WRITE IT YOURSELFthe order drill — four proposals for one slow program, ranked before a line is written
The situation, exactly as a teammate would hand it to you. A nightly job reconciles payments. It reads payments.csv (400,000 rows) and, for each payment, looks up the matching account by id in accounts, a list of 60,000 tuples, using a for loop with an if row[0] == pid inside. It then formats each result with an f-string, appends it to a list, and joins the list at the end. The job takes 41 minutes. A profile says: find_account is 38.2 minutes of self time on 400,000 calls, parse_row is 1.9 minutes on 400,000 calls, format_line is 0.5 minutes, and reading the file is 0.4 minutes. Nothing else is above a rounding error. The four proposals on the table. A: rewrite format_line's f-string as str.join on a tuple, which a benchmark shows is 1.3× faster. B: build a dict from account id to row once at start-up, and make find_account a single dict lookup. C: run the whole job under multiprocessing on 8 cores. D: hoist accounts into a local variable inside find_account and hoist out.append into a local, which a benchmark shows is 1.1× faster on that function. Your task, in writing, before you touch anything. First, rank all four by expected whole-program payoff, and for each one compute the Amdahl ceiling from the profile: what is p, and what is 1/(1-p)? Second, name which of the four change f(n) and which only change the constant c, and say why that distinction decides the ranking rather than the measured speedup of each fix. Third, for your top choice, state the one new cost you are taking on and how you would check it is affordable. Fourth, answer this: after your top fix lands, what is the largest total speedup any remaining work can still buy, and what would you profile next? One hint and no more: two of these four proposals are competing for the same 93%, and they are not equally good at taking it.
show the solution
# ---------- the ranking, with the arithmetic that produced it ----------
#
#  p = each function's share of the 41-minute run, read straight off the profile:
#      find_account  38.2 / 41.0 = 0.932      parse_row   1.9 / 41.0 = 0.046
#      format_line    0.5 / 41.0 = 0.012      read file   0.4 / 41.0 = 0.010
#
#  B  find_account -> dict lookup
#       p = 0.932. ceiling = 1/(1-0.932) = 14.7x.
#       A dict get is O(1) instead of a 60,000-element scan, so realistically
#       38.2 min collapses to a few seconds: about 14x, near the ceiling.
#       41 min -> roughly 2.8 min.
#
#  C  multiprocessing on 8 cores
#       p = ~0.99 (nearly all of it is CPU work), s = 8 at the very best.
#       1/((1-0.99) + 0.99/8) = 7.5x, and that is the fantasy number:
#       no pickling cost, no start-up, perfect balance. Ch 28 measured 2.35x
#       where the theory said 4x. Call it 4-5x, for a large permanent
#       increase in complexity -- and it makes the quadratic scan run on
#       eight cores instead of removing it.
#
#  D  hoist locals inside find_account
#       p = 0.932, s = 1.1. 1/((1-0.932) + 0.932/1.1) = 1.09x.
#       41 min -> 37.6 min. It touches the right function and still
#       does almost nothing, because it shaves c and leaves f(n) alone.
#
#  A  faster format_line
#       p = 0.012, s = 1.3. ceiling even at s = infinity is 1/(1-0.012) = 1.012x.
#       41 min -> 40.9 min. A perfect rewrite here cannot buy 1%.
#
#  RANK:  B  >>  C  >  D  >  A
#
#
# ---------- which lever does each one pull? ----------
#
#   changes f(n):   B only.   n*m scan  ->  n hash lookups.
#   changes c:      A, D.     same number of operations, each slightly cheaper.
#   changes neither: C.       it buys more workers to do the SAME operations.
#
# That is why the ranking is not the same as the ranking of measured speedups.
# D's benchmark (1.1x) and A's benchmark (1.3x) are both honest -- A's is even
# the larger of the two -- and both are nearly worthless here, because a
# constant-factor win is capped by its slice and B is capped by nothing.
#
#
# ---------- the new cost of proposal B ----------
#
#   The dict holds all 60,000 account rows in memory at once, keyed by id.
#   Check it: 60,000 entries is trivial (tens of MB at worst) -- confirm with
#   tracemalloc, not with a guess. Two real risks to check, not assume:
#     1. duplicate ids. A list scan returns the FIRST match; a dict keeps the
#        LAST insert. If ids repeat, B silently changes the answer.
#     2. build cost. Building the index is one O(n) pass over 60,000 rows,
#        paid once. Against 38 minutes it is free, but measure it anyway.
#   Correctness first: run the existing tests, then diff old and new output.
#
#
# ---------- what is left after B ----------
#
#   New total: about 2.8 min. The old 93% is now a few seconds, so the
#   profile has completely reshaped. parse_row was 1.9 min and did NOT get
#   faster -- it is now roughly 1.9 / 2.8 = 68% of the run and the new
#   tallest bar. Its ceiling: 1/(1-0.68) = 3.1x.
#
#   So the honest answer to "what can anything else still buy": about 3x
#   more, all of it now living in parse_row -- and only if you re-profile,
#   because that ranking did not exist an hour ago.
#
#   Proposal C is worth revisiting AFTER B, not instead of it. Parallelising
#   a linear job is a reasonable second move; parallelising a quadratic one
#   is buying eight cores to hide a bug in your data structure.

You picked the optimal algorithm and the Big-O is as good as it gets — and two programs with the same O(n) still differ by tenfold. The algorithm cannot explain that gap. The layer below it can: where your data lives in memory, and how the CPU waits for it. →

05Memory, allocation, and the cache

You did everything right. The algorithm is optimal, the Big-O is O(n), there is no wasted work in the operation count — and one program is ten times faster than the other. The one that flies stores its numbers in a packed array of raw floats. The slow one stores them in an ordinary Python list. Same n, same arithmetic, same complexity class. The algorithm cannot explain a tenfold gap, because the gap does not live in the algorithm. It lives one layer down, in the physics the algorithm sits on. The CPU spends most of its life not computing but waiting for memory, and where your data lives decides how long it waits.

Start with the hardware truth, because it is stark. Reading a value already in a CPU register costs about one cycle. From L1 cache, ~4 cycles. L2, ~12. L3, ~40. From main RAM — ~200+ cycles. A cache miss can cost fifty to a hundred times a hit. The CPU fights this with two tricks you must design around. It never fetches one byte; it fetches a cache line of 64 bytes at once — so if the next value you need is right beside the last, it is already there, free. And it prefetches: when it sees you walking memory sequentially, it fetches ahead of you. The consequence is enormous: contiguous, sequential access is vastly faster than chasing pointers to scattered addresses, even for the identical number of elements.

Now the Python-specific reality, and this is where the volume's thread comes home. A Python list of integers is not a block of integers. It is a contiguous array of pointers, and each pointer aims at a separate, heap-allocated PyObject — a little box holding a refcount, a type pointer, and the actual value — that can live anywhere in memory. Iterating the list means pointer-chasing: read a pointer, jump to a random address, almost certainly miss the cache, unbox the object, repeat. Contrast an array.array('d', ...) or a NumPy array, which stores raw C doubles packed end to end. One 64-byte cache line delivers eight of them at once. The prefetcher sees the straight-line pattern and runs ahead, and every value is already unboxed. Same eight numbers: eight misses versus one fetch.

layout_wins.pypython
import array, timeit

py_list = list(range(1_000_000))          # array of pointers → scattered PyObjects
packed  = array.array('q', range(1_000_000)) # contiguous raw 64-bit ints

# identical operation, identical O(n) — only the memory layout differs
print(min(timeit.repeat(lambda: sum(py_list), number=100)))
print(min(timeit.repeat(lambda: sum(packed),  number=100)))  # faster — the prefetcher can see it coming
Same eight numbers — 8 cache misses vs 1 fetchlist[float]contiguous pointers → objects scattered8 pointer cellsheap — CACHE MISS ~200 cycles eachobjobjobjobjobjeach value = a pointer chase to a random addressarray.array('d')raw doubles packed inline — no boxes1.02.03.04.05.06.07.08.0one 64-byte cache line — 8 values in a single fetchhardware prefetcher streams the next line ahead of you
Fig — Boxed objects scatter across RAM; packed arrays ride the cache line, so eight values cost one fetch, not eight.
LAYOUT IS A PERFORMANCE PROPERTY
For large numeric data, how the bytes are arranged can dominate a loop that is already algorithmically optimal. Contiguous, homogeneous, unboxed storage (array, bytes, NumPy) lets the cache line and the prefetcher work for you. A list of objects makes every element a pointer-chase and a likely miss. Same operation count, different physics.

There is a second cost centre hiding in Python's object model: allocation and refcount churn. Every time Python creates an object it calls the allocator — pymalloc hands out small blocks from pre-carved pools and arenas. And every assignment or function argument bumps a refcount up, then later down. Py_INCREF and Py_DECREF are not free bookkeeping. They are real memory writes, and writing to a shared object's refcount even invalidates that cache line across every core that holds it. A loop that creates a million short-lived temporary objects pays allocation, then refcount traffic, then deallocation for each one — that is churn — even though it computes nothing extra. This is why s += piece in a loop is a quiet O(n²) allocation disaster, a new string object every pass. Collecting pieces into a list and calling ''.join(pieces) once allocates the result a single time.

Hence the concrete levers, each one a move against a real cost you can now name. Prefer contiguous homogeneous storage for large numeric data. Reuse buffers instead of reallocating. Use generators to stream instead of materialising a huge intermediate list all at once. And add __slots__ to a class you make millions of, which drops the per-instance __dict__ — smaller objects, tighter packing, less allocation. Then tie it back to measurement, because this is the sting. These effects are invisible in the source, and largely invisible to cProfile's function-level view. The profiler points at a loop that is "slow for no reason," and the reason is the memory beneath it. The tools that confirm it are timeit comparisons and memory instruments — tracemalloc and sys.getsizeof — not the call profiler.

SYNTAX · the next four instruments — what each one sees that cProfile cannotline-level time, line-level memory, a picture of the call tree, and a sampler for live processes
# STDLIB -- always there, no install import tracemalloc tracemalloc.start() ... current, peak = tracemalloc.get_traced_memory() -- bytes, PEAK is the one tracemalloc.stop() # THIRD PARTY -- one pip install each pip install line_profiler -- time per LINE, not per function pip install memory_profiler -- MiB per LINE pip install snakeviz -- an interactive picture of a .prof pip install py-spy -- sample a process you did not start @profile -- both line profilers read this decorator def hot_function(n): ... kernprof -l -v prog.py -- line_profiler's runner python -m memory_profiler prog.py snakeviz out.prof -- opens a browser on the flame view py-spy top --pid 12345 -- attach to something already running
tracemallocStdlib. Tracks every allocation Python makes and reports peak bytes, which is the number that decides if you fit in RAM.
line_profilercProfile stops at the function boundary. This one splits a function into lines and charges each line its own time.
memory_profilerThe same idea for bytes. It shows which line grew the process, which is how you find an accidental materialisation.
snakevizReads a .prof you already have and draws it. Nothing new is measured; the same numbers become navigable.
py-spyA sampling profiler that attaches to a running process by pid. No code change, no restart, near-zero overhead.
@profileInjected by the runner, not imported. Run the file plainly with the decorator still on it and you get NameError.
the honest deferralThese are named here so you know they exist. Reach for them when cProfile has narrowed it to one function and you need inside it.
you type
# ---------- what is actually installed in this environment? ----------
$ python -c "import line_profiler"
$ python -c "import snakeviz"

# ---------- mem_check.py ----------
"""mem_check.py -- where the bytes go. Run: python -m memory_profiler mem_check.py"""

@profile
def build_list(n):
    rows = [f"row-{i}" for i in range(n)]      # materialise everything
    total = sum(len(r) for r in rows)
    return total


@profile
def stream(n):
    total = sum(len(f"row-{i}") for i in range(n))   # one row alive at a time
    return total


if __name__ == "__main__":
    print(build_list(50_000), stream(50_000))


$ python -m memory_profiler mem_check.py

# ---------- the same question with the stdlib, at 500,000 rows ----------
$ python -c "... tracemalloc.start(); fn(500_000); get_traced_memory() ..."
you see
$ python -c "import line_profiler"
Traceback (most recent call last):
  File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'line_profiler'

$ python -c "import snakeviz"
Traceback (most recent call last):
  File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'snakeviz'

$ python -m memory_profiler mem_check.py
438890 438890
Filename: mem_check.py

Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
     3   58.996 MiB   58.996 MiB           1   @profile
     4                                         def build_list(n):
     5   62.805 MiB    3.809 MiB       50001       rows = [f"row-{i}" for i in range(n)]      # materialise everything
     6   62.805 MiB    0.000 MiB      100003       total = sum(len(r) for r in rows)
     7   62.805 MiB    0.000 MiB           1       return total


Filename: mem_check.py

Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
    10   60.809 MiB   60.809 MiB           1   @profile
    11                                         def stream(n):
    12   60.809 MiB    0.000 MiB      100003       total = sum(len(f"row-{i}") for i in range(n))   # one row alive at a time
    13   60.809 MiB    0.000 MiB           1       return total

$ python -c "... tracemalloc, 500,000 rows ..."
  build_list  peak   28.19 MiB
  stream      peak    0.00 MiB
where beginners trip
  • Read the Increment column, not Mem usage. One line added 3.809 MiB; every other line added zero.
  • That is the generator argument, made checkable. The streaming version's peak is 0.00 MiB because one row is alive at a time.
  • line_profiler and snakeviz are genuinely absent here, and the traceback above is what that looks like. Nothing is broken.
  • Install nothing until cProfile has already named one function. A line profiler on the wrong function is a slower way to be wrong.
  • Leave @profile on the file and run it normally and you get NameError: name 'profile' is not defined. The runner injects it.
  • memory_profiler polls, so it is slow. At 500,000 rows this run did not finish in two minutes; 50,000 answered the same question.
  • tracemalloc counts only what Python allocated. A NumPy buffer or a C extension's memory is outside its view.
  • py-spy is the one to remember for production. It attaches to a process that is already misbehaving, without a restart.

You can now find the hot path, choose the right curve, and lay the data out for the cache. One question remains — the one that decides whether any of it is worth doing. You made a function 4× faster and the program barely moved. Amdahl's Law explains exactly why, and turns your profile into a budget. →

06Optimize the hot path, then remeasure

You found the hot function, you fixed it, it is now four times faster — and the whole program got six percent faster. It feels like a swindle. Meanwhile a colleague spent the same afternoon and got 3× on the total. Same effort, wildly different payoff, and the difference was not skill. It was which part they chose to fix. The need now is to know, before you spend the day, whether an optimisation is worth doing at all. And you have to accept that performance work is never one-and-done, because the moment you fix the top of the list a different function becomes the new bottleneck.

The law that governs all of this is Amdahl's Law, and it is simple enough to keep in your head. Say a portion p of the total runtime is the part you speed up, and you make that part s times faster. Then the overall speedup is:

amdahl.pypython
def speedup(p, s):
    # p = fraction of runtime you optimized; s = how many times faster you made it
    return 1 / ((1 - p) + p / s)

print(speedup(0.08, 4))    # 1.06  → your 4x fix on an 8% slice: +6% total
print(speedup(0.70, 2))    # 1.54  → a mere 2x on the 70% slice beats it easily
print(speedup(0.05, 10_000)) # 1.052 → infinite speed on a 5% slice caps at ~5%

Three consequences fall straight out, and together they are the lesson. First, the ceiling. Let s → ∞ — make the optimised part infinitely fast — and the best you can ever reach is 1 / (1 − p). A function that is 5% of runtime caps your total improvement at about 5.3%, no matter how brilliant the rewrite. Optimising cold code is money left on the table. Optimising the 5% part is nearly pointless even done perfectly. Second, it explains your swindle exactly: with p = 0.08 and s = 4, the total speedup is 1 / (0.92 + 0.02) ≈ 1.06. The fix worked. The part was just too small to matter. Third — and this is what turns theory into a tool — it tells you where to spend effort before you spend it. A modest 2× on the function that is 70% of runtime crushes a heroic 100× on the 3% function. Read this way, the profile is not just a list of suspects. It is a budget: each row's cumtime is the maximum payoff any fix to it can ever return.

NOW WRITE IT YOURSELFread this profile — a real capture, three questions, one fix, then prove the fix
First, read it cold. Below is a genuine python -m cProfile -s cumtime slow_report.py capture. The program builds a 30,000-line report from a 4,000-customer table. You have not seen its source.
30000 lines rendered
         120019 function calls in 1.220 seconds

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.001    0.001    1.216    1.216 slow_report.py:29(main)
        1    0.012    0.012    1.205    1.205 slow_report.py:17(enrich)
    30000    1.180    0.000    1.180    0.000 slow_report.py:6(lookup)
    30000    0.011    0.000    0.011    0.000 slow_report.py:13(fmt)
        1    0.000    0.000    0.010    0.010 slow_report.py:25(render)
    30001    0.008    0.000    0.008    0.000 slow_report.py:26(<genexpr>)
Answer these before you scroll. One: name the hog, and say which column convicted it. Two: enrich has a cumtime of 1.205 s, which is 99% of the run. Explain in one sentence why rewriting enrich would be a waste of your afternoon. Three: lookup and fmt were both called exactly 30,000 times, and one is 107× the other. Since ncalls is identical, ncalls cannot be the signal here, so what is? Four: from the numbers alone, say what lookup is almost certainly doing inside its body, and why tottime equals cumtime for it. Then fix it and prove the fix. Write a lookup whose per-call cost does not depend on how many customers exist, re-profile, and report three things: the new whole-program time, lookup's new cumtime, and which function is the tallest bar now. Finally, compute the Amdahl ceiling for that new tallest bar and say whether you would spend another afternoon on it.
show the solution
# ---------- the answers ----------
#
# 1. THE HOG IS lookup.  The column that convicted it is TOTTIME: 1.180 s of
#    1.220 s, 97% of the run, spent inside lookup's own body. main and enrich
#    have huge cumtime and near-zero tottime -- they are conductors.
#
# 2. enrich's 1.205 s is INHERITED. Its own tottime is 0.012 s, so a perfect
#    rewrite of enrich saves at most 0.012 s: 1% of the run. cumtime tells you
#    which way to walk; tottime tells you where to stop.
#
# 3. Not the call count -- it is identical. The signal is COST PER CALL, and
#    the profile prints it: percall for lookup is 1.180/30000 = 39 us, for fmt
#    it is 0.011/30000 = 0.37 us. Same number of calls, 107x the cost each.
#
# 4. tottime == cumtime means lookup calls NOTHING that the profiler can see.
#    It is a hot leaf. 39 us of pure self-time per call, on a 4,000-row table,
#    is the fingerprint of a linear SCAN written in Python.
#
#
# ---------- the source, once you look ----------
CUSTOMERS = [(i, f"customer-{i}", i * 3) for i in range(4_000)]

def lookup(cid):
    for row in CUSTOMERS:          # 4,000 rows, walked from the top
        if row[0] == cid:          # ...for every one of 30,000 orders
            return row
    return None                    # 30,000 x ~2,000 average = ~60M compares


# ---------- the fix: index once, look up in O(1) ----------
BY_ID = {row[0]: row for row in CUSTOMERS}    # built ONE time, at import

def lookup(cid):
    return BY_ID.get(cid)


# ---------- the proof ----------
$ python -m cProfile -s cumtime slow_report_fixed.py
30000 lines rendered
         150019 function calls in 0.045 seconds

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.001    0.001    0.040    0.040 slow_report_fixed.py:29(main)
        1    0.011    0.011    0.029    0.029 slow_report_fixed.py:17(enrich)
        1    0.000    0.000    0.010    0.010 slow_report_fixed.py:25(render)
    30000    0.009    0.000    0.009    0.000 slow_report_fixed.py:13(fmt)
    30001    0.008    0.000    0.008    0.000 slow_report_fixed.py:26(<genexpr>)
    30000    0.004    0.000    0.006    0.000 slow_report_fixed.py:9(lookup)
    30000    0.003    0.000    0.003    0.000 {method 'get' of 'dict' objects}

$ python -c "... perf_counter, best of three, no profiler ..."
slow_report        best 1.1103 s
slow_report_fixed  best 0.0729 s        # 15.2x


# ---------- the three numbers, and the ceiling ----------
#
#   whole program : 1.220 s -> 0.045 s profiled, 1.110 s -> 0.073 s real (15.2x)
#   lookup cumtime: 1.180 s -> 0.006 s  (about 200x)
#   tallest bar now: enrich, tottime 0.011 s of a 0.045 s run
#
#   The bottleneck MOVED. enrich did not get slower -- it is now 24% of a much
#   smaller total, and its own loop is the largest single piece left.
#   Ceiling for enrich: p = 0.011/0.045 = 0.24, so 1/(1-0.24) = 1.32x.
#
#   Verdict: no. A perfect rewrite of enrich buys at most 32%, on a job that
#   now takes 73 milliseconds. Stop. The next afternoon is worth more somewhere
#   else -- and that judgement is only defensible because it is measured.
#
#
# ---------- the trap to notice ----------
#
#   The dict keeps the LAST row for a duplicate id; the scan returned the FIRST.
#   If CUSTOMERS can hold duplicate ids, this fix is not equivalent, it is a
#   behaviour change wearing an optimisation's clothes. Check before shipping.
THE PROFILE IS A BUDGET, NOT A TO-DO LIST
Before optimising anything, read its share p off the profile and compute the ceiling 1/(1−p). If a function is 4% of runtime, the very best a perfect rewrite can buy the whole program is ~4%. Spend your day on the biggest p — a small speedup there beats a huge speedup on a sliver, every time.
Amdahl: speedup is capped by what you don't fixbeforehot path 70%rest 30%A — halve the 70% hot path→ 65% of runtime · 1.54× totalB — 20× on a 5% sliver→ 95% of runtime · 1.05× totalInfinite speedup on the 5% sliver still caps the whole program at a 5% cut.total speedup vs s (per parallel fraction p)speedup of the part → ceiling = 1/(1−p)p=.3p=.7p=.9
Fig — Total speedup is bounded by 1/(1−p) — optimizing a small fraction can't move the whole no matter how large the local win.
Interactive · Amdahl's Lawdrag the target
you land a heroic 20× rewrite — but on WHICH function? the profile picks your payoff. inner loop (your hunch) ITS SHARE OF TOTAL RUNTIME  (p) 2% of total SPEEDUP OF THE WHOLE PROGRAM 1.02× a heroic 20× rewrite — poured straight down the drain EVEN IF YOU'D MADE IT INFINITELY FAST, THE CEILING IS ONLY… 1.02× — the slice you didn't touch is the wall. You made the innocent 2% function 20× faster. The guilty slice was never touched — total: 1.02×.
inner loop
Drag your target from the 2% inner loop you suspected up to load_config() at 71%. The rewrite is identical every time — a near-perfect 20×. Only the target changes, and it changes everything: the faint bar behind is the ceiling 1/(1−p), the payoff you'd get for infinite speed. On a small slice, even infinity is a nub. This is why the profile is a budget, not a to-do list — each function's share p is the most any fix to it can ever return.

Now the part people skip, and it is why performance work is a loop and not a task: the bottleneck moves. Say function A was 70% and you cut it in half. It is now a much smaller slice, and function B, which was a quiet 15%, is suddenly the tallest bar in a completely reshaped profile. Your first-round intuition is now worthless, because it was built against a distribution that no longer exists. This is why re-measure is mandatory, not optional. After every fix you must re-profile against the new reality, because the target physically relocated. Measure → find the hot path → fix only the hot path → measure again → repeat until the remaining wins cannot beat their cost.

NOW WRITE IT YOURSELFtime it right — a benchmark that reports a lie, and the three sins that made it lie
First, run the liar. Save this exactly as written and run it twice.
"""bench_bad.py -- a benchmark with three sins. It reports a lie."""
import time, random

random.seed(29)
data = [random.random() for _ in range(200_000)]

t0 = time.time()
out = sorted([random.random() for _ in range(200_000)])
print(f"sorted()  {time.time() - t0:.6f} s")

t0 = time.time()
for _ in range(5):
    data.sort()
print(f".sort()   {(time.time() - t0) / 5:.6f} s")
It will tell you that .sort() is about 7× faster than sorted(). Second, prove that verdict is impossible before you fix anything. Look up what CPython's sorted() actually does to its argument, in two steps, and write down why those two functions cannot differ by 7×. A benchmark that reports an impossible result has a bug in the harness, and finding it is the skill. Third, name the three sins, one per line, and say what each one does to the number. They are all in the eleven lines above, and each is a different failure: one is about the clock, one is about what is inside the bracket, and one is about the state of the data on the second and later runs. The third is the nastiest, because it makes the loser look like a winner rather than just adding noise. Fourth, rewrite it honestly and report the real verdict. Use timeit, put the data construction in setup, give both candidates the same starting input every trial, interleave the two so any machine drift is shared, and take the minimum. Then answer: what is the true ratio, and what does that ratio tell you about the two spellings? One hint and no more: time t = s[:]; t.sort() on a list that is already sorted and compare it against the same statement on a shuffled list.
show the solution
# ---------- why the verdict is impossible ----------
#
#   CPython's sorted(x) does exactly two things:
#       1. build a new list from the iterable        (a copy)
#       2. call list.sort on that new list           (the same C sort)
#   So sorted(d) IS "copy, then sort". It cannot be 7x slower than itself.
#   When the arithmetic is impossible, stop editing code and audit the harness.
#
#
# ---------- the three sins ----------
#
#   SIN 1  -- THE CLOCK.  time.time() on Windows resolves to 15.625 ms, and
#             each candidate is timed exactly ONCE. A single sample from a
#             coarse, adjustable clock is a coin flip, not a measurement.
#             Fix: timeit (which uses perf_counter), repeated, minimum taken.
#
#   SIN 2  -- WHAT IS IN THE BRACKET.  The sorted() timing includes building
#             200,000 fresh random floats INSIDE the timed region. That is
#             ~15 ms of list-building charged to sorted(). The .sort() timing
#             includes no construction at all. The two brackets do not hold
#             the same work, so the comparison is meaningless.
#             Fix: construction goes in setup, which timeit does not time.
#
#   SIN 3  -- THE DATA MUTATES.  data.sort() sorts IN PLACE. The first of the
#             five iterations sorts a random list; the other four sort an
#             ALREADY SORTED list. Timsort detects existing runs and finishes
#             a sorted list in O(n). So four of the five reps do almost no
#             work, and the average is divided by five anyway.
#             Fix: hand every trial the same fresh input -- t = d[:]; t.sort().
#
#             Measured, so it is not a story:
#                 t = d[:];  t.sort()   on random input     20.38 ms
#                 t = s[:];  t.sort()   on sorted input      1.39 ms   (14.7x)
#
#             This is the sin that inverts a verdict. Sins 1 and 2 add noise
#             and bias; sin 3 hands the winner's medal to the wrong candidate.
#
#
# ---------- bench_good.py ----------
"""bench_good.py -- the same question, asked honestly."""
import timeit

SETUP = "import random; random.seed(29)\nd = [random.random() for _ in range(200_000)]"
NUMBER, REPEAT = 5, 7

a = timeit.Timer("sorted(d)", SETUP)
b = timeit.Timer("t = d[:]; t.sort()", SETUP)

best_a = best_b = float("inf")
for _ in range(REPEAT):                                # interleave A and B so
    best_a = min(best_a, a.timeit(NUMBER) / NUMBER)    # any drift is shared
    best_b = min(best_b, b.timeit(NUMBER) / NUMBER)

print(f"sorted(d)           {best_a*1000:7.2f} ms")
print(f"t = d[:]; t.sort()  {best_b*1000:7.2f} ms")
print(f"ratio               {max(best_a,best_b)/min(best_a,best_b):.3f}x  -- a dead heat")


$ python bench_good.py
sorted(d)             21.02 ms
t = d[:]; t.sort()    21.07 ms
ratio               1.002x  -- a dead heat

$ python bench_good.py
sorted(d)             20.81 ms
t = d[:]; t.sort()    20.63 ms
ratio               1.009x  -- a dead heat


# ---------- the real verdict ----------
#
#   1.002x and 1.009x, and the winner swaps between runs. That is a tie, and a
#   tie is a RESULT: these two spellings do identical work, so choose between
#   them on meaning, not speed. Use .sort() when you want the original list
#   reordered; use sorted() when you want a new list and the original kept.
#
#   The 7x from bench_bad.py measured three bugs. Notice that fixing sin 3
#   alone would still have left a wrong number, and fixing sin 1 alone would
#   have made a wrong number more precise. A benchmark is only as honest as
#   its weakest line.

✗ The myth

"Optimisation means making the code as fast as possible everywhere. Faster is always better, so tighten every loop."

✓ The reality

Every optimisation costs readability and maintainability — a cost you pay whether or not it helped. So you optimise only after correctness, only after measuring, and only the one slice the profile says can pay. Knuth's "premature optimisation is the root of all evil" was never anti-speed — the same sentence says to seize the critical 3%. It is anti-guessing at the other 97%.

PRICE THE COMPLEXITY, NOT JUST THE SPEEDUP
A clean 1.8× and an unreadable, bit-twiddling 2.0× are not close — the extra 0.2× buys a large, permanent tax on every future reader and every future bug. Keep a benchmark so a "speedup" is a measured fact, not a hopeful guess, and know when to stop: when the hot path is genuinely balanced, or the next win cannot clear its own complexity cost.

And that closes both the chapter and the volume. Correctness first — the whole of Volumes 1 and 2. Then measure. Then fix the one thing the measurement flagged. Then measure again. Not the guesser's leap — the engineer's loop. →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 29, in working code

Chapter 29 is about measuring before you cut — and the one thing you can never trust is your gut about where the time goes. Every program here swaps the stopwatch (which lies differently every run) for a deterministic proxy: call counts, comparison counts, cache stats, and structural facts. Same numbers every time, same lesson every time — cost lives where you don't look, and complexity is something you can literally count.

Where the time actually hides
Your intuition points at the flashy code; the instrumentation points somewhere else. We replace timers with call counters so the answer is exact and repeatable.
Complexity you can count
Big-O stops being an abstraction the moment you count the actual operations. Absent targets and worst-case searches make the growth curves show up as plain integers.
Data layout is the hidden cost
Same algorithm, different memory shape, wildly different price. These prove the layout differences structurally — item sizes, missing __dict__s, chars copied — never with a timer.
The measure-fix-remeasure loop
Optimizing is a loop, not a one-shot. Amdahl tells you which slice is worth it; a fix moves the bottleneck; you remeasure and the verdict changes.
end of chapter 29 · six sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked