1. 개요
| SIMD Single Instruction, Multiple Data | |
|---|---|
| 분류 | 플린 분류(1966/1972)의 한 칸 |
| x86 | MMX → SSE → AVX/AVX2 → AVX-512 |
| Arm | NEON(128비트), SVE/SVE2(128~2048비트 가변) |
| RISC-V | V 확장(벡터 길이 가변) |
| 배정밀도 폭 | SSE2 2개, AVX 4개, AVX-512 8개 |
| GPU 쪽 친척 | SIMT (워프 단위 실행) |
루프를 한 바퀴씩 돌지 말고, 네 바퀴를 한 번에 돌아라.
SIMD(single instruction, multiple data, 단일 명령 다중 데이터)는 명령 하나가 레지스터에 나란히 담긴 여러 데이터 원소에 같은 연산을 동시에 적용하는 병렬 처리 방식이다. 마이클 플린이 1966년 제안하고 1972년 논문에서 다듬은 컴퓨터 분류(SISD·SIMD·MISD·MIMD)의 한 칸에서 이름이 왔다. 오늘날 이 말은 대개 CPU 코어 안의 벡터 명령어 확장을 가리킨다. 256비트 레지스터 하나에 배정밀도 실수 4개를 넣고 한 명령으로 4개를 더하는 식이다.
현대 CPU 의 부동소수점 피크는 거의 전부 SIMD 에서 나온다. 코어 하나가 사이클당 배정밀도 32 플롭을 낸다는 사양은 “AVX-512 레지스터 8칸 × FMA(곱+덧 = 2 플롭) × FMA 유닛 2개”를 곱한 값이다. 벡터화되지 않은 스칼라 코드는 같은 코어에서 그 1/8 천장에 갇힌다. 루프라인 모델로 말하면 SIMD 는 지붕을 위로 올리는 손잡이다. 이 문서는 명령어 세대, 수치 코드가 벡터화되는 조건과 막히는 이유, 그리고 시뮬레이션·게임 코드에서 데이터 배치를 바꾸는 법을 다룬다. 스레드·노드 단위 병렬화는 병렬 컴퓨팅과 MPI에, GPU 의 실행 모델은 GPU 컴퓨팅에 있다.
2. 명령어 세대
뿌리는 1970년대 크레이-1 같은 벡터 프로세서다. 이쪽은 긴 벡터를 파이프라인으로 흘려보냈고, 데스크톱 SIMD 는 그걸 짧은 고정 폭 레지스터로 축소한 것이다.
| 확장 | 등장 | 레지스터 폭 | 배정밀도 칸 | 비고 |
|---|---|---|---|---|
| MMX | 1997 | 64비트 | — | 정수 전용 |
| SSE | 1999 | 128비트 | — | 단정밀도 4개 |
| SSE2 | 2000 | 128비트 | 2 | x86-64 의 기본 부동소수점 경로 |
| AVX | 2011 | 256비트 | 4 | 3-피연산자 형식 |
| AVX2 + FMA3 | 2013 | 256비트 | 4 | 정수 256비트, 융합 곱셈-덧셈 |
| AVX-512 | 2016 | 512비트 | 8 | 마스크 레지스터, 산개(scatter) |
| Arm NEON | ARMv7 | 128비트 | 2 (AArch64) | 모바일의 기본 |
| Arm SVE | ARMv8.2 | 128~2048비트 | 구현마다 | 벡터 길이 비의존 |
SVE 는 설계 철학이 다르다. x86 확장은 폭이 바뀔 때마다 새 명령어 집합과 새 코드가 필요했지만, SVE 는 코드가 벡터 폭을 모르고도 돌도록(vector-length agnostic) 만들어서 같은 바이너리가 128비트 칩과 512비트 칩에서 모두 돈다. 루프 꼬리 처리도 술어(predicate) 레지스터로 자연스럽게 흡수한다. 후지쓰 A64FX(512비트 SVE)를 얹은 일본의 후가쿠가 2020년 슈퍼컴퓨터 TOP500 1위에 오르며 이 방식이 HPC 에서 검증됐다. RISC-V 의 V 확장도 같은 계열이다.
AVX-512 에는 유명한 함정이 있었다. 일부 인텔 서버 세대(스카이레이크-SP 등)는 512비트 명령을 많이 쓰면 전력 한도 때문에 클럭을 낮췄다. 벡터화한 구간은 빨라졌는데 그 뒤의 스칼라 코드까지 느린 클럭으로 돌아 전체가 오히려 느려지는 일이 생겼다. 그래서 컴파일러들이 한동안 512비트 대신 256비트를 기본으로 골랐다.1
3. 직접 쓰기 — 인트린식
컴파일러가 알아서 못 할 때는 인트린식(intrinsic)으로 명령을 직접 부른다. AVX2+FMA 로 daxpy()를 쓰면 이렇다.
#include <immintrin.h>
void daxpy(int n, double a, const double *x, double *y) {
__m256d va = _mm256_set1_pd(a); // a 를 4칸에 복사
int i = 0;
for (; i + 4 <= n; i += 4) {
__m256d vx = _mm256_loadu_pd(x + i);
__m256d vy = _mm256_loadu_pd(y + i);
_mm256_storeu_pd(y + i, _mm256_fmadd_pd(va, vx, vy));
}
for (; i < n; ++i) y[i] += a * x[i]; // 꼬리(4로 안 나눠지는 나머지)
}
이 코드에서 SIMD 프로그래밍의 세 요소가 다 보인다. 브로드캐스트(스칼라를 전 칸에 복사), 본 루프(한 번에 4칸), 꼬리 처리(나머지를 스칼라로). AVX-512 와 SVE 는 마스크로 꼬리를 한 번에 처리할 수 있다. 다만 daxpy 는 바이트당 연산이 너무 적어서(루프라인 모델의 표 참고) 배열이 캐시에 안 들어가면 벡터화해도 거의 안 빨라진다. SIMD 이득은 데이터가 캐시에 있거나 연산이 무거울 때 나온다.
4. 컴파일러는 언제 벡터화하나
실무에서 인트린식은 마지막 수단이고, 대부분은 자동 벡터화(auto-vectorization)에 맡긴다. GCC·Clang 은 -O3(GCC 12 부터는 -O2 에서도 일부)에서 루프를 벡터화하는데, 다음 조건을 증명할 수 있을 때만 한다.
- 반복 횟수를 셀 수 있다. 루프 안에서
break로 빠지거나 반복 횟수가 실행 중 바뀌면 곤란하다. - 반복 사이에 의존이 없다.
a[i] = a[i-1] * c같은 점화식은 이전 칸 결과가 있어야 다음 칸을 계산하므로 그대로는 못 나눈다. 호너 방법이나 누적 합이 대표적이다. 접두사 합처럼 결합법칙을 써서 다시 짜야 한다. - 포인터가 겹치지 않는다.
void f(double *x, double *y)에서 컴파일러는x와y가 같은 메모리를 가리킬 가능성을 배제하지 못한다. C 의restrict, 포트란의 배열 인자 의미론이 이 문제를 푼다. 포트란 수치 코드가 C 보다 벡터화가 잘 됐던 역사적 이유다. - 메모리 접근이 연속이다. 보폭(stride)이 1 이면 한 번에 적재하고, 아니면 모음(gather)을 써야 하는데 모음은 원소별 적재와 크게 다르지 않게 느리다.
- 분기가 단순하다.
if는 양쪽을 다 계산한 뒤 마스크로 고르는 블렌드로 바꾼다. 양쪽 다 싸면 이득이고, 한쪽이 무거우면 손해다.
컴파일러 보고서(-fopt-info-vec-missed, -Rpass-missed=loop-vectorize)가 왜 실패했는지 알려 주므로, 성능이 중요한 루프는 그걸 먼저 읽는 게 국룰이다.
4.1. 리덕션과 부동소수점 결합법칙
합계 를 벡터화하려면 각 칸이 부분합을 따로 쌓고 마지막에 칸끼리 더해야 한다. 그런데 이건 덧셈의 순서를 바꾸는 것이고, 부동소수점 덧셈은 결합법칙이 성립하지 않는다. 그래서 컴파일러는 -ffast-math(또는 -fassociative-math)나 OpenMP simd reduction 같은 허락 없이는 실수 리덕션을 벡터화하지 않는다.
얼마나 다를 수 있는지 극단적인 예로 보자. 을 배정밀도로 더하면
- 순서대로: ( 근처의 ULP 가 2 라서 이 으로 반올림된다)
- 2칸 SIMD(짝수 번째·홀수 번째 칸에 나눠 쌓기):
- 짝지어 더하기 :
세 답이 1, 2, 0 으로 모두 다르다(직접 돌려 확인). 실제 데이터에서 차이는 보통 마지막 몇 비트지만, 벡터 폭·스레드 수·컴파일 옵션이 바뀌면 결과가 비트 단위로 달라진다는 사실은 그대로다. 회귀 테스트를 비트 일치로 걸어 둔 시뮬레이션 코드가 새 CPU 에서 “깨지는” 흔한 원인이다. 정확도 자체는 칸별 부분합이 오히려 나은 경우가 많다(오차가 이 아니라 개 덧셈 사슬에서 쌓인다). 문제는 정확도가 아니라 재현성이다.2
5. 데이터 배치 — AoS 에서 SoA 로
SIMD 가 원하는 건 “같은 종류의 값이 메모리에 나란히” 있는 것이다. 입자 시뮬레이션에서 흔히 쓰는 구조체 배열(AoS, array of structures)은 그 반대다.
struct P { double x, y, z, vx, vy, vz, m; }; // AoS
struct P parts[N];
struct Ps { double *x, *y, *z, *vx, *vy, *vz, *m; }; // SoA
AoS 에서 모든 입자의 x 를 갱신하려면 56바이트 간격으로 흩어진 값을 모아야 한다. 배열 구조체(SoA, structure of arrays)로 바꾸면 x[i] += dt * vx[i] 가 보폭 1 연속 접근이 되어 그대로 벡터화된다. 캐시 라인에도 실제로 쓰는 필드만 실려 오므로 대역폭도 아낀다. 둘 사이 절충인 AoSoA(SIMD 폭만큼 묶은 작은 SoA 블록의 배열)는 지역성과 벡터화를 모두 잡으려는 배치로, 분자동역학 코드나 SPH 코드에서 자주 쓴다.
같은 논리가 다른 분야에서도 반복된다.
- 격자 볼츠만 방법: 격자점마다 분포 19개를 묶는 배치보다 방향별로 따로 저장하는 배치가 스트리밍 단계에서 벡터화된다.
- N체 문제: 직접 합산의 안쪽 루프는 상대 입자에 대해 독립이라 SIMD 의 교과서 예제다. 역제곱근을 근사 명령(
rsqrt)+뉴턴 1회로 바꾸는 게 국룰 최적화다. - 게임 엔진: 데이터 지향 설계와 ECS 아키텍처가 컴포넌트를 종류별 배열로 저장하는 것도 결국 SoA 다. 4칸 단정밀도 레지스터에 쿼터니언이나 3차원 벡터+패딩을 넣는 “수학 라이브러리 SIMD”는 손쉽지만 효율이 낮고, 같은 연산을 여러 객체에 동시에 하는 SoA 방식이 레지스터를 더 꽉 채운다.
6. SIMD 와 SIMT
GPU 도 내부적으로는 SIMD 기계다. NVIDIA GPU 는 32개 스레드(워프)가 한 명령을 함께 실행한다. 차이는 프로그래밍 모델에 있다. SIMT(single instruction, multiple threads)는 프로그래머에게 스레드 하나의 스칼라 코드를 쓰게 하고, 하드웨어가 워프 단위로 묶어 돌린다. 분기가 갈리면 하드웨어가 마스크를 씌워 양쪽을 차례로 실행한다(워프 발산). CPU SIMD 에서 프로그래머·컴파일러가 하던 마스킹과 블렌드를 하드웨어가 대신하는 셈이다. 그래서 위의 교훈, 즉 연속 접근(GPU 에서는 병합 접근(coalescing)), 분기 최소화, SoA 배치가 GPU 에서도 거의 그대로 통한다.3
7. 관련 문서
- 병렬 컴퓨팅 · GPU 컴퓨팅 · 루프라인 모델 · MPI
- BLAS · 행렬 곱셈 · 부동소수점 연산 · 호너 방법
- SPH · N체 문제 · 격자 볼츠만 방법
- 자동 벡터화 · 데이터 지향 설계 · OpenMP
8. Footnotes
-
리누스 토르발스가 2020년 리얼월드테크 포럼에서 AVX-512 에 대해 “고통스럽게 죽기를 바란다”고 쓴 건 유명한 일화다. 그 뒤 AMD 가 Zen 4 에서 클럭 하락 없이 AVX-512 를 지원하면서 평판이 슬그머니 회복됐다. 명령어 집합에도 인생 역전이 있다. ↩
-
“제 컴퓨터에선 1.0000000000000002 였는데 클러스터에선 1.0000000000000000 이에요” — 이 질문의 정답은 버그 리포트가 아니라 이 절이다. ↩
-
그래서 “CPU 코드를 SoA 로 바꿔 뒀더니 나중에 GPU 로 옮기는 게 쉬웠다”는 경험담이 흔하다. 데이터 배치는 하드웨어를 갈아타도 살아남는 몇 안 되는 투자다. ↩
이 문서의 주요 출처
의견을 반영할게요. 감사합니다.