生产者 – 消费者在超级同胞与非超级同胞之间共享内存位置的延迟和吞吐成本是多less?

单个进程中的两个不同的线程可以通过读取和/或写入共享一个共同的内存位置。

通常,这种(有意的)共享是通过使用x86上的lock前缀的primefaces操作来实现的,这对于lock前缀本身(即,无争议的成本)具有相当公知的成本,并且当高速caching行实际上具有额外的一致性成本共享 (真实或虚假分享)。

在这里,我感兴趣的是单线程P写入内存位置的产生消费者成本,另一个线程`C从内存位置读取,都使用普通读写。

在同一个套接字的不同内核上执行这种操作时的延迟和吞吐量是多less,而在最近的x86内核上的同一个物理内核上的同级超线程上执行时,则是​​如此。

在标题中,我使用的术语“超级同胞”是指在同一个内核的两个逻辑线程上运行的两个线程,而内核之间的同级是指在不同的物理内核上运行的两个线程的更常见的情况。

好吧,我找不到任何权威的来源,所以我想我会自己去。

 #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; } 

使用以下命令编译,replace定义:

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

哪里:

  • CONSUMER_CPU是运行消费者线程的CPU的编号。
  • PRODUCER_CPU是运行生产者线程的CPU的编号。
  • SIZE是内部循环的大小(对于caching很重要)
  • LOOPS是…

这里是生成的循环:

消费者线程

  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> 

生产者线程,空循环(不写入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> 

生产者线程,写入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> 

该程序计算消费者核心所消耗的CPU周期数,以完成整个循环。 我们比较第一个生产者,它什么也不做,只是刻录CPU周期,第二个生产者通过反复写入shared来破坏消费者。

我的系统有一个i5-4210U。 也就是说,2个核心,每个核心2个线程。 它们被内核暴露为Core#1 → cpu0, cpu2 Core#2 → cpu1, cpu3

结果没有启动生产者:

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

结果与空的生产者。 对于1G操作(1000 * 1M或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 

正如预期的那样,由于这两个线程都是cpu猪,都获得了公平的份额,生产者的燃烧循环使消费者减慢了大约一半。 这只是CPU争夺。

在cpu#2上的生产者,由于没有交互作用,消费者在运行在另一个cpu上的生产者没有任何影响的情况下运行。

有了CPU#1的制作人,我们看到了超线程在工作。

破坏性生产者的结果:

 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 
  • 当我们将两个线程安排在同一个内核的同一线程上时,不会有任何影响。 预计再次,由于生产者写保持本地,不产生同步成本。

  • 我无法真正解释为什么我的超线程性能比两个内核的性能差得多。 build议欢迎。

杀手问题是核心进行推测性读取,这意味着每次在“满足”之前写入推测性读取地址(或者对于相同的高速caching行更正确)意味着CPU必须撤消读取(至less如果你是一个x86),这实际上意味着它从该指令中取消所有的推测性指令。

在阅读退役之前的某个时刻,它会被“履行”,即。 之前没有任何指令可能会失败,并且不再有任何理由重新发布,并且CPU可以充当 – 如果之前已经执行了所有指令的话。

其他核心例子

除了取消指令之外,这些还在播放caching乒乓,所以这应该比HT版本更差。

让我们从具有共享数据的caching行被标记为共享的过程开始,因为消费者已经请求读取它。

  1. 生产者现在想要写共享数据,并发出caching行独占所有权的请求。
  2. 消费者收到他的caching线仍处于共享状态,并愉快地读取值。
  3. 消费者继续读取共享值,直到独占请求到达。
  4. 消费者在哪一点发送caching行的共享请求。
  5. 在这一点上,消费者从共享价值的第一个未实现的加载指令中清除它的指令。
  6. 当消费者等待数据时,这些数据就会投机推行。

所以消费者可以在它获得共享caching行之间的时间段内前进,直到它再次失效。 目前还不清楚同时可以实现多less个读取,最有可能是2个,因为CPU具有2个读取端口。 一旦CPU的内部状态得到满足,它就不需要重新运行它们,它们不能在它们之间不能失效。

同样的核心HT

这里两个HT分享核心,必须分享资源。

caching行应该一直保持独占状态,因为它们共享caching,因此不需要caching协议。

现在为什么在HT内核上需要这么多的周期呢? 让消费者开始阅读共享价值。

  1. 接下来循环来自Produces发生的写入。
  2. 消费者线程检测到写入,并从第一个未完成的读取中取消其所有指令。
  3. 消费者重新发布约5-14个周期的指令再次运行。
  4. 最后,读取的第一条指令被发出并执行,因为它没有读取推测值,而是读取队列前的正确值。

因此,对于每次读取共享值,消费者都将被重置。

结论

每个caching乒乓之间,不同的内核显然都有很大的提升,比HT更好。

如果CPU等待这个值是否真的发生了变化,会发生什么?

对于testing代码,HT版本的运行速度要快得多,甚至可能和私有写入版本一样快。 由于高速caching未命中覆盖重发延迟,所以不同核心不会跑得更快。

但是,如果数据不同,则会出现相同的问题,除了不同的核心版本会更糟糕,因为这样还需要等待caching行,然后重新发布。

所以如果OP可以改变一些angular色让时间戳生产者从共享中读取并且使性能达到最好。

在这里阅读更多