web-dev-qa-db-ja.com

foreach + break vs linqFirstOrDefaultのパフォーマンスの違い

特定の日の日付と日付の範囲のデータフェッチを実行する2つのクラスがあります。

public class IterationLookup<TItem>
{
    private IList<Item> items = null;

    public IterationLookup(IEnumerable<TItem> items, Func<TItem, TKey> keySelector)
    {
        this.items = items.OrderByDescending(keySelector).ToList();
    }

    public TItem GetItem(DateTime day)
    {
        foreach(TItem i in this.items)
        {
           if (i.IsWithinRange(day))
           {
               return i;
           }
        }
        return null;
    }
}


public class LinqLookup<TItem>
{
    private IList<Item> items = null;

    public IterationLookup(IEnumerable<TItem> items, Func<TItem, TKey> keySelector)
    {
        this.items = items.OrderByDescending(keySelector).ToList();
    }

    public TItem GetItem(DateTime day)
    {
        return this.items.FirstOrDefault(i => i.IsWithinRange(day));
    }
}

次に、Linqバージョンが約5倍遅いことを示す速度テストを実行します。これは、ToListを使用してアイテムを列挙せずにローカルにアイテムを保存する場合に意味があります。 FirstOrDefaultを呼び出すたびに、linqもOrderByDescendingを実行するため、これにより処理速度が大幅に低下します。しかし、そうではないので、何が起こっているのか本当にわかりません。 Linqは反復と非常によく似たパフォーマンスを発揮するはずです。

これは私のタイミングを測定するコードの抜粋です

IList<RangeItem> ranges = GenerateRanges(); // returns List<T>

var iterLookup = new IterationLookup<RangeItems>(ranges, r => r.Id);
var linqLookup = new LinqLookup<RangeItems>(ranges, r => r.Id);

Stopwatch timer = new Stopwatch();

timer.Start();
for(int i = 0; i < 1000000; i++)
{
    iterLookup.GetItem(GetRandomDay());
}
timer.Stop();
// display elapsed time

timer.Restart();
for(int i = 0; i < 1000000; i++)
{
    linqLookup.GetItem(GetRandomDay());
}
timer.Stop();
// display elapsed time

なぜ私はそれがより良く機能するはずだと知っているのですか?これらのルックアップクラスを使用せずに非常に類似したコードを作成すると、Linqはforeachの反復と非常に類似したパフォーマンスを実行するためです。

// continue from previous code block

// items used by both order as they do in classes as well
IList<RangeItem> items = ranges.OrderByDescending(r => r.Id).ToList();

timer.Restart();
for(int i = 0; i < 1000000; i++)
{
    DateTime day = GetRandomDay();
    foreach(RangeItem r in items)
    {
        if (r.IsWithinRange(day))
        {
            // RangeItem result = r;
            break;
        }
    }
}    
timer.Stop();
// display elapsed time

timer.Restart();
for(int i = 0; i < 1000000; i++)
{
   DateTime day = GetRandomDay();
   items.FirstOrDefault(i => i.IsWithinRange(day));
}
timer.Stop();
// display elapsed time

これは私の意見では非常によく似たコードです。 FirstOrDefault私が知っている限り、有効なアイテムに到達するまで、または最後に到達するまで、繰り返します。そして、これはどういうわけかforeachbreakと同じです。

しかし、反復クラスでさえ、私の単純なforeach反復ループよりもパフォーマンスが劣ります。これは、直接アクセスと比較して、クラス内のメソッドの呼び出しだけであるため、謎でもあります。

質問

LINQクラスのパフォーマンスが非常に遅いので、何が間違っていますか?
Iterationクラスで何が間違っているので、直接のforeachループの2倍の速度で実行されますか?

どの時間が測定されていますか?

私はこれらのステップを実行します:

  1. 範囲を生成します(以下の結果を参照)
  2. IterationLookup、LinqLookup(およびここでの説明の一部ではない最適化された日付範囲クラスBitCountLookup)のオブジェクトインスタンスを作成します。
  3. 以前にインスタンス化されたIterationLookupクラスを使用して、タイマーを開始し、最大日付範囲内のランダムな日に100万回のルックアップを実行します(結果に表示されます)。
  4. 以前にインスタンス化されたLinqLookupクラスを使用して、タイマーを開始し、最大日付範囲内のランダムな日に100万回のルックアップを実行します(結果に表示されます)。
  5. タイマーを開始し、手動のforeach + breakループとLinq呼び出しを使用して100万回のルックアップ(6回)を実行します。

