जावा: मैन्युअल रूप से अनियंत्रित लूप अभी भी मूल लूप की तुलना में तेज है। क्यों?


13

लंबाई 2 की एक सरणी पर कोड के निम्नलिखित दो स्निपेट पर विचार करें:

boolean isOK(int i) {
    for (int j = 0; j < filters.length; ++j) {
        if (!filters[j].isOK(i)) {
            return false;
        }
    }
    return true;
}

तथा

boolean isOK(int i) {
     return filters[0].isOK(i) && filters[1].isOK(i);
}

मुझे लगता है कि इन दो टुकड़ों का प्रदर्शन पर्याप्त गर्मजोशी के बाद समान होना चाहिए।
मैंने जेएमएच माइक्रो-बेंचमार्किंग फ्रेमवर्क का उपयोग करके यह जाँच की है जैसे कि यहाँ और यहाँ वर्णित है और देखा कि दूसरा स्निपेट 10% से अधिक तेज़ है।

प्रश्न: जावा ने बुनियादी लूप अन्रॉलिंग तकनीक का उपयोग करके मेरे पहले स्निपेट को क्यों नहीं अनुकूलित किया है?
विशेष रूप से, मैं निम्नलिखित समझना चाहूंगा:

  1. मैं आसानी से एक कोड का उत्पादन कर सकता हूं जो 2 फिल्टर के मामलों के लिए इष्टतम है और अभी भी फिल्टर की एक और संख्या के मामले में काम कर सकता है (एक साधारण बिल्डर की कल्पना करें:)
    return (filters.length) == 2 ? new FilterChain2(filters) : new FilterChain1(filters)। क्या JITC भी ऐसा कर सकता है और यदि नहीं, तो क्यों?
  2. क्या JITC यह पता लगा सकता है कि ' filter.length == 2 ' सबसे लगातार मामला है और कोड का उत्पादन करता है जो कुछ वार्म-अप के बाद इस मामले के लिए इष्टतम है? यह मैन्युअल रूप से अनियंत्रित संस्करण के रूप में लगभग इष्टतम होना चाहिए।
  3. क्या JITC यह पता लगा सकता है कि किसी विशेष उदाहरण का उपयोग बहुत बार किया जाता है और फिर इस विशिष्ट उदाहरण के लिए एक कोड का उत्पादन किया जाता है (जिसके लिए यह जानता है कि फ़िल्टर की संख्या हमेशा 2 है)?
    अपडेट: एक जवाब मिला कि जेआईटीसी केवल एक वर्ग स्तर पर काम करता है। ठीक मिल गया।

आदर्श रूप से, मैं जेआईटीसी के काम करने की गहरी समझ के साथ किसी से उत्तर प्राप्त करना चाहूंगा।

बेंचमार्क रन विवरण:

  • जावा 8 ओपनजेडके और ओरेकल हॉटस्पॉट के नवीनतम संस्करणों की कोशिश की, परिणाम समान हैं
  • जावा झंडे का इस्तेमाल किया: -Xmx4g -Xms4g -server -Xbatch -XX: CICompilerCount = 2 (फैंसी झंडे के बिना भी इसी तरह के परिणाम मिले)
  • वैसे, मुझे समान रन समय अनुपात मिलता है अगर मैं बस इसे कई बिलियन बार (जेएमएच के माध्यम से नहीं) चलाता हूं, तो दूसरा स्निपेट हमेशा स्पष्ट रूप से तेज होता है

विशिष्ट बेंचमार्क आउटपुट:

बेंचमार्क (filterIndex) मोड Cnt स्कोर त्रुटि इकाइयाँ
LoopUnrollingBenchmark.runBenchmark 0 avgt 400 44.202 24 0.224 ns / op
LoopUnrollingBenchmark.runBenchmark 1 avgt 400 38.347 op 0.063 ns / op

