72 votes

Comment et quand s'aligner sur la taille des lignes de cache ?

Dans l'excellente file d'attente mpmc bornée de Dmitry Vyukov écrite en C++. Voir : http://www.1024cores.net/home/lock-free-algorithms/queues/bounded-mpmc-queue

Il ajoute quelques variables de rembourrage. Je suppose que c'est pour l'aligner sur une ligne de cache pour les performances.

J'ai quelques questions.

  1. Pourquoi le faire de cette manière ?
  2. Est-ce une méthode portable qui toujours fonctionner
  3. Dans quels cas serait-il préférable d'utiliser __attribute__ ((aligned (64))) à la place.
  4. pourquoi le remplissage avant un pointeur de tampon améliorerait-il les performances ? le pointeur n'est-il pas simplement chargé dans le cache, de sorte qu'il n'a que la taille d'un pointeur ?

    static size_t const     cacheline_size = 64;
    typedef char            cacheline_pad_t [cacheline_size];
    
    cacheline_pad_t         pad0_;
    cell_t* const           buffer_;
    size_t const            buffer_mask_;
    cacheline_pad_t         pad1_;
    std::atomic<size_t>     enqueue_pos_;
    cacheline_pad_t         pad2_;
    std::atomic<size_t>     dequeue_pos_;
    cacheline_pad_t         pad3_;

Ce concept fonctionnerait-il sous gcc pour le code c ?

1 votes

Pourquoi utiliser le cacheline_size comme taille du tampon, plutôt que ( cacheline_size - sizeof( std::atomic<size_t> ) ) ?

51voto

Novelocrat Points 12126

On procède de cette façon pour que les différents cœurs qui modifient des champs différents n'aient pas à faire rebondir la ligne de cache contenant les deux entre leurs caches. En général, pour qu'un processeur puisse accéder à certaines données en mémoire, la ligne de cache entière qui les contient doit se trouver dans le cache local de ce processeur. S'il modifie ces données, cette entrée de cache doit généralement être la seule copie dans n'importe quel cache du système (mode exclusif dans les systèmes MESI/MOESI). protocoles de cohérence de la mémoire cache ). Lorsque des cœurs distincts essaient de modifier des données différentes qui se trouvent sur la même ligne de cache, et perdent ainsi du temps à déplacer toute cette ligne dans les deux sens, on parle de faux partage .

Dans l'exemple particulier que vous donnez, un noyau peut mettre en file d'attente une entrée (lecture (partagée)). buffer_ et l'écriture (exclusive) seulement enqueue_pos_ ) tandis qu'un autre met en file d'attente (partagé buffer_ et exclusif dequeue_pos_ ) sans que l'un des deux noyaux ne se bloque sur une ligne de cache appartenant à l'autre.

Le remplissage au début signifie que buffer_ y buffer_mask_ se retrouvent sur la même ligne de cache, au lieu d'être réparties sur deux lignes et de nécessiter ainsi un double trafic mémoire pour y accéder.

Je ne suis pas sûr que la technique soit entièrement portable. L'hypothèse est que chaque cacheline_pad_t sera lui-même aligné sur une limite de ligne de cache de 64 octets (sa taille), et donc tout ce qui le suit sera sur la ligne de cache suivante. Pour autant que je sache, les normes des langages C et C++ ne l'exigent que pour les structures entières, de sorte qu'elles puissent vivre dans des tableaux de manière agréable, sans violer les exigences d'alignement de l'un de leurs membres. (voir les commentaires)

Le site attribute serait plus spécifique au compilateur, mais pourrait réduire de moitié la taille de cette structure, puisque le remplissage se limiterait à arrondir chaque élément à une ligne de cache complète. Cela pourrait être très bénéfique si l'on en avait beaucoup.

Le même concept s'applique en C et en C++.

0 votes

@Novelcrat - Ok, ça a beaucoup de sens. Alors qu'en est-il des questions 2 et 3 ?

13 votes

@MattH : Pour la portabilité, C++11 introduit std::aligned_storage qui vous permettent d'exiger un stockage de taille et d'alignement définis. L'alignement par défaut d'un char [N] est 1 autrement.

1 votes

Pourquoi l'éditeur de liens n'optimiserait-il pas les variables de remplissage si elles ne sont pas utilisées ?

5voto

Vous pouvez avoir besoin de vous aligner sur une limite de ligne de cache, qui est généralement de 64 octets par ligne de cache, lorsque vous travaillez avec des interruptions ou des lectures de données à haute performance, et il est obligatoire de les utiliser lorsque vous travaillez avec des sockets interprocessus. Avec les sockets interprocessus, certaines variables de contrôle ne peuvent pas être réparties sur plusieurs lignes de cache ou mots DDR RAM, sinon les caches L1, L2, etc. ou la DDR RAM fonctionneront comme un filtre passe-bas et filtreront vos données d'interruption ! C'EST MAUVAIS ! !! Cela signifie que vous obtenez des erreurs bizarres alors que votre algorithme est bon et cela a le potentiel de vous rendre fou !

La mémoire vive DDR est presque toujours lue en mots de 128 bits (mots de mémoire vive DDR), soit 16 octets, de sorte que les variables du tampon circulaire ne doivent pas être réparties sur plusieurs mots de mémoire vive DDR. Certains systèmes utilisent des mots de mémoire vive DDR de 64 bits et, techniquement, il est possible d'obtenir un mot de mémoire vive DDR de 32 bits sur un processeur de 16 bits, mais il est préférable d'utiliser de la mémoire SDRAM dans ce cas.

On peut aussi simplement être intéressé par la minimisation du nombre de lignes de cache utilisées lors de la lecture des données dans un algorithme à haute performance. Dans mon cas, j'ai développé l'algorithme integer-to-string le plus rapide du monde (40% plus rapide que l'algorithme le plus rapide précédent) et je travaille à l'optimisation de l'algorithme Grisu, qui est l'algorithme à virgule flottante le plus rapide du monde. Pour imprimer le nombre à virgule flottante, il faut imprimer le nombre entier, donc pour optimiser Grisu, j'ai aligné les tables de consultation (LUT) de Grisu sur 15 lignes de cache exactement, ce qui est plutôt étrange qu'elles soient alignées comme ça. Cela prend les LUTs de la section .bss (c'est-à-dire la mémoire statique) et les place sur la pile (ou le tas, mais la pile est plus appropriée). Je n'ai pas fait d'analyse comparative mais il est bon de le souligner, et j'ai beaucoup appris à ce sujet, la façon la plus rapide de charger des valeurs est de les charger à partir du i-cache et non du d-cache. La différence est que le i-cache est en lecture seule et a des lignes de cache beaucoup plus grandes parce qu'il est en lecture seule (2KB c'est ce qu'un professeur m'a cité une fois.). Vous allez donc dégrader vos performances en indexant les tableaux plutôt qu'en chargeant une variable comme celle-ci :

int faster_way = 12345678;

par opposition à la voie la plus lente :

int variables[2] = { 12345678, 123456789};
int slower_way = variables[0];

La différence est que le int variable = 12345678 sera chargé à partir des lignes du i-cache en décalant la variable dans le i-cache depuis le début de la fonction, tandis que slower_way = int[0] sera chargé à partir des lignes plus petites du d-cache en utilisant une indexation de tableau beaucoup plus lente. Cette subtilité particulière, comme je viens de le découvrir, ralentit en fait mon algorithme integer-to-string et celui de beaucoup d'autres. Je dis cela parce que vous pouvez penser que vous optimisez en alignant en cache des données en lecture seule alors que ce n'est pas le cas.

Typiquement, en C++, vous utiliserez la fonction std::align fonction. Je conseille de ne pas utiliser cette fonction car il n'est pas garanti qu'il fonctionne de manière optimale . Voici le moyen le plus rapide de s'aligner sur une ligne de cache. Pour être franc, je suis l'auteur et c'est une publicité éhontée :

Algorithme d'alignement de la mémoire de la boîte à outils Kabuki

namespace _ {
/* Aligns the given pointer to a power of two boundaries with a premade mask.
@return An aligned pointer of typename T.
@brief Algorithm is a 2's compliment trick that works by masking off
the desired number of bits in 2's compliment and adding them to the
pointer.
@param pointer The pointer to align.
@param mask The mask for the Least Significant bits to align. */
template <typename T = char>
inline T* AlignUp(void* pointer, intptr_t mask) {
  intptr_t value = reinterpret_cast<intptr_t>(pointer);
  value += (-value ) & mask;
  return reinterpret_cast<T*>(value);
}
} //< namespace _

// Example calls using the faster mask technique.

enum { kSize = 256 };
char buffer[kSize + 64];

char* aligned_to_64_byte_cache_line = AlignUp<> (buffer, 63);

char16_t* aligned_to_64_byte_cache_line2 = AlignUp<char16_t> (buffer, 63);

et voici le remplacement plus rapide de std::align :

inline void* align_kabuki(size_t align, size_t size, void*& ptr,
                          size_t& space) noexcept {
  // Begin Kabuki Toolkit Implementation
  intptr_t int_ptr = reinterpret_cast<intptr_t>(ptr),
           offset = (-int_ptr) & (align - 1);
  if ((space -= offset) < size) {
    space += offset;
    return nullptr;
  }
  return reinterpret_cast<void*>(int_ptr + offset);
  // End Kabuki Toolkit Implementation
}

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X