सारांश : 240 से नीचे, एलएलवीएम पूरी तरह से आंतरिक लूप को अनियंत्रित करता है और इसकी सूचना देता है कि यह दोहराव लूप को दूर कर सकता है, आपके बेंचमार्क को तोड़ सकता है।
आपको एक जादुई दहलीज मिली जिसके ऊपर LLVM कुछ अनुकूलन करना बंद कर देता है । दहलीज 8 बाइट्स है * 240 = 1920 बाइट्स (आपकी सरणी एस की एक सरणी है usize
, इसलिए लंबाई 8 बाइट्स के साथ गुणा की जाती है, x86-64 सीपीयू मानकर)। इस बेंचमार्क में, एक विशिष्ट अनुकूलन - केवल लंबाई 239 के लिए प्रदर्शन किया गया - विशाल गति अंतर के लिए जिम्मेदार है। लेकिन चलो धीरे-धीरे शुरू करें:
(इस उत्तर में सभी कोड संकलित हैं -C opt-level=3
)
pub fn foo() -> usize {
let arr = [0; 240];
let mut s = 0;
for i in 0..arr.len() {
s += arr[i];
}
s
}
यह सरल कोड मोटे तौर पर असेंबली का उत्पादन करेगा जो एक उम्मीद करेगा: तत्वों को जोड़ने वाला एक लूप। हालाँकि, यदि आप बदलते 240
हैं 239
, तो उत्सर्जित विधानसभा काफी भिन्न होती है। इसे Godbolt Compiler Explorer पर देखें । यहाँ विधानसभा का एक छोटा सा हिस्सा है:
movdqa xmm1, xmmword ptr [rsp + 32]
movdqa xmm0, xmmword ptr [rsp + 48]
paddq xmm1, xmmword ptr [rsp]
paddq xmm0, xmmword ptr [rsp + 16]
paddq xmm1, xmmword ptr [rsp + 64]
; more stuff omitted here ...
paddq xmm0, xmmword ptr [rsp + 1840]
paddq xmm1, xmmword ptr [rsp + 1856]
paddq xmm0, xmmword ptr [rsp + 1872]
paddq xmm0, xmm1
pshufd xmm1, xmm0, 78
paddq xmm1, xmm0
इसे लूप अनरोलिंग कहा जाता है : एलएलवीएम लूप बॉडी को उन सभी "लूप मैनेजमेंट निर्देशों" को निष्पादित करने से बचने के लिए समय का एक गुच्छा चिपकाता है, अर्थात लूप वेरिएबल को बढ़ाता है, जांचें कि क्या लूप समाप्त हो गया है और लूप की शुरुआत में कूद गया है ।
मामले में आप सोच रहे हैं: paddq
और इसी तरह के निर्देश SIMD निर्देश हैं जो समानांतर में कई मूल्यों को जोड़ते हैं। इसके अलावा, दो 16-बाइट SIMD रजिस्टर ( xmm0
और xmm1
) समानांतर में उपयोग किए जाते हैं ताकि सीपीयू के अनुदेश-स्तरीय समानांतरवाद मूल रूप से एक ही समय में इनमें से दो निर्देशों को निष्पादित कर सकें। आखिरकार, वे एक दूसरे से स्वतंत्र होते हैं। अंत में, दोनों रजिस्टरों को एक साथ जोड़ दिया जाता है और फिर क्षैतिज रूप से स्केलर परिणाम के लिए संक्षेप में प्रस्तुत किया जाता है।
आधुनिक मुख्यधारा x86 सीपीयू (कम-शक्ति एटम नहीं) वास्तव में एल 1 डी कैश में हिट होने पर प्रति घड़ी 2 वेक्टर लोड कर सकते हैं और paddq
अधिकांश सीपीयू पर 1 चक्र विलंबता के साथ थ्रूपुट भी कम से कम 2 प्रति घड़ी है। Https://agner.org/optimize/ देखें और इसके बजाय इस Q & A में मल्टीपल जमा करने वालों के बारे में (एन डॉट प्रोडक्ट के लिए एफपी एफएमए) छिपाने के लिए और थ्रूपुट के बजाय अड़चन पर।
LLVM उतारना छोटे छोरों करता कुछ है जब यह नहीं है पूरी तरह से unrolling, और अभी भी कई एक्युमुलेटरों उपयोग करता है। इसलिए, आम तौर पर, फ्रंट-एंड बैंडविड्थ और बैक-एंड लेटेंसी अड़चनें एलएलवीएम-जनरेट किए गए लूप के लिए पूरी तरह से अनियंत्रित हुए बिना एक बड़ी समस्या नहीं हैं।
लेकिन लूप अनरोलिंग फैक्टर 80 के प्रदर्शन अंतर के लिए ज़िम्मेदार नहीं है! कम से कम अकेले अनियंत्रित लूप नहीं। आइए वास्तविक बेंचमार्किंग कोड पर एक नज़र डालें, जो एक लूप को दूसरे के अंदर रखता है:
const CAPACITY: usize = 239;
const IN_LOOPS: usize = 500000;
pub fn foo() -> usize {
let mut arr = [0; CAPACITY];
for i in 0..CAPACITY {
arr[i] = i;
}
let mut sum = 0;
for _ in 0..IN_LOOPS {
let mut s = 0;
for i in 0..arr.len() {
s += arr[i];
}
sum += s;
}
sum
}
( गॉडबॉल्ट कंपाइलर एक्सप्लोरर पर )
विधानसभा CAPACITY = 240
सामान्य दिखती है: दो नेस्टेड लूप। (फंक्शन की शुरुआत में सिर्फ इनिशियलाइज़ करने के लिए कुछ कोड होते हैं, जिन्हें हम नज़रअंदाज़ कर देते हैं।) 239 के लिए, हालाँकि, यह बहुत अलग दिखता है! हम देखते हैं कि प्रारंभिक लूप और आंतरिक लूप अनियंत्रित हो गए: अभी तक अपेक्षित है।
महत्वपूर्ण अंतर यह है कि 239 के लिए, एलएलवीएम यह पता लगाने में सक्षम था कि आंतरिक लूप का परिणाम बाहरी लूप पर निर्भर नहीं करता है! परिणामस्वरूप, LLVM कोड का उत्सर्जन करता है जो मूल रूप से पहले केवल आंतरिक लूप (राशि की गणना) को निष्पादित करता है और फिर sum
कई बार एक गुच्छा जोड़कर बाहरी लूप का अनुकरण करता है !
पहले हम ऊपर की तरह लगभग एक ही असेंबली (आंतरिक लूप का प्रतिनिधित्व करने वाली विधानसभा) देखते हैं। बाद में हम इसे देखते हैं (मैंने असेंबली को समझाने के लिए टिप्पणी की; टिप्पणी के साथ *
विशेष रूप से महत्वपूर्ण हैं):
; at the start of the function, `rbx` was set to 0
movq rax, xmm1 ; result of SIMD summing up stored in `rax`
add rax, 711 ; add up missing terms from loop unrolling
mov ecx, 500000 ; * init loop variable outer loop
.LBB0_1:
add rbx, rax ; * rbx += rax
add rcx, -1 ; * decrement loop variable
jne .LBB0_1 ; * if loop variable != 0 jump to LBB0_1
mov rax, rbx ; move rbx (the sum) back to rax
; two unimportant instructions omitted
ret ; the return value is stored in `rax`
जैसा कि आप यहां देख सकते हैं, आंतरिक लूप का परिणाम लिया जाता है, ऊपर जोड़ा जाता है जितनी बार बाहरी लूप चलता है और फिर वापस आ जाता है। एलएलवीएम केवल इस अनुकूलन का प्रदर्शन कर सकता है क्योंकि यह समझ गया था कि आंतरिक लूप बाहरी एक से स्वतंत्र है।
इसका मतलब रनटाइम से CAPACITY * IN_LOOPS
होता हैCAPACITY + IN_LOOPS
। और यह विशाल प्रदर्शन अंतर के लिए जिम्मेदार है।
एक अतिरिक्त नोट: क्या आप इस बारे में कुछ कर सकते हैं? ज़रुरी नहीं। LLVM के पास ऐसे जादुई थ्रेसहोल्ड होने चाहिए जैसे उनके बिना LLVM- अनुकूलन कुछ कोड पर पूरा करने के लिए हमेशा के लिए ले सकते हैं। लेकिन हम यह भी मान सकते हैं कि यह कोड अत्यधिक कृत्रिम था। व्यवहार में, मुझे संदेह है कि इतना बड़ा अंतर होगा। पूर्ण लूप के अनियंत्रित होने के कारण का अंतर आमतौर पर इन मामलों में कारक 2 भी नहीं होता है। तो वास्तविक उपयोग के मामलों के बारे में चिंता करने की कोई जरूरत नहीं है।
मुहावरेदार जंग कोड के बारे में अंतिम नोट के रूप में: arr.iter().sum()
एक सरणी के सभी तत्वों को योग करने का एक बेहतर तरीका है। और इसे दूसरे उदाहरण में बदलने से उत्सर्जित विधानसभा में कोई उल्लेखनीय अंतर नहीं होता है। आपको लघु और मुहावरेदार संस्करणों का उपयोग करना चाहिए जब तक कि आपने माप नहीं लिया है कि यह प्रदर्शन को नुकसान पहुंचाता है।