루프가 병목일 때: 자동 벡터화, 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에서 크래시가 나요."
원인 후보
- 자동 벡터화 실패: 반복 간 의존성, 복잡한 분기, 간접 접근 → 컴파일러가 SIMD로 변환하지 못함
- 단일 스레드 실행:
std::transform등에 실행 정책을 넘기지 않아 한 코어만 사용 - 정렬 요구 위반:
_mm256_load_ps는 32바이트 정렬 필요. 비정렬 주소 사용 시 크래시 - 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_ps | 8개 float 로드 |
| 스토어 (비정렬) | _mm256_storeu_ps | 8개 float 저장 |
| 덧셈 | _mm256_add_ps | va + vb |
| 곱셈 | _mm256_mul_ps | va * vb |
| FMA | _mm256_fmadd_ps | va * 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;
}
}
같이 보면 좋은 글
- C++ SIMD 최적화: 자동 벡터화 조건, SSE·AVX 인트린식, 메모리 정렬
- C++26에서 달라지는 것
- C++26 프리뷰: Reflection과 신규 표준 라이브러리 제안들 [#44-1]
- C++ 캐시 효율적인 코드: 데이터 지향 설계 가이드
- time() 대신 std::chrono
- std::pmr로 메모리 할당 최적화하기
자주 묻는 질문 (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으로 외부 라이브러리 의존성 지옥 탈출