O Padrão de Projeto Barracuda

Transcrição

O Padrão de Projeto Barracuda
O Padrão de Projeto Barracuda
Thiago Bonfim1 , Jesse Nery Filho1 , Ricardo Ramos1 , Murilo Boratto2
1
Colegiado de Engenharia de Computação (CECOMP)
Universidade Federal do Vale do São Francisco (UNIVASF)
Avenida Antônio Carlos Magalhães, 510 – 48.902-300 – Juazeiro – BA – Brasil
2
Núcleo de Arquitetura de Computadores e Sistemas Operacionais (ACSO)
Universidade do Estado da Bahia (UNEB)
Salvador – Bahia – Brasil
[email protected], [email protected],
[email protected], [email protected]
Abstract. The High Performance Computing has achieved a high level in terms
of processing capacity at reduced cost using Graphics Processing Units to perform algorithms of high computing cost. The massively parallel systems based
on GPU have to deal with hundreds of processing cores embedded in a single
chip, leading to exceptional computing gains. The CUDA (Computing Unified
Device Architecture) is a computing architeture of general purposes that uses
paralel computing in GPUs to solve high cost computing problems in less time
when compared to its execution in CPU (Central Process Unit). The Barracuda
Design Pattern supplies a layer of abstraction to the application of massively parallel solutions using CUDA which is easier to handle and similar to the other
layers found in existing API’s (Application Programming Interface) which are
regularly used by programmers that use High Performance Computing.
Resumo. A Computação de Alto Desempenho deu um grande salto em termos
de capacidade de processamento a custo reduzido com o uso de placas gráficas
(GPUs) para execução de algoritmos de grande custo computacional. Os sistemas massivamente paralelos baseados em GPU utilizam centenas de núcleos
de processamento embutidos em um único chip, permitindo ganhos computacionais excepcionais. O CUDA (Compute Unified Device Architecture) é uma arquitetura computacional de propósito geral que utiliza computação paralela em
GPUs para resolver problemas de alto custo computacional em tempo reduzido,
quando comparado à execução em CPU (Unidade Central de Processamento).
O padrão de projeto Barracuda provê uma camada de abstração para aplicação
de soluções massivamente paralelas utilizando CUDA de fácil manuseio e semelhante à encontrada em APIs (Application Programming Interface) já existentes
que são comumente utilizadas por programadores que utilizam Computação de
Alto Desempenho.
1. Intenção
A utilização de múltiplas unidades de processamento, sejam elas vários processadores em
uma máquina ou várias máquinas interligadas, é uma prática corriqueira na Computação
de Alto Desempenho há muito tempo e que recentemente vem ganhado espaço no uso
doméstico, com os processadores de vários núcleos. Também dentro da esfera da computação paralela, as GPU (Graphics Processing Unit) passaram a incorporar até centenas de
núcleos para processamento de gráficos. Tecnologias como CUDA (Compute Unified Device Architecture) e OpenCL (Open Computing Language) possibilitam a utilização deste
poder computacional para computação gráfica e também para computação de propósito
geral.
O padrão estabelecido tem como objetivo auxiliar programadores no desenvolvimento de aplicativos que utilizam CUDA visando aumentar a produtividade, reduzir as
ocorrências de erros, facilitar melhorias e manutenções, e expandir a possibilidade do
reuso de código, contribuindo para a obtenção da qualidade de software [Meyer 1997].
Neste artigo será descrito o padrão de projeto Barracuda, que visa prover uma solução
genérica para aplicações massivamente paralelas utilizando CUDA que seja de fácil manuseio e reutilizável.
2. Contexto
Um padrão de projeto segue a ideia básica de definir formas de se resolver um problema
que é comum, baseando-se na experiência adquirida pelos que desenvolveram o padrão.
Não são poucas a vezes em que um programador se encontra diante de um problema que
já resolveu antes porém não lembra como. Caso esse programador tenha documentado
a solução em um projeto orientado a objetos bem definido, este problema seria solucionado uma única vez, e bastaria reaplicar a mesma solução novamente adequando-a à
nova situação. A solução desenvolvida pode também servir para outros programadores
que venham a ter o mesmo problema. Caso não haja documentação alguma, o programador (e outros programadores na mesma situação) teria(m) que resolver todo o problema
novamente [Gamma et al. 2000]. A utilização de padrões de projeto torna o desenvolvimento da aplicação menos propenso a erros e mais produtivo. Com este intuito, este
artigo descreve um padrão de projeto para auxiliar o desenvolvimento de aplicações de
alto desempenho massivamente paralelas que utilizam CUDA.
O termo Computação de Alto Desempenho tem sido amplamente utilizado para
caracterizar o uso de recursos computacionais que são aproximadamente uma ordem de
grandeza superior aos recursos disponíveis [Severance and Dowd 1998]. A demanda por
técnicas de software e hardware vem da demanda crescente por aplicações que necessitam
de grande poder de computação . Dentre as principais razões para a aplicação de Modelos
de Programação baseados em Computação de Alto Desempenho estão: diminuir o tempo
total de execução de uma aplicação, conseguir resolver problemas mais complexos, de
grandes dimensões, e prover paralelismo, ou seja, permitir a execução de diferentes tarefas
de forma simultânea.
Outro fator que justifica a necessidade da Computação de Alto Desempenho é a
relação custo/benefício proporcionada. Para adquirir uma máquina com o dobro de capacidade computacional, normalmente é necessário o investimento de mais que o dobro do
valor da mesma. Na Computação de Alto Desempenho, a conexão de múltiplos processadores através de uma rede de interconexão permite a obtenção do aumento no desempenho
de forma proporcional ao número de processadores envolvidos, com um custo mínimo
adicional. Estas características, aliadas às novas e crescentes demandas das aplicações
Figura 1. Comparação estrutural entre uma CPU e uma GPU. A GPU dedica mais
transistores às Unidades Lógicas e Aritméticas que à memória cache ou às Unidades de Controle [NVIDIA. 2007].
emergentes, que demandam quantidades de recursos computacionais elevadas, tornam a
Computação de Alto Desempenho indispensável.
Exemplos de aplicações que demandam alto custo computacional estão presentes
em diversas áreas como na medicina que utiliza métodos de reconstrução de imagens
para compensar as falhas, na simulação do clima, avaliando a mudança climática global,
na modelagem e simulação astrofísica, na indústria petrolífera em que a simulação dos
reservatórios de petróleo e gás é uma ferramenta crítica para a gestão dos mesmos.
A partir do ano de 2005 os processadores de computadores de uso pessoal passaram a ter vários núcleos encapsulados em um único chip. Em seguida foi a vez das
GPU’s (Graphics Processing Units) que passaram a ter até centenas de núcleos, diferentemente das CPU’s (Central Processing Units) que incorporaram algumas poucas unidades
de núcleos. Essa diferença no número de núcleos se deve ao propósito de cada um. Uma
GPU possui várias unidades de processamento trabalhando em paralelo para realizar operações de ponto flutuante que visam aumentar o desempenho de aplicações gráficas. Uma
GPU dedica mais transistores ao processamento de dados diferentemente da CPU, que
necessita de mais cache e estruturas de controle de fluxo [NVIDIA. 2007], como mostra
a Figura 1.
Em uma breve comparação de capacidade de processamento, um processador Intel
Core i7 930 com quatro núcleos trabalhando cada um a @2.8GHz possui 40GFLOPS
(bilhões de operações por segundo) e custa em torno de US$ 350,00 nos Estados Unidos.
Uma GPU nVidia GeForce GTX 480 com clock de @700MHz, possui 480 núcleos, provê
1345GFLOPS, e custa US$ 450,00. Cerca de 30 vezes mais desempenho com apenas 30%
a mais de custo. A grosso modo, seria necessário um cluster com 30 computadores Core
i7 para atingir o desempenho de uma única GPU (Unidade de Processamento Gráfico).
As GPU’s são, portanto, uma poderosa ferramenta para aplicações envolvendo métodos
numéricos.
Em 2007, a nVidia publicou a primeira versão da especificação do Compute Unified Device Architecture (CUDA) [NVIDIA. 2007] em uma extensão para a linguagem
C. O CUDA é uma arquitetura de computação de propósito geral que tira proveito do
mecanismo de computação paralela das GPUs para resolver problemas computacionais
complexos. O CUDA utiliza funções que são executadas na GPU a fim de aumentar o de-
Listagem 1. Exemplo de kernel CUDA que soma duas matrizes.
__global__ void mat_add(float *A, float *B, float *C, int N) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
int j = blockIdx.y * blockDim.y + threadIdx.y;
if (i < N && j < N)
C[i][j] = A[i][j] + B[i][j];
}
sempenho do algoritmo. Uma aplicação CUDA pode ter uma ou mais funções desse tipo.
Estas funções são implementadas visando explorar as características de paralelismo das
GPU’s. Juntamente à especificação do CUDA, a nVidia lançou seu próprio compilador
C++ responsável por gerar o código objeto que será executado na CPU (código do host)
e o código objeto que será executado na GPU (código do device) e consequentemente
permitindo a utilização da orientação a objetos. O CUDA foi originalmente desenvolvido
para ser utilizado com C++, porém, existem camadas CUDA para o Java (JCUDA), C#
(CUDA.NET) e Python (PyCUDA). Para utilizar o CUDA não é necessário conhecimento
algum de computação gráfica, ou de qualquer biblioteca gráfica.
A extensão CUDA para linguagem C permite que o programador defina funções
chamadas de kernel, que, quando são chamadas, são executadas N vezes por N threads
CUDA diferentes. As funções de kernel são definidas usando o especificador de declaração __global__. As threads CUDA são organizadas em blocos, que por sua vez estão
contidos em uma grade de blocos, conforme mostra a Figura 2. Tanto as grades quanto
os blocos podem ter até três dimensões. Cada thread pode ser identificada pela variável
threadIdx que contém as compontentes threadIdx.x, threadIdx.y e threadIdx.z, formando
tripla ordenada (x, y, z) que identifica a thread dentro de um bloco que da mesma forma
pode ser indexado pela variável blockIdx contendo as componentes blockIdx.x, blockIdx.y
e blockIdx.z. Da mesma forma que threadIdx e blockIdx a variável blockDim contém
as dimensões (x, y, z) dos blocos. A Listagem 1 mostra um exemplo simples de kernel
CUDA de duas dimensões que soma duas matrizes A e B e armazena o resultado em uma
terceira matriz C.
3. Problema
Para facilitar o entendimento do problema que o padrão propõe-se a resolver, vamos considerar um exemplo clássico de demonstração do CUDA, um algoritmo que soma dois
vetores e armazena o resultado em um terceiro. A Listagem 2 mostra um programa que
utiliza a API (Application Programming Interface) JCUDA, que é uma camada Java para
o CUDA que é nativamente para C++. A Listagem 3 mostra o kernel C++ invocado por
este programa na forma de código objeto nativo da GPU. Caso o kernel não tenha sido
compilado, o programa executa o compilador nvcc da NVIDIA automaticamente.
Um kernel CUDA não pode utilizar a memória do host para processar os dados,
já que mesmo que pudesse, isso seria um gargalo devido ao barramento que conecta a
memória da CPU à GPU. Além disso, o barramento que conecta a memória da placa
gráfica à GPU possui altas taxas de transferências. Portanto faz-se necessário transferir os
dados da memória da CPU para a memória da GPU. Observando inúmeras aplicações com
CUDA, percebe-se que cada aplicação segue os mesmos passos para a execução de um
Figura 2. Disposição de uma grade de blocos de thread CUDA [NVIDIA. 2007].
kernel CUDA, no que se refere à alocação de memória no device (GPU), transferência de
dados da memória do host (CPU) para a memória do device, e execução do kernel. Estes
passos são exatamente os mesmos em todas as aplicações CUDA, causando a indesejável
repetição de código. São eles:
1.
2.
3.
4.
5.
6.
Alocação de memória no device para os dados de entrada;
Cópia dos dados de entrada para a memória do device;
Alocação de memória no device para os dados de saída;
Execução da função kernel;
Cópia dos dados de saída para a memória do host;
Liberação da memória alocada no device;
4. Estrutura
O padrão tem sua estrutura definida pela classe abstrata chamada Engine, sua implementação, e pelo kernel CUDA. Para que o padrão seja utilizado, esta classe deve ser especializada e ter seus métodos abstratos implementados. Esta contém métodos que executarão
os passos descritos na Seção 3. A Figura 3 mostra a classe Engine representada em um
Diagrama de Classes UML (Linguagem de Modelagem Unificada do inglês Unified Modeling Language) [Booch et al. 2006].
5. Comportamento
Para implementar o padrão Barracuda deve-se criar a classe abstrata Engine e sua implementação. Para a linguagem Java, uma possível implementação da classe Engine é mostrada na Listagem 4. É importante observar que implementações que utilizem o JCUDA
só podem utilizar tipos primitivos no device (ao contrário de aplicações em C++, que podem utilizar tipos mais específicos). Considerando essa restrição, para implementações
do Barracuda em Java deve ser implementada uma exceção para representar situações
envolvendo o uso de tipos não suportados e os métodos da classe Engine devem lançar
Listagem 2. Exemplo de código utilizando o JCUDA.
public static void main(String args[]) throws IOException {
JCudaDriver.setExceptionsEnabled(true);
String ptxFileName = preparePtxFile(" J C u d a V e c t o r A d d K e r n e l . cu ");
cuInit(0);
CUdevice device = new CUdevice();
cuDeviceGet(device, 0);
CUcontext context = new CUcontext();
cuCtxCreate(context, 0, device);
CUmodule module = new CUmodule();
cuModuleLoad(module, ptxFileName);
CUfunction function = new CUfunction();
cuModuleGetFunction(function, module, " add ");
int numElements = 100000;
float hostInputA[] = new float[numElements];
float hostInputB[] = new float[numElements];
for (int i = 0; i < numElements; i++) {
hostInputA[i] = (float) i;
hostInputB[i] = (float) i;
}
// Realiza a alocação de memória no device para os dados de entrada (
Passo 1)
CUdeviceptr deviceInputA = new CUdeviceptr();
cuMemAlloc(deviceInputA, numElements * Sizeof.FLOAT);
CUdeviceptr deviceInputB = new CUdeviceptr();
cuMemAlloc(deviceInputB, numElements * Sizeof.FLOAT);
// Realiza a cópia de memória dos dados de entrada para o device (
Passo 2)
cuMemcpyHtoD(deviceInputA, Pointer.to(hostInputA), numElements *
Sizeof.FLOAT);
cuMemcpyHtoD(deviceInputB, Pointer.to(hostInputB), numElements *
Sizeof.FLOAT);
// Realiza a alocação de memória no device para os dados de saída (
Passo 3)
CUdeviceptr deviceOutput = new CUdeviceptr();
cuMemAlloc(deviceOutput, numElements * Sizeof.FLOAT);
Pointer kernelParameters = Pointer.to(Pointer.to(new int[]{numElements
}), Pointer.to(deviceInputA), Pointer.to(deviceInputB), Pointer.
to(deviceOutput));
int blockSizeX = 256;
int gridSizeX = (int) Math.ceil((double) numElements / blockSizeX);
// Executa o kernel (Passo 4)
cuLaunchKernel(function, gridSizeX, 1, 1, blockSizeX, 1, 1, 0, null,
kernelParameters, null); // Executa o kernel
cuCtxSynchronize();
// Realiza a cópia de memória dos dados de saída para o host (Passo 5)
float hostOutput[] = new float[numElements];
cuMemcpyDtoH(Pointer.to(hostOutput), deviceOutput, numElements *
Sizeof.FLOAT);
// Libera a memória do device (Passo 6)
cuMemFree(deviceInputA);
cuMemFree(deviceInputB);
cuMemFree(deviceOutput);
}
private static String preparePtxFile(String cuFileName) throws
IOException { /* Corpo omitido */ };
private static byte[] toByteArray(InputStream inputStream) throws
IOException { /* Corpo omitido */ };
Listagem 3. Exemplo de kernel CUDA.
extern "C"
__global__ void add(int n, float *a, float *b, float *sum) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) {
sum[i] = a[i] + b[i];
}
}
Figura 3. Diagrama UML da classe Engine.
tal exceção. No código mostrado pela Listagem 4, pode-se observar que os métodos da
classe Engine lançam a exceção UnsupportedTypeException. A implementação da classe
Engine, denominada EngineImpl também deve considerar tal exceção.
A Figura 4 mostra um diagrama de sequência que descreve a dinâmica do padrão.
Este diagrama mostra a classe EngineImpl que estende a classe Engine implementando
seus métodos abstratos. Mais detalhes da implementação do padrão serão dados na Seção
6. O comportamento do padrão se dá seguinte forma:
1. Inicialmente é feita a instanciação de um objeto da classe EngineImpl;
2. Em seguida são passados para a classe EngineImpl os dados de entrada para as
operações a serem realizadas no kernel CUDA através do método setInputHostData;
2.1. O próprio método setInputHostData se encarrega de alocar memória no device (GPU) para os dados de entrada utilizando o método allocateInputMemoryAndCopyInputDataToDevice, que também faz a cópia desses dados do
host (CPU) para o device;
3. Através do método setOutputHostData o utilizador do padrão informa como serão
os dados de saída da computação executada no kernel passando as estruturas de
dados que armazenarão esses dados no host;
4. Finalmente, é possível invocar o método callKernel. Este método possui três opções de parâmetros de acordo com a distribuição das threads na grade de computação que pode ser de uma, duas ou três dimensões.
4.1. O método callKernel irá alocar memória no device para os dados de saída
chamando o método allocateOutputMemory;
4.2. Este mesmo método irá fazer a chamada do kernel CUDA.
4.3. Após a execução do kernel o método copyOutputDataToHost irá copiar os
dados computados para o host e liberar toda memória alocada no device pelos
métodos anteriores.
Figura 4. Diagrama de sequência que representa a dinâmica do padrão Barracuda.
Listagem 4. Classe Engine que faz parte de uma implementação do Barracuda
na linguagem Java.
public abstract class Engine {
public abstract void setInputHostData(Object... params) throws
UnsupportedTypeExeception;
protected abstract void copyInputDataToDevice() throws
UnsupportedTypeExeception;
public abstract void setOutputHostData(Object... params) throws
UnsupportedTypeExeception;
protected abstract void allocateDeviceOutputMemory() throws
UnsupportedTypeExeception;
public void callKernel(long numThreadsX, int blockSizeX, int
sharedMemBytes) throws UnsupportedTypeExeception {
callKernel(numThreadsX, blockSizeX, 1, 1, 1, 1, sharedMemBytes);
}
public void callKernel(long numThreadsX, int blockSizeX, long
numThreadsY, int blockSizeY, int sharedMemBytes) throws
UnsupportedTypeExeception {
callKernel(numThreadsX, blockSizeX, numThreadsY, blockSizeY, 1, 1,
sharedMemBytes);
}
public abstract void callKernel(long numThreadsX, int blockSizeX, long
numThreadsY, int blockSizeY, long numThreadsZ, int blockSizeZ,
int sharedMemBytes) throws UnsupportedTypeExeception;
}
6. Implementação
A Listagem 4 mostra o a classe Engine que permite a implementação do padrão Barracuda na linguagem Java. O JCUDA permite somente o uso de tipos primitivos no device,
já que código compilado não pode ter acesso às classes Java, diferentemente das implementações CUDA utilizando C++, que permite o uso de objetos no device. Com isso,
fez-se necessário criar a classe UnsupportedTypeExeception que representa uma exceção
que é lançada quando um tipo não primitivo é utilizado nos dados de entrada ou nos dados de saída. Uma implementação do Barracuda em C++ permitiria o uso de objetos
no kernel que seria integrado à aplicação, portanto, tendo acesso às classes definidas em
C++.
7. Exemplo
As Listagens 5 a 8 mostram um exemplo de aplicação que utiliza o padrão Barracuda
utilizando a linguagem de programação Java. A classe Valiant especializa a classe Engine
e contém os passos responsáveis pela aplicação do padrão. A classe CompilerUtil contém
métodos estáticos que fazem a compilação do kernel. Este é um passo opcional, já que
esta compilação pode ser feita manualmente invocando o compilador nvcc.
Listagem 5. Classe Valiant que implementa o principal elemento do Barracuda.
public class Valiant extends Engine {
private LinkedList inputHostData, outputHostData;
private LinkedList<CUdeviceptr> inputDeviceData, outputDeviceData;
private CUfunction function;
private LinkedList<NativePointerObject> parameters;
public Valiant(String kernelFileName, String kernelName) throws
IOException {
inputHostData = new LinkedList(); outputHostData = new LinkedList();
inputDeviceData = new LinkedList<CUdeviceptr>();
outputDeviceData = new LinkedList<CUdeviceptr>();
parameters = new LinkedList<NativePointerObject>();
JCudaDriver.setExceptionsEnabled(true);
String ptxFileName = CompilerUtil.preparePtxFile(kernelFileName);
cuInit(0);
CUdevice device = new CUdevice();
cuDeviceGet(device, 0);
CUcontext context = new CUcontext();
cuCtxCreate(context, 0, device);
CUmodule module = new CUmodule();
cuModuleLoad(module, ptxFileName);
function = new CUfunction();
cuModuleGetFunction(function, module, kernelName);
}
public void setExtraKernelParameters(Object... params) throws
UnsupportedTypeExeception {
for (Object e : params) {
if (e.getClass().getName().equals(double[].class.getName())) {
parameters.add(Pointer.to((double[]) e));
} else if (e.getClass().getName().equals(float[].class.getName())) {
parameters.add(Pointer.to((float[]) e));
// byte, char, int, long, e short otimidos
} else throw new UnsupportedTypeExeception(" U n s u p p o r t e d t y p e ");
}
}
@Override
public void setInputHostData(Object... params) throws
UnsupportedTypeExeception {
inputHostData.addAll(Arrays.asList(params));
copyInputDataToDevice();
}
Listagem 6. Continuação da classe Valiant que implementa o principal elemento
do Barracuda.
@Override
protected void allocateInputMemoryAndCopyInputDataToDevice() throws
UnsupportedTypeExeception {
for (Object e : inputHostData) {
CUdeviceptr deviceInput = new CUdeviceptr();
if (e.getClass().getName().equals(double[].class.getName())) {
double[] data = (double[]) e;
int numElements = data.length;
int elementSize = Sizeof.DOUBLE;
cuMemAlloc(deviceInput, numElements * elementSize);
cuMemcpyHtoD(deviceInput, Pointer.to(data), numElements *
elementSize);
} else if (e.getClass().getName().equals(float[].class.getName())) {
float[] data = (float[]) e;
int numElements = data.length;
int elementSize = Sizeof.FLOAT;
cuMemAlloc(deviceInput, numElements * elementSize);
cuMemcpyHtoD(deviceInput, Pointer.to(data), numElements *
elementSize);
// byte, char, int, long, e short otimidos
} else throw new UnsupportedTypeExeception(" U n s u p p o r t e d t y p e ");
inputDeviceData.add(deviceInput);
parameters.add(Pointer.to(deviceInput));
}
}
@Override
public void setOutputHostData(Object... params) {
outputHostData.addAll(Arrays.asList(params));
}
@Override
protected void allocateDeviceOutputMemory() throws
UnsupportedTypeExeception {
for (Object e : outputHostData) {
CUdeviceptr deviceOutput = new CUdeviceptr();
if (e.getClass().getName().equals(double[].class.getName())) {
double[] data = (double[]) e;
int numElements = data.length;
int elementSize = Sizeof.DOUBLE;
cuMemAlloc(deviceOutput, numElements * elementSize);
} else if (e.getClass().getName().equals(float[].class.getName())) {
float[] data = (float[]) e;
int numElements = data.length;
int elementSize = Sizeof.FLOAT;
cuMemAlloc(deviceOutput, numElements * elementSize);
// byte, char, int, long, e short otimidos
} else throw new UnsupportedTypeExeception(" U n s u p p o r t e d t y p e ");
outputDeviceData.add(deviceOutput);
parameters.add(Pointer.to(deviceOutput));
}
}
Listagem 7. Continuação da classe Valiant que implementa o principal elemento
do Barracuda.
@Override
public void callKernel(long numThreadsX, int blockSizeX, long
numThreadsY, int blockSizeY, long numThreadsZ, int blockSizeZ, int
sharedMemBytes) throws UnsupportedTypeExeception {
allocateDeviceOutputMemory();
NativePointerObject[] param = new NativePointerObject[parameters.size
()];
int i = 0;
for (NativePointerObject npo : parameters) param[i++] = npo;
Pointer kernelParameters = Pointer.to(param);
int gridSizeX = (int) Math.ceil((double) numThreadsX / blockSizeX);
int gridSizeY = (int) Math.ceil((double) numThreadsY / blockSizeY);
int gridSizeZ = (int) Math.ceil((double) numThreadsZ / blockSizeZ);
cuLaunchKernel(function, gridSizeX, gridSizeY, gridSizeZ, blockSizeX,
blockSizeY, blockSizeZ, 0, null, kernelParameters, null);
cuCtxSynchronize();
copyOutputDataToHost();
}
@Override
protected void copyOutputDataToHost() {
for (int i = 0; i < outputHostData.size(); i++) {
Object e = outputHostData.get(i);
if (e.getClass().getName().equals(double[].class.getName())) {
double[] data = (double[]) e;
int numElements = data.length;
int elementSize = Sizeof.DOUBLE;
CUdeviceptr deviceOutput = outputDeviceData.get(i);
double[] tmp = new double[numElements];
cuMemcpyDtoH(Pointer.to(tmp), deviceOutput,
numElements * elementSize);
outputHostData.set(i, tmp);
} else if (e.getClass().getName().equals(float[].class.getName())) {
float[] data = (float[]) e;
int numElements = data.length;
int elementSize = Sizeof.FLOAT;
CUdeviceptr deviceOutput = outputDeviceData.get(i);
float[] tmp = new float[numElements];
cuMemcpyDtoH(Pointer.to(tmp), deviceOutput,
numElements * elementSize);
outputHostData.set(i, tmp);
// byte, char, int, long, e short otimidos
} else throw new UnsupportedTypeExeception(" U n s u p p o r t e d t y p e ");
}
for (CUdeviceptr e : outputDeviceData) cuMemFree(e);
for (CUdeviceptr e : inputDeviceData) cuMemFree(e);
}
}
Listagem 8. Classe CompilerUtil que compila o código CUDA em C++.
public class CompilerUtil {
public static String preparePtxFile (String
cuFileName) throws IOException {...}
private static byte[] toByteArray(InputStream
inputStream) throws IOException {...}
}
Listagem 9. Trecho de código de aplicação que segue o padrão Barracuda.
try {
Valiant valiant = new Valiant(" J C u d a V e c t o r A d d K e r n e l . cu ", " add ");
valiant.setExtraKernelParameters(new int[]{numElements});
valiant.setInputHostData(hostInputA,hostInputB);
valiant.setOutputHostData(hostOutput);
valiant.callKernel(numElements, 16, 0);
} catch (UnsupportedTypeExeception ex) {
System.err.println(ex);
} catch (IOException ex) {
System.err.println(ex);
}
A Listagem 9 mostra um exemplo de código utilizando a classe Valiant. Este
programa implementa o mesmo algoritmo citado na Seção 3 que realiza a cópia de dois
vetores e armazena o resultado em um terceiro. Não é necessário fazer modificações ao
kernel C++ para aplicar o padrão Barracuda a uma aplicação já existente. Portanto a
Listagem 9 invoca o mesmo kernel da Listagem 3, apresentado na Seção 3.
Para que o usuário possa tirar proveito do padrão da classe Valiant são necessárias
5 linhas de código:
1. Na primeira linha cria-se um objeto da classe Valiant, informando o nome do
arquivo com extensão .cu contendo o kernel CUDA e o nome da função que implementa o kernel.
2. Em seguida são passados os parâmetros do kernel que não são dados de entrada
ou de saída, se estes exitirem, que em nosso exemplo é o tamanho dos vetores a
serem copiados.
3. São passados os dados de entrada para o kernel, neste caso o vetores a serem
somados.
4. São passadas as estruturas que formatarão dados de saída do kernel, que neste
exemplo é o vetor que conterá a soma dos dois anteriores.
5. Finalmente é feita a chamada ao kernel, passando o número de threads que serão
criadas para executar o algoritmo paralelamente.
Não há limite para a quantidade de parâmetros dos métodos chamados nos itens
2, 3 e 4. Estes itens podem ter sua ordem trocada entre si de acordo com a ordem dos
parâmetros do kernel. Percebe-se que para aplicar o CUDA em uma outra aplicação,
basta criar uma função kernel e reescrever apenas estas 5 linhas de código no código Java,
promovendo assim o reuso de código, já que as classes Engine e Valiant são totalmente
reaproveitáveis.
8. Contexto Resultante
A utilização deste padrão gera de imediato algumas consequências: a) Facilita e incentiva
o uso do CUDA; b) Incentiva boas praticas de programação em aplicações CUDA reduzindo as repetição de código; c) Pode servir de base para construção de API’s de aplicação
do CUDA na linguagem C++, JAVA ou Python.
9. Usos conhecidos
O padrão de projeto Barracuda é amplamente utilizado em aplicações CUDA. Exemplos de aplicações podem ser encontradas em livros e tutoriais. Para citar alguns tem-se
[Kirk and Hwu 2010], [jcuda.org 2012] e [NVIDIA. 2007].
Observando inúmeras aplicações com CUDA, percebe-se que cada aplicação segue os mesmos passos para a execução de um kernel CUDA. O padrão de projeto Barracuda provê uma forma simples e intuitiva para aplicação de soluções massivamente
paralelas utilizando CUDA.
Pretende-se no futuro explorar implementações do padrão de projeto Barracuda
em aplicações na linguagem C++, removendo a limitação da API JCUDA, que permite
apenas o uso de tipos de dados primitivos no kernel. Além do que, C e C++ são as
linguagens mais utilizadas na Programação de Alto Desempenho.
10. Padrões Relacionados
O padrão Barracuda funciona parecido o Adapter [Gamma et al. 2000]. A proposta do
Barracuda baseia-se em ideias vindas de APIs como o Threading Building Blocks (TBB)
e o OpenMP [Dagum and Menon 1998]. O TBB é uma biblioteca desenvolvida pela Intel
voltada para área de processamento paralelo em CPU. É uma biblioteca C++ construída
sobre templates. Seu objetivo é facilitar a utilização de threads, minimizando as preocupações recorrentes na programação para processadores paralelos. Já o OpenMP é uma
biblioteca C que utiliza o Modelo de Memória Compartilhada, que incluem os sistemas
multi-threads e computadores de alto desempenho com memória virtual compartilhada.
O OpenMP define uma API para chamadas a funções de bibliotecas que permitem a utilização de uma grande variedade de funcionalidades.
Referências
Booch, G., Jacobson, I., and Rumbaugh, J. (2006). UML - Guia do Usuário. Ediora
Campus/Elsevier, 2a edition.
Dagum, L. and Menon, R. (1998). Openmp: An industry-standard api for shared-memory
programming. IEEE Comput. Sci. Eng., 5(1):46–55.
Gamma, E., Helm, R., Johnson, R., and Vilissides, J. (2000). Padrões de Projeto. Bookman.
jcuda.org (2012). jcuda.org - java bindings for cuda. Available in: http://www.jcuda.org/.
Kirk, D. B. and Hwu, W.-m. W. (2010). Programming Massively Parallel Processors: A
Hands-on Approach. Morgan Kaufmann Publishers Inc., San Francisco, CA, USA, 1st
edition.
Meyer, B. (1997). Object-Oriented Software Construction. Prentice Hall PTR, 2a edition.
NVIDIA. (2007). CUDA Programming Guide. NVIDIA Corp., Santa Clara, CA, USA.
Severance, C. and Dowd, K. (1998). High Performance Computing. O’Reilly Media,
second edition.

Documentos relacionados

MINI-CURSO: Introdução à Programação em CUDA

MINI-CURSO: Introdução à Programação em CUDA utilizar o poder computacional de GPUs nVIDIA Extensão da linguagem C, que permite o uso de GPUs: - Suporte a uma hierarquia de grupos de threads - Definição de kernels que são executados na GPU - ...

Leia mais

Módulo 03 Identificadores, Palavras Reservadas, e - BCC Unifal-MG

Módulo 03 Identificadores, Palavras Reservadas, e - BCC Unifal-MG Tipos de Referência em Java • Além de tipos primitivos, todos os outros são tipos de referência. • Uma variável de referência contém a forma de se manusear um objeto. • Exemplo: 1 public class Dat...

Leia mais