Bee*_*ope 17 concurrency performance x86 hyperthreading
单个进程中的两个不同线程可以通过读取和/或写入来共享公共存储器位置.
通常,这种(有意)共享是使用lock
x86上的前缀使用原子操作实现的,该前缀对于lock
前缀本身(即,无竞争成本)具有相当广为人知的成本,并且当实际共享高速缓存行时还具有额外的一致性成本(真或假共享).
在这里,我对生产 - 消费者成本感兴趣,其中单个线程P
写入内存位置,另一个线程`C从内存位置读取,都使用普通读取和写入.
在同一个套接字上的不同内核上执行此类操作的延迟和吞吐量是多少,并且在最近的x86内核上在同一物理内核上执行兄弟超线程时进行比较.
在标题中,我使用术语"超级兄弟"来指代在同一核心的两个逻辑线程上运行的两个线程,以及核心间兄弟,以指代在不同物理核心上运行的两个线程的更常见情况.
spe*_*ras 10
好吧,我找不到任何权威来源,所以我想我自己试一试.
#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;
}
Run Code Online (Sandbox Code Playgroud)
使用以下命令编译,替换定义:
gcc -std=c++11 -DCONSUMER_CPU=3 -DPRODUCER_CPU=0 -DSIZE=131072 -DLOOPS=8000 timing.cxx -lstdc++ -lpthread -O2 -o timing
Run Code Online (Sandbox Code Playgroud)
哪里:
以下是生成的循环:
消费者线程
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>
Run Code Online (Sandbox Code Playgroud)
生产者线程,空循环(无写入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>
Run Code Online (Sandbox Code Playgroud)
制作人线程,写信给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>
Run Code Online (Sandbox Code Playgroud)
该程序计算消费者核心消耗的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
Run Code Online (Sandbox Code Playgroud)
空生产者的结果.对于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
Run Code Online (Sandbox Code Playgroud)
正如预期的那样,由于两个线程都是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
Run Code Online (Sandbox Code Playgroud)
当我们在同一个核心的同一个线程上安排两个线程时,没有任何影响.由于制作人的写作保持在本地,因此不会产生同步成本.
我无法解释为什么我的超线程性能比两个核心差得多.建议欢迎.
杀手级问题是内核进行推测性读取,这意味着每次在"满足"之前写入推测读取地址(或更准确地写入同一缓存行)意味着CPU必须撤消读取(至少如果你的x86),这实际上意味着它取消了该指令以及之后的所有推测性指令.
在读取退役之前的某个时刻它会"完成",即.没有任何指令可以失败,并且没有任何理由重新发布,并且CPU可以充当 - 如果之前已经执行了所有指令.
其他核心例子
这些除了取消指令之外还在播放缓存乒乓,因此这应该比HT版本更糟糕.
让我们在过程中的某个时刻开始,其中带有共享数据的缓存行刚刚被标记为共享,因为Consumer已经要求读取它.
因此消费者可以在它获得共享缓存行之间的时间段内推进,直到它再次失效.目前还不清楚可以同时完成多少次读取,最有可能是2,因为CPU有2个读取端口.一旦CPU的内部状态得到满足,它就不需要重新运行它们,它们不能在每个状态之间失败.
相同的核心HT
在这里,两个HT共享核心并且必须共享其资源.
缓存行应始终保持独占状态,因为它们共享缓存,因此不需要缓存协议.
现在为什么在HT核心上需要这么多周期呢?让我们从消费者开始阅读共享值开始.
因此,对于每次读取共享值,都会重置消费者.
结论
不同的核心每次在每个缓存乒乓之间显然都有很大的进步,它比HT的性能要好.
如果CPU等待查看该值是否实际发生了变化,会发生什么?
对于测试代码,HT版本的运行速度要快得多,甚至可能与私有写入版本一样快.由于缓存未命中覆盖了重发延迟,因此不同的核心不会运行得更快.
但是如果数据不同,则会出现同样的问题,除非对于不同的核心版本会更糟,因为它还必须等待缓存行,然后重新发布.
因此,如果OP可以更改某些角色,让时间戳生成器从共享中读取并获得性能影响,则会更好.
在这里阅读更多