x86架构中的“PAUSE”指令的目的是什么?

76
我正在尝试创建一个简单版本的自旋锁。在浏览网页时,我发现了x86中称为“PAUSE”的汇编指令,用于向处理器提示此CPU上当前正在运行自旋锁。英特尔手册和其他可用信息说明,大多数情况下,处理器使用此提示来避免内存顺序违规,从而极大地提高了处理器性能。因此,建议在所有自旋等待循环中放置PAUSE指令。文档还提到,“wait(一些延迟)”是该指令的伪实现。上面段落的最后一行很直观。如果我无法获取锁,则必须等待一段时间才能再次获取锁。
然而,在自旋锁的情况下,内存顺序违规是什么意思?“内存顺序违规”是否意味着自旋锁后的指令的不正确推测式加载/存储?
关于自旋锁的问题之前在Stack Overflow上已经问过,但内存顺序违规的问题仍未得到回答(至少对我来说是这样)。

英特尔文档链接:https://software.intel.com/en-us/download/intel-64-and-ia-32-architectures-software-developers-manual-volume-2b-instruction-set-reference-m-u - ahcox
2个回答

114

想象一下,处理器如何执行典型的自旋等待循环:

1 Spin_Lock:
2    CMP lockvar, 0   ; Check if lock is free
3    JE Get_Lock
4    JMP Spin_Lock
5 Get_Lock:
在经过几次迭代后,分支预测器会预测条件分支(3)永远不会被执行,并且流水线将填充CMP指令(2)。这种情况一直持续,直到另一个处理器向lockvar写入零。此时,我们的流水线充满了推断(即尚未提交)的CMP指令,其中一些已经读取了lockvar并向下一个条件分支(也是推断性的)报告了一个(不正确的)非零结果。这就是内存顺序违规发生的时候。每当处理器“看到”外部写入(来自另一个处理器的写入)时,它会在其流水线中搜索那些推测地访问相同内存位置并且尚未提交的指令。如果找到任何这样的指令,则处理器的推断状态无效,并使用流水线刷新来清除。
不幸的是,每当处理器在等待自旋锁时,这种情况(极有可能)会重复发生,并使得这些锁比应该慢得多。
接下来介绍PAUSE指令:
1 Spin_Lock:
2    CMP lockvar, 0   ; Check if lock is free
3    JE Get_Lock
4    PAUSE            ; Wait for memory pipeline to become empty
5    JMP Spin_Lock
6 Get_Lock:

PAUSE指令将“去流水线化”内存读取,以避免像第一个示例中那样充满假设的CMP (2)指令的流水线。 (即,它可能会阻止流水线,直到所有旧的内存指令都被提交。)由于CMP指令(2)按顺序执行,因此外部写入在CMP提交之前发生的时间窗口较短,可能性小得多。

当然,“去流水线化”也将在自旋锁中浪费更少的能量,在超线程的情况下,它不会浪费其他线程可以更好地利用的资源。 另一方面,在每个循环退出之前仍存在分支预测等待出现的情况。英特尔的文档并未建议PAUSE消除该流水线刷新,但谁知道呢...


8
正如 @Mackie 所说,管道将填充 cmp。当另一个核心写入时,Intel 将不得不刷新这些 cmp,这是一项昂贵的操作。如果 CPU 不刷新它,则会出现内存顺序违规。以下是此类违规的示例:
(此处开始 lock1 = lock2 = lock3 = var = 1)
线程1:
spin:
cmp lock1, 0
jne spin
cmp lock3, 0 # lock3 should be zero, Thread 2 already ran.
je end # Thus I take this path
mov var, 0 # And this is never run
end:

第二个线程:

mov lock3, 0
mov lock1, 0
mov ebx, var # I should know that var is 1 here.

首先,考虑线程1:

如果cmp lock1, 0; jne spin分支预测lock1不为零,则将cmp lock3, 0添加到流水线中。

