LINQ 和性能






4.93/5 (28投票s)
LINQ 是否是处理大量运行时相关环境数据的正确技术?
引言
自 Microsoft 在 .NET 3.5 中引入 LINQ 以来,已经过去了 15 年(2007 年)。这应该足以让它成熟为一个全方位的有用工具。
LINQ 的使用有几个方面
- 延迟执行和惰性求值(更好地利用多线程系统)
- 缩短代码(可读性和可重用性)
- 所用技术的运行时开销(性能)
在本文中,我将只关注 LINQ 的一个方面——性能。
背景
LINQ 的出现,至少在我的环境中,让所有 C# 程序员都为之兴奋,并随后导致这项技术的大规模使用。
我想在这里问一下,当我们以非常清醒的眼光看待 LINQ 时——它是一种创新的组合,将 扩展方法 和 匿名委托(lambda 表达式)与延迟执行(yield)相结合——它的营销光环还剩下多少?
为了回答性能方面的问题,我想使用 LINQ 过滤一个对象列表并对结果进行排序。
这种方法的第一处特殊性在于 LINQ 必须处理到最后一个元素。因此,我故意排除了使用 Any()
或 First()
可以实现的运行时优势,并专注于必须处理所有元素的场景。
这种方法的第二处特殊性在于 LINQ 是对内存进行操作(而不是对数据库进行操作)。因此,我故意排除了访问数据库会引入的运行时影响——无论是(小的)内存数据库、经典的服务器数据库还是它们的混合体。因此,LINQ 的一个典型应用——数据库查询——在测试中并没有得到合理的体现。这是因为将数据库查询添加到测试中也会很容易导致结果失真。
- LINQ 数据库查询的有效性更多地受数据库驱动程序的影响,而不是编程方法的影响。
- 如果 LINQ 数据库查询是针对缓存数据库执行的,那么结果仍然来自内存。
- 编程方法必须有多么愚蠢才能与 LINQ 相提并论?我预计存储过程(可参数化且已编译的 SQL 查询)将远优于 LINQ 查询。
更新 1:在评论部分,有许多评论讨论了不将 LINQ 用于数据库访问的自我约束。这些评论的范围从批评(这忽略了核心用例)到赞同(存储 SQL 过程无论如何都是数据库高效查询的更好方法)。通过我的方法,我只想且仅限于排除数据库访问会给测试带来的副作用。不多不少。 |
测试
当然,结果取决于底层运行时系统、操作系统和硬件的多线程能力——因此只有在同一环境中比较不同方法的有用性才有意义。出于这个原因,我准备了 4 个可以相互比较的测试用例。
- 测试用例 1:LINQ with C++(有和无排序)
- 测试用例 2:经典方法(
for(...)
循环、if(...)
和sort(...)
)与 C++(有和无排序) - 测试用例 3:LINQ with C#(有和无排序)
- 测试用例 4:经典方法(
for(...)
循环、if(...)
和sort(...)
)与 C#(有和无排序)
测试用例将在两个不同的环境中执行……
Win10 测试
第一个测试环境是一台联想 T550,配备 i7-5600U @ 2.6GHz、Windows 10 x64、.NET 4.7.2 和 16GB 内存。
C++ 的测试结果是
- C++ 代码已针对 x86 架构编译,符合 ISO C++ 14 标准,并使用 /O2 优化标志。
- 我也测试了 x64 架构——但没有观察到差异。
C# 的测试结果是
openSUSE 测试
第二个测试环境是一台联想 T520,配备 i5-2540M @ 2.6GHz、openSUSE Leap 15.4 x64、.NET 6.0 和 12 GB 内存。
更新 1:当我首次发布测试时,.NET 6.0 是当前版本,而 .NET 7.0 即将发布。评论区有许多评论提出是否 .NET 7.0 的结果会更好。在文章的最后,我添加了涵盖此问题的测量结果。 |
C++ 的测试结果是
- C++ 代码使用 GNU C++ 12-lp154 默认设置和 /O3 优化标志编译。
C# 的测试结果是
结果比较
为了更好地比较结果,我忽略了最差的测试运行(最长的运行时间),而是取剩余三次测试运行的中位数。这产生了以下汇总图
行 | 系统 | 硬件 | code | 尝试 | 排序 | 中位数 | C++ vs. C# | LINQ vs. 经典方法 |
1 | Win 10 | T550 | C++ | LINQ | 否 | 11.5 毫秒 | 好 13% | 差约 3 倍 |
2 | Win 10 | T550 | C# | LINQ | 否 | 13.0 毫秒 | 差 13% | |
3 | Win 10 | T550 | C++ | 经典方法 | 否 | 2.5 毫秒 | 好 112% | 好约 3 倍 |
4 | Win 10 | T550 | C# | 经典方法 | 否 | 5.3 毫秒 | 差 112% | |
5 | Win 10 | T550 | C++ | LINQ | 是 | 21.2 毫秒 | 好 159% | 不一致(C++ 差约 2.5 倍, C# 可比) |
6 | Win 10 | T550 | C# | LINQ | 是 | 55.0 毫秒 | 差 159% | |
7 | Win 10 | T550 | C++ | 经典方法 | 是 | 8.2 毫秒 | 好 554% | 不一致(C++ 好约 2.5 倍, C# 可比) |
8 | Win 10 | T550 | C# | 经典方法 | 是 | 53.7 毫秒 | 差 554% | |
9 | openSUSE | T520 | C++ | LINQ | 否 | 6.0 毫秒 | 好 171% | 差约 3.5 倍 |
10 | openSUSE | T520 | C# | LINQ | 否 | 16.3 毫秒 | 差 171% | |
11 | openSUSE | T520 | C++ | 经典方法 | 否 | 1.2 毫秒 | 好 575% | 好约 3.5 倍 |
12 | openSUSE | T520 | C# | 经典方法 | 否 | 8.1 毫秒 | 差 575% | |
13 | openSUSE | T520 | C++ | LINQ | 是 | 10.5 毫秒 | 好 206% | 不一致(C++ 差约 2 倍, C# 可比) |
14 | openSUSE | T520 | C# | LINQ | 是 | 32.2 毫秒 | 差 206% | |
15 | openSUSE | T520 | C++ | 经典方法 | 是 | 5.2 毫秒 | 好 496% | 不一致(C++ 好约 2 倍, C# 可比) |
16 | openSUSE | T520 | C# | 经典方法 | 是 | 31.0 毫秒 | 差 496% |
对期望和结果的讨论
C++ vs. C#
- 我期望 C++ 比 C# 有明显的优势。
- 期望已满足。
C++ 的速度最高可达 C# 的约 5.5 倍——这对于应用程序来说不算戏剧性,但对于平台和服务来说可能是一个决定性的标准。显然,STL 和 C++ 编译器优化器在这里做得非常出色。
更新 1:评论区有一些评论似乎提及 C++ 与 C# 的比较不公平。 核心观点是:C++ 被设计用于接近硬件的开发(操作系统、平台、驱动程序……),而 C# 被设计用于快速且最少出错的开发(应用程序系统级别)。 |
LINQ vs. 经典方法(for(...) 循环、if(...) 和 sort(...))
- 我期望 LINQ 比经典方法有明显的劣势。
- 期望已满足。但是,不如预期那样明显。
对于 C++ 而言,LINQ 始终比经典方法(for(...)
循环、if(...)
和 sort(...)
)差,但对于 C# 并非如此。
我认为 LINQ 在 C# 中表现如此出色的原因是特殊的数据结构。虽然 C++ 中的 LINQ 使用 vector<Element>
作为源数据和结果,但 C# 中的 LINQ 使用 List<Element>
作为源数据,以及 System.Linq.Enumerable.WhereListIterator<Element>
或 System.Linq.OrderedEnumerable<Element, int>
作为结果。第二个 datatype
提供了对排序标准的显式访问,也可以被视为不再可比或作弊。但我们还是温和一点。
Win10 vs. openSUSE
我不想对此做过多评论,但测试似乎在 Linux 上以更弱的硬件运行得更快。也许是因为 Microsoft Defender——它在我的 Windows 机器上总是运行着的。
扩展
C# 相对于 C++ 的结果相当令人满意,这让我感到惊讶(我曾预计至少有十倍的差距),也让我感到高兴(显然 15 年的发展确实对 C# 产生了积极影响)。
尽管如此,我想确保 C# 确实如此强大。因此,我简单地将数据量扩大了二十倍,从 640000
增加到 12800000
。
缩放后的 C++ 测试结果是
缩放后的 C# 测试结果是
这产生了以下汇总图
行 | 系统 | 硬件 | code | 尝试 | 排序 | 中位数 | C++ vs. C# | LINQ vs. 经典方法 |
1 | openSUSE | T520 | C++ | LINQ | 否 | 108.9 毫秒 | 好 58% | 差约 2.5 倍 |
2 | openSUSE | T520 | C# | LINQ | 否 | 171.8 毫秒 | 差 58% | |
3 | openSUSE | T520 | C++ | 经典方法 | 否 | 35.9 毫秒 | 好 197% | 好约 2.5 倍 |
4 | openSUSE | T520 | C# | 经典方法 | 否 | 106.7 毫秒 | 差 197% | |
5 | openSUSE | T520 | C++ | LINQ | 是 | 250.9 毫秒 | 好 153% | 不一致(C++ 差约 2 倍, C# 可比) |
6 | openSUSE | T520 | C# | LINQ | 是 | 636.4 毫秒 | 差 153% | |
7 | openSUSE | T520 | C++ | 经典方法 | 是 | 135.1 毫秒 | 好 383% | 不一致(C++ 好约 2 倍, C# 可比) |
8 | openSUSE | T520 | C# | 经典方法 | 是 | 652.1 毫秒 | 差 383% |
讨论
先前关于较小数据集的所有陈述都得到了证实。
Using the Code
更新 1:以下代码(C++ 和 C#)代表了我测试的初始版本(第一次代码生成),不包括已集成的错误修复。评论区中的许多评论都提出了使代码更高效的建议。这些建议范围从更现代的表示法(例如属性)到对基准测试库的推荐。 首先,感谢大家! 我已经尝试/实现了其中许多建议。对于使 C++ 代码更高效的建议,事实证明 GNU C++ 编译器优化得非常高效,以至于代码更改没有可衡量的效果。对于使 C# 代码更高效的建议,非常好的评价可以从测量结果中读出改进,但 C# 编译器也优化得非常高效。 从基准测试库的建议中,我只采纳了提供清晰结果评估接口并以更紧凑、更清晰的方式呈现结果的想法。C++ 和 C# 的基准测试库差异很大,以至于会过度影响方法的比较,因此我决定使用一个自实现的解决方案,该方案在 C++ 和 C# 中都能完全相同地工作。 我很乐意提供增强的代码:C++ 增强代码 下载 LINQ-SimulationTest.zip C# 增强代码 下载 LINQ-Test.zip |
C++ 测试代码(第一次代码生成)
代码在 Win10 和 openSUSE 上是完全相同的。
#include <iostream>
#include <iomanip>
#include <vector>
#include <chrono>
#include "boolinq.h"
using namespace std;
using namespace boolinq;
static const size_t VECTORSIZE = 640000;
class Element
{
private:
int tag;
int order;
int data;
public:
int getTag() const { return tag; }
void setTag(int t) { tag = t; }
int getOrder() const { return order; }
void setOrder(int o) { order = o; }
int getData() const { return data; }
void setData(int d) { data = d; }
inline bool operator<(const Element& b)
{
return this->order * VECTORSIZE + this->data < b.order* VECTORSIZE + b.data;
}
};
void checkResults(vector<Element> resultVector)
{
int d = 0;
int o = 0;
for (auto it = resultVector.begin(); it != resultVector.end(); it++)
{
if (d > it->getData() && o > it->getOrder())
throw "Ordering failed";
d = it->getData();
o = it->getOrder();
}
}
void testClasic(vector<Element> testVector, bool order, int run)
{
auto startTime = chrono::high_resolution_clock::now();
vector<Element> resultVector;
for (auto it = testVector.begin(); it != testVector.end(); it++)
{
if (it->getTag() < 1)
{
resultVector.push_back(*it);
}
}
if(order)
sort(resultVector.begin(), resultVector.end());
if (resultVector.size() != VECTORSIZE / 8)
throw "Number of result elements not correct!";
checkResults(resultVector);
auto endTime = chrono::high_resolution_clock::now();
auto ordering = (order ? "ordered" : "not ordered");
double timeTaken = chrono::duration_cast<chrono::nanoseconds>
(endTime - startTime).count() / 1000000000.0;
cout << "Classic (" << ordering << "), run " << run << " -> Time: "
<< setprecision(9) << timeTaken << " sec" << " To be: "
<< VECTORSIZE / 8 << " Result: " << resultVector.size() << endl;
}
void testLINQ(vector<Element> testVector, bool order, int run)
{
auto startTime = chrono::high_resolution_clock::now();
auto resultVector = (order ?
from(testVector).where([](const Element& element)
{ return element.getTag() < 1; })
.orderBy([](const Element& element)
{ return element.getOrder() * VECTORSIZE +
element.getData(); })
.toStdVector() :
from(testVector).where([](const Element& element)
{ return element.getTag() < 1; })
.toStdVector());
if (resultVector.size() != VECTORSIZE / 8)
throw "Number of result elements not correct!";
checkResults(resultVector);
auto endTime = chrono::high_resolution_clock::now();
auto ordering = (order ? "ordered" : "not ordered");
double timeTaken = chrono::duration_cast<chrono::nanoseconds>
(endTime - startTime).count() / 1000000000.0;
cout << "LINQ (" << ordering << "), run " << run << " -> Time: "
<< setprecision(9) << timeTaken << " sec" << " To be: "
<< VECTORSIZE / 8 << " Result: " << resultVector.size() << endl;
}
int main()
{
vector<Element> testVector(VECTORSIZE);
for (size_t index = 0; index < VECTORSIZE; index++)
{
testVector[index].setTag(index % 8);
testVector[index].setOrder(index % 12);
testVector[index].setData(index);
}
for (int run = 0; run < 4; run++)
{
testLINQ(testVector, false, run);
testClasic(testVector, false, run);
}
for (int run = 0; run < 4; run++)
{
testLINQ(testVector, true, run);
testClasic(testVector, true, run);
}
int i;
cin >> i;
return 0;
}
C# 测试代码(第一次代码生成)
代码在 Win10 和 openSUSE 上是完全相同的。
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
namespace LINQtest
{
public static class Settings
{
public const int VECTORSIZE = 640000;
}
class Element : IComparable<Element>
{
private int tag;
private int order;
private int data;
public Element(int t, int o, int d)
{
tag = t;
order = o;
data = d;
}
public int Tag { get { return tag; } set { tag = value; } }
public int Order { get { return order; } set { order = value; } }
public int Data { get { return data; } set { data = value; } }
public int CompareTo(Element? b)
{
if (b == null)
return 1;
return (b.order * Settings.VECTORSIZE + b.data) -
(order * Settings.VECTORSIZE + data);
}
}
class Program
{
static void checkResults(IEnumerable<Element> resultVector)
{
int d = 0;
int o = 0;
foreach (var element in resultVector)
{
if (d > element.Data && o > element.Order)
throw new Exception("Ordering failed");
d = element.Data;
o = element.Order;
}
}
static void testClasic(List<Element> testVector, bool order, int run)
{
Stopwatch sw = new Stopwatch();
sw.Start();
var resultVector = new List<Element>(testVector.Count / 8);
foreach (var element in testVector)
{
if (element.Tag < 1)
resultVector.Add(element);
}
if (order)
resultVector.Sort();
if (resultVector.Count != Settings.VECTORSIZE / 8)
throw new Exception("Number of result elements not correct!");
checkResults(resultVector);
sw.Stop();
var ordering = (order ? "ordered" : "not ordered");
Console.WriteLine(
"Classic ({0}), run {1} -> Time: {2} sec To be: {3} Result: {4}",
ordering, run, sw.Elapsed.ToString().Substring(7),
Settings.VECTORSIZE / 8, resultVector.Count());
}
static void testLINQ(List<Element> testVector, bool order, int run)
{
Stopwatch sw = new Stopwatch();
sw.Start();
var resultVector = (order ?
testVector.Where(element => element.Tag < 1)
.OrderBy(element => element.Order *
Settings.VECTORSIZE + element.Data) :
testVector.Where(element => element.Tag < 1)).ToList();
if (resultVector.Count() != Settings.VECTORSIZE / 8)
throw new Exception("Number of result elements not correct!");
checkResults(resultVector);
sw.Stop();
var ordering = (order ? "ordered" : "not ordered");
Console.WriteLine(
"LINQ ({0}), run {1} -> Time: {2} sec To be: {3} Result: {4}",
ordering, run, sw.Elapsed.ToString().Substring(7),
Settings.VECTORSIZE / 8, resultVector.Count());
}
static void Main(string[] args)
{
var testVector = new List<Element>(Settings.VECTORSIZE);
for (int index = 0; index < Settings.VECTORSIZE; index++)
{
testVector.Add(new Element(index % 8, index % 12, index));
}
for (int run = 0; run < 4; run++)
{
testLINQ(testVector, false, run);
testClasic(testVector, false, run);
}
for (int run = 0; run < 4; run++)
{
testLINQ(testVector, true, run);
testClasic(testVector, true, run);
}
var key = Console.ReadKey();
}
}
}
Win10 代码编译
我使用了 Visual Studio 2019 项目来编译 C++ 和 C#。
openSUSE 代码编译
要编译 C++ 代码,我创建了一个 Code::Blocks 20.3 项目并使用了 gcc-c++ 12
。
要编译 C# 代码,我使用了 NET6.0 SDK 和此 shell 脚本
# |<-- The path to the *.scproj file
|<-- The target folder (the result will also be in the bin/Release folder).
dotnet publish . --configuration Release --framework net6.0 --output ./pub
--self-contained false --runtime linux-x64 --verbosity quiet
更新 1:同时,我已从 .NET 6.0 升级到 .NET 7.0。转换测试所需做的就是将 shell 脚本和项目文件(*.csproj)中的 net6.0 更改为 net7.0 。 |
关注点
我想知道 LINQ 和经典方法(for(...)
循环、if(...)
和 sort(...)
)在其各自的最佳实现中的差距有多大。
结果表明,对于 C++,经典方法是最具性能的,而对于 C#,则推荐使用 LINQ。这似乎有充分的理由。虽然 C++ 的 LINQ 只意味着额外的开销,但 C# 的 LINQ 可以通过特殊的数据结构(System.Linq.Enumerable.WhereListIterator<Element>
和 System.Linq.OrderedEnumerable<Element, int>
)来弥补这种额外的开销。
更新 1:同时,我已从 .NET 6.0 升级到 .NET 7.0。下表显示了在 openSUSE 上的测量结果(在 Win10 上,测量结果的散布几乎不允许做出可靠的陈述,这一点已经显示出来)。 这些测量结果是通过增强的代码、更紧凑清晰的结果输出(新的干净基准测试接口)以及将运行次数增加到 9 次评估运行来获得的。 我重复了测试 3 次——并且无法检测到 .NET 7.0 的任何加速。 |
Platform environment: Unix
Runtime environment: .NETCoreApp,Version=<code>v6.0</code>
| Measurement set | N | Mean | Error | StdDev | Ratio |
| --------------------- | ----- | ---------- | ---------- | ---------- | ----- |
| LINQ (not ordered) | 9 | 0.197 s | 0.037 s | 0.020 s | 1.00 |
| Classic (not ordered) | 9 | 0.106 s | 0.000 s | 0.000 s | 0.54 |
| LINQ (ordered) | 9 | 0.658 s | 0.041 s | 0.019 s | 3.35 |
| Classic (ordered) | 9 | 0.686 s | 0.037 s | 0.020 s | 3.49 |
| Measurement set | N | Mean | Error | StdDev | Ratio |
| --------------------- | ----- | ---------- | ---------- | ---------- | ----- |
| LINQ (not ordered) | 9 | 0.197 s | 0.038 s | 0.020 s | 1.00 |
| Classic (not ordered) | 9 | 0.106 s | 0.000 s | 0.000 s | 0.54 |
| LINQ (ordered) | 9 | 0.659 s | 0.041 s | 0.019 s | 3.35 |
| Classic (ordered) | 9 | 0.690 s | 0.036 s | 0.019 s | 3.51 |
| Measurement set | N | Mean | Error | StdDev | Ratio |
| --------------------- | ----- | ---------- | ---------- | ---------- | ----- |
| LINQ (not ordered) | 9 | 0.196 s | 0.035 s | 0.019 s | 1.00 |
| Classic (not ordered) | 9 | 0.106 s | 0.001 s | 0.001 s | 0.54 |
| LINQ (ordered) | 9 | 0.659 s | 0.039 s | 0.018 s | 3.36 |
| Classic (ordered) | 9 | 0.691 s | 0.035 s | 0.019 s | 3.53 |
Platform environment: Unix
Runtime environment: .NETCoreApp,Version=<code>v7.0</code>
| Measurement set | N | Mean | Error | StdDev | Ratio |
| --------------------- | ----- | ---------- | ---------- | ---------- | ----- |
| LINQ (not ordered) | 9 | 0.196 s | 0.038 s | 0.020 s | 1.00 |
| Classic (not ordered) | 9 | 0.105 s | 0.001 s | 0.000 s | 0.54 |
| LINQ (ordered) | 9 | 0.661 s | 0.050 s | 0.025 s | 3.38 |
| Classic (ordered) | 9 | 0.764 s | 0.035 s | 0.019 s | 3.91 |
| Measurement set | N | Mean | Error | StdDev | Ratio |
| --------------------- | ----- | ---------- | ---------- | ---------- | ----- |
| LINQ (not ordered) | 9 | 0.198 s | 0.038 s | 0.020 s | 1.00 |
| Classic (not ordered) | 9 | 0.108 s | 0.000 s | 0.000 s | 0.55 |
| LINQ (ordered) | 9 | 0.660 s | 0.042 s | 0.020 s | 3.33 |
| Classic (ordered) | 9 | 0.778 s | 0.036 s | 0.018 s | 3.93 |
| Measurement set | N | Mean | Error | StdDev | Ratio |
| --------------------- | ----- | ---------- | ---------- | ---------- | ----- |
| LINQ (not ordered) | 9 | 0.197 s | 0.038 s | 0.020 s | 1.00 |
| Classic (not ordered) | 9 | 0.103 s | 0.001 s | 0.001 s | 0.52 |
| LINQ (ordered) | 9 | 0.657 s | 0.042 s | 0.019 s | 3.33 |
| Classic (ordered) | 9 | 0.771 s | 0.036 s | 0.019 s | 3.91 |
历史
- 2022 年 11 月 1 日:初始版本
- 2022 年 11 月 6 日:更正:C++ 的排序未被禁用。因此,C++ 的所有测量值都需要重新进行。
- 2022 年 11 月 9 日:更正:C# 的排序是对输入数据而不是结果数据进行排序,C# 的所有测量值都需要重新进行。
- 2022 年 11 月 16 日:更新 1 - C++ 和 C# 的增强代码,.NET 7.0