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 highperformance 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 (7n3+5) e exponencial (2n) .................................... 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
CAMHAM
CPU
CUDA
GPGPU
GPU
MIMD
MPI
MISD
ms
NP
OpenCL
P
SAT
SIMD
SISD
SLI
SM
TTC
UNIVALI
DIMACS
Problema do Caminho
Problema do Caminho Hamiltoniano
Central Processing Unit
Compute Unified Device Architecture
General-purpose computing on Graphics Processing Units
Graphics Processing Unit
Multiple Instruction, Multiple Data
Message Passing Interface
Multiple Instruction, Single Data
Milissegundo(s)
Non-Deterministic Polynomial time
Open Computing Language
Deterministic Polynomial time
Satisfazibilidade
Single Instruction, Multiple Data
Single Instruction, Single Data
Scalable Link Interface
Streaming Multiprocessor
Trabalho Técnico-científico de Conclusão de Curso
Universidade do Vale do Itajaí
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 NPCOMPLETO.............................................................................................................. 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 NPCompleto 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 NPCompleto 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 NPCompleto 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 NPCompleto.
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 2n. 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 (2n)
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, percorrese 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
Constant
1x
[__device__] __constant__ int ConstantVar;
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
Ordenação e Fusão
n elementos
Geometria
Computacional
n pontos
Grafos
n Vértices, m Arestas
Numéticos
Matrizes n x m
Split-Radix Sort
Quicksort
Halving Merge
Closeste Pair
Line of Sight
Line Drawing
Minimum Spanning Tree
Maximum Flow
Maximal Independent Set
Bocinnected Components
Matrix-Vetor Multiply
Linear-Systems Solver (n = m)
Step of Simplex
Step
Complexity
O(log n)
O(log n)
O(log n)
O(log n)
O(1)
O(1)
O(log n)
O(n²)
O(log n)
O(log n)
O(1)
O(n)
O(1)
Element
Complexity
O(n log n)
O(n log n)
O(n)
O(n log n)
O(n)
O(n)
O(m log n)
O(n²m)
O(m log n)
O(m log n)
O(nm)
O(n²)
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 NPCompleto 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 NPCompleto 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.
CUDA
Problema NP-Completo
Abordado
Estratégia de
implementação
Uso de CPU Sequencial
Uso de CPU Paralela
Ganho de desempenho da
GPU em relação a CPU
Sequencial
Solving NP-Complete Problems
on the CUDA Architecture
using Genetic Algorithms
Sim
Problema SAT
Problema da Mochila
Algoritmos Genéticos
A Parallel GPU Version
of the Traveling
Salesman Problem
Sim
Caixeiro Viajante
Sim
Não
Sim
Sim
Até 67,2 vezes
Até 62 vezes
Hill Climbing
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³ ) + (
O ( 2n )
))
Equação 1
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² ) + (
O ( 2n )
))
Equação 3
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
Sucesso([0,3,6],[1,2,4],[1,2,5]), para o resultado da execução.
a
funçã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
1200
1000
Memória
800
600
MB
400
200
0
20 21 22 23 24 25 26 27 28 29 30
Vértices
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
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
johnson8-2-4
hamming6-4
c-fat200-1
28
64
200
Arestas Vértices no Clique
Máximo
210
4
704
4
1534
12
Vértices na Cobertura
Mínima
24
60
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
800
684,24
700
Milissegundos
600
500
440
400
324,66
300
178,54
200
100
16 17,932,22 7,9 23,24
147,94
93,42
47 63,26
28,0253,26
0
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
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.
71
CUDA CUDA Tamanho
Paralelizando CUDA Paralelizando CUDA do Clique
operações GT Subgrafos operações
Subgrafos
Vértices Máximo
CPU
640
GT 640
GTX 670
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.
http://dimacs.rutgers.edu/Challenges/ >. Acesso em: 10 out. 2012.
Disponível
em:
<
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/whatis-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
+
Download

Análise Comparativa de Algoritmos NP-Completo