Budowanie sieci neuronowych na FPGA

Ari Mahpour
|  Utworzono: sierpień 8, 2024  |  Zaktualizowano: październik 9, 2024
Budowanie sieci neuronowych na FPGA

W Rozumieniu Sieci Neuronowych przyjrzeliśmy się przeglądowi Sztucznych Sieci Neuronowych i próbowaliśmy podać przykłady z rzeczywistości, aby zrozumieć je na wyższym poziomie. W tym artykule przyjrzymy się, jak szkolić sieci neuronowe, a następnie wdrażać je na Programowalnej Macierzy Bramkowej (FPGA) za pomocą otwartej biblioteki o nazwie hls4ml.

Model

W przeciwieństwie do Zrozumienia sieci neuronowych, będziemy przyglądać się znacznie prostszemu modelowi z dwóch głównych powodów. Dla niektórych, przejście z 28 x 28 pikseli do pełnego modelu było ogromnym skokiem. Chociaż wyjaśniłem rozkład jako serię funkcji kawałkami w dużym wielowymiarowym macierzu, nadal było to trudne do pojęcia. Często słyszymy o „parametrach”, ale w modelach przetwarzania obrazów mogą one wydawać się abstrakcyjne i skomplikowane. Chcemy również użyć prostszego modelu, ponieważ zamierzamy zsyntetyzować go na sprzęcie. Jednostka przetwarzania graficznego (GPU), nawet standardowe GPU domowego komputera, może szybko obliczać i trenować modele na bieżąco. Jest specjalnie zaprojektowane do tego zadania (tj. obliczeń numerycznych). Pomyśl, ile obliczeń wchodzi w gry 3D z ich zaawansowanymi silnikami fizyki i renderowaniem obrazu. GPU stały się również popularne przy wydobywaniu różnych typów kryptowalut ze względu na ich zdolność do obliczeń numerycznych. I wreszcie, trenowanie i uruchamianie sieci neuronowej to również tylko seria mnożeń macierzy i obliczeń równoległych. Chociaż FPGA zawierają bloki matematyczne w swojej strukturze, narzędzia programowe do kompilacji tych sekwencji obliczeń numerycznych nie są optymalizowane w taki sposób jak GPU (przynajmniej na razie).

Z tych dwóch powodów wybrałem znacznie prostszy model, model klasyfikacji Irysów. Chociaż nie jest to tak zabawne jak model oparty na wizji (np. baza danych MNIST), jest znacznie bardziej bezpośredni. Zbiór danych używany do trenowania modelu to zestaw 150 obserwacji trzech różnych gatunków kwiatów Irys: Setosa, Versicolor i Virginica. Każda obserwacja (tj. zbiór danych) zawiera kilka pomiarów unikalnych dla tych kwiatów. Są to:

  1. Długość działki kielicha (cm)
  2. Szerokość działki kielicha (cm)
  3. Długość płatka (cm)
  4. Szerokość płatka (cm)

Z tych pomiarów jesteśmy w stanie stworzyć model, który może nam powiedzieć (z dość wysokim poziomem pewności) jaki to gatunek kwiatu. Podajemy te cztery dane wejściowe, a on informuje nas, czy Irys jest typu Setosa, Versicolor, czy Virginica. Dobrą stroną tego zbioru danych jest to, że nie ma w nim dużego nakładania się wartości. Na przykład, Setosa, Versicolo i Virginica mają dość wyraźne długości działek kielicha. To samo dotyczy pozostałych 3 cech. Jest to pokazane na wykresie punktowym zbioru danych:

Pairwise Scatterplots of Iris Dataset

Rysunek 1: Wykresy punktowe par cech zbioru danych Irys

Gdy rozmawialiśmy o tym, dlaczego warto użyć sieci neuronowej w Zrozumieniu sieci neuronowych, omówiliśmy koncepcję programowania opartego na regułach: zestaw reguł, którymi może kierować się komputer (czyli algorytm). W tym przypadku z pewnością moglibyśmy zakodować ten problem jako algorytm. Przedstawione wyżej wykresy punktowe prawdopodobnie można by przedstawić za pomocą serii równań matematycznych. Używamy tego zestawu danych bardziej jako przykładu dla złożoności, która nas czeka (pomyśl o bazie danych MNIST). Z tym przechodzimy do następnego tematu: Implementacja.

Dlaczego FPGA?

