Parallel Reductions & Race Conditions
Learn why reductions race, and implement correct parallel sums using locks, atomics, and reduction trees.
Reductions Are Shared-State Updates (Theory)
A reduction computes an aggregate (sum/min/max/product) over many elements.
In parallel, the tempting approach is:
- each worker computes partial work
- each worker adds into one shared accumulator
That shared accumulator becomes a race condition hotspot.
Example of a race (conceptually)
Two threads do:
- read accumulator value
- add their partial
- write accumulator back
If interleaving happens, one update can overwrite the other.
Code Example 1 — Race Condition in C++ (No Sync)
#include <thread>
#include <vector>
#include <iostream>
int main() {
const int N = 1'000'000;
const int T = 8;
std::vector<int> a(N, 1);
long long sum = 0; // shared, unprotected (race)
auto worker = [&](int tid) {
long long local = 0;
int start = tid * (N / T);
int end = (tid + 1) * (N / T);
for (int i = start; i < end; i++) local += a[i];
// RACE: multiple threads update sum concurrently
sum += local;
};
std::vector<std::thread> threads;
for (int tid = 0; tid < T; tid++) threads.emplace_back(worker, tid);
for (auto& th : threads) th.join();
long long expected = 1LL * N;
std::cout << "expected=" << expected << " got=" << sum << "\n";
}
Code Example 2 — Correct Reduction Using a Mutex
#include <thread>
#include <vector>
#include <iostream>
#include <mutex>
int main() {
const int N = 1'000'000;
const int T = 8;
std::vector<int> a(N, 1);
long long sum = 0;
std::mutex m;
auto worker = [&](int tid) {
long long local = 0;
int start = tid * (N / T);
int end = (tid + 1) * (N / T);
for (int i = start; i < end; i++) local += a[i];
// Critical section: only one thread updates sum at a time
std::lock_guard<std::mutex> lock(m);
sum += local;
};
std::vector<std::thread> threads;
for (int tid = 0; tid < T; tid++) threads.emplace_back(worker, tid);
for (auto& th : threads) th.join();
std::cout << "sum=" << sum << "\n";
}
Common Gotchas
- Too much contention: using one global lock for every element kills performance.
- Using local variables then forgetting to sync: local is safe; shared write must be synchronized.
- Non-associativity surprises: floating-point addition isn’t strictly associative; result order can change slightly.
Quick Checklist
- Compute partials locally first
- Combine partials with a safe strategy:
- mutex/lock
- atomic updates
- reduction tree / two-stage reduce
- Validate with deterministic tests (and allow small FP tolerance)
Frequently Asked Questions
Why do parallel reductions cause bugs?
Because multiple workers update the same accumulator concurrently. Without synchronization/atomic operations, updates are lost or interleavings produce incorrect results.
What is a reduction tree?
A structured algorithm that combines partial results in stages, reducing contention by aggregating locally first, then combining partials.
Locks vs atomics?
Locks protect multi-step critical sections but add overhead and can serialize. Atomics are good for single-variable updates (like increment) but can still suffer under contention.