- Neat의 related_post_gen 벤치마크 순위 상승은 고수준 최적화가 아니라, 배열을 24바이트 구조체 대신 포인터 3개 인자로 넘긴 작은 ABI 변경에서 나옴
- Neat 배열은 참조 카운터 때문에 시작·끝 포인터 외에 배열 객체 기반 포인터가 필요해, D 배열의 16바이트와 달리 SystemV AMD64 ABI의 메모리 전달 경로를 타게 됨
- 16바이트를 넘는 특정 aggregate는 호출 시 스택에 값을 복사한 뒤 포인터로 전달되어, 레지스터 전달의 이점을 잃고 스택 셔플 비용이 커짐
- 예제 벤치마크에서
struct Vector { double x, y, z; }를 구조체로 넘기면 10억 회 실행이 12.3초, 필드를 개별 인자로 넘기면 5.3초로 줄어듦
- C API는 C ABI를 따라야 하지만, 언어 런타임 내부의 배열·튜플·sumtype 같은 타입은 16바이트를 넘을 때 필드 분리 전달을 벤치마크할 가치가 있음
Neat에서 드러난 병목
- Neat는 related_post_gen 벤치마크에서 순위가 몇 단계 올라감
- 성능 향상은 새로운 고수준 최적화 패스가 아니라, 배열 전달 방식을 바꾼 결과임
- 기존: 3개 포인터를 담은 구조체 인자
- 변경: 3개 포인터를 각각 인자로 전달
- Neat는 D와 비교해 예상보다 느렸고, 프로파일러에서는 함수 호출을 위해 스택의 큰 영역을 옮기는 동작이 보임
- 병목은 계산 자체보다 호출 시점의 스택 재배치 비용에 가까웠음
Neat 배열이 24바이트가 되는 이유
- D 배열과 달리 Neat는 참조 카운터를 사용함
- Neat 배열은 다음 3개 포인터를 포함함
- 시작 포인터
- 끝 포인터
- 참조 카운트가 저장된 배열 객체 기반 포인터
- 포인터 3개는 24바이트이므로, 포인터 2개의 16바이트 배열과 AMD64 인자 전달 규칙에서 다른 경로를 탐
- D 배열이 빠르고 Neat 배열이 느렸던 이유는 24바이트가 되면서 16바이트 경계를 넘었기 때문임
SystemV AMD64 ABI의 16바이트 경계
- SystemV AMD64 ABI specification는 특정 aggregate 크기가 두 개의 eightbyte를 넘으면 전체 인자를 메모리로 전달한다고 규정함
- 구조체를 메모리로 전달하려면 호출 쪽에서 다음 작업이 필요함
- 스택에 구조체 크기만큼 공간을 할당함
- 전달할 값으로 그 공간을 채움
- 함수에는 해당 구조체 위치의 포인터를 넘김
- 이 경우 값이 반드시 스택에 있어야 하므로 LLVM의 최적화 여지가 작아짐
- 값은 레지스터에서 스택으로 복사되어야 하고, 스택의 어느 부분이 사용 중이며 어느 부분을 재사용할 수 있는지도 추적해야 함
- 이 스택 재사용 추적에서 LLVM이 좋지 않은 모습을 보였음
3개 double 벡터 벤치마크
- 벤치마크는
struct Vector { TYPE x, y, z; }; 형태의 3개 필드 벡터를 사용함
TYPE은 double로 정의됨
- 두 함수는 같은 덧셈을 수행하지만 인자 전달 방식이 다름
vector_add_struct(struct Vector left, struct Vector right)는 큰 구조체를 인자로 받음
vector_add_fields(...)는 left_x, left_y, left_z, right_x, right_y, right_z를 개별 인자로 받음
mode와 실행 길이는 명령줄 인자로 받아 최적화기가 전체 계산을 상수 접기하지 못하게 함
impl.c는 인라이닝을 피하기 위해 별도로 컴파일함
clang -O3 impl.c -c -o impl.o
clang -O3 harness.c impl.o -o benchmark
time ./benchmark 0 1000000000
time ./benchmark 1 1000000000
- 결과는 구조체 전달에서 12.3초, 필드 개별 전달에서 5.3초였음
어셈블리에서 보이는 차이
- 구조체 전달 버전은 많은 명령을 스택 셔플에 사용함
- 필드 버전은 파라미터가 함수에 들어올 때 이미 SSE 레지스터에 있다는 점에서 유리함
- 구조체 전달 버전은 매번 스택에서 값을 로드해야 함
- SystemV ABI는 값을 최대한 레지스터로 전달하려는 목적을 갖지만, 이 사례에서는 16바이트 초과 구조체 때문에 그 이점이 사라짐
- AMD64에서 사용할 수 있는 레지스터 수를 고려하면 16바이트 초과 타입에도 값 전달이 유용했을 것이라고 봄
cdecl과 비슷해지는 상황
- 필드를 스택에 쓰고 포인터를 넘기는 방식은 결과적으로 모든 것을 스택으로 넘기던 예전 x86
cdecl ABI와 비슷해짐
cdecl은 느리다고 알려져 있었고, 이를 빠르게 만들기 위한 여러 호출 규약이 생겼음
- AMD64 System V ABI가 구조체 크기 때문에 이 경우 예전 스택 전달 방식처럼 동작한다는 점이 문제임
인라이닝과 LTO의 예외
- 실제 코드에서는 이런 함수가 모두 인라이닝될 수 있음
gcc에서 LTO를 켜면 두 버전 사이의 성능 차이가 사라짐
clang에서는 같은 결과가 나오지 않음
- 모든 함수가 인라이닝될 수 있거나 인라이닝되어야 하는 것은 아님
언어 구현자와 API 최적화에 대한 조언
- C API를 호출할 때는 C ABI를 따라야 함
- 그러나 비-C 언어 내부의 고수준 타입은 백엔드에는 구조체처럼 보이더라도 반드시 하나의 구조체로 표현할 필요가 없음
- 언어 구현자는 배열, 튜플, sumtype 등을 어떻게 전달할지 직접 정할 수 있음
- Neat에서는 16바이트를 넘는 이런 타입들을 개별 필드로 전달하도록 선택했고, 벤치마크에서 이점이 나타남
- AMD64에서 언어 구현을 하거나 API를 미세 최적화한다면, 16바이트 초과 구조체를 수동으로 나누는 방식이 도움이 되는지 벤치마크할 필요가 있음
- 특히 내부 루프에서는 성능 차이가 예상보다 클 수 있음
덧붙임: double 구조체와 SSE
- 질문은
double이 명세상 SSE 클래스인데 왜 구조체가 SSE 레지스터로 전달되지 않느냐는 것임
- 답은 이유를 모르지만 실제로는 그렇게 전달되지 않는다는 것임