为什么在这种特定情况下,结构体比类快那么多?

12
我有三个案例来测试类、继承类和结构体的相对性能。这些将用于紧密循环,因此性能很重要。点积是2D和3D几何中许多算法的一部分,我已经在真实代码上运行了分析器。下面的测试反映了我见过的真实世界性能问题。
100000000次循环和点积应用的结果如下:
ControlA 208 ms   ( class with inheritence )
ControlB 201 ms   ( class with no inheritence )
ControlC 85  ms   ( struct )

测试时未开启调试和优化。我的问题是,在这种情况下,是什么导致类变得如此缓慢?我认为JIT仍然能够内联所有的调用,无论是类还是结构体,因此结果应该是相同的。请注意,如果我禁用优化,则结果相同。
ControlA 3239
ControlB 3228
ControlC 3213

如果重新运行测试,它们始终相差不超过20毫秒。

调查中的类

using System;
using System.Diagnostics;

public class PointControlA
{
    public double X
    {
        get;
        set;
    }

    public double Y
    {
        get;
        set;
    }

    public PointControlA(double x, double y)
    {
        X = x;
        Y = y;
    }
}

public class Point3ControlA : PointControlA
{
    public double Z
    {
        get;
        set;
    }

    public Point3ControlA(double x, double y, double z): base (x, y)
    {
        Z = z;
    }

    public static double Dot(Point3ControlA a, Point3ControlA b)
    {
        return a.X * b.X + a.Y * b.Y + a.Z * b.Z;
    }
}

public class Point3ControlB
{
    public double X
    {
        get;
        set;
    }

    public double Y
    {
        get;
        set;
    }

    public double Z
    {
        get;
        set;
    }

    public Point3ControlB(double x, double y, double z)
    {
        X = x;
        Y = y;
        Z = z;
    }

    public static double Dot(Point3ControlB a, Point3ControlB b)
    {
        return a.X * b.X + a.Y * b.Y + a.Z * b.Z;
    }
}

public struct Point3ControlC
{
    public double X
    {
        get;
        set;
    }

    public double Y
    {
        get;
        set;
    }

    public double Z
    {
        get;
        set;
    }

    public Point3ControlC(double x, double y, double z):this()
    {
        X = x;
        Y = y;
        Z = z;
    }

    public static double Dot(Point3ControlC a, Point3ControlC b)
    {
        return a.X * b.X + a.Y * b.Y + a.Z * b.Z;
    }
}

测试脚本

public class Program
{
    public static void TestStructClass()
    {
        var vControlA = new Point3ControlA(11, 12, 13);
        var vControlB = new Point3ControlB(11, 12, 13);
        var vControlC = new Point3ControlC(11, 12, 13);
        var sw = Stopwatch.StartNew();
        var n = 10000000;
        double acc = 0;
        sw = Stopwatch.StartNew();
        for (int i = 0; i < n; i++)
        {
            acc += Point3ControlA.Dot(vControlA, vControlA);
        }

        Console.WriteLine("ControlA " + sw.ElapsedMilliseconds);
        acc = 0;
        sw = Stopwatch.StartNew();
        for (int i = 0; i < n; i++)
        {
            acc += Point3ControlB.Dot(vControlB, vControlB);
        }

        Console.WriteLine("ControlB " + sw.ElapsedMilliseconds);
        acc = 0;
        sw = Stopwatch.StartNew();
        for (int i = 0; i < n; i++)
        {
            acc += Point3ControlC.Dot(vControlC, vControlC);
        }

        Console.WriteLine("ControlC " + sw.ElapsedMilliseconds);
    }

    public static void Main()
    {
        TestStructClass();
    }
}

这个dotnet fiddle只是编译的证明,它并没有展示性能差异。

我正试图向供应商解释,为什么他们选择使用类而不是结构体来表示小数值类型是一个糟糕的想法。现在我有了测试用例来证明它,但我无法理解其中的原因。

注意:我尝试开启JIT优化来设置断点,但调试器不会停止。关闭JIT优化后查看IL也没法得到任何信息。

编辑

