为啥循环指令很慢?英特尔不能有效地实施它吗?

Posted

技术标签:

【中文标题】为啥循环指令很慢?英特尔不能有效地实施它吗?【英文标题】:Why is the loop instruction slow? Couldn't Intel have implemented it efficiently?为什么循环指令很慢?英特尔不能有效地实施它吗? 【发布时间】:2016-06-15 00:45:15 【问题描述】:

循环 (Intel ref manual entry) 递减 ecx / rcx,and then jumps if non-zero。它很慢,但英特尔不能廉价地让它变快吗? dec/jnz 已经在 Sandybridge-family 上 macro-fuses into a single uop;唯一的区别是设置标志。

loop 在各种微架构上,来自Agner Fog's instruction tables:

K8/K10:7 次操作

Bulldozer-family/Ryzen:1 m-op(与宏融合测试和分支相同的成本,或jecxz

P4:4 微秒(与jecxz 相同)

P6 (PII/PIII):8 微秒

奔腾 M,Core2:11 微秒

Nehalem:6 微秒。 (11 代表 loope / loopne)。吞吐量 = 4c (loop) 或 7c (loope/ne)。

SnB 系列:7 微秒。 (11 代表 loope / loopne)。 吞吐量 = 每 5 个周期一个,与将循环计数器保存在内存中一样多的瓶颈! jecxz 只有 2 微指令,吞吐量与常规 jcc 相同

Silvermont:7 微秒

AMD Jaguar(低功耗):8 微指令,5c 吞吐量

通过 Nano3000:2 微秒


解码器不能像lea rcx, [rcx-1]/jrcxz一样解码吗?那将是3 uops。至少在没有地址大小前缀的情况下是这样,否则它必须使用ecx,如果跳转,则将RIP截断为EIP也许控制递减宽度的地址大小的奇怪选择解释了许多 uops?(有趣的事实:rep-string 指令与使用 32 位地址的 ecx 具有相同的行为-大小。)

或者更好,只是将其解码为不设置标志的融合 dec-and-branch? SnB 上的 dec ecx / jnz 解码为单个 uop(确实设置了标志)。

我知道真正的代码不会使用它(因为它至少从 P5 或其他东西开始就很慢),但 AMD 认为让它为 Bulldozer 快速运行是值得的。可能是因为它很容易。


SnB 家族的 uarch 是否容易拥有快速的loop 如果是这样,为什么不呢?如果不是,为什么难?很多解码器晶体管?或者融合的 dec&branch uop 中的额外位来记录它没有设置标志?那 7 个微指令能做什么?这是一个非常简单的指令。

Bulldozer 有什么特别之处可以让loop 快速变得容易/值得? 或者 AMD 是否浪费了一堆晶体管来让 loop 变得快速?如果是这样,大概有人认为这是个好主意。


如果 loop 速度很快,那么它非常适合 BigInteger arbitrary-precision adc loops, to avoid partial-flag stalls / slowdowns(请参阅我的答案中的 cmets),或者任何其他您想要在不接触标志的情况下循环的情况。与dec/jnz 相比,它还具有较小的代码大小优势。 (而dec/jnz 仅适用于 SnB 系列的宏熔断器)。

在现代 CPU 上,dec/jnz 在 ADC 循环中可以使用,loop 仍然适用于 ADCX / ADOX 循环(以保留 OF)。

如果 loop 速度很快,编译器已经将其用作在没有宏融合的 CPU 上对代码大小 + 速度的窥视孔优化。


这不会阻止我对每个循环都使用loop 的糟糕 16 位代码的所有问题感到恼火,即使它们还需要循环内的另一个计数器。但至少不会像 那样那么糟糕。

【问题讨论】:

搞笑的是AMD自己recommends avoiding the LOOP instruction when optimizing for Bulldozer。 @Michael:也许它的分支预测方式不同?身份证。我在groups.google.com/d/msg/comp.arch/5RN6EegUxE0/KETMqmKWVN4J 上发现了一些推测和似是而非的理论。 (虽然链接到 Paul Clayton 的帖子中途。向上滚动到线程的开头,这与我的问题完全相同)。快点用谷歌搜索你的问题 >. 其他答案之一说:“当重要的流水线开始发生时,LOOP 在一些最早的机器(大约 486 年)上变得很慢,并且有效地沿着流水线运行除了最简单的指令之外的任何指令在技​​术上是不切实际的。所以 LOOP 在几代人中都很慢。所以没有人使用它。所以当可以加速它时,没有真正的动机去这样做,因为没有人真正使用它。“那么,如果编译器已经停止使用该指令,为什么还要费心改进它呢?它不会提高新 CPU 的基准... " 不值得加快速度,因为没有人使用它,因为它很慢?" 那是天才:-) @BoPersson:如果它在 P6 上再次高效,编译器将已经在使用它,并节省了几个代码字节。 (并且在宏融合 dec-and-branch 之前,如果它是单 uop 也可以节省 uops)。这仅适用于编译器可以将循环计数器转换为倒计时的极少数情况,因为大多数程序员将循环编写为向上计数。即使没有loop,在 asm 级别,倒数到零的效率也稍微高一些,因为递减将设置零标志,而无需比较。我仍然通常从 0..n 开始编写我的 C 循环,但为了便于阅读。 【参考方案1】:

