logo le blog invivoo blanc

Gestion de la Mémoire – A la recherche de la mémoire perdue (épisode 1)

20 novembre 2017 | C++

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 :

gestion de la mémoire padding

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 :

gestion de la mémoire padding

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.

gestion de la mémoire padding

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 :

gestion de la mémoire padding

Pour mieux comprendre, on va utiliser un tableau :

gestion de la mémoire padding

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 :

gestion de la mémoire padding

Et en l’exécutant, on obtient :

gestion de la mémoire padding

On reprend notre organisation sous forme d’un tableau :

gestion de la mémoire padding

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

gestion de la mémoire padding

Et voici le tableau mémoire que nous obtenons :

gestion de la mémoire padding

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 :

gestion de la mémoire padding

Ce qui en exécutant nous donnera :

gestion de la mémoire padding

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.