일반적으로 분기를 피하기 위해 가상 기능을 사용할 가치가 있습니까?


21

브랜치 미스 가상 기능의 비용과 동일한 지침과 비슷한 지침이 비슷한 것으로 보입니다.

  • 명령 대 데이터 캐시 미스
  • 최적화 장벽

다음과 같은 것을 보면 :

if (x==1) {
   p->do1();
}
else if (x==2) {
   p->do2();
}
else if (x==3) {
   p->do3();
}
...

멤버 함수 배열을 가질 수도 있고, 많은 함수가 동일한 분류에 의존하거나 더 복잡한 분류가 존재하는 경우 가상 함수를 사용하십시오.

p->do()

그러나, 일반적으로, 얼마나 비싼 것은 분기 대 가상 함수는 일반화하기에 충분 플랫폼에서 테스트하기 어렵다 있습니다 (이 4 명로 간단하게한다면 아름다운 하나가 엄지 손가락의 거친 규칙이 있다면 궁금 그래서 if중단 점 인들)

일반적으로 가상 기능은 더 명확하고 그 기능에 기대어 있습니다. 그러나 코드를 가상 함수에서 분기로 변경할 수있는 매우 중요한 섹션이 몇 개 있습니다. 나는 이것을하기 전에 이것에 대해 생각하는 것을 선호한다. (사소한 변경이 아니거나 여러 플랫폼에서 쉽게 테스트 할 수 있음)


12
성능 요구 사항은 무엇입니까? 적중해야 할 어려운 숫자가 있거나 조기 최적화에 참여하고 있습니까? 브랜칭과 가상 메소드는 모두 대단한 체계에서 매우 저렴합니다 (예 : 잘못된 알고리즘, I / O 또는 힙 할당과 비교).
amon

4
미래의 변화를 막기 위해 더 읽기 쉽고 융통성있는 / 불확실한 것을 수행하고 일단 작동 하면 프로파일 링 수행하고 이것이 실제로 중요한지 확인하십시오. 보통 그렇지 않습니다.
Ixrec

1
질문 : "일반적으로 가상 기능은 얼마나 비쌉
니까

1
대부분의 답변은 지침 수를 세는 것에 근거합니다. 저수준 코드 최적화 프로그램으로서, 나는 명령의 수를 신뢰하지 않습니다. 실험 조건 하에서 물리적으로 특정 CPU 아키텍처에서이를 입증해야합니다. 이 질문에 대한 올바른 답변은 이론적이 아닌 실험적이고 실험적이어야합니다.
rwong

3
이 질문의 문제점은 이것이 걱정할만큼 크다는 것을 전제로한다는 것입니다. 실제 소프트웨어에서 성능 문제는 여러 크기의 피자 조각과 같이 큰 덩어리로 나타납니다. 예를 들어 여기보십시오 . 가장 큰 문제가 무엇인지 알고 있다고 가정하지 마십시오. 프로그램이 알려줍니다. 그 문제를 해결 한 후 다음 내용이 무엇인지 알려주십시오. 이 수십 번을 수행하면 가상 함수 호출이 걱정할만한 곳으로 갈 있습니다. 그들은 내 경험으로는 결코 없었습니다.
Mike Dunlavey

답변:


21

나는 이미 이미 훌륭한 답변 중 하나로 뛰어 들어가서 다형성 코드를 변경 switches하거나 if/else반향을 측정 하는 반 패턴으로 이득을 측정 하는 반 패턴으로 실제로 역행하는 추한 접근 방식을 취하고 있음을 인정했습니다 . 그러나 나는 가장 중요한 길을 위해서만이 도매를하지 않았습니다. 흑백 일 필요는 없습니다.

면책 조항으로서, 정확성이 달성하기가 어렵지 않고 (종종 퍼지 및 근사화되는) 레이트 레이싱과 같은 영역에서 작업하지만 속도는 종종 가장 경쟁력있는 품질 중 하나입니다. 렌더링 시간 단축은 종종 가장 일반적인 사용자 요청 중 하나이며, 우리는 끊임없이 머리를 긁고 가장 중요한 측정 경로를 달성하는 방법을 알아냅니다.

조건부 다형성 리팩토링

첫째, 조건부 분기 ( switch또는 여러 가지 if/else문) 보다 유지 관리 측면에서 다형성이 더 좋은 이유를 이해할 가치가 있습니다 . 여기서 주요 이점은 확장 성 입니다.

다형성 코드를 사용하면 코드베이스에 새로운 하위 유형을 도입하고 일부 다형성 데이터 구조에 해당 인스턴스를 추가 할 수 있으며 기존의 모든 다형성 코드가 더 이상 수정없이 자동으로 작동하도록 할 수 있습니다. "이 유형이 'foo'이면 그 형식을 수행하십시오 " 와 유사한 대형 코드베이스에 여러 개의 코드가 흩어져 있다면 50 개의 서로 다른 코드 섹션을 업데이트해야하는 끔찍한 부담이 생길 수 있습니다. 새로운 유형의 일이지만 여전히 몇 가지가 빠져 있습니다.

이러한 유형 검사를 수행해야하는 코드베이스의 한두 섹션 만 있으면 다형성의 유지 관리 이점이 자연스럽게 줄어 듭니다.

최적화 장벽

나는 이것을 분기와 파이프 라이닝의 관점에서 많이 보지 말고 최적화 장벽의 컴파일러 디자인 사고 방식에서 더 많이 살펴볼 것을 제안합니다. 하위 유형을 기반으로 데이터를 정렬하는 것과 같이 두 시퀀스 모두에 적용되는 분기 예측을 향상시키는 방법이 있습니다 (시퀀스에 맞는 경우).

이 두 전략의 차이점은 옵티마이 저가 미리 가지고있는 정보의 양입니다. 알려진 함수 호출은 훨씬 더 많은 정보를 제공하며 컴파일 타임에 알 수없는 함수를 호출하는 간접 함수 호출은 최적화 장벽을 초래합니다.

호출되는 함수가 알려지면 컴파일러는 구조를 없애고 스패 터리스로 스쿼시하여 호출을 인라인하고 잠재적 인 앨리어싱 오버 헤드를 제거하며 명령 / 레지스터 할당에서 더 나은 작업을 수행 할 수 있습니다. 적절한 경우 switch코딩 된 소형 LUT (GCC 5.3은 최근 점프 테이블이 아니라 결과에 하드 코딩 된 LUT 데이터를 사용하여 설명에 놀랐다 ).

간접 함수 호출의 경우와 같이 컴파일 타임을 알 수없는 믹스를 믹스에 도입하기 시작하면 조건부 분기가 우위를 점할 가능성이있는 일부 이점이 사라집니다.

메모리 최적화

타이트한 루프로 일련의 생물을 반복적으로 처리하는 비디오 게임의 예를 들어 보자. 이러한 경우 다음과 같은 다형성 컨테이너가있을 수 있습니다.

vector<Creature*> creatures;

참고 : 단순화를 위해 unique_ptr여기서 피 했습니다.

... 여기서 Creature다형성 기본 유형이 있습니다. 이 경우, 다형성 컨테이너의 어려움 중 하나는 종종 각 하위 유형에 대해 개별 / 개별적으로 메모리를 할당하려고한다는 것입니다 (예 : operator new각 개별 생물에 대한 기본 던지기 사용 ).

그것은 종종 브랜칭이 아닌 메모리 기반의 최적화 (필요한 경우) 우선 순위를 결정합니다. 여기서 한 가지 전략은 각 하위 유형에 고정 할당자를 사용하여 할당 된 각 하위 유형에 대해 메모리를 풀링하고 큰 청크를 할당하여 연속적인 표현을 장려하는 것입니다. 이러한 전략을 사용하면 creatures분기 예측을 향상시킬뿐만 아니라 참조 하위 위치를 개선하여 동일한 하위 유형의 여러 생물체에 액세스 할 수 있기 때문에이 컨테이너를 하위 유형 (및 주소)별로 정렬하는 것이 확실히 도움이 될 수 있습니다 제거하기 전에 단일 캐시 라인에서).

데이터 구조와 루프의 부분 가상화

이 모든 동작을 겪었지만 여전히 더 빠른 속도를 원한다고 가정 해 봅시다. 여기서 우리가 벤처하는 각 단계가 유지 관리 성을 저하시키고 있으며, 이미 성능 수익이 감소하는 다소 금속 연마 단계에있을 것입니다. 따라서이 영역으로 넘어 가면 성능에 대한 요구가 상당히 높아야합니다.이 영역에서 더 작고 작은 성능 향상을 위해 유지 관리 성을 더욱 희생하고자합니다.

그러나 다음 시도는 (그리고 전혀 도움이되지 않으면 변경 사항을 기꺼이 기꺼이 포기하려는) 수동 육성 수 있습니다.

버전 관리 팁 : 나보다 훨씬 최적화에 정통하지 않다면, 최적화 노력이 빠질 경우이 지점에서 새 브랜치를 만들면 가치가 있습니다. 나에게 그것은 프로파일 러가 있더라도 이러한 종류의 포인트 이후의 모든 시행 착오입니다.

그럼에도 불구하고 우리는이 사고 방식을 적용 할 필요가 없습니다. 우리의 예를 계속하면서,이 비디오 게임은 대부분 인간으로 이루어져 있다고 가정 해 봅시다. 이 경우 인간 생물을 끌어 올리고 그것들을위한 별도의 데이터 구조를 만들어 인간 생물을 가상화 할 수 있습니다.

vector<Human> humans;               // common case
vector<Creature*> other_creatures;  // additional rare-case creatures

이것은 생물체를 처리해야하는 코드베이스의 모든 영역이 인간 생물체에 대해 별도의 특수 사례 루프가 필요하다는 것을 의미합니다. 그러나 이는 가장 일반적인 생물 유형 인 인간에 대한 동적 디스패치 오버 헤드 (또는 아마도 더 적절하게는 최적화 장벽)를 제거합니다. 이 영역의 수가 많고 여유가 있다면 다음과 같이 할 수 있습니다.

vector<Human> humans;               // common case
vector<Creature*> other_creatures;  // additional rare-case creatures
vector<Creature*> creatures;        // contains humans and other creatures

... 우리가 이것을 감당할 수 있다면 덜 중요한 경로는 그대로 유지되고 모든 생물체 유형을 추상적으로 처리 할 수 ​​있습니다. 중요한 경로는 humans하나의 루프와 other_creatures두 번째 루프에서 처리 할 수 ​​있습니다 .

우리는 필요에 따라이 전략을 확장하고 잠재적으로 이런 방식으로 이익을 줄 수 있지만 프로세스에서 유지 관리 성을 얼마나 저하시키고 있는지 주목할 가치가 있습니다. 여기서 함수 템플릿을 사용하면 로직을 수동으로 복제하지 않고도 인간과 생물 모두에 대한 코드를 생성 할 수 있습니다.

클래스의 부분 탈 가상화

몇 년 전에 내가 실제로 해왔 던 일이 더 이상 유익하지 않다고 확신하지는 않지만 (C ++ 03 시대에) 클래스의 부분적인 가상화였습니다. 이 경우, 우리는 이미 다른 목적을 위해 (가상이 아닌 기본 클래스의 접근자를 통해 액세스 한) 각 인스턴스와 함께 클래스 ID를 저장하고있었습니다. 거기에서 우리는 이것과 비슷한 것을했습니다 (내 기억은 약간 흐릿합니다).

switch (obj->type())
{
   case id_common_type:
       static_cast<CommonType*>(obj)->non_virtual_do_something();
       break;
   ...
   default:
       obj->virtual_do_something();
       break;
}

... virtual_do_something서브 클래스에서 비가 상 버전을 호출하도록 구현되었습니다. 나는 함수 호출을 구체화하기 위해 명시 적 정적 다운 캐스트를 수행하는 것이 중요합니다. 나는 몇 년 동안 이런 종류의 것을 시도하지 않았으므로 이것이 지금 얼마나 유익한 지 전혀 모른다. 데이터 지향 디자인에 노출되면서 데이터 구조와 루프를 핫 / 콜드 방식으로 분할하는 위의 전략이 훨씬 더 유용하여 최적화 전략을위한 더 많은 문을 열었습니다.

도매 가상화

나는 지금까지 최적화 사고 방식을 적용하지 않았다는 것을 인정해야하므로 이점에 대해 전혀 모른다. 하나의 중앙 조건 조건 집합 (예 : 하나의 중앙 장소 처리 이벤트로 이벤트 처리) 만 있음을 알았지 만 다형 적 사고 방식으로 시작하여 모든 방법으로 최적화되지 않은 경우 예측 기능에서 간접 기능을 피했습니다. 여기까지

이론적으로, 여기서 즉각적인 이점은 이러한 최적화 장벽을 완전히 없애는 것 외에도 가상 포인터보다 유형을 식별하는 잠재적으로 더 작은 방법 일 수 있습니다 (예 : 256 개의 고유 한 유형 이하라는 아이디어에 전념 할 수있는 경우 단일 바이트). .

switch하위 유형을 기반으로 데이터 구조와 루프를 분할하지 않고 하나의 중앙 명령문을 사용 하거나 순서가있는 경우 유지 관리하기 쉬운 코드를 작성하는 데 도움이 될 수 있습니다 (위의 최적화 된 수동 가상화 예제와 비교). 사물을 정확한 순서로 처리해야하는 경우 (종속으로 분기하는 경우에도)-종속성. 이 작업을 수행해야하는 장소가 너무 많지 않은 경우에 해당합니다 switch.

일반적으로 유지 관리가 쉽지 않으면 성능이 매우 중요한 사고 방식으로도 권장하지 않습니다. "쉬운 유지"는 두 가지 주요 요소에 달려 있습니다.

  • 실제 확장 성이 필요하지 않은 경우 (예 : 처리 할 항목이 정확히 8 가지이며 더 이상 없는지 확인)
  • 코드에 이러한 유형을 확인해야하는 장소가 많지 않습니다 (예 : 하나의 중앙 장소).

...하지만 대부분의 경우 위의 시나리오를 권장하고 필요에 따라 부분 가상화로 더 효율적인 솔루션을 반복합니다. 확장 성과 유지 관리 필요성을 성능과 균형을 맞출 수있는 더 많은 호흡 공간을 제공합니다.

가상 함수와 함수 포인터

이것을 끝내기 위해 가상 함수와 함수 포인터에 대한 토론이 있음을 알았습니다. 가상 함수는 호출하는 데 약간의 추가 작업이 필요하지만 이것이 느리다는 것을 의미하지는 않습니다. 직관적으로, 심지어 더 빨라질 수도 있습니다.

여기서는 훨씬 직관적 인 메모리 계층 구조의 역학에주의를 기울이지 않고 명령어 측면에서 비용을 측정하는 데 익숙하기 때문에 반 직관적입니다.

우리는을 비교하는 경우 class대 20 개 가상 기능 struct하는 매장 20 함수 포인터, 모두가 각각의 메모리 오버 헤드를 여러 번 인스턴스화 class메모리 동안,이 경우에는 64 비트 시스템에 대한 가상 포인터 8 바이트 인스턴스 의 오버 헤드 struct는 160 바이트입니다.

