240 या अधिक तत्वों के साथ एक सरणी पर लूपिंग करते समय एक बड़ा प्रदर्शन प्रभाव क्यों होता है?


230

जब Rust में एक सरणी पर एक योग लूप चल रहा है, तो मैंने एक बड़ा प्रदर्शन ड्रॉप देखा जब CAPACITY> = 240. CAPACITY= 239 लगभग 80 गुना तेज है।

क्या विशेष संकलन अनुकूलन रस्ट "लघु" सरणियों के लिए कर रहा है?

के साथ संकलित किया rustc -C opt-level=3

use std::time::Instant;

const CAPACITY: usize = 240;
const IN_LOOPS: usize = 500000;

fn main() {
    let mut arr = [0; CAPACITY];
    for i in 0..CAPACITY {
        arr[i] = i;
    }
    let mut sum = 0;
    let now = Instant::now();
    for _ in 0..IN_LOOPS {
        let mut s = 0;
        for i in 0..arr.len() {
            s += arr[i];
        }
        sum += s;
    }
    println!("sum:{} time:{:?}", sum, now.elapsed());
}


4
हो सकता है कि 240 के साथ आप सीपीयू कैश लाइन को ओवरफ्लो कर रहे हों? अगर ऐसा है, तो आपके परिणाम बहुत विशिष्ट होंगे।
रॉडरिगो

11
यहां पुन: पेश किया गया । अब मैं अनुमान लगा रहा हूं कि लूप के अनियंत्रित होने से इसका कुछ लेना-देना है।
रॉडरिगो

जवाबों:


355

सारांश : 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()एक सरणी के सभी तत्वों को योग करने का एक बेहतर तरीका है। और इसे दूसरे उदाहरण में बदलने से उत्सर्जित विधानसभा में कोई उल्लेखनीय अंतर नहीं होता है। आपको लघु और मुहावरेदार संस्करणों का उपयोग करना चाहिए जब तक कि आपने माप नहीं लिया है कि यह प्रदर्शन को नुकसान पहुंचाता है।


2
@ महान जवाब के लिए lukas-kalbertodt धन्यवाद! अब मैं यह भी समझता हूं कि मूल कोड जो sumसीधे नहीं स्थानीय पर अद्यतन किया गया sथा, बहुत धीमा चल रहा था। for i in 0..arr.len() { sum += arr[i]; }
गाइ कोरलैंड

4
@LukasKalbertodt एएलएक्स 2 पर एलएलवीएम चालू करने से कुछ और फर्क नहीं होना चाहिए। जंग में भी
विद्रोह

4
@Mgetz दिलचस्प! लेकिन यह उपलब्ध सिमडी निर्देशों पर निर्भर करने के लिए मेरे लिए बहुत पागल नहीं लगता है, क्योंकि यह अंततः पूरी तरह से अनियंत्रित लूप में निर्देशों की संख्या निर्धारित करता है। लेकिन दुर्भाग्य से, मैं निश्चित रूप से नहीं कह सकता। इसका उत्तर देने के लिए एलएलवीएम देव के लिए मीठा होगा।
लुकास कालबर्टोड

7
कंपाइलर या एलएलवीएम को यह एहसास क्यों नहीं होता है कि पूरी गणना संकलन के समय की जा सकती है? मुझे उम्मीद है कि लूप रिजल्ट हार्डकोड होगा। या Instantरोकने का उपयोग है?
अनुपयोगी नाम

4
@JosephGarvin: मेरा मानना ​​है कि यह पूरी तरह से अनियंत्रित होने के कारण बाद में होने वाले अनुकूलन को देखने की अनुमति देता है। याद रखें कि अनुकूलन करने वाले कंपाइलर अभी भी जल्दी से संकलन करने की परवाह करते हैं, साथ ही कुशल एएसएम भी बनाते हैं, इसलिए उन्हें किसी भी विश्लेषण की सबसे खराब स्थिति की सीमा को सीमित करना होगा, ताकि जटिल लूप के साथ कुछ बुरा स्रोत कोड संकलित करने में घंटों / दिन न लगें। । लेकिन हां, यह स्पष्ट रूप से आकार के लिए एक चूक अनुकूलन है = = 240. मुझे आश्चर्य है कि अगर छोरों के अंदर छोरों का अनुकूलन नहीं किया गया है तो क्या साधारण बेंचमार्क को तोड़ने से बचने के लिए जानबूझकर किया गया है? शायद नहीं, लेकिन शायद।
पीटर कॉर्ड्स

