当前位置: 首页 > 知识库问答 >
问题:

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

孙成益
2023-03-14

我有两段代码,它们从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代码。

我在运行红帽企业 Linux 工作站(版本 7.5)或 Windows 10 的多台计算机上对此进行了测试。所有有问题的计算机都有一个至强处理器(Linux)或i7-4510U(Windows 10)。我使用没有任何标志的g来编译,或者使用Visual Studio社区版2017。所有结果都一致:在html" target="_blank">循环中声明变量会导致加速。

当在64位Linux机器上的循环中声明增量时,多次运行的运行时间约为5.00秒(几乎没有变化)。

当在同一台机器的循环外声明增量时,多次运行的运行时间约为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个答案

苗信鸥
2023-03-14

这显然是未优化的,首先,也是最重要的,那只是死代码,所以它会消失。编译器按照你的要求做了,你在循环中添加了一个额外的赋值,就这么简单,未优化,这并不奇怪你会有这种感觉。如果你对性能感兴趣,那么你想使用优化的代码。你的实验存在优化问题。这与寄存器和变量保存无关。你在一个循环中有两个操作,在另一个循环中有一个操作。你需要做更多的工作和理解,这些简单的测试会发现其他问题,比如对齐。

我可以使用两个指令“减法”和“跳转”(如果不为零)返回到“减法”,这取决于架构、实现、运行位置和对齐方式。这两个指令可以有非常不同的性能、完全相同的机器代码、相同的计算机/处理器,即使您进行非常精确的时间测量,但这里也没有。基本上,像这样的循环是用来证明基准测试有多差,而不是它有多好/有用。

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

你的begin/end变量不是易变的,这不是一个神奇的解决方案,但是我见过优化器把两次读取都放在循环之前或之后,因为一件事与另一件事不相关,导致测量很糟糕。

阿布拉什的《装配禅》和其他书籍有助于学习性能和陷阱。良好测量的重要性,以及注意不要对正在发生的事情走错误的假设道路。

请注意,像这个问题一样,前面的问题应该作为主要基于观点的问题关闭,但是前面的问题有一个准确和完整的答案,这就是选择的答案。就像这个,你必须测量它。从编写的代码、设计的测试来看,结果可能会有所不同,是的,你可以让更多的指令比更少的指令执行得更快,这一点通常不难证明。没有比这更好、更快、更便宜的方法了。用这些词提问通常会导致无法回答的结果。循环中有两个操作,而不是一个操作,并且没有优化(这段代码不是为优化而设计的,使用volatile不一定会保存它)编译器很可能只在一个循环中做两个操作,而在另一个循环中做一个操作。加上所需的开销。但是我可以选择一个平台,并且可能显示更多操作的循环更快。有经验的你也可以。

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

燕玉堂
2023-03-14

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

其次,查看生成的汇编代码的一个更好的方法是使用< code>-S标志进行编译,并使用< code >检查结果文件。S扩展名。

您通常应该在启用优化和警告的情况下进行编译,也许是 -g -O -墙 -Wextra -Wpedantic -W 转换-std=c 17 或您正在编写的语言的任何版本。您可能希望设置您的 CFLAGS/CXXFLAGS 环境变量,或创建一个生成文件。

如果不进行优化,编译器就会过于脑残,甚至无法将增量保留在寄存器中或将其折叠为常量。代码转储中对应int增量=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;
}

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

由于increment是编译时已知的常量,因此最好在C或C中将其声明为constexprstatic const。在这样一个简单的例子中,现代编译器不应该需要提示,但在更复杂的情况下,它可能仍然会产生影响。

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

子车峰
2023-03-14

虽然我们不需要保留的值通常可以转储到寄存器中,而不会被拉入主内存,但只要寄存器可用,引用最多是过于简化(或过时),最糟糕的是毫无意义。

2018年的编译器知道你是否要重新使用那个值,是否在循环主体中找到声明。好吧,所以在循环中声明变量会让编译器的工作变得容易一点,但是你的编译器很聪明。

在像这样一个微不足道的例子中移动声明不会对现代工具链编译的程序产生任何影响。C程序不是机器指令的一对一映射;它是程序的描述。人们之所以说“差异只是学术上的”,是因为差异只是学术上的。就像,从字面上看。

 类似资料:
  • 背景: 在使用嵌入式汇编语言优化某些Pascal代码时,我注意到一条不必要的MOV指令,并将其删除。 令我惊讶的是,删除不必要的指令导致我的程序速度减慢。 我发现添加任意、无用的指令会进一步提高性能。 效果是不稳定的,并且基于执行顺序的更改:由单行向上或向下转置的相同垃圾指令会产生减速。 我知道CPU会进行各种优化和精简,但这更像是黑魔法。 数据: 我的代码的一个版本在一个循环的中间有条件地编译了

  • 该程序应该使用int 0x10在ASCII中打印一个具有给定字符的金字塔,3行的预期结果(下面代码中使用的数量)将是: A. a a a a a 要编译和运行代码,我使用nasm编译它,然后使用qemu进行仿真: 然而,程序get无法打印所有ASCII值。此外,如果有任何针对nasm代码的调试器,可以让您逐行运行,允许您检查寄存器值,这对学习也很有帮助。

  • } 链接:https://www.hackerrank.com/challenges/java-string-compare/problem

  • 我想删除至少有一个“NaN”的所有行。数据框如下图所示,但实际的数据框大约有1000004行。 完整的CSV文件:文件 我写的代码如下: 我预计至少有300000行,但我只有大约200000行。当我签入实际的CSV文件时,第一个NaN至少在第380000行之前不会出现。那么,为什么删除多余的行?

  • #include <stdio.h> int global_var; void change_var(){ global_var=100; } int main(void){ change_var(); return 0; } 技巧 使用gdb调试汇编程序时,可以用“display /i $pc”命令显示当程序停止时,将要执行的汇编指令。以上面程序为例: (gdb)