Construyendo Redes Neuronales en FPGAs

Ari Mahpour
|  Creado: Agosto 8, 2024  |  Actualizado: Octobre 9, 2024
Construyendo Redes Neuronales en FPGAs

En Entendiendo las Redes Neuronales examinamos una visión general de las Redes Neuronales Artificiales e intentamos dar ejemplos en el mundo real para comprenderlas a un nivel superior. En este artículo vamos a ver cómo entrenar redes neuronales y luego desplegarlas en un Arreglo de Puertas Programables en Campo (FPGA) utilizando una biblioteca de código abierto llamada hls4ml.

El Modelo

En contraste con Entendiendo las Redes Neuronales, vamos a estar observando un modelo mucho más simple por dos razones principales. Para algunos, pasar de 28 x 28 píxeles a un modelo completo fue un gran salto. Aunque expliqué la descomposición como una serie de funciones por partes en una gran matriz multidimensional, todavía era difícil de comprender. A menudo escuchamos sobre los "parámetros", pero en los modelos de procesamiento de imágenes, pueden parecer abstractos y complicados. También queremos usar un modelo más simple porque vamos a sintetizarlo en hardware. Una Unidad de Procesamiento Gráfico (GPU), incluso una GPU estándar de escritorio doméstico, puede calcular y entrenar modelos al vuelo rápidamente. Está diseñada específicamente para esa tarea (es decir, cálculos numéricos). Piensa en cuántos cálculos se realizan en juegos 3D con sus sofisticados motores de física y renderizado de imágenes. Las GPU también han sido populares con la minería de diferentes tipos de criptomonedas debido a su capacidad para procesar números. Y, finalmente, entrenar y ejecutar una red neuronal también es solo una serie de multiplicaciones de matrices y computación paralela. Aunque los FPGA contienen bloques matemáticos dentro de su estructura, las herramientas de software para compilar estas secuencias de cálculos numéricos no están optimizadas de la manera que las GPU están (al menos no todavía).

Por estas dos razones, he elegido un modelo mucho más simple, el modelo de clasificación de Iris. Aunque no es tan divertido como un modelo basado en visión (por ejemplo, base de datos MNIST), es mucho más directo. El conjunto de datos utilizado para entrenar el modelo es un conjunto de 150 observaciones de tres especies diferentes de flores de Iris: Setosa, Versicolor y Virginica. Cada observación (es decir, conjunto de datos) contiene varias mediciones únicas de esas flores. Son:

  1. Longitud del sépalo (cm)
  2. Ancho del sépalo (cm)
  3. Longitud del pétalo (cm)
  4. Ancho del pétalo (cm)

Con estas mediciones somos capaces de crear un modelo que puede decirnos (con un nivel de certeza bastante alto) qué especie de flor es. Proporcionamos esas cuatro entradas y nos dice si el Iris es de tipo Setosa, Versicolor o Virginica. Lo bueno de este conjunto de datos es que no hay mucho solapamiento dentro de estos valores. Por ejemplo, Setosa, Versicolo y Virginica tienen longitudes de sépalo bastante distintas. Lo mismo ocurre con las otras 3 características. Esto se muestra en el diagrama de dispersión del conjunto de datos:

Pairwise Scatterplots of Iris Dataset

Figura 1: Diagramas de dispersión por pares del conjunto de datos de Iris

Cuando hablamos sobre por qué usar una red neuronal en Entendiendo las Redes Neuronales, discutimos el concepto de programación basada en reglas: un conjunto de reglas por las cuales puede fluir la computadora (es decir, un algoritmo). En este caso, ciertamente podríamos codificar este problema como un algoritmo. Los diagramas de dispersión anteriores probablemente se puedan representar mediante una serie de ecuaciones matemáticas. Usamos este conjunto de datos más como un ejemplo de la complejidad que está por venir (piense en la base de datos MNIST). Con eso, pasamos al siguiente tema: Implementación.

¿Por qué un FPGA?

