web-dev-qa-db-ja.com

Funcの値の切り替え対辞書では、どちらが高速で、なぜですか?

次のコードがあると仮定します。

private static int DoSwitch(string arg)
{
    switch (arg)
    {
        case "a": return 0;
        case "b": return 1;
        case "c": return 2;
        case "d": return 3;
    }
    return -1;
}

private static Dictionary<string, Func<int>> dict = new Dictionary<string, Func<int>>
    {
        {"a", () => 0 },
        {"b", () => 1 },
        {"c", () => 2 },
        {"d", () => 3 },
    };

private static int DoDictionary(string arg)
{
    return dict[arg]();
}

両方の方法を繰り返して比較することにより、「a」、「b」、「c」、「d」がより多くのキーを含むように展開された場合でも、辞書がわずかに高速になることがわかりました。これはなぜですか?

これは循環的な複雑さに関係していますか?それは、ジッターが辞書内のreturnステートメントをネイティブコードに1回だけコンパイルするためです。辞書の検索がO(1)であるためですか? switchステートメントの場合はそうではないかもしれません ? (これらは単なる推測です)

42
cubetwo1729

簡単な答えは、switchステートメントは線形に実行され、辞書は対数的に実行されるということです。

ILレベルでは、通常、小さなswitchステートメントは、スイッチ変数と各ケースの等価性を比較する一連のif-elseifステートメントとして実装されます。そのため、このステートメントは、myVarの有効なオプションの数に比例した時間で実行されます。ケースは出現順に比較されます。最悪のシナリオは、すべての比較が試行され、最後の比較が一致するか一致しないかです。したがって、32個のオプションがある場合、最悪の場合はどれもありません。コードはこれを判断するために32個の比較を行います。

一方、ディクショナリは、インデックスに最適化されたコレクションを使用して値を保存します。 .NETでは、ディクショナリはHashtableに基づいており、Hashtableは事実上一定のアクセス時間を持っています(スペース効率が極端に悪いという欠点があります)。辞書などの「マッピング」コレクションに一般的に使用されるその他のオプションには、対数アクセス(および線形空間効率)を提供する赤黒木のようなバランスのとれたツリー構造が含まれます。これらのいずれを使用しても、コードは、switchステートメントが同じことを行うよりもはるかに速く、コレクション内の適切な「ケース」に対応するキーを見つける(または、存在しないと判断する)ことができます。

