カウントアップよりカウントダウンの方が速いですか?


131

私たちのコンピューターサイエンスの教師はかつて、何らかの理由で、カウントアップよりカウントダウンの方が効率的であると言っていました。たとえば、FORループを使用する必要があり、ループインデックスがどこかで使用されていない場合(N *の行を画面に出力するなど)、次のようなコードを意味します。

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

よりも良い:

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

本当ですか?もしそうなら、なぜ誰かが知っていますか?


6
どのコンピューター科学者?どの出版物に?
bmargulies

26
反復ごとに1ナノ秒、または毛むくじゃらのマンモスの家族の1本の髪と同じくらい節約できると考えられます。putchar時間(与えるか、またはテイク)の99.9999%を使用しています。
Mike Dunlavey、2010年

38
時期尚早の最適化は、すべての悪の根源です。(すでにご存じのように)それらは論理的に同等であるため、どちらが正しいと思われる形式を使用します。プログラミングの最も難しい部分は、プログラムの理論を他のプログラマー(そしてあなた自身!)に伝えることです。あなたまたは他のプログラマーが1秒以上それを見るようにする構造を使用することは、純損失です。誰が「なぜこれがカウントダウンするのか」と考えるのに費やす時間を取り戻すことは決してないでしょう。
David M

61
最初のループはputcharを11回呼び出すため、明らかに遅くなりますが、2番目のループでは10回しか呼び出されません。
ポールクリニエビッツ

17
iが署名されていない場合、最初のループは無限ループであることに気づきましたか?
Shahbaz

回答:


371

本当ですか?もしそうなら誰もが理由を知っていますか?

昔、コンピューターがまだ溶融石英からチップで削られていたとき、8ビットマイクロコントローラーが地球を歩き回っていたとき、そして先生が若かった(または先生の先生が若かった)とき、減分とスキップという一般的な機械命令がありました。ゼロの場合(DSZ)。Hotshotアセンブリプログラマーは、この命令を使用してループを実装しました。後のマシンにはより洗練された命令がありましたが、他のものと比較するよりもゼロと比較する方が安価なプロセッサがまだかなりありました。(PPCやSPARCなど、レジスタ全体を常にゼロに予約する一部の最新のRISCマシンでも同じです。)

したがって、ループをリグしての代わりにゼロと比較するとN、どうなるでしょうか?

  • あなたはレジスターを保存するかもしれません
  • あなたはより小さなバイナリエンコーディングでcompare命令を得るかもしれません
  • 前の命令がたまたまフラグを設定した場合(おそらくx86ファミリマシンのみ)、明示的な比較命令すら必要ない場合もあります。

これらの違いは、現代のアウトオブオーダープロセッサ上の実際のプログラム測定可能な改善をもたらす可能性がありますか?ありそうもない。実際、マイクロベンチマークでも測定可能な改善を示すことができれば感心します。

概要:先生の頭を逆さまにします。 ループを整理する方法について、古い陳腐な事実を学ぶべきではありません。あなたはそれを学習しなければならないループに関する最も重要なことは、彼らがいることを確認するためにある終了し、生産正しい答えを、とされている読みやすいです あなたの先生が神話ではなく重要なことに集中してくれることを望みます。


3
++ putcharさらに、ループオーバーヘッドよりも何桁も長くかかります。
Mike Dunlavey、

41
それは厳密には神話ではありません。彼が何らかの超最適化されたリアルタイムシステムを実行している場合、それは便利です。しかし、その種のハッカーはおそらくすでにこれらすべてを知っており、エントリーレベルのCSの学生をアルカナと混同しないでしょう。
ポールネイサン

4
@ジョシュア:この最適化はどのようにして検出できるでしょうか?質問者が言ったように、ループインデックスはループ自体では使用されないため、反復回数が同じであれば、動作に変化はありません。正当性の証明に関して、変数置換j=N-iを行うと、2つのループが同等であることを示します。
psmears

7
サマリーの+1。最近のハードウェアではほとんど違いがないので、気にしないでください。20年前もほとんど違いはありませんでした。気にする必要があると思われる場合は、両方の方法で時間を計測し、明確な違いがないことを確認してから、コードを明確かつ正確に記述してください
ドナルフェロー

3
本文に賛成投票するのか、要約に反対投票するのかわかりません。
ダヌビアンセーラー2013

29

使用している数値の範囲についてコンパイラが何を推定できるかに応じて、一部のハードウェアで発生する可能性があるのは次のとおりです。増分ループi<Nでは、ループのたびにテストする必要があります。デクリメントバージョンの場合、キャリーフラグ(減算の副作用として設定)が自動的に通知する場合がありi>=0ます。これにより、ループ全体の時間あたりのテストが節約されます。

実際には、最新のパイプラインプロセッサハードウェアでは、命令からクロックサイクルへの単純な1対1のマッピングがないため、これはほぼ確実に無関係です。(マイクロコントローラーから正確なタイミングのビデオ信号を生成するようなことをしている場合は、それが近づくと想像できますが、とにかくアセンブリ言語で記述します。)


2
それはゼロフラグであり、キャリーフラグではないでしょうか?
ボブ

2
@Bobこの場合、ゼロに到達し、結果を出力し、さらにデクリメントし、ゼロより1小さいとキャリー(または借用)が発生することがわかります。しかし、少し異なる方法で記述されたデクリメントループは、代わりにゼロフラグを使用する場合があります。
sigfpe

1
完全に理解しやすくするために、すべての最新のハードウェアがパイプライン化されているわけではありません。組み込みプロセッサは、この種のマイクロ最適化との関連性がはるかに高くなります。
ポールネイサン

@Paul Atmel AVRの経験があるので、マイクロコントローラーについて言及するのを忘れていませんでした...
sigfpe

27

Intel x86命令セットでは、ゼロまでカウントダウンするループの構築は、通常、ゼロ以外の終了条件までカウントするループよりも少ない命令で実行できます。具体的には、ECXレジスタは伝統的にx86 asmのループカウンターとして使用され、Intelの命令セットには、ECXレジスタのゼロをテストし、テストの結果に基づいてジャンプする特別なjcxzジャンプ命令があります。

ただし、ループがすでにクロックサイクルカウントに非常に敏感でない限り、パフォーマンスの違いは無視できます。ゼロまでカウントダウンすると、カウントアップと比較して、ループの各反復で4または5クロックサイクルが削られる可能性があるため、実際には、便利な手法というよりも斬新なものです。

また、最近の優れた最適化コンパイラーは、カウントアップループソースコードをゼロマシンコードへのカウントダウンに変換できるはずです(ループインデックス変数の使用方法によって異なります)。ループを書き込む理由は実際にはありません。あちこちで1つか2つのサイクルを絞る奇妙な方法。


2
数年前からMicrosoftのC ++コンパイラがその最適化を行っているのを見てきました。ループインデックスが使用されていないことがわかるため、最高速のフォームに再配置します。
Mark Ransom

1
@マーク:Delphiコンパイラだけでなく、1996年に開始
dthorpe

4
@MarkRansom実際には、ループインデックス変数が使用されている場合でも、ループインデックス変数が使用されている場合でも、コンパイラーはカウントダウンを使用してループを実装できる場合があります。ループインデックス変数が静的配列(コンパイル時に既知のサイズの配列)にインデックスを付けるためだけに使用される場合、配列のインデックス付けは、ptr +配列サイズ-ループインデックスvarとして行うことができます。アセンブラをデバッグしてループがカウントダウンするのを見ることはかなりワイルドですが、配列のインデックスは上がります!
dthorpe

1
実際、今日、コンパイラはループとjecxz命令を使用しないでしょう。これらはdec / jnzペアよりも遅いためです。
fuz 2013

1
@FUZxxlループを奇妙な方法で記述しない理由はなおさらです。人間が読み取れる明確なコードを記述し、コンパイラーにその仕事を任せます。
dthorpe 2013

23

はい..!!

ハードウェアが比較を処理する方法という意味で、Nから0までのカウントは、0からNまでのカウントよりもわずかに高速です。

各ループの比較に注意してください

i>=0
i<N

ほとんどのプロセッサはゼロ命令と比較しています。したがって、最初のプロセッサは次のようにマシンコードに変換されます。

  1. 負荷i
  2. ゼロ以下の場合は比較してジャンプ

しかし、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ループは同等ではないことに注意してください。最初のループはもう1つ出力します* ....

関連: なぜn ++はn = n + 1よりも速く実行するのですか?


6
つまり、カウントダウンが速くなく、他のどの値よりもゼロと比較する方が速いということです。10から100に数えることと100から10に数えることは同じ意味でしょうか?
ボブは

8
はい..それは「ダウンカウントまたはアップ」の問題ではありません..しかし、それは「何と比較」の問題です
Betamoo

