2P by neo 7달전 | favorite | 댓글 1개

이 글에서는 Rust 언어의 Calling Convention을 개선하는 방법에 대해 상세히 설명하고 있음

Rust의 현재 Calling Convention의 문제점

  • Rust는 현재 호출 규약(Calling Convention)이 명확히 정의되어 있지 않음
  • 실제로는 LLVM의 기본 C 호출 규약을 사용하고 있음
  • Rust는 현재 보수적으로 Clang이 생성할 법한 LLVM 함수 시그니처를 생성하려고 함
    • 디버거와의 호환성을 위해
    • LLVM 버그를 피하기 위해
  • 하지만 너무 보수적이라서 간단한 함수에 대해서도 나쁜 코드를 생성함
fn extract(arr: [i32; 3]) -> i32 { arr[1] }
  • 위 코드는 레지스터로 전달되어야 하지만 포인터로 전달됨
  • Rust는 C ABI보다 더 보수적임. extern "C"로 지정하면 레지스터로 전달함.

새로운 Calling Convention 제안

  • extern "Rust" 함수에 대해 기존 호출 규약은 유지
  • -Zcallconv 플래그를 추가해서 extern "Rust" 함수의 호출 규약을 설정
    • -Zcallconv=legacy는 현재 방식
    • -Zcallconv=fast는 새로 설계할 방식
  • 왜 기존 호출 규약을 유지해야 하나?
    • 디버깅 용이성을 위해 C ABI 순서로 배치하지 않음
    • WASM 같은 일부 타겟은 지원하지 않을 수 있음
    • 디버그 빌드에서는 의미가 없을 수 있음
  • 함수 포인터 및 extern "Rust" {} 블록과 관련된 주의사항
    • 크레이트 단위 플래그라서 함수 포인터에는 적용 불가
    • 함수 포인터 호출은 느리고 드물기에 -Zcallconv=legacy 사용
    • 필요하면 Shim을 생성해서 호출 규약 변환
    • extern "Rust" { fn my_func() -> i32; } 처럼 직접 호출하는 경우
      • Mangling되지 않은 심볼만 호출 가능
      • #[no_mangle] 함수는 기존 호출 규약 사용

LLVM 활용 방안

  • 이상적으로는 LLVM에 호출 규약을 직접 지정할 수 있으면 좋겠지만 현실적으로 어려움
  • 다음과 같은 절차로 우회 가능
    • 주어진 타겟에 대해 레지스터로 전달 가능한 최대 값 개수 확인
    • 반환값을 어떻게 전달할지 결정. 레지스터에 맞으면 그대로, 크면 참조로 전달
    • 값으로 전달된 인자 중 참조로 전달해야 할 것 선별
      • 레지스터로 전달 가능한 공간보다 큰 것들
      • x86에서는 176바이트 정도
    • 레지스터 공간을 최대한 활용하기 위해 어떤 인자를 레지스터로 전달할지 결정
      • NP-hard 문제라서 휴리스틱이 필요함
      • 나머지는 스택으로 전달
    • LLVM IR로 함수 시그니처 생성
      • 레지스터로 전달되는 인자들은 i64, ptr, double, <2 x i64> 등의 비집합체로 표현
      • 스택으로 전달되는 인자는 "레지스터 입력"을 따름
    • 함수 프롤로그 생성
      • Rust 수준 인자를 레지스터 입력에서 디코딩해서 -Zcallconv=legacy 때와 동일한 %ssa 값 생성
      • 함수 본문은 호출 규약에 상관없이 동일한 코드 생성 가능
      • 불필요한 디코딩 코드는 DCE로 제거됨
    • 함수 반환 블록 생성
      • -Zcallconv=legacy 때와 동일한 반환 타입에 대한 phi 명령어 포함
      • 필요한 출력 형식으로 인코딩하고 ret으로 반환
      • ret 대신 이 블록으로 분기해야 함
    • 함수 포인터로 사용될 수 있는 비다형적, 비인라인 함수가 있으면
      • 크레이트 밖으로 노출되거나 함수 포인터로 전달되는 경우
      • -Zcallconv=legacy를 사용하는 Shim을 생성하고 실제 구현을 Tail Call
      • 함수 포인터 동등성을 유지하기 위해 필요함

