- SIMD는 최고 성능 소프트웨어만을 위한 복잡한 기법이 아니라, 연속된 데이터를 여러 값씩 처리해 평범한 반복문을 가속하는 일상적 최적화 수단임
- 일반적인 SIMD 코드는 상수 브로드캐스트, 벡터 단위 순회, 병렬 연산, 결과 축소·저장, 스칼라 꼬리 처리라는 5단계 구조를 따름
- Ghostty의 코드포인트 검색 루프는 한 번에 4·8·16개의
u32를 비교하며, 이론상 처리량을 ARM NEON에서 최대 4배, AVX2에서 8배, AVX-512에서 16배까지 높일 수 있음
- AVX2 Intel 데스크톱의 터미널 전체 처리량은 약 5배 빨라졌으며, 지원할 벡터 폭이 없거나 입력이 남으면 기존 스칼라 반복문이 전체 입력 또는 나머지를 처리함
- 컴파일러의 자동 벡터화는 단순한 반복문에서도 기회를 놓칠 수 있으므로 먼저 최적화된 출력을 확인하되, 중요한 핫 루프는 명시적 SIMD로 동작과 성능을 예측 가능하게 유지할 수 있음
SIMD가 하는 일
- SIMD는 CPU가 하나의 명령으로 여러 값을 병렬 처리하게 함
- 바이트를 하나씩 비교하는 대신 한 번에 4개, 8개 또는 그 이상을 비교할 수 있음
for (byte in bytes), for (character in string), for (value in array) 같은 반복문을 벡터 폭 단위 처리로 바꿀 기회가 있음
- 데이터가 수백·수천·수백만 바이트라면 병렬 폭에 따라 4배, 8배 이상의 국소적 가속을 얻을 수 있음
- 데이터가 몇 개나 수십 개에 불과하다면 SIMD를 적용할 가치가 없음
- simdutf와 simdjson은 복잡한 SIMD 기법을 사용하지만, 일상적인 SIMD까지 이 정도로 복잡할 필요는 없음
- 예제는 Zig를 사용하지만 5단계 구조는 다른 언어에도 적용되며, 언어마다 SIMD 명령 지원 방식은 다름
반복되는 5단계 구조
- 필요한 상수를 모든 레인에 브로드캐스트하고, 필요하면 벡터 누산기를 초기화함
- 입력을 한 번에 벡터 폭 크기만큼 순회함
- 모든 레인에서 비교나 산술 연산을 병렬 실행함
- 알고리듬에 맞게 벡터 결과를 축소하거나 저장함
- 완전한 벡터에 들어가지 않는 나머지는 기존 반복문인 스칼라 꼬리(scalar tail) 로 처리함
- 이 구조에 익숙해지면 일반 반복문을 같은 5단계로 분해할 수 있어 SIMD 작성도 스칼라 반복문만큼 단순해짐
- 이 구조로 간단히 표현되지 않는다면 당장은 SIMD 적용을 건너뛰는 편이 적절함
Ghostty의 실제 검색 루프
- Ghostty는 디코딩된 코드포인트 배열에서
0xF 이하의 값을 만날 때까지 데이터를 소비함
- 터미널 데이터 대부분은 출력할 일반 문자이므로 이를 묶어서 처리함
- 반복문은 다음 출력 가능 구간의 끝을 가능한 한 빨리 찾음
- 원래 스칼라 구현은 코드포인트를 하나씩 검사함
while (end < cps.len and cps[end] > 0xF) end += 1;
- 벡터 구현은 CPU 전용 내장 함수 없이 일반 벡터를 사용하며, 스칼라 구현보다 코드가 12줄 늘어남
- 기대되는 처리량 향상은 벡터 레인 수와 대응함
- ARM NEON과 Apple Silicon: 최대 4배
- 대부분의 최신 x86 CPU가 지원하는 AVX2: 최대 8배
- 일부 Intel CPU와 AMD Zen 4 이상이 지원하는 AVX-512: 최대 16배
- AVX2 Intel 데스크톱에서 터미널 프로그램 입력부터 최종 터미널 상태까지 측정한 전체 처리량은 약 5배 빨라짐
- SIMD 주변 작업 때문에 이론적 가속을 모두 얻지는 못함
- C0 제어 문자는
0xF 이후에도 존재하지만, 0xF는 이 Ghostty 코드 경로에서 사용하는 기준임
- ESC와 다른 제어 시퀀스는 별도 경로에서 처리함
1단계: 상수 브로드캐스트
if (simd.lanes(u32)) |lanes| {
const V = @Vector(lanes, u32);
const threshold: V = @splat(0xF);
- Ghostty의
simd.lanes(u32)는 대상 CPU가 동시에 처리할 수 있는 u32 개수를 반환함
- 각각의 값은 레인(lane) 이라고 부름
- ARM은 4, AVX2는 8, AVX-512는 16을 반환함
- 사용할 벡터 크기가 없으면
null을 반환해 SIMD 코드를 건너뜀
@Vector(lanes, u32)는 해당 레인 수를 가진 벡터 타입을 생성함
lanes가 8이면 V 하나에 병렬 처리할 수 있는 u32 8개가 들어감
- 벡터 비교에는 양쪽 모두 벡터가 필요하므로
@splat(0xF)가 0xF를 모든 레인에 복제함
{ 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF }
- 이 알고리듬에는 벡터 누산기가 필요하지 않지만, 다른 알고리듬은 이 단계에서 누산기를 초기화할 수 있음
2단계: 벡터 하나씩 순회
while (end + lanes <= cps.len) : (end += lanes) {
const values: V = cps[end..][0..lanes].*;
lanes가 8이면 값이 최소 8개 남았을 때만 반복문에 들어가고, 8개를 values에 적재함
- 각 반복이 끝날 때
end를 1이 아니라 레인 수만큼 증가시킴
- 완전한 벡터를 적재할 수 있어야 하므로 값이 5개만 남았다면 8레인 벡터를 읽지 않음
- 벡터에 들어가지 않는 값은 5단계의 스칼라 꼬리가 처리함
3단계: 모든 레인의 병렬 비교
const greater_than_threshold = values > threshold;
values와 threshold가 모두 벡터이므로 >는 각 대응 레인을 하나의 벡터 연산으로 비교함
- 8레인이라면
cps[end] > 0xF에 해당하는 비교 8개를 병렬 수행함
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 }
- 명시적인 내부 반복문은 없으며, 결과는 레인별 불리언을 담은 벡터임
- 비교뿐 아니라 덧셈, 곱셈, 최솟값, 최댓값 등 벡터 타입이 지원하는 연산에도 같은 구조를 적용할 수 있음
- 비교 자체는 하나의 벡터 연산이지만 벡터 적재, 결과 축소, 실패한 레인 검색에는 추가 명령이 필요함
4단계: 벡터 결과 축소
if (@reduce(.And, greater_than_threshold)) continue;
@reduce(.And, ...)는 모든 불리언을 and로 결합해 단일 불리언으로 만듦
- 모든 레인이
true이면 다음 벡터로 넘어가며, 하나라도 false이면 실패한 정확한 위치를 찾음
const mask: std.meta.Int(.unsigned, lanes) = @bitCast(greater_than_threshold);
end += @ctz(~mask);
break;
@bitCast는 불리언 벡터를 레인당 1비트인 정수 마스크로 변환함
1은 값이 0xF보다 큼을 뜻함
0은 비교에 실패했음을 뜻함
- 마스크를 반전하면 실패한 비교가
1이 되고, @ctz는 첫 번째 1 이전의 0비트 수를 셈
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)는 3을 반환하며, end를 첫 제어 문자인 0x0A가 있는 3번 레인으로 이동시킴
- 결과 축소는 5단계 중 알고리듬마다 가장 크게 달라지는 부분임
- 합계는 벡터 누산기를 단일 숫자로 축소할 수 있음
- 변환은 벡터 전체를 출력 버퍼에 저장할 수 있음
- 이 검색은 비트 마스크를 만들어 특정 레인의 위치를 찾음
5단계: 스칼라 꼬리 처리
while (end < cps.len and cps[end] > 0xF) end += 1;
- 입력 길이가 벡터 폭의 정확한 배수가 아니면 원래 스칼라 반복문이 나머지를 처리함
- 8레인 벡터 반복문 뒤에는 0개에서 7개의 값이 남을 수 있음
simd.lanes(u32)가 null인 CPU에서는 SIMD 구간을 건너뛰고 스칼라 반복문이 전체 입력을 처리함
- 원래 구현이 나머지 처리와 호환성 폴백을 동시에 담당함
- 일반 벡터는 CPU별 문법을 제거할 뿐 CPU별 코드 생성까지 없애지는 않음
- Zig는 대상에 활성화된 명령어 집합으로 벡터 연산을 변환함
자동 벡터화가 놓치는 것
- 컴파일러는 복잡한 제어 흐름이 없는 규칙적인 산술 반복문처럼 단순한 코드를 자동 벡터화할 수 있음
- 수동 SIMD를 작성하기 전에 스칼라 버전을 최적화 옵션으로 컴파일하고 생성된 코드를 확인해야 함
- 프로덕션 컴파일러는 벡터화 기회를 자주 놓치며, 자동 벡터화는 수십 년간 연구됐지만 최근 연구도 이 문제에서 출발함
- 5배 가속이 중요할 정도의 반복문이라면 벡터화를 명시적으로 작성해 동작을 예측 가능하게 유지할 수 있음
- 관련 없는 코드 변경이나 컴파일러 업데이트가 벡터 반복문을 조용히 스칼라 반복문으로 되돌리는 상황을 피할 수 있음
개발자가 익혀야 할 SIMD의 범위
- 대량의 연속 데이터를 검색·비교·계수·변환하는 핫 루프를 발견하면 벡터 폭 단위 처리를 고려할 수 있어야 함
- 일상적인 SIMD는 상수 준비, 벡터 적재, 병렬 연산, 결과 축소, 스칼라 꼬리라는 규칙적인 형태를 따름
- 언어가 SIMD를 잘 지원한다면 어셈블리나 CPU별 세부 사항을 직접 알지 못해도 성능을 개선할 수 있음
- 모든 개발자에게 필요한 수준은 복잡한
simdutf·simdjson식 기법이 아니라, SIMD 적용 기회를 인식하고 공통 구조를 활용할 수 있는 정도임