WhereとSelectのパフォーマンスがSelectだけの理由


145

私はこのようなクラスを持っています:

public class MyClass
{
    public int Value { get; set; }
    public bool IsValid { get; set; }
}

実際にははるかに大きいですが、これは問題(奇妙さ)を再現します。

Valueインスタンスが有効なの合計を取得します。これまでのところ、これに対する2つの解決策を見つけました。

最初のものはこれです:

int result = myCollection.Where(mc => mc.IsValid).Select(mc => mc.Value).Sum();

ただし、2つ目は次のとおりです。

int result = myCollection.Select(mc => mc.IsValid ? mc.Value : 0).Sum();

最も効率的な方法を取得したいと思います。最初は、2番目のほうが効率的だと思いました。それから私の理論的な部分は「まあ、1つはO(n + m + m)、もう1つはO(n + n)です。最初の1つは無効が多いほどパフォーマンスが良く、2つ目はパフォーマンスが高いはずです。より少ない」。それらは同等に機能すると思いました。編集:そして@MartinはWhereとSelectが組み合わされていることを指摘したので、実際にはO(m + n)になるはずです。ただし、以下を見るとこれは関係ないようです。


だから私はそれをテストにかけました。

(100行以上なので、要旨として投稿した方がいいと思いました。)
結果は...興味深いものでした。

0%のタイ許容差で:

スケールは、約30ポイントでSelectWhereに支持されています。

How much do you want to be the disambiguation percentage?
0
Starting benchmarking.
Ties: 0
Where + Select: 65
Select: 36

2%のタイ許容差で:

同じですが、2%以内に収まるものもありました。これが最小の誤差範囲だと思います。SelectそしてWhere今、わずか20ポイントのリードがあります。

How much do you want to be the disambiguation percentage?
2
Starting benchmarking.
Ties: 6
Where + Select: 58
Select: 37

5%のタイ許容差で:

これが私の最大の誤差範囲であると私は言うでしょう。それはにとっては少し良くなりますがSelect、多くはありません。

How much do you want to be the disambiguation percentage?
5
Starting benchmarking.
Ties: 17
Where + Select: 53
Select: 31

10%のタイ許容差で:

これは私の誤差の範囲外ですが、私はまだ結果に興味があります。それが与えるのでSelectそしてWhereそれは今しばらく持ったという20ポイントのリード。

How much do you want to be the disambiguation percentage?
10
Starting benchmarking.
Ties: 36
Where + Select: 44
Select: 21

25%のタイ許容差で:

これは道であり、エラーの私のマージンのうち、しかし、ので、私は今でも、結果に興味があるSelectし、Where まだ(ほぼ)その20ポイントのリードを保ちます。いくつかの点でそれを上回っていると思われ、それが主導権を握っています。

How much do you want to be the disambiguation percentage?
25
Starting benchmarking.
Ties: 85
Where + Select: 16
Select: 0


今、私は20ポイントのリードは、それらが両方得るためにバインドされている途中、から来たことを推測しているの周りに同じ性能を。試してログに記録することはできますが、それを取り込むには大量の情報が必要になります。グラフの方がいいと思います。

それが私がしたことです。

選択vs選択と場所。

これは、Selectラインが安定している(予想される)ことと、Select + Whereラインが上昇する(予想される)ことを示しています。それはと合致しない理由しかし、何を私に困惑することでSelect、余分な列挙子がために作成されなければならなかったとして、実際に私は、以前の50よりも期待していた:50またはそれ以前のバージョンでSelectWhere。つまり、これは20ポイントのリードを示していますが、その理由を説明していません。これが私の質問の要点だと思います。

なぜこのように動作するのですか?信用すべきでしょうか?そうでない場合は、もう一方またはこれを使用する必要がありますか?


コメントで@KingKongが言及したように、Sumラムダをとるのオーバーロードを使用することもできます。したがって、私の2つのオプションは次のように変更されます。

最初:

int result = myCollection.Where(mc => mc.IsValid).Sum(mc => mc.Value);

第二:

int result = myCollection.Sum(mc => mc.IsValid ? mc.Value : 0);

少し短くしますが、次のようにします。

How much do you want to be the disambiguation percentage?
0
Starting benchmarking.
Ties: 0
Where: 60
Sum: 41
How much do you want to be the disambiguation percentage?
2
Starting benchmarking.
Ties: 8
Where: 55
Sum: 38
How much do you want to be the disambiguation percentage?
5
Starting benchmarking.
Ties: 21
Where: 49
Sum: 31
How much do you want to be the disambiguation percentage?
10
Starting benchmarking.
Ties: 39
Where: 41
Sum: 21
How much do you want to be the disambiguation percentage?
25
Starting benchmarking.
Ties: 85
Where: 16
Sum: 0

20ポイントのリードはまだあります。つまり、コメントで@Marcinによって指摘されたWhereとのSelect組み合わせとは関係ありません。

テキストの壁を読んでいただきありがとうございます。あなたが興味を持っている場合も、ここだ ExcelがでとることCSVをログに記録します修正版。


1
私はそれが合計とアクセスへのアクセスの高さに依存すると思いますmc.Value
Medinoc 2013

14
@ It'sNotALie。Where+ Selectは、入力コレクションに対して2つの分離された反復を引き起こしません。LINQ to Objectsは、それを1つの反復に最適化します。私のブログ投稿の
MarcinJuraszek 2013

4
面白い。配列のforループは、最高のLINQソリューションよりも10倍高速であることを指摘しておきます。したがって、perfを探している場合は、最初にLINQを使用しないでください。
usr

2
時々人々は実際の調査の後に尋ねます、これは1つの例の質問です:私はC#ユーザーではありませんホット質問リストから来ました
Grijesh Chauhan

2
@WiSaGaNそれは良い点です。ただし、これが分岐移動と条件付き移動によるものである場合、50%/ 50%で最も劇的な違いが見られます。ここでは、分岐が最も予測可能な端で最も劇的な違いが見られます。Whereがブランチであり、3項が条件付きの移動である場合、すべての要素が有効であるときにWhereの時間が戻ることが期待されますが、それが戻ることはありません。
John Tseng 2013

回答:


131

Selectセット全体を1回繰り返し、各項目について、条件付き分岐(有効性のチェック)と+操作を実行します。

Where+Select無効な要素をスキップする(そうしないyield)イテレータを作成+し、有効なアイテムに対してのみを実行します。

したがって、aのコストSelectは次のとおりです。

t(s) = n * ( cost(check valid) + cost(+) )

そしてのためにWhere+Select

t(ws) = n * ( cost(check valid) + p(valid) * (cost(yield) + cost(+)) )

どこ:

  • p(valid) リスト内のアイテムが有効である確率です。
  • cost(check valid) 有効性をチェックするブランチのコストです
  • cost(yield)whereイテレータの新しい状態を構築するコストですSelect。バージョンが使用する単純なイテレータよりも複雑です。

ご覧のとおり、指定されnたのSelectバージョンは定数ですが、Where+Selectバージョンはp(valid)変数としての線形方程式です。コストの実際の値は2つの線の交点を決定し、とはcost(yield)異なる可能性があるため、cost(+)必ずしもp(valid)= 0.5で交差するとは限りません。


34
(これまでのところ)質問に実際に対処する唯一の回答である+1は、回答を推測せず、「私も」を生成するだけではありません。統計学。
Binary Worrier 2013

4
技術的には、LINQメソッドは、「セット」ではなく、コレクション全体に対して1回実行される式ツリーを作成します。
スポーク2013

なにcost(append)?本当に良い答えですが、統計だけではなく、別の角度から見ています。
それはNotALieです。

5
Where何も作成せずsource、述語を満たすだけの場合、シーケンスから一度に1つの要素を返します。
MarcinJuraszek 2013

13
@Spoike- 式ツリーはここでは関係ありません。これはlinq-to-something-else(エンティティなど)ではなく、linq-to-objectsだからです。それは違いますIEnumerable.Select(IEnumerable, Func)IQueryable.Select(IQueryable, Expression<Func>)。コレクションを反復処理するまでLINQは「何もしない」というのは正しいことです。
Kobi

33

タイミングの違いの原因について詳しく説明します。


Sum()関数はIEnumerable<int>次のようになります。

public static int Sum(this IEnumerable<int> source)
{
    int sum = 0;
    foreach(int item in source)
    {
        sum += item;
    }
    return sum;
}

