우리가 마땅히 받아야 할 Rust 호출 규약 · mcyoung

ko생성일: 2025. 9. 23.갱신일: 2025. 9. 25.

Rust의 기본 호출 규약이 지나치게 보수적이어서 비효율적인 코드를 낳는 문제를 짚고, LLVM의 능력을 활용해 레지스터 중심의 빠른 호출 규약을 설계·구현하는 방법을 상세히 설명한다. x86을 중심으로 ARM·RISC‑V에도 일반화 가능한 설계, 히ュー리스틱, IR 예시, 그리고 최적화 의존적 ABI까지 다룬다.

나는 이른바 “C ABI”가 매우 안 좋고, 복잡한 타입을 효율적으로 전달하는 데 상상력이 부족하다고 자주 말한다. 많은 이들이 “그럼 대안으로 뭘 쓰겠냐”고 묻는데, 나는 보통 Go 레지스터 ABI를 가리킨다. 하지만 대부분은 내가 의미하는 바를 메우는 데 어려움을 겪는 듯하다. 이 글은 그 의미를 자세히 설명한다.

나는 예전에 호출 규약에 대해 논의한 바 있지만, 상기 차원에서 정리하자면: 호출 규약은 함수의 인자와 반환값을 어떻게 전달하고, 함수를 실제로 어떻게 호출할지를 다루는 ABI의 일부다. 어떤 레지스터에 인자를 넣는지, 어떤 레지스터로 값을 반환하는지, 함수 프롤로그/에필로그가 어떤 모양인지, 언와인딩이 어떻게 동작하는지 등이 포함된다.

이 글은 주로 x86에 관한 것이지만, 가능한 한 일반적으로 서술하려 한다(ARM, RISC‑V 등에도 동일하게 적용되도록). 독자가 x86 어셈블리, LLVM IR, 그리고 Rust(단, rustc 내부는 제외)에 전반적으로 익숙하다고 가정한다.

문제

오늘날, 많은 네이티브 컴파일 언어와 마찬가지로, Rust는 원하는 방식으로 함수를 호출할 수 있게 해주는 명시되지 않은 호출 규약을 정의한다. 실무적으로 Rust는 LLVM의 내장 C 호출 규약으로 낮춰지며, LLVM의 프롤로그/에필로그 코드 생성이 호출을 만든다.

Rust는 꽤 보수적이다. Clang이 그럴듯하게 생성했을 법한 LLVM 함수 시그니처를 만들려고 한다. 이에는 두 가지 중요한 이점이 있다.

  1. 디버거가 말썽을 피울 확률이 낮다. 다만 Linux에서는 DWARF가 매우 일반적이고 Linux C ABI를 내장하지 않으므로 이건 큰 걱정거리가 아니다. 우리는 ELF 기반 시스템만을 고려하고, 디버깅 가능성은 문제에서 제외하자.

  2. Clang이 사용하지 않는 ABI 코드 생성 경로를 써서 LLVM 버그를 건드릴 가능성이 낮아진다. 내 생각에 Rust가 LLVM 버그를 건드린다면 실제로 그 버그를 고쳐야 한다(실제로 아주 소수의 rustc 기여자들이 그렇게 하고 있다).

그러나 우리는 너무 보수적이다. 간단한 함수에서도 끔찍한 코드 생성이 나온다:

fn extract(arr: [i32; 3]) -> i32 {
  arr[1]
}

Rust

extract:
  mov   eax, dword ptr [rdi + 4]
  ret

x86 어셈블리

arr는 12바이트 너비이므로 레지스터로 전달될 것이라고 생각하겠지만, 아니다! 포인터로 전달된다! 사실 Rust는 Linux C ABI가 요구하는 것보다 더 보수적이다. 왜냐하면 extern "C"를 요청하면 [i32; 3]를 레지스터로 전달하기 때문이다.

extern "C" fn extract(arr: [i32; 3]) -> i32 {
  arr[1]
}

Rust

extract:
  mov   rax, rdi
  shr   rax, 32
  ret

x86 어셈블리

배열은 rdirsi로 전달되고, i32들이 레지스터에 팩(pack)된다. 함수는 rdi를 출력 레지스터 rax로 옮기고 상위 절반을 아래로 시프트한다.

Clang은 값 전달에 대해 분명히 나쁜 코드를 생성할 뿐만 아니라, 표준 호출 규약을 요청하면 더 잘할 수 있다는 것도 알고 있다! 우리는 Clang보다 훨씬 더 좋은 코드를 생성할 수 있는데, 그러지 않는다!

이제부터, 그 방법을 설명하겠다.

-Zcallconv

