【问题标题】:Is this a good fit for a class or struct (speed is more important than memory)?这是否适合类或结构(速度比内存更重要)?
【发布时间】:2012-08-20 11:48:36
【问题描述】:

通常,我永远不必问自己给定的场景是否更适合结构或类,坦率地说,在这种情况下,我在上课之前没有问过这个问题。现在我正在优化,事情变得有点混乱。

我正在编写一个数字运算应用程序,用于处理包含数百万个 Base10 数字的超大数字。这些数字是 2D 空间中的 (x,y) 坐标。主要算法是相当连续的,并且在任何给定时间在内存中都没有超过 200 个 Cell 类的实例(如下所列)。该类的每个实例占用大约 5MB 的内存,导致应用程序的总峰值内存不超过 1GB。成品将在 16 核机器上运行,具有 20GB 的 RAM,并且没有其他应用程序占用资源。

这是课程:

// Inheritance is convenient but not absolutely necessary here.
public sealed class Cell: CellBase
{
    // Will contain numbers with millions of digits (512KB on average).
    public System.Numerics.BigInteger X = 0;
    // Will contain numbers with millions of digits (512KB on average).
    public System.Numerics.BigInteger Y = 0;

    public double XLogD = 0D;
    // Size of the array is roughly Base2Log(this.X).
    public byte [] XBytes = null;

    public double YLogD = 0D;
    // Size of the array is roughly Base2Log(this.Y).
    public byte [] YBytes = null;

    // Tons of other properties for scientific calculations on X and Y.
    // NOTE: 90% of the other fields and properties are structs (similar to BigInteger).

    public Cell (System.Numerics.BigInteger x, System.Numerics.BigInteger y)
    {
        this.X = x;
        this.XLogD = System.Numerics.BigInteger.Log(x, 2);
        this.XBytes = x.ToByteArray();

        this.Y = y;
        this.YLogD = System.Numerics.BigInteger.Log(y, 2);
        this.YBytes = y.ToByteArray();
    }
}

我选择使用类而不是结构只是因为它“感觉”更自然。字段、方法和内存的数量都本能地指向类而不是结构。通过考虑临时分配调用会产生多少开销,我进一步证明了这一点,因为底层的主要对象是 BigInteger 的实例,而 BigInteger 本身就是一个结构。

问题是,考虑到速度效率是这种情况下的最终目标,我是否明智地选择了这里?

这里有一些关于算法的信息,以防万一。在每次迭代中:

  1. 对所有 200 个实例执行一次排序。 20% 的执行时间。
  2. 计算感兴趣的相邻 (x,y) 坐标。 60% 的执行时间。
  3. 上述第 2 点的并行/线程开销。 10% 的执行时间。
  4. 分支开销。 10% 的执行时间。
  5. 最昂贵的函数:BigInteger.ToByteArray() (implementation)

【问题讨论】:

    标签: c# .net class struct


    【解决方案1】:

    这将更适合作为一个类,原因有很多,包括

    • 它在逻辑上不代表单个值
    • 大于 16 个字节
    • 它是可变的

    详情请见Choosing Between Classes and Structures

    此外,我还建议它更适合给定的类:

    • 它包含引用类型(数组)。包含类的结构很少是一个好的设计理念。

    但是,考虑到您正在做的事情,这尤其正确。如果您要使用struct,排序将需要整个结构的副本,而不仅仅是引用的副本。方法调用(除非通过 ref 传递)也会产生巨大的开销,因为您将复制所有数据。

    集合中项目的并行化也可能会产生巨大的开销,因为对结构的任何数组的边界检查(即:如果它保存在 List<Cell> 或类似中)将导致错误的错误共享,因为所有访问该列表将访问列表开头的内存。

    我建议将其保留为一个类,此外,我建议尝试将字段移动到属性中,并使该类尽可能不可变。这将有助于保持您的设计简洁,并且在多线程时不太可能出现问题。

    【讨论】:

    • 可变性在这里可能不是问题,因为他认真地瞄准了性能。我认为现在设计问题并不重要。
    • @usr 设计问题往往是性能的关键——如果您希望能够在优化阶段重构多线程代码,那么干净的设计(尤其是不变性)是至关重要的。优化(好)设计糟糕的代码几乎是不可能的。
    • @ReedCopsey,我感谢您对设计的重视,但就 LOC 和分支而言,算法的实际复杂性非常简单。事实上,当我完成优化时,应用程序将只包含它现在执行的 10% 的方法。所以是的,速度是关键。
    • @RaheelKhan 在这种情况下,两者很可能是齐头并进的。但是,是的,撇开这些不谈,课程仍然可能是最好的选择。您提到排序 - 什么类型的集合包含 200 个单元格?
    • @RaheelKhan List<T> 在内部使用数组。当您在项目上并行执行工作时,按索引查找在内部使用数组查找,这反过来又查找存储在数组开始之前的值(用于边界检查)。这可能会导致错误共享 - 避免这种情况是个好主意,只需坚持引用,这样您就可以获得 1) 良好的缓存一致性和 2) 避免错误共享。
    【解决方案2】:

    根据您所写的内容很难判断(例如,我们不知道您最终复制 Cell 类型的值的频率),但我强烈期待 @ 987654323@ 是正确的方法。

    类中方法的数量无关紧要,但如果它有很多字段,则需要考虑在将值传递给另一个方法(等)时复制所有这些字段的影响.

    从根本上说,它感觉不像是一种价值类型,但我知道,如果性能特别重要,那么哲学方面对你来说可能就不那么有趣了。

    所以是的,我认为你做出了正确的决定,我认为目前没有理由相信其他任何事情 - 但当然,如果你可以轻松地改变决定并测试它一个结构,这比猜测要好。性能非常难以准确预测。

    【讨论】:

    • 正确。与总共 1GB 相比,这种类型的 200 个实例的堆开销低得离谱。
    • 关于赋值,没有太多显式的情况,但是,考虑构造函数。它需要两个 BigInteger 参数,它们是结构。那很贵(或者是吗?)。同样,类中的许多其他功能也是如此。我想知道为什么 MS 决定将 BigInteger 保留为结构。我本来希望在运行时向 BigInt 注入代码以访问底层数据,但对于生产应用程序却不能这样做。
    • @RaheelKhan:我怀疑BigInteger 在幕后使用了一个数组(或其他类)作为一个字段——否则它无法处理任意大小的数据,不是吗?跨度>
    • 是的,它肯定使用内部数组。 Here 是 ToByteArray 实现。我想这意味着 BigInteger 是一个结构这一事实并不是真正的瓶颈。
    • 顺便感谢您在回答中的最后一个建议。我下意识地避免重构为结构,但你是对的,没有什么比结果更能说明问题了。
    【解决方案3】:

    由于您的类确实包含消耗大部分内存的数组,并且您只有 200 个单元实例,因此类本身的内存消耗不是问题。你说得对,一门课感觉更自然,这确实是正确的选择。我的猜测是 XByte[] 和 XYBytes[] 的比较确实限制了您的排序时间。这完全取决于您的数组有多大以及您如何进行比较。

    【讨论】:

    • 字节数组很痛苦,是的。它们的存在只是为了避免重复调用 BigInteger.ToByteArray()。数组的大小当然是 x 和 y 的以 2 为底的对数。
    • 可能值得包装这些字节数组的使用,这样您只需反复调用ToByteArray 就可以快速更改它。然后,您可以测试内存成本是否真正节省了那些重复调用的成本,并且确实在稍后再次测试(因为应用程序的更改会改变调用频率与内存使用的平衡)。
    【解决方案4】:

    让我们开始忽略性能问题,并努力解决它们。

    结构是值类型,值类型是值类型。整数和DateTime 是值类型,是很好的比较。谈论一个11 有什么不同,或者一个2010-02-03T12:45:23.321Z 与另一个2010-02-03T12:45:23.321Z 有什么不同是没有意义的。它们在不同的用途中可能具有不同的意义,但是 1 == 1 和 1 != 2 以及 2010-02-03T12:45:23.321Z == 2010-02-03T12:45:23.321Z 和 2010-02-03T12 :45:23.321Z != 2931-03-05T09:21:29.43Z 是整数和日期时间的本质所固有的,这就是使它们成为值类型的原因。

    这是最纯粹的思考方式。如果它与上面的匹配,它是一个值类型,如果它不匹配,它是一个引用类型。没有其他内容。

    扩展 1:如果一个 X 可以有一个 X,那么它必须是一个引用类型。这是否从上面所说的逻辑上得出是有争议的,但是无论您对此事有何看法,在实践中,您都不能拥有一个结构,该结构具有自身的另一个实例作为成员(直接或间接),就是这样。

    扩展2:有人说可变结构带来的困难来自上述,而有些则不然。不过,无论您对此事有何看法,都存在实际困难。可变结构在少数情况下可能有用,但它们会引起足够的混乱,因此它们应该被限制在私有情况下作为优化,而不是理所当然的公开情况。

    性能位来了...

    值类型和引用类型在不同的情况下具有不同的特性,它们会影响速度、内存使用以及内存使用对垃圾收集的影响方式,这些方式在性能方面各有不同的优缺点。我们对此付出了多少关注,取决于我们需要多少才能达到那个水平。现在值得说的是,如果您遵循上述在 struct 和 class 之间做出决定的规则,那么它们的不同方式往往会取得平衡,因此如果我们开始考虑这一点,那么我们至少接近于优化领域。

    优化级别 1。

    如果一个值类型实例每个实例包含超过 16 个字节,它可能应该被作为一个引用。这有时甚至被称为“自然”差异,而不是优化之一。严格来说,“值类型”中没有任何内容需要“16 个或更少字节”,但它确实倾向于以这种方式平衡。

    摆脱简单的“16 字节”规则,它越小复制速度越快,反之亦然,因此将其弯曲为 20 字节的实例比将其弯曲为 200 字节的影响要小。字节实例。

    您需要大量装箱和拆箱吗?自从引入泛型以来,我们已经能够避免很多使用 1.0 和 1.1 装箱和拆箱的情况,所以这不像以前那么重要,但如果这样做会损害性能。

    优化级别 2。

    值类型可以放置在堆栈中,直接放置在数组中(而不是引用它们)并且是结构或类的直接字段(同样,而不是引用它们)的事实可以访问它们并更快地到达他们的领域。

    如果您要创建它们的数组并且如果全零值对您来说是一个有用的起点,您会立即得到它,而与引用类型一样,您会得到一个数组空值。这可以使结构更快。

    编辑: 从上面延伸出来的东西,如果您要快速迭代数组,那么以及直接访问比遵循参考提供了提升,您将一次将几个实例加载到 CPU 缓存中(当前 x86-32 或 x86-64/amd 上为 64 字节,在 ia-64 上为 128 字节)。它必须是一个非常紧密的循环,但在某些情况下确实如此。

    几乎大多数“我选择结构而不是类来获得性能”归结为第一点,或者第一点与第二点的结合。

    优化级别 3。

    如果您所关心的某些值相互重复,并且它们的大小很大,那么使用不可变实例(或一旦开始执行以下操作就永远不会改变的可变实例),您可以故意为不同的引用设置别名,以便节省大量内存,因为您的例如20 个大小为 2kiB 的重复对象实际上是同一个对象,因此在这种情况下节省了 26kiB。它还可以使比较更快,因为您可以在身份上捷径的情况更加频繁。这只能通过引用类型来完成。

    优化级别 4。

    具有数组的结构虽然对包含的数组起别名,但可以在内部使用上述技术,平衡这一点,尽管它涉及更多。

    优化级别 X。

    如果实际衡量结果会得出不同的结果,那么对这些利弊进行多少思考就会得出一个特定的答案,这并不重要。由于有利有弊,因此总是有可能出错。

    在考虑 1 到 4 时,除了这些优化问题之外,除了值类型和引用类型之间的差异之外,我认为您应该选择一个类。

    在考虑 X 级时,如果您的实际测试证明我错了,我不会感到惊讶。最好的一点是,如果从 class 更改为 struct 很困难(您大量使用别名或 null 值的可能性),那么您可以确信这样做是失败的。如果它不费力,那么你可以这样做并测量!我强烈建议测量一个涉及实际运行 10,000 次的测试 - 如果您在真实事物中多次执行不同的操作 20 次,谁会在乎您是否可以在几秒钟内执行给定的操作 10,000 次?

    【讨论】:

    • 说得好,谢谢。这绝对是“类与结构”问题的参考答案,应该在此页面上获得更高的可见性。 Jon Skeet 打败了你,但我敢打赌他会同意的。
    • 我正在考虑重构以通过引用传递所有结构方法参数,但重新阅读上面的一些观点告诉我,这也不会有多大好处。以构造函数为例,它需要两个内部数组大小总计 1MB 的结构体,但结构体堆只存储对数组的引用,因此装箱(通过引用)在那里不会有多大用处。
    • 不,此时装箱只会为您节省 8 个字节的复制,而且您已经增加了装箱和以后拆箱的成本。 BigInterval 本身就是上述几点的一个很好的例子:它的大小是 8 或 12 字节(32/64 位版本),但可以更大的数组是引用类型。不变性允许在同一整数的不同副本之间安全地引用数组,从而节省内存。看看我上面的优化级别,它是两全其美的,因为它是一个引用大引用类型的小型不可变结构......
    • ... 但请注意,如果我们只关注“让我们开始忽略性能问题”这一点,那么在这种情况下我们可能也会选择 struct。毕竟,进入 .NET 本身的设计决策都是将值/引用区别编码到类型系统中(例如,C++ 的区别与类型完全不同,这在某些方面是一个优点,但在其他方面会增加复杂性)并考虑如何对待他们。大多数情况下,“自然”结构和“自然”类在构建时效率更高,而不是相反。
    • 同意。我从一大堆潜在的优化开始,当我深入应用每一个优化时,“自然”设计的效率使它变得毫无意义。到这个时候,我从数百个列表中只剩下少数需要改进的地方。
    【解决方案5】:

    只有在以下两种情况下,结构才能安全地包含数组类型字段:(1) 结构的状态取决于数组的标识而不是其内容(如 ArraySegment 的情况),或 (2)任何可能试图改变它的东西都不会持有对数组的引用(通常,这意味着数组字段将是私有的,并且结构本身将创建数组并执行将对其进行的所有修改,在字段中存储引用之前)。

    我在这里比其他人更普遍地提倡使用结构,但是你的数据存储东西将有两个数组类型字段这一事实似乎是反对使用结构的有力论据。

    【讨论】:

      猜你喜欢
      • 2021-07-19
      • 1970-01-01
      • 1970-01-01
      • 2019-08-14
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2023-03-25
      • 1970-01-01
      相关资源
      最近更新 更多