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版本差。

让我们从流程中的某个点开始,其中包含共享数据的缓存行刚刚被标记为共享,因为消费者要求读取它。

  1. 生产者现在想要写入共享数据并发出对缓存行的独占所有权的请求。
  2. 消费者收到他的缓存行仍然处于共享状态并愉快地读取值。
  3. 消费者继续读取共享值,直到独占请求到达。
  4. 此时消费者发送缓存行的共享请求。
  5. 此时消费者从共享值的第一条未完成的加载指令中清除其指令。
  6. 当消费者等待数据时,它 运行 推测性地领先。

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

同核HT

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

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

现在为什么在 HT 内核上需要这么多周期?让我们从刚刚读取共享值的消费者开始。

  1. 下一个周期从 Produces 发生写入。
  2. 消费者线程检测到写入并从第一个未完成的读取中取消其所有指令。
  3. 消费者重新发出其指令,需要 ~5-14 个周期才能再次 运行。
  4. 最后发出并执行了第一条指令,即读取,因为它读取的不是推测值,而是队列前面的正确值。

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

结论

不同的核心显然每次在每次缓存乒乓之间都进步很多,以至于它比 HT 的表现更好。

如果 CPU 等着看值是否真的改变了会发生什么?

对于测试代码,HT 版本会 运行 快得多,甚至可能与私有写入版本一样快。不同的核心不会 运行 更快,因为缓存未命中覆盖了重新发布延迟。

但如果数据不同,就会出现同样的问题,只是对于不同的核心版本会更糟,因为它还必须等待缓存行,然后重新发布。

因此,如果 OP 可以更改一些角色,让时间戳制作者从共享中读取并承担性能损失,那会更好。

阅读更多here