Дивне підвищення продуктивності в простому еталоні


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це дає мені неправильні результати, тому що я чітко бачу, що це закінчується через одну секунду.

Хтось може сказати мені, що тут може відбуватися?

(Оновлення)

Ось два методи в одній програмі, що показує, що причина не в 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, я перевірив оптимізований розбір для обох методів, і вони досить різні:

Тест1 ліворуч, Тест2 праворуч

Це, мабуть, показує, що різниця може бути пов’язана з компілятором, який у першому випадку поводиться смішно, а не з подвійним вирівнюванням поля?

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

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

Існує дуже простий спосіб завжди отримати «швидку» версію вашої програми. Проект> Властивості> Вкладка «Збірка», зніміть прапорець «Віддати перевагу 32-розрядному», переконайтеся, що цільовим вибором платформи є 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. Той самий фокус не працює в керованій програмі, збирач сміття дуже дбає про те, де саме локальна змінна знаходиться в пам'яті. Необхідно, щоб він міг виявити, що на об'єкт у купі GC все ще є посилання. Він не може правильно обробляти таку змінну, яка рухається на 4, оскільки стек був неправильно вирівняний під час введення методу.

Це також основна проблема з. NET тремтінням, яке нелегко підтримує інструкції SIMD. Вони мають набагато сильніші вимоги до вирівнювання, такі, які процесор також не може вирішити сам. SSE2 вимагає вирівнювання 16, AVX вимагає вирівнювання 32. Не вдається отримати це в керованому коді.

І останнє, але не менш важливе, також зауважте, що це робить продуктивність програми C #, яка працює в 32-розрядному режимі, дуже непередбачуваною. Коли ви отримуєте доступ до подвійного або довгого, який зберігається як поле в об'єкті, тоді 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: Я бачив, щось рибне. Також це відбувається лише на структурах.
леппі

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мс, так само, як і зовнішній футляр секундоміра. Однак, якщо ми видалимо 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: так, це здається розумним, але не тоді, коли ти береш до уваги інші дивні поведінки, які ми спостерігаємо. Видалення 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.