Comment accélérer votre code à l'aide de caches CPU

Le cache du processeur réduit la latence de la mémoire lorsque les données sont accessibles à partir de la mémoire système principale. Les développeurs peuvent et doivent tirer parti du cache du processeur pour améliorer les performances des applications.

Fonctionnement des caches CPU

Les processeurs modernes ont généralement trois niveaux de cache, appelés L1, L2 et L3, qui reflètent l'ordre dans lequel le processeur les vérifie. Les processeurs ont souvent un cache de données, un cache d'instructions (pour le code) et un cache unifié (pour tout). L'accès à ces caches est beaucoup plus rapide que l'accès à la RAM: en général, le cache L1 est environ 100 fois plus rapide que la RAM pour l'accès aux données, et le cache L2 est 25 fois plus rapide que la RAM pour l'accès aux données.

Lorsque votre logiciel s'exécute et a besoin d'extraire des données ou des instructions, les caches du processeur sont d'abord vérifiés, puis la RAM système plus lente et enfin les lecteurs de disque beaucoup plus lents. C'est pourquoi vous souhaitez optimiser votre code pour rechercher d'abord ce qui sera probablement nécessaire dans le cache du processeur.

Votre code ne peut pas spécifier où résident les instructions de données et les données - le matériel informatique le fait - vous ne pouvez donc pas forcer certains éléments dans le cache du processeur. Mais vous pouvez optimiser votre code pour récupérer la taille du cache L1, L2 ou L3 de votre système à l'aide de Windows Management Instrumentation (WMI) pour optimiser le moment où votre application accède au cache et donc ses performances.

Les CPU n'accèdent jamais à l'octet du cache par octet. Au lieu de cela, ils lisent la mémoire dans les lignes de cache, qui sont des morceaux de mémoire généralement de 32, 64 ou 128 octets.

La liste de codes suivante illustre comment vous pouvez récupérer la taille du cache du processeur L2 ou L3 dans votre système:

public static uint GetCPUCacheSize (string cacheType) {try {using (ManagementObject managementObject = new ManagementObject ("Win32_Processor.DeviceID = 'CPU0'")) {return (uint) (managementObject [cacheType]); }} catch {return 0; }} static void Main (string [] args) {uint L2CacheSize = GetCPUCacheSize ("L2CacheSize"); uint L3CacheSize = GetCPUCacheSize ("L3CacheSize"); Console.WriteLine ("L2CacheSize:" + L2CacheSize.ToString ()); Console.WriteLine ("L3CacheSize:" + L3CacheSize.ToString ()); Console.Read (); }

Microsoft a une documentation supplémentaire sur la classe WMI Win32_Processor.

Programmation pour la performance: exemple de code

Lorsque vous avez des objets dans la pile, il n'y a pas de surcharge de garbage collection. Si vous utilisez des objets basés sur le tas, il y a toujours un coût impliqué avec le garbage collection générationnel pour la collecte ou le déplacement d'objets dans le tas ou le compactage de la mémoire du tas. Un bon moyen d'éviter la surcharge du garbage collection est d'utiliser des structures au lieu de classes.

Les caches fonctionnent mieux si vous utilisez une structure de données séquentielle, telle qu'un tableau. L'ordre séquentiel permet au processeur de lire à l'avance et également de lire à l'avance de manière spéculative en prévision de ce qui sera probablement demandé ensuite. Ainsi, un algorithme qui accède séquentiellement à la mémoire est toujours rapide.

Si vous accédez à la mémoire dans un ordre aléatoire, le processeur a besoin de nouvelles lignes de cache à chaque fois que vous accédez à la mémoire. Cela réduit les performances.

L'extrait de code suivant implémente un programme simple qui illustre les avantages de l'utilisation d'une structure sur une classe:

 struct RectangleStruct {public int width; public int height; } classe RectangleClass {public int width; public int height; }

Le code suivant décrit les performances de l'utilisation d'un tableau de structures par rapport à un tableau de classes. À des fins d'illustration, j'ai utilisé un million d'objets pour les deux, mais vous n'avez généralement pas besoin de beaucoup d'objets dans votre application.

static void Main (string [] args) {const int size = 1000000; var structs = new RectangleStruct [taille]; var classes = new RectangleClass [taille]; var sw = nouveau chronomètre (); sw.Start (); for (var i = 0; i <size; ++ i) {structs [i] = new RectangleStruct (); structs [i] .breadth = 0 structs [i] .height = 0; } var structTime = sw.ElapsedMilliseconds; sw.Reset (); sw.Start (); for (var i = 0; i <size; ++ i) {classes [i] = new RectangleClass (); classes [i] .largeur = 0; classes [i] .height = 0; } var classTime = sw.ElapsedMilliseconds; sw.Stop (); Console.WriteLine ("Temps pris par un tableau de classes:" + classTime.ToString () + "millisecondes."); Console.WriteLine ("Temps pris par le tableau de structures:" + structTime.ToString () + "millisecondes."); Console.Read (); }

Le programme est simple: il crée 1 million d'objets de structures et les stocke dans un tableau. Il crée également 1 million d'objets d'une classe et les stocke dans un autre tableau. La largeur et la hauteur des propriétés reçoivent une valeur de zéro sur chaque instance.

Comme vous pouvez le voir, l'utilisation de structures compatibles avec le cache offre un énorme gain de performances.

Règles générales pour une meilleure utilisation du cache du processeur

Alors, comment écrire du code qui utilise le mieux le cache du processeur? Malheureusement, il n'y a pas de formule magique. Mais il y a quelques règles de base:

  • Évitez d'utiliser des algorithmes et des structures de données qui présentent des modèles d'accès mémoire irréguliers; utilisez plutôt des structures de données linéaires.
  • Utilisez des types de données plus petits et organisez les données de sorte qu'il n'y ait pas de trous d'alignement.
  • Tenez compte des modèles d'accès et tirez parti des structures de données linéaires.
  • Améliorez la localité spatiale, qui utilise chaque ligne de cache au maximum une fois qu'elle a été mappée à un cache.