Странное увеличение производительности в простых тестах

Вчера я нашел статью Кристофа Нахра озаглавленную "Производительность .NET Struct" , в которой сравнивались несколько языков (С++, С#, Java, JavaScript) для метода, который добавляет две точечные структуры (double кортежи).

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

Чтобы проверить его сам, я взял код С# (и немного упростил вызов только метода, когда параметры переданы по значению), и запустил его на машине i7-3610QM (повышение 3,1 ГГц для одного ядра), 8 ГБ ОЗУ, 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 и фактически только измеряет базовую арифметику 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, я проверил оптимизированную разборку для обоих методов, и они довольно разные:

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

Обновление 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 использовать стек, а второй секундомер позволяет ему снова использовать регистры.

Ответ 2

Существует очень простой способ всегда получать "быструю" версию вашей программы. Project > Properties > Build tab, отключите опцию "Предпочитайте 32-разрядную", убедитесь, что целевой целевой объект платформы - AnyCPU.

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

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

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

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

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

И последнее, но не менее важное: обратите внимание, что это делает первичную программу С#, которая работает в 32-битном режиме, очень непредсказуема. Когда вы получаете доступ к двойному или длинному, который хранится как поле в объекте, тогда перфомант может резко измениться, когда сборщик мусора сжимает кучу. Который перемещает объекты в памяти, такое поле теперь может внезапно оказаться ошибочным/выровненным. Конечно, очень случайный, может быть довольно головоломкой:)

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

Ответ 3

Сузилось какое-то (что, похоже, влияет на 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);
}

Ответ 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 ms, как и в случае с внешним секундомером. Однако, если мы удалим условие 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.Y из вывода Console.

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