ฉันกำลังมองหาวิธีที่เร็วที่สุดในการจัดpopcount
เก็บข้อมูลขนาดใหญ่ ฉันพบลักษณะพิเศษที่แปลกมาก : การเปลี่ยนตัวแปรลูปจากunsigned
เป็นuint64_t
ทำให้ประสิทธิภาพลดลง 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 เพื่อให้ได้ผลลัพธ์ที่แม่นยำยิ่งขึ้นเราได้ทำ popcount 10,000 ครั้ง เราวัดเวลาสำหรับ popcount ในกรณีที่บนตัวแปรภายในวงเป็นในกรณีที่ต่ำกว่าตัวแปรภายในวงคือunsigned
uint64_t
ฉันคิดว่าสิ่งนี้ไม่ควรสร้างความแตกต่าง แต่อย่างตรงกันข้าม
ผลลัพธ์ (บ้าสุด ๆ )
ฉันรวบรวมมันเช่นนี้ (รุ่น g ++: Ubuntu 4.8.2-19ubuntu1):
g++ -O3 -march=native -std=c++11 test.cpp -o test
นี่คือผลลัพธ์บนHaswell Core i7-4770K CPU ของฉันที่3.50 GHz ที่ใช้งานtest 1
(ดังนั้นข้อมูลสุ่ม 1 MB):
- ไม่ได้ลงนาม 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
ดังนั้นมันเกือบจะเป็นผลลัพธ์เดียวกันและก็ยังแปลก แต่ตอนนี้มันแปลกมาก ฉันแทนที่ขนาดบัฟเฟอร์ที่อ่านจากอินพุตด้วยค่าคงที่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
จึงเปลี่ยนไม่ใช่อย่างต่อเนื่องโดยนำค่าคงที่ไปdeoptimization อย่างจริงจังฉันไม่มีเงื่อนงำสิ่งที่เกิดขึ้นที่นี่! แต่ตอนนี้ไปclang++
กับรุ่นใหม่:
- ไม่ได้ลงนาม 41959360000 0.677009 วินาที 15.4884 GB / s
- uint64_t 41959360000 0.676909 วินาที 15.4906 GB / s
รออะไร? ตอนนี้ทั้งสองเวอร์ชันลดลงเป็นจำนวนช้า 15 GB / s ดังนั้นการแทนที่ค่าคงที่ด้วยค่าคงที่จะทำให้โค้ดช้าลงในทั้งสองกรณีสำหรับ Clang!
ฉันถามเพื่อนร่วมงานกับIvy Bridge CPU เพื่อรวบรวมมาตรฐานของฉัน เขาได้ผลลัพธ์ที่คล้ายกันดังนั้นจึงดูเหมือนจะไม่มีแฮส เนื่องจากคอมไพเลอร์สองตัวสร้างผลลัพธ์แปลก ๆ ที่นี่จึงดูเหมือนจะไม่เป็นข้อผิดพลาดของคอมไพเลอร์ เราไม่มี CPU ของ AMD ที่นี่ดังนั้นเราสามารถทดสอบกับ Intel เท่านั้น
บ้ามากขึ้นได้โปรด!
ใช้ตัวอย่างแรก (อันที่มีatol(argv[1])
) และวางไว้ข้าง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
ยายยังอีกทางเลือกหนึ่ง เรายังมีความเร็ว 26 GB / s ด้วยu32
แต่เราจัดการเพื่อให้ได้u64
อย่างน้อยจาก 13 GB / s ไปเป็นรุ่น 20 GB / s! บนพีซีของเพื่อนร่วมงานของฉันu64
เวอร์ชันกลายเป็นเร็วกว่าu32
เวอร์ชันทำให้ได้ผลลัพธ์ที่เร็วที่สุด น่าเศร้านี้ทำงานเฉพาะสำหรับg++
, ดูเหมือนจะไม่เกี่ยวกับการดูแลclang++
static
คำถามของฉัน
คุณอธิบายผลลัพธ์เหล่านี้ได้ไหม โดยเฉพาะอย่างยิ่ง:
- จะมีความแตกต่างระหว่าง
u32
และu64
อย่างไร - วิธีสามารถแทนที่ non-constant ด้วยขนาดบัฟเฟอร์คงที่ทำให้รหัสที่เหมาะสมน้อยลงได้อย่างไร
- การแทรก
static
คำหลักจะทำให้u64
วนซ้ำเร็วขึ้นได้อย่างไร ยิ่งเร็วกว่ารหัสต้นฉบับในคอมพิวเตอร์ของเพื่อนร่วมงานของฉัน!
ฉันรู้ว่าการปรับให้เหมาะสมนั้นเป็นดินแดนที่มีเล่ห์เหลี่ยม แต่ฉันไม่เคยคิดเลยว่าการเปลี่ยนแปลงเล็ก ๆ เหล่านี้สามารถนำไปสู่ความแตกต่างในเวลาดำเนินการ100%และปัจจัยเล็ก ๆ เช่นขนาดบัฟเฟอร์คงที่สามารถผสมผลลัพธ์ทั้งหมดอีกครั้ง แน่นอนฉันต้องการมีรุ่นที่สามารถ popcount 26 GB / s ได้เสมอ วิธีเดียวที่เชื่อถือได้ที่ฉันคิดได้คือคัดลอกวางชุดประกอบสำหรับกรณีนี้และใช้ชุดประกอบแบบอินไลน์ นี่เป็นวิธีเดียวที่ฉันสามารถกำจัดคอมไพเลอร์ที่ดูเหมือนจะคลั่งไคล้กับการเปลี่ยนแปลงเล็กน้อย คุณคิดอย่างไร? มีวิธีอื่นในการรับรหัสที่มีประสิทธิภาพสูงสุดอย่างน่าเชื่อถือหรือไม่
การถอดประกอบ
นี่คือการถอดชิ้นส่วนสำหรับผลลัพธ์ต่าง ๆ :
รุ่น 26 GB / s จากg ++ / u32 / ขนาดที่ไม่คงที่ :
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
รุ่น 13 GB / s จากg ++ / u64 / ขนาดที่ไม่คงที่ :
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
รุ่น 15 GB / s จากclang ++ / u64 / bufsize ที่ไม่ใช่ const :
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
รุ่น 20 GB / s จาก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
รุ่น 15 GB / s จากclang ++ / 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
น่าสนใจรุ่นที่เร็วที่สุด (26 GB / s) ก็ยาวที่สุดเช่นกัน! lea
มันน่าจะเป็นเพียงการแก้ปัญหาที่ใช้ บางรุ่นใช้ที่จะข้ามไปที่คนอื่นใช้jb
jne
แต่นอกเหนือจากนั้นทุกรุ่นดูเหมือนจะเทียบเคียง ฉันไม่เห็นว่าช่องว่างของประสิทธิภาพ 100% นั้นมาจากไหน แต่ฉันไม่เชี่ยวชาญในการถอดรหัสการชุมนุม รุ่นที่ช้าที่สุด (13 GB / s) จะดูสั้นและดีมาก มีใครอธิบายเรื่องนี้ได้บ้าง
บทเรียนที่ได้เรียนรู้
ไม่ว่าคำตอบสำหรับคำถามนี้จะเป็นอย่างไร ฉันได้เรียนรู้ว่าในลูปร้อนจริงๆทุกรายละเอียดสามารถเรื่องรายละเอียดแม้กระทั่งที่ไม่ได้ดูเหมือนจะมีการเชื่อมโยงไปยังรหัสร้อนใด ๆ ฉันไม่เคยคิดเกี่ยวกับประเภทของการใช้สำหรับตัวแปรลูป แต่อย่างที่คุณเห็นการเปลี่ยนแปลงเล็กน้อยสามารถสร้างความแตกต่างได้100% ! แม้แต่ประเภทการจัดเก็บของบัฟเฟอร์ก็สามารถสร้างความแตกต่างได้อย่างมากดังที่เราเห็นด้วยการแทรกstatic
คำหลักไว้ด้านหน้าตัวแปรขนาด! ในอนาคตฉันจะทดสอบทางเลือกที่หลากหลายในคอมไพเลอร์ต่าง ๆ เสมอเมื่อเขียนลูปที่แน่นและร้อนแรงซึ่งมีความสำคัญต่อประสิทธิภาพของระบบ
สิ่งที่น่าสนใจก็คือความแตกต่างของประสิทธิภาพการทำงานยังคงสูงแม้ว่าฉันจะคลี่วงวนสี่ครั้งไปแล้ว ดังนั้นแม้ว่าคุณจะเลิกเปิดใช้งานคุณก็ยังสามารถได้รับผลกระทบจากการเบี่ยงเบนที่สำคัญ น่าสนใจทีเดียว