倒计时比倒数更快吗?

时间:2010-05-12 21:56:13

标签: c performance loops

我们的计算机科学老师曾经说过,由于某些原因,倒计时比计数更有效率。 例如,如果你需要使用FOR循环并且某个地方没有使用循环索引(比如在屏幕上打印一行N *) 我的意思是这样的代码:

for (i = N; i >= 0; i--)  
  putchar('*');  

优于:

for (i = 0; i < N; i++)  
  putchar('*');  

这是真的吗?如果是这样,有谁知道为什么?

19 个答案:

答案 0 :(得分:364)

  

这是真的吗?如果是这样,有谁知道为什么?

在古代,当计算机仍然用手工熔化的二氧化硅芯片时,当8位微控制器在地球上漫游时,以及当你的老师年轻时(或者你老师的老师很年轻),有一个常见的机器指令叫做递减并跳过零(DSZ)。 Hotshot程序员使用此指令来实现循环。后来的机器获得了更好的指令,但是仍然有相当多的处理器,比较一些零比用其他任何东西更便宜。 (甚至在某些现代RISC机器上也是如此,例如PPC或SPARC,它们保留整个寄存器始终为零。)

那么,如果您将循环设置为与零而不是N进行比较,可能会发生什么?

  • 您可以保存注册
  • 您可能会得到一个带有较小二进制编码的比较指令
  • 如果前一条指令碰巧设置了一个标志(可能只在x86系列机器上),你可能甚至不需要一个明确的比较指令

这些差异是否可能导致现代无序处理器上真实程序上的任何可衡量的改进?不大可能。事实上,如果你能在微基准测试中显示出可测量的改进,我会留下深刻的印象。

总结:我把你的老师放在头上!你不应该学习关于如何组织循环的过时的伪事实。您应该了解关于循环最重要的事情是确保它们终止,生成正确答案,并且易于阅读我希望你的老师能专注于重要的东西,而不是神话。

答案 1 :(得分:29)

以下是某些硬件可能会发生的情况,具体取决于编译器可以推断出您正在使用的数字范围:使用递增循环,每次循环时都必须测试i<N。对于递减版本,进位标志(设置为减法的副作用)可以自动告诉您是否i>=0。这样可以在循环中每次都节省一次测试。

实际上,在现代流水线处理器硬件上,这种东西几乎肯定无关紧要,因为没有从指令到时钟周期的简单1-1映射。 (虽然我可以想象如果你正在做一些事情,比如从微控制器生成精确定时的视频信号。但是无论如何你都会用汇编语言写。)

答案 2 :(得分:27)

在Intel x86指令集中,构建一个倒计数到零的循环通常可以使用比计数达到非零退出条件的循环更少的指令来完成。具体来说,ECX寄存器传统上用作x86 asm中的循环计数器,而Intel指令集有一个特殊的jcxz跳转指令,用于根据测试结果测试ECX寄存器的零和跳转。

但是,除非您的循环对时钟周期计数非常敏感,否则性能差异可以忽略不计。与向上计数相比,向下计数到零可能会在循环的每次迭代中减少4或5个时钟周期,因此它实际上比新技术更具新颖性。

此外,一个好的优化编译器现在应该能够将你的计数循环源代码转换为倒数到零的机器代码(取决于你如何使用循环索引变量)所以真的没有任何理由写你的循环以奇怪的方式在这里和那里挤压一两个循环。

答案 3 :(得分:23)

是.. !!

从N向下计数到0的速度稍微快一点,从硬件处理比较的角度来看从0到N的计数......

请注意每个循环中的 比较

i>=0
i<N

大多数处理器都与零指令进行比较。因此第一个处理器将转换为机器代码:

  1. 加载i
  2. 比较并跳过小于或等于零
  3. 但第二个需要每次加载N形式内存

    1. load i
    2. 加载N
    3. Sub i和N
    4. 比较并跳过小于或等于零
    5. 所以这不是因为倒数或向上......而是因为您的代码将如何转换为机器代码..

      因此从10​​到100的计数与计数表格100到10相同 但是从i = 100到0的计数比从i = 0到100的计数要快 - 在大多数情况下为
      从i = N到0的计数比从i = 0到N的计数快

      • 请注意,现在编译器可以为您进行此优化(如果它足够聪明)
      • 另请注意,管道可能会导致Belady's anomaly - 效果(无法确定会有什么更好)
      • 最后:请注意,您提供的2个for循环不等同......第一个打印一个* ....
        

      相关:   Why does n++ execute faster than n=n+1?