extern "Rust"의 현재 호출 규약은 유지하되1, 크레이트를 컴파일할 때 extern "Rust"의 호출 규약을 설정하는 -Zcallconv 플래그를 추가한다고 가정하자. 지원 값은 현재 방식을 위한 -Zcallconv=legacy, 그리고 여기서 설계할 방식을 위한 -Zcallconv=fast가 될 것이다. -O가 자동으로 -Zcallconv=fast를 설정하게 할 수도 있다.

왜 기존 호출 규약을 유지하는가? 위에서 디버깅 가능성을 대충 덮어두긴 했지만, -Zcallconv=fast가 가지지 못하는 장점 하나는 인자를 C ABI 순서로 배치하지 않는다는 점이다. 즉, x86에서 “Diana’s silk dress cost $89”라는 암기를 기대하는 독자는 꽤 혼란스러울 수 있다.

또한, WASM처럼 “레지스터”와 “스필” 개념이 없는 타깃에서는 -Zcallconv=fast를 지원하지 않을 수도 있다고 가정한다. 최적화가 꺼진 디버그 빌드에서는 오히려 훨씬 나쁜 코드를 만들 수 있으므로 활성화하는 것이 합리적이지 않을 수도 있다.

함수 포인터와 extern "Rust" {} 블록과 관련된 작은 문제도 있다. 이 플래그는 크레이트 단위이므로, 함수가 자신이 어떤 버전의 extern "Rust"를 사용하는지 광고할 수 있다고 해도, 함수 포인터에는 그런 사치가 없다. 그러나 함수 포인터를 통한 호출은 느리고 드물기 때문에, 그냥 -Zcallconv=legacy를 쓰도록 강제할 수 있다. 필요하면 호출 규약을 번역하는 셈(shim)을 생성하면 된다.

비슷하게, 원칙적으로는 어떤 Rust 함수든 다음처럼 호출할 수 있다:

fn secret_call() -> i32 {
  extern "Rust" {
    fn my_func() -> i32;
  }
  unsafe { my_func() }
}

Rust

그러나 이 메커니즘은 맹글링되지 않은 심볼을 호출할 때만 사용할 수 있다. 따라서 #[no_mangle] 심볼은 기존 호출 규약을 강제하도록 하면 된다.

Bending LLVM to Our Will

이상적으로는, LLVM이 호출 규약을 직접 지정할 수 있는 방법을 제공하면 좋겠다. 예를 들어, 이 인자는 저 레지스터, 이 반환값은 저 레지스터 등. 불행히도 LLVM에 호출 규약을 추가하려면 상당한 양의 C++ 코드를 작성해야 한다.

그러나 다음 절차를 따르면 우리만의 호출 규약을 지정하는 것이 가능하다.

  1. 먼저, 특정 타깃 트리플에 대해 “레지스터로” 전달할 수 있는 값의 최대 개수를 결정한다. 아래에서 그 방법을 설명한다.

  2. 반환값 전달 방식을 결정한다. 출력 레지스터에 맞으면 그대로, 맞지 않으면 “참조로 반환”해야 하며, 이 경우 함수에 추가 ptr 인자를 전달한다(sret 특성으로 태깅) 그리고 함수의 실제 반환값은 그 포인터가 된다.

  3. 값으로 전달된 인자 중 참조 전달로 강등해야 할 인자를 결정한다. 이는 휴리스틱일 것이나, 대체로 “레지스터 공간보다 큰 인자” 정도가 된다. 예컨대 x86에서는 약 176바이트가 된다.

  4. 레지스터 공간 사용을 극대화하도록 어떤 인자를 레지스터로 전달할지 결정한다. 이 문제는 NP-난해(배낭 문제)하므로 휴리스틱이 필요하다. 나머지 인자들은 스택으로 전달한다.

  5. LLVM IR에서 함수 시그니처를 생성한다. 이는 각종 비-집합형(non-aggregate)으로 인코딩된, 레지스터로 전달되는 모든 인자(예: i64, ptr, double, <2 x i64> 등)를 포함한다. 유효한 비-집합형의 선택지는 타깃에 따라 다르지만, 위 예들은 64비트 아키텍처에서 일반적이다. 스택으로 전달되는 인자들은 “레지스터 입력” 뒤를 따른다.

  6. 함수 프롤로그를 생성한다. 이는 레지스터 입력에서 Rust 레벨의 각 인자를 디코딩하여, -Zcallconv=legacy를 사용할 때 존재했을 %ssa 값에 대응하는 값을 만들기 위한 코드다. 이렇게 하면 호출 규약과 관계없이 함수 본문에 대해 동일한 코드를 생성할 수 있다. 중복 디코딩 코드는 DCE 패스가 제거한다.

  7. 함수 종료 블록을 생성한다. 이는 -Zcallconv=legacy에서와 같은 반환 타입에 대한 단일 phi 명령을 포함하는 블록이다. 이 블록은 그것을 필요한 출력 형식으로 인코딩한 뒤 적절히 ret 한다. 함수의 모든 종료 경로는 ret 대신 이 블록으로 br 해야 한다.

  8. 다형적이지 않고 인라인되지 않는 함수가(크레이트 밖으로 내보내지거나 크레이트가 해당 함수의 포인터를 취함으로써) 주소가 취해질 수 있다면, -Zcallconv=legacy를 사용하고 실제 구현으로 즉시 테일 콜하는 셈을 생성한다. 이는 함수 포인터 동등성을 보존하는 데 필요하다.

핵심 요지는, 어떤 것을 레지스터에 넣을지 결정하는 휴리스틱이 필요하다는 점이다(더 나은 처리량을 위해 인자 재정렬을 허용하므로). 이는 배낭 문제와 동등하며, 배낭 휴리스틱 자체는 이 글의 범위를 벗어난다. 가능한 한 이 처리를 이르게 수행하여 rmeta에 정보를 넣고 재계산을 피하도록 한다. -Copt-level에 따라 더 빠른 다른 휴리스틱을 사용할 수도 있다. 올바름을 위해, 서로 다른 Rust 컴파일러들이 생성한 코드를 링크하는 일을 금지해야 한다는 점에 유의해야 하는데, 이는 Rust가 릴리스마다 ABI를 깨기 때문에 이미 사실상 그렇다.

LLVM이 허용하는 한계는?

그렇게 한다고 치면, LLVM이 우리가 원하는 방식으로 값을 전달하게 만들려면 어떻게 해야 할까? LLVM이 허용하는 최대 “레지스터로 전달”의 범위를 알아내야 한다. 특정 버전의 LLVM에서 이를 알아내는 데 유용한 프로그램은 다음과 같다:

%InputI = type [6 x i64]
%InputF = type [0 x double]
%InputV = type [8 x <2 x i64>]

%OutputI = type [3 x i64]
%OutputF = type [0 x double]
%OutputV = type [4 x <2 x i64>]

define void @inputs({ %InputI, %InputF, %InputV }) {
  %p = alloca [4096 x i8]
  store volatile { %InputI, %InputF, %InputV } %0, ptr %p
  ret void
}

%Output = { %OutputI, %OutputF, %OutputV }
@gOutput = constant %Output zeroinitializer
define %Output @outputs() {
  %1 = load %Output, ptr @gOutput
  ret %Output %1
}

LLVM IR

LLVM 함수에 집합형(aggregate)을 값으로 전달하면, LLVM은 가능한 많은 레지스터로 그 집합형을 “폭발(explode)”시키려 한다. 시스템마다 서로 다른 레지스터 클래스가 있다. 예컨대 x86과 ARM 모두에서, 부동소수점과 벡터는 같은 레지스터 클래스를 공유한다(어느 정도는2).

위 값들은 x86에 대한 것이다3. LLVM은 여섯 개의 정수와 여덟 개의 SSE 벡터를 레지스터로 전달하며, 반환은 그 절반(3과 4)을 레지스터로 전달한다. 값들을 더 늘리면, LLVM이 포기하고 스택으로 인자를 전달했음을 나타내는 추가 로드/스토어가 생성된다.

aarch64-unknown-linux의 값은 입력과 출력 모두 정수 8개, 벡터 8개다.

이는 각 클래스별로 우리가 사용할 수 있는 레지스터의 최대 개수다. 초과하는 것은 스택으로 전달된다.

나는 모든 함수가 동일한 수의 레지스터 전달 인자를 갖기를 권한다. 따라서 x86에서는 모든 -Zcallconv=fast 함수 시그니처가 다음과 같아야 한다:

declare {[3 x i64], [4 x <2 x i64>]} @my_func(
  i64 %rdi, i64 %rsi, i64 %rdx, i64 %rcx, i64 %r8, i64 %r9,
  <2 x i64> %xmm0, <2 x i64> %xmm1, <2 x i64> %xmm2, <2 x i64> %xmm3,
  <2 x i64> %xmm4, <2 x i64> %xmm5, <2 x i64> %xmm6, <2 x i64> %xmm7,
  ; other args...
)

LLVM IR

포인터를 전달할 때는 해당 i64ptr로, double을 전달할 때는 <2 x i64>double로 교체해야 한다.

아마 이렇게 말하고 싶을 것이다. “Miguel, 그건 미친 짓이야! 대부분의 함수는 176바이트를 전달하지 않아!” 맞는 말이다. 만약 LLVM의 아주 잘 정의된 poison 의미론의 마법이 없었다면 말이다.

