<
Media
>
Article

Orienter ses choix techniques en Java avec le microbenchmarking

7 min
15
/
07
/
2020


Pour s'adapter au changement dans le monde du logiciel, on souhaite réduire les boucles de feedback.
Et c'est alors que :

  • Les codes review deviennent du pair programming
  • Les cahiers des charges deviennent des Epics, avec des User Story et des critères d'acceptation
  • La couverture de code devient du TDD
  • La recette devient du test d'acceptation

Once upon a time

Très bien, la PO Estelle a alors spécifié une superbe User Story, avec des critères d'acceptation en Gherkin.
Benjamin et Bertrand l'ont développé en pair programming.
Ils ont commencé par développer :

  • des tests end-to-end
  • des tests d'intégration
  • des bouchons pour les endpoints
  • des tests d'acceptation

Et enfin ils ont développé les tests unitaires et le code de production en TDD.

Ducks everywhere

Voici comment ils ont décidé de faire quacker tous les canards jaunes dans le <span class="css-span">DuckService</span> :

<pre><code>public void makeAllYellowDucksQuack() {
   filterAllYellowDucksThen().forEach(makeItQuack);
}

private Stream<Duck> filterAllYellowDucksThen(){
   return iterateAllDucksThen().filter(yellowDucks);
}

private static Consumer<Duck> makeItQuack = Duck::quack;

private Stream<Duck> iterateAllDucksThen() {
   return ducks.stream();
}

private static Predicate<Duck> yellowDucks = Duck::isYellow;<code><pre>

Après quelques itérations, la PIC[^1] finit par indiquer que 100% des tests passent : le DoD est rempli.

Delivery time

La branche de feature est alors livrée au bencheur.

Il charge des data (anonymysées) dans sa base de données avec une volumétrie indiquée par le PO.

Verdict time

Le verdict tombe, il y a un point de contention au niveau de la méthode <span class="css-span">makeAllYellowDucksQuack()</span>. Le rapport est transmis à la dev-team, et la feature repart en fabrication.

Que d'efforts d'adaptation, pour au final se prendre un bon vieux tunnel.

Reflection time

Sans pour autant affirmer qu'on devrait se passer du bench, je vous annonce que là aussi on peut réduire la boucle de feedback en ce qui concerne les choix techniques, liés, entres autres, à la volumétrie !

Suivez bien comment va s'y prendre notre binôme.

MicroBenchmarking

Tout comme la couverture unitaire rend une application plus stable dans sa globalité : le benchmark unitaire peut la rendre plus performante.

Dans le vif du sujet : JMH

JMH is a Java harness for building, running, and analysing nano/micro/milli/macro benchmarks written in Java and other languages targetting the JVM.

Requirements

Attention à bien utiliser un JDK avec une JVM HotSpot[^2]
Les résultats produits avec une autre JVM peuvent ne pas être fiables :

Extrait d'un benchmark avec une JVM OpenJ9[^3] :

<pre><code># VM version: JDK 1.8.0_242, Eclipse OpenJ9 VM, openj9-0.18.1
# *** WARNING: This VM is not supported by JMH. The produced benchmark data can be completely wrong.
WARNING: Not a HotSpot compiler command compatible VM ("Eclipse OpenJ9 VM-1.8.0_242"), compilerHints are disabled.<code><pre>

Ajout des dépendances

Pour des résultats plus fiables, openjdk recommande de créer les benchmarks dans un projet dédié :

[...] setup a standalone project that depends on the jar files of your application

MAVEN

Ils créent donc un nouveau projet de benchmark avec l'archetype maven :

<pre><code>mvn archetype:generate \
   -DinteractiveMode=false \
   -DarchetypeGroupId=org.openjdk.jmh \
   -DarchetypeArtifactId=jmh-java-benchmark-archetype \
   -DarchetypeVersion=1.23 \
   -DgroupId=fr.younup.benchmark \
   -DartifactId=quackquack<code><pre>

Exraits du pom.xml :

<pre><code>&lt;dependency>
     &lt;groupId>org.openjdk.jmh&lt;/groupId>
     &lt;artifactId>jmh-core&lt;/artifactId>
     &lt;version>${jmh.version}&lt;/version>
&lt;/dependency>
&lt;dependency>
     &lt;groupId>org.openjdk.jmh&lt;/groupId>
     &lt;artifactId>jmh-generator-annprocess&lt;/artifactId>
     &lt;version>${jmh.version}&lt;/version>
     &lt;scope>provided&lt;/scope>
&lt;/dependency></code></pre>

<pre><code>&lt;properties>
     &lt;project.build.sourceEncoding>UTF-8&lt;/project.build.sourceEncoding>
     &lt;jmh.version>1.23&lt;/jmh.version>
     &lt;javac.target>1.8&lt;/javac.target>
     &lt;uberjar.name>benchmarks&lt;/uberjar.name>
&lt;/properties></code></pre>

<pre><code>&lt;plugin>
     &lt;groupId>org.apache.maven.plugins&lt;/groupId>
     &lt;artifactId>maven-compiler-plugin&lt;/artifactId>
     &lt;version>3.8.0&lt;/version>
     &lt;configuration>
          &lt;compilerVersion>${javac.target}&lt;/compilerVersion>
          &lt;source>${javac.target}&lt;/source>
          &lt;target>${javac.target}&lt;/target>
     &lt;/configuration>
&lt;/plugin>
&lt;plugin>
     &lt;groupId>org.apache.maven.plugins&lt;/groupId>
     &lt;artifactId>maven-shade-plugin&lt;/artifactId>
     &lt;version>3.2.1&lt;/version>
     &lt;executions>
          &lt;execution>
               &lt;phase>package&lt;/phase>
               &lt;goals>
                    &lt;goal>shade&lt;/goal>
               &lt;/goals>
               &lt;configuration>
                    &lt;finalName>${uberjar.name}&lt;/finalName>
                    &lt;transformers>
                         &lt;transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                              &lt;mainClass>org.openjdk.jmh.Main&lt;/mainClass>
                         &lt;/transformer>
                         &lt;transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
                         &lt;/transformers>
                         &lt;filters>
                              &lt;filter>
                                   &lt;artifact>*:*&lt;/artifact>
                                   &lt;excludes>
                                        &lt;exclude>META-INF/*.SF&lt;/exclude>
                                        &lt;exclude>META-INF/*.DSA&lt;/exclude>
                                        &lt;exclude>META-INF/*.RSA&lt;/exclude>
                                   &lt;/excludes>
                              &lt;/filter>
                         &lt;/filters>
                    &lt;/configuration>
               &lt;/execution>
          &lt;/executions>
&lt;/plugin></code></pre>

Le plugin Maven shade permet de packager tout le projet et ses dépendances dans un uber-jar.

An uber-jar is an "over-jar", one level up from a simple JAR, defined as one that contains both your package and all its dependencies in one single JAR file.

Source
Documentation

Ils ajoutent la dépendance vers leur projet :

<pre><code>&lt;dependency>
&lt;groupId>fr.younup&lt;/groupId>
&lt;artifactId>quackquack&lt;/artifactId>
&lt;version>1.0&lt;/version>
&lt;/dependency></code></pre>

Gradle

Il est également possible d'utiliser JMH dans un projet Gradle.
https://github.com/melix/jmh-gradle-plugin

Hello World

Ils effectuent un smoke-test de leur conf avec un Hello World :

<pre><code>public class MyBenchmark {
   @Benchmark
   public String helloWorldBenchmark() {
       return "Hello World";
   }
}<code><pre>

First run !

Command line :

<pre><code>mvn package
java -jar target/benchmarks.jar<code><pre>

Et avec IntelliJ (et Eclipse), on peut lancer le jar en <span class="css-span">Run Configuration</span>, avec la step de packaging en <span class="css-span">Before launch<span> :

alt text

C'est parti !

alt text

Analysons le rapport avec eux

<pre><code># Warmup: 5 iterations, 10 s each
# Measurement: 5 iterations, 10 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: fr.younup.MyBenchmark.helloWorldBenchmark<code><pre>

Une synthèse des paramètres (ici ceux par défaut) :

  • Une itération signifie "looper sur le benchmark autant de fois qu'il est possible dans le temps imparti pour chaque itération"
  • Le warmup est une étape de stabilisation du système, où les résultats ne sont pas comptabilisés
  • Le Benchmark mode indique l'unité des résultats, ici en opération/seconde
  • Les ops (operations) sont le nombre de fois que sont exécutées les fonctions de benchmark annotées.

<pre><code># Run progress: 0,00% complete, ETA 00:08:20
# Fork: 1 of 5
# Warmup Iteration   1: 173299771,520 ops/s
# Warmup Iteration   2: 173048557,050 ops/s
# Warmup Iteration   3: 203116377,327 ops/s
# Warmup Iteration   4: 203426633,927 ops/s
# Warmup Iteration   5: 200113928,777 ops/s
Iteration   1: 199441166,100 ops/s
Iteration   2: 203273185,356 ops/s
Iteration   3: 203443620,476 ops/s
Iteration   4: 200002433,027 ops/s
Iteration   5: 204119799,321 ops/s<code><pre>

Un Fork de benchmark permet de le répéter dans des sous-process isolés, afin de se rapprocher du contexte de prod en ce qui concerne le cpu, le compilateur JIT, le GC, les caches mémoires, et j'en oublie très certainement.

Il semble que le benchmark compte faire 5 forks. Voyons déjà ce que les logs du 1er nous apprennent :

  • Leur système parvient à faire environ 200 millions de Hello World par seconde !
  • On voit que 2 étapes de warmup semblent suffire à stabiliser

Et si vous tenez vraiment aux 4 forks restants :

La synthèse des 5 forks :

<pre><code>Result "fr.younup.MyBenchmark.helloWorldBenchmark":
 200953085,031 ±(99.9%) 1464321,134 ops/s [Average]
 (min, avg, max) = (196712422,268, 200953085,031, 204119799,321), stdev = 1954826,820
 CI (99.9%): [199488763,897, 202417406,165] (assumes normal distribution)<code><pre>

Le rapport donne ici :

  • La moyenne d'opérations par seconde (200 953 085)
  • La variance (1 464 321)
  • La distribution

À chacun d'en tirer les conclusions spécifiques à son contexte.

<pre><code># Run complete. Total time: 00:08:21<code><pre>

À lire attentivement au moins 1 fois :

<pre><code>REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up onwhy the numbers are the way they are. Use profilers (see -prof, -lprof), design factorialexperiments, perform baseline and negative tests that provide experimental control, make surethe benchmarking environment is safe on JVM/OS/HW level, ask for reviews from the domain experts.Do not assume the numbers tell you what you want them to tell.<code><pre>

C'est pas faux !

En résumé : prenez les résultats avec des pincettes et méfiez vous du biais de confirmation.

<pre><code>Benchmark                         Mode  Cnt          Score         Error  Units
MyBenchmark.helloWorldBenchmark  thrpt   25  200953085,031 ± 1464321,134  ops/s<code><pre>

Et pour finir, on a une synthèse des benchmarks effectués dans ce run (ici 1 seul).
On notera que la moyenne est nommée "Score", et que la variance est nommée "error".
On notera aussi que le "Cnt" (Count) correspond à nb_fork * nb_iteration.

Tweaking params

Pour diminuer la variance, le temps d'exécution, ou si les paramètres par défaut ne conviennent pas au contexte, on peut utiliser, entre autres, ces annotations et leurs paramètres :

<pre><code>public class MyBenchmark {

   @Fork(value = 1)
   @BenchmarkMode(Mode.AverageTime)
   @Warmup(iterations = 3, time = 4)
   @Measurement(iterations = 3, time = 4)
   @OutputTimeUnit(TimeUnit.NANOSECONDS)
   @Benchmark
   public String helloWorldBenchmark() {
       return "Hello World";
   }
}<code><pre>

Pour plus de précisions sur les params des annotations, je vous invite à visiter leurs interfaces dans les sources de JMH.

Voyons l'incidence des annotations dans le rapport

<pre><code># Fork: 1 of 1
# Warmup Iteration   1: 2,911 ns/op
# Warmup Iteration   2: 2,961 ns/op
# Warmup Iteration   3: 2,552 ns/op
Iteration   1: 2,513 ns/op
Iteration   2: 2,523 ns/op
Iteration   3: 2,503 ns/op
Result "fr.younup.MyBenchmark.helloWorldBenchmark":
 2,513 ±(99.9%) 0,188 ns/op [Average]
 (min, avg, max) = (2,503, 2,513, 2,523), stdev = 0,010
 CI (99.9%): [2,325, 2,701] (assumes normal distribution)
# Run complete. Total time: 00:00:24
MyBenchmark.helloWorldBenchmark  avgt    3  2,513 ± 0,188  ns/op<code><pre>

Je trouve que l'unité seconde/opération est souvent plus parlante. C'est ce que permet le mode <span class="css-span">AverageTime</span>.

Benjamin et Bertrand sortent alors la carte "café". À leur retour, ils créeront un benchmark de leur code de production.

À leur retour du café, Benjamin passe navigateur et Bertrand passe conducteur. Avant leur pause, ils avaient expérimenté les annotations de JMH dans un HelloWorld, et ils étaient prêt à passer au code de prod.

Benchmark du code de prod

<pre><code>public class MyBenchmark {

   @State(Scope.Benchmark)
   public static class MyState {

       public DuckService duckService = new DuckService();
       public DuckFactory duckFactory = new DuckFactory();

       public MyState() {
           duckService.ducks = duckFactory.createDucksWithRandomColors(100);
       }
   }

   @Fork(value = 1)
   @BenchmarkMode(Mode.AverageTime)
   @Warmup(iterations = 2)
   @Measurement(iterations = 2)
   @OutputTimeUnit(TimeUnit.MICROSECONDS)
   @Benchmark
   public void makeAllYellowDucksQuackWithStreamBenchMark(MyState myState) {
       myState.duckService.makeAllYellowDucksQuack();
   }
}<code><pre>

Le Jeu de données

L'ANNOTATION <span class="css-span">@STATE<span>

L'étape d'instanciation du JDD ne doit pas être comptabilisée dans le bench. On le génère donc dans une classe (statique ou pas) annotée de <span class="css-span">@State</span>. Son scope se limite aux méthodes de benchmark, ou aux threads (forks). Cela signifie qu'entre le benchmark de la méthode <span class="css-span">makeAllYellowDucksQuackWithStreamBenchMark</span> et celui de la méthode <span class="css-span">filterYellowDucksWithForLoopBenchmark</span>, l'état <span class="css-span">State</span> sera ré-instancié malgré sa nature statique.

IDEMPOTENCE

Attention aux JDD randomisés !

Pour des résultats fiables, il est impératif d'utiliser le même JDD à chaque itération.

Ici la méthode <span class="css-span">createDucksWithRandomColors()</span> n'est en fait pas du tout random. Elle est idempotente. Elle créé toujours le même JDD (depuis un fichier CSV). Par contre ce CSV contient bien un JDD randomisé.

Ce n'est pas le sujet ici, mais je vous donne une implémentation grossière (et efficace) pour charger le CSV en <span class="css-span">List<Duck></span> :

<pre><code>public List&lt;Duck> createDucksWithRandomColors(int number) {
   List&lt;Duck> collect = IntStream.range(0, number).mapToObj(i -> new Duck()).collect(toList());
   List&lt;Integer> yellowDucksIndexes = new ArrayList<>();
   /* Contient une liste ordonnée d'entiers uniques aléatoires entre 0 et 1_000_000.
      Ils représentent les index des canards qui doivent être jaunes.*/
   File file = new File("src/main/resources/yellowDucksIndexes.csv");
   try (Scanner scanner = new Scanner(file)) {
       String nextLine;
       while (scanner.hasNextLine()) {
          nextLine = scanner.nextLine();
           yellowDucksIndexes.add(Integer.parseInt(nextLine));
       }
  } catch (Exception e) {

   }
   for (int i = 0; i < number; i++) {
       try {
           collect.get(yellowDucksIndexes.get(i)).color = Color.Yellow;
       } catch (Exception e) {
           break;
       }
  }
  return collect;}<code><pre>

Le résultat pour 100 canards :

<pre><code>100:
Benchmark                                          Mode  Cnt        Score   Error  Units
MyBenchmark.filterYellowDucksWithStreamBenchmark   avgt    2    47462,131          us/op<code><pre>

Et pour d'autres tailles d'élevage :

Un quack mettant environ 1ms, on note que la compilation JIT a économisé pas mal d'instructions sur les longues listes.

Stream vs forLoop vs parallelStream

Voyons les performances d'autres implémentations de quacking :

<pre><code>public void makeAllYellowDucksQuackWithForLoop() {
   for (Duck duck : ducks) {
       if (duck.isYellow()){
           duck.quack();
       }
   }
}
public void makeAllYellowDucksQuackWithParallelStream() {
   ducks.parallelStream().filter(yellowDucks).forEach(makeItQuack);
}</code></pre>

<pre><code>    @State(Scope.Benchmark)
   public static class MyState {
       public DuckService duckService = new DuckService();
       public DuckFactory duckFactory = new DuckFactory();
       {
           duckService.ducks = duckFactory.createDucksWithRandomColors(100);
       }
   }

    @Fork(value = 1)
   @BenchmarkMode(Mode.AverageTime)
   @Warmup(iterations = 2)
   @Measurement(iterations = 2)
   @OutputTimeUnit(TimeUnit.MICROSECONDS)
   @Benchmark
   public void filterYellowDucksWithForLoopBenchmark(MyState myState) {
       myState.duckService.makeAllYellowDucksQuackWithForLoop();
   }  

 @Fork(value = 1)
   @BenchmarkMode(Mode.AverageTime)
   @Warmup(iterations = 2)
   @Measurement(iterations = 2)
   @OutputTimeUnit(TimeUnit.MICROSECONDS)
   @Benchmark
   public void filterYellowDucksWithParallelStreamBenchmark(MyState myState) {
       myState.duckService.makeAllYellowDucksQuackWithParallelStream();
   }</code></pre>

<pre><code>Benchmark                                          Mode  Cnt        Score   Error  Units
MyBenchmark.filterYellowDucksWithForLoopBenchmark  avgt    2    47650,604          us/op
MyBenchmark.filterYellowDucksWithParallelStream    avgt    2     7303,440          us/op
MyBenchmark.filterYellowDucksWithStreamBenchmark   avgt    2    47462,131          us/op<code><pre>

L'implementation <span class="css-span">filterYellowDucksWithParallelStream</span> semble être plus performante.

Voyons pour des tailles d'élevages différentes :


Même pour seulement 5 canards, le temps d'inititalisation du stream et les temps de fork/merge du thread-pool sont négligeables.

Ça n'aurait pas été le cas si le temps d'un seul quack avait été de l'ordre de la nanoseconde/microseconde !

Conclusion

Grâce aux microbenchmarks et JMH, Benjamin et Bertrand savent qu'ils ont résolu leur problème de contention, avant même de renvoyer les correctifs au bencheur.

Disclaimer on results

Les microbenchmarks révèlent effectivement que des implémentations sont plus efficaces que d'autres. Cependant il faut toujours avoir en tête la volumétrie de production, afin de pouvoir répondre à la question : "Est-ce que ca vaut vraiment le coup de refactorer ?". En effet, il y a d'autres objectifs que la performane pour le code, comme la lisibilité, l'évolutivité ou la modularité.

