【问题标题】:Why are complicated memcpy/memset superior?为什么复杂的 memcpy/memset 更胜一筹?
【发布时间】:2012-02-10 03:22:56
【问题描述】:

在调试的时候,我经常踩到memcpy和memset的手写汇编实现。这些通常使用流指令(如果可用)、循环展开、对齐优化等来实现......我最近也遇到了这个'bug' due to memcpy optimization in glibc。

问题是:为什么硬件厂商(英特尔、AMD)不能针对具体情况进行优化

rep stos

和

rep movs

这样被认可,并在他们自己的架构上尽可能最快填充和复制?

【问题讨论】:

  • 复制答案:因为他们只是不这样做,因此没有人编写这样的代码,因此他们没有理由这样做......(循环继续)
  • @BillyONEal:我不这么认为。对于他们添加的每个新功能,仍然没有编写使用它的代码。
  • 是的,但是当添加一项新功能时,它会被添加以在一个或另一个领域显示更好的性能。优化这对 CPU 供应商来说没有意义,因为编译器不会发出类似的代码。
  • @BillyONEal:事实上他们确实如此。 MSVC 上的 memcpy 的内在版本正好扩展为这段代码。
  • 英特尔 Ivybridge 和后来的 do 优化了 rep stos 和 rep movs。英特尔将此称为“快速字符串”支持,或 ERMSB(增强型 Rep Movs/StosB)。有关详细信息,请参阅英特尔的优化手册(链接自 stackoverflow.com/tags/x86/info)。这仍然是一个很好的问题,为什么以前的 CPU 具有 rep movs take 的微编码实现不如执行 16B 加载/存储的 SSE 循环快。

标签: c optimization assembly x86 64-bit


【解决方案1】:

成本。

(注意 memcpy 即将进入 ARM,见下文。)

在 C 库中优化 memcpy 的成本相当低,可能需要几个星期的开发人员时间。当处理器功能变化到足以保证重写时,您必须每隔几年左右制作一个新版本。例如,GNU 的 glibc 和 Apple 的 libSystem 都有一个专门针对 SSE3 优化的 memcpy。

硬件优化的成本要高得多。它不仅在开发人员成本方面更昂贵(设计 CPU 比编写用户空间汇编代码要困难得多),而且会增加处理器的晶体管数量。这可能会产生许多负面影响:

  • 功耗增加
  • 单位成本增加
  • 某些 CPU 子系统的延迟增加
  • 降低最大时钟速度

理论上,它可能会对性能和单位成本产生总体负面影响。

Maxim:如果软件解决方案足够好,就不要在硬件上做。

但是,memcpy 即将来到 ARM。随着处理器变得越来越大,相对于内核的现有成本,添加更多指令的增量成本越来越低。来自Arm A-Profile Architecture Developments 2021:

为了解决这些问题,2021 扩展引入了专门针对 memcpy() 和 memset() 系列函数的新指令。

提到的关键问题是,memcpy 的复杂软件实现虽然速度很快,但可能需要针对不同的微架构重写,以获得更好的性能。他们还需要以不同的方式考虑对齐和大小。拥有快速的硬件实现意味着 memcpy 可以内联并在不同的微架构中获得良好的性能。

注意:您引用的错误并不是glibc w.r.t 中的真正错误。 C规范。它更复杂。基本上,glibc 的人说memcpy 的行为与标准中宣传的完全一样,而其他一些人则抱怨memcpy 应该别名为memmove。

讲故事的时间:这让我想起了一位 Mac 游戏开发者在使用 603 处理器而不是 601(这是 1990 年代的)运行游戏时的抱怨。 601 具有对未对齐加载和存储的硬件支持,性能损失最小。 603 只是产生了一个异常;通过卸载到内核,我想加载/存储单元可以变得更简单,可能使处理器在此过程中更快、更便宜。 Mac OS 超微内核通过执行所需的加载/存储操作并将控制权返回给进程来处理异常。

但是这个开发人员有一个自定义的 blitting 例程来将像素写入屏幕,它会进行未对齐的加载和存储。游戏性能在 601 上还不错,但在 603 上却很糟糕。大多数其他开发者都没有注意到他们是否使用了 Apple 的 blitting 功能,因为 Apple 可以为更新的处理器重新实现它。

