84
UNIVERSIDADE DO VALE DO ITAJAÍ CENTRO DE CIÊNCIAS TECNOLÓGICAS DA TERRA E DO MAR CURSO DE CIÊNCIA DA COMPUTAÇÃO ANÁLISE COMPARATIVA DE ALGORITMOS NP-COMPLETO EXECUTADOS EM CPU E GPU UTILIZANDO CUDA por Elcio Arthur Cardoso Itajaí (SC), novembro de 2012

UNIVERSIDADE DO VALE DO ITAJAÍ CENTRO DE CIÊNCIAS ...siaibib01.univali.br/pdf/Elcio Arthur Cardoso.pdf · soluções alternativas para o desenvolvimento dos algoritmos. A primeira

  • Upload
    others

  • View
    4

  • Download
    0

Embed Size (px)

Citation preview

UNIVERSIDADE DO VALE DO ITAJAÍ

CENTRO DE CIÊNCIAS TECNOLÓGICAS DA TERRA E DO MAR

CURSO DE CIÊNCIA DA COMPUTAÇÃO

ANÁLISE COMPARATIVA DE ALGORITMOS NP-COMPLETO

EXECUTADOS EM CPU E GPU UTILIZANDO CUDA

por

Elcio Arthur Cardoso

Itajaí (SC), novembro de 2012

UNIVERSIDADE DO VALE DO ITAJAÍ

CENTRO DE CIÊNCIAS TECNOLÓGICAS DA TERRA E DO MAR

CURSO DE CIÊNCIA DA COMPUTAÇÃO

ANÁLISE COMPARATIVA DE ALGORITMOS NP-COMPLETO

EXECUTADOS EM CPU E GPU UTILIZANDO CUDA

Área de Computação Paralela GPGPU

por

Elcio Arthur Cardoso

Relatório apresentado à Banca Examinadora

do Trabalho Técnico-científico de Conclusão

do Curso de Ciência da Computação para

análise e aprovação.

Orientador: Rafael de Santiago, M.Sc

Itajaí (SC), novembro de 2012

À Lúcia Cardoso, minha querida mãe.

AGRADECIMENTOS

Agradeço primeiramente a Deus por me dar sanidade e força suficiente para alcançar

as minhas metas e objetivos de vida.

A minha família, minha mãe Lúcia Cardoso, por acreditar no meu potencial e investir

em meus estudos e minha avó Venina P. Cardoso que passou esses anos aturando meus

momentos de estresse.

A minha companheira Flânery Demarche Fumagalli, por entender meus momentos de

ausência durante a realização deste trabalho e pela força dada nos bons e maus momentos.

A todos os mestres que puderam me passar um pouco do seu conhecimento neste

período, em especial ao professor e amigo Rafael de Santiago, que fez total diferença no

desenvolvimento e resultado final deste projeto.

Aos amigos de curso pela ajuda fornecida durante toda nossa formação e pela rica

troca de conhecimentos, em especial ao Alexandre Corsi, Cristiano Elias, Filipe Bernardi,

Paulo Eduardo Krieger, Marcus Simas e Matheus Weber da Conceição. Agradeço também ao

amigo Willian Da Silva que emprestou sua NVIDIA GeForce GTX 670 utilizada nos testes

deste trabalho e ao Tiago Carneiro que me ajudou com algumas dúvidas em relação a

arquitetura CUDA.

Agradeço ainda aos amigos de trabalho da H2K e SRSul, que de alguma forma

puderam contribuir para conclusão deste.

A todos o meu sincero muito obrigado!

“Eu tenho uma porção de coisas grandes prá conquistar e eu não posso ficar aí parado...”

Raul Seixas – “Ouro de Tolo”

RESUMO

CARDOSO, Elcio Arthur. Análise comparativa de algoritmos NP-Completo executados

em CPU e GPU utilizando CUDA. Itajaí, 2012. 84 f. Trabalho Técnico-científico de

Conclusão de Curso (Graduação em Ciência da Computação) – Centro de Ciências

Tecnológicas da Terra e do Mar, Universidade do Vale do Itajaí, Itajaí, 2012.

A busca constante por maior poder computacional, sempre esteve presente na computação.

Atualmente, limites físicos de aumento de frequência das CPUs levaram à criação de

arquiteturas paralelas compostas por milhares de unidades de processamento como ocorre nas

arquiteturas das GPUs. O presente projeto tem como objetivo utilizar este poder

computacional para resolver problemas incluídos na classe NP-Completo, problemas estes

que apresentam uma taxa de crescimento em tempo de execução exponencial, podendo levar

anos para serem resolvidos. O projeto também pretende posicionar a arquitetura utilizada pela

fabricante NVIDIA em suas GPUs, CUDA, em relação a estes problemas. O trabalho foi

fundamentado em livros, pesquisas acadêmicas e artigos científicos através dos quais foram

desenvolvidos algoritmos para resolução dos problemas Clique Máximo do Grafo e Cobertura

Mínima de Vértices, programados sequencialmente para serem executados em CPU onde

foram analisados e comparados com a implementação em CUDA, que buscou paralelizar os

cálculos feitos para atingir a solução dos problemas da forma mais eficiente possível. Para

uma árvore de backtracking, os algoritmos sequenciais abordam nó a nó e apresentam uma

taxa de crescimento exponencial representada pela complexidade de tempo O(n²), porém,

neste trabalho, os algoritmos paralelos em CUDA buscaram abordar cada nível da árvore

paralelamente em apenas uma unidade de tempo para assim reduzir o tempo total da execução

do algoritmo, simulando o dispositivo teórico dos algoritmos não determinísticos. Esta

solução não pode ser utilizada devido ao uso exponencial de memória o que levou a busca de

soluções alternativas para o desenvolvimento dos algoritmos. A primeira solução foi

paralelizar as operações feitas com os vetores, porém não obteve bons resultados, na qual foi

apresentada uma nova solução, onde os grafos foram divididos em “n” subgrafos, para “n”

igual ao número de vértices, e esta por sua vez se mostrou bastante eficiente na arquitetura

CUDA.

Palavras-chave: CUDA. NP-Completo. Computação Paralela.

ABSTRACT

Constant researches for high-performance computing, which has existed since the firsts

calculating machines to modern current computers and physical limitations of CPUs

frequency increases, led to the creation of parallel architectures composed of thousands of

processing units as occur in the GPUs architectures. This project aims to use this high-

performance computing to solve problems in class NP-Complete, problems that have a

growth rate in exponential runtime, which can take years to been solved. The project also

aims to position the architecture used by the manufacturer of NVIDIA GPUs, CUDA, in

relation to these problems. The work was based on books, academic research and scientific

papers and algorithms for solving the Maximum Clique of a Graph and Minimum Vertex

Cover, that have been sequentially developed to run on CPU in order to be analyzed and

compared with the CUDA implementation, which tried to parallelize the calculations to

achieve the solution of problems as efficiently as possible. In backtracking algorithms, a

sequential approach travels node by node and have an exponential growth rate that is

represented by the complexity function O(n²), but in this work, a parallel CUDA version of the

algorithms tried to solve each level of the tree in parallel on a single time unit to reduce the

total execution time of the algorithm, simulating the theoretical device of non-deterministic

algorithms. This solution can not be used due to exponential memory usage which prompted

the search for alternative solutions to the development of algorithms. The first found solution

was made by parallelizing the operations with arrays, but did not get good results, in which

was presented a new solution, where the graphs were split into “n” subgraphs, for “n”

equals to the number of vertices, and this solution showed efficient results in the CUDA

architecture.

Keywords: CUDA. NP-Complete. Parallel Computing.

LISTA DE FIGURAS

Figura 1. Entradas de pior e melhor caso para algoritmos de ordenação ................................. 22

Figura 2. Lógica do Algoritmo Insertionsort ............................................................................ 23 Figura 3. Taxa de crescimento polinomial (7n

3+5) e exponencial (2

n) .................................... 24

Figura 4. Classes P, NP e NP-Completo (se P diferente de NP) .............................................. 25 Figura 5. Árvore para um algoritmo determinístico ................................................................. 26 Figura 6. Árvore para um algoritmo não determinístico .......................................................... 26

Figura 7. Grafo G com os vértices s e t .................................................................................... 28 Figura 8. Possibilidades da relação P e NP .............................................................................. 30

Figura 9. Grafo G com o Caminho Hamiltoniano .................................................................... 31 Figura 10. Transformação polinomial do problema X no problema Y .................................... 32 Figura 11. K-clique de tamanho 4 ............................................................................................ 33 Figura 12. Clique Máximo do grafo ......................................................................................... 34 Figura 13. Coberturas de Tamanho K ...................................................................................... 34

Figura 14. Cobertura Mínima de um Grafo .............................................................................. 35 Figura 15. Taxonomia de Flynn ............................................................................................... 36 Figura 16. Distribuição dos transistores em CPUs e GPUs ...................................................... 41 Figura 17. Arquitetura CUDA .................................................................................................. 42

Figura 18. Memórias da arquitetura CUDA ............................................................................. 45 Figura 19. Execução de um programa de computação paralela ............................................... 47

Figura 20. Árvore element e step complexity ........................................................................... 48

Figura 21. Tempo de execução na CPU e GPU ....................................................................... 51

Figura 22. Desempenho do problema da mochila .................................................................... 51 Figura 23. Tempo mínimo, médio e máximo das execuções do Caixeiro Viajante ................. 53

Figura 24. Taxa mínima, média e máxima de ganho de velocidade ........................................ 53 Figura 25. Grafo exemplo e os conjuntos V, A e B ................................................................. 57 Figura 26. Árvore de backtracking ........................................................................................... 58

Figura 27. Árvore de chamadas da função MaxClique ............................................................ 59 Figura 28. Estratégia inicial de NP-Completo em CUDA........................................................ 63 Figura 29. Função vértices por megabytes de memória utilizados na estratégia inicial .......... 64

Figura 30. Caminhos e chamadas exponenciais dos kernels .................................................... 64 Figura 31. Divisão do grafo em n subgrafos ............................................................................ 66

Figura 32. Resultados da execução do clique do grafo ............................................................ 70

LISTA DE QUADROS

Quadro 1. Algoritmo não determinístico de busca em vetor .................................................... 27

Quadro 2. Exemplo da chamada de um kernel ......................................................................... 44 Quadro 3. Kernel N blocos com 1 thread ................................................................................. 44 Quadro 4. Kernel 1 bloco com N threads ................................................................................. 44 Quadro 5. Penalidade de acesso à memória ............................................................................. 46 Quadro 6. Complexidade step e elemento dos algoritmos ....................................................... 49

Quadro 7. Quadro comparativo dos trabalhos similares........................................................... 54 Quadro 8. Logica MaxClique ................................................................................................... 56

Quadro 9. Lógica CobMin ........................................................................................................ 60 Quadro 10. Cópia de vetor na CPU .......................................................................................... 65 Quadro 11. Cópia de vetor na GPU para N threads ................................................................. 65 Quadro 12. Grafos utilizados para teste.................................................................................... 69 Quadro 13. Resultados da execução do clique do grafo ........................................................... 71

Quadro 14. Algoritmo Clique Máximo do Grafo - Função MaxClique ................................... 78 Quadro 15. Algoritmo Cobertura Mínima de Vértices - Função CobMin ............................... 79 Quadro 16. Algoritmo Cobertura Mínima de Vértices - Função getCoberturaAtual ............... 80 Quadro 17. Algoritmo Paralelizando Operações do Clique Máximo do Grafo em CUDA ..... 82

Quadro 18. Algoritmo Subgrafos do Clique Máximo do Grafo em CUDA ............................ 84

LISTA DE EQUAÇÕES

Equação 1 ................................................................................................................................. 59

Equação 2 ................................................................................................................................. 59 Equação 3 ................................................................................................................................. 61 Equação 4 ................................................................................................................................. 61 Equação 5 ................................................................................................................................. 63

LISTA DE ABREVIATURAS E SIGLAS

CAM Problema do Caminho

CAMHAM Problema do Caminho Hamiltoniano

CPU Central Processing Unit

CUDA Compute Unified Device Architecture

GPGPU General-purpose computing on Graphics Processing Units

GPU Graphics Processing Unit

MIMD Multiple Instruction, Multiple Data

MPI Message Passing Interface

MISD Multiple Instruction, Single Data

ms Milissegundo(s)

NP Non-Deterministic Polynomial time

OpenCL Open Computing Language

P Deterministic Polynomial time

SAT Satisfazibilidade

SIMD Single Instruction, Multiple Data

SISD Single Instruction, Single Data

SLI Scalable Link Interface

SM Streaming Multiprocessor

TTC Trabalho Técnico-científico de Conclusão de Curso

UNIVALI Universidade do Vale do Itajaí

DIMACS Center for Discrete Mathematics and Theoretical Computer Science

LISTA DE SÍMBOLOS

O Ômicron, cota superior assintótica

Ω Ômega, cota inferior assintótica

Θ Theta, cota de equivalência assintótica

∑ Somatório

SUMÁRIO

1 INTRODUÇÃO ............................................................................................................................ 15

1.1 PROBLEMATIZAÇÃO ..................................................................................... 18

1.1.1 Formulação do Problema ............................................................................... 18

1.1.2 Solução Proposta ............................................................................................. 18

1.2 OBJETIVOS ........................................................................................................ 18

1.2.1 Objetivo Geral ................................................................................................. 18

1.2.2 Objetivos Específicos ...................................................................................... 18

1.3 METODOLOGIA ............................................................................................... 19

1.4 ESTRUTURA DO TRABALHO ....................................................................... 19

2 FUNDAMENTAÇÃO TEÓRICA ................................................................................................ 21

2.1 TEORIA DA COMPLEXIDADE ...................................................................... 21

2.1.1 Análise de Complexidade ............................................................................... 21

2.1.2 Classes de Complexidade................................................................................ 24

2.2 PROGRAMAÇÃO PARALELA ....................................................................... 35

2.2.1 Taxonomia de Arquiteturas ........................................................................... 36

2.2.2 Memória ........................................................................................................... 37

2.2.3 Evolução da GPU ............................................................................................ 38

2.2.4 Métricas de Complexidade Paralela .............................................................. 46

2.3 TRABALHOS QUE UTILIZAM CUDA E PROBLEMAS NP-

COMPLETO .............................................................................................................. 49

2.3.1 Solving NP-Complete Problems on the CUDA Architecture using

Genetic Algorithms ................................................................................................... 49

2.3.2 A Parallel GPU Version of the Traveling Salesman Problem .................... 52

