Hacker News

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