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


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

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
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.