hardware + ia
INT8 num RISC-V: como garanti que a FPGA calcula o mesmo que o Python
No meu TCC, uma rede neural roda num processador RISC-V sintetizado numa FPGA, com um acelerador que eu escrevi em VHDL. Quando o hardware devolve a classe errada, a pergunta é sempre a mesma: o erro está no modelo, na quantização, no firmware em C, no driver do acelerador ou no próprio VHDL? Este texto é sobre a estratégia que respondeu isso: ter uma referência que todos precisam reproduzir byte a byte.
Por que 8 bits
A rede é pequena: duas convoluções 3×3 e duas camadas densas. Mesmo assim, classificar uma imagem 28×28 exige 4,24 milhões de multiplicações seguidas de soma. Em ponto flutuante de 32 bits, cada multiplicador ocupa muito da FPGA e os pesos não caberiam tão fácil na memória. Em inteiros de 8 bits, o multiplicador é pequeno, os pesos caem para 422 KB e o arranjo sistólico 8×8 cabe com folga.
A perda foi pequena:
| conjunto | float32 | INT8 |
|---|---|---|
| MNIST | 98,72% | 98,20% |
| Fashion-MNIST | 91,78% | 91,85% |
Como um número real vira um byte
Cada tensor ganha uma escala e um zero-point: real = escala × (q − zero_point). Os
pesos usam escala simétrica (zero-point 0); as ativações, assimétrica, porque depois do ReLU elas nunca são negativas.
A multiplicação acontece toda em inteiros, com acumulador de 32 bits, e só no fim cada resultado é "requantizado"
para 8 bits:
// mult = in_scale * w_scale / out_scale, tudo em float32 como no C
mult = f32(f32(in_scale * w_scale) / out_scale)
v = f32(acc) * mult
v = v >= 0 ? v + 0.5 : v - 0.5 // "half away from zero"
q = trunc(v) + out_zero_point
q = clamp(q, relu ? out_zero_point : -128, 127)
O detalhe que quebra tudo: arredondamento
O PyTorch, o numpy e o C não arredondam do mesmo jeito por padrão. O round() do numpy arredonda 2,5
para 2 (para o par mais próximo); a requantização do firmware arredonda 2,5 para 3. Uma diferença de 1 numa ativação
da primeira camada se propaga e, de vez em quando, muda a classe final. Pior: a diferença é rara, então um teste com
poucas imagens passaria sem acusar nada.
A solução foi escrever a simulação em numpy imitando o C, e não o contrário: operações em float32
onde o firmware usa float, a mesma ordem de multiplicação, o mesmo arredondamento e o mesmo
argmax (primeiro máximo em caso de empate).
Golden files: a referência que todo mundo segue
O script de quantização gera, para 10 imagens de teste por conjunto, as ativações esperadas de cada estágio:
conv1, pool1, conv2, pool2, dense1 e os logits. São
arquivos binários com os bytes exatos que o hardware precisa produzir.
Isso muda a depuração. Em vez de "a placa errou a classe", a pergunta vira "em qual camada o primeiro byte diverge?".
Se conv1 bate e pool1 não, o problema está no max-pooling do firmware, não no acelerador.
Se a primeira divergência aparece no meio de uma convolução, o suspeito é o tiling 8×8 ou o VHDL.
A mesma referência, agora no navegador
Para a página do TCC, portei a inferência para JavaScript. O critério de pronto foi o mesmo do hardware: os 120 estágios (20 imagens × 6 camadas) idênticos aos golden files. Duas coisas foram necessárias:
Math.froundem cada operação de ponto flutuante, porque o JavaScript calcula em 64 bits e o firmware em 32;Int32Arraypara o acumulador, reproduzindo o estouro de 32 bits do C (que não acontece com esta rede, mas a aritmética fica idêntica).
Na primeira rodada, 120 de 120 bateram. Isso não foi sorte: foi consequência de a referência ter sido escrita imitando o C desde o começo.
O que eu levaria para outros projetos
- Defina "certo" em bytes, não em acurácia. Acurácia parecida esconde diferenças que vão aparecer depois.
- Gere referências por estágio. Um único golden de saída diz que algo está errado; golden por camada diz onde.
- A referência imita o alvo. Quando o alvo é C em 32 bits, a simulação também precisa ser.
Os modelos, os golden files e o script de quantização estão em inference_tcc.