Performance and profiling
Measure before you optimize, then fix the part that matters.
Create a free account Try part of it here
Why it matters
Slow software costs money and users. Model calls add seconds and dollars to every request. But the most common performance mistake is optimizing the wrong thing: rewriting a loop that takes 1% of the time while the real cost sits elsewhere. Professionals measure first, fix the biggest cost, and measure again. Caching, the main tool here, is also how LLM apps cut cost.
What you'll be able to do
- Time code reliably and profile it to find where the time really goes
- Rank the fixes: algorithm first, then repeated work, I/O, and finally raw speed
- Spot and fix the N+1 query problem
- Build an LRU cache and explain when caching helps and when it hurts
What it covers
- Measure first
- Profile to find the hot spot
- The fixes, biggest first
- Caching
- Memory: lists versus generators
- How to compare two versions fairly
- When to stop
Measure first
You could sand down a car's door handles to save weight while the trunk is full of bricks. Programmers are famously bad at guessing where time goes. Donald Knuth's line, "premature optimization is the root of all evil", is about this: changing code for speed before you know it's slow makes it harder to read, often for nothing.
For small pieces of code, use timeit. It runs the code many times and reports the total, which smooths out noise:
import timeit words = ["tent", "mug", "boot", "lantern"] * 250 def concat(): s = "" for w in words: s += w + " " return s def joined(): return " ".join(words) for f in (concat, joined): best = min(timeit.repeat(f, number=200, repeat=3)) print(f"{f.__name__:7} {best * 1000:6.1f} ms for 200 runs")
Take the minimum of several repeats. Slower runs are usually the machine doing something else, not your code.
Most code doesn't need to be fast. Find the bit that does.
Profile to find the hot spot
An itemized bill shows you where the money actually went. For a whole program, a profiler does that with time: it records how long each function takes. Python ships with cProfile:
import cProfile, pstats, io def load_rows(): return [{"id": i, "email": f"user{i}@shop.test"} for i in range(3000)] def find_duplicates(rows): emails = [r["email"] for r in rows] return [e for e in emails if emails.count(e) > 1] # count() walks the list each time def report(): rows = load_rows() return len(find_duplicates(rows)) profiler = cProfile.Profile() profiler.enable() report() profiler.disable() out = io.StringIO() pstats.Stats(profiler, stream=out).sort_stats("cumulative").print_stats(5) print(out.getvalue()[:1200])
Read the cumtime column (time inside a function, including what it calls). Nearly all of it sits in find_duplicates, and inside that, in thousands of calls to count. That one line is O(n²). Loading the rows barely registers.
In plain words: don't guess where the time goes. Run a profiler, and fix the biggest number first.
call_model and 3% in format_report. Where should you spend your effort?Show the answer
On the model calls: fewer, smaller, cached or concurrent
Even making format_report infinitely fast saves only 3%. The big win is where the time actually is.
The fixes, biggest first
- A better algorithm or data structure. O(n²) to O(n) beats any amount of tuning. In the profile above, a
Counteror a set fixes it. - Stop repeating work. Move calculations out of loops, and cache results you'll need again.
- Batch your I/O. One trip to the store with a list beats 100 trips for one item each. One request for 100 rows beats 100 requests for one row. Network and disk round trips dominate (remember the speed ladder).
- Overlap the waiting. Run independent I/O concurrently.
- Use faster building blocks. NumPy, built-in functions and compiled libraries.
- Only then consider a faster language for a hot loop.
The N+1 query problem
A classic case of number 3. A page lists 50 orders, and for each one the code runs another query for its customer: 1 query for the list plus N for the details. Each query is a round trip, like walking to the filing room once per order. So the page gets slower with every row. One query with a join, or one IN (...) query, does it in one or two trips:
import sqlite3 db = sqlite3.connect(":memory:") db.executescript(""" CREATE TABLE customers (id INTEGER PRIMARY KEY, email TEXT); CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, total REAL); """) db.executemany("INSERT INTO customers VALUES (?, ?)", [(i, f"c{i}@shop.test") for i in range(50)]) db.executemany("INSERT INTO orders VALUES (?, ?, ?)", [(i, i % 50, 10.0 + i) for i in range(50)]) queries = 0 def q(sql, args=()): global queries queries += 1 return db.execute(sql, args).fetchall() # N+1: one query for the orders, then one per order. for oid, cid, total in q("SELECT id, customer_id, total FROM orders"): q("SELECT email FROM customers WHERE id = ?", (cid,)) print("N+1 version:", queries, "queries") queries = 0 rows = q("SELECT o.id, c.email, o.total FROM orders o JOIN customers c ON c.id = o.customer_id") print("join version:", queries, "query for", len(rows), "rows")
ORMs make N+1 easy to write by accident, because order.customer.email looks like an attribute but runs a query.
Caching
You keep the takeout menu on the fridge so you don't have to look it up every time. A cache keeps results you've already computed or fetched so the next request is instant. It works when the same requests come back often and the answer doesn't change too quickly.
Caches need a size limit, or they grow until memory runs out. The most common rule is LRU: when full, throw away the least recently used entry. Python has one built in:
from functools import lru_cache import time @lru_cache(maxsize=128) def product_info(sku): time.sleep(0.05) # pretend this is a slow API call return {"sku": sku, "price": 249} start = time.perf_counter() for _ in range(20): product_info("TENT-2") # only the first call is slow print(round(time.perf_counter() - start, 2), "seconds for 20 calls") print(product_info.cache_info())
There are only two hard things in computer science, goes the old joke: cache invalidation and naming things. That menu on your fridge may have last year's prices. A cache can serve stale data after the source changes. Common answers are a TTL (time to live: each entry expires after so many seconds) or clearing entries whenever the data is written.
LLM apps cache at several levels: whole responses for repeated questions, embeddings for documents that haven't changed, and the provider's prompt caching, which reuses the work on a long, unchanged prompt prefix.
lru_cache remembers results by argument. What does this print?from functools import lru_cache calls = 0 @lru_cache(maxsize=None) def price(sku): global calls calls += 1 return len(sku) for s in ["a", "bb", "a", "a", "bb"]: price(s) print(calls)
Show the answer
2
There are only 2 distinct skus, "a" and "bb". Only the first call for each runs the function, so calls ends at 2. The other three come from the cache.
Memory: lists versus generators
A list holds every item at once. A generator makes each item when it's asked for, so a pipeline over a huge file uses almost no memory.
import sys squares_list = [n * n for n in range(100_000)] squares_gen = (n * n for n in range(100_000)) print("list: ", sys.getsizeof(squares_list), "bytes (plus the numbers themselves)") print("generator:", sys.getsizeof(squares_gen), "bytes") print(sum(squares_gen) == sum(squares_list))
The generator is a tiny object no matter how many items it will produce. The trade: you can only loop over it once, and you can't index it. For "read each line, transform it, write it out" over a 10 GB log file, generators are the right tool.
Show the answer
When almost every request is different, or the data changes every second
A cache only pays off when requests repeat and answers stay valid. Otherwise it costs memory and risks stale data for few hits.
How to compare two versions fairly
Timing a single run tells you very little. Other programs are running on your computer, and small differences come and go. So run each version many times, and take the fastest or the median, since the slow runs are mostly noise from outside your code. If the difference is only a few percent, it's probably not real, and it's probably not worth more complicated code anyway.
Measure the whole request before you tune a small piece. Say a page takes 800 ms: 700 ms in the database, 95 ms in the model call, and 5 ms in your Python. Making that Python ten times faster saves under 5 ms, and nobody will ever notice. Fixing the query might save 600 ms.
Costs you can't see
One line can hide a query. With an object-relational mapper, order.customer.name looks like reading a field. It may run a database query, and inside a loop over 500 orders, that's 501 queries. This is called the N+1 problem, and it's among the most common causes of slow web apps. Your profiler will show the same call hundreds of times. The fix is to load the related rows in one query.
A cache without a plan is a bug in waiting. A cache makes things fast by serving old answers. Before adding one, ask two questions: how old may this answer be, and what happens when the data changes? A price that's five minutes stale may be fine. A permission that's five minutes stale may not be. If you can't answer both questions, don't cache it yet.
When to stop
Nobody polishes the underside of a table. Stop once it's fast enough for its users. Further tuning costs time and clarity, and nobody will notice.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 6 review cards for spaced repetition
Try a quiz question
What's the N+1 query problem?