You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

20 KiB

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:SurvivorRatioEden和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来优化性能呢

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