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

5 c performance compiler-optimization

至于我(有限的)优化知识,我一直认为编译器优化并不重要.我的意思是,通过使用寄存器而不是内存和展开循环,我们肯定可以获得相当多的百分比(可能是0到100%作为一个非常粗略的第一个思想逼近),但是限制代码性能的基本因素实际上是算法的选择.


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

i = 1
sum = 0

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

print sum
Run Code Online (Sandbox Code Playgroud)

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

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
Run Code Online (Sandbox Code Playgroud)

所以基本上,这是一个具有10000000次迭代的紧密循环.time报告说它在编译时运行0.47 ... 0.52秒-O2,在1.51 ... 1.74秒时编译时使用-O0 -g3.16 ... 3.47秒-pg同时启用profiling().

如您所见,最快和最慢执行时间之间有7倍的差异.

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

local sum = 0

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

print(sum)
Run Code Online (Sandbox Code Playgroud)

当编译Lua时运行大约0.8 ... 0.87秒-O0 -g -pg,并且仅-O2在启用时运行0.39 ... 0.43秒.

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

现在我的问题是:

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

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

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

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

Jas*_*ams 1

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

接下来,请记住调试构建会增加大量开销。不仅仅是禁用优化。要查看优化器正在做什么,请使用禁用优化的发布版本。

Lua 和您的语言之间的差异将归因于您的字节码解释器的效率。这里的一点点低效率都会对如此大的循环的执行速度产生巨大的影响。您也许还可以添加优化,例如:

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

最后,不要担心调试代码的效率。当您有一个可用的解释器时,您可以分析发布版本以查找可以改进的领域。过早地这样做是没有帮助的,因为优化部分完整的代码然后发现需要更改它以支持新功能是没有意义的。只有当您拥有一个可以工作的系统时,您才能开始编写典型的脚本,以实际的方式锻炼您的解释器 - 您可能会发现优化像上面的示例这样的循环对日常脚本没有任何好处。