Em Compreendendo Redes Neurais, examinamos uma visão geral das Redes Neurais Artificiais e tentamos dar exemplos no mundo real para entendê-las em um nível mais elevado. Neste artigo, vamos olhar como treinar redes neurais e, em seguida, implantá-las em um Array de Portas Programáveis em Campo (FPGA) usando uma biblioteca de código aberto chamada hls4ml.
Em contraste com Entendendo Redes Neurais, vamos olhar para um modelo muito mais simples por duas razões principais. Para alguns, passar de 28 x 28 pixels para um modelo completo foi um grande salto. Embora eu tenha explicado a decomposição como uma série de funções fragmentadas em uma grande matriz multidimensional, ainda era difícil de compreender. Frequentemente ouvimos falar sobre "parâmetros", mas em modelos de processamento de imagens, eles podem parecer abstratos e complicados. Também queremos usar um modelo mais simples porque vamos sintetizá-lo em hardware. Uma Unidade de Processamento Gráfico (GPU), mesmo uma GPU padrão de desktop doméstico, pode calcular e treinar modelos rapidamente. Ela é projetada especificamente para essa tarefa (ou seja, cálculos numéricos). Pense em quantos cálculos são necessários para jogos 3D com seus sofisticados motores de física e renderização de imagens. GPUs também têm sido populares na mineração de diferentes tipos de criptomoedas devido à sua capacidade de processar números. E, finalmente, treinar e executar uma rede neural também é apenas uma série de multiplicações de matrizes e computação paralela. Embora FPGAs contenham blocos matemáticos em seu tecido, as ferramentas de software para compilar essas sequências de cálculos não são otimizadas da maneira que as GPUs são (pelo menos por enquanto).
Por essas duas razões, escolhi um modelo muito mais simples, o modelo de classificação Iris. Embora não seja tão divertido quanto um modelo baseado em visão (por exemplo, banco de dados MNIST), é muito mais direto. O conjunto de dados usado para treinar o modelo é um conjunto de 150 observações de três espécies diferentes de flores de Iris: Setosa, Versicolor e Virginica. Cada observação (ou seja, conjunto de dados) contém várias medições únicas dessas flores. São elas:
Com essas medições, somos capazes de criar um modelo que pode nos dizer (com um nível bastante alto de certeza) qual a espécie da flor. Nós fornecemos essas quatro entradas e ele nos diz se a Iris é do tipo Setosa, Versicolor ou Virginica. O bom desse conjunto de dados é que não há muita sobreposição dentro desses valores. Por exemplo, Setosa, Versicolo e Virginica têm comprimentos de Sépala bastante distintos. O mesmo vale para as outras 3 características. Isso é mostrado no gráfico de dispersão do conjunto de dados:
Figura 1: Gráficos de Dispersão Par a Par do Conjunto de Dados Iris
Quando falamos sobre por que usar uma rede neural em Entendendo Redes Neurais, discutimos o conceito de programação baseada em regras: um conjunto de regras que o computador pode seguir (ou seja, um algoritmo). Neste caso, certamente poderíamos codificar este problema como um algoritmo. Os gráficos de dispersão acima provavelmente podem ser representados por uma série de equações matemáticas. Usamos este conjunto de dados mais como um exemplo para a complexidade que está por vir (pense no banco de dados MNIST). Com isso, passamos para o próximo tópico: Implementação.
Ao nos prepararmos para implementar isso em um FPGA, você provavelmente está se perguntando, "Por que construir uma rede neural em um FPGA?" Já estabelecemos que GPUs fazem mais sentido para essa aplicação. Como você pode ou não saber, GPUs são caras e requerem muita energia. Elas também geram muito calor quando operam em plena capacidade. Ao projetar hardware específico para uma aplicação (ou um chip), você otimiza espaço, energia, calor e custo. O primeiro passo para projetar um Circuito Integrado Específico de Aplicação (ASIC) é primeiro projetá-lo em um FPGA. Se algum dia planejarmos construir redes neurais em ASICs, esse seria o primeiro passo.
Então, agora finalmente nos convencemos a dar uma chance a isso. Vimos exemplos de como treinar um modelo e assumimos que, se simplificarmos o modelo, então implementá-lo em um FPGA deveria ser bem simples, certo? Errado. Lembra do que falamos sobre as muitas matrizes e séries de equações? Tentar encaixar todos esses cálculos em um FPGA pequeno pode ser extremamente desafiador. Felizmente, existe um atalho muito bom por aí que os gentis colegas do Laboratório de Aprendizado de Máquina Rápido (e comunidade) trabalharam em um projeto de código aberto chamado hls4ml. Este projeto pega modelos existentes e os converte em uma linguagem de síntese de alto nível (por exemplo, SystemC), que então pode ser convertida em linguagens de FPGA comumente usadas, como Verilog ou VHDL (usando ferramentas de síntese de FPGA, como Vivado da AMD Xilinx). Essa tradução, por si só, elimina uma quantidade imensa de etapas. Tentar construir uma rede neural completa apenas em Verilog ou VHDL (especialmente uma complexa) seria extremamente desafiador. Esta ferramenta realmente nos ajuda a ir direto ao ponto - mas não sem alguns passos intermediários.
Estamos no ponto em que treinamos nosso modelo e gostaríamos de executar o hls4ml e testar isso em um FPGA. Não tão rápido. Se você pegar o conjunto de dados Iris, treiná-lo com técnicas básicas e, em seguida, sintetizar o código com hls4ml, provavelmente passará pela síntese. Agora, siga em direção ao lugar e roteamento e você nunca conseguirá encaixar esse modelo como está em um FPGA pequeno. Lembre-se, também precisamos de toda a lógica em torno do manuseio de dados e comunicação em nosso FPGA também. Em seus tutoriais, eles usam uma placa Pynq-Z2 para demonstrar a execução de um modelo em um FPGA. Esta placa foi escolhida porque contém não apenas um FPGA, mas também um microprocessador no chip (Zynq 7000) que executa um servidor web completo do Jupyter Notebook. Isso significa que você pode executar funções aceleradas por hardware em um FPGA, mas também experimentar uma interface simples e fácil de usar para carregar e testar seus dados. Essa interface que atua como uma camada de transporte entre o sistema operacional e o FPGA ainda ocupa espaço no próprio FPGA (assim como se tivéssemos planejado usar um FPGA direto como os FPGAs Spartan ou Virtex).
O desafio, como mencionado acima, é que os FPGAs são limitados em tamanho e não têm a mesma capacidade que os GPUs têm. Como resultado, não seremos capazes de despejar um modelo completo em um FPGA - ele nunca caberia. Até o simples modelo Iris não pôde ser inicialmente colocado no Pynq-Z2. Ao Tutorial 4 você começa a se familiarizar mais com técnicas de otimização (algumas das quais são tão esotéricas que eu nem mesmo comecei a entender como funcionam por baixo dos panos). Uma vez que você aplique essas técnicas de otimização, você deverá ser capaz de colocar modelos (pelo menos os mais simples) em um FPGA. No repositório podemos olhar para o BuildModel.py (especificamente a função de treinamento do modelo) e observar que não estamos apenas treinando um modelo básico, mas também otimizando-o:
def train_model(self):
"""
Construir, treinar e salvar um modelo de rede neural podado e quantizado usando QKeras.
Esta função constrói um modelo Sequencial com camadas QKeras. O modelo é podado para reduzir o número de parâmetros
e quantizado para usar precisão mais baixa para os pesos e ativações. O processo de treinamento inclui a configuração
de callbacks para atualizar as etapas de poda. Após o treinamento, os invólucros de poda são removidos, e o modelo final é salvo.
Parâmetros:
Nenhum
Retorna:
Nenhum
"""
# Inicializa um modelo Sequencial
self.model = Sequential()
# Adicione a primeira camada QDense com quantização e poda
self.model.add(
QDense(
64, # Número de neurônios na camada
input_shape=(4,), # Formato de entrada para o conjunto de dados Iris (4 características)
name='fc1',
kernel_quantizer=quantized_bits(6, 0, alpha=1), # Quantizar pesos para 6 bits
bias_quantizer=quantized_bits(6, 0, alpha=1), # Quantizar vieses para 6 bits
kernel_initializer='lecun_uniform',
kernel_regularizer=l1(0.0001),
)
)
# Adicione uma camada de ativação ReLU quantizada
self.model.add(QActivation(activation=quantized_relu(6), name='relu1'))
# Adicione a segunda camada QDense com quantização e poda
self.model.add(
QDense(
32, # Número de neurônios na camada
name='fc2',
kernel_quantizer=quantized_bits(6, 0, alpha=1), # Quantiza os pesos para 6 bits
bias_quantizer=quantized_bits(6, 0, alpha=1), # Quantiza os vieses para 6 bits
kernel_initializer='lecun_uniform',
kernel_regularizer=l1(0.0001),
)
)
# Adicione uma camada de ativação ReLU quantizada
self.model.add(QActivation(activation=quantized_relu(6), name='relu2'))
# Adicione a terceira camada QDense com quantização e poda
self.model.add(
QDense(
32, # Número de neurônios na camada
name='fc3',
kernel_quantizer=quantized_bits(6, 0, alpha=1), # Quantizar pesos para 6 bits
bias_quantizer=quantized_bits(6, 0, alpha=1), # Quantizar vieses para 6 bits
kernel_initializer='lecun_uniform',
kernel_regularizer=l1(0.0001),
)
)
# Adicione uma camada de ativação ReLU quantizada
self.model.add(QActivation(activation=quantized_relu(6), name='relu3'))
# Adicione a camada de saída QDense com quantização e poda
self.model.add(
QDense(
3, # Número de neurônios na camada de saída (corresponde ao número de classes no conjunto de dados Iris)
name='output',
kernel_quantizer=quantized_bits(6, 0, alpha=1), # Quantizar os pesos para 6 bits
bias_quantizer=quantized_bits(6, 0, alpha=1), # Quantizar os vieses para 6 bits
kernel_initializer='lecun_uniform',
kernel_regularizer=l1(0.0001),
)
)
# Adicione uma camada de ativação softmax para classificação
self.model.add(Activation(activation='softmax', name='softmax'))
# Configurar os parâmetros de poda para podar 75% dos pesos, começando após 2000 passos e atualizando a cada 100 passos
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 o modelo com o otimizador Adam e perda de entropia cruzada categórica
adam = Adam(lr=0.0001)
self.model.compile(optimizer=adam, loss=['categorical_crossentropy'], metrics=['accuracy'])
# Configurar o callback de poda para atualizar os passos de poda durante o treinamento
callbacks = [
pruning_callbacks.UpdatePruningStep(),
]
# Treinar o modelo
self.model.fit(
self.X_train_val,
self.y_train_val,
tamanho_do_lote=32, # Tamanho do lote para treinamento
épocas=30, # Número de épocas de treinamento
divisão_de_validação=0.25, # Fração dos dados de treinamento a ser usada como dados de validação
embaralhar=True, # Embaralhar os dados de treinamento antes de cada época
callbacks=callbacks, # Incluir callback de poda
)
# Remover os wrappers de poda do modelo
self.model = strip_pruning(self.model)
Há uma imensa quantidade de informações para absorver aqui, mas o essencial do que está acontecendo (além do treinamento) é que, por meio da quantização, regularização, poda, inicialização de pesos e otimização (Adam), estamos reduzindo o tamanho e a complexidade dos pesos para torná-lo mais eficiente e rápido. Alguns desses métodos são usados para melhorar a precisão e garantir que o processo de treinamento funcione bem. Uma vez que tenhamos executado nosso modelo através dessas técnicas, ele deve ser pequeno o suficiente para ser convertido em código FPGA. Lembre-se, existem várias maneiras de fazer isso. Esta foi a abordagem adotada pelo tutorial, então eu me ative ao que funcionou.
Então agora estamos prontos para compilar, sintetizar e posicionar e rotear nosso design. Ao final do processo, devemos acabar com um arquivo de bitstream que, efetivamente, atua como um arquivo flash para instruir o FPGA como operar. Como o Zynq possui tanto um processador quanto um FPGA, é bastante fácil programar o FPGA via Python (o que abordaremos mais tarde). Usando o repositório referenciado, podemos definir e compilar nosso modelo usando HLS e então construir o arquivo de bitstream tudo em algumas linhas de código Python. No código a seguir, realizamos algumas validações extras para garantir que nosso modelo gerado por HLS tenha a mesma precisão (ou suficientemente próxima) que o modelo original.
def build_bitstream(self):
"""
Constrói o bitstream HLS para o modelo treinado.
Esta função converte o modelo Keras treinado para um modelo HLS usando hls4ml, compila-o e gera o bitstream para implantação no FPGA.
Também valida o modelo HLS contra o modelo original e imprime a precisão de ambos os modelos.
"""
# Criar uma configuração HLS a partir do modelo Keras, com granularidade dos nomes das camadas
config = hls4ml.utils.config_from_keras_model(self.model, granularity='name')
# Definir a precisão para a camada softmax
config['LayerName']['softmax']['exp_table_t'] = 'ap_fixed<18,8>'
config['LayerName']['softmax']['inv_table_t'] = 'ap_fixed<18,4>'
# Definir o Fator de Reuso para as camadas totalmente conectadas para 512
for layer in ['fc1', 'fc2', 'fc3', 'output']:
config['LayerName'][layer]['ReuseFactor'] = 512
# Converter o modelo Keras para um 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 o modelo HLS
hls_model.compile()
# Prever usando o modelo HLS
y_hls = hls_model.predict(np.ascontiguousarray(self.X_test))
np.save('package/y_hls.npy', y_hls)
# Validar o modelo HLS contra o 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"A precisão do modelo original podado e quantizado: {accuracy_original * 100:.2f}%")
print(f"A precisão do modelo HLS: {accuracy_hls * 100:.2f}%")
# Construir o modelo HLS
hls_model.build(csim=False, export=True, bitfile=True)
É difícil apreciar o que foi feito aqui, mas a quantidade de trabalho envolvida para passar de um modelo Keras para um arquivo bitstream é insuperável. Isso torna significativamente mais acessível para o cidadão comum (embora a quantização, redução e otimização não tenham sido simples de forma alguma).
As configurações são, novamente, algo que foi emprestado do tutorial e o fator de reutilização é um número que pode mudar com base em quanto da lógica queremos reutilizar. Depois disso, são apenas algumas chamadas para hls4ml e, viola, você tem um arquivo bit!
Agora que temos um arquivo bitfile, gostaríamos de testá-lo no FPGA real. Usar o ambiente Pynq facilita isso. Para começar, precisamos copiar o bitfile, nosso script de teste e os vetores de teste para a placa Pynq. Isso pode ser feito com um simples comando SCP ou através da interface web. Para facilitar as coisas (como feito no tutorial hls4ml), despejamos tudo em uma única pasta chamada “package” e, em seguida, copiamos para o dispositivo alvo via SCP (veja o script BuildModel.py no repositório para mais detalhes). O que é importante notar aqui é uma biblioteca extra gerada automaticamente chamada axi_stream_driver.py. Este arquivo contém as funções auxiliares não apenas para flashar o lado FPGA do Zynq, mas também realiza a transferência de dados para testar o modelo de rede neural baseado em FPGA.
Uma vez que os arquivos tenham sido transferidos para a placa Pynq, podemos abrir um shell SSH no alvo ou criar um novo caderno para executar o código que vive dentro de on_target.py. Eu prefiro executar o script via linha de comando, mas isso exigirá privilégios de root, então você precisará primeiro executar sudo -s depois de ter feito SSH no seu dispositivo. Uma vez que você tenha um shell root rodando na placa Pynq, você pode navegar até jupyter_notebooks/iris_model_on_fpgas e executar o teste com o comando python3 on_target.py. Se tudo funcionou corretamente, você deve ver a seguinte saída:
Figura 2: Saída esperada do script on_targer.py
Então, como sabemos se tudo funcionou mesmo? Agora precisamos validar as saídas (ou seja, previsões) que o FPGA gerou e compará-las com nosso modelo local que criamos usando nossa GPU (ou CPU). Em termos leigos, para cada comprimento/largura de sépala/pétala que fornecemos como entrada, estamos esperando que seja uma saída um Íris do tipo Setosa, Versicolor ou Virginica (tudo em números, claro). Estamos esperando que o FPGA seja tão preciso quanto o modelo rodando em nossa máquina local.
Precisaremos copiar a saída do script de volta para nossa máquina. Você pode executar um comando SCP novamente ou simplesmente fazer o download pela interface do caderno Jupyter. O arquivo de saída será chamado y_hw.npy. Após copiar o arquivo, precisaremos executar utilities/validate_model.py. A saída deve parecer algo assim:
Figura 3: Resultados do script de validação
Como você pode ver, nosso modelo otimizado (no PC) e modelo sintetizado (no FPGA) ambos compartilham o mesmo nível de precisão: 96,67%. De todos os 30 pontos de teste, falhamos em prever apenas um - legal!
Neste artigo, pegamos o conjunto de dados de classificação Iris e criamos um modelo de rede neural a partir dele. Usando hls4ml, construímos um arquivo bit e as bibliotecas necessárias para executar o mesmo modelo em uma placa Pynq-Z2. Também executamos um script de validação que comparou o modelo baseado em computador contra o modelo baseado em FPGA e como ambos se comparavam aos dados originais. Embora o modelo e o conjunto de dados fossem bastante triviais, este tutorial estabeleceu a base para o que é projetar redes neurais complexas em FPGAs.
Nota: Todo o código para este projeto pode ser encontrado em este repositório.