30

लुकास के जवाब के अलावा, यदि आप एक पुनरावृत्ति का उपयोग करना चाहते हैं, तो यह प्रयास करें:

const CAPACITY: usize = 240;
const IN_LOOPS: usize = 500000;

pub fn bar() -> usize {
    (0..CAPACITY).sum::<usize>() * IN_LOOPS
}

रेंज पैटर्न के बारे में सुझाव के लिए धन्यवाद @ मॉरिस मॉर्गन।

विधानसभा अनुकूलित काफी अच्छा है:

example::bar:
        movabs  rax, 14340000000
        ret

3
या बेहतर अभी भी, (0..CAPACITY).sum::<usize>() * IN_LOOPSजो एक ही परिणाम देता है।
क्रिस मॉर्गन

11
मैं वास्तव में समझाता हूं कि विधानसभा वास्तव में गणना नहीं कर रही है, लेकिन एलएलवीएम ने इस मामले में जवाब को पूर्वनिर्धारित किया है।
जोसेप

मुझे इस तरह की हैरानी है कि rustcइस ताकत-कटौती को करने का अवसर याद आ रहा है। इस विशिष्ट संदर्भ में, हालांकि, यह एक टाइमिंग लूप प्रतीत होता है, और आप जानबूझकर इसे अनुकूलित नहीं करना चाहते हैं। पूरे बिंदु को गणना को दोहराना है जो खरोंच से कई बार होता है और पुनरावृत्ति की संख्या से विभाजित होता है। C में, इसके लिए (अनौपचारिक) मुहावरे को लूप काउंटर के रूप में घोषित करना है volatile, उदाहरण के लिए लिनक्स कर्नेल में BogoMIPS काउंटर। क्या जंग में इसे हासिल करने का कोई तरीका है? वहाँ हो सकता है, लेकिन मैं यह नहीं जानता। बाहरी कॉल fnकरने से मदद मिल सकती है।
डेविसलर

1
@ डेविज़र: volatileउस मेमोरी को सिंक में होने के लिए मजबूर करता है। इसे लूप काउंटर पर लागू करने से केवल लूप काउंटर वैल्यू का वास्तविक पुनः लोड / स्टोर होता है। यह सीधे लूप बॉडी को प्रभावित नहीं करता है। इसीलिए इसका उपयोग करने का एक बेहतर तरीका आम तौर पर volatile int sinkलूप के बाद (या यदि लूप-निर्भर निर्भरता है) या प्रत्येक पुनरावृत्ति के लिए वास्तविक महत्वपूर्ण परिणाम को निर्दिष्ट करने के लिए है, तो संकलक को लूप काउंटर को अनुकूलित करने देना चाहिए, लेकिन इसे बल देना चाहिए अमल में लाना करने के लिए परिणाम आप चाहते हैं एक रजिस्टर में तो यह यह स्टोर कर सकते हैं।
पीटर कॉर्ड्स

1
@ डेविड: मुझे लगता है कि Rust में GNU C. जैसी इनलाइन asm सिंटैक्स कुछ है। आप इनलाइन asm का उपयोग कंपाइलर को किसी रजिस्टर में मान को बिना स्टोर करने के लिए मजबूर करने के लिए मजबूर करने के लिए कर सकते हैं। प्रत्येक लूप पुनरावृत्ति के परिणाम पर इसका उपयोग करना इसे दूर करने से रोक सकता है। (लेकिन ऑटो-वेक्टरिंग से भी अगर आप सावधान नहीं हैं)। उदाहरण के लिए MSVC में "एस्केप" और "क्लोबर" समतुल्य 2 मैक्रोज़ बताते हैं (यह पूछते हुए कि उन्हें MSVC में कैसे पोर्ट करना है जो वास्तव में संभव नहीं है) और चांडलर कारूथ की बात के लिए लिंक जहां वह उनका उपयोग दिखाता है।
पीटर कॉर्ड्स
हमारी साइट का प्रयोग करके, आप स्वीकार करते हैं कि आपने हमारी Cookie Policy और निजता नीति को पढ़ और समझा लिया है।
Licensed under cc by-sa 3.0 with attribution required.