실제 비용은 가상 함수를 사용하는 클래스와 함수 포인터 테이블을 사용하여 훨씬 더 강제적이고 강제적이지 않은 캐시 미스가있을 수 있습니다 (그리고 충분히 큰 입력 스케일에서 페이지 오류가 발생할 수 있음). 이 비용은 가상 테이블 인덱싱의 약간의 추가 작업을 방해하는 경향이 있습니다.

또한 structs함수 포인터로 채워지고 여러 번 인스턴스화 된 레거시 C 코드베이스 (나보다 오래된)를 처리했으며 실제로 가상 함수가있는 클래스로 변환하여 실제로 성능을 크게 향상 (100 % 이상 향상)했습니다. 메모리 사용량의 대폭 감소, 캐시 친 화성 증가 등으로 인해

반대로, 사과와 사과를 비교할 때 C ++ 가상 함수 마인드에서 C 스타일 함수 포인터 마인드로 변환하는 반대 마인드가 다음 유형의 시나리오에서 유용하다는 것을 알았습니다.

class Functionoid
{
public:
    virtual ~Functionoid() {}
    virtual void operator()() = 0;
};

... 클래스가 무시할 수있는 단일 함수를 저장하는 곳 (또는 가상 소멸자를 세면 두 개). 이 경우, 중요한 경로에서이를 다음과 같이 전환하는 데 도움이 될 수 있습니다.

void (*func_ptr)(void* instance_data);

... 유형 안전 인터페이스 뒤에 배치하여 위험한 캐스트를 숨길 수 있습니다 void*.

단일 가상 함수가있는 클래스를 사용하려는 경우 함수 포인터를 대신 사용하는 것이 신속하게 도움이 될 수 있습니다. 큰 이유는 함수 포인터를 호출 할 때 비용이 반드시 절감되는 것은 아닙니다. 더 이상 힙의 흩어진 영역에 각각의 개별 기능을 할당하려는 유혹에 직면하지 않기 때문입니다. 이러한 종류의 접근 방식은 인스턴스 데이터가 예를 들어 동종이고 동작 만 변하는 경우 힙 관련 및 메모리 조각화 오버 헤드를 피하는 것이 더 쉬울 수 있습니다.

따라서 함수 포인터를 사용하면 도움이 될 수있는 경우가 있지만 클래스 포인터 당 하나의 포인터 만 저장 해야하는 단일 vtable과 함수 포인터 테이블을 비교하는 경우 종종 다른 방법을 찾았습니다. . 이 vtable은 종종 하나 이상의 L1 캐시 라인과 타이트한 루프에 있습니다.

결론

어쨌든, 그것은이 주제에 대한 나의 작은 회전입니다. 이 부분을주의해서 환기시키는 것이 좋습니다. 본능이 아닌 신뢰 측정 및 이러한 최적화로 인해 유지 관리 성이 저하되는 방식을 고려할 때, 가능한 한 멀리 만 이동하십시오 (유지 한 경로는 유지 관리 측면에서 오류가 발생 함).


가상 함수는 함수 포인터이며, 해당 클래스의 실행 가능한 기능으로 구현되었습니다. 가상 함수가 호출되면 먼저 자식에서 찾고 상속 체인을 찾습니다. 이것이 깊은 상속이 매우 비싸고 일반적으로 c ++에서 피하는 이유입니다.
Robert Baron

@RobertBaron : 당신이 말한 것처럼 가상 함수가 구현되는 것을 본 적이 없습니다 (= 클래스 계층을 통한 체인 조회). 일반적으로 컴파일러는 모든 정확한 함수 포인터를 사용하여 각 콘크리트 유형에 대해 "평평한"vtable을 생성하며 런타임시 단일 직선 테이블 조회로 호출이 해결됩니다. 깊은 상속 계층 구조에 대해서는 벌금이 부과되지 않습니다.
Matteo Italia

Matteo, 이것은 몇 년 전에 기술 책임자가 나에게 준 설명이었습니다. 물론, 그것은 C ++을위한 것이 었으므로 다중 상속의 의미를 고려했을 수 있습니다. vtable이 최적화되는 방법에 대한 나의 이해를 분명히 해 주셔서 감사합니다.
Robert Baron

좋은 답변 감사합니다 (+1). 나는 가상 함수 대신 std :: visit에 얼마나 많이 적용되는지 궁금합니다.
DaveFar

13

관찰 :

  • 많은 경우에 vtable 조회는 O(1)작업이고 else if()래더는 작업 이므로 가상 기능이 더 빠릅니다 O(n). 그러나 사례 분포가 평평한 경우에만 해당됩니다.

  • 단일의 if() ... else경우 함수 호출 오버 헤드를 저장하므로 조건부가 더 빠릅니다.

  • 따라서 사례가 균일하게 분포되어 있으면 손익 분기점이 존재해야합니다. 유일한 질문은 그것이 어디에 있는지입니다.

  • 당신이 사용하는 경우 switch()대신 else if()이 테이블에서 조회되는 위치로 분기를 할 수 있지만 함수 호출은하지 않은 : 사다리 또는 가상 함수 호출을 컴파일러는 더 나은 코드를 생성 할 수 있습니다. 즉, 모든 함수 호출 오버 헤드없이 가상 함수 호출의 모든 특성이 있습니다.

  • 하나가 나머지보다 훨씬 더 빈번한 경우, if() ... else이 경우로 시작 하면 최상의 성능을 얻을 수 있습니다. 대부분의 경우 올바르게 예측되는 단일 조건부 분기를 실행합니다.

  • 컴파일러는 예상되는 사례 분포에 대해 알지 못하며 균일 한 분포를 가정합니다.

컴파일러 switch()else if()래더 또는 테이블 조회로 코딩 할시기와 관련하여 좋은 휴리스틱을 가지고있을 가능성이 높습니다 . 사건 분포가 편향되어 있지 않다면 판단을 신뢰하는 경향이 있습니다.

그래서 내 충고는 이것입니다 :

  • 사례 중 하나가 빈도와 관련하여 나머지를 뒤 흔드는 경우 정렬 된 else if()사다리를 사용하십시오 .

  • 그렇지 않으면 switch()다른 방법 중 하나를 사용하여 코드를 더 읽기 쉽게 만들지 않는 한 명령문을 사용 하십시오. 가독성이 크게 저하되어 무시할 수없는 성능 향상을 구매하지 않도록하십시오.

  • 를 사용 switch()했는데도 여전히 성능에 만족하지 않으면 비교를 수행하되 switch()이미 가장 빠른 가능성 을 알 수 있도록 준비 하십시오.


2
일부 컴파일러는 어노테이션이 컴파일러에게 어떤 경우가 사실 일 가능성이 높은지 알려주고 어노테이션이 올바른 한 더 빠른 코드를 생성 할 수 있습니다.
gnasher729

5
O (1) 연산이 실제 실행 시간에서 O (n) 또는 O (n ^ 20)보다 반드시 빠를 필요는 없습니다.
whatsisname

2
@whatsisname 그래서 "많은 경우"라고 말했습니다. 의 정의에 O(1)O(n)이 존재 k하므로 있다는 O(n)기능은보다 큰 O(1)모든 기능을 n >= k. 유일한 질문은 당신이 많은 경우를 가질 가능성이 있는지 여부입니다. 그리고 네, 사다리가 가상 함수 호출이나로드 된 디스패치보다 확실히 느린 switch()경우가 많은 진술을 보았습니다 else if().
cmaster-monica reinstate

이 답변에서 내가 가진 문제는 완전히 관련이없는 성능 향상을 기반으로 결정을 내리는 것에 대한 유일한 경고는 마지막 단락의 다음 부분 어딘가에 숨겨져 있다는 것입니다. 그 밖의 모든 것은 여기에 대한 결정을 만들 수있는 좋은 아이디어가 될 수있다 척 ifswitchperfomance에 따라 대 가상 함수. 에서 매우 드문 경우 가있을 수 있지만, 대부분의 경우 그렇지 않다.
Doc Brown

7

일반적으로 분기를 피하기 위해 가상 기능을 사용할 가치가 있습니까?

일반적으로 그렇습니다. 유지 보수의 이점은 상당합니다 (분리, 우려 분리, 개선 된 모듈성 및 확장 성 테스트).

그러나 일반적으로 가상 기능과 분기의 비용은 얼마나 비쌉니까 일반화하기에 충분한 플랫폼에서 테스트하기가 어렵 기 때문에 어느 한 사람이 대략적인 경험을 가지고 있는지 궁금합니다.

코드를 프로파일 링하고 분기 간 디스패치 ( 조건 평가 )가 수행 된 계산 ( 분기의 코드 )보다 시간이 더 걸린다는 것을 알고 있지 않으면 수행 된 계산을 최적화하십시오.

즉, "가상 기능 대 분기 비용"에 대한 정답이 측정되어 있습니다.

경험 법칙 : (위의 분기 계산보다 분기 식별이 더 비싼) 위의 상황이 아닌 한 유지 관리 노력을 위해 코드의이 부분을 최적화하십시오 (가상 기능 사용).

이 섹션이 가능한 빨리 실행되기를 원한다고 말합니다. 얼마나 빠릅니까? 당신의 구체적인 요구 사항은 무엇입니까?

일반적으로 가상 기능은 더 명확하고 그 기능에 기대어 있습니다. 그러나 코드를 가상 함수에서 분기로 변경할 수있는 매우 중요한 섹션이 몇 개 있습니다. 나는 이것을하기 전에 이것에 대해 생각하는 것을 선호한다. (사소한 변경이 아니거나 여러 플랫폼에서 쉽게 테스트 할 수 있음)

그런 다음 가상 기능을 사용하십시오. 이를 통해 필요한 경우 플랫폼별로 최적화하고 클라이언트 코드를 깨끗하게 유지할 수 있습니다.


많은 유지 관리 프로그래밍을 수행 한 후 약간의주의를 기울 이겠습니다. 가상 기능은 IMNSHO이 유지 관리에 매우 나쁘다는 점입니다. 핵심 문제는 유연성입니다. 당신은 거기에 거의 무엇이든 붙일 수 있습니다 ... 그리고 사람들은. 동적 디스패치에 대해 정적으로 추론하기는 매우 어렵습니다. 그러나 대부분의 경우 코드에는 유연성이 모두 필요하지 않으며 런타임 유연성을 제거하면 코드를 쉽게 추론 할 수 있습니다. 그러나 동적 디스패치를 ​​사용해서는 안된다고 말하고 싶지는 않습니다. 터무니없는 말입니다.
Eamon Nerbonne

가장 좋은 추상화는 드물지만 (불투명 한 추상화는 약간 불투명하지만) 초고속입니다. 기본적으로 : 하나의 특정한 경우에 유사한 형태를 갖기 때문에 동적 디스패치 추상화 뒤에 무언가를 붙이지 마십시오. 당신이 합리적으로 임신을 할 수없는 경우에만 그렇게 할 어떤 적 해당 인터페이스를 공유하는 객체 사이의 구별에 대해 신경을 이유. 할 수없는 경우 : 누출 추상화보다 비 캡슐화 도우미를 사용하는 것이 좋습니다. 그리고 그때조차도; 런타임 유연성과 코드베이스 유연성 사이에는 상충 관계가 있습니다.
Eamon Nerbonne

5

다른 답변은 이미 좋은 이론적 주장을 제공합니다. 최근에 수행 한 실험 결과를 추가하여 switch연산 코드를 크게 사용하여 가상 머신 (VM)을 구현하는 것이 좋은지 또는 연산 코드를 색인으로 해석하는 것이 좋은지 평가하고 싶습니다. 함수 포인터의 배열로. 이것은 virtual함수 호출 과 정확히 같지는 않지만 합리적으로 가깝다고 생각합니다.

1과 10000 사이에서 무작위로 선택되는 명령 세트 크기 (균일하지는 않지만 낮은 범위를 더 조밀하게 샘플링)를 사용하여 VM에 대해 C ++ 14 코드를 무작위로 생성하는 Python 스크립트를 작성했습니다. 생성 된 VM에는 항상 128 개의 레지스터가있었습니다. 램. 지침은 의미가 없으며 모두 다음과 같은 형식으로되어 있습니다.

inline void
op0004(machine_state& state) noexcept
{
  const auto c = word_t {0xcf2802e8d0baca1dUL};
  const auto r1 = state.registers[58];
  const auto r2 = state.registers[69];
  const auto r3 = ((r1 + c) | r2);
  state.registers[6] = r3;
}

스크립트는 switch명령문을 사용하여 디스패치 루틴을 생성 합니다.

inline int
dispatch(machine_state& state, const opcode_t opcode) noexcept
{
  switch (opcode)
  {
  case 0x0000: op0000(state); return 0;
  case 0x0001: op0001(state); return 0;
  // ...
  case 0x247a: op247a(state); return 0;
  case 0x247b: op247b(state); return 0;
  default:
    return -1;  // invalid opcode
  }
}

… 그리고 함수 포인터의 배열.

inline int
dispatch(machine_state& state, const opcode_t opcode) noexcept
{
  typedef void (* func_type)(machine_state&);
  static const func_type table[VM_NUM_INSTRUCTIONS] = {
    op0000,
    op0001,
    // ...
    op247a,
    op247b,
  };
  if (opcode >= VM_NUM_INSTRUCTIONS)
    return -1;  // invalid opcode
  table[opcode](state);
  return 0;
}

생성 된 디스패치 루틴은 생성 된 각 VM에 대해 무작위로 선택되었습니다.

벤치마킹을 위해 op 코드 스트림은 무작위로 시드 된 ( std::random_device) Mersenne twister random engine ( std::mt19937_64) 으로 생성되었습니다 .

각 VM의 코드는 -DNDEBUG, -O3-std=c++14스위치를 사용하여 GCC 5.2.0으로 컴파일되었습니다 . 먼저, -fprofile-generate1000 개의 임의 명령을 시뮬레이션하기 위해 수집 된 옵션 및 프로파일 데이터를 사용하여 컴파일되었습니다 . 그런 다음 -fprofile-use수집 된 프로파일 데이터를 기반으로 최적화를 허용 하는 옵션으로 코드를 다시 컴파일했습니다 .

그런 다음 5 만 회주기 동안 VM을 동일한 프로세스로 4 회 실행하고 각 실행 시간을 측정했습니다. 콜드 캐시 효과를 제거하기 위해 첫 번째 실행이 삭제되었습니다. PRNG는 실행 사이에 다시 시드되지 않았으므로 동일한 순서의 명령을 수행하지 않았습니다.

이 설정을 사용하여 각 디스패치 루틴에 대해 1000 개의 데이터 포인트가 수집되었습니다. 데이터는 그래픽 데스크탑이나 다른 프로그램을 실행하지 않고 64 비트 GNU / Linux를 실행하는 2048 KiB 캐시가있는 쿼드 코어 AMD A8-6600K APU에서 수집되었습니다. 아래는 각 VM에 대한 명령어 당 평균 CPU 시간 (표준 편차 포함)의 도표입니다.

여기에 이미지 설명을 입력하십시오

이 데이터에서 함수 테이블을 사용하는 것이 매우 적은 수의 op 코드를 제외하고 좋은 아이디어라는 확신을 얻을 수 있습니다. switch500 ~ 1000 명령 사이 의 버전 이상 값에 대한 설명이 없습니다 .

벤치 마크에 대한 모든 소스 코드와 전체 실험 데이터 및 고해상도 플롯내 웹 사이트 에서 찾을 수 있습니다 .


