Command Palette

Search for a command to run...

Hectal
PHASE 13Advanced ~29 min· topic 8 of 9

Topic 13.8

Making Python Fast

In one line

Python code gets faster mainly by using a better algorithm or data structure, avoiding repeated work with caches, streaming with generators, and moving loops into C through built-ins and NumPy. Only after that do local-variable tricks and native extensions matter.

Think of it like this

Delivering 300 letters. You can walk to a house, deliver one letter, walk back and repeat 300 times (slow). You can sort letters by street first and walk one route (fast). Or you can hire a courier company with vans that does the whole route for you (faster still, but you pay and you must hand over the letters in boxes). Speeding up code works the same way: a better plan first, then reuse the work you already did, then let a faster worker do the loop.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Big-O
A way to describe how work grows as the input grows, such as linear (n) or constant (1).
Cache (memoise)
Store a result so the same input never has to be computed again.
Vectorise
Replace a Python loop over numbers by one operation on a whole array.
Lazy
Computing a value only when it is asked for.
Hot loop
A loop that runs so often that it dominates the run time.
Native extension
A compiled module (C, C++, Rust) that Python can import.
JIT
Just-in-time compiler: compiles hot code to machine code while running.
Constant factor
The fixed cost of each step. Big-O ignores it, but it still decides speed once the algorithm is right.

Step by step

01Count steps for two ways of finding duplicates

The slow version compares every pair, which for 300 items is 44,850 comparisons (n times n minus one, divided by two). The fast version walks the list once and remembers what it has seen in a set: 300 steps. Doubling the input quadruples the slow version and only doubles the fast one.

main.pywhole filepython
def has_duplicate_slow(values):
    for i in range(len(values)):
        for j in range(i + 1, len(values)):
            if values[i] == values[j]:
                return True
    return False

def has_duplicate_fast(values):
    seen = set()
    for v in values:
        if v in seen:
            return True
        seen.add(v)
    return False

02Cache work you have already done

A recursive fib recomputes the same values again and again. @lru_cache(maxsize=None) stores each result by argument. Calling fib(60) then does 61 real calls. fib.cache_info() shows hits and misses, which is how you check the cache is working.

Cache only pure functions (same input, same output, no side effects), and only with hashable arguments (a list argument raises TypeError: unhashable type: 'list').

main.pywhole filepython
from functools import lru_cache

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

print(fib(60))
print(fib.cache_info())

03Generators keep memory flat

[n * n for n in range(100_000)] builds all 100,000 numbers. (n * n for n in range(100_000)) builds none until asked. sys.getsizeof on the list counts the pointer array only (the number objects add more), and the generator stays tiny. If you only need to loop once, use the generator.

04Built-ins and comprehensions

sum(values) loops in C. A for loop with total += v runs bytecode per item. Prefer the built-in. A comprehension builds a list without repeated append lookups. "".join(words) builds one string, where s += w in a loop may copy the growing string again and again (CPython optimises some cases, but you cannot count on it).

05Vectorise with NumPy

The second runnable example sums squares with a Python loop and with NumPy, and gets the same answer. We use dtype=np.int64 because the browser's default integer is 32-bit (Topic 14.1). The NumPy version makes one Python-level call and the loop runs in C.

np.vectorize(f) looks vectorised but is a Python loop in disguise: the function is still called once per element. A real ufunc such as np.square calls no Python code per item.

06A decision flow

Work down this list and stop when the program is fast enough. Do not jump to the bottom: native code is the most expensive in effort and in maintenance.

A decision flowdiagram
Rendering diagram…

07When Python is the wrong layer

In AI systems, the heavy work (matrix multiplication, model inference) already runs in C, C++ or CUDA libraries, and Python is the thin layer that connects them. If a Python loop touches every element of a tensor, that loop is the bug. Keep Python for decisions and glue, and let the libraries do the arithmetic.

Try it yourself

  1. 1

    Double the input

    In the first example change range(300) to range(600). Predict the two step counts before running: the slow one should be about four times bigger, the fast one twice.

  2. 2

    Look at the cache

    Call fib(60) and then fib(61) and print fib.cache_info() again. How many new misses did fib(61) cause?

  3. 3

    Find the hot loop

    Take any loop you wrote earlier in this course that touches a list item by item. Ask three questions: can a set or dict replace a scan, can a built-in do the loop, and is any value inside the loop the same on every pass?

Code & diagrams

Better structure, caching and generators (counted, not timed) New tab
Sign in to run this example in your browser.

Expected output

slow: False in 44850 steps
fast: False in 300 steps
fib(60) = 1548008755920 with 61 real calls
CacheInfo(hits=58, misses=61, maxsize=None, currsize=61)
list is far bigger than generator: True
same answer: True
Vectorising with NumPy New tab

`np.vectorize` still calls your Python function for every element. `np.square` is a real ufunc and calls no Python code per element.

Sign in to run this example in your browser.

Expected output

loop result:   332833500
numpy result:  332833500
same: True
Python function calls for map: 1000
np.vectorize still calls Python: True
a real ufunc calls it 0 times:  True

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Cache a function that takes a list

Pass a list to a function decorated with lru_cache.

main.pywhole filepython
from functools import lru_cache


@lru_cache(maxsize=None)
def total(values):
    return sum(values)


print(total((1, 2, 3)))
print(total([1, 2, 3]))
terminal
$ python main.py
── what you'll see ──
6
Traceback (most recent call last):
File "main.py", line 10, in <module>
print(total([1, 2, 3]))
~~~~~^^^^^^^^^^^
TypeError: unhashable type: 'list'

Myth vs fact

Myth

Python is slow, so there is nothing to do.

Fact

Algorithm and data-structure choices change run time by factors of thousands. Libraries move the hot loops to C. Most slow Python programs are slow for fixable reasons.

Myth

A list comprehension is always faster than a loop.

Fact

It is often a little faster, but a built-in such as sum or a set lookup beats both. Use comprehensions for clarity first.

Myth

np.vectorize makes a function fast.

Fact

It is a convenience wrapper that still calls your Python function per element. Use array operations and ufuncs for speed.

Myth

lru_cache is free.

Fact

It uses memory for every distinct argument (unless limited by maxsize), and it keeps arguments alive, which can be a leak.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Constant factors matter once the algorithm is right. Attribute and global lookups, allocating temporary objects, and calling Python-level functions in a tight loop are what remain. __slots__ (Topic 8.12), tuples instead of small lists, and avoiding isinstance chains help at the margins. Do these after profiling shows the loop is the bottleneck.

  • ▸

    Memory is speed too: cache misses dominate on big data. That is why columnar layouts (NumPy, pandas, Arrow) beat lists of Python objects, and why array and memoryview (Topic 13.9) exist.

  • ▸

    Numba compiles numeric Python functions to machine code with LLVM and can release the GIL when you ask for it (nogil=True); Cython compiles type-annotated Python to C; PyO3 builds extensions in Rust. Each has build and packaging costs and limits, so consider them after vectorising. PyPy is often several times faster on pure-Python loops but has weaker support for C extension packages.

  • ▸

    The experimental JIT (Topic 13.3) and the free-threaded build (Topic 12.2) are still evolving; the free-threaded build was officially supported from 3.14 but is slower for single-threaded code than the default build, and the JIT was still experimental in 3.14. Benchmark them on your own workload before assuming a gain.

  • ▸

    The most common real slowdowns are not clever: row-by-row loops over a pandas DataFrame (df.apply(axis=1), iterrows) are Python loops in disguise and are usually replaced by column operations; building a big list with + in a loop copies it each time; and calling a function (an HTTP request, a database query) once per item instead of once per batch. Fix those before touching bytecode-level tricks.

  • ▸

    lru_cache is a hash lookup on a key built from the arguments, so it only pays when the function costs clearly more than that lookup. It is thread-safe in the sense that its internal state stays consistent, but two threads can both compute a missing value. functools.cache is lru_cache(maxsize=None). On methods, prefer a per-instance cache or cached_property, because the cache otherwise holds self alive for the life of the class.

