원문: https://mitchellh.com/writing/everyone-should-know-simd 발행일: 2026-07-22
SIMD(Single Instruction, Multiple Data)는 복잡하다는 평판이 있습니다. 나는 SIMD를 배우기엔 너무 복잡하거나, 일상적인 프로그래밍에는 쓸모없는 최고 성능 소프트웨어를 위한 틈새 최적화일 뿐이라고 여기는 뛰어난 엔지니어들을 많이 만났습니다.
나는 그것이 틀렸다고 생각합니다. SIMD는 이해하기에 간단할 수 있고, 단순한 for 루프를 가속하는 흔한 "N개 값을 한 번에 처리"하는 SIMD 코드는 거의 똑같은 일반적인 형태를 따릅니다. 기초를 배우고 나면 SIMD를 작성하는 것은 for 루프를 작성하는 것만큼 쉽습니다. 그렇지 않을 때는 보통 지금 당장은 SIMD를 건너뛰라는 좋은 신호입니다.
모든 개발자는 최소한 이 정도의 SIMD는 알아야 합니다.
이 글은 예제로 Zig를 사용하지만, 일반적인 내용이며 어떤 프로그래밍 언어에도 적용됩니다. SIMD 명령어에 대한 지원은 언어별로 다르며, 나는 앞으로 더 많은 언어가 이 일반적인 개념을 노출해주기를 바랍니다.
매 글마다 이렇게 밝혀야 하는 것이 싫지만, 이 글은 AI의 도움 없이 전적으로 수작업으로 작성되었음을 알리고 싶습니다.
목차
- SIMD란 무엇인가?: 배경
- 공통 형태
- 실제 예제
- 1단계: 상수 브로드캐스트(Broadcast Constants)
- 2단계: 벡터 단위로 루프 돌기
- 3단계: SIMD 연산 수행
- 4단계: 벡터 결과 축소(Reduce)
- 5단계: 스칼라 꼬리(Scalar Tail)로 마무리
- 공통 형태 되짚어 보기
- 컴파일러가 이걸 해줄 수는 없나?
- 모두가 SIMD를 알아야 한다
배경: SIMD란 무엇인가?
이미 SIMD를 안다면 이 섹션은 건너뛰세요.
SIMD는 CPU가 여러 값을 병렬로 연산할 수 있게 합니다. 예를 들어, 한 번에 한 바이트씩 비교하는 대신 CPU는 한 번의 명령어로 4개, 8개, 또는 그 이상의 바이트를 비교할 수 있습니다.
코드에서 이런 루프를 본 적이 있다면:
for (byte in bytes) { /* ... */ }
for (character in string) { /* ... */ }
for (value in array) { /* ... */ }
SIMD를 쓸 기회가 있습니다. SIMD는 이를 다음과 같이 바꿉니다:
for (8 byte chunk in bytes) { /* ... */ }
이는 병렬성에 직접 매핑되는 국소적 가속을 만듭니다: 데이터를 4배, 8배, 또는 더 빠르게 처리합니다.
이게 효과를 보기 위한 유일한 실제 요구사항은, 충분히 많은 바이트를 정기적으로 처리해야 한다는 것입니다. 데이터가 몇 개나 수십 개에 불과하다면 SIMD는 쓸모가 없습니다. 하지만 수백, 수천, 수백만 바이트를 반복한다면 보상은 엄청날 것입니다.
기본은 이것뿐입니다. simdutf나 simdjson 같은 프로젝트는 SIMD를 극한까지 사용하여 이해하기 어려운 기법을 보여줍니다. 하지만 그런 알고리즘을 직접 작성하지 않아도 SIMD의 이점을 볼 수 있습니다. 일반적인 경우는 훨씬 간단합니다.
공통 형태
흔한 "N개 값을 한 번에 처리"하는 SIMD 코드는 다음 다섯 단계를 따릅니다:
- 필요한 상수를 브로드캐스트하고, 필요한 경우 벡터 누산기(accumulator)를 초기화합니다.
- 입력을 벡터 폭만큼 한 번에 로드하여 반복합니다.
- 모든 레인(lane)에서 비교나 산술 연산을 병렬로 수행합니다.
- 필요에 따라 벡터 결과를 축소(reduce)하거나 저장합니다.
- 남은 요소들은 스칼라 꼬리(scalar tail)로 처리합니다. 스칼라 꼬리는 벡터화하기 전의 일반 루프와 같지만, 벡터에 꽉 차지 않는 나머지만 처리합니다.
이 작업을 반복할수록 모든 for 루프를 이 다섯 단계로 자연스럽게 분해하게 되고, SIMD를 작성하는 것은 스칼라 루프를 작성하는 것만큼 거의 자연스러워집니다.
실제 예제
Ghostty의 실제 예제를 봅시다. 스칼라 구현, SIMD 구현을 살펴보고, 위의 공통 형태에 다시 매핑해 보겠습니다.
나는 0xF 이하의 값(C0 제어 문자)이 나타날 때까지 디코딩된 코드포인트(codepoint) 슬라이스를 소비하고 싶습니다. 터미널은 대부분 출력할 수 있는 평범한 문자이므로, 이들을 한꺼번에 배치 처리하려고 합니다. 이 루프는 가능한 한 빨리 다음 출력 가능한 문자 묶음의 끝을 찾습니다.
스칼라 루프는 한 줄입니다:
while (end < cps.len and cps[end] > 0xF) end += 1;
이 루프는 한 번에 하나의 코드포인트를 처리합니다. 이해하기 쉽습니다.
다음은 CPU별 고유 내장 함수(intrinsic) 없이, 주석 없는 범용 벡터 버전입니다. 나중에 자세히 설명하겠습니다.
if (simd.lanes(u32)) |lanes| {
const V = @Vector(lanes, u32);
const threshold: V = @splat(0xF);
while (end + lanes <= cps.len) : (end += lanes) {
const values: V = cps[end..][0..lanes].*;
const greater_than_threshold = values > threshold;
if (@reduce(.And, greater_than_threshold)) continue;
const mask: std.meta.Int(.unsigned, lanes) = @bitCast(greater_than_threshold);
end += @ctz(~mask);
break;
}
}
while (end < cps.len and cps[end] > 0xF) end += 1;
12줄 더 추가되었습니다.
이는 ARM NEON(Apple Silicon 포함)에서는 최대 4배, AVX2(대부분의 최신 x86 CPU)에서는 8배, AVX-512(일부 Intel CPU 및 AMD Zen 4 이상)에서는 16배까지 이 루프의 처리량(throughput)을 향상시킬 수 있습니다.
AVX2 Intel 데스크톱에서 터미널 프로그램부터 최종 터미널 상태까지 실제 종단 처리량(end-to-end throughput)은 약 5배 가속이었습니다. SIMD 코드 주변의 다른 부분 때문에 이상적인 가속만큼은 얻지 못하지만... 그래도 5배입니다!
이 12줄이 개념에 익숙하지 않은 사람에게는 매우 생소해 보일 것이라는 걸 이해합니다. 그래서 지금부터 다섯 단계로 직접 매핑하며 단계별로 설명하겠습니다.
1단계: 상수 브로드캐스트
먼저 첫 세 줄부터 시작합니다:
if (simd.lanes(u32)) |lanes| {
const V = @Vector(lanes, u32);
const threshold: V = @splat(0xF);
simd.lanes(u32)는 Ghostty의 헬퍼로, 대상 CPU가 한 번에 처리할 수 있는 u32 값의 개수를 반환합니다. 이 개별 값들을 레인(lane)이라고 합니다. ARM에서는 4를, AVX2에서는 8을, AVX-512에서는 16을 반환합니다. 대상에 사용하고 싶은 벡터 크기가 없다면 null을 반환하고, 이 경우 모든 SIMD 코드를 건너뛰고 스칼라 작업만 합니다.
@Vector(lanes, u32)는 벡터 타입을 만듭니다. lanes가 8이면 V는 CPU가 병렬로 연산할 수 있는 8개의 u32 값을 담은 단일 값입니다. 이와 비슷하게 확장됩니다.
마지막으로, 모든 값을 0xF와 비교해야 합니다. 벡터 비교는 양쪽 모두 벡터여야 하므로, @splat(0xF)는 0xF를 모든 레인에 복사하거나 브로드캐스트합니다. 결과는 다음과 같은 벡터입니다:
{ 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF }
이것이 1단계입니다: 벡터 타입을 준비하고 상수를 브로드캐스트합니다. 일부 알고리즘은 여기서 벡터 누산기도 초기화하지만, 이 알고리즘은 그것이 필요 없습니다.
2단계: 벡터 단위로 루프 돌기
다음으로, 한 번에 하나의 완전한 벡터를 반복합니다:
while (end + lanes <= cps.len) : (end += lanes) {
const values: V = cps[end..][0..lanes].*;
lanes가 8이라면, 최소 8개 값이 남았을 때만 루프에 진입합니다. 루프 내부에서는 이 8개 값을 values 벡터에 로드합니다. 매 루프 마지막에 end += lanes는 8개씩 앞으로 전진합니다.
완전한 벡터가 필요한 것이 중요합니다. 5개 값만 남았다면 8레인 벡터를 로드할 수 없습니다. 이를 처리하는 다양한 트릭이 있지만, 우리는 쉬운 방법을 선택하고 스칼라 꼬리로 처리합니다. 이는 5단계에서 설명하겠습니다.
이것이 2단계입니다: 입력을 벡터 폭만큼 한 번씩 로드하고 반복합니다. 여기에서 레인 수만큼의 가속이 일어납니다!
3단계: SIMD 연산 수행
이제 비교를 수행합니다:
const greater_than_threshold = values > threshold;
values와 threshold는 모두 벡터이므로, 이것은 벡터 연산(실제 벡터 CPU 명령어)에 매핑됩니다. 하나의 >는 values의 모든 레인을 threshold의 해당 레인과 비교합니다. 8개 레인이 있다면, 이것은 스칼라 비교 cps[end] > 0xF를 8번 수행하는 것과 같지만, 하나의 CPU 명령어로 합니다.
결과는 레인당 하나의 불리언을 담은 또 다른 벡터입니다. 개념적으로 다음과 같습니다:
values: { 0x41, 0x42, 0x43, 0x0A, 0x44, 0x45, 0x46, 0x47 }
threshold: { 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF }
greater_than_threshold: { true, true, true, false, true, true, true, true }
이것이 실제 SIMD 연산입니다. 명시적인 내부 루프가 없습니다. > 연산자는 모든 레인에 병렬로 적용됩니다.
비교는 한 예일 뿐입니다. 덧셈, 곱셈, 최솟값, 최댓값, 또는 벡터 타입이 지원하는 다른 어떤 연산도 가능합니다. 핵심은 코드의 형태가 같다는 것입니다.
4단계: 벡터 결과 축소
이제 우리는 불리언 벡터를 가지고 있지만, 원래 루프는 0xF 이하인 첫 번째 값의 위치를 알아야 합니다.
먼저, 모든 값이 0xF를 초과하는 일반적인 경우를 처리합니다:
if (@reduce(.And, greater_than_threshold)) continue;
@reduce(.And, ...)는 모든 불리언을 and로 결합해 단일 불리언을 반환합니다. 모든 레인이 true면 continue를 해서 다음 벡터를 처리합니다. 우리 예제에서 레인 3은 false이므로, @reduce는 false를 반환하고 우리는 어떤 레인이 실패했는지 정확히 찾기 위해 계속 진행합니다.
어떤 레인이 false라면, 정확히 어떤 레인이 실패했는지 찾아야 합니다:
const mask: std.meta.Int(.unsigned, lanes) = @bitCast(greater_than_threshold);
end += @ctz(~mask);
break;
@bitCast는 불리언 벡터를 레인당 1비트를 갖는 정수로 변환합니다. 1 비트는 값이 0xF보다 컸다는 뜻이고, 0은 그렇지 않다는 뜻입니다. 우리는 마스크를 반전(invert)해 실패한 비교를 1로 만들고, @ctz는 첫 번째 실패 이전의 0 비트 수를 셉니다. 그 수가 첫 번째 실패한 레인의 인덱스입니다.
우리는 그 인덱스를 end에 더하고, 제어 문자를 찾았으므로 break합니다.
3단계의 같은 값으로, 레인별 변환을 볼 수 있습니다:
values: { 0x41, 0x42, 0x43, 0x0A, 0x44, 0x45, 0x46, 0x47 }
greater_than_threshold: { true, true, true, false, true, true, true, true }
mask: { 1, 1, 1, 0, 1, 1, 1, 1 }
~mask: { 0, 0, 0, 1, 0, 0, 0, 0 }
@ctz(~mask)는 첫 1 이전에 0이 3개 있으므로 3을 반환합니다. end에 3을 더하면 레인 3을 가리키게 되고, 그곳에 0x0A, 즉 첫 번째 제어 문자가 있습니다.
이것이 4단계입니다: 벡터 결과를 원래 알고리즘이 필요로 하는 것으로 축소합니다. 이 단계가 알고리즘마다 가장 많이 달라집니다. 합계는 벡터 누산기를 하나의 숫자로 축소할 수 있습니다. 변환은 출력 버퍼에 전체 벡터를 저장할 수 있습니다. 우리의 스캔은 하나의 특정 레인을 찾기 위해 벡터를 비트 마스크로 변환합니다.
5단계: 스칼라 꼬리로 마무리
벡터 루프 이후, 우리는 시작했던 그 스칼라 루프를 그대로 실행합니다:
while (end < cps.len and cps[end] > 0xF) end += 1;
입력 길이가 벡터 폭의 정확한 배수가 아니라면, 이 루프가 남은 값들을 처리합니다. 예를 들어, 8레인 벡터 루프는 0개에서 7개 사이의 값을 이 루프에 남깁니다. 이를 스칼라 꼬리(scalar tail)라고 합니다.
이 루프는 simd.lanes(u32)가 null을 반환하는 CPU에서도 전체 입력을 처리합니다. 그 경우 SIMD 코드를 모두 건너뛰고 스칼라 루프가 전체 입력을 처리합니다. 원래 구현은 폴백과 꼬리 모두로 남습니다.
이것이 5단계입니다. 그냥 일반적인 루프입니다.
공통 형태 되짚어 보기
전체 구현을 다섯 단계에 다시 매핑해 보겠습니다:
@splat(0xF)는 비교값을 모든 레인에 브로드캐스트합니다.while루프는lanes개의 값을 한 번에 로드합니다.values > threshold는 모든 레인을 병렬로 비교합니다.@reduce,@bitCast,@ctz는 첫 번째 실패한 비교를 찾습니다.- 원래 스칼라 루프가 나머지와 지원되지 않는 CPU를 처리합니다.
4단계의 세부 사항은 처음에는 이해하는 데 시간이 걸리지만, 전체 형태는 간단합니다. 그리고 1, 2, 3, 5단계는 완전히 다른 알고리즘에서도 거의 똑같이 보입니다.
for (byte in bytes)를 볼 때마다, 이것이 매핑할 형태입니다.
컴파일러가 이걸 해줄 수는 없나?
때로는 가능합니다! 컴파일러는 간단한 루프, 특히 복잡한 제어 흐름이 없는 규칙적인 산술 루프를 자동 벡터화(auto-vectorize)할 수 있습니다. 수동으로 SIMD를 작성하기 전에, 항상 스칼라 버전을 최적화와 함께 컴파일해 컴파일러가 어떤 결과를 내는지 확인해야 합니다.
하지만 컴파일러가 자동 벡터화할 수 있는 것은 심각하게 제한되어 있고, 일반적으로 이를 매우 잘하지 못합니다. 자동 벡터화는 수십 년간 활발한 컴파일러 연구 분야였고, 최근 연구조차도 상용 컴파일러가 정기적으로 벡터화 기회를 놓친다는 관찰에서 시작합니다. 이 문제는 당분간 사라지지 않을 것으로 예상합니다.
더 중요한 것은, 이 루프가 5배 가속만큼 중요할 때, 나는 벡터화가 명시적이고 예측 가능하기를 원합니다. 관련 없는 코드 변경이나 컴파일러 업데이트가 조용히 이를 다시 스칼라 루프로 되돌리는 것을 원하지 않습니다.
모두가 SIMD를 알아야 한다
모든 개발자는 SIMD를 적용할 기회를 알아볼 수 있어야 하고, 무엇보다 SIMD를 두려워하지 않아야 합니다. 핫 루프가 많은 연속 데이터를 스캔, 비교, 계수, 변환하는 것을 본다면, 벡터 폭 크기만큼의 청크로 처리하는 상상을 할 수 있어야 합니다.
이 글은 이런 일반적인 경우가 매우 규칙적인 패턴을 따르며, 익숙해지면 금방 익숙해진다는 것을 보여줍니다. 그리고 언어 지원이 잘 되어 있다면, 쉬운 성능 향상을 얻기 위해 어셈블리나 CPU별 특이 사항을 알 필요가 없습니다.
모두가 이 정도의 SIMD는 알아야 합니다.
핵심 요약 (20줄)
- SIMD는 "단일 명령어로 다중 데이터 처리"를 뜻하며, CPU가 한 번에 여러 값을 병렬 연산할 수 있는 기능입니다.
- 흔한
for (byte in bytes)같은 루프는 SIMD로 바꿔 4배~16배 가속할 수 있는 후보입니다. - Mitchell Hashimoto는 SIMD가 고성능 틈새 기술이 아니라 일상 개발에서도 접근 가능한 기초 기술이라고 주장합니다.
- 이 글은 Zig로 예제를 들지만, C/C++/Rust 등 대부분의 언어에 적용되는 일반 원칙입니다.
- "N개 값을 한 번에 처리"하는 SIMD 코드는 거의 같은 5단계 형태를 따릅니다.
- 1단계: 상수를 벡터의 모든 레인에 브로드캐스트하고 벡터 타입을 준비합니다.
- 2단계: 입력을 벡터 폭만큼 한 번에 로드하여 반복합니다.
- 3단계: 비교/산술 연산을 한 번의 벡터 명령어로 모든 레인에 병렬 수행합니다.
- 4단계: 벡터 결과를 원래 알고리즘에 필요한 형태(합계, 마스크, 인덱스 등)로 축소합니다.
- 5단계: 남은 값들은 원래 스칼라 루프(스칼라 꼬리)로 처리합니다.
- 예제는 Ghostty 터미널 에뮬레이터에서 C0 제어 문자(0xF 이하)를 찾는 루프입니다.
- 스칼라 루프는 한 줄이지만, SIMD 버전은 12줄 추가로 AVX2에서 5배 종단 가속을 냅니다.
simd.lanes(u32)는 CPU가 한 번에 처리할 수 있는 u32 레인 수(4/8/16)를 반환합니다.@splat(0xF)는 비교값을 벡터의 모든 레인에 복사합니다.@reduce(.And, ...)는 모든 레인이 조건을 만족하면 true를 반환해 다음 벡터로 넘어갑니다.@bitCast와@ctz를 조합하면 첫 번째 실패한 레인의 인덱스를 빠르게 찾습니다.- 컴파일러의 자동 벡터화는 제한적이고 불안정해, 중요한 루프는 직접 SIMD로 명시적 제어하는 것이 낫습니다.
- 데이터가 충분히 크고(수백~수백만 개) 연속적일 때 SIMD의 보상이 큽니다.
- simdutf/simdjson 같은 프로젝트는 복잡한 SIMD를 사용하지만, 일반 개발자는 훨씬 간단한 형태로도 큰 이익을 볼 수 있습니다.
- 결론: 모든 개발자는 SIMD 기회를 인식하고, 두려움 없이 벡터 폭 단위 처리를 상상할 줄 알아야 합니다.