Come ottimizzare la performance del codice Python

Ottimizzare Python significa, prima di tutto, misurare. Le micro-ottimizzazioni senza dati portano spesso a codice più complesso e non necessariamente più veloce. L’approccio efficace è iterativo: misurare, individuare i colli di bottiglia, intervenire in modo mirato, ricontrollare con gli stessi strumenti e gli stessi dati.

1. Misura prima di ottimizzare

Prima di cambiare anche una singola riga, chiarisci tre cose: quali sono le metriche (tempo, throughput, latenza, memoria), quale carico è realistico e quale baseline stai usando. Un test “finto” può far ottimizzare la cosa sbagliata.

Benchmark affidabili con timeit

timeit riduce la variabilità dovuta a rumore di sistema, warm-up e caching. È ideale per confronti locali (funzioni piccole, frammenti di codice).

import timeit

setup = "from math import sqrt; data = list(range(1_000))"
stmt = "[sqrt(x) for x in data]"
print(timeit.timeit(stmt=stmt, setup=setup, number=10_000))

Profiling CPU: cProfile e pstats

Quando l’applicazione è grande, serve vedere dove passa davvero il tempo. cProfile offre una vista completa delle chiamate e del tempo cumulativo.

import cProfile, pstats

def main():
    # chiamata al tuo programma
    run()

with cProfile.Profile() as pr:
    main()

stats = pstats.Stats(pr).sort_stats("cumulative")
stats.print_stats(30)  # top 30

Profiling “a linea”: line_profiler

Se conosci già la funzione sospetta, un profiler per linea è spesso la scelta migliore: ti dice quali righe pesano di più. In produzione lo userai di rado, ma in fase di tuning è prezioso.

Profiling memoria: tracemalloc e strumenti dedicati

Ottimizzare solo la CPU può peggiorare la memoria e viceversa. Con tracemalloc puoi confrontare snapshot e capire quali punti allocano più oggetti.

import tracemalloc

tracemalloc.start()
run()
snapshot = tracemalloc.take_snapshot()
top = snapshot.statistics("lineno")

for stat in top[:10]:
    print(stat)

2. Migliora l’algoritmo prima del micro-tuning

La fonte principale di lentezza è quasi sempre l’algoritmo. Passare da O(n²) a O(n log n) o O(n) batte qualsiasi micro-ottimizzazione.

Riduci complessità e lavoro ripetuto

  • Evita loop annidati se puoi riformulare il problema con strutture indicizzate (set/dict).
  • Precalcola risultati invarianti invece di ricalcolarli in un ciclo.
  • Sposta fuori dal loop tutto ciò che non cambia (lookup, costanti, compilazioni regex, aperture file).
# Esempio: evitare ricerche O(n) ripetute
ids_da_tenere = set(ids_da_tenere)  # O(n) una volta
filtrati = [x for x in records if x.id in ids_da_tenere]  # membership O(1)

Scegli la struttura dati giusta

In Python la scelta fra list, tuple, set e dict influisce molto. Regola pratica:

  • list: sequenza ordinata, accesso per indice, append efficiente.
  • tuple: come list ma immutabile; spesso più leggera e hashable.
  • set: membership e deduplica veloci.
  • dict: mapping chiave-valore con lookup veloce.
  • collections.deque: code con pop/append a entrambe le estremità efficienti.
from collections import deque

q = deque()
q.append(1)
q.appendleft(0)
q.pop()
q.popleft()

3. Sfrutta built-in e libreria standard

Molte operazioni della libreria standard sono implementate in C e risultano più veloci di equivalenti scritti in Python puro.

Preferisci funzioni built-in e moduli come itertools

from itertools import islice

# Iterare su grandi stream senza creare liste intermedie
for x in islice(stream(), 0, 1_000_000):
    process(x)

Sorting, key function e precomputazioni

Il sorting in Python è molto ottimizzato. Spesso conviene usare key= per evitare confronti costosi o ripetuti.

