Java: elle açılmış döngü hala orijinal döngüden daha hızlıdır. Neden?


13

Uzunluk 2 dizisinde aşağıdaki iki kod parçacığını düşünün:

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

ve

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

Yeterli ısınma sonrasında bu iki parçanın performansının benzer olması gerektiğini varsayabilirim.
Bunu burada ve burada tarif edildiği gibi JMH mikro-karşılaştırma çerçevesi kullanarak kontrol ettim ve ikinci snippet'in% 10'dan daha hızlı olduğunu gözlemledim.

Soru: Java neden ilk snippet'i temel döngü açma tekniğini kullanarak optimize etmedi?
Özellikle, aşağıdakileri anlamak istiyorum:

  1. Kolayca (basit bir oluşturucu hayal) filtrelerin başka bir numaraya durumunda çalışabilir hala 2 filtrelerin durumlar için en uygunudur ve bir kod üretebilir:
    return (filters.length) == 2 ? new FilterChain2(filters) : new FilterChain1(filters). JITC de aynısını yapabilir ve değilse, neden?
  2. JITC, 'filters.length == 2 ' nin en sık karşılaşılan durum olduğunu tespit edebilir ve bir miktar ısınmadan sonra bu durum için en uygun kodu üretebilir mi? Bu, manuel olarak açılmış versiyon kadar neredeyse optimal olmalıdır.
  3. JITC belirli bir örneğin çok sık kullanıldığını algılayabilir ve daha sonra bu belirli örnek için bir kod üretebilir (bunun için filtre sayısının her zaman 2 olduğunu bildiği)?
    Güncelleme: JITC'nin sadece sınıf düzeyinde çalıştığı bir cevap aldı. Tamam anladım.

İdeal olarak, JITC'nin nasıl çalıştığını derinlemesine anlayan birinden cevap almak istiyorum.

Benchmark çalıştırma detayları:

  • Java 8 OpenJDK ve Oracle HotSpot'un en son sürümlerinde denenen sonuçlar benzer
  • Kullanılan Java bayrakları: -Xmx4g -Xms4g -server -Xbatch -XX: CICompilerCount = 2 (süslü bayraklar olmadan da benzer sonuçlar aldı)
  • Bu arada, sadece bir döngüde birkaç milyar kez çalıştırırsam (JMH aracılığıyla değil) benzer çalışma süresi oranı elde ederim, yani ikinci snippet her zaman açıkça daha hızlıdır

Tipik kıyaslama çıktısı:

Karşılaştırma (filterIndex) Modu Cnt Puanı Hata Birimleri
DöngüKontrolBenchmark.runBenchmark 0 ortalama 400 44.202 ± 0.224 ns / op
LoopUnrollingBenchmark.runBenchmark 1 ortalama 400 38.347 ± 0.063 ns / op

(İlk satır birinci pasaja, ikinci satıra - ikinci satıra karşılık gelir.

Eksiksiz karşılaştırma kodu:

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
Derleyici dizinin uzunluğunun 2 olduğunu garanti edemez. Gerçi olsa bile onu açacağından emin değilim.
marstran

1
@Setup(Level.Invocation): yardımcı olduğuna emin değilim (bkz. javadoc).
GPI

3
Dizinin her zaman uzunluk 2 olduğu konusunda herhangi bir garanti olmadığından, iki yöntem aynı şeyi yapmaz. O halde JIT birincisini ikinciye değiştirmesine nasıl izin verebilir?
Andreas

@Andreas, soruyu cevaplamanızı öneririm, ancak JIT'in bu durumda neden çözemediğini, başka bir benzer durumla karşılaştırarak açıklayın
Alexander

1
@Alexander JIT , alan olduğu için dizi uzunluğunun oluşturma işleminden sonra değişemediğini finalgörebilir, ancak JIT , sınıfın tüm örneklerinin bir uzunluk 2 dizisi alacağını görmez. Bunu görmek için, createLeafFilters()ve dizinin her zaman 2 uzun olacağını öğrenecek kadar derin kod analiz edin. Neden JIT optimizatörünün kodunuzun derinliklerine dalacağına inanıyorsunuz?
Andreas

Yanıtlar:


10

TL; DR Buradaki performans farkının ana nedeni döngü çözmeyle ilgili değildir. Daha çok tür spekülasyonu ve satır içi önbelleklerdir .

Unrolling stratejileri

Aslında, HotSpot terminolojisinde, bu tür döngüler sayılmış olarak ele alınır ve bazı durumlarda JVM bunları açabilir . Olsa da senin durumunda değil.

HotSpot'un iki loop unrolling stratejisi vardır: 1) maksimuma çıkarmak, yani loop'u tamamen kaldırmak; veya 2) birkaç ardışık yinelemeyi birbirine yapıştırın.

Maksimum yineleme , yalnızca kesin yineleme sayısı biliniyorsa yapılabilir .

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

Ancak sizin durumunuzda, işlev ilk yinelemeden sonra erken dönebilir.

Kısmi unrolling işlemi muhtemelen uygulanabilir, ancak aşağıdaki koşul unroll'lemeyi keser:

  // 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;
  }

Durumunuzda beklenen yolculuk sayısı 2'den az olduğu için, HotSpot iki yinelemenin bile açılmasının layık olmadığını varsayar. İlk yinelemenin yine de ön döngüye çıkarıldığına dikkat edin ( döngü soyma optimizasyonu ), bu nedenle açma gerçekten çok yapay değildir.

Tip spekülasyonu

Kaydedilmemiş sürümünüzde iki farklı invokeinterfacebayt kodu vardır. Bu sitelerin iki farklı tip profili vardır. İlk alıcı daima Filter1ve ikinci alıcı daima Filter2. Yani, temelde iki monomorfik çağrı siteniz var ve HotSpot her iki çağrıyı da mükemmel bir şekilde sıralayabilir - bu durumda% 100 isabet oranına sahip "satır içi önbellek" olarak adlandırılır.

Döngü ile yalnızca bir invokeinterfacebayt kodu vardır ve yalnızca bir tür profili toplanır. HotSpot JVM, alıcı filters[j].isOK()ile% 86 ve Filter1alıcı ile% 14 olarak adlandırılır Filter2. Bu bimorfik bir çağrı olacak. Neyse ki, HotSpot spekülatif olarak bimorfik çağrıları da sıralayabilir. Her iki hedefi de koşullu bir dalla satır içine alır. Bununla birlikte, bu durumda isabet oranı en fazla% 86 olacaktır ve performans, mimari düzeyde karşılık gelen yanlış tahmin edilen dallardan muzdarip olacaktır.

3 veya daha fazla farklı filtreniz varsa işler daha da kötü olacaktır. Bu durumda isOK(), HotSpot'un hiç satır içi yapamayacağı megamorfik bir çağrı olacaktır. Böylece, derlenmiş kod daha büyük bir performans etkisi olan gerçek bir arayüz çağrısı içerir.

(Java) Yöntemi Dağıtımının Kara Büyüsü makalesinde spekülatif satır içi hakkında daha fazla bilgi .

Sonuç

Sanal / arayüz çağrılarını satır içine almak için HotSpot JVM, çağırma bayt kodu başına tip profilleri toplar. Bir döngüde sanal bir çağrı varsa, döngü açılmış olsun ya da olmasın, çağrı için yalnızca bir tür profil olacaktır.

Sanal arama optimizasyonlarından en iyi şekilde yararlanmak için, öncelikle tür profillerini bölmek amacıyla döngüyü manuel olarak bölmeniz gerekir. HotSpot bunu şimdiye kadar otomatik olarak yapamaz.


harika cevap için teşekkürler. Sadece bütünlük için: belirli bir örnek için kod üretebilecek herhangi bir JITC tekniğinin farkında mısınız?
Alexander

@Alexander HotSpot belirli bir örnek için kodu optimize etmez. Bayt kodu kodları, tür profili, şube hedef olasılıkları vb. İçeren çalışma zamanı istatistiklerini kullanır. Belirli bir vaka için kodu optimize etmek istiyorsanız, manuel olarak veya dinamik bayt kodu oluşturma ile bunun için ayrı bir sınıf oluşturun.
apangin

13

Sunulan döngü, yineleme sayısının ne derleme zamanında ne de çalışma zamanında belirlenemediği döngüler olan "sayılmamış" döngüler kategorisine girer. Sadece dizi boyutu hakkında @Andreas argümanı nedeniyle değil, aynı zamanda rasgele koşullu break(bu yazıyı yazarken karşılaştırmanızda kullanılan) nedeniyle.

Sayısız döngülerin açılması genellikle bir döngünün çıkış koşulunun da çoğaltılmasını gerektirdiğinden, en son derleyici optimizasyonları kaydedilmemiş kodu optimize edebiliyorsa çalışma zamanı performansını artıran son teknoloji derleyiciler bunları agresif bir şekilde optimize etmez. Bu tür şeylerin nasıl açılacağı konusunda önerilerde bulundukları ayrıntılar için bu 2017 belgesine bakın .

Bundan sonra, varsayımınız döngünün bir tür "manuel açma" yaptığınızı kabul etmez. Koşullu kesmeli bir dizi üzerinde bir yinelemeyi &&zincirlenmiş bir boole ifadesine dönüştürmek için bunu temel bir döngü açma tekniği olarak görüyorsunuz . Bu oldukça özel bir durum olarak düşünürdüm ve bir hot-spot optimizer'ı anında karmaşık bir yeniden düzenleme yapmak için şaşırırdım. Burada aslında ne yapabileceğini tartışıyorlar, belki de bu referans ilginç.

Bu, çağdaş bir açılmanın mekaniğini daha yakından yansıtacak ve belki de açılmamış makine kodunun nasıl görüneceğine yakın değil:

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

Sonuç olarak, bir kod parçası başka bir kod parçasından daha hızlı çalıştığı için, döngü çözülmedi. Olsa bile, farklı uygulamaları karşılaştırdığınız için çalışma zamanı farkını hala görebilirsiniz.

Daha fazla kesinlik kazanmak istiyorsanız, makine kodu (github) (sunum slaytları) dahil gerçek Jit işlemlerinin jitwatch analizörü / görselleştiricisi vardır . Sonunda görülecek bir şey varsa, JIT'in genel olarak neler yapabileceği veya yapamayacağına dair herhangi bir görüşten daha fazla kendi gözlerime güvenirdim, çünkü her vakanın kendine özgü özellikleri vardır. Burada , JIT söz konusu olduğunda, belirli durumlar için genel ifadelere ulaşma zorluğundan korkuyorlar ve bazı ilginç bağlantılar sağlıyorlar.

Hedefiniz minimum çalışma zamanı olduğundan, a && b && c ...döngü açma açma umuduna güvenmek istemiyorsanız , form en azından daha verimli olanıdır, ancak henüz sunulan herhangi bir şeyden en az daha verimlidir. Ama bunu genel bir şekilde yapamazsınız. Java.util.Function işlev bileşimi ile yine büyük bir ek yük var (her bir işlev bir sınıftır, her çağrı gönderme gerektiren sanal bir yöntemdir). Belki de böyle bir senaryoda, dil seviyesini düşürmek ve çalışma zamanında özel bayt kodu oluşturmak mantıklı olabilir . Öte yandan bir &&mantık bayt kodu seviyesinde dallanma gerektirir ve if / return ile eşdeğer olabilir (bu da tepegöz olmadan üretilemez).


sadece küçük adendum: JVM dünyada sayılan döngü aşkın "çalışır" Herhangi döngü int i = ....; i < ...; ++iherhangi başka döngü değildir.
Eugene
Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.