为什么在循环中包含额外的汇编指令会提高执行速度? [重复]

问题描述 投票:1回答:3

我有两段代码从gdb转储生成以下装配线指令。

# faster on my CPU

# Dump of assembler code for function main():
# This was produced when I declared increment inside the loop
# <snipped> I can put back the removed portions if requested.
0x00000000004007ee <+17>:   movq   $0x0,-0x8(%rbp)
0x00000000004007f6 <+25>:   movl   $0x0,-0xc(%rbp)
0x00000000004007fd <+32>:   jmp    0x400813 <main()+54>
0x00000000004007ff <+34>:   movl   $0xa,-0x1c(%rbp)
0x0000000000400806 <+41>:   mov    -0x1c(%rbp),%eax
0x0000000000400809 <+44>:   cltq   
0x000000000040080b <+46>:   add    %rax,-0x8(%rbp)
0x000000000040080f <+50>:   addl   $0x1,-0xc(%rbp)
0x0000000000400813 <+54>:   cmpl   $0x773593ff,-0xc(%rbp)
0x000000000040081a <+61>:   jle    0x4007ff <main()+34>
# <snipped>
# End of assembler dump.

然后是这段代码。

# slower on my CPU

# Dump of assembler code for function main():
# This was produced when I declared increment outside the loop.
# <snipped>
0x00000000004007ee <+17>:   movq   $0x0,-0x8(%rbp)
0x00000000004007f6 <+25>:   movl   $0xa,-0x1c(%rbp)
0x00000000004007fd <+32>:   movl   $0x0,-0xc(%rbp)
0x0000000000400804 <+39>:   jmp    0x400813 <main()+54>
0x0000000000400806 <+41>:   mov    -0x1c(%rbp),%eax
0x0000000000400809 <+44>:   cltq   
0x000000000040080b <+46>:   add    %rax,-0x8(%rbp)
0x000000000040080f <+50>:   addl   $0x1,-0xc(%rbp)
0x0000000000400813 <+54>:   cmpl   $0x773593ff,-0xc(%rbp)
0x000000000040081a <+61>:   jle    0x400806 <main()+41>
# <snipped>
# End of assembler dump.

可以看出,唯一的区别是这一行的位置:

0x00000000004007f6 <+25>:   movl   $0xa,-0x1c(%rbp)

在一个版本中它位于循环内部,而在另一个版本中它位于循环外部。我希望循环内部较少的版本运行得更快,但运行速度更慢。

为什么是这样?

Extra Info

如果相关,这里是我自己的实验的细节和产生它的c ++代码。

我在运行Red Hat Enterprise Linux Workstation(V7.5)或Windows 10的多台计算机上测试了这一点。所有计算机都有Xeon处理器(Linux)或i7-4510U(Windows 10)。我使用没有任何标志的g ++进行编译,或者使用2017年的Visual Studio社区版。所有结果都同意:在循环中声明变量会导致加速。

当在64位Linux机器上的循环内声明增量时,多次运行的运行时间为〜5.00s(非常小的方差)。

当在同一台机器上的循环外声明增量时,多次运行的运行时间为~5.40s(同样,变化非常小)。

在循环内声明变量。

#include <ctime>
#include <iostream>

using namespace std;

int main()
{
    clock_t begin, end;

    begin = clock();

    long int sum = 0;

    for(int i = 0; i < 2000000000; i++)
    {
        int increment = 10;
        sum += increment;
    }
    end = clock();
    double elapsed = double(end - begin) / CLOCKS_PER_SEC;
    cout << elasped << endl;
}

在循环外声明变量:

#include <ctime>
#include <iostream>

using namespace std;

int main()
{
    clock_t begin, end;

    begin = clock();

    long int sum = 0;
    int increment = 10;
    for(int i = 0; i < 2000000000; i++)
    {
        sum += increment;
    }
    end = clock();
    double elapsed = double(end - begin) / CLOCKS_PER_SEC;
    cout << elasped << endl;
}

由于评论的反馈,我对此问题进行了大量编辑。现在好多了,谢谢那些帮助改进它的人!我向那些已经努力回答我不明确的问题的人道歉,如果答案和评论似乎无关紧要,那是因为我无法沟通。

performance assembly x86 intel
3个回答
3
投票

虽然通常情况下我们不需要保留的值可以转储到寄存器中而不会被拉入主存储器,只要寄存器可用,报价最好过度简化(或过时)最糟糕的是胡说八道。

2018年的编译器知道您是否要重新使用该值,无论是否在循环体内找到声明。好的,所以在循环中声明变量会使编译器的工作变得容易一些,但是编译器很聪明。

在这样一个简单的例子中移动声明只不会对现代工具链编译的程序产生任何影响。 C ++程序不是机器指令的一对一映射;它是对程序的描述。人们说“差异只是学术上的”的原因是差异只是学术上的。就像字面意思一样。


2
投票

一些忠告

首先,您没有使用优化进行编译。那是个错误。在调试时,这甚至不是一个好主意,除非您需要通过单步执行某些代码来捕获逻辑错误。将发出的代码与最终优化版本有很大不同,它不会有相同的错误。您希望编译器在代码中公开错误的假设!

其次,更好的方法是查看您生成的汇编代码是使用-S标志进行编译,并使用.S扩展名检查生成的文件。

您通常应该编译启用优化和警告,可能是-g -O -Wall -Wextra -Wpedantic -Wconversion加上-std=c++17或您编写的任何语言版本。您可能需要设置CFLAGS / CXXFLAGS环境变量,或创建一个makefile。

这里发生了什么

如果没有优化,即使将increment保存在寄存器中或将其折叠成常量,编译器也会受到大脑损坏。与int increment = 10;对应的代码转储中的行是movl $0xa,-0x1c(%rbp),它将变量溢出到堆栈中并将常量10加载到该内存位置。

在代码片段中

long int sum = 0;

for(int i = 0; i < 2000000000; i++)
{
    int increment = 10;
    sum += increment;
}

编译器可以很容易地看到,increment不能在循环体外部改变或使用。它仅在循环体的范围内声明,并且在每次调用开始时始终设置为10。编译器只需要静态分析循环体,以确定increment只是一个可以折叠的常量。

现在比较:

long int sum = 0;
int increment = 10;
for(int i = 0; i < 2000000000; i++)
{
    sum += increment;
}

在这个片段中,increment就像sum。这两个变量都在循环外声明,并且都没有声明为常量。从理论上讲,它的值可能会在循环的迭代之间发生变化,就像sum一样。一个知道C的人可以很容易地看到increment在循环运行时不会改变,一个体面的编译器应该也能够,但是当你完全关闭优化时,这个不能。

未经优化的代码甚至不会在循环调用之间的寄存器中保留此变量!查看代码转储,它在每次迭代时执行的第一条指令是mov -0x1c(%rbp),%eax。这会从内存中重新加载increment的值。这是经济放缓的直接原因。

更多建议

由于increment是编译时已知的常量,因此在C ++或C,constexpr中将其声明为static const是个好主意。在这样一个简单的例子中,现代编译器不应该需要提示,但在更复杂的情况下,它可能仍然有所作为。

真正的优势在于人类维护者。它告诉编译器阻止你在脚下射击。我倾向于把我的大部分代码写成静态单一赋值,这是大多数C编译器无论如何都要将程序转换成的,因为它们对于计算机和人类来说都更容易理解和推理。也就是说,只要有可能,所有变量都声明为常量,只设置一次。每个值只有一个含义。您认为在更新后使用旧值或在更新之前使用新值的错误不会发生。优化编译器负责为您将值移入和移出寄存器。


1
投票

这显然是没有优化的,首先是那些只是死代码所以它会消失。编译器按照你的要求做了,你在循环中添加了一个额外的赋值,就像那样简单,没有优化你也不会感到惊讶。如果您对性能感兴趣,那么您希望使用优化代码。您的实验存在优化问题。这与寄存器和变量保存无关。一个循环中有两个操作,另一个循环中有一个操作。你需要继续努力并理解更多,这些简单的测试找到其他问题,如对齐。

我可以将两个指令减去并跳转,如果不是零则返回到减法,具体取决于体系结构,实现,运行的位置和对齐这两个指令可以具有相当不同的性能,相同的精确机器代码,相同的计算机/处理器,即使你做了非常准确的时间测量,你也没有在这里做。基本上这样的循环用于证明基准测试不是多么好/有用。

你不能让编译器同时注册这些变量并优化而不是将整个事物作为死代码删除,而不是可靠的。因此,使用像这样的高级语言,您通过典型的实现导致内存访问,如果在共享计算机上反对dram,则希望它被缓存,但可能不是。即使缓存,第一个循环也可能花费数百个周期或更长时间,并且有时可能会被注意到,这取决于循环的数量和测量的准确性。

你的开始/结束变量不是易变的,不是那是一个神奇的解决方案,但是我看到优化器在循环之后或之前放置了两个读取,因为一个东西与另一个不相关,导致测量结果不好。

Abrash的集会禅和其他书籍有助于学习性能和陷阱。良好衡量的重要性,并注意不要错过正确的假设路径。

请注意,先前的问题,如此问题,应该主要以意见为基础,但先前的问题确实有一个准确和完整的答案,这是选定的答案。就像这个,你必须衡量它。从编写的代码,按照设计的测试,你的结果可以/将会有所不同,是的,你可以有更多的指令执行速度比更少,通常不难证明。没有更好,更快,更便宜的问题,这些话通常会导致无法回答的问题。在循环中有两个操作而不是优化(这个代码不是为了优化而设计的,并且使用volatile不一定能保存它),编译器很可能只在另一个操作中执行两个操作。加上根据需要做的开销。但我可以选择一个平台,并可能显示更多操作的循环更快。你也可以有经验。

因此,尽管有两个操作并且没有优化,但是一个操作循环仍然可能较慢,但如果在大多数实验中两个操作较慢,则不会感到惊讶。

© www.soinside.com 2019 - 2024. All rights reserved.