草庐IT

c++ - 简单的for()循环基准测试与任何循环绑定(bind)都花费相同的时间

coder 2024-02-10 原文

我愿意编写使我的CPU执行某些操作的代码,并查看他花费多少时间来解决这些问题。我想做一个从i = 0到i <5000的循环,然后将i乘以一个常数并乘以该时间。我结束了这段代码,它没有错误,但是即使我更改循环i><49058349083或i><>

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++中的未定义行为,无限循环也是没有任何副作用的无限循环(例如系统调用),因此I checked on the Godbolt compiler explorer可以查看它在启用优化的情况下是如何真正编译的。有趣的事实:当条件是始终为真的比较,而不是像i*10这样的非零常量时,MSVC仍会优化掉一个空循环(包括一个未分配42的空循环)。

这样的循环从根本上来说是有缺陷的。
您可以使用Google的基准测试包(How would you benchmark the performance of a function?)微基准测试完整的非内联函数,但要通过将某些内容放入重复循环中来学习有用的知识,则必须了解很多有关编译器如何编译为asm的信息,以及您真正要使用的内容。重新尝试进行度量,以及如何使优化器制作类似于您在某些实际使用上下文中从代码中获得的asm的asm。例如通过using inline asm to require it to have a certain result in a register或分配给volatile变量(也有进行存储的开销)。
如果这听起来比您希望的要复杂得多,那就太糟糕了,这是有充分理由的。
那是因为编译器是非常复杂的机器,通常可以从源代码中生成相当有效的可执行文件,而这些可执行文件的目的是清楚地表达人类读者正在发生的事情,而不是避免多余的工作或看起来像高效的机器语言实现(这就是事实)。您的CPU实际上正在运行)。