Przygotowując się do implementacji na FPGA, prawdopodobnie zastanawiasz się: "Dlaczego w ogóle budować sieć neuronową na FPGA?" Już ustaliliśmy, że GPU po prostu mają więcej sensu dla tej aplikacji. Jak możesz wiedzieć lub nie, GPU są drogie i wymagają dużo energii. Generują również dużo ciepła, gdy pracują z pełną mocą. Projektując sprzęt specyficzny dla aplikacji (lub chip), optymalizujesz pod kątem przestrzeni, mocy, ciepła i kosztów. Pierwszym krokiem do zaprojektowania Układu Scalonego Specyficznego dla Aplikacji (ASIC) jest najpierw zaprojektowanie go na FPGA. Jeśli kiedykolwiek planujemy budować sieci neuronowe na ASIC-ach, to byłby to pierwszy krok.

Jak to robimy?

Więc teraz w końcu przekonaliśmy się, aby dać temu szansę. Widzieliśmy przykłady, jak szkolić model i zakładamy, że jeśli uproszczymy model, to implementacja na FPGA powinna być dość prosta, prawda? Niestety, nie. Pamiętacie, co mówiliśmy o wielu macierzach i serii równań? Próba upchnięcia wszystkich tych obliczeń na małym FPGA może być niezwykle trudna. Na szczęście istnieje naprawdę fajny skrót, nad którym pracowali mili ludzie z Fast Machine Learning Lab (i społeczność) w ramach projektu open source o nazwie hls4ml. Ten projekt bierze istniejące modele i konwertuje je na język syntezy wysokiego poziomu (np. SystemC), który następnie może być przekształcony na powszechnie używane języki FPGA, takie jak Verilog czy VHDL (za pomocą narzędzi syntezy FPGA, takich jak Vivado od AMD Xilinx). Ta translacja sama w sobie eliminuje ogromną ilość kroków. Próba zbudowania kompletnego sztucznego neuronu tylko w Verilogu lub VHDL (szczególnie skomplikowanego) byłaby niezwykle trudna. To narzędzie naprawdę pomaga nam przejść prosto do sedna - ale nie bez kilku pośrednich kroków.

Optymalizacje

Doszliśmy do momentu, w którym wytrenowaliśmy nasz model i chcielibyśmy uruchomić hls4ml oraz przetestować to na FPGA. Nie tak szybko. Jeśli weźmiesz zestaw danych Iris, wytrenujesz go przy użyciu podstawowych technik, a następnie zsyntetyzujesz kod za pomocą hls4ml, prawdopodobnie uda Ci się przejść przez syntezę. Teraz skieruj się w stronę umieszczania i trasowania i nigdy nie będziesz w stanie dopasować tego modelu w takiej formie do małego FPGA. Pamiętaj, że potrzebujemy również całej logiki dotyczącej obsługi danych i komunikacji na naszym FPGA. W ich tutoriale używają płytki Pynq-Z2, aby zademonstrować uruchamianie modelu na FPGA. Ta płyta została wybrana, ponieważ zawiera nie tylko FPGA, ale również mikroprocesor na chipie (Zynq 7000), który uruchamia pełny serwer internetowy Jupyter Notebook. Oznacza to, że możesz uruchamiać funkcje przyspieszane sprzętowo na FPGA, ale także doświadczyć prostego, łatwego w użyciu interfejsu do ładowania i testowania danych. Ten interfejs, który działa jako warstwa transportowa między systemem operacyjnym a FPGA, nadal zajmuje miejsce na samym FPGA (tak jakbyśmy planowali użyć czystego FPGA, takiego jak Spartan lub Virtex FPGA).

Wyzwanie, jak wspomniano powyżej, polega na tym, że FPGA są ograniczone rozmiarem i nie mają takiej samej pojemności, jak GPU. W rezultacie nie będziemy w stanie załadować pełnego modelu na FPGA - nigdy się nie zmieści. Nawet prosty model Iris nie mógł być początkowo umieszczony na Pynq-Z2. Do Samouczka 4 zaczynasz lepiej poznawać techniki optymalizacji (niektóre z nich są tak ezoteryczne, że nawet nie zacząłem rozumieć, jak działają wewnątrz). Po zastosowaniu tych technik optymalizacyjnych powinieneś być w stanie umieścić modele (przynajmniej te prostsze) na FPGA. W tym repozytorium możemy przyjrzeć się BuildModel.py (konkretnie funkcji trenowania modelu) i zauważyć, że nie tylko trenujemy podstawowy model, ale także go optymalizujemy:

