但我在帮助编译器

2020-08-22 01:06:40

编译器在每个版本中都变得越来越好。有时,在同一编译器的不同版本中,同一段代码的汇编输出可以观察到明显的差异(可以通过编译器资源管理器轻松地完成)。

最近,我开始了检查程序集输出的实践,以分析各种实现的开销。当心,有时它会让人上瘾。但我认为这是学习阅读汇编语言的一种很好的方式,同时也会惊讶于现在的编译器是多么聪明。

在本文中,我将介绍在CHIP8实现期间查看函数的程序集输出时发生的一个这样的事件。

移动语义是在C++11中引入的。我们可以将移动语义看作是转移对象所有权的一种方式。如果您对移动语义非常陌生,请考虑以下示例:

所以你的同事有一份你也想要的文件。我们有两个选择。第一种选择是,您将文档带到复印机,为自己复制一份文档,然后将原始文档返回给您的同事。第二种选择,假设你的同事不再需要这份文档,你的同事可以把它给你,而不是扔掉,这样可以节省纸张。

用程序中的内存资源替换文档,第一个选项是复制,第二个选项是移动,您转移所有权而不是浪费资源。

在实现我的CHIP8模拟器时,我看到了一个机会,可以将昂贵的复制操作替换为廉价的移动操作(至少我是这么想的)。

简单介绍一下上下文:在每个帧周期中,我必须返回一个包含2048个整数的数组,该数组将用于在屏幕上绘制图形。伪C++代码如下所示:

//chip8.cpp静态常量expr DISPLAY_SIZE=2048;类Chip8{...。Public:std::array<;uint8_t,display_size>;get_display_Pixels(){//执行一些计算返回gfx;}...。Private:std::array<;uint8_t,display_size>;gfx{};};//main.cpp While(DisplayOn){...。Const auto disp_Pixels=仿真器。GET_DISPLAY_PIXTES();...//使用DISP_PARENTS在屏幕上绘制像素}。

这是我在调用get_display_Pixels()成员函数时假定发生的情况:

编译器将Chip8类的gfx私有变量复制到get_display_Pixels()成员函数的返回值。

编译器调用复制构造函数将函数调用的返回值复制到disp_pixels变量。

因此,我得出结论,我可以使用Move构造函数将内容传输到本地变量disp_Pixels,以避免如上所述的第二步中的复制。

//main.cpp,同时(DisplayOn){...。Const auto disp_Pixels=std::move(仿真器。Get_display_Pixels();...//使用disp_pixels在屏幕上绘制像素}。

在你大发雷霆,停止阅读这篇文章之前,因为我认为这是完全错误的,我也意识到了这一点,这篇文章的其余部分就是关于这一点的。

我一使用std::move(如前面的代码片段所示),就注意到编译器生成的汇编代码比没有std::move(使用std::Move:Link,没有std::Move:Link)的初始代码要多。

NRVO代表命名返回值优化。如果满足某些条件,编译器会用它省略不必要的复制或移动,这是一个很好的技巧。编译器使用这个技巧已经有很长一段时间了。如果NRVO发生在我们的函数调用中,那么实际上我们只复制一个而不是两个。让我们看看它是怎么工作的。

即使GET_DISPLAY_PIXES的函数签名表明它不接受任何参数,编译器也会在后台将一个额外的参数从调用方(从main.cpp初始化调用disp_Pixels)传递给被调用方(chip8.cpp中的GET_DISPLAY_PILES函数)。调用方将为返回值分配内存,并将该内存的地址传递给被调用方。被调用者将使用该内存来构造对象并复制私有变量gfx的值(在本例中)。由于调用方的内存(Disp_Pixels)已由被调用方使用,因此不需要再次复制返回值,从而省去了一次不必要的复制/移动操作。

我们应该看看汇编输出,才能真正理解NRVO是如何在幕后发生的。调用方的汇编代码如下:

1 LEA RAX,[RBP-16384]2 LEA RDX,[RBP-8192]3 mov RSI,RDX 4 mov RDI,RAX 5调用芯片8::GET_DISPLAY_PIXES()。

在函数调用之前,RSI和RDI寄存器将加载disp_Pixels变量的内存地址的上限和下限。而且,从被调用方输出的修剪后的组件如下所示:

1芯片8::Get_Display_Pixels():2推送RBP 3 mov RBP,RSP 4 mov QWORD PTR[RBP-8],RDI 5 mov QWORD PTR[RBP-16],RSI...。

从被调用方的角度来看,RDI和RSI值被移到堆栈中,并使用该内存地址执行进一步的操作。相当整洁!

我喜欢想一个简单的比喻来比喻NRVO,当你让一个朋友往水瓶里灌水时,你会让你的瓶子直接从水龙头里灌水。先把水装进一个临时瓶子里,然后再把里面的东西转移到你的瓶子里,效率会很低。在C++上下文中,瓶子是内存空间,它容纳的水是返回值。

如果我们假设将发生NRVO,那么编写函数调用的最有效方式是:

//main.cpp,同时(DisplayOn){...。Const auto disp_Pixels=仿真器。GET_DISPLAY_PIXTES();...//使用DISP_PARENTS在屏幕上绘制像素}。

GCC和克朗甚至还有一个额外的警告标志--W悲观-Move,当我们试图使用编译器生成的NRVO效率更高的Move时,它会检测到这一点。

尽管我们可以假设在许多情况下会发生NRVO,特别是在开启优化的情况下,但C++标准并不保证在所有情况下都会发生NRVO 1。但是,如果编译器不执行NRVO怎么办?

虽然有一些建议来保证NRVO,但标准还没有保证。由于不能保证这一点,我们是否应该显式指定一个移动操作来保存副本,以防编译器不执行NRVO?为了回答这个问题,我在代码中添加了标志-fno-elide-构造函数来禁用复制省略(NRVO的超集),从而允许查看编译器的其他操作。

我很惊讶地看到编译器仍然在为启用-fno-elide构造函数的C++17标准执行NRVO。但对于C++14并非如此,编译器在启用-fno-elide构造函数的情况下生成不同的程序集。如果有人知道为什么在不保证NRVO的情况下C++17和C++14之间出现这种差异的原因,请给我发电子邮件。神箭链接。

让我们使用带有-fno-elide-structors标志的C++14来模拟编译器无法应用NRVO的场景,这样我们就可以检查是否需要做一些额外的操作来避免多余的副本。

因此,我在上一节的最后一段代码中添加了-fno-elide-构造函数来禁用任何NRVO。调用方生成了以下汇编代码:

1 Lea RDX,[rbp-8192]2 lea rax,[rbp-16384]3 mov rsi,rdx 4 mov rdi,rax 5 call芯片8::get_display()6 lea rdx,[rbp-8192]7 lea rax,[rbp-16384]8 mov rsi,rdx 9 mov rdi,rax 10 call std::array<;unsign char,32ul>;

正如我们注意到的,前5条汇编指令与启用NRVO的版本相同,此版本中还有5条汇编指令,因为我们禁用了NRVO。我们需要关注的最重要的指令是第10行,其中有一个移动构造函数(注意函数签名中的&;&;)。等等,调用了移动构造函数吗?我没有使用std::move,但是编译器还是决定这样做。要真正理解原因,我们需要了解C++中的值类别。

在这两篇文章:理解C和C++中的左值和右值以及basic.lval#1中,详细说明了值类别2.简而言之,引用第一篇文章中的话:“左值(定位器值)表示占用内存中某个可识别位置的对象。Rvalue是一个表达式,它不表示占用内存中某个可识别位置的对象。“。当然,除了左值和右值之外,还有更多的类别。我强烈推荐这两篇文章都读一读。不过,您不需要完全掌握它们就能理解本文后面的内容。让我们回到最初的示例,并分析为什么调用Move构造函数。

//main.cpp,同时(DisplayOn){...。Const auto disp_Pixels=仿真器。GET_DISPLAY_PIXTES();...//使用DISP_PARENTS在屏幕上绘制像素}。

在上面的代码片段中,对get_display_Pixels的函数调用属于rvalue(更准确地说是prvalue)类别,它会生成一个临时的。编译器现在可以安全地将该临时变量移到disp_Pixels变量中,因为在此语句之后,该临时变量无论如何都会被销毁。如果返回的类型没有移动构造函数(在我们的例子中,std::array有一个移动构造函数),那么编译器将调用复制构造函数。

原则上,如果任何可移动类型(标准或用户定义的)是通过值从函数返回的,我们可以安全地假设将发生NRVO或MOVE操作,从而不会为支持C++11和更高版本的标准编译器产生多余的副本。

您可能已经知道我在这篇文章中讨论的内容,您可能会想,为什么我要漫无边际地谈论我一开始没有正确理解的事情。我想这就是这篇文章的意义所在。

使用一个我们从博客或书中读到的新概念,并在不理解其含义的情况下立即使用它们总是很好的。有时,即使是经验丰富的程序员也会发生这种情况。因此,每当您学习一个新概念时,尤其是在C++中,检查它生成的程序集,以真正验证正在发生的事情,以及您预期会发生的事情。相信我,学习一点汇编肯定会有回报,因为它让我们可以窥探最终将在计算机上运行的内容。

我真的认为Move语义是以某种方式工作的,我开始过早地优化(在本例中是悲观的),而没有真正在更广泛的上下文中理解它们。研究程序集无疑加深了我对移动语义和一些编译器优化的理解。

最重要的是,我开始相信编译器会为我做正确的事情,并更加尊重编译器作者。看看这段马特·戈德波特的视频,它强调了我在这里想要表达的观点。

从C++17开始保证1个RVO。要了解RVO和NRVO之间的区别,请参阅此链接。

2在写这篇文章时,我也发现Sy Brand的这篇文章非常有用。