Trying to Make a Loop Auto-Vectorize
루프를 자동 벡터화시키기 — Rust에서 부동소수점 합산과 SIMD 최적화
Rust로 작성한 단순한 내적(dot product) 루프가 자동 벡터화되지 않는 이유는 IEEE 754 부동소수점 덧셈이 결합법칙을 만족하지 않아 컴파일러가 연산 순서를 바꿀 수 없기 때문입니다. 저자는 수동 청킹과 Rust 1.98에서 안정화된 algebraic 연산자로 벡터화를 유도하는 과정을 어셈블리와 함께 보여주며, 컴파일러가 생성한 코드가 naive한 intrinsics 구현보다 빠르다는 결과를 확인합니다.
- 주제
본문
Rust 개발자 jsgroth가 Compiler Explorer로 생성된 x86 어셈블리를 따라가며, float 배열의 곱셈 누적 루프가 왜 자동 벡터화되지 않는지, 그리고 어떻게 하면 벡터화시킬 수 있는지를 분석한 글입니다. Rust 1.98.0 stable 컴파일러 기준으로, 깊은 SIMD 전문 지식이 없어도 따라갈 수 있는 수준의 실험 기록입니다.
벡터화되지 않는 단순한 루프
길이 1600의 f32 배열 두 개에 대해 iter::zip과 fold로 곱의 합을 구하는 dot 함수를 x86-64 + opt-level=3으로 컴파일하면, xmm 벡터 레지스터를 쓰면서도 movss·mulss·addss 같은 32비트 스칼라 명령어만 나옵니다. x86-64 베이스라인에 포함된 SSE/SSE2의 128비트 f32x4 연산으로 충분히 벡터화할 수 있는데도 컴파일러가 선택하지 않은 것입니다. #[target_feature(enable = "avx2,fma")]를 붙여도 VEX 인코딩으로 바뀔 뿐 기능적으로 동일한 스칼라 코드가 나오고, 수동 for 루프로 바꿔도 어셈블리는 같습니다.
원인은 IEEE 754의 비결합성
부동소수점 덧셈은 결합법칙이 성립하지 않습니다. (a + b) + c와 a + (b + c)가 반올림 때문에 다를 수 있습니다. 배열을 4개씩 묶어 f32x4 누산기에 더한 뒤 마지막에 합치는 자연스러운 벡터화는 덧셈 순서 재배열 없이는 불가능하고, 컴파일러는 결과가 바뀔 수 있는 변환을 거부합니다. 반면 i32로 바꾼 동일 함수는 정수 연산이 결합법칙을 만족하므로 paddd·pmulld·movdqu를 쓰는 128비트 벡터 코드로 자동 벡터화됩니다. 문제가 부동소수점 특유의 것임을 보여주는 대조 실험입니다.
수동 청킹과 그 한계
as_chunks::<4>로 배열을 4개 단위로 나누고 [f32; 4] 배열에 누산하도록 직접 작성하면 addps·mulps·movups를 쓰는 벡터 코드가 나옵니다. 다만 청크 크기가 4로 고정되어 AVX의 256비트 f32x8을 활용하지 못하고, FMA도 반올림 동작이 mul+add와 미묘하게 달라 결과를 바꿀 수 있어 사용되지 않습니다.
Rust 1.98의 algebraic 연산자
GCC의 -ffast-math 계열 플래그와 유사한 효과를 stable Rust에서 연산 단위로 선언할 수 있게 해주는 algebraic_add·algebraic_mul이 Rust 1.98에서 안정화되었습니다. 컴파일러가 연산을 재배열하는 등 대수적으로 동등한 최적화를 허용한다는 의미입니다. 이를 적용하면 코드는 Java스러운 문법이 되지만, SSE 빌드에서 두 개의 f32x4 누산기를 쓰는 벡터 코드가 생성되고 Zen 4에서 수동 청킹 버전보다 눈에 띄게 빨랐습니다. AVX2+FMA 빌드에서는 코드 변경 없이 ymm 레지스터와 vfmadd 명령어, 네 개의 누산기를 쓰는 코드가 나옵니다. 자주 쓴다면 Wrapping<T> 같은 뉴타입으로 연산자 오버로딩을 감쌀 수 있다고 저자는 덧붙입니다.
순진한 intrinsics가 더 느린 이유
_mm256_loadu_ps와 _mm256_fmadd_ps로 직접 짠 AVX intrinsics 버전은 누산기가 하나뿐이라 명령어 간 의존성 체인이 직렬화되고, out-of-order 실행 하드웨어가 병렬성을 뽑지 못합니다. 결과적으로 자동 벡터화된 AVX 버전보다 상당히 느리고, 심지어 128비트 SSE 버전보다도 약간 느렸습니다. 재배열 권한을 얻은 컴파일러가 사람의 naive한 SIMD 코드보다 나은 선택을 할 수 있다는 점이 흥미로운 결론입니다.
정렬과 AVX-512 후기
#[repr(align(32))] 뉴타입으로 데이터를 벡터 경계에 정렬하면 성능상 이득이 있는데, 최신 CPU는 정렬된 주소에 대한 비정렬 로드(vmovups)도 정렬 로드(vmovaps)와 같은 속도이므로 명령어 종류보다 데이터 정렬 자체가 중요합니다. AVX-512 빌드는 zmm f32x16을 쓰는 유사 코드를 생성하지만 Zen 4는 256비트 유닛을 더블 펌핑하므로 성능은 비슷하고, Skylake-X 계열은 오히려 악영향이 있어 컴파일러가 512비트를 회피하기도 합니다. 다만 마스크드 로드/스토어와 두 배 많은 벡터 레지스터 때문에 256비트 코드에도 AVX-512는 여전히 유용하다고 정리합니다.
Hacker News 반응
- @jvanderbot — 기상 데이터와 비행 텔레메트리를 빠르게 처리하는 업무에서 자동 벡터화를 많이 다루는데, Rust에서 이를 얻는 것은 악몽에 가깝다고 합니다. 결국 SIMD 유사 API를 쓰거나 문제를 행렬-벡터 연산으로 재구성해 검증된 C 라이브러리를 호출하는 방향으로 수렴했다고 말합니다.
- @the__alchemist — 자동 벡터화 여부를 판별할 방법을 몰라 일단 안 된다고 가정하고 수동 SIMD를 쓴다고 합니다. stable에서 core::simd를 흉내 낸 x86 전용 라이브러리를 만들어 쓰는 중이라고 덧붙였습니다.
- @jvanderbot — 함수 단위 디스어셈블리는 cargo-show-asm으로 확인할 수 있으며, 요즘은 AI 에이전트가 이 과정을 안내해 줄 수 있어 진입 장벽이 낮아졌다고 조언합니다.
- @srean — 2008~2010년경 gcc/g++가 루프를 벡터화하지 못한 이유를 의외로 자세히 설명해 줘서 놀랐고, 당시에는 최적화 진단 메시지가 clang보다 gcc가 나았다는 경험을 공유합니다.
원문: jsgroth.dev / 번역·요약: Trawling