Punto flotante atómico doble o vector de carga / almacenamiento de SSE / AVX en x86_64

Aquí (y en unas pocas preguntas de SO) veo que C ++ no es compatible con algo como lock-free std::atomic y todavía no es compatible con algo como atomic AVX / SSE vector porque depende de la CPU (aunque hoy en día de las CPU que conozco, ARM, AArch64 y x86_64 tienen vectores).

¿Pero hay soporte de nivel de ensamblaje para operaciones atómicas en double o vectores en x86_64? Si es así, ¿qué operaciones son compatibles (como cargar, almacenar, agregar, restar, multiplicar tal vez)? ¿Qué operaciones implementa MSVC ++ 2017 sin locking en atomic ?

C ++ no es compatible con algo como lock-free std::atomic

En realidad, C ++ 11 std::atomic tiene lockings en las implementaciones típicas de C ++, y expone casi todo lo que puede hacer en asm para una progtwigción sin lockings con float / double en x86 (por ejemplo, cargar, almacenar y CAS son suficientes para implementar cualquier cosa: ¿Por qué el doble atómico no está completamente implementado ? Sin embargo, los comstackdores actuales no comstackn siempre atomic eficientemente.

C ++ 11 std :: atomic no tiene una API para las extensiones de memoria transaccional (TSX) de Intel (para FP o entero). TSX podría ser un cambio de juego especialmente para FP / SIMD, ya que eliminaría todos los gastos generales de rebote de datos entre xmm y registros enteros. Si la transacción no se cancela, lo que sea que acaba de hacer con cargas / tiendas dobles o vectoriales ocurre atómicamente.

Algunos hardware no x86 admiten add atómico para float / double, y C ++ p0020 es una propuesta para agregar fetch_add y operator+= / -= especializaciones de plantilla a std::atomic / C ++.

El hardware con atomics LL / SC en lugar de las instrucciones de destino de memoria estilo x86, como ARM y la mayoría de las otras CPU RISC, puede realizar operaciones RMW atómicas en double y float sin un CAS, pero aún debe obtener los datos de FP a entero registra porque LL / SC generalmente solo está disponible para regs enteros, como cmpxchg x86. Sin embargo, si el hardware arbitra los pares LL / SC para evitar / reducir el locking en vivo, sería significativamente más eficiente que con un bucle CAS en situaciones de muy alta contención. Si ha diseñado sus algoritmos para que la contención sea poco común, tal vez exista una pequeña diferencia de tamaño de código entre un bucle de rebashs LL / add / SC para fetch_add contra un bucle de reinteda CAS + load + LL / SC CAS.


Las cargas y almacenes alineados de forma natural de x86 son atómicos de hasta 8 bytes, incluso x87 o SSE . (Por ejemplo, movsd xmm0, [some_variable] es atómica, incluso en modo de 32 bits). De hecho, gcc usa x87 fild / fistp o SSE 8B loads / stores para implementar std::atomic carga y almacena en código de 32 bits.

Irónicamente, los comstackdores (gcc7.1, clang4.0, ICC17, MSVC CL19) hacen un mal trabajo en el código de 64 bits (o en el de 32 bits con SSE2 disponible) y recuperan datos a través de registros enteros en lugar de solo hacer cargas / tiendas movsd directamente a / desde xmm regs ( verlo en Godbolt ):

 #include  std::atomic ad; void store(double x){ ad.store(x, std::memory_order_release); } // gcc7.1 -O3 -mtune=intel: // movq rax, xmm0 # ALU xmm->integer // mov QWORD PTR ad[rip], rax // ret double load(){ return ad.load(std::memory_order_acquire); } // mov rax, QWORD PTR ad[rip] // movq xmm0, rax // ret 

Sin -mtune=intel , a gcc le gusta almacenar / recargar para entero-> xmm. Ver https://gcc.gnu.org/bugzilla/show_bug.cgi?id=80820 y los errores relacionados que informé. Esta es una opción pobre incluso para -mtune=generic . AMD tiene una alta latencia para movq entre números enteros y regs vectoriales, pero también tiene alta latencia para una tienda / recarga. Con el valor predeterminado -mtune=generic , load() comstack a:

 // mov rax, QWORD PTR ad[rip] // mov QWORD PTR [rsp-8], rax # store/reload integer->xmm // movsd xmm0, QWORD PTR [rsp-8] // ret 

El movimiento de datos entre xmm y el registro entero nos lleva al siguiente tema:


Atomic read-modify-write (como fetch_add ) es otra historia : hay soporte directo para enteros con cosas como lock xadd [mem], eax (ver ¿Puede num ++ ser atómico para ‘int num’? Para más detalles). Para otras cosas, como atomic o atomic , la única opción en x86 es un ciclo de rebash con cmpxchg (o TSX) .

Atomic compare-and-swap (CAS) se puede utilizar como bloque de construcción sin locking para cualquier operación de RMW atómica, hasta el ancho máximo de CAS admitido por hardware. En x86-64, eso es 16 bytes con cmpxchg16b (no disponible en algunos AMD K8 de primera generación, por lo que para gcc debe usar -mcx16 o -march=whatever que -march=whatever para habilitarlo).

gcc hace el mejor asm posible para el exchange() :

 double exchange(double x) { return ad.exchange(x); // seq_cst } movq rax, xmm0 xchg rax, QWORD PTR ad[rip] movq xmm0, rax ret // in 32-bit code, compiles to a cmpxchg8b retry loop void atomic_add1() { // ad += 1.0; // not supported // ad.fetch_or(-0.0); // not supported // have to implement the CAS loop ourselves: double desired, expected = ad.load(std::memory_order_relaxed); do { desired = expected + 1.0; } while( !ad.compare_exchange_weak(expected, desired) ); // seq_cst } mov rax, QWORD PTR ad[rip] movsd xmm1, QWORD PTR .LC0[rip] mov QWORD PTR [rsp-8], rax # useless store movq xmm0, rax mov rax, QWORD PTR [rsp-8] # and reload .L8: addsd xmm0, xmm1 movq rdx, xmm0 lock cmpxchg QWORD PTR ad[rip], rdx je .L5 mov QWORD PTR [rsp-8], rax movsd xmm0, QWORD PTR [rsp-8] jmp .L8 .L5: ret 

compare_exchange siempre hace una comparación bit a bit, por lo que no tiene que preocuparse por el hecho de que el cero negativo ( -0.0 ) se compara igual a +0.0 en la semántica de IEEE, o que NaN está desordenado. Sin embargo, esto podría ser un problema si intentas verificar el desired == expected y omitir la operación CAS. Para comstackdores suficientemente nuevos, memcmp(&expected, &desired, sizeof(double)) == 0 podría ser una buena forma de express una comparación bit a bit de los valores de FP en C ++. Solo asegúrate de evitar los falsos positivos; los falsos negativos conducirán a una CAS innecesaria.


El lock or [mem], 1 arbitrario por hardware lock or [mem], 1 es definitivamente mejor que tener varios hilos girando en los bucles de rebash de lock cmpxchg . Cada vez que un núcleo obtiene acceso a la línea de caché pero falla su cmpxchg es un rendimiento desperdiciado en comparación con las operaciones de destino de la memoria entera que siempre tienen éxito una vez que tienen en sus manos una línea de caché.

Algunos casos especiales para flotantes IEEE pueden implementarse con operaciones enteras . por ejemplo, el valor absoluto de un atomic podría hacerse con lock and [mem], rax (donde RAX tiene todos los bits excepto el bit de signo configurado). O fuerce un flotador / doble para que sea negativo al poner un 1 en el bit de signo. O alternar su signo con XOR. Incluso podría boost atómicamente su magnitud en 1 ulp con lock add [mem], 1 . (Pero solo si puedes estar seguro de que no era infinito para empezar … nextafter() es una función interesante, gracias al diseño genial de IEEE754 con exponentes sesgados que hace que el carry de mantisa en exponente realmente funcione).

Probablemente no haya forma de express esto en C ++ que permitirá a los comstackdores hacerlo por usted en objectives que usan IEEE FP. Entonces, si lo desea, puede que tenga que hacerlo usted mismo con “type-punning” para atomic o algo así, y verifique que FP endianness coincida con endianness entero, etc., etc. (O simplemente hágalo solo para x86. La mayoría de los otros targets tener LL / SC en lugar de operaciones bloqueadas de destino de memoria de todos modos.)


todavía no es compatible con algo así como el vector atómico AVX / SSE porque depende de la CPU

Correcto. No hay forma de detectar cuándo una tienda o carga de 128b o 256b es atómica a través del sistema de coherencia de caché. ( https://gcc.gnu.org/bugzilla/show_bug.cgi?id=70490 ). Incluso un sistema con transferencias atómicas entre L1D y unidades de ejecución puede desgarrarse entre 8B cuando transfiere líneas de caché entre cachés a través de un protocolo estrecho. Ejemplo real: un Opteron K10 de múltiples zócalos con interconexiones HyperTransport parece tener cargas / tiendas atómicas de 16B dentro de un solo zócalo, pero los hilos en diferentes zócalos pueden observarse rasgaduras.

Pero si tiene una matriz compartida de double alineadas, podrá usar cargas / almacenamientos vectoriales sin riesgo de “desgarrarse” dentro de un double dado.

Atomicidad por elemento de vector carga / almacenar y reunir / dispersar?

Creo que es seguro suponer que una carga / tienda alineada de 32B se realiza con cargas / tiendas no superpuestas 8B o más amplias, aunque Intel no garantiza eso. Para operaciones no alineadas, probablemente no es seguro asumir nada.

Si necesita una carga atómica de 16B, su única opción es lock cmpxchg16b , con desired=expected . Si tiene éxito, reemplaza el valor existente consigo mismo. Si falla, entonces obtienes los contenidos anteriores. (Caso de esquina: esta “carga” falla en la memoria de solo lectura, así que tenga cuidado con los punteros que pasa a una función que hace esto.) Además, el rendimiento es, por supuesto, horrible en comparación con las cargas de solo lectura reales que pueden dejar el línea de caché en estado Compartido, y que no son barreras de memoria completa.

La tienda atómica 16B y RMW pueden usar el lock cmpxchg16b la manera más obvia. Esto hace que las tiendas puras sean mucho más caras que las tiendas de vectores regulares, especialmente si el cmpxchg16b tiene que volver a intentarlo varias veces, pero el RMW atómico ya es costoso.

Las instrucciones adicionales para mover datos vectoriales hacia / desde regos enteros no son gratuitas, pero tampoco son caros en comparación con el lock cmpxchg16b .

 # xmm0 -> rdx:rax, using SSE4 movq rax, xmm0 pextrq rdx, xmm0, 1 # rdx:rax -> xmm0, again using SSE4 movq xmm0, rax pinsrq xmm0, rdx, 1 

En C ++ 11 términos:

atomic<__m128d> sería lento incluso para operaciones de solo lectura o de solo escritura (usando cmpxchg16b ), incluso si se implementara de manera óptima. atomic<__m256d> ni siquiera puede estar libre de lockings.

alignas(64) atomic shared_buffer[1024]; en teoría, todavía permitiría la auto-vectorización para el código que lo lea o lo escriba, solo necesitando movq rax, xmm0 y luego xchg o cmpxchg para RMW atómico en un double . (En el modo de 32 bits, cmpxchg8b funcionaría). Sin embargo, ¡seguramente no obtendrá un buen asm de un comstackdor para esto!


Puedes actualizar atómicamente un objeto 16B, pero atómicamente lee las mitades 8B por separado . (Creo que esto es seguro con respecto a la ordenación de memoria en x86: ver mi razonamiento en https://gcc.gnu.org/bugzilla/show_bug.cgi?id=80835 ).

Sin embargo, los comstackdores no proporcionan ninguna forma clara de express esto. Corté un truco de unión que funciona para gcc / clang: ¿cómo puedo implementar el contador ABA con c ++ 11 CAS? . Pero gcc7 y posteriores no se cmpxchg16b en cmpxchg16b , ya que están reconsiderando si los objetos 16B realmente deberían presentarse como “libres de lockings”. ( https://gcc.gnu.org/ml/gcc-patches/2017-01/msg02344.html ).

En las operaciones atómicas x86-64 se implementan a través del prefijo LOCK. El Manual del desarrollador de software Intel (Volumen 2, Referencia del conjunto de instrucciones) indica

El prefijo LOCK se puede anteponer solo a las siguientes instrucciones y solo a aquellas formas de las instrucciones donde el operando de destino es un operando de memoria: ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, CMPXCHG16B, DEC, INC, NEG, NO, O, SBB, SUB, XOR, XADD y XCHG.

Ninguna de esas instrucciones funciona en registros de coma flotante (como los registros XMM, YMM o FPU).

Esto significa que no existe una forma natural de implementar operaciones flotantes / dobles atómicas en x86-64. Si bien la mayoría de esas operaciones podrían implementarse cargando la representación de bits del valor de punto flotante en un registro de propósito general (es decir, entero), al hacerlo se degradaría severamente el rendimiento por lo que los autores del comstackdor optaron por no implementarlo.

Como señala Peter Cordes en los comentarios, el prefijo LOCK no se requiere para cargas y almacenes, ya que estos siempre son atómicos en x86-64. Sin embargo, el Intel SDM (Volumen 3, Guía de progtwigción del sistema) solo garantiza que las siguientes cargas / tiendas sean atómicas:

  • Instrucciones que leen o escriben un solo byte.
  • Instrucciones que leen o escriben una palabra (2 bytes) cuya dirección está alineada en un límite de 2 bytes.
  • Instrucciones que leen o escriben una doble palabra (4 bytes) cuya dirección está alineada en un límite de 4 bytes.
  • Instrucciones que leen o escriben un quadword (8 bytes) cuya dirección está alineada en un límite de 8 bytes.

En particular, no se garantiza la atomicidad de las cargas / almacenamientos desde / hasta los registros de vectores XMM y YMM más grandes.