倒计时比倒数更快吗?

Bob*_*Bob 130 c performance loops

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

for (i = N; i >= 0; i--)  
  putchar('*');  
Run Code Online (Sandbox Code Playgroud)

比以下更好:

for (i = 0; i < N; i++)  
  putchar('*');  
Run Code Online (Sandbox Code Playgroud)

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

Nor*_*sey 369

这是真的吗?若有,有谁知道为什么?

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

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

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

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

简介:我把你的老师抬起头来! 你不应该学习关于如何组织循环的过时的伪事实.你应该知道循环最重要的事情是确保它们终止,产生正确的答案,并且易于阅读. 我希望你的老师专注于重要的东西,而不是神话.

  • 这不是严格的神话:如果他正在做某种优化的实时系统,它会派上用场.但是那种黑客可能已经知道所有这些,当然也不会让入门级的CS学生混淆奥秘. (41认同)
  • 摘要+1.不要冒汗,因为在现代硬件上几乎没有任何区别.它在20年前几乎没有任何差别.如果你认为你必须要关心,那么两种方式都要加时间,看看没有明显的区别,并且**回去清楚正确地编写代码**. (7认同)
  • @Joshua:这种优化会以什么方式被发现?正如提问者所说,循环索引不在循环本身中使用,因此如果迭代次数相同,则行为没有变化.在证明正确性方面,使变量替换"j = Ni"表明两个循环是等价的. (4认同)
  • ++此外,`putchar`比循环开销要长许多个数量级. (3认同)
  • 我不知道我是应该为身体投票还是为了总结而投票. (3认同)

sig*_*fpe 29

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

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

  • 那不是零旗而不是进旗吗? (2认同)
  • @Bob在这种情况下,您可能希望达到零,打印结果,进一步递减,然后发现您已经低于零而导致进位(或借入).但是稍微不同的是,递减循环可能会使用零标志. (2认同)

dth*_*rpe 27

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

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

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

  • @MarkRansom实际上,即使使用循环索引变量,编译器也可以使用倒计时实现循环,具体取决于它在循环中的使用方式.如果循环索引变量仅用于索引到静态数组(编译时已知大小的数组),则数组索引可以作为ptr +数组大小完成 - 循环索引var,它仍然可以是x86中的单个指令.调试汇编程序并看到循环倒计时但数组索引上升是非常疯狂的! (4认同)
  • 我已经看到几年前微软的C++编译器进行了优化.它能够看到循环索引未被使用,因此它将其重新排列为最快的形式. (2认同)
  • 实际上,今天您的编译器可能不会使用循环和 jecxz 指令,因为它们比 dec / jnz 对慢。 (2认同)
  • @FUZxxl更有理由不以奇怪的方式编写循环。编写人类可读的清晰代码并让编译器完成其工作。 (2认同)

Bet*_*moo 23

是..!!

从N到0的计数稍微快一点,从硬件处理比较的意义上从0到N计数.

注意每个循环中的比较

i>=0
i<N
Run Code Online (Sandbox Code Playgroud)

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

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

但第二个需要每次加载N形式的内存

  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快?

  • 是的..这不是"倒计时或倒计时"的问题..但这是"与什么相比"的问题. (7认同)
  • 所以你所说的是倒计时并不快,与零比任何其他值相比,它更快.从10到100计数,从100到10倒计时是一样的吗? (6认同)
  • @Betamoo:我经常想知道为什么没有更好/更正确的答案(这是你的)不会更多的投票赞赏并得出结论,堆栈流量投票往往受到回答的人的声誉(点数)的影响(这是非常非常糟糕的)而不是答案的正确性 (4认同)
  • 虽然这对汇编程序来说是正确的.两件事情结合在一起实际上是不真实的 - 使用长管道和推测性指令的现代硬件将潜入"Sub i和N"而不会产生额外的周期 - 甚至最粗糙的编译器将优化"Sub i和N"不存在. (3认同)
  • @nico不一定是一个古老的系统.它只需要是一个指令集,其中存在与零操作的比较,这在某种程度上比与寄存器值的等效比较更快/更好.x86在jcxz中有它.x64还有它.不古老.此外,RISC架构通常是特殊情况零.例如,DEC AXP Alpha芯片(在MIPS系列中)有一个"零寄存器" - 读为零,写不做任何事情.与零寄存器进行比较而不是与包含零值的通用寄存器相比,减少了指令间的依赖性并有助于无序执行. (2认同)

