Deklarowanie wielu tablic z 64 elementami 1000 razy szybciej niż deklarowanie tablicy 65 elementów

91

Ostatnio zauważyłem, że deklarowanie tablicy zawierającej 64 elementy jest dużo szybsze (> 1000 razy) niż deklarowanie tego samego typu tablicy z 65 elementami.

Oto kod, którego użyłem do przetestowania tego:

public class Tests{
    public static void main(String args[]){
        double start = System.nanoTime();
        int job = 100000000;//100 million
        for(int i = 0; i < job; i++){
            double[] test = new double[64];
        }
        double end = System.nanoTime();
        System.out.println("Total runtime = " + (end-start)/1000000 + " ms");
    }
}

Ta przebiega w około 6 ms, jeśli zastąpi new double[64]się new double[65]trwa około 7 sekund. Ten problem staje się wykładniczo poważniejszy, jeśli praca jest rozłożona na coraz więcej wątków, stąd mój problem.

Ten problem występuje również w przypadku różnych typów tablic, takich jak int[65]lub String[65]. Ten problem nie występuje w przypadku dużych ciągów:, String test = "many characters";ale zaczyna się pojawiać po zmianie naString test = i + "";

Zastanawiałem się, dlaczego tak jest i czy da się obejść ten problem.

Sipko
źródło
3
Poza uwagą: System.nanoTime()powinno być preferowane w System.currentTimeMillis()przypadku testów porównawczych.
rocketboy,
4
Jestem po prostu ciekaw ? Czy jesteś pod Linuksem? Czy zachowanie zmienia się wraz z systemem operacyjnym?
bsd
9
Skąd to pytanie otrzymało głos przeciw?
Rohit Jain
2
FWIW, widzę podobne rozbieżności wydajności, jeśli uruchomię ten kod z bytezamiast double.
Oliver Charlesworth,
3
@ThomasJungblut: Więc co wyjaśnia rozbieżność w eksperymencie PO?
Oliver Charlesworth,

Odpowiedzi:

88

Obserwujesz zachowanie spowodowane optymalizacjami wykonanymi przez kompilator JIT maszyny wirtualnej Java. To zachowanie jest powtarzalne wyzwalane przez tablice skalarne do 64 elementów i nie jest wyzwalane w przypadku tablic większych niż 64.

Zanim przejdziemy do szczegółów, przyjrzyjmy się bliżej głównej części pętli:

double[] test = new double[64];

Ciało nie ma żadnego wpływu (obserwowalne zachowanie) . Oznacza to, że poza wykonaniem programu nie ma znaczenia, czy ta instrukcja jest wykonywana, czy nie. To samo dotyczy całej pętli. Może się więc zdarzyć, że optymalizator kodu tłumaczy pętlę na coś (lub nic) z tym samym funkcjonalnym i różnym zachowaniem czasowym.

W przypadku testów porównawczych należy przynajmniej przestrzegać następujących dwóch wskazówek. Gdybyś to zrobił, różnica byłaby znacznie mniejsza.

  • Rozgrzej kompilator JIT (i optymalizator), wykonując test porównawczy kilka razy.
  • Użyj wyniku każdego wyrażenia i wydrukuj go na końcu testu porównawczego.

Przejdźmy teraz do szczegółów. Nic dziwnego, że istnieje optymalizacja, która jest uruchamiana dla tablic skalarnych nie większych niż 64 elementy. Optymalizacja jest częścią analizy Escape . Umieszcza małe obiekty i małe tablice na stosie zamiast alokować je na stercie - lub nawet lepiej je całkowicie zoptymalizować. Więcej informacji na ten temat można znaleźć w następującym artykule Briana Goetza napisanym w 2005 roku:

Optymalizację można wyłączyć za pomocą opcji wiersza poleceń -XX:-DoEscapeAnalysis. Magiczną wartość 64 dla tablic skalarnych można również zmienić w wierszu poleceń. Jeśli wykonasz swój program w następujący sposób, nie będzie różnicy między tablicami z 64 i 65 elementami:

java -XX:EliminateAllocationArraySizeLimit=65 Tests

