Déclarer plusieurs tableaux avec 64 éléments 1000 fois plus rapidement que déclarer un tableau de 65 éléments

91

Récemment, j'ai remarqué que déclarer un tableau contenant 64 éléments est beaucoup plus rapide (> 1000 fois) que de déclarer le même type de tableau avec 65 éléments.

Voici le code que j'ai utilisé pour tester ceci:

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

Cela dure environ 6 ms, si je remplace new double[64]par new double[65]cela, cela prend environ 7 secondes. Ce problème devient exponentiellement plus grave si le travail est réparti sur de plus en plus de threads, d'où mon problème.

Ce problème se produit également avec différents types de tableaux tels que int[65]ou String[65]. Ce problème ne se produit pas avec des chaînes volumineuses:, String test = "many characters";mais commence à se produire lorsque cela est modifié enString test = i + "";

Je me demandais pourquoi c'est le cas et s'il est possible de contourner ce problème.

Sipko
la source
3
Off-note: System.nanoTime()devrait être préféré System.currentTimeMillis()à l'analyse comparative.
rocketboy
4
Je suis juste curieux ? Êtes-vous sous Linux? Le comportement change-t-il avec le système d'exploitation?
bsd
9
Comment diable cette question a-t-elle obtenu un vote défavorable?
Rohit Jain
2
FWIW, je vois des écarts de performances similaires si j'exécute ce code avec byteau lieu de double.
Oliver Charlesworth
3
@ThomasJungblut: Alors, qu'est-ce qui explique l'écart dans l'expérience du PO?
Oliver Charlesworth

Réponses:

88

Vous observez un comportement causé par les optimisations effectuées par le compilateur JIT de votre machine virtuelle Java. Ce comportement est reproductible déclenché avec des tableaux scalaires jusqu'à 64 éléments et n'est pas déclenché avec des tableaux supérieurs à 64.

Avant d'entrer dans les détails, examinons de plus près le corps de la boucle:

double[] test = new double[64];

Le corps n'a aucun effet (comportement observable) . Cela signifie que cela ne fait aucune différence en dehors de l'exécution du programme, que cette instruction soit exécutée ou non. La même chose est vraie pour toute la boucle. Il peut donc arriver que l'optimiseur de code traduise la boucle en quelque chose (ou rien) avec le même comportement de synchronisation fonctionnel et différent.

Pour les benchmarks, vous devez au moins respecter les deux directives suivantes. Si vous l'aviez fait, la différence aurait été beaucoup plus petite.

  • Réchauffez le compilateur JIT (et l'optimiseur) en exécutant le benchmark plusieurs fois.
  • Utilisez le résultat de chaque expression et imprimez-le à la fin du benchmark.

Passons maintenant aux détails. Il n'est pas surprenant qu'une optimisation soit déclenchée pour les tableaux scalaires ne dépassant pas 64 éléments. L'optimisation fait partie de l' analyse Escape . Il place de petits objets et de petits tableaux sur la pile au lieu de les allouer sur le tas - ou mieux encore les optimise entièrement. Vous pouvez trouver quelques informations à ce sujet dans l'article suivant de Brian Goetz écrit en 2005:

L'optimisation peut être désactivée avec l'option de ligne de commande -XX:-DoEscapeAnalysis. La valeur magique 64 pour les tableaux scalaires peut également être modifiée sur la ligne de commande. Si vous exécutez votre programme comme suit, il n'y aura aucune différence entre les tableaux de 64 et 65 éléments:

java -XX:EliminateAllocationArraySizeLimit=65 Tests

Cela dit, je déconseille fortement d'utiliser de telles options de ligne de commande. Je doute que cela fasse une énorme différence dans une application réaliste. Je ne l'utiliserais que si j'étais absolument convaincu de la nécessité - et non sur la base des résultats de certains pseudo benchmarks.

nosid
la source
9
Mais pourquoi l'optimiseur détecte-t-il que le tableau de taille 64 est amovible mais pas 65
ug_
10
@nosid: Bien que le code de l'OP puisse ne pas être réaliste, il déclenche clairement un comportement intéressant / inattendu dans la JVM, qui peut avoir des implications dans d'autres situations. Je pense qu'il est juste de se demander pourquoi cela se produit.
Oliver Charlesworth
1
@ThomasJungblut Je ne pense pas que la boucle soit supprimée. Vous pouvez ajouter "int total" en dehors de la boucle et ajouter "total + = test [0];" à l'exemple ci-dessus. Ensuite, en imprimant le résultat, vous verrez que le total = 100 millions et il stull s'exécute en moins d'une seconde.
Sipko
1
Le remplacement sur pile consiste à remplacer le code interprété par un code compilé à la volée, au lieu de remplacer l'allocation de tas par l'allocation de pile. EliminateAllocationArraySizeLimit est la taille limite des tableaux considérés comme scalaires remplaçables dans l'analyse d'échappement. Donc, le point principal selon lequel l'effet est dû à l'optimisation du compilateur est correct, mais ce n'est pas dû à l'allocation de pile, mais au fait que la phase d'analyse d'échappement n'a pas remarqué que l'allocation n'est pas nécessaire.
kiheru
2
@Sipko: Vous écrivez que l'application n'est pas mise à l'échelle avec le nombre de threads. C'est une indication, que le problème n'est pas lié aux micro-optimisations dont vous parlez. Je recommande de regarder la grande image au lieu des petites pièces.
nosid
2

Il existe plusieurs façons de faire une différence en fonction de la taille d'un objet.

Comme nosid l'a indiqué, le JITC peut (très probablement) allouer de petits objets "locaux" sur la pile, et la taille limite pour les "petits" tableaux peut être de 64 éléments.

L'allocation sur la pile est beaucoup plus rapide que l'allocation en tas et, plus précisément, la pile n'a pas besoin d'être récupérée, donc la surcharge du GC est considérablement réduite. (Et pour ce cas de test, la surcharge du GC est probablement de 80 à 90% du temps total d'exécution.)

En outre, une fois que la valeur est allouée à la pile, le JITC peut effectuer une "élimination du code mort", déterminer que le résultat de l ' newn'est jamais utilisé nulle part, et, après s'être assuré qu'il n'y a aucun effet secondaire qui serait perdu, éliminer toute l' newopération, puis la boucle (maintenant vide) elle-même.

Même si le JITC ne fait pas d'allocation de pile, il est tout à fait possible que des objets plus petits qu'une certaine taille soient alloués dans un tas différemment (par exemple, à partir d'un «espace» différent) que des objets plus grands. (Cependant, cela ne produirait normalement pas de différences de synchronisation aussi dramatiques.)

Hot Licks
la source
En retard sur ce fil. Pourquoi l'allocation sur la pile est-elle plus rapide que l'allocation sur le tas? Selon quelques articles, l'allocation sur le tas prend ~ 12 instructions. Il n'y a pas beaucoup de place à l'amélioration.
Vortex du
@Vortex - L'allocation à la pile prend 1-2 instructions. Mais c'est pour allouer un cadre de pile entier. La trame de pile doit être allouée de toute façon pour avoir une zone de sauvegarde de registre pour la routine, de sorte que toutes les autres variables allouées en même temps sont "libres". Et comme je l'ai dit, la pile ne nécessite aucun GC. La surcharge GC pour un élément de tas est beaucoup plus grande que le coût de l'opération d'allocation de tas.
Hot Licks le