Faster Than a Bullet

O que pode ser mais rápido do que a bala? This is the GPU…

No GPU in Science, a NVIDIA apresenta o seguinte titulo – Speed of Light: The Role of Visual and High Performance Computing in Scientific Innovation. Logo, a “luz” é mais rápida do que a bala (Speed of Light versus Speed of Bullet). Sarcastic smile

clip_image002 clip_image004

Assim, este cara bonitão, simpático e inteligente da foto abaixo tem “brincado” com GPUs a um certo tempo – inclusive isto rendeu algumas palestras sobre CUDA ou C++ AMP.

clip_image006

Empolgado pelo assunto decidiu escrever uma gentil introdução ao C++ Accelerated Massive Parallelism (C++ AMP) presente no Visual C++ 11 – VCAMP110.DLL:

clip_image008

Introdução ao C++ AMP

clip_image010

C++ Accelerated Massive Parallelism (C++ AMP) é uma tecnologia para usar e abusar dos hardwares que possuem suporte massivo a paralelismo de dados, ou seja, aceleradoras gráficas (GPU) e processadores com suporte a vetorização (por exemplo: AVX e SSE). O que ela tem de especial ou diferente das outras (CUDA, DirectCompute ou OpenCL)? Acho que vários pontos, assim como pode ser lido num dos primeiros anúncios, e também no VCBlog, editado por Diego Dagum, sobre esta tecnologia:

· É puramente C++. Ela se apresenta como uma biblioteca estilo Standard Template Library (STL). CUDA, DirectCompute e OpenCL são orientados a C. CUDA oferece a biblioteca Thrust para preencher esta lacuna;

· Não é necessário interagir com uma linguagem de shader. No OpenCL esta computação é feita usando GLSL, já no DirectCompute é através de HLSL;

· Não é necessário ter um arquivo com extensão diferente e um compiler driver (nvcc) como no caso do CUDA;

· É uma especificação aberta, logo aparecerão compiladores em outras plataformas.

As capacidades e o modelo de programação do C++ AMP permitem a exploração e a inclusão no universo da programação heterogênea. E isto faz todo o sentido, se pegarmos o último lançamento em termos de computadores pessoais, nota-se que eles possuem processadores multicore e aceleradoras gráficas discretas ou integradas. Nas palestras que ministrei sobre programação com computação heterogênea, mostro a seguinte figura, a qual caracteriza este poder computacional:

clip_image012

Para mostrar o C++ AMP em ação e fazer uma comparação com CUDA, eu adaptei o exemplo do fractal Julia set do livro CUDA by Example. É utilizado OpenGL para exibir o fractal.

clip_image014

Aqui temos o trecho que resolve o exemplo através da CPU e da GPU respectivamente. A versão CPU usa o que nós programadores C++ estamos acostumados. Já a versão GPU, apresenta novas abstrações providas pelo C++ AMP, para realização desta computação na aceleradora gráfica.

clip_image016

clip_image018

Estas novas abstrações são a mais pura aplicação dos idiomas que já conhecemos em C++. Por exemplo, array_view, não parece um array ou vector da STL? O parallel_for_each não segue o mesmo estilo da parallel_for_each da PPL e da TBB? Portanto, é bem tranquilo para programador C++ adotar este modelo de programação.

Provavelmente a novidade fica por conta do modificador restrict. Este modificador determina o contexto, ou as restrições que serão impostas pela função a ser executada. Isto faz sentido, pois o corpo desta função contém um código que será transformado num assembly compatível com o dispositivo target, no caso a GPU. Então o restrict impõe as regras, ou indica as instruções, que serão permitidas no corpo da função com o modificador. Por default, qualquer função (ou membro do tipo função) num compilador que suporta C++ AMP é restrict(cpu), ou seja, roda código compatível com a CPU target. O restrict não está limitado a um valor, como pode ser visto no membro do tipo função Generator:

clip_image020

Neste caso, a função suporta apenas compilar o que seria a intersecção entre as funcionalidades da CPU e da GPU. Assim, ela poderá ser chamada a partir de qualquer um dos contextos de execução indicados.

A versão do C++ AMP do exemplo apresentado exibe um desempenho na GPU 40,5% superior à versão CPU – este valor varia de acordo com os dispositivos. Ambos estão usando os flags de otimização do compilador (no caso da CPU o código gerado usa SSE).

Quando um programa na CPU interage com a GPU, há um conceito de host (CPU) e device (GPU), bem como existe uma hierarquia de memória na GPU diferente daquele que estamos acostumados com a CPU:

clip_image022

Basicamente, é necessário transferir a memória do host para o device, disparar o “kernel” (no caso do C++ AMP isto é feito com o parallel_for_each) e após o processamento, o resultado da computação é transferido da memória do device para o host.

clip_image024

Este é um papel desempenhado pelo array_view. Quando usado dentro de um contexto de execução da GPU, ele é responsável por fazer a transferência dos dados de forma transparente. Ao final é recomendado usar o método synchronize para atualização dos dados na CPU.

O array_view não está limitado apenas para a transferência dos dados entre os dispositivos, ele é um recurso que possibilita a visão (reshape) de vários modos para dados de um std::array, ou de um std::vector, ou de uma área densa e contínua, como por exemplo um ponteiro de uma sequência de floats alocada dinamicamente . Por exemplo, é possível enxergar um vector<int> com 10 elementos como uma matriz de 2×5. Na terminologia da STL, isto significa container adapter.

clip_image026

O array_view é muito simples de usar, os argumentos do template indicam o tipo e a dimensão, e o seu construtor aceita a tamanho e objeto a ser adaptado.

clip_image028

O array_view trabalha em conjunto com o extent, cujo representa os limites da visão. O membro operator() do array_view permite o acesso de até 3 dimensões. Além disso, há disponível a classe index que representa as posições de cada coordenada no espaço cartesiano. Os exemplos a seguir sintetizam a usabilidade destas classes:

clip_image030

clip_image032

clip_image034

Note que o extent e o index recebem em seus construtores das dimensões mais significativas a menos significativas. Isto quer dizer, se for apenas uma dimensão, o único parâmetro representa o eixo X. Se forem duas dimensões, o primeiro parâmetro representa o eixo Y e o segundo o eixo X, e assim sucessivamente.

Como visto no método Generator acima, é possível trabalhar com tipos compostos dentro de um domínio restrict. Este é o caso da classe ComplexNumber utilizada no exemplo:

clip_image036

Como descrito anteriormente, o parallel_for_each tem a responsabilidade de disparar uma computação. Normalmente isto é feito com um lambda ou um functor, onde estes elementos devem ser especificados com restrict. O uso da função parallel_for_each é exibido no clássico exemplo de multiplicação de matrizes. Ela possui diversas sobrecargas, no exemplo o primeiro parâmetro representa as dimensões e o tamanho do container. Para este caso, o C++ AMP possui uma heurística para determinar como será o particionamento deste container entre as threads da GPU.

clip_image038

O especificador restrict, parametrizado com amp, indica restrições dentro do contexto da aceleradora, no caso, a GPU. Desta maneira, a função é restrita em termos de capacidade com relação à versão padrão pura – restrict(cpu). Portanto as seguintes ações são desabilitadas quando uma função possuir tal restrição – amp: recursão, variáveis com volatile, funções virtuais, ponteiros para funções, ponteiros para membros do tipo funções, ponteiros em estruturas, ponteiros para ponteiros, goto, labels, try, catch, throw, variáveis globais, variáveis estáticas, dynamic_cast, typeid, bloco asm e varargs. O restrict do C++ AMP não tem relação com o restrict do C99.

No C++ AMP, através da classe tiled_extent, é permitido controlar o agrupamento e a granularidade das threads da aceleradora, no caso, uma GPU. Isto permite melhor explorar o modelo de memória do dispositivo. No exemplo a seguir, uma multiplicação de matrizes, o tile_extent é utilizado via o método tile do membro extend da instância array_view (c_view).

clip_image040

Os índices referentes à técnica de tiling devem ser interpretados conforme a figura abaixo:

tile_static

Imagine que existe uma matriz 8×8, onde se deseja dividi-la em quatro blocos (4×4 threads por bloco), sendo que cada um destes blocos serão tocados por threads distintas e estes blocos possuem uma característica peculiar onde suas threads podem acessar memória compartilhada (recap: GPU Memory Model) . A divisão destes blocos é representada no exemplo por c_view.extend.tile<4,4>(). Dentro do lambda, o acesso à memória é feito através dos valores dos índices do tipo tiled_index, cujos membros global, local, tile e tile_origin representam o índice em relação a matriz total, o índice em relação a submatriz, o índice ao qual o tile o elemento está relacionado e qual a origem deste tile, respectivamente – na figura anterior foram relacionados alguns valores como referencia a cada um destes membros.

No modelo de memória das GPUs existem alguns tipos ou categorias de memória, onde as interessam neste momento são duas: a global e a compartilhada.

A partir da memória global é onde ocorre à transferência de dados do host (CPU) para o device (GPU), esta transferência (ou cópia) acontece de forma transparente quando um array_view é consumido dentro de um lambda com restrict(amp). No entanto, é possível controlar esta cópia manualmente com funções estilo STL (copy ou copy_async). A memória global pode ser acessada de qualquer lugar dentro da GPU – por isto ela tenha o nome memória global, certo? Smile with tongue out