在@pkuderov的回答之后,我拿起了他的代码并进行了一些修改。我改变了代码,并发现如果我通过强制内联来实现

   [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static double Dot(Point3Class a)
    {
        return a.X * a.X + a.Y * a.Y + a.Z * a.Z;
    }

结构体和类在点乘方面的区别消失了。为什么有些设置不需要该属性,但对我来说却不清楚。然而,我没有放弃。供应商代码仍存在性能问题,我认为DotProduct不是最好的例子。 我修改了@pkuderov的代码以实现“向量加法”,这将创建结构体和类的新实例。结果在此处。

https://gist.github.com/bradphelan/9b383c8e99edc38068fcc0dccc8a7b48

In the example, 我还修改了代码,从数组中选择一个伪随机向量,以避免实例粘在寄存器上的问题(希望如此)。结果表明:对于类Vector Add和我假设的任何创建新对象的操作,DotProduct性能相同或更快。
Add class/class 2777ms Add struct/struct 2457ms
DotProd class/class 1909ms DotProd struct/struct 2108ms
如果有人想尝试,请查看完整的代码和结果here
再次编辑:
对于向量相加的示例,其中一个向量数组被总结在一起,结构体版本将累加器保留在3个寄存器中。
 var accStruct = new Point3Struct(0, 0, 0);
 for (int i = 0; i < n; i++)
     accStruct = Point3Struct.Add(accStruct, pointStruct[(i + 1) % m]);

汇编代码主体是

// load the next vector into a register
00007FFA3CA2240E  vmovsd      xmm3,qword ptr [rax]  
00007FFA3CA22413  vmovsd      xmm4,qword ptr [rax+8]  
00007FFA3CA22419  vmovsd      xmm5,qword ptr [rax+10h]  
// Sum the accumulator (the accumulator stays in the registers )
00007FFA3CA2241F  vaddsd      xmm0,xmm0,xmm3  
00007FFA3CA22424  vaddsd      xmm1,xmm1,xmm4  
00007FFA3CA22429  vaddsd      xmm2,xmm2,xmm5  

但是对于基于类的向量版本,它每次都需要读取和写入累加器到主内存中,这是低效的。
var accPC = new Point3Class(0, 0, 0);
for (int i = 0; i < n; i++)
    accPC = Point3Class.Add(accPC, pointClass[(i + 1) % m]);

汇编代码主体是

// Read and add both accumulator X and Xnext from main memory
00007FFA3CA2224A  vmovsd      xmm0,qword ptr [r14+8]     
00007FFA3CA22250  vmovaps     xmm7,xmm0                   
00007FFA3CA22255  vaddsd      xmm7,xmm7,mmword ptr [r12+8]  


// Read and add both accumulator Y and Ynext from main memory
00007FFA3CA2225C  vmovsd      xmm0,qword ptr [r14+10h]  
00007FFA3CA22262  vmovaps     xmm8,xmm0  
00007FFA3CA22267  vaddsd      xmm8,xmm8,mmword ptr [r12+10h] 

// Read and add both accumulator Z and Znext from main memory
00007FFA3CA2226E  vmovsd      xmm9,qword ptr [r14+18h]  
00007FFA3CA22283  vmovaps     xmm0,xmm9  
00007FFA3CA22288  vaddsd      xmm0,xmm0,mmword ptr [r12+18h]

// Move accumulator accumulator X,Y,Z back to main memory.
00007FFA3CA2228F  vmovsd      qword ptr [rax+8],xmm7  
00007FFA3CA22295  vmovsd      qword ptr [rax+10h],xmm8  
00007FFA3CA2229B  vmovsd      qword ptr [rax+18h],xmm0  

4
我们现在正在讨论的是1亿次迭代后的几毫秒? - Tim Schmelter
2
这个测试对于真实情况并不是很相关。通常的罪魁祸首是内存访问(TLB、L2/L3缺失)和分支预测错误,而这些都无法通过反复调用相同对象以及相同值来进行可行的测试。 - Remus Rusanu
2
我正在试图向供应商解释,为什么他们选择使用类而不是结构体来处理小数值类型是一个不好的主意,除非你实际上测量了性能并且发现使用类是瓶颈,否则你没有任何论据,即使有了你的发现。 - Remus Rusanu
1
如果我想要实时性能和用户反馈,以影响大三角网格的交互,那么这是有区别的。我们不是在爬取 Twitter 消息流 :) - bradgonesurfing
1
@bradgonesurfing,它抱怨X、Y和Z的赋值。请提供一个完整的可以编译的代码。 - David Haim
显示剩余16条评论
2个回答

4

更新

在思考了一些时间后,我认为我同意 @DavidHaim 的观点,内存跳跃开销不是这里的原因,因为有缓存存在。

我还为您的测试添加了更多选项(并删除了第一个具有继承性的选项)。所以现在我有:

  • cl = 3 个点的类变量:
    • Dot(cl, cl) - 初始方法
    • Dot(cl) - 即“平方和”
    • Dot(cl.X, cl.Y, cl.Z, cl.X, cl.Y, cl.Z)Dot(cl.xyz) - 传递字段
  • st = 3 个点的结构体变量:
    • Dot(st, st) - 初始方法
    • Dot(st) - 平方和
    • Dot(st.X, st.Y, st.Z, st.X, st.Y, st.Z)Dot(st.xyz) - 传递字段
  • st6 = 6 个点的结构体变量:
    • Dot(st6) - 想检查结构体大小是否影响速度
  • Dot(x, y, z, x, y, z)Dot(xyz) - 仅使用本地的 const double 变量。

结果时间如下:

  • Dot(cl.xyz) 是最差的,约为 570 毫秒,
  • Dot(st6)Dot(st.xyz) 是第二差的,约为 440 毫秒和 480 毫秒,
  • 其他则约为 325 毫秒。

... 我并不确定我为什么会看到这些结果。

可能对于纯基元类型,编译器会做更积极的寄存器传递优化,或者它更确信生命周期边界或常数性,然后再次做出更积极的优化。可能有某种循环展开。

我想我的专业知识还不够 :) 但是,我的结果与您的结果相反。

可以在我的机器上运行完整测试代码以及生成的 IL 代码,请单击此处查看。


在 C# 中,类是引用类型,结构体是值类型。一个主要的影响是,值类型可以(而且大多数情况下都是!)分配在堆栈上,而引用类型总是分配在堆上。

因此,每当您访问引用类型变量的内部状态时,您需要解引用指向堆中内存的指针(这是一种跳跃),而对于值类型来说,它已经在堆栈上或者甚至优化成了寄存器。

我认为您看到的差异就是由于这个原因。

顺便说一下,“most of the time are”指的是装箱,这是一种将值类型对象放置在堆上的技术(例如将值类型强制转换为接口或进行动态方法调用绑定)。


2
这是错误的。你也可以通过指针访问堆栈,并且你也可以将它们都缓存到寄存器中。对于像 mov rax, [rsp + 10h] vs. mov rax, [rcx + 10h] 这样的紧密循环,性能没有差异。 - David Haim
@DavidHaim,是的,我同意你的看法。需要编辑我的回答。 - pkuderov
非常奇怪,因为当我在64位本机Windows 10 Dell Precision M6800上使用DotNet Core 1.1控制台应用程序以发布模式运行您的示例时,我得到了https://gist.github.com/bradphelan/fec409ea979f97151e238ab7acf72a25,“dot(st,st)”始终是最快的,约为83ms。但我不明白为什么“dot(st)”始终比它应该与“dot(st,st)”相同的速度慢两倍。对我的测试套件的很好的扩展。 - bradgonesurfing
解决方案是在每个“Dot”方法上设置[MethodImpl(MethodImplOptions.AggressiveInlining)]属性。然后,即使在我的原始测试用例中,每个示例的结果也都快速且相同。这是您更新的代码和结果。https://gist.github.com/bradphelan/7fe5a7d480953b9bb35a40c179884270 - bradgonesurfing
1
@pkuderov 我更新/扩展了您的代码,并且从数组中选择了伪随机向量。结果表明,对于点积和使用属性进行强制内联的情况下,基于类的版本更快。但是……我还实现了向量加法并执行了相同的测试。这对于类而言比结构体慢。总的来说,我认为供应商问题只是在所有小方法上添加侵入式内联属性,因为 JIT 没有将它们内联(至少对我而言)。新代码位于 https://gist.github.com/bradphelan/9b383c8e99edc38068fcc0dccc8a7b48 - bradgonesurfing
我本来想加入随机选择的,但最终忘记了这个想法!这是对我的扩展的不错的补充 :) 很高兴看到你找出了原因)) 顺便说一下,在 .net 的世界里,我从来没有需要做过这样低级别的优化 - 大多数时候问题都存在于更高层次。 - pkuderov

1

正如我所想,这个测试并没有证明太多。

TLDR: 编译器完全优化了对Point3ControlC.Dot的调用,同时保留了对其他两个调用的调用。差异不是因为在这种情况下结构体更快,而是因为您跳过了整个计算部分。