在流水线中,cmp lock3, 0读取lock3并发现它等于1。

现在,假设线程1花费了很长时间,线程2开始快速运行:

lock3 = 0
lock1 = 0

现在,让我们回到线程1:
假设cmp lock1, 0最终读取了lock1,并发现lock1为0,对其分支预测能力感到满意。
这个命令被提交,没有刷新任何内容。正确的分支预测意味着不会刷新任何内容,即使有乱序读取也是如此,因为处理器推断出不存在内部依赖关系。在CPU的眼中,lock3与lock1无关,所以一切都是可以的。
现在,cmp lock3, 0正确读取到lock3等于1,被提交了。 je end未被执行,mov var, 0被执行。
在线程3中,ebx等于0,这应该是不可能的。这是英特尔必须补偿的内存顺序违规问题。
现在,英特尔为避免这种无效行为所采取的解决方案是刷新。当Thread 2上运行lock3 = 0时,它强制Thread 1刷新使用lock3的指令。在这种情况下,刷新意味着Thread 1将不会向流水线添加指令,直到使用lock3的所有指令被提交。在Thread 1的cmp lock3可以提交之前,必须提交cmp lock1。当cmp lock1尝试提交时,它读到lock1实际上等于1,并且分支预测失败了。这导致cmp被抛弃。现在Thread 1已经被刷新了,lock3在Thread 1的缓存中的位置被设置为0,然后Thread 1继续执行(等待lock1)。现在,Thread 2得知所有其他核心都刷新了对lock3的使用并更新了它们的缓存,因此Thread 2继续执行(在此期间它将执行独立语句,但下一个指令是另一个写操作,因此它可能需要挂起,除非其他核心有一个队列来保持挂起的lock1 = 0写操作)。
整个过程很昂贵,因此需要PAUSE。PAUSE帮助Thread 1,使其能够立即从即将发生的分支错误中恢复,并且在正确分支之前不必刷新流水线。PAUSE同样有助于Thread 2,使其不必等待Thread 1的刷新 (如前所述,我不确定此实现细节,但如果Thread 2尝试写入由太多其他核心使用的锁,则Thread 2最终必须等待刷新)。

一个重要的认识是,尽管在我的例子中需要刷新,但在Mackie的例子中则不需要。然而,CPU无法知道(它根本不分析代码,除了检查连续语句依赖关系和分支预测缓存),因此CPU会像在我的例子中一样刷新访问lockvar指令,在Mackie的例子中也是如此,以保证正确性。


1
我认为最好扩展wait(lock1)并填写Get_Lock。请参阅https://wiki.osdev.org/Spinlock。也许您可以更多地讨论有关加载和存储的内存顺序规则,并且执行乱序加载是一项必要的检查,以维护顺序。 - Hadi Brais
2
我认为 Mackie 给出的答案存在一个主要问题,即所有负载都定位在同一位置并属于同一指令。因此,在第一次排序中实际上不会进行重新排序。拥有两个不同的负载是一个现实的例子。 - Hadi Brais
1
即使分支错失不必从乱序核心的uops中丢弃任何内容,仍然需要重新引导前端。我认为关键是分支错失要便宜得多(因为CPU具有分支顺序缓冲区)比内存顺序错误或其他管道故障,这些故障完全刷新了像异常一样的管道。也就是说,分支误预测是可以预期并进行优化的。 - Peter Cordes
1
如果[...]分支预测lock1不为零——难道不应该是"lock1为零"吗? - janw
1
@DanielNitzan:好问题。可能管道-核弹逻辑有点保守,会在不检查可能与其重新排序的其他负载的情况下刷新管道。或者后面的负载触发了分支失误之前就被摧毁了?如果您旋转很长时间,那么在循环之前不会有任何负载在飞行中,在旋转循环之后的负载直到解决分支失误后才能获取。因此,也许核弹逻辑只是在某个负载退役时检查缓存行是否仍然有效? - Peter Cordes
显示剩余3条评论

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