• Acelerando

Acelerando - HLS

HLS (High-Level Synthesis Compiler) é uma ferramenta de compilação que permite criarmos um componente (hardware/ HDL) a partir de uma linguagem de programação de alto nível (no caso c++). Essa ferramenta facilita muito o desenvolvimento, e abstrai o hardware para software, porém ainda é preciso ter um conhecimento de hardware para utilizar-lha.

Intel

The Intel® HLS Compiler is a high-level synthesis (HLS) tool that takes in untimed C++ as input and generates production-quality register transfer level (RTL) code that is optimized for Intel® FPGAs. This tool accelerates verification time over RTL by raising the abstraction level for FPGA hardware design. Models developed in C++ are typically verified orders of magnitude faster than RTL.

centos

Warning

Eu só consegui fazer funcionar no centos6, minha solução foi a de executar um docker com centos, e instalar as dependências nele. Eu executo o HLS via o docker CLI.

Note

Para facilitar a vida, vamos disponibilizar uma imagem do docker já configurada. Veja com o seu professor como conseguir.

HLS

Vamos gerar um componente que aplica um offset (proc) em uma imagem, para isso, esse componente terá duas interfaces avalon de acesso a memória (AVALON-MM), na primeira interface, iremos acessar a imagem original e na outra iremos escrever a imagem processada.

O nosso hardware terá o seguinte formato:

    |-----|         AXI
    | ARM | ===========================
    |-----|            |             |
                       |             |
                   |-------|     |-------|
                   |  Min  |     |  Mout |
                   |-------|     |-------|
           AVALON-MM   |             ^
                       V             |
                   |-------|         |
                   |  Proc |---------- AVALON-MM
                   | (HLS) |
                   |-------|  
  • Min: Memória da FPGA onde iremos salvar a imagem original
  • Mout: Memória na FPGA onde iremos salvar a imagem processada
  • TH: Periférico criado pelo HLS

Para isso, iremos utilizar um sintax própria do HLS que define como em C qual tipo de interface será utilizada no componente (lembre das interfaces AVALON, memmory maped e streaming).

O HLS permite que validemos o código em duas camadas distintas: a primeira é compilando o mesmo código que será sintetizado para arquitetura x86, com isso conseguimos validar o algorítimo de forma mais rápida, a segunda é gerando o HDL do componente e simulando via modelsim, tudo isso é feito de forma transparente e automática pela ferramenta.

Note

A simulação do hardware é custosa em termos de tempo de processamento e poder computacional, ela deve ser a ultima coisa a ser feita, antes de usar o componente no hardware. Valide antes compilando para x86 e então simule.

Offset

A função a ser acelerada é a seguinte (imgOffSet):

#define OFFSET 50

typedef ihc::mm_master<unsigned char, ihc::aspace<1>,
                       ihc::awidth<32>,
                       ihc::dwidth<8> > Master1;
typedef ihc::mm_master<unsigned char, ihc::aspace<2>,
                       ihc::awidth<32>,
                       ihc::dwidth<8> > Master2;




// just for a NxN image
inline uint pxToMem(uint x, uint y, uint N){
  return(x+y*N);
}

// px + OFFSET
hls_avalon_slave_component
component void imgOffSet(Master1& imgIn,
                         Master2& imgOut,
                         hls_avalon_slave_register_argument int offSet,
                         hls_avalon_slave_register_argument int N
                         ){
    for(int y=0; y < N; y++){
      #pragma unroll 8
      for (int x=0; x < N; x++){
        int px = pxToMem(x,y,N);
        unsigned int tpx = ((unsigned int) imgIn[px])+offSet;
        if(tpx > 255)
          imgOut[px]= 255;
        else
          imgOut[px]= tpx;
        }
    }
}

Note que a função imgOffSet possui quatro argumentos: imgIn, imgOut, offSet e N. Os dois primeiros são ponteiros de memória, que é respectivamente onde o componente vai fazer a leitura da imagem e onde ele vai fazer a escrita da imagem. Já os argumentos offSet e N são: valor a ser aplicado de offSet no px e o tamanho da imagem em pxs, esse argumentos são do tipo hls_avalon_slave_register_argument, que será convertido para um banco de registradores.