Remember this

  1. 1

    The right data structure comes first. (Recall Topics 4.5, 4.10 and 4.11 for hashing, deque and the cost of each structure.) Membership tests: a set or dict answers x in s in constant time, a list scans (linear). Queue operations: collections.deque pops from the left in constant time, list.pop(0) moves everything. Sorted data: bisect finds a position in logarithmic time. Counting: Counter. This changes the growth of the run time, so it beats every micro-trick. The example counts 44,850 steps for a nested loop against 300 for a set on 300 items.

  2. 2

    Avoid repeated work. Move calculations that do not change out of loops. Cache results of pure functions with functools.lru_cache or cache (Topic 9.5): the cached recursive fib(60) makes 61 real calls instead of trillions. Read a file once, not once per line of another file. Build strings with "".join(parts) instead of += in a loop. Batch many small database or API calls into fewer large ones.

  3. 3

    Use the C code that already ships with Python. Built-ins such as sum, min, max, sorted, any, all, zip, map and the methods of str, list and dict run their loops in C. sum(data) and max(data) beat a hand-written loop by a wide margin, and str.join, str.split and slicing beat character-by-character code. (A generator expression inside sum is not as fast: each item still runs Python bytecode, so the win comes from the C-level loop, not from the generator.) A list comprehension is usually faster than the same for loop with append, because it avoids repeated method lookups.

  4. 4

    Stay lazy. A generator or generator expression produces one item at a time and never builds the whole list, so memory stays flat: a list of 100,000 squares is hundreds of times larger than the generator that makes them. Streaming a big file line by line, or an LLM response token by token, uses the same idea (Topic 9.2).

  5. 5

    Micro-tricks, last. Reading a local variable is faster than a global or attribute lookup, so inside a very hot loop binding append = items.append or sqrt = math.sqrt can help a little. The specialising interpreter (Topic 13.3) has narrowed the gap, so measure (Topic 13.7). Avoid creating objects in the hot loop, avoid exceptions as normal flow, and keep functions small and monomorphic (a function that always gets the same types in and gives the same types out, so the specialising interpreter can stay specialised).

  6. 6

    Vectorise. For numeric work, move the loop out of Python entirely: NumPy applies an operation to a whole array in compiled C (Topic 13.9, Topic 14.3), often tens to hundreds of times faster than a Python loop over the same numbers. When no library does the job, the next steps are: Cython, Numba (compiles numeric Python functions with a JIT), a C or Rust extension (PyO3), or running on PyPy, an alternative interpreter with a mature JIT. For parallel work see Phase 12. The order is always: algorithm, built-ins, caching, vectorise, then native code.

Explain it without notes

01

Give four general techniques for making Python code faster, in the order you would try them.

02

Why is x in some_set faster than x in some_list, and when is the list still fine?

03

Why is NumPy faster than a Python loop for numeric work?

04

What can go wrong with functools.lru_cache?

Practice

01

Make allowed = list(range(2000)). Count how many of the numbers 1999, 5 and 5000 are in allowed, first by testing against the list and then against set(allowed). Print the two counts.

02

Count the words in "a b a c b a" with a plain dict and .get, and print the dict. Then do it with Counter and print the two most common words.

03

Write a recursive fib(n) decorated with lru_cache(maxsize=None). Print fib(30), then the number of cache misses and hits from fib.cache_info().

Trade-offs

  • ↔

    Speed versus readability. Clever tricks make code harder to read. Spend them only on measured hot spots and leave a comment saying what was measured.

  • ↔

    Memory versus time. Caches and look-up tables trade memory for speed, and generators trade a little speed for flat memory. State the budget you are optimising for.

  • ↔

    Rewriting in a faster language versus a faster design. A C or Rust extension is the biggest gain for a tight numeric kernel, but it adds a build step, packaging work and a second language to maintain.

Done when you can

  • Done when you can choose a better data structure for a slow lookup or queue and explain the growth difference.

  • Done when you can cache a pure function and check the hit rate.

  • Done when you can replace a list-building loop with a generator where the data is used once.

  • Done when you can explain why np.vectorize is not vectorisation.

  • Done when you can list the order of speed-up steps and say when to stop.