调试构建的2x-3x性能改进

2021-05-08 22:45:50

我们在Visual Studio的默认调试配置中对X86 / X64 C ++编译器进行了大量的运行时性能改进。对于Visual Studio 2019年版本16.10预览2,我们测量2x - 3x加速,用于在调试模式下编译的程序。这些改进来自减少运行时检查(/ RTC)引入的开销,默认情况下启用。

在Visual Studio中的Debug配置中编译代码时,默认情况下有一些标志传递给C ++编译器。与此博客文章最相关的是/ rtc1,/ jmc和/ zi。

虽然所有这些标志都添加了有用的调试功能,但它们的交互,特别是当涉及时,增加了大量的开销。在此版本中,我们删除了不必要的开销,同时确保他们继续帮助您找到错误并使您的调试体验更顺畅。

在使用/ rtc1 / jmc / zi(godbolt链接)编译时由16.9编译器生成的x64组件:

1 int foo(void)proc 2 $ ln3: 3推RBP. 4推RDI. 5子RSP,232;由于/ zi,/ jmc而分配的额外空间 6 Lea RBP,QWORD PTR [RSP + 32] 7 MOV RDI,RSP 8 MOV ECX,58; (= x) 9 MOV EAX,-858993460; 0xccccccc. 10 Rep Stosd;在堆栈上写0xcc for x dwords 11 Lea RCX,偏移平:__ 977E49D0_EXAMPLE @ CPP 12;由于/ JMC而致电 13致电__CheckFordebeBuggerJustmycode. 14 mov eax,32 15 Lea RSP,QWORD PTR [RBP + 200] 16流行rdi. 17流行rbp. 18 RET 0. 19 int foo(void)endp

在上面所示的组件中,/ JMC和/ ZI标志在堆叠上添加了总共232个附加字节(第5行)。此堆栈空间并不总是必要的。与初始化分配的堆栈空间(第10行)初始化的rtc1标志(第10行)时,它会消耗大量的CPU周期。在该具体示例中,即使我们分配的堆栈空间是适当运行/ JMC和/ ZI的正常运行所必需的,其初始化不是。我们可以在编译时证明这些检查是不必要的。任何现实世界C ++码库中有很多这样的功能,那就是绩效效益来自的。

继续阅读以获得深度潜入这些标志,他们与RTC1的互动,以及我们如何避免其不必要的开销。

使用/ RTC1标志等同于使用两个/ RTC和/ RTCU标志。 / RCS将函数的堆栈帧初始化0xcc才能进行各种运行时检查,即检测未初始化的局部变量,检测阵列溢出和undruns,以及堆栈指针验证(对于x86)。您可以在此处看到使用/ RTC的代码膨胀。

如上面的汇编代码(第10行)所示,由/ RTC引入的REP STOSD指令是减速的主要原因。当/ RTC(或/ RTC1)结合/ JMC,/ ZI或两者时,情况加剧了情况。

/ JMC代表我的代码调试功能,并且在调试期间,它会自动跳过您未写入的函数(例如框架,库和其他非用户代码)。它通过在调用进入运行时库中的序幕中插入函数调用来工作。这有助于调试器区分用户和非用户代码。这里的问题在于将函数调用插入项目中的每个函数的序幕意味着整个项目中没有叶子函数。如果函数最初不需要任何堆栈帧,现在它将是,因为根据适用于Windows平台的AMD64 ABI,我们需要至少有四个可用于功能参数的堆栈插槽(称为P ARAM Home Area)。这意味着所有未在/ RTC初始化的函数,因为它们是叶函数并没有堆栈帧,现在将初始化。在您的程序中有很多和大量的叶子函数是正常的,特别是如果您使用像C ++ STL这样的重型模糊代码库。 / JMC在这种情况下会愉快地吃一些CPU周期。这不适用于x86(32位),因为我们没有任何参数家庭区域。您可以在此处看到/ JMC的影响。

我们将谈论的下一个互动是与/ zi。它使您的编辑和继续支持代码,这意味着您不需要在调试过程中重新编译整个程序以进行小型更改。

为了添加此类支持,我们将一些填充字节添加到堆栈(填充字节的实际数量取决于功能有多大)。这样,您可以在调试会话期间添加的所有新变量都可以在填充区域分配,而无需更改总堆栈帧大小,并且您可以在不必重新编译代码的情况下继续调试。请参阅此处启用此标志为生成的代码添加额外的64个字节。

正如您可能已经猜到的那样,更多的堆栈区域意味着更多的东西以初始化/ RTC初始化,导致更多的开销。

所有这些问题的根源是不必要的初始化。我们是否真的需要每次初始化堆栈区域?在真正需要堆栈初始化时,可以安全地在编译器中证明。例如,当存在至少一个地址拍摄的变量时需要它,在函数或未初始化的变量中声明的数组。对于每种情况,我们可以安全地跳过初始化,因为我们无论如何我们都不会发现通过运行时检查有用。

当您使用编辑和继续编译时,情况会变得更加复杂,因为现在可以在调试会话中添加未初始化的变量,只有在初始化堆栈区域时才可以检测到的调试会话中。我们可能没有这样做。为了解决这个问题,我们在调试信息中包含必要的比特,并通过调试接口访问SDK公开它。此信息讲述了调试器,其中由/ zi开始和结束引入的填充区域。如果函数需要任何堆栈初始化,它还告诉调试器。如果是这样,则调试器然后无条件地初始化此内存范围内的堆栈区域,以获取在调试会话期间编辑的函数。新变量始终在此初始化区域的顶部分配,我们的运行时检查现在可以检测到新添加的代码是否安全。

我们在默认调试配置中编译了以下项目,然后使用生成的可执行文件运行测试。我们注意到我们尝试的所有项目中的2x - 3x改进。更多的STL沉重的项目可能会看到更大的改进。让我们在评论中知道您在项目中注意到的任何改进。项目1和项目2是客户提供的样品。

我们希望此加速使您的调试工作流程高效和愉快。我们不断聆听您的反馈并致力于提高内部循环体验。我们很乐意听到您在下面评价中的经验。您还可以在开发人员社区,电子邮件([email protected])和推特(@visualc)联系。