大师兄

13 | 编译期优化:只有修改业务代码才能提升系统性能?

你好,我是尉刚强。

我们知道,所谓的编译,就是把我们编写的软件代码,变成计算机可以识别的汇编代码的过程,而这个编译过程会直接影响到最终运行的软件性能。所以今天这节课,我们就一起来聊聊编译期优化对软件性能的影响。

事实上,编译期优化是做软件性能优化时最常见的优化手段,**它的最大优势就是可以在不用修改业务代码的场景下来提升软件的性能。**另外,它也可以让开发人员以较低的成本来获取一定的性能收益,所以编译期优化也算是高性能软件系统研发中不可或缺的一个环节。

不过同时它也存在局限性,那就是需要开发人员对语言实现和底层编译过程都有比较深入的理解,否则就会很容易漏掉一些优化方向,导致发挥不出最佳的性能优化效果。举个简单的例子,在我以前参与的一个C加加性能优化项目中,只是帮忙调整配置了关于内联相关的编译配置,就直接给产品带来了比较明显的性能提升,而团队之前的性能优化就没有考虑过这个方向。

而且除此之外,不同的编程语言,受制于其语言内置设计机制的差异,导致在编译期优化时的关注点和优化方法也有很大的不同。比如,有些编程语言(如Java、Python、Ruby等)将内存管理内置到了语言中,那么在做编译期优化时就需要重点关注内存空间这部分的优化。另外,由于编译期优化和语言的相关性很大,我也不可能逐一介绍。

所以在今天的课程中,我会基于C/C加加和Java这两门语言,来给你展开讲解编译期优化中比较常用的优化手段和关键原则,以此帮助你明确应该按照怎样的步骤与思路去开展编译期优化工作,从而让你能够在实际的软件研发过程中,选择合适的优化手段来帮助提升产品性能。

这里,我之所以选择C/C加加和Java,一是因为这两门语言是通用语言中的典型代码,二是因为二者也正好代表了两种优化类型,也就是其最后执行指令分别是在虚拟机上执行和在OS上执行。这样,你在理解了C/C加加和Java的编译期优化手段之后,再去思考其他编程语言的编译期优化手段,就可以融会贯通了。

C/C加加是传统编译型语言中的一个代表,它与底层硬件比较贴近,编译期优化配置手段也比较丰富。所以接下来,我们就先来看下针对C/C加加开发高性能系统时,应该如何在编译期进行调优。

C/C加加编译期优化

首先,C/C加加在不同操作系统中所使用的编译工具并不是统一的,比如有GCC、Clang等,这节课我主要是基于最通用的GCC编译器来给你展开介绍。而如果你的产品使用的是其他编译工具链,其大部分的编译优化手段和方法也都是相似的,所以你也可以借鉴参考。

那么下面,我就根据使用GCC支撑的编译优化角度,来给你介绍下C/C加加在编译期优化时最关键的一些编译配置手段和方法,主要包括编译期优化选项配置、编译辅助函数、C加加语言特殊优化。

编译期优化选项配置

对于GCC来说,它提供的针对编译优化的选项配置开关主要有这几种:-O1,-O2,-O3,-Os,-Of,-Og等。

其中,-O1,-O2,-O3分别代表不同的编译优化级别,-Os代表的是对Size的优化,-Of代表的是fast的极速优化,-Og代表支持Debug的优化,这些都属于GCC上最常用的编译配置开关,更详细的你可以参考官方文档

而在调整配置这些开关之前,你需要明白这些编译配置会对编译生成的汇编指令产生哪些影响,以避免盲目调整。然后在调整这些配置时,你还需要做好针对编译花费的时间长短、生成的二进制执行程序的大小、程序执行的性能、程序的可调试能力之间的权衡。比如说:

  • -Og在程序中加入了定位调试信息,方便你跟踪定位问题;
  • 从-O1到-O3,编译出来的软件执行速度会越来越快,但也会导致编译时间越来越长,同时如果程序出现异常,你再回头基于汇编指令分析定位问题的难度也会加大;
  • -Os在-O2的基础之上,会优化生成的目标文件的大小,所以它关闭了可能会使目标文件变大的部分优化选项;
  • -Of则会开启所有-O3的编译开关,可能会对不符合标准的程序进行优化,所以潜在触发程序异常的概率会更高。

那么对于发布的软件版本来说,通常建议至少可以打开-O2级别,如果你的软件对性能要求比较高的话,也可以打开到-O3(但这样可能会碰到一些因编译期优化而引起的故障问题)。在我之前做过的多个性能优化项目中,通过调整编译期优化开关,都可以给生成的软件带来10%~20%不等的性能收益。

