C ++에서 가상 함수가 왜 그리고 어떻게 느려 집니까?


38

누구나 가상 테이블이 정확히 작동하는 방식과 가상 함수가 호출 될 때 어떤 포인터가 연관되는지 자세히 설명 할 수 있습니다.

실제로 느리다면 가상 함수를 실행하는 데 걸리는 시간이 일반 클래스 메서드보다 큼을 보여줄 수 있습니까? 일부 코드를 보지 않고 어떻게 / 무슨 일이 일어나고 있는지 쉽게 잃을 수 있습니다.


5
vtable 에서 올바른 메소드 호출을 찾으 려면 메소드를 직접 호출하는 것보다 더 많은 시간이 걸릴 것입니다. 자신의 프로그램의 맥락에서 시간이 얼마나 오래 걸리는지 또는 추가 시간이 중요한지 여부는 또 다른 질문입니다. en.wikipedia.org/wiki/Virtual_method_table
Robert Harvey

10
정확히 무엇보다 느리게? 일부 프로그래머가 가상 함수가 느리다는 것을 들었 기 때문에 많은 switch 문으로 동적 동작이 깨지고 느리게 구현 된 코드를 보았습니다.
Christopher Creutzig 2016 년

7
종종 가상 호출 자체가 느리지는 않지만 컴파일러가 인라인 할 수있는 능력이없는 경우가 종종 있습니다.
Kevin Hsu

4
@Kevin Hsu : 그렇습니다. 거의 모든 사람이 "가상 함수 호출 오버 헤드"를 제거하여 속도가 향상되었다고 말할 때마다, 실제로 모든 속도 향상이 어디에서 왔는지 살펴보면 컴파일러는 최적화 할 수 없기 때문에 가능한 최적화에서 비롯됩니다. 이전에 불확실한 전화.
timday

7
어셈블리 코드를 읽을 수있는 사람이라도 실제 CPU 실행시 오버 헤드를 정확하게 예측할 수 없습니다. 데스크톱 기반 CPU 제조업체는 지점 예측뿐만 아니라 가상 기능의 대기 시간을 가리는 주요 이유에 대한 가치 예측 및 추론 적 실행에 수십 년에 걸친 연구에 투자했습니다. 왜? 데스크탑 OS와 소프트웨어는 많이 사용하기 때문입니다. (모바일 CPU에 대해서는 똑같이 말하지
않겠습니다

답변:


55

가상 메소드는 일반적으로 함수 포인터가 저장되는 소위 가상 메소드 테이블 (단순 vtable)을 통해 구현됩니다. 이것은 실제 호출에 대한 간접 성을 추가합니다 (vtable에서 호출 할 함수의 주소를 가져 와서 바로 호출하는 대신 호출하십시오). 물론 시간과 코드가 더 필요합니다.

그러나 반드시 속도 저하의 주요 원인은 아닙니다. 실제 문제는 컴파일러가 (일반적으로 / 보통) 어떤 함수가 호출 되는지 알 수 없다는 것입니다. 따라서 인라인하거나 다른 최적화를 수행 할 수 없습니다. 이것만으로도 12 개의 무의미한 명령어 (레지스터 준비, 호출, 이후 상태 복원)가 추가 될 수 있으며, 관련이없는 것처럼 보이는 다른 최적화를 방해 할 수 있습니다. 또한 많은 다른 구현을 호출하여 미친 것처럼 분기하는 경우 다른 방법을 통해 미친 것처럼 분기하는 것과 같은 타격을 입습니다. 캐시 및 분기 예측기가 도움이되지 않으면 분기가 완전히 예측 가능한 것보다 오래 걸립니다 분기.

크지 만 : 이러한 성능 적중은 보통 너무 작아서 중요하지 않습니다. 고성능 코드를 생성하고 놀라운 빈도로 호출되는 가상 기능을 추가하는 것을 고려할 가치가 있습니다. 그러나 또한 분기의 다른 수단을 가상 함수 호출을 대체하는 (것을 명심 if .. else, switch, 함수 포인터 등) 근본적인 문제가 해결되지 않습니다 - 그것은 아주 잘 느려질 수 있습니다. 문제는 (있는 경우) 가상 기능이 아니라 (필요하지 않은) 간접적 인 것입니다.

편집 : 통화 지침의 차이점은 다른 답변에 설명되어 있습니다. 기본적으로 정적 ( "정상") 호출 코드는 다음과 같습니다.

  • 호출 된 함수가 해당 레지스터를 사용할 수 있도록 스택에 일부 레지스터를 복사하십시오.
  • 호출 된 함수가 호출 된 위치에 관계없이 찾을 수 있도록 인수를 사전 정의 된 위치에 복사하십시오.
  • 반송 주소를 누릅니다.
  • 컴파일 타임 주소 인 함수 코드로 분기 / 점프하므로 컴파일러 / 링커에 의해 바이너리로 하드 코딩됩니다.
  • 사전 정의 된 위치에서 반환 값을 가져오고 사용하려는 레지스터를 복원하십시오.

가상 호출은 함수 주소를 컴파일 타임에 알 수 없다는 점을 제외하고는 정확히 동일합니다. 대신 몇 가지 지침 ...

  • 객체에서 각 가상 함수마다 하나씩 함수 포인터 (함수 주소) 배열을 가리키는 vtable 포인터를 가져옵니다.
  • vtable에서 올바른 함수 주소를 레지스터로 가져옵니다 (올바른 함수 주소가 저장된 인덱스는 컴파일 타임에 결정됨).
  • 하드 코드 된 주소로 이동하지 않고 해당 레지스터의 주소로 이동하십시오.

브랜치 (branch) : 브랜치는 다음 명령을 실행시키는 대신 다른 명령으로 점프하는 것입니다. 여기에는 if, switch때때로 다양한 루프, 함수 호출 등과 그렇지 않은 컴파일러 구현 물건의 일부가 실제로 후드 아래 지점을 필요로하는 방식으로 지점에 보인다. 정렬되지 않은 배열보다 정렬 된 배열을 처리하는 이유는 무엇입니까?를 참조하십시오 . 왜 이것이 느려질 수 있는지, CPU가이 둔화를 막기 위해 무엇을하는지, 그리고 이것이 전부가 아닌 방법을 위해.


6
@ JörgWMittag 그들은 모두 통역사이며, C ++ 컴파일러가 생성 한 바이너리 코드보다 여전히 느리다
Sam

13
@ JörgWMittag 이러한 최적화는 주로 필요하지 않은 경우 간접 / 지연 바인딩을 거의 무료로 만들기 위해 존재합니다 . 이러한 언어에서는 모든 호출이 기술적으로 늦기 때문입니다 . 짧은 시간 동안 한곳에서 여러 가지 가상 메서드를 실제로 호출하는 경우 이러한 최적화가 도움이되지 않거나 적극적으로 아프지 않습니다 (많은 코드를 생성하십시오). C ++ 사용자는 매우 다른 상황에 있기 때문에 이러한 최적화에 관심이 없습니다.

10
@ JörgWMittag ... C ++ 녀석은 매우 다른 상황에 있기 때문에 최적화에 관심이 없습니다. (템플릿을 통해) 바인딩되므로 AOT 최적화로 수정할 수 있습니다. 마지막으로, 컴파일 타임에 추측하는 대신 이러한 최적화를 적응 적 으로 수행 하려면 런타임 코드 생성이 필요하며, 이는 수많은 두통을 유발 합니다. JIT 컴파일러는 다른 이유로 이러한 문제를 이미 해결했기 때문에 걱정하지 않아도되지만 AOT 컴파일러는이를 피하려고합니다.

3
좋은 대답, +1. 한 가지 주목할 점은 분기시 결과가 컴파일 타임에 알려진다는 것입니다. 예를 들어 다른 용도를 지원해야하는 프레임 워크 클래스를 작성하지만 일단 애플리케이션 코드가 해당 클래스와 상호 작용하면 특정 용도가 이미 알려져 있습니다. 이 경우 가상 함수의 대안은 C ++ 템플릿 일 수 있습니다. 좋은 예는 CRTP인데, vtable없이 가상 함수 동작을 에뮬레이트합니다. en.wikipedia.org/wiki/Curiously_recurring_template_pattern
DXM

3
@ 제임스 포인트가 있습니다. 내가 말하려고 한 것은 : 모든 간접 지시에 동일한 문제가 virtual있습니다.

23

다음은 각각 가상 함수 호출과 비가 상 호출에서 실제로 디스 어셈블 된 코드입니다.

mov    -0x8(%rbp),%rax
mov    (%rax),%rax
mov    (%rax),%rax
callq  *%rax

callq  0x4007aa

가상 통화에는 정확한 주소를 찾기 위해 세 개의 추가 명령어가 필요하지만 가상 통화가 아닌 주소는 컴파일 할 수 있습니다.

그러나 추가 조회 시간은 대부분 무시할 수있는 것으로 간주됩니다. 루프에서와 같이 조회 시간이 중요한 상황에서는 일반적으로 루프 전에 처음 세 명령을 수행하여 값을 캐시 할 수 있습니다.

조회 시간이 중요한 다른 상황은 객체 모음이 있고 각 객체에서 가상 함수를 호출하여 반복하는 경우입니다. 그러나이 경우 어쨌든 호출 할 함수를 선택하는 몇 가지 방법이 필요 하며 가상 테이블 조회는 다른 방법과 마찬가지로 좋은 방법입니다. 실제로 vtable 조회 코드가 널리 사용되기 때문에 최적화가 많이되어 있으므로 수동으로 해결하려고하면 성능이 저하 수 있습니다.


1
이해해야 할 것은 대부분의 경우 vtable 조회 및 간접 호출은 호출되는 메소드의 총 실행 시간에 무시할만한 영향을 미칩니다.
John R. Strohm

11
@ JohnR.Strohm 한 사람이 무시할 수있는 것은 다른 사람의 병목입니다
James

1
-0x8(%rbp). 오 마이 ... 그 AT & T 문법.
Abyx

" 세 가지 추가 지침 "아니, 단 2 : vptr에서로드하고 함수 포인터를로드
curiousguy

@ curiousguy 그것은 실제로 세 가지 추가 지침입니다. 가상 메소드가 항상 포인터 에서 호출 된다는 것을 잊었 으므로 먼저 포인터를 레지스터에로드해야합니다. 요약하면, 첫 번째 단계는 포인터 변수가 보유한 주소를 레지스터 % rax에로드 한 다음 레지스터의 주소에 따라이 주소에 vtpr을로드하여 % rax를 등록한 다음 register, 호출 할 메소드의 주소를 % rax에로드 한 다음, q * % rax!를 호출하십시오.
Gab 是 好人

18

무엇보다 느리게 ?

가상 함수는 직접 함수 호출로 해결할 수없는 문제를 해결합니다. 일반적으로 동일한 것을 계산하는 두 프로그램 만 비교할 수 있습니다. "이 광선 추적기는 해당 컴파일러보다 빠릅니다"는 의미가 없으며이 원칙은 개별 함수 나 프로그래밍 언어 구문과 같은 작은 것까지 일반화합니다.

가상 객체를 사용하여 객체 유형과 같은 데이텀을 기반으로 코드 조각으로 동적으로 전환하지 않으면 switch동일한 작업을 수행하기 위해 명령문 과 같은 다른 것을 사용해야합니다 . 다른 것에는 자체 오버 헤드가 있으며 유지 관리 및 글로벌 성능에 영향을 미치는 프로그램 구성에 영향을 미칩니다.

C ++에서 가상 함수 호출이 항상 동적 인 것은 아닙니다. 객체가 포인터 나 참조가 아니거나 그 유형이 정적으로 유추 될 수 있기 때문에 정확한 유형을 알고있는 객체에서 호출을 수행하면 일반 멤버 함수 호출입니다. 이는 오버 헤드가 발생하지 않을뿐만 아니라 이러한 호출이 일반 호출과 동일한 방식으로 인라인 될 수 있음을 의미합니다.

즉, 가상 함수에 가상 디스패치가 필요하지 않은 경우 C ++ 컴파일러가 작동 할 수 있으므로 일반적으로 비가 상 함수에 비해 성능에 대해 걱정할 필요가 없습니다.

새로운 : 또한, 우리는 공유 라이브러리를 잊어서는 안된다. 공유 라이브러리에있는 클래스를 사용하는 경우 일반 멤버 함수에 대한 호출은 단순히 좋은 명령 시퀀스가 ​​아닙니다 callq 0x4007aa. "프로그램 링크 테이블"또는 이와 같은 구조를 통한 간접 지정과 같은 몇 가지 과정을 거쳐야합니다. 따라서 공유 라이브러리 간접 지정은 (완전히 간접적 인) 가상 호출과 직접 호출 간의 비용 차이를 어느 정도 (완전하지는 않지만) 평준화 할 수 있습니다. 따라서 가상 함수 트레이드 오프에 대한 추론은 프로그램이 어떻게 빌드되는지를 고려해야합니다. 대상 객체의 클래스가 호출하는 프로그램에 모 놀리 식으로 연결되어 있는지 여부입니다.


4
"무엇보다 느린가요?" -필요하지 않은 방법으로 가상을 만들면 비교 자료가 상당히 좋습니다.
tdammers

2
가상 함수 호출이 항상 동적 인 것은 아니라는 점을 지적 해 주셔서 감사합니다. 여기에있는 다른 모든 응답은 상황에 관계없이 함수 가상을 선언하면 자동 성능 히트를 의미하는 것처럼 보입니다.
Syndog 2016 년

12

가상 통화는

res_t (*foo)(arg_t);
foo = (obj->vtable[foo_offset]);
foo(obj,args)

비가 상 함수를 사용하면 컴파일러가 첫 번째 줄을 일정하게 접을 수 있습니다.

이것은 또한 함수를 인라인 할 수있게합니다 (모든 적절한 최적화 결과와 함께)

당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.