2016-10-07 6 views
9

В настоящее время я беру класс Data Structures и, как вы, возможно, должны сделать некоторые из обычных видов. При написании моего алгоритма сортировки вставки я заметил, что он работает значительно быстрее, чем у моего преподавателя (для 400000 точек данных мне понадобился мой алгоритм около 30 секунд и около 90). Я отправил ему по электронной почте свой код, и те же результаты произошли, когда они работали на одной машине. Нам удалось потратить больше 40 минут, медленно меняя метод сортировки на мой, пока он не был точно таким же, слово в слово, кроме одной, казалось бы, произвольной вещи. Во-первых, вот мой код для вставки сортировать:Очень странные квоты эффективности при сортировке

public static int[] insertionSort(int[] A){ 

    //Check for illegal cases 
    if (A == null || A.length == 0){ 

     throw new IllegalArgumentException("A is not populated"); 

    } 

    for(int i = 0; i < A.length; i++){ 

     int j = i; 

     while(j > 0 && A[j - 1] > A[j]){ 

      int temp = A[j]; 
      A[j] = A[j - 1]; 
      A[j - 1] = temp; 

      j--; 

     } 

    } 

    return A; 

} 

Теперь в этот момент его код был точно такой же, как у меня на линиях, где мы переставляем A[j] и A[j - 1] кроме. Его код сделал следующее:

int temp = A[j - 1]; 
A[j - 1] = A[j]; 
A[j] = temp; 

Мы обнаружили, что эти 3 линии являются виновниками. Из-за этого мой код работал значительно быстрее. Perplexed, мы побежали javap -c, чтобы получить байтовый код для простой программы, которая только что имела main, которая содержала объявление массива, объявление переменной для int j и 3 строки кода для замены, как я их написал, и когда он их написал. Вот байт-код для моего метода свопинга:

Compiled from "me.java" 
public class me { 
    public me(); 
    Code: 
     0: aload_0 
     1: invokespecial #1     // Method java/lang/Object."<init>":()V 
     4: return 

    public static void main(java.lang.String[]); 
    Code: 
     0: sipush  10000 
     3: newarray  int 
     5: astore_1 
     6: bipush  10 
     8: istore_2 
     9: aload_1 
     10: iload_2 
     11: iaload 
     12: istore_3 
     13: aload_1 
     14: iload_2 
     15: aload_1 
     16: iload_2 
     17: iconst_1 
     18: isub 
     19: iaload 
     20: iastore 
     21: aload_1 
     22: iload_2 
     23: iconst_1 
     24: isub 
     25: iload_3 
     26: iastore 
     27: return 
} 

и байт-код метода моего инструктора по:

Compiled from "instructor.java" 
public class instructor { 
    public instructor(); 
    Code: 
     0: aload_0 
     1: invokespecial #1     // Method java/lang/Object."<init>":()V 
     4: return 

    public static void main(java.lang.String[]); 
    Code: 
     0: sipush  10000 
     3: newarray  int 
     5: astore_1 
     6: bipush  10 
     8: istore_2 
     9: aload_1 
     10: iload_2 
     11: iconst_1 
     12: isub 
     13: iaload 
     14: istore_3 
     15: aload_1 
     16: iload_2 
     17: iconst_1 
     18: isub 
     19: aload_1 
     20: iload_2 
     21: iaload 
     22: iastore 
     23: aload_1 
     24: iload_2 
     25: iload_3 
     26: iastore 
     27: return 
} 

Я не вижу никакой реальной разницы между этими байт-кодов. Что может вызвать это странное поведение (мой код все еще работал в 3 раза быстрее, чем его, и, как можно было ожидать, эта разница стала более резкой, поскольку мы кормим алгоритмы большими массивами)? Это просто странная причуда Java. Кроме того, это происходит на вашем компьютере? Для справки, это было запущено на MacBook Pro в середине 2014 года, и мой код точно так же, как он появляется здесь, и его код был выведен точно на тот код, который появляется здесь, за исключением трех строк.

[EDIT] Вот мои тестовые классы:

public class Tester1 { 

