Java Math.rendimiento mínimo / máximo


EDITAR: maaartinus dio la respuesta que estaba buscando y los datos de tmyklebu sobre el problema ayudaron mucho, así que gracias a ambos! :)

He leído un poco sobre cómo HotSpot tiene algunos "intrínsecos" que se inyectan en el código, especialmente para las bibliotecas matemáticas estándar de Java (desde aquí)

Así que decidí darle una oportunidad, para ver cuánta diferencia podría hacer HotSpot en contra de hacer la comparación directamente (especialmente porque he oído que min / max puede compilar a branchless asm).

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

    return b;
}

Esa es mi implementación. De otra pregunta TAN he leído que el uso del operador ternario utiliza un registro extra, no he encontrado diferencias significativas entre hacer un bloque if y el uso de un operador ternario (es decir, return ( a > b ) ? a: b).

Asignando una matriz int de 8Mb (es decir, 2 millones de valores), y aleatorizándola, hago la siguiente prueba:

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] );
        }
    }

Estoy usando un objeto Benchmark en un bloque try-with-resources. Cuando termina, llama a close () en el objeto e imprime el tiempo que el bloque tardó en completarse. Las pruebas se realizan por separado comentando in / out las llamadas máximas en el código anterior.

'max' se agrega a una lista fuera del bloque de referencia y se imprime más tarde, para evitar que la JVM optimice todo el bloque.

La matriz se aleatoriza cada vez que se ejecuta la prueba.

Ejecutando la prueba 6 veces, da estos resultados:

Matemáticas estándar de Java:

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 

Tan bastante estable después de la primera carrera, y corriendo el las pruebas nuevamente dan resultados similares.

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 

De nuevo, resultados muy estables después de la primera ejecución.

La pregunta es: ¿Por qué? Esa es una gran diferencia allí. Y no tengo idea de por qué. Incluso si implemente mi método max () exactamente como Math.max () (es decir, return (a >= b) ? a: b) ¡Todavía obtengo mejores resultados! No tiene sentido.

Especificaciones:

CPU: Intel i5 2500, 3,3 Ghz. Versión Java: JDK 8 (public march 18 release), x64. Debian Jessie (versión de prueba) x64.

Todavía tengo que probar con JVM de 32 bits.

EDITAR: Prueba autónoma según lo solicitado. Se ha añadido una línea para forzar a la JVM a precargar las clases Math y OpsMath. Eso elimina el costo de 18 ms de la primera iteración para la prueba 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.resultado máximo:

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.resultado máximo:

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

Siguen siendo los mismos resultados generales. He intentado aleatorizar la matriz solo una vez, y repetir las pruebas sobre la misma matriz, me vuelvo más rápido resultados en general, pero la misma diferencia de 2x entre las matemáticas de Java.max y OpsMath.max.

Author: TheStack, 2014-03-31

3 answers

Es difícil saber por qué Math.max es más lento que un Ops.max, pero es fácil saber por qué este parámetro favorece fuertemente la ramificación a movimientos condicionales: En la n-ésima iteración, la probabilidad de

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

No siendo igual a max es la probabilidad de que array[n-1] sea mayor que todos los elementos anteriores. Obviamente, esta probabilidad se vuelve más y más baja con el crecimiento n y dado

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

Es bastante insignificante la mayor parte del tiempo. La instrucción de movimiento condicional es insensible a la probabilidad de ramificación, siempre toma la misma cantidad de tiempo para ejecutar. La instrucción de movimiento condicional es más rápida que la predicción de rama si la rama es muy difícil de predecir. Por otro lado, la predicción de ramas es más rápida si la rama se puede predecir bien con alta probabilidad. Actualmente, no estoy seguro de la velocidad del movimiento condicional en comparación con el mejor y peor caso de ramificación.1

En su caso, todas las ramas, excepto las primeras, son bastante predecibles. De sobre n == 10 en adelante, no tiene sentido usar movimientos condicionales ya que la rama está garantizada para ser predicha correctamente y puede ejecutarse en paralelo con otras instrucciones (supongo que necesita exactamente un ciclo por iteración).

Esto parece suceder para algoritmos que calculan mínimo/máximo o que hacen alguna clasificación ineficiente (una buena previsibilidad de rama significa baja entropía por paso).


1 Tanto el movimiento condicional como la rama predicha toman un ciclo. Problema con el primero es que necesita sus dos operandos y esto requiere instrucción adicional. Al final, la ruta crítica puede alargarse y / o saturarse el ALUs mientras la unidad de ramificación está inactiva. A menudo, pero no siempre, las ramas se pueden predecir bien en aplicaciones prácticas; es por eso que la predicción de ramas se inventó en primer lugar.

En cuanto a los detalles sangrientos de la sincronización de movimiento condicional vs.predicción de rama mejor y peor caso, vea la discusión a continuación en los comentarios. My my own benchmark muestra que el movimiento condicional es significativamente más rápido que la predicción de ramas cuando la predicción de ramas encuentra su peor caso, pero no puedo ignorar resultados contradictorios. Necesitamos alguna explicación de lo que hace exactamente la diferencia. Algunos puntos de referencia y / o análisis más podrían ayudar.

 11
Author: maaartinus,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-05-23 11:53:37

Cuando corro tu código (adecuadamente modificado) usando Math.max en una JVM antigua (1.6.0_27), el hot loop se ve así:

0x00007f4b65425c50: mov    %r11d,%edi         ;*getstatic array
                                              ; - foo146::bench@81 (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::bench@86 (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::bench@88 (line 40)
0x00007f4b65425caf: add    $0x8,%edx          ;*iinc
                                              ; - foo146::bench@92 (line 39)
0x00007f4b65425cb2: cmp    $0x1ffff9,%edx
0x00007f4b65425cb8: jl     0x00007f4b65425c50

Aparte del prefijo REX extrañamente colocado (no estoy seguro de qué se trata), aquí tienes un bucle que se ha desenrollado 8 veces que hace principalmente lo que esperarías: cargas, comparaciones y movimientos condicionales. Curiosamente, si cambias el orden de los argumentos a max, aquí sale el otro tipo de cadena cmovl de 8 profundidades. Supongo que no sabe cómo generar un árbol de 3 profundidades de cmovl s u 8 cadenas separadas cmovl que se fusionarán después de que se realice el bucle.

Con el explícito OpsMath.max, se convierte en un ratsnest de ramas condicionales e incondicionales que se desenrollan 8 veces. No voy a publicar el bucle; no es bonito. Básicamente cada mov/cmp/cmovl anterior se divide en una carga, una comparación y un salto condicional a donde a mov y a jmp suceden. Curiosamente, si cambias el orden de los argumentos a max, aquí se genera una cadena cmovle de 8 profundidades en su lugar. EDITAR: Como señala @maaartinus, dicho ratsnest de ramas es en realidad más rápido en algunas máquinas porque el predictor de ramas funciona su magia en ellas y estas son ramas bien predichas.

Dudaría en sacar conclusiones de este punto de referencia. Tienes problemas de construcción de benchmark; tienes que ejecutarlo un lote más veces de lo que eres y tienes que factorizar tu código de manera diferente si quieres cronometrar el código más rápido de Hotspot. Más allá de la wrapper code, no está midiendo qué tan rápido es su max, o qué tan bien entiende Hotspot lo que está tratando de hacer, o cualquier otra cosa de valor aquí. Ambas implementaciones de max darán como resultado un código que es completamente demasiado rápido para que cualquier tipo de medición directa sea significativa dentro del contexto de un programa más grande.

 3
Author: tmyklebu,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2014-03-31 14:32:43

Usando 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)

En Ubuntu 13.10

Corrí lo siguiente:

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

Y obtuve los siguientes resultados (promedio de nanosegundos tomados para cada llamada al máximo):

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

Así que a largo plazo parece que la segunda implementación es la más rápida, aunque por un margen relativamente pequeño.

Para tener una prueba un poco más científica, tiene sentido calcular el máximo de pares de elementos donde cada llamada es independiente de la anterior. Esto puede ser hecho mediante el uso de dos arrays aleatorios en lugar de uno como en este punto de referencia:

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

Que me dio:

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

La forma en que se configura su prueba hace una gran diferencia en los resultados. La versión JDK parece ser la más rápida en este escenario. Esta vez por un margen relativamente grande en comparación con el caso anterior.

Alguien mencionó Caliper. Bueno, si lees el wiki , una de las primeras cosas que dicen sobre el micro benchmarking es no hacerlo: esto es porque es difícil obtener resultados precisos en general. Creo que este es un claro ejemplo de ello.

 1
Author: Giovanni Botta,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2014-03-31 14:45:14