ist Teil einer Serie über verteilte KI über mehrere GPUs:

  • Teil 1: Das Host- und Geräteparadigma verstehen (dieser Artikel)
  • Teil 2: Punkt-zu-Punkt- und Sammeloperationen (kommt bald)
  • Teil 3: Wie GPUs kommunizieren (kommt bald)
  • Teil 4: Gradientenakkumulation und verteilte Datenparallelität (DDP) (kommt bald)
  • Teil 5: ZeRO (kommt bald)
  • Teil 6: Tensorparallelität (kommt bald)

Einführung

In diesem Handbuch werden die grundlegenden Konzepte erläutert, wie eine CPU und eine separate Grafikkarte (GPU) zusammenarbeiten. Es handelt sich um eine allgemeine Einführung, die Ihnen dabei helfen soll, ein mentales Modell des Host-Gerät-Paradigmas zu erstellen. Wir werden uns speziell auf NVIDIA-GPUs konzentrieren, die am häufigsten für KI-Workloads verwendet werden.

Bei integrierten GPUs, wie sie beispielsweise in Apple-Silicon-Chips zu finden sind, ist die Architektur etwas anders und wird in diesem Beitrag nicht behandelt.

Das große Ganze: Der Host und das Gerät

Das wichtigste Konzept, das es zu verstehen gilt, ist die Beziehung zwischen den Gastgeber und die Gerät.

  • Der Gastgeber: Das ist Ihr CPU. Es führt das Betriebssystem aus und führt Ihr Python-Skript Zeile für Zeile aus. Der Gastgeber ist der Kommandant; Es ist für die Gesamtlogik verantwortlich und teilt dem Gerät mit, was es tun soll.
  • Das Gerät: Das ist Ihr GPU. Es handelt sich um einen leistungsstarken, aber spezialisierten Coprozessor, der für massiv parallele Berechnungen entwickelt wurde. Das Gerät ist der Beschleuniger; Es führt nichts aus, bis der Host ihm eine Aufgabe gibt.

Ihr Programm startet immer auf der CPU. Wenn die GPU eine Aufgabe ausführen soll, beispielsweise das Multiplizieren zweier großer Matrizen, sendet die CPU die Anweisungen und Daten an die GPU.

Die CPU-GPU-Interaktion

Der Host kommuniziert mit dem Gerät über ein Warteschlangensystem.

  1. CPU initiiert Befehle: Ihr Skript, das auf der CPU ausgeführt wird, stößt auf eine Codezeile, die für die GPU bestimmt ist (z. B. tensor.to('cuda')).
  2. Befehle stehen in der Warteschlange: Die CPU wartet nicht. Dieser Befehl wird einfach in eine spezielle To-Do-Liste für die GPU mit dem Namen a eingefügt CUDA-Stream – mehr dazu im nächsten Abschnitt.
  3. Asynchrone Ausführung: Die CPU wartet nicht darauf, dass der eigentliche Vorgang von der GPU abgeschlossen wird, der Host fährt mit der nächsten Zeile Ihres Skripts fort. Das nennt man asynchrone Ausführungund es ist ein Schlüssel zur Erzielung hoher Leistung. Während die GPU damit beschäftigt ist, Zahlen zu verarbeiten, kann die CPU anderen Aufgaben nachgehen, beispielsweise der Vorbereitung des nächsten Datenstapels.

CUDA-Streams

A CUDA-Stream ist eine geordnete Warteschlange von GPU-Operationen. An einen einzelnen Stream übermittelte Vorgänge werden ausgeführt in Ordnungeiner nach dem anderen. Allerdings Operationen quer anders Streams können ausgeführt werden gleichzeitig – Die GPU kann mehrere unabhängige Arbeitslasten gleichzeitig bewältigen.

Standardmäßig wird jede PyTorch-GPU-Operation in die Warteschlange gestellt aktuell aktiver Stream (Normalerweise ist es der Commonplace-Stream, der automatisch erstellt wird). Das ist einfach und vorhersehbar: Jeder Vorgang wartet, bis der vorherige abgeschlossen ist, bevor er beginnt. Bei den meisten Codes merkt man das nie. Aber die Leistung bleibt auf dem Tisch, wenn man damit zu tun hat könnte überlappen.

