Optimisation Techniques

Once the algorithm is right, a handful of techniques reliably help: build less, look up less, let C do the loop, and cache what repeats.

First: is it worth it?

# A function called once, taking 50 milliseconds:
#   optimising it to 5 ms saves 45 ms. Nobody notices.
#
# A function called a million times, taking 50 microseconds:
#   optimising it to 5 us saves 45 seconds. Everyone notices.

Multiply the saving by the call count before spending time on it. Then confirm with a profile that the function is actually hot.

Build less

import timeit

setup = "data = list(range(100_000))"

# Builds a full list, then throws it away
print(min(timeit.repeat("sum([n * n for n in data])", setup=setup, number=20, repeat=3)))

# Builds nothing
print(min(timeit.repeat("sum(n * n for n in data)", setup=setup, number=20, repeat=3)))
import timeit

setup = "data = list(range(200_000))"

print(min(timeit.repeat("any(n > 199_000 for n in data)", setup=setup,
                        number=100, repeat=3)))       # stops early

print(min(timeit.repeat("len([n for n in data if n > 199_000]) > 0", setup=setup,
                        number=100, repeat=3)))       # examines everything

any, all and next stop at the first decisive value. Feeding them a generator rather than a list means the remaining work never happens.

Look up less

import timeit

setup = "data = list(range(500_000))"

# An attribute lookup on every iteration
code_a = """
result = []
for n in data:
    result.append(n * 2)
"""

# The method bound once
code_b = """
result = []
append = result.append
for n in data:
    append(n * 2)
"""

# No lookup at all
code_c = "result = [n * 2 for n in data]"

for label, code in [("attribute", code_a), ("bound", code_b), ("comprehension", code_c)]:
    print(f"{label:<16}{min(timeit.repeat(code, setup=setup, number=10, repeat=3)):.4f}s")
import math

# A global lookup on every call
def distance_slow(points):
    return [math.sqrt(x * x + y * y) for x, y in points]


# Bound to a local once
def distance_fast(points, _sqrt=math.sqrt):
    return [_sqrt(x * x + y * y) for x, y in points]
This works because local names compile to LOAD_FAST, an array index, while globals and attributes need a dictionary lookup. It is a real gain in a tight loop and pure noise anywhere else - so do it only where a profile says it matters.

Let C do the loop

import timeit

setup = "data = list(range(200_000))"

print(min(timeit.repeat("""
total = 0
for n in data:
    total += n
""", setup=setup, number=50, repeat=3)))

print(min(timeit.repeat("total = sum(data)", setup=setup, number=50, repeat=3)))
import timeit

setup = 'data = [str(n) for n in range(100_000)]'

print(min(timeit.repeat("result = [s.upper() for s in data]",
                        setup=setup, number=20, repeat=3)))
print(min(timeit.repeat("result = list(map(str.upper, data))",
                        setup=setup, number=20, repeat=3)))
Instead of a loopUse
Adding valuessum()
Finding the largestmax(), min()
Applying one existing functionmap()
Countingcollections.Counter
Joining stringsstr.join()
Checking any or allany(), all()
Sortingsorted()
Running totalsitertools.accumulate

These are implemented in C, so the loop runs without interpreter overhead per iteration. The gain is typically two to five times, and the code is usually shorter.

Cache what repeats

from functools import cache, lru_cache
import time


@cache
def expensive(n):
    time.sleep(0.05)
    return n * n


start = time.perf_counter()
for _ in range(20):
    expensive(4)
print(f"{time.perf_counter() - start:.3f}s")       # one computation, not twenty
print(expensive.cache_info())
from functools import cached_property


class Report:
    def __init__(self, rows):
        self.rows = rows

    @cached_property
    def summary(self):
        print("  computing summary")
        return {"count": len(self.rows), "total": sum(self.rows)}


r = Report([1, 2, 3])
print(r.summary)
print(r.summary)          # no recomputation
# Hoist an invariant out of the loop
import re

# Recompiles, and rebuilds the set, on every iteration
def slow(lines, words):
    return [l for l in lines if re.search(r"\d+", l) and set(words) & set(l.split())]


# Both hoisted out
PATTERN = re.compile(r"\d+")

def fast(lines, words):
    targets = set(words)
    return [l for l in lines if PATTERN.search(l) and targets & set(l.split())]

Avoid repeated work in a loop

import timeit

setup = "data = list(range(100_000))"

# len() recalculated every iteration
code_a = """
result = []
for i in range(len(data)):
    result.append(data[i] * 2)
"""

# Iterate directly, no indexing
code_b = """
result = []
for value in data:
    result.append(value * 2)
"""

for label, code in [("index", code_a), ("direct", code_b)]:
    print(f"{label:<10}{min(timeit.repeat(code, setup=setup, number=20, repeat=3)):.4f}s")
import timeit

setup = 'names = [f"user{i}" for i in range(2000)]'

# Formats and concatenates repeatedly
code_a = """
text = ""
for name in names:
    text += f"<li>{name}</li>"
"""

# One join
code_b = 'text = "".join(f"<li>{name}</li>" for name in names)'

for label, code in [("concatenate", code_a), ("join", code_b)]:
    print(f"{label:<14}{min(timeit.repeat(code, setup=setup, number=100, repeat=3)):.4f}s")

Choose the right tool for numbers

import timeit
import array

# A list of ints stores pointers to int objects
print(timeit.timeit("sum(data)", setup="data = list(range(100_000))", number=200))

# array stores raw machine values: less memory, similar speed for sums
print(timeit.timeit("sum(data)",
                    setup="import array; data = array.array('i', range(100_000))",
                    number=200))

import sys
print(sys.getsizeof(list(range(100_000))))
print(sys.getsizeof(array.array("i", range(100_000))))

Do the work once, not per item

import time

records = [{"id": i, "dept": f"d{i % 50}"} for i in range(20_000)]
departments = {f"d{i}": f"Department {i}" for i in range(50)}

# A lookup per record, but the dictionary is built once - fine
start = time.perf_counter()
labelled = [{**r, "label": departments[r["dept"]]} for r in records]
print(f"dict lookup: {time.perf_counter() - start:.4f}s")

# Rebuilding the lookup inside the comprehension - not fine
start = time.perf_counter()
labelled = [
    {**r, "label": {f"d{i}": f"Department {i}" for i in range(50)}[r["dept"]]}
    for r in records
]
print(f"rebuilt:     {time.perf_counter() - start:.4f}s")

Batch input and output

import time

lines = [f"line {i}\n" for i in range(50_000)]

# One write call per line
start = time.perf_counter()
with open("out_a.txt", "w", encoding="utf-8") as handle:
    for line in lines:
        handle.write(line)
print(f"per line: {time.perf_counter() - start:.4f}s")

# One call
start = time.perf_counter()
with open("out_b.txt", "w", encoding="utf-8") as handle:
    handle.writelines(lines)
print(f"batched:  {time.perf_counter() - start:.4f}s")

The same principle applies far beyond files: one database query returning a thousand rows beats a thousand queries, and one network request beats a hundred. Batching usually matters more than anything else in this note.

When Python itself is the limit

SituationAnswer
Waiting on files or the networkThreads or asyncio
Pure computation across coresmultiprocessing
Heavy numeric workA library that loops in C
One very hot functionRewrite that function only
Everything is slowThe algorithm is wrong, not the language

Readability is a cost too

# Clear
def average(values):
    return sum(values) / len(values)


# Marginally faster, considerably worse
def average_micro(values, _sum=sum, _len=len):
    return _sum(values) / _len(values)

The second version saves a fraction of a microsecond and costs every future reader a moment of confusion. Reserve tricks like this for the small number of functions a profile has identified, and add a comment saying why.

Common mistakes

  • Optimising code that a profiler has not identified as hot.
  • Applying micro-optimisations while an O(n²) algorithm remains.
  • Caching a function whose result depends on changing state.
  • Building a large list where a generator would do.
  • Rebuilding a constant lookup inside a loop.
  • Making code unreadable for an unmeasurable gain.
  • Forgetting to verify that the optimised version still produces the same output.

Best practices

  • Profile first, and optimise only the hot spot.
  • Improve the algorithm before the constants.
  • Prefer built ins and comprehensions to hand written loops.
  • Use generators when the result is consumed once.
  • Hoist invariants out of loops and cache pure functions.
  • Batch input and output, database calls and network requests.
  • Keep a test that proves the fast version still agrees with the slow one.

Practice

  1. Take a nested loop over two lists and make it linear with a set or a dictionary.
  2. Replace a hand written accumulation loop with a built in and measure the difference.
  3. Find an invariant computed inside a loop in your own code and hoist it out.
  4. Add functools.cache to a repeatedly called pure function and measure the effect.
  5. Convert a per line file write into a batched one and compare the timings.

Conclusion

Build less, look up less, let C run the loop, cache what repeats and batch what is expensive. Apply these only where a profile points, verify the output is unchanged, and stop as soon as it is fast enough - readability has value that does not show up in a benchmark.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Python notes →
Python

Trees and Graphs

A tree is a graph with no cycles and one root. Both are walked with the same two strategies - depth first with a stack, breadth first with a queue.

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.