PYTH-250 · Lesson 12 of 12 · 22 min

Capstone: speeding up a slow program

▶ Video coming soon A video version of this lesson is on the way.

The course ends where it began: with a slow program and the temptation to start rewriting it. This time Jordan has the method and the tools. Rosa has asked for a new year-end report that combines everything Ridgeline knows: every sale since the shop opened, priced in Canadian or US dollars depending on where the customer lives, matched to the product list, flagged when a customer has bought the same coffee more than five times so Mei can send a thank-you card, and summarized by month and by coffee. Jordan's first version, written quickly and correctly, takes nineteen minutes on the real data. The goal agreed with Rosa is under thirty seconds.

This capstone works through the whole optimization loop from lesson 1 on that program: set the goal, build a reproducible benchmark, profile, fix the biggest cost, check the results are unchanged, measure again, and repeat until the goal is met. Each fix uses a technique from an earlier lesson. The exercises then ask you to make the same kinds of change yourself, with tests that check both that your answers are correct and that your code does far less work.

The slow first version

Here is the heart of Jordan's first version, scaled down so it runs in a moment. It loops over sales, scans the product list for each sale's coffee, fetches the exchange rate for each US sale, checks for repeat customers by counting through all their previous purchases, and builds monthly totals. Every part is correct, and every part does far more work than it needs to.

The slow version, instrumented to count its work
import random

rnd = random.Random(42)
PRODUCTS = [{"sku": f"RC-{n}", "price_cad": round(16 + n % 7 * 0.75, 2)} for n in range(101, 401)]
CUSTOMERS = [f"555-{n:04d}" for n in range(50)]
SALES = [{"day": f"2026-{rnd.randint(1, 12):02d}-{rnd.randint(1, 28):02d}",
          "sku": f"RC-{rnd.randint(341, 400)}", "qty": rnd.randint(1, 3),
          "phone": rnd.choice(CUSTOMERS), "country": rnd.choice(["CA", "CA", "US"])}
         for _ in range(6000)]
WORK = {"product_scans": 0, "rate_fetches": 0, "history_checks": 0}

def fetch_rate(currency):                       # pretend: a slow web request
    WORK["rate_fetches"] += 1
    return {"CAD": 1.0, "USD": 0.73}[currency]

def year_end_slow(sales):
    monthly, loyal, seen = {}, set(), []
    for s in sales:
        product = None
        for p in PRODUCTS:                       # O(products) scan per sale
            WORK["product_scans"] += 1
            if p["sku"] == s["sku"]:
                product = p
                break
        rate = fetch_rate("USD" if s["country"] == "US" else "CAD")
        amount = round(product["price_cad"] * rate * s["qty"], 2)
        month = s["day"][:7]
        monthly[month] = round(monthly.get(month, 0) + amount, 2)
        seen.append((s["phone"], s["sku"]))
        WORK["history_checks"] += len(seen)      # seen.count scans the whole history
        if seen.count((s["phone"], s["sku"])) > 5:
            loyal.add(s["phone"])
    return monthly, sorted(loyal)

monthly, loyal = year_end_slow(SALES)
print("months:", len(monthly), "| January:", monthly["2026-01"], "| loyal customers:", len(loyal))
for k, v in WORK.items():
    print(f"{k:15} {v:>12,}")
Output
months: 12 | January: 15838.56 | loyal customers: 27
product_scans      1,622,022
rate_fetches           6,000
history_checks    18,003,000

The work counters tell the story before any profiler runs. For 6,000 sales, the program scans more than one and a half million product entries, makes 6,000 rate requests when only two different rates exist, and examines 18 million history entries to spot repeat buyers. On the real data, with 150,000 sales, the history check alone grows with the square of the number of sales, which is why the report takes nineteen minutes.

Step 1: goal and benchmark

Jordan writes the goal into the benchmark script: the full report on 150,000 sales in under thirty seconds, with output identical to the slow version's. The benchmark generates sales with a fixed seed, as in lesson 1, runs both the old and the new function, compares their results, and prints the work counters and the best of three timings. Keeping the slow version around as a reference is important: it is the definition of the correct answer, and every optimized version must match it exactly.

