Neunter in unserer Serie auf Leistungsprofilierung und Optimierung in Pytorch Ziel, die kritische Rolle der Leistungsanalyse und -optimierung bei der Entwicklung des maschinellen Lernens hervorzuheben. Während der gesamten Serie haben wir eine Vielzahl praktischer Instruments und Techniken zur Analyse und Steigerung der Laufzeitleistung von Pytorch-basierten KI/ML-Modellen überprüft. Unser Ziel battle zweifach:
- Betonung der Bedeutung der routinemäßigen Bewertung und Optimierung von AI/ML -Workloads.
- Demonstration der Zugänglichkeit von Instruments und Techniken mit großer Sorten zur Analyse und Optimierung der Laufzeitleistung der KI/ML. Sie müssen kein CUDA -Experte sein, um Ihre Modellleistung sinnvoll zu verbessern und die Rechenkosten zu senken.
In diesem Beitrag werden wir die Verwendung von CUDA -Streams untersuchen, ein leistungsstarkes Merkmal des CUDA -Programmiermodells von NVIDIA, das eine ausgefeilte Methode zur Überlappung von GPU -Operationen und gleichzeitig ausführen kann. Obwohl wir normalerweise unsere Arbeitsbelastung für KI/ML -Modelltraining mit einem einzigen monolithischen (auch bekannt als „unzerbrechlichen“) Berechnungsdiagramm assoziieren G Wenn Sie auf der GPU ausgeführt werden, gibt es einige Szenarien, in denen die Grafik in zwei unterschiedliche Untergraphen zerlegt werden kann G1 Und G2Wo G = g2*g1. In solchen Fällen ermöglichen CUDA -Streams das „Pipelining“ des Berechnungsdiagramms, dh unser Trainingsschritt, um zu laufen G1 (Batch -Eingabe N+1) parallel zu G2 (auf dem nth Ausgabe von G1). Diese Technik ist besonders nützlich, wenn:
- Keiner der Subgraph nutzt die GPU, wenn sie alleine ausgeführt werden, und
- Die beiden Untergraphen haben ähnliche Rechenkosten (dh weder dominiert die Laufzeit).
Wir werden zwei gemeinsame Szenarien untersuchen, in denen „Pipelining“ machbar ist:
- Teilmodell-Coaching oder -Fecker:
Es ist üblich, ein vorgebildetes Modell einzufrieren Rückgrat (z. B. Function Extractor oder Encoder) und trainieren nur ein Modell Kopf (zB, Decoder). Seit der Gefrorenen Rückgrat verlässt sich nicht auf Gradienten aus dem Kopfdie beiden können gleichzeitig ausgeführt werden. - Abladen von Datenvorverarbeitung in die GPU:
Eine gemeinsame Methode zur Behandlung von Engpässen in der Eingabepipeline (auch als GPU -Starvation bezeichnet) kann die Datenvorverarbeitung in die GPU verschoben werden. Während die Vorbereitung der Vorverarbeitungsvorgänge in der Modelldiagramme die Leistung verbessert, können zusätzliche Gewinne erzielt werden, indem die Vorverarbeitung auf einem separaten CUDA -Stream parallel zur Modellausführung ausgeführt wird. Die Verarbeitung der Vorverarbeitung ist im Vergleich zu Modellkomputenten nicht trivial.
Um unsere Diskussion zu erleichtern, werden wir zwei Spielzeugtrainingsskripte definieren und die Trainingsleistung unter verschiedenen Szenarien messen. Die Experimente wurden auf einem durchgeführt Amazon EC2 G5.2XLARGE Instanz (mit einer NVIDIA A10G -GPU und 8 VCPUs) ausgeführt a Pytorch (2.6) Deep Studying AMI (Dlami).
Bitte beachten Sie: Die Code -Snippets, die wir teilen, dienen nur zu Demonstrationszwecken – bitte verlassen sich nicht auf ihre Richtigkeit oder Optimalität. Die Auswirkungen der Verwendung von CUDA -Streams variieren je nach Modellarchitektur und Systemkonfiguration. Wir ermutigen Sie, Ihr eigenes Profiling und Experimentieren durchzuführen, bevor Sie CUDA -Streams (oder eine andere Device -Technik, auf die wir uns beziehen) in Ihren Workflow integrieren.
Teil 1: Pipelination eines Encoder-Decoder-Modells
Der erste Anwendungsfall, den wir untersuchen, beinhaltet ein CNN-basierter Bildsegmentierungsmodell, das aus einem festen (vorgeborenen) Encoder und einem trainierbaren Decoder besteht. In diesem Szenario kann der Encoder unabhängig vom Coaching des Decoders unabhängig von der Schulung des Decoders ausgeführt werden, da die Encodergewichte eingefroren und nicht betroffen sind. In diesem Abschnitt bewerten wir die Auswirkungen der Pipelination des Trainingsprozesses mithilfe von CUDA -Streams.
Ein Spielzeugbild -Trainingsexperiment
Wir beginnen mit der Definition eines einfachen CNN-basierten Bildcodierers zusammen mit seinem entsprechenden Decoder.
undefined
Als nächstes erstellen wir einen synthetischen Datensatz mit zufälligen Bildern und Segmentierungskarten.
from torch.utils.knowledge import DataLoader
from torchvision.datasets.imaginative and prescient import VisionDataset
# A dataset with random pictures and per-pixel labels
class FakeDataset(VisionDataset):
def __init__(self):
tremendous().__init__(root=None)
self.dimension = 1000000
def __getitem__(self, index):
# create a random picture
img = torch.randint(0, 256, (3, img_size, img_size),
dtype=torch.uint8)
# create a random label map
goal = torch.randint(0, num_classes, (img_size, img_size))
return img, goal
def __len__(self):
return self.dimension
train_set = FakeDataset()
train_loader = DataLoader(
dataset=train_set,
batch_size=8,
num_workers=8
)
Schließlich definieren wir die Verlustfunktion, die Optimierer und die Trainingsschleife. Beachten Sie, dass wir die Gewichte des Encoders einfrieren und nur den Decoder trainieren.
import time
gadget = torch.gadget("cuda")
criterion = torch.nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(decoder.parameters())
# Freeze the encoder weights
encoder.requires_grad_(False)
encoder.eval().to(gadget)
decoder.prepare().to(gadget)
warmup = 10
active_batches = 100
total_iters = warmup + active_batches
for idx, knowledge in enumerate(train_loader):
inputs = knowledge(0).to(gadget=gadget, non_blocking=True).float()
labels = knowledge(1).to(gadget=gadget, non_blocking=True)
optimizer.zero_grad()
with torch.no_grad():
options = encoder(inputs)
output = decoder(options)
loss = criterion(output, labels)
loss.backward()
optimizer.step()
if idx == warmup:
# sync the GPU and begin the timer
torch.cuda.synchronize()
t0 = time.perf_counter()
if idx == total_iters:
break
# look ahead to the GPU to finnish after which cease the timer
torch.cuda.synchronize()
total_time = time.perf_counter() - t0
print(f'throughput: {active_batches / total_time}')
Unser Foundation -Trainingskript erreicht einen durchschnittlichen Durchsatz von 83 Schritten professional Sekunde mit einer durchschnittlichen GPU -Auslastung von 85%.
Pipelination der Modellausführung mit CUDA -Strömen
In der überarbeiteten Model der unten gezeigten Trainingsschleife stellen wir zwei CUDA -Streams vor: einen für die Ausführung des Encoders und eine für die Schulung des Decoders. In jeder Iteration führen wir zwei Operationen gleichzeitig aus:
- Trainieren Sie den Decoder mit den Bildfunktionen und Etiketten von Batch N.
- Führen Sie den Encoder auf der Eingabestapel aus N+1 Um seine Bildmerkmale zu erzeugen.
encoder_stream = torch.cuda.Stream()
decoder_stream = torch.cuda.Stream()
# initialize the options to None
options = None
for idx, knowledge in enumerate(train_loader):
inputs = knowledge(0).to(gadget, non_blocking=True).float()
labels_next = knowledge(1).to(gadget, non_blocking=True)
if options just isn't None:
with torch.cuda.stream(decoder_stream):
decoder_stream.wait_stream(encoder_stream)
optimizer.zero_grad()
output = decoder(options)
loss = criterion(output, labels)
loss.backward()
optimizer.step()
with torch.cuda.stream(encoder_stream):
with torch.no_grad():
options = encoder(inputs)
# Report that options was produced on s1_backbone
options.record_stream(encoder_stream)
labels = labels_next
if idx == warmup:
# sync the GPU and begin the timer
torch.cuda.synchronize()
t0 = time.perf_counter()
if idx == total_iters:
break
# look ahead to the GPU to complete after which cease the timer
torch.cuda.synchronize()
total_time = time.perf_counter() - t0
print(f'throughput: {active_batches / total_time}')
Diese Modifikation ergibt einen durchschnittlichen Durchsatz von 91 Schritten professional Sekunde, was eine Beschleunigung von 9,6% entspricht. Dies ist eine signifikante Verbesserung – insbesondere angesichts der Tatsache, dass unsere Grundlinie bereits eine hohe GPU -Nutzung hatte (85%).
Empfindlichkeit der Pipelination gegenüber Workload -Eigenschaften
Die Effektivität der Pipelinierung mit CUDA -Streams hängt stark von den Besonderheiten der Trainingsloadung und der Laufzeitumgebung ab. Wenn der Encoder deutlich größer ist als der Decoder (oder umgekehrt), kann Pipelining wenig Nutzen bieten oder sogar die Leistung behindern. Umgekehrt neigt die Pipelination, wenn die GPU nicht ausreichend genutzt wird, zu erheblicheren Gewinnen.
Um diese Abhängigkeit zu veranschaulichen, können wir das Experiment mit unterschiedlichen Chargengrößen umgeben. Die Ergebnisse sind nachstehend zusammengefasst:

