浅谈代码的执行效率(4):汇编优化
终于谈到这个话题了,首先声明我不是汇编优化的高手,甚至于我知道的所有关于汇编优化的内容,仅仅来自于学校的课程、书本及当年做过的一些简单练习。换句话说,我了解的东西只能算是一些原则,甚至也有一些“陈旧”了——不过我想既然是一些原则性的东西,还是能够用它来做一定程度的判断。至少我认为,我在博客园里看到的许多关于“汇编优化”也好,“内嵌汇编”也罢的说法,经常是有些问题的。
说到汇编优化,自然被人想到“高性能”。似乎用.NET或Java平台上的程序性能一定不佳,性能好的程序一定要用C++——不,至少一定要用C来写。为什么呢?因为一个“常识”:便是“封装”会损失性能。性能最高的是“机器码”,因为CPU直接执行机器吗;“汇编”作为机器码的直接对应产物性能自然是一致的;C语言对于汇编/机器码几乎没有任何封装,因此性能也很好;而到了C++语言时,性能就要比C慢一些了——不过,这个看法正确吗?
其实我最近这几篇文章谈的都是与程序性能,尤其是代码执行效率有关的话题。在上一篇文章里,我们可以知道即便是在使用汇编编写代码,同样绕不开“CPU缓存”这部分与计算机体系结构相关的内容,而它对程序性能的影响甚至远远超过几句指令本身。事实上这也只是一小个方面而已,我们平时在谈性能相关问题时,总是在做很多假设,例如我们会假设不同指令的执行速度是一样的,各级别存储的读取性能也是相同的,但这都只是一个“理想环境”,和“事实”有很大差距。而进行汇编级别的优化,往往也是在利用“事实”进行细枝末节的调整。
例如,假设编译器只是对代码做“直接翻译”的话,您认为以下两种做法性能哪个比较好?
int sum = 0; for (int i = 0; i < 100; i++) { sum += array[i]; }
int sum1 = 0, sum2 = 0; for (int i = 0; i < 100; i += 2) { sum1 += array[i]; sum2 += array[i + 1]; } int sum = sum1 + sum2;
从算法上看,两者完全相同,但是对于CPU来说,后一种做法比前一种做法性能要高。首先,第二段代码与前者相比,一个循环内部有两个完全不相关的加法运算,这样CPU便有机会将他们并行地执行,于是性能便会更好一些。其次,第二种做法的条件跳转次数少,一般来说性能就会更好一些。因为条件跳转直到最后一刻才知道要跳向何方,因此CPU流水线就很难对代码的走向进行预测了。当然,现在CPU设计已经引入了分支预测技术,如果预测成功,效率自然较高,但如果预测失败,那么便会有比较严重的损失了。因此,有时候“我们”会尽可能想办法去减少条件跳转的次数。
例如,求一个有符号32位整数的绝对值,按照我们普通的逻辑,它应该是这样的:
if (eax < 0) eax = -eax
这显然是一个条件跳转,但是它的汇编实现也完全可以是:
cdq // 扩展eax的符号位到edx中,如果eax是正数则edx为0否则edx为0xffffffff xor eax, edx // 如果eax为负数,就把所有的位取反,否则不变 sub eax, edx // 如果最开始eax为负数,则把这个数字取反加一
这样,原本的条件跳转消失了,但是我们使用顺序的汇编指令得到了正确的结果。
这样看来,内嵌汇编对于性能多么关键啊。但是,我们真需要亲自动手实现这些吗?无论是前面的“循环展开”还是后面的“取绝对值”都是机械的汇编级别的优化,这些正是编译器最(包括运行时里的JIT)擅长的优化手段了。如果我们想要代替编译器去做这些事情,基本上唯一的结果只是“丑陋的代码”而难以有性能的提高。
编译器其实是提高代码执行效率的重要工具,例如之前在谈这个话题的时候,有人谈到OCaml的性能比C/C++要高,这便是因为它的编译器并不需要像C/C++编译器那样作出最坏的打算——例如C/C++很多时候无法检测出两个变量之间的关系,因此只能按部就班地执行。同样,我们为什么说C语言中strlen()不应该放在循环内部,因为它会造成重复计算?因为C语言编译器不能假设在循环过程中strlen的返回值永远不变,因此它无法自动将其提取到循环外部,只能一遍遍地执行。
因此很多时候,我们在这方面必须为编译器做点什么。例如,一个关于处理器的“常识”便是,不管是整数还是浮点数,除法操作都比乘法要慢上许多,因此我们需要尽可能消除一些除法,例如在进行图片缩放的时候,我们需要确定缩放的依据是“宽”还是“高”,因此我们可能就会写这样的代码:
if (desiredWidth / originalWidth < desiredHeight / originalHeight)
事实上,如果您要在性能上作精细地追求,则这样是更好的做法:
if (desiredWidth * originalHeight < desiredHeight * originalWidth)
可惜的是,编译器可能无法为我们自动作这样的优化:我们的这些变量都是32为“有符号”整数,因此originalWidth可能会是负数。虽然我们知道图片的尺寸一定大于零,但是我们却没有办法把这些信息告诉编译器,因此编译器只能做最保守的计算了。
看到这里您可能会说,这些是在谈汇编优化吗?好像还是一直再说高级代码啊。没错,因为正向刚才所说那样,我不其实并不了解多少汇编优化的内容,我也只能说一些“大道理”。如果您对这方面有些“兴趣”的话,云风的《游戏之旅——我的编程感悟》一书似乎值得您一看(其实这篇文章的许多说法,都和这本书有密切关系)。在这本书里,云风总结他在多年游戏开发中总结到的经验,其中有相当部分便是汇编优化方面的内容。其中也讨论了许多其他方面的问题,如文章开始我提到的C++和C语言的性能高低,他认为C++的性能其实与C语言相比有过之而无不及,如果您在C语言里实现C++的特性(如多态)则几乎无法作的如C++一样好,而反过来,如果在C++中做C语言写过程式的代码,其性能往往会比C语言来的好。为什么?语言特性与编译器的威力呗。
如今的处理器,它的的优化手段已经非常高级,远不是在加快时钟频率上那么简单。这给了程序员手动进行汇编优化的动力,因为此时可能只要交换两条指令的顺序便可以有很明显的性能提高,而编译器的力量已经不足以作更细致的优化了。同时,CPU设计上的进步也在敦促程序员要不断更新自己的知识,因为可能在旧CPU上常用的优化方式,到了新的CPU上就不是那么明显了。例如《游戏之旅》就用了“不小”的篇幅“简单”描述了从Pentium到Pentium IV上渐进的优化方式。
当然,我并不赞同以性能为尊的程序编写方式,事实上汇编优化远比编写高级代码更可能遇到麻烦。云风在书上也强调,不要过于信任自己的汇编书写能力,即便像他这样有丰富经验的高手也遇到过不少令人大跌眼镜的事情。