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

时间:2017-08-10 00:37:29

标签: performance concurrency x86 hyperthreading

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

通常,这种(有意)共享是使用x86上使用lock前缀的原子操作实现的,这对lock前缀本身(即无竞争成本)都有相当广为人知的成本当缓存行实际共享(真实或false共享)时,还会产生额外的一致性成本。

这里我对生产 - 消费者成本感兴趣,其中单个线程P写入内存位置,另一个线程`C从内存位置读取,两者都使用 plain 读写。

在同一个套接字上的不同内核上执行此类操作的延迟和吞吐量是多少,并且在最近的x86内核上在同一物理内核上执行兄弟超线程时进行比较。

在标题中,我使用术语"超兄弟姐妹"引用在同一内核的两个逻辑线程上运行的两个线程,以及引用不同物理内核上运行的两个线程的更常见情况的内核兄弟。

2 个答案:

答案 0 :(得分: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;
}

使用以下命令编译,替换define:

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    $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 hogs并且两者都得到了公平的份额,因此生产者燃烧周期会使消费者减慢一半左右。这只是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
  • 当我们在同一个核心的同一个线程上安排两个线程时,没有任何影响。由于制作人的写作仍然是本地的,因此不会产生同步成本。

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

答案 1 :(得分:8)

杀手级问题是核心进行推测性读取,这意味着每次在推测读取地址之前(或者更准确地写入相同的缓存行)之前是#34 ;完成&#34;表示CPU必须撤消读取(至少如果你的x86),这实际上意味着它取消了该指令及之后的所有推测性指令。

在阅读退休之前的某个时刻,它会得到满足&#34;,即。没有任何指令可以失败,并且没有任何理由重新发布,并且CPU可以充当 - 如果它之前已经执行了所有指令。

其他核心示例

除了取消指令外,这些都在播放缓存乒乓,所以这应该比HT版本更糟。

让我们在流程中的某个时刻开始,其中包含共享数据的缓存行刚刚被标记为已共享,因为Consumer已要求读取它。

  1. 制作人现在想要写入共享数据,并发出对缓存行独占所有权的请求。
  2. 消费者收到他的缓存行仍处于共享状态,并愉快地读取该值。
  3. 在独家请求到达之前,消费者继续读取共享值。
  4. 此时,Consumer会发送缓存行的共享请求。
  5. 此时,消费者从共享值的第一个未履行的加载指令中清除其指令。
  6. 当消费者等待数据时,它会以推测方式提前运行。
  7. 因此消费者可以在它获得共享缓存行之间的时间段内前进,直到它再次失效。目前还不清楚可以同时完成多少次读取,最有可能是2,因为CPU有2个读取端口。一旦CPU的内部状态得到满足,它就不需要重新运行它们,它们不能在每个状态之间失败。

    相同的核心HT

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

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

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

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

      结论

      每个缓存乒乓之间的不同核心显然在每次缓存乒乓之间都取得了很大的进步,它的性能要好于HT。

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

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

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

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

      了解更多here