1P by GN⁺ | ★ favorite | 댓글 1개
  • 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에서 드러난 병목

  • Neatrelated_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개 필드 벡터를 사용함
  • TYPEdouble로 정의됨
  • 두 함수는 같은 덧셈을 수행하지만 인자 전달 방식이 다름
    • 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 레지스터로 전달되지 않느냐는 것임
  • 답은 이유를 모르지만 실제로는 그렇게 전달되지 않는다는 것임

댓글과 토론

Hacker News 의견들
  • 여기서 문제는 SysV amd64 ABI임. 언어 내부 ABI를 SysV로 쓰지 않아도 됨. SysV C 호출자에게 노출되지 않는다면 원하는 호출 규약을 써도 가능함
    https://llvm.org/docs/LangRef.html#calling-conventions
    궁금한 사람을 위해 neatlang의 관련 변경은 여기임: https://github.com/Neat-Lang/neat/commit/f4ba38cefc1e26631a5...
    단순히 LLVM 호출 규약 출력을 바꾸는 것보다 훨씬 복잡해 보임. 아마 작성자는 이 타입들을 C 프로그램에 결정적인 호출 규약으로 노출하고 싶었던 듯함

    • 사실 ABI 전반이 그렇다고 봐도 됨. 어셈블리 프로그래머라면 알겠지만, 이건 컴파일러를 쉽게 이길 수 있는 낮게 달린 과일 중 하나임
      관습을 맹목적으로 따르지 말고, 특정 상황에서 가장 말이 되는 방식을 택하면 됨
    • 처음 떠오른 질문에 이미 답이 있었음. 오래전에 만들어진 ABI 같은 것들을 많은 것이 따르는 게 흥미로움
      특히 ABI들은 더 오래된 CPU와의 호환성 쪽으로 기울어 있는 경우가 많고, 더 새 CPU는 확장 레지스터 같은 기능을 써서 구조체 크기를 줄이지 않고도 개선할 여지가 있을 수 있음. 특정 하드웨어나 세대에 맞춘 소프트웨어는 일부 머신에서 못 쓰게 되니 그리 매력적이지 않겠지만, 자기 시스템의 하드웨어 기능에 맞춰 코드를 극도로 최적화하고 싶을 때 그런 출력을 낼 수 있는 컴파일러가 있으면 멋질 듯함
  • 인자 전달 비용은 잘 이해되지 않는 경우가 많아서 이런 글이 반가움. Google 같은 곳에서도 24바이트 객체를 값으로 넘기는 일이 흔하고, 그 비용은 모든 함수에 넓게 퍼져 있어서 프로파일러에 잘 드러나지 않음

    • 값 전달과 참조 전달은 사실상 ABI/API에 영향을 주므로 꽤 큰 인지 부담이 됨. Zig는 이를 강제하지 않으려 해서 “값으로 전달”하더라도 컴파일러가 실제로는 참조로 전달하도록 결정할 수 있음
      다만 이런 발목잡기도 노출됨: https://github.com/ziglang/zig/issues/5973#issuecomment-1330...
    • “Google 같은 곳”이라니, 직접 겪은 얘기임? 전직 Googler로서 확실히 말하면, 원시 타입이 아닌 것은 포인터나 참조로 넘기라는 가이드라인이 있음
      생각나는 유일한 예외는 string_view 정도임
    • 호출 규약에 내장된 것처럼 넓게 분산된 오버헤드는 프로파일링으로 거의 못 찾는다는 점을 짚은 게 좋음
    • 24바이트 객체를 포인터로 대신 넘기면, 실제로 그 객체를 써야 할 때 포인터를 역참조해야 한다는 트레이드오프가 있음. 그런데 그 객체가 가까운 곳에 있다는 보장은 없음
      운이 나쁘면 캐시 미스가 나서 주 메모리에서 24바이트 객체를 가져오느라 100나노초쯤 기다릴 수 있음. 같은 객체를 직접 전달하면 스택에 있으므로 캐시에 있을 가능성이 큼
    • C++ ABI도 호출마다 24바이트 객체를 스택으로 흘리는지 궁금함. std::string이나 std::function 매개변수가 빠를 거라고 기대하진 않지만 그래도 놀라움
  • x64로 처음 넘어갔을 때 그래픽스의 vec3 객체(float 3개)가 sizeof()=12가 아니라 16바이트로 커지는 게 걱정돼서 그래픽스 엔진을 엄청 벤치마크했음
    놀랍지 않게도 8바이트 읽기 정렬 덕분에 12바이트보다 16바이트가 더 빨랐음. 내부에서도 GPU에서도 그랬음. 그래서 vec3는 조용히 vec4가 됐고, 별도로 vec4도 여전히 존재함. 언제나 그렇듯 지역 벤치마크가 아니라 전체 관점의 벤치마크를 해야 함

    • SSE 크기와도 잘 맞는 아주 좋은 부수효과가 있음. 그래서 _mm_load_ps를 직접 쓸 수 있고, 코드가 더 깔끔해지며 벡터화도 매우 쉬워짐
    • 아마 훨씬 빠르진 않을 듯함. 그리고 이 데이터로 무엇을 하느냐와 별개로 CPU에도 많이 좌우되지 않나 싶음
      16바이트라면 많은 접근이 3x4바이트 대신 정렬된 2x8바이트 또는 1x16바이트가 될 수 있다는 건 이해됨. 하지만 다른 접근에서는 덜 그럴 수도 있고, 캐시 압박 증가 문제도 있음
    • x64 ABI는 x86 ABI보다도 상당히 나음
  • 상식적으로, 레지스터에 전달되는 값은 추측 실행 덕분에 미리 적재될 수 있어 스택 쓰기보다 빠르고, 스택 조작은 힙 할당보다 빠름
    그래서 전역 변수가 잔뜩 있는 지저분한 스파게티 코드가 엄청 빠르고, 우아한 재귀 함수나 튜플/구조체/리스트 인자는 믿기 어려울 만큼 느림. 전자는 촘촘한 어셈블리 루프로 최적화하기가 훨씬 쉬움

    • 물론 그 스파게티 코드가 우아한 코드와 같은 알고리즘을 구현한다는 전제가 필요함
      우아한 코드가 O(n)이고 스파게티 코드가 O(n^2)라면 차이를 느끼게 될 수 있음. 유지보수도 고려해야 함. 어떤 의미에서 컴파일러는 우리의 우아한 해법을 스파게티 코드로 바꾸기 위해 존재함
    • “매개변수는 스택이 아니라 레지스터로 넘겨라”는 상식에 가깝지만, “16바이트보다 큰 매개변수는 항상 스택으로 전달된다”는 그만큼 명백하지 않음
    • 요즘 일부 CPU는 메모리 이름 변경을 할 수 있어서 스택으로 흘리는 비용이 더 싸질 수 있음
      전역 객체는 컴파일러 최적화도 방해함
  • 참고로 MSVC에서는 구조체가 스택으로 전달되기 전 컷오프 크기가 8바이트임. 이건 ABI 세부사항이라 이식 가능한 코드에서 의존하면 안 됨
    하지만 자주 호출되지 않는 함수라면 너무 스트레스 받을 필요도 없음. 예시처럼 자주 호출되는 작은 함수라면 LTO 같은 방식으로 컴파일러가 코드를 인라인할 수 있게 하면 됨. 그러면 인자를 레지스터로 넘기는 것보다 훨씬 더 유용한 최적화들이 열림

  • 이런 글은 “골칫거리가 되기에 딱 충분한 지식”으로 분류함. 지시대로 따로 컴파일해서 컴파일러가 ABI로 호출 가능한 함수를 만들게 강제하더라도, LTO로 이 오류를 되돌릴 수 있음
    이 프로그램을 LTO로 빌드하면 LTO 없는 프로그램의 어느 모드보다 두 모드 모두에서 극적으로 빨라짐. 성능이 민감한 프로그램이라면 프로파일링하고, 병목을 극한까지 최적화한 뒤에야 구조체를 인자로 풀어헤치는 식의 짓을 커밋해야 함

    • 좋은 조언이긴 한데, 이런 종류를 보이게 해주는 컴파일러를 아직 본 적이 없음. 먼저 코드베이스 전체에 분산되어 있고, 운 좋게 핫스팟이 되지 않는 한 그 영향을 보여주는 프로파일러도 못 봤음
      거의 모든 컴파일러 생성 코드에 해당함. Valgrind는 측정할 수 있겠지만 표본추출 프로파일러는 아마 못 할 것이고, 흩어진 코드 생성 문제를 강조해 주는 도구가 없음
    • 게다가 성능의 절대적 중요성을 말하면서 참조 카운팅을 쓰고 있음
  • Windows의 기본 cdecl 호출 규약에서는 8바이트보다 큰 구조체가 레지스터로 전달되지 않음 [1]
    [1]: https://learn.microsoft.com/en-us/cpp/build/x64-calling-conv...

  • amd64에서 SysV amd64 ABI를 쓰더라도 16바이트보다 큰 구조체를 값으로 전달하고 반환하는 건 완전히 가능함. 그냥 느릴 뿐임
    그래도 코드를 더 명확하게 만들기 위해 값 전달이 가치 있는 경우가 많음. 물론 이 경우는 아니지만, loeg가 지적했듯 자기 언어 내부에서는 C++ 컴파일러들, Go, OCaml, SBCL처럼 사용자 정의 ABI를 쓰면 됨

  • 제공된 예시에서는 호출자에 영향을 주지 않고 매개변수 타입을 “struct Vector”에서 “const struct Vector &”로 바꿔 참조 전달하면 고칠 수 있음
    포인터 버그가 있던 많은 C++ 코드가 굳이 포인터를 썼는데, 참조 전달로도 충분했고 더 쉽고 안전하게 쓸 수 있었던 경우를 많이 봤음

    • 아님. 사실 그게 바로 여기서의 핵심 문제임. ABI 덕분에 컴파일러가 사실상 정확히 그 일을 함
      ABI가 값을 포인터로 전달하라고 하므로 포인터를 얻기 위해 어딘가에 저장해야 하고, const-ref로 명시했을 때와 같은 일이 벌어짐. 구조체 값을 별도 인자로 바꾸면 인자를 레지스터로 전달할 수 있음
    • 이 문제를 발견했을 때는 byval에 대한 포인터를 넘기려고 alloca가 스무 개나 서른 개 있는 코드였음. 모든 함수가 호출에 전달되는 각 매개변수마다 별도 alloca로 시작했음
      LLVM이 이런 걸 잘 정리해 줄 거라고 늘 어느 정도 가정했는데, 알고 보니 그렇지 않았음
    • 그래도 구조체 포인터를 피호출자에게 넘기려면 컴파일러가 세 레지스터를 스택에 직렬화해야 함
      설명된 이점은 레지스터에서 스택으로의 직렬화를 전혀 피하는 것인데, 참조 전달로는 피할 수 없어 보임
    • 이건 C++ 예제가 아니라 C99 예제였음. 많은 환경에서는 최소한의 관성 때문에 도구를 마음대로 바꿀 수 없음
      C++를 허용한다면 복사를 줄이기 위한 이동 인자 같은 더 많은 선택지가 생김
  • C++에서 늘 들어온 경험칙은 원시 타입이 아닌 것은 값으로 넘겨야 할 좋은 이유가 없는 한 참조로 넘기고, 정말 필요하면 포인터로 넘기라는 것임
    ABI 때문이기도 하고 복사 생성자나 이동 생성자를 피하기 위해서이기도 함. 지루한 저수준 세부사항이지만, C++에서 최고 성능을 원하면 신경 써야 하는 부분임. 분명히 말하면 이건 성능 최적화일 뿐이고, 구조체 전달 코드는 정상 동작하지만 덜 빠를 뿐임