这个故事的寓意是,更好的性能来自软件和硬件的改进。

总的来说,趋势似乎与上述硬件优化的方向相反。虽然在 x86 中很容易在汇编中编写 memcpy,但一些较新的架构将更多的工作卸载到了软件上。特别值得注意的是 VLIW 架构:Intel IA64 (Itanium)、TI TMS320C64x DSP 和 Transmeta Efficeon 就是示例。使用 VLIW,汇编编程变得更加复杂:您必须明确选择哪些执行单元获取哪些命令以及哪些命令可以同时执行,现代 x86 将为您做一些事情(除非它是 Atom)。所以写memcpy 突然变得更加困难了。

这些架构技巧允许您从微处理器中削减大量硬件,同时保留超标量设计的性能优势。想象一下,有一个芯片的尺寸更接近 Atom,但性能更接近 Xeon。我怀疑这些设备的编程难度是阻碍更广泛采用的主要因素。

【讨论】:

  • 好答案。或者我会如何总结它:“不要在硬件中做事,如果你可以在软件中同样有效地做事”。关于 glibc 的东西:Torvalds 显然是这里实用的东西——用户不在乎为什么有些东西不工作。而且我不知何故怀疑memmove 与memcpy 这些天相比有明显的性能下降.. 必须对其进行测试。
  • @ybungalobill:我只是在添加上下文,因为即使您可能知道 glibc memcpy 的故事,该网站的访问者也可能不知道。
  • @Voo:请注意,Adobe 修复了 flash 插件,现在似乎很少有人介意 memcpy 仍然不是 memmove。我不想偏袒任何一方。
  • @DietrichEpp:您可能想重写答案。一方面你说 ARM 增加了优化的memcpy,另一方面说“趋势是相反的”。 Itanium 早就死了,@PhiS 的回答告诉英特尔很久以前确实实现了这种优化。在我看来,似乎每个人都接受 CPU 支持快速 memcpy/memset 是一个理想的功能,而 memcpy 的实现落后于硬件,因为根据运行时架构选择正确路径会增加复杂性。
  • @YakovGalka:我已经将其纳入答案,并解释了原因。根据经验,一旦答案获得 100 次赞成,我通常会为了清楚起见而重写——如果它覆盖了那么多人,那么重写它是值得的。这个答案并没有传达给这么多人——8 年来总共只有 31 票(上下)。
【解决方案2】:

我想添加到其他答案的一件事是 rep movs 在所有现代处理器上实际上并不慢。例如,

通常,REP MOVS 指令的选择开销很大 并设置正确的方法。因此,它不是最适合 小数据块。对于大数据块,它可能相当 当满足对齐等某些条件时有效。这些 条件取决于特定的 CPU (参见第 143 页)。 关于英特尔 Nehalem 和 Sandy Bridge 处理器,这是最快的移动方法 大块数据,即使数据未对齐。

[高亮是我的。] 参考:Agner Fog, Optimizing subroutines in assembly language An optimization guide for x86 platforms. ,p. 156(另见第 16.10 节,第 143 页)[2011-06-08 版]。

