在 x86中使用“ PAUSE”指令的目的是什么?

我正在尝试创建一个旋转锁的哑版本。在浏览网页时,我偶然发现了 x86中的一个名为“ PAUSE”的汇编指令,它用于向处理器提示当前在这个 CPU 上运行的自旋锁。情报手册和其他可用信息表明

处理器使用此提示来避免 在大多数情况下,这将大大提高处理器的性能 因此,建议在 文档中还提到“ wait (some 是指令的伪实现。

以上段落的最后一行是直观的。如果我没有抓住锁,我必须等待一段时间,然后再次抓住锁。

但是,在自旋锁的情况下,我们所说的内存顺序违反是什么意思呢? “违反内存顺序”是否意味着在旋转锁定之后不正确的 推测加载/存储指令?

自旋锁问题之前已经在堆栈溢出问题上提出过,但是内存顺序违反问题仍然没有得到回答(至少就我的理解而言)。

14041 次浏览

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

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 写入一个0。此时,我们的管道中充满了推测(即尚未提交) CMP 指令,其中一些指令已经读取了 lockvar,并向下面的条件分支(3)报告了一个(不正确的)非零结果(也是推测的)。这是内存顺序冲突发生的时候。每当处理器“看到”外部写操作(来自另一个处理器的写操作) ,它就在管道中搜索猜测访问了相同内存位置但尚未提交的指令。如果找到任何这样的指令,那么处理器的推测状态就是无效的,并且通过管道刷新被擦除。

不幸的是,每当处理器正在等待自旋锁时,这种情况将(很可能)重复出现,使得这些锁比它们应该的速度慢得多。

输入 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 指令(2)读取 lockvar 之后、 CMP 提交之前,不太可能发生外部写操作(即时间窗口要短得多)。

当然,“去管道化”也会在自旋锁中浪费更少的能量,而且在超线程的情况下,它不会浪费另一个线程可以使用得更好的资源。另一方面,在每个循环退出之前仍然有一个分支错误预测等待发生。Intel 的文档并没有说明 PAUSE 消除了管道刷新,但是谁知道呢..。

正如@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:

线程2:

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,所以这一切都没问题。

现在,正确读取 lock3等于1的 cmp lock3, 0提交。

没有采用 je end,而是执行 mov var, 0

在线程3中,ebx等于0。这本来是不可能的。这是 Intel 必须补偿的内存顺序违反。


现在,英特尔采取的避免这种无效行为的解决方案是冲水。当 lock3 = 0在线程2上运行时,它强制线程1刷新使用 lock3的指令。在这种情况下,刷新意味着线程1将不会向管道添加指令,直到提交了使用 lock3的所有指令。在线程1的 cmp lock3可以提交之前,cmp lock1必须提交。当 cmp lock1尝试提交时,它读取 lock1实际上等于1,并且分支预测失败。这导致 cmp被抛出。现在线程1已经刷新,将线程1缓存中的 lock3位置设置为 0,然后线程1继续执行(等待 lock1)。线程2现在得到通知,所有其他内核已经刷新了对 lock3的使用,并更新了它们的缓存,因此线程2继续执行(同时它将执行独立语句,但下一条指令是另一个写操作,所以它可能必须挂起,除非其他内核有一个队列来保存挂起的 lock1 = 0写操作)。

整个过程是昂贵的,因此需要暂停。PAUSE 有助于线程1,它现在可以立即从即将发生的分支错误预测中恢复,并且它不必在正确分支之前刷新其管道。PAUSE 同样有助于线程2,它不必等待线程1的刷新(如前所述,我不确定这个实现细节,但如果线程2尝试写入太多其他核使用的锁,线程2最终将不得不等待刷新)。

一个重要的理解是,虽然在我的例子中,同花是必需的,但在 Mackie 的例子中,它不是。然而,CPU 没有办法知道(它根本不分析代码,除了检查连续的语句依赖关系和一个分支预测缓存) ,所以 CPU 会像在我的例子中一样刷新访问 lockvar的指令,以保证正确性。