Introduzione : La ricerca di un’inferenza più veloce
Nel campo in rapida evoluzione dell’intelligenza artificiale, addestrare modelli è solo metà del lavoro. La vera misura dell’utilità di un modello risiede spesso nella sua capacità di effettuare inferenze—fare previsioni o generare output—rapidamente ed efficacemente. Per molte applicazioni del mondo reale, che spaziano dalla rilevazione di oggetti in tempo reale alle risposte dei grandi modelli di linguaggio, la velocità di inferenza è fondamentale. Sebbene l’inferenza basata sulla CPU abbia la sua utilità, la potenza di calcolo parallelo delle unità di elaborazione grafica (GPU) le rende le campionesse indiscusse per l’inferenza IA ad alta velocità e a bassa latenza.
Questo tutorial ti guiderà attraverso strategie e tecniche pratiche per ottimizzare l’uso delle GPU durante l’inferenza. Andremo oltre i concetti teorici ed esploreremo passi concreti, corredati di esempi di codice, per aiutarti a sfruttare al meglio le tue risorse hardware. Alla fine, avrai una solida comprensione di come identificare i colli di bottiglia e implementare ottimizzazioni efficaci per i tuoi carichi di lavoro di inferenza in deep learning.
Comprendere i colli di bottiglia dell’inferenza GPU
Prima di ottimizzare, è fondamentale comprendere cosa potrebbe rallentare la tua inferenza. L’inferenza GPU non è sempre limitata dal calcolo; spesso, altri fattori fungono da colli di bottiglia. I colpevoli comuni includono:
- Trasferimento dati (Host a Dispositivo/Dispositivo a Host) : Spostare dati tra la memoria della CPU (host) e la memoria della GPU (dispositivo) è lento. Minimizza questo aspetto.
- Dimensioni del batch piccole : Le GPU prosperano grazie al parallelismo. Dimensioni di batch molto piccole potrebbero non utilizzare appieno le unità di calcolo della GPU.
- Costi di lancio del kernel : Ogni volta che un kernel GPU (un piccolo programma eseguito sulla GPU) viene avviato, c’è un leggero costo aggiuntivo. Molte piccole operazioni sequenziali possono accumulare un costo significativo.
- Pattern di accesso alla memoria : Un accesso alla memoria inefficiente (ad esempio, letture non contigue) può causare fallimenti nella cache e performance più lente.
- Unità di calcolo sotto-utilizzate : L’architettura del modello o la strategia di inferenza potrebbero non sfruttare appieno la potenza di calcolo della GPU.
- Forme dinamiche/Controllo di flusso : Le operazioni che impediscono la compilazione di grafi statici (ad esempio, le branche if-else basate sui dati di input) possono ostacolare l’ottimizzazione.
- Sovraccarico del framework : Il framework di deep learning stesso può introdurre dei sovraccarichi.
Strategie di ottimizzazione pratiche
1. Quantizzazione del modello : Ridurre la tua impronta e aumentare la velocità
La quantizzazione è il processo di riduzione della precisione dei numeri utilizzati per rappresentare i pesi e le attivazioni di un modello, generalmente da 32 bit fluttuanti (FP32) a formati di precisione inferiore come 16 bit fluttuanti (FP16 o BFloat16) o interi a 8 bit (INT8). Ciò comporta diversi vantaggi:
- Impronta memoria ridotta : Modelli più piccoli richiedono meno memoria, consentendo dimensioni di batch più ampie o un deployment su dispositivi con risorse limitate.
- Calcolo più veloce : Le operazioni aritmetiche a bassa precisione sono generalmente più veloci e consumano meno energia. Le GPU moderne possiedono spesso hardware specializzato (ad esempio, Tensor Cores) per le operazioni FP16 e INT8.
- Trasferimento dati ridotto : Meno dati devono essere spostati.
Esempio : Quantizzazione con PyTorch (FP16)
La maggior parte delle GPU moderne supporta FP16 (precisione ridotta). PyTorch rende facile la conversione del tuo modello.
import torch
import torch.nn as nn
# Supponiamo che 'model' sia il tuo modello PyTorch addestrato (ad esempio, un ResNet)
model = nn.Sequential(
nn.Linear(784, 128),
nn.ReLU(),
nn.Linear(128, 10)
)
model.eval() # Imposta il modello in modalità di valutazione
# Sposta il modello sulla GPU
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)
# Opzione 1 : Precisione mista automatica (AMP) per l'inferenza
# Questo è generalmente raccomandato perché gestisce la conversione solo dove è utile
from torch.cuda.amp import autocast
# Esempio di ciclo di inferenza con AMP
input_data = torch.randn(64, 784).to(device)
with autocast():
output = model(input_data)
print(f"Tipo di output di inferenza AMP : {output.dtype}")
# Opzione 2 : Convertire esplicitamente l'intero modello in FP16 (meno comune per l'inferenza)
# model_fp16 = model.half() # Converte tutti i parametri e i buffer in FP16
# input_data_fp16 = input_data.half()
# output_fp16 = model_fp16(input_data_fp16)
# print(f"Tipo di output di inferenza FP16 esplicito : {output_fp16.dtype}")
# Per la quantizzazione INT8, si utilizzerebbero generalmente gli strumenti di quantizzazione nativi di PyTorch
# o si esporterebbe verso un runtime come ONNX Runtime/TensorRT che si occupa di questo.
2. Ottimizzazione della dimensione del batch : Trovare il giusto compromesso
Le GPU raggiungono un alto throughput elaborando molti punti dati in parallelo. Aumentare la dimensione del batch consente alla GPU di eseguire più calcoli simultaneamente, il che porta spesso a un utilizzo migliore e a un tempo di inferenza complessivo più veloce, fino a un certo punto. Tuttavia, una dimensione del batch troppo grande può causare errori di memoria o rendimenti decrescenti se la larghezza di banda della memoria o le unità di calcolo della GPU diventano sature.
Strategia : Regolazione della dimensione del batch
Sperimenta con diverse dimensioni del batch. Inizia con una dimensione del batch piccola (ad esempio, 1, 4, 8) e aumentala gradualmente finché non osservi rendimenti decrescenti nella velocità di inferenza o incontri limiti di memoria. Sfrutta il tuo modello per comprendere come la dimensione del batch impatti l’utilizzo della GPU.
import time
# ... (configurazione del modello e del dispositivo come sopra)
batch_sizes = [1, 16, 32, 64, 128, 256]
times = []
print("\nValutazione di diverse dimensioni del batch :")
for bs in batch_sizes:
input_data = torch.randn(bs, 784).to(device)
# Esecuzione di riscaldamento
with autocast():
_ = model(input_data)
torch.cuda.synchronize() # Aspetta che la GPU termini
start_time = time.time()
num_runs = 100
for _ in range(num_runs):
with autocast():
_ = model(input_data)
torch.cuda.synchronize()
end_time = time.time()
avg_time_per_batch = (end_time - start_time) / num_runs
times.append(avg_time_per_batch)
print(f"Dimensione del batch : {bs}, Tempo medio per batch : {avg_time_per_batch:.4f}s")
# Il tracciamento o l'analisi della lista 'times' mostrerebbe la dimensione del batch ottimale.
3. Compilazione di grafi e compilatori JIT (Just-In-Time)
I framework di deep learning come PyTorch e TensorFlow eseguono generalmente i modelli in modo interpretativo (modalità eager). Sebbene flessibile, ciò può introdurre sovraccarichi legati a Python e impedire le ottimizzazioni globali che un compilatore potrebbe effettuare. La compilazione di grafi converte il tuo modello in un grafo di calcolo statico, che può essere ottimizzato e compilato in codice macchina altamente efficiente.
Esempio : TorchScript con PyTorch
TorchScript è un modo per creare modelli serializzabili e ottimizzabili a partire dal codice PyTorch. Può tracciare un modulo esistente o convertirlo tramite lo script.
# ... (configurazione del modello e del dispositivo)
# Opzione 1 : Tracciamento (per modelli con flusso di controllo statico)
# Fornire un'entrata fittizia per tracciare le operazioni
example_input = torch.randn(1, 784).to(device)
traced_model = torch.jit.trace(model, example_input)
print("\nTipo di modello tracciato :", type(traced_model))
# Inferenza con il modello tracciato
start_time = time.time()
num_runs = 100
for _ in range(num_runs):
with autocast():
_ = traced_model(example_input)
torch.cuda.synchronize()
end_time = time.time()
print(f"Tempo di inferenza del modello tracciato (per esecuzione) : {(end_time - start_time)/num_runs:.6f}s")
# Opzione 2 : Scripting (per modelli con flusso di controllo dinamico, ma richiede una sintassi specifica)
# @torch.jit.script
# def my_scripted_function(x):
# if x.mean() > 0:
# return x * 2
# else:
# return x / 2
# scripted_output = my_scripted_function(torch.randn(10, 10).to(device))
Torch.compile (PyTorch 2.0+)
PyTorch 2.0 ha introdotto torch.compile, un potente compilatore JIT che utilizza tecnologie come TorchInductor per accelerare significativamente i modelli senza necessitare di conversione manuale in TorchScript. È generalmente l’ottimizzazione a livello di grafo più semplice ed efficace.
# ... (configurazione del modello e del dispositivo)
# Compilare il modello
compiled_model = torch.compile(model)
# Inferenza con il modello compilato
example_input = torch.randn(64, 784).to(device) # Usa una dimensione del batch più grande per un effetto migliore
# Esecuzione di riscaldamento per la compilazione
with autocast():
_ = compiled_model(example_input)
torch.cuda.synchronize()
start_time = time.time()
num_runs = 100
for _ in range(num_runs):
with autocast():
_ = compiled_model(example_input)
torch.cuda.synchronize()
end_time = time.time()
print(f"\nTempo di inferenza con Torch.compile (per esecuzione) : {(end_time - start_time)/num_runs:.6f}s")
4. Runtimes di inferenza dedicati : Oltre i framework
Per prestazioni massime e flessibilità di distribuzione, considera runtimes di inferenza dedicati. Questi runtimes sono ottimizzati per ambienti di produzione e spesso includono ottimizzazioni grafiche avanzate, fusione di kernel e supporto per vari acceleratori hardware.
- NVIDIA TensorRT : Un ottimizzatore e un runtime di inferenza di apprendimento profondo ad alte prestazioni di NVIDIA. Prende una rete addestrata, la ottimizza (ad esempio, quantizzazione, fusione di layer, autotuning dei kernel) e produce un motore di esecuzione ottimizzato. È specificamente progettato per GPU NVIDIA.
- ONNX Runtime : Supporta modelli nel formato Open Neural Network Exchange (ONNX). Fornisce un motore di inferenza unificato su vari hardware e sistemi operativi, con backend per CPU, GPU (CUDA, ROCm, DirectML) e acceleratori IA specializzati.
Strategia : Esportare nel formato ONNX e inferire con ONNX Runtime
Esportare il tuo modello PyTorch nel formato ONNX è un primo passo comune per utilizzare runtimes come ONNX Runtime o TensorRT.
import onnx
import onnxruntime as ort
# ... (configurazione del modello)
# Esportare il modello PyTorch in ONNX
onnx_path = "model.onnx"
example_input = torch.randn(1, 784).to(device)
torch.onnx.export(
model.cpu(), # L'esportazione ONNX di solito avviene prima su CPU
example_input.cpu(),
onnx_path,
input_names=["input"],
output_names=["output"],
dynamic_axes={
"input": {0: "batch_size"}, # Permettere una dimensione del batch dinamica
"output": {0: "batch_size"}
},
opset_version=14
)
print(f"Modello esportato in {onnx_path}")
# Verificare il modello ONNX
onnx_model = onnx.load(onnx_path)
onnx.checker.check_model(onnx_model)
print("Modello ONNX verificato con successo.")
# Inferenza con ONNX Runtime
# Creare una sessione di inferenza
sess_options = ort.SessionOptions()
# Facoltativo : impostare il livello di ottimizzazione del grafo per migliori prestazioni
sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
# Usare il fornitore CUDA per l'inferenza GPU
providers = ['CUDAExecutionProvider', 'CPUExecutionProvider']
ort_session = ort.InferenceSession(onnx_path, sess_options=sess_options, providers=providers)
# Preparare l'input per ONNX Runtime
input_name = ort_session.get_inputs()[0].name
output_name = ort_session.get_outputs()[0].name
# Esempio di inferenza con una dimensione del batch di 64
input_data_np = torch.randn(64, 784).cpu().numpy().astype(import numpy as np; np.float32)
start_time = time.time()
num_runs = 100
for _ in range(num_runs):
ort_outputs = ort_session.run([output_name], {input_name: input_data_np})
end_time = time.time()
print(f"\nTempo di inferenza ONNX Runtime (per esecuzione) : {(end_time - start_time)/num_runs:.6f}s")
5. Esecuzione Asincrona e Pipelining
Le operazioni GPU sono asincrone. La CPU avvia un kernel e passa immediatamente ad altro, mentre la GPU lo esegue in background. Comprendere questo è essenziale per un efficace pipelining.
Strategia : Sovrapporre il Trasferimento dei Dati e il Calcolo
Invece di aspettare che un batch sia completamente elaborato prima di trattare il successivo, puoi sovrapporre il caricamento dei dati per il prossimo batch con il calcolo del batch attuale. Il DataLoader di PyTorch con num_workers > 0 e pin_memory=True aiuta a trasferire i dati verso la memoria bloccata, che è più veloce per l’accesso GPU.
import torchvision.datasets as datasets
import torchvision.transforms as transforms
from torch.utils.data import DataLoader
# Dataset fittizio e DataLoader
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.5,), (0.5,))
])
dataset = datasets.MNIST(root='./data', train=False, download=True, transform=transform)
# Importante : pin_memory=True per trasferimenti più veloci dall'host al dispositivo
dataloader = DataLoader(dataset, batch_size=64, shuffle=False, num_workers=4, pin_memory=True)
# ... (configurazione del modello e del dispositivo, ad esempio utilizzando torch.compile o traced_model)
compiled_model = torch.compile(model)
# Loop di inferenza con caricamento dati asincrono
start_time = time.time()
for i, (images, labels) in enumerate(dataloader):
images = images.view(images.shape[0], -1).to(device, non_blocking=True) # non_blocking=True è cruciale
with autocast():
outputs = compiled_model(images)
# Se devi usare le uscite sulla CPU, aggiungi un punto di sincronizzazione
# Ad esempio, per calcolare metriche dopo un certo numero di batch
# if (i+1) % 100 == 0:
# torch.cuda.synchronize()
# # Trattare le uscite qui
torch.cuda.synchronize() # Assicurati che tutte le operazioni GPU siano complete prima che il cronometraggio finisca
end_time = time.time()
print(f"\nTempo di inferenza asincrona per {len(dataloader.dataset)} campioni : {end_time - start_time:.4f}s")
6. Gestione e Allocazione della Memoria
Un uso efficiente della memoria è critico. Gli errori di memoria insufficiente interrompono l’inferenza, e le riallocazioni frequenti possono introdurre sovraccarichi.
Strategia : Svuotare la Cache e Usare Gestori di Contesto
Svuota periodicamente la cache della memoria GPU, soprattutto se carichi/scarichi modelli o elabori dimensioni di input molto diverse.
import gc
# ... alcune operazioni di inferenza ...
del model # Rimuovi il modello se non è più necessario
gc.collect()
torch.cuda.empty_cache() # Svuota la cache della memoria GPU di PyTorch
print("Cache GPU svuotata.")
Strategia : Pre-allocare Tenzioni (per input di dimensione fissa)
Se la dimensione del tuo tensore di input è fissa, pre-alloca i tensori di input e output sulla GPU per evitare allocazioni ripetute.
# ... (configurazione del modello e del dispositivo)
# Pre-allocare i tensori di input e output
fixed_batch_size = 64
fixed_input_shape = (fixed_batch_size, 784)
pre_allocated_input = torch.empty(fixed_input_shape, dtype=torch.float32, device=device)
# Esecuzione fittizia per ottenere la forma di output
with autocast():
dummy_output = model(pre_allocated_input)
pre_allocated_output = torch.empty(dummy_output.shape, dtype=dummy_output.dtype, device=device)
# Ora, nel tuo ciclo di inferenza, copia i dati in pre_allocated_input
# e usa pre_allocated_output per memorizzare i risultati
# Esempio : (supponendo che tu abbia l'array numpy 'new_batch_data')
# pre_allocated_input.copy_(torch.from_numpy(new_batch_data))
# with autocast():
# model(pre_allocated_input, out=pre_allocated_output) # Alcuni modelli/operazioni supportano l'argomento 'out'
Profilazione e Debugging delle Prestazioni
L’ottimizzazione è un processo iterativo. Hai bisogno di strumenti per identificare dove il tuo tempo viene speso.
- PyTorch Profiler : Usa
torch.profilerper ottenere rapporti dettagliati sulle operazioni CPU e GPU, i tempi di avvio dei kernel, l’uso della memoria e il trasferimento dei dati. - NVIDIA Nsight Systems / Nsight Compute : Strumenti autonomi potenti per la profilazione approfondita della GPU, mostrando le linee temporali di esecuzione dei kernel, la larghezza di banda della memoria e l’utilizzo dei calcoli.
- Modulo
timedi Python : Semplice ma efficace per il timing a livello elevato dei blocchi di codice.
Esempio : Profilare PyTorch
from torch.profiler import profile, schedule, tensorboard_trace_handler, ProfilerActivity
# ... (configurazione del modello e del dispositivo)
with profile(
schedule=schedule(wait=1, warmup=1, active=3, repeat=1),
on_trace_ready=tensorboard_trace_handler("./log/profiler_inference"),
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
record_shapes=True,
with_stack=True
) as prof:
for step in range(1 + 1 + 3 + 1): # attendere, riscaldamento, attivo, ritardo di ripetizione
input_data = torch.randn(64, 784).to(device)
with autocast():
_ = model(input_data)
prof.step()
print("\nRisultati del profiler registrati in ./log/profiler_inference. Vedi con 'tensorboard --logdir=./log'")
Conclusione
Ottimizzare l’inferenza GPU è una sfida multifaccettata, ma applicando sistematicamente le strategie descritte in questo tutorial, puoi ottenere guadagni di velocità significativi. Inizia con la quantificazione, sperimenta con le dimensioni dei batch, utilizza compilatori di grafo come torch.compile e considera runtime dedicati come ONNX Runtime o TensorRT per i deployment in produzione. Non dimenticare mai di profilare il tuo codice per identificare i veri colli di bottiglia, poiché l’ottimizzazione prematura può risultare controproducente. Con questi strumenti e tecniche, sei ben attrezzato per liberare tutto il potenziale delle tue GPU per un’inferenza AI ultra-rapida.
🕒 Published: