【问题标题】:Performance of List<struct> vs List<class>List<struct> 与 List<class> 的性能
【发布时间】:2014-05-26 18:48:47
【问题描述】:

出于好奇,我尝试同时使用valuereference 类型来测试List&lt;T&gt; 的性能。

结果与我预期的不一样,这让我相信我对这些对象在内存中的布局方式的理解可能不正确。

这是我的实验:

  • 创建一个基本的class,只包含两个成员,一个int 和一个bool

  • 创建 2 个 List&lt;T&gt; 对象来保存我的测试类(List1List2

  • 随机生成测试对象并交替添加到List1List2

  • 遍历List1 需要多长时间(执行一些任意工作,例如递增计数器然后访问元素)

然后我用struct 代替class 重复了一遍

我的假设是,当使用 class 时,List&lt;T&gt; 中的引用是连续的,但由于我创建它们的方式(在添加到 List1List2 之间切换),它们的对象指向可能不会。

我认为当使用 struct 时,因为它是一种值类型,所以对象本身将连续保存在内存中(因为 List&lt;T&gt; 保存实际项目而不是引用集合)。

因此,我希望 struct 表现更好(由于预取器等)

事实上,两者非常相似。

这是怎么回事?

编辑 - 添加代码以实际访问迭代器中的元素,包括代码示例

测试类(或结构)

public class/struct TestClass
{
    public int TestInt;
    public bool TestBool;
}

创建随机列表:

var list1 = new List<TestClass>();
var list2 = new List<TestClass>();

var toggle = false;
for (var i=0; i < 4000000; i++)
{
    // Random object generation removed for simplicity

    if (toggle)
        list1.Add(randomObject);
    else
        list2.Add(randomObject);

    toggle = !toggle;    
}

测试:

var stopWatch = new Stopwatch();
var counter = 0;
var testBool = false;

stopwatch.Start();

foreach(var item in list1)
{
    // Access the element
    testBool = item.TestBool;
    counter++;
}

stopwatch.Stop();

TestObject 重复为classstruct

我意识到没有太大区别,但我预计 struct 的性能明显优于 class

【问题讨论】:

  • 向我们展示您的基准
  • 像这样的微基准测试是出了名的不可靠。代码太快了,像未对齐的分支目标这样简单的事情就可以产生很大的不同。标准错误是测量抖动时间并且没有重复测试至少 10 次。
  • @HansPassant 谢谢我尝试访问元素(代码示例和指标更新)性能似乎更加平等。我仍然希望struct 表现更好,但也许正如你所说,这个测试并不代表真实场景。
  • @HansPassant 您能否建议我如何正确地进行基准测试,或者在这样的简化示例中不可能给出真正的结果?

标签: c# memory-management struct clr prefetch


【解决方案1】:
// Access the element
testBool = item.TestBool;

这没有任何效果,优化器将删除该语句,因为它没有有用的副作用。您实际上并没有衡量结构和类之间的区别,因为您从未真正访问过元素。

counter++;

同样的故事,很可能会被优化掉。除非您在循环完成后真正使用 计数器值。让优化器删除太多代码并使测试变得毫无意义是一种常见的微基准风险。解决方法是:

foreach(var item in list1)
{
    // Access the element
    counter += item.TestInt;
}
Console.WriteLine(counter);

基准指南是:

  • 仅由发布配置生成的配置文件代码。 Debug 版本会产生过多的额外代码并抑制优化
  • Tools + Options,Debugging,General,取消勾选“Suppress JIT optimization on module load”。这可确保即使您使用调试器运行也能获得优化的代码
  • Debug + Windows + Disassembly 是一个非常重要的调试器窗口,它可以显示真正运行的代码。正确解释该窗口需要对机器代码有所了解
  • 在测试代码周围放置一个外循环非常重要,以确保您至少运行 10 次测试。这消除了冷启动效应,例如处理器必须填充 L1 指令高速缓存以及抖动必须从程序集中加载 IL 并在第一次执行时对其进行编译。并消除随机异常值,因为您必须与机器上运行的其他进程竞争并竞争处理器。
  • 15% 的差异没有统计学意义。

【讨论】:

    【解决方案2】:

    如果您实际上并未访问存储在列表中的类对象的成员,那么以下两种类型应该提供等效的迭代性能。

    1. List&lt;IntPtr&gt;
    2. List&lt;object&gt;

    即使引用类型 instances 没有填充内存的连续部分,references 本身也是。

    上述情况的例外情况是,如果 CLR 在执行内存小于 32GiB 的 64 位应用程序时压缩指针。此策略在 JVM 中记录为 Compressed OOPS。但是,x86-64 指令集包含允许此压缩/解压缩非常高效执行的指令,因此即使在这种情况下,您也应该看到类似于List&lt;int&gt; 的性能。

    当您的值类型超过指针的大小 (IntPtr.Size) 时,事情就会变得有趣。在那之后,包含引用的List&lt;T&gt; 的性能应该很快超过值类型的List&lt;T&gt; 的性能。这是因为无论您的引用类型 instance 有多大,对该实例的 reference 最多为 IntPtr.Size

    【讨论】:

    • 您知道 .NET 的任何 x64 实现是否使用 32 位引用吗?我一直认为,如果代码可以使用更大的 x64 寄存器文件而不必使用 64 位引用,那么将获得最佳性能,因为后者比 32 位引用占用更多的缓存空间。此外,超过 4GB 的最佳方法是让 x86 支持 32 位段寄存器,其中高位是描述符选择器,低位是可扩展的偏移量。这可能允许内存映射的某些部分在 4 字节边界上具有段,而其他部分......
    • ...然后在更大(例如 4K)的边界上,使得可以访问许多 gigs 的对象,这些对象的单个大小可能是几个 gigs,同时使用 32 位段和 32 -位偏移量。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2011-09-29
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2016-05-27
    • 1970-01-01
    • 2011-08-23
    相关资源
    最近更新 更多