(पहली पंक्ति पहली स्निपेट से, दूसरी पंक्ति - दूसरी से मेल खाती है।

पूरा बेंचमार्क कोड:

public class LoopUnrollingBenchmark {

    @State(Scope.Benchmark)
    public static class BenchmarkData {
        public Filter[] filters;
        @Param({"0", "1"})
        public int filterIndex;
        public int num;

        @Setup(Level.Invocation) //similar ratio with Level.TRIAL
        public void setUp() {
            filters = new Filter[]{new FilterChain1(), new FilterChain2()};
            num = new Random().nextInt();
        }
    }

    @Benchmark
    @Fork(warmups = 5, value = 20)
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    public int runBenchmark(BenchmarkData data) {
        Filter filter = data.filters[data.filterIndex];
        int sum = 0;
        int num = data.num;
        if (filter.isOK(num)) {
            ++sum;
        }
        if (filter.isOK(num + 1)) {
            ++sum;
        }
        if (filter.isOK(num - 1)) {
            ++sum;
        }
        if (filter.isOK(num * 2)) {
            ++sum;
        }
        if (filter.isOK(num * 3)) {
            ++sum;
        }
        if (filter.isOK(num * 5)) {
            ++sum;
        }
        return sum;
    }


    interface Filter {
        boolean isOK(int i);
    }

    static class Filter1 implements Filter {
        @Override
        public boolean isOK(int i) {
            return i % 3 == 1;
        }
    }

    static class Filter2 implements Filter {
        @Override
        public boolean isOK(int i) {
            return i % 7 == 3;
        }
    }

    static class FilterChain1 implements Filter {
        final Filter[] filters = createLeafFilters();

        @Override
        public boolean isOK(int i) {
            for (int j = 0; j < filters.length; ++j) {
                if (!filters[j].isOK(i)) {
                    return false;
                }
            }
            return true;
        }
    }

    static class FilterChain2 implements Filter {
        final Filter[] filters = createLeafFilters();

        @Override
        public boolean isOK(int i) {
            return filters[0].isOK(i) && filters[1].isOK(i);
        }
    }

    private static Filter[] createLeafFilters() {
        Filter[] filters = new Filter[2];
        filters[0] = new Filter1();
        filters[1] = new Filter2();
        return filters;
    }

    public static void main(String[] args) throws Exception {
        org.openjdk.jmh.Main.main(args);
    }
}

1
संकलक गारंटी नहीं दे सकता है कि सरणी की लंबाई 2 है। मुझे यकीन नहीं है कि यह इसे अनियंत्रित करेगा भले ही यह हो।
14

1
@Setup(Level.Invocation): निश्चित नहीं है कि यह मदद करता है (देखें जवादोक)।
जीपीआई

3
चूंकि कहीं भी कोई गारंटी नहीं है कि सरणी हमेशा लंबाई 2 है, दो तरीके समान कार्य नहीं कर रहे हैं। फिर जेआईटी पहले खुद को दूसरे में बदलने की अनुमति कैसे दे सकता है?
एंड्रियास

@ और मैं आपको सुझाव देता हूं कि आप सवाल का जवाब दें, लेकिन विस्तृत करें कि जेआईटी इस मामले में किसी अन्य समान मामले की तुलना में अनियंत्रित क्यों नहीं हो सकती है
अलेक्जेंडर

1
@ अलेक्जेंडर जेआईटी देख सकता है कि सृजन के बाद सरणी की लंबाई नहीं बदल सकती है, क्योंकि क्षेत्र है final, लेकिन जेआईटी यह नहीं देखता है कि कक्षा के सभी उदाहरणों को लंबाई की एक सरणी मिल जाएगी। 2. यह देखने के लिए, उसे गोता लगाना होगा createLeafFilters()विधि और कोड को गहराई से जानने के लिए विश्लेषण करें कि सरणी हमेशा 2 लंबी होगी। आप क्यों मानते हैं कि जेआईटी ऑप्टिमाइज़र आपके कोड में गहरा होगा?
एंड्रियास

जवाबों:


10

TL; DR यहां प्रदर्शन अंतर का मुख्य कारण लूप के अनियंत्रित होने से संबंधित नहीं है। यह बल्कि अटकलें और इनलाइन कैश हैं

अनियंत्रित रणनीति

वास्तव में, हॉटस्पॉट शब्दावली में, ऐसे छोरों को गिना जाता है , और कुछ मामलों में जेवीएम उन्हें अनियंत्रित कर सकता है। हालांकि आपके मामले में नहीं।

हॉटस्पॉट में दो लूप की अनियंत्रित रणनीतियां हैं: 1) अधिकतम रूप से अनियंत्रित करें, अर्थात लूप को पूरी तरह से हटा दें; या 2) एक साथ कई लगातार पुनरावृत्तियों को गोंद करें।

