Texto

Transcrição

Texto
Tendências e Técnicas em Realidade Virtual e Aumentada
Capítulo
3
Programação introdutória em OpenCL
e aplicações em realidade virtual e aumentada
César Leonardo Blum Silveira
V3D Studios
[email protected]
Luiz Gonzaga da Silveira Jr
V3D Studios
[email protected]
Abstract
OpenCL is an open standard for high-performance computing in heterogeneous computational environments composed of multicore CPUs and many-core GPUs. OpenCL
makes it possible to write multi-platform code for such devices, without the need to use
vendor-specific languages and tools, or to map problems to graphics APIs. Focused on
parallelism, programming in OpenCL is based on writing functions that are executed in
multiple simultaneous instances. This chapter aims to introduce key concepts and explore
the architecture defined by the OpenCL standard, presenting practical examples in the
fields of Virtual and Augmented Reality.
Resumo
OpenCL é um padrão aberto para programação de alto desempenho em ambientes computacionais heterogêneos equipados com CPUs multicore e GPUs many-core. OpenCL
torna possível a escrita de código multi-plataforma para tais dispositivos, sem a necessidade do uso de linguagens e ferramentas específicas de fabricante ou do mapeamento
65
Tendências e Técnicas em Realidade Virtual e Aumentada
de problemas para APIs gráficas. Com foco no paralelismo, a programação em OpenCL
baseia-se na escrita de funções que são executadas em múltiplas instâncias simultâneas.
Este capítulo visa introduzir conceitos-chave e explorar a arquitetura definida no padrão
OpenCL, apresentando exemplos práticos nas áreas de Realidade Virtual e Aumentada.
3.1. Introdução
Os computadores pessoais, servidores e dispositivos móveis atuais podem ser equipados
com placas gráfica capazes rodar aplicações gráficas 3-D de alta qualidade visual em
tempo-real. Além disso, a cada aproximadamente um ano e meio, são lançadas novas
placas gráficas ainda mais sofisticadas, capazes de processar grandes volumes de dados.
A preços acessíveis e oferecendo alto desempenho1 , graças, sobretudo, à indústria de jogos digitais, estas placas atualmente podem ser utilizadas para a solução de problemas em
diversas áreas do conhecimento que demandam alto poder de processamento, como processamento de imagens, visão computacional, simulações de processos físicos e químicos,
problemas algébricos que manipulam grandes volumes de informação, entre outras.
O foco deste capítulo é mostrar como tirar proveito do poder computacional provido pelas centenas de processadores das placas gráficas modernas. Aplicações que fazem
uso da capacidade de processamento paralelo dos dispositivos gráficos geralmente o fazem através do mapeamento de problemas para as APIs gráficas OpenGL ou Direct3D,
ou através de programas que executam diretamente nos processadores das placas gráficas,
denominados shaders. Além disso, fabricantes de placas gráficas fornecem linguagens,
APIs e ferramentas de desenvolvimento que permitem o acesso direto à memória e aos
processadores destas placas; porém, o uso destes recursos de programação torna o código desenvolvido específico para uma determinada arquitetura. Neste contexto, surge o
padrão OpenCL (Open Computing Language), criado pela Apple [Apple Inc. 2009] e padronizado pelo Khronos Group [Khronos Group 2010]. OpenCL é a primeira plataforma
aberta e livre de royalties para computação de alto desempenho em sistemas heterogêneos compostos por CPUs, GPUs, arquiteturas tipo Cell e outros processadores paralelos.
OpenCL oferece aos desenvolvedores um ambiente uniforme de programação paralela
para escrever códigos portáveis para estes sistemas heterogêneos, a fim de tirarem proveito do poder de processamento dos dispositivos gráficos e processadores e usá-los para
diversas aplicações em múltiplas plataformas computacionais.
A especificação OpenCL 1.0 é realizada em três partes: uma linguagem, uma
camada de plataforma e um runtime. A especificação da linguagem descreve a sintaxe e a
API para escrita de código em OpenCL, que executa nos aceleradores suportados, CPUs
multicore e GPUs many-core. A linguagem é baseada na especificação C99 linguagem
C [ISO 2005]. A camada de plataforma fornece ao desenvolvedor acesso às rotinas que
buscam o número e os tipos de dispositivos no sistema. Assim, o desenvolvedor pode
escolher e inicializar os dispositivos adequados para o processamento. O runtime permite
1 Os
modelos de topo tem desempenho na ordem de teraflops, fornecidos por centenas de stream proces-
sors.
66
Tendências e Técnicas em Realidade Virtual e Aumentada
ao desenvolvedor enfileirar comandos para execução nos dispositivos, sendo também é
responsável por gerenciar os recursos de memória e computação disponíveis.
Na prática, aplicações OpenCL são estruturadas em uma série de camadas, como
mostra a Figura 3.1. Kernels correspondem às entidades que são escritas pelo desenvolvedor na linguagem OpenCL C. A aplicação faz uso da API C do padrão para comunicar-se
com a camada de plataforma (Platform Layer), enviando comandos ao runtime, que gerencia diversos aspectos da execução.
Aplicação
Kernels
API
OpenCL C
OpenCL
Platform Layer
OpenCL runtime
Driver
Hardware
(GPU,CPU,...)
Figura 3.1. Arquitetura da OpenCL
3.1.1. Uma breve ilustração
A fim de evitar que o leitor tenha que passar por muitos conceitos teóricos antes de ter
uma ideia de como ocorre a programação em OpenCL na prática, será apresentado aqui
um exemplo simples de código. O trecho de código a seguir corresponde a um kernel que
calcula a diferença entre os elementos de dois arrays e os armazena em um terceiro:
__kernel void ArrayDiff(
__global const int* a,
__global const int* b,
__global int* c)
{
int id = get_global_id(0);
c[id] = a[id] - b[id];
}
Por ora, os detalhes a respeito de palavras-chave da linguagem, como __kernel
e __global serão deixados de lado, pois serão introduzidos mais adiante. O que é
67
Tendências e Técnicas em Realidade Virtual e Aumentada
importante observar no código é que não há um laço para iterar sobre os arrays. Em
OpenCL, o código escrito corresponde a uma unidade do resultado desejado. O runtime
cria várias instâncias do mesmo código no momento da execução, e cada uma delas recebe
um ID que pode ser usado para determinar a porção dos dados que devem ser acessada
por uma instância em particular. Isto viabiliza o processamento paralelo dos dados.
A título de comparação, o código abaixo realizaria a mesma tarefa, porém de
forma sequencial (considere que ARRAY_LENGTH é uma constante que contém o número
de elementos nos arrays):
void ArrayDiff(
const int* a,
const int* b,
int* c)
{
for (int i = 0; i < ARRAY_LENGTH; ++i)
{
c[i] = a[i] - b[i];
}
}
O uso de OpenCL é, portanto, recomendado para aplicações que realizam operações computacionalmente custosas, porém paralelizáveis por permitirem o cálculo independente de diferentes porções do resultado.
3.1.2. Organização do texto
O restante do texto está organizado como segue. A Seção 3.2 apresenta um histórico da
programação em GPUs. A Seção 3.3 introduz os conceitos da arquitetura OpenCL. As
Seções 3.4 e 3.5 discutem alguns aspectos da linguagem OpenCL C e da sua API, respectivamente. A Seção 3.6 apresenta passo-a-passo a construção de uma aplicação OpenCL
completa. As Seções 3.7 e 3.8 ilustram possibilidades de uso de OpenCL em Realidade Virtual e Aumentada através de exemplos. A Seção 3.9 discute aspectos relativos
à otimização de aplicações com OpenCL. Finalmente, a Seção 3.10 apresenta algumas
considerações finais.
3.2. Programação em GPU
Antes de mais nada, é importante recordar a evolução das tecnologias destes dispositivos gráficos e das APIs de suporte à programação. Sabemos que sobretudo a indústria
dos jogos tem impulsionado de forma decisiva esta evolução, através do desenvolvimento
de jogos visual ricos e que exigem cada vez mais enorme poder computacional para serem executados em tempo-real. Para entender este caminho retrocedendo no tempo e
observamos que as placas gráficas começaram como simples controladoras de vídeo e
gradativamente incorporaram funções de aceleração 3-D, bem como modificaram o modelo de programação. Neste entremeio, o termo GPU (Graphics Processing Unit) foi
68
Tendências e Técnicas em Realidade Virtual e Aumentada
cunhado para denotar que o dispositivo gráfico tinha se tornado um processador. Esta
evolução alavancou inúmeras possibilidades para a computação gráfica, em especial para
a renderização em tempo-real, pois se dispunha de um dispositivo dedicado para para
processamento gráfico 3-D.
A evolução dos dispositivos gráficos foi acompanhada por softwares, abstraem os
detalhes de hardware de cada fabricante e oferecem APIs para programação. Os grandes
representantes deste seguimento são o padrão aberto OpenGL e a tecnologia Direct3D
(parte da DirectX) da Microsoft. Ambas as tecnologias implementam o processo de renderização usando como base um pipeline gráfico, que descreve todo o fluxo de dados do
processo de renderização. As APIs da OpenGL e do Direct3D fornecem um conjuntos de
funções para a programação do pipeline gráfico, e ainda provêm mecanismos transparentes de acionamento do dispositivo gráfico e comunicação com o sistema computacional
principal, Figura 3.2.
Processamento
de primitivas
API
Transformações &
Iluminação (T&L)
Montagem de
primitivas
Rasterizador
VBO
Texturas
...
Stencil
...
Fog
Color Buffer
Blending
FRAMEBUFFER
Figura 3.2. Pipeline gráfico da OpenGL
Durante o processo evolutivo, as placas gráficas 3-D tornaram-se passíveis de serem programadas diretamente através do pipeline gráfico. Os programas, denominados
shaders, são escritos através de linguagens específicas (GLSL, HLSL, Cg e outras), compilados para o hardware gráfico. Desta forma, as GPUs oferecem mecanismos para permutar funções fixas por códigos desenvolvidos externamente, porém limitados a hot spots
definidos pipeline gráfico, Figura 3.3.
A possibilidade de se programar os processadores das GPUs através de shaders,
provocou uma explosão de pesquisas e experimentação no sentido de empregar estes dispositivos na solução de problemas em diversas outras áreas. Para viabilizar o uso dos
processadores do dispositivo gráfico bastava codificar o problema e os dados em estruturas processáveis pelos shaders e representadas pelo pipeline gráfico. Nascia assim a
GPGPU (General Purpose computation on GPU), programação em GPU para propósito
general.
Recentemente ocorreu uma grande inovação nos dispositivos gráficos, instruções
69
Tendências e Técnicas em Realidade Virtual e Aumentada
Processamento
de primitivas
API
Vertex
Shader
VBO
Geometry
Shader
Montagem de
primitivas
Rasterizador
Stream
buffer
Fragment
Shader
Stencil
...
Color Buffer
Blending
FRAMEBUFFER
Figura 3.3. Pipeline gráfico programável da OpenGL
de processador e hardware de memória foram adicionados a GPU , para dar suporte às
linguagens de programação de propósito geral. Concomitantemente, foram criados ambientes de programação para permitir que as GPUs possam ser programadas com linguagens
de programação familiares, como C e C++. Esta inovação torna as GPU processadores
many-core, paralelos, completamente programáveis para aplicações de propósito geral.
Nascia assim o termo GPU Computing, cunhado para denotar o uso de GPU para computação via uma API e linguagem de programação paralela, sem necessariamente ficar
preso à estrutura do pipeline gráfico, a menos que seja conveniente.
Duas soluções tecnológicas proprietárias dão suporte a GPU Computing, CUDA
da NVIDIA e Stream da AMD/ATI e a OpenCL, um padrão aberto com suporte aos dispositivos gráficos de diversos fabricantes, preservando-se a interface de programação. Tanto
a NVIDIA [NVIDIA Corporation 2010], quanto a AMD/ATI [ATI 2010] são fontes de excelentes materiais sobre OpenCL e também provedoras das implementações da OpenCL
para seus respectivos hardwares. O restante deste capítulo será destinado exclusivamente
a OpenCL e por vezes, uma comparação com CUDA e Stream. A sinergia entre OpenCL
e OpenGL será abordada com mais detalhes, pois são mais e mais comuns aplicações
que utilizam tanto os recursos gráficos para renderização e outras tarefas não-gráficas,
como áudio, simulação física, inteligência artificial, sistemas financeiros, diversas áreas
da matemática em si e em qualquer problema que demande alto desempenho e seja computacionalmente paralelizável.
3.3. A arquitetura OpenCL
A arquitetura OpenCL é composta por um conjunto de entidades que definem uma camada
de abstração de baixo nível para os dispositivos que suportam o padrão. A arquitetura
também pode ser vista na forma de quatro modelos: modelo de plataforma, modelo de
execução, modelo de programação e modelo de memória.
As seções a seguir introduzem os conceitos da arquitetura OpenCL. Os modelos de
70
Tendências e Técnicas em Realidade Virtual e Aumentada
plataforma, execução e programação são introduzidos junto à discussão de alguns destes
conceitos. O modelo de memória é detalhado em uma seção separada. Os nomes dos
conceitos foram mantidos na sua forma original em inglês, deixando o leitor familiarizado
com a terminologia encontrada na literatura.
3.3.1. Devices
Um device OpenCL é qualquer dispositivo computacional acessível pelo runtime OpenCL
para o envio de tarefas a serem executadas. Uma GPU ou uma CPU são consideradas devices segundo esta definição. Cada device possui uma ou mais compute units capazes
de executar instruções em paralelo. Uma compute unit comporta um ou mais processing
elements, que efetivamente realizam o processamento.
O sistema computacional responsável pela agregação, inicialização e submissão
de tarefas a diferentes devices OpenCL é denominado host. Host, devices, compute units
e processing elements compõem o modelo de plataforma da arquitetura OpenCL. As entidades representadas por retângulos na Figura 3.4 correspondem ao modelo de plataforma.
Device
Computer Unit
Processing
Element
Computer Unit
Processing
Element
Memória
local
Memória
privada
Memória
local
Memória
privada
Memória global e memória constante
Device
Figura 3.4. Modelos de plataforma e de memória da arquitetura OpenCL.
3.3.2. Kernels
Um kernel (também chamado de kernel function ou compute kernel ) é um conjunto de
instruções que podem ser executadas em um device OpenCL. Kernels são compilados a
partir de funções escritas na linguagem OpenCL C (ver Seção 3.4) e constituem a unidade
básica de desenvolvimento em OpenCL.
Durante a execução, podem existir uma ou mais instâncias de um mesmo kernel,
71
Tendências e Técnicas em Realidade Virtual e Aumentada
sendo cada instância denominada um work-item. O padrão OpenCL define em seu modelo
de programação dois modos de uso para um kernel :
• No modo data parallel, múltiplos work-items operam sobre porções diferentes de
um conjunto de dados. Diversos work-items podem ser executados em paralelo,
de acordo com o número de compute units e de processing elements do device em
uso. Neste modo, work-items são combinados em work-groups.
• No modo task parallel, um único work-item é executado sobre um único processing
element do device, designando uma tarefa sequencial. Este modo é assim denominado porque permite a execução de tarefas distintas em paralelo, de acordo com o
número de compute units e de processing elements do device em uso.
Um kernel tem seus work-items executados sobre um espaço de índices denominado NDRange (N-dimensional range). São suportados espaços de índices de 1, 2 e
3 dimensões. Quando o host encaminha a execução de um kernel para um device, ele
informa também o número de dimensões, bem como o tamanho de cada uma destas, do
espaço de índices sobre o qual o kernel deve ser executado. O runtime encarrega-se de
criar um work-item para cada ponto neste espaço. Em cada dimensão, os índices são
inteiros que variam de 0 até o tamanho da respectiva dimensão menos um. A definição do espaço de índices e a criação de work-items para os pontos do espaço de índices
constituem o modelo de execução da arquitetura OpenCL.
Cada work-item de um kernel é identificado por uma tupla de IDs globais, com
um ID para cada dimensão do espaço de índices, e por uma tupla de IDs locais dentro
do work-group. Da mesma forma, cada work-group é identificado por uma tupla de IDs,
com um ID pra cada dimensão do espaço de índices. Assim, um work-item pode ser
identificado pelos seus IDs globais ou pela combinação dos IDs do seu work-group com
seus IDs locais. A Figura 3.5 ilustra a organização de IDs em um espaço de índices de
duas dimensões.
Aplicações que realizam processamento paralelo muitas vezes requerem a presença de algum mecanismo de sincronização. OpenCL permite que work-items em um
mesmo work-group definam barreiras onde a execução é suspendida até que todos os
work-items atinjam a barreira. No entanto, não existe nenhuma forma de sincronizar as
atividades entre diferentes work-groups.
3.3.3. Program objects
Um (program object) é utilizado pelo host para encapsular um programa OpenCL, isto
é, o código de um ou mais kernels, na forma de código-fonte ou binária. Program objects também encapsulam uma referência para um context (ver Seção 3.3.6), o último
executável compilado com sucesso e uma lista de devices para os quais o programa foi
compilado.
72
Tendências e Técnicas em Realidade Virtual e Aumentada
Figura 3.5. Exemplo de espaço de índices de duas dimensões. Os identificadores externos correspondem aos identificadores dos work-groups, representados
pelas caixas maiores. As caixas menores correspondem aos work-items. Os
identificadores na parte superior das caixas menores correspondem aos IDs globais, enquanto os identificadores na parte inferior correspondem aos IDs locais.
3.3.4. Memory objects
Um memory object permite ao host alocar e acessar a memória global (ver Seção 3.3.7)
dos devices OpenCL utilizados pela aplicação.
Existem duas categorias de memory objects: buffer objects (ou simplesmente
buffers) e image objects. Buffers armazenam coleções de tamanho fixo de valores de
um determinado tipo de dados, isto é, correspondem a arrays comumente usados na programação. Image objects armazenam texturas bi- ou tri-dimensionais, frame-buffers ou
imagens. Enquanto buffers são sempre suportados por devices OpenCL, estes podem não
oferecer suporte a image objects. É necessário que o host verifique o suporte antes do
envio de um kernel que utiliza image objects para execução.
A principal diferença entre as duas categorias de memory objects reside na forma
como os dados armazenados por estes são acessados. Buffers são acessados por meio de
índices inteiros. Imagens são acessadas através de sampler objects, que possuem parâmetros relativos à representação binária dos valores dos pixels e métodos de interpolação,
entre outros.
3.3.5. Command queues
Uma command queue é a entidade que permite que o host comunique-se com um device
OpenCL, através do envio de comandos para este. Tais comandos possibilitam o envio de
kernels para execução, comunicação com a memória global dos devices e sincronização
de tarefas.
Os comandos inseridos em uma command queue sempre são iniciados em ordem,
porém o término da sua execução pode ocorrer fora de ordem. No entanto, também é
possível fazer com que a execução seja totalmente em ordem, isto é, um comando só
73
Tendências e Técnicas em Realidade Virtual e Aumentada
começará a ser executado quando o comando anterior houver finalizado sua execução.
Sempre que um comando é enfileirado, é possível solicitar o retorno de um event object
para o comando, que permite o aguardo pelo evento do término da execução do comando
antes que a execução prossiga.
3.3.6. Contexts
Um context constitui um ambiente para execução de kernels OpenCL, agregando um ou
mais devices, memory objects e command queues. A criação e configuração de um context estão entre as primeiras tarefas que o host deve realizar em uma aplicação OpenCL.
3.3.7. Modelo de memória
A arquitetura OpenCL define um modelo de memória que divide a memória dos devices
em quatro categorias. Estas categorias determinam a forma como os work-items compartilham diferentes regiões de memória:
• Memória global (global memory): compartilhada por todos os work-items de um
kernel em execução, sendo permitido o acesso de escrita e leitura. Os work-items
de um determinado work-group podem perceber mudanças na memória global realizadas pelos work-items de outros work-groups (no entanto, como explicado na
Seção 3.3.2, não há mecanismo de sincronização entre work-groups, portanto a
memória global não é um meio confiável de comunicação entre work-groups distintos).
• Memória local (local memory): compartilhada apenas entre os work-items de um
mesmo work-group, sendo permitido o acesso para escrita e leitura.
• Memória privada (private memory): não-compartilhada, restrita a cada work-item,
para leitura e escrita.
• Memória constante (constant memory): compartilhada por todos os work-items,
mas é permitido somente o acesso para leitura durante a execução de um kernel.
As entidades elípticas na Figura 3.4 correspondem às diferentes categorias de memória encontradas em um device da arquitetura OpenCL. Na figura, também pode ser
observada a relação do modelo de plataforma com o modelo de memória.
3.3.8. Consistência de memória
O estado visível da memória a um work-item pode não ser o mesmo para outros workitems durante a execução de um kernel, sendo isto dependente de implementação. Porém,
as seguintes garantias são feitas quanto à consistência do acesso à memória:
1. Para um único work-item, há consistência de leitura e escrita. Se um work-item
escrever em uma região de memória e a seguir ler a mesma região, o valor lido será
aquele que foi escrito.
74
Tendências e Técnicas em Realidade Virtual e Aumentada
2. As memórias global e local são consistentes entre work-items de um mesmo workgroup em uma barreira (ver Seção 3.5.2).
3.4. A linguagem OpenCL
A linguagem utilizada para a escrita kernels OpenCL, chamada OpenCL C, é baseada na
especificação C99 da linguagem C, com algumas extensões e um conjunto de restrições.
As seções a seguir detalham os aspectos mais relevantes da linguagem OpenCL C.
3.4.1. Tipos de dados
A linguagem OpenCL C oferece tipos de dados divididos em duas categorias: tipos escalares e tipos vetoriais. Tipos escalares permitem o armazenamento de informações numéricas com diferentes níveis de precisão e em diversos intervalos. Tipos vetoriais permitem
o armazenamento de múltiplos dados escalares em uma única estrutura. Além disso, tipos
definidos através de struct ou union, além de arrays, funções e ponteiros, também
são suportados. Há também tipos especiais para imagens (image2d_t e image3d_t),
samplers de imagens (sampler_t) e eventos (event_t).
A Tabela 3.1 apresenta os principais tipos escalares da linguagem OpenCL C.
Tipos vetoriais são nomeados de acordo com os tipos escalares, sendo sua forma geral
tipon. tipo corresponde a um dos tipos listados na Tabela 3.1, enquanto n especifica
o número de componentes do vetor, o qual pode ser 2, 4, 8 ou 16. Por exemplo, um
vetor com 4 componentes do tipo int possui tipo int4. Tipos vetoriais com componentes sem sinal sempre são declarados seguindo a forma abreviada do tipo escalar, isto
é, com o prefixo u no início do nome do tipo, ao invés do modificador unsigned. Não
são suportados vetores de bool, half, size_t e void, nem de tipos definidos pelo
usuário.
As componentes de uma variável de tipo vetorial são acessadas por meio do operador .. As componentes são identificadas pelos caracteres x, y, z e w, ou por índices
numéricos de 0 a F (notação hexadecimal). Mais de uma componente pode ser informada
em um único acesso, em qualquer ordem, ou mesmo de forma repetida, para formar outro vetor, contanto que sejam respeitadas as quantidades de componentes permitidas. Os
exemplos abaixo ilustram alguns acessos a vetores:
float4 vec1;
float4 vec2;
float16 vec3;
// Acesso à primeira componente de vec1
vec1.x = 1.0f;
// Acesso simultâneo às componentes x e z de vec2
vec2.xz = (float2)(2.0f, 3.0f);
// Acesso com componentes misturadas
75
Tendências e Técnicas em Realidade Virtual e Aumentada
Tipo
bool
uchar / char
ushort / short
uint / int
ulong / long
float
half
size_t
void
Descrição
Booleano; assume os valores true ou false.
Inteiro de 8 bits sem/com sinal.
Inteiro de 16 bits sem/com sinal.
Inteiro de 32 bits sem/com sinal.
Inteiro de 64 bits sem/com sinal.
Ponto-flutuante de 32 bits.
Ponto-flutuante de 16 bits.
Equivalente a uint ou ulong, dependendo
da implementação.
Tipo vazio.
Tabela 3.1. Tipos de dados escalares da linguagem OpenCL C. Tipos sem sinal também podem ser nomeados na forma unsigned [nome do tipo com
sinal].
vec1.zw = vec2.yx;
// Acesso com componentes repetidas
vec2.yz = vec1.ww;
// Acesso às componentes 0, 5, 10 e 15 de vec3
vec3.s05af = vec1;
3.4.2. Qualificadores
A linguagem OpenCL C oferece um único qualificador de função, __kernel, cujo propósito é indicar que a função qualificada é um kernel. Dentro de um código OpenCL, uma
chamada a uma função definida com o qualificador __kernel comporta-se de forma
similar a qualquer outra chamada. Deve-se atentar, no entanto, a chamadas a funções
__kernel que tenham no seu código variáveis declaradas com o qualificador __local
(ver texto a seguir), pois o comportamento é definido pela implementação.
Os qualificadores de espaço de endereçamento da linguagem OpenCL C, __global,
__local, __constant e __private2 indicam o tipo de região de memória onde
uma variável ou buffer está alocado, de acordo com o modelo de memória descrito na
seção 3.3.7. As seguintes regras se aplicam ao uso de qualificadores de espaço de endereçamento:
1. Variáveis declaradas sem um qualificador de espaço de endereçamento são automaticamente tratadas como __private.
2 Estes
qualificadores podem também ser usados sem o prefixo __.
76
Tendências e Técnicas em Realidade Virtual e Aumentada
2. Em um kernel, argumentos declarados como ponteiros devem ser __global, __local
ou __constant. O uso de um destes qualificadores é obrigatório para tais argumentos.
3. Um ponteiro de um espaço de endereçamento só pode ser atribuído a outro ponteiro
do mesmo espaço de endereçamento. Por exemplo, tentar atribuir o valor de um
ponteiro __local a outro declarado com o qualificador __global constitui uma
operação ilegal.
4. Variáveis cujo escopo seja o programa inteiro devem ser declaradas com o qualificador __constant.
A seguir estão ilustradas algumas declarações de variáveis com os qualificadores
de espaço de endereçamento apresentados:
// Array de inteiros alocado na memória global
__global int* arr;
// Vetor com 4 componentes ponto-flutuante alocado
// na memória local
__local float4 vec;
// Inteiro de 64 bits alocado na memória privada
long n;
3.4.3. Operadores
A linguagem OpenCL C oferece um conjunto de operadores que trabalham tanto com
tipos escalares quanto vetoriais, permitindo a construção de expressões aritméticas, lógicas e relacionais com tais tipos. A Tabela 3.2 apresenta os principais operadores da
linguagem, divididos de acordo com a sua categoria.
A seguir são listadas algumas observações importantes sobre o uso dos operadores
da linguagem OpenCL C:
1. Uma operação entre um vetor e um escalar converte o escalar para o tipo do vetor3
e aplica a operação sobre cada componente do vetor. Por exemplo, uma soma entre
um vetor e um escalar resultará em um vetor cujas componentes foram acrescidas
da quantidade escalar. Da mesma forma, os operadores unários retornam resultados
vetoriais onde a operação foi aplicada individualmente sobre cada componente do
vetor.
3O
escalar deve possuir o mesmo número de bits ou menos que o tipo das componentes do vetor; caso
contrário, a tentativa de conversão para um tipo menor resultará em um erro de compilação.
77
Tendências e Técnicas em Realidade Virtual e Aumentada
Categoria
Operadores aritméticos binários
Operadores aritméticos unários
Operadores relacionais
Operadores lógicos binários
Operadores lógicos unários
Operadores bit-a-bit
Operadores de atribuição
Operadores
+, -, *, /, %
+, -, ++, >, >=, <, <=, ==, !=
&&, ||
!
&, |, ˆ, , <<, >>
=,
+=, -=, *=, /=, %=,
&=, |=, ^=, <<=, >>=
Tabela 3.2. Principais operadores da linguagem OpenCL C.
2. Operações entre vetores requerem que estes sejam do mesmo tamanho, isto é, contenham o mesmo número de componentes. Caso contrário, ocorrerá um erro de
compilação. Tais operações são aplicadas componente-a-componente sobre o vetor, por isso a necessidade de estes possuírem o mesmo número de componentes.
3. Operações lógicas e relacionais com pelo menos um operando vetorial retornam
um vetor do mesmo tipo deste, porém sempre com sinal. Cada componente pode
conter o valor 0 para um resultado falso e -1 (todos os bits configurados em 1) para
um resultado verdadeiro.
4. Os operadores aritméticos %, ++, -, operadores lógicos e operadores bit-a-bit não
suportam tipos ponto-flutuante.
3.4.4. Restrições
A linguagem OpenCL C estende a linguagem da especificação C99 com alguns tipos
novos e outros elementos, mas também acrescenta uma série de restrições. A lista a
seguir enumera algumas das principais destas restrições:
1. Ponteiros para função não são permitidos.
2. Argumentos para kernels não podem ser ponteiros para ponteiros.
3. Variáveis do tipo image2d_t e image3d_t podem ser declaradas apenas como
argumentos para funções.
4. Não são permitidos arrays nem ponteiros para variáveis do tipo sampler_t. Variáveis deste tipo não podem ser usadas como variáveis locais em uma função,
retornadas por uma função nem passadas como argumento para funções chamadas por um kernel (exceto as funções da API OpenCL). Finalmente, uma variável
sampler_t passada como argumento para um kernel não pode ser modificada.
78
Tendências e Técnicas em Realidade Virtual e Aumentada
5. Funções e macros com número variável de argumentos não são suportados.
6. Os qualificadores extern, static e auto não são suportados.
7. Não há suporte a recursão.
8. A escrita em ponteiros ou arrays com tipos de tamanho inferior a 32 bits (inclusive
os os tipos vetoriais char2 e uchar2) não é permitida.
9. Não são permitidos argumentos do tipo event_t para kernels.
10. Os elementos de uma struct ou union devem pertencer ao mesmo espaço de
endereçamento, de acordo com os qualificadores discutidos em 3.4.1.
3.5. A API OpenCL
Além da linguagem OpenCL C, o padrão OpenCL define uma API para uso em conjunto com estas linguagem, provendo diversas funções que podem ser usadas pelos kernels durante a computação das suas tarefas. Estas funções permitem a a identificação de
work-items e work-groups, a realização de operações matemáticas diversas (livrando o
programador da tarefa de implementá-las), sincronização, e ainda outras funcionalidades.
Esta fora do escopo deste material realizar uma listagem exaustiva de toda a API
OpenCL. Caso esteja interessado em ter acesso à todas as funções disponíveis, o leitor
deve referir-se ao documento da especificação OpenCL 1.0 [Khronos Group 2009]. As
seções a seguir apresentam as funções de identificação e sincronização da API OpenCL.
3.5.1. Identificação
Funções de identificação permitem que cada work-item adquira conhecimento sobre a sua
localização no espaço de índices sobre o qual o kernel é executado.
3.5.1.1. get_global_id
size_t get_global_id(uint dimindx)
Retorna o índice global do work-item no espaço de índices, na dimensão identificada por dimindx.
3.5.1.2. get_local_id
size_t get_local_id(uint dimindx)
79
Tendências e Técnicas em Realidade Virtual e Aumentada
Retorna o índice local do work-item no seu work-group, na dimensão identificada
por dimindx.
3.5.1.3. get_group_id
size_t get_group_id(uint dimindx)
Retorna o índice do work-group ao qual o work-item pertence, na dimensão identificada por dimindx.
3.5.1.4. get_global_size
size_t get_global_size(uint dimindx)
Retorna o número total de work-items na dimensão identificada por dimindx.
3.5.1.5. get_local_size
size_t get_local_size(uint dimindx)
Retorna o número de work-items em um work-group na dimensão identificada
por dimindx.
3.5.1.6. get_num_groups
size_ get_num_groups(uint dimindx)
Retorna o número de work-groups na dimensão identificada por dimindx.
3.5.1.7. get_work_dim
uint get_work_dim()
80
Tendências e Técnicas em Realidade Virtual e Aumentada
Retorna o número de dimensões do espaço de índices sobre o qual o kernel está
sendo executado.
3.5.2. Sincronização
A API de sincronização permite a especificação de pontos de sincronização que garantem
a consistência de memória, devendo ser atingidos por todos os work-items em um workgroup antes que a execução prossiga.
3.5.2.1. barrier
void barrier(cl_mem_fence_flags flags)
Cria uma barreira de sincronização. A execução só continuará após todos os workitems pertencentes ao work-group do work-item que atingiu a barreiram também a atinjam. Do contrário, o work-item, ou os work-items, que chegarem à barreira terão sua
execução bloqueada indefinidamente, e o host não será notificado do término da execução do kernel em momento algum.
Uma barreira permite a criação de uma memory fence, que garante que todas
as escritas na memória anteriores à barreira terão sido realmente efetuadas. O parâmetro
flags permite controlar a memory fence. Seu valor pode ser CLK_LOCAL_MEM_FENCE,
garantindo a consistência da memória local, CLK_GLOBAL_MEM_FENCE, garantindo a
consistência da memória global, ou a combinação de ambos os valores através do operador | (or bit-a-bit).
3.6. OpenCL na prática
Uma vez discutidos os principais conceitos referentes à arquitetura e ao padrão OpenCL,
é interessante ilustrar o seu uso de forma prática, através da apresentação de um exemplo
simples.
As seções a seguir demonstram a construção de uma aplicação OpenCL simples,
para o cálculo da diferença entre os elementos de dois arrays. O kernel utilizado é o
mesmo apresentado na Seção 3.1.1.
A execução do kernel é bastante simples. Após obter o seu ID global no espaço de
índices, este é utilizado para endereçar a posição correspondente nos arrays de entrada e
saída. A atribuição de IDs aos work-items é feita pelo runtime, cabendo ao desenvolvedor
informar apenas o número de work-items desejados através da especificação das dimensões do espaço de índices. Os work-items, então, acessam as regiões de memória que lhes
interessam de acordo com seus IDs. Estes work-items executam de forma paralela, de
acordo com a quantidade de compute units e processing elements do device em uso.
Os trechos de código das seções seguintes ilustram em sequência todos os passos
81
Tendências e Técnicas em Realidade Virtual e Aumentada
necessários para a criação de uma aplicação OpenCL simples. Se inseridos em um arquivo
de código-fonte, com os devidos cuidados de ligação e localização de cabeçalhos, este
código gera um executável funcional em plataformas que suportam o padrão OpenCL.
As explicações para o propósito e os argumentos de cada função estão incluídas
nos comentários junto aos trechos de código. Detalhes de menor relevância para a discussão são deixados de lado, sendo solicitado que o leitor busque mais informações na
especificação do padrão OpenCL, onde há uma listagem exaustiva de todas as funções e
possíveis valores para seus argumentos.
3.6.1. Inclusão de cabeçalhos e definições
A inclusão do cabeçalho contendo as declarações de funções e outros símbolos da OpenCL
é diferente de acordo com o sistema operacional. Em sistemas Mac OS X, o cabeçalho é
incluído da seguinte maneira:
#include <OpenCL/opencl.h>
Nos demais sistemas, da seguinte forma:
#include <CL/opencl.h>
Neste exemplo, também é incluído o cabeçalho padrão para entrada e saída padrão
na linguagem C++, e é definida uma macro para controlar o tamanho dos arrays:
#include <iostream>
#define ARRAY_LENGTH 32
3.6.2. Declaração de variáveis
Os conceitos apresentados na Seção 3.3 possuem, em sua maior parte, tipos de dados associados a si no momento da programação. Variáveis destes tipos são declaradas pelo host
para serem utilizadas pelo runtime. O código abaixo apresenta as declarações necessárias
para este exemplo:
int main(int argc, char** argv)
{
// Identificador de device
cl_device_id deviceId;
// Context OpenCL
cl_context context;
// Command queue
82
Tendências e Técnicas em Realidade Virtual e Aumentada
cl_command_queue queue;
// Program object
cl_program program;
// Kernel
cl_kernel kernel;
// Event object
cl_event event;
// Memory objects (para buffers)
cl_mem bufA;
cl_mem bufB;
cl_mem bufC;
Além das variáveis relacionadas ao ambiente OpenCL, também são declaradas
variáveis que o host utiliza para o armazenamento de dados de entrada e saída para o
kernel, para a determinação das dimensões do espaço de índices e para o armazenamento
de códigos de erro:
// Arrays no lado host
int* hostA;
int* hostB;
int* hostC;
// Array de um único elemento contendo o número de
// work-items que serão criados na única dimensão (0)
// usada neste exemplo
size_t globalSize[1] = { ARRAY_LENGTH };
// Variável para armazenamento de códigos de erro
int err;
3.6.3. Código-fonte do kernel
O código-fonte do kernel é armazenado em uma string embutida no código-fonte do programa executado pelo host. Uma possível alternativa seria realizar a carga do códigofonte a partir de um arquivo texto durante a execução. Esta código-fonte é passado para a
camada de plataforma para ser compilado para a placa gráfica usada usada para o processamento.
const char* source =
"__kernel void ArrayDiff( \
__global const int* a, \
__global const int* b, \
__global int* c) \
{ \
83
Tendências e Técnicas em Realidade Virtual e Aumentada
int id = get_global_id(0); \
c[id] = a[id] - b[id]; \
}"
;
3.6.4. Inicialização do context
Para tornar possível o acesso a devices OpenCL, é necessário que o host primeiro inicialize e configure um context. Nesta discussão, a inicialização do context OpenCL não se
refere somente à alocação do context em si, mas também à criação do ambiente mínimo
necessário para a execução de kernels OpenCL. No exemplo, é criado um context para
um único device, uma GPU, com uma única command queue:
// A função clGetDeviceIDs permite a obtenção de
// um ou mais identificadores para devices do
// host que suportem o padrão OpenCL.
// Aqui, é obtido um ID para uma única GPU.
err = clGetDeviceIDs(
// (cl_platform_id) Ver especificação.
NULL,
// (cl_device_type) Tipo de device desejado.
// Aqui, uma GPU. Outras opções comuns são
// CL_DEVICE_TYPE_CPU, para uma CPU, e
// CL_DEVICE_TYPE_ALL, para obter todos os
// devices disponíveis.
CL_DEVICE_TYPE_GPU,
// (cl_uint) Número de devices desejados.
1,
// (cl_device_id*) Ponteiro para o local onde a
// lista de IDs de devices deverá ser armazenada.
&deviceId,
// (cl_uint*) Retorna o número de devices
// encontrados.
NULL
);
if (err != CL_SUCCESS) {
std::cerr << "Falha ao obter ID para o device.\n";
exit(EXIT_FAILURE);
}
// A função clCreateContext permite a
// context OpenCL.
context = clCreateContext(
// (const cl_context_properties*)
0,
// (cl_uint) Número de devices no
1,
// (const cl_device_id*) Lista de
// para o context.
84
criação de um
Ver especificação.
context.
IDs de devices
Tendências e Técnicas em Realidade Virtual e Aumentada
&deviceId,
// Ponteiro para uma função de notificação chamada
// sempre que houver um erro no context. Ver
// especificação para detalhes sobre a assinatura da
// função.
NULL,
// (void*) Ponteiro para dados arbitrários passados
// para a função apontada pelo argumento anterior.
NULL,
// (cl_int*) Retorna código de erro.
&err
);
if (!context || err != CL_SUCCESS) {
std::cerr << "Falha ao criar context.\n";
exit(EXIT_FAILURE);
}
// A função clCreateCommandQueue cria uma command queue
// para um determinado device em um context.
queue = clCreateCommandQueue(
// (cl_context) Context.
context,
// (cl_device_id) Identificador do device.
deviceId,
// (cl_command_queue_properties) Propriedades da command
// queue. Ver especificação.
0,
// (cl_int*) Retorna código de erro.
&err
);
if (!queue || err != CL_SUCCESS) {
std::cerr << "Falha ao criar command queue.\n";
exit(EXIT_FAILURE);
}
3.6.5. Compilação do kernel
A compilação do kernel ocorre em três passos:
1. Criação de um program object no context.
2. Compilação do programa para os devices do context.
3. Criação de um kernel a partir do programa compilado.
O código a seguir demonstra a realização destes passos:
85
Tendências e Técnicas em Realidade Virtual e Aumentada
// A função clCreateProgramWithSource cria um program
// object a partir do código-fonte de um kernel,
// estando este código-fonte representado em um array
// de strings.
program = clCreateProgramWithSource(
// (cl_context) Context.
context,
// (cl_uint) Número de strings no array do
// código-fonte.
1,
// (const char**) Array de strings do código-fonte.
&source,
// (const size_t*) Array de tamanhos de string para
// cada string do array do código-fonte.
NULL,
// (cl_int*) Retorna código de erro.
&err
);
if (!program || err != CL_SUCCESS) {
std::cerr << "Falha ao criar program object.\n";
exit(EXIT_FAILURE);
}
// A função clBuildProgram compila o código-fonte de um
// program object para um ou mais devices do
// context.
err = clBuildProgram(
// (cl_program) Program object.
program,
// (cl_uint) Número de devices para o qual o
// programa deve ser compilado. 0 indica que o
// programa deve ser compilado para todos os
// devices do context.
0,
// (const cl_device_id*) Lista de devices para
// o qual o programa deve ser compilado. NULL indica
// que o programa deve ser compilado para todos os
// devices do context.
NULL,
// (const char*) Opções para o compilador OpenCL.
NULL,
// Ponteiro para função que será chamada após a
// compilação. Ver especificação para mais detalhes.
NULL,
// (void*) Ponteiro para dados arbitrários passados
// para a função apontada pelo argumento anterior.
NULL
);
// Caso a compilação tenha falhado, imprime as mensagens
86
Tendências e Técnicas em Realidade Virtual e Aumentada
// de erro do compilador
if (err != CL_SUCCESS) {
size_t len;
char info[2048];
// A função clGetProgramBuildInfo permite a obtenção
// de dados sobre a compilação de um programa para um
// determinado device.
clGetProgramBuildInfo(
// (cl_program) Program object.
program,
// (cl_device_id) ID do device para o qual se
// deseja obter dados a respeito da compilação.
deviceId,
// (cl_program_build_info) Nome do parâmetro que
// indica a informação desejada sobre a compilação.
// Ver especificação para outros parâmetros.
CL_PROGRAM_BUILD_LOG,
// (size_t) Tamanho do array onde os dados
// retornados serão armazenados.
sizeof(info),
// (void*) Array onde os dados serão armazenados.
info,
// (size_t*) Retorna o tamanho em bytes dos dados
// obtidos.
&len
);
std::cerr << info << std::endl;
exit(EXIT_FAILURE);
}
// A função clCreateKernel cria um kernel a partir
// de um program object que passou pela etapa de
// compilação.
kernel = clCreateKernel(
// (cl_program) Program object, após compilação.
program,
// (const char*) Nome da função __kernel declarada
// no código-fonte do programa.
"ArrayAbsDiff",
// Retorna código de erro.
&err
);
if (!kernel || err != CL_SUCCESS) {
std::cerr << "Falha ao criar kernel.\n";
exit(EXIT_FAILURE);
}
87
Tendências e Técnicas em Realidade Virtual e Aumentada
3.6.6. Criação dos arrays no host
Neste exemplo, os arrays de entrada são criados pelo host e inicializados com valores
aleatórios de -50 a +50. Também é criado um array onde são armazenados os resultados
da execução do kernel :
// Cria os arrays no host, para armazenamento dos
// dados de entrada e saída do kernel.
hostA = new int[ARRAY_LENGTH];
hostB = new int[ARRAY_LENGTH];
hostC = new int[ARRAY_LENGTH];
// Inicializa os arrays hostA e hostB com valores
// aleatórios entre -50 e +50.
for (int i = 0; i < ARRAY_LENGTH; ++i) {
hostA[i] = rand() % 101 - 50;
hostB[i] = rand() % 101 - 50;
}
3.6.7. Criação dos buffers OpenCL
Após a criação do context e da obtenção dos dados de entrada, estes devem ser transferidos para a memória do device que será usado para a computação (neste caso, a GPU). No
entanto, é necessário que antes sejam alocados memory objects do tamanho adequado no
context, que permitem a transferência dos dados para a memória do device:
// A função clCreateBuffer permite a criação de
// buffers em um determinado context. Estes buffers
// permitem a posterior entrada e saída de dados
// para a memória global dos devices do context.
bufA = clCreateBuffer(
// (cl_context) Context.
context,
// (cl_mem_flags) Flags de especificação de
// informações sobre a alocação e o uso da
// memória associada ao buffer. A flag
// CL_MEM_READ_ONLY cria um buffer somente-leitura.
// Outra flags comum é CL_MEM_READ_WRITE, para
// a criação de buffers que permitem leitura e
// escrita da memória. Ver especificação para demais
// flags permitidas.
CL_MEM_READ_ONLY,
// (size_t) Tamanho em bytes do memory object
// a ser alocado.
ARRAY_LENGTH * sizeof(int),
// (void*) Ponteiro para dados de inicialização
// do buffer.
NULL,
// Retorna código de erro.
88
Tendências e Técnicas em Realidade Virtual e Aumentada
&err
);
if (!bufA || err != CL_SUCCESS)
{
std::cerr << "Falha ao criar buffer bufA.\n";
exit(EXIT_FAILURE);
}
bufB = clCreateBuffer(
context,
CL_MEM_READ_ONLY,
ARRAY_LENGTH * sizeof(int),
NULL,
&err
);
if (!bufA || err != CL_SUCCESS)
{
std::cerr << "Falha ao criar buffer bufB.\n";
exit(EXIT_FAILURE);
}
bufC = clCreateBuffer(
context,
CL_MEM_READ_WRITE,
ARRAY_LENGTH * sizeof(int),
NULL,
&err
);
if (!bufA || err != CL_SUCCESS)
{
std::cerr << "Falha ao criar buffer bufC.\n";
exit(EXIT_FAILURE);
}
3.6.8. Escrita dos buffers de entrada
Após a criação dos memory objects, é possível transferir os dados para o device. Isto é
feito através do envio de comandos de escrita de buffers para a sua command queue:
// A função clEnqueueWriteBuffer insere em uma command
// queue um comando para escrita de dados do
// host na memória do device associado à
// command queue, por meio de um memory object.
err = clEnqueueWriteBuffer(
// (cl_queue) Command queue.
queue,
// (cl_mem) Memory object.
89
Tendências e Técnicas em Realidade Virtual e Aumentada
bufA,
// (cl_bool) Determina se o host deve
// bloquear até o término da escrita dos dados.
CL_TRUE,
// (size_t) Offset a partir do qual os dados
// devem ser transferidos.
0,
// (size_t) Comprimento em bytes dos dados a
// serem transferidos.
ARRAY_LENGTH * sizeof(int),
// (const void*) Ponteiro para a região de memória
// do host onde os dados se localizam.
hostA,
// (cl_uint) Número de eventos na lista de eventos
// que devem ser aguardados antes do início da
// transferência dos dados.
0,
// (const cl_event*) Lista de eventos que devem ser
// aguardados antes do início da transferência dos
// dados.
NULL,
// (cl_event*) Retorna um event object para
// aguardar pelo término da transferência dos dados,
// caso a chamada seja não-bloqueante.
NULL);
err |= clEnqueueWriteBuffer(
queue,
bufB,
CL_TRUE,
0,
ARRAY_LENGTH * sizeof(int),
hostB,
0,
NULL,
NULL
);
if (err != CL_SUCCESS) {
std::cerr << "Falha ao transferir dados para o device.\n";
exit(EXIT_FAILURE);
}
É interessante observar que os memory objects em si, no momento da sua criação,
estão associados ao context, e não a um device específico. A associação com um device
ocorre no momento do enfileiramento de um comando de escrita (ou leitura), dado que
cada command queue é específica de um único device.
90
Tendências e Técnicas em Realidade Virtual e Aumentada
3.6.9. Configuração dos argumentos do kernel
Antes do envio do kernel para execução, é necessário que seu argumentos sejam configurados com os valores adequados. A configuração dos argumentos se dá pela informação
das regiões de memória do host onde seus valores se encontram.
// A função clSetKernelArg é utilizada para configurar os
// argumentos para um kernel.
err = clSetKernelArg(
// (cl_kernel) Kernel.
kernel,
// (cl_uint) Posição (índice) do argumento, de acordo
// com a ordem em que os argumentos foram declarados
// no código-fonte OpenCL C.
0,
// (size_t) Tamanho dos dados apontados pelo argumento
// seguinte.
sizeof(cl_mem),
// (const void*) Dados a serem passados como argumento.
&bufA);
err |= clSetKernelArg(kernel, 1, sizeof(cl_mem), &bufB);
err |= clSetKernelArg(kernel, 2, sizeof(cl_mem), &bufC);
if (err != CL_SUCCESS) {
std::cerr << "Falha ao configurar argumentos do kernel.\n";
exit(EXIT_FAILURE);
}
3.6.10. Envio do kernel para execução
Após a configuração dos argumentos, o kernel pode ser enviado para execução no device.
É importante observar que a chamada que enfileira o comando de execução do kernel é
sempre não-bloqueante. O término da execução deve ser aguardado a partir do evento
retornado pelo enfileiramento.
// A função clEnqueueNDRangeKernel insere em uma
// command queue um kernel para execução em um
// device. A função permite também a
// especificação das dimensões do espaço de índices
// sobre o qual o kernel será executado.
err = clEnqueueNDRangeKernel(
// (cl_queue) Command queue.
queue,
// (cl_kernel) Kernel.
kernel,
// (cl_uint) Número de dimensões do espaço de
// índices.
1,
// (const size_t*) Ver especificação.
91
Tendências e Técnicas em Realidade Virtual e Aumentada
NULL,
// (const size_t*) Array de tamanhos das dimensões
// globais do espaço de índices.
globalSize,
// (const size_t*) Array de tamanhos das dimensões
// locais do espaço de índices (dimensões dos
// work-groups). Quando NULL, o runtime determina
// o tamanho das dimensões.
NULL,
// (cl_uint) Número de eventos na lista de eventos
// que devem ser aguardados antes do início da
// execução do kernel.
0,
// (const cl_event*) Lista de eventos que devem ser
// aguardados antes do início da execução do kernel.
NULL,
// (cl_event*) Retorna um event object para
// aguardar pelo término da execução do kernel.
&event
);
if (err != CL_SUCCESS) {
std::cerr << "Falha ao enviar kernel para execução.\n";
exit(EXIT_FAILURE);
}
Quando o array de tamanhos de dimensões locais é especificado pelo desenvolvedor, é necessário que a divisão das dimensões globais pelas dimensões locais seja exata,
sem resto. Caso contrário, um erro ocorrerá e o kernel não será enviado para execução.
3.6.11. Sincronização: espera pelo término da execução do kernel
Sincronização no host consiste no aguardo pelo término de um ou mais comandos submetidos a uma command queue, ou mesmo de todos estes. Aqui, o evento retornado pelo
comando de enfileiramento do kernel é usado para aguardar o término da execução do
mesmo:
// A função clWaitForEvents aguarda o término de todos
// os eventos em um array de eventos.
// Aqui, como há apenas um evento, o endereço do "array"
// corresponde ao próprio endereço da variável event.
clWaitForEvents(
// Número de eventos no array.
1,
// Array de eventos.
&event
);
92
Tendências e Técnicas em Realidade Virtual e Aumentada
3.6.12. Leitura dos resultados
A leitura de dados da memória do device é efetuada de forma praticamente idêntica à
escrita de dados, porém com a invocação de uma função diferente:
// A função clEnqueueReadBuffer insere em uma command
// queue um comando para leitura de dados de um
// device para a memória do host. Os
// argumentos são os mesmos de clEnqueueWriteBuffer,
// porém o conteúdo do array apontado pelo 6o. argumento
// é sobrescrito com os dados lidos do device.
// Aqui, o terceiro argumento é CL_FALSE, portanto
// a chamada é não-bloqueante.
err = clEnqueueReadBuffer(
queue,
bufC,
CL_FALSE,
0,
ARRAY_LENGTH * sizeof(int),
hostC,
0,
NULL,
NULL
);
if (err != CL_SUCCESS) {
std::cerr << "Falha ao ler resultados.\n";
exit(EXIT_FAILURE);
}
3.6.13. Sincronização: espera pelo término da leitura dos resultados
A leitura dos resultados foi feita de forma não-bloqueante na seção anterior para ilustrar
uma forma de sincronização alternativa ao uso de clWaitForEvents:
// A função clFinish bloqueia a execução no host
// até o término da execução de todos os comandos na command
// queue de comandos passada como argumento. Neste caso, o
// o único comando na command queue é o comando de leitura do
// buffer que contém os resultados da computação.
clFinish(queue);
3.6.14. Apresentação dos resultados
O código a seguir imprime os dados de entrada gerados, bem como da saída calculada,
para verificação dos resultados da execução:
// Imprime os arrays gerados e os resultados na tela
#define PRINT_ARRAY(name, array) \
93
Tendências e Técnicas em Realidade Virtual e Aumentada
std::cout << name << ":"; \
for (int i = 0; i < ARRAY_LENGTH; ++i) { \
std::cout << " " << array[i]; \
} \
std::cout << "\n";
PRINT_ARRAY("A", hostA)
PRINT_ARRAY("B", hostB)
PRINT_ARRAY("C", hostC)
3.6.15. Liberação de recursos
Após a execução do kernel, os recursos alocados devem ser liberados através de funções
especiais disponibilizadas pela OpenCL. Existe uma função de liberação para cada tipo
de objeto utilizado:
clReleaseMemObject(bufA);
clReleaseMemObject(bufB);
clReleaseMemObject(bufC);
clReleaseKernel(kernel);
clReleaseProgram(program);
clReleaseCommandQueue(queue);
clReleaseContext(context);
Finalmente, os demais recursos são liberados pela aplicação e esta é encerrada:
delete hostA;
delete hostB;
delete hostC;
return 0;
}
3.7. Aplicações em Realidade Virtual
Em Realidade Virtual, um ambiente computacional imaginário ou simulando o mundo
real é o cenário no qual usuários podem interagir com objetos, controlá-los ou simplesmente visualizá-los em um tela de computador ou utilizando sistemas estereoscópicos,
que permitem a imersão naquele mundo virtual. Para tornar o ambiente atrativo, o sistema
deve ser visualmente rico, dinâmico e ser processado em tempo-real. Neste contexto, a
exigência de placa gráfica é inevitável para a execução e o desenvolvimento destes aplicativos. Como descrito na Seção 3.2, para a programação de aplicativos gráficos, a escolha
por APIs gráficas como OpenGL e Direct3D é o caminho natural para a realização da
tarefa.4 .
4 APIs
que fornecem um nível de abstração de acima destas, como OpenSceneGraph, OpenSG e outras
auxiliam no desenvolvimento, porém ainda utilizam as APIs OpenGL ou Direct3D como base.
94
Tendências e Técnicas em Realidade Virtual e Aumentada
A possibilidade de interação entre OpenCL e OpenGL permite que além da renderização em tempo-real, outros algoritmos paralelizáveis tirem proveito do poder de processamento do dispositivo gráfico. Para ilustrar esta sinergia entre OpenCL e OpenGL, é
mostrado que object buffers OpenGL podem ser mapeados no espaço de endereçamento
de objetos OpenCL, permitindo que um kernel OpenCL leia dados escritos por meio da
API OpenGL. O contrário também é possível, isto é, kernels OpenCL podem escrever
dados que são consumidos pelo código OpenGL.
O exemplo a seguir demonstra como explorar a interoperabilidade entre OpenGL e
OpenCL, modificando-se dinamicamente um vertex buffer através de um kernel OpenCL.
Neste exemplo, os vértices de uma malha (mesh ), são armazenados em um VBO, sendo
suas posições modificadas via OpenCL. Objetivamente, os seguintes passos devem ser
executados:
1. Criar um VBO (vazio):
// Cria o buffer object
glGenBuffers(1, vbo);
glBindBuffer(GL_ARRAY_BUFFER, *vbo);
// Inicia o buffer object com o tamanho total da malha (vazia)
unsigned int size = mesh_width * mesh_height * 4 * sizeof(float);
glBufferData(GL_ARRAY_BUFFER, size, 0, GL_DYNAMIC_DRAW);
2. Criar um memory object de um VBO na memória globa, para que a OpenCL tenha
acesso ao VBO:
// cria buffer OpenCL do VBO
vbo_cl = clCreateFromGLBuffer(
cxGPUContext, CL_MEM_WRITE_ONLY, *vbo, NULL);
3. Obter o VBO para escrita a partir da OpenCL;
// mapeia o buffer object para escrita pela OpenCL
ciErrNum |= clEnqueueAcquireGLObjects(
cqCommandQueue, 1, &vbo_cl, 0,0,0);
4. Executar o seguinte kernel para modificar as posições dos vértices:
__kernel void seno(
__global float4* pos,
unsigned int width,
unsigned int height,
float time)
{
unsigned int x = get_global_id(0);
unsigned int y = get_global_id(1);
// Calcula coordenadas u,v.
float u = x / (float) width;
95
Tendências e Técnicas em Realidade Virtual e Aumentada
float v = y / (float) height;
u = u*2.0f - 1.0f;
v = v*2.0f - 1.0f;
// Calcular um padrão de onda senoidal
float freq = 4.0f;
float w = sin(u*freq + time) * cos(v*freq + time) * 0.5f;
// Escreve vértice de saída
pos[y*width+x] = (float4)(u, w, v, 1.0f);
}
5. Liberar o VBO para retornar a posse para OpenGL:
// liberar o buffer object para a OpenGL
ciErrNum |= clEnqueueReleaseGLObjects(
cqCommandQueue, 1, &vbo_cl, 0,0,0);
6. Renderizar o resultado usando OpenGL:
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glVertexPointer(4, GL_FLOAT, 0, 0);
glEnableClientState(GL_VERTEX_ARRAY);
glColor3f(1.0, 0.0, 0.0);
glDrawArrays(GL_POINTS, 0, mesh_width * mesh_height);
glDisableClientState(GL_VERTEX_ARRAY);
A Figura 3.6 mostra telas da aplicação em execução, destacando as diferentes
formas da malha, obtidas pelo deslocamento dos vértices.
Figura 3.6. Malha renderizada em OpenGL, com forma modificada via OpenCL.
96
Tendências e Técnicas em Realidade Virtual e Aumentada
Em termos de aplicações, pode-se ainda enumerar algumas que poderiam fazer
empregar OpenGL e OpenCL em conjunto: simulação de fluidos, sistemas de partículas,
manipulação de texturas, visualização de imagens, entre outras. Em todos estes casos,
OpenGL é responsável pela renderização (ou simplesmente visualização, no caso de imagens), enquanto a simulação é realizada por meio de kernels OpenCL.
Resultados semelhantes aos do exemplo apresentado podem ser obtidos através da
OpenGL e do uso shaders. OpenCL pode ser mais interessante para a solução de determinados problemas, enquanto shaders para outros. O mecanismo de gerenciamento de
shaders na GPU fica completamente a cargo da API gráfica. Por outro lado, OpenCL pode
ser mais flexível e oferecer mais recursos do que shaders. Para desenvolvedores de aplicações genéricas, empregar shaders torna necessário estudar mais uma linguagem (GLSL,
HLSL, Cg, etc.), com uma curva de aprendizagem mais longa, dada a necessidade de se
conhecer o pipeline gráfico. A falta de comunicação entre processos pode ser limitante no
uso de shaders. OpenCL oferece um modelo de memória mais flexível e com capacidade
de sincronização entre work-items; no entanto, exige um conhecimento mais detalhado
do modelo de programação, gerenciamento de memória e controle de concorrência.
A escalabilidade na instanciação dos shaders está limitada ao dispositivo gráfico
de saída, enquanto OpenCL limita-se apenas à capacidade da placa gráfica. No entanto,
alguns acessos ao hardware só podem ser realizados por meio de shaders, como, por
exemplo, mipmapping e cubemaps. No que concerne a depuração, shaders tem evoluído,
enquanto a OpenCL ainda carece de ferramentas, apesar de permitir a execução de kernels
em devices emulados e suportar breakpoints.
3.8. Aplicações em Realidade Aumentada
Aplicações de Realidade Aumentada dependem de bons algoritmos de visão computacional para a correta detecção dos pontos de interesse na cena, como marcadores. Além de
uma boa capacidade de detecção, tais algoritmos devem possuir bom desempenho computacional, para proporcionar uma experiência adequada e contínua ao usuário.
Muitos algoritmos de visão computacional baseiam-se na informação de bordas
(grandes variações de intensidade entre pixels adjacentes) das imagens para a detecção de
pontos ou características de interesse. O operador de Laplace é utilizado para a detecção
das bordas de uma imagem, tendo a vantagem de seus resultados serem invariantes a
rotação e de permitir implementações eficientes, uma vez que sua aplicação se dá de
forma independente sobre cada ponto da imagem. Computações deste tipo são adequadas
ao modelo de paralelismo oferecido pela arquitetura OpenCL.
O operador de Laplace é aplicado sobre uma imagem realizando-se a convolução
desta com a máscara apresentada na Figura 3.7(a). O resultado é uma imagem com valores
próximos de zero nas regiões mais homogêneas, e com valores muito baixos ou muito
altos onde há bordas. As Figuras 3.7(b) e 3.7(c) apresentam uma imagem de entrada e o
resultado da aplicação do operador sobre esta, respectivamente.
97
Tendências e Técnicas em Realidade Virtual e Aumentada
(a)
(b)
(c)
Figura 3.7. (a) Operador de Laplace. (b) Imagem original. (c) Resultado da convolução, escalado para o intervalo de valores de 0 a 255.
O kernel OpenCL a seguir implementa o operador de Laplace:
__kernel void Laplacian(
__global const int* image,
__global int* laplacian)
{
int x = get_global_id(0);
int y = get_global_id(1);
int width = get_global_size(0);
int height = get_global_size(1);
if (x > 0 && x < width - 1 &&
y > 0 && y < height - 1)
{
laplacian[y * width + x] =
-4 * image[y * width + x] +
image[(y - 1) * width + x] +
image[(y + 1) * width + x] +
image[y * width + (x - 1)] +
image[y * width + x + 1];
98
Tendências e Técnicas em Realidade Virtual e Aumentada
}
}
Assume-se que a imagem de entrada está representada em escala de cinza, tendo
apenas um canal. O argumento image contém um ponteiro para uma região de memória
global com a imagem de entrada. Os pixels estão dispostos em ordem row-major, isto é,
com uma linha seguida da outra. O resultado do operador de Laplace é armazenado na
região de memória global apontada pela argumento laplacian.
O kernel apresentado executa sobre um espaço de índices de duas dimensões, conforme pode ser observado pelas chamadas a get_global_id e get_global_size,
sendo estas dimensões correspondentes às dimensões da imagem de entrada. Os workitems designados para o contorno da imagem não realizam trabalho algum, pois não possuem uma vizinhança completa.
Conforme visto na Seção 3.3.2, os work-items de um kernel executam em paralelo, de acordo com a capacidade dos device em uso. Kernels como o apresentado tem
potencial para obter grandes vantagens com o uso de GPUs modernas, dada a grande
quantidade de processadores com que estas são equipadas, e dado o número de cálculos
que devem ser feitos para uma imagem inteira. Acelerando passos simples como este,
algoritmos de visão computacional podem dedicar mais esforço à detecção de pontos de
interesse em si, o que muitas vezes requer heurísticas complexas para uma operação robusta.
3.9. Aspectos de otimizações através da OpenCL
A otimização do desempenho de aplicações através do uso de OpenCL pode ser classificada em dois níveis:
1. Otimização da solução do problema.
2. Otimização para uma arquitetura específica.
No primeiro nível, a otimização consiste na paralelização das tarefas necessárias
para encontrar a solução do problema. É importante que o desenvolvedor saiba identificar
pontos de paralelismo e atividades que podem ser executadas simultaneamente. Além
disso, é igualmente importante saber determinar os pontos de sincronização necessários
para a consistência dos dados. Esta fora do escopo deste trabalho tratar sobre a identificação de atividades paralelizáveis.
O segundo nível de otimização consiste na escrita de kernels que tomem proveito
da forma como a arquitetura OpenCL é mapeada para um device (ou uma família de
devices específicos). Em GPUs, o acesso aos diferentes espaços de endereçamento da
memória possui tempos de latência variados, e alguns espaços possuem caches associadas, enquanto outros não. É o caso, por exemplo, das GPUs da arquitetura CUDA da
99
Tendências e Técnicas em Realidade Virtual e Aumentada
NVIDIA. Nestas GPUs, a latência de acesso à memória global é grande quando comparada com a latência de acesso à memória local. Além disso, a memória local possui
caches em tais GPUs [NVIDIA Corporation 2009]. Cabe ao desenvolvedor buscar a documentação disponibilizada pelo fabricante de cada dispositivo a respeito da otimização
de kernels OpenCL para as suas arquiteturas.
3.10. Considerações finais
A OpenCL possibilita a utilização do poder de processamento de CPUs e GPUs para programação de alto desempenho em ambientes computacionais heterogêneos. Por ser um
padrão livre de royalties e aberto, tem se mostrado, na sua curta existência, ser uma grande
alternativa às linguagens e ferramentas específicas de fabricante. Usando OpenCL, o desenvolvedor não mais necessita mapear soluções para APIs gráficas para utilizar GPUs,
sendo o foco da programação na implementação de funções OpenCL escritas em C, que
são executadas em múltiplas instâncias simultâneas em diferentes processadores.
Este material procurou introduzir os conceitos-chave e explorar a arquitetura definida no padrão OpenCL, apresentando exemplos práticos, com códigos e o funcionamento
da plataforma, comparando com código sequencial e APIs gráficas. Espera-se que o leitor, hobista e profissional de áreas que demandam paralelismo na solução de problemas,
possa fazer uso dos conhecimentos e códigos deste material para resolver seus problemas de forma mais rápida e eficiente, bem como formar opinião a respeitos de problemas
que não aquirem ganho de desempenho ao utilizar GPUs. Para programadores OpenGL,
apresentou-se uma breve introdução sobre a sinergia entre OpenGL e OpenCL, que podem e devem conviver juntas para prover soluções mais robustas, principalmente onde
houver o envolvimento de renderização.
Para manter um canal de comunicação entre os leitores e os autores, a V3D
Studios disponibiliza todo este material, código-fonte e dicas de programação no site
http://labs.v3d.com.br. Além disso, este site está sendo preparado para prover
informações, código aberto e listas de discussão para os interessados no tema de processamento de alto desempenho usando processadores multicore e many-core em conjunto.
Referências
[Apple Inc. 2009] Apple Inc. (2009). OpenCL Programming Guide for Mac OS
X.
http://developer.apple.com/mac/library/documentation/
Performance/Conceptual/OpenCL_MacProgGuide/Introduction/
Introduction.html.
[ATI 2010] ATI (2010).
OpenCL: The Open Standard for Parallel Programming of GPUs and Multi-core CPUs. http://ati.amd.com/technology/
streamcomputing/opencl.html.
[ISO 2005] ISO (2005). ISO/IEC 9899:1999 - C language specification. http://www.
open-std.org/JTC1/SC22/wg14/www/docs/n1124.pdf.
100
Tendências e Técnicas em Realidade Virtual e Aumentada
[Khronos Group 2009] Khronos Group (2009). The OpenCL Specification. http://
www.khronos.org/registry/cl/specs/opencl-1.0.48.pdf.
[Khronos Group 2010] Khronos Group (2010). OpenCL - The open standard for parallel programming of heterogeneous systems. http://www.khronos.org/
opencl/.
[NVIDIA Corporation 2009] NVIDIA Corporation (2009).
NVIDIA OpenCL
Best
Practices
Guide.
http://www.nvidia.com/content/
cudazone/CUDABrowser/downloads/papers/NVIDIA_OpenCL_
BestPracticesGuide.pdf.
[NVIDIA Corporation 2010] NVIDIA Corporation (2010). OpenCL: Developer Zone.
http://developer.nvidia.com/object/opencl.html.
101

Documentos relacionados

clica para - Portugal-a

clica para - Portugal-a no espaço de tempo definido tenta-se fazer o maior número possível de programas e soluções informáticas com um determinado propósito.

Leia mais