In diesem Artikel werden wir einige grundlegende Details auf niedriger Ebene durchgehen, um zu verstehen, warum GPUs gut für Grafik-, neuronale Netzwerk- und Deep-Learning-Aufgaben geeignet sind und CPUs für eine Vielzahl sequentieller, komplexer allgemeiner Computeraufgaben. Es gab mehrere Themen, die ich für diesen Beitrag recherchieren und etwas genauer verstehen musste, einige davon werde ich nur am Rande erwähnen. Der Fokus liegt bewusst nur auf den absoluten Grundlagen der CPU- und GPU-Verarbeitung.
Frühere Computer waren dedizierte Geräte. Hardwareschaltkreise und Logikgatter wurden so programmiert, dass sie eine bestimmte Reihe von Aufgaben ausführen. Wenn etwas Neues getan werden musste, mussten die Schaltkreise neu verdrahtet werden. „Etwas Neues“ konnte so einfach sein wie die Durchführung mathematischer Berechnungen für zwei verschiedene Gleichungen. Während des Zweiten Weltkriegs arbeitete Alan Turing an einer programmierbaren Maschine, um die Enigma-Maschine zu schlagen, und veröffentlichte später das Papier „Turing Machine“. Etwa zur gleichen Zeit arbeiteten auch John von Neumann und andere Forscher an einer Idee, die im Wesentlichen vorschlug:
Wir wissen, dass alles in unserem Computer binär ist. Zeichenfolgen, Bilder, Videos, Audiodateien, Betriebssysteme, Anwendungsprogramme usw. werden alle als Einsen und Nullen dargestellt. Die Spezifikationen der CPU-Architektur (RISC, CISC usw.) umfassen Befehlssätze (x86, x86-64, ARM usw.), die von den CPU-Herstellern eingehalten werden müssen und für die Schnittstelle zwischen Betriebssystem und Hardware verfügbar sind.
Betriebssystem- und Anwendungsprogramme werden einschließlich der Daten in Befehlssätze und Binärdaten übersetzt, die dann in der CPU verarbeitet werden. Auf Chipebene erfolgt die Verarbeitung an Transistoren und Logikgattern. Wenn Sie ein Programm zum Addieren zweier Zahlen ausführen, erfolgt die Addition (die „Verarbeitung“) an einem Logikgatter im Prozessor.
In einer CPU gemäß der Von-Neumann-Architektur wird beim Addieren von zwei Zahlen ein einziger Additionsbefehl für zwei Zahlen im Schaltkreis ausgeführt. Für den Bruchteil dieser Millisekunde wurde im (Ausführungs-)Kern der Verarbeitungseinheit nur der Additionsbefehl ausgeführt! Dieses Detail hat mich schon immer fasziniert.
Die Komponenten im obigen Diagramm sind selbsterklärend. Weitere Einzelheiten und eine ausführliche Erklärung finden Sie in diesem hervorragenden Artikel . In modernen CPUs kann ein einzelner physischer Kern mehr als eine Integer-ALU, Gleitkomma-ALU usw. enthalten. Auch diese Einheiten sind physische Logikgatter.
Um GPU besser einschätzen zu können, müssen wir den „Hardware-Thread“ im CPU-Kern verstehen. Ein Hardware-Thread ist eine Recheneinheit, die in Ausführungseinheiten eines CPU-Kerns in jedem einzelnen CPU-Taktzyklus ausgeführt werden kann . Er stellt die kleinste Arbeitseinheit dar, die in einem Kern ausgeführt werden kann.
Das obige Diagramm veranschaulicht den CPU-Befehlszyklus/Maschinenzyklus. Es handelt sich dabei um eine Reihe von Schritten, die die CPU durchführt, um einen einzelnen Befehl auszuführen (z. B.: c=a+b).
Fetch: Der Programmzähler (spezielles Register im CPU-Kern) verfolgt, welche Anweisungen abgerufen werden müssen. Anweisungen werden abgerufen und im Befehlsregister gespeichert. Für einfache Operationen werden auch entsprechende Daten abgerufen.
Decodierung: Die Anweisung wird decodiert, um Operatoren und Operanden anzuzeigen.
Ausführen: Basierend auf der angegebenen Operation wird die entsprechende Verarbeitungseinheit ausgewählt und ausgeführt.
Speicherzugriff: Wenn eine Anweisung komplex ist oder zusätzliche Daten benötigt werden (mehrere Faktoren können dies verursachen), erfolgt der Speicherzugriff vor der Ausführung. (Der Einfachheit halber wird dies im obigen Diagramm ignoriert.) Bei einer komplexen Anweisung sind die Anfangsdaten im Datenregister der Recheneinheit verfügbar, aber für die vollständige Ausführung der Anweisung ist ein Datenzugriff vom L1- und L2-Cache erforderlich. Dies bedeutet, dass möglicherweise eine kurze Wartezeit vergeht, bevor die Recheneinheit ausgeführt wird, und der Hardware-Thread hält die Recheneinheit während der Wartezeit noch fest.
Zurückschreiben: Wenn die Ausführung eine Ausgabe erzeugt (z. B.: c=a+b), wird die Ausgabe zurück in das Register/den Cache/den Speicher geschrieben. (Wird im obigen Diagramm oder an jeder anderen Stelle später im Beitrag der Einfachheit halber ignoriert)
Im obigen Diagramm wird nur zum Zeitpunkt t2 eine Berechnung durchgeführt. Den Rest der Zeit ist der Kern einfach im Leerlauf (wir erledigen keine Arbeit).
Moderne CPUs verfügen über Hardwarekomponenten, die grundsätzlich die gleichzeitige Ausführung von Schritten (Abrufen, Dekodieren, Ausführen) pro Taktzyklus ermöglichen.
Ein einzelner Hardware-Thread kann nun in jedem Taktzyklus Berechnungen durchführen. Dies wird als Instruction Pipelining bezeichnet.
Abrufen, Dekodieren, Speicherzugriff und Zurückschreiben werden von anderen Komponenten in einer CPU ausgeführt. In Ermangelung eines besseren Wortes werden diese als „Pipeline-Threads“ bezeichnet. Der Pipeline-Thread wird zu einem Hardware-Thread, wenn er sich in der Ausführungsphase eines Befehlszyklus befindet.
Wie Sie sehen, erhalten wir ab t2 in jedem Zyklus Rechenleistung. Zuvor erhielten wir alle 3 Zyklen Rechenleistung. Pipelining verbessert den Rechendurchsatz. Dies ist eine der Techniken zur Bewältigung von Verarbeitungsengpässen in der Von-Neumann-Architektur. Es gibt auch andere Optimierungen wie Out-of-Order-Ausführung, Verzweigungsvorhersage, spekulative Ausführung usw.
Dies ist das letzte Konzept, das ich im Zusammenhang mit der CPU besprechen möchte, bevor wir zum Schluss kommen und zu den GPUs übergehen. Mit zunehmender Taktfrequenz wurden auch die Prozessoren schneller und effizienter. Mit zunehmender Komplexität der Anwendung (Befehlssatz) wurden die CPU-Rechenkerne nicht mehr ausreichend genutzt und es wurde mehr Zeit mit Warten auf den Speicherzugriff verbracht.
Wir sehen also einen Speicherengpass. Die Recheneinheit verbringt Zeit mit Speicherzugriffen und erledigt keine nützliche Arbeit. Der Speicher ist um mehrere Größenordnungen langsamer als die CPU und diese Lücke wird sich nicht so schnell schließen. Die Idee war, die Speicherbandbreite in einigen Einheiten eines einzelnen CPU-Kerns zu erhöhen und Daten für die Recheneinheiten bereitzuhalten, wenn sie auf Speicherzugriff warten.
Hyper-Threading wurde 2002 von Intel in Xeon- und Pentium-4-Prozessoren verfügbar gemacht. Vor Hyper-Threading gab es nur einen Hardware-Thread pro Kern. Mit Hyper-Threading gibt es 2 Hardware-Threads pro Kern. Was bedeutet das? Doppelte Verarbeitungsschaltung für einige Register, Programmzähler, Abrufeinheit, Decodiereinheit usw.
Das obige Diagramm zeigt nur neue Schaltungselemente in einem CPU-Kern mit Hyperthreading. So wird ein einzelner physischer Kern für das Betriebssystem als 2 Kerne angezeigt. Wenn Sie einen 4-Kern-Prozessor mit aktiviertem Hyperthreading hätten, würde dieser vom Betriebssystem als 8 Kerne angezeigt . Die Cachegröße von L1 bis L3 wird erhöht, um zusätzliche Register unterzubringen. Beachten Sie, dass die Ausführungseinheiten gemeinsam genutzt werden.
Angenommen, wir haben die Prozesse P1 und P2, die a=b+c, d=e+f ausführen. Diese können aufgrund der HW-Threads 1 und 2 gleichzeitig in einem einzigen Taktzyklus ausgeführt werden. Mit einem einzigen HW-Thread wäre dies, wie wir zuvor gesehen haben, nicht möglich. Hier erhöhen wir die Speicherbandbreite innerhalb eines Kerns, indem wir einen Hardware-Thread hinzufügen, sodass die Verarbeitungseinheit effizient genutzt werden kann. Dies verbessert die Rechenparallelität.
Einige interessante Szenarien:
Lesen Sie diesen Artikel und probieren Sie auch das Colab-Notebook aus. Es zeigt, dass die Matrizenmultiplikation eine parallelisierbare Aufgabe ist und wie parallele Rechenkerne die Berechnung beschleunigen können.
Mit der zunehmenden Rechenleistung stieg auch die Nachfrage nach Grafikverarbeitung. Aufgaben wie UI-Rendering und Gaming erfordern parallele Operationen, was den Bedarf an zahlreichen ALUs und FPUs auf Schaltkreisebene erhöht. CPUs, die für sequentielle Aufgaben entwickelt wurden, konnten diese parallelen Arbeitslasten nicht effektiv bewältigen. Daher wurden GPUs entwickelt, um die Nachfrage nach paralleler Verarbeitung bei Grafikaufgaben zu erfüllen, was später den Weg für ihre Einführung zur Beschleunigung von Deep-Learning-Algorithmen ebnete.
Ich kann es nur wärmstens empfehlen:
Kerne, Hardware-Threads, Taktfrequenz, Speicherbandbreite und On-Chip-Speicher von CPUs und GPUs unterscheiden sich erheblich. Beispiel:
Diese Zahl wird zum Vergleich mit der GPU verwendet, da das Erreichen der Spitzenleistung bei Allzweck-Computern sehr subjektiv ist. Diese Zahl ist eine theoretische Höchstgrenze, was bedeutet, dass FP64-Schaltkreise maximal genutzt werden.
Die Terminologien, die wir bei CPUs gesehen haben, lassen sich nicht immer direkt auf GPUs übertragen. Hier sehen wir Komponenten und Kerne der NVIDIA A100 GPU. Bei der Recherche für diesen Artikel war ich überrascht, dass CPU-Anbieter nicht veröffentlichen, wie viele ALUs, FPUs usw. in den Ausführungseinheiten eines Kerns verfügbar sind. NVIDIA ist sehr transparent, was die Anzahl der Kerne angeht, und das CUDA-Framework bietet vollständige Flexibilität und Zugriff auf Schaltkreisebene.
Im obigen Diagramm in der GPU können wir sehen, dass es keinen L3-Cache, einen kleineren L2-Cache, einen kleineren, aber viel größeren Steuereinheiten- und L1-Cache und eine große Anzahl von Verarbeitungseinheiten gibt.
Hier sind die GPU-Komponenten in den obigen Diagrammen und ihr CPU-Äquivalent für unser erstes Verständnis. Ich habe noch keine CUDA-Programmierung durchgeführt, daher hilft der Vergleich mit CPU-Äquivalenten beim ersten Verständnis. CUDA-Programmierer verstehen dies sehr gut.
Grafik- und Deep-Learning-Aufgaben erfordern eine Ausführung vom Typ SIM(D/T) [Single Instruction Multi Data/Thread]. Das bedeutet, dass mit einer einzigen Anweisung große Datenmengen gelesen und bearbeitet werden müssen.
Wir haben besprochen, dass auch Befehlspipelines und Hyperthreading in CPUs und GPUs möglich sind. Die Implementierung und Funktionsweise unterscheiden sich geringfügig, aber die Prinzipien sind dieselben.
Im Gegensatz zu CPUs bieten GPUs (über CUDA) direkten Zugriff auf Pipeline-Threads (Daten aus dem Speicher abrufen und die Speicherbandbreite nutzen). GPU-Scheduler arbeiten zunächst, indem sie versuchen, Recheneinheiten (einschließlich des zugehörigen gemeinsam genutzten L1-Cache und Register zum Speichern von Rechenoperanden) zu füllen, dann „Pipeline-Threads“, die Daten in Register und HBM abrufen. Ich möchte noch einmal betonen, dass CPU-App-Programmierer nicht darüber nachdenken und Spezifikationen zu „Pipeline-Threads“ und der Anzahl der Recheneinheiten pro Kern nicht veröffentlicht werden. Nvidia veröffentlicht diese nicht nur, sondern bietet Programmierern auch vollständige Kontrolle.
Ich werde in einem speziellen Beitrag über das CUDA-Programmiermodell und die „Batching“-Technik bei der Modellbereitstellungsoptimierung näher darauf eingehen und dort zeigen, wie vorteilhaft dies ist.
Das obige Diagramm zeigt die Ausführung von Hardware-Threads im CPU- und GPU-Kern. Siehe den Abschnitt „Speicherzugriff“, den wir zuvor bei CPU-Pipelining besprochen haben. Dieses Diagramm zeigt das. Die komplexe Speicherverwaltung der CPU macht diese Wartezeit klein genug (einige Taktzyklen), um Daten aus dem L1-Cache in Register zu holen. Wenn Daten aus L3 oder dem Hauptspeicher geholt werden müssen, erhält der andere Thread, für den sich Daten bereits im Register befinden (wir haben dies im Abschnitt zum Hyper-Threading gesehen), die Kontrolle über die Ausführungseinheiten.
Aufgrund der Überbuchung (hohe Anzahl von Pipeline-Threads und Registern) und des einfachen Befehlssatzes sind in GPUs bereits große Datenmengen in Registern verfügbar, die auf ihre Ausführung warten. Diese auf ihre Ausführung wartenden Pipeline-Threads werden zu Hardware-Threads und führen die Ausführung in jedem Taktzyklus aus, da Pipeline-Threads in GPUs leichtgewichtig sind.
Was ist über dem Ziel?
Dies ist der Hauptgrund, warum die Latenz der Matrixmultiplikation kleinerer Matrizen bei CPU und GPU mehr oder weniger gleich ist. Probieren Sie es aus .
Aufgaben müssen parallel genug sein, die Daten müssen groß genug sein, um Rechen-FLOPs und Speicherbandbreite auszulasten. Wenn eine einzelne Aufgabe nicht groß genug ist, müssen mehrere solcher Aufgaben gepackt werden, um Speicher und Rechenleistung auszulasten und die Hardware voll auszunutzen.
Rechenintensität = FLOPs/Bandbreite , d. h. das Verhältnis der Arbeitsmenge, die von den Recheneinheiten pro Sekunde erledigt werden kann, zur Datenmenge, die vom Speicher pro Sekunde bereitgestellt werden kann.
Im obigen Diagramm sehen wir, dass die Rechenintensität zunimmt, wenn wir zu höherer Latenz und geringerer Speicherbandbreite übergehen. Wir möchten, dass diese Zahl so klein wie möglich ist, damit die Rechenleistung voll ausgenutzt wird. Dafür müssen wir so viele Daten wie möglich in L1/Registern behalten, damit die Berechnung schnell erfolgen kann. Wenn wir einzelne Daten von HBM abrufen, gibt es nur wenige Operationen, bei denen wir 100 Operationen an einzelnen Daten durchführen, damit es sich lohnt. Wenn wir keine 100 Operationen durchführen, sind die Recheneinheiten im Leerlauf. Hier kommt die hohe Anzahl von Threads und Registern in GPUs ins Spiel. Um so viele Daten wie möglich in L1/Registern zu behalten, müssen die Rechenintensität niedrig gehalten und parallele Kerne ausgelastet werden.
Es besteht ein vierfacher Unterschied in der Rechenintensität zwischen CUDA- und Tensor-Kernen, da CUDA-Kerne nur einen 1x1 FP64 MMA ausführen können, während Tensor-Kerne 4x4 FP64 MMA-Befehle pro Taktzyklus ausführen können.
Eine hohe Anzahl an Recheneinheiten (CUDA- und Tensor-Kerne), eine hohe Anzahl an Threads und Registern (über Abonnement), ein reduzierter Befehlssatz, kein L3-Cache, HBM (SRAM), ein einfaches und hochdurchsatzstarkes Speicherzugriffsmuster (im Vergleich zu CPUs – Kontextwechsel, mehrschichtiges Caching, Speicher-Paging, TLB usw.) sind die Prinzipien, die GPUs beim parallelen Rechnen (Grafik-Rendering, Deep Learning usw.) so viel besser machen als CPUs.
GPUs wurden ursprünglich für die Verarbeitung von Grafikverarbeitungsaufgaben entwickelt. KI-Forscher begannen, CUDA und seinen direkten Zugriff auf leistungsstarke Parallelverarbeitung über CUDA-Kerne zu nutzen. NVIDIA GPU verfügt über Texture Processing, Ray Tracing, Raster, Polymorph-Engines usw. (sagen wir, grafikspezifische Befehlssätze). Mit der zunehmenden Verbreitung von KI werden Tensor-Kerne hinzugefügt, die sich gut für 4x4-Matrixberechnungen (MMA-Befehle) eignen und speziell für Deep Learning entwickelt wurden.
Seit 2017 hat NVIDIA die Anzahl der Tensor-Kerne in jeder Architektur erhöht. Diese GPUs eignen sich jedoch auch gut für die Grafikverarbeitung. Obwohl der Befehlssatz und die Komplexität bei GPUs viel geringer sind, sind sie nicht vollständig auf Deep Learning ausgerichtet (insbesondere Transformer Architecture).
FlashAttention 2 , eine Softwareschichtoptimierung (mechanische Sympathie für das Speicherzugriffsmuster der Aufmerksamkeitsschicht) für die Transformer-Architektur, sorgt für eine Verdoppelung der Aufgabenbeschleunigung.
Mit unserem tiefgreifenden, auf Grundprinzipien basierenden Verständnis von CPU und GPU können wir die Notwendigkeit von Transformer-Beschleunigern verstehen: Ein dedizierter Chip (Schaltkreis nur für Transformer-Operationen), mit noch mehr Recheneinheiten für Parallelität, reduziertem Befehlssatz, keine L1/L2-Caches, massiver DRAM (Register) als Ersatz für HBM, Speichereinheiten, die für das Speicherzugriffsmuster der Transformer-Architektur optimiert sind. Schließlich sind LLMs neue Begleiter für Menschen (nach Web und Mobile) und sie benötigen dedizierte Chips für Effizienz und Leistung.