Estudo e Avaliação do Problema de Otimização da Multiplicação de Cadeias de Matrizes
-
Upload
eduardo-de-lucena-falcao -
Category
Documents
-
view
1.272 -
download
12
description
Transcript of Estudo e Avaliação do Problema de Otimização da Multiplicação de Cadeias de Matrizes
Estudo e Avaliação do Problema de Otimização da Multiplicação de
Cadeias de Matrizes
Eduardo de Lucena Falcão, Alisson Vasconcelos Brito, Alexandre Nóbrega Duarte
Departamento de Informática – Universidade Federal da Paraíba (UFPB)
{eduardolfalcao, alisson.brito, alexandrend}@gmail.com
Abstract. Operations of matrix multiplications are widely used in scientific
applications, and commonly have huge dimensions. Another problem derived
of the first it’s the matrix-chain multiplications, which also has scientific
applicability. Thus, efficient algorithms for matrix multiplications (or matrix-
chains) are extremely useful, as also are new approaches or even distributed
versions of already known algorithms. The present work aims at analyzing
some approaches for the solution of the matrix-chain multiplications problem,
implement it and measure their runtime to analyse the efficacy of the same.
1. Introdução
Desde 1946, com a criação do primeiro computador eletrônico para fins genéricos (o
ENIAC), pode-se perceber a rápida evolução dos processadores ao longo de décadas
[Koomey et al 2009]. A principal razão para tal é a evolução tecnológica que permite
cada vez mais a construção de transistores sempre menores e mais baratos, de tal modo
que mais ou menos a cada dois anos seu número dobrasse nos processadores, seguindo
uma tendência conhecida como Lei de Moore [Schaller 1997].
A rápida evolução dos processadores já é um fator que constantemente tem
tornado a execução de programas mais eficiente. Contudo, o aumento da velocidade dos
processadores é apenas um dos fatores que podem elevar o desempenho de um
programa e, além disto, é limitado por outros fatores que têm influência direta nesta
melhoria, dentre eles, a memória [Alted 2010]. De nada adianta ter processadores super
velozes, se a memória não é rápida o suficiente para fornecê-los dados para
processamento sem que eles fiquem ociosos. Deste modo, foi preciso adotar uma nova
estratégia para diminuir o tempo de espera do processador: a memória cache.
A memória cache é um tipo bastante rápido de memória que serve para
armazenar os dados mais frequentemente usados pelo processador, evitando na maioria
das vezes que ele tenha que recorrer à “lenta” memória RAM (Random Access
Memory). É interessante perceber o funcionamento da memória cache com o objetivo
de usá-las da maneira mais eficiente possível, e.g. tentar minimizar o cache miss.
Além de aspectos de hardware como frequência do processador e tamanho da
memória, é importante utilizar as estruturas de dados e técnicas de algoritmos que mais
se adéquam ao estilo de problema a ser resolvido. Adicionalmente, podemos perceber
que as escolhas de ferramentas, Linguagens de Programação (LP), e APIs (Application
Programer Interface), também podem ter certo impacto no desempenho da solução.
O objetivo do presente trabalho é tornar evidentes os vários fatores que podem
influenciar no desempenho de um programa:
utilizar a memória cache de maneira inteligente;
estruturas de dados e técnicas de programação mais adequados ao problema;
ferramentas e LPs a serem utilizadas.
Para tal, será apresentado o problema da Multiplicação de Cadeias de Matrizes
explicado no tópico abaixo, avaliando seu tempo de execução quando projetado de
diferentes modos.
2. O Problema da Multiplicação de Cadeias de Matrizes
No problema da multiplicação de cadeias de matrizes, recebe-se como entrada uma
cadeia de n matrizes, e retorna-se o produto delas. É fácil perceber,
que por motivos da lógica da multiplicação de matrizes que a matriz deve ter número
de linhas igual ao número de colunas da matriz .
O problema em sua essência é trivial, pois se multiplicarmos sequencialmente as
n matrizes obteremos facilmente o resultado esperado. O maior desafio que reside neste
problema é otimizar o desempenho do programa que o resolve. Para tal, podemos
explorar técnicas de programação aplicadas ao problema específico, além de buscar
sempre explorar o processamento máximo do computador de maneira inteligente.
3. O Problema do Cache Miss
O cache miss acontece quando o processador necessita de um dado, e este não
está na memória cache. Tal fato obriga o processador a buscar um bloco de dados –
constituído por um número fixo de palavras - diretamente na memória RAM, trazendo-
os para a memória cache e em seguida fornecendo a palavra requerida ao processador.
Em razão do fenômeno da localidade espacial, quando um bloco de dados é
trazido para a memória cache para satisfazer uma dada referência à memória, é provável
que futuras referências sejam feitas para outras palavras desse bloco (Figura 1)
[Stallings 2010]. Sendo assim, é fácil perceber que quando acontece vários cache miss
na execução de um programa, seu desempenho tenderá a cair. Os programadores podem
utilizar a propriedade da localidade espacial dentre outras relacionadas ao
funcionamento da memória cache no intuito de minimizar os cache miss e tornar a
execução dos programas mais rápidos.
Um exemplo simples que comprova tal fato é o problema da soma de todos os
elementos de uma matriz. Se para somar todos os elementos de uma matriz o algoritmo
percorrer as colunas ao invés das linhas, haverá maior probabilidade de ocorrer o cache
miss. Para comprovar este fato, foi implementado um programa simples na LP Java, que
mede o tempo de execução das duas formas de se percorrer uma matriz quadrada de
tamanho 10000. Ao percorrer as colunas o programa executou em 2886 milissegundos,
e ao percorrer as linhas o programa executou em 53 milissegundos. Podemos perceber
que quando minimizamos o cache miss tivemos um speedup superior a 50 vezes.
A Figura 1 ilustra o fenômeno da localidade espacial ocorrendo no problema da
soma dos elementos de uma matriz. Quando o primeiro elemento da matriz ( ) é
buscado para efetuar a soma, o processador buscará um bloco de dados sequencial (uma
linha do array, ou parte dela) para a memória cache. Se forem percorridas as linhas da
matriz, provavelmente os próximos elementos a serem somados (representados através
da cor verde e amarela na Figura 1) estarão na memória cache, o que ocasiona um cache
hit, tornando o acesso ao dado mais rápido. Se forem percorridas as colunas, há maior
probabilidade de que os próximos elementos a serem somados (vermelho) não estejam
na memória cache, ocorrendo um cache miss.
Figura 1 – Ilustração do fenômeno da localidade espacial.
4. Análise e Detalhamento de Possíveis Soluções para o Problema de
Multiplicação de Cadeias de Matrizes
Neste tópico serão descritas algumas abordagens possíveis para a solução deste
problema, que em alguns casos são complementares e em outros são antagônicas.
4.1. Abordagem Sequencial
Na abordagem sequencial multiplicam-se sequencialmente as n matrizes para obter seu
produto. Por exemplo, para uma cadeia com quatro matrizes a ordem de multiplicação
seria a seguinte: . O código da Tabela 1 recebe duas matrizes A e B
como entrada, e retorna a matriz C como resultado da multiplicação entre A e B. A
partir da Tabela 1 pode-se perceber que a complexidade para realizar a multiplicação
dessas duas matrizes é , sendo n relativo ao tamanho das matrizes (linhas e
colunas). Logo, para t matrizes tem-se que a complexidade é .
Tabela 1 – Código para multiplicação de duas matrizes. 1 for(int i=0; i<a.length ;i++){
for(int j=0; j<b[0].length ;j++){ c[i][j] = 0; for(int k=0; k<a[0].length ;k++) c[i][j] += a[i][k] * b[k][j]; } }
2
3
4
5
6
7
4.2. Abordagem com Programação Dinâmica
Analisando mais cuidadosamente o código da Tabela 1, pode-se perceber que o
número de multiplicações escalares relacionadas à multiplicação de duas matrizes
respeita o cálculo exibido na Figura 2. O referente à multiplicação pode ser
descrito de maneira mais detalhada por . Sendo assim, pode-se observar que a ordem com que as matrizes são
multiplicadas pode tornar o programa mais eficiente, ou seja, reduzir o número de
multiplicações.
Figura 2 – Quantidade de multiplicações escalares para a multiplicação de duas
matrizes. Adaptado de [Cormen et al 2002].
Por exemplo, para multiplicar uma cadeia de matrizes contendo três matrizes
existem duas parentizações possíveis. A Tabela 2 exibe as possíveis parentizações para
a seguinte cadeia com três matrizes: .
Tabela 2 – Possíveis ordens de multiplicação para uma cadeira com três matrizes.
1 2
A partir do cálculo fornecido pela Figura 2, é possível verificar que a ordem de
multiplicação das matrizes da linha 1 da Tabela 2 realiza 75000 multiplicações
escalares, ao passo que a ordem da linha 2 resulta em apenas 7500. Deste modo, Pode-
se perceber que para a multiplicação da cadeia já citada, apenas a ordem de
multiplicação das mesmas pode gerar um speedup de 10 vezes. É interessante perceber
que para o exemplo anterior a abordagem sequencial (4.1) é a mais rápida. Mas, deve
ser ressaltado que isso foi apenas uma coincidência, e que a probabilidade da
abordagem sequencial ser a mais rápida torna-se bastante remota quando o número de
matrizes da cadeia aumenta, o que proporciona um ganho expressivo quando se utiliza a
abordagem com a parentização otimizada.
Mas como proceder para encontrar a parentização otimizada? Através da força
bruta, o número de soluções seria exponencial em n ( ), sendo n o número de
multiplicações, tornando essa verificação inviável [Cormen et al 2002]. Uma alternativa
viável para este problema é a utilização da estratégia de Programação Dinâmica.
A Programação Dinâmica (PD) geralmente é aplicada a problemas de
otimização, ou seja, problemas que possuem várias soluções, mas deseja-se encontrar
uma solução ótima, como é o caso do corrente problema. Para aplicar a PD ao problema
da multiplicação de cadeias de matrizes são necessárias quatro etapas: caracterizar a
estrutura de uma solução ótima, que nesse caso é a estrutura de uma colocação ótima
dos parênteses; definir recursivamente o valor de uma solução ótima para o problema;
calcular o valor de uma parentização ótima; construir a solução ótima a partir das
informações coletadas. Mais detalhes sobre descrição e implementação desse problema
através da PD são fornecidos em [Cormen et al 2002].
4.3. Abordagem com Minimização de Cache Miss
O tópico 3 explicou alguns detalhes quanto a ocorrência de cache miss. Com relação ao
problema de multiplicação de matrizes, deve-se primeiramente ressaltar que a própria
natureza de seu algoritmo o faz mais suscetível à ocorrência de cache miss. Pois é
sabido que para multiplicar duas matrizes, deve-se percorrer a linha da primeira e a
coluna da segunda, e quando se percorre a coluna de uma matriz a probabilidade de
ocorrência do cache miss aumenta (vide Tópico 3).
A Tabela 1 exibe o código referente a um algoritmo tradicional para
multiplicação de matrizes. Nesse código o algoritmo sempre preencherá linha por linha
da matriz resultante. Este comportamento do algoritmo requer que o processador faça a
multiplicação de uma linha da primeira matriz com todas as colunas da segunda matriz.
Baseado no funcionamento do algoritmo, observou-se que é possível minimizar
a ocorrência do cache miss invertendo as linhas 1 e 2 da Tabela 1 (quando pertinentes),
com o objetivo de que o programa reutilize dados previamente armazenados na
memória cache. Pensou-se o seguinte (Tabela 3): se a quantidade de linhas da primeira
matriz é maior que a quantidade de linhas da segunda matriz, deve-se manter o código
da Tabela 1, caso contrário, devem ser invertidas as linhas 1 e 2 da Tabela 1. Com essa
verificação, o algoritmo sempre percorrerá a “maior linha possível”, o que minimiza o
cache miss.
Tabela 3 – Código proposto para minimização do cache miss. if(a.length > b.length) <Código da Tabela 1> else <Código da Tabela 1 com linhas 1 e 2 invertidas>
Outra maneira de se reduzir o cache miss para o problema da multiplicação de
matrizes seria multiplicar a primeira matriz pela matriz transposta da segunda. Pode-se
perceber que para isto seria necessário mudar o código relativo à multiplicação das
matrizes: ao invés de multiplicar linhas da primeira matriz por colunas da segunda,
multiplicar-se-iam linhas da primeira matriz por linhas da segunda (Figura 3), o que
implicaria numa redução significativa do cache miss. Para evitar um custo adicional
para o cálculo da transposta da segunda matriz, poderia apenas ser alterado o método de
criação das mesmas para já criá-las como sendo suas próprias transpostas.
A Figura 3 exemplifica a multiplicação tradicional com uma matriz
convencional, e a multiplicação proposta com a matriz transposta. As linhas coloridas
representam os cálculos relativos à primeira linha da matriz resultante:
Linhas vermelhas: multiplicar linha 1 da primeira matriz com colunas 1, 2, ..., n
da segunda matriz;
Linhas azuis: multiplicar linha 1 da primeira matriz com linhas 1, 2, ..., n da
segunda matriz.
Figura 3 – Multiplicação convencional, e multiplicação com matrizes transpostas.
4.4. Abordagem Distribuída com Threads
A abordagem distribuída utilizando threads visa explorar a potencialidade máxima dos
processadores atuais, que geralmente possuem mais de um núcleo físico e/ou lógico.
Contudo, será explicado adiante o porquê da abordagem distribuída com threads
utilizada no presente trabalho não caracterizar uma solução ótima, apesar de explorar a
capacidade máxima do hardware.
A estratégia utilizada para esta abordagem divide a cadeia
pelo número de threads a serem criadas. Desse modo, cada thread fica encarregada de
realizar a multiplicação de uma sub-cadeia, e ao final, quando todas terminarem sua
execução, a thread principal realizará a multiplicação das matrizes resultantes. Como
exemplo, considere uma cadeia com doze matrizes ( ) utilizando a
abordagem distribuída com duas threads. Esta cadeia seria quebrada ao meio em duas
sub-cadeias e , e depois passaria pelo processo de
se encontrar a parentização ótima através da estratégia de PD. Porém, é fácil perceber
que a parentização ótima para a cadeia muito dificilmente será
alcançada com a divisão da cadeia. Para alcançarmos tanto a parentização ótima e ainda
explorar a capacidade máxima do processador outra estratégia deve ser utilizada, como
por exemplo, a abordagem distribuída utilizando a biblioteca OpenMP.
4.5. Abordagem Distribuída com OpenMP
A abordagem distribuída utilizando a biblioteca OpenMP (Open Multi-Processing) não
divide a cadeia de matrizes em sub-cadeias, e portanto, mantém a parentização
otimizada encontrada através da abordagem por PD. Ao invés disso, utilizam-se
diretivas em trechos de código que são interpretadas pelo compilador, e que ao executar
o programa, ativam as threads dos processadores de acordo com as mesmas. Portanto, o
trecho de código que se pretende distribuir deve ser minuciosamente estudado para que
nenhuma violação lógica ou física seja cometida.
Para o problema da multiplicação de matrizes, foi percebido que os elementos da
matriz resultante não possuem interdependências, o que possibilita a paralelização desse
trecho de código, mais especificamente do loop for da linha 1 na Tabela 1. Deste modo,
o código ficaria como apresentado abaixo na Tabela 4.
Tabela 4 – Paralelizando com a biblioteca OpenMP. #pragma omp parallel for <Código da Tabela 1>
A biblioteca OpenMP usa o modelo de paralelismo fork-and-join, que ramifica a
thread principal em threads paralelas, e quando estas terminam de executar, a thread
principal reassume o controle do programa [Chandra et al 2001]. Existem diversas
diretivas da biblioteca OpenMP que permitem os programadores tornarem seus
programas distribuídos. A diretiva que foi utilizada na Tabela 4 atua de forma simples,
ela subdivide a quantidade total de iterações no loop for pelo número de threads a serem
utilizadas, e cada thread executará uma parte das iterações que antes eram executadas
por apenas uma thread. No problema da multiplicação de matrizes, se a matriz
resultante tiver 10 linhas, e for especificado que o processador deve trabalhar com duas
threads, a primeira computará o produto das 5 primeiras linhas da matriz resultante, e a
segunda computará as linhas restantes (Figura 4).
Figura 4 – Multiplicação com abordagem não-distribuída e distribuída.
5. Resultados
Para avaliar algumas das abordagens supracitadas pensou-se em realizar cinco
experimentos, aliando diferentes algoritmos e técnicas de programação. As cinco
estratégias são citadas abaixo, visando a cada novo experimento implementado
melhorar o desempenho do programa.
1. Linguagem de Programação Java:
a) Abordagem sequencial, maximizando o cache miss;
b) Utilizando técnicas de PD, maximizando o cache miss;
c) Utilizando técnicas de PD, minimizando o cache miss;
d) Utilizando técnicas de PD, minimizando o cache miss, além de tornar a lógica do
algoritmo distribuída:
i. Em 2 Threads;
ii. Em 4 Threads;
“Maximizar o cache miss” tem o objetivo de tornar mais evidente a ocorrência
do mesmo, e prover uma noção de tempo desperdiçado quando o mesmo ocorre. Para
tal, as linhas 1 e 2 da Tabela 1 devem ser invertidas com a lógica inversa da Tabela 3.
Para minimizar o cache miss foi utilizada o código presente na Tabela 3. E para tornar a
lógica do algoritmo distribuído foi utilizada a abordagem explanada no tópico 4.4. Para
fins de padronização as matrizes foram preenchidas com 1’s.
Para tais experimentos, foi utilizado um notebook Dell Vostro 3450, Sistema
Operacional Windows 7 Professional Service Pack 1 de 64 Bits, 4 GB de memória
RAM, processador Intel® Core™ i5-250M de 2.5 GHz.
A cadeia de matrizes escolhida para os experimentos foi a seguinte:
<7000,50,30,5000,32,40,5000,80,20,4000,20,10,1000,50>, o que equivale às matrizes
. Pode-se perceber que todas as matrizes da
cadeia possuem o número de linhas muito superior ao número de colunas, ou justamente
o inverso, pois a ocorrência de cache miss torna-se mais acentuada nesses casos.
A Figura 5 exibe o gráfico com o resultado dos experimentos propostos. A
legenda na direita corresponde aos experimentos descritos anteriormente.
Figura 5 – Resultados do experimento na LP Java.
Pode-se observar que a diferença de desempenho da abordagem que usa PD para
a sequencial é deveras significante (25.8 segundos), um fato esperado e descrito nos
tópicos 4.1 e 4.2 do tópico. Analisando o resultado dos experimentos 1.b e 1.c, pode-se
notar que quando é utilizada uma estratégia de minimização de cache miss (descrita no
tópico 4.3) para o problema, obteve-se 4.7 segundos de speedup, que representa uma
evolução de quase 50%. É importante ressaltar que quando o tamanho da cadeia
aumenta, essa disparidade também tende a aumentar. Por último, ao analisar a estratégia
distribuída (descrita no tópico 4.4), pode-se perceber que tanto a 1.d quanto 1.e,
utilizando 2 e 4 threads respectivamente, apresentaram perda de desempenho. No tópico
4.4, já se havia cogitado a possibilidade dessa ocorrência devido à perda de parentização
ótima. Porém, é de se esperar que esta abordagem apresente ganho de desempenho para
determinadas cadeias de matrizes, principalmente quando o número de matrizes cresce.
0
20
40 36,7
10,9 6,2 6,6 6,4
1.a
1.b
1.c
1.d
5. Conclusão
O objetivo do presente trabalho foi mostrar a importância de dominar técnicas
relacionadas a projeto de algoritmos, e também conceitos de arquitetura de
computadores para obter melhor desempenho do software e hardware. Ao utilizar a
técnica de PD aliada à minimização de cache miss, obteve-se um speedup de 6x em
relação ao desempenho da solução mais simples (sequencial). É interessante perceber
que todos os algoritmos estão na mesma ordem de complexidade de tempo ( ), e
mesmo assim foram obtidos ganhos de desempenho.
Destes resultados, pode-se colher como aprendizado, que ao receber um
problema é necessário estudá-lo, visualizá-lo de diferentes perspectivas para adotar a
estratégia de solução mais eficiente. Para tal, devem ser analisados os algoritmos
pertinentes, ferramentas (APIs, bibliotecas, etc.) e LPs a serem utilizadas, além de
conceitos sobre funcionamento de software/hardware, de sistemas operacionais e da
arquitetura do computador de uma forma generalizada.
7. Trabalhos Futuros
Como possibilidades para trabalhos futuros, pode ser considerada a implementação das
abordagens já citadas na LP C++, uma vez que geralmente os programas escritos nessa
LP tendem a ter melhor desempenho que em Java. Além disso, a abordagem descrita no
tópico 4.5 parece ser a mais eficiente. O fato de aproveitar o desempenho da LP C++,
realizando a distribuição do algoritmo em quantas threads desejar, mantendo a
parentização ótima alcançada pela PD, e minimizar a ocorrência de cache miss com a
abordagem da matriz transposta, aparenta ser a melhor solução analisada no presente
trabalho. Outra tarefa interessante seria implementar os algoritmos para multiplicação
de matrizes desenvolvidos por Strassen , que possui complexidade de , ou o
desenvolvido por Coppersmith e Winograd com ordem de complexidade , aliando à alguma abordagem de minimização de cache miss para comparação dos
resultados [Robinson 2005].
Referências
Koomey, J. G., Berard, S., Sanchez, M., Wong, H. (2009) “Assessing Trends in the
Electrical Efficiency”. Final report to Microsoft Corporation and Intel Corporation.
Schaller, R. R. “Moore's law: past, present and future”. (1997) IEEE Spectrum, Vol 34,
pp. 52-59.
Alted, F. “Why Modern CPUs are Starving and what can be done about it”. (2010)
Computing in Science & Engineering, pp. 68–71.
Stallings, W. “Arquitetura e Organização de Computadores”. (2010) Editora Prentice
Hall, pp. 122-144.
Cormen, T. H., Leiserson, C. E., Rivest, R. L., Stein, C. “Algoritmos: Teoria e Prática”.
(2002) Editora Campus, pp. 259-295.
Chandra, R., Dagum, L., Kohr, D., Maydan, D., McDonald, J., Menon, R. “Parallel
Programming in OpenMP”. (2001) Editora Morgan Kaufmann.
Robinson, S. “Toward an Optimal Algorithm for Matrix Multiplication”. (2005) Society
for Industrial and Applied Mathematics. Vol. 38, Nº 9.