1988 年,IBM 研究员 Glenn Henry 刚刚加入戴尔,当时该公司有几百名员工,在他上任的第一个月,他就 386 内部人员进行了技术演讲。我们一群 Bios 程序员一直想知道为什么 LOOP 比 DEC/JNZ 慢,所以在问答部分有人提出了这个问题。

他的回答很有道理。它与分页有关。

LOOP 由两部分组成:递减 CX,如果 CX 不为零则跳转。第一部分不会导致处理器异常,而跳转部分可以。一方面,您可以跳转(或通过)到段边界之外的地址,从而导致 SEGFAULT。对于两个,您可以跳转到已换出的页面。

SEGFAULT 通常表示进程的结束,但页面错误是不同的。当发生页面错误时,处理器会抛出异常,并且操作系统会进行内务处理以将页面从磁盘交换到 RAM。之后,它重新启动导致故障的指令。

重新启动意味着将进程的状态恢复到违规指令之前的状态。特别是在 LOOP 指令的情况下,它意味着恢复 CX 寄存器的值。有人可能会认为您可以将 1 加到 CX,因为我们知道 CX 会递减,但显然,这并不是那么简单。例如,看看这个erratum from Intel:

涉及的保护违规通常表明可能 如果出现这些违规行为之一,则不需要软件错误并重新启动 发生。在具有等待状态的保护模式 80286 系统中 总线周期,当某些保护违规被检测到 80286 组件,并且组件将控制权转移到异常 处理例程,CX寄存器的内容可能不可靠。 (CX 内容是否改变是总线活动的函数 内部微码检测到保护违规的时间。)

为了安全起见,他们需要在 LOOP 指令的每次迭代中保存 CX 的值,以便在需要时可靠地恢复它。

正是这种节省 CX 的额外负担让 LOOP 变得如此缓慢。

英特尔和当时的其他所有人一样,变得越来越 RISC。旧的 CISC 指令(LOOP、ENTER、LEAVE、BOUND)正在逐步淘汰。我们仍然在手工编码的汇编中使用它们,但编译器完全忽略了它们。

【讨论】:

