为什么更新结构体数组比类数组慢?

3
为了对现有软件框架进行优化准备,我进行了独立的性能测试,以便在花费大量时间之前评估潜在收益。
情况如下: 共有N种不同类型的组件,其中一些实现了IUpdatable接口,这些是有趣的组件。它们分组在M个对象中,每个对象维护一个组件列表。更新它们的工作方式如下:
foreach (GroupObject obj in objects)
{
    foreach (Component comp in obj.Components)
    {
        IUpdatable updatable = comp as IUpdatable;
        if (updatable != null)
            updatable.Update();
    }
}

优化

我的目标是针对大量分组对象和组件进行优化更新。首先,确保按照种类将所有组件逐一更新,通过将它们缓存在每种组件的一个数组中。基本上,就是这样:

foreach (IUpdatable[] compOfType in typeSortedComponents)
{
    foreach (IUpdatable updatable in compOfType)
    {
        updatable.Update();
    }
}

这个想法的背后是,JIT或CPU在重复操作同一对象类型时,与在随机版本中进行操作相比,可能更容易操作。

在下一步中,我想进一步改善情况,确保一个组件类型的所有数据都能够在内存中对齐 - 通过将它存储在一个结构体数组中,类似于这样:

foreach (ComponentDataStruct[] compDataOfType in typeSortedComponentData)
{
    for (int i = 0; i < compDataOfType.Length; i++)
    {
        compDataOfType[i].Update();
    }
}

## 问题

在我的独立性能测试中,这些更改都没有带来显著的性能提升。我不确定为什么。没有显著的性能提升意味着,在10000个组件,每个批次运行100个更新周期时,所有主要测试需要大约85毫秒+/- 2毫秒。

(唯一的区别是引入了as强制转换和if检查,但这并不是我要测试的内容。)

  • All tests were performed in Release mode, without attached debugger.
  • External disturbances were reduced by using this code:

        currentProc.ProcessorAffinity = new IntPtr(2);
        currentProc.PriorityClass = ProcessPriorityClass.High;
        currentThread.Priority = ThreadPriority.Highest;
    
  • Each test actually did some primitive math work, so it's not just measuring empty method calls which could potentially be optimized away.

  • Garbage Collection was performed explicitly before each test, to rule out that interference as well.
  • The full source code (VS Solution, Build & Run) is available here

考虑到内存对齐和更新模式的重复,我本来期望会有显著的变化。因此,我的核心问题是:为什么我无法测量到显著的改进?我是否忽视了一些重要的事情?在测试中是否遗漏了某些内容?


1
@Jamel,你链接的帖子是关于Swift的,与C#有什么关系呢? - Ed Chapel
其背后的想法是…… - 那个想法是错误的。不要“猜测”问题出在哪里,而是进行测量。 - H H
@HenkHolterman 我在请求帮助解释我的性能测量结果 - 不太确定你的陈述意味着什么? - Adam
1
你做出了一个对我来说仍然看起来非常奇怪的假设,基于那个假设进行了一些改变(优化),但并没有发现任何变化。你觉得是哪里出了问题,是你的假设还是你的测量? - H H
@HenkHolterman 我的假设和测量都可能是错误的。我做了一个假设,试图验证它,但失败了。现在我想找出为什么会这样。这就是我问的原因。对我来说,你的“不要猜测,要测量”的评论似乎没有意义,因为它是针对那些刚刚进行了测量并真诚地试图讨论的人发表的。 “你的假设是错误的”是一个有效的答案,当然也是我正在寻找的答案之一 - 只是在那之后的评论让我有点困惑。 - Adam
显示剩余3条评论
1个回答

6
你传统上可能更喜欢后者的实现方式,主要原因是引用局部性。如果数组的内容适合CPU缓存,那么你的代码运行速度会快得多。相反,如果有很多缓存未命中,那么你的代码运行速度会慢得多。
我怀疑你的错误在于,你第一个测试中的对象可能已经具有良好的引用局部性。如果一次性分配了许多小对象,则这些对象即使位于堆上也可能是连续的。(我正在寻找更好的来源,但我自己的工作中已经有过同样的经历)即使它们不是连续的,GC 也可能将它们移动到连续的位置。由于现代 CPU 具有大型缓存,因此整个数据结构可能适合于 L2 缓存,因为周围没有太多其他竞争性的东西。即使缓存不大,现代 CPU 在预测使用模式和预取方面已经做得非常好了。
可能你的代码需要对结构体进行装箱/拆箱。然而,如果性能真的如此相似,这似乎不太可能发生。
在C#这样的低级别东西中,重要的是你需要信任框架来完成它的工作,或者在识别出低级别性能问题之后,在实际条件下进行分析。我很欣赏这可能只是一个玩具项目,或者你可能只是为了内存优化而摆弄,但像你在原始帖子中所做的预先优化很少会在项目规模上产生可观的性能提升。
我还没有详细查看你的代码,但我怀疑你在这里遇到的问题是不切实际的条件。随着更多内存压力,特别是更多组件的动态分配,你可能会看到你期望的性能差异。但是,你也可能不会看到,这就是为什么进行分析非常重要的原因。
值得注意的是,如果你事先确定严格手动优化内存局部性对于应用程序的正确功能至关重要,你可能需要考虑使用管理语言是否是正确的工具。
编辑:是的,问题几乎肯定是在这里:-
public static void PrepareTest()
{
  data = new Base[Program.ObjCount]; // 10000
  for (int i = 0; i < data.Length; i++)
    data[i] = new Data(); // Data consists of four floats
}

那10,000个Data实例在内存中可能是连续的。此外,它们很可能都适合您的缓存,因此我怀疑您在此测试中不会看到任何来自缓存未命中的性能影响。

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