Neuronale Netze auf FPGAs aufbauen

Ari Mahpour
|  Erstellt: August 8, 2024  |  Aktualisiert am: Oktober 9, 2024
Neuronale Netze auf FPGAs aufbauen

In Verständnis neuronaler Netze haben wir uns einen Überblick über künstliche neuronale Netze verschafft und versucht, Beispiele aus der realen Welt zu geben, um sie auf einer höheren Ebene zu verstehen. In diesem Artikel werden wir uns anschauen, wie man neuronale Netze trainiert und sie dann auf einem Field Programmable Gate Array (FPGA) mit einer Open-Source-Bibliothek namens hls4ml einsetzt.

Das Modell

Im Gegensatz zu Verständnis neuronaler Netze werden wir uns ein viel einfacheres Modell ansehen, und zwar aus zwei Hauptgründen. Für einige war der Sprung von 28 x 28 Pixeln zu einem vollständigen Modell riesig. Obwohl ich den Aufbau als eine Reihe von stückweisen Funktionen in einer großen mehrdimensionalen Matrix erklärt habe, war es immer noch schwer zu verstehen. Wir hören oft von „Parametern“, aber in Bildverarbeitungsmodellen können sie abstrakt und kompliziert erscheinen. Wir möchten auch ein einfacheres Modell verwenden, weil wir es auf Hardware synthetisieren werden. Eine Grafikprozessoreinheit (GPU), selbst eine Standard-GPU für Heimdesktops, kann Modelle schnell berechnen und trainieren. Sie ist speziell für diese Aufgabe konzipiert (d.h. Zahlencrunching). Denken Sie daran, wie viele Berechnungen in 3D-Spiele mit ihren ausgefeilten Physik-Engines und Bildrendering einfließen. GPUs sind auch bei der Berechnung verschiedener Arten von Kryptowährungen beliebt geworden, wegen ihrer Fähigkeit zum Zahlencrunching. Und schließlich ist auch das Training und der Betrieb eines neuronalen Netzwerks nur eine Reihe von Matrixmultiplikationen und parallelem Computing. Obwohl FPGAs Mathematikblöcke in ihrem Gefüge enthalten, sind die Software-Tools zum Kompilieren dieser Zahlencrunching-Sequenzen nicht so optimiert wie GPUs (zumindest noch nicht).

Aus diesen zwei Gründen habe ich mich für ein viel einfacheres Modell entschieden, das Iris-Klassifikationsmodell. Obwohl es nicht so spannend wie ein auf Sicht basierendes Modell (z.B. MNIST-Datenbank) ist, ist es viel geradliniger. Der Datensatz, der zum Trainieren des Modells verwendet wird, besteht aus 150 Beobachtungen von drei verschiedenen Arten von Iris-Blumen: Setosa, Versicolor und Virginica. Jede Beobachtung (d.h. Datensatz) enthält mehrere Messungen, die für diese Blumen einzigartig sind. Diese sind:

  1. Blattlänge (cm)
  2. Blattbreite (cm)
  3. Blütenlänge (cm)
  4. Blütenbreite (cm)

Mit diesen Messungen können wir ein Modell erstellen, das uns (mit ziemlich hoher Sicherheit) sagen kann, zu welcher Art eine Blume gehört. Wir geben diese vier Eingaben an und es teilt uns mit, ob die Iris vom Typ Setosa, Versicolor oder Virginica ist. Das Schöne an diesem Datensatz ist, dass es innerhalb dieser Werte nicht viel Überschneidung gibt. Zum Beispiel haben Setosa, Versicolo und Virginica alle ziemlich unterschiedliche Blattlängen. Das Gleiche gilt für die anderen 3 Merkmale. Dies wird im Streudiagramm des Datensatzes gezeigt:

Pairwise Scatterplots of Iris Dataset

Abbildung 1: Paarweise Streudiagramme des Iris-Datensatzes

