Everyone Should Know SIMD

Mitchell Hashimoto

누구나 알아야 할 SIMD

SIMD는 어렵다는 이미지가 있습니다. 저는 실력 있는 소프트웨어 엔지니어 중에도 SIMD를 배우기엔 너무 복잡하거나, 최고 성능을 요구하는 소프트웨어에만 필요한 특수한 최적화이며 일상적인 프로그래밍에는 쓸모없다고 치부하는 분들을 많이 만나봤습니다.

저는 그렇게 생각하지 않습니다. SIMD는 이해하기 어렵지 않을 수 있으며1, 단순한 for 루프를 가속하기 위해 “한 번에 N개의 값을 처리하는” 일반적인 SIMD 코드는 거의 항상 같은 형태를 따릅니다. 기초만 익히면 SIMD를 작성하는 일은 for 루프를 작성하는 것만큼이나 쉬워집니다. 그리고 쉽지 않다면, 지금은 건너뛰어도 좋다는 신호일 때가 많습니다.

모든 개발자는 최소한 그 정도의 SIMD는 알아야 합니다.

이 글은 예제로 Zig를 사용하지만, 어떤 프로그래밍 언어에도 적용할 수 있는 일반적인 이야기입니다. SIMD 명령어에 대한 지원은 언어마다 다르며, 앞으로 더 많은 언어에서 이런 일반적인 개념을 제공해주길 바랍니다!

매 글마다 이런 말을 붙여야 하는 게 유감이지만, 이 글은 AI 도움 없이 전부 직접 작성했다는 점을 밝혀둡니다.

배경: SIMD란 무엇인가?

이미 SIMD가 무엇인지 알고 있다면 이 절은 건너뛰어도 좋습니다.

SIMD를 이용하면 CPU가 여러 값을 병렬로 처리할 수 있습니다. 예를 들어 한 번에 1바이트씩 비교하는 대신, 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배, 혹은 그 이상 빠르게 처리하게 되는 셈입니다.

이 최적화가 효과를 보려면 단 하나의 조건만 만족하면 됩니다. 충분히 많은 바이트를 반복적으로 처리하고 있어야 한다는 것입니다. 데이터가 매번 몇 바이트 혹은 몇십 바이트에 불과하다면 굳이 할 필요가 없습니다. 하지만 수백, 수천, 수백만 바이트를 순회한다면 효과는 엄청납니다.

이것이 기본입니다. simdutfsimdjson 같은 프로젝트는 이를 극한까지 밀어붙여 이해하기 어려운 SIMD 기법을 사용합니다. 하지만 SIMD의 이점을 누리기 위해 그런 알고리즘을 작성할 필요는 없습니다. 일반적인 경우는 훨씬 더 단순합니다.

공통 패턴

‘한 번에 N개의 값을 처리하는’ 일반적인 SIMD 코드는 항상 다음 다섯 단계를 따릅니다:

  1. 필요한 상수를 브로드캐스트하고, 필요하다면 벡터 누산기를 초기화합니다.
  2. 입력을 벡터 너비만큼씩 나누어 반복합니다.
  3. 모든 레인에 대해 비교나 연산을 병렬로 수행합니다.
  4. 필요에 따라 벡터 결과를 리듀스하거나 저장합니다.
  5. 남은 원소는 스칼라 테일로 처리합니다. 스칼라 테일은 벡터화하기 전의 일반적인 루프와 같지만, 벡터 하나를 채우지 못하고 남은 나머지만 처리합니다.

이런 작업을 반복하다 보면 모든 for 루프를 자연스럽게 이 다섯 단계로 분해하게 되고, SIMD를 작성하는 일이 스칼라 루프를 작성하는 것만큼 자연스러워집니다.

실제 예제

Ghostty의 실제 예제를 살펴보겠습니다. 스칼라 구현과 SIMD 구현을 차례로 보고, 이를 위에서 설명한 공통 패턴에 대입해 보겠습니다.

디코딩된 코드포인트 슬라이스가 있고, 0xF 이하의 값(C0 제어 문자)이 나올 때까지 이를 소비하려고 합니다.2 터미널은 대부분 출력할 일반 문자로 이루어져 있으므로, 이들을 한 번에 묶어서 처리하려 합니다. 따라서 이 루프는 다음 출력 가능한 구간의 끝을 최대한 빠르게 찾습니다.

