C++ SIMD 최적화: SSE·AVX2·AVX-512·NEON 인트린직, 자동 벡터화, C++26 std::simd

들어가며: 같은 연산인데 4배 차이

대량의 float 배열을 합산하는 코드를 작성했습니다. -O3로 컴파일했으니 컴파일러가 알아서 벡터화했을 거라 생각했는데, 디스어셈블해 보니 vaddss(스칼라 덧셈) 한 줄씩이 돌고 있었다. 이 글의 첫 예제가 바로 그 경우다. float 합산은 순서를 바꾸면 결과가 달라지는 연산이라 컴파일러가 기본 설정으로는 벡터화하지 않는다(자동 벡터화 절 참고). 여기에 SIMD(Single Instruction Multiple Data)를 직접 적용하면 같은 연산이 여러 배 빨라집니다. CPU는 벡터 레지스터(AVX2 기준 256비트 = float 8개)로 한 번에 여러 데이터를 처리할 수 있습니다. 한 명령으로 8개 float를 동시에 더하는 것이 가능하므로, 루프를 벡터화하면 throughput이 크게 올라갑니다. 컴파일러의 자동 벡터화가 잘 되기도 하지만, 분기·의존성·정렬 문제로 실패하는 경우가 많습니다. 이때 인트린직(intrinsics)으로 수동 벡터화를 적용하면 됩니다.

느린 코드 (스칼라):

// 100만 개 float 합산 - 스칼라
float sum_scalar(const float* data, size_t n) {
    float sum = 0.0f;
    for (size_t i = 0; i < n; ++i) {
        sum += data[i];
    }
    return sum;
}
// -O3만으로는 벡터화되지 않음 (부동소수점 리덕션)

빠른 코드 (AVX2 SIMD):

#include <immintrin.h>
float sum_avx2(const float* data, size_t n) {
    __m256 vsum = _mm256_setzero_ps();
    size_t i = 0;
    for (; i + 8 <= n; i += 8) {
        __m256 v = _mm256_loadu_ps(data + i);
        vsum = _mm256_add_ps(vsum, v);
    }
    // 수평 합산
    float sum_arr[8];
    _mm256_storeu_ps(sum_arr, vsum);
    float sum = sum_arr[0] + sum_arr[1] + sum_arr[2] + sum_arr[3]
             + sum_arr[4] + sum_arr[5] + sum_arr[6] + sum_arr[7];
    for (; i < n; ++i) sum += data[i];
    return sum;
}

원인: 한 번에 8개씩 처리 vs 1개씩 처리. 다만 실제 배속은 데이터가 캐시에 있는지에 크게 좌우됩니다. 100만 개 float(4MB)는 L2보다 크고 L3에 들어가는 크기라, 반복 측정하면 캐시에서 읽는 속도가 결과를 정합니다. 수백 MB 배열이라면 메모리 대역폭이 병목이 되어 SIMD 이득이 크게 줄어듭니다.

위 AVX2 버전은 누적 레지스터를 하나만 쓰기 때문에, 덧셈 명령의 지연 시간(몇 사이클) 동안 다음 덧셈이 기다려야 합니다. 누적 레지스터를 4개 정도로 나눠 번갈아 더하면(vsum0~vsum3) 이 의존성 사슬이 끊겨 캐시에 있는 데이터에서는 추가로 빨라집니다. 컴파일러가 -ffast-math로 자동 벡터화할 때도 같은 방식으로 여러 누적기를 씁니다. 이 글을 읽으면:

  • SIMD의 개념과 동작 원리를 이해할 수 있습니다.
  • AVX2·NEON 인트린직으로 실전 코드를 작성할 수 있습니다.
  • 자동 벡터화 실패 시 수동 벡터화를 적용할 수 있습니다.
  • 프로덕션에서 CPU 분기·폴백 패턴을 활용할 수 있습니다.

SIMD가 효과를 내는 작업들

스칼라 루프가 병목이 되는 상황

"100만 개 float 배열 합산이 프로파일에서 30%를 차지해요."
"이미지 픽셀 처리 루프가 너무 느려서 실시간 처리에 못 미쳐요."
"컴파일러가 벡터화했다고 하는데 실제로는 스칼라 코드가 나와요."
"AVX2를 쓰려 했는데 구형 CPU에서 Illegal instruction으로 크래시가 나요."
"_mm256_load_ps를 쓰므로 가끔 SIGSEGV가 발생해요."
"데이터가 8의 배수가 아닌데 마지막 원소가 누락돼요."

원인 후보: 자동 벡터화 실패(의존성·분기), 정렬 위반, CPU 기능 미검사, 나머지 처리 누락.

이미지 픽셀 밝기 조정

1920×1080 이미지(약 200만 픽셀)의 RGB 값을 스케일링할 때, 픽셀마다 3번 곱셈이 필요합니다. 스칼라 루프로는 수 ms가 걸리는데, SIMD로 처리하면 실시간 처리에 근접합니다.

오디오 샘플 정규화

44.1kHz 오디오 1초 분량(44,100 샘플)을 -1.0~1.0 범위로 정규화할 때, 대량의 float 연산이 필요합니다. SIMD 없이는 실시간 스트리밍에서 지연이 발생할 수 있습니다.

머신러닝 추론

CNN의 fully-connected 레이어에서 행렬-벡터 곱셈이 핵심입니다. 한 뉴런당 수천 개 가중치와 입력의 곱을 합산하는 연산이 반복되므로, SIMD로 가속하면 추론 속도가 크게 향상됩니다.

게임 물리 엔진

수백 개의 파티클 위치·속도 업데이트에서 pos += vel * dt 같은 연산이 매 프레임 반복됩니다. SIMD로 벡터화하면 프레임 드랍을 줄일 수 있습니다.

암호화/해시 연산

AES, SHA 등 암호화 알고리즘의 내부 루프는 비트 연산과 테이블 조회가 반복됩니다. AES-NI, SHA 확장 등 CPU 전용 명령이 있지만, 일반적인 비트 연산도 SIMD로 128/256비트 단위 처리하면 속도가 향상됩니다.

JSON/직렬화 숫자 파싱

대량의 숫자 문자열을 float/double로 변환할 때, SIMD로 여러 자릿수를 동시에 처리하는 기법이 사용됩니다. (예: simdjson 라이브러리)

시나리오별 해결 방향

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

SIMD와 인트린직 기본 개념

SIMD란?

SIMD(Single Instruction Multiple Data)는 하나의 명령으로 여러 데이터에 동시에 연산을 수행하는 방식입니다. CPU의 벡터 레지스터(128비트 SSE, 256비트 AVX/AVX2, 512비트 AVX-512)에 여러 값을 담아 한 번에 처리합니다.

flowchart LR
  subgraph scalar["스칼라 (1개씩)"]
    S1[a0] --> S2[+]
    S2 --> S3[결과]
  end
  subgraph simd["SIMD (8개씩)"]
    V1[a0..a7] --> V2[한 번에 +]
    V2 --> V3[결과 8개]
  end

비유: 스칼라는 “사과를 하나씩 세는 것”, SIMD는 “사과 8개를 한 손에 들고 한 번에 세는 것”입니다.

SIMD 파이프라인 개요

flowchart TB
  subgraph memory[메모리]
    M1[배열 A]
    M2[배열 B]
  end
  subgraph load[로드]
    L1[_mm256_loadu_ps]
  end
  subgraph compute[연산]
    C1[_mm256_add_ps]
  end
  subgraph store[스토어]
    S1[_mm256_storeu_ps]
  end
  M1 --> L1
  M2 --> L1
  L1 --> C1
  C1 --> S1
  S1 --> M3[결과 배열 C]

벡터 레지스터 크기

ISA레지스터float 개수double 개수
SSE128비트42
AVX/AVX2256비트84
AVX-512512비트168
ARM NEON128비트42

인트린직(Intrinsics)이란?

인트린직은 컴파일러가 제공하는 내장 함수로, SIMD 명령어에 직접 대응합니다. 예: _mm256_add_ps → 8개 float 동시 덧셈. 어셈블리보다 이식성이 좋으며, 컴파일러가 레지스터 할당·스케줄링을 최적화합니다.


SSE 예제 (128비트)

SSE(Streaming SIMD Extensions)는 128비트 레지스터로 float 4개 또는 double 2개를 한 번에 처리합니다. 대부분의 x86-64 CPU에서 지원하며, AVX2가 없는 환경의 폴백으로 사용합니다.

SSE 헤더와 타입

#include <xmmintrin.h>   // SSE
#include <emmintrin.h>   // SSE2
#include <pmmintrin.h>   // SSE3
// 또는 immintrin.h로 한 번에 포함 (SSE~AVX-512)
#include <immintrin.h>
// SSE 타입: __m128 (float 4개), __m128d (double 2개), __m128i (정수)

SSE float 4개 덧셈

#include <emmintrin.h>
// SSE: 4개 float를 한 번에 더함 (128비트)
void add_float4_sse(const float* a, const float* b, float* c, size_t n) {
    size_t i = 0;
    for (; i + 4 <= n; i += 4) {
        __m128 va = _mm_loadu_ps(a + i);   // unaligned load
        __m128 vb = _mm_loadu_ps(b + i);
        __m128 vc = _mm_add_ps(va, vb);
        _mm_storeu_ps(c + i, vc);
    }
    for (; i < n; ++i) {
        c[i] = a[i] + b[i];
    }
}

코드 설명:

  • _mm_loadu_ps: 16바이트 정렬 불필요 (u = unaligned)
  • _mm_load_ps: 16바이트 정렬 필요 (SSE는 16바이트 경계)
  • _mm_add_ps: 4쌍 float 동시 덧셈

SSE float 4개 합산

#include <emmintrin.h>
float sum_sse(const float* data, size_t n) {
    __m128 vsum = _mm_setzero_ps();
    size_t i = 0;
    for (; i + 4 <= n; i += 4) {
        __m128 v = _mm_loadu_ps(data + i);
        vsum = _mm_add_ps(vsum, v);
    }
    // 수평 합산: 4개 → 1개
    __m128 shuf = _mm_movehdup_ps(vsum);   // [1,1,3,3]
    __m128 sums = _mm_add_ps(vsum, shuf);  // [0+1, 2+3]
    shuf = _mm_movehl_ps(shuf, sums);      // [2+3, 2+3]
    shuf = _mm_add_ss(sums, shuf);
    float sum = _mm_cvtss_f32(shuf);
    for (; i < n; ++i) sum += data[i];
    return sum;
}

SSE 정렬 로드 (16바이트 경계)

#include <emmintrin.h>
// 16바이트 정렬된 메모리에서 로드 (SSE)
void add_aligned_sse(const float* a, const float* b, float* c, size_t n) {
    size_t i = 0;
    for (; i + 4 <= n; i += 4) {
        __m128 va = _mm_load_ps(a + i);   // aligned
        __m128 vb = _mm_load_ps(b + i);
        _mm_store_ps(c + i, _mm_add_ps(va, vb));
    }
    for (; i < n; ++i) c[i] = a[i] + b[i];
}
// 정렬된 메모리 할당 (16바이트)
float* alloc_aligned_sse(size_t n) {
    return static_cast<float*>(std::aligned_alloc(16, n * sizeof(float)));
}

SSE vs AVX2: SSE는 4개 float, AVX2는 8개 float. AVX2 지원 시 _mm256_* 인트린직을 사용하면 2배 처리량이 됩니다.


AVX2 덧셈·수평 합산·FMA 예제

float 배열 덧셈 (A + B → C)

#include <immintrin.h>
#include <cstddef>
// AVX2: 8개 float를 한 번에 더함
void add_float8_avx2(const float* a, const float* b, float* c, size_t n) {
    size_t i = 0;
    // 8개씩 벡터 처리
    for (; i + 8 <= n; i += 8) {
        __m256 va = _mm256_loadu_ps(a + i);
        __m256 vb = _mm256_loadu_ps(b + i);
        __m256 vc = _mm256_add_ps(va, vb);
        _mm256_storeu_ps(c + i, vc);
    }
    // 나머지 스칼라 처리
    for (; i < n; ++i) {
        c[i] = a[i] + b[i];
    }
}

코드 설명:

  • _mm256_loadu_ps: 정렬되지 않은 주소에서 8개 float 로드 (u = unaligned)
  • _mm256_add_ps: 8쌍 동시 덧셈
  • _mm256_storeu_ps: 결과를 메모리에 저장
  • 주의: n이 8의 배수가 아니면 나머지를 스칼라로 처리해야 함

float 배열 합산 (수평 합산)

#include <immintrin.h>
float sum_avx2(const float* data, size_t n) {
    __m256 vsum = _mm256_setzero_ps();
    size_t i = 0;
    for (; i + 8 <= n; i += 8) {
        __m256 v = _mm256_loadu_ps(data + i);
        vsum = _mm256_add_ps(vsum, v);
    }
    // 수평 합산: 8개 → 1개
    __m128 lo = _mm256_castps256_ps128(vsum);
    __m128 hi = _mm256_extractf128_ps(vsum, 1);
    __m128 sum128 = _mm_add_ps(lo, hi);
    sum128 = _mm_hadd_ps(sum128, sum128);
    sum128 = _mm_hadd_ps(sum128, sum128);
    float sum = _mm_cvtss_f32(sum128);
    for (; i < n; ++i) sum += data[i];
    return sum;
}

수평 합산(horizontal add): 벡터 내 8개 값을 하나로 합치는 연산. _mm_hadd_ps를 2번 사용해 4→2→1로 줄입니다.

조건부 연산 (max, min)

#include <immintrin.h>
// c[i] = max(a[i], b[i])
void max_float8_avx2(const float* a, const float* b, float* c, size_t n) {
    size_t i = 0;
    for (; i + 8 <= n; i += 8) {
        __m256 va = _mm256_loadu_ps(a + i);
        __m256 vb = _mm256_loadu_ps(b + i);
        __m256 vc = _mm256_max_ps(va, vb);
        _mm256_storeu_ps(c + i, vc);
    }
    for (; i < n; ++i) c[i] = (a[i] > b[i]) ? a[i] : b[i];
}

FMA (Fused Multiply-Add)

c = a * b + c를 한 번에 수행. 반올림 오차가 적고 성능이 좋습니다.

#include <immintrin.h>
// c[i] = a[i] * b[i] + c[i]
void fma_float8_avx2(const float* a, const float* b, float* c, size_t n) {
    size_t i = 0;
    for (; i + 8 <= n; i += 8) {
        __m256 va = _mm256_loadu_ps(a + i);
        __m256 vb = _mm256_loadu_ps(b + i);
        __m256 vc = _mm256_loadu_ps(c + i);
        vc = _mm256_fmadd_ps(va, vb, vc);
        _mm256_storeu_ps(c + i, vc);
    }
    for (; i < n; ++i) c[i] = a[i] * b[i] + c[i];
}

주의: _mm256_fmadd_ps는 FMA 지원 CPU에서만 동작. 컴파일 시 -mfma 필요.


ARM NEON 예제

ARM(Apple M1/M2, Android, 서버)에서는 NEON을 사용합니다. 128비트 레지스터로 float 4개 또는 8개(int8)를 처리합니다.

NEON float 4개 덧셈

#if defined(__ARM_NEON) || defined(__aarch64__)
#include <arm_neon.h>
void add_float4_neon(const float* a, const float* b, float* c, size_t n) {
    size_t i = 0;
    for (; i + 4 <= n; i += 4) {
        float32x4_t va = vld1q_f32(a + i);
        float32x4_t vb = vld1q_f32(b + i);
        float32x4_t vc = vaddq_f32(va, vb);
        vst1q_f32(c + i, vc);
    }
    for (; i < n; ++i) c[i] = a[i] + b[i];
}
#endif

NEON float 4개 합산

#if defined(__ARM_NEON) || defined(__aarch64__)
#include <arm_neon.h>
float sum_neon(const float* data, size_t n) {
    float32x4_t vsum = vdupq_n_f32(0.0f);
    size_t i = 0;
    for (; i + 4 <= n; i += 4) {
        float32x4_t v = vld1q_f32(data + i);
        vsum = vaddq_f32(vsum, v);
    }
    float sum = vgetq_lane_f32(vsum, 0) + vgetq_lane_f32(vsum, 1)
              + vgetq_lane_f32(vsum, 2) + vgetq_lane_f32(vsum, 3);
    for (; i < n; ++i) sum += data[i];
    return sum;
}
#endif

크로스 플랫폼 래퍼 예시

void add_floats(const float* a, const float* b, float* c, size_t n) {
#if defined(__AVX2__)
    add_float8_avx2(a, b, c, n);
#elif defined(__ARM_NEON) || defined(__aarch64__)
    add_float4_neon(a, b, c, n);
#else
    for (size_t i = 0; i < n; ++i) c[i] = a[i] + b[i];
#endif
}

자동 벡터화

컴파일러가 루프를 분석해 SIMD로 변환하는 것을 자동 벡터화라고 합니다. 조건이 맞으면 -O3만으로도 벡터화됩니다.

자동 벡터화가 잘 되는 조건

  • 연속 메모리 접근
  • 단순 연산 (덧셈, 곱셈 등)
  • 반복 간 데이터 의존성 없음
  • 분기 최소화

자동 벡터화 실패 예시

// ❌ 의존성: 이전 반복 결과에 의존
void prefix_sum_bad(float* data, size_t n) {
    for (size_t i = 1; i < n; ++i) {
        data[i] += data[i - 1];  // data[i-1]이 직전에 변경됨
    }
}
// ❌ 간접 접근: 인덱스가 연속이 아님
void gather_bad(const float* src, const int* indices, float* dst, size_t n) {
    for (size_t i = 0; i < n; ++i) {
        dst[i] = src[indices[i]];
    }
}
// ❌ 복잡한 분기
void branch_heavy(float* data, size_t n) {
    for (size_t i = 0; i < n; ++i) {
        if (data[i] > 0) data[i] *= 2;
        else data[i] /= 2;
    }
}

부동소수점 리덕션은 기본으로 벡터화되지 않는다

이 글의 첫 예제처럼 sum += data[i]는 가장 벡터화하기 쉬워 보이는 루프인데, GCC와 Clang은 -O3에서도 이 루프를 벡터화하지 않습니다. 벡터화하면 ((a0+a1)+a2)+... 순서가 (a0+a8+...)+(a1+a9+...)+...로 바뀌고, 부동소수점 덧셈은 결합 법칙이 성립하지 않으므로 결과가 달라질 수 있기 때문입니다. 컴파일러는 기본적으로 IEEE 규칙상 결과를 바꾸는 변환을 하지 않습니다.

g++ -O3 -march=x86-64-v3 -fopt-info-vec-missed sum.cpp
# note: reduction: unsafe fp math optimization: sum_6 = _5 + sum_12;

해결 방법은 세 가지입니다.

// 1) 이 루프에서만 재배열을 허용 (OpenMP SIMD, -fopenmp-simd로 컴파일)
float sum_omp(const float* data, size_t n) {
    float sum = 0.0f;
    #pragma omp simd reduction(+:sum)
    for (size_t i = 0; i < n; ++i) sum += data[i];
    return sum;
}
// 2) 파일 전체에 -ffast-math (또는 -fassociative-math -fno-signed-zeros -fno-trapping-math)
// 3) 위의 AVX2 예제처럼 직접 벡터화

-ffast-math는 편하지만 NaN·무한대 검사(std::isnan)가 최적화로 사라지는 등 부작용이 넓어서, 저는 전역 옵션보다 1번처럼 필요한 루프에만 재배열을 허용하는 쪽을 씁니다. 정수 합산은 결합 법칙이 성립하므로 이런 제약 없이 자동 벡터화됩니다. 1번 방법에서 주의할 점은 -fopenmp-simd(또는 -fopenmp)를 켜지 않으면 컴파일러가 알 수 없는 pragma로 보고 경고 없이 무시한다는 것입니다(경고를 보려면 -Wall의 -Wunknown-pragmas가 필요합니다). pragma를 붙였는데 속도가 그대로라면 플래그부터 확인하고, 아래의 벡터화 리포트로 실제로 벡터화됐는지 확인합니다.

벡터화 힌트 (GCC/Clang)

// #pragma GCC ivdep: 반복 간 의존성 없음을 힌트
#pragma GCC ivdep
for (size_t i = 0; i < n; ++i) {
    c[i] = a[i] + b[i];
}

벡터화 리포트 확인

컴파일러가 어떤 루프를 벡터화했는지 확인할 수 있습니다.

# GCC: 벡터화 리포트
g++ -O3 -march=native -fopt-info-vec-optimized simd_test.cpp -o simd_test
# Clang: 벡터화 상세
clang++ -O3 -march=native -Rpass=loop-vectorize -Rpass-missed=loop-vectorize simd_test.cpp -o simd_test

출력 예시: simd_test.cpp:15:5: remark: vectorized loop (vectorization width 8, interleaved count 2)

자동 벡터화 성공 예시

// ✅ 연속 접근, 독립 반복 → 자동 벡터화 잘 됨. restrict로 별칭 없음 힌트 가능
void add_auto(const float* a, const float* b, float* c, size_t n) {
    for (size_t i = 0; i < n; ++i) c[i] = a[i] + b[i];
}

컴파일 옵션

# 개발 머신 전용: 현재 CPU의 모든 명령 사용 (AVX2·FMA 포함)
g++ -O3 -march=native -o simd_test simd_test.cpp
# 배포용 x86-64 기준선: x86-64-v3 = AVX2, FMA, BMI2 등 (2015년 이후 대부분의 CPU)
g++ -O3 -march=x86-64-v3 -o simd_test simd_test.cpp
# ARM64: NEON은 기본 포함
clang++ -O3 -o simd_test simd_test.cpp        # Apple Silicon, aarch64 Linux

-march=native는 빌드한 머신의 CPU에 맞춘 바이너리를 만듭니다. CI 서버나 개발 PC가 AVX-512를 지원하는 최신 CPU라면 그 명령이 바이너리에 섞여 들어가고, 구형 서버나 AVX-512가 없는 PC에서 실행하면 Illegal instruction으로 즉시 죽습니다. 제가 본 가장 흔한 SIMD 사고가 이것으로, 코드는 한 줄도 SIMD를 쓰지 않았는데 컴파일러의 자동 벡터화가 AVX-512 명령을 넣은 경우였습니다. 배포 바이너리는 x86-64-v2(SSE4.2)나 x86-64-v3(AVX2) 같은 마이크로아키텍처 레벨을 명시하고, 그 이상은 운영 패턴의 런타임 디스패치로 처리하세요.


정렬·언롤링·Gather·SoA 기법

데이터 정렬 (Aligned Load/Store)

정렬된 주소에서 로드/스토리하면 일부 CPU에서 더 빠를 수 있습니다.

ISA정렬 요구바이트 경계
SSE16바이트alignas(16)
AVX/AVX232바이트alignas(32)
AVX-51264바이트alignas(64)
#include <immintrin.h>
#include <cstdlib>
void add_aligned(const float* a, const float* b, float* c, size_t n) {
    // 32바이트 정렬 가정 (AVX2)
    size_t i = 0;
    for (; i + 8 <= n; i += 8) {
        __m256 va = _mm256_load_ps(a + i);   // aligned
        __m256 vb = _mm256_load_ps(b + i);
        _mm256_store_ps(c + i, _mm256_add_ps(va, vb));
    }
    for (; i < n; ++i) c[i] = a[i] + b[i];
}
// 정렬된 메모리 할당 (C++17)
float* alloc_aligned(size_t n) {
    return static_cast<float*>(std::aligned_alloc(32, n * sizeof(float)));
}
// C++11: posix_memalign
float* alloc_aligned_posix(size_t n) {
    void* p = nullptr;
    if (posix_memalign(&p, 32, n * sizeof(float)) != 0)
        return nullptr;
    return static_cast<float*>(p);
}

정렬 검증: 디버그 빌드에서 assert(reinterpret_cast<uintptr_t>(ptr) % 32 == 0)로 검증 가능. 주의: _mm256_load_ps에 정렬되지 않은 주소를 넘기면 SIGSEGV 또는 잘못된 결과가 발생할 수 있습니다.

루프 언롤링

한 번에 처리하는 원소 수를 늘려 분기 비용을 줄입니다.

void add_unrolled(const float* a, const float* b, float* c, size_t n) {
    size_t i = 0;
    for (; i + 32 <= n; i += 32) {
        __m256 va0 = _mm256_loadu_ps(a + i + 0);
        __m256 vb0 = _mm256_loadu_ps(b + i + 0);
        _mm256_storeu_ps(c + i + 0, _mm256_add_ps(va0, vb0));
        __m256 va1 = _mm256_loadu_ps(a + i + 8);
        __m256 vb1 = _mm256_loadu_ps(b + i + 8);
        _mm256_storeu_ps(c + i + 8, _mm256_add_ps(va1, vb1));
        __m256 va2 = _mm256_loadu_ps(a + i + 16);
        __m256 vb2 = _mm256_loadu_ps(b + i + 16);
        _mm256_storeu_ps(c + i + 16, _mm256_add_ps(va2, vb2));
        __m256 va3 = _mm256_loadu_ps(a + i + 24);
        __m256 vb3 = _mm256_loadu_ps(b + i + 24);
        _mm256_storeu_ps(c + i + 24, _mm256_add_ps(va3, vb3));
    }
    for (; i < n; ++i) c[i] = a[i] + b[i];
}

Gather / Scatter (AVX2)

Gather: 서로 다른 주소에서 값을 모아 벡터로 로드
Scatter: 벡터 값을 서로 다른 주소에 저장 간접 접근이 필요할 때 사용합니다. AVX2에서는 _mm256_i32gather_ps 등이 있습니다.

#include <immintrin.h>
// indices[]가 가리키는 위치에서 8개 float 수집
void gather_example(const float* base, const int* indices, float* result) {
    __m256i vidx = _mm256_loadu_si256(reinterpret_cast<const __m256i*>(indices));
    __m256 v = _mm256_i32gather_ps(base, vidx, 4);  // stride 4 = sizeof(float)
    _mm256_storeu_ps(result, v);
}

주의: Gather/Scatter는 연속 로드보다 느릴 수 있어, 가능하면 데이터 레이아웃을 SoA로 바꾸는 것이 좋습니다.

SoA (Structure of Arrays)

AoS(Array of Structures)보다 SoA가 벡터화에 유리합니다.

// ❌ AoS: 연속된 필드가 아님
struct Particle { float x, y, z, vx, vy, vz; };
Particle particles[1000];
// ✅ SoA: 같은 타입이 연속
struct Particles {
    float x[1000], y[1000], z[1000];
    float vx[1000], vy[1000], vz[1000];
};
void update_soa(Particles& p, float dt, size_t n) {
    for (size_t i = 0; i + 8 <= n; i += 8) {
        __m256 vx = _mm256_loadu_ps(p.vx + i);
        __m256 dx = _mm256_mul_ps(vx, _mm256_set1_ps(dt));
        __m256 x = _mm256_loadu_ps(p.x + i);
        _mm256_storeu_ps(p.x + i, _mm256_add_ps(x, dx));
        // y, z도 동일
    }
}

정렬 안 된 로드, 나머지 원소, AVX2 미지원 CPU: SIMD 에러 해결

정렬되지 않은 메모리로 _mm256_load_ps 사용

증상: SIGSEGV 또는 잘못된 결과 원인: _mm256_load_ps는 32바이트 정렬을 요구합니다. 해결법:

// ❌ 잘못된 사용
float* data = new float[n];
__m256 v = _mm256_load_ps(data);  // data가 정렬되지 않을 수 있음
// ✅ 올바른 사용: loadu 사용
__m256 v = _mm256_loadu_ps(data);
// 또는 정렬된 메모리 할당
float* data = static_cast<float*>(std::aligned_alloc(32, n * sizeof(float)));
__m256 v = _mm256_load_ps(data);

배열 크기가 벡터 크기의 배수가 아님

증상: 마지막 원소 누락 또는 버퍼 오버런 해결법:

// ✅ 나머지 스칼라 처리
for (; i + 8 <= n; i += 8) { /* 벡터 처리 */ }
for (; i < n; ++i) { /* 스칼라 처리 */ }

CPU 기능 미지원에서 AVX2 실행

증상: Illegal instruction (SIGILL) 원인: 구형 CPU에서 AVX2 미지원 해결법: 런타임 CPU 기능 검사 후 폴백 (아래 프로덕션 패턴 참조)

수평 합산 순서 오류

증상: 합산 결과가 틀림

// ❌ 잘못된 수평 합산
float sum = sum_arr[0];  // 8개 중 1개만 사용
// ✅ 올바른 수평 합산
float sum = sum_arr[0] + sum_arr[1] + sum_arr[2] + sum_arr[3]
          + sum_arr[4] + sum_arr[5] + sum_arr[6] + sum_arr[7];

컴파일 옵션 누락

증상: 인트린직이 스칼라 코드로 컴파일됨 해결법:

# AVX2 활성화
g++ -O3 -mavx2 -mfma -o app app.cpp
# 또는
g++ -O3 -march=native -o app app.cpp

캐시 라인 분할 (False Sharing)

증상: 멀티스레드 + SIMD에서 예상보다 느림 원인: 여러 스레드가 인접한 메모리에 쓰면, 서로 다른 캐시 라인이 같은 캐시 라인을 공유해 쓰기 충돌이 발생합니다. 해결법: 스레드별 데이터를 캐시 라인(64바이트) 경계로 정렬하고 패딩을 둡니다.

struct alignas(64) ThreadData {
    float sum;
    char padding[64 - sizeof(float)];
};

int와 float 혼용

증상: float 벡터에 int 인트린직 사용 시 컴파일 에러 해결법: 타입에 맞는 인트린직 사용. _mm256_add_ps(float) vs _mm256_add_epi32(int32).

// float
__m256 va = _mm256_loadu_ps(a);
__m256 vb = _mm256_add_ps(va, vb);
// int32
__m256i va = _mm256_loadu_si256(reinterpret_cast<const __m256i*>(a));
__m256i vb = _mm256_add_epi32(va, vb);

부동소수점 연산 순서 차이

증상: 스칼라와 SIMD 결과가 소수점 아래에서 미세하게 다름. 원인: 병렬 처리로 연산 순서 변경. 해결법: 대부분 허용 범위. 수치 안정성 중요 시 Kahan 합산 등 고려.

벡터화 힌트 오용

증상: #pragma GCC ivdep 사용해도 벡터화 안 됨. 원인: 힌트일 뿐, 실제 의존성 있으면 무시됩니다. 해결법: 의존성 제거 또는 수동 인트린직.

AVX-512 클럭 다운

증상: AVX-512를 쓰는 코드가 들어간 뒤 서비스 전체가 느려짐. 원인: Skylake-SP 같은 초기 AVX-512 Intel 서버 CPU는 무거운 512비트 연산을 실행하는 동안 코어 클럭을 낮췄고, 그 영향이 SIMD가 아닌 주변 코드까지 미쳤습니다. 짧은 구간에만 AVX-512를 쓰는 경우 이득보다 손해가 컸습니다. Ice Lake 이후 세대와 AMD Zen 4 이후는 이 문제가 크게 줄었습니다. 해결법: 대상 서버 CPU에서 전체 서비스 기준으로 벤치마크하고, 컴파일러의 -mprefer-vector-width=256으로 자동 벡터화는 256비트까지만 쓰게 하는 선택지도 있습니다.


프로파일링 우선·자동 벡터화 먼저 같은 SIMD 원칙

프로파일링 우선

SIMD 최적화 전에 반드시 프로파일링으로 병목을 확인합니다. 병목이 아닌 루프에 SIMD를 적용하면 유지보수만 복잡해집니다.

✅ 순서: 프로파일 → 병목 확인 → 자동 벡터화 시도 → 수동 인트린직
❌ 순서: "우선 SIMD로 바꿔보자"

자동 벡터화 먼저 시도

-O3 -march=native로 컴파일하며, 루프를 단순하게 유지합니다. 컴파일러가 벡터화할 수 있으면 수동 인트린직보다 유지보수가 쉽습니다.

// ✅ 자동 벡터화 유리: 연속 접근, 단순 연산
for (size_t i = 0; i < n; ++i) {
    c[i] = a[i] + b[i];
}
// ❌ 자동 벡터화 불리: 의존성
for (size_t i = 1; i < n; ++i) {
    data[i] += data[i - 1];
}

정렬 규칙 일관 적용

  • 불확실하면 loadu/storeu 사용: _mm256_loadu_ps, _mm256_storeu_ps
  • 정렬이 보장되면 load/store 사용: std::aligned_alloc, alignas 활용
  • 혼용 금지: 같은 함수 내에서 정렬 가정을 섞지 않음

나머지 처리 필수

배열 크기가 벡터 크기(8 for AVX2 float)의 배수가 아니면 반드시 스칼라 루프로 나머지를 처리합니다.

// ✅ 올바른 패턴
for (; i + 8 <= n; i += 8) { /* 벡터 */ }
for (; i < n; ++i) { /* 스칼라 */ }

CPU 기능 런타임 검사

배포 바이너리는 AVX2 미지원 CPU에서도 동작해야 합니다. 런타임에 CPU 기능을 검사하고 폴백 경로를 제공합니다.

SoA 데이터 레이아웃

파티클·행렬 등 반복 처리할 데이터는 SoA(Structure of Arrays)로 배치하면 벡터화가 쉽습니다.

벤치마크로 검증

SIMD 적용 후 실제 환경에서 벤치마크를 수행합니다. 데이터 크기·캐시 상태에 따라 스칼라가 더 빠른 경우도 있습니다.


SIMD 벤치마크와 흔히 속는 부분

테스트 환경 (예시)

  • CPU: Intel Core i7-12700 (AVX2 지원)
  • 컴파일: g++ -O3 -march=native
  • 데이터: 1,000,000 float

벤치마크할 때 흔히 속는 부분

표에 “스칼라 0.45ms vs AVX2 0.12ms” 같은 숫자를 적는 글이 많은데, 측정 방법에 따라 이 비교는 쉽게 틀립니다.

  • “스칼라” 루프가 이미 벡터화돼 있음: -O3 -march=native로 컴파일하면 c[i] = a[i] + b[i] 같은 루프는 컴파일러가 AVX2로 자동 벡터화합니다. 이걸 스칼라라고 부르고 손으로 쓴 인트린직과 비교하면 차이가 거의 없게 나옵니다. 진짜 스칼라와 비교하려면 -fno-tree-vectorize(GCC)나 -fno-vectorize(Clang)로 따로 빌드해야 합니다.
  • 캐시 상태: 같은 배열로 100번 반복하면 두 번째부터는 캐시에서 읽습니다. 실제 서비스에서 데이터가 매번 메모리에서 오는 상황이라면, 대역폭 한계 때문에 SIMD 이득이 훨씬 작습니다.
  • 최적화로 사라진 연산: 결과를 쓰지 않으면 컴파일러가 루프 자체를 지웁니다. 결과를 전역 변수에 넣거나 Google Benchmark의 benchmark::DoNotOptimize를 쓰세요.

그래서 이 글에서는 특정 숫자를 제시하지 않습니다. 아래 코드를 자기 CPU와 데이터 크기로 돌려 보는 것이 가장 정확합니다.

벤치마크 코드 예시

#include <chrono>
#include <iostream>
#include <vector>
template <typename Func>
double benchmark(Func&& f, int iterations = 100) {
    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < iterations; ++i) f();
    auto end = std::chrono::high_resolution_clock::now();
    return std::chrono::duration<double, std::milli>(end - start).count() / iterations;
}
int main() {
    const size_t n = 1000000;
    std::vector<float> a(n, 1.0f), b(n, 2.0f), c(n);
    double t_scalar = benchmark([&]() {
        for (size_t i = 0; i < n; ++i) c[i] = a[i] + b[i];
    });
    double t_simd = benchmark([&]() {
        add_float8_avx2(a.data(), b.data(), c.data(), n);
    });
    std::cout << "Scalar: " << t_scalar << " ms\n";
    std::cout << "SIMD:   " << t_simd << " ms\n";
    std::cout << "Speedup: " << (t_scalar / t_simd) << "x\n";
    return 0;
}

CPU 디스패처·SIMD 라이브러리·std::simd

CPU 디스패처 (런타임 분기)

배포 바이너리가 여러 CPU에서 실행될 때, 런타임에 CPU 기능을 검사해 적절한 구현을 선택합니다.

#include <cstddef>

void add_scalar(const float* a, const float* b, float* c, size_t n) {
    for (size_t i = 0; i < n; ++i) c[i] = a[i] + b[i];
}

// AVX2 구현은 이 함수에만 AVX2를 허용 (파일 전체에 -mavx2를 주지 않아도 됨)
__attribute__((target("avx2,fma")))
void add_float8_avx2(const float* a, const float* b, float* c, size_t n);

using AddFunc = void (*)(const float*, const float*, float*, size_t);

void add_dispatcher(const float* a, const float* b, float* c, size_t n) {
    static const AddFunc fn = []() -> AddFunc {
        __builtin_cpu_init();
        if (__builtin_cpu_supports("avx2") && __builtin_cpu_supports("fma"))
            return add_float8_avx2;
        return add_scalar;
    }();
    fn(a, b, c, n);
}

CPUID 비트를 직접 읽는 코드도 흔한데, AVX 계열은 CPU가 지원하는 것만으로는 부족하고 OS가 YMM 레지스터 상태를 저장해 줘야(XSAVE/OSXSAVE) 쓸 수 있습니다. CPUID의 AVX2 비트만 보고 판단하면 가상 머신이나 특정 OS 설정에서 크래시가 날 수 있습니다. GCC·Clang의 __builtin_cpu_supports는 이 확인까지 포함하므로 직접 비트를 읽는 것보다 안전합니다. MSVC에서는 __cpuid와 _xgetbv를 함께 확인해야 합니다.

디스패치 코드를 손으로 쓰기 싫다면 GCC·Clang의 target_clones가 같은 일을 해 줍니다. 컴파일러가 함수를 ISA별로 여러 벌 만들고, 프로그램 시작 시 CPU에 맞는 버전으로 연결합니다(리눅스 glibc의 ifunc 기능 사용).

__attribute__((target_clones("avx512f", "avx2", "default")))
void scale(float* data, size_t n, float k) {
    for (size_t i = 0; i < n; ++i) data[i] *= k;   // 각 버전이 해당 ISA로 자동 벡터화됨
}

ARM에서는 Linux의 getauxval(AT_HWCAP)으로 SVE 같은 확장 지원을 확인하고, NEON은 AArch64에서 항상 사용 가능하므로 따로 검사할 필요가 없습니다.

컴파일 타임 분기

template <bool UseSIMD>
void add_impl(const float* a, const float* b, float* c, size_t n) {
    if constexpr (UseSIMD) {
        add_float8_avx2(a, b, c, n);
    } else {
        for (size_t i = 0; i < n; ++i) c[i] = a[i] + b[i];
    }
}

SIMD 라이브러리 활용

플랫폼 차이를 추상화하려면 xsimd, Highway, Vc 같은 라이브러리를 사용할 수 있습니다.

// xsimd 예시 (의사 코드)
#include <xsimd/xsimd.hpp>
namespace xs = xsimd;
void add_xsimd(const float* a, const float* b, float* c, size_t n) {
    using batch = xs::batch<float>;
    size_t simd_size = batch::size;
    size_t i = 0;
    for (; i + simd_size <= n; i += simd_size) {
        auto va = batch::load_unaligned(a + i);
        auto vb = batch::load_unaligned(b + i);
        (va + vb).store_unaligned(c + i);
    }
    for (; i < n; ++i) c[i] = a[i] + b[i];
}

std::execution과 병행

SIMD로 벡터화하며, std::execution::par로 멀티코어를 활용할 수 있습니다.

#include <algorithm>
#include <execution>
std::transform(std::execution::par_unseq, a.begin(), a.end(), b.begin(), c.begin(),
               [](float x, float y) { return x + y; });
// par_unseq는 스레드 병렬 + 벡터화를 모두 허용. GCC/libstdc++는 TBB가 있어야 실제로 병렬 실행됨

std::simd (C++26)와 std::experimental::simd

C++26에는 std::simd(P1928)가 채택되어, 인트린직 없이 이식 가능한 SIMD 코드를 표준 C++로 쓸 수 있게 됩니다. 표준안의 최종 이름과 세부 인터페이스는 채택 과정에서 여러 번 조정되었고, 2026년 기준 이 최종 형태를 완전히 구현한 표준 라이브러리는 아직 드뭅니다. 지금 바로 써 볼 수 있는 것은 GCC 11부터 libstdc++에 들어 있는 Parallelism TS 2 버전인 <experimental/simd>입니다. 개념(타입 하나가 레지스터 폭만큼의 값을 담고, 연산자가 원소별로 동작)은 같습니다.

#include <experimental/simd>
#include <cstddef>
namespace stdx = std::experimental;

float sum_simd(const float* data, std::size_t n) {
    using V = stdx::native_simd<float>;          // 컴파일 타깃의 기본 폭 (AVX2면 8, NEON이면 4)
    V acc = 0.0f;
    std::size_t i = 0;
    for (; i + V::size() <= n; i += V::size()) {
        acc += V(data + i, stdx::element_aligned);
    }
    float sum = stdx::reduce(acc);                // 수평 합산
    for (; i < n; ++i) sum += data[i];
    return sum;
}

같은 코드를 -march=x86-64-v3로 빌드하면 AVX2, ARM64에서 빌드하면 NEON 명령이 나옵니다. 인트린직 버전의 _mm256_*와 vaddq_f32를 두 벌 유지할 필요가 없어지는 것이 가장 큰 장점입니다. 한계는 셔플, gather, 특수 정수 연산처럼 ISA마다 성격이 크게 다른 명령을 세밀하게 제어하기 어렵다는 점이고, 그런 부분은 여전히 인트린직이나 Highway 같은 라이브러리가 낫습니다.

빌드 타겟 분리

여러 ISA 지원 시 컴파일 유닛을 나눕니다. simd_avx2.cpp는 -mavx2로, simd_scalar.cpp는 기본 옵션으로 컴파일 후 런타임에 함수 포인터로 선택합니다.

SIMD를 쓰지 말아야 할 때

  • 데이터 양이 적을 때 (수백 개 미만): 벡터화 오버헤드가 이득을 상쇄할 수 있음
  • 분기가 많은 로직: 벡터화해도 마스킹 비용이 커서 이득이 적음
  • 이식성이 최우선일 때: 순수 C++ 표준만 쓰는 것이 유지보수에 유리
  • 프로파일링 전: 병목이 SIMD로 해결되는 영역인지 먼저 확인

밝기 조정·내적·ReLU·행렬-벡터 곱 예제

예제: 이미지 밝기 조정 (RGBA)

#include <immintrin.h>
#include <cstddef>
// RGBA 픽셀: 4바이트씩. 8픽셀 = 32바이트
void brighten_avx2(uint8_t* pixels, size_t num_pixels, float factor) {
    size_t i = 0;
    const __m256 vfactor = _mm256_set1_ps(factor);
    for (; i + 32 <= num_pixels * 4; i += 32) {
        // 32바이트 = 8개 float (RGBA 8픽셀)
        __m256 v = _mm256_cvtepi32_ps(
            _mm256_cvtepu8_epi32(_mm_loadl_epi64(
                reinterpret_cast<const __m128i*>(pixels + i))));
        v = _mm256_mul_ps(v, vfactor);
        v = _mm256_min_ps(v, _mm256_set1_ps(255.0f));
        __m256i vi = _mm256_cvtps_epi32(v);
        // 패킹 후 저장 (실제로는 더 복잡)
    }
    // 나머지 스칼라 처리
}

참고: 실제 RGBA는 8→32비트 변환, 클램핑, 패킹이 필요해 코드가 더 길어집니다.

예제: Dot Product (내적)

float dot_avx2(const float* a, const float* b, size_t n) {
    __m256 vsum = _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);
        vsum = _mm256_fmadd_ps(va, vb, vsum);
    }
    float sum_arr[8];
    _mm256_storeu_ps(sum_arr, vsum);
    float sum = sum_arr[0] + sum_arr[1] + sum_arr[2] + sum_arr[3]
             + sum_arr[4] + sum_arr[5] + sum_arr[6] + sum_arr[7];
    for (; i < n; ++i) sum += a[i] * b[i];
    return sum;
}

예제: ReLU 활성화 함수

딥러닝에서 자주 쓰이는 ReLU: y = max(0, x)

void relu_avx2(const float* x, float* y, size_t n) {
    __m256 zero = _mm256_setzero_ps();
    size_t i = 0;
    for (; i + 8 <= n; i += 8) {
        __m256 vx = _mm256_loadu_ps(x + i);
        __m256 vy = _mm256_max_ps(zero, vx);
        _mm256_storeu_ps(y + i, vy);
    }
    for (; i < n; ++i) y[i] = (x[i] > 0) ? x[i] : 0.0f;
}

예제: 행렬-벡터 곱셈 (일부)

// y = A * x (A: rows x cols)
void matvec_avx2(const float* A, const float* x, float* y,
                 size_t rows, size_t cols) {
    for (size_t r = 0; r < rows; ++r) {
        __m256 vsum = _mm256_setzero_ps();
        size_t c = 0;
        for (; c + 8 <= cols; c += 8) {
            __m256 va = _mm256_loadu_ps(A + r * cols + c);
            __m256 vx = _mm256_loadu_ps(x + c);
            vsum = _mm256_fmadd_ps(va, vx, vsum);
        }
        float sum_arr[8];
        _mm256_storeu_ps(sum_arr, vsum);
        float sum = sum_arr[0] + sum_arr[1] + sum_arr[2] + sum_arr[3]
                 + sum_arr[4] + sum_arr[5] + sum_arr[6] + sum_arr[7];
        for (; c < cols; ++c) sum += A[r * cols + c] * x[c];
        y[r] = sum;
    }
}

AVX-512 간단 소개

AVX-512는 512비트 레지스터로 float 16개를 한 번에 처리합니다. 서버/워크스테이션 CPU에서 지원하며, _mm512_* 인트린직을 사용합니다.

#if defined(__AVX512F__)
#include <immintrin.h>
void add_float16_avx512(const float* a, const float* b, float* c, size_t n) {
    size_t i = 0;
    for (; i + 16 <= n; i += 16) {
        __m512 va = _mm512_loadu_ps(a + i);
        __m512 vb = _mm512_loadu_ps(b + i);
        _mm512_storeu_ps(c + i, _mm512_add_ps(va, vb));
    }
    for (; i < n; ++i) c[i] = a[i] + b[i];
}
#endif

주의: 일반 소비자용 Intel CPU(12세대 이후)는 대부분 AVX-512를 지원하지 않고, AMD는 Zen 4부터 지원합니다. 서버에서도 세대에 따라 클럭 영향이 다르므로(아래 “AVX-512 클럭 다운” 항목 참고) 대상 하드웨어에서 확인한 뒤 런타임 디스패치로만 사용하세요.


참고 자료


SIMD 명령어 세트별 요약

항목설명
개념SIMD = 한 명령으로 여러 데이터 동시 처리
AVX2256비트, float 8개. loadu/storeu로 정렬 불필요
NEONARM 128비트, float 4개
자동 벡터화-O3, 연속 접근, 의존성 없음이 유리
에러정렬, 나머지 처리, CPU 기능 검사
프로덕션CPU 디스패처, 폴백 경로, SIMD 라이브러리

핵심 원칙:

  1. 먼저 자동 벡터화를 시도하며, 실패 시 인트린직 사용
  2. 정렬되지 않은 데이터는 loadu/storeu 사용
  3. 배열 크기가 벡터 크기의 배수가 아니면 나머지 스칼라 처리
  4. 배포 시 CPU 기능 검사 및 폴백 구현 제공

SIMD 적용 전 점검 항목

  • -O3 -march=native (또는 구체적 타겟) 컴파일 옵션 설정
  • 정렬되지 않은 데이터는 _mm256_loadu_ps 사용
  • 나머지 원소 스칼라 처리
  • 프로덕션: CPU 기능 검사 및 폴백 경로
  • 벤치마크로 실제 환경에서 성능 측정
  • SoA 등 데이터 레이아웃 검토

자주 묻는 질문 (FAQ)

Q. 컴파일러가 루프를 벡터화했는지 어떻게 확인하나요?

A. GCC는 -fopt-info-vec-optimized(성공)와 -fopt-info-vec-missed(실패 이유), Clang은 -Rpass=loop-vectorize와 -Rpass-missed=loop-vectorize로 확인합니다. 더 확실한 방법은 Compiler Explorer에서 어셈블리를 보고 vaddps(packed, 여러 개)인지 vaddss(scalar, 하나)인지 확인하는 것입니다. 이름 끝의 ps/ss 차이가 벡터화 여부를 알려 줍니다.

Q. -O3로 컴파일했는데 float 합산 루프가 벡터화되지 않습니다.

A. float 덧셈은 결합 법칙이 성립하지 않아서, 컴파일러가 합산 순서를 바꾸면 결과가 비트 단위로 달라집니다. 그래서 GCC·Clang은 -O3에서도 부동소수점 리덕션(합·곱·min 등)을 기본적으로 벡터화하지 않습니다. -ffast-math(또는 -fassociative-math 계열)를 켜거나 루프에 #pragma omp simd reduction(+:sum)을 붙이면 벡터화되며, 결과가 스칼라 버전과 약간 달라질 수 있음을 받아들여야 합니다. 먼저 자동 벡터화가 되는지 확인하고(float 리덕션은 기본으로 안 됨), 필요한 곳만 인트린직이나 std::simd로 바꾸되, 배포 바이너리는 런타임 디스패치로 CPU 차이를 흡수하세요.


시리즈 네비게이션

이전 글: C++ 프로파일러 비교: perf 화염 그래프, gprof, Valgrind Callgrind, VTune, Tracy — perf·gprof·Valgrind·VTune·Tracy로 병목 찾기 다음 글: C++ 멀티스레딩 튜닝 #51-3


같이 보면 좋은 글