c assembly locking x86 atomic

Rendimiento relativo de los bloqueos swap vs compare-and-swap en x86



assembly locking (5)

Dos idiomas de bloqueo comunes son:

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

y:

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

donde val podría simplemente ser una constante o un identificador para el nuevo propietario potencial de la cerradura.

Lo que me gustaría saber es si tiende a haber una diferencia de rendimiento significativa entre las dos máquinas x86 (y x86_64). Sé que esta es una pregunta bastante amplia, ya que la respuesta puede variar mucho entre los modelos de CPU individuales, pero esa es parte de la razón por la que estoy pidiendo SO, en lugar de simplemente hacer benchmarks en algunas CPU a las que tengo acceso.


¿Estás seguro de que no quisiste decir

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

para el segundo?

Probar y probar y establecer bloqueos (ver Wikipedia https://en.wikipedia.org/wiki/Test_and_test-and-set ) son una optimización bastante común para muchas plataformas.

Dependiendo de cómo se implemente la comparación y el intercambio, podría ser más rápido o más lento que una prueba y prueba y configuración.

Como x86 es una plataforma ordenada relativamente más fuerte, las optimizaciones de HW que pueden hacer que la prueba y prueba y establecer bloqueos más rápido sean menos posibles.

La figura 8 del documento que encontró Bo Persson http://software.intel.com/en-us/articles/implementing-scalable-atomic-locks-for-multi-core-intel-em64t-and-ia32-architectures/ muestra que los bloqueos de Prueba y Prueba y Conjunto son superiores en rendimiento.


En términos de rendimiento en los procesadores de Intel, es lo mismo, pero en aras de la simplicidad, para que las cosas sean más fáciles de comprender, prefiero la primera manera de los ejemplos que usted ha dado. No hay ninguna razón para usar cmpxchg para adquirir un bloqueo si puede hacer esto con xchg .

De acuerdo con el principio de la navaja de Occam, las cosas simples son mejores.

Además de eso, bloquear con xchg es más potente: también puede verificar la corrección de la lógica de su software, es decir, que no está accediendo al byte de memoria que no se ha asignado explícitamente para el bloqueo, o que no se desbloquea dos veces.

No hay consenso sobre si liberar una cerradura debe ser solo una tienda normal o una tienda con lock . Por ejemplo, LeaveCriticalSection en Windows 10 usa lock -ed store para liberar el bloqueo incluso en un procesador de un solo socket; mientras que en múltiples procesadores físicos con acceso a memoria no uniforme (NUMA), la cuestión de cómo liberar el bloqueo: una tienda normal frente a una tienda con lock puede ser aún más importante.

Vea este ejemplo de funciones de bloqueo más seguras que verifican la validez de los datos y atrapa los intentos de liberar bloqueos que no fueron adquiridos:

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 - tried 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;

Su aplicación principal va aquí:

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.

También puede usar el siguiente código como spin-loop, usa carga normal mientras gira para ahorrar recursos, como lo sugirió Peter Cordes. Después de 5000 ciclos, llama a la función API de Windows SwitchToThread (). Este valor de 5000 ciclos es mi empírica. Los valores de 500 a 50000 también parecen estar bien, en algunos escenarios, los valores más bajos son mejores, mientras que en otros, los mejores son mejores. Tenga en cuenta que puede usar este código solo en procesadores compatibles con SSE2; debe verificar el bit de CPUID correspondiente antes de llamar a la instrucción de pause ; de lo contrario, habrá una pérdida de potencia. En los procesadores sin pause solo use otros medios, como EnterCriticalSection / LeaveCriticalSection o Sleep (0) y luego Sleep (1) en un bucle. Algunas personas dicen que en los procesadores de 64 bits no se puede verificar SSE2 para asegurarse de que se implemente la instrucción de pause , porque la arquitectura original AMD64 adoptó SSE y SSE2 de Intel como instrucciones básicas y, prácticamente, si ejecuta código de 64 bits , ya tiene SSE2 seguro y, por lo tanto, la instrucción de pause . Sin embargo, Intel desalienta la práctica de confiar en una característica específica de presencia y establece explícitamente que ciertas características pueden desaparecer en futuros procesadores y las aplicaciones siempre deben verificar las características a través de CPUID. Sin embargo, las instrucciones SSE se volvieron omnipresentes y muchos compiladores de 64 bits las usan sin verificar (por ejemplo, Delphi para Win64), por lo que es probable que en algunos procesadores futuros no haya SSE2, y mucho menos pause , sean muy escasas.

// 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:


En x86, cualquier instrucción con un prefijo LOCK realiza todas las operaciones de memoria como ciclos de lectura, modificación y escritura. Esto significa que XCHG (con su LOCK implícito) y LOCK CMPXCHG (en todos los casos, incluso si la comparación falla) siempre obtienen un bloqueo exclusivo en la línea de caché. El resultado es que básicamente no hay diferencia en el rendimiento.

Tenga en cuenta que muchas CPU todas girando en el mismo bloqueo pueden causar una gran cantidad de sobrecarga de bus en este modelo. Esta es una de las razones por las que los bucles de bloqueo de giro deben contener instrucciones de PAUSA. Algunas otras arquitecturas tienen mejores operaciones para esto.


Encontré este documento de Intel, indicando que no hay diferencia en la práctica:

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

Un mito común es que el bloqueo que utiliza una instrucción cmpxchg es más barato que un bloqueo que utiliza una instrucción xchg. Esto se usa porque cmpxchg no intentará obtener el bloqueo en modo exclusivo ya que el cmp pasará primero. La Figura 9 muestra que el cmpxchg es tan caro como la instrucción xchg.


Supongo que atomic_swap (lockaddr, 1) se traduce en un reg xchg, la instrucción mem y atomic_compare_and_swap (lockaddr, 0, val) se traduce en un cmpxchg [8b | 16b].

Algunos desarrolladores de kernel de Linux piensan que cmpxchg es más rápido, porque el prefijo de bloqueo no está implícito como con xchg. Por lo tanto, si está en un uniprocesador, multihilo o puede asegurarse de que no se necesita el candado, probablemente sea mejor con cmpxchg.

Pero es probable que tu compilador lo traduzca a "lock cmpxchg" y en ese caso realmente no importa. También tenga en cuenta que aunque las latencias para estas instrucciones son bajas (1 ciclo sin bloqueo y aproximadamente 20 con bloqueo), si utiliza una variable de sincronización común entre dos hilos, lo cual es bastante habitual, se aplicarán algunos ciclos de bus adicionales, que duran para siempre en comparación con las latencias de instrucción. Es muy probable que estos estén completamente ocultos por un ciclo de 200 o 500 cpu largo de snoop / sync / mem access / bus lock / lo que sea.