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

Weird performance increase in simple benchmark

昨天我发现了一个 article by Christoph Nahr titled ".NET Struct Performance",它对几种语言(C++、C#、Java、JavaScript)进行了基准测试,用于添加两个点结构的方法(double元组)。

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

为了自己测试,我使用了 C# 代码(并稍微简化为仅调用按值传递参数的方法),并 运行 在 i7-3610QM 机器上(3.1Ghz 提升为单核),8GB RAM,Win8.1,使用 .NET 4.5.2,RELEASE 构建 32 位(x86 WoW64,因为我的 OS 是 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

第一次运行ge观察

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

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 算法(在其他所有内容都被优化之后)。

奇怪的东西

奇怪的部分来了。如果我只是在循环外添加 另一个秒表 (是的,我在多次重试后将其缩小到这个疯狂的步骤),代码 运行s 三次更快:

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

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

(更新 4)

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

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

此外,如果我添加 两个 变量(总偏移量为 8 个字节),我仍然会获得相同的速度提升 - 而且它似乎不再与字段对齐相关汉斯·帕桑特:

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

抖动中似乎存在一些错误,因为行为更加奇怪。考虑以下代码:

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) 条件,它将在 3000 毫秒后 运行。更奇怪的是,下面的代码也会 运行 in 900 ms:

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 输出中删除了 a.Xa.Y 引用。

我不知道发生了什么,但这对我来说闻起来很臭,而且它与是否有外部 Stopwatch 无关,这个问题似乎更普遍。

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

请注意 var f = Stopwatch.Frequency; 的位置让一切变得不同。

慢(2700 毫秒):

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

快(800 毫秒):

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

有一种非常简单的方法可以始终获取程序的 "fast" 版本。 Project > Properties > Build 选项卡,取消勾选 "Prefer 32-bit" 选项,确保 Platform target 选择是 AnyCPU。

您真的不喜欢 32 位,不幸的是,对于 C# 项目,它始终默认打开。从历史上看,Visual Studio 工具集在 32 位进程上工作得更好,这是微软一直在努力解决的一个老问题。是时候删除该选项了,VS2015 特别解决了 64 位代码的最后几个真正障碍,具有全新的 x64 抖动和对 Edit+Continue 的普遍支持。

废话少说,你发现了变量对齐的重要性。处理器非常关心它。如果一个变量在内存中没有对齐,那么处理器必须做额外的工作来打乱字节以使它们以正确的顺序排列。有两个明显的未对齐问题,一个是字节仍然在单个 L1 缓存行内,这需要额外的周期才能将它们移动到正确的位置。还有一个特别糟糕的,你发现的那个,其中一部分字节在一个缓存行中,一部分在另一个缓存行中。这需要两个单独的内存访问并将它们粘合在一起。慢了三倍。

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

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

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

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

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

Update 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 使用堆栈,第二个秒表允许它再次使用寄存器。