사용하지 않는 모든 인자에 poison을 전달하면 추가 작업 없이 넘어갈 수 있다. poison은 “현재 시점에서 가장 편리한 값”과 같기 때문에, LLVM은 레지스터로 전달된 인자에 poison이 들어오는 것을 보면, 가장 편리한 값은 “지금 레지스터에 우연히 들어 있는 값”이라고 판단하여 그 레지스터를 건드릴 필요가 없다고 여긴다!

예컨대, rcx로 포인터를 전달하려면 다음과 같은 코드를 생성하면 된다.

; This is a -Zcallconv=fast-style function.
%Out = type {[3 x i64], [4 x <2 x i64>]}
define %Out @load_rcx(
  i64 %rdi, i64 %rsi, i64 %rdx,
  ptr %rcx, i64 %r8, i64 %r9,
  <2 x i64> %xmm0, <2 x i64> %xmm1,
  <2 x i64> %xmm2, <2 x i64> %xmm3,
  <2 x i64> %xmm4, <2 x i64> %xmm5,
  <2 x i64> %xmm6, <2 x i64> %xmm7
) {
  %load = load i64, ptr %rcx
  %out = insertvalue %Out poison,
                      i64 %load, 0, 0
  ret %Out %out
}

declare ptr @malloc(i64)
define i64 @make_the_call() {
  %1 = call ptr @malloc(i64 8)
  store i64 42, ptr %1
  %2 = call %Out @by_rcx(
    i64 poison, i64 poison, i64 poison,
    ptr %1,     i64 poison, i64 poison,
    <2 x i64> poison, <2 x i64> poison,
    <2 x i64> poison, <2 x i64> poison,
    <2 x i64> poison, <2 x i64> poison,
    <2 x i64> poison, <2 x i64> poison)
  %3 = extractvalue %Out %2, 0, 0
  %4 = add i64 %3, 42
  ret i64 %4
}

LLVM IR

by_rcx:
  mov   rax, qword ptr [rcx]
  ret

make_the_call:
  push  rax
  mov   edi, 8
  call  malloc
  mov   qword ptr [rax], 42
  mov   rcx, rax
  call  load_rcx
  add   rax, 42
  pop   rcx
  ret

x86 어셈블리

금지된 방식으로 상호작용하지 않는 한, 함수에 poison을 전달하는 것은 완전히 합법적이다. 보듯이 load_rcx()는 포인터 인자를 rcx로 받으며, make_the_call()은 호출 준비에서 불이익이 없다. 나머지 13개 레지스터에 poison을 적재하는 것은 아무것도 아닌 것으로 컴파일되므로4, malloc이 반환한 포인터를 rcx에 적재하기만 하면 된다.

이로써 우리는 인자 전달에 대해 거의 완전한 제어권을 갖는다. 아쉽게도 완전히는 아니다. 이상적으로는 입력과 출력에 같은 레지스터를 사용하여, 추가 레지스터 트래픽 없이 호출들을 더 쉽게 파이프라인할 수 있어야 한다. 이는 ARM과 RISC‑V에서는 사실이지만 x86에서는 아니다. 하지만 레지스터 순서는 우리에겐 단지 제안일 뿐이므로, 반환 레지스터를 원하는 순서로 할당하도록 선택할 수 있다. 예컨대 입력에 대해 레지스터 할당 순서를 rdx, rcx, rdi, rsi, r8, r9로, 출력에 대해 rdx, rcx, rax로 가정할 수 있다.

%Out = type {[3 x i64], [4 x <2 x i64>]}
define %Out @square(
  i64 %rdi, i64 %rsi, i64 %rdx,
  ptr %rcx, i64 %r8, i64 %r9,
  <2 x i64> %xmm0, <2 x i64> %xmm1,
  <2 x i64> %xmm2, <2 x i64> %xmm3,
  <2 x i64> %xmm4, <2 x i64> %xmm5,
  <2 x i64> %xmm6, <2 x i64> %xmm7
) {
  %sq = mul i64 %rdx, %rdx
  %out = insertvalue %Out poison,
                      i64 %sq, 0, 1
  ret %Out %out
}

define i64 @make_the_call(i64) {
  %2 = call %Out @square(
    i64 poison, i64 poison, i64 %0,
    i64 poison, i64 poison, i64 poison,
    <2 x i64> poison, <2 x i64> poison,
    <2 x i64> poison, <2 x i64> poison,
    <2 x i64> poison, <2 x i64> poison,
    <2 x i64> poison, <2 x i64> poison)
  %3 = extractvalue %Out %2, 0, 1

  %4 = call %Out @square(
    i64 poison, i64 poison, i64 %3,
    i64 poison, i64 poison, i64 poison,
    <2 x i64> poison, <2 x i64> poison,
    <2 x i64> poison, <2 x i64> poison,
    <2 x i64> poison, <2 x i64> poison,
    <2 x i64> poison, <2 x i64> poison)
  %5 = extractvalue %Out %4, 0, 1

  ret i64 %5
}

LLVM IR

square:
  imul rdx, rdx
  ret

make_the_call:
  push rax
  mov rdx, rdi
  call square
  call square
  mov rax, rdx
  pop rcx
  ret

x86 어셈블리

square는 극도로 단순한 코드를 생성한다. 입력과 출력 레지스터가 rdi이기 때문에 추가 레지스터 트래픽이 필요 없다. 마찬가지로 사실상 @square(@square(%0))를 할 때, 두 함수 사이에 준비 작업이 필요 없다. 이는 입력과 출력에 같은 레지스터 순서를 사용하는 aarch64에서 볼 수 있는 코드와 유사하다. 이러한 이유로 이 IR의 “단순” 버전은 aarch64에서도 정확히 같은 코드를 생성한다.

define i64 @square(i64) {
  %2 = mul i64 %0, %0
  ret i64 %2
}

define i64 @make_the_call(i64) {
  %2 = call i64 @square(i64 %0)
  %3 = call i64 @square(i64 %2)
  ret i64 %3
}

LLVM IR

square:
  mul x0, x0, x0
  ret

make_the_call:
  str x30, [sp, #-16]!
  bl square
  ldr x30, [sp], #16
  b square  // Tail call.

ARM 어셈블리

Rust의 구조체와 유니언

이제 레지스터가 어떻게 할당되는지에 대해 거의 완전한 제어를 확보했으니, Rust에서 이 레지스터들의 사용을 극대화하는 데로 눈을 돌릴 수 있다.

간단히 하기 위해, rustc가 이미 사용자의 타입을 기본 집합형과 유니언으로 처리했다고 가정하자. 즉, 여기에 열거형은 없다! 이제 인자의 어느 부분을 레지스터에 할당할지 몇 가지 결정을 내려야 한다.

먼저 반환값. 전달할 값은 하나뿐이므로 비교적 단순하다. 반환해야 하는 데이터의 양은 구조체의 크기와 같지 않다. 예컨대 [(u64, u32); 2]는 32바이트이다. 그러나 그중 8바이트는 패딩이다! 값으로 반환할 때는 패딩을 보존할 필요가 없으므로, 구조체를 (u64, u32, u64, u32)로 평탄화하고 크기순으로 정렬하여 (u64, u64, u32, u32)로 만들 수 있다. 이는 패딩이 없고 24바이트로, x86에서 LLVM이 제공하는 세 개의 반환 레지스터에 들어맞는다. 우리는 타입의 “유효 크기”를, 패딩이 아닌 비트의 개수로 정의한다. [(u64, u32); 2]의 경우 패딩을 제외하므로 192비트다. bool은 1, char는 기술적으로 21이지만 단순화를 위해 u32의 별칭으로 다룬다.

이렇게 비트를 세는 이유는 상당한 압축을 가능케 하기 때문이다. 예컨대 bool로 가득한 구조체를 반환하는 경우, 단일 레지스터에 불리언들을 비트 패킹할 수 있다.

따라서 반환값은, 그 유효 크기가 출력 레지스터 공간보다 작을 때 레퍼런스 반환으로 전환된다(예: x86에서는 정수 레지스터 3개와 SSE 레지스터 4개, 총 88바이트 또는 704비트).

인자 레지스터는 훨씬 어렵다. 배낭 문제를 만나기 때문이다. 다음의 비교적 단순한 휴리스틱을 출발점으로 삼을 수 있지만, 시간이 갈수록 무한히 더 똑똑해질 수 있다.

먼저, 유효 크기가 전체 입력 레지스터 공간보다 큰 인자는 참조 전달로 강등한다(x86에서는 176바이트 또는 1408비트). 이렇게 하면 대신 포인터 인자를 얻게 된다. 이를 먼저 하는 것이 유리한데, 큰 구조체 하나보다 단일 포인터가 더 잘 팩킹될 수 있기 때문이다.

열거형은 적절한 구분자-유니언 쌍으로 대체해야 한다. 예컨대 Option<i32>는 내부적으로 (union { i32, () }, i1)이고, Option<Option<i32>>(union { i32, (), () }, i2)다. 2의 거듭제곱이 아닌 작은 정수를 사용하면, 열거형 구분자가 종종 매우 작기 때문에 팩킹 능력이 향상된다.

이제 유니언을 처리해야 한다. 유니언의 초기화되지 않은 비트를 (우리 모르게) 건드리는 것이 허용되므로, 비어 있지 않은 변형이 하나뿐인 경우를 제외하고는 u8 배열로 전달해야 한다. 비어 있지 않은 변형이 하나뿐이면 그 변형으로 대체한다5.

이제 모든 것을 평탄화할 수 있다. 변환된 모든 인자를 포인터, 정수, 부동소수점, 불리언 같은 가장 원시적인 구성요소로 평탄화한다. 모든 필드는 가장 작은 인자 레지스터보다 커서는 안 된다. 이는 u128이나 f64 같은 큰 타입을 분할해야 할 수 있음을 뜻한다.

이 거대한 원시 목록을 유효 크기 기준으로 작은 것에서 큰 것으로 정렬한다. 레지스터 공간에 맞는 가장 큰 접두 구간(prefix)을 취하고, 나머지는 스택으로 보낸다.

Rust 레벨 입력의 일부가 이런 식으로 스택으로 보내지고 그 부분이 포인터 크기의 작은 배수(예: 2배)보다 크다면, 메모리 트래픽을 최소화하기 위해 스택 위의 포인터 전달로 강등한다. 나머지는 정렬 이전의 입력 순서를 유지한 채 스택에 직접 전달한다. 이는 복사해야 하는 영역을 비교적 연속적으로 유지하여 memcpy 호출을 최소화하는 데 도움이 된다.

레지스터로 전달하기로 선택한 것들은 역크기 순서로 레지스터에 할당한다. 즉 64비트 것들부터, 그다음 32비트 등으로. 이는 repr(Rust) 구조체가 모든 패딩을 꼬리 영역으로 보내기 위해 사용하는 레이아웃 알고리즘과 같다. bool에 도달하면, 이를 레지스터 하나당 64개씩 비트 패킹한다.

다음은 비교적 복잡한 예시다. Rust 함수는 다음과 같다:

struct Options {
  colorize: bool,
  verbose_debug: bool,
  allow_spurious_failure: bool,
  retries: u32,
}

trait Context {
  fn check(&self, n: usize, colorize: bool);
}

fn do_thing<'a>(op_count: Option<usize>, context: &dyn Context,
                name: &'a str, code: [char; 6],
                options: Options,
) -> &'a str {
  if let Some(op_count) = op_count {
    context.check(op_count, options.colorize);
  }

  for c in code {
    if let Some((_, suf)) = name.split_once(c) {
      return suf;
    }
  }

  "idk"
}

Rust

이 함수의 코드 생성은 꽤 복잡하므로, 프롤로그와 에필로그만 다루겠다. 정렬과 평탄화 이후, 원시 인자 LLVM 타입은 대략 다음과 같다:

gprs: i64, ptr, ptr, ptr, i64, i32, i32
xmm0: i32, i32, i32, i32
xmm1: i32, i1, i1, i1, i1

LLVM IR

모두 레지스터에 들어간다! 그렇다면 x86에서 LLVM 함수는 어떻게 생겼을까?

%Out = type {[3 x i64], [4 x <2 x i64>]}
define %Out @do_thing(
  i64 %rdi, ptr %rsi, ptr %rdx,
  ptr %rcx, i64 %r8, i64 %r9,
  <4 x i32> %xmm0, <4 x i32> %xmm1,
  ; Unused.
  <2 x i64> %xmm2, <2 x i64> %xmm3,
  <2 x i64> %xmm4, <2 x i64> %xmm5,
  <2 x i64> %xmm6, <2 x i64> %xmm7
) {
  ; First, unpack all the primitives.
  %r9.0 = trunc i64 %r9 to i32
  %r9.1.i64 = lshr i64 %r9, 32
  %r9.1 = trunc i64 %r9.1.i64 to i32
  %xmm0.0 = extractelement <4 x i32> %xmm0, i32 0
  %xmm0.1 = extractelement <4 x i32> %xmm0, i32 1
  %xmm0.2 = extractelement <4 x i32> %xmm0, i32 2
  %xmm0.3 = extractelement <4 x i32> %xmm0, i32 3
  %xmm1.0 = extractelement <4 x i32> %xmm1, i32 0
  %xmm1.1 = extractelement <4 x i32> %xmm1, i32 1
  %xmm1.1.0 = trunc i32 %xmm1.1 to i1
  %xmm1.1.1.i32 = lshr i32 %xmm1.1, 1
  %xmm1.1.1 = trunc i32 %xmm1.1.1.i32 to i1
  %xmm1.1.2.i32 = lshr i32 %xmm1.1, 2
  %xmm1.1.2 = trunc i32 %xmm1.1.2.i32 to i1
  %xmm1.1.3.i32 = lshr i32 %xmm1.1, 3
  %xmm1.1.3 = trunc i32 %xmm1.1.3.i32 to i1

  ; Next, reassemble them into concrete values as needed.
  %op_count.0 = insertvalue { i64, i1 } poison, i64 %rdi, 0
  %op_count = insertvalue { i64, i1 } %op_count.0, i1 %xmm1.1.0, 1
  %context.0 = insertvalue { ptr, ptr } poison, ptr %rsi, 0
  %context = insertvalue { ptr, ptr } %context.0, ptr %rdx, 1
  %name.0 = insertvalue { ptr, i64 } poison, ptr %rcx, 0
  %name = insertvalue { ptr, i64 } %name.0, i64 %r8, 1
  %code.0 = insertvalue [6 x i32] poison, i32 %r9.0, 0
  %code.1 = insertvalue [6 x i32] %code.0, i32 %r9.1, 1
  %code.2 = insertvalue [6 x i32] %code.1, i32 %xmm0.0, 2
  %code.3 = insertvalue [6 x i32] %code.2, i32 %xmm0.1, 3
  %code.4 = insertvalue [6 x i32] %code.3, i32 %xmm0.2, 4
  %code = insertvalue [6 x i32] %code.4, i32 %xmm0.3, 5
  %options.0 = insertvalue { i32, i1, i1, i1 } poison, i32 %xmm1.0, 0
  %options.1 = insertvalue { i32, i1, i1, i1 } %options.0, i1 %xmm1.1.1, 1
  %options.2 = insertvalue { i32, i1, i1, i1 } %options.1, i1 %xmm1.1.2, 2
  %options = insertvalue { i32, i1, i1, i1 } %options.2, i1 %xmm1.1.3, 3

  ; Codegen as usual.
  ; ...
}

LLVM IR

위에서, 인자 값을 실제로 만들어내는 명령에 !dbg 메타데이터를 붙여야 한다. 이는 gdb가 인자 값을 출력하도록 요청받을 때 그럴듯한 일을 하도록 보장한다.

반면 현재 rustc는 포인터 크기의 매개변수 8개를 LLVM에 주므로, 정수 레지스터 6개를 모두 사용하고 스택에 두 개를 추가로 전달하게 된다. 훌륭하지 않다!

이는 지나치게 공학적인 호출 규약이 취할 수 있는 모든 것을 다 설명한 것이 아니다. 어떤 경우에는 추가 레지스터(예: x86의 AVX 레지스터)가 사용 가능함을 알 수도 있다. 어떤 경우에는 구조체를 레지스터와 스택에 걸쳐 분할하고 싶을 수도 있다.

또한 반환을 어떻게 할 수 있는지에 대해서도 충분히 다루지 않았다. ?를 통해 Result가 여러 함수층을 통과하면, 많은 중복 레지스터 이동이 발생할 수 있다. 흔히 Result는 레지스터에 들어가지 않을 만큼 커서, ? 스택의 각 호출이 ok 비트를 메모리에서 로드하여 검사해야 한다. 대신, Result 반환을 에러에 대한 아웃 파라미터 포인터로 구현하고, ok 변형의 페이로드와 ok 여부 비트는 Option<T>로 반환할 수 있다. ?를 통한 Into 호출과 관련된 까다로운 세부사항이 있지만, 아이디어 자체는 구현 가능하다.

최적화 의존적 ABI

이제, Rust이기에 C에는 없고(Go에는 있는) 비장의 카드가 더 있다! 우리가 호출자 모두가 보게 될 ABI(-Zcallconv=fast용)를 생성할 때, 함수 본문을 들여다볼 수 있다. 즉, 크레이트가 함수의 정확한 ABI(레지스터 전달 관점에서)를 광고할 수 있다.

이는 더 급진적인 최적화 기반 ABI의 문을 연다. 먼저 쓰이지 않는 인자를 과감히 버리는 것부터 시작할 수 있다. 함수가 매개변수로 아무것도 하지 않는다면, 그에 레지스터를 낭비하지 말라.

또 다른 예: 어떤 &T 인자가 유지(retain)되지 않음을 알고(컴파일러의 대여 검사기가 이 시점에서 답할 수 있음), raw 포인터로 변환되지도 않으며(또는 raw 포인터가 취해진 메모리에 쓰이지도 않음)를 안다고 하자. 또한 T가 꽤 작고, T: Freeze라고 하자. 그러면 레퍼런스를 대신 포인티를 직접 값으로 전달하도록 바꿀 수 있다.

가장 명백한 후보는 HashMap::get() 같은 API다. 키가 i32 같은 것이라면, 그 정수를 스택에 스필하고 그 포인터를 전달해야 한다! 이는 불필요하고 피할 수 있는 메모리 트래픽을 유발한다.

프로파일 기반 ABI는 한 단계 더 나아간다. 어떤 인자들이 다른 것보다 더 뜨겁다는 것을 알 수 있으며, 이는 레지스터 할당 순서에서 우선순위를 높이는 원인이 될 수 있다.

