web-dev-qa-db-fra.com

Comment écrire le code qui utilise le mieux le cache du processeur pour améliorer les performances?

Cela peut sembler une question subjective, mais ce que je recherche, ce sont des cas spécifiques, que vous auriez pu rencontrer.

  1. Comment rendre le code, cache efficace/amical pour le cache (plus de cache cache, moins de cache manquants que possible)? Dans les deux perspectives, cache de données et cache de programme (cache d’instructions), c’est-à-dire quels éléments de son code, liés aux structures de données et aux constructions de code, doivent être pris en charge pour le rendre efficace.

  2. Existe-t-il des structures de données particulières à éviter/à utiliser, ou existe-t-il un moyen particulier d'accéder aux membres de cette structure, etc., pour rendre le cache de code efficace?.

  3. Existe-t-il des constructions de programme (si, pour, switch, break, goto, ...), un flux de code (pour à l'intérieur d'un if, si à l'intérieur d'un, etc.) à suivre/éviter en cette matière?

Je suis impatient d'entendre des expériences individuelles liées à la création d'un code cache efficace en général. Cela peut être n'importe quel langage de programmation (C, C++, Assembly, ...), n'importe quelle cible matérielle (ARM, Intel, PowerPC, ...), n'importe quel OS (Windows, Linux, Symbian, ...), etc. .

La variété aidera à mieux la comprendre en profondeur.

152
goldenmean

Le cache est là pour réduire le nombre de fois où le CPU se bloque dans l’attente d’une requête de mémoire (en évitant le temps de latence de la mémoire ), et deuxième effet, éventuellement pour réduire la quantité globale de données à transférer (en préservant la mémoire la bande passante ).

Les techniques permettant d'éviter de souffrir de la latence d'extraction de mémoire sont généralement la première chose à prendre en compte, ce qui peut parfois s'avérer très utile. La bande passante mémoire limitée est également un facteur limitant, en particulier pour les applications multicœurs et multithreads où de nombreux threads souhaitent utiliser le bus mémoire. Un ensemble de techniques différent aide à résoudre ce dernier problème.

Améliorer localité spatiale signifie que vous vous assurez que chaque ligne de cache est utilisée intégralement une fois celle-ci mappée sur un cache. Lorsque nous avons examiné divers tests de performances standard, nous avons constaté qu’une fraction surprenante d’entre eux n’utilisait pas 100% des lignes de cache récupérées avant que celles-ci ne soient expulsées.

L'amélioration de l'utilisation de la ligne de cache aide à trois égards:

  • Il a tendance à contenir davantage de données utiles dans le cache, augmentant essentiellement la taille effective du cache.
  • Cela a tendance à contenir des données plus utiles dans la même ligne de cache, ce qui augmente la probabilité que les données demandées se trouvent dans le cache.
  • Cela réduit les besoins en bande passante mémoire, car il y aura moins d'extraits.

Les techniques courantes sont:

  • Utiliser des types de données plus petits
  • Organisez vos données pour éviter les trous d'alignement (le tri de vos membres de structure en réduisant leur taille est un moyen)
  • Méfiez-vous de l'allocateur de mémoire dynamique standard, qui peut introduire des trous et répartir vos données dans la mémoire au fur et à mesure de son échauffement.
  • Assurez-vous que toutes les données adjacentes sont réellement utilisées dans les boucles chaudes. Sinon, envisagez de diviser les structures de données en composants chauds et froids, de sorte que les boucles chaudes utilisent des données chaudes.
  • évitez les algorithmes et les infrastructures de données qui présentent des modèles d'accès irréguliers et favorisez les infrastructures de données linéaires.

Nous devrions également noter qu'il existe d'autres moyens de masquer le temps de latence de la mémoire que l'utilisation de caches.

Les processeurs modernes ont souvent un ou plusieurs pré-lecteurs de matériel . Ils s'entraînent sur les ratés dans une cache et essaient de détecter les régularités. Par exemple, après quelques manques dans les lignes de cache suivantes, le préfet hw commencera à extraire les lignes de cache dans le cache, anticipant les besoins de l'application. Si vous avez un modèle d’accès normal, le préfetcher matériel fait généralement un très bon travail. Et si votre programme n'affiche pas les schémas d'accès habituels, vous pouvez améliorer les choses en ajoutant vous-même des instructions de pré-extraction .

En regroupant les instructions de manière à ce que celles qui manquent toujours dans le cache soient proches les unes des autres, la CPU peut parfois chevaucher ces opérations, de sorte que l'application ne subisse qu'un seul impact de latence ( Parallélisme au niveau de la mémoire ).

Pour réduire la pression globale du bus mémoire, vous devez commencer à adresser ce qui s'appelle localité temporelle. Cela signifie que vous devez réutiliser les données alors qu'elles n'ont toujours pas été expulsées du cache.

Fusion de boucles qui touchent les mêmes données ( fusion de boucles ) et utilisation de techniques de réécriture connues sous le nom de mosaïque ou le blocage s'efforce d'éviter les extractions de mémoire supplémentaires.

Bien qu'il existe des règles empiriques pour cet exercice de réécriture, vous devez généralement examiner attentivement les dépendances de données acheminées en boucle, afin de ne pas affecter la sémantique du programme.

Ces choses sont ce qui rapporte vraiment dans le monde multicœur, où vous ne verrez généralement pas beaucoup d’améliorations du débit après l’ajout du deuxième thread.

116
Mats N

Je ne peux pas croire qu'il n'y a pas plus de réponses à cela. Quoi qu’il en soit, un exemple classique consiste à itérer un tableau multidimensionnel "à l’intérieur":

pseudocode
for (i = 0 to size)
  for (j = 0 to size)
    do something with ary[j][i]

La raison en est que la mémoire cache est inefficace, car les processeurs modernes chargent la ligne de mémoire cache avec des adresses mémoire "proches" de la mémoire principale lorsque vous accédez à une adresse mémoire unique. Nous parcourons les lignes "j" (extérieures) du tableau de la boucle intérieure. Ainsi, pour chaque trajet dans la boucle intérieure, la ligne de cache entraînera le vidage et le chargement d'une ligne d'adresses proches du [ j] [i] entrée. Si cela est remplacé par l'équivalent:

for (i = 0 to size)
  for (j = 0 to size)
    do something with ary[i][j]

Cela ira beaucoup plus vite.

52
1800 INFORMATION

Je recommande de lire l'article en 9 parties Ce que tout programmeur devrait savoir sur la mémoire de Ulrich Drepper si vous êtes intéressé par la manière dont la mémoire et les logiciels interagissent. Il est également disponible au format PDF de 104 pages .

Les sections particulièrement pertinentes pour cette question pourraient être partie 2 (caches de processeur) et partie 5 (ce que les programmeurs peuvent faire - optimisation du cache).

43
Tomi Kyöstilä

Les règles de base sont en réalité assez simples. La difficulté réside dans la manière dont elles s’appliquent à votre code.

La cache fonctionne sur deux principes: la localité temporelle et la localité spatiale. Le premier est l’idée que si vous avez récemment utilisé un certain bloc de données, vous en aurez probablement besoin bientôt. Ce dernier signifie que si vous avez récemment utilisé les données à l'adresse X, vous aurez probablement besoin de l'adresse X + 1.

Le cache tente de résoudre ce problème en se souvenant des derniers blocs de données utilisés. Il fonctionne avec des lignes de cache, généralement de 128 octets ou plus, de sorte que même si vous n'avez besoin que d'un seul octet, toute la ligne de cache qui la contient est extraite dans le cache. Donc, si vous avez besoin de l'octet suivant par la suite, il sera déjà dans le cache.

Et cela signifie que vous voudrez toujours que votre propre code exploite autant que possible ces deux formes de localité. Ne sautez pas partout dans la mémoire. Faites le plus de travail possible sur un petit domaine, puis passez au suivant et effectuez le plus de travail possible.

Un exemple simple est la traversée de tableau 2D que la réponse de 1800 a montrée. Si vous le parcourez ligne par ligne, vous lisez la mémoire séquentiellement. Si vous le faites en colonne, vous lirez une entrée, vous passerez ensuite à un emplacement complètement différent (le début de la ligne suivante), lirez une entrée et sauterez à nouveau. Et lorsque vous revenez enfin à la première ligne, il ne sera plus dans la mémoire cache.

