Gabriel Rocha
Projetos

Mandelbrot

Fractal de Mandelbrot em C comparando quatro estratégias de paralelização e seus desempenhos.

2026Repositório

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:

  1. Retorno das funções pthreads ignorado no main. calcularPthreads1 e calcularPthreads2 retornam 1 quando pthread_create ou pthread_join falham, mas no main eu 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).

  2. Sem limpeza quando a criação falha no meio. No laço de criação, se a thread i não puder ser criada eu retorno 1 na hora; as threads já criadas ficam sem join e o vetor args, 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
        }
    }
  3. PGM sem cabeçalho. O escreverPGM grava só os valores, separados por espaço e quebrados por linha, sem o cabeçalho P2 com 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");
    }
  4. 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 sido schedule(dynamic) ou guided no OpenMP.

  5. num_threads sem 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];
  6. 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.

  7. Cast inconsistente e int contra long nos laços. Na serial e na OpenMP eu atribuo o int intensidade direto no unsigned char (conversão implícita), enquanto nas rotinas pthreads uso o cast explícito (unsigned char). Os laços também percorrem com int i/int j até limites long (largura, altura); em dimensões realistas não muda nada, mas o índice conceitualmente deveria ser long, já que o deslocamento i * largura + j usa a largura em long.

Pontos de melhoria