A memória compartilhada é limitada as threads dentro de um bloco. Ela oferece desempenho superior à memória global.

clip_image044 clip_image046
Fonte: “Shared memory is a key enabler for many high-performance CUDA applications” – NVIDIA Fermi Architecture Whitepaper

Para usar memória compartilhada no C++ AMP, uma nova palavra reservada é introduzida na especificação: tile_static (a outra é restrict). No exemplo, tile_static qualifica as matrizes a_shared e b_shared para residirem na memória compartilhada do bloco (cada bloco terá a sua própria cópia). No exemplo, de multiplicação de matrizes a ideia é que cada bloco tenha seu cache, ao qual o programador gerencia manualmente (diferente dos caches da CPU), onde para cada cache do bloco são transferidos os dados necessários.

clip_image048

A figura acima exibe os dados necessários para cada elemento da matriz resultado. No bloco (0,0) de C são necessários os dados dos blocos (0,0) e (0,1) de A e os dados dos blocos (0,0) e (1,0) de B. Logo, a estratégia desta computação é trazer para memória compartilhada os dados que serão usados no bloco resultado, visto que os dados dos blocos de A e B serão acessados mais de uma vez para computar os elementos do bloco C. Note que cada elemento destes blocos representa uma thread, e para calcular um elemento na matriz C é obrigatório todos os dados estejam transferidos para memória compartilhada. Onde há threads pode haver race condition. Neste caso, é necessário a sincronização, no caso do exemplo, isto é feito com o idx.barrier.wait(). O idx representa o tiled_index referente ao bloco corrente, o wait espera todas as threads daquele bloco alcançarem o ponto da barreira antes de liberar para os próximos passos. Em relação ao exemplo, a primeira sincronização aguarda a memória compartilhada ter todos os dados preenchidos – para a multiplicação de matrizes, a_shared precisará de todas as colunas de uma determinada linha e b_shared precisará de todas as linhas de uma determinada coluna.

Se quiser saber mais, Daniel Moth escreveu um ótimo artigo introdutório sobre tiling com C++ AMP.

Quando temos computação envolvendo paralelismo massivo de dados é inerente que teremos cálculos a fazer, e para isto precisamos de um conjunto de funções matemáticas para nos ajudar. O C++ AMP oferece este conjunto de funções em dois sabores (menor precisão, somente float, maior desempenho – fast_math, e maior precisão, float ou double, menor desempenho – precise_math) – elas são acessadas através do header amp_math.h.

clip_image050

As funções que residem nestes namespaces possuem assinaturas compatíveis com as funções encontradas no header cmath. Logo, é possível transferir qualquer tipo de “continha” para a GPU processar – como é o caso das trajetórias de balística exemplificado abaixo:

clip_image052

Note que neste exemplo, ao invés do array_view foi utilizado o array. O array tem o mesmo “look’n’feel” do container array da STL, porém este reside no domínio do C++ AMP. Ele representa uma área previamente alocada na aceleradora, tendo como características fundamentais: suas transferências são explicitas, usando o copy ou copy_async; no lambda só podem ser capturados por referencia.

No C++ AMP existe a capacidade de enumerar as aceleradoras (accelerator) existentes, bem como executar uma computação numa aceleradora especifica – a execução é feita através de um accelerator_view, cujo representa uma abstração ou visão da aceleradora. O C++ AMP considera a aceleradora um hardware capacitado a executar computação de dados em paralelo, isto pode ser um dispositivo conectado a um bus PCI Express, como uma GPU, ou também uma CPU – de preferencia com registradores vetoriais.

clip_image054

clip_image056

clip_image058

O accelerator_view é obtido através de um accelerator, ele é passado para uma das sobrecargas do parallel_for_each, orientando-o onde deve ser executado o código contido no lambda.

clip_image060

Se desejar se aprofundar neste assunto o Native Concurrency MSDN Blog é uma ótima referencia! Assim como os screencasts do Daniel Moth e o futuro livro da Kate Gregory e do Ade Miller. (Se livros em português vendessem bem, eu até empolgaria em escrever sobre este assunto. Mas no Brasil é a maior decepção escrever um bom livro técnico na língua nativa. Sad smile).

Faça o download do código fonte e binário dos exemplos de C++ AMP e rock’n’rollHot smile

About Fabio Galuppo

I'm Software Engineer and Professional Trainer. I love Programming and Rock'n'Roll.
This entry was posted in C++, GPU. Bookmark the permalink.

One Response to Faster Than a Bullet

  1. Pingback: Retrospectiva 2012 | C++ Renaissance & Functional Revolution

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s