web-dev-qa-db-ja.com

Cでシフトと乗算の時間の違いをテストすると、違いはありません。どうして?

2進数でシフトする方が2 ^ kを掛けるよりもはるかに効率的であると私は教えられてきました。だから私は実験したかった、そして私はこれをテストするために次のコードを使用した:

#include <time.h>
#include <stdio.h>

int main() {
    clock_t launch = clock();
    int test = 0x01;
    int runs;

    //simple loop that oscillates between int 1 and int 2
    for (runs = 0; runs < 100000000; runs++) {


    // I first compiled + ran it a few times with this:
    test *= 2;

    // then I recompiled + ran it a few times with:
    test <<= 1;

    // set back to 1 each time
    test >>= 1;
    }

    clock_t done = clock();
    double diff = (done - launch);
    printf("%f\n",diff);
}

どちらのバージョンでも、出力は約440000で、10000程度の差があります。2つのバージョンの出力には(視覚的には)大きな違いはありませんでした。だから私の質問は、私の方法論に何か問題がありますか?視覚的な違いさえあるべきですか?これは私のコンピューターのアーキテクチャー、コンパイラー、または何か他のことと関係がありますか?

28
NicholasFolk

他の回答で述べたように、ほとんどのコンパイラーは、ビットシフトで実行される乗算を自動的に最適化します。

これは、最適化する際の非常に一般的なルールです。ほとんどの「最適化」は、実際の意味についてコンパイルを誤って導き、パフォーマンスを低下させる可能性さえあります。

パフォーマンスの問題に気づき、問題を測定した場合にのみ最適化してください。 (そして、私たちが書くほとんどのコードはそれほど頻繁に実行されないので、わざわざする必要はありません)

最適化の大きな欠点は、「最適化された」コードは多くの場合、読みにくくなることです。だからあなたの場合、あなたが乗算しようとしているときは常に乗算に行ってください。そして、ビットを移動したい場合は、ビットシフトを行ってください。

45
Thirler

コンパイラーは定数を認識し、必要に応じて乗算をシフトに変換します。

26
ddyer

シフトが乗算より速いかどうかは、CPUのアーキテクチャによって異なります。 Pentium以前の時代に戻ると、シフトは被乗数の1ビットの数に応じて、乗算よりも高速でした。たとえば、被乗数が320の場合、101000000、2ビットです。

a *= 320;               // Slower
a = (a<<7) + (a<<9);    // Faster

しかし、2ビット以上ある場合は...

a *= 324;                        // About same speed
a = (a<<2) + (a<<7) + (a<<9);    // About same speed

a *= 340;                                 // Faster
a = (a<<2) + (a<<4) + (a<<7) + (a<<9);    // Slower

PIC18 のような小さなマイクロコントローラーでは、シングルサイクル乗算を使用しますが、 バレルシフター を使用しない場合、1ビット以上シフトすると乗算が速くなります。

a  *= 2;   // Exactly the same speed
a <<= 1;   // Exactly the same speed

a  *= 4;   // Faster
a <<= 2;   // Slower

それが古いIntel CPUで真実だった反対であることに注意してください。

しかし、それはまだそれほど単純ではありません。私の記憶が正しければ、スーパースカラーアーキテクチャにより、Pentiumは1つの乗算命令または2つのシフト命令を同時に処理できました(相互に依存していない限り)。つまり、two変数を2のべき乗で乗算する場合は、シフトの方が適している可能性があります。

a  *= 4;   // 
b  *= 4;   // 

a <<= 2;   // Both lines execute in a single cycle
b <<= 2;   // 
22
Rocketmagnet

テストプログラムにいくつかの問題があります。

まず、実際にはtestの値を使用していません。 C規格内では、testの値が重要であることはありません。オプティマイザは、これを完全に削除して自由にできます。削除すると、ループは空になります。目に見える唯一の効果は_runs = 100000000_を設定することですが、runsも使用されません。したがって、オプティマイザはループ全体を削除できます(そして、そうすべきです!)。簡単な修正:計算された値も出力します。十分に決定されたオプティマイザがループを最適化する可能性があることに注意してください(コンパイル時に既知の定数に完全に依存しています)。

次に、互いにキャンセルする2つの操作を行います。オプティマイザはこれに気づくことを許可され、それらを取り消すです。再び空のループを残して、削除しました。これは修正するのが実に難しい。 _unsigned int_に切り替えることができます(オーバーフローが未定義の動作ではないため)が、もちろん結果は0になります。そして、単純なもの(たとえば、_test += 1_)は、オプティマイザが簡単に実行できます理解してください。

最後に、_test *= 2_が実際に乗算にコンパイルされると想定します。これは非常に単純な最適化です。ビットシフトが速い場合、オプティマイザは代わりにそれを使用します。これを回避するには、実装固有のアセンブリのようなものをインラインで使用する必要があります。

または、マイクロプロセッサーのデータシートをチェックしてどちらが速いかを確認してください。

バージョン4.9を使用して_gcc -S -O3_でプログラムをコンパイルしたアセンブリの出力を確認したところ、オプティマイザは実際に上記の簡単なバリエーションすべて、さらにいくつかを確認しました。すべての場合で、ループを削除し(testに定数を割り当て)、残っているのはclock()への呼び出し、変換/減算、およびprintfだけでした。 。

11
derobert

私は質問といくつかの回答またはコメントにいくつかの未検証の仮定があるので、質問者がより差別化された回答を持っているとより役立つと思います。

結果として生じるシフトと乗算の相対的な実行時間は、Cとは何の関係もありません。私がCと言うとき、それはGCCのそのバージョンやそのバージョンなどの特定の実装のインスタンスではなく、言語を意味します。私はこの不条理を取るつもりはありませんが、例として極端な例を使用します。完全に標準に準拠したCコンパイラを実装し、乗算に1時間かかり、シフトにはミリ秒かかるか、またはその逆です。 CまたはC++でのこのようなパフォーマンスの制限については知りません。

議論では、この専門性を気にする必要はありません。 Cを選択したのは、シフトと乗算の相対的なパフォーマンスをテストすることでした。おそらくCを選択したのは、低レベルのプログラミング言語として認識されているため、ソースコードが対応する命令に直接変換されることを期待できるためです。そのような質問は非常に一般的であり、Cではソースコードが特定のインスタンスで考えるほど直接的に命令に変換されないことを指摘するとよいでしょう。以下に、可能なコンパイル結果をいくつか示します。

ここで、実際のソフトウェアでこの同等性を置き換えることの有用性を疑問視するコメントが寄せられます。EricLippertのコメントなど、質問へのコメントでいくつかを見ることができます。それは、そのような最適化に対応して、より熟練したエンジニアから一般的に得られる反応と一致しています。乗算と除算の包括的な手段として製品コードでバイナリシフトを使用する場合、人々はおそらくあなたのコードにこだわり、ある程度の感情的な反応があります(「天国のためにJavaScriptについてこの無意味な主張があったのを聞いた」)。プログラマーがそれらの反応の理由をよりよく理解しない限り、初心者プログラマーには意味がないかもしれません。

これらの理由は主に、相対的なパフォーマンスの比較ですでにわかっているように、そのような最適化の読みやすさの低下と無駄の組み合わせです。しかし、乗算の代わりにシフトを使用することがそのような最適化の唯一の例である場合、人々が反応にそれほど強いとは思わない。あなたのような質問は、さまざまな形や状況で頻繁に出てきます。上級エンジニアが実際に非常に強く反応するのは、少なくともときどきあることですが、このようなマイクロ最適化をコードベース全体で自由に使用すると、はるかに広範囲の害が発生する可能性があるということです。マイクロソフトのような会社で大規模なコードベースで作業している場合、他のエンジニアのソースコードを読むのに多くの時間を費やしたり、その中の特定のコードを見つけようとするでしょう。ポケットベルで電話を受けた後に生産停止を修正する必要がある場合など、特に数か月以内に意味のない時間に理解しようとする独自のコードである可能性もあります金曜日の夜の義務、友人との楽しい夜に向けて出発しようとしています...コードを読むことに多くの時間を費やしているなら、あなたはそれが可能な限り読みやすいものであることに感謝します。あなたの好きな小説を読んでいると想像してみてください。しかし、出版社はabbrvを使用する新版をリリースすることを決定しました。すべてovr th plc bcs thy thnk it svs spc。これは、そのような最適化を施した場合、他のエンジニアがコードに対して行う可能性のある反応に似ています。他の答えが指摘しているように、あなたが何を意味しているのかを明確に述べた方がいいです。つまり、アイデアで表現しようとしているものに意味的に最も近いコードの操作を使用しています。

ただし、これらの環境でも、これまたは他の同等性を知っていることが期待されるインタビューの質問を自分で解決している場合があります。それらを知ることは悪くないことであり、優れたエンジニアはバイナリシフトの算術効果を知っているでしょう。これが優れたエンジニアになるとは言わなかったことに注意してください。しかし、私の意見では、優れたエンジニアは知っているでしょう。特に、通常はインタビューループの終わり頃に、マネージャーが見つかるかもしれません。マネージャーは、このスマートエンジニアリングの「トリック」をコーディングの質問で明らかにし、彼/彼女がも、かつては、または単なる「マネージャー」ではなく、精通したエンジニアの1人でした。そのような状況では、ただ印象的に見えるようにして、啓発的なインタビューに対して彼/彼女に感謝してください。

Cで速度の違いが見られなかったのはなぜですか?最も可能性の高い答えは、どちらも同じアセンブリコードになったことです。

int shift(int i) { return i << 2; }
int multiply(int i) { return i * 2; }

両方ともコンパイルできます

shift(int):
    lea eax, [0+rdi*4]
    ret

最適化なしのGCCでは、つまりフラグ「-O0」を使用すると、次のようになります。

shift(int):
    Push    rbp
    mov rbp, rsp
    mov DWORD PTR [rbp-4], edi
    mov eax, DWORD PTR [rbp-4]
    sal eax, 2
    pop rbp
    ret
multiply(int):
    Push    rbp
    mov rbp, rsp
    mov DWORD PTR [rbp-4], edi
    mov eax, DWORD PTR [rbp-4]
    add eax, eax
    pop rbp
    ret

ご覧のように、「-O0」をGCCに渡しても、生成されるコードの種類がいくらか賢くならないわけではありません。特に、この場合でも、コンパイラーは乗算命令の使用を避けていることに注意してください。同じ実験を他の数値によるシフトで繰り返したり、2の累乗ではない数値で乗算したりすることもできます。おそらく、プラットフォームではシフトと加算の組み合わせが表示されますが、乗算は表示されません。乗算とシフトのコストが実際に同じである場合、コンパイラーがこれらのすべてのケースで乗算の使用を明らかに回避するのは少し偶然のように思えますか?しかし、証明のために仮定を提供するつもりはないので、次に進みましょう。

上記のコードを使用してテストを再実行し、速度の違いに気付いたかどうかを確認できます。それでも、乗算がないことからわかるように、シフトと乗算をテストしていませんが、シフトと乗算のC演算のためにGCCによって特定のフラグセットを使用して生成されたコード特定のインスタンス。したがって、別のテストでは、アセンブリコードを手動で編集し、代わりに「乗算」メソッドのコードで「imul」命令を使用できます。

コンパイラの優れた機能のいくつかを無効にしたい場合は、より一般的なシフトおよび乗算メソッドを定義すると、次のような結果になります。

int shift(int i, int j) { return i << j; }
int multiply(int i, int j) { return i * j; }

