La programmation haute performance n’est-elle réservée qu’à une élite de développeurs C++ ?

Récemment un papier d’étude de Google UK a été publié sur la performance des langages de programmation JAVA, Scala, C++ et Go (Loop Recognition in C++/Java/Go/Scala). Dans ce papier, les performances des langages sont comparées sur la base d’un algorithme de recherche de boucles dans un graphe (Algorithme de Tarjan).

Principalement basé sur la performance d’exécution d’instructions séquentielles (boucles), la gestion de la mémoire, le temps de compilation et le nombre de lignes de code écrites cette étude montre que pour arriver à des hautes performances en C++ les optimisations techniques (au niveau du langage) deviennent trop compliquées pour le résultat produit. Comme le dit Robert Hundt :

 

We find that in regards to performance, C++ wins out by
a large margin. However, it also required the most extensive
tuning efforts, many of which were done at a level of sophistication
that would not be available to the average programmer.

Cependant, cet article a été critiqué sur son use-case peu pertinent et sur la validité des résultats. Quand même, C++ est le ++ fort ! Bien qu’il ait été maladroit, ce cher Robert a soulevé un point très intéressant : la programmation haute performance n’est-elle réservée qu’à une élite de développeur C++ ? Le développeur moyen peut-il espérer développer des applications haute performance ?

Plusieurs critiques de cette étude sont apparues sur Internet. Les arguments contre ont été :

  • L’exemple en JAVA pourrait être plus optimisé au niveau des collections : http://jeremymanson.blogspot.com/2011/06/scala-java-shootout.html
  • C’est ridicule de comparer JAVA et C++ sur une application mono-threadée : (même site)
  • L’exemple implémenté en Go n’est pas représentatif et aurait pu être optimisé pour être plus rapide (mailing-list Go)

Ces arguments sont valables mais le message que voulait faire passer Robert Hundt semble malgré tout cohérent. Explorons en détail ce sujet.

De quelle performance parle-t-on ?

Tout d’abord la performance d’un langage (au sens de l’étude Google) n’est pas une affaire si subjective. Il y a deux aspects principaux de performance : le CPU et la mémoire.

Essoufler son CPU

Pour le CPU, un langage performant est un langage qui permet d’exécuter des instructions en un nombre de cycles CPU faible. Ensuite il y a la possibilité de répartir son code sur les différents cœurs du CPU (parallélisme).

En ce qui concerne l’exécution d’instructions, les compilateurs JIT (Just In Time, compilation à la volée) de JAVA et .NET sont assez performants pour générer du byte-code optimisé. En C/C++ cela dépend énormément du compilateur et de son utilisation (flags de compilation). Il faut connaître son hardware pour compiler de manière optimale.

Le monde managé et les différents compilateurs C++ sont dans le même ordre de grandeur d’exécution d’instruction. Match nul ici entre JAVA/Scala (les langages managés de manière générale) et C/C++.

Le parallélisme via les threads POSIX semble idéal dans la théorie grâce à une meilleure gestion de la synchronisation (pthreads et semaphores). Donc C et C++ remportent la palme. En théorie seulement…

Là entre en jeu la complexité du design des applications multithreadées : il faut découper son code pour le rendre parallélisable sur la base d’un langage qui n’est pas fait pour (tous les langages objets/impératifs). Il faut également gérer la concurrence à la main (locks). De plus, la parallélisation peut drastiquement dégrader les performances d’une application : I/O en goulot d’étranglement, deadlocks, synchronisation des threads. Ceci est valable pour C/C++ mais également pour JAVA, C#… bref pour tous les langages objets supportant le multithreading.

Notre fameux développeur moyen se casse les dents ! Il peut être un très bon développeur, très intelligent mais il n’a pas les outils (langage) pour développer une application performante quand le multithreading entre en jeu. On peut même enlever le mot « moyen » !

Les langages managés et C++ se valent pour une application massivement séquentielle. Pour du multithreading, les problèmes sont similaires entre les langages managés objet et C++. L’écriture de traitement parallèle est simplifiée dans les langages fonctionnels par la nature du paradigme fonctionnel : closures, currying, lambda expressions.

Optimiser l’utilisation de la mémoire

Ensuite vient la gestion de la mémoire. Un langage performant est un langage permettant d’optimiser l’utilisation de celle-ci notamment éviter de saturer la mémoire, accéder rapidement à la donnée..

