Disciplina de TICs 1 - Introdução a Programação em GPGPU Aula 10 - Streams (Parte I)
Introdução Até então foi visto como engenho de processamento paralelo massivo de dados nas GPUs pode aumentar assombrosamente o ganho em tempo quando comparado com as CPUs Contudo ainda há outro tipo de paralelismo a ser explorado Esta outra forma é semelhante as tarefas multithreads nas aplicações em CPU Ao invés de realizar a mesma função sobre vários dados de forma paralela, esta nova forma de paralelismo é realizar duas ou mais tarefas completamente diferentes ao mesmo tempo. Neste contexto, uma tarefa poderia ser qualquer número de coisas Exemplo: uma aplicação que uma thread redesenha a GUI e uma segunda thread realiza um download de uma atualização sobre a rede.
Alocações de Memória Como já visto até então, quando é desejado alocar memória, Memória na GPU: utiliza-se a função cudamalloc() Memória no Host: utiliza-se a função malloc()
Alocações de Memória Como já visto até então, quando é desejado alocar memória, Memória na GPU: utiliza-se a função cudamalloc() Memória no Host: utiliza-se a função malloc() Contudo ainda há uma outra forma de alocar memória no device, O runtime CUDA oferece um mecanismo de alocação de memória no Host, utilizando-se a função cudahostalloc()
Alocações de Memória Como já visto até então, quando é desejado alocar memória, Memória na GPU: utiliza-se a função cudamalloc() Memória no Host: utiliza-se a função malloc() Contudo ainda há uma outra forma de alocar memória no device, O runtime CUDA oferece um mecanismo de alocação de memória no Host, utilizando-se a função cudahostalloc() Contudo, porque utiliza a função cudaalloc() ao invés de malloc()?
Alocações de Memória Na realidade há uma diferença entra a memória alocada pela função Malloc() e a função cudahostalloc() A função Malocc() alocará uma memória padrão, uma memória paginável no host A função cudahostalloc() irá alocar uma memória com página travada (memória presa)
Alocações de Memória Na realidade há uma diferença entra a memória alocada pela função Malloc() e a função cudahostalloc() A função Malocc() alocará uma memória padrão, uma memória paginável no host A função cudahostalloc() irá alocar uma memória com página travada (memória presa) Um buffer com página travada tem uma importante propriedade: o S.O. nunca irá paginar para o disco uma memória presa, garantindo sua permanência na memória. Assim, um corolário é que este se transforma em uma região segura de memória para o S.O. permitir o acesso de uma aplicação ao endereço físico da memória.
DMA - Acesso Direto a Memória Uma vez que o endereço físico (real) é conhecido, a GPU pode utilizá-lo para acessar diretamente a memória (DMA) para copiar dados para ou a partir do host.
DMA - Acesso Direto a Memória Uma vez que o endereço físico (real) é conhecido, a GPU pode utilizá-lo para acessar diretamente a memória (DMA) para copiar dados para ou a partir do host. Com o Acesso Direto da Memória, a cópia DMA procede sem a intervenção da CPU. Caso a memória acessada diretamente não foce preza, significaria que a CPU estaria podendo realizar um paginamento no endereço que a GPU estaria acessando. Observe que toda vez que se deseja realizar uma cópia de memória paginável, o CUDA está utilizando uma cópia DMA. Assim, esta cópia ocorre em duas etapas: primeiro a partir de um buffer paginável um buffer da página travada é criado, e depois deste último buffer é transferido para a GPU.
Uso de Memória com Página Travada Contudo, deve-se resistir a tentação de se trocar todos os malloc()s por cudahostalloc() Utilizar uma memória preza é uma faca de dois gumes! A paginação de memória não foi desenvolvida a toa! A memória virtual é de suma importância para a grande maioria das aplicações poderem rodar em uma configuração mínima de memória bem abaixo do espaço que esta mesma aplicação necessitaria para rodar sem a memória virtual ou paginação de memória.
Uso de Memória com Página Travada Contudo, deve-se resistir a tentação de se trocar todos os malloc()s por cudahostalloc() Utilizar uma memória preza é uma faca de dois gumes! A paginação de memória não foi desenvolvida a toa! A memória virtual é de suma importância para a grande maioria das aplicações poderem rodar em uma configuração mínima de memória bem abaixo do espaço que esta mesma aplicação necessitaria para rodar sem a memória virtual ou paginação de memória. Outro ponto é que deve existir memória para garantir todos os buffers de página travada, visto que estes não sofrerão swap como disco.
Uso de Memória com Página Travada Contudo, deve-se resistir a tentação de se trocar todos os malloc()s por cudahostalloc() Utilizar uma memória preza é uma faca de dois gumes! A paginação de memória não foi desenvolvida a toa! A memória virtual é de suma importância para a grande maioria das aplicações poderem rodar em uma configuração mínima de memória bem abaixo do espaço que esta mesma aplicação necessitaria para rodar sem a memória virtual ou paginação de memória. Outro ponto é que deve existir memória para garantir todos os buffers de página travada, visto que estes não sofrerão swap como disco. Assim, o uso de memória preza implica em um alto consumo efetivo de memória, podendo influenciar no desempenho de outras aplicações.
Uso de Memória com Página Travada De forma geral, o uso de memória com paginamento travado é muito mais raro do que os outros acessos de memória já estudados. Contudo, vejamos um exemplo como uso de memória com página travada. O exemplo é muito simples e serve primariamente como um benckmark para o desempenho da função cudamemcpy() com ambas as memórias pagináveis e não pagináveis. A ideia é alocar um buffer na GPU e um na CPU de mesmo tamanho, e então executar uma certa quantidade de cópias entre estes dois buffers Será permitido ao usuário definir a direção destas cópias, se up (do host para o device) ou down (do device para o host) Para se obter uma cronometragem acurada, serão executados os eventos CUDA para o início e o fim da sequência de cópias
Exemplo Introdução float cuda_malloc_test( int size, bool up ) { cudaevent_t start, stop; int *a, *dev_a; float elapsedtime; HANDLE_ERROR( cudaeventcreate( &start ) ); HANDLE_ERROR( cudaeventcreate( &stop ) ); a = (int*)malloc( size * sizeof( *a ) ); HANDLE_NULL( a ); HANDLE_ERROR( cudamalloc( (void**)&dev_a, size * sizeof( *dev_a ) ) );
Exemplo Introdução HANDLE_ERROR( cudaeventrecord( start, 0 ) ); for (int i=0; i<100; i++) { if (up) HANDLE_ERROR( cudamemcpy( dev_a, a, size * sizeof( *dev_a ), cudamemcpyhosttodevice ) ); else HANDLE_ERROR( cudamemcpy( a, dev_a, size * sizeof( *dev_a ), cudamemcpydevicetohost ) ); } HANDLE_ERROR( cudaeventrecord( stop, 0 ) ); HANDLE_ERROR( cudaeventsynchronize( stop ) ); HANDLE_ERROR( cudaeventelapsedtime( &elapsedtime, start, stop) );
Exemplo Introdução free( a ); HANDLE_ERROR( cudafree( dev_a ) ); HANDLE_ERROR( cudaeventdestroy( start ) ); HANDLE_ERROR( cudaeventdestroy( stop ) ); } return elapsedtime;
Exemplo Introdução No exemplo dado, independente da direção da cópia, começa-se com a alocação de um buffer no host e na GPU de tamanho inteiro size. São geradas 100 cópias na direção especificada pelo argumento up, parando o tempo depois do término do processo das 100 cópias. E por fim as memórias do host e GPU são liberadas.
Exemplo Introdução No exemplo dado, independente da direção da cópia, começa-se com a alocação de um buffer no host e na GPU de tamanho inteiro size. São geradas 100 cópias na direção especificada pelo argumento up, parando o tempo depois do término do processo das 100 cópias. E por fim as memórias do host e GPU são liberadas. Mas o ponto importante da função cuda malloc test() é que esta aloca memória paginável, com o uso da função malloc()
Exemplo - Page-Locked float cuda_host_alloc_test( int size, bool up ) { cudaevent_t start, stop; int *a, *dev_a; float elapsedtime; HANDLE_ERROR( cudaeventcreate( &start ) ); HANDLE_ERROR( cudaeventcreate( &stop ) ); HANDLE_ERROR( cudahostalloc( (void**)&a, size * sizeof( *a ), cudahostallocdefault ) ); HANDLE_ERROR( cudamalloc( (void**)&dev_a, size * sizeof( *dev_a ) ) );
Exemplo - Page-Locked HANDLE_ERROR( cudaeventrecord( start, 0 ) ); for (int i=0; i<100; i++) { if (up) HANDLE_ERROR( cudamemcpy( dev_a, a, size * sizeof( *a ), cudamemcpyhosttodevice ) ); else HANDLE_ERROR( cudamemcpy( a, dev_a, size * sizeof( *a ), cudamemcpydevicetohost ) ); } HANDLE_ERROR( cudaeventrecord( stop, 0 ) ); HANDLE_ERROR( cudaeventsynchronize( stop ) ); HANDLE_ERROR( cudaeventelapsedtime( &elapsedtime, start, stop ) );
Exemplo - Page-Locked HANDLE_ERROR( cudafreehost( a ) ); HANDLE_ERROR( cudafree( dev_a ) ); HANDLE_ERROR( cudaeventdestroy( start ) ); HANDLE_ERROR( cudaeventdestroy( stop ) ); } return elapsedtime;
Exemplo - Page-Locked Neste segundo exemplo, é utilizada a alocação de uma memória preza. O buffer alocado pela função cudahostalloc() é usado da mesma forma que o buffer alocado poe malloc() Um ponto de diferença é o argumento cudahostallocdefault utilizado na função cudahostalloc() Este argumento pode receber uma coleção de flags que podem ser utilizadas para modificar o comportamento da função cudahostalloc() de forma a alocar outras variáveis da memória preza do host No próximo capítulo serão abordadas outras possibilidades para este argumento. Aqui esta se querendo uma memória não paginável padrão, assim se utiliza cudahostallocdefault Para desalocar um buffer alocado com cudahostalloc() utiliza-se cudafreehost()
Testes Introdução Utilizando-se uma GeForce GTX 285, foi observado Cópia do Hosto para o Device Memória paginável: 2.77GB/s Memória não paginável: 5.11GB/s Cópia do Device para o Hosto Memória paginável: 2.43GB/s Memória não paginável: 5.46GB/s
Streams no CUDA Introdução Um Stream CUDA Simples Já foi introduzida a ideia de eventos em CUDA, contudo ainda não foi discutido a função do segundo argumento da função cudaeventrecird() Este segundo argumento especifica o stream ao qual está sendo inserido o evento. cudaevent_t start; cudaeventcreate(&start); cudaeventrecord( start, 0 ); Um stream CUDA representa uma fila de operações de GPU a serem executadas em uma ordem específica É possível adicionar operações em um stream tais como, Lancamentos de kernels Cópia de Memória Início e finalização de eventos
Stream CUDA Introdução Um Stream CUDA Simples O verdadeiro poder do uso dos streams torna-se aparente quando se utiliza mais de um deles por vez, mas verificaremos o uso de um stream inicialmente.
Stream CUDA Introdução Um Stream CUDA Simples O verdadeiro poder do uso dos streams torna-se aparente quando se utiliza mais de um deles por vez, mas verificaremos o uso de um stream inicialmente. Imagine um kernal CUDA tomando dois buffers de entrada, a e b Será realizada alguma computação com a e b, gerando o buffer de saída c (média de três observações) #define N (1024*1024) #define FULL_DATA_SIZE (N*20) global void kernel( int *a, int *b, int *c ) { int idx = threadidx.x + blockidx.x * blockdim.x; if (idx < N) { int idx1 = (idx + 1) % 256; int idx2 = (idx + 2) % 256; float as = (a[idx] + a[idx1] + a[idx2]) / 3.0f; float bs = (b[idx] + b[idx1] + b[idx2]) / 3.0f; c[idx] = (as + bs) / 2; } }
Stream CUDA Introdução Um Stream CUDA Simples Seja o código main() int main( void ) { cudadeviceprop prop; int whichdevice; HANDLE_ERROR( cudagetdevice( &whichdevice ) ); HANDLE_ERROR( cudagetdeviceproperties( &prop, whichdevice ) ); if (!prop.deviceoverlap) { printf( "Device will not handle overlaps, so no " "speed up from streams\n" ); return 0; } Aqui, o primeiro passa é escolher um device e verificar se este suporta uma característica conhecida como device overlap A característica device overlap permite a GPU simultaneamente executar um kernel CUDA C e uma cópia de memória entre o Device e o Host
Stream CUDA Introdução Um Stream CUDA Simples Vamos começar criando e inicializando um evento timer, cudaevent_t start, stop; float elapsedtime; // start the timers HANDLE_ERROR( cudaeventcreate( &start ) ); HANDLE_ERROR( cudaeventcreate( &stop ) ); HANDLE_ERROR( cudaeventrecord( start, 0 ) );
Stream CUDA Introdução Um Stream CUDA Simples Vamos começar criando e inicializando um evento timer, cudaevent_t start, stop; float elapsedtime; // start the timers HANDLE_ERROR( cudaeventcreate( &start ) ); HANDLE_ERROR( cudaeventcreate( &stop ) ); HANDLE_ERROR( cudaeventrecord( start, 0 ) ); Depois de inicializar o timer, cria-se o stream, // initialize the stream cudastream_t stream; HANDLE_ERROR( cudastreamcreate( &stream ) );
Stream CUDA Alocação de Dados Um Stream CUDA Simples int *host_a, *host_b, *host_c; int *dev_a, *dev_b, *dev_c; // allocate the memory on the GPU HANDLE_ERROR( cudamalloc( (void**)&dev_a, N * sizeof(int) ) ); HANDLE_ERROR( cudamalloc( (void**)&dev_b, N * sizeof(int) ) ); HANDLE_ERROR( cudamalloc( (void**)&dev_c, N * sizeof(int) ) ); // allocate page-locked memory, used to stream HANDLE_ERROR( cudahostalloc( (void**)&host_a, FULL_DATA_SIZE * sizeof(int),cudahostallocdefault)); HANDLE_ERROR( cudahostalloc( (void**)&host_b, FULL_DATA_SIZE * sizeof(int),cudahostallocdefault)); HANDLE_ERROR( cudahostalloc( (void**)&host_c, FULL_DATA_SIZE * sizeof(int),cudahostallocdefault)); for (int i=0; i<full_data_size; i++) { host_a[i] = rand(); host_b[i] = rand(); }
Stream CUDA Introdução Um Stream CUDA Simples Foram alocados as entradas e saídas tanto da GPU com no Host. Observe que foi escolhido utilizar a memória não paginável na Host, utilizando-se a função cudahostalloc() para realizar as alocações Há mais do que realizar cópias mais rápido para o uso de memória não paginável. Estará sendo utilizada um novo tipo da função cudamemcpy(), e esta requer memória não paginável.
Stream CUDA Introdução Um Stream CUDA Simples Foram alocados as entradas e saídas tanto da GPU com no Host. Observe que foi escolhido utilizar a memória não paginável na Host, utilizando-se a função cudahostalloc() para realizar as alocações Há mais do que realizar cópias mais rápido para o uso de memória não paginável. Estará sendo utilizada um novo tipo da função cudamemcpy(), e esta requer memória não paginável. Depois da alocação das entradas, as alocações do host são preenchidas com números aleatórios
Stream CUDA Introdução Um Stream CUDA Simples Foram alocados as entradas e saídas tanto da GPU com no Host. Observe que foi escolhido utilizar a memória não paginável na Host, utilizando-se a função cudahostalloc() para realizar as alocações Há mais do que realizar cópias mais rápido para o uso de memória não paginável. Estará sendo utilizada um novo tipo da função cudamemcpy(), e esta requer memória não paginável. Depois da alocação das entradas, as alocações do host são preenchidas com números aleatórios Com o stream, os eventos de tempo, os buffers do host e device alocados, estamos prontos para desempenhar as computações O procedimento geral ainda é copia-se os buffers de entrada para a GPU, levanta-se um kernel e copia-se um buffer de saída de volta para o host
Stream CUDA Introdução Um Stream CUDA Simples Serão tomadas algumas pequenas alterações do procedimento genérico, Os buffers de entrada não serão copiados em sua integridade para a GPU As entradas serão divididos em pedaços, e cada pedaço será aplicado um processo em três etapas Dada uma fração do buffer de entrada, copia-se para a GPU e se executa o kernel sobre a fração do buffer, copiando-se de volta para o host a fração do buffer de saída gerado. Imagine que este procedimento é utilizado por que o buffer do host simplesmente não cabe na memória da GPU No próximo slide será apresentado o código para desempenhar a sequência de computação
Stream CUDA Introdução Um Stream CUDA Simples // now loop over full data, in bite-sized chunks for (int i=0; i<full_data_size; i+= N) { // copy the locked memory to the device, async HANDLE_ERROR( cudamemcpyasync(dev_a,host_a+i,n*sizeof(int), cudamemcpyhosttodevice, stream ) ); HANDLE_ERROR( cudamemcpyasync(dev_b,host_b+i,n*sizeof(int), cudamemcpyhosttodevice, stream ) ); kernel<<<n/256,256,0,stream>>>( dev_a, dev_b, dev_c ); } // copy the data from device to locked memory HANDLE_ERROR( cudamemcpyasync(host_c+i,dev_c,n*sizeof(int), cudamemcpydevicetohost, stream ) );
Modificações Utilizadas Um Stream CUDA Simples Ao invés de se utilizar a função cudamemcpy() foi utilizada a função cudamemcpyasync() A diferença entre as duas funções é sutil, porém significante. A função cudamemcpy() se comporta da mesma forma que memcpy() do C padrão Esta função funciona de forma síncrona, significando que quando a função retorna um valor, a cópia tem sido completada.
Modificações Utilizadas Um Stream CUDA Simples Ao invés de se utilizar a função cudamemcpy() foi utilizada a função cudamemcpyasync() A diferença entre as duas funções é sutil, porém significante. A função cudamemcpy() se comporta da mesma forma que memcpy() do C padrão Esta função funciona de forma síncrona, significando que quando a função retorna um valor, a cópia tem sido completada. Em oposição a uma função síncrona existe uma função assíncrona. No caso, a função cudamemcpyasync()
Modificações Utilizadas Um Stream CUDA Simples A chamada da função cudamemcpyasync() simplesmente recoloca o pedido de cópia de memória em um stream especificado pelo argumento stream Quando a chamada da função cudamemcpyasync() retorna, não há nenhuma garantia que a cópia tenha sequer inicializado, muito menos tenha sido finalizada. A única garantia é que a cópia será finalizada antes que a próxima operação localizada no mesmo stream É requerido que qualquer ponteiro da memória Host passado para a função cudamemcpyasync() tenha sido alocado por cudahostalloc() Ou seja, todo agendamento de cópia assíncrona só é possível de ou para uma região de memória não paginável.
Modificações Utilizadas Um Stream CUDA Simples Observe também que o lançamento do kernel também foi modificado Agora há um maior número de argumentos sendo passados para o kernel. O lançamento deste kernel também é assíncrono. Tecnicamente, é possível concluir uma iteração do laço for sem ter começado qualquer alocação de memória e/ou execução do kernel A única garantia é que a primeira cópia lançada no stream será executada antes da segunda cópia. A segunda cópia será realizada antes do ínicio do kernel, e o kernel irá ser finalizado antes da terceira cópia
Modificações Utilizadas Um Stream CUDA Simples Observe também que o lançamento do kernel também foi modificado Agora há um maior número de argumentos sendo passados para o kernel. O lançamento deste kernel também é assíncrono. Tecnicamente, é possível concluir uma iteração do laço for sem ter começado qualquer alocação de memória e/ou execução do kernel A única garantia é que a primeira cópia lançada no stream será executada antes da segunda cópia. A segunda cópia será realizada antes do ínicio do kernel, e o kernel irá ser finalizado antes da terceira cópia O stream funciona como uma lista ordenada de trabalhos a serem realizados na GPU.
Modificações Utilizadas Um Stream CUDA Simples Observe que depois do lação for() ter sido concluído, ainda há a possibilidade da GPU ainda está processando.
Modificações Utilizadas Um Stream CUDA Simples Observe que depois do lação for() ter sido concluído, ainda há a possibilidade da GPU ainda está processando. Caso haja o desejo de que a CPU espera a computação da GPU, há a necessidade de se realizar uma sincronização. Este sincronização pode ser realizada por meio da função cudastreamsynchronaze() e o stream que se deseja esperar, // copy result chunk from locked to full buffer HANDLE_ERROR( cudastreamsynchronize( stream ) );
Modificações Utilizadas Um Stream CUDA Simples Observe que depois do lação for() ter sido concluído, ainda há a possibilidade da GPU ainda está processando. Caso haja o desejo de que a CPU espera a computação da GPU, há a necessidade de se realizar uma sincronização. Este sincronização pode ser realizada por meio da função cudastreamsynchronaze() e o stream que se deseja esperar, // copy result chunk from locked to full buffer HANDLE_ERROR( cudastreamsynchronize( stream ) ); Uma vez as computações e cópias tenham sido completadas depois da sincronização da stream como o host, falta para o timer, coletar os dados e liberar a memória.
o Restante do Código Um Stream CUDA Simples HANDLE_ERROR( cudaeventrecord( stop, 0 ) ); HANDLE_ERROR( cudaeventsynchronize( stop ) ); HANDLE_ERROR( cudaeventelapsedtime( &elapsedtime, start, stop ) ); printf( "Time taken:%3.1f ms\n", elapsedtime ); // cleanup the streams and memory HANDLE_ERROR( cudafreehost( host_a ) ); HANDLE_ERROR( cudafreehost( host_b ) ); HANDLE_ERROR( cudafreehost( host_c ) ); HANDLE_ERROR( cudafree( dev_a ) ); HANDLE_ERROR( cudafree( dev_b ) ); HANDLE_ERROR( cudafree( dev_c ) );
Restante do Código Um Stream CUDA Simples E por fim, antes de sair da aplicação, é necessário destruir o stream, HANDLE_ERROR( cudastreamdestroy( stream ) ); } return 0;
Exercício Introdução Um Stream CUDA Simples Implemente os códigos apresentados, realizando as comparações de desempenho.
Bibliografia Jason Sandres and Edward Kandrot. CUDA by exemple. An Introduction to General-Purpose GPU Programming. Addison-Wesley, 2011. Capítulo 10