Benchmark : concaténer des listes

Benchmark : concaténer des listes

Il y a peu, je suis tombé sur ce bout de code qui concatène deux listes en utilisant la classe utilitaire Lists de Guava puis l’API Stream :

Lists.newArrayList(collectionOriginal,collectionValue)
        .stream()
        .flatMap(Collection::stream)
        .toList();

Comme ce bout de code est dans le chemin critique de l’application et donc appelé très fréquemment, je me suis demandé si c’était la meilleure manière de concaténer deux listes.

On peut concaténer deux listes de beaucoup de manières différentes :

  • Avec le code ci-dessus
  • Avec Stream.concat()
  • Avec Stream.of()
  • Avec ListUtils d’Apache Commons Collection
  • Avec ArrayList.addAll()

Il y a certainement d’autre manière, mais je voulais déjà comparer ces cinq là !

Pour comparer ces cinq solutions, rien de mieux que JMH – The Java Microbenchmark Harness. Cet outil nous permet d’écrire des micro-benchmarks pertinents en tenant compte des caractéristiques internes de la JVM. Il offre aussi des outils intégrés pour analyser les performances de nos tests (profiler, disassembler, …). Si vous ne connaissez pas JMH, vous pouvez vous référer à cet article : INTRODUCTION À JMH – JAVA MICROBENCHMARK HARNESS.

Le benchmark

Pour pouvoir comparer sur plusieurs tailles de liste, j’utilise @Param pour paramétriser la taille des listes à concaténer. Voici le setup que j’ai utilisé :

@Param({"10", "1000", "10000"})
int size;

List<String> list1;
List<String> list2;

@Setup
public void setup() {
    list1 = new ArrayList<>(size);
    list2 = new ArrayList<>(size);
    for (int i = 0; i < size; i++) {
        list1.add("list1-" + i);
        list2.add("list2-" + i);
    }
}

Voici ensuite l'implémentation des cinq benchmarks

@Benchmark
public List<String> concatWithStreamConcat() {
    return Stream.concat(list1.stream(), list2.stream())
            .toList();
}

@Benchmark
public List<String> concatWithStreamOf() {
    return Stream.of(list1, list2)
            .flatMap(List::stream)
            .toList();
}

@Benchmark
public List<String> concatWithGuava() {
    return Lists.newArrayList(list1, list2)
            .stream()
            .flatMap(Collection::stream)
            .toList();
}

@Benchmark
public List<String> concatWithCommonsCollection() {
    return ListUtils.union(list1, list2);
}

@Benchmark
public List<String> concatWithNewArrayList() {
    List<String> newList = new ArrayList<>(list1.size() + list2.size());
    newList.addAll(list1);
    newList.addAll(list2);
    return newList;
}

Les résultats

Tous les tests ont été exécutés sur mon laptop : Intel(R) Core(TM) i7-1260P 8 coeurs (16 avec hyperthreading) – Ubuntu 24.10.

La version de Java utilisé est openjdk version "21.0.5" 2024-10-15 LTS.

 Benchmark                               (size)  Mode  Cnt       Score        Error  Units
 ConcatList.concatWithCommonsCollection      10  avgt    5      57,706 ±     27,376  ns/op
 ConcatList.concatWithCommonsCollection    1000  avgt    5    1631,110 ±    472,323  ns/op
 ConcatList.concatWithCommonsCollection   10000  avgt    5   15434,192 ±   2164,191  ns/op
 ConcatList.concatWithGuava                  10  avgt    5     225,249 ±     83,069  ns/op
 ConcatList.concatWithGuava                1000  avgt    5    7967,360 ±   8693,234  ns/op
 ConcatList.concatWithGuava               10000  avgt    5  108601,706 ± 104439,847  ns/op
 ConcatList.concatWithNewArrayList           10  avgt    5      54,327 ±     10,894  ns/op
 ConcatList.concatWithNewArrayList         1000  avgt    5    1515,667 ±    595,135  ns/op
 ConcatList.concatWithNewArrayList        10000  avgt    5   14827,026 ±   1253,715  ns/op
 ConcatList.concatWithStreamConcat           10  avgt    5     111,837 ±     26,451  ns/op
 ConcatList.concatWithStreamConcat         1000  avgt    5    7104,415 ±   4030,866  ns/op
 ConcatList.concatWithStreamConcat        10000  avgt    5   57587,144 ±  13994,551  ns/op
 ConcatList.concatWithStreamOf               10  avgt    5     157,634 ±     55,757  ns/op
 ConcatList.concatWithStreamOf             1000  avgt    5    5915,797 ±   1692,437  ns/op
 ConcatList.concatWithStreamOf            10000  avgt    5   58156,102 ±   1655,090  ns/op

