UNIVERSIDADE FEDERAL DO RIO GRANDE DO SUL
INSTITUTO DE INFORMÁTICA
BACHARELADO EM CIÊNCIA DA COMPUTAÇÃO
STÉFANO DRIMON KURZ MOR
Emprego da Técnica de Workstealing:
Estudo de Caso com o Problema da Mochila
e MPI
Projeto de Diplomação
Prof. Dr. Nicolas Maillard
Orientador
Porto Alegre, Junho de 2007
UNIVERSIDADE FEDERAL DO RIO GRANDE DO SUL
Reitor: Prof. José Carlos Ferraz Hennemann
Vice-reitor: Prof. Pedro Cezar Dutra Fonseca
Pró-Reitor de Graduação: Prof. Carlos Alexandre Netto
Diretor do Instituto de Informática: Prof. Flávio Rech Wagner
Coordenador do CIC: Prof. Raul Fernando Weber
Bibliotecária-Chefe do Instituto de Informática: Beatriz Regina Bastos Haro
“Ouve e esquecerás, vê e recordarás, faz e saberás.”
— C ONFÚCIO
AGRADECIMENTOS
• Agradeço a Deus, por tudo nessa vida.
• Agradeço aos meus pais e meu irmão, que me fizeram chegar até aqui.
• Agradeço ao meu orientador, Nicolas Maillard, por sempre estar disposto a me
atender e por sempre respeitar minha opinião, sendo impecável durante todo este
processo.
• Agradeço à minha "mentora” Márcia Cera, por tudo que me ensinou, pelo apoio e
pelas correções.
• Agradeço à Jamile Moroszczuk, pelas discussões tão produtivas e o suporte inabalável.
SUMÁRIO
LISTA DE ABREVIATURAS E SIGLAS . . . . . . . . . . . . . . . . . . . .
8
LISTA DE FIGURAS . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9
LISTA DE TABELAS . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11
LISTA DE ALGORITMOS . . . . . . . . . . . . . . . . . . . . . . . . . . . .
12
RESUMO . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
13
ABSTRACT
14
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1 INTRODUÇÃO . . . . . . . . . . . . . .
1.1
Atualidades . . . . . . . . . . . . . . .
1.2
Descrição do Problema . . . . . . . . .
1.3
Motivação . . . . . . . . . . . . . . . .
1.4
Trabalhos Anteriores . . . . . . . . . .
1.5
Ferramentas Empregadas . . . . . . .
1.6
Metodologia e Abordagem de Estudo .
1.7
Áreas de Interesse . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
. . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
15
15
15
16
17
18
18
19
2 CONTEXTO CIENTíFICO . . . . . . . . . .
2.1
Programação Paralela . . . . . . . . . . .
2.1.1
Hardware . . . . . . . . . . . . . . . . .
2.1.2
Modelos Computacionais e Comunicação
2.1.3
Desempenho . . . . . . . . . . . . . . . .
2.2
Introdução ao MPI . . . . . . . . . . . . .
2.2.1
Motivação . . . . . . . . . . . . . . . . .
2.2.2
História . . . . . . . . . . . . . . . . . .
2.2.3
Definição e Características . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
. . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
20
20
20
24
27
28
29
29
30
3 MPI . . . . . . . . . . . . . . . . . . . . . . . . . .
3.1
As Seis Primitivas Básicas . . . . . . . . . . . . .
3.1.1
Inicialização e Finalização . . . . . . . . . . . .
3.1.2
Identificação de Processos e Comunicadores . . .
3.1.3
Primeiro Exemplo . . . . . . . . . . . . . . . . .
3.1.4
Troca de Mensagens . . . . . . . . . . . . . . . .
3.1.5
Segundo Exemplo . . . . . . . . . . . . . . . . .
3.1.6
Programação Distribuída × Programação Paralela
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
. . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
32
32
32
33
33
36
38
38
3.2
Funcionalidades Adicionais . . . . . .
3.2.1
Comunicação Coletiva . . . . . . . .
3.2.2
Terceiro Exemplo . . . . . . . . . . .
3.2.3
Temporização . . . . . . . . . . . . .
3.2.4
Envio e Recebimento Não-bloqueantes
3.2.5
Quarto Exemplo . . . . . . . . . . . .
3.3
Multiplicação Matriz × Vetor . . . . .
3.3.1
Multiplicação Matriz × Matriz . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
40
40
43
45
45
47
48
48
4 PROBLEMA DA MOCHILA . .
4.1
Definição Intuitiva . . . . . . .
4.2
Definição Formal . . . . . . . .
4.3
Outras Definições . . . . . . . .
4.3.1
Problema da Mochila Limitado
4.3.2
Problema da Mochila 0-1 . . .
4.4
Complexidade . . . . . . . . . .
4.5
Solução . . . . . . . . . . . . .
4.5.1
Algoritmo . . . . . . . . . . .
4.5.2
MPI . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
. . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
56
56
56
57
57
57
58
58
58
63
5 WORKSTEALING . . . . . . .
5.1
Difusão de Máximo Local . .
5.1.1
Algoritmo . . . . . . . . . .
5.1.2
Implementação . . . . . . .
5.2
Workstealing . . . . . . . . . .
5.2.1
Algoritmo . . . . . . . . . .
5.2.2
Considerações . . . . . . . .
5.2.3
Implementação . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
. . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
69
69
69
70
74
74
76
77
6 AVALIAÇÃO DE DESEMPENHO . . .
6.1
Implementação . . . . . . . . . . . . .
6.2
Elaboração dos Casos de Teste . . . .
6.3
Velocidade de Execução . . . . . . . .
6.3.1
Análise do Tempo de Execução . . . .
6.3.2
Speedup . . . . . . . . . . . . . . . .
6.3.3
Eficiência . . . . . . . . . . . . . . .
6.4
Consumo de Memória . . . . . . . . .
6.5
Balanceamento de Carga . . . . . . .
6.6
Impacto de Utilização do Workstealing
6.7
Deadlock . . . . . . . . . . . . . . . . .
6.7.1
Difusão de Máximo Local . . . . . . .
6.7.2
Workstealing . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
. . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
84
84
85
87
87
88
89
90
92
96
96
97
98
7 CONCLUSÕES . . . . . . . . . . . . .
7.1
Problema da Mochila × Paralelização
7.2
Implementação . . . . . . . . . . . . .
7.3
Desempenho . . . . . . . . . . . . . . .
7.4
WS Clássico × Implementação . . . .
7.5
Escalabilidade . . . . . . . . . . . . .
7.6
Trabalhos Futuros . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
. . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
100
100
100
101
102
102
102
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
APÊNDICE A ANÁLISE DOS CASOS DE TESTE
A.1 Tempo de Execução . . . . . . . . . . . . . . .
A.2 Consumo de Memória . . . . . . . . . . . . . .
A.3 Balanceamento de Carga . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
. . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
104
104
107
107
REFERÊNCIAS . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 112
LISTA DE ABREVIATURAS E SIGLAS
AMD
Advanced Micro Devices.
DML
Difusão de Máximo Local.
E/S
Entrada/Saída.
GiB
Gigabyte (binário).
GPPD
Grupo de Processamento Paralelo e Distribuído.
IP
Internet Protocol.
LAM
Local Area Multicomputer.
MIMD
Multiple-Instruction, Multiple-Data.
MISD
Multiple-Instruction, Single-Data.
MPI
Message-Passing Interface.
PVM
Parallel Virtual Machine
RAM
Random Access Memory.
SIMD
Single-Instruction, Multiple-Data.
SISD
Single-Instruction, Single-Data.
TCP
Transmission Control Protocol.
UCP
Unidade Central de Processamento.
UFRGS
Universidade Federal do Rio Grande do Sul.
WS
Workstealing.
LISTA DE FIGURAS
Figura 2.1:
Figura 2.2:
Modelo clássico de multiprocessadores. . . . . . . . . . . . . . . . .
Modelo clássico de multicomputadores. . . . . . . . . . . . . . . . .
23
23
Figura 3.1:
Figura 3.2:
Figura 3.3:
Figura 3.4:
Figura 3.5:
Figura 3.6:
Figura 3.7:
Figura 3.8:
Figura 3.9:
Figura 3.10:
Figura 3.11:
Figura 3.12:
Figura 3.13:
Figura 3.14:
Figura 3.15:
Figura 3.16:
Figura 3.17:
Figura 3.18:
Figura 3.19:
Figura 3.20:
Figura 3.21:
Figura 3.22:
Figura 3.23:
Figura 3.24:
Figura 3.25:
Figura 3.26:
Figura 3.27:
Figura 3.28:
Figura 3.29:
Figura 3.30:
Figura 3.31:
Protótipos de MPI_Init e MPI_Finalize. . . . . . . . . . . . .
Protótipos de MPI_Comm_size() e MPI_Comm_rank(). . . . . . . .
Arquivos de inclusão MPI. . . . . . . . . . . . . . . . . . . . . . . .
Variáveis do programa Hello World MPI. . . . . . . . . . . . . . . .
Programa “Hello World” sem troca de mensagens. . . . . . . . . . .
Possibilidade de saída - primeiro exemplo. . . . . . . . . . . . . . .
Outra possibilidade de saída - primeiro exemplo. . . . . . . . . . . .
Protótipos de MPI_Send() e MPI_Recv(). . . . . . . . . . . . . . . .
Programa “Hello World” com troca de mensagens. . . . . . . . . . .
Saída - segundo exemplo. . . . . . . . . . . . . . . . . . . . . . . .
Diagrama do algoritmo de passagem de token. . . . . . . . . . . . .
Impressão seqüencial de rank com algoritmo de token em anel. . . . .
Resultado para a impressão seqüencial de identificadores. . . . . . .
Implementação direta de broadcast. . . . . . . . . . . . . . . . . . .
Protótipo de MPI_Broadcast(). . . . . . . . . . . . . . . . . . . . . .
Protótipo de MPI_Reduce(). . . . . . . . . . . . . . . . . . . . . . .
Programa para calcular uma aproximação de π. . . . . . . . . . . . .
Protótipos de MPI_Wtime() e MPI_Wtick(). . . . . . . . . . . . . .
Programa para calcular uma aproximação de π, com medição de tempo.
Protótipos de MPI_Isend() e MPI_Irecv(). . . . . . . . . . . . . . . .
Protótipos de MPI_Wait() e MPI_Test(). . . . . . . . . . . . . . . . .
Portótipos de MPI_Testany() e MPI_Waitany(). . . . . . . . . . . . .
Protótipo de MPI_Cancel(). . . . . . . . . . . . . . . . . . . . . . .
Recebimento de mensagem que pode ter exatas 2 tags. . . . . . . . .
Código para o programa de multiplicação matriz × vetor (início). . .
Código para o programa de multiplicação matriz × vetor (mestre). . .
Código para o programa de multiplicação matriz × vetor (escravo). .
Desempenho: multiplicação matriz × vetor – MPI-1.2 . . . . . . . .
Código para o programa de multiplicação matriz × matriz (início). . .
Código para o programa de multiplicação matriz × matriz (mestre). .
Código para o programa de multiplicação matriz × matriz (escravo). .
32
33
34
34
35
36
36
37
39
39
40
41
41
42
42
43
44
45
46
47
47
47
48
48
49
50
51
51
53
54
55
Figura 4.1:
Figura 4.2:
Formato de instância do problema da mochila. . . . . . . . . . . . .
Algoritmo Branch & Bound para o Problema da Mochila, em Python.
58
64
Figura 4.3:
Figura 4.4:
Figura 4.5:
Figura 4.6:
Figura 4.7:
Variáveis da implementação seqüencial que resolve o Problema da
Mochila (em C). . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Algoritmo Branch & Bound para o Problema da Mochila, em C . . .
Algoritmo C com primitivas básicas para o Problema da Mochila . .
Função que realiza a distribuição cíclica da entrada. . . . . . . . . . .
Algoritmo C/MPI paralelizado. . . . . . . . . . . . . . . . . . . . .
64
65
66
67
68
Figura 5.1:
Figura 5.2:
Figura 5.3:
Figura 5.4:
Figura 5.5:
Figura 5.6:
Figura 5.7:
Figura 5.8:
Figura 5.9:
Figura 5.10:
Figura 5.11:
Figura 5.12:
Implementação de broadcast assíncrono. . . . . . . . . . . . .
Implementação do gerenciador de difusão. . . . . . . . . . . .
Implementação de mpi_process_nonblocking(). . . . . . . . . .
Implementação do mecanismo de barreira. . . . . . . . . . . .
Algoritmo C/MPI paralelizado e com Difusão de Máximo Local.
Esquema de funcionamento – Fila de Distribuição Dupla . . . .
Implementação da fila de distribuição dupla. . . . . . . . . . .
Algoritmo C/MPI, versão com fila de distribuição dupla . . . .
Implementação do gerenciador de Workstealing. . . . . . . . .
Implementação da tabela de processos. . . . . . . . . . . . . .
Solicitação de tarefas - Workstealing. . . . . . . . . . . . . . .
Implementação MPI com Workstealing. . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
70
71
72
74
75
77
78
79
80
81
82
83
Formato do arquivo de saída. . . . . . . . . . . . . . . . . . . . . . .
Interface da estrutura de Benchmark. . . . . . . . . . . . . . . . . . .
Gráfico tempo de execução (número de elementos vs. tempo em segundos). . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Figura 6.4: Gráfico tempo de execução (número de processos vs. tempo em segundos). . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Figura 6.5: Gráfico Speedup. . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Figura 6.6: Gráfico Eficiência. . . . . . . . . . . . . . . . . . . . . . . . . . . .
Figura 6.7: Gráfico Consumo de Memória (número de elementos vs. memória
em bytes). . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Figura 6.8: Gráfico Consumo de Memória (número de processos vs. memória
em bytes). . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Figura 6.9: Gráfico mensagens enviadas/recebidas (num. de elementos vs. num.
de mensagens). . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Figura 6.10: Gráfico mensagens enviadas/recebidas (num. de processos vs. num.
de mensagens). . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Figura 6.11: Impacto do WS: tempo de execução. . . . . . . . . . . . . . . . . . .
Figura 6.12: Impacto do WS: consumo de memória . . . . . . . . . . . . . . . . .
84
85
Figura 6.1:
Figura 6.2:
Figura 6.3:
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
88
89
90
91
92
93
94
95
97
98
LISTA DE TABELAS
Tabela 2.1:
Notação do cálculo do Speedup. . . . . . . . . . . . . . . . . . . . .
27
Tabela 3.1:
Tabela 3.2:
As seis funções básicas do MPI. . . . . . . . . . . . . . . . . . . . .
Tempos da multiplicação matriz × matriz paralela. . . . . . . . . . .
32
52
Tabela 4.1:
Significado das variáveis na função de distribuição cíclica da entrada.
67
Tabela 6.1:
Significado dos arquivos de saída do programa. . . . . . . . . . . . .
85
LISTA DE ALGORITMOS
1
2
3
4
Branch (um galho) . . . . . . . . . . . . . . . . . . .
Branch (toda a árvore). . . . . . . . . . . . . . . . . .
Bound . . . . . . . . . . . . . . . . . . . . . . . . . .
Solução Branch & Bound para o Problema da Mochila
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
59
60
61
62
RESUMO
O cerne da discussão aqui introduzida é o estudo do emprego da técnica de roubo de
tarefas (Workstealing) em programas paralelos, tomando-se como base a paralelização de
um algoritmo do tipo Branch & Bound que resolve o Problema da Mochila. Como principal ferramenta, utiliza-se uma implementação da especificação MPI (Message-Passing
Interface).
A monografia apresenta uma descrição detalhada da especificação MPI, com ênfase
nos recursos empregados na confecção do algoritmo. São introduzidas definições de
Workstealing e do Problema da Mochila, sendo apresentadas e comentadas as principais
partes do código do programa MPI que combina ambas. Além disso, são apresentadas
medições de desempenho, cujo objetivo é avaliar o impacto da solução apresentada sobre
diferentes aspectos (e.g., tempo de execução, consumo de memória, balanceamento de
carga, complexidade algorítmica, etc.) da execução de programas.
Ao final, são apresentadas conclusões gerais sobre o assunto e indicados trabalhos
futuros a serem realizados.
Palavras-chave: MPI, Problema da Mochila, Workstealing.
Use of The Workstealing Technique in Knapasack Problem’s Paralelization Using
MPI
ABSTRACT
The introduced discussion’s kernel is the study of the use of the task stealing (Workstealing) technique on programs’ paralelization process, taking as base the paralelization
of a Branch & Bound algorithm that solves the Knapsack Problem. As main tool, it utilizes a implementation of the MPI (Message-Passing Interface) specification.
The monography presents a detailed description of the MPI specification, giving emphasis at the resources used on algorithm’s confection. Definitons of the Workstealing
concept and Knapsack Problem are introduced, being presented and commented the MPI
program’s main parts of code, that combines both. Moreover, performance measurements
are presented, whose objective is to evaluate the presented solution’s impact on different
programs’ execution aspects (e.g., execution time, memory consumption, load balancing,
algorithmic complexity, etc.).
Finnaly, general conclusions are presented and future to-be-done work is pointed.
Keywords: MPI, Knapasack Problem, Workstealing.
15
1
INTRODUÇÃO
Programação paralela e arquiteturas paralelas de computadores representam um passo
natural na próxima geração da escala evolutiva da Computação. De fato,
[...] computadores paralelos evoluíram de bugigangas experimentais em
laboratórios para tornarem-se as ferramentas cotidianas dos Cientistas
da Computação, que precisam do que há de melhor em recursos computacionais, no intuito de resolver seus problemas. (GROPP; LUSK;
SKJELLUM, 1999, p.1)
1.1
Atualidades
Mesmo os processadores destinados ao uso pessoal começam a ganhar feições multiprocessadas; num estágio de transição de dual-core (dois núcleos) para quad-core (quatro
núcleos), fabricantes como Intel e AMD trazem para o cotidiano conceitos que antes faziam parte de empresas e universidades, apenas (CREEGER, 2005).
Recentemente, a Intel fez demonstração de um chip com oitenta núcleos e memória
RAM embutida, o TeraFLOP, capaz de processar um teraflop1 por segundo. Embora
não seja retrocompatível com qualquer arquitetura de microprocessador da empresa, protótipos como esse evidenciam os rumos que o mercado de microprocessadores (tanto
empresarial quanto doméstico) tenderá a adotar (MATTSON; HENRY, 1998).
1.2
Descrição do Problema
MPI é, desde 1996, o padrão de fato para a implementação do modelo de comunicação
por troca de mensagens em Computação de Alto Desempenho. MPI oferece um modelo
de replicação de processos (execução do mesmo código) em todas as UCPs que participam da computação, bem como primitivas de troca de mensagens entre estes processos
(GROPP; LUSK; SKJELLUM, 1999).
Um problema recorrente em aplicações paralelas é o balanceamento de carga de trabalho entre os processadores; para um melhor aproveitamento do paralelismo, é conveniente
que todos os processos tenham aproximadamente a mesma quantidade de tarefas a realizar. Qualquer desbalanceamento implica aumento do tempo de ociosidade, o que acaba
por afetar o desempenho. (PACHECO, 1997).
Uma técnica que visa obter o balanceamento de carga é o Workstealing. Esta abordagem consiste em fazer com que um processo que tenha esgotado sua carga computacional
1 Um
trilhão de operações em ponto flutuante.
16
“roube” tarefas de outros processos que ainda têm trabalho pendente, equilibrando a distribuição de tarefas (BLUMOFE; LEISERSON, 1994).
A utilização do Workstealing, no entanto, introduz questões relevantes para a computação. e.g.,
Impacto no desempenho. WS tenta diminuir o tempo de execução ao trocar tarefas entre os processos. Mas esse ganho pode não compensar o tempo perdido ao fazer as
operações de sincronização (trocas de mensagens) e o tempo agregado ao gerenciamento destas operações.
Escalabilidade. Quanto mais escalável o problema, mais difícil a sua implementação
através de Workstealing, visto que existem questões clássicas como ocorrência de
deadlocks, gerenciamento eficiente de memória (o consumo tende a ser elevado) e
adequação ao hardware utilizado.
Implementação Certos aspectos do algoritmo podem ser implementados de várias maneiras. Tais maneiras implicam, muitas vezes, em custos e dependências de certas
estruturas pré-definidas, que tornam o processo menos transparente. Em especial,
há a questão da portabilidade da solução para aplicações genéricas, que necessita
ser investigada, em decorrência da forte dependência em relação ao problema resolvido com o qual se pode construir o algoritmo.
Algumas destas questões ainda tem caráter em aberto; é difícil mensurar um caso
médio e eficiente, sendo necessário um estudo mais aprofundado.
1.3
Motivação
Estimar os possíveis benefícios da do emprego de Workstealing tem impacto forte
sobre questões relevantes da área. Traçar e resolver questões inerentes a este problema
introduz melhoramentos em áreas como:
Escalonadores de Processos. Implementações de escalonadores de processos MPI (e.g.,
LAM) não possuem um sistema de balanceamento de cargas eficiente2 e que conte
com recursos de roubo de processos3 (CERA et al., 2006), o que poderia ofertar
benefícios à computação realizada.
Bibliotecas Paralelas. Uma biblioteca genérica, eficiente e transparente ao máximo, que
realize Workstealing sobre tipos genéricos de dados pode garantir um ganho de
desempenho em aplicações paralelas de uma maneira ampla.
Otimização de Recursos. O balanceamento de cargas diminui ociosidade, otimizando o
uso de recursos.
Conforme já mencionado, o algoritmo utilizado para a aplicação da técnica e realização das medições foi um algoritmo do tipo Branch & Bound que resolve o Problema da
Mochila. Tal problema foi escolhido por uma série de motivos, a citar:
2 Carga,
neste caso, é a alocação do número de processos por processador.
tópico fundamental neste processo é a migração de processos, uma maneira eficiente e efetiva
de transferir os processos, uma vez que um algoritmo de Workstealing determine que é vantajoso fazê-lo.
3 Outro
17
• popularidade;
• escalabilidade; e
• relevância.
Nem todos os problemas possuem uma estrutura adequada ao uso de Workstealing
(BLUMOFE; LEISERSON, 1994). O Problema da Mochila, por sua estrutura combinatorial (KELLER; PFERSCHY; PISINGER, 2005), parece, a priori, apto a se beneficiar
do uso da técnica. Parte do estudo, portanto, consiste em determinar o quão o Problema
da Mochila é adequado para a aplicação de WS. Além disso, o Problema da Mochila é
especialmente interessante quando escrito em formas de maximização de lucros, como:
• decidir onde perfurar poços de petróleo;
• problemas de otimização de transporte de cargas;
• problemas de otimização de rotas aéreas.
Vale ressaltar que estes problemas são inerentemente intratáveis do ponto de vista
computacional, visto que o Problema da Mochila pertence à classe dos problemas NPCompletos4 (TOSCANI; VELOSO, 2002).
Esta monografia, portanto, preocupa-se em
1. Analisar e apresentar os principais recursos para se construir um programa paralelo
usando a especificação MPI;
2. Propor uma estratégia de paralelização do Problema da Mochila e fazê-lo através
de programação MPI;
3. Introduzir e dissertar sobre a técnica de Workstealing, apresentado vantagens e desvantagens, integrando esta característica ao programa construído; e
4. Fazer medições de diversas características na execução desta solução (e.g., tempo
de execução, memória, número de trocas de mensagens, etc.).
1.4
Trabalhos Anteriores
Parte dos assuntos abordados dão continuidade aos trabalhos do GPPD que tratam
do escalonamento dinâmico de processos. Em especial, os trabalhos de Guilherme Pezzi
sobre a implementação de Workstealing em algoritmos de divisão e conquista usando
MPI-2 (PEZZI et al., 2006) e os trabalhos de Márcia Cera sobre melhoras no escalonador
MPI para criação dinâmica de processos (CERA et al., 2006) são tomadas como referência
básica. Estende-se o primeiro, tentando estabelecer uma especificação de algoritmo de
Workstealing genérico (em contraposição ao caráter específico do programa apresentado)
e se objetiva, com os dados medidos, fornecer resultados que ampliem o leque de opções
na melhora do escalonador MPI que é proposta pelo segundo.
Resultados parciais deste trabalho foram publicados na Escola Regional de Alto Desempenho 2007 (ESCOLA REGIONAL DE ALTO DESEMPENHO, 2007) (no Fórum
de Iniciação Científica), em conjunto com uma abordagem introdutória sobre impacto da
criação dinâmica de processos com MPI-2, ausente nesta monografia.
4 Indica que a melhor solução só pode ser encontrada por meio da análise de todo o espaço combinatorial,
ou seja, possui complexidade exponencial quando executado em máquinas determinísticas.
18
1.5
Ferramentas Empregadas
Os exemplos e demonstrações cujos códigos são apresentados foram executados no
cluster labtec do GPPD da UFRGS. Ele executa sobre a distribuição LAM MPI 7.25 , uma
implementação de ambos os padrões MPI-1.2 e MPI-2.
Os códigos de programas expostos estão, predominantemente, em linguagem C. Em
alguns casos, quando o algoritmo seqüencial ganha foco, opta-se por mostrar sua implementação na linguagem Python, que fornece uma maneira mais próxima da descrição
matemática do problema. Os scripts apresentados são escritos em Bash Script6 . Estes
programas, por sua natureza e pelas características dos clusters, são executados em ambiente Linux.
1.6
Metodologia e Abordagem de Estudo
A cada capítulo apresentado procurou-se evidenciar o máximo de informações possíveis. No entanto, dependendo do interesse do leitor, pode-se dar enfoque a algum aspecto
específico da monografia. Por exemplo, se houver maior interesse na parte de implementação, os códigos do programa implementado estão disponíveis e comentados ao longo da
monografia. Se por outro lado, o interesse for na abordagem formal do problema e nos
resultados alcançados, não há necessidade marcante de se observar todos os trechos de
programa apresentados.
Procura-se tornar os capítulos o mais independentes possível, para se ajustar aos conhecimentos prévios que o leitor possui. Se já houver conhecimento da especificação MPI
ou do Problema da Mochila, por exemplo, pode-se dar um maior foco aos outros capítulos. Não se aconselha, no entanto, a omissão da leitura de algum capítulo; considerações
importantes sobre a implementação da solução do problema são feitas ao longo de todos
eles.
Os capítulos abordados, e seus respectivos temas, são:
Contexto Científico. Agrupa conceitos básicos e considerações relevantes encontradas
ao longo de todo o processo de montagem e seleção da bibliografia. Divide-se em
dois grandes tópicos (seções): Programação Paralela e Introdução ao MPI.
MPI. Aborda os comandos e primitivas básicas de MPI. Demonstra, através de exemplos,
as funções básicas e a construção de programas paralelos elementares no modelo
de troca de mensagens.
Problema da Mochila. Descreve detalhadamente o problema da Mochila, enumerando
sua definição e características. Apresenta uma solução seqüencial para este e também uma solução paralela, empregando MPI.
Workstealing. Introduz o conceito de Workstealing e discute seu emprego. Aborda, também, as modificações necessárias à resolução paralela do Problema da Mochila para
que se obtenha um algoritmo que aplique Workstealing.
Avaliação de Desempenho. Expõe os principais resultados obtidos na confecção da solução do problema. Vários aspectos são mensurados e, ao final, apresenta conclusões sobre estes resultados e o emprego da técnica proposta.
5 Mais
sobre a distribuição pode ser encontrado em http://www.lam-mpi.org/.
de script para o bash (bourne-again shell).
6 Linguagem
19
Conclusões. Enumera as principais conclusões inferidas ao longo do processo de confecção do trabalho. Relaciona conceitos e estabelece hipóteses sobre os resultados
apresentados. Ao término, traça um perfil dos trabalhos futuros a serem realizados.
1.7
Áreas de Interesse
Ao longo do desenvolvimento da monografia, são permeadas várias áreas da Ciência
da Computação e Matemática em geral; em especial, cada capítulo tem um foco distinto,
sendo interessante enumerar os conceitos básicos abordados ao longo de cada um:
Contexto Científico: Arquiteturas Paralelas, Programação Paralela, Programação Distribuída, Análise de Desempenho.
MPI: Programação Concorrente, Algoritmos e Programação, Estruturas de Dados, Classificação e Pesquisa de Dados, Técnicas de Construção de Programas, Algoritmos
Paralelos, Redes de Computadores.
Problema da Mochila: Programação Paralela e Distribuída, Complexidade de Algoritmos, Teoria da Computação, Pesquisa Operacional.
Workstealing: Algoritmos Paralelos, Sistemas Operacionais, Arquiteturas Paralelas, Técnicas de Otimização.
Avaliação de Desempenho: Probabilidade e Estatística, Complexidade de Algoritmos
Paralelos, Análise de Desempenho.
Conclusões: Teoria da Computação, Complexidade de Algoritmos, Arquiteturas Paralelas, Sistemas Operacionais, Programação Distribuída.
20
2
CONTEXTO CIENTÍFICO
Este capítulo descreve o atual estado da arte de tópicos fundamentais no desenvolvimento da monografia, sobretudo no que tange Programação Paralela e MPI. Dessa
maneira, o que se apresenta é a síntese e apontamento das direções da área, verificados na
bibliografia consultada; serve, ao mesmo tempo, de referência e de base para o restante
do conteúdo desenvolvido ao longo do estudo.
A abordagem adotada é mais conceitual. O leitor que deseje uma abordagem mais prática (sobretudo a presença de códigos) e focada em aplicações deve consultar os capítulos
posteriores.
2.1
Programação Paralela
Embora o enfoque do estudo seja a programação e codificação de algoritmos paralelos,
existe uma plataforma de hardware que desempenha papel essencial para o processamento
em alto desempenho e, sob certa perspectiva, torna-se o foco da área; a programação em
si passa a ser um artifício para se valer dos recursos disponíveis no hardware (GROPP;
LUSK; SKJELLUM, 1999).
Considerando esta importância, torna-se conveniente apresentar alguns conceitos da
parte física, a fim de contextualizar o leitor sobre o modelo de programação paralela e o
porquê deste modelo depender fortemente do hardware sobre o qual opera.
2.1.1
Hardware
A classificação original de computadores paralelos é conhecida como Taxonomia de
Flynn. Michael Flynn classificou as arquiteturas paralelas quanto ao número de fluxos
de dados e número de fluxo de instruções. A máquina de Von Neumman, precursora das
arquiteturas modernas, por exemplo, possui um fluxo de instrução e um fluxo de dados.
Desta maneira, é classificada como “single-instruction, single-data” (SISD). No extremo
oposto, temos o modelo “multiple-instruction, multiple-data” (MIMD), máquinas de vários processadores, executando instruções em paralelo sobre diferentes dados (FLYNN,
1972).
2.1.1.1
SISD
Máquinas nesta classificação remetem ao modelo clássico da máquina de Von Neumman; não há paralelismo e tudo é seqüencial. Neste modelo, há uma memória, representada por um grande bloco, e um processador. Entre processador e memória são movimentados dados e instruções, através de um barramento.
O “gargalo de Von Neumman” é justamente o acesso à memória. Por mais rápidos que
21
sejam os processadores, a latência de acesso à memória não decresce na mesma medida
em que a velocidade dos primeiros aumenta. Há, portanto, um decréscimo de desempenho
considerável. Como conseqüência, poucas máquinas, atualmente, seguem estritamente o
modelo de Von Neumman. Utilizam-se, na maioria das vezes, memórias hierárquicas, com
modelos baseados em memória cache1 , que aproveitam-se do princípio da localidade2
para aumentar o desempenho (PATTERSON; HENNESSY, 2005).
É possível estender a arquitetura SISD através de pipelining e o processamento vetorial. Pipelining é uma técnica bastante conhecida e melhora o aspecto do desempenho
ao quebrar instruções de máquina em microinstruções; dessa maneira, é possível começar
o processamento de uma instrução posterior sem que o ciclo da instrução corrente esteja
finalizado. Ser vetorial significa, em última instância, adicionar novas instruções ao conjunto de instruções da máquina para que ela forneça o processamento do mesmo comando
para vários dados em paralelo.(NAVAUX; ROSE, 2003)
É importante salientar que há certa discordância sobre a natureza dos processadores
vetoriais. Existem classificações em que, por exemplo, estes processadores capazes de
operar sobre vetores são vistos como máquinas SIMD (TANENBAUM, 1995). Esta classificação acaba variando entre autores; alguns afirmam que máquinas vetoriais são MISD,
outros que máquinas MISD sequer existem (são, portanto, variações de SIMD) e, ainda,
há aqueles que nem classificam as máquinas vetoriais como paralelas. Esta falta de uniformidade é natural; fluxos de instruções são abstrações em nível de hardware e, portanto,
são relativos (PACHECO, 1997).
O principal ponto fraco destas técnicas é que processadores vetorias e processadores
pipeline, em geral, não “escalam” bem, isto é, não são facilmente modificáveis, do ponto
de vista do hardware, para processar desafios maiores (NAVAUX; ROSE, 2003).
2.1.1.2
SIMD
Há distinção entre um sistema SIMD puro e processadores vetorias. A definição canônica de um processador SIMD é ter uma UCP mestra e várias subordinadas; a cada ciclo,
a UCP mestra faz o broadcast de uma instrução para as UCPs subordinadas operarem
sobre sua pequena porção de memória. Assim, as UCPs subordinadas ou executam essa
instrução ou ficam ociosas. Diante deste enquadramento, máquinas vetoriais são vistas
como monoprocessadas e este processador é que tem extensões multiprocessadas. Conforme mencionado anteriormente, no entanto, essa definição é relativa (NAVAUX; ROSE,
2003).
A desvantagem de sistemas SIMD reside nos códigos com muitos branches condicionais ou que dependam muito de estruturas condicionais. É muito provável que vários
processos fiquem ociosos por vários períodos de tempo. Seu principal trunfo é a fácil
programação (se o problema abordado possui uma estrutura regular). O custo de comunicação é alto, mas igual ao de máquinas MIMD de memória distribuída (revisadas adiante).
Por fim, elas possuem excelente escalabilidade (TANENBAUM, 2003).
2.1.1.3
MISD
De acordo com a Taxonomia de Flynn, não existem máquinas que satisfaçam esta
classificação (PATTERSON; HENNESSY, 2003). Alguns autores (ROOSTA, 1999) clas1 Memórias
de tamanho e velocidade de acesso intermediário entre memória principal e os registradores
(PATTERSON; HENNESSY, 2005).
2 Tal princípio enumera que dados utilizados em lugares próximos na execução do programa tendem a
estar dispostos assim também na memória (PATTERSON; HENNESSY, 2005).
22
sificam máquinas vetoriais como máquinas MISD, mas a bibliografia não é uniforme
sobre o assunto.
2.1.1.4
MIMD
A diferença fundamental entre máquinas MIMD e SIMD é que os processadores
MIMD são autônomos, não precisam todos executar o mesmo código (todos os processadores são UCPs individuais, com seus respectivos componentes). Ao contrário de máquinas SIMD, modelos MIMD são assíncronos (sem relógio global) e devem ser programados para se sincronizarem, se esta for a intenção. O mundo MIMD é dividido em dois
blocos: os de memória compartilhada (multiprocessadores) e os de memória distribuída
(multicomputadores) (TANENBAUM, 1995).
Multiprocessadores são um conjunto de processadores e módulos de memória ligados
por uma rede. Desta maneira, podem se classificar como (NAVAUX; ROSE, 2003):
Arquitetura baseada em barramento. É a mais simples forma de conexão. Acesso à
memória através de barramento. Não se comporta bem para um grande número de
processadores, visto que o barramento fica sobrecarregado. Justamente por isso,
não é uma solução escalável em grande porte. Para contornar este problema, usualmente os processadores têm grande quantidade de memória cache.
Arquitetura baseada em switches. Usa switches para gerenciar o acesso aos módulos
da memória. Um exemplo disso é uma arquitetura do tipo “cross-bar switch”. Esta
arquitetura se caracteriza por um engranzamento (mesh) retangular, com switches
nas intersecções e terminais nas bordas esquerda e superior. Processadores e módulos de memória podem ser conectados aos terminais. Os switches podem permitir
que um sinal se propague na vertical e horizontal simultaneamente ou redirecionar
o sinal de um eixo para o outro. Então, por exemplo, se tivermos processadores
à esquerda e memória acima, qualquer processador pode acessar qualquer módulo
de memória ao mesmo tempo que algum outro processador acessa algum outro
módulo. Assim, não se sofre com o problema de saturação encontrado em barramentos. Infelizmente, este tipo de arquitetura tende a ser muito caro. Uma matriz
m × n requereria m × n switches. Logo, a maioria destes sistema são pequenos.
Multiprocessadores, independentemente da disposição física dos módulos de memória, possuem um sistema de memória compartilhada, ou seja, todos os processadores têm
uma visão lógica de um único espaço de endereçamento pertencente a uma memória global (PATTERSON; HENNESSY, 2003). Um esquema de um multiprocessador clássico é
apresentado na Figura 2.1.
No sistema de Multicomputadores, cada processador tem sua memória privativa, ou
seja, é um sistema de memória distribuída. Isto significa, basicamente, que cada processador tem um espaço de endereçamento próprio e diferente dos demais (TANENBAUM,
1995). Se observarmos tais sistemas como grafos, veremos dois tipos de grafos: aqueles
cujos vértices correspondem ao par processador/memória (nodos) e aqueles em que alguns vértices são nodos e outros switches. Redes do primeiro tipo são “redes estáticas” e
do segundo tipo, “redes dinâmicas” (PATTERSON; HENNESSY, 2005). Uma ilustração
de um sistema multicomputador pode ser visto na Figura 2.2
Dos quatro modelos propostos pela Taxonomia de Flynn, o que se torna relevante
para o estudo são as máquinas MIMD; clusters e grids são exemplos clássicos de máquinas MIMD e, atualmente, são as estruturas funcionais que oferecem um maior poder de
processamento em razão de sua escalabilidade (DONGARRA et al., 2003).
23
Figura 2.1: Modelo clássico de multiprocessadores.
Figura 2.2: Modelo clássico de multicomputadores.
24
Um cluster é um computador paralelo, com um ponto de acesso centralizado. A visão
que o usuário tem ao programar é de um grande computador com vários processadores
paralelos. Grids, por outro lado, são computadores naturalmente heterogêneos, com pontos de acesso distribuídos. Além disso, a disposição e tipo dos nodos, dependendo da
arquitetura da máquina, pode sofrer alterações a qualquer momento. O usuário enxerga
o grid como um agregado de computadores que aproveitam os tempos ociosos uns dos
outros para melhorar o desempenho. (NAVAUX; ROSE, 2003).
2.1.2
Modelos Computacionais e Comunicação
Além de ter definido um hardware (máquinas MIMD, no caso), é necessário definir
um modelo de computação paralela que rode sobre este hardware. Modelos computacionais
[...] são uma visão conceitual dos tipos de operações disponíveis ao
programa. Estes não incluem sintaxe especifica de uma linguagem de
programação ou biblioteca em particular, e são (quase) independentes
do nível de hardware que os suporta (GROPP; LUSK; THAKUR, 1999,
p. 3).
Alguns exemplos de tópicos que influem sobre a classificação são o compartilhamento
(ou não) de memória, a quantidade de comunicação que está no hardware ou software, a
unidade de execução básica e etc.
Alguns modelos-exemplo são (GROPP; LUSK; THAKUR, 1999):
Paralelismo de Dados. Introduzido com os processadores vetoriais que, à época, ofereciam paralelismo apenas a nível de dados; o programa era, sob todos os outros
aspectos, seqüencial, com instruções que operassem em paralelo. Com o passar do
tempo, o paralelismo de dados passou a ser enquadrado mais como uma técnica
de programação do que uma arquitetura propriamente dita. O modelo, no entanto,
continua o mesmo: todo o paralelismo continua advindo dos dados. Muito desse
trabalho, hoje em dia, é feito pelo compilador.
Exemplo de implementação: HPF (High Performance FORTRAN) (KOELBEL;
LOVEMAN; SCHREIBER, 1993).
Memória Compartilhada. No modelo oposto ao Paralelismo de Dados, o paralelismo
não é extraído da independência implícita de certa parte do código seqüencial. Ao
invés disso, o paralelismo é explicitado diretamente pelo programador, sendo chamado de Paralelismo de Controle3 .
Memória compartilhada é uma técnica de comunicação no Paralelismo de Controle.
Através dela todos os processadores acessam todo o espaço de endereçamento da
memória e, portanto, compartilham variáveis. É um modelo extremamente eficiente, pois não envolve custos de comunicação, já que ela é toda feita através da
memória principal. Este modelo, no entanto, traz dois inconvenientes:
1. Repassa as questões de sincronização para o programador; a consistência das
variáveis deve ser garantida no nível de programação.
3 Muito
embora (GROPP; LUSK; THAKUR, 1999) omita qualquer referência, um modelo misto é perfeitamente factível; nada impede a existência de um modelo que possui, simultaneamente, paralelismo
implícito e explícito.
25
2. Para mais do que algumas dezenas de processadores o acesso à memória fica
comprometido, pois existe disputa pelo barramento e por células de armazenamento. Logo, o desempenho também acaba sofrendo um compromentimento.
Exemplo de implementação: OpenMP (Open Multi-Processing) (OPENMP C AND
C++ APPLICATION PROGRAM INTERFACE, 2002)
Troca de Mensagens. É um modelo conceitualmente oposto ao modelo de Memória
Compartilhada. Ao invés de uma memória global, acessada e compartilhada por
todos, existe apenas uma memória local para cada processador, cujo único postulante a acessar é o próprio. Qualquer troca de informações é feita pelo envio
e recebimento de mensagens, através de primitivas especiais. É um modelo mais
seguro em termos de sincronização, mas também é mais caro, pois trocas de mensagens são típicas operações de E/S. Baseia-se no uso de primitivas do tipo send()
e receive() sincronizadas entre processos. É, portanto, um modelo mais complexo do ponto de vista do programador.
Exemplo de implementação: PVM (Parallel Virtual Machine) (GEIST et al., 1994)
e MPI (GROPP; LUSK; SKJELLUM, 1999).
Operações em Memória Remota. É um misto dos modelos de Memória Compartilhada
e Troca de Mensagens. Existe uma única memória, acessada por todos (mas não diretamente), e cada processador também tem uma memória local disponível. A memória global é acessada via primitivas (tipicamente put() e get()) para escrita
e leitura. Esta medida implica, diretamente, que os processos não precisam mais
sincronizar-se para trocar informações; uma primitiva send() não mais necessita
de um correspondente receive(). Esta estrutura remete ao modelo clássico de
Distributed Shared Memory (NAVAUX; ROSE, 2003).
Este modelo consegue simplificar a complexidade da programação com troca de
mensagens e soma algum desempenho de um modelo com memória compartilhada,
uma vez que o acesso à memória passa a ser mais eficiente sem a sincronização
de primitivas de E/S. Além disso, tal modelo inclui um novo recurso chamado de
“Mensagem Ativa”, que consiste na execução de subrotinas de um processo no espaço de endereçamento de outro. Tal operação geralmente é usada para fins de cópia
de memória remota, emulando send() e receive() de maneira unilateral.
Exemplo de implementação: a especificação MPI-2 (GROPP; LUSK; THAKUR,
1999) fornece recursos para programação com operações em memória remota.
Threads. Os primeiros modelos de Memória Compartilhada usavam acessos a espaços
de endereçamento locais. A obtenção de memória compartilhada era feita através
de uma primitiva especial (parecido com malloc() da linguagem C). Hoje em
dia, no entanto, se assume um modelo onde toda a memória é distribuída. Isto dá
espaço para a aplicação do modelo em sistemas multithread.
Tendo-se um processo como a definição intuitiva de um programa em execução
(TOSCANI; OLIVEIRA; SILVA CARíSSIMI, 2002), uma thread é um fluxo de
execução de processo4 (TOSCANI; OLIVEIRA; SILVA CARíSSIMI, 2003). Qualquer comunicação entre threads é feita através da memória principal, compartilhada. Variáveis locais, no entanto, ainda permanecem privativas para cada thread.
4 Na
verdade, pode-se, ainda, definir um fluxo de execução como registradores, Program Counter, Stack
Pointer e alguns outros dados de contexto fortemente dependentes da arquitetura.
26
Como o chaveamento de uma thread para outra não requer ação explícita de programador, tal modelo aproxima-se de um modelo de Programação Paralela mesmo
que, muitas vezes, seja executado em máquinas monoprocessadas.
Exemplo de implementação: Pthreads (Posix Threads) (BUTENHOF, 1997).
Dentre os modelos de programação paralela, atualmente, o modelo mais utilizado na
programação de clusters é o de comunicação por troca de mensagens. Tal modelo também
é implementado pela MPI, como será visto adiante. Alguns fatores que contribuem para
esta estatística são (GROPP; LUSK; THAKUR, 1999):
Universalidade. A maioria das máquinas paralelas atuais são agregados de processadores conectados por uma rede de interconexão. Troca de mensagens ajusta-se bem
a este tipo lógica computacional; adapta-se, por tanto, com facilidade ao hardware
atual.
Expressividade. Algoritmos paralelos tendem a ser melhor expressos em termos de troca
de mensagens do que leituras/escritas em memória global, por permitirem certo
controle sobre a sincronização dos eventos.
Facilidade de depuração. Depurar programas paralelos permanece sendo um desafio
considerável na área. Embora depuradores sejam mais facilmente construídos no
modelo de memória distribuída, o processo de depuração, em si, é melhor realizado no modelo de troca de mensagens, visto que a maioria dos erros ocorre por
sobrescrita inesperada na memória.
Performance. No sistema de memória compartilhada existe apenas uma memória global, com uma hierarquia de cache e memória para todos os processos. Embora o
acesso a esta memória seja mais rápido que a sincronização por send()/
receive(), não é possível aproveitar todo o potencial do sistema de hierarquias,
causando sério prejuízo ao desempenho. O modelo de comunicação por troca de
mensagens associa trocas de dados e consultas à memória a cada processo; é uma
abordagem mais eficiente para o aproveitamento de princípio da localidade5 , fundamental para o melhor desempenho da cache do sistema.
2.1.2.1
Arquiteturas Multicore
Processadores da vários núcleos encontram-se numa fase de transição entre arquiteturas de 2 e arquiteturas de 4 núcleos. Atualmente a comunicação entre os núcleos é feita
via modelo de Memória Compartilhada (memória principal), variando o comportamento
da memória cache (compartilhada ou privativa) de acordo com o modelo de processador
(CREEGER, 2005).
No entanto, com o aumento considerável do número de núcleos, tal modelo se torna
problemático, visto que há disputa intensa por barramentos. O modelo de comunicação
que será adotado no futuro ainda é uma questão em aberto, mas há certas tendências de
que se adote um modelo próximo à troca de mensagens; o Intel TeraFLOP, máquina com
80 núcleos da Intel, adota uma abordagem NOC (Network on Chip) para fazer comunicação entre os seus processadores, com emprego de roteadores e switches, onde dados são
5 Baseia-se
no fato de que dados próximos tendem a ser usados em trechos próximos do código. Trata-se
de uma das bases da utilização de uma hierarquia de memória.
27
transferidos como numa rede de computadores clássica (TCP/IP) (MATTSON; HENRY,
1998).
Arquiteturas Multicore proporcionam abordagens mistas de alguns modelos. O uso
destes chips em clusters tende a seguir um destes modelos mistos. Internamente, em cada
chip multicore, usa-se o modelo de threads entre os processos do Sistema Operacional (e
um modelo de memória compartilhada entre os núcleos), o que garante uma comunicação
extremamente rápida. Cada nó, entretanto, comunica-se por um modelo de Troca de
Mensagens, através de uma rede específica.
2.1.3
Desempenho
Até agora todo embasamento apresentado para o uso de programação paralela foi
conceitual; organização do hardware e comunicação de processos foram o tema com
maior ênfase. Existe, no entanto, um embasamento teórico importante para o emprego de
Programação Paralela. É essencial que exista um modelo de referência para a medição e
formalização dos conceitos de desempenho e eficiência.
Um conceito fundamental na área de programação paralela é o conceito de speedup.
Speedup é o ganho de desempenho proporcionado pela adoção de um algoritmo paralelo
(DONGARRA et al., 2003); Uma abordagem para avaliação do speedup é a razão entre
o número de operações realizadas por uma execução seqüencial e o número de operações realizada por uma UCP na execução em paralelo (JAJA, 1992). Define-se a notação
utilizada na Tabela 2.1
Notação
P
A
n
p
T ∗ (n)
Tp (n)
S p (n)
Significado
Problema computacional.
Algoritmo paralelo utilizado.
Tamanho da entrada.
Número de processadores (UCPs)
Número de operações do melhor algoritmo
seqüencial para uma entrada de tamanho n.
Número de operações para cada UCP do algoritmo A para p processadores e uma entrada de
tamanho n.
Speedup obtido com p processadores e uma entrada de tamanho n.
Tabela 2.1: Notação do cálculo do Speedup.
A partir da notação empregada, define-se speedup como:
S p (n) =
T ∗ (n)
Tp (n)
(2.1)
É importante ressaltar que a base da fórmula é o melhor algoritmo seqüencial para o
problema (T ∗ (n)) e não o algoritmo paralelo executado com 1 processador (T1 (n)); embora seus tempos de execução sejam bem próximos, existem alguns testes feitos pelo algoritmo paralelo que independem do número de processadores que participarão da execução
(e.g., teste que determina o número de processadores ativos) e que adicionam elementos
ao cálculo da complexidade sem relação com o problema em si (JAJA, 1992). Outro
ponto que se faz destacar é que deve ser utilizado o melhor algoritmo seqüencial para este
28
mesmo cálculo. Dessa maneira, tal algoritmo não necessita seguir o modus-operandi do
algoritmo paralelo; ambos podem resolver o problema de maneira distinta.
Embora adequada, tal abordagem (número de operações executadas) é complexa de
ser precisada e medida, visto que é difícil definir precisamente o conceito de operação.
Logo, adota-se uma abordagem mais simples para o speedup, onde este é definido como
a razão entre tempo de processamento em um processador e o tempo de processamento
em uma configuração paralela (DONGARRA et al., 2003). Tem-se, então, a equação:
Speedup(n) =
T (1)
T (n)
(2.2)
A medida que n (número de processadores) cresce, T (n) (tempo de execução para n
processadores) decresce e o speedup aumenta.
É interessante, também, apresentar o conceito de eficiência. A eficiência é a medida
de utilização efetiva de todos os p processadores em uma execução paralela; representa,
conceitualmente, o quanto o desempenho se beneficia do aumento do número de processadores. Um índice de eficiência máximo (E p (n) = 1) é obtido quando a execução de A
com p processadores é p vezes mais rápida do que a execução de A com 1 processador
(JAJA, 1992).
Pode-se expressar eficiência pela fórmula
E(p) =
T (1)
p × T (p)
(2.3)
O ideal seria que o tempo de A se dividisse com o número de processadores; a execução com quatro 4 processadores, por exemplo, seria 4 vezes mais rápida que a execução
com 1 processador. Sob esta óptica, torna-se óbvio que, ao se multiplicar o tempo de
execução de A com 4 processadores pelo número total de processadores obtém-se, novamente, o tempo de execução com 1 processador.
Isto, no entanto, se configura somente numa situação ideal. Na maior parte dos casos,
0 < E p (n) < 1. Ocorre que existem custos de comunicação e sincronização que consomem tempo significativo durante a execução do algoritmo.
Por fim, é importante ressaltar que a eficiência é calculada com a base do algoritmo
paralelo com 1 processador e não com o algoritmo seqüencial; conforme explicitado na
parte sobre speedup, o algoritmo paralelo não necessita ser uma mera versão portada do
seqüencial e, dessa maneira, não discorre sobre a escalabilidade do algoritmo ao variarse o número de processadores. Esta abordagem está mais de acordo com o cálculo do
speedup simplificado apresentado anteriormente.
Com a base teórica sobre arquiteturas de computadores e análise de desempenho já
estabelecida, falta, ainda, apresentar o ferramental empregado em nível de software para
a programação paralela, o MPI. A próxima seção trata disso.
2.2
Introdução ao MPI
MPI não é uma biblioteca, uma linguagem ou um jeito novo de programar. MPI é, na
verdade, a especificação de uma interface para a implementação do modelo de comunicação por troca de mensagens (PACHECO, 1997). Esta seção foca a história, a filosofia
de implementação e as motivações que levaram à especificação. Um aprofundamento
técnico, com detalhes de implementação e primitivas básicas, pode ser visto no Capítulo
3.
29
2.2.1
Motivação
O padrão de comunicação por troca de mensagens demorou a se consolidar. A principal razão para essa situação ter ocorrido foi a falta de um padrão de fato a ser adotado
pelos usuários de máquinas paralelas. Desta maneira, cada vendedor implementava bibliotecas para troca de mensagens funcionais no seu sistema; emergia, assim, a falta de
portabilidade de tais sistemas, ao mesmo tempo em que, por se tratar de uma área nova, a
Computação Paralela não havia sido suficientemente experimentada para que se abstraíssem os conceitos mais úteis na implementação de algoritmos (GROPP; LUSK; THAKUR,
1999).
Surgiram muitas tentativas dentro da comunidade científica para a implementação de
um padrão (e.g., PVM). Tal diversidade foi proveitosa para o desenvolvimento do estado
da arte do tópico, mas acabou por gerar acirrado conflito e concorrência; cada novo padrão
vinha a fragmentar uma parcela dos usuários o que resultava em pessoas especialistas
em um ou outro destes padrões. Um grande entrave na utilização generalizada destas
bibliotecas é que elas não se comunicavam, ou seja, não havia portabilidade de um sistema
para o outro (DONGARRA et al., 2003).
Havia, então, uma dualidade na área, consistindo de adotar um sistema extremamente
portável (em geral, “meta-bibliotecas” sobre as bibliotecas existentes), mas escasso em
funcionalidades, ou um sistema robusto, com muitas funções, mas de uso muito específico. Sockets, por exemplo, consistem no uso mais generalizado que se poderia implementar troca de mensagens em Programação Paralela. No entanto, as mais diversas
implementações de sockets não oferecem qualquer facilidade em termos de algoritmos
paralelos. (GROPP; LUSK; THAKUR, 1999).
2.2.2
História
A diversidade de soluções para a implementação de um modelo de comunicação por
troca de mensagens, que introduziu as questões acima, atingiu um ponto largo de saturação; um padrão que aliasse portabilidade, performance e recursos passou ser um requisito
demandado pela comunidade de usuários e fornecedores.
Na conferência Supercomputing ’92 (em Novembro), formou-se um comitê para a
criação de um padrão a ser adotado na implementação do supracitado modelo. Uma enumeração dos principais objetivos deste comitê seria (GROPP; LUSK; THAKUR, 1999):
• definir um padrão portável para a troca de mensagens, que não seria uma padrão
oficial, mas que potencialmente atrairia implementadores e usuários;
• operar de uma maneira completamente aberta, permitindo a todos juntarem-se às
discussões; e
• ser terminado em um ano.
Dos contrastes entre os três objetivos, foi completado, em Maio de 1994, a referência
para o padrão MPI. O esforço para a criação do padrão foi vívido e ativo, fruto da participação ampla de uma comunidade focada. Adicionalmente, cumpriu sua meta ao atrair
tanto implementadores quanto usuários, em grande parte por ser aberto a qualquer opinião; ambos os pontos de vista puderam ser discutidos. Assim, após sucessivas reuniões
do fórum no período entre 1993 e 1995, surgia o MPI (GROPP; LUSK; THAKUR, 1999).
30
Em 1995-1997 o fórum tornou a deliberar sobre extensões à norma MPI original;
foram incluídos adventos para E/S paralela, gerenciamento dinâmico de processos, operações em memória remota e vários outros recursos. Como resultado deste esforço, o MPI
foi estendido para sua segunda versão, o MPI - 2.
2.2.3
Definição e Características
MPI é uma especificação de uma biblioteca para a troca de mensagens em C, C++
e FORTRAN, que especifica como devem ser (sintática e semanticamente) as estruturas
para estes fins nestas três linguagens (e.g., Funções, Classes, Subrotinas, etc.). MPI não
é uma implementação específica; qualquer interessado pode implementar o padrão da
maneira que preferir. Um programa MPI corretamente escrito deve rodar em todas as
implementações MPI (GROPP; LUSK; THAKUR, 1999).
Além das estruturas de send() e receive() básicos, MPI provê, também, estruturas para a comunicação coletiva, ou seja, computações que envolvem todos ou um grande
número de processos. Tal comunicação pode envolver o transporte de dados (e.g., primitivas do tipo send_broadcast()) ou computações coletivas (e.g., mínimos, máximos,
soma, etc.) (PACHECO, 1997).
MPI oferece suporte para vários tipos de primitivas send() e receive(). Pode-se
usá-las em modo bloqueante, esperando que a operação de comunicação se realize para o
prosseguimento da computação, ou não-bloqueante, caso contrário. Além disso, existem
outros modos, frutos da combinação dos anteriores, como (GROPP; LUSK; THAKUR,
1999)
Modo Síncrono. Primitivas do tipo send() só desbloqueiam quando o respectivo receive() houver ocorrido;
Modo de Prontidão (Ready). Para send(), provê ao programador maneiras de avisar
o sistema do uso de uma primitiva receive() já postada no processo destino,
para que a camada de baixo use um protocolo mais rápido, se disponível;
Modo Buffered. Operações de send() têm o controle de buffer feito pelo usuário; e
Modo Padrão. É a configuração normal das primitivas send() e receive() do MPI.
O receive() é bloqueante e o send() é bloqueante apenas enquanto o buffer
não for liberado.
Outra característica interessante do MPI são as topologias virtuais, ou seja, modelos
de disposição da comunicação entre processos (e.g., árvore, grafo, plano cartesiano, etc.)
independentes da real implementação (física) dos processadores sobre os quais estes rodam. Com elas, provém-se um método em alto-nível de manipular-se grupos de processos
e, ao mesmo tempo, abstrair sua localização real (DONGARRA et al., 2003).
Uma característica marcante, insistentemente trabalhada no desenvolvimento da especificação MPI, é o fato de que o suporte à múltiplos recursos é intrínseco à norma. Destes,
se destacam (GROPP; LUSK; SKJELLUM, 1999):
• O suporte à depuração nativo, através de ferramentas que permitem colocar “ganchos” ao longo do código e, posteriormente, interceptar chamadas MPI através desses mecanismos;
31
• O suporte à implementação de bibliotecas paralelas personalizadas, que usam o
MPI como base. Devido à noção de comunicadores, é possível construir bibliotecas independentes de código de usuário, com atributos e controladores de erros
personalizados.
Uma das capacidades-chave especificadas no MPI é sua característica de executar em
redes heterogêneas, pois tem uma definição de tipos portável e compatível com diferentes
arquiteturas. Desta maneira, diferentes tipos e estruturas de dados são convertidos de
acordo com a implementação do MPI; os implementadores têm total liberdade em decidir
como as conversões são realizadas, de acordo com a especificação.
Outra característica (que dá suporte a redes heterogêneas) é a abstração entre processadores e processos no sistema. A especificação MPI fala de processos e não de processadores; vai depender da implementação o número máximo de processos que podem
executar sobre um único processador. Além disso, também fica a cargo dos implementadores definir as fronteiras, para a respectiva biblioteca, entre nós (no caso clusters) e
processadores, pois um nó pode conter n processadores e é necessário estabelecer esta
relação com os processos MPI. Normalmente, um processo MPI corresponde a um processo de Sistema Operacional. No entanto, isto não é necessariamente verdade; existem
implementações sobre o UNIX em que vários processos MPI são mapeados sobre um
processo UNIX (GROPP; LUSK; THAKUR, 1999).
Unindo uma definição de tipos suficientemente portável e uma política flexível de
mapeamento processador-processo, MPI consegue dar uma visão de máquina virtual ao
usuário; pode-se enxergar apenas processos e mensagens, de acordo com a topologia
desejada (por meio das topologias virtuais) sem a preocupação do hardware sobre o qual
este sistema opera. Tal flexibilidade fornece ao MPI uma grande robustez no quesito
portabilidade, conforme almejado na sua feitura (PACHECO, 1997).
32
3
MPI
Este capítulo versa sobre aspectos mais técnicos e aplicados da especificação MPI
(versão 1.2). Serão apresentadas e descritas as primitivas básicas e exemplos de código
(comentados) serão utilizados para exemplificar seu uso.
3.1
As Seis Primitivas Básicas
MPI é uma especificação abrangente, com muitos recursos. No entanto, para qualquer
algoritmo paralelo que se queira utilizar, MPI pode ser tão simples quanto o uso de 6
funções básicas. Com estas funções, é possível implementar quase qualquer programa
MPI; a maioria das outras subrotinas são simplificações e combinações destas primitivas
de acordo com estruturas comuns em Programação Paralela.
A Tabela 3.1 mostra as seis primitivas principais usadas no MPI.
Primitiva
Função
MPI_Init
Inicializa o MPI
MPI_Finalize
Encerra o MPI.
MPI_Comm_size Retorna o número de processos rodando.
MPI_Comm_rank Retorna que processo eu sou.
MPI_Send
Manda uma mensagem.
MPI_Recv
Recebe uma mensagem.
Tabela 3.1: As seis funções básicas do MPI.
3.1.1
Inicialização e Finalização
MPI possui duas primitivas destinada a isso, cujo protótipo é apresentado na Figura
3.1.
int MPI_Init(int* argc char** argv[])
int MPI_Finalize()
Figura 3.1: Protótipos de MPI_Init e MPI_Finalize.
Todo o código MPI (ou seja, que se utilize de funções da biblioteca de implementação do MPI) utilizado deve estar contido entre estas duas primitivas; elas se encarregam
de inicializar e instanciar estruturas de dados em um nível de abstração transparente ao
usuário e também desalocam estas estruturas ao sair da área delimitada. Uma exceção a
33
esta regra é a função MPI_Wtime, utilizada para medição de tempos, que será visitada
mais adiante.
3.1.2
Identificação de Processos e Comunicadores
É muito útil agrupar-se os processos de um modo conveniente para a resolução do
problema (e.g., processos produtores e consumidores de recursos). MPI oferece duas
estruturas para esta manipulação: grupos e comunicadores.
Um grupo é simplesmente um conjunto de processos, sem qualquer propriedade sobre
sua disposição. Pode ser usado como referência para a inclusão ou exclusão de processos
em um canal e enxerga somente os processos de seu escopo. Grupos possuem um tipo
específico em MPI, o MPI_Group.
A peça fundamental para a execução de um programa MPI, no entanto, são os comunicadores. Pode-se entender um comunicador como um agregado que guarda informações
de um grupo de processos e da disposição (e.g., grafo, árvore, plano cartesiano) pela qual
estes processos relacionam-se. Desta maneira, esta estrutura permite referenciar um dado
processo através de um identificador de processo, que, a priori, deixa transparecer sua
topologia. Este é o mecanismo básico para se referenciar um processo; a fonte e o destino
de qualquer mensagem é identificado univocamente por um identificador e o comunicador
que fornece aquela referência.
A princípio, durante a execução de um programa MPI, todos os processos pertencem
ao comunicador MPI_COMM_WORLD, uma constante da especificação que fornece um
identificador de processo único e seqüencial (um inteiro) para todos os processos que
foram disparados na inicialização do programa. O usuário pode, quando desejar, criar
novos comunicadores, com todos ou com somente alguns processos, na topologia em
que preferir. Também é possível extrair o grupo de processos de qualquer comunicador,
inclusive o MPI_COMM_WORLD.
Existem duas funções de suma importância para o MPI que fazem uso de comunicadores. Elas são apresentadas na Figura 3.2
int MPI_Comm_size(MPI_Comm comm, int* size)
int MPI_Comm_rank(MPI_Comm comm, int* rank)
Figura 3.2: Protótipos de MPI_Comm_size() e MPI_Comm_rank().
A função MPI_Comm_size retorna o tamanho do comunicador (número de processos contidos) comm na variável size. Ao ser usado em conjunto com MPI_COMM_WORLD
como argumento, retorna o número total de processos participantes da execução.
MPI_Comm_rank retorna o identificador do processo dentro do comunicador passado como parâmetro. Se utilizado com MPI_COMM_WORLD, retorna o identificador do
processo dentre todos os processos que estão sendo executados. Neste caso, seu rank é
um número que varia, para p processos, de 0 até p − 1.
3.1.3
Primeiro Exemplo
A seguir é apresentado o código de um programa simples, que exibe uma mensagem
na tela no clássico estilo “Hello World”. O código não inclui troca de mensagens, a fim
de refletir o que foi apresentado até aqui e apresentar o uso das primitivas MPI_Init,
MPI_Finalize, MPI_Comm_size e MPI_Comm_rank.
34
Para executar o programa é necessário utilizar os arquivos de inclusão vistos na Figura
3.3.
#include <mpi.h>
#include <stdio.h>
Figura 3.3: Arquivos de inclusão MPI.
O arquivo mpi.h contém toda a especificação e os protótipos para a utilização das
bibliotecas MPI. O arquivo stdio.h, conforme conhecido, inclui funções para a manipulação de E/S num terminal de execução.
As variáveis utilizadas no código são vistas na Figura 3.4
int rank;
int p;
/* Rank of process. */
/* Number of processes. */
Figura 3.4: Variáveis do programa Hello World MPI.
A variável rank armazenará o identificador do processo. A variável p, por sua vez,
armazena o número de processos. O que o programa faz é, para cada processo, imprimir
seu identificador. Adicionalmente, faz distinção entre dois processos: o processo raiz e
o processo de maior rank. Esta divisão não é necessária no momento; mais adiante, nos
exemplos posteriores, ela revelará utilidade fundamental. O código do programa pode ser
visto na Figura 3.5.
Para compilar o programa, é necessário fazer a ligação com a biblioteca MPI, nomeada de maneira específica em cada distribuição. Para facilitar o trabalho de pesquisa,
existe um script, incluído na maioria das distribuições MPI, que faz essas ligações de
maneira transparente ao usuário, chamado mpicc. Para compilar o código apresentado, assumindo que o nome do arquivo seja hello_world.c e querendo um nome
de HelloWorld para o executável, basta invocar
mpicc -o HelloWorld hello_world.c
e, se nenhum erro ocorrer, o executável HelloWorld será criado.
No entanto, não basta apenas compilar o programa de maneira acertada; é preciso
executá-lo apropriadamente. Os processadores e arquiteturas atuais não “entendem” a
alocação de processos MPI; não há qualquer suporte no nível de hardware que distribua
os processos MPI e os mapeie sobre processos do Sistema Operacional. Logo, é necessário uma camada de software (em geral, um processo de Sistema Operacional no modo
daemon1 ) a qual se encarregará de realizar este mapeamento (e.g., o processo lamd na
distribuição LAM-MPI) e, também, de se comunicar com outras máquinas, com o mesmo
serviço, especificadas para participarem da computação.
Após o processo daemon ser executado e estiver rodando (tal processo e a maneira de
fazê-lo é específico de cada distribuição) é necessário utilizar um comando que diga a este
processo qual executável será disparado para a criação dos processos MPI. Para facilidade
1 Para
todos os efeitos, dizer que um processo está em modo daemon é o mesmo que dizer que sua
execução é feita em segundo plano pelo Sistema Operacional
35
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
#include <mpi.h>
#include <stdio.h>
int
main(int argc, char *argv[])
{
int process_rank;
/* Rank of the process.
int p;
/* Number of processes.
*/
*/
MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &process_rank);
MPI_Comm_size(MPI_COMM_WORLD, &p);
if ( process_rank == 0 ) {
printf("Greetings from process %d, the root!\n",
process_rank);
}
else if ( process_rank == p-1 ) {
printf("Greetings from process %d, the greatest rank!\n",
process_rank);
}
else {
printf("Greetings from process %d!\n", process_rank);
}
MPI_Finalize();
return 0;
}
Figura 3.5: Programa “Hello World” sem troca de mensagens.
36
do usuário, as distribuições conhecidas também utilizam-se de um script padrão para fazêlo, o mpiexec2 . A utilização do mpiexec, com o exemplo mostrado, é
mpiexec -n 4 HelloWorld
através do parâmetro -n, informa-se a quantidade de processos MPI que serão disparados.
Neste caso, são 4 processos.
MPI implementa um padrão de réplicas; todos os processos MPI rodam sobre exatamente o mesmo código e, por conseqüência, todos os processadores também. Se é necessário que algum processo específico tome ações diferentes dos demais, pode-se testar o
rank do processo, conforme demonstrado no exemplo. Em geral, a diferenciação mais recorrente é o processo zero ser o processo mestre em um algoritmo do tipo mestre-escravo
(DONGARRA et al., 2003).
A execução desse programa deve transcorrer sem complicações, produzindo o resultado esperado. Um exemplo de resultado de uma execução é visto na Figura 3.6. Outro
resultado de execução possível é mostrado na Figura 3.7.
Greetings
Greetings
Greetings
Greetings
from
from
from
from
process
process
process
process
0, the root!
1!
2!
3, the greatest rank!
Figura 3.6: Possibilidade de saída - primeiro exemplo.
Greetings
Greetings
Greetings
Greetings
from
from
from
from
process
process
process
process
1!
2!
3, the greatest rank!
0, the root!
Figura 3.7: Outra possibilidade de saída - primeiro exemplo.
A impressão da mensagem em console possui 1 ponteiro para a saída padrão, fornecido pela linguagem C e o Sistema Operacional. Logo, ocorre uma condição de corrida
(TOSCANI; OLIVEIRA; SILVA CARíSSIMI, 2002) entre os processos MPI, e a ordem
de impressão da mensagem pode variar entre uma execução e outra. Na verdade, o risco
da condição de corrida que se criou é ainda maior; devido à característica não-atômica
das primitivas de E/S em C é possível que as mensagens fossem escritas de forma embaralhada. Isto, no entanto, não acontece, pois estas mensagens são curtas e em pequena
quantidade, tendo uma execução rápida. MPI oferece mecanismos para o controle de condições de corridas, como barreiras (TOSCANI; OLIVEIRA; SILVA CARíSSIMI, 2002).
3.1.4
Troca de Mensagens
Para a troca de mensagens MPI se usa MPI_Send e MPI_Recv. Seus protótipos são
apresentados na Figura 3.8.
A literatura propõe uma discussão sobre a necessidade específica de cada parâmetro
(GROPP; LUSK; SKJELLUM, 1999), que foge deste escopo. Seu significado é
2 Em
referências mais antigas é utilizado o script mpirun (PACHECO, 1997). Embora a maioria das
distribuições ainda suportem esta sintaxe, ela está, agora, obsoleta (GROPP; LUSK; SKJELLUM, 1999).
37
int MPI_Send(void* buf, int count, MPI_Datatype dt, int dest,
int tag, MPI_Comm comm )
int MPI_Recv(void* buf, int count, MPI_Datatype dt, int source,
int tag, MPI_Comm comm, MPI_Status* status )
Figura 3.8: Protótipos de MPI_Send() e MPI_Recv().
void* buf : é o buffer de recepção/envio. É o endereço de memória que aponta para o
início da área de dados que será enviada (MPI_Send) ou recebida (MPI_Recv).
int count : é o número de variáveis de um certo tipo que será enviada/recebida. Pode
ser usada para indicar que se deseja receber, por exemplo, dois inteiros, ou enviar
três números em ponto flutuante, com a condição de que estes estejam contíguos na
memória (e.g., um vetor).
MPI_Datatype dt : é o que define o tipo das variáveis enviadas (e.g., MPI_INT para
inteiros). Presente por razões de portabilidade; nas arquiteturas de processadores,
o número de bytes (ou sua ordem de leitura) que representa uma variável de um
determinado tipo pode diferir. Desta maneira, o mapeamento é feito através de uma
camada inferior, pertencente ao MPI, e é transparente ao usuário, que o enxerga
por meio dos tipos da biblioteca. Por essa mesma razão não é recomendado enviar
fluxos de bytes baseado nos tamanhos-padrão de cada tipo; para isso, MPI oferece
o tipo MPI_BYTE.
int dest : é o identificador de processo, referente ao comunicador também passado por
parâmetro, que aponta a qual processo a mensagem será enviada no MPI_Send.
int source : é o identificador de processo, referente ao comunicador, também passado por
parâmetro, que aponta de qual processo a mensagem será recebida no MPI_Recv.
Para se receber uma mensagem de qualquer processo, usa-se a constante MPI_ANY
_SOURCE.
int tag : é uma marca identificadora para uma mensagem; serve para facilitar o controle do fluxo de mensagens entre dois ou mais processos. Pode, por exemplo, ser
usada para indicar a seqüência das mensagens a serem recebidas, se esta for importante; por mais que a rede, eventualmente, entregue as mensagens fora de ordem, se
houver uma seqüência de MPI_Recv que receba mensagens com tags seqüenciais
(mandas do mesmo modo pelo MPI_Send), então a entrega respeitará essa seqüência. A constante MPI_ANY_TAG pode ser usada para se receber uma mensagem
independentemente do valor da sua tag.
MPI_Comm comm : é o comunicador que dá semântica aos campos dest e source;
guarda a informação do grupo de processos e da topologia a qual se está fazendo
referência.
MPI_Status* status : é a variável que receberá uma estrutura de dados que contém informações diversas sobre a recepção da mensagem, incluindo quem a enviou e qual sua
tag. É especialmente útil quando é necessário usar as constantes MPI_ANY_SOURCE
ou MPI_ANY_TAG; embora possa-se receber a mensagem de qualquer fonte, com
38
qualquer marcação, é possível identificar quem enviou a mensagem (e em que contexto) para, por exemplo, enviar uma resposta. Abordagem muito usada em algoritmos do tipo mestre-escravo (DONGARRA et al., 2003).
A primitiva MPI_Recv é bloqueante e MPI_Send é bloqueante até que o buffer de
envio possa ser reaproveitado. Existem outros modos de envio/recepção, mencionados
anteriormente (e.g, send() bloqueante), que possuem uma correspondente primitiva em
MPI.
Existe um detalhe importante para a recepção e envio corretos de mensagem, que é a
questão do batimento dos parâmetros. Entre um MPI_Send e um MPI_Recv existem
certos argumentos que devem estar em concordância e outros que são completamente
independentes:
count, datatype : não necessariamente devem ser o mesmo; pode-se reinterpretar os
dados recebidos através das mudanças destes parâmetros.
tag, comm : devem ser o mesmo em ambos ou, no MPI_Recv, pode ser MPI_ANY_TAG.
dest, source : devem concordar (dest de um como o source do outro e vice-versa)
ou, no MPI_Recv, pode ser MPI_ANY_SOURCE.
buf : é completamente independente em ambas as primitivas.
3.1.5
Segundo Exemplo
Considerando o código de exemplo apresentado anteriormente, pode-se, agora, introduzir a troca de mensagens no corpo do algoritmo. O princípio, desta vez, é fazer com
que todos os processos enviem uma mensagem ao processo raiz e este as imprima na
tela, à medida que as for recebendo. O código resultante de aplicação de MPI_Send e
MPI_Recv pode ser visto na Figura 3.9 (PACHECO, 1998). A execução deste programa
deve ser semelhante à execução do programa anterior, conforme a Figura 3.10, com a diferença do processo de maior rank não estar identificado e o processo raiz (0) não imprimir
nada, pois sua única função é receber mensagens dos outros processos e imprimi-las.
Ainda existe uma condição de corrida, presente no recebimento das mensagens. No
entanto, não há mais o risco da impressão embaralhada: o processo raiz (0) imprimi as
mensagens seqüencialmente, na ordem em que forem chegando.
3.1.6
Programação Distribuída × Programação Paralela
A esta altura já existe ferramental suficiente para a apresentação de um exemplo importante para ilustrar as diferenças entre programação paralela e programação distribuída.
A programação distribuída ocorre quando o processamento não é realizado em apenas
uma unidade fundamental (UCP); a execução ocorre em vários processadores. Já a programação paralela, pressupõe que, além de existir uma distribuição da computação, estas
partes são executados ao mesmo tempo.
É difícil encontrar um exemplo de computação paralela pura. De fato, a maioria dos
programas ditos paralelos apresentam trechos que, embora distribuídos, são seqüenciais.
O exemplo a seguir mostra um programa MPI que ilustra a abordagem inversa, onde não
há processamento paralelo.
39
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <mpi.h>
#include <string.h>
int
main(int argc, char *argv[])
{
int process_rank;
/*
int p;
/*
int source;
/*
int dest;
/*
int tag = 50;
/*
char message[100];
/*
MPI_Status status;
/*
*/
*/
*/
*/
*/
*/
*/
MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &process_rank);
MPI_Comm_size(MPI_COMM_WORLD, &p);
if ( process_rank != 0 ) {
sprintf(message, "Greetings from process %d!", process_rank);
dest = 0;
MPI_Send(message, strlen(message) + 1, MPI_CHAR, dest, tag,
MPI_COMM_WORLD );
} else {
for (source = 1; source < p; source ++) {
MPI_Recv(message, 100, MPI_CHAR, source, tag,
MPI_COMM_WORLD, &status );
printf("%s\n", message);
}
}
MPI_Finalize();
return 0;
24
25
26
27
28
29
30
31
32
Rank of process.
Number of processes.
Rank of sender.
Rank of receiver.
Tag for messages.
Storage for the message.
Return status from receive.
}
Figura 3.9: Programa “Hello World” com troca de mensagens.
Greetings from process 1!
Greetings from process 2!
Greetings from process 3!
Figura 3.10: Saída - segundo exemplo.
40
3.1.6.1
Impressão Seqüencial
O exemplo apresentado aqui faz com que processos imprimam seu rank de maneira
garantidamente seqüencial, em ordem crescente. Para tanto, utiliza-se um algoritmo do
tipo passagem de token, onde um processo só imprime seu rank ao receber uma mensagem
do processo de identificador imediatamente anterior. A Figura 3.11 contém um diagrama
do algoritmo, enquanto a Figura 3.12 contém o código C.
Figura 3.11: Diagrama do algoritmo de passagem de token.
Neste caso, não há programação paralela; toda a computação é distribuída, porém
seqüencial. O resultado obtido, para 10 processos, é visto na Figura 3.13.
3.2
Funcionalidades Adicionais
Esta seção enumera algumas funcionalidades adicionais que o MPI oferece como ferramental ao programador paralelo. O objetivo não é exaurir a lista destas utilidades; são
apresentados os principais recursos que contribuirão, nos capítulos posteriores, para a
implementação do algoritmo que resolve o Problema da Mochila em paralelo.
3.2.1
Comunicação Coletiva
Além de operações MPI_Send e MPI_Recv, a especificação MPI oferece, também,
funções utilitárias, que implementam estruturas de comunicação para modos recorrentes
de transmitir mensagens entre processos.
Uma das principais comunicações coletivas é o broadcast, ou seja, mandar uma mensagem para todos os outros processos; de fato, tal tipo de envio é quase tão freqüentemente
usado quanto MPI_Send (GROPP; LUSK; SKJELLUM, 1999).
A primeira solução que ocorre para fazer comunicação broadcast entre os processos
seria a utilização de múltiplas chamadas MPI_Send, uma para cada processo, como na
estrutura vista na Figura 3.14, onde todos os processos, menos o que enviou a mensagem,
recebem os dados..
Existem, no entanto, dois motivos principais pelos quais esta abordagem não é a melhor:
Ineficiência. A rede de comunicação fica sobrecarregada de mensagens com o aumento
do número de processos participantes. Além disso, não há uma lógica para distribuir
as mensagens em uma hierarquia que aproveite topologias mais eficientes para a
41
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <mpi.h>
#include <stdio.h>
#define TOKEN 0
int
main(int argc, char *argv[])
{
int my_rank;
int num_procs;
int sender = -1;
MPI_Status status;
int first_proc;
int last_proc;
MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &my_rank);
MPI_Comm_size(MPI_COMM_WORLD, &num_procs);
first_proc = 0;
last_proc = num_procs - 1;
if ( my_rank != first_proc )
MPI_Recv(&sender, 1, MPI_INT, MPI_ANY_SOURCE, TOKEN,
MPI_COMM_WORLD, &status );
24
25
26
27
28
29
30
31
32
33
34
35
printf("Rank %d", my_rank);
if ( sender >= 0 )
printf(" sended by %d\n", sender);
else
printf("\n");
fflush(stdout);
if (my_rank != last_proc)
MPI_Send(&my_rank, 1, MPI_INT, my_rank + 1, TOKEN,
MPI_COMM_WORLD);
MPI_Finalize();
}
Figura 3.12: Impressão seqüencial de rank com algoritmo de token em anel.
Rank
Rank
Rank
Rank
Rank
Rank
Rank
Rank
Rank
Rank
0
1
2
3
4
5
6
7
8
9
sended
sended
sended
sended
sended
sended
sended
sended
sended
by
by
by
by
by
by
by
by
by
0
1
2
3
4
5
6
7
8
Figura 3.13: Resultado para a impressão seqüencial de identificadores.
42
for ( i = 0; i < num_procs; ++ i ) {
if ( i != my_rank )
MPI_Send(message,
size_message,
MPI_INT,
i,
tag,
MPI_COMM_WORLD);
}
Figura 3.14: Implementação direta de broadcast.
distribuição de mensagens (e.g., distribuir as mensagens numa topologia de árvore,
onde cada processo é um nó da árvore, faz com que o tempo de distribuição seja
logarítmico3 , enquanto o tempo seqüencial é linear4 ) (PACHECO, 1998).
Encapsulamento. Fazer uma distribuição mais eficiente promove muito trabalho ao programador (que não faz parte do algoritmo). Um detalhe importante a ser salientado
é o controle feito para que o processo que enviou não receba a própria mensagem,
fato que poderia causar deadlocks (TOSCANI; OLIVEIRA; SILVA CARíSSIMI,
2002) inesperados.
Em MPI, há uma primitiva específica para se implementar broadcast, a MPI_Bcast,
cujo protótipo é apresentado na Figura 3.15.
int MPI_Bcast(void* buf, int count, MPI_Datatype datatype, int root,
MPI_Comm comm )
Figura 3.15: Protótipo de MPI_Broadcast().
O significado dos parâmetros é o mesmo que em MPI_Send, com a diferença de que
não há um destino, mas sim a identificação do processo emissor das mensagens (root).
Existem dois motivos para a presença de tal informação:
• Conforme mencionado anteriormente, é necessário que o processo que emite as
mensagens não as envie para si mesmo; e
• Ao contrário do que se pensa inicialmente, não se recebe uma mensagem em broacast através de um MPI_Recv, mas sim através de outro MPI_Bcast. Logo, é
importante para o processo saber se é ele mesmo quem está mandando a mensagem
ou a recebendo.
O último item citado acima causa estranheza, a princípio, mas justifica-se pelo uso
eficiente da topologia; devido às otimizações topológicas que a primitiva pode fazer para
o envio eficiente das mensagens, a recepção lógica das mesmas difere da recepção física.
Em outras palavras, um processo pode estar recebendo a mensagem de um retransmissor,
precisamente, tenha uma complexidade média de, aproximadamente, log2 ( 2p ) ciclos, onde p é o
número de processos e 2p é o número de processos que apenas recebem mensagens, sem enviá-las (nodosfolha da árvore).
4 Complexidade de p − 1 envio de mensagens (ciclos), onde p é o número de processos.
3 Mais
43
ao invés da fonte. É importante, portanto, deixar a resolução física para um nível mais
baixo e transparente, ao invés de se adotar a abordagem padrão do MPI_Recv.
Um programa, ao utilizar um algoritmo do tipo mestre-escravo, elege um processo
como mestre. Este processo tem como função reunir as computações realizadas pelos
outros processos, efetuar uma operação sobre elas e devolvê-la ao usuário. Esta também
é uma operação muito freqüente em Programação Paralela.
Para simplificar este processo, MPI oferece maneiras de se unificar parcialmente o
trabalho do processo raiz. É possível, através de uma primitiva, unificar o processo de
receber uma mensagem de todos os processos, agrupá-las e realizar uma operação. Tal
primitiva é o MPI_Reduce, cujo protótipo é visto na Figura 3.16.
int MPI_Reduce(void* sendbuf, void* recvbuf, int count, MPI_Datatype
datatype, MPI_Op op, int root, MPI_Comm comm )
Figura 3.16: Protótipo de MPI_Reduce().
Da mesma maneira que MPI_Bcast, MPI_Reduce também deve ser invocada pelos processos que fizeram as computações parciais (onde terá um papel semelhante à
MPI_Send) e pelo processo raiz (como um MPI_Recv). Os parâmetros da função (inéditos) são
void* sendbuf, void* recvbuf : para o processo raiz, recvbuf será o buffer de recepção da mensagem. Para os outros processos, sendbuf será o buffer de envio. Isto
sugere que apenas uma variável do tipo buffer seria necessária, mas isso é incorreto;
é possível que o próprio processo raiz calcule alguma coisa e tenha que fundir seus
dados ao de todo o grupo, tendo de utilizar os dois buffers;
MPI_Op op : é uma constante da biblioteca que expressa qual operação deve ser feita
sobre os dados (e.g, MPI_SUM, para somar todos os dados e MPI_MAX para, dentre todos os dados recebidos, verificar qual é o maior) dentre as operações padrão
da especificação ou de operações construídas pelo usuário, cujo suporte é oferecido
pelo MPI, embora não seja detalhado aqui5
MPI_Reduce pode ser encarada como possuidora de uma lógica inversa à lógica de
envio/recepção do MPI_Bcast; aqui, o processo raiz espera que cheguem mensagens de
todos os processos, ao invés de enviá-las.
3.2.2
Terceiro Exemplo
O exemplo a seguir mostra a utilização das primitivas MPI_Bcast e MPI_Reduce
através de um programa que usa o método dos retângulos (CLáUDIO; MARINS, 1994)
para calcular um valor aproximado da constante π. O código foi retirado de (GROPP;
LUSK; THAKUR, 1999) e foi acrescido de algumas correções e otimizações6 . O algoritmo MPI pode ser visto na Figura 3.17
5 Em ambos os casos, com uma operação padrão ou personalizada, é importante que esta seja associativa,
ou seja, a ordem de realização da operação sobre os subconjuntos de um conjunto de dados não influa sobre
o resultado final.
6 A destacar, a inclusão dos cabeçalhos para E/S em C e a transaformação do valor de π com 25 (vinte e
cinco) dígitos em uma constante.
44
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
33
34
35
36
37
38
#include <mpi.h>
#include <math.h>
#include <stdio.h>
#define PI25DT 3.141592653589793238462643
int
main(int argc, char *argv[]) {
int n;
/* Number of intervals.
int myid;
/* ID of current process.
int numprocs;
/* Number of processes
int i;
/* "for" index.
double mypi;
/* Part of the sum of each process
double pi;
/* The final PI (sum) of all processes
double h;
/* Height of each sub-rectangle.
double sum;
/* Rectangle’s sum.
double x;
/* Middle value of the rectangle’s base.
MPI_Init(&argc, &argv);
MPI_Comm_size(MPI_COMM_WORLD, &numprocs);
MPI_Comm_rank(MPI_COMM_WORLD, &myId);
while (1) {
if ( myId == 0 ) {
printf("Enter the number of intervals: (0 quits) ");
scanf("%d", &n);
}
MPI_Bcast(&n, 1, MPI_INT, 0, MPI_COMM_WORLD);
if ( n == 0 ) {
break;
} else {
h = 1.0 / (double) n;
sum = 0.0;
for ( i = myid + 1; i <= n; i += numprocs ) {
x = h * ((double)i - 0.5 );
sum += (4.0 / (1.0 + x * x));
}
mypi = h * sum;
MPI_Reduce(&mypi, &pi, 1, MPI_DOUBLE, MPI_SUM, 0,
MPI_COMM_WORLD );
if ( myid == 0 )
printf("PI is approximately %.16f, Error is %.16f\n",
pi, fabs(pi - PI25DT) );
}
}
MPI_Finalize();
return 1;
39
40
41
42
43
44
45
*/
*/
*/
*/
*/
*/
*/
*/
*/
}
Figura 3.17: Programa para calcular uma aproximação de π.
45
Este método baseia-se no cálculo da integral da função 4/1 + x2 ; ela divide o eixo
x em tantos intervalos quanto o usuário requisitar. Após, envia esta informação via
MPI_Bcast, (altura, no eixo y, equivale ao valor de x constante naquele intervalo) para
que os processos façam o cálculo da área do respectivo retângulo e devolvem ao processo
raiz, que redistribui os intervalos pendentes. Após isso, todas as áreas são obtidas e somadas, através de MPI_Reduce. Para calcular o erro, é feita a comparação com um valor
π suficientemente grande e previamente definido.
3.2.3
Temporização
Parte fundamental da programação paralela consiste em mensurar o ganho de desempenho obtido com o emprego de uma solução multiprocessada; até agora, foram apresentadas soluções para a paralelização básica de algoritmos. Agora, é apresentado um
mecanismo simples para a medição do tempo de execução dos programas. Tal abordagem é útil para a confecção de comparativos e o estabelecimento de benchmarks, que
adquirem grande importância em capítulos posteriores.
Visando suprir estas necessidades, a especificação MPI oferece duas primitivas para a
medição de tempo, cujos protótipos são apresentados na Figura 3.18.
double MPI_Wtime()
double MPI_Wtick()
Figura 3.18: Protótipos de MPI_Wtime() e MPI_Wtick().
MPI_Wtime retorna o tempo transcorrido (em segundos) desde a última chamada
dela mesma ou, caso isto não tenha ocorrido, uma data padrão da implementação. De
qualquer modo, duas chamadas consecutivas revelam o tempo decorrido entre estas. Para
fins de uniformização da contagem de tempo, existe MPI_Wtick, que retorna o tempo
(em segundos) entre dois pulsos de clock consecutivos. Ambas as primitivas podem ser
usadas fora de MPI_Init(...) [...] MPI_Finalize(...), pois muitas vezes
é necessário medir a execução de um programa seqüencial com o mesmo ferramental.
Para exemplificar seu uso, apresenta-se uma versão com medição de tempo do código
apresentado na Figura 3.17. A Figura 3.19 contém o novo código.
3.2.4
Envio e Recebimento Não-bloqueantes
Conforme explanado anteriormente, existem vários modos no que se refere à espera
de uma operação de send ou receive em MPI. Um que desperta interesse especial para
fins da implementação que será desenvolvida é o envio e recebimento não-bloqueantes.
MPI possui MPI_Isend para envio e MPI_Irecv para recebimento não-bloqueante.
Seus protótipos são visualizados na Figura 3.20.
MPI_Isend é idêntico à sua versão bloqueante, mas a execução do programa prossegue mesmo que o buffer não tenha sido esvaziado. MPI_Irecv também é muito semelhante ao já apresentado, mas, ao invés de um ponteiro para a estrutura MPI_Status, há
um ponteiro para um outra estrutura, MPI_Request. Esta estrutura é um argumento que
guarda uma requisição após a chamada de um MPI_Irecv e pode ser testado a qualquer
momento para verificar se a mensagem já chegou.
Existem duas primitivas usadas para testar se um dada mensagem chegou, listadas na
Figura 3.21.
46
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <mpi.h>
#include <math.h>
#include <stdio.h>
#define PI25DT 3.141592653589793238462643
int
main(int argc, char *argv[])
{
(...)
/* Time in seconds application started. */
double start_time;
/* Time in seconds application ended. */
double end_time;
MPI_Init(&argc, &argv);
MPI_Comm_size(MPI_COMM_WORLD, &numprocs);
MPI_Comm_rank(MPI_COMM_WORLD, &myId);
while ( 1 ) {
if ( myid == 0 ) {
printf("Enter the number of intervals: (0 quits) ");
scanf("%d", &n);
}
/* Timing count begins. */
start_time = MPI_Wtime();
MPI_Bcast(&n, 1, MPI_INT, 0, MPI_COMM_WORLD);
if ( n == 0 ) {
break;
} else {
(...)
/* Timing count ends. */
end_time = MPI_Wtime();
if ( myid == 0 ) {
printf("PI is approximately %.16f, Error is %.16f\n",
pi,
fabs(pi - PI25DT) );
/* Timing and "clock tick" count. */
printf("Time is %.16f seconds or %.16f clock ticks.\n",
endTime - startTime,
(endTime - startTime)/MPI_Wtick() );
}
}
}
MPI_Finalize();
return 1;
}
Figura 3.19: Programa para calcular uma aproximação de π, com medição de tempo.
47
int MPI_Isend(void* buf, int count, MPI_Datatype dt, int dest,
int tag, MPI_Comm comm )
int MPI_Irecv(void* buf, int count, MPI_Datatype dt, int source,
int tag, MPI_Comm comm, MPI_Request* request )
Figura 3.20: Protótipos de MPI_Isend() e MPI_Irecv().
int MPI_Wait (MPI_Request *request, MPI_Status *status)
int MPI_Test (MPI_Request *request, int *flag, MPI_Status *status)
Figura 3.21: Protótipos de MPI_Wait() e MPI_Test().
MPI_Wait bloqueia até que a mensagem chegue, sendo útil para adiantar alguma
parte não-dependente da chegada da mensagem e, então, esperar. Quando a mensagem
chegar será como um MPI_Recv, entregando os dados nas variáveis indicadas e seu
status em status.
MPI_Test testa se a mensagem chegou, indicando tal fato na variável flag; em
seguida, testadando flag (e.g., if ( flag == 0 )), pode-se continuar de acordo
com as preferências do programador.
A estas primitivas tem versões que atuam sobre vetores de ponteiros do tipo MPI_Request;
estas funções têm o protótipo conforme visto na Figura 3.22.
int MPI_Testany(int count, MPI_Request* array_of_requests, int* index,
MPI_Status* status)
int MPI_Waitany(int count, MPI_Request* array_of_requests, int* index,
MPI_Status* status)
Figura 3.22: Portótipos de MPI_Testany() e MPI_Waitany().
index contém a posição do vetor que guarda o request da solicitação que foi
atendida. Uma aplicação disto será vista no próximo exemplo a ser mostrado.
Visto que as estruturas MPI tem um buffer, pode ser útil cancelar um MPI_Irecv,
que fica aguardando as mensagens; faz-se isso através de MPI_Cancel, descrito na Figura 3.23, que cancela uma requisição de aguardo de mensagem apontada por request.
3.2.5
Quarto Exemplo
Este exemplo ilustra o uso de primitivas não-bloqueantes. MPI fornece maneiras de se
receber uma mensagem com 1 determinado indicador (uma tag) ou de qualquer indicador (através da constante MPI_ANY_TAG). Não há, entretanto, um recurso para se receber
mensagens de um conjunto de tags. O código mostrado usa primitivas não-bloqueantes
para receber uma mensagem que pode ter exatamente 2 tipos de tag e pode ser visto
na Figura 3.24. O exemplo mostrado é claramente extensível para tantas tags quanto se
queira.
No exemplo apresentado, algum processamento é feito enquanto se espera a mensagem, geralmente para evitar uma condição de deadlock. No entanto, ao se remover este
processamento, tem-se o equivalente a um MPI_Recv, bloqueante, que aceita duas tags
no cabeçalho da mensagem.
48
int MPI_Cancel(MPI_Request *request)
Figura 3.23: Protótipo de MPI_Cancel().
1 MPI_Irecv(&msg, 1, MPI_INT, target_proc, TAG_ONE, MPI_COMM_WORLD,
2
&drequest[0]);
3 MPI_Irecv(&msg, 1, MPI_INT, target_proc, TAG_TWO, MPI_COMM_WORLD,
4
&drequest[1]);
5 flag = 0;
6 index = -1;
7 do {
8
MPI_Testany(2, drequest, &index, &flag, &status);
9
/* Some proc. */
10 } while ( flag == 0 );
11 MPI_Cancel(&(drequest[2-index]));
Figura 3.24: Recebimento de mensagem que pode ter exatas 2 tags.
3.3
Multiplicação Matriz × Vetor
Um estudo de caso a ser abordado é o algoritmo que realiza a multiplicação de uma
matriz por um vetor em paralelo. Lembrando, esta implementação é sobre MPI-1.2 e
é apresentada em (GROPP; LUSK; SKJELLUM, 1999), na linguagem FORTRAN. Para
fins desta monografia tal algoritmo foi convertido para a linguagem C.
Seja A uma matriz quadrada de ordem n e x um vetor de cumprimento n tal que
A × x = b, sendo b o vetor resultante da multiplicação, de tamanho n. O algoritmo é do
tipo mestre-escravo7 e decompõe A em linhas, que distribui ciclicamente a medida que os
escravos requisitam trabalho. Cada escravo, tendo recebido do mestre x (por broadcast),
faz a multiplicação do elemento de b correspondente.
Cada processo escravo, ao acabar a computação, requisita mais trabalho para o mestre,
que pode enviá-lo ou dizer ao escravo para terminar, pois não há mais tarefas. Ao final,
o processo mestre recolhe cada resultado e monta o vetor-reposta. O código está todo
contido em um arquivo mas, para melhor entendimento, subdividiu-se em três partes:
início (Figura 3.25), mestre (Figura 3.26) e escravo (Figura 3.27) .
Esta computação, para diferentes tamanhos de matriz e número de processos, teve seu
tempo de execução medido. As execuções são feitas para configurações utilizando de 2 a
10 nós. Para cada configuração, executou-se o mesmo programa com matrizes quadradas
de ordem 100 até 1000, incrementados de 100. O mesmo procedimento foi repetido 5
vezes e, ao final, tomou-se a média aritmética do tempo de execução. A Figura 3.28
apresenta os resultados obtidos. Estes resultados foram publicados, em conjunto com
medições sobre MPI-2 (com criação dinâmica dos processos) na Escola Regional de Alto
Desempenho 2007 (ESCOLA REGIONAL DE ALTO DESEMPENHO, 2007).
3.3.1
Multiplicação Matriz × Matriz
É interessante reparar que, com poucas extensões, é possível aproveitar o código apresentado e alterá-lo para produzir um programa que compute, paralelamente, a multiplicação de duas matrizes quadradas (A × B = C, com A, B e C de tamanho n).
O código estendido estava, originalmente, em FORTRAN (GROPP; LUSK; SKJEL7 Um
processo distribui tarefas a outros processos e colhe resultados (PACHECO, 1997).
49
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
33
34
35
#include <mpi.h>
#include <stdio.h>
#include <unistd.h> /* GNU GetOpt library. */
#define MAX_ROWS 1001
#define MAX_COLS 1001
int
main(int argc, char *argv[])
{
int rows;
int cols;
double matrix[MAX_ROWS][MAX_COLS];
/* Matrix to be multiplied. */
double vectorA[MAX_COLS];
double vectorB[MAX_ROWS];
double buffer[MAX_COLS];
double answer;
int myid;
/* ID of current process.
*/
int master;
/* Master process ID.
*/
int numprocs;
/* Number of processes.
*/
int i, j;
/* Index for looping.
*/
int numsent;
int sender;
int anstype;
int row;
MPI_Status status;
/* Benchmark variables. */
double start_time;
(... GET PARAM ...)
MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &myid);
MPI_Comm_size(MPI_COMM_WORLD, &numprocs);
Figura 3.25: Código para o programa de multiplicação matriz × vetor (início).
50
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
master = 0;
if ( myid == master ) {
/* Master’s part: */
/* Initializing vector and matrix. */
for (i = 0; i < cols; i = i + 1) {
vectorA[i] = 1;
for (j = 0; j < rows; j = j + 1)
matrix[j][i] = j;
}
start_time = MPI_Wtime();
/* Sends vectorA to each slave process. */
MPI_Bcast(vectorA, cols, MPI_DOUBLE, master, MPI_COMM_WORLD);
/* Sends a row for each slave process; tag with row number. */
numsent = 0;
for ( i = 0; i < numprocs-1; i = i + 1 ) {
for (j = 0; j < cols; j = j + 1)
buffer[j] = matrix[i][j];
MPI_Send(buffer, cols, MPI_DOUBLE, i + 1, i + 1,
MPI_COMM_WORLD);
numsent += 1;
}
for ( i = 0; i < rows; i = i + 1 ) {
MPI_Recv(&answer, 1, MPI_DOUBLE, MPI_ANY_SOURCE,
MPI_ANY_TAG, MPI_COMM_WORLD, &status);
sender = status.MPI_SOURCE;
anstype = status.MPI_TAG;
vectorB[anstype-1] = answer;
if ( numsent < rows ) {
for ( j = 0; j < cols; j = j + 1 ) {
buffer[j] = matrix[numsent][j];
}
MPI_Send(buffer, cols, MPI_DOUBLE, sender, numsent+1,
MPI_COMM_WORLD);
numsent += 1;
}
else
MPI_Send(MPI_BOTTOM, 0, MPI_DOUBLE, sender, 0,
MPI_COMM_WORLD);
}
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/* Process 0 is the master process. */
printf("%d %d %f \n", rows, numprocs - 1,
MPI_Wtime() - start_time);
/* Prints solution */ (...)
}
Figura 3.26: Código para o programa de multiplicação matriz × vetor (mestre).
51
1
2
3
4
5
6
7
8
9
} else {
/* Slave’s part: */
MPI_Bcast(vectorA, cols, MPI_DOUBLE, master, MPI_COMM_WORLD);
if ( myid > rows ) {
MPI_Finalize();
return 0;
}
while (1) {
MPI_Recv(buffer, cols, MPI_DOUBLE, master, MPI_ANY_TAG,
MPI_COMM_WORLD, &status);
if ( status.MPI_TAG == 0 ) {
MPI_Finalize();
return 0;
} else {
row = status.MPI_TAG;
answer = 0.0;
for (i = 0; i < cols; i = i + 1)
answer += buffer[i] * vectorA[i];
MPI_Send(&answer, 1, MPI_DOUBLE, master, row,
MPI_COMM_WORLD);
}
}
}
MPI_Finalize();
return 0;
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
}
Figura 3.27: Código para o programa de multiplicação matriz × vetor (escravo).
Figura 3.28: Desempenho: multiplicação matriz × vetor – MPI-1.2
52
LUM, 1999) e foi portado para C. O código, como o anterior, também é apresentado em
três partes: início (Figura 3.29), mestre (Figura 3.30) e escravo (Figura 3.31)
As medições de tempo, tomadas variando-se apenas o número de processos para uma
matriz 1000 × 1000, foram realizadas 5 vezes e tomada a média aritmética; o número de
processos listados desconsidera o processo mestre (e.g., 0 significa o algoritmo seqüencial, rodado em separado da estrutura MPI), resultados apresentados na Tabela 3.2.
N. Proc.
0
1
2
3
4
5
6
7
Tempo (s)
0.026577
0.030405
0.069374
0.049342
0.054366
0.057348
0.076734
0.073961
N. Proc.
8
9
10
11
12
13
14
15
Tempo (s)
0.084667
0.110110
0.093460
0.103958
0.160807
0.141569
0.394071
0.258599
Tabela 3.2: Tempos da multiplicação matriz × matriz paralela.
É interessante notar que, contrariando a lógica, os tempos apresentados sobem com o
aumento do número de processos. Pode-se justificar isso através de três argumentos,
1. Com o aumento do número de processos, o número de troca de mensagens (operações de E/S, custosas em termos de tempo) sobe;
2. O fato da multiplicação envolver duas matrizes ao invés de um vetor e uma matriz
gera um aumento considerável no envio de mensagens (E/S); e
3. A matrizes não são grandes o suficiente para se beneficiar do aumento de processos
e sobrepujar o custo de comunicação.
53
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <mpi.h>
#include <stdio.h>
#include <unistd.h> /* GNU GetOpt library. */
#define MAX_A_ROWS 100
#define MAX_A_COLS 100
#define MAX_B_COLS 100
int
main(int argc, char *argv[])
{
/* Algorithm variables. */
int a_rows;
int b_rows;
int c_rows;
int a_cols;
int b_cols;
int c_cols;
double matrixA[MAX_A_ROWS][MAX_A_COLS];
double matrixB[MAX_A_COLS][MAX_B_COLS];
double matrixC[MAX_A_ROWS][MAX_B_COLS];
double buffer[MAX_A_COLS]; /* Buffer for temp. multiplication. */
double answer[MAX_B_COLS];
int myid;
/* ID of current process. */
int master;
/* Master process ID. */
int numprocs;
/* Number of processes. */
int i;
/* Index for looping. */
int j;
/* Index for looping. */
int numsent;
/* Number of sent processes. */
int sender;
/* The sender identifier. */
int anstype;
/* Where the answer vector should be. */
int row;
/* Holds the row number. */
MPI_Status status;
double start_time;
(... GET PARAM ...)
/* Matrixes initialized sizes (indexes limits). */
master = 0;
/* Process 0 is the master process. */
a_rows = MAX_A_ROWS;
a_cols = MAX_A_COLS;
b_rows = MAX_A_COLS;
b_cols = MAX_B_COLS;
c_rows = a_rows;
c_cols = b_cols;
/* Initializating MPI. */
MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &myid);
MPI_Comm_size(MPI_COMM_WORLD, &numprocs);
Figura 3.29: Código para o programa de multiplicação matriz × matriz (início).
54
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
33
34
35
36
if ( myid == master ) {
/** Master’s part: */
/* Initializing matrixes. */
(...)
start_time = MPI_Wtime();
/* Sends MatrixB to each slave process. */
for ( i = 0; i < b_rows; i = i + 1 )
MPI_Bcast(&(matrixB[i][0]), b_cols, MPI_DOUBLE, master,
MPI_COMM_WORLD);
/* Sends a row for each slave process; tag with row number. */
numsent = 0;
for ( i = 0; i < min(numprocs-1, a_rows); i = i + 1 ) {
for ( j = 0; j < a_cols; j = j + 1 )
buffer[j] = matrixA[i][j];
MPI_Send(buffer, a_cols, MPI_DOUBLE, i + 1, i + 1,
MPI_COMM_WORLD);
numsent = numsent + 1;
}
for ( i = 0; i < c_rows; i = i + 1 ) {
MPI_Recv(&answer, c_cols, MPI_DOUBLE, MPI_ANY_SOURCE,
MPI_ANY_TAG, MPI_COMM_WORLD, &status);
sender = status.MPI_SOURCE;
anstype = status.MPI_TAG;
for ( j = 0; j < c_cols; j = j + 1 )
matrixC[anstype-1][j] = answer[j];
if ( numsent < a_rows ) {
for ( j = 0; j < a_cols; j = j + 1 )
buffer[j] = matrixA[numsent][j];
MPI_Send(buffer, a_cols, MPI_DOUBLE, sender, numsent +
1, MPI_COMM_WORLD);
numsent = numsent + 1;
} else {
MPI_Send(MPI_BOTTOM, 0, MPI_DOUBLE, sender, 0,
MPI_COMM_WORLD);
}
}
/* Prints the data into the GNUPLOT format. */
printf("%d %f \n",numprocs - 1, MPI_Wtime() - start_time);
Figura 3.30: Código para o programa de multiplicação matriz × matriz (mestre).
55
1
2
3
4
} else {
/** Slave’s part: */
for ( i = 0; i < b_rows; i = i + 1 )
MPI_Bcast(&(matrixB[i][0]), b_cols, MPI_DOUBLE, master,
MPI_COMM_WORLD);
if ( myid > b_rows ) {
MPI_Finalize();
return 0;
}
while ( 1 ) {
MPI_Recv(buffer, a_cols, MPI_DOUBLE, master, MPI_ANY_TAG,
MPI_COMM_WORLD, &status);
if ( status.MPI_TAG == 0 ) {
MPI_Finalize();
return 0;
} else {
row = status.MPI_TAG;
for ( i = 0; i < b_cols; i = i + 1 ) {
answer[i] = 0.0;
for ( j = 0; j < a_cols; j = j + 1 )
answer[i] = answer[i]
+ buffer[j] * matrixB[j][i];
}
MPI_Send(&answer, b_cols, MPI_DOUBLE, master, row,
MPI_COMM_WORLD);
}
}
}
MPI_Finalize();
return 0;
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
}
Figura 3.31: Código para o programa de multiplicação matriz × matriz (escravo).
56
4
PROBLEMA DA MOCHILA
O Problema da Mochila é um problema de otimização combinatória de especial importância na Ciência da Computação, pois pertence à classe de problemas NP-Completos
(GAREY; JOHNSON, 1979). É, também, um dos principais problemas da área de Pesquisa Operacional, visto que muitas situações onde se busca maximizar o lucro podem
ser reduzidas a resolver este problema. Sua resolução demanda um grande esforço computacional, tornando-se especialmente interessante para a área de Programação Paralela.
O seguinte capítulo apresenta o Problema da Mochila e mostra um algoritmo paralelo que
o resolve, implementado em MPI.
4.1
Definição Intuitiva
O Problema da Mochila pode ser definido intuitivamente através de um cenário. Suponhase um ladrão que almeja roubar uma fábrica de peças de informática. Tudo de que o ladrão
dispõe para carregar o produto de seu furto é uma mochila escolar, de porte médio. Existem objetos das mais variadas dimensões, com os mais variados valores (e.g., placas-mãe,
memória de vídeo, memórias RAM, etc.). Caberá ao ladrão, portanto, escolher os itens a
carregar na sua mochila de modo que o valor a ser roubado seja máximo e que ele possa
carregar.
O Problema da Mochila, portanto, pode ser visto como o problema de preencher uma
mochila de capacidade limitada com itens que possuem um valor associado de modo a
não exceder esta capacidade e maximizar o valor resultante.
4.2
Definição Formal
A definição formal do Problema da Mochila é (KELLER; PFERSCHY; PISINGER,
2005):
Definição (Problema da Mochila Ilimitado). Sejam n tipos de itens (n ∈ N). Cada xi ∈ N
representa a quantidade de itens do tipo i, onde cada tipo tem associado um valor vi ∈ R+
e um peso pi ∈ R+ (i ∈ {1, . . . , n}). O máximo peso que a mochila pode carregar é C ∈ R+ .
O Problema da Mochila Ilimitado é, então,
n
maximizar
∑ xi × vi
i=1
n
|
∑ xi × pi ≤ C
i=1
Esta definição chama-se “Problema da Mochila Ilimitado” pois não há número de itens
de cada tipo (o valor de xi ) máximo. Este é o problema cuja implementação futuramente
apresentada resolve.
57
4.3
Outras Definições
Existem outros tipos de Problema da Mochila, com muitas variações nas restrições e
condições de execução (e.g., Problema da Mochila Compartimentada, Problema da Mochila Multidimensional, etc. ). É interessante analisar, entretanto, duas variações do Problema da Mochila que podem ser obtidas adicionando, cada uma, uma pequena restrição
no Problema da Mochila original. Estas variações são especialmente interessantes porque
podem ser facilmente adicionadas ao programa que resolve tal problema, por meio da
modificação de uma única função (branch).
4.3.1
Problema da Mochila Limitado
Retomando o exemplo do ladrão, este seria o caso do assaltante estar furtando uma
residência, ao invés de uma loja de computadores. Na residência, o número de itens que
pode ser levado do mesmo tipo tende a ser limitado1 ; é improvável, por exemplo, que
existam mais do que três ou quatro televisores. Assim cada item pode ser incluído no
somatório um número limitado de vezes. Formalmente (KELLER; PFERSCHY; PISINGER, 2005),
Definição (Problema da Mochila Limitado). Sejam n tipos de itens (n ∈ N). Cada xi ∈
{0, . . . , bi } representa a quantidade de itens do tipo i, onde cada tipo tem associado um
número de itens disponíveis máximo bi ∈ N, um valor vi ∈ R+ e um peso pi ∈ R+ (i ∈
{1, . . . , n}). O máximo peso que a mochila pode carregar é C ∈ R+ . O Problema da
Mochila Limitado é, então,
n
n
∑ xi × vi
maximizar
|
i=1
onde
4.3.2
∑ xi × pi ≤ C
i=1
0 ≤ xi ≤ bi
Problema da Mochila 0-1
No mesmo cenário dos anteriores, o ladrão, desta vez, atenta em roubar uma loja de
raros vasos chineses. Tal loja tem apenas um tipo de cada vaso e, deste modo, a decisão
do ladrão é entre levar o item ou não. Formalmente (KELLER; PFERSCHY; PISINGER,
2005),
Definição (Problema da Mochila 0-1). Sejam n tipos de itens (n ∈ N). Cada xi ∈ {0, 1}
representa a quantidade de itens do tipo i, um valor vi ∈ R+ e um peso pi ∈ R+ (i ∈
{1, . . . , n}). O máximo peso que a mochila pode carregar é C ∈ R+ . O Problema da
Mochila 0-1 é, então,
n
maximizar
n
∑ xi × vi
|
xi = 0
ou
i=1
onde
1A
∑ xi × pi ≤ C
i=1
xi = 1
priori, o número de itens da fábrica também é limitado. No entanto, pode-se considerar que o ladrão
tem tempo disponível para produzir quantas peças de um mesmo tipo quiser.
58
4.4
Complexidade
O Problema da Mochila é um problema NP-Completo. Portanto, o PM é resolvível
em tempo polinomial por um algoritmo não-determinístico. De fato, não há prova de
que existe algoritmo que resolva a o Problema da Mochila em tempo polinomial em uma
máquina determinística (pertence à classe NP) e qualquer outro problema NP pode ser
reduzido polinomialmente a um Problema da Mochila, sendo, assim, o problema NPCompleto (GAREY; JOHNSON, 1979) . A complexidade da solução adotada será apresentada junto com o código do algoritmo.
Devido à sua complexidade exponencial, uma solução eficiente para o problema deve
ser aquela que consegue reduzir, ao máximo, os termos da expressão exponencial. O
fato do Problema da Mochila ser NP-Completo permite que os resultados obtidos nesta
monografia possam ser generalizados para toda a classe de problemas NP-Completos.
Em outras palavras, é provável que o emprego de uma técnica de Workstealing produza
impacto semelhante quando aplicada à soluções paralelalas de outros algoritmos que resolvem problemas NP-Completos.
4.5
Solução
O algoritmo adotado para a solução do problema é do tipo Branch & Bound. Algoritmos desta natureza concentram-se em gerar ramos da árvore de possibilidades de
todas as soluções possíveis (branch) e cortam os ramos que não são promissores, ou seja,
garantidamente não vão gerar uma solução ótima (bound). Conforme explicitado, a enumeração de todas as soluções é feita em tempo exponencial, sendo de suma importância
a otimização do processo.
4.5.1
Algoritmo
Toma-se a Definição 4.2, “Problema da Mochila Ilimitado”. A solução apresentada
(KELLER; PFERSCHY; PISINGER, 2005) considera que a entrada está disposta de maneira decrescente em relação aos valores relativos dos objetos. Como valor relativo,
entende-se a razão entre o valor de um objeto do tipo i (vi ) e o peso do mesmo objeto
(pi ). Ou seja, há a relação
v2
vn
v1
≥
≥ ··· ≥
p1
p2
pn
Tal ordenamento, se desejado, pode ser feito com uma complexidade O(n × log2 (n))
(e.g., Quicksort). Para a implementação deste algoritmo construiu-se um gerador de casos
em Python, que gera arquivos de instâncias do Problema da Mochila com o formato apresentado na Figura 4.1. Esta medida garante a ordem decrescente dos valores relativos,
retirando este custo da implementação.
<número de elementos (n)> <capacidade da mochila (C)>
v1 v2 v3 v4 ... vn
p1 p2 p3 p4 ... pn
Figura 4.1: Formato de instância do problema da mochila.
A construção do algoritmo Branch & Bound que resolve o Problema da Mochila será
abordado em separado, para um melhor entendimento, já que as partes de branch e de
59
bound possuem certa independência.
4.5.1.1
Branch
Esta parte consiste em gerar os galhos da árvore de soluções (cada galho corresponde a
um conjunto {x1 , . . . , xn } candidato à solução ótima do problema), um a um. Para fazê-lo,
usa-se uma técnica de geração baseada em um algoritmo guloso.
Conforme foi estabelecido, os itens da entrada estão ordenados de acordo com seu
valor relativo. Logo, o primeiro item é o melhor em termos de custo/benefício. O algoritmo guloso atribui a este item (x1 ) o maior valor possível tal que o peso deste não
ultrapasse o limite da mochila, ou seja, tal que x1 × p1 ≤ C. Sobrando espaço na mochila
(C − x1 × p1 > 0), tenta-se preenchê-lo elevando-se ao máximo o valor de x2 respeitando
que (x1 × p1 ) + (x2 × p2 ) ≤ C. Sobrando espaço, ainda, na mochila, parte-se para x3 e
assim sucessivamente, até xn . Com isso, gera-se o primeiro galho da árvore de soluções
possíveis. Exemplo no Algoritmo 1.
Algoritmo 1 Branch (um galho)
1: x1 ← bC/p1 c
2: for j ← 2, n do
j
3:
x j ← b(C − ∑i=1 (pi × xi ))/p j c
4: end for
Fica evidente, assim, que é possível distribuir os itens a caberem na mochila simplesmente alterando o valor inicial de j; pode-se gerar um novo galho tanto da raiz (caso
apresentado), quanto de qualquer nodo intermediário. Isso implica que, para gerar a árvore toda, basta utilizar o algoritmo acima n − 1 vezes, onde, a cada execução o valor
inicial de j, inicializado com n, é decrescido de uma unidade.
O processo de gerar toda a árvore imediatamente remete a uma implementação recursiva. De fato, uma função recursiva é muito mais fácil de ser implementada e muito
mais legível. No entanto, para fins desta monografia, torna-se especialmente interessante
a abordagem iterativa, onde simula-se a pilha da recursividade. Os motivos para isso ficarão claros quando, ao implementar a solução baseada em MPI, as tarefas tiverem de ser
divididas, inviabilizando o uso da recursividade oferecida pelo Sistema Operacional. Por
hora, é apresentada a função iterativa. É importante notar que esta versão necessita que
se inicialize o primeiro galho conforme descrito acima antes de gerar todos os outros, que
usam este como base. Vê-se o resultado no Algoritmo 2.
4.5.1.2
Bound
Este procedimento faz com que galhos que comprovadamente não possam gerar o
valor ótimo não sejam nem expandidos. Basicamente, faz-se isso através da comparação
do máximo valor que ainda pode ser atingido com uma ramificação da árvore com o maior
valor corrente. Uma vez que o ponteiro k aponta para determinado nodo da árvore, acaba
particionando-a; todos os valores anteriores já foram estabelecidos, e os que estão à frente
ainda precisam ser expandidos iterativamente. Desse modo, tem-se
−−−−−→
{x1 , x2 , . . . , xk , −
x−
k+1 , . . . , xn }
Toma-se V e P como ∑ni=1 (xi × vi ) e ∑ni=1 (xi × pi ), respectivamente. Além disso,
sejam V ’ e P’ o valor e o peso, respectivamente, da partição {x1 , . . . , xk }. Dessa partição,
o espaço restante na mochila C − P’ (denotado, doravante, P”) deve ser preenchido com
60
Algoritmo 2 Branch (toda a árvore).
1: x1 ← bC/p1 c
2: for j ← 2, n do
j
3:
x j ← b(C − ∑i=1 (pi × xi ))/p j c
4: end for
5: k ← n
6: while true do
7:
k←n
8:
while (k ≥ 1) ∧ (xk = 0) do
9:
k ← k−1
10:
end while
11:
if then(k ≥ 1)
12:
exit
13:
end if
14:
if k = n then
15:
xk ← 0
16:
continue
17:
end if
18:
xk ← xk − 1
19:
for j ← k + 1, n do
j
20:
x j ← b(C − ∑i=1 (pi × xi ))/p j c
21:
end for
22:
V ← ∑ni=1 (vi × xi )
23:
if V > Vbest then
24:
xbest ← x
25:
Vbest ← V
26:
end if
27:
k←n
28: end while
61
a distribuição {xk+1 , . . . , xn } que maximize V − V ’. Note-se, também, que o maior valor
vk+1
relativo dos itens que restam é sk+1
, dado o ordenamento inicial dos valores relativos.
Para fazer a poda, basta supor o melhor caso possível para a partição não analisada e
verificar se esta situação supera ou não a melhor configuração corrente (xbest ), que tem o
valor Vbest . No caso, a melhor situação possível seria se todos os itens restantes possuísvk+1
. Basta, então, calcular a soma como se possuíssem este valor e
sem o valor relativo sk+1
comparar Vbest com este; caso não haja superação do primeiro pelo segundo, o algoritmo
prossegue sem abrir aquele ramo da árvore. Matematicamente, o Algoritmo 3 reflete estas
considerações.
Algoritmo 3 Bound
1: V 0 = ∑ki=1 (vi × xi )
2: P00 = P − ∑ki=1 (pi × xi )
3: if V 0 + (vk+1 /pk+1 × P00 ) ≤ Vbest then
4:
continue
5: else
6:
(branch)
7: end if
Para uma versão completa do algoritmo, basta adicionar o trecho de bound ao código já apresentado até aqui (MARTELLO; TOTH, 1990), o que pode ser visualizado no
Algoritmo 4.
O código apresentado resolve o Problema da Mochila. No entanto, uma abordagem
formal é por demais abstrata; é bom que se use uma abordagem mais prática para ilustrar o algoritmo. A Figura 4.2 mostra a mesmo algoritmo implementado na linguagem
Python. Com ela, há fácil compreensão das transcrições feitas, ao mesmo tempo em que
a análise fica mais limpa e clara. O módulo modknapsack contém apenas uma função
(readInstance(...)) que lê a entrada mostrada na Figura 4.1.
A demonstração formal da complexidade desta solução foge ao escopo desta monografia. No entanto, emxerga-se que:
1. O cálculo do valor total (V ) associado a um conjunto {x1 , . . . , xn } e a verificação da
obtenção de um novo maior (linhas 28-30) é realizada em tempo polinomial O(n);
2. A geração de um conjunto {x1 , . . . , xn } candidato à solução (linhas 24-26) é feito
em tempo polinomial O(n), mas depende da geração de todos os valores de k.
3. A geração de todos os conjuntos {x1 , . . . , xn } possíveis é exponencial; o número total de conjuntos que existem, pelo Princípio Fundamental da Contagem, é (considerase #xi como o número de valores que xi pode assumir)
n
#x1 × #x2 × #x3 × · · · × #xn = ∏ #xi
i=1
logo, como todos os conjuntos possíveis são gerados, este é o mínimo (exponencial)
de operações a serem realizadas.
De fato, a complexidade exata do Problema da Mochila é difícil de ser estimada, mas
podem-se traçar conjecturas em cima da complexidade pessimista e do tipo de Problema
da Mochila analisado:
62
Algoritmo 4 Solução Branch & Bound para o Problema da Mochila
1: procedure K NAPSACK B RANCH B OUND(C, n, {v1 , . . . , vn }, {p1 , . . . , pn })
2:
x1 ← bC/p1 c
3:
for j ← 2, n do
j
4:
x j ← b(C − ∑i=1 (pi × xi ))/p j c
5:
end for
6:
Vbest ← ∑ni=1 (vi × xi )
7:
xbest ← x
8:
while true do
. O laço será quebrado internamente, via break.
9:
k←n
10:
while (k ≥ 1) ∧ (xk = 0) do
11:
k ← k−1
12:
end while
13:
if k = n then
14:
xk ← 0
15:
continue
16:
end if
17:
if then(k ≥ 1)
18:
xk ← xk − 1
19:
V 0 = ∑ki=1 (vi × xi )
20:
P00 = P − ∑ki=1 (pi × xi )
21:
if V 0 + (vk+1 /pk+1 × P00 ) ≤ Vbest then
22:
continue
23:
else
24:
for j ← k + 1, n do
j
25:
x j ← b(C − ∑i=1 (pi × xi ))/p j c
26:
end for
27:
V ← ∑ni=1 (vi × xi )
28:
if V > Vbest then
29:
xbest ← x
30:
Vbest ← V
31:
end if
32:
k←n
33:
end if
34:
else
35:
break
36:
end if
37:
end while
38: end procedure
63
• O Problema da Mochila 0-1 possui complexidade pessimista O(2n ), pois cada xi
pode assumir dois valores, 0 e 1;
• O Problema da Mochila Limitado possui complexidade pessimista
O(max({b1 , . . . , bn })n ), pois, no pior caso, o número máximo de sub-árvores geradas é igual à maior quantidade de itens disponível;
• O Problema da Mochila Ilimitado possui complexidade pessimista
O((C/min({p1 , . . . , pn }))n ), pois, analogamente ao caso anterior, o número máximo de sub-árvores é determinado pelo maior xi possível, que, não possuindo limites explícitos, é determinado pela quantidade máxima do item que possui menor
peso (C/min({p1 , . . . , pn })).
tais considerações se mostram afinadas com a literatura (MARTELLO; TOTH, 1990).
Em geral, o Problema da Mochila é resolvido por Programação Dinâmica, técnica
algorítmica que utiliza recursões e uma estrutura de dados auxiliar que armazena os resultados parciais destas recursões para evitar a repetição de trabalho. No entanto, ambas
as soluções possuem a mesma complexidade (KELLER; PFERSCHY; PISINGER, 2005).
4.5.2
MPI
Uma vez que se tenha um algoritmo formal estabelecido e implementado em uma linguagem flexível, como Python, o próximo passo é paralelizar este algoritmo, através de
uma implementação da especificação MPI. No entanto, há um passo intermediário, que
é implementar o algoritmo seqüencial em C e, aí então, paralelizá-lo. Primeiramente,
apresenta-se a mera transcrição do programa em Python para C, na Figura 4.4. A declaração das variáveis é vista na Figura 4.3
A função read_instance(...) lê um arquivo (instance.txt) o qual contém
uma instância do Problema da Mochila, conforme a Figura 4.1. A função dotprod(...)
realiza o produto escalar entre dois vetores ou partes de vetores (indicados através dos
parâmetros int low e int high). O produto escalar corresponde ao somatório do
produto entre vetores (Algoritmo 4).
Para evidenciar um processo de paralelização mais claro, convém agrupar certas expressões em termos de operações fundamentais do algoritmo, como branch e bound, que
pudessem ser transformadas em funções independentes, trazendo mais clareza ao código.
Tal abordagem pode ser visualizada na Figura 4.5.
Para realizar a paralelização deste algoritmo usa-se uma técnica conhecida de programação paralela, a Decomposição Cíclica. Basicamente, esta técnica consiste em replicar
a entrada entre todos os processos participantes (processo realizado automaticamente pelo
MPI) e o processo selecionar, com base no seu identificador, que partes de entrada processará e que partes da entrada deixará que outros processos computem. O nome “cíclica”
advém do fato desejado que a distribuição da entrada seja cíclica, ou seja, que posições
sucessivas em um vetor de entrada sejam distribuídos dentre processos diferentes (PACHECO, 1997).
Existe outra abordagem para a Distribuição da entrada muito utilizada em programa
do tipo mestre-escravo, chamada Decomposição por Blocos; ao invés de enviar elementos
de um vetor sucessivos para processos diferentes, se enviam blocos de elementos sucessivos para processos diferentes. Tal abordagem visa economizar o custo da comunicação
envolvido no primeiro caso, visto que o envio de uma mensagem grande é mais eficiente
64
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
33
34
35
36
37
38
39
from modknapsack import readInstance
knapsack_instance = readInstance(sys.argv[1])
C = knapsackInstance[’size’]
v = knapsackInstance[’value’]
p = knapsackInstance[’weight’]
n = knapsackInstance[’itemnum’]
x = [0 for i in range(n)]
x[0] = C // p[0]
for j in range(1, n) :
x[j] = (C - sum([p[i]*x[i] for i in range(0, j)])) // s[j]
V_best = sum([v[i]*x[i] for i in range(0, n)])
x_best = x[:]
while True :
k = n-1
while (k >= 0) and (x[k] == 0) :
k = k - 1
if k == n-1 :
x[k] = 0
continue
if k >= 0 :
x[k] = x[k] - 1
V_temp = sum([v[i]*x[i] for i in range(0, k+1)])
P_temp = C - sum([p[i]*x[i] for i in range(0, k+1)])
if (V_temp + ((v[k+1] * P_temp) / p[k+1])) <= V_best :
continue
else :
k = k + 1
for j in range(k, n) :
x[j] = (C-sum([p[i]*x[i] for i in range(0, j)]))//p[j]
V = sum([v[i]*x[i] for i in range(0, n)])
if V > V_best :
x_best = x[:]
V_best = V
else :
break
Figura 4.2: Algoritmo Branch & Bound para o Problema da Mochila, em Python.
1
2
3
4
5
6
7
int i, j;
struct ks_instance_s ks_ins;
read_instance("instance.txt", &ks_ins);
int *x = (int *) calloc(ks_ins.item_num, sizeof(int));
int *x_best = (int *) calloc(ks_ins.item_num, sizeof(int));
int v_temp, b_temp;
int k;
Figura 4.3: Variáveis da implementação seqüencial que resolve o Problema da Mochila
(em C).
65
1 int
2 main(int argc, char* argv[])
3 {
4
(... VARS... )
5
x[0] = (int) (ks_ins.ks_capacity / ks_ins.item_weight[0]);
6
for ( j = 1; j < ks_ins.item_num; ++ j )
7
x[j] = (int) ((ks_ins.ks_capacity
8
- dotprod(ks_ins.item_weight, x, 0, j - 1))
9
/ ks_ins.item_weight[j]) ;
10
int v = 0;
11
int v_best = dotprod(ks_ins.item_value, x, 0, ks_ins.item_num - 1);
12
memcpy(x_best, x, ks_ins.item_num * sizeof(int) / sizeof(char));
13
14
k = ks_ins.item_num - 1;
15
while ( 1 ) {
16
while ( (k >= 0) && (x[k] == 0) )
17
k = k - 1;
18
if ( k == ks_ins.item_num - 1) {
19
x[k] = 0;
20
continue;
21
}
22
if ( k >= 0 ) {
23
x[k] = x[k] - 1;
24
v_temp = dotprod(ks_ins.item_value, x, 0, k);
25
b_temp = ks_ins.ks_capacity
26
- dotprod(ks_ins.item_weight, x, 0, k);
27
if ( (v_temp + ((ks_ins.item_value[k+1] * b_temp)
28
/ ks_ins.item_weight[k+1])) <= v_best ) {
29
printf("x[%d] = %d BOUND!\n", k, x[k]);
30
continue;
31
} else {
32
for ( j = k + 1; j < ks_ins.item_num; ++ j )
33
x[j] = (int) ((ks_ins.ks_capacity
34
- dotprod(ks_ins.item_weight, x, 0, j - 1))
35
/ ks_ins.item_weight[j]) ;
36
v= dotprod(ks_ins.item_value, x, 0, ks_ins.item_num-1);
37
if ( v > v_best ) {
38
memcpy(x_best,x, ks_ins.item_num
39
* sizeof(int)
40
/ sizeof(char));
41
v_best = v;
42
}
43
k = ks_ins.item_num -1;
44
}
45
} else {
46
break;
47
}
48
}
49 }
Figura 4.4: Algoritmo Branch & Bound para o Problema da Mochila, em C
66
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
33
34
read_instance(filename, &ks_ins);
x = (int *) calloc(ks_ins.item_num, sizeof(int));
branch(ks_ins, x, 0);
v = 0;
ks_sol.x = (int *) myalloc(ks_ins.item_num, sizeof(int));
ks_sol.v = dotprod(ks_ins.item_value, x, 0, ks_ins.item_num - 1);
memcpy(ks_sol.x, x, ks_ins.item_num * sizeof(int) / sizeof(char));
v = dotprod(ks_ins.item_value, x, 0, ks_ins.item_num - 1 );
k = ks_ins.item_num - 1;
while ( 1 ) {
while ( (k > 0) && (x[k] == 0) )
k = k - 1;
if ( k == ks_ins.item_num - 1) {
x[k] = 0;
continue;
}
if ( k > 0 ) {
x[k] = x[k] - 1;
if ( bound(ks_ins, x, k, ks_sol.v) ) {
continue;
} else {
branch(ks_ins, x, k + 1);
v = dotprod(ks_ins.item_value, x, 0, ks_ins.item_num - 1 );
update_best_sol(ks_ins, &ks_sol, x, v);
k = ks_ins.item_num - 1;
}
}
else
break;
}
free(x);
free(ks_sol.x);
free_instance(&ks_ins);
Figura 4.5: Algoritmo C com primitivas básicas para o Problema da Mochila
67
que o envio de várias pequenas, pois minimiza trocas de mensagens (DONGARRA et al.,
2003).
É nítido que, ao possibilitar o ajuste do tamanho do bloco enviado, na Decomposição
por Blocos, pode-se encarar a Decomposição Cíclica como um caso especial desta, onde
o tamanho do bloco é 1 (DONGARRA et al., 2003). Adicionalmente, para o Problema da
Mochila, é interessante distribuir um ramo da árvore, contado da raiz, para cada processo;
logo, o número de elementos que compõe a entrada para os processos do algoritmo paralelo que resolve o Problema da Mochila são os valores que x1 (doravante referenciado
como o código C, x[0]) pode assumir. A implementação da função de distribuição da
entrada é apresentada na Figura 4.6. A função retorna o identificador do processo que
deve processar aquele elemento da entrada e seu significado é apresentado na Tabela 4.1.
int
owner(int num_procs, int block_size, int index)
{
int block = (int) index/block_size;
int p = block % num_procs;
return p;
}
Figura 4.6: Função que realiza a distribuição cíclica da entrada.
Variável
Significado
int num_procs
Número total de processos.
int block_size Tamanho de bloco desejado.
int index
Posição da entrada a qual será distribuída.
Tabela 4.1: Significado das variáveis na função de distribuição cíclica da entrada.
Quando block_size é 1, há um simples hash da entrada, calculado através do módulo. Esta abordagem é a adotada, visto que o algoritmo não é do tipo mestre-escravo;
todos os processos calculam o valor ótimo de seus ramos, sem precisar receber a entrada
de alguém. Como não há envio (fora a distribuição inicial de dados que o MPI realiza
de forma transparente) de dados a processar e todos os processos possuem, inicialmente,
a entrada replicada, convém optar pela simples Distribuição Cíclica, visto os benefícios
óbvios que esta traz em relação ao balanceamento de carga; blocos muito grandes agregam fragmentação externa e, possivelmente, os processos de ranks (identificadores) mais
elevados computariam menos que processos de ranks mais baixos. A inserção desse mecanismo, bem como os preâmbulos MPI necessários à paralelização podem ser vistos
na Figura 4.7, onde a estrutura ks_sol guarda a solução (a distribuição em x[] que
apresenta o valor máximo, ks_sol.x e esse valor, ks_sol.v .).
A implementação apresentada do algoritmo que resolve o Problema da Mochila, paralelizado, ainda está incompleta; cada processo encontra sua distribuição de valor máximo
local, mas não há comunicação entre eles para determinar qual o melhor global, dentre
todos os ramos analisados. Este processo será abordado no capítulo seguinte, quando será
inserido num contexto mais complexo, ao se tratar de Difusão de Máximo Local, na parte
que tange o Workstealing.
68
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
read_instance(filename, &ks_ins);
x = (int *) calloc(ks_ins.item_num, sizeof(int));
branch(ks_ins, x, 0);
v = 0;
ks_sol.x = (int *) calloc(ks_ins.item_num, sizeof(int));
ks_sol.v = dotprod(ks_ins.item_value, x, 0, ks_ins.item_num - 1);
memcpy(ks_sol.x, x, ks_ins.item_num * sizeof(int) / sizeof(char));
int num_procs; // Number of procs.
int my_rank;
// Current proc’s rank.
MPI_Init(&argc, &argv);
MPI_Comm_size(MPI_COMM_WORLD, &num_procs);
MPI_Comm_rank(MPI_COMM_WORLD, &my_rank);
v = dotprod(ks_ins.item_value, x, 0, ks_ins.item_num - 1 );
k = ks_ins.item_num - 1;
while ( 1 ) {
while ( (k > 0) && (x[k] == 0) )
k = k - 1;
if ( k == ks_ins.item_num - 1) {
x[k] = 0;
continue;
}
if ( k > 0 ) {
x[k] = x[k] - 1;
if ( my_rank == owner(num_procs, 1, x[0]) ) {
if ( bound(ks_ins, x, k, ks_sol.v) ) {
continue;
} else {
branch(ks_ins, x, k + 1);
v = dotprod(ks_ins.item_value, x, 0,
ks_ins.item_num - 1 );
update_best_sol(ks_ins, &ks_sol, x, v);
k = ks_ins.item_num - 1;
}
}
}
else
break;
}
free(x);
free(ks_sol.x);
free_instance(&ks_ins);
MPI_Finalize();
Figura 4.7: Algoritmo C/MPI paralelizado.
69
5
WORKSTEALING
Este capítulo se dedica a apresentar técnicas de otimização do algoritmo paralelo
Branch & Bound que resolve o Problema da Mochila apresentado nos capítulos anteriores. O foco dessas técnicas será a técnica de Workstealing (BLUMOFE; LEISERSON,
1994), que busca garantir um balanceamento de carga ideal e distribuição homogênea
das tarefas entre os processos. Antes disso, no entanto, é necessário corrigir a deficiência apresentada no final do Capítulo 4, Problema da Mochila, oferecendo uma maneira
de comparar os máximos locais e obter um máximo global. Essa correção não faz parte
do algoritmo de WS em si. No entanto, como ambos são implementados com a mesma
arquitetura, é interessante os colocar em seqüência.
5.1
Difusão de Máximo Local
A questão não é apenas a difusão dos máximos locais ao final da computação de todos
os processos. É interessante que os processos, durante a execução das tarefas a eles atribuídas, tenham a informação de qual é o atual valor máximo global; com isso, é possível
otimizar o uso do bound de forma a cortar um número maior de galhos que não apresentam potencial para obter uma solução ótima. Esta troca de informações, entretanto, deve
permitir que os processos continuem a computar, enquanto esperam por uma notificação
de que um novo melhor foi encontrado.
A maneira mais eficiente de esperar a notificação mencionada seria através do uso
de Threads. Entretanto, as implementações MPI ainda não conseguem ter um comportamento estável quando utilizam tal recurso. Uma solução paliativa, no caso, é o emprego
de comunicação não-bloqueante.
5.1.1
Algoritmo
O algoritmo empregado é bem simples, onde segue
1. Ao achar um novo máximo local, fazer um broadcast não-bloqueante a todos os
processos;
2. Um processo, ao receber uma notificação de que existe um novo melhor local, compara este, recebido na notificação, com o que possui (ks_sol.v). Então, duas
coisas podem ocorrer:
(a) Substituição do atual melhor, local, pelo melhor global. Neste caso uma indicação de que o atual melhor não foi encontrado pelo processo local é indicada zerando-se todos os elementos do vetor que fornece a melhor solução,
ks_sol.x;
70
(b) Não substituição do melhor local pelo global. Pode acontecer se os tempos
relativos entre a descoberta e o envio do novo máximo, entre dois ou mais
processos, for aproximado. Neste caso, a mensagem é simplesmente descartada.
3. Ao final de sua execução, um processo envia uma mensagem de fim de execução a
todos os outros.
4. Ao receber a mensagem de fim, o processo subtrai uma unidade do número de
processos ativos, que todos os processos guardam.
5.1.2
Implementação
A implementação desta difusão é de especial interesse, pois é construída com a mesma
arquitetura que é empregada na implementação do Workstealing.
MPI não possui uma implementação de broadcast assíncrono, por questões de deadlock. Logo, deve-se implementar tal recurso e, ainda, garantir que o deadlock não
ocorra. A prova formal de que ele não ocorre será apresentada no Capítulo 6, Avaliação
de Desempenho. Apenas sua implementação isenta de travas é mostrada. O broadcast
assíncrono, ao mandar uma mensagem, é implementado pela função vista na Figura 5.1,
onde MPI_Send(...) é usado no lugar de MPI_Isend porque poupa gerenciamento
de buffer e não depende de outros processos para se desbloquear. Apenas o recebimento
desta mensagem deve ser não-bloqueante, para a sincronização.
int
mpi_assync_broadcast(void *data, int num_item, MPI_Datatype datatype,
int tag, MPI_Comm comm, int num_procs, int my_rank)
{
int i;
for ( i = 0; i < num_procs; ++ i )
if ( i != my_rank )
MPI_Send(data, num_item, datatype, i, tag, comm);
return 1;
}
Figura 5.1: Implementação de broadcast assíncrono.
Para gerenciar o recebimento destes dados, é necessária uma lógica mais mais complexa. A opção escolhida foi montar um estrutura de dados gerenciadora, com todas as
variáveis e funções (interface) implementadas de maneira modular, por dois motivos:
1. Dar suporte futuro a uma biblioteca feita sobre MPI para a Difusão de Máximo
Local genérica; e
2. Melhor encapsulamento, controle e depuração, ao eliminar replicação de código.
A definição do gerenciador de difusão é dada na Figura 5.2. Os dois MPI_Request
atendem, cada um, um tipo de mensagem, em modelo semelhante ao que foi abordado na
Subseção 3.2.5. v_global guarda o atual melhor notificado e num_active_procs
guarda quantos processos ainda estão ativos, para prevenir o deadlock, através da primitiva1 mpi_process_nonblocking(...), explanada a seguir.
1 Note-se
que, até aqui, todas as funções implementadas que usem alguma primitiva MPI tem seu nome
71
/* Message Types. */
#define NEW_BEST_MSG
#define END_MSG
0
1
struct mpi_dif_manager_s {
MPI_Request request_end_msg;
MPI_Request request_new_best_msg;
int v_global;
int null; // For unimportant data.
int num_active_procs;
int whoinf; // Who gave this data.
};
void mpi_init_dif_manager(struct mpi_dif_manager_s *mpi_man,
int num_procs);
void mpi_free_dif_manager(struct mpi_dif_manager_s *mpi_man);
void mpi_process_nonblocking(struct mpi_dif_manager_s *mpi_man,
struct ks_solution_s *ks_sol,
struct ks_instance_s ks_ins);
int active_procs_remain(struct mpi_dif_manager_s mpi_man);
int mpi_nonblocking_broadcast(void *data,
int num_item,
MPI_Datatype datatype,
int tag,
MPI_Comm comm,
int num_procs,
int my_rank);
Figura 5.2: Implementação do gerenciador de difusão.
72
A função mpi_process_nonblocking(...) testa a recepção de uma mensagem de fim de computação ou de novo máximo local encontrado e deve ser invocada toda
vez que se quiser tratar o recebimento de uma mensagem de difusão ao longo do código.
Como o próprio nome já diz, ela é não bloqueante. Isso torna a verificação atômica e
garante tempo de processamento reservado. Em geral é útil invocá-la a cada nova geração
de um conjunto candidato a solução x[], dando-lhe periodicidade. O tratamento dessa
mensagem é feito automaticamente pela função, bem como a atualização do atual melhor
valor (através do ponteiro para ks_sol), de maneira transparente ao programador, que
não precisa adicionar linhas de código para tratar cada mensagem.
Esta transparência da mpi_process_nonblocking(...) tem pouco efeito sobre a lógica do algoritmo, simplificando a programação. No entanto, o tratamento destas
mensagens exige certos detalhes que a função deve atender, para que não haja deadlock.
A implementação desta função pode ser observada na Figura 5.3.
void
mpi_process_nonblocking(struct mpi_dif_manager_s *mpi_dif_man,
struct ks_solution_s *ks_sol,
struct ks_instance_s ks_ins )
{
int flag;
// Flag for non-blocking comm.
MPI_Status status; // Status for recv-like comm.
MPI_Test(&(mpi_dif_man -> request_new_best_msg), &flag, &status);
if ( flag != 0 ) {
if ( (mpi_dif_man -> v_global) >= (ks_sol -> v) ) {
ks_sol -> v = mpi_dif_man -> v_global;
mpi_dif_man -> whoinf = status.MPI_SOURCE;
}
MPI_Irecv(&(mpi_dif_man -> v_global),
1,
MPI_INT,
MPI_ANY_SOURCE,
NEW_BEST_MSG,
MPI_COMM_WORLD,
&(mpi_dif_man -> request_new_best_msg));
}
MPI_Test(&(mpi_dif_man -> request_end_msg), &flag, &status);
if ( flag != 0 ) {
-- (mpi_dif_man -> num_active_procs);
MPI_Irecv(&(mpi_dif_man -> null),
1,
MPI_INT,
MPI_ANY_SOURCE,
END_MSG,
MPI_COMM_WORLD,
&(mpi_dif_man -> request_end_msg));
}
}
Figura 5.3: Implementação de mpi_process_nonblocking().
precedido por mpi_. Faz-se isso para (a) diferenciar as funções implementadas na especificação MPI e as
específicas do problema, (b) identificar, facilmente, quais funções fazem E/S de troca de mensagens em alto
nível.
73
A função mpi_init_dif_manager(...) inicializa as requests e a função acima
testa o recebimento de mensagens sobre elas, chamando-as novamente após o recebimento de alguma mensagem. Ao receber uma mensagem de fim, simplesmente subtrai o
número de processos ativos de uma unidade.
Quando um processo acaba sua computação e envia sua mensagem de fim a todos
os outros, é necessário que ele cancele seus receives não-bloqueantes. No entanto, esta
abordagem simplista introduz alguns problemas, a saber:
1. Os processos não tem controle de quais outros processos acabam, apenas do número total dos processos ativos. Logo, os outros processos vão enviar mensagens
normalmente que, sem ninguém para recebê-las, esgotarão o buffer;
2. Mesmo que um controle seja implementado, pode ser que o processo se encerre
existindo mensagens destinadas a ele transitando na rede, lotando o buffer;
3. Se for optado por não cancelar os receives, então a LAM MPI travará quando chamar MPI_Finalize(...), pois ocorrerá deadlock dentre seus processos (para
que a LAM MPI encerre, é necessário que os buffers de mensagens estejam vazios,
ou seja, que todos os sends tenham um receive correspondente).
A solução para evitar o deadlock, neste caso, é fazer com que todos os processos só
cancelem as suas comunicações não-bloqueantes quando tiverem certeza de que nenhum
dos outros processos pode mandar mensagens ou que mensagens estão em trânsito; em
outras palavras, um processo só deve para de receber mensagens quando todos chegarem
ao final.
A solução óbvia que surge é a implementação de um algoritmo de detecção de término
distribuído (JAJA, 1992). No entanto, o cenário de aplicações de um algoritmo deste
tipo é mais restrito, sem comunicação broadcast, e, por isso, sua implementação é mais
complexa do que o necessário.
Algo que serve bem para sanar o problema apresentado é uma sincronização do tipo
barreira2 (TOSCANI; OLIVEIRA; SILVA CARíSSIMI, 2003). De fato, MPI oferece
MPI_Barrier(...) para esse propósito. No entanto, a solução MPI não é adequada;
os processos não podem ficar parados enquanto os outros processos computam, sendo
necessário que eles ainda possam receber notificações de novos melhores encontrados
para que, ao final da computação, todos tenham a mesma resposta (global) que soluciona
o problema.
É necessário implementar um mecanismo de barreira de mais alto nível, em que um
processo, ao acabar sua computação, fique apenas recebendo notificações de novo máximo ou de fim de processo. Apenas quando uma mensagem de fim é recebida de todos
os outros processos é que o processo se encerra. Tal mecanismo, no entanto, é de fácil
implementação (ao final do código), conforme a Figura 5.4. A cada mensagem de fim
que chega, o número de processos ativos é decrementado e, apenas quando todos informam isto é que todos estão livres para terminar a computação. Isto também garante que
condições de corrida não ocorram; um ponto de barreira, ao final, garante que todos os
processos de fato receberam a última mensagem de notificação, anterior à mensagem end
e que todos possuem a mesma resposta.
A integração do código mostrado com a Difusão de Máximo Local é mostrado na
Figura 5.5.
2 Uma
sincronização em que todos os fluxos de programa ficam parados ao atingirem certo ponto do
código e só prosseguem quando todos estiverem neste mesmo ponto.
74
/* Prevents deadlock and the race condition. */
while ( active_procs_remain(mpi_dif_man) )
mpi_process_nonblocking(&mpi_dif_man, &ks_sol, ks_ins);
Figura 5.4: Implementação do mecanismo de barreira.
5.2
Workstealing
A técnica de Workstealing (BLUMOFE; LEISERSON, 1994) é um conceito formalizado academicamente, mas que ainda não encontrou uma implementação genérica que
se estabelecesse como padrão de facto. Muitos autores publicaram sua síntese sob outros
nomes e.g., árvore de busca paralela (PACHECO, 1997), mas a essência do algoritmo é
mantida.
A técnica tem, por objetivo, obter uma balanceamento de carga entre os processos.
Isto significa que, ao garantir uma distribuição igualitária de tarefas, os processos ficarão menos ociosos e, portanto, será obtida uma exploração maior do paralelismo. Além
disso, o método possui robustez suficiente para equilibrar cargas que, pela definição do
problema, são naturalmente desbalanceadas (e.g., árvore degenerada). Esta monografia se
propõe a provar, na prática, se esta solução obtém o ganho de desempenho teórico a que
se propõe.
5.2.1
Algoritmo
Os elementos necessários para a implementação são:
1. Cada processo possuir uma pilha de tarefas. Estas tarefas são, ao longo da execução,
desempilhadas e processadas;
2. Mensagens específicas, definidas previamente, de solicitação de trabalho, de resposta com envio de trabalho e resposta vazia.
A seqüência clássica de operações (BLUMOFE; LEISERSON, 1994) realizadas pelos
processos consiste em
1. Caso a pilha de tarefas não esteja vazia, desempilhar as tarefas, seqüencialmente, e
realizá-las, uma a uma;
2. Caso a pilha estiver vazia, escolher aleatóriamente um processo e solicitar trabalho
para este;
3. Se o processo fornecer tarefas, empilha-las e ir para 1. Caso contrário, ir para 4;
4. Caso o processo não forneça tarefas, repetir 2 até que se receba uma tarefa ou um
timeout pré-definido seja atingido;
5. Parar.
No início do algoritmo tradicional, todos os processos começam com suas pilhas vazias, a exceção de um, cuja pilha contém todas as tarefas. Desta maneira, o balanceamento
de carga ocorre naturalmente, enquanto o algoritmo se desenvolve.
75
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
33
34
35
36
37
(...INIT VARs...)
MPI_Init(&argc, &argv);
MPI_Comm_size(MPI_COMM_WORLD, &num_procs);
MPI_Comm_rank(MPI_COMM_WORLD, &my_rank);
v = dotprod(ks_ins.item_value, x, 0, ks_ins.item_num - 1 );
k = ks_ins.item_num - 1;
while ( 1 ) {
while ( (k > 0) && (x[k] == 0) )
k = k - 1;
if ( k == ks_ins.item_num - 1) {
x[k] = 0;
continue;
}
if ( k > 0 ) {
x[k] = x[k] - 1;
if ( my_rank == owner(num_procs, 1, x[0]) ) {
if ( bound(ks_ins, x, k, ks_sol.v) ) {
continue;
} else {
branch(ks_ins, x, k + 1);
v= dotprod(ks_ins.item_value, x, 0, ks_ins.item_num-1);
if ( update_best_sol(ks_ins, &ks_sol, x, v) ) {
/* The one that have the maximum is me! */
mpi_dif_man.whoinf = my_rank;
/* Broadcast the new local maximun. */
mpi_assync_broadcast(&(ks_sol.v), 1, MPI_INT,
NEW_BEST_MSG, MPI_COMM_WORLD, num_procs,
my_rank );
}
}
}
}
else
break;
}
mpi_nonblocking_broadcast(&(ks_sol.v), 1, MPI_INT, END_MSG,
MPI_COMM_WORLD, num_procs, my_rank);
38
39 /* Prevents deadlock and the race condition. */
40 while ( active_procs_remain(mpi_dif_man) )
41
mpi_process_nonblocking(&mpi_dif_man, &ks_sol, ks_ins);
42 /* Cancels the non-blocking comms. */
43 mpi_free_dif_manager(&mpi_dif_man);
44
45 free(x);
46 free(ks_sol.x);
47 free_instance(&ks_ins);
48 MPI_Finalize();
Figura 5.5: Algoritmo C/MPI paralelizado e com Difusão de Máximo Local.
76
5.2.2
Considerações
Para fins da aplicação neste trabalho, o algoritmo clássico sofreu algumas modificações. Tais modificações foram introduzidas visando obter maior desempenho, modificando certas premissas do algoritmo original. As alterações foram inseridas com especial
cuidado para evitar a ocorrência de deadlocks, já que tocam na questão da sincronização
do envio de mensagens.
Inicialmente, ao invés de um único processo possuir a pilha cheia, todas as pilhas são
abastecidas com tarefas via Distribuição Cíclica (vide Subseção 4.5.2). Isso faz com que
o fluxo de troca de mensagens grande que ocorre no início o Workstealing cesse, o que
provoca:
1. Menor execução de primitivas de troca de mensagens (E/S);
2. Menor tráfego na rede;
3. Balanceamento de carga inicial (diminui a necessidade de troca de mensagens ao
longo da execução);
4. Melhor aproveitamento do tipo de entrada do problema considerado (Problema da
Mochila).
Todos os fatores acima parecem contribuir para um aumento direto do desempenho (a
ser verificado no Capítulo 6, Avaliação de Desempenho.). Além disso, visando melhorar a
distribuição de processos, a estrutura de dados que armazena tarefas não é uma pilha, mas
sim uma fila de distribuição dupla (double sided queue3 ). Esta organização permite que
o a resolução das tarefas enxergue a estrutura de dados como uma pilha e que o roubo de
tarefas enxergue a mesma situação como uma fila (Figura 5.6). Como, heuristicamente,
as soluções que maximizam os elementos inicias de x[] oferecem maior potencialidade
de serem a solução ótima, pode-se empilhá-las por último. Isto garante um bom índice
de bound (pois serão desempilhadas primeiro) ao mesmo tempo em que, ao atender uma
solicitação de roubo, o processo fornecerá tarefas menos promissoras, que são ideais para
sofrerem um maior atraso, devido a latência de rede.
Outras alterações foram feitas para coibir a ocorrência de postergação indefinida. Ao
imaginar um cenário onde a mesma tarefa é repassada circularmente entre todos os processos, sem nunca ser processada e sem nunca atingir o timeout fica evidente que, dentro
do modelo clássico, a postergação indefinida é possível. Para contornar esta situação,
adotam-se duas medidas:
1. Apenas uma tarefa é passada por vez a cada solicitação de tarefas atendidas; e
2. Uma tarefa recebida por meio de workstealing é imediatamente processada e não
pode ser repassada.
O primeiro item tem implementação trivial. O segundo, no entanto, é mais complicado; a função que trata o recebimento de mensagens se comunica com os processos
apenas via pilha. Para contornar esta limitação, opta-se por restringir o uso da primeira
posição da pilha; qualquer tarefa alocada neste espaço nunca é enviada por Workstealing.
Como, ao solicitar tarefas, um processo indica que sua pilha está vazia, qualquer tarefa
recebida se alojará nesta posição e não correrá o risco de ser repassada.
3 Esta
estrutura de dados que possui característica de incluir dados somente através de um ponteiro para
início da fila, mas de retirar dados através de qualquer uma de suas extremidades.
77
Figura 5.6: Esquema de funcionamento – Fila de Distribuição Dupla
Estas simples medidas claramente (a) evitam que uma tarefa seja repassada ad infinitum (eliminando o risco de postergação indefinida) e (b) uniformizam e simplificam o
processo de implementação do Workstealing.
5.2.3
Implementação
Seguindo a ordem apresentada na subseção anterior, primeiramente deve-se modificar o código para que este processe o conceito de tarefas e consiga empilhar estas tarefas numa estrutura de fila de distribuição dupla. Inicialmente, o problema foi modelado
definindo-se uma tarefa como um conjunto x[] (galho) e a computação dessa tarefa como
o cálculo do valor de sua utilidade e sua comparação com a melhor utilidade local. Essa
abordagem, no entanto, revelou-se muito ruim, pois gera:
Mal aproveitamente de recursos computacionais. A complexidade do Problema da Mochila reside na geração de todos os conjuntos x[] e não no cálculo de seu valor.
Balancear a tarefa menos complexa é um grande fator de desperdício de recursos,
pois a tarefa mais pesada fica desbalanceada;
Consumo intratável de memória. Em testes realizado no cluster, a execução desse problema com 100 elementos consumiu memória RAM na ordem de 2GB. Como a
geração de conjuntos é uma operação exponencial e cada conjunto tem um grande
número de elementos, o consumo de memória torna-se, também, exponencial, o
que é intratável.
A abordagem alternativa (e que foi adotada), é considerar que uma tarefa é um número
inteiro, que representa um dos valores possíveis de x[0]. Com isso, cada tarefa passa a
ser o cálculo de todas as sub-árvores cujo primeiro elemento possui o valor associado ao
inteiro que representa a tarefa. Essa abordagem sana os defeitos da aproximação anterior
ao problema.
Definindo uma pilha e a noção de tarefa, a estrutura de dados (com a respectiva interface) é implementada conforme a Figura 5.7.
78
struct task_dfq_s {
struct task_dfq_node_s *start;
struct task_dfq_node_s *end;
int size;
};
struct task_dfq_node_s {
int task;
struct task_dfq_node_s *next;
struct task_dfq_node_s *previous;
};
int init_dfq(struct task_dfq_s* dfq);
int pop(struct task_dfq_s* dfq, int task);
int push_front(struct task_dfq_s* dfq);
int push_back(struct task_dfq_s* dfq);
int is_empty(struct task_dfq_s dfq);
int get_size(struct task_dfq_s dfq);
Figura 5.7: Implementação da fila de distribuição dupla.
A inserção do conceito de pilha no código é simples (Figura 5.8), mas tem uma consideração importante. Como agora os valores de x[0] não iniciam necessariamente no
máximo (pois esse valor representa uma tarefa), o valor de x[1] é que deve ser maximizado, visando uma execução correta do algoritmo guloso de geração de galhos.
O próximo passo é implementar a troca de mensagens em si. Para isso, usa-se uma
construção análoga ao gerenciador de difusão apresentado anteriormente. Conforme já
explicitado, um dos focos do trabalho realizado é oferecer as bases a um futuro suporte
de uma biblioteca Workstealing genérica. Embora alguns fatores ainda dependam fortemente de elementos do Problema da Mochila, sua generalização é simples, mesmo que
trabalhosa. Procurou-se, aqui, oferecer uma versão primitiva da abordagem de callbacks,
presente em linguagens de paradigma de Orientação a Objetos, que permite uma generalização fortíssima a qualquer aplicação, bastando reescrever algumas funções que são
chamadas internamente, em operações desta biblioteca.
Todas as considerações apresentadas sobre a construção de ambos os gerenciadores
mantém a validade, gerando, inclusive, funções colocadas nos mesmos lugares. A estrutura de dados do gerenciador Workstealing é visitada na Figura 5.9. As funções da interface do gerenciador mais importantes são mpi_ws_process_nonblocking(...)
(responsável por atender às requisições de roubo) e mpi_workstealing(...) (responsável por fazer as solicitações de roubo).
A função mpi_ws_process_nonblocking(...), como o próprio nome sugere, é extremamente semelhante a mpi_process_nonblocking(...), tendo modelos idênticos. É verificado se chegou mensagem de solicitação de tarefas
(WS_REQUEST_WORK_MSG) e o tratamento dado consiste em, verificando se a pilha do
atual processo tem tarefas, enviá-las ao processo solicitante (mensagem tipo
WS_TASK_MSG) ou enviar uma mensagem de que não há tarefa disponível
(WS_EMPTY_MSG). O cancelamento do receive é efetuado após a saída da barreira estabelecida pela Difusão de Máximo Local, aproveitando sua característica de ponto de
retenção, para também evitar deadlock.
A essência do algoritmo, no entanto, reside na função mpi_workstealing(...).
Procura-se, ao máximo, dar transparência a esta função, que intermedia o acesso à pilha
79
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
33
34
35
36
37
38
39
40
41
42
43
44
45
MPI_Init(&argc, &argv);
MPI_Comm_size(MPI_COMM_WORLD, &num_procs);
MPI_Comm_rank(MPI_COMM_WORLD, &my_rank);
mpi_init_dif_manager(&mpi_dif_man, num_procs);
// Builds a task and initializes the "stack".
init_dfq(&task_stack);
for ( i = 0; i <= x[0]; i ++ )
if ( my_rank == owner(num_procs, 1, i) )
pop(&task_stack, i);
// Points to end of the array, for dissembling the recursivity.
k = ks_ins.item_num - 1;
while ( ! is_empty(&task_stack) ) {
x[0] = push_front(&task_stack);
branch(ks_ins, x, 1);
v = dotprod(ks_ins.item_value, x, 0, ks_ins.item_num - 1 );
while ( 1 ) {
mpi_process_nonblocking(&mpi_dif_man, &ks_sol, ks_ins);
while ( (k > 0) && (x[k] == 0) )
k = k - 1;
if ( k == ks_ins.item_num - 1) {
x[k] = 0;
continue;
}
if ( k > 0 ) {
x[k] = x[k] - 1;
if ( bound(ks_ins, x, k, ks_sol.v) ) {
continue;
} else {
branch(ks_ins, x, k + 1);
v = dotprod(ks_ins.item_value, 0, ks_ins.item_num-1 );
if ( update_best_sol(ks_ins, &ks_sol, x, v) ) {
mpi_dif_man.whoinf = my_rank;
mpi_nonblocking_broadcast(&(ks_sol.v), 1, MPI_INT,
NEW_BEST_MSG, MPI_COMM_WORLD, num_procs,
my_rank );
}
k = ks_ins.item_num - 1;
}
}
else
break;
}
}
mpi_assync_broadcast(&(ks_sol.v), 1, MPI_INT, END_MSG, MPI_COMM_WORLD,
num_procs, my_rank);
46
47 /* Prevents deadlock and the race condition. */
48 while ( active_procs_remain(mpi_dif_man) )
49
mpi_process_nonblocking(&mpi_dif_man, &ks_sol, ks_ins);
50 mpi_free_dif_manager(&mpi_dif_man);
51 (...)
52 MPI_Finalize();
Figura 5.8: Algoritmo C/MPI, versão com fila de distribuição dupla
80
/* Message Types */
#define WS_REQUEST_WORK_MSG
#define WS_TASK_MSG
#define WS_EMPTY_MSG
2
3
4
/* Request Types. */
#define WS_NUM_MSG_TYPE
#define WS_TASK_REQUEST
#define WS_EMPTY_REQUEST
2
0
1
struct mpi_ws_manager_s {
MPI_Request request_ws_work_msg;
struct ks_instance_s *ks_ins;
struct task_dfq_s *stack;
struct proc_table_s ws_proc_table;
int my_rank;
int num_procs;
int null;
};
int mpi_init_workstealing(struct mpi_ws_manager_s *workst, struct
task_dfq_s *stack, struct ks_instance_s *ks_ins, int my_rank, int
num_procs );
int mpi_workstealing(struct mpi_ws_manager_s *workst);
int mpi_free_workstealing(struct mpi_ws_manager_s *workst);
int mpi_ws_process_nonblocking(struct mpi_ws_manager_s *workst);
Figura 5.9: Implementação do gerenciador de Workstealing.
de tarefas e, se esta se encontra vazia, faz a solicitação de tarefas. Encarando-se como
uma máquina de estados, isso traz a seqüência:
1. Se existem tarefas na pilha, entrega a tarefa do topo da pilha ao processo;
2. Caso não exista tarefa na pilha, escolhe, dentre todos os processos, um, aleatoriamente;
3. Requisita-se tarefa ao processo escolhido;
4. Recebendo-se uma tarefa, entrega-a ao processo;
5. Recebendo uma negativa de tarefa, marca-se o processo em uma tabela como sem
tarefas, escolhe-se outro processo e repetindo-se a tentativa;
6. Caso todos os processos existentes estejam marcados como sem tarefas, acaba-se a
computação.
Note-se que o timeout é suprimido; é garantido, pela abordagem da implementação
(já apresentada), que não há risco de postergação indefinida. Adicionalmente, pode-se
notar que, uma vez que um processo seja identificado como sem tarefas, não há risco
de ignorar-se uma eventual realimentação da pilha; como já foi estatizado, o algoritmo é
implementado sem que esta ocorra, para que uma tarefa não seja repassada ad infinitum.
Na Figura 5.10, a estrutura de tabela de processos que foi empregada. O código da função
mpi_process_nonblocking(...) pode ser visto na Figura 5.11. O código do
programa com as partes relevantes, todo integrado com o que foi apresentado, pode ser
visto na Figura 5.12.
81
struct proc_table_s {
int *vector;
int size;
};
int init_proc_table(struct proc_table_s *table, int num_procs);
int free_proc_table(struct proc_table_s *table);
int set_all_active(struct proc_table_s *table);
int set_all_inactive(struct proc_table_s *table);
int set_proc_active(struct proc_table_s *table, int proc_rank);
int set_proc_inactive(struct proc_table_s *table, int proc_rank);
int is_proc_active(struct proc_table_s *table, int proc_rank);
int exist_active_proc(struct proc_table_s *table);
Figura 5.10: Implementação da tabela de processos.
82
1 int
2 mpi_workstealing(struct mpi_ws_manager_s *workst)
3 {
4
int i;
5
int msg;
6
int target_proc;
7
int task = 0;
8
int ws_flag;
9
MPI_Request ws_request[WS_NUM_MSG_TYPE];
10
MPI_Status ws_status;
11
int ws_index;
12
13
if ( ! is_empty(*(workst -> stack)) )
14
return 1;
15
set_all_active(&(workst -> ws_proc_table));
16
set_proc_inactive(&(workst -> ws_proc_table), workst -> my_rank);
17
18
while ( exist_active_proc(&(workst -> ws_proc_table)) ) {
19
20
do { target_proc = rand() % (workst -> num_procs); }
21
while ( ! is_proc_active(&(workst -> ws_proc_table),
target_proc) );
22
23
msg = WS_REQUEST_WORK_MSG;
24
MPI_Send(&msg, 1, MPI_INT, target_proc, WS_REQUEST_WORK_MSG,
MPI_COMM_WORLD);
25
MPI_Irecv(&task, 1, MPI_INT, target_proc, WS_TASK_MSG,
MPI_COMM_WORLD, &(ws_request[WS_TASK_REQUEST]) );
26
MPI_Irecv(&task, 1, MPI_INT, target_proc, WS_EMPTY_MSG,
MPI_COMM_WORLD, &(ws_request[WS_EMPTY_REQUEST]) );
27
28
ws_flag = 0;
29
do {
30
MPI_Testany(WS_NUM_MSG_TYPE, ws_request, &ws_index,
31
&ws_flag, &ws_status );
32
mpi_ws_process_nonblocking(workst, benchmark);
33
} while ( ws_flag == 0 );
34
for (i = 0; i < WS_NUM_MSG_TYPE; i ++)
35
if ( i != ws_index)
36
MPI_Cancel(&(ws_request[i]));
37
38
switch ( ws_status.MPI_TAG ) {
39
case WS_TASK_MSG :
40
pop(workst -> stack, task);
41
return 1;
42
43
case WS_EMPTY_MSG :
44
set_proc_inactive(&(workst -> ws_proc_table),
45
ws_status.MPI_SOURCE);
46
break;
47
}
48
}
49
return 0;
50 }
Figura 5.11: Solicitação de tarefas - Workstealing.
83
1 (...)
2 while ( mpi_workstealing(&workst) ) {
3
x[0] = push_front(&task_stack);
4
branch(ks_ins, x, 1);
5
v = dotprod(ks_ins.item_value, x, 0, ks_ins.item_num - 1 );
6
while ( 1 ) {
7
mpi_process_nonblocking(&mpi_dif_man, &ks_sol, ks_ins);
8
mpi_ws_process_nonblocking(&workst);
9
while ( (k > 0) && (x[k] == 0) )
10
k = k - 1;
11
if ( k == ks_ins.item_num - 1) {
12
x[k] = 0;
13
continue;
14
}
15
if ( k > 0 ) {
16
x[k] = x[k] - 1;
17
if ( bound(ks_ins, x, k, ks_sol.v) ) {
18
continue;
19
} else {
20
branch(ks_ins, x, k + 1);
21
v = dotprod(ks_ins.item_value, x, 0,
22
ks_ins.item_num - 1 );
23
if ( update_best_sol(ks_ins, &ks_sol, x, v) ) {
24
mpi_dif_man.whoinf = my_rank;
25
mpi_nonblocking_broadcast(&(ks_sol.v), 1, MPI_INT,
NEW_BEST_MSG, MPI_COMM_WORLD, num_procs,
my_rank );
26
}
27
k = ks_ins.item_num - 1;
28
}
29
}
30
else
31
break;
32
}
33 }
34 mpi_nonblocking_broadcast(&(ks_sol.v), 1, MPI_INT, END_MSG,
MPI_COMM_WORLD, num_procs, my_rank);
35
36 /* Prevents deadlock and the race condition. */
37 while ( active_procs_remain(mpi_dif_man) ) {
38
mpi_process_nonblocking(&mpi_dif_man, &ks_sol, ks_ins);
39
mpi_ws_process_nonblocking(&workst, &benchmark);
40 }
41 mpi_free_dif_manager(&mpi_dif_man);
42 mpi_free_workstealing(&workst);
43 (...)
Figura 5.12: Implementação MPI com Workstealing.
84
6
AVALIAÇÃO DE DESEMPENHO
Este capítulo dedica-se a medir e avaliar o desempenho da aplicação implementada e
discutida nos capítulos anteriores.
Os itens a serem avaliados foram:
1. velocidade de execução;
2. utilização de memória;
3. balanceamento de carga.
Todos os testes foram realizados no cluster labtec, do GPPD/UFRGS. Os experimentos foram realizados utilizando 10 UCPs Pentium III 1.1 GHz com 1 GiB de memória
RAM. A rede de comunicação é Gigabit Ethernet.
6.1
Implementação
Para se efetuar as medições, foi criada uma estrutura gerenciadora de benchmarks.
Certas funções desta estrutura são chamadas quando um evento ocorre e as medições
vão sendo registradas. Ao final da execução, tudo é impresso em um arquivo que tem o
formato visto na Figura 6.1 e cujo significado é descrito na Tabela 6.1.
EXTIME > xxxx
MUSAGE[0] > xxxx
STACK[0] > xxxx
WSSEND[0] > xxxx
WSRECV[0] > xxxx
.
.
.
MUSAGE[<num_proc-1>] > xxxx
STACK[<num_proc-1>] > xxxx
WSSEND[<num_proc-1>] > xxxx
WSRECV[<num_proc-1>] > xxxx
Figura 6.1: Formato do arquivo de saída.
Por condição de corrida, não se pode garantir que as medições serão impressas no arquivo na ordem desejada, visto que os tempos relativos dos processos variam. No entanto,
o script Python construído para processar os dados do benchmark faz uma análise léxica
do arquivo e obtém os dados corretos.
85
Item
EXTIME > xxxx
MUSAGE[n] > xxxx
STACK[n] > xxxx
Significado
Programa executado em xxxx segundos
Processo n consumiu xxxx bytes de memória.
Tamanho inicial da pilha de tarefas do processo
n em bytes.
WSSEND[n] > xxxx Número de tarefas enviadas por Workstealing
no processo n.
WSRECV[n] > xxxx Número de tarefas recebidas por Workstealing
no processo n.
Tabela 6.1: Significado dos arquivos de saída do programa.
A estrutura de benchmark deve ser passada como parâmetro em algumas funções,
modificando seu protótipo; as modificações introduzidas, no entanto, são mínimas. Considerações mais relevantes sobre esta implementação serão feitas quando necessário. A
interface da estrutura é apresentada na Figura 6.2. As funções do tipo set_flag informam se aquele parâmetro deve ser medido, enquanto as do tipo get têm função óbvia.
myalloc(...) tem função específica, abordada mais adiante.
struct benchmark_s {
int EXTIME_FLAG;
int MEM_FLAG;
int VERBOSE_FLAG;
int LOAD_FLAG;
double start_time;
int mem_usage;
int sstack;
int nwssend;
int nwsrecv;
};
void init_benchmark(struct benchmark_s *benchmark);
void set_extime_flag_on(struct benchmark_s *benchmark);
void set_mem_flag_on(struct benchmark_s *benchmark);
void set_verbose_flag_on(struct benchmark_s *benchmark);
void set_load_flag_on(struct benchmark_s *benchmark);
void *myalloc(int nvar, int svar, struct benchmark_s *benchmark);
int get_mem_usage(struct benchmark_s *benchmark);
void start_time_count(struct benchmark_s *benchmark);
double get_time_count(struct benchmark_s *benchmark);
int get_stack_size(struct benchmark_s *benchmark);
int get_wssend(struct benchmark_s *benchmark);
int get_wsrecv(struct benchmark_s *benchmark);
Figura 6.2: Interface da estrutura de Benchmark.
6.2
Elaboração dos Casos de Teste
Os casos de teste foram gerados automaticamente por um script Python escrito especialmente para isso. O script recebe como parâmetro o número de itens total e fornece, para
cada item, um valor de utilidade e um peso. Tal programa produz estes valores de maneira
86
aleatória, o que introduz um problema: é difícil gerar casos bem balanceados1 por causa
do advento do bound; não importa o quão grande seja uma entrada, bounds realizados
com sucesso no início da execução fazem o tempo de resolução ser muito pequeno2 .
É conveniente, para a análise de desempenho, que o tempo de resolução seja diretamente proporcional ao tamanho da entrada do algoritmo sempre e não apenas no pior
caso. Baseado nisso, as seguintes medidas são tomadas para obter o cenário da avaliação:
• Desabilitar o bound no programa sendo testado;
• O gerador de casos de teste gera itens com o peso máximo equivalente a metade
da capacidade da mochila para, heuristicamente, tentar usar mais itens distintos na
solução encontrada;
• O gerador de casos de teste gera utilidades diversas de 1 a 1000 para os itens,
para que, heursticamente, o espaço do valor de utilidades se distribua entre os itens
de maneira homogênea (sem muitas ocorrências de itens com o mesmo peso mas
utilidades diferentes), visando reduzir o número de itens “mortos”, quem sempre
são preteridos.
Por heurísticas se entendem medidas que produzem o efeito esperado intuitivamente
ou em um grande número de execuções, mas que não funcionam sempre.
É importante salientar que desenhar este cenário não é tendencioso; o que se faz é
uniformizar o número de operações realizadas com o tamanho da entrada para obter um
comportamento pessimista e com isso evidenciar a efetividade (ou não) dos mecanismos
implementados.
Para determinar um bom espaço de testes, foram feitos alguns testes preliminares, com
número, valores e pesos de elementos ajustados empiricamente e 10 UCPs. Em 10000 e
15000 encontrou-se um limite superior para o número de itens, graças à complexidade
exponencial do problema. O primeiro consumiu 7 horas de processamento do cluster e
o segundo, 2 dias3 . Com 2500 elementos, porém, o tempo de resolução foi de ∼ 20 segundos para 10 processos e ∼ 120 segundos para um processo. Como deve-se determinar
uma média dos tempos de execução via múltiplas execuções (vide Seção 6.3), este tempo
mostrou-se adequado ao número de testes realizados, elegendo-se este como o número de
elementos máximo empregado no teste.
Vale destacar que, como o valor total deve ser maximizado, sem estar sujeito a restrições, os valores individuais de cada item não tem relevância sobre a o número de conjuntos solução gerados. Ao contrário, o peso desses itens em relação ao peso da mochila
determina o número de galhos da árvore de possibilidades. Logo, é interessante se fixar
uma capacidade máxima da mochila de tal maneira que os pesos representem um espaço
de variedade grande, gerando conjuntos-solução diversificados. Através de alguns testes
com poucos elementos, chegou-se ao número fixo 5000 para a capacidade da mochila e
um valor de peso máximo igual à metade disso, de modo a gerar, no máximo, 2500 valores de pesos diferentes possíveis, o que, no pior caso (para os 2500 elementos postulados
1 Um
algoritmo que faça isso é, provavelmente, mais custoso computacionalmente do que resolver o
Problema da Mochila! Isso decorre de que, para verificar se um caso possui uma árvore bem-equilibrada, é
preciso executar n vezes um programa que resolva o Problema da Mochila.
2 Isto não diminui a complexidade do problema; no pior caso o bound é inefetivo.
3 Muito embora a política do cluster no que se refere a walltime não permita este período, resultados parciais foram guardados (estado das pilhas) e, após, a execução é retomada, carregando as pilhas armazenadas
ao invés de refazer a distribuição cíclica.
87
anteriormente), geram todos os itens de pesos diferentes. Em todas as execuções realizadas foram usados de 1 a 10 UCPs no processamento do problema, oferecidos pelo cluster
labtec.
O número de elementos iniciais escolhidos, empiricamente, foram 15, 50, 100, 1000,
2500, que apresentaram tempos mensuráveis de execução (a complexidade exponencial
faz com que cada elemento, ao ser elevado, aumente bastante o tempo de processamento).
Note-se que, na prática, a complexidade do algoritmo nunca atinge o valor pessimista
apresentado (O((C/min{p1 , . . . , pn })n )), pois muitos elementos, de valor relativo muito
baixo jamais são considerados para entrarem na mochila. Elevar a capacidade da mochila para considerar estes elementos é redundante, pois o limite superior de elementos
processados em tempo tratável seria apenas deslocado.
Para calcular-se os fatores medidos, toma-se a média aritmética das grandezas, visando uniformidade da medição. No entanto, a média só é confiável quando a análise do
desvio padrão é feita; se os valores, a cada execução, forem muito distantes da média, ela
não representará o tempo de execução do caso normal, pois pode ser o reflexo de uma
deturpação muito grande em alguns dos dados. Para fins de execução deste programa,
o seguinte algoritmo é adotado, visando determinar o número de execuções necessárias
para a obtenção de uma média confiável:
1. Escolher uma constante N (e.g, 20), que representa o número inicial de execuções;
2. Elege-se um número ε (e.g, 0.01), que representa a margem de distância desejada
em relação à média;
3. Roda-se N testes, calculando a média (x) e o desvio padrão (s) destes;
4. Se s < ε × x, então acabou. Caso contrário faz-se N ← N + k (e.g, k = 10) e volta-se
a 3.
Após algumas execuções, discutidas no Apêndice A, montou-se os casos de teste com
• 30 execuções;
• conjuntos de 1000, 1500, 1800, 2000 e 2500 elementos; e
• número de UCPs utilizadas variando de 1 a 10.
6.3
Velocidade de Execução
Esta seção dedica-se a avaliar três aspectos da velocidade de execução do programa
apresentado: a análise do tempo de execução, o speedup e a eficiência.
6.3.1
Análise do Tempo de Execução
A Figura 6.3 mostra os tempos de execução em contraste com o número de elementos processados, para execuções de 1 a 10 processos. É interessante observar que com
1800 elementos o comportamento sofre uma leve distorção, seja qual for o número de
processos. Como foram procedidas várias execuções, é evidente que não se trata de uma
anomalia isolada. Surge a hipótese de que o advento da aplicação do Workstealing tenha
surtido tal efeito, visto que o roubo de tarefas não tem um comportamento constante, pois
é modificado por uma condição de corrida entre a execução dos processos. Um fator mais
88
forte, no entanto, para esta distorção, seria o caráter aleatório do gerador de casos de teste;
mesmo com o bound desabilitado, é possível que os valores relativos se distribuam de tal
forma que a capacidade da mochila seja exaurida facilmente logo no início do algoritmo,
gerando uma árvore degenerada, com um conjunto de possíveis soluções muito menor a
ser gerado. Contudo, como, ao aumentar o número de processos, a anomalia se torna mais
evidente, não se pode descartar a influência do workstealing, pois o aumento do número
de processos leva a tamanhos iniciais de pilha menores, mais mensagens do protocolo de
roubo de tarefas trocadas e, por conseqüência, maior impacto no desempenho.
O ganho de desempenho obtido ao aumentar-se o número de processos é progressivamente menor, graças ao custo de comunicação que cresce; esse valor tende a convergir
para um valor ótimo a partir do qual o aumento do número de processos é inefetivo. Tal
fato é evidenciado pela Figura 6.4.
Figura 6.3: Gráfico tempo de execução (número de elementos vs. tempo em segundos).
6.3.2
Speedup
Conforme apresentado anteriormente, o speedup, ou seja, o ganho de desempenho
relativo ao uso de vários processadores, pode ser calculado por
Sp =
T (1)
T (n)
usando-se n processadores. Numa situação ideal, S p = n.
Por usar-se a função MPI_Wtime(...) para o cálculo do tempo e pela necessidade
de igualar os ambientes de execução (que é a noção de máquina virtual oferecida pelo
89
Figura 6.4: Gráfico tempo de execução (número de processos vs. tempo em segundos).
MPI), a versão seqüencial é executada em ambiente MPI, mas não faz testes de verificação
de ranking ou de comunicação. O gráfico correspondente pode ser visto na Figura 6.5.
A imagem que representa o speedup agrega pouca informação de desperdício de
tempo, pois as retas são na maior parte do tempo constantes, paralelas e sobrepostas,
exceto quando o número de processos fica perto de 10, onde começam a se diferenciar,
sinal de que a comunicação (incluindo aí o Workstealing) começa a pesar (de fato, mais
para frente é mostrado que, quanto maior o número de processos, mais tarefas tendem a
ser roubadas). Essa constatação mostra que o comportamento do algoritmo proposto se
aproxima do ideal.
Pode-se usar o gráfico de speedup como um teste de regressão linear e verificar que,
comparados com a função pt (t sendo o tempo de execução e p o número de processos),
as retas apresentadas aproximam-se da reta que o teste gera para a mesma função. Em
outras palavras, o speedup evidencia que até que se atinja o valor ótimo no qual não se
ganha velocidade, o gráfico do número de processos pelo tempo se comporta como uma
parábola e, após, o tempo de execução tende a ser constante.
6.3.3
Eficiência
O conceito de eficiência é dado por:
Ep =
T (1)
T (p) × p
Para uma entrada de tamanho n e usando-se p processadores. Numa situação ideal,
90
Figura 6.5: Gráfico Speedup.
E p = 1. O gráfico correspondente pode ser observado na Figura 6.6.
A imagem da Eficiência, ao contrário do speedup, consegue evidenciar melhor as discrepâncias introduzidas pela comunicação (troca de mensagens). Até um número razoável
de processos empregados (∼ 5) o decaimento é o esperado, em virtude da comunicação
normal da distribuição cíclica da entrada. No entanto, para um número de processos maiores, começa-se a observar distorções fora do padrão, provavelmente introduzidas pelo
advento do Workstealing. Uma análise mais consistente será feita na Seção 6.5, Balanceamento de Carga.
6.4
Consumo de Memória
O consumo de memória representa parte importante da avaliação de desempenho,
pois tem impacto direto na velocidade de execução e no esforço computacional realizado.
Além disso, através dessa medição, pode-se estimar se a técnica de Workstealing, em
troca de balanceamento de carga, não eleva o consumo de memória consideravelmente4 .
Um programa corretamente escrito libera toda a memória que aloca. Este fator desagrega a capacidade de medição do uso de memória. Além disso, não adianta estabelecer
um “checkpoint” no código, pois a memória é alocada e desalocada em posições diferentes da programação. Para solucionar este dilema, aborda-se o pior caso e avalia-se o
4 Disso
também depende a boa implementação do algoritmo; o mesmo problema pode gerar consumo
linear(e.g, cada tarefa é um inteiro, como nesta implementação) ou exponencial (e.g., cada tarefa é um
conjunto candidato à solução) da memória.
91
Figura 6.6: Gráfico Eficiência.
somatório de toda a memória alocada do programa.
Para se registrar a memória alocada, na implementação em C, construiu-se a primitiva
myalloc(...), que substitui malloc(...), registra na estrutura de benchmark a
quantidade de memória requerida e, somente após, aloca essa memória ao usuário. Ao
final da execução, tem-se o somatório de todas as chamadas de alocação de memória
realizadas pelo usuário.
Foi verificado, através do framework para a supervisão de execução de programas
Valgrind (NETHERCOTE; SEWARD, 2003), que não existe memória não-liberada ao
final da execução do programa e, portanto, não compromete a máquina em que está sendo
executado.
É importante observar que a memória usada por um processo nem sempre é constante; como a função usada para fazer as medições é uma função do tipo somatório, há
certa irregularidade na obtenção do consumo total de memória por culpa do Workstealing.
Quando se rouba tarefas, mais memória é alocada em função das tarefas recebidas e isso
desbalanceia o somatório. No entanto, dado o tamanho restrito das mensagens, essa diferença é pequena (mas, como já mencionado anteriormente, aumenta com o aumento do
número de processos). Naturalmente para a execução com 1 processo o desvio padrão é
0, visto que não há Workstealing (a memória ocupada é constante para cada entrada.).
Ao final, a memória consumida por todos os processos é somada. Obtém-se o consumo de memória médio por processo dividindo-se esse somatório pelo número total de
processos. Os gráficos que ilustram o consumo de memória (média por processo) são
vistos nas Figuras 6.7 e 6.8.
É evidente que quanto mais processos, mais se divide a entrada dentre as pilhas de
92
Figura 6.7: Gráfico Consumo de Memória (número de elementos vs. memória em bytes).
tarefas e o consumo de memória diminui. Pode-se notar, por observação, que o empilhamento das tarefas por Workstealing não tem contribuição significativa, para poucos
processos. Isso, pela análise das estruturas de dados, é normal; como há uma distribuição
cíclica da entrada (que contém centenas de elementos) no início, a troca de alguns inteiros tem influência pequena sobre o somatório final, a não ser que sejam muitas trocas,
que ocorrem mais freqüentemente com o aumento do número de processos. A Figura 6.7
mostra que o aumento de consumo de memória é linear; ao se adicionar um elemento, as
estruturas de dados empregadas crescem proporcionalmente, resultando numa reta dentro
da comparação gráfica. A Figura 6.8 mostra o mesmo cenário, pela óptica do número de
processos utilizado em função da memória alocada.
6.5
Balanceamento de Carga
Para medir a efetividade do balanceamento de carga é importante, antes, relembrar
que, diferentemente do WS original (BLUMOFE; LEISERSON, 1994), aqui se faz uma
distribuição cíclica da entrada antes da execução do algoritmo, para otimizá-lo. Isso implica num impacto menor da adoção da técnica no algoritmo. Além disso, as pilhas entre
os processos sempre serão carregadas com b ep c ou d ep e (onde p é o número de processos
e e o número de elementos da entrada).
As medições são feitas quanto ao número de tarefas efetivamente recebidas e efetivamente enviadas5 ; requisições não são levadas em conta, pois pode ser que não sejam
5 Este
é um número total de mensagens, e não a média. Logo, não faz sentido falar em desvio-padrão,
93
Figura 6.8: Gráfico Consumo de Memória (número de processos vs. memória em bytes).
atendidas. Devido às requisições não atendidas, o tempo de obtenção de uma tarefa pode
variar pois, tanto o primeiro quanto o último processo tentado pode possuir as tarefas,
sendo impossível medir o tempo associado à obtenção da tarefa, mesmo em uma rede
ideal.
Ao invés de medir as requisições não atendidas, adota-se um simplificação matemática para o problema: pode-se mensura o caso médio do atendimento de uma solicitação
(atendida com p−1
2 tentativas) ou o pior caso da mesma situação (atendimento em p − 1
tentativas), de modo a adequar a análise ao grau de pessimismo que se quer empregar.
Para fins desta monografia, trata-se apenas das tarefas recebidas e enviadas.
É interessante observar a heterogeneidade do comportamento, frente à variação do
número de elementos e processos, conforme as Figuras 6.9 e 6.10. Como sends e receives
são complementares, basta avaliar uma das operações e considerá-la em dobro caso queira
se avaliar ambas.
Fica claro o não-determinismo implícito da ocorrência de Workstealing ao se comparar os gráficos apresentados. Embora não sirvam para se traçar uma função, é importante
ressaltar que eles não tem utilidade nula; dadas alterações não esperadas nas medições
de desempenho e/ou consumo de memória, é possível, usando os dados apresentados, ter
evidências de que as alterações são provocadas pela ocorrência de atividades de roubo de
tarefas, naquele contexto. Por exemplo, ao observar a Figura 6.6, Eficiência, observa-se
que, para 1800 elementos, na transição do proessamento com 8 para o processamento
com 9 processadores, há uma queda abrupta de eficiência, que foge ao padrão teórico.
neste caso
94
Figura 6.9: Gráfico mensagens enviadas/recebidas (num. de elementos vs. num. de
mensagens).
95
Figura 6.10: Gráfico mensagens enviadas/recebidas (num. de processos vs. num. de
mensagens).
96
Ao verificar a Figura 6.10, Mensagens Enviadas/Recebidas vemos que, com 9 processos houve um acréscimo considerável de troca de mensagens por causa do Workstealing
implementado, justificando o comportamente observado.
É importante salientar que as observações mostradas são válidas apenas para o Workstealing como foi implementado aqui. A técnica original (BLUMOFE; LEISERSON,
1994) difere em vários aspectos dessa versão e pode não responder da mesma maneira
ao testes realizados.
6.6
Impacto de Utilização do Workstealing
Até agora, todas as medições apresentadas e análises se valiam do programa implementado com os recursos de WS habilitados e funcionando. No entanto, uma das propostas da monografia é responder se o Workstealing traz ganho real de desempenho ao
buscar o balanceamento de carga em um ambiente paralelo. Além disso, caso este benefício seja percebido é necessário, também, determinar o quanto de memória pode ter sido
sacrificado em prol do aumento de desempenho.
Conforme já mencionado, a ocorrência de roubo de tarefas aumenta com o aumento do
número de processos. Além disso, um cenário com bastantes elementos também oferece
uma maior chance de que aconteçam roubos. Desta maneira, os testes foram feitos com
2500 elementos e variando-se de 1 a 10 processos, o máximo de elementos e processos
das medições anteriores. Além disso, foi calculada a média de execução que satisfez
um desvio padrão menor ou igual que 0.01, obtida com 30 execuções. O resultado das
comparações pode ser observado nas Figuras 6.11 (tempo de execução) e 6.12 (consumo
de memória).
Observando as Figuras, é fácil perceber que a introdução de Workstealing traz um
grande ganho de desempenho para um número elevado de processos, visto que o tempo
de execução passou de ∼ 80 segundos para ∼ 20 segundos com 10 UCPs, um aumento de
velocidade de aproximadamente 25%, sem, no entanto, apresentar diferença significativa
no consumo de memória.
Vale destacar que o ganho obtido vale para essa implementação do Problema da Mochila; não é garantido que a técnica traga o mesmo ganho quando aplicadas em outros
problemas; fatores como o encapsulamento da noção de tarefas, latência da rede e natureza do problema podem afetar este resultado.
Devido ao emprego de Workstealing, os tempo de execução, com carga balanceada,
são menos suscetíveis a atrasos de uma UCP, pois o trabalho desta, no meio tempo, é distribuído entre as outras, enquanto na abordagem convencional as UCPs que já acabaram
o trabalho ficam ociosas.
6.7
Deadlock
Para validar todas as medições encontradas, é importante provar a corretude do programa, em especial a ausência de deadlocks. Lembrando, existem 3 condições básicas
para a ocorrência de deadlock (TOSCANI; OLIVEIRA; SILVA CARíSSIMI, 2003):
1. quando um processo que já possui um recurso pode requisitar outro recurso;
2. quando os recursos alocados a um processo não podem ser preemptados;
3. quando é possível a formação de um ciclo (cadeia fechada) no qual cada processo
97
Figura 6.11: Impacto do WS: tempo de execução.
está bloqueado à espera de recursos que estão alocados para outros processos do
mesmo ciclo.
Inexistindo uma destas condições, garante-se que não ocorrer deadlock. No programa
que resolve o Problema da Mochila (versão final), existem duas partes passíveis da ocorrência da trava: a Difusão de Máximo Local e o Workstealing. A prova formal de que
estes trechos não apresentam deadlock é apresentada a seguir.
Fica claro, também, a capacidade de adaptação que o algoritmo tem em ambientes
heterogêneos; quanto maior a discrepância entre a eficiência dos processadores de uma
máquina paralela, mais Workstealing tende a ser realizado, visto que os processos possuirão velocidades de esvaziamento da pilha diferentes e os processos mais lentos tendem a
doar tarefas a processos mais rápidos.
6.7.1
Difusão de Máximo Local
Neste algoritmo, como foi implementado, a única chance de um processo se bloquear
é na barreira, ao final do programa; ao longo da execução não há dependência alguma
do recebimento de mensagens para a computação; a barreira é o único ponto onde um
processo fica bloqueado.
Pode-se entender o bloqueio do processo como uma entrada em um estado de espera
do qual só é libera (sendo p o número de processos) ao receber os p−1 tokens informando
o fim dos outros processos; neste caso, o recurso a ser consumido são tokens de final, ao
qual cada processo possui, inicialmente, p − 1 tokens que distribui ao fim de sua computação e espera pelo recebimento de outros p − 1 tokens, oriundos um de cada processo. A
98
Figura 6.12: Impacto do WS: consumo de memória
prova da não-ocorrência deadlock baseia-se no fato de que (1) não procede e é feita por
absurdo:
Teorema (Algoritmo DML × Deadlock). O algoritmo de Difusão de Máximo Local implementado não entra em deadlock.
Prova. (absurdo). Sejam um processo pi e um número de processos n tal que i ∈ {1, . . . , n}.
Supõe-se que (1) é válido:
(1) é válido
∃pi | (pi possui token não-enviado) ∧ (pi espera por tokens de outros processos)
pi possui token não-enviado
pi não acabou a computação.
−→
−→
−→
no entanto,
(1) é válido
∃pi | (pi possui token não-enviado) ∧ (pi espera por tokens de outros processos)
pi espera por tokens de outros processos
pi acabou a computação.
−→
−→
−→
o que é um absurdo; logo, (1) deve se falso.
6.7.2
Workstealing
O algoritmo de Workstealing tem dois pontos críticos, que podem originar deadlock:
a barreira ao final do programa e a solicitação de uma tarefa. Muito embora o programa
99
possa se bloquear no primeiro caso, a barreira não depende de recursos da parte de Workstealing, mas sim exclusivamente dos recursos do algoritmo DML; a prova de que esta
parte não entra em trava mortal foi apresentada no Teorema “Algoritmo DML × Deadlock”.
Resta provar que, ao solicitar uma tarefa de outro processo, é impossível ocorrer um
ciclo entre todos os processos, logo (3) não é válido. Nos casos apresentados a seguir, o
recurso a ser consumido é uma tarefa como definida anteriormente, que pode ser empilhada (recurso disponível) ou executada (recurso consumido). É importante lembrar que
algumas implicações são válida pois partem dos códigos já apresentados do programa;
em especial, é importante relembrar
1. um processo que está no aguardo de uma solcitação de Workstealing pode atender
sempre requisições de tarefas de outros processos; e
2. há sincronização sobre a pilha de tarefas.
Teorema (Algoritmo WS × Deadlock). O algoritmo de Workstealing implementado não
entra em deadlock.
Prova. (direta) Quer se provar que (3) é inválido pois um processo pode atender outro
mesmo que esteja na esperando por tarefas. Seja um número de processos n e a ← b
significa que “a está bloqueado, à espera de uma resposta de b”.
∀i ∈ {1, . . . , n}(pi ← pi+1 )
−→
(p1 ← p2 ) ∧ (p2 ← p3 ) ∧ · · · ∧ (pn−1 ← pn ) ∧ (pn ← p1 )
no entanto, p1 responde à solicitação de pn , conforme postulado:
(p1 responde a pn )
∀i ∈ {1, . . . , n}(pi ) é desbloqueado
(3) não ocorre.
−→
−→
100
7
CONCLUSÕES
Este capítulo apresenta conclusões construídas sobre todo o processo definido ao
longo desta monografia, baseado nos capítulos anteriores.
7.1
Problema da Mochila × Paralelização
Conforme referido no Capítulo 4, o Problema da Mochila é um problema NP-Completo,
o que significa que possui complexidade exponencial quando processado por uma máquina não-determinística. O advento da paralelização consegue diminuir o tempo de execução do algoritmo. No entanto, tal advento não reduz a complexidade do problema; a
curva de crescimento exponencial persiste para soluções paralelas do problema.
A paralelização, no entanto, mostra-se especialmente relevante na execução prática
do problema. Relativamente ao número de elementos apresentados, o ganho no tempo de
execução foi grande para o escopo experimentado.
O resultado mais importante obtido nesta área, no entanto, foi a constatação de que
é possível paralelizar o Problema da Mochila e esta solução paralela se beneficia do emprego de WS, verificando a hipótese apresentada no Capítulo 1.
7.2
Implementação
Um dos pontos a serem verificados é a possibilidade de se construir uma biblioteca
que implemente o Workstealing de maneira transparente ao usuário; por ser um algoritmo
que depende fortemente das estruturas de dados sobre as quais atua, existe a possibilidade
de que não se possa construir uma biblioteca robusta e portável simultaneamente.
Verifica-se que a construção da biblioteca é viável e, ao longo da monografia, propô-se
estruturas de dados e interfaces para essas estruturas genéricas. Tais construções conseguiram mostrar, na implementação do código:
Encapsulamento. As funções a serem chamadas englobam detalhes de protocolos que
não são interessantes aos usuários, permitindo que este se foque na resolução do
problema a que se propõe.
Portabilidade. Como usa apenas bibliotecas-padrão da linguagem C e MPI, a solução é
portável para a maioria dos sistemas que ofereçam suporte a ambos os padrões.
No entanto, a implementação ainda não está em estágio satisfatório para a montagem
de uma biblioteca autônoma; existem fortes dependências das estruturas em relação à
paralelização específica do Problema da Mochila. Tais dependências impedem que a
abordagem seja genérica o suficiente em relação aos tipos de problema utilizados.
101
A solução para o problema da dependência das estruturas de dados foi prototipada
apenas, pois sua implementação foge ao escopo da monografia. Para atender ao problema,
utiliza-se de estruturas de dados (e.g, pilha de tarefas) e funções-chave (interfaces para as
estruturas) que necessitam ser reescritas pelo usuário e fornecidas como parâmetros para
certas subrotinas que atendem ao protocolo. Tal abordagem é conhecida como “utilização
de callbacks” e é especialmente popular no contexto de linguagens orientadas a objetos.
A biblioteca padrão de C (stdlib.h) usa a mesma abordagem para prover funções de
ordenamento genéricas.
7.3
Desempenho
Uma das questões-chave do trabalho desenvolvido é o impacto do uso da técnica de
Workstealing no desempenho de um programa paralelo. Objetiva-se verificar se a adoção
do método impacta num ganho (pelo balanceamento de carga) ou num custo (pelos custos
de comunicação) adicional ao programa paralelo sendo executado.
O capítulo 6 mostrou que há uma ganho perceptível de desempenho ao se utilizar a
técnica de WS nesta implementação. Este não é um resultado absoluto, pois depende,
também, da eficiência com que as estruturas de dados de pilhas de tarefas é gerenciada
e das características das máquinas que executam o programa. Em outras palavras, esse
ganho não pode ser generalizado para qualquer aplicação que, porventura, utilize uma biblioteca genérica baseada no algoritmo apresentado, muito embora um largo contingente
de aplicações possa se beneficiar disto.
O trabalho é desenvolvido sobre máquinas homogêneas (clusters homogêneos). Essa
fato é importante, como evidencia a seguinte linha de pensamento:
1. o Workstealing clássico tem um alto custo de comunicação no início, tendo desempenho inferior ao obtido ao se fazer uma simples decomposição cíclica da entrada;
2. o Workstealing sobre a decomposição cíclica (conforme apresentado aqui) é efetivo
quando existe disparidade entre as velocidades de processamento dos nós, pois processos ociosos (mais rápidos) roubam carga dos processos cheios de tarefas (mais
lentos), o que incrementa a velocidade global de resolução do problema.
O último item implica que quanto mais diferença houver entre as velocidades dos
vários processadores, mais o efeito do Workstealing será proveitoso. Para um cluster
homogêneo, a velocidade de processamento de pilhas do mesmo tamanho tende a ser a
mesma, deixando um número pequeno de tempo ocioso e, ainda, tendo de arcar com o
custo de comunicação de requisições não atendidas, fazendo do WS, apesar de efetivo,
uma técnica que não desenvolve todo o seu potencial.
Grids e clusters heterogêneos, por outro lado, permitem que a técnica atinja seu ápice
de eficiência (mesmo que isto implique, no caso de grids, um perda de desempenho nãodesejável para o usuário); existe bastante diferença entre a capacidade de cada nó e isso
provoca a ociosidade latente de aplicações paralelas, sanada adequadamente pelo Workstealing clássico.
Vale lembrar que implementação apresentada, não oferece um suporte consistente para
a execução em Grids, conforme discutido na próxima seção.
102
7.4
WS Clássico × Implementação
Conforme referido, o algoritmo implementado difere do algoritmo clássico do WS,
pois, na implementação apresentada, em especial, há distribuição cíclica da entrada.
Tal medida é implementada para fins de desempenho, visto que a execução em ambiente MPI (em arquiteturas do tipo cluster) possui uma replicação natural de processos
e entradas que evita o grande número de solicitações iniciais feitas para o processo que
possui todas as tarefas, no algoritmo clássico.
Tal ganho, entretanto, desagrega flexibilidade ao modelo original. Embora otimize
o desempenho num contexto MPI/cluster, tal implementação não é facilmente generalizada para outros tipo de arquiteturas (e.g., grids), que tem um caráter mais dinâmico
das máquinas e processos que participam da computação e, portanto, não há garantia da
replicação de entrada que MPI proporciona. No contexto onde os processos só recebem
informações da entrada via troca de mensagens, a implementação apresentada falha (pois
se baseia fortemente na replicação da entrada), enquanto o WS clássico funciona sem
problemas.
7.5
Escalabilidade
Para um número de elementos elevado, surgem duas questões referentes à escalabilidade da solução quando o problema cresce: a ocorrência de deadlocks e o consumo
elevado de memória.
Demonstrou-se formalmente, no Capítulo 6, que o algoritmo implementado não entra
em deadlock. Além disso, o controle de deadlock não impacta em redução de desempenho significativa, conforme apresentado. A inexistência de travas mortais comprovada
genéricamente faz com que o ambiente de execução ganhe heterogeneidade; é altamente
provável que o algoritmo se comporte como o esperado independentemente do ambiente
sobre o qual executa.
Já o consumo de memória não é inerente à implementação. Por mais que a solução
desenvolvida consuma pouca memória e mantenha um patamar aceitável, uma biblioteca
genérica não pode gozar da mesma propriedade; a implementação dos callbacks de pilha
de tarefas, a cargo do usuário, é quem gerencia a quantidade de memória ocupada por
cada pilha. É importante destacar que isso exige especial atenção na implementação de
um programa NP-Completo; como deve haver a geração de todos os conjuntos-solução
possíveis (que crescem exponencialmente), é altamente aconselhável que não se empilhem estes conjuntos, sob risco de uso exponencial da memória.
Além de ser escalável (comporta-se como esperado quando o problema aumenta),
a solução apresentada também é bem-escalável, ou seja, continua eficiente quando se
aumenta o tamanho do problema. Além disso, o algoritmo apresentado tende a ser mais
eficiente para tamanhos de problema maiores, onde o percentual de tempo gasto com a
comunicação é percentualmente menor.
7.6
Trabalhos Futuros
Alguns trabalhos futuros relacionados a esta monografia incluem
Construção da Biblioteca. O trabalho apresentado mostra um esqueleto e guias para a
construção de uma biblioteca genérica e transparente de Workstealing. O próximo
passo, natural, é a construção desta biblioteca.
103
Uso de MPI-2. A especificação MPI-2 oferece a criação dinâmica de processos, ou seja,
novos processos podem ser criados e alocados a processadores a medida em que
surge a necessidade. É interessante investigar até onde esta possibilidade contribui
para a realização do WS, sobretudo no caso de Grids.
Medição em Grids e Clusters Heterogêneos. As medições foram realizadas em um cluster homogêneo. Seria interessante fornecer dados precisos sobre a performance do
algoritmo em um cluster heterogêneo ou grid, visando comprovar a sua escalabilidade e enumerando as modificações necessárias que o código deve sofrer para
suportar este contexto;
Migração de Processos. Seria interessante agregar o algoritmo de Workstealing a um escalonador de processos MPI. Para tanto, deve-se trabalhar na construção específica
de um gerenciador de escalonamento que consiga abstrair processos como tarefas
e, com isso, comutar tarefas através do uso de algoritmos de migração de processos.
104
APÊNDICE A
ANÁLISE DOS CASOS DE TESTE
Este anexo dedica-se a discutir a obtenção dos valores apresentados no Capítulo 6,
Avaliação de Desempenho, utilizados para a realização dos testes envolvendo tempo de
execução, consumo de memória e troca de mensagens. Para todas as medições, ε = 0.01.
A.1
Tempo de Execução
A Figura A.2 ilustra os resultados de tempo de execução para todos os números de
elementos (15, 50, 100, 2500), de 1 a 10 processos com . O formato apresentado é visto
na Figura A.1 e seu significado na Tabela A.1.
[<num_elem>,<num_proc>] [EXT = xxx] [DPAD = xxx] <state>
Figura A.1: Formato da tabela de resultados do benchmark.
Item
<num_elem>
<num_proc>
EXT = xxx
DPAD = xxx
<state>
Significado
Número de elementos.
Número de processadores.
Média dos tempos de execução para as N execuções.
Desvio padrão.
OK, caso s < ε × x. FAIL, caso contrário.
Tabela A.1: Significado dos símbolos da tabela de resultados do benchmark.
A Figura A.2 mostra que, para menos do que 1000 elementos e tomando-se ε = 0.01,
a média obtida não é satisfatória. Nos casos onde o número de processos é igual a 1,
a média, óbviamente, é satisfatória sempre, visto que ela é igual às suas parcelas e, por
isso, o desvio padrão é o menor possível. Aparentemente há certa dificuldade em obter
um valor médio satisfatório para um número reduzido de elementos. Para verificar isso,
segue-se o algoritmo proposto no Capítulo 6 para obtenção da média, define-se o valor de
k = 20 e se faz uma nova bateria de testes, com N = 30 (Figura)
A situação torna a não convergir para poucos elementos. Pode-se traçar duas possibilidades para tal ocorrência:
1. Para casos muito pequenos, o tempo de execução também é muito pequeno. Logo,
o poder de representação da arquitetura do processador não consegue representar
os números decimais que representam os desvios-padrão, muito baixos;
105
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
[15,1]
[EXT
[15,2]
[EXT
[15,3]
[EXT
[15,4]
[EXT
[15,5]
[EXT
[15,6]
[EXT
[15,7]
[EXT
[15,8]
[EXT
[15,9]
[EXT
[15,10] [EXT
[50,1]
[EXT
[50,2]
[EXT
[50,3]
[EXT
[50,4]
[EXT
[50,5]
[EXT
[50,6]
[EXT
[50,7]
[EXT
[50,8]
[EXT
[50,9]
[EXT
[50,10] [EXT
[100,1] [EXT
[100,2] [EXT
[100,3] [EXT
[100,4] [EXT
[100,5] [EXT
[100,6] [EXT
[100,7] [EXT
[100,8] [EXT
[100,9] [EXT
[100,10] [EXT
[1000,1] [EXT
[1000,2] [EXT
[1000,3] [EXT
[1000,4] [EXT
[1000,5] [EXT
[1000,6] [EXT
[1000,7] [EXT
[1000,8] [EXT
[1000,9] [EXT
[1000,10][EXT
[2500,1] [EXT
[2500,2] [EXT
[2500,3] [EXT
[2500,4] [EXT
[2500,5] [EXT
[2500,6] [EXT
[2500,7] [EXT
[2500,8] [EXT
[2500,9] [EXT
[2500,10][EXT
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
0.0107576]
0.02639]
0.0337298]
0.0447095]
0.044959]
0.0528291]
0.0609]
0.063024]
0.0700221]
0.0717377]
0.0142862]
0.0198123]
0.029569]
0.029055]
0.0387701]
0.0374721]
0.0438698]
0.0534928]
0.053187]
0.0631203]
0.0422086]
0.0342021]
0.0339237]
0.0364187]
0.0418637]
0.0439955]
0.0498867]
0.0534728]
0.0591513]
0.0632516]
26.849526]
13.4566571]
8.9943672]
6.7585116]
5.4241147]
4.5401515]
3.9027259]
3.4260675]
3.0589852]
2.7664852]
167.2858947]
83.717032]
55.8893633]
42.01759]
33.757429]
28.2815779]
24.4468126]
21.6946972]
19.5886754]
18.0523394]
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
0.000339238100586]
0.0109487714887]
0.00142401832073]
0.00205701230213]
0.00293363771148]
0.00245342918517]
0.00304944956782]
0.00272217258005]
0.00449719044269]
0.00858316771426]
5.63674452927e-05]
0.00259086798197]
0.019246385144]
0.00157378242178]
0.0193391512077]
0.00132459750281]
0.00128360333091]
0.0140158684497]
0.00149727448682]
0.01259240406]
0.00016504558293]
0.00117051521894]
0.000824536779046]
0.00126314449336]
0.00586649501738]
0.00121264973875]
0.00798741355787]
0.00256856586877]
0.0113798287826]
0.00382172800358]
0.0288982616629]
0.0143115247608]
0.00888024790306]
0.00891155488583]
0.00422864740978]
0.00968449113249]
0.00457775945818]
0.00494981492928]
0.00351585073676]
0.00631425626465]
0.0752806144414]
0.0390441750138]
0.0191406907589]
0.0171415084713]
0.0236446289749]
0.0116703285491]
0.0212549473136]
0.0450827801559]
0.0490940156333]
0.0456056258905]
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
OK
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
OK
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
Figura A.2: Medição dos tempos de execução (10 execuções).
106
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
[15,1]
[15,2]
[15,3]
[15,4]
[15,5]
[15,6]
[15,7]
[15,8]
[15,9]
[15,10]
[50,1]
[50,2]
[50,3]
[50,4]
[50,5]
[50,6]
[50,7]
[50,8]
[50,9]
[50,10]
[100,1]
[100,2]
[100,3]
[100,4]
[100,5]
[100,6]
[100,7]
[100,8]
[100,9]
[100,10]
[1000,1]
[1000,2]
[1000,3]
[1000,4]
[1000,5]
[1000,6]
[1000,7]
[1000,8]
[1000,9]
[1000,10]
[2500,1]
[2500,2]
[2500,3]
[2500,4]
[2500,5]
[2500,6]
[2500,7]
[2500,8]
[2500,9]
[2500,10]
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
0.0106930666667]
0.0263158333333]
0.0324301666667]
0.0452368]
0.0498012333333]
0.0519160666667]
0.0605000333333]
0.0656367666667]
0.072859]
0.0689583333333]
0.0142467]
0.0194492333333]
0.0258476666667]
0.0291807666667]
0.0359899]
0.0392619333333]
0.0441116333333]
0.0520438333333]
0.0556016666667]
0.0610661]
0.0421672333333]
0.0343344666667]
0.0341094333333]
0.0367348666667]
0.0410060666667]
0.0441573333333]
0.0479009666667]
0.0547305]
0.0617156666667]
0.0650695]
26.852102]
13.4561770333]
8.99220156667]
6.76308713333]
5.42681156667]
4.53885793333]
3.9043424]
3.42688143333]
3.06061966667]
2.76708153333]
167.264477267]
83.7193909]
55.8895467667]
42.0201783333]
33.7555503]
28.2830196667]
24.4531655333]
21.6740607]
19.5902117]
18.0564178]
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
0.000203823441739]
0.0068566158642]
0.00233781246237]
0.00614348694315]
0.025044165907]
0.00242229953348]
0.00228690821979]
0.012495231404]
0.0213064093255]
0.00629709258765]
7.58270036523e-05]
0.00217931858511]
0.0111155109623]
0.0025677076679]
0.0125894234519]
0.00945302219484]
0.00212798322011]
0.0111664808153]
0.00802547821041]
0.0123452958802]
0.000132810646467]
0.00133704134858]
0.00102807274274]
0.00215245599472]
0.00399603013289]
0.00144612567616]
0.00479299221553]
0.0100921236402]
0.0141683640146]
0.0140342567454]
0.0288489445812]
0.0146939932812]
0.00827961494725]
0.0134307544759]
0.00856314744591]
0.0117236657087]
0.00794709719687]
0.00750067155899]
0.00946464069412]
0.0125237204887]
0.0972753907263]
0.0391541690631]
0.0250703633823]
0.0156450808144]
0.0266859214887]
0.0211015637273]
0.0331389888623]
0.0368826845733]
0.0419581238801]
0.0427533562805]
Figura A.3: Medição dos tempos de execução (30 execuções).
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
OK
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
OK
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
FAIL
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
107
2. O tempo de processamento é muito pequeno frente ao tempo de comunicação; o
programa gasta mais tempo na troca de mensagens do que fazendo o processamento
em si.
A primeira hipótese pode parecer razoável, mas basta uma olhada nos desvios-padrão
apresentados para verificar que ela falha; o menor desvio padrão apresentado (∼ 7.5827 ×
10−5 ) é verificado positivamente. A hipótese, portanto, é falha. A segunda hipótese parece ser a mais razoável; o alto tempo de latência que a troca de mensagens provoca supera
o tempo de processamento. Como os tempos de troca de mensagens dependem de uma
série da fatores quase aleatórios (carga da rede, ocupação dos buffers, etc.), os custos de
comunicação também são determinados aleatoriamente, o que faz com que estes números
nunca convirjam para uma média1 . Estes custos, de fato, são problemas recorrentes em
programação paralela, abordada em vários eventos da área (ESCOLA REGIONAL DE
ALTO DESEMPENHO, 2007).
Mediante isto, é útil, para fins de análise, usar mais elementos, afim de confirmar o
postulado. Baseado nos dados apresentados, tendo certeza que os valores, para mais de
1000 elementos, convergem, é conveniente escolher mais elementos do que este valor,
no caso, 1000, 1500, 1800, 2000, 2500. Para evitar a repetição desnecessária de testes,
usam-se, novamente, 30 execuções. Estes elementos, de fato, confirmam a conclusão
apresentada, como pode ser visto na Figura A.4.
A.2
Consumo de Memória
A Figura A.5 mostra o consumo de memória no mesmo formato em que é feita
a avaliação de desempenho, substituindo EXT (tempo de execução em segundos) por
MEM (memória consumida em bytes). As medições foram feitas, tal qual anteriormente,
respeitando-se a máxima distancia da média dada pelo desvio padrão.
A.3
Balanceamento de Carga
O recebimento e envio de tarefas é um fator aleatório, pois é resultado de uma condição de corrida. Isto pode ser evidenciado pela Figura A.6, onde se mostra o resultado
das 30 execuções para 2500 elementos e 10 processos, onde o referencial é o processo de
número 10.
De fato, como mencionado anteriormente, o envio e recebimento de tarefas ocorre
mais facilmente com o aumento do número de processos. A Figura A.7 mostra a execução
do programa para 2500 elementos e 3 processos, tomando o processo 2 como referência.
Dessa maneira, fica difícil precisar corretamente uma função que expresse a taxa de
envio ou recebimento de tarefas. Contornos para esta situação são descritos no Capítulo
6.
1 Esta
conclusão deve ser válida, mesmo com o Workstealing (que implica maiores custos de comunicação) desabilitado, pois há o custo de comunicação MPI natural às aplicações. Mesmo que isso eventualmente permita que uma faixa do número de elementos menor venha a convergir, matematicamente sempre
irá existir uma faixa de número de custo elevado de comunicação, que não convergem.
108
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
[1000,1]
[1000,2]
[1000,3]
[1000,4]
[1000,5]
[1000,6]
[1000,7]
[1000,8]
[1000,9]
[1000,10]
[1500,1]
[1500,2]
[1500,3]
[1500,4]
[1500,5]
[1500,6]
[1500,7]
[1500,8]
[1500,9]
[1500,10]
[1800,1]
[1800,2]
[1800,3]
[1800,4]
[1800,5]
[1800,6]
[1800,7]
[1800,8]
[1800,9]
[1800,10]
[2000,1]
[2000,2]
[2000,3]
[2000,4]
[2000,5]
[2000,6]
[2000,7]
[2000,8]
[2000,9]
[2000,10]
[2500,1]
[2500,2]
[2500,3]
[2500,4]
[2500,5]
[2500,6]
[2500,7]
[2500,8]
[2500,9]
[2500,10]
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
[EXT
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
26.8528707333] [DPAD = 0.0333933707441]
13.4683186667] [DPAD = 0.0129807776576]
9.00799513333] [DPAD = 0.00749613369631]
6.77735542857] [DPAD = 0.00612728575351]
5.44311833333] [DPAD = 0.00552155552148]
4.55871196429] [DPAD = 0.00644472073056]
3.92496076667] [DPAD = 0.00841211594081]
3.45192932143] [DPAD = 0.00463376159555]
3.07427937931] [DPAD = 0.00462139043263]
2.7850921]
[DPAD = 0.0132990536696]
60.1102189]
[DPAD = 0.0721571054224]
30.0877856]
[DPAD = 0.0194270428265]
20.0838294333] [DPAD = 0.0113542488599]
15.0915499524] [DPAD = 0.0106060842986]
12.0909193846] [DPAD = 0.00664547562375]
10.1025945714] [DPAD = 0.00674633604601]
8.68623239286] [DPAD = 0.00706197531535]
7.62627093333] [DPAD = 0.0105303161567]
6.81066524138] [DPAD = 0.0126470038737]
6.18087403448] [DPAD = 0.0597001885098]
81.2249111333] [DPAD = 0.0372123445571]
40.6557511]
[DPAD = 0.0159091125928]
26.4657286]
[DPAD = 0.0096779610483]
21.8746560714] [DPAD = 0.0185950807239]
15.73247146667] [DPAD = 0.0264851551593]
13.32193596667] [DPAD = 0.0159201086801]
11.3027701]
[DPAD = 0.0102274948979]
10.5480813]
[DPAD = 0.00928848878609]
9.0141603]
[DPAD = 0.00977744383077]
8.5509957]
[DPAD = 0.0170690271447]
106.5561137]
[DPAD = 0.0926798866441]
53.3542754667] [DPAD = 0.0320474529672]
35.6413687]
[DPAD = 0.0267787454417]
26.7958899231] [DPAD = 0.0177569118023]
21.5221352]
[DPAD = 0.0149270678017]
18.0358812667] [DPAD = 0.0144334347093]
15.6029128667] [DPAD = 0.0213496455353]
13.7993478333] [DPAD = 0.0228852701203]
12.4591445667] [DPAD = 0.0304073834422]
11.4293376667] [DPAD = 0.0288214095991]
167.294710867] [DPAD = 0.104966505727]
83.7104580333] [DPAD = 0.044471359152]
55.8979496667] [DPAD = 0.0243657349261]
42.02193104]
[DPAD = 0.0173290449507]
33.7411595882] [DPAD = 0.0180413635638]
28.2778684828] [DPAD = 0.0136757405541]
24.4634930667] [DPAD = 0.0312944140158]
21.6711581429] [DPAD = 0.030941047535]
19.5753810345] [DPAD = 0.0397428517781]
18.0408034333] [DPAD = 0.0555629899421]
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
Figura A.4: Medição dos tempos de execução (30 execuções) – versão final.
109
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
[1000,1]
[1000,2]
[1000,3]
[1000,4]
[1000,5]
[1000,6]
[1000,7]
[1000,8]
[1000,9]
[1000,10]
[1500,1]
[1500,2]
[1500,3]
[1500,4]
[1500,5]
[1500,6]
[1500,7]
[1500,8]
[1500,9]
[1500,10]
[1800,1]
[1800,2]
[1800,3]
[1800,4]
[1800,5]
[1800,6]
[1800,7]
[1800,8]
[1800,9]
[1800,10]
[2000,1]
[2000,2]
[2000,3]
[2000,4]
[2000,5]
[2000,6]
[2000,7]
[2000,8]
[2000,9]
[2000,10]
[2500,1]
[2500,2]
[2500,3]
[2500,4]
[2500,5]
[2500,6]
[2500,7]
[2500,8]
[2500,9]
[2500,10]
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
[MEM
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
76016.0]
46019.6]
36019.6]
31022.0]
28024.0]
26032.8]
24608.4]
23543.2]
22710.4]
22045.2]
84016.0]
54016.0]
44018.4]
39016.8]
36024.0]
34034.8]
32614.8]
31541.2]
30713.6]
30056.8]
88816.0]
58811.2]
48816.8]
43321.6]
40822.0]
38831.2]
36133.6]
35598.0]
34178.0]
33857.2]
92016.0]
62011.2]
52022.4]
47017.2]
44038.4]
42061.2]
40653.6]
39589.6]
38761.2]
38103.2]
100016.0]
70010.8]
60022.0]
55019.6]
52030.8]
50061.6]
48651.6]
47596.0]
46764.0]
46124.8]
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
[DPAD
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
0.0]
11.5716657588]
6.41979697976]
8.18745887065]
5.75355961782]
5.39731735105]
2.190890206]
4.38178045704]
4.88205721529]
6.81984932394]
0.0]
11.9308351588]
5.81081036828]
3.04449755451]
7.27774121836]
5.16219679054]
7.51274779396]
12.8717814849]
6.08899516302]
19.0650429331]
0.0]
5.39731735105]
6.24996550452]
6.08899517382]
4.54858826147]
5.39731736324]
9.13349274993]
7.77263102839]
6.10257153259]
19.3451410626]
0.0]
5.3973173998]
8.1773446431]
4.83093483736]
21.5432203888]
8.73518450756]
14.5782076432]
47.4171509799]
18.5591319683]
71.7881942083]
0.0]
5.16219676505]
6.10257153259]
9.532521489]
17.6447706308]
9.83168697937]
18.5055443667]
44.7151906611]
13.4932933533]
80.6484067858]
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
Figura A.5: Consumo de memória (30 execuções).
110
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
PROC.:10
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
0
17
9
0
11
0
0
22
0
13
0
25
0
0
0
0
24
0
0
4
0
0
3
11
14
24
25
15
26
14
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
14
1
2
13
3
13
12
0
14
0
13
0
12
14
12
14
0
13
13
3
14
11
5
2
0
0
0
0
0
0
Figura A.6: Tarefas enviadas e recebidas do processo 10 (2500 elementos, 10 processos).
111
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
NUM.
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
ELEM.:2500
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
PROC.:2
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
SEND:
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
1
1
0
0
1
0
0
1
0
0
0
0
0
0
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
RECEIVED:
1
0
1
0
0
0
1
1
0
1
0
0
0
0
0
0
0
0
0
0
0
0
1
0
0
0
0
1
0
0
Figura A.7: Tarefas enviadas e recebidas do processo 2 (2500 elementos, 3 processos).
112
REFERÊNCIAS
BLUMOFE, R. D.; LEISERSON, C. E. Scheduling Multithread Computations by Work
Stealing. In: ANNUAL SYMPOSIUM ON FOUNDATIONS OF COMPUTER SCIENCE, 35., 1994. Proceedings. . . [S.l.: s.n.], 1994.
BUTENHOF, D. R. Programming with POSIX Threads. [S.l.]: Addison-Wesley Professional, 1997.
CERA, M. C.; PEZZI, G. P.; MATHIAS, E. N.; MAILLARD, N.; NAVAUX, P. O. A.
Improving the Dynamic Creation of Processes in MPI-2. In: EURO-PVM/MPI, 2006,
2006. Proceedings. . . [S.l.: s.n.], 2006. p.8.
CLáUDIO, D. M.; MARINS, J. M. Cálculo Numérico Computacional : teoria e prática.
2.ed. [S.l.]: Atlas, 1994.
CREEGER, M. Multicore CPUs for the masses. Queue, New York, NY, USA, v.3, n.7,
p.64–ff, 2005.
DONGARRA, J.; FOSTER, I.; FOX, G.; GROPP, W.; KENNEDY, K.; TORCZON, L.;
WHITE, A. Sourcebook of Parallel Computing. 1.ed. São Francisco: Morgan Kaufmann Publishers, 2003.
ESCOLA REGIONAL DE ALTO DESEMPENHO, 2007. Anais. . . [S.l.: s.n.], 2007. n.7.
FLYNN, M. J. Some Computer Organization and Their Effectiveness. In: IEEE Transactions on Computers. [S.l.: s.n.], 1972. v.C-21, n.9.
GAREY, M. R.; JOHNSON, D. S. Computers and Intractability: a guide to the theory
of np-completeness. [S.l.]: W. H. Freeman, 1979. (Series of Books in the Mathematical
Sciences).
GEIST, A.; BEGUELIN, A.; DONGARRA, J.; JIANG, W.; MANCHEK, R.; SUNDERAM, V. PVM: parallel virtual machine a users’ guide and tutorial for networked parallel
computing. 1.ed. [S.l.]: MIT Press, 1994. (The Scientific and Engineering Computation
series).
GROPP, W.; LUSK, E.; SKJELLUM, A. Using MPI - Portable Parallel Programming
with Message-Passing Interface. 2.ed. Massachusetts Institue of Technology - Cambridge, Massachusetts 02142: The MIT Press, 1999. (Scientific and Engineering Computation Series).
113
GROPP, W.; LUSK, E.; THAKUR, R. Using MPI-2: advanced features of the messagepassing interface. Massachusetts Institue of Technology - Cambridge, Massachusetts
02142: The MIT Press, 1999. (Scientific and Engineering Computation Series).
JAJA, J. An Introduction to Paralell Algorithms. [S.l.]: Addinson-Wesley, 1992.
KELLER, H.; PFERSCHY, U.; PISINGER, D. Knapsack Problems. [S.l.]: Springer,
2005. 546p.
KOELBEL, C. H.; LOVEMAN, D. B.; SCHREIBER, R. S. The High Performance Fortran Handbook. [S.l.]: The MIT Press, 1993. (Scientific and Engineering Computation).
MARTELLO, S.; TOTH, P. Knapsack Problems: algorithms and computer implementations. [S.l.]: John Wiley & Sons Inc, 1990. (Wiley Interscience Series in Discrete Mathematics and Optimization).
MATTSON, T. G.; HENRY, G. An Overview of the Intel TFLOPS Supercomputer. Intel
Technology Journal, [S.l.], n.Q1, p.12, 1998.
NAVAUX, P. O. A.; ROSE, C. A. F. D. Arquiteturas Paralelas. 1.ed. [S.l.]: Editora Sagra
Luzzato, 2003. n.15. (Série Livros Didáticos).
NETHERCOTE, N.; SEWARD, J. Valgrind: a program supervision framework. Eletronic
Notes in Theoretical Computer Science, [S.l.], v.89, n.2, p.23, 2003.
OPENMP C and C++ Application Program Interface. 2.ed. [S.l.]: OpenMP Architecture
Review Board, 2002.
PACHECO, P. S. Paralell Programming With MPI. 1.ed. [S.l.]: Morgan Kaufmann
Publishers, 1997.
PACHECO, P. S. A User’s Guide to MPI. 1998.
PATTERSON, D. A.; HENNESSY, J. L. Arquitetura de Computadores: uma abordagem quantitativa. 3.ed. [S.l.]: Editora Campus, 2003.
PATTERSON, D. A.; HENNESSY, J. L. Organização e Projeto de Computadores: a
interface hardware software. 3.ed. [S.l.]: Editora Campus, 2005.
PEZZI, G. P.; CERA, M. C.; MATHIAS, E. N.; MAILLARD, N.; NAVAUX, P.
O. A. Escalonamento Dinâmico de programas MPI-2 utilizando Divisão e Conquista. In:
WORKSHOP EM SISTEMAS COMPUTACIONAIS DE ALTO DESEMPENHO 2006 WSCAD, 2006, 2006. Proceedings. . . [S.l.: s.n.], 2006.
ROOSTA, S. H. Parallel Processing and Parallel Algorithms: theory and computation.
1.ed. [S.l.]: Springer, 1999.
TANENBAUM, A. S. Distributed Operating Systems. [S.l.]: Prentice-Hall, 1995.
TANENBAUM, A. S. Sistemas Operacionais Modernos. 2.ed. [S.l.]: Prentice-Hall,
2003.
TOSCANI, L. V.; VELOSO, P. Complexidade de Algoritmos. 2.ed. [S.l.]: Editora Sagra
Luzzato, 2002. n.13. (Série Livros Didáticos).
114
TOSCANI, S. S.; OLIVEIRA, R. S. de; SILVA CARíSSIMI, A. da. Sistemas Operacionais. 2.ed. [S.l.]: Editora Sagra Luzzato, 2002. n.11. (Série Livros Didáticos).
TOSCANI, S. S.; OLIVEIRA, R. S. de; SILVA CARíSSIMI, A. da. Sistemas Operacionais e Programação Concorrente. 1.ed. [S.l.]: Editora Sagra Luzzato, 2003. n.14. (Série
Livros Didáticos).
Download

Emprego da Técnica de Workstealing: Estudo de Caso com