2.3.3 Comparativo entre os trabalhos similares .................................................... 54

3 DESENVOLVIMENTO ............................................................................................................... 55

3.1 ALGORITMOS NP-COMPLETO PARA CPU .............................................. 55

3.1.1 Clique Máximo do Grafo ................................................................................ 55

3.1.2 Cobertura Mínima de Vértices ...................................................................... 60

3.2 ALGORITMOS NP-COMPLETO PARA GPU .............................................. 61

3.2.1 Soluções alternativas para GPU .................................................................... 65

3.3 TESTES E RESULTADOS ................................................................................ 67

3.3.1 Ambiente .......................................................................................................... 68

3.3.2 Grafos ............................................................................................................... 68

3.3.3 Resultados ........................................................................................................ 69

4 CONCLUSÕES ............................................................................................................................ 72

APÊNDICE A. ALGORITMOS PARA CPU ...................................... 78

A.1. CLIQUE MÁXIMO DO GRAFO ................................................ 78

A.2. COBERTURA MÍNIMA DE VÉRTICES .................................. 79

APÊNDICE B. ALGORITMOS PARA GPU ...................................... 81

B.1. CLIQUE MÁXIMO DO GRAFO – PARALELIZANDO

OPERAÇÕES ............................................................................................ 81

B.2. CLIQUE MÁXIMO DO GRAFO – SUBGRAFOS ................... 82

15

1 INTRODUÇÃO

A demanda por computadores cada vez mais rápidos nos mais diversos contextos

comerciais e científicos: para cálculos astronômicos, projetos de modelos aeronáuticos, etc.,

existe desde o Eniac, até hoje, com máquinas um milhão de vezes mais rápidas.

(TANENBAUM, 2003).

A partir de 1980, o principal método de aumento de desempenho nas CPUs (Central

Processing Units) ocorreu devido a incrementos na frequência de operação. Seguindo com

essa evolução, barreiras foram encontradas no aumento da frequência nas CPUs, tais como:

alto consumo de energia, restrições de calor e também pelo limite físico de fabricar resistores

menores do que os atuais. Com estas limitações, pesquisadores e fabricantes procuraram por

outros modelos para se ganhar desempenho (TANENBAUM, 2003).

Com a existência de um limite físico quanto ao projeto de processadores mais velozes,

fabricantes aumentaram o número de processadores como alternativa de suprir a demanda de

desempenho em tempo de processamento. O que tornou comum o uso de supercomputadores

multiprocessados com até milhares de processadores trabalhando em conjunto (SANDERS;

KANDROT, 2010).

Juntamente com a evolução das CPUs, as GPUs (Graphics Processing Unit) também

passaram por uma grande evolução, motivada principalmente por grandes investimentos no

mercado de jogos, principalmente focada em uma arquitetura com um maior numero de

circuitos de processamento do que para cache, as GPUs apresentam um crescimento, de

desempenho, superior a duas vezes ao ano e mil vezes em uma década, superando assim a Lei

de Moore aplicável para as CPUs. (HARRIS, 2005; VIANA, 2011).

Em 1970 surgiram os primeiros aceleradores gráficos 2D, de 1995 a 1998 os 3D que

tiveram um papel importante no desenvolvimento das GPUs. Com o passar dos anos deixaram

de ser exclusivamente para processamento gráfico dando lugar as GPUs programáveis,

passando a ser possível o aproveitamento do enorme poder computacional paralelo. Com

isso, segundo Ikeda (2011) pode-se dividir a evolução das GPUs em gerações:

Primeira Geração: a placa de vídeo era vista como o dispositivo que servia

apenas para enviar dados para o monitor, sem qualquer processamento.

16

Segunda Geração: surgimento da Primeira GPU onde funcionalidades

anteriormente executadas na CPU passaram para GPU. Ex.: transformações

geométricas.

Terceira Geração: primeiras GPUs programáveis utilizando linguagem de

montagem.

Quarta Geração: GPUs capazes de executar milhares de instruções e utilizar

variável de ponto flutuante, possibilitando as implementações de propósito

geral GPGPU (General-Propose GPU).

Quinta Geração: surgimento das arquiteturas e linguagens de programação

exclusivas para as GPUs.

Em novembro de 2006 a fabricante NVIDIA lançou a GeForce 8800 GTX, a primeira

GPU construída com a arquitetura CUDA (Compute Unified Device Architecture), que

apresentou soluções a várias limitações que impediam que as placas anteriores fossem

legitimamente usadas para computação de proposito geral, consequentemente apresentando

alta performance para aplicações heterogêneas com a utilização das unidades central e gráfica

de processamento (SANDERS; KANDROT, 2010).

Atualmente pode-se encontrar as novas placas gráficas da NVIDIA desenvolvidas

sobre a arquitetura Fermi contendo mais de três bilhões de transistores e 512 processadores

que constitui a programação paralela de forma fácil e com desempenho em uma ampla gama

de aplicações como ray tracing, analise de elementos finitos, alta precisão cientifica, álgebra

linear, algoritmos de busca entre outros (NVIDIA, 2012).

A pesquisa por desempenho não é realizada somente no Hardware. Existem áreas da

Ciência da Computação que estudam a questão em algoritmos como, por exemplo, a Teoria

da Complexidade. Esta área busca investigar e compreender o porquê de algoritmos terem

características de consumo de recursos (geralmente espaço em memória e tempo) (SIPSER,

2007).

De acordo com Sipser (2007), uma das maiores contribuições que os pesquisadores da

área já realizaram é a definição de um esquema de classificação de algoritmos, onde se é

possível posicioná-los de acordo com seu grau de complexidade.

17

Dentre estas classes, cita-se a classe P, formada pelos problemas que podem ser

resolvidos em tempo polinomial com algoritmo determinístico. Outro exemplo importante é a

classe NP, que é composta por problemas que podem ser verificados em tempo polinomial e

que podem ser resolvidos por algoritmo não-determinístico em tempo polinomial, incluindo

então, diversos problemas que são resolvidos em tempo exponencial. Para a Ciência da

Computação teórica e matemática contemporânea a questão de "se P = NP?" é um dos

maiores problemas não resolvidos. Caso essas classes sejam iguais, qualquer problema

polinomialmente verificável seria polinomialmente “decidível” (SIPSER, 2007).

Os algoritmos da classe NP-Completo (NP-Completo NP) são grandes motivadores

da computação de alto desempenho. De acordo com Arora e Barak (2009), algoritmos desta

classe, possuem complexidade exponencial e podem ser reduzidos polinomialmente a

problemas da mesma classe. São exemplos de problemas desta classe: cobertura de vértices,

k-coloração, clique do grafo, caminho hamiltoniano, caixeiro viajante, substituição de

expressões regulares, chave de cardinalidade mínima, escalonamento em multiprocessadores,

entre outros (GAREY; JOHNSON,1979).

O projeto analisou o desempenho de dois algoritmos exatos para problemas NP-

Completo ao serem executados sequencialmente em uma CPU e paralelamente em uma GPU

NVIDIA utilizando a arquitetura CUDA, unindo dois temas: problemas computacionalmente

intratáveis e uma das tecnologias responsáveis por maior desempenho computacional.

Para isso foram implementados os algoritmos para a arquitetura CUDA identificando

abordagens na qual relacionem a arquitetura com os problemas que compõem a classe NP-

Completo conforme as estratégias descritas no projeto.

Após a codificação dos algoritmos, os mesmos passaram por uma etapa de testes, na

qual grafos modelos foram executados em ambos ambientes para coleta das estatísticas de

execução. Os resultados obtidos foram analisados, comparados e documentados.

18

1.1 PROBLEMATIZAÇÃO

1.1.1 Formulação do Problema

Problemas da classe NP-Completo são clássicos na área de computação e a busca por

soluções cada vez mais eficientes é constante, porém esta busca é regida pela evolução dos

computadores e do poder computacional existente, o que parece ser insuficiente para

resolução destes problemas em tempo polinomial.

Os últimos avanços nas arquiteturas das GPUs permitem que problemas da classe NP-

Completo sejam beneficiados com esta arquitetura, porém não existem definições de como os

problemas podem ser abordados para obtenção de bons resultados.

1.1.2 Solução Proposta

Para solucionar o problema identificado, este trabalho propõe codificar dois problemas

NP-Completo em CPU e em GPGPU utilizando CUDA, fazendo uma análise comparativa

entre eles a fim de identificar padrões de desenvolvimento que possam relacionar CUDA e

NP-Completo posicionando a arquitetura em relação aos problemas desta classe.

1.2 OBJETIVOS

1.2.1 Objetivo Geral

Analisar e posicionar a tecnologia CUDA em relação a problemas NP-Completo.

1.2.2 Objetivos Específicos

Ter selecionado dois problemas NP-Completo para as análises;

Finalizar a codificação dos problemas NP-Completo, utilizando técnicas de

programação paralela CUDA;

Concluir a análise comparativa a partir dos resultados das execuções em CPU e

CUDA;

Posicionar o potencial da tecnologia CUDA no contexto de problemas NP-

Completo.

19

1.3 Metodologia

Este trabalho foi divido em cinco etapas: (i) fundamentação do projeto e pesquisas de

trabalhos similares; (ii) implementação dos algoritmos para CPU; (iii) estratégias de

desenvolvimento CUDA e suas implementações; (iv) testes e análises dos resultados; (v)

documentação do TTC.

Na primeira etapa, foram pesquisadas e conceituadas todas as informações necessárias

para a realização deste projeto, nas áreas de teoria da complexidade, problemas NP-Completo,

programação paralela e CUDA. As informações foram extraídas principalmente em trabalhos

de conclusão, dissertações de mestrados, livros e artigos.

Na segunda etapa, foram analisadas as lógicas dos algoritmos, onde os mesmo foram

implementados e documentados. Nesta etapa também foi feita a análise da complexidade dos

algoritmos desenvolvidos.

Na terceira etapa, foram documentadas estratégias que utilizadas para a

implementação dos algoritmos em CUDA baseando-se na fundamentação e nos trabalhos

similares.

Na quarta e última etapa, foram descritos a fundamentação, implementações em CPU,

implementações em GPU, testes e análises.

1.4 Estrutura do trabalho

Este documento está estruturado em quatro capítulos: (i) Introdução; (ii)

Fundamentação Teórica; (iii) Desenvolvimento; e (iv) Conclusões.

O Capítulo 1, Introdução, apresentou uma visão geral do trabalho com: problema

proposto, objetivos do projeto e a metodologia utilizada.

No Capítulo 2, Fundamentação Teórica, é apresentada uma revisão bibliográfica sobre

os assuntos envolvidos com o projeto: complexidade de algoritmos, problemas NP-Completo,

computação paralela, CUDA e artigos com propósito semelhante ao deste trabalho.

O Capítulo 3, Desenvolvimento, apresenta o projeto detalhado dos algoritmos

desenvolvidos, as estratégias utilizadas para implementação dos algoritmos na arquitetura

CUDA, testes com seus resultados e análises.

20

No Capítulo 4, Conclusões, apresentam os resultados e conhecimentos obtidos durante

o desenvolvimento do projeto. O texto ainda inclui um apêndice contendo os algoritmos

desenvolvidos que complementam as informações apresentadas no trabalho.

21

2 FUNDAMENTAÇÃO TEÓRICA

Para aquisição das informações necessárias ao desenvolvimento do projeto, foram

realizados estudos em livros, periódicos e artigos. Dentre o que foi levantado, destacam-se:

classes de complexidade, problemas NP-Completo, programação paralela, a arquitetura

CUDA e projetos similares. Os dados, informações e estudos podem ser vistos nesta seção.

2.1 Teoria da Complexidade

De acordo com Sipser (2005), a Teoria da Computação é dividida em três áreas:

Teoria de Autômatos, Teoria da Computabilidade e Teoria da Complexidade. A Teoria da

Complexidade, como subárea, está relacionada aos estudos das causas do que faz um

problema ser computacionalmente difícil de resolver ou não. Chama-se de problemas

difíceis, aqueles que consomem recursos considerável, de tempo e/ou memória.

Na Teoria da Complexidade podem-se destacar quatro temas importantes: análise da

complexidade (perspectivas otimista, média e pessimista), identificação da complexidade de

um problema, classes de complexidade e algoritmos aproximados. Estes tópicos envolvem

matemática (inclusive probabilidade), técnicas e teoremas da Teoria dos Autômatos e da

Computabilidade (subáreas da Teoria da Computação).

Neste trabalho, a complexidade está envolvida no contexto relacionado ao tema

Classes de Complexidade, por tratar-se de um estudo realizado sobre os problemas

classificados como NP-Completo. A seguir, algumas subseções são utilizadas para explicar

este conceito, suas características e algumas das principais Classes de Complexidade

(envolvidas no trabalho).

2.1.1 Análise de Complexidade

Ao desenvolver um algoritmo para resolver um problema em particular, é importante

conhecer o quanto de recurso computacional (tempo e espaço) será consumido na execução

do mesmo. Métodos matemáticos são capazes de estimar o tempo e espaço requerido por

algoritmos antes de serem implementados, o que é importante, pois pode-se compará-lo a

outros algoritmos já conhecidos. A taxa de crescimento do tempo ou espaço na execução um

algoritmo é chamada de complexidade (KREHER; STINSON, 1999).

22

Por muitas vezes dependerem da entrada, o esforço computacional não pode ser

medido por um valor, mas sim por uma função. Um exemplo disto seria um algoritmo para

ordenação de um vetor, onde o tamanho de entrada vai definir o tempo e espaço gasto para tal

tarefa. Na inversão de matrizes o número de instruções (adição e multiplicação) varia

significativamente em função do tamanho da matriz. Consequentemente a complexidade é

medida em função do número de instruções executadas por um algoritmo ou a quantidade de

memória requerida (TOSCANI; VELOSO, 2005).

Segundo Toscani e Veloso (2005) outro critério a ser observado são os casos

específicos de entradas de mesmo tamanho que exigem diferentes esforços computacionais.

No caso de ordenação de um vetor, o mesmo pode estar quase na ordem correta, ou pode estar

completamente desordenado. A abordagem onde a entrada considerada para a análise de

complexidade é dada da pior forma possível é chamado de complexidade pessimista. Assim,

como a entrada de melhor caso é chamada de otimista. Uma analise que aborda todas as

diferentes entradas fazendo uma média ponderada entre elas é chamada de complexidade

média. Um exemplo de complexidade pessimista, complexidade otimista e complexidade

média, podem ser verificados na Figura 1.

