Qu'est-ce qu'un code «compatible avec le cache»?

739

Quelle est la différence entre le " code hostile au cache " et le code " compatible avec le cache "?

Comment puis-je m'assurer d'écrire du code efficace pour le cache?

Noah Roth
la source
28
Cela pourrait vous donner un indice: stackoverflow.com/questions/9936132/…
Robert Martin
4
Soyez également conscient de la taille d'une ligne de cache. Sur les processeurs modernes, c'est souvent 64 octets.
John Dibling
3
Voici un autre très bon article. Les principes s'appliquent aux programmes C / C ++ sur tout système d'exploitation (Linux, MaxOS ou Windows): lwn.net/Articles/255364
paulsm4
4
Question connexe: stackoverflow.com/questions/8469427/…
Matt
stackoverflow.com/questions/763262/…
Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功

Réponses:

966

Préliminaires

Sur les ordinateurs modernes, seules les structures de mémoire de niveau le plus bas (les registres ) peuvent déplacer des données en un seul cycle d'horloge. Cependant, les registres sont très chers et la plupart des cœurs d'ordinateurs ont moins de quelques dizaines de registres (de quelques centaines à peut-être mille octets au total). À l'autre extrémité du spectre de la mémoire ( DRAM ), la mémoire est très bon marché (c'est-à-dire littéralement des millions de fois moins cher ) mais prend des centaines de cycles après une demande de réception des données. Pour combler cet écart entre super rapide et cher et super lent et bon marché, les mémoires cache, nommé L1, L2, L3 en diminuant la vitesse et le coût. L'idée est que la plupart du code d'exécution va souvent frapper un petit ensemble de variables, et le reste (un ensemble beaucoup plus grand de variables) rarement. Si le processeur ne trouve pas les données dans le cache L1, il les recherche dans le cache L2. Si ce n'est pas le cas, le cache L3 et sinon la mémoire principale. Chacun de ces «ratés» coûte cher en temps.

