Je constate des performances inattendues pour une boucle de magasin simple comportant deux magasins: l'un avec un pas en avant de 16 octets et l'autre toujours au même endroit.1, comme ça:
volatile uint32_t value;
void weirdo_cpp(size_t iters, uint32_t* output) {
uint32_t x = value;
uint32_t *rdx = output;
volatile uint32_t *rsi = output;
do {
*rdx = x;
*rsi = x;
rdx += 4; // 16 byte stride
} while (--iters > 0);
}
En Assemblée cette boucle probablement3 ressemble à:
weirdo_cpp:
...
align 16
.top:
mov [rdx], eax ; stride 16
mov [rsi], eax ; never changes
add rdx, 16
dec rdi
jne .top
ret
Lorsque la région de mémoire consultée est en L2, je pense que cela s'exécutera à moins de 3 cycles par itération. Le deuxième magasin continue de frapper le même emplacement et devrait ajouter environ un cycle. Le premier magasin implique l’introduction d’une ligne à partir de L2 et donc l’éviction d’une ligne une fois toutes les 4 itérations. Je ne sais pas comment vous évaluez le coût de la couche 2, mais même si vous estimez de manière prudente que la couche 1 ne peut effectuer que l'un des cycles suivants: (a) valider un magasin ou (b) recevoir une ligne de la part de L2 ou (c) expulser une ligne vers L2, vous obtiendrez quelque chose comme 1 + 0,25 + 0,25 = 1,5 cycle pour le flux de magasin stride-16.
En effet, si vous commentez un magasin, vous obtenez environ 1,25 cycle par itération pour le premier magasin uniquement et environ 1,01 cycle par itération pour le deuxième magasin. 2,5 cycles par itération semblent donc être une estimation prudente.
La performance réelle est cependant très étrange. Voici un exemple typique du test harnais:
Estimated CPU speed: 2.60 GHz
output size : 64 KiB
output alignment: 32
3.90 cycles/iter, 1.50 ns/iter, cpu before: 0, cpu after: 0
3.90 cycles/iter, 1.50 ns/iter, cpu before: 0, cpu after: 0
3.90 cycles/iter, 1.50 ns/iter, cpu before: 0, cpu after: 0
3.89 cycles/iter, 1.49 ns/iter, cpu before: 0, cpu after: 0
3.90 cycles/iter, 1.50 ns/iter, cpu before: 0, cpu after: 0
4.73 cycles/iter, 1.81 ns/iter, cpu before: 0, cpu after: 0
7.33 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.33 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.34 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.26 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.28 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.31 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.29 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.28 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.29 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.27 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.30 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.30 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.28 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.28 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
Deux choses sont bizarres ici.
Il y a d’abord les synchronisations bimodales: il y a un mode rapide et un mode lent. Nous commençons en mode lent en prenant environ 7,3 cycles par itération et, à un moment donné, une transition à environ 3,9 cycles par itération. Ce comportement est cohérent et reproductible et les deux minutages sont toujours assez cohérents regroupés autour des deux valeurs. La transition apparaît dans les deux sens de mode lent à mode rapide et vice-versa (et parfois de plusieurs transitions en une fois).
L'autre chose étrange est la très mauvaise performance. Même en mode rapide, à environ 3,9 cycles, les performances sont bien pires que les 1,0 + 1,3 = 2,3 cycles de la distribution la plus mauvaise que vous attendiez en additionnant chacun des cas avec un seul magasin (et en supposant que absolument zéro travaillé peut être superposé lorsque les deux magasins sont dans la boucle). En mode lent, les performances sont médiocres par rapport à ce que vous attendez des premiers principes: il faut 7,3 cycles pour effectuer 2 magasins, et si vous le définissez en termes de largeur de bande de magasin L2, cela correspond à peu près à 29 cycles par magasin N2 (puisque nous ne stockons qu'une seule ligne de cache toutes les 4 itérations).
Skylake est enregistré comme ayant un débit de 64 B/cycle entre L1 et L2, ce qui est chemin supérieur au débit observé ici (environ 2 octets/cycle en mode lent) .
Qu'est-ce qui explique le faible débit et les performances bimodales et puis-je l'éviter?
Je suis également curieux de savoir si cela se reproduit sur d'autres architectures et même sur d'autres box Skylake. N'hésitez pas à inclure des résultats locaux dans les commentaires.
Vous pouvez trouver le code test et le harnais sur github . Il existe une Makefile
pour les plates-formes Linux ou Unix, mais il devrait également être relativement facile à compiler sous Windows. Si vous voulez exécuter la variante asm
, vous aurez besoin de nasm
ou yasm
pour l'assembly.4 - Si vous ne l'avez pas, vous pouvez simplement essayer la version C++.
Voici quelques possibilités que j'ai considérées et en grande partie éliminées. La plupart des possibilités sont éliminées du simple fait que la transition de performance est vue de manière aléatoire au milieu de la boucle d'analyse comparative}, _ alors que de nombreuses choses n'ont simplement pas changé (par exemple, si cela était lié au tableau de sortie alignement, cela ne pourrait pas changer au milieu d’une exécution car le même tampon est utilisé tout le temps). Je parlerai de cela comme élimination par défaut ci-dessous (même pour les choses qui sont l'élimination par défaut, un autre argument doit souvent être avancé).
stress -vm 4
). De toute façon, le repère lui-même devrait être complètement central, puisqu'il correspond à la N2 et perf
confirme qu'il y a très peu de manquements de N2 par itération (environ 1 fois toutes les 300 à 400 itérations, probablement liées au code printf
.).intel_pstate
en mode performance
. Aucune variation de fréquence n’est observée pendant le test (la CPU reste essentiellement verrouillée à 2,59 GHz).perf
ne signale aucun comportement particulièrement étrange du TLB.J'ai utilisé toplev.py qui implémente la méthode d'analyse Top Down d'Intel et, sans surprise, il identifie le point de repère comme étant lié au magasin:
BE Backend_Bound: 82.11 % Slots [ 4.83%]
BE/Mem Backend_Bound.Memory_Bound: 59.64 % Slots [ 4.83%]
BE/Core Backend_Bound.Core_Bound: 22.47 % Slots [ 4.83%]
BE/Mem Backend_Bound.Memory_Bound.L1_Bound: 0.03 % Stalls [ 4.92%]
This metric estimates how often the CPU was stalled without
loads missing the L1 data cache...
Sampling events: mem_load_retired.l1_hit:pp mem_load_retired.fb_hit:pp
BE/Mem Backend_Bound.Memory_Bound.Store_Bound: 74.91 % Stalls [ 4.96%] <==
This metric estimates how often CPU was stalled due to
store memory accesses...
Sampling events: mem_inst_retired.all_stores:pp
BE/Core Backend_Bound.Core_Bound.Ports_Utilization: 28.20 % Clocks [ 4.93%]
BE/Core Backend_Bound.Core_Bound.Ports_Utilization.1_Port_Utilized: 26.28 % CoreClocks [ 4.83%]
This metric represents Core cycles fraction where the CPU
executed total of 1 uop per cycle on all execution ports...
MUX: 4.65 %
PerfMon Event Multiplexing accuracy indicator
Cela n’apporte pas vraiment beaucoup de lumière: nous savions déjà que ce devait être les magasins qui gâchent les choses, mais pourquoi? La description d'Intel de l'état ne dit pas grand chose.
Voici un résumé raisonnable de certaines des questions liées à l’interaction L1-L2.
1 C'est un MCVE grandement simplifié de ma boucle originale, qui était au moins trois fois plus grande et qui a fait beaucoup de travail supplémentaire, mais a présenté exactement les mêmes performances que cette version simple, goulot d'étranglement sur le même problème mystérieux.
3 Cela ressemble en particulier à exactement si vous écrivez l’Assembly à la main, ou si vous le compilez avec gcc -O1
(version 5.4.1), et probablement les compilateurs les plus raisonnables (volatile
est utilisé pour éviter de perdre la plupart du temps). -dead deuxième magasin en dehors de la boucle).
4 Nul doute que vous pourriez convertir cela en syntaxe MASM avec quelques modifications mineures, car l’Assemblée est si triviale. Les demandes de tirage sont acceptées.
Sandy Bridge dispose de "pré-récupérateurs de matériel de données L1". Cela signifie qu'au départ, lorsque vous effectuez votre magasin, le processeur doit extraire les données de L2 à L1; mais après que cela se soit produit plusieurs fois, le pré-récupérateur de matériel remarque le modèle séquentiel de Nice et commence à pré-extraire les données de L2 vers L1 pour vous, de sorte que les données se trouvent soit en L1, soit le magasin.