La même chose s'applique au code. Les sauts ou les branches se traduisent par une utilisation moins efficace du cache (car vous ne lisez pas les instructions de manière séquentielle, vous passez à une adresse différente). Bien sûr, les petites instructions if ne changeront probablement rien (vous ne sautez que quelques octets, vous vous retrouverez donc toujours dans la région mise en cache), mais les appels de fonction impliquent généralement que vous passez à une position complètement différente. adresse qui ne peut pas être mis en cache. À moins que cela ait été appelé récemment.

L'utilisation du cache d'instructions est cependant beaucoup moins problématique. Ce qui vous préoccupe généralement, c’est le cache de données.

Dans une structure ou une classe, tous les membres sont disposés de manière contiguë, ce qui est bien. Dans un tableau, toutes les entrées sont également disposées de manière contiguë. Dans les listes chaînées, chaque nœud est attribué à un emplacement complètement différent, ce qui est mauvais. Les pointeurs en général ont tendance à pointer vers des adresses non liées, ce qui entraînera probablement un manque de mémoire cache si vous le déréférenciez.

Et si vous souhaitez exploiter plusieurs cœurs, cela peut devenir très intéressant, car en général, un seul processeur peut avoir une adresse donnée dans son cache L1 à la fois. Ainsi, si les deux cœurs accèdent en permanence à la même adresse, le nombre d'erreurs dans la mémoire cache sera constant, car ils se disputent l'adresse.

43
jalf

Outre les modèles d'accès aux données, les données taille constituent un facteur majeur du code convivial pour le cache. Moins de données signifie que plus de données entrent dans le cache.

Ceci est principalement un facteur avec les structures de données alignées sur la mémoire. La sagesse "conventionnelle" stipule que les structures de données doivent être alignées sur les limites de Word, car la CPU ne peut accéder qu'à des mots entiers. Si un mot contient plusieurs valeurs, vous devez effectuer un travail supplémentaire (lecture-modification-écriture au lieu d'une simple écriture). . Mais les caches peuvent complètement invalider cet argument.

De la même manière, un tableau boolean Java) utilise un octet entier pour chaque valeur afin de permettre l’opération directe sur des valeurs individuelles. Vous pouvez réduire la taille des données d’un facteur 8 si vous utilisez des bits réels. l'accès aux valeurs individuelles devient alors beaucoup plus complexe, nécessitant des opérations de décalage de bits et de masque (la classe BitSet le fait pour vous). Toutefois, en raison des effets de cache, cette opération peut être considérablement plus rapide que d'utiliser un booléen [] Lorsque le tableau est grand. IIRC I a déjà réussi à accélérer un facteur 2 ou 3 de cette façon.

14
Michael Borgwardt

La structure de données la plus efficace pour un cache est un tableau. Les caches fonctionnent mieux si votre structure de données est disposée de manière séquentielle, les CPU lisant des lignes de cache entières (généralement 32 octets ou plus) en même temps dans la mémoire principale.

Tout algorithme qui accède à la mémoire dans un ordre aléatoire supprime les caches car il a toujours besoin de nouvelles lignes de cache pour accueillir la mémoire accédée de manière aléatoire. D'autre part, un algorithme, qui fonctionne séquentiellement dans un tableau, est préférable car:

  1. Il donne au processeur une chance de lire à l’avance, par exemple spéculativement mis plus de mémoire dans le cache, qui sera consulté plus tard. Cette lecture anticipée améliore considérablement les performances.

  2. L'exécution d'une boucle serrée sur un grand tableau permet également à la CPU de mettre en cache le code qui s'exécute dans la boucle et, dans la plupart des cas, d'exécuter un algorithme entièrement à partir de la mémoire cache sans avoir à bloquer l'accès à la mémoire externe.

9
grover

Un exemple que j'ai vu utilisé dans un moteur de jeu était de déplacer des données d'objets dans leurs propres tableaux. Un objet de jeu soumis à la physique peut également être associé à de nombreuses autres données. Mais pendant la boucle de mise à jour physique, tout ce qui importait au moteur était de disposer de données sur la position, la vitesse, la masse, la boîte englobante, etc. Ainsi, tout cela a été placé dans ses propres tableaux et optimisé autant que possible pour SSE.

Ainsi, au cours de la boucle de physique, les données de physique ont été traitées dans l'ordre du tableau en utilisant les mathématiques vectorielles. Les objets de jeu utilisaient leur identifiant d'objet comme index dans les divers tableaux. Ce n'était pas un pointeur, car les pointeurs pourraient être invalidés si les tableaux devaient être déplacés.

À bien des égards, cela violait les modèles de conception orientés objet, mais rendait le code beaucoup plus rapide en plaçant des données rapprochées qui devaient être exploitées dans les mêmes boucles.

Cet exemple est probablement obsolète car je pense que la plupart des jeux modernes utilisent un moteur physique prédéfini comme Havok.

8
Zan Lynx

Une remarque à "l'exemple classique" par l'utilisateur 1800 INFORMATION (trop long pour un commentaire)

Je voulais vérifier les différences de temps pour deux ordres d'itération ("outter" et "inner"), alors j'ai fait une expérience simple avec un grand tableau 2D:

measure::start();
for ( int y = 0; y < N; ++y )
for ( int x = 0; x < N; ++x )
    sum += A[ x + y*N ];
measure::stop();

et le second cas avec les boucles for échangées.

La version la plus lente ("x premier") était de 0,88 seconde et la plus rapide de 0,06 seconde. C'est le pouvoir de la mise en cache :)

J'ai utilisé gcc -O2 et toujours les boucles étaient pas optimisées. Le commentaire de Ricardo selon lequel "la plupart des compilateurs modernes peuvent le comprendre par eux-mêmes" ne tient pas

7
Jakub M.

Un seul article a abordé le sujet, mais un gros problème se pose lors du partage de données entre processus. Vous voulez éviter que plusieurs processus tentent de modifier simultanément la même ligne de cache. Un partage à effectuer ici est un "faux" partage, où deux structures de données adjacentes partagent une ligne de cache et les modifications apportées à l’une invalident la ligne de cache pour l’autre. Cela peut entraîner des lignes de cache inutilement entre les caches de processeur partageant les données sur un système multiprocesseur. Une façon de l'éviter est d'aligner et de compléter les structures de données pour les placer sur différentes lignes.

7
RussellH

Je peux répondre (2) en disant que dans le monde C++, les listes chaînées peuvent facilement tuer le cache du processeur. Les tableaux sont une meilleure solution lorsque cela est possible. Aucune expérience sur si la même chose s'applique à d'autres langues, mais il est facile d'imaginer que les mêmes problèmes se poseraient.

4
Andrew

Le cache est organisé en "lignes de cache" et la mémoire (réelle) est lue et écrite en morceaux de cette taille.

Les structures de données contenues dans une seule ligne de cache sont donc plus efficaces.

De même, les algorithmes qui accèdent à des blocs de mémoire contigus seront plus efficaces que ceux qui sautent dans la mémoire dans un ordre aléatoire.

Malheureusement, la taille de la ligne de cache varie énormément d'un processeur à l'autre. Il est donc impossible de garantir qu'une structure de données optimale sur un processeur sera efficace sur un autre.

4
Alnitak

Demander comment créer un code, Cache-Friendly Friendly et la plupart des autres questions, demande généralement comment optimiser un programme. En effet, le cache a un tel impact sur les performances que tout programme optimisé en est un. cache efficace.

Je suggère de lire à propos de l'optimisation, il y a quelques bonnes réponses sur ce site. En termes de livres, je recommande sur Systèmes informatiques: le point de vue d'un programmeur qui contient un texte précis sur l'utilisation correcte du cache.

(b.t.w - aussi mauvais qu'un cache-miss puisse être, il y a pire - si un programme est paging depuis le disque dur ...)

4
Liran Orevi

Il y a eu beaucoup de réponses sur des conseils généraux tels que la sélection de la structure de données, le modèle d'accès, etc. J'aimerais ici ajouter un autre modèle de conception de code appelé un pipeline logiciel qui utilise la gestion active du cache.

L'idée est empruntée à d'autres techniques de pipeline, par exemple. Traitement des instructions de la CPU.

Ce type de modèle s’applique mieux aux procédures qui

  1. pourrait être décomposé en plusieurs sous-étapes raisonnables, S [1], S [2], S [3], ..., dont le temps d'exécution est à peu près comparable au temps d'accès RAM (~ 60-70ns).
  2. prend un lot d’intrants et effectue plusieurs étapes susmentionnées pour obtenir un résultat.

Prenons un cas simple où il n'y a qu'une seule sous-procédure. Normalement, le code voudrait:

def proc(input):
    return sub-step(input))