L’accès à la mémoire pourrait être plus rapide en C++ via l’utilisation directe des pointeurs mais je pense que c’est anecdotique. D’autant plus qu’en JAVA l’accès au tas (heap) est plus rapide qu’en C++. Enfin, l’overhead de défragmentation côté JAVA est significatif grâce à la défragmentation en continu par le garbage collector.

Le point différenciant est l’optimisation de l’espace mémoire : à la main en C/C++, via garbage collector en JAVA.
Le problème principal du GC (Garbage Collector) est l’étape de full-collect qui stoppe une application et la rend donc non-déterministe : j’ai multithréadé partout mais ce n’est pas plus rapide… Pire, ça freeze !
Ce point est très souvent reproché à JAVA (aux langages managés d’une manière générale) même si les évolutions des différents GC ont permis de rendre ce temps de full-collect plus court. On peut estimer que les full-collect (pauses) sont d’un ordre de grandeur inférieur à la seconde sur une JVM Sun classique (en tunant les paramètres de la « young generation » de la JVM).

Les JVM embarquées (appliances) ou optimisées (type Azul )  réduisent (ou même suppriment) le full-collect. On arrive à des ordres de grandeurs bien inférieurs à 100ms (on approche plutôt des 10ms).

En C/C++ la gestion de la mémoire se fait à la main. Malheureusement cela signifie également plus de chance d’introduire des fuites mémoires (l’erreur est humaine). Les smart pointers (auto_ptr ou bibliothèque Boost par exemple) permettent d’éviter ces fuites via les compteurs de référence mais leur utilisation peut être lourde : utilisation hasardeuse avec les conteneurs de la STL, conversion vers pointeurs classiques pour utiliser des frameworks tiers ne manipulant pas les smart pointers…

Les inconvénients de l’utilisation des pointeurs en C++ et les avancées en matière de gestion de la mémoire dans les langages managés (objet et fonctionnel) équilibrent le rapport de force.

La performance est-elle uniquement réservée aux applications C/C++ ?

De mon point de vue, je sursaute quand j’entends qu’on ne peut faire de la haute performance uniquement en C++. Premièrement, la complexité du C++ équilibre le rapport de performance avec les langages managés objets et fonctionnels comme nous avons pu le voir jusqu’à présent.

Les avancées dans les langages managés permettent de développer des applications performantes. Prenons tout simplement l’exemple de Twitter qui utilise un front-end JAVA : Netty (en remplacement d’une architecture basée sur RoR).

Ensuite, les goulots d’étranglement sont souvent ailleurs :

  • Accès I/O : le goulot d’étranglement classique par la dépendance à l’unité de stockage physique (disque dur) ou même distant (web-services, appels RPC)  - 4 ms de latence uniquement pour trouver la donnée sur un HDD 7200 trs/min à ajouter au taux de transfert de données (environ 1000 Mbits/sec sur un HDD 7200 trs/min).
  • Réseau (transport/déserialisation/queuing) : même avec une bonne connexion réseau, la sérialisation/désérialisation des paquets arrivant/sortant au niveau du switch engendre un overhead, tout comme le queuing des messages au niveau du switch – 11µs de sérialisation pour un paquet de 1500 bytes sur une connexion 1Gb (en UDP), 2,7ms de latence pour une queue de 1024 pkts 
  • Architecture logicielle : l’empilement de stacks applicatives réduisent les performances – Latence très variable
  • Système d’exploitation : l’ordonnancement des processus du noyau et la gestion des signaux d’interruption matériel peut nuire à la priorité d’exécution des threads d’une application (le noyau choisit ce qu’il exécute en priorité) – Latence très variable, dépend de la sollicitation du matériel pouvant aller de 10µs à plus de 10ms dans des cas de livelock (trafic réseau important sur un serveur par exemple)

Nous voyons que les ordres de grandeurs ci-dessus ne sont pas extrêmement éloignés du full-collect sur des runtimes optimisés (dans les 10ms).

Le CPU peut même être un goulot d’étranglement si le besoin de latence sur des traitements est inférieur à la milliseconde (cache miss/taille des caches, memory barrier, latence du bus interne, nombre limité de cores; les cartes FPGA (Field-Programmable Gate Array) ou ASIC (Application-Specific Integrated Circuit) et les GPU (Graphics Processing Unit) sont utilisés pour cela). Pour ces besoins de latence extrêmement faibles (ex : en arbitrage haute fréquence), quelque soit le langage les algorithmes ne peuvent tout simplement pas être exécutés sur un CPU compte tenu de toutes ces contraintes à gérer.

Certains langages fonctionnels permettent d’ailleurs de répartir des threads sur plusieurs cœurs (Haskell et Erlang notamment). De plus, le modèle de programmation par « acteurs » utilisé par ces deux langages fonctionnels assurent un traitement simplifié de la concurrence : immuabilité, mémoire non partagée. En effet, en Erlang et Haskell un acteur est exécuté dans un processus light (à ne pas confondre avec un processus OS; un processus Erlang , par exemple, est un thread du processus de l’application ayant un heap protégé) et non un thread applicatif; il a donc son propre espace mémoire; pas de risque qu’un autre thread de l’application écrive dans cet espace (c’est tout de même possible en Scala).

Alors, moi, développeur moyen, puis-je espérer développer du code performant ?

Au fond je suis d’accord avec ce brave Robert : l’effort d’optimisation est un paramètre essentiel pour définir la performance d’une application. La recherche d’optimisation peut être très compliquée pour gagner peu voire même dégrader les performances d’une application.

Ainsi sur des applications où la notion d’algorithme existe, quand bien même les threads POSIX sont maîtrisés il y a toujours la barrière de l’interprétation humaine. C’est au développeur de concevoir l’algorithme, de le rendre parallélisable et d’éviter tout problème de conception. Notre développeur moyen défini par Robert Hundt est en réalité un développeur commun, c’est-à-dire une personne ayant tout simplement sa propre interprétation d’une solution à un problème donné

La complexité de la parallélisation et de la gestion de la mémoire me font dire qu’il est peut être plus judicieux d’utiliser les bons outils : simples et productifs. En utilisant les bons outils il est donc possible de développer des applications performantes sans avoir besoin d’un niveau stratosphérique.

Finalement, que choisir : C++/JAVA/Scala ?

L’un des seuls cas où il est incontestable que C++ est le langage qu’il faut pour de la performance est pour tout ce qui est driver/middleware/OS; l’accès direct au matériel en quelque sorte. En effet, les applications s’appuient sur un OS qui utilise des drivers pour accéder au matériel. Pour éviter un overhead supplémentaire (matériel (code machine) > OS (C++) > application) et abstraire la complexité au niveau des applications, les drivers et fonctions systèmes d’un OS se doivent d’être au plus proche du matériel (i.e. sans l’overhead du runtime).

Au niveau applicatif, la tendance est différente. Un algorithme ne prend pas directement en compte l’accès au matériel. De ce fait la performance n’est pas directement fonction de l’accès au matériel.

Pour une application low-latency -exécution de traitements inférieurs à 10ms comprenant réseau, exécution d’instruction, récupération de données…- C/C++ est une solution pertinente. Délocaliser des traitements CPU sur des GPU et autres cartes FPGA/ASIC tant que possible est même nécessaire.

Pour une application massivement parallèle avec threads indépendants C++ est une bonne solution. Si les ressources sont difficiles à trouver, les langages managés objets (JAVA/C#) répondent également à ce besoin. Souvenons-nous de Twitter qui utilise un front-end JAVA !
Les compilateurs et runtimes actuels (notamment les optimisations de GC pour Java 7) permettent d’obtenir du code optimisé (au niveau du compilateur, sur les boucles par exemple) et de la gestion de la mémoire performante(défragmentation en continu). On gagne très peu en performance d’exécution d’instruction en C++ par rapport aux langages managés.

Pour une application nécessitant d’être massivement parallèle et de faire transiter des états, la performance devient affaire de design et les langages fonctionnels répondent à ce besoin. Rien que sur le développement de l’algorithme de Tarjan, voici la conclusion sur une version optimisée en Scala :

 

This version is only 270 lines of code, about 25% of the C++ version, and not only is it shorter, run-time also improved by about 3x.

Les langages fonctionnels permettent de surpasser les difficultés des langages objets. L’avancée de ces langages en termes de performance (et sa simplicité inhérente) en font des candidats plus que sérieux au développement haute performance. Un élément penche en cette faveur : l’arrivée prochaine de C++0x qui introduit des notions fonctionnelles au sein de C++ (inférence de type, lambda expression, et function objects -à savoir closures passées aux threads-). Même le C++ veut se simplifier !