LINQ 性能测试:我的第一个 Visual Studio 2008 项目
一个示例 Visual Studio 2008 项目,
引言
本文将讨论 LINQ 循环和常规 For 循环之间的性能差异,以此来初次实践使用 Visual Studio 2008、Linq 和单元测试。
背景
LINQ(语言集成查询)是微软为 .NET 语言新增的功能,允许使用类似 SQL 的语法来构造查询。它在遍历数据集、XML DOM 树和集合时特别有用。
我第一次接触 Visual Studio 2008,特别是 LINQ,是在今年的奥兰多 Tech Ed 大会上。
一位来自微软的出色开发人员为我描述并演示了它(当时它只适用于 VB.NET,但 C# 版本随 beta 2 一同发布)。那位开发人员坚称,LINQ 不仅适用于数据集或复杂对象,还可以用于简单的循环。
我决定将他的理论付诸实践,以此来学习这项新技术:我写了一个简单的程序,用于在数组中搜索奇数,并比较了常规循环和 Linq 循环找出正确答案所花费的时间。
虽然这篇文章是几个月前写的,但我一直等到 VS 2008 和 .NET 3.5 的 RTM 版本发布后才发表。
在此过程中,我更深入地学习了 LINQ 并使用了 VS 2008 的单元测试功能。
随着项目的发展,我加入了第三种循环(ForEach)。然后我决定将所有结果输出到 CSV 文件中,并在 Excel 中分析结果。
逻辑
- 该程序分配一个包含 n 个元素的数组,并用数字填充它。
- 然后它对 3 个函数中的每一个都调用
GetAverage
。 GetAverage
会调用每个函数 1000 次(可配置)。- 它测量一个函数遍历 n 个元素所需的时间,并计算平均值。
测量数据是使用 Daniel Strigl 的高性能系统计时器得出的。 - 每个函数针对 n 个元素的平均值都会被显示(或输出到文件)。
- 整个代码运行 5 次,以确保平均值的一致性。
Using the Code
这是一个最基础的应用程序。它作为控制台应用程序运行,没有用户界面。
其中唯一可配置的部分是:
numElements
- 数组中的元素数量numIterations
- 每个算法被调用的次数,用于计算平均值- 应用程序的输出可以到文件或标准输出(屏幕)——只需注释掉相应的代码行即可。
测试框架
这是 Main
函数
static void Main(string[] args)
{
StreamWriter file = new StreamWriter("results.txt");
file.WriteLine("Elements\tFor loop\tForEach Loop\tLinq Loop");
//Console.WriteLine("Elements\tFor loop\tForEach Loop\tLinq Loop");
for (int i = 0; i < 5; i++)
{
int numElements = 1000 * (int)Math.Pow(10, i);
FillArray(numElements);
file.WriteLine("{0:#,#}\t{1:0.0000000000}\t{2:0.0000000000}\t{3:0.0000000000}",
numElements, GetAverage(GetOdd), GetAverage(GetOddForEach), GetAverage(GetOddLinq));
//Console.WriteLine("{0:#,#}\t{1:0.0000000000}\t{2:0.0000000000}\t{3:0.0000000000}",
numElements, GetAverage(GetOdd), GetAverage(GetOddForEach), GetAverage(GetOddLinq));
}
//Console.ReadLine();
file.Close();
}
如你所见,它所做的只是调用 GetAverage
函数,并将算法函数作为参数传递。
GetAverage
如下所示
private static double GetAverage(func f)
{
double averageDuration = 0.0;
for (int i = 0; i < numIterations; i++)
{
pt.Start();
int odd = f();
pt.Stop();
//Console.WriteLine("Time difference: {0}", pt.Duration);
averageDuration += pt.Duration;
}
averageDuration /= numIterations;
return averageDuration;
}
如你所见,并不太复杂:它启动一个计时器,调用函数 f()
,停止计时器并累加时间。它这样做 numIterations
次,并返回平均值。我非常喜欢将函数名作为参数提交的想法,因为它抽象了设计,并让我在未来可以基于此进行扩展。
算法
本质上,所有 3 个函数都使用简单的 O(n) 搜索算法:GetOdd
是最直接的。
private static int GetOdd()
{
int counter = 0;
for(int n = 0; n < theArray.Length; n++)
{
if (theArray[n] % 2 == 1)
{
counter++;
}
}
return counter;
}
GetOddForEach
private static int GetOddForEach()
{
int counter = 0;
foreach (int n in theArray)
{
if (n % 2 == 1)
{
counter++;
}
}
return counter;
}
最后是使用新 Linq 语法的 GetOddLinq
private static int GetOddLinq()
{
var odd = from n in theArray
where n % 2 == 1
select n;
return odd.Count();
}
你首先会注意到新的关键字 var
(设计这个的人是不是想到了 JavaScript?)。它定义了一个新的 IEnumerable 集合,该集合将包含 LINQ 查询的结果。查询本身看起来有点像一个反向的 SQL 查询(select
在末尾),但仍然是可读的。
有关 Linq 语法和示例的更多信息,请访问官方 LINQ 项目页面。
结果
我已经在几台计算机和虚拟机上运行了这个程序。我在 Windows XP、Vista 和 2008 RC1 上都试过。我尝试在繁忙的机器上运行,或在完全空闲的机器上运行。最后,我测试了调试版本和发布版本。数字可能会变,但趋势保持不变。

