GN⁺: 우리가 마땅히 받아야 할 Rust 호출 규약(calling convention)
(mcyoung.xyz)이 글에서는 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로 제거됨
- Rust 수준 인자를 레지스터 입력에서 디코딩해서
- 함수 반환 블록 생성
-
-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로 끌 수 있음.