Skip to content

P1 · Mandelbrot with threads

Render a 1600 × 1200 image with std::thread, using at most 256 iterations per pixel.

The two Mandelbrot views

Views 1 and 2 from the handout. Brighter pixels require more computation.

1. Split the image between two threads

workerThreadStart in mandelbrotThread.cpp:

void workerThreadStart(WorkerArgs * const args) {
    int startRow = 0;
    int numRows = args->height / 2;

    if (args->threadId == 1) {
        startRow = args->height / 2;
    }

    mandelbrotSerial(args->x0, args->y0, args->x1, args->y1,
                     args->width, args->height,
                     startRow, numRows,
                     args->maxIterations,
                     args->output);
}

1.1 How the row range controls the work

startRow and numRows select the rows to render. Thread 0 handles [0, 600) and thread 1 handles [600, 1200).

The complex-plane bounds (x0, y0, x1, y1) and full image dimensions stay unchanged, preserving the pixel coordinates in both workers.

Pixel (r, c) is stored at output[r * width + c]. The workers share the output array but write disjoint rows, so no lock is needed.

1.2 Where the second thread comes from

The starter's mandelbrotThread launches worker 1 with std::thread, runs worker 0 on the calling thread, and joins worker 1 before returning.

2. Extend to 2–8 threads

void workerThreadStart(WorkerArgs * const args) {
    int numRows = args->height / args->numThreads;
    int startRow = args->threadId * numRows;

    if (args->threadId == args->numThreads - 1) {
        numRows = args->height - startRow;
    }

    mandelbrotSerial(args->x0, args->y0, args->x1, args->y1,
                     args->width, args->height,
                     startRow, numRows,
                     args->maxIterations,
                     args->output);
}

2.1 Block assignment

Let q = height / numThreads, using integer division. Worker t starts at t * q and initially receives q rows.

With seven threads, equal blocks cover only 7 × 171 = 1197 rows. The last worker starts at row 1026 and takes 1200 - 1026 = 174 rows, including the three-row remainder. This fixes the seven-thread mismatch.

2.2 Scaling results

The contiguous-block version gives the following speedups:

Contiguous-block scaling with 2–8 threads for both Mandelbrot views

Scaling is not linear. For view 1, speedup falls from 1.98× with two threads to 1.61× with three. Pixels require different numbers of iterations, so equally sized blocks can contain very different amounts of work. The middle block is the likely bottleneck.

3. Measure each worker

Add a timer around the block computation:

void workerThreadStart(WorkerArgs * const args) {
    double startTime = CycleTimer::currentSeconds();

    int numRows = args->height / args->numThreads;
    int startRow = args->threadId * numRows;

    if (args->threadId == args->numThreads - 1) {
        numRows = args->height - startRow;
    }

    mandelbrotSerial(args->x0, args->y0, args->x1, args->y1,
                     args->width, args->height,
                     startRow, numRows,
                     args->maxIterations,
                     args->output);

    double endTime = CycleTimer::currentSeconds();
    printf("Thread %d: %.3f ms\n",
           args->threadId, (endTime-startTime)*1000);
}

For view 1 with three threads, median worker times over 15 invocations were:

Worker Time (ms)
0 49.825
1 151.085
2 51.046

The middle block takes roughly three times as long as the outer blocks. Rendering must wait for this slowest worker, confirming that load imbalance causes the poor three-thread result.

4. Interleave the rows

void workerThreadStart(WorkerArgs * const args) {
    double startTime = CycleTimer::currentSeconds();

    for (unsigned int i = args->threadId;
         i < args->height;
         i += args->numThreads) {
        mandelbrotSerial(args->x0, args->y0, args->x1, args->y1,
                         args->width, args->height,
                         i, 1,
                         args->maxIterations,
                         args->output);
    }

    double endTime = CycleTimer::currentSeconds();
    printf("Thread %d: %.3f ms\n",
           args->threadId, (endTime-startTime)*1000);
}

4.1 Row assignment

Worker t starts at row t and advances by numThreads. Each call with arguments i, 1 renders one row:

12 rows, 3 workers; entries are row numbers

              Contiguous blocks       Interleaved rows
              +-------------+         +-------------+
Worker 0      |  0  1  2  3 |         |  0  3  6  9 |
Worker 1      |  4  5  6  7 |         |  1  4  7 10 |
Worker 2      |  8  9 10 11 |         |  2  5  8 11 |
              +-------------+         +-------------+

Each row r belongs to worker r % numThreads, giving every row exactly one owner. The loop condition handles the bottom of the image even when the height is not divisible by the thread count.

Spreading rows throughout the image gives every worker a mixture of cheap and expensive pixels. In view 1, the costly middle region is shared across workers. This static mapping needs no communication during rendering and applies to both views.

4.2 Results

The three worker times for view 1 become 84.584, 88.906, and 88.950 ms, much closer together.

Final eight-thread results:

View Serial (ms) 8 threads (ms) Speedup
1 241.408 41.502 5.82×
2 135.515 24.461 5.54×

Both views pass the pixel-by-pixel serial comparison.

4.3 Comparison with the handout

The handout expects about 7–8× with eight threads.

The local i9-13900H combines six P cores and eight E cores, unlike the handout's four-core i7-7700K. Static row assignment gives each worker a similar amount of work, while completion time depends on the slowest worker's throughput. Core placement and clock behavior also affect the serial baseline.

A separate comparison tested default scheduling against restricting both serial and parallel runs to the eight E cores:

View CPU selection Serial (ms) 8 threads (ms) Speedup
1 Default 343.321 58.922 5.83×
1 Eight E cores 530.944 66.802 7.95×
2 Default 192.635 34.322 5.61×
2 Eight E cores 316.804 40.039 7.91×

The E-core results approach 8× partly because their serial baseline is slower. Default scheduling still gives shorter parallel runtimes.

5. Compare 8 and 16 threads

Using the improved, interleaved version:

View 8 threads (ms) 16 threads (ms) 16-thread speedup over serial Gain over 8 threads
1 41.502 23.285 10.36× 1.78×
2 24.461 14.049 9.65× 1.74×

Sixteen threads improve performance on this machine. It has 14 physical cores and 20 logical CPUs, so additional threads can use more execution resources.

Measurement method