Ce qui saute directement aux yeux, c'est que la version utilisée aujourd'hui est la pire :(.
Si on la laisse de côté, on constate que concatWithStreamOf et concatWithStreamConcat ont quasiment la même performance, ainsi que concatWithNewArrayList et concatWithCommonsCollection. En regardant le code de ListUtils.union(list1, list2) on s’aperçoit qu'il fait exactement ce qui est fait dans concatWithNewArrayList.

Analyse plus poussée

Pour aller plus loin dans l'analyse, relançons le benchmark avec le profiler GC intégré à JMH via l'option ligne de commande -prof gc, c'est souvent un bon début ;). Ce qui m’intéresse tout particulièrement, c'est le débit d'allocation mémoire (allocation rate).

ConcatList.concatWithCommonsCollection:·gc.alloc.rate.norm                   10  avgt    5     232,012 ±     0,002    B/op
ConcatList.concatWithCommonsCollection:·gc.alloc.rate.norm                 1000  avgt    5   16072,479 ±     0,036    B/op
ConcatList.concatWithCommonsCollection:·gc.alloc.rate.norm                10000  avgt    5  160077,107 ±     0,434    B/op
ConcatList.concatWithGuava:·gc.alloc.rate.norm                               10  avgt    5     888,058 ±     0,009    B/op
ConcatList.concatWithGuava:·gc.alloc.rate.norm                             1000  avgt    5   16969,104 ±     0,221    B/op
ConcatList.concatWithGuava:·gc.alloc.rate.norm                            10000  avgt    5  212148,835 ±     2,822    B/op
ConcatList.concatWithNewArrayList:·gc.alloc.rate.norm                        10  avgt    5     232,011 ±     0,003    B/op
ConcatList.concatWithNewArrayList:·gc.alloc.rate.norm                      1000  avgt    5   16072,511 ±     0,040    B/op
ConcatList.concatWithNewArrayList:·gc.alloc.rate.norm                     10000  avgt    5  160076,834 ±     0,366    B/op
ConcatList.concatWithStreamConcat:·gc.alloc.rate.norm                        10  avgt    5     424,023 ±     0,006    B/op
ConcatList.concatWithStreamConcat:·gc.alloc.rate.norm                      1000  avgt    5    8344,716 ±     0,173    B/op
ConcatList.concatWithStreamConcat:·gc.alloc.rate.norm                     10000  avgt    5   80350,980 ±     1,306    B/op
ConcatList.concatWithStreamOf:·gc.alloc.rate.norm                            10  avgt    5     824,037 ±     0,007    B/op
ConcatList.concatWithStreamOf:·gc.alloc.rate.norm                          1000  avgt    5   16905,076 ±     0,208    B/op
ConcatList.concatWithStreamOf:·gc.alloc.rate.norm                         10000  avgt    5  212083,533 ±     2,497    B/op

Ici, on va s’intéresse à la valeur de gc.alloc.rate.norm c'est le débit d'allocation mémoire normalisé par opération, et on voit tout de suite une grande différence. Si on prend comme référence ConcatList.concatWithNewArrayList :

  • concatWithCommonsCollection : allocation identique.
  • concatWithGuava : 4x plus d'allocation.
  • concatWithStreamConcat : 2x plus d'allocation.
  • concatWithStreamOf : 4x plus d'allocation.

Cela explique une partie des différences de performance, mais pas tout.

Relançons le test avec le profiler stack qui est un profiler simple et naïf intégré à JMH.

# Benchmark: fr.loicmathieu.jmh.ConcatList.concatWithCommonsCollection
# Parameters: (size = 10)

....[Thread state: RUNNABLE]........................................................................
 33,3%  50,0% $lt;stack is empty, everything is filtered?>
 31,6%  47,4% fr.loicmathieu.jmh.ConcatList.concatWithCommonsCollection
  1,5%   2,3% fr.loicmathieu.jmh.jmh_generated.ConcatList_concatWithCommonsCollection_jmhTest.concatWithCommonsCollection_avgt_jmhStub
  0,1%   0,1% java.util.ArrayList.<init>
  0,1%   0,1% java.util.Arrays.copyOf
  0,0%   0,0% org.apache.commons.collections4.ListUtils.union
  0,0%   0,0% java.lang.invoke.MethodHandle.maybeCustomize

# Benchmark: fr.loicmathieu.jmh.ConcatList.concatWithGuava
# Parameters: (size = 10)

....[Thread state: RUNNABLE]........................................................................
 33,3%  50,0% $lt;stack is empty, everything is filtered?>
 19,2%  28,8% java.util.stream.SpinedBuffer.ensureCapacity
  5,3%   8,0% java.util.stream.AbstractPipeline.wrapSink
  3,4%   5,1% java.util.stream.ReferencePipeline.toArray
  3,2%   4,8% java.util.stream.SpinedBuffer.copyInto
  2,1%   3,1% fr.loicmathieu.jmh.jmh_generated.ConcatList_concatWithGuava_jmhTest.concatWithGuava_avgt_jmhStub
  0,1%   0,1% java.util.stream.StreamSupport.stream
  0,0%   0,0% java.util.stream.ReferencePipeline.lambda$toArray$0
  0,0%   0,0% java.util.ArrayList.spliterator
  0,0%   0,0% java.util.stream.SpinedBuffer.inflateSpine

# Benchmark: fr.loicmathieu.jmh.ConcatList.concatWithNewArrayList
# Parameters: (size = 10)

....[Thread state: RUNNABLE]........................................................................
 33,3%  50,0% $lt;stack is empty, everything is filtered?>
 24,4%  36,6% java.util.ArrayList.addAll
  8,7%  13,1% fr.loicmathieu.jmh.jmh_generated.ConcatList_concatWithNewArrayList_jmhTest.concatWithNewArrayList_avgt_jmhStub
  0,1%   0,2% java.util.Arrays.copyOf
  0,0%   0,1% java.util.ArrayList.<init>
  0,0%   0,0% fr.loicmathieu.jmh.ConcatList.concatWithNewArrayList


# Benchmark: fr.loicmathieu.jmh.ConcatList.concatWithStreamConcat
# Parameters: (size = 10)

....[Thread state: RUNNABLE]........................................................................
 33,3%  50,0% $lt;stack is empty, everything is filtered?>
 22,5%  33,8% java.util.ArrayList$ArrayListSpliterator.forEachRemaining
  5,8%   8,7% fr.loicmathieu.jmh.ConcatList.concatWithStreamConcat
  3,7%   5,6% java.util.stream.ReferencePipeline.toArray
  1,1%   1,7% fr.loicmathieu.jmh.jmh_generated.ConcatList_concatWithStreamConcat_jmhTest.concatWithStreamConcat_avgt_jmhStub
  0,0%   0,1% java.util.ImmutableCollections.listFromTrustedArrayNullsAllowed
  0,0%   0,1% java.util.stream.ReferencePipeline.lambda$toArray$0
  0,0%   0,1% java.util.stream.StreamSupport.stream
  0,0%   0,0% java.util.ArrayList.spliterator



# Benchmark: fr.loicmathieu.jmh.ConcatList.concatWithStreamOf
# Parameters: (size = 10)

....[Thread state: RUNNABLE]........................................................................
 33,3%  50,0% $lt;stack is empty, everything is filtered?>
 16,9%  25,4% java.util.ArrayList$ArrayListSpliterator.forEachRemaining
  4,5%   6,8% java.util.stream.SpinedBuffer.copyInto
  4,5%   6,7% java.util.stream.AbstractPipeline.wrapSink
  3,4%   5,2% java.util.stream.SpinedBuffer.ensureCapacity
  3,4%   5,0% fr.loicmathieu.jmh.jmh_generated.ConcatList_concatWithStreamOf_jmhTest.concatWithStreamOf_avgt_jmhStub
  0,3%   0,5% java.util.stream.ReferencePipeline.toArray
  0,1%   0,2% java.util.stream.ReferencePipeline$Head.forEach
  0,0%   0,0% java.util.ArrayList.spliterator
  0,0%   0,0% java.util.stream.SpinedBuffer.inflateSpine

concatWithCommonsCollection et concatWithNewArrayList utilisent tous les deux java.util.Arrays.copyOf, cette méthode est annotée par @IntrinsicCandidate ce qui veut dire qu'il y a un intrinsic qui permet de l'optimiser directement au sein de la JVM. À ma connaissance, même si l'annotation contient Candidate dans le nom, au sein de la JVM OpenJDK d'Oracle il y a toujours au moins un intrinsic pour une des architectures supportées (ce qui peut ne pas être le cas pour d'autres fournisseurs de JVM). Cet intrinsic permet une copie efficiente en mémoire des tableaux des éléments de la liste sans nécessiter de parcours de la liste et copie un à un des éléments.

concatWithStreamConcat et concatWithStreamOf tous les deux parcourent tous les éléments de la liste via java.util.ArrayList$ArrayListSpliterator.forEachRemaining ce qui est moins efficace.

concatWithGuava parcours aussi tous les éléments de la liste, mais en plus va créer une liste avec les deux premières listes ce qui va doubler le nombre de fois où les éléments sont copiés !

Conclusion

Le code le plus simple est souvent le plus performant même s'il n'est pas forcément le plus élégant.

La JVM contient de nombreux intrinsics qui permettent des opérations de copie en mémoire de tableaux, ceux-ci sont primordiaux quand on travaille avec de grande liste de données.

Bien sûr, cette optimisation n'a du sens que si le code appelé est dans le chemin critique de l’application, ici, la concaténation est potentiellement réalisée des milliers de fois par seconde dans Kestra, c'est donc important qu'elle soit réalisée de la manière la plus performante.

Pour rappel, cet article contient des nombres qui ne sont que ça, des nombres, ils peuvent être très différents d'un contexte à l'autre. Il est toujours mieux de réaliser vous-même vos benchmarks, dans votre contexte puis de les valider dans un environnement réel pour en déduire la pertinence.

Pour les curieux, voici la PR dans le repo Kestra: https://github.com/kestra-io/kestra/pull/7160 et voici le benchmark complet : ConcatList.java.

Laisser un commentaire

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur la façon dont les données de vos commentaires sont traitées.