Skip to Content
Course content

187: Parallel Algorithms with the Execution Policy (C++17)

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I see this all the time when developers first encounter C++17's parallel algorithms: they treat std::execution::par like a "magic turbo button." The assumption is that by simply inserting that policy as the first argument to std::sort or std::for_each, the code will automatically run faster across all CPU cores regardless of the workload.

The "Free Speed" Fallacy

The reality is that parallelism isn't free; it has a cost. Launching threads, coordinating their execution, and managing cache contention take time. If your dataset is small, or if the operation you're performing inside the loop is trivial, the overhead of managing the parallelism will actually make your code slower than the sequential version.

#include <vector>
#include <algorithm>
#include <execution>
#include <iostream>

void trivial_work() {
    std::vector<int> data(100, 1); // Tiny dataset
    
    // You might think this is "faster"
    std::for_each(std::execution::par, data.begin(), data.end(), [](int& n) {
        n++; 
    });
}

In the example above, incrementing an integer is a lightning-fast operation. The time it takes for the C++ runtime to partition the vector and assign tasks to different cores is orders of magnitude longer than the actual work of incrementing 100 integers. I've seen benchmarks where the parallel version is 10x slower than the sequential one for small inputs. Use parallelism when the "work per element" is high or the "number of elements" is massive.

Thread Safety is Now Your Problem

The second part of the misconception is thinking that std::execution::par handles the synchronization for you. It doesn't. The algorithm handles the distribution of work, but it does not protect your data. If your lambda captures a variable by reference and modifies it, you've just introduced a data race.

int total = 0;
std::vector<int> data(10000, 1);

// DANGER: This is a data race!
std::for_each(std::execution::par, data.begin(), data.end(), [&](int n) {
    total += n; 
});

In a sequential std::for_each, this is perfectly fine. In a parallel one, multiple threads will attempt to read, increment, and write to total simultaneously. You'll end up with a final value that is unpredictably lower than 10,000 because updates are being lost. To fix this, you'd need a std::atomic<int> or, better yet, use std::reduce which is designed specifically for this pattern.

Picking the Right Policy for the Workload

C++17 provides four execution policies. Choosing the right one is about communicating your intentions to the compiler and the hardware.

  • std::execution::seq: Forces sequential execution. Use this if you want to explicitly disable parallelism for a specific call to avoid the overhead I mentioned earlier.
  • std::execution::par: Allows the implementation to run the algorithm in parallel on multiple threads. The order of execution is not guaranteed.
  • std::execution::par_unseq: This is "parallel and vectorized." It tells the compiler that the work can be done in parallel and that it's safe to use SIMD (Single Instruction, Multiple Data) instructions. This is the fastest, but the most restrictive; you cannot use synchronization primitives (like mutexes) inside the lambda because it could lead to deadlocks during vectorization.
  • std::execution::unseq: Only vectorization, no multi-threading.

My rule of thumb? Start with seq. If it's too slow and your dataset is large, try par. Only move to par_unseq if you've profiled your code and you know your operation is "embarrassingly parallel" and free of any synchronization requirements. And remember, depending on your compiler (like GCC), you might need to link against an external library like Intel TBB (Threading Building Blocks) to actually see the parallel gains.




📋 Practical Task

Exercise: Parallelizing a Large-Scale Coordinate Distance Summation

You are tasked with calculating the total distance of a set of 10 million 2D points from the origin (0,0). The current implementation is sequential and too slow for the production pipeline.

Requirements:

  • Create a std::vector of a simple Point struct (containing double x, y) with 10,000,000 elements, filled with random values.
  • Implement a function that calculates the sum of the distances using std::sqrt(x*x + y*y).
  • First, implement this using std::for_each with std::execution::par. You must use a std::atomic<double> (or a similar thread-safe mechanism) to accumulate the total to avoid the data races discussed in the lesson.
  • Then, refactor the code to use std::transform_reduce with std::execution::par. This is the "correct" C++17 way to handle this pattern as it avoids the contention caused by an atomic variable.
  • Print the results of both methods to ensure they match.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.