Everyone Should Know SIMD

Mitchell Hashimoto

모두가 알아야 할 SIMD

원문은 Mitchell Hashimoto님이 에 게재했습니다. 이 블로그 구독하기

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배, 혹은 그 이상 빠르게 처리하는 것이다.

이것이 효과를 보려면 유일한 실질적 조건은 충분히 많은 바이트를 정기적으로 처리해야 한다는 것이다. 이 for 루프가 처리하는 데이터가 항상 몇 바이트나 몇십 바이트에 불과하다면 할 가치가 없다. 하지만 수백, 수천, 수백만 바이트를 순회한다면 그 효과는 엄청날 것이다.

그게 전부다. 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(애플 실리콘 포함)에서 최대 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가 병렬로 연산할 수 있는 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;

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에 더하고 제어 문자를 찾았으므로 루프를 빠져나온다.

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을 반환한다. 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 댓글을 바탕으로 했다.

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

댓글