【问题标题】:Is re-declaring variables in every iterations faster than resetting them after every iteration?在每次迭代中重新声明变量是否比在每次迭代后重置它们更快?
【发布时间】:2016-06-08 16:59:05
【问题描述】:

所以我对两种不同代码技术的性能有疑问。你能帮我理解哪个更快/更好,为什么?

这是第一种技术:

int x, y, i;
for(i=0; i<10; i++)
{
    //do stuff with x and y
}
//reset x and y to zero
x=0; 
y=0;

这是第二个:

int i;
for(i=0; i<10; i++)
{
    int x, y;
    //do the same stuff with x and y as above
}

那么哪种编码技术更好呢? 另外,如果您知道更好的和/或任何网站/文章等,我可以在其中阅读有关此内容以及更多与性能相关的内容,我也希望拥有它!

【问题讨论】:

  • 取决于您的编译器的复杂程度,以及您对代码在高优化设置下正常工作的信任程度。最好的情况,根本不重要。
  • 在这两种情况下,您都必须重置变量。它们具有自动存储类,因此它们的初始值是未定义的。
  • 要查看哪种方式更快,请尝试两种方式并对代码进行基准测试。
  • 对于 x86 上的性能主题,最终资源是 Agner Fog:agner.org/optimize

标签: c performance


【解决方案1】:

在最内层范围内声明变量,您将在其中使用它们:

int i;
for(i=0; i<10; i++)
{
    int x, y;
    //do the same stuff with x and y as above
}

永远是首选。最大的改进是您限制了xy 变量的范围。这可以防止您在不希望的地方意外使用它们。

即使你再次使用“相同”的变量:

int i;
for(i=0; i<10; i++)
{
    int x, y;
    //do the same stuff with x and y as above
}

for(i=0; i<10; i++)
{
    int x, y;
    //do the same stuff with x and y as above
}

不会对性能产生任何影响。语句int x, y 在运行时实际上没有任何效果。

大多数现代编译器将计算所有局部变量的总大小,并在函数序言中发出代码以保留堆栈空间(例如sub esp, 90h)。这些变量的空间几乎肯定会从x 的一个“版本”重复使用到下一个。它纯粹是一个词法结构,编译器使用它来阻止您在堆栈上使用您不打算使用的“空间”。

【讨论】:

    【解决方案2】:

    这根本不重要,因为编译器不会自动将变量声明转换为内存或寄存器分配。这两个样本之间的区别在于,在第一种情况下,变量在循环体之外是可见的,而在第二种情况下则不可见。然而,这种差异只存在于 C 级别,如果您不使用循环外的变量,则会产生相同的编译代码。

    编译器有两个选项来存储局部变量:它要么在堆栈上,要么在寄存器中。对于您在程序中使用的每个变量,编译器必须选择它的存在位置。如果在堆栈上,则需要递减堆栈指针以为变量腾出空间。但是这种递减不会发生在变量声明的地方,通常它会在函数的开头完成:堆栈指针只会递减一次,其量足以容纳所有堆栈分配的变量。如果它只是在一个寄存器中,则不需要进行初始化,并且当您第一次进行分配时,该寄存器将用作目的地。重要的是它可以并且将重新使用以前用于现在超出范围的变量的内存位置和寄存器。

    为了说明,我做了两个测试程序。我使用了 10000 次迭代而不是 10 次,因为否则编译器会在高优化级别展开循环。这些程序使用rand 来制作一个快速且可移植的演示,但不应在生产代码中使用它。

    declare_once.c:

    #include <stdio.h>
    #include <time.h>
    #include <stdlib.h>
    
    int main(void) {
        srand(time(NULL));
    
        int x, y, i;
        for (i = 0; i < 10000; i++) {
            x = rand();
            y = rand();
            printf("Got %d and %d !\n", x, y);
        }
    
        return 0;
    }
    

    redeclare.c 是一样的,除了循环是 :

    for (i = 0; i < 10000; i++) {
        int x, y;
        x = rand();
        y = rand();
        printf("Got %d and %d !\n", x, y);
    }
    

    我在 x86_64 Mac 上使用 Apple 的 LLVM 版本 7.3.0 编译了这些程序。我要求它提供我在下面复制的装配输出,省略了与问题无关的部分。

    clang -O0 -S declare_once.c -o declare_once.S :

    _main:
    ## Function prologue
        pushq   %rbp
        movq    %rsp, %rbp           ## Move the old value of the stack 
                                     ## pointer (%rsp) to the base pointer 
                                     ## (%rbp), which will be used to 
                                     ## address stack variables
    
        subq    $32, %rsp            ## Decrement the stack pointer by 32 
                                     ## to make room for up to 32 bytes 
                                     ## worth of stack variables including 
                                     ## x and y
    
    ## Removed code that calls srand
    
        movl    $0, -16(%rbp)        ## i = 0. i has been assigned to the 4 
                                     ## bytes starting at address -16(%rbp),
                                     ## which means 16 less than the base  
                                     ## pointer (so here, 16 more than the 
                                     ## stack pointer).
    
    LBB0_1:                                 
        cmpl    $10, -16(%rbp)
        jge LBB0_4
        callq   _rand                ## Call rand. The return value will be in %eax
    
        movl    %eax, -8(%rbp)       ## Assign the return value of rand to x. 
                                     ## x has been assigned to the 4 bytes
                                     ## starting at -8(%rbp)
        callq   _rand
        leaq    L_.str(%rip), %rdi
        movl    %eax, -12(%rbp)      ## Assign the return value of rand to y. 
                                     ## y has been assigned to the 4 bytes
                                     ## starting at -12(%rbp)
        movl    -8(%rbp), %esi
        movl    -12(%rbp), %edx
        movb    $0, %al
        callq   _printf
        movl    %eax, -20(%rbp)
        movl    -16(%rbp), %eax
        addl    $1, %eax
        movl    %eax, -16(%rbp)
        jmp LBB0_1
    LBB0_4:
        xorl    %eax, %eax
        addq    $32, %rsp            ## Add 32 to the stack pointer : 
                                     ## deallocate all stack variables 
                                     ## including x and y
        popq    %rbp
        retq
    

    redeclare.c 的汇编输出几乎完全相同,只是由于某种原因 x 和 y 分别分配给 -16(%rbp)-12(%rbp),而 i 分配给 -8(%rbp)。我只复制粘贴了循环:

        movl    $0, -16(%rbp)
    LBB0_1:
        cmpl    $10, -16(%rbp)
        jge LBB0_4
        callq   _rand
        movl    %eax, -8(%rbp)        ## x = rand();
        callq   _rand
        leaq    L_.str(%rip), %rdi
        movl    %eax, -12(%rbp)       ## y = rand();
        movl    -8(%rbp), %esi
        movl    -12(%rbp), %edx
        movb    $0, %al
        callq   _printf
        movl    %eax, -20(%rbp)
        movl    -16(%rbp), %eax
        addl    $1, %eax
        movl    %eax, -16(%rbp)
        jmp LBB0_1
    

    所以我们看到,即使在 -O0 处,生成的代码也是一样的。需要注意的重要一点是,在每次循环迭代中 x 和 y 重复使用相同的内存位置,即使从 C 语言的角度来看,它们在每次迭代中都是单独的变量。

    在 -O3 时,变量保存在寄存器中,两个程序都输出完全相同的程序集

    clang -O3 -S declare_once.c -o declare_once.S :

        movl    $10000, %ebx       ## i will be in %ebx. The compiler decided
                                   ## to count down from 10000 because 
                                   ## comparisons to 0 are less expensive,
                                   ## so it actually does i = 10000.
        leaq    L_.str(%rip), %r14
        .align  4, 0x90
    LBB0_1:
        callq   _rand
        movl    %eax, %r15d        ## x = rand(). x has been assigned to
                                   ## register %r15d (32 less significant
                                   ## bits of r15)
        callq   _rand
        movl    %eax, %ecx         ## y = rand(). y has been assigned to
                                   ## register %ecx
        xorl    %eax, %eax
        movq    %r14, %rdi
        movl    %r15d, %esi
        movl    %ecx, %edx
        callq   _printf
        decl    %ebx
        jne LBB0_1
    

    同样,两个版本之间没有区别,即使在 redeclare.c 中我们在每次迭代中都有不同的变量,但相同的寄存器被重复使用,因此没有分配开销。

    请记住,我所说的一切都适用于在每次循环迭代中分配的变量,这似乎是您的想法。另一方面,如果您想对所有迭代使用相同的值,当然应该在循环之前完成赋值。

    【讨论】:

      【解决方案3】:

      这无关紧要,因为在任何一种情况下都需要初始化变量。此外,第一种情况设置 x 和 y 它们不再被使用之后。因此,不需要重置。

      这是第一种技术:

      int x=0, y=0, i;
      for(i=0; i<10; i++)
      {
          //do stuff with x and y
          // x and y stay at the value they get set to during the pass
      }
      // x and y need to be reset if you want to use them again.
      // or would retain whatever they became during the last pass.
      

      如果您希望在循环内将 x 和 y 重置为 0,那么您需要说

      这是第一种技术:

      int x, y, i;
      for(i=0; i<10; i++)
      {
          //reset x and y to zero
          x=0; 
          y=0;
          //do stuff with x and y
          // Now x and y get reset before the next pass
      }
      

      第二个过程使 x 和 y 在范围内是局部的,因此它们在最后一遍结束时被删除。这些值保留在每次传递期间为下一次传递设置的任何值。编译器实际上会在编译时设置变量并初始化它们而不是在运行时。因此,您不会为每次循环定义(和初始化)变量。

      这是第二个:

      int i;
      for(i=0; i<10; i++)
      {
          int x=0, y=0;
          //do the same stuff with x and y as above
          // Usually x and y only saet to 0 at start of first pass.
      }
      

      【讨论】:

        【解决方案4】:

        最佳实践

        那么哪种编码技术更好?

        正如其他人所指出的,鉴于足够成熟/现代的编译器,由于优化,性能方面可能会为零。相反,首选代码是由称为最佳实践的一组想法确定的。

        限制范围

        “Scope”描述了您代码中的range of access。假设预期的范围被限制在循环本身内,xy 应该被声明为在循环内,因为编译器会阻止你稍后在你的函数中使用它们。但是,在您的 OP 中,您显示它们正在重置,这意味着它们稍后将再次用于其他目的。在这种情况下,您必须将它们声明到顶部(例如在循环之外),以便您以后可以使用它们。

        这里有一些代码可以用来演示范围的限制:

        #include <stdio.h>
        
        #define IS_SCOPE_LIMITED
        
        int main ( void )
        {
          int i;
        
        #ifndef IS_SCOPE_LIMITED
          int x, y;                 // compiler will not complain, scope is generous
        #endif
        
          for(i=0; i<10; i++)
          {
        #ifdef IS_SCOPE_LIMITED
            int x, y;              // compiler will complain about use outside of loop
        #endif
            x = i;
            y = x+1;
            y++;
          }
        
          printf("X is %d and Y is %d\n", x, y);
        }
        

        要测试范围,请在顶部注释掉 #define。使用 gcc -Wall loopVars.c -o loopVars 编译并使用./loopVars 运行。

        基准测试和分析

        如果您仍然担心性能,可能是因为您有一些涉及这些变量的晦涩操作,那么测试,测试,再测试一次!(试试benchmarkingprofiling 你的代码)。同样,通过优化,您可能不会发现显着(如果有的话)差异,因为编译器会在运行前完成所有这些(分配变量空间)。

        更新

        为了以另一种方式演示这一点,您可以从代码中删除#ifdef#ifndef(同时删除每个#endif),并在printf 之前添加一行,例如x=2; y=3;。您会发现代码将编译并运行,但输出将是“X is 2 and Y is 3”。这是合法的,因为这两个作用域可以防止同名变量相互竞争。当然,这是一个坏主意,因为您现在在同一段代码中有多个具有相同名称的变量,而且对于更复杂的代码,这将不那么容易阅读和维护。

        【讨论】:

          【解决方案5】:

          int 变量的特定情况下,它几乎没有(或没有)区别。

          对于更复杂类型的变量,尤其是具有(例如)动态分配一些内存的构造函数的变量,在循环的每次迭代中重新创建变量可能比重新初始化它要慢得多。例如:

          #include <vector>
          #include <chrono>
          #include <numeric>
          #include <iostream>
          
          unsigned long long versionA() {
              std::vector<int> x;
              unsigned long long total = 0;
          
              for (int j = 0; j < 1000; j++) {
                  x.clear();
                  for (int i = 0; i < 1000; i++)
                      x.push_back(i);
                  total += std::accumulate(x.begin(), x.end(), 0ULL);
              }
              return total;
          }
          
          unsigned long long versionB() {
              unsigned long long total = 0;
          
              for (int j = 0; j < 1000; j++) {
                  std::vector<int> x;
                  for (int i = 0; i < 1000; i++)
                      x.push_back(i);
                  total += std::accumulate(x.begin(), x.end(), 0ULL);
              }
              return total;
          }
          
          template <class F>
          void timer(F f) {
              using namespace std::chrono;
          
              auto start = high_resolution_clock::now();
              auto result = f();
              auto stop = high_resolution_clock::now();
          
              std::cout << "Result: " << result << "\n";
              std::cout << "Time:   " << duration_cast<microseconds>(stop - start).count() << "\n";
          }
          
          int main() {
              timer(versionA);
              timer(versionB);
          }
          

          至少当我运行它时,这两种方法之间存在相当大的差异:

          Result: 499500000
          Time:   5114
          Result: 499500000
          Time:   13196
          

          在这种情况下,每次迭代创建一个新向量所需的时间是每次迭代清除现有向量的两倍多。

          对于它的价值,可能有两个不同的因素导致速度差异:

          1. 向量的初始创建。
          2. 在将元素添加到向量时重新分配内存。

          当我们clear() 一个向量时,它删除了现有元素,但保留了当前分配的内存,所以在这种情况下,我们在外循环的每次迭代中使用相同的大小,这个版本只是重置vector 不需要在后续迭代中分配任何内存。如果我们在定义vesionA 中的向量后立即添加x.reserve(1000);,则差异会大大缩小(至少在我的测试中,速度并没有完全绑定,但非常接近)。

          【讨论】:

            猜你喜欢
            • 2023-04-03
            • 2014-12-01
            • 2016-05-30
            • 2017-01-15
            • 2012-11-17
            • 2018-12-03
            • 1970-01-01
            • 2015-04-24
            • 1970-01-01
            相关资源
            最近更新 更多