C#では、foreach.Netバージョンのイテレータの構文上のシュガーです(と混同しないでください。したがって、上記のコードは実際には次のように変換されます。IEnumerator<T> IEnumerable<T>

public static int Sum(this IEnumerable<int> source)
{
    int sum = 0;

    IEnumerator<int> iterator = source.GetEnumerator();
    while(iterator.MoveNext())
    {
        int item = iterator.Current;
        sum += item;
    }
    return sum;
}

比較している2行のコードは次のとおりです。

int result1 = myCollection.Where(mc => mc.IsValid).Sum(mc => mc.Value);
int result2 = myCollection.Sum(mc => mc.IsValid ? mc.Value : 0);

今ここにキッカーがあります:

LINQは遅延実行を使用します。したがって、コレクションに対して2回繰り返されるように見えることがありますresult1実際には1回だけ繰り返されます。Where()条件は、実際に中に適用されるSum()の呼び出しの、内側MoveNext() (これはの魔法のおかげで可能ですyield return

これはresult1whileループ内のコードについて、

{
    int item = iterator.Current;
    sum += item;
}

を使用して各アイテムに対して1回だけ実行されますmc.IsValid == true。比較result2すると、コレクション内のすべてのアイテムに対してそのコードを実行します。そのためresult1、一般的に高速です。

(ただし、Where()内で条件を呼び出すと、MoveNext()多少のオーバーヘッドが発生するため、ほとんどまたはすべてのアイテムにがあるmc.IsValid == true場合、result2実際には高速になります!)


うまくいけば、なぜresult2通常は遅いのかは明らかです。では、なぜこれらのLINQパフォーマンスの比較は重要ではないとコメントで述べたのかを説明したいと思います。

LINQ式の作成は簡単です。デリゲート関数の呼び出しは安価です。イテレータの割り当てとループは安価です。しかし、これらのことを行わないほうが安上がりです。したがって、LINQステートメントがプログラムのボトルネックであることがわかった場合、私の経験では、LINQなしでそれを書き直すと、さまざまなLINQメソッドよりも常に高速になります。

したがって、LINQワークフローは次のようになります。

  1. どこでもLINQを使用します。
  2. プロフィール。
  3. LINQがボトルネックの原因であるとプロファイラーが言った場合は、LINQなしでそのコードを書き直します。

幸い、LINQのボトルネックはまれです。一体、ボトルネックはまれです。私は過去数年で数百のLINQステートメントを記述しており、1%未満に置き換わっています。そして、それらのほとんどは、LINQのせいではなく、LINQ2EFのSQL最適化が不十分なためでした

したがって、いつものように、明確で実用的なコードを最初に記述し、プロファイリングが完了するまで待ってからマイクロ最適化について心配してください。


3
小さな補遺:上位の回答が修正されました。
それはNotALieです。

16

面白いこと。どのようにSum(this IEnumerable<TSource> source, Func<TSource, int> selector)定義されているか知っていますか?方法を使うSelect

public static int Sum<TSource>(this IEnumerable<TSource> source, Func<TSource, int> selector)
{
    return source.Select(selector).Sum();
}

したがって、実際には、すべてがほぼ同じように機能するはずです。私は自分で簡単に調査しましたが、結果は次のとおりです。

Where -- mod: 1 result: 0, time: 371 ms
WhereSelect -- mod: 1  result: 0, time: 356 ms
Select -- mod: 1  result 0, time: 366 ms
Sum -- mod: 1  result: 0, time: 363 ms
-------------
Where -- mod: 2 result: 4999999, time: 469 ms
WhereSelect -- mod: 2  result: 4999999, time: 429 ms
Select -- mod: 2  result 4999999, time: 362 ms
Sum -- mod: 2  result: 4999999, time: 358 ms
-------------
Where -- mod: 3 result: 9999999, time: 441 ms
WhereSelect -- mod: 3  result: 9999999, time: 452 ms
Select -- mod: 3  result 9999999, time: 371 ms
Sum -- mod: 3  result: 9999999, time: 380 ms
-------------
Where -- mod: 4 result: 7500000, time: 571 ms
WhereSelect -- mod: 4  result: 7500000, time: 501 ms
Select -- mod: 4  result 7500000, time: 406 ms
Sum -- mod: 4  result: 7500000, time: 397 ms
-------------
Where -- mod: 5 result: 7999999, time: 490 ms
WhereSelect -- mod: 5  result: 7999999, time: 477 ms
Select -- mod: 5  result 7999999, time: 397 ms
Sum -- mod: 5  result: 7999999, time: 394 ms
-------------
Where -- mod: 6 result: 9999999, time: 488 ms
WhereSelect -- mod: 6  result: 9999999, time: 480 ms
Select -- mod: 6  result 9999999, time: 391 ms
Sum -- mod: 6  result: 9999999, time: 387 ms
-------------
Where -- mod: 7 result: 8571428, time: 489 ms
WhereSelect -- mod: 7  result: 8571428, time: 486 ms
Select -- mod: 7  result 8571428, time: 384 ms
Sum -- mod: 7  result: 8571428, time: 381 ms
-------------
Where -- mod: 8 result: 8749999, time: 494 ms
WhereSelect -- mod: 8  result: 8749999, time: 488 ms
Select -- mod: 8  result 8749999, time: 386 ms
Sum -- mod: 8  result: 8749999, time: 373 ms
-------------
Where -- mod: 9 result: 9999999, time: 497 ms
WhereSelect -- mod: 9  result: 9999999, time: 494 ms
Select -- mod: 9  result 9999999, time: 386 ms
Sum -- mod: 9  result: 9999999, time: 371 ms

以下の実装の場合:

result = source.Where(x => x.IsValid).Sum(x => x.Value);
result = source.Select(x => x.IsValid ? x.Value : 0).Sum();
result = source.Sum(x => x.IsValid ? x.Value : 0);
result = source.Where(x => x.IsValid).Select(x => x.Value).Sum();

modつまり、すべての1からのmodアイテムが無効です。mod == 1すべてのアイテムが無効である、mod == 2奇数のアイテムが無効である、などです。コレクションには10000000アイテムが含まれます。

ここに画像の説明を入力してください

そして、100000000アイテムのコレクションの結果:

ここに画像の説明を入力してください

ご覧のとおりSelectSum結果はすべてのmod値で非常に一貫しています。ただしwherewhere+ selectはそうではありません。


1
It'sNotALieの結果が中央で交差するのに対して、結果ではすべてのメソッドが同じ場所から始まり、分岐するのが非常に興味深いです。
John Tseng

6

私の推測では、Whereのバージョンは0を除外し、それらはSumの対象ではありません(つまり、加算を実行していません)。追加のラムダ式の実行と複数のメソッドの呼び出しが単純な0の追加よりどのように機能するかを説明できないので、これはもちろん推測です。

私の友人は、合計の0がオーバーフローチェックのために深刻なパフォーマンスペナルティを引き起こす可能性があるという事実を提案しました。これがチェックされていない状況でどのように機能するかを見るのは興味深いでしょう。


を使用した一部のテストでuncheckedは、に少しだけ優れていSelectます。
それはNotALieです。

チェックされていないスタックに呼び出されているメソッド、または最上位の操作のみに影響があるかどうか誰かが言うことができますか?
スティルガー2013

1
@Stilgarこれはトップレベルにのみ適用されます。
Branko Dimitrijevic

したがって、チェックされていないSumを実装して、この方法で試す必要があるかもしれません。
スティルガー2013

5

次のサンプルを実行すると、Where + SelectがSelectよりも優れたパフォーマンスを発揮できるのは、実際にはリスト内の潜在的なアイテムのかなりの量(私の非公式のテストでは約半分)を破棄している場合です。以下の小さな例では、Whereが10milのうち約4milの項目をスキップするときに、両方のサンプルからほぼ同じ数を取得します。私はリリースで実行し、where + selectとselectの実行を同じ順序で並べ替えました。

static void Main(string[] args)
        {
            int total = 10000000;
            Random r = new Random();
            var list = Enumerable.Range(0, total).Select(i => r.Next(0, 5)).ToList();
            for (int i = 0; i < 4000000; i++)
                list[i] = 10;

            var sw = new Stopwatch();
            sw.Start();

            int sum = 0;

            sum = list.Where(i => i < 10).Select(i => i).Sum();            

            sw.Stop();
            Console.WriteLine(sw.ElapsedMilliseconds);

            sw.Reset();
            sw.Start();
            sum = list.Select(i => i).Sum();            

            sw.Stop();

            Console.WriteLine(sw.ElapsedMilliseconds);
        }

の10未満の数字を破棄しないためではないSelectでしょうか。
それはNotALieです。

3
デバッグで実行しても意味がありません。
MarcinJuraszek 2013

1
@MarcinJuraszek明らかに。本当に私がリリースで走ったと言うことを意味しました:)
DavidN 2013

@ It'sNotALieそれがポイントです。Where + SelectがSelectよりも優れたパフォーマンスを発揮できる唯一の方法は、Whereが合計されている大量のアイテムをフィルターで除外することであるように思えます。
DavidN 2013

2
それは基本的に私の質問が述べていることです。このサンプルのように、約60%で結びます。問題はここに答えられない理由です。
それはNotALieです。

4

速度が必要な場合は、単純なループを行うだけがおそらく最善の策です。そして、行うforことはより良い傾向がありforeachます(もちろん、コレクションがランダムアクセスであると仮定します)。

以下は、要素の10%が無効になるタイミングです。

Where + Select + Sum:   257
Select + Sum:           253
foreach:                111
for:                    61

そして90%の無効な要素で:

Where + Select + Sum:   177
Select + Sum:           247
foreach:                105
for:                    58

そして、これが私のベンチマークコードです...

public class MyClass {
    public int Value { get; set; }
    public bool IsValid { get; set; }
}

class Program {

    static void Main(string[] args) {

        const int count = 10000000;
        const int percentageInvalid = 90;

        var rnd = new Random();
        var myCollection = new List<MyClass>(count);
        for (int i = 0; i < count; ++i) {
            myCollection.Add(
                new MyClass {
                    Value = rnd.Next(0, 50),
                    IsValid = rnd.Next(0, 100) > percentageInvalid
                }
            );
        }

        var sw = new Stopwatch();
        sw.Restart();
        int result1 = myCollection.Where(mc => mc.IsValid).Select(mc => mc.Value).Sum();
        sw.Stop();
        Console.WriteLine("Where + Select + Sum:\t{0}", sw.ElapsedMilliseconds);

        sw.Restart();
        int result2 = myCollection.Select(mc => mc.IsValid ? mc.Value : 0).Sum();
        sw.Stop();
        Console.WriteLine("Select + Sum:\t\t{0}", sw.ElapsedMilliseconds);
        Debug.Assert(result1 == result2);

        sw.Restart();
        int result3 = 0;
        foreach (var mc in myCollection) {
            if (mc.IsValid)
                result3 += mc.Value;
        }
        sw.Stop();
        Console.WriteLine("foreach:\t\t{0}", sw.ElapsedMilliseconds);
        Debug.Assert(result1 == result3);

        sw.Restart();
        int result4 = 0;
        for (int i = 0; i < myCollection.Count; ++i) {
            var mc = myCollection[i];
            if (mc.IsValid)
                result4 += mc.Value;
        }
        sw.Stop();
        Console.WriteLine("for:\t\t\t{0}", sw.ElapsedMilliseconds);
        Debug.Assert(result1 == result4);

    }

}

ところで、私はスティルガーの推測に同意します。2つのケースの相対的な速度は、無効なアイテムのパーセンテージSumによって異なります。これは、「Where」のケースではジョブの実行量が異なるためです。


1

説明で説明するのではなく、より数学的なアプローチをとります。

LINQが内部で行っていることを概算する以下のコードを考えると、相対的なコストは次のとおりです。
選択のみ:Nd + Na
場所+選択:Nd + Md + Ma

それらが交差するポイントを理解するには、少し代数を実行する必要があります。
Nd + Md + Ma = Nd + Na => M(d + a) = Na => (M/N) = a/(d+a)

つまり、変曲点を50%にするには、デリゲート呼び出しのコストが追加のコストとほぼ同じである必要があります。実際の変曲点が約60%であることがわかっているので、逆に作業して、@ It'sNotALieのデリゲート呼び出しのコストが、実際には追加のコストの約2/3であると判断できますが、これは驚くべきことです。彼の数は言う。

static void Main(string[] args)
{
    var set = Enumerable.Range(1, 10000000)
                        .Select(i => new MyClass {Value = i, IsValid = i%2 == 0})
                        .ToList();

    Func<MyClass, int> select = i => i.IsValid ? i.Value : 0;
    Console.WriteLine(
        Sum(                        // Cost: N additions
            Select(set, select)));  // Cost: N delegate
    // Total cost: N * (delegate + addition) = Nd + Na

    Func<MyClass, bool> where = i => i.IsValid;
    Func<MyClass, int> wSelect = i => i.Value;
    Console.WriteLine(
        Sum(                        // Cost: M additions
            Select(                 // Cost: M delegate
                Where(set, where),  // Cost: N delegate
                wSelect)));
    // Total cost: N * delegate + M * (delegate + addition) = Nd + Md + Ma
}

