Index Ai Academy
Phase 7 · Software engineering · How computers run code

Performance and profiling

Measure before you optimize, then fix the part that matters.

About 35 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Measure first
  2. Profile to find the hot spot
  3. The fixes, biggest first
  4. Caching
  5. Memory: lists versus generators
  6. How to compare two versions fairly
  7. 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")
It runs right here in your browser, with nothing to install.

Take the minimum of several repeats. Slower runs are usually the machine doing something else, not your code.

Try it now

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.

Quick check
A profile shows 92% of the time in call_model and 3% in format_report. Where should you spend your effort?
  1. Split the effort evenly
  2. On the model calls: fewer, smaller, cached or concurrent
  3. On format_report, because it's your own code
  4. Nowhere, profiles aren't reliable
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

  1. A better algorithm or data structure. O(n²) to O(n) beats any amount of tuning. In the profile above, a Counter or a set fixes it.
  2. Stop repeating work. Move calculations out of loops, and cache results you'll need again.
  3. 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).
  4. Overlap the waiting. Run independent I/O concurrently.
  5. Use faster building blocks. NumPy, built-in functions and compiled libraries.
  6. 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.

Quick check
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)
  1. 5
  2. 1
  3. 2
  4. 3
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.

Worked example

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))
In the lesson you can change this and run it.

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.

Quick check
When does caching help least?
  1. When a slow API returns the same exchange rates all hour
  2. When almost every request is different, or the data changes every second
  3. When an embedding is computed for a document that rarely changes
  4. When the same product page is requested thousands of times
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

Create a free account

Try a quiz question

What's the N+1 query problem?

  1. Running one query for a list, then one more query per item, instead of one combined query
  2. A query that returns one row more than the limit you asked for, so that the last row has to be thrown away
  3. Adding one more index to a table than it needs, so that every write gets slower without helping any read
  4. A loop that runs one extra time, so that the code asks the database for a row that doesn't exist at the end

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson