루프가 병목일 때: 자동 벡터화, std::execution 병렬 정책, SSE·AVX2·AVX-512 인트린직

들어가며: 루프가 느린데 컴파일러가 벡터화를 못 한다

컴파일 타임 최적화와 캐시를 다뤘으며, 데이터 지향 설계로 캐시 효율을 올렸다면, SIMD(Single Instruction Multiple Data)는 한 명령으로 여러 데이터를 동시에 처리해 throughput을 높입니다.
컴파일러가 자동으로 자동 벡터화를 하기도 하지만, 복잡한 분기나 정렬이 있으면 실패합니다. 이때 std::execution::unsequenced_policy나 인트린직(intrinsics—컴파일러가 제공하는 저수준 SIMD 명령어 함수. SSE/AVX 등)으로 수동 벡터화를 적용할 수 있습니다. std::execution::par는 기존 알고리즘에 병렬 실행 정책을 붙여 멀티코어를 활용하는 가장 간단한 방법입니다.


루프가 병목일 때: 배열 덧셈 Before/After

실제 겪는 상황

"100만 개 float 배열 덧셈이 프로파일에서 30%를 차지해요."
"이미지 픽셀 처리 루프가 너무 느려요."
"컴파일러가 벡터화했다고 하는데 실제로는 스칼라 코드가 나와요."
"멀티코어가 있는데 transform이 한 코어만 쓰고 있습니다."
"AVX를 쓰려 했는데 구형 CPU에서 크래시가 나요."

원인 후보

  1. 자동 벡터화 실패: 반복 간 의존성, 복잡한 분기, 간접 접근 → 컴파일러가 SIMD로 변환하지 못함
  2. 단일 스레드 실행: std::transform 등에 실행 정책을 넘기지 않아 한 코어만 사용
  3. 정렬 요구 위반: _mm256_load_ps는 32바이트 정렬 필요. 비정렬 주소 사용 시 크래시
  4. CPU 기능 미검사: AVX 코드를 AVX 미지원 CPU에서 실행 → SIGILL

시나리오별 해결 방향

시나리오특징권장 방법
단순 배열 연산 (덧셈, 곱셈)연속 접근, 독립 반복std::execution::par 또는 par_unseq
자동 벡터화 실패분기, 의존성인트린직 수동 벡터화
다양한 CPU 지원AVX/SSE 혼재CPU 디스패치 + 폴백
이미지/신호 처리SoA, 고정 크기 블록AVX 인트린직 + 정렬된 버퍼

Before/After: 배열 덧셈 예시

Before (스칼라 + 순차): 한 번에 하나씩, 한 코어만 사용합니다.

// ❌ 스칼라 순차 — 느림
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): 여러 코어에 나눠 처리합니다. 다만 단순 덧셈은 메모리 대역폭에 묶이기 쉬워, 코어 수만큼 빨라지지는 않습니다(뒤의 벤치마크 절 참고). GCC의 libstdc++는 병렬 정책 구현에 Intel TBB를 쓰므로 -ltbb로 링크해야 실제로 병렬 실행됩니다.

// ✅ std::execution::par — 멀티코어 활용
#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; });
}

std::execution 정책

알고리즘에 병렬·벡터화 힌트 주기

C++17부터 std::sort, std::transform, std::reduce 등에 실행 정책을 넘길 수 있습니다.

정책설명요구사항
seq순차 실행 (기본)없음
par멀티스레드 병렬반복자·함수 스레드 안전
par_unseq병렬 + 벡터화(SIMD) 허용동기화 프리(락 등 금지)
unseq (C++20)단일 스레드 벡터화만동기화 프리

std::transform의 첫 인자로 std::execution::par를 주면, a·b의 원소 쌍에 대해 람다가 여러 스레드에 나뉘어 실행됩니다. c[i] = a[i] + b[i]가 서로 다른 인덱스에서 독립적으로 계산되므로 스레드 안전하며, par만으로도 멀티코어 활용이 됩니다. par_unseq는 여기에 SIMD 벡터화까지 허용하지만, 람다가 동기화 없이 동작해야 하므로 먼저 par로 효과를 본 뒤 도입하는 것이 좋습니다.

#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; });
}

par_unseq 사용 시 주의: 람다 내부에서 std::mutex, std::atomic 등 동기화를 사용하면 정의되지 않은 동작입니다. 원소별로 완전히 독립적인 연산만 허용됩니다.

