C ++ 클래스에서 가상 메서드를 사용할 때의 성능 비용은 얼마입니까?
C ++ 클래스 (또는 상위 클래스 중 하나)에 가상 메서드가 하나 이상 있다는 것은 클래스에 가상 테이블이 있고 모든 인스턴스에 가상 포인터가 있음을 의미합니다.
따라서 메모리 비용은 매우 명확합니다. 가장 중요한 것은 인스턴스의 메모리 비용입니다 (특히 인스턴스가 작은 경우, 예를 들어 정수를 포함하려는 경우 :이 경우 모든 인스턴스에 가상 포인터가 있으면 인스턴스 크기가 두 배가 될 수 있습니다.). 가상 테이블이 사용하는 메모리 공간은 실제 메소드 코드가 사용하는 공간에 비해 일반적으로 무시할 수 있다고 생각합니다.
이것은 저에게 질문을 던집니다. 방법을 가상으로 만드는 데 측정 가능한 성능 비용 (즉, 속도 영향)이 있습니까? 런타임시 모든 메서드 호출시 가상 테이블에서 조회가 발생하므로이 메서드에 대한 호출이 매우 자주 발생하고이 메서드가 매우 짧다면 측정 가능한 성능 저하가있을 수 있습니까? 플랫폼에 따라 다르지만 벤치 마크를 실행하는 사람이 있습니까?
내가 묻는 이유는 프로그래머가 가상 메소드를 정의하는 것을 잊었 기 때문에 발생한 버그를 발견했기 때문입니다. 이런 종류의 실수를 본 것은 이번이 처음이 아닙니다. 그리고 저는 생각했습니다. 왜 가상 키워드가 필요 하지 않다고 확신 할 때 가상 키워드 를 제거 하는 대신 필요할 때 가상 키워드를 추가 해야합니까? 성능 비용이 낮 으면 팀에서 다음을 권장합니다. 모든 클래스에서 소멸자를 포함한 모든 메서드를 기본적으로 가상으로 만들고 필요할 때만 제거합니다. 그게 당신에게 미친 소리입니까?
나는 약간의 타이밍을 실행 3GHz의에서 주문 PowerPC 프로세서에. 해당 아키텍처에서 가상 함수 호출 비용은 직접 (비가 상) 함수 호출보다 7 나노초 더 깁니다.
따라서 함수가 인라인 이외의 다른 것이 낭비되는 사소한 Get () / Set () 접근 자와 같은 것이 아니라면 비용에 대해 걱정할 가치가 없습니다. 0.5ns로 인라인되는 함수에 대한 7ns 오버 헤드는 심각합니다. 실행하는 데 500ms가 걸리는 함수의 7ns 오버 헤드는 의미가 없습니다.
가상 함수의 큰 비용은 실제로 vtable에서 함수 포인터를 조회하는 것이 아니라 (일반적으로 단일주기 일뿐) 간접 점프는 일반적으로 분기 예측이 불가능합니다. 간접 점프 (함수 포인터를 통한 호출)가 종료되고 새 명령 포인터가 계산 될 때까지 프로세서가 명령을 가져올 수 없기 때문에 이로 인해 큰 파이프 라인 버블이 발생할 수 있습니다. 따라서 가상 함수 호출의 비용은 어셈블리를 살펴 보는 것보다 훨씬 큽니다. 그러나 여전히 7 나노초에 불과합니다.
편집 : Andrew, Not Sure 등은 가상 함수 호출이 명령어 캐시 미스를 유발할 수 있다는 매우 좋은 점을 제기합니다. 캐시에없는 코드 주소로 점프하면 전체 프로그램이 중단되는 동안 명령은 주 메모리에서 가져옵니다. 이것은 항상 중요한 지연입니다. Xenon에서는 약 650 사이클 (내 테스트에 의해)입니다.
그러나 이것은 가상 함수에 특정한 문제가 아닙니다. 캐시에없는 명령어로 건너 뛰면 직접 함수 호출로도 누락이 발생하기 때문입니다. 중요한 것은 함수가 최근 이전에 실행되었는지 여부 (캐시에있을 가능성이 더 높음)와 아키텍처가 정적 (가상이 아닌) 분기를 예측하고 해당 명령을 미리 캐시로 가져올 수 있는지 여부입니다. 내 PPC는 그렇지 않지만 인텔의 최신 하드웨어는 그렇지 않을 수 있습니다.
내 타이밍은 실행에 대한 icache 미스의 영향을 제어하므로 (고의적으로 CPU 파이프 라인을 개별적으로 검사하려고했기 때문에) 비용을 할인합니다.
가상 함수를 호출 할 때 확실히 측정 가능한 오버 헤드가 있습니다. 호출시 vtable을 사용하여 해당 유형의 객체에 대한 함수 주소를 확인해야합니다. 추가 지침은 걱정할 필요가 없습니다. vtables는 많은 잠재적 컴파일러 최적화를 방지 할뿐만 아니라 (유형이 컴파일러의 다형성이므로) I-Cache를 스 래시 할 수도 있습니다.
물론 이러한 불이익이 중요한지 여부는 애플리케이션, 해당 코드 경로가 실행되는 빈도 및 상속 패턴에 따라 다릅니다.
제 생각에는 기본적으로 모든 것을 가상으로 사용하는 것이 다른 방법으로 해결할 수있는 문제에 대한 포괄적 인 해결책입니다.
아마도 클래스가 어떻게 설계 / 문서화 / 작성되는지 살펴볼 수있을 것입니다. 일반적으로 클래스의 헤더는 파생 클래스에서 재정의 할 수있는 함수와 호출 방법을 명확히해야합니다. 프로그래머가이 문서를 작성하게하면 가상 문서로 올바르게 표시되도록하는 데 도움이됩니다.
또한 모든 기능을 가상으로 선언하면 가상으로 표시하는 것을 잊는 것보다 더 많은 버그가 발생할 수 있다고 말하고 싶습니다. 모든 기능이 가상이면 모든 것이 기본 클래스 (공개, 보호, 개인)로 대체 될 수 있습니다. 모든 것이 공정한 게임이됩니다. 우연히 또는 의도적으로 하위 클래스는 기본 구현에서 사용할 때 문제를 일으키는 함수의 동작을 변경할 수 있습니다.
때에 따라 다르지. :) (다른 것을 기대 했습니까?)
일단 클래스가 가상 함수를 받으면 더 이상 POD 데이터 유형이 될 수 없으며 (이전에도 하나가 아니었을 수 있으며,이 경우 차이가 없습니다) 전체 범위의 최적화를 불가능하게 만듭니다.
일반 POD 유형의 std :: copy ()는 간단한 memcpy 루틴에 의존 할 수 있지만 비 POD 유형은 더 신중하게 처리해야합니다.
vtable을 초기화해야하므로 구성이 훨씬 느려집니다. 최악의 경우 POD 데이터 유형과 비 POD 데이터 유형 간의 성능 차이가 클 수 있습니다.
최악의 경우 실행 속도가 5 배 더 느릴 수 있습니다 (최근에 몇 가지 표준 라이브러리 클래스를 다시 구현하기 위해 수행 한 대학 프로젝트에서 가져온 것입니다. 컨테이너가 저장 한 데이터 유형이 저장 되 자마자 생성하는 데 약 5 배 더 오래 걸렸습니다. vtable)
물론, 대부분의 경우 측정 가능한 성능 차이를 볼 가능성은 거의 없습니다 . 이는 일부 국경의 경우 비용이 많이들 수 있음 을 지적하기위한 것입니다 .
그러나 여기서 성능이 주요 고려 사항은 아닙니다. 모든 것을 가상으로 만드는 것은 다른 이유로 완벽한 솔루션이 아닙니다.
파생 클래스에서 모든 것을 재정의하도록 허용하면 클래스 불변성을 유지하기가 훨씬 더 어려워집니다. 클래스는 메서드 중 하나를 언제든지 재정의 할 수있을 때 일관된 상태를 유지하는 것을 어떻게 보장합니까?
모든 것을 가상으로 만들면 몇 가지 잠재적 버그를 제거 할 수 있지만 새로운 버그도 도입됩니다.
가상 디스패치 기능이 필요한 경우 가격을 지불해야합니다. C ++의 장점은 직접 구현할 수있는 비효율적 인 버전이 아닌 컴파일러에서 제공하는 가상 디스패치의 매우 효율적인 구현을 사용할 수 있다는 것입니다.
그러나 필요하지 않은 경우 오버 헤드로 자신을 벌목하는 것은 너무 멀리 갈 수 있습니다. 그리고 대부분의 클래스는 상속되도록 설계되지 않았습니다. 좋은 기본 클래스를 만들려면 함수를 가상으로 만드는 것 이상이 필요합니다.
가상 디스패치는 인라인 방지만큼 간접적 인 것이 아니라 일부 대안보다 훨씬 느립니다. 아래에서는 가상 디스패치를 개체에 "유형 (-식별) 번호"를 포함하고 스위치 문을 사용하여 유형별 코드를 선택하는 구현과 대조하여 설명합니다. 이렇게하면 함수 호출 오버 헤드가 완전히 방지됩니다. 로컬 점프 만 수행하면됩니다. 유형별 기능의 강제 지역화 (스위치에서)를 통해 유지 관리 가능성, 재 컴파일 종속성 등에 대한 잠재적 인 비용이 있습니다.
이행
#include <iostream>
#include <vector>
// virtual dispatch model...
struct Base
{
virtual int f() const { return 1; }
};
struct Derived : Base
{
virtual int f() const { return 2; }
};
// alternative: member variable encodes runtime type...
struct Type
{
Type(int type) : type_(type) { }
int type_;
};
struct A : Type
{
A() : Type(1) { }
int f() const { return 1; }
};
struct B : Type
{
B() : Type(2) { }
int f() const { return 2; }
};
struct Timer
{
Timer() { clock_gettime(CLOCK_MONOTONIC, &from); }
struct timespec from;
double elapsed() const
{
struct timespec to;
clock_gettime(CLOCK_MONOTONIC, &to);
return to.tv_sec - from.tv_sec + 1E-9 * (to.tv_nsec - from.tv_nsec);
}
};
int main(int argc)
{
for (int j = 0; j < 3; ++j)
{
typedef std::vector<Base*> V;
V v;
for (int i = 0; i < 1000; ++i)
v.push_back(i % 2 ? new Base : (Base*)new Derived);
int total = 0;
Timer tv;
for (int i = 0; i < 100000; ++i)
for (V::const_iterator i = v.begin(); i != v.end(); ++i)
total += (*i)->f();
double tve = tv.elapsed();
std::cout << "virtual dispatch: " << total << ' ' << tve << '\n';
// ----------------------------
typedef std::vector<Type*> W;
W w;
for (int i = 0; i < 1000; ++i)
w.push_back(i % 2 ? (Type*)new A : (Type*)new B);
total = 0;
Timer tw;
for (int i = 0; i < 100000; ++i)
for (W::const_iterator i = w.begin(); i != w.end(); ++i)
{
if ((*i)->type_ == 1)
total += ((A*)(*i))->f();
else
total += ((B*)(*i))->f();
}
double twe = tw.elapsed();
std::cout << "switched: " << total << ' ' << twe << '\n';
// ----------------------------
total = 0;
Timer tw2;
for (int i = 0; i < 100000; ++i)
for (W::const_iterator i = w.begin(); i != w.end(); ++i)
total += (*i)->type_;
double tw2e = tw2.elapsed();
std::cout << "overheads: " << total << ' ' << tw2e << '\n';
}
}
성능 결과
내 Linux 시스템에서 :
~/dev g++ -O2 -o vdt vdt.cc -lrt
~/dev ./vdt
virtual dispatch: 150000000 1.28025
switched: 150000000 0.344314
overhead: 150000000 0.229018
virtual dispatch: 150000000 1.285
switched: 150000000 0.345367
overhead: 150000000 0.231051
virtual dispatch: 150000000 1.28969
switched: 150000000 0.345876
overhead: 150000000 0.230726
이는 인라인 유형 번호 전환 방식이 약 (1.28-0.23) / (0.344-0.23) = 9.2 배 빠르다는 것을 의미합니다. 물론 이것은 테스트 된 정확한 시스템 / 컴파일러 플래그 및 버전 등에 따라 다르지만 일반적으로 표시됩니다.
가상 디스패치에 대한 의견
가상 함수 호출 오버 헤드는 거의 중요하지 않은 것이지만 자주 호출되는 사소한 함수 (게터 및 세터와 같은)에만 해당됩니다. 그럼에도 불구하고 한 번에 많은 것을 가져오고 설정하는 단일 기능을 제공하여 비용을 최소화 할 수 있습니다. 사람들은 가상 디스패치를 너무 많이 걱정하므로 어색한 대안을 찾기 전에 프로파일 링을 수행하십시오. 이들의 주요 문제는 라인 외부 함수 호출을 수행하지만 캐시 활용 패턴을 변경하는 실행 된 코드를 지역화 해제하는 것입니다 (더 좋든 (더 자주) 나쁘 든).
추가 비용은 대부분의 시나리오에서 사실상 아무것도 아닙니다. (말장난을 용서하십시오). ejac은 이미 합리적인 상대 측정을 게시했습니다.
The biggest thing you give up is possible optimizations due to inlining. They can be especially good if the function is called with constant parameters. This rarely makes a real difference, but in a few cases, this can be huge.
Regarding optimizations:
It is important to know and consider the relative cost of constructs of your language. Big O notation is onl half of the story - how does your application scale. The other half is the constant factor in front of it.
As a rule of thumb, I wouldn't go out of my way to avoid virtual functions, unless there are clear and specific indications that it is a bottle neck. A clean design always comes first - but it is only one stakeholder that should not unduly hurt others.
Contrived Example: An empty virtual destructor on an array of one million small elements may plow through at least 4MB of data, thrashing your cache. If that destructor can be inlined away, the data won't be touched.
When writing library code, such considerations are far from premature. You never know how many loops will be put around your function.
While everyone else is correct about the performance of virtual methods and such, I think the real problem is whether the team knows about the definition of the virtual keyword in C++.
Consider this code, what is the output?
#include <stdio.h>
class A
{
public:
void Foo()
{
printf("A::Foo()\n");
}
};
class B : public A
{
public:
void Foo()
{
printf("B::Foo()\n");
}
};
int main(int argc, char** argv)
{
A* a = new A();
a->Foo();
B* b = new B();
b->Foo();
A* a2 = new B();
a2->Foo();
return 0;
}
Nothing surprising here:
A::Foo()
B::Foo()
A::Foo()
As nothing is virtual. If the virtual keyword is added to the front of Foo in both A and B classes, we get this for the output:
A::Foo()
B::Foo()
B::Foo()
Pretty much what everyone expects.
Now, you mentioned that there are bugs because someone forgot to add a virtual keyword. So consider this code (where the virtual keyword is added to A, but not B class). What is the output then?
#include <stdio.h>
class A
{
public:
virtual void Foo()
{
printf("A::Foo()\n");
}
};
class B : public A
{
public:
void Foo()
{
printf("B::Foo()\n");
}
};
int main(int argc, char** argv)
{
A* a = new A();
a->Foo();
B* b = new B();
b->Foo();
A* a2 = new B();
a2->Foo();
return 0;
}
Answer: The same as if the virtual keyword is added to B? The reason is that the signature for B::Foo matches exactly as A::Foo() and because A's Foo is virtual, so is B's.
Now consider the case where B's Foo is virtual and A's is not. What is the output then? In this case, the output is
A::Foo()
B::Foo()
A::Foo()
The virtual keyword works downwards in the hierarchy, not upwards. It never makes the base class methods virtual. The first time a virtual method is encountered in the hierarchy is when the polymorphism begins. There isn't a way for later classes to make previous classes have virtual methods.
Don't forget that virtual methods mean that this class is giving future classes the ability to override/change some of its behaviors.
So if you have a rule to remove the virtual keyword, it may not have the intended effect.
The virtual keyword in C++ is a powerful concept. You should make sure each member of the team really knows this concept so that it can be used as designed.
Depending on your platform, the overhead of a virtual call can be very undesirable. By declaring every function virtual you're essentially calling them all through a function pointer. At the very least this is an extra dereference, but on some PPC platforms it will use microcoded or otherwise slow instructions to accomplish this.
I'd recommend against your suggestion for this reason, but if it helps you prevent bugs then it may be worth the trade off. I can't help but think that there must be some middle ground that is worth finding, though.
It will require just a couple of extra asm instruction to call virtual method.
But I don't think you worry that fun(int a, int b) has a couple of extra 'push' instructions compared to fun(). So don't worry about virtuals too, until you are in special situation and see that it really leads to problems.
P.S. If you have a virtual method, make sure you have a virtual destructor. This way you'll avoid possible problems
In response to 'xtofl' and 'Tom' comments. I did small tests with 3 functions:
- Virtual
- Normal
- Normal with 3 int parameters
My test was a simple iteration:
for(int it = 0; it < 100000000; it ++) {
test.Method();
}
And here the results:
- 3,913 sec
- 3,873 sec
- 3,970 sec
It was compiled by VC++ in debug mode. I did only 5 tests per method and computed the mean value (so results may be pretty inaccurate)... Any way, the values are almost equal assuming 100 million calls. And the method with 3 extra push/pop was slower.
The main point is that if you don't like the analogy with the push/pop, think of extra if/else in your code? Do you think about CPU pipeline when you add extra if/else ;-) Also, you never know on what CPU the code will be running... Usual compiler can generates code more optimal for one CPU and less optimal for an other (Intel C++ Compiler)
'development' 카테고리의 다른 글
Java에는 C #의 ref 및 out 키워드와 같은 것이 있습니까? (0) | 2020.08.16 |
---|---|
ReactJS 서버 측 렌더링 대 클라이언트 측 렌더링 (0) | 2020.08.16 |
Faye 대 Socket.IO (및 Juggernaut) (0) | 2020.08.16 |
C #에 CSV 리더 / 라이터 라이브러리가 있습니까? (0) | 2020.08.16 |
Python 2.6에서 unicode_literals를 사용하는 문제가 있습니까? (0) | 2020.08.16 |