如何编写最能利用CPUcaching来提高性能的代码?
这可能听起来像一个主观的问题,但我正在寻找的是具体的实例,你可能遇到过这个问题。
-
如何使代码,caching有效/caching友好(更多的caching命中,尽可能less的caching未命中)? 从这两个angular度来看,数据caching和程序caching(指令caching),即代码中与数据结构和代码结构有关的东西,应该注意使其caching有效。
-
是否有任何特定的数据结构必须使用/避免,还是有一种特定的方式来访问该结构的成员等…使代码caching有效。
-
是否有任何程序结构(如果,切换,中断,转到,…),代码stream(内部如果,如果内部等等…)应该遵循/避免在这件事情?
我期待着听到有关caching高效代码的个人经验。 它可以是任何编程语言(C,C ++,汇编,…),任何硬件目标(ARM,Intel,PowerPC,…),任何操作系统(Windows,Linux,S ymbian,…)等。
品种将有助于更好地深入理解它。
caching在那里可以减lessCPU停顿等待内存请求被执行的次数(避免内存延迟 ),作为第二个效果,可能减less需要传输的数据总量(保留内存带宽 )。
避免内存读取等待时间的技术通常是首先要考虑的,有时候还有很长的路要走。 有限的内存带宽也是一个限制因素,特别是对于multithreading想要使用内存总线的multithreading和multithreading应用程序而言。 一套不同的技术有助于解决后一个问题。
改善空间局部性意味着你确保每一个caching行被完全使用,一旦它被映射到caching。 当我们查看了各种标准的基准testing结果后,我们看到,在高速caching行被驱逐之前,有很大一部分未能使用所提取的高速caching行的100%。
提高caching线利用率有三方面的帮助:
- 它倾向于使更多有用的数据在caching中,从而本质上增加了有效的caching大小。
- 它倾向于在同一个caching行中包含更多有用的数据,从而增加了在caching中find请求的数据的可能性。
- 它减less了内存带宽的要求,因为会有更less的提取。
常用的技术是:
- 使用较小的数据types
- 组织你的数据以避免alignment漏洞(通过减小大小来sorting你的结构成员是一种方法)
- 小心标准的dynamic内存分配器,它可能引入漏洞,并随着温度的boost而将数据传播到内存中。
- 确保所有相邻的数据实际在热循环中使用。 否则,请考虑将数据结构分解为冷热组件,以便热环路使用热数据。
- 避免显示不规则访问模式的algorithm和数据结构,并且倾向于线性数据结构。
我们还应该注意到,隐藏内存延迟的方法比使用caching还要多。
现代CPU:通常有一个或多个硬件预取器 。 他们在高速caching上训练失误,并试图发现规律。 例如,在后续高速caching行出现一些错误之后,hw预取程序将开始将高速caching行读取到高速caching中,以预测应用程序的需求。 如果你有一个常规的访问模式,那么硬件预取器通常做的非常好。 如果你的程序没有显示常规的访问模式,你可以通过自己添加预取指令来改进。
重组指令的方式使那些总是在高速caching中丢失的指令彼此靠近,CPU有时可能会重叠这些指令,使得应用程序只能承受一个等待时间( 内存级并行性 )。
为了减less总体存储器总线压力,你必须开始处理所谓的时间局部性 。 这意味着您必须重用数据,但仍未从caching中清除。
合并接触相同数据的循环 ( 循环合并 ),并采用称为平铺或阻塞的重写技术来努力避免那些额外的内存提取。
虽然这个重写过程有一些经验法则,但是通常你必须仔细考虑循环中携带的数据依赖性,以确保你不会影响程序的语义。
在多核世界中,这些东西是真正的成就,在添加第二个线程之后,通常不会看到吞吐量的提高。
我不相信没有更多的答案。 无论如何,一个典型的例子就是迭代multidimensional array“内外”:
pseudocode for (i = 0 to size) for (j = 0 to size) do something with ary[j][i]
这是caching效率低下的原因是,当你访问一个单一的内存地址时,现代CPU将从主内存加载具有“近”内存地址的caching行。 我们遍历内部循环中的数组中的“j”(外部)行,因此对于每次通过内部循环的行,caching行将导致刷新并加载一行地址, j] [i]条目。 如果这改变为等同的:
for (i = 0 to size) for (j = 0 to size) do something with ary[i][j]
它将运行得更快。
如果你对内存和软件的交互方式感兴趣,那么我build议你阅读这篇由9部分组成的文章。每个程序员应该知道 Ulrich Drepper的内存。 它也可以作为一个104页的PDF 。
与此问题特别相关的部分可能是第2部分 (CPU高速caching)和第5部分 (程序员可以做什么 – 高速caching优化)。
基本规则其实很简单。 哪里变得棘手的是它们如何适用于你的代码。
caching工作原理有两个:时间局部性和空间局部性。 前者是这样的想法,如果你最近使用了某个数据块,那么很快就可能需要它。 后者意味着如果您最近使用了地址X处的数据,则很可能很快就需要地址X + 1。
caching试图通过记住最近使用的数据块来适应这种情况。 它使用高速caching行,通常大小为128字节左右,所以即使只需要一个字节,包含它的整个高速caching行也会被拉入高速caching。 所以如果你以后需要下面的字节,它就已经在caching中了。
这意味着你总是希望你自己的代码尽可能地利用这两种forms的局部性。 不要跳过所有的记忆。 在一个小范围内尽可能多地工作,然后继续下一个工作,尽可能地在那里做尽可能多的工作。
一个简单的例子是1800年答案显示的二维数组遍历。 如果一次遍历一行,则按顺序读取内存。 如果以列的方式进行,您将读取一个条目,然后跳转到一个完全不同的位置(下一行的开头),读取一个条目并再次跳转。 当你终于回到第一行时,它将不再在caching中。
这同样适用于代码。 跳转或分支意味着caching使用效率较低(因为您不是按顺序读取指令,而是跳到不同的地址)。 当然,小if语句可能不会改变任何东西(你只是跳过几个字节,所以你仍然会在caching区域内),但函数调用通常意味着你跳到一个完全不同的地方地址可能不被caching。 除非它最近被称为。
指令caching的使用通常远不是一个问题。 你通常需要担心的是数据caching。
在一个结构或类中,所有成员都是连续的,这是好的。 在一个数组中,所有的条目都是连续的。 在链表中,每个节点被分配在一个完全不同的位置,这是不好的。 指针通常倾向于指向不相关的地址,如果解除引用,可能会导致caching未命中。
如果你想利用多核心,它可以变得非常有趣,因为通常情况下,一次只有一个CPU可能在L1caching中有任何给定的地址。 所以,如果两个内核不断地访问相同的地址,将导致不断的caching未命中,因为他们在争夺地址。
除了数据访问模式,caching友好代码的一个主要因素是数据大小 。 更less的数据意味着更多的数据适合caching。
这主要是与内存alignment的数据结构的一个因素。 “传统”的观点认为,数据结构必须与字边界alignment,因为CPU只能访问整个字,如果一个字包含多个值,则必须做额外的工作(读 – 修改 – 写,而不是简单的写) 。 但caching可以完全无效这个论点。
同样,一个Java布尔数组为每个值使用一个完整的字节,以便直接对各个值进行操作。 如果使用实际位,则可以将数据大小减小8倍,但是访问单个值变得复杂得多,需要位移和掩码操作( BitSet
类为您执行此操作)。 但是,由于caching效应,当数组很大时,这比使用boolean []要快得多。 IIRC我曾经以这种方式提高了2或3倍。
caching最有效的数据结构是一个数组。 如果您的数据结构是按顺序布置的,CPU将一次从主内存中读取整个caching行(通常是32个字节或更多),caching的效果最好。
任何以随机顺序访问内存的algorithm都会破坏caching,因为它总是需要新的caching行来容纳随机访问的内存。 另一方面,通过数组顺序运行的algorithm是最好的,因为:
-
它为CPU提供了预读的机会,例如推测性地将更多内存放入caching中,稍后将访问它。 这个预读提供了巨大的性能提升。
-
在大型数组上运行紧密循环还允许CPUcaching在循环中执行的代码,并且在大多数情况下允许您完全从高速caching中执行algorithm,而不必阻塞外部内存访问。
用户对“经典示例”的评论1800 INFORMATION (评论太长)
我想检查两个迭代顺序(“外部”和“内部”)的时间差异,所以我做了一个大的二维数组的简单实验:
measure::start(); for ( int y = 0; y < N; ++y ) for ( int x = 0; x < N; ++x ) sum += A[ x + y*N ]; measure::stop();
第二种情况是换了for
循环。
较慢的版本(“x first”)为0.88sec,较快的版本为0.06sec。 这是caching的力量:)
我使用gcc -O2
,仍然没有优化循环。 里卡多的评论是“大多数现代编纂者可以自己弄清楚这一点”并不成立
只有一篇文章涉及到它,但在进程间共享数据时会出现一个大问题。 您希望避免多个进程试图同时修改相同的caching行。 这里需要注意的是“虚假的”共享,其中两个相邻的数据结构共享一个caching行,并且对一个caching行进行修改使另一个caching行无效。 这可能会导致caching行不必要地在多处理器系统上共享数据的处理器caching之间来回移动。 避免这种情况的方法是alignment和填充数据结构以将其放在不同的行上。
我在游戏引擎中看到的一个例子就是将数据从对象中移出并放入自己的数组中。 一个受物理限制的游戏对象也可能附带许多其他的数据。 但是在物理更新循环期间,所有引擎关心的是关于位置,速度,质量,边界框等的数据。因此,所有这些被放置到它自己的arrays中,并且尽可能地优化SSE。
所以在物理循环过程中,物理数据使用向量math按照数组顺序处理。 游戏对象使用它们的对象ID作为各种数组的索引。 这不是一个指针,因为如果数组必须重新定位,指针可能会失效。
在许多方面,这违反了面向对象的devise模式,但是通过将数据紧密放在一起,需要在相同的循环中进行操作,使得代码更快。
这个例子可能已经过时了,因为我期望大多数现代游戏都使用像Havok这样的预build物理引擎。
我可以回答(2)说,在C ++的世界里,链表可以很容易地杀死CPUcaching。 arrays在可能的情况下是更好的解决scheme。 没有关于是否适用于其他语言的经验,但很容易想象出现相同的问题。
高速caching被安排在“高速caching行”中,并且以这种大小的块来读取和写入(真实)存储器。
包含在单个caching行中的数据结构因此更高效。
类似地,访问连续存储器块的algorithm将比以随机顺序跳过存储器的algorithm更有效。
不幸的是,处理器之间的高速caching行大小差异很大,因此无法保证在一个处理器上最佳的数据结构对其他任何处理器都是有效的。
要询问如何编写代码,caching有效caching友好以及大部分其他问题,通常要问如何优化一个程序,这是因为caching对性能有如此巨大的影响,任何优化的程序都是caching有效caching友好。
我build议阅读关于优化,这个网站上有一些很好的答案。 就书本而言,我推荐“ 计算机系统:程序员的观点” ,它有关于caching正确使用的一些文本。
(顺便说一句,像cache-miss一样糟糕,更糟糕的是,如果程序是从硬盘分页的话)
关于数据结构的select,访问模式等一般的build议已经有很多的答案了。在这里我想添加一个叫做软件pipe道的代码devise模式,它利用了主动cachingpipe理。
这个想法是借用其他stream水线技术,例如CPU指令stream水线。
这种types的模式最适用于程序
- 可以分解为合理的多个子步骤S [1],S [2],S [3],…其执行时间大致与RAM访问时间(〜60-70ns)相当。
- 需要一批input,并对其进行上述多个步骤才能得到结果。
让我们举一个简单的例子,只有一个子程序。 通常代码会喜欢:
def proc(input): return sub-step(input))
为了获得更好的性能,您可能希望在批处理中将多个input传递给该函数,以便分摊函数调用开销并增加代码caching局部性。
def batch_proc(inputs): results = [] for i in inputs: // avoids code cache miss, but still suffer data(inputs) miss results.append(sub-step(i)) return res
但是,如前所述,如果该步骤的执行与RAM访问时间大致相同,则可以进一步将代码改进为如下所示:
def batch_pipelined_proc(inputs): for i in range(0, len(inputs)-1): prefetch(inputs[i+1]) # work on current item while [i+1] is flying back from RAM results.append(sub-step(inputs[i-1])) results.append(sub-step(inputs[-1]))
执行stream程如下所示:
- 预取(1)要求CPU将input[1]预取到高速caching中,预取指令以P为周期自行返回,后台input[1]经R周期后到达高速caching。
- work_on(0)0的冷错过并且在它上面工作,这需要M
- 预取(2)发出另一个提取
- work_on(1)如果P + R <= M,则input[1]应在此步骤之前已经在caching中,从而避免数据caching未命中
- work_on(2) …
可能会涉及更多的步骤,那么只要步骤的时序和内存访问延迟相匹配,就可以devise一个多级stream水线,您将很less遇到代码/数据caching未命中的情况。 但是,这个过程需要经过多次实验才能find正确的分组步骤和预取时间。 由于需要付出的努力,它在高性能数据/数据包stream处理中看到了更多的采用。 在DPDK QoS入队pipe道devise中可以find一个很好的产品代码示例: http ://dpdk.org/doc/guides/prog_guide/qos_framework.html第21.2.4.3节。 排队pipe道。
更多的信息可以find:
写你的程序,以最小的尺寸。 这就是为什么使用-O3优化GCC并不总是一个好主意。 它占用更大的尺寸。 通常,-Os和-O2一样好。 这一切都取决于使用的处理器。 因人而异。
一次处理大量的数据。 这就是为什么如果数据集很大,效率较低的sortingalgorithm可能比快速sorting更快。 find方法将更大的数据集分解成更小的数据集。 其他人提出这个build议。
为了帮助您更好地利用指令时间/空间局部性,您可能需要研究如何将代码转换为程序集。 例如:
for(i = 0; i < MAX; ++i) for(i = MAX; i > 0; --i)
这两个循环产生不同的代码,即使它们只是parsing一个数组。 无论如何,你的问题是非常体系结构的。 因此,严格控制caching使用的唯一方法是了解硬件的工作原理并优化代码。
除了alignment你的结构和字段,如果你的结构如果堆分配,你可能想要使用分配器支持alignment的分配; 像_aligned_malloc(sizeof(DATA),SYSTEM_CACHE_LINE_SIZE); 否则你可能会有随机的虚假分享; 请记住,在Windows中,默认堆有16个字节alignment。