Mientras nos preparamos para implementar esto en un FPGA, probablemente te estés preguntando, "¿Por qué incluso construir una red neuronal en un FPGA?" Ya hemos establecido que los GPUs simplemente tienen más sentido para esta aplicación. Como sabrás o no, los GPUs son costosos y requieren mucha energía. También generan mucho calor cuando funcionan a plena capacidad. Al diseñar hardware específico para una aplicación (o un chip), optimizas el espacio, la energía, el calor y el costo. El primer paso para diseñar un Circuito Integrado Específico de Aplicación (ASIC) es primero diseñarlo en un FPGA. Si alguna vez planeamos construir redes neuronales en ASICs, este sería el primer paso.

¿Cómo lo hacemos?

Así que ahora finalmente nos hemos convencido de darle una oportunidad. Hemos visto ejemplos sobre cómo entrenar un modelo y asumimos que si simplificamos el modelo, entonces implementarlo en un FPGA debería ser bastante simple, ¿verdad? Incorrecto. ¿Recuerdan lo que hablamos sobre las muchas matrices y series de ecuaciones? Intentar meter todos esos cálculos en un FPGA pequeño puede ser extremadamente desafiante. Afortunadamente, hay un atajo realmente bueno allí fuera que las amables personas del Laboratorio de Aprendizaje Automático Rápido (y la comunidad) trabajaron en un proyecto de código abierto llamado hls4ml. Este proyecto toma modelos existentes y los convierte en un lenguaje de síntesis de alto nivel (por ejemplo, SystemC) que luego puede ser convertido en lenguajes de FPGA comúnmente utilizados como Verilog o VHDL (usando herramientas de síntesis de FPGA como Vivado de AMD Xilinx). Esa traducción, por sí misma, elimina una cantidad inmensa de pasos. Intentar construir una red neuronal completa solo en Verilog o VHDL (especialmente una compleja) sería extremadamente desafiante. Esta herramienta realmente nos ayuda a ir directamente al grano - pero no sin algunos pasos intermedios.

Optimizaciones

Estamos en el punto en el que hemos entrenado nuestro modelo y nos gustaría ejecutar hls4ml y probar esto en un FPGA. No tan rápido. Si tomas el conjunto de datos Iris, lo entrenas con técnicas básicas y luego sintetizas el código con hls4ml, probablemente superarás la síntesis. Ahora dirígete hacia el lugar y enrutamiento y nunca podrás ajustar ese modelo tal como está en un FPGA pequeño. Recuerda, también necesitamos toda la lógica alrededor del manejo de datos y la comunicación en nuestro FPGA también. En sus tutoriales, utilizan una placa Pynq-Z2 para demostrar la ejecución de un modelo en un FPGA. Esta placa fue elegida porque contiene no solo un FPGA sino también un microprocesador en el chip (Zynq 7000) que ejecuta un servidor web completo de Jupyter Notebook. Esto significa que puedes ejecutar funciones aceleradas por hardware en un FPGA pero también experimentar una interfaz simple y fácil de usar para cargar y probar tus datos. Esta interfaz que actúa como una capa de transporte entre el sistema operativo y el FPGA todavía ocupa espacio en el FPGA en sí (justo como si hubiéramos planeado usar un FPGA directo como los FPGAs Spartan o Virtex).

El desafío, como se mencionó anteriormente, es que los FPGAs tienen un tamaño limitado y no cuentan con la misma capacidad que los GPUs. Como resultado, no podremos cargar un modelo completo en un FPGA - nunca cabría. Incluso el modelo simple de Iris no pudo ser colocado inicialmente en el Pynq-Z2. Para el Tutorial 4 comienzas a familiarizarte más con técnicas de optimización (algunas de las cuales son tan esotéricas que ni siquiera he comenzado a entender cómo funcionan internamente). Una vez que aplicas estas técnicas de optimización, deberías ser capaz de cargar modelos (al menos los más simples) en un FPGA. En este repositorio podemos mirar BuildModel.py (específicamente la función de entrenamiento del modelo) y observar que no estamos solo entrenando un modelo básico, sino también optimizándolo:

def train_model(self):
    """
    Construir, entrenar y guardar un modelo de red neuronal podado y cuantificado usando QKeras.

    Esta función construye un modelo Secuencial con capas QKeras. El modelo se poda para reducir el número de parámetros
    y se cuantifica para usar menor precisión en los pesos y activaciones. El proceso de entrenamiento incluye configurar
    callbacks para actualizar los pasos de poda. Después del entrenamiento, se eliminan los envoltorios de poda y se guarda el modelo final.

    Parámetros:
    Ninguno

    Devuelve:
    Ninguno
    """
    # Inicializar un modelo Secuencial
    self.model = Sequential()

    # Agregar la primera capa QDense con cuantización y poda
    self.model.add(
        QDense(
            64,  # Número de neuronas en la capa
            input_shape=(4,),  # Forma de entrada para el conjunto de datos Iris (4 características)
            name='fc1',
            kernel_quantizer=quantized_bits(6, 0, alpha=1),  # Cuantizar los pesos a 6 bits
            bias_quantizer=quantized_bits(6, 0, alpha=1),  # Cuantizar los sesgos a 6 bits
            kernel_initializer='lecun_uniform',
            kernel_regularizer=l1(0.0001),
        )
    )

    # Agregar una capa de activación ReLU cuantizada
    self.model.add(QActivation(activation=quantized_relu(6), name='relu1'))

    # Añadir la segunda capa QDense con cuantización y poda
    self.model.add(
        QDense(
            32,  # Número de neuronas en la capa
            nombre='fc2',
            kernel_quantizer=quantized_bits(6, 0, alpha=1),  # Cuantizar los pesos a 6 bits
            bias_quantizer=quantized_bits(6, 0, alpha=1),  # Cuantizar los sesgos a 6 bits
            kernel_initializer='lecun_uniform',
            kernel_regularizer=l1(0.0001),
        )
    )

    # Añadir una capa de activación ReLU cuantizada
    self.model.add(QActivation(activation=quantized_relu(6), nombre='relu2'))

    # Añadir la tercera capa QDense con cuantización y poda
    self.model.add(
        QDense(
            32,  # Número de neuronas en la capa
            nombre='fc3',
            kernel_quantizer=quantized_bits(6, 0, alpha=1),  # Cuantizar los pesos a 6 bits
            bias_quantizer=quantized_bits(6, 0, alpha=1),  # Cuantizar los sesgos a 6 bits
            kernel_initializer='lecun_uniform',
            kernel_regularizer=l1(0.0001),
        )
    )

    # Añadir una capa de activación ReLU cuantizada
    self.model.add(QActivation(activation=quantized_relu(6), nombre='relu3'))

    # Añadir la capa de salida QDense con cuantificación y poda
    self.model.add(
        QDense(
            3,  # Número de neuronas en la capa de salida (coincide con el número de clases en el conjunto de datos Iris)
            nombre='output',
            kernel_quantizer=quantized_bits(6, 0, alpha=1),  # Cuantificar los pesos a 6 bits
            bias_quantizer=quantized_bits(6, 0, alpha=1),  # Cuantificar los sesgos a 6 bits
            kernel_initializer='lecun_uniform',
            kernel_regularizer=l1(0.0001),
        )
    )

    # Añadir una capa de activación softmax para la clasificación
    self.model.add(Activation(activation='softmax', nombre='softmax'))

    # Configurar los parámetros de poda para eliminar el 75% de los pesos, comenzando después de 2000 pasos y actualizando cada 100 pasos
    pruning_params = {"pruning_schedule": pruning_schedule.ConstantSparsity(0.75, begin_step=2000, frequency=100)}
    self.model = prune.prune_low_magnitude(self.model, **pruning_params)

    # Compilar el modelo con el optimizador Adam y pérdida de entropía cruzada categórica
    adam = Adam(lr=0.0001)
    self.model.compile(optimizer=adam, loss=['categorical_crossentropy'], metrics=['accuracy'])

    # Configurar el callback de poda para actualizar los pasos de poda durante el entrenamiento
    callbacks = [
        pruning_callbacks.UpdatePruningStep(),
    ]

    # Entrenar el modelo
    self.model.fit(
        self.X_train_val,
        self.y_train_val,
        tamaño_lote=32,  # Tamaño del lote para el entrenamiento
        épocas=30,  # Número de épocas de entrenamiento
        división_validación=0.25,  # Fracción de los datos de entrenamiento que se utilizarán como datos de validación
        barajar=True,  # Mezclar los datos de entrenamiento antes de cada época
        callbacks=callbacks,  # Incluir callback de poda
    )

    # Eliminar los envoltorios de poda del modelo
    self.model = strip_pruning(self.model)

 