def train_model(self):
    """
    Zbuduj, wytrenuj i zapisz przycięty i kwantyzowany model sieci neuronowej przy użyciu QKeras.

    Ta funkcja konstruuje model sekwencyjny z warstwami QKeras. Model jest przycinany w celu zmniejszenia liczby parametrów
    i kwantyzowany, aby używać niższej precyzji dla wag i aktywacji. Proces treningu obejmuje ustawienie
    callbacków do aktualizacji kroków przycinania. Po treningu, opakowania przycinające są usuwane, a finalny model jest zapisywany.

    Parametry:
    Brak

    Zwraca:
    Brak
    """
    # Inicjalizacja modelu sekwencyjnego
    self.model = Sequential()

    # Dodaj pierwszą warstwę QDense z kwantyzacją i przycinaniem
    self.model.add(
        QDense(
            64,  # Liczba neuronów w warstwie
            input_shape=(4,),  # Kształt wejściowy dla zbioru danych Iris (4 cechy)
            name='fc1',
            kernel_quantizer=quantized_bits(6, 0, alpha=1),  # Kwantyzacja wag do 6 bitów
            bias_quantizer=quantized_bits(6, 0, alpha=1),  # Kwantyzacja biasów do 6 bitów
            kernel_initializer='lecun_uniform',
            kernel_regularizer=l1(0.0001),
        )
    )

    # Dodaj warstwę aktywacji ReLU z kwantyzacją
    self.model.add(QActivation(activation=quantized_relu(6), name='relu1'))

    # Dodaj drugą warstwę QDense z kwantyzacją i przycinaniem
    self.model.add(
        QDense(
            32,  # Liczba neuronów w warstwie
            name='fc2',
            kernel_quantizer=quantized_bits(6, 0, alpha=1),  # Kwantyzacja wag do 6 bitów
            bias_quantizer=quantized_bits(6, 0, alpha=1),  # Kwantyzacja biasów do 6 bitów
            kernel_initializer='lecun_uniform',
            kernel_regularizer=l1(0.0001),
        )
    )

    # Dodaj warstwę aktywacji ReLU z kwantyzacją
    self.model.add(QActivation(activation=quantized_relu(6), name='relu2'))

    # Dodaj trzecią warstwę QDense z kwantyzacją i przycinaniem
    self.model.add(
        QDense(
            32,  # Liczba neuronów w warstwie
            name='fc3',
            kernel_quantizer=quantized_bits(6, 0, alpha=1),  # Kwantyzacja wag do 6 bitów
            bias_quantizer=quantized_bits(6, 0, alpha=1),  # Kwantyzacja skłonności do 6 bitów
            kernel_initializer='lecun_uniform',
            kernel_regularizer=l1(0.0001),
        )
    )

    # Dodaj kwantyzowaną warstwę aktywacji ReLU
    self.model.add(QActivation(activation=quantized_relu(6), name='relu3'))

    # Dodaj warstwę wyjściową QDense z kwantyzacją i przycinaniem
    self.model.add(
        QDense(
            3,  # Liczba neuronów w warstwie wyjściowej (odpowiada liczbie klas w zbiorze danych Iris)
            name='output',
            kernel_quantizer=quantized_bits(6, 0, alpha=1),  # Kwantyzacja wag do 6 bitów
            bias_quantizer=quantized_bits(6, 0, alpha=1),  # Kwantyzacja skłonności do 6 bitów
            kernel_initializer='lecun_uniform',
            kernel_regularizer=l1(0.0001),
        )
    )

    # Dodaj warstwę aktywacji softmax dla klasyfikacji
    self.model.add(Activation(activation='softmax', name='softmax'))

    # Ustaw parametry przycinania, aby usunąć 75% wag, zaczynając po 2000 krokach i aktualizując co 100 kroków
    pruning_params = {"pruning_schedule": pruning_schedule.ConstantSparsity(0.75, begin_step=2000, frequency=100)}
    self.model = prune.prune_low_magnitude(self.model, **pruning_params)

    # Skompiluj model z optymalizatorem Adam i stratą krzyżową kategorii
    adam = Adam(lr=0.0001)
    self.model.compile(optimizer=adam, loss=['categorical_crossentropy'], metrics=['accuracy'])

    # Ustaw callback przycinania, aby aktualizować kroki przycinania podczas treningu
    callbacks = [
        pruning_callbacks.UpdatePruningStep(),
    ]

    # Trenuj model
    self.model.fit(
        self.X_train_val,
        self.y_train_val,
        batch_size=32,  # Rozmiar partii do treningu
        epochs=30,  # Liczba epok treningowych
        validation_split=0.25,  # Ułamek danych treningowych używany jako dane walidacyjne
        shuffle=True,  # Mieszaj dane treningowe przed każdą epoką
        callbacks=callbacks,  # Dołącz callback do przycinania
    )

    # Usuń owijki przycinania z modelu
    self.model = strip_pruning(self.model)

 

