최근 포토로그


Rvalue 와 RVO, NRVO 복잡한컴퓨터이야기

C/C++를 써야만 하는 혹은 쓸 수 밖에 없는 상황들이라는 것은 대부분 성능,속도, 자원 사용의 최적화 등이지요. Visual Studio 2010에 새로이 추가된 Rvalue concept 이나 사용 예들도 정확히 이러한 상황들에 대응하기 위한 것이라 할 수 있을 겁니다.

Rvalue의 경우에도 class의 개발 시 move constructor/operator 를 추가하여,  method를 호출할 때, 매개변수를 통하여 객체를 전달하는 과정에서 임시객체의 생성/소멸을 가능한 막아보자는 것이지요. 하지만 이러한 최적화 과정은 사실 Rvalue concept의 도입이전에도 다양한 형태로 시도되고, 실제 컴파일러에 적용되었습니다. 오늘 설명 드리고자 하는 (N)RVO 도 Rvalue와 비슷한 맥락의 최적화 기법 중 하나입니다.

간단한 예제를 통해서 그 내용을 살펴 볼까 합니다.

#include <stdio.h>

class RVO
{
public:
RVO()
{
printf("I am in constructor\n");
}

RVO (const RVO& c_RVO)
{
printf ("I am in copy constructor\n");
}
~RVO()
{
printf ("I am in destructor\n");
}

int mem_var;
};

RVO MyMethod (int i)
{
RVO rvo;
rvo.mem_var = i;
return (rvo);
}

int _tmain(void)
{
RVO rvo;
rvo = MyMethod(5);

return 0;
}

위 예제를 머리 속으로 컴파일 해서 수행해 보시고 출력 결과가 어떠할지, 그리고 생성자, 복사 생성자, 파괴자가 각각 몇 번이나 호출될지 정확히 예측하실 수 있으실까요? 위 source를 어떠한 optimiation도 켜지 않은 상태로, debug mode에서 수행하면 다음과 같은 결과를 출력합니다.


I am in constructor <-- _tmain의 RVO rvo; 행에 의해서 호출될 겁니다. 
I am in constructor <-- MyMethod 함수의 RVO rvo; 행에 의해서 호출될 겁니다.
I am in copy constructor  <-- 이 녀석은 어디서 호출되는 걸까요? MeThod 함수의 return (rvo); 에서 호출됩니다.
I am in destructor
I am in destructor
I am in destructor

생성자, 복사 생성자, 파괴자는 총 6회 호출 되었음을 알 수 있죠. 어디서 생성자, 복사 생성자, 파괴자가 생성되었는지를 좀 더 명확하게 살펴보려면 위 코드를 컴파일 한 후 assembly code를 드려다 보면 좋습니다.

먼저 _tmain() 부터 확인해보죠.


    RVO rvo;

00F41518  lea         ecx,[ebp-10h]  // rvo의 위치는 [ebp-10h] 입니다.
00F4151B  call        RVO::RVO (0F41140h)  // 생성자가 호출되었군요 1
00F41520  mov         dword ptr [ebp-4],0 
    rvo = MyMethod(5);
00F41527  push        5 
00F41529  lea         eax,[ebp-58h] 
// 5는 [ebp-58h]에 저장됩니다.
00F4152C  push        eax 
// stack에 push하구요.
00F4152D  call        MyMethod (0F410DCh)  // 함수를 호출했습니다.
00F41532  add         esp,8  // stack을 정리 했구요.
00F41535  mov         dword ptr [ebp-5Ch],eax  // 반환 값을 [ebp-5ch]로 담고는
00F41538  mov         ecx,dword ptr [ebp-5Ch]  // 그 값을 다시 ecx로 옮깁니다.
00F4153B  mov         edx,dword ptr [ecx]    // ecx가 가리키는 메모리의 값을 edx로 옮기고
00F4153D  mov         dword ptr [ebp-10h],edx  // rvo 변수가 가리킬 수 있도록 변경합니다.
00F41540  lea         ecx,[ebp-58h]  // 이건 반환되었던 객체죠?
00F41543  call        RVO::~RVO (0F4101Eh) // 반환 되었던 객체의 파괴자를 호출합니다. 5

    return 0;
00F41548  mov         dword ptr [ebp-54h],0 
00F4154F  mov         dword ptr [ebp-4],0FFFFFFFFh 
00F41556  lea         ecx,[ebp-10h]  // rvo 입니다.
00F41559  call        RVO::~RVO (0F4101Eh) // rvo에 대한 파괴자를 호출하는군요. 6
00F4155E  mov         eax,dword ptr [ebp-54h] 


이젠 MyMethod도 살펴 보겠습니다.



    RVO rvo;
00F413EF  lea         ecx,[ebp-10h]  // rvo의 위치는 [ebp-10h] 입니다.
00F413F2  call        RVO::RVO (0F41140h)  // 생성자가 호출되었군요. 2
00F413F7  mov         dword ptr [ebp-4],1 
    rvo.mem_var = i;
00F413FE  mov         eax,dword ptr [ebp+0Ch] 
00F41401  mov         dword ptr [ebp-10h],eax 
    return (rvo);
00F41404  lea         eax,[ebp-10h] 
00F41407  push        eax 
00F41408  mov         ecx,dword ptr [ebp+8]  // [ebp+8] 위치의 임시 객체에 대해서
00F4140B  call        RVO::RVO (0F41145h)  // 복사 생성자를 호출하는군요. 3
00F41410  mov         ecx,dword ptr [ebp-54h] 
00F41413  or          ecx,1 
00F41416  mov         dword ptr [ebp-54h],ecx 
00F41419  mov         byte ptr [ebp-4],0 
00F4141D  lea         ecx,[ebp-10h]  // [ebp-10h] 위치의 객체는 rvo 입니다.
00F41420  call        RVO::~RVO (0F4101Eh) // 파괴자를 호출하는군요 4

00F41425  mov         eax,dword ptr [ebp+8] 
}


(복사)생성자, 파괴자의 호출 code는 붉은색으로 표시하였고, 옆에 호출 순서에 따라 번호를 써 두었습니다. 근데 여기서 우리가 유심히 살펴보았음 직한 녀석은 3번의 복사 생성자 호출과 5번의 파괴자 호출입니다. 조금만 고민해 보면 MyMethod()에서 객체를 반환하기 위해서 임시 객체를 생성하고, 생성된 임시 객체를 반환한 후, 생성된 임시 객체의 파괴자를 호출하는 일련의 과정은 없어도 될 것 같지 않으세요? 이미 _tmain에서 생성된 객체가 있으므로, 임시 객체의 생성/파괴는 사실 불필요 하죠.

임시 객체를 생성해야 했던 이유는 함수의 반환 값으로 객체를 전달하기 위해서만 쓰였잖아요. 이제 제가 말씀 드리고 싶어한 걸 설명할 시간이 되었네요.

RVO는 Return Value Optimization 이라는 기법인데요. 이것이 뭔고 하니 위 예와 같이 어떤 함수가 객체를 반환해야 할 경우에 필요하지 않은 임시 객체를 생성하지 않도록 최적화 하는걸 말합니다. 그런데 RVO는 MyMethod() 에서 처럼 반환 객체가 변수명을 가지는 경우는 최적화가 되질 않았어요. 그래서 몇몇 사람들이 “변수가 이름을 가지는 경우에도 최적화의 대상에 포함시키자”로 주장했고, 이를 NRVO, Named Return Value Optimization 이라고 구분하여 불렀습니다. 그리하여 ISO/ANSI C++ 위원회에서 1996년에 이 이 둘 모두에 대해서 최적화 될 수 있음을 발표했다고 하는군요.(사실 발표하는 것이 뭐 어렵습니까? compiler 개발사만 어렵지..) 여하둥둥 그리하여 Visual Studio 2005에서 NRVO에 대한 최적화 기능이 포함되었습니다.


Visual Studio에서 NRVO를 가능하게 하기 위해서는 /O2 compiler option을 주면 됩니다.(프로젝트 설정에서 Optimization : Maximize Speed를 선택하시면 됩니다.)

이제 최적화의 결과물을 살펴 보시죠.

I am in constructor
I am in constructor
I am in destructor
I am in destructor


위의 출력결과와 사뭇 다른 것은 복사생성자의 호출이 빠졌고, 파괴자의 호출이 하나 줄어들었음을 알 수 있습니다.(어느 부분이 생략되었는지 예측하실 수 있겠죠?)

내용을 명확하게 하기 위해서 컴파일된 결과물을 disassmebly 한 결과를 다음에 나타내었습니다. 먼저 _tmain() 입니다.


    RVO rvo;
01351045  lea         eax,[rvo] 
01351048  push        eax 
01351049  call        RVO::RVO (1351000h)  // rvo 객체 생성자 호출이지요 
    rvo = MyMethod(5);
0135104E  lea         esi,[rvo]  // 여기가 핵심입니다. 함수의 호출 결과를 rvo로 할당하는게 아니라, rvo 값을 esi를 통해서 MyMethod로 넘겨버립니다.
01351051  call        MyMethod (1351030h) 
01351056  call        RVO::~RVO (1351020h)  // 파괴자 호출

    return 0;
0135105B  call        RVO::~RVO (1351020h)  // 파괴자 호출
01351060  xor         eax,eax 
01351062  pop         esi 
}
01351063  mov         esp,ebp 
01351065  pop         ebp 
01351066  ret 

다음은 MyMethod() 입니다.

RVO MyMethod (int i)

    RVO rvo;
01351030  push        esi 
01351031  call        RVO::RVO (1351000h)  // rvo 객체 생성자 호출이 여기 있군요. 
    rvo.mem_var = i;
01351036  mov         dword ptr [esi],5  // 여기도 중요하죠. esi를 통해서 전달된 객체에다가 5를 넣어버립니다. 객체생성이 없죠. 
    return (rvo);
0135103C  mov         eax,esi 
}
0135103E  ret 


여기저기 최적화 루틴 때문에, 앞서의 코드와는 많이 달라졌지만, 어떤 식으로 최적화가 진행되었는지를 미루어 짐작해 볼 수 있습니다. 글을 써놓고 보니 너무 어렵네요. 지워버리긴 아까워서 일단 포스팅 합니다...


덧글

  • 상훈이 2012/09/12 13:00 # 삭제 답글

    좋은글 잘 봤습니다..

  • 나그네 2019/05/08 10:43 # 삭제 답글

    nrvo내용을 어셈블리 단에서 분석하는 글은 처음봤네요
    intel 어셈블리코드를 잘 알았더라면 더욱 deep하게 이해되었을텐데 아쉽네요
    좋은 내용 잘봤습니다.
댓글 입력 영역


facebook 프로필 위젯

트위터 위젯