Além dessas entradas e saídas, para cada interface do tipo mm_master o HLS vai criar mais um conduit, que será o offset de endereço na qual ele deve acessar o dado (para a função o endereço 0 é relativo). E mais dois conduits, um para controlar o inicio do processamento (chamada de função/ call) e outro para informar sobre o status do processamento (return).

imgIn , imgOut

Os dois primeiros argumento são do tipo ihc::mm_master< unsigned char, que significa que serão traduzidos para um barramento do tipo Avalon e que devem ser tradados como unsigned char.

  • ihc::aspace<n>: e um identificador único do barramento (1,2,3,4,...)
  • ihc::awidth<32>: Define o tamanho do barramento de endereço, nesse caso 32 bits
  • ihc::dwidth<8>: Define o tamanho do barramento de dados, nesse caso 8 (leitura de 8 bits)
  • Existem outras configurações do barramento que podem ser feitas nessa declaração: latência/ waitrequest/ burst/ (, ihc::latency<0>, ihc::maxburst<8>, ihc::waitrequest<true>)...

pxToMem()

Para facilitar o desenvolvimento, a função pxToMem(x,y,N) traduz um acesso a px por endereço na matriz para o endereço de memória do px.

printf()

Essa função será removida quando a função for compilada para hardware, ela só está disponível para simulação e testes.

offSet, n

Precisamos lembrar que estamos criando um componente que resolverá um código em C, e a maneira de conseguirmos passar argumentos para um componente é criando uma memória interna, que chamamos normalmente de banco de registrador e dando funcionalidade para eles. É dessa maneira, que os parâmetros offSet e n serão criados. Na geração do componente, uma memória será inicializada e endereços serão reservados para o offSet e n, como no exemplo a seguir:

/******************************************************************************/
/* Memory Map Summary                                                         */
/******************************************************************************/

/*
  Register  | Access  |   Register Contents      | Description
  Address   |         |      (64-bits)           | 
------------|---------|--------------------------|-----------------------------
        0x0 |       R |         {reserved[62:0], |     Read the busy status of
            |         |               busy[0:0]} |               the component
            |         |                          |  0 - the component is ready
            |         |                          |       to accept a new start
            |         |                          |    1 - the component cannot
            |         |                          |          accept a new start
------------|---------|--------------------------|-----------------------------
        0x8 |       W |         {reserved[62:0], |  Write 1 to signal start to
            |         |              start[0:0]} |               the component
------------|---------|--------------------------|-----------------------------
       0x10 |     R/W |         {reserved[62:0], |      0 - Disable interrupt,
            |         |   interrupt_enable[0:0]} |        1 - Enable interrupt
------------|---------|--------------------------|-----------------------------
       0x18 |  R/Wclr |         {reserved[61:0], | Signals component completion
            |         |               done[0:0], |       done is read-only and
            |         |   interrupt_status[0:0]} | interrupt_status is write 1
            |         |                          |                    to clear
------------|---------|--------------------------|-----------------------------
       0x20 |     R/W |         {reserved[31:0], |             Argument offSet
            |         |            offSet[31:0]} |                            
------------|---------|--------------------------|-----------------------------
       0x28 |     R/W |         {reserved[31:0], |                  Argument N
            |         |                 N[31:0]} |                            
*/

main.c

