
반복문 속도가 8배 빨라지는 마법: 컴파일러 자동 벡터화(SIMD) 제대로 쓰기
반복문 속도를 최대 8배까지 높이는 컴파일러 자동 벡터화(SIMD)의 원리와 최적화 기법을 8년 차 백엔드 개발자의 시선으로 쉽고 깊게 설명합니다.
김테크
8년차 개발자

안녕하세요. 풀링포레스트 백엔드 개발자 김테크입니다.
대용량 데이터를 처리하다 보면 가끔 벽에 부딪히는 기분이 듭니다. 특히 수천만 건의 로그 데이터나 메트릭을 가공해야 할 때, 로직은 단순한데 처리 시간이 하염없이 길어지는 경험, 다들 한 번쯤 있으실 겁니다. 저는 8년 차가 된 지금도 "이 루프를 어떻게 더 줄이지?"라는 고민을 달고 삽니다.
오늘은 제가 주니어 시절, 단순히 코드를 '깔끔하게' 짜는 것을 넘어 하드웨어의 힘을 빌려 성능을 극대화하는 방법을 깨달았던 순간을 공유하려 합니다. 바로 컴파일러가 부리는 마법, 자동 벡터화(Auto-Vectorization)에 대한 이야기입니다.
CPU에게 숟가락 말고 삽을 쥐여주자
보통 우리가 작성하는 코드는 "데이터 하나를 가져와서, 연산하고, 저장해라"라는 식입니다. 하지만 빅 데이터 스타일의 문제, 즉 "거대한 배열에 동일한 수학 연산을 적용하라"는 상황에서는 이 방식이 비효율적입니다. 병목은 수학 계산 자체가 아니라, CPU에게 데이터를 하나씩 떠먹여 주는 과정에서 발생하기 때문이죠.
여기서 등장하는 개념이 SIMD(Single Instruction, Multiple Data)입니다. 말 그대로 하나의 명령으로 여러 데이터를 한 번에 처리하는 기술입니다. 마치 숟가락으로 흙을 퍼 나르던 것을, 포크레인으로 한 번에 푹 퍼서 옮기는 것과 같습니다. 과거에는 이 기능을 쓰려면 개발자가 직접 어셈블리어를 작성해야 하는 고통이 따랐지만, 다행히 지금은 컴파일러가 우리 대신 이 작업을 해줍니다.
-O2 옵션의 한계와 깨달음
상황을 가정해 봅시다. 두 개의 거대한 배열 x와 y가 있고, 각 요소를 비교해 더 큰 값을 x에 저장하는 코드를 짰습니다.
보통 사용하는 -O2 최적화 옵션으로 컴파일하면, 우리가 예상하는 '정직한' 기계어 코드가 나옵니다. 값을 하나 읽고(mov), 비교하고(cmp), 조건에 따라 분기(jle)하는 식이죠. 안전하지만, CPU의 잠재력을 100% 쓰지는 못하는 상태입니다.
이때 컴파일러 옵션을 -O3로 올리고, 타겟 아키텍처를 최신 CPU(예: -march=skylake)로 지정하면 놀라운 일이 벌어집니다. 컴파일러가 갑자기 ymm 레지스터를 사용하기 시작합니다. 이건 한 번에 8개의 정수(또는 4개의 double)를 담을 수 있는 큰 그릇입니다.
코드를 뜯어보면 더 이상 하나씩 비교하지 않습니다.
1. 8개의 데이터를 한 번에 읽어옵니다.
2. 8개의 비교를 단 하나의 명령(vpcmpgtd)으로 처리합니다.
3. 결과를 한 번에 저장합니다.
루프 한 번에 데이터 8개를 처리하니, 이론상 처리 속도가 비약적으로 상승합니다. 이것이 바로 SIMD의 힘입니다.
마스크 무브(Mask Move): 조건문의 딜레마 해결
하지만 여기서 한 가지 의문이 생깁니다. "조건문(if)이 있는데 어떻게 한 번에 처리하지?"
배열의 요소마다 x > y일 수도 있고 아닐 수도 있습니다. 하나씩 처리할 때는 분기(Branch)를 타면 되지만, 8개를 한 번에 묶어서 처리할 때는 이게 불가능해 보입니다.
여기서 컴파일러의 영리함이 드러납니다. 컴파일러는 '마스크 무브(Mask Move)'라는 기술을 씁니다. 조건에 맞는 요소들만 1로 마킹한 '마스크'를 만들고, 그 마스크에 해당하는 값들만 메모리에 덮어쓰는 방식입니다. 분기 예측 실패로 인한 성능 저하 없이 깔끔하게 조건부 업데이트를 수행하는 것이죠.
더 나아가, 만약 우리가 조건부 쓰기가 아니라 std::max 같은 로직을 써서 무조건 값을 업데이트하도록 코드를 바꾼다면? 컴파일러는 vpmaxsd라는 특수 명령어를 사용합니다. 비교하고 분기할 필요도 없이, 하드웨어 레벨에서 "최댓값 뽑기"를 한 번에 수행해 버립니다. 코드는 더 단순해지고 성능은 더 빨라집니다.
왜 여전히 느린 루프가 남아있을까?
그런데 생성된 어셈블리 코드를 자세히 보면 이상한 점이 있습니다. 벡터화된 빠른 루프가 있는데, 그 뒤에 여전히 하나씩 처리하는 느린 루프가 또 존재하고, 실행 초기에 무언가를 검사합니다.
"혹시 벡터화가 실패한 건가?" 저도 처음엔 그렇게 생각했습니다. 하지만 이건 컴파일러의 '신중함' 때문입니다.
컴파일러는 데이터 겹침(Aliasing)을 걱정합니다. 만약 배열 x와 y가 메모리 상에서 미묘하게 겹쳐 있다면, 8개씩 묶어서 처리할 때 x에 쓴 값이 아직 읽지 않은 y의 값에 영향을 줄 수 있습니다. 그래서 컴파일러는 런타임에 주소를 검사합니다. "두 배열이 안전하게 떨어져 있나?"라고 확인한 뒤, 안전하면 빠른 벡터 루프를 타고, 위험하다 싶으면 느리지만 안전한 일반 루프로 빠지는 것입니다.
이런 오버헤드를 없애려면 개발자가 "이 두 포인터는 절대 겹치지 않아"라고 보장해 줘야 합니다. C/C++의 __restrict 키워드나 최신 언어들의 힌트 기능을 통해 컴파일러에게 확신을 주면, 이 불필요한 검사 코드를 제거할 수 있습니다.
마치며
개발자로서 우리는 종종 로직의 효율성(Big-O)에만 집착하곤 합니다. 하지만 하드웨어가 데이터를 어떻게 소화하는지, 컴파일러가 우리 코드를 어떻게 해석하는지를 이해하면 코드 한 줄 고치지 않고도, 혹은 컴파일러 플래그 하나만으로도 극적인 성능 향상을 이뤄낼 수 있습니다.
데이터가 일렬로 예쁘게 정렬되어 있는지(Data Layout), 그리고 컴파일러가 마음 놓고 벡터화를 할 수 있게 도와주고 있는지 한 번 점검해 보세요. 여러분의 루프가 스쿠터에서 스포츠카로 변신할지도 모릅니다.
풀링포레스트에서,
김테크 드림.