ご覧のとおり、オブジェクトのインスタンス化は測定されません

付録I:100万回を超えるルックアップの結果

これらの結果に表示される範囲は重複していません。これにより、LINQバージョンが正常な一致でループを中断しない場合(可能性が高い)、両方のアプローチがさらに類似するはずです。

生成された範囲:
 
 ID範囲000000000111111111122222222223300000000011111111112222222222 
 123456789012345678901234567890112345678901234567890123456789 
 0922.01.-30.01。 | ---- 0814.01.-16.01。 |-| 
 0716.02.-19.02。 |-| 
 0615.01.-17.01。 |-| 
 0519.02.-23.02。 | --- | 
 0401.01.-07.01。| ----- | 
 0302.01.-10.01。 | ------- | 
 0211.01.-13.01。 |-| 
 0116.01.-20.01。 | --- | 
 0029.01.-06.02。 | ------- | 
 
ルックアップクラス... 
 
-反復:1028ms 
--Linq:4517ms !!!これIS問題!!!
-BitCounter:401ms 
 
手動ループ... 
 
-Iter:786ms 
-Linq:981ms 
-Iter:787ms 
-Linq:996ms 
-Iter:787ms 
-Linq:977ms 
-Iter:783ms 
-Linq: 979ms 

付録II:GitHub:自分でテストするための要点コード

完全なコードを自分で入手して何が起こっているかを確認できるように、要点を記載しました。 Consoleアプリケーションを作成し、Program.csをコピーして、この一部である他のファイルを追加します要旨。