3

필자가 주장한 cmaster의 좋은 대답 외에도 함수 포인터는 일반적으로 가상 함수보다 엄격하게 빠릅니다. 가상 함수 디스패치는 일반적으로 먼저 객체에서 vtable로 포인터를 따라 가며 적절하게 인덱싱 한 다음 함수 포인터를 역 참조합니다. 따라서 마지막 단계는 동일하지만 처음에는 추가 단계가 있습니다. 또한 가상 함수는 항상 "this"를 인수로 사용하며 함수 포인터가 더 유연합니다.

명심해야 할 또 다른 사항 : 중요한 경로에 루프가 포함 된 경우 디스패치 대상별로 루프를 정렬하는 것이 도움이 될 수 있습니다. 분명히 이것은 nlogn이지만 루프를 순회하는 것은 n 일뿐이지만 여러 번 횡단 할 경우 가치가 있습니다. 디스패치 대상을 기준으로 정렬하면 동일한 코드가 반복적으로 실행되어 icache에서 뜨겁게 유지되어 캐시 누락을 최소화 할 수 있습니다.

명심해야 할 세 번째 전략 : 가상 함수 / 함수 포인터에서 if / switch 전략으로 이동하기로 결정한 경우 다형성 객체에서 boost :: variant (스위치도 제공함)로 전환하면 유용 할 수 있습니다. 방문자 추상화 형태의 경우). 다형성 객체는 기본 포인터로 저장해야하므로 데이터가 캐시의 모든 곳에 있습니다. 이는 가상 조회 비용보다 중요한 경로에 쉽게 영향을 줄 수 있습니다. 변형은 차별적 인 노동 조합으로 인라인으로 저장되는 반면; 크기는 가장 큰 데이터 유형과 같으며 작은 상수도 있습니다. 객체의 크기가 너무 다르지 않은 경우이를 처리하는 좋은 방법입니다.

사실, 데이터의 캐시 일관성을 향상시키는 것이 원래의 질문보다 더 큰 영향을 미치더라도 놀라지 않을 것입니다.


가상 기능에는 "추가 단계"가 포함되어 있다는 것을 모르겠습니다. 클래스의 레이아웃이 컴파일 타임에 알려져 있다고 가정하면 본질적으로 배열 액세스와 동일합니다. 즉, 클래스 상단에 대한 포인터가 있으며 함수의 오프셋이 알려져 있으므로 추가하고 결과를 읽고 주소입니다. 많은 오버 헤드가 없습니다.

1
추가 단계가 필요합니다. vtable 자체에는 함수 포인터가 포함되어 있으므로 vtable을 만들 때 함수 포인터로 시작한 것과 같은 상태에 도달했습니다. vtable에 도착하기 전에 모든 것이 추가 작업입니다. 클래스는 vtable을 포함하지 않으며 vtable에 대한 포인터를 포함하며 그 포인터를 따르는 것은 추가적인 역 참조입니다. 실제로, 다형성 클래스는 일반적으로 기본 클래스 포인터에 의해 유지되므로 세 번째 역 참조가 있으므로 vtable 주소를 가져 오려면 포인터를 역 참조해야합니다 (;-)).
Nir Friedman

반대로, vtable이 인스턴스 외부에 저장된다는 사실은 실제로 각각의 모든 함수 포인터가 다른 메모리 주소에 저장된 함수 포인터의 여러 개별 구조체에 대한 시간적 지역성에 도움이 될 수 있습니다. 이러한 경우 백만 개의 vptr을 가진 단일 vtable은 메모리 소비로 시작하여 백만 개의 함수 포인터 테이블을 쉽게 이길 수 있습니다. 여기에서는 다소 던지기 일 수 있습니다. 일반적으로 함수 포인터가 종종 저렴하지만 하나를 다른 것보다 쉽게 ​​배치 할 수는 없다는 데 동의합니다.

가상 함수가 함수 포인터보다 빠르고 총체적으로 성능을 향상시키기 시작하는 또 다른 방법은 관련된 객체 인스턴스의 보트로드 (각 객체가 여러 함수 포인터 또는 단일 vptr을 저장해야 함)가있을 때입니다. 예를 들어, 함수로드는 메모리에 저장된 단 하나의 함수 포인터 만 가지고 있으면 보트로드라고 불리는 경우가 더 저렴합니다. 그렇지 않으면 함수 중복 포인터가 많은 중복 메모리 양으로 인해 동일한 주소를 가리키는 데이터 중복 및 캐시 누락으로 인해 느려질 수 있습니다.

물론 함수 포인터를 사용하면 메모리를 낭비하지 않고 캐시 누락이 발생하지 않도록 백만 개의 개별 객체가 공유하더라도 중앙 위치에 저장할 수 있습니다. 그러나 vpointer와 동등 해지기 시작하여 메모리에서 공유 위치에 대한 포인터 액세스를 통해 호출하려는 실제 기능 주소에 도달합니다. 여기서 근본적인 질문은 : 현재 액세스하고있는 데이터 또는 중앙 위치에 기능 주소를 더 가깝게 저장합니까? vtables는 후자를 허용합니다. 함수 포인터는 두 가지 방법을 모두 허용합니다.

