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

时间:2017-07-06 12:52:42

标签: c# performance jit numerical-methods

我有三种情况来测试类的相对性能,具有继承性和结构的类。这些将用于紧密循环,因此性能很重要。 Dot产品被用作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

如果重新运行测试,它们总是在彼此的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;
    }
}

测试脚本

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的代码来实现Vector Add,它将创建结构和类的新实例。结果在这里

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

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

结果表明:

对于班级来说,DotProduct的表现相同或更快 Vector Add,我假设任何创建新对象的东西都比较慢。

添加课程/班级2777ms 添加struct / struct 2457ms

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

如果有人想要试用,完整的代码和结果为here

再次编辑

对于矢量添加示例,其中向量数组求和,struct版本将累加器保存在3个寄存器中

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

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  

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

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

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  

2 个答案:

答案 0 :(得分:4)

更新

在花了一些时间思考问题之后,我认为我对@DavidHaim非常认可,因为缓存,内存跳跃开销并非如此。

此外,我已经为您的测试添加了更多选项(并删除了第一个继承)。所以我有:

  • cl = 3级变量:
    • Dot(cl, cl) - 初始方法
    • Dot(cl) - 这是“方形产品”
    • Dot(cl.X, cl.Y, cl.Z, cl.X, cl.Y, cl.Z) aka Dot(cl.xyz) - 传递字段
  • st = 3个点的struct变量:
    • Dot(st, st) - 首字母
    • Dot(st) - 方形产品
    • Dot(st.X, st.Y, st.Z, st.X, st.Y, st.Z) aka Dot(st.xyz) - 传递字段
  • st6 = 6点结构可变:
    • Dot(st6) - 想检查结构的大小是否重要
  • Dot(x, y, z, x, y, z) aka Dot(xyz) - 只是本地const双变量。

结果时间是:

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

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

对于简单的原始类型,编译器可以通过寄存器优化进行更具侵略性的传递,也许它可以更加确定生命周期边界或常量,然后再进行更积极的优化。也许是某种循环展开。

我认为我的专业知识还不够:)但是,我的结果反驳了你的结果。

我的机器上有结果的完整测试代码和生成的IL代码,您可以找到here

在C#类中,引用类型和结构是值类型。一个主要的影响是值类型可以(并且大部分时间都是!)在堆栈上分配,而引用类型总是在堆上分配。

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

我认为你因此而看到了不同。

P.S。顺便说一句,“大部分时间都是”我的意思是拳击;它是一种用于在堆上放置值类型对象的技术(例如,将值类型转换为接口或用于动态方法调用绑定)。

答案 1 :(得分:1)

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

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

我的设置:

  • Visual studio 2015 update 3
  • .Net framework version 4.6.1
  • 发布模式,任何CPU(我的CPU为64位)
  • Windows 10
  • CPU:处理器Intel(R)Core(TM)i5-5300U CPU @ 2.30GHz,2295 Mhz,2 Core(s),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的调用,实际上,您只需为循环付费,而不是为调用本身付费。因此这&#34;测试&#34;首先完成,因为它开始没什么用。

我们可以单独说一下这个测试中的结构与类吗?好吧,没有。 我仍然没有放弃为什么编译器决定优化结构函数的调用同时保留其他调用。我确信的是,在现实代码中,如果使用结果,编译器无法优化调用。在这个迷你基准测试中,我们不会对结果做很多事情,即使我们这样做了,编译器也可以在编译时计算结果。所以编译器可能比实际代码更具侵略性。