32 비트 루프 카운터를 64 비트로 바꾸면 인텔 CPU에서 _mm_popcnt_u64와 성능 차이가 심해집니다.


1424

popcount대규모 데이터 배열에 가장 빠른 방법을 찾고있었습니다 . 나는 발생하는 매우 이상한 효과를 :에서 루프 변수 변경 unsigneduint64_t내 PC에 50 %에 의한 성능 저하를.

벤치 마크

#include <iostream>
#include <chrono>
#include <x86intrin.h>

int main(int argc, char* argv[]) {

    using namespace std;
    if (argc != 2) {
       cerr << "usage: array_size in MB" << endl;
       return -1;
    }

    uint64_t size = atol(argv[1])<<20;
    uint64_t* buffer = new uint64_t[size/8];
    char* charbuffer = reinterpret_cast<char*>(buffer);
    for (unsigned i=0; i<size; ++i)
        charbuffer[i] = rand()%256;

    uint64_t count,duration;
    chrono::time_point<chrono::system_clock> startP,endP;
    {
        startP = chrono::system_clock::now();
        count = 0;
        for( unsigned k = 0; k < 10000; k++){
            // Tight unrolled loop with unsigned
            for (unsigned i=0; i<size/8; i+=4) {
                count += _mm_popcnt_u64(buffer[i]);
                count += _mm_popcnt_u64(buffer[i+1]);
                count += _mm_popcnt_u64(buffer[i+2]);
                count += _mm_popcnt_u64(buffer[i+3]);
            }
        }
        endP = chrono::system_clock::now();
        duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
        cout << "unsigned\t" << count << '\t' << (duration/1.0E9) << " sec \t"
             << (10000.0*size)/(duration) << " GB/s" << endl;
    }
    {
        startP = chrono::system_clock::now();
        count=0;
        for( unsigned k = 0; k < 10000; k++){
            // Tight unrolled loop with uint64_t
            for (uint64_t i=0;i<size/8;i+=4) {
                count += _mm_popcnt_u64(buffer[i]);
                count += _mm_popcnt_u64(buffer[i+1]);
                count += _mm_popcnt_u64(buffer[i+2]);
                count += _mm_popcnt_u64(buffer[i+3]);
            }
        }
        endP = chrono::system_clock::now();
        duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
        cout << "uint64_t\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
             << (10000.0*size)/(duration) << " GB/s" << endl;
    }

    free(charbuffer);
}

보시다시피, 우리는 임의의 데이터 버퍼를 만들고, 크기는 x메가 바이트이며 x명령 행에서 읽습니다. 그런 다음 버퍼를 반복하고 x86 popcount내장 버전의 언 롤링 버전 을 사용하여 팝 카운트를 수행합니다. 보다 정확한 결과를 얻으려면 popcount를 10,000 번 수행합니다. 우리는 popcount의 시간을 측정합니다. 대문자 인 경우 내부 루프 변수는 unsigned이고, 소문자 인 경우 내부 루프 변수는 uint64_t입니다. 나는 이것이 아무런 차이가 없어야한다고 생각했지만 그 반대의 경우입니다.

(절대적으로 미친) 결과

나는 이것을 다음과 같이 컴파일한다 (g ++ 버전 : Ubuntu 4.8.2-19ubuntu1) :

g++ -O3 -march=native -std=c++11 test.cpp -o test

다음은 3.50GHz에서 실행중인 Haswell Core i7-4770K CPU 의 결과입니다 test 1(따라서 1MB의 임의 데이터).

  • 부호없는 41959360000 0.401554 초 26.113 GB / s
  • uint64_t 41959360000 0.759822 초 13.8003 GB / s

보시다시피, uint64_t버전 의 처리량은 버전의 절반불과 합니다 unsigned! 문제는 다른 어셈블리가 생성되는 것처럼 보이지만 왜 그럴까요? 먼저 컴파일러 버그를 생각했기 때문에 시도했습니다 clang++(Ubuntu Clang 버전 3.4-1ubuntu3).

clang++ -O3 -march=native -std=c++11 teest.cpp -o test

결과: test 1

  • 부호없는 41959360000 0.398293 초 26.3267 GB / s
  • uint64_t 41959360000 0.680954 초 15.3986 GB / s

따라서 거의 같은 결과이며 여전히 이상합니다. 그러나 이제는 매우 이상해집니다. 입력에서 읽은 버퍼 크기를 constant로 1바꾸므로 다음과 같이 변경합니다.

uint64_t size = atol(argv[1]) << 20;

uint64_t size = 1 << 20;

따라서 컴파일러는 이제 컴파일 타임에 버퍼 크기를 알고 있습니다. 아마도 최적화를 추가 할 수 있습니다! 다음은 숫자입니다 g++.

  • 부호없는 41959360000 0.509156 초 20.5944 GB / s
  • uint64_t 41959360000 0.508673 초 20.6139 GB / s

이제 두 버전 모두 똑같이 빠릅니다. 그러나, unsigned 더 느려 ! 그것으로부터 떨어 2620 GB/s따라서 (A)에 일정한 값의 리드가 일정하지 않은 대체 deoptimization . 진지하게, 나는 여기서 무슨 일이 일어나고 있는지 전혀 모른다! 그러나 이제 clang++새 버전으로 :

  • 부호없는 41959360000 0.677009 초 15.4884 GB / s
  • uint64_t 41959360000 0.676909 초 15.4906 GB / s

무엇을 기다립니다? 이제 두 버전 모두 15GB / s 의 느린 수로 떨어졌습니다 . 따라서 상수가 아닌 상수를 바꾸면 Clang의 경우 모두 코드가 느려질 수 있습니다 !

Ivy Bridge CPU를 사용 하는 동료에게 벤치 마크를 컴파일 하도록 요청했습니다 . 그는 비슷한 결과를 얻었으므로 Haswell이 아닌 것 같습니다. 두 개의 컴파일러가 여기에서 이상한 결과를 생성하기 때문에 컴파일러 버그가 아닌 것 같습니다. 여기에는 AMD CPU가 없으므로 인텔에서만 테스트 할 수 있습니다.

더 많은 광기, 제발!

첫 번째 예제 (로 된 예제)를 가져 와서 변수 앞에 atol(argv[1])a static를 넣으십시오 .

static uint64_t size=atol(argv[1])<<20;

다음은 g ++의 결과입니다.

  • 부호없는 41959360000 0.396728 초 26.4306 GB / s
  • uint64_t 41959360000 0.509484 초 20.5811 GB / s

예, 또 다른 대안 입니다. 우리는 여전히 26GB / s의 빠른 속도를 유지 u32하지만 u64최소한 13GB / s에서 20GB / s 버전으로 업그레이드했습니다! 동료의 PC에서 u64버전이 버전보다 훨씬 빨라져서 u32가장 빠른 결과를 얻었습니다. 슬프게도이 기능은에만 효과가 있으며 g++, clang++신경 쓰지 않는 것 같습니다 static.

내 질문

이 결과를 설명 할 수 있습니까? 특히:

  • 어떻게 간의 이러한 차이가있을 수 있습니다 u32u64?
  • 불변 상수를 일정한 버퍼 크기로 바꾸면 어떻게 최적의 코드가 트리거 되지 않습니까?
  • static키워드를 삽입 하면 u64루프가 더 빨라집니다. 동료 컴퓨터의 원래 코드보다 훨씬 빠릅니다!