A small benchmark helper: best of several runs, plus the result to compare
import time

def benchmark(func, sales, repeat=3):
    """Best time of several runs, plus the result for comparison."""
    best, result = float("inf"), None
    for _ in range(repeat):
        start = time.perf_counter()
        result = func(sales)
        best = min(best, time.perf_counter() - start)
    return best, result

def total_bags(sales):
    return sum(s["qty"] for s in sales)

sales = [{"qty": q % 3 + 1} for q in range(100_000)]
t, r = benchmark(total_bags, sales)
print(f"result {r:,} in {t * 1000:.1f} ms (best of 3)")
Output
result 199,999 in 5.6 ms (best of 3)

Step 2: profile

A profile of the full report on the real data confirms the counters. seen.count, inside the repeat-customer check, has by far the largest tottime, followed by the product scan in the inner loop, followed by fetch_rate, whose time is almost all waiting for the bank's server. Everything else, the month arithmetic and building the dictionaries, is noise. Amdahl's law says the history check must go first: removing it alone takes the report from nineteen minutes to under two, and nothing else could have done that.

A horizontal bar chart of time by function in the slow year-end report: seen.count 1,020 seconds, product scan 96 seconds, fetch_rate 22 seconds, everything else 4 seconds. After the fixes, the same chart shows a total of 1.1 seconds.
Where the nineteen minutes went, and what was left after the fixes

Step 3: fix the biggest cost first

Counting a customer's previous purchases of a coffee by scanning the whole history is an O(n) operation inside an O(n) loop, O(n²) overall. The question it answers, how many times has this customer bought this coffee so far, is a counting problem, and lesson 3's Counter answers it in O(1) per sale. The same lesson's dictionary index replaces the product scan, and lesson 10's caching means the two exchange rates are fetched once each instead of 6,000 times.

The fast version: an index, a Counter and a cache
import random
from collections import Counter
from functools import lru_cache

rnd = random.Random(42)
PRODUCTS = [{"sku": f"RC-{n}", "price_cad": round(16 + n % 7 * 0.75, 2)} for n in range(101, 401)]
CUSTOMERS = [f"555-{n:04d}" for n in range(50)]
SALES = [{"day": f"2026-{rnd.randint(1, 12):02d}-{rnd.randint(1, 28):02d}",
          "sku": f"RC-{rnd.randint(341, 400)}", "qty": rnd.randint(1, 3),
          "phone": rnd.choice(CUSTOMERS), "country": rnd.choice(["CA", "CA", "US"])}
         for _ in range(6000)]
WORK = {"rate_fetches": 0}

def fetch_rate(currency):
    WORK["rate_fetches"] += 1
    return {"CAD": 1.0, "USD": 0.73}[currency]

@lru_cache(maxsize=None)
def rate_for(currency):                          # lesson 10: fetch each rate once
    return fetch_rate(currency)

def year_end_fast(sales):
    by_sku = {p["sku"]: p for p in PRODUCTS}     # lesson 3: index built once
    bought = Counter()                           # lesson 3: running counts
    monthly, loyal = {}, set()
    for s in sales:
        product = by_sku[s["sku"]]
        rate = rate_for("USD" if s["country"] == "US" else "CAD")
        amount = round(product["price_cad"] * rate * s["qty"], 2)
        month = s["day"][:7]
        monthly[month] = round(monthly.get(month, 0) + amount, 2)
        key = (s["phone"], s["sku"])
        bought[key] += 1
        if bought[key] > 5:
            loyal.add(s["phone"])
    return monthly, sorted(loyal)

monthly, loyal = year_end_fast(SALES)
print("months:", len(monthly), "| January:", monthly["2026-01"], "| loyal customers:", len(loyal))
print("rate fetches:", WORK["rate_fetches"], "| product lookups: one per sale | history checks: one per sale")
Output
months: 12 | January: 15838.56 | loyal customers: 27
rate fetches: 2 | product lookups: one per sale | history checks: one per sale

