超线程兄弟姐妹间与非超线程兄弟姐妹间共享内存位置的生产者-消费者延迟和吞吐成本有何区别?

26

一个进程内的两个不同线程可以通过读取和/或写入共享内存位置来共享该位置。通常,这种(有意的)共享是使用x86上的lock前缀实现的原子操作,对于lock前缀本身(即未争用的成本)以及在实际共享缓存行时(真正的或共享)的额外一致性成本都有相当熟知的成本。

我主要关心的是生产者-消费者模式下的成本,其中单个线程P写入内存位置,另一个线程`C从内存位置读取,两者都使用普通的读取和写入操作。

在同一物理芯片上的不同核心上执行此操作时的延迟和吞吐量,以及在同一物理核心的超线程上执行此操作时的比较,在近期的x86核上如何。

标题中我使用“超线程同胞”一词来指代运行在同一核心的两个逻辑线程,而使用“跨核同胞”来指代通常情况下在不同物理核心上运行的两个线程。


我是否漏掉了什么?我认为将P-C放在不同的核心中会使它们的缓存行在S-M和S-I状态之间来回切换。这似乎非常昂贵(特别是如果没有L3),而且我认为延迟无法隐藏在P中。如果使用lock前缀,在C中只有一个dep.链,那么延迟可能无法被隐藏。我认为您在这方面非常有知识,并且您肯定可以自己测量延迟/吞吐量,所以我必须漏掉了一些东西才能完全理解这个问题。是什么呢? :) - Margaret Bloom
@MargaretBloom - 的确,如果没有人进行测量(看起来我们已经有一个志愿者了!),我的计划就是自己进行测量,但我认为这个问题非常有趣,值得探讨一下。你说得对,我希望在内核间共享时会很昂贵(虽然现在很少有x86芯片缺乏L3),但问题的关键在于是否在超线程兄弟姐妹中真的很便宜,因为所有东西都是本地的。从直觉上来看,考虑硬件,答案应该是肯定的(至少对于吞吐量),但我不是完全确定。 - BeeOnRope
例如,我非常确定超线程不能窥探彼此的存储缓冲区(尽管从硬件角度来看这是自然的,但它会破坏x86内存排序中微妙的IRIW规则),因此延迟可能受到存储在存储缓冲区中的存储器寿命的下限的限制。这个问题源于这里的讨论。 - BeeOnRope
@MargaretBloom 和 Bee:没有大型包容性 L3 的 x86 CPU 大多是 AMD 使用 MOESI 协议,因此它们可以在缓存之间转发脏数据,而不是通过大型包容性 L3 进行同步。我认为我曾经读过,在 AMD Bulldozer 家族之间共享线程的最佳情况可能比在 Intel 上更好。我忘记了 Ryzen 是什么样子,但它也是不同的。(当然还支持实际的 SMT)。 - Peter Cordes
2个回答

14

好的,我没有找到任何权威来源,所以我想自己试一试。

#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    $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>

生产者线程,向共享内存写入:

  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占用程序并且都得到了公平份额,生产者烧掉的循环会使消费者变慢约一半。这只是CPU争用。

当生产者在第2个CPU上运行时,由于没有交互,消费者可以在另一个CPU上运行而不受到生产者影响。

当生产者在第1个CPU上运行时,我们可以看到超线程的工作效果。

有干扰的生产者的结果:

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
  • 当我们将两个线程调度到同一个核心的同一个线程上时,不会产生影响。再次预期,由于生产者写入仍然是本地的,因此不会产生同步成本。

  • 我无法真正解释为什么在超线程方面性能比两个核心要差得多。欢迎建议。


2
你可以查看uops_executed与uops_retired的比较。 - harold
2
@harold:可能还要看一下machine_clears.memory_ordering。由于消费者不使用pause,运行消费者线程的CPU可能会猜测它可以提前加载shared,并且在发现shared的值在其data[idx]加载完成时已经改变时必须回滚。(这些加载必须按顺序进行)。其中一个原因是:根据ocperf.py list的输出,3.跨SMT-HW线程嗅探(存储)命中加载缓冲区。(勘误SKL089:它可能会低估聚集负载,但不影响此测试)。 - Peter Cordes
2
@PeterCordes> 你可能有一个好的线索。对于2核心,machine_clear.memory_ordering是40M,对于1核心,2线程情况下为360M。为了更好地了解情况,我猜测某个时候需要使用固定每秒写入次数的方式来重写干扰线程。 - spectras
1
顺便说一下,针对之前的评论,我在每个全局变量之间添加了64字节的填充(将它们移动到一个具有虚拟字段和检查顺序的静态结构体中),并获得了相同的结果。 - spectras
1
我在查看您在编辑历史记录中的原始mfence版本。尝试与写入非共享位置的生产者进行比较,而不是与空生产者进行比较。空生产者可能会因未运行存储地址和存储数据uop而具有稍低的HT影响。并且由于不太有效的循环结构(未采用测试/ jcc然后是jmp),因此效率较低。也许更改为do {} while(!stop_producer)结构以帮助编译器制作更简单的asm循环。 - Peter Cordes
显示剩余17条评论

11
核心问题在于,内核进行推测读取,这意味着每次写入到推测读取地址(或更正确地说,同一缓存行)之前,必须撤消该读取(至少对于 x86),这实际上意味着它将取消所有指令以及后面的所有推测指令。

在读取完成之前的某个时间点,即没有任何指令失败且不再需要重新发出请求时,该读取就会被“完成”,CPU 可以表现得好像它已经执行了所有指令。其他核心的例子是这些核心除了撤销指令外,还在玩“缓存乒乓”,因此应该比 HT 版本更糟糕。假设在共享数据的缓存行刚标记为共享状态时开始,在生产者发送请求以独占拥有缓存行时:
  1. 生产者现在想要写入共享数据并发送独占拥有缓存行的请求。
  2. 使用者接收其处于共享状态的缓存行并愉快地读取该值。
  3. 使用者继续读取共享值,直到独占请求到达。
  4. 此时,使用者发送缓存行的共享请求。
  5. 此时,使用者清除其第一个未满足的读取指令之后的所有指令。
  6. 在使用者等待数据时,它可以在推测执行期间继续运行。
因此,在使用者获取其共享缓存行直到其再次失效之间,使用者可以向前推进。不清楚可以同时完成多少读取操作,最可能为 2,因为 CPU 有 2 个读取端口。并且一旦 CPU 的内部状态得到满足,它们就无法在各自之间失败,因此可能不需要重新运行它们。

同一核心 HT 版本,这里的两个 HT 共享核心并必须共享其资源。缓存行应始终保持独占状态,因为它们共享缓存,因此不需要缓存协议。现在为什么在 HT 核心上需要这么多周期?让我们以使用者刚刚读取共享值为例。
  1. 接下来的周期中,生产者会进行一次写入。
  2. 消费者线程检测到写入并取消了所有未完成的读取指令。
  3. 消费者重新发出其指令,需要约5-14个周期才能再次运行。
  4. 最后,第一个指令(读取)被发出并执行,因为它没有读取到推测值,而是正确排在队列前面。

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

结论

显然,不同的核心每次在缓存 ping pong 之间都有很大进展,因此它的性能比 HT 版本更好。

如果 CPU 等待查看值是否实际更改会发生什么?

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

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

因此,如果 OP 可以更改某些角色,让时间戳生产者从共享读取并承担性能损失将会更好。

阅读更多这里


是的,我也考虑过这个问题,但很遗憾它必须出现在消费者端,而这正是我不想减慢的一侧。我想不到任何能帮助生产者端的东西,除了某种形式的“无序存储”,但我们拥有的那些(NT 存储器)意味着将 L1(及更高级别)中的行刷新。也许我应该花点心思制作一个更现实的案例,既读取密度较低,写入密度也较低,但似乎机器清除不会真正消失,只会减少频率。 - BeeOnRope
1
@BeeOnRope> 所以最后,我认为对于您的问题真正有趣的是比较另一个核心干扰主核心与让主核心进行计算之间的性能影响。如果它足够简单,很可能您只需让一个线程完成所有操作即可获得最佳性能。我想尝试两种方法并对它们进行基准测试,我的综合测试无法像测试您的实际代码一样准确 :) - spectras
@spectras - 是的,完全正确。内核负载缓冲区嗅探的影响对我来说是意外的,并且对于您的测试来说非常大,但我不知道它在更分散的写入方面会如何发挥作用。 - BeeOnRope
@BeeOnRope> 我认为这是一个内存排序问题,可能会引入一些抖动。在最坏的情况下,主线程读取值时恰好被另一个线程写入,导致完全的性能影响,就像我的测试结果一样。另一方面,如果在写入线程更改值时,该值的读取不在进行中,则根本不会有性能影响。有趣的是,这意味着潜在的影响将随着新型处理器的推出而变得更大,因为它们能够进行更多的推测执行。 - spectras
我想知道是否有足够的使用情况来制作一个只读取当前值而不回溯的加载指令。目前的情况使得所有线程间通信都非常昂贵。 - Surt
显示剩余4条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接