Distâncias entre vértces em um grafo Implementação sequencial André de Freitas Smaira 16 de outubro de 2013 1
1 Introdução Nesse projeto, temos por objetivo a determinação das distâncias mínimas entre todos os pares existentes de vértices em um grafo suficientemente grande para que sua representação em uma matriz de adjacência comum (formada por array 2D) seja inviável. Para tal, temos que utilizar estruturas diferentes, que ocupem somente o espaço necessário, isto é, somente seja alocado espaço para as arestas existentes. Além disso temos que escolher o algoritmo que mais se adeque à situação, tanto em relação à quantidade de dados armazenados durante o processo, quanto ao desempenho do programa e à facilidade de se realizar posteriores paralelizações. 2 Algoritmo Para essa situação, instantaneamente pensamos em dois algoritmos. São eles: Floyd-Warshall: O [N 3 ] Dijkstra: O [NE + N 2 log (N)] sendo N o número de vértices e E o número de arestas. Vejamos agora qual escolher. O algoritmo de Floyd-Warshall (O [N 3 ]) testa para cada possível intermediário, todos os pares de vértices de forma a minimizar o caminho entre eles, guardando todos as distâncias entre todos os pares a cada iteração. Se o grafo for conexo, ao fim do processo, teremos uma matriz quadrada N N armazenada com todas as mínimas distâncias, o que é inviável para a nossa situação. Além disso, a complexidade do algoritmo é, na maioria das vezes, bem superior à do algoritmo de Dijkstra. O algoritmo de Dijkstra (O [E + N log (N)]) calcula as distâncias de um vértice inicial até todos os outros através de um algoritmo guloso que se assemelha ao BFS, armazenando somente um vetor de distâncias de tamanho N, um vetor de vértices visitados de tamanho N e uma fila de prioridades de tamanho máximo E durante o processo. Repetindo isso para cada vértice inicial, obtemos todas as distâncias entre cada par de vértices a partir de um algoritmo O [NE + N 2 log (N)], portanto mais eficiente que o anterior para um grafo de baixa densidade 1
( ) E N < N. Além disso, intuitivamente temos uma distribuição de paralelização bem simples, entregando um vértice inicial para cada processo, totalizando N processos independentes entre si. 3 Representação dos Dados Para representarmos esse grafo não podemos usar uma matriz de adjacência composta por um array 2D estático tradicional de C. Temos que usar uma representação que inicia vazia e que aumenta com o acréscimo de arestas. Para representarmos os nós, podemos simplesmente usar inteiros de 0 a N 1 como representado na entrada do programa. Para ligarmos esses nós (arestas), uma opção (a utilizada por mim) é utilizarmos um a matriz de adjacência, porém, ao contrário do comumente usado, dinâmica, em que adicionamos arestas (edge, como mostrado abaixo) apenas se ela existir no grafo. typedef struct { } edge ; int d e s t ; // v e r t i c e d e s t i n o f l o a t w; // d i s t a n c i a e n t r e v e r t i c e s Para a representação da linha i da matriz de adjacência, isto é, as arestas que saem do vértice i, podemos usar a seguinte estrutura: typedef struct { } l i n e ; std : : l i s t <edge> l ; Finalmente para a representação da matriz como um todo, utilizamos um array 1D de N elementos do tipo line definido acima. Juntando a matriz de adjacência, o número de vértices e o número de arestas, pode-se criar a classe graph, com o seguinte protótipo: 2
class graph { int _nv, _na ; // nodes and edges number l i n e _adj ; // a d j a c e n t matrix public : graph ( int nv, int na ) : _nv( nv ), _na( na ) { _adj = new l i n e [ nv ] ; } // c r e a t e t h e graph graph ( graph const& g ) ; // copy c o n s t r u c t o r graph ( ) { delete [ ] _adj ; } // graph d e s t r o y e r void i n s e r t ( int o r i g, int dest, f l o a t w) ; // i n s e r t t h e edge from o r i g to d e s t with c o s t w bool s e t ( int o r i g, int dest, f l o a t w) ; // s e t t h e edge from o r i g to d e s t with c o s t w. r e t u r n f a l s e i f t h e edge doesn t e x i s t f l o a t d i s t ( int o r i g, int d e s t ) ; // d i s t a n c e from o r i g to d e s t. r e t u r n 1, i f t h e path doesn t a l r e a d y e x i s t int s i z e ( ) { return _na ; } // edges number int o r d e r ( ) { return _nv ; } // nodes number void p r i n t ( ) ; // p r i n t t h e a d j a c e n t matrix on t h e standard output (DEBUG) } ; Esse modelo de representação foi considerado adequado pelo pequeno gasto de memória adicionado à facilidade de implementação, já que a estrutura utilizada é basicamente uma matriz normal, mas com a maioria dos elementos faltantes no caso de uma matriz esparsa, como é a proposta deste trabalho. Outra coisa importante a se destacar é minha decisão em utilizar uma função externa à classe graph para calcular as distâncias mínimas. Fiz isso por uma questão simplesmente de estética, pois, apesar de gastar muito mais tempo, esse não é um método intrínseco ao grafo, isto é, não é um método que alterasse ou retornasse uma característica do grafo de forma simples 3
e sim uma rotina mais complexa que se utiliza de métodos da classe para obter os resultados desejados. Perceba que essa decisão só altera a constante de desempenho do algoritmo e não a complexidade do mesmo. Como o objetivo desses trabalhos é a comparação do algoritmo nas implementações sequencial e paralelas, considero essa uma decisão válida. Se essa diferença de tempo fosse um prejuízo importante para o objetivo do projeto, com certeza essa rotina deveria ser um método da classe. 4 Avaliação de Desempenho Para avaliação do desempenho, foi utilizada a função local_time() da biblioteca posix_time.hpp para medir o tempo de execução somente do algoritmo. Essa informação foi exibida pelo programa na saída padrão no formato n o de vértices n o de arestas tempo de execução Salvando os dados de várias execuções em um arquivo, obtemos gráficos para a análise de desempenho do programa. Vamos começar por fixar o número de vértices e análisar os resultados, com o objetivo de observar a complexidade do algoritmo já citada. Compilamos o programa descrito (seq.cpp) com a flag de otimização O2: g++ O2 seq. cpp o seq Com essa linha de compilação e executando o programa, para todos os casos de teste disponibilizados e vários outros gerados, num computador com as características descritas na seção 5, obtemos, sem nenhum erro nos resultados, o arquivo com os tempos. 4.1 Variando o número de arestas Primeiramente, vamos fixar o número de vértices em valores diferentes e observar as semelhanças e diferenças entre eles. Sabemos, devido à complexidade do algoritmo [1], que o tempo de execução é: t (N, E) = k 1 NE + k 2 N 2 log (N) 4
Fixando N, temos: t (E) = C 1 E + C 2 isto é, uma equação linear com coeficiente angular C 1 = k 1 N e coeficiente linear C 2 = k 2 N 2 log (N). Portanto tentaremos ajustar uma reta para essa primeira forma de análise para cada um dos casos e esperamos, daí, obter as constantes de proporcionalidade do algoritmo. Começaremos por um valor pequeno (N = 60). Abaixo, vemos gráfico relativo a esse valor de N. x 10 3 60 vértices 7 6 Tempo de Execução, t (s) 5 4 3 2 1 0 0 100 200 300 400 500 600 Número de Arestas N o arestas Tempo de execução (s) 60 0, 000620 240 0, 001943 420 0, 003009 600 0, 004953 Observamos um comportamento linear como previsto anteriormente. A partir desses resultados e de um ajuste dos pontos em uma reta, podemos obter as constandes da complexidade desse algoritmo: C 1 = 7, 814 10 6 C 2 = 5, 267 10 5 k 2 = k 1 = C 1 N = 1, 3023 10 7 C 2 N 2 log (N) = 3, 5734 10 9 Vamos seguir para um valor de N uma ordem de grandeza acima (N = 660): 5
660 vértices 3.5 3 Tempo de Execução, t (s) 2.5 2 1.5 1 0.5 0 0 1000 2000 3000 4000 5000 6000 7000 Número de Arestas N o arestas Tempo de execução (s) 660 0, 042093 2640 1, 719374 4620 2, 169467 6600 2, 573474 Observe que no gráfico acima os pontos não são mais ajustados perfeitamente por uma reta. Por que isso ocorre se o algoritmo é o mesmo? A resposta a essa pergunta está relacionada à memória cache. O grande aumento de arestas faz com que a quantidade de cache misses aumente consideravelmente, deslocando os três últimos pontos para cima. Ajustando uma reta a todos esses pontos, como mostrado no gráfico, e adotanto a notação já descrita, temos: C 1 = 0, 0004063 C 2 = 0, 1513 k 2 = k 1 = C 1 N = 6, 1561 10 7 C 2 N 2 log (N) = 5, 3500 10 8 Observe uma ordem de grandeza maior, como esperado pelo aumento considerável de cache misses devido ao grande aumento de arestas do primeiro para o segundo ponto. Vamos agora fazer o mesmo ajustando somente os últimos três pontos: 6
660 vértices 3.5 3 Tempo de Execução 2.5 2 1.5 1 0.5 0 1000 2000 3000 4000 5000 6000 7000 Número de Arestas C 1 = 0, 0002157 C 2 = 1, 158 k 2 = k 1 = C 1 N = 3, 2682 10 7 C 2 N 2 log (N) = 4, 0947 10 7 Observe uma constante relacionada ao termo de primeiro grau bem mais próxima da primeira, pois os pontos tem apenas um pequeno aumento relativo de cache misses. A maior diferença está no coeficiente linear, pois o aumento conjunto dos 3 pontos é muito maior que o aumento relativo entre eles. Vamos agora fazer a análise para N = 1000. Nesse caso temos muito mais pontos, gerando um resultado bom, porém comparável apenas com os três últimos pontos do gráfico anterior por causa da grande deslocalização espacial. Vamos ao gráfico: 20 1000 vértices 18 16 14 Tempo de Execução, t (s) 12 10 8 6 4 2 0 0 0.2 0.4 0.6 0.8 1 Número de Arestas 1.2 1.4 1.6 1.8 2 x 10 4 7
C 1 = 0, 0005761 C 2 = 3, 066 k 2 = k 1 = C 1 N = 5, 7610 10 7 C 2 N 2 log (N) = 4, 4385 10 7 Observe que as duas constantes estão bem próximas das constantes obtidas para os três últimos pontos do gráfico anterior, como previsto, já que nos dois casos só se esgota o cache L1 de dados, como descrito na seção 5. 4.2 Variando ambos os parâmetros Podemos agora analisar o comportamento da variação tanto do número de vértices como do número de arestas ao mesmo tempo e novamente calcular as constantes da complexidade para comparar com os casos já obtidos. Para esse gráfico temos a seguinte equação: t (N, E) = k 1 NE + k 2 N 2 log (N) k 1 = 5, 744 10 6 k 2 = 9, 907 10 5 8
Nesse gráfico, observamos constantes bem maiores. Isso ocorre pois nele foi levado em conta todos os pontos (10 N 5000, 10 E 35000), o que causa grande variação de deslocalização espacial causando, para os valores de E mais altos, grande taxa de cache misses mesmo para o cache L2 (conforme descrito na seção 5), aumentando ainda mais as constantes de complexidade em relação às previamente encontradas. Uma evidência disso é que para a maioria dos pontos com N pequeno, temos um bom ajuste sobre a superfície, enquanto que para a maioria dos pontos com N alto, temos um ajuste muito ruim pela mesma superfície. 4.3 Variando a densidade Vamos agora ver o comportamento do tempo de execução em função da densidade (número de arestas para cada vértice). Os gráficos abaixo mostram esse comportamento. 900 800 700 600 Tempo de Execução, t (s) 500 400 300 200 100 0 0 2 4 6 8 10 12 14 16 18 Densidade 9
Observe nos gráficos, que a influência da densidade é pequena em relação ao número de vértices. Isso porque a complexidade do algoritmo varia linearmente com a densidade (como pode ser observado para os pontos mais baixos) e é quadrático com o número de vértices. Mas observe também um aumento repentino de tempo de execução em função da densidade quando ela passa de 1 para 2. Isso ocorre pois esse é o limite para o qual a maioria dos pontos ultrapassa o limite de tamanho que causa muitos cache misses e que tem muita deslocalização espacial, o que acarreta em um grande gradiente positivo na superfície do gráfico. 5 Conclusões Depois de toda a análise realisada, podemos concluir que a deslocalização espacial pode causar um grande prejuízo no desempenho de um programa e, portanto, devemos analisar bem as estruturas que serão empregadas em um programa para que isso ocorra a menor quantidade de vezes possível, mesmo em um programa com grande quantidade de dados como é o caso da situação analisada nesse trabalho. 6 Especificações do Computador Para essa prática, foi utilizado um notebook da marca Toshiba com o sistema operacional Ubuntu 13.04, com as seguintes configurações: Processador Memória RAM Memória Cache Intel R Core TM i5-2410m CPU 64-bit @ 2,30GHz 2,30GHz 6,00 GB L1 data: 2 32KB L1 instructions: 2 32KB L2: 2 256KB L3: 3MB 10
Referências [1] Algoritmo de Dijkstra - Wikipédia Disponível em: http://pt.wikipedia.org/wiki/algoritmo_de_dijkstra Acessado em: 13/10/2013 [2] Algoritmo de Floyd-Warshall - Wikipédia Disponível em: http://pt.wikipedia.org/wiki/algoritmo_de_floyd-warshall Acessado em: 13/10/2013 11