L'art du benchmark

le 08/01/2014 par Henri Tremblay
Tags: Software Engineering

Un benchmark comparant JavaEE et NodeJS a récemment causé un certain émoi sur la toile. J'ai été surpris des résultats et me suis mis en tête de le reproduire pour vérifier une intuition. L'article est, de plus, enrichi de plusieurs commentaires intéressants qui méritent eux-mêmes d'être commentés. Tout ceci m'amène au présent billet.

Qu'est-ce qu'un benchmark? Le benchmark est la mesure des performances d'une application. Nous tentons de reproduire en laboratoire une utilisation normale de l'application. Si vous demandez aux experts, ils vous diront qu'un benchmark est un outil extrêmement dangereux dont les résultats sont presque toujours faux. C'est, à mon point de vue, légèrement exagéré, mais pas complètement faux non plus comme nous verrons plus bas.

Une chose est sûre, quelques précautions sont toutefois nécessaires pour ne pas avoir de surprise plus tard. Par exemple:

  • Avoir une volumétrie de base de données similaire à la cible
  • Avoir une hétérogénéité des données représentatives. En particulier pour des problématiques de cache
  • Une infrastructure proportionnelle à la cible

À noter, contrairement à la croyance populaire, les benchmarks sont généralement optimistes. Si vous avez de mauvais temps de réponse lors du benchmark, il y a fort à parier que ça ne s'améliorera pas en production. C'est donc de l'inverse qu'il faut se méfier. De bons temps de réponse lors du benchmark ne sont pas une garantie de bons temps de réponse en production si le benchmark n'a pas été bien fait.

Un autre point intéressant est le périmètre du benchmark. Son but. Celui qui nous intéresse porte sur NodeJS vs J2EE. À la lecture du code, nous constatons que côté Java, il s'agit plus précisément d'un appel de servlet vers CouchDB (et non CouchBase) via couchdb4j. C'est donc ce que nous voulons tester. Il est inutile de suggérer d'utiliser Vert.x parce que plus proche de NodeJS en terme de comportement, car ce n'est pas ce que l'on veut tester. On teste NodeJS contre une servlet utilisant couchdb4j pour récupérer un document json. Un point c'est tout. Savoir si une autre technologie aurait été plus performante est un autre benchmark.

Ensuite, et c'est là où les experts s'inquiètent, il faut s'assurer que l'on teste ce que l'on pense tester. On pourrait par exemple reprocher au présent benchmark d'avoir a priori mis la base de données sur la même machine que le serveur d'application. Ce qui empêche de savoir clairement si c'est bien le serveur Java qui sature les ressources système. J'ai d'ailleurs fait la même chose pour rester plus près du benchmark originel.

Par contre, les benchmarks sont souvent critiqués sous l'angle de l'optimisation. "Mais avez-vous activé le paramètre xxx?". Je ne suis pas d'accord avec cette approche. Un benchmark est toujours effectué au meilleur de ses connaissances. À partir du moment où l'on a pris les précautions d'usage assurant que nous testons la bonne chose (que c'est bien Java et non la base de données qui sature par exemple).

Qu'est-ce que j'entends par "au meilleur de ses connaissances"?

Que, par exemple, je teste Java contre C++. Mais j'ignore que je dois ajouter des paramètres au compilateur C++ pour lui demander d'optimiser le code. Java est donc nettement plus rapide. Et bien mes résultats ne sont pas faux. Car, si je mets ma solution Java en production, en l'état actuel de mes connaissances, elle est la plus rapide. Mon but est atteint. Mon benchmark est valide.

Évidemment, si je publie mes résultats et que la communauté les commente, mes connaissances viennent d'augmenter. Je peux donc refaire le benchmark à la lumière de mes nouveaux acquis. Si j'en ai le temps et le budget évidemment. À noter, un benchmark est une expérience scientifique. Il est utile d'en publier les résultats, mais aussi le protocole de test précis.

Mais revenons à nos moutons. Pour le benchmark qui nous intéresse, si on étudie un petit peu la question, les résultats sont tout de même louches. En effet, il n'est pas très logique d'avoir une dégradation des temps de réponse et un nombre de requêtes par seconde constant lorsque le nombre d'utilisateurs augmente. C'est signe de point de contention.

J'ai donc reproduit le benchmark. Je n'ai pas pu utiliser les mêmes versions des briques logicielles, car certaines étaient étrangement anciennes ou introuvables. J'ai donc installé ceux-ci:

  • Java HotSpot 64 bits 1.7.0_21
  • Tomcat 7.0.35
  • CouchDB 1.2.0
  • couchdb4j 0.3.0-i386-1

