硬件是否将多个代码操作合并为一个物理CPU操作

Does hardware consolidate multiple code operations into one physical CPU operation?

本文关键字:操作 CPU 一个 合并 是否 代码 硬件      更新时间:2023-10-16

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

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

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

有没有办法在C++或C中向CPU建议这样的事情?

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

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

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

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

你可能对我的文章CPU重新排序感兴趣——实际上重新排序的是什么?这为在低水平上发生的事情(至少在逻辑上)提供了一些见解。

这是一个相当宽泛的问题,但我将尝试涵盖要点。

是的,只看一个bool就可以将数据读取到缓存中,这有点浪费——然而,处理器通常不知道在那之后你打算做什么,例如,如果你是否需要下一个连续的值。您可以依赖于同一类或结构中的数据位于彼此相邻的位置,因此使用它来存储您经常在一起操作的数据将给您带来好处。

至于"一次处理多个数据",大多数现代处理器都有各种形式的扩展,可以对多个数据项进行相同的操作(SIMD-同一指令,多个数据)。这始于20世纪90年代末的MMX,并已扩展到包括3DNow!,SSE和用于x86的AVX。在ARM中有"Neon"扩展,它也提供类似的功能。PowerPC也有类似的东西,我现在记不起它的名字了。

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

最后,大多数更大的现代处理器(自1995年以来的x86、ARM A15、PowerPC)也执行超标量执行——一次执行多条指令,并执行"无序执行"(处理器了解指令的依赖性,并执行那些"准备好"执行的指令,而不是完全按照给处理器的顺序执行)。编译器会知道这一点,并尝试"帮助"安排代码,以便处理器轻松完成任务。

缓存的全部目的是允许大量高度本地化的内存操作快速发生。

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

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

为了让这个处理器更快,我们可以添加一个缓存,在指令执行时间内,将缓存中的数据的加载和存储提高到0.5 ns。内存复制在读取方面没有改善,因为前8字节的工作仍然需要花费5 ns,额外的字需要花费1 ns,但写入速度要快得多:在缓存填充之前,每个字需要0.5 ns,在填充之后,以正常的5+1+1等速率,与其他使用内存较少的工作并行。初始加载的字节增量提高到5ns,指令和写入的字节增量为0.5+0.5ns,然后每个额外字节的字节增量增加到0.5+0.5+0.5ns(读取或写入时的缓存暂停期间除外)。相同少数地址的重复次数越多,缓存命中率就越高。

实际处理器、多级缓存等会发生什么。?简单的答案是事情变得更加复杂。编写缓存感知代码包括尝试提高内存访问的局部性、分析以避免缓存崩溃,以及大量的评测。

是的,对缓存行的相邻int32_t的背靠背写入可以在一些CPU的存储缓冲区中合并,因此它们可以作为单个8字节对齐的更新提交到L1d。(在许多非x86 CPU上,完整的32位存储在更新L1d缓存时避免了RMW周期,因此合并字节存储很好:有没有现代CPU的缓存字节存储实际上比字存储慢?在Alpha 21264上,即使将32位存储合并为64位提交也很重要)。

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


一些编译器(例如GCC8和更高版本的IIRC)可以将对相邻结构成员或局部变量的加载/存储合并为一条asm指令,例如,用一个32位存储同时存储4个char。(或者在64位机器上为2个int)。在一些像x86这样的ISAs上,即使不知道对齐方式,它也会这样做。

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

在结构成员上使用alignas(8) int foo;使整个结构更加对齐,可以在没有有效的未对齐加载/存储的情况下对ISAs进行编译时优化。

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