¿Cuáles son los costos de latencia y rendimiento de compartir una ubicación de memoria entre productores y consumidores entre hiperhermanos y no hiperhermanos?

Resuelto BeeOnRope asked hace 7 años • 3 respuestas

Dos subprocesos diferentes dentro de un solo proceso pueden compartir una ubicación de memoria común leyéndola y/o escribiendo en ella.

Por lo general, dicho intercambio (intencional) se implementa mediante operaciones atómicas usando el lockprefijo en x86, lo que tiene costos bastante conocidos tanto para el lockprefijo en sí (es decir, el costo no disputado) como también costos de coherencia adicionales cuando la línea de caché se comparte realmente ( compartir verdadero o falso ).

Aquí estoy interesado en los costos de producción y consumo donde un solo subproceso Pescribe en una ubicación de memoria y otro subproceso `C lee desde la ubicación de memoria, ambos usando lecturas y escrituras simples .

¿Cuál es la latencia y el rendimiento de dicha operación cuando se realiza en núcleos separados en el mismo socket y, en comparación, cuando se realiza en hiperprocesos hermanos en el mismo núcleo físico, en núcleos x86 recientes?

En el título estoy usando el término "hiper-hermanos" para referirme a dos subprocesos que se ejecutan en dos subprocesos lógicos del mismo núcleo, y hermanos entre núcleos para referirme al caso más habitual de dos subprocesos que se ejecutan en diferentes núcleos físicos. .

BeeOnRope avatar Aug 10 '17 07:08 BeeOnRope
Aceptado

Bien, no pude encontrar ninguna fuente autorizada, así que pensé en intentarlo yo mismo.

#include <pthread.h>
#include <sched.h>
#include <atomic>
#include <cstdint>
#include <iostream>


alignas(128) static uint64_t data[SIZE];
alignas(128) static std::atomic<unsigned> shared;
#ifdef EMPTY_PRODUCER
alignas(128) std::atomic<unsigned> unshared;
#endif
alignas(128) static std::atomic<bool> stop_producer;
alignas(128) static std::atomic<uint64_t> elapsed;

static inline uint64_t rdtsc()
{
    unsigned int l, h;
    __asm__ __volatile__ (
        "rdtsc"
        : "=a" (l), "=d" (h)
    );
    return ((uint64_t)h << 32) | l;
}

static void * consume(void *)
{
    uint64_t    value = 0;
    uint64_t    start = rdtsc();

    for (unsigned n = 0; n < LOOPS; ++n) {
        for (unsigned idx = 0; idx < SIZE; ++idx) {
            value += data[idx] + shared.load(std::memory_order_relaxed);
        }
    }

    elapsed = rdtsc() - start;
    return reinterpret_cast<void*>(value);
}

static void * produce(void *)
{
    do {
#ifdef EMPTY_PRODUCER
        unshared.store(0, std::memory_order_relaxed);
#else
        shared.store(0, std::memory_order_relaxed);
#enfid
    } while (!stop_producer);
    return nullptr;
}



int main()
{
    pthread_t consumerId, producerId;
    pthread_attr_t consumerAttrs, producerAttrs;
    cpu_set_t cpuset;

    for (unsigned idx = 0; idx < SIZE; ++idx) { data[idx] = 1; }
    shared = 0;
    stop_producer = false;

    pthread_attr_init(&consumerAttrs);
    CPU_ZERO(&cpuset);
    CPU_SET(CONSUMER_CPU, &cpuset);
    pthread_attr_setaffinity_np(&consumerAttrs, sizeof(cpuset), &cpuset);

    pthread_attr_init(&producerAttrs);
    CPU_ZERO(&cpuset);
    CPU_SET(PRODUCER_CPU, &cpuset);
    pthread_attr_setaffinity_np(&producerAttrs, sizeof(cpuset), &cpuset);

    pthread_create(&consumerId, &consumerAttrs, consume, NULL);
    pthread_create(&producerId, &producerAttrs, produce, NULL);

    pthread_attr_destroy(&consumerAttrs);
    pthread_attr_destroy(&producerAttrs);

    pthread_join(consumerId, NULL);
    stop_producer = true;
    pthread_join(producerId, NULL);

    std::cout <<"Elapsed cycles: " <<elapsed <<std::endl;
    return 0;
}

Compile con el siguiente comando, reemplazando define:

gcc -std=c++11 -DCONSUMER_CPU=3 -DPRODUCER_CPU=0 -DSIZE=131072 -DLOOPS=8000 timing.cxx -lstdc++ -lpthread -O2 -o timing

Dónde:

  • CONSUMER_CPU es el número de CPU en el que se ejecutará el subproceso del consumidor.
  • PRODUCER_CPU es el número de CPU en el que se ejecutará el hilo de productor.
  • TAMAÑO es el tamaño del bucle interno (importa para el caché)
  • LOOPS es, bueno...

Aquí están los bucles generados:

Hilo del consumidor

  400cc8:       ba 80 24 60 00          mov    $0x602480,%edx
  400ccd:       0f 1f 00                nopl   (%rax)
  400cd0:       8b 05 2a 17 20 00       mov    0x20172a(%rip),%eax        # 602400 <shared>
  400cd6:       48 83 c2 08             add    $0x8,%rdx
  400cda:       48 03 42 f8             add    -0x8(%rdx),%rax
  400cde:       48 01 c1                add    %rax,%rcx
  400ce1:       48 81 fa 80 24 70 00    cmp    $0x702480,%rdx
  400ce8:       75 e6                   jne    400cd0 <_ZL7consumePv+0x20>
  400cea:       83 ee 01                sub    $0x1,%esi
  400ced:       75 d9                   jne    400cc8 <_ZL7consumePv+0x18>

Hilo productor, con bucle vacío (sin escritura shared):

  400c90:       c7 05 e6 16 20 00 00    movl   $0x0,0x2016e6(%rip)        # 602380 <unshared>
  400c97:       00 00 00 
  400c9a:       0f b6 05 5f 16 20 00    movzbl 0x20165f(%rip),%eax        # 602300 <stop_producer>
  400ca1:       84 c0                   test   %al,%al
  400ca3:       74 eb                   je     400c90 <_ZL7producePv>

Hilo del productor, escribiendo a shared:

  400c90:       c7 05 66 17 20 00 00    movl   $0x0,0x201766(%rip)        # 602400 <shared>
  400c97:       00 00 00 
  400c9a:       0f b6 05 5f 16 20 00    movzbl 0x20165f(%rip),%eax        # 602300 <stop_producer>
  400ca1:       84 c0                   test   %al,%al
  400ca3:       74 eb                   je     400c90 <_ZL7producePv>

El programa cuenta el número de ciclos de CPU consumidos, en el núcleo del consumidor, para completar todo el ciclo. Comparamos el primer productor, que no hace más que quemar ciclos de CPU, con el segundo productor, que interrumpe al consumidor al escribir repetidamente en shared.

Mi sistema tiene un i5-4210U. Es decir, 2 núcleos, 2 hilos por núcleo. Están expuestos por el núcleo como Core#1 → cpu0, cpu2 Core#2 → cpu1, cpu3.

Resultado sin iniciar el productor en absoluto:

CONSUMER    PRODUCER     cycles for 1M      cycles for 128k
    3          n/a           2.11G              1.80G

Resultados con productor vacío. Para operaciones 1G (ya sea 1000*1M o 8000*128k).

CONSUMER    PRODUCER     cycles for 1M      cycles for 128k
    3           3            3.20G              3.26G       # mono
    3           2            2.10G              1.80G       # other core
    3           1            4.18G              3.24G       # same core, HT

Como era de esperar, dado que ambos subprocesos consumen la CPU y ambos obtienen una parte justa, los ciclos de grabación del productor ralentizan al consumidor aproximadamente a la mitad. Eso es sólo una disputa sobre la CPU.

Con el productor en la CPU n.° 2, como no hay interacción, el consumidor se ejecuta sin ningún impacto por parte del productor que se ejecuta en otra CPU.

Con el productor en la CPU#1, vemos el hyperthreading en funcionamiento.

Resultados con productor disruptivo:

CONSUMER    PRODUCER     cycles for 1M      cycles for 128k
    3           3            4.26G              3.24G       # mono
    3           2           22.1 G             19.2 G       # other core
    3           1           36.9 G             37.1 G       # same core, HT
  • Cuando programamos ambos subprocesos en el mismo subproceso del mismo núcleo, no hay ningún impacto. Se espera nuevamente, ya que el productor escribe que sigue siendo local, sin incurrir en ningún costo de sincronización.

  • Realmente no puedo explicar por qué obtengo un rendimiento mucho peor con hyperthreading que con dos núcleos. Bienvenidos consejos.

spectras avatar Aug 10 '2017 09:08 spectras

El problema principal es que los núcleos realizan lecturas especulativas, lo que significa que cada vez que se escribe en la dirección de lectura especulativa (o más correctamente en la misma línea de caché) antes de que se "cumple", significa que la CPU debe deshacer la lectura (al menos si eres un x86), lo que efectivamente significa que cancela todas las instrucciones especulativas de esa instrucción y posteriores.

En algún momento antes de que se retire la lectura, se "cumple", es decir. ninguna instrucción anterior puede fallar y ya no hay ningún motivo para volver a emitirla, y la CPU puede actuar como si hubiera ejecutado todas las instrucciones antes.

Otro ejemplo central

Estos juegan ping pong de caché además de cancelar instrucciones, por lo que debería ser peor que la versión HT.

Comencemos en algún punto del proceso donde la línea de caché con los datos compartidos acaba de marcarse como compartida porque el consumidor solicitó leerla.

  1. El productor ahora quiere escribir en los datos compartidos y envía una solicitud de propiedad exclusiva de la línea de caché.
  2. El consumidor recibe su línea de caché todavía en estado compartido y felizmente lee el valor.
  3. El consumidor continúa leyendo el valor compartido hasta que llega la solicitud exclusiva.
  4. En ese momento, el consumidor envía una solicitud compartida para la línea de caché.
  5. En este punto, el Consumidor borra sus instrucciones de la primera instrucción de carga no cumplida del valor compartido.
  6. Mientras el consumidor espera los datos, avanza especulativamente.

Por lo tanto, el consumidor puede avanzar en el período entre la obtención de su línea de caché compartida hasta que se invalide nuevamente. No está claro cuántas lecturas se pueden realizar al mismo tiempo, probablemente 2, ya que la CPU tiene 2 puertos de lectura. Y, apropiadamente, no es necesario volver a ejecutarlos tan pronto como el estado interno de la CPU esté satisfecho, no pueden fallar entre cada uno.

Mismo núcleo HT

Aquí los dos HT comparten el núcleo y deben compartir sus recursos.

La línea de caché debe permanecer en estado exclusivo todo el tiempo, ya que comparten el caché y, por lo tanto, no necesitan el protocolo de caché.

Ahora bien, ¿por qué se necesitan tantos ciclos en el núcleo HT? Comencemos con el consumidor recién leído el valor compartido.

  1. En el siguiente ciclo se produce una escritura desde Produces.
  2. El hilo del consumidor detecta la escritura y cancela todas sus instrucciones desde la primera lectura no cumplida.
  3. El consumidor vuelve a emitir sus instrucciones y tarda entre 5 y 14 ciclos en ejecutarse nuevamente.
  4. Finalmente, la primera instrucción, que es de lectura, se emite y ejecuta, ya que no leyó un valor especulativo sino uno correcto, ya que está al frente de la cola.

Entonces, por cada lectura del valor compartido, se reinicia el consumidor.

Conclusión

El núcleo diferente aparentemente avanza tanto cada vez entre cada ping pong de caché que funciona mejor que el HT.

¿Qué hubiera pasado si la CPU hubiera esperado para ver si el valor realmente había cambiado?

Para el código de prueba, la versión HT se habría ejecutado mucho más rápido, tal vez incluso tan rápido como la versión de escritura privada. El núcleo diferente no se habría ejecutado más rápido ya que la pérdida de caché cubría la latencia de reedición.

Pero si los datos hubieran sido diferentes, surgiría el mismo problema, excepto que sería peor para la versión principal diferente, ya que entonces también tendría que esperar la línea de caché y luego volver a emitir.

Entonces, si el OP puede cambiar algunas de las funciones, permitiendo que el productor de la marca de tiempo lea el contenido compartido y afecte el rendimiento, sería mejor.

Leer más aquí

Surt avatar Aug 12 '2017 22:08 Surt