- Rust의 현재
extern "Rust"호출 규약은 LLVM의 C 호출 규약 경로에 기대며, 복잡한 값 전달에서 레지스터 활용이 보수적이라 더 나은 코드 생성을 놓치고 있음 - 크레이트 단위 플래그
-Zcallconv로 현재 방식인legacy와 새 레지스터 중심 방식인fast를 나누고, 최적화 빌드에서 더 공격적인 ABI를 쓰는 구상이 핵심임 - LLVM에 새 호출 규약을 직접 추가하지 않아도, 고정된 LLVM 함수 시그니처와
poison값으로 사용하지 않는 레지스터 인자를 비용 없이 비워두며 인자 배치를 제어할 수 있음 - 구조체, enum, union,
bool,Result같은 Rust 타입은 패딩을 제외한 유효 크기, 평탄화, 비트 패킹, 스택/레지스터 분할 휴리스틱으로 더 조밀하게 전달 가능함 - 함수 본문, borrow checker 정보, 프로파일 정보를 ABI 결정에 반영하면 더 강한 최적화가 가능하지만, rustc ABI 코드 생성의 복잡도와 LLVM 전문성 부족이 현실적인 장벽으로 남아 있음
Rust가 현재 놓치는 호출 규약 최적화
- 호출 규약(calling convention) 은 함수 인자와 반환값을 어떻게 전달할지, 어떤 레지스터를 쓸지, 프롤로그/에필로그와 언와인딩을 어떻게 처리할지 정하는 ABI의 일부임
- Rust는 자체적으로 unspecified 호출 규약을 정의하지만, 실제로는 LLVM의 내장 C 호출 규약으로 낮춰지고 LLVM의 프롤로그/에필로그 코드 생성에 의존함
- rustc는 Clang이 만들 법한 LLVM 함수 시그니처를 생성하려고 보수적으로 동작함
- 디버거가 깨질 가능성을 줄일 수 있음
- Clang이 잘 쓰지 않는 ABI 코드 생성 경로로 LLVM 버그를 건드릴 가능성을 낮출 수 있음
- ELF 기반 시스템에서는 DWARF가 Linux C ABI를 박아두지 않으므로, 글의 범위에서는 디버깅 가능성을 핵심 문제로 보지 않음
- 단순한 예로
fn extract(arr: [i32; 3]) -> i32에서 12바이트 배열이 레지스터가 아니라 포인터로 전달됨extern "C"를 붙이면 같은[i32; 3]가rdi,rsi에 packed되어 전달됨- Rust 기본 경로가 Linux C ABI보다도 더 보수적인 사례임
-Zcallconv: legacy와 fast를 나누는 방식
extern "Rust"의 현재 호출 규약은 유지하되, 크레이트 컴파일 플래그-Zcallconv로 사용할 호출 규약을 선택함-Zcallconv=legacy: 현재 방식-Zcallconv=fast: 새로 설계하는 레지스터 중심 방식-O가 자동으로-Zcallconv=fast를 설정할 수도 있음
fast호출 규약은 인자를 C ABI 순서에 맞춰 배치하지 않으므로, x86의 관용적 레지스터 순서를 기대하는 사람이 보면 혼란스러울 수 있음- WASM처럼 레지스터와 spilling 개념이 없는 타깃에서는
-Zcallconv=fast가 지원되지 않을 수 있음 - 최적화를 끈 debug 빌드에서는
fast가 더 나쁜 코드를 만들 수 있어 활성화가 적절하지 않을 수 있음 - 함수 포인터와
extern "Rust" {}블록에는 별도 제약이 필요함- 플래그는 크레이트 단위지만 함수 포인터는 어떤
extern "Rust"버전을 쓰는지 표현하기 어려움 - 함수 포인터 호출은 느리고 드문 경로로 보고
-Zcallconv=legacy를 강제할 수 있음 - 필요하면 호출 규약을 변환하는 shim을 생성함
- unmangled 심볼을 호출할 수 있는 경로 때문에
#[no_mangle]심볼도 legacy 호출 규약을 쓰게 할 수 있음
- 플래그는 크레이트 단위지만 함수 포인터는 어떤
LLVM을 우회적으로 조종하는 방식
- 이상적으로는 LLVM에 “이 인자는 이 레지스터, 이 반환값은 저 레지스터”처럼 직접 호출 규약을 지정하고 싶지만, LLVM에 호출 규약을 추가하려면 C++ 코드를 많이 작성해야 함
- 대신 다음 절차로 자체 호출 규약에 가까운 효과를 낼 수 있음
- 타깃 triple별로 레지스터로 전달 가능한 값의 최대 개수를 결정함
- 반환값이 출력 레지스터에 들어가는지, 아니면
sret속성이 붙은 추가ptr인자로 by-reference 반환해야 하는지 결정함 - 너무 큰 by-value 인자는 by-reference로 낮춤
- 어떤 인자를 레지스터로 보낼지 정해 레지스터 공간 사용률을 최대화함
- 나머지 인자는 스택에 둠
- LLVM IR 함수 시그니처는
i64,ptr,double,<2 x i64>같은 non-aggregate 인자들로 구성함 - 함수 프롤로그에서 레지스터 입력을 Rust 수준 인자로 디코딩함
- 함수 종료 블록에서 반환값을 필요한 출력 형식으로 인코딩한 뒤
ret함 - 주소를 취할 수 있는 non-polymorphic, non-inline 함수에는 legacy shim을 만들어 함수 포인터 동일성을 보존함
- 어떤 값을 레지스터에 넣을지 결정하는 문제는 배낭 문제(knapsack problem) 와 같아 NP-hard이며, 실제 구현에는 휴리스틱이 필요함
- 이 정보는 너무 늦게 계산하지 않고
rmeta에 넣어 재계산을 피할 수 있음 - Rust는 릴리스마다 ABI가 깨지므로, 여러 다른 Rust 컴파일러가 생성한 코드를 링크하지 못하게 해야 한다는 조건은 이미 현재 상황과 맞음
LLVM이 허용하는 레지스터 전달 한계
- LLVM은 aggregate by-value 인자를 함수에 전달할 때 가능한 한 많이 레지스터로 “explode”하려고 함
- x86에서는 LLVM이 레지스터로 전달할 수 있는 입력이 대략 다음과 같음
- 정수 6개
- SSE 벡터 8개
- 반환은 그 절반인 정수 3개와 벡터 4개
aarch64-unknown-linux에서는 입력과 출력 모두 정수 8개, 벡터 8개가 가능함- x86의 모든
-Zcallconv=fast함수가 같은 수의 by-register 인자를 갖도록 설계할 수 있음- 정수 레지스터용 인자 6개
xmm0부터xmm7까지 벡터 인자 8개- 실제 포인터 전달 시 해당
i64를ptr로 바꿈 double전달 시<2 x i64>자리를 대체함
- 대부분 함수가 176바이트를 전달하지 않더라도, 사용하지 않는 인자에 LLVM
poison을 넘기면 추가 비용을 피할 수 있음- LLVM은
poison을 현재 가장 편리한 값으로 볼 수 있음 - 레지스터 인자로
poison이 전달되면 “이미 그 레지스터에 있던 값”처럼 처리할 수 있어 레지스터를 건드릴 필요가 없음 - 예시에서
load_rcx()는 포인터를rcx로 받고, 나머지 13개 레지스터에poison을 싣는 코드는 최적화 시 아무 코드도 만들지 않음
- LLVM은
- 이 방식은 인자 전달을 거의 완전히 제어하게 해주지만, 입력과 출력에 같은 레지스터를 쓰는 이상적 상황은 아키텍처마다 다름
- ARM과 RISC-V는 입력과 출력에 같은 레지스터를 쓰는 구조에 가까움
- x86은 그렇지 않지만, 레지스터 할당 순서를 다르게 가정해 불필요한 레지스터 이동을 줄일 수 있음
Rust 타입을 레지스터에 더 잘 맞추기
- Rust 구조체와 union을 다룰 때는 rustc가 이미 사용자 타입을 기본 aggregate와 union으로 처리했다고 가정하고, 어떤 부분을 레지스터에 둘지 결정함
- 반환값에서는 구조체의 전체 크기보다 패딩을 제외한 유효 크기가 더 중요함
[(u64, u32); 2]는 전체 32바이트지만 8바이트가 패딩임(u64, u32, u64, u32)로 평탄화한 뒤 크기순으로(u64, u64, u32, u32)로 정렬하면 24바이트가 됨- x86의 정수 반환 레지스터 3개에 들어갈 수 있음
- 유효 크기는 non-
undef비트 수로 정의됨[(u64, u32); 2]는 192비트bool은 1비트char는 기술적으로 21비트지만 단순화를 위해u32별칭처럼 취급함
bool이 많은 구조체는 여러bool을 한 레지스터에 비트 패킹해 반환할 수 있음- 인자 쪽은 더 어렵고, 다음과 같은 휴리스틱을 적용할 수 있음
- 유효 크기가 전체 by-register 입력 공간보다 큰 인자는 by-reference로 낮춤
- x86 기준 전체 입력 공간은 176바이트, 1408비트임
- enum은 discriminant와 union 쌍으로 바꿈
Option<i32>는 내부적으로(union { i32, () }, i1)처럼 볼 수 있음Option<Option<i32>>는(union { i32, (), () }, i2)처럼 볼 수 있음
- union은 미초기화 비트를 임의로 건드릴 수 있으므로 보통
u8배열처럼 전달함 - non-empty variant가 하나뿐인 union은 해당 variant로 대체함
- 변환된 인자는 포인터, 정수, float, bool 같은 primitive로 평탄화함
u128,f64처럼 작은 인자 레지스터보다 큰 필드는 쪼갤 수 있음- primitive 목록을 유효 크기 기준으로 정렬하고, 레지스터에 들어가는 가장 큰 prefix를 선택함
- 나머지는 스택에 둠
- 스택으로 가는 부분이 포인터 크기의 작은 배수보다 크면 pointer-on-the-stack으로 낮춰 메모리 트래픽을 줄임
- 레지스터 전달 값은 큰 것부터 배치하고,
bool은 레지스터당 64개까지 비트 패킹함
복잡한 Rust 함수 예시와 현재 rustc의 한계
Option<usize>,&dyn Context,&str,[char; 6],Options구조체를 받는do_thing예시에서는 평탄화와 정렬 후 원시 LLVM 인자들이 모두 레지스터에 들어갈 수 있음- 예시의 raw argument LLVM 타입은 다음 형태가 됨
gprs: i64, ptr, ptr, ptr, i64, i32, i32xmm0: i32, i32, i32, i32xmm1: i32, i1, i1, i1, i1
- 함수 프롤로그는 primitive를 꺼낸 뒤 Rust 수준 값으로 재조립함
Option<usize>는{ i64, i1 }- trait object는
{ ptr, ptr } &str은{ ptr, i64 }[char; 6]는[6 x i32]Options는{ i32, i1, i1, i1 }
- 인자 값을 실제로 materialize하는 명령에
!dbg메타데이터를 붙이면 gdb가 인자 값을 출력할 때 더 나은 결과를 낼 수 있음 - 현재 rustc는 같은 함수에 대해 LLVM에 pointer-sized 파라미터 8개를 넘기며, 결과적으로 정수 레지스터 6개를 모두 쓰고 값 2개는 스택으로 넘김
반환값과 Result 최적화 여지
- 이 설계가 가능한 모든 호출 규약 최적화를 다루는 것은 아님
- 일부 경우에는 x86의 AVX 레지스터처럼 추가 레지스터를 사용할 수 있음
- 구조체를 레지스터와 스택에 나눠 전달하는 방식도 고려할 수 있음
Result반환에는 별도 최적화 여지가 있음?를 통해 여러 함수 계층을 지나가면 중복 레지스터 이동이 많아질 수 있음Result가 레지스터에 들어가지 않을 만큼 크면 각?호출 스택에서 ok bit를 메모리에서 로드해 검사해야 함- 대안으로 error는 out-parameter pointer로 두고, ok variant payload와 is-ok bit는
Option<T>로 반환할 수 있음 ?가Into호출을 동반하는 세부 처리는 까다롭지만 구현 가능함
최적화 의존 ABI
- Rust는 C와 달리,
-Zcallconv=fast에서 호출자가 보게 될 ABI를 만들 때 함수 본문을 볼 수 있음 - 크레이트는 함수별로 레지스터 전달 관점의 정확한 ABI를 광고할 수 있음
- 가장 단순한 최적화는 사용하지 않는 인자를 ABI에서 버리는 것임
- 함수가 어떤 파라미터도 사용하지 않으면 그 인자에 레지스터를 쓰지 않음
&T인자가 유지되지 않고 raw pointer로 변환되지 않으며,T가 작고T: Freeze라면 참조 대신 pointee 자체를 by-value로 전달할 수 있음HashMap::get()같은 API가 후보임- key가
i32같은 타입이면 현재는 정수를 스택에 spill하고 그 포인터를 넘겨야 함 - 이 메모리 트래픽은 피할 수 있음
- key가
- 프로파일 기반 ABI는 더 공격적인 형태임
- 더 hot한 인자를 레지스터 할당 순서에서 우선할 수 있음
- 큰 구조체를 참조로 받더라도 hot한
i64필드 3개를 호출자가 미리 로드해 포인터와 레지스터 양쪽으로 넘길 수 있음 - callee는 어차피 그 load를 해야 하므로 추가 비용을 보지 않음
- instrumentation profile은 ABI만 다른 함수 복제를 정당화할 수도 있음
왜 아직 하지 못하는가
- Rust는 C++보다 ABI 제약이 적어 더 나은 코드를 만들 수 있고, 이 아이디어는 Go register ABI가 실제로 쓰는 방식과 맞닿아 있음
- 첫 번째 장애물은 ABI 코드 생성 복잡도임
- LLVM은 유용한 제어 노브를 거의 제공하지 않음
- rustc 안에서도 친절한 영역이 아님
- 잘못 구현하면 사용성에 나쁜 결과가 생길 수 있음
- 또 다른 장애물은 전문성 부족임
- rustc 기여자 중 LLVM 의미론과 코드 생성 특성을 충분히 이해해 좋은 코드를 내고 LLVM을 크래시시키지 않을 수 있는 사람은 소수임
- 컴파일 시간도 부담이 될 수 있음
- 함수 시그니처가 복잡해질수록 LLVM이 처리해야 하는 프롤로그/에필로그 코드가 늘어남
- 다만
-Zcallconv는 최적화를 켠 상태에서만 쓰도록 의도되므로, 결정적 단점으로 보지는 않음
- Rust의 ABI 코드는 bus factor가 낮은 영역이며, LLVM 지식은 Rust 컴파일러 팀이 더 최적화된 코드를 만들도록 돕는 데 직접 활용될 수 있음