Alignement des données entre les architectures : le bon, le mauvais et le truand

Même si la carte mémoire d’un ordinateur semble assez lisse et très adressable par octets à première vue, la même mémoire au niveau matériel est beaucoup plus cahoteuse. Un terme essentiel qu’un développeur peut rencontrer dans ce contexte est alignement des données, qui fait référence à la manière dont le matériel accède à la mémoire vive (RAM) du système. Ceci et d’autres sont des propriétés de l’implémentation de la RAM et du bus mémoire du système, avec diverses implications pour les développeurs de logiciels.

Pour un bus mémoire 32 bits, le type d’accès optimal pour certaines données serait de quatre octets, alignés exactement sur une bordure de quatre octets dans la mémoire. Ce qui se passe lorsqu’un accès non aligné est tenté – comme la lecture de ladite valeur de quatre octets alignée à mi-chemin dans un mot – est défini par l’implémentation. Certaines plates-formes matérielles ont un support matériel pour l’accès non aligné, d’autres lèvent une exception que le système d’exploitation (OS) peut intercepter et revenir à une routine non alignée dans le logiciel. Les autres plates-formes génèrent généralement une erreur de bus (SIGBUS dans POSIX) si vous tentez un accès non aligné.

Pourtant, même si l’accès à la mémoire non alignée est autorisé, quel est le véritable impact sur les performances ?

Une vue matérielle

Topologie DRAM de base (Crédit : Anandtech)
Topologie DRAM de base (Crédit : Anandtech)

Aussi nébuleuse que puisse paraître la mémoire système, sa mise en œuvre sous la forme d’une mémoire vive dynamique synchrone (SDRAM) est très liée à des limitations physiques. Une excellente introduction au fonctionnement de la SDRAM se trouve dans un article Anandtech de 2010 de Rajinder Gill. L’essentiel à retenir de cela est la façon dont les modules SDRAM sont adressés.

Chaque requête de lecture et d’écriture doit sélectionner la banque cible sur un module DIMM (BAn), suivie de commandes qui spécifient la ligne (RAS) et la colonne (CAS) cibles. Chaque ligne se compose de plusieurs milliers de cellules, chaque puce SDRAM sur un module DIMM contribuant huit bits au bus de données 64 bits trouvé sur les modules DIMM DDR 3 et DDR 4.

La conséquence de cette configuration physique est que tous les accès à la RAM et au(x) cache(s) intermédiaire(s) sont alignés le long de ces frontières physiquement définies. Lorsqu’un contrôleur de mémoire est chargé de récupérer les données d’une variable, il est extrêmement utile que ces données puissent être extraites de la RAM en une seule opération de lecture afin qu’elles puissent être lues dans un registre du CPU.

Que se passe-t-il lorsque ces données ne sont pas alignées ? Il est possible de lire d’abord la section initiale de données, puis d’effectuer une deuxième lecture pour obtenir la section finale, puis de fusionner les deux parties. Naturellement, il s’agit d’une opération qui doit être prise en charge directement par le contrôleur de mémoire ou gérée par le système d’exploitation.

Le contrôleur de mémoire génère une erreur de bus lorsqu’il est invité à accéder à une adresse non valide, tombe sur une erreur de pagination ou est invité à effectuer un accès non aligné lorsque cela n’est pas pris en charge. Les plates-formes telles que x86 et ses dérivés prennent en charge l’accès à la mémoire non alignée.

Quand les choses explosent

Comme indiqué, x86 et x86_64 sont essentiellement très bien, mais vous accédez à la RAM système avec l’alignement que vous avez choisi ou que vous avez utilisé au hasard. Là où les choses deviennent plus compliquées, c’est avec d’autres plates-formes, telles qu’ARM, avec la documentation ARMv7 répertoriant les propriétés de la plate-forme dans le contexte d’un accès aux données non alignées. Essentiellement, dans de nombreux cas, vous obtiendrez un défaut d’alignement du matériel.

Dans cet article IBM de 2005, il est expliqué comment les processeurs Motorola m68k, MIPS et PowerPC de cette époque géraient l’accès non aligné. La chose intéressante à noter ici est que jusqu’au 68020, l’accès non aligné générerait toujours une erreur de bus. Les processeurs MIPS ne se sont pas souciés de l’accès non aligné au nom de la vitesse, et PowerPC a adopté une approche hybride, avec un accès non aligné 32 bits autorisé, mais un accès non aligné 64 bits (virgule flottante) entraînant une erreur de bus.

Lorsqu’il s’agit de répliquer l’erreur d’alignement SIGBUS, cela se fait facilement en utilisant par exemple le déréférencement d’un pointeur :

uint8_t* data = binary_blob;
uint32_t val = *((uint32_t*) data);

Ici binary_blob est supposé être une collection de valeurs de taille variable, pas seulement des entiers 32 bits.

Bien que ce code fonctionne correctement sur n’importe quelle plate-forme x86, sur une plate-forme basée sur ARM telle que le Raspberry Pi, le déréférencement de cette manière est à peu près garanti pour vous obtenir une erreur SIGBUS et un processus très mort. Le problème est notamment que lorsque vous demandez à accéder au uint8_t pointeur sous la forme d’un entier 32 bits, les chances qu’il soit correctement aligné uint32_t sont essentiellement nuls.

Alors que faire dans ce cas ?

Rester aligné

Les arguments en faveur de l’utilisation d’un accès mémoire aligné sont nombreux. Certains des plus importants sont l’atomicité et la portabilité. L’atomicité fait référence à une lecture ou une écriture d’entier qui peut être effectuée en une seule opération de lecture ou d’écriture. Dans le cas d’un accès non aligné, cette atomicité ne s’applique plus car elle doit lire au-delà des frontières. Certains codes peuvent s’appuyer sur de telles lectures et écritures atomiques, qui, lorsque l’accès non aligné n’est pas pris en compte, peuvent entraîner des bogues et des plantages intéressants et sporadiques.

L’éléphant très évident dans la pièce, cependant, est celui de la portabilité. Comme nous l’avons vu dans la section précédente, il est très facile d’écrire du code qui fonctionne très bien sur une plate-forme, mais qui mourra pitoyablement sur une autre plate-forme. Il existe cependant un moyen d’écrire du code qui sera entièrement portable, qui est en fait défini dans la spécification C comme le seul véritable moyen de copier des données sans se heurter à des problèmes d’alignement : memcpy.

Si nous devions réécrire le fragment de code précédent en utilisant memcpyon se retrouve avec le code suivant :

#include <cstring>
uint8_t* data = binary_blob; 
uint32_t val;
memcpy(&val, data, 4); 

Ce code est entièrement portable, avec le memcpy mise en œuvre de la gestion des problèmes d’alignement. Si nous exécutons un code comme celui-ci, par exemple sur un système Raspberry Pi, aucune erreur SIGBUS ne sera générée et le processus continuera à voir un autre cycle CPU.

Structures de données, struct en C, sont des groupements de valeurs de données liées. Comme ceux-ci doivent être placés dans la RAM de manière consécutive, cela créerait évidemment des problèmes d’alignement, à moins qu’un remplissage ne soit appliqué. Les compilateurs ajoutent un tel rembourrage si nécessaire par défaut, ce qui garantit que chaque membre de données d’une structure est aligné en mémoire. Évidemment, cela « gaspille » de la mémoire et augmente la taille de la structure, mais garantit que chaque accès d’un membre de données se produit entièrement aligné.

Pour les cas courants où des structures sont utilisées, telles que les E/S mappées en mémoire sur les MCU et les périphériques matériels, cela n’est généralement pas un problème car ceux-ci n’utilisent que des registres 32 bits ou 64 bits qui sont toujours alignés lorsque le premier membre de données est. Il est souvent possible d’ajuster manuellement le rembourrage de la structure avec les chaînes d’outils du compilateur pour des raisons de performances et de taille, mais cela ne doit être fait qu’avec le plus grand soin.

Impact sur les performances

Mais, peut-on se demander, quel est l’impact sur les performances d’ignorer l’alignement des données et de simplement laisser le matériel ou le système d’exploitation couvrir les complications ? Comme nous l’avons vu dans l’exploration de l’implémentation physique de la RAM système, un accès non aligné est possible, au prix de cycles de lecture ou d’écriture supplémentaires. Évidemment, cela doublerait au moins le nombre de ces cycles. Si cela devait se produire dans toutes les banques, l’impact sur les performances pourrait être majeur.

Accès à un ou deux ou quatre octets (Crédit : Jonathan Rentzsch, IBM)
Accès à un ou deux ou quatre octets (Crédit : Jonathan Rentzsch, IBM)

Dans l’article IBM de 2005 référencé précédemment par Johnathan Rentzsch, un certain nombre de résultats de référence sont fournis à l’aide de modèles d’accès à simple, double, quatre et huit octets. Malgré le fonctionnement sur un PowerBook G5 800 MHz plutôt lent, l’impact de l’accès non aligné était assez perceptible, l’accès non aligné sur deux octets étant 27 % plus lent que l’accès aligné. Pour un accès non aligné sur quatre octets, cela était plus lent que l’accès aligné sur deux octets, ce qui rendait inutile le passage à des tailles de données plus importantes.

Lors du passage à un accès aligné sur huit octets, cela était 10 % plus rapide que l’accès aligné sur quatre octets. Pourtant, l’accès non aligné sur huit octets a pris 1,8 seconde pour l’ensemble du tampon, 4,6 fois plus lent que l’alignement, car le PowerPC G4 n’a pas de support matériel pour l’accès non aligné sur huit octets, le système d’exploitation effectuant à la place les opérations de fusion requises.

L’impact sur les performances ne concerne pas seulement les opérations CPU ALU standard, mais également les extensions SIMD (vecteur), comme détaillé par Mesa et al. (2007). De plus, lors du développement du codec x264, il a été constaté que l’utilisation de l’alignement de la ligne de cache (transfert aligné sur 16 octets) était 69 % plus rapide sur l’une des fonctions les plus couramment utilisées dans x264. L’implication ici étant que l’alignement des données va bien au-delà de la RAM système, mais s’applique également aux caches et autres éléments d’un système informatique.

Cela se résume en grande partie au doublement des opérations (communes) et à l’impact sur les performances globales qui en résulte.

Conclure

À certains égards, l’architecture x86 est plutôt à l’aise dans la façon dont elle vous protège des parties laides de la réalité, telles que l’accès à la mémoire non alignée, mais la réalité a le moyen de vous surprendre lorsque vous vous y attendez le moins. Une telle occasion s’est produite pour moi il y a quelques mois alors que je faisais du profilage et des optimisations sur une bibliothèque d’appels de procédure à distance.

Après que l’outil de profilage Cachegrind de Valgrind m’ait montré la quantité excessive de copies non alignées en cours dans les composants internes, le défi consistait non seulement à implémenter une version sans copie de la bibliothèque, mais également à analyser sur place les données binaires. Ce qui a conduit à certaines des implications susmentionnées avec un accès mémoire non aligné lors du déréférencement des données binaires (compressées).

Bien que le problème ait été facilement résolu en utilisant le memcpy-, cela m’a fourni un aperçu fascinant des défauts SIGBUS sur les systèmes basés sur ARM où le même code avait fonctionné sans accroc sur les systèmes x86_64. Quant à l’impact sur les performances? Les benchmarks avant et après les modifications de la bibliothèque RPC ont montré une augmentation remarquable des performances, qui peut être en partie due au passage à l’accès aligné.

Même s’il y a des gens qui insistent sur le fait que l’impact sur les performances d’un accès non aligné n’est pas assez important aujourd’hui pour s’inquiéter, l’impact très réel sur la portabilité et l’atomicité devrait faire réfléchir tout le monde. Au-delà de cela, cela vaut vraiment la peine d’exécuter le code via un profileur pour avoir une idée de ce à quoi ressemblent les modèles d’accès à la mémoire et de ce qui pourrait être amélioré ou optimisé.