[
编辑 2017年:请参见本文末尾有关C#7的重要评论。]
经过多年的研究,我总结了几种技术和解决方案来应对这个问题。除了风格口味之外,
结构体数组真的是在
C#中唯一的内存中
批量存储方法。如果你的应用程序确实在高吞吐量条件下处理数百万个中等大小的对象,则没有其他
受控的替代方法。
我同意@kaalus的看法,即对象头和GC压力很快就会增加;然而,我的自然语言语法处理系统可以在不到一分钟的时间内操作8-10 GB(或更多)的结构分析,用于解析和/或生成漫长的自然语言句子。进入合唱团:“C#并不适合这样的问题……”,“切换到汇编语言……”,“上线一个
FPGA……” 等等。
好的,我们来做一些测试。首先,完全理解值类型 (struct
)管理问题和class
与struct
之间的权衡是至关重要的。当然还有装箱、固定缓冲区、GCHandle
、IntPtr
等,但在我看来最重要的是明智地使用托管指针(也称为“内部指针”)。
你对这些主题的掌握还将包括以下知识:如果你在struct
中包含一个或多个引用托管类型的引用(而不仅仅是可平铺原语),那么使用unsafe
指针访问struct
的选项会大大减少。但是对于下面要提到的托管指针方法来说,这不是问题。所以通常情况下,包括对象引用是可以的,并不会在这方面改变太多。
如果您确实需要保留unsafe
访问,可以使用“正常”模式下的GCHandle
来无限期地存储对象引用。幸运的是,将GCHandle
放入结构中不会触发不安全访问禁令。(请注意,GCHandle
本身是值类型,甚至可以定义和使用)
var gch = GCHandle.Alloc("spookee",GCHandleType.Normal);
GCHandle* p = &gch;
String s = (String)p->Target;
...等等。作为一个值类型,GCHandle 本身 直接映射到您的结构体中,但显然其引用的GC实例不是。它们在堆中,不包含在您数组的物理布局中。请注意,GCHandle不必处于“固定”模式。最后,在处理GCHandle时要注意其复制语义,因为如果您不最终释放每个分配的GCHandle,则会出现内存泄漏。
@Ani提醒我们,一些人认为可变的struct
实例很“邪恶”,但问题实际上在于它们很容易出现意外情况。确实,OP的示例...
s[543].a = 3
这段内容展示了我们试图实现的目标:在原地访问数据记录。请注意:class
类型的引用数组语法与此类似,但本文仅讨论用户定义的值类型的非jagged数组。对于我的程序而言,如果我遇到一个超大的可平面化结构体完全被镜像出其数组存储行(意外地)的情况,我通常会认为这是一个严重的错误:
rec no_no = s[543]; // 不要这样做
no_no.a = 3 //
至于你的struct
有多大(宽),这并不重要,因为你将小心翼翼地永远不会让struct
执行前面示例中展示的操作,即从其嵌入式数组中完全移出。实际上,这指向了本文的一个基本前提:
规则:
对于结构体数组,总是在原地访问单个字段;永远不要在C#中“提到”整个结构体实例。
不幸的是,C#语言没有系统地标记或禁止违反此规则的代码的方法,因此成功取决于仔细的编程纪律。
由于我们的“巨型结构”从未被映射出其数组,因此它们实际上只是内存上的模板。换句话说,正确的思考方式是将struct
视为覆盖数组元素。我们总是将每个元素视为一个空的“内存模板”,而不是可传输或可移植的封装器或数据容器。对于数组限定的“巨型”值类型,我们从不想调用“结构体”的最基本特性,即按值传递。
例如:
public struct rec
{
public int a, b, c, d, e, f;
}
在这里,我们为每个“记录”叠加了6个
int
,总共占用24字节。您需要考虑并注意打包选项以获得对齐友好的大小。但是过多的填充可能会削减您的内存预算:因为更重要的考虑因素是非LOH对象的85,000字节限制。确保您的记录大小乘以预期行数不超过此限制。
因此,对于此处给出的示例,最好建议您将
rec
数组保持在每个数组不超过3,000行。希望您的应用程序可以围绕这个甜点设计。当您记住另一种选择——每行都是一个单独的垃圾回收对象时,这并不是很限制。您已经将对象扩散降低了三个数量级,这对于一天的工作来说非常不错。因此,在这里,.NET环境强烈引导我们使用相当特定的约束来设计应用程序的内存,似乎如果您将应用程序的内存设计目标定向到30-70 KB范围内的单块分配,那么您确实可以大量地使用它们,实际上,您将受到更棘手的性能瓶颈控制(即,硬件内存总线的带宽)。
现在您有一个单一的.NET引用类型(数组),其中包含3,000个6元组,以物理连续的表格存储方式。首先,我们必须非常小心,
永远不要“拾取”其中一个结构体。正如Jon Skeet上面所指出的,“大型结构体的性能通常比类差”,这是绝对正确的。没有比开始随意抛掷丰满值类型更好的方法来瘫痪您的内存总线了。
因此,让我们利用结构体数组中很少提到的一个方面:整个数组的所有行的所有对象(及其对象或结构体的字段)始终初始化为它们的默认值。您可以从任何行或列(字段)的任何位置开始逐个插入值。您可以将某些字段保留在其默认值,或替换相邻字段而不干扰中间的字段。在使用之前,不需要手动初始化堆栈驻留(本地变量)结构体,这令人恼火的手动初始化已经消失了。
有时候很难维护逐个字段的方法,因为.NET总是试图让我们轰入一个完整的new
'd-up结构体,但对我来说,这种所谓的“初始化”只是违反了我们的禁忌(不要从数组中拔出整个结构体),换了一种方式而已。
现在我们来到问题的核心。显然,在原地访问表格数据可以最小化繁琐的数据移动工作。但通常这是一个不方便的麻烦。由于.NET中的边界检查,数组访问可能很慢。那么,如何维护一个指向数组内部的“工作”指针,以避免系统不断重新计算索引偏移量呢?
评估
让我们评估五种不同方法对值类型数组存储行中单个字段进行操作的性能。下面的测试旨在衡量密集访问位于某个数组索引处的结构体的数据字段的效率,即在原地——也就是“它们所在的地方”,而不是提取或重写整个结构体(数组元素)。比较了五种不同的访问方法,其他所有因素都保持不变。
这五种方法如下:
- 普通方式,通过方括号和字段指定符号直接访问数组。需要注意的是,在.NET中,数组是公共类型系统的一种特殊且独特的原语。如上所述,这种语法不能用于使用其他集合类型(例如
List<T> where T: struct
)直接修改索引结构元素的字段。
- 使用未记录的C#语言关键字
__makeref
。
- 通过使用
ref
关键字的委托来使用托管指针
- "不安全"指针
- 与#3相同,但使用C#函数而非委托。
在给出C#测试结果之前,这里是测试工具实现。这些测试是在.NET 4.5上运行的,是一个AnyCPU版本的发布版本,运行在x64平台上,使用Workstation gc。(请注意,由于测试不关心分配和释放数组本身的效率,因此上面提到的LOH问题并不适用。)
const int num_test = 100000;
static rec[] s1, s2, s3, s4, s5;
static long t_n, t_r, t_m, t_u, t_f;
static Stopwatch sw = Stopwatch.StartNew();
static Random rnd = new Random();
static void test2()
{
s1 = new rec[num_test];
s2 = new rec[num_test];
s3 = new rec[num_test];
s4 = new rec[num_test];
s5 = new rec[num_test];
for (int x, i = 0; i < 5000000; i++)
{
x = rnd.Next(num_test);
test_m(x); test_n(x); test_r(x); test_u(x); test_f(x);
x = rnd.Next(num_test);
test_n(x); test_r(x); test_u(x); test_f(x); test_m(x);
x = rnd.Next(num_test);
test_r(x); test_u(x); test_f(x); test_m(x); test_n(x);
x = rnd.Next(num_test);
test_u(x); test_f(x); test_m(x); test_n(x); test_r(x);
x = rnd.Next(num_test);
test_f(x); test_m(x); test_n(x); test_r(x); test_u(x);
x = rnd.Next(num_test);
}
Debug.Print("Normal (subscript+field): {0,18}", t_n);
Debug.Print("Typed-reference: {0,18}", t_r);
Debug.Print("C# Managed pointer: (ref delegate) {0,18}", t_m);
Debug.Print("C# Unsafe pointer: {0,18}", t_u);
Debug.Print("C# Managed pointer: (ref func): {0,18}", t_f);
}
由于实现每个特定方法的测试的代码片段有点长,我将先给出结果。时间是“ticks;”,较低的时间意味着更好。
Normal (subscript+field): 20,804,691
Typed-reference: 30,920,655
Managed pointer: (ref delegate) 18,777,666 // <- a close 2nd
Unsafe pointer: 22,395,806
Managed pointer: (ref func): 18,767,179 // <- winner
我很惊讶这些结果是如此明确的。`TypedReferences` 是最慢的,可能是因为它们随着指针一起携带类型信息。考虑到那个费力的“Normal”版本的 IL 代码的重量,它表现得出奇地好。模式转换似乎会对不安全代码造成伤害,以至于您真的必须证明、计划和测量每个要部署的位置。
但是,利用函数参数传递中的 `ref` 关键字指向数组内部部分以达到消除“每个字段访问”的数组索引计算目的可以实现最快的时间。
也许我的测试设计有利于这一个,但测试场景代表了我的应用程序中的经验使用模式。令我惊讶的是,在保持托管模式的优势的同时——同时拥有指针——并不会因为需要调用函数或通过委托调用而被取消。
胜者:
最快的一个:(也许是最简单的?)
static void f(ref rec e)
{
e.a = 4;
e.e = e.a;
e.b = e.d;
e.f = e.d;
e.b = e.e;
e.a = e.c;
e.b = 5;
e.d = e.f;
e.c = e.b;
e.e = e.a;
e.b = e.d;
e.f = e.d;
e.c = 6;
e.b = e.e;
e.a = e.c;
e.d = e.f;
e.c = e.b;
e.e = e.a;
e.d = 7;
e.b = e.d;
e.f = e.d;
e.b = e.e;
e.a = e.c;
e.d = e.f;
e.e = 8;
e.c = e.b;
e.e = e.a;
e.b = e.d;
e.f = e.d;
e.b = e.e;
e.f = 9;
e.a = e.c;
e.d = e.f;
e.c = e.b;
e.e = e.a;
e.b = e.d;
e.a = 10;
e.f = e.d;
e.b = e.e;
e.a = e.c;
e.d = e.f;
e.c = e.b;
}
static void test_f(int ix)
{
long q = sw.ElapsedTicks;
f(ref s5[ix]);
t_f += sw.ElapsedTicks - q;
}
但它的缺点是,你无法在程序中将相关逻辑保持在一起:函数的实现分散在两个C#函数
f和
test_f之间。
我们可以通过仅牺牲一点性能来解决这个特定问题。下一个示例与前面的示例基本相同,但将其中一个函数嵌入另一个函数作为lambda函数...
第二个示例非常接近。
在前面的示例中用静态函数替换内联委托需要使用
ref
参数,这反过来又排除了使用
Func<T>
lambda语法;而必须使用旧式.NET中的显式委托。
通过添加这个全局声明一次:
delegate void b(ref rec ee);
...我们可以在整个程序中使用它,直接ref
到数组rec []的元素中,在线访问它们:
static void test_m(int ix)
{
long q = sw.ElapsedTicks;
((b)((ref rec e) =>
{
e.a = 4;
e.e = e.a;
e.b = e.d;
e.f = e.d;
e.b = e.e;
e.a = e.c;
e.b = 5;
e.d = e.f;
e.c = e.b;
e.e = e.a;
e.b = e.d;
e.f = e.d;
e.c = 6;
e.b = e.e;
e.a = e.c;
e.d = e.f;
e.c = e.b;
e.e = e.a;
e.d = 7;
e.b = e.d;
e.f = e.d;
e.b = e.e;
e.a = e.c;
e.d = e.f;
e.e = 8;
e.c = e.b;
e.e = e.a;
e.b = e.d;
e.f = e.d;
e.b = e.e;
e.f = 9;
e.a = e.c;
e.d = e.f;
e.c = e.b;
e.e = e.a;
e.b = e.d;
e.a = 10;
e.f = e.d;
e.b = e.e;
e.a = e.c;
e.d = e.f;
e.c = e.b;
}))(ref s3[ix]);
t_m += sw.ElapsedTicks - q;
}
此外,尽管每次调用似乎会实例化一个新的lambda函数,但如果您小心谨慎,就不会发生这种情况:在使用此方法时,请确保不要“关闭”任何局部变量(即从其主体内部引用在lambda函数之外的变量)或执行任何其他将阻止委托实例成为静态的操作。如果一个局部变量恰好落入您的lambda中,并且lambda因此被提升为实例/类,则在尝试创建五百万个委托时,“可能”会注意到差异。
只要让lambda函数远离这些副作用,就不会有多个实例;这里发生的是,只要C#确定lambda没有非显式依赖项,它就会惰性地创建(并缓存)一个静态单例。令人遗憾的是,这种如此明显的性能变化被隐藏在我们看不见的优化中。总的来说,我喜欢这种方法。它快速而且简洁 - 除了奇怪的括号外,这里没有任何可以省略的内容。
其他测试
为了完整起见,这里是剩下的测试:普通的括号加点号;TypedReference;和不安全指针。
static void test_n(int ix)
{
long q = sw.ElapsedTicks;
s1[ix].a = 4;
s1[ix].e = s1[ix].a;
s1[ix].b = s1[ix].d;
s1[ix].f = s1[ix].d;
s1[ix].b = s1[ix].e;
s1[ix].a = s1[ix].c;
s1[ix].b = 5;
s1[ix].d = s1[ix].f;
s1[ix].c = s1[ix].b;
s1[ix].e = s1[ix].a;
s1[ix].b = s1[ix].d;
s1[ix].f = s1[ix].d;
s1[ix].c = 6;
s1[ix].b = s1[ix].e;
s1[ix].a = s1[ix].c;
s1[ix].d = s1[ix].f;
s1[ix].c = s1[ix].b;
s1[ix].e = s1[ix].a;
s1[ix].d = 7;
s1[ix].b = s1[ix].d;
s1[ix].f = s1[ix].d;
s1[ix].b = s1[ix].e;
s1[ix].a = s1[ix].c;
s1[ix].d = s1[ix].f;
s1[ix].e = 8;
s1[ix].c = s1[ix].b;
s1[ix].e = s1[ix].a;
s1[ix].b = s1[ix].d;
s1[ix].f = s1[ix].d;
s1[ix].b = s1[ix].e;
s1[ix].f = 9;
s1[ix].a = s1[ix].c;
s1[ix].d = s1[ix].f;
s1[ix].c = s1[ix].b;
s1[ix].e = s1[ix].a;
s1[ix].b = s1[ix].d;
s1[ix].a = 10;
s1[ix].f = s1[ix].d;
s1[ix].b = s1[ix].e;
s1[ix].a = s1[ix].c;
s1[ix].d = s1[ix].f;
s1[ix].c = s1[ix].b;
t_n += sw.ElapsedTicks - q;
}
static void test_r(int ix)
{
long q = sw.ElapsedTicks;
var tr = __makeref(s2[ix]);
__refvalue(tr, rec).a = 4;
__refvalue(tr, rec).e = __refvalue( tr, rec).a;
__refvalue(tr, rec).b = __refvalue( tr, rec).d;
__refvalue(tr, rec).f = __refvalue( tr, rec).d;
__refvalue(tr, rec).b = __refvalue( tr, rec).e;
__refvalue(tr, rec).a = __refvalue( tr, rec).c;
__refvalue(tr, rec).b = 5;
__refvalue(tr, rec).d = __refvalue( tr, rec).f;
__refvalue(tr, rec).c = __refvalue( tr, rec).b;
__refvalue(tr, rec).e = __refvalue( tr, rec).a;
__refvalue(tr, rec).b = __refvalue( tr, rec).d;
__refvalue(tr, rec).f = __refvalue( tr, rec).d;
__refvalue(tr, rec).c = 6;
__refvalue(tr, rec).b = __refvalue( tr, rec).e;
__refvalue(tr, rec).a = __refvalue( tr, rec).c;
__refvalue(tr, rec).d = __refvalue( tr, rec).f;
__refvalue(tr, rec).c = __refvalue( tr, rec).b;
__refvalue(tr, rec).e = __refvalue( tr, rec).a;
__refvalue(tr, rec).d = 7;
__refvalue(tr, rec).b = __refvalue( tr, rec).d;
__refvalue(tr, rec).f = __refvalue( tr, rec).d;
__refvalue(tr, rec).b = __refvalue( tr, rec).e;
__refvalue(tr, rec).a = __refvalue( tr, rec).c;
__refvalue(tr, rec).d = __refvalue( tr, rec).f;
__refvalue(tr, rec).e = 8;
__refvalue(tr, rec).c = __refvalue( tr, rec).b;
__refvalue(tr, rec).e = __refvalue( tr, rec).a;
__refvalue(tr, rec).b = __refvalue( tr, rec).d;
__refvalue(tr, rec).f = __refvalue( tr, rec).d;
__refvalue(tr, rec).b = __refvalue( tr, rec).e;
__refvalue(tr, rec).f = 9;
__refvalue(tr, rec).a = __refvalue( tr, rec).c;
__refvalue(tr, rec).d = __refvalue( tr, rec).f;
__refvalue(tr, rec).c = __refvalue( tr, rec).b;
__refvalue(tr, rec).e = __refvalue( tr, rec).a;
__refvalue(tr, rec).b = __refvalue( tr, rec).d;
__refvalue(tr, rec).a = 10;
__refvalue(tr, rec).f = __refvalue( tr, rec).d;
__refvalue(tr, rec).b = __refvalue( tr, rec).e;
__refvalue(tr, rec).a = __refvalue( tr, rec).c;
__refvalue(tr, rec).d = __refvalue( tr, rec).f;
__refvalue(tr, rec).c = __refvalue( tr, rec).b;
t_r += sw.ElapsedTicks - q;
}
static void test_u(int ix)
{
long q = sw.ElapsedTicks;
fixed (rec* p = &s4[ix])
{
p->a = 4;
p->e = p->a;
p->b = p->d;
p->f = p->d;
p->b = p->e;
p->a = p->c;
p->b = 5;
p->d = p->f;
p->c = p->b;
p->e = p->a;
p->b = p->d;
p->f = p->d;
p->c = 6;
p->b = p->e;
p->a = p->c;
p->d = p->f;
p->c = p->b;
p->e = p->a;
p->d = 7;
p->b = p->d;
p->f = p->d;
p->b = p->e;
p->a = p->c;
p->d = p->f;
p->e = 8;
p->c = p->b;
p->e = p->a;
p->b = p->d;
p->f = p->d;
p->b = p->e;
p->f = 9;
p->a = p->c;
p->d = p->f;
p->c = p->b;
p->e = p->a;
p->b = p->d;
p->a = 10;
p->f = p->d;
p->b = p->e;
p->a = p->c;
p->d = p->f;
p->c = p->b;
}
t_u += sw.ElapsedTicks - q;
}
概述
对于大规模 C# 应用程序中的内存密集型工作,使用 托管指针 直接访问 值类型数组元素 的字段是可行的。
如果你真的很在意性能,这可能足以成为你使用 C++/CLI
(或者同样适用于 CIL
)而不是 C#
来处理应用程序相关部分的原因之一,因为这些语言允许你在函数体内直接声明托管指针。
在 C#
中,创建托管指针的唯一方法是声明一个带有 ref
或 out
参数的函数,然后调用方将观察到托管指针。因此,在 C# 中获得性能优势,必须使用上述两种方法中的一种。 [请参见下面的 C#7]
遗憾的是,这些部署使用了将一个函数分成多个部分的拙劣方法,只为了访问数组元素。虽然不如等效的 C++/CLI
代码优雅,但测试表明,在 C# 中,对于高吞吐量应用程序,即使与朴素的值类型数组访问相比,我们仍然获得了巨大的性能优势。
[编辑于2017年: 尽管此文章中的一般忠告可能会赋予一定程度的先见之明,但是在同一时间发布的Visual Studio 2017
中的C# 7版本使得上述特定方法完全过时。简而言之,语言中的新ref locals功能允许您将自己的托管指针声明为局部变量,并将其用于合并单个数组解引用操作。因此,以上述测试结构为例...
public struct rec { public int a, b, c, d, e, f; }
static rec[] s7 = new rec[100000];
以下是如何编写与上述相同的测试函数:
static void test_7(int ix)
{
ref rec e = ref s7[ix]
e.a = 4
e.b = 5
e.c = 6
e.d = 7
e.e = 8
e.f = 9
e.a = 10
}
注意,这完全消除了我上面讨论的那些丑陋解决方案的需求。使用受控指针更为简洁,避免了“获胜者”中不必要的函数调用,这是我审查过的最佳性能方法之一。因此,使用新功能的性能只会比上述方法中的获胜者更好。
具有讽刺意味的是,C# 7 还添加了
本地函数,这个特性直接解决了我对前两种 hack 的封装不良的抱怨。令人高兴的是,为了获取对受控指针的访问而大量繁殖专用函数的整个企业现在已经完全无用了。
myCollection.OperateOnItem(index, (ref Point pt, ref int x) => pt.x = x, ref x);
语法最终变得相当丑陋,但不需要对象分配。 - supercatOperateOnItem
上的单个ref
参数就足够了。但是,我不确定该方法的泛型参数的确切性能影响,因为泛型有时会有效地分派,有时则不会... - supercat