Figura 1. Entradas de pior e melhor caso para algoritmos de ordenação

Segundo Kreher e Stinson (1999), a análise do algoritmo irá descrever como o recurso de

execução se comportará em relação ao tamanho dos dados de entrada. Como exemplo na

análise do recurso tempo, é utilizado no algoritmo INSERTIONSORT (que pode ser

visualizado na Figura 2), que tem o objetivo de ordenar um vetor A de tamanho N onde: A =

[A[0], A[1], ..., A[n-1]]. Supondo que o primeiro elemento i do vetor A já esteja posicionado

na ordem correta, um laço do tipo while encontra o valor de j para A [i] e move os outros

elementos seguintes A[j+1], A[j+2] ,..., A[n-1] para dar lugar ao valor do primeiro elemento

i+1 a fim de inseri-lo na posição correta.

23

O laço while requer i - 1 iterações para cada i no laço for, em todo 1 <= i <= n - 1. No

caso o tempo de execução é T(n) que por definição é Θ(n2) assim o INSERTIONSORT é

considerado um algoritmo de complexidade quadrática.

Figura 2. Lógica do Algoritmo Insertionsort

Fonte: Adaptado de Kreher e Stinson (1999).

Considera-se algoritmos com complexidade polinomial quando a mesma é definida

por uma função que é O(nd), sendo "d" uma constante inteira e positiva. Algoritmos de

complexidade exponencial possui sua função de complexidade como Ω (cn), onde para toda

constante "c" maior do que 1 (um). Estes últimos algoritmos são considerados difíceis, pois

dada uma pequena diferença no tamanho da entrada para o problema pode significar um

consumo muito maior de recurso (HOPCROFT; MOTWANI; ULLMAN, 2001).

As diferenças polinomiais de tempo são consideradas pequenas, enquanto diferenças

exponenciais são consideradas grandes. A taxa de crescimento polinomial geralmente é algo

parecido com n3e de crescimento exponencial de 2

n. Por exemplo, considerando n o tamanho

dos dados de entrada de um determinado algoritmo onde seu valor é igual a 1000, no caso

polinomial n3 teria o resultado de 1 bilhão, um valor alto, mas computável. Por outro lado o

resultado de 2n é um numero muito maior que a quantidade de átomos no universo (SIPSER,

2007).

Segue a Figura 3 que representa a taxa de crescimento polinomial (7n²+5) e

exponencial (2n), onde o crescimento exponencial inicia com tempos (eixo y) menores, porém

aumentando o tamanho da entrada (eixo x) no ponto n0 o crescimento exponencial passa a ser

muito maior que o polinomial.

24

Figura 3. Taxa de crescimento polinomial (7n3+5) e exponencial (2

n)

2.1.2 Classes de Complexidade

Classes de complexidade são agrupamentos de problemas computacionais que podem

ser decididos utilizando o mesmo recurso computacional (ARORA; BARAK, 2006).

Para ilustrar o amplo conteúdo do tema "classes de complexidade", Aaronson (2011)

cita 417 classes diferentes, que agrupam problemas de acordo com complexidade semelhante.

No trabalho descrito por este documento são apresentadas as classes de complexidade P, NP e

NP-Completo, conforme ilustra a Figura 4 (se P diferente de NP), por possuírem relações

importantes com o trabalho.

25

Figura 4. Classes P, NP e NP-Completo (se P diferente de NP)

Fonte: Adaptado de Ziviani (2007).

Os algoritmos podem ser divididos em duas classes, determinísticos e não

determinísticos, para o primeiro o resultado de cada operação é definido de maneira única, já

o segundo é um dispositivo teórico e sua definição pode ser vista na próxima sessão

(ZIVIANI, 2007).

2.1.2.1 Algoritmos não Determinísticos

Em alguns problemas se há a necessidade de, exaustivamente, tentar-se todas as

possibilidades de configuração de um determinado grupo de vértices, variáveis ou outros

objetos. Algoritmos em geral são chamados de determinístico, pois não possuem a capacidade

de executarem estas múltiplas configurações caminhos ao mesmo tempo. Algoritmos não

determinísticos já conseguem, através de um dispositivo teórico, realizar a execução em

múltiplas configurações consumindo o tempo de apenas uma (ZIVIANI, 2007).

A Figura 5 demonstra a execução de um algoritmo determinístico em uma busca

exaustiva. A árvore da figura representa as tentativas realizadas na localização de algum

padrão, realizada através da tentativa de combinar os objetos do problema (como vértices por

exemplo). O algoritmo percorre de nó em nó, a cada unidade de tempo.

26

Figura 5. Árvore para um algoritmo determinístico

Em um algoritmo não determinístico ainda tenta-se procurar as configurações

necessárias através de tentativa e erro, mas ao contrário do algoritmo determinístico, percorre-

se a árvore consumindo apenas uma unidade de tempo a cada nível da árvore, conforme

ilustrado na Figura 6. Isto ocorre como se diversos caminhos (da raiz até as folhas) fossem

verificados de forma simultânea. Este comportamento teórico será explorado na elaboração

deste trabalho, utilizando tecnologia de programação paralela (ZIVIANI, 2007).

Figura 6. Árvore para um algoritmo não determinístico

Segundo Ziviani (2007) e Toscani e Veloso (2005), um algoritmo não determinístico é

capaz, além das operações determinísticas, de executar operações como: escolhe, falha e

sucesso.

Escolhe(C): escolhe um dos elementos do conjunto C de forma arbitrária,

consumindo uma unidade de tempo;

Falha: indica o término sem sucesso;

Sucesso: indica o término com sucesso.

27

Um exemplo de algoritmo não determinístico para uma busca do elemento x em um

vetor A, pode ser dado como ilustra o Quadro 1, onde o algoritmo não determinístico escolhe

os elementos em um conjunto, de forma simultânea, e caso a posição correspondente a este

elemento no vetor A for igual a x ele finaliza a execução com a saída sucesso, caso contrario

com a saída falha.

j <- escolhe (1..n)

se A[j] = x

então sucesso()

senão falha()

Quadro 1. Algoritmo não determinístico de busca em vetor

Fonte: Adaptado de Ziviani (2007) e Toscani e Veloso (2005).

2.1.2.2 A Classe P

Os problemas que compõem a classe P (polynomial time) são os problemas que podem

ser resolvidos em tempo polinomial por uma máquina de Turing determinística, ou seja, são

problemas que podem ser solucionados por um computador em tempo aceitável (SIPSER,

2007; ARORA; BARAK, 2006).

Segundo Sipser (2007), um exemplo de problema inserido na classe P é o problema

CAM (Caminho), onde o objetivo é determinar se existe um caminho no grafo G entre os

vértices "s" e "t", conforme ilustrado na Figura 7, na qual é exibido um grafo G e este contém

os vértices "s" e "t" e as suas ligações, onde os círculos representam cada vértice e as linhas

entre eles representam suas ligações (arestas).

28

Figura 7. Grafo G com os vértices s e t

Fonte: Adaptado de Sipser (2007).

A solução para o exemplo, segundo Sipser (2007) consiste em buscar uma solução

diferente de um algoritmo de força bruta, pois não seria suficientemente rápido. O algoritmo

ideal consiste em considerar o número de vértices m, onde m é o comprimento máximo de um

possível caminho entre "s" e "t", e fazer uma busca por largura1 nos caminhos direcionados de

tamanho 1, depois 2, depois 3, até atingir o tamanho m e marcar os nós visitados. Caso o nó

"t" for marcado, encerra a execução resultando na existência do caminho entre "s" e "t", caso

contrário, não existe um caminho.

Por todas as arestas possuírem o mesmo custo, a solução ótima para o problema CAM

pode ser encontrada, usando uma complexidade de O(n), onde um grafo, por exemplo, com

20 vértices serão necessário no máximo 20 testes para verificar a existência de um caminho

entre "s" e "t" (SIPSER, 2007).

1 Na teoria dos grafos, busca por largura visa encontrar um elemento em uma árvore e sua lógica visa iniciar a

busca em um vértice, depois visitar todos os seus vizinhos (distância 1) depois os vizinhos dos vizinhos

(distância 2) e assim sucessivamente até encontrar o elemento procurado ou finalizar a árvore sem sucesso.

29

2.1.2.3 A Classe NP

Os problemas que podem ser verificados em tempo polinomial e que podem ser

resolvidos por um algoritmo não determinístico em tempo polinomial estão na classe NP

(Non-Deterministic Polynomial Time). A verificabilidade polinomial dos problemas é muito

importante para entender sua complexidade. Um exemplo de problema que pode ser

verificado em tempo polinomial é o caminho hamiltoniano, onde, para descobrir se um grafo

contém um caminho hamiltoniano, são conhecidos apenas algoritmos de tempo exponencial,

mas após conhecer o caminho, provar sua existência no grafo é feito em tempo polinomial

(SIPSER, 2007).

Um dos maiores problemas não resolvidos da Teoria da Complexidade e matemática

contemporânea é a questão “se P = NP ?”. Embora essa questão não tenha sido respondida,

muitos estudos apontam para que P ≠ NP e que somente P ⊂ NP. A Figura 8 ilustra as duas

possibilidades. Caso essas classes sejam iguais, qualquer problema polinomialmente

verificável seria polinomialmente “decidível” (GAREY; JOHNSON, 1979; SIPSER, 2007;

ZIVIANI, 2007).

Se P for igual a NP, diversos problemas que hoje são resolvidos em tempo

exponencial, poderiam ser resolvidos em tempo polinomial, o que significa grande redução de

uso de recurso tempo. Para se ter um exemplo da importância disto, os mais eficientes

algoritmos criptográficos são construídos com base de que apenas algoritmos com

complexidade de tempo exponencial podem quebrar o sigilo envolvido. Se P=NP, estes

algoritmos poderiam ser executados em questão de segundos ou minutos, ao invés de meses e

anos (ARORA; BARAK, 2009).

30

Figura 8. Possibilidades da relação P e NP

Fonte: Adaptado de Sipser (2007).

Segundo Sipser (2007), um exemplo de problema NP é o caminho hamiltoniano

(CAMHAM). Este procura um caminho em um grafo G direcionado onde passa por cada nó

exatamente uma vez. Conforme ilustra a Figura 9, partindo de "s" até atingir "t" passando por

todos os vértices uma única vez, conforme mostra o resultado do problema ilustrado pelo

caminho em vermelho. O problema CAMHAM pode ser solucionado através do algoritmo

CAM modificado para executar utilizando força bruta, precisando apenas o teste para verificar

se o caminho encontrado é hamiltoniano, resultando assim em um algoritmo de tempo

exponencial.

31

Figura 9. Grafo G com o Caminho Hamiltoniano

Fonte: Adaptado de Sipser (2007).

2.1.2.4 A Classe NP-Completo

Segundo Garey e Johnson (1979) e Sipser (2007), o teorema do início dos anos 1970

Cook-Levin, conhecido pelo nome de seus autores Stephen Cook e Leonid Levin, foi um

avanço importante na questão P versus NP. O teorema comprova que há um grupo em NP que

se houver um algoritmo polinomial capaz de resolver qualquer um desses problemas, então

todos os problemas da classe NP seriam solúveis em tempo polinomial. Esses problemas

pertencem a classe NP-Completo.

O primeiro problema NP-Completo apresentado foi o da “Satisfazibilidade” (SAT),

que consiste em testar se uma expressão com diversas variáveis booleanas é satisfazível

(verdadeira), utilizando os operadores lógicos: and, or e not. Um teorema desenvolvido por

Cook-Levin liga o problema SAT à complexidade de todos os problemas existentes em NP,

deste modo, se SAT for provado como pertencente a P, todos os problemas que estão em NP o

serão (SIPSER, 2007).

Um importante conceito para classificação em problemas NP-Completo é o da redução

polinomial, que consiste em reduzir um problema A ao problema B, na qual uma solução para

resolver B também resolve A. A redução polinomial consiste em selecionar um problema X,

aplicar a ele uma transformação polinomial, através de um algoritmo de tempo polinomial

determinístico. No caso dos problemas NP-Completo seleciona-se um problema que possui o

algoritmo Y para resolvê-lo. Depois utiliza-se um problema comprovadamente NP-Completo

32

com o algoritmo X, Adapta-se as entradas de X para Y através da transformação polinomial,

processando-o em Y. Após este processo os resultados são convertidos novamente através de

transformação polinomial para saídas de X, conforme ilustra a Figura 10. (SIPSER, 2007,

TOSCANI; VELOSO, 2005; ZIVIANI, 2007).

Figura 10. Transformação polinomial do problema X no problema Y

Fonte: Adaptado de Ziviani (2007).

Para que um problema X possa ser considerado NP-Completo, o mesmo deverá

contemplar as seguintes características:

É solúvel por um algoritmo não-determinístico em tempo polinomial;

Todo problema em NP-Completo pode ser reduzido polinomialmente a X.

Para provar que um problema X é NP-Completo, muitos cientistas recorrem ao

seguinte método: provam que X é NP, depois realizam uma redução polinomial a qualquer

problema comprovadamente NP-Completo (GAREY; JOHNSON, 1979; SIPSER, 2007;

TOSCANI; VELOSO, 2005; ARORA; BARAK, 2009; KARP, 1972).

Em continuidade, o teorema de Cook-Levin diz que todo problema NP transformado

em NP-Completo através de uma função polinomial também pertence à classe NP-Completo

(Cook reduction) (GAREY; JOHNSON, 1979; SIPSER 2007).

2.1.2.5 Exemplos de problemas NP-Completo

Segundo Karp (1972) e Garey e Johnson (1979) há uma vasta lista de problemas que

compõe a classe NP-Completo. A seguir serão apresentados dois problemas

comprovadamente NP-Completo: Clique Máximo do Grafo e Cobertura Mínima de Vértices.

Estes problemas foram desenvolvidos e analisados neste trabalho de TTC.

33

Clique do Grafo

Segundo Sipser (2007) e Ziviani (2007) o Clique do Grafo é um conjunto de vértices

no qual cada um deles possui conexão mútua com os demais do conjunto. O clique em um

grafo pode ser limitado a um tamanho específico, o que é chamado de k-clique, onde o

subgrafo possui k vértices conforme a Figura 11.

Figura 11. K-clique de tamanho 4

Fonte: Adaptado de Sipser (2007).

Neste trabalho também foi analisado o algoritmo para solucionar o problema de clique

do grafo, a fim de encontrar o Clique Máximo de um Grafo, conforme mostra a Figura 12.

34

Figura 12. Clique Máximo do grafo

Fonte: Adaptado de Sipser (2007).