Mehrere Streams: Parallelität

Der klassische Anwendungsfall für mehrere Streams ist Überlappende Berechnung mit Datenübertragungen. Während die GPU Batch N verarbeitet, können Sie Batch N+1 gleichzeitig vom CPU-RAM in den GPU-VRAM kopieren:

Stream 0 (compute): (course of batch 0)────(course of batch 1)───
Stream 1 (information):   ────(copy batch 1)────(copy batch 2)───

Diese Pipeline ist möglich, weil Rechen- und Datenübertragung auf separaten Hardwareeinheiten innerhalb der GPU erfolgen, was echte Parallelität ermöglicht. In PyTorch erstellen Sie Streams und planen die Arbeit daran mit Kontextmanagern:

compute_stream = torch.cuda.Stream()
transfer_stream = torch.cuda.Stream()

with torch.cuda.stream(transfer_stream):
    # Enqueue the switch on transfer_stream
    next_batch = next_batch_cpu.to('cuda', non_blocking=True)

with torch.cuda.stream(compute_stream):
    # This runs concurrently with the switch above
    output = mannequin(current_batch)

Beachten Sie die non_blocking=True Flagge an .to(). Ohne sie würde die Übertragung immer noch den CPU-Thread blockieren, selbst wenn Sie eine asynchrone Ausführung beabsichtigen.

Synchronisierung zwischen Streams

Da Streams unabhängig sind, müssen Sie explizit signalisieren, wenn einer von einem anderen abhängt. Das stumpfe Werkzeug ist:

torch.cuda.synchronize()  # waits for ALL streams on the system to complete

Ein eher chirurgischer Ansatz wird verwendet CUDA-Ereignisse. Ein Ereignis markiert einen bestimmten Punkt in einem Stream und ein anderer Stream kann darauf warten, ohne den CPU-Thread anzuhalten:

occasion = torch.cuda.Occasion()

with torch.cuda.stream(transfer_stream):
    next_batch = next_batch_cpu.to('cuda', non_blocking=True)
    occasion.document()  # mark: switch is finished

with torch.cuda.stream(compute_stream):
    compute_stream.wait_event(occasion)  # do not begin till switch completes
    output = mannequin(next_batch)

Das ist effizienter als stream.synchronize() Da dadurch nur der abhängige Stream auf der GPU-Seite blockiert wird, bleibt der CPU-Thread frei, um weiterhin Arbeit in die Warteschlange zu stellen.

Für alltäglichen PyTorch-Trainingscode müssen Sie Streams nicht manuell verwalten. Aber Funktionen wie DataLoader(pin_memory=True) und Prefetching hängen stark von diesem Mechanismus unter der Haube ab. Das Verständnis von Streams hilft Ihnen zu erkennen, warum diese Einstellungen vorhanden sind, und gibt Ihnen die Instruments an die Hand, mit denen Sie subtile Leistungsengpässe diagnostizieren können, wenn sie auftreten.

PyTorch-Tensoren

PyTorch ist ein leistungsstarkes Framework, das viele Particulars abstrahiert, aber diese Abstraktion kann manchmal verschleiern, was unter der Haube passiert.

Wenn Sie einen PyTorch-Tensor erstellen, besteht dieser aus zwei Teilen: Metadaten (wie Kind und Datentyp) und den tatsächlichen numerischen Daten. Wenn Sie additionally so etwas ausführen t = torch.randn(100, 100, system=system)werden die Metadaten des Tensors im RAM des Hosts gespeichert, während seine Daten im VRAM der GPU gespeichert werden.

Diese Unterscheidung ist wichtig. Wenn du rennst print(t.form)kann die CPU sofort auf diese Informationen zugreifen, da sich die Metadaten bereits im eigenen RAM befinden. Aber was passiert, wenn man rennt? print

Host-Gerät-Synchronisierung

Der Zugriff auf GPU-Daten von der CPU kann einen Fehler auslösen Host-Gerät-Synchronisierungein häufiger Leistungsengpass. Dies geschieht immer dann, wenn die CPU ein Ergebnis von der GPU benötigt, das noch nicht im RAM der CPU verfügbar ist.

Betrachten Sie zum Beispiel die Linie print(gpu_tensor) Dadurch wird ein Tensor ausgegeben, der noch von der GPU berechnet wird. Die CPU kann die Tensorwerte erst drucken, wenn die GPU alle Berechnungen abgeschlossen hat, um das Endergebnis zu erhalten. Wenn das Skript diese Zeile erreicht, wird die CPU dazu gezwungen Blockdh es stoppt und wartet, bis die GPU fertig ist. Erst nachdem die GPU ihre Arbeit abgeschlossen und die Daten von ihrem VRAM in den RAM der CPU kopiert hat, kann die CPU fortfahren.

Als weiteres Beispiel: Was ist der Unterschied zwischen torch.randn(100, 100).to(system) Und torch.randn(100, 100, system=system)? Die erste Methode ist weniger effizient, da sie die Daten auf der CPU erstellt und sie dann an die GPU überträgt. Die zweite Methode ist effizienter, da sie den Tensor direkt auf der GPU erstellt; Die CPU sendet nur den Erstellungsbefehl.

Diese Synchronisierungspunkte können die Leistung erheblich beeinträchtigen. Bei einer effektiven GPU-Programmierung geht es darum, sie zu minimieren, um sicherzustellen, dass sowohl der Host als auch das Gerät so ausgelastet wie möglich bleiben. Schließlich möchten Sie, dass Ihre GPUs funktionieren brrrrr.

Bild vom Autor: generiert mit ChatGPT

Skalierung: Verteiltes Rechnen und Ränge

Das Coaching großer Modelle, wie etwa Giant Language Fashions (LLMs), erfordert oft mehr Rechenleistung, als eine einzelne GPU bieten kann. Durch die Koordinierung der Arbeit über mehrere GPUs hinweg gelangen Sie in die Welt des verteilten Computings.

In diesem Zusammenhang entsteht ein neues und wichtiges Konzept: das Rang.

  • Jede Rang ist ein CPU-Prozess, dem ein einzelnes Gerät (GPU) und eine eindeutige ID zugewiesen werden. Wenn Sie ein Trainingsskript auf zwei GPUs starten, erstellen Sie zwei Prozesse: einen mit rank=0 und noch einer mit rank=1.

Das bedeutet, dass Sie zwei separate Instanzen Ihres Python-Skripts starten. Auf einer einzelnen Maschine mit mehreren GPUs (einem einzelnen Knoten) werden diese Prozesse auf derselben CPU ausgeführt, bleiben jedoch unabhängig, ohne Speicher oder Standing gemeinsam zu nutzen. Rank 0 befiehlt seiner zugewiesenen GPU (cuda:0), während Rank 1 befiehlt eine andere GPU (cuda:1). Obwohl auf beiden Rängen derselbe Code ausgeführt wird, können Sie eine Variable nutzen, die die Rang-ID enthält, um jeder GPU unterschiedliche Aufgaben zuzuweisen, z. B. dass jede GPU einen anderen Teil der Daten verarbeitet (Beispiele hierfür sehen wir im nächsten Blogbeitrag dieser Serie).

Abschluss

Herzlichen Glückwunsch, dass Sie den Textual content bis zum Ende gelesen haben! In diesem Beitrag haben Sie Folgendes erfahren:

  • Die Host/Gerät-Beziehung
  • Asynchrone Ausführung
  • CUDA-Streams und wie sie gleichzeitige GPU-Arbeit ermöglichen
  • Host-Gerät-Synchronisierung

Im nächsten Blogbeitrag werden wir uns eingehender mit Punkt-zu-Punkt- und kollektiven Operationen befassen, die es mehreren GPUs ermöglichen, komplexe Arbeitsabläufe wie das Coaching verteilter neuronaler Netzwerke zu koordinieren.

Von admin

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert