web-dev-qa-db-fra.com

Qu'est-ce qu'un code "convivial pour le cache"?

Quelle est la différence entre "code anti-cache)" et le code "anti-cache"?

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

687
Alex

Préliminaires

Sur les ordinateurs modernes, seules les structures de mémoire de niveau le plus bas ( registres ) peuvent déplacer des données par cycles d'horloge simples. Cependant, les registres sont très coûteux et la plupart des cœurs d’ordinateur ont moins de quelques dizaines de registres (quelques centaines à peut-être un millier octets au total). À l’autre extrémité du spectre mémoire (DRAM), la mémoire est très peu coûteuse (c’est-à-dire littéralement des millions de fois moins chère ), mais prend des centaines de cycles après une demande pour recevoir les données. Pour combler cet écart entre ultra-rapide et coûteux, et très lent et bon marché, utilisez les mémoires cache , appelées L1, L2, L3 en termes de vitesse et de coût décroissants. L'idée est que la plupart du code en cours d'exécution frappe souvent un petit ensemble de variables, et le reste (un ensemble de variables beaucoup plus vaste) rarement. Si le processeur ne parvient pas à trouver les données dans le cache L1, il le recherche dans le cache L2. Si ce n'est pas le cas, cachez L3, et sinon, mémoire principale. Chacun de ces "ratés" coûte cher dans le 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 très bon marché mais très lent).

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

Les données sont toujours récupérées dans la hiérarchie de la mémoire (le plus petit == le plus rapide au plus lent). Un hit/miss dans le cache fait généralement référence à un hit/miss dans le niveau de cache le plus élevé de la CPU - par niveau le plus élevé, j'entends le plus grand == le plus lent. Le taux de réussite du cache est crucial pour les performances car chaque cache manquant entraîne l'extraction de données de RAM (ou pire ...), ce qui prend beaucoup du temps (des centaines de cycles pour la RAM, des dizaines de millions de cycles pour le disque dur). En comparaison, la lecture de données à partir du cache (niveau le plus élevé) ne prend généralement que quelques cycles.

Dans les architectures informatiques modernes, le goulot d'étranglement des performances laisse la puce du processeur (par exemple, accéder à RAM ou plus). Cela ne fera qu'empirer avec le temps. L'augmentation de la fréquence du processeur n'est plus pertinente pour améliorer les performances. Le problème est l’accès à la mémoire. Les efforts de conception matérielle dans les processeurs sont donc actuellement fortement axés sur l’optimisation des caches, du préchargement, des pipelines et de la simultanéité. Par exemple, les processeurs modernes dépensent environ 85% des puces en mémoire cache et jusqu'à 99% en stockage/déplacement de 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:

Principaux concepts pour un code convivial en cache

Un aspect très important du code convivial au cache est tout principe de localité, dont le but est de placer les données connexes en mémoire pour permettre mise en cache efficace. En termes de cache de la 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 revêtent 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 sera à nouveau accédé dans un proche avenir. Idéalement, cette information sera toujours mise en cache à ce stade.
  2. localité spatiale : il s'agit de placer des données connexes à proximité les unes des autres. La mise en cache se produit à plusieurs niveaux, pas seulement dans la CPU. Par exemple, lorsque vous lisez dans la RAM, une quantité de mémoire supérieure à celle demandée spécifiquement est extraite, car très souvent, le programme nécessitera rapidement ces données. Les caches de disque dur suivent la même ligne de pensée. Spécifiquement pour les caches de CPU, la notion de lignes de cache est importante.

Utilisation appropriée c ++ conteneurs

Un exemple simple de convivialité de cache par rapport à anti-cache est: std::vector de c ++ versus std::list. Les éléments d'un std::vector sont stockés dans une mémoire contiguë et, en tant que tels, y accéder est beaucoup plus convivial que les éléments d'un std::list, qui stocke son contenu partout. Ceci est dû à la localité spatiale.

Bjarne Stroustrup en donne une très belle illustration dans ce clip youtube (merci à @Mohammad ALi Baydoun pour le lien!).

Ne négligez pas le cache dans la structure de données et la conception d'algorithmes

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

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

Un autre exemple simple, que de nombreuses personnes sur le terrain oublient parfois, est la colonne majeure (par exemple, fortran , matlab ) par rapport à l’ordre des lignes principales (par exemple, c , c ++ ) pour stocker des tableaux à deux dimensions. Par exemple, considérons la matrice suivante:

1 2
3 4

Dans l'ordre des lignes majeures, il est stocké en mémoire sous le nom 1 2 3 4; dans l'ordre des colonnes, il serait stocké sous le nom 1 3 2 4. Il est facile de voir que les implémentations qui n'exploitent pas cet ordre se heurteront rapidement à des problèmes de cache (facilement évitables!). Malheureusement, je vois des choses comme cela très souvent dans mon domaine (apprentissage automatique). @ MatteoItalia a montré cet exemple plus en détail dans sa réponse.

Lors de l'extraction d'un certain élément d'une matrice à partir de la mémoire, les éléments proches de celle-ci sont également récupérés et stockés dans une ligne de cache. Si le classement est exploité, cela se traduira par moins d'accès à la mémoire (car les valeurs suivantes nécessaires pour les calculs suivants se trouvent déjà dans une ligne de cache).

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

Exploiter la commande (par exemple, changer l’index de la colonne d’abord dans c ++ ):

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 c ++ ):

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 environ 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 performance peut être beaucoup plus grande .

Évitez les branches imprévisibles

Les architectures modernes comportent des pipelines et les compilateurs deviennent de plus en plus aptes à 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, voire impossible, de pré-télécharger des données. Cela conduira indirectement à plus de mises en cache.

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

Evitez les fonctions virtuelles

