popcount
대규모 데이터 배열에 가장 빠른 방법을 찾고있었습니다 . 나는 발생하는 매우 이상한 효과를 :에서 루프 변수 변경 unsigned
에 uint64_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
더 느려 ! 그것으로부터 떨어 26
에 20 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
.
내 질문
이 결과를 설명 할 수 있습니까? 특히:
- 어떻게 간의 이러한 차이가있을 수 있습니다
u32
와u64
? - 불변 상수를 일정한 버퍼 크기로 바꾸면 어떻게 최적의 코드가 트리거 되지 않습니까?
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 % 차이를 만들 수 있습니다 ! static
size 변수 앞에 키워드를 삽입하면 버퍼의 스토리지 유형조차도 큰 차이를 만들 수 있습니다 ! 앞으로 시스템 성능에 중요한 빡빡하고 핫한 루프를 작성할 때 다양한 컴파일러에서 다양한 대안을 테스트 할 것입니다.
흥미로운 점은 이미 루프를 네 번 풀었지만 성능 차이가 여전히 높다는 것입니다. 따라서 롤을 풀더라도 주요 성능 편차로 인해 여전히 타격을받을 수 있습니다. 꽤 흥미로운.