为什么在哪里和选择优于仅选择?


145

我有一堂课,像这样:

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

实际上,它要大得多,但这会重现问题(古怪)。

我想获取Value实例有效的的总和。到目前为止,我已经找到了两种解决方案。

第一个是这样的:

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

第二个是:

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

我想得到最有效的方法。首先,我认为第二个会更有效。然后我的理论部分开始说:“好,一个是O(n + m + m),另一个是O(n + n)。第一个应该表现更好,有更多的无效项,而第二个应该表现更好。少”。我认为他们会表现一样。编辑:然后@Martin指出Where和Select相结合,因此它实际上应该是O(m + n)。但是,如果您查看下面的内容,似乎与此无关。


所以我进行了测试。

(这是100多行,所以我认为最好将它发布为要点。)
结果很有趣。

领带公差为0%:

鳞片都在赞成SelectWhere,约〜30分。

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

领带公差为2%:

相同,除了一些误差在2%以内。我会说这是最小误差范围。SelectWhere现在只是一个〜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%:

这超出了我的误差范围,但是我仍然对结果感兴趣。因为它给出了SelectWhere二十一点导致它已经有一段时间了。

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

领带公差为25%:

这是方式,方法我的误差范围的,但我仍然有兴趣的结果,因为SelectWhere 仍然(几乎)保持其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,因为必须为Selectand 创建一个额外的枚举数Where。我的意思是,这表明领先20分,但并不能解释为什么。我想这是我的问题的重点。

为什么会这样呢?我应该相信吗?如果没有,我应该使用另一个还是另一个?


正如@KingKong在评论中提到的,您还可以使用Sum需要一个lambda的重载。所以现在我的两个选项更改为:

第一:

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在评论中指出的Whereand Select组合无关。

感谢您阅读我的文字墙!另外,如果您有兴趣,这里是修改版本,用于记录Excel接收的CSV。


1
我会说这取决于总和和访问的成本mc.Value
Medinoc

14
@ It'sNotALie。Where+ Select不会在输入集合上引起两个单独的迭代。LINQ to Objects将其优化为一个迭代。在我的博客文章中
MarcinJuraszek,2013年

4
有趣。我只想指出,数组上的for循环比最佳LINQ解决方案快10倍。因此,如果您追求性能,请不要首先使用LINQ。
usr

2
有时人们经过真正的研究后问,这是一个示例问题:我不是C#用户来自热门问题列表。
2013年

2
@WiSaGaN很好。但是,如果这是由于分支与条件转移引起的,则我们期望看到最大的差异是50%/ 50%。在这里,我们看到了端头最明显的差异,分支是最可预测的。如果Where是一个分支,而三元是有条件的移动,那么我们希望当所有元素都有效时Where时间会回落,但从不回落。
曾俊

Answers:


131

Select遍历整个集合一次,并针对每个项目执行条件分支(检查有效性)和+操作。

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版本使用的简单迭代器更为复杂。

如您所见,对于给定的nSelect版本是常数,而Where+Select版本是带有p(valid)变量的线性方程。成本的实际值确定了两条线的交点,并且由于cost(yield)可能与不同cost(+),因此它们不一定在p(valid)= 0.5 处相交。


34
+1是到目前为止(实际上)真正解决问题的唯一答案,不会猜测答案,也不会产生“我也是!” 统计。
Binary Worrier 2013年

4
从技术上讲,LINQ方法创建的表达式树将在整个集合中运行一次,而不是“集合”。
Spoike

什么cost(append)啊 确实,这是一个很好的答案,它从不同的角度来看,而不仅仅是统计。
不是。

5
Where不会创建任何东西,仅在source填充谓词时才从序列中返回一个元素。
MarcinJuraszek

13
@Spoike- 表达式树在这里不相关,因为这是linq-to-objects,而不是linq-to-something-else(例如Entity)。这之间的区别IEnumerable.Select(IEnumerable, Func)IQueryable.Select(IQueryable, Expression<Func>)。没错,在您遍历集合之前,LINQ不会做任何事情,这可能就是您的意思。
科比

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;
}

请记住,您正在比较的两行代码如下

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

现在这是踢球者:

