简单的 for() 循环基准测试在任何循环绑定下花费相同的时间

Simple for() loop benchmark takes the same time with any loop bound

本文关键字:循环 时间 绑定 for 基准测试 简单 任何      更新时间:2023-10-16

我愿意写一个代码,让我的CPU执行一些操作,看看他需要多少时间来解决它们。我想做一个从 i=0 到 i<5000 的循环,然后将 i 乘以一个常数和时间。我最终得到了这段代码,它没有错误,但即使我更改循环 i<49058349083或者如果 i<2 也需要相同的时间,也只需要 0.024 秒来执行代码。错误是什么?

PD:我昨天开始学习C++如果这是一个非常容易回答的问题,我很抱歉,但我找不到解决方案

#include <iostream>
#include <ctime>
using namespace std;
int main () {
int start_s=clock();
int i;
for(i=0;i<5000;i++){
i*434243;
}
int stop_s=clock();
cout << "time: "<< (stop_s-start_s)/double(CLOCKS_PER_SEC)*1000;
return 0;
}

顺便说一句,如果你真的做了i<49058349083,gcc 和 clang 在具有 32 位int(包括 x86 和 x86-64)的系统上创建一个无限循环。 49058349083大于INT_MAX. 大文字数字被隐式提升为足以容纳它们的类型,因此您有效地执行了(int64_t)i < 49058349083LL,这对于任何可能的int i值都是正确的。

签名溢出在C++中是未定义的行为,因此是一个无限循环,循环内部没有副作用(例如系统调用),所以我检查了 Godbolt 编译器资源管理器,看看它在启用优化的情况下是如何真正编译的。 有趣的事实:当条件是始终为真的比较而不是像42这样的非零常量时,MSVC 仍然优化了一个空循环(包括一个没有分配给任何东西的i*10循环)。


像这样的循环是有根本缺陷的。

你可以使用谷歌的基准测试包对一个完整的非内联函数进行微基准测试(你将如何对函数的性能进行基准测试?),但是要通过将一些东西放在重复循环中来学习任何有用的东西,你必须了解很多关于编译器如何编译为asm的知识,确切地说,你试图测量什么,以及如何让优化器制作类似于你在实际使用环境中从代码中获得的asm。 例如,通过使用内联ASM要求它在寄存器中具有特定结果,或者通过分配给volatile变量(这也具有执行存储的开销)。

如果这听起来比你希望的要复杂得多,那就太糟糕了,确实如此,而且有充分的理由。

这是因为编译器是非常复杂的机器,通常可以从源代码中生成相当有效的可执行文件,这些可执行文件是为了清楚地表达人类读者正在发生的事情而编写的,而不是为了避免冗余工作或看起来像一个高效的机器语言实现(这是你的CPU实际运行的)。


微基准测试很难- 除非您了解代码的编译方式以及真正测量的内容,否则无法获得有意义的结果。

优化编译器旨在创建一个可执行文件,该可执行文件生成与C++源相同的结果,但运行速度尽可能快。 性能不是一个可观察的结果,因此使程序更有效率始终是合法的。 这是"假设"规则:"假设"规则到底是什么?

你希望编译器不会浪费时间和未使用的代码大小计算结果。 编译器将函数内联到调用方后,通常会发现它计算的某些内容未被使用。 对于编写良好的C++代码来说,有很多工作可以扔掉是正常的,包括完全优化临时变量;这不是一件坏事,没有这样做的编译器会很糟糕。

请记住,您正在为C++抽象机器编写代码,但编译器的工作是将其转换为CPU的汇编语言(或机器代码)。 汇编语言与C++完全不同。 (现代更高性能的CPU也可以不按顺序执行指令,同时遵循自己的"as-if"规则,以保持编译器生成的代码按程序顺序运行的错觉。 但是CPU不能放弃工作,只能重叠它。

一般情况下,您无法对C++int * int二进制运算符进行微基准测试,即使只是针对您自己的桌面(更不用说其他硬件/不同的编译器)。 不同上下文中的不同用途将编译为不同的代码。 即使你可以创建一些版本的循环来测量一些有用的东西,它也不一定能告诉你foo = a * b在另一个程序中有多昂贵。 另一个程序可能会在乘以延迟而不是吞吐量上遇到瓶颈,或者将其与ab上的其他一些附近的操作相结合,或任何数量的东西。


不要禁用优化

您可能认为禁用优化(如gcc -O0而不是gcc -O3)会很有用。 但这样做的唯一方法还引入了反优化,例如在每个C++语句之后将每个值存储回内存,以及从内存中重新加载变量以用于下一条语句。 这使您可以在调试已编译的程序时修改变量值,甚至可以跳转到同一函数中的新行,并且仍然可以从查看C++源中获得预期的结果。

支持这种级别的干扰会给编译器带来巨大的负担。 存储/重新加载(存储转发)在典型的现代 x86 上具有大约 5 个周期延迟。 这意味着反优化循环最多只能每~6个时钟周期运行一次迭代,而像looptop: dec eax/jnz looptop这样的紧循环则为1个周期,其中循环计数器位于寄存器中。

没有太多中间立场:编译器没有选项来制作"看起来像"C++源的asm,而是跨语句将值保留在寄存器中。 无论如何,这都没有用或有意义,因为这不是他们在启用完全优化的情况下进行编译的方式。 (gcc -Og可能有点像这样。

花时间修改C++以使其运行得更快-O0完全是浪费时间:如何优化这些循环(禁用编译器优化)?

注意:反优化调试模式(-O0)是大多数编译器的默认模式。 它也是"编译快速"模式,因此可以很好地查看您的代码是否编译/运行,但对于基准测试毫无用处。 生成的编译器生成的asm运行速度取决于硬件,但不要告诉您有关优化代码运行速度的任何信息。 (例如,在没有优化的情况下编译时,添加冗余分配会加快代码速度的答案是一些相当微妙的英特尔 Sandybridge 系列存储转发延迟行为,直接由存储/重新加载和循环计数器上的循环瓶颈在内存中引起。 请注意,问题的第一个版本是询问为什么这样做会使 C 更快,这被正确地否决了,因为基准测试-O0是愚蠢的。 当我将其编辑为x86 asm问题时,它才变成了一个有趣的问题,这个问题很有趣,因为asm更大但更快,而不是因为它来自具有任何特定源更改的gcc -O0

这甚至没有提到C++标准库函数,如std::sortstd::vector::push_back,它们依赖于优化器对微小的辅助器/包装器函数进行内联大量嵌套调用。


编译器如何优化

(我将展示C++代码的转换。 请记住,编译器实际上是在转换程序逻辑的内部表示形式,然后生成 asm。 您可以将转换后的C++视为 asm 的伪代码,其中i++表示 x86inc eax指令或其他东西。 大多数 C/C++ 语句可以映射到 1 条或几条机器指令。 因此,这是一种有用的方法来描述实际编译器生成的asm可能正在做什么的逻辑,而无需实际在asm中编写它。

从未使用过的结果不必首先计算。可以完全消除没有副作用的循环。 或者,可以优化分配给全局变量(可观察到的副作用)的循环,以仅执行最后一个赋值。 例如

// int gsink;  is a global.  
// "sink" is the opposite of a source: something we dump results into.
for (int i=0 ; i<n ; i++) {
gsink = i*10;
}

等效于此代码,就优化编译器而言:

if ( 0 < n ) {      // you might not have noticed that your loop could run 0 times
gsink = (n-1)*10; // but the compiler isn't allowed to do gsink=0 if n<1
}

如果gsink是本地或文件范围的static,没有任何读取它的内容,编译器可以完全优化它。 但是编译器在编译包含当前C++源文件("编译单元")的函数时无法"看到"当前源文件("编译单元")之外的代码,因此它无法更改函数返回时可观察到的副作用,gsink = n*10;

没有任何东西读取gsink的中间值,因为没有对非内联函数的函数调用。 (因为它不是atomic<int>,编译器可以假设没有其他线程或信号处理程序读取它;这将是数据竞争未定义行为。


使用volatile让编译器执行一些工作。

如果它是全局volatile int gsink,将值放在内存中的实际存储是一个可观察到的副作用(这就是volatile在C++中的意思)。 但这是否意味着我们可以以这种方式对乘法进行基准测试? 不,它没有。 编译器必须保留的副作用只是将最终值放置在内存中。 如果它每次通过循环计算它比i * 10更便宜,它就会这样做。

这个循环也会产生相同的结果序列,分配给gsink,因此是优化编译器的有效选择。将独立乘法转换为循环携带加法称为"强度降低"优化

volatile int gsink;
int i10 = 0;   // I could have packed this all into a for() loop
int i=0;       // but this is more readable
while (i<n) {
gsink = i10;
i10 += 10;
i++;
}

编译器是否可以完全删除i并使用i10 < n*10作为循环条件? (当然,将upperbound = n*10计算提升到循环之外。

这并不总是提供相同的行为,因为n*10可能会溢出,因此如果以这种方式实现,循环最多可以运行INT_MAX/10次。 但是C++中的签名溢出是未定义的行为,循环体中的i*10会在任何n*10溢出的程序中溢出,因此编译器可以安全地引入n*10,而无需更改任何合法/明确定义的程序的行为。了解每个 C 程序员应该了解的关于未定义行为的知识

(实际上,最多只能评估in-1i*10n*10可能会溢出,而(n-1)*10则不会。 gcc实际上所做的更像是while(i10 != n*10)在编译 x86 时使用无符号数学。 x86 是 2 的补码机,所以无符号和有符号是相同的二进制操作,即使(unsigned)n*10UL == 0x8000000UL检查!=而不是签名小于也是安全的,这是INT_MIN。

有关查看编译器asm输出的更多信息,以及x86 asm的初学者介绍,请参阅Matt Godbolt的CppCon2017演讲"我的编译器最近为我做了什么?打开编译器的盖子"。(相关:如何从GCC/clang程序集输出中删除"噪音"? 有关当前 x86 CPU 性能的更多信息,请参阅 http://agner.org/optimize/。

来自 gcc7.3 -O3 的此函数的编译器输出,针对 x86-64 编译,在 Godbolt 编译器资源管理器上

volatile int gvsink;
void store_n(int n) {
for(int i=0 ; i<n; i++) {
gvsink = i*10;
}
}
store_n(int):          # n in EDI  (x86-64 System V calling convention)
test    edi, edi
jle     .L5                   # if(n<=0) goto end
lea     edx, [rdi+rdi*4]      # edx = n * 5
xor     eax, eax              # tmp = 0
add     edx, edx              # edx = n * 10
.L7:                                   # do {
mov     DWORD PTR gvsink[rip], eax   # gvsink = tmp
add     eax, 10                      # tmp += 10
cmp     eax, edx
jne     .L7                        # } while(tmp != n*10)
.L5:
rep ret

最优/惯用的asm循环结构是一个do{}while(),所以编译器总是试图制作这样的循环。 (这并不意味着你必须以这种方式编写源代码,但你可以让编译器避免在无法证明的情况下检查零迭代。

如果我们使用unsigned int,溢出将被很好地定义为环绕,因此编译器没有 UB 可以用作借口以您意想不到的方式编译代码。 (现代C++是一种宽容的语言。 优化编译器对那些不小心避免任何 UB 的程序员非常敌视,这可能会变得非常微妙,因为很多东西都是未定义的行为。 为 x86 编译C++不像编写 x86 程序集。 但幸运的是,有像gcc -fsanitize=undefined这样的编译器选项,它们可以在运行时检测并警告UB。 不过,您仍然需要检查您关心的每个可能的输入值。

void store_n(unsigned int n) {
for(unsigned int i=0 ; i<n; i++) {
gvsink = i*10;
}
}
store_n(unsigned int):
test    edi, edi
je      .L9            # if (n==0) return;
xor     edx, edx       # i10 = 0
xor     eax, eax       # i = 0
.L11:                      # do{
add     eax, 1         #    i++
mov     DWORD PTR gvsink[rip], edx
add     edx, 10        #    i10 += 10
cmp     edi, eax       
jne     .L11           # } while(i!=n)
.L9:
rep ret

Clang 使用两个单独的计数器进行编译,分别用于签名和无符号。 Clang的代码更像

i10 = 0;
do {
gvsink = i10;
i10 += 10;
} while(--n != 0);

因此,它将n寄存器倒计时为零,避免了单独的cmp指令,因为添加/子指令还设置了CPU可以分支的条件代码标志。 (Clang 默认展开小循环,生成i10i10 + 10i10 + 20,最多i10 + 70个寄存器,同时只运行一次循环开销指令。 不过,在典型的现代 CPU 上展开并没有太多好处。 每个时钟周期一个存储是一个瓶颈,每个时钟 4 uops(在英特尔 CPU 上)从前端发出到内核的无序部分也是一个瓶颈。


让编译器乘法:

我们如何阻止强度降低优化? 用* variable替换*10不起作用,然后我们只会得到添加寄存器而不是添加即时常量的代码。

我们可以把它变成像a[i] = b[i] * 10;这样的数组的循环,但这样我们也会依赖于内存带宽。 此外,这可以使用 SIMD 指令进行自动矢量化,我们可能想要也可能不想测试这些指令。

我们可以做类似tmp *= i;的操作(使用 unsigned,以避免有符号溢出 UB)。 但这会使每次迭代中的乘法输出成为下一次迭代的输入。 因此,我们将对延迟进行基准测试,而不是吞吐量。 (CPU 是流水线的,例如,每个时钟周期可以启动一个新的乘法,但单个乘法的结果要到 3 个时钟周期后才能准备好。 因此,您至少需要tmp1*=itmp2*=itmp3*=i才能使英特尔 Sandybridge 系列 CPU 上的整数乘法单元饱和。

这又回到了必须确切地知道您正在测量的内容,才能在这种细节水平上制作有意义的微基准。

如果这个答案超出了你的头脑,请坚持对整个功能进行计时!如果不了解周围的上下文以及它在 asm 中的工作方式,就不可能对单个 C 算术运算符或表达式说太多。

如果你了解缓存,你可以相当体面地理解内存访问和数组与链表,而无需涉及太多 asm 级细节。 可以在不了解 asm的情况下了解C++性能的某种程度的细节(除了它存在的事实,以及编译器大量优化的事实)。 但是,您对 asm、CPU 性能调优以及编译器的工作原理了解得越多,事情就越有意义。

>PS:

任何基于编译时常量值的计算都可以(并且希望是)在编译时完成。 这称为"恒定传播"。 对优化器隐藏常量(例如,通过将它们输入为命令行参数(atoi(argv[1]),或其他技巧)可以使编译器为微基准生成的代码看起来更像真实的用例,如果该用例在编译时也看不到常量。 (但请注意,在其他文件中定义的常量通过链接时间优化变得可见,这对于具有许多小函数的项目非常有用,这些小函数跨源文件边界相互调用,并且未在标头(.h)中定义,它们可以正常内联。

乘以 16(或任何其他 2 的幂)将使用移位,因为这比实际的乘法指令更有效。 这对分裂来说尤其重要。 请参阅为什么用于测试 Collatz 猜想C++代码比手写汇编运行得更快?和为什么 GCC 在实现整数除法时使用乘以奇数?。

其他在二进制表示中仅设置了几位的乘法常量可以通过一些 shift+add 来完成,通常比通用乘法指令具有更低的延迟。 例如,请参阅如何在 x86 中仅使用 37 个连续的 leal 指令将寄存器乘以 86?。

如果在编译时两个输入都未知,则这些优化都无法通过a * ba / b进行。


另请参阅:如何对C++代码的性能进行基准测试?,尤其是Chandler Carruth的CppCon 2015演讲的链接:"调优C++:基准测试,CPU和编译器!天呐!

因为值得一提两次:Matt Godbolt的CppCon2017演讲"我的编译器最近为我做了什么?打开编译器的盖子"。这是一个足够温和的介绍,初学者可能会很好地遵循它,看看他们的循环是如何编译的,看看它是否优化了。

因为 for 循环的主体:

i*434243;

它不执行任何操作,因此假设您在启用优化标志的情况下编译代码,编译器会将其清除。

将其更改为:

int a = i*434243;

可能会在除-O0以外的任何其他方面进行优化,所以我不建议这样做。

此外,这将导致未定义的行为,因为溢出,因为您使用的常量值相对较大,因为i继续递增。

我建议你改为:

int a = i * i;
cout << a << "n";   

您编写的代码不是 CPU 正在执行的指令。简而言之:编译器将您的代码转换为机器指令,只要结果与您在代码中写下的完全相同的步骤相同,就可以是任何内容(俗称"as-if"规则)。请考虑以下示例:

int foo() {
int x = 0;
for (int i=0;i<1000;i++){
x += i;
}
return 42;
}
int bar() {
return 42;
}

这两个函数看起来完全不同,但是,编译器可能会为它们创建完全相同的机器代码,因为您无法确定是否执行了额外的循环(消耗 CPU 功率和花费时间不计入 as-if 规则)。

编译器优化代码的聚合程度由-O标志控制。通常-O0用于调试版本(因为它编译速度更快),-O2-O3用于发布版本。

计时代码可能很棘手,因为您必须确保实际测量某些内容。对于foo,您可以通过编写以下内容来确保循环被执行(*):

int foo() {
int x = 0;
for (int i=0;i<1000;i++){
x += i;
}
return x;
}

(*) = 即使这样也不会导致在大多数编译器上运行循环,因为这种循环是一种常见的模式,它会被检测到并导致x = 1000*1001/2;线。

通常很难让编译器保留您感兴趣的分析代码。

我强烈建议分析实际代码,因为根据许多单独的测量来预测实际代码的时间有很多陷阱。

如果你想继续,一个选择是将变量声明为易失性变量,并为其分配答案。

易失性 int a = i * 434243;

另一种方法是创建一个函数并返回值。您可能需要禁用内联。

你不太可能回答像"乘法需要多长时间?你总是在回答这样的问题,"做一个乘法并给我答案需要多长时间?

您通常需要"保持温度"并检查汇编程序以确保它正在执行您的期望。如果要调用函数,则可能需要比较调用不执行任何操作的函数,以尝试消除计时的调用开销。

我稍微更改了您的代码如下:

#include <iostream>
#include <ctime>
using namespace std;
int main() {
for (long long int j = 10000000; j <= 10000000000; j = j * 10) {
int start_s = clock();
for (long long int i = 0; i < j; i++) {
i * 434243;
}
int stop_s = clock();
cout << "time: " << (stop_s - start_s) / double(CLOCKS_PER_SEC) * 1000 << endl;
}
int k;
cin >> k;
return 0;
}

输出:(对我来说看起来很好。

time: 23
time: 224
time: 2497
time: 21697

有一件事需要注意。由于i是一个整数,因此无论如何它都不会等于49058349083。在您的情况下,上限转换为int对应于 -2,147,483,648 和 2,147,483,647 之间的任何值,因此循环运行 0 到 2,147,483,647 次之间的任何值,这对于简单的乘法运算来说并不是那么大的数字。(1813708827在49058349083的情况下)。

尝试使用介于 -2^63 和 2^63-1 之间的long long int