nat*_*ose 12

在C到psudo-assembly中:

for (i = 0; i < 10; i++) {
    foo(i);
}
Run Code Online (Sandbox Code Playgroud)

变成

    clear i
top_of_loop:
    call foo
    increment i
    compare 10, i
    jump_less top_of_loop
Run Code Online (Sandbox Code Playgroud)

而:

for (i = 10; i >= 0; i--) {
    foo(i);
}
Run Code Online (Sandbox Code Playgroud)

变成

    load i, 10
top_of_loop:
    call foo
    decrement i
    jump_not_neg top_of_loop
Run Code Online (Sandbox Code Playgroud)

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

x = x - 0
Run Code Online (Sandbox Code Playgroud)

在语义上是相同的

compare x, 0
Run Code Online (Sandbox Code Playgroud)

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

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


0x2*_*9A3 6

在这种情况下倒数更快:

for (i = someObject.getAllObjects.size(); i >= 0; i--) {…}
Run Code Online (Sandbox Code Playgroud)

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


当然,size()正如Peter提到的那样,可以通过调出循环来实现类似的行为:

size = someObject.getAllObjects.size();
for (i = 0; i < size; i++) {…}
Run Code Online (Sandbox Code Playgroud)

  • 它并非"绝对更快".在许多情况下,在计数时,size()调用可以从循环中提升,因此它仍然只会被调用一次.显然,这是依赖于语言和编译器的(并且依赖于代码;例如,在C++中,如果size()是虚拟的,它将不会被提升),但这两种方式都远非明确. (5认同)
  • @Peter:只有当编译器确实知道size()在循环中是幂等的时候.这可能几乎总是**不是这种情况,除非循环非常简单. (3认同)

Mat*_* K. 5

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

持怀疑态度?只需编写一个程序来对向上/向下内存进行时间循环即可。这是我得到的输出:

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

Average Up Memory   = 18638 mus
Average Down Memory = 19053 mus
Run Code Online (Sandbox Code Playgroud)

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

#include <chrono>
#include <iostream>
#include <random>
#include <vector>
using namespace std;

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

template<class T>
chrono::nanoseconds TimeUp(vector<T> &vec, const vector<T> &vec_original,
                           size_t num_repititions, T &running_sum) {
  chrono::nanoseconds total{0};
  for (size_t i = 0; i < num_repititions; i++) {
    auto start_time = chrono::high_resolution_clock::now();
    sum_abs_up(vec.begin(), vec.end(), running_sum);
    total += 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) {
  random_device rnd_device;
  mt19937 generator(rnd_device());
  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) {
  random_device rnd_device;
  mt19937_64 generator(rnd_device());
  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(size_t num_repititions, size_t vec_size = (1u << 24)) {
  auto lower = numeric_limits<ValueType>::min();
  auto upper = numeric_limits<ValueType>::max();
  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();
  cout << "Average Up Memory   = " << time_up/(num_repititions * 1000) << " mus\n";
  cout << "Average Down Memory = " << time_down/(num_repititions * 1000) << " mus"
       << endl;
  return ;
}

int main() {
  size_t num_repititions = 1 << 10;
  TimeFunctions<int>(num_repititions);
  cout << '\n';
  TimeFunctions<double>(num_repititions);
  return 0;
}
Run Code Online (Sandbox Code Playgroud)

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_up,并sum_abs_down以一种使它们改变的方式vec,同时不允许这些更改影响未来的时间安排。我强烈建议尝试使用sum_abs_upsum_abs_down并对结果进行计时。