// ✅ par_unseq — 독립적 연산만
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
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와 std::transform_reduce

std::reduce는 순서에 의존하지 않는 축소 연산에 사용합니다. 덧셈·곱셈처럼 결합 법칙이 성립하면 병렬로 부분 합을 구한 뒤 합칩니다.

#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<>());
}

SIMD와 자동 벡터화

한 명령으로 여러 데이터

SIMD: 하나의 명령으로 벡터 레지스터에 담긴 여러 값(예: 4개 float, 8개 float)에 동시에 연산합니다. 데이터 지향 설계(39-1)로 연속 메모리에 데이터를 두면 벡터화가 잘 됩니다.

flowchart LR
    subgraph scalar["스칼라 (1개씩)"]
        S1[a0] --> S2[+]
        B1[b0] --> S2
        S2 --> O1[out0]
    end
    subgraph simd["SIMD (8개씩, AVX)"]
        V1[a0..a7] --> V2[_mm256_add_ps]
        V3[b0..b7] --> V2
        V2 --> V4[out0..out7]
    end

자동 벡터화 조건

컴파일러가 루프를 SIMD로 바꾸려면:

  • 연속 메모리 접근: a[i], a[i+1] 형태
  • 반복 간 독립성: out[i]가 out[i-1] 등에 의존하지 않음
  • 단순 연산: 덧셈, 곱셈, 비트 연산 등
  • 분기 최소화: if가 있으면 마스킹이나 스칼라 폴백으로 나뉨 컴파일 옵션: -O3, -march=native(또는 -mavx2)로 타겟 CPU를 지정하면 더 공격적으로 벡터화합니다.
# AVX2 지원 CPU 타겟
g++ -O3 -march=native -o program program.cpp
# 벡터화 리포트 확인 (GCC)
g++ -O3 -march=native -fopt-info-vec-optimized program.cpp

벡터화 실패 패턴

패턴원인대안
a[i] = a[i-1] + b[i]반복 간 의존성수동 인트린직 또는 알고리즘 변경
if (a[i] > 0) out[i] = ...분기마스킹 또는 분리 루프
out[indices[i]] = a[i]간접 접근SoA 재구성 또는 수동 처리
a[i] = func(a[i])외부 함수 호출인라인 또는 인트린직

인트린직 소개

수동 벡터 연산

인트린직은 컴파일러가 제공하는 내장 함수로, SSE(<xmmintrin.h> 등), AVX(<immintrin.h> 등)에 대응합니다. __m128, __m256 타입으로 128비트/256비트 벡터를 다룹니다.

  • SSE: 128비트, float 4개 또는 double 2개
  • AVX/AVX2: 256비트, float 8개 또는 double 4개 __m256은 256비트(8개 float) 벡터 타입입니다. _mm256_loadu_ps(a)로 정렬되지 않은 주소 a에서 8개 float를 로드하며, _mm256_add_ps(va, vb)로 8쌍을 한 번에 더한 뒤 _mm256_storeu_ps(out, …)로 out에 저장합니다. loadu/storeu는 정렬 요구가 없어 일반 배열에 그대로 쓸 수 있습니다.
#include <immintrin.h>
// 8개 float 덧셈 (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));
}

정렬된 로드/스토어 (load_ps vs loadu_ps)

  • _mm256_load_ps / _mm256_store_ps: 32바이트(256비트) 정렬 필요. 비정렬 주소 시 크래시.
  • _mm256_loadu_ps / _mm256_storeu_ps: 정렬 불필요. Haswell 이후 CPU에서는 주소가 실제로 정렬되어 있으면 load_ps와 속도 차이가 거의 없고, 캐시 라인 경계를 넘는 접근에서만 느려집니다.
// ✅ 정렬된 버퍼 사용 시 load_ps
void add_aligned(const float* a, const float* b, float* out, size_t n) {
    const size_t n8 = n & ~size_t{7};  // 8의 배수 (~7u를 쓰면 size_t 상위 비트가 잘림)
    size_t i = 0;
    for (; i < n8; i += 8) {
        __m256 va = _mm256_load_ps(a + i);   // a, b, out 모두 32바이트 정렬 가정
        __m256 vb = _mm256_load_ps(b + i);
        _mm256_store_ps(out + i, _mm256_add_ps(va, vb));
    }
    // 나머지 스칼라 처리
    for (; i < n; ++i)
        out[i] = a[i] + b[i];
}

주요 AVX 인트린직

