Performances relatives des verrous swap par rapport aux verrous de comparaison et d’échange sur x86

Deux idiomes de locking courants sont:

if (!atomic_swap(lockaddr, 1)) /* got the lock */ 

et:

 if (!atomic_compare_and_swap(lockaddr, 0, val)) /* got the lock */ 

val pourrait simplement être une constante ou un identifiant pour le nouveau propriétaire éventuel de la serrure.

Ce que j’aimerais savoir, c’est s’il y a généralement une différence de performances significative entre les deux machines x86 (et x86_64). Je sais que la question est assez large car la réponse peut varier énormément d’un modèle de processeur à l’autre, mais c’est en partie la raison pour laquelle je pose la question SO plutôt que de faire des points de repère sur quelques CPU auxquels j’ai access.

Je suppose que atomic_swap (lockaddr, 1) est traduit en instruction xchg reg, mem et que atomic_compare_and_swap (lockaddr, 0, val) est traduit en cmpxchg [8b | 16b].

Certains développeurs du kernel Linux pensent que cmpxchg est plus rapide, car le préfixe du verrou n’est pas implicite comme avec xchg. Donc, si vous utilisez un processeur unique, une multithread ou si vous pouvez vous assurer que le locking n’est pas nécessaire, cmpxchg est probablement préférable.

Mais il est probable que votre compilateur le traduira en “lock cmpxchg” et dans ce cas, cela n’a pas vraiment d’importance. Notez également que bien que les latences pour ces instructions soient faibles (1 cycle sans locking et environ 20 avec locking), si vous utilisez des variables de synchronisation communes entre deux threads, ce qui est assez habituel, certains cycles de bus supplémentaires seront appliqués, lesquels dureront plus longtemps. pour toujours comparé aux latences d’instruction. Celles-ci seront probablement complètement cachées par un cache long de 200 à 500 cpu / synchronisation / access mémoire / verrou de bus / peu importe.

J’ai trouvé ce document d’Intel indiquant qu’il n’y avait pas de différence dans la pratique:

http://software.intel.com/en-us/articles/implementing-scalable-atomic-locks-for-multi-core-intel-em64t-and-ia32-architectures/

Un mythe commun est que le verrou utilisant une instruction cmpxchg est moins cher qu’un verrou utilisant une instruction xchg. Ceci est utilisé parce que cmpxchg n’essaiera pas d’obtenir le verrou en mode exclusif car le cmp passera en premier. La figure 9 montre que cmpxchg est aussi coûteux que l’instruction xchg.

Sur x86, toute instruction avec un préfixe LOCK effectue toutes les opérations de la mémoire sous forme de cycles lecture-modification-écriture. Cela signifie que XCHG (avec son verrou implicite) et LOCK CMPXCHG (dans tous les cas, même si la comparaison échoue) obtiennent toujours un verrou exclusif sur la ligne de cache. Le résultat est qu’il n’y a pratiquement aucune différence de performance.

Notez que de nombreux processeurs tournant tous sur le même verrou peuvent entraîner beaucoup de surcharge de bus dans ce modèle. C’est l’une des raisons pour lesquelles les boucles à locking verrouillé doivent contenir des instructions PAUSE. Certaines autres architectures ont de meilleures opérations pour cela.

