保持缓存的热度
首先,别浪费缓存,因为主存很慢。这意味着无序地从内存中读取数据(被称为指针追逐pointer-chasing)并非明智。在现代处理器上,程序以预测的方式读取连续的内存块,可以受益于硬件级的预读取(prefetching)机制。一言以蔽之,即数据局部化(data locality)。
举个反面例子,唉,是我们古老而可靠的链表,遍历链表是一次实实在在的指针追逐盛宴,因为所有的节点都是动态分配的,可以位于内存中的任何地方。然而我们可以用前述的优化技巧进行补救:先进行预分配再使用定制化的内存分配器。如此一来,链表的所有节点将位于预分配的缓冲区附近,这将使链表重新变为缓存友好型。当然,我们可以一开始就使用数组,但这个例子说明的是如何在类似的情况下实现数据的局部化。
第二个常见的错误是将两个常用的整数x, y在数据结构中分隔得很远,那么当我们要同时使用这两个整数时,就不得不加载两个缓存行而不是一个。需要在一起使用的数据应当在数据结构上确保邻近;不要让缓存行的边界打破你的数据结构。
更深入的广为人知的优化是包含数组的结构体取代元素是结构体的数组。这有助于SIMD指令加载数据或应用其他并行化读数据的技术。
另一个经常被提及的优化技巧是通过调整数据访问模式来提高矩阵乘法的性能。我们可以用k, j, i三层循环替代简单的三元组i, j, k:
for ( size_t k = 0; k < P; ++k)
for (size_t j = 0; j < M; ++j)
for (size_t i = 0; i < N; ++i)
res[i][j] += m1[i][k] * m2[k][j];
这一调整将遍历的顺序变得对缓存友好,即对两个矩阵的元素,以连续的方式读取能极大地提升运算地性能(在一些测试中,有高达94%的性能提升)!
从上面的例子我们可以看到数据的组织方式和结构,甚至于数据的访问模式,在现代CPU环境里,都对程序的性能有巨大的影响!
第二种缓存即指令缓存,也需要心存怜惜。与在数据区一样,在代码段中前后跳跃,是同样忌讳的。因此,代码的局部化是接下来的一个重要议题。一个可能的做法是将正常情况的处理代码放在错误处理的代码之前,比如:
if (ok) {
do_work();
} else {
printf(“ERROR”);
return;
}
这样做避免了在正常情况下的指令跳转,从而提高了代码的局部性。我们将在第3章深入C++及其性能讨论程序优化中的编译器的作用时,讨论更多的细节。