web-dev-qa-db-fra.com

Java lambdas 20 fois plus lent que les classes anonymes

J'ai vu beaucoup de questions ici sur les performances de Java lambdas, mais la plupart d'entre elles vont comme "les Lambdas sont légèrement plus rapides, mais deviennent plus lentes lors de l'utilisation des fermetures" ou "Warm-up vs temps d'exécution" sont différents "ou d'autres choses de ce genre.

Cependant, j'ai frappé une chose assez étrange ici. Considérez ce problème LeetCode :

Étant donné un ensemble d'intervalles qui ne se chevauchent pas, insérez un nouvel intervalle dans les intervalles (fusionnez si nécessaire).

Vous pouvez supposer que les intervalles ont été initialement triés en fonction de leurs heures de début.

Le problème a été marqué durement, j'ai donc supposé qu'une approche linéaire n'était pas ce qu'ils voulaient. J'ai donc décidé de trouver un moyen intelligent de combiner la recherche binaire avec des modifications de la liste d'entrée. Maintenant, le problème n'est pas très clair sur la modification de la liste d'entrée - il dit "insérer", même si la signature nécessite de renvoyer une référence à la liste, mais peu importe pour l'instant. Voici le code complet, mais seules les premières lignes sont pertinentes pour cette question. Je garde le reste ici juste pour que tout le monde puisse l'essayer:

public List<Interval> insert(List<Interval> intervals, Interval newInterval) {
    int start = Collections.binarySearch(intervals, newInterval,
                                         (i1, i2) -> Integer.compare(i1.start, i2.start));
    int skip = start >= 0 ? start : -start - 1;
    int end = Collections.binarySearch(intervals.subList(skip, intervals.size()),
                                       new Interval(newInterval.end, 0),
                                       (i1, i2) -> Integer.compare(i1.start, i2.start));
    if (end >= 0) {
        end += skip; // back to original indexes
    } else {
        end -= skip; // ditto
    }
    int newStart = newInterval.start;
    int headEnd;
    if (-start - 2 >= 0) {
        Interval prev = intervals.get(-start - 2);
        if (prev.end < newInterval.start) {
            // the new interval doesn't overlap the one before the insertion point
            headEnd = -start - 1;
        } else {
            newStart = prev.start;
            headEnd = -start - 2;
        }
    } else if (start >= 0) {
        // merge the first interval
        headEnd = start;
    } else { // start == -1, insertion point = 0
        headEnd = 0;
    }
    int newEnd = newInterval.end;
    int tailStart;
    if (-end - 2 >= 0) {
        // merge the end with the previous interval
        newEnd = Math.max(newEnd, intervals.get(-end - 2).end);
        tailStart = -end - 1;
    } else if (end >= 0) {
        newEnd = intervals.get(end).end;
        tailStart = end + 1;
    } else { // end == -1, insertion point = 0
        tailStart = 0;
    }
    intervals.subList(headEnd, tailStart).clear();
    intervals.add(headEnd, new Interval(newStart, newEnd));
    return intervals;
}

Cela a bien fonctionné et a été accepté, mais avec un temps d'exécution de 80 ms, alors que la plupart des solutions étaient de 4 à 5 ms et de 18 à 19 ms. Quand je les ai regardés, ils étaient tous linéaires et très primitifs. Pas quelque chose que l'on pourrait attendre d'un problème étiqueté "dur".

Mais voici la question: ma solution est également linéaire dans le pire des cas (car les opérations d'ajout/suppression sont du temps linéaire). Pourquoi est-ce plus lent? Et puis je l'ai fait:

    Comparator<Interval> comparator = new Comparator<Interval>() {
        @Override
        public int compare(Interval i1, Interval i2) {
            return Integer.compare(i1.start, i2.start);
        }
    };
    int start = Collections.binarySearch(intervals, newInterval, comparator);
    int skip = start >= 0 ? start : -start - 1;
    int end = Collections.binarySearch(intervals.subList(skip, intervals.size()),
                                       new Interval(newInterval.end, 0),
                                       comparator);

De 80 ms à 4 ms! Que se passe t-il ici? Malheureusement, je n'ai aucune idée du type de tests que LeetCode exécute ou dans quel environnement, mais quand même, n'est-ce pas 20 fois trop?

33
Sergei Tachenov

Vous rencontrez évidemment la première surcharge d'initialisation des expressions lambda. Comme déjà mentionné dans les commentaires, les classes pour les expressions lambda sont générées au moment de l'exécution plutôt que d'être chargées à partir de votre chemin de classe.

Cependant, être généré n'est pas la cause du ralentissement. Après tout, la génération d'une classe ayant une structure simple peut être encore plus rapide que le chargement des mêmes octets à partir d'une source externe. Et la classe interne doit également être chargée. Mais lorsque l'application n'a pas utilisé d'expressions lambda auparavant, même le cadre de génération des classes lambda doit être chargé (l'implémentation actuelle d'Oracle utilise ASM sous le capot). C'est la cause réelle du ralentissement, du chargement et de l'initialisation d'une douzaine de classes utilisées en interne, et non l'expression lambda elle-même.

Vous pouvez facilement le vérifier. Dans votre code actuel utilisant des expressions lambda, vous avez deux expressions identiques (i1, i2) -> Integer.compare(i1.start, i2.start). L'implémentation actuelle ne reconnaît pas cela (en fait, le compilateur ne fournit aucun indice non plus). Donc ici, deux instances lambda, ayant même des classes différentes, sont générées. Vous pouvez refactoriser le code pour n'avoir qu'un seul comparateur, similaire à votre variante de classe interne:

final Comparator<? super Interval> comparator
  = (i1, i2) -> Integer.compare(i1.start, i2.start);
int start = Collections.binarySearch(intervals, newInterval, comparator);
int skip = start >= 0 ? start : -start - 1;
int end = Collections.binarySearch(intervals.subList(skip, intervals.size()),
                                   new Interval(newInterval.end, 0),
                                   comparator);

Vous ne remarquerez aucune différence de performances significative, car ce n'est pas le nombre d'expressions lambda qui importe, mais juste le chargement de classe et l'initialisation du framework, qui se produisent exactement une fois.

Vous pouvez même le maximiser en insérant des expressions lambda supplémentaires comme

final Comparator<? super Interval> comparator1
    = (i1, i2) -> Integer.compare(i1.start, i2.start);
final Comparator<? super Interval> comparator2
    = (i1, i2) -> Integer.compare(i1.start, i2.start);
final Comparator<? super Interval> comparator3
    = (i1, i2) -> Integer.compare(i1.start, i2.start);
final Comparator<? super Interval> comparator4
    = (i1, i2) -> Integer.compare(i1.start, i2.start);
final Comparator<? super Interval> comparator5
    = (i1, i2) -> Integer.compare(i1.start, i2.start);

sans voir aucun ralentissement. C'est vraiment la surcharge initiale de la toute première expression lambda de tout le runtime que vous remarquez ici. Étant donné que Leetcode lui-même n'utilise apparemment pas d'expressions lambda avant d'entrer votre code, dont le temps d'exécution est mesuré, ce surcoût s'ajoute à votre temps d'exécution ici.

Voir aussi "Comment les fonctions lambda Java seront-elles compilées?" et "Une expression lambda crée-t-elle un objet sur le tas à chaque exécution?")

52
Holger