• Nenhum resultado encontrado

3 Arquitetura CUDA

3.2 Modelo de Programação

Os programas em CUDA seguem basicamente o mesmo padrão; copiam-se os dados a serem trabalhados para a memória da GPU, é feita a chamada do kernel e após o processamento na GPU o resultado é copiado de volta para a memória do computador. Apesar da forma simples de como foi citado, há grandes desafios no desenvolvimento dos programas para CUDA, desde a cópia de dados para as memórias, tamanho do kernel, adaptação dos programas sequenciais para o paralelismo, divisão do processamento em blocos, definição de padrões de acesso a memória, etc.

O CUDA já é aceito por várias linguagens de programação, tornando-o ainda mais difundido na indústria e nos meios acadêmicos. A NVIDIA disponibiliza todo o material necessário para começar o desenvolvimento, desde um guia de programação, até exemplos de códigos prontos.

3.2.1

Kernel

A função kernel, como foi dito anteriormente, é parte do código escrito em uma lin- guagem suportada (C no caso desse trabalho) e que tem como finalidade realizar algum processamento na GPU. Para exemplificar como um kernel é escrito, foi criado o Algo- ritmo 1, que realiza soma de vetores. Nele é feita a definição do kernel que vai realizar essa soma e logo após, a sua chamada. Essa chamada tem a seguinte configuração: um bloco de threads contendo N threads. Isso quer dizer que o kernel será executado por uma SM e todas as N threads serão executadas ao mesmo tempo. Um dos problemas dessa configuração, é que ela é limitada pelo número máximo de threads por SM, normalmente 1024. Uma solução para esse problema é a criação de blocos de threads. Dessa forma, conseguindo-se aumentar consideravelmente o N.

A sintaxe que define quantos blocos de threads e quantas threads por blocos vão ser executadas na chamada do kernel no caso do Algoritmo 1 é <<< 1, N >>>. Essa é a configuração mais simplista de chamada. A configuração mais completa é definida por <<< Dg, Db, N s, S >>>, onde Dg é o número de blocos a serem executado, Db o número de threads por bloco, Ns o tamanho em bytes da memória compartilhada e S a stream em que o kernel será executado. Os dois últimos parâmetros são opcionais, sendo 0 quando não informados.

Existem 3 tipos de qualificadores de função, __global__ para funções que vão ser chamadas pela CPU para serem executadas na GPU, __host__ para funções que são

Algoritmo 1 Exemplo de kernel em CUDA que somam dois vetores.

1 // Definição do kernel

2 __global__ void SomaVet(float* V1, float* V2, float* R)

3 {

4 int i = threadIdx.x;

5 R[i] = V1[i] + V2[i];

6 }

7 int main()

8 {

9 ...

10 // Chamada do kernel com 1 bloco de N threads

11 SomaVet<<<1, N>>>(V1, V2, R);

12 ...

13 }

chamadas e executadas pela CPU e __device__ para funções chamadas e executadas pela GPU, no entanto a partir da compute capability1 3.2 as funções com o qualificador

__global__ também podem ser chamadas pela GPU. A Tabela 1 resume todos os casos dos qualificadores citados.

Qualificador Chamada pela: Execução pela:

__host__ CPU CPU

__device__ GPU GPU

__global__ CPU e GPU GPU

Tabela 1: Qualificadores de função em programas CUDA.

3.2.2

Hierarquia de Threads

Ao chamar a execução de uma função na GPU um número de threads é criado. Esse número pode chegar a milhões a depender da compute capability da GPU. As threads criadas são divididas em blocos, sendo esses blocos divididos em grids. Tanto os grids de blocos quanto os blocos de threads podem ter até 3 dimensões. Na Figura 3.5 estão representados um grid e um bloco com 2 dimensões cada. Essa configuração representa um total de 72 threads.

Essa configuração na chamada do kernel é de extrema importância, pois o mau dimen- sionamento das threads e o tamanho da memória compartilhada podem afetar diretamente 1Identifica, a partir do número da versão, quais características de hardware e instruções de código são

a performance do programa. Como mencionado na Seção 3.1 desse trabalho, os blocos de threadssão divididos entre as SMs da GPU, sendo que cada CUDA core pertencente a SM fica responsável pela execução de uma thread. As threads pertencentes ao mesmo bloco têm como característica comum a possibilidade de acessar o mesmo endereço de memória chamada de memória compartilhada e poder interagir entre si através de comandos de sincronismo.

Figura 3.5: Grid de blocos e bloco de threads. Fonte: (NVIDIA CORPORATION, 2017).