Tout ce petit monde s'exécute sur un VM Ubuntu 13.04 avec 4 CPUs mais ce n'est pas très important.

J'ai considéré que le nombre de "concurrent requests" mentionné était en fait le nombre de virtual users. J'ai remplacé JMeter par Gatling pour des raisons de préférences personnelles. J'obtiens les résultats suivants:

Concurrent RequestsAverage Response time (ms)Requests/second
1023343
50123742
100234742
150350642

C'est une excellente nouvelle car ils sont équivalents aux résultats de l'article. Par cela, j'entends que j'ai toujours cette douteuse stabilité du nombre de requêtes par secondes. Je ne parle pas de performances brutes (qui sont sensiblement plus lentes en Java et en NodeJS chez moi pour une quelconque raison), car ce n'est pas ce qui m'intéresse. Pour se convaincre qu'il s'agit bien d'un soucis de contention, un petit vmstat est de rigueur.

cs   us sy id 1134  3  4 93 1574 13 13 74 1285 13 13 74 1047 12 13 75

Il nous montre que notre système, pourtant très chargée, se tourne les pouces au niveau CPU.

S'en suit un thread dump pour déterminer ce qui bloque notre système.

"http-bio-8080-exec-294" - Thread t@322 java.lang.Thread.State: WAITING at sun.misc.Unsafe.park(Native Method) - parking to wait for <49921538> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:186) at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject .await(AbstractQueuedSynchronizer.java:2043) at org.apache.http.impl.conn.tsccm.WaitingThread .await(WaitingThread.java:159) at org.apache.http.impl.conn.tsccm.ConnPoolByRoute .getEntryBlocking(ConnPoolByRoute.java:339) at org.apache.http.impl.conn.tsccm.ConnPoolByRoute$1 .getPoolEntry(ConnPoolByRoute.java:238) at org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager$1 .getConnection(ThreadSafeClientConnManager.java:175) at org.apache.http.impl.client.DefaultRequestDirector .execute(DefaultRequestDirector.java:324) at org.apache.http.impl.client.AbstractHttpClient .execute(AbstractHttpClient.java:555) at org.apache.http.impl.client.AbstractHttpClient .execute(AbstractHttpClient.java:487) at org.apache.http.impl.client.AbstractHttpClient .execute(AbstractHttpClient.java:465) at com.fourspaces.couchdb.Session.http(Session.java:476) at com.fourspaces.couchdb.Session.get(Session.java:433) at com.fourspaces.couchdb.Database.getDocument(Database.java:361) at com.fourspaces.couchdb.Database.getDocument(Database.java:315) at com.henri.couchbench.GetDocumentServlet.doGet(GetDocumentServlet.java:26)

Intéressant. Le système attend d'obtenir une connexion HTTP vers CouchDB. Le problème est que couchdb4j utilise httpcomponents. Et httpcomponents ne permet, par défaut, que 2 connections en parallèle. Dommage pour nos 150 utilisateurs. Ils attendent leur tour. Je corrige donc, à la dure, le code du driver pour permettre 150 connexions. Hop! Mes temps de réponse sont diminués de moitié et mon nombre de requêtes par seconde passe à 100.

Concurrent RequestsAverage Response time (ms)Requests/second
1010096
50416103
100102395
150145089

Mais mon boulot n'est pas terminé. Je retourne vers mes métriques. Mon CPU s'est amélioré, mais je constate une augmentation du CPU système et un context switching qui s'envole.

cs    us sy id 11979 57 21 22 12538 54 22 24

Tomcat 7 utilise par défaut un connecteur HTTP synchrone. Passons en NIO pour diminuer la quantité de threads en parallèle nécessaire pour supporter la charge.

Concurrent RequestsAverage Response time (ms)Requests/second
1054177
50135357
100278348
150198873

Yippee! Les résultats sont bons. Pour 50 utilisateurs, 8,5 fois plus de requêtes par seconde et des temps de réponse 10x inférieur.

Avec toutefois une surprise. Les temps de réponse pour 150 utilisateurs se sont dégradés. La raison est simple. En regardant les métriques GC, étant donnée l'augmentation de débit, on constate que la mémoire n'est plus suffisante pour supporter 150 utilisateurs. Le CPU GC s'envole à 30%.

Mais je m'arrêterai ici pour aujourd'hui, ce billet étant déjà fort long.

Pour conclure, le benchmark est un outil précieux, mais à utiliser avec précaution. Personne ne connaît toutes les possibilités d'optimisation disponibles et chacun fait donc de son mieux. Il est toutefois important de garder un oeil critique. Des résultats improbables sont signe qu'une analyse plus approfondie est nécessaire. Dans le doute, appelez un expert ;-)