연산인트린직설명
로드 (비정렬)_mm256_loadu_ps8개 float 로드
스토어 (비정렬)_mm256_storeu_ps8개 float 저장
덧셈_mm256_add_psva + vb
곱셈_mm256_mul_psva * vb
FMA_mm256_fmadd_psva * vb + vc
최대/최소_mm256_max_ps, _mm256_min_ps요소별 max/min
비교_mm256_cmp_ps마스크 생성
블렌드_mm256_blendv_ps마스크로 선택적 병합

AVX 배열 덧셈, 수평 합산, 마스킹, FMA 내적 예제

예제 1: 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: 8개씩 처리
    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));
    }
    // 나머지 스칼라
    for (; i < n; ++i) {
        out[i] = a[i] + b[i];
    }
}

예제 2: 배열 합 (reduce) — 수평 합산

#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);
    }
    // 수평 합산: 8개 → 1개
    __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;
}

예제 3: 조건부 연산 (마스킹)

0보다 큰 값만 2배로 만드는 예시입니다.

#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);  // mask면 doubled
        _mm256_storeu_ps(out + i, result);
    }
    for (; i < n; ++i)
        out[i] = (in[i] > 0) ? in[i] * 2.0f : in[i];
}

예제 4: 내적(dot product) — FMA 활용

FMA(Fused Multiply-Add)는 a*b+c를 명령 하나로, 중간 반올림 없이 계산합니다. _mm256_fmadd_ps는 AVX2와는 별개인 FMA3 확장 명령이라 -mfma(또는 -march=haswell 이상)가 필요하고, 런타임 검사도 AVX2와 따로 해야 합니다. 아래 코드는 누적기가 sum8 하나뿐이라 FMA의 지연 시간(수 사이클)만큼 반복마다 기다리게 되므로, 더 빠르게 하려면 누적기를 여러 개 두어 독립적인 FMA를 겹쳐 실행합니다.

#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
    }
    // 수평 합산
    __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;
}

예제 5: std::execution + 인트린직 조합

par로 큰 범위를 청크로 나눠 스레드에 맡기고, 각 청크 내부는 AVX로 처리할 수 있습니다. 원소 인덱스 하나마다 8개 폭 연산을 시키면 같은 원소를 여러 스레드가 겹쳐 쓰게 되므로, 반드시 겹치지 않는 청크 단위로 나눠야 합니다.

void add_arrays_chunked_par(const float* a, const float* b, float* out,
                            size_t n) {
    constexpr size_t chunk = 4096;  // 청크 크기
    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));
                      }
                      size_t i = start + ((end - start) & ~size_t{7});
                      for (; i < end; ++i)
                          out[i] = a[i] + b[i];
                  });
}

청크 내부 SIMD 루프는 start부터 8개씩 진행하므로, 나머지 시작 위치도 start 기준으로 계산해야 합니다. 청크 크기를 8의 배수로 두면 마지막 청크에서만 나머지가 생깁니다.


정렬 안 된 load_ps, AVX 미지원 CPU, par_unseq 안 동기화 같은 에러

에러 1: 정렬되지 않은 주소에 load_ps 사용 (SIGSEGV)

증상: 특정 입력에서 크래시, SIGSEGV. 원인: _mm256_load_ps는 32바이트 정렬을 요구합니다. malloc이나 new로 할당한 버퍼는 보장되지 않습니다.

// ❌ 위험: 정렬 보장 안 됨
float* a = new float[1000];
__m256 v = _mm256_load_ps(a);  // a가 32바이트 정렬이 아닐 수 있음 → 크래시

해결법: loadu_ps 사용 또는 정렬된 할당.

// ✅ loadu_ps 사용 (정렬 불필요)
__m256 v = _mm256_loadu_ps(a);
// ✅ 정렬된 할당 (C++11)
alignas(32) float a[1000];
__m256 v = _mm256_load_ps(a);
// ✅ aligned_alloc (C++17): 크기는 정렬 값의 배수여야 하고, 해제는 std::free
size_t bytes = (1000 * sizeof(float) + 31) & ~size_t{31};
float* a = static_cast<float*>(std::aligned_alloc(32, bytes));
// ... 사용 후
std::free(a);

MSVC는 std::aligned_alloc을 제공하지 않으므로 _aligned_malloc/_aligned_free를 쓰거나, C++17의 new (std::align_val_t{32}) float[n]을 사용합니다.

