C++ SIMD | '벡터 연산' 가이드 | 핵심 개념과 실전 활용
이 글의 핵심
C++ SIMD - "벡터 연산" 가이드. C++ SIMD의 SIMD란?, 자동 벡터화, 실전 예시를 실전 코드와 함께 설명합니다.
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 플래그
실전 예시
자동 벡터화가 항상 원하는 만큼 효과적으로 동작하는 것은 아니기 때문에, 성능이 특히 중요한 코드에서는 개발자가 직접 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 명령이 생성되는 것을 보장할 수 있습니다.
#include <immintrin.h>
void addSSE(float* a, float* b, float* c, int n) {
for (int i = 0; i < 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 < 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) 가능성 검사를 건너뛰게 합니다. 다만 이런 힌트는 컴파일러에 대한 “약속”이므로, 실제로 의존성이 있는 코드에 힌트를 잘못 붙이면 눈에 띄지 않는 계산 오류로 이어질 수 있어 신중하게 사용해야 합니다.
// 컴파일러 힌트
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나 특정 명령에서 비정렬 접근보다 더 빠르거나, 애초에 정렬되지 않은 주소에서는 동작 자체가 보장되지 않는 경우도 있어 성능과 안전성 두 측면에서 모두 신경 써야 할 부분입니다.
// 정렬된 데이터
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
벡터화 조건
지금까지 다룬 문제들을 종합하면, 어떤 반복문이 벡터화 가능한지 판단하는 기준을 정리할 수 있습니다. 각 반복이 이전 반복 결과에 의존하지 않고, 배열 원소에 연속적인 메모리 패턴으로 접근하며, 반복문 안에 조건 분기나 부작용이 있는 함수 호출이 없어야 컴파일러가 안전하게 벡터화를 적용할 수 있습니다. 반대로 반복 간 의존성이 있거나, 반복마다 다른 경로로 분기하거나, 예측할 수 없는 부작용을 가진 함수를 호출하는 반복문은 자동 벡터화 대상에서 제외되는 경우가 대부분입니다. 새로운 반복문을 작성할 때 이 조건들을 미리 염두에 두면, 나중에 벡터화 리포트를 보며 왜 최적화가 적용되지 않았는지 역추적하는 수고를 크게 줄일 수 있습니다.
// 벡터화 가능:
// 1. 독립적 반복
// 2. 연속 메모리
// 3. 단순 연산
// 벡터화 불가:
// 1. 의존성
// 2. 분기
// 3. 함수 호출
FAQ
Q1: SIMD는?
A: 벡터 연산. 병렬 처리.
Q2: 종류?
A: SSE, AVX, AVX-512, NEON.
Q3: 자동 벡터화?
A: 컴파일러 최적화. -O3.
Q4: 인트린식?
A: 명시적 SIMD. 플랫폼 의존.
Q5: 성능?
A: 2~8배 향상 가능.
Q6: 학습 리소스는?
A:
- “Optimized C++”
- “Intel Intrinsics Guide”
- cppreference.com
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ SIMD와 병렬화: std::execution과 인트린직 가이드
- C++ Cache Optimization | “캐시 최적화” 가이드
- C++ Parallel Algorithms | “병렬 알고리즘” 가이드
관련 글
- C++ SIMD와 병렬화: std::execution과 인트린직 가이드
- C++ SIMD 최적화 실전 | SSE·AVX2·NEON 인트린직으로 4배 빠르게 [#51-2]
- C++ Algorithm Sort
- C++ Benchmarking
- C++ Cache Optimization
이 글에서 다루는 키워드 (관련 검색어)
C++, SIMD, vectorization, SSE, performance 등으로 검색하시면 이 글이 도움이 됩니다.