3
これは真実ですが、アセンブラーレベルです。2つの要素が組み合わさって、実際には正しくありません-長いパイプと投機的な命令を使用する最新のハードウェアは、余分なサイクルを発生させることなく "Sub iとN"に忍び込みます-そして-最も粗いコンパイラでさえ "Sub iとN "は存在しません。
ジェームズアンダーソン、

2
@nico古代のシステムである必要はありません。これは、ゼロとの比較操作が存在する命令セットである必要があります。ゼロ操作は、同等のレジスター比較値よりも多少高速/優れています。x86はjcxzにそれを持っています。x64にはまだあります。古くない。また、RISCアーキテクチャは、多くの場合、特殊なケースのゼロです。たとえば、DEC AXP Alphaチップ(MIPSファミリの場合)には「ゼロレジスタ」があり、0として読み取られ、何も書き込まれません。ゼロ値を含む汎用レジスターではなくゼロレジスターと比較すると、命令間の依存関係が減少し、順序どおりに実行されません。
dthorpe 2012年

5
@Betamoo:私はしばしばより良い/より正確な回答(あなたのものです)がより多くの投票によってより高く評価されない理由を不思議に思っています。これは非常に悪いです)と回答の正しさではありません
Artur

12

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

2番目の疑似アセンブリでの比較の欠如に注意してください。多くのアーキテクチャでは、ジャンプに使用できる算術演算(加算、減算、乗算、除算、増分、減分)によって設定されるフラグがあります。これらは多くの場合、基本的には操作の結果を無料で0と比較したものを提供します。実際、多くのアーキテクチャで

x = x - 0

意味的に同じです

compare x, 0

また、私の例では10と比較すると、コードの品質が低下する可能性があります。10はレジスター内に存在する必要がある場合があるため、不足するとコストがかかり、ループを介して毎回10を移動したり、10を再ロードしたりする追加のコードが発生する可能性があります。

コンパイラーはこれを利用するためにコードを再配置できる場合がありますが、ループを介した方向の逆転が意味的に同等であることを確認できないことが多いため、難しい場合がよくあります。


1つだけではなく2つの命令の差分がある可能性はありますか?
パセリエ2017

また、なぜそれを確認するのが難しいのですか?var iがループ内で使用されていない限り、裏返すことができますね。
パセリエ2017

6

このような場合は、より早くカウントダウンしてください:

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

someObject.getAllObjects.size()最初に1回実行されるため。


もちろん、size()ピーターが述べたように、ループの外を呼び出すことで同様の動作を実現できます。

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

5
「間違いなく速い」というわけではありません。多くの場合、そのsize()呼び出しは、カウントアップ時にループから外れる可能性があるため、まだ一度しか呼び出されません。明らかに、これは言語とコンパイラに依存します(そしてコードに依存します。たとえば、C ++では、size()が仮想の場合は巻き上げられません)が、どちらにしても明確ではありません。
ピーター

3
@Peter:コンパイラーがsize()がループ全体でべき等であることを確実に知っている場合のみ。ループが非常に単純でない限り、ほとんどの場合そうではありません
ローレンスドル

@LawrenceDol、を使用した動的コードコンパイラがない限り、コンパイラはそれを確実に認識しますexec
パセリエ2017

4

カウントアップするよりもカウントダウンする方が速いですか?

多分。しかし、それは重要ではない時間の99%よりはるかに大きいので、ループを終了するために最も「賢明な」テストを使用する必要がありますループが何をしているか(ループを停止させるものを含む)。コードを、コードが行っていることのメンタル(または文書化)モデルに一致させます。

ループが配列(またはリストなど)を介して上向きに機能している場合、増分カウンターは、多くの場合、読者がループが何をしているかを考えている方法とよりよく一致します-このようにループをコーディングします。

ただし、Nアイテムが含まれているコンテナーを操作していて、アイテムを移動しながら削除している場合は、カウンターを下に向けることがより認知的になるかもしれません。

答えの「たぶん」についてもう少し詳しく:

ほとんどのアーキテクチャでは、結果がゼロになる(またはゼロから負になる)計算のテストに明示的なテスト命令は必要ありません。結果を直接チェックできます。計算の結果が他の数値になるかどうかをテストする場合、通常、命令ストリームにはその値をテストするための明示的な命令が必要です。ただし、特に最近のCPUでは、このテストは通常​​、ループ構成にノイズレベルよりも少ない追加時間を追加します。特に、そのループがI / Oを実行している場合。

一方、ゼロからカウントダウンして、配列のインデックスとしてカウンターを使用すると、たとえば、システムのメモリアーキテクチャに対してコードが機能する場合があります。メモリの読み取りにより、キャッシュが「先読み」されることがよくあります。順次読み取りを見越して現在の場所を過ぎたいくつかのメモリ位置。メモリを逆方向に使用している場合、キャッシングシステムは、より低いメモリアドレスでのメモリ位置の読み取りを予期しない場合があります。この場合、「逆方向」にループするとパフォーマンスが低下する可能性があります。ただし、正確性が最重要であり、コードをモデルに一致させることは正確性を保証するのに役立つ優れた方法であるため、おそらくループをこのようにコーディングします(パフォーマンスが問題にならない限り)。不正なコードは、可能な限り最適化されていません。

そのため、コードのパフォーマンスが本当に重要でない限り、教授のアドバイスを忘れる傾向があります(もちろん、彼のテストではありません-教室までは実際的である必要があります)。


3

一部の古いCPUには、DJNZ==「デクリメントし、ゼロでない場合はジャンプする」などの命令がありました。これにより、初期カウント値をレジスタにロードし、1つの命令でデクリメントループを効果的に管理できる効率的なループが可能になりました。ただし、ここでは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_upを容易にsum_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)  

しかし、私は現代のコンパイラがこれらの最適化を正確に実行できることを期待します。


これまでに人が不満を言うのを見たことがない。しかし、リンクを読んだ後、それは実際に理にかなっています:)ありがとう。
Tommy Jakobsen、

3
ええと、なぜ彼は常にプレフィックス形式を使用する必要があるのですか?割り当てが行われていない場合、それらは同一であり、リンク先の記事では、postfixフォームがより一般的であるとさえ述べています。
bobDevil 2010年

3
なぜ常にプレフィックス形式を使用する必要があるのですか?この場合、意味的には同じです。
ベンゾット2010年

2
postfixフォームはオブジェクトの不要なコピーを作成する可能性がありますが、値が使用されない場合、コンパイラはおそらくそれをprefixフォームに最適化します。
Nick Lewis、

習慣の力から、私は常に--iとi ++を実行します。なぜなら、Cコンピューターは通常、レジスターの事前減少と事後増加を持っているが、その逆はないためです。したがって、* p ++と*-pは* ++ pと* p--よりも高速でした。前者の2つは1つの68000マシンコード命令で実行できるためです。
JeremyP 2010年

2

それは興味深い質問ですが、実際問題として、私はそれが重要だとは考えておらず、1つのループを他のループよりも良くすることはしません。

このウィキペディアのページによれば、うるう秒、「...主に潮汐摩擦により、太陽の日は毎世紀1.7ミリ秒長くなります。」しかし、誕生日までの日数を数えている場合、この時間のわずかな違いを本当に気にしますか?

ソースコードが読みやすく、理解しやすいことがより重要です。これらの2つのループは、読みやすさが重要な理由の良い例です。ループが同じ回数行われないためです。

ほとんどのプログラマーは(i = 0; i <N; i ++)を読んで、これがN回ループすることをすぐに理解するでしょう。(i = 1; i <= N; i ++)のループは、とにかく私には少し不明確ですが、(i = N; i> 0; i--)の場合は、少し考えなければなりません。コードの意図が何も考えずに直接脳に入るのが最善です。


どちらの構成要素も、まったく同じように簡単に理解できます。繰り返しが3つか4つある場合は、ループを作成するよりも命令をコピーする方が理解しやすいため、ループを作成するほうがよいと主張する人もいます。
ダヌビアンセーラー

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 ++を考えます。3つのことが起こります。$ iが一時変数に格納され、$ iがインクリメントされ、$ fooにその一時変数の値が割り当てられます。$ i ++の場合; スマートコンパイラは、一時が不要であることを認識できます。PHPはそうではありません。C ++およびJavaコンパイラーは、この単純な最適化を行うのに十分スマートです。
目立つコンパイラ

そしてなぜ$ i--は$ i ++より速いのですか?
ts。

ベンチマークを何回繰り返しましたか?あなたは部外者を切り取って、それぞれの結果の平均を取りましたか?ベンチマーク中にコンピューターが何か他のことをしていましたか?その〜0.5の違いは、他のCPUアクティビティ、パイプラインの利用、または...または...の結果である可能性があります。
Eight-Bit Guru

はい、ここで平均を出しています。ベンチマークは別のマシンで実行され、違いは偶然です。
ts。

