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.
sumber
Jawaban:
Penyebab: Ketergantungan Data Salah (dan penyusun bahkan tidak menyadarinya)
Pada prosesor Sandy / Ivy Bridge dan Haswell, instruksi:
tampaknya memiliki ketergantungan palsu pada register tujuan
dest
. Meskipun instruksi hanya menulis kepadanya, instruksi akan menunggu sampaidest
siap sebelum dieksekusi. Ketergantungan salah ini (sekarang) didokumentasikan oleh Intel sebagai erratum HSD146 (Haswell) dan SKL029 (Skylake)Skylake memperbaiki ini untuk
lzcnt
dantzcnt
.Danau Cannon (dan Danau Es) memperbaiki ini untuk
popcnt
.bsf
/bsr
memiliki dependensi output yang benar: output tidak dimodifikasi untuk input = 0. (Tapi tidak ada cara untuk mengambil keuntungan dari itu dengan intrinsik - hanya AMD yang mendokumentasikan dan kompilernya tidak memaparkannya.)(Ya, semua instruksi ini dijalankan pada unit eksekusi yang sama ).
Ketergantungan ini tidak hanya menahan 4
popcnt
detik dari satu iterasi loop tunggal. Itu dapat membawa melintasi iterasi loop sehingga tidak memungkinkan prosesor untuk memaralelkan iterasi loop yang berbeda.The
unsigned
vsuint64_t
dan tweaks lainnya tidak secara langsung mempengaruhi masalah. Tetapi mereka mempengaruhi pengalokasi register yang menempatkan register ke variabel.Dalam kasus Anda, kecepatan adalah hasil langsung dari apa yang terjebak pada rantai dependensi (salah) tergantung pada apa yang diputuskan pengalokasi register.
popcnt
-add
-popcnt
-popcnt
→ iterasi berikutnyapopcnt
-add
-popcnt
-add
→ iterasi berikutnyapopcnt
-popcnt
→ iterasi berikutnyapopcnt
-popcnt
→ iterasi berikutnyaPerbedaan antara 20 GB / s dan 26 GB / s tampaknya merupakan artefak minor dari pengalamatan tidak langsung. Either way, prosesor mulai memukul kemacetan lainnya setelah Anda mencapai kecepatan ini.
Untuk menguji ini, saya menggunakan inline assembly untuk mem-bypass compiler dan mendapatkan perakitan yang saya inginkan. Saya juga membagi
count
variabel untuk memutus semua dependensi lain yang mungkin mengacaukan tolok ukur.Inilah hasilnya:
Sandy Bridge Xeon @ 3.5 GHz: (kode uji lengkap dapat ditemukan di bagian bawah)
g++ popcnt.cpp -std=c++0x -O3 -save-temps -march=native
Register Berbeda: 18.6195 GB / s
Daftar yang Sama: 8.49272 GB / s
Daftar yang sama dengan rantai rusak: 17,8869 GB / s
Jadi apa yang salah dengan kompiler?
Tampaknya baik GCC maupun Visual Studio sadar yang
popcnt
memiliki ketergantungan salah seperti itu. Namun demikian, dependensi palsu ini tidak biasa. Hanya masalah apakah kompiler menyadarinya.popcnt
bukan instruksi yang paling sering digunakan. Jadi tidak terlalu mengejutkan bahwa kompiler utama dapat melewatkan sesuatu seperti ini. Tampaknya juga tidak ada dokumentasi di mana pun yang menyebutkan masalah ini. Jika Intel tidak mengungkapkannya, maka tidak ada orang di luar yang akan tahu sampai seseorang kebetulan melihatnya.( Pembaruan: Pada versi 4.9.2 , GCC menyadari kesalahan-ketergantungan ini dan menghasilkan kode untuk menggantinya ketika optimasi diaktifkan. Kompiler utama dari vendor lain, termasuk Dentang, MSVC, dan bahkan ICC milik Intel sendiri belum mengetahui erratum mikroarsitektur ini dan tidak akan memancarkan kode yang mengkompensasi itu.)
Mengapa CPU memiliki ketergantungan yang salah?
Kami dapat berspekulasi: ini berjalan pada unit eksekusi yang sama dengan
bsf
/bsr
yang memang memiliki ketergantungan pada output. ( Bagaimana POPCNT diimplementasikan dalam perangkat keras? ). Untuk instruksi tersebut, Intel mendokumentasikan hasil integer untuk input = 0 sebagai "tidak terdefinisi" (dengan ZF = 1), tetapi perangkat keras Intel benar-benar memberikan jaminan yang lebih kuat untuk menghindari kerusakan perangkat lunak lama: output tidak dimodifikasi. AMD mendokumentasikan perilaku ini.Agaknya tidak nyaman untuk membuat beberapa uops untuk unit eksekusi ini tergantung pada output tetapi yang lain tidak.
Prosesor AMD tampaknya tidak memiliki ketergantungan salah ini.
Kode tes lengkap di bawah ini untuk referensi:
Tolok ukur yang sama menariknya dapat ditemukan di sini: http://pastebin.com/kbzgL8si
Tolok ukur ini memvariasikan jumlah
popcnt
s yang ada dalam rantai ketergantungan (salah).sumber
imul dst, src, imm
tidak memiliki ketergantungan output, dan juga tidak lambatlea
. Tidak jugapdep
, tapi itu VEX dikodekan dengan 2 operan input. Setuju bukan unit eksekusi itu sendiri yang menyebabkan dep palsu; itu tergantung pada RAT dan mengeluarkan / mengubah nama tahap karena mengubah nama operan register arsitektur ke register fisik. Agaknya itu membutuhkan tabel kode-uop -> pola ketergantungan dan pilihan port, dan mengelompokkan semua uops untuk unit eksekusi yang sama bersama-sama menyederhanakan tabel itu. Itulah yang saya maksud dengan lebih detail.Saya membuat kode program C yang setara untuk bereksperimen, dan saya bisa mengkonfirmasi perilaku aneh ini. Terlebih lagi,
gcc
percaya integer 64-bit (yang mungkin harussize_t
tetap ...) lebih baik, karena menggunakanuint_fast32_t
penyebab gcc menggunakan uint 64-bit.Saya melakukan sedikit mucking dengan perakitan:
Cukup ambil versi 32-bit, ganti semua instruksi / register 32-bit dengan versi 64-bit di loop-pop pop-dalam program. Pengamatan: kodenya secepat versi 32-bit!
Ini jelas merupakan peretasan, karena ukuran variabel tidak benar-benar 64 bit, karena bagian lain dari program masih menggunakan versi 32-bit, tetapi selama popcount-loop mendominasi kinerja, ini adalah awal yang baik .
Saya kemudian menyalin kode loop dalam dari versi 32-bit program, meretasnya hingga 64 bit, mengutak-atik register untuk menjadikannya sebagai pengganti loop dalam versi 64-bit. Kode ini juga berjalan secepat versi 32-bit.
Kesimpulan saya adalah bahwa ini adalah penjadwalan instruksi yang buruk oleh kompiler, bukan keuntungan kecepatan / latensi aktual dari instruksi 32-bit.
(Peringatan: Saya meretas perakitan, bisa merusak sesuatu tanpa memperhatikan. Saya kira tidak.)
sumber
sizeof(uint_fast32_t)
harus didefinisikan. Jika Anda tidak mengizinkannya, Anda bisa melakukan tipu daya itu, tetapi itu hanya bisa dilakukan dengan ekstensi kompiler.Ini bukan jawaban, tetapi sulit dibaca jika saya memberikan hasil dalam komentar.
Saya mendapatkan hasil ini dengan Mac Pro ( Westmere 6-Cores Xeon 3,33 GHz). Saya mengompilasinya dengan
clang -O3 -msse4 -lstdc++ a.cpp -o a
(-O2 mendapatkan hasil yang sama).berdenting dengan
uint64_t size=atol(argv[1])<<20;
berdenting dengan
uint64_t size=1<<20;
Saya juga mencoba untuk:
for
pernyataan secara terbalik:for (uint64_t i=size/8;i>0;i-=4)
. Ini memberikan hasil yang sama dan membuktikan kompilasi cukup pintar untuk tidak membagi ukuran dengan 8 setiap iterasi (seperti yang diharapkan).Ini tebakan liar saya:
Faktor kecepatan terdiri dari tiga bagian:
cache kode:
uint64_t
versi memiliki ukuran kode lebih besar, tetapi ini tidak berpengaruh pada CPU Xeon saya. Ini membuat versi 64-bit lebih lambat.Instruksi digunakan. Perhatikan tidak hanya hitungan loop, tetapi buffer diakses dengan indeks 32-bit dan 64-bit pada dua versi. Mengakses pointer dengan offset 64-bit meminta register dan pengalamatan 64-bit khusus, sementara Anda dapat menggunakan langsung untuk offset 32-bit. Ini dapat membuat versi 32-bit lebih cepat.
Instruksi hanya dipancarkan pada kompilasi 64-bit (yaitu, prefetch). Ini membuat 64-bit lebih cepat.
Tiga faktor bersama-sama cocok dengan hasil yang tampaknya bertentangan.
sumber
12.9
dan16.8
, jadiunsigned
lebih cepat di sini. Dalam tolok ukur saya, yang terjadi adalah sebaliknya, yaitu 26 untukunsigned
, 15 untukuint64_t
Saya tidak bisa memberikan jawaban resmi, tetapi memberikan ikhtisar tentang kemungkinan penyebabnya. Referensi ini menunjukkan dengan cukup jelas bahwa untuk instruksi di badan loop Anda ada rasio 3: 1 antara latensi dan throughput. Ini juga menunjukkan efek dari beberapa pengiriman. Karena ada (beri-atau-ambil) tiga unit integer dalam prosesor x86 modern, umumnya memungkinkan untuk mengirim tiga instruksi per siklus.
Jadi antara pipa puncak dan kinerja banyak pengiriman dan kegagalan mekanisme ini, kami memiliki faktor enam dalam kinerja. Sudah cukup dikenal bahwa kompleksitas set instruksi x86 membuatnya cukup mudah untuk kerusakan yang unik terjadi. Dokumen di atas memiliki contoh yang bagus:
Saya pribadi mengalami kasus aneh di mana loop panas berjalan jauh lebih lambat pada inti spesifik dari chip empat-inti (AMD jika saya ingat). Kami benar-benar mendapatkan kinerja yang lebih baik pada perhitungan pengurangan peta dengan mematikan inti itu.
Di sini dugaan saya adalah pertikaian untuk unit integer: bahwa
popcnt
penghitung loop, penghitungan, dan alamat semua bisa saja berjalan dengan kecepatan penuh dengan penghitung lebar 32-bit, tetapi penghitung 64-bit menyebabkan pertengkaran dan warung pipa. Karena hanya ada sekitar 12 siklus total, berpotensi 4 siklus dengan banyak pengiriman, per eksekusi body loop, satu warung dapat memengaruhi waktu proses dengan faktor 2.Perubahan yang disebabkan oleh menggunakan variabel statis, yang saya duga hanya menyebabkan pengubahan urutan instruksi, adalah petunjuk lain bahwa kode 32-bit berada pada titik kritis untuk pertikaian.
Saya tahu ini bukan analisis yang ketat, tetapi ini adalah penjelasan yang masuk akal.
sumber
Saya mencoba ini dengan Visual Studio 2013 Express , menggunakan pointer bukan indeks, yang mempercepat prosesnya sedikit. Saya menduga ini karena pengalamatannya adalah offset + register, alih-alih offset + register + (register << 3). Kode C ++.
kode perakitan: r10 = bfrptr, r15 = bfrend, rsi = hitung, rdi = buffer, r13 = k:
sumber
Sudahkah Anda mencoba meneruskan
-funroll-loops -fprefetch-loop-arrays
ke GCC?Saya mendapatkan hasil berikut dengan optimasi tambahan ini:
sumber
Sudahkah Anda mencoba memindahkan langkah reduksi di luar loop? Saat ini Anda memiliki ketergantungan data yang sebenarnya tidak diperlukan.
Mencoba:
Anda juga memiliki aliasing aneh yang terjadi, yang saya tidak yakin sesuai dengan aturan aliasing yang ketat.
sumber
void*
danchar*
adalah dua jenis yang dapat disebut, karena mereka pada dasarnya dianggap "pointer ke beberapa memori"! Gagasan Anda tentang penghapusan ketergantungan data bagus untuk optimasi, tetapi itu tidak menjawab pertanyaan. Dan, seperti yang dikatakan @NilsPipenbrinck, sepertinya tidak mengubah apa pun.char*
untuk mengakses aT[]
. Anda tidak dapat menggunakan aT*
dengan aman untuk mengakseschar[]
, dan kode Anda tampaknya melakukan yang terakhir.malloc
array apa pun, seperti malloc kembalivoid*
dan Anda menafsirkannya sebagaiT[]
. Dan saya cukup yakin ituvoid*
danchar*
memiliki semantik yang sama tentang aliasing yang ketat. Namun, saya kira ini cukup offtopic di sini :)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 */
TL; DR: Gunakan
__builtin
intrinsik sebagai gantinya; mereka mungkin akan membantu.Saya dapat membuat
gcc
4.8.4 (dan bahkan 4.7.3 di gcc.godbolt.org) menghasilkan kode optimal untuk ini dengan menggunakan__builtin_popcountll
yang menggunakan instruksi perakitan yang sama, tetapi beruntung dan kebetulan membuat kode yang tidak memiliki yang tidak terduga ketergantungan ketergantungan loop panjang karena bug ketergantungan palsu.Saya tidak 100% yakin akan kode tolok ukur saya, tetapi
objdump
output tampaknya berbagi pandangan saya. Saya menggunakan beberapa trik lain (++i
vsi++
) untuk membuat kompilator membuka gulungan untuk saya tanpamovl
instruksi (perilaku aneh, saya harus mengatakan).Hasil:
Kode pembandingan:
Opsi kompilasi:
Versi GCC:
Versi kernel Linux:
Informasi CPU:
sumber
-funroll-loops
kebetulan membuat kode yang tidak macet pada rantai ketergantungan loop-carry yang dibuat olehpopcnt
dep palsu. Menggunakan versi kompiler lama yang tidak tahu tentang ketergantungan salah adalah risiko. Tanpa-funroll-loops
, loop gcc 4.8.5 akan mengalami hambatan pada popcnt latency alih-alih throughput, karena itu diperhitungkanrdx
. Kode yang sama, dikompilasi oleh gcc 4.9.3 menambahkan sebuahxor edx,edx
untuk memutus rantai ketergantungan.x86intrin.h
's_mm_popcnt_*
fungsi pada GCC secara paksa inline pembungkus sekitar__builtin_popcount*
; inlining seharusnya membuat satu persis sama dengan yang lain. Saya sangat ragu Anda akan melihat perbedaan yang dapat disebabkan oleh beralih di antara mereka.Pertama-tama, coba perkirakan kinerja puncak - periksa https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf , khususnya, Lampiran C.
Dalam kasus Anda, tabel C-10 yang menunjukkan instruksi POPCNT memiliki latensi = 3 jam dan throughput = 1 jam. Throughput menunjukkan laju maksimal Anda dalam jam (kalikan dengan frekuensi inti dan 8 byte jika menggunakan popcnt64 untuk mendapatkan jumlah bandwidth terbaik).
Sekarang periksa apa yang dikompilasi oleh kompiler dan jumlah semua instruksi lain dalam loop. Ini akan memberikan estimasi terbaik untuk kode yang dihasilkan.
Akhirnya, lihat dependensi data antara instruksi dalam loop karena mereka akan memaksa latensi-besar bukannya throughput - jadi pisahkan instruksi iterasi tunggal pada rantai aliran data dan hitung latensi di antara mereka lalu dengan naif mengambil maksimal dari mereka. itu akan memberikan perkiraan kasar dengan mempertimbangkan ketergantungan aliran data akun.
Namun, dalam kasus Anda, hanya menulis kode dengan cara yang benar akan menghilangkan semua kerumitan ini. Alih-alih mengumpulkan ke variabel jumlah yang sama, hanya mengakumulasikan ke yang berbeda (seperti count0, count1, ... count8) dan jumlahkan mereka di akhir. Atau bahkan membuat array jumlah [8] dan terakumulasi ke elemen-elemennya - mungkin, itu akan di-vektor bahkan dan Anda akan mendapatkan hasil yang jauh lebih baik.
PS dan jangan pernah menjalankan benchmark selama satu detik, pertama pemanasan inti kemudian jalankan loop setidaknya 10 detik atau lebih baik 100 detik. jika tidak, Anda akan menguji firmware manajemen daya dan implementasi DVFS di perangkat keras :)
PPS Saya mendengar debat tanpa henti tentang berapa banyak waktu yang harus benar-benar dijalankan oleh benchmark. Kebanyakan orang paling pintar bahkan bertanya mengapa 10 detik bukan 11 atau 12. Saya harus mengakui ini lucu secara teori. Dalam praktiknya, Anda cukup menjalankan dan menjalankan tolok ukur ratusan kali berturut-turut dan mencatat penyimpangan. itu IS lucu. Kebanyakan orang mengubah sumber dan menjalankan bangku setelah itu SEKALI untuk menangkap catatan kinerja baru. Lakukan hal yang benar dengan benar.
Masih tidak yakin? Cukup gunakan benchmark C-versi di atas dengan assp1r1n3 ( https://stackoverflow.com/a/37026212/9706746 ) dan coba 100 bukannya 10.000 di coba lagi loop.
7960X saya menunjukkan, dengan RETRY = 100:
Count: 203182300 Sudah Berlalu: 0,008385 detik Kecepatan: 12,55379 GB / s
Count: 203182300 Sudah berlalu: Kecepatan 0,011063 detik: 9,478225 GB / s
Count: 203182300 Sudah berlalu: 0,011188 detik Kecepatan: 9.372327 GB / s
Count: 203182300 Sudah berlalu: 0,010393 detik Kecepatan: 10,089252 GB / s
Count: 203182300 Sudah berlalu: 0,009076 detik Kecepatan: 11,553283 GB / s
dengan RETRY = 10000:
Count: 20318230000 Sudah berlalu: 0,661791 detik Kecepatan: 15,844519 GB / s
Count: 20318230000 Berlalu: 0,665422 detik Kecepatan: 15.758060 GB / s
Count: 20318230000 Sudah berlalu: 0,660983 detik Kecepatan: 15,863888 GB / s
Count: 20318230000 Sudah berlalu: 0,665337 detik Kecepatan: 15.760073 GB / s
Count: 20318230000 Sudah berlalu: 0,662138 detik Kecepatan: 15,836215 GB / s
PPPS Akhirnya, pada "jawaban yang diterima" dan misteri lainnya ;-)
Mari kita gunakan jawaban assp1r1n3 - dia memiliki inti 2.5Ghz. POPCNT memiliki 1 jam throuhgput, kodenya menggunakan popcnt 64-bit. Jadi matematika adalah 2.5Ghz * 1 jam * 8 byte = 20 GB / s untuk pengaturannya. Dia melihat 25Gb / s, mungkin karena turbo boost sekitar 3Ghz.
Maka pergilah ke ark.intel.com dan cari i7-4870HQ: https://ark.intel.com/products/83504/Intel-Core-i7-4870HQ-Processor-6M-Cache-up-to-3-70 -GHz-? Q = i7-4870HQ
Inti itu bisa berjalan hingga 3.7Ghz dan kecepatan maksimal nyata adalah 29,6 GB / s untuk perangkat kerasnya. Jadi di mana 4GB / s lainnya? Mungkin, itu dihabiskan untuk logika loop dan kode sekitarnya lainnya dalam setiap iterasi.
Sekarang dimana ketergantungan salah ini? perangkat keras berjalan pada tingkat yang hampir puncak. Mungkin matematika saya buruk, kadang-kadang terjadi :)
PPPPPS Masih orang menyarankan errata HW adalah pelakunya, jadi saya mengikuti saran dan membuat inline sebagai contoh, lihat di bawah.
Pada 7960X saya, versi pertama (dengan output tunggal ke cnt0) berjalan pada 11MB / s, versi kedua (dengan output ke cnt0, cnt1, cnt2 dan cnt3) berjalan pada 33MB / s. Dan orang bisa mengatakan - voila! itu ketergantungan output.
OK, mungkin, poin yang saya buat adalah bahwa tidak masuk akal untuk menulis kode seperti ini dan itu bukan masalah ketergantungan output tetapi pembuatan kode bodoh. Kami tidak menguji perangkat keras, kami menulis kode untuk melepaskan kinerja maksimal. Anda dapat berharap bahwa HW OOO harus mengganti nama dan menyembunyikan "dependensi-keluaran" itu tetapi, astaga, lakukan saja hal yang benar dan Anda tidak akan pernah menghadapi misteri apa pun.
sumber
__builtin_popcountl
dengan AVX2vpshufb
, dan tidak perlu beberapa akumulator di sumber C untuk melakukannya. Saya tidak yakin tentang_mm_popcnt_u64
; yang mungkin hanya melakukan auto-vektor dengan AVX512-VPOPCNT. (Lihat Menghitung 1 bit (jumlah populasi) pada data besar menggunakan AVX-512 atau AVX-2 /)popcnt
. Ini didokumentasikan dalam errata Intel untuk beberapa mikroarsitektur terbaru mereka, tapi saya pikir tidak pada saat itu. Analisis dep-chain Anda akan gagal jika ada dependensi salah yang tidak terduga, jadi jawaban ini adalah saran umum yang baik tetapi tidak berlaku di sini.lzcnt
/tzcnt
, tetapi tidak untukpopcnt
. Lihat Intel erratum SKL029 di intel.com/content/dam/www/public/us/en/documents/… . Juga, gcc.gnu.org/bugzilla/show_bug.cgi?id=62011 adalah "diselesaikan tetap", bukan "tidak valid". Tidak ada dasar untuk klaim Anda bahwa tidak ada ketergantungan output di HW.popcnt eax, edx
/dec ecx / jnz
, Anda akan mengharapkannya berjalan pada 1 per jam, bottlenecked pada throughput popcnt dan throughput cabang yang diambil. Tapi itu sebenarnya hanya berjalan pada 1 per 3 jam bottlenecked padapopcnt
latency untuk berulang kali menimpa EAX, meskipun Anda akan berharap itu hanya untuk menulis. Anda memiliki Skylake, sehingga Anda dapat mencobanya sendiri.Ok, saya ingin memberikan jawaban kecil untuk salah satu sub-pertanyaan yang ditanyakan OP yang sepertinya tidak dibahas dalam pertanyaan yang ada. Peringatan, saya belum melakukan pengujian atau pembuatan kode, atau pembongkaran, hanya ingin berbagi pemikiran untuk orang lain untuk mungkin menguraikan.
Mengapa
static
perubahan kinerja?Baris yang dimaksud:
uint64_t size = atol(argv[1])<<20;
Jawaban singkat
Saya akan melihat perakitan yang dihasilkan untuk mengakses
size
dan melihat apakah ada langkah-langkah tambahan dari penunjuk arah terlibat untuk versi non-statis.Jawaban panjang
Karena hanya ada satu salinan variabel apakah itu dinyatakan
static
atau tidak, dan ukurannya tidak berubah, saya berteori bahwa perbedaannya adalah lokasi memori yang digunakan untuk mendukung variabel bersama dengan tempat variabel itu digunakan dalam kode lebih lanjut turun.Ok, untuk memulai dengan yang jelas, ingat bahwa semua variabel lokal (bersama dengan parameter) dari suatu fungsi disediakan ruang pada stack untuk digunakan sebagai penyimpanan. Sekarang, jelas, bingkai tumpukan untuk main () tidak pernah dibersihkan dan hanya dihasilkan sekali. Ok, bagaimana kalau membuatnya
static
? Nah, dalam hal ini kompiler tahu untuk memesan ruang dalam ruang data global dari proses sehingga lokasi tidak dapat dihapus dengan penghapusan bingkai tumpukan. Tapi tetap saja, kita hanya punya satu lokasi jadi apa bedanya? Saya menduga itu ada hubungannya dengan bagaimana lokasi memori pada stack direferensikan.Ketika kompiler membuat tabel simbol, itu hanya membuat entri untuk label bersama dengan atribut yang relevan, seperti ukuran, dll. Ia tahu bahwa ia harus menyimpan ruang yang sesuai dalam memori tetapi tidak benar-benar memilih lokasi itu sampai agak kemudian di proses setelah melakukan analisis liveness dan mungkin mendaftar alokasi. Lalu bagaimana tautan dapat mengetahui alamat apa yang akan diberikan ke kode mesin untuk kode perakitan akhir? Entah tahu lokasi akhir atau tahu bagaimana tiba di lokasi. Dengan tumpukan, cukup mudah untuk merujuk ke lokasi berdasarkan satu dua elemen, penunjuk ke stackframe dan kemudian offset ke dalam bingkai. Ini pada dasarnya karena linker tidak dapat mengetahui lokasi stackframe sebelum runtime.
sumber
static
terjadi untuk mengubah alokasi register untuk fungsi dengan cara yang mempengaruhi ketergantungan output salahpopcnt
pada CPU Intel yang diuji OP, dengan kompiler yang tidak tahu untuk menghindarinya. (Karena lubang kinerja dalam CPU Intel ini belum ditemukan.) Sebuah kompiler dapat menyimpanstatic
variabel lokal dalam register, seperti variabel penyimpanan otomatis, tetapi jika mereka tidak optimal dengan asumsimain
hanya berjalan sekali, maka itu akan mempengaruhi code-gen (karena nilainya ditentukan oleh panggilan pertama saja.)[RIP + rel32]
dan[rsp + 42]
mode pengalamatan cukup diabaikan untuk sebagian besar kasus.cmp dword [RIP+rel32], immediate
tidak dapat melebur mikro menjadi satu beban + cmp uop, tapi saya tidak berpikir itu akan menjadi faktor. Seperti yang saya katakan, di dalam loop mungkin tetap dalam register, tetapi mengutak-atik C ++ dapat berarti pilihan kompiler yang berbeda.