मैक्सिमल अनरोलिंग किया जा सकता है, केवल तभी जब पुनरावृत्तियों की सही संख्या ज्ञात हो

  if (!cl->has_exact_trip_count()) {
    // Trip count is not exact.
    return false;
  }

आपके मामले में, हालाँकि, पहले पुनरावृत्ति के बाद फ़ंक्शन जल्दी लौट सकता है।

आंशिक अनियंत्रण शायद लागू किया जा सकता है, लेकिन निम्नलिखित स्थिति अनियंत्रित हो जाती है:

  // Don't unroll if the next round of unrolling would push us
  // over the expected trip count of the loop.  One is subtracted
  // from the expected trip count because the pre-loop normally
  // executes 1 iteration.
  if (UnrollLimitForProfileCheck > 0 &&
      cl->profile_trip_cnt() != COUNT_UNKNOWN &&
      future_unroll_ct        > UnrollLimitForProfileCheck &&
      (float)future_unroll_ct > cl->profile_trip_cnt() - 1.0) {
    return false;
  }

चूंकि आपके मामले में अपेक्षित यात्रा की संख्या 2 से कम है, हॉटस्पॉट मानता है कि यह दो पुनरावृत्तियों को भी नियंत्रित करने के लिए योग्य नहीं है। ध्यान दें कि पहले पुनरावृत्ति को वैसे भी प्री-लूप में निकाला जाता है ( लूप पीलिंग ऑप्टिमाइज़ेशन ), इसलिए अनरोलिंग करना वास्तव में यहाँ बहुत अधिक नहीं है।

अटकलें टाइप करें

आपके अनियंत्रित संस्करण में, दो अलग-अलग invokeinterfaceबायोटेक हैं। इन साइटों में दो अलग-अलग प्रकार के प्रोफाइल हैं। पहला रिसीवर हमेशा होता है Filter1, और दूसरा रिसीवर हमेशा होता है Filter2। तो, आपके पास मूल रूप से दो मोनोमॉर्फिक कॉल साइट हैं, और हॉटस्पॉट दोनों कॉल को पूरी तरह से इनलाइन कर सकता है - तथाकथित "इनलाइन कैश" जिसमें इस मामले में 100% हिट अनुपात है।

लूप के साथ, बस एक invokeinterfaceबायटेकोड होता है, और केवल एक प्रकार का प्रोफाइल एकत्र किया जाता है। हॉटस्पॉट JVM देखता है कि रिसीवर के filters[j].isOK()साथ 86% बार और Filter1रिसीवर के साथ 14% बार कहा जाता है Filter2। यह एक द्विमासिक कॉल होगा। सौभाग्य से, हॉटस्पॉट सट्टेबाज़ी से द्विअर्थी कॉल को भी इनलाइन कर सकता है। यह एक सशर्त शाखा के साथ दोनों लक्ष्यों को रेखांकित करता है। हालांकि, इस मामले में हिट अनुपात अधिकतम 86% होगा, और प्रदर्शन वास्तुकला स्तर पर संबंधित गलत शाखाओं से पीड़ित होगा।

