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

时间:2018-09-04 16:47:33

标签: performance assembly x86 intel

我有两个代码,它们是从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)

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

这是为什么?

其他信息

如果相关,以下是我自己的实验以及产生该实验的c ++代码的详细信息。

我在运行Red Hat Enterprise Linux Workstation(版本7.5)或Windows 10的多台计算机上进行了测试。所有有问题的计算机都具有Xeon处理器(Linux)或i7-4510U(Windows 10)。我使用不带任何标志的g ++进行编译,或者使用Visual Studio Community Edition2017。所有结果都一致:在循环中声明变量导致加速。

在64位Linux计算机上的循环内声明增量时,多次运行的运行时间约为5.00s(差异很小)。

在同一台机器上的循环外声明增量时,多次运行的运行时间约为5.40秒(再次,方差很小)。

在循环中声明变量。

#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;
}

由于评论的反馈,我对这个问题进行了大量编辑。现在好多了,谢谢那些帮助改进它的人!对于那些已经回答了我不清楚的问题的人表示歉意,如果答案和评论似乎无关紧要,那是因为我无法沟通。

3 个答案:

答案 0 :(得分:3)

虽然我们确实可以将不需要保留的值转储到寄存器中,而不必提取到主存储器中,但这是正确的,但只要有可用的寄存器,引号就位于最好过度简化(或过时),最糟糕的是废话。

2018年的编译器会知道您是否要重用该值,无论是否在循环体内找到该声明。好的,因此在循环中声明变量会使编译器的工作更加轻松,但是您的编译器是 smart

在这样的简单示例中移动声明不会对现代工具链编译的程序产生任何影响。 C ++程序不是机器指令的一对一映射。这是程序说明。人们之所以说“差异只是学术上的”,是因为差异只是学术上的。从字面上说。

答案 1 :(得分: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编译器将程序转换为静态分配的原因,因为它们对于计算机和人类而言都更易于理解和推理。也就是说,只要有可能,所有变量都被声明为常量,并且只能设置一次。每个值只有一个含义。在您认为更新后使用旧值或更新前使用新值的错误不会发生。优化的编译器会为您处理将值移入和移出寄存器的问题。

答案 2 :(得分:1)

这显然是未经优化的,首要的是那只是死代码,所以它会消失。编译器按照您的要求进行了操作,您在循环中添加了一个附加分配,并且这样做确实很简单,未优化它,这并不奇怪。如果您对性能感兴趣,那么您希望使用优化的代码。您的实验存在优化问题。这与寄存器和变量的保存无关。一个循环中有两个操作,另一个循环中有一个。您需要进一步研究和了解更多内容,这些简单的测试会发现其他问题,例如对齐问题。

我可以采用两条指令进行减法运算,如果不为零,则跳转回减法运算,具体取决于体系结构,实现,运行位置和对齐方式,这两条指令可能具有完全不同的性能,相同的精确机器代码,相同的计算机/处理器,即使您进行的是非常精确的时间测量,您也不会在此处进行测量。基本上,像这样的循环用于说明基准测试有多不好/有多有用。

您不能真正让编译器既注册这些变量,又优化而不将整个对象作为无效代码删除,这是不可靠的。因此,使用这样的高级语言会导致通过典型的实现进行内存访问,如果针对共享计算机上的dram,人们希望它可以被缓存,但可能不会。即使被缓存,第一个循环也可能花费数百个周期或更多的时间,并且根据循环数和测量精度有时会被注意到。

您的开始/结束变量不是可变的,这并不是一个魔术解决方案,但是我已经看到优化器将两个读取都放在循环之后或循环之前,因为一件事与另一件事无关,从而导致测量结果不佳。

Abrash的《 Zen of Assembly》和其他书籍非常适合学习性能和陷阱。进行良好测量的重要性,并注意不要对正在发生的事情走错误的假设道路。

请注意,像这个问题一样,先前的问题应以主要基于意见为基础而关闭,但是该先前的问题确实具有准确而完整的答案,这就是选定的答案。就像这样,您必须对其进行测量。从编写的代码到设计的测试,您的结果可能/将有所不同,是的,您可以使更多的指令执行得更快而不是更少的指令执行,通常不难证明这一点。用这些词提出更好,更快,更便宜的问题通常不会导致无法回答的问题。循环中有两个操作而不是一个而不进行优化(此代码并非旨在进行优化,并且使用volatile不一定会节省代码),编译器很可能只会在一个操作中进行两个操作,而在另一个操作中进行一个操作。加上执行所需的开销。但是我可以选择一个平台,并可能以更快的速度显示更多操作的循环。有经验的人也可以。

因此,尽管进行了两次操作且未进行优化,但一次操作循环仍然有可能变慢,但是在大多数实验中如果两次操作变慢也不会感到惊讶。

相关问题