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

Художественное отступление, шутка на текущую тему. Чтобы узнать самое интересное, читать текст под катом совершенно необязательно.

Перед архивом университета стояла гигантская очередь. Философы, лингвисты, математики-программисты. Все вместе. Отстояв мучительно долгую очередь, каждый из них заходил внутрь и пропадал ещё на полчаса. Выходил уставший и радостный с нужной бумажкой. Такая вот унизительная процедура при выпуске.

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

Наконец настал тот самый момент, когда разрешили зайти в заветный кабинет за огромными дверьми. К большому разочарованию, внутри снова была очередь, но поменьше. Зато стало интереснее — теперь можно наблюдать, как работает архив.

Философы и лингвисты нервно и смиренно провожали глазами медленную работницу архива. Тётенька шла из одного угла огромного кабинета ко входу, где у перегородки толпились зашедшие. Выдавала следующему ручку и бумажку. Велела написать на бумажке свои ФИО. Затем она медленно шла с этой бумажкой обратно к себе на место и что-то заносила в бумажный журнал. Бессмысленные и пустые разговоры с другой тётенькой, которая как будто бы ничего не делала, ничуть не ускоряли процесс.

Отхлебнув из старой, пожелтевшей и не отмывающейся от дешевой заварки кружки, тётенька направлялась в другой угол огромного кабинета. Зачем этот кабинет такой большой? Там же буквально ничего нет, и она каждый раз проходит с десяток метров по пустому пространству. В этом другом углу находилась приоткрытая дверь в тёмное помещение. Тётенька просочилась туда.

Стояла почти полная тишина. Философ переминался с ноги на ногу. Другая тётенька стучала ногтями по экрану смартфона. Лингвист глубоко вздыхал. Приглушенно доносилось, как по проспекту ездят трамваи. Чирикали воробьи, наверное, обучали летать свой последний за это лето выводок. Сквозь огромные окна с прозрачным тюлем в огромный кабинет проникал тёплый летний солнечный свет, разливаясь по старому, потрескавшемуся линолеуму. Было видно, как шевелится тень какого-то лиственного дерева. Стояла духота, но окна почему-то не открывали. Почему мы, зашедшие, стоим втроём за этой перегородкой, теснясь и кое-как вмещаясь, когда тут такой пустой и огромный кабинет?

Тётенька вынырнула из тёмного помещения с какими-то листками. Философ чуть не запрыгал от счастья, но тут же начал с сожалением провожать глазами тётеньку до её рабочего места. Снова через этот длинный кабинет.

Сделав какие-то записи в журнале, она наконец-то пошла к перегородке, отдала листки философу и велела ему позвать следующего. И снова вернулась на своё место.

Тётенька взяла уже известные бумажку и ручку, и снова пошла к перегородке. Философ успел убежать, на его место подвинулись все оставшиеся. Зашёл очередной таящий надежды на скорое решение своего вопроса.

Процедура повторилась: бумажка, ФИО, журнал, тёмное помещение, трамваи, журнал, листки, новый человек.

Очередь дошла до математика-программиста, который не выносит неоптимальностей, и тётеньке поступило предложение по оптимизации процесса. Про батчинг сбора бумажек с ФИО, про оптимизацию перемещений по кабинету, про сортировку бумажек по алфавиту или году, вы только скажите, ведь наверняка в том тёмном помещении это всё отсортировано по какому-то признаку?

Тётенька не слушала. В её вселенной невозможно воспринять тот факт, что с ней разговаривают немолебным и неуничижительным тоном «мне пожалуйста забрать документики». Несколько секунд стеклянного взгляда (каким смотрят на самого заклятого врага) сменились гневом. Из неё начали сыпаться оскорбления.

Попытка оптимизации не удалась. Потеряв минуту-другую на выслушивание бесполезных сотрясаний воздуха, процедура начала повторяться как ни в чем не бывало. Бумажка, ФИО, журнал, тёмное помещение, трамваи, журнал, листки, новый человек.

Введение

Давайте представим, что ваш коллега написал код, который должен контролировать количество исходящих запросов во внешний сервис, который имеет строгий дневной лимит. Если его превысить, нас забанят. А заодно код будет отфильтровывать заведомо некорректные запросы, чтобы не тратить лимит зазря. 

И вот этот код попадает к вам на ревью (опустим проблемы с многопоточностью и весьма курьезные try catch):