Para tornar a programação mais simples, a plataforma CUDA disponibiliza algumas variáveis Built-in que auxiliam nas identificações através do ID das threads, blocos e grids, bem como as dimensões dos blocos e grid. Elas são válidas apenas para as funções executadas na GPU, e a composição delas foram uma identificação única para cada thread criada na chamada do kernel. Essas variáveis são:

• gridDim: Variável que contém as dimensões do grid. • blockIdx: Variável que contém o índice do bloco no grid. • blockDim: Variável que contém as dimensões do bloco. • threadIdx: Variável que contém o índice da thread no bloco.

3.2.3

Hierarquia de Memória

Um dos grandes devoradores de desempenho das aplicações CUDA é o uso indevido das memórias da GPU. Cada tipo de memória tem suas características, como o escopo, tempo de vida, leitura e escrita. A Tabela 2 mostra um resumo das características das principais memórias da GPU.

A principal memória da GPU é a global, ela possui alguns gigabytes, dependendo da GPU, e recebe os dados para processamento oriundos do computador. No entanto, o seu uso deve ser moderado visto que ela é a memória com o acesso mais lento em comparação com as outras memórias. Os registradores são as memórias mais rápidas para acessos não alinhados, seguidas da memória compartilhada (shared memory), textura, surface, memória local e memória global.

Quanto ao acesso, cada memória tem sua particularidade. A global é criada pelo host (aplicação na CPU), podendo ser acessada por diversos kernels e tem duração igual à da aplicação. A memória local e os registradores são criados na chamada do kernel para cada thread e possuem acesso individualizado, isso quer dizer que cada thread tem memória local e registradores únicos. A memória compartilhada também é criada na chamada do kernel, porém, para cada bloco definido na chamada, um espaço de memória compartilhada é associado e só as threads do mesmo bloco pode acessar esse espaço. Por fim, as memórias de textura e surface são criadas pelo host e tem acesso igual ao da global.

Memória Acesso Escopo Tempo de vida

Global Leitura e Escrita Grid e Blocos Aplicação Registradores Leitura e Escrita threads kernel

Local Leitura e Escrita threads kernel Compartilhada Leitura e Escrita Bloco kernel

Constante Leitura Grid e Blocos Aplicação Textura Leitura Grid e Blocos Aplicação Surface Leitura e Escrita Grid e Blocos Aplicação

Tabela 2: Tipos de memória da GPU.

3.2.4

Questões de Performance

Programar com eficiência para CUDA é um grande desafio. Todos os detalhes podem afetar o desempenho da aplicação, como o dimensionamento dos blocos, tipos de memórias usadas, padrões de acesso a memória, uso da ocupação máxima da GPU, instruções de controle de fluxo, transferência de memória entre o host e a GPU.

O dimensionamento dos blocos deve ser feito pensando no uso da capacidade máxima da GPU, o número de blocos disparados para as SMs depende diretamente da quantidade de threads por bloco, da quantidade de memória compartilhada alocada e dos registradores usados no kernel.

A utilização de forma mais eficiente das memórias da GPU geram um grande es- forço por parte do programador, devendo-se sempre buscar minimizar as transferências de memórias com baixa largura de banda2. Uma das formas de conseguir essa eficiência é

utilizar a memória compartilhada, pois ela tem uma latência bem menor que a memória global, podendo chegar a centenas de ciclos mais rápida. Outra forma é minimizar a trans- ferência de dados entre o host e a GPU, buscando sempre fazer cópias com o máximo de dados possível. Há ainda padrões de acesso coalesced (alinhados) à memória que devem ser respeitados, visto que padrões mal definidos podem gerar conflito de banco 3, como

exemplificado na Figura 3.6.

Figura 3.6: Dois tipos de acesso à memória, sendo um acesso coalesced à direita, e um acesso com bank conflicts à esquerda. Fonte: (NVIDIA CORPORATION, 2017).

Há ainda instruções de controle de fluxo que afetam significantemente a performance da aplicação, como o if e switch. Essas instruções fazem com que threads do mesmo

2Se refere a largura de banda (velocidade) da memória.

3Normalmente quando mais de uma thread tenta acessar o mesmo endereço de memória ao mesmo

warp sigam caminhos de execução diferentes. Com isso, esses caminhos são executados de forma serializada na GPU, ou seja, a execução dos comando é repetida para cada escolha diferente nesses comandos, assim, aumentando o número de instruções executadas para esse warp. Outros controles de fluxos são as instruções de sincronização de threads, que fazem com que o SM espere todas as threads do bloco finalizarem a execução até o ponto indicado.

Documentos relacionados