分享

为什么经常听人说编译器比你聪明?

 半佛肉夹馍 2023-10-20 发布于河南

经常在知乎上看到人说编译器比你聪明bllablabla。但是编译器也是人写的也会有bug,难不成是因为编译器接近底层代码更容易知道哪些可以被优化?

九几年国外一些大公司做过研究,结论是超过百分之九十几的C程序员以及汇编高手们手工优化的代码,性能劣于主流编译器的自动优化。

正是基于这个调查,当时的一些新兴语言,比如Java,干脆彻底堵死了程序员干预底层的一切可能——甚至极端到“变量/小对象分配在栈上”这样极其粗疏的细节都禁止程序员干预。

这个设计的弊病就是,很久以来,Java的小对象传递极其臃肿笨拙。比如UI设计里面满天飞的、代表鼠标点击位置的point2D对象,正常来说完全可以放在一个64bit甚至16bit的空间里面,从而直接使用CPU提供的很多基础设施无代价解决的;但Java里面却只能new出来、传递、然后等待垃圾收集……可以说是肉眼可见的臃肿和低效。

但,之后Java有了“逃逸分析”技术。这个技术可以自动分析所有对象的引用关系,取消不必要的复制、以及正确使用栈对象。这就使得Java可以在这个领域达到甚至超过绝大多数C/汇编程序员的能力上限。

换句话说,当时的设计目标是:编程语言本身就应该做到高级语言和底层汇编/机器码之间的完美隔离,从而避免机器底层细节污染了程序高层逻辑——由此得到的性能,对于需要长期维护的项目来说,是得不偿失甚至有百害而无一利的。

如果的确有优化需求,那么应该隔离到库代码里、直接利用汇编实现,不要在高级语言层面搞一些奇技淫巧。

因为在摩尔定律的影响下,你耗费大量人力物力得到的那点性能,还没有每年芯片性能自然增长的多。

除非你就是编译器厂商、就是高性能计算库的编写者,你的代码的复用率决定了,你的优化物有所值——而如果你写面向用户的终端应用……省点心吧。

事实上,绝大多数的开发者是没有能力做出任何有意义的优化的。

作为对比,我们知道C++引入了一个const标记,这个标记就给许多人造成了很大困扰。

实际上,这个标记就是为了配合编译器的“常量优化”而设计的。正确设置const标记可以让编译器在更大范围内识别常量、编译出最低消耗的代码。

如果你不能正确使用const标记,可想而知,你怎么可能优化出比编译器更好的代码?

更进一步的,既然常量优化、逃逸分析对绝大多数程序员已经是很难理解和掌握的知识了,那么……

下一步,更致命的是,编译器优化是程序优化。程序优化的好处是——无遗漏!

没错。一个复杂的程序,用了八百个变量;你能肉眼把所有可以做常量优化的变量都找出来吗?需要多久?

而编译器呢……只要你写对了逻辑,它完成这件事可能只需几个毫秒、甚至不足一个毫秒。

事实上,只要是可以成文总结出来的、套路型的优化,竞争激烈的编译器厂商一定已经把它做在里面了——你就看看这些天rust和c++打的有多厉害,就知道这个压力有多大了。

千万别把网上看到的、东一鳞西一爪的优化技巧当宝。那玩意儿是成体系的,你不玩编译器的话,甚至都不知道是不是早已集成在编译器里面了;你自己写,正确率和执行效率都成问题(比如,经常性的,你写了多重if或者switch语句,就会自动触发编译器的“表驱动优化”,并不需要你内嵌汇编自己写表驱动)。

另一个事实就是,正因为编译器内置的自动优化算法越来越激进,这才会优化出bug——以至于某些时候开O3都是一种冒险。

但,反过来说也对:只要你自己别瞎优化、不要通过极其特殊的写法利用奇怪的副作用、不要随意嵌入自以为是的汇编……那么,竞争如此激烈的编译器赛场上,那些连常规搞法都hold不住的选手……你猜它该怎么混下去。

再换句话说,或许你的确知道一种编译器尚无法支持的优化技法;但当你应用这种技法时,你其实已经拒绝了编译器提供的大量自动化的常规优化算法。

因为,不去碰用户手工优化的东西、尽可能保留它的原貌,对编译器来说是最明智的做法。原因很简单,C/Java源码允许编译器知道更多东西,而汇编甚至会打散“结构化编程”这个基本共识。没了共识,那么很多优化自然无从谈起。

最终,或许你的优化技法节省了11%的时间;但你做不到编译器那种“无遗漏的全面优化”的覆盖率,而这些优化可能足以节省8%甚至13%的时间——于是,哪怕你真的比编译器水平更高,最终得到的代码却还是输给了编译器。

换句话说,你用海量的精力、更大的编译bug可能、“规避”了编译器提供给你的大量自动优化服务,换取了某个片面的执行效率提升——这个提升究竟是真的提升,还是丢了西瓜捡了芝麻,那就不得而知了。


在计算机的世界里,能用程序做的东西就尽量用程序做;程序真的做不了了,也一定要想个办法、尽量把程序已经实现过的东西给利用上。

但问题就在这里:“程序自动优化”是编译器这个黑盒子里面的;你的优化本身构成了另一个黑盒子。

现在,编译器不知道你的黑盒子里装的是什么、稍有“越界”就可能产生严重后果(编译器bug);而你呢,也不知道编译器的黑盒子里装着什么、该如何利用它……

这也是至今底层开发仍然大量使用C,甚至连C++都不被信任的根本原因。

怎么解决呢?

两条路——当然,并列关系,你可以双管齐下:

1、熟练掌握编译器技术,把自己的“手工优化经验”总结出来、固定于编译器。

比如,gcc等编译器是开源的;当你发现某种场景下可用的、普适性的优化方法时,不妨把它总结出来、写成程序,提交给gcc(但如果这种优化太小众,gcc未必会接受)。

2、使用profile,找出性能热点,只优化这个热点——并用profile验证是否真的提高了性能、在哪些情况下可以提高性能而在哪些情况下效果不明显甚至起了反效果。

注意,profile并不仅仅是你从网上搜到的那套傻瓜式工具。它的确可以帮你解决80%的问题;但还有一些问题需要你创造性的使用它,甚至是自己编写代码去探测更精细的数据。

另一个,在动手做这些琐碎的优化之前,请尽量从整体上考虑问题——比如,使用大O表示法分析算法的整体效率、像高德纳《计算机程序设计艺术》卷一那样,尽可能分析明白每个消耗较高的算法需要的最低复杂度、并想办法分析乃至证明你设计的算法复杂度已经到了极限……

举例来说,排序算法,我们需要每个元素都和其他元素比较过至少一次,也就是排序算法的复杂度至少也是O(N logN)等级的;那么我们起码就要把自己的算法优化到这个等级——比如冒泡算法这种O(N^2)的,还是从一开始就否定掉吧……

在强人工智能出现之前,这可能是唯一无法内置于编译器的东西。


换句话说,其实想要确定编译器是不是比你更聪明、或者想要避免团队里有人胡乱优化造成更大问题,我们其实是可以通过成文规范、按固定程序来解决的:

1、首先考察算法复杂度,算法复杂度不达标,那么修改算法。直到可以证明算法复杂度最优。

尤其如果这个算法处于核心位置,那么这个考察一定要做的更深入、详尽一些。

2、在算法达标的前提下,鉴于当前多核普及的现状,我们首先要考虑能不能做并行优化。如果能,那么并行优化完成之前,不要做细节上的优化。

注意并行优化一定要确保并行出现,要验证并行阶段执行时间和准备时间/协议时间的占比——但不要随便搞无锁。先确保整个架构在理论上的确能提高效率、且起码是较优甚至最优架构。

比如,一项任务,IO时间是多少、内存访问最少需要多少次、每轮计算的CPU指令数、网络用时等等,这些都要搞明白——然后,其实就是我们在语文课上学过的数学:《统筹方法》的发威时间了。

先在统筹水平上,把各部件的并行、并行能带来的优化比率期望大致弄明白,尤其是注意会不会出现锁的循环依赖(死锁!)以及如何解决,之后才是下一步优化开始的时间。

3、在1和2的基础上,做profile,分析性能热点,找出瓶颈所在;然后反复做profile,验证优化是否成功。

锁粒度大了,那就优化锁,甚至搞无锁;缓存未命中多了,想办法提高缓存命中率;不需要浪费时间做过高精度的计算,那就把16bit甚至64bit的数字压缩到8bit范围内、并尽可能的通过SIMD指令快速处理、以及经典的空间换时间……此外还有更灵敏的界面响应给用户执行速度很快一切正常的错觉等等偏“邪道”的手法,等等。

经常的,要特别注意少量数据、特殊数据状态下才能起效的“假优化”。也就是少量数据测试时效果很好,但数据量一多,耗时甚至可能指数增加:换句话说就是把1的成果给吐出来了。

如果你没有搞1和2、或者搞了1和2却没有做profile就动手做3,那就是“过早优化”。

事实上,得益于发展迅猛的CPU和编译器,绝大多数情况下,解决了1已经解决了一切——甚至,很多时候,很多软件连1都没有解决(或者没有能力解决)、用的还是理论最低就要耗费10倍性能的解释器执行脚本程序,都足够解决现实中99.99999%的问题了。

其中,1需要较好的数学视野,2需要面对问题的整体把控;3做到深处,也需要数论、CPU体系结构/执行细节等方面的专业知识,可以说是比1和2难得多的。

但是呢,3里面的很多技法是可以总结出来的——不然怎么用编译器自动优化,对吧——所以很多人看了一点点资料,就开始折腾了。啊,常量优化?我懂了我懂了!我要内嵌汇编,把变量访问给它优化掉,嘿嘿,编译器会的我都会,我甚至还知道某个值虽然理论上会变化所以编译器不敢优化但在我的工程里面它一定是不会变的,看,我比编译器聪明!

很遗憾,但是大多数时候,这种优化是捡了芝麻丢了西瓜:或许你在常量优化这个片面的确强于编译器;但编译器内置的一千八百种优化套路,你可是一无所知

这就是为何199x年,c/c++编译器还非常不完善、汇编高手还非常多时,编译器已经能够盖过百分之九十多的“高手手工精制汇编”的根本原因。

不过,那时候CPU都还是单核单线程,不像现在,CPU越趋复杂,而多线程/并行优化方面,编译器能做的事情还很少。

因为它只看得见最终的语句,看不见问题的全貌。

因此,自9x年MMX之类SIMD指令集普遍集成于处理器、以及后来多核多线程处理器流行之后,各大流行语言也是绞尽脑汁,从并行库到各种编译指示,搞了一大堆方案。但至今仍未能达成普遍的一致意见——反而因为显卡、TPU、NPU等等的掺乎,把这个群雄逐鹿的战场搞的更加纷纷乱了。

在这个领域,超过编译器还是相对容易的。

当然,很可惜,哪怕基本的并行编程,能玩明白的也不多。你就看现在多少游戏都还是“一核有难多核围观”,就知道形势不容乐观了。

在你能把多核充分利用之前,就别和编译器在单线程优化上面较劲儿了。投入产出比太低,而且CPU制程略有突破,就……

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多