测量单位是秒。E 列显示了 Linq 相对于 For 增加的时间百分比:Fi = (Di - Bi)/Di。
当然,一旦你有了原始数据,你就可以随心所欲地分析它,例如生成一个图表。

注意:如前所述,结果相当一致,Linq 在几乎每次测试中都有 75-85% 的开销。但在调试版本中,LINQ 完成任务所需的时间更长,而 For 和 ForEach 基本保持不变。
我唯一的猜测是,LINQ 内置了一些检测机制,以便于调试——因此它在调试版本中更慢。
单元测试
Tech Ed 的大量会议都致力于测试,特别是,在 VS 2008 中添加单元测试是多么容易。的确,这并不需要很长时间。在源代码的任何地方右键单击并选择“创建单元测试...”。一个向导将引导你选择项目中要测试的函数,并最终创建一个测试项目并将其添加到解决方案中。
测试项目已经准备好了正确的引用和一组访问器——允许单元测试函数访问原始类的所有成员——甚至是私有成员。
那么,你如何测试这段代码呢?这是创建数组的函数的单元测试。
/// <summary>
///A test for FillArray
///</summary>
[TestMethod()]
[DeploymentItem("LinqTest.exe")]
public void FillArrayTest()
{
int n = 10; // TODO: Initialize to an appropriate value
Program_Accessor.FillArray(n);
Assert.AreEqual(n, Program_Accessor.theArray.Length);
}
很简单,不是吗?本质上,你正在使用 Program_Accessor
来访问 LinqTest.exe 程序集。在为 n 个元素调用 FillArray 函数后,你断言数组的大小现在应该是 n。
现在,让我们测试其中一个搜索函数(所有函数的测试都相同——单元测试不关心函数的内部逻辑,只关心结果)。
/// <summary>
///A test for GetOddLinq
///</summary>
[TestMethod()]
[DeploymentItem("LinqTest.exe")]
public void GetOddLinqTest()
{
int expected = 1; // In every 2 numbers, one is odd
int actual;
Program_Accessor.FillArray(2);
actual = Program_Accessor.GetOddLinq();
Assert.AreEqual(expected, actual);
}
这里我取巧了。知道我的数组将用连续的数字填充,我知道我选取的任何 2 个相邻单元格都将包含 1 个奇数。所以,我们构建一个 2 个单元格的数组,填充它,并将从 GetOddLinqTest
返回的奇数数量与预期结果进行比较。在程序的变体中,如果数组是用随机数填充的,你就必须更改此函数,以获得正确的 expected
值。
注意:随机数不会改变测量结果,因为我们总是需要扫描整个数组。
现在,在构建解决方案之前运行所有单元测试(或点击 CTRL+R, A),然后,希望你能看到所有测试都通过了(显示为绿色)。
历史
1.00 版本于 2007年12月5日发布
1.01 版本于 2007年12月14日发布
更新
根据评论中收到的建议,实施了 2 项修正,以提高测量精度。
- 根据 Dennis Dollfus 的建议,LINQ 函数现在看起来是这样的
private static int GetOddLinq() { //per Dennis Dollfus's suggestion on CodeProject 12/14/2007 int oddNumbers = theArray.Count(n => n % 2 == 1); return oddNumbers; }
- 根据 kckn4fun 的建议,结果被累积到一个
StringBuilder
中,并且只在最后才写入文件/控制台。新的Main
函数看起来是这样的
static void Main(string[] args) { //use of StringBuilder to avoid file noise suggested by kckn4fun on //CodeProject 12/14/2007 StringBuilder sb = new StringBuilder(); sb.AppendLine("Elements\tFor loop\tForEach Loop\tLinq Loop"); for (int i = 0; i < 5; i++) { int numElements = 1000 * (int)Math.Pow(10, i); FillArray(numElements); sb.AppendLine(string.Format( "{0:#,#}\t{1:0.0000000000}\t{2:0.0000000000}\t{3:0.0000000000}", numElements, GetAverage(GetOdd), GetAverage(GetOddForEach), GetAverage(GetOddLinq))); } Console.ReadLine(); WriteFile(sb.ToString()); }
新的结果如下所示
如你所见,性能略有提升——但趋势依旧。
最后说明
这个程序绝不是对 LINQ 总体性能的详尽分析。我敢肯定它在遍历复杂数据集和 XML DOM 树时的表现要好得多。我从未打算证明任何事情,只是想在新环境中稍作尝试。
请随意使用这个程序及其结果。根据我的设计方式,它更容易接入更复杂的逻辑并仍然获得测量数据。