본문으로 건너뛰기 C++ SIMD and Parallelism | std::execution and Intrinsics ...

C++ SIMD and Parallelism | std::execution and Intrinsics Guide

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

  1. Auto-vectorization failure: dependencies across iterations, complex branches, indirect access → the compiler can’t turn the loop into SIMD
  2. Single-threaded execution: no execution policy passed to std::transform etc., so only one core is used
  3. Alignment violation: _mm256_load_ps requires 32-byte alignment; using a misaligned address crashes
  4. Missing CPU feature check: running AVX code on a CPU without AVX support → SIGILL

Solution by scenario

ScenarioCharacteristicsRecommended approach
Simple array operations (add, multiply)contiguous access, independent iterationsstd::execution::par or par_unseq
Auto-vectorization failsbranches, dependenciesmanual vectorization with intrinsics
Multiple CPU targetsmixed AVX/SSE supportCPU dispatch + fallback
Image/signal processingSoA, fixed-size blocksAVX 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.

PolicyDescriptionRequirements
seqsequential execution (default)none
parmultithreaded paralleliterators/callable must be thread-safe
par_unseqparallel + vectorization (SIMD) allowedmust be synchronization-free (no locks, etc.)
unseq (C++20)single-threaded vectorization onlymust 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 on out[i-1], etc.
  • Simple operations: addition, multiplication, bitwise ops, etc.
  • Minimal branching: an if forces either masking or a scalar fallback path Compiler flags: specifying the target CPU with -O3 and -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

PatternCauseAlternative
a[i] = a[i-1] + b[i]dependency across iterationsmanual intrinsics or a different algorithm
if (a[i] > 0) out[i] = ...branchingmasking or splitting into separate loops
out[indices[i]] = a[i]indirect accessrestructure as SoA or handle manually
a[i] = func(a[i])external function callinline 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

OperationIntrinsicDescription
Load (unaligned)_mm256_loadu_psload 8 floats
Store (unaligned)_mm256_storeu_psstore 8 floats
Add_mm256_add_psva + vb
Multiply_mm256_mul_psva * vb
FMA_mm256_fmadd_psva * vb + vc
Max/Min_mm256_max_ps, _mm256_min_pselement-wise max/min
Compare_mm256_cmp_psproduces a mask
Blend_mm256_blendv_psselectively 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):

Method1M float additions (μs)Relative speed
Scalar500~15001x
AVX150~4002~4x
par (4 cores)150~4002~4x
par_unseq100~3003~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

OperationScalarAVXparpar_unseqNotes
1M float additions1x2~4x2~4x3~6xpar’s gain scales with core count
10M float sum1x2~3x3~8x4~10xreduce parallelizes very well
Conditional operation1x2~3x2~4x3~5xheavy 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_unseq lambda
  • 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

TopicSummary
std::executionparallelize/vectorize algorithms with seq/par/par_unseq/unseq
SIMDone instruction, many data elements — favors SoA and contiguous access
Intrinsicsmanual vectorization (SSE/AVX, etc.) — fills the gap when auto-vectorization fails
Watch out foralignment, 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:
  1. Start with std::execution::par to use multiple cores
  2. Consider intrinsics if auto-vectorization fails
  3. A CPU feature check plus a fallback path is essential
  4. Watch alignment, the remainder, and the n==0 case

Other articles related to this topic.

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