【讨论】:

  • REP MOVS 使用常规代码不可用的缓存协议功能。基本上类似于 SSE 流式存储,但以与正常内存排序规则等兼容的方式。 // “选择和设置正确方法的大量开销”主要是由于缺乏微码分支预测。我一直希望我使用硬件状态机而不是微码来实现 REP MOVS,这样可以完全消除开销。
  • @PeterCordes:自我监督的 1996 年 Pentium Pro (P6) 以来,英特尔 x86 已经有了“快速字符串”。 P6 快速字符串采用 REP MOVSB 和更大,并使用 64 位微码加载和存储以及无 RFO 缓存协议来实现它们。与 iVB 中的 ERMSB 不同,它们没有违反内存顺序。
  • 重写一个有自动更正错字的评论:@PeterCordes:在微码中执行快速字符串的最大弱点是(a)微码分支错误预测,以及(b)微码失调每一代人都变得越来越慢,直到有人开始修复它。就像图书馆的人副本走调了。我想可能错过的机会之一是在 128 位加载和存储可用时使用它们,等等。
  • 回想起来,我应该编写一个自我调整的基础架构,以便在每一代都获得相当好的微代码。但这无助于使用新的、更广泛的负载和存储,当它们可用时。 // Linux 内核似乎有这样一个自动调整基础设施,即在启动时运行。 // 然而,总的来说,我提倡可以在模式之间平滑转换的硬件状态机,而不会导致分支预测错误。 // 良好的微码分支预测是否可以避免这一点值得商榷。
  • @KrazyGlew:我觉得奇怪的是,“rep movs”不会一直是硬件优化的焦点。考虑到现代 x86 CPU 上的其他逻辑数量,确保“rep movs”从未达到最佳状态所需的数量似乎很小。如果想要快速 memcpy 的用户代码必须通过逻辑来选择最佳方法,那么硬件将很难完全优化掉此类测试。如果用户代码可以简单地使用rep movs_ 并让硬件担心最佳方法,那么硬件就不必优化此类测试。
【解决方案3】:

通用与专用

一个因素是这些指令(rep 前缀/字符串指令)是通用的,因此它们将处理任何对齐、任意数量的字节或字,并且它们将具有与缓存和/或寄存器状态相关的特定行为等等,即定义明确的副作用,不能改变。

专用内存副本可能仅适用于某些对齐方式、大小,并且可能具有与缓存不同的行为。

手写程序集(在库中或一个开发人员可以自己实现)在使用它的特殊情况下可能优于字符串指令实现。编译器通常会有几个针对特殊情况的 memcpy 实现,然后开发人员可能会遇到“非常特殊”的情况,他们自己会推出。

在硬件级别进行这种专门化是没有意义的。过于复杂(= 成本)。

收益递减规律

另一种思考方式是,当引入新功能时,例如SSE,设计人员进行架构更改以支持这些功能,例如更宽或更高带宽的内存接口、对流水线的更改、新的执行单元等。此时设计人员不太可能回到设计的“遗留”部分来尝试使其跟上最新功能.那会适得其反。如果您遵循这一理念,您可能会问为什么我们首先需要 SIMD,对于有人使用 SIMD 的情况,设计人员难道不能让狭窄的指令像 SIMD 一样快吗?答案通常是不值得,因为投入新的执行单元或指令更容易。

【讨论】:

  • 我对这个答案有以下问题:“这些说明是通用的”,库中的 memcpy 也是如此。它可以与任何对齐方式等一起工作......“与缓存和/或寄存器状态相关的某些行为”“rep stos”的语义比基于 SIMD 的memcpy 简单得多。您甚至在执行命令之前就知道副作用,它只是 esi += ecx, edi += ecx, ecx = 0。与实际使用 CPU 上几乎所有东西的 SIMD 版本相比,没有其他任何变化。
  • “专门的内存拷贝……”我不说专门的。 “手写程序集可能优于库实现......”什么?库实现是手写的。
  • @ybungalobill:有些人编写自己的内存复制函数,然后有专门的库实现。我不清楚你在说哪些。我会澄清我的答案。
  • 立即调整 memcopy,您的调整后版本可以部署到市场上的每台现有 PC。调整 REP MOVS,它只能部署到未来的 CPU。 (可以使用微码补丁,但主要用于安全漏洞。另外,ucode补丁有开销。)
  • 如果硬件/固件调整 REP MOVS,那么新处理器上使用 REP MOVS 的每个应用程序都将受益。而任何自行调整 memcopy 的软件都不会从调整后的 memcopy 中受益。如果此类软件使用“SIMD”加载和存储,理想情况下是 512 位全缓存行备忘录,可能带有矢量掩码,那么它可能会受益于未来的硬件优化。
【解决方案4】:

曾几何时rep movsb 是最佳解决方案。

最初的 IBM PC 有一个 8088 处理器,带有 8 位数据总线,没有缓存。那么最快的程序通常是指令字节数最少的程序。有特殊说明会有所帮助。

