À propos de l'alignement en mémoire

Ce qui suit présume quelques connaissances de programmation « système », donc de bas niveau.

Contrairement à ce que l'on aurait pu croire à une autre époque, par exemple celle où j'ai débuté mes propres études supérieures, la programmation contemporaine ne fait pas totalement abstractionb du substrat matériel. En fait, il est possible aujourd'hui de programmer sans tenir compte des ordinateurs pour lesquels nos programmes seront destinés, mais c'est beaucoup moins vrai lorsque nous avons des préoccupations telles que faire en sorte que minimiser le temps d'exécution des programmes (entre autres dû aux effets de l'antémémoire) ou faire en sorte que le programme soit correct même en situation de concurrence.

Le présent texte se veut une introduction aux enjeux associés à l'alignement des données en mémoire, qui est un facteur de vitesse et de rectitude de programmes contemporains. Notez que le texte est axé sur C++ car ce langage donne accès aux mécanismes de gestion de la mémoire à très, très bas niveau, permettant même de remplacer des mécanismes tels que new et delete ou de placer des objets à une adresse choisie par la progammeuse ou le programmeur.

Enjeux

Le standard C++ 14, §3.11, décrit les règles quant à l'alignement des données selon les types. En effet, en C++, tout type comporte des restrictions quant à l'alignement en mémoire de ses objets. Par objet, on entend ici une zone mémoire susceptible d'entreposer une valeur et ayant une adresse (une variable de type int est un objet, à ce titre).

L'alignement d'un objet d'un type T lorsque pris isolément peut différer de l'alignement d'un objet du même type lorsque cet objet est dans un agrégat (classe, struct, tableau, etc.).

Par exemple, soit les exemples à droite : les variables a et b peuvent être séparées par quelques bytes de mémoire non-utilisée si l'alignement naturel d'un short diffère de la taille d'un short (donc si alignof(short)>sizeof(short)) pour que les deux variables soient correctement alignées. Par contre, c[0] et c[1] sont contiguës en mémoire, par définition, donc sizeof(c)==2*sizeof(short) et si c[0] est aligné comme un short, il se peut que c[1] ne le soit pas.

short a, b;
short c[2];

Un autre exemple, pris du standard lui-même, est celui des classes B et D à droite. Ici, D est un dérivé virtuel de B, ce qui signifie que dans le cas où un dérivé de D dérive aussi indirectement de B par d'autres parents, alors la partie B ne sera pas répétée plusieurs fois mais sera « fusionnelle ».

Si un B est utilisé isolément, son alignement en mémoire devra correspondre à celui d'un long double, mais dans le cas d'un dérivé de D, la situation pourrait être différente.

Notez qu'ici, alignof(B) indiquera la valeur lorsqu'un B est utilisé isolément.

struct B {
   long double ld;
};
struct D : virtual B {
   char c;
};

Exemple concret

Supposons un type maybe<T> comme celui décrit dans ../TrucsScouts/maybe.html et dont la structure interne (les attributs) prend la forme suivante :

template <class T>
   class maybe {
      bool vide;
      char buf[sizeof(T)];
      // ...
   };

Ce type a pour but de représenter un possible T, donc un T qui peut être dans le maybe<T> ou non. L'attribut vide sera faux si buf contient un T. Si T est double par exemple, alors buf sera de la bonne taille, mais placer un T dans buf ne sera correct que si buf est aligné comme un double; ici, le compilateur ne connaît pas notre intention, et alignera buf selon les règles applicables à char. Conséquemment, pour que maybe<T> soit légal, une meilleure structure serait :

template <class T>
   class maybe {
      bool vide;
      alignas(T) unsigned char buf[sizeof(T)];
      // ...
   };

La taille d'un maybe<T> sera supérieure à celle d'un T, dû à la présence du bool et de l'alignement  en mémoire de buf, mais au moins la représentation interne de cette classe sera désormais correcte. Une écriture alternative et équivalente serait :

template <class T>
   class maybe {
      bool vide;
      typename std::aligned_storage<sizeof(T),alignof(T)>::type buf;
      // ...
   };

... qui fera de buf un tampon d'unsigned char d'une taille totale de sizeof(T), aligné comme un T.

Conséquences d'un alignement incorrect

Selon les plateformes, les conséquences d'un alignement incorrect iront de déplaisantes à dangereuses. Si on suppose le code suivant :

int main() {
   char buf[2*sizeof(short)+sizeof(int)];
   short *p = new (static_cast<void*>(&buf[0])) short{3};
   short *q = new (static_cast<void*>(&buf[0] + sizeof(short))) int{4};
   short *r = new (static_cast<void*>(&buf[0] + sizeof(short) + sizeof(int))) short{5};
   // On prend un risque ici...
   ++(*q);
}

Accéder à *q ici est risqué car il est probable que alignof(int)!=alignof(short). Le plus probable problème ici sera que *q se trouve à cheval entre deux mots mémoire. Sur une architecture x86, ceci ralentira considérablement l'exécution, alors que sur un système embarqué tel qu'une console de jeu vidéo, cela plantera probablement de manière violente.

Ici, alors que normalement, ++(*q) impliquerait :

...le fait que *q ne soit pas aligné convenablement en mémoire impliquera probablement :

C'est plus long et plus risqué. 

Outils pour contrôler l'alignement

Depuis C++ 11, le langage offre quelques mécanismes portables pour gérer les questions propres à l'alignement des données en mémoire.

Spécification alignas

La spécification alignas permet d'indiquer l'alignement souhaité pour un type ou pour une expression. Par exemple, alignas(double) short s; alignera s dans le respect des règles normales pour un double, et alignas(4) short s; alignera s sur une frontière de quatre bytes.

Opérateur alignof

L'opérateur alignof permet d'obtenir l'alignement d'un type. Par exemple, alignof(std::string) donne l'alignement d'une std::string.

Métafonction std::alignment_of<T>

La métafonction std::alignment_of<T> a le même effet que l'opérateur alignof, mais en étant exprimé sous forme d'une métafonction, elle peut être utilisée avecdes algorithmes statiques.

Type std::aligned_storage<std::size_t,std::size_t>

Le type std::aligned_storage<std::size_t,std::size_t> permet d'obtenir un tampon de bytes correctement aligné en mémoire pour utiliser un type T aligné de manière au moins aussi stricte que son alignement naturel..

En pratique, vous devriez avoir les mêmes tailles pour buf0 et buf1 en exécutant le code suivant :

#include <iostream>
#include <memory
class X { int _[3]; };
int main() {
   using namespace std;
   alignas(X) unsigned char buf0[sizeof(X)];
   aligned_storage<sizeof(X), alignof(X)>::type buf1;
   cout << "buf0 occupe " << sizeof(buf0) << " bytes" << endl;
   cout << "buf1 occupe " << sizeof(buf1) << " bytes" << endl;
}

Type std::aligned_union<std::size_t,class...Ts>

Le type std::aligned_union<std::size_t,class...Ts> permet d'obtenir un tampon de bytes correctement aligné en mémoire pour utiliser n'importe lequel des types de T... aligné de manière au moins aussi stricte que l'alignement naturel du type de Ts... ayant l'alignement le plus strict. La taille du tampon décrit par ce type sera au moins celle dictée par le std::size_t qui tient le rôle de premier paramètre pour le template. La valeur de cet alignement sera donné par la constante interne et publique alignment_value.

Par exemple, ci-dessous, buf::type sera au moins aussi gros que sizeof(int) bytes, mais cette taille sera au minimum de 8 bytes :

aligned_union<8, bool, char, short, int>::type buf;

Techniques plus « manuelles »

Avant d'avoir accès à des outils portables, l'alignement était une considération plus... artisanale. Ainsi :

Les nouveaux outils ont une sémantique claire et permettent d'exprimer les mêmes idées de manière portable et à un niveau d'abstraction adéquat. Je vous souhaite un compilateur récent.

Lectures complémentaires

Quelques liens supplémentaires pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !