Construction de réseaux de neurones sur des FPGA

Ari Mahpour
|  Créé: Août 8, 2024  |  Mise à jour: Octobre 9, 2024
Construction de réseaux de neurones sur des FPGA

Dans Comprendre les réseaux de neurones, nous avons examiné un aperçu des réseaux de neurones artificiels et avons tenté de donner des exemples dans le monde réel pour les comprendre à un niveau supérieur. Dans cet article, nous allons voir comment entraîner des réseaux de neurones puis les déployer sur un Field Programmable Gate Array (FPGA) en utilisant une bibliothèque open source appelée hls4ml.

Le Modèle

En contraste avec Comprendre les réseaux neuronaux, nous allons examiner un modèle beaucoup plus simple pour deux raisons principales. Pour certains, passer de 28 x 28 pixels à un modèle complet représentait un énorme saut. Bien que j'aie expliqué la décomposition comme une série de fonctions morceaux dans une grande matrice multidimensionnelle, cela restait difficile à saisir. Nous entendons souvent parler de « paramètres », mais dans les modèles de traitement d'images, ils peuvent sembler abstraits et compliqués. Nous voulons également utiliser un modèle plus simple parce que nous allons le synthétiser sur du matériel. Une unité de traitement graphique (GPU), même un GPU de bureau domestique standard, peut rapidement calculer et entraîner des modèles à la volée. Il est spécifiquement conçu pour cette tâche (c'est-à-dire le calcul numérique). Pensez au nombre de calculs nécessaires pour les jeux 3D avec leurs moteurs physiques sophistiqués et le rendu d'images. Les GPU ont également été populaires pour le minage de différents types de cryptomonnaies en raison de leur capacité à effectuer des calculs. Et, enfin, l'entraînement et l'exécution d'un réseau neuronal sont également juste une série de multiplications matricielles et de calculs parallèles. Bien que les FPGA contiennent des blocs mathématiques dans leur structure, les outils logiciels pour compiler ces séquences de calculs ne sont pas optimisés de la manière dont le sont les GPU (du moins pas encore).

Pour ces deux raisons, j'ai choisi un modèle beaucoup plus simple, le modèle de classification des Iris. Bien que cela ne soit pas aussi amusant qu'un modèle basé sur la vision (par exemple, base de données MNIST), c'est beaucoup plus direct. Le jeu de données utilisé pour entraîner le modèle est un ensemble de 150 observations de trois espèces différentes de fleurs d'Iris : Setosa, Versicolor et Virginica. Chaque observation (c'est-à-dire jeu de données) contient plusieurs mesures uniques à ces fleurs. Elles sont :

  1. Longueur du Sépale (cm)
  2. Largeur du Sépale (cm)
  3. Longueur du Pétale (cm)
  4. Largeur du Pétale (cm)

Avec ces mesures, nous sommes capables de créer un modèle qui peut nous dire (avec un niveau de certitude assez élevé) de quelle espèce de fleur il s'agit. Nous fournissons ces quatre entrées et il nous dit si l'Iris est de type Setosa, Versicolor ou Virginica. L'avantage de ce jeu de données est qu'il n'y a pas beaucoup de chevauchement entre ces valeurs. Par exemple, Setosa, Versicolo et Virginica ont tous des longueurs de Sépale assez distinctes. Il en va de même pour les 3 autres caractéristiques. Cela est montré dans le nuage de points du jeu de données :

Pairwise Scatterplots of Iris Dataset

Figure 1 : Nuages de points par paires du jeu de données Iris

Lorsque nous avons parlé de pourquoi utiliser un réseau de neurones dans Comprendre les réseaux de neurones, nous avons discuté du concept de programmation basée sur des règles : un ensemble de règles que l'ordinateur peut suivre (c'est-à-dire un algorithme). Dans ce cas, nous pourrions très certainement coder ce problème sous forme d'algorithme. Les graphiques en nuage de points ci-dessus peuvent probablement être représentés par une série d'équations mathématiques. Nous utilisons cet ensemble de données davantage comme un exemple pour la complexité à venir (pensez à la base de données MNIST). Avec cela, nous passons au sujet suivant : Implémentation.

Pourquoi un FPGA ?

Alors que nous nous préparons à mettre cela en œuvre sur un FPGA, vous vous demandez probablement : "Pourquoi même construire un réseau neuronal sur un FPGA ?" Nous avons déjà établi que les GPU sont plus adaptés pour cette application. Comme vous le savez peut-être ou non, les GPU sont coûteux et nécessitent beaucoup d'énergie. Ils génèrent également beaucoup de chaleur lorsqu'ils fonctionnent à pleine capacité. Lors de la conception de matériel spécifique à une application (ou d'une puce), vous optimisez l'espace, la puissance, la chaleur et le coût. La première étape de la conception d'un Circuit Intégré Spécifique à une Application (ASIC) est de d'abord le concevoir sur un FPGA. Si nous envisageons un jour de construire des réseaux neuronaux sur des ASIC, ce serait la première étape.

Comment le faisons-nous ?

Alors maintenant, nous sommes finalement convaincus de tenter l'expérience. Nous avons vu des exemples sur la manière de former un modèle et nous supposons que si nous simplifions le modèle, alors sa mise en œuvre sur un FPGA devrait être assez simple, n'est-ce pas ? Faux. Souvenez-vous de ce dont nous avons parlé concernant les nombreuses matrices et séries d'équations ? Essayer de caser tous ces calculs sur un petit FPGA peut être extrêmement difficile. Heureusement, il existe un raccourci vraiment intéressant que les gentilles personnes du Laboratoire d'Apprentissage Machine Rapide (et la communauté) ont travaillé sur un projet open source appelé hls4ml. Ce projet prend des modèles existants et les convertit en un langage de synthèse de haut niveau (par exemple, SystemC) qui peut ensuite être converti en langages FPGA couramment utilisés tels que Verilog ou VHDL (en utilisant des outils de synthèse FPGA tels que Vivado de AMD Xilinx). Cette traduction, en elle-même, élimine une immense quantité d'étapes. Tenter de construire un réseau neuronal complet en utilisant uniquement Verilog ou VHDL (surtout un complexe) serait extrêmement difficile. Cet outil nous aide vraiment à aller droit au but - mais pas sans quelques étapes intermédiaires.

Optimisations

Nous sommes à un point où nous avons entraîné notre modèle et nous aimerions exécuter hls4ml et tester cela sur un FPGA. Pas si vite. Si vous prenez le jeu de données Iris, l'entraînez avec des techniques de base, puis synthétisez le code avec hls4ml, vous passerez probablement la synthèse. Maintenant, dirigez-vous vers le placement et le routage et vous ne pourrez jamais intégrer ce modèle tel quel sur un petit FPGA. Souvenez-vous, nous avons également besoin de toute la logique autour de la gestion des données et de la communication sur notre FPGA également. Dans leurs tutoriels, ils utilisent une carte Pynq-Z2 pour démontrer l'exécution d'un modèle sur un FPGA. Cette carte a été choisie parce qu'elle contient non seulement un FPGA mais aussi un microprocesseur sur la puce (Zynq 7000) qui exécute un serveur web complet de Jupyter Notebook. Cela signifie que vous pouvez exécuter des fonctions accélérées par matériel sur un FPGA mais aussi bénéficier d'une interface simple et facile à utiliser pour charger et tester vos données. Cette interface qui agit comme une couche de transport entre le système d'exploitation et le FPGA prend toujours de la place sur le FPGA lui-même (tout comme si nous avions prévu d'utiliser un FPGA pur et dur comme les FPGA Spartan ou Virtex).

Le défi, comme mentionné ci-dessus, est que les FPGA sont limités en taille et n'ont pas la même capacité que les GPU. En conséquence, nous ne pourrons pas transférer un modèle complet sur un FPGA - il ne rentrera jamais. Même le modèle Iris simple n'a pas pu être placé initialement sur le Pynq-Z2. D'ici Tutoriel 4, vous commencez à vous familiariser davantage avec les techniques d'optimisation (certaines sont tellement ésotériques que je n'ai même pas effleuré la surface pour comprendre comment elles fonctionnent sous le capot). Une fois que vous appliquez ces techniques d'optimisation, vous devriez être capable de mettre des modèles (au moins les plus simples) sur un FPGA. Dans ce dépôt, nous pouvons regarder BuildModel.py (spécifiquement la fonction d'entraînement du modèle) et observer que nous n'entraînons pas juste un modèle de base mais aussi en optimisant un :

def train_model(self):
    """
    Construire, entraîner et sauvegarder un modèle de réseau neuronal élagué et quantifié en utilisant QKeras.

    Cette fonction construit un modèle Séquentiel avec des couches QKeras. Le modèle est élagué pour réduire le nombre de paramètres
    et quantifié pour utiliser une précision inférieure pour les poids et les activations. Le processus d'entraînement inclut la configuration
    des rappels pour la mise à jour des étapes d'élagage. Après l'entraînement, les enveloppes d'élagage sont retirées, et le modèle final est sauvegardé.

    Paramètres :
    Aucun

    Renvoie :
    Aucun
    """
    # Initialiser un modèle Séquentiel
    self.model = Sequential()

    # Ajouter la première couche QDense avec quantification et élagage
    self.model.add(
        QDense(
            64,  # Nombre de neurones dans la couche
            input_shape=(4,),  # Forme d'entrée pour le jeu de données Iris (4 caractéristiques)
            name='fc1',
            kernel_quantizer=quantized_bits(6, 0, alpha=1),  # Quantifier les poids à 6 bits
            bias_quantizer=quantized_bits(6, 0, alpha=1),  # Quantifier les biais à 6 bits
            kernel_initializer='lecun_uniform',
            kernel_regularizer=l1(0.0001),
        )
    )

    # Ajouter une couche d'activation ReLU quantifiée
    self.model.add(QActivation(activation=quantized_relu(6), name='relu1'))

    # Ajouter la seconde couche QDense avec quantification et élagage
    self.model.add(
        QDense(
            32,  # Nombre de neurones dans la couche
            nom='fc2',
            quantificateur_noyau=quantized_bits(6, 0, alpha=1),  # Quantifier les poids à 6 bits
            quantificateur_biais=quantized_bits(6, 0, alpha=1),  # Quantifier les biais à 6 bits
            initialiseur_noyau='lecun_uniform',
            régularisateur_noyau=l1(0.0001),
        )
    )

    # Ajouter une couche d'activation ReLU quantifiée
    self.model.add(QActivation(activation=quantized_relu(6), nom='relu2'))

    # Ajouter la troisième couche QDense avec quantification et élagage
    self.model.add(
        QDense(
            32,  # Nombre de neurones dans la couche
            nom='fc3',
            quantificateur_noyau=quantized_bits(6, 0, alpha=1),  # Quantifier les poids à 6 bits
            quantificateur_biais=quantized_bits(6, 0, alpha=1),  # Quantifier les biais à 6 bits
            initialiseur_noyau='lecun_uniform',
            régularisateur_noyau=l1(0.0001),
        )
    )

    # Ajouter une couche d'activation ReLU quantifiée
    self.model.add(QActivation(activation=quantized_relu(6), nom='relu3'))

    # Ajouter la couche de sortie QDense avec quantification et élagage
    self.model.add(
        QDense(
            3,  # Nombre de neurones dans la couche de sortie (correspond au nombre de classes dans le jeu de données Iris)
            nom='output',
            kernel_quantizer=quantized_bits(6, 0, alpha=1),  # Quantifier les poids à 6 bits
            bias_quantizer=quantized_bits(6, 0, alpha=1),  # Quantifier les biais à 6 bits
            kernel_initializer='lecun_uniform',
            kernel_regularizer=l1(0.0001),
        )
    )

    # Ajouter une couche d'activation softmax pour la classification
    self.model.add(Activation(activation='softmax', nom='softmax'))

    # Configurer les paramètres d'élagage pour élaguer 75% des poids, en commençant après 2000 étapes et en mettant à jour toutes les 100 étapes
    pruning_params = {"pruning_schedule": pruning_schedule.ConstantSparsity(0.75, begin_step=2000, frequency=100)}
    self.model = prune.prune_low_magnitude(self.model, **pruning_params)

    # Compiler le modèle avec l'optimiseur Adam et la perte d'entropie croisée catégorique
    adam = Adam(lr=0.0001)
    self.model.compile(optimizer=adam, loss=['categorical_crossentropy'], metrics=['accuracy'])

    # Configurer le rappel d'élagage pour mettre à jour les étapes d'élagage pendant l'entraînement
    callbacks = [
        pruning_callbacks.UpdatePruningStep(),
    ]

    # Entraîner le modèle
    self.model.fit(
        self.X_train_val,
        self.y_train_val,
        batch_size=32,  # Taille du lot pour l'entraînement
        epochs=30,  # Nombre d'époques d'entraînement
        validation_split=0.25,  # Fraction des données d'entraînement à utiliser comme données de validation
        shuffle=True,  # Mélanger les données d'entraînement avant chaque époque
        callbacks=callbacks,  # Inclure le rappel d'élagage
    )

    # Retirer les enveloppes d'élagage du modèle
    self.model = strip_pruning(self.model)

 

Il y a énormément à assimiler ici, mais l'essentiel de ce qui se passe (en plus de la formation) est que, grâce à la quantification, la régularisation, l'élagage, l'initialisation des poids et l'optimisation (Adam), nous réduisons la taille et la complexité des poids pour les rendre plus efficaces et plus rapides. Certaines de ces méthodes sont utilisées pour améliorer la précision et garantir le bon déroulement du processus de formation. Une fois que nous avons soumis notre modèle à ces techniques, il devrait être suffisamment petit pour être converti en code FPGA. Souvenez-vous, il existe de nombreuses façons de faire cela. C'était l'approche adoptée par le tutoriel, donc je me suis tenu à ce qui fonctionnait.

Synthèse

Nous sommes donc prêts à compiler, synthétiser, et placer et router notre conception. À la fin du processus, nous devrions obtenir un fichier bitstream qui, effectivement, agit comme un fichier flash pour instruire le FPGA sur son fonctionnement. Puisque le Zynq possède à la fois un processeur et un FPGA, il est assez facile de programmer le FPGA via Python (ce que nous verrons plus tard). En utilisant le référentiel mentionné, nous pouvons définir et compiler notre modèle en utilisant HLS puis construire le fichier bitstream tout en quelques lignes de code Python. Dans le code suivant, nous effectuons une validation supplémentaire pour nous assurer que notre modèle généré par HLS a la même précision (ou suffisamment proche) que le modèle original.

def build_bitstream(self):
    """
    Construit le bitstream HLS pour le modèle formé.

    Cette fonction convertit le modèle Keras formé en un modèle HLS en utilisant hls4ml, le compile, et génère le bitstream pour le déploiement sur FPGA.
    Elle valide également le modèle HLS par rapport au modèle original et affiche la précision des deux modèles.

    """
    # Créer une configuration HLS à partir du modèle Keras, avec une granularité des noms de couches
    config = hls4ml.utils.config_from_keras_model(self.model, granularity='name')

    # Définir la précision pour la couche softmax
    config['LayerName']['softmax']['exp_table_t'] = 'ap_fixed<18,8>'
    config['LayerName']['softmax']['inv_table_t'] = 'ap_fixed<18,4>'

    # Définir le ReuseFactor pour les couches entièrement connectées à 512
    for layer in ['fc1', 'fc2', 'fc3', 'output']:
        config['LayerName'][layer]['ReuseFactor'] = 512

    # Convertir le modèle Keras en un modèle HLS
    hls_model = hls4ml.converters.convert_from_keras_model(
        self.model, hls_config=config, output_dir='hls4ml_prj_pynq', backend='VivadoAccelerator', board='pynq-z2'
    )

    # Compiler le modèle HLS
    hls_model.compile()

    # Prédire en utilisant le modèle HLS
    y_hls = hls_model.predict(np.ascontiguousarray(self.X_test))
    np.save('package/y_hls.npy', y_hls)

    # Valider le modèle HLS par rapport au modèle original
    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"Précision du modèle original élagué et quantifié : {accuracy_original * 100:.2f}%")
    print(f"Précision du modèle HLS : {accuracy_hls * 100:.2f}%")

    # Construire le modèle HLS
    hls_model.build(csim=False, export=True, bitfile=True)


Il est difficile d'apprécier ce qui est fait ici, mais la quantité de travail nécessaire pour passer d'un modèle Keras à un fichier bitstream est insurmontable. Cela le rend nettement plus accessible pour le commun des mortels (bien que la quantisation, la réduction et l'optimisation n'aient pas été simples du tout).

Les configurations sont, une fois de plus, quelque chose qui a été emprunté au tutoriel et le facteur de réutilisation est un nombre qui peut changer en fonction de la quantité de logique que nous souhaitons réutiliser. Après cela, il suffit de quelques appels à hls4ml et, viola, vous avez un fichier bit !

Test

Maintenant que nous disposons d'un fichier bitstream, nous aimerions tester cela sur le FPGA réel. Utiliser l'environnement Pynq rend cela plus facile. Pour commencer, nous devons copier le fichier bitstream, notre script de test et les vecteurs de test sur la carte Pynq. Cela peut être fait avec une simple commande SCP ou via l'interface web. Pour simplifier les choses (comme cela a été fait dans le tutoriel hls4ml), nous mettons tout dans un seul dossier appelé « package » puis nous le copions sur le dispositif cible via SCP (voir le script BuildModel.py dans le dépôt pour plus de détails). Ce qui est important à noter ici, c'est une bibliothèque auto-générée supplémentaire appelée axi_stream_driver.py. Ce fichier contient les fonctions d'aide pour non seulement flasher le côté FPGA du Zynq mais aussi effectuer le transfert de données pour tester le modèle de réseau neuronal basé sur FPGA.

Une fois les fichiers transférés sur la carte Pynq, nous pouvons soit ouvrir un shell SSH sur la cible, soit créer un nouveau notebook pour exécuter le code qui se trouve dans on_target.py. Je préfère exécuter le script via la ligne de commande, mais cela nécessitera des privilèges de superutilisateur, donc vous devrez d'abord exécuter sudo -s après vous être connecté en SSH à votre appareil. Une fois que vous avez un shell root actif sur la carte Pynq, vous pouvez naviguer jusqu'à jupyter_notebooks/iris_model_on_fpgas et lancer le test avec la commande python3 on_target.py. Si tout s'est bien passé, vous devriez voir la sortie suivante :

Expected output of on_targer.py script

Figure 2 : Sortie attendue du script on_target.py

Validation

Alors, comment savoir si tout a fonctionné ? Maintenant, nous devons valider les sorties (c'est-à-dire les prédictions) générées par le FPGA et les comparer avec notre modèle local que nous avons créé en utilisant notre GPU (ou CPU). En termes simples, pour chaque longueur/largeur de sépale/pétale que nous fournissons en entrée, nous nous attendons à obtenir en sortie une Iris de type Setosa, Versicolor ou Virginica (tout en nombres, bien sûr). Nous espérons que le FPGA est tout aussi précis que le modèle exécuté sur notre machine locale.

Nous devrons copier la sortie du script sur notre machine. Vous pouvez exécuter à nouveau une commande SCP ou simplement télécharger via l'interface du carnet Jupyter. Le fichier de sortie sera appelé y_hw.npy. Après avoir copié le fichier, nous devrons exécuter utilities/validate_model.py. La sortie devrait ressembler à ceci :

Results from validation script

Figure 3 : Résultats du script de validation

Comme vous pouvez le voir, notre modèle optimisé (sur le PC) et le modèle synthétisé (sur le FPGA) partagent tous les deux le même niveau de précision : 96,67 %. Sur les 30 points de test, nous n'avons échoué à en prévoir qu'un seul - pas mal !

Conclusion

Dans cet article, nous avons pris le jeu de données de classification Iris et créé un modèle de réseau neuronal à partir de celui-ci. En utilisant hls4ml, nous avons construit un fichier bit et les bibliothèques nécessaires pour exécuter le même modèle sur une carte Pynq-Z2. Nous avons également exécuté un script de validation qui comparait le modèle basé sur l'ordinateur au modèle basé sur le FPGA et comment ils se comparaient tous les deux aux données originales. Bien que le modèle et le jeu de données étaient assez triviaux, ce tutoriel a posé les bases de ce que la conception de réseaux neuronaux complexes sur des FPGA représente.

Note : Tout le code pour ce projet peut être trouvé dans ce dépôt.

A propos de l'auteur

A propos de l'auteur

Ari est un ingénieur doté d'une solide expérience dans la conception, la fabrication, les tests et l'intégration de systèmes électriques, mécaniques et logiciels. Il aime collaborer avec des ingénieurs chargés de la conception, la vérification et les tests afin de favoriser les synergies.

Ressources associées

Documentation technique liée

Retournez à la Page d'Accueil
Thank you, you are now subscribed to updates.