The printed figures match the slow version exactly, which is the first thing to check after any optimization. The work, though, has collapsed: one dictionary lookup per sale instead of a scan, one counter update instead of a history search, and two rate fetches in total. On the real 150,000 sales the report now takes a little over one second.

Step 4: check, measure, decide

Correctness checks come before celebration. Jordan's test compares the fast and slow versions on several seeded datasets, including edge cases: a day with no sales, a customer who reaches the loyalty threshold on the very last sale, a sku with a price that rounds awkwardly. The tests also assert upper bounds on the work counters, so that a future change that reintroduces a scan fails loudly. Only then does he time the real report: 1.1 seconds, far inside the thirty-second goal.

Comparing the optimized version against the slow reference on several datasets
def check_same(slow, fast, datasets):
    for name, data in datasets.items():
        assert slow(data) == fast(data), f"results differ on {name}"
    return "all datasets match"

def slow_bags(sales):
    return {sku: sum(q for s, q in sales if s == sku) for sku, _ in sales}

def fast_bags(sales):
    totals = {}
    for sku, q in sales:
        totals[sku] = totals.get(sku, 0) + q
    return totals

datasets = {
    "empty": [],
    "one sale": [("RC-101", 2)],
    "repeats": [("RC-101", 2), ("RC-104", 1), ("RC-101", 3)],
}
print(check_same(slow_bags, fast_bags, datasets))
Output
all datasets match

With the goal met, the method says stop. Jordan notes two further ideas in the issue tracker instead of doing them: reading the sales file as a stream, which would matter only if the data grew tenfold, and fetching rates asynchronously, which no longer matters because only two are fetched. Neither would change what Rosa sees, and both would add complexity. Knowing when to stop optimizing is as much a part of the craft as knowing how to start.

Try it yourself

Remove the scan

revenue_by_sku(sales, products) should return a dictionary from sku to total revenue (price times quantity, rounded to 2 decimal places) for the given sales, a list of (sku, qty) pairs. The starter is correct but scans the product list for every sale.

Rewrite it so that it looks at each product only once: build a dictionary index first. The tests use a product list that counts how many entries are looked at.

def revenue_by_sku(sales, products):
    totals = {}
    for sku, qty in sales:
        for p in products:
            if p['sku'] == sku:
                totals[sku] = round(totals.get(sku, 0) + p['price'] * qty, 2)
                break
    return totals

print(len(revenue_by_sku(SALES, PRODUCTS)))

Try it yourself

Find repeat orders in linear time

repeated(ids) must return a sorted list of the order ids that appear more than once. The starter uses ids.count(i) for every id, which is O(n²).

Rewrite it in O(n) with collections.Counter, a dictionary or a set. The tests include 50,000 ids.

def repeated(ids):
    return sorted({i for i in ids if ids.count(i) > 1})

print(repeated(['A-1', 'A-2', 'A-1', 'A-3', 'A-2']))

Step 5: the same method elsewhere

The year-end report used indexing, counting and caching because those matched its problems. Other programs in Ridgeline's toolkit needed other tools, and the course has covered them all. Theo's shipping quotes were I/O-bound, so concurrency fixed them: threads in lesson 4, a thread pool in lesson 6, and finally an asyncio client with a semaphore and backoff in lesson 8. Kai's roast analysis was CPU-bound numeric work: multiprocessing in lesson 5 helped, and NumPy in lesson 9 helped far more. The year-long archive was too big for memory, so lesson 11's streaming kept memory flat. In every case the decision came from a measurement, not a hunch.

A decision tree. Start: measure and profile. Is the time spent waiting for I/O? Yes: use concurrency, threads or asyncio, with limits. No: is it repeated work? Yes: cache or index. No: is it numeric work on many values? Yes: vectorize with NumPy. No: is the algorithm worse than it needs to be? Yes: choose better data structures. Otherwise: use multiple processes. A side branch: does the data fit in memory? No: stream it in chunks.
Choosing a technique from what the measurement shows

Try it yourself

Cache the slow lookups

fetch_rate(currency) (provided) is slow and counts its calls in FETCHES. Write convert_all(amounts) taking a list of (amount, currency) pairs and returning the list of amounts converted to Canadian dollars, each round(amount / rate, 2), where the rate comes from fetch_rate.

Each currency must be fetched only once per program run, however many amounts use it: wrap the lookup in a function decorated with functools.lru_cache.

def convert_all(amounts):
    return [round(a / fetch_rate(c), 2) for a, c in amounts]

print(convert_all([(10.0, 'USD'), (20.0, 'CAD')]))

What you have learned

Performance work is a discipline with a few durable rules. Decide what fast enough means before you start. Measure with realistic data, repeat the measurement, and count work when timing is too noisy. Profile to find the hot spot, and remember that cumulative time points at call paths while own time points at the culprit. Fix the biggest cost first and expect diminishing returns, as Amdahl's law predicts. Prefer better algorithms and data structures to clever tricks. Use concurrency for waiting, processes or vectorized libraries for computing, caches for repeated work and streaming for data that does not fit. Check results after every change, and stop when the goal is met. Those rules will outlast every specific tool in this course, including the ones not yet invented.

Common mistakes

  • MistakeOptimizing before writing down a goal and a benchmark.

    InsteadSet a numeric goal and a reproducible benchmark first, so you know when you are done.

  • MistakeDeleting the slow version once the fast one works.

    InsteadKeep it as a reference and test that the fast version matches it.

  • MistakeFixing small costs first because they are easy.

    InsteadWork on the biggest cost the profile shows; small fixes cannot beat Amdahl's law.

  • MistakeUsing threads or processes when the real problem is an O(n²) algorithm.

    InsteadFix the algorithm first; more hardware cannot rescue quadratic growth.

  • MistakeKeeping on optimizing after the goal is met.

    InsteadStop, record further ideas, and spend the time on features or correctness.

  • MistakeNot testing edge cases after optimizing.

    InsteadCheck empty inputs, boundaries and rounding, where optimized code most often differs.

Summary and key terms

  • The optimization loop: goal, benchmark, profile, fix the biggest cost, verify, measure, repeat, stop.
  • Work counters and profiles expose the real costs before any code changes.
  • Quadratic patterns, such as list.count or scans inside loops, are the first targets; Counter and dictionary indexes remove them.
  • Caching removes repeated slow lookups; lru_cache makes it a one-line change.
  • The slow version is kept as a reference, and tests assert both identical results and bounded work.
  • I/O-bound problems need concurrency, CPU-bound numeric problems need NumPy or processes, large data needs streaming.
  • Every decision follows from measurement, and optimization stops when the goal is met.
optimization loop
The cycle of measuring, changing the biggest cost and measuring again.
reference implementation
A simple, trusted version used to check that optimized code gives the same results.
work counter
A counter added to code to measure how many operations it performs.
quadratic time
O(n²) behaviour, where doubling the input quadruples the work.
bottleneck
The part of a program that limits its overall speed.
performance budget
The maximum time or memory a task is allowed to use.
diminishing returns
Each further improvement giving less benefit than the one before.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Which part of the slow year-end report grew with the square of the number of sales?

    Show answer

    The repeat-customer check, which scanned the whole history with seen.count for every sale.

  2. Why keep year_end_slow after writing year_end_fast?

    Show answer

    It defines the correct answer, so tests can check the fast version matches it.

  3. A report meets its 30-second goal in 1.1 seconds. Should you parallelize it?

    Show answer

    No: the goal is met, and parallelism would add complexity for no visible benefit.

  4. Name the technique for each: waiting on many APIs, crunching sensor arrays, repeated identical lookups.

    Show answer

    Concurrency (threads or asyncio), NumPy vectorization, caching.

  5. How can a test guard against a scan being reintroduced?

    Show answer

    Count the work, for example items examined, and assert an upper bound.

  6. What should you do with optimization ideas you decide not to pursue?

    Show answer

    Record them, for example in the issue tracker, with the measurement that would make them worthwhile.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz