GCC 5.4.0으로 비약적인 발전


171

나는 다음과 같은 기능을 가지고 있었다 (중요 부분만을 보여줌) :

double CompareShifted(const std::vector<uint16_t>& l, const std::vector<uint16_t> &curr, int shift, int shiftY)  {
...
  for(std::size_t i=std::max(0,-shift);i<max;i++) {
     if ((curr[i] < 479) && (l[i + shift] < 479)) {
       nontopOverlap++;
     }
     ...
  }
...
}

이와 같이 작성하면이 기능은 내 컴퓨터에서 ~ 34ms가 걸렸습니다. 조건을 부울 곱셈으로 변경 한 후 (코드를 다음과 같이 표시) :

double CompareShifted(const std::vector<uint16_t>& l, const std::vector<uint16_t> &curr, int shift, int shiftY)  {
...
  for(std::size_t i=std::max(0,-shift);i<max;i++) {
     if ((curr[i] < 479) * (l[i + shift] < 479)) {
       nontopOverlap++;
     }
     ...
  }
...
}

실행 시간은 ~ 19ms로 줄었습니다.

사용 된 컴파일러는 -O3을 사용하는 GCC 5.4.0이며 godbolt.org를 사용하여 생성 된 asm 코드를 확인한 후 첫 번째 예제는 점프를 생성하지만 두 번째 예제는 점프를 생성하지 않는다는 것을 알았습니다. 첫 번째 예제를 사용할 때 점프 명령을 생성하는 GCC 6.2.0을 시도했지만 GCC 7은 더 이상 생성하지 않는 것 같습니다.

코드 속도를 높이는이 방법을 찾는 것은 다소 번거롭고 시간이 많이 걸렸습니다. 컴파일러는 왜 이런 식으로 동작합니까? 프로그래머가주의해야 할 의도입니까? 이것과 비슷한 것이 더 있습니까?

편집 : godbolt에 연결 https://godbolt.org/g/5lKPF3


17
컴파일러는 왜 이런 식으로 동작합니까? 생성 된 코드가 올바른 경우 컴파일러는 원하는대로 수행 할 수 있습니다. 일부 컴파일러는 다른 컴파일러보다 최적화가 더 좋습니다.
Jabberwocky

26
내 추측은 단락 평가로 &&인해 발생합니다.
Jens

9
이것이 우리에게도있는 이유 &입니다.
rubenvb

7
@Jakub 정렬하면 실행 속도가 가장 높아질 것입니다 . 이 질문을 참조하십시오 .
rubenvb

8
@rubenvb "평가해서는 안된다"실제로 않습니다 평균 부작용이없는 표현 아무것도. 나는 벡터가 범위 검사를하고 GCC가 벡터가 범위를 벗어 났음을 증명할 수 없다고 생각합니다. 편집 : 실제로, 나는 당신 i + shift가 한계를 벗어나는 것을 막기 위해 아무것도하고 있다고 생각하지 않습니다 .
Random832

답변:


263

논리 AND 연산자 ( &&)는 단락 평가를 사용합니다. 즉, 두 번째 테스트는 첫 번째 비교가 true로 평가되는 경우에만 수행됩니다. 이것은 종종 당신이 요구하는 의미론입니다. 예를 들어 다음 코드를 고려하십시오.

if ((p != nullptr) && (p->first > 0))

역 참조하기 전에 포인터가 널이 아닌지 확인해야합니다. 이것이 단락 평가 가 아닌 경우 널 포인터를 역 참조하기 때문에 동작이 정의되지 않은 것입니다.

조건 평가가 비싼 프로세스 인 경우 단락 평가로 성능이 향상 될 수도 있습니다. 예를 들면 다음과 같습니다.

if ((DoLengthyCheck1(p) && (DoLengthyCheck2(p))

경우 DoLengthyCheck1에 실패, 전화 아무 문제가 없다 DoLengthyCheck2.

그러나 결과 바이너리에서는 단락 연산으로 인해 종종 두 가지 분기가 발생하는데, 이는 컴파일러가 이러한 의미를 보존하는 가장 쉬운 방법이기 때문입니다. (어느 왜 동전의 반대편에, 단락 회로 평가 때로는 수 있습니다 금지 . 최적화 가능성이) 당신은 당신을 위해 생성 된 오브젝트 코드의 관련 부분을 보면이 볼 수 ifGCC 5.4에 의해 문 :

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13w, 478         ; (curr[i] < 479)
    ja      .L5

    cmp     ax, 478           ; (l[i + shift] < 479)
    ja      .L5

    add     r8d, 1            ; nontopOverlap++

여기에 두 가지 비교 ( cmp명령)가 있으며 각각 별도의 조건부 점프 / 분기 ( ja또는 위의 경우 점프)가 이어집니다.

일반적으로 분기가 느리기 때문에 빡빡한 고리에서는 피해야합니다. 이것은 거의 모든 x86 프로세서에서 겸손한 8088 (매우 느린 페치 시간 및 매우 작은 프리 페치 큐 (명령 캐시와 비교할 수 있음), 분기 예측이 완전히 결여되어 분기에서 캐시를 덤프해야 함을 의미 함) )를 현대적인 구현 (긴 파이프 라인으로 잘못 예측 한 지점을 비슷하게 비싸게 만드는)에 적용합니다. 내가 미끄러운 작은 경고에 주목하십시오. Pentium Pro 이후의 최신 프로세서에는 지점 비용을 최소화하도록 설계된 고급 지점 예측 엔진이 있습니다. 지점의 방향을 올바르게 예측할 수 있으면 비용이 최소화됩니다. 대부분의 경우, 이것은 잘 작동하지만 분기 예측 변수가 귀하의 편이 아닌 병리학 적 사례에 들어가면,코드가 매우 느려질 수 있습니다 . 배열이 정렬되지 않았다고 말했기 때문에 아마도 여기에있을 것입니다.

당신은 벤치 마크는 대체 있음을 확인 말 &&로모그래퍼 것은 *눈에 띄게 더 빠른 코드를 만든다. 그 이유는 객체 코드의 관련 부분을 비교할 때 분명합니다.

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    xor     r15d, r15d        ; (curr[i] < 479)
    cmp     r13w, 478
    setbe   r15b

    xor     r14d, r14d        ; (l[i + shift] < 479)
    cmp     ax, 478
    setbe   r14b

    imul    r14d, r15d        ; meld results of the two comparisons

    cmp     r14d, 1           ; nontopOverlap++
    sbb     r8d, -1

여기에 더 많은 지침 이 있기 때문에 이것이 더 빠를 수는 약간 반 직관적 이지만 최적화가 때때로 작동하는 방식입니다. cmp여기 에서 동일한 비교 ( )가 수행되는 것을 볼 수 있지만 이제 각각 앞에 xora와 뒤에을 붙 setbe입니다. XOR은 레지스터를 지우는 표준 방법입니다. 이 setbe플래그의 값에 기초하여 비트를 설정하고, 종종 지점없는 코드를 구현하기 위해 사용되는 x86 명령어이다. 여기에 setbe의 반대가 ja있습니다. 비교가 이하이거나 같으면 (레지스터가 사전 영 점화되었으므로 0이 됨) 대상 레지스터를 1로 설정하고 ja비교가 위의 경우 분기됩니다. 이 두 값이 얻어되고 나면 r15br14b레지스터는을 사용하여 함께 곱해집니다 imul. 곱셈은 ​​전통적으로 비교적 느린 연산이지만 현대 프로세서에서는 빠르며 특히 2 바이트 크기의 값만 곱하기 때문에 빠릅니다.

곱셈을 비트 단위 AND 연산자 ( &) 로 쉽게 대체 할 수 있었으며 단락 평가를 수행하지 않습니다. 이것은 코드를 훨씬 명확하게하며 컴파일러가 일반적으로 인식하는 패턴입니다. 그러나 코드 로이 작업을 수행하고 GCC 5.4로 컴파일하면 첫 번째 분기가 계속 발생합니다.

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13w, 478         ; (curr[i] < 479)
    ja      .L4

    cmp     ax, 478           ; (l[i + shift] < 479)
    setbe   r14b

    cmp     r14d, 1           ; nontopOverlap++
    sbb     r8d, -1

이런 식으로 코드를 생성해야하는 기술적 이유는 없지만 어떤 이유로 든 내부 휴리스틱이이를 통해 더 빠르다고 말합니다. 그것은 것입니다 분기 예측이 옆에 있었다면 아마 더 빠를 수 있지만, 분기 예측이 더 자주 성공보다 실패 할 경우 가능성이 느려집니다.

최신 세대의 컴파일러 (및 Clang과 같은 다른 컴파일러)는이 규칙을 알고 있으며이를 수동 최적화로 찾은 것과 동일한 코드를 생성하는 데 사용합니다. 나는 Clang이 &&표현식을 사용했을 때 방출되었을 것과 동일한 코드로 변환 하는 것을 정기적으로 본다 &. 다음은 일반 &&연산자를 사용하여 코드와 함께 GCC 6.2의 관련 출력입니다 .

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13d, 478         ; (curr[i] < 479)
    jg      .L7

    xor     r14d, r14d        ; (l[i + shift] < 479)
    cmp     eax, 478
    setle   r14b

    add     esi, r14d         ; nontopOverlap++

이것이 얼마나 영리한지 주목하십시오 ! 그것은 (서명 조건을 사용 jg하고 setle) 부호없는 조건 (반대 ja하고 setbe), 그러나 이것은 중요하지 않습니다. 여전히 이전 버전과 같은 첫 번째 조건에 대해 비교 및 ​​분기를 수행하고 동일한 setCC명령을 사용 하여 두 번째 조건에 대한 분기 없는 코드를 생성하지만 증가를 수행하는 방법에 훨씬 효율적입니다. . sbb연산에 대한 플래그를 설정하기 위해 두 번째 중복 비교를 수행하는 대신 r14d이 값을 무조건 추가하기 위해 1 또는 0 의 지식을 사용합니다 nontopOverlap. 경우 r14d0, 다음 추가는 어떤 조합입니다; 그렇지 않으면 정확히 1과 동일합니다.

GCC 6.2는 실제로 비트 연산자 보다 단락 연산자 를 사용할 때 효율적인 코드를 생성합니다 .&&&

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13d, 478         ; (curr[i] < 479)
    jg      .L6

    cmp     eax, 478          ; (l[i + shift] < 479)
    setle   r14b

    cmp     r14b, 1           ; nontopOverlap++
    sbb     esi, -1

분기와 조건부 집합은 여전히 ​​존재하지만 이제는 덜 영리한 방식으로 되돌아갑니다 nontopOverlap. 이것은 컴파일러를 능숙하게 만들려고 할 때주의해야 할 중요한 교훈입니다!

그러나 벤치마킹으로 분기 코드가 실제로 느리다는 것을 증명할 수 있다면 컴파일러를 시도하고 능가하는 데 비용이들 수 있습니다. 디스 어셈블리를주의 깊게 검사해야하며 이후 버전의 컴파일러로 업그레이드 할 때 결정을 다시 평가할 준비가되어 있어야합니다. 예를 들어, 작성한 코드는 다음과 같이 다시 작성할 수 있습니다.

nontopOverlap += ((curr[i] < 479) & (l[i + shift] < 479));

여기에는 if진술 이 전혀 없으며 대다수의 컴파일러는이를 위해 분기 코드를 생성하는 것에 대해 결코 생각하지 않을 것입니다. GCC도 예외는 아닙니다. 모든 버전은 다음과 유사한 것을 생성합니다.

    movzx   r14d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r14d, 478         ; (curr[i] < 479)
    setle   r15b

    xor     r13d, r13d        ; (l[i + shift] < 479)
    cmp     eax, 478
    setle   r13b

    and     r13d, r15d        ; meld results of the two comparisons
    add     esi, r13d         ; nontopOverlap++

이전 예제를 따라 본 적이 있다면 이것은 매우 친숙 할 것입니다. 두 비교는 모두 분기없는 방식으로 수행되고 중간 결과는 and함께 계산 된 다음이 결과 (0 또는 1 임)는로 계산 add됩니다 nontopOverlap. 분기없는 코드를 원한다면 실제로 얻을 수 있습니다.

GCC 7은 더욱 똑똑해졌습니다. 위 코드는 원래 코드와 거의 동일한 코드를 생성합니다 (일부 명령어 재 배열 제외). 그래서 당신의 질문에 대한 대답은 "왜 컴파일러는 이런 식으로 행동합니까?" 아마도 완벽하지 않기 때문일 것입니다. 휴리스틱을 사용하여 가능한 가장 최적의 코드를 생성하려고하지만 항상 최선의 결정을 내리지는 않습니다. 그러나 적어도 그들은 시간이 지남에 따라 더 똑똑해질 수 있습니다!

이 상황을 보는 한 가지 방법은 분기 코드의 성능이 가장 우수 하다는 것 입니다. 분기 예측이 성공하면 불필요한 작업을 건너 뛰면 실행 시간이 약간 빨라집니다. 그러나 분기없는 코드는 최악의 경우 성능 이 더 좋습니다 . 분기 예측이 실패하면 분기를 피하기 위해 필요에 따라 몇 가지 추가 명령을 실행하는 것이 잘못 예측 된 분기보다 확실히 빠릅니다. 가장 똑똑하고 똑똑한 컴파일러 조차도이 선택을하기가 어려울 것입니다.

그리고 이것이 프로그래머가 조심해야 할 것인지에 대한 질문에 대해서는 마이크로 최적화를 통해 속도를 높이려는 특정 핫 루프를 제외하고는 대답이 거의 없습니다. 그런 다음 분해와 함께 앉아서 조정할 방법을 찾으십시오. 이전에 말했듯이 최신 버전의 컴파일러로 업데이트하면 까다로운 코드로 어리석은 짓을하거나 다시 돌아갈 수 있도록 최적화 휴리스틱을 변경했을 수 있으므로 이러한 결정을 다시 확인할 준비를하십시오. 원래 코드를 사용합니다. 철저하게 의견을 말하십시오!


3
글쎄, 보편적 인 "더 나은"것은 없습니다. 이는 모두 상황에 따라 다르므로 이런 종류의 저수준 성능 최적화를 수행 할 때 반드시 벤치마킹해야하는 이유입니다. 나는이 질문에 설명 된대로 분기 예측의 손실 크기에 있다면, 잘못 예측 된 지점은 아래 코드를 느리게하려고 많은 . 코드의 마지막 비트는 사용하지 않는 모든 지점 (의 부재주의 j*가 빠른 경우에 할 수 있도록, 지시). [계속]
코디 그레이


2
@ 8bit Bob이 옳습니다. 프리 페치 큐를 참조하고있었습니다. 나는 그것을 캐시라고 부르지 않았을 수도 있지만, 문구에 대해 크게 걱정하지 않았으며 역사적 호기심을 제외하고는 많은 관심을 가진 사람을 찾지 못했기 때문에 세부 사항을 기억하는 데 오랜 시간을 소비하지 않았습니다. 세부 사항을 원한다면 Michael Abrash의 Zen of Assembly Language 가 매우 중요합니다. 전체 책은 온라인으로 다양한 장소에서 이용할 수 있습니다. 여기 branching에 적용 가능한 부분이 있지만 프리 페치에 대한 부분도 읽고 이해해야합니다.
코디 그레이

6
@Hurkyl 나는 전체 답변이 그 질문에 말하는 것처럼 느껴집니다. 당신은 내가 그것을 명시 적으로 부르지 않았다는 것이 맞습니다. 그러나 그것은 이미 충분히 길었던 것 같습니다. :-) 전체 내용을 읽는 데 시간이 걸리는 사람은 그 점을 충분히 이해해야합니다. 그러나 무언가 빠졌다고 생각되거나 더 많은 설명이 필요하다면 답을 포함하도록 답변을 편집하는 것에 부끄러워하지 마십시오. 어떤 사람들은 이것을 좋아하지 않지만 나는 절대로 신경 쓰지 않습니다. 8bittree에서 제안한 내 문구 수정과 함께 이에 대한 간단한 의견을 추가했습니다.
코디 그레이