Jest tu ogromna ilość informacji do przyswojenia, ale sedno tego, co się dzieje (oprócz szkolenia), polega na tym, że dzięki kwantyzacji, regularyzacji, przycinaniu, inicjalizacji wag i optymalizacji (Adam) zmniejszamy rozmiar i złożoność wag, aby uczynić proces bardziej efektywnym i szybszym. Niektóre z tych metod są używane do poprawy dokładności i zapewnienia, że proces szkolenia przebiega prawidłowo. Gdy już przeprowadzimy nasz model przez te techniki, powinien być na tyle mały, aby można go było przekształcić w kod FPGA. Pamiętaj, że istnieje wiele sposobów, aby to zrobić. To był podejście przyjęte przez samouczek, więc trzymałem się tego, co działało.

Synteza

Więc teraz jesteśmy gotowi do kompilacji, syntezy oraz rozmieszczenia i trasowania naszego projektu. Na końcu procesu powinniśmy otrzymać plik bitstream, który efektywnie działa jak plik flash instruujący FPGA, jak ma działać. Ponieważ Zynq posiada zarówno procesor, jak i FPGA, programowanie FPGA za pomocą Pythona jest dość proste (do czego przejdziemy później). Korzystając z odniesionego repozytorium, możemy zdefiniować i skompilować nasz model używając HLS, a następnie zbudować plik bitstream, wszystko w kilku liniach kodu Pythona. W poniższym kodzie wykonujemy dodatkową walidację, aby upewnić się, że nasz model wygenerowany przez HLS ma taką samą (lub wystarczająco bliską) dokładność, jak model oryginalny.

def build_bitstream(self):
    """
    Buduje bitstream HLS dla wytrenowanego modelu.

    Ta funkcja konwertuje wytrenowany model Keras na model HLS za pomocą hls4ml, kompiluje go i generuje bitstream do wdrożenia na FPGA.
    Waliduje również model HLS względem oryginalnego modelu i wyświetla dokładność obu modeli.

    """
    # Utwórz konfigurację HLS z modelu Keras, z granularnością nazw warstw
    config = hls4ml.utils.config_from_keras_model(self.model, granularity='name')

    # Ustaw precyzję dla warstwy softmax
    config['LayerName']['softmax']['exp_table_t'] = 'ap_fixed<18,8>'
    config['LayerName']['softmax']['inv_table_t'] = 'ap_fixed<18,4>'

    # Ustaw ReuseFactor dla warstw w pełni połączonych na 512
    dla warstwy w ['fc1', 'fc2', 'fc3', 'output']:
        config['LayerName'][warstwa]['ReuseFactor'] = 512

    # Konwertuj model Keras na model HLS
    hls_model = hls4ml.converters.convert_from_keras_model(
        self.model, hls_config=config, output_dir='hls4ml_prj_pynq', backend='VivadoAccelerator', board='pynq-z2'
    )

    # Skompiluj model HLS
    hls_model.compile()

    # Przewidywanie przy użyciu modelu HLS
    y_hls = hls_model.predict(np.ascontiguousarray(self.X_test))
    np.save('package/y_hls.npy', y_hls)

    # Walidacja modelu HLS w porównaniu z oryginalnym modelem
    y_pred = self.model.predict(self.X_test)
    dokładność_orginalna = accuracy_score(np.argmax(self.y_test, axis=1), np.argmax(y_pred, axis=1))
    dokładność_hls = accuracy_score(np.argmax(self.y_test, axis=1), np.argmax(y_hls, axis=1))

    print(f"Dokładność oryginalnego modelu po przycięciu i kwantyzacji: {dokładność_orginalna * 100:.2f}%")
    print(f"Dokładność modelu HLS: {dokładność_hls * 100:.2f}%")

    # Budowanie modelu HLS
    hls_model.build(csim=False, export=True, bitfile=True)


Trudno docenić wykonaną tutaj pracę, ale ilość wysiłku potrzebnego, aby przejść od modelu Keras do pliku bitstream, jest nie do pokonania. To sprawia, że staje się to znacznie bardziej dostępne dla przeciętnego Kowalskiego (chociaż kwantyzacja, redukcja i optymalizacja były wszystko, tylko nie proste).

Konfiguracje są, ponownie, czymś, co zostało zapożyczone z poradnika, a współczynnik ponownego użycia to liczba, która może się zmieniać w zależności od tego, ile logiki chcemy ponownie wykorzystać. Po tym wystarczy kilka wywołań hls4ml i, viola, masz plik bitowy!

Testowanie

Więc teraz, gdy mamy już plik bitowy, chcielibyśmy przetestować to na rzeczywistym FPGA. Użycie środowiska Pynq ułatwia to zadanie. Aby zacząć, musimy skopiować na płytę Pynq plik bitowy, nasz skrypt testowy oraz wektory testowe. Można to zrobić za pomocą prostego polecenia SCP lub przez interfejs webowy. Aby ułatwić sprawę (jak to zostało zrobione w samouczku hls4ml), wrzucamy wszystko do jednego folderu o nazwie „package” i następnie kopiujemy go na urządzenie docelowe za pomocą SCP (zobacz skrypt BuildModel.py w repozytorium po więcej szczegółów). Ważne jest tutaj zwrócenie uwagi na dodatkowo wygenerowaną bibliotekę o nazwie axi_stream_driver.py. Ten plik zawiera funkcje pomocnicze, które nie tylko programują stronę FPGA Zynq, ale również wykonują transfer danych, aby przetestować model sieci neuronowej oparty na FPGA.

Po przeniesieniu plików na płytę Pynq możemy otworzyć powłokę SSH na docelowym urządzeniu lub utworzyć nowy notatnik, aby uruchomić kod znajdujący się w pliku on_target.py. Preferuję uruchamianie skryptu z linii poleceń, ale będzie to wymagało uprawnień roota, więc najpierw musisz uruchomić sudo -s po zalogowaniu się na swoje urządzenie przez SSH. Gdy uzyskasz powłokę roota na płycie Pynq, możesz przejść do jupyter_notebooks/iris_model_on_fpgas i uruchomić test poleceniem python3 on_target.py. Jeśli wszystko przebiegło poprawnie, powinieneś zobaczyć następujące wyjście:

Expected output of on_targer.py script

Rysunek 2: Oczekiwane wyjście skryptu on_targer.py

Weryfikacja

Więc jak możemy wiedzieć, czy wszystko zadziałało? Teraz musimy zweryfikować wyniki (tj. prognozy), które wygenerował FPGA i porównać je z naszym lokalnym modelem, który stworzyliśmy przy użyciu naszej GPU (lub CPU). Mówiąc prostym językiem, dla każdej długości/szerokości działki kielicha/kwiatu, którą podajemy jako dane wejściowe, oczekujemy, że na wyjściu będzie jeden z typów Irys: Setosa, Versicolor lub Virginica (oczywiście wszystko w liczbach). Mamy nadzieję, że FPGA jest równie dokładny, jak model działający na naszym lokalnym komputerze.

Będziemy musieli skopiować wynik skryptu z powrotem na naszą maszynę. Możesz ponownie uruchomić polecenie SCP lub po prostu pobrać przez interfejs notatnika Jupyter. Plik wyjściowy będzie nazywał się y_hw.npy. Po skopiowaniu pliku będziemy musieli uruchomić utilities/validate_model.py. Wynik powinien wyglądać mniej więcej tak:

Results from validation script

Rysunek 3: Wyniki z walidacyjnego skryptu

Jak widać, nasz zoptymalizowany model (na PC) i syntezowany model (na FPGA) mają taki sam poziom dokładności: 96,67%. Spośród wszystkich 30 punktów testowych nie udało nam się przewidzieć tylko jednego - świetnie!

Podsumowanie

W tym artykule wzięliśmy zbiór danych klasyfikacji Iris i stworzyliśmy z niego model sieci neuronowej. Używając hls4ml, zbudowaliśmy plik bitowy i niezbędne biblioteki, aby uruchomić ten sam model na płycie Pynq-Z2. Uruchomiliśmy również skrypt walidacyjny, który porównał model oparty na komputerze z modelem opartym na FPGA oraz jak oba te modele wypadły w porównaniu z oryginalnymi danymi. Chociaż model i zbiór danych były dość trywialne, ten samouczek położył podstawy pod to, czym jest projektowanie złożonych sieci neuronowych na FPGA.

Uwaga: Cały kod do tego projektu można znaleźć w tym repozytorium.

About Author

About Author

Ari jest inżynierem z rozległym doświadczeniem w projektowaniu, produkcji, testowaniu i integracji systemów elektrycznych, mechanicznych i oprogramowania. Jego pasją jest łączenie inżynierów zajmujących się projektowaniem, weryfikacją i testowaniem, aby pracowali jako jeden zespół.

Powiązane zasoby

Powiązana dokumentacja techniczna

Powrót do strony głównej
Thank you, you are now subscribed to updates.