Quadratura Adaptativa José Eduardo Talavera Herrera [email protected] 1. Introdução Este trabalho apresenta algumas variantes do algoritmo da quadratura adaptativa [1], utilizando pthreads e OpenMP. Não seção 2. É apresentado o conceito da quadratura adaptativa. O problema que se pretende resolver neste trabalho é descrito na seção 3. Uma solução sequencial para a quadratura adaptativa é definida na seção 4. A seção 5 e 6 são usadas para desenvolver a quadratura adaptativa usando pThread e OpenMP. Por último os resultados são apresentados não seção 7. 2. Quadratura Adaptativa Considere uma função f(x) continua e não negativa, e também dois valores l e r, onde l < r. Ver Figura 1 . Uma maneira de aproximar o valor da área embaixo da curva é dividir o intervalo [l,r] em uma serie de subintervalos, e usar trapézios para aproximar a área de cada subintervalo. Por exemplo, para aproximar da área de uma função f no intervalo [a,b], primeiro obter o valor da base do trapézio b-a , depois obter o tamanho dos lados f(a) e f(b) e usar a formula clássica: Area = (f(a) + f(b)) * (b-a) *0.5 O problema da quadratura pode ser resolvido estaticamente o dinamicamente. A abordagem estática usa um número fixo de intervalos de igual tamanho, computa a área do trapézio para cada intervalo e suma os resultados. O processo é repetido para cada intervalo, normalmente cada um é dividido em dois novos subintervalos, acabando quando as aproximações das áreas são próximas a um valor aceitável. A abordagem dinâmica começa com um intervalo [l,r] e é computado o ponto médio entre eles m. Depois ela computa a área de três trapézios: A1) a primeira limitada por l, r, f(l), f(r) que é a área de maior tamanho embaixo a função; A2) a segunda limitada por l, m, f(l), f(m); e A3) m, r, f(m), f(r). Ver Figura 1. Continuando com o processo, a abordagem dinâmica compara a área de maior tamanho com a suma das áreas menores. Se estas áreas são muito próximas, a abordagem considera a suma de estas últimas áreas como uma aproximação aceitável da área embaixo de f . Em caso contrario, o problema é repetido para resolver os dois subproblemas gerados, ou seja, computar a área dos intervalos [l,m] e [m,r]. Este processo é repetido recursivamente até a solução de cada problema ser aceitável. No final do processo, a abordagem suma as respostas dos subprocessos para obter a resposta final. A abordagem dinâmica é chamada de Quadratura Adaptativa, porque a abordagem adapta por se mesma a forma da curva. Em particular, em lugares onde a curva é plana uma área ampla de trapézio pode se aproximar com a área da função. Em lugares onde a curva muda de forma, especificamente onde a tangente de f(x) é quase vertical, pequenos subproblemas serão gerados, conforme seja necessário. Figura 1 Quadratura Adaptativa 3. Problema Neste trabalho deve-se implementar algumas variantes do algoritmo da quadratura adaptativa [1], utilizando pthreads e OpenMP. O programa deve utilizar X threads para computar a aproximação, pelo método de trapézios, da área abaixo da curva formada por uma função f.. O número de trapézios a ser calculado para realizar a aproximação depende de uma tolerância. A tolerância na diferença entre um trapézio e os dois subtrapézios “seguintes” deve ser fixada inicialmente em 10^-20, mas deve ser um valor facilmente alterado no programa. 1. Na primeira variante, cada thread calcula um subintervalo pelo qual será responsável e calcula o resultado para esse subintervalo inteiro. Quando todas as threads terminarem, a thread principal deve mostrar o resultado final. 2. Na segunda variante, a thread principal inicialmente cria uma lista de tarefas, contendo os extremos dos intervalos, com NUMINICIAL tarefas. Cada thread executa uma tarefa, e se ela gerar subtarefas, coloca uma delas na fila global e processa a outra, até que não encontre mais tarefas na fila (Escreva operaçoes InsereTarefa e RetiraTarefa para manipular essa fila.). A thread principal espera as demais terminarem e mostra o resultado final. O programa deve aceitar facilmente a mudança para outras funções e intervalos, parametrizando o cálculo da integral pelos extremos e por um ponteiro de função. Execute cada variante para diferentes números de threads (2, 4 e 8), com algumas medidas preliminares dos tempos obtidos para cada combinação. Deve-se experimentar com diferentes tolerâncias e com diferentes funções. (Nas próximas aulas iremos discutir questões relacionadas a medidas de desempenho.) Procure funções (pode inventar loops e muitas operações com doubles) que criem uma carga de processamento relevante. Elabore um pequeno relatório com seus resultados, explicitando também a arquitetura em que executou os testes. Discuta também o que considerou vantagens ou desvantagens de cada um dos ambientes. Fazer o programa em C. Nas variantes em pthreads, não misture semáforos e monitores. 4. Solução sequencial da Quadratura Adaptativa A seguir se apresenta a solução da quadratura adaptativa de forma sequencial. Esta solução presenta uma recursividade que ajuda a identificar as possíveis seções paralelizáveis, e outras que podem virar seções criticas, como por exemplo, a suma das áreas, no caso seja uma variável compartilhada. 1. void integral(double l, double r, double area) 2.{ 3. double m = (r+l)/2.0; 4. double larea = trape_area(l,m); 5. double rarea = trape_area(m,r); 6. double sumOfAreas = larea + rarea; 7. double relError = fabs(area - sumOfAreas); 8. if(relError <= EPSILON) 9. { return sumTotal = sumTotal + sumOfAreas; } 10. else 11. { integral(l,m, larea) ; integral(m,r, rarea); } 12.} 13. 14.double trape_area(double a, double b) 15.{ 16. double f_a = function(a); 17. double f_b = function(b); 18. double c = fabs(b-a); 19. return 0.5 * (f_a + f_b) * c; 20.} Algoritmo. 1 Quadratura Adaptativa Sequencial No Algoritmo 1 os subproblemas gerados devem manter salvo o resultado de área do intervalo que processam e devolver a chamado ao subproblema principal (ver linha 9). A variável sumTotal mantém o resultado computado por cada subprocesso, no caso sequencial não terá concorrência de acessos, no entanto em um ambiente paralelo seria um problema que precisa-se tratar cuidadosamente. A segunda variante do problema apresentado anteriormente, as seções paralelizáveis, podem ser facilmente detectadas na linha 11 do algoritmo. Uma delas pode ser resolvida por uma thread e outra agendada em uma fila global compartilhada pelas threads, assim alguma delas a resolverá em algum momentos. A primeira variante do problema depende muito da quantidade de tarefas definidas inicialmente, pois cada subintervalo gerado é atribuído a uma thread e resolvida usando uma recursividade como a definida no Algoritmo 1. 5. Solução da Quadratura Adaptativa usando pThread 5.1 Variante 1 No algoritmo 2 apresentamos a variante 1 resolvida com pthread para o problema definido na seção 3. Nas linhas 1-5 é definida uma estrutura de dados que representa um intervalo e na linha 7 um semáforo, ele ajuda a proteger a seção critica identificada na seção 4. Nas linhas 17-25 são criada uma quantidade de trabalhos segundo o número de thread definido previamente. Para cada thread criado é atribuída uma função Worker definida na linha 26, cada uma de estas funções resolve um intervalo atribuído na linha 19 do Algoritmo 2. Cada Worker faz uma chamada à função integral da mesma que o algoritmo sequencial da seção 4, com a diferença que na linha 41-43 protegem a computação da área total. 1. typedef struct{ 2. double a; 3. double b; 4. double area; 5. }Interval; 6. ..... 7. sem_t mutex; 8. ..... 9. int main () 10. { 11. pthread_t work[NUMBER_THREAD]; 12. pthread_attr_t attr; 13. pthread_attr_init(&attr); 14. pthread_attr_setscope(&attr, PTHREAD_SCOPE_SYSTEM); 15. sem_init (&mutex, SHARED, 1); 16. . double width = fabs(b-a)/NUMBER_THREAD; 17. for(int i= 0; i < NUMBER_THREAD; i++) 18. { 19. Interval *inteval = create(a+ (width*i), a + (width*(i+1))); 20. pthread_create (&work[i], &attr, Worker, (void *) point); 21. } 22. for(int j= 0; j < NUMBER_THREAD; j++) 23. { pthread_join(work[j], NULL); } 24. printf("Total = %f\n", sumTotal); 25. } 26. void *Worker(void *arg) { 27. Interval *interval = (Interval*) arg; 28. integral(interval->a,interval->b, interval->area); 29. } 30. 31. void integral(double r, double l, double area ) 32. { 33. double m,larea,rarea,sumOfAreas,relError; 34. m = (l+r)*0.5; 35. larea = trape_area(l,m); 36. rarea = trape_area(m,r); 37. sumOfAreas = larea + rarea; 38. relError = fabs(area - sumOfAreas); 39. if(relError <= EPSILON) 40. { 41. sem_wait(&mutex); 42. sumTotal = sumTotal + sumOfAreas; 43. sem_post(&mutex); 44. } 45. else 46. { integral(l,m,larea) ; integral(m,r,rarea); } 47.} Algoritmo. 2 Quadratura Adaptativa Variante 1 com pThread 5.1 Variante 2 No algoritmo 3 apresentamos a variante 2 resolvida com pThread para o problema definido na seção 3. Para resolver esta variante é necessário uma estrutura de dados de tipo queue para armazenar as tarefas geradas pelas diferentes thread ( ver linhas 13-18 ). Nas linhas 1-7 se define uma estrutura de dados para representar as tarefas a processar (taskInterval). Três semáforos são necessários nesta solução, um para manter a integridade da suma das áreas (linha 22), outro para sincronizar o armazenamento de novas tarefas (linha 23) e mais um para sincronizar o final do processamento (linha 24). Na linha 38 uma chamada a função createInitialTask ajuda a criar um número de tarefas iniciais. Nas linhas 96-103 é defina a função createInitialTask, ali novos sub-intervalos são gerados e empilhados na queue. Na linhas 39-41 são criados os diferentes thread que chamamos de consumer, que no final também fazem o role de producer, pois eles podem criar novas subtarefas. Para cada consumer[i] é atribuída a funão Consumer definida na linha 46, nesta função são aplicadas as diferentes regras de sincronização para resolver este problema, a seguir se definem estas regrras: 1) Manter a suma das tarefas, as linhas 84-86 ajudam a sincronizar a suma total da área gerada pelas diferentes sub-tarefas. 2) Armazenar novas tarefas, nas linhas 54-57 controlam o acesso coordenado da queue para extrair uma tarefa e processá-la, na linha 90-92 (dentro da chamada da função integral) é gerada uma nova subtarefa e empilhada na queue. 3) Sincronizar o final do processamento, as linhas 50-52 e 70-72 são necessárias para saber quantos thread estão processando alguma tarefa. As linhas 64-69 avaliam se o número total de thread é igual ao número total definido previamente, e também se na queue não tem nenhuma tarefa para processar. Depois disto último o processo acaba. Na linha 60 a chamada para a função integral serve para processar as tarefas atribuídas em cada consumer[i]. Na linha 93 assegura que o mesmo cosumer[i] processe parte do intervalo inicial, e na linha 91 o cosumer[i] empilha uma sub-tarefa na queue global do processamento. 1. typedef struct taskInterval 2. { 3. double r; 4. double l; 5. double area; 6. struct taskInterval *next; 7. } taskInterval; .... 13. typedef struct queue 14. { 15. int contains; // no. of elements currently in the queue 16. struct taskInterval *front; // the front ptr 17. struct taskInterval *rear; // the rear ptr 18. } queue; 19. 20. queue *q; 21. 22. sem_t mutexsum; 23. sem_t mutexenqueue; 24. sem_t mutexidle; 25. 26. int main() 27. { 28. pthread_t consumer[NUMBER_THREAD]; 29. pthread_attr_t attr; 30. pthread_attr_init(&attr); 31. pthread_attr_setscope(&attr, PTHREAD_SCOPE_SYSTEM); 32. sem_init (&mutexsum, SHARED, 1); 34. sem_init (&mutexenqueue, SHARED, 1); 35. sem_init (&mutexidle, SHARED, 1); 36. queue_init (); 37. activeconsumer= 0; 38. createInitialTask(r, l); 39. for(int i= 0; i < NUMBER_THREAD; i++) 40. { pthread_create (&consumer[i], &attr, Consumer, (void *) &i); 41. 42. for(int j= 0; j < NUMBER_THREAD; j++) 43. { pthread_join(consumer[j], NULL); } 44. printf("Total = %f\n", sumTotal); 45. } 46. void *Consumer(void *arg) 47. { 48. while(1) 49. { 50. sem_wait(&mutexidle); 51. idle++; 52. sem_post(&mutexidle); 53. struct taskInterval *task=NULL; 54. sem_wait(&mutexenqueue); 55. if(q->contains > 0) 56. { task = dequeue (); } 57. sem_post(&mutexenqueue); 58. if(task!=NULL) 59. { 60. integral(task->l, task->r, task->area); 61. free(task); 62. task =NULL; 63. } 64. sem_wait(&mutexidle); 65. if(idle == NUMBER_THREAD && q->contains==0) 66. { sem_post(&mutexidle); 67. break; 68. } 69. sem_post(&mutexidle); 70. sem_wait(&mutexidle); 71. idle--; 72. sem_post(&mutexidle); } 73. } 74.} 75.void integral(double l, double r, double area) 76.{ 77. double m,larea,rarea,sumOfAreas,relError; 78. m = (r+ l)*0.5; 79. larea = trape_area(l,m); 80. rarea = trape_area(m,r); 81. sumOfAreas = larea + rarea; 82. relError = fabs(area - sumOfAreas); 83. if(relError <= EPSILON) 83. { 84. sem_wait(&mutexsum); 85. sumTotal = sumTotal + sumOfAreas; 86. sem_post(&mutexsum); 87. } 88. else 89. { 90. sem_wait(&mutexenqueue); 91. enqueue(l,m,larea); 92. sem_post(&mutexenqueue); 93. integral(m,r,rarea); 94. } 95.} 96.void createInitialTask(double l, double r) 97.{ 98. double width = fabs(r-l)/(double)NUMBER_TASK; 99. for(int i=0; i < NUMBER_TASK; i++) 100. { 101. enqueue (l+ (width*i), l + (width*(i+1)), -1.0); 102. } 103.} Algoritmo. 3 Quadratura Adaptativa Variante 2 com pThread 6. Solução da Quadratura Adaptativa usando openMP 6.1 Variante 1 No Algoritmo 4 resolve a variante 1 usando openMP. Na linha 4 do Algoritmo 4 se define o número de threads usado no processamento, na linha 5 é computado o tamanho da altura dos trapézios, que no eixo x pode ser considerado como a largura de cada área atribuída para cada thread. Na linha 8 os intervalos são atribuídos para cada thread criado implicitamente por openMP. Antes de seguir explicando é melhor definir cada #pragma usada por openMP para paralelizar o código nesta variante apresentada. Utilizando as diretivas #pragma disponíveis na linguagem, o programador explicita os locais do programa que serão paralelizados. A etapa de précompilação do programa converte as diretivas em chamadas para threads comuns, como pThreads por exemplo. O programador não precisa usar as APIs de threads diretamente, isto é feito pelo OpenMP. Por exemplo, o pragma #pragma omp parallel for usado no algoritmo 4 na linha 6 atribui cada intervalo a uma thread gerado implicitamente por openOMP. O pragma #pragma omp critical usado na linha 33 serve para proteger a variável compartilhada entre eles e que mantém a suma total da área. Na linha 32 se apresenta a recursividade necessária para computar as possíveis subtarefas que cada thread gera. 1. int main () 2. { 3. omp_set_dynamic(0); 4. omp_set_num_threads(NUMBER_THREAD); 5. double width = fabs(r-l)/NUMBER_THREAD; 6. #pragma omp parallel for 7. for (int i=0; i < NUMBER_THREAD; i++) 8. { paralelIntegral(l+ (width*i), l + (width*(i+1))); } 9. printf("Total = %f\n", sumTotal); 10. return 0; 11. } 12. void paralelIntegral(double l , double r) 13. { 14. double area = trape_area(l,r); 15. integral(l,r,area); 16. } 17. void integral(double l, double r, double area ) 18. { 19. double m,larea,rarea,sumOfAreas,relError; 20. m = (l+r)*0.5; 21. larea = trape_area(l,m); 22. rarea = trape_area(m,r); 23. sumOfAreas = larea + rarea; 24. relError = fabs(area - sumOfAreas); 25. if(relError <= EPSILON) 26. { 27. #pragma omp critical 28. sumTotal = sumTotal+ sumOfAreas; 29. } 30. else 31. { 32. integral(a,m,larea) ; integral(m,b,rarea); 35. } 36. } Algoritmo. 4 Quadratura Adaptativa Variante 1 com openOMP 6.1 Variante 2 A variante 2 resolvida com openMP é apresentada no algoritmo 5. Neste algoritmo o openOMP faz a gestão de produtor consumidor implicitamente. Nas linhas 6-10 o pragma #pragma omp parallel for ajuda a criar um número de threads definido, um intervalo é atribuído a cada thread para ela resolver. Na linha 33 o pragma #pragma omp task untied é usado para criar novas tarefas para alguma outra thread resolver. Neste pragma é usado a tag untied, que significa qualquer outra thread pode executar o porção de código ligada a ela. Por tanto, nas linhas 33-34 uma nova tarefa é criada e armazenada em um pool de tarefas. Assim, se alguma thread está disponível atendera está nova tarefa. Por último, a linha 28 protege a variável que mantém consistente a suma das áreas. 1. int main() 2. { 3. 4. omp_set_num_threads(NUMBER_THREAD); 5. double width = fabs(r-l)/NUMBER_THREAD; 6. #pragma omp parallel for 7. for (int i=0; i < NUMBER_THREAD; i++) 8. { parallelIntegral(l+ (width*i), l + (width*(i+1))); } 9. printf("Total = %f\n", sumTotal); return 0; 10. } 11. void parallelIntegral(double l , double r) 12. { 13. double area = trape_area(l,r); 14. integral(l,r,area); 15. } 17. void integral(double l, double r, double area ) 18. { 19. double m,larea,rarea,sumOfAreas,relError; 20. m = (l+r)*0.5; 21. larea = trape_area(l,m); 22. rarea = trape_area(m,r); 23. sumOfAreas = larea + rarea; 24. relError = fabs(area - sumOfAreas); 26. if(relError <= EPSILON) 27. { 28. #pragma omp critical 29. sum = sum + sumOfAreas; 30. } 31. else 32. { 33. #pragma omp task untied 34. integral(l,m,larea) ; 35. integral(m,r,rarea); 36. } 37. } Algoritmo. 5 Quadratura Adaptativa Variante 1 com openOMP 7. Comparação das variantes em openMP e pThread. Nesta seção apresenta os resultados dos diferentes testes usando OpenMP e pThread, diferentes opções são usadas para rodar as variantes da quadratura adaptativa. A figura 2 representa os diferentes testes rodados para encontrar a área da função e^(x), no intervalo [0,15], a seguir a se define a integral desta função: Figura 1 Integral da função e^x Na figura 2 são apresentados os comportamentos de cada teste. Nestes gráficos cada linha representa uma diferente configuração, por exemplo, “C2-T2” significa que foi rodado dois thread em dois cores. Assim, em total foi rodado para cada variante 9 diferentes testes. O eixo “X” da imagem é define o EPSILON permitido na computação das diferentes áreas . O eixo “Y” representa o tempo em segundos que gasta cada teste. (a) Variante 1 (b) Variante 2 Figura 2. Apresenta uma comparativa entre número de cores (Cx) e o número de threads por core (Tx) usadas em pThread, isto para a variante 1 (a) e para variante 2 (b). Pode se observar que as linhas nas Figura 2 cresce seguindo a forma da função original, a forma da função e^x faz que o processamento se incremente rapidamente. Quanto a função cresça novas subtarefas são geradas e o tempo de processamento aumenta. Da Figura 2, pode-se ressaltar que o teste C8-T2, para ambas variantes, se mantem estável durante o processamento. Um mesmo comportamento segue o teste C8-T4 que parece ter uma relação entre a quantidade usada para executar alguma tarefa e os possíveis recursos necessários para mente a execução ativa. Os testes para openMP seguem o mesmo padrão definido anteriormente. Da mesma forma, as linhas da figura 3 também seguem o comportamento da função e^x. A principal observação, comparando as duas variantes 1 (ver figura 2 e 3), em OpenMP a solução para a primeira variante gasta mais tempo que a variante 2. Na variante 2 openMP faz a gestão implícita do produtor/consumidor e em pThread um abstração é definida para compartilhar a bolsa de tarefas. (b) Variante 1 (a) Variante 2 Figura 3. Apresenta uma comparativa entre número de cores (Cx) e o número de threads por core (Tx) usadas em openOMP, isto para a variante 1 (a) e para variante 2 (b). As figuras4,5 apresentam uma comparativa entre as duas bibliotecas na computação da integral da função e^x no intervalo [0,15]. Para o desenho de esta figura foram considerados os testes que gastaram maior tempo de processamento. Figura 4 Comparativa entre PThread e openOMP para a Variante 1. Figura 5 Comparativa entre PThread e openOMP para a Variante 2. Estas imagens reflexam que OpemMP uma vantagen sobre pThread nos diferentes testes desenvolvidos neste trabalho. 8. Vantagens ou Desvantagens Sobre OpenMP, ela resulta fácil de usar e não precisa de conceitos avançados para definir um código paralelizável. Por outro lado, em pThread é necessário definir com atenção os componentes que serão coordenados no processamento, um ponto positivo de pThread é que o programados tem conhecimento de como o processamento está sendo feito. 9. Referências [1]. G. Andrews. Paradigms for process interaction in distributed programs. ACM Computing Surveys, 23(1), Mar. 1991, pp. 49–90.