2010년 4월 26일 월요일

RVO와 NRVO

컴파일러가 컴파일 시간에 수행하는 최적화는 참으로 다양하다. 의미 없는 코드를 삭제하는 Dead code elimination 같은 기본적인 테크닉부터 함수 내부 코드만이 아니라 함수 사이의 관계까지 최적화를 하는 Interprocedural optimization까지 많은 종류의 최적화가 수행된다. 특히나 템플릿을 적극 활용하는 스타일의 C++ 코딩에서는 많은 변인들이 컴파일 시간에 정적으로 결정되기 때문에 이러한 최적화의 혜택을 많이 받는다. [footnote]예를 들어, VS의 STL vector 구현을 보면 값 하나 참조하는 데에만 함수가 몇 번씩 호출되어 콜스택이 몇 중으로 쌓이는데 최적화된 목적 코드에서는 이 코드들이 전부 인라인되고 상수 브랜칭들도 다 삭제되어 해당 객체의 포인터를 곧바로 가리키는 코드로 바뀐다.[/footnote] 이로 인해 C++의 템플릿을 강력한 유연성과 높은 효율 두 마리 토끼를 함께 잡을 수 있는 것이다.

 

이러한 최적화로 인해 얻어지는 수행 효율의 증대는 크누스 교수님으로 하여금 어설픈 최적화는 모든 악의 근원이다라는 말까지 남기도록 하였다. 그럼에도 불구하고 쪼잔한 우리 프로그래머들은 컴파일러를 믿지 못하고 문장 단위의 최적화를 시도하는 경우가 많은데, 이 중 가장 대표적인 사례를 들라면 함수의 반환값을 복사 대신 참조로 넘기기 위한 다양한 노력이 있다. 스캇 마이어의 Effective C++에서도 함수의 반환값을 참조로 넘기지 말라고 강조를 하지만 복사 생성으로 인해 성능에서 피를 본 적이 있는 사람이라면 이러한 유혹에 빠질 수 밖에 없는 것이다.

 

이를테면 스코프에 대한 개념이 잘 잡혀 있지 않은 초보 프로그래머들이 '참조로 넘기는게 효율이 좋데'라는 말을 듣고 최적화를 하려고 한다. 그런데 그렇게 큰 객체가 아니라 힙에다가 매 번 할당하는 것은 비효율적이고 또 수동 할당 해제 등의 번거로움도 싫다. 그래서 힙에 할당을 하지 않고 그냥 스택에 있는 객체를 이용하려 한다 치면 아래와 같은 실수를 저지를 수 있다.

 

[code cpp]SomeClass& someFunction()<br>{<br>    SomeClass ret;<br>    // Do something...<br><br>    return ret;<br>} [/code]

 

당연히 안 될 말이다. SomeClass의 인스턴스 ret는 해당 함수가 끝나는 순간 그 유효 기간이 다 한다. 자동으로 소멸자가 호출되어 관련 정보를 깨끗하게 정리해버리는 것이다. 이 상황에서 객체 자체가 아닌, 객체의 참조를 반환한다면 대재앙이 일어날 수 밖에 없다. 그렇다면 이건 어떨까?

 

[code cpp]class SomeClass<br>{<br>    // ...<br><br>    SomeObject buffer_;<br>};<br>[/code]

[code cpp]SomeObject& SomeClass::getSomeObject()<br>{<br>    // do Something on buffer_<br>    return buffer_;<br>}<br>[/code]

 

이는 큰 문제 없이 돌아가겠지만, 객체 안의 객체를 반환할 경우에나 사용할 수 있다는, 다시 말해 원본 객체 상태의 일부에 한정되는 객체만 반환 가능하다는 한계점을 가지고 있다. SomeClass에 종속적이지 않은 객체를 만들고 싶은 경우라면 사용할 수 없는 코드인 것이다.

 

그렇게나 효율을 강조하는 언어인 C++에서 이런 문제 하나 제대로 해결을 못하고 결국 무작정 복사를 해야 한다니 무언가 이상하다. 컴파일러 개발자나 C++ 표준 위원회 등이 이러한 내용에 대해 고민을 안 해봤을리가 없다. 그리고 이 문제에 대해 Return value optimization(RVO)이라는 해법을 내놓는다. RVO란 함수가 특정 값을 반환할 때 객체를 생성하고 복사, 소멸시키는 삼중 오버헤드를 단순히 객체 생성 한 번으로 끝내도록 만드는 최적화이다. 우선 아래의 코드를 보자.

 

[code cpp]SomeClass someFunction()<br>{<br>    // Do something...<br><br>    return SomeClass(someArgument);<br>} [/code]

 

이렇게 반환문에 객체 생성문을 명시했다는 것은 실제 반환되는 위치에 곧바로 해당 객체를 생성하여도 의미적으로 볼 때 차이가 없을 것이라는 의미이다. 그렇다면 위의 코드는 아래와 같은 코드로 바뀔 수 있다. (정확하게 아래와 같지는 않다. 단지 이렇게 될 것이라는 것을 말할 뿐이다.)

 

[code cpp]void someFunction(SomeClass& returnTo)<br>{<br>    // Do something...<br><br>    returnTo.SomeClass::SomeClass(someArgument); // Construct SomeClass on returnTo<br>} [/code]

이렇게 반환값 최적화가 일어나면 반환값을 굳이 참조로 넘기지 않아도 효율적인 코드가 생성된다. 헌데 위와 같은 최적화만 가지고는 아무래도 반환하는 객체에 대한 표현력이 생성자에 의해 곧바로 만들어 질 수 있는 익명 객체 수준으로만 한정되기 때문에 아쉬운 점이 있다. 이를 보완하는 최적화 테크닉도 있는데 이는 Named return value optimization(NRVO)으로, 이름을 가진 반환값까지 이러한 최적화의 대상으로 만든다.

 

[code cpp]Object generate()<br>{<br>    Object someObject;<br>    someObject.doSomething();<br>    return someObject;<br>}<br>[/code]

 

이런 코드는 앞서 언급한 RVO만 가지고는 반환값 최적화의 혜택을 받을 수 없는데, NRVO는 이마저 최적화시켜버린다. someObject가 있다고 치면 굳이 someObject를 생성할 필요 없이 반환값을 저장할 변수를 가져와서 그 곳에다가 someObject 관련 작업을 해버리는 것이 포인트이다.

 

[code cpp]void generate(Object& returnTo)<br>{<br>    returnTo.Object::Object();<br>    returnTo.doSomething();<br>}<br>[/code]

 

물론 실제 코드가 이렇게 간단하게 최적화될 수 있다면 고대 컴파일러인 VC 6.0에서부터 구현이 되었을 것이다. 하지만 실제로 사용하는 코드들은 이보다 복잡한 경로를 거쳐 값들을 반환하기 때문에 훨씬 복잡한 알고리즘을 동원하여 최적화를 하는 것으로 보인다. MSDN에 따르면 NRVO는 2005부터 지원을 하고 있다. [footnote]해당 사이트에 NRVO가 적용 안 되는 경우 등이 자세히 설명되고 있으므로 한번 읽어 볼 만 하다.[/footnote]

 

다만 이 최적화의 문제라면 생성자, 소멸자의 호출 횟수가 달라지면서 같은 코드라도 내는 결과가 최적화 여부에 따라 달라질 수 있다는 점인데, 웹에서 본 바에 따르면 이러한 최적화는 C++ 표준에서 이미 허용 가능 한 범위로 정의된 것으로 보인다. 성능이 좋은 컴파일러를 이용하는 경우라면 적어도 값 반환에 따른 복사의 오버헤드를 걱정할 필요는 없다는 이야기이다. 즉, 대부분의 경우 값을 반환하는 루틴을 최적화하는 것은 어설픈 최적화의 범주에 속한다는 의미.

 

기계의 관점에서 쪼잔할 정도로 코드를 최적화하는 것[footnote]이를테면 배열 대신 포인터를 쓴다거나[/footnote]이 과거에는 의미가 있었을지 모르겠으나, 요즘 컴파일러들의 최적화 실력을 보면 어설픈 수준의 최적화는 오히려 성능을 낮추고 코드의 유지 보수를 힘들게 만들 가능성이 높다. 이러한 수준의 최적화는 컴파일러에게 맡기고, 사람은 프로그램의 흐름이나 논리를 면밀하게 검토하여 불필요한 연산을 제거하거나 보다 더 효율적인 알고리즘으로 교체하는 등 기계가 할 수 없는 보다 더 높은 수준에서의 최적화를 해야 할 것이다.

댓글 없음:

댓글 쓰기