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
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