Quali sono i costi di latenza e throughput della condivisione produttore-consumatore di una posizione di memoria tra iper-fratelli e non-iper-fratelli?

Due thread diversi all’interno di un singolo processo possono condividere una posizione di memoria comune leggendola e / o scrivendola.

Di solito, tale condivisione (intenzionale) viene implementata utilizzando le operazioni atomiche usando il prefisso di lock su x86, che ha costi abbastanza noti sia per il prefisso di lock stesso (cioè il costo uncontended) sia per i costi di coerenza aggiuntivi quando la linea di cache è effettivamente condiviso ( condivisione vera o falsa ).

Qui mi interessano i costi prodotti-consumatori in cui un singolo thread P scrive in una posizione di memoria, e un altro thread `C legge dalla posizione di memoria, sia usando letture semplici che scritture.

Qual è la latenza e la velocità effettiva di tale operazione quando eseguita su core separati sullo stesso socket, e in confronto se eseguita su hyperthreads di fratello sullo stesso core fisico, su core x86 recenti.

Nel titolo sto usando il termine “hyper-siblings” per fare riferimento a due thread in esecuzione sui due thread logici dello stesso core e ai fratelli inter-core per fare riferimento al caso più comune di due thread in esecuzione su core fisici diversi .

Ok, non sono riuscito a trovare alcuna fonte autorevole, quindi ho pensato di provarlo io stesso.

 #include  #include  #include  #include  #include  alignas(128) static uint64_t data[SIZE]; alignas(128) static std::atomic shared; #ifdef EMPTY_PRODUCER alignas(128) std::atomic unshared; #endif alignas(128) static std::atomic stop_producer; alignas(128) static std::atomic 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(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: " < 

Compilare con il seguente comando, sostituendo definisce:

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

Dove:

  • CONSUMER_CPU è il numero della CPU su cui eseguire il thread consumer.
  • PRODUCER_CPU è il numero della CPU su cui eseguire il thread del produttore.
  • SIZE è la dimensione del ciclo interno (importa per la cache)
  • LOOPS è, beh ...

Ecco i loop generati:

Filo del consumatore

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

Thread del produttore, con loop vuoto (nessuna scrittura shared ):

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

Thread del produttore, scrittura su shared :

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

Il programma conta il numero di cicli della CPU consumati, sul core del consumatore, per completare l'intero ciclo. Confrontiamo il primo produttore, che non fa altro che masterizzare i cicli della CPU, al secondo produttore, che interrompe il consumatore scrivendo ripetutamente in shared .

Il mio sistema ha un i5-4210U. Cioè, 2 core, 2 thread per core. Sono esposti dal kernel come Core#1 → cpu0, cpu2 Core#2 → cpu1, cpu3 .

Risultato senza avviare il produttore:

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

Risultati con produttore vuoto. Per operazioni 1G (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 

Come previsto, dal momento che entrambi i thread sono cpu hogs ed entrambi ottengono una quota equa, i cicli di produzione dei produttori rallentano il consumo di circa la metà. Questa è solo la contesa della cpu.

Con il produttore su cpu # 2, poiché non c'è interazione, il consumatore corre senza alcun impatto dal produttore che gira su un'altra CPU.

Con il produttore su cpu # 1, vediamo il hyperthreading al lavoro.

Risultati con produttore dirompente:

 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 
  • Quando pianifichiamo entrambi i thread sullo stesso thread dello stesso core, non c'è alcun impatto. Previsto di nuovo, poiché il produttore scrive rimanere locale, senza costi di sincronizzazione.

  • Non riesco davvero a spiegare perché ottengo prestazioni molto peggiori per l'hyperthreading rispetto a due core. Consiglio benvenuto

Il problema killer è che i core fanno letture speculative, il che significa che ogni volta che una scrittura sull’indirizzo di lettura speculativo (o più correttamente sulla stessa riga della cache) prima di essere “soddisfatta” significa che la CPU deve annullare la lettura (almeno se sei un x86), il che significa in effetti che annulla tutte le istruzioni speculative da quella istruzione e in seguito.

Ad un certo punto, prima che la lettura sia ritirata, viene “soddisfatta”, cioè. nessuna istruzione prima può fallire e non c’è più alcun motivo di riemettere, e la CPU può agire come se avesse già eseguito tutte le istruzioni precedenti.

Altro esempio principale

Questi stanno giocando alla cache ping pong oltre a cancellare le istruzioni, quindi questa dovrebbe essere peggio della versione HT.

Iniziamo ad un certo punto del processo in cui la linea della cache con i dati condivisi è stata appena contrassegnata come condivisa perché il Consumatore ha chiesto di leggerla.

  1. Il produttore ora vuole scrivere sui dati condivisi e invia una richiesta di proprietà esclusiva della linea cache.
  2. Il consumatore riceve la sua linea di cache ancora in stato condiviso e legge felicemente il valore.
  3. Il consumatore continua a leggere il valore condiviso fino all’arrivo della richiesta esclusiva.
  4. A questo punto il consumatore invia una richiesta condivisa per la riga della cache.
  5. A questo punto il consumatore cancella le sue istruzioni dalla prima istruzione di caricamento non soddisfatta del valore condiviso.
  6. Mentre il Consumatore attende i dati che procede in modo speculativo.

Così il consumatore può avanzare nel periodo che intercorre tra la linea cache condivisa fino alla sua invalidata di nuovo. Non è chiaro quante letture possono essere soddisfatte allo stesso tempo, molto probabilmente 2 come la CPU ha 2 porte di lettura. E non ha bisogno di rieseguirli non appena lo stato interno della CPU è soddisfatto, non possono fallire tra loro.

Lo stesso core HT

Qui i due HT condividono il nucleo e devono condividere le proprie risorse.

La linea della cache dovrebbe rimanere sempre nello stato esclusivo mentre condivide la cache e quindi non ha bisogno del protocollo della cache.

Ora perché ci vogliono così tanti cicli sul core HT? Iniziamo con il consumatore dopo aver letto il valore condiviso.

  1. Prossimo ciclo una scrittura dal Produces occures.
  2. Il thread Consumer rileva la scrittura e annulla tutte le sue istruzioni dalla prima lettura non soddisfatta.
  3. Il consumatore ri-emette le sue istruzioni prendendo ~ 5-14 cicli per eseguire nuovamente.
  4. Infine, la prima istruzione, che è una lettura, viene emessa ed eseguita poiché non ha letto un valore speculativo ma uno corretto come di fronte alla coda.

Quindi per ogni lettura del valore condiviso il consumatore viene resettato.

Conclusione

Il diverso core sembra avanzare tanto ogni volta tra ogni ping pong della cache che si comporta meglio di quello HT.

Cosa sarebbe successo se la CPU avesse aspettato di vedere se il valore fosse effettivamente cambiato?

Per il codice di prova la versione HT sarebbe stata eseguita molto più velocemente, forse anche più velocemente della versione di scrittura privata. Il core differente non avrebbe funzionato più velocemente poiché la cache miss copriva la latenza della ristampa.

Ma se i dati fossero stati diversi, si sarebbe verificato lo stesso problema, tranne che sarebbe stato peggiore per la diversa versione core, in quanto avrebbe dovuto anche attendere la linea cache e quindi riemettere.

Quindi, se l’OP può cambiare alcuni ruoli lasciando che il produttore di timestamp legga i dati condivisi e prenda il colpo sulle prestazioni, sarebbe meglio.

Leggi di più qui