Computação paralela com GPUs

Computação paralela com CUDA (parte I)

Thomas Freud
luisfredgs

--

Ao longo de duas décadas os microprocessadores baseados em uma unidade central de processamento (CPU-núcleo) apresentavam incremento de performance e redução de custos computacionais a cada nova geração. Entretanto, devido a limitações tais como consumo de energia e problemas relativos à dissipação de calor, além da impossibilidade física de seguir diminuindo o tamanho de transistores de forma constante, essa tendência de aumento de potência a cada geração de processadores cessou. Dado esse novo cenário, os fabricantes passaram a contornar essa limitação incrementando o número de núcleos dos microprocessadores, daí vêm termos como dual-core, quad-core, octa-core, etc.

Antes da introdução de múltiplos núcleos de processamento, os programas de computador eram codificados de forma exclusivamente serial — considerando o caso das CPUs — tal como o leitor provavelmente aprendeu em um curso tradicional de introdução a programação (procedural), uma sequência de instruções escritas na ordem em que serão executadas e uma de cada vez, afinal, processadores de um só núcleo são como uma linha de produção com apenas uma máquina trabalhando. Com a introdução de mais núcleos, seria como se a planta dessa fábrica incluísse outras máquinas, dividindo o fluxo da esteira de produtos entre elas (ou dividindo tarefas) e, obviamente, com mais máquinas trabalhando, a produção é acelerada. Daí, abordam-se conceitos como computação paralela e concorrência.

À parte das CPUs, existem as unidades gráficas de processamento (GPU). Diferentemente de uma CPU, uma GPU é repleta de núcleos capaz de processar milhares de cálculos de forma simultânea, ou seja, em paralelo. Foram inicialmente criadas para processar pixels, gerando gráficos em duas e três dimensões. Por outro lado, com a limitação da computação serial, a computação paralela foi sendo cada vez mais empregada em computação científica, devido justamente a essa capacidade de processar grandes volumes de dados de forma muito rápida.

Como os cálculos computacionais processados em arquitetura baseada no emprego de CPU são executados de forma sequencial, uma questão que vem então à baila é: como são codificados os programas a serem executados em processadores de múltiplos núcleos? A resposta para essa pergunta nos leva ao conceito de programação paralela.

Programação Sequencial e Programação Paralela

A programação que aqui estou chamando de sequencial, é aquele paradigma tradicional, onde o problema é dividido em pequenas partes as tarefas relativas a cada uma das partes são executadas em sequência, em um fluxo contínuo até a conclusão do processo.

Fonte: Professional Cuda C programming, por Cheng, Grossman e McKercher.

Quando fazemos essa partição do problema em um conjunto de tarefas a serem computadas, em geral, a relação entre duas dessas tarefas pode ser de duas formas, (1) as tarefas entre as quais existe uma relação de precedência e (2) tarefas que podem ser executadas de forma independente. Um programa que contém a segunda abordagem é o que se chama de programa paralelo. Note que, não necessariamente, um programa paralelo será composto totalmente de tarefas independentes, em geral, algumas partes serão paralelas e outras sequenciais, conforme o diagrama abaixo. Portanto, haverá partes com execução paralela e partes com execução sequencial.

Fonte: Professional Cuda C programming, por Cheng, Grossman e McKercher.

Paralelismo

O paralelismo pode ser de dois tipos: o paralelismo de tarefa (task parallelism) e paralelismo de dados (data parallelism). O paralelismo de tarefa acontece quando se tem várias partes independentes de um programa que podem ser executadas em paralelo. O paralelismo de dados, por outro lado, ocorre quando há uma quantidade de dados que podem ser processados, individualmente e de forma simultânea.

Pensemos em uma linha de produção que envaza refrigerantes. Pode-se produzir o refrigerante e fabricar as garrafas de forma independente, isso seria o paralelismo de tarefas. Por outro lado, imagine o processo de envasamento da bebida. Se a indústria tem apenas uma máquina de envazar, só poderá encher uma garrafa por vez, mas se ela compra mais duas maquinas e instala duas esteiras a mais, ela poderá dividir as garrafas a serem envasadas (dados) em três filas, e cada máquina vai preencher uma porção das garrafas, essa seria uma analogia ao paralelismo de dados.