에러 2: AVX 미지원 CPU에서 실행 (SIGILL)

증상: 최신 CPU에서는 동작하는데, 구형 PC에서 Illegal instruction으로 크래시. 원인: AVX 명령을 지원하지 않는 CPU에서 실행. 해결법: 런타임 CPU 기능 검사 후 분기합니다. CPUID의 AVX 비트만 보면 운영체제가 AVX 레지스터 저장을 지원하지 않는 환경을 놓칠 수 있으므로, 직접 CPUID를 읽는다면 OSXSAVE 비트와 xgetbv까지 확인해야 합니다. 컴파일러 내장 함수가 이 과정을 대신해 줍니다.

// GCC/Clang: CPU 지원과 운영체제의 AVX 상태 저장 지원(OSXSAVE/XGETBV)까지 확인
bool has_avx() {
    return __builtin_cpu_supports("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];
    }
}

에러 3: par_unseq에서 동기화 사용 (UB)

증상: 간헐적 크래시, 데드락, 잘못된 결과. 원인: par_unseq 람다 내부에서 mutex, atomic 등 사용.

// ❌ UB
std::atomic<int> counter{0};
std::transform(std::execution::par_unseq, a.begin(), a.end(), out.begin(),
               [&counter](int x) {
                   counter++;  // UB: par_unseq에서 atomic 연산 제한
                   return x * 2;
               });

해결법: par만 사용하거나, 동기화를 람다 밖으로 빼기.

// ✅ par 사용 (동기화 필요 시)
std::transform(std::execution::par, a.begin(), a.end(), out.begin(),
               [&counter](int x) {
                   counter++;  // par에서는 허용 (단, 성능 주의)
                   return x * 2;
               });

에러 4: 나머지 요소 처리 누락

증상: 배열 끝 몇 개가 잘못된 값 또는 초기화되지 않은 값. 원인: SIMD 루프가 8개(또는 4개) 단위로만 처리하며, n이 8의 배수가 아닐 때 나머지를 처리하지 않음.

// ❌ 나머지 미처리
for (size_t i = 0; i + 8 <= n; i += 8) {
    // ...
}
// n=10이면 i=8에서 끝나고, out[8], out[9]는 미처리

해결법: 스칼라 루프로 나머지 처리.

// ✅ 나머지 처리
size_t i = 0;
for (; i + 8 <= n; i += 8) {
    // ...
}
for (; i < n; ++i)
    out[i] = a[i] + b[i];

에러 5: 오버랩(overlap) 버퍼

증상: 결과가 잘못되며, 크래시. 원인: in과 out이 일부만 겹치는 경우입니다. out == in인 완전한 in-place는 같은 8개를 읽은 뒤 같은 자리에 쓰므로 문제가 없습니다. 문제는 out = in + 1처럼 어긋나게 겹칠 때로, 스칼라 루프라면 앞 원소의 결과가 다음 반복의 입력이 되는데 SIMD는 8개를 한꺼번에 읽어 버려 결과가 달라집니다.

// ❌ out이 in보다 1~7칸 뒤에서 겹치면 스칼라 버전과 결과가 달라짐
void prefix_like(const 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));
    }
}

해결법: API에서 겹치지 않음을 요구하고 __restrict__로 표시하거나, 겹침을 검사해 스칼라 경로로 보냅니다. 컴파일러의 자동 벡터화도 같은 이유로 포인터가 겹칠 수 있으면 런타임 겹침 검사를 넣거나 벡터화를 포기합니다.

에러 6: n & ~7u로 나머지 시작 위치를 계산

증상: 원소 수가 2^32 이상인 배열에서 결과 일부가 틀리거나 범위 밖에 접근합니다. 원인: ~7u는 unsigned int(32비트)라서 size_t(64비트)로 변환될 때 상위 32비트가 0이 됩니다. 그래서 n & ~7u는 n의 상위 비트를 지워 버립니다. 해결법: n & ~size_t{7}처럼 마스크를 size_t로 만들거나, 앞의 예제처럼 SIMD 루프의 인덱스 i를 그대로 이어서 나머지를 처리합니다. 또 n - 8처럼 부호 없는 정수에서 빼는 조건은 n < 8일 때 거대한 값이 되므로 i + 8 <= n 형태로 씁니다.


스칼라 vs AVX vs par 벤치마크

벤치마크 1: 스칼라 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. 스칼라
    auto t1 = std::chrono::steady_clock::now();
    for (size_t i = 0; i < N; ++i)
        c[i] = a[i] + b[i];
    auto t2 = std::chrono::steady_clock::now();
    // 2. AVX
    auto t3 = std::chrono::steady_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::steady_clock::now();
    // 3. std::transform par
    auto t5 = std::chrono::steady_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::steady_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";
}

결과 해석 (환경에 따라 다름):

방식기대되는 경향이유
스칼라-O3에서는 AVX와 비슷하게 나올 수 있음단순 덧셈 루프는 컴파일러가 자동 벡터화함. -O0/-O1이면 한 번에 하나씩 처리
AVX이론상 한 명령에 float 8개100만 float(4MB)는 캐시를 벗어나 메모리 대역폭이 먼저 한계에 닿기 쉬움
par (4코어)스레드 시작 비용을 회수할 만큼 데이터가 커야 이득대역폭이 병목이면 코어를 늘려도 한계가 있음
par_unseq병렬 + 벡터화위 두 가지 한계를 모두 가짐

스칼라 버전이 생각보다 빠르게 나온다면 -fopt-info-vec(GCC)나 -Rpass=loop-vectorize(Clang)로 자동 벡터화가 일어났는지 먼저 확인하세요.

벤치마크 2: reduce (합계)

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::steady_clock::now();
    volatile float r1 = sum_scalar(a.data(), N);
    auto t2 = std::chrono::steady_clock::now();
    auto t3 = std::chrono::steady_clock::now();
    volatile float r2 = std::reduce(std::execution::par, a.begin(), a.end());
    auto t4 = std::chrono::steady_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";
}

벤치마크 요약 표

연산병목SIMD/병렬 효과를 좌우하는 것
100만 float 덧셈메모리 대역폭데이터가 캐시에 들어가는지, 컴파일러 자동 벡터화 여부
1000만 float 합계덧셈 의존 사슬스칼라 s += a[i]는 부동소수점 결합 법칙 때문에 -ffast-math 없이는 벡터화되지 않음. std::reduce는 순서 재배열을 허용해 병렬·벡터화 가능(대신 결과가 비트 단위로 달라질 수 있음)
조건부 연산분기마스크 연산으로 분기를 없애면 벡터화 가능, 분기가 복잡하면 이득 감소

그래서 합계는 병렬화 효과가 큰 편이고, 단순 덧셈은 대역폭에 막혀 코어를 늘려도 효과가 제한적인 경우가 많습니다. 실제 배수는 위 벤치마크 코드를 자기 CPU에서 실행해 확인하세요.


CPU 디스패치, xsimd, 정렬 버퍼, 벡터화 리포트

패턴 1: CPU 디스패치 (AVX → SSE → 스칼라)

런타임에 CPU 기능을 검사해 적절한 경로를 선택합니다. 핵심은 AVX 구현만 AVX로 컴파일하는 것입니다. 파일 전체를 -mavx로 빌드하면 컴파일러가 스칼라 경로나 디스패치 코드에도 AVX 명령을 넣을 수 있어, 검사하기도 전에 구형 CPU에서 SIGILL이 납니다.

#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];
}
// 파일 전체를 -mavx로 빌드하지 않고, 이 함수만 AVX 명령을 쓰도록 지정 (GCC/Clang)
__attribute__((target("avx")))
void add_arrays_avx(const float* a, const float* b, float* out, size_t n);
AddFunc get_add_func() {
    if (__builtin_cpu_supports("avx"))
        return add_arrays_avx;
    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);
}

패턴 2: xsimd 라이브러리 활용

인트린직을 직접 쓰지 않으며, 이식 가능한 SIMD 추상화 라이브러리를 사용할 수 있습니다.

// xsimd 사용 예 (설치 필요: 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];
}

장점: CPU별로 최적 경로 자동 선택, ARM NEON 등 다른 아키텍처 지원.

패턴 3: 정렬된 버퍼 할당

SIMD 성능을 극대화하려면 32바이트 정렬된 버퍼를 사용합니다.

#include <cstdlib>
#include <memory>
struct FreeDeleter { void operator()(void* p) const { std::free(p); } };
// aligned_alloc으로 얻은 메모리는 delete[]가 아니라 free로 해제해야 한다
std::unique_ptr<float[], FreeDeleter> make_aligned_buffer(size_t n) {
    size_t bytes = (n * sizeof(float) + 31) & ~size_t{31};  // 정렬 값의 배수로 올림
    return std::unique_ptr<float[], FreeDeleter>(
        static_cast<float*>(std::aligned_alloc(32, bytes)));
}
// 또는 정렬 할당자를 쓰는 vector (aligned_allocator는 직접 구현하거나
// boost::alignment::aligned_allocator 등을 사용)