LINQ使用延迟执行。因此,尽管它可能出现的是result1在集合迭代两次,它实际上只是遍历一次。Where()条件实际上Sum()是在调用的期间应用的MoveNext() (由于的魔力,这是可能的yield return

这意味着,对于result1while循环内的代码,

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

对于的每个项目仅执行一次mc.IsValid == true。相比之下,result2将对集合中的每个项目执行该代码。这就是为什么result1通常更快。

(不过,请注意,在其中调用Where()条件MoveNext()仍会产生少量开销,因此,如果大多数/所有项目都具有mc.IsValid == trueresult2实际上会更快!)


希望现在很清楚为什么result2通常会变慢。现在我要解释为什么我在评论中指出这些LINQ性能比较无关紧要

创建LINQ表达式很便宜。调用委托函数很便宜。在迭代器上分配和循环很便宜。但它甚至更便宜地没有做这些事情。因此,如果您发现LINQ语句是程序的瓶颈,以我的经验,不使用LINQ 进行重写将始终使其比各种LINQ方法中的任何一种都快。

因此,您的LINQ工作流程应如下所示:

  1. 随处使用LINQ。
  2. 个人资料。
  3. 如果分析器说LINQ是造成瓶颈的原因,请在不使用LINQ的情况下重写该代码。

幸运的是,LINQ瓶颈很少见。瓶颈,很少见。在过去的几年中,我已经编写了数百条LINQ语句,但最终取代了<1%。而大多数的那些是由于LINQ2EF的SQL优化不佳,而不是LINQ的错。

因此,像往常一样,首先编写清晰而明智的代码,然后等到进行概要分析后再担心微优化。


3
小附录:固定答案。
不是。

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表示:mod项目中的每1 个都是无效的:对于mod == 1每个项目都是无效的,对于mod == 2奇数项目也是无效的,等等。集合包含10000000项目。

在此处输入图片说明

以及用于收集100000000项目的结果:

在此处输入图片说明

正如你所看到的,Select并且Sum结果是所有相当一致mod的值。但是wherewhere+ select不是。


1
有趣的是,在您的结果中,所有方法都从同一位置开始并且有所不同,而It'sNotALie的结果则在中间交叉。
曾俊

6

我的猜测是,其中Where的版本会滤除0,并且它们不是求和的主题(即,您不执行加法运算)。这当然是个猜测,因为我无法解释执行额外的lambda表达式并调用多个方法如何胜过简单地加0。

我的一个朋友建议总和为0的事实可能会由于溢出检查而导致严重的性能损失。看看这在未经检查的上下文中如何执行将很有趣。


进行了一些测试,unchecked使其性能略有提高Select
不是。

有人可以说是否未选中会影响被调用的方法或仅影响顶层操作的方法?
Stilgar

1
@Stilgar它仅适用于顶层。
Branko Dimitrijevic

因此,也许我们需要实现未经检查的Sum并尝试这种方式。
斯蒂尔加

5

通过运行以下示例,我清楚地知道,其中+选择只能胜过选择的事实实际上是当它丢弃了列表中潜在项目的大量(在我的非正式测试中约为一半)。在下面的小示例中,当Where从1000万中跳过大约400万项目时,我从两个样本中得到的数字大致相同。我参加了发布会,并重新排序了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吗?
不是。

3
在调试中运行是没有用的。
MarcinJuraszek

1
@MarcinJuraszek显然。真的是要说我已经发布了:)
DavidN

@ItsNotALie这就是重点。在我看来,Where + Select胜过Select的唯一方法是在Where过滤掉大量求和项时。
DavidN

2
这基本上就是我的问题。像该样本一样,它们的领带比例约为60%。问题是为什么,这里没有回答。
不是。

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);

    }

}

顺便说一句,我同意Stilgar的猜测:您两种情况的相对速度取决于无效项目的百分比,这仅仅是因为Sum“ Where”情况下需要完成的工作量不同。


1

与其尝试通过描述进行解释,不如说我将采用一种更数学的方法。

给定下面的代码,该代码应该近似于LINQ在内部所做的工作,相对成本如下:
仅选择:Nd + Na
Where + Select: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的结果从所有四个实现都在同一位置开始,而It'sNotALie的结果则在中间出现。我将从源头解释这是如何工作的。

让我们假设存在n总计元素和m有效元素。

Sum功能非常简单。它只是遍历枚举器:http : //typedescriptor.net/browse/members/367300-System.Linq.Enumerable.Sum(IEnumerable%601)

为了简单起见,我们假设该集合是一个列表。两个选择WhereSelect将创建一个WhereSelectListIterator。这意味着生成的实际迭代器是相同的。在这两种情况下,都有一个Sum循环遍历迭代器的WhereSelectListIterator。迭代器中最有趣的部分是MoveNext方法。

由于迭代器相同,因此循环相同。唯一的区别在于循环的主体。

这些lambda的主体具有非常相似的成本。where子句返回一个字段值,三元谓词也返回一个字段值。select子句返回一个字段值,三元运算符的两个分支返回一个字段值或一个常数。组合的select子句将分支作为三元运算符,但是WhereSelect使用中的分支MoveNext

但是,所有这些操作都相当便宜。到目前为止,最昂贵的操作是分支机构,错误的预测将使我们付出代价。

这是另一个昂贵的操作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的成本要比+ =高得多,但是可以想象,根据硬件的优化,Invoke的成本可能更高。
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.