Подтвердить что ты не робот

Почему производительность отличается, когда единственная разница в кодах, которые не выполняются?

Test1 ниже на 10% быстрее, чем Test2, хотя я всегда вызываю метод с аргументом 0, поэтому материал, который внутри случая коммутатора - единственная разница, никогда не выполняется.

Btw после копирования и вставки кода в совершенно новый проект, только сменив имя тестовой функции на Main, результаты будут отменены. Каждый раз, когда я запускаю этот проект, Test2 работает быстрее.

Итак, каковы факторы, связанные с тем, чтобы сделать это медленнее и быстрее? И: Могу ли я влиять на производительность сознательно в .net в свою пользу?

Эти методы, конечно, почти ничего не делают, поэтому разница в производительности на 10% для теста, которая включает в себя в основном идентичные вызовы виртуальных методов (которые, как правило, CLR не способны встраивать - вводная часть к ВС btw для реализации этого в JVM ) кажется огромным.

N.B. На самом деле это минимальная версия реальной программы, в которой несколько вложенных операторов switch приводят к огромной разнице в производительности не только 10%, но и 100% и более, по-видимому, благодаря наличию кода внутри вложенных ветвей коммутатора, которые испытывают никогда не входите. (Поэтому, возможно, эта минимальная версия опускает некоторые другие аспекты реальной программы, которые могут быть задействованы, но она реплицирует значительную и последовательную разницу в производительности)

Изменить. В реальной программе, что может быть важнее, чем явление в этом вопросе, заключается в том, соответствуют ли фактические утверждения case в заявлении коммутатора посредством таблица ветвей или нет - (что зависит от пробелов в значениях case - что-то, что я мог бы проверить, посмотрев на сгенерированный код IL)

Тестирование на

  • .net 4.5.1
  • Выполнить сборку, выполнить с помощью Ctrl-F5
  • Intel i7 CPU

Код:

using System;
using System.Diagnostics;

class Test1 : ITest
{
    public int Test(int a)
    {
        switch (a)
        {
            case 1: return a + a + a == 1234 ? 1 : 2;
            case 2: return 2;
        }
        return 0;
    }
}

class Test2 : ITest
{
    public int Test(int a)
    {
        switch (a)
        {
            case 1: return 1;
            case 2: return 2;
        }
        return 0;
    }
}

class Program
{
    static void Main(string[] args)
    {
        const long iterations = 200000000;
        var test1 = new Test1();
        var test2 = new Test2();

        while (true)
        {
            var sw1 = Stopwatch.StartNew();
            for (long i = 0; i < iterations; i++)
                test1.Test(0);
            sw1.Stop();


            var sw2 = Stopwatch.StartNew();
            for (long i = 0; i < iterations; i++)
                test2.Test(0);
            sw2.Stop();

            var iterPerUsec1 = iterations / sw1.Elapsed.TotalMilliseconds / 1000;
            var iterPerUsec2 = iterations / sw2.Elapsed.TotalMilliseconds / 1000;
            Console.WriteLine("iterations per usec: " + (int) iterPerUsec1 + " / " + (int) iterPerUsec2 + " ratio: " + iterPerUsec1/iterPerUsec2);
        }
    }
}

interface ITest
{
    int Test(int a);
}

Здесь вывод типичного прогона, где Test1 последовательно на самом деле более чем на 12% быстрее:

iterations per usec: 369 / 342 ratio: 1.07656329512607
iterations per usec: 367 / 314 ratio: 1.16820632522335
iterations per usec: 372 / 337 ratio: 1.10255744679504
iterations per usec: 374 / 342 ratio: 1.09248387354978
iterations per usec: 367 / 329 ratio: 1.11451205881061
iterations per usec: 375 / 340 ratio: 1.10041698470293
iterations per usec: 373 / 314 ratio: 1.19033461920118
iterations per usec: 366 / 334 ratio: 1.09808424282708
iterations per usec: 372 / 314 ratio: 1.18497411681768
iterations per usec: 377 / 342 ratio: 1.10482425370152
iterations per usec: 380 / 346 ratio: 1.09794853154766
iterations per usec: 385 / 342 ratio: 1.12737583603649
iterations per usec: 376 / 327 ratio: 1.15024393718844
iterations per usec: 374 / 332 ratio: 1.12400483908544
iterations per usec: 383 / 341 ratio: 1.12106159857722
iterations per usec: 380 / 345 ratio: 1.10267634674555
iterations per usec: 375 / 344 ratio: 1.09211401775982
iterations per usec: 384 / 334 ratio: 1.14958454236246
iterations per usec: 368 / 321 ratio: 1.14575850263002
iterations per usec: 378 / 335 ratio: 1.12732301818235
iterations per usec: 380 / 338 ratio: 1.12375853123099
iterations per usec: 386 / 344 ratio: 1.12213818994067
iterations per usec: 385 / 336 ratio: 1.14346447712043
iterations per usec: 374 / 345 ratio: 1.08448615249764
...
4b9b3361

Ответ 1

Бенчмаркинг - это изобразительное искусство, очень сложно надежно измерить очень быстрый код, подобный этому. В целом, различия в 15% или менее не являются статистически значимыми результатами. Я могу только прокомментировать недостаток в опубликованном коде, это довольно распространенный вопрос. Типичный гейзенбургский язык, сам тест влияет на исход.

Второй цикл for() не так оптимизирован, как первый цикл for(). Проблема, вызванная выбором оптимизатора, по которому локальные переменные сохраняются в регистре CPU. В частности, проблема, когда вы используете долго в 32-разрядной программе, записывает два регистра процессора. По всей вероятности, комментаторы, которые упомянули, что не могут воспроизвести его, проверили тест с джиттером x64.

Вы снимаете давление с распределителя регистров CPU, перемещая тесты в отдельный метод:

static Stopwatch runTest1(Test1 test1, long iterations) {
    var sw1 = Stopwatch.StartNew();
    for (long i = 0; i < iterations; i++)
        test1.Test(0);
    sw1.Stop();
    return sw1;
}

static Stopwatch runTest2(Test2 test2, long iterations) {
    var sw2 = Stopwatch.StartNew();
    for (long i = 0; i < iterations; i++)
        test2.Test(0);
    sw2.Stop();
    return sw2;
}

static void Main(string[] args) {
    const long iterations = 200000000;
    var test1 = new Test1();
    var test2 = new Test2();

    while (true) {
        var sw1 = runTest1(test1, iterations);
        var sw2 = runTest2(test2, iterations);
        // etc..
    }
}

И теперь вы получите то, что ожидаете.

Это изменение было вызвано просмотром созданного машинного кода. Инструменты > Параметры > Отладкa > Общие > Отключить опцию Suppress JIT Optimization. Затем вы можете посмотреть оптимизированный машинный код с помощью Debug > Windows > Disassembly. Что показывает первый цикл for():

            for (long i = 0; i < iterations; i++)
00000089  xor         ebx,ebx 
0000008b  xor         esi,esi 
0000008d  mov         ecx,dword ptr [esp+4] 
00000091  xor         edx,edx 
00000093  call        dword ptr ds:[04732844h] 
00000099  add         ebx,1 
0000009c  adc         esi,0 
0000009f  test        esi,esi 
000000a1  jg          000000AD 
000000a3  jl          0000008D 
000000a5  cmp         ebx,0BEBC200h 
000000ab  jb          0000008D 

И второй цикл:

            for (long i = 0; i < iterations; i++)
000000f5  mov         dword ptr [esp+2Ch],0 
000000fd  xor         ebx,ebx 
000000ff  mov         ecx,dword ptr [esp] 
00000102  xor         edx,edx 
00000104  call        dword ptr ds:[056E28B8h] 
0000010a  mov         eax,ebx 
0000010c  mov         edx,dword ptr [esp+2Ch]        ; <== here
00000110  add         eax,1 
00000113  adc         edx,0 
00000116  mov         dword ptr [esp+2Ch],edx        ; <== here
0000011a  mov         ebx,eax 
0000011c  cmp         dword ptr [esp+2Ch],0 
00000121  jg          0000012D 
00000123  jl          000000FF 
00000125  cmp         ebx,0BEBC200h 
0000012b  jb          000000FF 

Я отметил инструкции, которые сделали это медленнее. Первый цикл for() может хранить переменную цикла в регистрах esi: ebx и сохранять их там. Это не сработало во втором цикле for(), у него закончились доступные регистры процессора, а верхние 32-битные переменные цикла должны были храниться в кадре стека. Это медленно.

Внесение изменений в такую ​​программу не является общим советом, а не универсальным решением. Это просто оказалось эффективным в этом конкретном случае. Только просмотр машинного кода может дать вам указание, что такое изменение может оказаться полезным. Что именно о том, что нужно для ручной настройки кода. Вероятно, больше того, что вы на самом деле пытаетесь оптимизировать, этот тест вряд ли будет представлять ваш фактический код.

Ответ 2

Чтобы исследовать, я взял ваш код и немного изменил его. Моя основная петля повторяется 20 раз, и я удалил часть, где вы печатаете скорость двух тестов. Вместо этого я сохраняю разницу в тиках (sw2.ElapsedTicks - sw1.ElapsedTicks) в предварительно распределенном массиве. Это просто, чтобы основной цикл был максимально простым.

В конце основного цикла я усредняю ​​разницу тиков. Положительное число означает, что второй тест выполняется медленнее, используя больше тиков, чем первый тест.

Я сделал этот тест со всеми перестановками возможных тестовых последовательностей: Test1, за которым следует Test2, Test2, за которым следуют Test1, Test1, за которыми следуют Test1 и Test2, за которыми следует Test2, Это мои результаты с включенными оптимизациями:

 Tests | Average
-------+---------
 1 2   | 248,747
 2 1   | 313,372
 1 1   | 234.812
 2 2   | 210.533

Поэтому независимо от того, как я запускаю тесты, второй тест в цикле всегда медленнее первого.

Мой вывод состоит в том, что по какой-то причине первый тест в основном цикле будет выполняться несколько быстрее. Я не знаю, почему, и я не всегда получаю согласованные результаты, но по крайней мере это, похоже, не связано с кодом в Test1 и Test2.