GCC 中的优化

作者:M. Tim Jones

在本文中,我们将探讨 GCC 编译器工具链提供的优化级别,包括每个级别中提供的具体优化。我们还将识别需要显式指定的优化,包括一些具有架构依赖性的优化。本文讨论重点是 gcc 3.2.2 版本(发布于 2003 年 2 月),但也适用于当前版本 3.3.2。

优化级别

让我们首先看看 GCC 如何对优化进行分类,以及开发人员如何控制使用哪些优化,有时更重要的是,不使用哪些优化。GCC 提供了各种各样的优化。大多数优化分为三个级别之一,但有些优化在多个级别提供。一些优化减小了生成的机器代码的大小,而另一些优化则试图创建更快的代码,可能会增加其大小。为了完整性,默认优化级别为零,这表示根本不进行优化。这可以使用选项 -O 或 -O0 显式指定。

级别 1 (-O1)

第一级优化的目的是在短时间内生成优化的映像。这些优化通常不需要大量的编译时间来完成。级别 1 还有两个有时相互冲突的目标。这些目标是减小编译代码的大小,同时提高其性能。-O1 中提供的一组优化在大多数情况下都支持这些目标。这些优化在表 1 中标记为 -O1 的列中显示。第一级优化启用方式如下:

gcc -O1 -o test test.c
Optimization in GCC

表 1. GCC 优化及其启用的级别。

任何优化都可以在任何级别之外启用,只需使用 -f 前缀指定其名称,如下所示:

gcc -fdefer-pop -o test test.c

我们也可以启用级别 1 优化,然后使用 -fno- 前缀禁用任何特定的优化,如下所示:

gcc -O1 -fno-defer-pop -o test test.c

此命令将启用第一级优化,然后专门禁用 defer-pop 优化。

级别 2 (-O2)

第二级优化执行给定架构中所有其他支持的优化,这些优化不涉及空间-速度权衡,即两个目标之间的平衡。例如,循环展开和函数内联具有增加代码大小的效果,同时也有可能使代码更快,因此不执行这些优化。第二级优化启用方式如下:

gcc -O2 -o test test.c

表 1 显示了级别 -O2 优化。级别 -O2 优化包括所有 -O1 优化,以及大量的其他优化。

级别 2.5 (-Os)

特殊的优化级别 (-Os 或 size) 启用所有不增加代码大小的 -O2 优化;它将重点放在大小而不是速度上。这包括除对齐优化之外的所有二级优化。对齐优化跳过空间以将函数、循环、跳转和标签对齐到地址,该地址是 2 的幂的倍数,以架构相关的方式进行。跳到这些边界可以提高性能以及生成的代码和数据空间的大小;因此,这些特定的优化被禁用。大小优化级别启用方式如下:

gcc -Os -o test test.c

在 gcc 3.2.2 中,reorder-blocks 在 -Os 级别启用,但在 gcc 3.3.2 中,reorder-blocks 被禁用。

级别 3 (-O3)

第三级也是最高级别启用更多优化(表 1),将重点放在速度而不是大小上。这包括在 -O2 级别启用的优化和 rename-register。优化 inline-functions 也在此处启用,这可以提高性能,但也可能大幅增加对象的大小,具体取决于内联的函数。

gcc -O3 -o test test.c

虽然 -O3 可以生成快速代码,但映像大小的增加可能会对其速度产生不利影响。例如,如果映像大小超过可用指令缓存的大小,则可能会观察到严重的性能损失。因此,最好只在 -O2 级别进行编译,以增加映像适合指令缓存的机会。

架构规范

到目前为止讨论的优化可以显著提高软件性能和对象大小,但指定目标架构也可以产生有意义的好处。gcc 的 -march 选项允许指定 CPU 类型(表 2)。

表 2. x86 架构

目标 CPU 类型-march= 类型
i386 DX/SX/CX/EX/SLi386
i486 DX/SX/DX2/SL/SX2/DX4i486
487i486
奔腾pentium
奔腾 MMXpentium-mmx
奔腾 Propentiumpro
奔腾 IIpentium2
赛扬pentium2
奔腾 IIIpentium3
奔腾 4pentium4
威盛 C3c3
Winchip 2winchip2
Winchip C6-2winchip-c6
AMD K5i586
AMD K6k6
AMD K6 IIk6-2
AMD K6 IIIk6-3
AMD Athlonathlon
AMD Athlon 4athlon
AMD Athlon XP/MPathlon
AMD Duronathlon
AMD Tbirdathlon-tbird

默认架构为 i386。GCC 在所有其他 i386/x86 架构上运行,但可能会导致在较新的处理器上性能下降。如果您担心映像的可移植性,则应使用默认设置进行编译。如果您更关心性能,请选择与您自己的架构匹配的架构。

现在让我们看一个例子,说明如何通过关注实际目标来提高性能。让我们构建一个简单的测试应用程序,该应用程序对 10,000 个元素执行冒泡排序。数组中的元素已被反转以强制执行最坏情况。构建和计时过程如清单 1 所示。

清单 1. 架构规范对简单应用程序的影响

[mtj@camus]$ gcc -o sort sort.c -O2
[mtj@camus]$ time ./sort

real    0m1.036s
user    0m1.030s
sys     0m0.000s
[mtj@camus]$ gcc -o sort sort.c -O2 -march=pentium2
[mtj@camus]$ time ./sort

real    0m0.799s
user    0m0.790s
sys     0m0.010s
[mtj@camus]$

通过指定架构(在本例中为 633MHz 赛扬),编译器可以为特定目标生成指令,并启用仅适用于该目标的其他优化。如清单 1 所示,通过指定架构,我们看到时间缩短了 237 毫秒(提高了 23%)。

虽然清单 1 显示速度有所提高,但缺点是映像略大。使用 size 命令(清单 2),我们可以识别映像各个部分的大小。

清单 2. 清单 1 中应用程序的大小变化

[mtj@camus]$ gcc -o sort sort.c -O2
[mtj@camus]$ size sort
   text    data     bss     dec     hex filename
    842     252       4    1098     44a sort
[mtj@camus]$ gcc -o sort sort.c -O2 -march=pentium2
[mtj@camus]$ size sort
   text    data     bss     dec     hex filename
    870     252       4    1126     466 sort
[mtj@camus]$

从清单 2 中,我们可以看到映像的指令大小(文本段)增加了 28 个字节。但在本例中,为了获得速度优势,这是一个很小的代价。

数学单元优化

一些专门的优化需要开发人员显式定义。这些优化特定于 i386 和 x86 架构。例如,可以指定数学单元,尽管在许多情况下,它是根据目标架构的规范自动定义的。表 3 显示了 -mfpmath= 选项的可能单元。

表 3. 数学单元优化

选项描述
387标准 387 浮点协处理器
sse流式 SIMD 扩展 (奔腾 III, Athlon 4/XP/MP)
sse2流式 SIMD 扩展 II (奔腾 4)

默认选择是 -mfpmath=387。一个实验性选项是同时指定 sse 和 387 (-mfpmath=sse,387),这试图同时使用这两个单元。

对齐优化

在第二级优化中,我们看到引入了许多对齐优化,这些优化具有提高性能但也增加生成映像大小的效果。还有三个特定于此架构的附加对齐优化可用。-malign-int 选项允许将类型对齐到 32 位边界。如果您在 16 位对齐的目标上运行,则可以使用 -mno-align-int。-malign-double 控制双精度浮点数、长双精度浮点数和长长整型是否在双字边界上对齐(使用 -mno-align-double 禁用)。对双精度浮点数进行对齐可以在奔腾架构上提供更好的性能,但会增加额外的内存开销。

堆栈也可以使用选项 -mpreferred-stack-boundary 进行对齐。开发人员指定 2 的幂作为对齐方式。例如,如果开发人员指定-mpreferred-stack-boundary=4,堆栈将在 16 字节边界上对齐(默认值)。在奔腾和奔腾 Pro 目标上,堆栈双精度浮点数应在 8 字节边界上对齐,但奔腾 III 在 16 字节对齐时性能更好。

速度优化

对于使用标准函数(例如 memset、memcpy 或 strlen)的应用程序,-minline-all-stringops 选项可以通过内联字符串操作来提高性能。这会产生增加映像大小的副作用。

循环展开发生在通过每次迭代完成更多工作来最大限度地减少循环数量的过程中。此过程会增加映像的大小,但也可以提高其性能。可以使用 -funroll-loops 选项启用此选项。对于难以理解循环迭代次数的情况(-funroll-loops 的先决条件),可以使用 -funroll-all-loops 优化来展开所有循环。

一个有用的选项是 -momit-leaf-frame-pointer,但它的缺点是使映像难以调试。此选项将帧指针保留在寄存器之外,这意味着减少了此值的设置和恢复。此外,它使寄存器可供代码使用。优化 -fomit-frame-pointer 也可能很有用。

当在 -O3 级别或指定 -finline-functions 时操作时,可以通过特殊的参数接口指定可以内联的函数的大小限制。以下命令说明了将要内联的函数的大小限制为 40 条指令:

gcc -o sort sort.c --param max-inline-insns=40

这对于控制使用 -finline-functions 增加映像的大小非常有用。

代码大小优化

默认堆栈对齐为 4,即 16 个字。对于空间受限的系统,可以使用选项 -mpreferred-stack-boundary=2 将默认值最小化为 8 字节。当定义常量(例如字符串或浮点值)时,这些独立的值通常占用内存中的唯一位置。与其允许每个值都是唯一的,不如将相同的常量合并在一起以减少保存它们所需的空间。可以使用 -fmerge-constants 启用此特定优化。

图形硬件优化

根据指定的目标架构,某些其他扩展被启用。这些扩展也可以显式启用或禁用。诸如 -mmx 和 -m3dnow 之类的选项对于支持它们的架构自动启用。

其他可能性

我们讨论了许多可以提高性能或减小大小的优化和编译器选项。现在让我们看一下一些可能为您的应用程序带来好处的边缘优化。

-ffast-math 优化提供了可能导致正确代码的转换,但它可能不会严格遵守 IEEE 标准。请使用它,但要仔细测试。

当全局公共子表达式消除启用时(-fgcse,级别 -O2 及以上),可以使用另外两个选项来最大限度地减少加载和存储移动。优化 -fgcse-lm 和 -fgcse-sm 可以将加载和存储移动到循环外部,以减少循环内执行的指令数量,从而提高循环的性能。-fgcse-lm(加载移动)和 -fgcse-sm(存储移动)应一起指定。

-fforce-addr 优化强制编译器在对地址执行任何算术运算之前将地址移动到寄存器中。这类似于 -fforce-mem 选项,该选项在优化级别 -O2、-Os 和 -O3 中自动启用。

最后一个边缘优化是 -fsched-spec-load,它与在 -O2 及以上级别启用的 -fschedule-insns 优化一起使用。此优化允许投机性地移动某些加载指令,以最大限度地减少由于数据依赖性而导致的执行停顿。

测试改进

之前我们使用 time 命令来识别给定命令花费了多少时间。这可能很有用,但是当我们分析应用程序时,我们需要更深入地了解映像。GNU 和 GCC 编译器提供的 gprof 实用程序满足了这一需求。gprof 的完整介绍超出了本文的范围,但清单 3 说明了其用法。

清单 3. gprof 的简单示例

[mtj@camus]$ gcc -o sort sort.c -pg -O2 -march=pentium2
[mtj@camus]$ ./sort
[mtj@camus]$ gprof --no-graph -b ./sort gmon.out
Flat profile:

Each sample counts as 0.01 seconds.
  %   cumulative   self              self     total
 time   seconds   seconds    calls  ms/call  ms/call  name
100.00      0.79     0.79        1   790.00   790.00  bubbleSort
  0.00      0.79     0.00        1     0.00     0.00  init_list
[mtj@camus]$

映像使用 -pg 选项编译,以在映像中包含分析指令。执行映像后,会生成一个 gmon.out 文件,该文件可以与 gprof 实用程序一起使用,以生成人类可读的分析数据。在此 gprof 用法中,我们指定了 -b 和 --no-graph 选项。为了获得简要输出(排除详细的字段说明),我们指定了 -b。--no-graph 选项禁用函数调用图的发出;它识别哪些函数调用哪些其他函数以及在每个函数上花费的时间。

阅读清单 3 中的示例,我们可以看到 bubbleSort 被调用了一次,耗时 790 毫秒。init_list 函数也被调用了,但它完成的时间少于 10 毫秒(配置文件采样的分辨率),因此其值为零。

如果我们更关心对象大小而不是速度的变化,我们可以使用 size 命令。要获得更具体的信息,我们可以使用 objdump 实用程序。要查看对象中的函数列表,我们可以搜索 .text 段,如下所示:

objdump -x sort | grep .text

从这个简短的列表中,我们可以识别出我们感兴趣的要更好理解的特定函数。

检查优化

GCC 优化器本质上是一个黑匣子。指定了选项和优化标志,生成的代码可能会或可能不会改进。当它们确实改进时,生成的代码中到底发生了什么?可以通过查看生成的代码来回答这个问题。

要从编译器发出目标指令,可以指定 -S 选项,例如:

gcc -c -S test.c

这告诉 gcc 仅编译源文件 (-c) ,但也为源文件发出汇编代码 (-S)。生成的汇编输出将包含在文件 test.s 中。

前一种方法的缺点是您只能看到汇编代码,没有给出实际指令大小的任何方面。为此,我们可以使用 objdump 来发出汇编指令和本机指令,如下所示:

gcc -c -g test.c
objdump -d test.o

对于 gcc,我们指定仅使用 -c 进行编译,但我们也希望在对象 (-g) 中包含调试信息。使用 objdump,我们指定 -d 选项来反汇编对象中的指令。最后,我们可以使用以下命令获取汇编代码穿插的源代码列表:

gcc -c -g -Wa,-ahl,-L test.c

此命令使用 GNU 汇编器发出列表。-Wa 选项用于将 -ahl 和 -L 选项传递给汇编器,以将包含高级源代码和汇编代码的列表发出到标准输出。-L 选项将本地符号保留在符号表中。

结论

所有应用程序都是不同的,因此没有神奇的优化配置和选项开关可以产生最佳结果。获得良好性能的最简单方法是依赖 -O2 优化级别;如果您对可移植性不感兴趣,请使用 -march= 指定目标架构。对于空间受限的应用程序,应首先考虑 -Os 优化级别。如果您有兴趣最大限度地提高应用程序的性能,最好的方法是尝试不同的级别,然后使用各种实用程序来检查生成的代码。启用和/或禁用某些优化也可能有助于利用优化器来获得最佳性能。

本文资源: www.linuxjournal.com/article/7971

M. Tim Jones (mtj@mtjones.com) 是位于科罗拉多州朗蒙特的 Emulex Corp. 的高级首席工程师。除了是一名嵌入式固件工程师之外,Tim 最近还完成了著作 BSD 多语言视角下的套接字编程。他曾为通信和研究卫星编写内核,现在为网络产品开发嵌入式固件。

加载 Disqus 评论