简单基准测试中怪异的性能提升


97

昨天,我找到了Christoph Nahr撰写的名为“ .NET Struct Performance”文章,文章 对几种语言(C ++,C#,Java,JavaScript)的基准测试了一种添加两个点结构(double元组)的方法。

事实证明,C ++版本执行大约需要1000毫秒(1e9迭代),而C#在同一台机器上的时间不能低于3000毫秒(并且在x64中的表现甚至更差)。

为了自己进行测试,我使用了C#代码(并略微简化以仅调用按值传递参数的方法),并在i7-3610QM机器(单核3.1Ghz Boost),8GB RAM,Win8上运行了它。 1,使用.NET 4.5.2,RELEASE构建32位(由于我的OS是64位,因此x86 WoW64)。这是简化版本:

public static class CSharpTest
{
    private const int ITERATIONS = 1000000000;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static Point AddByVal(Point a, Point b)
    {
        return new Point(a.X + b.Y, a.Y + b.X);
    }

    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", 
            a.X, a.Y, 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性能,实际上似乎只是衡量了基本的double算术(在其他所有东西都被优化之后)。

奇怪的东西

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

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",
            a.X, a.Y, 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(a.X + b.Y, a.Y + b.X);
    }

    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", 
            a.X, a.Y, 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", 
            a.X, a.Y, 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)

在@usr对@Hans答案的评论之后,我检查了两种方法的优化反汇编,它们有很大不同:

左边的Test1,右边的Test2

这似乎表明,差异可能是由于编译器在第一种情况下表现出了好笑而不是双字段对齐?

另外,如果我添加两个变量(总偏移量为8个字节),我仍然会获得相同的速度提升-而且似乎不再与Hans Passant提到的字段对齐有关:

// 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",
            a.X, a.Y, sw.ElapsedMilliseconds);
    }

    GC.KeepAlive(magical_speed_booster_1);
    GC.KeepAlive(magical_speed_booster_2);
}

1
除了JIT之外,它还取决于编译器的优化,最新的Ryujit进行了更多的优化,甚至引入了有限的SIMD指令支持。
Felix K.

3
乔恩·斯凯特(Jon Skeet)发现结构中的只读字段存在性能问题:微观优化:只读字段令人惊讶的低效。尝试将私有字段设置为非只读。
dbc

2
@dbc:我仅使用局部double变量进行了测试,没有structs,因此我排除了结构布局/方法调用效率低下的问题。
Groo

3
似乎只发生在32位上,使用RyuJIT,我两次都得到1600ms。
leppie 2015年

2
我看了两种方法的反汇编。没有什么有趣的看到。Test1生成没有明显原因的低效率代码。JIT错误或设计使然。在Test1中,JIT将每次迭代的double加载并存储到堆栈中。这可能是为了确保精确的精度,因为x86浮动单元使用80位内部精度。我发现该函数顶部的任何非内联函数调用都会使其再次快速运行。
usr

Answers:


10

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

事实上, Test1由于工作缓慢Stopwatch。我根据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使用堆栈,第二个秒表允许其再次使用寄存器。


这并不能真正解释原因。如果您检查我的测试,则看来带有附加测试的测试Stopwatch实际上运行得更快。但是,如果交换在Main方法中调用它们的顺序,则另一个方法将得到优化。
Groo

75

有一种非常简单的方法可以始终获取程序的“快速”版本。在“项目”>“属性”>“构建”选项卡上,取消选中“首选32位”选项,确保平台目标选择为AnyCPU。

您确实不喜欢32位,但是不幸的是,对于C#项目,默认情况下总是将其打开。从历史上看,Visual Studio工具集在32位进程上的性能要好得多,这是Microsoft一直在解决的一个老问题。是时候删除该选项了,尤其是VS2015通过全新的x64抖动和对Edit + Continue的普遍支持,解决了针对64位代码的最后几个实际障碍。

ter不休,您发现对准的重要性变量。处理器非常关心它。如果变量在内存中未对齐,则处理器必须执行额外的工作来对字节进行混洗以按正确的顺序获取它们。存在两个明显的未对齐问题,一个是字节仍位于单个L1高速缓存行内,这需要花费额外的周期才能将它们移到正确的位置。还有一个比较糟糕的地方,就是您发现的那部分,其中部分字节位于一个缓存行中,而另一部分则位于另一缓存行中。这需要两个单独的内存访问并将它们粘合在一起。慢三倍。

doublelong类型是麻烦制造者在32位进程。它们的大小为64位。并且因此可能会错位4,因此CLR仅能保证32位对齐。在64位进程中,这不是问题,可以保证所有变量都与8对齐。也是C#语言不能保证它们为atomic的根本原因。以及为什么在大型对象堆中有1000个以上的元素时会分配double数组。LOH提供8的对齐保证。并解释了为什么添加局部变量可以解决此问题,对象引用为4字节,因此它将double变量移动了4,现在将其对齐。意外地。

32位C或C ++编译器做了额外的工作,以确保不会将double对齐。这并不是一个简单的解决问题,输入函数时,堆栈可能会错位,因为唯一的保证就是它对齐4。此函数的序言需要做更多的工作才能使其对齐8。相同的技巧在托管程序中不起作用,垃圾回收器非常关心局部变量在内存中的确切位置。必要时,它可以发现GC堆中的对象仍被引用。由于输入该方法时堆栈未对齐,因此无法正确处理此类变量增加4的情况。

这也是.NET抖动不容易支持SIMD指令的潜在问题。它们具有更强的对齐要求,这也是处理器本身无法解决的要求。SSE2要求对齐方式为16,AVX要求对齐方式为32。无法在托管代码中获取。

最后但并非最不重要的一点是,还要注意,这会使以32位模式运行的C#程序的性能非常难以预测。当您访问存储在对象中作为字段存储的doublelong时,当垃圾收集器压缩堆时,perf可能会发生巨大变化。它将对象移动到内存中,这样的字段现在可能突然变得不对齐。当然,非常随机,可能会让人头疼:)

好吧,没有简单的修补程序,但将来会是一个64位代码。只要Microsoft不更改项目模板,就可以消除抖动强制。当他们对Ryujit更有信心时,也许是下一个版本。


1
不知道当可以注册双变量(并且在Test2中)时,对齐方式如何发挥作用。Test1使用堆栈,而Test2不使用堆栈。
usr

2
这个问题变化太快,我无法跟踪。您必须提防测试本身会影响测试结果。您需要将[MethodImpl(MethodImplOptions.NoInlining)]放在测试方法上,以将苹果与橙子进行比较。现在您将看到,在两种情况下,优化器都可以将变量保留在FPU堆栈上。
汉斯·帕桑

4
天哪,是真的。为什么方法对齐对生成的指令有任何影响?循环主体应该没有任何区别。所有都应该在寄存器中。对齐序言应该无关紧要。似乎仍然是一个JIT错误。
usr

3
我不得不大刀阔斧地修改答案,可惜。明天再说。
汉斯·帕桑

2
@HansPassant您是否要挖掘JIT来源?这应该很有趣。至此,我所知道的只是一个随机的JIT错误。
usr 2015年

5

将其缩小一些(似乎只影响32位CLR 4.0运行时)。

请注意,make的位置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",
      a.X, a.Y, 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",
      a.X, a.Y, sw.ElapsedMilliseconds);
}

在不触摸的情况下修改代码Stopwatch也会大大改变速度。将方法的签名更改为,Test1(bool warmup)并在Console输出中添加条件:if (!warmup) { Console.WriteLine(...); }也具有相同的效果(在构建用于重现该问题的测试时偶然发现)。
其间的

@InBetween:我看到了,有些东西很腥。也仅在结构上发生。
leppie

4

抖动中似乎存在一些错误,因为该行为甚至更加棘手。考虑以下代码:

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",
            a.X, a.Y, sw.ElapsedMilliseconds);
    }
}

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

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);
}

注意我已从输出中删除a.Xa.Y引用Console

我不知道发生了什么,但是对我来说这听起来很臭,并且与是否有外部外观Stopwatch无关,这个问题似乎更加笼统。


当删除对a.X和的调用时a.Y,编译器可能可以自由地优化循环中的几乎所有内容,因为该操作的结果尚未使用。
Groo

@Groo:是的,这似乎是合理的,但是考虑到我们看到的其他奇怪行为,这是不合理的。删除a.X并且a.Y不会使其比包含if (!warmup)条件或OP的运行更快outerSw,这意味着它并没有进行任何优化,只是消除了使代码以次优速度运行的任何错误(3000毫秒而不是900毫秒)。
其间的

2
哦,好吧,我想速度的提升发生了,当warmup是真实的,但在这种情况下,线连打印,所以它的情况下不会得到实际打印引用a。但是,无论何时我进行基准测试时,我都想确保始终在方法末尾附近引用计算结果。
Groo
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.