컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
arch · 14

SIMD 벡터 연산

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는 정렬을 요구하지 않습니다.

이득이 큰 곳은 규칙적인 데이터 배열입니다 — 같은 연산을 많은 원소에 반복하는 이미지·오디오 처리와 행렬 계산이 대표적입니다. 원소를 하나씩 읽는 순서가 정해져 있고 원소끼리 서로 기다리지 않을수록 잘 맞습니다.
이론상 상한은 레인 수입니다 — 레인이 4개이면 덧셈 명령 수가 최대 4분의 1이 됩니다. 실제 실행 시간은 메모리에서 데이터를 가져오는 속도와 나머지 처리 때문에 이보다 덜 줄 수 있습니다. 데이터가 도착하지 않으면 연산 폭이 넓어도 기다립니다.
앞 결과를 뒤 반복이 읽으면 벡터화할 수 없습니다 — a[i] = a[i - 1] + b[i]처럼 바로 앞 원소의 결과가 필요한 루프(루프 간 의존성)는 원소를 동시에 계산할 수 없습니다. 두 포인터가 같은 메모리를 가리킬 수 있는 경우에도 컴파일러는 안전하게 벡터화를 포기할 수 있습니다.
분기가 많거나 접근이 불규칙하면 불리합니다 — 원소마다 처리 경로가 크게 다르거나 연속되지 않은 주소를 읽는 작업은 벡터 레지스터를 채우기 어렵습니다. 흩어진 주소를 한 번에 읽는 gather 명령이 있지만 연속된 메모리를 읽을 때만큼 효율적이라고 기대하기는 어렵습니다.
이 시각화에서 확인할 것 — "스칼라 vs SIMD" 탭에서 같은 덧셈에 필요한 명령 수의 차이를, "레지스터 폭과 레인" 탭에서 폭과 원소 크기에 따라 레인 수가 달라지는 모습을, "나머지와 조건문" 탭에서 나머지 원소와 마스크 처리를 STEP으로 확인하세요.

SIMD의 배경

한 번에 여러 데이터를 처리하려는 시도는 벡터 슈퍼컴퓨터와 일리악 IV 같은 초기 SIMD 기계에서 먼저 쓰였고, 일반 CPU에는 비교적 짧은 길이의 벡터 레지스터와 전용 명령으로 들어왔습니다. 일반 CPU의 SIMD는 멀티미디어와 그래픽 처리를 빠르게 하려는 필요에서 확산됐고, 오늘날에는 과학 계산, 이미지·소리 처리, 딥러닝 라이브러리의 CPU 연산에서도 쓰입니다. Arm SVE는 벡터 폭이 바뀔 때마다 명령어 집합을 새로 만들 수 없다는 이유로 길이를 구현에 맡기고, RISC-V 벡터 확장도 벡터 길이(VLEN)가 다른 하드웨어에서 같은 프로그램이 이식 가능하게 실행되도록 설계됐습니다.

숫자로 확인하기

이 시각화의 8개 덧셈 — 스칼라 방식은 덧셈 명령이 8개이고, 128비트 SIMD(float 4레인)는 8 ÷ 4 = 2개입니다. 같은 일을 하는 덧셈 명령이 4분의 1로 줄어듭니다.
float 1,024개를 더하는 경우 — 128비트(4레인)는 1,024 ÷ 4 = 256개, 256비트(8레인)는 128개, 512비트(16레인)는 64개의 벡터 덧셈이 필요합니다. 스칼라는 1,024개입니다. 로드와 저장 명령은 계산에 넣지 않은 단순한 계산입니다.
작은 원소일수록 레인이 늘어납니다 — 1920 × 1080 크기의 8비트 한 채널 이미지는 픽셀이 2,073,600개입니다. 128비트 레지스터에 8비트 값이 16개 들어가므로 2,073,600 ÷ 16 = 129,600번의 벡터 연산으로 한 번 훑을 수 있습니다.
나머지가 생기는 경우 — 원소 10개를 4레인으로 처리하면 벡터 연산이 2번(원소 8개)이고, 남은 2개는 스칼라로 처리하면 2번이라 연산이 모두 4번입니다. 원소를 하나씩 처리하면 10번입니다.

자주 하는 오해

SIMD는 멀티스레딩과 같다 — SIMD는 한 코어의 명령 하나가 여러 데이터를 다루는 방식이고, 멀티스레딩은 코어(또는 스레드)마다 따로 명령을 실행하는 방식입니다. 둘은 겹치지 않으며 함께 쓸 수 있습니다.
레인 수만큼 항상 빨라진다 — 벡터화는 명령 수를 줄이지만 메모리에서 옮겨야 하는 데이터의 양은 그대로입니다. 그래서 메모리 대역폭이 병목인 작업은 레인 수만큼 빨라지지 않는 경우가 많고, 나머지 처리와 정렬되지 않은 데이터도 이득을 깎습니다.
컴파일러가 모든 루프를 알아서 벡터화한다 — 컴파일러는 루프 간 의존성, 포인터 별칭, 복잡한 분기를 만나면 벡터화하지 못할 수 있습니다. 어떤 루프가 벡터화됐는지는 컴파일러가 주는 보고 옵션으로 확인합니다.
SIMD는 GPU에만 있는 개념이다 — 일반 CPU에도 벡터 확장 명령이 있습니다. NVIDIA GPU는 스레드 32개 묶음(워프)이 한 번에 같은 명령을 실행하는 SIMT를 씁니다. SIMD와 비슷하지만 스레드마다 분기할 수 있고, 갈라지면 효율이 떨어집니다.

