CES-41 COMPILADORES Capítulo Extra Compiladores Paralelos Capítulo Extra Compiladores Paralelos E.1 – Linguagens para processamento paralelo E.2 – Componentes de um compilador paralelo E.3 – Análise de dependências de dados E.4 – O processo de paralelização de programas E.1 – Linguagens para processamento paralelo E.1.1 - Conceito de processamento paralelo Supercomputadores: computadores com milhares de processadores e milhares de módulos de memória Multiprocessamento: execução simultânea de vários programas Processamento paralelo: execução simultânea de vários trechos de um mesmo programa E.1.2 - Alternativas para linguagens As linguagens utilizadas em processamento paralelo se classificam em: – – – Linguagens paralelas Linguagens convencionais ampliadas Linguagens sequenciais Linguagens paralelas e convencionais ampliadas: – – – Exigem recodificação de todos os programas sequenciais de interesse, escritos até hoje Há mais de 50 anos de programação sequencial Obrigam muitos cientistas e pesquisadores a aprendê-las Linguagens sequenciais: alternativa muito pesquisada na programação de computadores paralelos: – Desobrigam do aprendizado de novas linguagens (inércia dos cientistas e pesquisadores) A utilização de linguagens sequenciais em máquinas paralelas exigem do compilador a detecção automática de paralelismo dos programas Outras utilidades da detecção automática de paralelismo: – Em linguagens paralelas, para descobrir paralelismo não explícito pelo programador – Quanto menor o nível de paralelismo, mais difícil expressá-lo (iterações paralelas de laços, comandos paralelos internos às iterações, paralelismo intracomandos) – Para adequar o paralelismo dos programas ao potencial de paralelismo da máquina E.1.3 - Paralelismo em programas sequenciais Programas sequenciais apresentam muito paralelismo implícito – Uma das maiores fontes de paralelismo é o laço sequencial Exemplos: 1) C1: C2: for (i 1; i 5; i++) { A[i] B[i] + C[i]; D[i] E[i] + F[i]; } Todas as instâncias de C1 e de C2 podem ser executadas simultaneamente 2) C1: C2: for (i 1; i 5; i++) { A[i] B[i] + C[i]; D[i] A[i] + E[i]; } C1[1] C1[2] C1[3] C1[4] C1[5] C2[1] C2[2] C2[3] C2[4] C2[5] Todas as iterações podem ser executadas simultaneamente Alguns laços não apresentam paralelismo Exemplo: C1: C2: for (i 1; i 5; i++) { A[i] B[i] + C[i]; B[i+1] A[i] + D[i]; } C1[1] C1[2] C1[3] C1[4] C1[5] C2[1] C2[2] C2[3] C2[4] C2[5] Há dependências intra e inter iterações As iterações e os seus comandos internos são sequenciais Há comandos a princípio sequenciais, mas que podem ser transformados de forma a apresentar paralelismo Exemplo: seja o seguinte laço: C1: C2: for (i 1; i 5; i++) { A[i] B[i] + C[i]; D[i] A[i] + A[i+1]; } C1[1] C1[2] C1[3] C1[4] C1[5] C2[1] C2[2] C2[3] C2[4] C2[5] Há uma dependência de fluxo de C1[i] para C2[i] e uma antidependência de C2[i] para C1[i+1] Há comandos a princípio sequenciais, mas que podem ser transformados de forma a apresentar paralelismo Exemplo: seja o seguinte laço: C1: C2: for (i 1; i 5; i++) { A[i] B[i] + C[i]; D[i] A[i] + A[i+1]; } C1[1] C1[2] C1[3] C1[4] C1[5] C2[1] C2[2] C2[3] C2[4] C2[5] Novamente as iterações e os comandos delas internos são sequenciais C1: C2: for (i 1; i 5; i++) { A[i] B[i] + C[i]; D[i] A[i] + A[i+1]; } No entanto, pode-se alterar o escopo do laço de forma a introduzir nele paralelismo: C2’: C1: C2: for (i 1; i 5; i++) { T[i] A[i+1]; A[i] B[i] + C[i]; D[i] A[i] + T[i]; } O laço pode ser decomposto em dois: C2’: C1: C2: for (i 1; i 5; i++) T[i] A[i+1]; for (i 1; i 5; i++) { A[i] B[i] + C[i]; D[i] A[i] + T[i]; } Em cada laço, todas as iterações podem ser executadas simultaneamente Assim, um compilador de uma linguagem sequencial para processamento paralelo deve fazer: – Análise de dependências do programa fonte – Transformações no programa fonte, introduzindo-lhe mais paralelismo E.2 – Componentes de um compilador paralelo Grosso modo, um compilador paralelo de linguagens sequenciais deve ter os seguintes componentes: 1. Analisador sintático e semântico, armazenando o programa fonte numa estrutura adequada 2. Construtor do grafo de dependências do programa, fonte de consultas para as transformações subsequentes 3. Eliminador de dependências e redutor de seus ciclos, visando aumentar o potencial de paralelismo do programa fonte E.2 – Componentes de um compilador paralelo Grosso modo, um compilador paralelo de linguagens sequenciais deve ter os seguintes componentes: 4. Reorganizador de laços, tornando-os tratáveis pelo passo 6 5. Alocador de memória, visando boa utilização de memória local e global 6. Paralelizador propriamente dito, transformando os comandos e laços sequenciais em equivalentes paralelos 7. Gerador de código intermediário e objeto E.3 – Análise de Dependências de Dados E.3.1 – Tipos de dependências a) Dependência de fluxo: Um comando produz um valor e outro o utiliza Exemplo: C1: C2: A B + C; D A + E; Simbologia: a dependência é expressa por (C1 f C2) b) Antidependência: Um comando usa o valor de uma variável e outro o altera Exemplo: C1: C2: A B + C; B D + E; Simbologia: a dependência é expressa por (C1 a C2) c) Dependência de saída: Um comando produz um valor para uma variável e depois outro lhe produz outro valor Exemplo: C1: C2: A B + C; A D + E; Simbologia: a dependência é expressa por (C1 s C2) d) Dependência condicional: O resultado da condição de um comando determina se outro comando será ou não executado Exemplo: C1: C2: if (A < B) C D + E; Simbologia: a dependência é expressa por (C1 c C2) E.3.2 – Grafos de dependências O conceito de dependência pode ser estendido para o escopo de laços Exemplo: um laço e a lista de suas dependências: for (i 1; i n; i++) { C1: a X[i] + X[i-1]; C2: Z[i] a ** 2; C3: X[i] X[i-1] + B[i]; } Dependências: (C1 (C1 (C1 (C2 (C3 (C3 s f a a f f C1) C2) C3) C1) C1) C3) Concebe-se o grafo de dependências: nível de comandos, nível de instância e nível atômico for (i 1; i n; i++) { C1: a X[i] + X[i-1]; C2: Z[i] a ** 2; C3: X[i] X[i-1] + B[i]; } Dependências: (C1 (C1 (C1 (C2 (C3 (C3 s f a a f f C1) C2) C3) C1) C1) C3) Nível de comandos: Todas as dependências estão num só ciclo; ciclos têm difícil solução paralela for (i 1; i n; i++) { C1: a X[i] + X[i-1]; C2: Z[i] a ** 2; C3: X[i] X[i-1] + B[i]; } Nível de instâncias: Dependências: (C1 (C1 (C1 (C2 (C3 (C3 s f a a f f C1) C2) C3) C1) C1) C3) for (i 1; i n; i++) { C1: a X[i] + X[i-1]; C2: Z[i] a ** 2; C3: X[i] X[i-1] + B[i]; } Nível atômico: Mostra os elementos de variáveis responsáveis pelas dependências Dependências: (C1 (C1 (C1 (C2 (C3 (C3 s f a a f f C1) C2) C3) C1) C1) C3) E.3.3 – Definições para dependências Existe uma dependência de dados (excluída a dependência condicional) de um comando C1 para outro não necessariamente distinto C2, simbolicamente (C1 C2), se e somente se existirem instâncias C1’ e C2’ de C1 e C2 e uma posição M de memória tais que: 1. C1’ e C2’ fazem referência a M e pelo menos uma dessas referências é uma escrita 2. C1’ é executada antes de C2’ 3. M não recebe escrita entre C1’ e C2’ 1. C1’ e C2’ fazem referência a M e pelo menos uma dessas referências é uma escrita 2. C1’ é executada antes de C2’ 3. M não recebe escrita entre C1’ e C2’ C1: fonte da dependência; C1’: instância fonte C2: destino da dependência; C2’: instância destino Dependência de fluxo: C1’ é escrita e C2’ é leitura em M Antidependência: C1’ é escrita e C2’ é leitura em M Dependência de saída: C1’ e C2’ são escritas em M Obs.: algumas referências bibliográficas incluem o item 3 apenas para as dependências de fluxo 1. C1’ e C2’ fazem referência a M e pelo menos uma dessas referências é uma escrita 2. C1’ é executada antes de C2’ 3. M não recebe escrita entre C1’ e C2’ Item 1: C1’ e C2’ fazem referência a M: – Para variáveis escalares é trivial: basta referenciarem a mesma variável – Para variáveis indexadas não basta: é necessária uma análise de seus subscritos 1. C1’ e C2’ fazem referência a M e pelo menos uma dessas referências é uma escrita 2. C1’ é executada antes de C2’ 3. M não recebe escrita entre C1’ e C2’ Item 2: C1’ é executada antes de C2’; seja o seguinte laço: C2: C1: for (j 1; j n; j++) { for (i 1; i n; i++) { D[i] A ** 2; A E[i] + F[i]; } A B[j] + C[j]; } A dependência (C1 f C2) existe Se o laço j não existisse a dependência não existiria 1. C1’ e C2’ fazem referência a M e pelo menos uma dessas referências é uma escrita 2. C1’ é executada antes de C2’ 3. M não recebe escrita entre C1’ e C2’ Item 3: M não recebe escrita entre C1’ e C2’; seja o seguinte laço: C2: C1: C3: } for (i 1; i n; i++) { C[i] A ** 2; A D[i] + E[i]; F[i] A ** 3; A G[i] + H[i]; A dependência (C1 f C2) não existe Se o comando C3 fosse condicional, a dependência existiria Dentro de laços, dependências têm direção e distância Exemplos: sejam os seguintes laços: 1) for (i 1; i n; i++) { A[i] B[i] + 5; C[i] A[i] * 2; } A dependência (C1 f C2) é interna à iteração i for (i 1; i n; i++) { A[i] B[i] + 5; C[i] A[i-3] * 2; } A dependência (C1 f C2) vai da iteração i até a i+3 C1: C2: 2) C1: C2: Direção: Distância: Direção: Distância: ‘=’ zero ‘<’ 3 Dentro de laços, dependências têm direção e distância Exemplos: sejam os seguintes laços: 3) C1: C2: for (i 1; i n; i++) for (j 1; j n; j++) { A[i, j] B[i, j] + C[i, j] ; D[i, j] A[i-2, j+1] + E[i, j]; } A dependência (C1 f C2) vai da iteração (i, j) até a (i+2, j-1) Vetor-direção: Vetor-distância: (‘<’, ‘>’) (2, -1) E.3.4 – Detecção de dependências A detecção de dependências entre variáveis escalares pode utilizar análise de fluxo de dados aplicada em otimização de código, na construção de compiladores Entre variáveis indexadas, além disso, é necessária uma análise dos subscritos dessas variáveis Seja o aninhamento a seguir for (I1 P1; I1 Q1; I1 ++) { Laços de C1: for (I2 P2; I2 Q2; I2 ++) { I1, I2, ... , Id, Id+1, ... , Ie for (Id Pd; Id Qd; Id ++) { for (Id+1 Pd+1; Id+1 Qd+1; Id+1 ++) { Laços de C2: I1, I2, ... , Id, Ie+1, ... , In for (Ie Pe; Ie Qe; Ie ++) { C1: . . . V[expr1,1, expr1,2, … , expr1,m,] . . . A dependência (C1 C2) } existe se e somente se existirem instâncias } for (Ie+1 Pe+1; Ie+1 Qe+1; Ie+1 ++) { C1(i1, i2, ... , id, id+1, ... , ie) e C2(j1, j2, ... , jd, je+1, ... , jn) for (In Pn; In Qn; In ++) { C2: . . . V[expr2,1, expr2,2, … , expr2,m,] . . . tais que: } 1. k | (1 k m), expr1,k = expr2,k } 2. k | (1 k n), Pk ik, jk Qk } 3. A instância de C1 é executada antes da instância de C2 } } 1. k | (1 k m), expr1,k = expr2,k 2. k | (1 k n), Pk ik, jk Qk 3. A instância de C1 é executada antes da instância de C2 Item 1: sistema de m equações com as incógnitas: i1, i2, ... , id, id+1, ... , ie, j1, j2, ... , jd, je+1, ... , jn Item 2 e 3: inequações com as mesmas incógnitas Se esse sistema de equações e inequações tiver solução, a dependência existe; caso contrário, não existe Quando esse sistema é linear a literatura apresenta vários métodos de solução E.4 – O Processo de Paralelização E.4.1 – Um programa tipicamente sequencial Seja o seguinte programa sequencial a ser paralelizado e seu grafo de dependências: for (i 1; i 100; i++) for (j 1; j 100; j++) { C1: a X[i, j] + X[i, j-1]; C2: Z[i, j] a ** 2; C3: X[i, j] X[i, j-1] + B[j]; } Todos os comandos e dependências estão num só ciclo do grafo Não é possível nenhuma paralelização E.4.2 – Expansão de variáveis escalares for (i 1; i 100; i++) C1: C2: C3: for (j 1; j 100; j++) { a X[i, j] + X[i, j-1]; Z[i, j] a ** 2; X[i, j] X[i, j-1] + B[j]; } A variável a é a causa das seguintes dependências: (C1 s C1), (C1 f C2) e (C2 a C1) Seu local é utilizado por todas a iterações Substituindo-a dentro do aninhamento pela variável indexada AA, cada iteração referencia um local diferente for (i 1; i 100; i++) C1: C2: C3: for (j 1; j 100; j++) { a X[i, j] + X[i, j-1]; Z[i, j] a ** 2; X[i, j] X[i, j-1] + B[j]; } C1: C2: C3: for (i 1; i 100; i++) for (j 1; j 100; j++) { AA[i,j] X[i, j] + X[i, j-1]; Z[i, j] AA[i,j] ** 2; X[i, j] X[i, j-1] + B[j]; } a AA[100, 100]; C4: O comando C2 ficou fora do ciclo E.4.3 – Reordenação de comandos de atribuição Os comandos C2 e C3 podem trocar de posição: for (i 1; i 100; i++) C1: C3: C2: for (j 1; j 100; j++) { AA[i,j] X[i, j] + X[i, j-1]; X[i, j] X[i, j-1] + B[j]; Z[i, j] AA[i,j] ** 2; } O comando C2 pode ser vetorizado O comando C4 foi omitido, mas será reconsiderado no final E.4.4 – Vetorização de laços É a geração de comandos vetoriais for (i 1; i 100; i++) { C1: C3: C2: } for (j 1; j 100; j++) { AA[i,j] X[i, j] + X[i, j-1]; X[i, j] X[i, j-1] + B[j]; } Z[i, 1: 100] AA[i, 1: 100] ** 2 Os elementos Z[i, 1], Z[i, 2], ... , Z[i, 100] podem ser calculados simultaneamente O ciclo (C1 C3) pode ser quebrado E.4.5 – Fissão de comandos Um ciclo contendo antidependências pode ser quebrado com a introdução de variáveis temporárias for (i 1; i 100; i++) { for (j 1; j 100; j++) { C1: C3: C2: } AA[i, j] X[i, j] + X[i, j-1]; X[i, j] X[i, j-1] + B[j]; } Z[i, 1: 100] AA[i, 1: 100] ** 2 E.4.5 – Fissão de comandos Um ciclo contendo antidependências pode ser quebrado com a introdução de variáveis temporárias for (i 1; i 100; i++) { C11: C12: C3: C2: } for (j 1; j 100; j++) { T[i, j] X[i, j]; AA[i, j] T[i, j] + X[i, j-1]; X[i, j] X[i, j-1] + B[j]; } Z[i, 1: 100] AA[i, 1: 100] ** 2 E.4.5 – Fissão de comandos E, para a vetorização de C11 e C12, é necessária nova reordenação: for (i 1; i 100; i++) { C11: C12: C3: C2: } for (j 1; j 100; j++) { T[i, j] X[i, j]; AA[i, j] T[i, j] + X[i, j-1]; X[i, j] X[i, j-1] + B[j]; } Z[i, 1: 100] AA[i, 1: 100] ** 2 E.4.5 – Fissão de comandos E, para a vetorização de C11 e C12, é necessária nova reordenação: for (i 1; i 100; i++) { C11: C3: C12: C2: } for (j 1; j 100; j++) { T[i, j] X[i, j]; X[i, j] X[i, j-1] + B[j]; AA[i, j] T[i, j] + X[i, j-1]; } Z[i, 1: 100] AA[i, 1: 100] ** 2 E.4.5 – Fissão de comandos E, para a vetorização de C11 e C12, é necessária nova reordenação: for (i 1; i 100; i++) { C11: C3: C12: C2: } T[i, 1: 100] X[i, 1: 100]; for (j 1; j 100; j++) X[i, j] X[i, j-1] + B[j]; AA[i, 1: 100] T[i, 1: 100] + X[i, 0: 99]; Z[i, 1: 100] AA[i, 1: 100] ** 2 E.4.6 – Decomposição parcial de laços Um laço contendo vários comandos em seu escopo pode ser decomposto num conjunto de laços menores Geralmente a paralelização de laços pequenos é mais simples que a de laços grandes Assim o laço do programa anterior pode ser decomposto conforme o programa logo a seguir E.4.6 – Decomposição parcial de laços C11: for (i 1; i 100; i++) T[i, 1: 100] X[i, 1: 100]; C3: for (i 1; i 100; i++) for (j 1; j 100; j++) X[i, j] X[i, j-1] + B[j]; C12: C2: for (i 1; i 100; i++) { AA[i, 1: 100] T[i, 1: 100] + X[i, 0: 99]; Z[i, 1: 100] AA[i, 1: 100] ** 2; } Neste grafo de dependências, cada aninhamento tem seu grafo particular E.4.7 – Paralelização total das iterações dos laços No programa anterior, os grafos do 1o e do 3o aninhamentos não apresentam ciclos No grafo do 2o aninhamento, a dependência é interna à iteração do laço externo (=, <) Todos esses laços podem ser transformados em comandos DoAll, conforme o programa a seguir Todas a iterações de um DoAll são executadas simultaneamente E.4.7 – Paralelização total das iterações dos laços E.4.9 – Resultado da paralelização Resumindo o que foi feito, o comando sequencial, desprovido de paralelismo: for (i 1; i 100; i++) for (j 1; j 100; j++) { C1: a X[i, j] + X[i, j-1]; C2: Z[i, j] a ** 2; C3: X[i, j] X[i, j-1] + B[j]; } Foi transformado no programa paralelo a seguir DoAll i = 1, 100 T[i, 1: 100] X[i, 1: 100]; EndDoAll DoAll (i 1; i 100; i++) for (j 1; j 100; j++) X[i, j] X[i, j-1] + B[j]; EndDoAll DoAll i = 1, 100 AA[i, 1: 100] T[i, 1: 100] + X[i, 0: 99]; Z[i, 1: 100] AA[i, 1: 100] ** 2; EndDoAll a = AA[100, 100]; Suponha que este programa seja executado numa máquina com 100 processadores e que cada um deles seja um processador matricial de 100 processadores síncronos Tal máquina tem na realidade o equivalente a 10000 processadores escalares Suponha também, grosso modo, que cada comando de atribuição seja executado num tempo igual a t unidades de tempo DoAll i = 1, 100 T[i, 1: 100] X[i, 1: 100]; EndDoAll DoAll (i 1; i 100; i++) for (j 1; j 100; j++) X[i, j] X[i, j-1] + B[j]; EndDoAll DoAll i = 1, 100 AA[i, 1: 100] T[i, 1: 100] + X[i, 0: 99]; Z[i, 1: 100] AA[i, 1: 100] ** 2; EndDoAll a = AA[100, 100]; A seguinte tabela mostra o tempo de execução do laço sequencial e das várias tarefas do programa paralelo DoAll i = 1, 100 T[i, 1: 100] X[i, 1: 100]; EndDoAll DoAll (i 1; i 100; i++) for (j 1; j 100; j++) X[i, j] X[i, j-1] + B[j]; EndDoAll DoAll i = 1, 100 AA[i, 1: 100] T[i, 1: 100] + X[i, 0: 99]; Z[i, 1: 100] AA[i, 1: 100] ** 2; EndDoAll a = AA[100, 100]; A baixa eficiência se deve à tarefa T2, que utiliza somente 100 dos 10000 processadores, deixando ociosos os outros 9900. Se o ganho de 300 for imprescindível, não importa essa baixa eficiência. DoAll i = 1, 100 T[i, 1: 100] X[i, 1: 100]; EndDoAll DoAll (i 1; i 100; i++) for (j 1; j 100; j++) X[i, j] X[i, j-1] + B[j]; EndDoAll DoAll i = 1, 100 AA[i, 1: 100] T[i, 1: 100] + X[i, 0: 99]; Z[i, 1: 100] AA[i, 1: 100] ** 2; EndDoAll a = AA[100, 100]; Porém se essa eficiência de 3% for inaceitável, pode-se usar uma arquitetura em que os 100 processadores não sejam matriciais Assim a tabela dos tempos de execução será a seguinte: