Generating mandelbrot images in c++ using multithreading. No speedup?

陌路散爱 提交于 2020-01-03 05:26:08

问题


So I posted a similar question to this earlier, but I didn't post enough code to get the help I needed. Even if I went back and added that code now, I don't think it would be noticed because the question is old and "answered". So here's my issue:

I'm trying to generate a section of the mandelbrot fractal. I can generate it fine, but when I add more cores, no matter how large the problem size is, the extra threads generate no speedup. I am completely new to multithreading and it's probably just something small I'm missing. Anyway, here are the functions that generate the fractal:

void mandelbrot_all(std::vector<std::vector<int>>& pixels, int X, int Y, int numThreads) {
    using namespace std;

    vector<thread> threads (numThreads);
    int rowsPerThread = Y/numThreads;
    mutex m;

    for(int i=0; i<numThreads; i++) {
        threads[i] = thread ([&](){
            vector<int> row;
            for(int j=(i-1)*rowsPerThread; j<i*rowsPerThread; j++) {
                row = mandelbrot_row(j, X, Y);
                {
                    lock_guard<mutex> lock(m);
                    pixels[j] = row;
                }
            }
        });
    }
    for(int i=0; i<numThreads; i++) {
        threads[i].join();
    }
}

std::vector<int> mandelbrot_row(int rowNum, int topX, int topY) {
    std::vector<int> row (topX);
    for(int i=0; i<topX; i++) {
        row[i] = mandelbrotOne(i, rowNum, topX, topY);
    }
    return row;
}

int mandelbrotOne(int currX, int currY, int X, int Y) { //code adapted from http://en.wikipedia.org/wiki/Mandelbrot_set
    double x0 = convert(X, currX, true);
    double y0 = convert(Y, currY, false);
    double x = 0.0;
    double y = 0.0;
    double xtemp;
    int iteration = 0;
    int max_iteration = 255;
    while ( x*x + y*y < 2*2  &&  iteration < max_iteration) {
        xtemp = x*x - y*y + x0;
        y = 2*x*y + y0;
        x = xtemp;
        ++iteration;
    }
    return iteration;
}

mandelbrot_all is passed a vector to hold the pixels, the maximum X and Y of the vector, and the number of threads to use, which is taken from the command line when the program is run. It attempts to split the work by row among multiple threads. Unfortunately, it seems that even if that is what it's doing, it's not making it any faster. If you need more details, feel free to ask and I will do my best to provide them.

Thanks in advance for the help.

Edit: reserved vectors in advance Edit 2: ran this code with problem size 9600x7200 on a quad core laptop. It took an average of 36590000 cycles for one thread (over 5 runs) and 55142000 cycles for four threads.


回答1:


Your code might appear to do parallel processing, but in practice it doesn't. Basically, you are spending your time copying data around and queueing for memory allocator accesses.

Besides, you are using the unprotected i loop indice as if there was nothing to it, which will feed your worker threads with random junk instead of beautiful slices of the image.

As usual, C++ is hiding these sad facts from you under a thick crust of syntactic sugar.

But the greatest flaw of your code is the algorithm itself, as you might see if you read further ahead.

Since this example seems a textbook case of parallel processing to me and I never saw an "educational" analysis of it, I will try one.

Functional analysis

You want to use all CPU cores to crunch pixels of the Mandelbrot set. This is a perfect case of parallelizable computation, since each pixel computation can be done independently.

So basically it you have N cores on your machine you should have exactly one thread per core doing 1/N of the processing.

Unfortunately, dividing the input data so that each processor ends up doing 1/N of the needed processing is not as obvious as it might seem.

A given pixel can take from 0 to 255 iterations to compute. "black" pixels are 255 times more costly than "white" ones.

So if you simply divide your picture into N equal sub-surfaces, chances are all of your processors will breeze through "white" areas except one that will crawl through a "black" area. As a result, the slowest area computation time will dominate and parallelization will gain practically nothing.

In real cases, this will not be as dramatic, but still a huge loss of computing power.

Load balancing

To better balance the load, it is more efficient to split your picture in much smaller bits, and have each worker thread pick and compute the next available bit as soon as it is finished with the previous one. That way, a worker processing "white" chunks will eventually finish its job and start picking "black" chunks to help its less fortunate siblings.

Ideally the chunks should be sorted by decreasing complexity, to avoid adding the linear cost of a big chunk to the total computatuin time.

Unfortunately, due to the chaotic nature of the Mandlebrot set, there is no practical way of predicting the computation time of a given area.

If we decide the chunks will be horizontal slices of the picture, sorting them in natural y order is clearly suboptimal. If that particular area is a kind of "white to black" gradient, the most costly lines will all be bunched at the end of the chunks list and you will end up computing the costliest bits last, which is the worst case for load balancing.

A possible solution is to shuffle the chunks in a butterfly pattern, so that the likelihood of having a "black" area concentrated in the end is small.
Another way would simply be to shuffle input patterns at random.

Here are two outputs of my proof of concept implementation:

Jobs are executed in reverse order (jobs 39 is the first, job 0 is the last). Each line decodes as follows:

t a-b : thread n°a on processor b
b : begining time (since image computation start)
e : end time
d : duration (all times in milliseconds)

1) 40 jobs with butterfly ordering

job  0: t 1-1  b 162 e 174 d  12 // the 4 tasks finish within 5 ms from each other
job  1: t 0-0  b 156 e 176 d  20 //
job  2: t 2-2  b 155 e 173 d  18 //
job  3: t 3-3  b 154 e 174 d  20 //
job  4: t 1-1  b 141 e 162 d  21
job  5: t 2-2  b 137 e 155 d  18
job  6: t 0-0  b 136 e 156 d  20
job  7: t 3-3  b 133 e 154 d  21
job  8: t 1-1  b 117 e 141 d  24
job  9: t 0-0  b 116 e 136 d  20
job 10: t 2-2  b 115 e 137 d  22
job 11: t 3-3  b 113 e 133 d  20
job 12: t 0-0  b  99 e 116 d  17
job 13: t 1-1  b  99 e 117 d  18
job 14: t 2-2  b  96 e 115 d  19
job 15: t 3-3  b  95 e 113 d  18
job 16: t 0-0  b  83 e  99 d  16
job 17: t 3-3  b  80 e  95 d  15
job 18: t 2-2  b  77 e  96 d  19
job 19: t 1-1  b  72 e  99 d  27
job 20: t 3-3  b  69 e  80 d  11
job 21: t 0-0  b  68 e  83 d  15
job 22: t 2-2  b  63 e  77 d  14
job 23: t 1-1  b  56 e  72 d  16
job 24: t 3-3  b  54 e  69 d  15
job 25: t 0-0  b  53 e  68 d  15
job 26: t 2-2  b  48 e  63 d  15
job 27: t 0-0  b  41 e  53 d  12
job 28: t 3-3  b  40 e  54 d  14
job 29: t 1-1  b  36 e  56 d  20
job 30: t 3-3  b  29 e  40 d  11
job 31: t 2-2  b  29 e  48 d  19
job 32: t 0-0  b  23 e  41 d  18
job 33: t 1-1  b  18 e  36 d  18
job 34: t 2-2  b  16 e  29 d  13
job 35: t 3-3  b  15 e  29 d  14
job 36: t 2-2  b   0 e  16 d  16
job 37: t 3-3  b   0 e  15 d  15
job 38: t 1-1  b   0 e  18 d  18
job 39: t 0-0  b   0 e  23 d  23

You can see load balancing at work when a thread having processed a few small jobs will overtake another that took more time to process its own chunks.

2) 40 jobs with linear ordering

job  0: t 2-2  b 157 e 180 d  23 // last thread lags 17 ms behind first
job  1: t 1-1  b 154 e 175 d  21
job  2: t 3-3  b 150 e 171 d  21
job  3: t 0-0  b 143 e 163 d  20 // 1st thread ends
job  4: t 2-2  b 137 e 157 d  20
job  5: t 1-1  b 135 e 154 d  19
job  6: t 3-3  b 130 e 150 d  20
job  7: t 0-0  b 123 e 143 d  20
job  8: t 2-2  b 115 e 137 d  22
job  9: t 1-1  b 112 e 135 d  23
job 10: t 3-3  b 112 e 130 d  18
job 11: t 0-0  b 105 e 123 d  18
job 12: t 3-3  b  95 e 112 d  17
job 13: t 2-2  b  95 e 115 d  20
job 14: t 1-1  b  94 e 112 d  18
job 15: t 0-0  b  90 e 105 d  15
job 16: t 3-3  b  78 e  95 d  17
job 17: t 2-2  b  77 e  95 d  18
job 18: t 1-1  b  74 e  94 d  20
job 19: t 0-0  b  69 e  90 d  21
job 20: t 3-3  b  60 e  78 d  18
job 21: t 2-2  b  59 e  77 d  18
job 22: t 1-1  b  57 e  74 d  17
job 23: t 0-0  b  55 e  69 d  14
job 24: t 3-3  b  45 e  60 d  15
job 25: t 2-2  b  45 e  59 d  14
job 26: t 1-1  b  43 e  57 d  14
job 27: t 0-0  b  43 e  55 d  12
job 28: t 2-2  b  30 e  45 d  15
job 29: t 3-3  b  30 e  45 d  15
job 30: t 0-0  b  27 e  43 d  16
job 31: t 1-1  b  24 e  43 d  19
job 32: t 2-2  b  13 e  30 d  17
job 33: t 3-3  b  12 e  30 d  18
job 34: t 0-0  b  11 e  27 d  16
job 35: t 1-1  b  11 e  24 d  13
job 36: t 2-2  b   0 e  13 d  13
job 37: t 3-3  b   0 e  12 d  12
job 38: t 1-1  b   0 e  11 d  11
job 39: t 0-0  b   0 e  11 d  11

Here the costly chunks tend to bunch together at the end of the queue, hence a noticeable performance loss.

3) a run with only one job per core, with one to 4 cores activated

reported cores: 4
Master: start jobs 4 workers 1
job  0: t 0-0  b 410 e 590 d 180 // purely linear execution
job  1: t 0-0  b 255 e 409 d 154
job  2: t 0-0  b 127 e 255 d 128
job  3: t 0-0  b   0 e 127 d 127
Master: start jobs 4 workers 2   // gain factor : 1.6 out of theoretical 2
job  0: t 1-1  b 151 e 362 d 211 
job  1: t 0-0  b 147 e 323 d 176
job  2: t 0-0  b   0 e 147 d 147
job  3: t 1-1  b   0 e 151 d 151
Master: start jobs 4 workers 3   // gain factor : 1.82 out of theoretical 3
job  0: t 0-0  b 142 e 324 d 182 // 4th packet is hurting the performance badly
job  1: t 2-2  b   0 e 158 d 158
job  2: t 1-1  b   0 e 160 d 160
job  3: t 0-0  b   0 e 142 d 142
Master: start jobs 4 workers 4   // gain factor : 3 out of theoretical 4
job  0: t 3-3  b   0 e 199 d 199 // finish at 199ms vs. 176 for butterfly 40, 13% loss
job  1: t 1-1  b   0 e 182 d 182 // 17 ms wasted
job  2: t 0-0  b   0 e 146 d 146 // 44 ms wasted
job  3: t 2-2  b   0 e 150 d 150 // 49 ms wasted

Here we get a 3x improvement while a better load balancing could have yielded a 3.5x.
And this is a very mild test case (you can see the computation times only vary by a factor of about 2, while they could theoretically vary by a factor of 255 !).