    public static void main(String[] args){ 

     int[] A = new int[400000]; 

     for(int i = 0; i < A.length; i++){ 

      A[i] = (int) (Math.random() * Integer.MAX_VALUE); 

     } 

     double start = System.currentTimeMillis(); 
     insertionSort(A); 
     System.out.println("My insertion sort took " + (System.currentTimeMillis() - start) + " milliseconds."); 


    } 

    public static int[] insertionSort(int[] A){ 

     //Check for illegal cases 
     if (A == null || A.length == 0){ 

      throw new IllegalArgumentException("A is not populated"); 

     } 

     for(int i = 0; i < A.length; i++){ 

      int j = i; 

      while(j > 0 && A[j - 1] > A[j]){ 

       int temp = A[j]; 
       A[j] = A[j - 1]; 
       A[j - 1] = temp; 

       j--; 

      } 

     } 

     return A; 

    } 

} 

И второй файл:

public class Tester2 { 

    public static void main(String[] args){ 

     int[] A = new int[400000]; 

     for(int i = 0; i < A.length; i++){ 

      A[i] = (int) (Math.random() * Integer.MAX_VALUE); 

     } 

     double start = System.currentTimeMillis(); 
     otherInsertion(A); 
     System.out.println("Other insertion sort took " + (System.currentTimeMillis() - start) + " milliseconds."); 


    } 


    public static int[] otherInsertion(int[] A){ 

     //Check for illegal cases 
     if (A == null || A.length == 0){ 

      throw new IllegalArgumentException("A is not populated"); 

     } 

     for(int i = 0; i < A.length; i++){ 

      int j = i; 

      while(j > 0 && A[j - 1] > A[j]){ 

       int temp = A[j - 1]; 
       A[j - 1] = A[j]; 
       A[j] = temp; 

       j--; 

      } 

     } 

     return A; 

    } 

} 

И выходы (без аргументов, просто java Tester1 и java Tester2):

My insertion sort took 37680.0 milliseconds. 
Other insertion sort took 86358.0 milliseconds. 

Данные были выполнены как 2 отдельных файла в 2 разных JVM.

+0

Интересно. Можете ли вы опубликовать один из ваших тестовых классов? –

+0

@JonKiparsky я сделал. Кроме того, у меня было это поменялось, что эффективно. Назначение темпа A [j - 1] показалось более эффективным. Кроме того, нет замены temp делает его еще хуже. –

+0

Нечетный, есть ли что-то очевидное в соответствующем нативном коде? – harold

ответ

6

Это эффект петли разворачивая оптимизации вместе с общим ликвидации подвыражения. В зависимости от порядка команд доступа к массиву JIT может исключать избыточные нагрузки в одном случае, но не в другом.

Позвольте мне объяснить подробно. В обоих случаях JIT разворачивает 4 итерации внутреннего цикла.

E.g. для Вашего случая:

while (j > 3) { 
     if (A[j - 1] > A[j]) { 
      int temp = A[j]; 
      A[j] = A[j - 1]; 
      A[j - 1] = temp;   \ 
     }        A[j - 1] loaded immediately after store 
     if (A[j - 2] > A[j - 1]) { /
      int temp = A[j - 1]; 
      A[j - 1] = A[j - 2]; 
      A[j - 2] = temp;   \ 
     }        A[j - 2] loaded immediately after store 
     if (A[j - 3] > A[j - 2]) { /
      int temp = A[j - 2]; 
      A[j - 2] = A[j - 3]; 
      A[j - 3] = temp;   \ 
     }        A[j - 3] loaded immediately after store 
     if (A[j - 4] > A[j - 3]) { /
      int temp = A[j - 3]; 
      A[j - 3] = A[j - 4]; 
      A[j - 4] = temp; 
     } 
     j -= 4; 
    } 

Затем JIT устраняет избыточные нагрузки массива, и в результате сборки выглядит

0x0000000002d53a70: movslq %r11d,%r10 
0x0000000002d53a73: lea 0x0(%rbp,%r10,4),%r10 
0x0000000002d53a78: mov 0x10(%r10),%ebx ; ebx = A[j] 
0x0000000002d53a7c: mov 0xc(%r10),%r9d  ; r9d = A[j - 1] 

0x0000000002d53a80: cmp %ebx,%r9d   ; if (r9d > ebx) { 
0x0000000002d53a83: jle 0x0000000002d539f3 
0x0000000002d53a89: mov %r9d,0x10(%r10) ;  A[j] = r9d 
0x0000000002d53a8d: mov %ebx,0xc(%r10)  ;  A[j - 1] = ebx 
               ; } 
0x0000000002d53a91: mov 0x8(%r10),%r9d  ; r9d = A[j - 2] 

0x0000000002d53a95: cmp %ebx,%r9d   ; if (r9d > ebx) { 
0x0000000002d53a98: jle 0x0000000002d539f3      
0x0000000002d53a9e: mov %r9d,0xc(%r10)  ;  A[j - 1] = r9d  
0x0000000002d53aa2: mov %ebx,0x8(%r10)  ;  A[j - 2] = ebx 
               ; }    
0x0000000002d53aa6: mov 0x4(%r10),%r9d  ; r9d = A[j - 3]  

0x0000000002d53aaa: cmp %ebx,%r9d   ; if (r9d > ebx) { 
0x0000000002d53aad: jle 0x0000000002d539f3      
0x0000000002d53ab3: mov %r9d,0x8(%r10)  ;  A[j - 2] = r9d 
0x0000000002d53ab7: mov %ebx,0x4(%r10)  ;  A[j - 3] = ebx 
               ; }     
0x0000000002d53abb: mov (%r10),%r8d  ; r8d = A[j - 4] 

0x0000000002d53abe: cmp %ebx,%r8d   ; if (r8d > ebx) { 
0x0000000002d53ac1: jle 0x0000000002d539f3 
0x0000000002d53ac7: mov %r8d,0x4(%r10)  ;  A[j - 3] = r8 
0x0000000002d53acb: mov %ebx,(%r10)  ;  A[j - 4] = ebx 
               ; } 
0x0000000002d53ace: add $0xfffffffc,%r11d ; j -= 4 
0x0000000002d53ad2: cmp $0x3,%r11d   ; while (j > 3) 
0x0000000002d53ad6: jg  0x0000000002d53a70 

код вашего инструктора будет выглядеть по-другому после того, как петли разворачивания:

