私たちのコンピュータサイエンスの先生は、ある理由で、カウントアップするよりもカウントダウンする方が効率的だと言っていました。たとえば、FORループを使用する必要があり、ループインデックスがどこかで使用されていない場合(N *の行を画面に出力するなど)、次のようなコードを意味します。
for (i = N; i >= 0; i--)
putchar('*');
よりも良い:
for (i = 0; i < N; i++)
putchar('*');
本当に本当ですか?もしそうなら、誰かが理由を知っていますか?
本当に本当ですか?もしそうなら誰もが理由を知っていますか?
古代、コンピューターがまだ手作業でフューズドシリカから削り取られていたとき、8ビットマイクロコントローラーが地球を歩き回っていたとき、そしてあなたの教師が若かったとき(または教師の教師が若かったとき)と呼ばれる共通の機械命令がありましたゼロの場合はデクリメントしてスキップ(DSZ)。 Hotshot Assemblyプログラマーは、この命令を使用してループを実装しました。後のマシンはより洗練された命令を取得しましたが、他のものと比較するよりもゼロと何かを比較する方が安価なプロセッサがまだかなりありました。 (PPCまたはSPARC、レジスタ全体が常にゼロになるように予約しているSPARCなど、一部の最新のRISCマシンでも当てはまります。)
ループをリグしてN
の代わりにゼロと比較すると、どうなるのでしょうか?
これらの違いは、測定可能な改善の結果になりそうですか実際のプログラム最新のアウトオブオーダープロセッサですか?ありそうにない。実際、マイクロベンチマークでも測定可能な改善を示すことができれば感銘を受けます。
要約:教師の頭をひっくり返します!ループの整理方法に関する時代遅れの擬似事実を学ぶべきではありません。 ループに関する最も重要なことは、それらがterminateであることを確認することです、正解、そして読みやすい。先生が重要なことに集中してほしい神話ではありません。
使用している数値の範囲についてコンパイラが何を推測できるかに応じて、一部のハードウェアで発生する可能性があるものを次に示します。増分ループでは、ループを回るたびにi<N
をテストする必要があります。デクリメントバージョンの場合、キャリーフラグ(減算の副作用として設定)がi>=0
かどうかを自動的に通知する場合があります。これにより、ループを回るたびにテストが節約されます。
現実には、現代のパイプラインプロセッサハードウェアでは、命令からクロックサイクルへの単純な1-1マッピングがないため、このようなものはほとんど確実に無関係です。 (マイクロコントローラから正確なタイミングのビデオ信号を生成するようなことをしていると想像できますが、とにかくアセンブリ言語で書くことになります。)
Intel x86命令セットでは、ゼロまでカウントダウンするループの構築は、通常、ゼロ以外の終了条件までカウントするループよりも少ない命令で実行できます。具体的には、ECXレジスタは伝統的にx86 asmのループカウンターとして使用され、Intel命令セットには特別なjcxzジャンプ命令があり、ECXレジスタのゼロをテストし、テスト結果に基づいてジャンプします。
ただし、ループが既にクロックサイクルカウントに非常に敏感でない限り、パフォーマンスの違いは無視できます。ゼロまでカウントダウンすることは、カウントアップと比較してループの各反復で4または5クロックサイクルを削る可能性があるため、実際には有用な手法というよりは目新しいものです。
また、最近の最適な最適化コンパイラは、カウントアップループソースコードを(ループインデックス変数の使用方法に応じて)カウントダウンゼロのマシンコードに変換できるため、実際にループを記述する理由はありませんあちこちでサイクルを1つまたは2つ絞る奇妙な方法。
はい..!!
Nから0までのカウントは、ハードウェアが比較を処理するという意味で、0からNまでのカウントよりもわずかに高速です。
各ループのcomparisonに注意してください
i>=0
i<N
ほとんどのプロセッサはゼロ命令と比較されます。したがって、最初のプロセッサは次のようにマシンコードに変換されます。
しかし、2番目は毎回Nフォームメモリをロードする必要があります
したがって、カウントダウンやカウントアップのためではありません。しかし、コードがマシンコードに変換される方法のためです。
したがって、10から100までのカウントは、フォーム100から10までのカウントと同じです。
ただし、i = 100から0までのカウントは、i = 0から100までのカウントよりも高速です-ほとんどの場合
そして、i = Nから0へのカウントは、i = 0からNへのカウントよりも高速です。
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
2番目のpsudo-Assemblyには比較がないことに注意してください。多くのアーキテクチャには、ジャンプに使用できる算術演算(加算、減算、乗算、除算、増分、減分)によって設定されるフラグがあります。これらは、多くの場合、基本的に操作の結果と0との比較を無料で提供します。実際、多くのアーキテクチャで
x = x - 0
意味的にはと同じです
compare x, 0
また、私の例の10と比較すると、コードが悪化する可能性があります。 10はレジスターに住む必要があるかもしれないので、供給が不足している場合はコストがかかり、ループを通過するたびに10を移動したりリロードしたりする余分なコードが発生する可能性があります。
コンパイラーはこれを利用するためにコードを再配置することがありますが、ループの方向を逆にすることは意味的に同等であると確信できないことが多いため、しばしば困難です。
このような場合には、より早くカウントダウンします。
_for (i = someObject.getAllObjects.size(); i >= 0; i--) {…}
_
someObject.getAllObjects.size()
は最初に1回実行されるためです。
確かに、Peterが述べたように、ループからsize()
を呼び出すことで同様の動作を実現できます。
_size = someObject.getAllObjects.size();
for (i = 0; i < size; i++) {…}
_
カウントダウンするのはアップするよりも速いですか?
多分。しかし、99%を超える時間は重要ではないので、ループを終了するために最も「賢明な」テストを使用する必要があります。そして、賢明なことに、読者が理解するのに最小限の思考で済むことを意味しますループの実行内容(ループを停止させるものを含む)。コードが実行している内容のメンタル(または文書化された)モデルにコードを一致させます。
ループが機能している場合、配列(またはリストなど)を介して処理されます。インクリメントカウンターは、読者がループの動作をどのように考えているかによりよく一致します。このようにループをコーディングします。
ただし、N
アイテムを含むコンテナを操作していて、アイテムを削除している場合は、カウンターを下に移動する方がより意味があります。
答えの「多分」の詳細:
ほとんどのアーキテクチャでは、結果がゼロになる(またはゼロからネガティブになる)計算のテストに明示的なテスト命令が必要ないことは事実です。結果を直接確認できます。計算の結果が他の数値になるかどうかをテストする場合、通常、命令ストリームにはその値をテストするための明示的な命令が必要です。ただし、特に最新のCPUの場合、このテストは通常、ループ構造にノイズレベル未満の追加時間を追加します。特に、そのループがI/Oを実行している場合。
一方、ゼロからカウントダウンし、カウンターを配列インデックスとして使用すると、たとえば、システムのメモリアーキテクチャに対して機能するコードが見つかる場合があります-メモリの読み取りにより、キャッシュが「先読み」されることがよくあります順次読み取りを見越して、現在のメモリ位置を超えた複数のメモリ位置。メモリを逆方向に操作している場合、キャッシュシステムは、より低いメモリアドレスのメモリ位置の読み取りを予期しない場合があります。この場合、「後方」にループするとパフォーマンスが低下する可能性があります。ただし、正確性が最重要であり、コードをモデルに一致させることは、正確性を確保するための優れた方法であるため、おそらくループをこの方法でコーディングします(パフォーマンスが問題にならない限り)。不正なコードは、可能な限り最適化されていません。
そのため、コードのパフォーマンスが実際に重要でない限り、教授のアドバイスを忘れる傾向があります(もちろん、彼のテストではありません。教室に関する限り、実際的であるべきです)。
一部の古いCPUには、DJNZ
== "ゼロでない場合はデクリメントしてジャンプする"などの命令があります。これにより、初期カウント値をレジスタにロードし、1つの命令で減分ループを効果的に管理できる効率的なループが可能になりました。ただし、1980年代のISAについてはここで話しています。先生がこの「経験則」が現代のCPUにも適用されると考えると、真剣に連絡が取れなくなります。
カウンターを増やすか減らすかよりも重要なのは、メモリを増やすか減らすかです。ほとんどのキャッシュは、メモリのダウンではなくメモリのアップに最適化されています。メモリアクセス時間が現在のほとんどのプログラムが直面しているボトルネックであるため、これは、カウンタをゼロ以外の値と比較する必要がある場合でも、メモリを上げるようにプログラムを変更するとパフォーマンスが向上する可能性があることを意味します私のプログラムのいくつかでは、コードを変更してメモリを下げるのではなくメモリを上げることで、パフォーマンスが大幅に向上しました。
懐疑的?ここに私が得た出力があります:
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_up
およびsum_abs_down
は同じことを行い、同じ方法でタイミングが調整されますが、唯一の違いはsum_abs_up
はメモリを増やしますが、sum_abs_down
はメモリを下げます。両方の関数が同じメモリ位置にアクセスするように、vec
を参照渡しします。それでも、sum_abs_up
はsum_abs_down
。自分で実行してください(g ++ -O3でコンパイルしました)。
ご参考までに vec_original
は実験のためにあり、簡単に変更できるようにするsum_abs_up
およびsum_abs_down
これらの変更が将来のタイミングに影響を与えないようにしながら、vec
を変更する方法で。
私が計時しているループがどれだけタイトかを注意することが重要です。ループの本体が大きい場合、ループの本体を実行するのにかかる時間が完全に支配する可能性が高いため、イテレータがメモリを上下するかどうかは重要ではありません。また、いくつかのまれなループでは、メモリを下げる方が上がるよりも速い場合があることに注意することが重要です。しかし、そのようなループでさえ、上昇することが常に下降するよりも常に遅い場合はめったにありません(メモリを上昇させる小さなボディのループとは異なり、その反対はしばしば真実です;実際、私が持っている小さな握りのループのために)時限、メモリを増やすことによるパフォーマンスの増加は40 +%でした)。
経験則として、オプションがある場合、ループの本体が小さい場合、ループを下から上に上げるのではなく、上に上げるのとの違いがほとんどない場合は、メモリを上げる必要があります。
ボブ、
マイクロ最適化を行うまでは、CPUのマニュアルは手元にありません。さらに、そのようなことをしていれば、とにかくこの質問をする必要はないでしょう。 :-)しかし、あなたの先生は明らかにそのアイデアを購読していません。
ループの例で考慮すべき4つのことがあります。
for (i=N;
i>=0; //thing 1
i--) //thing 2
{
putchar('*'); //thing 3
}
比較は(他の人が示したように)特定のプロセッサに関連していますアーキテクチャ。 Windowsを実行するプロセッサよりも多くの種類のプロセッサがあります。特に、0との比較を単純化し、高速化する命令があるかもしれません。
場合によっては、上下に調整する方が高速です。通常、goodコンパイラーはそれを把握し、可能であればループをやり直します。ただし、すべてのコンパイラが優れているわけではありません。
Putcharを使用してsyscallにアクセスしています。それは非常に遅いです。さらに、画面に(間接的に)レンダリングしています。それはさらに遅いです。 1000:1以上の比率を考えてください。この状況では、ループ本体がループ調整/比較のコストを完全に完全に上回ります。
キャッシュとメモリのレイアウトは、パフォーマンスに大きな影響を与える可能性があります。この状況では、問題ではありません。ただし、アレイにアクセスして最適なパフォーマンスが必要な場合は、コンパイラとプロセッサがメモリアクセスをどのようにレイアウトするかを調査し、それを最大限に活用するためにソフトウェアを調整する必要があります。ストックの例は、行列の乗算に関連して与えられたものです。
これは興味深い質問ですが、実際問題として、それは重要ではなく、1つのループが他のループよりも優れているとは思いません。
このウィキペディアのページによると、 うるう秒 、「...太陽の日は、主に潮の摩擦により、世紀ごとに1.7ミリ秒長くなります。」しかし、誕生日までの日数を数えている場合、このわずかな時間の違いに本当に関心がありますか?
ソースコードが読みやすく、理解しやすいことがより重要です。これらの2つのループは、読みやすさが重要である理由の良い例です。同じ回数ループすることはありません。
ほとんどのプログラマーは(i = 0; i <N; i ++)を読んで、これがN回ループすることをすぐに理解すると思います。とにかく、(i = 1; i <= N; i ++)のループは少し明確ではなく、(i = N; i> 0; i--)で少し考えなければなりません。コードの意図が何も考えずに脳に直接入り込むのが最善です。
あなたの先生が言ったことは、あまり説明のない斜めの声明でした。デクリメントがインクリメントより速いということではありませんが、インクリメントよりもデクリメントの方がはるかに速いループを作成できます。
ループカウンタなどを使用せずに、それについて詳しく説明しなくても、以下で重要なのは、速度とループカウント(ゼロ以外)だけです。
ほとんどの人が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つ(はい)の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が上記の命令を実装していない場合でも、エミュレートするのに必要なのは、前の命令の結果がゼロである場合に条件付きジャンプが続くデクリメントです。
だから、私が間違っている理由などをコメントとして指摘するかもしれないいくつかのケースにかかわらず、私は強調する-はいIT IS方法、理由、および時間を知っていれば、下向きにループするのが有益です。
PS。はい、賢明なコンパイラー(適切な最適化レベルを使用)がループ(ループカウンターの昇順)をdo..whileに書き換え、定数ループの反復と同等であることがわかります...(または展開)...
奇妙なことに、ISの違いがあります。少なくとも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
誰かが理由を知っているなら、それは知ってうれしいです:)
[〜#〜] edit [〜#〜]:0からではなく、他の任意の値からカウントを開始しても、結果は同じです。だから、おそらくゼロと比較するだけで違いはありませんか?
can速くなります。
現在作業している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秒です。したがって、カウントダウンのループは、約%より高速です。
ただし:このテストは、すべてのGCC最適化をオフにして行われました。それらをオンにすると、コンパイラは実際にこの便利な最適化よりも賢く、ループ全体でレジスタに値を保持し、次のようなアセンブリを取得します
addi r2,r2,-1
bne r2,zero,0xa01c
この特定の例では、コンパイラーは、変数aがループ実行後に常に1になり、ループをすべてスキップすることにも気付きます。
ただし、ループ本体が十分に複雑な場合、コンパイラがこの最適化を実行できないことがあるため、常に高速ループ実行を取得する最も安全な方法は次のとおりです。
register int i;
for(i=10000;i--;)
{ ... }
もちろん、これが機能するのは、ループが逆に実行され、Betamooが言ったように、ifのみゼロまでカウントダウンしていることが重要でない場合のみです。
ポイントは、カウントダウンするときに、i
を減分するためにi >= 0
を個別にチェックする必要がないことです。観察する:
for (i = 5; i--;) {
alert(i); // alert boxes showing 4, 3, 2, 1, 0
}
i
の比較とデクリメントの両方を1つの式で実行できます。
X86命令の数が少なくなる理由については、他の回答をご覧ください。
それがアプリケーションに意味のある違いをもたらすかどうかについては、ループの数とネストの深さに依存していると思います。しかし、私にとっては、この方法で行うのと同じくらい読みやすいため、とにかくそれを行います。
いいえ、それは本当ではありません。より高速になる可能性のある状況の1つは、ループの各反復中に境界をチェックする関数を呼び出す場合です。
for(int i=myCollection.size(); i >= 0; i--)
{
...
}
しかし、そのように行うことがあまり明確でない場合、それは価値がありません。とにかく、現代の言語では、可能であればforeachループを使用する必要があります。インデックスが不要な場合に、foreachループを使用する必要がある場合について具体的に言及します。
方向に関係なく、常にprefix形式(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)
しかし、現代のコンパイラーがこれらの最適化を正確に行えると期待しています。
今、あなたは十分なアセンブリ講義を受けたと思います:)トップダウンアプローチの別の理由を紹介したいと思います。
上から行く理由は非常に簡単です。ループの本体では、境界を誤って変更する可能性があります。これにより、誤った動作が発生したり、ループが終了することさえあります。
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);
}
したがって、私のポイントは、上から下に行くか、境界として定数を持つことを好むことを考慮する必要があるということです。