2

왜 이것이 XY 문제 라고 생각하는지 설명해 주 시겠습니까? (당신은 그들에게 물어 보는 것이 혼자가 아닙니다.)

귀하의 실제 목표는 캐시 누락 및 가상 기능에 대한 요점을 이해하는 것이 아니라 전반적인 시간을 절약하는 것입니다.

실제 소프트웨어에서 실제 성능 조정 의 예는 다음과 같습니다 .

실제 소프트웨어에서는 프로그래머의 경험에 관계없이 더 나은 결과를 얻을 수 있습니다. 프로그램이 작성되고 성능 조정이 완료 될 때까지 자신이 무엇인지 알 수 없습니다. 프로그램 속도를 높이는 방법은 거의 항상 여러 가지가 있습니다. 결국, 프로그램이 당신이 가능한 프로그램의 판테온에서 문제를 해결하는 것을 말하고, 최적 말할 것도 그 시간이 덜 걸릴하지 않습니다. 정말?

내가 연결 한 예에서 원래 "작업"당 2700 마이크로 초가 걸렸습니다. 피자 주변에서 시계 반대 방향으로 진행되는 일련의 6 가지 문제가 해결되었습니다. 첫 번째 가속은 시간의 33 %를 제거했습니다. 두 번째는 11 %를 제거했습니다. 그러나 첫 번째 문제가 사라졌기 때문에 두 번째 것은 발견 된 시점에서 11 %가 아니라 16 % 였습니다 . 마찬가지로, 첫 번째 두 가지 문제가 없어서 세 번째 문제는 7.4 %에서 13 % (거의 두 배)로 확대되었습니다.

결국,이 확대 과정은 3.7 마이크로 초를 제외한 모든 것을 제거 할 수있었습니다. 원래 시간의 0.14 % 또는 730 배의 속도입니다.

여기에 이미지 설명을 입력하십시오

초기에 큰 문제를 제거하면 속도가 약간 향상되지만 나중에 문제를 제거 할 수있는 길을 열어줍니다. 이 후자의 문제는 처음에는 전체에서 중요하지 않은 부분 이었지만 초기 문제가 제거 된 후에는이 작은 문제가 커져서 속도가 크게 향상 될 수 있습니다. (이 결과를 얻으려면 아무도 빠질 수 없으며이 게시물 은 그들이 얼마나 쉬운 지 보여줍니다.)

여기에 이미지 설명을 입력하십시오

최종 프로그램이 최적 이었습니까? 아마 아닙니다. 속도 향상은 캐시 미스와 관련이 없습니다. 캐시 미스가 중요할까요? 아마도.

편집 : 나는 OP 질문의 "매우 중요한 섹션"에 관심이있는 사람들로부터 하향 투표를 받고 있습니다. 시간이 어느 정도인지를 알기 전까지는 "매우 중요한"것을 알지 못합니다. 호출되는 메소드의 평균 비용이 시간이 지남에 따라 10주기 이상인 경우, 메소드를 디스패치하는 방법은 실제로 수행하는 방법과 비교하여 "중요하지"않을 수 있습니다. 나는 이것을 사람들이 "나노초마다 니딩"을 페니와 어리석은 짓으로 여기는 것으로보고 있습니다.


그는 이미 마지막 나노초의 성능을 요구하는 몇 가지 "매우 중요한 섹션"이 있다고 말했다. 그래서 이것은 그가 물었던 질문에 대한 답변이 아닙니다 (다른 사람의 질문에 대한 훌륭한 답변 일지라도)
gbjbaanb

2
@gbjbaanb : 모든 마지막 나노초가 중요하다면 왜 "일반적으로"질문이 시작됩니까? 말도 안 돼요 나노초가 계산되면 일반적인 대답을 찾을 수없고, 컴파일러가하는 일을보고, 하드웨어가하는 일을보고, 변형을 시도하고, 모든 변형을 측정합니다.
gnasher729

@ gnasher729 모르겠지만 왜 "매우 중요한 섹션"으로 끝나나요? 나는 slashdot처럼 제목뿐만 아니라 항상 내용을 읽어야한다고 생각합니다!
gbjbaanb

2
@gbjbaanb : 모두가 "매우 중요한 섹션"을 가지고 있다고 말합니다. 그들은 어떻게 알 수 있습니까? 예를 들어 10 개의 샘플을 채취하여 2 개 이상의 샘플에서 볼 때까지 중요한 것이 무엇인지 모르겠습니다. 이와 같은 경우 호출되는 메소드가 10 개 이상의 명령어를 사용하는 경우 가상 함수 오버 헤드는 중요하지 않을 수 있습니다.
Mike Dunlavey

@ gnasher729 : 글쎄, 내가하는 첫 번째 일은 스택 샘플을 얻는 것입니다. 각 샘플에서 프로그램이 수행하는 작업과 이유를 검사하십시오. 그런 다음 통화 트리에서 모든 시간을 보내고 모든 통화를 피할 수없는 경우 컴파일러와 하드웨어의 기능이 중요합니다. 샘플이 메소드 디스패치 수행 중일 경우 메소드 디스패치가 중요하다는 것을 알고 있습니다.
Mike Dunlavey
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.