次のアセンブリコードが生成される可能性があります。

shift(int, int):
    mov eax, edi
    mov ecx, esi
    sal eax, cl
    ret
multiply(int, int):
    mov eax, edi
    imul    eax, esi
    ret

ここに、GCC 4.9の最高の最適化レベルであっても、最初にテストを開始したときに期待したアセンブリ命令の式があります。それ自体がパフォーマンス最適化の重要な教訓になる可能性があると思います。コンパイラーが適用できる賢さの観点から、コード内の具体的な定数の代わりに変数を使用することで生じた違いを確認できます。シフト乗算置換のようなマイクロ最適化は、コンパイラーが通常それ自体で簡単に実行できる非常に低レベルの最適化です。パフォーマンスにはるかに影響を与える他の最適化では、コードの意図を理解する必要があります。これは、コンパイラーがアクセスできないことが多いか、何らかのヒューリスティックによってのみ推測できます。ソフトウェアエンジニアとしてのあなたの出番であり、通常、乗算をシフトで置き換えることはありません。これには、I/Oを生成してプロセスをブロックする可能性があるサービスへの冗長な呼び出しを回避するなどの要素が含まれます。ハードディスクにアクセスするか、禁じられているように、リモートデータベースにアクセスして、すでにメモリにあるデータから取得できる余分なデータがある場合、待機に費やす時間は、100万の命令の実行を上回ります。さて、あなたの元の質問から少し離れていると思いますが、特にコードの翻訳と実行を理解し始めたばかりの人がいるとすれば、これを質問者に指摘することは非常に重要です質問自体だけでなく、その(おそらく)根底にある仮定の多くに対処する。

それで、どちらが速くなりますか?これは、パフォーマンスの違いを実際にテストするために選択した良いアプローチだと思います。一般に、一部のコード変更の実行時パフォーマンスには驚かれがちです。最新のプロセッサが採用している技術は多数あり、ソフトウェア間の相互作用も複雑になる可能性があります。ある状況で特定の変更を行ったときにパフォーマンスの結果が得られたとしても、この種の変更によって常にパフォーマンスが向上すると結論付けるのは危険だと思います。このようなテストを1回実行するのは危険だと思います。「わかりました。これでどちらが速いかわかります!」そして、測定を繰り返さずに無差別に同じ最適化を製品コードに適用します。

では、シフトが乗算よりも速い場合はどうでしょうか?それが真実である理由は確かに指摘されています。上記のように、GCCは(最適化がなくても)他の命令を優先して直接乗算を回避することをお勧めします。 インテル64およびIA-32アーキテクチャー最適化リファレンスマニュアル は、CPU命令の相対的なコストを示します。命令のレイテンシとスループットに重点を置いた別のリソースは http://www.agner.org/optimize/instruction_tables.pdf です。これらは絶対的なランタイムの優れた述語ではなく、相互に関連する命令のパフォーマンスの述語であることに注意してください。緊密なループでは、テストがシミュレートしているため、「スループット」のメトリックが最も関連性があります。これは、特定の命令を実行するときに実行ユニットが通常拘束されるサイクル数です。

では、シフトが乗算よりも速くない場合はどうでしょうか?先に述べたように、現代のアーキテクチャは非常に複雑になる可能性があり、分岐予測、キャッシュ、パイプライン処理、並列実行ユニットなどにより、論理的に同等な2つのコードの相対的なパフォーマンスを予測することが困難になる場合があります。私はこれを強調したいと思います。なぜなら、このような質問に対するほとんどの答えと、シフトが乗算より速いというのは(もはや)真実ではないという人々の陣営がはっきり言っていることに不満があるからです。

