정수에서는 SIMD 벡터화가 훌륭히 작동하지만, 부동소수점 합에서는 결합법칙이 성립하지 않아 컴파일러가 같은 방식으로 벡터화하지 못하는 이유와, 이를 제한적으로 완화하는 방법을 살펴본다.
Matt Godbolt의 블로그 =========================================
내가 작성했고, LLM이 교정했다.
자세한 내용은 글 끝에.
어제는 정수에서 SIMD가 아주 멋지게 동작하는 것을 보았다. 하지만 부동소수점에는 깜짝 놀랄 만한 함정이 있다. 배열을 더해 보자1:
핵심 루프를 보면, 컴파일러가 영리한 트릭을 성공시켰다:
.L2:
; 8개의 정수를 가져와 ymm0에 원소별로 더한다
vpaddd ymm0, ymm0, YMMWORD PTR [rdi]
add rdi, 32 ; 다음 정수 묶음으로 이동
cmp rax, rdi ; 끝인가?
jne .L2 ; 아니면 루프
컴파일러는 ymm0를 서로 독립적인 8개의 정수로 취급하여, [rdi]에서 읽어온 대응 원소에 각각 더하는 벡터화된 덧셈 명령을 사용하고 있다. 루프 한 번당 8개 정수를 처리하니 매우 효율적이다. 하지만 이 루프가 끝나면 8개의 별도 부분합(subtotal)이 남는다. 컴파일러는 루프 뒤에 이 8개의 부분합을 모두 더하기 위한 “마무리(fix up)” 코드를 조금 덧붙인다:
vextracti128 xmm1, ymm0, 0x1 ; xmm1 = ymm0 >> 128
vpaddd xmm0, xmm1, xmm0 ; xmm0 += xmm1
vpsrldq xmm1, xmm0, 8 ; xmm1 = xmm0 >> 64
vpaddd xmm0, xmm0, xmm1 ; xmm0 += xmm1
vpsrldq xmm1, xmm0, 4 ; xmm1 = xmm0 >> 32
vpaddd xmm0, xmm0, xmm1 ; xmm0 += xmm1
vmovd eax, xmm0 ; xmm0 반환
이 시퀀스는 결과의 “윗절반”을 아랫절반에 반복해서 더해, 마지막에 하나만 남을 때까지 줄여 나간다. 이 마무리 코드는 약간의 추가 작업이지만, 루프의 효율이 이를 상쇄한다2.
float로 바꿔서3 무슨 일이 일어나는지 보자:
여전히 루프 반복당 32바이트 분량의 float를 처리하고 있기는 하다4. 하지만 불행한 일이 벌어지고 있다:
.L2:
vaddss xmm0, xmm0, DWORD PTR [rdi] ; xmm0 += 첫 번째 원소
add rdi, 32 ; 다음 8개 float로 이동
vaddss xmm0, xmm0, DWORD PTR [rdi-28] ; xmm0 += 두 번째
vaddss xmm0, xmm0, DWORD PTR [rdi-24] ; 등등
vaddss xmm0, xmm0, DWORD PTR [rdi-20] ; ...
vaddss xmm0, xmm0, DWORD PTR [rdi-16]
vaddss xmm0, xmm0, DWORD PTR [rdi-12]
vaddss xmm0, xmm0, DWORD PTR [rdi-8]
vaddss xmm0, xmm0, DWORD PTR [rdi-4] ; xmm0 += 여덟 번째
cmp rax, rdi ; 끝인가?
jne .L2 ; 아니면 루프
컴파일러는 루프 반복당 8개의 float를 처리하기로 해놓고… 정작 각 덧셈을 개별적으로 수행해 버렸다. 도대체 무슨 일이 일어난 걸까?
이걸 이해하려면 float 연산이 특별하다는 점을 떠올려야 한다. 연산이 결합법칙(associative)을 만족하지 않는다. 정수를 더할 때는 어떤 식으로든 묶어도 된다. 즉, (x + y) + z는 x + (y + z)와 같은 결과를 준다. 하지만 float는 그렇지 않다. 각 연산 뒤에는 반올림이 일어난다. x, y, z의 상대적인 크기에 따라 덧셈을 묶는 방식(재그룹화)을 바꾸면 결과가 달라진다.
정수 합 루프에서 SIMD를 활용하기 위해 컴파일러가 8개의 부분합을 만들고, 마지막에 부분합들을 합치도록 바꿨을 때, 이는 덧셈의 그룹핑을 바꾼 것이었다. 우리는 첫 원소부터 마지막 원소까지 순서대로 더하는 루프를 작성했는데, 컴파일러가 이를 “매 8번째 원소를 8개의 별도 그룹에 누적한 뒤, 그 결과들을 더한다”로 바꾼 것이다. 정수에서는 괜찮지만 부동소수점에서는 그렇지 않다.
그렇다면 우리는 꼼짝없이 막힌 걸까? 혹시 결합법칙을 신경 쓰지 않아도 된다는 것을 알고 있을지도 모른다. 여기서 -Ofast나 -funsafe-math-optimizations5 플래그를 쓰고 싶어 몸이 근질근질한 분도 있을 것이다. 두 플래그 모두 컴파일러가 부동소수점 수학 규칙을 전역적으로 무시할 수 있는 여지를 주며, 둘 다 동작한다6. 하지만 더 낫고, 더 표적화된 방법이 있다:
이 예제에서는 어노테이션을 사용해 sum 함수에 대해서만 GCC에 “수학이 결합법칙을 만족한다고 가정하라”고 지시한다. 즉, 영향이 그 함수 하나로 제한되며, 이 함수가 어떤 방식으로 컴파일되거나 사용되든 일관되게 적용된다. 그렇다. 안타깝게도 GCC 확장에 의존한다. 이론적으로는 비순차(unsequenced) 실행 정책과 함께 std::reduce를 사용할 수 있지만, 내 테스트에서는 여기서는 동작하지 않았다.
부동소수점 수학에는 특이하고 어쩌면 놀라운 함정이 있다. 이를 알고 있으면 컴파일러와 협력해 초고속 벡터화 코드를 얻는 데 도움이 된다.
이 글과 함께하는 동영상을 보라.
이 글은 Advent of Compiler Optimisations 2025의 21일차 글로, 25일 동안 컴파일러가 코드를 어떻게 변환하는지 탐구하는 시리즈다.
이 글은 사람(Matt Godbolt)이 작성했고, LLM과 사람의 리뷰 및 교정을 거쳤다.
Patreon 또는 GitHub에서 Compiler Explorer를 후원하거나, Compiler Explorer Shop에서 CE 제품을 구매해 지원해 달라.
맞다. 이런 일은 std::accumulate 같은 것이 하라고 있는 것이고, 그것들을 사용해도 비슷한 코드 생성이 보일 것이다.↩
특히 컴파일러가 처리할 원소가 65536개임을 볼 수 있는 이 상황에서는 더욱 그렇다. 원소 수가 가변적이라면, 컴파일러는 개수에 따라 조건부로 벡터화할지 결정하는 코드를 추가할 수도 있다. 어차피 원소가 8개보다 적은지 확인하기 위해서도 그런 검사가 필요하다.↩
어제 글로 돌아가서 float로 바꾸면, 컴파일러가 정수에서와 마찬가지로 벡터로 max를 잘 수행할 수 있음을 볼 수 있다.↩
컴파일러가 벡터화했는지 보는 내 휴리스틱은 루프를 찾고, 루프 카운터에 컴파일러가 얼마나 더하는지 확인하는 것이다. 한 번에 하나보다 많은 원소를 처리하는지 판단하는 좋은 출발점이다. 다만 이 경우에는 약간 오해를 불러일으킬 수 있다.↩
내가 가장 좋아하는 컴파일러 플래그이긴 하지만, 재미(fun)도 안전(safe)도 아니다.↩
예제에서 플래그를 편집해 실제로 동작하는 것을 볼 수 있다. 다만 둘 다 프로그램/컴파일된 번역 단위 전체에 전역적으로 영향을 준다. 그러면 실제로 적용하고 싶은 한두 개 함수 바깥에서 불행한 부작용을 낳을 수 있다. Chandler Carruth는 이를 “경계 없는(unbounded) 정밀도 손실”이라고 부르는데, 틀린 말이 아니다.↩
2025년 12월 21일 CST 06:00:00에 게시.
Matt Godbolt는 시카고에 사는 C++ 개발자다. Hudson River Trading에서 매우 재미있지만 비밀스러운 일들을 한다. Two's Complement 팟캐스트의 공동 진행자이기도 하다. Mastodon 또는 Bluesky에서 그를 팔로우하라.
저작권 2007-2025 Matt Godbolt. 별도 표기가 없는 한, 모든 콘텐츠는 Creative Commons Attribution-Noncommercial 3.0 Unported License에 따라 라이선스된다. 이 블로그는 Malcolm Rowe의 MalcBlogSystem으로 구동된다. 참고: 이것은 내 개인 웹사이트다. 이 페이지들에 표현된 견해는 전적으로 내 것이며 고용주의 견해가 아니다.