결과는 컴파일러나 모드에 따라 다를 수 있지만 전체 문맥적으로 본다면 이해하는데 큰 무리는 없을겁니다





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

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

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


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#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를 선택하시면 됩니다.) 


vs2017 에서 Release 모드인경우 O2가 default 

Debug mode 에서는 '사용 않함 /Od' 가 기본 모드

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

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


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




내용을 명확하게 하기 위해서 컴파일된 결과물을 disassmebly 한 결과


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 




_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  



여기저기 최적화 루틴 때문에, 앞서의 코드와는 많이 달라졌지만, 어떤 식으로 최적화가 진행되었는지를 미루어 짐작해 볼 수 있습니다. 







1. RVO

MEC++ 20항목에 나오는 예제를 그대로 활용해보자.


  1. class Rational
  2. {
  3. public:
  4.         Rational(int numerator = 0int denominator = 1);
  5.         ...
  6.         int numerator() const;
  7.         int denominator() const;
  8. };
  9.  
  10. const Rational operator * (const Rational &lhs, const Rational &rhs);

위에서 operator * 는 Rational 객체를 값으로 (const) 반환하고 있다.
객체를 값으로 반환하기 때문에 임시 객체 생성이 불가피한 것이다.


  1. // 1.
  2. const Rational* operator * (const Rational &lhs, const Rational &rhs);
  3. // 2.
  4. const Rational& operator * (const Rational &lhs, const Rational &rhs);


객체를 값으로 반환하는 함수에 대해 원론적으로 임시 객체 생성을 피할 수는 없다.
다만, 최신의 컴파일러들이 지원하는 RVO 최적화를 이용하는 길이 있을 뿐이다.

최적화를 이용하는 그 방법은 바로 객체 대신에 객체 생성자 인자를 반환하는 것이다.

  1. inline const Rational operator * (const Rational &lhs, const Rational &rhs)
  2. {
  3.         return Rational(lhs.numerator * rhs.numerator,
  4.                         lhs.denominator * rhs.denominator);
  5. }

얼핏 보기에 위 코드가 무슨 이득이 있나 싶다.

operator * 안에서 객체가 1번 만들어지고 다시 이것을 반환하면서 임시 객체까지 만들어지니
오히려 손해일 것 같다는 느낌이 팍팍 드는데 말이다.

하지만, 컴파일러는 반환시 임시 객체를 없애고,
계산 결과값을 반환값을 받는 객체에 대해 할당된 메모리에 직접 넣어 초기화해 준다.
결국 생성자 한번, 소멸자 한번의 호출 비용만 들어가는 것이다.

이것이 바로 RVO(Return Value Optimization)인 것이다.

RVO는 단독 형태의 연산자 오버로딩 구현에도 톡톡히 아름답게 쓰일 수 있다.

단독 형태의 (산술) 연산자는 결과만 가지고 값을 반환하기 때문에 임시 객체가 만들어진다.
(operator + 나 operator - 같은...)
그리고 대입 형태 연산자는 lhs에 값을 기록하고 반환하기 때문에 임시 객체를 만들 필요가 없다.
(operator += 나 operator -= 같은...)

int a, b, c, d, result;
result = a + b + c + d; 

보다는

result = a;
result += b;
result += c;
result += d; 가 더 효율 측면에서만 보면 낫다는 것이다.

물론 위의 예는 극단적인 것이다. 

평소에 효율을 위해 저렇게 코딩하지는 않을테니.말이 그렇다는 것이다. 
하지만, operator + 를 oerator += 를 이용하여 다음과 같이 구현한다면...

  1. template<typename T>
  2. opeator + (const T& lhs, const T& rhs)
  3. {
  4.         return T(lhs) += rhs;
  5. }

대입 형태 연산자의 특성과 RVO의 특성이 합쳐져서 임시 객체 생성이 전혀 이루어지지 않게 된다.
(아~ 물론 lhs와 rhs는 서로 대입이 가능한 pair 여야만 한다)


2. NRVO (Named RVO)

vs2005부터 지원되는 NRVO는 말 그대로 이름이 있는 변수에 대해서도 RVO가 적용되는 것이다.
RVO와는 다르게 NRVO는 최적화 옵션 /O1(크기 최소화)부터 동작함을 기억하자.

최적화 모드에 따라 달라짐 Debug , Release 모드 기본 최적화 옵션이 다른데 각각 모드를 변경해보면 차이를 알 수 있음
vs2017 기준 
debug : 사용 안 함(/Od)
Release : 최대 최적화(속도 우선)(/O2)


바로 예제부터 살펴보자.

  1. class RVOSample
  2. {
  3. public:
  4.     RVOSample();
  5.     ~RVOSample();
  6.  
  7.     int value;
  8. };
  9.  
  10. RVOSample TestNRVO(int num)
  11. {
  12.     RVOSample rvo;
  13.     rvo.value = num;
  14.     return rvo;    // 임시 객체가 생성되지 않는다.
  15. }

RVO와 다르게 변수를 선언하고, 해당 변수를 사용한 다음 반환해도 임시 객체가 생성되지 않는다.

RVO에 비해 훨씬 쓰게 편하고 보기도 익숙하다.
하지만, 아래 예제와 같은 경우엔 NRVO로 최적화되지 않는다.

  1. RVOSample TestNRVO(int num)
  2. {
  3.     RVOSample rvo;
  4.     rvo.value = num;
  5.  
  6.     /- 조건에 의해 반환값이 다른 경우 NRVO로 최적화되지 않는다. *-
  7.     if (5 == num)
  8.     {
  9.         rvo.value *= 2;
  10.         return rvo;
  11.     }
  12.  
  13.     return rvo;
  14. }

이 점만 유의하면, 중간에 어떠한 코드들이 들어가도 임시 객체는 생성되지 않는다.


3. RVO / NRVO 종합 예제

  1. #include "stdafx.h"
  2. #include <iostream>
  3.  
  4. class RVOTest
  5. {
  6. public:
  7.     RVOTest() { std::cout << "Constructor\n"; }
  8.     RVOTest(const RVOTest& rhs) { std::cout << "Copy Constructor\n"; }
  9.     RVOTest(RVOTest&& rhs) { std::cout << "Move Constructor\n"; }
  10.  
  11.     ~RVOTest() { std::cout << "Destructor\n"; }
  12. };
  13.  
  14. RVOTest RVOFunc()
  15. {
  16.     return RVOTest();
  17. }
  18.  
  19. RVOTest NRVOFunc()
  20. {
  21.     RVOTest r;
  22.     return r;
  23. }
  24.  
  25. int _tmain(int argc, _TCHAR* argv[])
  26. {
  27.     /*
  28.         Constructor
  29.         Destructor
  30.     */
  31.     {
  32.         RVOTest r1 = RVOFunc();                
  33.     }
  34.  
  35.     std::cout << "=============================\n";
  36.  
  37.    //이건 최적화 모드에 따라 달라짐 Debug , Release 모드 기본 최적화 옵션이 다른데 각각 모드를 변경해보면 차이를 알 수 있음
  38.     /*
  39.         Constructor
  40.         Destructor
  41.     */
  42.     {
  43.         RVOTest r2 = NRVOFunc();
  44.     }
  45.  
  46.     return 0;
  47. }




ref : http://egloos.zum.com/himskim/v/3630181

ref : http://egloos.zum.com/sweeper/v/1942099





반응형

+ Recent posts