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
dataclassesconslots=Trueper 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
- Riproduci il problema con un input realistico.
- Misura (timeit per micro, cProfile per macro, memoria se serve).
- Trova il collo di bottiglia principale (non il più facile da cambiare).
- Prova prima: algoritmo, struttura dati, riduzione I/O, caching.
- Solo dopo: micro-ottimizzazioni nei loop caldi.
- Se serve: parallelismo (I/O vs CPU) o accelerazione con Numba/Cython/NumPy.
- 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.