나는 최적화가 까다로운 영역이라는 것을 알고 있지만 그런 작은 변화로 인해 실행 시간 이 100 % 차이 를 낼 수 있으며 일정한 버퍼 크기와 같은 작은 요소가 다시 결과를 완전히 혼합 할 수 있다고 생각하지 않았습니다 . 물론, 나는 항상 26GB / s를 차지할 수있는 버전을 원합니다. 내가 생각할 수있는 유일한 확실한 방법은이 경우에 어셈블리를 복사하여 붙여 넣고 인라인 어셈블리를 사용하는 것입니다. 이것은 작은 변화에 화를 낸 것처럼 보이는 컴파일러를 제거 할 수있는 유일한 방법입니다. 어떻게 생각해? 대부분의 성능으로 코드를 안정적으로 얻는 다른 방법이 있습니까?

분해

다양한 결과에 대한 분해는 다음과 같습니다.

g ++ / u32 / non-const bufsize의 26GB / s 버전 :

0x400af8:
lea 0x1(%rdx),%eax
popcnt (%rbx,%rax,8),%r9
lea 0x2(%rdx),%edi
popcnt (%rbx,%rcx,8),%rax
lea 0x3(%rdx),%esi
add %r9,%rax
popcnt (%rbx,%rdi,8),%rcx
add $0x4,%edx
add %rcx,%rax
popcnt (%rbx,%rsi,8),%rcx
add %rcx,%rax
mov %edx,%ecx
add %rax,%r14
cmp %rbp,%rcx
jb 0x400af8

g ++ / u64 / non-const bufsize의 13GB / s 버전 :

0x400c00:
popcnt 0x8(%rbx,%rdx,8),%rcx
popcnt (%rbx,%rdx,8),%rax
add %rcx,%rax
popcnt 0x10(%rbx,%rdx,8),%rcx
add %rcx,%rax
popcnt 0x18(%rbx,%rdx,8),%rcx
add $0x4,%rdx
add %rcx,%rax
add %rax,%r12
cmp %rbp,%rdx
jb 0x400c00

clang ++ / u64 / non-const bufsize 의 15GB / s 버전 :