Powiedziawszy to, zdecydowanie odradzam używanie takich opcji wiersza poleceń. Wątpię, czy robi to dużą różnicę w realistycznej aplikacji. Wykorzystałbym go tylko wtedy, gdybym był absolutnie przekonany o konieczności - a nie w oparciu o wyniki niektórych pseudo-benchmarków.

nosid
źródło
9
Ale dlaczego optymalizator wykrywa, że ​​tablica o rozmiarze 64 jest wymienna, ale nie 65
ug_
10
@nosid: Chociaż kod PO może nie być realistyczny, wyraźnie wyzwala interesujące / nieoczekiwane zachowanie w JVM, które może mieć konsekwencje w innych sytuacjach. Myślę, że warto zapytać, dlaczego tak się dzieje.
Oliver Charlesworth
1
@ThomasJungblut Nie sądzę, żeby pętla została usunięta. Możesz dodać „int total” poza pętlą i dodać „total + = test [0];” do powyższego przykładu. Po wydrukowaniu wyniku zobaczysz, że suma = 100 milionów i trwa to krócej niż sekundę.
Sipko,
1
Zastąpienie na stosie polega na zastąpieniu zinterpretowanego kodu skompilowanym w locie, zamiast zastępowania alokacji sterty alokacją stosu. EliminateAllocationArraySizeLimit to limit rozmiaru tablic, które w analizie ucieczki są uznawane za zamienniki skalarne. Tak więc główny punkt, że efekt wynika z optymalizacji kompilatora, jest poprawny, ale nie jest to spowodowane alokacją stosu, ale z powodu fazy analizy ucieczki, która nie zauważa, alokacja nie jest potrzebna.
kiheru
2
@Sipko: Piszesz, że aplikacja nie skaluje się wraz z liczbą wątków. To oznacza, że ​​problem nie jest związany z mikro optymalizacjami, o które pytasz. Zalecam spojrzenie na duży obraz zamiast na małe części.
nosid
2

Różnice mogą wystąpić na wiele sposobów, w zależności od rozmiaru obiektu.

Jak stwierdził nosid, JITC może (najprawdopodobniej będzie) przydzielać małe „lokalne” obiekty na stosie, a wielkość odcięcia dla „małych” tablic może wynosić 64 elementy.

Alokowanie na stosie jest znacznie szybsze niż alokowanie w stercie, a co ważniejsze, stos nie musi być zbierany jako śmieci, więc obciążenie GC jest znacznie zmniejszone. (A dla tego przypadku testowego narzut GC wynosi prawdopodobnie 80-90% całkowitego czasu wykonania).

Co więcej, po przydzieleniu wartości na stosie JITC może przeprowadzić „eliminację martwego kodu”, ustalić, że wynik operacji newnigdy nie jest nigdzie używany, a po upewnieniu się, że nie ma żadnych skutków ubocznych, które zostałyby utracone, wyeliminować całynew operację, a następnie (teraz pusta) sama pętla.

Nawet jeśli JITC nie dokonuje alokacji stosu, jest całkowicie możliwe, że obiekty mniejsze niż pewien rozmiar mogą być alokowane na stercie inaczej (np. Z innej „przestrzeni”) niż większe obiekty. (Zwykle nie spowodowałoby to jednak tak dramatycznych różnic w czasie).

Hot Licks
źródło
Spóźniony do tego wątku. Dlaczego alokacja na stosie jest szybsza niż alokacja na stercie? Według kilku artykułów, alokacja na stercie wymaga ~ 12 instrukcji. Nie ma wiele miejsca na ulepszenia.
Vortex
@Vortex - przydzielanie do stosu zajmuje 1-2 instrukcje. Ale to służy do przydzielenia całej ramki stosu. Ramka stosu i tak musi być alokowana, aby mieć obszar zapisu rejestru dla procedury, więc wszelkie inne zmienne przydzielane w tym samym czasie są „wolne”. I jak powiedziałem, stos nie wymaga GC. Narzut GC dla elementu sterty jest znacznie większy niż koszt operacji alokacji sterty.
Hot Licks