Cobertura de Vértices

Cobertura de Vértices em um grafo não direcionado é um subconjunto de vértices

composto por pelo menos uma das pontas de cada aresta. Em outras palavras a Cobertura de

Vértices em um grafo G é um subconjunto onde toda aresta de G é ligada em um dos vértices.

O problema de Cobertura de Vértices procura saber se um grafo tem uma cobertura de

tamanho especificado k conforme ilustra a Figura 13 (SIPSER, 2007).

Figura 13. Coberturas de Tamanho K

Neste trabalho foi analisado o algoritmo para solucionar o problema de Cobertura de

Vértices, a fim de encontrar a Cobertura Mínima de um Grafo, ou seja, um algoritmo com

35

uma quantidade mínima de vértices que compõem a Cobertura de Vértices (conforme mostra

a Figura 14).

Figura 14. Cobertura Mínima de um Grafo

2.2 Programação Paralela

Desde o ENIAC, capaz de executar 300 operações por segundo, até hoje, com

máquinas um milhão de vezes mais rápidas, há uma demanda por computadores cada vez

mais rápidos nos mais diversos contextos comerciais e científicos: para cálculos

astronômicos, projetos de modelos aeronáuticos, etc. “Não importa quanto poder

computacional exista: ele nunca será suficiente” (TANENBAUM, 2003, p. 379).

A partir de 1980, o principal método de aumento de desempenho nas CPUs (Central

Processing Units) ocorreu devido a incrementos na velocidade de operação (clock) dos

processadores, partindo de 1MHz até chegar em frequências entre 1GHz e 4GHz nos anos

2000. Após esta evolução, barreiras foram encontradas no aumento da frequência nas CPUs,

tais como: alto consumo de energia dos circuitos integrados, restrições de calor e também pelo

limite físico de fabricar resistores menores do que os atuais. Com estas limitações,

pesquisadores e fabricantes procuraram por outros modelos para se ganhar desempenho

(TANENBAUM, 2003).

Com a existência de um limite físico quanto ao projeto de processadores mais velozes,

fabricantes aumentaram o número de processadores como alternativa de suprir a demanda de

desempenho em tempo de processamento. O que tornou mais comum o uso de

supercomputadores multiprocessados com dezenas, centenas ou até mesmo milhares de

processadores trabalhando em conjunto. Esta demanda de computadores paralelos passou a

36

ser um requisito de computadores pessoais, que aumentaram a quantidade de processadores

trabalhando em conjunto para obter maior desempenho (SANDERS; KANDROT, 2010).

2.2.1 Taxonomia de Arquiteturas

Visando classificar as arquiteturas de computadores Michael J. Flynn classificou-as

em quatro grupos definidos entre a quantidade de instruções executadas em paralelo e a

quantidade de dados tratados em paralelo, conforme mostra Figura 15 (MARTINS, 2001).

Figura 15. Taxonomia de Flynn

Fonte: Adaptado de Roque (2012)

SISD: Segundo Martins (2001), SISD (Single Instruction, Single Data) é a

arquitetura encontrada onde uma instrução é executada após a outra,

encontrado em computadores com uma única CPU, no qual se enquadram os

algoritmos sequenciais abordados neste trabalho.

MISD: Segundo Martins (2001), MISD (Multiple Instruction, Single Data) é

um tipo de arquitetura paralela na qual são executados muitas operações sobre

os mesmos dados. Existem poucas máquinas que operam nesta definição, mas

existem algoritmos que utilizam da ideia de fazer várias operações sobre os

mesmos dados, como por exemplo, algoritmos de criptografia usados para

decodificar uma mensagem.

37

SIMD: Na qual é baseada a arquitetura CUDA, SIMD (Single Instruction,

Multiple Data) possui várias unidades lógicas de processamento que podem

executar uma instrução por vários conjuntos de dados simultaneamente. Muito

usado para cálculos matemáticos e processamento científico, utilizam

estruturas de dados regulares como vetores e matrizes (ROQUE, 2012).

MIMD: Cada unidade de processamento executa instruções diferentes em cada

momento e cada uma destas unidades pode operar sobre um fluxo de dados

diferente através do compartilhamento de memória (ROCHA, 2007).

2.2.2 Memória

Segundo Martins (2001) nas diferentes formas de paralelismo, podemos dividir

também em dois grupos de acordo com o modelo de acesso a memória, memória distribuída e

memória compartilhada.

Memória Distribuída: Nestes sistemas a memória é fisicamente distribuída

entre os processadores e cada memória local pode ser acessada exclusivamente

pelo seu processador, quando uma tarefa necessita dos dados de outra as

mesmas se comunicam através da troca de mensagens (MARTINS, 2001).

Para comunicação dos dados em sistemas de memória distribuída é muito

comum usar o padrão MPI (Message Passing Interface), que tem como

objetivo prover um amplo padrão para desenvolver programas utilizando esta

arquitetura (TANEMBAUM; STEEN, 2007)

Memória Compartilhada: Modelo utilizado pela arquitetura CUDA, onde a

mesma memória é acessada por múltiplos processadores e é de

responsabilidade do desenvolvedor controlar a sincronização deste acesso para

que os dados sejam sempre consistentes (MARTINS, 2001).

Por oferecer acesso local às arquiteturas que utilizam memória compartilhada

oferecem uma comunicação mais rápida entre tarefas, porém tem a

escalabilidade limitada pelo numero de caminhos entre memória e

processadores (MARTINS, 2001).

38

2.2.3 Evolução da GPU

Juntamente com a evolução das CPUs, as GPUs também passaram por uma grande

evolução, esta motivada principalmente por grandes investimentos no mercado de jogos

crescente nos últimos anos, principalmente focada em uma arquitetura com um maior numero

de circuitos de processamento do que para cache, as GPUs apresentam um crescimento, de

desempenho, superior a duas vezes ao ano e mil vezes em uma década, superando assim a Lei

de Moore aplicável para as CPUs (HARRIS, 2005; VIANA, 2011).

Segundo Ikeda (2011) em 1970 surgiram os primeiros aceleradores gráficos 2D, de

1995 a 1998 os 3D que tiveram um papel importante no desenvolvimento das GPUs. Com o

passar dos anos os aceleradores gráficos deixaram de ser exclusivamente para processamento

de imagens dando lugar as GPUs programáveis, passando a ser possível o aproveitamento do

enorme poder computacional presente neste hardware paralelo. Com isso pode-se dividir a

evolução das GPUs em cinco gerações:

Primeira Geração: a placa de vídeo era vista como o dispositivo que servia

apenas para enviar dados para o monitor, sem nenhum processamento feito

internamente. Com os grandes competidores no mercado: ATI com os modelos

Rage 128 (e suas várias versões), 3Dfx com a Voodoo3 que dividia o posto de

melhor placa de vídeo com a RIVA TNT2 (NV5) da concorrente NVIDIA;

Segunda Geração: surgimento da GeForce 256 (NV10) da NVIDIA,

proclamada como a primeira GPU do mundo destacando-se por introduzir o

conceito de pipeline gráfico responsável pelas transformações geométricas e de

iluminação, onde muitas funcionalidades anteriormente executadas na CPU

passaram para a GPU. Esta geração ficou marcada também pela queda da 3Dfx

que posteriormente foi adquirida pela NVIDIA. Nesta geração foi onde

surgiram as tecnologias que permitem que duas ou mais placas de vídeo

trabalhem em conjunto SLI (NVIDIA) e Crossfire (ATI);

Terceira Geração: lançamento da GeForce 3 (NV20) da NVIDIA, a primeira

GPU programável do mercado, que em breve foi superada pela Radeon 8500

da rival ATI. Não havia uma linguagem de programação específica, o que

dificultava o desenvolvimento, obrigando a utilização da linguagem de

39

montagem da GPU (assembly), mesmo assim começa-se a vislumbrar a GPU

como hardware programável;

Quarta Geração: geração representada por placas gráficas capazes de executar

milhares de instruções, utilizar variável de ponto flutuante e não possuir

limitação na utilização dos dados de textura, possibilitando e assim surgindo

implementações de propósito geral GPGPU (General-Propose GPU);

Quinta Geração: surgimento da GeForce 8 a primeira a suportar a nova

arquitetura de programação paralela CUDA, foi lançada em 2007 pela

fabricante NVIDIA, com este lançamento o mercado de computação de alto

desempenho em GPUs ganhou grande destaque não somente cientifico, mas

também comercial. A rival ATI entrou neste promissor mercado com a ATI

Stream, além da abordagem aberta OpenCL adotada por ambas fabricantes.

2.2.3.1 GPGPU

Durante a quarta geração da evolução das GPUs, as mesmas passaram a oferecer a

possibilidade de uso para programação para propósitos gerais, ou seja, não destinado

exclusivamente à computação gráfica, o que foi chamado por Mark Harris, em 2002, de

GPGPU. No início esta utilização era complicada e devido à codificação complexa e a uma

arquitetura improvisada, entretanto na quinta geração surgiram novas arquiteturas oferecendo

suporte para linguagens de programação já conhecidas como o C e totalmente focada a

computação paralela de alto desempenho, passando a ser conhecido também por GPU

Computing (IKEDA, 2011; GPGPU.org, 2012; NVIDIA, 2012).

2.2.3.2 CUDA

Segundo Ikeda (2011) a fabricante NVIDIA ganha destaque nos ambientes GPGPU

com o pioneirismo ao lançar a arquitetura CUDA e por isso foi selecionada como objeto de

estudo deste projeto.

Em novembro de 2006, a fabricante NVIDIA lançou a GeForce 8800 e deu origem ao

novo modelo de computação em GPU com a arquitetura chamada CUDA. Em junho de 2008

uma nova geração de placas gráficas foi lançada aumentando o número de processadores

gráficos, também chamados de CUDA core, de 128 para 240 e a memória de cada processador

40

também foi aumentada suportando também operações de ponto flutuante, permitindo que

várias operações científicas fossem executadas usando a arquitetura (NVIDIA, 2012).

CUDA tem como principal objetivo substituir as difíceis APIs existentes

anteriormente, trazendo grandes facilidades no desenvolvimento para as GPUs sem que os

programadores tenham a necessidade de conhecer os detalhes do hardware, apenas utilizando

uma linguagem como C e o compilador próprio para a arquitetura (IKEDA, 2011).

2.2.3.2.1 Stream Processing

Segundo Ikeda (2011), a arquitetura CUDA utiliza do modelo de programação Stream

Processing (conhecido também como Thread Processing), onde um conjunto uniforme de

dados, que podem ser processados em paralelo (stream), são separados em várias instruções

chamadas de kernel. Desta forma é constituído um modelo simples de programação paralelo,

ideal para aplicações compostas por alta demanda aritmética ou de dados que podem ser

processados paralelamente.

2.2.3.2.2 Arquitetura da GPU

Por serem originalmente projetadas para processamento gráfico as GPUs evoluíram

seguindo um caminho diferente das CPUs, onde as aplicações gráficas utilizam uma mesma

operação diversas vezes para uma grande quantidade de dados, com isso as GPUs foram

sempre projetadas para trabalhar com várias instruções em paralelo (SIMD) (VIANA, 2012;

IKEDA, 2011).

Conforme ilustra a Figura 16, as GPUs por apresentam um número maior de

transistores para cálculos e apresentando uma quantidade menor de unidades de controle e de

memória cache como acontece na CPU.

41

Figura 16. Distribuição dos transistores em CPUs e GPUs

Fonte: NVIDIA (2012).

Em 2010 a NVIDIA lança sua nova GPU CUDA com o codinome Fermi, na qual

serão feitas as execuções deste trabalho e as novidades desta evolução são o total suporte a

linguagem C++, GPUs com até 3 bilhões de transistores divididos em até 512 núcleos,

endereçamento de memória de 64-bits com uma interface de 384-bits, no qual foi chamado de

Compute Capability (versão) 2.0 (NVIDIA, 2012).

Na arquitetura Fermi os 512 núcleos são organizados em 16 Streaming Multiprocessor

(SM) com 32 núcleos cada. O GigaThread, escalonador global, distribui o blocos de thread

para os escalonadores dentro dos SMs.

42

2.2.3.2.3 Arquitetura CUDA

Figura 17. Arquitetura CUDA

Fonte: Adaptado de NVIDIA (2009).

Segundo NVIDIA (2009), a arquitetura CUDA conforme ilustra a Figura 17 é

composta pelos seguintes itens:

1. GPU com suporte a CUDA;

2. Suporte a CUDA no kernel do Sistema Operacional;

3. CUDA driver, utilizado pelos desenvolvedores;

4. PTX, conjunto de instruções da arquitetura (ISA) para kernels de computação

paralela e funções.

O ambiente de desenvolvimento CUDA também é composto pelas ferramentas

necessárias para os desenvolvedores:

43

Biliotecas;

C Runtime for CUDA, que permite a execução de funções padrões da

linguagem C e ligações a outras linguagens como Fortran, Java, and Python;

Compilador (nvcc), debugger(cudagdb) e outras ferramentas;

Documentação de programação;

Exemplos de códigos contendo as melhores práticas.

2.2.3.2.4 Programação CUDA

Segundo Ikeda (2011), o principio básico do desenvolvimento com CUDA está em

definir kernels (utilizando uma extensão da linguagem C), que especifica como o código será

executado na GPU.

Qualificadores de função determinam onde a função será executada, host ou device

(CPU e GPU respectivamente) e por quem a função poderá ser chamada:

"__device__": especifica que a função será executada no device e somente

poderá ser invocada a partir do mesmo;

"__global__": especifica um kernel, que será executado no device e somente

pode ser invocado a partir do host;

"__host__": especifica que uma função será executada no host e somente

poderá ser invocada do mesmo.

Um exemplo da chamada de um kernel, nomeado como exemplo, pode ser visto no

Quadro 2:

44

#include <iostream>

__global__ void exemplo ( void ){

}

int main ( void ) {

exemplo<<<1,1>>>();

printf( “Olá Mundo\n” );

return 0;

}

Quadro 2. Exemplo da chamada de um kernel

Fonte: Adaptado de Sanders e Kandrot (2011).

Para obtenção de melhor desempenho em CUDA o objetivo está em conciliar o

número de threads ativas com os recursos disponíveis encontrando um ponto de equilíbrio

que possa trazer os maiores benefícios às aplicações (IKEDA, 2011).

Na execução de um kernel a principal preocupação está em escolher a melhor

configuração de blocos e threads por blocos, onde na chamada de um kernel é informado à

