倒数比倒数快吗?

Posted

技术标签:

【中文标题】倒数比倒数快吗?【英文标题】:Is it faster to count down than it is to count up? 【发布时间】:2011-02-18 20:45:27 【问题描述】:

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

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

优于:

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

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

【问题讨论】:

哪位计算机科学家?在什么刊物上? 可以想象,每次迭代可以节省一纳秒,或者大约相当于猛犸象家族的一根头发。 putchar 使用了 99.9999% 的时间(给予或接受)。 过早的优化是万恶之源。使用您认为合适的任何形式,因为(如您所知)它们在逻辑上是等价的。编程中最困难的部分是将程序的理论传达给其他程序员(和你自己!)。使用一个让你或其他程序员看到它超过一秒钟的结构是一种净损失。你永远不会收回任何人花在思考“为什么要倒计时?”的时间 第一个循环显然更慢,因为它调用了 11 次 putchar,而第二个循环只调用了 10 次。 你注意到如果i 是无符号的,那么第一个循环是无限循环的吗? 【参考方案1】:

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

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

【讨论】:

那不是零标志而不是进位标志吗? @Bob 在这种情况下,您可能想要达到零,打印结果,进一步递减,然后发现您已经低于零,导致进位(或借位)。但写法略有不同,递减循环可能会使用零标志。 只是为了完全迂腐,并不是所有的现代硬件都是流水线的。嵌入式处理器将与这种微优化更相关。 @Paul 因为我有一些使用 Atmel AVR 的经验,所以我没有忘记提及微控制器......【参考方案2】:

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

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

   ...

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

【讨论】:

为了清楚高效,您至少应该养成for(int i=0, siz=myCollection.size(); i&lt;siz; i++)的习惯。【参考方案3】:

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

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

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


当然,可以通过在循环外调用size() 来实现类似的行为,正如彼得所说:

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

【讨论】:

这不是“绝对更快”。在许多情况下, size() 调用可能会在向上计数时被提升到循环之外,因此它仍然只会被调用一次。显然,这取决于语言和编译器(也取决于代码;例如,在 C++ 中,如果 size() 是虚拟的,它就不会被提升),但无论哪种方式都远未确定。 @Peter:只有当编译器确定 size() 在循环中是幂等的时。这可能几乎总是不是的情况,除非循环非常简单。 @LawrenceDol,除非您使用exec 进行动态代码编译,否则编译器肯定会知道。【参考方案4】:

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

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

【讨论】:

以前从未见过有人抱怨过这一点。但是在阅读了链接之后,它实际上是有道理的 :) 谢谢。 呃,他为什么要一直使用前缀形式呢?如果没有分配,它们是相同的,你链接到的文章甚至说后缀形式更常见。 为什么总是使用前缀形式?在这种情况下,它在语义上是相同的。 后缀形式可能会创建一个不必要的对象副本,尽管如果该值从未被使用,编译器可能会将其优化为前缀形式。 出于习惯,我总是使用 --i 和 i++,因为当我学习 C 计算机时,通常有一个寄存器前减和后增,反之则不然。因此,*p++ 和 *--p 比 *++p 和 *p-- 更快,因为前两者可以在一条 68000 机器码指令中完成。【参考方案5】:

是的..!!

就硬件如何处理比较而言,从 N 向下计数到 0 比从 0 计数到 N 稍微快一些。

注意每个循环中的比较

i>=0
i<N

大多数处理器都与零指令进行比较..所以第一个将被翻译为机器代码:

    加载 i 如果小于或等于零则比较并跳转

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

    加载我 加载 N Sub i 和 N 如果小于或等于零则比较并跳转

所以这不是因为倒计时或倒计时..而是因为您的代码将如何被翻译成机器代码..

所以从 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?

【讨论】:

所以你的意思是倒计时并不快,只是与零比较比任何其他值都快。从 10 数到 100 和从 100 倒数到 10 的意思是一样的吗? 是的..这不是“倒数或倒数”的问题..而是“与什么比较”的问题.. 虽然这是汇编程序级别的。有两件事结合起来在现实中变得不真实——使用长管道和推测指令的现代硬件将潜入“Sub i 和 N”而不产生额外的循环——而且——即使是最粗糙的编译器也会优化“Sub i 和N”不存在。 @nico 不一定是一个古老的系统。它只需要一个指令集,其中有一个与零比较的操作,这在某种程度上比等效的比较寄存器值更快/更好。 x86 在 jcxz 中有它。 x64 还是有的。不古。此外,RISC 架构通常是特殊情况零。例如,DEC AXP Alpha 芯片(在 MIPS 系列中)有一个“零寄存器”——读为零,写什么也不做。与零寄存器进行比较,而不是与包含零值的通用寄存器进行比较,可减少指令间依赖性并有助于乱序执行。 @Betamoo:我经常想知道为什么没有更好/更正确的答案(这是你的)没有更多的投票得到更多的赞赏,并得出结论,*** 投票经常受到声誉的影响(在一个人的回答(这是非常非常糟糕的)而不是答案的正确性【参考方案6】:

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

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

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

【讨论】:

几年前我已经看到微软的 C++ 编译器进行了优化。它能够看到循环索引没有被使用,所以它把它重新排列成最快的形式。 @Mark:Delphi 编译器也是如此,从 1996 年开始。 @MarkRansom 实际上,即使使用了循环索引变量,编译器也可以使用倒计时来实现循环,这取决于它在循环中的使用方式。如果循环索引变量仅用于索引静态数组(编译时已知大小的数组),则数组索引可以通过 ptr + 数组大小 - 循环索引 var 完成,在 x86 中仍然可以是一条指令。调试汇编程序并看到循环倒计时但数组索引上升,这真是太疯狂了! 实际上,今天你的编译器可能不会使用循环和 jecxz 指令,因为它们比 dec / jnz 对慢。 @FUZxxl 更有理由不以奇怪的方式编写循环。编写人类可读的清晰代码,让编译器完成它的工作。【参考方案7】:

在一些较旧的 CPU 上,有/曾经有类似 DJNZ == “如果不为零则递减并跳转”的指令。这允许高效循环,您将初始计数值加载到寄存器中,然后您可以使用一条指令有效地管理递减循环。不过,我们在这里谈论的是 1980 年代的 ISA——如果他认为这种“经验法则”仍然适用于现代 CPU,那么你的老师就严重脱节了。

【讨论】:

【参考方案8】:

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

在远古时代,当计算机仍然是手工从熔融石英中切割出来的时候,当 8 位微控制器在地球上漫游时,当你的老师年轻(或者你老师的老师年轻)时,有一种常见的机器指令叫做如果为零则减少并跳过 (DSZ)。 Hotshot 汇编程序员使用此指令来实现循环。后来的机器得到了更高级的指令,但仍有不少处理器在它们上与零比较比与其他任何东西比较便宜。 (即使在一些现代 RISC 机器上也是如此,比如 PPC 或 SPARC,它们将整个寄存器保留为零。)

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

您可以保存一个寄存器 您可能会得到一个具有较小二进制编码的比较指令 如果前一条指令碰巧设置了标志(可能仅在 x86 系列机器上),您甚至可能不需要显式比较指令

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

总结:我打了你老师的脑袋!你不应该学习关于如何组织循环的过时的伪事实。您应该了解循环最重要的一点是确保它们终止、产生正确答案并且易于阅读 em>。我希望你的老师专注于重要的东西而不是神话。

【讨论】:

++ 此外,putchar 比循环开销要长许多数量级。 严格来说并不是神话:如果他正在做某种超级优化的实时系统,它会派上用场的。但这类黑客可能已经知道这一切,而且肯定不会将入门级 CS 学生与神秘学混淆。 @Joshua:通过什么方式可以检测到这种优化?正如提问者所说,循环索引本身并不用于循环,因此只要迭代次数相同,行为就不会发生变化。就正确性的证明而言,将变量替换为j=N-i 表明两个循环是等价的。 摘要 +1。不要出汗,因为在现代硬件上它几乎没有什么区别。 20 年前也几乎没有什么不同。如果您认为必须在意,请双向计算时间,看不出明显的区别,然后重新清晰正确地编写代码 我不知道我应该对正文投赞成票还是对摘要投反对票。【参考方案9】:

倒数比倒数快吗?

也许吧。但远远超过 99% 的情况下它并不重要,所以你应该使用最“明智”的测试来终止循环,而明智的意思是读者只需最少的思考就能弄清楚循环在做什么(包括什么让它停止)。使您的代码与代码正在执行的心理(或记录的)模型相匹配。

如果循环通过数组(或列表,或其他)向上运行,递增计数器通常会更好地匹配读者可能对循环在做什么的想法 - 以这种方式编写循环。

但是,如果您正在处理一个包含 N 项目的容器,并且正在移除这些项目,那么倒计时可能更具有认知意义。

关于答案中“可能”的更多细节:

确实,在大多数架构上,测试导致零(或从零变为负)的计算不需要明确的测试指令 - 可以直接检查结果。如果你想测试一个计算是否会产生其他数字,指令流通常必须有一个明确的指令来测试那个值。但是,特别是对于现代 CPU,此测试通常会为循环构造增加低于噪声级别的额外时间。特别是如果该循环正在执行 I/O。

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

所以我往往会忘记教授的建议(当然,不是在他的测试中——就课堂而言,你仍然应该务实),除非并且直到代码的性能真的很重要。

【讨论】:

【参考方案10】:

鲍勃,

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

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

for (i=N; 
 i>=0;             //thing 1
 i--)             //thing 2

  putchar('*');   //thing 3

比较

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

调整

在某些情况下,向上或向下调整会更快。通常,good 编译器会弄清楚它并在可能的情况下重做循环。但并非所有编译器都很好。

循环体

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

缓存

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

【讨论】:

【参考方案11】:

在 C 中为伪程序集:

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 条指令? 另外,为什么很难确定呢?只要 var i 没有在循环中使用,显然你可以翻转它不是吗?【参考方案12】:

现在,我想你已经听够了组装讲座了:) 我想向你介绍另一个使用自上而下方法的原因。

从上到下的原因很简单。在循环体中,您可能会意外更改边界,这可能会导致错误行为甚至非终止循环。

看看这小部分 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 &lt; 999; i++) @Software Monkey 想象 n 是一些计算的结果......例如你可能想遍历某个集合,它的大小是边界,但作为一些副作用,你在循环体中向集合添加新元素。 如果这就是您想要传达的内容,那么这就是您的示例应该说明的内容:for(int xa=0; xa&lt;collection.size(); xa++) collection.add(SomeObject); ... @Software Monkey 我想更笼统地谈一谈集合,因为我的推理与集合无关 是的,但是如果你要通过例子来推理,你的例子需要是可信的并且能说明问题。【参考方案13】:

重点是倒计时时不需要单独检查i &gt;= 0 来减少i。观察:

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

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

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

至于它是否对您的应用程序产生有意义的影响,我想这取决于您有多少循环以及它们的嵌套程度。但对我来说,这样做也一样易读,所以我还是这样做了。

【讨论】:

我认为这是一种糟糕的风格,因为它取决于读者知道 i-- 的返回值是 i 的旧值,以获取保存循环的可能值。只有当有很多循环迭代时,这才有意义,并且循环是迭代长度的很大一部分,并且实际上出现在运行时。接下来,有人会尝试 for (i=5; --i;) 因为他们听说在 C++ 中,当 i 是一个非平凡的类型时,您可能希望避免创建一些临时的,而现在您处于错误领域无情地放弃了让错误代码看起来错误的机会。【参考方案14】:

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

根据此***页面:Leap second,“...由于潮汐摩擦,太阳日每个世纪都会增加 1.7 毫秒。”但是,如果您计算生日之前的天数,您真的在乎这个微小的时间差异吗?

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

我敢打赌,大多数程序员读过 (i = 0; i 0; i--) 我必须考虑一下.最好是代码的意图直接进入大脑而不需要任何思考。

【讨论】:

这两种结构都非常容易理解。有些人声称,如果你有 3 或 4 次重复,最好复制指令而不是循环,因为这样更容易理解。【参考方案15】:

奇怪的是,似乎存在差异。至少,在 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 编译器足够聪明,可以进行这种简单的优化。 为什么 $i-- 比 $i++ 快? 您的基准测试运行了多少次迭代?你剪掉了外来者并为每个结果取平均值吗?在基准测试期间,您的计算机是否在执行其他任何操作?大约 0.5 的差异可能只是其他 CPU 活动或管道利用率的结果,或者......或者......好吧,你明白了。 是的,我在这里给出平均值。 Benchmark 是在不同的机器上运行的,差异是偶然的。 @Conspicuous Compiler => 你知道吗?【参考方案16】:

可以更快。

在我目前使用的 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 说的那样,只有当你倒数到零时。

【讨论】:

【参考方案17】:

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

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

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

【讨论】:

【参考方案18】:

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

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

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 total0;
  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 total0;
  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+%)。

根据经验,要点是,如果您可以选择,如果循环的主体很小,并且如果让您的循环增加内存而不是减少内存差别不大,那么您应该增加内存。

仅供参考 vec_original 用于实验,以便轻松更改 sum_abs_upsum_abs_down 以使它们更改 vec 同时不允许这些更改影响未来的时间安排。我强烈建议使用sum_abs_upsum_abs_down 并为结果计时。

【讨论】:

你能推荐任何关于内存访问方向及其性能的文献/博客/视频吗?【参考方案19】:

您的老师所说的只是一些不明确的陈述。 并不是说递减比递增快,但是你可以创建比递增快得多的循环。

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

以下是大多数人如何通过 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 次(是的,我知道 i 值与显示的 for 循环相比是不同的,但我们在这里关心迭代次数)。 这可以编译成这样:

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..while 等价于恒定循环迭代...(或展开它)...

【讨论】:

以上是关于倒数比倒数快吗?的主要内容,如果未能解决你的问题,请参考以下文章

速求平方根倒数

for循环倒数到0会更快吗?

[LeetCode] 19. 删除链表的倒数第N个节点 ☆☆☆

LeetCode Algorithm 剑指 Offer 22. 链表中倒数第k个节点

LeetCode Algorithm 剑指 Offer 22. 链表中倒数第k个节点

倒数第几个(本质上是将倒数 转化成(两个点之间)具体的距离)