微基准测试很难-除非您了解代码的编译方式和实际测量内容,否则您将无法获得有意义的结果。
优化编译器的目的是创建一个可执行文件,该可执行文件与C++源代码产生的结果相同,但运行速度尽可能快。性能不是可观察到的结果,因此使程序更高效始终是合法的。这是“假设”规则:What exactly is the "as-if" rule?
您希望编译器不要浪费时间和未使用的代码大小的计算结果。编译器将函数内联到调用程序中后,通常会发现它没有使用某些计算出来的东西。编写良好的C++代码通常会放弃很多工作,包括完全优化临时变量,这是正常的。这不是一件坏事,没有这样做的编译器会很烂。
请记住,您是为C++抽象机编写的,但是编译器的工作是将其翻译为CPU的汇编语言(或机器代码)。汇编语言与C++完全不同。 (现代高性能的CPU也可以不按顺序执行指令,同时遵循自己的“按条件”规则,以保留编译器生成的代码按程序顺序运行的错觉。但是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一起运行更快,这完全是浪费时间:C loop optimization help for final assignment (with compiler optimization disabled)
注意:对于大多数编译器,默认情况下为反优化 Debug模式(-O0)。它也是“快速编译”模式,因此非常适合查看代码是否完全可以编译/运行,但是对基准测试毫无用处。最终生成的编译器生成的asm的运行速度取决于硬件,但并没有告诉您任何有关优化代码运行速度的信息。 (例如Adding a redundant assignment speeds up code when compiled without optimization的答案是一些相当微妙的Intel Sandybridge系列存储转发延迟行为,直接由存储/重新加载引起,并且循环计数器上的循环瓶颈在内存中。
请注意,该问题的第一个版本询问的是为什么这样做会使C更快,这被正确地否决了,因为基准测试-O0很愚蠢。当我将其编辑为x86 asm问题时,它才变成一个有趣的问题,该问题很有趣,这是因为asm更大但速度更快,而不是因为它来自gcc -O0,并且进行了任何特定的源更改。)
而且,这甚至都没有提到C++标准库函数,例如std::sortstd::vector::push_back,它们依赖于优化器来内联许多对微小的辅助函数/包装函数的嵌套调用。

编译器如何优化
(我将展示C++代码的转换。请记住,编译器实际上是在转换程序逻辑的内部表示形式,然后生成asm。您可以将转换后的C++视为asm的伪代码,其中i++表示一个x86 inc 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相同的赋值结果序列,因此对于优化编译器而言是有效的选项。 将独立的乘法转换为循环进行的加法运算称为"strength-reduction" optimization
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而不更改任何合法/定义良好的程序的行为。 参见What Every C Programmer Should Know About Undefined Behavior
(实际上,对i*10最多仅评估i,最多评估n-1,而n*10可能会溢出,而(n-1)*10则不会。 unsigned和signed是相同的二进制操作,即使while(i10 != n*10)为INT_MIN,也检查!=而不是signed-than是安全的。)
有关查看编译器asm输出以及x86 asm的入门知识的更多信息,请参见Matt Godbolt的CppCon2017对话“What Has My Compiler Done for Me Lately? Unbolting the Compiler's Lid”(和相关:How to remove "noise" from GCC/clang assembly output?)。有关当前x86 CPU的性能的更多信息,请参见http://agner.org/optimize/
gcc7.3 -O3, compiling for x86-64, on the Godbolt compiler explorer 编译此函数的输出:
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循环结构是(unsigned)n*10UL == 0x8000000UL,即compilers always try to make loops like this。 (这并不意味着您必须以这种方式编写源代码,但是您可以让编译器在无法证明这种情况的情况下避免检查零迭代。)
如果我们使用do{}while(),则溢出将被很好地定义为环绕,因此编译器没有UB可以用作您以意想不到的方式编译代码的借口。 (现代C++并不是一种宽容的语言。优化编译器对没有仔细避免使用任何UB的程序员非常不利,这会变得非常微妙,因为很多东西都是未定义的行为。针对x86编译C++并不像编写x86汇编语言一样。幸运的是,有诸如unsigned int这样的编译器选项,它们会在运行时检测并警告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使用两个单独的计数器进行签名和未签名的编译。 lang的代码更像
i10 = 0;
do {
    gvsink = i10;
    i10 += 10;
} while(--n != 0);
因此,它将gcc -fsanitize=undefined寄存器递减至接近零,从而避免了单独的n指令,因为add/sub指令还设置了CPU可以分支的条件码标志。 (Clang默认情况下会展开小循环,在它可以存储的寄存器中生成cmpi10i10 + 10以及高达i10 + 20,而只运行一次循环开销指令。在典型的现代CPU上展开,在这里没有太多收获,每个时钟周期一个存储是一个瓶颈,从前端到内核的无序部分发出的每个时钟(在Intel CPU上)每个4 uop也是一个瓶颈。

让编译器相乘:
我们如何停止降低强度的优化?用i10 + 70替换*10不起作用,然后我们只获得添加了register的代码,而不是添加立即数。
我们可以将它变成像* variable这样的数组的循环,但是然后我们也将依赖于内存带宽。而且,这可以使用我们可能想要或不想进行测试的SIMD指令自动向量化。
我们可以做一些类似a[i] = b[i] * 10;的操作(使用无符号,以避免有符号的UB溢出)。但是,这使得乘法运算的输出在每次迭代中都成为下一次的输入。因此,我们将基准乘以延迟,而不是吞吐量。 (CPU被流水线化,例如可以在每个时钟周期开始一个新的乘法,但是直到3个时钟周期后,单乘法的结果才准备就绪。因此,至少需要tmp *= i;tmp1*=itmp2*=i才能保持充满工作的Intel Sandybridge系列CPU上的整数乘法单元。
这要回到必须确切地了解您要测量的内容,才能在此详细级别上进行有意义的微基准测试。
如果您的答案太过头了,请坚持对所有功能进行计时! 在不了解周围上下文及其在asm中如何工作的情况下,实际上不可能讲太多有关单个C算术运算符或表达式的信息。
如果您了解缓存,则可以相当体面地了解内存访问,数组与链表,而无需过多地了解asm级别的细节。可能无需了解asm就可以更详细地了解C++的性能(除了它存在的事实之外,而且编译器会进行大量优化)。但是,您对asm,CPU性能调整以及编译器的工作了解得越多,就越有意义。

PS:
任何基于编译时常数值的计算都可以(希望)在编译时进行。这称为"constant propagation"。隐藏优化程序中的常量(例如,通过将其作为命令行args(tmp3*=i或其他技巧)输入)可以使编译器生成的微基准代码看起来更像真实的用例,如果该用例也不能请参阅编译时的常量。(但请注意,其他文件中定义的常量在链接时优化中变得可见,这对于具有许多跨源文件边界相互调用且未在 header 中定义的小函数的项目非常有用( atoi(argv[1])),它们可以正常内联。)
乘以16(或任何其他2的幂)将使用移位,因为这比实际的乘法指令更有效。尤其是对于分割而言,这很重要。参见Why does C++ code for testing the Collatz conjecture run faster than hand-written assembly?Why does GCC use multiplication by a strange number in implementing integer division?
其他在二进制表示中只设置了几位的乘法常数可以通过一些shift + add来完成,通常比通用乘法指令的等待时间短。参见例如How to multiply a register by 37 using only 2 consecutive leal instructions in x86?
如果在编译时都不知道任何输入,那么对于.ha * b,这些优化都不可行。

另请参阅:How can I benchmark the performance of C++ code?,尤其是的链接钱德勒·卡鲁思(Chandler Carruth)的CppCon 2015演讲:"Tuning C++: Benchmarks, and CPUs, and Compilers! Oh My!"
并且因为值得一提两次: Matt Godbolt在CppCon2017上的演讲“What Has My Compiler Done for Me Lately? Unbolting the Compiler's Lid”这是一个足够温和的介绍,新手可能可以很好地了解它,以了解他们的循环是如何编译的,并查看它是否进行了优化。

关于c++ - 简单的for()循环基准测试与任何循环绑定(bind)都花费相同的时间,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/50924929/

有关c++ - 简单的for()循环基准测试与任何循环绑定(bind)都花费相同的时间的更多相关文章

  1. ruby-on-rails - 使用 Ruby on Rails 进行自动化测试 - 最佳实践 - 2

    很好奇,就使用ruby​​onrails自动化单元测试而言,你们正在做什么?您是否创建了一个脚本来在cron中运行rake作业并将结果邮寄给您?git中的预提交Hook?只是手动调用?我完全理解测试,但想知道在错误发生之前捕获错误的最佳实践是什么。让我们理所当然地认为测试本身是完美无缺的,并且可以正常工作。下一步是什么以确保他们在正确的时间将可能有害的结果传达给您? 最佳答案 不确定您到底想听什么,但是有几个级别的自动代码库控制:在处理某项功能时,您可以使用类似autotest的内容获得关于哪些有效,哪些无效的即时反馈。要确保您的提

  2. ruby - 树顶语法无限循环 - 2

    我脑子里浮现出一些关于一种新编程语言的想法,所以我想我会尝试实现它。一位friend建议我尝试使用Treetop(Rubygem)来创建一个解析器。Treetop的文档很少,我以前从未做过这种事情。我的解析器表现得好像有一个无限循环,但没有堆栈跟踪;事实证明很难追踪到。有人可以指出入门级解析/AST指南的方向吗?我真的需要一些列出规则、常见用法等的东西来使用像Treetop这样的工具。我的语法分析器在GitHub上,以防有人希望帮助我改进它。class{initialize=lambda(name){receiver.name=name}greet=lambda{IO.puts("He

  3. ruby-on-rails - 在 Ruby 中循环遍历多个数组 - 2

    我有多个ActiveRecord子类Item的实例数组,我需要根据最早的事件循环打印。在这种情况下,我需要打印付款和维护日期,如下所示:ItemAmaintenancerequiredin5daysItemBpaymentrequiredin6daysItemApaymentrequiredin7daysItemBmaintenancerequiredin8days我目前有两个查询,用于查找maintenance和payment项目(非排他性查询),并输出如下内容:paymentrequiredin...maintenancerequiredin...有什么方法可以改善上述(丑陋的)代

  4. ruby - 如何将脚本文件的末尾读取为数据文件(Perl 或任何其他语言) - 2

    我正在寻找执行以下操作的正确语法(在Perl、Shell或Ruby中):#variabletoaccessthedatalinesappendedasafileEND_OF_SCRIPT_MARKERrawdatastartshereanditcontinues. 最佳答案 Perl用__DATA__做这个:#!/usr/bin/perlusestrict;usewarnings;while(){print;}__DATA__Texttoprintgoeshere 关于ruby-如何将脚

  5. ruby - 使用 C 扩展开发 ruby​​gem 时,如何使用 Rspec 在本地进行测试? - 2

    我正在编写一个包含C扩展的gem。通常当我写一个gem时,我会遵循TDD的过程,我会写一个失败的规范,然后处理代码直到它通过,等等......在“ext/mygem/mygem.c”中我的C扩展和在gemspec的“扩展”中配置的有效extconf.rb,如何运行我的规范并仍然加载我的C扩展?当我更改C代码时,我需要采取哪些步骤来重新编译代码?这可能是个愚蠢的问题,但是从我的gem的开发源代码树中输入“bundleinstall”不会构建任何native扩展。当我手动运行rubyext/mygem/extconf.rb时,我确实得到了一个Makefile(在整个项目的根目录中),然后当

  6. ruby - Ruby 的 Hash 在比较键时使用哪种相等性测试? - 2

    我有一个围绕一些对象的包装类,我想将这些对象用作散列中的键。包装对象和解包装对象应映射到相同的键。一个简单的例子是这样的:classAattr_reader:xdefinitialize(inner)@inner=innerenddefx;@inner.x;enddef==(other)@inner.x==other.xendenda=A.new(o)#oisjustanyobjectthatallowso.xb=A.new(o)h={a=>5}ph[a]#5ph[b]#nil,shouldbe5ph[o]#nil,shouldbe5我试过==、===、eq?并散列所有无济于事。

  7. ruby-on-rails - 如何优雅地重启 thin + nginx? - 2

    我的瘦服务器配置了nginx,我的ROR应用程序正在它们上运行。在我发布代码更新时运行thinrestart会给我的应用程序带来一些停机时间。我试图弄清楚如何优雅地重启正在运行的Thin实例,但找不到好的解决方案。有没有人能做到这一点? 最佳答案 #Restartjustthethinserverdescribedbythatconfigsudothin-C/etc/thin/mysite.ymlrestartNginx将继续运行并代理请求。如果您将Nginx设置为使用多个上游服务器,例如server{listen80;server

  8. ruby - RSpec - 使用测试替身作为 block 参数 - 2

    我有一些Ruby代码,如下所示:Something.createdo|x|x.foo=barend我想编写一个测试,它使用double代替block参数x,这样我就可以调用:x_double.should_receive(:foo).with("whatever").这可能吗? 最佳答案 specify'something'dox=doublex.should_receive(:foo=).with("whatever")Something.should_receive(:create).and_yield(x)#callthere

  9. ruby-on-rails - form_for 中不在模型中的自定义字段 - 2

    我想向我的Controller传递一个参数,它是一个简单的复选框,但我不知道如何在模型的form_for中引入它,这是我的观点:{:id=>'go_finance'}do|f|%>Transferirde:para:Entrada:"input",:placeholder=>"Quantofoiganho?"%>Saída:"output",:placeholder=>"Quantofoigasto?"%>Nota:我想做一个额外的复选框,但我该怎么做,模型中没有一个对象,而是一个要检查的对象,以便在Controller中创建一个ifelse,如果没有检查,请帮助我,非常感谢,谢谢

  10. ruby - ruby 中的 TOPLEVEL_BINDING 是什么? - 2

    它不等于主线程的binding,这个toplevel作用域是什么?此作用域与主线程中的binding有何不同?>ruby-e'putsTOPLEVEL_BINDING===binding'false 最佳答案 事实是,TOPLEVEL_BINDING始终引用Binding的预定义全局实例,而Kernel#binding创建的新实例>Binding每次封装当前执行上下文。在顶层,它们都包含相同的绑定(bind),但它们不是同一个对象,您无法使用==或===测试它们的绑定(bind)相等性。putsTOPLEVEL_BINDINGput

随机推荐