简单基准

时间:2015-08-20 09:19:44

标签: c# performance benchmarking cil

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

事实证明,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

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

(更新4)

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

Test1 on the left, Test2 on the right

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

另外,如果我添加两个变量(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);
}

4 个答案:

答案 0 :(得分:75)

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

你真的不喜欢32位,不幸的是,C#项目默认情况下总是打开。从历史上看,Visual Studio工具集在32位进程中运行得更好,这是微软一直在努力解决的一个老问题。是时候删除该选项,特别是VS2015使用全新的x64抖动和对Edit + Continue的通用支持,解决了最后几个真正的64位代码路径问题。

足够的喋喋不休,您发现的是对齐对变量的重要性。处理器非常关心它。如果变量在内存中未对齐,则处理器必须执行额外的工作来重新排列字节以使它们按正确的顺序排列。存在两个明显的错位问题,一个是字节仍在单个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#程序的perf非常难以预测。当您访问作为对象中的字段存储的 double long 时,当垃圾收集器压缩堆时,perf会发生剧烈变化。这会移动内存中的对象,这样的字段现在可能突然变得错误/对齐。当然非常随机,可能是一个令人头疼的问题:)

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

答案 1 :(得分:10)

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

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

答案 2 :(得分:5)

将某些内容缩小(仅影响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);
}

答案 3 :(得分:4)

Jitter中似乎存在一些错误,因为行为甚至更加严重。请考虑以下代码:

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 ms内运行。更奇怪的是,以下代码也将在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);
}

注意我已从a.X输出中删除了a.YConsole个引用。

我不知道最近会发生什么事,但这对我来说闻起来很麻烦,而且它与外部Stopwatch无关,这个问题似乎有点笼统。