यदि आपके पास 3 या अधिक भिन्न फ़िल्टर हैं, तो चीजें और भी बदतर होंगी। इस मामले में isOK()एक मेगाफोरिक कॉल होगी जिसे हॉटस्पॉट बिल्कुल भी इनलाइन नहीं कर सकता है। तो, संकलित कोड में एक सच्चा इंटरफ़ेस कॉल होगा जिसमें एक बड़ा प्रदर्शन प्रभाव होता है।

लेख के सट्टा inlining के बारे में अधिक काला जादू (जावा) विधि प्रेषण

निष्कर्ष

वर्चुअल / इंटरफ़ेस कॉल को इनलाइन करने के लिए, हॉटस्पॉट JVM, इनवॉइस बायटेकोड के प्रकार प्रोफाइल एकत्र करता है। यदि लूप में वर्चुअल कॉल है, तो कॉल के लिए सिर्फ एक प्रकार का प्रोफाइल होगा, भले ही लूप अनियंत्रित हो या न हो।

वर्चुअल कॉल ऑप्टिमाइज़ेशन से सर्वश्रेष्ठ प्राप्त करने के लिए, आपको लूप को मैन्युअल रूप से विभाजित करना होगा, मुख्यतः टाइप प्रोफाइल को विभाजित करने के लिए। हॉटस्पॉट स्वचालित रूप से अब तक ऐसा नहीं कर सकता है।


महान जवाब के लिए धन्यवाद। पूर्णता के लिए: क्या आप किसी JITC तकनीक के बारे में जानते हैं जो एक विशिष्ट उदाहरण के लिए कोड का उत्पादन कर सकती है?
अलेक्जेंडर

@Alexander HotSpot किसी विशिष्ट उदाहरण के लिए कोड का अनुकूलन नहीं करता है। यह रनटाइम सांख्यिकी का उपयोग करता है जिसमें प्रति-बायोटेक काउंटर, प्रकार प्रोफ़ाइल, शाखा लक्ष्य संभावनाएं आदि शामिल हैं। यदि आप किसी विशिष्ट मामले के लिए कोड का अनुकूलन करना चाहते हैं, तो इसके लिए एक अलग वर्ग बनाएं, या तो मैन्युअल रूप से या डायनेमिक बायोटकोड पीढ़ी के साथ।
अपंगिन

13

प्रस्तुत लूप संभावित रूप से "नॉन काउंटेड" श्रेणी के छोरों के अंतर्गत आता है, जो कि लूप हैं, जिसके लिए पुनरावृति गणना न तो संकलन समय पर और न ही रन टाइम पर निर्धारित की जा सकती है। न केवल सरणी आकार के बारे में @Andreas तर्क के कारण, बल्कि यादृच्छिक रूप से सशर्त के कारण भी break(जब मैं इस पोस्ट को लिखने के दौरान आपके बेंचमार्क में हुआ करता था)।

अत्याधुनिक कंपाइलर आक्रामक रूप से उनका अनुकूलन नहीं करते हैं, क्योंकि गैर-गिने हुए लूपों को अनियंत्रित करने में अक्सर एक लूप की निकास स्थिति को भी डुप्लिकेट करना शामिल होता है, जो इस प्रकार केवल रन-टाइम प्रदर्शन में सुधार करता है यदि बाद में कंपाइलर अनुकूलन अनियंत्रित कोड को अनुकूलित कर सकते हैं। विवरण के लिए यह 2017 का पेपर देखें जहां वे प्रस्ताव बनाते हैं कि ऐसे सामान को भी कैसे अनियंत्रित किया जाए।

