C# 中数组的高效 Map 操作






4.92/5 (28投票s)
对 C# 中数组的 map 高阶函数实现技术进行的非正式调查。
介绍
本文比较了实现用于转换数组的 map 函数的各种技术的性能,并介绍了一种使用表达式树重写的新颖高性能 map 实现。
一个map 操作从另一个列表中构造一个序列或列表,其中新列表中的每个元素都是将函数应用于原始列表中每个对应元素的结果。对于 C# 程序员来说,最著名的 map 操作示例是系统库中的IEnumerable.Select()
函数。
map 操作因其常见且易于并行而成为优化的有趣研究对象。高性能的 map 操作是高效集合库的基石。
观察结果
实验的主要观察结果是:
- 当数组较小时(< 10,000 个元素),最简单的非并行实现远远优于并行实现。
- 使用带有范围分区的
Parallel.ForEach()
始终优于AsParallel().AsOrdered().Select().ToArray()
。 - 通过将 lambda 作为表达式树传递并重写它,我们可以提高带有范围分区的
Parallel.ForEach()
的性能。
方法论
这些测试是在一台 2.3 Ghz 的 Intel i7 四核机器上运行的。
为了比较 map 的不同实现,我创建了一个测试套件,比较了 6 种不同的 map 实现。我针对 9 种不同的函数参数和长度从 100 到 10,000,000 个整数不等的输入数组尝试了每种实现。
在每个数组大小上,测试函数会重复运行,并在不同的实现技术之间交替进行,直到总经过时间达到 1 秒。
实现数组 Map 操作的技术
在我的实验中,我研究了以下数组 map 操作的实现。
简单的顺序 Map
这是一个简单的 map 实现,使用了 for 循环。
U[] Map<T, U>(T[] xs, Func<T, U> f) { var r = new U[xs.Length]; for (int i=0; i < xs.Length; ++i) r[i] = f(xs[i]); return r; }
对于小型数组(<= 1000),这始终是最有效的实现。
使用 IEnumerable.Select 实现 Map
最简单的 map 实现使用IEnumerable.Select()
操作,然后调用ToArray()
。
U[] Map<T, U>(T[] xs, Func<T, U> f) {
return xs.Select(f).ToArray();
}
根据我的测量,这通常是最慢的 map 实现方法,特别是对于大小为 10,000 及以上的数组。这比手动编写的版本效率低是合乎情理的,因为它使用了枚举器,使得编译器难以利用有关底层数据类型的优化知识。使用 Parallel.For 的并行 Map 操作
使用Parallel.For()可以轻松地将手动编写的 map 操作转换为并行操作,如下所示:
static U[] SimpleParallelMap<T, U>(T[] xs, Func<T, U> f) {
var r = new U[xs.Length];
Parallel.For(0, xs.Length, i => r[i] = f(xs[i]));
return r;
}
令人惊讶的是,即使对于大型数组,甚至在某些情况下表现不佳,这种实现也从未显著优于非并行版本。
使用 IEnumerable.AsParallel() 的并行 Map
另一种并行实现 map 的方法是使用IEnumerable.AsParallel()
。
U[] Map<T, U>(T[] xs, Func<T, U> f) {
return xs.Select(f).AsParallel().AsOrdered().ToArray();
}
这种技术的性能与Parallel.For()
方法相当,有时更好,有时更差。出于某种原因,对于正好是 1,000,000 个元素的数组,其性能一直比 Parallel.For() 技术差(有时长达 2 倍或更多)。
使用范围分区器的并行 Map
通过使用
,分区器可以通过创建更少的操作子范围数组的任务来减轻任务调度程序的负担。Parallel.ForEeach()
static U[] PartitionedParallelMap<T, U>(T[] xs, Func<T, U> f)
{
var r = new U[xs.Length];
Parallel.ForEach(Partitioner.Create(0, xs.Length),
range => { for (int i = range.Item1; i < range.Item2; ++i)
r[i] = f(xs[i]); });
return r;
}
对于大型数组,此方法的性能是迄今为止最好的,但与大多数并行方法一样,只有当数组大小达到 10,000 个元素时,它才开始优于简单的手动编写的顺序 map 实现。
使用范围分区器和表达式树重写的并行 Map
通过观察传递给ForEach
的函数主要由调用 lambda 函数参数的成本决定,可以提高PartitionedParallelMap()
函数的性能。总的来说,调用 lambda 函数具有显着的性能成本。
如果 lambda 函数作为表达式树传递,则可以将其内联到动态创建的函数中,并传递给Parallel.ForEach()
函数。
这是伪代码中的通用方法:
static U[] OptimizedPartitionedParallelMap<T, U>(T[] xs, Expresion<Func<T, U>> fexpr)
{
var r = new U[xs.Length];
Expression<Action<T[], U[], int, int>> fexpr2 = RewriteExpressionTree(fexpr);
Action<T[], U[], int, int> f = fexpr.Compile();
Parallel.ForEach(Partitioner.Create(0, xs.Length), range =>
{ f(xs, r, range.Item1, range.Item2); });
return r;
}
重写代码创建了一个新的表达式树,并使用ExpressionVisitor
类将函数参数(fexpr
)的主体内联到其中,从而消除了循环中昂贵的 lambda 调用。
public class FunctionInliner <t,> : ExpressionVisitor
{
Expression argument;
ParameterExpression parameter;
public FunctionInliner(Expression<func<t,>> function, Expression argument){
parameter = function.Parameters[0];
this.argument = argument;
}
public Expression Modify(Expression expression) {
return Visit(expression);
}
protected override Expression VisitParameter(ParameterExpression node){
return node = parameter ? argument : node;
}
}
public static class FunctionInliner
{
public static Expression InlineFunctionCall<t,>(Expression<func<t,>> function, Expression arg) {
var fi = new FunctionInliner<t,>(function, arg);
return fi.Modify(function.Body);
}
}
这个 map 实现只有在Expression.Compile()
被记忆化(即缓存)的情况下才有合理的性能,如下所示:
private static Dictionary<Expression, object> memoizedFastMaps = new Dictionary<Expression, object>();
public static U[] FastMap<T, U>(this T[] self, Expression<Func<T, U>> fexpr) {
Action<T[], U[], int, int> f;
lock (memoizedFastMaps)
{
if (!memoizedFastMaps.ContainsKey(fexpr))
memoizedFastMaps[fexpr] = ComputeFastMap(fexpr);
f = (Action<T[], U[], int, int>)memoizedFastMaps[fexpr];
}
var r = new U[self.Length];
Parallel.ForEach(Partitioner.Create(0, self.Length),
range => f(self, r, range.Item1, range.Item2));
return r;
}
该技术的性能通常比简单地使用范围分区器略好。
结果
此表包含不同 map 测试的结果。它也可以作为彩色编码的 Excel 表格在 zip 包中找到。每个值代表每个测试花费的总毫秒数。每列代表不同大小的数组。
100 | 1000 | 10,000 | 100,000 | 1,000,000 | 10,000,000 | |
x * 2 | ||||||
IEnumerable.Select | 47.0013 | 177.8057 | 328.9458 | 360.1104 | 354.0814 | 645.1824 |
SimpleMap | 7.5816 | 34.7406 | 69.7591 | 94.4403 | 92.0408 | 189.9622 |
Parallel.For | 356.2646 | 250.5682 | 145.9923 | 234.1428 | 103.7589 | 214.9254 |
ParallelQuery | 154.392 | 230.0595 | 288.8722 | 237.1865 | 385.0914 | 131.25 |
Partitioned ForEach | 209.446 | 154.7732 | 107.0929 | 44.0619 | 25.6843 | 52.4506 |
Expression-tree map | 202.83 | 138.4812 | 55.6059 | 29.6556 | 70.3177 | 47.7849 |
x % 3 | ||||||
IEnumerable.Select | 50.9754 | 187.2522 | 326.9465 | 351.2355 | 404.2332 | 546.7806 |
SimpleMap | 11.0956 | 53.0866 | 96.3511 | 120.8589 | 136.8649 | 205.0038 |
Parallel.For | 335.2865 | 221.5865 | 138.5964 | 213.4733 | 121.2471 | 159.3329 |
ParallelQuery | 158.7274 | 228.8488 | 277.0174 | 219.9876 | 260.9124 | 125.8866 |
Partitioned ForEach | 217.3006 | 152.4565 | 89.5992 | 56.3834 | 41.3112 | 57.5527 |
Expression-tree map | 203.2819 | 144.2859 | 68.2051 | 40.7922 | 97.9225 | 56.9034 |
x % 10 == 0 | ||||||
IEnumerable.Select | 44.749 | 154.2371 | 269.2269 | 299.4427 | 375.8133 | 486.5356 |
SimpleMap | 11.0698 | 54.2887 | 97.424 | 135.1354 | 181.7922 | 213.2659 |
Parallel.For | 327.3782 | 233.9054 | 144.3949 | 134.1175 | 147.8055 | 192.6815 |
ParallelQuery | 159.6129 | 233.3072 | 275.8584 | 331.0025 | 185.0597 | 143.3002 |
Partitioned ForEach | 227.4341 | 157.5438 | 128.4379 | 55.6181 | 53.5953 | 59.0363 |
Expression-tree map | 207.7087 | 153.7541 | 81.5905 | 47.0574 | 62.8087 | 61.7823 |
x * y | ||||||
IEnumerable.Select | 47.5342 | 176.2845 | 330.5266 | 417.78 | 389.5859 | 641.2155 |
SimpleMap | 6.6616 | 29.933 | 60.012 | 82.1904 | 86.6731 | 164.2725 |
Parallel.For | 335.883 | 239.2442 | 151.766 | 151.0844 | 109.0079 | 208.9425 |
ParallelQuery | 156.0036 | 229.0513 | 307.9049 | 260.6253 | 319.6288 | 114.221 |
Partitioned ForEach | 219.3628 | 156.26 | 81.5716 | 41.7017 | 34.5045 | 54.1712 |
Expression-tree map | 211.8914 | 155.145 | 65.0756 | 45.8949 | 80.9438 | 57.4718 |
Math.Sqrt(x) | ||||||
IEnumerable.Select | 52.052 | 178.1152 | 325.6915 | 337.2131 | 314.3897 | 594.8534 |
SimpleMap | 11.5972 | 60.8885 | 108.1417 | 150.2933 | 142.9398 | 226.6415 |
Parallel.For | 332.5255 | 214.7269 | 155.6848 | 115.8106 | 107.1056 | 206.2448 |
ParallelQuery | 151.1703 | 236.2765 | 262.9026 | 221.0244 | 333.1709 | 108.9785 |
Partitioned ForEach | 218.4954 | 153.5197 | 70.759 | 133.0735 | 53.0308 | 116.2612 |
Expression-tree map | 212.4254 | 146.074 | 74.6249 | 46.6229 | 51.6872 | 84.8528 |
1.0 / x | ||||||
IEnumerable.Select | 57.7009 | 192.1225 | 351.9372 | 342.5118 | 342.8452 | 422.6756 |
SimpleMap | 13.6616 | 61.0858 | 113.1279 | 215.0919 | 149.2644 | 159.6373 |
Parallel.For | 327.4285 | 211.7903 | 168.9644 | 123.7927 | 123.0176 | 130.4033 |
ParallelQuery | 156.755 | 233.4242 | 211.9213 | 156.8925 | 305.5861 | 379.4584 |
Partitioned ForEach | 222.793 | 151.4817 | 73.8911 | 51.0748 | 56.9122 | 86.312 |
Expression-tree map | 200.7036 | 139.8924 | 77.7088 | 115.2319 | 54.257 | 59.8788 |
F(x) | ||||||
IEnumerable.Select | 48.3564 | 172.7315 | 328.2192 | 339.3331 | 401.4378 | 685.65 |
SimpleMap | 9.3622 | 53.5944 | 119.9011 | 134.9806 | 153.9984 | 271.0598 |
Parallel.For | 317.6485 | 199.1581 | 138.0239 | 188.8737 | 127.2151 | 202.1605 |
ParallelQuery | 148.9416 | 201.4768 | 241.8003 | 197.0903 | 154.3222 | 141.0791 |
Partitioned ForEach | 215.59 | 172.11 | 64.8258 | 47.5668 | 45.3556 | 65.6707 |
Expression-tree map | 239.0486 | 190.5578 | 103.7636 | 92.9645 | 160.6785 | 143.9428 |
xs[x % xs.Length] | ||||||
IEnumerable.Select | 53.4851 | 194.6166 | 326.2611 | 371.1026 | 431.2638 | 568.9713 |
SimpleMap | 11.1547 | 52.3415 | 92.1421 | 123.3329 | 142.5564 | 203.5745 |
Parallel.For | 321.7491 | 215.2261 | 135.3974 | 225.5697 | 129.4563 | 188.0756 |
ParallelQuery | 161.9284 | 219.4217 | 290.0379 | 169.8483 | 139.5091 | 118.6672 |
Partitioned ForEach | 219.876 | 157.8085 | 73.0295 | 58.3842 | 42.7669 | 59.3939 |
Expression-tree map | 210.0777 | 148.9512 | 80.0346 | 53.4993 | 121.5434 | 70.5305 |
x.ToString() | ||||||
IEnumerable.Select | 174.3339 | 318.9216 | 322.7798 | 263.6981 | 263.9824 | 2986.9454 |
SimpleMap | 123.489 | 255.0868 | 285.1731 | 247.5723 | 297.4845 | 2781.8611 |
Parallel.For | 198.1301 | 109.1844 | 115.8489 | 147.2986 | 174.429 | 1952.4264 |
ParallelQuery | 145.9428 | 112.4906 | 96.8155 | 125.436 | 183.3579 | 2024.5259 |
Partitioned ForEach | 182.2398 | 96.6949 | 89.1915 | 129.1918 | 226.7504 | 1928.2476 |
Expression-tree map | 165.3074 | 104.9725 | 89.9368 | 121.4181 | 178.6953 | 1835.5693 |
结语
本次调查是非正式的,不够严谨。理想情况下,应该添加更多测试,并探索更多输入数组类型(不仅仅是 int 数组)。最后,需要测试更多的 CPU 配置。
尽管如此,这些结果确实为数组大小对最有效技术产生了巨大影响这一事实提供了一些有趣的见解,并展示了范围分区在数组并行 map 操作中的有效性。
虽然表达式树重写技术很有趣,但性能提升不如预期,而且不稳定。目前,由于复杂性,我不建议使用它,但我希望其他人能够找到提高其性能的方法。
当然,欢迎所有评论,但我尤其希望听到其他机器上的结果以及建议的替代实现。
历史
2012年9月2日 - 第一个版本