x86 中“PAUSE”指令的目的是啥?

Posted

技术标签:

【中文标题】x86 中“PAUSE”指令的目的是啥?【英文标题】:What is the purpose of the "PAUSE" instruction in x86?x86 中“PAUSE”指令的目的是什么? 【发布时间】:2012-10-05 08:03:26 【问题描述】:

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

处理器使用这个提示来避免内存顺序冲突 大多数情况下,这大大提高了处理器性能。为了 因此,建议将 PAUSE 指令置于 所有自旋等待循环。该文档还提到“等待(一些 delay)" 是指令的伪实现。

上一段的最后一行很直观。如果我没有成功抢到锁,我必须等待一段时间才能再次抢到锁。

但是,在自旋锁的情况下,内存顺序违规是什么意思? “内存顺序违规”是否意味着自旋锁后指令的错误推测加载/存储?

自旋锁问题之前在堆栈溢出时被问过,但内存顺序违规问题仍未得到解答(至少在我的理解中)。

【问题讨论】:

英特尔文档链接:software.intel.com/en-us/download/… 【参考方案1】:

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

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 并向下面的条件分支 (3)(也是推测的)报告了一个(不正确的)非零结果。这是发生内存顺序冲突的时候。每当处理器“看到”外部写入(来自另一个处理器的写入)时,它就会在其管道中搜索推测性地访问相同内存位置但尚未提交的指令。如果发现任何此类指令,则处理器的推测状态无效,并通过管道刷新擦除。

不幸的是,这种情况(很可能)会在每次处理器等待自旋锁时重复,并使这些锁比它们应有的速度慢得多。

输入暂停指令:

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 提交之前。

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

【讨论】:

【参考方案2】:

正如@Mackie 所说,管道将充满cmps。当另一个内核写入时,英特尔将不得不刷新那些cmps,这是一项昂贵的操作。如果 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,所以这一切都可以。

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

je end 未被占用,mov var, 0 被执行。

在线程 3 中,ebx 等于 0。这应该是不可能的。这是英特尔必须补偿的内存顺序违规。


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

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

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

【讨论】:

我觉得扩展wait(lock1)Get_Lock会更好。见https://wiki.osdev.org/Spinlock。也许您可以对有关加载和存储的内存顺序规则进行更多讨论,并且无序执行加载是需要检查以维护顺序的实现细节。 Mackie 回答的主要问题(我认为)是所有负载都到同一个位置并且属于同一个指令。所以实际上一开始就不会有重新排序。有两个不同的负载是一个现实的例子。 分支未命中仍然必须重新引导前端,即使他们不必丢弃核心无序部分的任何微指令。我认为关键是分支未命中(因为 CPU 具有分支顺序缓冲区)比内存顺序错误推测或其他管道核弹便宜得多,它们像异常一样完全刷新管道。即预期会出现分支错误预测并对其进行优化。 这仍然是一个很好的例子,但这不是我要解释的方式。我不确定这一切是否完全正确,但它至少给出了正在解决的基本问题的正确想法。 @PeterCordes 是的,我只是将错误预测作为额外的好处,但我在第 2 段到最后一段中添加了冲洗说明,以更明确地说明这是这里的主要收获。你会怎么解释?

以上是关于x86 中“PAUSE”指令的目的是啥?的主要内容,如果未能解决你的问题,请参考以下文章

"rep; nop;" 是啥意思?在 x86 程序集中是啥意思?它与“暂停”指令相同吗?

汇编指令JMP是啥意思?

CIL nop 操作码的目的是啥?

ARM与X86 CPU架构区别

计算机组成的一些总结X86指令简介

计算机组成的一些总结X86指令简介