いいえ、私が知っている限り、1970年代に、または乗算ユニットとビットシフターのコストの違いを突然無効にするために、秘密のエンジニアリングソースを発明していません。論理ゲートの観点から、そして確かに論理演算の観点から、一般的な乗算は、多くのアーキテクチャーの多くのシナリオで、バレルシフターを使用したシフトよりもさらに複雑です。これがデスクトップコンピュータの全体的なランタイムにどのように変換されるかは、少し不透明かもしれません。特定のプロセッサにどのように実装されているかはわかりませんが、乗算の説明を次に示します。 整数の乗算は、最新のCPUでの加算と実際に同じ速度です

ここでは バレルシフター について説明します。前の段落で参照したドキュメントは、CPU命令のプロキシによる操作の相対的なコストに関する別の見方を示しています。 Intelのエンジニアはよく似た質問をするようです:インテル開発者ゾーンフォーラム コア2デュオプロセッサでの整数の乗算と加算のクロックサイクル

はい、ほとんどの実際のシナリオで、そしてほぼ確実にJavaScriptで、パフォーマンスのためにこの同等性を利用しようとするのはおそらく無駄な仕事です。ただし、乗算命令の使用を強制した後、ランタイムに違いが見られなかった場合でも、正確には、使用したコストメトリックの性質によるものであり、コストの違いがないためではありません。エンドツーエンドのランタイムは1つのメトリックであり、それが私たちが気にする唯一のものである場合、すべてが順調です。しかし、それは乗算とシフトの間のすべてのコストの違いが単に消えたという意味ではありません。そして、現代のコードの実行時間とコストに関係する要因を明らかにし始めたばかりの質問者に、含意またはその他の方法でそのアイデアを伝えることは、確かに良い考えではないと思います。エンジニアリングは常にトレードオフについてです。現代のプロセッサーが実行時間を示すためにどのようなトレードオフを行ったかについての調査と説明は、ユーザーが最終的に見ると、より差別化された答えが得られる可能性があることを示しています。そして、「最適化」の性質をより一般的に理解する必要があるため、可読性を無効にするマイクロ最適化コードをチェックインするエンジニアの数を減らしたい場合は、「これはもう真実ではない」よりも差別化された答えが必要だと思います。単にいくつかの特定のインスタンスを古いものとして参照するのではなく、そのさまざまな多様な化身を見つけます。

8
user2880576

あなたが見るのはオプティマイザの効果です。

オプティマイザーの仕事は、結果として生成されるコンパイル済みコードを小さくするか、速くすることです(ただし、まれに両方が同時に発生することはめったにありません...しかし、多くのことと同様に...コードが何であるかに依存します)。

PRINCIPLEでは、乗算ライブラリへの呼び出し、またはハードウェア乗算器の使用でさえ、ビット単位のシフトを実行するよりも遅くなります。

つまり...単純なコンパイラーが操作* 2のライブラリーへの呼び出しを生成した場合、当然、ビット単位のシフトよりも実行速度が遅くなります*。

しかし、オプティマイザはパターンを検出し、コードをより小さく/より速く/どうするかを理解するために存在します。そして、コンパイラが* 2がシフトと同じであることを検出したことを確認しました。

ちょうど関心事として、私は今日、* 5のようないくつかの操作のために生成されたアセンブラーを見ていました...実際にはそれではなく他のものを見ていました。そして、コンパイラーが* 5に変換したことに気づきました:

  • シフト
  • シフト
  • 元の番号を追加

したがって、私のコンパイラのオプティマイザは、インラインシフトを生成し、汎用の乗算ライブラリを呼び出すのではなく追加するのに十分スマートでした(少なくとも特定の小さな定数に対して)。

コンパイラーオプティマイザーの芸術は完全に別の主題であり、魔法で満たされており、地球全体の約6人が本当に正しく理解しています:)

6
quickly_now

次のタイミングで試してみてください:

for (runs = 0; runs < 100000000; runs++) {
      ;
}

コンパイラーは、ループの各反復後にtestの値が変更されず、testの最終値が未使用であることを認識し、ループを完全に排除する必要があります。

3

