producer-consumer 在 hyper-siblings 与 non-hyper 兄弟姐妹之间共享内存位置的延迟和吞吐量成本是多少?
What are the latency and throughput costs of producer-consumer sharing of a memory location between hyper-siblings versus non-hyper siblings?
单个进程中的两个不同线程可以通过读取and/or 写入来共享 公共内存位置。
通常,这种(有意的)共享是在 x86 上使用 lock
前缀使用原子操作实现的,lock
前缀本身(即,无竞争成本)以及当缓存行实际共享(真或false共享)时的额外一致性成本。
这里我对 produced-consumer 成本感兴趣,其中单个线程 P
写入内存位置,另一个线程`C 从内存位置读取,两者都使用 plain 读写。
在同一插槽上的不同内核上执行此类操作时的延迟和吞吐量是多少,以及在最近的 x86 内核上在同一物理内核上的同级超线程上执行时的比较。
在标题中,我使用术语 "hyper-siblings" 来指代同一核心的两个逻辑线程上的两个线程 运行,并使用 inter-core 兄弟来指代更常见的情况是两个线程 运行 在不同的物理内核上。
好吧,我找不到任何权威来源,所以我想我自己试一试。
#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;
}
使用以下命令编译,替换定义:
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 是内部循环的大小(缓存的问题)
- LOOPS 是,嗯...
这里是生成的循环:
消费者线程
400cc8: ba 80 24 60 00 mov [=12=]x602480,%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 [=12=]x8,%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 [=12=]x702480,%rdx
400ce8: 75 e6 jne 400cd0 <_ZL7consumePv+0x20>
400cea: 83 ee 01 sub [=12=]x1,%esi
400ced: 75 d9 jne 400cc8 <_ZL7consumePv+0x18>
生产者线程,空循环(不写入shared
):
400c90: c7 05 e6 16 20 00 00 movl [=13=]x0,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 [=14=]x0,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
当我们在同一个核心的同一个线程上调度两个线程时,没有影响。再次符合预期,因为生产者写入保持在本地,不会产生同步成本。
我真的无法解释为什么超线程的性能比双核差得多。欢迎指教
杀手问题是核心进行推测读取,这意味着每次写入推测读取地址(或更正确地写入 相同 缓存行)之前它是 "fulfilled" 意味着 CPU 必须撤消读取(至少如果你是 x86),这实际上意味着它取消了该指令和以后的所有推测指令。
在读取退出之前的某个时刻,它会得到 "fulfilled",即。 no instruction before can fail and no longer any reason to reissue, and the CPU can act as-if it had executed all instructions before.
其他核心示例
这些除了取消指令外还在玩缓存乒乓所以这应该比HT版本差。
让我们从流程中的某个点开始,其中包含共享数据的缓存行刚刚被标记为共享,因为消费者要求读取它。
- 生产者现在想要写入共享数据并发出对缓存行的独占所有权的请求。
- 消费者收到他的缓存行仍然处于共享状态并愉快地读取值。
- 消费者继续读取共享值,直到独占请求到达。
- 此时消费者发送缓存行的共享请求。
- 此时消费者从共享值的第一条未完成的加载指令中清除其指令。
- 当消费者等待数据时,它 运行 推测性地领先。
因此,消费者可以在获取共享缓存行之间的时间段内前进,直到其再次失效。目前还不清楚有多少读取可以同时完成,最有可能是 2,因为 CPU 有 2 个读取端口。一旦CPU的内部状态得到满足,它就不需要重新运行它们,它们不能在每个之间失败。
同核HT
这里两个HT共享核心,必须共享其资源。
缓存行应该一直保持独占状态,因为它们共享缓存,因此不需要缓存协议。
现在为什么在 HT 内核上需要这么多周期?让我们从刚刚读取共享值的消费者开始。
- 下一个周期从 Produces 发生写入。
- 消费者线程检测到写入并从第一个未完成的读取中取消其所有指令。
- 消费者重新发出其指令,需要 ~5-14 个周期才能再次 运行。
- 最后发出并执行了第一条指令,即读取,因为它读取的不是推测值,而是队列前面的正确值。
因此,对于每次读取共享值,消费者都会被重置。
结论
不同的核心显然每次在每次缓存乒乓之间都进步很多,以至于它比 HT 的表现更好。
如果 CPU 等着看值是否真的改变了会发生什么?
对于测试代码,HT 版本会 运行 快得多,甚至可能与私有写入版本一样快。不同的核心不会 运行 更快,因为缓存未命中覆盖了重新发布延迟。
但如果数据不同,就会出现同样的问题,只是对于不同的核心版本会更糟,因为它还必须等待缓存行,然后重新发布。
因此,如果 OP 可以更改一些角色,让时间戳制作者从共享中读取并承担性能损失,那会更好。
阅读更多here
单个进程中的两个不同线程可以通过读取and/or 写入来共享 公共内存位置。
通常,这种(有意的)共享是在 x86 上使用 lock
前缀使用原子操作实现的,lock
前缀本身(即,无竞争成本)以及当缓存行实际共享(真或false共享)时的额外一致性成本。
这里我对 produced-consumer 成本感兴趣,其中单个线程 P
写入内存位置,另一个线程`C 从内存位置读取,两者都使用 plain 读写。
在同一插槽上的不同内核上执行此类操作时的延迟和吞吐量是多少,以及在最近的 x86 内核上在同一物理内核上的同级超线程上执行时的比较。
在标题中,我使用术语 "hyper-siblings" 来指代同一核心的两个逻辑线程上的两个线程 运行,并使用 inter-core 兄弟来指代更常见的情况是两个线程 运行 在不同的物理内核上。
好吧,我找不到任何权威来源,所以我想我自己试一试。
#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;
}
使用以下命令编译,替换定义:
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 是内部循环的大小(缓存的问题)
- LOOPS 是,嗯...
这里是生成的循环:
消费者线程
400cc8: ba 80 24 60 00 mov [=12=]x602480,%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 [=12=]x8,%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 [=12=]x702480,%rdx
400ce8: 75 e6 jne 400cd0 <_ZL7consumePv+0x20>
400cea: 83 ee 01 sub [=12=]x1,%esi
400ced: 75 d9 jne 400cc8 <_ZL7consumePv+0x18>
生产者线程,空循环(不写入shared
):
400c90: c7 05 e6 16 20 00 00 movl [=13=]x0,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 [=14=]x0,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
当我们在同一个核心的同一个线程上调度两个线程时,没有影响。再次符合预期,因为生产者写入保持在本地,不会产生同步成本。
我真的无法解释为什么超线程的性能比双核差得多。欢迎指教
杀手问题是核心进行推测读取,这意味着每次写入推测读取地址(或更正确地写入 相同 缓存行)之前它是 "fulfilled" 意味着 CPU 必须撤消读取(至少如果你是 x86),这实际上意味着它取消了该指令和以后的所有推测指令。
在读取退出之前的某个时刻,它会得到 "fulfilled",即。 no instruction before can fail and no longer any reason to reissue, and the CPU can act as-if it had executed all instructions before.
其他核心示例
这些除了取消指令外还在玩缓存乒乓所以这应该比HT版本差。
让我们从流程中的某个点开始,其中包含共享数据的缓存行刚刚被标记为共享,因为消费者要求读取它。
- 生产者现在想要写入共享数据并发出对缓存行的独占所有权的请求。
- 消费者收到他的缓存行仍然处于共享状态并愉快地读取值。
- 消费者继续读取共享值,直到独占请求到达。
- 此时消费者发送缓存行的共享请求。
- 此时消费者从共享值的第一条未完成的加载指令中清除其指令。
- 当消费者等待数据时,它 运行 推测性地领先。
因此,消费者可以在获取共享缓存行之间的时间段内前进,直到其再次失效。目前还不清楚有多少读取可以同时完成,最有可能是 2,因为 CPU 有 2 个读取端口。一旦CPU的内部状态得到满足,它就不需要重新运行它们,它们不能在每个之间失败。
同核HT
这里两个HT共享核心,必须共享其资源。
缓存行应该一直保持独占状态,因为它们共享缓存,因此不需要缓存协议。
现在为什么在 HT 内核上需要这么多周期?让我们从刚刚读取共享值的消费者开始。
- 下一个周期从 Produces 发生写入。
- 消费者线程检测到写入并从第一个未完成的读取中取消其所有指令。
- 消费者重新发出其指令,需要 ~5-14 个周期才能再次 运行。
- 最后发出并执行了第一条指令,即读取,因为它读取的不是推测值,而是队列前面的正确值。
因此,对于每次读取共享值,消费者都会被重置。
结论
不同的核心显然每次在每次缓存乒乓之间都进步很多,以至于它比 HT 的表现更好。
如果 CPU 等着看值是否真的改变了会发生什么?
对于测试代码,HT 版本会 运行 快得多,甚至可能与私有写入版本一样快。不同的核心不会 运行 更快,因为缓存未命中覆盖了重新发布延迟。
但如果数据不同,就会出现同样的问题,只是对于不同的核心版本会更糟,因为它还必须等待缓存行,然后重新发布。
因此,如果 OP 可以更改一些角色,让时间戳制作者从共享中读取并承担性能损失,那会更好。
阅读更多here