Подтвердить что ты не робот

Производительность Java Math.min/max

EDIT: maaartinus дал ответ, который я искал, и данные tmyklebu по этой проблеме очень помогли, так что спасибо!:)

Я немного ознакомился с тем, как HotSpot имеет некоторые "встроенные функции", которые вводят в код, особенно для Java standard Math libs (здесь)

Итак, я решил попробовать, чтобы увидеть, насколько сильно может повлиять HotSpot против прямого сравнения (особенно, так как я слышал, что min/max может скомпилироваться в unlessless asm).

    public static final int max ( final int a, final int b )
{
    if ( a > b )
    {
        return a;
    }

    return b;
}

Это моя реализация. Из другого вопроса SO, который я прочитал, с использованием тернарного оператора используется дополнительный регистр, я не обнаружил существенных различий между выполнением блока if и использованием тернарного оператора (т.е. Return (a > b)? A: b).

Выделение массива int 8Mb (т.е. 2 миллиона значений) и рандомизация его, я делаю следующий тест:

try ( final Benchmark bench = new Benchmark( "millis to max" ) )
    {
        int max = Integer.MIN_VALUE;

        for ( int i = 0; i < array.length; ++i )
        {
            max = OpsMath.max( max, array[i] );
            // max = Math.max( max, array[i] );
        }
    }

Я использую объект Benchmark в блоке try-with-resources. Когда он заканчивается, он вызывает функцию close() на объекте и печатает время завершения блока. Тесты выполняются отдельно, комментируя ввод/вывод максимальных вызовов в приведенном выше коде.

'max' добавляется в список вне контрольного блока и печатается позже, поэтому, чтобы избежать JVM, оптимизирующего весь блок.

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

Запуск теста 6 раз, он дает следующие результаты:

Java standard Math:

millis to max 9.242167 
millis to max 2.1566199999999998
millis to max 2.046396 
millis to max 2.048616  
millis to max 2.035761
millis to max 2.001044 

Достаточно стабильный после первого запуска, а запуск тестов снова дает аналогичные результаты.

OpsMath:

millis to max 8.65418 
millis to max 1.161559  
millis to max 0.955851 
millis to max 0.946642 
millis to max 0.994543 
millis to max 0.9469069999999999 

Опять же, очень стабильные результаты после первого запуска.

Вопрос: Почему?. Там есть большая разница. И я понятия не имею, почему. Даже если я реализую свой метод max() точно, например Math.max() (т.е. Return (a >= b)? A: b) Я все равно получаю лучшие результаты! Это не имеет никакого смысла.

Технические характеристики:

Процессор: Intel i5 2500, 3,3 ГГц. Версия Java: JDK 8 (публикация 18 марта), x64. Debian Jessie (тестовый выпуск) x64.

Мне еще нужно попробовать с 32-битным JVM.

EDIT: Самостоятельный тест по запросу. Добавлена ​​строка, чтобы заставить JVM предварительно загружать классы Math и OpsMath. Это исключает стоимость 18 мс первой итерации для теста OpsMath.

// Constant nano to millis.
final double TO_MILLIS = 1.0d / 1000000.0d;
// 8Mb alloc.
final int[] array = new int[(8*1024*1024)/4];
// Result and time array.
final ArrayList<Integer> results = new ArrayList<>();
final ArrayList<Double> times = new ArrayList<>();
// Number of tests.
final int itcount = 6;
// Call both Math and OpsMath method so JVM initializes the classes.
System.out.println("initialize classes " + 
OpsMath.max( Math.max( 20.0f, array.length ), array.length / 2.0f ));

final Random r = new Random();
for ( int it = 0; it < itcount; ++it )
{
    int max = Integer.MIN_VALUE;

    // Randomize the array.
    for ( int i = 0; i < array.length; ++i )
    {
        array[i] = r.nextInt();
    }

    final long start = System.nanoTime();
    for ( int i = 0; i < array.length; ++i )
    {
        max = Math.max( array[i], max );
            // OpsMath.max() method implemented as described.
        // max = OpsMath.max( array[i], max );
    }
    // Calc time.
    final double end = (System.nanoTime() - start);
    // Store results.
    times.add( Double.valueOf( end ) );
    results.add( Integer.valueOf(  max ) );
}
// Print everything.
for ( int i = 0; i < itcount; ++i )
{
    System.out.println( "IT" + i + " result: " + results.get( i ) );
    System.out.println( "IT" + i + " millis: " + times.get( i ) * TO_MILLIS );
}

Результат Java Math.max:

IT0 result: 2147477409
IT0 millis: 9.636998
IT1 result: 2147483098
IT1 millis: 1.901314
IT2 result: 2147482877
IT2 millis: 2.095551
IT3 result: 2147483286
IT3 millis: 1.9232859999999998
IT4 result: 2147482828
IT4 millis: 1.9455179999999999
IT5 result: 2147482475
IT5 millis: 1.882047

Результат OpsMath.max:

IT0 result: 2147482689
IT0 millis: 9.003616
IT1 result: 2147483480
IT1 millis: 0.882421
IT2 result: 2147483186
IT2 millis: 1.079143
IT3 result: 2147478560
IT3 millis: 0.8861169999999999
IT4 result: 2147477851
IT4 millis: 0.916383
IT5 result: 2147481983
IT5 millis: 0.873984

Все те же общие результаты. Я попытался с рандомизацией массива только один раз и повторил тесты по одному и тому же массиву, я получаю более быстрые результаты в целом, но та же 2x разница между Java Math.max и OpsMath.max.

4b9b3361

Ответ 1

Трудно сказать, почему Math.max медленнее, чем a Ops.max, но легко понять, почему этот тест сильно способствует ветвлению на условные ходы: на n -ой итерации вероятность

Math.max( array[i], max );

не равное max, является вероятностью, что array[n-1] больше, чем все предыдущие элементы. Очевидно, что эта вероятность становится все ниже и ниже с ростом n и заданной

final int[] array = new int[(8*1024*1024)/4];

это довольно незначительно в большинстве случаев. Условная инструкция перемещения нечувствительна к вероятности ветвления, для выполнения всегда требуется одинаковое количество времени. Условная инструкция перемещения быстрее прогноза ветвления, если ветвь очень трудно предсказать. С другой стороны, предсказание ветвей происходит быстрее, если ветвь может быть предсказана с большой вероятностью. В настоящее время я не уверен в скорости условного перемещения по сравнению с лучшим и худшим случаем ветвления. 1

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

Это, по-видимому, происходит для алгоритмов, вычисляющих минимум/максимум или выполняющих некорректную сортировку (хорошая предсказуемость ветки означает низкую энтропию на шаг).


1 Оба условного перемещения и предсказанная ветвь принимают один цикл. Проблема с первой заключается в том, что ей нужны два операнда, и это требует дополнительной инструкции. В конце концов критический путь может увеличиться и/или ALU насыщаться, пока ветвящийся блок не работает. Часто, но не всегда, ветки могут быть хорошо предсказаны в практических приложениях; что, в первую очередь, было изобретено предсказание ветвей.

Что касается подробных данных о времени условного перехода против прогноза ветвления в лучшем и худшем случае, см. обсуждение ниже в комментариях. Мой мой собственный тест показывает, что условное перемещение значительно быстрее, чем предсказание ветки, когда предсказание ветки встречает его наихудший случай, но я не могу игнорировать противоречивые результаты. Нам нужно какое-то объяснение того, что именно имеет значение. Некоторые дополнительные тесты и/или анализ могут помочь.

Ответ 2

Когда я запускаю ваш (соответствующим образом модифицированный) код с помощью Math.max на старой (1.6.0_27) JVM, горячий цикл выглядит следующим образом:

0x00007f4b65425c50: mov    %r11d,%edi         ;*getstatic array
                                              ; - foo146::[email protected] (line 40)
0x00007f4b65425c53: mov    0x10(%rax,%rdx,4),%r8d
0x00007f4b65425c58: mov    0x14(%rax,%rdx,4),%r10d
0x00007f4b65425c5d: mov    0x18(%rax,%rdx,4),%ecx
0x00007f4b65425c61: mov    0x2c(%rax,%rdx,4),%r11d
0x00007f4b65425c66: mov    0x28(%rax,%rdx,4),%r9d
0x00007f4b65425c6b: mov    0x24(%rax,%rdx,4),%ebx
0x00007f4b65425c6f: rex mov    0x20(%rax,%rdx,4),%esi
0x00007f4b65425c74: mov    0x1c(%rax,%rdx,4),%r14d  ;*iaload
                                              ; - foo146::[email protected] (line 40)
0x00007f4b65425c79: cmp    %edi,%r8d
0x00007f4b65425c7c: cmovl  %edi,%r8d
0x00007f4b65425c80: cmp    %r8d,%r10d
0x00007f4b65425c83: cmovl  %r8d,%r10d
0x00007f4b65425c87: cmp    %r10d,%ecx
0x00007f4b65425c8a: cmovl  %r10d,%ecx
0x00007f4b65425c8e: cmp    %ecx,%r14d
0x00007f4b65425c91: cmovl  %ecx,%r14d
0x00007f4b65425c95: cmp    %r14d,%esi
0x00007f4b65425c98: cmovl  %r14d,%esi
0x00007f4b65425c9c: cmp    %esi,%ebx
0x00007f4b65425c9e: cmovl  %esi,%ebx
0x00007f4b65425ca1: cmp    %ebx,%r9d
0x00007f4b65425ca4: cmovl  %ebx,%r9d
0x00007f4b65425ca8: cmp    %r9d,%r11d
0x00007f4b65425cab: cmovl  %r9d,%r11d         ;*invokestatic max
                                              ; - foo146::[email protected] (line 40)
0x00007f4b65425caf: add    $0x8,%edx          ;*iinc
                                              ; - foo146::[email protected] (line 39)
0x00007f4b65425cb2: cmp    $0x1ffff9,%edx
0x00007f4b65425cb8: jl     0x00007f4b65425c50

Помимо странного REX-префикса (не уверен, что это значит), здесь у вас есть цикл, который был развернут 8 раз, что в основном означает то, что вы ожидаете - нагрузки, сравнения и условные ходы. Интересно, что если вы поменяете порядок аргументов на max, здесь он выведет другой тип цепочки cmovl с 8 глубинами. Я думаю, он не знает, как создать трехмерное дерево cmovl или 8 отдельных цепочек cmovl, которые будут объединены после завершения цикла.

С явным OpsMath.max он превращается в ratsnest условных и безусловных ветвей, которые разворачиваются 8 раз. Я не собираюсь публиковать цикл; это некрасиво. В основном каждый mov/cmp/cmovl выше разбивается на нагрузку, сравнивается и условный переход туда, где происходят a mov и a jmp. Интересно, что если вы меняете порядок аргументов на max, здесь вместо этого вызывается цепочка cmovle с 8 глубинами. РЕДАКТИРОВАТЬ. Как указывает @maaartinus, упомянутые ratsnest ветвей на некоторых машинах быстрее быстрее, потому что предиктор ветки работает на них магией, и это хорошо спрогнозированные ветки.

Я бы не стал делать выводы из этого теста. У вас есть проблемы с эталонным построением; вы должны запускать его намного больше, чем вы, и вам придется по-разному влиять на ваш код, если вы хотите быстро найти самый быстрый код Hotspot. Помимо кода обертки, вы не измеряете, насколько быстро ваш max, или насколько хорошо Hotspot понимает, что вы пытаетесь сделать, или что-то еще ценное здесь. Обе реализации max приведут к тому, что код будет слишком быстрым, чтобы любые прямые измерения были значимыми в контексте более крупной программы.

Ответ 3

Использование JDK 8:

java version "1.8.0"
Java(TM) SE Runtime Environment (build 1.8.0-b132)
Java HotSpot(TM) 64-Bit Server VM (build 25.0-b70, mixed mode)

В Ubuntu 13.10

Я запустил следующее:

import java.util.Random;
import java.util.function.BiFunction;

public class MaxPerformance {
  private final BiFunction<Integer, Integer, Integer> max;
  private final int[] array;

  public MaxPerformance(BiFunction<Integer, Integer, Integer> max, int[] array) {
    this.max = max;
    this.array = array;
  }

  public double time() {
    long start = System.nanoTime();

    int m = Integer.MIN_VALUE;
    for (int i = 0; i < array.length; ++i) m = max.apply(m, array[i]);

    m = Integer.MIN_VALUE;
    for (int i = 0; i < array.length; ++i) m = max.apply(array[i], m);

    // total time over number of calls to max
    return ((double) (System.nanoTime() - start)) / (double) array.length / 2.0;
  }

