Einleitung : Die Suche nach schnelleren Inferenzzeiten
Im sich schnell entwickelnden Bereich der künstlichen Intelligenz ist das Trainieren von Modellen nur die halbe Miete. Das wahre Maß für den Nutzen eines Modells liegt oft in seiner Fähigkeit, Inferenz—Vorhersagen zu treffen oder Ausgaben zu generieren—schnell und effizient durchzuführen. Für viele Anwendungen in der realen Welt, von der Echtzeit-Objekterkennung bis hin zu den Antworten großer Sprachmodelle, ist die Inferenzgeschwindigkeit entscheidend. Während CPU-basierte Inferenz ihre Berechtigung hat, machen die parallele Verarbeitungskraft von Grafikprozessoren (GPU) sie zu den unbestrittenen Champions für hochdurchsatzfähige und latenzarme KI-Inferenz.
Dieses Tutorial wird Sie durch praktische Strategien und Techniken führen, um die Nutzung von GPUs während der Inferenz zu optimieren. Wir werden über theoretische Konzepte hinausgehen und konkrete Schritte erkunden, begleitet von Codebeispielen, um Ihnen zu helfen, das Beste aus Ihrer Hardware herauszuholen. Am Ende werden Sie ein solides Verständnis dafür haben, wie Sie Engpässe identifizieren und effektive Optimierungen für Ihre Deep-Learning-Inferenz-Workloads implementieren können.
Verständnis der GPU-Inferenzengpässe
Bevor Sie optimieren, ist es entscheidend zu verstehen, was Ihre Inferenz verlangsamen könnte. GPU-Inferenz wird nicht immer durch die Berechnung limitiert; oft wirken andere Faktoren als Engpässe. Häufige Übeltäter sind:
- Datenübertragung (Host zu Gerät/ Gerät zu Host) : Das Verschieben von Daten zwischen dem CPU-Speicher (Host) und dem GPU-Speicher (Gerät) ist langsam. Minimieren Sie dies.
- Kleine Batchgrößen : GPUs gedeihen durch Parallelität. Sehr kleine Batchgrößen könnten die Recheneinheiten der GPU nicht vollständig auslasten.
- Kernstartkosten : Jedes Mal, wenn ein GPU-Kern (ein kleines Programm, das auf der GPU ausgeführt wird) gestartet wird, gibt es einen geringen zusätzlichen Kostenfaktor. Viele kleine sequenzielle Operationen können sich zu einem signifikanten Kostenfaktor summieren.
- Speicherzugriffsmuster : Ineffizienter Speicherzugriff (z.B. nicht zusammenhängende Lesevorgänge) kann zu Cache-Fehlern und langsamerer Leistung führen.
- Unterausgelastete Recheneinheiten : Die Architektur des Modells oder die Inferenzstrategie könnte die Rechenleistung der GPU nicht vollständig nutzen.
- Dynamische Formen/Flusskontrolle : Operationen, die die Kompilierung statischer Graphen verhindern (z.B. datenabhängige if-else-Zweige), können die Optimierung behindern.
- Framework-Overhead : Das Deep-Learning-Framework selbst kann Overhead verursachen.
Praktische Optimierungsstrategien
1. Modellquantifizierung : Ihre Fußabdruck reduzieren und die Geschwindigkeit erhöhen
Die Quantifizierung ist der Prozess, die Genauigkeit der Zahlen, die zur Darstellung der Gewichte und Aktivierungen eines Modells verwendet werden, zu reduzieren, normalerweise von 32-Bit-Gleitkommazahlen (FP32) auf Formate mit niedrigerer Genauigkeit wie 16-Bit-Gleitkommazahlen (FP16 oder BFloat16) oder 8-Bit-Ganzzahlen (INT8). Dies bietet mehrere Vorteile:
- Reduzierter Speicherbedarf : Kleinere Modelle benötigen weniger Speicher, was größere Batchgrößen oder den Einsatz auf ressourcenbeschränkten Geräten ermöglicht.
- Schnelleres Rechnen : Arithmetische Operationen mit niedrigerer Genauigkeit sind in der Regel schneller und verbrauchen weniger Energie. Moderne GPUs verfügen oft über spezialisierte Hardware (z.B. Tensor Cores) für FP16- und INT8-Operationen.
- Reduzierte Datenübertragung : Es müssen weniger Daten verschoben werden.
Beispiel : Quantifizierung mit PyTorch (FP16)
Die meisten modernen GPUs unterstützen FP16 (reduzierte Genauigkeit). PyTorch erleichtert die Umwandlung Ihres Modells.
import torch
import torch.nn as nn
# Angenommen, 'model' ist Ihr trainiertes PyTorch-Modell (z.B. ein ResNet)
model = nn.Sequential(
nn.Linear(784, 128),
nn.ReLU(),
nn.Linear(128, 10)
)
model.eval() # Modell in den Evaluierungsmodus setzen
# Modell auf die GPU verschieben
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)
# Option 1 : Automatische gemischte Genauigkeit (AMP) für die Inferenz
# Dies wird in der Regel empfohlen, da es die Umwandlung nur dort verwaltet, wo es vorteilhaft ist
from torch.cuda.amp import autocast
# Beispiel einer Inferenzschleife mit AMP
input_data = torch.randn(64, 784).to(device)
with autocast():
output = model(input_data)
print(f"AMP Inferenzausgabetyp : {output.dtype}")
# Option 2 : Das gesamte Modell explizit in FP16 umwandeln (weniger gebräuchlich für die Inferenz)
# model_fp16 = model.half() # Wandelt alle Parameter und Puffer in FP16 um
# input_data_fp16 = input_data.half()
# output_fp16 = model_fp16(input_data_fp16)
# print(f"Expliziter FP16 Inferenzausgabetyp : {output_fp16.dtype}")
# Für die INT8-Quantifizierung würden Sie normalerweise die nativen Quantifizierungswerkzeuge von PyTorch verwenden
# oder zu einem Runtime wie ONNX Runtime/TensorRT exportieren, das sich darum kümmert.
2. Batchgrößenoptimierung : Den richtigen Kompromiss finden
GPUs erreichen einen hohen Durchsatz, indem sie viele Datenpunkte parallel verarbeiten. Eine Erhöhung der Batchgröße ermöglicht es der GPU, mehr Berechnungen gleichzeitig durchzuführen, was oft zu einer besseren Auslastung und schnelleren Gesamtinferenzzeiten führt, bis zu einem bestimmten Punkt. Eine zu große Batchgröße kann jedoch zu Speicherfehlern oder abnehmenden Erträgen führen, wenn die Speicherbandbreite oder die Recheneinheiten der GPU überlastet werden.
Strategie : Anpassung der Batchgröße
Experimentieren Sie mit verschiedenen Batchgrößen. Beginnen Sie mit einer kleinen Batchgröße (z.B. 1, 4, 8) und erhöhen Sie diese schrittweise, bis Sie abnehmende Erträge bei der Inferenzgeschwindigkeit beobachten oder auf Speichergrenzen stoßen. Nutzen Sie Ihr Modell, um zu verstehen, wie die Batchgröße die GPU-Auslastung beeinflusst.
import time
# ... (Modell- und Gerätekonfiguration wie oben)
batch_sizes = [1, 16, 32, 64, 128, 256]
times = []
print("\nBewertung verschiedener Batchgrößen :")
for bs in batch_sizes:
input_data = torch.randn(bs, 784).to(device)
# Aufwärmphase
with autocast():
_ = model(input_data)
torch.cuda.synchronize() # Warten, bis die GPU fertig ist
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"Batchgröße : {bs}, Durchschnittliche Zeit pro Batch : {avg_time_per_batch:.4f}s")
# Das Nachverfolgen oder Analysieren der Liste 'times' würde die optimale Batchgröße zeigen.
3. Graphkompilierung und JIT-Compiler (Just-In-Time)
Deep-Learning-Frameworks wie PyTorch und TensorFlow führen Modelle normalerweise interpretativ (eager mode) aus. Obwohl dies flexibel ist, kann es Python-Overhead einführen und globale Optimierungen verhindern, die ein Compiler durchführen könnte. Die Graphkompilierung wandelt Ihr Modell in einen statischen Berechnungsgraphen um, der dann optimiert und in hoch effizienten Maschinencode kompiliert werden kann.
Beispiel : TorchScript mit PyTorch
TorchScript ist eine Möglichkeit, serialisierbare und optimierbare Modelle aus PyTorch-Code zu erstellen. Es kann ein bestehendes Modul nachverfolgen oder es über das Skript umwandeln.
# ... (Modell- und Gerätekonfiguration)
# Option 1 : Nachverfolgung (für Modelle mit statischem Kontrollfluss)
# Fiktive Eingabe bereitstellen, um die Operationen nachzuverfolgen
example_input = torch.randn(1, 784).to(device)
traced_model = torch.jit.trace(model, example_input)
print("\nTyp des nachverfolgten Modells :", type(traced_model))
# Inferenz mit dem nachverfolgten Modell
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"Inferenzzeit des nachverfolgten Modells (pro Ausführung) : {(end_time - start_time)/num_runs:.6f}s")
# Option 2 : Skripting (für Modelle mit dynamischem Kontrollfluss, erfordert jedoch spezifische Syntax)
# @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 hat torch.compile eingeführt, einen leistungsstarken JIT-Compiler, der Technologien wie TorchInductor nutzt, um Modelle signifikant zu beschleunigen, ohne eine manuelle Umwandlung in TorchScript zu erfordern. Dies ist in der Regel die einfachste und effektivste Graphoptimierung.
# ... (Modell- und Geräte-Konfiguration)
# Modell kompilieren
compiled_model = torch.compile(model)
# Inferenz mit dem kompilierten Modell
example_input = torch.randn(64, 784).to(device) # Verwenden Sie eine größere Batch-Größe für bessere Ergebnisse
# Warm-up-Ausführung für die Kompilierung
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"\nInferenzzeit mit Torch.compile (pro Ausführung): {(end_time - start_time)/num_runs:.6f}s")
4. Dedizierte Inferenz-Runtimes: Über Frameworks hinaus
Für maximale Leistung und Flexibilität beim Deployment sollten Sie dedizierte Inferenz-Runtimes in Betracht ziehen. Diese Runtimes sind für Produktionsumgebungen optimiert und beinhalten häufig fortgeschrittene Graph-Optimierungen, Kernel-Fusion und Unterstützung für verschiedene Hardware-Beschleuniger.
- NVIDIA TensorRT: Ein Hochleistungs-Optimierer und Inferenz-Runtime für Deep Learning von NVIDIA. Es nimmt ein trainiertes Netzwerk, optimiert es (z. B. Quantisierung, Layer-Fusion, automatische Kernel-Anpassung) und erzeugt einen optimierten Ausführungs-Engine. Es ist speziell für NVIDIA-GPUs konzipiert.
- ONNX Runtime: Unterstützt Modelle im Open Neural Network Exchange (ONNX) Format. Es bietet eine einheitliche Inferenz-Engine auf verschiedenen Hardware- und Betriebssystemen, mit Backends für CPU, GPU (CUDA, ROCm, DirectML) und spezialisierten KI-Beschleunigern.
Strategie: Exportieren im ONNX-Format und Inferenz mit ONNX Runtime
Das Exportieren Ihres PyTorch-Modells im ONNX-Format ist ein häufiger erster Schritt, um Runtimes wie ONNX Runtime oder TensorRT zu verwenden.
import onnx
import onnxruntime as ort
# ... (Modellkonfiguration)
# PyTorch-Modell in ONNX exportieren
onnx_path = "model.onnx"
example_input = torch.randn(1, 784).to(device)
torch.onnx.export(
model.cpu(), # Der ONNX-Export erfolgt normalerweise zuerst auf der CPU
example_input.cpu(),
onnx_path,
input_names=["input"],
output_names=["output"],
dynamic_axes={
"input": {0: "batch_size"}, # Dynamische Batch-Größe erlauben
"output": {0: "batch_size"}
},
opset_version=14
)
print(f"Modell nach {onnx_path} exportiert")
# ONNX-Modell überprüfen
onnx_model = onnx.load(onnx_path)
onnx.checker.check_model(onnx_model)
print("ONNX-Modell erfolgreich überprüft.")
# Inferenz mit ONNX Runtime
# Inferenz-Session erstellen
sess_options = ort.SessionOptions()
# Optional: Graph-Optimierungslevel für bessere Leistung festlegen
sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
# CUDA-Anbieter für GPU-Inferenz verwenden
providers = ['CUDAExecutionProvider', 'CPUExecutionProvider']
ort_session = ort.InferenceSession(onnx_path, sess_options=sess_options, providers=providers)
# Eingabe für ONNX Runtime vorbereiten
input_name = ort_session.get_inputs()[0].name
output_name = ort_session.get_outputs()[0].name
# Beispiel-Inferenz mit einer Batch-Größe von 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"\nInferenzzeit ONNX Runtime (pro Ausführung): {(end_time - start_time)/num_runs:.6f}s")
5. Asynchrone Ausführung und Pipelining
GPU-Operationen sind asynchron. Die CPU startet einen Kernel und geht sofort zu etwas anderem über, während die GPU ihn im Hintergrund ausführt. Dies zu verstehen, ist entscheidend für ein effektives Pipelining.
Strategie: Datenübertragung und Berechnung überlagern
Anstatt zu warten, bis ein Batch vollständig abgeschlossen ist, bevor Sie den nächsten verarbeiten, können Sie das Laden der Daten für den nächsten Batch mit der Berechnung des aktuellen Batches überlagern. Der DataLoader von PyTorch mit num_workers > 0 und pin_memory=True hilft, Daten in den gesperrten Speicher zu übertragen, was für den GPU-Zugriff schneller ist.
import torchvision.datasets as datasets
import torchvision.transforms as transforms
from torch.utils.data import DataLoader
# Fiktives Dataset und DataLoader
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.5,), (0.5,))
])
dataset = datasets.MNIST(root='./data', train=False, download=True, transform=transform)
# Wichtig: pin_memory=True für schnellere Übertragungen vom Host zum Gerät
dataloader = DataLoader(dataset, batch_size=64, shuffle=False, num_workers=4, pin_memory=True)
# ... (Modell- und Geräte-Konfiguration, z. B. mit torch.compile oder traced_model)
compiled_model = torch.compile(model)
# Inferenzschleife mit asynchronem Datenladen
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 ist entscheidend
with autocast():
outputs = compiled_model(images)
# Wenn Sie die Ausgaben auf der CPU verwenden müssen, fügen Sie einen Synchronisationspunkt hinzu
# Zum Beispiel, um Metriken nach einer bestimmten Anzahl von Batches zu berechnen
# if (i+1) % 100 == 0:
# torch.cuda.synchronize()
# # Ausgaben hier verarbeiten
torch.cuda.synchronize() # Stellen Sie sicher, dass alle GPU-Operationen abgeschlossen sind, bevor die Zeitmessung endet
end_time = time.time()
print(f"\nAsynchrone Inferenzzeit für {len(dataloader.dataset)} Proben: {end_time - start_time:.4f}s")
6. Speicherverwaltung und -zuweisung
Eine effiziente Speichernutzung ist entscheidend. Speichermangel-Fehler unterbrechen die Inferenz, und häufige Neuzuweisungen können Overhead verursachen.
Strategie: Cache leeren und Kontextmanager verwenden
Leeren Sie regelmäßig den GPU-Speicher-Cache, insbesondere wenn Sie Modelle laden/entladen oder sehr unterschiedliche Eingangsgrößen verarbeiten.
import gc
# ... einige Inferenzaufgaben ...
del model # Modell löschen, wenn es nicht mehr benötigt wird
gc.collect()
torch.cuda.empty_cache() # Leert den GPU-Speicher-Cache von PyTorch
print("GPU-Cache geleert.")
Strategie: Tensors vorab zuweisen (für Eingaben fester Größe)
Wenn die Größe Ihres Eingabetensors fest ist, weisen Sie die Eingangs- und Ausgangstensors auf der GPU vorab zu, um wiederholte Zuweisungen zu vermeiden.
# ... (Modell- und Geräte-Konfiguration)
# Vorab die Eingangs- und Ausgangstensors zuweisen
fixed_batch_size = 64
fixed_input_shape = (fixed_batch_size, 784)
pre_allocated_input = torch.empty(fixed_input_shape, dtype=torch.float32, device=device)
# Fiktive Ausführung, um die Ausgangsform zu erhalten
with autocast():
dummy_output = model(pre_allocated_input)
pre_allocated_output = torch.empty(dummy_output.shape, dtype=dummy_output.dtype, device=device)
# Jetzt, in Ihrer Inferenzschleife, kopieren Sie die Daten in pre_allocated_input
# und verwenden Sie pre_allocated_output, um die Ergebnisse zu speichern
# Beispiel: (vorausgesetzt, Sie haben das numpy-Array 'new_batch_data')
# pre_allocated_input.copy_(torch.from_numpy(new_batch_data))
# with autocast():
# model(pre_allocated_input, out=pre_allocated_output) # Einige Modelle/Operationen unterstützen das Argument 'out'
Profilierung und Leistungs-Debugging
Optimierung ist ein iterativer Prozess. Sie benötigen Werkzeuge, um zu identifizieren, wo Ihre Zeit ausgegeben wird.
- PyTorch Profiler: Verwenden Sie
torch.profiler, um detaillierte Berichte über CPU- und GPU-Operationen, Kernel-Startzeiten, Speichernutzung und Datenübertragungen zu erhalten. - NVIDIA Nsight Systems / Nsight Compute: Leistungsstarke eigenständige Werkzeuge für das tiefgehende Profiling von GPUs, die die Ausführungszeitlinien von Kernen, den Speicherbandbreite und die Nutzung von Berechnungen anzeigen.
- Python-Modul
time: Einfach, aber effektiv für das Timing von Codeblöcken auf hoher Ebene.
Beispiel: PyTorch Profiler
from torch.profiler import profile, schedule, tensorboard_trace_handler, ProfilerActivity
# ... (Modell- und Geräte-Konfiguration)
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): # warten, aufwärmen, aktiv, Wiederholungszeit
input_data = torch.randn(64, 784).to(device)
with autocast():
_ = model(input_data)
prof.step()
print("\nProfilergebnisse in ./log/profiler_inference gespeichert. Mit 'tensorboard --logdir=./log' ansehen.")
Fazit
Die Optimierung der GPU-Inferenz ist eine vielschichtige Herausforderung, aber durch die systematische Anwendung der in diesem Tutorial beschriebenen Strategien können Sie erhebliche Geschwindigkeitsgewinne erzielen. Beginnen Sie mit der Quantifizierung, experimentieren Sie mit Batch-Größen, verwenden Sie Graph-Compiler wie torch.compile, und ziehen Sie dedizierte Laufzeiten wie ONNX Runtime oder TensorRT für Produktionsbereitstellungen in Betracht. Vergessen Sie niemals, Ihren Code zu profilieren, um die tatsächlichen Engpässe zu identifizieren, da vorzeitige Optimierung kontraproduktiv sein kann. Mit diesen Werkzeugen und Techniken sind Sie gut gerüstet, um das volle Potenzial Ihrer GPUs für eine ultra-schnelle KI-Inferenz auszuschöpfen.
🕒 Published:
Related Articles
- Meus custos de nuvem prejudicam minhas margens de lucro (e as suas)
- Ich habe meine Cloud-Kosten optimiert, indem ich die Leistung der Agents verbessert habe.
- O Tempo de Inatividade dos Meus Agentes Está Matando Meu Orçamento (E o Seu)
- I miei costi cloud danneggiano i miei margini di profitto (e i vostri)