AI Hints
Home›Python Tutorial›Under the Hood: Performance and Memory
Python · Lesson 42 of 49

Performance and Memory

Make it fast, and understand why.

⏱ 2 min read▶ Video at 2:06:36🎓 Free
Watch this lesson in the Python courseWatch on YouTube ↗Full Python course

Ava's order report takes a minute to run, and she wonders why. The golden rule of performance is, measure first. Most of the time, a tiny part of the program uses nearly all of the time. Find that part with a profiler, fix only that, and leave the rest alone. Guessing wastes hours.

Performance and Memory
From the video: Performance and Memory
  • The report takes a minute
  • Measure before you optimise
  • Fix the real bottleneck

Profiling with cProfile

Python includes a profiler. It records how many times each function runs, and how long it takes. Here, a program has two functions, and one of them is slow. The report, sorted by time, points straight to the culprit. Now you know exactly where to look.

profile1.pypython
import cProfile, pstats, io

def slow():
    return sum(i * i for i in range(300_000))

def fast():
    return 42

def main():
    for _ in range(5):
        slow()
        fast()

pr = cProfile.Profile()
pr.enable(); main(); pr.disable()
s = io.StringIO()
pstats.Stats(pr, stream=s).sort_stats("cumtime").print_stats(3)
print([l for l in s.getvalue().splitlines() if "slow" in l][0].split()[0])
Output
5

Common speed-ups

Many speed ups are simple. Move work out of loops. Use the right data structure, a set for membership. Use built in functions, like sum and join, which run in fast C code. Build strings with join, not repeated plus. And cache results of expensive pure functions. Here, we compare two ways of building a string.

speedup.pypython
import timeit

words = ["leaf"] * 2000

def concat():
    s = ""
    for w in words:
        s += w + " "
    return s

def joined():
    return " ".join(words) + " "

print(concat() == joined())
a = timeit.timeit(concat, number=200)
b = timeit.timeit(joined, number=200)
print("join is faster:", b < a)
Output
True
join is faster: True

How Python stores objects

In Python, a variable is a name, pointing to an object. The id function shows the identity. Two names can point to the same object, and the is operator checks that. Memory is managed by reference counting. When nothing points to an object any more, it is freed. The sys module reveals the count, and the size.

refs.pypython
import sys

a = [1, 2, 3]
b = a
c = [1, 2, 3]
print(a is b, a is c, a == c)
b.append(4)
print(a)
print(sys.getrefcount(a) >= 3)
del b
print(sys.getsizeof([]), sys.getsizeof(list(range(100))) > 800)
Output
True False True
[1, 2, 3, 4]
True
56 True

Saving memory with slots and generators

Every normal object carries a dictionary of attributes, which uses memory. Declaring slots tells Python the exact attributes, and drops the dictionary. For millions of small objects, it saves a lot. And generators, as we saw, avoid holding everything at once. The tracemalloc module shows real memory use.

slots.pypython
import tracemalloc

class A:
    def __init__(self, x): self.x = x

class B:
    __slots__ = ("x",)
    def __init__(self, x): self.x = x

def measure(cls):
    tracemalloc.start()
    items = [cls(i) for i in range(20000)]
    cur, _ = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    return cur

print("slots use less:", measure(B) < measure(A))
Output
slots use less: True

Optimisation rules of thumb

A few rules guide you. First make it correct, then make it clear, and only then, make it fast, if you need to. Measure before and after. Prefer better algorithms over clever tricks. Use NumPy for number crunching, and processes for heavy work. And remember, developer time is usually more expensive, than computer time.

Correct, clear, then fastin that order
Measure before and afterprofile, do not guess
Better algorithm winsO(n) beats a clever O(n²)
Right tool for the jobNumPy, sets, generators, processes

Time two approaches

Your turn. Use timeit, to compare building a list of squares, with a loop, and with a comprehension. Print True, if the comprehension is not slower. Ready? Here is one solution. Two small functions, two timings, and a comparison.

✍️ Exercise: Time two approaches

Use timeit to compare a loop and a list comprehension for squares of range(1000).

Show hint

timeit.timeit(function, number=500)

Show solution
time_two.pypython
import timeit

def loop():
    out = []
    for i in range(1000):
        out.append(i * i)
    return out

def comp():
    return [i * i for i in range(1000)]

a = timeit.timeit(loop, number=500)
b = timeit.timeit(comp, number=500)
print(b <= a * 1.2)
Output
True

Summary

Good work. You can profile to find bottlenecks, apply simple speed ups, understand references and memory, and save memory with slots. Next, two advanced features that power many frameworks, descriptors, and metaclasses.

Key takeaways
  • Profile first, then fix
  • join, sets, builtins are fast
  • __slots__ and generators save memory
Next up: Descriptors and Metaclasses — how frameworks work their magic