0x400e50:
popcnt (%r15,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r15,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r15,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r15,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp %rbp,%rcx
jb 0x400e50

g ++ / u32 & u64 / const bufsize 의 20GB / s 버전 :

0x400a68:
popcnt (%rbx,%rdx,1),%rax
popcnt 0x8(%rbx,%rdx,1),%rcx
add %rax,%rcx
popcnt 0x10(%rbx,%rdx,1),%rax
add %rax,%rcx
popcnt 0x18(%rbx,%rdx,1),%rsi
add $0x20,%rdx
add %rsi,%rcx
add %rcx,%rbp
cmp $0x100000,%rdx
jne 0x400a68

clang ++ / u32 & u64 / const bufsize 의 15GB / s 버전 :

0x400dd0:
popcnt (%r14,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r14,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r14,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r14,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp $0x20000,%rcx
jb 0x400dd0

흥미롭게도 가장 빠른 (26GB / s) 버전도 가장 길다! 사용하는 유일한 솔루션 인 것 같습니다 lea. 일부 버전 jb은 점프에 사용하고 다른 버전은 을 사용 jne합니다. 그러나 그 외에도 모든 버전은 비슷한 것으로 보입니다. 100 % 성능 차이가 어디에서 발생하는지 알 수 없지만 어셈블리 해독에 너무 익숙하지는 않습니다. 가장 느린 (13GB / s) 버전은 매우 짧고 좋습니다. 누구든지 이것을 설명 할 수 있습니까?

교훈

이 질문에 대한 답이 무엇이든간에; 나는 정말로 핫 루프에서 모든 세부 사항, 심지어 핫 코드와 관련이없는 것처럼 보이는 세부 사항도 중요 하다는 것을 배웠습니다 . 루프 변수에 사용할 유형에 대해 생각한 적이 없지만 이러한 사소한 변경으로 100 % 차이를 만들 수 있습니다 ! staticsize 변수 앞에 키워드를 삽입하면 버퍼의 스토리지 유형조차도 큰 차이를 만들 수 있습니다 ! 앞으로 시스템 성능에 중요한 빡빡하고 핫한 루프를 작성할 때 다양한 컴파일러에서 다양한 대안을 테스트 할 것입니다.

흥미로운 점은 이미 루프를 네 번 풀었지만 성능 차이가 여전히 높다는 것입니다. 따라서 롤을 풀더라도 주요 성능 편차로 인해 여전히 타격을받을 수 있습니다. 꽤 흥미로운.


8
많은 의견들! 채팅에서 볼 수 있고 원하는 경우 직접 남겨 둘 수도 있지만 여기에 더 이상 추가하지 마십시오!
Shog9

3
popcnt 명령어의 GCC Issue 62011, False Data Dependency 도 참조하십시오 . 다른 사람이 제공했지만 정리 중에 손실 된 것 같습니다.
jww

나는 말할 수 없지만 정적 버전의 분해 중 하나입니까? 그렇지 않은 경우 게시물을 편집하고 추가 할 수 있습니까?
Kelly S. French

답변:


1552

Culprit : False Data Dependency (컴파일러도 인식하지 못함)

Sandy / Ivy Bridge 및 Haswell 프로세서에서 다음 명령을 수행하십시오.

popcnt  src, dest

대상 레지스터에 대한 종속성이 잘못된 것으로 보입니다 dest. 명령은 명령에만 쓰지만 dest실행 전에 준비가 될 때까지 기다립니다 . 이 잘못된 종속성은 Intel에서 정오표 HSD146 (Haswell)SKL029 (Skylake) 로 문서화했습니다.

Skylake가 lzcnt및에 대해이 문제를 해결했습니다tzcnt .
캐논 레이크 (및 아이스 레이크)가이 문제를 해결했습니다 popcnt.
bsf/ bsr실제 출력 종속성이 있습니다 : input = 0에 대해 수정되지 않은 출력. (그러나 내장 함수를 사용하는 방법 은 없습니다. AMD 문서 만 해당하며 컴파일러는이를 공개하지 않습니다.)

(예, 이러한 명령어는 모두 동일한 실행 단위에서 실행됩니다 ).


이 의존성은 popcnt단일 루프 반복에서 4 초를 유지하지 않습니다 . 루프 반복을 수행 할 수 있으므로 프로세서가 다른 루프 반복을 병렬화 할 수 없습니다.

unsigneduint64_t및 기타 비틀기 직접 문제에 영향을 미치지 않습니다. 그러나 레지스터를 변수에 할당하는 레지스터 할당 자에 영향을줍니다.

귀하의 경우, 속도는 레지스터 할당자가 수행하기로 결정한 것에 따라 (거짓) 종속성 체인에 붙어있는 것의 직접적인 결과입니다.

  • 13기가바이트 / s는 체인을 가지고 : popcnt- add- popcnt- popcnt→ 다음 반복
  • 15기가바이트 / s는 체인을 가지고 : popcnt- add- popcnt- add→ 다음 반복
  • 20GB / s에는 체인이 있습니다 popcnt.- popcnt→ 다음 반복
  • 26GB / s에는 체인이 있습니다 popcnt.- popcnt→ 다음 반복

20GB / s와 26GB / s의 차이는 간접 주소 지정의 사소한 것으로 보입니다. 어느 쪽이든,이 속도에 도달하면 프로세서가 다른 병목 현상을 시작합니다.


이것을 테스트하기 위해 인라인 어셈블리를 사용하여 컴파일러를 무시하고 원하는 어셈블리를 정확하게 얻었습니다. 또한 count변수를 분할 하여 벤치 마크에 혼란을 줄 수있는 다른 모든 종속성을 해제했습니다.

결과는 다음과 같습니다.

Sandy Bridge Xeon @ 3.5 GHz : (전체 테스트 코드는 하단에 있습니다)

  • GCC 4.6.3 : g++ popcnt.cpp -std=c++0x -O3 -save-temps -march=native
  • 우분투 12

다른 레지스터 : 18.6195 GB / s

.L4:
    movq    (%rbx,%rax,8), %r8
    movq    8(%rbx,%rax,8), %r9
    movq    16(%rbx,%rax,8), %r10
    movq    24(%rbx,%rax,8), %r11
    addq    $4, %rax

    popcnt %r8, %r8
    add    %r8, %rdx
    popcnt %r9, %r9
    add    %r9, %rcx
    popcnt %r10, %r10
    add    %r10, %rdi
    popcnt %r11, %r11
    add    %r11, %rsi

    cmpq    $131072, %rax
    jne .L4

동일한 레지스터 : 8.49272 GB / s

.L9:
    movq    (%rbx,%rdx,8), %r9
    movq    8(%rbx,%rdx,8), %r10
    movq    16(%rbx,%rdx,8), %r11
    movq    24(%rbx,%rdx,8), %rbp
    addq    $4, %rdx

    # This time reuse "rax" for all the popcnts.
    popcnt %r9, %rax
    add    %rax, %rcx
    popcnt %r10, %rax
    add    %rax, %rsi
    popcnt %r11, %rax
    add    %rax, %r8
    popcnt %rbp, %rax
    add    %rax, %rdi

    cmpq    $131072, %rdx
    jne .L9

체인이 파손 된 동일한 레지스터 : 17.8869 GB / s

.L14:
    movq    (%rbx,%rdx,8), %r9
    movq    8(%rbx,%rdx,8), %r10
    movq    16(%rbx,%rdx,8), %r11
    movq    24(%rbx,%rdx,8), %rbp
    addq    $4, %rdx

    # Reuse "rax" for all the popcnts.
    xor    %rax, %rax    # Break the cross-iteration dependency by zeroing "rax".
    popcnt %r9, %rax
    add    %rax, %rcx
    popcnt %r10, %rax
    add    %rax, %rsi
    popcnt %r11, %rax
    add    %rax, %r8
    popcnt %rbp, %rax
    add    %rax, %rdi

    cmpq    $131072, %rdx
    jne .L14

그렇다면 컴파일러에 어떤 문제가 있습니까?

GCC 나 Visual Studio 모두 popcnt그러한 잘못된 종속성 이 있음을 인식하지 못하는 것 같습니다 . 그럼에도 불구하고 이러한 잘못된 의존성은 드문 일이 아닙니다. 컴파일러가 인식하고 있는지 여부는 문제입니다.

popcnt정확히 가장 많이 사용되는 명령은 아닙니다. 따라서 주요 컴파일러가 이와 같은 것을 놓칠 수 있다는 것은 놀라운 일이 아닙니다. 이 문제를 언급 한 문서는 어디에도 없습니다. 인텔이 공개하지 않으면 누군가 외부에 우연히 들어 오기 전까지는 아무도 외부에서 알 수 없습니다.

( 업데이트 : 버전 4.9.2 부터 GCC는 이러한 잘못된 종속성을 인식하고 최적화가 활성화 될 때이를 보상하는 코드를 생성합니다. Clang, MSVC 및 인텔 자체 ICC를 포함한 다른 공급 업체의 주요 컴파일러는 아직 인식하지 못하고 있습니다. 이 미세 아키텍처 정오표는이를 보완하는 코드를 생성하지 않습니다.)

CPU가 왜 이러한 잘못된 종속성을 가지고 있습니까?

우리는 추측 할 수는 같은 실행 장치에서 실행 bsf/ 수행 출력 의존성을 가지고있다. ( 하드웨어에서 POPCNT는 어떻게 구현됩니까? ). 이러한 지침에 대해 인텔은 input = 0의 정수 결과를 "정의되지 않은"(ZF = 1)로 기록하지만 인텔 하드웨어는 실제로 오래된 소프트웨어 (출력 수정되지 않은)를 깨뜨리지 않도록 더 강력하게 보장합니다. AMD는 이러한 행동을 기록합니다.bsr

아마도이 실행 장치에 대한 일부 uops를 출력에 의존하게 만드는 것은 다소 불편했지만 다른 것은 그렇지 않았습니다.

AMD 프로세서는이 잘못된 종속성을 가지고 있지 않습니다.


전체 테스트 코드는 다음과 같습니다.

#include <iostream>
#include <chrono>
#include <x86intrin.h>

int main(int argc, char* argv[]) {

   using namespace std;
   uint64_t size=1<<20;

   uint64_t* buffer = new uint64_t[size/8];
   char* charbuffer=reinterpret_cast<char*>(buffer);
   for (unsigned i=0;i<size;++i) charbuffer[i]=rand()%256;

   uint64_t count,duration;
   chrono::time_point<chrono::system_clock> startP,endP;
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "popcnt %4, %4  \n\t"
                "add %4, %0     \n\t"
                "popcnt %5, %5  \n\t"
                "add %5, %1     \n\t"
                "popcnt %6, %6  \n\t"
                "add %6, %2     \n\t"
                "popcnt %7, %7  \n\t"
                "add %7, %3     \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "No Chain\t" << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "popcnt %4, %%rax   \n\t"
                "add %%rax, %0      \n\t"
                "popcnt %5, %%rax   \n\t"
                "add %%rax, %1      \n\t"
                "popcnt %6, %%rax   \n\t"
                "add %%rax, %2      \n\t"
                "popcnt %7, %%rax   \n\t"
                "add %%rax, %3      \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
                : "rax"
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "Chain 4   \t"  << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "xor %%rax, %%rax   \n\t"   // <--- Break the chain.
                "popcnt %4, %%rax   \n\t"
                "add %%rax, %0      \n\t"
                "popcnt %5, %%rax   \n\t"
                "add %%rax, %1      \n\t"
                "popcnt %6, %%rax   \n\t"
                "add %%rax, %2      \n\t"
                "popcnt %7, %%rax   \n\t"
                "add %%rax, %3      \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
                : "rax"
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "Broken Chain\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }

   free(charbuffer);
}

똑같이 흥미로운 벤치 마크는 여기에서 찾을 수 있습니다 : http://pastebin.com/kbzgL8si
이 벤치 마크는 popcnt(거짓) 의존성 체인에있는의 수를 변경합니다.

False Chain 0:  41959360000 0.57748 sec     18.1578 GB/s
False Chain 1:  41959360000 0.585398 sec    17.9122 GB/s
False Chain 2:  41959360000 0.645483 sec    16.2448 GB/s
False Chain 3:  41959360000 0.929718 sec    11.2784 GB/s
False Chain 4:  41959360000 1.23572 sec     8.48557 GB/s

3
안녕 여러분! 여기에 많은 과거 의견이 있습니다. 새 파일을 남기기 전에 보관 파일을 검토하십시오 .
Shog9

1
@ JustinL.it이 특정 문제는 Clang 7.0에서 수정 된 것 같습니다
Dan M.

@ PeterCordes 스케줄러만큼 실행 단위라고 생각하지 않습니다. 종속성을 추적하는 스케줄러입니다. 이를 위해 명령어는 여러 "명령 클래스"로 그룹화되어 있으며 각 명령어 클래스는 스케줄러에서 동일하게 처리됩니다. 따라서 모든 3 사이클 "슬로우-인"명령은 명령 스케줄링을 위해 동일한 "클래스"로 처리되었습니다.
Mysticial

@Mysticial : 아직도 그렇게 생각해? 그것은 그럴듯하지만 imul dst, src, imm출력 의존성이 없으며 느리지도 않습니다 lea. 두 가지 모두 pdep아니지만 두 개의 입력 피연산자로 인코딩 된 VEX입니다. 거짓 부서 를 일으키는 것은 실행 단위 자체 가 아니라는 것에 동의했습니다 . 아키텍처 레지스터 피연산자의 이름을 물리적 레지스터로 바꾸는 것은 RAT 및 발행 / 이름 바꾸기 단계에 달려 있습니다. 아마도 uop 코드-> 종속성 패턴 및 포트 선택 테이블이 필요하며 동일한 실행 단위에 대한 모든 uop를 그룹화하면 해당 테이블이 단순화됩니다. 이것이 제가 더 자세하게 의미 한 바입니다.
Peter Cordes

대답을 편집하여 수정하거나 원래 스케줄러에 대해 말한 것과 같은 말로 되돌리려면 알려주십시오. SKL이 lzcnt / tzcnt에 대해 잘못된 dep을 삭제했지만 popcnt가 아닌 사실은 우리에게 무언가를 말해야하지만 IDK는 무엇을 말해야합니까? 이름 바꾸기 / RAT 관련이라는 또 다른 가능한 신호는 SKL이 색인 주소 지정 모드를 lzcnt / tzcnt의 메모리 소스로 unlaminate하지만 popcnt는 아니라는 것입니다. 분명히 이름 바꾸기 단위는 백엔드가 나타낼 수있는 UOP를 만들어야합니다.
Peter Cordes

50

실험을 위해 동등한 C 프로그램을 코딩 했으며이 이상한 행동을 확인할 수 있습니다. 또한 gcc가 64 비트 uint를 사용하기 때문에 64 gcc비트 정수 (아마도 어쩌면 size_t...)가 더 좋을 것이라고 생각합니다 uint_fast32_t.

어셈블리와 관련하여 약간의 문제
가 발생했습니다. 32 비트 버전을 가져 와서 모든 32 비트 명령 / 레지스터를 프로그램의 내부 popcount-loop에서 64 비트 버전으로 바꾸십시오. 관찰 : 코드는 32 비트 버전만큼 빠릅니다!

프로그램의 다른 부분이 여전히 32 비트 버전을 사용하기 때문에 변수의 크기가 실제로 64 비트가 아니기 때문에 이것은 분명히 해킹입니다. 그러나 내부 popcount-loop가 성능을 지배하는 한 좋은 시작입니다. .

그런 다음 32 비트 버전의 프로그램에서 내부 루프 코드를 복사하여 64 비트 버전으로 해킹했으며 64 비트 버전의 내부 루프를 대체하기 위해 레지스터로 조정했습니다. 이 코드는 32 비트 버전만큼 빠르게 실행됩니다.

내 결론은 이것이 32 비트 명령어의 실제 속도 / 대기 시간 이점이 아니라 컴파일러의 잘못된 명령어 스케줄링이라는 것입니다.

(주의 : 나는 어셈블리를 해킹했다.


1
"또한 ucc_fast32_t를 사용하면 gcc가 64 비트 uint를 사용하므로 gcc는 64 비트 정수 […]가 더 좋다고 생각합니다." 불행히도, 그리고 유감스럽게도 이러한 유형의 배후에는 마법과 깊은 코드 내성이 없습니다. 나는 그들이 가능한 모든 장소와 모든 플랫폼의 모든 프로그램에 대해 단일 typedefs 이외의 다른 방법을 제공하는 것을 아직 보지 못했습니다. 정확한 유형 선택에 대해 약간의 생각이 있었지만 각 유형에 대한 하나의 정의는 모든 응용 프로그램에 적합하지 않을 수 있습니다. 추가 참고 자료 : stackoverflow.com/q/4116297 .
Keno

2
@Keno 그것은 sizeof(uint_fast32_t)정의되어야 하기 때문 입니다. 허용하지 않으면 속임수를 사용할 수 있지만 컴파일러 확장을 통해서만 수행 할 수 있습니다.
wizzwizz4

25

이것은 대답이 아니지만 결과를 주석에 넣으면 읽기가 어렵습니다.

Mac Pro ( Westmere 6-Cores Xeon 3.33 GHz)로 이러한 결과를 얻습니다 . 나는 그것을 컴파일했다 clang -O3 -msse4 -lstdc++ a.cpp -o a(-O2는 같은 결과를 얻는다).

씨름하다 uint64_t size=atol(argv[1])<<20;

unsigned    41950110000 0.811198 sec    12.9263 GB/s
uint64_t    41950110000 0.622884 sec    16.8342 GB/s

씨름하다 uint64_t size=1<<20;

unsigned    41950110000 0.623406 sec    16.8201 GB/s
uint64_t    41950110000 0.623685 sec    16.8126 GB/s

나는 또한 시도했다 :

  1. 테스트 순서를 반대로하면 결과는 동일하므로 캐시 팩터를 배제합니다.
  2. for역으로 문을 : for (uint64_t i=size/8;i>0;i-=4). 이것은 동일한 결과를 제공하며 컴파일이 예상대로 반복마다 8 씩 크기를 나눌 수 없을 정도로 똑똑하다는 것을 증명합니다.

여기 내 거친 추측이 있습니다.

속도 계수는 세 부분으로 나뉩니다.

  • 코드 캐시 : uint64_t버전의 코드 크기는 크지 만 Xeon CPU에는 영향을 미치지 않습니다. 이로 인해 64 비트 버전이 느려집니다.

  • 사용 된 지침. 루프 수뿐만 아니라 두 버전에서 32 비트 및 64 비트 인덱스로 버퍼에 액세스합니다. 64 비트 오프셋으로 포인터에 액세스하면 전용 64 비트 레지스터 및 주소 지정이 필요하지만 32 비트 오프셋에 즉시 사용할 수 있습니다. 이로 인해 32 비트 버전이 더 빨라질 수 있습니다.

  • 명령어는 64 비트 컴파일 (즉, 프리 페치)에서만 생성됩니다. 이를 통해 64 비트가 더 빨라집니다.

이 세 가지 요소는 서로 상충되는 결과와 일치합니다.


4
흥미롭게도, 컴파일러 버전과 컴파일러 플래그를 추가 할 수 있습니까? 가장 좋은 점은 컴퓨터에서 결과가 바뀌어 u64를 사용하는 것이 더 빠릅니다 . 지금까지 루프 변수가 어떤 유형인지 생각하지 못했지만 다음에 두 번 생각해야합니다. :).
gexicide

2
@gexicide : 나는 16.8201에서 16.8126으로 점프하여 "더 빠르다"고 말하지 않을 것입니다.
user541686

2
@Mehrdad는 : 내 말은 점프는 사이의 하나입니다 12.916.8그래서 unsigned빨리 여기에있다. 내 벤치 마크에서 반대의 경우가 있었다. 즉 26 일 unsigned, 15 일uint64_t
gexicide

@gexicide 당신은 버퍼링 버퍼링 [i]의 차이점을 알고 있습니까?
마스크 할 수없는 인터럽트

@Calvin : 아니요, 무슨 뜻입니까?
gexicide

10

나는 정답을 줄 수는 없지만 가능한 원인에 대한 개요를 제공합니다. 이 참조 는 루프 본문의 명령어에 대해 대기 시간과 처리량 사이에 3 : 1의 비율이 있음을 분명히 보여줍니다. 또한 다중 디스패치의 효과를 보여줍니다. 최신 x86 프로세서에는 3 개의 정수 단위가 제공되므로 일반적으로주기 당 3 개의 명령어를 디스패치 할 수 있습니다.

따라서 최대 파이프 라인과 여러 디스패치 성능과 이러한 메커니즘의 실패 사이에 성능의 요소는 6 배입니다. x86 명령어 세트의 복잡성으로 인해 기발한 파손이 발생하기 쉽다는 것은 잘 알려져 있습니다. 위의 문서는 훌륭한 예입니다.

64 비트 오른쪽 시프트에 대한 Pentium 4 성능은 실제로 열악합니다. 64 비트 왼쪽 시프트 및 모든 32 비트 시프트는 허용 가능한 성능을 갖습니다. ALU의 상위 32 비트에서 하위 32 비트로의 데이터 경로가 제대로 설계되지 않은 것 같습니다.

나는 개인적으로 핫 루프가 4 코어 칩의 특정 코어에서 느리게 실행되는 이상한 경우에 부딪쳤다. 실제로 코어를 끄면 맵 감소 계산에서 성능이 향상되었습니다.

여기에 정수 단위에 대한 경합이 있습니다. popcnt, 루프 카운터 및 주소 계산은 32 비트 너비 카운터에서 최대 속도로 거의 실행될 수 없지만 64 비트 카운터는 경합 및 파이프 라인 중단을 유발합니다. 루프 바디 실행 당 여러 디스패치가있는 총 12 개의 사이클, 잠재적으로 4 개의 사이클 만 있기 때문에 단일 스톨은 실행 시간에 2의 영향을 줄 수 있습니다.

정적 변수를 사용하여 유도 된 변경 사항은 명령의 약간의 재정렬을 유발한다고 추측합니다 .32 비트 코드가 경합의 전환점에 있다는 또 다른 단서입니다.

나는이 엄격한 분석 아니라는 것을 알고 있지만, 그것은 이다 그럴듯한 설명.


2
불행히도 (Core 2?) 이후 로이 코드에는없는 multiply / divide를 제외하고 32 비트와 64 비트 정수 연산 사이에는 사실상 성능 차이가 없습니다.
Mysticial

@Gene : 모든 버전은 레지스터에 크기를 저장하고 루프의 스택에서 읽지 않습니다. 따라서 주소 계산은 적어도 루프 내부가 아닌 믹스에 포함될 수 없습니다.
gexicide

@ 제네 : 참 흥미로운 설명! 그러나 주요 WTF 요점을 설명하지는 않습니다. 파이프 라인 중단으로 인해 64 비트가 32 비트보다 느리다는 것이 한 가지입니다. 그러나이 경우 64 비트 버전이 32 비트 버전 보다 안정적으로 느리지 않아야 합니까? 대신, 컴파일 타임 상수 버퍼 크기를 사용할 때 32 비트 버전에서도 세 가지 컴파일러가 느린 코드를 생성합니다. 버퍼 크기를 정적으로 다시 변경하면 완전히 변경됩니다. 64 비트 버전이 상당히 빠른 동료 컴퓨터 (그리고 Calvin의 대답)에서도 사례가있었습니다! 그것은 절대적으로 예측할 수없는 것처럼 보인다 ..
gexicide

@Mysticial 그게 내 요점입니다. IU, 버스 시간 등에 대한 경합이 없을 때의 성능 차이는 없습니다. 경합은 모든 것을 다르게 만듭니다. 인텔 코어 문헌의 예는 다음과 같습니다. "디자인에 포함 된 새로운 기술 중 하나는 Macro-Ops Fusion으로, 두 개의 x86 명령어를 단일 마이크로 연산으로 결합합니다. 예를 들어 비교와 같은 일반적인 코드 시퀀스와 조건부 점프 불행히도이 기술은 64 비트 모드에서는 작동하지 않습니다. " 따라서 실행 속도가 2 : 1입니다.
유전자

@gexicide 나는 당신이 무슨 말을하는지 알지만, 당신은 내가 의미하는 것 이상을 추론하고 있습니다. 가장 빠르게 실행되는 코드는 파이프 라인과 디스패치 큐를 가득 채우는 것입니다. 이 상태는 깨지기 쉽습니다. 총 데이터 흐름에 32 비트를 추가하는 것과 같은 사소한 변경 및 명령 재정렬만으로는 충분하지 않습니다. 간단히 말해서, 피들 링과 테스트가 유일한 방법이라는 OP 주장.
유전자

10

Visual Studio 2013 Express 에서 색인 대신 포인터를 사용하여 시도해 보았습니다.이 색인은 프로세스를 약간 가속화했습니다. 주소 지정이 offset + register + (register << 3) 대신 offset + register이기 때문에 이것이 의심됩니다. C ++ 코드.

   uint64_t* bfrend = buffer+(size/8);
   uint64_t* bfrptr;

// ...

   {
      startP = chrono::system_clock::now();
      count = 0;
      for (unsigned k = 0; k < 10000; k++){
         // Tight unrolled loop with uint64_t
         for (bfrptr = buffer; bfrptr < bfrend;){
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
         }
      }
      endP = chrono::system_clock::now();
      duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "uint64_t\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
           << (10000.0*size)/(duration) << " GB/s" << endl;
   }

어셈블리 코드 : r10 = bfrptr, r15 = bfrend, rsi = count, rdi = buffer, r13 = k :

$LL5@main:
        mov     r10, rdi
        cmp     rdi, r15
        jae     SHORT $LN4@main
        npad    4
$LL2@main:
        mov     rax, QWORD PTR [r10+24]
        mov     rcx, QWORD PTR [r10+16]
        mov     r8, QWORD PTR [r10+8]
        mov     r9, QWORD PTR [r10]
        popcnt  rdx, rax
        popcnt  rax, rcx
        add     rdx, rax
        popcnt  rax, r8
        add     r10, 32
        add     rdx, rax
        popcnt  rax, r9
        add     rsi, rax
        add     rsi, rdx
        cmp     r10, r15
        jb      SHORT $LL2@main
$LN4@main:
        dec     r13
        jne     SHORT $LL5@main

9

-funroll-loops -fprefetch-loop-arraysGCC로 전달해 보셨습니까 ?

이러한 추가 최적화로 다음과 같은 결과를 얻습니다.

[1829] /tmp/so_25078285 $ cat /proc/cpuinfo |grep CPU|head -n1
model name      : Intel(R) Core(TM) i3-3225 CPU @ 3.30GHz
[1829] /tmp/so_25078285 $ g++ --version|head -n1
g++ (Ubuntu/Linaro 4.7.3-1ubuntu1) 4.7.3

[1829] /tmp/so_25078285 $ g++ -O3 -march=native -std=c++11 test.cpp -o test_o3
[1829] /tmp/so_25078285 $ g++ -O3 -march=native -funroll-loops -fprefetch-loop-arrays -std=c++11     test.cpp -o test_o3_unroll_loops__and__prefetch_loop_arrays

[1829] /tmp/so_25078285 $ ./test_o3 1
unsigned        41959360000     0.595 sec       17.6231 GB/s
uint64_t        41959360000     0.898626 sec    11.6687 GB/s

[1829] /tmp/so_25078285 $ ./test_o3_unroll_loops__and__prefetch_loop_arrays 1
unsigned        41959360000     0.618222 sec    16.9612 GB/s
uint64_t        41959360000     0.407304 sec    25.7443 GB/s

3
그러나 언 롤링이 잘못된 종속성의 주요 문제를 해결하지 않기 때문에 결과는 완전히 이상합니다 (먼저 부호가없는 것보다 먼저 uint64_t 더 빠릅니다).
gexicide

7

루프 밖으로 축소 단계를 이동해 보셨습니까? 지금은 실제로 필요하지 않은 데이터 종속성이 있습니다.

시험:

  uint64_t subset_counts[4] = {};
  for( unsigned k = 0; k < 10000; k++){
     // Tight unrolled loop with unsigned
     unsigned i=0;
     while (i < size/8) {
        subset_counts[0] += _mm_popcnt_u64(buffer[i]);
        subset_counts[1] += _mm_popcnt_u64(buffer[i+1]);
        subset_counts[2] += _mm_popcnt_u64(buffer[i+2]);
        subset_counts[3] += _mm_popcnt_u64(buffer[i+3]);
        i += 4;
     }
  }
  count = subset_counts[0] + subset_counts[1] + subset_counts[2] + subset_counts[3];

엄격한 앨리어싱 규칙을 준수하는지 확실하지 않은 이상한 앨리어싱도 진행 중입니다.


2
그 질문을 읽은 후에 처음으로 한 일이었습니다. 종속성 체인을 끊습니다. 성능 차이는 변하지 않습니다 (적어도 내 컴퓨터에서는 GCC 4.7.3의 Intel Haswell).
Nils Pipenbrinck

1
@ BenVoigt : 엄격한 앨리어싱을 따릅니다. void*그리고 char*"일부 메모리 덩어리에 대한 포인터"로 간주되기 때문에 별명을 지정할 수있는 두 가지 유형이 있습니다! 데이터 종속성 제거에 대한 귀하의 아이디어는 최적화에 좋지만 질문에 대답하지는 않습니다. 그리고 @NilsPipenbrinck가 말했듯이 아무것도 변경하지 않는 것 같습니다.
gexicide

@gexicide : 엄격한 앨리어싱 규칙은 대칭이 아닙니다. char*에 액세스하는 데 사용할 수 있습니다 T[]. 를 사용하여 a 에 안전하게 액세스 할 수 없으며 코드가 후자를 수행하는 것으로 보입니다. T*char[]
Ben Voigt

@ BenVoigt : 그런 다음 mallocmalloc이 반환 void*하고로 해석하면 아무것도 배열로 저장할 수 없습니다 T[]. 그리고 나는 확신이 오전 void*char*엄격한 앨리어싱에 관한 동일한 의미를 가지고 있었다. 그러나, 나는 이것이 여기서 매우
논란

1
개인적으로 저는 올바른 방법이라고 생각합니다uint64_t* buffer = new uint64_t[size/8]; /* type is clearly uint64_t[] */ char* charbuffer=reinterpret_cast<char*>(buffer); /* aliasing a uint64_t[] with char* is safe */
Ben Voigt

6

TL; DR : __builtin대신 내장 함수를 사용하십시오 . 그들은 도움이 될 수 있습니다.

나는 할 수 있었다 gcc4.8.4 (그리고 gcc.godbolt.org 심지어 4.7.3)를 사용하여 최적의 코드를 생성 __builtin_popcountll같은 어셈블리 명령어를 사용하는,하지만 운이 얻고 예기치 않게이없는 코드를 만들기 위해 발생 잘못된 종속성 버그로 인해 루프가 오래 걸리는 종속성.

벤치마킹 코드를 100 % 확신 objdump할 수 없지만 결과는 내 견해를 공유하는 것 같습니다. 나는 어떤 다른 트릭 ( ++ivs i++)을 사용하여 컴파일러가 아무런 movl명령 없이 루프를 풀게합니다 (이상한 행동, 말해야합니다).

결과 :

Count: 20318230000  Elapsed: 0.411156 seconds   Speed: 25.503118 GB/s

벤치마킹 코드 :

#include <stdint.h>
#include <stddef.h>
#include <time.h>
#include <stdio.h>
#include <stdlib.h>

uint64_t builtin_popcnt(const uint64_t* buf, size_t len){
  uint64_t cnt = 0;
  for(size_t i = 0; i < len; ++i){
    cnt += __builtin_popcountll(buf[i]);
  }
  return cnt;
}

int main(int argc, char** argv){
  if(argc != 2){
    printf("Usage: %s <buffer size in MB>\n", argv[0]);
    return -1;
  }
  uint64_t size = atol(argv[1]) << 20;
  uint64_t* buffer = (uint64_t*)malloc((size/8)*sizeof(*buffer));

  // Spoil copy-on-write memory allocation on *nix
  for (size_t i = 0; i < (size / 8); i++) {
    buffer[i] = random();
  }
  uint64_t count = 0;
  clock_t tic = clock();
  for(size_t i = 0; i < 10000; ++i){
    count += builtin_popcnt(buffer, size/8);
  }
  clock_t toc = clock();
  printf("Count: %lu\tElapsed: %f seconds\tSpeed: %f GB/s\n", count, (double)(toc - tic) / CLOCKS_PER_SEC, ((10000.0*size)/(((double)(toc - tic)*1e+9) / CLOCKS_PER_SEC)));
  return 0;
}

컴파일 옵션 :

gcc --std=gnu99 -mpopcnt -O3 -funroll-loops -march=native bench.c -o bench

GCC 버전 :

gcc (Ubuntu 4.8.4-2ubuntu1~14.04.1) 4.8.4

리눅스 커널 버전 :

3.19.0-58-generic

CPU 정보 :

processor   : 0
vendor_id   : GenuineIntel
cpu family  : 6
model       : 70
model name  : Intel(R) Core(TM) i7-4870HQ CPU @ 2.50 GHz
stepping    : 1
microcode   : 0xf
cpu MHz     : 2494.226
cache size  : 6144 KB
physical id : 0
siblings    : 1
core id     : 0
cpu cores   : 1
apicid      : 0
initial apicid  : 0
fpu     : yes
fpu_exception   : yes
cpuid level : 13
wp      : yes
flags       : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx rdtscp lm constant_tsc nopl xtopology nonstop_tsc eagerfpu pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm arat pln pts dtherm fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 invpcid xsaveopt
bugs        :
bogomips    : 4988.45
clflush size    : 64
cache_alignment : 64
address sizes   : 36 bits physical, 48 bits virtual
power management:

3
의 거짓 dep에 -funroll-loops의해 생성 된 루프 전달 종속성 체인에서 병목 현상이없는 코드를 만드는 것은 행운입니다 popcnt. 잘못된 종속성에 대해 모르는 이전 컴파일러 버전을 사용하면 위험합니다. 없이 -funroll-loops, GCC 4.8.5의 루프 대신 처리량의 popcnt 지연에 병목 것 이로 계산 때문이다rdx . gcc 4.9.3에 의해 컴파일 된 동일한 코드 xor edx,edx는 의존성 체인을 중단하기 위해를 추가합니다 .
Peter Cordes

3
오래된 컴파일러를 사용하면 코드가 여전히 OP와 똑같은 성능 변화에 취약 할 수 있습니다. 사소한 변경으로 인해 gcc가 문제를 일으킬 지 몰랐기 때문에 gcc가 느려질 수 있습니다. 오래된 컴파일러에서 어떤 경우에 작동하는 것을 찾는 것은 문제 가 아닙니다 .
Peter Cordes

2
레코드의 경우 GCC x86intrin.h_mm_popcnt_*기능 __builtin_popcount* ; 인라이닝은 하나를 다른 것과 정확히 동일하게 만들어야합니다. 나는 당신이 그들 사이의 전환으로 인해 발생할 수있는 차이점을 볼 것이라고 의심합니다.
ShadowRanger

-2

우선, 최고 성능을 추정하십시오-https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf를 검토 하십시오. 특히 부록 C.

귀하의 경우, POPCNT 명령의 대기 시간 = 3 클록 및 처리량 = 1 클록을 나타내는 것은 표 C-10입니다. 처리량은 클럭의 최대 속도를 보여줍니다 (최대 주파수 수를 얻기 위해 popcnt64의 경우 코어 주파수와 8 바이트를 곱함).

이제 컴파일러가 수행 한 작업을 조사하고 루프에있는 다른 모든 명령어의 처리량을 합산하십시오. 이것은 생성 된 코드에 대한 최상의 추정치를 제공합니다.

마지막으로 처리량 대신 지연 시간이 큰 지연이 발생하므로 루프의 명령 간 데이터 종속성을 살펴보십시오. 따라서 데이터 흐름 체인에서 단일 반복에 대한 명령을 분할하고이를 통해 지연 시간을 계산 한 다음 순진하게 최대 값을 선택합니다. 데이터 흐름 종속성을 고려하여 대략적인 추정치를 제공합니다.

그러나 귀하의 경우 코드를 올바르게 작성하면 이러한 모든 복잡성이 제거됩니다. 동일한 개수 변수에 누적하는 대신 다른 변수 (count0, count1, ... count8)에 누적하여 마지막에 합계를 계산하십시오. 또는 카운트 배열 [8]을 만들어 요소에 누적 할 수도 있습니다. 아마도 벡터화되어 훨씬 더 나은 처리량을 얻을 수 있습니다.

PS는 1 초 동안 벤치 마크를 실행하지 않습니다. 먼저 코어를 예열 한 다음 10 초 이상 또는 100 초 이상 루프를 실행하십시오. 그렇지 않으면 하드웨어에서 전원 관리 펌웨어 및 DVFS 구현을 테스트합니다. :)

PPS 벤치 마크가 실제로 얼마나 진행되어야하는지에 대한 끝없는 논쟁을 들었습니다. 대부분의 똑똑한 사람들은 왜 10 초가 11 또는 12가 아닌지 묻습니다. 이론 상으로는 이것이 재미 있다는 것을 인정해야합니다. 실제로 벤치 마크를 100 회 연속 실행하여 편차를 기록합니다. 즉, IS 재미. 대부분의 사람들은 새로운 성능 기록을 얻기 위해 정확히 ONCE 이후에 소스를 변경하고 벤치를 실행합니다. 옳은 일을하세요.

아직도 확신하지 않습니까? assp1r1n3 ( https://stackoverflow.com/a/37026212/9706746 ) 에서 벤치 마크의 C 버전 이상을 사용 하고 재시도 루프에서 10000 대신 100을 시도하십시오.

RETRY = 100 인 내 7960X 쇼 :

카운트 : 203182300 경과 : 0.008385 초 속도 : 12.505379 GB / s

카운트 : 203182300 경과 : 0.011063 초 속도 : 9.478225 GB / s

카운트 : 203182300 경과 : 0.011188 초 속도 : 9.372327 GB / s

카운트 : 203182300 경과 : 0.010393 초 속도 : 10.089252 GB / s

카운트 : 203182300 경과 : 0.009076 초 속도 : 11.553283 GB / s

재시도 = 10000 인 경우 :

카운트 : 20318230000 경과 : 0.661791 초 속도 : 15.844519 GB / s

카운트 : 20318230000 경과 : 0.665422 초 속도 : 15.758060 GB / s

횟수 : 20318230000 경과 : 0.660983 초 속도 : 15.863888 GB / s

카운트 : 20318230000 경과 : 0.665337 초 속도 : 15.760073 GB / s

카운트 : 20318230000 경과 : 0.662138 초 속도 : 15.836215 GB / s

PPPS 마지막으로, "허용 된 답변"및 기타 미스에 대한 ;-)

assp1r1n3의 답변을 사용합시다-그는 2.5Ghz 코어를 가지고 있습니다. POPCNT에는 1 개의 클록 스루풋이 있으며 그의 코드는 64 비트 popcnt를 사용합니다. 따라서 수학은 2.5Ghz * 1 클럭 * 8 바이트 = 20GB / s입니다. 그는 약 3Ghz의 터보 부스트로 인해 25Gb / s를보고있다.

따라서 ark.intel.com으로 이동하여 i7-4870HQ를 찾으십시오 : https://ark.intel.com/products/83504/Intel-Core-i7-4870HQ-Processor-6M-Cache-up-to-3-70 -GHz-? q = i7-4870HQ

이 코어는 최대 3.7Ghz까지 작동 할 수 있으며 하드웨어의 실제 최대 속도는 29.6GB / s입니다. 그렇다면 또 다른 4GB / s는 어디에 있습니까? 아마도 각 반복 내에서 루프 논리 및 기타 주변 코드에 소비되었을 것입니다.

이제이 잘못된 의존성 은 어디에 있습니까? 하드웨어는 거의 최고 속도로 실행됩니다. 어쩌면 내 수학이 나쁠 수도 있습니다.

PPPPPS 아직도 HW 에라타를 제안하는 사람들은 범인이므로 제안을 따르고 인라인 asm 예제를 만들었습니다 (아래 참조).

내 7960X에서 첫 번째 버전 (단일 출력 cnt0으로)은 11MB / s로 실행되고, 두 번째 버전 (cnt0, cnt1, cnt2 및 cnt3으로 출력)은 33MB / s로 실행됩니다. 그리고 말할 수 있습니다-짜잔! 출력 의존성입니다.

아마, 내가 만든 요점은 이와 같은 코드를 작성하는 것이 의미가 없으며 출력 종속성 문제가 아니라 바보 같은 코드 생성이라는 것입니다. 우리는 하드웨어를 테스트하지 않고, 최대 성능을 발휘하기 위해 코드를 작성하고 있습니다. HW OOO는 이러한 "출력 의존성"의 이름을 바꾸고 숨겨야 할 것으로 예상 할 수 있지만, 올바른 작업을 제대로 수행하면 결코 미스터리에 직면하지 않을 것입니다.

uint64_t builtin_popcnt1a(const uint64_t* buf, size_t len) 
{
    uint64_t cnt0, cnt1, cnt2, cnt3;
    cnt0 = cnt1 = cnt2 = cnt3 = 0;
    uint64_t val = buf[0];
    #if 0
        __asm__ __volatile__ (
            "1:\n\t"
            "popcnt %2, %1\n\t"
            "popcnt %2, %1\n\t"
            "popcnt %2, %1\n\t"
            "popcnt %2, %1\n\t"
            "subq $4, %0\n\t"
            "jnz 1b\n\t"
        : "+q" (len), "=q" (cnt0)
        : "q" (val)
        :
        );
    #else
        __asm__ __volatile__ (
            "1:\n\t"
            "popcnt %5, %1\n\t"
            "popcnt %5, %2\n\t"
            "popcnt %5, %3\n\t"
            "popcnt %5, %4\n\t"
            "subq $4, %0\n\t"
            "jnz 1b\n\t"
        : "+q" (len), "=q" (cnt0), "=q" (cnt1), "=q" (cnt2), "=q" (cnt3)
        : "q" (val)
        :
        );
    #endif
    return cnt0;
}

코어 클럭주기 (초가 아닌)로 타이밍을 설정하는 경우 1 초는 작은 CPU 바운드 루프에 충분한 시간입니다. 큰 차이를 찾거나 성능 카운터의 성능 카운터를 확인하는 데 100ms라도 좋습니다. 특히 하드웨어 P 상태 관리를 통해로드 시작 후 최대 클럭 속도를 마이크로 초 단위로 늘릴 수있는 Skylake에서.
Peter Cordes

clang은 __builtin_popcountlAVX2로 자동 벡터화 할 수 있으며 vpshufb,이를 위해 C 소스에 여러 개의 누산기가 필요하지 않습니다. 나는 확실하지 않다 _mm_popcnt_u64; AVX512-VPOPCNT에서만 자동 벡터화 할 수 있습니다. ( AVX-512 또는 AVX-2를 사용하여 대용량 데이터에서 1 비트 계산 (인구 수) 참조 )
Peter Cordes

그러나 어쨌든 인텔의 최적화 매뉴얼을 보면 도움이되지 않습니다. 허용 된 답변에서 알 수 있듯이 문제는의 예상치 못한 출력 종속성입니다 popcnt. 이것은 최근의 마이크로 아키텍처에 대한 인텔의 정오표에 기록되어 있지만 당시에는 그렇지 않았다고 생각합니다. 예기치 않은 잘못된 종속성이 있으면 딥 체인 분석이 실패 하므로이 답변은 일반적인 조언이지만 적용 할 수는 없습니다.
Peter Cordes

1
농담 해? 손으로 쓴 asm 루프에서 성능 카운터로 실험적으로 측정 할 수있는 것을 "믿을"필요는 없습니다. 그들은 단지 사실입니다. 나는 테스트했고 Skylake는 lzcnt/ tzcnt에 대한 잘못된 종속성을 수정 했지만에 대한 것은 아닙니다 popcnt. intel.com/content/dam/www/public/us/en/documents/… 에서 Intel의 정오표 SKL029를 참조 하십시오 . 또한 gcc.gnu.org/bugzilla/show_bug.cgi?id=62011 은 "해결되지 않은"것이 아니라 "해결 된 해결"입니다. HW에 출력 종속성이 없다고 주장 할 근거는 없습니다.
Peter Cordes

1
popcnt eax, edx/ 와 같은 간단한 루프 dec ecx / jnz를 만들면 popcnt 처리량 및 분기 처리량에 병목 현상이 발생하여 시계 당 1로 실행됩니다. 그러나 실제로 popcnt쓰기 전용 일 것으로 예상하더라도 EAX를 반복적으로 덮어 쓰기 위해 대기 시간에 병목 현상이 발생하는 3 개 클럭 당 1 개로 만 실행 됩니다. Skylake가 있으므로 직접 시도해 볼 수 있습니다.
Peter Cordes

-3

좋아, OP가 기존 질문에서 다루지 않은 것처럼 보이는 하위 질문 중 하나에 대한 작은 답변을 제공하고 싶습니다. 주의 사항, 테스트 또는 코드 생성 또는 분해를 수행하지 않았으며 다른 사람들이 설명 할 수있는 생각을 나누고 싶었습니다.

static성능 이 변경됩니까?

문제의 줄 : uint64_t size = atol(argv[1])<<20;

짧은 답변

액세스하기 위해 생성 된 어셈블리 size를보고 비 정적 버전에 대한 포인터 간접 처리의 추가 단계가 있는지 확인합니다.

긴 답변

선언 여부에 관계없이 변수의 사본이 하나만 static있고 크기가 변경되지 않기 때문에 차이는 코드에서 사용되는 위치와 함께 변수를 백업하는 데 사용되는 메모리의 위치입니다. 내려가는.

자, 명백히 시작하려면 함수의 모든 지역 변수 (매개 변수와 함께)는 저장 공간으로 사용하기 위해 스택에 공간이 제공됩니다. 이제 main ()의 스택 프레임은 정리되지 않으며 한 번만 생성됩니다. 좋아, static어때? 이 경우 컴파일러는 프로세스의 전역 데이터 공간에 공간을 확보하여 스택 프레임을 제거하여 위치를 지울 수 없음을 알고 있습니다. 그러나 여전히 우리는 하나의 위치 만 가지고 있으므로 차이점은 무엇입니까? 스택의 메모리 위치를 참조하는 방법과 관련이 있다고 생각합니다.

컴파일러가 심볼 테이블을 생성 할 때 크기 등과 같은 관련 속성과 함께 레이블을 입력하면됩니다. 메모리에 적절한 공간을 확보해야하지만 나중에 나중에는 해당 위치를 선택하지 않습니다. 라이브 니스 분석을 한 후 프로세스 및 등록 할당 가능 그러면 링커는 최종 어셈블리 코드의 기계 코드에 제공 할 주소를 어떻게 알 수 있습니까? 최종 위치를 알고 있거나 해당 위치에 도착하는 방법을 알고 있습니다. 스택을 사용하면 위치 기반의 두 가지 요소, 스택 프레임에 대한 포인터 및 프레임에 대한 오프셋을 참조하는 것이 매우 간단합니다. 링커가 런타임 전에 스택 프레임의 위치를 ​​알 수 없기 때문입니다.


2
OP를 테스트하고있는 Intel CPU의 static잘못된 출력 종속성에 영향을 미치는 방식으로 함수의 레지스터 할당을 변경 popcnt하여 피할 수없는 컴파일러를 사용하면 훨씬 더 가능성이 높습니다 . (인텔 CPU에서 이러한 성능 저하가 아직 발견되지 않았기 때문에) 컴파일러 static는 자동 저장 변수와 마찬가지로 레지스터에 로컬 변수를 유지할 수 있지만 main한 번만 실행 한다고 가정하지 않으면 최적화되지 않습니다. 코드 세대 (값은 첫 번째 호출에 의해서만 설정되어 있기 때문에.)
피터 코르

1
어쨌든 대부분의 경우 주소 지정 모드 [RIP + rel32][rsp + 42]주소 지정 모드 의 성능 차이 는 무시할 만합니다. cmp dword [RIP+rel32], immediate단일로드 + cmp uop에 마이크로 퓨즈를 사용할 수는 없지만 이것이 요인이 될 것이라고는 생각하지 않습니다. 내가 말했듯이, 내부 루프는 어쨌든 레지스터에 남아 있지만 C ++을 조정하면 다른 컴파일러 선택을 의미 할 수 있습니다.
Peter Cordes
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.