倒数比倒数快吗?


131

我们的计算机科学老师曾经说过,由于某种原因,倒数比倒数更有效。例如,如果您需要使用FOR循环,而循环索引未在某处使用(例如在屏幕上打印N *行),则表示这样的代码:

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

优于:

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

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


6
哪位计算机科学家?在什么出版物上?
bmargulies 2010年

26
可以想象,每次迭代可以节省一纳秒的时间,或者在一个毛茸茸的猛family象族上最多可以节省一根头发。的putchar使用时间为99.9999%(给予或接受)。
Mike Dunlavey 2010年

38
过早的优化是万恶之源。请使用您认为合适的任何形式,因为(您已经知道)它们在逻辑上是等效的。编程中最困难的部分是将程序的理论传达给其他程序员(和您自己!)。使用使您或其他程序员看过一秒钟以上的构造是一个净损失。您将永远不会收回任何人花在思考“为什么要倒数?”上的时间。
David M 2010年

61
第一个循环显然要慢一些,因为它调用了11次putchar,而第二个循环只调用了10次。
Paul Kuliniewicz

17
您是否注意到,如果i是无符号的,则第一个循环是无限循环?
Shahbaz 2012年

Answers:


371

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

在远古时代,当仍然用手从熔融的二氧化硅中切出计算机,8位微控制器在地球上漫游以及您的老师年轻(或您的老师的老师年轻)时,有一条通用的机器指令称为减量和跳过如果为零(DSZ)。热门汇编程序员使用此指令来实现循环。后来的机器得到了更奇妙的指令,但是仍然有很多处理器在上面比较零的东西比与其他东西比较便宜。(即使在某些现代RISC机器(例如PPC或SPARC)上也是如此,它们保留整个寄存器始终为零。)

因此,如果将循环绑定为零而不是N,那会发生什么?

  • 您可以保存注册
  • 您可能会使用较小的二进制编码获得比较指令
  • 如果前面的指令碰巧设置了一个标志(可能仅在x86系列机器上),那么您甚至可能不需要显式的比较指令。

这些差异是否可能导致对现代无序处理器上的真实程序进行任何可衡量的改进?不大可能。实际上,即使您在微基准测试上也能表现出可衡量的改进,我会印象深刻。

摘要:我拍打你的老师头顶! 您不应该学习有关如何组织循环的过时的伪事实。您应该了解,关于循环的最重要的事情是确保循环终止,产生正确的答案并且易于阅读 我希望您的老师将重点放在重要的东西上,而不是神话。


3
++而且,其putchar花费比循环开销长许多数量级。
Mike Dunlavey 2010年

41
这不是严格的神话:如果他正在做某种超级优化的实时系统,它将派上用场。但是,这类黑客可能已经知道了所有这些信息,并且肯定不会将入门级CS学生与神秘事物混淆。
保罗·内森

4
@Joshua:以哪种方式可以检测到这种优化?正如提问者所说,循环索引本身并不使用循环,因此,只要迭代次数相同,行为就不会改变。就正确性的证明而言,进行变量替换j=N-i表明两个循环是等效的。
psmears,2010年

7
为摘要+1。不要汗流because背,因为在现代硬件上,它几乎没有区别。20年前,这几乎没有什么不同。如果您认为自己必须在意,请对两种方法都进行计时,看不出明显的区别,然后重新开始清楚,正确地编写代码
Donal Fellows 2010年

3
我不知道我应该赞成还是赞成总结。
Danubian Sailor

29

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

实际上,在现代流水线处理器硬件上,由于没有从指令到时钟周期的简单1-1映射,因此这些东西几乎可以说是无关紧要的。(尽管我可以想象,如果您正在执行诸如从微控制器生成精确定时的视频信号之类的事情的话,但是无论如何您都将用汇编语言编写。)


2
那不是零标志而不是进位标志吗?
鲍勃

2
@Bob在这种情况下,您可能希望达到零,打印结果,进一步递减,然后发现您的零位以下了零,导致进位(或借位)。但是写得稍有不同的是,递减循环可能会改用零标志。
sigfpe 2010年

1
只是为了完美地讲究技巧,并不是所有现代硬件都已流水线化。嵌入式处理器将与此类微优化具有更大的相关性。
保罗·内森