while (j > 3) { 
     if (A[j - 1] > A[j]) { 
      int temp = A[j - 1]; 
      A[j - 1] = A[j]; 
      A[j] = temp;   <-- another store instruction between A[j - 1] access 
     } 
     if (A[j - 2] > A[j - 1]) { 
      int temp = A[j - 2]; 
      A[j - 2] = A[j - 1]; 
      A[j - 1] = temp; 
     } 
     ... 

JVM будет не устраняйте последующую нагрузку A[j - 1], так как после предыдущего загрузки A[j - 1] есть другая инструкция по хранению (хотя в является частным случаем, такая теоретическая теоретика возможна).

Таким образом, код сборки будет иметь больше команд загрузки и производительность будет хуже:

0x0000000002b53a00: cmp %r8d,%r10d   ; if (r10d > r8d) { 
0x0000000002b53a03: jle 0x0000000002b53973 
0x0000000002b53a09: mov %r8d,0xc(%rbx)  ;  A[j - 1] = r8d 
0x0000000002b53a0d: mov %r10d,0x10(%rbx) ;  A[j] = r10d 
               ; } 
0x0000000002b53a11: mov 0xc(%rbx),%r10d  ; r10d = A[j - 1] 
0x0000000002b53a15: mov 0x8(%rbx),%r9d  ; r9d = A[j - 2] 

0x0000000002b53a19: cmp %r10d,%r9d   ; if (r9d > r10d) { 
0x0000000002b53a1c: jle 0x0000000002b53973 
0x0000000002b53a22: mov %r10d,0x8(%rbx)  ;  A[j - 2] = r10d 
0x0000000002b53a26: mov %r9d,0xc(%rbx)  ;  A[j - 1] = r9d  
               ; } 
0x0000000002b53a2a: mov 0x8(%rbx),%r8d  ; r8d = A[j - 2] 
0x0000000002b53a2e: mov 0x4(%rbx),%r10d  ; r10d = A[j - 3] 

Обратите внимание, что при запуске виртуальной машины Java с разворачиванием цикла оптимизации отключена (-XX:LoopUnrollLimit=0), производительность обоих случаях будет таким же.

P.S. Полный демонтаж обоих методов доступен here, полученные с использованием
-XX:CompileOnly=Test -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly

+0

Ну. Что-то еще странное произошло. Сегодня я запускал один и тот же тестовый код без параметров, и мои результаты были заменены: 'Мой сортировка вставки заняла 56634,0 миллисекунды. Другая сортировка вставки заняла 146979.0 миллисекунд. ' Так же, когда я бежал с' -XX: LoopUnrollLimit = 0', это только уменьшило мою скорость: 'Мой сортировка вставки заняла 75795,0 миллисекунды. Другая сортировка вставки заняла 141911.0 миллисекунды. Код не изменился. Мой код по-прежнему является 'int temp = A [j]'. Я использовал компилятор 'javac' для этих тестов. –

+0

@ DylanSiegler Никогда не тестируйте два алгоритма в одном методе или даже в одном JVM. Это может привести к непредсказуемым результатам. Вот [хорошая статья] (http://www.oracle.com/technetwork/articles/java/architect-benchmarking-2266277.html), описывающая, почему это работает неправильно. – apangin

+0

Когда я отделил тесты на 2 файла и побежал, те же результаты сохранились. Мой код работал быстрее (49,7 секунды), а другой код также работал немного быстрее (111,6 секунды), но это все еще является резкой разницей между двумя методами. –

-1

TL; DR

Ваш эксперимент не является действительным, существует множество переменных, которые могут повлиять на результат. Лучше всего использовать инструмент microbenchmarking, такой как Caliper или JMH. Я использовал такой инструмент для проверки which method is faster to create indentation

Разница между вашим и вашим профессором ничтожно.

Эксперимент

Для моего эксперимента я имел 745,038 точек данных. Я создал 3 теста, ваш, версию вашего инструктора и Arrays.sort(), который является частью JDK.

https://microbenchmarks.appspot.com/runs/8b8c0554-d3f1-4339-af5a-fdffd18dd053

Основываясь на результатах вашей среды выполнения: 1,419,867.808 нс Ваш инструктор был: 1,429,798.824 нс

Итак, мы говорим о 0,01 мс.

У инструктора было меньше различий между трассами.

JDK Arrays.sort() был более медленным с увеличением на 1,779,042.513 ns, который на 0.300 мс медленнее вашего.

Вот код, который я использовал для микробизнеса в суппорте ниже.

package net.trajano.caliper.test; 

import java.io.DataInputStream; 
import java.io.EOFException; 
import java.io.IOException; 
import java.nio.file.Files; 
import java.nio.file.Paths; 
import java.util.ArrayList; 
import java.util.Arrays; 
import java.util.List; 

import com.google.caliper.BeforeExperiment; 
import com.google.caliper.Benchmark; 
import com.google.caliper.api.VmOptions; 
import com.google.caliper.runner.CaliperMain; 

@VmOptions("-XX:-TieredCompilation") 
public class SortBenchmark { 

    public static int[] insertionSort(final int[] A) { 

     // Check for illegal cases 
     if (A == null || A.length == 0) { 

      throw new IllegalArgumentException("A is not populated"); 

     } 

     for (int i = 0; i < A.length; i++) { 

      int j = i; 

      while (j > 0 && A[j - 1] > A[j]) { 

       final int temp = A[j - 1]; 
       A[j - 1] = A[j]; 
       A[j] = temp; 

       j--; 

      } 

     } 

     return A; 

    } 

    public static int[] insertionSortInstructor(final int[] A) { 

     // Check for illegal cases 
     if (A == null || A.length == 0) { 

      throw new IllegalArgumentException("A is not populated"); 

     } 

     for (int i = 0; i < A.length; i++) { 

      int j = i; 

      while (j > 0 && A[j - 1] > A[j]) { 

       final int temp = A[j]; 
       A[j] = A[j - 1]; 
       A[j - 1] = temp; 

       j--; 

      } 

     } 

     return A; 

    } 

    @BeforeExperiment 
    void setUp() throws IOException { 
     try (final DataInputStream dis = new DataInputStream(
       Files.newInputStream(Paths.get("C:/Program Files/iTunes/iTunes.exe")))) { 
      final List<Integer> list = new ArrayList<Integer>(); 
      while (true) { 
       try { 
        list.add(dis.readInt()); 
       } catch (final EOFException e) { 
        break; 
       } 
      } 

      data = list.stream().mapToInt(i -> i).toArray(); 
      System.out.println("Data size = " + data.length); 
     } 
    } 

    // data to sort 
    private static int[] data; 

    @Benchmark 
    public void insertionSort(final int reps) { 
     for (int i = 0; i < reps; i++) { 
      insertionSort(data); 
     } 
    } 

    @Benchmark 
    public void insertionSortInstructor(final int reps) { 
     for (int i = 0; i < reps; i++) { 
      insertionSortInstructor(data); 
     } 
    } 

    @Benchmark 
    public void jdkSort(final int reps) { 
     for (int i = 0; i < reps; i++) { 
      Arrays.sort(data); 
     } 
    } 

    public static void main(final String[] args) { 
     CaliperMain.main(SortBenchmark.class, args); 
    } 
} 

Помимо

Честно говоря, я был удивлен результатом, что JDK медленнее. Поэтому я взглянул на the source code. Кажется, что три алгоритма сортировки используются JDK в зависимости от пороговых значений (сортировка слияния, quicksort для менее 286 элементов и сортировка вставки для менее 47 элементов).

Поскольку набор данных, который был у меня был, был довольно большой для начала, сначала начинается сортировка слияния, которая имеет сложность пространства O (n) в виде второй копии массива. Таким образом, это, вероятно, дополнительные распределения кучи, которые вызывают дополнительное время.

+0

Я только что запустил свой тестовый код на MacOS 10.11 Java 8u77b03 с использованием компилятора 'javac', и это были мои результаты (что еще страннее, теперь мой код работает значительно быстрее, чем мой инструктор): ' Мой сортировка вставки заняла 56634,0 миллисекунды. Другие вставки сортировка взяла 146979.0 миллисекунд. ' –

0

Вы ничего не увидите в байткод Java, чтобы объяснить. Такие вещи могут быть результатом либо машинных инструкций (созданных компилятором JIT точно в срок), либо даже микропроцессора (в частности, эффекта одного из кэшей процессора).

Следует отметить, что в этом алгоритме сортировки для каждой итерации цикла необходимо загружать только A [j], поскольку на последней итерации был загружен A [j-1]. Таким образом, самым последним загруженным значением был A [j] (при условии, что я только что заявил, был использован на некотором уровне). Поэтому в цикле алгоритм, который сначала сохраняет A [j], может вести себя по-другому, потому что эти значения были недавно загружены проверкой цикла, так что значение с большей вероятностью останется в регистре или в кеше процессора или будет к одной загрузке памяти из-за оптимизации в JIT-сгенерированном машинного кода.

Это правда (как показывает вышеприведенный ответ), что очень трудно точно узнать, какие последовательности машинных команд выполняются (JIT имеет несколько разных уровней компиляции, каждая с разными оптимизациями, в зависимости от того, насколько часто метод , и существует множество различных JIT-оптимизаций, которые могут взаимодействовать, приводя к разному машинным кодам). Также трудно узнать, какие обращения к памяти должны поступать в ОЗУ и которых избегают попадания в кеш памяти в процессоре. Несомненно, некоторые комбинации вышеупомянутых эффектов влияют на производительность здесь. Порядок алгоритма оказывает наибольшее влияние на производительность, но помимо этого существует много оптимизаций в JIT и микропроцессоре, которые могут повлиять на производительность, но не все из них очевидны для прогнозирования.

Моим инстинктом является то, что это результат работы кэша памяти процессора, который лучше работает с вашей последовательностью операций, частично из-за того, что для каждой итерации цикла необходимо загружать только A [j].