Gabriel Rocha
Projects

Mandelbrot

Mandelbrot fractal in C comparing four parallelization strategies and their performance.

2026Repository

In this project I implemented the Mandelbrot fractal in C with four parallelization strategies (serial, OpenMP and two pthreads variations, one by blocks and one interleaved) to compare the performance of each one. Each run generates 4 equivalent .pgm images and a times.txt with the timings, to 9 decimal places.

How the program works

The flow is linear: I validate the arguments, allocate a single pixel buffer, run the four variants in sequence (reusing the same buffer) and, at the end of each one, write the image. Time is measured only for the computation — clock_gettime(CLOCK_MONOTONIC) wraps the call and the file write comes afterwards, outside the measurement. The excerpt from main for the serial variant shows the pattern used in all four:

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;
}

At the end, the four measurements go to times.txt in a single fprintf, one per line:

fprintf(t, "serial %.9f\nopenmp %.9f\npthreads1 %.9f\npthreads2 %.9f\n",
        tempo_serial, tempo_openmp, tempo_pthreads1, tempo_pthreads2);

The core of the computation

The pixel-to-complex-plane mapping and the iteration of z = z² + c live in two small, independent functions:

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
}

The mapped region is fixed: real axis [-2.0, 1.0] and imaginary [-1.5, 1.5]. For each pixel I compute the intensity as (iter * 255) / max_iteracoes, with no palette and no smoothing.

The four computation variants

The parallel variants reuse the same pixel loop; what changes is who splits the rows. The serial one is the baseline:

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
        }
    }
}

OpenMP is the same structure, with the directive before the outer loop:

#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
    }
}

In the pthreads versions I create one thread per range, each one receives a Thread filled in main and runs one of two routines. The first splits the rows into contiguous ranges:

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;
}

The second distributes the rows in an interleaved way, each thread starts at its id and skips num_threads rows:

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;
}

Each thread writes only to the pixels that belong to it (disjoint indices), so there is no race condition and the single buffer is shared safely.

Particularities and points I would revisit

Reviewing the code today, I see decisions I would handle differently:

  1. Return values of the pthreads functions ignored in main. calcularPthreads1 and calcularPthreads2 return 1 when pthread_create or pthread_join fail, but in main I call them without checking the return, just like the serial one:

    clock_gettime(CLOCK_MONOTONIC, &ini);
    calcularPthreads1(buffer, largura, altura, max_iteracoes, num_threads);   // retorno ignorado
    clock_gettime(CLOCK_MONOTONIC, &fim);

    If creation fails, the program goes on and writes an image with the corresponding region never computed (the freshly allocated buffer still contains garbage).

  2. No cleanup when creation fails midway. In the creation loop, if thread i cannot be created I return 1 right away; the threads already created are left without a join and the args array, which lives on the caller’s stack, goes out of scope with the threads still using it:

    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 without a header. escreverPGM writes only the values, separated by spaces and broken by line, without the P2 header with width, height and maximum value. The file does not open in conventional viewers; that was intentional for the assignment, but today I recognize it limits visual checking and reuse:

    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. Load imbalance. The central region of the fractal concentrates the work, but schedule(static) and the block split distribute by number of rows, not by cost, so some threads finish much earlier. The interleaved variation spreads the work better, yet both are static; the ideal would have been schedule(dynamic) or guided in OpenMP.

  5. num_threads without an upper bound. Validation accepts any positive integer and the threads are created as a VLA on the stack, an absurd value can overflow the stack:

    pthread_t threads[num_threads];   // VLA na stack, sem teto validado
    Thread args[num_threads];
  6. Cold measurement. The four runs execute in sequence and the first (serial) does not warm caches. The numbers are comparable among themselves, but the serial absolute value comes out worse than in an isolated measurement.

  7. Inconsistent cast and int versus long in the loops. In the serial and OpenMP versions I assign the int intensidade directly to the unsigned char (implicit conversion), while in the pthreads routines I use the explicit (unsigned char) cast. The loops also iterate with int i/int j up to long limits (width, height); with realistic dimensions nothing changes, but the index conceptually should be long, since the offset i * largura + j uses the width as a long.

Points for improvement