면접에서 이렇게 물어요

면접 질문

SIMD는 무엇이고 왜 빠른가요?

명령어 하나가 벡터 레지스터의 여러 레인에 같은 연산을 동시에 적용합니다. 같은 일을 하는 데 필요한 명령 수가 레인 수만큼 줄어서, 규칙적인 배열 연산에서 처리량이 늘어납니다.

면접 질문

벡터화하기 어려운 루프는 어떤 모양인가요?

앞 반복의 결과를 뒤 반복이 읽는 루프 간 의존성, 원소마다 분기가 크게 갈리는 코드, 연속되지 않은 주소를 읽는 접근이 대표적입니다. 의존성이 없다는 것을 컴파일러가 증명하지 못해도 벡터화되지 않습니다.

면접 질문

SIMD와 멀티스레딩은 어떻게 다른가요?

SIMD는 한 코어 안에서 명령 하나가 여러 데이터를 처리하는 데이터 병렬성이고, 멀티스레딩은 코어마다 독립적인 명령 흐름을 실행하는 병렬성입니다. 한 스레드 안에서 SIMD를 쓰고 스레드를 여러 개 두어 함께 쓸 수 있습니다.

면접 질문

배열 길이가 레인 수의 배수가 아니면 어떻게 하나요?

레인을 가득 채우는 반복을 먼저 돌리고, 남은 원소는 스칼라 반복으로 따로 처리하는 방법이 흔합니다. 마스크를 지원하는 명령어 집합에서는 남은 레인만 켜서 한 번에 처리할 수도 있습니다.

실제 시스템에서는

컴파일러의 자동 벡터화 — GCC 문서에 따르면 -ftree-vectorize는 루프 벡터화와 SLP 벡터화를 켭니다. 이 옵션은 -O3에서 켜지고, GCC 12부터는 -O2에서도 제한된 비용 모델(very-cheap)로 켜집니다. -fopt-info-vec 옵션으로 벡터화된 루프를 보고받을 수 있습니다.
NumPy — NumPy 문서는 일부 연산이 SIMD 명령으로 가속되며, 실행 중인 CPU가 지원하는 명령어 집합을 확인해 알맞은 구현을 고르는 구조를 설명합니다.
자바 Vector API — OpenJDK의 JEP 448은 지원하는 CPU에서 최적의 벡터 명령으로 컴파일되도록 설계된 Vector API(jdk.incubator.vector)를 인큐베이터 모듈로 소개합니다.
메모리와의 관계 — 벡터 연산이 넓어질수록 데이터를 얼마나 빨리 공급하느냐가 중요해집니다. 캐시 메모리 토픽에서 데이터가 가까이 있을 때와 아닐 때의 차이를 봅니다.

함께 보면 좋은 토픽

암달의 법칙 — 코드의 일부만 벡터화할 때 전체 성능 향상이 제한되는 이유를 봅니다.
파이프라이닝 — 명령어를 겹쳐 실행하는 또 다른 병렬화 방식을 봅니다.
비순차 실행 — 독립된 명령어를 앞당겨 실행하는 명령어 수준 병렬성을 봅니다.
캐시 메모리 — 벡터 연산에 데이터를 공급하는 메모리 계층을 봅니다.
CPU 구성 요소 — 레지스터와 연산 장치가 어떻게 이루어지는지 봅니다.

참고 자료

명령어 집합 문서 — Intel Intrinsics Guide, Arm Developer의 Neon · Scalable Vector Extensions 문서
논문 — Flynn, "Very High-Speed Computing Systems", Proceedings of the IEEE 54(12), 1966
교재 — Hennessy, Patterson, "Computer Architecture: A Quantitative Approach", "Data-Level Parallelism in Vector, SIMD, and GPU Architectures" 장

스칼라 루프와 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는 무엇의 약자일까?

하나의 명령으로 여러 데이터를 처리한다는 뜻입니다.
SIMD는 Single Instruction, Multiple Data의 약자로, 하나의 명령어로 여러 데이터 원소에 같은 연산을 동시에 적용하는 방식입니다.

Q2

배열의 원소 4개를 한 번에 더하는 SIMD 명령이 스칼라 반복문보다 빠른 이유는?

원소를 하나씩 처리하는 반복문과 비교해 보세요.
스칼라 방식은 원소마다 덧셈 명령을 한 번씩 실행하지만, SIMD는 한 명령어로 4개(또는 그 이상)를 함께 처리하므로 명령어 수와 반복 횟수가 줄어듭니다.

Q3

SIMD가 가장 효과적인 작업은?

"같은 연산을 많은 데이터에" 라는 조건에 맞는 작업입니다.
SIMD는 같은 연산을 규칙적인 데이터 배열에 반복 적용할 때 효과가 큽니다. 이미지 필터, 오디오 처리, 행렬 연산 등이 대표적이며 원소마다 분기가 갈리는 작업에는 불리합니다.