Edit : À des fins de référence (si quelqu'un tombe sur cette question), Igor Ostrovsky a écrit un excellent article sur les échecs de cache. Il aborde plusieurs problèmes différents et montre des exemples de numéros. Fin de l'édition
J'ai fait quelques tests <long story goes here>
et je me demande si une différence de performances est due à des échecs de cache mémoire. Le code suivant illustre le problème et le résume à la partie de synchronisation critique. Le code suivant a quelques boucles qui visitent la mémoire dans un ordre aléatoire, puis dans un ordre d'adresse croissant.
Je l'ai exécuté sur une machine XP (compilée avec VS2005: cl/O2) et sur une boîte Linux (gcc –Os). Les deux ont produit des temps similaires. Ces temps sont en millisecondes. Je crois que tous les boucles sont en cours d'exécution et ne sont pas optimisées (sinon, elles s'exécuteraient "instantanément").
*** Test de 20000 nœuds Temps total commandé: 888.822899 Temps aléatoire total: 2155.846268
Ces chiffres ont-ils un sens? La différence est-elle principalement due à des échecs de cache L1 ou quelque chose d'autre se passe-t-il également? Il y a 20 000 ^ 2 accès à la mémoire et si chacun était un échec de cache, cela représente environ 3,2 nanosecondes par échec. La machine XP (P4) sur laquelle j'ai testé est à 3,2 GHz et je soupçonne (mais je ne sais pas) d'avoir un cache L1 de 32 Ko et 512 Ko L2. Avec 20 000 entrées (80 Ko), je suppose qu'il y a n'est pas un nombre significatif de L2 manqués. Ce serait donc (3.2*10^9 cycles/second) * 3.2*10^-9 seconds/miss) = 10.1 cycles/miss
. Cela me semble élevé. Peut-être que ce n'est pas le cas, ou peut-être que mes calculs sont mauvais. J'ai essayé de mesurer les erreurs de cache avec VTune, mais j'ai obtenu un BSOD. Et maintenant, je ne parviens plus à me connecter au serveur de licences (grrrr).
typedef struct stItem
{
long lData;
//char acPad[20];
} LIST_NODE;
#if defined( WIN32 )
void StartTimer( LONGLONG *pt1 )
{
QueryPerformanceCounter( (LARGE_INTEGER*)pt1 );
}
void StopTimer( LONGLONG t1, double *pdMS )
{
LONGLONG t2, llFreq;
QueryPerformanceCounter( (LARGE_INTEGER*)&t2 );
QueryPerformanceFrequency( (LARGE_INTEGER*)&llFreq );
*pdMS = ((double)( t2 - t1 ) / (double)llFreq) * 1000.0;
}
#else
// doesn't need 64-bit integer in this case
void StartTimer( LONGLONG *pt1 )
{
// Just use clock(), this test doesn't need higher resolution
*pt1 = clock();
}
void StopTimer( LONGLONG t1, double *pdMS )
{
LONGLONG t2 = clock();
*pdMS = (double)( t2 - t1 ) / ( CLOCKS_PER_SEC / 1000 );
}
#endif
long longrand()
{
#if defined( WIN32 )
// Stupid cheesy way to make sure it is not just a 16-bit Rand value
return ( Rand() << 16 ) | Rand();
#else
return Rand();
#endif
}
// get random value in the given range
int randint( int m, int n )
{
int ret = longrand() % ( n - m + 1 );
return ret + m;
}
// I think I got this out of Programming Pearls (Bentley).
void ShuffleArray
(
long *plShuffle, // (O) return array of "randomly" ordered integers
long lNumItems // (I) length of array
)
{
long i;
long j;
long t;
for ( i = 0; i < lNumItems; i++ )
plShuffle[i] = i;
for ( i = 0; i < lNumItems; i++ )
{
j = randint( i, lNumItems - 1 );
t = plShuffle[i];
plShuffle[i] = plShuffle[j];
plShuffle[j] = t;
}
}
int main( int argc, char* argv[] )
{
long *plDataValues;
LIST_NODE *pstNodes;
long lNumItems = 20000;
long i, j;
LONGLONG t1; // for timing
double dms;
if ( argc > 1 && atoi(argv[1]) > 0 )
lNumItems = atoi( argv[1] );
printf( "\n\n*** Testing %u nodes\n", lNumItems );
srand( (unsigned int)time( 0 ));
// allocate the nodes as one single chunk of memory
pstNodes = (LIST_NODE*)malloc( lNumItems * sizeof( LIST_NODE ));
assert( pstNodes != NULL );
// Create an array that gives the access order for the nodes
plDataValues = (long*)malloc( lNumItems * sizeof( long ));
assert( plDataValues != NULL );
// Access the data in order
for ( i = 0; i < lNumItems; i++ )
plDataValues[i] = i;
StartTimer( &t1 );
// Loop through and access the memory a bunch of times
for ( j = 0; j < lNumItems; j++ )
{
for ( i = 0; i < lNumItems; i++ )
{
pstNodes[plDataValues[i]].lData = i * j;
}
}
StopTimer( t1, &dms );
printf( "Total Ordered Time: %f\n", dms );
// now access the array positions in a "random" order
ShuffleArray( plDataValues, lNumItems );
StartTimer( &t1 );
for ( j = 0; j < lNumItems; j++ )
{
for ( i = 0; i < lNumItems; i++ )
{
pstNodes[plDataValues[i]].lData = i * j;
}
}
StopTimer( t1, &dms );
printf( "Total Random Time: %f\n", dms );
}
Bien que je ne puisse pas vous dire si les chiffres ont un sens ou non (je ne connais pas bien les latences du cache, mais pour mémoire, le cache L1 à 10 cycles manque des sons à peu près corrects), je peux vous offrir Cachegrind comme un outil pour vous aider à voir réellement les différences de performances de cache entre vos 2 tests.
Cachegrind est un outil Valgrind (le framework qui alimente le toujours beau memcheck) qui profile les occurrences/échecs de cache et de branche. Cela vous donnera une idée du nombre de hits/miss cache que vous obtenez réellement dans votre programme.
Voici une tentative de donner un aperçu du coût relatif des erreurs de cache par analogie avec la cuisson des cookies aux pépites de chocolat ...
Vos mains sont vos registres. Il vous faut 1 seconde pour déposer des pépites de chocolat dans la pâte.
Le comptoir de la cuisine est votre cache L1, douze fois plus lent que les registres. Il faut 12 x 1 = 12 secondes pour se rendre au comptoir, ramasser le sac de noix, et en vider dans votre main.
Le réfrigérateur est votre cache L2, quatre fois plus lent que L1. Il faut 4 x 12 = 48 secondes pour marcher jusqu'au réfrigérateur, l'ouvrir, bouger en dernier les restes de la nuit à l'écart, sortez un carton d'œufs, ouvrez le carton, mettez 3 œufs sur le comptoir et remettez le carton au réfrigérateur.
L'armoire est votre cache L3, trois fois plus lente que L2. Il faut 3 x 48 = 2 minutes et 24 secondes pour faire trois pas vers l'armoire, penchez-vous, ouvrez la porte, fouillez pour trouver le moule à pâtisserie, extrayez-le du placard, ouvrez-le, creusez pour trouver la levure chimique, posez-la sur le comptoir et balayez le désordre que vous avez renversé sur le sol.
Et la mémoire principale? C'est le magasin du coin, 5 fois plus lent que L3. Il faut 5 x 2:24 = 12 minutes pour trouver votre portefeuille, mettre vos chaussures et votre veste, vous précipiter dans la rue, prendre un litre de lait, rentrez à la maison, enlevez vos chaussures et votre veste et retournez à la cuisine.
Notez que tous ces accès sont d'une complexité constante - O(1) - mais les différences entre eux peuvent avoir un un impact énorme sur les performances. L'optimisation purement pour la complexité du big-O revient à décider d'ajouter des pépites de chocolat à la pâte 1 à la fois ou 10 à la fois, mais en oubliant de les mettre sur votre liste d'épicerie.
Morale de l'histoire: Organisez vos accès à la mémoire afin que le CPU doive aller faire les courses aussi rarement que possible.
Les chiffres ont été tirés du blog CPU Cache Flushing Fallacy , qui indique que pour un processeur Intel particulier de l'ère 2012, ce qui suit est vrai:
Gallery of Processor Cache Effects fait également une bonne lecture sur ce sujet.
3.2ns pour un échec de cache L1 est tout à fait plausible. À titre de comparaison, sur un processeur PowerPC multicœur moderne particulier, un échec L1 représente environ 4 cycles - un peu plus long pour certains cœurs que pour d'autres, en fonction de leur éloignement du cache L2 (oui vraiment) . Un échec L2 est au moins 6 cycles.
Le cache est tout en performance; Les processeurs sont tellement plus rapides que la mémoire maintenant que vous optimisez vraiment presque pour le bus mémoire au lieu du noyau.
Eh bien oui, il semble que ce sera principalement des ratés de cache L1.
10 cycles pour un échec de cache L1 semblent raisonnables, probablement un peu faibles.
Une lecture de RAM va prendre de l'ordre de 100s ou peut-être même 1000s (Je suis trop fatigué pour essayer de faire le calcul en ce moment;)) de cycles donc c'est toujours une énorme victoire dessus.
Si vous prévoyez d'utiliser cachegrind, veuillez noter qu'il s'agit uniquement d'un simulateur de succès/échec de cache. Ce ne sera pas toujours exact. Par exemple: si vous accédez à un emplacement mémoire, disons 0x1234 dans une boucle 1000 fois, cachegrind vous montrera toujours qu'il n'y a eu qu'un seul échec de cache (le premier accès) même si vous avez quelque chose comme:
clflush 0x1234 dans votre boucle.
Sur x86, cela entraînera tous les 1000 échecs de cache.
Quelques chiffres pour un P4 3,4 GHz d'une course Lavalys Everest:
Plus ici: http://www.freeweb.hu/instlatx64/G GenuineIntel0000F25_P4_Gallatin_MemLatX86.txt
(pour les latences regardez en bas de page)
Il est difficile de dire quoi que ce soit avec certitude sans beaucoup plus de tests, mais d'après mon expérience, cette échelle de différence peut certainement être attribuée au cache CPU L1 et/ou L2, en particulier dans un scénario avec accès aléatoire. Vous pourriez probablement aggraver la situation en vous assurant que chaque accès est au moins à une distance minimale du dernier.