(L'analogie est que la mémoire cache est la mémoire système, car la mémoire système est trop de stockage sur disque dur. Le stockage sur disque dur est super bon marché mais très lent).

La mise en cache est l'une des principales méthodes pour réduire l'impact de la latence . Pour paraphraser Herb Sutter (voir les liens ci-dessous): augmenter la bande passante est facile, mais nous ne pouvons pas nous sortir de la latence .

Les données sont toujours récupérées via la hiérarchie de la mémoire (la plus petite == la plus rapide à la plus lente). Un hit / miss de cache se réfère généralement à un hit / miss dans le plus haut niveau de cache du CPU - par niveau le plus élevé, je veux dire le plus grand == le plus lent. Le taux d'accès au cache est crucial pour les performances, car chaque échec du cache entraîne la récupération des données de la RAM (ou pire ...), ce qui prend beaucoup de temps (des centaines de cycles pour la RAM, des dizaines de millions de cycles pour le disque dur). En comparaison, la lecture des données du cache (niveau le plus élevé) ne prend généralement qu'une poignée de cycles.

Dans les architectures informatiques modernes, le goulot d'étranglement des performances laisse le processeur mourir (par exemple, accéder à la RAM ou plus). Cela ne fera qu'empirer avec le temps. L'augmentation de la fréquence du processeur n'est actuellement plus pertinente pour augmenter les performances. Le problème est l'accès à la mémoire. Les efforts de conception matérielle dans les CPU se concentrent donc actuellement sur l'optimisation des caches, de la prélecture, des pipelines et de la concurrence. Par exemple, les processeurs modernes dépensent environ 85% des matrices dans les caches et jusqu'à 99% pour le stockage / déplacement des données!

Il y a beaucoup à dire sur le sujet. Voici quelques excellentes références sur les caches, les hiérarchies de mémoire et la programmation appropriée:

Concepts principaux pour un code compatible avec le cache

Un aspect très important du code compatible avec le cache concerne le principe de la localité , dont le but est de placer les données associées à proximité en mémoire pour permettre une mise en cache efficace. En termes de cache CPU, il est important de connaître les lignes de cache pour comprendre comment cela fonctionne: Comment fonctionnent les lignes de cache?

Les aspects particuliers suivants sont d'une grande importance pour optimiser la mise en cache:

  1. Localité temporelle : lorsqu'un emplacement de mémoire donné a été accédé, il est probable que le même emplacement soit accédé à nouveau dans un avenir proche. Idéalement, ces informations seront toujours mises en cache à ce stade.
  2. Localité spatiale : il s'agit de placer les données connexes à proximité les unes des autres. La mise en cache se produit à plusieurs niveaux, pas seulement dans le CPU. Par exemple, lorsque vous lisez à partir de la RAM, une plus grande partie de la mémoire est généralement récupérée que ce qui a été spécifiquement demandé car, très souvent, le programme aura bientôt besoin de ces données. Les caches HDD suivent la même ligne de pensée. Spécifiquement pour les caches CPU, la notion de lignes de cache est importante.

Utilisez approprié conteneurs

Un exemple simple de compatibilité avec le cache par rapport à la non-compatibilité avec le cache est est std::vectorcontre std::list. Les éléments de a std::vectorsont stockés dans une mémoire contiguë et, en tant que tels, leur accès est beaucoup plus convivial pour le cache que l'accès aux éléments de a std::list, qui stocke son contenu partout. Cela est dû à la localité spatiale.

Une très belle illustration de cela est donnée par Bjarne Stroustrup dans ce clip youtube (merci à @Mohammad Ali Baydoun pour le lien!).

Ne négligez pas le cache dans la structure des données et la conception des algorithmes

Dans la mesure du possible, essayez d'adapter vos structures de données et l'ordre des calculs de manière à permettre une utilisation maximale du cache. Une technique courante à cet égard est le blocage du cache (version Archive.org) , qui est d'une extrême importance dans le calcul haute performance (voir par exemple ATLAS ).

Connaître et exploiter la structure implicite des données

Un autre exemple simple, que de nombreuses personnes sur le terrain oublient parfois est celui des colonnes (ex. ,) par rapport aux commandes de ligne principale (ex. ,) pour stocker des tableaux bidimensionnels. Par exemple, considérez la matrice suivante:

1 2
3 4

Dans l'ordre des lignes principales, cela est stocké en mémoire sous la forme 1 2 3 4; dans l'ordre des colonnes, cela serait stocké sous 1 3 2 4. Il est facile de voir que les implémentations qui n'exploitent pas cet ordre rencontreront rapidement des problèmes de cache (facilement évitables!). Malheureusement, je vois très souvent des trucs comme ça dans mon domaine (machine learning). @MatteoItalia a montré cet exemple plus en détail dans sa réponse.

Lors de la récupération d'un certain élément d'une matrice à partir de la mémoire, les éléments à proximité seront également récupérés et stockés dans une ligne de cache. Si l'ordre est exploité, cela se traduira par moins d'accès à la mémoire (car les quelques valeurs suivantes qui sont nécessaires pour les calculs ultérieurs sont déjà dans une ligne de cache).

Pour simplifier, supposons que le cache comprend une seule ligne de cache qui peut contenir 2 éléments de matrice et que lorsqu'un élément donné est extrait de la mémoire, le suivant l'est également. Disons que nous voulons prendre la somme sur tous les éléments de l'exemple de matrice 2x2 ci-dessus (appelons-le M):

Exploiter l'ordre (par exemple, changer l'index de colonne en premier dans ):

M[0][0] (memory) + M[0][1] (cached) + M[1][0] (memory) + M[1][1] (cached)
= 1 + 2 + 3 + 4
--> 2 cache hits, 2 memory accesses

Ne pas exploiter l'ordre (par exemple, changer l'index de ligne en premier dans ):

M[0][0] (memory) + M[1][0] (memory) + M[0][1] (memory) + M[1][1] (memory)
= 1 + 3 + 2 + 4
--> 0 cache hits, 4 memory accesses

