코드는 왜 4배 빨라지지 않았는가?
AVX2의 256비트 레지스터에는 8개의 float이 들어가지만, 이것은 한 벡터 명령의 lane 수이지 프로그램 전체의 가속 배수가 아니다. 실제 처리량은 연산 의존성, load/store 대역폭, gather·shuffle 비용, tail 처리, 컴파일러가 이미 만든 scalar/vector 코드, CPU의 실행 포트와 주파수 정책에 따라 달라지며 느려질 수도 있다. 컴파일러는 언제 자동 벡터화에 실패하는가? 그리고 그것을 어떻게 돕거나 우회할 수 있는가?
SIMD(Single Instruction, Multiple Data)는 현대 프로세서의 핵심 기능이지만, 그 힘을 제대로 끌어내려면 하드웨어, 컴파일러, 그리고 프로그래머가 함께 춤을 춰야 한다. 이 글은 SIMD의 역사적 맥락에서 시작해, 실제로 벡터화를 가로막는 장벽들을 하나씩 살펴본다.
1972년, Michael Flynn은 컴퓨터 아키텍처를 명령어 스트림(Instruction)과 데이터 스트림(Data)의 개수에 따라 네 가지로 분류했다.
SIMD는 데이터 병렬성(Data Parallelism)의 핵심이다. 같은 연산을 여러 데이터에 동시에 적용할 때 진가를 발휘한다.
// 스칼라 버전 (SISD)
for (int i = 0; i < n; i++) {
c[i] = a[i] + b[i];
}
// 벡터화 버전 (SIMD) - 개념적 표현
for (int i = 0; i < n; i += 8) {
vec8_c[i/8] = vec8_a[i/8] + vec8_b[i/8]; // 8개를 한 번에
}
이 단순한 개념이 왜 실제로는 복잡한가? 하드웨어의 진화와 함께 살펴보자.
x86 아키텍처의 SIMD는 끊임없이 진화해왔다.
Intel Pentium MMX에서 처음 등장. 64비트 레지스터(mm0-mm7) 8개를 제공했지만, FPU 레지스터를 재사용했기 때문에 부동소수점과 혼용할 수 없었다. 정수 연산만 지원했고, 주로 멀티미디어 처리에 사용되었다.
Pentium III에서 도입. 128비트 레지스터(xmm0–xmm7) 8개와 독립적인 실행 유닛을 제공(64비트 x86-64에서 xmm0–xmm15 16개로 확장). 부동소수점 SIMD가 가능해졌다.
// SSE: 128비트 = 4개의 float
__m128 a = _mm_load_ps(&arr[i]); // 4개 로드
__m128 b = _mm_load_ps(&arr[i+4]);
__m128 c = _mm_add_ps(a, b); // 4개 동시 덧셈
_mm_store_ps(&result[i], c); // 4개 저장
SSE2(2001)는 정수 연산을, SSE3(2004)와 SSSE3(2006)은 추가 명령어를, SSE4(2006-2008)는 더 많은 연산을 추가했다.
Sandy Bridge에서 도입. 레지스터 크기가 256비트(ymm0-ymm15)로 확장되었다. 중요한 변화는 VEX 인코딩을 도입해 3-operand 형식을 지원한 것이다.
// SSE: 2-operand (destination을 덮어씀)
addps xmm0, xmm1 // xmm0 = xmm0 + xmm1
// AVX: 3-operand (non-destructive)
vaddps ymm0, ymm1, ymm2 // ymm0 = ymm1 + ymm2
Haswell에서 도입. 정수 연산이 256비트로 확장되고 gather 명령어가 추가되었다.
// Gather: 불연속적인 메모리 접근
__m256i indices = _mm256_set_epi32(7, 5, 3, 1, 6, 4, 2, 0);
__m256 data = _mm256_i32gather_ps(base_ptr, indices, 4);
FMA(Fused Multiply-Add)는 AVX2와 별개의 확장 집합이다. Intel은 이를 FMA3로 분리해 별도 CPUID 비트(CPUID.01H:ECX.FMA[bit 12])로 표시하고, 컴파일러는 -mfma(GCC/Clang) 또는 /arch:AVX2(MSVC, AVX2와 함께 활성화)로 별도 켜야 한다. Haswell 이후 인텔 CPU는 AVX2와 FMA3를 함께 제공해 둘이 동의어처럼 쓰이지만, 사양상 별개라는 점이 정확하다.
// FMA: a = a * b + c (한 명령어, IEEE 754 정확도가 더 높음)
__m256 result = _mm256_fmadd_ps(a, b, c);
Xeon Phi와 Skylake-X에서 도입. 512비트 레지스터(zmm0-zmm31) 32개, 그리고 마스크 레지스터(k0-k7) 8개를 제공한다. 조건부 실행이 가능해졌다.
// 마스크를 사용한 조건부 연산
__mmask16 mask = _mm512_cmp_ps_mask(a, b, _CMP_GT_OQ); // a > b인 위치
__m512 result = _mm512_mask_add_ps(c, mask, a, b); // mask가 1인 곳만 덧셈
하지만 AVX-512는 논란이 많다. 전력 소비가 크고, CPU 클럭이 떨어지며, 칩 면적을 많이 차지한다. Intel은 최신 클라이언트 CPU(Alder Lake 이후)에서 AVX-512를 제거했다.
핵심 교훈은 더 넓은 SIMD가 항상 더 빠른 것은 아니라는 점이다. 전력, 클럭 다운, 메모리 대역폭, 그리고 실제 벡터화 가능성을 모두 고려해야 한다.
SIMD를 사용하는 방법은 세 가지다.
인트린직은 중간 지점이다. Intel Intrinsics Guide는 수천 개의 함수를 문서화하고 있다.
__m256 _mm256_add_ps(__m256 a, __m256 b);
// │ │ │ │
// │ │ │ └─ packed single (float)
// │ │ └──── 연산 (add)
// │ └────────── 256비트
// └───────────────── 데이터 타입 (__m256 = 8개의 float)
접미사 규칙:
ps: packed single (float)pd: packed doubleepi32: packed 32-bit integerssi256: 256-bit integer vector (타입 불특정)초기 SSE는 16바이트 정렬을 필수로 요구했다. 정렬되지 않은 메모리 접근은 segfault를 일으켰다.
// 정렬된 로드/저장 (16바이트 경계)
__m128 a = _mm_load_ps(ptr); // ptr은 반드시 16바이트 정렬
_mm_store_ps(ptr, a);
// 정렬되지 않은 로드/저장 (느림)
__m128 b = _mm_loadu_ps(ptr); // 어떤 주소든 가능, 성능 페널티
_mm_storeu_ps(ptr, b);
현대 CPU(Sandy Bridge 이후)에서는 loadu의 성능 페널티가 거의 사라졌다. 하지만 여전히 정렬된 접근이 선호되는 상황이 있다. 캐시 라인 경계를 넘지 않는 점, 컴파일러 최적화 힌트로 작용하는 점이 그것이다.
C++17의 std::aligned_alloc이나 alignas를 사용한다.
alignas(32) float data[1024]; // 32바이트 정렬 (AVX)
void* ptr = std::aligned_alloc(32, 1024 * sizeof(float));
플랫폼 주의: std::aligned_alloc은 C11/C++17 표준이지만 MSVC는 이를 제공하지 않는다(Windows에서는 _aligned_malloc(size, alignment)를 쓰고, 해제는 free가 아니라 _aligned_free로 해야 한다). 또한 표준 aligned_alloc은 size가 alignment의 배수여야 한다는 제약이 있다. C++17 이상이라면 operator new의 정렬 오버로드나 std::pmr 할당자가 이식성 있는 대안이다.
컴파일러(GCC, Clang, MSVC)는 점점 똑똑해지고 있다. 하지만 벡터화는 여전히 "될 때도 있고 안 될 때도 있다."
# GCC
gcc -O3 -march=native -fopt-info-vec-optimized -fopt-info-vec-missed
# Clang
clang -O3 -march=native -Rpass=loop-vectorize -Rpass-missed=loop-vectorize
# MSVC
cl /O2 /arch:AVX2 /Qvec-report:2
이 플래그들은 컴파일러가 무엇을 벡터화했고, 무엇을 실패했는지 알려준다.
// 벡터화됨: 단순한 map 연산
void add_arrays(float* a, float* b, float* c, int n) {
for (int i = 0; i < n; i++) {
c[i] = a[i] + b[i];
}
}
컴파일러는 이를 대략 이렇게 변환한다.
int 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_add_ps(va, vb);
_mm256_storeu_ps(&c[i], vc);
}
// 나머지 처리
for (; i < n; i++) {
c[i] = a[i] + b[i];
}
// 벡터화 불가: 이전 반복의 결과를 사용
for (int i = 1; i < n; i++) {
a[i] = a[i-1] + b[i]; // a[i]가 a[i-1]에 의존
}
각 반복이 이전 결과를 기다려야 하므로 병렬화가 불가능하다. 이런 패턴은 recurrence 또는 reduction으로 분류된다.
일부 reduction은 벡터화 가능하다.
// 벡터화 가능: associative & commutative
float sum = 0.0f;
for (int i = 0; i < n; i++) {
sum += a[i]; // 순서를 바꿔도 됨 (부동소수점 정밀도 제외)
}
컴파일러는 부분 합을 만들어 나중에 합친다.
__m256 vsum = _mm256_setzero_ps(); // 8개의 부분 합
for (int i = 0; i < n; i += 8) {
vsum = _mm256_add_ps(vsum, _mm256_loadu_ps(&a[i]));
}
// vsum의 8개 원소를 모두 더함
float sum = horizontal_sum(vsum);
// 벡터화 불확실: a와 b가 겹칠 수 있음
void add(float* a, float* b, int n) {
for (int i = 0; i < n; i++) {
a[i] = a[i] + b[i];
}
}
만약 a와 b가 같은 메모리를 가리키거나 겹친다면, 벡터화가 결과를 바꿀 수 있다. C99의 restrict 키워드는 이를 해결한다.
// 벡터화됨: 포인터가 겹치지 않음을 보장
void add(float* restrict a, float* restrict b, int n) {
for (int i = 0; i < n; i++) {
a[i] = a[i] + b[i];
}
}
restrict는 "이 포인터를 통해서만 해당 메모리에 접근한다"는 프로그래머의 약속이다. 거짓말하면 undefined behavior다.
C++에는 restrict가 없지만, GCC/Clang은 __restrict__를, MSVC는 __restrict를 제공한다.
// 벡터화 불가: 함수 호출
for (int i = 0; i < n; i++) {
c[i] = expensive_function(a[i], b[i]);
}
컴파일러는 expensive_function의 내부를 모를 수 있다. 해결책은 두 가지다.
inline 또는 -flto (Link Time Optimization)#pragma omp declare simd (OpenMP) 또는 SVML (Short Vector Math Library)// OpenMP SIMD 선언
#pragma omp declare simd
float expensive_function(float a, float b) {
return sqrtf(a * a + b * b);
}
#pragma omp simd
for (int i = 0; i < n; i++) {
c[i] = expensive_function(a[i], b[i]);
}
// 벡터화 어려움: 조건문
for (int i = 0; i < n; i++) {
if (a[i] > threshold) {
c[i] = a[i] * 2.0f;
} else {
c[i] = a[i] * 0.5f;
}
}
AVX-512의 마스크 연산이 없다면, 컴파일러는 두 경로를 모두 계산하고 선택(blending)한다.
for (int i = 0; i < n; i += 8) {
__m256 va = _mm256_loadu_ps(&a[i]);
__m256 mask = _mm256_cmp_ps(va, vthreshold, _CMP_GT_OQ);
__m256 v1 = _mm256_mul_ps(va, _mm256_set1_ps(2.0f));
__m256 v2 = _mm256_mul_ps(va, _mm256_set1_ps(0.5f));
__m256 vc = _mm256_blendv_ps(v2, v1, mask);
_mm256_storeu_ps(&c[i], vc);
}
이는 분기가 거의 50/50일 때 유리하다. 분기 예측이 잘 되는 상황(99% 한쪽)이라면 스칼라 버전이 더 빠를 수 있다.
// 벡터화 어려움: stride 접근
for (int i = 0; i < n; i++) {
c[i] = a[i*2] + b[i*3]; // 불규칙한 간격
}
이런 패턴은 gather/scatter나 셔플 명령어가 필요하며, 오버헤드가 크다.
메모리 레이아웃은 SIMD와 직접 연결된다.
struct Particle {
float x, y, z;
float vx, vy, vz;
};
Particle particles[1000];
// 벡터화 어려움: 인터리브된 데이터
for (int i = 0; i < 1000; i++) {
particles[i].x += particles[i].vx * dt;
particles[i].y += particles[i].vy * dt;
particles[i].z += particles[i].vz * dt;
}
메모리 레이아웃:
[x0 y0 z0 vx0 vy0 vz0][x1 y1 z1 vx1 vy1 vz1][x2 ...
x 값들이 흩어져 있어 한 번에 로드하기 어렵다.
struct Particles {
float x[1000];
float y[1000];
float z[1000];
float vx[1000];
float vy[1000];
float vz[1000];
};
Particles particles;
// 벡터화 완벽: 연속된 데이터
for (int i = 0; i < 1000; i++) {
particles.x[i] += particles.vx[i] * dt;
particles.y[i] += particles.vy[i] * dt;
particles.z[i] += particles.vz[i] * dt;
}
메모리 레이아웃:
[x0 x1 x2 x3 x4 x5 x6 x7 ...][y0 y1 y2 y3 ...
8개의 x 값을 한 번에 로드할 수 있다.
모든 데이터를 SoA로 바꿀 필요는 없다. Hot path의 데이터만 SoA로 변환해도 큰 효과를 본다.
타협안: 작은 그룹을 SoA로, 그룹의 배열로 만든다.
struct Particle8 {
float x[8], y[8], z[8];
float vx[8], vy[8], vz[8];
};
Particle8 particles[125]; // 125 * 8 = 1000
for (int group = 0; group < 125; group++) {
for (int i = 0; i < 8; i++) {
particles[group].x[i] += particles[group].vx[i] * dt;
// ...
}
}
이는 벡터화와 데이터 지역성을 모두 잡는다. 그룹 크기는 SIMD 폭(8 for AVX)에 맞춘다.
이론을 실전에 적용해보자. 벡터 내적(dot product)을 최적화한다.
float dot_scalar(const float* a, const float* b, int n) {
float sum = 0.0f;
for (int i = 0; i < n; i++) {
sum += a[i] * b[i];
}
return sum;
}
float dot_auto(const float* restrict a, const float* restrict b, int n) {
float sum = 0.0f;
for (int i = 0; i < n; i++) {
sum += a[i] * b[i];
}
return sum;
}
// 컴파일: gcc -O3 -march=native -ffast-math
restrict와 -ffast-math(부동소수점 재결합 허용)가 핵심이다.
float dot_avx2(const float* a, const float* b, int n) {
__m256 vsum = _mm256_setzero_ps(); // 8개의 부분 합
int 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); // vsum += va * vb
}
// 수평 합
__m128 lo = _mm256_castps256_ps128(vsum);
__m128 hi = _mm256_extractf128_ps(vsum, 1);
lo = _mm_add_ps(lo, hi);
lo = _mm_hadd_ps(lo, lo);
lo = _mm_hadd_ps(lo, lo);
float sum = _mm_cvtss_f32(lo);
// 나머지 처리
for (; i < n; i++) {
sum += a[i] * b[i];
}
return sum;
}
float dot_avx2_unroll(const float* a, const float* b, int n) {
__m256 vsum0 = _mm256_setzero_ps();
__m256 vsum1 = _mm256_setzero_ps();
__m256 vsum2 = _mm256_setzero_ps();
__m256 vsum3 = _mm256_setzero_ps();
int i = 0;
for (; i + 32 <= n; i += 32) {
__m256 va0 = _mm256_loadu_ps(&a[i]);
__m256 vb0 = _mm256_loadu_ps(&b[i]);
vsum0 = _mm256_fmadd_ps(va0, vb0, vsum0);
__m256 va1 = _mm256_loadu_ps(&a[i+8]);
__m256 vb1 = _mm256_loadu_ps(&b[i+8]);
vsum1 = _mm256_fmadd_ps(va1, vb1, vsum1);
__m256 va2 = _mm256_loadu_ps(&a[i+16]);
__m256 vb2 = _mm256_loadu_ps(&b[i+16]);
vsum2 = _mm256_fmadd_ps(va2, vb2, vsum2);
__m256 va3 = _mm256_loadu_ps(&a[i+24]);
__m256 vb3 = _mm256_loadu_ps(&b[i+24]);
vsum3 = _mm256_fmadd_ps(va3, vb3, vsum3);
}
// 4개의 부분 합을 합침
vsum0 = _mm256_add_ps(vsum0, vsum1);
vsum2 = _mm256_add_ps(vsum2, vsum3);
vsum0 = _mm256_add_ps(vsum0, vsum2);
// 수평 합 + 나머지 처리 (위와 동일)
// ...
}
누적 합 하나에는 FMA 결과가 같은 누적기의 입력으로 돌아오는 dependency chain이 생긴다. 독립 누적기를 여러 개 두면 CPU가 여러 연산을 겹쳐 FMA의 latency보다 throughput에 가까워질 수 있다. 필요한 누적기 수와 FMA latency/throughput은 마이크로아키텍처마다 다르며, 과도한 언롤은 레지스터 압박과 코드 크기를 늘린다.
scalar, 일반 최적화, 자동 벡터화, intrinsic, unroll 버전을 같은 데이터와 부동소수점 계약으로 비교한다. -ffast-math는 결합법칙 재배치, NaN/Inf·signed zero 처리 같은 의미를 바꾸므로 단지 “빠른 최적화 옵션”으로 baseline에 섞으면 안 된다. 정확한 합 순서가 필요한 버전과 재결합을 허용한 버전을 별도 결과로 둔다.
결과에는 CPU·컴파일러·플래그, vectorization report, 입력이 cache에 상주하는지, bytes/s와 FLOP/s를 기록한다. 메모리 대역폭이 포화되면 AVX 폭을 늘려도 속도가 비례하지 않고, 컴파일러가 같은 명령을 생성했다면 intrinsic의 이득도 없다.
Agner Fog의 Optimizing software in C++가 일관되게 권하는 순서도 같다. 먼저 측정하고, 자동 벡터화로 충분한지 확인한 뒤에야 인트린직의 유지보수 비용을 감수할 가치가 있는지 판단한다.
벡터화를 시도할 때 확인할 항목:
restrict, loop fission, 임시 변수.#pragma GCC ivdep // "ignore vector dependencies"
#pragma clang loop vectorize(enable)
#pragma omp simdARM도 SIMD를 가지고 있다. NEON은 128비트 고정 폭이지만, SVE(Scalable Vector Extension)는 가변 폭(128~2048비트)을 지원한다. 핵심은 벡터 길이가 컴파일 시점이 아니라 런타임 하드웨어에 의해 결정된다는 점이다. 코드는 길이를 모른 채 작성하고(VLA, vector length agnostic), 같은 바이너리가 128비트 단위로 다른 폭(128~2048비트)을 갖는 다양한 ARM 칩에서 그대로 작동한다.
// SVE: 벡터 길이를 모른 채 코드 작성
svfloat32_t va = svld1_f32(pg, &a[i]);
svfloat32_t vb = svld1_f32(pg, &b[i]);
svfloat32_t vc = svadd_f32_z(pg, va, vb);
svst1_f32(pg, &c[i], vc);
WASM도 128비트 SIMD를 지원한다(2021년부터 표준). 웹 브라우저에서도 고성능 계산이 가능해졌다.
SIMD와 GPU는 모두 데이터 병렬성을 다루지만, 규모가 다르다.
작은 데이터나 복잡한 제어 흐름은 SIMD가, 거대한 데이터와 단순한 연산은 GPU가 유리하다.
SIMD는 공짜 점심이 아니다. 하드웨어는 가능성을 제공하지만, 그 힘을 끌어내는 것은 프로그래머와 컴파일러의 협력에 달려 있다.
핵심 교훈은 다음과 같다. 컴파일러를 믿되 생성 코드를 확인한다. -O3와 대상 ISA 설정은 출발점이며, 벡터화 리포트를 읽어야 한다. 필요한 field만 밀집 순회할 때 SoA가 유리할 수 있지만 모든 field를 함께 쓰는 작업은 AoS가 나을 수 있다. alias와 loop-carried dependency를 제거하거나 알고리즘을 재설계한다. lane 수로 프로그램 가속 배수를 단정하지 않고 bandwidth, cache, 분기와 downclock을 함께 측정한다. 인트린직은 ISA 종속성과 유지 비용이 있으므로 자동 벡터화가 충분한 경로에서는 더 단순한 코드를 유지한다.
벡터화된 코드도 캐시 미스 앞에서는 무력하다. SIMD의 효과는 연산 폭만이 아니라 메모리 배치, 접근 패턴, prefetch, 캐시 라인 활용률이 함께 맞을 때 드러난다.