threading e multiprocessing in Python, e perché il training di una rete neurale ne ha bisogno
threading per task I/O-boundmultiprocessing per task CPU-boundIn TPSIT hai già incontrato il concetto di processo e thread nel sistema operativo. Qui li usiamo direttamente in Python per velocizzare i nostri programmi di data science.
threading non accelera il calcolo puro (CPU-bound), ma è perfetto quando il programma aspetta qualcosa (I/O-bound: rete, file, database). Per sfruttare davvero più core della CPU serve multiprocessing, che crea processi separati, ognuno con il proprio interprete e la propria memoria.
| Caratteristica | threading | multiprocessing |
|---|---|---|
| Adatto a | Task I/O-bound | Task CPU-bound |
| Memoria | Condivisa | Separata per processo |
| Overhead di creazione | Basso | Più alto |
| Limitato dal GIL | Sì | No |
threadingEsempio: scaricare più "risorse" simulate in parallelo (I/O-bound, simulato con time.sleep).
import threading
import time
def scarica_dato(nome, secondi):
print(f"Inizio download di {nome}")
time.sleep(secondi) # simula un'attesa di rete
print(f"Fine download di {nome}")
risorse = [("dataset_A", 2), ("dataset_B", 1), ("dataset_C", 3)]
inizio = time.time()
threads = []
for nome, secondi in risorse:
t = threading.Thread(target=scarica_dato, args=(nome, secondi))
threads.append(t)
t.start()
for t in threads:
t.join() # aspetta che tutti i thread finiscano
print(f"Tempo totale: {time.time() - inizio:.2f}s") # ~3s invece di 6sI tre "download" partono quasi insieme: il tempo totale è vicino al più lento dei tre, non alla somma.
multiprocessingOra un caso CPU-bound reale: calcolare qualcosa di pesante su più array, distribuendo il lavoro su più processi/core.
import multiprocessing
import time
import numpy as np
def somma_quadrati(array):
return np.sum(array ** 2)
if __name__ == "__main__":
dati = [np.random.rand(5_000_000) for _ in range(4)]
# Versione sequenziale
inizio = time.time()
risultati_seq = [somma_quadrati(d) for d in dati]
print(f"Sequenziale: {time.time() - inizio:.2f}s")
# Versione parallela con Pool di processi
inizio = time.time()
with multiprocessing.Pool(processes=4) as pool:
risultati_par = pool.map(somma_quadrati, dati)
print(f"Parallela: {time.time() - inizio:.2f}s")multiprocessing su Windows/macOS il blocco if __name__ == "__main__": è obbligatorio, perché ogni processo re-importa il file principale.
Il legame con l'IA è diretto: addestrare una rete neurale significa ripetere milioni di volte la stessa operazione — moltiplicazioni di matrici e aggiornamento dei pesi — su grandi quantità di dati. Questo lavoro è "embarrassingly parallel": ogni esempio del batch può essere elaborato in modo indipendente.
Pool.map, in miniatura)Una GPU è di fatto migliaia di "piccoli processori" che fanno la stessa operazione in parallelo su dati diversi: è lo stesso concetto del nostro multiprocessing.Pool, ma con granularità enormemente maggiore. Capire threading e multiprocessing in Python è quindi il primo passo per capire perché il deep learning moderno è nato solo quando l'hardware parallelo (GPU) è diventato accessibile.
threading per "scaricare" 5 file simulati con tempi diversi e misura il tempo totale, confrontandolo con la somma sequenziale.multiprocessing.Pool per calcolare la media di 8 array NumPy di grandi dimensioni, confrontando tempo sequenziale e parallelo con time.time().Il GIL limita threading al solo I/O-bound; multiprocessing supera il GIL usando processi separati ed è la scelta giusta per il calcolo puro. Il training delle reti neurali sfrutta lo stesso principio del parallelismo — dividere il lavoro tra unità indipendenti — a scala molto più grande, tramite GPU e training distribuito su più macchine.