Java Math.performances min / max

EDIT: maaartinus a donné la réponse que je cherchais et les données de tmyklebu sur le problème ont beaucoup aidé, donc merci à tous les deux! :)

j'ai lu un peu sur la façon dont HotSpot a certains "intrinsèques" qui injecte dans le code, spécialement pour Java standard Math libs ( d'ici )

donc j'ai décidé de donner un essai, pour voir combien différence HotSpot pourrait faire contre faire la comparaison directement (surtout depuis que j'ai entendu min / max peut compiler à l'asm sans branchement).

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

    return b;
}

C'est mon implémentation. D'une autre question, j'ai lu que l'utilisation de l'opérateur ternaire utilise un registre supplémentaire, je n'ai pas trouvé de différences significatives entre faire un bloc SI et utiliser un opérateur ternaire (c'est-à-dire, retourner ( a > b ) ? a: b).

l'Allocation d'une 8Mb int array (c'est à dire, 2 millions de valeurs), et la randomisation, je fais le test suivant:

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

j'utilise un Objet de référence dans un bloc "essayer avec les ressources". Quand il termine, il appelle close() sur l'objet et imprime le temps que le bloc a pris pour terminer. Les tests sont effectués séparément en commentant les appels max dans le code ci-dessus.

" max " est ajouté à une liste en dehors du bloc de référence et imprimé plus tard, afin d'éviter que la JVM optimise tout le bloc.

le tableau est aléatoire chaque fois que le test court.

l'Exécution de l' test 6 fois, il donne ces résultats:

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 

donc assez stable après le premier essai, et l'exécution des essais donne à nouveau des résultats similaires.

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 

encore une fois, résultats très stables après la première course.

la question Est: pourquoi? C'est une grosse différence. Et je n'ai aucune idée pourquoi. Même si je mets en œuvre mon max() la méthode exactement comme les Maths.max() (c'est à dire, le retour (a >= b) ? a: B) j'obtiens toujours de meilleurs résultats! Il ne fait aucun sens.

Specs:

CPU: Intel i5 2500, 3,3 Ghz. Version Java: JDK 8 (public march 18 release), x64. Debian Jessie (testing) x64.

je n'ai pas encore essayer avec 32 bits de la JVM.

EDIT: Autonome test comme demandé. Ajout d'une ligne pour forcer la JVM à précharger les mathématiques et des cours D'OpsMath. Cela élimine le coût de 18ms de la première itération pour le test 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.résultat 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.résultat 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

Toujours les mêmes résultats globaux. J'ai essayé de randomiser le tableau une seule fois, et en répétant les tests sur le même tableau, j'obtiens des résultats plus rapides dans l'ensemble, mais la même différence de 2x entre les mathématiques Java.max et OpsMath.Max.

24
demandé sur TheStack 2014-03-31 05:17:57

3 réponses

il est difficile de dire pourquoi Math.max est plus lent qu'un Ops.max , mais il est facile de dire pourquoi ce benchmark favorise fortement la ramification en mouvements conditionnels: sur la n - e itération, la probabilité de

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

n'étant pas égal à max est la probabilité que array[n-1] est plus grand que tous les éléments précédents. De toute évidence, cette probabilité devient de plus en plus faible avec la croissance n et compte tenu

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

c'est plutôt négligeable, la plupart du temps. L'instruction conditionnelle move est insensible à la probabilité de branchement, elle prend toujours le même temps à exécuter. L'instruction de déplacement conditionnel est plus rapide que la prédiction de branche si la branche est très difficile à prédire. D'autre part, la prédiction de branche est plus rapide si la branche peut être bien prédite avec une forte probabilité. Actuellement, Je ne suis pas sûr de la vitesse de mouvement conditionnel par rapport à la meilleure et le pire cas de ramification. 1

dans votre cas, toutes les branches sauf les premières sont assez prévisibles. À partir d'environ n == 10 , il n'y a aucun intérêt à utiliser des mouvements conditionnels car la branche est plutôt garantie d'être prédite correctement et peut être exécutée en parallèle avec d'autres instructions (je suppose que vous avez besoin d'exactement un cycle par itération).

cela semble se produire pour les algorithmes calculant minimum / maximum ou faisant certaines tri inefficace (une bonne prévisibilité des branches signifie une faible entropie par étape).


1 le mouvement conditionnel et la branche prévue prennent un cycle. Le problème avec le premier est qu'il a besoin de ses deux opérandes et cela demande des instructions supplémentaires. En fin de compte, le chemin critique peut être plus long et/ou l'ALUs saturé alors que l'Unité de branchement est inactif. Souvent, mais pas toujours, les branches peuvent être prédits bien dans la pratique applications; c'est pourquoi la prédiction de branche a été inventée en premier lieu.

en ce qui concerne les détails coriaces du moment du déménagement conditionnel par rapport à la prédiction de la direction dans le meilleur et le pire des cas, voir la discussion ci-dessous dans les commentaires. Mon mon propre benchmark montre que le mouvement conditionnel est beaucoup plus rapide que la prédiction de branche quand la prédiction de branche rencontre son pire cas, mais je ne peux pas ignorer résultats contradictoires . Nous avons besoin de quelques explication de ce qui fait vraiment la différence. D'autres points de repère et/ou analyses pourraient être utiles.

11
répondu maaartinus 2017-05-23 11:53:37

lorsque j'exécute votre code (modifié de manière appropriée) en utilisant Math.max sur une ancienne (1.6.0_27) JVM, la boucle d'attente ressemble à ceci:

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    "151900920"x8,%edx          ;*iinc
                                              ; - foo146::bench@92 (line 39)
0x00007f4b65425cb2: cmp    "151900920"x1ffff9,%edx
0x00007f4b65425cb8: jl     0x00007f4b65425c50

mis à part le préfixe REX placé étrangement (pas sûr de savoir de quoi il s'agit), voici une boucle qui a été déroulée 8 fois qui fait la plupart du temps ce que vous attendez---charges, comparaisons, et mouvements conditionnels. Fait intéressant, si vous changez l'ordre des arguments en max , ici il affiche l'autre type de 8-deep cmovl de la chaîne. Je suppose qu'il ne sait pas comment générer un arbre de 3 profondeurs de cmovl s ou 8 chaînes séparées cmovl à fusionner après la boucle est faite.

avec le OpsMath.max explicite, il se transforme en un ratsnest de branches conditionnel et inconditionnel qui est déroulé 8 fois. Je ne vais pas poster la boucle; c'est pas joli. En gros, chaque mov/cmp/cmovl ci-dessus se brise en une charge, une comparaison et un saut conditionnel à où un mov et un jmp arrive. Fait intéressant , si vous changez l'ordre des arguments en max , ici il affiche une chaîne de 8 niveaux cmovle à la place. EDIT : comme le souligne @maaartinus, dit ratsnest de branches est en fait plus rapide sur certaines machines parce que le prédicteur de branche opère sa magie sur elles et ce sont des branches bien prédites.

j'hésiterais à tirer des conclusions de ce point de repère. Vous avez problèmes de construction de référence; vous devez exécuter un lot plus de fois que vous êtes et vous devez prendre en compte votre code différemment si vous voulez chronométrer le code le plus rapide de Hotspot. Au-delà du code d'emballage, vous ne mesurez pas la vitesse de votre max , ni la mesure dans laquelle Hotspot comprend ce que vous essayez de faire, ou quoi que ce soit d'autre de valeur ici. Les deux implémentations de max aboutiront à un code qui est tout à fait trop rapide pour qu'une sorte de mesure directe soit pertinents dans le contexte d'un programme plus vaste.

3
répondu tmyklebu 2014-03-31 14:32:43

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

On Ubuntu 13.10

j'ai couru le suivant:

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

et j'ai eu les résultats suivants (moyenne de nanosecondes prises pour chaque appel à max):

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

ainsi, à long terme, il semble que la deuxième mise en œuvre est la plus rapide, bien que d'une marge relativement faible.

afin d'avoir un test un peu plus scientifique, il fait sens de calculer le max de paires d'éléments où chaque appel est indépendante de la précédente. Ceci peut être fait en utilisant deux tableaux aléatoires au lieu d'un comme dans ce benchmark:

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

qui m'a donné:

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

la façon dont votre test est mis en place fait une énorme différence sur les résultats. La version JDK semble être la plus rapide dans ce scénario. Cette fois d'une marge relativement importante par rapport au cas précédent.

Quelqu'un a mentionné Caliper. Eh bien , si vous lisez le wiki , une des premières choses qu'ils disent à propos du micro-benchmarking est pas pour le faire: c'est parce qu'il est difficile d'obtenir des résultats précis en général. Je pense que c'est un exemple clair de cela.

1
répondu Giovanni Botta 2014-03-31 14:45:14