代码改变世界

浅谈代码的执行效率(3):缓存与局部性

2010-01-12 00:03 Jeffrey Zhao 阅读(...) 评论(...) 编辑 收藏

在前两篇文章里,我们讨论了程序性能的两个方面,一是算法(广义的算法,即解决问题的方法),二是编译器。通过这两个方面,我想表达的意思是,一段程序的执行效率,是很难从表面现象得出结论的,至少从一些简单的层面,如代码的长度是几乎难以说明任何问题——因此一定要进行Profiling才能做到有效的优化。而现在,我们假设两段程序算法基本相同,编译器也只是进行简单的“翻译”,那么……我们能从“表面”看出性能高下吗?

那么就从一个最简单的例子看起吧。假设DoSomethingA和DoSomethingB里做的事情是固定的,那么您认为下面两种写法的哪个性能更好?

for (int i = 0; i < 100; i++)
{
    DoSomethingA();
    DoSomethingB();
}
for (int i = 0; i < 100; i++)
    DoSomethingA();

for (int i = 0; i < 100; i++)
    DoSomethingB();

这两段逻辑的算法基本上完全相同,如果编译器只是进行直接“翻译”而不进行优化,那么第一种做法对于i的累加和条件跳转比较少,因此您可能会得出结论:“很明显”第一段代码的执行效率比较高。只可惜事实并非那么简单,因为影响程序性能的另一个关键因素是:缓存。

“缓存”无处不在。在CPU中,性能最快的存储设备当属“寄存器”,不过众所周知寄存器的数量是极其有限的。因此,CPU都会有L1 Cache和L2 Cache的多级缓存机制。其中,L2 Cache的性能比L1 Cache和寄存器都要慢,但还是比内存要快许多。当某个Core需要从内存中获取数据的时候,便会从L1 Cache获取数据,如果L1 Cache没有那么就会从多个核共用的L2 Cache拿,再没有便会从内存拿——由于操作系统的虚拟内存机制,可能还要从磁盘的交换页中获取数据,此时性能自然相当差了。

虽然寄存器只使用一个字长(如4字节)的数据,但是L1 Cache从L2 Cache拿数据时总是“一块一块”拿的——这么一块往往就是连续的64个字节。换句话说,在CPU读取的一个地址的数据之后,读取其他一些地址上的数据便会比另一些特别快,因为它们都已经在L1 Cache中了。如果一个程序能够利用起CPU的这个特性,那它的性能往往便可以更好一些(自然还有很多其他影响性能的因素)。

局部性(Locality),便是用来描述程序是否能利用好缓存的名词。我们说一个程序的局部性比较好,那么就表示它能够较好地利用起CPU的缓存机制。局部性分“空间局部性”和“时间局部性”两方面,前者是指“加载一个地址的数据之后,继续加载它附近的数据”,后者表示“在加载一个地址的数据之后,短时间内重新加载这块数据”。无论是哪一方面,目的都是希望从较快的缓存中加载“热”的数据。为什么冷启动总是很慢?为什么有人说系统从开机后会越跑越快?其实道理都差不多。

那么现在,您还能判断上面两种做法的效率孰高孰低?虽然第一种做法减少了i的累加次数和条件跳转的次数,但是它在一次循环中做了两件事情,可能在执行DoSomethingB方法的时候,DoSomethingA方法中刚刚进入缓存的数据便冷却了,于是在下次执行DoSomethingA时又要重新从较慢的存储设备中加载数据。而在第二种做法中,我们“密集”地执行完100次DoSomethingA或DoSomethingB的调用,而此间大量的数据访问都是集中在L1 Cache上,性能优势不言而喻。

我以前的文章《计算机体系结构与程序性能》在第一部分里也讨论了局部性对程序性能的影响,讲的更为具体一些,您也可以参考其中的内容。

由于程序指令不是执行效率的唯一因素,因此从代码长短上判断程序性能也是非常不靠谱的事情。当然,从任何独立的角度来判断性能可能都不合适。例如在那篇文章里提到,出于程序性能的考虑应该使用全局变量——当然作者也认为这不是好的设计,事实上在我们刚才的例子中,在一个循环中做多件事情可能也值得重构。如果您使用全局变量,它的确省下了push,pop等指令的开销,但是这么一个全局变量——例如是一个静态变量,它存储在堆的某一个地方,访问它并非是一个局部性方面的优秀实践。与之相反,由于L1 Cache的作用,在调用栈上访问“参数”或“局部变量”并不会比访问寄存器慢多少,此时push,pop几个指令的开销可能就不算什么了。更何况,如果编译器/运行时内联了这个方法,这样连push,pop等指令也不会出现了。

记得前一段时间在有某些朋友在我的博客上发布一些较为“激进”的说法,例如“学底层只是对写.NET程序没有帮助,因为就算你知道了这些,C#也没有办法内嵌汇编”。我不同意这个说法,因为即便是.NET程序,它也是在符合计算机体系结构的规律下运行的,我们完全可以在一定程度上了解一段代码在执行时的表现。

就拿目前谈到的“局部性”来说,我们便可以把握很多东西。比如,我们知道每个线程的调用栈在默认情况下是1兆大小,因此两个线程调用栈上的数据几乎不可能出现在同一个Cache条目中。再比如,由于“时间局部性”,最近使用的数据最有可能出现在缓存中,因此在.NET 4.0的并行库在调度“私有队列”的任务时会倾向于执行最新创建的任务。再比如,您是使用两个int数组来表示一系列坐标的x值和y值,还是构造一个struct Point数组来保存它们呢?虽然使用两个int数组更节省内存,但是从局部性考虑问题的话,您会发现同一个坐标的x值和y值存放在一起可能更为合适。

我的这几篇文章,其实也都在强调从代码表面判断程序性能的“不确定性”。同样道理,即便是把它们的汇编代码(片断)放在您面前,您也可能很难“看出”性能区别。这也从侧面说明了Profiling的重要性:阅读代码是静态的,而程序执行和Profiling都是动态的。之前有朋友对我说“你最近迷上Profiler啦?”其实我这里的Profiling泛指“一种探索程序性能的方式”,并不是指某个特定的手段,更不是某个具体的工具——不过无论是使用VS的Profiler也好,还是自己搞一个CodeTimer,都比“读代码”来的可靠。

相关文章