Kapitel 4. Alles zusammenfügen: Effizientes Deep Learning

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

In Kapitel 2 hast du etwas über die Grundlagen und Datenflüsse von Deep Learning-Anwendungen gelesen. In Kapitel 3 hast du die verschiedenen Recheneinheiten kennengelernt, die heute verfügbar sind, und wie sie das Number Crunching in großem Maßstab ermöglichen. Dieses Kapitel baut auf den Inhalten der beiden vorangegangenen Kapitel auf, indem es die Beschleunigung durch spezialisierte Computerhardware demonstriert und einige Beispiele für praktische Anwendungen liefert. Außerdem werden einige Tipps und Tricks vorgestellt, wie man ein Deep-Learning-Modell auf einem einzigen Rechner mit höchstens einem beschleunigten Gerät effizient trainiert.

In diesem Kapitel gibt es zwei praktische Übungen, eine mit einem Sprachmodell (OpenAIs GPT-2) und die zweite mit einem Bildklassifizierungsmodell (EfficientNet).1 Die GPT-2-Übung ermöglicht es dir, den Grad der Beschleunigung, den ein Grafikprozessor bietet, zu erforschen und die Details der Profiling-Tools zu verstehen, um die zugrunde liegenden Auswirkungen zu verstehen. Im zweiten praktischen Beispiel lernst du, wie du mit dem MIT Scene Parsing Benchmark (SceneParse150) eine Lösung zur Segmentierung von Bildern mit mehreren Klassen erstellst. Nach diesen Übungen wirst du dir verschiedene Techniken ansehen, die du anwenden kannst, um deinen Code effizienter zu gestalten. Genauer gesagt lernst du etwas über Graphenkompilierung, Mixed-Precision-Training, Effizienzsteigerungen durch Gradiententricks, Speicherlayouttricks und einige DataLoader Tricks, um den Overhead der Modelleingabepipeline zu bewältigen. Das Kapitel endet mit einem kleinen Beispiel für einen benutzerdefinierten Kernel, der zeigt, wie man beschleunigte Berechnungen für benutzerdefinierte Operationen nutzen kann.

Praktische Übung 1: GPT-2

ChaptGPT ist ein einflussreiches generatives Sprachmodell, das in der Lage ist, sich über freie Eingabeaufforderungen zu "unterhalten". Die Technologie hinter ChatGPT ist der Generative Pre-trained Transformer (GPT) von OpenAI, ein großes Transformer-basiertes Sprachmodell. Die Skalierung war ein entscheidender Faktor für den Erfolg von GPT; es ist ein großes Modell, das mit riesigen Datenmengen trainiert wurde und viele Tricks anwendet, von denen einige bekannt sind und in Kapitel 10 behandelt werden. Es war die zweite Version von GPT, GPT-2, die einen beeindruckenden Erfolg erzielte. In dieser Übung wirst du dieses Modell untersuchen.

Die Aufgabe von für GPT-2 ist es, das nächste Wort anhand der vorherigen Wörter in einem Text vorherzusagen. GPT-2 hat mehr als 10x so viele Parameter und wurde mit 10x mehr Daten trainiert als das ursprüngliche GPT. Konkret: GPT-2 hat 1,5 Milliarden Parameter und wurde mit dem Text von 8 Millionen Webseiten trainiert.

Hinweis

GPT-2 wurde Anfang 2019 veröffentlicht. Seitdem (bis Anfang 2024) wurden auch GPT-3, -3.5 und -4 veröffentlicht, allerdings als Closed Source (mit sehr begrenztem Austausch von Implementierungsdetails). Einen Vergleich der Versionen nach verschiedenen Kriterien findest du in Tabelle 4-1, in "Key contributors to scale".

Ziele der Übung

Die Ziele dieser Übung sind wie folgt

  • Überprüfe Beispiele dafür, wie du infrastrukturunabhängigen Code schreibst. So kannst du im Falle eines Engpasses einfach auf eine andere Infrastruktur ausweichen.

  • Profil und Monitor, um das Verhalten deines Modells und deiner Lernschleife zu messen. Diese Fähigkeiten sind nützlich, um die Grenzen zu verstehen und Hinweise zur Optimierung zu erhalten.

  • Lerne Techniken kennen, mit denen du die Möglichkeiten der Hardware für eine effiziente Entwicklung nutzen kannst.

  • Optional kannst du dich über Docker und andere Build-Toolchains informieren, um Laufzeiten zu erstellen, die auf deine Entwicklungsbedürfnisse zugeschnitten sind, anstatt Schweizer Taschenmesser-Umgebungen wie NVIDIA GPU Cloud (NGC) Container zu verwenden.

Der folgende Abschnitt befasst sich mit der Architektur des Modells und den Details der Umsetzung. (Eine ausführlichere Darstellung findest du in Jay Alammars detaillierter Erklärung von GPT-2, in der komplizierte Konzepte wie der Transformer, der Aufmerksamkeitsblock, die maskierte Selbst- und Fremdaufmerksamkeit und die Rolle, die die Abfrage, der Schlüssel und der Wert bei der Vorhersage des möglichen nächsten Worts/Tokens spielen, erläutert werden).2 Der Code für diese Übung ist im Ordner chapter_4 im GitHub-Repository des Buches verfügbar.

Modell Architektur

Die Schlüsseltechnik, die GPT anwendet, besteht darin, einige Wörter im Textkorpus zu maskieren und das Modell darauf zu trainieren, die maskierten Wörter unter Ausnutzung des restlichen Textkorpus vorherzusagen. Man könnte dem Modell zum Beispiel den Text "Ich habe ein Buch mitgebracht" zeigen und es bitten, das fehlende Wort "lesen" vorherzusagen. Wie du siehst, kann es mehr als ein Wort geben, das hier passen könnte (z. B. "lernen"). Diese möglichen Wörter müssen gewertet werden, um das empfohlene Wort zu finden. Mit Aufmerksamkeitsblöcken können Transformer-Architekturen längere Sequenzen von Token parallel bearbeiten. In Verbindung mit der Maskierungsfunktion kann das Modell so ein besseres Sprachverständnis entwickeln und die semantische Bedeutung des Textes über die Worteinbettung nutzen.

GPT-2 ist ein autoregressives Modell nur für Decoder. Die vollständige Architektur ist in Abbildung 4-1 dargestellt, einschließlich einer Aufschlüsselung des GPT-Blocks (der Decoderkomponente). Diese Abbildung zeigt, wie die Texteinbettung und die Positionskodierung durch das Netz fließen, das aus einer Reihe von mehrköpfigen maskierten Aufmerksamkeitsblöcken besteht, die zusammen mit den Schichten LayerNorm (Schichtnormalisierung) und Conv1d (1D-Faltung) aufgebaut sind. Du wirst feststellen, dass dieses Netz mehrere Restverbindungen aufweist. Der Aufmerksamkeitsblock hat sowohl Selbst- als auch Fremdaufmerksamkeit, so dass (wie in der unteren rechten Ecke der Abbildung zu sehen ist) entweder die Abfrage, der Schlüssel und der Wert alle aus derselben Textsequenz stammen können, oder die Abfrage aus einer Sequenz und der Schlüssel und der Wert aus einer anderen.

Die wichtigsten Faktoren für die Skalierung

Schauen wir uns einige der wichtigsten Merkmale von GPT-2 an, die zur Skalierung dieser Technik beigetragen haben.

Aufmerksamkeitsblock Transformator

Ashish Vaswani et al. schlugen den mehrköpfigen Aufmerksamkeitsblock, der in GPT verwendet wird, ursprünglich in ihrem bahnbrechenden Artikel "Attention Is All You Need" vor.3 Das Hauptmerkmal dieser Architektur ist, dass die Wörter des Textes als Ganzes verarbeitet werden. Andere moderne Architekturen wie rekurrente neuronale Netze (RNNs) und Netze mit Langzeitgedächtnis (LSTMs) verarbeiten die Wörter einzeln in einer zeitlichen Abfolge und erinnern sich an die Darstellung dieser Wörter durch versteckte Zustände. Die Unfähigkeit, Wörter parallel zu verarbeiten, war ein Hauptgrund dafür, dass sich die Fähigkeiten von Sprachmodellen vor dem Transformator nicht schnell verbesserten. Mit der Einführung des Transformers konnte ein größerer Kontext durch eine Folge von Token/Wörtern bereitgestellt werden. Bei GPT-2 betrug diese Kontextgröße 1.024 Token und war damit doppelt so groß wie bei GPT (siehe Tabelle 4-1). In GPT-4 wurde die Kontextgröße auf eine Anfangsgröße von 8.192 Token erhöht,4 und die Argumentationsfähigkeiten dieses Netzwerks haben sich entsprechend erweitert.

Abbildung 4-1. Die Architektur des GPT-2 und die detaillierte Aufschlüsselung seines Teilnetzes
Tabelle 4-1. Die Entwicklung der verschiedenen GPT-Modelle über die Versionen hinweg
Name Kontext Größe Anzahl der Parameter Lagen Tiefe des Modells Vergleichbares Modell (in Bezug auf die Kapazität)
GPT-2 klein 1,024 117M 12 768 GPT (Original)
GPT-2 Medium 1,024 345M 24 1,024 BERT
GPT-2 groß 1,024 762M 36 1,280
GPT-2 XL 1,024 1,542M 48 1,600
GPT-3 klein 2,048 125M 12 768 GPT-2 klein
GPT-3 Medium 2,048 350M 24 1,024 GPT-2 Medium
GPT-3 groß 2,048 760M 24 1,536 GPT-2 groß
GPT-3 XL 2,048 1.3B 24 2,048 GPT-2 XL
GPT-3 2.7B 2,048 2.7B 32 2,560
GPT-3 6.7B 2,048 6.7B 32 4,096
GPT-3 13B 2,048 13B 40 5,140
GPT-3 175B 2,048 175B 96 12,288
GPT-4 8,192 Unbekannt (Gerüchten zufolge 1,76T) Unbekannt Unbekannt
GPT-4-32k 32,768 Unbekannt Unbekannt Unbekannt

Unüberwachtes Training

Wie unter bereits erwähnt, wird das Training von GPT durch das Ausblenden bestimmter Token im Trainingsdatensatz erreicht. Dank dieser Technik ist für das Training dieser großen Sprachmodelle keine Kennzeichnung oder Überwachung erforderlich. Die Trainingsdaten lassen sich daher leicht über einen Webcrawler beschaffen. Angesichts der enormen Menge an Textdaten, die im Internet verfügbar sind, ist nur noch die Qualität der Eingaben ein Problem. OpenAI setzt eine Filtertechnik ein, um Textseiten auszuwählen, die mindestens drei User-Uvotes ("Likes" der Seite) mehr erhalten haben, um die Qualität der Inhalte sicherzustellen, die zum Trainieren des Modells verwendet werden. Diese gescrapten Texte bildeten das Vokabular von GPT-2, das ebenfalls von den 40.478 einzigartigen Token, die für das Training von GPT verwendet wurden, auf 50.257 erweitert wurde.

Null-Schuss-Lernen

In der ersten Version von GPT konzentrierte sich OpenAI auf das Training eines großen Sprachmodells durch Vortraining und Feinabstimmung verschiedener Versionen des Modells für verschiedene Sprachanwendungen wie Fragebeantwortung, Textzusammenfassung, Sprachübersetzung und Leseverständnis. Da die semantischen Beziehungen zwischen den Wörtern jedoch bei allen Anwendungen der gesprochenen Sprache gleich bleiben, wurde bei GPT-2 festgestellt, dass das Modell durch Zero- oder Little-Shot-Techniken (die in den Kapiteln 11, 12 und 13 besprochen werden) für solche Aufgaben umgewidmet werden kann. Mit anderen Worten: Es ist möglich, ein sehr gutes Sprachmodell zu trainieren und es zum Zeitpunkt der Inferenz für verschiedene Aufgaben zu verwenden. Dies ist möglich, weil Zero-Shot-Techniken keine Backpropagation erfordern und die Wiederverwendung somit sehr kosten-, rechen- und zeiteffizient ist.

