生产者 - 消费者在超兄弟姐妹与非兄弟姐妹之间共享内存位置的延迟和吞吐量成本是多少?

Bee*_*ope 17 concurrency performance x86 hyperthreading

单个进程中的两个不同线程可以通过读取和/或写入来共享公共存储器位置.

通常,这种(有意)共享是使用lockx86上的前缀使用原子操作实现的,该前缀对于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)

哪里:

  • CONSUMER_CPU是运行消费者线程的cpu的编号.
  • PRODUCER_CPU是运行生产者线程的cpu的编号.
  • SIZE是内循环的大小(缓存很重要)
  • 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>
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)
  • 当我们在同一个核心的同一个线程上安排两个线程时,没有任何影响.由于制作人的写作保持在本地,因此不会产生同步成本.

  • 我无法解释为什么我的超线程性能比两个核心差得多.建议欢迎.

  • 你可以看看uops_executed vs uops_retired (2认同)
  • @harold:可能还会看一下`machine_clears.memory_ordering`.由于消费者不使用`pause`,跑步消费者线程的CPU可能推测,它可以提前加载`shared`,并具有回滚时,发现`shared`的时候有不同的价值及其` data [idx]`load完成.(并且这些负载必须按顺序发生).它可能发生的原因之一是:`3.根据`ocperf.py list`的输出交叉SMT-HW-thread snoop(stores)命中加载缓冲区.(错误SKL089:收集载荷可能会计算不足,这不会影响此测试). (2认同)
  • @PeterCordes>你可以取得好成绩.`machine_clear.memory_ordering`对于2核是40M,对于1核,2线程情况是360M.为了获得更好的画面,我想在某些时候,必须用每秒固定数量的写入来重写不同的线程. (2认同)

Sur*_*urt 8

杀手级问题是内核进行推测性读取,这意味着每次在"满足"之前写入推测读取地址(或更准确地写入同一缓存行)意味着CPU必须撤消读取(至少如果你的x86),这实际上意味着它取消了该指令以及之后的所有推测性指令.

在读取退役之前的某个时刻它会"完成",即.没有任何指令可以失败,并且没有任何理由重新发布,并且CPU可以充当 - 如果之前已经执行了所有指令.

其他核心例子

这些除了取消指令之外还在播放缓存乒乓,因此这应该比HT版本更糟糕.

让我们在过程中的某个时刻开始,其中带有共享数据的缓存行刚刚被标记为共享,因为Consumer已经要求读取它.

  1. 生产者现在想要写入共享数据并发出对高速缓存行的独占所有权的请求.
  2. 消费者仍然在共享状态下接收他的缓存行,并愉快地读取该值.
  3. 在独占请求到达之前,消费者继续读取共享值.
  4. 消费者在此时发送缓存行的共享请求.
  5. 此时,消费者从共享值的第一个未实现的加载指令中清除其指令.
  6. 消费者等待数据提前投机.

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

相同的核心HT

在这里,两个HT共享核心并且必须共享其资源.

缓存行始终保持独占状态,因为它们共享缓存,因此不需要缓存协议.

现在为什么在HT核心上需要这么多周期呢?让我们从消费者开始阅读共享值开始.

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

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

结论

不同的核心每次在每个缓存乒乓之间显然都有很大的进步,它比HT的性能要好.

如果CPU等待查看该值是否实际发生了变化,会发生什么?

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

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

因此,如果OP可以更改某些角色,让时间戳生成器从共享中读取并获得性能影响,则会更好.

在这里阅读更多