如果你已经到了这个博客系列的中间,你可能想从最开始 开始 .
这篇文章研究了称为死代码消除(Dead Code Elimination)的优化,我将其缩写为DCE。 它做了它所说的:丢弃任何结果不是实际的计算 习惯于 通过程序。
现在,您可能会断言您的代码 只有 使用的结果,而不是 不 用法:毕竟,只有白痴才会无缘无故地添加无用的代码——计算代码的前1000位 圆周率 例如,在做一些有用的事情的同时。 那么,DCE优化什么时候会有效果呢?
在本系列中如此早地描述DCE的原因是,它可能会在我们的整个过程中造成破坏和混乱 探索其他更有趣的优化:考虑这个小示例程序,它保存在一个名为 总和.cpp :
int main(){ 长s=0; for(长i=1;i<=1000000000++i) s+=i; }
我们感兴趣的是循环的执行速度,计算前10亿个整数的和。 (是的,这是一个非常愚蠢的例子,因为结果有一个封闭的公式,在高中教过。 但这不是重点)
使用以下命令生成此程序: CL/Od/FA总和.cpp 并按命令运行 总和 . 请注意,此版本 禁用 优化,通过 /外径 开关。 在我的电脑上,运行大约需要4秒钟。 现在尝试编译优化的速度,使用 CL/O2/FA总和.cpp . 在我的电脑上,这个版本运行得很快,没有明显的延迟。 编译器在优化我们的代码方面真的做得这么出色吗? 答案是否定的(但令人惊讶的是,答案也是肯定的):
让我们先看看它为 /外径 箱子,存放在 总和.asm . 我已经修剪和注释 仅显示循环体的文本:
压敏电阻 QWORD PTR s$[rsp],0 ;; 长s=0 压敏电阻 QWORD PTR 1美元[rsp],1 ;; 长i=1 jmp公司 短$LN3@main $LN2@main : 压敏电阻 rax,QWORD PTR 1美元 ;; 雷克斯= 我 股份有限公司 雷克斯 ;; rax+=1 压敏电阻 QWORD PTR 1美元[rsp],rax ;; i=rax $LN3@main : 化学机械抛光 QWORD PTR 1美元[rsp],1000000000 ;; 我<=1000000000? jg公司 短$LN1@main ;; 不-我们结束了 压敏电阻 rax,QWORD PTR 1美元 ;; 雷克斯= 我 压敏电阻 rcx,QWORD PTR s$[rsp] ;; rcx=秒 添加 rcx、rax ;; rcx+=rax 压敏电阻 雷克斯,雷克斯 ;; rax=rcx 压敏电阻 QWORD PTR s$[rsp],英国皇家航空公司 ;; s=rax jmp公司 短$LN2@main  ;;环 $LN1@main:
说明书看起来很像你所期望的。 变量 我 在堆栈上偏移 一美元 从RSP寄存器指向的位置;在asm文件的其他地方,我们发现 一美元 = 0. 我们使用 雷克斯 注册为增量 我 . 同样,变量 s 保持在堆栈上(偏移 s码$ 从 RSP公司 登记册;在asm文件的其他地方,我们发现 s码$ = 8). 代码使用 RCX公司 计算循环中每次的运行和。
注意我们是如何加载 我 从其“家”的位置上堆叠,每一次的循环;并将新值写回其“主”位置。 变量也一样 s . 我们可以用“na”来描述这个代码ïve“–它是由一个哑编译器生成的(例如,一个禁用了优化的编译器)。 例如,在循环的每次迭代中保持对内存的访问是一种浪费,而我们本可以保持循环的值 我 和 s 登记在案。
非优化代码到此为止。 为优化案例生成的代码呢? 让我们看看相应的 总和.asm 对于优化的, /氧气 ,内部版本。 同样,我已经将文件精简为实现循环体的部分,答案是:
;; 这里什么都没有!
是的,它是空的! 有 不 计算 s .
你可能会说,这个答案显然是错的。 但我们该怎么做呢 知道 答案是错的? 优化器推断程序没有 事实上 利用答案 s 在任何一点上;因此它不必费心包含任何代码来计算它。 你不能说答案是错的,除非你检查一下答案,对吗?
我们刚刚成为受害者,在街上被DCE抢劫,丢了我们的衬衫。 如果你没有观察到一个答案,程序(通常)不会计算这个答案。
你可能会把优化器中的这种效果看作是类似于量子物理学中的一个基本结果,这一结果在一篇关于大众科学的文章中经常被解释为 如果树倒了 在森林里,周围没有人听见,它会发出声音吗?”
好吧,让我们在程序中“观察”答案,添加一个 打印F 变量的 s ,如下所示:
#包括
这个 /外径 这个程序的版本打印正确的答案,仍然需要大约4秒的时间来运行。 这个 /氧气 版本打印相同的正确答案,但运行速度要快得多。 (请参阅下面的可选部分,了解“快得多”的值(事实上,加速比大约是7倍)
在这一点上,我们已经解释了这篇博文的要点:永远要非常小心 在分析编译器优化和衡量它们的好处时,我们没有被DCE误导。 这里有四个步骤来帮助注意,以及 避开,未请求DCE注意:
- 检查计时是否没有突然提高一个数量级
- 检查生成的代码(使用 /FA公司 开关)
- 如有疑问,添加战略 打印F
- 将感兴趣的代码放在自己的代码中 .CPP文件 文件,与保存文件的文件分开 主要的 . 只要你这么做,这就行 不 请求整个程序优化(通过 /德国劳埃德船级社 切换,稍后我们将讨论)
然而,我们可以从中得到一些更有趣的教训 小小的例子。 我在下面将这些部分标记为“可选-1”到“可选-4”。
为什么我们的 /氧气 版本(包括 打印F 打败DCE),比 /外径 版本? 下面是为 /氧气 版本,从 总和.asm 文件:
异或 edx,edx公司 压敏电阻 eax,1个 压敏电阻 ecx、edx 压敏电阻 r8d,edx公司 压敏电阻 r9d,edx公司 npad公司 13 $LL3@main : 股份有限公司 r9级 添加 r8、2级 添加 rcx,3个 添加 r9,雷克斯 ;; r9级 = 2 8 18 32 50 … 添加 r8,雷克斯 ;; r8级 = 3 10 21 36 55 … 添加 rcx、rax ;; rcx=4 12 24 40 60… 添加 黑索今,黑索今 ;; 黑索今=1 6 15 28 45 … 添加 拉克斯,4 ;; rax=1 5 9 13 17 … 化学机械抛光 1000000000南非兰特 ;; 我<=1000000000? jle公司 短$LL3@main ;; 是的,所以回过头来
请注意,循环体包含的指令数与未优化构建的指令数大致相同,但它仍在运行 许多的 更快。 这主要是因为这个优化循环中的指令使用寄存器,而不是内存位置。 众所周知,访问寄存器比访问内存快得多。 给你 近似延迟 这演示了内存访问如何将程序的速度减慢到蜗牛般的速度:
位置 |
延迟 |
注册 | 1个周期 |
L1级 | 4个周期 |
L2级 | 10个周期 |
三级 | 75个周期 |
德拉姆 | 60纳秒 |
因此,非优化版本是读写堆栈位置,它将快速迁移到二级缓存(10周期访问时间)和一级缓存(4周期访问时间)。 两者都比在寄存器中完成所有计算要慢,访问时间大约为一个周期。
但是这里还有更多的东西可以让代码运行得更快。 注意 /外径 版本在循环中每次循环计数器递增1。 但是 /氧气 版本在循环中每次将循环计数器(保存在寄存器RAX中)递增4。 嗯?
优化器有 展开的 循环。 因此,它在每次迭代中添加四项,如下所示:
s=(1+2+3+4)+(5+6+7+8)+(9+10+11+12)+(13+。
由 展开 在这个循环中,循环结束的测试是每四次迭代进行一次,而不是每次迭代都进行一次,因此CPU花费更多的时间做有用的工作,而不是永远检查它是否可以停止!
此外,它没有将结果累加到一个位置,而是决定使用4个单独的寄存器,累加4个部分和,如下所示:
黑索今=1+5+ 9 + 13 + … = 1, 6, 15, 28 … R9级 = 2 + 6 + 10 + 14 + … = 2, 8, 18, 32 … R8级 = 3 + 7 + 11 + 15 + … = 3, 10, 21, 36 … RCX=4+8+12+16+… = 4, 12, 24, 40 …
在循环结束时,它将这四个寄存器中的部分和相加,得到最终答案。
(我将留给读者一个练习,让读者了解优化器如何处理行程计数不是4的倍数的循环)
早些时候,我说过 /氧气 程序的版本,没有 打印F 抑制剂 ,“跑得太快,没有明显的延迟”。 下面是一个程序,它使这一说法更加精确:
#包括
它使用 查询性能计数器 测量经过的时间。 (这是一个“锯下”版本的 高精度定时器 在以前的博客中描述)。 在度量性能时,有许多注意事项需要牢记(您可能希望浏览 我以前写的清单 ),但它们对这个特殊情况并不重要,我们马上就会看到:
在我的电脑上 /外径 此程序的版本打印 差异 大约700万 一些事情 . (答案的单位并不重要——只要知道随着程序运行时间的延长,这个数字会越来越大)。 这个 /氧气 版本打印的值 差异 第0页。 原因,如上所述,是我们的朋友DCE。
当我们添加 打印F 为了防止DCE /外径 版本运行量约100万 一些事情 –加速约7倍。
如果我们再仔细看看这篇文章中的汇编程序清单,我们会在初始化寄存器的程序部分发现一些惊喜:
异或 edx,edx公司 ;; rdx=0 (64位!) 压敏电阻 eax,1个 ;; rax=i=1(64位!) 压敏电阻 ecx、edx ;; rcx=0 (64位!) 压敏电阻 r8d,edx公司 ;; r8级 = 0 (64位!) 压敏电阻 r9d,edx公司 ;; r9级 = 0 (64位!) npad公司 13 &国家统计局p; ;; 多字节nop对齐填充 $LL3@main:
首先回顾一下原始C++程序的用法 长的长的 循环计数器和总和的变量。 在VC++编译器中,这些映射到64位整数。 因此,我们应该期望生成的代码使用x64的64位寄存器。
我们已经在之前的帖子中解释过 异或寄存器,寄存器 是将 规则 . 但我们的第一个指示是应用 十