答案 4 :(得分:12)

在C to psudo-assembly中:

for (i = 0; i < 10; i++) {
    foo(i);
}

变成

    clear i
top_of_loop:
    call foo
    increment i
    compare 10, i
    jump_less top_of_loop

,同时:

for (i = 10; i >= 0; i--) {
    foo(i);
}

变成

    load i, 10
top_of_loop:
    call foo
    decrement i
    jump_not_neg top_of_loop

注意第二个psudo-assembly中没有比较。在许多体系结构中,有一些标志由算术运算(加,减,乘,除,增量,减量)设置,可用于跳转。这些通常可以为您提供基本上比较操作结果与0的免费比较。事实上,在许多架构上

x = x - 0

在语义上与

相同
compare x, 0

此外,在我的示例中与10的比较可能会导致更糟糕的代码。 10可能不得不住在一个寄存器中,所以如果它们供不应求,可能会导致额外的代码移动或每次循环重新加载10个。

编译器有时可以重新排列代码以利用这一点,但它通常很难,因为它们通常无法确定通过循环反转方向在语义上是等效的。

答案 5 :(得分:6)

在这种情况下倒数更快:

for (i = someObject.getAllObjects.size(); i >= 0; i--) {…}

因为someObject.getAllObjects.size()在开始时执行一次。


当然,通过调用size()离开循环可以实现类似的行为,正如Peter所说:

size = someObject.getAllObjects.size();
for (i = 0; i < size; i++) {…}

答案 6 :(得分:4)

  

倒计时比起来更快吗?

也许。但是远远超过99%的时间它都无关紧要,所以你应该使用最“明智”的测试来终止循环,并且明智地,我的意思是读者需要花费最少的思考来弄清楚循环正在做什么(包括使它停止的原因)。使代码与代码正在执行的内容(或文档)模型匹配。

如果循环正在通过一个数组(或列表,或其他任何东西)运行,增量计数器通常会更好地与读者如何考虑循环正在做什么匹配 - 以这种方式编写循环。

但是,如果您正在使用具有N个项目的容器,并且正在移除这些项目,则可能会使计数器更具认知感。

答案中的“可能”更详细一点:

确实,在大多数体系结构中,测试计算结果为零(或从零变为负数)不需要显式测试指令 - 可以直接检查结果。如果要测试计算是否导致某个其他数字,则指令流通常必须有一个显式指令来测试该值。但是,特别是对于现代CPU,此测试通常会为循环结构添加小于噪声级别的额外时间。特别是如果该循环正在执行I / O.

另一方面,如果从零开始倒计时,并使用计数器作为数组索引,您可能会发现代码对系统的内存架构起作用 - 内存读取通常会导致缓存“在预期顺序读取时,向前看'超过当前存储位置的几个存储位置。如果您正在通过内存向后工作,则缓存系统可能不会预期在较低内存地址处读取内存位置。在这种情况下,循环“向后”可能会损害性能。但是,我仍然可能以这种方式编写循环代码(只要性能不成为问题)因为正确性至关重要,并且使代码与模型匹配是帮助确保正确性的好方法。不正确的代码就像你可以得到的那样没有优化。

所以我倾向于忘记教授的建议(当然,不是在他的考试中 - 你应该仍然在课堂上务实),除非并且直到代码的表现真的很重要。

答案 7 :(得分:3)

在一些较旧的CPU上,有DJNZ ==“递减和跳转(如果不为零)”。这允许有效的循环,其中您将初始计数值加载到寄存器中,然后您可以使用一条指令有效地管理递减循环。我们在这里谈论的是20世纪80年代的国际检索单位 - 如果他认为这种“经验法则”仍适用于现代CPU,那么你的老师就会严重失去联系。