Als wir darüber sprachen, warum man ein neuronales Netzwerk in Verständnis neuronaler Netzwerke verwenden sollte, diskutierten wir das Konzept der regelbasierten Programmierung: eine Reihe von Regeln, denen der Computer folgen kann (d.h. ein Algorithmus). In diesem Fall könnten wir dieses Problem sicherlich als Algorithmus codieren. Die Streudiagramme oben könnten wahrscheinlich durch eine Reihe mathematischer Gleichungen dargestellt werden. Wir verwenden diesen Datensatz eher als ein Beispiel für die Komplexität, die noch kommt (denken Sie an die MNIST-Datenbank). Damit gehen wir zum nächsten Thema über: Implementierung.

Warum ein FPGA?

Während wir uns darauf vorbereiten, dies auf einem FPGA zu implementieren, fragen Sie sich wahrscheinlich: „Warum sollte man überhaupt ein neuronales Netzwerk auf einem FPGA aufbauen?“ Wir haben bereits festgestellt, dass GPUs für diese Anwendung einfach sinnvoller sind. Wie Sie vielleicht wissen oder auch nicht, GPUs sind teuer und benötigen viel Strom. Sie erzeugen auch viel Wärme, wenn sie mit voller Kapazität laufen. Beim Entwerfen von anwendungsspezifischer Hardware (oder einem Chip) optimieren Sie hinsichtlich Platz, Strom, Wärme und Kosten. Der erste Schritt zum Entwerfen eines Application Specific Integrated Circuit (ASIC) ist, ihn zuerst auf einem FPGA zu entwerfen. Wenn wir jemals planen, neuronale Netzwerke auf ASICs zu bauen, wäre dies der erste Schritt.

Wie machen wir das?

Also haben wir uns nun endlich überzeugen lassen, dies zu versuchen. Wir haben Beispiele gesehen, wie man ein Modell trainiert, und wir nehmen an, dass, wenn wir das Modell vereinfachen, die Implementierung auf einem FPGA ziemlich einfach sein sollte, richtig? Falsch. Erinnern wir uns an das, was wir über die vielen Matrizen und Gleichungsserien gesprochen haben? All diese Berechnungen auf einen kleinen FPGA zu packen, kann extrem herausfordernd sein. Glücklicherweise gibt es da draußen eine wirklich nette Abkürzung, an der die freundlichen Leute vom Fast Machine Learning Lab (und die Community) in einem Open-Source-Projekt namens hls4ml gearbeitet haben. Dieses Projekt nimmt bestehende Modelle und konvertiert sie in eine High-Level-Synthesesprache (z.B. SystemC), die dann in gängige FPGA-Sprachen wie Verilog oder VHDL umgewandelt werden kann (unter Verwendung von FPGA-Synthesewerkzeugen wie Vivado von AMD Xilinx). Diese Übersetzung an sich eliminiert eine immense Menge an Schritten. Den Versuch, ein komplettes neuronales Netzwerk nur in Verilog oder VHDL zu bauen (besonders ein komplexes), wäre extrem herausfordernd. Dieses Werkzeug hilft uns wirklich, direkt zum Kern zu kommen - aber nicht ohne ein paar Zwischenschritte.

Optimierungen

Wir sind an dem Punkt angelangt, an dem wir unser Modell trainiert haben und hls4ml auf einem FPGA testen möchten. Nicht so schnell. Wenn Sie den Iris-Datensatz nehmen, ihn mit grundlegenden Techniken trainieren und dann den Code mit hls4ml synthetisieren, kommen Sie wahrscheinlich über die Synthese hinaus. Jetzt gehen Sie zum Platzieren und Verdrahten über und Sie werden niemals in der Lage sein, dieses Modell, so wie es ist, auf einem kleinen FPGA unterzubringen. Denken Sie daran, dass wir auch die gesamte Logik rund um die Datenverarbeitung und Kommunikation auf unserem FPGA benötigen. In ihren Tutorials verwenden sie ein Pynq-Z2-Board, um zu demonstrieren, wie ein Modell auf einem FPGA ausgeführt wird. Dieses Board wurde gewählt, weil es nicht nur einen FPGA, sondern auch einen Mikroprozessor auf dem Chip (Zynq 7000) enthält, der einen vollständigen Jupyter Notebook-Webserver betreibt. Das bedeutet, dass Sie hardwarebeschleunigte Funktionen auf einem FPGA ausführen können, aber auch eine einfache, leicht zu bedienende Schnittstelle erleben, um Ihre Daten zu laden und zu testen. Diese Schnittstelle, die als Transportebene zwischen dem Betriebssystem und dem FPGA fungiert, nimmt immer noch Platz auf dem FPGA selbst ein (genau wie wenn wir geplant hätten, einen reinen FPGA wie die Spartan- oder Virtex-FPGAs zu verwenden).

Die Herausforderung, wie oben erwähnt, besteht darin, dass FPGAs in ihrer Größe begrenzt sind und nicht die gleiche Kapazität wie GPUs besitzen. Als Ergebnis können wir kein vollständiges Modell auf einem FPGA ablegen - es würde niemals passen. Selbst das einfache Iris-Modell konnte anfangs nicht auf dem Pynq-Z2 platziert werden. Bis Tutorial 4 beginnen Sie, sich mit Optimierungstechniken vertrauter zu machen (einige davon sind so esoterisch, dass ich noch nicht einmal die Oberfläche dessen verstanden habe, wie sie unter der Haube funktionieren). Sobald Sie diese Optimierungstechniken anwenden, sollten Sie in der Lage sein, Modelle (zumindest die einfacheren) auf einem FPGA unterzubringen. In diesem Repository können wir uns BuildModel.py (speziell die Modelltrainingsfunktion) anschauen und beobachten, dass wir nicht nur ein grundlegendes Modell trainieren, sondern es auch optimieren:

def train_model(self):
    """
    Erstellen, trainieren und speichern eines beschnittenen und quantisierten neuronalen Netzwerkmodells unter Verwendung von QKeras.

    Diese Funktion erstellt ein sequentielles Modell mit QKeras-Schichten. Das Modell wird beschnitten, um die Anzahl der Parameter zu reduzieren
    und quantisiert, um eine geringere Präzision für die Gewichte und Aktivierungen zu verwenden. Der Trainingsprozess beinhaltet das Einrichten
    von Callbacks für die Aktualisierung der Beschneidungsschritte. Nach dem Training werden die Beschneidungswrapper entfernt und das endgültige Modell gespeichert.

    Parameter:
    Keine

    Rückgabe:
    Keine
    """
    # Initialisiere ein sequentielles Modell
    self.model = Sequential()

    # Fügen Sie die erste QDense-Schicht mit Quantisierung und Pruning hinzu
    self.model.add(
        QDense(
            64,  # Anzahl der Neuronen in der Schicht
            input_shape=(4,),  # Eingabeform für den Iris-Datensatz (4 Merkmale)
            name='fc1',
            kernel_quantizer=quantized_bits(6, 0, alpha=1),  # Gewichte auf 6 Bits quantisieren
            bias_quantizer=quantized_bits(6, 0, alpha=1),  # Biases auf 6 Bits quantisieren
            kernel_initializer='lecun_uniform',
            kernel_regularizer=l1(0.0001),
        )
    )

    # Fügen Sie eine quantisierte ReLU-Aktivierungsschicht hinzu
    self.model.add(QActivation(activation=quantized_relu(6), name='relu1'))

    # Fügen Sie die zweite QDense-Schicht mit Quantisierung und Pruning hinzu
    self.model.add(
        QDense(
            32,  # Anzahl der Neuronen in der Schicht
            name='fc2',
            kernel_quantizer=quantized_bits(6, 0, alpha=1),  # Gewichte auf 6 Bits quantisieren
            bias_quantizer=quantized_bits(6, 0, alpha=1),  # Biases auf 6 Bits quantisieren
            kernel_initializer='lecun_uniform',
            kernel_regularizer=l1(0.0001),
        )
    )

    # Fügen Sie eine quantisierte ReLU-Aktivierungsschicht hinzu
    self.model.add(QActivation(activation=quantized_relu(6), name='relu2'))

    # Fügen Sie die dritte QDense-Schicht mit Quantisierung und Beschneidung hinzu
    self.model.add(
        QDense(
            32,  # Anzahl der Neuronen in der Schicht
            name='fc3',
            kernel_quantizer=quantized_bits(6, 0, alpha=1),  # Gewichte auf 6 Bits quantisieren
            bias_quantizer=quantized_bits(6, 0, alpha=1),  # Biases auf 6 Bits quantisieren
            kernel_initializer='lecun_uniform',
            kernel_regularizer=l1(0.0001),
        )
    )

    # Fügen Sie eine quantisierte ReLU-Aktivierungsschicht hinzu
    self.model.add(QActivation(activation=quantized_relu(6), name='relu3'))

    # Fügen Sie die Ausgabeschicht QDense mit Quantisierung und Beschneidung hinzu
    self.model.add(
        QDense(
            3,  # Anzahl der Neuronen in der Ausgabeschicht (entspricht der Anzahl der Klassen im Iris-Datensatz)
            name='output',
            kernel_quantizer=quantized_bits(6, 0, alpha=1),  # Gewichte auf 6 Bits quantisieren
            bias_quantizer=quantized_bits(6, 0, alpha=1),  # Biases auf 6 Bits quantisieren
            kernel_initializer='lecun_uniform',
            kernel_regularizer=l1(0.0001),
        )
    )

    # Fügen Sie eine Softmax-Aktivierungsschicht für die Klassifizierung hinzu
    self.model.add(Activation(activation='softmax', name='softmax'))

    # Einrichten der Beschneidungsparameter, um 75% der Gewichte zu beschneiden, beginnend nach 2000 Schritten und Aktualisierung alle 100 Schritte
    pruning_params = {"pruning_schedule": pruning_schedule.ConstantSparsity(0.75, begin_step=2000, frequency=100)}
    self.model = prune.prune_low_magnitude(self.model, **pruning_params)

    # Kompilieren des Modells mit Adam-Optimierer und kategorischem Kreuzentropieverlust
    adam = Adam(lr=0.0001)
    self.model.compile(optimizer=adam, loss=['categorical_crossentropy'], metrics=['accuracy'])

    # Einrichten des Beschneidungs-Callbacks, um die Beschneidungsschritte während des Trainings zu aktualisieren
    callbacks = [
        pruning_callbacks.UpdatePruningStep(),
    ]

    # Trainiere das Modell
    self.model.fit(
        self.X_train_val,
        self.y_train_val,
        batch_size=32,  # Batchgröße für das Training
        epochs=30,  # Anzahl der Trainingsepochen
        validation_split=0.25,  # Anteil der Trainingsdaten, der als Validierungsdaten verwendet wird
        shuffle=True,  # Trainingsdaten vor jeder Epoche mischen
        callbacks=callbacks,  # Pruning-Callback einbeziehen
    )

    # Entferne die Pruning-Wrappers vom Modell
    self.model = strip_pruning(self.model)

 

