Aujourd'hui, j'aimerais parler du travail d'un jour que j'ai trouvé intéressant.
Notre équipe était récemment en train d’enquêter lire la performance pour de petites mais nombreuses requêtes, qui s'étaient révélées être liées au processeur. Environ la moitié de notre temps processeur a été consacré à la lutte contre les verrous, et nous avons eu un certain succès à nous classer parmi les meilleurs candidats pour tenter de gagner encore plus. IOPS. Étant donné que nous avions déjà travaillé à réduire le nombre de conflits de verrous dans plusieurs domaines, je me suis demandé si l'impact de nos verrous sur la performance était considérable.
J'ai réservé du temps pour examiner notre implémentation de verrouillage la plus couramment utilisée. Mon idée était de voir s'il y avait des moyens de rendre tous nos verrous en conflit un peu plus rapides. J'ai découvert deux implémentations. Un "spinlock" était un wrapper pour pthread_mutex, et l'autre moins couramment utilisé était un wrapper pour futex. Un pthread_mutex est une structure massive de 40 octets fournie par la bibliothèque pthreads, tandis qu'un futex est de 4 octets. J'ai décidé d'obtenir des données sur leurs performances, mais je commençais déjà à aimer l'idée d'optimiser l'implémentation basée sur le futex et de la rendre plus standard.
Avant de partir et de faire des changements plus importants, j'ai décidé de comparer les deux verrous. D'après mon expérience, la plupart des verrous posant des problèmes de performances ont été fortement contestés. J'ai écrit un microbenchmark qui met en place quatre threads où chaque thread acquiert un verrou, incrémente un entier et libère le verrou. Je reconnais également que toute surcharge liée à une vitesse d'acquisition non contrôlée serait probablement assez difficile à détecter dans le produit. J'ai donc également écrit une variante à thread unique. Je laisse chacun courir pendant 30 secondes avant de vérifier le compteur. Voici les premiers résultats:
Non contrôlé | Contended (Threads 4) | |
Pthread Mutex | ~ 47 ma / s * | ~ 13 ma / s * |
Futex | ~ 53 ma / s * | ~ 8.4 ma / s * |
* millions d'acquisitions / seconde
J'ai validé ces résultats avec un test rapide sur le produit réel, où j'ai échangé l'implémentation de Pthreads pour l'implémentation de Futex. Effectivement, la mise en œuvre du Futex a régressé les performances. C'est un résultat frustrant, car je sais que Pthread Mutex est implémenté sur Futex pour cette plate-forme. J'ai examiné de plus près l'implémentation du Futex. Voici les parties importantes du pseudo-code. Je simplifie intentionnellement les détails du fonctionnement des futex car il n’est en grande partie pas pertinent pour cette histoire.
bool trylock (futex_lock * self) {return! atomic_bool_exchange (& self-> is_locked, true); } void lock (futex_lock * self) {for (int i = 0; i <SOME_NUMBER; ++ i) {if (trylock (self)) return; pause () // indique au CPU que nous sommes dans une boucle de verrouillage tournant} while (true) {futex_sleep (& self-> is_locked); if (trylock (self)) return; }} void unlock (futex_lock * self) {atomic_bool_set (& self-> is_locked, false); futex_wake_all (& self-> is_locked); }
La chose la plus évidente à changer ici pour moi était SOME_NUMBER, suivie peut-être d'une pause. Après quelques essais de chiffres différents et quelques idées différentes sur la façon de faire une pause, j'ai découvert que la chose la plus efficace à faire était de ne pas tourner du tout. La nouvelle serrure ressemblait un peu à ceci.
void lock (futex_lock * self) {while (true) {if (trylock (self)) return; futex_sleep (& self-> is_locked); }}
1 discussion | Fils 4 | |
Pthread Mutex | ~ 47 ma / s * | ~ 13 ma / s * |
Futex | ~ 53 ma / s * | ~ 8.4 ma / s * |
Futex (sans filer) | ~ 53 ma / s * | ~ 17 ma / s * |
* millions d'acquisitions / seconde
Cela peut être surprenant étant donné que dormir sur un futex est un système, et donc assez coûteux. Quelque chose d'autre cher doit se passer pendant que nous tournons. Sur les processeurs Intel, il existe trois états pour les lignes de cache. «I» signifie que ce noyau a un accès exclusif (écriture sécurisée). «S» signifie que la ligne de cache est propre, mais peut également se trouver dans le cache d'un autre cœur (lecture sécurisée). «A» signifie qu'un autre noyau peut avoir un accès exclusif à la ligne de cache (non sécurisé sans demander). Lorsque plusieurs cœurs effectuent un accès atomique sur des données de la même ligne de cache, ils négocient quel cédant obtient un accès exclusif et les tables de ping-pong de données entre les états I et A. Cette opération coûteuse implique potentiellement la diffusion de messages entre des sockets CPU.
Dans ce contexte, l’amélioration de nos performances a beaucoup de sens. Cela ouvre également de nouvelles pistes d'optimisation. futex_wake_all semble être un problème particulièrement important ici. Au cas où il y aurait plus d'un serveur, un seul réussira à obtenir le verrou tandis que tous lutteront pour un accès exclusif à la ligne de cache. Après quelques manipulations, je suis arrivé à quelque chose comme ce qui suit.
bool trylock (futex_lock * self) {auto old_state = atomic_uint_compare_exchange (& self-> state, LOCK_FREE, LOCK_HELD); return old_state == LOCK_FREE; } verrouillage vide (futex_lock * self) {auto old_state = atomic_uint_compare_exchange (& self-> state, LOCK_FREE, LOCK_HELD); if (old_state == LOCK_FREE) retourne if (old_state == LOCK_HELD) old_state = atomic_uint_exchange (& self-> state, LOCK_CONTENDED) while (old_state! = spinlock_free) {futex_wait_if_value_equals (& self-> state, LOCK_equals) state, LOCK_CONTENDED)}} void unlock (futex_lock * self) {auto old_state = atomic_uint_exchange (& self-> state, LOCK_FREE); if (old_state == LOCK_CONTENDED) futex_wake_one (& self-> state)}
Ce nouveau design présente de gros avantages en termes de performances contestées. En ajoutant un nouvel état de conflit, nous pouvons faire en sorte de ne réveiller qu'un à la fois. Bien qu'il soit encore possible d'appeler inutilement le réveil, nous aurions dû supprimer la source des conflits de lignes de cache supplémentaires. Cela a entraîné une augmentation supplémentaire des performances du microbenchmark.
1 discussion | Fils 4 | |
Pthread Mutex | ~ 47 ma / s * | ~ 13 ma / s * |
Futex | ~ 53 ma / s * | ~ 8.4 ma / s * |
Futex (sans filer) | ~ 53 ma / s * | ~ 17 ma / s * |
Futex (état 3) | ~ 53 ma / s * | ~ 20 ma / s * |
* millions d'acquisitions / seconde
Bien qu'il semble encore y avoir des domaines d'amélioration mineurs, cela semble être suffisamment en avance sur le Pthread Mutex pour que nous devrions voir des gains dans le produit réel si nous le remplaçons. Lors de l'exécution de notre benchmark IOPS à lecture aléatoire, cela nous a donné une augmentation d'environ 4%. Ce n'est pas beaucoup, mais le changement a eu un impact très large sur de nombreuses charges de travail liées au verrouillage pour le produit. La réduction de taille de 40 à 4 octets a également réduit notre consommation totale de RAM dans certains scénarios jusqu'à un gigaoctet.