1 – Contexte
Dans une application de calculs de risque chez un client, de nombreux crashs aléatoires survenaient. Le symptôme visible était la mémoire d’un processus qui augmentait de manière régulière jusqu’à 3Go : à partir de là, le processus crashait. Cette application était multiprocessus. La répartition se faisait sur un cluster de 2 à 7 machines : chaque machine exécutait 1.5 fois plus de processus que de nombre de cœurs (une machine de 16 cœurs logiques exécutait donc 24 processus) et chaque processus travaillait avec 4 à 30 threads. Du fait de l’architecture, plusieurs traders accédaient simultanément aux machines pour exécuter des simulations. En conséquence, les équipes n’arrivaient pas à reproduire le problème sur les environnements de développement. Qui plus est, les traders, jaloux de leurs idées, ne disaient pas vraiment quelles données ils avaient fournies au cluster : on pouvait s’attendre au mieux à un "à peu près." Nous n’avions alors que les fichiers de logs et notre expérience pour comprendre les causes du problème.
L’application était écrite en C++ avec un framework (ICE) simplifiant la programmation distribuée et la communication entre les processus. Pour des raisons de « performances », les premiers développeurs avaient intégré boost et tbb (Thread Building Block d’Intel) pour, notamment, avoir accès à des conteneurs efficaces. Il y avait des changements de structures de données importants entre les conteneurs STL/boost et ceux du framework.
2 – Comprendre
Le C et les C++ sont des langages de bas niveau : le résultat de la compilation promet de bonnes performances, mais les tâches d’allocations et de libérations de la mémoire sont à la charge du développeur. Le développeur est humain, l’erreur est humaine : oublier de libérer de la mémoire est donc malheureusement fréquent. Depuis l’apparition du C++ 11, des outils comme les pointeurs intelligents (std::unique_ptr, std::shared_ptr et std::weak_ptr) permettent de simplifier la gestion de la mémoire au prix d’un léger overhead. Cependant, dans les codes « legacy » qui existent depuis bien avant l’arrivée de C++ 11, ces derniers ne sont pas utilisés. La deuxième raison qui peut engendrer des consommations excessives de mémoire est un mauvais choix de type pour les données. Supposons que vous ayez besoin de stocker une valeur entière de 0 à 9 : si vous utilisez un ‘nit’, vous allez utiliser quatre octets alors qu’un ‘unsigned char’ d’un octet suffirait… Si la volumétrie de cette donnée se compte en millions, on perd rapidement des méga-octets d’espace. Bon nombre de codes ont été conçus pour une volumétrie faible sans penser qu’un jour, celle-ci augmenterait de manière importante. Cela est d’autant plus vrai dans l’environnement bancaire. La troisième raison est la méconnaissance des structures de données provenant de la bibliothèque standard (STL) ou des bibliothèques tierces (boost ou tbb pour le cas cité dans le contexte.) Pour travailler, certaines de ces structures de données allouent plus de mémoire qu'attendu.
La quatrième raison est l’optimisateur du compilateur C++ qui va prendre des décisions dont le développeur ne va pas forcément avoir conscience. C’est sur ce point que nous allons travailler dans cet article.
La dernière raison, qui est certes peu fréquente en dehors d’un système très multi-threadé, est la pénurie de mémoire disponible, car trop de threads allouent et libèrent de la mémoire en même temps. Il faut savoir que sous Windows et Linux, lorsque vous faites ‘free’, ‘delete’ ou ‘delete []’, vous stockez le bloc de mémoire dans une file et un thread ramasse-miette va le rendre au système. Si jamais trop de threads consommateurs travaillent simultanément, le thread ramasse-miette n’a plus un quantum de temps suffisant pour satisfaire tout le monde.
3- Les types de données simples et leur taille
La première chose à faire est de comprendre quels sont les types de données de base et la taille en octet de chacun d’entre eux. En C/C++, il y a une façon simple de récupérer la taille d’un type de données : sizeof. C’est une fonction qui prend en paramètre le nom du type et qui retourne le nombre d’octets pris par celui-ci. Voici un exemple de programme pour récupérer les tailles :
Voici le résultat obtenu :
Type
Information Taille
en
octets
bool
true, false 1
char
-128 à 127 1
unsigned char
0 à 255
1
std::int8_t
-128 à 127
1
std::uint8_t
0 à 255 1 short
-32768 à 32767
2
unsigned short
0 à 65535
2
std::int16_t
-32768 à 32767
2
std::uint16_t 0 à 65535
2
nit
-2147483648 à 2147483647
4
unsigned int
0 à 4294967295
4
std::int32_t
-2147483648 à 2147483647
4
std::uint32_t
0 à 4294967295
4
std::int64_t
-9223372036854775808 à 9223372036854775807
8
std::uint64_t
0 à 18446744073709551615
8
float 7 digits
4
double 15 digits
8
Pour des raisons pratiques, aujourd’hui et depuis C++ 11, il est préférable d’utiliser les entiers définis dans la STL : std::int8_t, …, std::int64_t.
4 – Les types structurés et leurs tailles
4.1 – Commençons par un petit exemple
Les types structurés (struct et class) ont eux aussi des tailles que l’on peut obtenir grâce à sizeof. Jusque-là, le C/C++ reste cohérent 😊. Partons d’un exemple simple : je souhaite simuler un intervalle de nombres flottants potentiellement ouvert ( [-10, 20] ou ]-oo, 50] ou [15, +oo[ pour exemple). Afin de savoir si les bornes sont définies ou infinies, nous allons utiliser des booléens (‘bool’). Pour les valeurs des bornes, nous utiliserons des flottants en double précision (‘double’). Ce qui nous donnera la déclaration de type suivante :
Ayant utilisé 2 ‘bool’ et 2 ‘double’, je m’attends à ce que la taille du type Intervalle soit de : 2 x 1+2 x 8 = 18 octets… En effectuant un sizeof sur le type, j’obtiens (suspense)... 32 octets ! Je ne comprends plus rien… Je ne m’y attendais pas du tout… Où sont passés les 14 octets, et surtout, pourquoi se sont-ils volatilisés ?
4.2 – Faisons des tests pour comprendre
Le C/C++ étant un langage de bas niveau (et Dieu merci), nous avons des outils pour comprendre comment est composé le type. Nous avons un opérateur ‘&’ pour récupérer l’adresse d’une variable.
En effectuant ‘ADDR(t._isLowerBoundInfinite) - ADDR(t)’, on obtient la position relative en octet au sein de la structure. En C++, on peut aussi utiliser ‘offsetof(t, x)’ qui fournira la même fonctionnalité. En exécutant le code, on va obtenir ceci :
Pour mieux comprendre, on va utiliser un tableau :
En gris, nous avons la mémoire perdue. Il semblerait que le compilateur ait essayé d’aligner les adresses des membres de la structure. Essayons de changer l’organisation de la structure :
Et en l’exécutant, on obtient :
On reprend notre organisation sous forme d’un tableau :
On constate que la taille de la structure a diminué de 8 octets et que l’on a plus que 6 octets de perdus… La solution consiste peut-être à mettre les booléens à la fin de la structure… Essayons cette dernière solution
Et voici le tableau mémoire que nous obtenons :
On a malheureusement de l’espace perdu à la fin de la structure.
4.3 – Le padding
Cette façon de ranger la mémoire au sein des structures est appelée padding… C’est une optimisation du compilateur qui part du principe que la vitesse d’exécution est le principal objectif. Il va donc aligner les adresses sur 4, 8 ou 16 octets pour accélérer les accès à la mémoire, : un processeur accède plus rapidement à la mémoire si les adresses sont des multiples de 4, 8 ou 16 octets. Pour utiliser les commandes SSE, il faut obligatoirement aligner les vecteurs sur 16 octets pour profiter de performances optimales.
Tous les compilateurs ont un nombre d’octets (ou stratégie) d’alignement par défaut. Par exemple, Visual C++ alignait sur 8 octets. Changer cette stratégie est possible localement ou globalement. Lorsque l’on travaille en environnement embarqué avec peu de mémoire disponible, il peut être utile de changer globalement les alignements pour garantir que l’on ne consommera que la mémoire utile. Pour se faire, il faudra changer une des options du compilateur (et là, il faudra chercher dans la documentation, car il n’y a pas de normes quant aux paramètres des compilateurs 1.)
4.3.1 Changement global
Pour gcc, l’option ‘-fpack-struct=1’ permet de forcer le padding à 1 octet au lieu des 4 par défaut. On peut aussi activer l’option ‘-Wpadded’ pour que le compilateur lève un warning sur les structures dans lesquelles il y a du padding (mais malheureusement le message n’est pas toujours clair...)
Sous VC++, l’option du compilateur est ‘/Zp[1|2|4|8|16]’ où le nombre correspond au nombre d’octets de l’alignement.
4.3.2 Changement local
On peut également désactiver l’option localement ; par exemple, sous gcc et Visual C++, vous pouvez faire :
Ce qui en exécutant nous donnera :
Nous n’avons plus de mémoire perdue… YOUPI !!!
Découvrez le dernier article de Philippe Boulanger, Manager de l'Expertise C++ : L'intérêt de se diversifier pour un développeur.