Si le gain de temps est de quelques nanosecondes pour très peu d'itérations, on préferera conserver une implémentation plus simple, et/ou plus conpréhensible.

En utilisant régulièrement JMH, on découvre que les for-loop sont très souvent plus rapides que leur équivalent fonctionnel, mais elles sont aussi très souvent plus complexes (difficiles à prédire) et/ou plus compliquées (difficiles à comprendre).

Avant de re-factorer, on se re-pose alors les questions :

  • "Quelle est ma volumétrie ?"
  • "Quelle est la latence max admissible ?"

[^1]: Plateforme d'Intégration Continue
[^2]: HotSpot is the VM from the OpenJDK community. It is the most widely used VM today and is used in Oracle’s JDK. It is suitable for all workloads.
[^3]: Eclipse OpenJ9 is the VM from the Eclipse community. It is an enterprise-grade VM designed for low memory footprint and fast start-up and is used in IBM’s JDK. It is suitable for running all workloads.

No items found.
ça t’a plu ?
Partage ce contenu
Antoine

Si ça parle Java chez Younup, soyez sûr qu'Antoine n'est jamais très loin !

Spécialiste de Spring Boot et fan de Groovy - pour son langage intuitif, haut niveau et qui permet la métaprogrammation - il est toujours curieux de découvrir de nouvelles technos ou outils qui pourraient booster sa productivité.

Un peu de chocolat, du café et un pc qui tient la route, c'est tout ce dont il a besoin pour coder ! Pas possible de poireauter 4h par jour devant des loaders...

Mais Antoine n'est pas seulement fan de Bob Martin ou de designs modulaires, laissez-lui un après-midi pour customiser son jardin avec un nouveau cabanon ou karchériser sa terrasse.