Salutations gurus du x86 ! J'ai un petit problème de perf intéressant pour vous !
Contexte :
Je développe actuellement un framework évenementiel pour machines multi-coeur. Classiquement ça fonctionne très bien jusqu'à 4 coeurs et puis après (8 coeurs / 16 coeurs) les perfs s'écroulent.
En investiguant sur les causes du pourquoi du comment je suis tombé sur des comportements étranges sur des Q6600 & Bi Xeon (j'ai pas encore testé sur d'autres processeurs). L'idée de base était de tester le comportement des test&set (faits via "xchgb").
Voici les résultats qu'on obtient : (chaque coeur bourinne et fait des xchgb en boucle)
- si chaque coeur fait des xchgb sur une variable différente, pas de problème : on en fait énormement et ça scale bien (~1 milliard / seconde au total avec 8 coeurs)
- si chaque coeur fait un xchgb sur la même variable les perfs sont nazes (écroulement : on en fait plus que ~18 millions/sec au total avec 8 coeurs ! (210m/s avec 4))
On s'est dit que c'était dû à la cohérence des caches qui moulinait un peu, donc on a décidé de faire un faux T&S pour se convaincre.
Le test en question est tout bête : on lance un thread par coeur (fixé sur un coeur) et chaque thread fait des T&S en boucle : (cette fois les tests sont faits sur des Q6600 donc on passe de 2 à 4 coeurs)
Code:
unsigned char faux_test_and_set(volatile unsigned char* addr) {
register unsigned char ret = *addr;
*addr = 1;
return ret;
}
... Les perfs sont effectivement nazes... (On passe de 210m/s à 17m/s de 2 à 4 coeurs )
LE PROBLEME c'est que si on fait ça :
Code:
unsigned char faux_test_and_set(volatile unsigned char* addr) {
register unsigned char ret = *addr;
(*addr)++; // Subtile modif
return ret;
}
... Là ça marche plus que bien ! (~700m/s à 2 coeurs et ~800m/s à 4).
J'ai fouillé un peu dans le code asm généré par gcc (et icc : les perfs sont identiques et le code asm très très proche) et fait quelques modifs. De base on a : (SYNTAXE AT&T !)
- version *addr = 1 : (lent)
Movb $1, value_to_tas(%rip)
- version (*addr)++ : (rapide)
Movzbl value_to_tas(%rip), %eax
Addl $1, %eax
Movb %eax, value_to_tas(%rip)
Modifs & perfs : (lent = ~20m/s, rapide = ~600/800m/s)
*1/
Movzbl value_to_tas(%rip), %eax
Xorl %eax, %eax
Movb %eax, value_to_tas(%rip)
=> C'est lent !
*2/
Movzbl value_to_tas(%rip), %eax
Andl %eax, %eax
Movb %eax, value_to_tas(%rip)
=> C'est rapide !
*3/
Movzbl value_to_tas(%rip), %eax
Incl %eax
Movb %eax, value_to_tas(%rip)
=> C'est rapide !
*4/
Movzbl value_to_tas(%rip), %eax
Xorl %eax, %eax
Nop (x 50)
Movb %eax, value_to_tas(%rip)
=> C'est rapide !
Et là je comprends plus rien... Si on regarde juste 1 & 4, on pourrait se dire qu'il faut laisser un peu de temps au cache pour qu'il soit bien efficace (algo de cohérence foireux ? Optimisation qui fonctionne mal quand on bourrine trop ?), mais pourquoi ça redevient rapide lorsqu'on met un andl/incl à la place d'un xorl ???
Bizarrement si on lance notre test de T&S avec un autre test bien intensif (un test qui fait un while(1) { malloc(); free(); } par exemple), on a de meilleures perfs que si le test T&S est lancé seul.
(Empiriquement : T&S seul : ~20m/s et T&S + processus qui fait des mallocs : ~380m/s !)
Quelqu'un a une idée du pourquoi du comment ?