@Conspicuous Compiler =>ご存知ですか、それともお考えですか?
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に加えて、CPUティックが本当に重要なタイムクリティカルなソフトウェア(通常は組み込み、OS、ゲームなど)の全世界が存在するため、以下のアセンブリコードを簡単に見てください。

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バイト)です。その中に-実際には2つの分岐を持つ6つの命令(17バイト)があります。はいはい私はそれがより良くできることを知っています(それは単なる例です)。

次に、組み込み開発者が頻繁に作成するこの頻繁な構成を考えます。

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バイト)と1つの分岐のみ。実際には、ループ内に4つの命令があります(11バイト)。

最良のことは、一部のCPU(x86 / x64互換を含む)に、レジスタをデクリメントし、後で結果をゼロと比較し、結果がゼロと異なる場合に分岐を実行する命令があることです。事実上すべてのPC CPUがこの命令を実装しています。これを使用すると、ループは実際には1つ(はい1)の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。はい、私は(適切な最適化レベルの)賢明なコンパイラーがループ(昇順のループカウンター付き)をdo..whileに定数ループの繰り返しと同等(または展開)に書き換えることを知っています...


1

いいえ、それは本当ではありません。より高速になる可能性がある状況の1つは、ループのすべての反復中に境界をチェックする関数を別の方法で呼び出す場合です。

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

しかし、そのようにするのが不明確な場合、それは価値がありません。現代の言語では、とにかく、可能な場合はforeachループを使用する必要があります。インデックスが不要な場合に、foreachループを使用する必要がある場合について具体的に説明しました。


1
明確効率的であるためには、少なくともの習慣を身に付けるべきfor(int i=0, siz=myCollection.size(); i<siz; i++)です。
Lawrence Dol、

1

ポイントは、カウントダウンするときにi >= 0、デクリメントするために個別にチェックする必要がないことですi。観察する:

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

i1つの式で比較と減分の両方を実行できます。

これにより、x86命令が少なくなる理由については、他の回答を参照してください。

それがあなたのアプリケーションに意味のある違いをもたらすかどうかに関しては、私はそれがあなたが持っているループの数とそれらがどれだけ深くネストされているかに依存すると思います。しかし、私にとっては、この方法で行うのと同じくらい読みやすいので、とにかくそれを行います。


これは悪いスタイルだと思います。なぜなら、サイクルを保存することで考えられる値として、iの戻り値がiの古い値であることを読者が知っているからです。これは、ループの反復が多数あり、サイクルが反復の長さのかなりの部分であり、実際に実行時に現れた場合にのみ重要です。次に、誰かが(i = 5; --i;)を試してみます。C++では、iが自明ではない型であるときに一時的なものを作成することを避けた方がよいと聞いたため、間違ったコードを間違って見えるようにする機会を捨てました。
マブラハム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);
    }

だから私のポイントは、上から下に行くことを好むか、境界として定数を持つことを検討する必要があるということです。


えっ?あなたが失敗した例は本当に直感に反しています。つまり、ストローマンの議論です-誰もこれを書いたことはないでしょう。1つ書くだろうfor (int i=0; i < 999; i++) {
ローレンスドル

@Software Monkeyは、nが何らかの計算の結果であると想像します...たとえば、あるコレクションを反復処理し、そのサイズを境界とする場合がありますが、副作用として、ループ本体のコレクションに新しい要素を追加します。
GabrielŠčerbák10年

それがあなたが伝えようとしていたことなら、それはあなたの例が説明すべきことです:for(int xa=0; xa<collection.size(); xa++) { collection.add(SomeObject); ... }
ローレンス・ドル

@Software Monkey特にコレクションについて話すだけではなく、もっと一般的になりたかったのです。なぜなら、私が考えていることは、コレクションとは何の関係もないからです
GabrielŠčerbák10年

2
はい。ただし、例を使用して推論する場合は、例が信頼でき、要点を説明する必要があります。
Lawrence Dol、

-1

アセンブラーレベルでは、ゼロまでカウントダウンするループは、通常、指定された値までカウントアップするループよりもわずかに高速です。計算結果がゼロに等しい場合、ほとんどのプロセッサはゼロフラグを設定します。1を減算すると、計算がゼロを超えてラップする場合、これにより通常キャリーフラグが変更されます(一部のプロセッサでは他のフラグが設定され、クリアされます)。したがって、ゼロとの比較は基本的に無料です。

これは、反復回数が定数ではなく変数である場合にさらに当てはまります。

些細なケースでは、コンパイラーはループのカウント方向を自動的に最適化できますが、より複雑なケースでは、プログラマーはループの方向が全体的な動作に無関係であることを知っているかもしれませんが、コンパイラーはそれを証明できません。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.