quantidade de copias em paralelo dos blocos que serão executados e a quantidade de threads

em cada um desses blocos. O Quadro 3 exemplifica a chamada de um kernel com N blocos e

1 thread enquanto o Quadro 4 exemplifica a chamada de um kernel com 1 bloco executando

N threads (SANDERS; KANDROT, 2010).

Add<<<N,1>>>( vet_a, vet_b, vet_soma );

Quadro 3. Kernel N blocos com 1 thread

Fonte: Adaptado de Sanders e Kandrot (2011).

Add<<<1,N>>>( vet_a, vet_b, vet_soma );

Quadro 4. Kernel 1 bloco com N threads

Fonte: Adaptado de Sanders e Kandrot (2011).

2.2.3.2.5 Memória das GPUs NVIDIA

A arquitetura CUDA disponibiliza ao desenvolvedor as memórias ilustradas na Figura

18, possibilitando que ele a utilize da forma como necessita para obter melhor desempenho

45

em suas aplicações, visando à velocidade de acesso, tamanho e a visibilidade de cada uma

delas (SANDERS; KANDROT, 2010).

Figura 18. Memórias da arquitetura CUDA

Fonte: Adaptado de NVIDIA (2012).

O tempo de acesso em cada uma das memórias é diferente a penalidade no

desempenho pode ser dada pelo Quadro 5.

46

Declaração da Variável Memória utilizada Penalidade no

desempenho

int LocalVar; Registrador 1x

int LocalArray[10] Local 100x

[__device__] __shared__ int SharedVar; Shared 1x

__device__ int GlobalVar; Global 100x

[__device__] __constant__ int ConstantVar; Constant 1x

Quadro 5. Penalidade de acesso à memória

Fonte: Adaptado de NVIDIA (2012).

2.2.4 Métricas de Complexidade Paralela

A execução de um programa de computação paralela pode ser visto como na Figura

19, onde se emprega a estratégia de divisão por conquista na execução dos algoritmos. A

tarefa inicial "f" composta por "n" elementos, é dividida (fork) em tarefas menores "g" com

uma porção dos dados iniciais "x" e é repassada para um novo kernel. Os itens colocados lado

a lado são executados ao mesmo tempo, e o período de execução da figura, transcorre de cima

para baixo. No final das execuções paralelas, os dados são unidos formando a solução do

problema (BARNEY, 2012).

47

Figura 19. Execução de um programa de computação paralela

Blelloch (1990) cita duas maneiras de se mensurar a complexidade de um programa

utilizando linguagens de programação paralela: (i) element complexity; e (ii) step complexity.

Na element complexity, a função de complexidade é formada somando a complexidade de

cada kernel. Na Figura 20, a element complexity seria a soma da complexidade de todos os nós

(complexidade do nó 1 + complexidade do nó 2 + ... + complexidade do nó 13).

A step complexity é o número de passos dados, ou seja, cada nível da árvore da Figura

20, possui uma determinada complexidade dada pela tarefa a ser executada. Na step

complexity, deve-se somar a complexidade de cada um destes níveis, pois, os kernels com as

mesmas tarefas são executados paralelamente. Esta abordagem é semelhante à complexidade

de profundidade, utilizada no contexto de complexidade de circuitos digitais, que pode ser

vista em Sipser (2007).

48

Figura 20. Árvore element e step complexity

Segundo Sipser (2007) circuitos digitais são modelos semelhantes às Máquinas de

Turing. O teorema e a prova demonstrados em Sipser (2007, p. 376) demonstram que tais

estruturas, além de análogas às Máquinas de Turing com complexidade "t(n)", possuem

complexidade "t2(n)", ou seja, são polinomialmente equivalentes em tempo.

O Quadro 6 demonstra alguns exemplos da diferença na análise das complexidades

element e step para determinados algoritmos.

49

Algoritmo Step

Complexity

Element

Complexity

Ordenação e Fusão

n elementos

Split-Radix Sort O(log n) O(n log n)

Quicksort O(log n) O(n log n)

Halving Merge O(log n) O(n)

Geometria

Computacional

n pontos

Closeste Pair O(log n) O(n log n)

Line of Sight O(1) O(n)

Line Drawing O(1) O(n)

Grafos

n Vértices, m Arestas

Minimum Spanning Tree O(log n) O(m log n)

Maximum Flow O(n²) O(n²m)

Maximal Independent Set O(log n) O(m log n)

Bocinnected Components O(log n) O(m log n)

Numéticos

Matrizes n x m

Matrix-Vetor Multiply O(1) O(nm)

Linear-Systems Solver (n = m) O(n) O(n²)

Step of Simplex O(1) O(nm)

Quadro 6. Complexidade step e elemento dos algoritmos

Fonte: Adaptado de Blolloch (1990).

A métrica de step complexity deverá ser utilizada no trabalho descrito por este

documento.

2.3 Trabalhos que utilizam CUDA e problemas NP-Completo

A cada dia surgem novas pesquisas sobre implementações que buscam maior

desempenho através da arquitetura CUDA, a seguir se encontram duas pesquisas que tem

estreita relação com estre trabalho ao abordarem problemas classificados como NP-Completo.

Os estudos apresentados são: Solving NP-Complete Problems on the CUDA Architecture

using Genetic Algorithms (Solucionando Problemas NP-Completo na Arquitetura CUDA

usando Algoritmos Genéticos) e A Parallel GPU Version of the Traveling Salesman Problem

(Uma Versão Paralela em GPU para o Problema do Caixeiro Viajante).

2.3.1 Solving NP-Complete Problems on the CUDA Architecture using

Genetic Algorithms

Desenvolvido por Mihai Calin Feier, Camelia Lemnaru e Rodica Potolea na Technical

University of Cluj-Capoca em 2011 e apresentado no Decimo Simpósio Internacional de

Computação Paralela e Distribuída, o trabalho teve como objetivo comparar a execução de

dois problemas NP-Completo adotando a forma de execução através de algoritmos genéticos.

O conceito de algoritmo genético é uma técnica de Inteligência Artificial e se refere ao

processo de busca heurística que se aproxima da solução através do processo natural de

50

evolução e são usados para otimizar buscas complexas como problemas NP (FEIER;

LEMANARU; POTOLEA, 2011).

Segundo os autores, os testes foram dados a partir de dois problemas NP-Completo:

satisfabilidade booleana (SAT) e problema da mochila.

Segundo os autores, o problema da satisfazibilidade booleana que foi o primeiro a ser

identificado como NP-Completo, tem como objetivo determinar se existe uma atribuição de

valores lógicos (verdadeiro ou falso) para as variáveis de uma expressão booleana que a

tornem verdadeira, ou seja, que a “satisfaça”. Já o problema da mochila busca determinar os

objetos necessários para preencher uma mochila de forma que a mesma apresente o maior

valor possível e não ultrapassando o valor máximo, onde os objetos disponíveis possuem

diferentes pesos e valores.

Os algoritmos foram executados e comparados em dois ambientes, no primeiro foi

usando uma CPU com quatro núcleos rodando a 2,4 GHz e os algoritmos desenvolvidos de

forma sequencial e no segundo ambiente, uma GPU Nvidia GeForce GTX 260 com 216

núcleos e os algoritmos desenvolvidos para buscar o maior desempenho paralelo que esta

arquitetura pode oferecer (FEIER; LEMANARU; POTOLEA, 2011).

A Figura 21 relata a comparação entre as implementações em CPU e GPU com as

mesmas configurações e dados dos problemas, onde foram variados na quantidade de ilhas

onde as populações genéticas estão localizadas e no tamanho destas populações. A figura

apresenta ainda o tempo de execução no primeiro e segundo ambiente e a taxa de evolução no

quesito tempo. Quanto à qualidade dos resultados, ambos os ambientes apresentaram a mesma

qualidade (FEIER; LEMANARU; POTOLEA, 2011).

51

Figura 21. Tempo de execução na CPU e GPU

Fonte: Feier, Lemanaru e Potolea (2011).

Segundo os autores a Figura 22 apresenta um gráfico de comparação do algoritmo da

mochila em função do tempo (eixo y), de execução em CPU e CUDA em função da

quantidade de ilhas (eixo x) configuradas no Algoritmo Genético, como pode ser visto, a

execução em CUDA apresentou menor tempo de execução em todas as situações.

Figura 22. Desempenho do problema da mochila

Fonte: Feier, Lemanaru e Potolea (2011).

Como resultado do experimento, os autores concluem que, ao resolver problemas NP-

Completo em GPU, o uso de Algoritmos Genéticos pode apresentar um ganho de

52

desempenho, demonstrado no trabalho em uma faixa que varia em um fator de 0,39 e 67,2

para determinados critérios de configurações (FEIER; LEMANARU; POTOLEA, 2011).

2.3.2 A Parallel GPU Version of the Traveling Salesman Problem

Desenvolvido por Molly A. O`Neil, Dan Tamir e Martin Burtscher no Department of

Computer Science na Texas State University em 2011, o trabalho teve como objetivo

comparar a execução do Traveling Salesman Problem (Problema do Caixeiro Viajante), que

está posicionado na classe NP-Completo, em três ambientes: sequencial utilizando CPU,

paralelo usando CPU e paralelo usando a arquitetura CUDA.

Para os ambientes paralelos os algoritmos foram desenvolvidos utilizando as técnicas

hill climbing (escalada), que são frequentemente utilizados para otimização de problemas

combinacionais (O`NEIL; TAMIR; BURSTSCHER, 2011).

Segundo os autores, o problema do caixeiro viajante consiste em encontrar o caminho

de menor distância para percorrer um conjunto de cidades partindo de uma cidade, visitando

todas as demais uma única vez e regressando para cidade inicial.

Os testes foram efetuados em três diferentes ambientes, nos dois primeiros usados 128

CPUs com 8 núcleos com uma frequência de 2.0 GHz e memoria principal compartilhada de

4TB, sendo que no primeiro foi executado apenas uma thread, ou seja, sequencialmente, e no

segundo com diferentes configurações no número de threads, ou seja, paralelamente através

da utilização de pthreads. No terceiro ambiente foi utilizando uma GPU Nvidia Tesla C2050

com 445 núcleos e executando utilizando a arquitetura CUDA (O`NEIL; TAMIR;

BURSTSCHER, 2011).

Segundo os autores, a Figura 23 relata os resultados e a comparação entre os diferentes

ambientes para a execução de 100.000 instancias aleatórias do problema, apresentando o

tempo mínimo, médio e máximo das execuções.

53

Figura 23. Tempo mínimo, médio e máximo das execuções do Caixeiro Viajante

Fonte: O`Neil, Tamir e Burstscher (2011).

A Figura 24 apresenta a taxa de ganho de velocidade sobre a implementação

sequencial em CPU.

Figura 24. Taxa mínima, média e máxima de ganho de velocidade

Fonte: O`Neil, Tamir e Burstscher (2011).

Como resultado do experimento, os autores concluem que, resolver o problema do

caixeiro viajante através de algoritmos hill climbing em GPUs, pode apresentar um

desempenho de até 62 vezes mais rápido que em um processador e até mesmo mais rápido

que executado em 32 CPUs (64 pthreads) com 8 núcleos (O`NEIL; TAMIR; BURSTSCHER,

2011).

54

2.3.3 Comparativo entre os trabalhos similares

Com base nos trabalhos similares apresentados o Quadro 7 apresenta informações

comparativas entre os mesmos, para as colunas temos os trabalhos para as linhas suas

caracteristicas. Ambos os trabalhos utilizam a arquitetura CUDA para analisar algoritmos NP-

Completo em comparação com os mesmo executados em CPU, como pode ser visto, os

problemas abordados foram diferentes e utilizaram estratégias diferentes, um inclusive

abordou um conjunto de CPUs também trabalhando em paralelo. Apesar das estratégias e

problemas diferentes ambos conseguiram um ganho de desempenho considerável ao serem

executados em GPU.

Solving NP-Complete Problems

on the CUDA Architecture

using Genetic Algorithms

A Parallel GPU Version

of the Traveling

Salesman Problem

CUDA Sim Sim

Problema NP-Completo

Abordado

Problema SAT

Problema da Mochila

Caixeiro Viajante

Estratégia de

implementação

Algoritmos Genéticos Hill Climbing

Uso de CPU Sequencial Sim Sim

Uso de CPU Paralela Não Sim

Ganho de desempenho da

GPU em relação a CPU

Sequencial

Até 67,2 vezes Até 62 vezes

Quadro 7. Quadro comparativo dos trabalhos similares

Este trabalho se difere dos trabalhos semelhantes nos algoritmos a serem estudados, e

principalmente na estratégia a ser usada para implementação que irá seguir suas heurísticas

em busca da solução ótima, porém adotando a ideia de Blelloch (1990) ao paralelizar cada

step da árvore de cobertura conforme demostrado na seção do Projeto.

55

3 DESENVOLVIMENTO

Neste capítulo, são apresentados tópicos pertinentes à etapa de desenvolvimento,

implementações, testes e análises dos resultados, utilizando como base o que foi levantado na

Fundamentação Teórica. Divide-se o capítulo em: algoritmos NP-Completo e implementação

para CPU, complexidade (sob perspectiva pessimista destes), implementações dos algoritmos

NP-Completo para CUDA e suas estratégias de desenvolvimento e resultados obtidos com os

mesmos.

3.1 Algoritmos NP-Completo para CPU

Os algoritmos Clique Máximo do Grafo e Cobertura Mínima de Vértices foram

implementados para serem executados na CPU, o processo de implementação e a análise de

complexidade dos mesmos pode ser acompanhado a seguir.

3.1.1 Clique Máximo do Grafo

Segundo Kreher e Stinson (1999) o problema do Clique Máximo pode ser resolvido

conforme a lógica relatada no Quadro 8, na qual a função recursiva MaxClique é responsável

por percorrer o grafo em busca da solução. Para isso ela utiliza os seguintes conjuntos de

dados:

V: Vértices do grafo;

A: Adjacentes de V;

B: Vértices a serem analisados;

C: Vértices que compõem a árvore de backtracking;

OptClique: Solução do problema;

l: Nível da árvore de backtracking;

O algoritmo proposto busca percorrer o grafo seguindo sua árvore de backtracking,

controlado pelos elementos do conjunto B (responsável por permitir identificar relações entre

vértices já verificadas) e identificado os possíveis adjacentes a serem incluídos no clique

anteriormente encontrado.

56

Resumidamente, o algoritmo descrito pelo Quadro 8 realiza os seguintes passos:

1. Verifica se a solução atual possui uma quantidade de vértices, com ligações

