原子操作的成本是多少?

107
原子操作(比如比较并交换或原子加/减)的成本是多少?它需要多少个周期?它会暂停SMP或NUMA上的其他处理器,还是会阻塞内存访问?它会刷新乱序CPU中的重排序缓冲区吗?
对缓存会有什么影响?
我对现代流行的CPU(如x86、x86_64、PowerPC、SPARC、Itanium)很感兴趣。

@Jason S,任何情况下,CAS 和原子增/减之间的差异都是微不足道的。 - osgx
2
在x86上的原子操作会随着对内存地址的争用增加而变慢。一般来说,它们比非锁定操作慢一个数量级,但显然这取决于操作、争用和内存屏障的使用情况。 - Stephen Nutt
在Java中,32位写操作是原子的,即它是可移植的原子操作(但没有内存屏障语义,因此对于指针来说通常不足够)。除非添加LOCK前缀,否则添加1通常不是原子操作。关于Linux内核,无需查看spin_unlock。请参见当前版本中的arch/x86/include/asm/atomic_32.h(以前是include/asm-i386/atomic.h)。 - Blaisorblade
@Blaisorblade,Java不在这里。LOCK操作的成本是多少? - osgx
Java是一个软件平台。我对硬件平台很感兴趣。在英特尔中写入双字是原子性的,但LOCK呢? - osgx
显示剩余2条评论
3个回答

68

过去几天我一直在寻找实际数据,但是没有找到。不过,我做了一些研究,比较了原子操作的成本和缓存未命中的成本。