@Paul因为我对Atmel AVR有一定的经验,所以我没有忘记提及微控制器...
sigfpe 2010年

27

在Intel x86指令集中,构建循环以递减至零的循环通常可以使用比计数到非零退出条件的循环少的指令来完成。具体来说,ECX寄存器传统上用作x86 asm中的循环计数器,并且Intel指令集具有特殊的jcxz跳转指令,该指令会测试ECX寄存器是否为零并根据测试结果进行跳转。

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

另外,如今,一个好的优化编译器应该能够将递增循环的源代码转换为递减计数的零机器代码(取决于您使用循环索引变量的方式),因此实际上没有任何理由要在其中编写循环只是在这里和那里挤压一个或两个周期的奇怪方法。


2
几年前,我已经看到Microsoft的C ++编译器进行了这种优化。可以看到未使用循环索引,因此将其重新排列为最快的形式。
Mark Ransom

1
@Mark:也是从1996
。– dthorpe 2010年

4
@MarkRansom实际上,即使使用了循环索引变量,编译器也可以使用递减计数来实现循环,具体取决于在循环中的使用方式。如果循环索引变量仅用于索引静态数组(在编译时已知大小的数组),则可以将数组索引设置为ptr +数组大小-循环索引var,它在x86中仍然可以是一条指令。调试汇编器并看到循环递减计数但数组索引增加,这是很疯狂的事情!
dthorpe 2012年

1
实际上,今天您的编译器可能不会使用循环和jecxz指令,因为它们比dec / jnz对慢。
2013年

1
@FUZxxl更有理由不要以奇怪的方式编写循环。编写易于阅读的清晰代码,然后让编译器完成工作。
dthorpe

23

是..!!

从N到0的计数比从0到N的计数在硬件将如何处理比较的意义上要快一些。

注意每个循环中的比较

i>=0
i<N

大多数处理器的指令与零指令进行比较。因此第一个处理器将被转换为机器代码,如下所示:

  1. 加载我
  2. 如果小于或等于零,则比较并跳转

但是第二个每次需要加载N个Form Memory

  1. 加载我
  2. 负载N
  3. Sub i和N
  4. 如果小于或等于零,则比较并跳转

因此,这并不是因为递减计数。而是因为您的代码将如何转换为机器代码。

所以从10到100的计数与从100到10的
计数相同,但是从i = 100到0的计数比从i = 0到100的
计数快-在大多数情况下,从i = N到0的计数比从i = 0至N

  • 请注意,如今,编译器可能会为您进行此优化(如果足够聪明的话)
  • 还要注意,管道可能会导致Belady的异常现象(无法确定会更好)
  • 最后:请注意,您介绍的2个for循环不相同。.第一个再打印* *。

相关: 为什么n ++执行的速度快于n = n + 1?


6
因此,您要说的是,倒数并不是更快,与零比较比其他任何值都更快。意思是从10计数到100,从100计数到10是一样的吗?
鲍勃

8
是的..它是不是“倒计时或向上”的事..但它是“比较什么” ..事
Betamoo

3
虽然这是正确的汇编程序级别。两件事结合在一起,使现实变得不真实-使用长管道和推测性指令的现代硬件将潜入“ Sub i and N”而不会产生额外的周期-甚至是最粗糙的编译器也会优化“ Sub i and N” N”不存在。
詹姆斯·安德森

2
@nico不必是一个古老的系统。它只需要一个指令集,就可以进行零位比较操作,从某种意义上说,它比等效的比较寄存器值要快/好一些。x86在jcxz中有它。x64仍然有它。不古老。同样,RISC体系结构通常在特殊情况下为零。例如,DEC AXP Alpha芯片(在MIPS系列中)有一个“零寄存器”-读为零,写什么也不做。与零寄存器进行比较,而不是与包含零值的通用寄存器进行比较,可以减少指令间的依赖性,并有助于乱序执行。
dthorpe 2012年

5
@Betamoo:我经常想知道为什么更好/更正确的答案(这是您的答案)为什么不被更多的选票所赞赏,并得出结论,太频繁的stackoverflow选票会受到回答的人的声誉(以点数计)的影响(这是非常非常糟糕的),而不是答案的正确性
Artur 2013年

12

在C到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

注意第二个伪程序集中缺少比较。在许多体系结构上,都有一些由算术运算设置的标志(加,减,乘,除,递增,递减),可用于跳转。这些通常会给您本质上是免费将操作结果与0进行比较的结果。实际上在许多架构上

x = x - 0

在语义上与

compare x, 0

另外,在我的示例中将10与之比较可能会导致代码变差。10可能必须存放在寄存器中,因此,如果它们供不应求,则会产生成本,并可能会导致产生额外的代码来移动事物或每次通过循环重新加载10。

编译器有时可以重新安排代码以利用此优势,但是通常很困难,因为他们通常无法确保通过循环反转方向在语义上是等效的。


是否有2个指令的差异而不是1个差异?
佩里耶

另外,为什么很难确定呢?只要i在循环中不使用var ,显然您可以将其翻转过来,不是吗?
佩里耶

6

在这种情况下,倒数计时更快:

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

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


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

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

5
这不是“绝对快”。在许多情况下,向上计数时可以将size()调用挂起循环,因此仍然只被调用一次。显然,这与语言和编译器有关(与代码有关;例如,在C ++中,如果size()是虚拟的,则不会被吊起),但是无论哪种方式都不确定。
彼得2010年

3
@Peter:仅当编译器确定在整个循环中size()是幂等时,才可以。除非循环非常简单,否则可能并非总是如此。
劳伦斯·多尔

@LawrenceDol,除非您使用进行动态代码编译,否则编译器肯定会知道它exec
佩里耶

4

倒数比倒数快吗?

也许。但是,远远超过99%的时间都无关紧要,因此您应该使用最“明智”的测试来终止循环,并且明智的是,我的意思是,读者花费最少的精力就能弄清楚循环在做什么(包括使它停止的原因)。使您的代码与代码行为的思维(或记录)模型相匹配。

如果循环正在工作,则将其向上遍历数组(或列表或其他内容),则递增计数器通常会更好地与读者可能认为循环在做什么的方式相匹配-以这种方式编写循环。

但是,如果您正在通过一个装有N物品的容器来工作,并且要随手取走这些物品,那么降低计数器的工作量可能更具认知意义。

有关答案中“也许”的更多详细信息:

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

另一方面,例如,如果您从零开始递减计数,并将计数器用作数组索引,则可能会发现代码对系统的内存体系结构起作用-内存读取通常会导致高速缓存“往前看”预期顺序读取会在当前位置之后超过几个存储位置。如果您正在反向浏览内存,则缓存系统可能无法预期在较低内存地址处读取内存位置。在这种情况下,“向后”循环可能会损害性能。但是,我仍然可能会以这种方式编写循环代码(只要性能没有成为问题),因为正确性至关重要,而使代码与模型匹配则是确保正确性的好方法。错误的代码会尽可能地得到优化。

因此,我倾向于忘记教授的建议(当然,尽管不是在他的考试中-您应该在课堂上还是务实的),除非并且直到代码的性能真正重要为止。


3

在某些较旧的CPU上,有DJNZ==“减量,如果不为零则跳转”的指令。这样可以实现高效的循环,在该循环中,您可以将初始计数值加载到寄存器中,然后可以使用一条指令有效地管理递减循环。但是,我们在这里谈论的是1980年代的ISA-如果您的老师认为这种“经验法则”仍然适用于现代CPU,那将是严重的脱节。


3

鲍勃

直到您进行微优化后,您才能掌握CPU的手册。此外,如果您正在做这种事情,您可能根本不需要问这个问题。:-)但是,您的老师显然不同意这个想法。

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

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

如其他人所指出的,比较与特定处理器体系结构有关。处理器类型比运行Windows的处理器更多。特别是,可能有一条指令可以简化并加快与0的比较。

  • 调整

在某些情况下,向上或向下调整速度更快。通常,一个好的编译器会解决它,并在可能的情况下重做循环。并非所有的编译器都是好的。

  • 圈体

您正在使用putchar访问syscall。那太慢了。另外,您正在渲染到屏幕上(间接)。那更慢。认为比例为1000:1或更高。在这种情况下,回路体完全超过了回路调节/比较的成本。

  • 快取

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