At any rate, if you don't implement some kind of load balancing, all the shiny multiprocessor code you might write will still yield poor do downright miserable performances.

Implementation

For the threads to work unhindered, they must be kept free from interferences from the ouside world.

One such interference is the memory allocation. Each time you allocate even a byte of memory, you will queue for exclusive access to the global memory allocator (and waste a bit of CPU doing the allocation).

Also, creating worker tasks for each picture computation is another waste of time and resources. The computation might be used to display the Mandlebrot set in an interactive application, so better have the workers premanently created and synchronized to compute successive images.

Lastly, there are the data copies. If you synchronize with the main program each time you're done computing a few points, you will again spend a good part of your time queueing for exclusive access to the result area. Besides, the useless copies of a sizeable amount of data will hurt the performances even more.

The obvious solution is to dispense with the copies altogether and work on original data.

design

You must provide your worker threads all they need to work unhindered. For that you need to:

  • determine the number of available cores on your system
  • pre-allocate all the memory needed
  • give access to a list of image chunks to each of your worker
  • launch exactly one thread per core and let them run free to do their job

job queue

There is no need for fancy no-wait or whatever gizmos, nor do we need to pay special attention to cache optimization.
Here again, the time needed to compute pixels dwarves the inter-thread synchronization cost and cache efficiency problems.

Basically, the queue can be computed as a whole at the start of an image generation. Workers will only have to read the jobs from it: there will never be concurrent read/write accesses on this queue, so the more or less standard bits of code around to implement job queues will be suboptimal and too complex for the job at hand.

We need two sync points:

  1. let the workers wait for a new batch of jobs
  2. let the master wait for a picture completion

workers will wait until the queue length changes to a positive value. They will then all wakeup and start atomically decrementing the queue length. The current value of the queue length will provide them exclusive access to the associated job data (basically an area of the Mandlebrot set to compute, with an associated bitmap area to store the computed iteration values).

The same mechanism is used to terminate the workers. Instead of finding a new batch of jobs, the poor workers will wakeup to find an order to terminate.

the master waiting for a picture completion will be awoken by the worker that will finish processing the last job. This will be based on an atomic counter of the number of jobs to process.

This is how I implemented it:

class synchro {
    friend class mandelbrot_calculator;

    mutex              lock;    // queue lock
    condition_variable work;    // blocks workers waiting for jobs/termination
    condition_variable done;    // blocks master waiting for completion
    int                pending; // number of jobs in the queue
    atomic_int         active;  // number of unprocessed jobs
    bool               kill;    // poison pill for workers termination

    void synchro (void)
    {
        pending = 0;  // no job in queue
        kill = false; // workers shall live (for now :) )
    }

    int worker_start(void)
    {
        unique_lock<mutex> waiter(lock);
        while (!pending && !kill) work.wait(waiter);
        return kill 
            ? -1         // worker should die
            : --pending; // index of the job to process
    }

    void worker_done(void)
    {
        if (!--active) // atomic decrement (exclusive with other workers)
            done.notify_one(); // last job processed: wakeup master
    }

    void master_start(int jobs)
    {
        unique_lock<mutex> waiter(lock);
        pending = active = jobs;
        work.notify_all(); // wakeup all workers to start jobs
    }

    void master_done(void)
    {
        unique_lock<mutex> waiter(lock);
        while (active) done.wait(waiter); // wait for workers to finish
    }

    void master_kill(void)
    {
        kill = true;
        work.notify_all(); // wakeup all workers (to die)
    }
};

Putting all together:

class mandelbrot_calculator {
    int      num_cores;
    int      num_jobs;
    vector<thread> workers; // worker threads
    vector<job> jobs;      // job queue
    synchro sync;          // synchronization helper

    mandelbrot_calculator (int num_cores, int num_jobs)
        : num_cores(num_cores)
        , num_jobs (num_jobs )
    {
        // worker thread
        auto worker = [&]()
        {
            for (;;)
            {
                int job = sync.worker_start(); // fetch next job

                if (job == -1) return; // poison pill
                process (jobs[job]);   // we have exclusive access to this job

                sync.worker_done();    // signal end of picture to the master
            }
        };

        jobs.resize(num_jobs, job()); // computation windows
        workers.resize(num_cores);
        for (int i = 0; i != num_cores; i++)
            workers[i] = thread(worker, i, i%num_cores);
    }

    ~mandelbrot_calculator()
    {
        // kill the workers
        sync.master_kill();
        for (thread& worker : workers) worker.join();
    }

    void compute(const viewport & vp)
    {
        // prepare worker data
        function<void(int, int)> butterfly_jobs;
        butterfly_jobs = [&](int min, int max) 
            // computes job windows in butterfly order
            {
                if (min > max) return;
                jobs[min].setup(vp, max, num_jobs);

                if (min == max) return;
                jobs[max].setup(vp, min, num_jobs);

                int mid = (min + max) / 2;
                butterfly_jobs(min + 1, mid    );
                butterfly_jobs(mid + 1, max - 1);
            };
        butterfly_jobs(0, num_jobs - 1);

        // launch workers
        sync.master_start(num_jobs);

        // wait for completion
        sync.master_done();
    }
};

Testing the concept

This code works pretty well on my 2 cores / 4 CPUs Intel I3 @ 3.1 GHz, compiled with Microsoft Dev Studio 2013.

I use a bit of the set that has an average of 90 iterations / pixel, on a window of 1280x1024 pixels.

The computation time is about 1.700s with only one worker and drops to 0.480s with 4 workers.
The maximal possible gain would be a factor 4. I get a factor 3.5. Not too bad.

I assume the difference is partly due to the processor architecture (the I3 has only two "real" cores).

Tampering with the scheduler

My program forces the threads to run on one core each (using MSDN SetThreadAffinityMask).
If the scheduler is left free to allocate the tasks, the gain factor drops from 3,5 to 3,2.

This is significant, but still the Win7 scheduler does a pretty good job when left alone.

synchronization overhead

running the algorithm on an "white" window (outside the r < 2 area) gives a good idea of the system calls overhead.

It takes about 7ms to compute this "white" area, compared with the 480 ms of a representative area.

Something like 1.5%, including both the synchronization and computation of the job queue. And this is doing a synchronization on a queue of 1024 jobs.

Utterly neglectible, I would say. That might give food for thought to all the No-wait queue fanatics around.

optimizing iterations

The way iterations are done is a key factor for optimization.
After a few trials, I settled for this method:

static inline unsigned char mandelbrot_pixel(double x0, double y0)
{
    register double x = x0;
    register double y = y0;
    register double x2 = x * x;
    register double y2 = y * y;
    unsigned       iteration = 0;
    const int      max_iteration = 255;
    while (x2 + y2 < 4.0)
    {
        if (++iteration == max_iteration) break;
        y = 2 * x * y + y0;
        x = x2 - y2   + x0;
        x2 = x * x;
        y2 = y * y;
    }
    return (unsigned char)iteration;
}

net gain: +20% compared with the OP's method

(the register directives don't make a bit of a difference, they are just there for decoration)

killing the tasks after each computation

The benefit of leaving the workers alive is about 5% of the computation time.

butterfly effect

On my test case, the "butterfly" order is doing really well, yielding more than 30% gain in extreme cases and routinely 10-15% due to "de-bunching" the bulkiest requests.




回答2:


The problem in your code is that all thread capture and access the same i variable. This creates a race condition and the results are wildly incorrect.

You need to pass it as an argument to the thread lambda, and also correct the ranges (i-1 will make your indexing go out of bounds).



来源:https://stackoverflow.com/questions/21354853/generating-mandelbrot-images-in-c-using-multithreading-no-speedup

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!