In Comprendere le Reti Neurali abbiamo esaminato una panoramica delle Reti Neurali Artificiali e abbiamo cercato di fornire esempi nel mondo reale per comprenderle a un livello superiore. In questo articolo andremo a vedere come addestrare le reti neurali e poi come implementarle su un Field Programmable Gate Array (FPGA) utilizzando una libreria open source chiamata hls4ml.
In contrasto con Comprendere le reti neurali, ci concentreremo su un modello molto più semplice per due motivi principali. Per alcuni, passare da pixel 28 x 28 a un modello completo è stato un grande salto. Anche se ho spiegato la suddivisione come una serie di funzioni a tratti in una grande matrice multidimensionale, era ancora difficile da afferrare. Spesso sentiamo parlare di "parametri", ma nei modelli di elaborazione delle immagini, possono sembrare astratti e complicati. Vogliamo anche utilizzare un modello più semplice perché stiamo per sintetizzarlo su hardware. Una unità di elaborazione grafica (GPU), anche una GPU standard per desktop domestici, può calcolare e addestrare modelli al volo. È progettata specificamente per quel compito (cioè il calcolo numerico). Pensate a quante calcolazioni sono necessarie per i giochi 3D con i loro sofisticati motori fisici e rendering delle immagini. Le GPU sono state anche popolari con il mining di diversi tipi di criptovalute a causa della loro capacità di elaborazione numerica. E, infine, addestrare ed eseguire una rete neurale è anche solo una serie di moltiplicazioni di matrici e calcolo parallelo. Mentre le FPGA contengono blocchi matematici all'interno del loro tessuto, gli strumenti software per compilare queste sequenze di calcolo numerico non sono ottimizzati nel modo in cui lo sono le GPU (almeno non ancora).
Per queste due ragioni ho scelto un modello molto più semplice, il modello di classificazione Iris. Anche se non è divertente come un modello basato sulla visione (ad es. database MNIST), è molto più diretto. Il dataset utilizzato per addestrare il modello è un insieme di 150 osservazioni di tre diverse specie di fiori di Iris: Setosa, Versicolor e Virginica. Ogni osservazione (ovvero dataset) contiene diverse misurazioni uniche per quei fiori. Queste sono:
Con queste misurazioni siamo in grado di creare un modello che può dirci (con un livello di certezza piuttosto alto) di che specie è il fiore. Forniamo questi quattro input e ci dice se l'Iris è di tipo Setosa, Versicolor o Virginica. La cosa bella di questo dataset è che non c'è molto sovrapposizione tra questi valori. Ad esempio, Setosa, Versicolo e Virginica hanno lunghezze del Sepalo piuttosto distinte. Lo stesso vale per le altre 3 caratteristiche. Questo è mostrato nel grafico a dispersione del dataset:
Figura 1: Grafici a dispersione a coppie del dataset Iris
Quando abbiamo parlato del motivo per cui utilizzare una rete neurale in Comprendere le Reti Neurali, abbiamo discusso il concetto di programmazione basata su regole: un insieme di regole che il computer può seguire (cioè un algoritmo). In questo caso, potremmo certamente codificare questo problema come un algoritmo. I grafici a dispersione sopra possono probabilmente essere rappresentati da una serie di equazioni matematiche. Utilizziamo questo insieme di dati più come un esempio per la complessità che verrà (pensate al database MNIST). Con questo passiamo all'argomento successivo: Implementazione.
Mentre ci prepariamo a implementare questo su un FPGA, probabilmente ti starai chiedendo, "Perché costruire una rete neurale su un FPGA?" Abbiamo già stabilito che le GPU hanno più senso per questa applicazione. Come potresti o non sapere, le GPU sono costose e richiedono molta energia. Generano anche molto calore quando funzionano a piena capacità. Quando si progetta hardware specifico per applicazioni (o un chip), si ottimizza per spazio, potenza, calore e costo. Il primo passo per progettare un Circuito Integrato Specifico per Applicazioni (ASIC) è prima progettarlo su un FPGA. Se mai pianifichiamo di costruire reti neurali su ASIC, questo sarebbe il primo passo.
Quindi ora siamo finalmente convinti a dare una possibilità a questo approccio. Abbiamo visto esempi su come addestrare un modello e supponiamo che, semplificando il modello, implementarlo su un FPGA dovrebbe essere abbastanza semplice, giusto? Sbagliato. Ricordate di cosa abbiamo parlato riguardo le molte matrici e serie di equazioni? Cercare di inserire tutti quei calcoli su un piccolo FPGA può essere estremamente sfidante. Fortunatamente, esiste una scorciatoia davvero interessante che le gentili persone del Laboratorio di Apprendimento Automatico Veloce (e la comunità) hanno lavorato in un progetto open source chiamato hls4ml. Questo progetto prende i modelli esistenti e li converte in un linguaggio di sintesi ad alto livello (ad es. SystemC) che può poi essere convertito nei linguaggi FPGA comunemente usati come Verilog o VHDL (utilizzando strumenti di sintesi FPGA come Vivado di AMD Xilinx). Questa traduzione, di per sé, elimina un'enorme quantità di passaggi. Tentare di costruire una rete neurale completa solo in Verilog o VHDL (soprattutto una complessa) sarebbe estremamente difficile. Questo strumento ci aiuta davvero ad andare dritti al sodo - ma non senza alcuni passaggi intermedi.
Siamo arrivati al punto in cui abbiamo addestrato il nostro modello e vorremmo eseguire hls4ml e testarlo su un FPGA. Non così in fretta. Se prendi il dataset Iris, lo addestri con tecniche di base e poi sintetizzi il codice con hls4ml, probabilmente supererai la sintesi. Ora dirigiti verso il posizionamento e il tracciamento e non sarai mai in grado di adattare quel modello così com'è su un piccolo FPGA. Ricorda, abbiamo bisogno anche di tutta la logica intorno alla gestione dei dati e alla comunicazione sul nostro FPGA. Nei loro tutorial, utilizzano una scheda Pynq-Z2 per dimostrare l'esecuzione di un modello su un FPGA. Questa scheda è stata scelta perché contiene non solo un FPGA ma anche un microprocessore sul chip (Zynq 7000) che esegue un completo server web Jupyter Notebook. Questo significa che puoi eseguire funzioni accelerate hardware su un FPGA ma anche sperimentare un'interfaccia semplice e facile da usare per caricare e testare i tuoi dati. Questa interfaccia che funge da strato di trasporto tra il sistema operativo e l'FPGA occupa comunque spazio sull'FPGA stesso (proprio come se avessimo pianificato di usare un FPGA puro come gli FPGA Spartan o Virtex).
La sfida, come menzionato sopra, è che gli FPGA sono limitati in dimensione e non hanno la stessa capacità dei GPU. Di conseguenza, non saremo in grado di trasferire un modello completo su un FPGA - non ci entrerà mai. Persino il semplice modello Iris non poteva essere inizialmente collocato sul Pynq-Z2. Entro Tutorial 4 inizi a familiarizzare maggiormente con le tecniche di ottimizzazione (alcune delle quali sono così esoteriche che non ho nemmeno iniziato a capire come funzionano sotto il cofano). Una volta applicate queste tecniche di ottimizzazione, dovresti essere in grado di trasferire i modelli (almeno quelli più semplici) su un FPGA. In questo repository possiamo guardare BuildModel.py (specificatamente la funzione di addestramento del modello) e osservare che non stiamo solo addestrando un modello di base, ma lo stiamo anche ottimizzando:
def train_model(self):
"""
Costruisci, addestra e salva un modello di rete neurale potato e quantizzato utilizzando QKeras.
Questa funzione costruisce un modello Sequenziale con strati QKeras. Il modello è ridotto per diminuire il numero di parametri
e quantizzato per utilizzare una precisione inferiore per i pesi e le attivazioni. Il processo di addestramento include l'impostazione
di callback per aggiornare i passaggi di riduzione. Dopo l'addestramento, i wrapper di riduzione sono rimossi, e il modello finale viene salvato.
Parametri:
Nessuno
Ritorna:
Nessuno
"""
# Inizializza un modello Sequenziale
self.model = Sequential()
# Aggiungi il primo strato QDense con quantizzazione e potatura
self.model.add(
QDense(
64, # Numero di neuroni nello strato
input_shape=(4,), # Forma dell'input per il dataset Iris (4 caratteristiche)
name='fc1',
kernel_quantizer=quantized_bits(6, 0, alpha=1), # Quantizza i pesi a 6 bit
bias_quantizer=quantized_bits(6, 0, alpha=1), # Quantizza i bias a 6 bit
kernel_initializer='lecun_uniform',
kernel_regularizer=l1(0.0001),
)
)
# Aggiungi uno strato di attivazione ReLU quantizzato
self.model.add(QActivation(activation=quantized_relu(6), name='relu1'))
# Aggiungi il secondo strato QDense con quantizzazione e potatura
self.model.add(
QDense(
32, # Numero di neuroni nello strato
nome='fc2',
kernel_quantizer=quantized_bits(6, 0, alpha=1), # Quantizza i pesi a 6 bit
bias_quantizer=quantized_bits(6, 0, alpha=1), # Quantizza i bias a 6 bit
kernel_initializer='lecun_uniform',
kernel_regularizer=l1(0.0001),
)
)
# Aggiungi uno strato di attivazione ReLU quantizzato
self.model.add(QActivation(activation=quantized_relu(6), nome='relu2'))
# Aggiungi il terzo strato QDense con quantizzazione e potatura
self.model.add(
QDense(
32, # Numero di neuroni nello strato
nome='fc3',
kernel_quantizer=quantized_bits(6, 0, alpha=1), # Quantizza i pesi a 6 bit
bias_quantizer=quantized_bits(6, 0, alpha=1), # Quantizza i bias a 6 bit
kernel_initializer='lecun_uniform',
kernel_regularizer=l1(0.0001),
)
)
# Aggiungi uno strato di attivazione ReLU quantizzato
self.model.add(QActivation(activation=quantized_relu(6), nome='relu3'))
# Aggiungi il layer di output QDense con quantizzazione e potatura
self.model.add(
QDense(
3, # Numero di neuroni nel layer di output (corrisponde al numero di classi nel dataset Iris)
name='output',
kernel_quantizer=quantized_bits(6, 0, alpha=1), # Quantizza i pesi a 6 bit
bias_quantizer=quantized_bits(6, 0, alpha=1), # Quantizza i bias a 6 bit
kernel_initializer='lecun_uniform',
kernel_regularizer=l1(0.0001),
)
)
# Aggiungi un layer di attivazione softmax per la classificazione
self.model.add(Activation(activation='softmax', name='softmax'))
# Imposta i parametri di potatura per potare il 75% dei pesi, iniziando dopo 2000 passi e aggiornando ogni 100 passi
pruning_params = {"pruning_schedule": pruning_schedule.ConstantSparsity(0.75, begin_step=2000, frequency=100)}
self.model = prune.prune_low_magnitude(self.model, **pruning_params)
# Compila il modello con l'ottimizzatore Adam e la perdita di entropia incrociata categorica
adam = Adam(lr=0.0001)
self.model.compile(optimizer=adam, loss=['categorical_crossentropy'], metrics=['accuracy'])
# Imposta il callback di potatura per aggiornare i passi di potatura durante l'allenamento
callbacks = [
pruning_callbacks.UpdatePruningStep(),
]
# Addestrare il modello
self.model.fit(
self.X_train_val,
self.y_train_val,
batch_size=32, # Dimensione del batch per l'addestramento
epochs=30, # Numero di epoche di addestramento
validation_split=0.25, # Frazione dei dati di addestramento da utilizzare come dati di validazione
shuffle=True, # Mescola i dati di addestramento prima di ogni epoca
callbacks=callbacks, # Includi callback di potatura
)
# Rimuovere i wrapper di potatura dal modello
self.model = strip_pruning(self.model)
C'è un'enorme quantità di informazioni da assimilare qui, ma l'essenza di ciò che sta accadendo (oltre all'addestramento) è che attraverso la quantizzazione, la regolarizzazione, il pruning, l'inizializzazione dei pesi e l'ottimizzazione (Adam) stiamo riducendo le dimensioni e la complessità dei pesi per renderli più efficienti e veloci. Alcuni di questi metodi sono utilizzati per migliorare l'accuratezza e garantire che il processo di addestramento funzioni bene. Una volta che abbiamo eseguito il nostro modello attraverso queste tecniche, dovrebbe essere abbastanza piccolo da trasformarlo in codice FPGA. Ricorda, ci sono molti modi per fare ciò. Questo era l'approccio adottato dal tutorial quindi mi sono attenuto a ciò che ha funzionato.
Quindi ora siamo pronti a compilare, sintetizzare e posizionare e instradare il nostro progetto. Al termine del processo dovremmo ottenere un file bitstream che, di fatto, funge da file flash per istruire l'FPGA su come operare. Poiché il Zynq ha sia un processore che un FPGA, è abbastanza semplice programmare l'FPGA tramite Python (di cui parleremo più avanti). Utilizzando il repository di riferimento, possiamo definire e compilare il nostro modello usando HLS e poi costruire il file bitstream tutto in poche righe di codice Python. Nel codice seguente eseguiamo una validazione aggiuntiva per assicurarci che il nostro modello generato da HLS abbia la stessa precisione (o abbastanza vicina) del modello originale.
def build_bitstream(self):
"""
Costruisce il bitstream HLS per il modello addestrato.
Questa funzione converte il modello Keras addestrato in un modello HLS usando hls4ml, lo compila e genera il bitstream per il dispiegamento su FPGA.
Valida anche il modello HLS rispetto al modello originale e stampa l'accuratezza di entrambi i modelli.
"""
# Crea una configurazione HLS dal modello Keras, con granularità dei nomi dei layer
config = hls4ml.utils.config_from_keras_model(self.model, granularity='name')
# Imposta la precisione per il layer softmax
config['LayerName']['softmax']['exp_table_t'] = 'ap_fixed<18,8>'
config['LayerName']['softmax']['inv_table_t'] = 'ap_fixed<18,4>'
# Imposta il ReuseFactor per i layer completamente connessi a 512
for layer in ['fc1', 'fc2', 'fc3', 'output']:
config['LayerName'][layer]['ReuseFactor'] = 512
# Converti il modello Keras in un modello HLS
hls_model = hls4ml.converters.convert_from_keras_model(
self.model, hls_config=config, output_dir='hls4ml_prj_pynq', backend='VivadoAccelerator', board='pynq-z2'
)
# Compila il modello HLS
hls_model.compile()
# Prevedere utilizzando il modello HLS
y_hls = hls_model.predict(np.ascontiguousarray(self.X_test))
np.save('package/y_hls.npy', y_hls)
# Validare il modello HLS rispetto al modello originale
y_pred = self.model.predict(self.X_test)
accuratezza_originale = accuracy_score(np.argmax(self.y_test, axis=1), np.argmax(y_pred, axis=1))
accuratezza_hls = accuracy_score(np.argmax(self.y_test, axis=1), np.argmax(y_hls, axis=1))
print(f"Accuratezza del modello originale potato e quantizzato: {accuratezza_originale * 100:.2f}%")
print(f"Accuratezza del modello HLS: {accuratezza_hls * 100:.2f}%")
# Costruire il modello HLS
hls_model.build(csim=False, export=True, bitfile=True)
È difficile apprezzare ciò che è stato fatto qui, ma la quantità di lavoro coinvolto per passare da un modello Keras a un file bitstream è insormontabile. Questo lo rende significativamente più accessibile alla persona media (anche se la quantizzazione, la riduzione e l'ottimizzazione non sono state affatto semplici).
Le configurazioni sono, di nuovo, qualcosa che è stato preso in prestito dal tutorial e il fattore di riutilizzo è un numero che può cambiare in base a quanto della logica vogliamo riutilizzare. Dopo ciò, sono sufficienti alcune chiamate a hls4ml e, viola, hai un file bit!
Quindi, ora che abbiamo un file bit, vorremmo testarlo sull'FPGA reale. Utilizzare l'ambiente Pynq rende questo processo più semplice. Per iniziare dobbiamo copiare sul board Pynq il file bit, il nostro script di test e i vettori di test. Questo può essere fatto con un semplice comando SCP o tramite l'interfaccia web. Per semplificare le cose (come fatto nel tutorial hls4ml) mettiamo tutto in una singola cartella chiamata "package" e poi la copiamo sul dispositivo target tramite SCP (vedere lo script BuildModel.py nel repository per maggiori dettagli). Ciò che è importante notare qui è una libreria aggiuntiva generata automaticamente chiamata axi_stream_driver.py. Questo file contiene le funzioni di aiuto non solo per flashare il lato FPGA dello Zynq ma anche per eseguire il trasferimento dei dati per testare il modello di rete neurale basato su FPGA.
Una volta trasferiti i file sulla scheda Pynq, possiamo aprire una shell SSH sul target o creare un nuovo notebook per eseguire il codice presente in on_target.py. Preferisco eseguire lo script tramite riga di comando, ma sarà necessario disporre dei privilegi di root, quindi dovrai prima eseguire sudo -s dopo esserti connesso via SSH al tuo dispositivo. Una volta ottenuta una shell root sulla scheda Pynq, puoi navigare in jupyter_notebooks/iris_model_on_fpgas ed eseguire il test con il comando python3 on_target.py. Se tutto è stato eseguito correttamente, dovresti vedere il seguente output:
Figura 2: Output atteso dello script on_target.py
Come facciamo a sapere se tutto ha funzionato? Ora dobbiamo validare gli output (ovvero le previsioni) generati dall'FPGA e confrontarli con il nostro modello locale che abbiamo creato utilizzando la nostra GPU (o CPU). In termini semplici, per ogni lunghezza/larghezza di sepalo/petalo che forniamo come input ci aspettiamo in output un'iris di tipo Setosa, Versicolor o Virginica (tutto in numeri, naturalmente). Speriamo che l'FPGA sia preciso quanto il modello in esecuzione sulla nostra macchina locale.
Dovremo copiare l'output dello script sul nostro computer. Puoi eseguire nuovamente un comando SCP o semplicemente scaricarlo tramite l'interfaccia del notebook Jupyter. Il file di output si chiamerà y_hw.npy. Dopo aver copiato il file dovremo eseguire utilities/validate_model.py. L'output dovrebbe apparire più o meno così:
Figura 3: Risultati dello script di validazione
Come puoi vedere, il nostro modello ottimizzato (sul PC) e il modello sintetizzato (sull'FPGA) condividono lo stesso livello di accuratezza: 96,67%. Su tutti i 30 punti di test ne abbiamo previsto correttamente 29 - ottimo!
In questo articolo, abbiamo preso il dataset di classificazione Iris e creato un modello di rete neurale a partire da esso. Utilizzando hls4ml, abbiamo costruito un bitfile e le necessarie librerie per eseguire lo stesso modello su una scheda Pynq-Z2. Abbiamo anche eseguito uno script di validazione che ha confrontato il modello basato sul computer con quello basato sull'FPGA e come entrambi si sono confrontati con i dati originali. Sebbene il modello e il dataset fossero piuttosto semplici, questo tutorial ha gettato le basi per quello che significa progettare reti neurali complesse su FPGA.
Nota: Tutto il codice per questo progetto può essere trovato in questo repository.