简单基准测试表现奇怪

昨天我发现了Christoph Nahr的一篇题为“.NET结构性能”的文章,该文章针对添加了两个点结构( double元组)的方法对几种语言(C ++,C#,Java,JavaScript)进行了基准测试。

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

为了自己测试它,我使用了C#代码(稍微简化为仅调用参数按值传递的方法),然后在i7-3610QM机器上运行(3.1Ghz boost for single core),8GB RAM,Win8。 1,使用.NET 4.5.2,RELEASE构建32位(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(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);
}

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位“选项,确保平台目标选择为AnyCPU。

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

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

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

    32位C或C ++编译器会做额外的工作以确保double不会错位。 不是一个简单的问题需要解决,当输入函数时,堆栈可能会错位,因为唯一的保证就是它与4对齐。这样一个函数的序言需要做额外的工作来使它对齐到8。同样的技巧在托管程序中不起作用,垃圾回收器非常关心本地变量在内存中的位置。 必要的是,它可以发现GC堆中的对象仍然被引用。 它不能正确地处理这样一个被4移动的变量,因为在输入方法时堆栈未对齐。

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

    最后但并非最不重要的一点,还要注意,这使得在32位模式下运行的C#程序的性能非常不可预测。 当你访问存储为对象字段的double或long时,perf会在垃圾收集器压缩堆时急剧改变。 其中移动内存中的对象,这样的领域现在可以突然得到错误/对齐。 当然非常随机,可以是一个相当头痛的人:)

    那么,没有简单的修复,只有一个,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",
          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);
    }
    
    链接地址: http://www.djcxy.com/p/86527.html

    上一篇: Weird performance increase in simple benchmark

    下一篇: Which is the better benchmarking strategy: local machine vs remote machine