启用优化后,代码的运行速度会快一个数量级。我错过了什么吗?

时间:2013-09-01 09:36:14

标签: c performance compiler-optimization

就我的(有限的)优化知识而言,我一直认为编译器优化与无关。我的意思是,我们肯定会获得相当多的百分比(可能为0)。通过使用寄存器而不是内存和展开循环来节省时间,这是一个非常粗略的第一个思考方法,但限制代码性能的基本因素实际上是算法的选择。


我最近开始了一个宠物项目 - 一种小型解释脚本语言。它编译为字节码并使用虚拟机执行它。为了便于调试,分析和测试,首先我自然地使用-O0 -g(和gcc)的clang标志编译代码,然后使用-O2。我已经time在VM上运行了一个小程序,基本上这样做(伪代码,在项目公开之前,我没有向您展示实际语法):

i = 1
sum = 0

while (i < 10000000) {
    sum = (sum + i * i) mod 1000000
    i++
}

print sum

这大致转换为以下伪装配:

load r0, 0   # sum = 0
load r1, 1   # i = 1
load r2, 1000000
load r3, 10000000

loop:
mul  r4, r1, r1 # i * i
add  r0, r0, r4 # sum += i * i
mod  r0, r0, r2 # sum %= 1000000
inc  r1
gt   r5, r1, r3 # if i > 10000000
jz   r5, loop   # then don't goto loop

ret  r0

基本上,这是一个具有10000000次迭代的紧密循环。 time报告使用-O2编译时运行0.47 ... 0.52秒,使用-O0 -g编译时运行1.51 ... 1.74秒,运行分析时运行3.16 ... 3.47秒(-pg)也已启用。

正如您所看到的,最快和最慢的执行时间之间存在7倍的差异。

这本身并不是 令人惊讶,因为我知道额外的调试信息和缺少小优化确实会让代码运行得更慢,但现在是有趣的部分。为了更真实地了解实际发生的事情,我和Lua 5.2一起玩了同样的游戏。摆弄Makefile,我发现了同样的Lua程序:

local sum = 0

for i = 1, 10000000 do
    sum = (sum + i * i) % 1000000
end

print(sum)
当使用-O0 -g -pg编译Lua时,

运行大约0.8 ... 0.87秒;当仅-O2启用时,

运行0.39 ... 0.43秒。

因此,当优化器对其执行棘手的修复时,我的代码似乎有7倍的速度提升,而Lua参考实现似乎从这些改进中受益更少。

现在我的问题是:

  1. 知道为什么会这样吗?我怀疑主要原因是Lua的创建者比我更聪明,并且更清楚知道编译器及其VM正在做什么。

  2. 我也抓住了自己的想法“好吧,这必须是过早的优化;让我把它传递给优化器,它无论如何都会完成任务”几次。这些包括在几乎所有VM指令的实现中调用的单行静态函数(我认为它们将在必要时进行内联),各种复杂表达式的使用(有时具有不那么容易预测的副作用)但是,这可以简化。 我的这种态度是否也很重要?(顺便说一下,这是正确的态度吗?)

  3. 无论如何,我应该担心这种现象吗?毕竟我的代码运行速度比Lua快1.5倍 - 而且非常好(至少对我来说这已经足够了) 。我是否应该尝试提高调试版本的性能,因为没有这样做表明我对自己的代码缺乏了解?或者我可以完全忘记它,只要发布(优化)版本足够快?

  4. (如果这个问题更适合Prog.SE或CodeReview,请告诉我,我会迁移它。)

1 个答案:

答案 0 :(得分:1)

首先,您认为算法比优化更重要的断言通常是正确的,但是相同的算法可以编码以最佳或最差地使用您正在执行的平台,因此应始终考虑优化...只是避免过早优化,而不是完全避免优化。

接下来,请记住调试版本会增加很多开销。不仅仅是禁用优化。要查看优化器正在执行的操作,请使用已禁用优化的发布版本。

Lua和您的语言之间的差异将取决于您的字节码解释器的效率。这里微不足道的效率会对这么大的循环执行速度产生巨大影响。您也可以添加优化,例如:

  • 使用“寄存器”来保存循环中使用的变量(在字节代码中,将变量加载到插槽1中,然后使用新的指令,使用简单的数组索引而不是命名变量来增加和修改插槽,然后将槽的最终值写回循环结束时的变量。
  • 检测到循环执行的次数很多,也许有一种方法可以在byecode中表达这一点,以便循环变量和逻辑由本机代码执行,而不是通过解释字节码。显然,你可以在很多情况下添加特殊情况,所以这里的诀窍是找出最常见的结构并首先优化它们,以获得最大的收益。

最后,不要担心调试代码的效率。如果您有一个可操作的解释器,那么您可以分析发布版本以查找可以改进的区域。过早地这样做也无济于事,因为没有必要优化部分完整的代码,然后发现需要更改它以支持新功能。只有当你有一个可以开始编写典型脚本的工作系统时才能以真实的方式运用你的解释器 - 你可能会发现像上面这个例子一样优化循环在日常脚本中没有任何好处。