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

97

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

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

Чтобы проверить это сам, я взял код C # (и немного упростил его, чтобы вызвать только метод, в котором параметры передаются по значению), и запустил его на машине 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 значит, что это дает мне неправильные результаты, потому что я ясно вижу, что это заканчивается через одну секунду.

Кто-нибудь может сказать мне, что здесь может происходить?

(Обновить)

Вот два метода в одной программе, которые показывают, что причина не в JIT:

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 байт), я все равно получу такой же прирост скорости - и больше не кажется, что это связано с выравниванием полей, упомянутым Гансом Пассантом:

// 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);
}
Groo
источник
1
Помимо JIT, это также зависит от оптимизаций компилятора, новейший Ryujit выполняет больше оптимизаций и даже вводит ограниченную поддержку инструкций SIMD.
Феликс К.
3
Джон Скит обнаружил проблему производительности с полями только для чтения в структурах: Микрооптимизация: удивительная неэффективность полей только для чтения . Попробуйте сделать закрытые поля недоступными только для чтения.
dbc
2
@dbc: Я провел тест только с локальными doubleпеременными, без structs, поэтому я исключил неэффективность структуры структуры / вызова метода.
Groo
3
Кажется, это происходит только на 32-битной версии, с RyuJIT я оба раза получаю 1600 мс.
leppie
2
Я посмотрел разборку обоих методов. Нет ничего интересного. Test1 генерирует неэффективный код без видимой причины. Ошибка JIT или по дизайну. В Test1 JIT загружает и сохраняет в стек двойники для каждой итерации. Это может быть для обеспечения точной точности, потому что блок с плавающей запятой x86 использует внутреннюю точность 80 бит. Я обнаружил, что любой вызов невстроенной функции в верхней части функции заставляет ее снова работать быстро.
usr

Ответы:

10

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

Андрей Акиншин
источник
Это на самом деле не объясняет причину. Если вы проверите мои тесты, то окажется, что тест с дополнительным на Stopwatchсамом деле работает быстрее . Но если вы поменяете местами порядок, в котором они вызываются в Mainметоде, тогда другой метод будет оптимизирован.
Groo
75

Есть очень простой способ всегда получить «быструю» версию вашей программы. Project> Properties> Build tab, снимите отметку с опции «Prefer 32-bit», убедитесь, что целевой платформой выбран AnyCPU.

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

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

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

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

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

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

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

Ганс Пассан
источник
1
Не уверен, как выравнивание играет в этом, когда двойные переменные могут быть зарегистрированы (и находятся в Test2). Test1 использует стек, Test2 - нет.
usr
2
Этот вопрос меняется слишком быстро, и я не могу его уследить. Вы должны следить за тем, чтобы сам тест влиял на результат теста. Вам нужно добавить [MethodImpl (MethodImplOptions.NoInlining)] в методы тестирования, чтобы сравнить яблоки с апельсинами. Теперь вы увидите, что оптимизатор может сохранять переменные в стеке FPU в обоих случаях.
Ханс Пассан,
4
Омг, это правда. Почему выравнивание метода влияет на генерируемые инструкции ?! Для тела цикла не должно быть никакой разницы. Все должно быть в реестрах. Пролог выравнивания не должен иметь значения. Все еще похоже на ошибку JIT.
usr
3
Я должен существенно пересмотреть ответ, облом. Я займусь этим завтра.
Ханс Пассан
2
@HansPassant вы собираетесь копаться в источниках JIT? Было бы весело. На данный момент все, что я знаю, это случайная ошибка JIT.
usr
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);
}
леппи
источник
Изменение кода без прикосновения Stopwatchтакже резко меняет скорость. Изменение сигнатуры метода Test1(bool warmup)и добавление условного выражения в Consoleвыводе: if (!warmup) { Console.WriteLine(...); }также имеет тот же эффект (наткнулся на это при создании своих тестов для воспроизведения проблемы).
Между
@InBetween: Я видел, что-то подозрительно. Также происходит только в структурах.
leppie
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мс. Что еще более странно, так это то, что следующий код также будет работать в 900мс:

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или нет, проблема кажется немного более общей.

Между
источник
Когда вы удаляете вызовы a.Xи a.Y, компилятор, вероятно, свободен оптимизировать почти все внутри цикла, потому что результаты операции не используются.
Groo
@Groo: да, это кажется разумным, но не с учетом другого странного поведения, которое мы наблюдаем. Удаление a.Xи a.Yне заставляет его работать быстрее, чем когда вы включаете if (!warmup)условие или OP outerSw, что означает, что он ничего не оптимизирует, а просто устраняет любую ошибку, заставляющую код работать с субоптимальной скоростью ( 3000мс вместо 900мс).
Между
2
О, хорошо, я думал , что увеличение скорости происходит , когда warmupбыло правдой, но в этом случае линия даже не печатается, так что случай , когда он действительно получить напечатанной на самом деле ссылку a. Тем не менее, я хотел бы убедиться, что всегда ссылаюсь на результаты вычислений где-то ближе к концу метода, когда я тестирую материал.
Groo