何时使用结构体?

36
我正在进行一个兴趣项目——光线追踪器,最初我在我的矢量和射线对象中使用结构体,并且认为光线追踪器是使用它们的完美场景:你创建了数百万个它们,它们的生命周期不超过单个方法,它们很轻便。然而,仅仅将Vector和Ray上的'struct'更改为'class',我得到了非常显著的性能提升。
怎么回事?它们都很小(3个浮点数用于Vector,2个Vector用于Ray),不会被过度复制。当然,我需要在需要时将它们传递给方法,但这是不可避免的。那么,在使用结构体时会导致性能下降的常见陷阱是什么?我读过thisMSDN文章,其中说:
“运行此示例时,您会发现结构体循环速度要快几个数量级。但是,当您将ValueTypes像对象一样处理时,要注意不要这样做。这会给您的程序添加额外的装箱和取消装箱开销,并最终比坚持使用对象更费钱!要查看此操作,请修改上面的代码以使用foo和bar的数组。您会发现性能或多或少相等。”
但是,这篇文章已经相当老了(2001年),而整个“将它们放入数组中会导致装箱/取消装箱”让我感到奇怪。这是真的吗?然而,我确实预先计算了主光线并将其放入了一个数组中,所以我参考了这篇文章,当我需要时计算主光线并从未将其添加到数组中,但是它没有改变任何事情:使用类仍然比结构体快1.5倍。
我正在运行.NET 3.5 SP1,我相信该版本修复了结构体方法从未内联的问题,因此也不可能是这个问题。
所以基本上:有什么提示、需要考虑的事情和要避免的?
编辑:如一些答案中建议的那样,我设置了一个测试项目,在其中尝试将结构体作为ref传递。添加两个向量的方法:
public static VectorStruct Add(VectorStruct v1, VectorStruct v2)
{
  return new VectorStruct(v1.X + v2.X, v1.Y + v2.Y, v1.Z + v2.Z);
}

public static VectorStruct Add(ref VectorStruct v1, ref VectorStruct v2)
{
  return new VectorStruct(v1.X + v2.X, v1.Y + v2.Y, v1.Z + v2.Z);
}

public static void Add(ref VectorStruct v1, ref VectorStruct v2, out VectorStruct v3)
{
  v3 = new VectorStruct(v1.X + v2.X, v1.Y + v2.Y, v1.Z + v2.Z);
}

对于每个 I,我都得到了以下基准测试方法的一个变化:

VectorStruct StructTest()
{
  Stopwatch sw = new Stopwatch();
  sw.Start();
  var v2 = new VectorStruct(0, 0, 0);
  for (int i = 0; i < 100000000; i++)
  {
    var v0 = new VectorStruct(i, i, i);
    var v1 = new VectorStruct(i, i, i);
    v2 = VectorStruct.Add(ref v0, ref v1);
  }
  sw.Stop();
  Console.WriteLine(sw.Elapsed.ToString());
  return v2; // To make sure v2 doesn't get optimized away because it's unused. 
}

所有的表现都十分相似。是否可能它们被JIT优化为传递此结构的最佳方式?

编辑2:顺便说一下,在我的测试项目中使用结构体比使用类快大约50%。我不知道这在我的光线追踪器中为什么会有所不同。


祝你在项目中好运,光线追踪器是我即将要处理的东西。 - GurdeepS
请参见https://dev59.com/P3RB5IYBdhLWcg3wxZ1K#521343(特别是我的答案 :)) - Brian
2
创建一个光线追踪器非常有趣。我发现令人着迷的是,你可以从一堆浮点数和相对简单的向量数学中创建图像。 - JulianR
1
我认为这篇文章并没有说将结构体放入数组会导致装箱。它提醒我们,在需要对象的地方使用结构体确实会导致装箱;例如,如果你将它们传递给一个期望类型为 object 的参数的方法。 - ILoveFortran
可能是在C#中何时使用结构体?的重复问题。 - nawfal
12个回答

30
一个结构体数组是一块连续的内存空间,而对象数组(引用类型实例)中的项需要通过指针(即指向托管堆上的对象的引用)单独寻址。因此,如果你需要同时访问大量的项目,结构体将会带来性能提升,因为它们需要更少的间接引用。此外,结构体不能被继承,这可能允许编译器进行额外的优化(但这只是一个可能性,取决于编译器)。
然而,结构体具有不同的赋值语义,并且也不能被继承。因此,除非出于所需的性能原因,否则我通常会避免使用结构体。
struct 由结构体(值类型)编码的值v的数组在内存中看起来像这样:
vvvv class 由类(引用类型)编码的值v的数组在内存中看起来像这样: pppp ..v..v...v.v..
其中p是this指针或引用,指向堆上实际的值v。点表示可能穿插在堆上的其他对象。对于引用类型,您需要通过相应的p引用v;对于值类型,您可以通过其在数组中的偏移量直接获取该值。

此外,除了多余的间接性,对于vvvv的线性遍历是缓存友好的,而对于..v..v...v.v..的线性遍历则不是。现代硬件按64字节块进行内存请求。即使您想从某个特定的内存位置加载8个字节,也会传输和缓存64个字节在CPU上 - 即仅有8/64=0.125的有效负载。 - Evgeny Panasyuk
你有关于结构体数组按照你所描述的方式工作的来源吗?(我知道它们是值类型,但也许数组存储对这些值类型的引用?) - ispiro

12
在关于何时使用结构体的建议中,它说结构体的大小不应超过16字节。你的向量是12字节,接近限制。射线有两个向量,使其达到了24字节,显然超出了建议的限制。
当结构体大于16字节时,无法使用单组指令有效地复制它,而必须使用循环。因此,通过传递这个“神奇”的限制,当你传递结构体时,实际上要做更多的工作,而当你传递对象的引用时则不然。这就是为什么虽然分配对象时存在更多开销,但使用类的代码更快的原因。
向量仍然可以是结构体,但射线作为结构体使用效果不好。

我明白了,没想到16字节的限制是如此严格,以为它更像是一条指南。然而,有趣的是,将我的Ray作为结构体并不会使应用程序变慢,而将我的Vector作为结构体确实会使其变慢50%。 - JulianR
2
将Vector作为类,Ray作为结构体会使Ray包含两个引用。这在大小上可以工作,但可能会产生一些令人惊讶的语义效果。将两者都设置为结构体是导致Ray结构体超过大小限制的原因。 - Guffa
1
结构体处理针对16字节或更少的情况进行了优化;因此,17字节的结构体性能将明显比16字节的结构体差。另一方面,如果尽可能避免通过值传递结构体(而是尽可能使用“ref”传递),即使是100字节的结构体也可以比100字节的类表现得更好。 - supercat
@supercat:将结构体通过引用传递与按值传递类几乎相同。如果您实际上使用结构/类中的数据,则由于通过引用传递添加了额外的间接性,类实际上会表现得稍微更好。此外,如果您必须在各处通过引用传递结构体,则被迫编写相当丑陋的代码。 - Guffa
@Guffa:无论是使用类还是结构体,间接性都存在。如果为每个元素使用单独的类实例,则除了创建类对象所需的时间和额外的12-24字节的类引用和对象开销之外,性能几乎相同。主要区别在于,如果我说someProc(ref myStruct);,则该过程将能够改变myStruct仅限于返回。相比之下,如果可变类对象暴露给外部代码,就无法知道何时会导致其更改。 - supercat

9

在.NET泛型出现之前,关于装箱/拆箱的任何内容都需要谨慎对待。泛型集合类型已经消除了值类型的装箱和拆箱需求,这使得在这些情况下使用结构更有价值。

至于您特定的减速问题 - 我们可能需要看一些代码。


大概是这样,但某种类型的数组不一直都是“通用”的吗?或者在.NET 1.0中,int[]内部是一个object[]吗?至于源代码:我几乎无法在这里发布整个源代码,但我会看看能否找到相关的东西。 - JulianR
是的,数组一直(在某种程度上)是泛型的。 - configurator

7

基本上,不要使它们过大,并在可以时通过引用传递它们。我也是通过将我的向量和射线类更改为结构体才发现这一点。

随着更多的内存传递,这必然会导致缓存抖动。


2
传递引用?使用结构体的原因是它非常小,复制它比拥有对其的引用的开销更高效... - Guffa
3
由于我现在的经验比当初询问时更加丰富,所以我进行了一次处理。在重新编写光线追踪器时,通过引用传递所有结构确实更快。但是,这确实让我的代码变得非常丑陋。简短的数学单行语句变成了5行的Vector3.Add/Subtract/Multiply等块,并且我不得不使用很多公共字段,因为属性无法通过引用传递(这很有道理,毕竟它是一个方法)。尽管如此,现在它真的很快,在非常简单的场景中可以实现实时帧率。 - JulianR
@JulianR:在许多情况下,传递结构体的“正确”方式是通过引用传递。如果可以避免在不需要独立副本的任何情况下按值传递或复制结构体,则大型结构体的性能可以与小型结构体一样好。这种方法的唯一困难在于,在某些上下文中,系统将坚持对结构体进行防御性复制(例如,在只读存储位置上调用任何成员方法时)。类在某些情况下可能具有优势,但当这些优势不需要时,通过引用传递的结构体胜出。 - supercat
1
@ErikForbes:当结构体被存储为独立的堆对象时,它们会被装箱,这些对象必须在使用它们的代码或对象的上下文之外继续存在。每个独立的堆对象都需要存储类型信息,因为可能没有其他标识其类型的内容。相比之下,如果有一个类型为“Rectangle”的变量,使用该变量的代码将知道它是一个“Rectangle”;同样,如果一个类有一个该类型的字段,使用该字段的代码也将知道它的类型。将像List<int>.Enumerator这样的结构体传递给代码... - supercat
1
如果一个方法期望的是 IEnumerator<int>,那么它会将其装箱,因为使用枚举器的代码不知道它所接收到的是一个 List<int>.Enumerator。相反,如果编写了一个像 void UseEnumerator<T>(ref T theEnumerator) where T:IEnumerator<int> 的方法,并使用 List<int>.Enumerator 调用它,系统将生成 UseEnumerator<List<int>.Enumerator>() 的特殊版本,该版本将知道其参数的确切类型;由于代码知道确切的类型,因此不需要进行装箱。 - supercat
显示剩余4条评论