simultâneas, superior a que já se havia encontrado. Se houver, uma nova

solução é mantida (até que outra com uma maior quantidade de vértices seja

encontrada);

2. Em seguida, cria um conjunto relativo às adjacências mútuas (conjunto C), ao

grupo de vértices analisados em questão;

3. Para cada uma das adjacências mútuas identificadas no passo 2, repetem os

mesmos procedimento dos passos 1, 2 e 3, até que não haja mais destas

adjacências.

global A[l], B[l], C[l], x[l] (l = 0, 1, ... , n - 1)

MaxClique(l)

if l > OptSize {

OptSize <- l + 1

OptClique <- (x[0], ... , x[l-1])

}

if l == 0 {

C[l] <- V

} else {

C[l] <- A[x[l-1]] ∩ B[x[l-1]] ∩ C[l-1] }

for each x2 ∈ C[l]{ x[l] <- x2

MaxClique(l + 1)

}

main{

OptSize <- 0

MaxClique(0)

output(OptClique)

}

Quadro 8. Logica MaxClique

Fonte: Adaptado de Kreher e Stinson (1999).

A Figura 25 traz um grafo que pode ser usando como exemplo, com a representação

visual do grafo e os valores dos conjuntos V, A e B.

57

Figura 25. Grafo exemplo e os conjuntos V, A e B

Fonte: Adaptado de Kreher e Stinson (1999).

Para o exemplo de grafo ilustrado na Figura 25, os resultados obtidos por seu

algoritmo é representado através da árvore de backtraking da Figura 26, onde são localizados

todos os cliques e consequentemente localizando o maior dentre eles. Nesta árvore o

algoritmo inicia com o primeiro elemento no conjunto V, que é representado mais a esquerda

da árvore pelo elemento [0], em seguida o algoritmo localiza, através do conjunto A, os

adjacentes de [0] que formam os novos cliques [0,1], [0,3] e [0,6], continuando a execução

são analisados os adjacentes de [0,1], como nenhum adjacente é encontrado a função retorna e

passa a analisar os adjacentes de [0,3], logo encontra o vértice 6, formando o clique [0,3,6]

composto por três vértices que passa a ser o novo Clique Máximo. Esta verificação continua

até que todos os vértices e seus adjacentes sejam verificados garantindo que o Clique Máximo

seja encontrado.

58

Figura 26. Árvore de backtracking

Fonte: Adaptado de Kreher e Stinson (1999).

O algoritmo descrito foi desenvolvido para ser usado nos testes em CPU, sua

codificação pode ser visualizada no Apêndice A.2.

3.1.1.1 Análise de complexidade

A Figura 27 representa as chamadas recursivas da função MaxClique para um grafo de

quatro vértices ("a", "b", "c" e "d") em seu caso pessimista, onde cada vértice tem como

descendente seus adjacentes ainda não verificados, resultando assim em 2n chamadas.

59

Figura 27. Árvore de chamadas da função MaxClique

Analisando a complexidade pessimista do algoritmo desenvolvido, através de sua

árvore obtemos as complexidades das operações (para n representando o número de vértices):

2n: chamadas da função MaxClique para caso pessimista;

n³: intersecção entre os conjuntos A, B e C;

∑ ( )

: no pior caso, n cliques são encontrados até chegar ao maior

clique possível envolvendo os n vértices. Quando um novo clique é encontrado

uma cópia dos vértices envolvidos é feita para um vetor com a solução.

A complexidade da função completa é dada unindo complexidades individuais

conforme mostra a Equação 1. Em ordem Ômicron é demonstrada pela Equação 2,

representando o limite superior da complexidade encontrada.

( 2n ( n³ ) + (

) ) Equação 1

O ( 2n ) Equação 2

60

3.1.2 Cobertura Mínima de Vértices

Assim como o algoritmo Clique do Grafo, a Cobertura Mínima de Vértices também

foi implementada, através da função recursiva CobMin, utilizando um novo conjunto que

substitui o conjunto A descrito no algoritmo anterior pelo conjunto Arestas contendo as

arestas do grafo.

Resumidamente, o algoritmo descrito pelo Quadro 9, encontra a solução do problema

realizando os seguintes passos:

1. Verifica se a solução atual possui uma quantidade de arestas igual à quantidade

de arestas existente no grafo e se a quantidade de vértices da solução atual é

menor do que a quantidade de vértices da solução anterior. Em caso positivo,

uma nova solução é mantida (até que outra com menor quantidade de vértices

seja encontrada);

2. Em seguida, cria um conjunto relativo aos vértices já analisados pela cobertura

atual (conjunto C);

3. Cada uma das arestas ainda não incluídas nesta cobertura é adicionada na

mesma e repetem-se os mesmos procedimentos dos passos 1, 2 e 3, até que não

haja mais arestas a serem analisadas.

global Arestas[l], B[l], C[l], x[l] (l = 0, 1, ... , n - 1)

CobMin(l, cobertura)

if cobertura == Arestas.size && x.sixe < menorCobertura {

menorCobertura <- x.size

OptCob <- (x[0], ... , x[l-1])

return( )

}

if l == 0 {

menorCobertura = V.size

C[l] <- V

} else {

C[l] <- B[x[l-1]] ∩ C[l-1] }

for each x2 ∈ C[l]{ x[l] <- x2

CobMin(l + 1, cobertura ∪ arestasAdacentes(x[l]) ) }

main{

menorCobertura <- V.size + 1

CobMin(nova cobertura)

output(OptCob)

}

Quadro 9. Lógica CobMin

61

Da mesma forma que o Clique Máximo, o algoritmo descrito foi desenvolvido para ser

usado nos testes em CPU, sua codificação pode ser visualizada no Apêndice A.1.

3.1.2.1 Análise de complexidade

Analisando a complexidade pessimista do algoritmo desenvolvido obtemos as

complexidades das operações (para n representando o número de vértices e m representando o

número de arestas):

2n: chamadas da função CobMin para caso pessimista;

n²: intersecção entre os conjuntos B e C;

m²: busca e inclusão de nova aresta na cobertura;

∑ ( )

: no pior caso, n coberturas são encontrados até chegar a menor

dentre elas. Quando uma nova cobertura é encontrada uma cópia dos vértices

envolvidos é feita para um vetor com a solução o que geraria o somatório.

A complexidade da função completa é dada unindo complexidades individuais

conforme mostra a Equação 3 e que em representação ômicron pode ser simplificada a

complexidade representada pela Equação 2, onde ela representa o limite superior da

complexidade encontrada.

( 2n ( n² ) + ( m² ) + (

) ) Equação 3

O ( 2n ) Equação 4

3.2 Algoritmos NP-Completo para GPU

Para desenvolver os algoritmos NP-Completo a fim de serem testados na GPU

utilizando CUDA, primeiramente foi utilizada uma estratégia similar à apresentada por

Blelloch (1990) para paralelização das funções similares e que dependem dos mesmos dados

para serem executadas, abordando assim uma árvore com a complexidade próxima a da step

complexity.

62

Utilizando esta estratégia de implementação a execução do algoritmo era similar a de

uma linguagem não determinística, onde a função Escolhe( ) seria comparada a um dos

kernels executados em paralelo que encontra a solução do problema enquanto outros kernels

obteriam um resultado de Falha( ). Um exemplo da execução do Clique Máximo do Grafo

através desta estratégia pode ser vista na Figura 28, onde cada nível da árvore é considerado

um step e apenas os kernels que se aproximam do resultado são subdivididos em mais um

nível dando continuidade na busca pela solução enquanto os outros são encerrados sem

encontrar o resultado.

No exemplo, o grafo utilizado é o descrito na Figura 25 e sua abordagem pode ser

descrita pelos seguintes passos:

Step 1: A função Escolhe([0],[1],[2],[3],[4],[5],[6]) recebe todos os vértices

para iniciar a execução em paralelo para cada elemento;

Step 2: Todos os vértices são analisados, buscando seus adjacentes, a função

Escolhe([0],[1],[2],[3]) é chamada para vértices que ainda possuem adjacentes

para serem verificados, os que não possuem são representados pela função

Falha([4],[5],[6]);

Step 3: Novamente os vértices são analisados, buscando agora os adjacentes

mútuos entre os vértices que compõem a função Escolhe([0,3],[1,2]), os que

não possuem mais adjacentes mútuos são representados na função

Falha([0,1],[0,6],[1,4],[1,5],[2,3],[2,4],[2,5],[3,6]);

Step 4: Similar ao Step 3, os vértices adjacentes mútuos são buscados, porém

como os mesmos não existem mais, é chamada a função

Sucesso([0,3,6],[1,2,4],[1,2,5]), para o resultado da execução.

63

Figura 28. Estratégia inicial de NP-Completo em CUDA

Para o problema da Cobertura Mínima de Vértices, a mesma estratégia seria utilizada,

buscando a cada step a remoção dos vértices para obter uma cobertura menor que a anterior.

No decorrer do uso desta estratégia problemas relacionados ao uso memória foram

encontrados, nos quais as estruturas dos dados responsáveis por armazenar o maior clique ou

maior cobertura já encontrada e o caminho que já havia sido percorrido não podiam mais ser

compartilhadas, mas sim exclusivas de cada kernel, resultando assim, não só na troca do uso

exponencial de tempo para o uso exponencial de processamento, mas também para o consumo

exponencial de memória, representado pela Equação 5, que tem como resultado o número de

caminhos possíveis de serem percorridos, ou seja, a quantidade de conjuntos de memória para

ser possível gerar o resultado final é exponencial. Um exemplo deste consumo seria para um

grafo formado por 40 vértices, na qual seriam necessários cerca de 1TB de memória para

guardar os possíveis caminhos.

( 2(n-1)

)

A Figura 29 exemplifica, de forma aproximada, a quantidade de memória,

em megabytes, em função da quantidade de vértices existente no grafo.

Equação 5

64

Figura 29. Função vértices por megabytes de memória utilizados na estratégia inicial

Assim como o crescimento da memória o número de chamadas ao kernel (unidades de

processamento) também é exponencial, na qual um kernel é responsável por resolver um step,

ou seja, um diferente caminho e se dividindo a medida que os caminhos fossem se

ramificando, um exemplo desta abordagem pode ser visto na Figura 30 juntamente com os

possíveis caminhos.

Figura 30. Caminhos e chamadas exponenciais dos kernels

0

200

400

600

800

1000

1200

20 21 22 23 24 25 26 27 28 29 30

Me

ria

Vértices

MB

65

3.2.1 Soluções alternativas para GPU

Devido ao fato da solução inicial proposta neste trabalho não ter sido possível, o foco

do mesmo se tornou em buscar abordagens alternativas na qual os problemas NP-Completo

fossem capaz de tirar proveito da arquitetura CUDA.

3.2.1.1 Solução alternativa – Paralelizando operações

Por ser comum em problemas NP-Completo o uso exaustivo de operações de união e

intersecção de vetores, foi implementado, como solução alternativa à estratégia inicial, um

algoritmo em CUDA seguindo a mesma lógica dos algoritmos desenvolvidos para a CPU,

porém operações com os elementos em uma lista puderam ser paralelizadas para executarem

na mesma unidade de tempo, por exemplo, o Quadro 10 mostra como é feita uma operação de

cópia do vetor "x", de tamanho N, para o vetor "c", na CPU utilizando um laço de repetição já

o Quadro 11 exibe como esta mesma operação pode ser feita na GPU, para N = número de

threads paralelas, na qual cada thread é responsável por copiar um dos N elementos.

for (int i=0; i<N; i++) {

x[i] = c[i];

}

Quadro 10. Cópia de vetor na CPU

x[threadIdx.x] = c[threadIdx.x];

Quadro 11. Cópia de vetor na GPU para N threads

Devido a vantagem da GPU trabalhar com N elementos em paralelo algumas outras

otimizações também se tornaram viáveis, como por exemplo, uma verificação de elementos

vazios na lista, na qual todos os elementos puderam ser verificados em uma única unidade de

tempo.

Mesmo com as otimizações feitas nas operações com os elementos dos vetores e

matrizes, o desempenho obteve melhoras apenas em um dos ambientes, entretanto os mesmos

não foram significativos quando comparados com a CPU. Este resultado ocorreu devido ao

fato que esta implementação exige muitas leituras e gravações na memória global da GPU,

que é considerada lenta, levando assim a resultados que comprovam que uma lógica para CPU

nem sempre obtém bons resultados quando levada para a GPU, mesmo porque não houve

66

alteração na função de limite superior de complexidade. Esta comparação entre CPU e GPU

paralelizando operações pode ser acompanhada mais adianta na seção de resultados.

O algoritmo descrito foi desenvolvido para ser usado nos testes em GPU, sua

codificação pode ser visualizada no Apêndice B.1.

3.2.1.2 Solução alternativa – Subgrafos

Como os resultados obtidos com a primeira solução alternativa não foram satisfatórios

uma nova estratégia foi buscada pensando em dividir o grafo e processá-las em paralelo,

respeitando as limitações apresentadas por CUDA, na solução anterior, e visando explorar

onde a arquitetura se mostrou mais eficiente.

Para o Clique Máximo do grafo foi adotada a estratégia de dividir o grafo testado em n

subgrafos, onde n é o número de vértices do grafo, ou seja, n outros grafos menores conforme

ilustra a Figura 31.

Figura 31. Divisão do grafo em n subgrafos

67

Para cada subgrafo foi disparado um bloco, no total de n, número de vértices, blocos

executados em paralelo, capazes de resolver o Clique Máximo conectado ao vértice inicial do

subgrafo, por exemplo, no subgrafo tratado pelo bloco zero o vértice zero é o vértice inicial e

o resultado da execução deste bloco é dado pelo maior clique envolvendo o vértice zero, o

mesmo o corre para os vértices um, dois, três, até n. No final o tamanho de cada clique é

comparado a fim de encontrar o maior dentre eles. Para isso foram utilizadas duas principais

estruturas:

A: Lista de adjacentes do vértice tratado no bloco;

maxCliques: Lista dos maiores cliques encontrados;

Esta solução acaba por executar cálculos repetidos, como por exemplo, o maior clique

é encontrado p vezes, na qual p é a quantidade de vértices do maior clique, entretanto estes

cálculos repetidos são executados paralelamente.

Na estratégia, apenas uma thread é disparada por bloco, melhorias na execução desse

bloco podem ser obtidas utilizando uma quantidade maior de threads, na qual operações

comuns em problemas NP-Completo, como por exemplo, união e intersecção, possam ser

feitas de forma mais rápida.