Pour améliorer les performances, vous souhaiterez peut-être transmettre plusieurs entrées à la fonction dans un lot afin d'amortir la surcharge des appels de fonction et d'augmenter la localité du cache de code.

def batch_proc(inputs):
    results = []
    for i in inputs:
        // avoids code cache miss, but still suffer data(inputs) miss
        results.append(sub-step(i))
    return res

Toutefois, comme indiqué précédemment, si l'exécution de l'étape est à peu près identique au temps d'accès à RAM, vous pouvez améliorer le code pour obtenir un résultat similaire à celui-ci:

def batch_pipelined_proc(inputs):
    for i in range(0, len(inputs)-1):
        prefetch(inputs[i+1])
        # work on current item while [i+1] is flying back from RAM
        results.append(sub-step(inputs[i-1]))

    results.append(sub-step(inputs[-1]))

Le flux d'exécution ressemblerait à ceci:

  1. prefetch (1) demande à la CPU de pré-extraire l'entrée [1] en mémoire cache, l'instruction prefetch prenant P cycles elle-même et renvoyée, et en entrée d'arrière-plan [1] arriverait en mémoire cache après R. cycles.
  2. works_on (0) cold miss on 0 et fonctionne dessus, ce qui prend M
  3. prefetch (2) lance un autre téléchargement
  4. works_on (1) si P + R <= M, alors les entrées [1] devraient déjà être dans le cache avant cette étape, évitez ainsi les erreurs de cache de données
  5. works_on (2) ...

Plusieurs étapes pourraient être impliquées. Vous pouvez alors concevoir un pipeline en plusieurs étapes, à condition que le minutage des étapes et les latences d’accès en mémoire correspondent, vous risquez peu de manquer du cache code/données. Cependant, ce processus doit être optimisé avec de nombreuses expériences afin de déterminer le bon regroupement des étapes et le temps de prélecture. En raison des efforts requis, le traitement des flux de données/paquets à hautes performances est de plus en plus adopté. Vous trouverez un bon exemple de code de production dans la conception du pipeline DPDK QoS Enqueue: http://dpdk.org/doc/guides/prog_guide/qos_framework.html Chapitre 21.2.4.3. Pipeline Enqueue.

Plus d'informations peuvent être trouvées:

https://software.intel.com/en-us/articles/memory-management-for-optimal-performance-on-intel-xeon-phi-coprocessor-alignment-and

http://infolab.stanford.edu/~ullman/dragon/w06/lectures/cs243-lec13-wei.pdf

4
Wei Shen

Outre l'alignement de votre structure et de vos champs, si votre structure est allouée par tas, vous pouvez utiliser des allocateurs prenant en charge des allocations alignées. comme _aligned_malloc (sizeof (DATA), SYSTEM_CACHE_LINE_SIZE); sinon, vous pourriez avoir un faux partage aléatoire; N'oubliez pas que sous Windows, le segment de mémoire par défaut est aligné sur 16 octets.

1
aracntido

Ecrivez votre programme pour prendre une taille minimale. C’est pourquoi il n’est pas toujours judicieux d’utiliser les optimisations -O3 pour GCC. Cela prend une plus grande taille. Souvent, -Os est aussi bon que -O2. Tout dépend du processeur utilisé. YMMV.

Travaillez avec de petites quantités de données à la fois. C'est pourquoi des algorithmes de tri moins efficaces peuvent être exécutés plus rapidement que le tri rapide si le jeu de données est volumineux. Trouvez des moyens de diviser vos grands ensembles de données en plus petits. D'autres ont suggéré cela.

Afin de vous aider à mieux exploiter la localité temporelle/spatiale des instructions, vous souhaiterez peut-être étudier comment votre code est converti en Assembly. Par exemple:

for(i = 0; i < MAX; ++i)
for(i = MAX; i > 0; --i)

Les deux boucles produisent des codes différents même si elles analysent simplement un tableau. Dans tous les cas, votre question est très spécifique à l'architecture. Ainsi, votre seul moyen de contrôler étroitement l'utilisation du cache consiste à comprendre le fonctionnement du matériel et à en optimiser le code.

1
sybreon