Um programa que envolve o paralelismo de dados emprega o uso de threads — uma porção de código que executa determinada tarefa — para mapear o fluxo de dados a ser processado dividindo em partes que podem ser executados de forma equivalente e independente. Pense em nosso exemplo do envasamento das garrafas, o total de garrafas a serem envasadas é dividido em três fluxos e cada máquina (thread) é responsável por processar parte das garrafas. Esse tipo de particionamento dos dados é chamado de block partioning, no qual muitos pontos consecutivos de dados são agrupados juntos e então processados por um thread (máquina de envasar). Há também o particionamento cíclico (cyclic partioning), onde um thread pode processar mais de um bloco de dados adjacentes. A arquitetura que nomeia esse tipo de paralelismo é Single Instruction, Multiple Thread (SIMT), ou seja, temos um programa que consiste em múltiplos threads executando a mesma porção de código em vários blocos de dados.

Fonte: Professional Cuda C programming, por Cheng, Grossman e McKercher.

Arquitetura heterogênea

Em geral, quando consideramos computação paralela, um programa não será totalmente paralelo, mas sim algo mais de acordo com a segunda figura apresentada no texto, partes do programa serão executadas na CPU (host) e partes na GPU (device), usufruindo da vantagem decorrente da combinação de cada tipo de processador, afinal, existem problemas que são executados de forma mais eficiente em uma CPU, e outros, como manipular grandes quantidades de dados, em GPU, alguns problemas simplesmente não podem ser implementados usando computação paralela.

A comunicação entre os processadores se dá através de uma conexão PCI Express Bus e, ao longo da execução do programa, os fluxos de tarefas são transferidos entre um processador e outro, sempre que o programa chegar em uma linha de código que assim o faça. Por conta disso, teremos duas porções de código: (1) host code, a ser executado pela CPU; e (2) device code, a ser executado pela GPU. O host code é responsável pela execução da aplicação e transferência de fluxo de atividade para a GPU. Escrever programas combinando os processadores de modo a empregá-los em tarefas adequadas à suas arquiteturas é a melhor forma de maximizar o desempenho da aplicação, extraindo o melhor de cada um.

Fonte: Professional Cuda C programming, por Cheng, Grossman e McKercher.

Como a computação paralela não substitui a computação serial em todas as situações, é preciso identificar quando combinar as abordagens para maximizar a performance do programa. Em geral, quando a quantidade de dados é pequena e o algoritmo é de forma tal que existem poucas oportunidades de paralelizá-lo, então escrever um código para ser executado na CPU é uma escolha inteligente. Entretanto, se a tarefa for razoavelmente paralelizável e envolver grandes quantidades de dados, implementar aquela porção do código de forma que seja executado em uma GPU pode ser muito benéfico, sob o ponto de vista da performance.

Fonte: Professional Cuda C programming, por Cheng, Grossman e McKercher.

O que é CUDA?

Cuda é uma plataforma de computação paralela que possibilita o emprego de GPUs da Nvidia para a resolução de cálculos computacionais em atividades que vão além da computação gráfica, tais como a computação científica. Consiste em uma extensão da linguagem C, por assim dizer, sendo um conjunto de interfaces que abstrai camadas mais complexas que envolvem o uso de processadores gráficos, oferecendo um ambiente intuitivo e relativamente fácil de usar. Existe uma variedade de linguagens compatíveis, tais como C++, C e Fortran, python etc. CUDA traz também uma vasta quantidade de bibliotecas para computação numérica voltada para simulações (CURAND) e álgebra linear (CUBLAS).

Em geral, um programa CUDA é composto de duas partes de código: (1) host code, executado na CPU; e (2) device code, executado na GPU. No mais, uma vez que o usuário tenha instalado os pacotes Nvidia CUDA e o ambiente esteja configurado, é relativamente fácil escrever um programa CUDA.

Leitura recomendada

Para a parte II desse texto acesse aqui.

Para orientações sobre como instalar e configurar Nvidia Cuda o leitor poder checar <https://developer.nvidia.com/cuda-zone>.

Trabalhar com computação paralela requer um pouco mais de conhecimento sobre a arquitetura de computadores e demais detalhes dessa natureza, não é salutar apenas ler códigos e tentar replicá-los, o leitor precisará entender esses conceitos para avançar na área e, sobretudo, para criar aplicações com o máximo de eficiência.

Portanto, recomendo também o livro de (Cheng, Grossman e McKercher) Professional Cuda C programming; para uma leitura bem equilibrada que aborda de forma direta as interfaces CUDA.

O trabalho de (Kirk e Hwu) Programming Massively Parallel Processors, o qual trata com profundidade e fluidez os aspectos teóricos da computação paralela com CUDA.

Para uma leitura mais direta, recomendo o excelente livro de (Sanders e Kandrot) CUDA By Example.

--

--

Thomas Freud
luisfredgs

PhD Student, Actuary and master in statistics and probability | Accounting bachelor's degree.