前段时间,我在编码中看了一个热门部分,我看到了这个:
这让我思考了。此代码处于性能关键循环,看起来像浪费 - 我们从未与&#34一起运行;调试"启用标志[1]。如果基本上从未运行的话,是否可以拥有?当然,必须有一些表现成本......
在一般规则的日子里,一般规则是:一个完全可预测的分支已经接近零CPU成本。
这是什么程度的真实?如果一个分支很好,那么十几岁?一百?一千?如果陈述是一个坏主意的话,什么时候添加一个?
在某些时候,简单分支指示的可忽略费用肯定会增加大量。作为另一个例子,我的一位同事在我们的生产代码中找到了这个代码段:
const char * getCountry(int cc){if(cc == 1)返回" a1&#34 ;;如果(cc == 2)返回" a2&#34 ;; if(cc == 3)返回" o1&#34 ;;如果(cc == 4)返回"广告&#34 ;;如果(cc == 5)返回" ae&#34 ;;如果(cc == 6)返回" af&#34 ;;如果(cc == 7)返回" ag&#34 ;;如果(cc == 1)返回" ai&#34 ;; ...如果(cc == 252)返回" yt&#34 ;;如果(cc == 253)返回" za&#34 ;;如果(cc == 254)返回" zm&#34 ;;如果(cc == 255)返回" zw&#34 ;;如果(cc == 256)返回" xk&#34 ;;如果(cc == 257)返回" t1&#34 ;;返回"未知&#34 ;;}
显然,可以改进此代码[2]。但是当我想到它更多:应该改进吗?是否存在由一系列简单分支组成的代码的实际绩效?
我们必须使用一点理论开始旅程。我们想弄清楚分支的CPU成本是否增加,因为我们添加了更多。事实证明,评估分支的成本并不琐碎。在现代处理器上,它需要一个和二十个CPU周期。有至少四类的控制流程指令[3]:无条件分支(X86上的JMP),呼叫/返回,条件分支(例如,X86上)所采取的条件分支。采集的分支尤为问题:没有特别照顾,他们本质上是昂贵的 - 我们' ll在以下部分解释这一部分。降低成本,现代CPU'尝试预测未来,并在分支实际完全执行之前找出分支目标!这在称为分支预测器单元(BPU)的处理器的特殊部分中完成。
分支预测器尝试非常早期弄清楚分支指令的目的地,并且具有很少的背景。这种魔法发生在"解码器&#34之前发生了;管道阶段和预测器具有非常有限的数据可用。它只有一些过去的历史和当前指令的地址。如果你想到它 - 这是超级强大的。只有当前指令指针它可以评估,非常高的置信度,跳跃的目标是。
BPU维持几个数据结构,但今天我们' LL专注于分支目标缓冲区(BTB)。它' s一个地方,BPU记得先前分支的目标指令指针。整个机制更复杂,看看Vladimir Uzelac'硕士学位,了解有关CPU的分支预测的详细信息,从2008年开始:
对于本文的范围,我们' LL仅简化并专注于BTB。我们' ll试图展示它在不同条件下的表现是多大的。
但首先,为什么要使用分支预测?为了获得最佳性能,CPU流水线必须馈送恒定的指令流。考虑分支指令上的多级CPU管道发生的情况。为了说明让' s考虑以下arm程序:
在第一个周期中,BR指令被提取。这是一个无条件的分支指令,改变了CPU的执行流程。在这一点上它'尚未解码,但CPU希望取得另一个指令!如果在循环2中没有分支预测器2,则需要等待或简单地继续到内存中的下一个指令,希望它将是正确的。
在我们的示例中,即使此ISN' t正确的指令也被获取了指令X1。在循环4中,当分支指令完成执行阶段时,CPU将能够理解错误,并在具有任何效果之前重新推出推测说明。此时,更新了获取单元以在我们的情况下正确地获得正确的指令 - Y1。
由于从错误的地方获取代码而失去许多循环的情况被称为A"前泡泡"当一个分支目标未预定一个正确的右侧时,我们的理论CPU有一个双周期前端泡沫。
在这个例子中,我们看到了,虽然CPU到底是正确的,但没有良好的分支预测,它浪费了糟糕的指示。在过去,已经使用各种技术来减少这个问题,例如静态分支预测和分支延迟槽。但是今天的主导CPU设计依赖于动态分支预测。这种技术能够通过预测未完全解码和执行的分支即使对于未被完全解码和执行的分支也可以大多避免前端泡沫问题。
今天我们'重点关注BTB - 由分支预测器管理的数据结构,负责计算分支的目标。重要的是要注意,如果拍摄或未采取分支,BTB是不同的,并且独立于系统评估。请记住,我们想弄清楚分支的成本是否随着他们的运行而增加。
准备实验强调BTB相对简单(基于Matt Godbolt'工作)。事实证明了一系列无条件的JMPS完全足够了。考虑这个x86代码:
此代码将BTB强调为极端 - 它只是由JMP +2语句的链组成(即字面上跳到下一个指令)。为了避免前端管道气泡上的浪费循环,每次跳跃都需要一个BTB击中。在指令解码完成之前,该分支预测必须在CPU管道中非常早期发生。无论是何处,无论是何处,无论是无条件,条件还是函数调用,都需要相同的机制。
上面的代码在测试线束内运行,测量每个指令的每次CPU周期数。例如,在此运行中我们'重新测量密集的时间 - 每两个字节 - 1024 JMP指令一个接一个:
我们将在几个不同的CPU上看这样的实验结果。但在这种情况下,它在带有AMD EPY 3642的机器上运行。在这里,每JMP的冷运行需要10.5个周期,然后所有后续运行都是每JMP〜3.5周期。代码是以这样的方式编写的,以确保它'■在第一次运行时减慢的BTB。看看完整的代码,有很多魔法来热身L1缓存和ITLB而无需启动BTB。
顶部提示1.在此CPU上,拍摄的分支指令但未预测,成本〜7个周期超过一个被采用和预测的循环。即使分支是无条件的。
要收到完整的图片,我们还需要考虑代码中的JMP指令的密度。上面的代码每16字节代码块执行八个JMPS。这很多。例如,下面的代码包含16个字节的每个块中的一个JMP指令。请注意,NOP操作码跳过。块大小不会更改执行的指令的数量,只有代码密度:
改变JMP块大小可能很重要。它允许我们控制JMP操作码的放置。记住BTB由指令指针地址索引。其价值及其对齐可能会影响BTB中的放置,并帮助我们揭示BTB布局。增加对齐将导致添加更多NOP填充。单个测量指令的序列 - 在这种情况下,在这种情况下 - 以及零或更多NOPS,我将调用"块"及其尺寸"块大小"请注意,块大小越大,CPU的工作代码大小越大。在更大的值下,由于L1缓存空间耗尽,我们可能会看到一些性能下降。
我们的实验被设计为根据分支机构的数量来制作性能下降,以外的不同工作代码大小。希望,我们将能够证明性能大多依赖于块的数量 - 因此是BTB大小,而不是工作代码大小。
请参阅github上的代码。如果要查看生成的机器代码,则需要运行特殊命令。它'由代码进行程序创建,通过传递参数自定义。这里'举个例子gdb咒语:
让'我们带来了这个实验,如果我们花了每个运行的最佳时间 - 具有完全追加的BTB - 用于不同的JMP块大小和块数的不同值 - 工作集大小?干得好:
这是一个惊人的图表。首先,它明显的事情发生在4096 JMP标记[4]中发生的事情,无论JMP块大小如何大幅增加 - 我们跳过多少NOP'大声朗读:
在左边,我们看到,如果代码量足够小 - 小于2048字节(256倍为8个字节) - 它可能会击中某种UOP / L1缓存并获得〜1.5每个完全预测的分支周期。这真太了不起了。
否则,如果您将热门循环保留为4096分支机构,那么,无论您的代码多么密集,您都可能看到每个完全预测的分支的〜3.4周期
在4096上方分支分支预测器放弃,每个分支的成本射到每JMP的约10.5周期。这与我们所看到的内容符合 - Flashed BTB上的未预测分支率为约10.5周期。
很棒,所以这是什么意思?嗯,如果您想避免分支未命中,您应该避免分支指示,因为您拥有最多4096个快速BTB插槽。尽管如此,这不是一个非常务实的建议 - 它'不像我们故意把许多无条件的jmps放在真正的代码中!
讨论的CPU有几个外卖器。我重复了用始终拍摄的条件分支序列进行实验,并且结果图表看起来几乎相同。预测的唯一差异是由无条件-JE指令的差异比无条件JMP慢。
在分支机构&#34中添加了BTB的条目;拍摄" - 也就是说,跳跃实际上发生了。无条件" jmp"或者总是拍摄有条件的分支机构,将花费BTB插槽。为了获得最佳性能,确保在热回路中没有超过4096个分支。好消息是,分支从未拍过了在BTB中占用的空间。我们可以用另一个实验说明这一点:
这种无聊的代码未被拍摄的jne,后跟两个nops(块大小= 4)。针对这次测试(JNE从不采取),前一个(JMP始终采用)和一个条件分支JE始终拍摄,我们可以绘制此图表:
首先,没有任何惊喜,我们可以看到条件' je始终采取'比简单的无条件JMP越来越昂贵,但只有在4096分支标记之后。这是有道理的,条件分支稍后在管道中得到解决,因此前端泡沫更长。然后看看悬停在零附近的蓝线。这是" jne从不拍摄"无论我们按顺序运行多少个块,线路平面为0.3时钟/块。外带很清楚 - 你可以像你想要的那样多的树枝,没有任何成本。在4096标记中没有任何尖峰,这意味着在这种情况下没有使用BTB。似乎之前没有看到的条件跳跃被猜测被猜测。
顶端提示2:从未采取的条件分支基本上是免费的 - 至少在此CPU上。
到目前为止,我们建立了总是占用的分支机构,分支机构从未占用过。像呼叫一样的其他控制流指令怎么样?
我没有在文献中找到这个,但似乎呼叫/ ret也需要BTB条目以获得最佳性能。我能够在我们的AMD EPYC上说明这一点。让'看看这个测试:
这次我们' ll发出许多CallQ指令,后跟Ret - 这两者都应该完全预测。实验被制作,以便每个CallQ调用唯一的功能,以允许RETQ预测 - 每个返回到完全一个呼叫者。
该图表确认了理论:无论代码密度 - 除了64字节块大小外,均比64字节块大小 - 预测呼叫/ RET的成本开始在2048标记之后劣化。此时,BTB充满了呼叫和RET预测,可以' t处理任何数据。这导致了重要的结论:
顶尖提示3.在您想要的热代码中,您想要少于2k函数调用 - 在此CPU上。
在我们的测试CPU中,完全预测的呼叫/ RET序列需要大约7个周期,这与两个无条件预测的JMP操作码大致相同。它'与我们的结果一致。
到目前为止,我们彻底检查了AMD EPYC 7642.我们从这个CPU开始,因为分支预测器相对简单,并且易于阅读图表。事实证明,最近的CPU不太清晰。
较新的AMD比前几代更复杂。让' s运行两个最重要的实验。首先,JMP一:
对于始终采取的分支机构,我们可以看到一个非常好的,亚1周期,当分支的数量没有超过1024,代码是' t太密集了。
顶部提示4.在此CPU上,当热环合在〜32kib中时,可以获得'可以获得'每个预测的JMP。
然后在4096 jmps标记之后开始一些噪音。随后是一个大约6000个分支的完整速度。这符合BTB是4096条参赛作品的理论。我们可以推测一些其他预测机制在超出该方面成功踢球,并保持了〜6k标记的性能。
呼叫/ ret图表显示了类似的故事,定时在2048标记后开始断开,并且完全无法预测超过〜3000。
我们的测试显示预测的分支成本2周期。英特尔为非常密集的分支代码记录了一个时钟惩罚 - 这解释了在〜3个周期的4字节块大小线悬停。分支成本在4096 JMP标志处休息,确认英特尔BTB可以容纳4096个条目的理论。 64字节块大小图表看起来很困惑,但真的是' t。分支成本在平坦的2个循环中保持直到512 JMP计数。然后它增加了。这是由BTB的内部布局引起的,这被认为是8路联想。似乎具有64字节的块大小,我们可以在4096 BTB插槽的大部分中使用。
同样,我们可以看到2048 JMP标记后的分支预测失败 - 在此实验中,一个块使用两个流量控制指令:呼叫和RET。这再次确认了4K条目的BTB大小。由于NOP填充,64字节块大小通常会慢较慢,但由于指令对齐问题,此外,也会更快地打破。注意,我们没有看到对AMD的影响。
到目前为止,我们看到了AMD和英特尔服务器等级CPU的示例。 Apple Silicon M1如何适合这张照片?
我们希望它非常不同 - 它' S设计用于移动和它的使用ARM64架构。让我们看看我们的两个实验:
预测的JMP测试显示了一个有趣的故事。首先,当代码适合4096字节(1024 * 4或512 * 8等),您可以期望预测的JMP为成本1个时钟周期。这是一个出色的分数。
除此之外,通常,您可以预期每次预测JMP的3个时钟周期的成本。这也很好。当工作代码长到200kib时,这开始恶化。这是可见的,块尺寸64在3072标记3072 * 64 = 196K中断开,并且对于6144:6144 * 32 = 196K的块32。此时,预测似乎停止工作。该文档表明M1 CPU具有192 kB L1的指令缓存 - 我们的实验匹配。
让' s比较"预测的jmp"与"未预测的jmp"图表。用一粒盐拍摄这个图表,因为冲洗分支预测器是众所周知的。
然而,即使我们不信任刷新BPU代码(改编自Matt Godbolt),这个图表揭示了两件事。首先,"不受预期的"分支成本似乎与分支距离相关。它越来越久是昂贵的。我们在x86 CPU上看过这样的行为。
然后有成本本身。我们看到了预测的分支机构成本,以及据说 - 不预测的JMP成本。在第一个图表中,我们看到超过192kib的工作代码,分支预测器似乎无效。据推翻的BPU似乎显示了相同的成本。例如,具有小工作集大小的64字节块大小JMP的成本为3个周期。小姐是〜8周期。对于大型工作集尺寸,两次都是〜8个周期。似乎BTB链接到L1缓存状态。 Paul A. Clayton建议2016年的这种设计的可能性。
顶端提示6.在M1上,预测的分支通常需要3个周期,未受预测,但取决于JMP长度,成本具有不同的成本。 BTB可能与L1缓存相关联。
就像在图表之前,如果热代码适合4096字节(512 * 4或256 * 8),我们就可以看到大效益。否则,您可以根据每个呼叫/ RET序列(或者,BL / RET作为ARM中的&#39)计算4-6个周期。图表显示了有趣的对齐问题。 '尚不清楚他们是由此引起的。请注意,使用x86比较此图表中的数字是不公平的,因为ARM呼叫操作基本上与x86变体不同。
M1似乎很快,预测分支通常在3个时钟周期。即使是未预测的分支也从来没有在我们的基准中花费超过8次。密集代码的呼叫+ RET序列应适用于5个周期。
我们从一块微不足道的代码开始旅程,并询问了一个基本问题:在代码的热部分中添加了非从未拍摄的分支的成本如何?
然后我们在非常低的CPU功能中快速潜水。在本文结束时,希望精明的读者可能会更好地直觉,如何现代分支预测器如何运作。
在x86上,热代码需要将BTB预算拆分在函数调用和分支之间。 BTB只有4096个条目的大小。在16kib下保持热电代码存在强烈的好处。
另一方面,在M1上,BTB似乎受到L1指令缓存的限制。如果您'重新编写超级热代码,理想情况下,它应该适合4kib。
最后,如果声明,你可以再添加这个吗?如果它'从未拍摄,它可能是好的。我发现没有证据表明这些分支机构会产生任何额外费用。但是避免始终采取的分支机构和职能调用。
我不是第一个调查BTB如何工作的人。我基于我的实验:
哦,哦。但这不是整个故事!类似的研究是幽灵v2攻击的基础。该攻击正在利用与上下文交换机之间未清除BPU状态的鲜为人知的事实。通过正确的技术,可以训练BPU - 在幽灵的情况下,它是IBTB - 并强制授权被引导地执行的特权代码。这与缓存侧通道数据泄漏结合,允许攻击者从特权内核中窃取秘密。强大的东西。
提出的解决方案是避免使用共享的BTB。这可以通过两种方式完成:使间接跳转始终无法预测,或修复CPU以避免跨隔离域共享BTB状态。这是一个很长的故事,也许是另一个时间......
编程AMD EPYC速度&可靠性深度潜水