2
아, @green을 보완 해 주셔서 감사합니다. 제안 할 구체적인 내용이 없습니다. 모든 것과 마찬가지로 행동하고보고 경험함으로써 전문가가됩니다. x86 아키텍처, 최적화, 컴파일러 내부 및 기타 저수준 항목에 관해서는 내가 할 수있는 모든 것을 읽었으며 여전히 알아야 할 모든 것의 일부만 알고 있습니다. 배우는 가장 좋은 방법은 손을 더럽히는 것입니다. 그러나 시작하기를 희망하기 전에 C (또는 C ++), 포인터, 어셈블리 언어 및 기타 모든 저급 기본 사항에 대한 확실한 이해가 필요합니다.
코디 그레이

23

주목해야 할 중요한 점은

(curr[i] < 479) && (l[i + shift] < 479)

(curr[i] < 479) * (l[i + shift] < 479)

의미 상 동등하지 않습니다! 특히 다음과 같은 상황이 발생하는 경우 :

  • 0 <= i그리고 i < curr.size()둘 다 사실이다
  • curr[i] < 479 거짓이다
  • i + shift < 0아니면 i + shift >= l.size()사실

그런 다음 표현식 (curr[i] < 479) && (l[i + shift] < 479)은 잘 정의 된 부울 값이어야합니다. 예를 들어, 분할 오류가 발생하지 않습니다.