इस प्रकार, कि आपकी धारणा यह नहीं रखती है कि आपने लूप के "मैनुअल अनरोलिंग" की तरह किया था। आप इसे एक बुनियादी लूप अनरोलिंग तकनीक पर विचार कर रहे हैं, जो एक सरणी से अधिक एक &&जंजीर बूलियन अभिव्यक्ति में सशर्त विराम के साथ पुनरावृत्ति को बदलने के लिए है । मैं इस पर विशेष रूप से विचार करना चाहता हूं और एक हॉट-स्पॉट ऑप्टिमाइज़र को मक्खी पर एक जटिल रीफैक्टरिंग करने के लिए आश्चर्यचकित होना चाहिए। यहां वे चर्चा कर रहे हैं कि यह वास्तव में क्या कर सकता है, शायद यह संदर्भ दिलचस्प है।

यह एक समकालीन अनियंत्रण के यांत्रिकी के करीब को प्रतिबिंबित करेगा और शायद अभी भी कहीं भी नहीं है जो कि अनियंत्रित मशीन कोड जैसा दिखेगा:

if (! filters[0].isOK(i))
{
   return false;
} 
if(! filters[1].isOK(i))
{
   return false;
}
return true;

आप निष्कर्ष निकाल रहे हैं, क्योंकि कोड का एक टुकड़ा कोड के दूसरे टुकड़े की तुलना में तेजी से चलता है, लूप अनियंत्रित नहीं हुआ। यदि ऐसा हुआ भी, आप अभी भी रनटाइम अंतर को इस तथ्य के कारण देख सकते हैं कि आप विभिन्न कार्यान्वयनों की तुलना कर रहे हैं।

यदि आप अधिक निश्चितता प्राप्त करना चाहते हैं, तो मशीन कोड (गीथब) (प्रेजेंटेशन स्लाइड्स) सहित वास्तविक जित संचालन के जिटवाच विश्लेषक / विज़ुअलाइज़र हैं । अगर कुछ देखने के लिए है, तो आखिरकार मुझे अपनी खुद की आँखों पर भरोसा होगा कि जेआईटी सामान्य रूप से क्या कर सकती है या नहीं कर सकती है, क्योंकि हर मामले की अपनी बारीकियां हैं। यहां तक कि वे विशिष्ट मामलों के लिए सामान्य बयानों तक पहुंचने में कठिनाई के बारे में झल्लाहट करते हैं जहां तक ​​कि जेआईटी का संबंध है और कुछ दिलचस्प लिंक प्रदान करते हैं।

चूंकि आपका लक्ष्य न्यूनतम रनटाइम है, इसलिए a && b && c ...फॉर्म सबसे अधिक कुशल है, यदि आप लूप-अनरोलिंग के लिए आशा पर निर्भर नहीं रहना चाहते हैं, तो कम से कम अभी तक प्रस्तुत किसी भी चीज की तुलना में अधिक कुशल है। लेकिन आप एक सामान्य तरीके से ऐसा नहीं कर सकते। Java.util.Function की कार्यात्मक संरचना के साथ फिर से बहुत बड़ा ओवरहेड है (प्रत्येक फ़ंक्शन एक वर्ग है, प्रत्येक कॉल एक आभासी विधि है जिसे प्रेषण की आवश्यकता है)। इस तरह के परिदृश्य में शायद यह भाषा के स्तर को कम करने और रनटाइम पर कस्टम बाइट कोड उत्पन्न करने के लिए समझ में आता है। दूसरी ओर एक &&तर्क के लिए बाइट कोड स्तर में शाखाओं में बंटने की आवश्यकता होती है और अगर / वापसी (जो बिना ओवरहेड के भी उत्पन्न नहीं की जा सकती) के बराबर हो सकती है।


बस एक छोटा सा परिशिष्ट: जेवीएम दुनिया में एक गिना हुआ लूप कोई भी लूप है जो int i = ....; i < ...; ++iकिसी भी अन्य लूप के ऊपर "चलता है" नहीं है।
यूजीन
हमारी साइट का प्रयोग करके, आप स्वीकार करते हैं कि आपने हमारी Cookie Policy और निजता नीति को पढ़ और समझा लिया है।
Licensed under cc by-sa 3.0 with attribution required.