- C
- OpenMP
- pthreads
- Paralelismo
Mandelbrot
Fractal de Mandelbrot em C comparando quatro estratégias de paralelização e seus desempenhos.
Neste projeto implementei o fractal de Mandelbrot em C com quatro estratégias de paralelização (serial, OpenMP e duas variações com pthreads, uma por blocos e outra intercalada) para comparar o desempenho de cada uma. Cada execução gera 4 imagens .pgm equivalentes e um times.txt com os tempos, com 9 casas decimais.
Como o programa funciona
O fluxo é linear: valido os argumentos, aloco um único buffer de pixels, rodo as quatro variantes em sequência (reaproveitando o mesmo buffer) e, ao final de cada uma, gravo a imagem. O tempo é medido somente do cálculo, o clock_gettime(CLOCK_MONOTONIC) envolve a chamada e a escrita do arquivo vem depois, fora da medição. O trecho do main para a variante serial mostra o padrão usado nas quatro:
unsigned char *buffer = (unsigned char*)malloc(largura * altura * sizeof(unsigned char));
if (buffer == NULL) {
fprintf(stderr, "Erro: alocação de memória falhou\n");
return 1;
}
clock_gettime(CLOCK_MONOTONIC, &ini);
calcularSerial(buffer, largura, altura, max_iteracoes);
clock_gettime(CLOCK_MONOTONIC, &fim);
double tempo_serial = (fim.tv_sec - ini.tv_sec) + (fim.tv_nsec - ini.tv_nsec) / 1e9;
if (escreverPGM("mandelbrot_gnfr_serial.pgm", buffer, largura, altura) != 0) {
free(buffer);
return 1;
}
No fim, as quatro medições vão para o times.txt em um único fprintf, uma por linha:
fprintf(t, "serial %.9f\nopenmp %.9f\npthreads1 %.9f\npthreads2 %.9f\n",
tempo_serial, tempo_openmp, tempo_pthreads1, tempo_pthreads2);
O núcleo do cálculo
O mapeamento do pixel para o plano complexo e a iteração de z = z² + c ficam em duas funções pequenas e independentes:
void mapearPixel(int col, int row, long largura, long altura, double *cr, double *ci) {
double passo_x = 3.0 / (largura > 1 ? largura - 1 : 1);
double passo_y = 3.0 / (altura > 1 ? altura - 1 : 1);
*cr = -2.0 + col * passo_x;
*ci = 1.5 - row * passo_y;
}
int contarIteracoes(double cr, double ci, long max_iteracoes) {
double a = 0.0, b = 0.0;
long iter = 0;
while (a * a + b * b <= 4.0 && iter < max_iteracoes) { // |z|² <= 4
double a_novo = a * a - b * b + cr; // z = z² + c
double b_novo = 2.0 * a * b + ci;
a = a_novo;
b = b_novo;
iter++;
}
return (int)iter; // critério de escape
}
A região mapeada é fixa: eixo real [-2.0, 1.0] e imaginário [-1.5, 1.5]. Para cada pixel eu calculo a intensidade como (iter * 255) / max_iteracoes, sem paleta e sem suavização.
As quatro variantes de cálculo
As variantes paralelas reusam o mesmo laço de pixels; o que muda é quem divide as linhas. A serial é a base:
void calcularSerial(unsigned char *buffer, long largura, long altura, long max_iteracoes) {
for (int i = 0; i < altura; i++) {
for (int j = 0; j < largura; j++) {
double cr, ci;
mapearPixel(j, i, largura, altura, &cr, &ci);
int iter = contarIteracoes(cr, ci, max_iteracoes);
int intensidade = (iter * 255) / max_iteracoes;
buffer[i * largura + j] = intensidade; // int -> unsigned char implícito
}
}
}
A OpenMP é a mesma estrutura, com a diretiva antes do laço externo:
#pragma omp parallel for schedule(static) num_threads((int)num_threads)
for (int i = 0; i < altura; i++) {
for (int j = 0; j < largura; j++) {
// corpo idêntico ao da versão serial
}
}
Nas versões com pthreads eu crio uma thread por faixa, cada uma recebe um Thread preenchido no main e executa uma de duas rotinas. A primeira divide as linhas em faixas contíguas:
void* rotinaBlocos(void *arg) { // faixa contígua de linhas por thread
Thread *t = (Thread*)arg;
long inicio = (t->id * t->altura) / t->num_threads;
long fim = ((t->id + 1) * t->altura) / t->num_threads;
for (long i = inicio; i < fim; i++)
for (long j = 0; j < t->largura; j++) {
double cr, ci;
mapearPixel((int)j, (int)i, t->largura, t->altura, &cr, &ci);
int iter = contarIteracoes(cr, ci, t->max_iteracoes);
t->buffer[i * t->largura + j] = (unsigned char)((iter * 255) / t->max_iteracoes);
}
return NULL;
}
A segunda distribui as linhas de forma alternada, cada thread começa na sua id e pula num_threads linhas:
void* rotinaIntercalada(void *arg) { // linhas alternadas entre as threads
Thread *t = (Thread*)arg;
for (long i = t->id; i < t->altura; i += t->num_threads)
for (long j = 0; j < t->largura; j++) {
double cr, ci;
mapearPixel((int)j, (int)i, t->largura, t->altura, &cr, &ci);
int iter = contarIteracoes(cr, ci, t->max_iteracoes);
t->buffer[i * t->largura + j] = (unsigned char)((iter * 255) / t->max_iteracoes);
}
return NULL;
}
Cada thread escreve apenas nos pixels que lhe cabem (índices disjuntos), então não há condição de corrida e o buffer único é compartilhado com segurança.
Particularidades e pontos que revisaria
Revisando o código hoje, vejo decisões que eu trataria de outra forma:
-
Retorno das funções pthreads ignorado no
main.calcularPthreads1ecalcularPthreads2retornam1quandopthread_createoupthread_joinfalham, mas nomaineu as chamo sem conferir o retorno, igual à serial:clock_gettime(CLOCK_MONOTONIC, &ini); calcularPthreads1(buffer, largura, altura, max_iteracoes, num_threads); // retorno ignorado clock_gettime(CLOCK_MONOTONIC, &fim);Se a criação falhar, o programa segue e grava uma imagem com a região correspondente nunca calculada (o buffer recém-alocado ainda contém lixo).
-
Sem limpeza quando a criação falha no meio. No laço de criação, se a thread
inão puder ser criada eu retorno1na hora; as threads já criadas ficam semjoine o vetorargs, que vive na stack do chamador, sai de escopo com as threads ainda o usando:for (long i = 0; i < num_threads; i++) { if (pthread_create(&threads[i], NULL, rotinaBlocos, &args[i]) != 0) { fprintf(stderr, "Erro: falha ao criar a thread %ld\n", i); return 1; // threads anteriores sem join e args inválido daqui pra frente } } -
PGM sem cabeçalho. O
escreverPGMgrava só os valores, separados por espaço e quebrados por linha, sem o cabeçalhoP2com largura, altura e valor máximo. O arquivo não abre em visualizadores convencionais; isso foi intencional para a atividade, mas hoje reconheço que limita a conferência visual e o reuso:for (int i = 0; i < altura; i++) { for (int j = 0; j < largura; j++) { fprintf(f, "%d", buffer[i * largura + j]); if (j + 1 < largura) fprintf(f, " "); } fprintf(f, "\n"); } -
Desbalanceamento de carga. A região central do fractal concentra o trabalho, mas
schedule(static)e a divisão por blocos distribuem por quantidade de linhas, não por custo, então algumas threads terminam muito antes. A variação intercalada espalha melhor o trabalho, ainda assim as duas são estáticas; o ideal teria sidoschedule(dynamic)ouguidedno OpenMP. -
num_threadssem limite superior. A validação aceita qualquer inteiro positivo e as threads são criadas como VLA na stack, um valor absurdo pode estourar a pilha:pthread_t threads[num_threads]; // VLA na stack, sem teto validado Thread args[num_threads]; -
Medição fria. As quatro execuções rodam em sequência e a primeira (serial) não aquece caches. Os números são comparáveis entre si, mas o absoluto da serial sai pior do que numa medição isolada.
-
Cast inconsistente e
intcontralongnos laços. Na serial e na OpenMP eu atribuo oint intensidadedireto nounsigned char(conversão implícita), enquanto nas rotinas pthreads uso o cast explícito(unsigned char). Os laços também percorrem comint i/int jaté limiteslong(largura, altura); em dimensões realistas não muda nada, mas o índice conceitualmente deveria serlong, já que o deslocamentoi * largura + jusa a largura emlong.
Pontos de melhoria
- Checar os retornos de
pthread_create/join, fazerjoindas threads já criadas em caso de erro e liberar o buffer nesse caminho. - Adicionar o cabeçalho PGM (P2/P5) ou uma flag para suprimir a escrita quando só quero o tempo.
- Limitar
num_threads(por exemplo<= alturae um teto) e usar distribuição dinâmica para reduzir o desbalanceamento. - Padronizar o cast para
unsigned charem todas as variantes e usarlongnos índices dos laços.
Gabriel Rocha