  public double averageTime(int repeats) {
    double cumulativeTime = 0;
    for (int i = 0; i < repeats; i++)
      cumulativeTime += time();
    return (double) cumulativeTime / (double) repeats;
  }

  public static void main(String[] args) {
    int size = 1000000;
    Random random = new Random(123123123L);
    int[] array = new int[size];
    for (int i = 0; i < size; i++) array[i] = random.nextInt();

    double tMath = new MaxPerformance(Math::max, array).averageTime(100);
    double tAlt1 = new MaxPerformance(MaxPerformance::max1, array).averageTime(100);
    double tAlt2 = new MaxPerformance(MaxPerformance::max2, array).averageTime(100);

    System.out.println("Java Math: " + tMath);
    System.out.println("Alt 1:     " + tAlt1);
    System.out.println("Alt 2:     " + tAlt2);
  }

  public static int max1(final int a, final int b) {
    if (a >= b) return a;
    return b;
  }

  public static int max2(final int a, final int b) {
    return (a >= b) ? a : b; // same as JDK implementation
  }
}

И я получил следующие результаты (средние наносекунды, принятые за каждый вызов до максимума):

Java Math: 15.443555810000003
Alt 1:     14.968298919999997
Alt 2:     16.442204045

Итак, в долгосрочной перспективе похоже, что вторая реализация является самой быстрой, хотя и с относительно небольшим отрывом.

Чтобы иметь несколько более научный тест, имеет смысл вычислить максимум пар элементов, где каждый вызов не зависит от предыдущего. Это можно сделать, используя два рандомизированных массива вместо одного, как в этом тесте:

import java.util.Random;
import java.util.function.BiFunction;
public class MaxPerformance2 {
  private final BiFunction<Integer, Integer, Integer> max;
  private final int[] array1, array2;

  public MaxPerformance2(BiFunction<Integer, Integer, Integer> max, int[] array1, int[] array2) {
    this.max = max;
    this.array1 = array1;
    this.array2 = array2;
    if (array1.length != array2.length) throw new IllegalArgumentException();
  }

  public double time() {
    long start = System.nanoTime();

    int m = Integer.MIN_VALUE;
    for (int i = 0; i < array1.length; ++i) m = max.apply(array1[i], array2[i]);
    m += m; // to avoid optimizations!

    return ((double) (System.nanoTime() - start)) / (double) array1.length;
  }

  public double averageTime(int repeats) {
    // warm up rounds:
    double tmp = 0;
    for (int i = 0; i < 10; i++) tmp += time();
    tmp *= 2.0;

    double cumulativeTime = 0;
    for (int i = 0; i < repeats; i++)
        cumulativeTime += time();
    return cumulativeTime / (double) repeats;
  }

  public static void main(String[] args) {
    int size = 1000000;
    Random random = new Random(123123123L);
    int[] array1 = new int[size];
    int[] array2 = new int[size];
    for (int i = 0; i < size; i++) {
        array1[i] = random.nextInt();
        array2[i] = random.nextInt();
    }

    double tMath = new MaxPerformance2(Math::max, array1, array2).averageTime(100);
    double tAlt1 = new MaxPerformance2(MaxPerformance2::max1, array1, array2).averageTime(100);
    double tAlt2 = new MaxPerformance2(MaxPerformance2::max2, array1, array2).averageTime(100);

    System.out.println("Java Math: " + tMath);
    System.out.println("Alt 1:     " + tAlt1);
    System.out.println("Alt 2:     " + tAlt2);
  }

  public static int max1(final int a, final int b) {
    if (a >= b) return a;
    return b;
  }

  public static int max2(final int a, final int b) {
    return (a >= b) ? a : b; // same as JDK implementation
  }
}

Который дал мне:

Java Math: 15.346468170000005
Alt 1:     16.378737519999998
Alt 2:     20.506475350000006

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

Кто-то упомянул суппорт. Хорошо, если вы читаете вики, одна из первых вещей, которые они говорят о микро-бенчмаркинге, не, чтобы сделать это: this потому что трудно получить точные результаты в целом. Я думаю, что это наглядный пример.