C++ SIMD 최적화: 자동 벡터화 조건, SSE·AVX 인트린식, 메모리 정렬
이 글의 핵심
SIMD가 한 명령으로 여러 데이터를 처리하는 원리와, 컴파일러 자동 벡터화가 되는 조건·안 되는 조건을 정리합니다. SSE·AVX 인트린식으로 직접 벡터 연산을 작성하는 방법과 메모리 정렬, 루프 간 데이터 의존성 같은 벡터화 방해 요인을 다룹니다.
SIMD란?
SIMD(Single Instruction Multiple Data)는 하나의 CPU 명령으로 여러 데이터를 동시에 연산하는 병렬 처리 방식입니다. 일반적인 스칼라 연산은 배열의 각 원소를 하나씩 순서대로 처리하지만, SIMD 명령은 레지스터 하나에 4개, 8개, 심지어 16개의 값을 한꺼번에 담아 단 한 번의 명령으로 모두 더하거나 곱할 수 있습니다. 이런 특성 때문에 이미지 처리, 오디오/비디오 인코딩, 수치 시뮬레이션처럼 같은 연산을 배열 전체에 반복 적용하는 코드에서 SIMD는 특히 큰 성능 이득을 가져다줍니다. 아래 예시는 일반적인 스칼라 덧셈 루프와, 같은 연산을 SIMD 한 번으로 처리했을 때의 개념적 차이를 비교한 것입니다.
// 일반 연산
for (int i = 0; i < 4; ++i) {
c[i] = a[i] + b[i];
}
// SIMD: 한 번에 4개 처리
// c = a + b (벡터 연산)
자동 벡터화
SIMD를 활용하는 가장 손쉬운 방법은 컴파일러의 자동 벡터화 기능에 맡기는 것입니다. 특별한 인트린식이나 #pragma를 쓰지 않고 평범한 반복문만 작성해도, -O3나 -march=native 같은 최적화 플래그를 주면 GCC나 Clang이 반복문의 구조를 분석해 조건이 맞을 경우 자동으로 SIMD 명령으로 변환해 줍니다. 아래 add 함수는 겉보기에 평범한 스칼라 덧셈 루프지만, 배열 원소 사이에 의존성이 없고 메모리 접근이 연속적이기 때문에 컴파일러가 벡터화 가능하다고 판단하면 SSE나 AVX 명령으로 자동 변환됩니다. 자동 벡터화는 코드 이식성과 가독성을 해치지 않으면서도 상당한 성능 이득을 얻을 수 있어, 인트린식을 직접 쓰기 전에 항상 먼저 시도해 볼 가치가 있습니다.
// 컴파일러 자동 벡터화
void add(float* a, float* b, float* c, int n) {
for (int i = 0; i < n; ++i) {
c[i] = a[i] + b[i];
}
}
// -O3 -march=native 플래그
이 평범한 루프가 실제로 벡터화되었는지는 추측하지 말고 컴파일러에게 물어보는 것이 좋습니다. GCC는 -fopt-info-vec-optimized(성공)와 -fopt-info-vec-missed(실패 이유), Clang은 -Rpass=loop-vectorize와 -Rpass-missed=loop-vectorize로 루프마다 결과를 알려 줍니다. Compiler Explorer에서 어셈블리에 vaddps ymm 같은 명령이 보이는지 확인하는 방법도 빠릅니다.
이 add 함수에는 컴파일러가 신경 써야 하는 숨은 조건이 하나 있습니다. c가 a나 b와 겹치는 메모리를 가리킬 수 있다는 별칭(aliasing) 가능성입니다. 예를 들어 c == a + 1이면 한 반복의 쓰기가 다음 반복의 읽기에 영향을 주므로 여러 원소를 한꺼번에 처리하면 결과가 달라집니다. 그래서 컴파일러는 실행 시점에 포인터가 겹치는지 검사하는 코드를 넣고 벡터 버전과 스칼라 버전을 둘 다 만들거나(loop versioning), 검사 비용이 크다고 판단하면 벡터화를 포기합니다. 겹치지 않음이 확실하다면 매개변수에 __restrict(GCC·Clang·MSVC 확장)를 붙여 알려 주면 검사 없이 벡터화됩니다.
부동소수점 합계 루프는 또 다른 이유로 자동 벡터화가 안 되는 대표적인 경우입니다. sum += a[i]를 여러 레인으로 나눠 더하면 덧셈 순서가 바뀌는데, 부동소수점 덧셈은 결합 법칙이 성립하지 않아 결과가 마지막 비트에서 달라질 수 있습니다. 컴파일러는 기본적으로 이 차이를 허용하지 않으므로 합계 루프를 스칼라로 남기고, -ffast-math(또는 더 좁게 -fassociative-math)를 줘야 벡터화합니다. 다만 -ffast-math는 NaN·무한대 처리 가정까지 바꾸므로 전체 빌드에 켜기보다 해당 함수에만 적용하거나 #pragma omp simd reduction(+:sum)으로 그 루프만 허용하는 편이 안전합니다. 처음 SIMD 최적화를 시도할 때 “같은 코드인데 정수 합계는 빨라지고 float 합계만 그대로”인 현상이 바로 이것입니다.
실전 예시
자동 벡터화가 항상 원하는 만큼 효과적으로 동작하는 것은 아니기 때문에, 성능이 특히 중요한 코드에서는 개발자가 직접 SIMD 인트린식을 호출해 명시적으로 벡터 연산을 지시하는 경우가 많습니다. 아래 예시들은 SSE부터 AVX, 컴파일러 힌트, 메모리 정렬까지 인트린식을 다루는 대표적인 패턴을 순서대로 보여줍니다.
예시 1: 인트린식 (SSE)
SSE(Streaming SIMD Extensions)는 128비트 레지스터를 사용해 float 4개를 한 번에 처리하는 x86 명령어 집합입니다. _mm_loadu_ps는 정렬 여부를 신경 쓰지 않고 메모리에서 4개의 float를 __m128 레지스터로 읽어들이고, _mm_add_ps는 두 레지스터를 원소별로 더한 뒤, _mm_storeu_ps로 결과를 다시 메모리에 씁니다. 반복문의 증가값이 i += 4인 이유도 바로 한 번의 반복에서 4개씩 처리하기 때문이며, 이렇게 인트린식을 직접 호출하면 컴파일러의 자동 벡터화에 기대지 않고도 정확히 원하는 SIMD 명령이 생성되는 것을 보장할 수 있습니다. 반복 조건을 i < n이 아니라 i + 4 <= n으로 쓴 것에 주의해야 합니다. i < n; i += 4로 쓰면 n이 4의 배수가 아닐 때 마지막 반복이 배열 끝을 넘어 최대 3개 원소만큼 더 읽고 쓰는데, 읽기는 대개 조용히 지나가고 쓰기는 옆 변수를 덮어써서 전혀 다른 곳에서 버그가 드러납니다. AddressSanitizer를 켜고 길이가 4의 배수가 아닌 입력으로 테스트해 보면 바로 잡힙니다.
#include <immintrin.h>
void addSSE(float* a, float* b, float* c, int n) {
// i + 4 <= n: 남은 원소가 4개 미만이면 멈춤 (나머지는 아래 "문제 2"처럼 스칼라로 처리)
for (int i = 0; i + 4 <= n; i += 4) {
// 4개 float 로드
__m128 va = _mm_loadu_ps(&a[i]);
__m128 vb = _mm_loadu_ps(&b[i]);
// 벡터 덧셈
__m128 vc = _mm_add_ps(va, vb);
// 저장
_mm_storeu_ps(&c[i], vc);
}
}
예시 2: AVX
AVX(Advanced Vector Extensions)는 SSE의 128비트 레지스터를 256비트로 확장해, 한 번에 float 8개를 처리할 수 있는 명령어 집합입니다. 코드 구조는 SSE 버전과 거의 동일하지만 __m128이 __m256으로, _mm_로 시작하던 함수들이 _mm256_으로 바뀌고, 반복문의 증가폭도 4에서 8로 늘어난 것을 확인할 수 있습니다. 이론적으로는 SSE 대비 두 배의 처리량을 얻을 수 있지만, 실제 이득은 CPU가 AVX를 지원하는지, 그리고 메모리 대역폭이 병목이 되지 않는지에 따라 달라지므로 벤치마크로 실제 효과를 확인하는 것이 중요합니다.
#include <immintrin.h>
void addAVX(float* a, float* b, float* c, int n) {
for (int i = 0; i + 8 <= n; i += 8) { // 나머지 원소는 별도 처리 필요
// 8개 float 로드
__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);
}
}
예시 3: 벡터화 힌트
인트린식을 직접 쓰지 않고도 컴파일러에게 “이 반복문은 벡터화해도 안전하다”는 힌트를 주는 방법이 있습니다. #pragma omp simd는 OpenMP 표준의 일부로, 컴파일러가 자체 판단으로는 벡터화를 주저하는 경우에도 개발자가 원소 간 의존성이 없음을 보장한다고 명시적으로 알려주는 역할을 합니다. GCC 전용 #pragma GCC ivdep 역시 비슷한 목적으로, 반복문 안의 포인터들이 서로 겹치지 않는다는 것을 컴파일러에게 알려 벡터화를 가로막는 별칭(aliasing) 가능성 검사를 건너뛰게 합니다. 다만 이런 힌트는 컴파일러에 대한 “약속”이므로, 실제로 의존성이 있는 코드에 힌트를 잘못 붙이면 눈에 띄지 않는 계산 오류로 이어질 수 있어 신중하게 사용해야 합니다. 또 #pragma omp simd는 OpenMP 지원을 켜지 않으면 경고 없이 무시됩니다. GCC·Clang에서는 스레드 런타임 없이 SIMD 지시어만 활성화하는 -fopenmp-simd 옵션을 주어야 효과가 있으므로, pragma를 붙였는데 벡터화 리포트에 변화가 없다면 이 옵션부터 확인해 보는 것이 좋습니다. std::sqrt의 경우 errno 설정 가능성 때문에 벡터화가 막히기도 하는데, 이때는 -fno-math-errno가 도움이 됩니다.
// 컴파일러 힌트
void process(float* data, int n) {
#pragma omp simd
for (int i = 0; i < n; ++i) {
data[i] = std::sqrt(data[i]);
}
}
// 또는
void process2(float* data, int n) {
#pragma GCC ivdep
for (int i = 0; i < n; ++i) {
data[i] = std::sqrt(data[i]);
}
}
예시 4: 정렬
_mm256_load_ps처럼 이름에 u가 붙지 않은 “정렬 로드/스토어” 함수는 메모리 주소가 반드시 지정된 바이트 경계(AVX는 32바이트)에 맞춰져 있어야 동작합니다. alignas(32)를 붙여 배열을 선언하면 컴파일러가 해당 변수를 32바이트 경계에 맞춰 배치해 주므로, 이런 정렬 로드/스토어 명령을 안전하게 사용할 수 있습니다. 정렬된 접근은 일부 구세대 CPU나 특정 명령에서 비정렬 접근보다 더 빠르거나, 애초에 정렬되지 않은 주소에서는 동작 자체가 보장되지 않는 경우도 있어 성능과 안전성 두 측면에서 모두 신경 써야 할 부분입니다. 힙 메모리는 더 주의해야 합니다. malloc이나 new float[n]은 보통 16바이트 정렬만 보장하므로 AVX의 32바이트 정렬 로드에 그대로 쓰면 가끔만 크래시가 납니다. C++17의 std::aligned_alloc(32, size)(크기는 정렬값의 배수여야 함)이나 new (std::align_val_t{32}) float[n], 또는 정렬 할당자를 붙인 std::vector를 써야 합니다.
// 정렬된 데이터
alignas(32) float a[8] = {1, 2, 3, 4, 5, 6, 7, 8};
alignas(32) float b[8] = {8, 7, 6, 5, 4, 3, 2, 1};
alignas(32) float c[8];
// AVX 정렬 로드
__m256 va = _mm256_load_ps(a); // 정렬 필요
__m256 vb = _mm256_load_ps(b);
__m256 vc = _mm256_add_ps(va, vb);
_mm256_store_ps(c, vc);
SIMD 연산
x86 계열 SIMD 명령어 집합은 세대를 거듭하며 레지스터 폭이 계속 넓어져 왔습니다. SSE는 128비트 레지스터로 float 4개를, AVX는 256비트로 8개를, 최신 서버·워크스테이션 CPU에 탑재된 AVX-512는 512비트로 무려 16개를 한 번에 처리할 수 있습니다. 각 세대마다 덧셈(add), 뺄셈(sub), 곱셈(mul), 나눗셈(div) 같은 기본 산술 연산이 거의 동일한 이름 규칙(_mm_*_ps, _mm256_*_ps, _mm512_*_ps)으로 제공되므로, 한 세대의 함수 이름 패턴을 익히면 다른 세대로 옮겨갈 때도 큰 어려움 없이 적용할 수 있습니다. 다만 레지스터 폭이 넓어질수록 지원하는 CPU 세대가 줄어들기 때문에, 타깃 하드웨어의 지원 여부를 반드시 확인한 뒤 어떤 명령어 집합을 사용할지 정해야 합니다.
// SSE (128비트, 4개 float)
__m128 _mm_add_ps(a, b) // 덧셈
__m128 _mm_sub_ps(a, b) // 뺄셈
__m128 _mm_mul_ps(a, b) // 곱셈
__m128 _mm_div_ps(a, b) // 나눗셈
// AVX (256비트, 8개 float)
__m256 _mm256_add_ps(a, b)
__m256 _mm256_sub_ps(a, b)
__m256 _mm256_mul_ps(a, b)
__m256 _mm256_div_ps(a, b)
// AVX-512 (512비트, 16개 float)
__m512 _mm512_add_ps(a, b)
자주 발생하는 문제
SIMD 코드는 일반 스칼라 코드보다 훨씬 더 하드웨어의 세부 사항에 민감합니다. 아래 네 가지는 SIMD를 처음 도입할 때 가장 자주 마주치는 함정입니다.
문제 1: 정렬
정렬 로드 함수(_mm256_load_ps 등)에 정렬되지 않은 주소를 넘기면 최악의 경우 세그멘테이션 폴트로 프로그램이 즉시 종료됩니다. 이런 문제는 스택이나 힙에서 alignas 없이 선언한 배열을 그대로 정렬 로드 함수에 넘길 때 흔히 발생하는데, 컴파일러가 항상 경고해 주는 것도 아니라서 런타임에야 발견되는 경우가 많습니다. 가장 안전한 해법은 데이터를 애초에 alignas(32)처럼 필요한 정렬 조건에 맞춰 선언하거나, 정렬을 보장할 수 없는 상황이라면 처음부터 비정렬 로드/스토어 함수(_mm256_loadu_ps 등)를 사용하는 것입니다. 비정렬 함수는 정렬 함수보다 약간의 성능 손해가 있을 수 있지만, 최신 CPU에서는 그 차이가 미미한 경우가 많아 안전성을 우선하는 편이 합리적입니다.
// ❌ 비정렬 데이터
float a[8] = {1, 2, 3, 4, 5, 6, 7, 8};
__m256 va = _mm256_load_ps(a); // 세그폴트 가능
// ✅ 정렬
alignas(32) float a[8] = {1, 2, 3, 4, 5, 6, 7, 8};
__m256 va = _mm256_load_ps(a);
// ✅ 비정렬 로드
float b[8] = {1, 2, 3, 4, 5, 6, 7, 8};
__m256 vb = _mm256_loadu_ps(b);
문제 2: 나머지
SIMD 레지스터는 고정된 개수의 원소만 처리하기 때문에, 배열의 길이가 벡터 폭(예: AVX의 8)으로 나누어떨어지지 않으면 마지막 몇 개의 원소가 처리되지 않고 남는 문제가 생깁니다. 아래 addSIMD 함수는 이 문제를 두 단계로 나눠 해결하는데, 먼저 i + 8 <= n 조건으로 8개씩 처리 가능한 구간까지는 SIMD로 빠르게 처리하고, 남은 원소들은 평범한 스칼라 반복문으로 하나씩 마무리합니다. 이렇게 SIMD 구간과 스칼라 나머지 구간을 나누는 패턴은 실무 SIMD 코드에서 거의 항상 등장하므로, 처음부터 이 구조를 템플릿처럼 익혀두면 다른 벡터화 코드를 작성할 때도 그대로 재사용할 수 있습니다.
void addSIMD(float* a, float* b, float* c, int n) {
int i = 0;
// SIMD (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];
}
}
문제 3: 의존성
벡터화가 불가능한 가장 흔한 이유는 반복문 안에서 이전 반복의 결과에 현재 반복이 의존하는 데이터 의존성입니다. 아래 첫 번째 루프는 a[i] = a[i-1] + b[i]처럼 각 반복이 직전 반복에서 계산된 a[i-1] 값을 필요로 하기 때문에, 여러 원소를 동시에 계산하는 SIMD 방식과는 근본적으로 맞지 않아 컴파일러도 자동 벡터화를 포기합니다. 반면 두 번째 루프처럼 c[i] = a[i] + b[i]는 각 반복이 완전히 독립적이어서 8개, 16개를 동시에 계산해도 결과가 순서와 무관하게 동일하므로 벡터화하기에 이상적인 형태입니다. 그래서 SIMD 최적화를 염두에 둔 코드를 작성할 때는 반복문 사이의 데이터 의존성을 최대한 제거하거나, 의존성이 있는 부분과 없는 부분을 구조적으로 분리하는 것이 중요합니다.
// ❌ 의존성 있음 (벡터화 불가)
for (int i = 1; i < n; ++i) {
a[i] = a[i-1] + b[i]; // a[i-1] 의존
}
// ✅ 독립적 (벡터화 가능)
for (int i = 0; i < n; ++i) {
c[i] = a[i] + b[i];
}
문제 4: 플랫폼
인트린식을 직접 사용하는 SIMD 코드는 CPU 아키텍처에 강하게 종속됩니다. x86 계열에서는 SSE·AVX·AVX-512 인트린식을 사용하지만, ARM 기반 CPU(애플 실리콘, 다수의 모바일·서버 칩 포함)에서는 전혀 다른 명령어 집합인 NEON을 사용해야 하므로, x86용으로 작성한 인트린식 코드는 ARM에서 아예 컴파일조차 되지 않습니다. 같은 x86 안에서도 -mavx2 같은 컴파일 플래그로 활성화한 명령어를 지원하지 않는 구형 CPU에서 실행하면 “잘못된 명령(illegal instruction)” 오류로 즉시 크래시가 발생할 수 있습니다. 그래서 여러 플랫폼을 지원해야 하는 코드베이스에서는 아래처럼 __AVX2__, __SSE4_2__ 같은 매크로로 컴파일 타임에 지원 여부를 분기하고, 어떤 매크로도 정의되지 않은 환경을 위한 스칼라 폴백(fallback) 경로를 항상 함께 준비해 두는 것이 안전합니다.
// SIMD는 플랫폼 의존
// - x86: SSE, AVX, AVX-512
// - ARM: NEON
// - 컴파일 플래그: -msse4.2, -mavx2
#ifdef __AVX2__
// AVX2 코드
#elif defined(__SSE4_2__)
// SSE4.2 코드
#else
// 스칼라 코드
#endif
이 #ifdef 분기는 컴파일할 때 결정된다는 한계가 있습니다. 배포용 바이너리는 보통 구형 CPU에서도 돌아가도록 기본 x86-64(SSE2)로 빌드하므로, 이 방식으로는 사용자의 CPU가 AVX2를 지원해도 AVX2 경로가 쓰이지 않습니다. 한 바이너리에서 CPU에 맞는 경로를 고르려면 런타임 디스패치가 필요합니다. GCC·Clang은 __builtin_cpu_supports("avx2")로 실행 중에 기능을 확인하고, 함수에 __attribute__((target("avx2")))를 붙여 그 함수만 AVX2로 컴파일할 수 있으며, __attribute__((target_clones("avx2", "default")))를 쓰면 컴파일러가 여러 버전을 만들고 시작 시점에 자동으로 골라 줍니다. 여러 아키텍처를 한 코드로 다루고 싶다면 Highway나 xsimd 같은 이식성 SIMD 라이브러리, 또는 C++26에 채택된 std::simd(현재는 std::experimental::simd로 일부 표준 라이브러리에서 사용 가능)를 검토할 수 있습니다.
벡터화 조건
지금까지 다룬 문제들을 종합하면, 어떤 반복문이 벡터화 가능한지 판단하는 기준을 정리할 수 있습니다. 각 반복이 이전 반복 결과에 의존하지 않고, 배열 원소에 연속적인 메모리 패턴으로 접근하며, 반복문 안에 조건 분기나 부작용이 있는 함수 호출이 없어야 컴파일러가 안전하게 벡터화를 적용할 수 있습니다. 반대로 반복 간 의존성이 있거나, 반복마다 다른 경로로 분기하거나, 예측할 수 없는 부작용을 가진 함수를 호출하는 반복문은 자동 벡터화 대상에서 제외되는 경우가 대부분입니다. 새로운 반복문을 작성할 때 이 조건들을 미리 염두에 두면, 나중에 벡터화 리포트를 보며 왜 최적화가 적용되지 않았는지 역추적하는 수고를 크게 줄일 수 있습니다.
// 벡터화 가능:
// 1. 독립적 반복
// 2. 연속 메모리
// 3. 단순 연산
// 벡터화 불가:
// 1. 의존성
// 2. 분기
// 3. 함수 호출
FAQ
Q1: float 배열 합계 루프만 자동 벡터화가 안 됩니다.
A: 부동소수점 덧셈은 결합 법칙이 성립하지 않아, 레인별로 나눠 더하면 결과가 달라질 수 있기 때문입니다. -ffast-math/-fassociative-math를 주거나 #pragma omp simd reduction(+:sum)(-fopenmp-simd 필요)으로 해당 루프만 허용하세요.
Q2: #pragma omp simd를 붙였는데 아무 변화가 없습니다.
A: OpenMP 지원이 꺼져 있으면 pragma는 조용히 무시됩니다. -fopenmp-simd(또는 -fopenmp)를 켜고 -fopt-info-vec·-Rpass=loop-vectorize로 결과를 확인하세요.
Q3: AVX2로 빌드한 프로그램이 일부 서버에서 “Illegal instruction”으로 죽습니다.
A: 그 CPU가 AVX2를 지원하지 않기 때문입니다. 기본 빌드는 구형 ISA로 하고, 핫 루프만 target·target_clones 속성과 __builtin_cpu_supports로 런타임에 골라 쓰는 방식이 안전합니다.
Q4: 인트린식 루프가 가끔 옆 변수 값을 망가뜨립니다.
A: 반복 조건이 i < n; i += 8이면 길이가 8의 배수가 아닐 때 배열 끝을 넘어 씁니다. i + 8 <= n으로 바꾸고 나머지는 스칼라로 처리하세요.
Q5: 성능?
A: SSE는 float 4개, AVX는 8개를 한 명령으로 처리하므로 이론상 최대 이득은 레인 수만큼입니다. 실제로는 메모리 대역폭(데이터가 캐시에 없으면 연산보다 로드가 병목), 정렬되지 않은 로드, 레인 수로 나누어떨어지지 않는 루프 끝부분 처리 때문에 이보다 작고, 컴파일러가 이미 자동 벡터화한 루프라면 인트린식으로 바꿔도 차이가 거의 없습니다. 적용 전후를 같은 입력으로 측정해 확인하세요.
같이 보면 좋은 글
- C++ SIMD와 병렬화: std::execution과 인트린직 가이드
- C++ 캐시 최적화 실전 | 캐시 친화적 구조·프리페치·False Sharing·AoS vs SoA 가이드
- C++17 병렬 알고리즘: std::execution::par·par_unseq로 sort·transform·reduce 병렬화할 때 주의점
- C++ SIMD 최적화: SSE·AVX2·AVX-512·NEON 인트린직, 자동 벡터화, C++26 std::simd
- C++ 정렬 알고리즘 구현과 비교: std::sort의 pdqsort, stable_sort, 병렬 정렬, 기수 정렬
- C++ Benchmarking