패턴 4: 청크 단위 병렬 + SIMD

큰 배열을 청크로 나누고, 각 청크를 스레드에 할당한 뒤 청크 내부는 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 = start + ((end - start) & ~size_t{7}); i < end; ++i)
                          out[i] = in[i] * in[i];
                  });
}

패턴 5: 점진적 도입 전략

// 1단계: std::execution::par만 적용 (가장 간단)
std::transform(std::execution::par, a.begin(), a.end(), b.begin(),
               c.begin(), [](auto x, auto y) { return x + y; });
// 2단계: 프로파일로 이득 확인 후 par_unseq 시도
std::transform(std::execution::par_unseq, a.begin(), a.end(), b.begin(),
               c.begin(), [](auto x, auto y) { return x + y; });
// 3단계: 여전히 병목이면 인트린직 수동 벡터화
// CPU 디스패치 + AVX/SSE/스칼라 경로

패턴 6: 벡터화 리포트로 자동 벡터화 확인

컴파일러가 벡터화를 적용했는지 확인하려면 리포트 옵션을 사용합니다.

# GCC: 벡터화된 루프 출력
g++ -O3 -march=native -fopt-info-vec-optimized -c program.cpp
# GCC: 벡터화 실패 원인 출력
g++ -O3 -march=native -fopt-info-vec-missed -c program.cpp
# Clang: 벡터화 분석
clang++ -O3 -march=native -Rpass=loop-vectorize -Rpass-missed=loop-vectorize -c program.cpp
# 예: 벡터화 성공 시
program.cpp:10:5: note: loop vectorized
# 예: 벡터화 실패 시
program.cpp:15:5: note: loop not vectorized: value that could not be identified as reduction is used outside the loop

패턴 7: SoA와 SIMD 조합

데이터 지향 설계(39-1)의 SoA(Structure of Arrays)와 SIMD는 궁합이 좋습니다. x, y, z가 각각 연속된 float 배열이면 벡터화가 쉽습니다.

// SoA: x, y, z가 각각 연속 배열
struct ParticleSoA {
    std::vector<float> x, y, z;  // 각 100만 개
};
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 & ~size_t{7}; i < n; ++i) {
        p.x[i] *= scale;
        p.y[i] *= scale;
        p.z[i] *= scale;
    }
}

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. AVX 코드가 어떤 PC에서는 Illegal instruction으로 죽는 이유는 무엇인가요?

A. AVX 명령을 지원하지 않는 CPU에서 AVX로 컴파일된 코드가 실행되면 CPU가 명령을 해석하지 못해 SIGILL로 종료됩니다. -mavx2 같은 플래그로 파일 전체를 빌드하면 컴파일러가 인트린직이 없는 부분에도 AVX 명령을 넣을 수 있어서, 조건 분기만으로는 막지 못하는 경우도 있습니다. 배포 대상 CPU가 다양하다면 CPUID로 런타임에 기능을 확인해 AVX·SSE·스칼라 구현 중 하나로 분기하고, 각 구현은 별도 번역 단위나 target 속성으로 분리해 빌드하는 것이 안전합니다.

Q. par와 par_unseq 중 뭘 써야 하나요?

A. 람다가 완전히 독립적이고 동기화가 없으면 par_unseq가 더 빠를 수 있습니다. 락이나 원자 연산처럼 다른 스레드와 동기화하는 코드가 있으면 par만 사용하세요.

Q. 인트린직과 xsimd·Highway 중 무엇을 쓰나요?

A. 인트린직은 외부 의존성 없이 명령 수준까지 제어할 수 있지만 x86 전용 코드가 됩니다. xsimd나 Google Highway는 같은 코드로 SSE·AVX·ARM NEON 등을 지원하고, Highway는 런타임 디스패치까지 제공합니다. 여러 아키텍처를 지원해야 한다면 라이브러리 쪽이 유지보수 비용이 낮습니다. 이전 글: 고성능 C++ #39-2: 커스텀 알로케이터·pmr 다음 글: [DevOps for C++ #40-1] C++ 패키지 관리 실무: vcpkg와 Conan으로 외부 라이브러리 의존성 지옥 탈출