public int Handle()
{
    var request = Request;
 
    var requestValid = true;
    if (request.From > request.To) requestValid = false;
    if (request.From < 0) requestValid = false;
    if (request.To < 0) requestValid = false;
 
    if (!requestValid)
    {
        try
        {
            if (request.UserId != Guid.Empty)
            {
                log.Error("Request from user {UserId} is not valid.", request.UserId);
            }
            else
            {
                throw new Exception("Unknown user");
            }
        }
        catch (Exception e)
        {
            log.Error(e, "Request from unknown user is not valid.");
        }
 
        return 400;
    }
 
    totalAttempts += 1;
 
    if (executedQueries >= LimitForMicrobenchmark)
    {
        try
        {
            log.Warn("Reached maximum queries limit. Last request was from {UserId}", request.UserId);
        }
        catch (Exception e)
        {
            log.Error(e);
        }
 
        return 429;
    }
 
    executedQueries += 1;
    return 200;
}

А вы говорите своему коллеге: «Что за чушь? Метод такой большой, аж в экран не влезает! Давай порефакторим и выделим методы». Используем хоткей ctrl + alt + M в ide и выделяем крупные блоки кода, решающие одну конкретную задачу, в отдельные методы:

public int HandleRefactored()
{
    var request = Request;
 
    var requestValid = IsValid(request);
 
    if (!requestValid)
    {
        return HandleAsInvalidRequest(request);
    }
 
    totalAttempts += 1;
 
    if (executedQueries >= LimitForMicrobenchmark)
    {
        return HandleTooManyRequests(request);
    }
 
    executedQueries += 1;
    return 200;
}
 
private int HandleAsInvalidRequest(UserRequest request)
{
    try
    {
        if (request.UserId != Guid.Empty)
        {
            log.Error("Request from user {UserId} is not valid.", request.UserId);
        }
        else
        {
            throw new Exception("Unknown user");
        }
    }
    catch (Exception e)
    {
        log.Error(e, "Request from unknown user is not valid.");
    }
    return 400;
}
 
private int HandleTooManyRequests(UserRequest request)
{
    try
    {
        log.Warn("Reached maximum queries limit. Last request was from {UserId}", request.UserId);
    }
    catch (Exception e)
    {
        log.Error(e);
    }
 
    return 429;
}
 
private bool IsValid(UserRequest request)
{
    if (request.From > request.To) return false;
    if (request.From < 0) return false;
    if (request.To < 0)  return false;
    return true;
}

Красота. Но внезапно этот код не только лучше выглядит, но и в два раза быстрее работает (если рассматривать только основную и популярную ветку исполнения кода — запрос всегда валиден, лимит не превышен)!

| Method           | Mean      | Code Size |
|----------------- |----------:|----------:|
| Handle           | 1.1950 ns |     408 B |
| HandleRefactored | 0.5864 ns |      63 B |

Давайте разбираться, почему. А если вы не хотите узнать ничего нового про Code as Data, процессорные кэш-линии, branch prediction и особенности компиляторов — можете сразу перейти к блоку Выводы за практическими советами.

Разбираемся

В бенчмарке Request был специально так подобран, чтобы всегда проходить валидацию и никогда не заходить в ветку про обработку невалидного запроса. А LimitForMicrobenchmark — бесконечность, чтобы никогда не заходить в ветку про превышение лимита. 

Таким образом, в бенчмарке этих методов тестируется только самая тривиальная и базовая часть кода со сравнениями, арифметикой, и чтением/записью в поля класса. А в ветки с тяжелым логированием и обработкой ошибок мы никогда не заходим. Легко убедиться, что тот код, что в итоге работает в бенчмарках, абсолютно одинаков и эквивалентен что в изначальном варианте, что в отрефакторенном, где мы эти тяжелые части кода (которые никогда не работают в нашем бенчмарке) вынесли в отдельные методы.

Не просто так в результате бенчмарка выведен столбец Code Size. Заглянем в ASM-код, который в итоге получился для наших методов. 

Отрефакторенный метод

Начнём с кода отрефакторенного метода:

## .NET 8.0.15 (8.0.1525.16413), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
``assembly
; benchmarks.FunctionSplitting.HandleRefactored()
       mov       rdx,[rcx+8]
       mov       eax,[rdx+20]
       mov       r8d,[rdx+24]
       cmp       eax,r8d
       jg        short M00_L00
       or        eax,r8d
       jl        short M00_L00
       inc       qword ptr [rcx+20]
       mov       rax,[rcx+18]
       cmp       rax,[7FFD81A87530]
       jge       short M00_L01
       inc       rax
       mov       [rcx+18],rax
       mov       eax,0C8
       ret
M00_L00:
       jmp       qword ptr [7FFD81A6F828]
M00_L01:
       jmp       qword ptr [7FFD81A6F840]
; Total bytes of code 63
``

Необязательно вчитываться в него. Важно осознать, что он простой, короткий и занимает всего 63 байта. В 17 строке (mov eax, 0c8) и 18 строке (ret) — присвоение результата 200 (ОК) и выход из метода. Вызовы методов  HandleAsInvalidRequest  и  HandleTooManyRequests компилятор сообразил перенести в конец программы: это jmp в 20 строке (qword ptr [7FFD81A6F828]) и в 22 строке (qword ptr [7FFD81A6F840]) — впрочем, до них он ни разу не добрался. Метод  IsValid  компилятор заинлайнил в тело основного метода.

Изначальный метод

А теперь посмотрим на код оригинального метода, где весь код был написан прямо в его теле:

## .NET 8.0.15 (8.0.1525.16413), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
``assembly
; benchmarks.FunctionSplitting.Handle()
       push      rbp
       push      rbx
       sub       rsp,48
       vzeroupper
       lea       rbp,[rsp+50]
       mov       [rbp-30],rsp
       mov       [rbp+10],rcx
       mov       rbx,[rcx+8]
       mov       eax,1
       mov       r8d,[rbx+20]
       mov       edx,[rbx+24]
       xor       r10d,r10d
       cmp       r8d,edx
       cmovg     eax,r10d
       test      r8d,r8d
       cmovl     eax,r10d
       test      edx,edx
       cmovl     eax,r10d
       test      eax,eax
       je        short M00_L00
       inc       qword ptr [rcx+20]
       mov       rax,[rcx+18]
       cmp       rax,[7FFD81A57530]
       jge       near ptr M00_L03
       inc       rax
       mov       [rcx+18],rax
       mov       eax,0C8
       add       rsp,48
       pop       rbx
       pop       rbp
       ret
M00_L00:
       vmovups   xmm0,[rbx+28]
       vmovups   [rbp-18],xmm0
       vmovups   xmm0,[rbp-18]
       vptest    xmm0,xmm0
       sete      r8b
       movzx     r8d,r8b
       test      r8d,r8d
       jne       short M00_L01
       mov       r8,[rcx+10]
       vmovups   xmm0,[rbx+28]
       vmovups   [rbp-28],xmm0
       mov       rcx,r8
       lea       r8,[rbp-28]
       mov       rdx,1B992B3BB80
       call      qword ptr [7FFD81C0CC30]
       jmp       short M00_L02
M00_L01:
       mov       rcx,offset MT_System.Exception
       call      CORINFO_HELP_NEWSFAST
       mov       rbx,rax
       mov       ecx,1A5
       mov       rdx,7FFD81A561C0
       call      CORINFO_HELP_STRCNS
       mov       rdx,rax
       mov       rcx,rbx
       call      qword ptr [7FFD8179CC48]
       mov       rcx,rbx
       call      CORINFO_HELP_THROW
       int       3
M00_L02:
       mov       eax,190
       add       rsp,48
       pop       rbx
       pop       rbp
       ret
M00_L03:
       mov       r8,[rcx+10]
       vmovups   xmm0,[rbx+28]
       vmovups   [rbp-28],xmm0
       mov       rcx,r8
       lea       r8,[rbp-28]
       mov       rdx,1B992B3BC50
       call      qword ptr [7FFD81C0CC18]
       nop
M00_L04:
       mov       eax,1AD
       add       rsp,48
       pop       rbx
       pop       rbp
       ret
       push      rbp
       push      rbx
       sub       rsp,28
       vzeroupper
       mov       rbp,[rcx+20]
       mov       [rsp+20],rbp
       lea       rbp,[rbp+50]
       mov       rcx,[rbp+10]
       mov       rcx,[rcx+10]
       mov       r8,1B992B3BBE8
       call      qword ptr [7FFD81C0CA98]
       lea       rax,[M00_L02]
       add       rsp,28
       pop       rbx
       pop       rbp
       ret
       push      rbp
       push      rbx
       sub       rsp,28
       vzeroupper
       mov       rbp,[rcx+20]
       mov       [rsp+20],rbp
       lea       rbp,[rbp+50]
       mov       rcx,[rbp+10]
       mov       rcx,[rcx+10]
       call      qword ptr [7FFD81C0CA80]
       lea       rax,[M00_L04]
       add       rsp,28
       pop       rbx
       pop       rbp
       ret
; Total bytes of code 408
``

Необязательно вчитываться в него. Важно осознать, что он сложный, большой и занимает 408 байт. В 30 строке (mov eax,0C8) — присвоение результата 200 (ОК), а в 34 строке (ret) — выход из метода. Вызовы веток кода, которые обрабатывают ошибки и превышение лимита, компилятор сообразил перенести в конец программы — впрочем, до них он ни разу не добрался.

Сравним методы

В первую очередь видно такое отличие: код обработки невалидного запроса и превышения лимита в изначальном коде был вынужден превратиться в ASM-код сразу, даже если он ни разу не вызывался. В отрефакторенном коде он попросту отсутствует.

Почему это влияет на скорость работы программы, если этот код никогда не исполнялся? Я думаю, всё дело в поставленной компилятору задаче, от сложности которой зависит результат его работы. 

Возьмем случай с изначальным методом. Перед компилятором, у которого ограниченное число регистров, возникла огроменная портянка кода с кучей условий и переменных, которые надо где-то хранить по ходу работы программы в этом методе. В итоге обычных регистров не хватает, и компилятор начинает использовать стек — то есть память — под хранение вспомогательной информации. Кроме того, кода в принципе очень много и становится тяжелее придумывать оптимальный ASM-код. Мы получаем больше инструкций, которые к тому же делают больше обращений к памяти.

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

Но это ещё не всё. И чтобы пойти дальше, надо предпринять кое-какие дополнительные усилия.

Несправедливость бенчмарка

На самом деле получается, что мы провели бенчмарк в едва ли справедливых условиях. В реальном мире всё-таки иногда будут случаться заходы во все ветки. Давайте проведём более честный бенчмарк, в котором оба метода будут в равных условиях: чаще всего они ходят по простым веткам и совсем редко заходят в ветки обработки некорректного запроса и превышения лимита. Так им обоим придётся иметь дело с полным кодом.

Справедливый бенчмарк

Обернём вызовы наших методов в цикл. Представим, что обрабатываем сотню тысяч таких запросов.

И подстроим условия так, чтобы самый последний запрос превысил лимит, а самый средний запрос мы сочли «невалидным». Таким образом, строго один раз зайдём в ветку обработки ошибки и в ветку обработки превышения лимита.

Обцикленный оригинальный код

public int HandleInCycle()
{
    executedQueries = 0;
    totalAttempts = 0;
    var lastResponse = 0;
 
    //Это для того, чтобы гарантированно зайти один раз в ветку про тротлинг
    Limit = N - 2;
 
    for (int i = 0; i < N; i++)
    {
        var request = Request;
 
        var requestValid = true;
        if (request.From > request.To) requestValid = false;
        if (request.From < 0) requestValid = false;
        if (request.To < 0)  requestValid = false;
 
        //Эта проверка для того, чтобы сымитировать редкий "некорректный запрос" строго один раз
        //Так мы заставим программу один раз зайти в ветку кода с обработкой этой ситуации.
        if (i == N / 2) requestValid = false;
 
        if (!requestValid)
        {
            try
            {
                if (request.UserId != Guid.Empty)
                {
                    log.Error("Request from user {UserId} is not valid.", request.UserId);
                }
                else
                {
                    throw new Exception("Unknown user");
                }
            }
            catch (Exception e)
            {
 
                log.Error(e, "Request from unknown user is not valid.");
            }
 
            lastResponse = 400;
            continue;
        }
 
        totalAttempts += 1;
 
        if (executedQueries >= Limit)
        {
            try
            {
                log.Warn("Reached maximum queries limit. Last request was from {UserId}", request.UserId);
            }
            catch (Exception e)
            {
                log.Error(e);
            }
            lastResponse = 429;
            continue;
        }
 
        executedQueries += 1;
        lastResponse = 200;
    }
 
    return lastResponse;
}

Обцикленный отрефакторенный код

public int HandleInCycleRefactored()
{
    totalAttempts = 0;
    executedQueries = 0;
    var lastResponse = 0;
 
    //Это для того, чтобы гарантированно зайти один раз в ветку про тротлинг
    Limit = N - 2;
 
    for (int i = 0; i < N; i++)
    {
        var request = Request;
 
        var requestValid = IsValid(i, request);
 
        if (!requestValid)
        {
            lastResponse = HandleAsInvalidRequest(request);
            continue;
        }
 
        totalAttempts += 1;
 
        if (executedQueries >= Limit)
        {
            lastResponse = HandleTooManyRequests(request);
            continue;
        }
 
        executedQueries += 1;
        lastResponse = 200;
    }
 
    return lastResponse;
}
 
private int HandleAsInvalidRequest(UserRequest request)
{
    try
    {
        if (request.UserId != Guid.Empty)
        {
            log.Error("Request from user {UserId} is not valid.", request.UserId);
        }
        else
        {
            throw new Exception("Unknown user");
        }
    }
    catch (Exception e)
    {
        log.Error(e, "Request from unknown user is not valid.");
    }
    return 400;
}
 
private int HandleTooManyRequests(UserRequest request)
{
    try
    {
        log.Warn("Reached maximum queries limit. Last request was from {UserId}", request.UserId);
    }
    catch (Exception e)
    {
        log.Error(e);
    }
 
    return 429;
}
 
private bool IsValid(int i, UserRequest request)
{
    if (request.From > request.To) return false;
    if (request.From < 0) return false;
    if (request.To < 0)  return false;
 
    //Эта проверка для того, чтобы сымитировать редкий "некорректный запрос" строго один раз
    //Так мы заставим программу один раз зайти в ветку кода с обработкой этой ситуации.
    if (i == N / 2) return false;
 
    return true;
}

Бенчмарк

| Method                  | Mean     | Code Size | Allocated |
|------------------------ |---------:|----------:|----------:|
| HandleInCycle           | 225.7 us |  10,700 B |     640 B |
| HandleInCycleRefactored | 153.5 us |  11,228 B |     640 B |

Очевидно, что два этих метода эквиваленты, что выполняется один и тот же C# код, что в одинаковом порядке проходят все условия. Как видно, мы аллоцировали одинаковое число байт (наши лог-эвенты, по 2 штучки на запуск). У нас теперь примерно одинаковый размер кода: 10 и 11 kb. Отрефакторенный получился даже длиннее, хотя он по-прежнему заметно быстрее.

Разбираемся дальше

Увы, тут снова никуда без ASM-кода. Начнём с кода порефакторенного метода, потому что он сильно проще.

Отрефакторенный метод

Давайте разбирать код на картинке, так будет проще.

Я намеренно не привёл получившийся ASM-код методов HandleTooManyRequests и HandleAsInvalidRequest, расположенный ниже. Потому что они содержатся в отдельных методах и вызываются всего один раз: несущественно на фоне цикла на сотню тысяч итераций в теле основного метода.

Необязательно вчитываться в этот код. Важно осознать, что он простой, короткий, занимает всего 153 байта. В 45 строке и 49 строке — вызовы методов HandleTooManyRequests и HandleAsInvalidRequest. Компилятор сообразил перенести их в конец кода метода. 

Метка M00_L00 — это начало цикла в строке 9. А в строке 35 мы прыгаем на эту метку.

Зелёные и синяя ветки — это переход к вызовам методов HandleTooManyRequests и HandleAsInvalidRequest, оранжевые — это возвращения из них обратно к циклу. Красная ветка — это наш цикл.

Можно заметить, что мы крутимся в цикле, который располагается целиком в строках [9; 35]. То есть 99.9% времени работы наш код крутится в сплошном и очень коротком участке кода:

Зафиксируем это важное соображение про маленький размер и локализованность кода.

Изначальный метод

Давайте разбирать код на картинке, так будет проще. Если слово «проще» вообще применимо к методу на 1180 строк…

Не нужно вчитываться в этот код. Тем более, это лишь его небольшая часть. Код огромный — 1180 строк суммарным размером 5075 байт. И это всё наш один большой изначальный метод.

