至少对于典型的桌面 CPU,您无法真正直接指定缓存使用情况。不过,您仍然可以尝试编写缓存友好的代码。在代码方面,这通常意味着展开循环(仅举一个明显的例子)很少有用——它扩展了代码,而现代 CPU 通常会最大限度地减少循环的开销。您通常可以在数据方面做更多的事情,以提高引用的局部性,防止错误共享(例如,两个经常使用的数据将尝试使用缓存的同一部分,而其他部分保持未使用)。
编辑(使某些观点更加明确):
一个典型的 CPU 有许多不同的缓存。现代台式机处理器通常至少有 2 级缓存,通常有 3 级缓存。根据(至少几乎)普遍的协议,“1 级”是“最接近”处理元素的缓存,并且数字从那里开始上升(2 级次之,3 级之后,等等)
在大多数情况下,(至少)一级缓存被分成两半:指令缓存和数据缓存(我知道英特尔 486 几乎是唯一的例外,两者都有一个缓存指令和数据——但它已经完全过时了,可能不值得深思)。
在大多数情况下,缓存被组织为一组“行”。高速缓存的内容通常一次读取、写入和跟踪一行。换句话说,如果 CPU 要使用高速缓存行任何部分的数据,则从下一个较低级别的存储中读取整个高速缓存行。靠近 CPU 的缓存通常更小,缓存行也更小。
这种基本架构导致了缓存的大部分特性,这些特性在编写代码时很重要。尽可能地,您希望将某些内容读入缓存一次,然后用它做所有您想做的事情,然后继续做其他事情。
这意味着,当您处理数据时,通常最好读取相对少量的数据(小到足以放入缓存中),尽可能多地处理该数据,然后继续下一块数据。诸如快速排序之类的算法可以将大量输入快速分解为逐渐变小的部分,或多或少是自动执行此操作的,因此它们往往对缓存非常友好,几乎不管缓存的精确细节如何。
这也会影响您编写代码的方式。如果你有这样的循环:
for i = 0 to whatever
step1(data);
step2(data);
step3(data);
end for
您通常最好将尽可能多的步骤串在一起达到适合缓存的数量。缓存溢出的那一刻,性能可能/将急剧下降。如果上面第 3 步的代码足够大以至于无法放入缓存中,则通常最好将循环分成两部分,如下所示(如果可能):
for i = 0 to whatever
step1(data);
step2(data);
end for
for i = 0 to whatever
step3(data);
end for
循环展开是一个相当激烈的话题。一方面,它可以导致代码对 CPU 更加友好,从而减少为循环本身执行的指令开销。同时,它可以(并且通常确实)增加代码大小,因此它对缓存相对不友好。我自己的经验是,在倾向于对大量数据进行非常少量处理的综合基准测试中,您可以从循环展开中获益良多。在更实际的代码中,您倾向于对单个数据进行更多处理,您获得的收益要少得多——而且导致严重性能损失的缓存溢出并不少见。
数据缓存的大小也受到限制。这意味着您通常希望数据尽可能密集地打包,以便尽可能多的数据适合缓存。举一个明显的例子,与指针链接在一起的数据结构需要在计算复杂性方面获得相当多的收益,以弥补这些指针使用的数据缓存空间量。如果要使用链接数据结构,通常至少要确保将相对较大的数据链接在一起。
然而,在很多情况下,我发现我最初学到的将数据装入已经(大部分)已经过时数十年的微型处理器中的微量内存中的技巧在现代处理器上效果很好。现在的目的是在缓存而不是主内存中容纳更多数据,但效果几乎相同。在相当多的情况下,您可以认为 CPU 指令几乎是空闲的,并且整体执行速度由缓存(或主内存)的带宽控制,因此从密集格式解压缩数据的额外处理在你的青睐。当您处理足够多的数据以使其不再完全适合缓存时尤其如此,因此整体速度由主内存的带宽控制。在这种情况下,您可以执行很多条指令来节省一些内存读取,并且仍然领先。
并行处理会加剧这个问题。在许多情况下,重写代码以允许并行处理实际上不会导致性能提升,有时甚至会导致性能下降。如果整体速度由从 CPU 到内存的带宽控制,那么让更多内核竞争该带宽不太可能有任何好处(并且可能会造成重大损害)。在这种情况下,使用多核来提高速度通常归结为做更多的事情来更紧密地打包数据,并利用更多的处理能力来解包数据,因此真正的速度增益来自减少消耗的带宽,并且额外的内核只是避免浪费时间从更密集的格式中解压缩数据。
并行编码中可能出现的另一个基于缓存的问题是变量共享(和错误共享)。如果两个(或更多)内核需要写入内存中的同一位置,则保存该数据的缓存线最终可能会在内核之间来回穿梭,以使每个内核都可以访问共享数据。结果通常是并行运行的代码比串行运行慢(即,在单核上)。有一种称为“错误共享”的变体,其中不同内核上的代码正在写入单独的数据,但是不同内核的数据最终在同一个缓存行中。由于缓存仅根据整行数据来控制数据,因此数据无论如何都会在内核之间来回打乱,导致完全相同的问题。