# Evita di calcolare più volte una trasformazione costosa
items.sort(key=lambda x: x.normalized_name)

4. Riduci overhead nei loop “caldi”

Se un loop gira milioni di volte, anche piccoli overhead diventano importanti. Interventi tipici:

  • Riduci accessi a attributi e globali (assegnali a variabili locali).
  • Evita concatenazioni ripetute di stringhe: usa ''.join.
  • Minimizza chiamate a funzioni dentro loop se non necessarie.
# Esempio: portare lookup a variabile locale
append = result.append
for x in data:
    append(transform(x))

List comprehension vs loop esplicito

Le comprehension sono spesso più rapide e più leggibili quando l’operazione è semplice.

squares = [x * x for x in range(10_000)]

Generatori per evitare liste intermedie

Quando non serve materializzare tutto, i generatori riducono memoria e talvolta migliorano la cache locality.

total = sum(x * x for x in range(10_000_000))

5. Ottimizza I/O: file, rete e database

Molti programmi sono I/O-bound: la CPU attende disco, rete o database. In questi casi ottimizzare il codice CPU non cambia quasi nulla.

File: buffering e lettura a blocchi

def count_lines(path: str) -> int:
    n = 0
    with open(path, "rb", buffering=1024 * 1024) as f:
        for _ in f:
            n += 1
    return n

Database: batch, indici, query efficienti

  • Evita N query in un loop (problema N+1): usa join o fetch in batch.
  • Verifica gli indici nelle colonne filtrate/ordinate.
  • Misura la latenza di rete e il tempo sul DB separatamente.

Rete: connessioni persistenti e parallelismo I/O

Per molte richieste HTTP, una sessione persistente e l’async I/O possono aumentare molto il throughput.

# Esempio concettuale: asyncio per molte richieste I/O
import asyncio
import aiohttp

async def fetch(session, url):
    async with session.get(url) as r:
        return await r.text()

async def main(urls):
    async with aiohttp.ClientSession() as session:
        return await asyncio.gather(*(fetch(session, u) for u in urls))

6. Caching e memoization

Se richiami spesso la stessa funzione con gli stessi argomenti, un cache può essere un enorme moltiplicatore di performance.

lru_cache per funzioni pure

from functools import lru_cache

@lru_cache(maxsize=10_000)
def expensive(x: int) -> int:
    # computazione costosa deterministica
    return compute(x)

Attenzione: caching non è gratis. Valuta la memoria, l’eviction, e se gli input sono davvero ripetuti.

7. Vectorization e librerie numeriche

Quando il problema è numerico, spostare il lavoro in librerie native (NumPy, SciPy, Pandas) può dare accelerazioni enormi, perché il loop avviene in C/Fortran e sfrutta ottimizzazioni a basso livello.

import numpy as np

a = np.random.rand(1_000_000)
b = np.random.rand(1_000_000)

# Loop in C, molto più veloce che iterare in Python
c = a * 2.0 + b

Con Pandas, prova a preferire operazioni vettoriali a apply riga-per-riga. Se proprio serve una trasformazione complessa, valuta Numba o Cython.

8. Concorrenza e parallelismo: scegliere lo strumento giusto

Python offre più modelli di concorrenza. La scelta dipende dal tipo di carico:

Tipo di carico Scelta tipica Perché
I/O-bound (rete, file, DB) asyncio / threading La CPU è spesso in attesa: sovrapporre attese aumenta il throughput
CPU-bound puro (calcolo) multiprocessing / librerie native Il GIL limita i thread; processi separati o codice in C/NumPy aggirano il collo di bottiglia
Mix I/O + CPU pipeline (async + process pool) Separare fasi I/O e CPU migliora l’utilizzo delle risorse

multiprocessing per CPU-bound

from concurrent.futures import ProcessPoolExecutor

def work(x):
    return heavy_compute(x)

with ProcessPoolExecutor() as ex:
    results = list(ex.map(work, range(100)))

ThreadPool per I/O-bound

from concurrent.futures import ThreadPoolExecutor

def download(url):
    return fetch_sync(url)

with ThreadPoolExecutor(max_workers=32) as ex:
    pages = list(ex.map(download, urls))

9. Riduci allocazioni e migliora la memoria

Molte applicazioni rallentano perché allocano troppo (garbage collector, cache miss, pressione di memoria). Strategie utili:

  • Evita creare oggetti temporanei inutili; preferisci generatori dove appropriato.
  • Per classi con molti oggetti, valuta __slots__ per ridurre overhead.
  • In Python 3.10+ valuta dataclasses con slots=True per oggetti “leggeri”.
from dataclasses import dataclass

@dataclass(slots=True)
class Point:
    x: float
    y: float

Stringhe: join e buffering

parts = []
append = parts.append
for x in items:
    append(str(x))
out = ",".join(parts)

10. Compilazione e acceleratori: Cython, Numba, PyPy

Quando hai identificato una “hot path” che non puoi vectorizzare e che resta CPU-bound, puoi considerare strumenti che riducono l’overhead dell’interprete.

  • Numba: JIT per funzioni numeriche (spesso con NumPy), molto efficace per loop aritmetici.
  • Cython: compila in estensioni C; utile per performance massime e integrazione con C/C++.
  • PyPy: runtime alternativo con JIT; può accelerare alcune classi di programmi (non sempre compatibile con tutte le estensioni).
# Esempio concettuale Numba
from numba import njit

@njit
def dot(a, b):
    s = 0.0
    for i in range(a.size):
        s += a[i] * b[i]
    return s

11. Logging, debug e feature flag

Logging e debug possono diventare costosi se fatti in punti caldi o se costruiscono stringhe anche quando il livello non le stampa.

import logging
log = logging.getLogger(__name__)

# Evita f-string costose quando il livello non è attivo
log.debug("record=%s elapsed=%f", record_id, elapsed)

12. Checklist pratica di ottimizzazione

  1. Riproduci il problema con un input realistico.
  2. Misura (timeit per micro, cProfile per macro, memoria se serve).
  3. Trova il collo di bottiglia principale (non il più facile da cambiare).
  4. Prova prima: algoritmo, struttura dati, riduzione I/O, caching.
  5. Solo dopo: micro-ottimizzazioni nei loop caldi.
  6. Se serve: parallelismo (I/O vs CPU) o accelerazione con Numba/Cython/NumPy.
  7. Ricontrolla le metriche e aggiungi test per evitare regressioni.

13. Esempio completo: dal profiling al miglioramento

Supponiamo di avere una funzione che filtra e aggrega record. Un approccio comune è iniziare con un’implementazione leggibile, profilare, quindi spostare in set/dict e ridurre lavoro ripetuto.

from collections import defaultdict

def slow(records, allowed_ids):
    out = defaultdict(int)
    for r in records:
        if r["id"] in allowed_ids:  # allowed_ids è una lista: membership O(n)
            out[r["category"]] += r["value"]
    return dict(out)

def fast(records, allowed_ids):
    allowed = set(allowed_ids)  # membership O(1)
    out = defaultdict(int)
    get = out.get  # local binding (opzionale)
    for r in records:
        if r["id"] in allowed:
            k = r["category"]
            out[k] = get(k, 0) + r["value"]
    return dict(out)

In molti casi il passaggio da lista a set è già sufficiente a ridurre drasticamente il tempo totale. Il binding locale è un dettaglio che può aiutare solo se il loop è davvero “hot”.

Conclusione

Le performance in Python migliorano in modo affidabile quando segui un metodo: misurazione, identificazione dei colli di bottiglia, interventi ad alto impatto (algoritmi, I/O, strutture dati), e solo alla fine ottimizzazioni a basso livello. Con questa disciplina otterrai codice più veloce senza sacrificare manutenzione e chiarezza.

Torna su