Wenn die Chargengröße zunimmt, nimmt der Nutzen der Pipelinierung ab. Dies liegt wahrscheinlich daran, dass größere Stapelgrößen natürlich zu einer höheren (und effizienteren) GPU -Nutzung führen und durch gleichzeitige Ausführung weniger Raum für Verbesserungen hinterlassen.
Teil 2: Vergrößerungen auf die GPU abladen
In diesem Abschnitt werden wir die Verwendung von CUDA -Streams auf die Beschleunigung der Datenvergrößerung anwenden. In früheren Weblog -Posts (z. B.,, Hier Und Hier) Wir haben das Drawback der Engpässe auf der Dateneingangspipeline aus verschiedenen Perspektiven untersucht und verschiedene Techniken zur Diagnose und Ansprache überprüft. Eine häufige Ursache für diese Engpässe ist die Erschöpfung der CPU -Ressourcen, bei der die CPU die Rechenanforderungen der Vorverarbeitungspipeline nicht erfüllen kann. Das Ergebnis ist die GPU -Starvation – ein Szenario, in dem die teure GPU im Leerlauf sitzt und auf die Ankunft von Daten wartet.
Eine effektive Lösung besteht darin, starke Datenvorverarbeitung in die GPU zu laden. Wir werden diese Technik demonstrieren und einen Schritt weiter gehen, indem wir die Augmentationen auf einem dedizierten CUDA -Stream ausführen, wodurch die gleichzeitige Ausführung mit dem Modelltraining ermöglicht wird.
Ein Spielzeugbild -Klassifizierungs -Trainingsexperiment
Wir beginnen mit der Definition eines einfachen CNN-basierten Bildklassifizierungsmodells:
import torch
import torch.nn as nn
import torch
import torch.nn as nn
img_size = 256
num_classes = 10
mannequin = nn.Sequential(
# Begin with 256x256 picture
nn.Conv2d(3, 16, kernel_size=1),
nn.ReLU(inplace=True),
nn.Conv2d(16, 32, kernel_size=2, stride=2), # 2x downsample
nn.ReLU(inplace=True),
nn.Conv2d(32, 64, kernel_size=2, stride=2), # 4x downsample
nn.ReLU(inplace=True),
nn.Conv2d(64, 128, kernel_size=2, stride=2), # 8x downsample
nn.ReLU(inplace=True),
nn.Conv2d(128, 256, kernel_size=2, stride=2), # 16x downsample
nn.ReLU(inplace=True),
nn.Conv2d(256, 512, kernel_size=2, stride=2), # 32x downsample
nn.ReLU(inplace=True),
nn.Conv2d(512, 1024, kernel_size=2, stride=2), # 64x downsample
nn.ReLU(inplace=True),
nn.Conv2d(1024, 2048, kernel_size=2, stride=2), # 128X downsample
nn.ReLU(inplace=True),
nn.Conv2d(2048, 4096, kernel_size=2, stride=2), # 256X
nn.Flatten(),
nn.Linear(4096, num_classes)
)
Als nächstes erstellen wir einen synthetischen Datensatz mit einer Augmentation -Pipeline, die absichtlich zu einem schwerwiegenden Leistungs Engpass ausgelegt ist:
import random
from torch.utils.knowledge import DataLoader
import torchvision.transforms.v2 as T
from torchvision.datasets.imaginative and prescient import VisionDataset
import torchvision.transforms.v2.useful as F
import torchvision.ops as ops
# A dataset with random pictures and labels
class FakeDataset(VisionDataset):
def __init__(self, remodel = None):
tremendous().__init__(root=None, remodel=remodel)
self.dimension = 1000000
def __getitem__(self, index):
# create a random picture
img = torch.randint(0, 256, (3, img_size, img_size),
dtype=torch.uint8)
# create a random label
goal = torch.randint(0, num_classes, (1, ))
if self.remodel:
# Apply tranformations
img = self.remodel(img)
return img, goal
def __len__(self):
return self.dimension
augmentations = T.Compose((
T.ToDtype(torch.float32),
T.RandomCrop(img_size//2),
T.Resize(img_size),
T.RandomRotation(levels=45.0),
T.GaussianBlur(kernel_size=7),
T.Normalize(imply=(0, 0, 0), std=(1, 1, 1))
))
train_set = FakeDataset(remodel=augmentations)
train_loader = DataLoader(
dataset=train_set,
batch_size=32,
num_workers=8
)
Schließlich definieren wir die Verlustfunktion, die Optimierer und die Trainingsschleife:
import time
gadget = torch.gadget("cuda")
criterion = torch.nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(mannequin.parameters())
mannequin.prepare().to(gadget)
warmup = 10
active_batches = 100
total_iters = warmup + active_batches
for idx, knowledge in enumerate(train_loader):
inputs = knowledge(0).to(gadget=gadget, non_blocking=True)
labels = knowledge(1).to(gadget=gadget, non_blocking=True).squeeze()
optimizer.zero_grad()
output = mannequin(inputs)
loss = criterion(output, labels)
loss.backward()
optimizer.step()
if idx == warmup:
# sync the GPU and begin the timer
torch.cuda.synchronize()
t0 = time.perf_counter()
if idx == total_iters:
break
# look ahead to the GPU to finnish after which cease the timer
torch.cuda.synchronize()
total_time = time.perf_counter() - t0
print(f'throughput: {active_batches / total_time}')
Das Ausführen dieses Basiskripts führt zu einem durchschnittlichen Durchsatz von 20,41 Schritten professional Sekunde und einer GPU -Auslastung von nur 42%. Die schweren Datenvergrößerungen ersticken die CPU, die zum GPU -Starvation führt. Sehen Sie unsere Vorheriger Beitrag Weitere Informationen zum Erkennen von Engpässen auf der Dateneingangspipeline.
Ableiten von Datenvergrößerungen in die GPU
Um den Leistungs Engpass für die Dateneingangspipeline zu beheben, verschieben wir die Augmentationen auf die GPU.
Der erste Schritt ist zu definieren Benutzerdefinierte Datenumstellungen Dadurch werden zufällige Drehungen und Pflanzen professional Probe in einer Stapel angewendet. Dies ist wichtig, weil der eingebaute Einbau Torchvision Transformationen wenden die gleiche Augmentation über die gesamte Cost an-und verliert die Zufälligkeit der Probenprobe auf der CPU.
Wir implementieren die Batchrandomcrop Verwenden Sie mit dem roi_align Operator.
class BatchRandomCrop(T.Rework):
def __init__(self, output_size):
tremendous().__init__()
self.output_size = output_size
def remodel(self, img: torch.Tensor, params: dict):
batch_size, _, original_height, original_width = img.form
gadget = img.gadget
max_top = original_height - self.output_size
max_left = original_width - self.output_size
# Generate random prime and left coords for every picture within the batch
random_top = torch.randint(0, max_top + 1, (batch_size,),
gadget=gadget, dtype=torch.float32)
random_left = torch.randint(0, max_left + 1, (batch_size,),
gadget=gadget, dtype=torch.float32)
image_indices = torch.arange(batch_size, gadget=gadget,
dtype=torch.float32)
packing containers = torch.stack((
image_indices,
random_left,
random_top,
random_left + self.output_size,
random_top + self.output_size
), dim=1)
cropped_batch = ops.roi_align(
img,
packing containers,
output_size=self.output_size
)
return cropped_batch
Wir implementieren die Batchrandomrotate Überschreiten Sie durch Iterien über alle Bilder in der Stapel und wenden Sie eine zufällige Drehung auf jeden an. Beachten Sie, dass diese Model nicht vektorisiert ist; Eine vollständig vektorisierte Implementierung wäre mehr mehr Aufwand.
class BatchRandomRotation(T.Rework):
def __init__(self, levels):
tremendous().__init__()
self .levels = levels
def remodel(self, inpt: torch.Tensor, params: dict):
# break up the batch into an inventory of particular person pictures
pictures = record(torch.unbind(inpt, dim=0))
augmented_images = ()
for img_tensor in pictures:
# generate a random angle
angle = random.uniform(-self.levels, self.levels)
# apply the rotation to the one picture
transformed_img = F.rotate(
img_tensor,
angle=angle
)
augmented_images.append(transformed_img)
# stack the reworked pictures
return torch.stack(augmented_images, dim=0)
Wir definieren jetzt batch_transform Dies ahmt die oben definierte CPU-basierte Augmentation-Pipeline nach:
batch_transform = T.Compose((
T.ToDtype(torch.float32),
BatchRandomCrop(img_size//2),
T.Resize(img_size),
BatchRandomRotation(levels=45.0),
T.GaussianBlur(kernel_size=7),
T.Normalize(imply=(0, 0, 0), std=(1, 1, 1))
))
Schließlich setzen wir den Datensatz zurück und aktualisieren die Trainingsschleife, um das neue anzuwenden batch_transform:
train_set = FakeDataset(remodel=None)
train_loader = DataLoader(
dataset=train_set,
batch_size=32,
num_workers=8
)
for idx, knowledge in enumerate(train_loader):
inputs = knowledge(0).to(gadget=gadget, non_blocking=True)
labels = knowledge(1).to(gadget=gadget, non_blocking=True).squeeze()
# apply augmentations
inputs = batch_transform(inputs)
optimizer.zero_grad()
output = mannequin(inputs)
loss = criterion(output, labels)
loss.backward()
optimizer.step()
if idx == warmup:
torch.cuda.synchronize()
t0 = time.perf_counter()
if idx == total_iters:
break
torch.cuda.synchronize()
total_time = time.perf_counter() - t0
print(f'throughput: {active_batches / total_time}')
Dieses aktualisierte Trainingsskript verbessert den Durchsatz auf 35,22 Schritte professional Sekunde – eine Beschleunigung von 72,57% gegenüber dem Basisergebnis.
Pipelining -Augmentationen mit CUDA -Strömen
Als nächstes pipeline wir die Augmentations- und Trainingsschritte mit zwei separaten CUDA -Streams: Eine zum Ausführen der Datenumwandlung für das Coaching des Modells. In jeder Iteration der Schleife führen wir zwei gleichzeitige Operationen aus:
- Wir trainieren das Modell auf der Augmented Cost N.
- Führen Sie GPU-basierte Datenvergrößerungen auf Batch durch N+1
transform_stream = torch.cuda.Stream()
model_stream = torch.cuda.Stream()
# initialize the reworked worth to None
reworked = None
for idx, knowledge in enumerate(train_loader):
inputs = knowledge(0)
labels_next = knowledge(1)
if reworked just isn't None:
with torch.cuda.stream(model_stream):
labels = labels.to(gadget, non_blocking=True).squeeze()
model_stream.wait_stream(transform_stream)
optimizer.zero_grad()
output = mannequin(reworked)
loss = criterion(output, labels)
loss.backward()
optimizer.step()
with torch.cuda.stream(transform_stream):
inputs = inputs.to(gadget, non_blocking=True)
reworked = batch_transform(inputs)
# Report that the tensor was produced on transform_stream
reworked.record_stream(transform_stream)
labels = labels_next
if idx == warmup:
torch.cuda.synchronize()
t0 = time.perf_counter()
if idx == total_iters:
break
torch.cuda.synchronize()
total_time = time.perf_counter() - t0
print(f'throughput: {active_batches / total_time}')
Dies verbessert den Durchsatz weiter auf 38,82 Schritte professional Sekunde – ein Anstieg der serialisierten Lösung um 10,2% und 90,20% schneller als die ursprüngliche Basislinie
Empfindlichkeit der Pipelination gegenüber Workload -Eigenschaften
Wie wir in Teil 1 gesehen haben, variiert der Vorteil der Pipelination unter Verwendung von CUDA -Streams je nach Particulars der Arbeitsbelastung. In der folgenden Tabelle erfassen wir die Ergebnisse für verschiedene Chargengrößen:

Mit zunehmender Stapelgröße wird die GPU -Abladung effektiver und steigert die Leistung erheblich. Gleichzeitig nehmen die Gewinne durch Pipelining ab. Dies gilt wahrscheinlich für die Tatsache, dass größere Chargengrößen die GPU -Effizienz erhöhen und die Überlappungsmöglichkeiten verringern.
Zusammenfassung
Wenn es darum geht, KI/ML -Workloads auszuführen, zählt jede Millisekunden. In diesem Beitrag haben wir die Auswirkungen der Pipelination eines KI/ML -Trainingsschritts unter Verwendung von CUDA -Stream in zwei gemeinsamen Szenarien untersucht: Teilmodelltraining und Ausladung von Datenvergrößerungen in die GPU. In beiden Fällen übertraf die pipelierte Lösung die serialisierte Implementierung – obwohl das Ausmaß der Verbesserung aufgrund des Werts der Chargengröße signifikant variierte.
Wie wir während des gesamten Beitrags betont haben, kann die erwartete Auswirkungen der Verwendung von CUDA -Streams je nach KI/ML -Arbeitsbelastung stark variieren. In Fällen, in denen die GPU bereits effizient genutzt wird, kann der Overhead bei der Verwendung von CUDA -Streams tatsächlich zu einer Verschlechterung der Laufzeitleistung führen. Wir empfehlen dringend, diese Technik auf Ihren eigenen Workloads zu testen, bevor wir diesen Ansatz verfolgen.
Wir hoffen, dass Sie die in diesem Beitrag beschriebene Technik nützlich finden. Weitere Tipps, Methods und Techniken zum Profilieren und Optimieren von KI/ML -Workflows finden Sie in den anderen Posts in dieser Serie.
