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 +