À 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 abstraction 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.

Le problème

La manière dont les opérations machines sont implémentées physiquement a pour conséquence que les données doivent être alignées sur certaines frontières en mémoire vive pour qu'un programme fonctionne correctement. Accéder à une donnée mal alignée peut faire planter un programme, le ralentir considérablement, ou causer (en situation de multiprogrammation) des bogues d'une rare ignomnie (pensez à des Torn Reads ou à des Torn Writes... Ouch!).

Normalement, les programmeuses et les programmeurs ne devraient pas avoir à faire face à cette problématique du fait que les compilateurs positionnent naturellement les variables correctement en mémoire en fonction de leurs types. Toutefois, il arrive que nous devions mentir à nos compilateurs, par exemple pour implémenter des abstractions sophistiquées : pensez à un type optional<T> qui représente un possible T... Pour bien l'implémenter, il faut typiquement avoir un tableau de bytes bruts suffisamment grand pour contenir un T, et n'y placer un T que si l'objet doit bel et bien en contenir un. Ce tableau de bytes doit donc être de taille sizeof(T), mais aussi aligné comme un T, sinon les opérations sur cet éventuel T souffiront des maux mentionnés au paragraphe précédent.

Cette réalité a des conséquences. Par exemple, sur la plupart des compilateurs, les types X et Y ci-dessous n'auront pas la même taille :

struct X {
   char c;
   short s;
   int n;
};
struct Y {
   short s;
   int n;
   char c;
};
static_assert(sizeof(X) != sizeof(Y)); // le contraire me surprendrait :)

La raison de cette différence est l'alignement. Supposant sizeof(short)==2 et sizeof(int)==4, et supposant un alignement de 2 pour short et de 4 pour int (ce qui est probable; pour char, la taille et l'alignement sont tous deux nécessairement 1), sizeof(X) sera probablement 8 alors que sizeof(Y) sera probablement 12. En effet :

Il y aura un espace d'un byte entre X::c et X::s pour assurer l'alignement correct de X::s


      0   1    2-3       4-7
    +===+===+=======+===========+
X : | c |   |   s   |     n     |
    +===+===+=======+===========+

Il y aura par contre un espace de deux bytes entre Y::s et Y::n, puis un espace de trois bytes après Y::c pour faire en sorte que, s'il y avait un tableau de Y, le prochain Y soit lui aussi correctement aligné


      0-1   2-3      4-7      8     9-11
    +=====+=====+===========+===+=========+
Y : |  s  |     |     n     | c |         |
    +=====+=====+===========+===+=========+

Examinons maintenant le tout plus en détail...

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 :

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

... qui fera de buf un tampon d'unsigned char d'une taille totale de sizeof(T), aligné comme un T. Voir toutefois les remarques sur std::aligned_storage (plus bas) avant de suivre ce chemin.

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.

Il est possible d'exprimer le pire alignement d'un groupe variadique de types de la manière suivante (https://wandbox.org/permlink/z8nDloafTpQqHR1Y) :

#include <algorithm>
template <class ... Ts>
   struct Machin {
      alignas(Ts...) char buf[std::max({sizeof(Ts)...})]{};
   };
#include <iostream>
using namespace std;
int main() {
   cout << alignof(Machin<short, int, char>{}.buf) << endl;
}

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_t<sizeof(X), alignof(X)> buf1;
   cout << "buf0 occupe " << sizeof(buf0) << " bytes" << endl;
   cout << "buf1 occupe " << sizeof(buf1) << " bytes" << endl;
}

Note importante : des discussions sont, en date de 2020, en cours pour déprécier std::aligned_storage qui souffre de certains défauts de conception. Il est probablement préférable de préférer une solution reposant sur les mécanismes du langage (alignas, sizeof) plutôt que d'avoir recours à ce type.

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 :

Depuis C++ 11 Depuis C++ 14
std::aligned_union<8, bool, char, short, int>::type buf;
std::aligned_union_t<8, bool, char, short, int> 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 !