乗算は、シフトと加算の組み合わせです。

あなたが言及したケースでは、コンパイラがそれを最適化するかどうかは重要ではないと思います-"xを2倍する"は次のいずれかとして実装できます:

  • xのビットを1桁左にシフトします。
  • xxに追加します。

これらはそれぞれ基本的なアトミック操作です。一方は他方よりも速くありません。

xを4倍する」(または任意の_2^k, k>1_)に変更すると、少し異なります。

  • xのビットを左に2桁シフトします。
  • xxに追加してyと呼び、yyに追加します。

基本的なアーキテクチャでは、シフトがより効率的であることが簡単にわかります。1つではなく2つの演算を実行します。yyに追加するのは、yが何であるかがわからないためです。

後者(または任意の_2^k, k>1_)を適切なオプションで試して、実装で同じになるように最適化しないようにします。 O(1)で繰り返し加算するよりもO(k)の方がシフトが速いことがわかります。

明らかに、被乗数が2の累乗ではない場合、シフトと加算の組み合わせ(それぞれの数が0でない場合)が必要です。

2
OJFord

符号付きまたは符号なしの値の2のべき乗による乗算は、左シフトと同等であり、ほとんどのコンパイラーが置換を行います。符号なしの値、または符号付きの値の除算コンパイラが負になり得ないことを証明できるは、右シフトと同等であり、ほとんどのコンパイラはその置換を行います(ただし、署名されたときに証明できるほど洗練されていないものもあります)値を負にすることはできません)。

ただし、負になる可能性のある符号付き値の除算はnotが右シフトと同等であることに注意してください。 (x+8)>>4のような式は(x+8)/16と同等ではありません。前者は、コンパイラーの99%で、-24から-9から-1、-8から+7から0、+ 8から+23から1までの値をマッピングします(数値をほぼ対称的にゼロに丸めます)。後者は、-39から-24まで-1、-23から+7まで0、+ 8から+23まで+1をマッピングします[大幅に非対称で、意図したものではない可能性が高い]。値が負であると予期されていない場合でも、>>4を使用すると、コンパイラが値が負でないことを証明できない限り、/16よりも高速なコードが生成される可能性があります。

1
supercat

私がチェックアウトしたいくつかの詳細情報。

X86_64では、MULオペコードのレイテンシは10サイクル、スループットは1/2サイクルです。 MOV、ADD、およびSHLのレイテンシは1サイクルで、スループットは2.5、2.5、および1.7サイクルです。

15の乗算には、最低3つのSHLと3つのADD演算が必要であり、おそらくいくつかのMOVが必要です。

https://gmplib.org/~tege/x86-timing.pdf

0
Rich Remer

あなたの方法論には欠陥があります。ループのインクリメントと条件チェック自体は、かなりの時間がかかります。

  • 空のループを実行して、時間を測定します(baseと呼びます)。
  • 次に、シフト操作を1つ追加して、時間を測定します(s1と呼びます)。
  • 次に10個のシフト演算を追加して時間を測定します(これをs2と呼びます)

すべてが正常に機能している場合、base-s2base-s1の10倍になるはずです。さもなければ、ここで何か他のものが出てきます。

今私は実際にこれを試してみて、ループが問題を引き起こしているのなら、完全に削除しないのはなぜか考えました。だから私は先に行ってこれをやった:

int main(){

    int test = 2;
    clock_t launch = clock();

    test << 6;
    test << 6;
    test << 6;
    test << 6;
    //.... 1 million times
    test << 6;

    clock_t done = clock();
    printf("Time taken : %d\n", done - launch);
    return 0;
}

そしてそこにあなたの結果があります

1ミリ秒未満で100万回のシフト操作?.

64倍の乗算でも同じことを行い、同じ結果を得ました。他の人がtestの値は決して変更されないと述べたので、おそらくコンパイラは操作を完全に無視しています。

Shiftwise Operator Result

0
DollarAkshay