Êtes-vous sûr de ne pas dire

  if (!atomic_load(lockaddr)) { if (!atomic_swap(lockaddr, val)) /* got the lock */ 

pour le second?

Tester et tester et définir des verrous (voir Wikipedia https://en.wikipedia.org/wiki/Test_and_test-and-set ) sont une optimisation assez courante pour de nombreuses plates-formes.

Selon la manière dont la comparaison et l’échange sont mis en œuvre, cela peut être plus rapide ou plus lent qu’un test, un test et une définition.

X86 étant une plate-forme ordonnée relativement plus puissante, les optimisations matérielles susceptibles de simplifier les tests et de définir plus rapidement les lockings sont parfois moins possibles.

La figure 8 du document selon lequel Bo Persson a trouvé http://software.intel.com/en-us/articles/implementing-scalable-atomic-locks-for-multi-core-intel-em64t-and-ia32-architectures/ que les verrous Test and Test et Set offrent des performances supérieures.

En termes de performances sur les processeurs d’Intel, c’est la même chose, mais dans un souci de simplicité, pour avoir des choses plus faciles à comprendre, je préfère la première façon à partir des exemples que vous avez donnés. Il n’y a aucune raison d’utiliser cmpxchg pour acquérir un verrou si vous pouvez le faire avec xchg .

Selon le principe du razor d’Occam, les choses simples sont meilleures.

En outre, le locking avec xchg est plus puissant. Vous pouvez également vérifier l’exactitude de la logique de votre logiciel, c’est-à-dire que vous xchg pas à l’octet mémoire qui n’a pas été explicitement alloué pour le locking ou que vous ne déverrouillez pas deux fois.

Il n’y a pas de consensus sur le sharepoint savoir si la libération d’un verrou doit être simplement un magasin normal ou un magasin lock . Par exemple, LeaveCriticalSection sous Windows 10 utilise lock magasin verrouillé pour libérer le locking, même sur un processeur à socket unique; Alors que sur plusieurs processeurs physiques avec NUMA (Non-Uniform-Memory-Access), le problème de la libération du verrou: un magasin normal par rapport à un magasin lock peut être encore plus important.

Voir cet exemple de fonctions de locking plus sûres qui vérifient la validité des données et intercepte les tentatives de libération de verrous non acquis:

 const cLockAvailable = 107; // arbitrary constant, use any unique values that you like, I've chosen prime numbers cLockLocked = 109; cLockFinished = 113; function AcquireLock(var Target: LONG): Boolean; var R: LONG; begin R := InterlockedExchange(Target, cLockByteLocked); case R of cLockAvailable: Result := True; // we've got a value that indicates that the lock was available, so return True to the caller indicating that we have acquired the lock cLockByteLocked: Result := False; // we've got a value that indicates that the lock was already acquire by someone else, so return False to the caller indicating that we have failed to acquire the lock this time else begin raise Exception.Create('Serious application error - sortinged to acquire lock using a variable that has not been properly initialized'); end; end; end; procedure ReleaseLock(var Target: LONG); var R: LONG; begin // As Peter Cordes pointed out (see comments below), releasing the lock doesn't have to be interlocked, just a normal store. Even for debugging we use normal load. However, Windows 10 uses locked release on LeaveCriticalSection. R := Target; Target := cLockAvailable; if R <> cLockByteLocked then begin raise Exception.Create('Serious application error - tried to release a lock that has not been actually locked'); end; end; 

Votre application principale va ici:

 var AreaLocked: LONG; begin AreaLocked := cLockAvailable; // on program initialization, fill the default value .... if AcquireLock(AreaLocked) then try // do something critical with the locked area ... finally ReleaseLock(AreaLocked); end; .... AreaLocked := cLockFinished; // on program termination, set the special value to catch probable cases when somebody will try to acquire the lock end. 

Vous pouvez également utiliser le code suivant comme boucle d’essorage. Il utilise une charge normale pendant l’essorage pour économiser des ressources, comme suggéré par Peter Cordes. Après 5000 cycles, il appelle la fonction SwitchToThread () de l’API Windows. Cette valeur de 5000 cycles est mon empirique. Les valeurs comsockets entre 500 et 50000 semblent également convenir. Dans certains scénarios, les valeurs les plus basses sont meilleures, alors que dans d’autres, les valeurs les plus élevées sont meilleures. Veuillez noter que vous ne pouvez utiliser ce code que sur des processeurs prenant en charge SSE2 – vous devez vérifier le bit CPUID correspondant avant d’appeler une instruction de pause – sinon, vous perdrez simplement de l’énergie. Sur les processeurs sans pause utilisez simplement d’autres moyens, tels que EnterCriticalSection / LeaveCriticalSection ou Sleep (0), puis Sleep (1) en boucle. Certaines personnes disent que sur les processeurs 64 bits, il est possible que vous ne vérifiiez pas SSE2 pour vous assurer que l’instruction de pause est implémentée, car l’architecture AMD64 d’origine a adopté les instructions SSE et SSE2 d’Intel comme instructions de base et, pratiquement, si vous exécutez du code 64 bits. , vous avez déjà SSE2 et l’instruction pause . Toutefois, Intel déconseille de s’appuyer sur une fonctionnalité spécifique à la présence et indique explicitement que certaines fonctionnalités peuvent disparaître dans les futurs processeurs et les applications doivent toujours vérifier les fonctionnalités via le CPUID. Cependant, les instructions SSE sont devenues omniprésentes et de nombreux compilateurs 64 bits les utilisent sans vérification (par exemple, Delphi pour Win64). Par conséquent, les chances que, dans certains processeurs, il n’y ait plus de SSE2, encore moins de pause , sont très minces.

 // on entry rcx = address of the byte-lock // on exit: al (eax) = old value of the byte at [rcx] @Init: mov edx, cLockByteLocked mov r9d, 5000 mov eax, edx jmp @FirstCompare @DidntLock: @NormalLoadLoop: dec r9 jz @SwitchToThread // for static branch prediction, jump forward means "unlikely" pause @FirstCompare: cmp [rcx], al // we are using faster, normal load to not consume the resources and only after it is ready, do once again interlocked exchange je @NormalLoadLoop // for static branch prediction, jump backwards means "likely" lock xchg [rcx], al cmp eax, edx // 32-bit comparison is faster on newer processors like Xeon Phi or Cannonlake. je @DidntLock jmp @Finish @SwitchToThread: push rcx call SwitchToThreadIfSupported pop rcx jmp @Init @Finish: