2P by GN⁺ | ★ favorite | 댓글 1개
  • 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개
    • 실제 포인터 전달 시 해당 i64ptr로 바꿈
    • double 전달 시 <2 x i64> 자리를 대체함
  • 대부분 함수가 176바이트를 전달하지 않더라도, 사용하지 않는 인자에 LLVM poison 을 넘기면 추가 비용을 피할 수 있음
    • LLVM은 poison을 현재 가장 편리한 값으로 볼 수 있음
    • 레지스터 인자로 poison이 전달되면 “이미 그 레지스터에 있던 값”처럼 처리할 수 있어 레지스터를 건드릴 필요가 없음
    • 예시에서 load_rcx()는 포인터를 rcx로 받고, 나머지 13개 레지스터에 poison을 싣는 코드는 최적화 시 아무 코드도 만들지 않음
  • 이 방식은 인자 전달을 거의 완전히 제어하게 해주지만, 입력과 출력에 같은 레지스터를 쓰는 이상적 상황은 아키텍처마다 다름
    • 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, i32
    • xmm0: i32, i32, i32, i32
    • xmm1: 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하고 그 포인터를 넘겨야 함
    • 이 메모리 트래픽은 피할 수 있음
  • 프로파일 기반 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 컴파일러 팀이 더 최적화된 코드를 만들도록 돕는 데 직접 활용될 수 있음

댓글과 토론

Hacker News 의견들
  • 호출 규약을 최적화할 때 핵심은 좋다고 생각되는 형태를 머릿속으로 따지는 게 아니라 성능을 측정하는 것임
    코드는 빨라야 좋은 것이지, 빨라 보인다고 좋은 게 아님
    작성자가 나쁜 코드라고 부르는 것이 전혀 직관적이지 않은 이유로 가장 빠른 경우도 있고, 그건 큰 벤치마크에서 재봐야만 알 수 있음
    나빠 보이는 호출 규약이 잘 동작하는 이유 중 하나는 인자 레지스터를 아껴 레지스터 할당기를 조금 더 편하게 해주기 때문임
    또 오늘날 CPU는 C 컴파일러가 만든 명령어 흐름에 최적화되어 있어서, 특히 MSVC처럼 스택 전달을 의외로 자주 하는 C 컴파일러식 코드를 만들면 CPU의 최적 지점에 맞을 수 있음
    인라이닝이 워낙 잘 되다 보니 뜨거운 경로에서 호출은 드문 경계가 되고, 그 경계가 좀 지저분해도 다른 것들을 단순하게 만든다면 괜찮음
    여기 변경이 나쁘다는 뜻은 아니지만, 이상해 보이는 코드만 보고 측정 없이 논하는 건 이상함
    JavaScriptCore에서 호출 규약 최적화를 직업으로 했고, 실제 큰 코드에서 나빠 보이는 스택 전달 코드가 이기는 일이 놀랄 만큼 잦았음

    • 코드가 빨라 보인다고 항상 실제로 빠르지 않다는 데 강하게 동의함
      다만 성능 측정 결과만이 유일한 기준이어서는 안 된다고 봄
      “오늘날” CPU가 최적화되어 있다는 표현에서 중요한 단어는 오늘날이고, CPU는 계속 변하므로 호출 규약은 장기적 설계가 되어야 함
      그래서 안타깝지만 C++이 하는 방식에서 너무 벗어나지 않는 편이 유리함. 앞으로의 프로세서 최적화도 그쪽을 겨냥할 가능성이 높기 때문임
      동시에 인자 레지스터를 아끼는 것처럼 쉽게 변하지 않을 일반 원칙을 고려해 호출 규약을 견고하고 미래 지향적으로 만드는 게 좋음
      Rust가 최근 몇 년 사이 이상함 허용치(https://steveklabnik.com/writing/the-language-strangeness-bu...) 면에서 너무 보수적이 된 것 같아, 이렇게 말하는 게 좀 묘하긴 함. 결국 다르지 않고는 더 나아질 수 없음
    • 레지스터 전달이 더 빠른지도 함수 본문에 따라 달라짐
      함수가 시작하자마자 매개변수의 주소를 잡아 알 수 없는 함수에 넘긴다면 어차피 스택으로 흘려야 함
      함수 본문 기반 호출 규약 최적화를 보면 흥미로울 듯함. C의 정적 함수라면 주소를 취하지 않는 한 안전할 것 같음
    • 그 경험이 완전히 이전되지는 않음
      JIT는 어셈블리 한 줄을 만들기 전부터 실제 실행 중인 CPU에 대한 정보를 많이 모아둔 상태라 이 문제에서 유리함
      순수 정적 컴파일 코드에서는 런타임의 아키텍처 기능 집합을 알 수 없어서, 가장 최적화하고 싶은 코드에서 오히려 인라이닝 장벽을 자주 만나게 됨
    • 성능에는 실행 속도뿐 아니라 바이너리 크기도 포함될 수 있음
      현재 Rust는 작은 플랫폼에서 이 부분이 약해 보이고, 호출 규약이 Result 반환과 관련해 도움이 될 수도 있음
    • 원문은 대체로 x86에 관한 것이고, Intel은 사람들이 사는 자사 실리콘 위에서 보기 흉한 x86 코드가 빠르게 돌도록 수십 년간 놀라운 공학을 해왔음
      그래도 스택 전달의 경험적 이점이 레지스터가 많은 ARMV8 CPU나 RISC-V로 넘어가도 계속 적용되는지 궁금함
  • 합리적인 초안이지만 호출자 저장/피호출자 저장 구분이 빠져 있고, 입력 레지스터의 일부를 출력에 배정하는 흔한 실수가 있음
    디버거가 C와 다른 호출 규약을 이해할 것이라는 기대도 낙관적임. DWARF가 무엇을 인코딩할 수 있든 실제로는 처참히 실패할 가능성이 큼
    최적화 설정에 따라 ABI를 바꾸면 분리 컴파일과 매우 나쁘게 상호작용함
    인자를 빈 패킹처럼 재배치하는 방식은 동작하겠지만 컴파일러 복잡도가 크게 늘고, 왼쪽에서 오른쪽으로 첫 적합 배치하는 것 대비 가치가 있는지 모르겠음. 개발자가 인자가 어디로 갈지 예측하기도 어려워짐
    주소가 빠져나가는 함수와 그렇지 않은 함수에 서로 다른 호출 규약을 두는 큰 방향은 타당함. 임피던스 매칭을 하는 프롤로그를 떼어내는 방식도 잘 작동함
    Rust는 C와 다른 호출 규약을 가질 의향이 있어야 하지만, 모든 함수가 쓰는 하드코딩된 하나의 규약이어야 하는지는 모르겠음. 타입 시스템에 넣는 게 자연스러워 보이고, 개발자가 호출 규약을 제어할 수 있게 하면 어셈블리의 성능상 이점 중 하나가 사라짐

    • 입력 레지스터 일부를 출력 레지스터로 쓰는 게 왜 그렇게 문제인지 궁금함
      호출자 입장에서는 어차피 두 함수 호출 사이에서 출력 레지스터를 비워야 하고, 시스템 호출 규약에서도 꽤 널리 쓰임
      피호출자가 입력 값을 그대로 둔 채 출력 값을 준비하기 쉽게 하려는 건가 싶음. 그렇다면 출력 레지스터를 입력 순서의 끝에 두어 겹침을 피하려는 정도는 이해되지만, 어떤 겹침도 완전히 금지해야 할 이유는 잘 안 보임
    • 개발자가 호출 규약을 제어하게 하면, Function AFunction B, Function C, Function D를 호출하는 식의 연쇄에서 중간 함수들의 인자를 다른 규약으로 바꿔 오버헤드를 줄이는 최적화가 동시에 막힘
      그런 최적화를 보존하면서 제어권도 허용하는 의미론이 무엇일지, 사실상 환상에 그치는 건 아닌지 의문임
      실제로 어셈블리는 대부분의 컴파일러 최적화 대상이 아니기 때문에 성능상 불리함이 있음. “동작을 살펴 완전히 중복임을 판단하고 통째로 제거”하는 최적화도 못 받는 경우가 많고, 이제 1990년대가 아님
      다만 그런 최적화를 고려할 수조차 없는 경우라면, 인라인 어셈블리가 확실히 밀릴 곳은 프로파일 기반 최적화 정도라고 봄. 애플리케이션 개발자는 코드 동작을 완벽히 알고 있고 컴파일러 개발자는 모르기 때문임
      호출 오버헤드는 관련된 뜨거운 경계를 덮을 때까지 어셈블리를 더 쓰면 제거할 수 있음
    • DWARF는 현재 맞춤 호출 규약을 전혀 인코딩하지 않음
    • 빈 패킹은 오히려 느리게 만들 가능성이 있고, 특히 bool의 경우 의존성 사슬을 만들 수 있음
      x64에서 bool들은 먼저 레지스터에 넣고, 시프트하고, 결과에 OR하는 것보다 나은 방법이 딱히 없어 보임
      단순한 방식은 길이 64의 의존성 사슬을 만들고 64사이클 페널티가 날 수 있지만, 잘하면 6사이클, 현실적으로는 12사이클 정도로 줄일 수도 있을 듯함
      하지만 64개의 bool이 어디서 오느냐도 문제임. 레지스터가 그렇게 많지 않으니 결국 스택에서 다시 읽어야 함
      Rust ABI가 구조체 안의 bool을 이미 이렇게 빽빽하게 패킹한다면 어차피 해야 할 일이겠지만 잘은 모름
      그리고 호출자는 다시 전부 풀어야 함
      컴파일러가 값을 스택의 결과 공간에 흘리도록 가르치는 편이 더 쉽고, 성능도 더 좋을 가능성이 큼
    • 최신 프로세서는 대부분 저장 직후 이어지는 읽기를 쉽게 전달하고, 스택 상태 추적에도 여러 요령이 있음
      그렇다면 값을 레지스터에 넣는 게 실제로 얼마나 도움이 되는지 의문임
  • C 호출 규약은 좀 별로임
    C 호출 규약을 바꿀 수 없다는 건 사실이지만, 그렇다고 덜 아쉬워지는 건 아님
    사용 가능한 모든 호출자 저장 레지스터를 인자와 반환값에 써야 하는데, 전통적인 SysV ABI에서는 반환값에 레지스터 하나, 때로는 둘만 씀
    struct Point3D { long x, y, z }를 반환하면 Point3Drax, rdi, rsi에 넣을 수 있는데도 스택에 흘림
    다른 시스템에는 다른 요령도 있음. 기억이 맞다면 SBCL에서는 함수가 여러 값을 반환할 때 종료 시 캐리 플래그를 세움. 예를 들어 Result가 오류를 담았는지 표시하는 데 캐리 플래그를 쓰면 좋지 않겠나 싶음

    • “별로”는 강한 표현이지만 반환값에 관해서는 맞음
      C 호출 규약은 사실상 C가 지원하는 것, 즉 인자 하나 반환을 지원함. 구조체 반환조차 제대로는 아님
      C에서는 “그럴 줄 몰랐나”에 가깝고, C++ 쪽에서는 “그냥 인라인하면 되잖아”라는 식이 됨
      반면 메모리 스필은 실제로 일어남. 예를 들어 SPARC의 넉넉한 레지스터 공간과 윈도우는 단순 함수에서 미사용 레지스터를 많이 남겼고, 레지스터 링을 스필하면 캐시를 깨는 큰 스택 사용량으로 이어졌음
      x86에서 데이터를 “필요한 곳”으로 재배치하는 mov가 많아도, 결과적으로 더 빠른 경우가 잦았음
      피호출자 코드만 보면 “이 인자는 여기에, 저 반환값은 저기에 있으면 무조건 빠르겠다”고 말하고 싶어지지만, 호출자를 알 수 없음
      인자 준비가 그대로 통과될지, 반환값이 뜨겁게 소비될지 보장할 수 없음. 예를 들어 struct Point { x: i32, y: i32, z: i32 }를 인자/반환으로 쓰고 호출자가 루프에서 mystruct.deepinside.point[i] = func(mystruct.deepinside.point[i]) 같은 일을 한다면 레지스터로 넣고 빼는 게 오버헤드가 되거나 벡터화를 막을 수도 있음
      피호출자는 이걸 알 수 없고, 컴파일러가 양쪽을 보고 인라인할 수 있을 때만 예외임
      호출 관련 가장 낮게 달린 열매는 거의 모든 C ABI에 박힌 함수는 원시 값 하나를 반환한다는 전제를 없애는 것 같음. 나머지는 많은 벤치마크와 코드 생성 통계가 필요함
  • Rust에는 구조체가 원하는 것보다 커지는 또 다른 아쉬운 세부사항이 있음
    None 또는 Some(u8)Option 필드 8개를 담은 Foo 구조체를 생각하면, C에서는 1비트 bool 8개와 uint8_t 8개로 총 9바이트에 표현 가능함
    Rust에서는 1바이트 판별자와 uint8_t가 8번 반복되어 16바이트가 됨
    이유는 구조체가 필드의 빌림을 제공할 수 있어야 하기 때문임. &Foo가 있으면 컴파일러는 &Foo::some_field, 즉 &Option을 만들 수 있어야 하고, 이 &Option은 프로그램 안의 다른 모든 &Option과 같은 형태여야 함
    따라서 내부 Option은 프로그램의 다른 Option과 같은 레이아웃, 즉 자기 판별 비트를 바이트로 올림한 것과 u8을 가져야 함. 실제로 &Foo::some_field를 만들지 않아도 구조체가 이 비용을 냄
    더 큰 타입의 Option을 생각하면 더 나빠짐. Option 필드 8개가 있는 구조체에서는 각 판별자가 2바이트로 올림되어 총 32바이트가 되고, 4분의 1, 판별자의 미사용 비트까지 포함하면 거의 절반이 중간 패딩으로 낭비됨. C 동등 구조는 18바이트면 됨
    Option을 쓰면 Rust 구조체는 128바이트, C 구조체는 72바이트가 될 수 있음
    물론 패킹된 판별자용 u8 하나와 MaybeUninit 8개를 두고, &Foo에서 Option<&T>, &mut Foo에서 Option<&mut T>로 매핑하는 함수들을 직접 만들면 C와 같은 표현을 구현할 수 있음. 다만 &Option이나 &mut Option으로는 못 감
    https://play.rust-lang.org/?version=stable&mode=debug&editio...

    • C 버전도 직접 구현해야 하니 Rust에서도 똑같이 직접 해야 하는 게 그렇게 이상하진 않음
      사실상 Option 8개를 담는 사용자 정의 타입을 설명한 것이고, 성능을 신경 쓰기 시작하면 내부 Option 처리는 직접 굴려야 함
    • C 동등 버전도 직접 구현해야 함
      Rust가 목표에 맞을 때 선택해 쓸 수 있는 편리한 기능을 제공한다고 해서 단점이라고 보긴 어려움
      설명한 사용 사례는 비교적 드물고, 실제 성능 병목이라면 Rust에서 조금 더 시간을 들여 구현하는 게 큰 문제는 아님
      일반적인 사용에서 Option<_> 타입이 주는 이점이 매우 크기 때문에, 이걸 Rust의 “아쉬운 세부사항”으로 보기는 힘듦
  • 비다형, 비인라인 함수의 주소가 함수 포인터로 취해질 수 있으면 -Zcallconv=legacy를 쓰는 shim을 만들고 실제 구현을 즉시 꼬리 호출한다는 내용이 있는데, 함수 포인터 동등성을 보존하려는 의도는 이해됨
    그런데 레거시 shim이 Rust 호출 규약 함수를 꼬리 호출하면, 호출 규약의 반환값 차이를 고칠 수 없지 않나?

    • 맞음. 사람들이 호출 규약의 반환 절반을 잊는 경향이 있어서, 이해할 만한 오타로 보임
  • 약간 다른 얘기지만, 현재 Go와 Rust 상호운용이 가능한지 궁금함
    예전에 중간에 Zig를 두고 해낸 사례를 본 기억이 있는데 찾을 수 없음. 레거시 Rust 코드가 있어서 조금씩 Go로 옮기고 싶음

    • 가능함. CGO로 extern "C" FFI를 써서 Rust 함수를 호출할 수 있음
      GitHub 코드 검색에서 어떻게 쓰는지 RustConf 2023에서 발표했고(https://www.youtube.com/watch?v=KYdlqhb267c), 이후 1Password 같은 곳에서도 비슷하게 하고 있다는 얘기를 들었음
      C 상호운용 경계를 넘어 타입을 옮기는 작업이 번거로워서 재미있지는 않지만, 가능하고 코드 재사용도 가능함
    • Go에서 Rust를 호출하려면 Rust 함수를 extern "C"로 선언한 뒤, Go에서 C를 호출하듯 부르면 됨
      반대 방향은 잘 모르겠음
    • 관리 메모리와 비관리 메모리를 섞는 건 보통 현명하지 않음
      관리 코드에서는 해제하거나 이동할 메모리를 소유할 수 있어야 하고, 비관리 코드는 메모리가 언제 해제되거나 이동되는지 추론해야 함
      cgo 같은 것들은 Go의 관리 코드에서 비관리 메모리로 FFI 호출을 섞게 해주지만 비용을 치름
      서로 호출하는 언어들이 가비지 컬렉터를 공유하지 않는 구현에서는 항상 이 문제가 생김
      관리/비관리 코드를 섞는 건 오래된 아이디어이면서도 여전히 활발히 연구되는 주제임
      내장 런타임이 이를 위해 설계된 경우가 아니라면 비관리 코드에서 관리 코드로 호출하는 건 거의 항상 나쁜 생각이고, 보통 중간에 직렬화 계층이 들어감
    • Rust와 Swift를 꽤 많이 써야 해서, 결국 직렬화된 protobuf의 바이트 배열을 상투적인 함수 호출로 주고받는 방식에 정착함
      이게 본업이라면 별로라고 생각할 수 있겠지만, 몇 주마다 코드로 돌아왔을 때 뭘 어떻게 하는지 기억나지 않는 데 지쳤음
    • 꽤 저주받은 예로, 최근 Rust에서 중간에 C를 끼워 Go 코드를 호출한 적이 있음
      상태를 가진 Rust 클로저를 Go 코드에 콜백으로 넘겨 Go 표준 라이브러리 함수에 넣었고, Rust 클로저 내부의 panic 풀림까지 포함했음
      https://github.com/Voultapher/sort-research-rs/commit/df6c91...
  • 섹션 제목이 어떻게 비스듬히 설정됐는지 알아내려고 요소 검사를 한참 봤는데, Safari 도구 기준으로는 막혔음. 대체 어떻게 한 건가?

    • 스타일은 .post-title 요소에 있음: transform: skewY(-2deg) translate(-1rem, -0.4rem);
    • 관련해서 미니맵이 CSS element() 함수(https://developer.mozilla.org/en-US/docs/Web/CSS/element)를 쓰는 줄 알았는데, 실제로는 글 본문을 아주 작게 축소한 복사본이었음
    • h1, h2, h3, h4, h5, h6transform:skewY(-2deg) translate(-1rem,0rem);, transform-origin:top;, font-style:italic;, text-decoration-line:underline;, text-decoration-color:goldenrod;, text-underline-offset:4%;, text-decoration-thickness:.25ex가 적용되어 있음
  • 대조적으로 2019년 글 “How Swift Achieved Dynamic Linking Where Rust Couldn't”가 있음
    https://faultlore.com/blah/swift-abi/
    Rust가 아직 Rust 수준 의미론을 위한 호출 규약을 갖고 있지 않다는 건 아쉽지만, 동시에 그 글은 거기까지 가는 데 필요한 작업량이 엄청나다는 점을 보여줌
    Apple은 Swift를 애플리케이션이 의존할 수 있는 실용적 시스템 언어로 만들기 위해 깊이 동기부여되어 있었지만, Rust에는 그런 후원이 없음
    HN 논의: https://news.ycombinator.com/item?id=21488415

    • Swift 방식에는 런타임 비용이 있다는 점도 공정하게 짚어야 함
      Rust에도 이 절충을 위한 지원 옵션이 더 많으면 좋겠고, https://github.com/rust-lang/rfcs/pull/3470 같은 것만으로 한정될 필요는 없음
  • 현재 Rust 컴파일러가 공격적으로 인라인한 다음 최적화한다면, 이게 수고할 가치가 있는지 의문임
    호출되는 함수가 작으면 인라인될 것이고, 크다면 함수 안에서 시간을 꽤 쓸 테니 호출 오버헤드는 작을 것임

    • 런타임 함수, 예를 들어 dyn Trait는 인라인될 수 없으니 이런 변경이 도움이 됨
      호출을 싸게 만들 수 있으면 그렇게 공격적으로 인라인하지 않아도 되어 코드 크기와 컴파일 시간에도 도움이 될 수 있음
    • 아마 가치가 있을 듯함
      인라인에 적합하지 않은 복잡한 함수는 메모리에 몇 번 접근할 가능성이 높고, 그 접근이 병목일 가능성이 큼
      스택 전달은 그 병목을 더 조임. 캐시 압박과 적재/저장이 늘어나기 때문임
      Rust가 상당한 비율의 함수 호출에서 인자를 최적으로 전달할 수 있다면 L1 접근 몇 사이클을 피할 뿐 아니라, CPU가 본질적인 메모리 병목에 더 빨리 도달하게 할 수도 있음
      몇 퍼센트 정도 이득이 있을지도 모르지만, 지금은 와인을 마시고 있고 계산은 안 하고 있음
  • x86 참고 자료에 나오는 “Diana’s silk dress cost $89” 암기법이 뭔지 설명해줄 수 있나?