스칼라 루프는 한 줄입니다:

while (end < cps.len and cps[end] > 0xF) end += 1;

한 번에 하나의 코드포인트를 처리합니다. 이해하기 쉽습니다.

다음은 CPU 전용 인트린식 없이 작성한 범용 벡터 버전이며, 주석도 달지 않았습니다. 뒤에서 자세히 설명하겠습니다.3

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배까지 향상될 수 있습니다.

AVX2를 탑재한 Intel 데스크톱에서 터미널 프로그램부터 최종 터미널 상태까지의 실제 종단 간 처리량으로 보면, 대략 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 값의 개수를 반환합니다. 이 개별 값들을 레인이라고 합니다. ARM에서는 4를, AVX2에서는 8을, AVX-512에서는 16을 반환합니다. 대상에서 사용하려는 벡터 크기를 지원하지 않으면 null을 반환하고, 이 코드 전체를 건너뛰어 SIMD 작업을 전혀 수행하지 않습니다.

@Vector(lanes, u32)는 벡터 타입을 만듭니다. lanes가 8이면, V는 CPU가 병렬로 연산할 수 있는 여덟 개의 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;

valuesthreshold 모두 벡터이므로, 이는 벡터 연산(실제로는 벡터 CPU 명령어 하나)으로 매핑됩니다. 하나의 > 연산자가 values의 모든 레인을 threshold의 대응되는 레인과 비교합니다. 레인이 8개라면 스칼라 비교 cps[end] > 0xF를 8번 수행하는 것과 같지만, 이를 하나의 CPU 명령어로 처리합니다.4

결과는 레인마다 하나의 불리언을 갖는 또 다른 벡터입니다. 개념적으로는 다음과 같이 생겼습니다:

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이므로 @reducefalse를 반환하고, 정확히 어느 레인이 실패했는지 찾기 위해 다음 코드로 넘어갑니다.

어느 레인이라도 false라면 정확히 어느 레인이 실패했는지 찾아야 합니다:

const mask: std.meta.Int(.unsigned, lanes) = @bitCast(greater_than_threshold);
end += @ctz(~mask);
break;

@bitCast는 불리언 벡터를 레인당 1비트인 정수로 변환합니다. 비트가 1이면 해당 값이 0xF보다 컸다는 뜻이고, 0이면 그렇지 않았다는 뜻입니다. 실패한 비교가 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을 반환합니다. end3을 더하면 0x0A를 담고 있는 레인 3, 즉 첫 번째 제어 문자를 가리키게 됩니다.

이것이 4단계입니다. 벡터 결과를 원래 알고리즘이 필요로 하는 형태로 리듀스하는 단계입니다. 이 단계는 알고리즘마다 가장 크게 달라지는 부분이기도 합니다. 합계를 구하는 경우 벡터 누산기를 하나의 숫자로 리듀스할 수도 있고, 변환 작업이라면 벡터 전체를 출력 버퍼에 저장할 수도 있습니다. 우리의 스캔은 특정 레인 하나를 찾기 위해 벡터를 비트 마스크로 변환합니다.

5단계: 스칼라 테일로 마무리하기

벡터 루프가 끝난 뒤에는 처음에 작성했던 스칼라 루프를 그대로 실행합니다:

while (end < cps.len and cps[end] > 0xF) end += 1;

입력 길이가 벡터 너비의 정확한 배수가 아니라면, 이 루프가 남은 값을 처리합니다. 예를 들어 8레인 벡터 루프 뒤에는 0개에서 7개 사이의 값이 이 루프에 남게 됩니다. 이를 스칼라 테일이라고 합니다.

이 루프는 simd.lanes(u32)null을 반환하는 CPU도 처리합니다. 그런 경우에는 SIMD 코드 전체를 건너뛰고 스칼라 루프가 입력 전체를 처리합니다. 원래 구현이 폴백이자 테일 역할을 그대로 유지하는 셈입니다.

이것이 5단계입니다. 그냥 일반적인 루프일 뿐입니다.

정리: 공통 패턴

전체 구현을 다섯 단계에 다시 대입해 보겠습니다:

  1. @splat(0xF)가 비교 값을 모든 레인에 브로드캐스트합니다.
  2. while 루프가 한 번에 lanes개의 값을 로드합니다.
  3. values > threshold가 모든 레인을 병렬로 비교합니다.
  4. @reduce, @bitCast, @ctz가 처음으로 실패한 비교를 찾습니다.
  5. 원래의 스칼라 루프가 나머지와 미지원 CPU를 처리합니다.

4단계의 세부 내용은 처음에는 이해하는 데 시간이 좀 걸리지만, 전체적인 형태는 단순합니다. 그리고 1, 2, 3, 5단계는 완전히 다른 알고리즘에서도 거의 동일하게 생겼습니다.

for (byte in bytes)를 볼 때마다 앞으로는 이 형태로 변환할 수 있을 것입니다.

컴파일러가 알아서 해 주면 안 될까?

가끔은 해 줍니다! 컴파일러는 자동 벡터화를 통해 단순한 루프, 특히 복잡한 제어 흐름이 없는 규칙적인 연산 루프를 벡터화할 수 있습니다. SIMD를 직접 작성하기 전에 항상 스칼라 버전을 최적화 옵션으로 컴파일해 컴파일러가 무엇을 만들어내는지 확인해야 합니다.

하지만 컴파일러가 자동 벡터화할 수 있는 범위는 매우 제한적이며, 전반적으로도 잘하지 못합니다. 자동 벡터화는 수십 년간 활발한 컴파일러 연구 분야였고, 최근 연구조차도 프로덕션 컴파일러가 벡터화 기회를 정기적으로 놓친다는 관찰에서 출발합니다. 이 문제는 조만간 사라질 것 같지 않습니다.

더 중요한 점은, 이 루프가 5배 속도 향상을 신경 쓸 만큼 중요하다면 벡터화가 명시적이고 예측 가능하기를 바란다는 것입니다. 관련 없는 코드 변경이나 컴파일러 업데이트 때문에 조용히 다시 스칼라 루프로 돌아가는 상황을 원하지 않습니다.

누구나 SIMD를 알아야 합니다

모든 개발자는 SIMD를 활용할 기회를 알아볼 수 있어야 하며, 무엇보다 SIMD를 두려워해서는 안 됩니다. 많은 양의 연속된 데이터를 스캔하거나, 비교하거나, 세거나, 변환하는 핫 루프를 본다면, 이를 벡터 너비만큼씩 나누어 처리하는 모습을 떠올릴 수 있어야 합니다.

이 글에서 보여드렸듯이 일반적인 경우는 매우 규칙적인 패턴을 따르며 금방 익숙해질 수 있습니다. 그리고 언어 지원만 잘 되어 있다면 어셈블리나 CPU별 특수한 사항을 알지 못해도 쉽게 성능을 개선할 수 있습니다.

누구나 이 정도의 SIMD는 알아야 합니다.5

각주

  1. simdutf나 simdjson 같은 매우 인상적인 프로젝트들은 목표를 달성하기 위해 극도로 복잡한 SIMD 트릭을 사용합니다. 하지만 저는 이를 “일상적인 SIMD”라고 생각하지 않습니다.

  2. C0 제어 문자는 0xF 이후까지 이어집니다. 이는 Ghostty가 이 특정 코드 경로에서 사용하는 기준이며, ESC를 비롯한 다른 제어 시퀀스 처리는 다른 곳에서 이루어집니다.

  3. 범용 벡터는 CPU 전용 구문을 없애줄 뿐, CPU 전용 코드 생성을 없애주는 것은 아닙니다. Zig는 여전히 이러한 연산을 타깃에 활성화된 명령어 집합으로 낮춥니다. Ghostty는 지원되는 벡터 너비를 선택할 수 없을 때 스칼라 코드로 폴백합니다.

  4. 비교 자체는 하나의 벡터 연산입니다. 벡터를 로드하고, 결과를 리듀스하며, 실패한 레인을 찾는 데는 추가 명령어가 필요합니다. 중요한 점은 여러 비교를 한 번에 수행한다는 것입니다.

  5. 이 글은 제가 Lobsters에 남긴 댓글을 바탕으로 작성되었습니다.

원문은 Mitchell Hashimoto님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.