Assim como o algoritmo Paralelizando Operações, o algoritmo para a estratégia

Subgrafos foi desenvolvido para ser usado nos testes em GPU, sua codificação pode ser

visualizada no Apêndice B.2.

3.3 Testes e Resultados

Após o desenvolvimento dos algoritmos foram feitos os testes de execução dos

mesmos, na qual foram selecionados três diferentes ambientes visando garantir desempenho e

confiabilidade dos resultados, além disso, foram selecionados grafos já conhecidos para

garantir que os resultados dos mesmos estivessem corretos. Feitas as execuções os tempos das

mesmas foram verificados e comparados.

A apresentação dos resultados foi dividida em três subseções: (i) Ambiente, na qual

descreve os equipamentos utilizados na execução dos testes; (ii) Grafos, onde são descritos os

grafos selecionados para as execuções; (iii) Resultados, apresentam os resultados obtidos em

cada ambiente e comparações entre estes.

68

3.3.1 Ambiente

Os testes realizados neste trabalho foram divididos em três ambientes:

CPU: notebook composto por uma CPU Intel Core i3 M370 de 2,40GHz, 3GB

de memória RAM DDR3;

GPU 1: computador composto por uma CPU Intel Pentium 4 de 2,26GHz, 2GB

de memória RAM DDR2 e uma GPU NVIDIA GeForce GT 640 possuindo

384 núcleos CUDA, interface de memória 128-bit e 2GB de memória DDR3;

GPU 2: computador composto por uma CPU Intel Core i5 de 3,3GHz, 12GB

de memória RAM DDR3 e uma GPU NVIDIA GeForce GTX 670 possuindo

1344 núcleos CUDA, interface de memória 256-bit e 2GB de memória

GDDR5;

Os algoritmos em CUDA foram compilados utilizando o NVIDIA Cuda compiler

driver (nvcc) versão 4.2 e foram utilizados os parâmetros “-

gencode=arch=compute_30,code=sm_30,compute_30” para utilizar as operações disponíveis

nas GPUs compute capability 3.0 e obter melhor desempenho das GPUs desta versão da

arquitetura CUDA, uma vez que ambas utilizadas nos testes fizam parte desta.

3.3.2 Grafos

Os grafos utilizados para os testes são listados no Quadro 12 e foram selecionados

dentre os grafos disponibilizados pela DIMACS (Center for Discrete Mathematics and

Theoretical Computer Science), um grupo norte americano formado por empresas e

universidades para estudo envolvendo aplicações práticas da matemática discreta e teoria da

computação, em seus desafios que buscam determinar o desempenho de alguns algoritmos de

seu interesse (DIMACS, 2012).

69

Vértices Arestas Vértices no Clique

Máximo

Vértices na Cobertura

Mínima

johnson8-2-4 28 210 4 24

hamming6-4 64 704 4 60

c-fat200-1 200 1534 12 188

Quadro 12. Grafos utilizados para teste

3.3.3 Resultados

Os algoritmos foram executados e o tempo de execução da função principal foi

coletado, para garantir que os resultados sejam os mais precisos possíveis, o resultado é dado

pela média do tempo de cinco execuções em cada ambiente, na qual o mesmo estava dedicado

exclusivamente aos algoritmos descritos por este documento, reduzindo assim interferências

pelo sistema operacional e de outros aplicativos que pudessem estar executando no mesmo

momento.

Os tempos resultantes das execuções do Clique Máximo do grafo podem ser vistos e

comparados na Figura 32, que exibe os resultados das exeucuções em CPU, de forma

sequencial e dos algoritmos Paralelizando operações e Subgrafos nos dois ambientes

utilizando GPU já descritos. Estes resultados também podem ser visualizados no Quadro 13.

70

Figura 32. Resultados da execução do clique do grafo

Com tempos resultantes das execuções do Clique Máximo do grafo é possível

observar que a CPU apresenta vantagens sobre a GPU 1 quando os grafos são menores, ou

seja, quando se tem pouco processamento em relação a quantidade dos dados a serem

processados, o que não ocorre com a GPU 2 pois a mesma é bem mais eficiente neste tipo de

operação, já as GPUs apresentam vantagem quando os grafos se tornam maiores, essa

vantagem é dada uma vez que a quantidade de dados aumentam um pouco e seu tempo de

alocação na memória gráfica cresce proporcionalmente, entretanto a quantidade de

processamento executado sobre esses dados cresce exponencialmente. Em resumo, nas GPUs,

o algoritmo executando grafos menores passa mais tempo preparando os dados do que os

executando, já nos grafos maiores, o tempo para preparar os dados deixa de ser significativo

devido ao tempo de processamento se tornar sobressalente e este crescimento tende a ser mais

notável a medida em que os grafos forem crescendo.

16 47

440

17,9 63,26

684,24

32,22 93,42

178,54

7,9 28,02

324,66

23,24 53,26

147,94

0

100

200

300

400

500

600

700

800

johnson8-2-4 hamming6-4 c-fat200-1

CPU CUDA - Paralelizando operações GT 640

CUDA - Subgrafos GT 640 CUDA - Paralelizando operações GTX 670

CUDA - Subgrafos GTX 670

Mil

isse

gundos

71

Vértices

Tamanho do Clique Máximo CPU

CUDA - Paralelizando operações GT 640

CUDA - Subgrafos GT 640

CUDA - Paralelizando operações GTX 670

CUDA - Subgrafos GTX 670

johnson8-2-4 28 4 16ms 17,9ms 32,22ms 7,9ms 23,24ms

hamming6-4 64 4 47ms 63,26ms 93,42ms 28,02ms 53,26ms

c-fat200-1 200 12 440ms 684,24ms 178,54ms 324,66ms 147,94ms

Quadro 13. Resultados da execução do clique do grafo

72

4 CONCLUSÕES

Buscando desenvolver uma solução de alto desempenho utilizando a arquitetura

CUDA, surgiu a proposta de avaliar os problemas NP-Completo, fazendo a relação entre uma

arquitetura inovadora e ascendente e problemas clássicos, constatados como

computacionalmente difíceis há décadas.

Para realizar o projeto, foram levantados temas para fundamentá-lo, como análise de

complexidade, que visou definir a dificuldade de encontrar uma solução ótima para problemas

da classe NP-Completo e permitiu analisar os algoritmos desenvolvidos. Por meio das

referências tornou-se possível definir também, e documentar os problemas computacionais

nas quais seriam trabalhados, Clique Máximo do Grafo e Cobertura Mínima de Vértices,

visando representar de forma geral todos os problemas exponenciais que compõem a classe

NP-Completo. Outra área necessária para a fundamentação e para modelagem do projeto foi

programação paralela, estudada e documentada de forma geral, permitindo aprofundamento

nos conceitos da arquitetura CUDA.

Baseado nos objetos de estudo do trabalho, foram pesquisados e documentados

trabalhos similares, que de alguma forma utilizam da arquitetura CUDA para solucionar

problemas inseridos na classe NP-Completo, na qual ajudaram nas definições de estratégias

que foram adotadas para o desenvolvimento dos algoritmos para a GPU.

Depois de analisar a literatura sobre conceitos agregados ao trabalho, foi iniciado a

implementação dos problemas utilizando da CPU e de estratégias sequenciais para solução do

problema. Após a implementação as estratégias foram analisadas de forma a documentar suas

complexidades e demonstrar a presença dos problemas na classe NP-Completo. Ambos os

problemas implementados, Clique Máximo do Grafo e Cobertura Mínima de Vértices,

obtiveram a complexidade dada pela função O(2n) que representa suas taxas de crescimento

exponencial.

Baseado nas definições de complexidade paralela, estudos dos problemas e trabalhos

similares, foi definida uma estratégia inicial, mas a mesma se tornou inviável devido ao uso

exponencial de memória, que era possível na CPU, pois cada caminho percorrido para solução

dos problemas, por serem executados sequencialmente, podiam compartilhar as estruturas

responsáveis por armazenar os vértices já analisados, os que faltavam analisar e os resultados

73

já encontrados, entretanto a solução definida para CUDA, por executar vários caminhos em

uma mesma unidade de tempo, passou a necessitar de memória exclusiva para guardar seus

dados.

Com a necessidade de cada caminho possuir sua própria memória, pôde-se constatar

que, utilizando a estratégia inicial, houve a troca de uso tempo de execução exponencial para

consumo de espaço em memória e unidades de processamento exponenciais.

Devido ao insucesso desta estratégia (apresentada inicialmente no TTC I), uma

tentativa de paralelização de operações, já que CUDA consegue fazer isso com facilidade, foi

implementada, porém não apresentou resultados significativamente melhores, uma vez que o

algoritmo paraleliza apenas um conjunto muito pequeno das operações, no que levou a

concluir que uma boa lógica de implementação em CPU, nem sempre tira proveito dos

benefícios que CUDA pode proporcionar, neste caso, sendo necessário uma nova abordagem.

Mais uma vez o insucesso levou a busca de uma nova estratégia, desta vez procurando

uma estratégia que dividisse o problema em diversos problemas menores a fim de que esses

pudessem ser executados de forma paralela, utilizando as vantagens apresentadas por CUDA

nos trabalhos similares e nas implementações feitas anteriormente. A alternativa encontrada

foi divisão do grafo em "n" subgrafos, na qual "n" representa o número de vértices, para

serem processados paralelamente e no fim a melhor solução dentro de pequenos subgrafos

formaria a solução do grafo completo.

Problemas NP-Completos são resolvidos em CPU através de árvore de backtracking

juntamente com estratégias de buscas exaustiva (em profundidade) procurando assim exaurir

todas as possibilidades até encontrar o melhor conjunto de configurações em relação à

resposta ao problema. Em CUDA boas estratégias devem se basear em dividir o problema em

etapas evitando a forma sequencial de solução (backtracking junto com busca exaustiva). Para

CUDA deve-se dividir o problema em etapas de acordo com a estratégia “divisão por

conquista” buscando paralelizar as operações e evitando consumo excessivo de memória, uma

vez que soluções paralelizáveis possuem memórias de acesso rápido muito restrito.

Com isso, verificando os testes e comparações realizadas, em busca do objetivo geral

deste trabalho, comprovou-se assim a eficiência de CUDA com os problemas NP-Completo

uma vez que os mesmos sejam abordados de forma a utilizar os benefícios e respeitar as

limitações da arquitetura.

74

Pode se listar ainda algumas dificuldades enfrentadas durante o desenvolvimento deste

trabalho, na qual foi gasto muito tempo em busca da solução proposta na etapa de projeto, que

por fim não pôde ser utilizada, tornando assim problema um dos riscos citados, levando a

busca por uma solução alternativa compatível com a arquitetura CUDA. Esta que atualmente

ainda possui algumas limitações, como por exemplo, a impossibilidade de um kernel fazer a

chamada de um novo kernel passando para este novas configurações de threads, esta

limitação será eliminada nos novos núcleos GK110 segundo a fabricante NVIDIA, outra

restrição da arquitetura são os limites de memória consideradas rápida, na qual impossibilitam

o uso grafos com uma grande quantidade de vértices e arestas.

Outra grande dificuldade enfrentada durante o desenvolvimento deste trabalho foi a

inativação, devido a atividades suspeitas, do fórum da fabricante, fórum oficial da arquitetura

CUDA, onde é possível encontrar várias opiniões e soluções a problemas comuns aos

desenvolvedores que utilizam a arquitetura, o mesmo foi inativado na primeira semana de

julho de 2012 e voltou a estar disponível no dia 01 de novembro de 2012.

Pode-se listar ainda uma dificuldade encontrada ao executar grafos maiores do que os

descritos pelo documento onde os mesmos apresentavam um erro desconhecido (“unknown

error”), na qual se suspeita de alguma restrição com relação ao uso de memória, entretando

não foi possível identificar seu motivo antes da conclusão deste trabalho, na qual a busca pelo

motivo e tentativa de solução foi listada como uma sugestão de trabalho futuro.

Como futuros trabalhos, podem-se citar: (i) fazer as implementações utilizando a CPU

de forma paralela e incluí-la nas comparações; (ii) reimplementar os algoritmos propostos na

proposta inicial utilizando a nova versão de CUDA 5 incluindo programação dinâmica,

disponíveis nos futuros núcleos GK110; (iii) fazer as mesmas comparações de ambientes

entre os problemas NP-Completo, porém buscando heurísticas que tragam não a solução

ótima, mas sim aproximada; (iv) verificar a possibilidade, em caso positivo desenvolver, os

algoritmos para solução do problema do Clique Máximo descritos por Carmo e Züge (2012)

utilizando CUDA; (v) implementar outros problemas NP-Completo utilizando a arquitetura

CUDA comparando os resultados com os obtidos neste; (vi) incluir técnicas de programação

dinâmica nas soluções apresentadas; (vii) utilizar o corte na quantidade de subgrafos

processados, calculando o Clique Máximo de menor tamanho possível descrito por Wilf

(1986) e Budinich (2012); (viii) incluir nos testes grafos com maior quantidade de vértices e

arestas.

75

REFERÊNCIAS

AARONSON, S. The Complexity Zoo. Disponível em:

<www.cse.unl.edu/~cbourke/latex/ComplexityZoo.pdf >. Acesso em: 30 mar. 2012.

ARORA, S.; BARAK, B. Computational complexity: a modern approach. Nova York:

Cambridge University Press, 2009.

BARNEY, B. OpenMP, Livermore, 2012. Disponível em :

<https://computing.llnl.gov/tutorials/openMP/>. Acesso em: 01 jun. 2012.

BLELLOCH, G. Vector Models for Data-Parallel Computing. [S.l.]: MIT Press, 1990.

BUDINICH, M. Bounds on the Maximum Clique of a Graph. Disponível em: <

http://www.ts.infn.ir/~mbh/MC_Bounds.ps.Z >. Acesso em: 27 out. 2012.

CARMO R.; ZÜGE, A. Branch and bound algorithms for the maximum clique problem under

a unified framework. In: J Braz Compit Soc, 18, 2012. p. 137-151.

DIMACS. DIMACS Implementation Challenges. Disponível em: <

http://dimacs.rutgers.edu/Challenges/ >. Acesso em: 10 out. 2012.

FEIER, M. C.; LEMNARU, C.; POTOLEA, R. Solving NP-Complete Problems on the

CUDA Architecture using Genetic Algorithms. In: International Conference on Parallel and

Distributed Processing Techniques and Applications, 10, 2011, Las Vegas. Proceedings…

Washington, IEEE Computer Society 2011.p. 278-281.

