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

Производительность С# для малых функций

Один из моих сотрудников читал "Чистый код" Роберта К Мартина и попал в раздел о том, как использовать множество небольших функций, а не меньше больших функций. Это привело к дискуссии о последствиях этой методологии. Поэтому мы написали быструю программу для проверки производительности и сбиты с толку результатами.

Для начала здесь есть нормальная версия функции.

static double NormalFunction()
{
    double a = 0;
    for (int j = 0; j < s_OuterLoopCount; ++j)
    {
        for (int i = 0; i < s_InnerLoopCount; ++i)
        {
            double b = i * 2;
            a = a + b + 1;
        }
    }
    return a;
}

Вот версия, которую я сделал, которая разбивает функциональность на небольшие функции.

static double TinyFunctions()
{
    double a = 0;
    for (int i = 0; i < s_OuterLoopCount; i++)
    {
        a = Loop(a);
    }
    return a;
}
static double Loop(double a)
{
    for (int i = 0; i < s_InnerLoopCount; i++)
    {
        double b = Double(i);
        a = Add(a, Add(b, 1));
    }
    return a;
}
static double Double(double a)
{
    return a * 2;
}
static double Add(double a, double b)
{
    return a + b;
}

Я использую класс секундомера во время выполнения функций, и когда я запускал его в отладке, я получил следующие результаты.

s_OuterLoopCount = 10000;
s_InnerLoopCount = 10000;
NormalFunction Time = 377 ms;
TinyFunctions Time = 1322 ms;

Эти результаты имеют смысл для меня особенно в отладке, поскольку в вызовах функций есть дополнительные накладные расходы. Когда я запускаю его в выпуске, я получаю следующие результаты.

s_OuterLoopCount = 10000;
s_InnerLoopCount = 10000;
NormalFunction Time = 173 ms;
TinyFunctions Time = 98 ms;

Эти результаты путают меня, даже если компилятор оптимизирует TinyFunctions, объединив все вызовы функций, как это могло бы сделать это на 57% быстрее?

Мы пробовали объявления переменных переменных в NormalFunctions и в основном не влияли на время выполнения.

Я надеялся, что кто-то узнает, что происходит, и если компилятор может так хорошо оптимизировать TinyFunctions, почему он не может применять аналогичные оптимизации для NormalFunction.

Оглядываясь по сторонам, мы обнаружили, что кто-то упоминал о том, что разблокировка функций позволяет JIT лучше оптимизировать, что помещать в регистры, но NormalFunctions имеет только 4 переменные, поэтому мне трудно поверить, что объясняет огромную разницу в производительности.

Буду благодарен за любое понимание, которое кто-то может предоставить.

Обновление 1 Как было указано ниже, Кайл менял порядок операций, делал огромную разницу в производительности NormalFunction.

static double NormalFunction()
{
    double a = 0;
    for (int j = 0; j < s_OuterLoopCount; ++j)
    {
        for (int i = 0; i < s_InnerLoopCount; ++i)
        {
            double b = i * 2;
            a = b + 1 + a;
        }
    }
    return a;
}

Вот результаты с этой конфигурацией.

s_OuterLoopCount = 10000;
s_InnerLoopCount = 10000;
NormalFunction Time = 91 ms;
TinyFunctions Time = 102 ms;

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

Кроме того, я затем попробовал его с целыми операциями, и мы вернулись к тому, чтобы не иметь никакого смысла.

s_OuterLoopCount = 10000;
s_InnerLoopCount = 10000;
NormalFunction Time = 87 ms;
TinyFunctions Time = 52 ms;

И это не изменяется независимо от порядка операций.

4b9b3361

Ответ 1

Я могу улучшить производительность, изменив одну строку кода:

a = a + b + 1;

Измените его на:

a = b + 1 + a;

Или:

a += b + 1;

Теперь вы обнаружите, что NormalFunction может быть немного быстрее, и вы можете "исправить" это, изменив подпись метода Double на:

int Double( int a ) { return a * 2; }

Я думал об этих изменениях, потому что это то, что было разным между двумя реализациями. После этого их производительность очень похожа на TinyFunctions на несколько процентов медленнее (как и ожидалось).

Второе изменение легко объяснить: реализация NormalFunction фактически удваивает int, а затем преобразует ее в Double (с кодом операции fild на уровне машинного кода). Первоначальный метод Double сначала загружает a Double, а затем удваивает его, что я ожидаю немного медленнее.

Но это не объясняет основную часть несоответствия времени выполнения. Это почти полностью связано с тем изменением порядка, которое я сделал первым. Зачем? Я понятия не имею. Разница в машинных кодах выглядит так:

Original                                                    Changed
01070620  push        ebp                                   01390620  push        ebp  
01070621  mov         ebp,esp                               01390621  mov         ebp,esp  
01070623  push        edi                                   01390623  push        edi  
01070624  push        esi                                   01390624  push        esi  
01070625  push        eax                                   01390625  push        eax  
01070626  fldz                                              01390626  fldz  
01070628  xor         esi,esi                               01390628  xor         esi,esi  
0107062A  mov         edi,dword ptr ds:[0FE43ACh]           0139062A  mov         edi,dword ptr ds:[12243ACh]  
01070630  test        edi,edi                               01390630  test        edi,edi  
01070632  jle         0107065A                              01390632  jle         0139065A  
01070634  xor         edx,edx                               01390634  xor         edx,edx  
01070636  mov         ecx,dword ptr ds:[0FE43B0h]           01390636  mov         ecx,dword ptr ds:[12243B0h]  
0107063C  test        ecx,ecx                               0139063C  test        ecx,ecx  
0107063E  jle         01070655                              0139063E  jle         01390655  
01070640  mov         eax,edx                               01390640  mov         eax,edx  
01070642  add         eax,eax                               01390642  add         eax,eax  
01070644  mov         dword ptr [ebp-0Ch],eax               01390644  mov         dword ptr [ebp-0Ch],eax  
01070647  fild        dword ptr [ebp-0Ch]                   01390647  fild        dword ptr [ebp-0Ch]  
0107064A  faddp       st(1),st                              0139064A  fld1  
0107064C  fld1                                              0139064C  faddp       st(1),st  
0107064E  faddp       st(1),st                              0139064E  faddp       st(1),st  
01070650  inc         edx                                   01390650  inc         edx  
01070651  cmp         edx,ecx                               01390651  cmp         edx,ecx  
01070653  jl          01070640                              01390653  jl          01390640  
01070655  inc         esi                                   01390655  inc         esi  
01070656  cmp         esi,edi                               01390656  cmp         esi,edi  
01070658  jl          01070634                              01390658  jl          01390634  
0107065A  pop         ecx                                   0139065A  pop         ecx  
0107065B  pop         esi                                   0139065B  pop         esi  
0107065C  pop         edi                                   0139065C  pop         edi  
0107065D  pop         ebp                                   0139065D  pop         ebp  
0107065E  ret                                               0139065E  ret  

Это код операции для кода операции идентичен, за исключением порядка операций с плавающей запятой. Это делает огромную разницу в производительности, но я не знаю достаточно о операциях с плавающей запятой x86, чтобы узнать, почему именно.

Update:

В новой целочисленной версии мы видим что-то еще любопытное. В этом случае кажется, что JIT пытается быть умным и применять оптимизацию, потому что это делает это:

int b = 2 * i;
a = a + b + 1;

В чем-то вроде:

mov esi, eax              ; b = i
add esi, esi              ; b += b
lea ecx, [ecx + esi + 1]  ; a = a + b + 1

Где a хранится в регистре ecx, i в eax и b в esi.

В то время как версия TinyFunctions превращается в нечто вроде:

mov         eax, edx  
add         eax, eax  
inc         eax  
add         ecx, eax  

Где i находится в edx, b находится в eax, а a находится в ecx на этот раз.

Я полагаю, что для нашей архитектуры процессора этот "трюк" LEA (объясненный здесь) заканчивается медленнее, чем просто использование самого ALU. По-прежнему можно изменить код, чтобы получить производительность между двумя линиями:

int b = 2 * i + 1;
a += b;

Это приводит к тому, что метод NormalFunction заканчивается тем, что он превращается в mov, add, inc, add, как он появляется в подходе TinyFunctions.