SIMD 벡터 연산
배열의 모든 원소에 같은 계산을 하는 반복문은 흔합니다. 두 배열을 원소끼리 더하거나, 이미지의 모든 픽셀 밝기를 올리거나, 소리 샘플에 같은 배율을 곱하는 일입니다. 일반(스칼라) 명령어는 한 번에 원소 한 개만 계산하므로, 원소가 1,000개이면 덧셈 명령을 1,000번 실행합니다. SIMD(Single Instruction, Multiple Data)는 명령어 하나로 여러 데이터에 같은 연산을 동시에 적용하는 방식입니다. 폭이 넓은 벡터 레지스터에 원소 여러 개를 나란히 담고, 덧셈 한 번으로 모든 칸을 함께 더합니다. 128비트 레지스터에 32비트 float을 담으면 4개가 한 번에 계산됩니다.
SIMD라는 이름은 마이클 플린(Michael J. Flynn)이 제안한 컴퓨터 분류(SISD, SIMD, MISD, MIMD)에서 왔습니다. 개인용 컴퓨터의 CPU에서는 x86의 SSE(128비트)에서 AVX(256비트), AVX-512(512비트)로 벡터 레지스터가 넓어졌고, Arm에는 128비트 NEON과 벡터 길이가 하드웨어마다 다를 수 있는 SVE가 있습니다. 멀티코어와 달리 SIMD는 한 코어 안에서 데이터 병렬성을 쓰는 방법이며, 코드를 크게 바꾸지 않고도 컴파일러가 루프를 벡터 명령으로 바꿔 주기도 합니다.
핵심 개념
SIMD와 플린 분류
플린 분류는 명령 흐름의 수와 데이터 흐름의 수로 컴퓨터를 나눕니다. SISD는 명령 하나가 데이터 하나를, SIMD는 명령 하나가 데이터 여러 개를 처리합니다. 일반 CPU의 벡터 확장 명령이 SIMD에 해당합니다.
핵심 개념
벡터 레지스터와 레인
벡터 레지스터는 같은 크기의 원소 여러 개를 칸(레인)으로 나눠 담습니다. 레인 수는 레지스터 폭을 원소 크기로 나눈 값입니다. 128비트 레지스터에는 32비트 float이 4개, 8비트 정수가 16개 들어갑니다. 연산은 같은 위치의 레인끼리 일어납니다.
핵심 개념
명령어 집합 확장
x86에는 SSE, AVX, AVX2, AVX-512가 있고, Arm에는 NEON과 SVE가 있으며, RISC-V에는 벡터 확장(RVV)이 있습니다. CPU마다 지원하는 확장이 다르므로 컴파일 옵션이나 실행 시점의 확인으로 쓸 수 있는 명령을 정합니다.
핵심 개념
자동 벡터화와 인트린식
컴파일러가 스칼라 루프를 벡터 명령으로 바꿔 주는 것이 자동 벡터화입니다. 더 세밀하게 제어하려면 _mm_add_ps 같은 인트린식 함수로 벡터 명령을 직접 씁니다. 인트린식은 CPU 종류에 묶이는 대신, 대응하는 명령이 문서에 대부분 적혀 있어 어떤 명령이 나갈지 짐작하기 쉽습니다. 일부는 여러 명령으로 풀리고, 컴파일러가 최적화로 결과를 바꿀 수도 있습니다.
핵심 개념
마스크와 조건문
벡터 연산은 모든 레인에 같은 명령을 실행하므로 레인마다 다른 경로로 분기할 수 없습니다. 대신 비교 결과를 레인별 참/거짓 마스크로 만들고, 마스크로 결과를 고릅니다. AVX-512의 opmask 레지스터와 Arm SVE의 predicate 레지스터는 레인별로 연산을 켜고 끄는 마스크를 명령에 지정할 수 있게 합니다.
핵심 개념
나머지 처리와 메모리 정렬
배열 길이가 레인 수의 배수가 아니면 끝에 남은 원소를 따로 처리해야 합니다. 메모리 정렬도 영향을 줍니다. 예를 들어 SSE의 _mm_load_ps는 16바이트로 정렬된 주소를 요구하고, _mm_loadu_ps는 정렬을 요구하지 않습니다.
SIMD의 배경
한 번에 여러 데이터를 처리하려는 시도는 벡터 슈퍼컴퓨터와 일리악 IV 같은 초기 SIMD 기계에서 먼저 쓰였고, 일반 CPU에는 비교적 짧은 길이의 벡터 레지스터와 전용 명령으로 들어왔습니다. 일반 CPU의 SIMD는 멀티미디어와 그래픽 처리를 빠르게 하려는 필요에서 확산됐고, 오늘날에는 과학 계산, 이미지·소리 처리, 딥러닝 라이브러리의 CPU 연산에서도 쓰입니다. Arm SVE는 벡터 폭이 바뀔 때마다 명령어 집합을 새로 만들 수 없다는 이유로 길이를 구현에 맡기고, RISC-V 벡터 확장도 벡터 길이(VLEN)가 다른 하드웨어에서 같은 프로그램이 이식 가능하게 실행되도록 설계됐습니다.
숫자로 확인하기
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
SIMD는 무엇이고 왜 빠른가요?
명령어 하나가 벡터 레지스터의 여러 레인에 같은 연산을 동시에 적용합니다. 같은 일을 하는 데 필요한 명령 수가 레인 수만큼 줄어서, 규칙적인 배열 연산에서 처리량이 늘어납니다.
면접 질문
벡터화하기 어려운 루프는 어떤 모양인가요?
앞 반복의 결과를 뒤 반복이 읽는 루프 간 의존성, 원소마다 분기가 크게 갈리는 코드, 연속되지 않은 주소를 읽는 접근이 대표적입니다. 의존성이 없다는 것을 컴파일러가 증명하지 못해도 벡터화되지 않습니다.
면접 질문
SIMD와 멀티스레딩은 어떻게 다른가요?
SIMD는 한 코어 안에서 명령 하나가 여러 데이터를 처리하는 데이터 병렬성이고, 멀티스레딩은 코어마다 독립적인 명령 흐름을 실행하는 병렬성입니다. 한 스레드 안에서 SIMD를 쓰고 스레드를 여러 개 두어 함께 쓸 수 있습니다.
면접 질문
배열 길이가 레인 수의 배수가 아니면 어떻게 하나요?
레인을 가득 채우는 반복을 먼저 돌리고, 남은 원소는 스칼라 반복으로 따로 처리하는 방법이 흔합니다. 마스크를 지원하는 명령어 집합에서는 남은 레인만 켜서 한 번에 처리할 수도 있습니다.
실제 시스템에서는
함께 보면 좋은 토픽
참고 자료
스칼라 루프와 SSE 인트린식 — 원소 4개씩 더하기
// 스칼라: 덧셈 명령 하나가 원소 한 쌍을 처리 for (int i = 0; i < n; i++) c[i] = a[i] + b[i]; // SSE: 128비트 레지스터에 float 4개를 담아 한 번에 더함 int i = 0; for (; i + 4 <= n; i += 4) { __m128 va = _mm_loadu_ps(a + i); __m128 vb = _mm_loadu_ps(b + i); _mm_storeu_ps(c + i, _mm_add_ps(va, vb)); } for (; i < n; i++) c[i] = a[i] + b[i]; // 4로 나누어떨어지지 않는 나머지
__m128은 float 4개를 담는 128비트 벡터 타입이고, _mm_add_ps 한 번이 네 쌍의 덧셈을 함께 수행합니다. 반복 횟수가 4분의 1로 줄어서 덧셈 명령도 그만큼 줄어듭니다. 배열 길이가 4의 배수가 아니면 끝에 남는 원소가 있으므로 마지막 스칼라 반복으로 따로 처리합니다. _mm_loadu_ps는 주소가 16바이트로 정렬돼 있지 않아도 읽을 수 있는 로드입니다. 정렬된 주소가 보장될 때 쓰는 _mm_load_ps는 정렬되지 않은 주소에 쓰면 안 됩니다.
벡터화되는 루프와 안 되는 루프
// 원소끼리 독립: 컴파일러가 벡터화할 수 있음 for (int i = 0; i < n; i++) a[i] = a[i] * 2 + b[i]; // 앞 반복의 결과를 읽음: a[i]를 계산하려면 a[i - 1]이 먼저 필요함 for (int i = 1; i < n; i++) a[i] = a[i - 1] + b[i]; // 확인: 벡터화된 루프를 컴파일러가 알려 줌 (GCC) gcc -O3 -fopt-info-vec -c sum.c
첫 루프는 i번째 계산이 다른 원소의 결과를 쓰지 않으므로 레인 4개를 동시에 계산해도 결과가 같습니다. 둘째 루프는 a[i]가 방금 계산한 a[i - 1]을 읽기 때문에 원소를 동시에 계산할 수 없고, 컴파일러도 벡터화하지 못합니다. 어떤 루프가 벡터화됐는지는 -fopt-info-vec 같은 보고 옵션으로 확인하는 것이 확실합니다. 함수가 포인터를 여럿 받을 때는, 한 포인터로 접근하는 객체를 다른 포인터로 접근하지 않겠다고 컴파일러에 약속하는 C의 restrict 한정자를 쓰면 별칭 때문에 막히던 벡터화가 가능해질 수 있습니다. 약속을 어기면 동작이 정의되지 않습니다.
조건문을 마스크로 — a가 양수면 a, 아니면 0
// 스칼라: 원소마다 분기 for (int i = 0; i < n; i++) r[i] = a[i] > 0 ? a[i] : 0; // SSE: 분기 없이 마스크로 선택 (4레인) __m128 zero = _mm_setzero_ps(); __m128 va = _mm_loadu_ps(a + i); __m128 mask = _mm_cmpgt_ps(va, zero); // a > 0 인 레인은 모든 비트가 1 __m128 res = _mm_and_ps(mask, va); // 마스크가 1인 레인만 a를 남김 _mm_storeu_ps(r + i, res);
_mm_cmpgt_ps는 레인마다 a > 0을 비교해 참이면 모든 비트가 1, 거짓이면 0인 마스크를 만듭니다. 이 마스크와 a를 _mm_and_ps로 비트 AND하면 양수인 레인은 값이 그대로 남고 나머지는 0이 되어 스칼라의 삼항 연산자와 같은 결과를 얻습니다. 모든 레인이 같은 명령을 실행하고 선택만 달라지므로 분기하지 않는 대신, 쓰지 않는 레인의 계산도 함께 실행됩니다. 작동 원리 탭의 "나머지와 조건문"에서 같은 예(3, -2, 5, -1)를 단계별로 볼 수 있습니다.
Q1
SIMD는 무엇의 약자일까?
Q2
배열의 원소 4개를 한 번에 더하는 SIMD 명령이 스칼라 반복문보다 빠른 이유는?
Q3
SIMD가 가장 효과적인 작업은?