[〜#〜] edit [〜#〜]:他の回答やコメントはこれに触れているので、完全を期すために私もそうします。 Microsoftコンパイラは、notを実行します。私が最初に推測したように、常にif/elseifへのスイッチをコンパイルします。通常、少数のケースや「スパース」ケース(1、200、4000などの非増分値)でこれを行います。隣接するケースのより大きなセットでは、コンパイラはCILステートメントを使用してスイッチを「ジャンプテーブル」に変換します。スパースケースの大規模なセットでは、コンパイラはバイナリ検索を実装してフィールドを絞り込み、少数のスパースケースを「フォールスルー」するか、隣接するケースのジャンプテーブルを実装できます。

ただし、コンパイラは通常、パフォーマンスとスペース効率の最適な妥協点である実装を選択するため、多数の密集したケースに対してのみジャンプテーブルを使用します。これは、ジャンプテーブルがカバーする必要のあるケースの範囲でメモリ内のスペースを必要とするためです。これは、スパースケースの場合、メモリ的には非常に非効率的です。ソースコードでディクショナリを使用することにより、基本的にコンパイラの手を強制します。メモリ効率を上げるためにパフォーマンスを犠牲にする代わりに、あなたのやり方でそれを行います。

したがって、switchステートメントまたはDictionaryのいずれかをソースで使用して、ディクショナリを使用するときのパフォーマンスを向上させることができるほとんどの場合を期待します。とにかく、switchステートメントの多数のケースは、保守性が低いため、避ける必要があります。

46
KeithS

これは、マイクロベンチマークが誤解を招く可能性がある理由の良い例です。 C#コンパイラは、スイッチ/ケースのサイズに応じて異なるILを生成します。そのため、このような文字列をオンにします

switch (text) 
{
     case "a": Console.WriteLine("A"); break;
     case "b": Console.WriteLine("B"); break;
     case "c": Console.WriteLine("C"); break;
     case "d": Console.WriteLine("D"); break;
     default: Console.WriteLine("def"); break;
}

基本的に各ケースで次のことを行うILを生成します。

L_0009: ldloc.1 
L_000a: ldstr "a"
L_000f: call bool [mscorlib]System.String::op_Equality(string, string)
L_0014: brtrue.s L_003f

以降

L_003f: ldstr "A"
L_0044: call void [mscorlib]System.Console::WriteLine(string)
L_0049: ret 

つまり一連の比較です。したがって、実行時間は線形です。

ただし、ケースを追加するなど。 a〜zのすべての文字を含めるには、生成されるILをそれぞれ次のようなものに変更します。

L_0020: ldstr "a"
L_0025: ldc.i4.0 
L_0026: call instance void [mscorlib]System.Collections.Generic.Dictionary`2<string, int32>::Add(!0, !1)

そして

L_0176: ldloc.1 
L_0177: ldloca.s CS$0$0001
L_0179: call instance bool [mscorlib]System.Collections.Generic.Dictionary`2<string, int32>::TryGetValue(!0, !1&)
L_017e: brfalse L_0314

そして最後に

L_01f6: ldstr "A"
L_01fb: call void [mscorlib]System.Console::WriteLine(string)
L_0200: ret 

つまり一連の文字列比較の代わりに辞書を使用するようになったため、辞書のパフォーマンスが得られます。

言い換えれば、これらのために生成されるILコードは異なり、これはちょうどILレベルです。 JITコンパイラーはさらに最適化できます。

TL; DR:ストーリーの士気は、マイクロベンチマークに基づいて最適化を試みるのではなく、実際のデータとプロファイルを調べることです。

38
Brian Rasmussen

デフォルトでは、文字列のスイッチはif/else/if/elseコンストラクトのように実装されます。ブライアンが提案したように、コンパイラはスイッチが大きくなるとスイッチをハッシュテーブルに変換します。 Bart de Smetはこれを示しています このchannel9ビデオで 、(スイッチは13:50で説明されています)

コンパイラーは、最適化のコストが利益を上回ることを防ぐために保守的であるため、4項目については行いません。ハッシュテーブルの構築には、少しの時間とメモリがかかります。

1
user180326

コンパイラーのコード生成の決定に関係する多くの質問と同様に、答えは「依存します」です。

多くの場合、独自のハッシュテーブルを構築すると、コンパイラが生成したコードよりも高速に実行される可能性があります。

ハッシュテーブルは、少数のif-then-else IL命令よりも多くのメモリを使用します。コンパイラがプログラム内のすべてのswitchステートメントに対してハッシュテーブルを吐き出すと、メモリの使用が爆発します。

Switchステートメントのcaseブロックの数が増えると、おそらくコンパイラーが異なるコードを生成することがわかります。より多くの場合、より速くより太い代替を支持して、コンパイラが小さくて単純なif-then-elseパターンを放棄するより大きな正当化があります。

C#またはJITコンパイラがこの特定の最適化を実行するかどうかはわからないが、ケースセレクタが多く、ほとんどがシーケンシャルである場合のswitchステートメントの一般的なコンパイラトリックは、ジャンプベクトルを計算することです。これには、より多くのメモリ(コードストリームに埋め込まれたコンパイラ生成のジャンプテーブルの形式)が必要ですが、一定の時間で実行されます。引き数arg-"a"、結果をジャンプテーブルへのインデックスとして使用して、適切なケースブロックにジャンプします。 20件または2000件のケースがあるかどうかに関係なく、完了しました。

コンパイラーは、スイッチセレクタータイプがcharまたはintまたはenumの場合、ジャンプテーブルモードに移行する可能性が高くなりますandケースセレクターの値は、ほとんどの場合シーケンシャル(「密集」)であるためです。簡単に減算して、オフセットまたはインデックスを作成します。文字列セレクターは少し難しいです。

文字列セレクターは、C#コンパイラーによって「インターン」されます。つまり、コンパイラーは、文字列セレクターの値を一意の文字列の内部プールに追加します。インターンされた文字列のアドレスまたはトークンをIDとして使用して、インターンされた文字列をID /バイト単位の同等性で比較するときにintのような最適化を行うことができます。十分なケースセレクターがある場合、C#コンパイラーは、引数文字列に相当するインターンを検索するILコード(ハッシュテーブルルックアップ)を生成し、インターントークンを事前計算済みケースセレクタートークンと比較(またはジャンプテーブル)します。

Char/int/enumセレクターケースでジャンプテーブルコードを生成するようにコンパイラを調整できる場合、独自のハッシュテーブルを使用するよりも高速に実行できます。

文字列セレクターの場合、ILコードはまだハッシュルックアップを実行する必要があるため、独自のハッシュテーブルを使用した場合のパフォーマンスの違いはほとんどありません。

ただし、一般に、アプリケーションコードを記述するときは、これらのコンパイラのニュアンスにあまりこだわらないでください。通常、Switchステートメントは、関数ポインターのハッシュテーブルよりも読みやすく、理解しやすいです。コンパイラをジャンプテーブルモードにプッシュするのに十分な大きさのスイッチステートメントは、人間が読めるには大きすぎることがよくあります。

Switchステートメントがコードのパフォーマンスホットスポットにあり、プロファイラーを使用してパフォーマンスに明確な影響があることを測定した場合、独自の辞書を使用するようにコードを変更することは、パフォーマンス向上のための合理的なトレードオフです。

この選択を正当化するパフォーマンス測定なしで、最初からハッシュテーブルを使用するようにコードを記述すると、過度にエンジニアリングされ、不必要に高いメンテナンスコストで計り知れないコードになります。複雑にしないでおく。

1
dthorpe