【问题标题】:Modern CPU Inner Loop Indirection Optimizations现代 CPU 内循环间接优化
【发布时间】:2011-03-30 02:41:27
【问题描述】:

来自http://www.boost.org/community/implementation_variations.html

"...编码差异,例如将类从虚拟成员更改为非虚拟成员或删除间接级别,除非在内部循环深处,否则不太可能产生任何可衡量的差异。即使在内部循环中,现代 CPU经常在相同数量的时钟周期内执行这样的竞争代码序列!”

我试图理解“即使在内部循环中”部分。具体来说,CPU 实现了哪些机制来在相同数量的时钟周期内执行两个代码(虚拟与非虚拟或额外的间接级别)?我知道指令流水线和缓存,但是如何在与非虚拟调用相同的时钟周期内执行虚拟调用?间接如何“丢失”?

【问题讨论】:

    标签: c++ performance cpu-registers


    【解决方案1】:

    流水线是主要方式。

    加载一条指令、对其进行解码、执行其操作和加载间接内存引用可能需要 20 个时钟周期。但是由于流水线的存在,处理器可以在流水线的不同阶段同时执行 19 条其他指令的一部分,从而在每个时钟周期提供 1 条指令的总吞吐量,而不管通过流水线提供该指令实际需要多长时间。

    【讨论】:

    • 我不确定我是否完全同意这里。虽然流水线分摊了指令的成本,但它们(单独)不能完全消除它们。因此,在仅流水线的 CPU 中,加载+加载+分支总是比较短的指令序列(例如,只是一个分支)花费更多的时间。鉴于,流水线结构简化了其他内容的包含,例如通过分支预测的指令消除/折叠……并且使用 OOO,您可以更接近理想的 1cyc/instr……但这似乎是流水线之外的额外内容。这可能只是我在争论的语义。 =)
    • 在所有流水线步骤中某些指令不会做任何事情并不重要。关键是,无论一条指令多么简单或复杂,一条指令都会到达流水线的末端,因此每个时钟周期都会“完成”。
    • 超标量设计也起作用。允许内核在每个周期内退出多条指令。
    • @Hans:是的,这就是我想要的。流水线分摊了负载等的成本,但是 3 条指令仍然需要比 1 条更多的时间来退出,因为每个周期不能退出超过一条指令。直接分支和间接 deref 和分支都具有足够深的流水线,速度很快,但如果无法将指令成本降低到每条 1 个周期以下,那么一个仍然可能比另一个更快。
    【解决方案2】:

    缓存(例如branch target caching)、并行加载单元(流水线的一部分,还有诸如“命中未命中”之类不会使流水线停滞的东西)和out-of-order execution 可能有助于转换load -load-branch 变成更接近固定branch 的东西。流水线的解码或分支预测阶段的指令折叠/消除(正确的术语是什么?)也可能有所贡献。

    所有这些都依赖于很多不同的东西,但是:有多少不同的分支目标(例如,你可能触发多少不同的虚拟重载),你循环了多少东西(是分支目标缓存“温暖”? icache/dcache 怎么样?),虚拟表或间接表如何在内存中布局(它们是缓存友好的,还是每个新的 vtable 加载都可能驱逐旧的 vtable?),缓存是否失效由于多核乒乓等原因反复...

    (免责声明:我绝对不是这里的专家,我的很多知识都来自研究有序嵌入式处理器,所以其中一些是外推。如果您有更正,请随时发表评论!)

    确定特定程序是否会出现问题的正确方法当然是分析。如果可以,请在硬件计数器的帮助下这样做——它们可以告诉您很多关于管道各个阶段的情况。


    编辑:

    正如 Hans Passant 在上述评论 Modern CPU Inner Loop Indirection Optimizations 中指出的那样,让这两件事花费相同时间的关键是能够在每个周期有效地“退出”多条指令。指令消除可以对此有所帮助,但superscalar design 可能更重要(未命中命中是一个非常小且具体的示例,完全冗余的负载单元可能更好)。

    让我们假设一个理想的情况,假设直接分支只是一条指令:

    branch dest
    

    ...间接分支是三个(也许你可以得到两个,但它大于一个):

    load vtable from this
    load dest from vtable
    branch dest
    

    让我们假设一个绝对完美的情况:*this 和整个 vtable 都在 L1 缓存中,L1 缓存的速度足以支持两次加载的每条指令成本分摊一个周期。 (您甚至可以假设处理器对负载重新排序并将它们与先前的指令混合在一起,以便它们有时间在分支之前完成;对于这个示例来说无关紧要。)还假设分支目标缓存是热的,并且没有管道分支的刷新成本,并且分支指令归结为单个周期(摊销)。

    因此,第一个示例的 理论最短时间是 1 个周期(已摊销)。

    第二个示例的理论最小值是 3 个周期(有 3 条指令),不存在指令消除或冗余功能单元或允许每个周期停用超过一条指令的东西!

    间接加载总是会比较慢,因为有更多的指令,直到你进入像超标量设计这样的东西,它允许每个周期引退多条指令。

    一旦你有了这个,这两个示例的最小值再次变为 0 到 1 个周期之间,前提是其他一切都是理想的。可以说,与第一个示例相比,第二个示例需要更理想的环境才能实际达到理论最小值,但现在有可能。

    在您关心的某些情况下,您可能不会达到这两个示例的最低要求。要么分支目标缓存变冷,要么 vtable 不在数据缓存中,要么机器无法重新排序指令以充分利用冗余功能单元。

    ...这就是分析的用武之地,无论如何这通常是个好主意。

    可以首先支持对虚拟的轻微偏执。请参阅Noel Llopis's article on data oriented design、优秀的Pitfalls of Object-Oriented Programming slidesMike Acton's grumpy-yet-educational presentations。现在,如果您正在处理大量数据,您突然进入了 CPU 可能已经满意的模式。

    像虚拟这样的高级语言特性通常是表达性和控制力之间的权衡。不过,老实说,我认为,只要提高你对 virtual 实际在做什么的认识(不要害怕不时阅读反汇编视图,并且一定要看看你的 CPU 的架构手册),你就会倾向于使用它什么时候有意义,什么时候没有意义,如果需要,分析器可以覆盖其余部分。

    关于“不使用虚拟”或“虚拟使用不太可能产生可衡量的差异”的千篇一律的陈述让我不高兴。现实通常更复杂,要么你将处于足够关心或避免它的情况,要么你处于另外 95% 的情况下,除了可能的教育内容外,它可能不值得关心。

    【讨论】:

    • 感谢您的详细回答。我查看了有关“面向数据的设计”和“OOP 的陷阱”的链接,但我认为比所说的要多。这需要更多的工作,但最初我认为缓存效率的内存布局应该由底层处理,而不牺牲“普通设计”来提高效率。
    • 我认为这一切都取决于您认为什么是“普通设计”。 =) 也许我从事游戏开发行业太久了;这里真的是关于数据的。开始将系统视为移动数据并有效处理数据有时会使系统设计更容易。深入研究函数式语言概念,这种转换似乎并非遥不可及。
    【解决方案3】:

    发生了什么,我认为处理器有一个特殊的缓存,它保存分支和间接跳转的位置和目标。如果在 $12345678 处遇到间接跳转,并且上次遇到它时转到地址 $12348765,则处理器甚至可以在解析分支地址之前在地址 $12348765 处开始推测执行指令。在许多情况下,在函数的内部循环中,特定的间接跳转在整个循环期间总是会跳转到同一个地址。间接跳转缓存因此可以避免分支惩罚。

    【讨论】:

      【解决方案4】:

      如果 CPU 已经在缓存中拥有内存地址,那么执行加载指令是微不足道的,如果那样的话。

      【讨论】:

        【解决方案5】:

        现代 CPU 使用自适应分支预测技术,该技术可以预测许多间接跳转,例如虚拟函数的 vtable 实现。见http://en.wikipedia.org/wiki/Branch_prediction#Prediction_of_indirect_jumps

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 2013-05-29
          • 2011-03-12
          • 1970-01-01
          • 2022-12-04
          • 2020-10-01
          • 1970-01-01
          • 2011-10-12
          • 1970-01-01
          相关资源
          最近更新 更多