// Cost: N delegate calls
static IEnumerable<T> Where<T>(IEnumerable<T> set, Func<T, bool> predicate)
{
    foreach (var mc in set)
    {
        if (predicate(mc))
        {
            yield return mc;
        }
    }
}

// Cost: N delegate calls
static IEnumerable<int> Select<T>(IEnumerable<T> set, Func<T, int> selector)
{
    foreach (var mc in set)
    {
        yield return selector(mc);
    }
}

// Cost: N additions
static int Sum(IEnumerable<int> set)
{
    unchecked
    {
        var sum = 0;
        foreach (var i in set)
        {
            sum += i;
        }

        return sum;
    }
}

0

MarcinJuraszekの結果がIt'sNotALieの結果と異なるのは興味深いと思います。特に、MarcinJuraszekの結果は、4つのすべての実装が同じ場所にある場合に始まりますが、It'sNotALieの結果は中間に渡ります。これがソースからどのように機能するかを説明します。

n合計要素とm有効な要素があると仮定しましょう。

Sum機能は非常に単純です。列挙子をループするだけです:http : //typedescriptor.net/browse/members/367300-System.Linq.Enumerable.Sum(IEnumerable%601)

簡単にするために、コレクションがリストであると仮定しましょう。SelectWhereSelectの両方がを作成しWhereSelectListIteratorます。つまり、生成される実際のイテレータは同じです。どちらの場合も、Sum反復子であるをループするがありますWhereSelectListIterator。イテレータの最も興味深い部分はMoveNextメソッドです。

イテレータは同じなので、ループは同じです。唯一の違いは、ループの本体です。

これらのラムダの本体のコストは非常に似ています。where句はフィールド値を返し、3項述語もフィールド値を返します。select句はフィールド値を返し、3項演算子の2つのブランチはフィールド値または定数を返します。結合されたselect句には、3項演算子としてブランチがありますが、Wh​​ereSelectはでブランチを使用しMoveNextます。

ただし、これらの操作はすべてかなり安価です。これまでで最も費用のかかる操作はブランチであり、間違った予測によりコストがかかります。

ここでのもう1つの高価な操作はInvokeです。Branko Dimitrijevicが示したように、関数の呼び出しには値を追加するよりもかなり時間がかかります。

また、量り込みは、のチェック済み累積ですSum。プロセッサに算術オーバーフローフラグがない場合、これもチェックにコストがかかる可能性があります。

したがって、興味深いコストは次のとおりです。

  1. n+ m)*呼び出す+ m*checked+=
  2. n*呼び出し+ n*checked+=

したがって、Invokeのコストがチェック済み累積のコストよりもはるかに高い場合、ケース2の方が常に優れています。それらがほぼ均等であれば、要素の約半分が有効なときにバランスが表示されます。

MarcinJuraszekのシステムでは、checked + =のコストはごくわずかですが、It'sNotALieおよびBranko Dimitrijevicのシステムでは、checked + =のコストがかなり高くなっています。ブレイクイーブンポイントがはるかに高いため、It'sNotALieのシステムでは最も高価なようです。累積がInvokeよりもはるかに高いシステムからの結果を投稿した人はいないようです。


@ It'sNotALie。誰かが間違った結果を持っているとは思いません。私はいくつかのことを説明することができませんでした。Invokeのコストは+ =のコストよりもはるかに高いと想定していましたが、ハードウェアの最適化によっては、はるかに近くなる可能性があると考えられます。
John Tseng 2013
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.