答案 8 :(得分:3)

鲍勃,

直到您进行微优化,此时您将获得CPU的手册。此外,如果你正在做那种事情,你可能不需要问这个问题。 :-)但是,你的老师显然不赞同这个想法....

循环示例中有四件事需要考虑:

for (i=N; 
 i>=0;             //thing 1
 i--)             //thing 2
{
  putchar('*');   //thing 3
}
  • 比较

比较(正如其他人指出的)与特定处理器架构相关。处理器的类型多于运行Windows的处理器类型。特别是,可能有一条指令可以简化并加快与0的比较。

  • 调整

在某些情况下,向上或向下调整速度会更快。通常情况下, good 编译器会将其弄清楚,如果可以,则重做循环。并非所有的编译器都很好。

  • Loop Body

您正在使用putchar访问系统调用。那是非常缓慢的。另外,您正在(间接)渲染到屏幕上。那甚至更慢。认为1000:1比率或更高。在这种情况下,循环体完全和完全超过了循环调整/比较的成本。

  • 缓存

缓存和内存布局会对性能产生很大影响。在这种情况下,没关系。但是,如果您正在访问阵列并需要最佳性能,那么您应该研究编译器和处理器如何布置内存访问并调整软件以充分利用它。股票示例是与矩阵乘法相关的示例。

答案 9 :(得分:2)

你老师所说的是一些倾斜的陈述,但没有做太多澄清。 并不是递减比递增更快,但是你可以用递减而不是递增来创建更快的循环。

没有长篇大论,不需要使用循环计数器等 - 下面的重点只是速度和循环计数(非零)。

以下是大多数人如何使用10次迭代实现循环:

int i;
for (i = 0; i < 10; i++)
{
    //something here
}

对99%的情况来说,这是人们可能需要的所有内容,但除了PHP,PYTHON,JavaScript之外,还有整个世界的时间关键软件(通常是嵌入式,操作系统,游戏等),其中CPU滴答真的很重要,所以请简要介绍一下代码:

int i;
for (i = 0; i < 10; i++)
{
    //something here
}

编译后(没有优化)编译版可能看起来像这样(VS2015):

-------- C7 45 B0 00 00 00 00  mov         dword ptr [i],0  
-------- EB 09                 jmp         labelB 
labelA   8B 45 B0              mov         eax,dword ptr [i]  
-------- 83 C0 01              add         eax,1  
-------- 89 45 B0              mov         dword ptr [i],eax  
labelB   83 7D B0 0A           cmp         dword ptr [i],0Ah  
-------- 7D 02                 jge         out1 
-------- EB EF                 jmp         labelA  
out1:

整个循环是8条指令(26字节)。在其中 - 实际上有6个指令(17个字节),带有2个分支。是的我知道它可以做得更好(这只是一个例子)。

现在考虑一下这种经常被嵌入式开发人员编写的构造:

i = 10;
do
{
    //something here
} while (--i);

它也迭代10次(是的,我知道我的值与循环显示的不同,但我们关心这里的迭代计数)。 这可以编译成:

00074EBC C7 45 B0 01 00 00 00 mov         dword ptr [i],1  
00074EC3 8B 45 B0             mov         eax,dword ptr [i]  
00074EC6 83 E8 01             sub         eax,1  
00074EC9 89 45 B0             mov         dword ptr [i],eax  
00074ECC 75 F5                jne         main+0C3h (074EC3h)  

5条指令(18字节)和一个分支。实际上循环中有4条指令(11字节)。

最好的是一些CPU(包括x86 / x64兼容)具有可以递减寄存器的指令,稍后将结果与零进行比较并且如果结果不为零则执行分支。几乎所有PC cpu都执行此指令。使用它,循环实际上只是一个(是的)2字节指令:

00144ECE B9 0A 00 00 00       mov         ecx,0Ah  
label:
                          // something here
00144ED3 E2 FE                loop        label (0144ED3h)  // decrement ecx and jump to label if not zero

我是否必须解释哪个更快?

现在,即使特定CPU没有实现上述指令,所有它需要模拟它是一个减量,然后是条件跳转,如果前一个指令的结果恰好为零。

因此,无论某些情况,你可能会指出为什么我错了等等我的意见等等我强调 - 如果你知道如何,为什么以及何时,这对于向下循环是有益的。

PS。是的,我知道明智的编译器(具有适当的优化级别)将重写for循环(使用递增循环计数器)到do ..而等效于常量循环迭代...(或展开它)...

答案 10 :(得分:2)

重要的是,无论你是在增加还是减少你的计数器,无论你是在上升记忆还是记忆力下降。大多数缓存都针对内存而非内存进行了优化。由于内存访问时间是当今大多数程序所面临的瓶颈,这意味着更改程序以便提高内存可以提高性能,即使这需要将计数器与非零值进行比较。在我的一些程序中,我通过将代码更改为内存而不是内存来看到性能的显着提高。

怀疑?这是我得到的输出:

Ave. Up Memory   = 4839 mus
Ave. Down Memory = 5552 mus

Ave. Up Memory   = 18638 mus
Ave. Down Memory = 19053 mus

运行此程序:

#include <chrono>
#include <iostream>
#include <random>
#include <vector>

template<class Iterator, typename T>
void FillWithRandomNumbers(Iterator start, Iterator one_past_end, T a, T b) {
  std::random_device rnd_device;
  std::mt19937 generator(rnd_device());
  std::uniform_int_distribution<T> dist(a, b);
  for (auto it = start; it != one_past_end; it++)
    *it = dist(generator);
  return ;
}

template<class Iterator>
void FillWithRandomNumbers(Iterator start, Iterator one_past_end, double a, double b) {
  std::random_device rnd_device;
  std::mt19937_64 generator(rnd_device());
  std::uniform_real_distribution<double> dist(a, b);
  for (auto it = start; it != one_past_end; it++)
    *it = dist(generator);
  return ;
}

template<class Iterator, class T>
inline void sum_abs_up(Iterator first, Iterator one_past_last, T &total) {
  T sum = 0;
  auto it = first;
  do {
    sum += *it;
    it++;
  } while (it != one_past_last);
  total += sum;
}

template<class Iterator, class T>
inline void sum_abs_down(Iterator first, Iterator one_past_last, T &total) {
  T sum = 0;
  auto it = one_past_last;
  do {
    it--;
    sum += *it;
  } while (it != first);
  total += sum;
}

template<class T>
std::chrono::nanoseconds TimeDown(std::vector<T> &vec, const std::vector<T> &vec_original,
                                  std::size_t num_repititions, T &running_sum) {
  std::chrono::nanoseconds total{0};
  for (std::size_t i = 0; i < num_repititions; i++) {
    auto start_time = std::chrono::high_resolution_clock::now();
    sum_abs_down(vec.begin(), vec.end(), running_sum);
    total += std::chrono::high_resolution_clock::now() - start_time;
    vec = vec_original;
  }
  return total;
}

template<class T>
std::chrono::nanoseconds TimeUp(std::vector<T> &vec, const std::vector<T> &vec_original,
                                std::size_t num_repititions, T &running_sum) {
  std::chrono::nanoseconds total{0};
  for (std::size_t i = 0; i < num_repititions; i++) {
    auto start_time = std::chrono::high_resolution_clock::now();
    sum_abs_up(vec.begin(), vec.end(), running_sum);
    total += std::chrono::high_resolution_clock::now() - start_time;
    vec = vec_original;
  }
  return total;
}


template<class ValueType>
void TimeFunctions(std::size_t num_repititions, std::size_t vec_size = (1u << 24)) {
  auto lower = std::numeric_limits<ValueType>::min();
  auto upper = std::numeric_limits<ValueType>::max();
  std::vector<ValueType> vec(vec_size);

  FillWithRandomNumbers(vec.begin(), vec.end(), lower, upper);
  const auto vec_original = vec;
  ValueType sum_up = 0, sum_down = 0;

  auto time_up   = TimeUp(vec, vec_original, num_repititions, sum_up).count();
  auto time_down = TimeDown(vec, vec_original, num_repititions, sum_down).count();
  std::cout << "Ave. Up Memory   = " << time_up/(num_repititions * 1000) << " mus\n";
  std::cout << "Ave. Down Memory = " << time_down/(num_repititions * 1000) << " mus"
            << std::endl;
  return ;
}

int main() {
  std::size_t num_repititions = 1 << 10;
  TimeFunctions<int>(num_repititions);
  std::cout << '\n';
  TimeFunctions<double>(num_repititions);
  return 0;
}

sum_abs_upsum_abs_down都做同样的事情,并以相同的方式计时,唯一的区别是sum_abs_upsum_abs_down内存下降时会占用内存。我甚至通过引用传递vec,以便两个函数访问相同的内存位置。然而,sum_abs_up始终比sum_abs_down更快。自己动手(我用g ++ -O3编译)。

仅供参考vec_original进行实验,让我可以轻松更改sum_abs_upsum_abs_down,使其更改vec,同时不允许这些更改影响未来的时间。

重要的是要注意我的计时循环有多紧。如果一个循环体很大,那么它的迭代器是否上升或下降都很重要,因为执行循环体的时间可能完全占主导地位。此外,值得一提的是,对于一些罕见的循环,降低内存有时比上升更快。但是,即使有这样的循环,上升的速度也总是慢于下降的情况(不同于上升记忆的小体积循环,相反的情况往往是正确的;实际上,对于少数几个循环我已经定时,通过提高内存来提高性能是40 +%)。

关键是,根据经验,如果你有选项,如果循环的体积很小,并且你的循环之间的差别就是内存而不是下降那么你应该记忆。

答案 11 :(得分:2)

可以更快。

在我正在使用的NIOS II处理器上,传统的for循环

for(i=0;i<100;i++)

生成程序集:

ldw r2,-3340(fp) %load i to r2
addi r2,r2,1     %increase i by 1
stw r2,-3340(fp) %save value of i
ldw r2,-3340(fp) %load value again (???)
cmplti r2,r2,100 %compare if less than equal 100
bne r2,zero,0xa018 %jump

如果我们倒计时

for(i=100;i--;)

我们得到的装配需要少2个指令。

ldw r2,-3340(fp)
addi r3,r2,-1
stw r3,-3340(fp)
bne r2,zero,0xa01c

如果我们有嵌套循环,内循环执行很多,我们可以有一个可衡量的差异:

int i,j,a=0;
for(i=100;i--;){
    for(j=10000;j--;){
        a = j+1;
    }
}

如果内部循环如上所述,则执行时间为:0.12199999999999999734秒。 如果以传统方式编写内循环,则执行时间为:0.17199999999999998623秒。因此,循环倒计时大约 30%

但是:此测试是在关闭所有GCC优化的情况下完成的。如果我们打开它们,编译器实际上比这个简单优化更聪明,甚至在整个循环期间将值保存在寄存器中,我们将得到像

这样的程序集
addi r2,r2,-1
bne r2,zero,0xa01c

在这个特殊的例子中,编译器甚至注意到,在循环执行之后变量 a 总是为1并且完全跳过循环。

但是我经历过,有时候如果循环体足够复杂,编译器就无法进行这种优化,所以总是得到快速循环执行的最安全的方法是写:

register int i;
for(i=10000;i--;)
{ ... }

当然这只会起作用,如果反向执行循环并不重要,就像Betamoo说的那样,只有在计数到零时才会有效。

答案 12 :(得分:2)

奇怪的是,似乎存在差异。至少,在PHP中。考虑以下基准:

<?php

print "<br>".PHP_VERSION;
$iter = 100000000;
$i=$t1=$t2=0;

$t1 = microtime(true);
for($i=0;$i<$iter;$i++){}
$t2 = microtime(true);
print '<br>$i++ : '.($t2-$t1);

$t1 = microtime(true);
for($i=$iter;$i>0;$i--){}
$t2 = microtime(true);
print '<br>$i-- : '.($t2-$t1);

$t1 = microtime(true);
for($i=0;$i<$iter;++$i){}
$t2 = microtime(true);
print '<br>++$i : '.($t2-$t1);

$t1 = microtime(true);
for($i=$iter;$i>0;--$i){}
$t2 = microtime(true);
print '<br>--$i : '.($t2-$t1);

结果很有意思:

PHP 5.2.13
$i++ : 8.8842368125916
$i-- : 8.1797409057617
++$i : 8.0271911621094
--$i : 7.1027431488037


PHP 5.3.1
$i++ : 8.9625310897827
$i-- : 8.5790238380432
++$i : 5.9647901058197
--$i : 5.4021768569946

如果有人知道原因,那么很高兴知道:)

编辑:即使您从0开始计数,但其他任意值,结果也是相同的。所以可能不仅仅是与零的比较,这会产生影响吗?

答案 13 :(得分:2)

这是一个有趣的问题,但作为一个实际问题,我认为这不重要,并且不会使一个循环比另一个更好。

根据这个维基百科页面:Leap second,“......由于潮汐摩擦,太阳日每个世纪变长1.7毫秒。”但是,如果你算几天直到你的生日,你真的关心这个微小的时间差异吗?

源代码易于阅读和理解更为重要。这两个循环是可读性很重要的一个很好的例子 - 它们不会循环相同的次数。

我敢打赌,大多数程序员都会阅读(i = 0; i&lt; N; i ++)并立即理解这种情况会循环N次。无论如何,对我来说,(i = 1; i <= N; i ++)的循环不太清楚,并且(i = N; i> 0; i--)我必须考虑它片刻。如果代码的意图直接进入大脑而不需要任何思考,那就最好了。

答案 14 :(得分:1)

重点是,在倒数时,您无需单独检查i >= 0以递减i。观察:

for (i = 5; i--;) {
  alert(i);  // alert boxes showing 4, 3, 2, 1, 0
}

比较和递减i都可以在一个表达式中完成。

请参阅其他答案,了解为什么归结为更少的x86指令。

至于它是否会对您的应用程序产生有意义的影响,我想这取决于您拥有多少循环以及它们的嵌套程度。但对我而言,这样做也是可读的,所以无论如何我都这么做。

答案 15 :(得分:1)

无论方向如何,总是使用前缀形式(++ i而不是i ++)!

for (i=N; i>=0; --i)  

for (i=0; i<N; ++i) 

说明:http://www.eskimo.com/~scs/cclass/notes/sx7b.html

此外你可以写

for (i=N; i; --i)  

但我希望现代编译器能够完成这些优化。

答案 16 :(得分:1)

不,那不是真的。可能更快的一种情况是,在循环的每次迭代期间,否则您将调用函数来检查边界。

for(int i=myCollection.size(); i >= 0; i--)
{
   ...
}

但如果以这种方式做到这一点不太清楚,那就不值得了。在现代语言中,无论如何都应该尽可能使用foreach循环。您特别提到了应该使用foreach循环的情况 - 当您不需要索引时。

答案 17 :(得分:0)

现在,我认为你有足够的汇编讲座:)我想向你介绍top-&gt; down方法的另一个原因。

从顶部出发的原因很简单。在循环体中,您可能会意外地更改边界,这可能以不正确的行为或甚至非终止循环结束。

查看Java代码的这一小部分(因为这个原因,语言并不重要):

    System.out.println("top->down");
    int n = 999;
    for (int i = n; i >= 0; i--) {
        n++;
        System.out.println("i = " + i + "\t n = " + n);
    }
    System.out.println("bottom->up");
    n = 1;
    for (int i = 0; i < n; i++) {
        n++;
        System.out.println("i = " + i + "\t n = " + n);
    }

所以我的观点是你应该考虑选择自上而下或以常数作为边界。

答案 18 :(得分:-1)

在汇编程序级别,计数到零的循环通常比计数到达给定值的循环略快。如果计算结果等于零,则大多数处理器将设置零标志。如果减去一个使计算回绕过零,这通常会改变进位标志(在某些处理器上它会将其设置在其他处理器上它将清除它),因此与零的比较基本上是免费的。

当迭代次数不是常数而是变量时,情况更是如此。

在琐碎的情况下,编译器可能能够自动优化循环的计数方向,但在更复杂的情况下,程序员可能知道循环的方向与整体行为无关,但编译器无法证明