其实,以上介绍每一级别的优化配置,都对应了一系列的详细编译配置,你可以根据业务代码进行更深入的配置和分析,具体的你可以参考GCC的官方文档。但是你也要知道,对编译选项进行更详细的调整优化,其实性价比已经不太高了,一般情况下不建议你一定要这样做。

那么,在做好了编译期优化的选型配置之后,我们还需要选择编译辅助函数来优化生成软件的执行速度。下面我们就继续来看看,C/C加加是如何与编译器进行配合,来实现更好的编译期优化效果的。

编译辅助函数

其实,对编译器而言,如果它掌握的信息越多,那么编译生成的汇编指令的执行效率就越高。所以不少编译器都提供了编译期优化辅助函数,支持从代码中获取更多额外信息来指导编译。

但这里你需要注意的是,通常这些编译辅助函数并不在编程语言标准中,而是由编译器独自定义的,所以使用前你需要谨慎选择。此外,如果使用编译辅助函数来优化生成软件的执行速度,也存在不少局限性,比如说:

  • 使用内置辅助函数会污染到业务代码实现,导致代码可读性变差;
  • 还有可能因为测试场景与产品运行环境的差异,导致编译辅助函数优化的实际效果并不理想;
  • 如果因为引入了新的业务需求或代码重构,导致软件代码发生变化,也很容易引起内置辅助函数的优化成果失效。

不过,在一些对性能要求非常苛刻的场景下,你可能不得不需要使用这种手段。那么对于C/C加加语言来说,早期比较常用的编译辅助函数,应该是likely和unlikely宏,具体如下所示:

#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)

然后,我们可以将这两个宏放在代码中对应语义的条件判断分支上,来提升代码分支预测效率。

在上节课我也介绍过了,代码分支预测出错有可能会引起CPU指令流水线不连续,这是目前影响CPU硬件性能发挥的重要因素之一。而使用这个内置函数,就是主动告知编译器哪个分支会被更大概率地执行,从而让编译器在生成指令时优先选择大概率的分支,以此进一步提升CPU的执行效率。

不过,现在GCC也提供了更加方便的手段来帮助分支预测,具体流程如下图所示。

我来给你介绍下它具体实现的功能:

  • 第一步,GCC编译时使用**-fprofile-arcs选项**,编译源代码,生成可以跟踪分支预测的可执行程序。
  • 第二步,启动可执行程序,并运行正常业务逻辑,这时候会生成很多cc.gcda文件,它们会记录相关的分支预测信息。
  • 第三步,GCC编译时使用**-fbranch-probabilities选项**,重新编译源文件时,会读取cc.gcda分支预测文件,从而最终可以生成执行效率比较高的可执行程序。

另外,还有一个比较常用的编译辅助函数,指令预取指令__builtin_prefetch ,它可以实现在代码执行的过程中,预先加载代码段或数据段到Cache中,从而达到降低数据或数据Cache Miss的概率。比如,你可以看看这个二分法查找代码,它在查询的过程中,就可以提前一步把接下来要对比的数据,先加载到Cache中来提升性能。

int binarySearch(int *array, int number_of_elements, int key) {
int low = 0, high = number_of_elements-1, mid;
while(low <= high) {
mid = (low + high)/2;
// low path
__builtin_prefetch (&array[(mid + 1 + high)/2], 0, 1);
// high path
__builtin_prefetch (&array[(low + mid - 1)/2], 0, 1);
if(array[mid] < key)
low = mid + 1;
else if(array[mid] == key)
return mid;
else if(array[mid] > key)
high = mid-1;
}
return -1;
}

我们知道,Cache预取手段是CPU执行性能的优化手段之一,这是因为在通常情况下,在CPU硬件上的Cache容量是非常有限的,一旦出现Cache Miss,就会导致CPU的执行速度不能完全发挥出来。而这里所使用预取指令,其实就是通过显式地告知编译器在哪些场景下,把哪些代码段或数据段加载到Cache中,从而提升Cache命中率来优化性能

C加加语言特殊优化

其实在GCC中,还有一些专门针对C加加语言的编译选项配置,它们对生成的可执行程序的性能影响也很大。这里我主要给你介绍下比较关键的一些配置,你可以根据实际业务中针对有关C加加语言特性的使用情况,去选择配置。

首先,在这些配置中,最重要的一个就是内联的优化配置

通常我们在编写代码时,会使用inline关键字来定义方法,而这样的方法是否可以真实地被内联掉,还依赖于编译器。因此,对于C加加语言来说,编译中是否开启内联选项,对性能影响是非常大的。

下面给出的是最常用的、与内联相关的几个编译选项配置和具体的含义,你可以参考:

'-fno-inline' 忽略代码中的 inline 关键字
'-finline-functions' 编译期尝试将'简单'函数集成到调用代码处
'-fearly-inlining' 加速编译 默认可用
'-finline-limit=N' gcc 默认限制内联函数的大小,使用该选项可以控制内联函数的大小

其次,由于目前在C加加中,新的语言特性越来越复杂,所以在开发高性能系统时,有很多的特性会因为可能产生的性能问题而很少使用。那么,你就可以在编译过程中,将这些编译特性的支持开关关闭掉,也可以进一步提升生成的可执行程序的性能。

我举几个简单的例子。如果你开发的C加加代码中没有使用异常,可以使用-fno-exceptions编译选项来关闭。它不仅可以提升运行性能,还能减少编译生成的二级制文件大小。还有,如果你的代码中没有使用动态类型转换、typeId运算符、typeinfo等语法,那么你可以使用-fno-rtti选项关闭运行时RTTI机制,来提升性能,等等。

更换编译器

好,最后我要给你介绍的一项编译期调优手段,就是可以通过更换编译器来优化性能。

实际上,对C/C加加而言,由于CPU硬件的发展变化很快,同时厂家的编译优化技术差异也很大,就导致了编译器生成的二进制性能也会存在差异。比如说,Clang编译出的软件执行速度,在大部分场景下都要比GCC要快,而不同的GCC版本之间的编译优化性能也存在差距。所以,在大部分业务场景下,你都可以通过尝试更换编译器或者编译器版本,来进一步的提升性能。

OK,前面我们介绍了C/C加加语言在编译期调优时的一些常用优化方法和手段。接下来,我们再来看看在Java语言当中,具体要如何更深入地挖掘出软件在编译期的性能优化点。

Java的JVM优化

一般来说,使用Java语言来开发软件是基于JVM之上来运行的,这是因为Java语言将对象内存管理职责交给了JVM,因此在很大程度上减轻了一线软件开发人员的负担。但同时,由于内存申请与释放操作对软件性能的影响非常大,所以对JVM的堆空间配置,就成为了Java性能调优中非常重要的手段之一。

而除此之外,在JVM中,我们可以**借助JIT(即时编译器)**把Java生成的热点字节码,转换为可以直接在CPU上执行的机器码,从而提升软件执行速度的效果。与此同时,JIT也对外提供了一些配置选项,方便我们直接对JIT的功能和过程进行配置,从而优化软件执行性能。

所以说,针对JVM的性能优化一般就主要集中在这两个点上,接下来,我就重点给你讲解这两个方面进行优化配置的思路和经验,方便你参考借鉴。

JVM的堆空间配置

首先,对JVM的堆空间配置,实际上可以分为三个方向来进行,分别是针对堆内存资源大小、堆空间的内部分配和JVM中的GC算法选择。它们之间是递进的关系,在针对软件的启动配置优化过程中,你就应该按照这个顺序来进行优化配置。

1. JVM堆内存资源配置

JVM在启动时,可以配置使用的堆内存资源大小,从而对最终运行的软件性能产生比较直接的影响。那么,关于JVM堆内存资源的配置,这里我给你介绍最重要的两个参数,当你开发的软件在服务器部署运行时,使用的资源也会限制在这个资源空间范围内:

  • -Xms,初始堆大小配置;
  • -Xmx,最大堆内存配置。

这里你可能要问了,这两个参数具体要怎么使用呢?下面我就给你详细介绍下。

首先,我推荐你根据真实服务器的内存大小,留给JVM使用的最大内存空间,去最大化配置JVM使用的堆空间(-Xmx)。注意,如果这里JVM的最大堆空间配置比较小,就会容易出现JVM堆内存上频繁触发GC,而服务器上还有空闲内存未被充分利用的场景。

其次,对于高吞吐量性能要求和低时延性能要求的软件来说,我比较推荐把-Xms和-Xmx设置成相同的值,这样可以在JVM启动时,就把相关堆内存创建好,以避免程序在运行期间再调整而引起时延抖动。

2. JVM堆空间内部分配

JVM的堆空间分为三个部分,分别是新生代、老生代和永久代。因此,你可以通过配置来改变应用的堆空间的分配比例,从而影响或改变软件应用的性能表现。

