मैं popcount
डेटा के बड़े सरणियों का सबसे तेज़ तरीका ढूंढ रहा था । मुझे एक बहुत ही अजीब प्रभाव का सामना करना पड़ा : मेरे पीसी पर प्रदर्शन ड्रॉप को 50% unsigned
तक uint64_t
बनाने के लिए लूप चर को बदलना ।
बेंचमार्क
#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
कमांड लाइन से पढ़ा जाता है। बाद में, हम बफर पर पुनरावृति popcount
करते हैं और पॉपकाउंट करने के लिए x86 आंतरिक के अनियंत्रित संस्करण का उपयोग करते हैं। अधिक सटीक परिणाम प्राप्त करने के लिए, हम 10,000 बार पॉपकाउंट करते हैं। हम पॉपकाउंट के लिए समय को मापते हैं। ऊपरी मामले में, आंतरिक लूप चर है unsigned
, निचले मामले में, आंतरिक लूप चर हैuint64_t
। मैंने सोचा था कि इससे कोई फर्क नहीं पड़ना चाहिए, लेकिन मामला इससे उलट है।
(बिल्कुल पागल) परिणाम
मैं इसे इस तरह संकलित करता हूं (g ++ संस्करण: Ubuntu 4.8.2-19ubuntu1):
g++ -O3 -march=native -std=c++11 test.cpp -o test
यहां मेरे हैसवेल कोर i7-4770K CPU @ 3.50 GHz पर परिणाम चल रहे हैं, test 1
(इसलिए 1 एमबी रैंडम डेटा):
- अहस्ताक्षरित 41959360000 0.401554 सेकंड 26.113 GB / s
- uint64_t 41959360000 0.759822 सेकेंड 13.8003 GB / s
जैसा कि आप देखते हैं, uint64_t
संस्करण का थ्रूपुट केवल संस्करण का आधा हिस्सा है unsigned
! समस्या यह प्रतीत होती है कि अलग-अलग विधानसभा उत्पन्न होती है, लेकिन क्यों? सबसे पहले, मैं एक संकलक बग के बारे में सोचा है, इसलिए मैं करने की कोशिश की 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 जीबी / एस की धीमी संख्या में गिर गए । इस प्रकार, एक स्थिर मान द्वारा गैर-स्थिरांक को बदलने पर भी दोनों में धीमा कोड होता है क्लैंग के लिए मामलों !
मैंने अपने बेंचमार्क को संकलित करने के लिए एक आइवी ब्रिज सीपीयू के साथ एक सहयोगी से पूछा । उसे समान परिणाम मिले, इसलिए यह हसवेल नहीं लगता है। क्योंकि दो संकलक यहाँ अजीब परिणाम उत्पन्न करते हैं, यह भी संकलक बग नहीं लगता है। हमारे यहां एएमडी सीपीयू नहीं है, इसलिए हम केवल इंटेल के साथ परीक्षण कर सकते हैं।
अधिक पागलपन, कृपया!
पहला उदाहरण लें (एक के साथ atol(argv[1])
) और static
चर से पहले एक , यानी:
static uint64_t size=atol(argv[1])<<20;
यहाँ जी ++ में मेरे परिणाम हैं:
- अहस्ताक्षरित 41959360000 0.396728 सेकंड 26.4306 GB / s
- uint64_t 41959360000 0.509484 सेकंड 20.5811 GB / s
हां, फिर भी एक और विकल्प । हमारे पास अभी भी तेज 26 जीबी / एस है u32
, लेकिन हम u64
कम से कम 13 जीबी / एस से 20 जीबी / एस संस्करण तक पहुंचने में कामयाब रहे ! मेरे सहयोगी के पीसी पर, u64
संस्करण सभी की तुलना u32
में सबसे तेज परिणाम प्राप्त करते हुए, संस्करण से भी तेज हो गया । अफसोस की बात है, यह केवल के लिए काम करता है g++
, के clang++
बारे में परवाह नहीं हैstatic
।
मेरा प्रश्न
क्या आप इन परिणामों की व्याख्या कर सकते हैं? विशेष रूप से:
- कैसे
u32
और के बीच इतना अंतर हो सकता हैu64
? - एक निरंतर बफर आकार द्वारा एक गैर-स्थिरांक को कम इष्टतम कोड कैसे बदला जा सकता है ?
static
कीवर्ड का सम्मिलनu64
लूप को कैसे तेज बना सकता है? मेरे कॉलगर्ल के कंप्यूटर पर मूल कोड से भी तेज!
मुझे पता है कि अनुकूलन एक मुश्किल क्षेत्र है, हालांकि, मैंने कभी नहीं सोचा था कि इस तरह के छोटे बदलाव 100% अंतर पैदा कर सकते हैं से निष्पादन समय में हो सकता है और यह कि एक निरंतर बफर आकार जैसे छोटे कारक फिर से पूरी तरह से परिणाम मिला सकते हैं। बेशक, मैं हमेशा वह संस्करण चाहता हूं जो 26 जीबी / एस को पॉपकॉर्न करने में सक्षम हो। एकमात्र विश्वसनीय तरीका जो मैं सोच सकता हूं कि इस मामले के लिए विधानसभा को कॉपी पेस्ट करें और इनलाइन विधानसभा का उपयोग करें। यह एकमात्र तरीका है जिससे मैं उन कंपाइलरों से छुटकारा पा सकता हूं जो छोटे परिवर्तनों पर पागल होने लगते हैं। तुम क्या सोचते हो? क्या अधिकांश प्रदर्शन के साथ कोड को मज़बूती से प्राप्त करने का एक और तरीका है?
द डिस्सैड
यहाँ विभिन्न परिणामों के लिए disassembly है:
G ++ / u32 / non-const bufsize से 26 GB / 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 से 13 GB / 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 से 15 GB / 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 से 20 GB / 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 से 15 GB / 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
दिलचस्प है, सबसे तेज़ (26 जीबी / एस) संस्करण भी सबसे लंबा है! यह एकमात्र समाधान लगता है जो उपयोग करता है lea
। कुछ संस्करण jb
कूदने के लिए उपयोग करते हैं, अन्य उपयोग करते हैं jne
। लेकिन इसके अलावा, सभी संस्करण तुलनीय प्रतीत होते हैं। मुझे नहीं लगता कि 100% प्रदर्शन अंतर कहां से उत्पन्न हो सकता है, लेकिन मैं विधानसभा में निपुण नहीं हूं। सबसे धीमा (13 GB / s) संस्करण बहुत छोटा और अच्छा लग रहा है। क्या कोई इसे समझा सकता है?
सीख सीखी
कोई फर्क नहीं पड़ता कि इस सवाल का जवाब क्या होगा; मैंने सीखा है कि वास्तव में गर्म छोरों में हर विस्तार से कोई फर्क पड़ सकता है, यहां तक कि यह विवरण भी कि गर्म कोड के लिए कोई संबंध नहीं है । मैंने कभी नहीं सोचा है कि लूप वेरिएबल के लिए किस प्रकार का उपयोग करना है, लेकिन जैसा कि आप देखते हैं कि ऐसा मामूली बदलाव 100% अंतर ला सकता है! यहां तक कि बफर का भंडारण प्रकार भी बहुत बड़ा अंतर बना सकता है, जैसा कि हमने static
आकार चर के सामने कीवर्ड के सम्मिलन के साथ देखा था ! भविष्य में, मैं हमेशा विभिन्न कंपाइलरों पर विभिन्न विकल्पों का परीक्षण करूंगा जब वास्तव में तंग और गर्म छोरों को लिखना होगा जो सिस्टम प्रदर्शन के लिए महत्वपूर्ण हैं।
दिलचस्प बात यह भी है कि प्रदर्शन का अंतर अभी भी इतना अधिक है, हालांकि मैं पहले ही चार बार लूप को अनियंत्रित कर चुका हूं। इसलिए यदि आप अनियंत्रित होते हैं, तब भी आप प्रमुख प्रदर्शन विचलन की चपेट में आ सकते हैं। काफी दिलचस्प।