7
我认为你帖子中的这两个声明是关键:
“你创建了数百万个结构体”
以及
“当需要时,我确实将它们传递给方法”
现在,除非你的结构体大小小于或等于4个字节(如果你在64位系统上则为8个字节),否则每次调用方法时都会复制比只传递对象引用更多的内容。

1
这仍然比大量的垃圾回收发生要快... - TraumaPony
1
它是否真的执行任何GC操作?如果我渲染一个非常大的图像,我的进程的内存使用量会无限制地增加,可能是因为即使使用了3 GB,我仍然有足够的内存,也许在这种情况下GC会等到我完成CPU占用后再执行。 - JulianR
好的,它应该正在进行垃圾收集。至少,这是我的光线追踪器出现问题的原因。 - TraumaPony
只有在不将这些结构体传递给其他方法时才是正确的。 - Andrew Hare
2
如果要创建数百万个不同的实例,那么struct将比不可变类提供更好的性能。几乎总是如此。唯一情况下类会胜出是当大多数引用指向可以与其他引用共享的实例时。有一百万个引用全部指向三个不可变类实例之一可能比拥有一百万个结构体更好,这些结构体所有“碰巧”持有三个字段组合中的一个,但如果这一百万个引用都指向不同的类实例,使用类而不是结构体将没有任何优势。 - supercat
显示剩余3条评论

6

首先,我会检查您是否已经明确实现了Equals和GetHashCode。如果没有这样做,运行时实现每个结构实例的比较都会执行非常昂贵的操作(内部使用反射来确定每个私有字段,然后检查它们是否相等,这会导致大量分配)。

通常情况下,最好的方法是在性能分析器下运行代码,查看哪些部分运行缓慢。这可能会让你大开眼界。


我尝试了覆盖,即使我没有使用任何向量或光线比较,但它没有效果。不过这是个好建议,从现在开始我会确保覆盖Equals和GetHashCode :) - JulianR

4

您是否对应用程序进行了剖析?剖析是唯一确保查看实际性能问题所在的方法。有些操作通常对结构体更好/更差,但除非进行剖析,否则您只能猜测问题所在。


2

虽然功能类似,但结构体通常比类更有效率。

如果类型作为值类型比引用类型更高效,则应定义结构体而不是类。具体来说,结构体类型应满足以下所有条件:

  • 逻辑上代表单个值
  • 实例大小小于16字节
  • 创建后不会被更改
  • 不会转换为引用类型

3
我不同意Eric Lippert对可变结构体的厌恶。确实,.net设计上的某些限制使得可变结构体不如它们本应该友好,但是,可变结构体的数组通常是存储东西的正确方式。在64位机器上,一个由一百万个8字节结构体组成的数组将占用8兆字节的内存;相比之下,一个有着8字节字段值的类的一百万个实例将占用40兆字节的内存。即使一个结构体包含40字节的数据(远超过16字节的推荐阈值),它仍然可以将内存使用量减少50%。 - supercat

0
我的光线追踪器也使用结构体向量(虽然不是射线),将Vector更改为类似的形式似乎对性能没有影响。我目前正在使用三个双精度浮点数表示向量,因此它可能比应该的要大。需要注意的一件事是,如果你在Visual Studio之外运行程序,即使你将其设置为优化版本,你也可以获得巨大的速度提升。任何你进行的基准测试都应该考虑到这一点。

出于好奇,你的向量类型是透明的还是不透明的?暴露字段结构通常比不透明的结构表现更好。编写 myVec.x = expr1; myVec.y = expr2; myVec.z = expr3; 很可能需要与仅为构造函数调用设置参数相同的时间。调用本身以及在构造函数中花费的任何时间都将代表纯粹的浪费开销。同样,在 JITter 可能优化仅读取后备字段 _x 的属性读取 myPoint.x 的情况下,有许多情况 JITter 不会这样做。 - supercat
据我所知,一些32位版本的.net(至少2.0和3.x)在优化类属性访问方面要比结构体属性访问好得多,因此从不透明结构体切换可能并不比类更快,即使透明结构体的性能也会远远超过类或不透明结构体。 - supercat
我的向量是只读的,通过构造函数设置。我对各种实现进行了大量测试,没有找到比这更快的东西,但我不记得是否使用透明结构进行测试。在 .NET 4 中,他们对结构和内联进行了一些优化,这有助于解决您提到的某些问题。此外,自从写出我的回答以来,我已将光线更改为结构体,以获得约10%的性能提升。 - Morten Christiansen
我很好奇如果你从不透明结构体改为透明结构体,会得到什么样的性能表现。我的基准测试表明,有时差异很小,但有时它们可能相当大。 - supercat

0

我基本上使用结构体作为参数对象,从函数中返回多个信息,以及...没有别的用途。不知道这是否“正确”或“错误”,但这就是我所做的。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接