L’art du benchmark

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 Requests Average Response time (ms) Requests/second
10 233 43
50 1237 42
100 2347 42
150 3506 42

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 Requests Average Response time (ms) Requests/second
10 100 96
50 416 103
100 1023 95
150 1450 89

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 Requests Average Response time (ms) Requests/second
10 54 177
50 135 357
100 278 348
150 1988 73

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

4 commentaires pour “L’art du benchmark”

  1. Article très intéressant! Quelques petites remarques cependant:

    - ce n’est pas httpcomponents qui est limité à 2 connections en parallèle mais l’implémentation du client manager que vous utilisez (ThreadSafeClientConnManager d’après vos logs). Un PoolingHttpClientConnectionManager serait plus approprié avec une gestion des routes et des limites via un ConnPoolControl
    - utiliser les libs natives de Tomcat et son connecteur HTTP APR aurait pu être envisageable dans le bench, le résultat aurait été intéressant :)

  2. Couchdb4j utilise en effet ThreadSafeClientConnManager comme connection manager. J’ai utilisé du paramétrage, car PoolingHttpClientConnectionManager n’était pas disponible dans la version de httpcomponents utilisée par Couchdb4j (4.0-beta2).

    Merci pour l’idée. Tester HTTP APR serait en effet intéressant. Je n’ai pas testé les optimisations au bout car ce n’était pas le but de l’exercice. Mais le code étant ouvert, je suis preneur de vos résultats ;-)

  3. mais non, ce n’est pas une histoire d’implementation.
    c’est la RFC qui dit qu’un client http a 2 connexions max par host.

    http://www.w3.org/Protocols/rfc2616/rfc2616-sec8.html

    A single-user client SHOULD NOT maintain more than 2 connections with any server or proxy.

  4. @yanndegat: La RFC indique en effet 2 connexions pour empêcher la congestion sur Internet. Nous ne sommes pas dans ce cas. Nos connexions HTTP dont l’équivalent d’un pool de connexions base de donnée. 2 est nettement insuffisant par rapport à la charge.

Laissez un commentaire