HARRIS, M. GPGPU: General General-Purpose Computation on GPU, 2005. Games

Developers Conference. Disponível em:

<ftp://216.151.177.77/developer/presentations/2005/GDC/OpenGL_Day/OpenGL_GPGPU.p

df> Acesso em: 1 mar. 2012.

HOPCROFT, J. E.; MOTWANI, R.; ULLMAN, J. D. Introduction to automata theory,

languages and computation, 2. ed. [S.l.] Prentice-Hall, 2001.

IKEDA, P.A. Um estudo do uso eficiente de programas em placas gráficas. São Paulo:

[s.n.], 2011.

KREHER, D. L.; STINSON, D. R. Combinatorial Algorithms: Generation, Enumeration,

and Search. Rockville: CRC Press, 1999.

NVIDIA. NVIDIA CUDA Architecture: Introduction & Overview 2009. Disponível em:

<http://developer.download.nvidia.com/compute/cuda/docs/CUDA_Architecture_Overview.p

df >. Acesso em: 29 mar. 2012.

NVIDIA. Next Generation CUDA Architecture, Code Named Fermi. 2012. Disponível

em: < http://www.nvidia.com/object/fermi_architecture.html>. Acesso em: 29 fev. 2012.

NVIDIA. What is CUDA. 2012. Disponível em: < http://developer.nvidia.com/what-cuda>.

Acesso em: 27 abr. 2012.

76

NVIDIA. What is GPU Computing? Disponível em: < http://www.nvidia.com/object/what-

is-gpu-computing.html>. Acesso em: 14 maio 2012.

O`NEIL, A. TAMIR, D.; BURSTSCHER, M. A Parallel GPU Version of the Traveling

Salesman Problem. In: International Conference on Parallel and Distributed Processing

Techniques and Applications, 10, 2011, Las Vegas. Proceedings… Washington, IEEE

Computer Society 2011.p. 348-353.

ROCHA, R. Programação Paralela e Distribuída: fundamentos. 2007. Disponível em: <

http://www.dcc.fc.up.pt/~ricroc/aulas/0708/ppd/apontamentos/fundamentos.pdf >. Acesso

em: 05 jun. 2012.

ROQUE, A. S. Taxonomia de Flynn. 2012. Disponível em: <http://srvapp2s.

santoangelo.uri.br/labcd/wp-content/uploads/2012/04/Taxonomia_de_Flynn.pdf >. Acesso

em: 04 jun. 2012.

SANDERS, J.; KANDROT, E. CUDA by example: an introduction to General-Purpose GPU

programming. Boston: Pearson Education, 2011.

SIPSER, M. Introdução a teoria da computação. 2. ed. São Paulo: Thomson Learning,

2007.

TOSCANI, L. V.; VELOSO, P. A. S. Complexidade de algoritmos. 2 ed. Porto Alegre:

Sagra Luzzatto, 2005.

TANEMBAUM, A. S.; STEEN M. V. Sistemas Distribuídos: princípios e paradigmas, 2. ed.

São Paulo: Pearson Prentice Hall, 2007.

TANENBAUM, A. S. Sistemas operacionais modernos. São Paulo: Pearson Prentice Hall,

2003.

VIANA, J. R. M. Programação em GPU: Passado, presente e futuro. 2011. Disponível em:

<http://www.ufpi.br/subsiteFiles/ercemapi/arquivos/files/minicurso/mc1.pdf> Acesso em: 1

mar. 2012.

WILF, H. S. Spectral bounds for the clique and independence numbers of graphs, J. Combin.

Theory. Proceedings… Vol 40, 1986. p. 113-117.

ZIVIANI, N. Projetos de Algoritmos. São Paulo: Cengage Learning, 2007.

77

GLOSSÁRIO

Ordem Ômicron Define uma cota assintótica superior.

Ordem Ômega Define um limite assintótico exato.

Ordem Theta Define uma cota assintótica inferior.

Decidível Diz-se decídivel um problema que possui uma resposta sim ou não,

encontrado nas referências como tradução do termo decibility.

Satisfazibilidade Primeiro problema NP-Completo, que consiste em testar se uma

expressão com diversas variáveis booleanas é satisfazível, encontrado

nas referências como tradução do tempo satisfiability.

78

APÊNDICE A. ALGORITMOS PARA CPU

A.1. CLIQUE MÁXIMO DO GRAFO

void MaxClique(int l)

{

if (l > optSize)

{

optSize = l + 1;

optClique.clear();

for (int i = 0; i < l; i++)

{

optClique.push_back(x[i]);

}

}

if (l == 0)

{

c[l] = vector<int>();

for (vector<int>::iterator it = v.begin(); it != v.end();

it++)

{

c[l].push_back(*it);

}

}

else

{

int posAB = x[l - 1];

c[l].clear();

for (vector<int>::iterator it = a[posAB].begin(); it !=

a[posAB].end(); it++)

{

if (find(b[posAB].begin(), b[posAB].end(), *it) !=

b[posAB].end() && find(c[l - 1].begin(), c[l - 1].end(), *it) != c[l

- 1].end())

{

c[l].push_back(*it);

}

}

}

for (vector<int>::iterator x2 = c[l].begin(); x2 != c[l].end();

x2++)

{

x[l] = *x2;

MaxClique(l + 1);

}

}

Quadro 14. Algoritmo Clique Máximo do Grafo - Função MaxClique

79

A.2. COBERTURA MÍNIMA DE VÉRTICES

void CobMin(int l, vector< vector<int> > cobertura)

{

totalChamadas++;

if (cobertura.size() == QTD_ARESTAS && tamanho(x) <

menorCobertura)

{

menorCobertura = tamanho(x);

saida.clear();

for (int i = 0; i < l; i++)

{

saida.push_back(x.at(i));

}

limpaVetor(l);

return;

}

if (l == 0)

{

menorCobertura = v.size();

c[l].clear();

for (vector<int>::iterator it = v.begin(); it != v.end();

it++)

{

c[l].push_back(*it);

}

}

else

{

c[l].clear();

for (vector<int>::iterator it = b[x.at(l - 1)].begin(); it !=

b[x.at(l - 1)].end(); it++)

{

if (find(c[l - 1].begin(), c[l - 1].end(), *it) != c[l -

1].end())

{

c[l].push_back(*it);

}

}

}

for (vector<int>::iterator x2 = c[l].begin(); x2 != c[l].end();

x2++)

{

x[l] = *x2;

CobMin(l + 1, getCoberturaAtual(cobertura, x.at(l)));

}

limpaVetor(l);

}

Quadro 15. Algoritmo Cobertura Mínima de Vértices - Função CobMin

80

vector< vector<int> > getCoberturaAtual(vector< vector<int> >

&coberturaAtual, int vertice)

{

vector< vector<int> > novaCobertura;

for(int i=0; i < coberturaAtual.size(); i++)

{

vector<int> aresta;

for(int j=0; j < coberturaAtual[i].size(); j++)

{

aresta.push_back(coberturaAtual[i].at(j));

}

novaCobertura.push_back(aresta);

}

for(int i=0; i < arestas.size(); i++)

{

vector<int> aresta = arestas.at(i);

if(vertice == arestas.at(i).at(0) || vertice ==

arestas.at(i).at(1))

{

bool existe = false;

for(int j=0; j < coberturaAtual.size(); j++)

{

vector<int> arAtual = coberturaAtual.at(j);

if((arAtual.at(0) == aresta.at(0) && arAtual.at(1) ==

aresta.at(1))

|| (arAtual.at(1) == aresta.at(0) &&

arAtual.at(0) == aresta.at(1)))

{

existe = true;

break;

}

}

if(!existe)

{

novaCobertura.push_back(arestas.at(i));

}

}

}

return novaCobertura;

}

Quadro 16. Algoritmo Cobertura Mínima de Vértices - Função getCoberturaAtual

81

APÊNDICE B. ALGORITMOS PARA GPU

B.1. CLIQUE MÁXIMO DO GRAFO – PARALELIZANDO

OPERAÇÕES

__device__ void OperacoesParalelas(int l, float* dv, float* da, float* db,

float* dc, float* doptClique, float* x, int pitch_a, size_t pitch_b, size_t

pitch_c, int *optSize)

{

if (l >= *optSize)

{

*optSize = l + 1;

ClearCuda(doptClique);

if (threadIdx.x < l)

{

doptClique[threadIdx.x] = x[threadIdx.x];

}

}

if (l == 0)

{

register float* rowc = (float*)((char*)dc + l * pitch_c);

rowc[threadIdx.x] = dv[threadIdx.x];

}

else

{

register int posAB = x[l - 1];

Clear2Cuda(dc, pitch_c, l);

register float* rowi = (float*)((char*)da + posAB * pitch_a);

register float* ceml = (float*)((char*)dc + l * pitch_c);

register float* cemlmenosum = (float*)((char*)dc + (l-1) *

pitch_c);

register float* rowj = (float*)((char*)db + posAB * pitch_b);

register float* rowk = (float*)((char*)dc + (l-1) * pitch_c);

if(rowk[threadIdx.x] != -1 && rowi[threadIdx.x] != -1 &&

rowj[threadIdx.x] != -1)

{

for (register int ic = 0; ic < QTD_VERTICES; ic++)

{

if(rowi[ic] == -1)

{

continue;

}

for (register int jc = ic; jc < QTD_VERTICES; jc++)

{

if(rowj[jc] == -1)

{

break;

}

if(rowi[ic] == rowj[jc] && rowj[jc] ==

rowk[threadIdx.x])

{

ceml[threadIdx.x] =

cemlmenosum[threadIdx.x];

}

}

}

}

}

register float* ceml = (float*)((char*)dc + l * pitch_c);

for(register int i = 0; i < QTD_VERTICES; i++)

{

if(ceml[i] != -1)

{

82

x[l] = ceml[i];

MaxCliqueCudaRec(l+1,dv,da,db,dc,doptClique,x,pitch_a,pitch_b,pitch_c

,optSize);

}

}

}

Quadro 17. Algoritmo Paralelizando Operações do Clique Máximo do Grafo em CUDA

B.2. CLIQUE MÁXIMO DO GRAFO – SUBGRAFOS

__global__ void Subgrafos(short * d_maxCliquesMatrix, int

pitch_maxCliquesMatrix, short * d_a, int pitch_a, short * d_ordem)

{

short orSel = blockIdx.x * blockDim.x + threadIdx.x;

short d_ordemSel = d_ordem[orSel];

short * rowACopia = (short*)((char*)d_a + d_ordemSel *

pitch_a);

__shared__ short a[QTD_THREADS][QTD_VERTICES + 1];

for(int i = 0; i < QTD_VERTICES + 1; i++){

a[threadIdx.x][i] = rowACopia[i];

}

__shared__ short maxCliqueMatrix[QTD_THREADS][QTD_VERTICES +

1];

maxCliqueMatrix[0][QTD_VERTICES] = -1;

__shared__ NosRec * topo;

topo = (NosRec *)malloc(sizeof(NosRec));

topo->l = 0;

topo->v = -1;

topo->caminho = NULL;

topo->qtdVNovoCam = 0;

topo->cortHorizontal = 0;

topo->tamXAnterior = 0;

topo->xPassado = NULL;

topo->proximo = NULL;

short * endNovoCaminho = NULL;

while ( topo != NULL ){

__shared__ NosRec *nr;

nr = topo;

topo= topo->proximo;

int l = nr->l;

int v = nr->v;

__shared__ short * caminho;

caminho = nr->caminho;

short qtdVNovoCam = nr->qtdVNovoCam;

int cortHorizontal = nr->cortHorizontal;

short tamXAnterior = nr->tamXAnterior;

__shared__ short * xPassado;

xPassado = nr->xPassado;

__shared__ short * x;

int tamX;

int qtdVCam = 0;

if (l>0){

qtdVCam = tamXAnterior;

x = (short *)malloc(qtdVCam - cortHorizontal - 1);

tamX = qtdVCam - cortHorizontal - 1; //elcio

int ix=0;

for (int i=cortHorizontal + 1; i < qtdVCam ;i++){

83

x[ix] = xPassado[i];

ix++;

}

}else{

tamX = a[ threadIdx.x ][QTD_VERTICES]+1;

x = (short *)malloc(tamX);

for (int i=0; i< tamX-1; i++){

x[i] = a[ threadIdx.x ][i];

}

x[tamX-1] = d_ordemSel;

}

int qtdVert = tamX; //elcio

qtdVCam = l;

for (int i=0; i< qtdVert; i++){

short * novoCaminho = (short*)malloc(qtdVCam+1);

for (int j=0; j < qtdVCam; j++){

novoCaminho[j] = caminho[j];

}

novoCaminho[qtdVCam] = x[i];

int qtdVNovoCam = qtdVCam + 1;

bool clique = true;

if (l>0) {

for (int ic=0; ic < qtdVNovoCam && clique;

ic++){

short * rowA2 = (short*)((char*)d_a +

novoCaminho[ic] * pitch_a);

short rowA2QTD = rowA2[QTD_VERTICES];

for (int jc = ic+1; jc < qtdVNovoCam &&

clique; jc++){

bool enc = false;

for (int z = 0; z < rowA2QTD;

z++){

if (rowA2[z] ==

novoCaminho[jc] ){

enc = true;

break;

}

}

if (enc == false){

clique = false;

free(novoCaminho);

break;

}

}

}

if (clique && (l+1) > maxCliqueMatrix[

threadIdx.x ][QTD_VERTICES] ){

maxCliqueMatrix[ threadIdx.x

][QTD_VERTICES] = l+1;

for (int cl = 0; cl< l+1 ; cl ++){

maxCliqueMatrix[ threadIdx.x ][cl]

= novoCaminho[cl];

}

}

}

if (clique){

NosRec * nnr = (NosRec*)malloc(sizeof(NosRec))

;

nnr->l = l+1;

nnr->v = x[i];

nnr->caminho = novoCaminho;

nnr->qtdVNovoCam = qtdVNovoCam;

nnr->cortHorizontal = i;

nnr->tamXAnterior = tamX;

nnr->xPassado = x;

nnr->proximo = topo;

topo = nnr;

}

}

84

free(caminho);

free(nr);

free(x);

}

short * rowMax = (short*)((char*)d_maxCliquesMatrix +

d_ordemSel * pitch_maxCliquesMatrix);

for(int i = 0; i < QTD_VERTICES + 1; i++){

rowMax[i] = maxCliqueMatrix[threadIdx.x][i];

}

}

Quadro 18. Algoritmo Subgrafos do Clique Máximo do Grafo em CUDA