Saya mencari cara tercepat untuk popcount
array data yang besar. Saya mengalami efek yang sangat aneh : Mengubah variabel loop dari unsigned
untuk uint64_t
membuat kinerja turun 50% pada PC saya.
Tolok Ukur
#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);
}
Seperti yang Anda lihat, kami membuat buffer data acak, dengan ukurannya x
megabita di mana x
dibaca dari baris perintah. Setelah itu, kita mengulangi buffer dan menggunakan versi popcount
intrinsik x86 yang belum dibuka untuk melakukan popcount. Untuk mendapatkan hasil yang lebih tepat, kami melakukan penghitungan 10.000 kali. Kami mengukur waktu untuk popcount. Dalam huruf besar, variabel loop dalam adalah unsigned
, dalam huruf kecil, variabel loop dalam adalah uint64_t
. Saya pikir ini seharusnya tidak membuat perbedaan, tetapi yang terjadi adalah sebaliknya.
Hasil (benar-benar gila)
Saya kompilasi seperti ini (versi g ++: Ubuntu 4.8.2-19ubuntu1):
g++ -O3 -march=native -std=c++11 test.cpp -o test
Berikut adalah hasil pada CPU Haswell Core i7-4770K saya @ 3,50 GHz, berjalan test 1
(jadi 1 MB data acak):
- unsigned 41959360000 0,401554 detik 26,133 GB / s
- uint64_t 41959360000 0.759822 dtk 13.8003 GB / s
Seperti yang Anda lihat, throughput uint64_t
versi hanya setengah dari unsigned
versi! Masalahnya tampaknya perakitan yang berbeda dihasilkan, tetapi mengapa? Pertama, saya memikirkan bug penyusun, jadi saya mencoba clang++
(Ubuntu Clang versi 3.4-1ubuntu3):
clang++ -O3 -march=native -std=c++11 teest.cpp -o test
Hasil: test 1
- unsigned 41959360000 0,398293 detik 26,3267 GB / s
- uint64_t 41959360000 0.680954 dtk 15.3986 GB / s
Jadi, hasilnya hampir sama dan masih aneh. Tapi sekarang menjadi sangat aneh. Saya mengganti ukuran buffer yang dibaca dari input dengan konstanta 1
, jadi saya ubah:
uint64_t size = atol(argv[1]) << 20;
untuk
uint64_t size = 1 << 20;
Jadi, kompiler sekarang mengetahui ukuran buffer pada waktu kompilasi. Mungkin itu dapat menambahkan beberapa optimasi! Ini nomor untuk g++
:
- unsigned 41959360000 0,509156 detik 20,5944 GB / s
- uint64_t 41959360000 0,508673 detik 20,6139 GB / s
Sekarang, kedua versi sama-sama cepat. Namun, unsigned
semakin lambat ! Itu turun dari 26
ke 20 GB/s
, sehingga menggantikan non-konstan dengan nilai konstan mengarah ke deoptimisasi . Serius, saya tidak tahu apa yang sedang terjadi di sini! Tetapi sekarang clang++
dengan versi baru:
- unsigned 41959360000 0,677009 dt 15,4884 GB / s
- uint64_t 41959360000 0,676909 detik 15,4906 GB / s
Tunggu apa? Sekarang, kedua versi turun ke angka lambat 15 GB / s. Dengan demikian, mengganti non-konstan dengan nilai konstan bahkan menyebabkan kode lambat dalam kedua kasus untuk Dentang!
Saya meminta seorang rekan dengan CPU Ivy Bridge untuk mengkompilasi tolok ukur saya. Dia mendapat hasil yang serupa, sehingga sepertinya bukan Haswell. Karena dua kompiler menghasilkan hasil yang aneh di sini, sepertinya juga bukan kompiler bug. Kami tidak memiliki CPU AMD di sini, jadi kami hanya dapat menguji dengan Intel.
Lebih banyak kegilaan!
Ambil contoh pertama (satu dengan atol(argv[1])
) dan letakkan static
sebelum variabel, yaitu:
static uint64_t size=atol(argv[1])<<20;
Inilah hasil saya di g ++:
- unsigned 41959360000 0,396728 detik 26,4306 GB / s
- uint64_t 41959360000 0,509484 detik 20,5811 GB / s
Yay, alternatif lain . Kami masih memiliki 26 GB / s cepat dengan u32
, tetapi kami berhasil mendapatkan u64
setidaknya dari 13 GB / s ke versi 20 GB / s! Pada PC kolega saya, u64
versi menjadi lebih cepat daripada u32
versi, menghasilkan hasil tercepat dari semua. Sayangnya, ini hanya berhasil g++
, clang++
tampaknya tidak peduli static
.
Pertanyaan saya
Bisakah Anda menjelaskan hasil ini? Terutama:
- Bagaimana bisa ada perbedaan antara
u32
danu64
? - Bagaimana cara mengganti non-konstan dengan ukuran buffer konstan memicu kode yang kurang optimal ?
- Bagaimana penyisipan
static
kata kunci membuatu64
loop lebih cepat? Bahkan lebih cepat daripada kode asli di komputer kolega saya!
Saya tahu bahwa pengoptimalan adalah wilayah yang rumit, namun, saya tidak pernah berpikir bahwa perubahan sekecil itu dapat menyebabkan perbedaan 100% dalam waktu eksekusi dan bahwa faktor kecil seperti ukuran buffer konstan dapat kembali mencampur hasil total. Tentu saja, saya selalu ingin memiliki versi yang dapat melakukan popcount 26 GB / s. Satu-satunya cara yang dapat saya pikirkan adalah menyalin rakitan rakitan untuk kasus ini dan menggunakan rakitan inline. Ini adalah satu-satunya cara saya dapat menyingkirkan kompiler yang tampaknya menjadi gila karena perubahan kecil. Bagaimana menurut anda? Apakah ada cara lain untuk mendapatkan kode dengan kinerja paling andal?
Pembongkaran
Inilah pembongkaran untuk berbagai hasil:
Versi 26 GB / s dari g ++ / u32 / bufsize non-const :
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
Versi 13 GB / s dari g ++ / u64 / bufsize non-const :
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
Versi 15 GB / s dari dentang ++ / u64 / non-const bufsize :
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
Versi 20 GB / s dari g ++ / u32 & u64 / const bufsize :
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
Versi 15 GB / s dari dentang ++ / u32 & u64 / const bufsize :
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
Menariknya, versi tercepat (26 GB / s) juga paling lama! Tampaknya menjadi satu-satunya solusi yang digunakan lea
. Beberapa versi digunakan jb
untuk melompat, yang lain digunakan jne
. Namun terlepas dari itu, semua versi tampaknya sebanding. Saya tidak melihat dari mana kesenjangan kinerja 100% bisa berasal, tetapi saya tidak terlalu mahir menguraikan perakitan. Versi paling lambat (13 GB / s) terlihat sangat pendek dan bagus. Adakah yang bisa menjelaskan ini?
Pelajaran yang dipetik
Tidak peduli apa jawaban untuk pertanyaan ini nantinya; Saya telah belajar bahwa dalam loop yang sangat panas setiap detail dapat berarti, bahkan detail yang tampaknya tidak memiliki hubungan dengan kode hot . Saya tidak pernah memikirkan jenis apa yang akan digunakan untuk variabel loop, tetapi seperti yang Anda lihat perubahan kecil seperti itu dapat membuat perbedaan 100% ! Bahkan tipe penyimpanan buffer dapat membuat perbedaan besar, seperti yang kita lihat dengan penyisipan static
kata kunci di depan variabel ukuran! Di masa depan, saya akan selalu menguji berbagai alternatif pada berbagai kompiler ketika menulis loop sangat ketat dan panas yang sangat penting untuk kinerja sistem.
Hal yang menarik adalah perbedaan kinerja masih sangat tinggi walaupun saya sudah membuka gulungannya empat kali. Jadi, bahkan jika Anda membuka gulungan, Anda masih bisa terkena penyimpangan kinerja utama. Cukup menarik.