Dies ist ein sehr wichtiger Aspekt der GPT, der bei der Modellentwicklung für verwandte Aufgaben ernsthaft in Betracht gezogen werden sollte. Die Wiederverwendung eines bestehenden Modells für eine verwandte Aufgabe durch Feinabstimmung oder Anpassung der Inferenzzeit, wie beim Meta-Lernen und Zero-Shot-Lernen, ist ein großartiger Optimierungstrick. Dies wird in Kapitel 11 ausführlich behandelt.

Parameter-Skala

Wie bereits erwähnt, ist GPT-2 einfach eine vergrößerte Version von GPT (mit einer leichten Umstrukturierung der LayerNorm Schichten). Die Skalierung in GPT-2 ergibt sich aus der Verwendung einer größeren Kontextgröße und mehr GPT-Blöcken(Abbildung 4-1 und Tabelle 4-1). Die nachfolgenden Versionen des Modells wurden weiter skaliert; das größte Modell GPT-3 hat zum Beispiel 175 Milliarden Parameter.5 Zum Vergleich: Der Mensch hat schätzungsweise 100 Billionen Synapsen.

Umsetzung

Obwohl der Code für GPT-2 von OpenAI freigegeben wurde, wirst du in dieser Übung die Transformers-Bibliothek von Hugging Face verwenden, die eine PyTorch-Implementierung der in Abbildung 4-1 dargestellten GPT-2-Architektur bietet. Konkret verwendest du die Bibliothek GPT2LMHeadModel, einen GPT2-Modelltransformator mit einem Kopf für die Sprachmodellierung oben drauf.

Für die Verkabelung von GPT-2 verwendest du PyTorch Lightning, wie in Kapitel 2 beschrieben. Für das Training verwendest du den Wikitext-Datensatz Hugging Face.

Wie bereits erwähnt, ist der Code für diese praktische Übung im GitHub-Repository des Buches verfügbar. Die Implementierung wird in den folgenden Abschnitten erklärt.

model.py

Die Modellimplementierung in diesem Skript ist einfach, da der größte Teil der Komplexität des Modells in GPT2LMHeadModel abstrahiert wird. Die Eingabepipeline des Modells tokenisiert den Text, und der Aufruf von forward wird an GPT2LMHeadModel weitergeleitet, das den Cross-Entropie-Verlust zwischen dem vorhergesagten Token und dem wahren Token zurückgibt. Dieses Skript wickelt das Modell aus der Transformers-Bibliothek in die Lightning-Abstraktion von LightningModule ein.

dataset.py

Die Implementierung des Datensatzes erfolgt in WikiDataModule, das die Version 2 des Wikitext-Datensatzes umschließt. In diesem Modul wird der Rohtext heruntergeladen, vorverarbeitet, mit Token versehen und dann entsprechend der Blockkapazität des Modells gruppiert. Die Daten werden in Trainings-, Validierungs- und Testdatensätze aufgeteilt und für jedes Modul wird ein DataLoader erstellt.

app.py

Der Code des Trainers ist in app.py enthalten. Beachte besonders die Einstiegsmethode train_gpt2, die verschiedene in das Trainingssystem integrierte Komponenten definiert, darunter Callbacks und Profiler (ähnlich wie bei der praktischen PyTorch-Übung in Kapitel 2). Die Profiler sollten nur während der Entwicklung und der formalen Läufe verwendet werden, da sie Kosten in Form von Speicher- und Rechenressourcen verursachen. Der Trainercode sieht wie folgt aus :

datamodule = WikiDataModule(name=model_name, batch_size=batch_size, 
                            num_workers=num_workers)
model = GPT2Module(name=model_name)

trainer = PLTrainer(
    accelerator="auto",
    devices=="auto",
    max_epochs=max_epochs,
    callbacks=[
        TQDMProgressBar(refresh_rate=refresh_rate),
        ckpt_cb,
        DeviceStatsMonitor(cpu_stats=True),
        EarlyStopping(monitor="val/loss", mode="min"),
    ],
    logger=[
        exp_logger,
        TensorBoardLogger(save_dir=result_dir / "logs"),
    ],
    profiler=torch_profiler,
)
trainer.fit(model, datamodule)

Beachte die Argumente accelerator und devices in diesem Code. Diese Parameter ermöglichen es, plattformunabhängigen Code zu schreiben, so dass derselbe Code auf CPUs, GPUs, TPUs oder anderen verschiedenen Beschleunigern ausgeführt werden kann. Er unterstützt auch Berechnungen mit Metal Shadern, die in Apples GPUs der M-Serie verfügbar sind. Mit anderen Worten: Mit diesem Snippet kannst du das erste Ziel abhaken, nämlich plattformunabhängigen Code zu schreiben, den du überall ausführen und nach deinen Bedürfnissen skalieren kannst.

Hinweis

Es gibt einige andere Möglichkeiten, dies zu tun, z. B. mit Hugging Face's Trainer, das eine ähnliche Funktion bietet. Aus Gründen der Konsistenz und Wiederverwendbarkeit wird in diesen Übungen jedoch PyTorch Lightning als Orchestrator verwendet.

Ausführen des Beispiels

Um den Code auszuführen, führe den folgenden Befehl in deiner Umgebung aus:

deep-learning-at-scale chapter_4 train_gpt2

Nachfolgend sind einige Beispiele für die Ergebnisse aufgeführt, die nach dem Training dieses Modells mit dem Wikitext-Datensatz für 50 Epochen erzielt wurden. Die Eingabeaufforderung lautete: "Ich habe mein ganzes Leben lang auf ein Buch über Deep Learning im Maßstab gewartet. Jetzt, wo ich eines habe, werde ich es lesen. Und ich." Hier ist ein Teil des generierten Textes:

  • Ich habe das Gefühl, dass es noch lange dauern wird, bis ich mich davon zurückziehe.

  • Ich habe so viel daraus gelernt.

  • Ich habe keinen Zweifel daran, dass es mein Leben verändern wird.

  • Ich werde alles tun, was ich kann, um ihm zu helfen. Ich werde auf der...

  • Ich werde alles tun können, was ich normalerweise im Leben tun würde.

  • Ich bin gespannt, wie sich das alles entwickelt.

  • Ich habe schon lange auf ein Deep Learning-Buch gewartet.

  • kann ich sehen, was ich tun kann, damit es klappt.

Experiment Tracking

Diese Übung auf (und andere in diesem Buch) verwendet Aim, eine Open-Source-Lösung zur Verfolgung von Experimenten, um den Fortschritt und die Kennzahlen der Trainingsläufe zu überwachen und zu visualisieren. Ich habe mich für Aim entschieden, weil es kostenlos und ohne jegliche Verpflichtung für dich nutzbar ist. Es gibt jedoch mehrere Alternativen, die du nach Belieben verwenden kannst. Einige dieser Alternativen werden in Kapitel 11 kurz besprochen (siehe "Einrichten für die iterative Ausführung"). Das folgende Snippet wird verwendet, um den Experiment-Tracking-Logger zu aktivieren:

exp_logger = AimLogger(
    experiment=exp_name,
    train_metric_prefix="train/",
    val_metric_prefix="val/",
    test_metric_prefix="test/",
)

Dieser Logger wird dann mit der Trainer-API als logger konfiguriert, d.h. trainer = Trainer(... , logger=[exp_logger]), wie in dem früheren Schnipsel Trainer gezeigt.

Hinweis

Du musst den aim-Server lokal starten, indem du den Befehl aim up ausführst, um die Laufprotokolle in aumhub zu visualisieren. Deine Läufe protokollieren sowohl Profiler-Protokolle als auch Laufprotokolle, die du mit Tensorboard bzw. aimhub visualisieren kannst (wie in Kapitel 2 beschrieben). Die in den Abbildungen 4-2 und 4-3 gezeigten Ergebnisse können mit diesen beiden Tools visualisiert werden.

Messen, um die Grenzen zu verstehen und auszubauen

In diesem Abschnitt werden die Leistungsmessungen vorgestellt, die durch die Ausführung des Codes auf einer High-End-CPU und einem Grafikprozessor erzielt wurden, und die beiden verglichen, um die Einschränkungen zu untersuchen.

Läuft auf einer CPU

Abbildung 4-2 zeigt die Ergebnisse, die erzielt wurden, als dieser Code auf einem nicht-beschleunigten Computer mit einer 10-Kern-CPU und 32 GB Arbeitsspeicher (RAM) ausgeführt wurde. Die Batchgröße wurde bei diesem Lauf auf 12 festgelegt.

Abbildung 4-2. Bildschirmabzüge der Ressourcennutzung und des Operator-Profils, die bei der Ausführung dieses Beispiels auf einem reinen CPU-Rechner erstellt wurden

Die durchschnittliche Zeit für einen Schritt (d.h. die Verarbeitung eines Datenstapels und die Durchführung von Vorwärts- und Rückwärtsdurchläufen) betrug etwa 304 Sekunden. 97,46 % dieser Zeit wurde für die Ausführung von Operationen auf der CPU und 2,54 % für Nicht-CPU-Operationen aufgewendet. Der Zeitaufwand für das Laden der Daten (z. B. das Laden des Wikitext-Datensatzes in den Speicher für die Ausführung) war vernachlässigbar gering.

Die Grafik des Profilers zum Speicherverbrauch zeigt einen Spitzenwert von 42,64 GB an. Wenn du dir jedoch die kontinuierliche Überwachung der Speichernutzung, in diesem Fall in den Systemmetriken von Aim, ansiehst, kannst du feststellen, dass der Spitzenwert bei 50 GB liegt.

Tipp

Profiler werden so konfiguriert, dass die Schritte, in denen die Profilerstellung durchgeführt werden soll, ausgewählt werden. Das siehst du an dem Zeitplan, der in diesem Beispiel für den Profiler konfiguriert wurde: torch.profiler.schedule(wait = 1, warmup = 1, active =5, repeat = 10, skip_first = True). Das ist nützlich, um den Overhead der Profilerstellung zu minimieren, birgt aber das Risiko, dass die Nutzung nicht angemessen erfasst wird. Aus diesem Grund ist eine kontinuierliche Überwachung auf hoher Ebene sinnvoll und die Zeitplaneinstellungen sollten so angepasst werden, dass eine gute Abdeckung gewährleistet ist.

Die Systemmetriken zeigen auch, dass die CPU-Auslastung bei diesem Lauf einen Spitzenwert von 410 % erreichte. Wenn du den Hauptprozess mit einem Tool wie htop beobachtest, siehst du, dass er eine virtuelle Größe von satten 453 GB hat. Dazu gehören der physische Speicher, der Swap-Speicher, Dateien auf der Festplatte, die dem Prozess zugeordnet wurden (z. B. gemeinsam genutzte Bibliotheken), und der gemeinsam genutzte Speicherplatz (z. B. mit anderen Prozessen).

Abbildung 4-2 zeigt die 10 Operationen, die am längsten für die Berechnung gebraucht haben, wobei die Batch-Matrix-zu-Matrix-Operation aten::bmm mit 15,2 % den größten Zeitanteil beansprucht hat. Wenn du die Stapelgröße erhöhst, kannst du die Gesamtzahl der Schritte reduzieren (d.h. die Gesamtzahl der Aufrufe dieser Operation pro Epoche).

Bei einer Losgröße von 12 beläuft sich die Gesamtzahl der Schritte auf 184. Hypothetisch gesprochen würde sich die Anzahl der Schritte auf 92 verringern, wenn die Stapelgröße auf 24 verdoppelt würde. Wenn du mehr Rechenzyklen zur Verfügung hättest, würde die Verdopplung der Stapelgröße die Kosten für diese Berechnung halbieren, da die aten::bmm Operation vektorisiert ist. Mit zunehmender Stapelgröße steigt jedoch der Speicherbedarf für den Eingabetensor (die Token-Einbettung) und damit auch der Speicherbedarf für die Gradienten. Der Speicherbedarf der Gradienten skaliert linear mit der Stapelgröße, d.h. die Raumkomplexität ist durch O(batch_size) gegeben. Die Auswirkungen auf die stichprobenartigen Verluste und Metriken würden ebenfalls in derselben Größenordnung steigen.

Aus diesem Grund ist es wichtig, ein optimales Gleichgewicht zwischen CPU und Speicher (physisch und virtuell) zu finden. In diesem Fall war es mit der verfügbaren CPU-Hardware einfach nicht möglich, eine Stapelgröße von 24 zu verwenden, da das System Probleme mit dem Arbeitsspeicher (OOM) bekam. Sogar bei einer Stapelgröße von 12 reagierte das Hostsystem sporadisch nicht mehr. Es gibt Autotuning-Techniken wie die Torch Memory-adaptive Algorithms (TOMA), die die am besten geeignete Stapelgröße finden können, indem sie bei OOM-Fehlern den Versuch mit einer niedrigeren Größe wiederholen. Mit diesen Tricks entfällt der manuelle Aufwand für die richtige Stapelgröße, und sie können einfach über die light⁠ning.pytorch.tuner.Tuner.scale_​batch_size() API aktiviert werden.

Wie in Kapitel 3 beschrieben, ist es auch möglich, den Speicherbedarf durch eine Verringerung der Genauigkeit der Tensoren zu verringern; Standard-CPUs verfügen jedoch nicht über spezielle Hardware für die verschiedenen dort erwähnten Gleitkommaformate. Beschleunigereinheiten sind eher auf Data Crunching spezialisiert und verfügen über zusätzliche Hardware zur Unterstützung von Berechnungen mit geringerer Genauigkeit; CPUs sind Allzweckhardware, bei der diese Unterstützung weitgehend fehlt oder emuliert wird.

Wenn man bedenkt, dass das System bei einer Batchgröße von 12 sporadisch nicht reagierte und durchschnittlich 304 Sekunden für einen Schritt benötigte, würde es etwa 15,5 Stunden dauern, um eine Epoche (184 Schritte) zu beenden. Die Funktionsverfolgung, die ein Profiling-Tool wie der in diesem Beispiel verwendete PyTorch Profiler bietet, ist sehr hilfreich, um die Dauer und Art der Operationen zu verstehen, die den Prozessraum belegen (siehe Abbildung 4-3). Dies ist nützlich, um suboptimale Aufrufe für weitere Optimierungen zu identifizieren.

Abbildung 4-3. Funktionsverfolgung durch den PyTorch Profiler, wenn dieses Beispiel auf einer CPU ausgeführt wird

Die wichtigste Erkenntnis aus dieser Übung ist, dass du das Beste aus dem CPU-basierten Training herausholen kannst, wenn du den Speicher und die Anzahl der CPU-Kerne richtig dimensionierst und die Stapelgröße einstellst. Allerdings kann die Durchlaufzeit für Deep Learning auf einer CPU unzureichend sein - 15+ Stunden für eine Epoche, wie in diesem Fall, ist ein sehr unpraktischer Feedback-Zyklus für eine schnelle Entwicklung und ein gutes Nutzererlebnis.

Im folgenden Abschnitt sehen wir uns an, wie dieses Beispiel auf einem heterogenen Computer skaliert werden kann, indem eine ähnliche CPU wie in diesem Abschnitt in Verbindung mit einer NVIDIA A100 80 GB GPU verwendet wird.

Betrieb auf einer GPU

In Kapitel 3 hast du das Ausführungsmodell für beschleunigtes Rechnen kennengelernt und erfahren, wie der Host/CPU die parallele Ausführung von Operatoren (z. B. des Kernels) auf einem Grafikprozessor ermöglicht. Du hast auch erfahren, welche Schritte notwendig sind, um Daten vom Host in den GPU-Speicher zu übertragen und welchen Aufwand dies aufgrund der begrenzten Bandbreite verursachen kann.

In dieser Übung lädt die Pipeline für die Modelleingabe (Daten) die Textdaten in den Speicher, führt eine Vorverarbeitung und Tokenisierung durch und erzeugt die Einbettung, wie in Abbildung 4-1 dargestellt. Diese Einbettungen - die Matrix-Tensoren - werden in den VRAM der GPU geladen. Während der Berechnung werden dann verschiedene Kernel-Funktionen auf der GPU aufgerufen, um die erforderlichen Operationen vektorisiert auf den Tausenden von Kernen auszuführen.

Abbildung 4-4 ist ein Ausschnitt aus einer Funktionsaufzeichnung eines Trainingslaufs dieses Beispiels auf einem NVIDIA A100 SXM 80 GB Grafikprozessor. Die obere Aufzeichnung bezieht sich auf einen Thread, der vom Hauptprozess gestartet wurde und die Kommunikation zwischen dem Gerät und dem Host verwaltet. Diese Spur zeigt auch einen anderen Thread auf demselben Host, der die CPU-gebundenen Berechnungen verwaltet (z. B. die Modelleingabe-Pipeline). Der untere Teil der Abbildung zeigt den Stack für den Thread, der für die GPU-Berechnungen zuständig ist. Beachte die entsprechende Vertiefung im Streaming-Multiprozessor, wenn die Speicherkopiervorgänge im Gange sind. Die unterste Zeile zeigt die verstrichene Zeit für jede der Kernel-Funktionen, die in den Phasen der Profilerstellung aufgerufen wurden. Beachte, wie unterschiedlich das Tracing von einer CPU und einer GPU ist (Abb. 4-3 bzw. 4-4).

Abbildung 4-4. Funktionsverfolgung durch den PyTorch Profiler bei Ausführung auf einer GPU

Um das Beispiel auf einem Grafikprozessor auszuführen, kannst du denselben Befehl wie für die CPU verwenden (siehe "Ausführen des Beispiels"). Da der Code plattformunabhängig geschrieben wurde, sind keine Änderungen erforderlich. Du musst jedoch sicherstellen, dass auf deiner Hardware ein NVIDIA-Treiber und eine Laufzeitumgebung installiert sind. Da eines der Ziele dieser Übung darin besteht, die Nutzung zu überwachen, musst du auch das CUDA Profiling Tools Interface (CUPTI) installiert haben. Hinweise zu den vollständigen Einrichtungsanweisungen findest du unter "Einrichten deiner Umgebung für praktische Übungen".

Hinweis

CUDA-Kernel-Profiling-Tools wie CUPTI und NVProf sind sehr hilfreich, aber sie liefern nur Profile auf Operator-/Kernel-Ebene. Leider bieten sie keine Perspektive auf höherer Ebene, z. B. auf die neuronale Schicht. Diese Kontextualisierung muss vom Benutzer vorgenommen werden.

Die erste Beobachtung, die man machen kann, wenn man diese Übung auf einer A100 80 GB GPU ausführt, ist, dass die Arbeitsspeicherauslastung des Hosts deutlich auf marginal gesunken ist. Außerdem werden bei einer Stapelgröße von 12 nur etwa 40 GB VRAM auf der GPU genutzt. Bemerkenswert ist auch, dass die durchschnittliche Schrittzeit von 304 Sekunden bei einem reinen CPU-Lauf auf 4 Sekunden gesunken ist. Wie du in Abbildung 4-5 sehen kannst, liegt die GPU-Auslastung bei 94,5 %, wobei 0,4 % für Speicherkopien, 1,5 % für CPU-gebundene Operationen und 3,7 % für andere Operationen verwendet werden. Bemerkenswert ist, dass DataLoadernur noch einen geringen Teil davon ausmacht.

Abbildung 4-5. Screenshots von Funktions-/Operator-Tracing und GPU-Kernel-Profiling, die bei der Ausführung dieses Beispiels auf einem heterogenen Rechner mit einer NVIDIA GPU erstellt wurden

Wir haben 80 GB und nur 50% werden genutzt. Erhöhen wir nun die Stapelgröße auf 24. Die ideale Auslastung sollte theoretisch 100 % betragen, was bedeutet, dass alle Kerne maximal ausgelastet sind. Bei einer Stapelgröße von 24 steigt die GPU-Auslastung um weniger als 2 % (auf 96,1 %), wie rechts in Abbildung 4-5 zu sehen ist. Die CPU-Ausführung und die "anderen" Vorgänge gehen entsprechend zurück.

Ein Blick auf das untere Ende von Abbildung 4-5 zeigt, dass der am längsten laufende Operator nicht mehr aten::bmm ist (wie im Fall des reinen CPU-Laufs in Abbildung 4-2), wenn ein Grafikprozessor ins Spiel kommt. Die teuerste Operation ist nun autograd::engine::evaluate_function:EmbeddingBackward0, die die Berechnung des Gradienten während der automatischen Differenzierung (d. h. die Rückwärtspropagation) betrifft. Der Grund für diesen Unterschied ist, dass die Batch-Matrix-zu-Matrix-Operation aten::bmm auf einem Grafikprozessor viel einfacher skaliert werden kann und die Durchlaufzeit durch die Parallelisierung massiv sinkt. Dies ist im Grunde eine gute Anwendung des Amdahlschen Gesetzes, da der skalierbare Teil der Berechnung - die Matrixoperation - parallelisiert wird. Andere teure Operationen hängen mit dem Kopieren zusammen, was auf die Herausforderungen bei der Speicherauslastung hindeutet.

Wie erwartet, steigt mit zunehmender Stapelgröße die GPU-Auslastung und die Schrittzeit sinkt. Es gibt jedoch keine Auswirkungen auf die Reihenfolge der Top-Operationen. Die in Abbildung 4-5 gezeigte Kurve ist weiterhin ähnlich, allerdings mit einem Anstieg der Auslastungsstatistiken.

Wie in Kapitel 3 beschrieben, verfügen die meisten modernen Beschleuniger, einschließlich der NVIDIA-GPUs, über Recheneinheiten für verschiedene Präzisionsformate. Der A100 verfügt über Tensor Core-Einheiten, die in der Lage sind, die passende Genauigkeit zu ermitteln und auf dieser Ebene auszuführen. Die drei verfügbaren Modi highest, high und medium beziehen sich auf abnehmende Reihenfolgen der internen Genauigkeit, wobei die höchste Stufe die Verwendung von Gleitkommazahlen mit einfacher Genauigkeit (32-Bit) gewährleistet. Wenn du die folgende Konfiguration verwendest, um diese Fähigkeit zu aktivieren, wirst du eine Zeitersparnis von etwa 0,7 s pro Schritt auf der mittleren Genauigkeitsstufe feststellen, was einer neuen durchschnittlichen Schrittzeit von 3,3 s entspricht:

torch.backends.cuda.matmul.allow_tf32 = True
torch.set_float32_matmul_precision("medium")

Der Profiler zeigt, dass nur 37,6 % der GPU-Berechnungen mit dem Tensor Core durchgeführt wurden, was darauf hindeutet, dass andere Operatoren möglicherweise nicht mit dem tf32 Format kompatibel waren. Dieses Verständnis ist bei der Optimierung des Trainingslaufs sehr hilfreich, denn es macht deutlich, wo der größte Teil der Rechen- und Speicherkosten anfällt, und ermöglicht es, Alternativen zu erforschen, falls diese kritisch sind.

Wenn du die Genauigkeit des Modells mit und ohne tf32 vergleichst, wirst du feststellen, dass die Auswirkungen des Präzisionsverlusts in diesem Fall vernachlässigbar sind. Je nach Situation kann es sich jedoch lohnen, diesen Kompromiss einzugehen. Dieser Trick nutzt die Tensor Core Architektur, die speziell für NVIDIAs Ampere und spätere GPU-Serien entwickelt wurde. Wenn deine Hardware nicht mit Tensor Core ausgestattet ist, kann die Vereinfachung durch die Verwendung von Standardpräzisionsformaten wie fp16 oder gemischt fp16 ebenfalls einen erheblichen Vorteil bieten. Mehr darüber erfahren Sie unter "Gemischte Präzision".

Tipp

Die Aktivierung von tf32 compute überschneidet sich mit dem Training mit gemischter Genauigkeit. Abhängig von der tf32 Unterstützung der in deinem Modell verwendeten Operatoren kann die Verwendung von gemischter Genauigkeit zusätzlich zur Aktivierung von tf32 Vorteile bringen, aber wie viel, hängt von deinem Modell und seinen Funktionen ab.

Der Übergang von der Sprache zur Vision

Die hier vorgestellte Übung deckt viele Aspekte von Sprachmodellen ab. Vor den Transformer-Architekturen waren die Nuancen der Eingabemodalität (z. B. Text, Bild) ausschlaggebend für die Modellierungstechniken und erforderten spezielle Kenntnisse im Umgang mit den verschiedenen Eingabearten. So wie die Sequenzmodelle den Sprachbereich dominierten, wurden die Faltungstechniken in den Computer-Vision-Modellen stark genutzt. Die Verwendung unterschiedlicher Modellierungstechniken für die einzelnen Eingabemodalitäten nimmt nun ab, da sich Transformers als allgegenwärtige Technik herauskristallisiert haben, die auf alle Eingabemodalitäten anwendbar ist. Transformator-basierte Architekturen wie der Vision Transformer (ViT)6 und Masked-attention Mask Transformer (Mask2Former)7 erweisen sich als leistungsfähig. Transformer-Netzwerke haben jedoch eine quadratische Berechnungskomplexität in Bezug auf die Eingangsdimension, was bei der Anwendung auf multimediale Inhalte wie z. B. gepixelte Bilder den Ressourcenbedarf explodieren lässt. Je nach Komplexität der Aufgabe können Faltungsarchitekturen rechnerisch weniger anspruchsvoll und dennoch effizient sein.

Im folgenden Abschnitt geht es um eine Computer-Vision-Übung, bei der die Faltungstechnik verwendet wird.

Praktische Übung #2: Sichtmodell mit Faltung

Die Aufgabe für diese Übung besteht darin, Segmentierungsergebnisse für die Bilder im MIT Scene Parsing Benchmark (SceneParse150) Datensatz zu erstellen, die mit 150 Objektkategorien annotiert sind. Wenn du den Hintergrund oder unbekannte Objekte berücksichtigst, wirst du insgesamt 151 Klassen segmentieren. Diese Übung veranschaulicht den Skalenkanal, wenn die Anzahl der Klassen, für die du segmentierst, ziemlich groß ist.

Modell Architektur

Diese Übung nutzt Faltungsneuronale Netze (CNNs), eine auf Computer Vision basierende Deep Learning-Technik. Genauer gesagt verwendest du EfficientNet als Feature-Encoder und Convolve als Decoder-Blocker, die zu einer U-Net-basierten Architektur zusammengefügt werden.8

Die wichtigsten Faktoren für die Skalierung bei der Analyse der Szene

Schauen wir uns einige der wichtigsten Techniken, die in dieser Übung verwendet werden, genauer an. Diese Techniken helfen bei der Skalierung, um große Bilder für eine große Anzahl von Klassen effizient und skalierbar zu verarbeiten.

Skalierung mit Faltungen

Eine der größten Herausforderungen bei der Arbeit mit Bildern ist der Maßstab der Eingabe. Ein 512x512 Drei-Kanal-Bild (Farbe) hat einen Eingangsvektor der Größe 786.432. Die Größe des Eingangsvektors nimmt linear zu, wenn die Höhe oder Breite der Bilder zunimmt. Diese Eingaben sind jedoch nicht unabhängig; es gibt auch eine strukturelle (räumliche) und texturelle Korrelation in den Bildern. Um effizientere Verfahren für das Lernen aus Bildern zu entwickeln, wurden Faltungsnetzwerke entwickelt, die diese Korrelation der Bildeigenschaften ausnutzen, indem sie Sparsamkeit und Parameter-Sharing nutzen.

CNNs wurden erstmals in den 1980er Jahren von Yann LeCun vorgeschlagen,9 wurden von der Computer-Vision-Technik "Falten" inspiriert, die bei Bildverarbeitungstechniken wie Kantenerkennung, Unschärfe usw. häufig eingesetzt wird. Bei diesen Techniken werden vorgegebene Filter F der Größe fxf in einem gleitenden Fenster über das Bild gelegt, das um den Schritt s verschoben wird, wodurch die Anzahl der Operationen um den Faktor s reduziert wird. Diese Filterung ist übersetzungsunabhängig, da derselbe Filter - z. B. der Kantenerkennungsfilter F -verwendet werden kann, um eine Kante an einer beliebigen Stelle des Bildes zu extrahieren (in der Mitte, entlang einer Kante oder anderweitig). Die Filter der Faltungsschichten (wie beim Deep Learning) werden während der Backpropagation gelernt. Die Wiederverwendbarkeit dieser gelernten Filter F (siehe Gleichung 4-1) macht CNNs sehr interessant und hochgradig optimiert, da nicht nur die Anzahl der benötigten Parameter in einem CNN massiv reduziert wird, sondern diese auch in jeder Schicht gemeinsam genutzt werden. Dieses Phänomen ist allgemein als Weight Sharing bekannt. Andrew Ng's Stanford CS230 Lecture Notes und Piotr Skalski's "Gentle Dive into Math Behind Convolutional Neural Networks" sind gute Quellen, um die Funktionsweise von Faltungsschichten näher zu erkunden.10

Gleichung 4-1. Mathematische Formel, die von Faltungsschichten zur Merkmalsextraktion aus Bildern verwendet wird
a f-1 b f-1 F ab x (i+a)(j+b)

Die Architektur des CNN von LeCun, LeNet-5, ist in Abbildung 4-6 dargestellt.