我的设置:

  • Visual Studio 2015更新3
  • .Net框架版本4.6.1
  • 发布模式,任何CPU(我的CPU是64位)
  • Windows 10
  • CPU:处理器Intel(R) Core(TM) i5-5300U CPU @ 2.30GHz,2295 Mhz,2个核心,4个逻辑处理器

生成的程序集为

for (int i = 0; i < n; i++)
        {
            acc += Point3ControlA.Dot(vControlA, vControlA);
        }

是:

00DC0573  xor         edx,edx  // temp = 0
00DC0575  mov         dword ptr [ebp-10h],edx // i = temp  
00DC0578  mov         ecx,edi  // load vControlA as first parameter
00DC057A  mov         edx,edi  //load vControlA as second parameter
00DC057C  call        dword ptr ds:[0BA4F0Ch] //call Point3ControlA.Dot
00DC0582  fstp        st(0)  //store the result
00DC0584  inc         dword ptr [ebp-10h]  //i++
00DC0587  cmp         dword ptr [ebp-10h],989680h //does i == n?  
00DC058E  jl          00DC0578  //if not, jump to the begining of the loop

后续思考:
由于某种原因,JIT编译器没有为i使用寄存器,所以它在堆栈上增加了一个整数(ebp-10h),导致该测试性能最差。

进入第二个测试:

for (int i = 0; i < n; i++)
        {
            acc += Point3ControlC.Dot(vControlC, vControlC);
        }

生成的汇编代码:

00DC0612  xor         edi,edi  //i = 0
00DC0614  mov         ecx,esi  //load vControlB as the first argument
00DC0616  mov         edx,esi  //load vControlB as the second argument
00DC0618  call        dword ptr ds:[0BA4FD4h] // call Point3ControlB.Dot
00DC061E  fstp        st(0) //store the result  
00DC0620  inc         edi  //++i
00DC0621  cmp         edi,989680h //does i == n
00DC0627  jl          00DC0614  //if not, jump to the beginning of the loop     

思路:这个生成的汇编代码与第一个几乎相同,但这次JIT确实为i使用了一个寄存器,因此在第一次测试中略微提高了性能。

接下来进入需要测试的内容:

for (int i = 0; i < n; i++)
        {
            acc += Point3ControlC.Dot(vControlC, vControlC);
        }

而对于生成的汇编代码:

00DC06A7  xor         eax,eax  //i = 0
00DC06A9  inc         eax  //++i
00DC06AA  cmp         eax,989680h //does i == n ?   
00DC06AF  jl          00DC06A9  //if not, jump to the beginning of the loop

正如我们所看到的,JIT已经完全优化掉了对Point3ControlC.Dot的调用,因此实际上只需支付循环而不是调用本身的费用。因此,这个“测试”首先完成,因为它本来就没有做太多事情。
仅从这个测试中,我们能否对结构体和类说些什么呢?嗯,不能。我仍然不确定编译器为什么决定优化掉结构体函数的调用,同时保留其他调用。我确定的是,在实际代码中,如果使用结果,则编译器无法优化掉调用。在这个小型基准测试中,我们对结果没有做太多处理,即使我们这样做了,编译器也可以在编译时计算出结果。因此,编译器可以比在实际代码中更加积极。

当你说JIT时,你是指编译器生成的IL输出还是将DLL加载到运行应用程序后的IL输出。由于我无法使用优化的调试代码停止断点,因此无法看到真正的JIT代码。我现在正在使用移动设备,所以在周一之前无法验证你的答案。 - bradgonesurfing
同时,每个测试的结果都被写入控制台。编译器/JIT如何删除计算但仍然获得正确的结果?除非它在JIT时间执行了点积和求和...如果是这样的话,那就太酷了。 - bradgonesurfing
我的意思是生成的机器代码,而不是中间语言。你可以在VS中完成这个过程。你提供的代码没有使用结果。而且,在编译时计算东西并不令人印象深刻,编译器从90年代就开始这样做了。 - David Haim
你在调试期间如何查看生成的机器代码。你知道在正常调试下,JiT优化器是关闭的。如果你开启它,VS会发出警告,并通常拒绝在断点处停止。无论如何,如果你阅读了我对问题的编辑,有趣的问题已经通过使用另一篇答案的修改版本得到了回答,该答案确实使用了结果,因此它不能被优化掉。 - bradgonesurfing
调试发布模式与调试调试模式不同,代码仍然被优化。 - David Haim
显示剩余9条评论

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