Странное увеличение производительности в простом бенчмарке


вчера я нашел статья Кристофа Нахра под названием ".NET Struct Performance" который сравнивал несколько языков (C++, C#, Java, JavaScript) для метода, который добавляет две точечные структуры (double ОК).

как оказалось, версия C++ занимает около 1000 мс для выполнения (1e9 итераций), в то время как C# не может получить под ~3000ms на той же машине (и выполняет еще хуже в x64).

чтобы проверить это сам, я взял код C# (и упростил слегка вызвать только метод, где параметры передаются по значению), и запустил его на машине i7-3610QM (3.1 Ghz boost для одного ядра), 8GB RAM, Win8.1, используя .NET 4.5.2, RELEASE build 32-bit (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 производительность и на самом деле только кажется, чтобы измерить basic 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

вот пастебин. вы должны запустить его как 32-разрядный релиз на .NET 4.x (есть несколько проверок код для обеспечения этого).

(обновление 4)

после комментариев @usr на ответ @Hans я проверил оптимизированную разборку для обоих методов, и они довольно разные:

это, кажется, показывает, что разница может быть связана с компилятором, действующим смешно в первом случае, а не с двойным выравниванием полей?

также, если я добавить два переменные (общее смещение 8 bytes), я все еще получаю тот же прирост скорости - и больше не кажется, что это связано с упоминанием выравнивания полей Хансом Пассантом:

// 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 96

4 ответа:

обновление 4 объясняет проблему: в первом случае JIT сохраняет вычисленные значения (a,b) в стеке; во втором случае 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 использовать стек, а второй секундомер позволяет ему снова использовать регистры.

существует очень простой способ всегда получать "быструю" версию вашей программы. Вкладка "проект > свойства > сборка", снимите флажок" предпочесть 32-разрядный", убедитесь, что целевой выбор платформы является AnyCPU.

вы действительно не предпочитаете 32-бит, к сожалению, всегда включен по умолчанию для проектов C#. Исторически сложилось так, что набор инструментов Visual Studio работал намного лучше с 32-разрядными процессами, старой проблемой, которую Microsoft отбрасывала. Время, чтобы удалить эту опцию, VS2015, в частности, обратился к последним нескольким реальным дорожным блокам к 64-битному коду с совершенно новым x64 джиттером и универсальной поддержкой Edit+Continue.

достаточно болтовни, то, что вы обнаружили, это важность трассы для переменных. Процессор заботится об этом. Если переменная неправильно выровнена в памяти, то процессор должен выполнить дополнительную работу, чтобы перетасовать байты, чтобы получить их в правильном порядке. Существуют две различные проблемы несоосности, одна из которых заключается в том, где байты все еще находятся внутри одной строки кэша L1, что стоит дополнительного цикла, чтобы переместить их в правильное положение. И очень плохой, тот, который вы нашли, где часть байтов находится в одной строке кэша, а часть в другой. Это требует двух отдельных обращений к памяти и склеивания их вместе. В три раза медленнее.

The double и long типы-это создатели проблем в 32-разрядном процессе. Они имеют размер 64 бита. И может получить таким образом получить смещение на 4, CLR может только гарантия 32-битного выравнивания. Не проблема в 64-битном процессе, все переменные гарантированно будут выровнены до 8. Также основная причина, по которой язык C# не может обещать им быть atomic. И почему массивы double выделяются в большой куче объектов, когда они имеют более 1000 элементов. LOH обеспечивает гарантию выравнивания 8. И объясняет, почему добавление локальной переменной решило проблему, ссылка на объект составляет 4 байта, поэтому она переместила двойной переменная на 4, Теперь выравнивается. Случайно.

32-разрядный компилятор C или C++ делает дополнительную работу, чтобы гарантировать, что двойной не может быть разрегулирован. Не совсем простая проблема для решения, стек может быть смещен при вводе функции, учитывая, что единственной гарантией является то, что он выровнен до 4. В прологе такой функции нужно проделать дополнительную работу, чтобы она выровнялась до 8. Тот же трюк не работает в управляемом программой, сборщик мусора очень о том, где именно локальная переменная находится в памяти. Необходимо, чтобы он мог обнаружить, что объект в куче GC все еще ссылается. Он не может правильно работать с такой переменной, перемещаемой на 4, потому что стек был смещен при вводе метода.

Это также основная проблема с .NET jitters не легко поддерживает инструкции SIMD. Они имеют гораздо более сильные требования к выравниванию, такие, что процессор также не может решить сам по себе. Поддержкой SSE2 требуется выравнивание 16, AVX требует выравнивания 32. Не удается получить это в управляемом коде.

и последнее, но не менее важное, Также обратите внимание, что это делает perf программы C#, которая работает в 32-разрядном режиме очень непредсказуемым. Когда вы получаете доступ к двойной или долго это хранится как поле в объекте, то perf может резко измениться, когда сборщик мусора сжимает кучу. Который перемещает объекты в памяти, такое поле теперь может внезапно получить mis / aligned. Очень случайный конечно, может быть довольно удручающе :)

Ну, нет простых исправлений, но один, 64-битный код-это будущее. Удалите принудительное дрожание до тех пор, пока Microsoft не изменит шаблон проекта. Может быть, следующая версия, когда они чувствуют себя более уверенными версия RyuJIT.

сузил его немного (только, кажется, влияет на 32-разрядную среду CLR 4.0 runtime).

обратите внимание на размещение 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);
}

быстро (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);
}

кажется, что в дрожании есть какая-то ошибка, потому что поведение еще более странное. Рассмотрим следующий код:

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 ms, то же самое, что и внешний корпус секундомера. Однако, если мы удалим if (!warmup) состоянии, он будет работать в 3000 МС. Что еще более странно, что следующий код будет работать в 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.Y ссылки Console выход.

у меня есть не знаю, что происходит, но это пахнет довольно глючно для меня и не связано с наличием внешнего Stopwatch или нет, вопрос кажется немного более обобщенно.