:如何实现高性能的 C 程序?)
引用使用 C 语言正确实现的程序可以享受到最高的运行时性能。因此如何编写具有“最高”执行性能的代码是每个 C 程序员都在竭尽所能去探索的一个问题。那么接下来的两讲我们就来看看如何编写高质量的 C 代码来让我们的程序达到最佳的运行状态。这一讲我主要会为你介绍四个优化 C 代码的技巧它们分别是利用高速缓存、利用代码内联、利用 restrict 关键字以及消除不必要内存引用。如何衡量程序的运行性能在开始正式介绍常用的性能优化技巧前我们首先需要知道如何衡量一个应用程序的运行性能。通常我们可以采用最简单和直观的两个宽泛指标即内存使用率和运行时间。对于具有相同功能的两个程序在完成相同任务时哪一个的内存使用率更低且运行时间更短则程序的整体性能相对更好。我们可以将这两个指标再进一步细分。比如程序的内存使用率可以细分为程序在单位时间内的主内存使用率、L1-L3 高速缓存使用率、程序堆栈内存使用率等指标。而运行时间也可以细分为程序总体运行时间墙上时钟时间、用户与操作系统时间CPU 时间以及空闲时间与 IO 等待时间等。至于这些指标的制定和使用方式属于性能监控的另一个话题这里我们就不展开了。但你需要知道的是无论程序多么复杂运行时间和内存消耗这两个指标都是可用于观察程序性能情况的基本指标。这一讲和下一讲我们会主要讨论可用于优化程序性能的具体编码方式。而采用这些编码方式能够保证你的代码在所有运行环境中都拥有一个相对稳定且高效的执行情况。但你也需要注意程序的实际运行可能会受到操作系统、编译器、网络环境等多方面的影响因此即使采用这些编码方式也并不一定保证程序可以在所有执行环境上都能够获得较大的性能提升。另外需要说明的是这一讲我会以程序对应的 x86-64 汇编代码为例来深入介绍各种优化技巧背后的逻辑。不过这些基于 C 代码的性能优化策略都是相通的只要掌握了它们的原理在不同的平台体系上你都可以做到举一反三运用自如。下面我们就来看具体的编码技巧吧。技巧一利用高速缓存相信你在选购电脑时经常会遇到 L1、L2、L3 高速缓存这几个指标由此可见这些概念在衡量计算机整体质量中的重要性。那么它们究竟代表什么呢我们都清楚缓存的重要性通过将常用数据保存在缓存中当硬件或软件需要再次访问这些数据时便能以相对更快的速度进行读取和使用。而在现代计算机体系中L1、L2、L3某些体系可能有 L4 缓存但不普遍一般分别对应于 CPU 芯片上的三个不同级别的高速缓存这些缓存的数据读写速度依次降低但可缓存数据的容量却依次升高。而这些位于 CPU 芯片上的缓存则主要用于临时存放 CPU 在最近一段时间内经常使用的数据和指令。L1 到 L3 缓存直接由 CPU 硬件进行管理当 CPU 想要读取某个数据时它会按照一定规则优先从 L1 缓存中查找如果没有找到则再查找 L2 缓存以此类推。而当在所有高速缓存中都没有找到目标数据时CPU 便会直接从内存中读取。与直接从 L1 中读取相比这个过程花费的时间会多出上百倍所以你可以很清楚地看到优先将数据存放在高速缓存中的重要性。高速缓存之所以能够提升性能一个重要的前提便在于“局部性locality原理”。该原理通常被分为两个方面即“时间局部性”和“空间局部性”它们的内容分别如下所示。时间局部性被引用过一次的内存位置可能在不远的将来会再被多次引用空间局部性如果一个内存位置被引用了一次那么程序很可能在不久的将来引用其附近的另一个内存位置。局部性本身是处理器访问内存行为的一种趋势因此如果一个程序在设计时能够很好地满足局部性原理其运行便可能会有更高的性能表现。接下来我们就看看如何让程序设计尽量满足这些原则。在这里我给出了一段不满足局部性原理的代码你可以先思考下这段代码有什么问题有没有可以进一步提升性能的空间。#define M 128 #define N 64 int sum(int a[M][N]) { int i, j, sum 0; for (j 0; j N; j) { for (i 0; i M; i) { sum a[i][j]; } } return sum; }可以看到的是这段代码每次的内存访问过程即访问数组 a 中的元素都没有尽可能地集中在一段固定的内存区域上。相反访存的过程发生在多个不同位置且各个位置之间的跨度很大。这一点从代码中 sum 变量按照“列优先”顺序先循环变量 i后循环变量 j访问数组元素的方式就可以得出。我们知道C 语言中二维数组的元素是按照“行优先”的方式存储的也就是说在上面的数组 a 中连续不同的 j 将引用数组中连续的内存位置。CPU 在缓存数据时会按照局部性原则缓存第一次访问内存时其附近位置的一段连续数据。而列优先的访问方式则使得数组中数据的引用位置变得不连续这在一定情况下可能会导致上一次已被放入高速缓存中的数据在下一次数据访问时没有被命中。而由此带来的直接对内存的频繁访问则会导致程序整体性能的降低。因此我们的结论是上述代码的设计并没有很好地满足空间局部性其运行效率并没有被最大化。你可以通过下图来形象地理解这个问题可以看到当代码的内层循环第一次访问数组 a 时假设 CPU 会将临近的 4 个元素放入到它的高速缓存中。而由于代码采用了“行优先”访问因此当下一次访问数组元素时位于高速缓存中的数据便不会被命中。以此类推后续的每次访问都会产生这样的问题。因此为了能更好地利用 CPU 高速缓存你可以参考这些原则来编写代码尽量确保定义的局部变量能够被多次引用在循环结构中以较短的步长访问数据对于数组结构使用行优先遍历循环体越小循环迭代次数越多则局部性越好。还记得我在上一讲中提到的自定义数据对齐吗通过合理使用 alignas 关键字我们也可以优化某些特定场景下的代码来让它在最大程度上利用高速缓存。来看下面这个例子struct data { char x; alignas(64) char y[118]; };在这段代码中我们定义了一个名为 data 的结构。它包含两个字段一个为字符类型的 x另一个为包含有 118 个元素的字符数组 y。而通过指定 alignas(64) 标识符我们限制字段 y 在内存中的起始地址需要在 64 字节上对齐。实际上在计算机内部高速缓存是以“缓存行Cache Line”的形式被组织的。也就是说一大块连续的高速缓存会被分为多个组每个组中有多个行而每个行则具有固定大小。当发生缓存不命中时CPU 会将数据从低层次缓存中以固定的“块大小通常为缓存行大小”为单位拷贝到高层次缓存中。而为了减少 CPU 需要进行的内存拷贝次数我们希望连续的数据能被组织在尽可能少的缓存行中。另一方面内存与高速缓存之间的映射关系一般与数据在内存中的具体地址有关。比如对于采用“直接映射”方式的缓存来说假设缓存行大小为 64 字节若某段数据起始于内存中对齐到 64 字节的地址而当它被拷贝到高速缓存中时便会从缓存行的开头处开始放置数据这在最大程度上减少了这段连续数据需要占用的缓存行个数当从缓存行中间开始存放数据时字段 y 可能需要占用三个缓存行。上面那段代码便是如此。技巧二代码内联第二种常用于性能优化的方式是代码内联Inlining。这种方式很好理解下面我们直接来看个例子由此理解内联的概念和它对程序运行的影响。C99 标准引入了一个名为 inline 的关键字通过该关键字我们可以建议编译器将某个方法的实现内联到它的实际调用处。来看下面这个简短的例子#include stdio.h static inline int foo() { return 10; } int main(void) { int v foo(); printf(Output is: %d\n, v); return 0; }在这段代码中我们使用 inline 关键字标注了方法 foo并在 main 函数内将 foo 方法的调用返回结果赋值给了变量 v。为了能够看清 inline 关键字对程序实际运行的影响我们还需要查看上述 C 代码对应的汇编代码具体如下所示.LC0: .string Output is: %d\n main: sub rsp, 8 mov esi, 10 mov edi, OFFSET FLAT:.LC0 xor eax, eax call printf xor eax, eax add rsp, 8 ret可以看到在这段汇编代码中实际上只有 main 函数的机器指令实现而 foo 函数的具体定义则已经被替换到了它在 main 函数中的实际调用位置处对应 mov esi, 10 这一行 。通过这种方式程序不再需要使用 call 指令来调用 foo 函数。这样做的好处在于可以省去 call 指令在执行时需要进行的函数栈帧创建和销毁过程以节省部分 CPU 时钟周期。而通过这种方式得到的性能提升通常在函数被多次调用的场景中更加显而易见。inline 固然好用但我们也要注意这一点函数本身作为 C 语言中对代码封装和复用的主体不恰当的内联也会导致程序可执行二进制文件的增大以及操作系统加载程序时的效率变低。一般情况下内联仅适用于那些本身实现较为短小且可能会被多次调用的函数。同时inline 关键字也仅是程序员对编译器提出的一个建议具体是否会被采纳还要看具体编译器的实现。而对于大部分常用编译器来说在高优化等级的情况下它们也会默认采用内联来对代码进行优化。当然除此之外你也可以选择通过宏来进行预处理时的代码内联。采用这种方式的话你需要将 C 代码封装成对应的宏并在需要内联的地方展开。技巧三restrict 关键字C99 标准新增了一个名为 restrict 的关键字可以优化代码的执行。该关键字只能用于指针类型用以表明该指针是访问对应数据的唯一方式。在计算机领域有一个名为 aliasing 的概念。这个概念是说内存中的某一个位置可以通过程序中多于一个的变量来访问或修改其包含的数据。而这可能会导致一个潜在的问题即当通过其中的某个变量修改数据时便会导致所有与其他变量相关的数据访问发生改变。因此aliasing 使得编译器难以对程序进行过多的优化。而在 C 语言中restrict 关键字便可以解决这个问题。当然如果你学习过 Rust这也是其所有权机制的核心内容。下面我们来看一个例子。#include stdio.h void foo(int* x, int* y, int* restrict z) { *x *z; *y *z; } int main(void) { int x 10, y 20, z 30; foo(x, y, z); printf(%d %d %d, x, y, z); return 0; }在这段代码中函数 foo 共接收三个整型指针参数它的功能是将第三个指针指向变量的值累加到前两个指针指向的变量上。其中第三个参数 z 被标记为了 restrict 这表明我们向编译器做出了这样一个承诺即在函数体 foo 内部我们只会使用变量 z 来引用传入函数第三个指针参数对应的内存位置而不会发生 aliasing。这样做使得编译器可以对函数的机器码生成做进一步优化。来看下上面这段 C 代码对应的汇编代码foo: mov eax, DWORD PTR [rdx] add DWORD PTR [rdi], eax add DWORD PTR [rsi], eax ret我们可以发现在使用 restrict 关键字标注了 foo 函数的第三个参数后在为指针 y 进行值累加前编译器不会再重复性地从内存中读取指针 z 对应的值对应上面第一行代码。而这对程序的执行来说无疑是一种性能上的优化。另外你需要注意的是若一个指针已被标记为 restrict 但在实际使用时却发生了 aliasing此时的行为是未定义的。技巧四消除不必要的内存引用在某些情况下可能我们只需要对程序的结构稍作调整便能在很大程度上提升程序的运行性能。你可以先看看下面这段代码思考下优化方式#define LEN 1024 int data[LEN] { ... }; int foo(int* dest) { *dest 1; for (int i 0; i LEN; i) { *dest *dest * data[i]; } }在这段代码中函数 foo 主要用来计算数组 data 中所有元素的乘积并将计算结果值拷贝给指针 dest 所指向的整型变量。函数的逻辑很简单但当我们仔细观察函数 foo 内部循环逻辑的实现时便会发现问题所在。在这个循环中为了保存乘积在每一次循环时产生的累积值函数直接将这个值更新到了指针 dest 指向的变量中。并且在每次循环开始时程序还需要再从该变量中将临时值读取出来。我们知道从内存中读取数据的速度是慢于寄存器的。因此这里我们可以快速想到一个优化方案。优化后的代码如下所示#define LEN 3 int data[LEN] { 1,2,4 }; int foo(int* dest) { register int acc 1; for (int i 0; i LEN; i) { acc acc * data[i]; } *dest acc; }在上面的代码中我们一共做了两件事情将循环中用于存放临时累积值的 “*dest” 替换为一个整型局部变量 “acc”在定义时为该变量添加额外的 register 关键字以建议编译器将该值存放在寄存器中而非栈内存中。通过消除不必要的内存引用我们就能够减少程序访问内存的次数进而提升一定的性能。总结好了讲到这里今天的内容也就基本结束了。最后我来给你总结一下。这一讲我主要介绍了可用于 C 代码优化的几种常见策略它们分别是利用高速缓存、使用代码内联和 restrict 关键字以及消除不必要的内存引用具体如下高速缓存利用了 CPU 的局部性使得满足局部性的程序可以更加高效地访问数据代码内联通过直接使用函数定义替换函数调用的方式减少了程序调用 call 指令带来的额外开销restrict 关键字通过限制指针的使用避免 aliasing进而给予了编译器更多的优化空间消除不必要的内存引用则是通过减少程序与内存的交互过程来进一步提升程序的运行效率。