Es gibt hier enorm viel zu verstehen, aber das Wesentliche dessen, was passiert (zusätzlich zum Training), ist, dass wir durch Quantisierung, Regularisierung, Beschneidung, Gewichtsinitialisierung und (Adam-)Optimierung die Größe und Komplexität der Gewichte reduzieren, um sie effizienter und schneller zu machen. Einige dieser Methoden werden verwendet, um die Genauigkeit zu verbessern und sicherzustellen, dass der Trainingsprozess gut funktioniert. Sobald wir unser Modell durch diese Techniken laufen lassen haben, sollte es klein genug sein, um in FPGA-Code umgewandelt zu werden. Denken Sie daran, es gibt viele Wege, dies zu tun. Dies war der Ansatz, der im Tutorial genommen wurde, also habe ich mich an das gehalten, was funktioniert hat.

Synthese

Also sind wir jetzt bereit, unser Design zu kompilieren, zu synthetisieren und zu platzieren und zu routen. Am Ende des Prozesses sollten wir mit einer Bitstream-Datei enden, die effektiv als Flash-Datei dient, um dem FPGA zu instruieren, wie er funktionieren soll. Da der Zynq sowohl einen Prozessor als auch ein FPGA hat, ist es ziemlich einfach, das FPGA über Python zu programmieren (was wir später behandeln werden). Unter Verwendung des referenzierten Repository können wir unser Modell mit HLS definieren und kompilieren und dann die Bitstream-Datei in ein paar Zeilen Python-Code erstellen. Im folgenden Code führen wir einige zusätzliche Validierungen durch, um sicherzustellen, dass unser mit HLS generiertes Modell die gleiche (oder annähernd gleiche) Genauigkeit wie das Originalmodell hat.

