No history yet

Performance Optimization

Writing Faster Python

Writing code that works is the first step. Writing code that runs fast is the next. Python is often called a “slow” language, but that’s not the full story. With the right techniques, you can significantly speed up your programs. It’s not about rewriting everything, but about identifying bottlenecks and using the right tools to fix them.

Lean on the Built-ins

One of the easiest ways to speed up your code is to use Python's built-in functions and libraries. These functions are often written in C, a much lower-level language, and are highly optimized for performance. When you write a for loop in Python to do something simple, you're adding overhead that a built-in function might not have. Before you write a custom loop, always check if there's a built-in that does the job.

Use built-in functions and libraries- Python has a lot of built-in functions and libraries that are highly optimized and can save you a lot of time and resources.

List comprehensions are another classic example. They aren't just a more 'Pythonic' way to create lists; they are generally faster than manually appending to a list inside a for loop. This is because the list's final size can be better estimated, and the .append() method doesn't need to be called on each iteration.

# Slower: using a for loop
squares = []
for i in range(10000):
    squares.append(i * i)

# Faster: using a list comprehension
squares_comp = [i * i for i in range(10000)]

Don't Repeat Yourself

Some functions are computationally expensive. If you find yourself calling the same function with the same arguments multiple times, you're wasting CPU cycles. The solution is caching, a technique where you store the results of function calls and return the saved result when the same inputs occur again. When applied to functions, this is often called memoization.

Memoization

noun

An optimization technique used primarily to speed up computer programs by storing the results of expensive function calls and returning the cached result when the same inputs occur again.

The classic example is calculating Fibonacci numbers recursively. A naive implementation is incredibly inefficient because it recalculates the same values over and over. Fortunately, Python's standard library makes memoization easy.

import functools

# The @lru_cache decorator automatically stores
# the results of the function.
@functools.lru_cache(maxsize=None)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

# The first call to fibonacci(30) will be slow.
print(fibonacci(30))

# The second call is nearly instant because
# the result is pulled from the cache.
print(fibonacci(30))

Drop Down to C

Sometimes, even with clever optimizations, a small part of your Python code is still a major bottleneck. For these performance-critical sections, you can rewrite them in a language that compiles to fast machine code, like C. This might sound intimidating, but a tool called Cython makes it surprisingly accessible.

Cython is a programming language that is a superset of Python. It lets you write Python-like code that can be translated directly into optimized C code. You can add static type declarations to your variables, which allows Cython to bypass Python's dynamic typing overhead and generate much faster code.

# This looks like Python, but it's a Cython file (.pyx)
# The 'cdef' keyword declares C-level variables.
def sum_squares(int n):
    cdef int i
    cdef long long total = 0
    for i in range(n):
        total += i * i
    return total

You don't need to rewrite your whole application. You can use Cython just for the one or two functions that are slowing you down and import them into your regular Python code like any other module. This gives you the performance of C where it matters, with the ease of Python everywhere else.

Parallel Processing

Modern computers have multiple CPU cores, but a standard Python program only uses one at a time. This is because of the Global Interpreter Lock (GIL), a mechanism that prevents multiple native threads from executing Python bytecodes at the same time. This simplifies memory management but limits performance for CPU-bound tasks.

To truly run code in parallel and use all your CPU cores, you need to use multiprocessing. The multiprocessing module sidesteps the GIL by creating new processes, each with its own Python interpreter and memory space. This is ideal for tasks that are CPU-bound, meaning they spend most of their time doing computations rather than waiting for external resources.

A task is CPU-bound if its speed is limited by the CPU. A task is I/O-bound if its speed is limited by waiting for input/output operations, like reading a file or making a network request.

For tasks that are I/O-bound, multiprocessing is overkill. While one process is waiting for a network response, the CPU is just sitting idle. This is where asyncio comes in. It uses a single thread to manage many tasks by switching between them whenever one is waiting for I/O. This approach, called cooperative multitasking, allows a program to handle thousands of connections concurrently without the overhead of creating thousands of threads or processes.

Choosing the right tool depends on the nature of your bottleneck. For crunching numbers, use multiprocessing. For juggling web requests or database queries, use asyncio.

Quiz Questions 1/6

Why are Python's built-in functions generally faster than an equivalent for loop written in pure Python?

Quiz Questions 2/6

Which technique involves storing the results of expensive function calls and returning the cached result when the same inputs occur again?

Optimizing code is a deep topic, but these techniques provide a powerful toolkit. By using built-ins, caching results, dropping to C with Cython when needed, and choosing the right concurrency model, you can make your Python applications significantly faster.