심지어, 매우 큰 구조체를 레퍼런스로 받는 함수인데 세 개의 i64 필드가 매우 뜨거운 경우를 상상해볼 수 있다. 그러면 호출자는 그 필드들을 미리 로드하여, 레지스터로도 전달하고 큰 구조체의 포인터로도 전달할 수 있다. 피호출자는 추가 비용을 보지 않는다. 어차피 그 로드를 발행해야 했기 때문이다. 그러나 호출자는 이미 그 값을 레지스터에 가지고 있을 가능성이 높아 메모리 트래픽을 줄일 수 있다.

계측 프로파일은 동일하지만 ABI만 다른 함수를 통째로 복제하는 것이 합리적임을 시사할 수도 있다. 비싼 스필을 피하기 위해 서로 다른 인자들을 레지스터로 받는 버전들 말이다.

결론

이 글은 평소보다 조금 더 진보적이고(그리고 더 푸념에 가깝고), 내가 Rust에서 정말로 답답함을 느끼는 한 측면을 다뤘다. 우리는 C++가(그들의 ABI 제약 탓에) 도저히 할 수 없는 일을 훨씬 더 잘할 수 있다. 이건 새로운 아이디어가 아니다. Go가 그대로 하고 있는 방식이다!

그런데 왜 우리는 그러지 않는가? 부분적으로는 ABI 코드 생성이 복잡하고, 위에서 설명했듯 LLVM이 우리에게 유용한 조절 장치를 거의 주지 않기 때문이다. rustc의 친절한 부분이 아니고, 잘못하면 사용성에 끔찍한 결과를 초래할 수 있다. 또 다른 이유는 전문성의 부족이다. 이 글을 쓰는 시점에서, rustc에 기여하는 사람들 중 소수만이 올바른 코드를 방출하여 좋은 코드 생성을 얻고 LLVM을 크래시시키지 않도록 하는 데 필요한 LLVM의 의미론(과 변덕)에 대한 이해를 갖고 있다.

또 다른 이유는 컴파일 시간이다. 함수 시그니처가 복잡할수록, 우리가 생성해야 하고 LLVM이 씹어먹어야 할 프롤로그/에필로그 코드가 더 많아진다. 하지만 -Zcallconv는 최적화가 켜졌을 때만 사용하도록 의도되었으므로, 이는 의미 있는 불만이라고 생각하지 않는다. 또한 프로젝트가 컴파일 시간을 지표로 과도하게 숭배(Goodhart의 법칙에 따른 지표 왜곡)하는 것도 건강하다고 보지 않는다… 하지만 궁극적으로 관련 있는 단점이라고는 생각하지 않는다.

불행히도 나는 rustc의 ABI 코드를 고칠 시간은 없지만, LLVM을 정말 잘 알고 있고, Rust의 버스 팩터가 낮은 영역이라는 것도 안다. 그런 이유로, 최적화된 코드를 더 빠르게 만들기 위해 LLVM이 올바르게 동작하도록 하는 전문 지식을 Rust 컴파일러 팀에 기꺼이 제공하겠다.

  1. 아니면 그냥 extern "C"extern "fastcall"의 코드 경로로 바꿔도 된다. 어차피 비-extern "Rust" 호출 규약에 대한 코드 생성은 계속 필요하다.

  2. 사정이 복잡하다. double을 전달하면 <2 x i64> 슬롯 하나를 통째로 소모한다. 안 좋아 보이지만, 보통 부동소수점 명령이 벡터 레지스터를 사용하기 때문에(혹은 ARM처럼 fp 레지스터가 벡터 레지스터를 그림자(shadow)로 공유하기 때문에) double을 벡터 레지스터에 유지하는 것이 레지스터 트래픽을 줄여 이득이 될 수 있다.

  3. 한편으로는, 이 “확장 호출 규약”이 LLVM의 ccc 호출 규약이 명시적으로 지원하는 부분이 아니라고 말할 수도 있다. 다른 한편으로는, Hyrum’s Law는 양날의 검이다. Rust는 이제 LLVM이 모든 Rust 프로그램을 잘못 컴파일해버릴 수 없을 만큼 충분히 큰 사용자이며, 내가 제안하는 IR은 극히 합리적이다.

만약 Rust가 LLVM을 오동작하게 만든다면, 그것은 LLVM 버그다. 우리는 우회하지 말고 LLVM 버그를 고쳐야 한다.

  1. 기묘하게도 -O1 이상에서만. -O0에서는 LLVM이 모든 poison이 같은 값을 가져야 한다고 판단하여, 불필요하게 많은 레지스터 복사를 수행한다. 버그로 보인다?

  2. 유니언을 그 변형 중 하나로 대체하고 싶은 다른 경우도 있다. 예컨대 Result<&T, Error>가 사실상 union { ptr, u32 }인 경우, 단일 ptr로 대체해야 한다.