x86 LOCK前缀的成本(包括用于原子CAS的lock cmpxchg),在PentiumPro之前(如文档所述),是一个内存访问(类似于缓存未命中),+停止其他处理器的内存操作,+与其他尝试锁定总线的处理器竞争。然而,自从PentiumPro以来,对于正常的写回可缓存内存(所有应用程序处理的内存,除非您直接与硬件交互),只有相关缓存行被阻塞(基于@osgx's answer中的链接)。

即核心延迟回答MESI共享和RFO请求,直到实际locked操作的存储部分完成。这称为“缓存锁定”,仅影响该缓存行。其他核心可以同时加载/存储或甚至CAS其他行。


实际上,CAS操作可能会更加复杂,如此页面所解释的那样,没有时间限制,但有一位可信赖的工程师提供了深入的描述。(至少对于常规用例,在实际执行CAS之前需要进行纯负载。)

在深入讨论之前,我要说的是,LOCK操作成本为一个缓存未命中+可能与同一缓存行上的其他处理器争用,而CAS操作+前面的负载(几乎总是需要除了在互斥锁上,这里您始终CAS 0和1)可以成本为两个缓存未命中。

他解释说,在单个位置上进行load + CAS实际上可能会成本为两个缓存未命中,就像Load-Linked/Store-Conditional一样(请参阅后者)。他的解释基于MESI缓存一致性协议的知识。它为缓存行使用4个状态:M(修改),E(独占),S(共享),I(无效)(因此称为MESI),必要时下面会进行解释。所述场景如下:

  • LOAD操作会导致缓存未命中 - 相关的缓存行从内存加载到共享状态(即其他处理器仍然允许将该缓存行保留在内存中;在此状态下不允许进行任何更改)。如果位置已经在内存中,这个缓存未命中将被跳过。可能的代价:1次缓存未命中。(如果缓存行处于共享、独占或修改状态,即数据在此CPU的L1缓存中,则跳过)。
  • 程序计算要存储的新值,
  • 并运行原子CAS指令。
    • 它必须避免并发修改,因此必须从其他CPU的缓存中删除缓存行的副本,将缓存行移动到独占状态。可能的代价:1次缓存未命中。如果它已经是独占所有权,即处于独占或修改状态,则不需要这样做。在这两种状态下,没有其他CPU持有缓存行,但在独占状态下,它尚未被修改(尚未)。
    • 在进行此通信后,变量在我们CPU的本地缓存中被修改,此时它对所有其他CPU都是全局可见的(因为它们的缓存与我们的缓存是一致的)。它最终将按照通常的算法写入主内存。
    • 尝试读取或修改该变量的其他处理器首先必须以共享或独占模式获取该缓存行,为此将与此处理器联系并接收缓存行的更新版本。相反,LOCKed操作只能产生缓存未命中(因为缓存行将直接请求处于独占状态)。
在所有情况下,缓存行请求都可能被已经修改数据的其他处理器阻塞。

为什么在其他CPU上更改状态会导致1个缓存未命中的开销? - osgx
1
因为它是 CPU 外部的通信,因此比访问缓存慢。当缓存未命中时,必须从其他 CPU 中传递。实际上,如果使用直接互连(例如 AMD Hypertransport(自很久以前)或基于 Nehalem 的最新 Xeon 处理器上的 Intel QuickPath Interconnect),与另一个 CPU 交流可能比与内存交流更快。否则,与其他 CPU 的通信将在与内存相同的 FSB 上发生。搜索维基百科上的 HyperTransport 和 Front Side Bus 以获取更多信息。 - Blaisorblade
2
真的吗?我习惯的数字是:缓存未命中需要100个周期,而上下文/特权切换(包括系统调用)需要数千个周期。 - Blaisorblade
2
缓存未命中不是几千个周期!它大约是100纳秒,通常相当于300-350个CPU周期... - user997112
@CarstenS:我认为它是在谈论@osgx答案中的书链接。但我不知道为什么;那个答案中引用的内容并没有真正支持这个答案所说的。 (不过,这个答案是正确的:lock操作只需要一个缓存锁。) - Peter Cordes
显示剩余2条评论

40
我使用以下设置进行了一些分析:测试机器(AMD Athlon64 x2 3800+)启动后,切换到长模式(中断被禁用),并在循环中执行感兴趣的指令,100次迭代展开和1,000次循环周期。循环体对齐到16字节。在循环之前和之后使用rdtsc指令测量时间。此外,还执行了一个没有任何指令的虚拟循环(每个循环迭代测量2个周期,其余测量14个周期),并将其结果从指令分析时间的结果中减去。
测量了以下指令:
- "lock cmpxchg [rsp - 8], rdx"(比较匹配和不匹配) - "lock xadd [rsp - 8], rdx" - "lock bts qword ptr [rsp - 8], 1"
在所有情况下,测量时间约为310个周期,误差约为+/- 8个周期。
这是在同一(缓存的)内存上重复执行的价值。如果有额外的缓存未命中,则时间会显著增加。此外,这是仅使用2个核心中的一个完成的,因此缓存是独占的,并且不需要进行缓存同步。
为了评估缓存未命中时锁定指令的成本,我在锁定指令之前添加了一个wbinvld指令,并将wbinvld加上一个add [rsp - 8],rax放入比较循环中。在两种情况下,成本约为每个指令对80,000个周期!在lock bts的情况下,时间差约为每个指令180个周期。
请注意,这是倒数吞吐量,但由于锁定操作是序列化操作,因此与延迟可能没有区别。
结论:锁定操作很重,但缓存未命中可能更重。 另外:锁定操作不会导致缓存未命中。它只能在缓存行不被独占时引起缓存同步流量。
要启动计算机,我使用了ReactOS项目的x64版本FreeLdr。以下是汇编源代码:
#define LOOP_COUNT 1000
#define UNROLLED_COUNT 100

PUBLIC ProfileDummy
ProfileDummy:

    cli

    // Get current TSC value into r8
    rdtsc
    mov r8, rdx
    shl r8, 32
    or r8, rax

    mov rcx, LOOP_COUNT
    jmp looper1

.align 16
looper1:

REPEAT UNROLLED_COUNT
    // nothing, or add something to compare against
ENDR

    dec rcx
    jnz looper1

    // Put new TSC minus old TSC into rax
    rdtsc
    shl rdx, 32
    or rax, rdx
    sub rax, r8

    ret

PUBLIC ProfileFunction
ProfileFunction:

    cli

    rdtsc
    mov r8, rdx
    shl r8, 32
    or r8, rax
    mov rcx, LOOP_COUNT

    jmp looper2

.align 16
looper2:

REPEAT UNROLLED_COUNT
    // Put here the code you want to profile
    // make sure it doesn't mess up non-volatiles or r8
    lock bts qword ptr [rsp - 8], 1
ENDR

    dec rcx
    jnz looper2

    rdtsc
    shl rdx, 32
    or rax, rdx
    sub rax, r8

    ret

谢谢!你能发布你的测试代码或者测试Core2/Core i3/i5/i7吗?在你的测试设置中,所有核心都被初始化了吗? - osgx
我添加了源代码。只初始化了一个核心。很想看看其他机器的结果。 - Timo
CLFLUSH应该比WBINVD更轻量地刷新缓存行,后者会刷新指令缓存,导致额外的缓存未命中。 - Peter Cordes
也许测试缓存行在共享状态下热的情况会很有趣。你可以通过让另一个线程使用纯负载读取它来实现这一点。 - Peter Cordes

5
在基于总线的SMP上,原子前缀LOCK确实会打开总线信号LOCK#。这将禁止总线上的其他CPU/设备使用它。
Ppro&P2书http://books.google.com/books?id=3gDmyIYvFH4C&pg=PA245&dq=lock+instruction+pentium&lr=&ei=_E61S5ehLI78zQSzrqwI&cd=1#v=onepage&q=lock%20instruction%20pentium&f=false第244-246页
锁定指令是串行化、同步操作... /关于乱序执行/ 锁定RMW/read-modify-write = 原子本身/指令确保处理器在执行它之前执行锁定指令之前的所有指令。 /关于尚未刷新的写入/ 它强制处理器中的所有已发布写入在执行下一条指令之前被刷新到外部内存。
/关于SMP/ 信号量在S状态的高速缓存中... 发出读取和无效事务以进行0字节的日期(这是相邻CPU中缓存行的共享副本的删除/)。

3
自1995年P6/Pentium Pro架构以来,基于总线的对称多处理(SMP)已不再使用。现在的LOCK指令不会每次都进行总线锁定,除非数据在缓存行上不对齐或存在缓存争用。请访问https://rigtorp.se/split-locks/以获取最新的数据。 - Arnaud Bouchez

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