C++ SIMD and Parallelism | std::execution and Intrinsics Guide
Introduction: the loop is slow and the compiler won’t vectorize it
”I want to process several values at once”
Having covered compile-time optimization and caching and improved cache efficiency with data-oriented design, the next lever is SIMD (Single Instruction, Multiple Data) — processing multiple data elements with a single instruction to raise throughput. Compilers sometimes perform auto-vectorization, but it fails in the presence of complex branches or unclear aliasing. In that case you can manually vectorize with the std::execution::unsequenced_policy or with intrinsics — low-level SIMD instruction wrappers the compiler provides, such as SSE/AVX. std::execution::par is the simplest way to take advantage of multiple cores by attaching a parallel execution policy to an existing algorithm. What this article covers
- Problem scenarios: when a loop is the bottleneck in your profile, and when auto-vectorization fails
- std::execution: seq, par, par_unseq, unseq — passing policies to algorithms
- SIMD concepts: vector registers, conditions for auto-vectorization
- Intrinsics: SSE/AVX headers, complete vector-operation examples
- Common errors and fixes: alignment, CPU feature checks, par_unseq constraints
- Performance benchmarks: scalar vs. SIMD vs. par
- Production patterns: CPU dispatch, fallback paths, using xsimd
1. Problem Scenario: When a Loop Is the Bottleneck
What actually happens
"Adding two 1M-element float arrays takes up 30% of the profile."
"The image pixel-processing loop is way too slow."
"The compiler claims it vectorized this, but the generated code is still scalar."
"We have multiple cores, but transform is only using one."
"I tried to use AVX and it crashes on an older CPU."
Likely causes
- Auto-vectorization failure: dependencies across iterations, complex branches, indirect access → the compiler can’t turn the loop into SIMD
- Single-threaded execution: no execution policy passed to
std::transformetc., so only one core is used - Alignment violation:
_mm256_load_psrequires 32-byte alignment; using a misaligned address crashes - Missing CPU feature check: running AVX code on a CPU without AVX support → SIGILL
Solution by scenario
| Scenario | Characteristics | Recommended approach |
|---|---|---|
| Simple array operations (add, multiply) | contiguous access, independent iterations | std::execution::par or par_unseq |
| Auto-vectorization fails | branches, dependencies | manual vectorization with intrinsics |
| Multiple CPU targets | mixed AVX/SSE support | CPU dispatch + fallback |
| Image/signal processing | SoA, fixed-size blocks | AVX intrinsics + aligned buffers |
Before/After: array addition example
Before (scalar, sequential): one element at a time, using only one core.
// ❌ scalar, sequential — slow
void add_arrays_scalar(const float* a, const float* b, float* out, size_t n) {
for (size_t i = 0; i < n; ++i) {
out[i] = a[i] + b[i];
}
}
After (std::execution::par): an instant speedup from using multiple cores.
// ✅ std::execution::par — uses multiple cores
#include <algorithm>
#include <execution>
#include <vector>
void add_arrays_par(std::vector<float>& a, std::vector<float>& b,
std::vector<float>& out) {
std::transform(std::execution::par, a.begin(), a.end(), b.begin(),
out.begin(), [](float x, float y) { return x + y; });
}
2. std::execution Policies
Giving algorithms parallel/vectorization hints
Since C++17, you can pass an execution policy to algorithms like std::sort, std::transform, and std::reduce.
| Policy | Description | Requirements |
|---|---|---|
| seq | sequential execution (default) | none |
| par | multithreaded parallel | iterators/callable must be thread-safe |
| par_unseq | parallel + vectorization (SIMD) allowed | must be synchronization-free (no locks, etc.) |
| unseq (C++20) | single-threaded vectorization only | must be synchronization-free |
| Passing std::execution::par as the first argument to std::transform runs the lambda across pairs of elements from a and b, split across multiple threads. Because c[i] = a[i] + b[i] is computed independently at each index, it’s thread-safe, and par alone already takes advantage of multiple cores. par_unseq additionally allows SIMD vectorization, but since the lambda must run without any synchronization, it’s best to first confirm a gain with par before adopting it. |
#include <algorithm>
#include <execution>
#include <vector>
void add_vectors_par() {
std::vector<double> a(1000000, 1.0), b(1000000, 2.0), c(a.size());
std::transform(std::execution::par, a.begin(), a.end(), b.begin(),
c.begin(), [](double x, double y) { return x + y; });
}
Caution with par_unseq: using synchronization such as std::mutex or std::atomic inside the lambda is undefined behavior. Only operations that are fully independent per element are allowed.
// ✅ par_unseq — independent operations only
std::transform(std::execution::par_unseq, a.begin(), a.end(), b.begin(),
c.begin(), [](double x, double y) { return x * y + 1.0; });
// ❌ par_unseq — UB if a lock is used
std::mutex mtx;
std::transform(std::execution::par_unseq, a.begin(), a.end(), c.begin(),
[&mtx](double x) {
std::lock_guard<std::mutex> lock(mtx); // UB!
return x * 2;
});
std::reduce and std::transform_reduce
std::reduce is for order-independent reduction operations. For associative operations like addition and multiplication, partial sums can be computed in parallel and then combined.
#include <numeric>
#include <execution>
double sum_par(const std::vector<double>& v) {
return std::reduce(std::execution::par, v.begin(), v.end(), 0.0);
}
double dot_product_par(const std::vector<double>& a,
const std::vector<double>& b) {
return std::transform_reduce(
std::execution::par, a.begin(), a.end(), b.begin(), 0.0,
std::plus<>(), std::multiplies<>());
}
3. SIMD and Auto-Vectorization
One instruction, many data elements
SIMD: a single instruction operates simultaneously on multiple values (e.g. 4 or 8 floats) held in a vector register. Laying data out contiguously via data-oriented design (article 39-1) makes vectorization much more effective.
flowchart LR
subgraph scalar["Scalar (one at a time)"]
S1[a0] --> S2[+]
B1[b0] --> S2
S2 --> O1[out0]
end
subgraph simd["SIMD (8 at a time, AVX)"]
V1[a0..a7] --> V2[_mm256_add_ps]
V3[b0..b7] --> V2
V2 --> V4[out0..out7]
end
Conditions for auto-vectorization
For the compiler to turn a loop into SIMD, it generally needs:
- Contiguous memory access: patterns like
a[i],a[i+1] - Independence across iterations:
out[i]does not depend onout[i-1], etc. - Simple operations: addition, multiplication, bitwise ops, etc.
- Minimal branching: an
ifforces either masking or a scalar fallback path Compiler flags: specifying the target CPU with-O3and-march=native(or-mavx2) makes vectorization more aggressive.
# Target an AVX2-capable CPU
g++ -O3 -march=native -o program program.cpp
# Check the vectorization report (GCC)
g++ -O3 -march=native -fopt-info-vec-optimized program.cpp
Common vectorization-failure patterns
| Pattern | Cause | Alternative |
|---|---|---|
a[i] = a[i-1] + b[i] | dependency across iterations | manual intrinsics or a different algorithm |
if (a[i] > 0) out[i] = ... | branching | masking or splitting into separate loops |
out[indices[i]] = a[i] | indirect access | restructure as SoA or handle manually |
a[i] = func(a[i]) | external function call | inline it, or use intrinsics |
4. Introduction to Intrinsics
Manual vector operations
Intrinsics are compiler-provided built-in functions that map to SSE (headers like <xmmintrin.h>) and AVX (headers like <immintrin.h>) instructions. The __m128 and __m256 types represent 128-bit and 256-bit vectors.
- SSE: 128 bits — 4 floats or 2 doubles
- AVX/AVX2: 256 bits — 8 floats or 4 doubles __m256 is a 256-bit (8-float) vector type. _mm256_loadu_ps(a) loads 8 floats from the unaligned address a, _mm256_add_ps(va, vb) adds the 8 pairs at once, and _mm256_storeu_ps(out, …) stores the result into out. loadu/storeu have no alignment requirement, so they work directly on ordinary arrays.
#include <immintrin.h>
// Add 8 floats at once (AVX)
void add_float8(const float* a, const float* b, float* out) {
__m256 va = _mm256_loadu_ps(a);
__m256 vb = _mm256_loadu_ps(b);
_mm256_storeu_ps(out, _mm256_add_ps(va, vb));
}
Aligned load/store (load_ps vs. loadu_ps)
- _mm256_load_ps / _mm256_store_ps: requires 32-byte (256-bit) alignment. Crashes on a misaligned address.
- _mm256_loadu_ps / _mm256_storeu_ps: no alignment required, at a small possible performance cost.
// ✅ use load_ps when you control an aligned buffer (can be faster)
void add_aligned(const float* a, const float* b, float* out, size_t n) {
const float* end = a + (n & ~7u); // multiple of 8
for (; a < end; a += 8, b += 8, out += 8) {
__m256 va = _mm256_load_ps(a); // assumes 32-byte alignment
__m256 vb = _mm256_load_ps(b);
_mm256_store_ps(out, _mm256_add_ps(va, vb));
}
// handle the remaining elements with scalar code
for (size_t i = n & ~7u; i < n; ++i)
out[i] = a[i] + b[i];
}
Key AVX intrinsics
| Operation | Intrinsic | Description |
|---|---|---|
| Load (unaligned) | _mm256_loadu_ps | load 8 floats |
| Store (unaligned) | _mm256_storeu_ps | store 8 floats |
| Add | _mm256_add_ps | va + vb |
| Multiply | _mm256_mul_ps | va * vb |
| FMA | _mm256_fmadd_ps | va * vb + vc |
| Max/Min | _mm256_max_ps, _mm256_min_ps | element-wise max/min |
| Compare | _mm256_cmp_ps | produces a mask |
| Blend | _mm256_blendv_ps | selectively merge using a mask |
5. Complete SIMD + execution Examples
Example 1: adding two full arrays with AVX
#include <immintrin.h>
#include <cstddef>
void add_arrays_avx(const float* a, const float* b, float* out, size_t n) {
size_t i = 0;
// AVX: process 8 at a time
for (; i + 8 <= n; i += 8) {
__m256 va = _mm256_loadu_ps(a + i);
__m256 vb = _mm256_loadu_ps(b + i);
_mm256_storeu_ps(out + i, _mm256_add_ps(va, vb));
}
// remaining elements, scalar
for (; i < n; ++i) {
out[i] = a[i] + b[i];
}
}
Example 2: array sum (reduce) — horizontal summation
#include <immintrin.h>
#include <cstddef>
float sum_avx(const float* a, size_t n) {
__m256 sum8 = _mm256_setzero_ps();
size_t i = 0;
for (; i + 8 <= n; i += 8) {
__m256 v = _mm256_loadu_ps(a + i);
sum8 = _mm256_add_ps(sum8, v);
}
// horizontal sum: 8 lanes → 1 value
__m128 hi = _mm256_extractf128_ps(sum8, 1);
__m128 lo = _mm256_castps256_ps128(sum8);
__m128 sum4 = _mm_add_ps(hi, lo);
sum4 = _mm_hadd_ps(sum4, sum4);
sum4 = _mm_hadd_ps(sum4, sum4);
float sum = _mm_cvtss_f32(sum4);
for (; i < n; ++i)
sum += a[i];
return sum;
}
Example 3: conditional operation (masking)
An example that doubles only the values greater than 0.
#include <immintrin.h>
#include <cstddef>
void clamp_positive_double_avx(const float* in, float* out, size_t n) {
__m256 zero = _mm256_setzero_ps();
__m256 two = _mm256_set1_ps(2.0f);
size_t i = 0;
for (; i + 8 <= n; i += 8) {
__m256 v = _mm256_loadu_ps(in + i);
__m256 mask = _mm256_cmp_ps(v, zero, _CMP_GT_OQ); // v > 0
__m256 doubled = _mm256_mul_ps(v, two);
__m256 result = _mm256_blendv_ps(v, doubled, mask); // doubled where mask is set
_mm256_storeu_ps(out + i, result);
}
for (; i < n; ++i)
out[i] = (in[i] > 0) ? in[i] * 2.0f : in[i];
}
Example 4: dot product — using FMA
FMA (Fused Multiply-Add) computes a*b+c in a single cycle. Using AVX2’s _mm256_fmadd_ps speeds up the dot-product computation.
#include <immintrin.h>
#include <cstddef>
float dot_product_avx(const float* a, const float* b, size_t n) {
__m256 sum8 = _mm256_setzero_ps();
size_t i = 0;
for (; i + 8 <= n; i += 8) {
__m256 va = _mm256_loadu_ps(a + i);
__m256 vb = _mm256_loadu_ps(b + i);
sum8 = _mm256_fmadd_ps(va, vb, sum8); // sum8 += va * vb
}
// horizontal sum
__m128 hi = _mm256_extractf128_ps(sum8, 1);
__m128 lo = _mm256_castps256_ps128(sum8);
__m128 sum4 = _mm_add_ps(hi, lo);
sum4 = _mm_hadd_ps(sum4, sum4);
sum4 = _mm_hadd_ps(sum4, sum4);
float sum = _mm_cvtss_f32(sum4);
for (; i < n; ++i)
sum += a[i] * b[i];
return sum;
}
Example 5: combining std::execution with intrinsics
First parallelize with par, then process the inside of each chunk with scalar code or intrinsics. Below, par splits the work by index while each element is handled with AVX.
#include <algorithm>
#include <execution>
#include <immintrin.h>
#include <vector>
#include <cstddef>
void add_arrays_par_avx(std::vector<float>& a, std::vector<float>& b,
std::vector<float>& out) {
const size_t n = a.size();
std::vector<size_t> indices(n);
std::iota(indices.begin(), indices.end(), 0);
std::for_each(std::execution::par, indices.begin(), indices.end(),
[&](size_t i) {
if (i + 8 <= n) {
__m256 va = _mm256_loadu_ps(&a[i]);
__m256 vb = _mm256_loadu_ps(&b[i]);
_mm256_storeu_ps(&out[i], _mm256_add_ps(va, vb));
// Note: splitting work into chunks is actually more efficient
}
});
}
In practice, splitting the work into chunks is better for cache behavior and overhead.
void add_arrays_chunked_par(const float* a, const float* b, float* out,
size_t n) {
constexpr size_t chunk = 4096; // chunk size
std::vector<size_t> chunk_starts;
for (size_t i = 0; i < n; i += chunk)
chunk_starts.push_back(i);
std::for_each(std::execution::par, chunk_starts.begin(), chunk_starts.end(),
[&](size_t start) {
size_t end = std::min(start + chunk, n);
for (size_t i = start; i + 8 <= end; i += 8) {
__m256 va = _mm256_loadu_ps(a + i);
__m256 vb = _mm256_loadu_ps(b + i);
_mm256_storeu_ps(out + i, _mm256_add_ps(va, vb));
}
for (size_t i = (end & ~7u); i < end; ++i)
out[i] = a[i] + b[i];
});
}
6. Common Errors and Fixes
Error 1: using load_ps on an unaligned address (SIGSEGV)
Symptom: crashes with SIGSEGV on certain inputs.
Cause: _mm256_load_ps requires 32-byte alignment. Buffers allocated with malloc or new don’t guarantee that.
// ❌ dangerous: alignment is not guaranteed
float* a = new float[1000];
__m256 v = _mm256_load_ps(a); // a might not be 32-byte aligned → crash
Fix: use loadu_ps, or allocate with alignment.
// ✅ use loadu_ps (no alignment required)
__m256 v = _mm256_loadu_ps(a);
// ✅ aligned allocation (C++11)
alignas(32) float a[1000];
__m256 v = _mm256_load_ps(a);
// ✅ aligned_alloc (C++17)
float* a = static_cast<float*>(std::aligned_alloc(32, 1000 * sizeof(float)));
Error 2: running on a CPU without AVX support (SIGILL)
Symptom: works fine on newer CPUs, crashes with an illegal instruction on older machines. Cause: running AVX instructions on a CPU that doesn’t support them. Fix: check CPU features at runtime and branch accordingly.
#include <immintrin.h>
#include <cpuid.h> // GCC/Clang
bool has_avx() {
unsigned int eax, ebx, ecx, edx;
__get_cpuid_count(1, 0, &eax, &ebx, &ecx, &edx);
return (ecx & (1u << 28)) != 0; // CPUID.01H:ECX.AVX
}
void add_arrays_safe(const float* a, const float* b, float* out, size_t n) {
if (has_avx()) {
add_arrays_avx(a, b, out, n);
} else {
for (size_t i = 0; i < n; ++i)
out[i] = a[i] + b[i];
}
}
Error 3: using synchronization inside par_unseq (UB)
Symptom: intermittent crashes, deadlocks, or incorrect results.
Cause: using a mutex, atomic, etc. inside a par_unseq lambda.
// ❌ UB
std::atomic<int> counter{0};
std::transform(std::execution::par_unseq, a.begin(), a.end(), out.begin(),
[&counter](int x) {
counter++; // UB: atomic operations are restricted under par_unseq
return x * 2;
});
Fix: use par instead, or move the synchronization outside the lambda.
// ✅ use par when synchronization is required
std::transform(std::execution::par, a.begin(), a.end(), out.begin(),
[&counter](int x) {
counter++; // allowed under par (but watch the performance cost)
return x * 2;
});
Error 4: forgetting to handle the remaining elements
Symptom: the last few elements of the array hold wrong or uninitialized values.
Cause: the SIMD loop only processes elements in chunks of 8 (or 4), and the remainder isn’t handled when n isn’t a multiple of 8.
// ❌ remainder not handled
for (size_t i = 0; i + 8 <= n; i += 8) {
// ...
}
// if n=10, the loop stops at i=8, leaving out[8] and out[9] unprocessed
Fix: handle the remainder with a scalar loop.
// ✅ remainder handled
size_t i = 0;
for (; i + 8 <= n; i += 8) {
// ...
}
for (; i < n; ++i)
out[i] = a[i] + b[i];
Error 5: overlapping buffers
Symptom: incorrect results, or a crash.
Cause: when in and out overlap (e.g. an in-place operation), processing them with loadu/storeu can let a store overwrite an address before it’s loaded.
// ❌ a problem if out == in for an in-place operation
void square_avx(float* in, float* out, size_t n) {
for (size_t i = 0; i + 8 <= n; i += 8) {
__m256 v = _mm256_loadu_ps(in + i);
_mm256_storeu_ps(out + i, _mm256_mul_ps(v, v)); // what if in == out?
}
}
Fix: use a temporary buffer for in-place operations, use a different algorithm, or guarantee in != out.
// ✅ handle in-place separately
void square_avx_safe(float* data, size_t n) {
alignas(32) float tmp[8];
for (size_t i = 0; i + 8 <= n; i += 8) {
__m256 v = _mm256_loadu_ps(data + i);
_mm256_store_ps(tmp, _mm256_mul_ps(v, v));
_mm256_storeu_ps(data + i, _mm256_load_ps(tmp));
}
for (size_t i = n & ~7u; i < n; ++i)
data[i] *= data[i];
}
Error 6: when n is 0
Symptom: accessing a[0] when n=0 can crash.
Cause: the loop condition only checks i < n, but a load/store can still execute at n==0 depending on how the loop is written.
Fix: return early.
// ✅ handle n==0
void add_arrays_avx_safe(const float* a, const float* b, float* out, size_t n) {
if (n == 0) return;
// ...
}
7. Performance Benchmarks
Benchmark 1: scalar vs. AVX vs. par
#include <chrono>
#include <execution>
#include <immintrin.h>
#include <numeric>
#include <vector>
#include <iostream>
#include <algorithm>
void benchmark_add() {
constexpr size_t N = 1'000'000;
std::vector<float> a(N, 1.0f), b(N, 2.0f), c(N);
// 1. scalar
auto t1 = std::chrono::high_resolution_clock::now();
for (size_t i = 0; i < N; ++i)
c[i] = a[i] + b[i];
auto t2 = std::chrono::high_resolution_clock::now();
// 2. AVX
auto t3 = std::chrono::high_resolution_clock::now();
size_t i = 0;
for (; i + 8 <= N; i += 8) {
__m256 va = _mm256_loadu_ps(&a[i]);
__m256 vb = _mm256_loadu_ps(&b[i]);
_mm256_storeu_ps(&c[i], _mm256_add_ps(va, vb));
}
for (; i < N; ++i) c[i] = a[i] + b[i];
auto t4 = std::chrono::high_resolution_clock::now();
// 3. std::transform par
auto t5 = std::chrono::high_resolution_clock::now();
std::transform(std::execution::par, a.begin(), a.end(), b.begin(),
c.begin(), [](float x, float y) { return x + y; });
auto t6 = std::chrono::high_resolution_clock::now();
using namespace std::chrono;
auto d_scalar = duration_cast<microseconds>(t2 - t1).count();
auto d_avx = duration_cast<microseconds>(t4 - t3).count();
auto d_par = duration_cast<microseconds>(t6 - t5).count();
std::cout << "Scalar: " << d_scalar << " μs\n";
std::cout << "AVX: " << d_avx << " μs (" << (double)d_scalar / d_avx << "x)\n";
std::cout << "par: " << d_par << " μs (" << (double)d_scalar / d_par << "x)\n";
}
Expected results (varies by environment):
| Method | 1M float additions (μs) | Relative speed |
|---|---|---|
| Scalar | 500~1500 | 1x |
| AVX | 150~400 | 2~4x |
| par (4 cores) | 150~400 | 2~4x |
| par_unseq | 100~300 | 3~6x |
Benchmark 2: reduce (sum)
float sum_scalar(const float* a, size_t n) {
float s = 0;
for (size_t i = 0; i < n; ++i) s += a[i];
return s;
}
void benchmark_sum() {
constexpr size_t N = 10'000'000;
std::vector<float> a(N);
std::iota(a.begin(), a.end(), 1.0f);
auto t1 = std::chrono::high_resolution_clock::now();
volatile float r1 = sum_scalar(a.data(), N);
auto t2 = std::chrono::high_resolution_clock::now();
auto t3 = std::chrono::high_resolution_clock::now();
volatile float r2 = std::reduce(std::execution::par, a.begin(), a.end());
auto t4 = std::chrono::high_resolution_clock::now();
using namespace std::chrono;
std::cout << "Scalar sum: " << duration_cast<microseconds>(t2-t1).count() << " μs\n";
std::cout << "par reduce: " << duration_cast<microseconds>(t4-t3).count() << " μs\n";
}
Benchmark summary table
| Operation | Scalar | AVX | par | par_unseq | Notes |
|---|---|---|---|---|---|
| 1M float additions | 1x | 2~4x | 2~4x | 3~6x | par’s gain scales with core count |
| 10M float sum | 1x | 2~3x | 3~8x | 4~10x | reduce parallelizes very well |
| Conditional operation | 1x | 2~3x | 2~4x | 3~5x | heavy branching reduces the gain |
8. Production Patterns
Pattern 1: CPU dispatch (AVX → SSE → scalar)
Check CPU features at runtime and pick the appropriate code path.
#include <immintrin.h>
using AddFunc = void (*)(const float*, const float*, float*, size_t);
void add_scalar(const float* a, const float* b, float* out, size_t n) {
for (size_t i = 0; i < n; ++i) out[i] = a[i] + b[i];
}
AddFunc get_add_func() {
#if defined(__AVX__)
if (__builtin_cpu_supports("avx"))
return add_arrays_avx;
#endif
return add_scalar;
}
void dispatch_add(const float* a, const float* b, float* out, size_t n) {
static AddFunc f = get_add_func();
f(a, b, out, n);
}
Pattern 2: using the xsimd library
Instead of writing intrinsics directly, you can use a portable SIMD abstraction library.
// Example using xsimd (install with: vcpkg install xsimd)
#include <xsimd/xsimd.hpp>
void add_xsimd(const float* a, const float* b, float* out, size_t n) {
using batch_type = xsimd::batch<float>;
size_t i = 0;
for (; i + batch_type::size <= n; i += batch_type::size) {
auto va = batch_type::load_unaligned(a + i);
auto vb = batch_type::load_unaligned(b + i);
(va + vb).store_unaligned(out + i);
}
for (; i < n; ++i)
out[i] = a[i] + b[i];
}
Advantages: automatically picks the best path per CPU, and supports other architectures such as ARM NEON.
Pattern 3: allocating aligned buffers
To maximize SIMD performance, use 32-byte-aligned buffers.
#include <memory>
#include <vector>
std::unique_ptr<float[]> make_aligned_buffer(size_t n) {
return std::unique_ptr<float[]>(
static_cast<float*>(std::aligned_alloc(32, n * sizeof(float))));
}
// or a vector with a custom allocator
std::vector<float, aligned_allocator<float, 32>> data(1000000);
Pattern 4: chunked parallelism + SIMD
Split a large array into chunks, assign each chunk to a thread, and process the inside of each chunk with SIMD.
void process_parallel_simd(const float* in, float* out, size_t n) {
constexpr size_t chunk = 65536;
std::vector<size_t> starts;
for (size_t i = 0; i < n; i += chunk)
starts.push_back(i);
std::for_each(std::execution::par, starts.begin(), starts.end(),
[&](size_t start) {
size_t end = std::min(start + chunk, n);
for (size_t i = start; i + 8 <= end; i += 8) {
__m256 v = _mm256_loadu_ps(in + i);
_mm256_storeu_ps(out + i, _mm256_mul_ps(v, v));
}
for (size_t i = (end & ~7u); i < end; ++i)
out[i] = in[i] * in[i];
});
}
Pattern 5: implementation checklist
- Check CPU features at runtime and provide a fallback path
- Alignment: guarantee 32-byte alignment when using
load_ps - Handle remaining elements with scalar code
- Early-return on
n==0 - No synchronization inside a
par_unseqlambda - Confirm the actual gain with profiling before adopting
Pattern 6: an incremental adoption strategy
// Step 1: apply std::execution::par only (simplest)
std::transform(std::execution::par, a.begin(), a.end(), b.begin(),
c.begin(), [](float x, float y) { return x + y; });
// Step 2: after confirming a gain by profiling, try par_unseq
std::transform(std::execution::par_unseq, a.begin(), a.end(), b.begin(),
c.begin(), [](float x, float y) { return x + y; });
// Step 3: if still a bottleneck, hand-vectorize with intrinsics
// CPU dispatch + AVX/SSE/scalar paths
Pattern 7: confirming auto-vectorization with a vectorization report
Use your compiler’s report flags to confirm whether it actually vectorized the loop.
# GCC: report successfully vectorized loops
g++ -O3 -march=native -fopt-info-vec-optimized -c program.cpp
# GCC: report why vectorization failed
g++ -O3 -march=native -fopt-info-vec-missed -c program.cpp
# Clang: vectorization analysis
clang++ -O3 -march=native -Rpass=loop-vectorize -Rpass-missed=loop-vectorize -c program.cpp
# Example: vectorization succeeded
program.cpp:10:5: note: loop vectorized
# Example: vectorization failed
program.cpp:15:5: note: loop not vectorized: value that could not be identified as reduction is used outside the loop
Pattern 8: combining SoA with SIMD
SoA (Structure of Arrays) from data-oriented design (article 39-1) pairs well with SIMD. When x, y, and z are each stored as contiguous float arrays, vectorization is straightforward.
// SoA: x, y, and z are each contiguous arrays
struct ParticleSoA {
std::vector<float> x, y, z; // 1M elements each
};
void scale_velocity_avx(ParticleSoA& p, float scale) {
const size_t n = p.x.size();
__m256 s = _mm256_set1_ps(scale);
for (size_t i = 0; i + 8 <= n; i += 8) {
_mm256_storeu_ps(&p.x[i], _mm256_mul_ps(_mm256_loadu_ps(&p.x[i]), s));
_mm256_storeu_ps(&p.y[i], _mm256_mul_ps(_mm256_loadu_ps(&p.y[i]), s));
_mm256_storeu_ps(&p.z[i], _mm256_mul_ps(_mm256_loadu_ps(&p.z[i]), s));
}
for (size_t i = n & ~7u; i < n; ++i) {
p.x[i] *= scale;
p.y[i] *= scale;
p.z[i] *= scale;
}
}
9. Summary
| Topic | Summary |
|---|---|
| std::execution | parallelize/vectorize algorithms with seq/par/par_unseq/unseq |
| SIMD | one instruction, many data elements — favors SoA and contiguous access |
| Intrinsics | manual vectorization (SSE/AVX, etc.) — fills the gap when auto-vectorization fails |
| Watch out for | alignment, CPU checks, no synchronization under par_unseq, handling the remainder |
| The 39-series covered hardware-level techniques for “overwhelming performance”: cache (DoD) → memory (pmr) → compute (SIMD/execution). | |
| Core principles: |
- Start with
std::execution::parto use multiple cores - Consider intrinsics if auto-vectorization fails
- A CPU feature check plus a fallback path is essential
- Watch alignment, the remainder, and the
n==0case
Related Articles (Internal Links)
Other articles related to this topic.
- C++ SIMD | ‘Vector Operations’ Guide
- C++26 Core Features Complete Guide | Reflection and std::execution
- C++26 Preview: Reflection and New Standard Library Proposals [#44-1]
Practical tips (SIMD / intrinsics)
- Measure first: intrinsics help only when the loop is the hotspot.
- Watch for alignment, tail handling, and portable fallbacks for non-SIMD builds.
- Validate results against a scalar reference implementation.
Checklist
- Correctness tests cover edge lengths and boundary alignment?
- Build flags and CPU targets documented for CI?
Keywords Covered in This Article (Related Search Terms)
This article covers SIMD, intrinsics, vectorization, std::execution, AVX, SSE, parallel algorithms, par_unseq.
Frequently Asked Questions (FAQ)
Q. When would I use this in practice?
A. Apply this when profiling shows a loop is the bottleneck, or when you have heavy array operations (image processing, signal processing, numeric computation). Try std::execution::par first, and if it helps, consider par_unseq or intrinsics next.
Q. Should I use par or par_unseq?
A. If the lambda is fully independent and synchronization-free, par_unseq can be faster. If there’s a lock or shared state, stick to par only.
Q. Intrinsics vs. xsimd/Highway?
A. Intrinsics give you direct control with no dependencies. xsimd and Highway offer better portability, including support for other CPUs like ARM. Choose based on your project’s needs.
Q. What should I read before this?
A. Follow the previous article link at the bottom of each post to learn in sequence. See the C++ series index for the full picture.
Q. Where can I study this more deeply?
A. See the Intel Intrinsics Guide and cppreference’s std::execution page. One-line summary: SIMD, std::execution, and intrinsics let you vectorize and parallelize computation. Next, consider reading vcpkg and Conan (#40-1). Previous article: High-Performance C++ #39-2: Custom Allocators and pmr Next article: [DevOps for C++ #40-1] C++ Package Management in Practice: Escaping Dependency Hell with vcpkg and Conan
Related Articles
- C++ Cache-Friendly Code: A Data-Oriented Design Guide
- C++ std::chrono Complete Guide | duration, time_point, clocks, and measuring time in practice
- Modern C++ Memory Management: Building a Custom Allocator and a Guide to std::pmr
- C++ std::pmr Complete Guide | Memory Pools with Polymorphic Memory Resources
- C++26 Core Features Complete Guide | Reflection and std::execution