奇怪的performance增加了简单的基准

昨天,我find了Christoph Nahr的一篇名为“.NET结构性能”的文章,该文章以几种语言(C ++,C#,Java,JavaScript)为基础,添加了两个点结构( double元组)。

事实certificate,C ++版本需要大约1000ms才能执行(1e9次迭代),而C#在同一台机器上不能低于〜3000ms(在x64中performance更差)。

为了自己testing,我使用了C#代码(稍微简化为只调用传入参数的方法),然后在i7-3610QM机器上运行(3.1Ghz单核加速),8GB RAM,Win8。 1,使用.NET 4.5.2,RELEASE构build32位(x86 WoW64,因为我的操作系统是64位)。 这是简化的版本:

 public static class CSharpTest { private const int ITERATIONS = 1000000000; [MethodImpl(MethodImplOptions.AggressiveInlining)] private static Point AddByVal(Point a, Point b) { return new Point(aX + bY, aY + bX); } public static void Main() { Point a = new Point(1, 1), b = new Point(1, 1); Stopwatch sw = Stopwatch.StartNew(); for (int i = 0; i < ITERATIONS; i++) a = AddByVal(a, b); sw.Stop(); Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms", aX, aY, sw.ElapsedMilliseconds); } } 

Point定义如下:

 public struct Point { private readonly double _x, _y; public Point(double x, double y) { _x = x; _y = y; } public double X { get { return _x; } } public double Y { get { return _y; } } } 

运行它会产生类似于文章中的结果:

 Result: x=1000000001 y=1000000001, Time elapsed: 3159 ms 

首先奇怪的观察

由于该方法应该内联,所以我想知道如果我完全删除了结构并简单地将整个事物联系在一起,代码将如何执行:

 public static class CSharpTest { private const int ITERATIONS = 1000000000; public static void Main() { // not using structs at all here double ax = 1, ay = 1, bx = 1, by = 1; Stopwatch sw = Stopwatch.StartNew(); for (int i = 0; i < ITERATIONS; i++) { ax = ax + by; ay = ay + bx; } sw.Stop(); Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms", ax, ay, sw.ElapsedMilliseconds); } } 

而且得到了几乎相同的结果(实际上在几次重试之后慢了1%),这意味着JIT-ter似乎在优化所有函数调用方面做得很好:

 Result: x=1000000001 y=1000000001, Time elapsed: 3200 ms 

这也意味着基准似乎没有测量任何struct性能,而实际上只是似乎测量了基本的doublealgorithm(在所有的东西都被优化之后)。

奇怪的东西

现在来了怪异的一部分。 如果我只是在循环外添加另一个秒表 (是的,经过几次重试,我缩小了这个疯狂的步骤),代码运行速度快三倍

 public static void Main() { var outerSw = Stopwatch.StartNew(); // <-- added { Point a = new Point(1, 1), b = new Point(1, 1); var sw = Stopwatch.StartNew(); for (int i = 0; i < ITERATIONS; i++) a = AddByVal(a, b); sw.Stop(); Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms", aX, aY, sw.ElapsedMilliseconds); } outerSw.Stop(); // <-- added } Result: x=1000000001 y=1000000001, Time elapsed: 961 ms 

这是荒谬的! 而且不像Stopwatch给我错误的结果,因为我可以清楚地看到它在一秒钟后结束。

谁能告诉我这里可能发生了什么?

(更新)

这里有两个方法在同一个程序中,这表明原因不是JITting:

 public static class CSharpTest { private const int ITERATIONS = 1000000000; [MethodImpl(MethodImplOptions.AggressiveInlining)] private static Point AddByVal(Point a, Point b) { return new Point(aX + bY, aY + bX); } public static void Main() { Test1(); Test2(); Console.WriteLine(); Test1(); Test2(); } private static void Test1() { Point a = new Point(1, 1), b = new Point(1, 1); var sw = Stopwatch.StartNew(); for (int i = 0; i < ITERATIONS; i++) a = AddByVal(a, b); sw.Stop(); Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms", aX, aY, sw.ElapsedMilliseconds); } private static void Test2() { var swOuter = Stopwatch.StartNew(); Point a = new Point(1, 1), b = new Point(1, 1); var sw = Stopwatch.StartNew(); for (int i = 0; i < ITERATIONS; i++) a = AddByVal(a, b); sw.Stop(); Console.WriteLine("Test2: x={0} y={1}, Time elapsed: {2} ms", aX, aY, sw.ElapsedMilliseconds); swOuter.Stop(); } } 

输出:

 Test1: x=1000000001 y=1000000001, Time elapsed: 3242 ms Test2: x=1000000001 y=1000000001, Time elapsed: 974 ms Test1: x=1000000001 y=1000000001, Time elapsed: 3251 ms Test2: x=1000000001 y=1000000001, Time elapsed: 972 ms 

这是一个pastebin。 您需要将其作为.NET 4.x上的32位版本运行(在代码中有几个检查来确保这一点)。

(更新4)

关于@Hans的回答@ usr的评论,我检查了这两种方法的优化反汇编,而且它们有很大不同:

Test1在左边,Test2在右边

这似乎表明,这种差异可能是由于编译器在第一种情况下行事有趣,而不是双字段alignment?

另外,如果我添加两个variables(8字节的总偏移量),我仍然可以获得相同的速度提升 – 而且不再与Hans Passant提到的字段alignment有关:

 // this is still fast? private static void Test3() { var magical_speed_booster_1 = "whatever"; var magical_speed_booster_2 = "whatever"; { Point a = new Point(1, 1), b = new Point(1, 1); var sw = Stopwatch.StartNew(); for (int i = 0; i < ITERATIONS; i++) a = AddByVal(a, b); sw.Stop(); Console.WriteLine("Test2: x={0} y={1}, Time elapsed: {2} ms", aX, aY, sw.ElapsedMilliseconds); } GC.KeepAlive(magical_speed_booster_1); GC.KeepAlive(magical_speed_booster_2); } 

Update 4解释了这个问题:在第一种情况下,JIT将计算值( ab )保留在堆栈上; 在第二种情况下,JIT将其保存在寄存器中。

事实上,由于Stopwatch原因, Test1运行速度很慢。 我写了基于BenchmarkDotNet的以下最低基准:

 [BenchmarkTask(platform: BenchmarkPlatform.X86)] public class Jit_RegistersVsStack { private const int IterationCount = 100001; [Benchmark] [OperationsPerInvoke(IterationCount)] public string WithoutStopwatch() { double a = 1, b = 1; for (int i = 0; i < IterationCount; i++) { // fld1 // faddp st(1),st a = a + b; } return string.Format("{0}", a); } [Benchmark] [OperationsPerInvoke(IterationCount)] public string WithStopwatch() { double a = 1, b = 1; var sw = new Stopwatch(); for (int i = 0; i < IterationCount; i++) { // fld1 // fadd qword ptr [ebp-14h] // fstp qword ptr [ebp-14h] a = a + b; } return string.Format("{0}{1}", a, sw.ElapsedMilliseconds); } [Benchmark] [OperationsPerInvoke(IterationCount)] public string WithTwoStopwatches() { var outerSw = new Stopwatch(); double a = 1, b = 1; var sw = new Stopwatch(); for (int i = 0; i < IterationCount; i++) { // fld1 // faddp st(1),st a = a + b; } return string.Format("{0}{1}", a, sw.ElapsedMilliseconds); } } 

我的电脑上的结果:

 BenchmarkDotNet=v0.7.7.0 OS=Microsoft Windows NT 6.2.9200.0 Processor=Intel(R) Core(TM) i7-4702MQ CPU @ 2.20GHz, ProcessorCount=8 HostCLR=MS.NET 4.0.30319.42000, Arch=64-bit [RyuJIT] Type=Jit_RegistersVsStack Mode=Throughput Platform=X86 Jit=HostJit .NET=HostFramework Method | AvrTime | StdDev | op/s | ------------------- |---------- |---------- |----------- | WithoutStopwatch | 1.0333 ns | 0.0028 ns | 967,773.78 | WithStopwatch | 3.4453 ns | 0.0492 ns | 290,247.33 | WithTwoStopwatches | 1.0435 ns | 0.0341 ns | 958,302.81 | 

我们可以看到:

  • WithoutStopwatch快速工作(因为a = a + b使用寄存器)
  • WithStopwatch工作缓慢(因为a = a + b使用堆栈)
  • WithTwoStopwatches再次快速工作(因为a = a + b使用寄存器)

JIT-x86的行为依赖于大量不同的条件。 出于某种原因,第一个秒表强制JIT-x86使用堆栈,第二个秒表允许它再次使用寄存器。

总是有一个非常简单的方法来获得程序的“快速”版本。 项目“>”属性“>”生成“选项卡,取消选中”首选32位“选项,确保平台目标select为AnyCPU。

你真的不喜欢32位,不幸的是总是默认开启C#项目。 从历史上看,Visual Studio工具集在32位进程中运行得更好,这是微软一直在削弱的一个老问题。 除去这个选项的时候,VS2015特别是将最后几个真正的路障模块解决为64位代码,并带有全新的x64抖动和对Edit + Continue的通用支持。

足够的喋喋不休,你发现了variablesalignment的重要性。 处理器关心它很多。 如果一个variables在内存中被错误alignment,那么处理器必须做额外的工作来对字节进行混洗才能使它们按正确的顺序排列。 有两个不同的错位问题,一个是字节仍然在一个单一的L1高速caching行内,这需要花费一个额外的周期,将他们转移到正确的位置。 还有一个额外的错误,就是你发现的那个,其中一部分字节在一个caching行中,另一个在另一个中。 这需要两个单独的内存访问并将它们粘合在一起。 慢三倍。

doublelongtypes是32位进程中的麻烦制造者。 它们的大小是64位。 而由此可以得到错位,CLR只能保证一个32位的alignment。 在64位进程中不是问题,所有variables都保证与8alignment。也是C#语言不能保证它们是primefaces的基本原因。 以及为什么在大对象堆中有超过1000个元素的情况下为double数组分配。 LOH提供了8的alignment保证。并解释了为什么添加一个局部variables解决了这个问题,一个对象引用是4个字节,所以它将双精度variables移动了4,现在让它alignment。 意外地。

32位C或C ++编译器会做额外的工作,以确保double不会错位。 不是一个简单的问题要解决,堆栈可以是错误的,当input一个函数时,假设唯一的保证是它alignment到4.这样的函数的序言需要做额外的工作,以使其alignment到8。同样的技巧在托pipe程序中不起作用,垃圾收集器非常关心本地variables在内存中的位置。 必要的,所以它可以发现GC堆中的一个对象仍然被引用。 它不能正确地处理这样一个被4移动的variables,因为当方法被input时,堆栈没有alignment。

这也是.NET抖动不容易支持SIMD指令的根本问题。 它们有更强的alignment要求,也就是处理器本身无法解决的那种。 SSE2需要16的alignment,AVX需要alignment32.无法在托pipe代码中获取。

最后但同样重要的是,这也使得在32位模式下运行的C#程序的性能非常不可预测。 当你访问一个双精度精度的对象存储为字段时,perf可以彻底改变垃圾收集器压缩堆的时间。 哪个在内存中移动对象,这样的一个字段现在可以突然变得错误/alignment。 当然是非常随机的,可以算是一个头疼的人:)

那么,没有简单的修复,只有一个,64位代码是未来。 只要Microsoft不更改项目模板,就可以删除抖动强制。 也许下一个版本,当他们对Ryujit更加自信。

缩小了一些什么(似乎只影响32位CLR 4.0运行时)。

注意var f = Stopwatch.Frequency; 使所有的差异。

慢(2700ms):

 static void Test1() { Point a = new Point(1, 1), b = new Point(1, 1); var f = Stopwatch.Frequency; var sw = Stopwatch.StartNew(); for (int i = 0; i < ITERATIONS; i++) a = AddByVal(a, b); sw.Stop(); Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms", aX, aY, sw.ElapsedMilliseconds); } 

快速(800ms):

 static void Test1() { var f = Stopwatch.Frequency; Point a = new Point(1, 1), b = new Point(1, 1); var sw = Stopwatch.StartNew(); for (int i = 0; i < ITERATIONS; i++) a = AddByVal(a, b); sw.Stop(); Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms", aX, aY, sw.ElapsedMilliseconds); } 

抖动似乎有一些错误,因为这种行为更为糟糕。 考虑下面的代码:

 public static void Main() { Test1(true); Test1(false); Console.ReadLine(); } public static void Test1(bool warmup) { Point a = new Point(1, 1), b = new Point(1, 1); Stopwatch sw = Stopwatch.StartNew(); for (int i = 0; i < ITERATIONS; i++) a = AddByVal(a, b); sw.Stop(); if (!warmup) { Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms", aX, aY, sw.ElapsedMilliseconds); } } 

这将运行在900毫秒,相同的外部秒表的情况下。 但是,如果我们删除if (!warmup)条件,它将在3000毫秒内运行。 更奇怪的是,下面的代码也将运行在900毫秒:

 public static void Test1() { Point a = new Point(1, 1), b = new Point(1, 1); Stopwatch sw = Stopwatch.StartNew(); for (int i = 0; i < ITERATIONS; i++) a = AddByVal(a, b); sw.Stop(); Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms", 0, 0, sw.ElapsedMilliseconds); } 

注意我已经从Console输出中删除了aXaY引用。

我不知道发生了什么事情,但是这个味道对我来说很奇怪,而且和外面的Stopwatch没有关系,这个问题似乎更加普遍一些。