【问题标题】:Does hardware consolidate multiple code operations into one physical CPU operation?硬件是否将多个代码操作合并到一个物理 CPU 操作中?
【发布时间】:2014-12-04 00:21:56
【问题描述】:

我读过一篇 2006 年的文章,内容是 CPU 如何在整个 l1 缓存行上执行操作,即使在您只需要对 l1 行包含的一小部分内容进行操作的情况下(例如,加载整个 l1 行进行写入)到布尔变量显然是矫枉过正)。该文章鼓励通过以 l1 缓存友好的方式管理内存来进行优化。

假设我有两个 int 变量恰好在内存中是连续的,并且在我的代码中我连续写入这两个变量。

硬件是否将我的两个代码操作合并为单个 l1 行上的一个物理操作(假设 CPU 有一个大到足以容纳两个变量的 l1 缓存行)?

有没有办法用 C++ 或 C 向 CPU 提出这样的建议?

如果硬件不以任何方式进行整合,那么如果在代码中实现这样的事情,您认为它会产生更好的性能吗?分配一个 l1 行大小的内存块并用尽可能多的热数据变量填充它?

【问题讨论】:

  • 你能贴一个链接或者你读过的文章的名字吗?另外,当您说 CPU 时,它是 RISC 还是 CISC 还是与架构无关?
  • farbrausch.de/~fg/seminars/lightspeed.html 这是您可以查看演示文稿中各种材料的页面。当我说 CPU 时,我指的是通用 x86。
  • 您可能对Ulrich Drepper's "What every programmer should know about memory" 感兴趣,它说明了 Linux 上可用的 write-combining builtins,并描述了许多相关的 CPU 架构和问题。
  • 我认为您可能误解了文章所说的内容。 CPU 不会将完整的缓存线加载到任何寄存器,它会通过缓存线读取/转储到下一个缓存/内存,但不会在 L1 和 CPU 之间。

标签: c++ c optimization cpu-architecture cpu-cache


【解决方案1】:

这是一个相当广泛的问题,但我会尽量涵盖要点。

是的,将数据读入缓存只看一个 bool 有点浪费 - 但是,处理器通常不知道您打算在那之后做什么,例如,如果您需要下一个连续值或不。您可以依靠位于同一类或结构中的数据彼此相邻/靠近,因此使用它来存储您经常彼此靠近操作的数据将为您带来好处。

至于“一次处理多个数据”,大多数现代处理器都有各种形式的扩展来对多个数据项执行相同的操作(SIMD - 相同的指令,多个数据)。这始于 1990 年代后期的 MMX,并已扩展到包括 3DNow!、SSE 和 AVX for x86。在 ARM 中有一个“Neon”扩展,它也提供了类似的功能。 PowerPC 也有类似的东西,它的名字现在让我忘记了。

C 或 C++ 程序无法立即控制指令的选择或缓存使用。但是现代编译器,如果选择正确,将生成代码,例如使用 SIMD 指令将所有int 加到一个更大的数组中,通过一次添加 4 个项目,然后,当全部完成后,水平添加4 个值。或者,如果您有一组 X、Y、Z 坐标,它可能会使用 SIMD 将两组这样的数据相加。这是编译器的选择,但它可以节省相当多的时间,因此正在修改编译器中的优化器以找到有帮助的情况,并使用这些类型的指令。

最后,大多数大型现代处理器(x86 自 1995 年以来,ARM A15,PowerPC)也执行超标量执行 - 一次执行多个指令,并且“无序执行”(处理器了解指令并执行那些“准备好”执行的指令,而不是完全按照它们被提供给处理器的顺序)。编译器会知道这一点并尝试“帮助”安排代码,以便处理器轻松完成任务。

【讨论】:

  • 我假设 SIMD 仅适用于您对多个变量执行完全相同的操作的情况?如果我将不同的数据写入不同的变量,但内存地址的排列方式使得 l1 缓存在一行中获取两个变量怎么办? CPU 会重新识别写入两者的机会,还是会再次加载同一行?
  • 缓存的目的是“保存少量数据,您可以在第一次访问缓存行后立即访问这些数据”,因此处理器将保留缓存的内容,直到它需要其他东西的相同缓存位置。大多数缓存有许多“方式”,这意味着对应于特定地址的不同位置 - 因此如果其他一些数据来自“匹配”地址,它不会立即丢弃特定缓存行的内容[缓存是通常根据数据的地址进行组织]。
  • 当数据可用时(可能与数据到达时相同的时钟周期或下一个时钟周期),处理器可能会立即对缓存中的下一条数据执行另一个操作请求该高速缓存行的第一条指令),假设它是一个超标量处理器。在传统的单指令一次处理器中,它必须等到当前指令完成后才能执行下一条指令。
【解决方案2】:

缓存行的大小主要与并发相关。它是可以在多个处理器之间同步的最小数据块。

正如您所建议的,有必要加载整个缓存行以仅对其中的几个字节进行操作。如果您在同一个处理器上执行多项操作,尽管它不需要不断地重新加载它。顾名思义,它实际上是被缓存的。这包括缓存对数据的写入。只要只有一个处理器在访问数据,您通常就可以放心,它正在高效地完成它。

