std::string_view를 값으로 전달해야 하는 3가지 이유 (번역)

2022. 9. 13. 16:53C++

원문

 

Three reasons to pass std::string_view by value

It is idiomatic to pass std::string_view by value. Let’s see why.

quuxplusone.github.io


std::string_view를 값으로 전달하는 것은 관용적입니다. 왜 그런지 봅시다.

 

첫째, 약간의 배경을 설명해보죠. C++에서, 모든 것들은 기본적으로 값으로 전달(pass-by-value)됩니다. Widget w라고 하면 여러분은 완전히 새로운 Widget 객체를 얻을 수 있습니다. 하지만 큰 객체들을 복사하는 것은 성능적으로 비쌀 수 있습니다. 그래서 우리는 "값 전달"의 최적화로써 "const 참조 전달"을 사용하며, 사람들에게 std::string와 같이 크고/크거나 비싼 것들은 값 대신 const 참조로 전달하라고 말합니다.

 

하지만 우리는 int, char*, std::pair<int, int>, std::span<Widget>와 같이 크기가 작고 성능적으로 싼 것들은 값 전달의 합리적인 기본 동작을 선호합니다.

 

값 전달은 (const) 참조 전달 보다 적어도 3가지의 성능적인 이점이 있습니다. 제가 이 3가지들을 string_view를 통해 설명하겠습니다.

여기에 나오는 모든 코드 조각들은 개별적으로 표시되며, 피호출자(callee)또는 호출자(caller)만을 보여줍니다. 만약 컴파일러가 호출자와 피호출자 양쪽을 모두 볼 수 있고 최적화 레벨 -O2가 주어졌으며, 피호출자를 호출자로 인라인 하기로 결정했다면, 비관용적인 참조 전달에 의해 야기되는 모든 손해들은 보통 없어질 수 있습니다. 그래서, 여러분은 종종 string_view를 참조로 전달하고 이 글에서 말하는 것들을 신경쓰지 않으셔도 됩니다. 하지만 여러분은 string_view를 값으로 전달하셔야 되는데, 그래야 컴파일러가 여러분 대신에 저러한 행동들을 할 필요가 없기 때문입니다. 그리고 여러분의 코드 검토자가 참조로 전달하는 비관용적 결정에 대해 숙고하면서 뇌세포를 태울 필요가 없습니다. 간단하게, 작고 복사가 싼 타입들은 값으로 전달하세요! 이면만 있으니까요!

좋습니다. 이제 제가 약속했던 3가지 성능적 이점을 보러 가봅시다. 

 

1. 피호출자 쪽에서 포인터 간접 참조 제거

(const) 참조 전달은 여러분이 객체의 주소를 전달한다는 의미입니다. 값 전달은 객체 그 자체를 전달하는데, 가능하면 레지스터를 통해 전달합니다. (만약 non-trivial 파괴자를 갖고있는 것과 같이, 전달하려는 객체가 "ABI의 목적으로 non-trivial" 하다면, "객체 그 자체"는 결국 스택을 통해 전달되며, 어느 쪽이든 메모리 간접 참조가 있을 것 입니다. 하지만 int, string_view, span과 같은 trivial 타입들은 이러한 것들을 걱정 할 필요가 없습니다. 이 타입들은 레지스터로 전달됩니다.)

 

값 전달은 피호출자 쪽에서 포인터 간접 참조를 제거하는데, memory load를 제거한다는 의미가 됩니다.

int byvalue(std::string_view sv) { return sv.size(); }

int byref(const std::string_view& sv) { return sv.size(); }

---

byvalue:
    movq %rsi, %rax
    retq

byref:
    movl 8(%rdi), %eax
    retq

byvalue 케이스를 보면, string_view가 레지스터 쌍 (%rdi, %rsi)으로 전달되며, 따라서 string_view의 "size" 멤버를 반환하는 것은 그냥 레지스터 to 레지스터 이동입니다. 반면에, byref는 %rdi 레지스터로 전달된 string_view의 참조를 받으며, "size" 멤버를 추출하기 위해 memory load가 필요하게 됩니다.

 

2. 호출자 쪽에서 레지스터 스필(Register Spill) 제거

여러분이 참조로 전달할 때 호출자는 객체의 주소를 레지스터에 넣어야 합니다. 그래서 객체는 반드시 주소를 가지고 있어야 하지요. 비록 호출자 쪽의 다른 모든 것들이 레지스터에 있는 것들로 이루어질 수 있었다고 해도, 객체를 참조로 전달하는 것은 호출자에게 레지스터 스필을 강요합니다.

(역주 : 레지스터 스필이란 레지스터에 있는 것들을 메인 메모리로 옮기는 기법으로, 주로 변수들을 담을 레지스터의 개수가 부족하거나 위와 같이 레지스터로 전달될 수 있는 크기여도 객체의 주소가 필요한 경우 발생한다.) 

 

값 전달은 인자의 레지스터 스필의 필요성을 제거하며, 이것은 때때로 호출자 쪽에서 스택 프레임이 필요성을 없앤다는 것을 의미합니다.

int byvalue(std::string_view sv);
int byref(const std::string_view& sv);

void callbyvalue(std::string_view sv) { byvalue("hello"); }

void callbyref(std::string_view sv) { byref("hello"); }

---

.Lhello:
    .asciz "hello"

callbyvalue:
    movl $.Lhello, %edi
    movl $5, %esi
    jmp byvalue    # tail call

callbyref:
    subq $24, %rsp
    movq $.Lhello, 8(%rsp)
    movq $5, 16(%rsp)
    leaq 8(%rsp), %rdi
    callq byref
    addq $24, %rsp
    retq

callbyvalue에서, 우리는 string_view 인자의 data pointer와 size 멤버를 각각 %rdi와 %rsi에 설정한 후, byvalue로 점프했습니다.  반면에 callbyref에서는 string_view의 주소를 필요로 하므로 스택에 공간을 만듭니다. 그리고 byref가 반환했을 때 스택에 만든 공간을 정리합니다.

 

3. 앨리어싱 제거

우리가 참조로 전달할 때, 우리는 피호출자는 전혀 알지 못하는 객체의 참조를 피호출자에게 전달합니다. 피호출자는 다른 누군가 그 객체의 포인터를 들고 있을지 알지 못하죠. 또한 피호출자는 자신의 함수 안에 선언된 어떤 포인터들이 그 객체 (또는 그 객체의 일부)를 가리키고 있을지 알지 못합니다. 그렇기에 컴파일러는 이러한 알 수 없는 것들에 대해 피호출자를 매우 보수적으로 최적화를 해야 합니다.

 

값 전달은 피호출자에게 완전히 새로운 객체의 복사본을 전달합니다. 이 복사본은 명확하게 프로그램의 어떤 다른 객체들과도 앨리어싱 관계가 없으므로 피호출자는 더 큰 최적화의 기회를 가지게 됩니다. 

void byvalue(std::string_view sv, size_t *p) {
    *p = 0;
    for (size_t i=0; i < sv.size(); ++i) *p += 1;
}

void byref(const std::string_view& sv, size_t *p) {
    *p = 0;
    for (size_t i=0; i < sv.size(); ++i) *p += 1;
}

---

byvalue:
    movq %rsi, (%rdx)
    retq

byref:
    movq $0, (%rsi)
    cmpq $0, 8(%rdi)
    je   .Lbottom
    movl $1, %eax
 .Ltop:
    movq %rax, (%rsi)
    leaq 1(%rax), %rcx
    cmpq 8(%rdi), %rax
    movq %rcx, %rax
    jb   .Ltop
 .Lbottom:
    retq

byvalue에서 Clang은 루프에서 *p1만큼 증가키시는 것을 sv.size()번 반복하는 것은 간단한 대입 *p = sv.size()와 마찬가지라고 볼 만큼 충분히 똑똑합니다. 하지만 byref에서 Clang은 같은 도약을 할 수 없습니다. 왜 안될까요? 자, byref는 심지어 다음과 같이 호출되더라도 "올바르게" 작동해야 하기 때문입니다.

std::string_view sv = "hello";
size_t *size_p = &sv.__size_;  // address of sv's "size" member
byref(sv, size_p);

이 상황에서, *size_p의 매 증가마다 sv.size()의 값을 변화시키게 되는데, 이것은 루프를 영원히 (또는 sv.__size__가 0으로 돌아올 때 까지) 돌게 만들죠. 그래서 byvalue와는 다르게 byref에서 루프는 간단한 대입과 같지 않습니다! 컴파일러는 더 복잡한 행동에 대응되는 기계 코드를 생성해야만 하죠.

 

byvalue는 저 사악한 호출자에 대해 걱정할 필요가 없습니다. 호출자가 string_view의 복사본과 이것의 내부를 가리키는 포인터를 전달하는 (명확한) 방법이 없기 때문입니다.

 


요약 :

  • C++은 기본적으로 모든 것들을 값으로 전달합니다.
  • 값 전달은 작고 복사가 싼 int, char*, pair<int, int>와 같은 타입들에 최적입니다.
  • 값 전달은 위에서 설명된 적어도 3가지 이점들이 있습니다. 하지만,  string, vector와 같이 복사본을 만드는 성능적 비용이 이러한 이점들보다 더 크다면, const 참조로 전달하는 것을 선호하세요. const 참조 전달은 값 전달의 최적화입니다.
  • C++17의 string_view, C++20의 span, C++2b의 function_ref와 같이 small, trivially copyable, "parameter-only" 타입들은 명시적으로 intchar*와 같은 범주를 차지하도록 설계 되었습니다. 이것들은 값으로 전달하세요!

 


https://quuxplusone.github.io/blog/2021/11/19/string-view-by-value-ps/

 

같은 저자가 쓴 위 글에 따르면, Microsoft’s x86-64 ABI는 값 전달로도 string_view를 레지스터로 전달하지 않고 참조로 전달할 때와 마찬가지의 코드를 생성한다고 합니다. 그럼에도 저자는 참조 보다는 값 전달을 추천하고 있습니다. 자세한 분석은 위 글을 참조.