Abbildung 4-6. LeNet-5 Architektur (Quelle: https://alexlenail.me/NN-SVG/LeNet.html)

Skalierung mit EfficientNet

EfficientNet ist, wie der Name schon sagt, eine Architektur für neuronale Netze, die eine zusammengesetzte Skalierung verwendet, wie in Abbildung 4-7 dargestellt, um Faltungsmodelle auf effiziente Weise zu skalieren. Sie kombiniert die Skalierung über Tiefe, Breite und Auflösung auf eine effektivere Art und Weise, um ein leistungsfähigeres Modell zu erhalten, das die Ressourcen optimal nutzt. EfficientNet wurde mit Hilfe einer Technik entwickelt, die als neuronale Architektursuche bezeichnet wird (ein Teilbereich von AutoML) und über die du in Kapitel 11 lesen wirst.

Abbildung 4-7. EfficientNet Architektur (angepasst von Tan und Le, 2019)

Umsetzung

Wie bereits erwähnt, ist die endgültige Architektur, die in der Übung verwendet wird, das U-Net,11 das EfficientNet als Backbone verwendet (siehe Abbildung 4-8).

Abbildung 4-8. U-Net Architektur (angepasst von Ronneberger et al., 2015)

Der Code für diese Übung befindet sich im Ordnerchapter_4 im Code Repository des Buches. Werfen wir einen Blick auf die Implementierung:

vision_model.py

Die Modellimplementierung befindet sich in UNetSegmentationModel. Sie verbindet den EfficientNet-Encoder aus der Hugging Face-Bibliothek timm mit einem anderen Kopf, der für die Segmentierungsaufgabe entwickelt wurde. Dieser Kopf, Decoder, fungiert als Feature-Decoder, der die Aufgabe hat, die Segmentierungsausgabe zu erzeugen. Der EfficientNet-Encoder liefert mehrstufige Merkmale, die mit den Decoder-Ausgaben auf hierarchische Weise kombiniert werden, wie in der U-Net-Skip-Verbindungsarchitektur dargestellt (siehe Abbildung 4-8).

Wie in der vorherigen Übung wird auch in diesem Beispiel PyTorch Lightning verwendet, um die Fähigkeit zu nutzen, infrastrukturunabhängigen Code zu schreiben. Die VisionSegmentationModule bietet die LightningModule Implementierung.

dataset.py

Die Implementierung des Datensatzes befindet sich in der SceneParsingModule, die den SceneParse150 -Datensatz umhüllt. Beachte die spezielle Transformation, die in diesem Fall verwendet wird, um die Bilder in Tensoren zu konvertieren.

app.py

Der Einstiegspunkt für diese Übung ist in app.py in der Methode train_vision_model definiert. Beachte hier die Verwendung von VisionSegmentationModule und SceneParsingModule. Ansonsten ist die Implementierung dieses Einstiegspunkts sehr ähnlich wie der Einstiegspunkt der vorherigen Übung, train_gpt2.

Ausführen des Beispiels

Um den Code für dieses Beispiel auszuführen, führe den folgenden Befehl in deiner Umgebung aus :

deep-learning-at-scale chapter_4 train_efficient_unet

Beobachtungen

Die maximale Anzahl von Proben, die in den Speicher einer A100 80 GB GPU passen, beträgt 85. Beim Basislauf dieses Beispiels kann eine durchschnittliche Schrittzeit von 3,3 s pro Iteration erreicht werden. Dies ist höher als die im GPT-2-Beispiel beobachtete Schrittzeit. Ein Grund für die hohe Latenzzeit ist, dass eine sehr große Datenmenge (85 Bilder) gelesen, dekodiert und weiterverarbeitet wird, was einen I/O-intensiven Prozess darstellt. Dies ist eine häufige Herausforderung bei bildbasierten Modellierungsaufgaben.

Wir haben uns nun zwei praktische Beispiele angeschaut und verschiedene Techniken untersucht, mit denen sie effiziente und effektive Modelle entwickeln. In den folgenden Abschnitten lernst du einige orthogonale Techniken kennen, um deinen Trainingscode zu beschleunigen und die Kompromisse dieser Techniken zu untersuchen.

Diagrammkompilierung mit PyTorch 2.0

Wie in Kapitel 2 erläutert hat, ist PyTorch eine dynamische Graphenberechnungsmaschine, bei der zusätzliche Kosten für die Graphenkompilierung anfallen. Wie du in Kapitel 3 gesehen hast, sind diese Kosten in der Regel relativ gering im Vergleich zu den Kosten für die Matrixberechnung, die das Netzwerk benötigt - aber da die Beschleuniger immer schneller werden, wird diese Lücke immer kleiner. Da sich die Deep-Learning-Praktiken weiterentwickeln, schreiben immer mehr Praktiker/innen eigene beschleunigte/GPU-Kernel, um ihren Code zu beschleunigen. Bei der Entwicklung des PyTorch-Frameworks und der APIs stand die Benutzerfreundlichkeit an erster Stelle. Die C++-Entwicklung, die für CUDA-Kernel erforderlich ist, schränkt jedoch die Nutzbarkeit für Deep-Learning-Anwender ein, die eigene CUDA-Operatoren benötigen.

PyTorch 2.0 löst die beiden oben genannten Herausforderungen durch seinen innovativen Ansatz, der eine bessere Leistung und die Unterstützung dynamischer Tensorformen ermöglicht und gleichzeitig abwärtskompatibel ist. Diese Fähigkeiten werden durch Änderungen auf Compiler-Ebene für die Graphenausführung erreicht. Die mit PEP 532 in Python 3.6 eingeführte API für die Frame-Evaluierung (die auch in Kapitel 2 besprochen wird) war für diese Entwicklung entscheidend.

Neue Komponenten von PyTorch 2.0

Um diese Fähigkeit zur Graphenkompilierung zu ermöglichen, wurden in PyTorch 2.0 vier neue Komponenten hinzugefügt: PrimTorch, TorchDynamo, AOTAutograd und TorchInductor. PrimTorch ist eine vereinfachte Minimalmenge von primitiven Operatoren, die das Schreiben von komplexen Operatoren in Python erleichtert. Der Schwerpunkt dieser Komponente liegt auf der einfacheren Entwicklung von benutzerdefinierten hardwarespezifischen Kernel-Funktionen, die sonst eine komplexe C++-Entwicklung mit einer entsprechenden CPython-Schnittstelle für Python erfordert hätten.

TorchDynamo ist ein Versuch, die Kompilierung von Graphen zu ermöglichen, ohne dabei die Benutzerfreundlichkeit zu beeinträchtigen, indem das Tracing dynamisch mithilfe der Python-Bytecode-Transformation erfolgt. TorchScript verfolgt, wie in Kapitel 2 erwähnt, eine ähnliche Philosophie, indem es den Graphen nachverfolgt, um eine effizientere Variante zu erhalten. Allerdings ist TorchScript nur begrenzt in der Lage, Kontrollflüsse zu verarbeiten. PyTorch bietet noch einige andere Tracing-Utilities, wie torch.fx und PyTorch/XLA, aber der Hauptunterschied zu TorchDynamo ist die Verwendung der Frame-Evaluierungs-API. Tatsächlich wurde torch.fx jetzt in TorchDynamo integriert (siehe Abbildung 4-9).

Abbildung 4-9. Schlüsselkomponenten von PyTorch 2.0, die die Erstellung kompilierter Diagramme ermöglichen

Graph-Ausführung in PyTorch 2.0

Die Graphenausführung in PyTorch 2.0 besteht aus drei Schritten: Graphenerfassung, Senkung und Kompilierung. Schauen wir uns jeden dieser Schritte nacheinander an.

Grafikerwerb

Dein Modellberechnungsgraph wird aus einer Reihe von Untergraphen (z. B. torch.nn.Module) zusammengesetzt. Diese Untergraphen werden von einem der vielen Backends von TorchDynamo (aot_ts_nvfuser, cudagraphs, inductor, ipex, nvprims_nvfuser, onnxrt, tvm) kompiliert und konsolidiert (abgeflacht), sofern dies möglich ist. Diese Kompilierung erspart den Aufwand, Graphen bei jeder Iteration dynamisch zu erzeugen (wie es in PyTorch 1.x geschieht, wie in Kapitel 2 beschrieben). Die Einschränkung ist, dass aufgrund der pythonischen Natur von PyTorch nicht alle Kontrollflussoperationen in Graphen oder Untergraphen kompiliert werden können. Diese nicht unterstützten Teile deines Kontrollflusses werden in die Ausführungsphase integriert, indem sie in den Eager-Modus zurückfallen. Dieses nahtlose Umschalten, das durch die Frame-Evaluierungs-API ermöglicht wird, ist eine effektive Methode, um die Graphenkompilierungsfunktion von PyTorch 2.0 zu nutzen. Die Effizienz, die dadurch erreicht wird, ohne dass die Benutzerfreundlichkeit verloren geht, ist großartig, ohne dabei auf Pythonic zu verzichten. Der PyTorch Dev Discussion Thread "TorchDynamo: An Experiment in Dynamic Python Bytecode Transformation" werden die Interna genauer erläutert.

Der folgende Ausschnitt zeigt, wie der Trace realisiert wird. conv_blockdie wir in den früheren Vision-Übungen verwendet haben:

from torch.fx import symbolic_trace
symbolic_traced : torch.fx.GraphModule = symbolic_trace(conv_block)

Die interne Darstellung, die über den Trace von PyTorch erhalten wird, ist hier zu sehen. Dies ist eine weitere Darstellung der in Abbildung 4-9 gezeigten Graphenberechnung:

print(symbolic_traced.graph)
graph():
    %x : [#users=1] = placeholder[target=x]
    %conv : [#users=1] = call_module[target=conv](args = (%x,), kwargs = {})
    %bn : [#users=1] = call_module[target=bn](args = (%conv,), kwargs = {})
    %act : [#users=1] = call_module[target=act](args = (%bn,), kwargs = {})
    return act

Das gleiche Diagramm wird hier in tabellarischer Form dargestellt, um die Verbindungen zwischen den Vorgängen und den Ein- und Ausgängen zu veranschaulichen:

symbolic_traced.graph.print_tabular()
opcode       name    target    args     kwargs
-----------  ------  --------  -------  --------
placeholder  x       x         ()       {}
call_module  conv    conv      (x,)     {}
call_module  bn      bn        (conv,)  {}
call_module  act     act       (bn,)    {}
output       output  output    (act,)   {}

Grafik Absenken

In der Phase der Graphenreduzierung werden alle Operationen des Graphen in ihre Bestandteile zerlegt, die für das gewählte Backend spezifisch sind. In diesem Schritt wird die interne Repräsentation (IR) des Graphen erstellt. Die ATen und primitiven Komponenten von PyTorch, auch Prims genannt, übernehmen diesen Schritt. In dieser Phase wird der Graph für den hardwarespezifischen Aufruf ausgerichtet/vorbereitet.

Graphische Zusammenstellung

In der Phase der Graphenkompilierung werden die Kernel aus der IR, die in der vorangegangenen Phase ermittelt wurden, in die entsprechenden gerätespezifischen Operationen auf niedriger Ebene übersetzt. In dieser Phase muss das Backend die Kompilierung durchführen und auch die gerätespezifischen Kernel ausführen. TorchInductor (auch bekannt als inductor), die Standard-Engine für die Kompilierung, verwendet OpenAI Triton. Es werden jedoch auch andere Backends (wie aot_ts_nvfuser, cudagraphs, ipex, nvprims_nvfuser, onnxrt und tvm) aktiv entwickelt.

In Kapitel 2 hast du etwas über den Datenfluss beim Deep Learning und die Rechenanforderungen für arithmetische Operationen gelernt. In diesem Abschnitt hast du dir clevere Verbesserungen in PyTorch angesehen, um die Ineffizienzen des dynamischen Graphen zu verringern. Im folgenden Abschnitt werden wir Tricks und Techniken wie die Graphkompilierung erkunden, mit denen sich Modelle effizient auf einem einzigen Gerät trainieren lassen.

Modellierungstechniken zur Skalierung der Ausbildung auf einem einzigen Gerät

Die meisten der in diesem Abschnitt beschriebenen Techniken sind orthogonal und können in Kombination oder unabhängig voneinander angewendet werden. Diese Techniken können dir helfen, effizienter zu arbeiten, indem sie die Rechengeschwindigkeit erhöhen oder den Speicherbedarf reduzieren.

Grafik-Zusammenstellung

Um die Vorteile eines kompilierten Graphen zu nutzen, verwende die orthogonale API torch.compile. Wenn du torch.compile auf torch.nn.Module anwendest, wird es in den Typ OptimizedModule umgewandelt, eine interne Darstellung für optimierte Graphenmodule. Die in PyTorch Issue #93794 dokumentierten Benchmarks zeigen, dass dies je nach Architektur und zugrundeliegender Implementierung einen Leistungsgewinn von 30 % bis 200 % bringt.

Das Argument fullgraph wird verwendet, um einen statischen Graphen ohne eager mode fallback zwischen den Teilgraphen zu erzeugen. Wenn dein Modul keinen Kontrollfluss hat (if/else und andere bedingte Flüsse), sind die Chancen höher, diese Art von Graph zu erhalten. Im Allgemeinen ist es effizienter, fullgraph zu verwenden, wo immer es möglich ist. Wie bereits erwähnt, kannst du auch dein Backend auswählen; standardmäßig ist dies inductor (siehe Abbildung 4-9).

Es gibt drei Arten der Zusammenstellung:

default

Der Standardmodus kompiliert den Graphen unter Verwendung des gewählten Backends, aber effizient, ohne zu viel Zeit oder Speicherplatz zu beanspruchen. Im Allgemeinen ist dieser Modus für große Modelle vorteilhafter.

reduce-overhead

Dieser Modus zielt darauf ab, den Overhead des zugrundeliegenden Frameworks zu beseitigen. Daher dauert die Kompilierung länger und es wird etwas mehr Speicher benötigt. Der Modus reduce-overhead ist effektiver, wenn dein Modell und deine Schrittvorgaben kleiner sind.

max-autotune

Wie der Name schon sagt, zielt dieser Modus darauf ab, einen kompilierten Graphen mit maximaler Abstimmung zu erstellen, der somit schneller ist. Allerdings ist die Kompilierungsphase die längste der drei Methoden.

Um den Unterschied vor und nach der Kompilierung zu sehen, setze den Parameter mode, wie hier gezeigt:

torch.compile(self.model, mode = "max-autotune")

und führe den folgenden Befehl in deiner Umgebung aus:

deep-learning-at-scale chapter_4 train_gpt2 ––use-compile

Um das Standardverhalten des Eager-Modus zu beobachten, das auch das Standardverhalten des Beispielskripts ist, führe denselben Befehl stattdessen mit -–no-use-compile aus.

Tipp

Wenn du Probleme mit der Kompilierung hast oder eine suboptimale Leistung feststellst, kann die Umgebungsvariable TORCH_LOGS=​"graph_breaks,recompiles" bei der Fehlersuche helfen.

Wenn du dieses Beispiel auf einem A100 SXM 80 GB Grafikprozessor ausführst, wirst du feststellen, dass bei einfacher Genauigkeit (fp32) 24 die optimale Stapelgröße für das Training ist. Eine Stapelgröße von mehr als 24 führt zu OOM-Fehlern. Wenn du den Graphen mit der Option default kompilierst, kannst du die Effizienz deines Trainings um 5 % steigern, verglichen mit der Basisberechnung im nicht kompilierten Eager-Modus. Wenn du den Modus auf max-autotune änderst, erhöht sich der Gewinn auf 12 %. Wie aus den TorchDynamo-Fehlerprotokollen hervorgeht, ist das Netzwerk nicht vollständig Dynamo-fähig (z. B. aufgrund der Verwendung von Listenobjekten). Wenn du diese Fehler behebst, kannst du einen höheren Effizienzgewinn erzielen. Generell sollte von der Verwendung von Listen im Training abgeraten werden, insbesondere in DataLoaders, da sie aufgrund der Art und Weise, wie Python Multiprocessing Listenobjekte pickt, zu einem Speicherblowout führen können (siehe PyTorch issue #13246 für eine Diskussion dieses Problems).

Eine der Techniken, die du verwenden kannst, um von Listen zu Tensoren zu gelangen, ist, sie zu stapeln (d.h. torch.stack([...]) zu verwenden), wenn sie die gleiche Größe haben. Andernfalls kannst du Polsterungen hinzufügen, um einen Tensor mit maximaler Größe zu erhalten. Wenn dein Anwendungsfall sehr speziell ist und keiner dieser Tricks hilft, kannst du versuchen, benutzerdefinierte Objekte mit geeigneter Handhabung zu schreiben, um die Methode .to() für den Gerätetransfer zu implementieren.

In diesem Abschnitt hast du einen Einzeilertrick kennengelernt, mit dem du die Effizienz um etwa 15 % steigern kannst. Im folgenden Abschnitt erfährst du, wie du auf Kosten der Präzision mehr Geschwindigkeit erreichen kannst.

Training mit reduzierter und gemischter Präzision

Der Speicher, der während des Trainings verbraucht wird, kann in zwei Kategorien eingeteilt werden: Speicher, der durch Modellzustände und durch Restzustände verbraucht wird. In Kapitel 9 werden wir mehr darüber sprechen, aber jetzt wollen wir uns erst einmal auf den absoluten Speicherbedarf während des Trainings konzentrieren. Dieser wird durch die folgenden Kategorien von numerischen Daten bestimmt, die in den Speicher geladen werden:

  • Modellparameter (Gewichte und Verzerrungen)

  • Eingabedaten (in der Regel in Chunks in den Speicher geladen)

  • Aktivierungen/Merkmalskarte (die Ergebnisse der Berechnungen, d.h. die aus den Eingabedaten extrahierten Merkmale)

  • Gradienten (die für Backpropagation und Fehlerkorrektur benötigten Fehlergradienten )

  • Optimierungsstatus (schätzungsweise 33-75% des Speichers)12

  • Metriken und Verluste (numerische Werte zur Beobachtung und Überwachung des Trainingsfortschritts)

Der Speicherbedarf für die Aktivierung ist zwar übergangsweise, steigt aber linear mit der Anzahl der Modellparameter und der Größe der Eingabedaten. Ebenso steigt der Speicherbedarf für die Backpropagation (d. h. die Gradienten) linear mit der Stapelgröße und der Anzahl der Parameter. Metriken und Verluste haben im Allgemeinen einen vernachlässigbaren Speicherbedarf. Je nach Art der Kennzahlen (z. B. Mikro- oder Makrokennzahlen) können die Anforderungen jedoch in der Größenordnung des Ziels des Modells liegen. Die Speicherung von Metriken für einen einfachen binären Klassifikator benötigt zum Beispiel viel weniger Speicherplatz als die Speicherung von Mikrometriken für den 151-Klassen-Multiklassen-/Multilabel-Klassifikator, den wir uns weiter oben in diesem Kapitel angesehen haben. Bei Aufgaben der Objekterkennung und -lokalisierung, wie z. B. der semantischen Segmentierung oder der Instanzsegmentierung, können die Anforderungen an die Metriken ebenfalls sehr hoch sein (z. B. Berechnung von MaskIoU und Mittelwert/Durchschnitt/Präzision für verschiedene Objektgrößen, wie es bei Mask R-CNN-Modellen der Fall ist).

Für das Training wird in der Regel das Standard-Gleitkommaformat mit einfacher Genauigkeit verwendet (fp32). Verschiedene weniger präzise Formate, die in Tabelle 3-1 unter "Fließkommastandards" aufgeführt sind , können verwendet werden, um das Training zu beschleunigen, sowohl durch die Erhöhung der Stapelgröße (durch die Verringerung des Speicherbedarfs aufgrund der erforderlichen Datencontainer) als auch durch die Nutzung der Fähigkeiten von hardwareoptimierten Rechengeräten. Die Verwendung eines Formats mit geringerer Genauigkeit (z. B. fp16) kann zu suboptimalen Modellen führen. In Szenarien, in denen ein hochpräzises Ergebnis nicht entscheidend ist, kann der Effizienzgewinn durch eine geringere Genauigkeit jedoch enorm sein.

Gemischte Präzision

Mixed Precision, eine Technik, die einfach- und halbgenaue Fließkommaformate kombiniert, kann ebenfalls verwendet werden, um den Kompromiss zwischen Recheneffizienz und numerischer Genauigkeit zu bewältigen. Das Torch-Paket torch.cuda.amp bietet eine Implementierung, die die Verwendung von gemischter Genauigkeit für CUDA-kompatible Geräte ermöglicht. Die automatische gemischte Genauigkeit wird durch die Funktion torch.cuda.amp.autocast realisiert, die Datentypkonvertierungen automatisch verwalten kann. Die Tensor Core-Architektur, die in NVIDIAs Ampere- und späteren GPU-Serien verwendet wird, kann auch Matrizen mit halber Genauigkeit multiplizieren und das Ergebnis entweder in einfacher oder halber Genauigkeit ausgeben. All diese Techniken erleichtern das Training mit gemischter Genauigkeit.

Tipp

Wenn du automatische gemischte Genauigkeit verwendest, solltest du davon absehen, deine Tensoren explizit auf einen bestimmten Datentyp zu casten. Explizites Casting behindert die automatische Präzisionsumwandlung und führt zu suboptimalen Ergebnissen.

Das Training mit gemischter Genauigkeit ist mit einem kleinen Speicherverlust verbunden, da zwei Kopien der Modellgewichte geladen werden: eine mit einfacher Genauigkeit und die andere mit halber Genauigkeit. Der Speicherbedarf beträgt das 1,5-fache des Bedarfs bei einfacher Genauigkeit. Die gemischte Genauigkeit ist bereits in den Bibliotheken torch.cuda.amp und Lightning implementiert, du kannst sie also einfach durch den Aufruf von Trainer(precision = "16-mixed") aktivieren.

Hinweis

Gemischte Genauigkeit ist in erster Linie eine beschleunigte Gerätefunktion. Die Standard-CPUs bieten keine Möglichkeit, mit geringerer Genauigkeit zu rechnen. Daher wird die automatische gemischte Genauigkeit im Allgemeinen nur für das Training auf heterogenen Systemen unterstützt. Die gemischte Genauigkeit ist auch eine relativ neue Implementierung (eingeführt um 2017); daher kann es sein, dass ältere Grafikprozessoren keine Unterstützung für dieses Training eingebaut haben.

Die Auswirkung der Präzision auf die Gradienten

Da die Gradienten auf der Grundlage des Fehlerfaktors (d.h. des Beitrags der Parameter zum Fehler) berechnet werden, kann der Wert entweder zu klein oder zu groß sein. Ein zu großer Gradient führt zu ausufernden Werten, was zu numerischer Instabilität bei der Berechnung führt. Dieses Phänomen wird als explodierende Gradientenproblem bezeichnet.13 Wenn die numerische Genauigkeit abnimmt, verringert sich die Kapazität, eine größere Mantisse zu halten, was zu einem höheren Risiko eines numerischen Überlaufs führt. Ebenso nimmt die Fähigkeit ab, sehr genaue Fließkommadifferenzen zu speichern, was zu einem erhöhten Risiko eines Unterlaufs führt. Keines der beiden Szenarien ist wünschenswert. Gradientenskalierung und Clipping sind zwei Techniken, die helfen, Über- und Unterlaufprobleme bei der Rückwärtsfortpflanzung zu vermeiden.

Gradientenskalierung

Der Gradientenskalierer von PyTorch skaliert, wie der Name schon sagt, die Gradienten, um den Präzisionsverlust auszugleichen. Im Allgemeinen wird der Skalierer initialisiert:

scaler = torch.cuda.amp.GradScaler()

und während der Trainingsschleife verwendet, um den Verlust vor der Backpropagation zu skalieren:

scaler.scale(loss).backward()

Frameworks wie Lightning kümmern sich automatisch um die Skalierung von Farbverläufen, wenn gemischte Präzision verwendet wird. Aus diesem Grund wirst du feststellen, dass die praktischen Beispiele in diesem Kapitel keine explizite Verwendung von GradScaler vorsehen.

Farbverlauf beschneiden

Das Beschneiden von Gradienten wird verwendet, um explodierende Gradienten abzuschwächen. In der Regel wird entweder der Wert des Gradienten oder die Norm des Gradienten beschnitten. Damit wird der Maximalwert (oder die Norm) des Gradienten während der Trainingsschleife begrenzt.

Lightning wird mit einer clip_gradients Funktion ausgeliefert, die über den Verdrahtungscode der Infrastruktur aktiviert werden kann (z.B. mit Trainer(gradient_clip_val = 0.5)) und mit der configure_gradient_clipping Funktionsüberschreibung deines LightningModule weiter angepasst werden kann. Ausführlichere Informationen findest du in der Dokumentation.

8-Bit-Optimierer und Quantisierung

Wie bereits erläutert hat, werden die Datencontainer für die Gradienten auch beim Training mit gemischter Genauigkeit auf fp32 gehalten. Versuche, die Gradientenpräzision auf fp16 zu ändern, haben sich als wenig zielführend erwiesen, da die Varianz der Gradienten groß sein kann (je nachdem, wie jeder der Parameter zum Fehler beiträgt). Die Herausforderung bei der Gradientenskalierung und dem Clipping besteht darin, dass diese beiden Tricks konsistent auf den gesamten Gradiententensor angewendet werden.

Dynamische Baumquantisierung ist eine weitere interessante Technik, die einen Kompromiss zwischen den Bits, die für die Darstellung der Mantisse und des Exponenten benötigt werden (siehe Kapitel 3), ermöglicht, indem sie ein Indikatorbit verwendet, um den Beginn der Bruchteilung der Zahl zu signalisieren (siehe Abbildung 4-10). Durch diese dynamische Quantisierung kann die Größe der Daten richtig bestimmt werden, was zu genaueren Ergebnissen führt. Die Gradientenstatistiken (d. h. die Optimierungszustände) werden jedoch in Formaten mit geringerer Genauigkeit gespeichert. Die Blockquantisierung ist eine weitere Quantisierungstechnik, bei der die Optimierungszustände in Blöcken quantisiert werden. Sie bietet eine bessere Genauigkeit als die Halbpräzision und nur eine geringfügig schlechtere Genauigkeit als die Einzelpräzision.

Abbildung 4-10. Dynamische Baumquantisierung in 8-Bit-Optimierern: Das Indikatorbit bewegt sich dynamisch und ermöglicht Kompromisse zwischen dem Bruch- und dem Dezimalteil der Zahl (nach Dettmers et al., 2022)

Die Bibliothekbitsandbytes bietet benutzerdefinierte CUDA-Kernel, die zusätzlich zur Blockquantisierung eine dynamische Baumquantisierung nutzen, um eine genauere und leistungsfähigere Implementierung einer Reihe von Optimierern, einschließlich Adam, zu ermöglichen. Diese Implementierungen (z. B. bnb.optim.Adam8bit) können mit einer Ein-Zeilen-Änderung gegen torch.optim.Adam ausgetauscht werden. (Je nach Version der NVIDIA-Laufzeitumgebung musst du die Bibliothek jedoch möglicherweise selbst kompilieren). Du wirst diese Bibliothek in den praktischen Übungen in den Kapiteln 7 und 9 verwenden.

Ein Algorithmus mit gemischter Genauigkeit

In Anbetracht der oben genannten Herausforderungen wird der Algorithmus für das Training mit gemischter Genauigkeit wie folgt zusammengefasst:

  1. Beginne mit einer Masterkopie der Gewichte mit einfacher Genauigkeit (fp32).

  2. Erhalte eine weitere halbgenaue Kopie (fp16) der Gewichte.

  3. Führe einen Vorwärtspass mit fp16 Gewichten und Aktivierungen aus.

  4. Skaliere den resultierenden Verlust mit dem Skalierungsfaktor S.

  5. Führe einen Rückwärtsdurchlauf mit den Gewichten (fp16), Aktivierungen (fp16) und deren Gradienten (fp32) durch.

  6. Verkleinere die Gradienten um den Faktor S (d.h. multipliziere mit 1/S).

  7. Führe zusätzliche optionale Farbverlaufstricks aus, wie z. B. das Beschneiden von Farbverläufen, Gewichtsabnahme usw.

  8. Aktualisiere die Gradientenstatistik (fp16) in den Optimierungszuständen.

  9. Aktualisiere die Masterkopie der Gewichte (unter fp32).

  10. Wiederhole die Iterationsschleife aus den Schritten 3-9 bis zur Konvergenz.

Speicher-Tricks für mehr Effizienz

Wie in Kapitel 1 dargelegt hat, ist die Effizienz ein entscheidender Faktor bei der Skalierung. In diesem Abschnitt sehen wir uns einige Speichertricks an, die bei der Modellentwicklung in speicherbeschränkten Umgebungen hilfreich sein können.

Speicherlayout

Die n-dimensionalen Tensoren müssen in einem 1D-Adressraum im Speicher dargestellt werden. Das Speicherlayout legt die Speicherung der n-dimensionalen Tensoren fest und beschreibt, wie die Tensoren in den Adressraum eingeordnet werden. Zeilenmajor und Spaltenmajor sind zwei gängige Formate, um die n-dimensionalen Tensoren zusammenhängend im Speicher anzuordnen. Wie in Abbildung 4-11 zu sehen ist, entspricht die Zeilenmajorität dem Schema, das auf dem Stapel (N), dem Kanal (C), der Höhe (H) und der Breite (W) basiert (d.h. NCHW, auch bekannt als "channels first"), während die Spaltenmajorität dem Schema NHWC (channels last) entspricht.

Abbildung 4-11. Speicherlayout des ersten und letzten Tensors der Kanäle

Wenn deine Operation zuerst auf dem Kanal parallelisiert wird, ist die Speicherung und der Zugriff auf Tensoren mit dem Layout "Kanäle zuerst" effizienter. Andere bildbasierte Modellierungsverfahren - insbesondere Faltungen, die die räumliche Korrelation von Signalen in Bildern ausnutzen - greifen eher pixelweise auf Tensoren zu. Daher kann bei faltungsbasierten Techniken der letzte Kanal die effizientere Wahl des Layouts sein. PyTorch unterstützt die optionale Umstellung auf das channels-last-Speicherlayout und unterstützt die Berechnung in denselben nativen Formaten, indem es zusätzlich zum standardmäßigen channels-first-Format Kernel für eine Reihe von Operatoren im channels-last-Format implementiert.

Hinweis

Sowohl PyTorch als auch NVIDIA cuDNN verwenden standardmäßig das Channel-First (NCHW) Layout. oneDNN und XNNPACK, die Bibliotheken, die PyTorch für reine CPU-Berechnungen verwendet, verwenden dagegen standardmäßig die Anordnung "Kanäle zuletzt" (NHWC). Die Ausrichtung des Layouts über den gesamten Stack sorgt für eine effizientere Ausführung der Trainingsschleife; andernfalls wird das Zugriffsmuster der Daten suboptimal, was zu einem Nachteil beim Zugriff auf die Subtensoren für relevante Operationen führt.

Auf einer CPU kann die Verwendung des Channels-Last-Formats für faltungsbasierte Netze einen bis zu 1,8-fachen Leistungsgewinn durch geeignete Speicherzugriffsmuster ermöglichen, die von den Faltungs-, Pooling- und Upsampling-Schichten implementiert werden.14

Um das Speicherlayout zu wechseln, rufst du .to(memory_format = torch.channels_last) entweder für den Tensor oder das Modul auf (um die Präferenz des Operators anzugeben).

Im zweiten praktischen Beispiel in diesem Kapitel kannst du sehen, dass images = images.to(memory_format = torch.channels_last) und self.model = self.model.to(memory_format = torch.channels_last) angewendet werden, wenn die letzte Ausführung der Kanäle angefordert wird. Probiere dies mit dem folgenden Befehl aus:

deep-learning-at-scale chapter_4 train-efficient-unet --use-channel-last

Bei einer einzelnen A100 SXM 80 GB GPU bringt die Verwendung des Channels-Last-Formats einen Leistungsgewinn von etwa 10 % im Vergleich zur Channels-First-Konfiguration.

Wenn das gleiche Beispiel auf der CPU mit dem mps Backend ausgeführt wird, führt die Verwendung der letzten Kanäle zu einer Leistungssteigerung von 17 % gegenüber der Verwendung der ersten Kanäle mit den gleichen Ressourceneinstellungen (51,84 s pro Iteration, statt 62,48 s).

Merkmal Komprimierung

Wie weit verbreitet die Speicherproblematik in der Deep Learning-Praxis ist, zeigt sich daran, wie häufig GPU OOM-Fehler auftreten.15 Der Einsatz von Datenkomprimierung (sowohl verlustbehaftet als auch verlustfrei) wurde erforscht, um den Speicherbedarf von Feature Maps vom Forward Pass bis zur erneuten Verwendung bei der Backward Propagation zu reduzieren.16 Mit dieser Technik kann der Speicherbedarf im Durchschnitt um das 1,8-fache reduziert werden. Allerdings entstehen durch die Komprimierung/Dekomprimierung und die verstärkte CPU-zu-GPU-Kommunikation Leistungseinbußen. Im Allgemeinen kann dieser Ansatz nützlich sein, wenn die Merkmalskarten dünn gesät oder redundant sind, aber die beobachteten Gewinne hängen stark von der Art des Modells und der Daten ab.

Meta- und Fake-Tensoren

Meta-Tensoren sind PyTorchs zugrundeliegender Mechanismus, um Form und Datentyp zu repräsentieren, ohne tatsächlich Speicher für die Speicherung zuzuweisen. Die Fake-Tensoren von PyTorch sind den Meta-Tensoren sehr ähnlich, nur dass ein Meta-Tensor einem abstrakten "Meta"-Gerät zugewiesen wird, während Fake-Tensoren konkreten Geräten (CPUs, GPUs, TPUs usw.) zugewiesen werden.

Ein Meta-Tensor wird wie folgt initialisiert:

meta_layer = torch.nn.Linear(100000, 100000, device = "meta")

Wir werden mehr über Meta- und Fake-Tensoren in Kapitel 8 sprechen, wo wir ihre Bedeutung für die Speicherverwaltung in einer skalierten Umgebung betrachten.

Optimierer-Effizienzen

Du hast dir bisher verschiedene Techniken angesehen, mit denen du die Effizienz deiner Trainingsläufe verbessern kannst, z. B. die Kompilierung von Graphen und die Änderung des Speicherlayouts und der Datenformate. In diesem Abschnitt lernst du einige Tricks kennen, mit denen du dein Training auf einem einzigen Rechner mit höchstens einer GPU skalieren kannst.

Stochastischer Gradientenabstieg (SGD)

Frühe Versionen von Optimierern, wie z. B. der Gradientenabstieg, verwendeten den gesamten Trainingsdatensatz in einem Schritt, um den Gradienten abzuleiten. Wenn der Datensatz jedoch immer größer wird, wird der Gradientenabstieg aufgrund der begrenzten Speicherkapazität von Computern zu einem Engpass beim Training. Um dieses Problem zu lösen, wurde eine Annäherungstechnik namens stochastischer Gradientenabstieg (SGD) entwickelt. Bei dieser Technik werden die Gradienten nicht über die gesamte Stichprobe abgeleitet, sondern schubweise weitergegeben, um ein annähernd vergleichbares Modell zu erhalten.

SGD und andere iterative Gradientenabstiegsverfahren werden heutzutage so häufig verwendet, dass man dazu neigt, ihre Bedeutung bei der Skalierung großer Datensätze zu ignorieren. Diese Verfahren sind nur dann effektiv, wenn die Losgröße für eine universelle Annäherung geeignet ist. Um ein Modell für eine komplexe Aufgabe zu entwickeln, die das Lernen über einen stark variierenden Datensatz erfordert, sind größere Stapelgrößen erforderlich, um eine universelle Annäherung zu ermöglichen. Die Speicherbudgets auf der Hardware sind jedoch begrenzt.

Gradientenakkumulation

Wie im vorherigen Abschnitt erläutert hat, sind größere Losgrößen immer vorzuziehen, da sie eine universelle Annäherung ermöglichen (was zu gut verallgemeinerten Modellen führt). In Szenarien, in denen die Rechenkapazität begrenzt, eine Skalierung der Losgröße aber erwünscht ist, kann die Gradientenakkumulation genutzt werden, um größere Lose zu simulieren.

Mit der Gradientenakkumulation wird die hier gezeigte Standardmodell-Trainingsschleife so umgestaltet, dass sie zusätzliche Schritte zur Normalisierung des Verlusts, zur Akkumulation der Gradienten und zur Durchführung der Optimierungsschritte alle x Intervalle von Schritten (anstelle von jedem Schritt) enthält:

# Standard training loop
for epoch in range(...):
    for idx, (inputs, labels) in enumerate(dataloader):
        optimizer.zero_grad()
        # Perform the forward pass
        outputs = model(inputs)
        # Compute loss
        loss = loss_fn(outputs, labels)
        # Perform backpropagation 
        loss.backward()
        # Update the optimizer
        optimizer.step()

Es folgt das Snippet für die Gradientenakkumulation, wobei accumulation_step_count die Häufigkeit angibt, mit der der Optimierungsschritt durchgeführt wird:

accumulation_step_count = ...

# Training loop with gradient accumulation enabled
for epoch in range(...):
    for idx, (inputs, labels) in enumerate(dataloader):
        optimizer.zero_grad()
        # Perform the forward pass
        outputs = model(inputs)
        # Compute loss 
        loss = loss_fn(outputs, labels)
        # Perform gradient normalization 
        loss = loss / accumulation_step_count
        # Perform backpropagation 
        loss.backward()
        # Update the optimizer
        if ((idx + 1) % accumulation_step_count == 0) \
                    or (idx + 1 == len(dataloader)):
                optimizer.step()

Diese Technik dient in erster Linie dazu, genauere Modelle in Situationen zu erhalten, in denen die GPU-Ressourcen begrenzt sind, und nicht dazu, die Berechnungen effizienter zu gestalten.

Gradient Checkpointing

Mehr beliebte Optimierungsverfahren wie SGD und Adam sind zustandsorientiert: Sie speichern die Statistiken der vergangenen Gradientenwerte über die Zeit (z. B. die exponentiell geglättete Summe in SGD mit Momentum und die quadrierte Summe in Adam). Einige Optimierer haben einen höheren Speicherbedarf als andere. AdamW speichert zum Beispiel zwei Zustände und hat daher einen doppelt so hohen Speicherbedarf wie SGD.

Du kannst das überprüfen, indem du im Aufruf configure_optimizers in vision_model.py AdamW mit SGD vertauschst und beobachtest, wie der Speicherbedarf abnimmt.

Gradient Checkpointing ist eine Technik, die darauf abzielt, auf Kosten des Rechenaufwands speichereffizienter zu sein. Wenn du dir die DAG (die in Kapitel 2 besprochen wurde) ansiehst, die für eine Conv2dReLUWithBN erstellt wurde, wie sie in der praktischen Übung #2 verwendet wurde, wirst du feststellen, dass einige Knoten den Pfad der Gradientenausbreitung teilen. Traditionell werden die Gradienten für diese Knoten im Speicher gespeichert, bis alle nachgelagerten Kinder in der Rückwärtsrichtung durchlaufen und ihre jeweiligen Gradienten berechnet wurden. Das andere Extrem dieser Implementierung ist, die Gradienten nicht zu speichern und sie stattdessen bei Bedarf neu zu berechnen. Wenn die Berechnungslatenz nicht sehr groß ist, kann dieser Kompromiss z. B. eine größere Speicherkapazität zum Trainieren des Modells oder eine höhere Stapelgröße ermöglichen. Diese Ansätze sind die beiden extremen Enden des Spektrums. Das Gradienten-Checkpointing, auch bekannt als Aktivierungs-Checkpointing, bietet einen Mittelweg: Es ermöglicht das Speichern von Gradienten an bekannten Kontrollpunkten, um ein optimales Gleichgewicht zwischen der Freigabe von Speicherplatz und der Verringerung des überflüssigen Rechenaufwands zu finden.

Diese Fähigkeit ist im Modul torch.utils.checkpoint.checkpoint von PyTorch enthalten. In Kapitel 5 gibt es praktische Übungen, in denen du das Checkpointing von Gradienten ausprobieren kannst.

Patch Gradient Descent

Patch Gradient Descent (PatchGD) ist eine weitere sehr interessante Technik, mit der das Training von Gigapixel-Bildern skaliert werden kann, die nicht in eine einzelne GPU passen (siehe Abbildung 4-12).17 Sie ähnelt der Technik der Gradientenakkumulation, nur dass bei PatchGD die Gradienten über die verschiedenen räumlichen Positionen desselben Bildes akkumuliert werden und nicht über unabhängige Stichproben. Bei dieser Technik wird jedes Bild in Patches unterteilt und die Patches werden in mehreren Schritten durch die Trainingsschleife geführt. Während dieser Schritte werden die Gradienten in dem entsprechenden Gradientenvektor akkumuliert, bis alle Patches durchlaufen wurden.

Abbildung 4-12. Der Arbeitsablauf von PatchGD, dargestellt an einem sehr großen Bildbeispiel

Diese Technik ist nur für klassifikationsähnliche Modelle effektiv, bei denen die Größe des Gradienten viel kleiner ist als bei Modellen, die für dichtere Aufgaben wie z. B. Erkennungen verwendet werden. Bei PatchGD wird die Eingabe in Stücke zerlegt, aber der Gradient wird in seiner vollen Größe im Speicher gehalten. Damit dieser Trick funktioniert, muss der geschätzte Gradientenvektor also in den Speicher passen.

Lernrate und Gewichtsabnahme

Die Lernrate ist ein weiterer wichtiger Parameter, der eng mit der Konvergenzzeit verbunden ist. Wie in Kapitel 2 erläutert, dauert es bei einer langsamen Lernrate in der Regel viel länger und es werden viel mehr Schritte benötigt, um die Konvergenz oder die globalen Minima zu erreichen. Umgekehrt kann eine sehr hohe Lernrate dazu führen, dass die Verlustkurve zu schnell durchlaufen wird, was dazu führt, dass die globalen Minima verpasst werden und ein suboptimales Modell erreicht wird. Der Gewichtsverfall kann auch über Optimierer angewendet werden, um eine Regularisierung (über L2-Normalisierung) zu erzwingen und so eine Überanpassung zu vermeiden. Sowohl die Lernrate als auch die Gewichtsabnahme beeinflussen die Konvergenzzeit.

Aus diesem Grund ist es sinnvoll, die Lernrate richtig zu bemessen. Aufgrund der stochastischen Natur des Modells ist die Abstimmung der Hyperparameter leider die einzige Lösung, um die Lernrate zu optimieren. In Teil II dieses Buches werden wir uns eingehend mit der Versuchsplanung und der Parametersuche befassen, und du wirst einige Übungen zur Abstimmung der Modellparameter durchführen.

Tricks bei der Modelleingabe-Pipeline

In beiden praktischen Beispielen dieses Kapitels wurde nur ein sehr kleiner Teil der Rechenzyklen von der Modelleingabe-Pipeline (d. h. von DataLoaders) verwendet. Je größer das Volumen und die Datenmenge pro Probe sind, desto mehr Rechenleistung ist erforderlich, um den Datensatz zu laden, zu dekomprimieren, zu lesen und weiter umzuwandeln, was zu einer ziemlichen Herausforderung werden kann. In Kapitel 6 erfährst du mehr über das Training mit großen Datensätzen und über Techniken zur Entwicklung effizienter DataLoaders, um die GPUs zu beschäftigen und die SM-Auslastung zu maximieren. Einige dieser Techniken beinhalten die Wahl der richtigen Komprimierung für deine Daten, die Skalierung von CPU-gebundenen Operationen mit Thread- und Prozessparallelität (ausführlich in Kapitel 3 behandelt) und die Verlagerung skalierbarer Transformationen auf die GPU, entweder über Bibliotheken, die GPU-kompatible Transformationen anbieten, wie z. B. Kornia, oder über das Verständnis der Engpässe bei der Speicherbandbreite zwischen CPU und GPU, um deine Eingaben richtig zu dimensionieren. In Kapitel 7 gibt es eine praktische Übung, die sich mit den Details des Schreibens effizienter Eingabepipelines befasst.

Im folgenden Abschnitt sehen wir uns ein kleines Beispiel für das Schreiben eigener CUDA-Kernel in PyTorch 2.0 mit OpenAI Triton an.

Eigene Kernel in PyTorch 2.0 mit Triton schreiben

Das Programmiermodell von Triton ist analog zur CUDA Programmierung, da beide SIMD(/T) Parallelität unterstützen. Triton kann die Konstruktion von Hochleistungs-Rechenkernen für neuronale Netze mit SIMD-ähnlichen Programmierparadigmen erleichtern. Sein Programmiermodell unterscheidet sich jedoch von dem von CUDA, da die Programme - und nicht die Threads - blockiert werden.

Ein praktisches Beispiel für das Schreiben eines eigenen Kernels für NVIDIA, custom_kernel_example.py, ist im GitHub-Repository des Buches verfügbar.

In diesem Code wird der Kernel multiply_kernel auf einem Block von 1.024 Elementen der Tensoren aufgerufen, um die Multiplikation durchzuführen und das Ergebnis an den entsprechenden Stellen zu speichern. In diesem Beispiel implementiert MultiplyWithAutoGrad eine Funktion mit einer automatischen Differenzierungsfunktion. Diese Funktion kann wie folgt aufgerufen werden:

MultiplyWithAutoGrad.apply(
    torch.ones((1999, 1999, 10)).to(device),
    torch.ones((10, 1999, 1999)).to(device)
)

Ein weiteres neues PyTorch-Compiler-Backend, Hidet, ermöglicht eine feinkörnigere Compiler-Optimierung als Triton, da es auf Thread-Ebene arbeitet (im Gegensatz zu Triton, das auf Block-Ebene arbeitet) und zusätzliche Paradigmen wie Task-Mapping und Fusion unterstützt, um auf Operator- und Tensor-Ebene weiter zu optimieren.18 Dieser Compiler kann im Vergleich zu Triton/max-autotune etwa 50% der Rechenzeit einsparen. Allerdings ist er derzeit nur auf Inferenzen beschränkt, während das Training noch auf dem Plan steht.

Zusammenfassung

In diesem Kapitel hast du GPT-2 und EfficientNet als zwei Architekturen für zwei verschiedene Eingabeformate kennengelernt: Text und Bilder. Zusätzlich zu diesen praktischen Beispielen hast du eine Reihe von Techniken kennengelernt, um Modelle effizienter zu entwickeln. Dieses Kapitel schließt den ersten Teil des Buches ab und stellt verschiedene grundlegende Techniken vor, mit denen du das Training deiner Deep Learning-Modelle beschleunigen und skalieren kannst.

Im nächsten Teil dieses Buches lernst du Techniken kennen, mit denen du das Modelltraining von einem auf viele beschleunigte Geräte skalieren kannst, indem du viel mehr über das Netzwerk verbundene Hosts einsetzt.

1 Radford, Alec, Jeffrey Wu, Rewon Child, David Luan, Dario Amodei, und Ilya Sutskever. 2019. "Language Models Are Unsupervised Multitask Learners ." https://paperswithcode.com/paper/language-models-are-unsupervised-multitask; Tan, Mingxing, and Quoc V. Le. 2019. "EfficientNet: Rethinking Model Scaling for Convolutional Neural Networks." arXiv, May 28, 2019. https://arxiv.org/abs/1905.11946.

2 Alammar, Jay. 2019. "The Illustrated GPT-2 (Visualizing Transformer Language Models)". Blog von Jay Alammar, 12. August 2019. https://jalammar.github.io/illustrated-gpt2.

3 Vaswani, Ashish, Noam Shazeer, Niki Parmar, Jakob Uszkoreit, Llion Jones, Aidan N. Gomez, Lukasz Kaiser, und Illia Polosukhin. 2017. "Attention Is All You Need." arXiv, 6. Dezember 2017. https://arxiv.org/abs/1706.03762.

4 Bubeck, Sébastien, Varun Chandrasekaran, Ronen Eldan, Johannes Gehrke, Eric Horvitz, Ece Kamar, Peter Lee, et al. 2023. "Sparks of Artificial General Intelligence: Early Experiments with GPT-4." arXiv, April 13, 2023. https://arxiv.org/abs/2303.12712.

5 Brown, Tom B., Benjamin Mann, Nick Ryder, Melanie Subbiah, Jared Kaplan, Prafulla Dhariwal, Arvind Neelakantan, et al. 2020. "Language Models Are Few-Shot Learners." arXiv, 28. Mai 2020. https://arxiv.org/abs/2005.14165.

6 Dosovitskiy, Alexey, Lucas Beyer, Alexander Kolesnikov, Dirk Weißenborn, Xiaohua Zhai, Thomas Unterthiner, Mostafa Dehghani, et al. 2021. "An Image Is Worth 16x16 Words: Transformers for Image Recognition at Scale." arXiv, June 3, 2021. https://arxiv.org/abs/2010.11929.

7 Cheng, Bowen, Ishan Misra, Alexander G. Schwing, Alexander Kirillov, Rohit Girdhar. 2022. "Masked-Attention Mask Transformer for Universal Image Segmentation". arXiv, 15. Juni 2022. https://arxiv.org/abs/2112.01527.

8 Tan und Le, "EfficientNet: Rethinking Model Scaling for Convolutional Neural Networks ", https://arxiv.org/abs/1905.11946; Ronneberger, Olaf, Philipp Fischer, und Thomas Brox. 2015. "U-Net: Convolutional Networks for Biomedical Image Segmentation." arXiv, 18. Mai 2015. https://arxiv.org/abs/1505.04597.

9 LeCun, Y., B. Boser, J.S. Denker, D. Henderson, R.E. Howard, W. Hubbard, und L.D. Jackel. 1989. "Backpropagation angewandt auf die Erkennung von handgeschriebenen Postleitzahlen". Neural Computation 1, no. 4: 541-51. https://doi.org/10.1162/neco.1989.1.4.541.

10 Ng, Andrew. n.d. Stanford CS230 lecture notes. https://cs230.stanford.edu/files/C4M1.pdf.; Skalski, Piotr. "Gentle Dive into Math Behind Convolutional Neural Networks." Towards Data Science, April 12, 2019. https://oreil.ly/odnbI.

11 Ronneberger et al., "U-Net: Convolutional Networks for Biomedical Image Segmentation ", https://arxiv.org/abs/1505.04597.

12 Dettmers, Tim, Mike Lewis, Sam Shleifer und Luke Zettlemoyer. 2022. "8-Bit Optimizer via Block-wise Quantization". arXiv, 20. Juni 2022. https://arxiv.org/abs/2110.02861.

13 Bengio, Y., P. Simard, und P. Frasconi. 1994. "Learning Long-Term Dependencies with Gradient Descent Is Difficult. IEEE Transactions on Neural Networks 5, no. 2: 157-66. https://doi.org/10.1109/72.279181.

14 Ma, Mingfei, Vitaly Fedyunin, und Wei Wei. "PyTorch Vision Models mit Channels Last auf der CPU beschleunigen". PyTorch Blog, 24. August 2022. https://oreil.ly/Dr3Tt.

15 Zhang, Ru, Wencong Xiao, Hongyu Zhang, Yu Liu, Haoxiang Lin, und Mao Yang. 2020. "Eine empirische Studie zu Programmfehlern bei Deep Learning-Aufträgen". In Proceedings of the ACM/IEEE 42nd International Conference on Softwareentwicklung (ICSE '20), 1159-70. https://doi.org/10.1145/3377811.3380362.

16 Jain, Animesh, Amar Phanishayee, Jason Mars, Lingjia Tang, und Gennady Pekhimenko. 2018. "Gist: Effiziente Datenkodierung für das Training von tiefen neuronalen Netzen". In ACM/IEEE 45th Annual International Symposium on Computer Architecture (ISCA), 776-89. https://doi.org/10.1109/ISCA.2018.00070.

17 Gupta, Deepak K., Gowreesh Mago, Arnav Chavan, und Dilip K. Prasad. 2023. "Patch Gradient Descent: Training Neural Networks on Very Large Images." arXiv, 31. Januar 2023. https://arxiv.org/abs/2301.13817.

18 Ding, Yaoyao, Cody Hao Yu, Bojian Zheng, Yizhi Liu, Yida Wang, und Gennady Pekhimenko. 2023. "Hidet: Task-Mapping Programming Paradigm for Deep Learning Tensor Programs." arXiv, February 15, 2023. https://arxiv.org/abs/2210.09603.

Get Deep Learning im Maßstab now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.