# 우리가 마땅히 받아야 할 Rust 호출 규약(calling convention)

> Clean Markdown view of GeekNews topic #14410. Use the original source for factual precision when an external source URL is present.

## Metadata

- GeekNews HTML: [https://news.hada.io/topic?id=14410](https://news.hada.io/topic?id=14410)
- GeekNews Markdown: [https://news.hada.io/topic/14410.md](https://news.hada.io/topic/14410.md)
- Type: GN+
- Author: [neo](https://news.hada.io/@neo)
- Published: 2024-04-20T10:27:09+09:00
- Updated: 2024-04-20T10:27:09+09:00
- Original source: [mcyoung.xyz](https://mcyoung.xyz/2024/04/17/calling-convention/)
- Points: 2
- Comments: 1

## Topic Body

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

### Rust의 현재 Calling Convention의 문제점

- Rust는 현재 호출 규약(Calling Convention)이 명확히 정의되어 있지 않음
- 실제로는 LLVM의 기본 C 호출 규약을 사용하고 있음
- Rust는 현재 보수적으로 Clang이 생성할 법한 LLVM 함수 시그니처를 생성하려고 함
  - 디버거와의 호환성을 위해
  - LLVM 버그를 피하기 위해 
- 하지만 너무 보수적이라서 간단한 함수에 대해서도 나쁜 코드를 생성함

```rust
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"`로 전환하는 것도 대안이 될 수 있음

## Comments



### Comment 24552

- Author: neo
- Created: 2024-04-20T10:27:09+09:00
- Points: 1

###### [Hacker News 의견](https://news.ycombinator.com/item?id=40081314) 
요약:

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