それをつかむ---(ここ

付録III:最終的な考えと測定テスト

最も問題だったのはもちろん、非常に遅いLINQimplementatinoでした。これは、デリゲートコンパイラの最適化にすべて関係していることがわかりました。 LukeHは最良で最も使いやすいソリューションを提供しました これにより、実際にさまざまなアプローチを試してみました。 GetItemメソッド(またはGistで呼び出されるGetPointData)でさまざまなアプローチを試しました。

  1. ほとんどの開発者が行う通常の方法(Gistにも実装されており、結果が明らかになった後も更新されませんでした):これは最善の方法ではありません。

    return this.items.FirstOrDefault(item => item.IsWithinRange(day));
    
  2. ローカル述語変数を定義することによって:

    Func<TItem, bool> predicate = item => item.IsWithinRange(day);
    return this.items.FirstOrDefault(predicate);
    
  3. ローカル述語ビルダー:

    Func<DateTime, Func<TItem, bool>> builder = d => item => item.IsWithinRange(d);
    return this.items.FirstOrDefault(builder(day));
    
  4. ローカル述語ビルダーとローカル述語変数:

    Func<DateTime, Func<TItem, bool>> builder = d => item => item.IsWithinRange(d);
    Func<TItem, bool> predicate = builder(day);
    return this.items.FirstOrDefault(predicate);
    
  5. クラスレベル(静的またはインスタンス)述語ビルダー:

    return this.items.FirstOrDefault(classLevelBuilder(day));
    
  6. 外部で定義され、メソッドパラメータとして提供される述語

    public TItem GetItem(Func<TItem, bool> predicate)
    {
        return this.items.FirstOrDefault(predicate);
    }
    

    このメソッドを実行するとき、私は2つのアプローチも取りました。

    1. forループ内のメソッド呼び出しで直接提供される述語:

      for (int i = 0; i < 1000000; i++)
      {
          linqLookup.GetItem(item => item.IsWithinRange(GetRandomDay()));
      }
      
    2. forループの外側で定義された述語ビルダー:

      Func<DateTime, Func<Ranger, bool>> builder = d => r => r.IsWithinRange(d);
      for (int i = 0; i < 1000000; i++)
      {
          linqLookup.GetItem(builder(GetRandomDay()));
      }
      

結果-何が最高のパフォーマンスを発揮するか

反復クラスを使用する場合の比較のために、それは約かかります。 770msランダムに生成された範囲で100万回のルックアップを実行します。

  1. 3つのローカル述語ビルダーは、コンパイラーに最適化されているため、通常の反復とほぼ同じ速度で実行されます。 800ms

  2. 6.2 forループの外側で定義された述語ビルダー:885ms

  3. 6.1 forループ内で定義された述語:1525ms

  4. 他のすべては4200ms-4360msの間のどこかにかかったため、使用できないと見なされます。

したがって、外部から頻繁に呼び出されるメソッドで述語を使用する場合は常に、ビルダーを定義して実行してください。これにより、最良の結果が得られます。

これについて私が最も驚いたのは、デリゲート(または述語)にこれほど多くの時間がかかる可能性があることです。

49
Robert Koritnik

Gabeの答え に加えて、GetPointDataへの呼び出しごとにデリゲートを再構築するコストが原因であるように見える違いを確認できます。

GetPointDataクラスのIterationRangeLookupSingleメソッドに1行追加すると、LinqRangeLookupSingleと同じクロールペースまで遅くなります。それを試してみてください:

// in IterationRangeLookupSingle<TItem, TKey>
public TItem GetPointData(DateTime point)
{
    // just a single line, this delegate is never used
    Func<TItem, bool> dummy = i => i.IsWithinRange(point);

    // the rest of the method remains exactly the same as before
    // ...
}

(コンパイラやジッターが上記で追加した余分なデリゲートを無視できない理由はわかりません。明らかに、デリゲート必要ですLinqRangeLookupSingleクラスで。)

考えられる回避策の1つは、LinqRangeLookupSingleで述語を作成して、pointが引数として渡されるようにすることです。つまり、デリゲートを作成する必要があるのは1回だけであり、GetPointDataメソッドが呼び出されるたびではありません。たとえば、次の変更によりLINQバージョンが高速化され、foreachバージョンとほぼ同等になります。

// in LinqRangeLookupSingle<TItem, TKey>
public TItem GetPointData(DateTime point)
{
    Func<DateTime, Func<TItem, bool>> builder = x => y => y.IsWithinRange(x);
    Func<TItem, bool> predicate = builder(point);

    return this.items.FirstOrDefault(predicate);
}
9
LukeH

ループ内のデリゲートの生成(特にメソッド呼び出しに対する非自明なループ)によって時間が追加される可能性があるため、LINQの表示が遅くなることがあります。代わりに、Finderをクラスから移動して、より汎用的にすることを検討することをお勧めします(キーセレクターが構築中のように)。

public class LinqLookup<TItem, TKey>
{
    private IList<Item> items = null;

    public IterationLookup(IEnumerable<TItem> items, Func<TItem, TKey> keySelector)
    {
        this.items = items.OrderByDescending(keySelector).ToList();
    }

    public TItem GetItem(Func<TItem, TKey> selector)
    {
        return this.items.FirstOrDefault(selector);
    }
}

反復コードでラムダを使用しないため、ループを通過するたびにデリゲートを作成する必要があるため、これは少し異なる可能性があります。通常、この時間は毎日のコーディングにとって重要ではなく、デリゲートを呼び出す時間は他のメソッド呼び出しよりも高くはありません。タイトなループでデリゲートを作成するだけで、少し余分な時間が追加される可能性があります。

この場合、デリゲートはクラスに対して変更されないため、ループしているコードの外部でデリゲートを作成でき、より効率的になります。

更新

実際、最適化を行わなくても、自分のマシンでリリースモードでコンパイルしても、5倍の違いは見られません。 ItemフィールドしかないDateTimeで1,000,000回のルックアップを実行し、リストに5,000個のアイテムが含まれています。もちろん、私のデータなどは異なりますが、デリゲートを抽象化すると、実際には時間が非常に近いことがわかります。

反復:14279ミリ秒、0.014279ミリ秒/呼び出し

linq w opt:17400ミリ秒、0.0174ミリ秒/呼び出し

これらの時差は非常にマイナーであり、LINQを使用することで読みやすさと保守性を向上させる価値があります。ただし、5倍の違いは見られません。そのため、テストハーネスには見られないものがあると思います。

14

次のようなループがあると仮定します。

for (int counter = 0; counter < 1000000; counter++)
{
    // execute this 1M times and time it 
    DateTime day = GetRandomDay(); 
    items.FirstOrDefault(i => i.IsWithinRange(day)); 
}

このループは、i.IsWithinRange呼び出しがdayにアクセスするために1,000,000個のラムダオブジェクトを作成します。ラムダが作成されるたびに、i.IsWithinRangeを呼び出すデリゲートが平均1,000,000 * items.Length/2回呼び出されます。これらの要素は両方ともforeachループには存在しません。そのため、明示的なループの方が高速です。

6
Gabe