def build_bitstream(self):
    """
    Erstellt den HLS-Bitstream für das trainierte Modell.

    Diese Funktion konvertiert das trainierte Keras-Modell in ein HLS-Modell mit hls4ml, kompiliert es und generiert den Bitstream für den FPGA-Einsatz.
    Sie validiert auch das HLS-Modell gegenüber dem Originalmodell und druckt die Genauigkeit beider Modelle aus.

    """
    # Erstellen einer HLS-Konfiguration aus dem Keras-Modell, mit der Granularität der Layernamen
    config = hls4ml.utils.config_from_keras_model(self.model, granularity='name')

    # Festlegen der Präzision für die Softmax-Schicht
    config['LayerName']['softmax']['exp_table_t'] = 'ap_fixed<18,8>'
    config['LayerName']['softmax']['inv_table_t'] = 'ap_fixed<18,4>'

    # Festlegen des Wiederverwendungsfaktors für die vollständig verbundenen Schichten auf 512
    für layer in ['fc1', 'fc2', 'fc3', 'output']:
        config['LayerName'][layer]['ReuseFactor'] = 512

    # Konvertieren des Keras-Modells in ein HLS-Modell
    hls_model = hls4ml.converters.convert_from_keras_model(
        self.model, hls_config=config, output_dir='hls4ml_prj_pynq', backend='VivadoAccelerator', board='pynq-z2'
    )

    # Kompilieren des HLS-Modells
    hls_model.compile()

    # Vorhersage mit dem HLS-Modell
    y_hls = hls_model.predict(np.ascontiguousarray(self.X_test))
    np.save('package/y_hls.npy', y_hls)

    # Validierung des HLS-Modells gegenüber dem Originalmodell
    y_pred = self.model.predict(self.X_test)
    accuracy_original = accuracy_score(np.argmax(self.y_test, axis=1), np.argmax(y_pred, axis=1))
    accuracy_hls = accuracy_score(np.argmax(self.y_test, axis=1), np.argmax(y_hls, axis=1))

    print(f"Genauigkeit des ursprünglichen beschnittenen und quantisierten Modells: {accuracy_original * 100:.2f}%")
    print(f"Genauigkeit des HLS-Modells: {accuracy_hls * 100:.2f}%")

    # Erstellung des HLS-Modells
    hls_model.build(csim=False, export=True, bitfile=True)


Es ist schwer zu würdigen, was hier geleistet wurde, aber der Arbeitsaufwand, um von einem Keras-Modell zu einer Bitstream-Datei zu gelangen, ist unüberwindbar. Dies macht es für den durchschnittlichen Joe deutlich zugänglicher (obwohl die Quantisierung, Reduktion und Optimierung alles andere als einfach waren).

Die Konfigurationen sind wiederum etwas, das aus dem Tutorial übernommen wurde, und der Wiederverwendungsfaktor ist eine Zahl, die sich ändern kann, je nachdem, wie viel von der Logik wir wiederverwenden möchten. Danach sind es nur noch ein paar Aufrufe an hls4ml und, voilà, Sie haben eine Bitdatei!

Testen

Nun, da wir eine Bitdatei haben, möchten wir diese auf dem tatsächlichen FPGA testen. Die Verwendung der Pynq-Umgebung erleichtert dies. Um zu beginnen, müssen wir die Bitdatei, unser Testskript und die Testvektoren auf das Pynq-Board kopieren. Dies kann mit einem einfachen SCP-Befehl oder über die Web-Oberfläche erfolgen. Um die Dinge zu vereinfachen (wie im hls4ml-Tutorial gemacht), packen wir alles in einen einzigen Ordner namens „package“ und kopieren ihn dann über SCP auf das Zielgerät (siehe das BuildModel.py-Skript im Repository für weitere Details). Wichtig zu beachten ist hier eine zusätzlich automatisch generierte Bibliothek namens axi_stream_driver.py. Diese Datei enthält die Hilfsfunktionen, um nicht nur den FPGA-Teil des Zynq zu flashen, sondern auch den Datentransfer durchzuführen, um das FPGA-basierte neuronale Netzwerkmodell zu testen.

