我有三种情况来测试类的相对性能,具有继承性和结构的类。这些将用于紧密循环,因此性能很重要。 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
答案 0 :(得分:4)
更新
在花了一些时间思考问题之后,我认为我对@DavidHaim非常认可,因为缓存,内存跳跃开销并非如此。
此外,我已经为您的测试添加了更多选项(并删除了第一个继承)。所以我有:
Dot(cl, cl)
- 初始方法Dot(cl)
- 这是“方形产品”Dot(cl.X, cl.Y, cl.Z, cl.X, cl.Y, cl.Z)
aka Dot(cl.xyz) - 传递字段Dot(st, st)
- 首字母Dot(st)
- 方形产品Dot(st.X, st.Y, st.Z, st.X, st.Y, st.Z)
aka Dot(st.xyz) - 传递字段Dot(st6)
- 想检查结构的大小是否重要Dot(x, y, z, x, y, z)
aka Dot(xyz) - 只是本地const双变量。结果时间是:
......我不确定为什么会看到这些结果。
对于简单的原始类型,编译器可以通过寄存器优化进行更具侵略性的传递,也许它可以更加确定生命周期边界或常量,然后再进行更积极的优化。也许是某种循环展开。
我认为我的专业知识还不够:)但是,我的结果反驳了你的结果。
我的机器上有结果的完整测试代码和生成的IL代码,您可以找到here。
在C#类中,引用类型和结构是值类型。一个主要的影响是值类型可以(并且大部分时间都是!)在堆栈上分配,而引用类型总是在堆上分配。
因此,每次访问引用类型变量的内部状态时,都需要取消引用堆中内存的指针(这是一种跳转),而对于值类型,它已经在堆栈上或者甚至已经优化了注册。
我认为你因此而看到了不同。
P.S。顺便说一句,“大部分时间都是”我的意思是拳击;它是一种用于在堆上放置值类型对象的技术(例如,将值类型转换为接口或用于动态方法调用绑定)。
答案 1 :(得分:1)
正如我所想,这个测试并没有多大证据。
TLDR:编译器完全优化了对Point3ControlC.Dot
的调用,同时保留了对其他两个调用的调用。差异不是因为结构在这种情况下更快,而是因为你跳过了整个计算部分。
我的设置:
生成的程序集
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;首先完成,因为它开始没什么用。
我们可以单独说一下这个测试中的结构与类吗?好吧,没有。 我仍然没有放弃为什么编译器决定优化结构函数的调用同时保留其他调用。我确信的是,在现实代码中,如果使用结果,编译器无法优化调用。在这个迷你基准测试中,我们不会对结果做很多事情,即使我们这样做了,编译器也可以在编译时计算结果。所以编译器可能比实际代码更具侵略性。