为什么 Where 和 Select 的表现优于 Select?

我有一堂课,像这样:

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分。

您希望消除歧义的百分比是多少?
0
开始基准测试。
平局: 0
在哪里 + 选择: 65
选择: 36

领带宽度为2% :

这是相同的,除了一些,他们是在2% 之内。我认为这是最小误差范围。SelectWhere现在只领先约20个百分点。

您希望消除歧义的百分比是多少?
2
开始基准测试。
平局: 6
在哪里 + 选择: 58
选择: 37

领带宽度5% :

这是我的最大误差范围。它使它有点更好的 Select,但不多。

您希望消除歧义的百分比是多少?
5
开始基准测试。
平局: 17
在哪里 + 选择: 53
选择: 31

领带宽度为10% :

这超出了我的误差范围,但我仍然对结果感兴趣。因为它给了 SelectWhere20分的领先优势。

您希望消除歧义的百分比是多少?
10
开始基准测试。
平局: 36
在哪里 + 选择: 44
选择: 21

领带宽度为25% :

这是方式,方式出我的误差范围,但我仍然感兴趣的结果,因为 SelectWhere 还是(几乎)保持他们的20点领先。它似乎在很多方面都超越了它,这就是它领先的原因。

您希望消除歧义的百分比是多少?
25
开始基准测试。
平局: 85
在哪里 + 选择: 16
选择: 0


现在,我猜测20分的领先优势来自中间,在那里他们都必须得到 在附近相同的表现。我可以试着记录下来,但是要接收的信息太多了。我觉得用图表比较好。

所以我就这么做了。

Select vs Select and Where.

它表明,Select线保持稳定(预期) ,而 Select + Where线攀升(预期)。然而,让我感到困惑的是为什么它不能在50或更早的时候遇到 Select: 事实上,我预计它会早于50,因为必须为 SelectWhere创建一个额外的枚举器。我的意思是,这显示了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);

我会把它缩短一点,但是:

您希望消除歧义的百分比是多少?
0
开始基准测试。
平局: 0
哪里60
总和: 41
您希望消除歧义的百分比是多少?
2
开始基准测试。
平局: 8
哪里55
总和: 38
您希望消除歧义的百分比是多少?
5
开始基准测试。
平局: 21
哪里: 49
总和: 31
您希望消除歧义的百分比是多少?
10
开始基准测试。
平局: 39
哪里41号
总和: 21
您希望消除歧义的百分比是多少?
25
开始基准测试。
平局: 85
哪里16号
总和: 0

20分的领先优势依然存在,这意味着它与@Marcin 在评论中指出的 WhereSelect的组合无关。

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

5265 次浏览

我猜测 Where 版本过滤掉了0,它们不是 Sum 的主题(也就是说不执行加法)。这当然是一种猜测,因为我无法解释执行额外的 lambda 表达式和调用多个方法的性能如何优于简单地添加0。

我的一个朋友建议,事实上,总和中的0可能会导致严重的性能惩罚,因为溢出检查。看看这在未检查的上下文中如何执行会很有趣。

运行下面的示例,我很清楚,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);
}

有趣的是,你知道 Sum(this IEnumerable<TSource> source, Func<TSource, int> selector)是如何定义的吗

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

enter image description here

以及收集 100000000项目的结果:

enter image description here

正如您所看到的,SelectSum的结果在所有 mod值之间是相当一致的。但是 wherewhere + select不是。

如果你需要速度,做一个简单的循环可能是你最好的选择。而且,执行 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);


}


}

顺便说一句,我同意 斯蒂尔加的猜测: 你的两个案件的相对速度变化取决于百分比的无效项目,只是因为工作量 Sum需要做的变化在“在哪里”的情况下。

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

Where+Select创建一个迭代器,它跳过无效的元素(不是 yield元素) ,只对有效的元素执行 +

因此,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处相交。

以下是对造成时间差异的原因的深入解释。


IEnumerable<int>Sum()函数如下:

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> (不要与“ http://msdn.microsoft.com/en-us/library/9eekhta0.aspx”rel = “ nofollow noReferrer”> 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通常更快。

(但是,请注意,在 MoveNext()中调用 Where()条件仍然有一些小的开销,所以如果大多数/所有项目都有 mc.IsValid == true,那么 result2实际上会更快!)


希望现在我们能够明白为什么 result2通常比较慢。现在我想解释一下为什么我在评论中说 这些 LINQ 性能比较并不重要

创建 LINQ 表达式成本低廉。调用委托函数很便宜。在迭代器上分配和循环是廉价的。但是 没有做这些事情更便宜。因此,如果您发现 LINQ 语句是程序中的瓶颈,根据我的经验,在不使用 LINQ 的情况下重写它将使 一直都是比任何不同的 LINQ 方法都要快。

因此,您的 LINQ 工作流应该是这样的:

  1. 到处使用 LINQ。
  2. 侧写。
  3. 如果探查器说 LINQ 是瓶颈的原因,重写那段没有 LINQ 的代码。

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

因此,像往常一样,首先编写清晰明了的代码,然后等到 之后完成剖析之后再考虑微优化问题。

我认为 MarcinJuraszek 的结果与 It‘ sNotALie 的不同很有趣。特别是,MarcinJuraszek 的结果开始时所有四个实现都在同一个地方,而 It‘ sNotALie 的结果在中间交叉。我会从源头解释这是如何运作的。

假设有 n总元素和 m有效元素。

Sum函数非常简单,它只是循环遍历枚举器: Http://typedescriptor.net/browse/members/367300-system 可数总和(可数% 601)

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

因为迭代器是相同的,所以循环也是相同的。唯一的区别在于环的主体。

这些小羊羔的身体有着非常相似的成本。Where 子句返回一个字段值,三元谓词也返回一个字段值。Select 子句返回一个字段值,三元运算符的两个分支返回一个字段值或常量。组合的 select 子句将分支作为三元运算符,但是 WHERE Select 使用 MoveNext中的分支。

然而,所有这些操作都相当便宜。到目前为止,最昂贵的操作是分行,一个错误的预测会让我们付出代价。

另一个昂贵的操作是 Invoke。调用一个函数比添加一个值要花费更长的时间,正如 Branko Dimitrijevic 所展示的那样。

同时称重的还有在 Sum中被检查的累积。如果处理器没有算术溢出标志,那么检查的成本也会很高。

因此,有趣的成本是: 是:

  1. (n + m) * 调用 + m * checked+=
  2. 调用 + n * checked+=

因此,如果 Invoke 的成本大大高于检查累积的成本,那么情况2总是更好。如果它们是偶数,那么当大约一半的元素是有效的时候,我们将看到一个平衡。

看起来在 MarcinJuraszek 的系统上,勾选 + = 的成本可以忽略不计,但在 It‘ sNotALie 和 Branko Dimitrijevic 的系统上,勾选 + = 的成本很高。看起来它是 It‘ sNotALie 系统中最贵的,因为盈亏平衡点要高得多。看起来没有任何人在一个累积成本比 Invoke 高得多的系统上发布过结果。

与其试图通过描述来解释,我将采取一种更加数学化的方法。

考虑到下面的代码应该接近 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;
}
}