Hay una enorme cantidad de información para digerir aquí, pero la esencia de lo que está sucediendo (además del entrenamiento) es que, a través de la cuantificación, regularización, poda, inicialización de pesos y optimización (Adam), estamos reduciendo el tamaño y la complejidad de los pesos para hacerlo más eficiente y rápido. Algunos de estos métodos se utilizan para mejorar la precisión y asegurar que el proceso de entrenamiento funcione bien. Una vez que hayamos pasado nuestro modelo por estas técnicas, debería ser lo suficientemente pequeño como para convertirlo en código FPGA. Recuerda, hay muchas maneras de hacer esto. Este fue el enfoque tomado por el tutorial, así que me quedé con lo que funcionó.

Síntesis

Así que ahora estamos listos para compilar, sintetizar y colocar y enrutar nuestro diseño. Al final del proceso, deberíamos terminar con un archivo de flujo de bits que, efectivamente, actúa como un archivo flash para instruir al FPGA cómo operar. Dado que el Zynq tiene tanto un procesador como un FPGA, es bastante fácil programar el FPGA a través de Python (lo cual veremos más adelante). Utilizando el repositorio referenciado, podemos definir y compilar nuestro modelo usando HLS y luego construir el archivo de flujo de bits todo en unas pocas líneas de código Python. En el siguiente código, realizamos una validación adicional para asegurarnos de que nuestro modelo generado por HLS tenga la misma precisión (o lo suficientemente cercana) como el modelo original.

def build_bitstream(self):
    """
    Construye el flujo de bits HLS para el modelo entrenado.

    Esta función convierte el modelo Keras entrenado a un modelo HLS usando hls4ml, lo compila y genera el flujo de bits para la implementación en FPGA.
    También valida el modelo HLS contra el modelo original e imprime la precisión de ambos modelos.

    """
    # Crear una configuración HLS a partir del modelo Keras, con la granularidad de los nombres de las capas
    config = hls4ml.utils.config_from_keras_model(self.model, granularity='name')

    # Establecer la precisión para la capa softmax
    config['LayerName']['softmax']['exp_table_t'] = 'ap_fixed<18,8>'
    config['LayerName']['softmax']['inv_table_t'] = 'ap_fixed<18,4>'

    # Establecer el Factor de Reutilización para las capas completamente conectadas a 512
    for layer in ['fc1', 'fc2', 'fc3', 'output']:
        config['LayerName'][layer]['ReuseFactor'] = 512

    # Convertir el modelo Keras a un modelo HLS
    hls_model = hls4ml.converters.convert_from_keras_model(
        self.model, hls_config=config, output_dir='hls4ml_prj_pynq', backend='VivadoAccelerator', board='pynq-z2'
    )

    # Compilar el modelo HLS
    hls_model.compile()

    # Predecir usando el modelo HLS
    y_hls = hls_model.predict(np.ascontiguousarray(self.X_test))
    np.save('package/y_hls.npy', y_hls)

    # Validar el modelo HLS contra el modelo 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"Exactitud del modelo original podado y cuantizado: {accuracy_original * 100:.2f}%")
    print(f"Exactitud del modelo HLS: {accuracy_hls * 100:.2f}%")

    # Construir el modelo HLS
    hls_model.build(csim=False, export=True, bitfile=True)


Es difícil apreciar lo que se ha hecho aquí, pero la cantidad de trabajo involucrado para pasar de un modelo de Keras a un archivo de bitstream es inmensurable. Esto lo hace significativamente más accesible para el ciudadano promedio (aunque la cuantificación, reducción y optimización no fueron simples en lo más mínimo).

Las configuraciones son, nuevamente, algo que se tomó prestado del tutorial y el factor de reutilización es un número que puede cambiar basado en cuánta lógica queremos reutilizar. Después de eso, son unas pocas llamadas a hls4ml y, viola, ¡tienes un archivo de bits!

Pruebas

Ahora que tenemos un archivo bitfile, nos gustaría probar esto en el FPGA real. Usar el entorno Pynq facilita esto. Para comenzar, tenemos que copiar el bitfile, nuestro script de prueba y los vectores de prueba en la placa Pynq. Esto se puede hacer con un simple comando SCP o a través de la interfaz web. Para facilitar las cosas (como se hizo en el tutorial de hls4ml), volcamos todo en una única carpeta llamada "package" y luego lo copiamos al dispositivo objetivo a través de SCP (consulte el script BuildModel.py en el repositorio para obtener más detalles). Lo importante a tener en cuenta aquí es una biblioteca auto generada adicional llamada axi_stream_driver.py. Este archivo contiene las funciones de ayuda no solo para flashear el lado FPGA del Zynq sino también para realizar la transferencia de datos para probar el modelo de red neuronal basado en FPGA.

Una vez que los archivos han sido transferidos a la placa Pynq, podemos abrir una shell SSH en el objetivo o crear un nuevo cuaderno para ejecutar el código que reside dentro de on_target.py. Prefiero ejecutar el script a través de la línea de comandos, pero requerirá privilegios de root, así que necesitarás ejecutar primero sudo -s después de haber accedido a tu dispositivo mediante SSH. Una vez que tengas una shell de root funcionando en la placa Pynq, puedes navegar a jupyter_notebooks/iris_model_on_fpgas y ejecutar la prueba con el comando python3 on_target.py. Si todo se ejecutó correctamente, deberías ver la siguiente salida:

Expected output of on_targer.py script

Figura 2: Salida esperada del script on_target.py

Validación

Entonces, ¿cómo sabemos si todo funcionó? Ahora necesitamos validar las salidas (es decir, predicciones) que el FPGA generó y compararlas con nuestro modelo local que creamos usando nuestra GPU (o CPU). En términos simples, por cada longitud/ancho de sépalo/pétalo que proporcionamos como entrada, esperamos que la salida sea un Iris de tipo Setosa, Versicolor o Virginica (todo en números, por supuesto). Esperamos que el FPGA sea tan preciso como el modelo que se ejecuta en nuestra máquina local.

Necesitaremos copiar la salida del script de vuelta a nuestra máquina. Puedes ejecutar un comando SCP de nuevo o simplemente descargar a través de la interfaz del cuaderno Jupyter. El archivo de salida se llamará y_hw.npy. Después de copiar el archivo, necesitaremos ejecutar utilities/validate_model.py. La salida debería parecerse a esto:

Results from validation script

Figura 3: Resultados del script de validación

Como puedes ver, nuestro modelo optimizado (en la PC) y el modelo sintetizado (en el FPGA) comparten el mismo nivel de precisión: 96.67%. De los 30 puntos de prueba, solo fallamos en predecir uno - ¡genial!

Conclusión

En este artículo, tomamos el conjunto de datos de clasificación de Iris y creamos un modelo de red neuronal a partir de él. Usando hls4ml, construimos un archivo bit y las bibliotecas necesarias para ejecutar el mismo modelo en una placa Pynq-Z2. También ejecutamos un script de validación que comparó el modelo basado en computadora contra el modelo basado en FPGA y cómo ambos se compararon con los datos originales. Aunque el modelo y el conjunto de datos eran bastante triviales, este tutorial sentó las bases de lo que significa diseñar redes neuronales complejas en FPGAs.

Nota: Todo el código para este proyecto se puede encontrar en este repositorio.

Sobre el autor / Sobre la autora

Sobre el autor / Sobre la autora

Ari es un ingeniero con una amplia experiencia en diseño, fabricación, pruebas e integración de sistemas eléctricos, mecánicos y de software. Le apasiona integrar a los ingenieros de diseño, de verificación y de pruebas para que trabajen juntos como una unidad cohesiva.

Recursos Relacionados

Documentación técnica relacionada

Volver a la Pàgina de Inicio
Thank you, you are now subscribed to updates.