在多个处理器访问数据的情况下,对齐数据可能会有所帮助。使用 C++ alignas 属性或编译器扩展,可以帮助您获得以您想要的方式对齐的数据结构。

您可能对我的文章 CPU Reordering – What is actually being reordered? 感兴趣,它对底层发生的事情(至少在逻辑上)提供了一些见解。

【讨论】:

    【解决方案3】:

    缓存的全部意义在于允许大量高度本地化的内存操作快速发生。

    当然,最快的操作涉及寄存器。使用它们所涉及的唯一延迟是在指令获取、解码和执行中。在一些寄存器丰富的体系结构(和向量处理器)中,它们实际上被用作专用缓存。除了最慢的处理器之外,所有处理器都有一层或多层缓存,对于普通指令来说,这看起来就像内存,但速度更快。

    为了相对于实际处理器进行简化,请考虑一个运行频率为 2 GHz(每个时钟 0.5 ns)的假设处理器,其内存需要 5 ns 来加载任意 64 位(8 字节)内存字,但只有 1 ns从内存中加载每个连续的 64 位字。 (假设写入也是相似的。)在这样的机器上,在内存中翻转一点非常慢:加载指令需要 1 ns(仅当它尚未在流水线中时——但在远处分支后需要 5 ns),5 ns加载包含该位的字,0.5 ns 执行指令,5 ns 将更改的字写回内存。内存副本更好:加载指令大约为零(因为流水线可能对指令循环做了正确的事情),加载前 8 个字节需要 5 ns,执行指令需要 0.5 ns,存储前 8 个字节需要 5 ns , 并且每增加 8 个字节需要 1+0.5+1 ns。局部性使事情变得更容易。但是有些操作可能是病态的:递增数组的每个字节会执行初始 5 ns 加载、0.5 ns 指令、初始 5 ns 存储,然后每个字节(而不是每个字)增加 1+0.5+1。 (不在相同单词边界上的内存副本也是坏消息。)

    为了让这个处理器更快,我们可以添加一个缓存,将指令执行时间的加载和存储时间提高到仅 0.5 ns,用于缓存中的数据。内存副本在读取时并没有改善,因为前 8 个字节的工作仍然需要 5 ns,其他字需要 1 ns,但是写入速度要快得多:每个字 0.5 ns,直到缓存填满,并且在正常情况下5+1+1 等速率在它填充后,与其他使用内存较少的工作并行。初始加载的字节增量提高到 5 ns,指令和写入为 0.5+0.5 ns,然后每个额外字节为 0.5+0.5+0.5 ns,除非在读取或写入的高速缓存停顿期间。相同的几个地址的更多重复会增加缓存命中的比例。

    真正的处理器、多级缓存等会发生什么?简单的答案是事情变得更加复杂。编写可感知缓存的代码包括尝试改进内存访问的局部性、分析以避免破坏缓存以及大量的分析。

    【讨论】:

      【解决方案4】:

      是的,对高速缓存行的相邻int32_t 的背靠背写入可以合并到某些 CPU 的存储缓冲区中,因此它们可以作为单个 8 字节对齐更新提交到 L1d。 (在许多非 x86 CPU 上,完整的 32 位存储在更新 L1d 缓存时避免了 RMW 循环,因此合并字节存储很好:Are there any modern CPUs where a cached byte store is actually slower than a word store?。在 Alpha 21264 上,甚至将 32 位存储合并为 64 位提交也很重要)。

      但存储缓冲区中的合并仅发生在单独执行多个存储指令之后。没有 CPU 可以将连续加载或存储融合到执行单元的单个硬件操作中。


      一些编译器(例如 GCC8 和更高版本,IIRC)可以将加载/存储到相邻的结构成员或局部变量合并到单个 asm 指令中,例如一次存储 4 个chars,一个 32 位存储。 (或 2 ints 在 64 位机器上)。在某些 ISA(如 x86)上,即使不知道对齐方式,它也会这样做。

      确实创建了一个访问多个 C 对象的 asm 操作。在具有高效未对齐加载/存储的 ISA(如 x86)上,这通常是一场胜利。 (缓存线拆分并不常见,也不会昂贵。不过,在 Skylake 之前,在 Intel 上跨 4k 边界的拆分要昂贵得多,比如大约 100 个周期。)

      在结构成员上使用 alignas(8) int foo; 以使整个结构更加对齐,这可能会在 ISA 上实现这种编译时优化,而无需有效的未对齐加载/存储。

      我认为 ARM ldp/stp(加载/存储对)在未完全对齐的情况下还不错,但在对齐的情况下,它可以将一对寄存器加载或存储为单个 64 位或 128 位操作.

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 2011-02-05
        • 1970-01-01
        • 1970-01-01
        • 2017-08-21
        • 2021-01-07
        • 1970-01-01
        • 2013-11-19
        相关资源
        最近更新 更多