Sobald die Dateien auf das Pynq-Board übertragen wurden, können wir entweder eine SSH-Shell auf dem Zielgerät öffnen oder ein neues Notebook erstellen, um den Code auszuführen, der in on_target.py enthalten ist. Ich bevorzuge es, das Skript über die Kommandozeile auszuführen, aber dafür sind Root-Rechte erforderlich, also müssen Sie zuerst sudo -s ausführen, nachdem Sie sich per SSH in Ihr Gerät eingeloggt haben. Sobald Sie eine Root-Shell auf dem Pynq-Board laufen haben, können Sie zu jupyter_notebooks/iris_model_on_fpgas navigieren und den Test mit dem Befehl python3 on_target.py ausführen. Wenn alles korrekt ausgeführt wurde, sollten Sie die folgende Ausgabe sehen:

Expected output of on_targer.py script

Abbildung 2: Erwartete Ausgabe des on_target.py-Skripts

Validierung

Wie wissen wir also, ob alles funktioniert hat? Jetzt müssen wir die Ausgaben (d.h. Vorhersagen) validieren, die das FPGA generiert hat, und sie mit unserem lokalen Modell vergleichen, das wir mit unserer GPU (oder CPU) erstellt haben. Einfach ausgedrückt, für jede Sepal-/Petal-Länge/Breite, die wir als Eingabe bereitstellen, erwarten wir entweder eine Iris vom Typ Setosa, Versicolor oder Virginica als Ausgabe (natürlich alles in Zahlen). Wir hoffen, dass das FPGA genauso genau ist wie das Modell, das auf unserer lokalen Maschine läuft.

Wir müssen die Ausgabe des Skripts zurück auf unseren Rechner kopieren. Sie können erneut einen SCP-Befehl ausführen oder einfach über die Jupyter-Notebook-Schnittstelle herunterladen. Die Ausgabedatei wird y_hw.npy genannt. Nachdem wir die Datei kopiert haben, müssen wir utilities/validate_model.py ausführen. Die Ausgabe sollte ungefähr so aussehen:

Results from validation script

Abbildung 3: Ergebnisse vom Validierungsskript

Wie Sie sehen können, teilen unser optimiertes Modell (auf dem PC) und das synthetisierte Modell (auf dem FPGA) beide das gleiche Genauigkeitsniveau: 96,67%. Von allen 30 Testpunkten haben wir nur einen einzigen nicht vorhergesagt - toll!

Schlussfolgerung

In diesem Artikel haben wir den Iris-Klassifizierungsdatensatz genommen und daraus ein neuronales Netzwerkmodell erstellt. Mit hls4ml haben wir eine Bitdatei und die notwendigen Bibliotheken erstellt, um dasselbe Modell auf einem Pynq-Z2-Board auszuführen. Wir haben auch ein Validierungsskript ausgeführt, das das computerbasierte Modell mit dem FPGA-basierten Modell verglich und wie beide im Vergleich zu den Originaldaten abschnitten. Obwohl das Modell und der Datensatz recht trivial waren, legte dieses Tutorial das Fundament dafür, worum es beim Entwerfen komplexer neuronaler Netzwerke auf FPGAs eigentlich geht.

Hinweis: Der gesamte Code für dieses Projekt kann in diesem Repository gefunden werden.

Über den Autor / über die Autorin

Über den Autor / über die Autorin

Ari ist ein PCB-Designer mit umfassender Erfahrung in der Entwicklung, Herstellung, Prüfung und Integration verschiedener Softwaresysteme. Dabei bringt er leidenschaftlich gern Entwickler aus den Bereichen Design, Prüfung und Abnahme zusammen, um gemeinsam an Projekten zu arbeiten und diese voranzutreiben.

Ähnliche Resourcen

Verwandte technische Dokumentation

Zur Startseite
Thank you, you are now subscribed to updates.