A fim de validarmos o projeto, devemos criar uma função main (que não será compilada para o hardware). Nessa função, abrimos um arquivo de imagem no formato .pgm ("in.pgm") e geramos outro arquivo de imagem, com a imagem original processada ("out.pgm"). A fim de validarmos o componente a ser gerado ( offSetImg() ) devemos alocar duas regiões de memórias contínuas (in[M_SIZE] e out[M_SIZE) que serão utilizadas como input do componente (simulando o barramento AVALON).

int main(void) {

  int N = IMG_W;
  int M_SIZE = N*N;

  // create memorys
  unsigned char in[M_SIZE];
  unsigned char out[M_SIZE];
  memset(out,0,sizeof(out));

  /* -------------------------- */
  /* reading img to mem */
  /* -------------------------- */
  printf("loading img\n");
  readImgPgm(IMG_IN, in, M_SIZE);

  /* -------------------------- */
  /* create fake memorys components*/
  /* -------------------------- */
  Master1 mm_in(in, M_SIZE);
  Master2 mm_out(out, M_SIZE);

  /* -------------------------- */
  /* process with kernel */
  /* -------------------------- */
  printf("kernel\n");
  imgOffSet(mm_in, mm_out, N);

  /* -------------------------- */
  /* img out */
  /* -------------------------- */
  printf("outputing \n");
  writeImgPgm(IMG_OUT, out)

  return 0;
}

Note

Quando formos executar a função imgOffSet no nosso hardware, não será tão simples quanto apenas uma chamada de função.

Testando (x86)

Note

Deve ser feito no centos (docker)

Para testar, vamos compilar o nosso projeto para x86 (não será um hardware) e validar se nossa lógica está correta. Se funcionar, compilamos para hardware.

Para compilar basta usarmos o compilador i++ como no exemplo a seguir:

$ i++ image.cpp -march=x86-64 -o image_x86

E testar o programa gerado:

$ ./image_x86

O resultado deve ser a belíssima foto img.ppm do seu professor, processada com um offset (out.ppm):

Tip

Para gerar uma imagem do tipo ppm você pode usar o Gimp

Note

Essa execução é como se tivéssemos compilado com gcc, só serve para validar lógica

input output
img.pgm image (binário)
image.cpp out.pgm

Acelerando na FPGA

Para acelerar na FPGA, vamos compilar novamente a aplicação, porém agora com a flag -march=CycloneV que representa a nossa FPGA

$ i++ image.cpp -march=CycloneV -o image-CycloneV

Note

Isso pode bastante tempo, o que ele vai fazer é:

  1. Gerar um HDL a partir da sua função
  2. Criar um componente para o Platform Designer
input output
img.pgm image-CycloneV.prj (pasta)

image-CycloneV.prj (pasta)

Se reparar na pasta do projeto, deve ter uma pasta nova: image-CycloneV.prj, com o seguinte conteúdo:

  • components: Pasta com o componente criado (para ser usado no Platform designer)
  • quartus: Pasta do projeto Quartus utilizado para compilar o componente, não vamos usar
  • report: Pasta com reports gerado pela ferramenta (html)
  • report: Pasta para simular o projeto

testando

Agora podemos testar nossa aplicação utilizando o hardware criado pelo HLS, para isso basta executar o novo binário criado quando compilamos para a arquitetura CycloneV.

$ ./image-CycloneV

Warning

Isso vai levar muito tempo! No monstrinho do lab de Arquitetura, levou mais de 1 hora!

Essa simulação é realizada no modelsim! A nível de hardware. O resultado será o esperado quando formos embarcar na FPGA. Com essa simulação conseguimos verificar erros de arredondamento, acesso a memória, entre outros.

Tipa

A imagem out-CycloneV.pgm que está na pasta do projeto, é o resultado dessa simulação.

report

O HLS gera um relatório da compilação do hardware, ele pode ser encontrado em: reports/report.html. Um report interessante de se analisar é o Loops analysis, que demonstra os loops do programa:

Otimizando

Podemos aplicar diversas técnicas de paralelização no software que irá impactar no hardware criado (área e performance), no manual do HLS (Intel High Level Synthesis Compiler: Reference Manual) tem a documentação que descreve cada uma das técnicas.

Vamos utilizar a do Loop Unrolling, que permite executarmos um loop paralelo:

 #pragma unroll <N>
 for (int i = 0; i < M; ++i) {
     // Some useful work
 }

Tip

N é a quantidade de loops a serem executado em //.

Vamos paralelizar a varredura da linha em 8 execuções em paralelo, para isso adicione no for que varre a linha (x):

 for(int y=0; y < N; y++){
      #pragma unroll 8
      for (int x=0; x < N; x++){

Criando um hardware

Agora com o componente criado é necessário adicionarmos ele no hardware, isso será feito via Plataform Design. Para facilitar o desenvolvimento, vamos usar o projeto de hw exemplo da Terasic: DE10_Standard_FB e modificar inserindo o componente e duas memórias, como indicado a seguir: