Núcleos de Comunicação: MPI e OpenMP
 
Elgio Schlemer

Introdução

Aumentar o desempenho de um sistema através do uso de vários processadores tem sido muito usado e explorado cientificamente nestes últimos anos. A pesquisa se volta, principalmente, a procurar soluções para problemas gerados pela concorrência destes processadores aos recursos do hardware, bem como técnicas de mantê-los o maior tempo possível ocupado (escalonamento). Outro aspecto que também é vital para o desempenho destas máquinas é a maneira como estes processadores se comunicam.

Através das curvas de speedup podemos facilmente perceber que, ao contrário do que o senso comum sugere, o desempenho de uma máquina não cresce proporcional ao número de processadores disponíveis. Temos, inclusive, um declínio do desempenho quando o número de processadores chega ao um determinado patamar. Esta característica se deve a vários fatores, mas um dos mais importantes é o overhead criado pela comunicação entre os processadores.

Esta comunicação pode ser implementada, basicamente, de duas formas: Por memória compartilhada ou troca de mensagens.

No primeiro caso cada processador tem acesso a uma memória global, sendo possível, portanto, um processador ler os dados armazenados por outros. Esta é a forma mais natural de se fazer a programação sendo razoavelmente fácil programar-se desta forma. Máquinas com este tipo de comunicação devem prover controle de acesso a memória bastante complexos para evitar que dois processadores acessem a memória simultaneamente. Este tipo de controle (acesso a memória) torna-se o grande "gargalo" com uma grande quantidade de processadores.

O segundo caso, troca de mensagens, foi inicialmente idealizado para funcionar nas chamadas "máquinas paralelas virtuais". Estas máquinas eram constituídas de hardware barato de apenas um processador, mas que, ligadas em rede, possibilitavam a programação paralela através do uso de bibliotecas de troca de mensagens. Uma destas bibliotecas era o PVM (Parallel Virtual Machine). Com ele podia-se montar uma máquina "virtual" de vários processadores, dividir uma tarefa em vários pedaços e determinar que cada um dos processadores (que são máquinas independentes ligadas em rede) execute uma delas, contribuindo para o resultado final. Um dos grandes problemas, porém, destas bibliotecas era o fato de ser um tanto quanto difícil programar-se, pois as trocas de mensagens entre as tarefas tinham que ser explicitadas pelo programador. Mais do que uma biblioteca de comunicação, o MPI (Message-Passing Interface) apareceu para tornar-se um padrão em troca de mensagens.

Porém, o mundo da programação paralela hoje não se divide apenas em máquinas com memória compartilhada e com memória distribuída. Cada vez mais buscam-se técnicas eficientes para simular memória compartilhada em máquinas inicialmente com memória distribuída. São as chamadas DSM, Distributed Shared Memory. A idéia típica consiste em ligar-se vários processadores através de uma rede de alta velocidade e simular memória compartilhada através de troca de mensagens. Tipicamente, cada processador determina uma área da sua memória que será compartilhada com os demais processadores do sistema. O gerenciador de memória, então, ficará encarregado de fazer a devida coerência desta parte da memória.

A princípio tais máquinas, mesmo sendo fisicamente de memória distribuída, poderiam ser programadas da mesma forma que as com memória compartilhada. Entretanto, dada a diversidade de técnicas de coerência, tipos de redes, protocolos de comunicação, etc., cada fabricante de hardware desenvolveu seu próprio meio de prover este mascaramento da memória para o programador. Para cada nova máquina tinha-se, então, um novo conjunto de compiladores e uma nova coleção de diretivas de compilação para ser explorada pelos programadores. Perdeu-se, então, um dos objetivos básicos do processamento paralelo: a portabilidade.

Isso fez com que os fabricantes, mesmo tendo disponibilizado recursos de comunicação por memória compartilhada, disponibilizassem nestas máquinas as mesmas bibliotecas de comunicação utilizadas pelas "Máquinas paralelas virtuais", como o PVM e o MPI.

Com a intenção de tornar a programação por memória compartilhada viável e portável, OpenMP apareceu como uma interessante alternativa a este problema.

Este trabalho tem como objetivo fazer uma análise desta interface bem como do MPI-2.

Inicialmente será dada uma pequena introdução ao método de trocas de mensagens, apresentando, para efeito didático, o PVM. Após, será apresentado o padrão MPI bem como suas principais novidades no MPI-2. Em seguida será estudado OpenMP, concluindo-se ao final com algumas comparações entre os dois.

PVM

O Parallel Virtual Machine é, digamos a mais tradicional biblioteca de comunicação. A idéia do PVM é montar uma máquina virtual de n processadores e usá-los para enviar tarefas e receber os resultados. Esta máquina virtual pode ser criada com chamadas de dentro do código ou fora do ambiente de execução. Para isso, o PVM continha três partes prinicipais:

A biblioteca dispõe de recursos que possibilitam manipular qualquer coisa do seu ambiente virtual, inclusive em tempo de execução, como retirar processadores, inseri-los, criar novos processos, matar processos, enviar mensagem para vários processos ao mesmo tempo, e inúmeras outras características.
 
 

Na figura 1 temos um exemplo de programação em PVM. Este exemplo serve apenas para ilustrar o uso das funções do PVM. Na linha 5 temos o nome das máquinas que serão usadas no processamento. Umas das grandes características do PVM é que estas máquinas nem ao menos precisam ter a mesma arquitetura, ou seja, podemos misturar máquinas SUN com LINUX para executar uma tarefa. Na linha 9 temos um comando para inserir as referidas máquinas em nosso ambiente e na linha 10, iniciamos 5 tarefas de nome "test", onde seus id's (numero para identificar o processo dentro da maquina virtual) são armazenados no vetor child. Os comandos do PVM retornam, geralmente um inteiro que indica condição de sucesso ou erro. No caso do pvm_spawn(), este retorno é apenas o número de tarefas que efetivamente foram iniciadas. Se este número for menor do que o desejado (no caso, 5) deduz-se que houve um erro (linhas 11 e 12).

Nas linhas 14 a 22 temos simplesmente o envio do identificador de processo para cada processo, o recebimento de um inteiro deste processo e o comando para matá-lo. Para enviar uma mensagem com PVM, uma maratona de procedimentos são necessárias. A primeira delas é preparar o buffer de mensagem com o pvm_initsend(). A constante PvmDataDefault indica que o PVM deve formatar as mensagens por um padrão XDR. Este padrão possibilita enviar mensagens entre arquiteturas diferentes. Se utilizarmos apenas um tipo de arquitetura (todos PC's rodando LINUX, por exemplo), podemos usar o parâmetro PvmDataRow que não prove nenhum tipo de codificação, sendo, portanto, muito mais eficiente.

A seguir, na linha 16, temos um comando de empacotamento de mensagens. Podemos, numa única mensagem, enviar vários tipos de dados, como inteiros, strings, etc. Após a mensagem estar devidamente preparada para ser enviada, o comando pvm_send() se encarrega de enviá-la ao processo destino.

Para receber uma mensagem, usa-se o pvmrecv(). No exemplo (linha 18) o primeiro "-1" indica o recebimento de mensagem de qualquer processo. Se deseja-se receber apenas de um único, coloca-se seu id neste parâmetro. O outro "-1" indica o recebimento de qualquer tag (veja na linha 17 o envio de msg com tag=1 - segundo parâmetro). O conceito de tag pode ser usado para separar, por exemplo, mensagens de dados das de controle (tag=1 para dados e tag=2 para controle). Novamente, na linha 20, deve-se utilizar rotinas do pvm para "desempacotar" os dados na mesma ordem com que foram empacotados.

Na linha 21 temos o comando para matar o processo para quem acabou-se de enviar a mensagem e, finalmente na linha 23 o comando para desmontar a máquina paralela virtual.

Adicionar e retirar máquinas em tempo de execução não é muito prático e bastante custoso computacionalmente. Por isso o PVM também dispõe de um console, onde é possível criar a máquina virtual fora do código e usá-la várias vezes, ou mesmo deixá-la ativa enquanto as máquinas estiverem ligadas. Também é possível disparar e matar processos do console.

São muitos os comandos do PVM para manipular os processadores e seus processos, inclusive possibilitando explicitar em qual processador uma tarefa deve ser iniciada. O programador, se desejar, pode fazer sua própria rotian de escalonamento e chamá-la de dentro do pvm_spawn().

Estes comandos e também a complicação em enviar-se mensagens (através de sucessivos "empacotamentos") tornou o PVM um pouco distante da idéia inicial de facilitar a programação, se possível, aproximá-la da por Memória compartilhada. Verificou-se também que iniciar processos dinamicamente, ou seja, de dentro de um outro processo, gera um overhead muito grande e confunde um pouco a lógica de programação. Além do mais, o programador tem que explicitar as tarefas que quer inicializar e guardar seu identificador para poder comunicar-se com ela posteriormente.

De qualquer modo, o PVM tornou-se a biblioteca mais popular e seu uso é muito expressivo nas arquiteturas existentes, existindo, inclusive, bibliotecas para suporte a Threads. [PVM91]

MPI

O MPI apareceu como uma alternativa as bibliotecas de comunicação da época. Constitui-se de um padrão de Troca de mensagens onde cada fabricante está livre para implementar as rotinas, já com sintaxe definida, utilizando características exclusivas de sua arquitetura. Inclusive para arquiteturas com memória compartilhada foram criadas bibliotecas MPI.

Quando comparado com o PVM, logo percebe-se uma série de restrições do MPI. Das restrições, com o objetivo de otimizar a comunicação, a principal é a não existência de gerenciamento dinâmico de processos e processadores. Enquanto que o PVM possibilita incluir e remover processadores e processos em tempo de execução, o MPI só permite fazê-lo fora da execução.

A princípio esta restrição pode parecer desfavorável, mas os programas escritos em MPI tendem a ser mais eficientes pelo fato de não haver overhead na carga de processos em tempo de execução. Alias, nem existem "processos" como no PVM, existe sim, um único processo que pode ser rodado em várias máquinas (chamadas de nodos) já previamente montadas na Máquina Virtual e não modificável em tempo de execução. Logo se percebe que esta técnica se beneficia em muito de programas paralelos simétricos, onde todos os participantes executam o mesmo trecho de código, só que em porções de dados diferenciadas.

A programação com o MPI é mais simples e mais legível que a do PVM. O simples fato de no PVM existirem n códigos diferentes de uma mesma aplicação e no MPI haver apenas um, já o torna mais atraente. Na figura 2, temos um exemplo escrito em MPI.
 

 
 

Neste exemplo, n cópias do processo foram disparadas em n nodos. Inicialmente, na linha 11, o código registra-se no ambiente e obtém em rank seu identificador. Como todos os processos são criados na linha de comando, não existe uma hierarquia de processos (não existe um processo "pai"). Todos são numerados de "0" a "n-1", sendo n o número total de processos que deve ser menor ou igual ao número de nodos disponíveis. No exemplo, determinamos que o nodo de número 0 será responsável para enviar mensagens aos demais. O envio e recebimento de mensagens não é muito diferente do PVM, apenas nota-se a não necessidade de "empacotar-se" as mensagems mas, em contrapartida, deve-se informar (linha 16, parametro MPI_CHAR) qual é o tipo de dado que está se enviando.

O MPI também trouxe alguma comunicação implícita, como o MPI_REDUCE. Neste caso, defini-se uma variável e determina-se que ela será reduzida de todos os processos por uma operação matemática. Como exemplo, podemos citar um programa que calcule o PI em que cada processo calcula uma parte e o resultado final é a soma de todas as partes, no caso um MPI_REDUCE pela soma. O efeito é o mesmo do que se cada processo enviasse uma mensagem com a sua parte ao nodo raiz (nodo 0), para que este faça a soma. As versões mais recentes do PVM também já trazem este tipo de operações.

O MPI, no entanto, por não prover gerenciamento dinâmico de processos, deixa de ser atraente para algumas aplicações, tipicamente as que cada processador seria responsável por uma tarefa específica. Para usar o MPI nestes casos, o que se fazia era um código bastante grande com vários comandos if. "Se eu sou o nodo tal, faço isso... Se não faço aquilo...". Estes processos usavam muitos recursos de hardware e sua inicialização era custosa. [MPI97]

MPI-2

Uma das grandes diferenças do MPI para o MPI-2 é que este tornou-se mais parecido com o PVM. Incorporou, mesmo que os manuais recomendam não usar, o gerenciamento dinâmico de processos. A programação, agora, pode ser feita disparando-se n processos de dentro de um processo principal e se for necessário!

Outra característica interessante é que incorporou algum suporte a Threads. Manipular Threads sempre foi um problema para as bibliotecas de troca de mensagens, justamente porque elas conhecem apenas processos e não Threads. Já que várias Threads podem compartilhar o mesmo endereçamento de um único processo, como fazer a comunicação entre Threads? A falta de suporte a Threads, porém, torna-se um problema se estivermos usando como nodo máquinas com mais do que um processador.

O que o MPI-2 traz é a possibilidade do programador restringir o acesso a comunicação de algumas Threads. O problema básico dá-se pelo fato de que se existem várias Threads em um único processo e todas podem fazer chamadas as funções do MPI, fica muito complicado evitar que alguma entre em deadlock, por exemplo. Isso poderia acontecer se mais do que uma executasse o mesmo comando Receive para uma mesma mensagem. Vale frisar que não é possível enviar mensagems para Threads mas apenas para processos que possuem ao menos uma Thread.

No MPI-2 pode-se definir, por exemplo, que apenas uma Thread (a principal) possa executar funções do MPI. Fica mais fácil, assim, definir-se uma Thread como responsável pela Comunicação, garantindo-se que mesmo que uma outra execute chamadas do MPI (send e receive, por exemplo) elas não terão efeito.

Se o programador deseja que todas as Threads estejam aptas a manipular mensagens, outra facilidade que o MPI traz é a de não permitir chamadas simultâneas num mesmo processo. [MPI97]

MPI - Conclusão

O MPI  tornou-se um padrão em comunicação por troca de mensagens. O fato de existir apenas uma especificação do padrão de comunicação, deixando que cada fabricante o implemente da melhor maneira possível, explorando os recursos específicos de sua arquitetura, possibilitou torná-lo ao mesmo tempo eficiente e portável. A portabilidade, no caso, é uma das grandes buscas em máquinas paralelas em geral. Esta característica fez com que mesmo os fabricantes de máquinas com memória Compartilhada (especialmente as simuladas em hardware com coerência de memória) incluissem o MPI em suas arquiteturas. Isso se deve ao fato de que a programação por memória compartilhada tornou-se complicada neste tipo de máquina e a programação por Troca de Mensagens é mais atraente. [OP1-97]

OpenMP

O OpenMP não é uma biblioteca de comunicação. Enquanto as bibliotecas como o PVM e o MPI tinham como objetivo prover comunicação entre máquinas com memória distribuída, o OpenMP tem como objetivo prover a comunicação entre máquinas com memória compartilhada, tipicamente as que simulam memória compartilhada em cima de distribuída. [OP1-97].

A necessidade de técnicas deste tipo deve-se a falta de portabilidade de programas escritos para este tipo de arquiteturas, pois cada fabricante tinha sua própria maneira de compartilhar áreas da memória com os outros processadores e isso fez com que muitos passassem a usar ainda as bibliotecas de comunicação nestas máquinas.

Somente neste aspecto, então, é que uma comparação do MPI com o OpenMP pode ser feita, já que o OpenMP não serve para comunicação por troca de mensagens.

Trata-se, então, de um conjunto de diretivas de programação com memória compartilhada. O principal foco do OpenMP é a linguagem Fortran, já que a mesma é muito pobre inclusive em criação de Threads. [OP1-97]

O mais interessante é que o OpenMP não exige que o usuário pormenorize a paralelização, ou seja, torna-se muito mais fácil a programação quando não há a necessidade de preocupar-se com detalhes de quantas tarefas existem e em quais processadores elas estão. Pode-se escrever um programa praticamente como se fosse seqüencial (respeitando-se algumas regrinhas) Esta é uma vantagem que se mostra inclusive muito mais interessante que a programação com Threads.

Na figura 3 podemos ver um pequeno trecho de código em fortran usando as diretivas do OpenMP. Todas as chamadas do OpenMP são comentários em um compilador fortran normal. No exemplo acima, na linha 3, o programador abriu uma sessão paralela determinando uma variável privada x  e uma compartilhada w. Na linha 4 ainda determinou uma variável do tipo redução em soma, semelhente ao descrito no MPI. As variáveis globais (como o I do laço) são, por default classificadas com privadas a cada Tread podendo o programador redefinir através da diretiva DEAFAULT.

O que o OpenMP fará é dividir as iterações do laço do entre os processadores disponíveis. Note que isso é quase que um paralelismo implícito, pois o programador não necessita informar quantos são os processadores e em quantas partes o laço deve ser dividido. Na linha 8 temos o final da sessão paralela (no caso a sessão terminaria com !$OMP END DO, mas é opcional no caso de laços, pois o OpenMP considera o fim do laço como o fim da sessão), onde todas as Threads são sincronizadas. Sempre no fim de cada sessão paralela as Threads sincronizam implicitamente. Na figura 4 podemos ver outras diretivas interessantes do OpenMP.

Primeiro, podemos perceber nas linhas 1, 4, 9 e 12 que as diretivas do OpenMP podem ser inseridas de várias formas no código, todas tratadas como comentários em um compilador comum. Nas linhas 2 e 3 temos que cada chamada call irá executar em processadores diferentes e logo após, na linha 4, temos novamente uma sessão do tipo do, conforme visto na figura 3. A diferença está na linha 7. Note o parâmetro NOWAIT ao final da sessão. Isso faz com que as Threads não sincronizem ao final, mas continuem sem esperar as outras, uma vez que, por default todas sincronizam explicitamente ao fim de cada sessão. Na linha 9, temos uma diretica SINGLE. Isso indica que apenas uma Thread (no caso a primeira que chegar) executará aquela sessão.

O programador, se quiser, pode explicitar alguns detalhes de paralelização, como especificar o número de Threads máximo gerado, colocar sincronização explicita, como o BARRIER, determinar o tipo de escalonamento usado, usar diretivas para avaliar o desempenho, etc.

No caso do escalonamento, tem-se basicamente três:

Muitas outras diretivas dão ao programador inúmeros recursos, desde a manipulação mais eficaz das Threads existentes até a definição de áreas não paralelizáveis, funções atômicas, de exclusão mútua, etc. O OpenMP torna-se, neste caso, muito mais atraente que o MPI. [OP1-97] e [OP2-97]

Conclusão

Como mencionado, comparar MPI com o OpenMP é possível somente quando seu uso se deve em máquinas com memória compartilhada (seja fisicamente, seja simulada por hardware ou software).

Neste caso o MPI se mostra muito desfavorável, pois a comunicação precisa ser explicitada pelo programador. Mesmo com estes problemas, visando a portabilidade para outras plataformas, muito programadores usam bibliotecas de troca de mensagens em máquinas deste tipo.

OpenMP aparece, então, como uma alternativa a Troca de Mensagens nestas arquiteturas, possibilitando uma programação fácil e acima de tudo portável. Usando as palavras dos próprios autores: "Se você abandonou a programação com memória compartilhada por causa da portabilidade, OpenMP fará você rever sua posição".

Bibliografia

[MPI97] Message Passing Interfece Forum. MPI-2: Extensions to the Message-Passing Interface. 1997.

[OP1-97] OPENMP. OpenMP: A Proposed Standard API for Shared memory Programming. 1997.

[OP2-97] OPENMP. OpenMP Fortran Application Program Interface. 1997.

[PVM91] The MIT press. PVM. A Users' Guide and Tutorial for Network Parallel Computing. Cambridge, Massachusetts. London, England. 1991.