感谢386的历史回答;它显然仍然不适用于 Sandybridge-family,其中dec ecx / jnz 解码为一个递减和分支的 uop。有趣的是,尝试避免破坏延迟循环并不是纯粹故意放慢速度。 我很惊讶;我认为从无效页面获取代码会给你一个页面错误,EIP = 跳转目标,所以重新运行跳转指令本身不会发生。但也许英特尔将检查内置到跳转指令中?而且,如果直通也可以做到这一点,那么任何指令在页面末尾都会有这个潜在的问题。 (除非我弄错了,从逻辑上讲,在 x86 中,跳转到无效页面成功并且本身不会出错,但是从该新地址获取代码可能会出错。)不过,+1 因为 286 勘误表是一些确凿的证据,表明存在这里是真实的东西。 LOOP 指令本身不会导致页面错误。如果未映射目标页面,则会发生页面错误,并将 CS:EIP 设置为目标并更新 ECX。但是,如果目标超出 CS 段限制,则 LOOP 指令可能会导致一般保护 (#GP) 错误,在这种情况下,ECX 需要保持不变。然而,实现这一点的最简单方法是仅在 (ECX - 1) == 0 时跳转,检查分段边界,然后递减 ECX。有关 LOOP 的详细信息,请参阅英特尔软件开发人员手册条目。 谢谢@Ross,我想知道段限制的工作方式是否与分页不同。这确实解释了需要多个内部步骤。 其实仔细阅读手册,Operation部分建议如果LOOP指令导致#GP错误,ECX会被改变,所以我不确定实际情况如何。【参考方案2】:

现在我在写完我的问题后用 Google 搜索了,结果发现它与 comp.arch 上的一个完全相同,它马上就出现了。我原以为很难用谷歌搜索(很多“为什么我的循环慢”点击),但我的第一次尝试 (why is the x86 loop instruction slow) 得到了结果。

这不是一个好的或完整的答案。

这可能是我们能得到的最好的,并且必须足够,除非有人能对此有所了解。我并没有打算把这篇文章写成一个回答我自己的问题的帖子。


该线程中具有不同理论的好帖子:

Robert

LOOP 在一些最早的机器(大约 486 年)上变得很慢,当时 重要的流水线开始发生,并且运行除了 管道中最简单的指令在技术上是有效的 不切实际的。所以 LOOP 在几代人中都很慢。所以没人 用过。因此,当可以加快速度时,没有真正的 这样做的动机,因为实际上没有人使用它。


Anton Ertl:

IIRC LOOP 在一些软件中用于计时循环;有 (重要)不能在 LOOP 太快的 CPU 上运行的软件 (这是在 90 年代初左右)。所以CPU制造商学会了制作LOOP 慢。


(Paul 和其他任何人:欢迎您重新发布自己的文章作为您自己的答案。我会将其从我的答案中删除并为您的答案投票。)

@Paul A. Clayton(偶尔SO poster 和 CPU 架构专家)took a guess at how you could use that many uops。 (这看起来像loope/ne,它同时检查计数器 ZF):

我可以想象一个可能合理的 6-µop 版本:

virtual_cc = cc; 
temp = test (cc); 
rCX = rCX - temp; // also setting cc 
cc = temp & cc; // assumes branch handling is not 
       // substantially changed for the sake of LOOP 
branch 
cc = virtual_cc 

(请注意,对于 LOOPE/LOOPNE,这是 6 uops,而不是 SnB 的 11,并且完全是猜测,甚至没有考虑从 SnB 性能计数器已知的任何东西。)

然后保罗说:

我同意更短的序列应该是可能的,但我正在尝试 考虑一个臃肿的序列,如果 minimal 可能是有意义的 微架构调整是允许的。

总结:设计者希望loop 仅通过微码得到支持,不对硬件进行任何调整。

如果一个无用的、仅兼容的指令被交给 微代码开发人员,他们可能无法或不愿意 建议对内部微架构进行细微更改以改进 这样的指示。他们不仅宁愿使用他们的“改变 建议资本”更有成效,但建议改变 对于一个无用的案例会降低其他建议的可信度。

(我的观点:英特尔可能仍在故意让它变慢,并且在 很长 时间里都没有费心为它重写微代码。现代 CPU 可能对于任何使用 @ 的东西来说都太快了987654332@以一种幼稚的方式正常工作。)

...保罗继续说:

Nano 背后的建筑师可能已经发现避免使用特殊外壳 LOOP 在面积或功率方面简化了他们的设计。或者他们 可能有来自嵌入式用户的激励来提供快速 实现(为了代码密度的好处)。这些只是WILD 猜测。

如果 LOOP 的优化脱离了其他优化(如融合 比较和分支),可能更容易将 LOOP 调整为快速 路径指令而不是在微码中处理它,即使 LOOP 的性能并不重要。

我怀疑此类决定是基于 执行。关于这些细节的信息似乎并不 普遍可用并解释此类信息将是 超出大多数人的技术水平。 (我不是硬件 设计师——从来没有在电视上玩过,也没有住过 智选假日酒店。 :-)


然后线程偏离主题进入 AMD 领域,让我们有机会清理 x86 指令编码中的垃圾。很难责怪他们,因为每次更改都是解码器无法共享晶体管的情况。而在英特尔采用 x86-64 之前,甚至不清楚它是否会流行起来。如果 AMD64 没有流行起来,AMD 不想让他们的 CPU 负担没有人使用的硬件。

但是,仍然有很多小东西:setcc 可以更改为 32 位。 (通常你必须使用 xor-zero / test / setcc 来避免错误的依赖,或者因为你需要一个零扩展的 reg)。移位可以有无条件写入的标志,即使移位计数为零(删除对 eflags 的输入数据依赖,以实现 OOO 执行的可变计数移位)。上次我输入这个讨厌的列表时,我认为还有第三个......哦,是的,bt / bts 等内存操作数的地址取决于索引的高位(位字符串,不仅仅是机器字中的位)。

bts 指令对于位域的东西非常有用,而且比它们需要的要慢,所以你几乎总是想加载到一个寄存器中然后使用它。 (在 Skylake 上使用 10 uop bts [mem], reg 而不是使用 10 uop bts [mem], reg 来自己获取地址通常会更快。但它确实需要额外的指令。所以它在 386 上有意义,但在 K8 上没有)。原子位操作必须使用 memory-dest 形式,但locked 版本无论如何都需要大量的微指令。它仍然比无法访问它所运行的dword 之外的速度要慢。

【讨论】:

我的理解基本上就是罗伯特所说的。自 '386 以来,LOOP 指令一直比 DEC/JNZ 慢。即使在 '86 和 '286 上,它也只快 2 个和 1 个周期,这意味着在那些使用更严格的 LOOP 指令的处理器上经常是错误的。我不确定当时是否有任何常见的 16 位编译器生成了该指令。即使在今天,我认为编写一个可以有效使用它的编译器也很困难。所以没有代码使用它,即使他们确实改进了指令,也不清楚它是否会真正开始被使用。 @RossRidge 和未来的读者:一个很棒的案例是for avoiding partial-flags problems in an adc loop。对于任意大小的 BigInteger adc 循环,一种不接触标志的廉价循环方式正是您想要的。因此,AMD Bulldozer 系列在这方面具有坚实的优势,甚至与 Intel Broadwell 以及后来的 adc 1-uop insn 相比也是如此。编译器已经可以将 rep stos 的字节数放入 ecx 等;我不认为它很难使用。 是的,像这样的手工优化的汇编代码最终可能会被使用。我仍然不确定它是否装配程序员会找到足够的机会来使用它来使工程工作值得。 @RossRidge:很好的一点是编译器很少生成adc 循环(通常只是一个用于 __int128_t 或 int64_t 的 adc)。我假设英特尔关心一些任意精度整数。 gmplib.org 已经存在很长时间了,公钥加密是一件大事。大整数的数学运算并不少见。 实际上,我有点夸大了这个案例。 dec/jcc 在 SnB 系列微架构上展开 2 或 4 的效果非常好。显然,当下一个adc 读取它们时,它添加了一个额外的 uop 来合并标志,因此 1uop loop 只会节省 1uop。但这只有在您愿意使用在 pre-SnB (Nehalem) 上表现不佳的代码时。否则,保存/恢复 cmp/jcc 周围的标志(使用 lahf/sahf)需要 2 个额外的 uops。并且使用adcx / adox(broadwell 的新功能)进行循环以并行执行两个 dep 链需要一个不影响标志的循环。 (lahf 不这样做OF。)【参考方案3】:

请参阅 Abrash, Michael 发表在 Dobb 博士杂志 1991 年 3 月 v16 n3 p16(8) 上的精彩文章:http://archive.gamedev.net/archive/reference/articles/article369.html

文章摘要如下:

8088、80286、80386 和 80486 微处理器的优化代码是 困难,因为芯片使用显着不同的内存 架构和指令执行时间。代码不能 针对 80x86 系列进行了优化;相反,代码必须设计成 在一系列系统上产生良好的性能或针对 处理器和内存的特定组合。程序员必须 避免 8088 支持的异常指令,这些指令已丢失 它们在后续芯片中的性能优势。字符串指令 应该使用但不依赖。应该使用寄存器而不是 比内存操作。所有四个的分支也很慢 处理器。内存访问应该对齐以改进 表现。通常,优化 80486 需要完全 与优化 8088 的步骤相反。

作者所说的“8088支持的异常指令”也指“循环”:

任何 8088 程序员都会本能地替换:DEC CX JNZ LOOPTOP with: LOOP LOOPTOP 因为 LOOP 在 8088 上明显更快。 LOOP 在 286 上也更快。然而,在 386 上,LOOP 实际上是 比 DEC/JNZ 慢两个周期。钟摆继续摆动 486,其中 LOOP 的速度大约是 DEC/JNZ 的两倍——而且,请注意, 我们正在谈论最初可能是最明显的东西 在整个 80x86 指令集中进行优化。

这是一篇非常好的文章,我强烈推荐它。尽管它是在 1991 年出版的,但它在今天却有着惊人的高度相关性。

但本文只是提供建议,它鼓励测试执行速度并选择更快的变体。它没有解释为什么某些命令变得很慢,所以它没有完全解决您的问题。

答案是,早期的处理器,如 80386(1985 年发布)及之前的处理器,是一个接一个地依次执行指令。

后来的处理器开始使用指令流水线——最初很简单,用于 804086,最后,Pentium Pro(1995 年发布)引入了完全不同的内部流水线,将其称为指令转换的乱序 (OOO) 内核到称为微操作或微操作的小操作片段,然后将不同指令的所有微操作放入一个大的微操作池中,只要它们不相互依赖,它们就应该同时执行。现代处理器上仍然使用这种 OOO 流水线原理,几乎没有改变。您可以在这篇精彩的文章中找到有关指令流水线的更多信息:https://www.gamedev.net/resources/_/technical/general-programming/a-journey-through-the-cpu-pipeline-r3115

为了简化芯片设计,英特尔决定以这样一种方式构建处理器,即一条指令确实以一种非常有效的方式转换为微操作,而另一些则不是。

从指令到微操作的高效转换需要更多晶体管,因此英特尔决定以降低解码和执行某些“复杂”或“很少使用”指令的速度为代价来节省晶体管。

例如,“英特尔® 架构优化参考手册”http://download.intel.com/design/PentiumII/manuals/24512701.pdf 提到以下内容:“避免使用通常具有超过 4 个微操作并需要多个周期的复杂指令(例如,进入、离开或循环)解码。请改用简单的指令序列。”

因此,英特尔不知何故认为“循环”指令是“复杂”的,从那时起,它变得非常慢。但是,英特尔没有关于指令分解的官方参考:每条指令产生多少个微操作,以及解码它需要多少个周期。

您还可以阅读乱序执行引擎 在“英特尔® 64 和 IA-32 架构优化参考手册”中 http://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf 2.1.2 部分。

【讨论】:

P6 对 uop 的解码解释了为什么 LOOP 在 PPRO 中很慢,但 Sandybridge 将 dec rcx / jnz looptop 解码为单个 uop(宏融合)。问题是为什么 LOOP 仍然 在 Sandybridge 上很慢,因为单个 uop 可以完成 LOOP 所做的所有事情(除了不修改标志)。 这个答案的早期部分确实很好地总结了为什么英特尔甚至没有尝试在 P6 上提高循环效率:它已经很慢,所以没有人在 486 和 586 上使用它,所以不值得花费任何晶体管来加快速度。与 Sandybridge 相比,第一代 P6 的晶体管数量要少得多。 至于解码和执行需要多少个周期,Agner Fog 的实验测试告诉我们,它可以在 Skylake 上以每 5 个周期一个的吞吐量执行。它产生多个微指令,因此它必须由第一个(复杂的)解码器解码,然后在一个周期内解码。由于它产生超过 4 条微指令(Skylake 上有 7 条微指令),因此微指令是从微码 ROM 中读取的。从 uop-cache 切换到微码可能会降低前端速度 (***.com/questions/26907523/…)。 @Peter Cordes - 也许 LOOP 只是转换为两个甚至一个微操作,但我的想法不是这些微操作执行缓慢。这个想法是,将 LOOP 指令转换为微操作的过程非常缓慢,因为英特尔希望节省晶体管。 我们知道它在 SnB 系列上解码为 7 uop,我们还知道解码器/uop-cache/微码 ROM 的工作原理,足够详细以排除您的理论。许多事件都有 CPU 性能计数器,英特尔已经发布了一些关于其 CPU 内部结构的信息。 Agner Fog 使用这些信息和他自己的实验来编写 CPU 微架构的详细描述。在 agner.org/optimize 中查看他的 microarch.pdf,在 the x86 tag wiki 中查看其他内容

以上是关于为啥循环指令很慢?英特尔不能有效地实施它吗?的主要内容,如果未能解决你的问题,请参考以下文章

实施英特尔实感和 SDL2 的问题

在英特尔 PIN 中跟踪本机指令 [重复]

使用英特尔 pintool 记录所有指令

MMX 是不是真的支持 PADDD 指令,即使英特尔的手册中没有它?

为啥此代码段错误(在分配期间)与 pgi 而不是英特尔?

如何将 8 字节长整数的每个字节相加?