Я схлопнул «ненужные» строчки и сфокусировался только на нашем цикле. Нарисуем снова красными стрелочками, какие переходы происходят в этом коде, пока мы крутимся в нашем цикле 99.9% времени.

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

То есть в противовес отрефакторенному коду, 99.9% времени работы наш код крутится в НЕсплошном и очень длинном участке кода, перепрыгивая через большое пространство. Два этих куска выделены черным цветом: строчки [9; 35] и [606; 617]

Зафиксируем это важное соображение про большой размер и НЕлокализованность кода.

Проконтролируем, что ASM код «эквивалентен»

Предлагаю рассмотреть вот эту картинку:

Слева — ASM-код изначального метода. Посередине — код отрефакторенного метода. Справа — код на C# (отрефакторенный, чтобы влезал в экран; как мы помним, мы уверены, что он «эквивалентен» изначальному коду, где методы ещё не были выделены).

Я обвел разноцветными областями сегменты цикла в ASM-коде, которые очень сильно похожи. Они буквально практически одинаковы. И соединил их с соответствующим кодом на C#. Можно убедиться, что расхождения в коде крайне несущественные. 

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

Вспоминаем особенности работы CPU и RAM

Напомню, как выглядел бенмарк.

| Method                  | Mean     | Code Size | Allocated |
|------------------------ |---------:|----------:|----------:|
| HandleInCycle           | 225.7 us |  10,700 B |     640 B |
| HandleInCycleRefactored | 153.5 us |  11,228 B |     640 B |

Код чуть-чуть отличается размером, количеством обращений к памяти и количеством сравнений с условными переходами. То есть причина разницы в производительности может быть в branch prediction или в процессорных кешах. 

Проведём бенчмарк с замерами, сколько раз мы некорректно предсказывали ветку кода и сколько раз промахивались в CPU-cache. Это можно собрать с помощью Performance counter-ов (и восхитительный benchmarkdotnet поддерживает их):

| Method                  | Mean     | BranchInstructions | BranchMispredictions | LLCMisses | CacheMisses | Code Size | Allocated |
|------------------------ |---------:|-------------------:|---------------------:|----------:|------------:|----------:|----------:|
| HandleInCycle           | 225.7 us |            703,176 |                  126 |        36 |          35 |  10,700 B |     640 B |
| HandleInCycleRefactored | 153.5 us |            502,577 |                  100 |        31 |          30 |  11,228 B |     640 B |

Видно, что в отрефакторенном коде:

  • В принципе исполняется меньше инструкций, предполагающих ветвление (BranchInstructions). Это следствие более простого кода в методе.

  • Реже делается некорректное предсказание branch prediction (BranchMispredictions). Это, в том числе, следствие предыдущего пункта.

  • Реже случается промах в CPU-кеш (CacheMisses и LLCMisses). Это следствие того, что у нас в целом меньше обращений к памяти (опять же, следствие более простого кода в методе)

Но в теории одной из причин для CacheMisses и LLCMisses может быть кое-что другое.

Вспоминаем, что в современных ЭВМ Code-as-data

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

Если кода много (а его много), он весь в этом кэше не помещается. И иногда нужно обращаться в память, чтобы положить в кэш следующие инструкции.

Вспоминаем про memory prefetch и линейность памяти

Latency обращения к памяти значительно выше, чем время на выполнение одной инструкции. Когда мы запрашиваем какую-то ячейку памяти, обычно мы не только берём её значение, но и кладём в кэш следующий за ней небольшой диапазон ячеек памяти из предположения, что почти наверняка он нам понадобится.

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

А теперь вспомним, как выглядит код цикла в изначальном неотрефакторенном коде:

Первая часть цикла находится по адресам (пусть будет в строчках, не в байтах) [9; 35], а вторая часть цикла находится по адресам [606; 617]. То есть на каждой итерации цикла, который толком ничего не делает, мы два раза прыгаем в разные и далеко удалённые друг от друга участки кода. И вполне вероятно, что иногда мы не попадаем в L1-кэш!

Просто для формального примера допустим, что запрашивая инструкцию из строки 9, в L1-кэш попадают строки [9; 509]. Когда же мы делаем jmp 606, то инструкции 606 в L1-кэше нет, и мы заново загружаем в L1-кэш из памяти строки, (или, скорее всего, из более высокого кеша L2) допустим, [606; 1106]. И если бы весь код цикла был в строках [9; 509], то лишней загрузки данных в L1-кэш, вероятно, не происходило бы. А ведь ровно такой код получился в нашем отрефакторенном методе!

В итоге в отрефакторенном коде весь цикл уместился в один сплошной диапазон ASM-кода, который, скорее всего, всегда спокойно лежит в L1-кэше и никуда оттуда не вытесняется, не перезаписывается.

А если ещё и вспомнить про размер страницы памяти (4kb по умолчанию) и заметить, что наш метод получился размером 5075 байт… Чисто в теории, в случае более крупного метода, мы могли бы попасть в ситуацию, когда кусочки нашего цикла оказались бы в разных страницах памяти. Значит, при необходимости загрузить другой кусочек инструкций в кеш, был бы шанс, что физический адрес этой страницы выпал бы из очень маленького кеша отображения виртуального адреса в физический, который снова пришлось бы вычислять…

Важный дисклеймер

Я не могу утверждать, что в нашем случае именно наличие далеко удалённых в коде частей основного цикла (выполнение инструкций которого составляет 99.9% времени работы программы) повлияло на повышенное количество CacheMisses. На это количество могли повлиять и другие обращения к памяти. Но моей целью было продемонстрировать теоретическую возможность такого обстоятельства.

И кстати, это было чертовски сложно. Потому что:

Компиляторы (+JIT, +PGO) дьявольски умны

Как бы я не старался, какой бы сложный код (и при этом похожий на настоящий) я не писал, компилятор умудрялся расположить тело цикла в один очень короткий и сплошной диапазон инструкций. Ему всегда удавалось унести наши сложные ветки куда-то вне цикла.

Теперь становится понятно, почему компилятор так усердно старался расположить ветки с вызовами методов HandleTooManyRequests и HandleAsInvalidRequest (или блоки кода в неотрефакторенном варианте, отвечающим за эту логику) далеко от кода основного цикла. Зная, что они и так огромные и дорогие куски кода, который не жалко выделить куда-нибудь отдельно, и зная, что вполне вероятно мы будем часто крутиться в цикле, не заходя в эти ветки — он старался локализовать рядом в коде те инструкции, которые наиболее вероятно будут выполняться последовательно.

Теперь пора признаться, откуда в коде такие нелепые try catch. Без них компилятор всегда уделывал меня. Только с их помощью удалось его обмануть и в коде на 1181 ASM-строку разбить тело основного цикла на два хоть немного удалённых друг от друга сегмента инструкций (строки [9; 35] и [606; 617]). Видимо, те совершенно нелепые try catch как-то сильно запутали его. 

Выводы

Пишите красивый и читаемый код! Тогда и программы будут работать эффективнее. 

Делайте простые и понятные методы, которые помещаются в экран. А если вам самим просто и понятно, как работает метод, если вы способны легко загрузить его к себе в оперативную память и «обработать», то и компилятор с этим прекрасно справится.

Чем сложнее код, чем длиннее код метода, тем больше вероятность, что компилятор не справится. На одного инженера в среднем приходится 1 мозг, 2 глаза и 10 пальцев рук, с помощью которых он может работать. А у компилятора в среднем всего пара десятков регистров. Если ему их не хватит, чтобы запрограммировать ваш метод, он начнёт работать с памятью. Если он не разберётся в вашем коде, то наворотит лишних условий, лишних инструкций, расположит самые частотные последовательности инструкции в несплошные диапазоны, и программа будет работать плохо.

Изучайте, как работают современные ЭВМ со времён фон Неймана. Это весело и увлекательно.

Комментарии (2)


  1. Vinegar
    11.06.2025 19:47

    А разве выводом будет не "доверьтесь компилятору и если не совершите совсем фатальную ошибку - все будет хорошо"?


    1. deniaa Автор
      11.06.2025 19:47

      Такой вывод, конечно же, имеет место быть.

      Но я предпочел вывод с хоть каким-то призывом к «действиям» или хотя бы «осознанности», чтобы не складывалось впечатления, что можно совершенно расслабиться.

      Строго говоря, ни тот ни другой вывод не будут корректны на 100%. Можно так постараться с красивым кодом, что станет только хуже (чаще, неверное, так и происходит). И можно положиться на силу компилятора там, где на самом деле он тебе не поможет.