UNIVERSIDADE TÉCNICA DE LISBOA
INSTITUTO SUPERIOR TÉCNICO
Optimização e Avaliação de Aplicações de Simulação
Numérica
João Nuno de Oliveira e Silva
(Licenciado)
Dissertação para obtenção do grau de
Mestre em Engenharia Informática e de Computadores
Orientador: Doutor Paulo Jorge Pires Ferreira
Presidente: Doutor Paulo Jorge Tavares Guedes
Vogais: Doutor João Luís Ferreira Sobral
Doutor Paulo Jorge Pires Ferreira
Setembro 2002
Dissertação realizada sob a orientação do
Prof. Doutor Paulo Jorge Pires Ferreira
Professor Auxiliar do Departamento de Engenharia Informática
do Instituto Superior Técnico da Universidade Técnica de Lisboa
Nome: João Nuno de Oliveira e Silva
Título: Optimização e Avaliação de Aplicações de Simulação Numérica
Palavras-chave: Simulação Numérica, optimização, paralelização, Memória Paralela e
Distribuída
Keywords: Numerical Simulation, Code Optimization, Paralelization, Distributed
Shared Memory
Resumo
A simulação numérica é, cada vez mais, usada na substituição de experiências físicas. Na
indústria naval, as simulações de modelos de embarcações em tanques de ensaio é
passível de ser substituída por simulações numéricas. Com um esforço, adicional ao
desenvolvimento desses programas, consegue-se obter uma redução dos tempos de
simulação, tornando-os aplicáveis em todo o processo de desenho de embarcações.
A aplicação analisada é um simulador do comportamento hidrodinâmico de carenas de
embarcações. A necessidade de optimizar esta aplicação advêm de uma das características
desejadas ser a avaliação rápida dos cascos, cujos resultados influenciam alterações
futuras no seu desenho.
Para optimizar esta aplicação utilizaram-se diversas técnicas. Ao código que resolvia os
vários sistemas de equações, e que não manipulava estruturas dinâmicas, aplicaram-se
optimizações dependentes da arquitectura dos computadores. O código que manipulava
estruturas dinâmicas apenas foi optimizado aplicando técnicas dependentes do
processador (alteração do algoritmo).
Para além das optimizações, também se realizou a sua paralelização de algum código,
usando-se threads e Memória Partilhada e Distribuída. Com estas optimizações também se
conseguiu comparar a eficiência de diversas tecnologias de suporte à execução paralela de
aplicações: threads, PVM e Memória Partilhada e Distribuída.
Ao longo deste trabalho usaram-se diversas técnicas de avaliação de desempenho.
Em resumo, para além de termos conseguido tornar a aplicação de simulação mais usável,
reduzindo-lhe o tempo de execução, também se consegui experimentar e avaliar os
ganhos obtidos com a aplicação de várias técnicas sobre código com características
distintas.
i
Abstract
Numerical simulation became a good substitution of physical experiences. In Naval
industry, ship hull numerical simulation may be used instead of ship hull towing tank
experiment. With an additional effort, by reducing simulation time, we can apply these
numerical simulations to all the ship design and analysis process.
The analyzed application simulates the ship behavior in a towing tank experience. This
application must be optimized due to the fact that the results produced should be used in
subsequent phases of the ship design process.
In order to optimize this application we used several optimization techniques. In the
equation systems solver witch didn’t handle any dynamic structure we used machinelevel optimization. On the other hand, the code that handled dynamic structures was
optimized using application-level optimizations (algorithm changes).
Besides the optimizations, we also parallelized some of its code using threads and
Distributed Shared Memory. With these optimizations we also managed to compare the
efficiency of some parallel execution support platforms: threads. PVM and Distributed
Shared Memory.
During this work we also used some performance evaluation techniques.
With this work we not only managed to make the simulation application more usable, by
reducing the execution time, but also experienced and evaluate several optimization
techniques applied to distinct codes.
iii
Agradecimentos
Ao Professor Dr. Paulo Guedes pela orientação inicial na execução do trabalho aqui
descrito e ao Professor Dr. Paulo Ferreira pela orientação final e acompanhamento na
escrita deste documento.
A todos os elementos do Grupo de Sistemas Distribuídos que ao longo destes anos se
interessaram pelo trabalho por mim realizado e pelas ideias com eles discutidas.
Ao Professor Dr. Paulo Ferreira, Engº João Garcia e Engº Alfonso Sandoval pela leitura
atenta que fizeram a este documento.
Ao Eng. David Matos e Drª Luísa Coheur pelas consultas de orientação profissional ☺
gratuitas dadas ao longo destes últimos anos.
À malta do Aikido (Engº Artur Caetano, João Almeida e Isabel Bentes) pelas horas de descontracção partilhadas e pela descoberta de algo novo.
Finalmente, mas não menos sentido, aos meus Pais e Irmãos, pelo longo acompanhamento e imprescindível apoio dado durante toda a minha educação e formação.
João Nuno Silva
Lisboa, 1 de Setembro de 2002
v
Para quem ler…
vii
Índice
Resumo........................................................................................................................... i
Abstract ....................................................................................................................... iii
Agradecimentos........................................................................................................... v
Índice............................................................................................................................ ix
Índice de Figuras..................................................................................................... xiii
Índice de Tabelas ...................................................................................................... xv
1 Introdução ...................................................................................................1
1.1 Motivação ............................................................................................................... 1
1.2 Enquadramento ..................................................................................................... 2
1.3 Contribuições......................................................................................................... 3
1.4 Estrutura do Documento...................................................................................... 4
2 Trabalho Relacionado ..............................................................................5
2.1 Introdução .............................................................................................................. 5
2.2 Técnicas de Optimização de Código Sequencial............................................ 7
2.2.1 Optimizações não dependentes da plataforma ............................................... 8
2.2.2 Optimizações dependentes da plataforma .................................................... 11
2.2.2.1 Arquitectura do Pentium II .......................................................................... 11
2.2.2.2 Optimizações aplicáveis a Pentium II ........................................................ 18
2.3 Paralelização ........................................................................................................ 21
2.3.1 Arquitectura de Sistemas Paralelos .............................................................. 22
2.3.2 Comunicação entre tarefas em multiprocessadores ..................................... 28
2.3.2.1 Memória Partilhada....................................................................................... 28
2.3.2.2 Infra-estrutura de passagem de mensagens.............................................. 29
2.3.2.3 Distributed Shared Memory............................................................................. 32
ix
Optimização e Avaliação de Aplicações de Simulação Numérica
2.3.2.4 High Performance Fortran (HPF)....................................................................34
2.3.3 Optimizações aplicáveis a código paralelo ...................................................35
2.4 Lei de Amdahl ......................................................................................................35
2.5 Técnicas de avaliação de desempenho ............................................................36
2.5.1 Timers ...........................................................................................................37
2.5.1.1 Relógio do sistema .........................................................................................37
2.5.1.2 Contadores de alta precisão .........................................................................38
2.5.2 Contadores dos processadores ......................................................................39
2.5.3 Contadores do S.O. .......................................................................................40
2.5.4 Polling vs. Instrumentação............................................................................42
2.5.5 Software de profiling.....................................................................................43
2.5.5.1 Gprof.................................................................................................................43
2.5.5.2 Etch ...................................................................................................................44
2.5.5.3 Vtune ................................................................................................................45
2.6 Resumo e enquadramento .................................................................................46
3 Enquadramento........................................................................................47
3.1 Descrição do Projecto Flash ...............................................................................47
3.1.1 Arquitectura do sistema ................................................................................49
3.1.2 Aplicação de simulação ................................................................................50
3.1.3 Ambiente de execução ..................................................................................52
3.1.4 Participação do INESC .................................................................................54
4 Realização .................................................................................................55
4.1 Optimização do Solver (Conjugate gradient)..................................................55
4.1.1 Função Solve ..............................................................................................56
4.1.2 Função Atimes ...........................................................................................58
4.1.3 Função Asolve ...........................................................................................61
4.2 Paralelização do Solver (Conjugate gradient) .................................................62
4.2.1 Paralelização usando threads........................................................................62
4.2.2 Paralelização usando DSM ...........................................................................66
4.3 Optimização do simulador (aplicação FLASH) .............................................67
4.4 Paralelização do simulador (aplicação FLASH).............................................69
4.4.1 Servidor para execução de tarefas remotas (TreadMark) .............................73
4.5 Avaliação de desempenho das versões série..................................................74
x
Índice
4.5.1 Driver para leitura de contadores ................................................................. 74
4.6 Avaliação de desempenho das versões paralelas ......................................... 76
4.7 Resumo.................................................................................................................. 78
5 Avaliação ...................................................................................................81
5.1 Ambiente de teste ............................................................................................... 81
5.2 Optimização do Solver (Conjugate gradient) ................................................. 82
5.3 Paralelização do Solver (conjugate gradient) ................................................. 87
5.4 Optimização do simulador (aplicação FLASH)............................................. 88
5.5 Paralelização do simulador (aplicação FLASH) ............................................ 89
6 Conclusões ................................................................................................93
Apêndice A – Matrizes esparsas ......................................................................... 97
Apêndice B –
Conjugate Gradient...................................................................... 99
Bibliografia .............................................................................................................. 103
Glossário................................................................................................................... 107
xi
Índice de Figuras
Fig. 2.1 – Micro-arquitectura dos processadores da família P6 com Advance Transfer Cache
(cache de nível 2)...................................................................................................................12
Fig. 2.2 – Unidade de Execução ..................................................................................................15
Fig. 2.3 – Exemplo de uso não optimizado da cache ................................................................19
Fig. 2.4 – Exemplo de uso optimizado da cache........................................................................20
Fig. 2.5 – Alteração do acesso a vectores de modo a permitir a utilização de registos .....21
Fig. 2.6 – Arquitectura de multiprocessadores de memória partilhada com ligações........23
Fig. 2.7 – Exemplo da arquitectura de um computador multiprocessador vectorial .........24
Fig. 2.8 – Exemplo da arquitectura de um computador massivamente paralelo ................25
Fig. 2.9 – Configuração de um cluster formando dois sistemas paralelos independentes .27
Fig. 2.10 – Desempenho máximo de uma aplicação paralela segundo lei de Amdahl ......36
Fig. 3.1 – Arquitectura geral da plataforma de desenvolvimento e simulação das
embarcações .........................................................................................................................49
Fig. 3.2 – Vista conceptual do simulador (versão série)..........................................................50
Fig. 3.3 – Diagrama de classes simplificado do simulador.....................................................51
Fig. 3.4 – Exemplo de uma iteração da simulação ...................................................................52
Fig. 4.1 – Optimização da função solve ..................................................................................56
Fig. 4.2 – Implementação original da função atimes ............................................................58
Fig. 4.3 – Versão optimizada da função atimes .....................................................................60
Fig. 4.4 – Implementação original da função asolve ............................................................61
Fig. 4.5 – Versão paralela dum produto interno ......................................................................64
Fig. 4.6 – Multiplicação de uma matriz por um vector (versão série)...................................64
Fig. 4.7 – Multiplicação de uma matriz por um vector (versão paralela).............................65
xiii
Optimização e Avaliação de Aplicações de Simulação Numérica
Fig. 4.8 – Criação de um vector em memória partilhada e distribuída................................ 67
Fig. 4.9 – Conversão para matriz esparsa (versão não optimizada)..................................... 67
Fig. 4.10 – Conversão para matriz esparsa (versão optimizada) .......................................... 68
Fig. 4.11 – Envio dos dados necessário à criação do sistema de equações
(comunicação entre processos usando PVM) ................................................................. 70
Fig. 4.12 – Envio dos dados necessário à criação do sistema de equações
(comunicação entre processos usando DSM) ................................................................. 72
Fig. 4.13 – Envio da solução do sistema de equações
(comunicação entre processos usando DSM) ................................................................. 73
Fig. 4.14 – Inicialização de um contador do processador Pentium ...................................... 75
Fig. 4.15 – Leitura de um contador do processador Pentium................................................ 76
Fig. 4.16 – Programação necessária à leitura dos contadores do Windows ........................ 77
Fig. 4.17 – Leitura de um valor contabilizado num contador do Windows........................ 78
Fig. 5.1 – Tempos de execução da versão paralela usando TreadMarks ............................. 90
Fig. 5.2 – Dados transmitidos e enviados pelo nó mestre nas versões paralelas usando
PVM e TreadMarks............................................................................................................. 92
Fig. A.1 – Representação esparsa de uma matriz.................................................................... 97
xiv
Índice de Tabelas
Tab. 2.1 – Eventos contabilizáveis com Performance-Monitoring counters .............................40
Tab. 2.2 – Contadores contabilizáveis com Performance counters do Windows ..................41
Tab. 3.1 – Sistemas usados pelo parceiros para execução do código de simulação............53
Tab. 4.1 – Dados manipulados na função solve e suas dependências ...............................63
Tab. 5.1 – Características dos computadores usados nos testes de desempenho ...............82
Tab. 5.2 – Análise estática do solver............................................................................................83
Tab. 5.3 – Número total de acessos à memória numa execução típica do solver................85
Tab. 5.4 – Número total de cache misses no acesso à memória numa execução típica do
solver .....................................................................................................................................86
Tab. 5.5 – Tempos de execução da versões serial do solver numa execução típica do solver
................................................................................................................................................86
Tab. 5.6 – Resultados da paralelização do solver usando threads e TreadMarks ................87
Tab. 5.7 – Tempos de execução da aplicação de simulação....................................................89
Tab. 5.8 – Speedups expectáveis e obtidos com a versão paralela usando TreadMarks......91
Tab. 5.9 – Comparação dos tempos de execução das versões paralelas usando PVM e
TreadMarks (DSM)..............................................................................................................91
xv
1 Introdução
1.1 Motivação
Desde há várias décadas que têm vindo a ser realizadas simulações de fenómenos físicos
usando computadores. Nos primeiros tempos, as simulações eram realizadas em computadores especialmente construídos para o efeito. Com o desenvolvimento da electrónica e
das arquitecturas de processadores, nasceu um mercado para supercomputadores unicamente dedicados ao cálculo numérico.
O preços dos supercomputadores sempre limitou o seu uso às grande empresas e grandes
centros de investigação. As pequenas e médias empresas nunca tiveram possibilidade de
efectuar simulações dos seus produtos devido ao custo proibitivo dos supercomputadores
e do seu tempo de processamento.
Nos últimos anos, verificou-se um aumento de desempenho considerável nos computadores pessoais, já suplantando o desempenho dos supercomputadores de alguns anos atrás.
Aliado a este aumento de desempenho, a possibilidade de usar vários computadores
ligados numa rede para a realização de cálculos em paralelo, tornou o cálculo numérico
intensivo mais acessível.
Para as pequenas e médias empresas, o uso de computadores pessoais tornou-se uma
alternativa viável à aquisição de supercomputadores. O desempenho dos computadores
pessoais está ao nível do das estações de trabalho. Também é possível, com custo mínimo,
usar vários computadores em paralelo formando um cluster. As empresas já usam abun1
Optimização e Avaliação de Aplicações de Simulação Numérica
dantemente os computadores pessoais na sua operação normal, podendo estes computadores ser usados em conjunto na simulação de produtos. Para além da fácil criação de um
cluster, também a sua escalabilidade os torna atraentes: o aumento do desempenho é conseguido substituindo os computadores existentes por outros mais rápidos ou, mais simplesmente, adicionando novos computadores ao conjunto já existente.
Para tirar partido da existência de vários computadores, é necessário alterar o código
sequencial, paralelizando-o. Um programa, depois de paralelizado, pode ser executado
em simultâneo em vários processadores ou computadores. O tempo de execução dos
programas consegue ser reduzido aumentando o número de computadores participantes
no cálculo.
O desenvolvimento destes programas de simulação é útil mas, com algum esforço adicional, é possível aumentar o desempenho destas aplicações. Este aumento do desempenho
permite que o tempo despendido na simulação dos produtos se reduza, permitindo a
avaliação de soluções distintas para o mesmo produto ou uma afinação iterativa dos
mesmos.
A solução mais óbvia para a aceleração das aplicações é a aquisição de computadores
mais rápidos. Os ganhos podem ser substanciais, mas os custos podem tornar esta solução
inviável para muitas empresas.
A optimização do código é a solução com custos mais reduzidos, se executada durante a
produção do software de simulação, visto não obrigar à aquisição de computadores mais
rápidos. A optimização consiste na alteração do código do programa de modo a que este
se execute mais rapidamente no tipo de computadores a usar na simulação. Este passo
nem sempre é realizado, visto as aplicações serem desenvolvidas por especialistas no
domínio da simulação, mas sem conhecimentos de arquitectura de computadores.
1.2 Enquadramento
O trabalho apresentado nesta tese desenvolveu-se no âmbito dum projecto europeu. Este
projecto, “FLASH – HPCN Tools for Enhanced Hydrodynamic Design of Fast Ships on Parallel
Computing Platforms”, tinha como objectivo o desenvolvimento de um pacote de software
para desenho de embarcações, que integrasse ferramentas de CAD, com software de
simulação do comportamento dos cascos das embarcações.
Após a avaliação dos meios computacionais existentes nas instalações dos participantes,
foi decidido que a plataforma de execução dos programas desenvolvidos consistiria em
2
Introdução
computadores pessoais. Também se constatou que havia a possibilidade de executar código paralelo, quer em várias máquinas quer em multiprocessadores de memória partilhada.
O trabalho por nós desenvolvido neste projecto, centrou-se na melhoria do desempenho
do código de simulação produzido por um dos parceiros, recorrendo-se à optimização do
código sequencial e do código paralelizado.
Numa primeira fase, tendo apenas acesso a uma versão preliminar do simulador, optou-se por efectuar primeiramente a sua optimização sem preocupações de execução num
ambiente paralelo:
•
Observou-se inicialmente onde era despendido mais tempo durante a execução;
•
Realizaram-se optimizações, primeiro da ordenação das instruções de modo a
reduzir os tempos perdidos pelo processador e, depois, dos algoritmos usados,
para reduzir as instruções executadas.
Durante esta primeira fase também se observaram as possibilidades de paralelização de
partes deste código.
Após a optimização do código sequencial, decidiu-se efectuar alterações à versão paralela
desenvolvida por um dos parceiros. Esta primeira versão paralela usava passagem de
mensagens (PVM[Gei94]) para efectuar a comunicação entre os computadores. Constatou-se o fraco desempenho desta versão paralela. O trabalho por nós realizado prendeu-se
com a tentativa de, usando outra tecnologia de comunicação (memória partilhada e distribuída), optimizar a versão paralela e permitir uma fácil instalação clusters de computadores pessoais.
Alterou-se a implementação paralela para passar a usar memória partilhada e distribuída,
usando-se o sistema TreadMarks[Kel94], tentando-se com esta opção reduzir a
comunicação entre os vários computadores.
Um outro factor que se teve em conta no desenvolvimento desta última tarefa, foi o público alvo da aplicação. Uma preocupação constante foi a facilidade de uso das aplicações
paralelas. Para tal alterámos o sistema de lançamento remoto das tarefas do TreadMarks.
1.3 Contribuições
A primeira e mais evidente contribuição é a optimização das várias versões do simulador.
Foram por nós desenvolvidas duas versões optimizadas do simulador:
3
Optimização e Avaliação de Aplicações de Simulação Numérica
•
Protótipo para execução em monoprocessador, optimizado e com desempenho superior à versão original;
•
Protótipo paralelo para execução em rede de computadores pessoais, com desempenho superior às versões originais (série e paralela) ;
Também ao nível de software de suporte, foram realizadas implementações que deverão
ser referidas:
•
Software para medição da ocorrência de eventos de processador associados ao desempenho de aplicações
•
Alteração do modo de iniciação de tarefas no sistema TreadMarks;
Paralelamente, pode-se considerar este documento como referência interessante a futuras
optimizações. São aqui descritas as várias técnicas de optimização por nós aplicadas a
software de simulação, com a apresentação dos ganhos reais obtidos.
1.4 Estrutura do Documento
Este documento está dividido em 6 capítulos. Neste primeiro capítulo apresenta-se uma
introdução ao trabalho realizado e descrito nesta dissertação.
No segundo capítulo apresentam-se as várias técnicas de avaliação e optimização de código existente. Descrevem-se, não só, as técnicas de optimização aplicáveis a código executado em um só processador, mas também se apresentam várias tecnologias de suporte à
execução de código em vários processadores.
Nos dois capítulos seguintes apresenta-se uma descrição do projecto onde este trabalho se
inseriu (capítulo “Enquadramento”) e o trabalho por nós aí realizado (capítulo
“Realização”).
Os resultados de desempenho são apresentadas no capítulo “Avaliação”; as conclusões do
nosso trabalho são descritas no último capítulo.
4
2 Trabalho Relacionado
São apresentadas neste capítulo, técnicas que permitem o aumento de desempenho de
aplicações de simulação numérica. São apresentadas optimizações que dependem da arquitectura de execução e outras das quais se obtêm resultados independentemente da arquitectura. São descritas algumas técnicas de optimização de código e tecnologias que
permitem a execução paralela de código em vários processadores ou computadores. A
escolhas das técnicas de optimização aqui apresentadas foi realizada ao longo do
desenvolvimento do projecto, sendo representativas das várias técnicas existentes e
possíveis de aplicar em aplicações de simulação numérica.
Em relação aos sistemas computacionais paralelos, apresentamos uma resenha histórica,
com indicação dos sistemas actualmente em uso. Também descrevemos as várias
possibilidades existentes no campo de sistemas de suporte à programação paralela.
No fim deste capítulo, apresentamos várias ferramentas de análise do desempenho
existentes e investigadas antes do início do projecto, assim como algumas características
dos computadores e sistemas operativos actuais usadas na avaliação concreta da nossa
aplicação de simulação.
2.1 Introdução
Existem duas características que se desejam do software de simulação: exactidão e rapidez.
Como os resultados a obter devem ser o mais próximos possíveis da realidade, a exactidão é característica importante. A outra característica, a rapidez, também é importante,
5
Optimização e Avaliação de Aplicações de Simulação Numérica
visto os resultados das simulações serem dados importantes para decisões futuras e necessários em tempo útil.
Neste capítulo, serão apresentadas técnicas cuja aplicação permitem a melhoria do desempenho de aplicações de simulação numérica. Também são apresentadas metodologias de
avaliação de programas, a fim de identificar o código cuja alteração introduz maiores
ganhos de desempenho.
Existem duas tarefas que se podem realizar e que permitem o aumento do desempenho
dos programas:
•
optimização do código que se executa de forma sequencial num único processador;
•
paralelização do código para ser executado em vários processadores (multiprocessadores ou rede de PC's).
Enquanto que, com a optimização do código, se altera o programa para que execute o mínimo de instruções e o mais rapidamente possível, com a paralelização, divide-se o trabalho a realizar por vários processadores ou computadores, reduzindo-se o tempo total de
execução à medida que se aumenta o número de processadores envolvidos nos cálculos.
A optimização do código sequencial necessita de acesso ao código dos programas de simulação, e com um custo muito inferior ao da aquisição de novo hardware conseguem-se
obter ganhos significativos nos tempos de execução.
A optimização de código sequencial segue duas fases:
•
Avaliação do programa
•
Aplicação de técnicas de optimização
Para se concluir quais as instruções duma aplicação que demoram mais tempo, é necessário medir com exactidão o tempo de execução de cada função. Um modo de conseguir
isso, consiste em alterar o código do programa a observar, introduzindo as chamadas às
funções de temporização no início e fim de cada função, ou em blocos pais pequenos. Em
seguida é apenas necessário avaliar os tempos medidos. As modificações podem ser realizadas automaticamente por um programa externo ou manualmente pelo programador. A
inclusão das funções explicitamente por um programador permite a medição do tempo de
execução do código com maior precisão.
Outro modo consiste em usar técnicas de amostragem. Com uma determinada frequência
avalia-se qual o código que se executa, traçando um gráfico aproximado do tempo
6
Trabalho Relacionado
despendido na execução de cada módulo e função.
As técnicas básicas de optimização sequencial consistem em alterar porções de código de
modo a reduzir o número de instruções executadas. Este tipo de optimização não necessita de conhecimento da arquitectura computacional usada. Basta apenas observar o código
que permite melhores ganhos e alterar o algoritmo aí usado de modo a reduzir o número
de instruções executadas.
Existem também outras técnicas de optimização em que o conhecimento da arquitectura é
necessário. Quando se pretende, por exemplo, reduzir o tempo de espera do processador
devido a determinados encadeamentos de instruções, é necessário conhecer a arquitectura
interna do processador. Neste caso, não é suficiente saber onde se despende mais tempo,
mas também é necessário saber com exactidão porque ocorrem estas perdas de desempenho dependentes do processador utilizado.
Para efectuar a paralelização de código, também é necessário avaliar quais as partes do
programa que permitem maiores ganhos. É necessário, depois, decidir qual o tipo de distribuição dos dados entre os vários nós de processamento e que tecnologia usar para efectuar a comunicação entre os vários nós.
Dependendo do tipo de computadores existentes, assim diferentes tecnologias de comunicação devem ser utilizadas. Se o código paralelizado vai ser executado em multiprocessadores de memória partilhada, é mais atractivo utilizar o paradigma de memória partilhada[Bro97]. Nos computadores multiprocessadores de memória distribuída, já é mais natural usar uma infra-estrutura de passagem de mensagens[Sun90], [Sni96]. Caso se deseje
utilizar vários computadores ligados por uma rede para executarem código paralelo,
pode-se usar passagem de mensagens ou memória partilhada e distribuída, como se
apresentará mais adiante.
2.2 Técnicas de Optimização de Código Sequencial
Os compiladores actuais efectuam optimizações ao código dependentes do processador
de destino. Embora tais optimizações sejam válidas e eficientes, existem alguns casos
onde a observação do código e sua alteração pelo programador pode trazer ganhos
adicionais; em particular, quando:
•
o número de instruções é elevado, havendo um modo mais eficiente de codificar o
problema;
•
a sequência dos acessos aos dados provoca atrasos nos acessos;
7
Optimização e Avaliação de Aplicações de Simulação Numérica
•
o encadeamento das instruções provoca atrasos na sua execução.
O primeiro problema apontado advém do uso de algoritmos não optimizados. Muitas
vezes, a descrição dos algoritmos, devido a requisitos de legibilidade e compreensão, não
é a óptima.
Os dois problemas seguintes são causados pelo facto dos processadores não executarem
de modo óptimo algumas sequências de instruções. Devido ao uso de caches, também a
ordem pela qual os dados são acedidos, pode provocar atrasos, como se verá na
secção 2.2.2.2.
Estes problemas podem ser resolvidos por duas acções distintas mas complementares.
Primeiro, é necessário alterar a implementação dos algoritmos de modo a reduzir o número de instruções realizadas, verificando depois se essas instruções executam do modo
mais eficiente, em termos de acesso aos dados e encadeamento de instruções, na arquitectura escolhida para a execução do programa.
2.2.1 Optimizações não dependentes da plataforma
O primeiro tipo de optimização que se deve tentar realizar é a que for independente da arquitectura. Este tipo de optimizações pode-se realizar logo após a identificação das rotinas
que demoram mais tempo a executar. Normalmente estas optimizações limitam-se a
alterar as instruções usadas de modo a reduzir o seu número; por exemplo, reduzir o
número de iterações de um ciclo, reduzir o número de operações aritméticas e reduzir o
número de comparações. Em casos extremos, este tipo de optimizações pode-se traduzir
na alteração de todo o algoritmo por um outro mais eficiente.
As optimizações apresentadas podem ser realizadas sem preocupações relacionadas com
a arquitectura de execução se o código for executado em computadores do tipo monoprocessador não vectorial. No caso de computadores vectoriais ou multiprocessadores
(secção 2.3.1), algumas alterações poderão reduzir o desempenho, visto não possibilitarem
a utilização das capacidades dos processadores[Dow98]. Com efeito, os computadores
vectoriais e os multiprocessadores aproveitam a estrutura dos dados e o modo como o
código lhes acede para executar em paralelo várias instruções.
Apresentamos de seguida algumas optimizações não dependentes da arquitectura de execução aplicáveis a código de cálculo numérico. Estas optimizações prendem-se com a eliminação de instruções repetidas, expressões complexas ou condições. Também se apresenta o loop unrolling que tenta reduzir os custos de avaliação da condição de terminação
dos ciclos.
8
Trabalho Relacionado
Eliminação de sub-expressões
Uma das situações onde se pode obter algum ganho de desempenho é na redução de cálculos duplicados.
1
2
c = a + b + d
e = q + a + b
Observa-se no exemplo anterior que se duplica o cálculo da expressão a + b. Consegue-se
optimizar este código usando uma variável auxiliar onde ficará guardado o resultado da
expressão em causa.
3
4
5
temp = a + b
c = temp + d
e = q + temp
Os ganhos desta optimização aumentam com a complexidade da expressão desnecessariamente calculada.
Uma optimização semelhante é a eliminação de cálculos invariantes dentro de um ciclo.
1
2
3
for (i = 0 ; i < N ; i++){
a[i] = a[i] / sqrt(x*x + y*x);
}
Ao exemplo anterior retirar-se-á a expressão sqrt(x*x + y*x) de dentro do ciclo, reduzindo os cálculos efectuados.
1
2
3
4
temp = sqrt( x*x +y*x);
for (i = 0 ; i < N ; i++){
a[i] = a[i] / temp;
}
Alguns compiladores efectuam a eliminação de sub-expressões simples. No entanto,
quando as sub-expressões contêm chamadas a funções, os compiladores não efectuam
esta optimização. Como as funções invocadas podem ter efeitos paralelos no estado do
programa, só o programador sabe se pode substituir as várias chamadas às funções por
uma única.
Eliminação de condições dentro de ciclos
A existência de condições dentro de um ciclo aumenta os atrasos causados por este tipo
de instrução. Quando se verifica que estas condições são a causa de grandes tempos de
execução, é necessário eliminá-las.
Quando, dentro de um ciclo, o teste depende do seu índice, é possível com o conhecimento prévio que se tem dos dados, eliminar essas condições. No exemplo seguinte, em cada
iteração do ciclo interior, comparam-se os índices i e j.
9
Optimização e Avaliação de Aplicações de Simulação Numérica
1
2
3
4
5
6
7
8
for (i = 0; i < N; i++){
for (j = 0; j < N; i++){
if (j <= i)
a[i][j] = a[i][j] + b[i][j] * c;
else
a[i][j] = 0;
}
}
A condição testada N*N vezes durante a execução do código apresentado pode ser eliminada:
1
2
3
4
5
6
7
8
for (i = 0; i < N; i++){
for (j = 0; j <= i; i++){
a[i][j] = a[i][j] + b[i][j] * c;
}
for ( ; j < N; i++){
a[i][j] = 0;
}
}
Loop unrolling
Existem situações em que o peso da avaliação da guarda de um ciclo é superior ao das
operações executadas em cada iteração. Nestes casos, o loop unrolling permite obter ganhos significativos. Com efeito, com o loop unrolling tenta-se reduzir o número de testes
executados, aumentando o número de instruções a executar em cada iteração.
1
2
3
for (i = 0 ; i < N; i++){
a[i] = A[i] +b[i] * c;
}
Se no exemplo anterior N for múltiplo de 4, pode-se aplicar loop unrolling, passando-se, em
cada iteração, a executar 4 atribuições. Considere-se o exemplo seguinte:
1
2
3
4
5
6
for (i =
a[i]
a[i]
a[i]
a[i]
}
0
=
=
=
=
; i < N; i
A[i] +b[i]
A[i] +b[i]
A[i] +b[i]
A[i] +b[i]
+= 4){
* c; i++;
* c; i++;
* c; i++;
* c; i++;
Com a aplicação desta técnica, reduz-se o tempo despendido na execução das instruções
de controle do ciclo.
Caso o número de iterações seja constante e predefinido, aquando da compilação, é possível eliminar completamente o ciclo, substituindo-o por uma simples sequência de operações.
10
Trabalho Relacionado
2.2.2 Optimizações dependentes da plataforma
A arquitectura dos processadores e computadores usados no cálculo no tempo de execução do código. Estas arquitecturas são desenvolvidas de modo a reduzir o tempo de execução das instruções mas, devido à arquitectura interna dos processadores e aos tipos de
cache usados, existem determinadas combinações de operações e acessos aos dados que
não são executadas tão eficientemente quanto possível.
Descrevem-se de seguida as características arquitecturais do processador Pentium II relevantes para as possíveis optimizações a realizar. Apresentam-se depois as optimizações
possíveis.
A escolha deste processador (Pentium II) prendeu-se com sua grande implantação e ser
este o processador usado nos computadores que executam o simulador optimizado. O
facto da sua arquitectura ser semelhante à de outros processadores disponíveis também
teve influência na sua escolha, tornando-o um bom exemplo. Este processador apresenta
toda as técnicas de aumento de desempenho presentes em todos os outros processadores
de alto desempenho:
•
arquitectura super-escalar;
•
execução de instruções for a de ordem;
•
existência de pipeline de execução
•
predição da execução dos saltos.
2.2.2.1 Arquitectura do Pentium II
Os processadores Pentium II e Pentium III[Int01a] são implementações da arquitectura de
32 bits da Intel (IA-32). Estes processadores têm uma arquitectura de execução dinâmica
que fornece as seguintes características que influenciam o desempenho das execuções de
aplicações:
•
Execução de instruções fora de ordem – para aproveitar paralelismo
•
Arquitectura super-escalar – para aproveitar paralelismo
•
Pipeline de execução – para aproveitar paralelismo
•
Predição de saltos – para reduzir atrasos de execução
•
Caches nível 1 de dados e instruções internas ao processador
Embora estes processadores tenham algumas inovações em relação ao processador
11
Optimização e Avaliação de Aplicações de Simulação Numérica
Pentium Pro, a arquitectura básica é a mesma , pertencendo todos à mesma família, que se
referencia a partir daqui como P6[Int98].
O pipeline desta família de processadores é formado por 3 partes:
•
Fetch & Decode Unit
•
Dispatch / Execute Unit
•
Retirement Unit
Na Fig. 2.1 mostra-se o esquema geral desta arquitectura e os fluxos de dados entre os
vários componentes.
System Bus
Bus Unit
2nd Level Cache
1st Level
Instruction Cache
Fetch/Decode
1st Level Data Cache
Execution
Retirement
Instruction Pool/reorder buffer
BTBs/Branch Prediction
Fig. 2.1 – Micro-arquitectura dos processadores da família P6 com Advance Transfer
Cache (cache de nível 2)
A unidade de fetch/decode é responsável por traduzir as instruções assembly em µoperações
que serão executadas na unidade de execução. Para garantir que a unidade de execução
não espera pela descodificação de instruções, a unidade de descodificação deve garantir
um fluxo constante de µoperações. Isto é conseguido aproveitando-se a rapidez da cache de
instruções e efectuando a descodificação de várias instruções em paralelo.
Também é nesta unidade que se prevêem quais as instruções a descodificar quando se
atinge um salto (JMP, JE, …). Com a informação armazenada nos BTBs (Branch Target
Buffer) prevê-se se um salto vai ser executado ou não e quais as instruções a descodificar
de seguida.
A unidade de execução é responsável por executar as instruções existentes na Instruction
12
Trabalho Relacionado
Pool de modo mais eficiente. Se uma micro-instrução não pode ser executada, porque os
seus operandos ainda não foram calculados, esta unidade pode executar outra micro-instrução; deste modo os atrasos causados por dependência de µoperações são minorados executando instruções que não dependem de operações ainda não terminadas. Devido ao desenho desta unidade, é possível executar várias instruções independentes em paralelo.
A unidade de retirement é responsável por, após a conclusão das instruções, actualizar os
dados em memória pela ordem correcta. Como as instruções podem ser executadas por
uma ordem diferente daquela com que aparecem no código, não é possível actualizar a
memória logo que uma instrução se complete. A retirement unit observa a ordem correcta
pela qual as instruções deveriam ter sido executadas e vai actualizando a memória segundo essa ordem.
Descrevem-se de seguida as várias características destes processadores, indicando qual a
funcionalidade de cada um dos componentes atrás apresentados. Consegue-se assim perceber de que modo os encadeamentos de instruções tiram ou não proveito da arquitectura
e como essas instruções se podem optimizar.
Execução de instruções fora de ordem
O núcleo dos processadores P6 permite avaliar a interdependência entre as várias instruções assembly e µoperações (resultados e operandos) e realizar a sua execução por ordem
diferente daquela pela qual aparecem no código. Conseguem-se assim esconder os atrasos
na execução das instruções, intercalando outras independentes.
A descodificação das instruções assembly é realizada na unidade de fetch/decode e são
armazenadas, com informação da localização dos operandos e resultados, na Instruction
Pool.
Aquando do fim de uma instrução, esta é retirada da Instruction Pool, e são verificadas
quais as instruções aí existentes que dependiam deste resultado. Todas aquelas que não
dependam de nenhum outro resultado podem ser imediatamente executadas pela unidade de execução. Consegue-se assim que a unidade de execução tenha sempre instruções
para executar, mesmo que não sejam executadas pela ordem pela qual aparecem no
código.
A forte interdependência entre operandos das várias instruções obriga a que estas sejam
executadas pela ordem do programa, o que pode levar ao não aproveitamento do
paralelismo associado às várias unidades do processador.
13
Optimização e Avaliação de Aplicações de Simulação Numérica
Pipeline de execução
O uso de um pipeline permite a realização de várias actividades sucessivas em paralelo.
Esta característica é conseguida utilizando as três unidades atrás descritas: unidade de
fetch/decode, unidade de execução e retirement unit.
O aumento de desempenho obtém-se pelo facto de todos os três componentes atrás descritos funcionarem em paralelo, processando dados que, num determinado instante, são
independentes. Enquanto a unidade de execução executa uma determinada instrução, a
unidade de fetch/decode lê da memória instruções que serão mais tarde executadas. Quando a unidade de execução terminar uma instrução, não necessita de esperar que novas
instruções sejam lidas e descodificadas. Estas operações foram realizadas em paralelo com
a execução de instruções anteriores.
Após a conclusão da execução de uma instrução, a retirement unit é responsável pela
actualização da memória com os resultados da execução. Esta operação é realizada em
paralelo com a leitura, descodificação e execução de outras instruções.
Arquitectura Super-escalar
A unidade de execução permite a execução de várias instruções independentes em paralelo. A unidade de execução é responsável por distribuir as várias µoperações existentes na
Instruction Pool pelos 5 portos existentes nesta unidade (ver Fig. 2.2). Esta distribuição é
realizada à medida que as várias sub-unidades de execução vão ficando livres.
Estas unidades podem executar em paralelo e permitem a finalização de várias instruções
num mesmo ciclo de relógio. Como cada unidade tem um tipo específico de instruções
que pode executar, é necessário garantir que existem na Instruction Poll instruções dos vários tipos possíveis, porque só assim é possível garantir que existe paralelismo na
execução das instruções.
A existência de uma única unidade de execução de operações de vírgula flutuante, evita
que duas instruções de vírgula flutuante sejam iniciadas simultaneamente. Neste caso,
não se aproveita tão eficientemente o paralelismo da unidade de execução. Um caso
semelhante pode ocorrer com instruções inteiras, quando duas instruções só podem ser
executadas pela mesma sub-unidade de execução.
14
Trabalho Relacionado
MMX
Execution Unit
Floating Point
Execution Unit
Port 0
Integer
Execution Unit
MMX
Execution Unit
Instruction Poll
(ReOrder Buffer)
Reservation
Station
Jump
Execution Unit
Port 1
Integer
Execution Unit
Port 2
Store
Execution Unit
Port 3, 4
Load
Execution Unit
Fig. 2.2 – Unidade de Execução
Cada uma destas sub-unidades de execução tem no seu interior um pipeline para garantir
a frequência máxima de execução de instruções (até 1 micro-instrução/ciclo). Com a combinação correcta de instruções é possível garantir uma frequência de terminação de
instruções superior a 1 instrução por ciclo de relógio.
Predição de saltos
Normalmente, a leitura e descodificação de instruções realiza-se sequencialmente, tal qual
aparecem no código, e não como são executados. Este facto é problemático no caso dos
saltos que se realizam e alteram o fluxo de execução do código.
Quando um salto ocorre, a próxima instrução a executar não é aquela que se encontra a
seguir no código. Se a leitura e descodificação for cega, as instruções anteriormente descodificadas (devido à existência de pipeline) devem ser ignoradas e será necessário recomeçar a leitura e descodificação de instruções a partir da nova posição de execução. No caso
de um if,que apenas é executado uma vez, estas perdas podem não ser muito significativas mas nos ciclos com guarda no fim de cada iteração, o tempo perdido pode ser
significativo. Em cada iteração, sempre que o salto se executa, há necessidade de esvaziar
o pipeline.
Os processadores com arquitectura IA-32 conseguem, usando o historial de execução dos
ciclos, prever qual a instrução que se vai executar de seguida. Num ciclo, por exemplo, a
previsão só falha aquando da última iteração. No caso dos processadores de família P6 é
a BTBs/Brach Prediction que armazena a direcção do salto (para a frente ou para trás),
15
Optimização e Avaliação de Aplicações de Simulação Numérica
assim como o seu destino, e indica à unidade de fetch/decode qual a instrução que, a seguir
ao salto, deve ser lida e descodificada.
Para além desta predição (dita dinâmica), quando não existe informação nos Branch Target
Buffers acerca do salto a executar, é executada predição estática; esta baseia-se em algumas
heurísticas:
•
todos os saltos não condicionais serão executados;
•
saltos condicionais para trás serão executados;
•
saltos condicionais para a frente não serão executados.
Resumindo, com estas técnicas consegue-se reduzir drasticamente o tempo perdido nos
saltos, devido à eliminação da necessidade de esvaziar completamente o pipeline na maioria das situações:
•
Salto erradamente predito — penalidade de 10 a 15 ciclos
•
Predição dinâmica
•
o
salto executado e correctamente previsto — penalidade de 1 ciclo
o
salto não executado e correctamente previsto — sem penalidade
Predição estática correcta — penalidade de aproximadamente 6 ciclos
O número de ciclos correspondentes a um salto erradamente predito é devido ao esvaziamento de todo o pipeline até ao início da nova instrução. Nas outras situações as penalidades, caso haja, são devidas à necessidade de carregar e descodificar um novo conjunto
de instruções.
No caso de um ciclo, a última iteração será erradamente prevista, perdendo-se cerca de 15
ciclos de processador. No entanto, nas várias iterações intermédias, quando as previsões
acertam, as penalidades, caso existam, são substancialmente menores.
Cache de dados e instruções
Devido à sua rapidez, os acessos aos dados em memória atrasam significativamente a execução das instruções no processador. O tempo de acesso à memória é cerca de 10 vezes
superior ao tempo de acesso aos registos internos do processador.
Para melhorar o tempo de leitura e escrita na memória existem algumas alterações arquitecturais que se podem aplicar ao computador:
•
16
Usar memória mais rápida
Trabalho Relacionado
•
Usar bancos de memória para serem acedidos intercaladamente (escondendo os
atrasos de funcionamento da memória)
•
Usar buses de memória mais largos
•
Usar caches rápidas entre a memória e o processador.
Ao desenhar um computador é necessário balancear os custos envolvidos com a aplicação
das tecnologias acima descritas e os ganhos obtidos. Uma das soluções adoptadas e que
permitem bons ganhos é o uso de caches de vários níveis e com tamanhos variados.
A cache é formada por memória rápida que, em cada instante, aloja um determinado conjunto de dados. Esta memória está organizada por linhas onde são armazenados valores
contíguos existentes na memória. Estas linhas de memória são carregadas quando o processador acede a uma posição de memória que não se encontra na cache. Na leitura do primeiro valor o tempo perdido é elevado: o valor tem de ser lido do nível de memória seguinte mas as seguintes leituras contíguas já se efectuam sem atrasos.
Com a organização da memória em várias hierarquias, cada vez mais rápidas e caras mas
mais pequenas, consegue-se reduzir o tempo de leitura da memória com uma boa relação
custo/desempenho.
Enquanto que as três primeiras alterações ao sistema de memória não obrigam ao redesenho das aplicações, o uso de caches obriga à avaliação e redesenho dos padrões de acesso à
memória para obter os ganhos máximos. Há sequências de acessos à memória que não
aproveitam os dados armazenados nas várias caches.
Os processadores com arquitectura P6 contêm, no seu núcleo, duas caches de nível 1, uma
de dados e outra de código. Estas duas caches são associativas de quatro vias, têm uma capacidade de 16 Kbytes (8 Kbytes no caso do Pentium Pro) e o comprimento de cada linha
é de 32 bytes. Usam um mecanismo de write-back e um algoritmo pseudo-LRU (least recently
used) para a substituição dos valores armazenados.
A cache de nível 2, que armazena dados e código, está ligada ao processador por um bus
dedicado de 64 bits com relógio igual ao do processador. Esta cache tem um tamanho
variável, mas superior a 256 Kbytes. Assim, dependendo do mercado de destino do
processador, alterando o tamanho da cache, consegue-se balancear o desempenho com o
custo.
O uso de dois níveis de cache consegue esconder o tamanho reduzido da cache de nível 1.
Nem todos os misses à cache de nível 1 resultam num acesso à memória. Só quando os dados não se encontram em nenhuma das caches tal acontece. Neste caso perdem-se cerca de
17
Optimização e Avaliação de Aplicações de Simulação Numérica
10 ciclos de relógio. Para o preenchimento de uma linha de cache são lidos 4 blocos de 8
bytes da memória. Os dados lidos ficam acessíveis ao processador à medida que vão sendo
transferidos para a cache.
Também com a finalidade de optimizar as escritas em memória, os processadores
Pentium usam 12 store buffers. Aquando da execução de uma instrução de escrita, os dados não são transferidos imediatamente para a cache ou memória, mas sim para um destes
buffers. Mais tarde, os dados serão realmente transferidos para a memória. Deste modo, logo que os dados são transferido para os buffers, o processador e buses ficam livres para a
realização de outras instruções.
Estes buffers também são necessários para garantir que os dados são escritos na memória
pela mesma ordem pelas quais as operações aparecem no código. Como as instruções são
ordenadas por questões de optimização, é necessário garantir que a ordem das escritas
seja a correcta.
2.2.2.2 Optimizações aplicáveis a Pentium II
Descrevem-se nesta secção algumas optimizações realizáveis em programas e que dependem das arquitecturas do processador e computador:
•
Optimização do acesso aos dados – de modo a aproveitar os ganhos introduzidos
pelas caches
•
Optimizar a execução das instruções – usando instruções mais rápidas e permitindo a sua execução em paralelo.
Existem outra optimizações[Int97], [Int99] que podem ser realizadas mas estão mais orientadas para o desenho de compiladores. A optimização do uso dos registos, por exemplo, é
um tipo de optimização que é difícil realizar tendo apenas acesso ao código de alto nível,
mas que é imprescindível que seja realizada por um compilador.
Optimização do uso da cache
O uso de caches nos computadores modernos faz com que os acessos à memória não tenham todos o mesmo custo. Ao aceder-se a uma posição de memória, se esta não estiver
já na cache, são também transferidos os valores adjacentes. As próximas leituras a algum
desses valores realizar-se-ão muito mais rapidamente. Assim, para obter os ganhos do uso
de caches, é necessário que haja localidade espacial nos acessos consecutivos à memória.
No exemplo apresentado na Fig. 2.3, devido à ordem dos ciclos e de actualização dos
18
Trabalho Relacionado
índices, a contabilização de elementos nulos de uma matriz não tira partido da existência
de caches.
1
2
3
4
5
6
7
8
for (j =0; j<= N; j++){
/* percorrer colunas */
for (i =0; I <=N; i++){
/* percorrer linhas */
if (m[i][j] == 0)
count ++;
}
}
x
i
x
x
x
x
x
j
Fig. 2.3 – Exemplo de uso não optimizado da cache
(acesso com cache misses - x, dados carregados para cache –
)
Na primeira iteração do ciclo exterior são visitadas todas as linhas da matriz, verificando
se a primeira posição é nula. Em cada acesso é lida uma linha de dados para a cache. A
partir do momento em que a cache já está cheia, é necessário substituir as linhas mais antigas. Para matrizes grandes, quando se termina a primeira iteração, as primeiras linhas já
não se encontram na cache. Se o tamanho da matriz for superior ao da cache, é provável
que a partir de determinada linha, os cache misses obriguem à substituição dos dados armazenados na cache. Assim, na segunda iteração externa são percorridas outra vez todas
as linhas da matriz, obrigando à leitura de todos os dados a partir da memória.
Neste caso, a existência da cache não se reflecte me ganhos observáveis, Com efeito, os
acessos aos dados demorariam o mesmo tempo se as caches não existissem. Com esta
aproximação não se obtém ganho nenhum do uso da cache. Em todas as leituras é
necessário efectuar a transferência de dados a partir da memória principal. Trocando a
ordem dos ciclos já se obtêm ganhos do uso da cache, tal como se observe na Fig. 2.4.
No primeiro acesso à matriz, é necessário efectuar a leitura de uma linha da cache. Os próximos acessos (nos endereços seguintes) já aproveitam os dados armazenados em cache,
não obrigando à transferência de dados a partir da memória principal. A partir deste momento, como os acessos à memória são todos sequenciais, após uma leitura da memória
principal, ocorrem diversos acessos cujos dados já se encontram em cache, reduzindo o
tempo total de leitura.
Para tipo de dados dinâmicos a optimização dos acessos à memória já é mais difícil. Como
a localização lógica das estruturas não influencia a sua localização física, não se consegue
garantir uma localidade espacial de referência aquando do acessos aos dados. Assim, não
19
Optimização e Avaliação de Aplicações de Simulação Numérica
se consegue tirar partido da cache.
1
2
3
4
5
6
7
8
for (i =0; i<= N; j++){
/* percorrer linhas */
for (j =0; j <=N; i++){
/* percorrer colunas*/
if (m[i][j] == 0)
count ++;
}
}
x
i
√
√
√
x
√
j
Fig. 2.4 – Exemplo de uso optimizado da cache
(acesso com cache misses - x, acesso com cache hit – √, dados carregados para cache –
)
Optimização da execução das instruções
As optimizações relacionadas com o encadeamento das instruções, geralmente, podem ser
resolvidas pelo compilador e pela possibilidade do processador executar instruções fora
de ordem. Há, no entanto, casos em que a organização do código impossibilita a execução
óptima das instruções.
A arquitectura da unidade de execução dos processadores Pentium II permite a execução
simultânea de várias instruções em portos diferentes. Como cada porto executa determinado tipo de instruções é necessário garantir que existem instruções de tipos distintos
para manter o funcionamento dos vários portos em paralelo.
Uma outra origem de atrasos é a dependência entre operandos e resultados de várias
instruções. Uma instrução só pode ser executada caso todos os seus operandos já tenham
sido calculados pelas instruções anteriores. Quando se têm uma grande dependência
entre resultados e operações posteriores é possível que ocorram stalls no processador.
Em código de processamento numérico, a alta densidade de instruções de vírgula
flutuante, os dois problemas anteriores são muito frequentes. O elevado tempo de
execução das instruções de vírgula flutuante (entre 5 e 17 ciclos de relógio) e o facto dos
processadores Pentium
II só terem uma unidade de execução de vírgula flutuante
agravam os problemas atrás descritos.
É então necessário escrever o código de modo a que seja possível intercalar operações de
vírgula flutuante e outras de manipulação de inteiros ou leituras/escritas.
Se houver a intercalação de instruções não dependentes entre si, consegue-se aproveitar o
pipeline de execução de instruções de vírgula flutuante. É possível iniciar a execução de
20
Trabalho Relacionado
uma nova instrução ainda antes de ter terminado a instrução de vírgula flutuante
anterior.
As operações sobre registos são executadas mais rapidamente do que sobre posições de
memória. Se o código gerado pelo compilador executar as instruções, tendo por operandos os registos e não a memória, consegue-se um ganho no tempo de execução. Para facilitar a tarefa dos compiladores é aconselhável reduzir ao mínimo os acessos a posições de
vectores indexadas por variáveis. Se, por exemplo, em cada iteração se transferir o valor
indexado para uma variável local, e se realizarem todos os cálculos sobre ela, há a possibilidade do código ser executado sobre registos. No fim dos cálculos, é efectuada a transferência do novo valor para o vector.
1
2
3
4
5
6
for (i =0; i<= N; j++){
a[i] = 0;
for (j =0; j <=N; i++){
a[i] += j;
}
}
a)
1
2
3
4
5
6
7
for (i =0; i<= N; j++){
aux = 0;
for (j =0; j <=N; i++){
aux += j;
}
a[i] = aux;
}
b)
Fig. 2.5 – Alteração do acesso a vectores de modo a permitir a utilização de registos
a) versão não optimizada, b) versão optimizada
Deste modo, não só se possibilita a execução de instruções que manipulam registos, como
também se elimina a necessidade de gerar endereços em cada iteração do ciclo.
Nesta secção apresentámos uma série de optimizações simples que podem ser aplicadas
após a implementação da aplicações. Preferencialmente, de modo a reduzir as alterações
necessárias, é preferível que, aquando da codificação das aplicações, se tenha em atenção
este tipo de técnicas:
•
redução do número de instruções através da substituição de sub-expressões e
eliminação de código repetido;
•
optimização dos acessos à memória, para se tirar partido da cache;
•
geração de código cujo assembly seja executado eficientemente num determinado
processador.
2.3 Paralelização
A paralelização de código permite aproveitar os vários processadores existentes num
21
Optimização e Avaliação de Aplicações de Simulação Numérica
computador para executar código mais rapidamente. Actualmente, com o aumento da velocidade das redes de dados locais, é também possível executar programas paralelos que
são executados em vários computadores, em simultâneo.
Neste capítulo, apresentamos resenha histórica sobre os diferentes tipos de configurações
de sistemas paralelos:
•
Clusters
•
Multiprocessadores de memória distribuída
•
Multiprocessadores de memória partilhada
•
Computadores vectoriais
•
Arrays de processadores
Também são apresentadas os sistemas que actualmente se podem usar para desenvolver
programas paralelos em diversas plataformas:
•
threads sobre memória partilhada;
•
passagem de mensagens (PVM e MPI);
•
memória partilhada e distribuída;
•
High performance Fortran.
2.3.1 Arquitectura de Sistemas Paralelos
Ao longo da história da computação paralela têm existido vários tipos de sistemas paralelos que se enumeram a seguir, agrupados pela taxinomia de Flynn[Fly72]:
•
•
Multiple Instruction Multiple Data (MIMD)
o
multiprocessadores de memória partilhada
o
multiprocessadores de memória distribuída
o
Clusters (sistema multiprocessador de memória distribuída)
Single Instruction Multiple Data (SIMD)
o
multiprocessadores de memória partilhada
o
multiprocessadores de memória distribuída (arrays de processadores)
Apresentam-se, de seguida, as características genéricas de cada um destes tipos de sistemas paralelos.
22
Trabalho Relacionado
Multiprocessadores de memória partilhada (MIMD)
Os primeiros computadores multiprocessadores tinham vários processadores, executando
as instruções em paralelo. Todas as tarefas executadas em processadores distintos tinham
o mesmo espaço de endereçamento mapeado sobre uma única memória. Este tipo de
computadores ainda é hoje largamente utilizado.
Neste tipo de computador cada processador executa uma sequência de instruções diferentes (programa ou tarefa) de todos os outros, manipulando os dados que se encontram na
memória partilhada.
Por questões de custos, o acesso à memória partilhada é realizado através de um bus (Fig.
2.6.a). O facto deste bus ser partilhado por todos os processadores para comunicação com
o sistema de memória reduz o número máximo de processadores possíveis dado que, a
partir de um determinado número de processadores, o tráfego de dados satura o bus. Um
outro modo de ligação dos processadores à memória é o uso de um crossbar (Fig. 2.6.b)
como por exemplos os servidores Sun Fire 15k[Sun01]. Aqui o tráfego agregado é superior
mas continua a existir a possibilidade de contenção aquando do acesso aos dados.
CPU
CPU
CPU
CPU
CPU
CPU
MEMÓRIA
BUS
I/O
I/O
MEMÓRIA
MEMÓRIA
MEMÓRIA
I/O
I/O
a)
b)
Fig. 2.6 – Arquitectura de multiprocessadores de memória partilhada com ligações
distintas: a) bus de ligação, b) crossbar
Em geral, este tipo de sistemas é programado usando threads e memória partilhada (ver
secção 2.3.2.1 – Memória Partilhada). A cada processador fica atribuída uma tarefa, que se
executa em paralelo com todas as outras, existindo estruturas de dados na memória partilhada que são manipuladas por cada uma das tarefas. O acesso aos dados partilhados é
efectuado tal como num programa sequencial, mas existe a necessidade de sincronização.
Este tipo de sistemas ainda hoje se encontra em uso, sendo o tipo de arquitectura mais
usada para computadores multiprocessadores com até cerca de 100 processadores[Sun01].
23
Optimização e Avaliação de Aplicações de Simulação Numérica
Computadores vectoriais (SIMD)
No ano de 1976 nas o primeiro computador (CRAY I[Hoc81]) com arquitectura vectorial e
sucesso comercial. A rapidez de cálculo deste computador resultava da capacidade do
processador em executar eficientemente uma determinada operação sobre vectores de
dados. Para realizar uma operação sobre um vector não era necessário programar um ciclo, mas simplesmente usar uma instrução máquina.
Os processadores usados eram especializados para o cálculo numérico e usavam técnicas
de pipelining para garantir que essas instruções sobre vectores produziam no mínimo um
resultado por ciclo de relógio.
Em 1983 apareceu o primeiro computador paralelo que usava vários processadores vectoriais (CRAY 2[Haw98]) , executando todos eles a mesma instrução, mas sobre dados diferentes. Usavam memória partilhada e tinham um bus de ligação entre os processadores e a
memória.
Neste tipo de computadores o controlo da execução é realizada por um processador dedicado a essa tarefa, sendo os cálculos realizados pelos vários processadores vectoriais.
Num determinado instante, todos os processadores vectoriais executam a mesma instrução, mas sobre dados distintos. Consegue-se assim reduzir a complexidade dos processadores vectoriais, eliminando destes a lógica de controlo.
....
Unidade de
Controlo
Processador
Vectorial
........
Processador
Vectorial
....
Memória Escalar
Memória Vectorial
(dados Partilhados)
Fig. 2.7 – Exemplo da arquitectura de um computador multiprocessador vectorial
Hoje, o uso de computadores vectoriais já não é viável devido aos custos de desenvolvimento e ao aumento do desempenho dos processadores genéricos, mas a tecnologia de
cálculo vectorial volta a ser implementada em alguns processadores, por exemplo através
da tecnologia MMX[Int01a].
24
Trabalho Relacionado
Arrays de processadores (SIMD)
Os primeiros computadores massivamente paralelos (até 64000 processadores) usavam
várias unidades de processamento simples para efectuar os cálculos em paralelo. Cada
unidade de processamento contém um processador escalar, memória local e um processador de entradas/saídas. Todo o controlo do fluxo do programa é realizado por um processador dedicado e único no sistema; este processador realiza a avaliação de condições,
efectua a leitura e descodificação das instruções e envia as instruções correctas para todos
o processadores participantes no cálculo. Todo o array de processadores trabalha sincronizado; em cada instante, todos os processadores executam simultaneamente a mesma
instrução emitida pela unidade de controlo.
Fig. 2.8 – Exemplo da arquitectura de um computador massivamente paralelo
(MasPar MP-2 – 1992)
A transferência de dados entre os processadores realiza-se através do uso de mensagens
explícitas de comunicação, visto não haver memória partilhada entre unidades de processamento. Devido à grande quantidade de processadores, a rede de interligação deverá
suportar taxas de transferência elevadas. Para aumentar a taxa de transferência agregada,
usaram-se diversas topologias de rede[Alm94] (estrela, toros, anel, hiper–cubo, …)
25
Optimização e Avaliação de Aplicações de Simulação Numérica
Computadores de memória distribuída (MIMD)
Com o desenvolvimento dos processadores genéricos, uma evolução natural dos computadores baseados em arrays de processadores foi a inclusão dos novos processadores genéricos neste tipo de computador. Conseguiu-se, assim, o desenvolvimento de máquinas
com maior desempenho e com custo menor, devido ao uso de processadores genéricos e
largamente disponíveis.
A eliminação dos processadores escalares muito simples usados nos arrays de processadores permitiu a execução de diferentes fios de execução nos vários processadores. Estes
computadores passaram a pertencer à família MIMD.
Estes sistemas, devido ao elevado número de processadores, continuavam a ter memória
distribuída, de modo a reduzir o tempo de acesso a dados locais. Continua a haver cuidado especial na topologia da interligação entre os processadores para garantir a taxa máxima de transferência de dados entre processadores. Em alguns destes computadores, cada
unidade de execução tem, para além da memória privada, vários processadores que acedem através de uma ligação rápida aos dados locais.
Nas primeiras máquinas deste tipo a comunicação entre os vários processadores era programada explicitamente, inserindo funções de transferência de dados. Para tal, usavam-se
bibliotecas de transferência de mensagens (ver secção 2.3.2.2) proprietárias, que vieram a
ser substituídas pelas bibliotecas públicas MPI[Gropp] (ver secção 2.3.2.2.2) ou
PVM[Sun90] (ver secção 2.3.2.2.1).
Existem outros computadores de memória distribuída em que todos os processadores
vêem um único espaço de endereçamento, independentemente da localização dos dados
ser em memória local ou remota (por exemplo SG Origin 2000[Lau97]). Para isso é usado
hardware especial que consegue garantir a coerência entre as caches e as vistas das memórias de cada processador. Nestas máquinas, a programação da comunicação já não necessita de ser por passagem de mensagens, podendo-se recorrer a variáveis partilhadas. O
hardware de suporte que permite a partilha de dados entre os processadores implementa
um sistema de Distributed Shared Memory (secção 2.3.2.3).
Clusters (MIMD)
Recentemente, desenvolveu-se um novo tipo de arquitectura de computadores paralelos:
os clusters. Um cluster é, essencialmente, um conjunto de computadores ligados por uma
rede rápida e com software de sistema que permite que os vários nós se comportem como
um só sistema. A grande diferença entre um cluster e os sistemas atrás descritos é que,
26
Trabalho Relacionado
num cluster, cada unidade de processamento quando desligada do resto do sistema
comporta-se como um computador normal. Estas unidades de processamento variam
desde computadores multiprocessadores de memória partilhada, nos sistemas de maior
desempenho, até simples computadores pessoais.
Este tipo de computadores, para além do seu desempenho elevado, permite uma gestão
facilitada dos recursos. É possível, dinamicamente, criar partições do sistema que se comportam como computadores distintos.
Dois dos componentes mais importantes de um cluster são a rede de ligação entre os nós e
a infra-estrutura de software que suporta a computação.
Com o aumento das taxas de transferência das redes locais (fast-ethernet, por exemplo),
estas soluções de interligação viabilizaram a construção de sistemas com várias dezenas
de computadores. Para conjuntos de nós maiores, é necessário usar tecnologias que
permitem maiores taxas de transferência e menor latência (Myrinet[Vit98], SCI[Dol96]).
SISTEMA 1
SISTEMA 2
NÓ DE GESTÃO
Fig. 2.9 – Configuração de um cluster formando dois sistemas paralelos independentes
Embora os clusters de maiores dimensões actualmente existentes consigam desempenhos
da ordem do teraflops, as máquina com tal desempenho não estão à disposição da maioria
dos utilizadores. Versões mais reduzidas podem ser abrangidas pelos mais variados orçamentos: os clusters têm a vantagem de permitir a construção de sistemas em função do orçamento. Um cluster com 4 máquinas, por exemplo, é facilmente realizável com um investimento mínimo: é suficiente instalar o software necessário em quatro computadores
pessoais de modo a tornarem-se um único sistema paralelo.
Esta adaptabilidade aos orçamentos existentes também se observa na expansão dos
clusters. O aumento do seu desempenho é conseguido com a adição de novas máquinas,
sem substituição das anteriores, ou aumentando o desempenho de cada um dos nós.
Tal como os computadores de memória distribuída, também aqui, os paradigmas de pro27
Optimização e Avaliação de Aplicações de Simulação Numérica
gramação existentes são a passagem de mensagens (ver secção 2.3.2.1) ou de Distributed
Shared Memory (secção 2.3.2.2.1) implementado em software. Para além do suporte à comunicação, para clusters de grandes dimensões, também há necessidade de usar software para
administração e configuração do cluster, para gestão das filas de trabalhos e armazenamento dos dados.
2.3.2 Comunicação entre tarefas em multiprocessadores
Nos computadores de memória partilhada, a comunicação entre tarefas a executar em
processadores distintos é trivial, pois é realizada através do uso de variáveis globais e
acessíveis a todas as threads.
Nos sistemas de memória distribuída, como cada memória é local a cada unidade de
processamento, é necessário garantir suporte para a transferência de dados entre as várias
memórias. Existem duas possibilidades para transferência de dados entre tarefas. A
primeira e mais simples de implementar é usando passagem de mensagens. Aquando da
programação, apenas se usam primitivas de comunicação explícita entre os vários
processadores. A outra possibilidade é a emulação da existência de um espaço de
endereçamento único a todo o sistema. Esta abordagem permite o uso de variáveis
partilhadas, mas obriga ao uso de um sistema de suporte (software ou hardware) que
efectue a emulação.
2.3.2.1 Memória Partilhada
Enquanto que a capacidade de multiprocessamento de um sistema operativo permite que
se executem vários programas em paralelo num mesmo computador, a execução multitarefa possibilita paralelismo semelhante mas ao nível da aplicação. O uso de threads permite que uma aplicação tenha vários fios de execução paralelos que acedem a dados partilhados.
No caso de multiprocessadores de memória partilhada, é possível, durante a execução das
várias threads, associá-las a processadores diferentes, aproveitando-se assim a existência
dos vários processadores.
Presentemente, todos os sistemas operativos para computadores multiprocessadores de
memória partilhada disponibilizam rotinas de manipulação de threads, algumas proprietárias (Windows[MsSDKc] ou Solaris[Pow91]) e outras portáveis entre sistemas (threads
POSIX[POSIX]).
Em computadores multiprocessadores de memória partilhada, o uso de threads, aliado à
28
Trabalho Relacionado
memória partilhada, apresenta-se como uma solução eficiente para a programação paralela. A programação da distribuição e comunicação de dados entre threads é fácil e a execução resulta eficiente.
Embora o acesso aos dados partilhados entre as threads seja automático e transparente para o programador, é necessário o uso de bibliotecas especiais que forneçam rotinas de
manipulação de threads e de sincronização no acesso aos dados.
Como exemplo, apresenta-se de seguida uma implementação do método de relaxação de
Jacobi usando threads. O código apresentado faz parte da rotina executada por todas as
threads. Após a inicialização da matriz, as várias tarefas são criadas e todas executam o
código apresentado.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*calculo dos limites inferior e superior */
first = myid*stripSize + 1;
last = first + stripSize - 1;
for (iters = 1; iters <= numIters; iters++) {
for (i = first; i <= last; i++) {
for (j = 1; j <= gridSize; j++) {
grid_aux[i][j] = (grid1[i-1][j]+ grid1[i+1][j]+
grid1[i][j-1]+ grid1[i][j+1]) * 0.25;
}
}
barrier();
for (i = first; i <= last; i++) {
for (j = 1; j <= gridSize; j++) {
grid1[i][j] = grid_aux[i][j];
}
}
barrier();
}
Como os dados estão em memória partilhada não há comunicação explícita entre as várias
tarefas. Observa-se que não existem invocações a rotinas de comunicação; a única invocação externa aos cálculos é à função barrier. Esta rotina é de sincronização, não lhe estando associada qualquer transferência de dados.
2.3.2.2 Infra-estrutura de passagem de mensagens
A passagem de mensagens é o paradigma de programação paralela mais usado em computadores de memória distribuída. Até há pouco tempo, o único modo de transferência
de dados entre tarefas consistia no uso de mensagens explícitas de comunicação. Durante
a execução do programa, as mensagens de recepção emparelham com as de envio, obrigando também à sincronização entre as tarefas.
29
Optimização e Avaliação de Aplicações de Simulação Numérica
Embora a implementação de um sistema de passagem de mensagens seja simples, a codificação da comunicação num programa paralelo requer o conhecimento exacto dos dados
a transmitir e de quais os receptores.
Embora inicialmente todos os fabricantes de sistemas paralelos tivessem a sua biblioteca
de programação por passagem de mensagens, actualmente, apenas duas subsistem:
PVM[Sun90] e MPI[Gropp]. Estas bibliotecas serão descritas mais adiante.
Apresenta-se de seguida um excerto duma iteração do método de relaxação de Jacobi
programada usando MPI[Gropp]. Antes do bloco apresentado, aparecem as rotinas de
inicialização e distribuição dos dados iniciais. No fim do programa, aparecem as rotinas
de transferência de dados necessárias à consolidação dos resultados.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
if (rank < size - 1)
/* envio da fronteira inferior*/
MPI_Send(xlocal[maxn/size], maxn, MPI_DOUBLE,
rank + 1, 0, MPI_COMM_WORLD );
if (rank > 0)
/* recepção da fronteira superior*/
MPI_Recv(xlocal[0], maxn, MPI_DOUBLE,
rank - 1, 0, MPI_COMM_WORLD, &status );
if (rank > 0)
/* envio da fronteira superior*/
MPI_Send(xlocal[1], maxn, MPI_DOUBLE,
rank - 1, 1, MPI_COMM_WORLD );
if (rank < size - 1)
/* recepção da fronteira inferior*/
MPI_Recv(xlocal[maxn/size+1], maxn, MPI_DOUBLE,
rank + 1, 1, MPI_COMM_WORLD, &status );
itcnt ++;
diffnorm = 0.0;
for (i=i_first; i<=i_last; i++)
for (j=1; j<maxn-1; j++) {
xnew[i][j] = (xlocal[i][j+1] + xlocal[i][j-1] +
xlocal[i+1][j] + xlocal[i-1][j]) / 4.0;
diffnorm += (xnew[i][j] - xlocal[i][j]) *
(xnew[i][j] - xlocal[i][j]);
}
for (i=i_first; i<=i_last; i++)
for (j=1; j<maxn-1; j++)
xlocal[i][j] = xnew[i][j];
/* envio das diferenças */
MPI_Allreduce(&diffnorm, &gdiffnorm, 1, MPI_DOUBLE,
MPI_SUM, MPI_COMM_WORLD );
Observa-se que, em cada iteração, a transmissão da fronteira dos dados privados é
30
Trabalho Relacionado
realizada explicitamente. Para tal, são invocadas as rotinas MPI_Send e MPI_Recv. Na
invocação das rotinas de comunicação são indicados os dados a transmitir, assim como
qual o outro nó interveniente. Devido ao emparelhamento dos envios e recepções
consegue-se obter sincronização entre as tarefas de forma implícita.
A conversão para PVM resumir-se-ia à alteração das chamadas de comunicação e de
criação de tarefas.
2.3.2.2.1
PVM
PVM[Sun90] (Portable Virtual Machine) é um sistema de software que permite que um conjunto de computadores heterogéneos sejam usados como uma única máquina de memória
distribuída (por exemplo, para a realização de cálculos numéricos). Os computadores que
participam na computação podem ser desde computadores pessoais até supercomputadores de memória distribuída, interligados por rede (ethernet, FDDI, …).
Para além da infra-estrutura de comunicação entre os nós, também é fornecida uma biblioteca que permite a programação de aplicações paralelas para serem executadas nos
vários nós.
O paradigma de programação implementado é a passagem de mensagem, havendo rotinas para a criação das tarefas nos vários computadores e rotinas de comunicação para
transferência de dados[Gei94].
Quando um computador tem vários processadores e executa várias tarefas em paralelo, a
comunicação entre estas é realizada usando o método de comunicação mais eficiente para
a plataforma, s, continuando o modelo de programação a ser passagem de mensagens.
2.3.2.2.2
MPI
MPI[Sni96] é uma outra biblioteca para programação paralela, que nasceu da necessidade
de tornar portáveis os programas paralelos que executam em se multiprocessadores de
memória distribuída.
O objectivo deste esforço foi elaborar uma biblioteca com todas as construções comuns às
bibliotecas de programação baseadas em passagem de mensagens já existentes. Deste modo tenta-se melhorar a portabilidade deste tipo de aplicações paralelas. Tal como o PVM,
também as rotinas implementadas se dividem em manipulação (criação, destruição, …)
de tarefas e comunicação de dados entre os vários nós participantes nos cálculos.
Embora inicialmente o objectivo fosse o de tornar os programas portáveis, actualmente é
31
Optimização e Avaliação de Aplicações de Simulação Numérica
possível, usando vários computadores, utilizar MPI na programação de aplicações para
executar em clusters, à semelhança do PVM.
Uma das grande diferenças entre MPI e PVM é a impossibilidade de, durante a execução
de uma aplicação que use MPI, serem criadas tarefas remotas. Em MPI, todas as tarefas
são criadas no início do programa.
2.3.2.3 Distributed Shared Memory
Os sistemas de Distributed Shared Memory simulam a existência de um espaço de memória
partilhada por todas as tarefas que executam em computadores diferentes. Os programas
criam dados num espaço de memória mapeado num mesmo espaço de endereçamento
por todas as tarefas e o sistema permite que a visão desse espaço seja coerente entre todas
elas, estando a memória física distribuída pelos vários computadores.
Para garantir o modelo de consistência sequencial[Lam79] entre as várias vistas deste
espaço partilhado, seria necessário, para cada escrita na memória partilhada, transmitir as
alterações para cada computador detentor de uma cópia dos dados. Como esta
comunicação pode ser realizada sobre uma rede local (Ethernet, ATM, …) a transmissão de
todas as alterações é incomportável. Para resolver este problema decidiu-se implementar
protocolos de coerência de dados mais fracos. Estes novos modelos obrigam o uso de primitivas de sincronização. Estas primitivas também são usadas pelo sistema para a transferência de dados entre nós, de modo a garantir a coerência entre as várias memórias.
O primeiro, e mais estrito, dos modelos a aparecer foi o de consistência fraca[Adv90]. Este
modelo obriga o programador a introduzir uma barreira entre dois acessos que possam
originar uma corridas. A execução das barreiras é sequencialmente consistente e garante
que todos os acessos anteriores foram executados e que nenhum acesso posterior se
iniciou. Se o programador garantir que não existem corridas entre duas barreiras, o
sistema de DSM garante que todos os acessos à memória parecem sequencialmente
consistentes.
O modelo de release consistency [Gha90]usa duas primitivas de sincronização (acquire e
release) para sincronização entre as tarefas. Todos os acessos a dados partilhados devem ser realizados entre um acquire e um release. O acquire garante que nenhum
acesso que lhe segue se executou, enquanto que o release garante que todos os acessos anteriores já terminaram. Estas duas instruções garantem que, ao entrar-se numa região
crítica, a memória está consistente entre tarefas, e que, ao sair, todas as alterações são propagadas aos outros computadores (as propagações são realizadas após o release).
32
Trabalho Relacionado
Este modelo é mais eficiente que a consistência sequencial, embora requeira atenção semelhante por parte do programador. A comunicação entre nós é reduzida, visto que só a
última alteração de uma variável é propagada e que as alterações de várias variáveis
podem ser agrupadas numa única mensagem, mas podem ser enviadas mais mensagens
do que uma implementação usando passagem de mensagens explícitas.
Os programas são muito semelhantes àqueles desenvolvidos usando threads e memória
partilhada, mas é necessário ter em atenção a quantidade de dados transmitidos entre os
vários computadores. A paralelização tem de ser realizada tendo em atenção a latência na
comunicação dos dados partilhados, devendo esta ser reduzida ao mínimo necessário.
Como não existem chamadas explícitas a funções de comunicação, a compilação directa
de uma aplicação programada com threads usando uma biblioteca deste tipo, pode criar
programas que executam de modo pouco eficiente em vários computadores. De facto, o
programador, ao limitar-se a compilar o programa usando uma nova biblioteca, pode-se
esquecer que os acessos a dados partilhados podem provocar a comunicação entre
computadores.
2.3.2.3.1
TreadMarks
O sistema TreadMarks[Kel94] implementa Distributed Shared Memory em software executando as várias tarefas em vários computadores ligados por uma rede local (ethernet ou
ATM). O modelo de consistência usado é o lazy release consistency[Kel92] que é um
refinamento do release consistency descrito anteriormente. Este sistema fornece ao
programador todas as rotinas necessárias a criação de dados partilhados e criação e
manipulação estruturas de sincronização. O sistema também fornece a infra-estrutura
para manter a memória consistente ao longo da execução das aplicações.
O modelo de consistência usado é uma refinamento do modelo release consistency.
Também neste modelo, todos os acessos à memória que possam gerar corridas devem
estar separados por uma rotina de sincronização. No entanto, a diferença entre ambos está
no escalonamento da transmissão dos dados entre computadores. No release consistency, os
dados alterados por uma tarefa são enviados para os outros computadores durante a
execução do release. Neste novo modelo apenas quando os dados são acedidos é que os
valores são transferidos entre computadores. Durante a execução do release, apenas é
transmitida informação acerca dos dados alterados. Com este atraso na comunicação das
alterações consegue-se reduzir a quantidade de dados transmitidos entre computadores.
O sistema TreadMarks fornece uma biblioteca de funções necessárias à criação e acesso à
33
Optimização e Avaliação de Aplicações de Simulação Numérica
memória partilhada e primitivas de sincronização.
Todos os dados a partilhas entre os vário processos devem ser criados explicitamente
através da função Tmk_malloc. Todos os outros dados, criados estaticamente ou através
do malloc, continuam locais a cada tarefa. É possível efectuara a actualização explícita de
uma posição de memória dos outros processos através da instrução Tmk_distribute.
A sincronização pode ser realizada através de barreiras ou locks. As barreiras herdam o
funcionamento da weak consistency, mas a transmissão dos dados é atrasada. Os locks
podem ser adquiridos (Tmk_lock_acquire) ou libertados(Tmk_lock_release).
Embora simples, esta API permite o desenvolvimento de aplicações paralelas e distribuídas complexas.
2.3.2.4 High Performance Fortran (HPF)
O High Performance Fortran[Hig97] não é um sistema de suporte à programação paralela,
mas sim um conjunto de extensões à linguagem Fortran 95 que permitem o desenvolvimento de aplicações paralelas. Os modos de comunicação subjacentes são completamente
independentes da programação.
Os objectivos aquando do início do desenvolvimento do HPF eram:
•
Definição de um conjunto mínimo de extensões à linguagem Fortran necessárias
ao desenvolvimento de aplicações paralelas
•
Obtenção de desempenho máximo em multiprocessadores de memória distribuída
•
Redução do esforço aplicado na paralelização de programas escritos em Fortran
•
Desenvolvimento de uma linguagem portável entre várias arquitecturas paralelas,
desviando-se o mínimo dos standard do Fortran
As extensões fornecidas pelo HPF fornecem meios de expressar o paralelismo possível
para os tipos de dados manipulados. Normalmente, o programador limita-se a indicar como é que os dados devem ser divididos, sendo responsabilidade do compilador introduzir as rotinas de distribuição, comunicação e sincronização entre os processos. Os paradigmas de comunicação e sincronização usados não estão explicitados no código; cabe
ao compilador, sabendo a arquitectura de execução, a escolha do tipo de rotinas de comunicação a usar (memória partilhada ou passagem de mensagens).
O compilador também é responsável por, observando as estruturas de controle do programa, descobrir o código que deve ser executado em paralelo pelos vários processadores.
34
Trabalho Relacionado
1
2
3
4
5
6
7
8
9
REAL A(1000, 1000)
!HPF$ PROCESSORS procs(4, 4)
!HPF$ DISTRIBUTE (BLOCK, BLOCK) ONTO procs :: A
DO k = 1, num_iter
FORALL (i = 2:999, j = 2:999)
B(i,j)= (A(i,j-1)+A(i-1,j)+A(i,j+1)+ A(i+1,j))/4
END FORALL
A = B
! copia matriz em paralelo
END DO
No exemplo acima, a única diferença entre a versão paralela e a versão série é a existência
das duas directivas HPF. A primeira directiva indica a existência de uma grelha de 4x4
processadores chamada procs. A directiva DISTRIBUTE recomenda ao compilador que
o vector A seja distribuído pelos processadores procs. A partir deste momento a
descoberta das possibilidades de paralelismo é realizada pelo compilador, tendo em
atenção a estrutura do código (ciclos), a estrutura dos dados (vectores ou matrizes) e as
primitivas do HPF introduzidas pelo programador. No código fonte não há referências directas à comunicação ou sincronização entre processos.
2.3.3 Optimizações aplicáveis a código paralelo
As optimizações apresentadas anteriormente na secção “2.2 – Técnicas de Optimização de
Código Sequencial” também podem ser aplicadas a código já paralelizado.
Outro tipo de optimização possível é a redução dos dados transferidos entre tarefas. Se
em sistemas de memória partilhada estes tempos podem ser negligenciáveis, em sistemas
de memória distribuída, o tempo de comunicação pode-se sobrepor ao dos cálculos. Neste
caso é necessário garantir que os tempos de transferência de dados são mínimos.
Esta redução pode-se conseguir alterando os algoritmos, de modo a reduzir os dados que
é necessário partilhar entre tarefas, ou efectuando uma selecção mais fina dos dados a
transmitir.
2.4 Lei de Amdahl
Em 1967, Gene Amdahl[Amd67] formulou uma lei em que descrevia os ganhos máximos
que se podem obter da paralelização de código. Todos os programas têm código que é
inerentemente sequencial e outro que é paralelizável. Amdahl descreveu os ganhos
máximos que se obtêm da paralelização de apenas parte do código.
Quando se paraleliza um programa, o tempo de execução da sua parte sequencial mantém-se constante. É este facto que faz com que a redução do tempo de execução não seja
35
Optimização e Avaliação de Aplicações de Simulação Numérica
linearmente dependentes do número de processadores. Com efeito, considere-se a paralelização de um programa com 95% de código paralelizável (t_para); se for utilizado um
número infinito de processadores, o speedup máximo obtido é 20. O tempo correspondente
aos 5% (t_serie) do código sequencial mantêm-se constante e limita os ganhos máximos.
A regra geral de cálculo dos speedups segundo a lei de Amdahl fica então:
t_serie + t_para
t para
t_serie +
n
speedup =
em que t_serie é o tempo de execução do código série, t_para é o tempo de execução
do código paralelizável (com 1 processador) e n é o número de processadores.
20
Speedup
15
10
5
0
0
16
32
48
64
80
96
112
128
144
Processadores
Fig. 2.10 – Desempenho máximo de uma aplicação paralela segundo lei de Amdahl
Para o exemplo anterior (95% de código paralelizável) e segundo a fórmula anterior
obtém-se o gráfico da Fig. 2.10 de ganhos máximos para várias quantidades de
processadores.
2.5 Técnicas de avaliação de desempenho
Antes de se efectuar qualquer tipo de alteração de código para melhorar o desempenho de
uma aplicação, é necessário quantificar o tempo despendido em cada parte do programa.
Só depois deste passo podemos avaliar os possíveis ganhos da optimização ou paralelização desse código.
Para rentabilizar as tarefas de optimização é necessário encontrar o código que despende
36
Trabalho Relacionado
mais tempo. Deve-se, depois, avaliar as possíveis causas do fraco desempenho do código;
se o este for causado por características arquitecturais do computador é necessário
descobrir quais os problemas no código e porque razão o código não executa
eficientemente.
Descreve-se de seguida quais as ferramentas hoje disponíveis que nos permitem, não só
medir o tempo de execução das aplicações, mas também descobrir onde ocorrem os
eventos que atrasam a execução dos programas:
•
timers;
•
contadores de processador;
•
contadores de Sistema operativo;
•
aplicações de profiling.
2.5.1 Timers
A medição do tempo despendido em cada procedimento indica-nos qual o código que potencialmente executa menos eficientemente. Esta medição pode ser realizada através do
uso do relógio do computador. Este relógio é externo ao processador e tem uma precisão
pouco adequada à velocidade de processamento dos processadores actuais. Os processadores modernos já incluem registos que permitem a contabilização de tempo com precisão
superior.
2.5.1.1 Relógio do sistema
O relógio dum computador é composto por um gerador de sinal com uma determinada
frequência e uma série de registos. Este relógio está ligado ao gerador de interrupções que
permite assinalar ao sistema operativo, periodicamente e com uma frequência predeterminada, a passagem do tempo.
As interrupções geradas pelo relógio do computador são usadas pelo sistema operativo
para execução das tarefas de escalonamento e contabilização dos tempos de execução dos
programas. Os sistemas operativos fornecem chamadas de sistema que permitem consultar esses valores.
Enquanto que os sistemas operativos que seguem a norma POSIX[POSIX] fornecem as
funções times ou clock, o Windows[MsSDKa] disponibiliza a função GetTickCount.
A precisão destas rotinas, devido ao modo como são implementadas, é da ordem das
dezenas de milisegundos.
37
Optimização e Avaliação de Aplicações de Simulação Numérica
Embora a norma POSIX especifique que a unidade dos valores retornados por times e
clock seja o microsegundo, não é obrigatório que as implementações garantam precisão
de tal ordem. Em sistemas Linux e Solaris, a precisão destas funções é de 10 milisegundos.
2.5.1.2 Contadores de alta precisão
Com o aumento de velocidade de execução das instruções, o uso do relógio do sistema
passou a sofrer de algumas deficiências. Em particular, a precisão da ordem do
milisegundo não é suficiente para medir com exactidão o tempo de execução de algumas
rotinas.
Como o relógio do sistema tem a finalidade de calcular e memorizar o valor corrente do
tempo do modo mais preciso possível, há sistemas que efectuam alterações periódicas ao
valor do relógio, para este se manter certo. Este facto faz com que, em algumas medições
de intervalos de tempo, não se obtenha um valor preciso.
Para resolver os problemas atrás descritos, as famílias de processadores mais recentes
incluem um contador de tempo guiado pela frequência de funcionamento do processador
e sem necessidade de sincronização com relógios externos. Estes contadores também têm
uma precisão maior.
No caso da família x86 da Intel, este contador apareceu pela primeira vez no processador
Pentium[Int01c]. Este contador tem o comprimento de 64 bits que é inicializado a 0
aquando do reset do computador e é incrementado em cada ciclo de relógio do processador.
A instrução usada para efectuar a leitura deste registo é RDTSC[Int01b] que transfere o valor armazenado no contador para os registos EDX:EAX, permitindo a sua posterior utilização.
Assim, tal como o aumento da velocidade dos processadores obrigou, ao nível do
hardware, à criação de novos contadores de tempo, também nos sistemas operativos foram
introduzidas chamadas ao sistema que permitem a leitura do tempo com mais precisão
que a função clock (ou times ou GetTickCount).
As versões mais recentes do sistema operativo Windows (a partir do Windows NT 3.51 e
Windows 95) fornecem a função QueryPerformanceCounter[MsSDKa] que permite
efectuar a medição de tempo com uma precisão da ordem dos microsegundos.
Adicionando o atraso devido à chamada da função, este método tem uma precisão
superior a 1 milisegundo.
38
Trabalho Relacionado
Como a frequência do contador varia entre máquinas e versões do sistema operativo, é
necessário obter esse valor. A função QueryPerformanceFrequency permite obter a
resolução dos valores obtidos por QueryPerformanceCounter.
2.5.2 Contadores dos processadores
Os novos processadores (MIPS[Zag96], por exemplo) incluem novos registos que permitem a contabilização de eventos que ocorram dentro do processador ou nos buses de ligação. Estes registos, com auxílio dos contadores de tempo, permitem a avaliação precisa
das aplicações, descobrindo quais as causas dos atrasos na execução dos programas.
Os processadores Pentium Pro e posteriores, da Intel, têm dois Model-Specific Registers[Int01c], designados por PerfCtr0 e PerfCtr1, que permitem contabilizar a ocorrência dos eventos relevantes para o desempenho das aplicações. Estes eventos estão relacionados com o funcionamento do processador (excepções de vírgula flutuante, por exemplo) e a sua interacção com a memória (faltas de cache, …).
Os processadores que dispõem destes registos também têm novas instruções que permitem a sua leitura[Int01b]. A instrução RDPMC (Read Performance-Monitoring Counters) permite a transferência do valor acumulado por um dos contadores para os registos
EDX:EAX.
Antes de efectuar as leituras dos contadores é necessário indicar quais os eventos a contabilizar. A programação dos eventos a contabilizar é realizada colocando nos registos
específicos PerfEvtSel0 ou PerfEvtSel1 o código dos eventos a registar.
Apresentam-se na Tab. 2.1 alguns dos eventos que é possível detectar e que estão directamente relacionados com a avaliação do desempenho de aplicações.
A instrução assembly RDPMC para aceder aos registos específicos do processador
(PerfEvtSel0 e PerfEvtSel1) é privilegiada, só podendo ser invocada por código a
executar em modo núcleo. Por esta razão, sem a utilização de um driver específico, não é
possível a contabilização dos eventos.
Estes contadores não só permitem a contabilização dos eventos, mas também a geração de
interrupções quando atingem um
valor predeterminado. É possível programar estes
contadores de modo a ser gerada uma interrupção aquando do seu overflow. Deste modo,
é possível, sem alteração do código, efectuar a sua avaliação: programa-se o contador para
contabilizar um determinado evento, indica-se que este deverá gerar uma interrupção
aquando do overflow e programa-se uma rotina que, quando invocada, verifica que código
39
Optimização e Avaliação de Aplicações de Simulação Numérica
se executava quando foi gerada a interrupção.
Mnemónica do evento
Descrição
DATA_READ
Número de leituras à memória – contabiliza
número de hits e misses na cache nível 1
DATA_WRITE
Número de escritas na memória – contabiliza número de hits e misses na cache nível 1
DATA_READ_MISS
Número de leitura à memória que geram
miss na cache nível 1
DATA_WRITE_MISS
Número de escritas na memória que geram
miss na cache nível 1
PIPELINE_FLUSHES
Número de flushes do pipeline devido a
excepções, interrupções e saltos mal previstos
INSTRUCTIONS_EXECUTED
Número de instruções executados por ciclo
(no máximo 2 por ciclo de relógio)
INSTRUCTIONS_EXECUTED_V_PIPE
Número de instruções executadas em paralelo
PIPELINE_AGI_STALLS
Número de atrasos devido a interdependência de endereços (Address generation interlock
– AGI).
FLOPS
Número de instruções de vírgula flutuante
– Inclui as quatro operações básicas, restos e
raízes. As operações compostas incrementam este contador várias vezes.
FLOATING_POINT_STALLS_DURATION
Número de ciclos que a execução espera
pela terminação de uma operação de vírgula flutuante
TAKEN_BRANCHES
Saltos executados
PIPELINE_FLUSHES_DUE_
TO_WRONG_BRANCH_PREDICTION
Limpeza do pipeline devido a incorrecta predição da execução de saltos
Tab. 2.1 – Eventos contabilizáveis com Performance-Monitoring counters
2.5.3 Contadores do S.O.
Existem alguns eventos relativos ao funcionamento do sistema operativo que são relevantes para a avaliação do desempenho das aplicações. Estes eventos estão relacionados com
os acessos ao disco (e consequentes atrasos) e transferências de dados na rede.
O Sistema operativo Windows[MsWRK] disponibiliza uma infra-estrutura de recolha e
leitura de dados acerca do funcionamento do sistema operativo e componentes do computador. Os contadores estão organizados numa hierarquia de dois níveis. O primeiro é o
40
Trabalho Relacionado
nível físico, constituído pelas classes de objectos (memória, rede, processos, por exemplo),
e o segundo é composto pelos contadores característicos de cada objecto. Numa mesma
máquina, podem existir várias instâncias de um mesmo objecto cada uma com o seu conjunto de contadores.
Contador
Descrição
Network Interface
Bytes Sent
Quantidade de informação enviada por
cada interface de rede
Bytes Received
Quantidade de informação recebida por
cada interface de rede
Memory
Page Faults
Número de faltas de página ocorridas
Page Read
Número de páginas lidas de memória
secundária
Page Write
Número de páginas
memória secundária
escritas
para
Process
IO Read Bytes
Quantidade de informação lida (a partir
do sistema de ficheiros ou rede) em cada
processo
IO Write Bytes
Quantidade de informação escrita (para o
sistema de ficheiros ou rede) em cada
processo
Page Faults
Número de faltas de página ocorridas
durante a execução do processo
Working Set
Memória física utilizada por cada processo
Virtual Bytes
Memória virtual utilizada por cada processo
Tab. 2.2 – Contadores contabilizáveis com Performance counters do Windows
(divididos por objectos do sistema)
Os dados acerca dos objectos, instâncias e contadores são armazenados no registry, sendo
o seu acesso realizado através das instruções de manipulação do registry. Para facilitar o
acesso e manipulação destes contadores, a Microsoft disponibiliza uma API específica
chamada Performance Data Helper[MsSDKb] (PDH).
Esta API fornece instruções para selecção dos contadores, obtenção das suas características e leitura dos seus valores.
41
Optimização e Avaliação de Aplicações de Simulação Numérica
Também é possível implementar novas fontes de dados e novos contadores, implementando dlls que efectuam as operações necessárias à inicialização, recolha de dados e leitura.
Apresenta-se na Tab. 2.2 alguns objectos e seus contadores relevantes para a observação
do desempenho de aplicações.
2.5.4 Polling vs. Instrumentação
A comparação entre os tempos de execução de duas versões duma aplicação é suficiente
para verificar se as optimizações já introduzidas de uma versão para a outra trazem benefícios, mas não é suficiente para descobrir que partes da aplicação necessitam de optimizações, e onde estas poderão ser mais proveitosas.
Para fazer esta avaliação é necessário saber com a granularidade mais fina possível, quais
as funções, ou mesmo linhas de código, que mais tempo demoram a executar. Também
pode ser interessante saber onde ocorrem os eventos do processador e sistema operativo
contabilizáveis (ver secções 2.5.2 e 0). para tal, pode-se efectuar a instrumentação do
código, que implica a sua alteração, ou, de modo transparente para a aplicação, efectuar
polling.
O polling é um método não intrusivo de contabilização de tempos de execução. Consiste
em, periodicamente, interromper a execução do programa para observar que instrução
estava a ser executada naquele instante. Consegue-se assim avaliar quais as instruções
que mais ocupam o processador. Para realizar este tipo de medição, não é necessário
alterar o código da aplicação a observar, podendo a contabilização ser efectuada por um
programa externo à aplicação a avaliar.
Se a frequência de amostragem não for suficiente alta, não se conseguem obter resultados
conclusivos, dado que, entre cada amostra existe uma série de instruções que se executam,
mas não são contabilizadas.
Com a instrumentação obtêm-se resultados mais precisos. São introduzidas no código instruções de contabilização de tempo, possibilitando a medição do tempo de execução entre
pontos no código com muita exactidão. A introdução das instruções de medida do tempo
pode ser efectuada automaticamente ou pelo programador que as introduz nos pontos
onde deseja efectuar a medição.
Se a introdução das instruções de medida for realizada automaticamente, a granularidade
de medida, normalmente, é a rotina. As ferramentas de análise de desempenho introdu42
Trabalho Relacionado
zem chamadas a funções de leitura do tempo no início e fim de cada sub-programa, e, no
fim da execução, é apresentada uma contabilização do tempo despendido em cada sub-programa. Esta tarefa pode ser realizada aquando da compilação (gprof, ver 2.5.5.1) ou
após (Etch, ver 2.5.5.2).
Se a introdução das rotinas de contabilização for realizada pelo programador é possível
contabilizar o tempo de execução do código com granularidade inferior, i. e. ao nível do
bloco de código ou mesmo da linha de código.
Um outro método de profiling possível é a geração de interrupções após a ocorrência de
um determinado número de eventos (ver secção 2.5.2 acerca destes eventos). Os processadores mais recentes, para além da contagem de diversos eventos, permitem a geração
de interrupções tratáveis pelo software após a ocorrência de vários eventos. Seleccionando
um valor correcto, e sem a alteração do programa a examinar, é possível obter os valores
aproximados do número de eventos ocorridos durante a execução do programa.
2.5.5 Software de profiling
Apresentam-se nesta secção programas que permitem a avaliação do software e a descoberta do tempo despendido por cada bloco. Estes pacotes de software permitem, de modo
automático, sem alteração do código, a avaliação do desempenho de aplicações, apresentando os tempos de execução das diversas funções e blocos.
Dois dos pacotes (Gprof e Etch) efectuam instrumentação do programa, sendo também
possível usar a técnica de polling através do Vtune e do Etch.
2.5.5.1 Gprof
O Gprof[Gra82] permite a obtenção de três tipos de informação acerca de uma aplicação:
número de invocações das rotinas, tempo de execução e grafos de dependências entre os
vários sub-programas.
Para obter os resultados pretendidos é necessário efectuar a recompilação de todos os módulos do programa, de modo a serem introduzidas as rotinas de contabilização. Durante a
execução do programa alterado, as rotinas do gprof efectuam a contabilização dos
tempos e número de execuções. Toda esta contabilização é efectuada sem recurso a aplicações externas. Após a execução do programa são criados ficheiros com os resultados.
A possibilidade de obtenção do número de invocação das rotinas e seus grafos de
dependências é obtida com a alteração do código da aplicação. Aquando da compilação e
43
Optimização e Avaliação de Aplicações de Simulação Numérica
ligação do código do programa são introduzidas chamadas a funções do gprof que efectuam a contabilização pretendida. Durante a execução do programa a avaliar, e quando é
invocada uma qualquer função, é também executada uma rotina do gprof, que contabiliza o número de invocações e onde foram realizadas.
Para a obtenção do tempo de execução de cada rotina, o gprof usa dois métodos distintos: instrumentação e polling. Através do uso da instrumentação do código são introduzidas instruções de medição do tempo no início e fim de cada função. Aliado à informação
obtida do grafo de chamadas consegue-se obter com exactidão o tempo de execução do
código de cada função. Nos sistemas de time-sharing esta aproximação apenas nos indica o
tempo real que as funções demoraram, mas não nos informa do tempo que estas estiveram a executar no processador.
O uso de polling permite saber durante quanto tempo cada função esteve a ser executada
no processador. No início do programa a avaliar é iniciado um outro processo que, com
uma determinada frequência, interrompe o programa e verifica qual a função que se
executava nesse momento. Com toda a informação obtida durante a execução é possível
obter uma aproximação estatística ao tempo de execução de cada rotina. É necessário um
cuidado especial com o valor da frequência de interrupção para não ignorar as funções
que executam muito rapidamente.
Esta segunda técnica não precisa de suporte especial do processador, visto apenas necessitar de temporizadores para efectuar a interrupção do programa com determinada frequência.
Após a execução da aplicação a observar passam a existir os ficheiros com os dados recolhidos durante a sua execução, tendo sido realizada a sua geração no fim da sessão de
profiling. Para serem apresentados de modo legível é necessário executar a aplicação
gprof. Esta aplicação permite a apresentação dos resultados de modos diversos, por
exemplo: tempo total ou médio de execução das funções, divisão do tempo de execução
pelas várias rotinas invocadas, etc.
2.5.5.2 Etch
O sistema Etch[Rom97] permite a alteração e rescrita de executáveis sem o acesso ao código fonte e sem compilação. Esta possibilidade de rescrita dos programas, sem acesso ao
código fonte, não só permite a optimização dos programas como também a contabilização
dos tempos de execução das várias funções e instruções.
44
Trabalho Relacionado
O Etch é uma plataforma que suporta a inclusão num programa de qualquer tipo de contabilização a realizar durante a execução. A avaliação da aplicação realiza-se em três fases:
i) compilação de uma versão sem instruções de contabilização, ii) instrumentação através
do Etch e iii) recolha dos dados durante a execução da aplicação. Estas três fases permitem que, para um mesmo executável, e sem alteração do código e posterior compilação, se
consigam efectuar diversas medições de indicadores distintos.
Para efectuar a instrumentação é necessário, numa primeira passagem, realizar a leitura
do executável e análise para obter a sua estrutura. Pode-se então efectuar a rescrita do
programa, introduzindo nos locais certos as instruções de contabilização de tempos ou
outros eventos relevantes.
Após a instrumentação e durante a execução do programa, são contabilizados os tempos
despendidos por cada bloco e o número de execuções. No fim do programa existe código
que recebe os valores contabilizados durante a execução, agrupa-os e guarda-os num ficheiro.
Com o sistema Etch são disponibilizados programas que alteram os executáveis de modo
a contabilizar vários factores relevantes (tempo de execução, cache misses, acessos à memória desalinhados,…), mas podem ser escritos programas que, usando as bibliotecas fornecidas, alteram os executáveis de modo a serem contabilizados outros factores.
2.5.5.3 Vtune
Vtune[Int02] é um sistema de avaliação de aplicações que, sem o recurso a instrumentação e usando os recursos fornecidos pelos processadores da Intel, permite a avaliação
de aplicações. Este sistema permite contabilizar o número de execuções de cada instrução,
o seu tempo de execução e o número de eventos relevantes para o desempenho que ocorreram durante a execução (ver secção 2.5.2). O Vtune também efectua avaliação estática
do código encontrando situações onde o encadeamento das instruções introduz atrasos na
execução.
As aplicações a observar não necessitam de qualquer alteração ou compilação adicional,
visto o sistema Vtune executar em paralelo com a aplicação e observar o seu estado durante a recolha dos dados. Apenas a informação de debug (tradução de símbolos) deverá
estar presente de modo a facilitar a apresentação dos resultados recolhidos. Para efectuar
o profiling de uma aplicação apenas é necessário executá-la dentro do ambiente Vtune.
O sistema Vtune permite efectuar a avaliação das aplicações através de polling ou por in45
Optimização e Avaliação de Aplicações de Simulação Numérica
terrupção após determinado número de eventos. Se o Vtune for configurado para usar
polling, com uma determinada frequência, interrompe a execução da aplicação e verifica
qual a instrução a executar e armazena os valores relevantes para a contabilização. No
outro modo de funcionamento, e usando uma característica dos processadores Pentium,
o Vtune interrompe a aplicação após a ocorrência de um determinado número de eventos, sendo então armazenada informação acerca da instrução que se executava nesse instante. Com estes dois métodos conseguem-se obter resultados muito aproximados dos valores reais.
Após a recolha dos dados é fornecida ao utilizador uma interface que permite a navegação no código, sendo apresentados os valores contabilizados para cada módulo, função e
instrução.
Os eventos mais relevantes para a optimização de código e contabilizáveis pelo Vtune
são os apresentados na secção 2.5.2. Embora os eventos contabilizáveis variem com o processador usado, é sempre possível contabilizar o tempo de execução e o número de
invocações de cada rotina e instrução.
2.6 Resumo e enquadramento
Neste capítulo foram apresentadas técnicas de optimização de código. Estas técnicas estão
divididas em dois tipos: dependentes e não dependentes da arquitectura de execução. As
técnicas apresentadas podem ser aplicadas a código de alto nível sem necessidade de manipulação do código assembly. Nos próximos capítulos são demonstrados de que modo se
aplicaram as técnicas de optimização a código de simulação numérica e que resultados se
obtiveram.
Também se descreveram neste capítulo várias arquitecturas de sistemas paralelos. Como
se concluiu do levantamento de requisitos do projecto Flash apenas clusters e multiprocessadores de memória partilhada está disponíveis em pequenas e médias empresas (ver a
secção 3.1.3 – Ambiente de execução). Também se descreu as bibliotecas e sistemas que
permitem o desenvolvimento de aplicações paralelas (MPI, PVM, TreadMarks e HPF).
Dois destes sistemas (PVM e TreadMarks) foram usados na paralelização de uma
aplicação e os resultados de ambas as execução serão apresentados no próximo capítulo.
As ferramentas de análise de desempenho apresentadas são representativas daquelas hoje
disponibilizadas para uso. Na avaliação da aplicação de simulação não foram usadas
todas elas, por em parte se sobreporem em funcionalidades. Como se verá utilizaram-se
os timers, os contadores do processador e do sistema operativo e a aplicação Vtune.
46
3 Enquadramento
O trabalho apresentado nesta dissertação foi realizado no âmbito do projecto europeu
n.º 24903 intitulado “HPCN Tools for Enhanced Hydrodynamic Design of Fast Ships on Parallel
Computing Platforms”. O objectivo geral deste projecto era o desenvolvimento de ferramentas de análise e desenho de cascos de embarcações rápidas, usando os meios computacionais já existentes nos utilizadores do sistema.
Neste capítulo apresenta-se com mais detalhe o projecto Flash e a participação do INESC.
Também se descreve o código de análise de cascos anteriormente existente e que serviu de
suporte ao projecto, assim como o ambiente e plataformas de execução do resultado final.
3.1 Descrição do Projecto Flash
Na indústria naval, para avaliar o desenho dos cascos das embarcações, usa–se a
simulação de modelos à escala, em tanques de ensaio. Este processo é útil mas os resultados obtidos são poucos e difíceis de avaliar: resistência total, perfil e elevações das ondas
geradas. Estes resultados são úteis na comparação do desempenho das carenas, mas não
permitem a análise estrutural dos navios.
A simulação numérica destes testes, não só permite obter os resultados atrás descritos, como também outros valores necessários à avaliação do comportamento dos cascos. A pressão exercida ao longo de todo o casco, assim como a velocidade real dos fluxos são valores
que é possível obter com a simulação.
A simulação numérica, para além dos resultados adicionais que fornece, também permite
47
Optimização e Avaliação de Aplicações de Simulação Numérica
a realização dos testes com custos menores e mais rapidamente. Não há necessidade de
realizar modelos dos cascos e os resultados numéricos são disponíveis logo após a
finalização dos testes, permitindo a sua integração com ferramentas de análise de
estruturas.
O objectivo geral do projecto Flash era a criação de uma plataforma integrada de desenho,
simulação e avaliação de cascos de embarcações rápidas (Número de Froude superior a
0.3), que aproveitasse as possibilidades de processamento paralelo dos computadores
existentes nos gabinetes de desenho de embarcações. Os objectivos específicos
apresentados eram:
•
Fornecer à indústria naval um novo sistema eficiente
para o desenho e
optimização de cascos e estruturas de embarcações usando uma rede de
computadores formando um cluster.
•
Demonstrar e avaliar o uso de sistemas HPCN em computações de larga escala
típicas do desenho de cascos de embarcações envolvendo problemas de
hidrodinâmica e mecânica de estruturas.
•
Aumentar o interesse para as utilização de aplicações de simulação computacional
na indústria naval europeia, fornecendo uma interface amigável para ferramentas
de desenho e optimização de embarcações comerciais (passageiros e mercadoria)
rápidas.
O consórcio participante neste projecto era formado por parceiros representativos da
indústria naval europeia e áreas afins:
•
4 construtores navais com desenhos de embarcações próprios
•
1 empresa de ensaios em tanques
•
2 grupos de investigação (um ligado à simulação numérica e outro ligado às
ciências da computação).
O desenvolvimento da plataforma Flash desenvolveu-se em três frentes: i) integração do
pré/pós-processador Gid[CIM02] e simulador com aplicações CAD já existentes; ii) melhoria dos resultados obtidos para o tipo de embarcações alvo; iii) optimização do código
de simulação.
A integração do código com as ferramentas de desenho foi realizada pelos elementos do
CIMNE, os especialistas em simulação numérica. Também estes investigadores alteraram
o código de modo a obterem-se resultados mais próximos dos obtidos em testes de tan48
Enquadramento
ques de ensaio. A avaliação dos resultados foi realizada pelos vários construtores de embarcações (BAZAN, ENVC, SP Technologies e NAUTATEC) com base em valores obtidos
no tanque de ensaios (BEC). A equipe do INESC foi responsável pela optimização do código de simulação produzido e testado pelos outros parceiros.
3.1.1 Arquitectura do sistema
A plataforma a instalar nos utilizadores do sistema é composta por três elementos principais:
•
Aplicação CAD para desenho da embarcação;
•
Aplicação CFD de simulação de comportamento do casco;
•
Aplicação para avaliação dos resultados e impacto na estrutura da embarcação.
Modelo
embarcação
Pre-processador
Malha
Aplicação de CAD
Aplicação de simulação
Alterações
Pos-processador
(visualização do
resultados)
Resultados
GiD
Plataforma FLASH
Fig. 3.1 – Arquitectura geral da plataforma de desenvolvimento e simulação das
embarcações
A aplicação de CAD serve para o desenho da embarcação, não só do contorno do seu casco, mas também de toda a estrutura de suporte. O GiD, desenvolvido por um dos parceiros (CIMNE), é um pré/pós-processador que, a partir da descrição da embarcação, gera
as malhas necessárias ao simulador. Após a simulação é possível não só observar os
resultados numéricos como também analisar as influências expectáveis sobre a estrutura
proposta.
O pré-processador gera a representação de 3 malhas com tipos de elementos diferentes:
contorno do casco, superfície do líquido e volume do líquido. Os elementos do contorno
do casco e da superfície são triangulares e o do volume do líquido são tetraédricos. Estes
dados juntamente com os parâmetros da simulação são lidos pela aplicação de simulação.
49
Optimização e Avaliação de Aplicações de Simulação Numérica
3.1.2 Aplicação de simulação
O código do simulador resolve as equações incompressíveis de Navier Stokes, que regem o
comportamento de fluidos num determinado domínio. A discretização temporal das
equações de Navier Stokes realiza-se através dum método de passos fraccionados semi-implícitos, enquanto que para a discretização espacial se usa um método de elementos
finitos.
Na simulação do contorno livre (superfície livre do fluido) utiliza-se um método de transpiração, em conjunto com o transporte euleriano da superfície de corrente. Também para
a discretização destas equações se usaram métodos de elementos finitos[Woo99].
Apresenta-se de seguida a vista conceptual[Hof00] da versão série do simulador.
Domínio
Leitura dos
dados
Malha
Escrita dos
resultados
Cod.
Simulação
Leitura da
configuração
Solver
Fig. 3.2 – Vista conceptual do simulador (versão série)
O módulo malha representa os dados físicos do problema, contorno do casco e fluidos
envolventes. O Código de Simulação é responsável pela manipulação dos dados
armazenados na malha, é este código que gera os sistemas de equações a resolver e
actualiza os dados. O Solver resolve os sistemas de equações construídos pelo Código
de Simulação.
Na Fig. 3.3 é apresentada a hierarquia das classes necessárias à implementação do código
numérico do simulador (malha , Código de Simulação e Solver). O armazenamento
dos elementos é realizado usando listas dinâmicas(Plex), uma para cada tipo de
elementos armazenados: casco, fluido e superfície livre. Também os nós são armazenados
usando a classe Plex. Cada nó, para além da informação acerca dos elementos a que
50
Enquadramento
pertence, tem associado os vários graus de liberdade (Dof). Toda a codificação dos
algoritmos e manipulação de dados foi implementada em C++ e usou-se metodologia
orientada a objecto para definição da hierarquia de classes e das relações entre elas. Todos
os dados são criados dinamicamente e os seus acesso, como é obvio, são realizados através de referências.
«bind»(Elemento)
ReaVec
ElmSet
«bind»(VirDof)
«bind»(MVirDof)
T
Plex
Node
FluidElmSet
BodyElmSet
FreeSElmSet
«bind»(Dof)
Elemento
«bind»(Node)
Domain
Conj_Grad
FreeSElm
FluidElm
Solver
BodyElm
Módulo
malha + Cod. Simulação
Fig. 3.3 – Diagrama de classes simplificado do simulador
Em cada iteração da simulação são gerados sistemas de equações (armazenados e
resolvido pelo solver) a partir da representação das malhas. Após a resolução dos
sistemas, os dados armazenados nas listas dinâmicas são actualizados. O sistema é representado por uma matriz esparsa e a resolução dos sistemas é realizada usando o método
iterativo de gradientes conjugados, tal como apresentado por W. Press et al[Pre92].
Versão paralela original
Inicialmente foi desenvolvido por um dos parceiros uma versão paralela do simulador
que executava sobre uma rede de computador usando PVM.
Esta primeira versão paralela do simulador efectua a criação dos sistemas de equações e a
actualização dos dados em paralelo. A resolução dos sistemas é realizada por um único
computador. Os únicos elementos particionados e distribuídos pelos vários computadores
são os que representam o fluido; todos os outros elementos encontram-se replicados pelos
vários nós participantes nos cálculos. O cálculo da distribuição dos elementos do fluido é
realizado pelo particionador genérico Metis[Kar98].
51
Optimização e Avaliação de Aplicações de Simulação Numérica
<<process>>
<<module>>
master
<<module>>
<<module>>
<<module>>
<<module>>
Domínio
malha
Solver
Sist. Com.
Sist. Com.
cria sist.
parcial
ret. sist
parcial
<<process>>
slave
* lê info
no/elem
cria sist.
parcial
ret. valor
send sist
send
sistem
ret sist.
parcial
read Values
create sist
Solve
Read res
Send res
Send res
Send
result
Update
data
Update
data
* Update
node
Fig. 3.4 – Exemplo de uma iteração da simulação
Apresenta-se na Fig. 3.4 uma interacção entre dois processos (o master e um slave) durante
uma iteração da simulação. Em cada iteração os processos criam as submatrizes correspondentes aos dados por si tratados. Antes da solução do sistema, as submatrizes são
transmitidas a um nó que as agrupa num único sistema e resolve-o. Após o cálculo da solução, os resultados são transmitidos para todos os nós que os usam para actualizar os
seus dados privados. A criação dos sistemas de equações é realizado em paralelo pelos
vários processos, reduzindo o tempo total de construção. No entanto, a resolução dos
sistemas de equações e a manipulação dos elementos replicados não aproveita a existência
dos vários processadores: i) o sistema de equações apenas é resolvido num processo e ii)
os dados replicados são manipulados simultaneamente por todos os processos participantes.
3.1.3 Ambiente de execução
Na fase inicial do projecto submeteram-se os parceiros a um inquérito[Cha97] para a
obtenção do tipo de ambiente possivelmente existente nos futuros utilizadores do sistema
a desenvolver. Parte do questionário prendia-se com o ambiente computacional e quais as
possibilidades de executar código paralelo nas suas instalações. Os meios disponibilizados por cada parceiro para a realização de testes está apresentado na Tab. 3.1.
Da observação dos resultados obtidos no inquérito foi possível inferir o tipo de equipa52
Enquadramento
mento informático existente nos futuros utilizadores do sistema. Observa-se facilmente,
que este tipo de empresas não tem possibilidade de usar os sistemas paralelos com melhores desempenhos. Dos sistemas apresentados na secção 2.3.1 (“Arquitectura de Sistemas
Paralelos”), estas empresas apenas têm acesso a multiprocessadores de memória partilhada (com 2 processadores) e, maioritariamente, a clusters de pequenas dimensões.
Parceiro
BAZAN
Execução de código série
Computador Pessoal
 Pentium II
 266MHz
 128Mb RAM



ENVC
Execução de código paralelo
Rede de
 Pentium II
5 ×  266MHz
 128Mb RAM



Servidor
2 × Pentium 
 133MHz 


Workstation HP
BEC
Computador Sun
2 × processadores 
 1.5 Gb RAM 


NautaTec
Computador Pessoal
 Pentium II
 300MHz
 128Mb RAM
SPTech



Computador Pessoal
 Pentium II
 266MHz
 128Mb RAM



Rede de
 Pentium II
4 ×  300MHz
 128Mb RAM



Rede de
 Pentium II
2 ×  266MHz
 128Mb RAM



 Pentium
4 ×  200MHz
 128Mb RAM



Tab. 3.1 – Sistemas usados pelo parceiros para execução do código de simulação
O esforço e a ordem de desenvolvimento baseou-se no tipo de computadores existentes na
maioria dos parceiro:
•
Versão série executando-se eficientemente num computador pessoal
•
Versão paralela executando-se em cluster de computadores pessoais
•
Versão paralela executando-se em multiprocessador de memória partilhada
53
Optimização e Avaliação de Aplicações de Simulação Numérica
3.1.4 Participação do INESC
As principais acções desenvolvidas pela equipa do INESC que participou neste projecto
prenderam-se com a optimização do código desenvolvido e testado pelos outros parceiros
Um outro factor por nós trabalhado foi a melhoria da interface de configuração e execução
das versões paralelas do simulador.
O trabalho por nós realizado sempre esteve dependente do código produzido e verificado
pelos parceiros. O código de simulação era produzido e testado contra resultados experimentais de tanques de ensaio e só depois era disponibilizado para optimização. Por esta
razão, as primeiras optimizações de código não se realizaram sobre o simulador completo,
mas sim sobre uma das partes estáveis: o solver do sistema de equações.
Só numa fase mais avançada do projecto foi possível contactar com o simulador completo
de modo a avaliá-lo e efectuar as optimizações possíveis e necessárias. Assim, foram
alteradas as duas versões existentes da aplicação de simulador: a versão série e a versão
paralela implementada com PVM.
54
4 Realização
Apresentamos neste capítulo, detalhadamente, as alterações realizadas aos programas originais de modo a aumentar o seu desempenho. Estas alterações são apresentadas pela ordem cronológica da sua realização. Para além das optimizações realizadas, também são
descritas outras acções complementares e úteis à optimização do software.
Apresenta-se inicialmente as optimizações realizadas ao solver usado na aplicação de
optimização, assim como a sua paralelização. Mostra-se em detalhe as alterações efectuadas aos vários blocos de código e a duas funções necessárias à resolução dos sistemas.
Apresenta-se de seguida à várias optimizações realizadas sobre a versão série e paralela
da aplicação de simulação completa. No fim deste capítulo são apresentadas as aplicações
complementares desenvolvidas para efectuar a avaliação do código e para facilitar a execução da versão paralela da aplicação de simulação.
4.1 Optimização do Solver (Conjugate gradient)
Durante uma primeira fase do projecto, o nosso esforço foi centrado na optimização do
código de resolução dos sistemas de equações através do método iterativo de gradientes
conjugados. O código usado na versão original não optimizada é uma variação do apresentado por W. Press[Pre92]. A implementação não optimizada original está apresentada
no “Apêndice B – Conjugate Gradient".
55
Optimização e Avaliação de Aplicações de Simulação Numérica
4.1.1 Função Solve
Ao observar a função solve, a primeira constatação é a da existência de um if dentro do
ciclo que serve unicamente para se executar código diferente na primeira iteração.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
r = rhs –r
asolve()
bnrm = snrm()
while{
bknum = r.r
if(iter = 1)
p=r
else
p = bk*p+r
atimes()
akden = r.p
x += ak*p
r -= ak*z
err = snrm()
if (err < k)
break
A eliminação do if é realizada copiando parte do código da primeira iteração para fora
do ciclo, ficando o código como se apresenta na Fig. 4.1.a.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
r = rhs –r
asolve(p,z)
bnrm = snrm(r)
bknum = r.r
p=r
while{
atimes(p,z)
akden = r.p
x += ak*p
r -= ak*z
err = snrm(r)
if (err < k)
break
bknum = r.r
p = bk*p+r
}
a)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
r = rhs –r
bknum = r.r
asolve(p,z)
bnrm = snrm(r)
p=r
while{
atimes(p,z)
akden = r.p
r -= ak*z
bknum = r.r
err = snrm(r)
if (err < k)
break
x += ak*p
p = bk*p+r
}
b)
Fig. 4.1 – Optimização da função solve a) remoção do if interno ao ciclo,
b) reordenamento das operações
Esta alteração introduz ganhos distintos conforme as características dos processadores. Se
56
Realização
o processador usado não tiver mecanismos de predição de saltos, para além da eliminação
de uma comparação, também se elimina a necessidade de “esvaziar” o pipeline por se ter
lido e descodificado instruções que não irão ser executadas (ser secção 2.2.2.1). No caso
dos processadores mais recentes (Pentium Pro, por exemplo), devido à sua capacidade de
predição de saltos, apenas se elimina a necessidade de efectuar a comparação em todas as
iterações.
Assim, não só se elimina o if, como também se facilitam as optimizações posteriores.
Após esta primeira optimização, observou-se onde era gasto mais tempo durante a execução da função solve. Realizaram-se, então, as optimizações necessárias e que se apresentam de seguida.
As alterações realizadas a esta função prenderam-se com a optimização dos acessos à memória, aumentando a percentagem de hits na cache.
Observando a função solve
(Fig. 4.1.a), verifica-se que existem duas ocasiões em que se acede ao vector p (linhas 9 e
15) mas separadas por várias outras instruções, cujo código C aqui se apresenta:
1
2
3
4
5
6
7
for (j=first_r;j<=last_r;j++) {
x[j] += ak*p[j];
r[j] -= ak*z[j];
}
(...)
for (j=first_r;j<=last_r;j++)
p[j]=bk*p[j]+r[j];
Com este código, devido à intercalação de outra instruções entre os ciclo, a cache não é
aproveitada no segundo ciclo. O vector p, que havia sido carregado para a cache no
primeiro ciclo, teve de ser substituído por outros dados.
Com a criação de dois ciclos distintos (correspondentes às linhas 1, 2 e 3 anteriores) já há
possibilidade de reordenar as operações, podendo ficar estas contíguas tal como se observa na Fig. 4.1.b (linhas 14 e 15). Assim já podem ser efectuadas num só ciclo:
1
2
3
4
for (j=first_r;j<=last_r;j++){
x[j] += ak*p[j];
p[j]=bk*p[j]+r[j];
}
Esta alteração permite que na segunda instrução de cada iteração do ciclo, não haja
necessidade de aceder à memória principal para ler a posição p[j] visto esta já se encontrar em cache (o cache miss anteriormente correu na instrução x[j] += ak*p[j]).
Com estas alterações optimizou-se o uso da cache. Em cada segundo acesso ao vector, já os
dados acedidos se encontram em cache, eliminando a necessidade de efectuar uma leitura
57
Optimização e Avaliação de Aplicações de Simulação Numérica
da memória. Na situação original, como os acessos aos vectores se encontravam distantes,
na segunda operação já o vector não se encontrasse em cache.
Alterações semelhantes foram realizadas para os outros vectores sempre que possíveis.
Na Fig. 4.1 encontram-se destacados os vário ciclos que foram deslocados e agregados, de
modo a se tirar melhor partido da cache.
4.1.2 Função Atimes
O código original da função atimes apresentado pelo parceiro responsável é o apresentado na Fig. 4.2. Esta função multiplica uma matriz esparsa (sa e ija)por um vector x.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
for (i=first;i<=last;i++){
b[i] = sa[i]*x[i];
int* ijai = &(ija[ija[i]]);
Real* bi = &(b[i]);
Real* sak = &(sa[ija[i]]);
int*
up = ijai + ((ija[i+1]-ija[i]) & ~07);
while (ijai<up){
*bi += *sak++ * x[*ijai++];
*bi += *sak++ * x[*ijai++];
*bi += *sak++ * x[*ijai++];
*bi += *sak++ * x[*ijai++];
*bi += *sak++ * x[*ijai++];
*bi += *sak++ * x[*ijai++];
*bi += *sak++ * x[*ijai++];
*bi += *sak++ * x[*ijai++];
}
up = &(ija[ija[i]]) + (ija[i+1]-ija[i]);
while (ijai < up) *bi += *sak++ * x[*ijai++];
}
Fig. 4.2 – Implementação original da função atimes
Embora se observe um loop unrolling, no ciclo while, o problema desta implementação
persiste. Observou-se, através do uso de contadores do processador (ver a secção 2.5.2 –
Contadores dos processadores) que ocorrem stalls do processador devido à dependência
entre os resultados das instruções e os operandos das operações seguintes. Também se
verificam atrasos devido à geração dos endereços necessários às realização das instruções.
A nossa proposta para optimização deste código prendeu-se com a eliminação desses
atrasos. O loop unrolling foi eliminado por se verificar que não introduzia ganhos significativos.
As primeiras inicializações dentro do ciclo for (linhas 2 a 6 na Fig. 4.2) são traduzidas
58
Realização
para o seguinte código assembly:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
;b[i]=sa[i]*x[i];
mov ebx, DWORD PTR [esi+28]
fld DWORD PTR [ecx+edx]
fmul DWORD PTR [ebx+eax*4]
fstp DWORD PTR [edx]
;int* ijai = &(ija[ija[i]]);
mov ebx, DWORD PTR [esi+24]
;Real* bi = &(b[i]);
;Real* sak = &(sa[ija[i]]);
mov esi, DWORD PTR [esi+28]
mov ebp, DWORD PTR [ebx+eax*4]
lea ecx, DWORD PTR [ebp*4]
add esi, ecx
lea eax, DWORD PTR [ecx+ebx]
;int* up
= ijai + ((ija[i+1]-ija[i]) & ~07);
mov ecx, DWORD PTR 8+[esp+16]
add ecx, edx
mov ebx, DWORD PTR [ecx+ebx]
sub ebx, ebp
and ebx, -8
; fffffff8H
lea ebx, DWORD PTR [eax+ebx*4]
Devido ao modo como estas instruções assembly estão encadeadas, não é possível
aproveitar o paralelismo fornecido pelos processadores. Por exemplo, as instruções
fld DWORD PTR[ecx+edx] e fmul DWORD PTR [ebx+eax*4] (linha 3 e 4 no código
anterior) não podem ser iniciadas em simultâneo por serem executadas na mesma
unidade de execução (ver secção 2.2.2.1).
Outra causa de penalidades que ocorre com frequência e atrasam a execução do programa
é a geração explícita de endereços. Nas instruções mov ebx, DWORD PTR [esi+24],
mov esi, DWORD PTR [esi+28] e mov ebp, DWORD PTR [ebx+eax*4] (linhas 7, 8
e 9) há uma dependência entre a primeira e a terceira instrução devido ao registo ebx, que
é alterado na primeira instrução e usado no cálculo de um endereço na última instrução.
Problema semelhante existe devido à proximidade das instruções nas linhas 11 e 12.
A optimização deste trecho de código consistiu, essencialmente, na eliminação das gerações de endereços e na intercalação de instruções de tipos distintos. A geração dos endereços passa a realizar-se unicamente dentro do ciclo while.
Estudo semelhante foi realizado para o ciclo interno da função atimes (while apresentado na Fig. 4.2) , observando-se o código assembly gerado e as penalidades que ocorrem da
sua execução. No código interno ao ciclo while ocorrem atrasos distintos daqueles já
descritos.
59
Optimização e Avaliação de Aplicações de Simulação Numérica
1
2
3
4
5
mov
fld
fmul
fadd
fstp
ebp,dword
dword ptr
dword ptr
dword ptr
dword ptr
ptr [eax-14h]
[edi+ebp*4] ;
[esi-14h]
;
[edx]
;
[edx]
;
leitura de x[*ijai++]
multiplicação
soma
armazenamento de *bi
Na tradução em assembly do cálculo *bi += *sak++ * x[*ijai++] atrás apresentada,
observam-se quatro instruções de vírgula flutuante seguidas (fld, fmul, fadd e fstp)
correspondentes à leitura, multiplicação, soma e armazenamento do resultado.
Como o resultado do fmul será um operando do fadd e estas instruções encontram-se
contíguas, esta dependência obrigará à ocorrência duma penalidade. Esta penalidade,
com duração de 1 ciclo de relógio, permite que o resultado da multiplicação seja calculado
antes de ser usado na instrução seguinte. Também ocorre uma penalidade semelhante
aquando da execução do fstp, que necessita de esperar pelo resultado da soma anterior.
Este problema é resolvido afastando a multiplicação da soma, intercalando outras instruções independentes. Na nossa solução, decidimos intercalar, entre os cálculos, as actualizações dos índices. O código final optimizado da função atimes, e com as alterações
atrás descritas, está aqui apresentado:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
for (i= first; i<=last; i++){
k= ija[i];
aux = ija[i+1]-1;
kaux = k;
ij_aux = ija[k];
daux = sa[i]*x[i];
while (k<= aux){
k++;
daux2 = sa[kaux]*x[ij_aux];
kaux++;
ij_aux = ija[k];
daux += daux2;
}
b[i] = daux;
}
Fig. 4.3 – Versão optimizada da função atimes
Se observarmos o seguinte trecho do código assembly da versão optimizada, podemos
observar que alguns dos problemas apresentados desaparecem.
1
2
3
4
5
60
fld
fmul
mov
add
add
DWORD PTR [edx+ebx*4]
DWORD PTR [edi]
ebx, DWORD PTR [esi+4]
esi, 4
edi, 4
Realização
6
7
dec eax
faddp
ST(1), ST(0)
Observa-se facilmente que entre a multiplicação (fmul na linha 2) e a soma (faddp na
linha 7), existem uma série de instruções independentes e não de vírgula flutuante que
podem ser executadas. Elimina-se assim a necessidade da existência de um stall do
processador. Também se observa que o uso da variável daux para armazenamento do
valor parcial de b[i] permitiu a eliminação de uma instrução fstp (escrita em memória
de um valor real).
4.1.3 Função Asolve
A função asolve, cujo código está apresentado na Fig. 4.4, exemplifica um tipo de código, que, com processamento escalar, é difícil de optimizar.
1
2
3
4
5
6
7
8
9
10
11
void Conj_Grad::asolve(int first, int last, Real b[],
Real x[], int itrnsp){
register int i;
if (itrnsp)
for(i=first;i<=last;i++)
x[i]=b[i];
else
for(i=first;i<=last;i++)
x[i]=(sa[i] != 0.0 ? b[i]/sa[i] :b[i]);
}
Fig. 4.4 – Implementação original da função asolve
Nesta função, grande parte do tempo é despendido no cálculo da divisão b[i]/sa[i]. A
divisão de número reais, nos processadores Pentium, apresenta dois problemas: a instrução demora muitos ciclos a executar e a divisão não pode ser executada em paralelo com
nenhuma outra instrução de vírgula flutuantes (fld, fcomp, fmul, fstp). Estes dois factos aliados levam as instruções seguintes à divisão (fstp e as operações da iteração seguinte) a esperar pela conclusão da divisão.
1
2
3
4
5
6
7
fld DWORD PTR [edx]
fnstsw
ax
test ah, 64
; 00000040H
jne SHORT $L57519
fdiv DWORD PTR [edi]
$L57519:
fstp DWORD PTR [ebx+edx]
Observando o código assembly anterior (correspondente à linha 10 da Fig. 4.4) observa-se
61
Optimização e Avaliação de Aplicações de Simulação Numérica
que imediatamente a seguir à instrução fdiv (linha 5) é executada uma outra instrução
de vírgula flutuante (linha 7). Devido ao desenho do processador Pentium, a instrução
fstm só pode ser iniciada após a conclusão da divisão (fdiv).
Para “esconder” o tempo de execução da divisão seria necessário permitir a intercalação
de outras instruções (inteiras ou de controle) entre a divisão e o armazenamento do resultado. No código apresentado, devido ao reduzido número de instruções existentes dentro
do ciclo, não é possível realizar essa intercalação.
4.2 Paralelização do Solver (Conjugate gradient)
Após a optimização do código, efectuámos a sua paralelização, primeiro para permitir a
execução em multiprocessadores de memória partilhada, em seguida foi avaliada a possibilidade de executar uma versão paralela do solver num clusters de PC’s. Apresentamos
aqui as alterações de código necessárias para a execução do solver nas arquitecturas atrás
apresentadas.
4.2.1 Paralelização usando threads
O primeiro tipo de optimização realizada teve como objectivo possibilitar a execução do
código em computadores multiprocessadores de memória partilhada. Para a realização
desta optimização, usou-se a biblioteca de threads fornecida pela Microsoft para os
sistemas operativos Windows.
Antes de paralelizar o código há necessidade de observar de que modo os dados são
manipulados de modo a efectuar a distribuição dos dados de forma mais eficiente. Na
optimização óptima, um determinada variável (ou porção de um vector) só deve ser
manipulado por uma tarefa. Só assim se consegue simplificar a sincronização necessária
entre tarefas e, no caso do uso de memória distribuída, reduzir a quantidade de dados
transmitidos entre as várias tarefas.
A primeira operação realizada foi a observação dos dados manipulados durante a solução
dum sistema (A.x = rhs), tendo-se coligido a seguinte informação apresentada na Tab.
4.1.
Para cada variável manipulada na resolução do sistema, é indicado o seu tipo (escalar ou
vector) e tamanho, assim como o significado. Também é indicado, para cada uma das variáveis) de que modo é manipulada durante a execução, mostrando-se também de que variáveis dependem os seu valores.
62
Realização
ija, sa Matriz esparsa A (nrows × nrows)
Sistema de equações
Matriz esparsa onde se encontram guardadas as equações que formam o
sistema. Apenas é lida durante a solução do sistema, não sendo alterada
a partir daí.
Rhs Vector com nrows elementos
Resultado do sistema
Vector com o resultado do sistema. Apenas é lido durante a resolução do
sistema, não sendo alterada a partir daí.
Err Escalar
Erro dos valores calculados
err = r
X Vector com nrows elementos
Valores das incógnitas
x = x + ak * p
R Vector com nrows elementos
Variável auxiliar
r = b - A * x (inic), r = r + ak * z (iter)
Z Vector com nrows elementos
Variável auxiliar
z = diag(A)– 1 * b (inic), z = A * p (iter)
P Vector com nrows elementos
Variável auxiliar
P = z + bk * p
Bknum Escalar
bknum = r . r
Variável auxiliar
akden Escalar
Akden = z . p
Variável auxiliar
ak, bk Escalares
Variáveis auxiliares
ak = bknum/akden, bk = bknum/bknum-1
Tab. 4.1 – Dados manipulados na função solve e suas dependências
Observando a tabela anterior, concluiu-se que os elementos dos vectores X, R Z e P,
devido às operações onde ocorrem, podem apenas ser manipulados por uma tarefa, e que
essa tarefa manipula sempre os elementos dos vários vectores com índices idênticos.
Decidiu-se assim uma destruição dos vectores pelas várias tarefas: a cada tarefa fica associado o limite inferior e superior (first_r, last_r) dos sub-vectores por si guardados e
manipulados.
Apresenta-se de seguida a paralelizações da operação x = x + ak * p. Cada tarefa
executa o mesmo código, mas como cada uma tem limites (first_r, last_r) distintos,
cada elemento dos vectores apenas é acedido por uma tarefa.
63
Optimização e Avaliação de Aplicações de Simulação Numérica
1
2
3
for (j=first_r;j<=last_r;j++) {
x[j] += ak*p[j];
}
De modo igualmente simples, mas com necessidade de comunicação adicional, foram implementados os produtos internos:
1
2
3
4
5
6
7
8
9
for (bknum=0.0, j=first_r;j<=last_r;j++) {
bknum += r[j]*r[j];
bknum += r[j+1]*r[j+1];
}
part_bknum[proc_id] = bknum;
barrier(0);
bknum = 0;
for (aux = 0; aux < Tmk_nprocs; aux ++)
bknum += part_bknum [aux];
Fig. 4.5 – Versão paralela dum produto interno
No primeiro ciclo, cada tarefa calcula o produto interno correspondente ao sub-vector por
si manipulado, armazenando de seguida a sua comparticipação para o valor final num
vector partilhado por todas as tarefas (part_bknum, neste exemplo). A sincronização
entre as várias tarefas é realizada na instrução barrier. Depois de todos os valores terem
sido escritos no vector partilhado, é possível a cada tarefa calcular o valor final do
produto interno (bknum).
O produto entre a matriz A e os vectores (z = A * p, por exemplo) também aproveita a
divisão dos dados anteriormente definida, mas obriga a que alguns vectores sejam partilhados e acessíveis a todas as tarefas. A multiplicação de uma matriz esparsa por um vector era executada originalmente tal como se apresenta na Fig. 4.6.
t0
X
=
t18
A (ija, sa)
x
b
1
2
3
4
5
6
7
8
for (i=0; i<nrows ;i++) {
b[i]=sa[i]*x[i];
k=ija[i]
while(k<=ija[i+1]-1){
b[i] += sa[k]*x[ija[k]];
k++;
}
}
Fig. 4.6 – Multiplicação de uma matriz por um vector (versão série)
Tal qual na multiplicação de uma matriz normal por um vector, o elemento i do vector b
é obtido efectuando o produto interno A[i].x. Como a matriz é esparsa as linhas de A
64
Realização
não são percorridas do início ao fim, mas apenas onde existem valores não nulos (posições cinzentas em A).
Existem dois modos de distribuir os cálculos pelas várias tarefas, estando-lhes associados
dois modo de partir a matriz.
Se a matriz for particionada em colunas, ficando cada tarefa com um conjunto disjunto de
coluna, não há necessidade da partilha de x, mas cada tarefa apenas calcula os resultados
parciais de b. No fim dos cálculos deve-se consolidar os resultados de cada tarefa. Nesta
caso cada tarefa, e para cada índice i, apenas calcula parte do produto interno A[i].x: cada tarefa apenas efectuar A[i][first_r..last_r].x[first_r..last_r]. No final
dos cálculos parciais há necessidade da agregação dos valores parciais.
Na solução por nós adoptada, a matriz é partida em vários conjuntos de linhas, ficando
cada tarefa com um desses conjuntos. Neste caso, cada tarefa consegue calcular completamente os elementos de b a si associados.
Cada elemento de índice i do vector resultado depende apenas da linha i da matriz, mas
também depende, potencialmente, de todo o vector x. Por esta razão o vector x deve ser
partilhado e acessível por todas as tarefas, embora só a sua actualização seja feita noutra
parte do código e em paralelo pelas várias tarefas.
Na Fig. 4.7 é apresentado um esquema e o código da versão paralela de cálculo do produto entre uma matriz e um vector.
t0
X
=
t6
t0
t12
A (ija, sa)
x
b
1 for (i=first_r;i<=last_r;i++){
2
b[i]=sa[i]*x[i];
3
k=ija[i]
4
while(k<=ija[i+1]-1){
5
b[i] += sa[k]*x[ija[k]];
6
k++;
7
}
8}
Fig. 4.7 – Multiplicação de uma matriz por um vector (versão paralela)
Neste caso, os produtos internos A[first_r+i].x realizados por tarefas distintas são
executados simultaneamente (representados pelas setas distintas na Fig. 4.7).
O cálculo do índice mínimo e máximo de cada tarefa (first_r, last_r) é realizado do
modo mais simples: o número de linhas da matriz é dividido pelo número de tarefas,
65
Optimização e Avaliação de Aplicações de Simulação Numérica
sendo, a partir desse valor, calculados os índices.
Como a matriz é esparsa, não é garantido que o número de operações realizadas pelas várias tarefas e o tempo de execução associado sejam iguais para todas elas. Pode ocorrer
que o bloco atribuído a uma das tarefas tenha mais elementos, obrigando à execução de
mais cálculos, mesmo após o término das outras tarefas. Para equilibrar os tempos de
execução das várias tarefas, o cálculo do índice mínimo e máximo de cada tarefa deveria
ser realizado tendo em atenção o número de elementos de cada linha. A situação óptima,
para esta operação seria encontrada quando os elementos estivessem irmãmente distribuídos pelas tarefas. Esta nova distribuição iria aumentar o tempo de execução da função
atimes e da multiplicação de vectores. Com a distribuição equitativa dos elementos dos
vectores entre tarefas, todas as tarefas executam o mesmo número de operações o que minimiza o tempo total de execução das multiplicações e da função atimes.
4.2.2 Paralelização usando DSM
Após a paralelização do código usando threads e memória partilhada, testámos a
possibilidade de executar este código numa rede de computadores pessoais.
O paradigma de programação usado foi memória partilhada e distribuída e a biblioteca
utilizada foi o TreadMarks. Como o paradigma de programação é muito semelhante ao
usado na paralelização anterior, as alterações ao código são mínimas.
O modo de partir e distribuir os dados manipulados pelas tarefas mantém-se iguais. A
sincronização também se mantêm, visto a primitiva usada, a barreira, ser o modo privilegiado de sincronização, quando se programa usando memória partilhada e distribuída.
Só a criação dos dados deve ser alterada. Enquanto que, na memória partilhada normal
com o uso de threads, todas as variáveis globais e dados acessíveis a partir destas são partilhadas por todas as tarefas, na memória partilhada e distribuída tal não ocorre. Há necessidade de, aquando da alocação de memória, indicar explicitamente que esses dados
serão criados no espaço partilhado. Observa-se na Fig. 4.8 o modo de criação de memória
partilhada e distribuída.
Neste exemplo, é a tarefa inicial que cria o vector no espaço de memória partilhado
através de função Tmk_malloc. A referência para essa memória é guardada na variável
global p. Há depois a necessidade de distribuir por todas as tarefas o novo valor da
variável p. Como p é uma variável global, mesmo não se encontrando em memória
partilhada, o seu endereço é igual em todas as tarefas. Após a instrução Tmk_distibute,
66
Realização
todas as tarefas ficam com a variável p a apontar para o vector criado em memória
partilhada e distribuída, passando a ser responsabilidade do sistema a coerência e
actualização dos dados distribuídos.
1
2
3
4
5
if (Tmk_proc_id == 0){
p = (Real *) Tmk_malloc((nrows+1)*sizeof(Real));
Tmk_distribute((char *) &p, sizeof (p));
}
Tmk_barrier(0);
Fig. 4.8 – Criação de um vector em memória partilhada e distribuída
4.3 Optimização do simulador (aplicação FLASH)
Da avaliação da aplicação de simulação (aplicação cuja vista conceptual se apresenta na
Fig. 3.2) observámos que era gasto tempo substancial na construção das matrizes esparsas
necessárias à representação dos sistemas de equações (ver secção 3.1.2). Este tempo era
substancialmente superior ao da leitura dos dados e resolução dos sistemas, correspondendo a cerca de 90% do tempo total de execução. A redução deste tempo foi a primeira
medida tomada na optimização do simulador.
Como as matrizes esparsas optimizam a utilização de memória, antes da sua criação é
necessário saber qual o número de elementos a armazenar e quantos elementos terá cada
linha, não bastando saber quantas linhas a matriz irá ter. É apresentado na Fig. 4.9 o modo
como originalmente se calculava a dimensão (número de elementos) exacta da matriz.
t0 0
1
+1
1
+1
1
0
1
1
1
0
1
1
2
0
1
2
0
1
3
+1
+1
3
4
+1
1
2
+1
+1
+1
3
4
5
+1
+1
+1
+1
+1
t18 1 +1 2 +1 4 +1 5 +1 6 +1
1
2
3
4
5
6
7
8
void usePos(int col,
int lin){
if (col == lin)
return ;
for (j = lin;
j<nrows;j++){
Row[j] ++;
}
Fig. 4.9 – Conversão para matriz esparsa (versão não optimizada)
No exemplo anterior as linhas têm as seguintes dimensões: (1, 1, 2, 1, 1). Cada linha da
figura representa a contabilização de um elemento não nulo pertencente à matriz, sendo
também indicadas as operações (somas) realizadas para cada elemento não nulo.
O código apresentado anteriormente é invocado para cada elemento que se deseja
67
Optimização e Avaliação de Aplicações de Simulação Numérica
armazenar na matriz, tendo como parâmetros a posição do elemento. Durante a contabilização da dimensão da matriz, o vector Row contém em cada posição i o número de elementos já contabilizados pertencentes a linhas com índice menor ou igual a i. No final
tem-se, em cada posição do vector, o número de elementos que iriam existir nas linhas da
matriz com índice menor ou igual.
O código original, para cada novo elemento da matriz não pertencente à diagonal, incrementa o valor de todas as posições do vector com índice superior ou igual ao índice da
sua linha. Consegue-se assim calcular não só, quantos elementos serão armazenados na
matriz, como também em que índice da matriz esparsa começará cada linha. Como se
pode facilmente observar, este código executa em O(n2) tempo, o que o torna ineficiente
para matrizes com dimensões elevadas.
A alteração proposta por nós obtém os mesmos resultados mas, nos estádios intermédios,
não guarda os valores acumulados, mas apenas o número de elementos de cada linha.
t0 0
1
0
+1
0
0
0
1
0
0
1
0
1
0
1
0
1
1
0
1
2
t18 1 +1 1
2
1
1+1
2
t6
+1
+1
2+2
4
+1
1
1
1
1
1
1
1
4+1
5
+1
5+1
6
t10
1
2
3
4
5
6
void usePos(int col,
int lin){
if (col == lin)
return ;
Row[lin] ++;
}
7
8
9
10
for(ind =1; ind <nrows;
ind++){
Row[ind] += Row[ind-1];
}
Fig. 4.10 – Conversão para matriz esparsa (versão optimizada)
A contabilização do número de elementos de cada linha, realiza-se apenas com o incremento do valor correspondente à linha. Após a recolha de todos os dados é necessário
calcular os valores acumulados, bastando para tal um ciclo como o apresentado na Fig.
4.10. Conseguimos assim reduzir o tempo de execução para O(n).
Após esta primeira optimização, cujos resultados se apresentam na secção 5.4 foi investigado se havia mais possibilidades de optimização. Da observação que se realizo concluiu-se que os tempos de execução estavam muito bem distribuído pelas várias funções. Tendo-se tentado optimizar algum código numérico concluiu-se que, embora localmente os
68
Realização
benefícios fossem visíveis, não se observavam resultados globais que compensassem o
esforço de optimizar o código disperso pelo programa.
4.4 Paralelização do simulador (aplicação FLASH)
Após a optimização da versão série do simulador, foi-nos apresentada uma versão paralela que executava sobre uma rede de computadores usando PVM. Apresenta-se aqui todas
as acções tomadas para acelerar a execução desta versão do simulador. O trabalho aqui
descrito foi apresentado na conferência “PARA2000 – Workshop on Applied Parallel
Computing” e publicado nos seus proceedings[Sil00].
Observou-se inicialmente que esta versão, em cada iteração, transmitia entre os vários nós
e o master uma série de vectores, estando parte substancial destes preenchida com zeros.
O nosso objectivo foi tentar reduzir a comunicação entre os vários nós de processamento.
Na primeira alteração proposta, decidiu-se alterar o modo de sincronização e a sua localização. Na versão original, a sincronização das tarefas realiza-se ao nível do código. Quando as tarefas atingem determinada linha de código, bloqueiam-se à espera que todas as
outras atinjam essa mesma posição, de modo a permitir a transmissão dos dados. Na nossa primeira proposta, a sincronização passou a estar associada aos acessos aos dados partilhados.
A divisão dos elementos processou-se do mesmo modo(ver secção 3.1.2), mas os nós que
anteriormente estavam replicados passaram a ser criados em memória partilhada e o seu
acesso passou a ser guardado por primitivas de sincronização.
Para a criação dos objectos em memória partilhada, redefiniu-se o método new de modo a
que , caso se desejasse, este método devolvesse um ponteiro para memória partilhada:
1
2
3
4
5
6
7
void * operator new (size_t sz, int shared){
if (shared && initialized){
return Tmk_malloc(sz);
}else{
return malloc(sz);
}
}
Os novos nós e vectores que devessem estar localizados em memória partilhada passaram
a ser criados invocando o new de um dos seguinte modos:
•
theNode =
•
theNode->NewVector(velc_t,new (1) ReaVec3)
new (1) Node(index, coords)
69
Optimização e Avaliação de Aplicações de Simulação Numérica
A manipulação dos dados dos nós deve ser realizada de modo sincronizado, evitando corridas sobre os dados do objecto. Para tal usam-se os locks fornecidos pela plataforma
TreadMarks:
1
2
3
Tmk_lock_acquire(node(iNodE)->Index()%CONST);
node(iNodE)->ConnectedElems++;
Tmk_lock_release(node(iNodE)->Index()%CONST);
De modo a não haver um único lock a guardar todos os dados, o que reduzia o
paralelismo, ou a exceder o número máximo de locks (caso se atribuísse um lock a cada
nó), decidiu-se distribuir os nós pelos lock tendo em atenção o seu índice.
Embora não houvesse transmissão de dados associada ao acesso, a transmissão de informação de controle da própria sincronização distribuída tornou esta aproximação impraticável. Os tempos de execução cresceram várias ordens de grandeza.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void Send_Rhs(){
if(This_Process != 0){ /* escravo – envio */
pvm_initsend( PvmDataDefault );
Pack_Array((rhs + 1), nrows, 1);
Send_Message(tids[0], SEND_IRHS);
}else /* mestre – recepcção */
for(int i=1 ; i<Number_Of_Processes ; i++ )
Recv_Rhs(tids[i]);
}
void Recv_Rhs(int tid){
Real* recv_s;
int ttsize = nrows;
Real* recv_s = new Real[nrows];
Recv_Message( tid, SEND_IRHS );
Unpack_Array( recv_s, nrows, 1 );
Real* up = recv_s + nrows;
register Real* s = recv_s ;
register Real* t = rhs + 1;
while(s < up)
(*t++) += (*s++);
delete recv_s;
}
Fig. 4.11 – Envio dos dados necessário à criação do sistema de equações
(comunicação entre processos usando PVM)
Tendo-se frustrado esta primeira tentativa, decidimos voltar a avaliar a versão paralela
original e tentar reduzir o tempo de transmissão dos dados entre tarefas. Para tal
observou-se de que modo as várias tarefas interagiam e onde e como transmitiam os
70
Realização
dados entre elas.
Em cada iteração (período de tempo) da simulação são transmitidos, para um computador central, os dados manipulados pelas várias tarefas. É então executado código não paralelizado e retornado para todas as tarefas o resultado da computação. O código de
comunicação dos dados está apresentado na Fig. 4.11.
Como os vectores transmitidos não são preenchidos completamente por todas as tarefas
(há elemento privados a cada tarefa), esta solução, implementada em PVM, tem o inconveniente de, em cada chamada da função Send_Message, ser enviado todo o vector, e
não só os dados actualizados pela tarefa e necessários à tarefa receptora.
Com o uso de memória partilhada e distribuída esperámos reduzir os dados transmitidos.
Como os algoritmos de coerência dos sistemas DSM, para reduzir a comunicação, apenas
transmitem os dados realmente alterados e acedidos por outra tarefa, o uso desta
plataforma para comunicação entre as tarefas pareceu-nos uma boa aposta.
Portanto, o código de comunicação entre processos original foi substituído pelo código
apresentado na Fig. 4.12.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void Send_Rhs(){
int i;
if(Tmk_proc_id != 0){ /* escravo – envio */
Tmk_barrier(0);
/* sicronização 1 */
for (i = 1; i<= nrows; i++)
rhs_shr[Tmk_proc_id][i] = rhs[i];
Tmk_barrier(0);
/* sicronização 2 */
}else{
/* mestre – recepcção */
if (rhs_shr[0] == NULL){
rhs_shr[0] = (Real *) 1;
for(i=1 ; i<Tmk_nprocs ; i++ ){
rhs_shr[i] =
(Real *)Tmk_malloc(
(nrows+1)* sizeof(Real));
Tmk_distribute((char *) &rhs_shr[i],
sizeof (Real *));
}
}
Tmk_barrier(0); /* sicronização 1 */
Tmk_barrier(0); /* sicronização 2 */
for(i=1 ; i<Tmk_nprocs ; i++ )
Recv_Rhs(i);
}
}
void Conj_Grad::Recv_Rhs(int tid){
71
Optimização e Avaliação de Aplicações de Simulação Numérica
27
28
29
30
31
32
Real* up = rhs_shr[tid] + nrows +1;
register Real* s = rhs_shr[tid] +1;
register Real* t = rhs + 1;
while(s < up)
(*t++) += (*s++);
}
Fig. 4.12 – Envio dos dados necessário à criação do sistema de equações
(comunicação entre processos usando DSM)
Observa-se que a comunicação explícita foi substituída pela transferência dos dados para
um vector criado em memória partilhada (escrita apresentada na linha 6 e leitura na linha
31, dentro da função Recv_Rhs).
A instrução de sincronização seguinte (linha 7 do lado dos escravos e linha 21 no mestre)
garante que todas as tarefas são informadas da ocorrência de alterações na memória partilhada. Devido ao sistema usado (TreadMarks) e ao seu modelo de coerência de dados,
nesse instante não são transmitidos os dados escritos para memória partilhada, apenas é
enviada informação acerca da memória alterada. A transferência dos dados só será
realizada quando o processo mestre ler os valores presentes no vector. Também devido às
características do modelo de consistência, só os dados alterados serão transmitidos. As
partes do vector que têm sempre o mesmo valor (zeros) apenas são transmitidas entre
processos uma única vez. Em todos os outros acessos subsequentes, não há necessidade
de comunicação, devido ao facto dessa parte do vector não ter sido alterada.
A comunicação dos resultados da computação realiza-se de modo semelhante (ver
Fig. 4.13). O servidor escreve os valores num vector partilhado e cada tarefa acede aos
dados pretendidos.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
72
void Read_Second_Solution(){
Node* NodePrt;
int NNode = get_number_of_real_nodes();
Tmk_barrier(0);
if(Tmk_proc_id == 0){ /* mestre – escrita */
if (press_ss_shr == NULL){
press_ss_shr =
(Real *) Tmk_malloc(NNode * sizeof(Real));
Tmk_distribute((char *) &press_ss_shr,
sizeof (Real *));
}
Real* real_pointer = press_ss_shr;
int idx;
Realização
17
18
19
20
21
22
23
24
25
26
27
28
29
30
LOOP_NODES
*real_pointer++ = PRESDOF->x(t);
Tmk_barrier(0); /* sincronização 1 */
}else{
Tmk_barrier(0); /* sincronização 1 */
Real* real_pointer = press_ss_shr;
int idx;
LOOP_NODES
PRESDOF->x(t) = *real_pointer++;
}
}
Fig. 4.13 – Envio da solução do sistema de equações
(comunicação entre processos usando DSM)
Como os nós estão replicados em todas as tarefas, não se obtém ganho algum com o uso
de DSM. Neste caso todas as tarefas necessitam de consultar o vector partilhado. Se os nós
estivessem distribuídos pelas várias tarefas, já não era necessário o envio de todo vector
para todas as tarefas.
4.4.1 Servidor para execução de tarefas remotas (TreadMarks)
O sistema TreadMarks usa, para lançamento das tarefas remotas, o serviço rexec (remote
execute). O uso deste método de invocação remota tem alguns problemas, quando
implementado sobre uma plataforma Windows:
•
difícil de configurar e usar;
•
falta de uma implementação gratuita do servidor de execução remota;
•
modo pouco seguro de invocação remota de comandos.
Devido aos três motivos atrás apresentados, decidimos implementar um pequeno servidor que executa os comandos remotamente invocados, substituindo-se assim o rexec.
Este servidor foi implementado usando o serviço RPC. Não só se conseguiu desenvolver
facilmente o servidor, como também se passa a usufruir da autenticação e segurança
fornecida pelo serviço de RPC do Windows.
O serviço de RPC do Windows fornece um modo de autenticação dos clientes remotos
integrado com a autenticação e identificação dos utilizadores do Windows. Assim, não há
necessidade de, aquando da invocação remota, efectuar a autenticação explícita do utili73
Optimização e Avaliação de Aplicações de Simulação Numérica
zador. Após a autenticação inicial, os processos criados no servidor executam com a identidade do utilizador-cliente. Estes processos só podem realizar as operações permitidas ao
utilizador, reduzindo-se assim a exploração maligna da execução remota. Associada à
autenticação, está a possibilidade de executar a comunicação sobre um canal cifrado.
Para permitir o uso pelos vários programas-clientes, desenvolveu-se também uma biblioteca, que exporta uma rotina que executa a invocação remota.
4.5 Avaliação de desempenho das versões série
Para a realização da avaliação das aplicações e optimizações realizadas e cujos resultados
apresentamos no capítulo 5, usámos várias técnicas distintas:
•
Instrumentação do código
•
Avaliação não intrusiva
A versão original do simulador contém uma classe de medição do tempo de blocos de
código. A inicialização dos temporizadores, o início da contagem e fim são programados
explicitamente no código. A contabilização realizada já permite a identificação das áreas
problemáticas, mas não permite identificar correctamente quais as funções que devem ser
optimizadas. Este método só permite medições com grande granularidade, superior à da
função.
Após esta primeira fase, houve necessidade de observar com maior exactidão quais as
funções e, no seu interior, que código executa com menor eficiência. Para tal, usou-se a
aplicação VTUNE da Intel. Esta aplicação, tal como descrito na secção 2.5.5.3 permite a
identificação do código que executa durante mais tempo e onde ocorrem os eventos
indicativos de quebras de desempenho.
Para a medição precisa do tempo executado e dos ganhos obtidos com as optimizações,
introduzimos no código chamadas à função de leitura dos contador de alta precisão
fornecido pelo sistema operativo (secção 2.5.1.2).
4.5.1 Driver para leitura de contadores
Para identificação mais precisa dos eventos que atrasam a execução da aplicação foi
necessária a leitura dos contadores do processador (ver secção 2.5.2). Como as instruções
de configuração e leitura dos contadores do processador são privilegiadas, é necessária a
execução do código em modo núcleo. O driver desenvolvido exporta duas funções, uma
para configuração do contador, indicando-se qual o evento a contabilizar e outra para
74
Realização
leitura do valor armazenado.
O código de programação de um dos contadores existentes no processador está apresentado na Fig. 4.14. A instrução wrmsr serve para escrever nos registos específicos do processador, o índice do registo está armazenado em ECX e o valor a escrever está em EDX:EAX
(valor de 64 bits).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void inic_c_event1(int n_event, int umask){
__int32 PerfEvtSel1 = 0;
PerfEvtSel1 = PerfEvtSel1 | n_event | (umask * 256)|
_USER_MODE| _EDGE_DECT| _ENABLE;
__asm{
xor edx, edx;
xor eax, eax;
mov ecx, 0xC2;
wrmsr ; inicalizacao do contador a 0
mov edx, DWORD PTR PerfEvtSel1;
mov eax, DWORD PTR PerfEvtSel1;
mov ecx, 0x187;
wrmsr ; programacao do contador
}
}
Fig. 4.14 – Inicialização de um contador do processador Pentium
Na primeira escrita, efectuada sobre o registo PerfCtr1 (contador), inicializa-se o valor
do contador a zero. Sabe-se então que, nas próximas contagens, o valor armazenado no
contador é baixo, reduzindo a probabilidade de overflow do contador.
Na segunda escrita, indica-se que evento será contado e quais as características da contagem, escrevendo no registo PerfEvtSel1 (0x187). Na programação deste registo indicam-se as características da contagem a realizar e cujo valor irá ser armazenado no registo
PerfCtr1:
•
Identificação do evento a contabilizar (n_event e umask)
•
Indicação de que a contagem só se realiza quando ocorre em modo utilizador
•
Detecção das transições dos eventos
Também se indica que a contagem se deve iniciar imediatamente.
A leitura do registo PerfCtr1 (Fig. 4.15) também se deve realizar em assembly. Para
75
Optimização e Avaliação de Aplicações de Simulação Numérica
garantir que, durante a execução da leitura, todas as outras instruções anteriores se
executaram completamente, invoca-se a instrução cpuid.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
__int64 get_c_event(int contador){
__int32 CTR0_L= 0;
__int32 CTR0_H= 0;
__int64 CTR0;
__asm{
xor EAX, EAX;
cpuid;
mov ecx, DWORD PTR contador;
rdpmc;
mov CTR0_L, EAX;
mov CTR0_H, EDX;
}
CTR0 = CTR0_H<<32;
CTR0 += CTR0_L;
return CTR0;
}
Fig. 4.15 – Leitura de um contador do processador Pentium
A instrução rdpmc lê o valor do contador de desempenho armazenado em contador (0
ou 1) e armazena o resultado da leitura nos registos EDX:EAX, que são retornados agrupados num valor de 64 bits.
Para aceder a estas duas funções, houve necessidade de gerar todo o código de controlo e
inicialização do driver, assim como para aceder às funcionalidades exportadas.
4.6 Avaliação de desempenho das versões paralelas
Para avaliar os ganhos obtidos com as várias versões paralelas do simulador, para além
da medição do tempo de execução, houve necessidade de efectuar medições da quantidade de dados transmitidos entre os vários nós. Os tempos de execução forma medidos
usando as técnicas atrás descritas (secção 4.5).
Para efectuar a contabilização dos dados transmitidos na versão paralela, implementadas
com PVM e TreadMarks, usaram-se os contadores do sistema operativo Windows (ver
secção 0) que armazenam a quantidade de dados transmitidos por uma placa de rede.
Para facilitar a leitura dos valores desenvolveram-se duas funções que escondem os de76
Realização
talhes de inicialização dos contadores (Fig. 4.16) e sua leitura (Fig. 4.17).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
int inic_count_bytes(HCOUNTER *hCounter1,
HCOUNTER *hCounter2){
PDH_STATUS res;
res = PdhOpenQuery( /* criacao de query */
NULL,
10,
&hQuery);
if (res != ERROR_SUCCESS)
return -1;
res = PdhAddCounter( /* adicao de contador */
hQuery,
"\\Network Interface(NDIS 5.0 driver)\\Bytes
Received/sec",
0,
hCounter1);
if (res != ERROR_SUCCESS)
return -1;
res = PdhAddCounter( /* adicao de contador */
hQuery,
"\\Network Interface(NDIS 5.0 driver)\\Bytes
Sent/sec",
0,
hCounter2);
if (res != ERROR_SUCCESS)
return -1;
}
Fig. 4.16 – Programação necessária à leitura dos contadores do Windows
A instrução inicial (PdhOpenQuery) cria uma estrutura de dados, a partir da qual se acedem aos dados pretendidos. É ao handle hQuery que se irão associar os contadores relevantes e que mais tarde será utilizado para ler os valores pretendidos. Invoca-se de
seguida a instrução PdhAddCounter. É deste modo que se associam os contadores à
query criada anteriormente; também assim são criadas as referências para os contadores
que serão lidos e formatados mais tarde. O primeiro contador indicado representa os
número de bytes recebidos pela interface de rede, enquanto que o segundo representa os
enviados.
A leitura dos valores armazenados nestes contadores efectua-se invocando a seguinte
função:
77
Optimização e Avaliação de Aplicações de Simulação Numérica
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
int print_bytes(){
PDH_RAW_COUNTER Value2;
PDH_RAW_COUNTER Value1;
PDH_STATUS res;
res = PdhCollectQueryData(hQuery); //recolha dos valores
if (res != ERROR_SUCCESS)
return -1;
res = PdhGetRawCounterValue( // leitura do contador 1
hCounter1,
NULL,
&Value1);
if (res != ERROR_SUCCESS)
return -1;
res = PdhGetRawCounterValue( // leitura do contador 2
hCounter2,
NULL,
&Value2);
if (res != ERROR_SUCCESS)
return -1;
printf( "-received %I64d -sent %I64d\n",
Value1.FirstValue,
Value2.FirstValue);
}
Fig. 4.17 – Leitura de um valor contabilizado num contador do Windows
A invocação desta função desencadeia duas acções distintas. A recolha dos dados é realizada quando se executa a função PdhCollectQuerydata. Neste instante são lidos os
valores correspondentes aos contadores associados à query. Para aceder aos dados lidos é
necessário executar a função PdhGetRawCounterValue para cada um dos contadores
associados à query. Embora os contadores representem a taxa de transferência de dados e
não a quantidade de dados transmitidos ou recebidos, é possível obter estes últimos valores. Como apenas lemos os valores brutos (Raw) destes contadores, estes apenas representam a quantidade de dados. Se desejássemos obter as taxas seria necessário efectuar uma
leitura formatada, que automaticamente devolve as taxas de recepção e envio.
4.7 Resumo
Apresentámos neste capítulo as várias optimizações realizadas à aplicação de simulação
do sistema Flash.
78
Realização
A optimização do código começou com a alteração do código de resolução de sistemas de
equações, visto ter sido este o primeiro código apresentado pelo parceiro responsável pela
geração do código do simulador. Este código consistia essencialmente em manipulações
matemática de dados armazenados em vectores. Por esse motivo as optimizações aplicadas foram todas elas dependentes do processador onde o código iria executar. Também se
paralelizou este código de modo a ser executado em multiprocessadores de memória partilhada e numa rede de computadores.
Após este primeiro esforço, seguiu-se a optimização da aplicação de simulação. Aqui, apenas se conseguiu aplicar com sucesso optimizações não dependentes do processador.
Mais tarde, também se optimizou a versão paralela da aplicação de simulação que executava numa rede de computadores. Para reduzir o tempo despendido na comunicação dos
dados, decidiu-se substituir a biblioteca de comunicação PVM pelo sistema memória partilhada e distribuída TreadMarks.
79
5 Avaliação
Após a aplicação das várias técnicas de optimização atrás apresentadas, é necessário verificar se as alterações produziram os resultados esperados. Neste capítulo são apresentados e comentados os vários valores medidos durante as várias versões seriais e paralelas,
optimizadas e não optimizadas. Os valores apresentados incluem os tempos de execução
e também outros indicadores do desempenho das aplicações relevantes.
No início deste capítulo descrevemos o ambiente de teste usado na avaliação do código
sendo os resultados obtidos apresentados de seguida. A ordem pela qual os resultados
são apresentados é a mesma pela qual as optimizações foram descritas no capítulo anterior. Aparecem primeiro os resultados da optimização do código de resolução de sistemas
de equações, aparecendo no fim os resultados da optimização da aplicação de simulação.
5.1 Ambiente de teste
Para a avaliação das optimizações usámos três ambientes de teste distintos, correspondentes às arquitecturas da plataforma de execução dos protótipos. As características dos computadores usados neste teste apresentam-se na Tab. 5.1.
O computador pessoal com dois processadores Pentium foi usado na avaliação da paralelização do solver, enquanto que nos outros testes usaram-se computadores Pentium II. Na
execução da versão paralela do simulador (sobre PVM e TreadMarks) usaram-se quatro
computadores PC Pentium II ligado por uma rede ethernet a 100 Mbit/s.
As versões de teste foram compiladas usando o compilador de C/C++ da Microsoft. Das
81
Optimização e Avaliação de Aplicações de Simulação Numérica
flags usadas, as mais significativas são as apresentadas na Tab. 5.1. Os programas gerados
não contêm código de debug e o código gerado contém as optimizações necessárias e conhecidas pelo compilador para aumentar a velocidade de execução:
•
Eliminação de sub-expressões locais e globais;
•
Alocação automática de registos;
•
Optimização de ciclos (Eliminação de sub-expressões internas ao ciclo);
•
Substituição de funções pelo código correspondente (abs, memcpy, strcmp, …);
•
Geração de código mais rápido (invocação de mais instruções assembly, mas com
menor custo de execução).
PC Dual Pentium
PC Pentium II
2x Intel Pentium
Intel Pentium II
@100MHz
@233MHz
Memória Primária
64 Mb
64 Mb
Cache L1 (Dados)
8kb
16Kb – 32b/linha
Processador
4-way set-associative
write-back
Cache L1 (Código)
8kb
16Kb
4-way set-associative
Cache L2
Interface de rede
512 Kb
ethernet 100Base-T
—
Sistema Operativo
Windows NT 4
Compilador
Microsoft 32-bit C/C++ Optimizing Compiler
Versão 12.00.8168 for 80x86
Flags: /MT /O2 /D "NDEBUG"
Tab. 5.1 – Características dos computadores usados nos testes de desempenho
5.2 Optimização do Solver (Conjugate gradient)
Das optimizações realizadas à versão série do solver apresentados na secção 4.1 obtiveram-se os resultados que agora se apresentam. Primeiro apresenta-se uma análise estática
do código, mostrando o número de instruções, a duração de execução destas e o número
de ciclos perdidos devido a penalidades que ocorrem durante a execução.
82
Avaliação
Os testes, cujos resultados aqui apresentamos, foram realizados com um sistema de
equações cuja uma matriz quadrada tem 15099 linhas e 112209 elementos não nulos. Este
sistema é resolvido em 175 iterações. Para a realização dos testes usou-se um computador
Pentium II com as características apresentadas na Tab. 5.1.
Análise estática do código
Apresenta-se na próxima tabela uma contabilização do número de instruções e seu tempo
de execução para cada bloco do solver: i) ciclos onde se efectuam multiplicações de
vectores por escalares, ii) função atimes e iii) restante código. Estes valores foram
calculados directamente da análise do código, indicando o número de instruções (e tempo
de execução) de cada iteração. Nas colunas marcadas a cinzento são apresentados os
valores correspondentes à versão optimizada. A coluna Execuções indica o número de
vezes que esse código é executado:
•
iter – número total de iterações do solver
•
linhas – número de linhas da matriz
•
elem – número de elementos não nulos existentes na matriz
Execuções
Instruções
iter*linhas
37
Paralelismo
Ciclos de
relógio
Penalidades
Solver
Ciclos
31
51
67
20
37
If (unroll)
0/iter
Outro cód.
iter
74
36
180
191
115
126
iter
39
36
25
24
0
1
For
iter*linhas
16
10
25%
60%
24
10
7
3
While
iter*elem
7
13
31%
61%
12
10
4
1
26
50
70
Atimes
Control
Tab. 5.2 – Análise estática do solver
Estes valores foram obtidos através da contagem directa de instruções de cada bloco, tendo sido a usada a aplicação VTUNE para contabilizar os ciclos de relógio e as penalidades
de cada instrução. A contabilização das penalidades não é precisa, visto serem contabili83
Optimização e Avaliação de Aplicações de Simulação Numérica
zadas apenas as penalidades geradas numa execução sequencial do código. As penalidades que ocorrem devido a mudanças do fluxo de execução (ifs e ciclos) não foram contabilizadas. Devido às capacidade de predição dos saltos (ver secção 2.2.2.1) não é possível
saber antes da execução quantos ciclos de relógio serão perdidos.
No caso dos ciclos onde se aplicou/removeu o loop unrolling, a contagem presente na Tab.
5.2 representa o número de instruções e o custo do processamento de um elemento (ou
linha). Os cálculos executados em ambos os casos (existência ou não de loop unrolling)
mantêm-se constantes, sendo o custo de cada cálculo obtido directamente ou ponderado
pelo número de execuções agrupadas com o unrolling.
Com o loop unrolling (aplicado aos ciclos da função Solve) agruparam-se num mesmo
iteração vários cálculos idênticos e consegue-se reduzir o número de instruções por cada
elemento. Observou-se um aumento dos ciclos de relógio e de penalidades devido ao
facto de, num mesmo bloco e na mesma iteração do ciclo, ocorrerem duas penalidades
que agora são contabilizadas na avaliação estática.
Já no caso da função atimes, observa-se uma redução de todos os valores. Embora o número de penalidades seja inferior na versão optimizada, a relação deste valor entre ambas
as versões não é a que se apresenta (1 para 4 e 3 para 7). É expectável que na versão optimizada o número de penalidades reais seja superior, visto haver dependências entre as
várias iterações dos ciclos, mas nunca duplicando de valor. O encadeamento das instruções da função atimes, não só levou à redução das penalidades, como também melhorou
o paralelismo da execução das instruções: consegui-se aumentar o número de instruções
que iniciam simultaneamente a sua execução (de 25% para 60%, por exemplo na função
atimes). Com a nova distribuição das instruções, é possível ao processador executar mais
instruções em paralelo.
Número total de acessos à memória
Os valores apresentados na próxima tabela representam o número de acessos à memória
realizados durante a execução da função Solve. Estes resultados foram obtidos usando o
driver descrito na secção 4.5.1. Durante a execução da função foi lido o contador 0x43 que
representa o número total de acessos à memória realizados. São contabilizados todos os
acessos à memória quer resultem num cache hit (L1 ou L2) ou numa transferência da memória para processador. Os valores apresentados maximizam o número real de acessos,
visto este contador contabilizar todos os acessos realizados no computador e não só os
acessos da aplicação testada.
84
Avaliação
Acessos à memória (total)
Execuções
Não optimizado
Optimizado
Redução
1
222 566 499
165 559 709
25.6%
Inicialização
1
1 059 918
858 790
19.0%
ciclo while
1
221 506 581
164 700 919
25.6%
Atimes
175
155 254 352
96 499 324
37.8%
Outro Cód.
175
66 252 229
68 201 595
-2.9%
Solve
Tab. 5.3 – Número total de acessos à memória numa execução típica do solver
Observa-se que a grande redução do número de acessos à memória se verifica na função
atimes. Em todo o outro código, onde se aplicou maioritariamente o loop unrolling, o número de acessos manteve-se aproximadamente constante.
A redução do número de acessos na função atimes advém da substituição das instruções
*bi+=*sak++ *x[*ijai++] por daux2= sa[kaux]*x[ij_aux];daux += daux2,
com a actualização de *bi no fim (b[i] = daux), tal como foi descrito na Fig. 4.3. Com
este código é possível aos compilador optimizar o código de modo a utilizar mais eficientemente os registos.
Misses à Cache de dados
Apresenta-se aqui o número de misses à cache de dados de nível 1. Também estes resultados foram obtidos usando o driver apresentado na secção 4.5.1. Os valores resultam da leitura do contador 0x45, número total de linhas lidas para a cache de dados resultantes de
misses.
Como se observa facilmente, foi a agregação de vários ciclos num só, realizada ao longo
do código da função solve que resultou em maiores reduções do número de misses da
cache. Reduziu-se em mais de 1/3 o número de misses, conseguindo-se deste modo a
redução do tempo de espera pelas leituras e escritas dos dados para a memória.
Também se observa que as alterações à função times, não resultaram em nenhum decréscimo no número de misses.
85
Optimização e Avaliação de Aplicações de Simulação Numérica
Cache Misses (total)
Execuções
Solve
Não optimizado
Optimizado
Redução
1
9 173 579
8 159 458
11.0%
Inicialização
1
42 804
36 889
13.8%
Ciclo while
1
9 130 775
8 122 569
11.0%
Atimes
175
5 455 105
5 704 616
-4.5%
Outro Cód.
175
3 675 670
2 417 953
34.2%
Tab. 5.4 – Número total de cache misses no acesso à memória numa execução
típica do solver
Tempos de execução
Podemos agora apresentar os tempos de execução do código optimizado, observando e
relacionando os vários valores atrás descritos.
Da observação dos dados anteriormente apresentados podem-se observar dois efeitos da
alteração do código: redução do número de acessos à memória e redução do número de
misses, que podemos agora comparar com os tempos de execução dos trechos de código
correspondentes (Tab. 5.5).
Tempos de execução (segundos)
Execuções
Solve
Não optimizado
Optimizado
Speedup
1
2.713 283
2.178 327
1.25
Inicialização
1
0.013 989
0.012 315
1.14
ciclo while
1
2.699 294
2.166 012
1.25
Atimes
175
2.032 845
1.592 426
1.28
Outro Cód.
175
0.666 449
0.573 586
1.16
Tab. 5.5 – Tempos de execução da versões serial do solver numa execução
típica do solver
As alterações à função atimes resultaram numa redução do número de acessos à memó86
Avaliação
ria, mas com um número de misses constante . Desta observação pode-se concluir somente
que a redução do tempo de execução não advêm dos acessos à memória, visto o acesso a
*bi (ver linhas 8 a 15 da Fig. 4.2) resultarem maioritariamente em cache hits.
Se observarmos com atenção a análise estática desta função (Tab. 5.2), conclui-se que o código desta função já executa com maior rapidez. Esta rapidez é causada pela intercalação
de instruções que possibilita o uso do paralelismo do processador. Também o uso de instruções que manipulam apenas registos, torna a descodificação e execução do código mais
eficiente.
No resto do código, onde se aplicou loop unrolling, observam-se também ganhos no tempo
de execução. Nestes casos o número de acessos à memória são constantes mas o número
cache hits aumentou na versão optimizada, como se pode observar nas Tab. 5.3 e Tab. 5.4.
Estes ganhos são directamente resultantes da redução de misses e consequente maior
rapidez nos acessos aos dados.
5.3 Paralelização do Solver (conjugate gradient)
Na secção 4.2 descreveram-se as paralelizações realizadas ao Solver (threads e Memória
Partilhada e Distribuída). Para testar estas versões paralelas usou-se o computador multiprocessador descrito na Tab. 5.1, para a versão com threads, e dois computadores
Pentium II, também apresentados na Tab. 5.1, ligados por uma rede ethernet a 100Mbit/s.
No caso da paralelização, usando-se threads e memória partilhada num computador
multiprocessador, o speedup expectável quando se usam dois processadores está próximo
de 2. Como se observa na Tab. 5.6.a, tal não acontece, embora se obtenham alguns ganhos
com a paralelização. Esta diferença advém de vários factores, tais como o uso de uma
instrução de sincronização complexa, a existência de código executado num só
processador e a possível distribuição não uniforme dos dados.
Processadores
Tempo (s)
Speedup
Processadores
Tempo (s)
Speedup
1
3.97
—
1
2.18
—
2
2.74
1.44
2
71.43
0.03
a)
b)
Tab. 5.6 – Resultados da paralelização do solver usando a) threads e b) TreadMarks
A instrução de sincronização usada, a barreira, não existe nas bibliotecas de threads, tendo
87
Optimização e Avaliação de Aplicações de Simulação Numérica
sido necessária a sua implementação. Como a matriz é esparsa, não é garantido que todas
as linhas tenham exactamente o mesmo número de elementos. Este facto leva a que duma
divisão equitativa do número de linhas não se obtenha uma distribuição do esforço igual
entre tarefas.
Finalmente, para justificar o speedup obtido, podemos observar que, por exemplo, o cálculo final dum produto interno (ver Fig. 4.5) é executado por todas as tarefas. Este facto leva
a que haja código que não é executado em paralelo, que não aproveita a existência de vários processadores e que reduz o speedup obtido.
Os resultados da paralelização usando TreadMarks não são os desejados (ver Tab. 5.6.b).
O código paralelizado não se executa mais rapidamente que o código série. A grande causa é o custo da comunicação e transferência de dados. A sincronização entre as várias tarefas obriga à comunicação entre elas que, quando executada sobre uma rede local, é muito
mais lenta do que usando memória partilhada. A comunicação de dados provocada, por
exemplo, pelos produtos internos (Fig. 4.5) e pela multiplicação de matrizes (Fig. 4.7) é outra causa para o mau desempenho desta versão. Na versão com memória partilhada ao
acesso a dados manipulados por outras tarefas não incorrem custos. Já com necessidade
de transmitir os dados entre computadores, este tipo de acessos é prejudicial ao desempenho.
5.4 Optimização do simulador (aplicação FLASH)
A optimização realizada ao simulador, e cujos resultados aqui apresentamos, consistiu
apenas na alteração do algoritmo de obtenção dos dados necessários à construção das matrizes esparsas, descrita na secção 1.
São apresentados na tabela Tab. 5.7 os tempos medido na execução da aplicação de
simulação sobre um modelo uma embarcação do tipo Series 60[Ste96], representada por
15000 nós e 85716 elementos. Esta simulação executa-se durante 10000 iterações, igual ao
número de matrizes esparsas construídas. Esta quantidade de nós e elementos e a duração
da simulação são representativos das simulações usualmente realizadas.
As alterações ao código realizadas trouxeram ganhos locais bastante elevados, a construção de uma matriz demorava originalmente 5 segundos, passando a ser executada em
cerca de 0.1 segundos. O speedup local obtido é de 55.6, sendo o speedup global de 12.2.
Este valor, embora inferior a 55.6, é significativo e bastante bom.
Na versão final da aplicação de simulação o código por nós proposto para a construção
das matrizes foi substituído pelo parceiro responsável, mas a ideia da eliminação das ins88
Avaliação
truções redundantes manteve-se e que. sem a optimização inicialmente proposta, a versão
final executaria muita mais ineficientemente.
Tempos de execução (segundos)
Não optimizado
Optimizado
Speedup
Total
58140
4764
12.2
Construção da matriz
54350
974
55.8
Outro Código
3790
3790
1
Tab. 5.7 – Tempos de execução da aplicação de simulação
5.5 Paralelização do simulador (aplicação FLASH)
Efectuaram-se duas tentativas de paralelização do simulador usando TreadMarks, tal
como descrito na secção 4.4. Numa das tentativas não se obtiveram resultados positivos.
A sincronização e comunicação de dados entre computadores aumentou substancialmente o tempo de execução da versão paralela.
Apresentam-se de seguida os resultados da segunda aproximação, comentando-os e comparando-os com os valores obtidos com a versão que usa PVM.
Os resultados seguintes foram obtidos executando a simulação do modelo de uma embarcação do tipo Series 60[Ste96], representado por 16000 elementos. O fluido circundante é
representado por 57000 elementos, sendo necessário um total de 15000 nós. O ambiente de
execução é composto por computadores Pentium II, tal como descrito na Tab. 5.1, ligados
por uma rede ethernet a 100Mbit/s.
Tempos de execução
Na Fig. 5.1 é apresentado um gráfico com a progressão do tempo de execução do simulador, em função do número de computadores usados nos cálculos. São distinguidos os vários tipos de código existentes:
•
Código não paralelizado
•
Código paralelizado
•
Comunicação
89
Optimização e Avaliação de Aplicações de Simulação Numérica
Comunicação
50
00
Paralelizado
00
30
0
10
0
0
20
0
0
Tempo (s)
40
00
Não Paralelizado
1
2
3
4
Processadores
Fig. 5.1 – Tempos de execução da versão paralela usando TreadMarks
A soma do tempo de execução do código paralelizado e da comunicação decresce com
dois e três processadores, aumentando quando se passa a usar um quarto computador.
Este facto deve-se à redução do tempo obtido com a paralelização não ser suficiente para
compensar o aumento de comunicação, quando já se tem quatro computadores.
Devido ao modo como os sistemas de DSM decidem quando os dados são transmitidos
entre os vários computadores (ver 2.3.2.3 – Distributed Shared Memory) os tempos apresentados no gráfico anterior para a comunicação estão aquém dos reais. Como parte da comunicação se realiza quando se acede aos dados partilhados, há casos em que é durante a
actualização dos dados que é realizada a comunicação, sendo esse tempo contabilizado no
código paralelizado.
Como é obvio, o tempo e execução do código não paralelizado mantêm-se constante ao
longo das medições, o que nos permite calcular com alguma precisão o speedup máximo
esperado.
Na Tab. 5.8 são apresentados os speedups esperados (speedup Ideal Corrigido) para uma
paralelização o em que, tal como o nosso simulador, cerca de 77% do código total foi
paralelizado. O Speedup Ideal Corrigido foi calculado pela expressão apresentada na secção 2.4:
speedup =
90
t_serie + t_para
t para
t_serie +
n
Avaliação
Processadores
1
2
3
4
Ideal
1
2
3
4
Ideal Corrigido
1
1.63
2.07
2.38
Real
1
1.36
1.72
1.68
Tab. 5.8 – Speedups expectáveis e obtidos com a versão paralela usando TreadMarks
Observa-se que, embora distante do Speedup Ideal, os ganhos aproximam-se daqueles esperados, tendo em consideração o facto de não se paralelizar todo o código. Também aqui
se observa que a comunicação tem um peso importante no tempo despendido, aumentando com o número de computadores participantes.
Comparação com versão PVM
Como a versão paralela final evoluiu directamente da versão que usa PVM, é interessante
efectuar uma comparação entre ambas e avaliar os ganhos que advêm do uso de DSM.
Na tabela seguinte apresentam-se os tempos de execução das versões paralelas, a original
usando PVM e a final com TreadMarks.
Tempos de execução
Speedup
(segundos)
Processadores
PVM
1
DSM
4764
PVM
DSM
1
1
2
3655
3501
1.30
1.36
3
4126
2756
1.15
1.72
4
—
2821
—
1.68
Tab. 5.9 – Comparação dos tempos de execução das versões paralelas
usando PVM e TreadMarks (DSM)
Observa-se que, na versão original, a partir de três computadores já não havia ganhos no
tempo de execução, e que esse limite passou, com o uso do TreadMarks, para quatro
máquinas.
91
Optimização e Avaliação de Aplicações de Simulação Numérica
Também em termos de desempenho total se observam benefícios na nova versão. Enquanto que com o uso de PVM apenas se obtinha um speedup de 1.30, com o uso do
TreadMarks passou-se a obter 1.72, usando três computadores nos cálculos. O gráfico
apresentado na Fig. 5.2 ajudar-nos-á a perceber a razão da diferença entre versões.
10
00
80
0
0
20
0
40
0
60
0
Mbyte
12
00
14
00
16
00
PVM
ThreadMarks
2
3
4
Processadores
Fig. 5.2 – Dados transmitidos e enviados pelo nó mestre nas versões paralelas usando
PVM e TreadMarks
Observando o gráfico da Fig. 5.2, podemos descobrir as causas da grande discrepância entre os tempos de execução das duas versões. Neste gráfico são apresentados as quantidades de dados transmitidos durante a execução. Estes valores foram obtidos usando os
contadores do sistema operativo que representam os dados transmitidos e recebidos
usando o protocolo TCP/IP.
Observa-se perfeitamente que a solução implementada usando TreadMarks usa mais eficientemente a rede, conseguindo reduzir os dados transmitidos entre computadores.
Esta grande diferença na quantidade de dados transmitidos advêm do facto de, na versão
original e em cada iteração, serem sempre transmitidos os mesmo dados para todos os
nós, independentemente de serem ou não relevantes para os cálculos realizados localmente. Com o uso de memória partilhada e distribuída, a comunicação dos dados só é realizada quando os vectores partilhados são realmente acedidos e estes foram alterados. Consegue-se assim, de modo transparente para o programador, a redução da comunicação, embora com custo adicional ao nível do processamento, devido à necessidade de verificar os
acessos e alterações dos dados.
92
6 Conclusões
Podemos começar por apresentar um resumo dos ganhos de desempenhos que obtivemos. Na optimização do solver, constituído por código C++ mas sem instruções de
manipulação de estruturas dinâmicas, os ganhos obtidos foram substanciais. O speedup
obtido foi de cerca de 1.25. As optimizações aplicadas a este código foram todas
dependentes do processador usado: optimização dos acessos à cache, uso dos registos para
manipulação de variáveis e melhoria do encadeamento das instruções. Tendo tentado
aplicar este mesmo tipo de optimizações ao código do simulador (código C++ com
manipulação intensiva de estruturas dinâmicas) não se obtiveram resultados que
justificassem esse esforço. As instruções aritméticas e optimizáveis estão dispersas pelo
código e grande parte do tempo de execução é perdido com a manipulação das listas
dinâmicas de suporte aos dados.
Embora não se tenha conseguido aplicar optimizações dependentes do processador, o
código do simulador sofreu alterações que influenciaram profundamente os tempos de
execução. O modo de construção das matrizes esparsas original era, embora correcto e
facilmente compreensível, muito ineficiente. O número de operações realizadas era O(n2),
tendo sido reduzido para O(n). Enquanto que na versão original este código dominava
completamente o tempo de execução, na versão final é desprezável.
Também se avaliaram várias possibilidades de execução num ambiente paralelo. A versão
do solver foi paralelizada e executada num multiprocessador de memória partilhada e
num cluster de computadores. Na primeira instalação os ganhos foram próximos dos
93
Optimização e Avaliação de Aplicações de Simulação Numérica
óptimos, enquanto que com os vários computadores ligados por uma rede ethernet os
tempos de execução aumentaram drasticamente.
A versão paralela original do simulador executava sobre PVM, mas a partir de 3
computadores já não se obtinha ganhos. Os tempos de transmissão dos dados superava os
ganhos obtidos com a execução paralela de código. Com o uso de uma tecnologia de
comunicação diferente (DSM) conseguimos reduzir os dados transmitidos, aumentando o
número máximo de máquinas utilizáveis e os ganhos totais.
Perante estes resultados a primeira conclusão a tirar é que, se o desempenho é um dos factores principais, não se pode partir do princípio que o código desenvolvido originalmente
é óptimo, nem que os algoritmos ou soluções apresentadas em livros de texto não são
optimizáveis. Observou-se isso no código do Conjugate Gradient e na solução para construção das matrizes esparsas. O código usado era correcto, mas oferecia possibilidades de
ser optimizado.
Outra conclusão que se tira é, mesmo após efectuada a paralelização do código, ser
necessária a
investigação de possibilidade de redução do tempo de execução. Se a
transmissão de dados for ineficiente, é necessário alterá-la, introduzindo código de
selecção dos dados as transmitir. Com o uso de DSM, essa selecção realiza-se
automaticamente. Sem alteração completa da estrutura do simulador, esta solução foi a
melhor que se tentou aplicar. Certo é, que há sempre a possibilidade de alterar a partição
e partilha dos dados, mas tal tarefa deve ser realizada pelos especialistas no domínio da
aplicação.
Como resultados palpáveis do trabalho realizado temos, obviamente, a aplicação de
simulação, cuja versão final e optimizada oferece um desempenho e rapidez muito
superior ao da versão original. Paralelamente desenvolveram-se ferramentas necessárias à
avaliação do desempenho da aplicação, tendo-se implementado duas bibliotecas para
leitura dos contadores de processador e sistema operativo. Também se desenvolveu um
servidor para lançamento de processos em computadores remotos.
A descrição da optimização da versão paralela encontra-se descrita num artigo apresentado numa conferência internacional.
Este documento, como súmula das várias técnicas de optimização aplicáveis a problemas
reais, também pode ser considerado como um resultado válido. Este documento tem a
mais valia de, não só apresentar as técnicas de avaliação e optimização, como também os
resultados obtidos da sua aplicação e expectáveis em aplicações semelhantes.
94
Conclusões
O grande ensinamento que se pode obter destes trabalho é que há uma grande necessidade dos especialista nos domínios das simulações terem algum conhecimento de técnicas
de optimizações e das arquitecturas de computadores e processadores onde se irá executar o código. Observou-se neste projecto, que o código desenvolvido resolvia os problemas, mas as soluções inicialmente adoptadas poderiam ser melhores. Por exemplo, numa
aplicação onde o desempenho era fundamental, o uso de estruturas de dados dinâmicas,
mostrou-se pouco acertada.
Ao apontarmos esta necessidade de formação na área de arquitecturas e optimização,
pretendemos facilitar o futura desenvolvimento de código eficiente. Os autores do código,
devem passar a ser capazes de avaliar as várias decisões tomadas. Mesmos que não conseguiam efectuar a optimização de código já existente, já há a capacidade de identificar código problemático e as soluções adoptadas menos boas e com necessidade de melhoria.
Um dos modos de melhorar a aplicação de simulação seria alterar o modo de armazenamento dos dados. Eliminado-se as estruturas de dados dinâmicas possibilitar-se-ía a aplicação de optimizações relacionadas com a arquitectura do computador (encadeamento de
intrusões e uso da cache).
95
Apêndice A – Matrizes esparsas
De modo a optimizar o espaço ocupado por matrizes com predominância de elementos
nulos, usam-se matrizes esparsas.
Este tipo de representações necessita de armazenar um dos índices da matriz num vector
distinto dos valores, visto estes não serem contíguos. Neste projecto usaram-se matrizes
esparsas com indexação das colunas, tal como se apresenta na Fig. 6.1.
3
0
1
0
0
0
4
0
0
0
0
7
5
9
0
0
0
0
0
2
0
0
0
6
5
ija[k]
7
8
8
10 11 12
3
2
4
5
4
sa[k]
3
4
5
0
1
7
9
2
6
índice k
1
2
3
4
7
8
9
10
11
a)
5
5
6
b)
Fig. 6.1 – Representação esparsa de uma matriz.
Para uma matriz com NxN elementos, esta representação tem as seguintes características:
•
As primeiras N posições do vector sa armazenam a diagonal da matriz (elementos
nulos também são representados) ;
•
As primeiras N posições do vector ija armazenam o índice de início da linha
correspondente (se alinha não tiver elementos não diagonais, o elemento seguinte
tem o mesmo valor) ;
•
A posição 1 de ija armazena o valor N+2;
•
A posição N+1 do vector ija é obtido somando 1 ao índice de sa do último
elemento armazenado;
•
Os elemento de sa com índice superior a N+2 armazenam o elemento não
97
Optimização e Avaliação de Aplicações de Simulação Numérica
diagonais da matriz, ordenado por linha e, para a mesma linha, ordenados por
coluna;
•
Os elemento de ija com índice superior a N+2 guardam a coluna do elementos
correspondentes de sa.
Com este tipo de representação não só se reduz a utilização de memória para armazenar
os dadas com também se conseguem obter implementações eficientes das operações sobre
matrizes usuais. Apresenta-se de seguida o algoritmo para conversão de uma matriz quadrada N*N para matriz esparsa.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void sprsin(float **a, int n, unsigned long nmax,
float sa[],unsigned long ija[]){
int i,j;
unsigned long k;
for (j=1;j<=n;j++)
sa[j]=a[j][j]; /* elementos diagonais */
ija[1]=n+2;
k=n+1;
for (i=1;i<=n;i++) { /* linhas */
for (j=1;j<=n;j++) { /* colunas */
if (a[i][j] != 0){
sa[k]=a[i][j];
ija[k]=j;
}
}
ija[i+1]=k+1; /* índice da proxima linhas */
}
}
Observa-se facilmente que o número de instruções desta função é da mesma ordem das
instruções da cópia de uma matriz não esparsa. Observações semelhantes se podem realizar para outras operações, tais como a multiplicação de matrizes por vectores.
98
Apêndice B – Conjugate Gradient
Apresenta-se aqui a implementação do método iterativo de gradientes conjugados usado
no projecto FLASH. Este código resolve um sistema de equações A.x = b, estando os
coeficientes das equações armazenados numa matriz esparsa (ija e sa) e representando
b os resultados conhecidos das equações.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void Conj_Grad::Solve()
{
register int i,j;
Real ak,akden,bk,bkden,bknum,bnrm,zm1nrm,znrm;
Real err;
int iter=0;
Real *r,*z;
p
r
z
p
=
=
=
=
new
new
new
new
Real[nrows+1];
Real[nrows+1];
Real[nrows+1];
Real[nrows+1];
atimes(first_r, last_r,x,r,0);
//r = A * x
for (j=first_r;j<=last_r;j++) {
//r = b - A * x
r[j]=rhs[j]-r[j];
}
asolve(first_r, last_r, rhs, z, 1); //z = diag(A)-1 * b
bnrm=snrm(first_r, last_r, z);
//bnrm = |z|
while (iter <= itmax) {
++(iter);
zm1nrm=znrm;
for (bknum=0.0,j=first_r;j<=last_r;j++)
bknum += r[j]*r[j];
//bk = r * r
if (iter == 1){
for (j=first_r;j<=last_r;j++){
99
Optimização e Avaliação de Aplicações de Simulação Numérica
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
p[j]=r[j];
}
}else{
bk=bknum/bkden;
for (j=first_r;j<=last_r;j++){
p[j]=bk*p[j]+r[j];
}
}
bkden=bknum;
atimes(first_r, last_r,p,z,0);
//z = A * p
for (akden=0.0,j=first_r;j<=last_r;j++)
akden += z[j]*p[j];
ak=bknum/akden;
//ak = r*z/pp*A*p
for (j=first_r;j<=last_r;j++) {
x[j] += ak*p[j];
r[j] -= ak*z[j];
}
//x = x + ak*p
//r = r + ak*z
znrm=1.0;
err=snrm(first_r, last_r, r);
err = err/bnrm;
if (err <= Tolerance)
break;
//err = |r|/bnrm
}
delete [] p;
delete [] r;
delete [] z;
}
A função asolve, divide os elementos de b pelos valores correspondentes na diagonal da
matriz A e armazena o resultado no vector x. A matriz A está armazenada nos vectores
ija e sa, encontrando-se os N valores diagonais de A nos N primeiros elementos de sa.
1
2
3
4
5
6
7
8
9
10
void Conj_Grad::asolve(int first, int last, Real b[],
Real x[], int itrnsp){
register int i;
if (itrnsp)
for(i=first;i<=last;i++)
x[i]=b[i];
else
for(i=first;i<=last;i++)
x[i]=(sa[i] != 0.0 ? b[i]/sa[i] :b[i]);
}
Para efectuar a multiplicação entre a matriz A e um vector, usa–se a função atimes, que
no nosso caso apenas invoca a função dsprsax (r = A*x).
100
Apêndice B – – Conjugate Gradient
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void Conj_Grad::atimes(int first, int last, Real x[],
Real r[], int itrnsp){
dsprsax(x,r,first, last);
}
void Conj_Grad::dsprsax(Real x[], Real b[], int first,
int last){
register int i;
for (i=first;i<=last;i++) {
b[i]=sa[i]*x[i];
int* ijai = &(ija[ija[i]]);
Real* bi = &(b[i]);
Real* sak = &(sa[ija[i]]);
int* up
= ijai + ((ija[i+1]-ija[i]) & ~07);
while (ijai < up){
*bi += *sak++ * x[*ijai++];
*bi += *sak++ * x[*ijai++];
*bi += *sak++ * x[*ijai++];
*bi += *sak++ * x[*ijai++];
*bi += *sak++ * x[*ijai++];
*bi += *sak++ * x[*ijai++];
*bi += *sak++ * x[*ijai++];
*bi += *sak++ * x[*ijai++];
}
up = &(ija[ija[i]]) + (ija[i+1]-ija[i]);
while (ijai < up)
*bi += *sak++ * x[*ijai++];
}
}
A função snrm calcula o módulo de um vector.
1
2
3
4
5
6
7
8
9
Real Conj_Grad::snrm(int first, int last, Real sx[])
{
register int i;
Real ans = 0.0;
for (i=first;i<=last;i++)
ans += sx[i]*sx[i];
return sqrt(ans);
}
101
Bibliografia
[Adv90] S. Adve, M. Hill. Weak Ordering - A New Definition. Proceedings of the 17th
Annual International Symposium on Computer Architecture, p. 2—14. 1990
[Alm94] G. Almasi, A. Gottlieb. Highly Parallel Computing. The Benjamim/Cummings
Publishing Company, Inc., 1994.
[Amd67] G. M. Amdahl. Validity of the single-processor approach to achieving large scale
computing capabilities. AFIPS Conference Proceedings vol. 30, p. 483—485.
AFIPS Press, 1967.
[Bro97] E. D. Brooks III and K. H. Warren. A Study of Performance on SMP and
Distributed Memory Architectures Using A Shared Memory Programming Model.
Proceedings of SuperComputing97. 1997.
[Cha97] J. R. Chacón, E. Oñate. Questionaire on FSHD Specifications. Documento Interno Projecto FLASH WOR-10-01. 1997.
[CIM02] CIMNE – International Center for Numerical Methods in Engineering.
Gid - The personnal pre and post processor. http://gid.cimne.upc.es/. 2002.
[Dol96] Dolphin Interconnect Solutions AS. The Dolphin SCI Interconnect White Paper.
Dolphin Interconnect Solutions AS, 1996
[Dow98] K. Dowd, C. Severance. High Performance Computing, 2nd ed.: RISC Architectures, Optimization, & Benchmarks. O'Reilly & Associates, Inc, 1998.
[Fly72] M. Flynn. Some Computer Organizations and Their Effectiveness. IEEE Transactions on Computers, vol. 21(9), p. 948—960. 1972.
[Gei94] A. Geist, A. Beguelin, J. Dongarra, W. Jiang, R. Manchek, V. Sunderam. PVM:
Parallel Virtual Machine. A Users' Guide and Tutorial for Networked Parallel Computing. Scientific and Engineering Computation. MIT Press, 1994.
103
Optimização e Avaliação de Aplicações de Simulação Numérica
[Gha90] K. Gharachorloo, D. Lenoski, J. Laudon, P. Gibbons, A. Gupta, J. Hennessy.
Memory Consistency and Event Ordering in Scalable Shared-Memory
Multiprocessors. Proceedings of the 17th Annual International Symposium on
Computer Architecture, p. 15—26. 1990
[Gra82] S. L. Graham, P. B. Kessler, M. K. McKusick. gprof: a call graph execution profiler. Proceedings of the SIGPlan '82 Symposium on Compiler Construction,
p. 120—126. 1982.
[Gropp] W. Gropp. Tutorial on MPI: The Message-Passing Interface. Mathematics and
Computer Science Division, Argonne National Laboratory.
[Haw98] K. Hawick, J. Dongarra, G. Fox, S. Elmohamed. Survey of High Performance
Computing Systems. National HPCC Software Exchange, 1998.
[Hig97] High Performance Fortran Forum. High Performance Fortran language specification, version 2.0. Center for Research on Parallel Computation, Rice University,
Houston, 1997.
[Hoc81] R. W. Hockney, C. R. Jesshope. Parallel Computers. Admam Hilget Ltd., 1981.
[Hof00] C. Hofmeister, R. Nord, D. Soni. Applied Software Architecture.
Addisson-Wesley, 2000.
[Int01a] Intel Corporation. The IA-32 Intel Architecture Software Developer's Manual
(Volume 1: Basic Architecture). Intel Corporation, 2001.
[Int01b] Intel Corporation. The IA-32 Intel Architecture Software Developer's Manual
(Volume 2: Instruction Set Reference). Intel Corporation, 2001.
[Int01c] Intel Corporation. The IA-32 Intel Architecture Software Developer's Manual
(Volume 3: System Programming Guide). Intel Corporation, 2001.
[Int02] Intel Corporation. Overview of the Intel® VTune™ Performance Analyzer5.0.
http://developer.intel.com/software/products/vtune/vtune_oview.htm.
2002
[Int97] Intel Corporation. Intel Architecture Optimization Manual. Intel Corporation,
1997
[Int98] Intel Corporation. P6 Family of Processors – Hardware Developer’s Manual.
Intel Corporation, 1998
104
Bibliografia
[Int99] Intel Corporation. Intel Architecture Optimization – Reference Manual. Intel Corporation, 1999.
[Kar98] G. Karypis, V. Kumar. METIS, a software package for partitioning unstructured
graphs, partitioning meshes, and computing fill-reducing orderings of sparce matrices. version 4.0. Technical report, Department of Computer Science, University
of Minnesota / Army HPC Research Center, Minneapolis, 1998.
[Kel92] P. Keleher, A. L. Cox, S. Dwarkadas, e W. Zwaenepoel. Lazy Release
Consistency for Software Distributed Shared Memory. Proceedings of the Nineteenth
International Symposium on Computer Architecture, p. 13-21. Maio 1992.
[Kel94] P. Keleher, A. L. Cox, S. Dwarkadas, e W. Zwaenepoel. TreadMarks: Distributed shared memory on standard workstations and operating system. Proceedings of
the Winter 1994 USENIX Conference, p. 115—132. USENIX, 1994.
[Lam79] L. Lamport. How to make a multiprocessor computer that correctly executes multiprocess programs. IEEE Transactions on Computers, vol. 28(9)m p. 690—691.
Setembro 1979.
[Lau97] J. Laudon, D. Lenoski. The SGI Origin: a ccNUMA highly scalable server.
Proceedings of the 24th international symposium on Computer architecture.
1997.
[Meu01] H. W. Meuer, E. Strohmaier, J. J. Dongarra, H. D. Simon. Top 500 supercomputer sites – 18th edition. Novembro 2001.
[MsSDKa] Microsoft Corporation. Microsoft Platform SDK, Timers. Microsoft Corporation, 2000.
[MsSDKb] Microsoft Corporation. Microsoft Platform SDK, Performance Monitoring. Microsoft Corporation, 2000.
[MsSDKc] Microsoft Corporation. Microsoft Platform SDK, Processes and Threads. Microsoft Corporation, 2000.
[MsWRK] Microsoft Corporation. Windows 2000 Professional Resource Kit, Overview of Performance Monitoring. Microsoft Corporation, 2000.
[POSIX] Institute of Electrical and Electronics Engineers, Inc., The Open Group.
The Open Group Base Specifications Issue 6/IEEE Std 1003.1-2001. 2001
105
Optimização e Avaliação de Aplicações de Simulação Numérica
[Pow91] M. L. Powell, S. R. Kleiman, S. Barton, D. Shah, D. Stein, M. Weeks. SunOS 5.0
multithread architecture. White paper, SunSoft, 1991.
[Pre92] W. H. Press, S. A. Teukolsky, W. T. Vetterling, B. P. Flannery. Numerical Recipes in C, 2nd. edition. Cambridge University Press, 1992.
[Rom97] T. Romer, G. Voelker, D. Lee, A. Wolman, W. Wong, H. Levy, B. Bershad,
J. Chen. Instrumentation and optimization of Win32/Intel executables using etch.
The USENIX Windows NT Workshop 1997, p. 1—7. USENIX, 1994.
[Sil00] J. Silva, P. Guedes. Ship Hull Hydrodynamic Analysis Using Distributed Shared
Memory. Applied Parallel Computing – New Paradigms for HPC in Industry
and Academia – 5th International Workshop, PARA 2000 Proceedings.
Springer-Verlag, 2001.
[Sni96] M. Snir, S. W. Otto, S. Huss-Lederman, D. W. Walker, J. Dongarra. MPI: the
complete reference. MIT Press, 1996.
[Ste96] F. Stern, J. Longo, Z. J. Zhang, A. Subramani. Detailed bow-flow data and cfd for
a series 60 cb=0.6 ship model for froude number 0.316. Journal of Ship Research.
1996.
[Sun01] Sun Microsystems, Inc., The Sun FireTM 15K Server for High Performance and
Technical Markets. White paper, Sun Microsystems, Inc., 2001.
[Sun90] V. S. Sunderam. PVM: A framework for parallel distributed computing.
Concurrency: practice and experience, vol. 2(4), p. 315—339. 1990.
[Vit98] VITA Standards Organization. Myrinet-on-VME Protocol Specification Draft
Standard – VITA 26-199x Draft 1.1. 1998.
[Woo99] M. Woolfson, G. Pert. An Introduction to Computer Simulation. Oxford
University Press, 1999.
[Zag96] M. Zagha, B. Larson, S. Turner, M. Itzkowitz. Performance analysis using the
MIPS R10000 performance counters. Supercomputing '96 Conference Proceedings. ACM Press e IEEE Computer Society Press, 1996.
106
Glossário
Micro-instrução Operação elementar executada pelo processador. Existem algumas
instruções assembly que são traduzidas em mais que uma micro-instrução
ATM Asynchronous Transfer Mode. Tecnologia de ligação de computadores
suportada pela existência de um comutador de pacotes
CAD Computer-aided design. Sistema de desenho (engenharia naval, …)
assistido por computador
CFD Computational Fluid Dynamics. Ciência que estuda a solução das
equações que regem o comportamento do fluidos, usando sistemas
computacionais e simulação numérica.
Driver Programa que executa em modo núcleo e que permite a interacção
dos programas dos utilizadores com recursos protegidos.
DSM Distributed Shared Memory. Memória Partilhada e Distribuída
Ethernet rede local de alto débito (10 ou 100 Mbit/s)
Handle Valor único, gerido pelo sistema operativo e representativo de um
determinado recurso acedido pela aplicação
NUMA Non-Uniform Memory Access. Sistemas multiprocessadores em que o
custo de acesso aos dados é variável consoante estes se encontram
em memória local o remota.
Número de Froude Quantidade adimensional representativa, na arquitectura naval, da
relação entre a velocidade da embarcação e a velocidade da
107
Optimização e Avaliação de Aplicações de Simulação Numérica
2
v
superfície da onda. Calcula-se usando a formula F =
.
n gL
PVM Parallel Virtual Machine (ou Máquina Paralela Virtual)
Query Pergunta efectuada a uma base de dados cuja resposta é composta
por dados
RPC Remote Procedure Call. Método de invocação remota de funções
através de uma ligação de rede.
Time-sharing Política de gestão do processador por parte do sistema operativo,
em que é atribuído a cada processo um período de tempo de
execução.
Consegue-se
assim
processador para cada processo.
108
simular
a
existência
de
um
Download

Optimização e Avaliação de Aplicações de Simulação Numérica