3

与要增加还是减少计数器相比,重要的是要增加内存还是减少内存。大多数缓存都针对增加内存而不是减少内存进行了优化。由于内存访问时间是当今大多数程序面临的瓶颈,因此,即使需要将计数器与非零值进行比较,更改程序以提高内存容量也可以提高性能。在某些程序中,通过更改代码以增加内存而不是减少内存,我看到了性能上的显着改善。

怀疑吗?只需编写一个程序来增加/减少内存的时间循环。这是我得到的输出:

Average Up Memory   = 4839 mus
Average Down Memory = 5552 mus

Average Up Memory   = 18638 mus
Average Down Memory = 19053 mus

(其中“ mus”代表微秒)运行该程序:

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

//Sum all numbers going up memory.
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;
}

//Sum all numbers going down memory.
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;
}

//Time how long it takes to make num_repititions identical calls to sum_abs_down().
//We will divide this time by num_repitions to get the average time.
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 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 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 << "Average Up Memory   = " << time_up/(num_repititions * 1000) << " mus\n";
  std::cout << "Average 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_up内存增加而sum_abs_down内存减少。我什至通过vec引用传递,以便两个函数都访问相同的内存位置。不过,sum_abs_up始终比快sum_abs_down。自己运行(我用g ++ -O3编译了它)。

重要的是要注意我正在计时的循环有多紧密。如果循环的主体很大,那么它的迭代器是增加还是减少内存都无关紧要,因为执行循环主体所需的时间很可能会完全占据主导地位。另外,重要的是要提到,在一些罕见的循环中,内存下降有时比内存上升快。但是即使有这样的循环,也永远不会出现内存上升总是慢于下降的情况(不同于小型内存循环会占用内存,事实恰恰相反;事实上,对于少数几个循环我定时,内存增加会使性能提高40%以上。

根据经验,要点是,如果可以选择,如果循环的主体很小,并且让循环增加内存而不是减少内存之间没有什么区别,则应该增加内存。

FYI vec_original在这里进行试验,以使其易于更改,sum_abs_upsum_abs_down使其进行更改,vec同时不允许这些更改影响将来的时间。我高度推荐玩弄sum_abs_upsum_abs_down和计时的结果。


2

无论方向如何,始终使用前缀形式(++ 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)  

但是我希望现代的编译器能够完全执行这些优化。


从未见过有人抱怨过。但是在阅读了链接之后,它实际上是有道理的:)谢谢。
汤米·雅各布森

3
嗯,为什么他总是要使用前缀形式?如果没有任务在进行,则它们是相同的,并且您链接到的文章甚至说后缀形式更为常见。
bobDevil 2010年

3
为什么要始终使用前缀形式?在这种情况下,它在语义上是相同的。
本·佐托

2
后缀形式可能会创建对象的不必要副本,尽管如果从未使用过该值,则编译器可能仍会将其优化为前缀形式。
尼克·刘易斯

出于习惯,我总是做--i和i ++,因为当我学习C计算机时,通常会有寄存器的前减量和后加量,但反之则不然。因此,* p ++和*-p比* ++ p和* p--快,因为前两个可以在一条68000机器代码指令中完成。
JeremyP,2010年

2

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

根据此Wikipedia页面:Le秒,“ ...主要由于潮汐摩擦,每个世纪的太阳日延长1.7毫秒。” 但是,如果您要计算直到生日的天数,您是否真的关心时间上的微小差异?

更重要的是源代码应易于阅读和理解。这两个循环很好地说明了为什么可读性很重要-它们不会循环相同的次数。

我敢打赌大多数程序员会读(i = 0; i <N; i ++)并立即理解这会循环N次。无论如何,对于(i = 1; i <= N; i ++)的循环来说不太清楚,并且对于(i = N; i> 0; i--),我必须考虑一下。最好让代码的意图直接进入大脑而无需任何思考。


两种结构都非常容易理解。有些人声称,如果您有3或4次重复,则复制指令比进行循环要好,因为这样会使他们更容易理解。
Danubian Sailor

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开始计数,而是从其他任意值开始计数。因此,可能不仅存在与零的比较,这会有所不同吗?


速度较慢的原因是前缀运算符不需要存储临时变量。考虑$ foo = $ i ++; 发生三件事:$ i存储到一个临时文件,$ i增加,然后$ foo被赋值为该临时文件的值。在$ i ++的情况下;一个聪明的编译器可以意识到临时是不必要的。PHP只是没有。C ++和Java编译器足够聪明,可以进行简单的优化。
出色的编译器,2010年

为什么$ i--比$ i ++快?
ts。

您运行了多少次基准测试?您是否裁剪了边界并为每个结果取平均值?在基准测试期间,您的计算机还有其他操作吗?大约0.5的差异可能只是其他CPU活动或管道使用率的结果,或者...或者...好了,您就明白了。
八位大师

是的,我在这里给出平均值。基准测试是在不同的计算机上运行的,并且偶然之间存在差异。
ts。

@Conspicuous编译器=>您知道还是假设?
ts。

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所说的那样)无关紧要,这才起作用。


2

您的老师说的是一些倾斜的陈述,没有太多澄清。并不是说减量要比增量快,但是您可以创建一个比减量快得多的循环。

无需详细说明,也无需使用循环计数器等-下面重要的只是速度和循环计数(非零)。

这是大多数人使用10次迭代实现循环的方式:

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

在99%的情况下,这是所有人可能需要的,但是与PHP,PYTHON,JavaScript一起,在整个时间紧迫的软件世界(通常是嵌入式,OS,游戏等)中,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兼容的CPU)具有可能使寄存器减1的指令,以后将结果与零进行比较,如果结果不同于零,则执行分支。实际上,所有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 ..而等效于进行恒定循环迭代...(或展开它)...


1

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

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

但是,如果这样做起来不太清楚,那是不值得的。在现代语言中,无论如何都应该使用foreach循环。您特别提到了在不需要索引时应该使用foreach循环的情况。


1
为了清晰和有效率,您应该至少有习惯for(int i=0, siz=myCollection.size(); i<siz; i++)
劳伦斯·多尔

1

关键是,在i >= 0递减计数时,您无需单独检查减量i。观察:

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

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

查看其他答案以了解为什么这归结为更少的x86指令。

至于它是否对您的应用程序产生有意义的变化,我想这取决于您有多少个循环以及它们有多深嵌套。但是对我来说,这样做是很容易理解的,所以我还是这么做。


我认为这是一种较差的样式,因为它取决于读者是否知道i--的返回值是i的旧值,以节省循环的可能值。这只有在有很多循环迭代的情况下才有意义,并且循环是迭代长度的很大一部分,并且实际上在运行时出现。接下来,有人会尝试(i = 5; --i;),因为他们听说在C ++中,当我是非平凡类型时,您可能想要避免创建一些临时项,而现在您正处于错误的境地故意丢掉了使错误代码看起来错误的机会。
mabraham 2014年

0

现在,我认为您有足够的汇编讲座:)我想向您介绍采用自上而下方法的另一个原因。

自上而下的原因很简单。在循环的主体中,您可能会意外更改边界,这可能会导致行为不正确甚至是非终止循环。

看一小部分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);
    }

所以我的意思是,您应该考虑更喜欢从上到下或者以常量作为边界。


嗯?!您失败的示例确实是违反直觉的,也就是说,这是一个稻草人的论点-没有人会写这个。一个人会写for (int i=0; i < 999; i++) {
劳伦斯·多尔

@Software Monkey想象n是某些计算的结果...例如,您可能要迭代某个集合,并且其大小是边界,但是由于某些副作用,您需要在循环体中向该集合添加新元素。
加布里埃尔·舍尔贝克(GabrielŠčerbák)2010年

如果这是您要传达的意图,那么您的示例应说明:for(int xa=0; xa<collection.size(); xa++) { collection.add(SomeObject); ... }
Lawrence Dol 2010年

@Software Monkey我想变得更笼统而不是只谈论收藏,因为我
要讲的

2
是的,但是如果您要通过示例进行推理,则您的示例必须可信并能说明这一点。
劳伦斯·多尔

-1

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

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

在平凡的情况下,编译器可能能够自动优化循环的计数方向,但在更复杂的情况下,可能是程序员知道循环的方向与总体行为无关,但是编译器无法证明这一点。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.