那么,关于JVM堆空间的内部分配,也有比较重要的三个参数需要你关注:

  • -XX:NewRatio:新生代和老生代的内存大小比例,如果配置4,则表示新生代与老生代的空间占比是1:4。
  • -XX:NewSize:新生代大小。
  • -XX:SurvivorRatio:Eden和Survivor区的比率。

此外,在配置新生代与老生代的堆空间占比时,你需要重点考虑两个因素,一个是业务软件中短期对象的占比状况,另一个是软件系统的时延要求是否足够高

如果业务软件中的短期对象比较多,我建议可以配置比较大的新生代堆空间占比,这样在一定程度上就可以减少触发Minor GC发生的概率。而系统针对时延要求很高的场景,为了避免新生代空间太大,触发GC后的执行时间长,造成业务的时延抖动比较大,你可以把新生代堆空间占比调小一些。

另外在一般情况下,当系统吞吐量比较大且时延要求不高的话,你就可以将新生代的堆空间配置得大一些。

3. JVM中的GC算法选择及配置

JVM中如何使用不同的GC算法,也会对软件应用的性能表现影响比较大,因此你需要根据业务性能要求差异,来选择配置合适的GC算法。

目前,JVM中支持的GC算法种类比较多,这里我给你介绍其中最典型的三种,来了解下选择配置的方法。

  • ParallelScavenge(-XX:+UseParallelGC),它是一个新生代收集器,如果你的业务对时延性能指标不是非常关注,可以考虑配置这种GC算法。
  • CMS(-XX:+UseConcMarkSweepGC),它是一个老生代收集器,其GC执行时间会比较短,如果业务对响应时间要求比较高的话,你可以优先选择这个GC算法。
  • GarbageFirst G1(-XX:+UseG1GC),它是新生代和老生代都通用的收集器,在管理大的内存上有比较大的优势,因此当堆内存空间比较大时,推荐你去使用这种。

总之,每种GC算法在不同场景下的性能优势都是不一样的,你需要基于业务特性,来选择配置合适的GC算法。

JIT优化

好,最后我们再来了解下JIT优化的相关配置,看看如何通过调整JIT的配置,来提升软件的执行速度。

首先,对于JIT来说,一般情况下包含了两种工作模式:

  • Client Compiler,简称C1,-client参数强制,代表客户端模式
  • Server Compiler,简称C2,-server参数强制,代表服务器模式

一般来说,使用Client可以获得更快的编译速度,而使用Server更容易获得较好的运行性能,所以在产品部署态,你可以先检查下JVM是否在服务器模式运行。而如果是在客户端模式下运行,你也可以通过显式地配置来运行服务器模式。

额外知识:GraalVM是Oracle新开发的编译器,在目前的评测中,其生成代码的执行速度会优于C2(服务器模式),你可以基于真实业务去对比分析下性能,看看是否需要采用。

然后,在JIT优化的过程中,也有一些比较重要的优化项,如果你需要对JIT进行更深入的优化配置来提升性能,就可以参考使用。

  • –XX:ReservedCodeCacheSize:调整代码缓存,避免因为缓存问题,导致无法进行JIT优化。
  • -XX:CompileThreshold:热点方法触发内联的阀值,当被执行次数超过这个门限值,才会进行内联优化。
  • -XX:UseFastAccessorMethods:是否针对get方法进行优化。
  • -XX:+UseCompressedOops:是否进行64位指针到32位指针的压缩优化等。

最后要注意,这里我只是给你简单介绍了下每个配置项的含义,你可以根据真实的业务软件,调整配置到更适合的值。

小结

在今天的课程中,我带你学习了在C/C加加和Java两门语言中,针对编译期优化可以使用的手段有哪些,并分享了这些手段在使用过程中的一些经验和建议。这里你要注意一点,就是你需要关注开发的业务软件的实现特点,以及它关注的性能指标是什么,然后再利用这些编译期优化手段,来调整优化到一个比较好的性能效果。

最后呢,我还想重点强调下,这些经验与建议其实都是在一些特定业务场景下总结出来的,但不同行业的软件业务差异会比较大,可能一些建议并不一定适用于你的产品。所以,你在借鉴或者采纳这些经验和建议的时候,还需要先在业务中做进一步验证后,再去使用。

思考题

C/C加加语言可以通过更换编译器来优化性能,那Java语言是不是也可以通过更换JVM来优化性能呢?

欢迎给我留言,分享你的思考和看法。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。