Dans cet exemple simple, l'exploitation de la commande double approximativement la vitesse d'exécution (car l'accès à la mémoire nécessite beaucoup plus de cycles que le calcul des sommes). En pratique, la différence de performances peut être beaucoup plus importante.

Évitez les branches imprévisibles

Les architectures modernes comportent des pipelines et les compilateurs deviennent très bons pour réorganiser le code afin de minimiser les retards dus à l'accès à la mémoire. Lorsque votre code critique contient des branches (imprévisibles), il est difficile ou impossible de pré-extraire des données. Cela entraînera indirectement davantage de ratés de cache.

Ceci est très bien expliqué ici (grâce à @ 0x90 pour le lien): Pourquoi le traitement d'un tableau trié est-il plus rapide que le traitement d'un tableau non trié?

Évitez les fonctions virtuelles

Dans le contexte de , les virtualméthodes représentent un problème controversé en ce qui concerne les ratés de cache (il existe un consensus général selon lequel ils doivent être évités lorsque cela est possible en termes de performances). Les fonctions virtuelles peuvent induire des échecs de cache lors de la recherche, mais cela ne se produit que si la fonction spécifique n'est pas appelée souvent (sinon elle serait probablement mise en cache), donc cela est considéré comme un non-problème par certains. Pour plus d'informations sur ce problème, consultez: Quel est le coût de performance d'avoir une méthode virtuelle dans une classe C ++?

Problèmes communs

Un problème courant dans les architectures modernes avec des caches multiprocesseurs est appelé faux partage . Cela se produit lorsque chaque processeur individuel tente d'utiliser des données dans une autre région de mémoire et tente de les stocker dans la même ligne de cache . Cela provoque l'écrasement de la ligne de cache - qui contient des données qu'un autre processeur peut utiliser -. En effet, différents threads se font attendre en induisant des échecs de cache dans cette situation. Voir aussi (merci à @Matt pour le lien): Comment et quand s'aligner sur la taille de la ligne de cache?

Un soi-disant thrash est un symptôme extrême de mauvaise mise en cache dans la mémoire RAM (ce qui n'est probablement pas ce que vous voulez dire dans ce contexte) . Cela se produit lorsque le processus génère en continu des défauts de page (par exemple, accède à la mémoire qui n'est pas dans la page en cours) qui nécessitent un accès au disque.

Marc Claesen
la source
27
peut-être pourriez-vous développer un peu la réponse en expliquant également que les données en code multithread peuvent également être trop locales (par exemple, faux partage)
TemplateRex
2
Il peut y avoir autant de niveaux de cache que les concepteurs de puces jugent utiles. Généralement, ils équilibrent vitesse et taille. Si vous pouviez rendre votre cache L1 aussi grand que L5 et tout aussi rapide, vous n'auriez besoin que de L1.
Rafael Baptista
24
Je me rends compte que les messages d'entente vides sont refusés sur StackOverflow, mais c'est honnêtement la meilleure réponse, la plus claire que j'ai vue jusqu'à présent. Excellent travail, Marc.
Jack Aidley
2
@JackAidley merci pour vos éloges! Quand j'ai vu l'attention portée à cette question, j'ai pensé que beaucoup de gens pourraient être intéressés par une explication assez détaillée. Je suis content que ce soit utile.
Marc Claesen
1
Ce que vous n'avez pas mentionné, c'est que les structures de données compatibles avec le cache sont conçues pour tenir dans une ligne de cache et alignées sur la mémoire pour optimiser l'utilisation des lignes de cache. Bonne réponse cependant! impressionnant.
Matt
140

En plus de la réponse de @Marc Claesen, je pense qu'un exemple classique instructif de code anti-cache est un code qui analyse un tableau bidimensionnel C (par exemple une image bitmap) colonne par colonne au lieu de ligne.

Les éléments qui sont adjacents dans une rangée sont également adjacents en mémoire, donc y accéder en séquence signifie y accéder dans l'ordre croissant de la mémoire; ceci est compatible avec le cache, car le cache a tendance à pré-extraire des blocs de mémoire contigus.

Au lieu de cela, l'accès à ces éléments en colonne n'est pas compatible avec le cache, car les éléments d'une même colonne sont éloignés en mémoire les uns des autres (en particulier, leur distance est égale à la taille de la ligne), donc lorsque vous utilisez ce modèle d'accès, vous sautent dans la mémoire, gaspillant potentiellement l'effort du cache de récupération des éléments à proximité dans la mémoire.

Et tout ce qu'il faut pour ruiner la performance est de passer de

// Cache-friendly version - processes pixels which are adjacent in memory
for(unsigned int y=0; y<height; ++y)
{
    for(unsigned int x=0; x<width; ++x)
    {
        ... image[y][x] ...
    }
}

à

// Cache-unfriendly version - jumps around in memory for no good reason
for(unsigned int x=0; x<width; ++x)
{
    for(unsigned int y=0; y<height; ++y)
    {
        ... image[y][x] ...
    }
}

Cet effet peut être assez dramatique (plusieurs ordres de grandeur de vitesse) dans des systèmes avec de petits caches et / ou travaillant avec de grands tableaux (par exemple 10+ images mégapixels 24 bpp sur les machines actuelles); pour cette raison, si vous devez effectuer de nombreuses analyses verticales, il est souvent préférable de faire pivoter l'image de 90 degrés en premier et d'effectuer les diverses analyses plus tard, en limitant le code hostile au cache uniquement à la rotation.

Matteo Italia
la source
Euh, cela devrait-il être x <largeur?
mowwwalker
13
Les éditeurs d'images modernes utilisent des tuiles comme stockage interne, par exemple des blocs de 64x64 pixels. Ceci est beaucoup plus convivial pour le cache pour les opérations locales (placement d'une touche, exécution d'un filtre de flou) car les pixels voisins sont proches en mémoire dans les deux sens, la plupart du temps.
maxy
J'ai essayé de chronométrer un exemple similaire sur ma machine, et j'ai trouvé que les temps étaient les mêmes. Quelqu'un d'autre a-t-il essayé de le chronométrer?
gsingh2011
@ I3arnon: non, le premier est compatible avec le cache, car normalement dans les tableaux C sont stockés dans l'ordre des lignes principales (bien sûr, si votre image est stockée dans l'ordre des colonnes, l'inverse est vrai).
Matteo Italia
1
@Gauthier: oui, le premier extrait est le bon; Je pense que lorsque j'ai écrit cela, je pensais dans le sens de "Tout ce qu'il faut [pour ruiner les performances d'une application qui fonctionne] est de passer de ... à ..."
Matteo Italia
88

L'optimisation de l'utilisation du cache se résume principalement à deux facteurs.

Localité de référence

Le premier facteur (auquel d'autres ont déjà fait allusion) est la localité de référence. La localité de référence a en réalité deux dimensions: l'espace et le temps.

  • Spatial

La dimension spatiale se résume également à deux choses: premièrement, nous voulons emballer nos informations de manière dense, afin que plus d'informations tiennent dans cette mémoire limitée. Cela signifie (par exemple) que vous avez besoin d'une amélioration majeure de la complexité de calcul pour justifier les structures de données basées sur de petits nœuds joints par des pointeurs.

Deuxièmement, nous voulons que les informations qui seront traitées ensemble soient également localisées ensemble. Un cache typique fonctionne en "lignes", ce qui signifie que lorsque vous accédez à certaines informations, d'autres informations à des adresses proches seront chargées dans le cache avec la partie que nous avons touchée. Par exemple, lorsque je touche un octet, le cache peut charger 128 ou 256 octets près de celui-ci. Pour en profiter, vous voulez généralement que les données soient organisées de manière à maximiser la probabilité que vous utilisiez également les autres données qui ont été chargées en même temps.

Pour un exemple vraiment trivial, cela peut signifier qu'une recherche linéaire peut être beaucoup plus compétitive avec une recherche binaire que ce à quoi vous vous attendez. Une fois que vous avez chargé un élément à partir d'une ligne de cache, l'utilisation du reste des données de cette ligne de cache est presque gratuite. Une recherche binaire ne devient sensiblement plus rapide que lorsque les données sont suffisamment volumineuses pour que la recherche binaire réduise le nombre de lignes de cache auxquelles vous accédez.

  • Temps

La dimension temporelle signifie que lorsque vous effectuez certaines opérations sur certaines données, vous souhaitez (autant que possible) effectuer toutes les opérations sur ces données en même temps.

Puisque vous avez balisé ce que C ++, je vais pointer vers un exemple classique d'une conception relativement cache peu favorables: std::valarray. valarraySurcharges opérateurs les plus arithmétiques, donc je peux (par exemple) disent a = b + c + d;(où a, b, cet dsont tous valarrays) à faire plus sage élément de ces tableaux.

Le problème avec cela est qu'il parcourt une paire d'entrées, met les résultats dans un temporaire, parcourt une autre paire d'entrées, etc. Avec beaucoup de données, le résultat d'un calcul peut disparaître du cache avant d'être utilisé dans le calcul suivant, donc nous finissons par lire (et écrire) les données à plusieurs reprises avant d'obtenir notre résultat final. Si chaque élément du résultat final sera quelque chose comme (a[n] + b[n]) * (c[n] + d[n]);, nous préférons généralement lire chaque a[n], b[n], c[n]et d[n]une fois, faire le calcul, écrire le résultat, incrément net répéter jusqu'à ce que nous avons fait. 2

Partage de ligne

Le deuxième facteur majeur est d'éviter le partage de ligne. Pour comprendre cela, nous devons probablement sauvegarder et regarder un peu comment les caches sont organisées. La forme de cache la plus simple est le mappage direct. Cela signifie qu'une seule adresse dans la mémoire principale ne peut être stockée qu'à un endroit spécifique du cache. Si nous utilisons deux éléments de données mappés au même endroit dans le cache, cela fonctionne mal - chaque fois que nous utilisons un élément de données, l'autre doit être vidé du cache pour faire de la place pour l'autre. Le reste du cache peut être vide, mais ces éléments n'utiliseront pas d'autres parties du cache.

Pour éviter cela, la plupart des caches sont ce que l'on appelle des «ensembles associatifs». Par exemple, dans un cache associatif à 4 voies, tout élément de la mémoire principale peut être stocké à l'un des 4 emplacements différents du cache. Ainsi, lorsque le cache va charger un élément, il recherche le moins récemment utilisé 3 élément parmi ces quatre, débusque à la mémoire principale, et charge le nouvel élément à sa place.

Le problème est probablement assez évident: pour un cache à mappage direct, deux opérandes qui mappent au même emplacement de cache peuvent entraîner un mauvais comportement. Un cache associatif à N voies augmente le nombre de 2 à N + 1. Organiser un cache en plus de "façons" nécessite des circuits supplémentaires et fonctionne généralement plus lentement, donc (par exemple) un cache associatif à 8192 voies est rarement une bonne solution non plus.

En fin de compte, ce facteur est cependant plus difficile à contrôler dans le code portable. Votre contrôle sur l'endroit où vos données sont placées est généralement assez limité. Pire, le mappage exact de l'adresse au cache varie entre des processeurs par ailleurs similaires. Dans certains cas, cependant, il peut être utile de faire des choses comme l'allocation d'un grand tampon, puis d'utiliser uniquement des parties de ce que vous avez alloué pour vous assurer que les données ne partagent pas les mêmes lignes de cache (même si vous devrez probablement détecter le processeur exact et agir en conséquence pour ce faire).

  • Faux partage

Il y a un autre élément connexe appelé "faux partage". Cela se produit dans un système multiprocesseur ou multicœur, où deux (ou plus) processeurs / cœurs ont des données distinctes, mais tombent dans la même ligne de cache. Cela oblige les deux processeurs / cœurs à coordonner leur accès aux données, même si chacun a son propre élément de données distinct. Surtout si les deux modifient les données en alternance, cela peut entraîner un ralentissement massif car les données doivent être constamment transférées entre les processeurs. Cela ne peut pas être facilement résolu en organisant le cache en plus de "façons" ou quelque chose comme ça non plus. Le principal moyen de l'empêcher est de s'assurer que deux threads modifient rarement (de préférence jamais) les données qui pourraient éventuellement se trouver dans la même ligne de cache (avec les mêmes mises en garde concernant la difficulté de contrôler les adresses auxquelles les données sont allouées).


  1. Ceux qui connaissent bien le C ++ pourraient se demander si cela est ouvert à l'optimisation via quelque chose comme des modèles d'expression. Je suis presque sûr que la réponse est que oui, cela pourrait être fait et si c'était le cas, ce serait probablement une victoire assez substantielle. Je ne suis au courant de personne qui l'ait fait, cependant, et étant donné le peu d' valarrayutilisation, je serais au moins un peu surpris de voir quelqu'un le faire non plus.

  2. Si quelqu'un se demande comment valarray(conçu spécifiquement pour les performances) pourrait être si mal, cela revient à une chose: il a été vraiment conçu pour des machines comme les anciennes Crays, qui utilisaient une mémoire principale rapide et sans cache. Pour eux, c'était vraiment un design presque idéal.

  3. Oui, je simplifie: la plupart des caches ne mesurent pas vraiment avec précision l'élément le moins récemment utilisé, mais ils utilisent une heuristique qui est censée être proche de cela sans avoir à garder un horodatage complet pour chaque accès.

Jerry Coffin
la source
1
J'aime les informations supplémentaires dans votre réponse, en particulier l' valarrayexemple.
Marc Claesen
1
+1 Enfin: une description simple de l'associativité des ensembles! EDITER davantage: C'est l'une des réponses les plus informatives sur SO. Je vous remercie.
Ingénieur
32

Bienvenue dans le monde de la conception orientée données. Le mantra de base est de trier, éliminer les branches, les lots, éliminer les virtualappels - toutes les étapes vers une meilleure localité.

Puisque vous avez marqué la question avec C ++, voici le Bullshit C ++ typique obligatoire . Les pièges de la programmation orientée objet de Tony Albrecht constituent également une excellente introduction au sujet.

arul
la source
1
que voulez-vous dire par lot, on peut ne pas comprendre.
0x90
5
Batching: au lieu d'effectuer l'unité de travail sur un seul objet, effectuez-la sur un lot d'objets.
arul
Blocage AKA, registres de blocage, caches de blocage.
0x90
1
Le blocage / non blocage fait généralement référence au comportement des objets dans un environnement simultané.
arul
2
batching == vectorisation
Amro
23

Juste en train de s'accumuler: l'exemple classique du code non compatible avec le cache par rapport au code convivial est le "blocage du cache" de la multiplication matricielle.

La multiplication de la matrice naïve ressemble à:

for(i=0;i<N;i++) {
   for(j=0;j<N;j++) {
      dest[i][j] = 0;
      for( k==;k<N;i++) {
         dest[i][j] += src1[i][k] * src2[k][j];
      }
   }
}

Si Nest grand, par exemple si N * sizeof(elemType)est supérieur à la taille du cache, alors chaque accès unique src2[k][j]sera un échec de cache.

Il existe de nombreuses façons d'optimiser cela pour un cache. Voici un exemple très simple: au lieu de lire un élément par ligne de cache dans la boucle interne, utilisez tous les éléments:

int itemsPerCacheLine = CacheLineSize / sizeof(elemType);

for(i=0;i<N;i++) {
   for(j=0;j<N;j += itemsPerCacheLine ) {
      for(jj=0;jj<itemsPerCacheLine; jj+) {
         dest[i][j+jj] = 0;
      }
      for( k=0;k<N;k++) {
         for(jj=0;jj<itemsPerCacheLine; jj+) {
            dest[i][j+jj] += src1[i][k] * src2[k][j+jj];
         }
      }
   }
}

Si la taille de la ligne de cache est de 64 octets et que nous fonctionnons sur des flottants de 32 bits (4 octets), il y a 16 éléments par ligne de cache. Et le nombre de cache manquant via cette simple transformation est réduit d'environ 16 fois.

Les transformations plus sophistiquées opèrent sur des tuiles 2D, optimisent pour plusieurs caches (L1, L2, TLB), etc.

Quelques résultats de googler "blocage de cache":

http://stumptown.cc.gt.atl.ga.us/cse6230-hpcta-fa11/slides/11a-matmul-goto.pdf

http://software.intel.com/en-us/articles/cache-blocking-techniques

Une belle animation vidéo d'un algorithme de blocage de cache optimisé.

http://www.youtube.com/watch?v=IFWgwGMMrh0

Le pavage en boucle est très étroitement lié:

http://en.wikipedia.org/wiki/Loop_tiling

Krazy Glew
la source
7
Les personnes qui liront ceci pourraient également être intéressées par mon article sur la multiplication matricielle où j'ai testé l'algorithme ikj "compatible avec le cache" et l'algorithme ijk hostile en multipliant deux matrices 2000x2000.
Martin Thoma
3
k==;J'espère que c'est une faute de frappe?
TrebledJ
13

Les processeurs fonctionnent aujourd'hui avec de nombreux niveaux de zones de mémoire en cascade. Ainsi, le processeur aura un tas de mémoire qui se trouve sur la puce du processeur lui-même. Il a un accès très rapide à cette mémoire. Il existe différents niveaux de cache, chacun un accès plus lent (et plus grand) que le suivant, jusqu'à ce que vous arriviez à la mémoire système qui n'est pas sur le CPU et qui est relativement beaucoup plus lente à accéder.

Logiquement, dans le jeu d'instructions du CPU, vous vous référez simplement aux adresses mémoire dans un espace d'adressage virtuel géant. Lorsque vous accédez à une seule adresse mémoire, le processeur va la récupérer. autrefois, il ne récupérait que cette seule adresse. Mais aujourd'hui, le CPU va chercher un tas de mémoire autour du bit que vous avez demandé et le copier dans le cache. Cela suppose que si vous avez demandé une adresse particulière, il est très probable que vous allez demander une adresse à proximité très bientôt. Par exemple, si vous copiez un tampon, vous lisez et écrivez à partir d'adresses consécutives, l'une après l'autre.

Donc, aujourd'hui, lorsque vous récupérez une adresse, il vérifie le premier niveau de cache pour voir s'il a déjà lu cette adresse dans le cache, s'il ne la trouve pas, c'est un échec de cache et il doit passer au niveau suivant de cache pour le trouver, jusqu'à ce qu'il doive finalement sortir dans la mémoire principale.

Le code convivial du cache essaie de garder les accès proches les uns des autres en mémoire afin de minimiser les ratés du cache.

Donc, un exemple serait d'imaginer que vous vouliez copier une table géante à 2 dimensions. Il est organisé avec une ligne de portée consécutive en mémoire et une ligne suit la suivante juste après.

Si vous copiez les éléments une ligne à la fois de gauche à droite - ce serait compatible avec le cache. Si vous décidiez de copier le tableau une colonne à la fois, vous copieriez exactement la même quantité de mémoire - mais ce serait hostile au cache.

Rafael Baptista
la source
4

Il convient de préciser que non seulement les données doivent être compatibles avec le cache, mais qu'elles sont tout aussi importantes pour le code. Cela s'ajoute à la prédiction des branches, à la réorganisation des instructions, en évitant les divisions réelles et d'autres techniques.

En règle générale, plus le code est dense, moins il faudra de lignes de cache pour le stocker. Il en résulte que davantage de lignes de cache sont disponibles pour les données.

Le code ne doit pas appeler de fonctions partout car elles nécessitent généralement une ou plusieurs lignes de cache qui leur sont propres, ce qui entraîne moins de lignes de cache pour les données.

Une fonction doit commencer à une adresse compatible avec l'alignement des lignes du cache. Bien qu'il existe des commutateurs du compilateur (gcc) pour cela, sachez que si les fonctions sont très courtes, il peut être inutile pour chacun d'occuper une ligne de cache entière. Par exemple, si trois des fonctions les plus souvent utilisées tiennent dans une ligne de cache de 64 octets, cela est moins inutile que si chacune a sa propre ligne et entraîne deux lignes de cache moins disponibles pour une autre utilisation. Une valeur d'alignement typique peut être 32 ou 16.

Alors, passez un peu plus de temps pour rendre le code dense. Testez différentes constructions, compilez et examinez la taille et le profil du code généré.

Olof Forshell
la source
2

Comme @Marc Claesen l'a mentionné, l'une des façons d'écrire du code compatible avec le cache consiste à exploiter la structure dans laquelle nos données sont stockées. En plus de cela, une autre façon d'écrire du code compatible avec le cache est: changer la façon dont nos données sont stockées; puis écrivez un nouveau code pour accéder aux données stockées dans cette nouvelle structure.

Cela a du sens dans le cas de la façon dont les systèmes de base de données linéarisent les tuples d'une table et les stockent. Il existe deux méthodes de base pour stocker les tuples d'une table, à savoir le magasin de lignes et le magasin de colonnes. Dans le magasin en ligne, comme son nom l'indique, les tuples sont stockés en ligne. Supposons qu'une table nommée Productstockée ait 3 attributs, c'est int32_t key, char name[56]-à- dire et int32_t price, donc la taille totale d'un tuple est en 64octets.

Nous pouvons simuler une exécution de requête de stockage de lignes très basique dans la mémoire principale en créant un tableau de Productstructures de taille N, où N est le nombre de lignes dans la table. Cette disposition de la mémoire est également appelée tableau de structures. Ainsi, la structure du produit peut être comme:

struct Product
{
   int32_t key;
   char name[56];
   int32_t price'
}

/* create an array of structs */
Product* table = new Product[N];
/* now load this array of structs, from a file etc. */

De même, nous pouvons simuler une exécution de requête de stockage de colonnes très basique dans la mémoire principale en créant 3 tableaux de taille N, un tableau pour chaque attribut de la Producttable. Une telle disposition de mémoire est également appelée structure de tableaux. Ainsi, les 3 tableaux pour chaque attribut de produit peuvent être comme:

/* create separate arrays for each attribute */
int32_t* key = new int32_t[N];
char* name = new char[56*N];
int32_t* price = new int32_t[N];
/* now load these arrays, from a file etc. */

Maintenant, après avoir chargé à la fois le tableau de structures (disposition des lignes) et les 3 tableaux séparés (disposition des colonnes), nous avons un magasin de lignes et un magasin de colonnes sur notre table Productprésents dans notre mémoire.

Nous passons maintenant à la partie du code compatible avec le cache. Supposons que la charge de travail sur notre table soit telle que nous ayons une requête d'agrégation sur l'attribut price. Tel que

SELECT SUM(price)
FROM PRODUCT

Pour le magasin de lignes, nous pouvons convertir la requête SQL ci-dessus en

int sum = 0;
for (int i=0; i<N; i++)
   sum = sum + table[i].price;

Pour le magasin de colonnes, nous pouvons convertir la requête SQL ci-dessus en

int sum = 0;
for (int i=0; i<N; i++)
   sum = sum + price[i];

Le code du magasin de colonnes serait plus rapide que le code de la disposition des lignes dans cette requête car il ne nécessite qu'un sous-ensemble d'attributs et dans la disposition des colonnes, nous faisons exactement cela, c'est-à-dire uniquement en accédant à la colonne des prix.

Supposons que la taille de la ligne de cache soit en 64octets.

Dans le cas de la disposition des lignes lorsqu'une ligne de cache est lue, la valeur de prix de seulement 1 ( cacheline_size/product_struct_size = 64/64 = 1) tuple est lue, car notre taille de structure de 64 octets remplit toute notre ligne de cache, donc pour chaque tuple, un échec de cache se produit au cas où d'une disposition de ligne.

Dans le cas de la disposition des colonnes lorsqu'une ligne de cache est lue, la valeur de prix de 16 ( cacheline_size/price_int_size = 64/4 = 16) tuples est lue, car 16 valeurs de prix contiguës stockées en mémoire sont introduites dans le cache, donc pour chaque seizième de tuple, un échec de cache se produit en cas de disposition des colonnes.

Ainsi, la disposition des colonnes sera plus rapide dans le cas d'une requête donnée et plus rapide dans de telles requêtes d'agrégation sur un sous-ensemble de colonnes de la table. Vous pouvez essayer une telle expérience par vous-même en utilisant les données du benchmark TPC-H et comparer les temps d'exécution pour les deux dispositions. L' article de wikipedia sur les systèmes de base de données orientés colonnes est également bon.

Ainsi, dans les systèmes de base de données, si la charge de travail des requêtes est connue à l'avance, nous pouvons stocker nos données dans des mises en page adaptées aux requêtes de la charge de travail et accéder aux données de ces mises en page. Dans le cas de l'exemple ci-dessus, nous avons créé une disposition de colonne et changé notre code pour calculer la somme afin qu'elle devienne compatible avec le cache.


la source
1

Sachez que les caches ne mettent pas uniquement en cache la mémoire continue. Ils ont plusieurs lignes (au moins 4), de sorte que la mémoire discontinue et superposée peut souvent être stockée tout aussi efficacement.

Ce qui manque dans tous les exemples ci-dessus, ce sont des repères mesurés. Il existe de nombreux mythes sur la performance. À moins que vous ne le mesuriez, vous ne savez pas. Ne compliquez pas votre code sauf si vous avez une amélioration mesurée .

Tuntable
la source