Dans le contexte de c ++ , virtual les méthodes représentent un problème controversé en ce qui concerne les erreurs de cache (un consensus s’est dégagé sur le fait qu’elles devraient être évitées autant que possible en termes de performances). Les fonctions virtuelles peuvent induire des erreurs 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), ce qui est considéré comme non problématique. par certains. Pour plus d'informations sur ce problème, consultez: Quel est le coût en performances d'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 tente d'utiliser des données dans une autre région de la mémoire et tente de les stocker dans la même ligne de cache . Ainsi, la ligne de cache, qui contient des données qu'un autre processeur peut utiliser, est écrasée encore et encore. Effectivement, différents threads se font attendre en induisant des erreurs 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 symptôme extrême de la mise en cache médiocre dans la mémoire RAM (ce qui n'est probablement pas ce que vous voulez dire dans ce contexte) s'appelle thrashing . Cela se produit lorsque le processus génère en permanence des erreurs de page (par exemple, l'accès à de la mémoire qui n'est pas dans la page en cours) qui nécessitent un accès au disque.

894
Marc Claesen

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

Les éléments adjacents dans une rangée sont également adjacents en mémoire, leur accès séquentiel signifie donc qu'ils y ont accès dans l'ordre croissant de la mémoire; ceci est convivial pour le cache, car le cache tend à pré-extraire des blocs de mémoire contigus.

Au lieu de cela, accéder à de tels éléments colonne par colonne est anti-cache, puisque les éléments sur la même colonne sont distants en mémoire les uns des autres (en particulier, leur distance est égale à la taille de la ligne), donc lorsque vous utilisez ce motif d'accès, sautent en mémoire, gaspillant potentiellement l’effort de la cache pour récupérer les éléments à proximité dans la mémoire.

Et tout ce qu'il faut pour ruiner la performance est d'aller 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 spectaculaire (plusieurs ordres de grandeur de la vitesse) dans les systèmes avec de petites caches et/ou avec de grandes baies (par exemple, plus de 10 mégapixels d’images 24 bpp sur les machines actuelles); Pour cette raison, si vous devez effectuer de nombreuses numérisations verticales, il est souvent préférable de faire pivoter l’image de 90 degrés d’abord, puis d’effectuer les diverses analyses plus tard, en limitant le code anti-cache à la rotation.

136
Matteo Italia

L’optimisation de l’utilisation du cache dépend essentiellement de deux facteurs.

Lieu 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 cependant deux dimensions: l’espace et le temps.

  • Spatial

La dimension spatiale se résume également à deux choses: premièrement, nous voulons regrouper nos informations de manière dense, afin que davantage 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é informatique pour justifier des 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 regroupées. Un cache typique fonctionne en "lignes", ce qui signifie que lorsque vous accédez à certaines informations, d'autres informations situées à 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 à proximité de celui-ci. Pour tirer parti de cela, vous souhaitez généralement que les données soient organisées de manière à maximiser la probabilité que vous utilisiez également les autres données chargées au même moment.

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 attendiez. Une fois que vous avez chargé un élément d'une ligne de cache, l'utilisation du reste des données de cette ligne de cache est presque gratuite. Une recherche binaire devient sensiblement plus rapide uniquement 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 des 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 étiqueté cela en C++, je citerai un exemple classique de conception peu compatible avec le cache: std::valarray. valarray surcharge la plupart des opérateurs arithmétiques, donc je peux (par exemple) dire a = b + c + d; (où a, b, c et d sont tous valarrays) pour ajouter des éléments à ces éléments.

Le problème, c’est qu’il passe par une paire d’entrées, affiche les résultats de manière temporaire, passe à travers une autre paire d’entrées, etc. Avec beaucoup de données, le résultat d'un calcul peut disparaître de la mémoire cache avant d'être utilisé dans le calcul suivant. Nous finissons donc 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érerions en général lire chaque a[n], b[n], c[n] et d[n], faire le calcul, écrire le résultat, incrémenter n et répéter jusqu'à ce que nous ayons terminé.2

Partage de ligne

Le deuxième facteur important est d'éviter le partage de ligne. Pour comprendre cela, nous devons probablement revenir en arrière et regarder un peu comment les caches sont organisés. La forme la plus simple de cache est mappée directement. Cela signifie qu'une 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 laisser de la place à 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 appelés "set associative". Par exemple, dans un cache associatif à 4 voies, tout élément de la mémoire principale peut être stocké à n'importe lequel 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 éléments, le vide dans 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 mappant au même emplacement de cache peuvent conduire à un comportement incorrect. Un cache associatif à un ensemble N-way augmente le nombre de 2 à N + 1. L'organisation d'un cache en plusieurs "manières" nécessite des circuits supplémentaires et s'exécute généralement plus lentement. Ainsi, par exemple, un cache associatif à 8192 voies est rarement une bonne solution.

En fin de compte, ce facteur est plus difficile à contrôler dans le code portable. Votre contrôle sur l'emplacement de vos données est généralement assez limité. Pire encore, le mappage exact d'adresse en cache varie entre des processeurs par ailleurs similaires. Dans certains cas, cependant, il peut être intéressant d’allouer un tampon important, puis de n’utiliser que certaines parties de ce que vous avez alloué pour éviter que les données ne partagent les mêmes lignes de cache (même si vous devrez probablement détecter le processeur agissez en conséquence pour le faire).

  • Faux partage

Il existe un autre élément connexe appelé "faux partage". Cela se produit dans un système multiprocesseur ou multicœur, où deux processeurs/cœurs (ou plus) ont des données séparées, 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 conduire à un ralentissement massif car les données doivent être constamment basculées entre les processeurs. Cela ne peut pas être facilement résolu en organisant la mémoire cache de manière plus ou moins similaire. Le principal moyen de l’empêcher est de s’assurer que deux threads ne modifient que rarement (de préférence jamais) des 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++ peuvent se demander si cela est ouvert à l'optimisation via quelque chose comme des modèles d'expression. Je suis à peu près 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 pas au courant que qui que ce soit l'ait fait, cependant, et étant donné que valarray est peu utilisé, je serais au moins un peu surpris de voir quelqu'un le faire non plus.

  2. Au cas où quelqu'un se demanderait comment valarray (conçu spécifiquement pour la performance) pourrait être si mauvais, c'est une chose: il a été conçu pour des machines comme les anciens Crays, qui utilisaient une mémoire principale rapide et aucun cache. Pour eux, c'était vraiment un design presque idéal.

  3. Oui, je simplifie les choses: la plupart des caches ne mesurent pas précisément l'élément le moins récemment utilisé, mais ils utilisent une méthode heuristique censée être proche de celle-ci sans devoir conserver un horodatage complet pour chaque accès.

84
Jerry Coffin

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

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

32
arul

Pour ne rien dire: l'exemple classique du code respectueux du cache par rapport au cache-amical est le "blocage en cache" de la multiplication de matrice.

La matrice naïve se multiplie 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 N est grand, par ex. Si N * sizeof(elemType) est supérieur à la taille du cache, 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 travaillons avec des valeurs flottantes de 32 bits (4 octets), il y a 16 éléments par ligne de cache. Et le nombre de manquements de cache via cette simple transformation est réduit d'environ 16 fois.

Les transformations fantaisistes fonctionnent sur des mosaïques 2D, optimisées pour plusieurs caches (L1, L2, TLB), etc.

Quelques résultats de googler "cache blocking":

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 optimisé de blocage de cache.

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

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

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

22
Krazy Glew

Les processeurs actuels fonctionnent avec de nombreux niveaux de zones de mémoire en cascade. Donc, 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 plus lent (et plus grand) que le suivant, jusqu'à ce que vous obteniez de la mémoire système qui ne se trouve pas sur le CPU et est beaucoup plus lente à accéder.

Logiquement, dans le jeu d'instructions de la CPU, vous vous référez simplement aux adresses de mémoire dans un espace d'adressage virtuel géant. Lorsque vous accédez à une seule adresse mémoire, la CPU va la chercher. autrefois, il ne récupérait que cette adresse unique. Mais aujourd'hui, le processeur va récupérer une quantité 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 fort probable que vous demanderez une adresse à proximité très bientôt. Par exemple, si vous copiez un tampon, vous lirez et écrivez à partir d’adresses consécutives, l’une après l’autre.

Ainsi, 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 le trouve pas, il s’agit alors d’un manque de cache et il doit passer au niveau suivant. cache pour le trouver, jusqu’à ce qu’il finisse par sortir dans la mémoire principale.

Le code convivial du cache tente de garder les accès rapprochés dans la mémoire afin de minimiser les erreurs de cache.

Par exemple, imaginons que vous vouliez copier un tableau 2 dimensions géant. Il est organisé avec une ligne de portée consécutive en mémoire et une ligne suit la suivante.

Si vous copiez les éléments une ligne à la fois, de gauche à droite, le cache est convivial. Si vous décidiez de copier le tableau une colonne à la fois, vous copiez exactement la même quantité de mémoire, mais le cache serait peu convivial.

13
Rafael Baptista

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

Généralement, plus le code est dense, moins de lignes de cache seront nécessaires pour le stocker. Cela se traduit par davantage de lignes de cache disponibles pour les données.

Le code ne doit pas appeler des fonctions partout car elles nécessiteront généralement une ou plusieurs lignes de cache, ce qui réduira le nombre de lignes de cache pour les données.

Une fonction doit commencer à une adresse conviviale d’alignement de ligne de cache. Bien qu'il existe (gcc) des commutateurs de compilation pour cela, sachez que si les fonctions sont très courtes, il pourrait être inutile que chacune d'elles occupe une ligne de cache complète. Par exemple, si trois des fonctions les plus souvent utilisées tiennent dans une ligne de cache de 64 octets, cela entraîne moins de gaspillage que si chacune d'entre elles possède sa propre ligne et produit deux lignes de cache moins disponibles pour un autre usage. Une valeur d'alignement typique pourrait être 32 ou 16.

Passez donc 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é.

4
Olof Forshell

Comme @Marc Claesen a mentionné que l'un des moyens d'écrire du code convivial pour 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 convivial pour le cache est la suivante: changer la façon dont nos données sont stockées; écrivez ensuite un nouveau code pour accéder aux données stockées dans cette nouvelle structure.

Cela est logique dans le cas de la manière dont les systèmes de base de données linéarisent les nuplets d’une table et les stockent. Il existe deux méthodes de base pour stocker les n-uplets d’une table, à savoir le stockage de lignes et le stockage de colonnes. Dans le magasin en ligne, comme son nom l'indique, les n-uplets sont stockés par rangée. Supposons qu'une table nommée Product en cours de stockage ait 3 attributs, à savoir int32_t key, char name[56] et int32_t price, de sorte que la taille totale d'un tuple est de 64 octets.

Nous pouvons simuler une exécution très basique d'une requête de stockage de lignes dans la mémoire principale en créant un tableau de structures Product de taille N, N étant le nombre de lignes de la table. Cette disposition de la mémoire est également appelée tableau de structures. Donc, la structure pour Product 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 la même manière, nous pouvons simuler une exécution de requête de magasin 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 table Product. Cette structure 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 de lignes) et les 3 tableaux distincts (disposition de colonnes), nous avons un magasin de lignes et un magasin de colonnes sur notre table Product présente dans notre mémoire.

Nous passons maintenant à la partie code conviviale du 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 en ligne, 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 n'utilisons que l'accès à la colonne price.

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

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 la ligne de cache, donc pour chaque Tuple a cache miss se produit dans le cas d'une mise en page.

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 placées dans le cache, ainsi, pour chaque seizième Tuple, un cache mademoiselle en cas de disposition de colonne.

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

Ainsi, dans les systèmes de base de données, si la charge de travail de la requête est connue à l'avance, nous pouvons stocker nos données dans des présentations qui conviendront aux requêtes de la charge de travail et accéder aux données à partir de ces présentations. Dans le cas de l'exemple ci-dessus, nous avons créé une disposition de colonne et modifié notre code pour calculer la somme afin qu'elle devienne conviviale pour le cache.

2
user2603796

Sachez que les caches ne mettent pas simplement en cache la mémoire continue. Ils ont plusieurs lignes (au moins 4), de sorte que la mémoire discontinue et qui se chevauchent peut souvent être stockée de manière tout aussi efficace.

Ce qui manque dans tous les exemples ci-dessus, ce sont les points de repère mesurés. Il y a beaucoup de mythes sur la performance. Si vous ne le mesurez pas, vous ne le savez pas. Ne compliquez pas votre code sauf si vous avez une amélioration mesurée.

0
Tuntable