그러나 이러한 상황에서 표현 (curr[i] < 479) * (l[i + shift] < 479)정의되지 않은 동작입니다 . 되는 세그먼트 오류가 발생할 수있었습니다.

예를 들어, 원래 코드 스 니펫의 경우 컴파일러는 and컴파일러 l[i + shift]가 필요하지 않은 상황에서 segfault를 발생시키지 않는다는 것을 입증 할 수없는 한 비교와 연산을 모두 수행하는 루프를 작성할 수는 없습니다.

간단히 말해, 원래 코드는 후자보다 최적화 기회가 적습니다. (물론 컴파일러가 기회를 인식하는지 여부는 완전히 다른 질문입니다)

대신 원래 버전을 수정해도됩니다

bool t1 = (curr[i] < 479);
bool t2 = (l[i + shift] < 479);
if (t1 && t2) {
    // ...

이! shift(그리고 max) 의 가치에 따라 여기에 UB가 있습니다.
Matthieu M.

18

&&연산자 단락 평가를 구현한다. 이것은 두 번째 피연산자가 첫 번째 피연산자가로 평가되는 경우에만 평가됨을 의미합니다 true. 이 경우 분명히 점프가 발생합니다.

이것을 보여주는 작은 예제를 만들 수 있습니다 :

#include <iostream>

bool f(int);
bool g(int);

void test(int x, int y)
{
  if ( f(x) && g(x)  )
  {
    std::cout << "ok";
  }
}

어셈블러 출력은 여기에서 찾을 수 있습니다 .

생성 된 코드가 먼저 호출 된 것을 확인한 f(x)다음 출력을 확인 g(x)하고이 시점 의 평가로 이동합니다 true. 그렇지 않으면 함수를 떠납니다.

대신 "부울"곱셈을 사용하면 매번 두 피연산자를 모두 평가하므로 점프 할 필요가 없습니다.

데이터에 따라 점프는 CPU의 파이프 라인 및 추론 실행과 같은 다른 요소를 방해하기 때문에 속도가 느려질 수 있습니다. 일반적으로 분기 예측이 도움이되지만 데이터가 무작위 인 경우 예측할 수있는 것이 많지 않습니다.


1
곱셈이 매번 두 피연산자의 평가를 강제한다고 설명하는 이유는 무엇입니까? x 값에 관계없이 0 * x = x * 0 = 0. 최적화로서, 컴파일러는 곱셈을 "단락"할 수 있습니다. 예를 들어 stackoverflow.com/questions/8145894/…를 참조하십시오 . 또한 &&연산자 와 달리 곱셈은 ​​첫 번째 또는 두 번째 인수를 사용하여 지연 평가 될 수 있으므로 최적화의 자유가 더 커집니다.
SomeWittyUsername

@Jens- "일반적으로 분기 예측은 도움이되지만 데이터가 무작위이면 예측할 수있는 것이 많지 않습니다." -좋은 대답을합니다.
SChepurin

1
@SomeWittyUsername 물론 컴파일러는 관찰 가능한 동작을 유지하는 최적화를 자유롭게 수행 할 수 있습니다. 이것은 변환하거나 계산하지 않을 수 있습니다. 계산 0 * f()하고 f관찰 가능한 동작이있는 경우 컴파일러는이를 호출해야합니다. 차이점은 단락 평가가 필수 &&이지만에 해당하는 것으로 표시 될 수있는 경우 허용된다는 것입니다 *.
Jens

변수 또는 상수에서 0 값을 예측할 수있는 경우에만 @SomeWittyUsername입니다. 나는이 경우가 매우 적은 것 같아요. 어레이 액세스가 관련되어 있기 때문에 OP의 경우에는 최적화를 수행 할 수 없습니다.
Diego Sevilla

3
@Jens : 단락 평가는 필수가 아닙니다. 코드는 단락 된 것처럼 동작하기 만하면 됩니다 . 컴파일러는 결과를 얻기 위해 원하는 수단을 사용할 수 있습니다.

-2

논리 연산자를 사용하는 &&경우 컴파일러는 if 문이 성공하기 위해 두 가지 조건을 확인 해야하기 때문일 수 있습니다 . 그러나 두 번째 경우에는 int 값을 암시 적으로 bool로 변환하기 때문에 컴파일러는 단일 점프 조건과 함께 전달되는 유형과 값을 기반으로 몇 가지 가정을합니다. 컴파일러가 비트 시프트로 jmp를 완전히 최적화 할 수도 있습니다.


8
점프는 첫 번째 조건 이 참인 경우에만 두 번째 조건이 평가된다는 사실에서 비롯됩니다 . 코드는 그렇지 않으면 코드를 평가해서는 안되므로 컴파일러는이를 더 잘 최적화하고 여전히 정확하지 않습니다 (첫 번째 문장이 항상 참이라고 추론하지 않는 한).
rubenvb
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.