为什么结构比这个特定情况下的类快得多?

bra*_*ing 12 c# performance jit numerical-methods

我有三种情况来测试类的相对性能,具有继承性和结构的类.这些将用于紧密循环,因此性能很重要.Dot产品被用作2D和3D几何中的许多算法的一部分,我在实际代码上运行了分析器.以下测试表明我见过的真实世界性能问题.

通过循环和点积的应用得到的结果为1亿次

ControlA 208 ms   ( class with inheritence )
ControlB 201 ms   ( class with no inheritence )
ControlC 85  ms   ( struct )
Run Code Online (Sandbox Code Playgroud)

测试正在运行,没有打开调试和优化.我的问题是,在这种情况下,类是什么导致它们如此缓慢?

我假设JIT仍然可以内联所有的调用,类或结构,所以实际上结果应该是相同的.请注意,如果我禁用优化,那么我的结果是相同的.

ControlA 3239
ControlB 3228
ControlC 3213
Run Code Online (Sandbox Code Playgroud)

如果重新运行测试,它们总是在彼此相差20ms内.

正在调查的课程

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;
    }
}
Run Code Online (Sandbox Code Playgroud)

测试脚本

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();
    }
}
Run Code Online (Sandbox Code Playgroud)

这个dotnet小提琴只是编译的证明.它没有显示性能差异.

我试图向供应商解释为什么他们选择使用类而不是小数字类型的结构是一个主意.我现在有测试用例来证明它,但我不明白为什么.

注意:我已尝试在调试器中设置断点,并启用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;
    }
Run Code Online (Sandbox Code Playgroud)

点积的结构和类之间的差异消失了.为什么有些设置不需要属性,但对我来说,目前尚不清楚.但是我并没有放弃.供应商代码仍然存在性能问题,我认为DotProduct不是最好的例子.

我修改了@pkuderov的代码来实现Vector Add它将创建结构和类的新实例.结果在这里

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

在示例中,我还修改了代码以从数组中选择伪随机向量,以避免实例在寄存器中出现问题(我希望).

结果表明:

对于
Vector Add 类,DotProduct性能相同或者更快,我认为创建新对象的任何东西都比较慢.

添加类/类2777ms添加struct/struct 2457ms

DotProd类/类1909ms DotProd struct/struct 2108ms

如果有人想试试,那么完整的代码和结果就在这里.

再次编辑

对于向量添加示例,其中向量数组被加在一起,结构版本将累加器保持在3个寄存器中

 var accStruct = new Point3Struct(0, 0, 0);
 for (int i = 0; i < n; i++)
     accStruct = Point3Struct.Add(accStruct, pointStruct[(i + 1) % m]);
Run Code Online (Sandbox Code Playgroud)

asm的身体是

// 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  
Run Code Online (Sandbox Code Playgroud)

但对于基于类的矢量版本,它每次都会读取和写出累加器到主存,这是低效的

var accPC = new Point3Class(0, 0, 0);
for (int i = 0; i < n; i++)
    accPC = Point3Class.Add(accPC, pointClass[(i + 1) % m]);
Run Code Online (Sandbox Code Playgroud)

asm的身体是

// 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  
Run Code Online (Sandbox Code Playgroud)

pku*_*rov 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 个点的结构的 vairable:
    • Dot(st6)- 想要检查结构的大小是否重要
  • Dot(x, y, z, x, y, z)又名 Dot(xyz) - 只是局部 const 双变量。

结果时间为:

  • Dot(cl.xyz) 最差 ~570ms,
  • Dot(st6)、Dot(st.xyz) 是第二差的 ~440ms 和 ~480ms
  • 其他的约为 325ms

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

也许对于普通基元类型,编译器会进行更积极的寄存器传递优化,也许它更确定生命周期边界或恒定性,然后再次进行更积极的优化。也许是某种循环展开。

我认为我的专业知识还不够:)但是,我的结果仍然与你的结果相反。

您可以在此处找到完整的测试代码以及​​我的机器上的结果以及生成的 IL 代码。


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

因此,每次访问引用类型变量的内部状态时,都需要取消引用指向堆中内存的指针(这是一种跳转),而对于值类型,它已经在堆栈中,甚至优化到寄存器中。

我认为您因此看到了差异。

PS 顺便说一句,“大多数时候”我指的是拳击;它是一种用于将值类型对象放置在堆上的技术(例如,将值类型转换为接口或用于动态方法调用绑定)。

  • 这是错误的。您也可以通过指针访问堆栈,并且也可以将它们缓存在寄存器中。对于像“mov rax, [rsp + 10h]”和“mov rax, [rcx + 10h]”这样的紧循环,没有性能差异。 (2认同)