LLVM의 레지스터 전달 한계 확인 방법

  • LLVM이 허용하는 최대 레지스터 전달 개수를 확인하는 LLVM 프로그램
  • x86에서는 정수 6개, SSE 벡터 8개 입력과 정수 3개, SSE 벡터 4개 출력 가능
  • aarch64에서는 정수 8개, 벡터 8개로 입력과 출력이 동일함
  • 이를 넘어가면 스택에 전달됨

Rust의 구조체와 열거형 처리

  • rustc가 이미 기본 집계와 공용체로 처리했다고 가정
  • 반환값 처리
    • 구조체 크기가 아니라 패딩을 제외한 실제 데이터 크기가 중요
    • [(u64, u32); 2]는 32바이트지만 패딩 8바이트 제외하면 24바이트
    • 타입의 유효 크기(Effective Size)를 정의
      • 패딩을 제외한 비정의 비트 수
      • [(u64, u32); 2]는 192비트
      • bool은 1비트
    • 유효 크기가 출력 레지스터 공간보다 작으면 값으로 반환
    • x86에서는 정수 3개 + SSE 4개 = 88바이트 = 704비트
  • 인자 레지스터 처리
    • Knapsack 문제로 NP-hard
    • 간단한 휴리스틱
      • 유효 크기가 전체 입력 레지스터 공간보다 크면 참조로 전달
      • 열거형은 판별자-공용체 쌍으로 대체
      • 공용체는 초기화되지 않은 비트를 건드릴 수 있으므로 u8 배열이나 비어있지 않은 한 개 변형으로 전달
      • 포인터, 정수, 실수, 불리언 등 가장 기본 요소로 평탄화
      • 유효 크기 기준 오름차순 정렬
      • 가능한 큰 접두사를 레지스터에 할당하고 나머지는 스택에
      • 스택으로 갈 입력의 일부가 포인터 크기의 작은 배수보다 크면 스택의 포인터로 전달
      • 나머지는 정렬 전 순서대로 스택에 직접 전달
      • 레지스터로 전달할 것은 크기 역순으로 할당
      • 불리언은 64개씩 비트 패킹

GN+의 의견

  • 개인적으로 Rust의 현재 호출 규약이 매우 아쉬움. C++보다 훨씬 나은 성능 낼 수 있는데 아직 못하고 있음
  • Go 언어가 이미 오래전에 구현한 방식임
  • Rust가 적용 못하는 이유
    • ABI 코드 생성이 복잡하고 LLVM이 별로 도움이 안됨
    • 컴파일러 팀에 LLVM을 잘 아는 사람이 별로 없음
    • 컴파일 시간에 대한 우려가 있지만 최적화 빌드에서만 쓸거라 큰 문제는 아님
  • 필자는 직접 고칠 시간은 없지만 LLVM에 대한 전문성을 바탕으로 Rust 컴파일러 팀을 도울 용의가 있음
  • 아니면 그냥 extern "C"extern "fastcall"로 전환하는 것도 대안이 될 수 있음
Hacker News 의견

요약:

  • 최적화된 호출 규약(Calling Convention)을 만들 때는 성능을 직접 측정하는 것이 중요함. 이상해 보이는 코드가 실제로는 가장 빠를 수 있음.
  • 오늘날의 CPU는 C 컴파일러가 생성한 명령어 추적을 최적화하므로, C 컴파일러처럼 스택에 자주 전달하는 것이 도움될 수 있음.
  • 인라이닝이 성공적이어서 호출은 드문 경계가 되므로, 다른 것을 단순화하기 위해 경계에 약간의 불규칙성을 허용할 수 있음.
  • Rust의 구조체는 필드에 대한 참조를 제공해야 하므로, C보다 크기가 커질 수 있음. Option<u8> 필드 8개를 가진 구조체는 Rust에서 16바이트, C에서는 9바이트임.
  • Rust에서는 수동으로 C와 동등한 구현을 할 수 있지만, &Option<T> 또는 &mut Option<T>로는 매핑할 수 없음.
  • Rust는 아직 Rust 수준 의미론을 위한 호출 규약이 없음. Apple은 이를 구축할 동기가 있었지만 Rust는 그런 지원이 없음.
  • Go와 Rust 간의 상호 운용성은 현재 Zig을 중간에 사용하여 달성 가능함.
  • 현재 Rust 컴파일러는 공격적인 인라이닝을 수행하고 최적화하므로, 이 문제를 해결할 가치가 있는지 의문임.
  • 디버깅을 위해 Cargo.toml 플래그를 사용하여 우려를 피할 수 있음. 필드를 크기순으로 정렬하는 것은 쉬운 최적화이며, repr로 끌 수 있음.