如今,最快的程序是可以并行使用尽可能多的 CPU 功能的程序。起初看起来很奇怪,拥有许多简单指令的代码实际上可以比单个“全能指令”运行得更快。

英特尔和 AMD 保留旧指令主要是为了向后兼容。

【讨论】:

    【解决方案5】:

    在嵌入式系统中,通常会有专门的硬件来执行 memcpy/memset。它通常不是作为特殊的 CPU 指令完成的,而是位于内存总线上的 DMA 外设。你写了几个寄存器来告诉它地址,剩下的就交给硬件了。它并不真正需要特殊的 CPU 指令,因为它实际上只是一个内存接口问题,并不需要涉及 CPU。

    【讨论】:

      【解决方案6】:

      如果它没有损坏,请不要修复它。它没有坏。

      主要问题是未对齐的访问。根据您运行的架构,它们会从坏到非常坏。其中很多与程序员有关,有些与编译器有关。

      修复 memcpy 最便宜的方法是不使用它,保持数据在良好的边界上对齐,并使用或替代仅支持良好对齐的块副本的 memcpy。更好的是有一个编译器开关来牺牲程序空间和内存以提高速度。使用大量结构的人或语言,以便编译器在内部生成对 memcpy 的调用,或者任何与该语言等效的语言,它们的结构都会增长,从而在内部或内部有一个填充。一个 59 字节的结构可能会变成 64 字节。 malloc 或仅提供指向按指定对齐的地址的指针的替代方法。等等等等。

      自己做这一切要容易得多。对齐的 malloc,结构是对齐大小的倍数。你自己的 memcpy 是对齐的,等等。为什么硬件人员会搞砸他们的设计、编译器和用户?没有商业案例。

      另一个原因是缓存改变了图片。您的 DRAM 只能以固定大小访问,32 位 64 位,类似的,任何小于该大小的直接访问都会对性能造成巨大影响。将缓存放在性能下降的前面,任何读取-修改-写入都发生在缓存中,修改允许对 dram 的单个读取和写入进行多次修改。您仍然希望减少缓存的内存周期数,是的,您仍然可以通过使用换档(8 位一档,16 位二档,32 位三档,64位巡航速度,下移32位,下移16位,下移8位)

      我不能代表英特尔,但我知道像 ARM 这样的人已经完成了你的要求

      ldmia r0!,{r2,r3,r4,r5}
      

      例如,如果内核使用 32 位接口,则仍然是四个 32 位传输。但是对于 64 位接口,如果在 64 位边界上对齐,它将变成长度为 2 的 64 位传输,双方之间的一组协商和两个 64 位字移动。如果未在 64 位边界上对齐,则它变为三个传输单个 32 位、单个 64 位然后单个 32 位。您必须小心,如果这些硬件寄存器可能无法工作,具体取决于寄存器逻辑的设计,如果它只支持单个 32 位传输,则您不能对该地址空间使用该指令。不知道为什么你会尝试这样的事情。

      最后一条评论是……当我这样做时会很痛……好吧,不要那样做。不要单步进入内存副本。其必然结果是,任何人都无法修改硬件设计以使用户更轻松地单步执行内存副本,该用例是如此之小以至于不存在。以使用该处理器的所有计算机日夜全速运行,对照所有计算机单步执行内存副本和其他性能优化代码来衡量。这就像把一粒沙子比作地球的宽度。如果您是单步执行,那么您仍然必须单步执行任何新解决方案(如果有的话)。为避免巨大的中断延迟,手动调整的 memcpy 仍将以 if-then-else 开始(如果副本太小,则进入一小组展开代码或字节复制循环),然后进入一系列块副本一些最佳速度,没有可怕的延迟大小。您仍然需要单步完成。

      要进行单步调试,您必须编译搞砸、缓慢的代码,通过 memcpy 解决单步问题的最简单方法是让编译器和链接器在被告知为调试、构建和链接而构建时通常针对非优化的 memcpy 或替代的非优化库。 gnu/gcc 和 llvm 是开源的,你可以让它们为所欲为。

      【讨论】:

        猜你喜欢
        • 2012-10-29
        • 2013-03-21
        • 2010-12-04
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2014-06-15
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多