갑자기 kldp.org에서 많은 레퍼러가 잡혀서 뭔 일인가 싶어서 들어가봤다. 어떤 분이 정말 C가 C++보다 빠른지에 대한 의문을 제기했다. 이 기회에 잘못된 미신을 타파하고 C++ 가상함수에 대해 좀 더 정확하게 알아보자.
다 좋은데 밑줄 친 문장이 자신의 의견이나 느낌이면 문제 없다. 그런데 저렇게 단정적인 표현을 쓰려면 객관적인 자료가 필요하다 가상 함수 호출에 드는 비용이 정말 미약하다는 데이터를 달라는 것이다!
일단, 글 쓰신 분은 두 가지 문제점을 제기했는데 내가 볼 땐 결국 하나다. 1번에서 제기한 "클레스 설계에 따른 잦은 함수 호출에 드는 비용"은 다소 모호하다. 클래스 설계로 인해 과도한 가상 함수 사용이라면 성능에 문제가 될 수 있지만, 일반 클래스 함수들을 호출하는데 부가적인 비용은 거의 (this 넣는 것 빼곤) 없다. 일반 클래스 멤버 함수는 컴파일할 때 함수의 주소가 바로 나오기 때문에 일반 함수 호출과 같다. 그리고 함수 호출이 많다고 해도 요즘 같이 똑똑한 컴파일러는 inline 키워드 안써도 알아서 잘 인라이닝 해준다. 게다가 IPO를 쓰면 다른 오브젝 파일에 있는 함수도 인라인 해준다. 그러니까 결국 "가상 함수 사용에 드는 비용"에 관한 의문 제기라고 할 수 있겠다. 정말 C++ 아니 C#, Java에 있는 가상 함수 호출에 드는 비용이 정말로 미약할까?
일단 가상함수의 실체부터 알아보자. Object는 base class, TurnOn은 가상 함수. 이걸 컴파일 하였을 때 가상 함수 호출 부분이 어떻게 번역되는지 보자. 잡다한 코드를 줄이기 위해 /RTCs와 같은 보안 관련 코드는 껐다.
Object* p = new Computer;
p->NoVirtual();
00BA15D9 mov ecx,dword ptr [p]
00BA15DC call Object::NoVirtual (0BA1028h)
p->TurnOn(10);
00BA15E1 push 0Ah ; 함수 인자 10
00BA15E3 mov eax,dword ptr [p] ; eax = p
00BA15E6 mov edx,dword ptr [eax] ; edx = *p
00BA15E8 mov ecx,dword ptr [p] ; this = p
00BA15EB mov eax,dword ptr [edx+8] ; eax = *(*p + 8)
00BA15EE call eax
Non-static인 (가상함수와 비가상함수 모두) 클래스 멤버 함수는 thiscall이라는 calling convention을 따른다. 즉, 우리가 멤버 함수에서 this를 묵시적으로 쓸 수 있도록 하기 위해 별도의 공간에 this에 해당하는 포인터를 넘겨주는 것을 약속한 것이다. VC++ 컴파일러는 this 포인터를 레지스터 ecx를 통해 넘어간다. 그래서 함수 call 명령어 앞에 ecx가 p로 초기화됨을 볼 수 있다. 첫 번째 NoVirtual 함수 호출은 보다시피 call 할 주소가 바로 계산이 되어있다. 반면, TurnOn이라는 가상 함수는 이 주소를 직접 계산 한다. 00BA15EB에 있는 문장은 C++ 클래스의 virtual table에 접근하는 부분이다. 이건 당연히 클래스 선언 레이아웃에 따라 달라질 것이다. vtable은 디버거로 쉽게 살펴볼 수 있다. vtable 3번 째 항목에 TurnOn이 있어서 +8 오프셋이 주어졌다.
대충 가상 함수가 어떻게 생겨먹었고 어떻게 불리는지 살펴봤다. 가상 함수를 한 줄로 요약하면
호출할 함수의 대상이 컴파일 시간에 정해지지 않고 실행 시간에 계산을 통해 결정 되는 간접 분기문
으로 요약할 수 있다. 따라서 OOP 언어의 가상 함수 뿐만 아니라 C 언어에서도 함수 포인터를 통한 호출도 이와 동일한 맥락이다. C에서도 함수 포인터를 모아놓은 자료구조로 얼마든지 OOP틱하게 코딩 할 수 있다. 따라서 이 포스팅이 답하고자 하는 질문은 프로그래밍 언어를 막론하고 간접 분기문에 대한 비용은 얼마나 큰가? 로 정리할 수 있다.
사실 왜 이 가상 호출과 같은 간접 분기문에 많은 비용이 드는지는 CPU 마이크로아키텍처를 이해하지 못하면 제대로 알기 힘들다. 그런데 여기서 이 모든 내용을 쓸 수 없고 정말로 이 간접 분기문이 얼마나 오버헤드를 가지는지 몇 가지 데이터를 가지고 피부로 느껴보자.
가상 함수를 보다 프로그래밍 랭귀지틱 하게 얘기하면 Dynamic dispatch라고 말 할 수 있는데, 여기에 대한 성능을 주로 측정하는 Richard라는 벤치 마크가 있다. 원래는 BCPL 언어로 만들어진 작은 규모의 벤치 마크 프로그램인데 객체 지향 정도를 여러 단계로 해서 Java, C++로 포팅이 되어 여러 성능을 가늠해볼 수 있다. 결과는 여기에서 가져왔다.
먼저, 이 그래프는 자바로 7가지 다양한 객체 지향 정도로 작성된 Richard의 성능 그래프이다. Richard는 여러 다른 작업을 번갈아가며 해주는 그런 프로그램이다. 몇 가지 흥미있는 경우만 간략히 살펴보자.
1) 원래 BCPL로 만들어진 코드를 충실히 자바로 옮긴 경우다. 객체 지향적이지 않으며 switch 문으로 여러 상태를 구분하고 있다. 보다시피 가장 빠르다. 약 600 ms.
4) 이제 프로그램을 객체 지향 스타일로 만들었다. 참고로 모든 자바의 함수는 기본으로 virtual이다. 그래서 일단 자바 클래스의 accessor 없이 바로 접근이 되도록 만들었다. 성능이 다소 나빠져서 800ms 정도.
5) 이제 일반적으로 짤 수 있는 자바 형식으로 만들었다. 자바 메소드를 그대로 즉, 모든 함수들을 virtual로 만들면 보다시피 성능이 3배 이상 느려졌다.
6) 버전 5에서 가상 함수로 불릴 필요가 없는 녀석들을 final 처리하였다. 성능이 급격히 좋아졌다.
7) 가상 함수와 interface를 써서 만들었다. 역시 오버헤드가 엄청나다.
대충 이 정도만 보더라도 가상 함수 호출에 들어가는 비용이 생각보다 매우 클 수도 있다는 사실을 알았을 것이다. 그러면 C++로 만들어진 Richard의 성능도 살펴보자.
일단 보다시피 시간 스케일이 확 다르다. 특별하게 매우 최적화되지 않은 자바 프로그램이 C++ 보다 매우 느리다는 사실을 한 눈에 알 수 있다. 이 그래프에서도 5) 버전 즉, 모든 함수를 버추얼로 만든 경우 최적의 경우보다 무려 5배 이상 느려짐을 볼 수 있다. 자 이제 가상 함수 호출 비용이 정말 미약하다고 할 수 있을까?
<!--[if !supportEmptyParas]--> <!--[endif]-->
(익명님의 제보로 추가합니다) 4에서 5로의 성능이 급격히 저하된 것은 obj.field와 virtual obj.getField()의 차이에서 기인한 것입니다. 따라서 성능 하락이 모두 가상함수 호출에 기인한 것은 아닙니다. 가상함수 호출과 직접함수 호출만의 비용차이는 이 보다는 덜할 것입니다. 그러나 가상함수는 함수 인라이닝을 불가능하게 하기 때문에, 단순히 가상함수 호출에 대한 비용 뿐만 아니라 최적화 기회를 잃어서 오는 성능하락도 큽니다. 이 벤치마크는 이런 맥락에서 이해해주시면 되겠습니다.
이런 가상 함수 호출의 비용을 줄이기 위해 사람들이 가만히 있지 않다. 대표적인 자바 최적화 기술 중 하나인 devirtualization은 과도한 가상 함수를 직접적인 함수 호출로 대체한다. HotJava 같은 경우 dynamic profiling으로 가상 함수의 부담을 상당히 줄일 수 있다.
이 그래프는 JVM이 이제 가상 함수를 직접 특정 타입에 대해 인라이닝 해서 성능을 높였을 때를 보여주고 있다. 보다시피 5번의 경우에도 성능이 상당히 우수함을 알 수 있다. (그래도 C++ 보다는 느리다)
이런 가상 함수 호출 비용을 줄이기 위한 노력은 Profile-Guided Optimization에서도 볼 수 있다. 컴파일러 최적화를 하는데 실제 프로파일링 데이터를 기초로 좀 더 똑똑하게 최적화 하는 것이다. 참고 PPT 파일을 보면 Virtual Call Speculation이라는 것이 있다. Base* 로 포인트된 클래스 포인터에서 call이라는 가상 함수를 부르는 것을 아래 그림과 같이 최적화 할 수 있다. 예를 들어, 프로파일 결과 중 Foo 타입이 대부분이었다고 하자. 그러면 이 부분의 경우를 가정하고 바로 이 함수의 구현을 호출한다. 여기서 인라인을 통해 성능을 더욱 높일 수 있다. 만약 이 가정이 틀리면 그냥 가상 호출을 그대로 한다.
void Func(Base *A){
while(true){
if(type(A) == Foo)
// inline of A->call();
else
A->call();
}
}
이런 것과 같이 가상 함수로 대표되는 간접 분기문의 성능을 높이기 위한 컴파일러 최적화 노력은 무수히 찾아볼 수 있다. 이것이 별로 느리지 않다면 왜 이렇게 많은 사람들이 이렇게 고생을 해가며 노력할까?
CPU 입장에서 분기문은 원래 처리하기 골치아프다. 그래서 분기문의 결과를 예측해서 성능을 높이는데, 일반적인 직접 분기문은 다음에 어디에 있는 명령이 실행될지를 쉽게 예측할 수 있다. 분기를 하느냐 마느냐, 이 Yes/No 질문에만 대답하면 어디로 갈지는 바로 알아낼 수 있다. 반면, 간접 분기문은 어디로 갈지가 Yes/No로 답이 나오지를 않는다. N개의 가능한 점프 대상이 있다면 N개의 답 중 하나를 골라줘야 한다. 그래서 처리하기 쉽지 않다.
저 kldp 스레드 중에 어떤 분이 Branch Target Buffer, BTB를 언급하셨다. 그런데 BTB로도 간접 분기문 처리는 쉽지 않다. BTB는 원초적으로 "가장 마지막으로 분기한 위치"를 기억해서 알려주는 구조다. 분기 예측기가 가장 마지막에 있었던 분기 결과를 기초로 알려주는 것과 같은 맥락이다. 그런데 가상 함수의 경우 호출 대상, 즉 분기 목적지가 수시로 바뀌기 때문에 BTB 구조 기반의 예측은 그리 정확하지 않다. 적중률이 50% 밖에 되지 않는다 (참고 논문은 귀찮아서 생략).
그래서 최초로 Pentium M에 쓰인 Core 마이크로아키텍처부터 이런 간접 분기문을 특별히 처리하기 위한 별도의 장치가 추가되었다. 그러나 여전히 이 방법도 완벽하지 못하다. 참고 논문에 따르면 이런 간접 분기문을 위한 하드웨어 장치가 있는 CPU에서도 실제 엑셀, IE, 파이어폭스와 같은 C++ 프로그램을 돌려보면 분기 예측 실패 중, 평균 28%가 이런 간접 분기문에서 기인함을 밝히고 있다. 천 개의 명령어 당 평균 약 6 번의 분기 예측 실패가 있고 이 중에 1.8개 정도가 간접 분기문 - 가상 함수, 함수 포인터 - 에서 기인한다. 여기에 관해 논문 쓰고 박사 받아 교수 하고 있는 사람도 있는 것을 보면 (... 내 지도 교수) 이 문제는 풀기 여전히 어려운 문제이다.
이래도 가상 함수 비용이 거의 무시할만한 수준인가!
3줄 요약:
가상 함수는 호출할 대상이 실행 시간에 결정되는 간접 분기문의 일종이다.
이런 간접 분기문은 현대 CPU에서도 여전히 처리하기 힘들다.
따라서 가상 함수 호출의 비용은 전혀 미약한 것이 아니다.
<!--[if !supportEmptyParas]--> <!--[endif]-->
3줄 요약도 귀찮다. 한 줄로 요약해달라.
네, C언어는 C++ 보다 빠릅니다.
<!--[if !supportEmptyParas]--> <!--[endif]-->
그러나 여기에 반전이 숨어있다. 이렇게 C++ 가상 함수, 자바 메소드의 호출 비용이 크다고 이들의 사용을 자제하라는 소리가 아니다. 분명, 가상 함수 호출에는 비용이 들지만 만약 가상 함수의 적절한 활용으로 프로그램 구조의 가독성이 높아지고 유지 보수가 용이하다면 당연히 써야 한다. 왕년에 했던 일이 C# Windows Form과 같은 컴포넌트 라이브러리를 만드는 일이었다. 당연히 가상 함수를 이용한 추상화가 핵심이었다.
그러나 이런 가상 함수가 어떠한 비용을 가지고 있다는 것은 정확하게 이해하는 것이 중요하다. MFC가 단순히 vtable 크기를 줄이기 위해 메세지 맵 함수를 비가상함수로 처리한 것만은 아니다. 이렇게 간접 분기문은 CPU에서 처리하기 힘들어 성능 저하에 주요 요소가 될 수 있기 때문에 그렇게 각종 매크로로 메세지 맵을 처리한 것이다. 세상에 공짜는 없는 법이다. printf와 cout을 비교한 글에서도 비용과 편의성의 tradeoff를 잘 살펴보았다. 버추얼 함수도 이런 것이다.
예전에 마이크로소프트에가서 모 팀이랑 이야기를 하는데 서버를 C도 아니고 C++도 아니고 무려 C#으로 만드는 것이었다! 난 너무나 이 무식함에 놀라 도대체 어떻게 서버를 만드는데 C#을 쓸 수 있습니까! 라고 반문했다. 그랬더니 팀장님 말씀: "5년 전에는 C++로 어떻게 서버를 만들어? 라는 이야기가 있었죠".
최종 결론:
가상 함수의 비용을 잘 이해하고 적절히 이용하자.
<!--[if !supportEmptyParas]--> <!--[endif]-->
'컴퓨터 공학 > C++' 카테고리의 다른 글
[C언어]파일 입출력 - 사용자 추가, 수정, 검색, 출력하기 (1) | 2015.12.07 |
---|---|
[공유] 씹어먹는 C 언어 - <24. 더 빠르게 실행되는 코드를 위하여 (C 코드 최적화)> (0) | 2015.11.27 |
C++ 파일 입출력 (0) | 2015.11.27 |
C++, cout의 조작자 (0) | 2015.11.27 |
[공유] Day09 - C언어 보수(1의보수, 2의보수) (0) | 2015.11.27 |